field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

TeraLocalRestoreTests.swift (8876B)


      1 import CryptoKit
      2 import Darwin
      3 import Foundation
      4 import RadrootsKit
      5 @testable import TeraApp
      6 import TeraKitBindings
      7 import XCTest
      8 
      9 final class TeraLocalRestoreTests: XCTestCase {
     10   func testActualColdRestoreAndGuardedReopenRequireExplicitResume() async throws {
     11     let fixture = try RestoreFileFixture()
     12     defer { fixture.remove() }
     13     let backup = try TeraLocalBackupHost(roots: fixture.roots, publicKey: fixture.backup.publicKey,
     14                                          generation: fixture.backup.sourceGeneration, protectedData: { true })
     15     try await backup.prepare(request: fixture.backup)
     16     let initial = try await fixture.runtime()
     17     _ = try await backup.capture(runtime: initial, request: fixture.backup)
     18     _ = try await initial.shutdown()
     19     let host = try fixture.host()
     20     do {
     21       _ = try await host.restore(store: fixture.store, request: fixture.request)
     22       XCTFail("A live process marker must block even after runtime shutdown")
     23     } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, "restore_busy") }
     24     // Simulate a fresh process in this test fixture only, after explicit close.
     25     // Production never removes a live PID marker to force recovery admission.
     26     try RadrootsAppleFileAccess(roots: fixture.roots).delete(RadrootsFileReference(
     27       scope: .data, relativePath: "\(TeraMediaProcessUse.directory)/\(getpid())"
     28     ))
     29     let guardRecord = try await host.restore(store: fixture.store, request: fixture.request)
     30     XCTAssertEqual(try TeraRestoreFiles(roots: fixture.roots, publicKey: fixture.backup.publicKey).readGuard(), guardRecord.bytes)
     31     do { _ = try await fixture.runtime(); XCTFail("Ordinary startup must refuse the guard") } catch {}
     32     let startup = try await TeraGeneratedRuntimeBackend.start(configuration: fixture.configuration, localBackups: true)
     33     let settings = try await startup.backend.mobileSettings()
     34     XCTAssertEqual(settings.revision, 1, "Guarded startup must not adopt conflicting launch settings")
     35     XCTAssertTrue(settings.identity.identities.isEmpty)
     36     let held = try await startup.backend.restoreStatus()
     37     XCTAssertEqual(held?.phase, .held)
     38     _ = try await startup.backend.shutdown()
     39     let restored = try await TeraRuntime.withHostSignerAndRestoreGuard(
     40       store: fixture.store, hostSigner: TeraGeneratedHostSigner(signer: TestRuntimeSigner()), guard: guardRecord.bytes, localBackups: true
     41     )
     42     let status = try await restored.applicationRestoreStatus()
     43     XCTAssertEqual(status?.phase, .held)
     44     XCTAssertEqual(status?.attemptId, fixture.request.attemptId)
     45     do {
     46       try await restored.resumeRestoredWork(reviewedInventory: String(repeating: "00", count: 32))
     47       XCTFail("Restore alone cannot authorize work")
     48     } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, "restore_reconciliation_required") }
     49     let digest = try await restored.reviewRestoredWork()
     50     try await restored.resumeRestoredWork(reviewedInventory: digest)
     51     let resumed = try await restored.applicationRestoreStatus()
     52     XCTAssertEqual(resumed?.phase, .resumed)
     53     _ = try await restored.shutdown()
     54   }
     55 
     56   func testUnknownOrActiveNativeTasksAndProtectedDataRefuseBeforeGuard() async throws {
     57     let fixture = try RestoreFileFixture()
     58     defer { fixture.remove() }
     59     for active in [false, true] {
     60       let host = try TeraLocalRestoreHost(roots: fixture.roots, activeTransfers: {
     61         if active {
     62           return try [RadrootsBackgroundTransferIdentifier("test.active")]
     63         }
     64         throw RadrootsBackgroundTransferError.unavailable
     65       }, protectedData: { true })
     66       do {
     67         _ = try await host.restore(store: fixture.store, request: fixture.request)
     68         XCTFail("Unproven native inactivity cannot restore")
     69       } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, active ? "restore_busy" : "restore_recovery_required") }
     70       XCTAssertNil(try TeraRestoreFiles(roots: fixture.roots, publicKey: fixture.backup.publicKey).readGuard())
     71     }
     72     let locked = try TeraLocalRestoreHost(roots: fixture.roots, activeTransfers: { [] }, protectedData: { false })
     73     do { _ = try await locked.restore(store: fixture.store, request: fixture.request); XCTFail("Protected data must be available") } catch {}
     74     XCTAssertNil(try TeraRestoreFiles(roots: fixture.roots, publicKey: fixture.backup.publicKey).readGuard())
     75   }
     76 
     77   func testImmutableLeaseRestoresMissingMediaAndRefusesTamperedBytes() throws {
     78     let fixture = try RestoreFileFixture()
     79     defer { fixture.remove() }
     80     let roots = try TeraDurableMediaRoots.selectingStaging(in: fixture.roots)
     81     let bytes = Data("restore exact media".utf8)
     82     let hash = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined()
     83     let item = FfiBackupMedia(sha256: hash, byteLength: UInt64(bytes.count),
     84                               leaseIdentifier: "tera_backup_\(fixture.backup.backupId)_\(hash)")
     85     let blob = try RadrootsStagedBlobReference(blobID: hash, sizeBytes: bytes.count)
     86     let access = RadrootsAppleFileAccess(roots: roots)
     87     try access.installStagedBlob(bytes, reference: blob)
     88     let backup = TeraBackupFiles(roots: roots, publicKey: fixture.backup.publicKey)
     89     let lease = try backup.mediaAccess().leaseStagedBlob(blob, expectedSHA256: hash, identifier: item.leaseIdentifier)
     90     try access.releaseStagedBlob(blob)
     91     let files = TeraRestoreFiles(roots: roots, publicKey: fixture.backup.publicKey)
     92     let manifest = FfiBackupManifest(request: fixture.backup, media: [item], manifest: Data())
     93     try files.restoreMedia(manifest)
     94     XCTAssertEqual(try access.readStagedBlob(blob), bytes)
     95     try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: lease.fileURL.path)
     96     try Data(repeating: 0, count: bytes.count).write(to: lease.fileURL)
     97     XCTAssertThrowsError(try files.restoreMedia(manifest))
     98     XCTAssertEqual(try access.readStagedBlob(blob), bytes)
     99   }
    100 }
    101 
    102 private struct RestoreFileFixture {
    103   let root: URL
    104   let roots: RadrootsAppleFileRoots
    105   let backup = FfiBackupRequest(schemaVersion: 1, backupId: String(repeating: "01", count: 16),
    106                                 publicKey: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
    107                                 sourceGeneration: String(repeating: "07", count: 32), requestedAtUnixMs: 200, maximumBytes: 128 * 1024 * 1024)
    108   init() throws {
    109     root = FileManager.default.temporaryDirectory.resolvingSymlinksInPath().appendingPathComponent("tera-restore-\(UUID().uuidString)")
    110     try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false)
    111     roots = try RadrootsAppleFileRoots(appIdentifier: "test.tera.restore", dataRoot: root.appendingPathComponent("data"),
    112                                        cacheRoot: root.appendingPathComponent("cache"), temporaryRoot: root.appendingPathComponent("temporary"))
    113     _ = try RadrootsAppleMobileStore.prepare(roots: roots, publicKeyHex: backup.publicKey, protectedDataAvailability: .available)
    114     try TeraBackupFiles(roots: roots, publicKey: backup.publicKey).prepare()
    115   }
    116 
    117   var store: FfiRestoreStore {
    118     FfiRestoreStore(applicationSupportDirectory: roots.dataRoot.path, publicKey: backup.publicKey,
    119                     sourceGeneration: backup.sourceGeneration, sourceGenerationCreatedAtMs: 100, protectedData: .available)
    120   }
    121 
    122   var request: FfiRestoreRequest {
    123     FfiRestoreRequest(attemptId: String(repeating: "02", count: 16), backup: backup, requestedAtMs: 300)
    124   }
    125 
    126   var configuration: TeraRuntimeLaunchConfiguration {
    127     TeraRuntimeLaunchConfiguration(
    128       applicationSupportDirectory: roots.dataRoot.path, publicKeyHex: backup.publicKey,
    129       sourceGenerationHex: backup.sourceGeneration, sourceGenerationCreatedAtUnixMilliseconds: 100,
    130       protectedData: .available, networkProfile: .simulator, writableRelays: ["ws://127.0.0.1:19999"], blossom: nil,
    131       app: TeraRuntimeAppMetadata(bundleIdentifier: "test.restore", version: "1", buildNumber: "1", buildSHA: nil),
    132       signerGeneration: "restore", signer: TestRuntimeSigner(), adoptBootstrapSettings: true
    133     )
    134   }
    135 
    136   func host() throws -> TeraLocalRestoreHost {
    137     try TeraLocalRestoreHost(roots: roots, activeTransfers: { [] }, protectedData: { true })
    138   }
    139 
    140   func runtime() async throws -> TeraRuntime {
    141     try await TeraRuntime.withHostSignerAndLocalBackups(applicationSupportDirectory: roots.dataRoot.path,
    142                                                         publicKeyHex: backup.publicKey, sourceGenerationHex: backup.sourceGeneration,
    143                                                         sourceGenerationCreatedAtUnixMs: 100, protectedData: .available,
    144                                                         hostSigner: TeraGeneratedHostSigner(signer: TestRuntimeSigner()))
    145   }
    146 
    147   func remove() {
    148     try? FileManager.default.removeItem(at: root)
    149   }
    150 }