工程文章

在云端 Mac CI 中建立 iOS 文件保护等级回归门禁

在云端 Mac CI 中建立 iOS 文件保护等级回归门禁

远程构建节点上的测试全部通过,并不代表应用写入磁盘的数据已经受到预期保护。最常见的偏差不是开发者忘记调用加密接口,而是文件经过原子替换、数据库迁移或缓存重建后,原先设置的保护等级没有落到最终文件。把检查放进云端 Mac 的持续集成流程,可以让每次合并都验证实际文件属性,而不是只审查某一行写入代码。

先定义哪些数据需要保护

iOS 文件保护不是越严格越好。应用应先按“锁定后是否仍需读取”分类,再决定保护等级。令牌、离线业务数据和用户导出的私密内容通常应在设备锁定后不可访问;后台传输队列、必要的推送状态或锁定期间必须继续工作的任务,则需要单独评估。

数据类型 建议基线 检查重点
会话令牌导出文件 完全保护 锁定后不应读取
用户私密文档 完全保护 创建、替换、恢复后属性一致
后台任务状态 首次解锁后可用 确认业务确实需要锁定期访问
可再生成缓存 按风险决定 不得混入令牌或个人数据
SQLite 数据库 与业务数据一致 同时检查 WAL 与 SHM 文件

不要把整个 Documents 或 Library 目录视为同一种数据。门禁最好维护一份明确清单,记录相对路径、预期等级、产生该文件的操作,以及允许例外的原因。

检查对象应是测试运行后真正存在的文件。只搜索 .completeFileProtection 字样会漏掉数据库旁路文件、替换后的新 inode 和第三方组件创建的数据。

用 XCTest 读取最终文件属性

测试应先通过正式业务入口写入数据,再从最终 URL 读取 fileProtection。下面的辅助方法既能报告缺失属性,也会把实际值写进失败信息,便于从 CI 日志直接定位。

import XCTest

final class FileProtectionTests: XCTestCase {
    private func assertProtection(
        _ url: URL,
        equals expected: URLFileProtection,
        file: StaticString = #filePath,
        line: UInt = #line
    ) throws {
        XCTAssertTrue(
            FileManager.default.fileExists(atPath: url.path),
            "Expected file does not exist: \(url.path)",
            file: file,
            line: line
        )

        let values = try url.resourceValues(forKeys: [.fileProtectionKey])
        XCTAssertEqual(
            values.fileProtection,
            expected,
            "Unexpected protection at \(url.path): \(String(describing: values.fileProtection))",
            file: file,
            line: line
        )
    }

    func testSensitiveExportUsesCompleteProtection() throws {
        let root = FileManager.default.temporaryDirectory
        let url = root.appendingPathComponent("private-export.json")
        let payload = Data(#"{"status":"ready"}"#.utf8)

        try payload.write(to: url, options: [.atomic, .completeFileProtection])
        try assertProtection(url, equals: .complete)
    }
}

测试数据不要包含真实凭据。路径也应使用测试容器动态生成,避免依赖某台节点的用户名或固定工作目录。若产品支持“覆盖保存”,测试必须连续写入两次,因为第二次写入通常会走临时文件加重命名的路径。

为例外建立显式清单

确需使用 .completeUntilFirstUserAuthentication 的文件,应在测试中逐项点名,不能允许“不是完全保护就算通过”。例外清单需要同时写明业务场景和负责人;当功能不再需要锁定期间访问时,删除例外并提高等级。

别漏掉 SQLite 旁路文件

SQLite 在 WAL 模式下可能创建 database.sqlite-waldatabase.sqlite-shm。只检查主文件会得到虚假的安全结论。测试应先执行一次真实写事务,确认旁路文件已经出现,再对三者应用同一断言。

如果使用 Core Data,测试应通过持久化容器完成插入和保存,而不是手工创建空数据库。某些旁路文件会在连接关闭后消失,因此检查时机应放在写事务完成之后、存储关闭之前。建议记录以下内容:

  1. 主数据库、WAL、SHM 的绝对路径;
  2. 每个文件的实际保护等级;
  3. 触发文件创建的测试步骤;
  4. 文件不存在时是预期清理,还是测试没有真正执行写入。

对于下载后导入数据库的流程,还要覆盖“下载临时文件—校验—移动到正式目录”整条链路。目标目录有正确属性,并不能自动保证移动后的文件继承同一策略。

在云端 Mac 上接入构建门禁

门禁可以作为独立测试方案运行,避免每次执行全部 UI 测试。先固定项目、Scheme、模拟器设备和结果包路径,再让 xcodebuild 的退出码决定流水线是否继续。

set -euo pipefail

RESULT_DIR="${PWD}/artifacts"
RESULT_BUNDLE="${RESULT_DIR}/FileProtection.xcresult"

rm -rf "${RESULT_BUNDLE}"
mkdir -p "${RESULT_DIR}"

xcodebuild test \
  -project App.xcodeproj \
  -scheme SecurityRegression \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -resultBundlePath "${RESULT_BUNDLE}" \
  -only-testing:AppSecurityTests/FileProtectionTests

设备名称应以节点上已安装的运行时为准,不要假定所有环境完全相同。执行前可用 xcrun simctl list devices available 验证目标;正式流水线则应把允许的设备与系统版本写进配置,目标不存在时直接失败,而不是静默改用另一套环境。

失败时至少保留 .xcresult、测试标准输出和保护等级清单。不要归档测试容器中的敏感样本,也不要为了排查方便输出文件正文。证据应回答“哪个路径、期望什么、实际是什么、由哪个测试步骤创建”,而不是复制业务数据。

处理模拟器边界与常见误判

模拟器适合验证代码是否把保护属性写到最终文件,但它不能完整复现物理设备锁定后的密钥可用状态。因此这道 CI 门禁负责发现属性回归,锁定、重启和后台唤醒时的真实访问行为仍需在受控设备流程中验证。

另一个误判来自清理顺序。若测试先关闭数据库或删除临时目录,再检查旁路文件,结果会显示“文件不存在”,却无法区分安全清理与测试失效。应把断言放在生命周期明确的位置,并让不存在成为带路径的失败。

最后检查四类变更:写入 API 是否更换、文件是否经过原子替换、数据库模式是否改变、第三方组件是否新增持久化路径。只要其中一项发生变化,就应重新审视保护清单。这样建立的门禁不依赖人工记忆,也不会把“曾经设置正确”误当成“当前落盘仍然正确”。

常见问题

只检查文件写入代码中的保护选项够吗?

不够。原子替换、数据库初始化、迁移和第三方组件都可能重新创建文件,应在测试运行后读取最终文件的实际保护等级。

模拟器上的检查能完全替代真机验证吗?

不能。模拟器适合检查属性是否按预期设置,锁屏后的可访问行为仍应在受控真机流程中单独验证。

VMMini M4

在独享物理节点上运行下一项任务

选择节点与计费周期,使用固定的 M4、16GB RAM 和 256GB SSD 配置开始部署。实际可用性以控制台实时返回为准。

立即租用云端 Mac