云端 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,非虚拟机。按天、周、月、季选择租期,配置与实际可用状态以控制台返回为准。