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 }