Passing every test on a remote build node does not mean the data an app writes to disk has the intended protection. The most common discrepancy is not a developer forgetting to call an encryption API. Instead, after an atomic replacement, database migration, or cache rebuild, the original protection class may not be applied to the final file. Adding this check to the continuous integration workflow on a cloud Mac ensures that every merge verifies actual file attributes rather than merely reviewing a particular line of write code.
First define which data needs protection
Stricter is not always better for iOS file protection. First classify data by whether it must remain readable after the device is locked, and then select the appropriate protection class. Tokens, offline business data, and private content exported by users should generally be inaccessible while the device is locked. Background transfer queues, essential push state, and tasks that must continue running while locked require separate evaluation.
| Data type | Recommended baseline | What to verify |
|---|---|---|
| Session token export file | Complete protection | Must not be readable after locking |
| Private user documents | Complete protection | Attributes remain consistent after creation, replacement, and restoration |
| Background task state | Available after first unlock | Confirm that access while locked is genuinely required |
| Regenerable cache | Based on risk | Must not contain tokens or personal data |
| SQLite database | Match the business data | Check WAL and SHM files as well |
Do not treat the entire Documents or Library directory as a single type of data. The gate should maintain an explicit inventory containing each relative path, its expected protection class, the operation that creates the file, and the reason for any permitted exception.
Checks must target the files that actually exist after the test run. Searching only for
.completeFileProtectionwill miss database sidecar files, new inodes created by replacement, and data created by third-party components.
Read the final file attributes with XCTest
The test should first write data through the production application flow and then read fileProtection from the final URL. The helper below reports a missing attribute and includes the actual value in the failure message, making the issue easy to locate directly from CI logs.
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)
}
}
Test data must not contain real credentials. Paths should also be generated dynamically within the test container instead of depending on a node-specific username or fixed working directory. If the product supports overwriting an existing file, the test must write twice in succession because the second write will often use a temporary file followed by a rename.
Maintain an explicit allowlist for exceptions
Files that genuinely require .completeUntilFirstUserAuthentication should be named individually in the tests. Do not accept any protection class merely because it is not complete protection. The exception list must document both the business scenario and the responsible owner. When a feature no longer needs access while the device is locked, remove the exception and raise the protection class.
Do not overlook SQLite sidecar files
In WAL mode, SQLite may create database.sqlite-wal and database.sqlite-shm. Checking only the main file produces a false security conclusion. The test should first perform a real write transaction, confirm that the sidecar files have appeared, and then apply the same assertion to all three files.
When using Core Data, the test should insert and save through the persistent container rather than manually creating an empty database. Some sidecar files disappear after the connection is closed, so checks should run after the write transaction completes but before the store is closed. Record the following:
- The absolute paths of the main database, WAL, and SHM files;
- The actual protection class of each file;
- The test step that caused each file to be created;
- Whether a missing file indicates expected cleanup or that the test never performed a real write.
For workflows that import a database after downloading it, cover the entire chain: download to a temporary file, validate it, and move it into the final directory. Correct attributes on the destination directory do not automatically guarantee that the moved file inherits the same policy.
Add the build gate on a cloud Mac
The gate can run as a separate test plan so that the full UI test suite does not need to execute every time. Pin the project, Scheme, simulator device, and result bundle path, then use the xcodebuild exit code to determine whether the pipeline may continue.
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
Choose the device name according to the runtimes installed on the node; do not assume that every environment is identical. Before execution, verify the target with xcrun simctl list devices available. In the production pipeline, define the permitted devices and OS versions in configuration, and fail immediately if the target is unavailable rather than silently switching to a different environment.
On failure, retain at least the .xcresult, test standard output, and protection-class inventory. Do not archive sensitive samples from the test container or print file contents merely to simplify troubleshooting. The evidence should answer “which path, what was expected, what was observed, and which test step created it” without copying business data.
Account for simulator limits and common false results
The simulator is suitable for verifying that code applies protection attributes to the final files, but it cannot fully reproduce key availability after a physical device is locked. This CI gate therefore detects attribute regressions, while actual access behavior during locking, restarting, and background wake-up must still be validated through a controlled device workflow.
Another false result comes from cleanup order. If a test closes the database or deletes the temporary directory before checking sidecar files, the result will say that the file does not exist without distinguishing secure cleanup from a test that failed to exercise the intended path. Place assertions at clearly defined lifecycle points and treat a missing file as a failure that includes its path.
Finally, review four categories of change: whether the write API changed, whether files undergo atomic replacement, whether the database mode changed, and whether a third-party component introduced a new persistence path. If any one of these changes, revisit the protection inventory. A gate built this way does not depend on human memory and does not mistake “it was once configured correctly” for “the current on-disk file is still protected correctly.”
Frequently asked questions
Is checking file protection options in source code enough?
No. Atomic replacement, migrations, and libraries can recreate files, so CI should inspect the protection class applied to each final file.
Can simulator checks replace device validation?
No. The simulator is useful for verifying attributes, while access behavior after device locking still requires a separate controlled device test.
Run Your Next Task on a Dedicated Physical Node
Choose a node and billing cycle, then start deploying with the fixed M4, 16GB RAM, and 256GB SSD configuration. Actual availability is based on the console's real-time status.