雲端 Mac 上的 iOS 安全封存解碼驗收
讓新版 App 讀取舊版留下的封存檔,最危險的結果不是報錯,而是傳回空資料後繼續儲存,覆寫原始檔案。把這條流程放到雲端 Mac 上驗收:先執行只依賴 Foundation 的解碼測試,再讓 iOS 測試目標讀取相同樣本。重點不是證明一次編碼與解碼往返成功,而是確認錯誤型別、未知版本與損毀輸入都會被明確拒絕。
先定義讀取邊界
本文處理 NSKeyedArchiver 產生的物件封存,不涉及加密。範例只允許一個根物件,包含版本號與文字欄位。驗收前先確立三項契約:
- 根物件必須是
Memo,不能先接受任意物件,再進行強制型別轉換。 - 版本必須等於目前支援的值,文字必須存在,且長度須受限制。
- 失敗時不得寫回原始檔案,也不得將空白記錄偽裝成成功結果。
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,非虛擬機器。可選擇日租、週租、月租或季租;實際配置與可用狀態請以控制台資訊為準。