field_ios

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

TeraLocalBackupTests.swift (9451B)


      1 import CryptoKit
      2 import Foundation
      3 import RadrootsKit
      4 @testable import TeraApp
      5 import TeraKitBindings
      6 import XCTest
      7 
      8 final class TeraLocalBackupTests: XCTestCase {
      9   func testForeignLeaseBindingRefusesBeforeCreatingBackupFiles() async throws {
     10     let fixture = try BackupFileFixture()
     11     defer { fixture.remove() }
     12     let host = try fixture.host()
     13     let foreign = FfiBackupMedia(sha256: String(repeating: "a", count: 64), byteLength: 20,
     14                                  leaseIdentifier: "another_backup")
     15     do {
     16       _ = try await host.retainMedia(request: fixture.request, media: [foreign])
     17       XCTFail("Canonical binding must be checked before native file effects")
     18     } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, "backup_media_unavailable") }
     19     XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.roots.dataRoot.appendingPathComponent("backups").path))
     20   }
     21 
     22   func testActualOwnerCaptureAndNativePublicationAreStableAcrossRetry() async throws {
     23     let fixture = try BackupFileFixture()
     24     defer { fixture.remove() }
     25     let host = try fixture.host()
     26     try await host.prepare(request: fixture.request)
     27     _ = try RadrootsAppleMobileStore.prepare(roots: fixture.roots, publicKeyHex: fixture.request.publicKey,
     28                                              protectedDataAvailability: .available)
     29     let runtime = try await TeraRuntime.withHostSignerAndLocalBackups(
     30       applicationSupportDirectory: fixture.roots.dataRoot.path, publicKeyHex: fixture.request.publicKey,
     31       sourceGenerationHex: fixture.request.sourceGeneration, sourceGenerationCreatedAtUnixMs: 100,
     32       protectedData: .available, hostSigner: TeraGeneratedHostSigner(signer: TestRuntimeSigner())
     33     )
     34     let result = try await host.capture(runtime: runtime, request: fixture.request)
     35     XCTAssertEqual(result.request, fixture.request)
     36     XCTAssertFalse(result.manifest.isEmpty)
     37     let replay = try await host.capture(runtime: runtime, request: fixture.request)
     38     XCTAssertEqual(result, replay)
     39     XCTAssertEqual(try fixture.files.read(id: fixture.request.backupId, completed: true), result.manifest)
     40     _ = try await runtime.shutdown()
     41   }
     42 
     43   func testImmutableMediaLeaseSurvivesOriginalRemovalAndRejectsCorruption() async throws {
     44     let fixture = try BackupFileFixture()
     45     defer { fixture.remove() }
     46     let host = try fixture.host()
     47     try await host.prepare(request: fixture.request)
     48     let bytes = Data("backup media fixture".utf8)
     49     let hash = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined()
     50     let blob = try RadrootsStagedBlobReference(blobID: hash, sizeBytes: bytes.count)
     51     let access = try RadrootsAppleFileAccess(roots: TeraDurableMediaRoots.selectingStaging(in: fixture.roots))
     52     try access.installStagedBlob(bytes, reference: blob)
     53     let media = FfiBackupMedia(sha256: hash, byteLength: UInt64(bytes.count),
     54                                leaseIdentifier: "tera_backup_\(fixture.request.backupId)_\(hash)")
     55     let retained = try await host.retainMedia(request: fixture.request, media: [media])
     56     XCTAssertEqual(retained, [media])
     57     try access.releaseStagedBlob(blob)
     58     let replay = try await host.retainMedia(request: fixture.request, media: [media])
     59     XCTAssertEqual(replay, retained)
     60     let directory = fixture.roots.dataRoot.appendingPathComponent("backups/\(fixture.request.publicKey)/staged_blob_leases")
     61     let lease = directory.appendingPathComponent(media.leaseIdentifier)
     62     XCTAssertEqual(try directory.resourceValues(forKeys: [.isExcludedFromBackupKey]).isExcludedFromBackup, true)
     63     let attributes = try FileManager.default.attributesOfItem(atPath: lease.path)
     64     XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o400)
     65     XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.roots.dataRoot.appendingPathComponent("staged_blob_leases").path))
     66     try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: lease.path)
     67     let corrupt = Data(repeating: 0, count: bytes.count)
     68     try corrupt.write(to: lease)
     69     do {
     70       _ = try await host.retainMedia(request: fixture.request, media: [media])
     71       XCTFail("A corrupt retained lease must not be replaced")
     72     } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, "backup_media_unavailable") }
     73     XCTAssertEqual(try Data(contentsOf: lease), corrupt)
     74   }
     75 
     76   func testMissingMediaAndConflictingManifestCannotPublishCompletion() async throws {
     77     let fixture = try BackupFileFixture()
     78     defer { fixture.remove() }
     79     let host = try fixture.host()
     80     try await host.prepare(request: fixture.request)
     81     let hash = String(repeating: "a", count: 64)
     82     let media = FfiBackupMedia(sha256: hash, byteLength: 42, leaseIdentifier: "tera_backup_\(fixture.request.backupId)_\(hash)")
     83     do {
     84       _ = try await host.retainMedia(request: fixture.request, media: [media])
     85       XCTFail("Missing media cannot become a receipt")
     86     } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, "backup_media_unavailable") }
     87     // This fixture exercises only the file callback; canonical manifest policy
     88     // is Rust-owned and the end-to-end test above uses its real encoded bytes.
     89     let first = FfiBackupManifest(request: fixture.request, media: [], manifest: Data("original".utf8))
     90     try await host.persistCandidate(manifest: first)
     91     let conflict = FfiBackupManifest(request: fixture.request, media: [], manifest: Data("conflict".utf8))
     92     do {
     93       try await host.persistCandidate(manifest: conflict)
     94       XCTFail("An opaque manifest ID is create-only")
     95     } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, "backup_publication_incomplete") }
     96     do {
     97       try await host.publishComplete(manifest: conflict)
     98       XCTFail("A different candidate cannot be published")
     99     } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, "backup_publication_incomplete") }
    100     XCTAssertEqual(try fixture.files.read(id: fixture.request.backupId), first.manifest)
    101     XCTAssertNil(try fixture.files.read(id: fixture.request.backupId, completed: true))
    102   }
    103 
    104   func testProtectedDataIdentityCancellationAndSymlinkRefuseWithoutCandidate() async throws {
    105     let fixture = try BackupFileFixture()
    106     defer { fixture.remove() }
    107     let locked = try fixture.host(protectedData: { false })
    108     do {
    109       try await locked.prepare(request: fixture.request)
    110       XCTFail("Locked storage cannot be prepared")
    111     } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, "backup_unavailable") }
    112     XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.roots.dataRoot.appendingPathComponent("backups").path))
    113     let foreign = try TeraLocalBackupHost(roots: fixture.roots, publicKey: String(repeating: "b", count: 64),
    114                                           generation: fixture.request.sourceGeneration, protectedData: { true })
    115     do {
    116       try await foreign.prepare(request: fixture.request)
    117       XCTFail("A foreign binding cannot create files")
    118     } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, "backup_identity_mismatch") }
    119     let host = try fixture.host()
    120     let cancelled = Task {
    121       withUnsafeCurrentTask { $0?.cancel() }
    122       try await host.prepare(request: fixture.request)
    123     }
    124     do {
    125       try await cancelled.value
    126       XCTFail("Cancelled preparation cannot succeed")
    127     } catch { XCTAssertEqual(TeraGeneratedRuntimeFailure.from(error).code, "backup_publication_incomplete") }
    128     let outside = fixture.root.appendingPathComponent("outside")
    129     try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true)
    130     try FileManager.default.createDirectory(at: fixture.roots.dataRoot, withIntermediateDirectories: true)
    131     try FileManager.default.createSymbolicLink(at: fixture.roots.dataRoot.appendingPathComponent("backups"), withDestinationURL: outside)
    132     do { try await host.prepare(request: fixture.request); XCTFail("Symlink traversal is forbidden") } catch {}
    133     XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: outside.path), [])
    134   }
    135 }
    136 
    137 private struct BackupFileFixture {
    138   let root: URL
    139   let roots: RadrootsAppleFileRoots
    140   let request = FfiBackupRequest(schemaVersion: 1, backupId: String(repeating: "01", count: 16),
    141                                  publicKey: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
    142                                  sourceGeneration: String(repeating: "07", count: 32), requestedAtUnixMs: 200, maximumBytes: 128 * 1024 * 1024)
    143 
    144   init() throws {
    145     root = FileManager.default.temporaryDirectory.resolvingSymlinksInPath()
    146       .appendingPathComponent("tera-backup-\(UUID().uuidString)", isDirectory: true)
    147     roots = try RadrootsAppleFileRoots(appIdentifier: "test.tera.backup", dataRoot: root.appendingPathComponent("data"),
    148                                        cacheRoot: root.appendingPathComponent("cache"), temporaryRoot: root.appendingPathComponent("temporary"))
    149   }
    150 
    151   var files: TeraBackupFiles {
    152     TeraBackupFiles(roots: roots, publicKey: request.publicKey)
    153   }
    154 
    155   func host(protectedData: @escaping @Sendable () async -> Bool = { true }) throws -> TeraLocalBackupHost {
    156     try TeraLocalBackupHost(roots: roots, publicKey: request.publicKey, generation: request.sourceGeneration, protectedData: protectedData)
    157   }
    158 
    159   func remove() {
    160     try? FileManager.default.removeItem(at: root)
    161   }
    162 }