雲端 Mac 上的 iOS 安全封存解碼驗收

雲端 Mac 上的 iOS 安全封存解碼驗收

雲端 Mac 上的 iOS 安全封存解碼驗收

讓新版 App 讀取舊版留下的封存檔,最危險的結果不是報錯,而是傳回空資料後繼續儲存,覆寫原始檔案。把這條流程放到雲端 Mac 上驗收:先執行只依賴 Foundation 的解碼測試,再讓 iOS 測試目標讀取相同樣本。重點不是證明一次編碼與解碼往返成功,而是確認錯誤型別、未知版本與損毀輸入都會被明確拒絕。

先定義讀取邊界

本文處理 NSKeyedArchiver 產生的物件封存,不涉及加密。範例只允許一個根物件,包含版本號與文字欄位。驗收前先確立三項契約:

supportsSecureCoding 是型別層級的宣告,不會自動驗證業務欄位。讀取端仍須啟用 requiresSecureCoding,每個物件欄位也都要使用具備型別限制的方法解碼。

安全解碼限制的是可實例化的物件型別,不提供來源驗證、機密性保障或檔案大小配額。應先限制輸入大小,再交給解碼器處理。

撰寫能拒絕錯誤輸入的解碼器

將以下內容儲存為 archive-check.swift。它只依賴 Foundation;請先確認 xcrun swift --version 能正常執行。範例中的版本號是封存協定版本,不是系統版本。

import Foundation

final class Memo: NSObject, NSSecureCoding {
    static var supportsSecureCoding: Bool { true }
    let schema: Int
    let text: String

    init(schema: Int = 1, text: String) {
        self.schema = schema
        self.text = text
        super.init()
    }

    required init?(coder: NSCoder) {
        guard coder.containsValue(forKey: "schema"),
              coder.decodeInteger(forKey: "schema") == 1,
              let value = coder.decodeObject(
                  of: NSString.self, forKey: "text"
              ),
              value.length <= 4096 else {
            return nil
        }
        schema = 1
        text = value as String
        super.init()
    }

    func encode(with coder: NSCoder) {
        coder.encode(schema, forKey: "schema")
        coder.encode(text as NSString, forKey: "text")
    }
}

enum ArchiveFailure: Error {
    case invalidSize
    case invalidObject
}

func readMemo(_ data: Data) throws -> Memo {
    guard !data.isEmpty, data.count <= 1_048_576 else {
        throw ArchiveFailure.invalidSize
    }
    let reader = try NSKeyedUnarchiver(forReadingFrom: data)
    reader.requiresSecureCoding = true
    reader.decodingFailurePolicy = .setErrorAndReturn
    let memo = reader.decodeObject(
        of: Memo.self, forKey: NSKeyedArchiveRootObjectKey
    )
    reader.finishDecoding()
    if let error = reader.error {
        throw error
    }
    guard let memo else {
        throw ArchiveFailure.invalidObject
    }
    return memo
}

func pack(_ value: Any) throws -> Data {
    try NSKeyedArchiver.archivedData(
        withRootObject: value, requiringSecureCoding: true
    )
}

這裡先檢查版本欄位是否存在,避免整數解碼的預設值掩蓋缺少索引鍵的情況。文字長度上限以 UTF-16 單元計算,與 NSString.length 一致。正式實作應依產品協定選擇上限,不要將範例門檻直接視為通用安全標準。

setErrorAndReturn 必須在讀取物件前設定。不要以為外層的 do/catch 能攔截所有解碼失敗:部分無效物件的處理取決於解碼失敗策略,並不走一般 Swift 擲出錯誤的路徑。

用四種輸入建立最小回歸測試集

接著加入以下程式碼。正常樣本必須讀回原文,另外三個樣本則必須失敗。只要出現不符合預期的結果,驗證程式就會以非零結束狀態退出。

func expectRejection(_ data: Data) {
    do {
        _ = try readMemo(data)
    } catch {
        return
    }
    fatalError("Unexpected archive acceptance")
}

let valid = try pack(Memo(text: "draft"))
let restored = try readMemo(valid)
guard restored.text == "draft" else {
    fatalError("Round-trip mismatch")
}

expectRejection(try pack("wrong root" as NSString))
expectRejection(try pack(Memo(schema: 2, text: "future")))
expectRejection(Data(valid.prefix(12)))

執行時先編譯再執行,避免直譯執行環境影響後續樣本的型別名稱:

set -eu
xcrun swiftc archive-check.swift -o archive-check
./archive-check

錯誤樣本可能讓 Foundation 將診斷訊息寫入標準錯誤輸出,但這不代表驗收程式失敗。判斷依據是程序的結束狀態與斷言,而不是記錄是否完全沒有訊息。如果錯誤的根物件被成功接受,應先修正允許型別清單,不能靠刪除反向案例讓測試通過。

擴充至巢狀物件與歷史樣本

針對欄位限制允許的型別

實際封存若包含陣列,不能只允許 NSArray 就認為其中的元素安全。請使用 decodeObject(of:forKey:) 接受類別集合的多載版本,將容器與預期的元素類別一併列入允許集合,再檢查數量、欄位長度及關聯關係。不要為了繞過失敗而加入過於寬泛的 NSObject。

封存的型別限制不能取代物件圖複雜度控制。限制輸入大小,也無法保證解碼後的記憶體用量;對於不受信任的輸入,還應採用限制更嚴格的協定,或設立獨立的處理邊界。

保留舊版本的實際檔案作為固定樣本

將已去識別化的舊版封存檔納入測試資源並提交,記錄產生檔案的版本、預期欄位與允許的遷移結果。動態產生的樣本適合驗證拒絕邏輯,卻無法證明舊版實際寫出的位元組仍可讀取。

Swift 類別的執行階段名稱可能隨模組改變。移動型別或變更模組名稱時,應先驗證歷史樣本,再依明確的舊名稱設定類別對應;禁止從輸入讀取任意類別名稱後自動放行。命令列測試通過後,仍須在 iOS 測試目標中執行同一組檔案,以涵蓋目標執行環境的差異。

讓失敗停在讀取層,不擴散到儲存層

將解碼結果表達為成功或明確錯誤,避免使用 try? 後再以空物件替代。讀取失敗時應保留原始檔案,並在介面提供復原途徑;只有在遷移完成,且新資料通過重新讀取驗證後,才允許取代舊檔案。

合併前檢查五項:正常樣本的欄位一致;錯誤根型別被拒絕;未知版本被拒絕;截斷輸入不會觸發成功回寫;歷史樣本在 iOS 目標上通過。將每個反向案例拆成獨立測試,失敗時才能直接定位是哪一項契約失守。

雲端 Mac 上的命令列程式負責快速回饋,iOS 測試負責確認目標平台的行為。兩層都通過,才表示封存讀取流程具備可重複的驗收證據,而不只是開發機上的一次成功。

常見問題

實作 supportsSecureCoding 就足夠了嗎?

不夠。讀取時仍須啟用 requiresSecureCoding,並使用限制型別的 decodeObject 方法;欄位條件與封存版本也要另外驗證。

解碼失敗後可以直接回傳空陣列嗎?

不要把失敗視為成功的空結果。應回傳明確錯誤並保留原檔,再由上層決定復原或遷移,避免後續儲存覆寫原始封存。

為下一次建置租用一台雲端 Mac

獨享實體 Mac mini,非虛擬機器。可選擇日租、週租、月租或季租;實際配置與可用狀態請以控制台資訊為準。