远程构建节点上的测试全部通过,并不代表应用写入磁盘的数据已经受到预期保护。最常见的偏差不是开发者忘记调用加密接口,而是文件经过原子替换、数据库迁移或缓存重建后,原先设置的保护等级没有落到最终文件。把检查放进云端 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-wal 与 database.sqlite-shm。只检查主文件会得到虚假的安全结论。测试应先执行一次真实写事务,确认旁路文件已经出现,再对三者应用同一断言。
如果使用 Core Data,测试应通过持久化容器完成插入和保存,而不是手工创建空数据库。某些旁路文件会在连接关闭后消失,因此检查时机应放在写事务完成之后、存储关闭之前。建议记录以下内容:
- 主数据库、WAL、SHM 的绝对路径;
- 每个文件的实际保护等级;
- 触发文件创建的测试步骤;
- 文件不存在时是预期清理,还是测试没有真正执行写入。
对于下载后导入数据库的流程,还要覆盖“下载临时文件—校验—移动到正式目录”整条链路。目标目录有正确属性,并不能自动保证移动后的文件继承同一策略。
在云端 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 是否更换、文件是否经过原子替换、数据库模式是否改变、第三方组件是否新增持久化路径。只要其中一项发生变化,就应重新审视保护清单。这样建立的门禁不依赖人工记忆,也不会把“曾经设置正确”误当成“当前落盘仍然正确”。
常见问题
只检查文件写入代码中的保护选项够吗?
不够。原子替换、数据库初始化、迁移和第三方组件都可能重新创建文件,应在测试运行后读取最终文件的实际保护等级。
模拟器上的检查能完全替代真机验证吗?
不能。模拟器适合检查属性是否按预期设置,锁屏后的可访问行为仍应在受控真机流程中单独验证。
在独享物理节点上运行下一项任务
选择节点与计费周期,使用固定的 M4、16GB RAM 和 256GB SSD 配置开始部署。实际可用性以控制台实时返回为准。