工程文章

在雲端 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 是否更換、檔案是否經過原子替換、資料庫模式是否改變,以及第三方元件是否新增持久化路徑。只要其中任何一項發生變化,就應重新檢視保護清單。這樣建立的門禁不依賴人工記憶,也不會把「過去曾正確設定」誤認為「目前落盤後仍然正確」。

常見問題

只檢查程式碼中的檔案保護選項就足夠嗎?

不足夠。原子替換、資料遷移與第三方元件可能重新建立檔案,CI 應讀取最終檔案實際套用的保護等級。

模擬器檢查可以完全取代實機驗證嗎?

不可以。模擬器適合確認屬性是否設定,裝置鎖定後的存取行為仍應在受控實機流程中另外驗證。

VMMini M4

在專屬實體節點上執行下一項任務

選擇節點與計費週期,使用固定的 M4、16GB RAM 和 256GB SSD 設定開始部署。實際可用性以控制台即時回傳為準。

立即租用雲端 Mac