field_ios

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

TeraLocalBackupHost.swift (4915B)


      1 import Foundation
      2 import RadrootsKit
      3 import TeraKitBindings
      4 import UIKit
      5 
      6 /// Explicit local backup only. Credential export and restore are separate ports.
      7 actor TeraLocalBackupHost: TeraBackupHost {
      8   let roots: RadrootsAppleFileRoots
      9   let publicKey: String
     10   let generation: String
     11   let protectedData: @Sendable () async -> Bool
     12 
     13   init(roots: RadrootsAppleFileRoots, publicKey: String, generation: String,
     14        protectedData: @escaping @Sendable () async -> Bool = currentProtectedData) throws
     15   {
     16     self.roots = try TeraDurableMediaRoots.selectingStaging(in: roots)
     17     self.publicKey = publicKey
     18     self.generation = generation
     19     self.protectedData = protectedData
     20   }
     21 
     22   /// The complete FFI call retains admission, including every callback and any
     23   /// canceled callback still finishing. Persistent markers also guard restart.
     24   func capture(runtime: TeraRuntime, request: FfiBackupRequest) async throws -> FfiBackupManifest {
     25     try await admit(request)
     26     let use = try reserve()
     27     defer { withExtendedLifetime(use) {} }
     28     return try await runtime.captureApplicationBackup(request: request, host: self)
     29   }
     30 
     31   func prepare(request: FfiBackupRequest) async throws {
     32     try await admit(request)
     33     let use = try reserve()
     34     defer { withExtendedLifetime(use) {} }
     35     do { try files.prepare() } catch { throw Self.failure("backup_unavailable") }
     36   }
     37 
     38   func loadCandidate(request: FfiBackupRequest) async throws -> Data? {
     39     try await admit(request)
     40     let use = try reserve()
     41     defer { withExtendedLifetime(use) {} }
     42     do {
     43       return try files.read(id: request.backupId)
     44     } catch { throw Self.failure("backup_verification_failed") }
     45   }
     46 
     47   func retainMedia(request: FfiBackupRequest, media: [FfiBackupMedia]) async throws -> [FfiBackupMedia] {
     48     try await admit(request)
     49     let use = try reserve()
     50     defer { withExtendedLifetime(use) {} }
     51     try retain(request: request, media: media)
     52     return media
     53   }
     54 
     55   func persistCandidate(manifest: FfiBackupManifest) async throws {
     56     try await admit(manifest.request)
     57     let use = try reserve()
     58     defer { withExtendedLifetime(use) {} }
     59     do {
     60       try retain(request: manifest.request, media: manifest.media)
     61       try files.install(manifest.manifest, id: manifest.request.backupId, completed: false)
     62     } catch { throw Self.failure("backup_publication_incomplete") }
     63   }
     64 
     65   func publishComplete(manifest: FfiBackupManifest) async throws {
     66     try await admit(manifest.request)
     67     let use = try reserve()
     68     defer { withExtendedLifetime(use) {} }
     69     do {
     70       try retain(request: manifest.request, media: manifest.media)
     71       guard try files.read(id: manifest.request.backupId) == manifest.manifest else {
     72         throw Self.failure("backup_conflict")
     73       }
     74       try files.install(manifest.manifest, id: manifest.request.backupId, completed: true)
     75     } catch { throw Self.failure("backup_publication_incomplete") }
     76   }
     77 
     78   private var files: TeraBackupFiles {
     79     TeraBackupFiles(roots: roots, publicKey: publicKey)
     80   }
     81 
     82   private func reserve() throws -> RadrootsFileUseReservation {
     83     do {
     84       return try TeraMediaProcessUse.admit(root: roots.dataRoot)
     85     } catch { throw Self.failure("backup_unavailable") }
     86   }
     87 
     88   private func admit(_ request: FfiBackupRequest) async throws {
     89     try validateApplicationBackupRequest(request: request)
     90     guard request.publicKey == publicKey else { throw Self.failure("backup_identity_mismatch") }
     91     guard request.sourceGeneration == generation else { throw Self.failure("backup_generation_mismatch") }
     92     guard await protectedData() else { throw Self.failure("backup_unavailable") }
     93     guard !Task.isCancelled else { throw Self.failure("backup_publication_incomplete") }
     94   }
     95 
     96   private func retain(request: FfiBackupRequest, media: [FfiBackupMedia]) throws {
     97     try validateApplicationBackupMedia(request: request, media: media)
     98     do {
     99       let access = try files.mediaAccess()
    100       for item in media {
    101         try Task.checkCancellation()
    102         guard let size = Int(exactly: item.byteLength) else { throw Self.failure("backup_media_unavailable") }
    103         let blob = try RadrootsStagedBlobReference(blobID: item.sha256, sizeBytes: size)
    104         _ = try access.leaseStagedBlob(blob, expectedSHA256: item.sha256, identifier: item.leaseIdentifier)
    105       }
    106     } catch { throw Self.failure("backup_media_unavailable") }
    107   }
    108 
    109   nonisolated static func failure(_ code: String) -> TeraAppError {
    110     .Failure(report: TeraErrorRecord(schemaVersion: 1, code: code, category: "backup", retryable: false,
    111                                      recoveryActions: ["inspect_local_stores"], operationId: nil, capabilityId: nil,
    112                                      safeMessage: "The local backup could not be completed."))
    113   }
    114 
    115   private static func currentProtectedData() async -> Bool {
    116     await MainActor.run { UIApplication.shared.isProtectedDataAvailable }
    117   }
    118 }