TeraBackupFiles.swift (3892B)
1 import Foundation 2 import RadrootsKit 3 import TeraKitBindings 4 5 /// Uses the existing durable file owner. It never reads or copies live DB files. 6 struct TeraBackupFiles { 7 let roots: RadrootsAppleFileRoots 8 let publicKey: String 9 10 func prepare() throws { 11 // The caller has validated the binding and observed protected data. Marker 12 // installation creates directories through the no-follow durable owner. 13 for directory in ["sqlite", "manifests", "staged_blob_leases"] { 14 try prepare(directory: directory) 15 } 16 } 17 18 /// Metadata belongs on the admitted directory before the owner installs 19 /// read-only leases. Original media remains in the canonical staging root. 20 func mediaAccess() throws -> RadrootsAppleFileAccess { 21 try prepare(directory: "staged_blob_leases") 22 return try RadrootsAppleFileAccess(roots: RadrootsAppleFileRoots( 23 appIdentifier: roots.appIdentifier, dataRoot: accountDirectory, 24 cacheRoot: roots.cacheRoot, temporaryRoot: roots.temporaryRoot, 25 stagedBlobsRoot: roots.stagedBlobsRoot 26 )) 27 } 28 29 func read(id: String, completed: Bool = false) throws -> Data? { 30 let relative = "backups/\(publicKey)/manifests/\(name(id: id, completed: completed))" 31 do { 32 guard case let .inline(data) = try RadrootsAppleFileAccess(roots: roots).read( 33 RadrootsFileReference(scope: .data, relativePath: relative), 34 mode: .inline(maxBytes: Int(applicationBackupLimits().manifestBytes)) 35 ) else { throw TeraLocalBackupHost.failure("backup_verification_failed") } 36 return data 37 } catch RadrootsAppleFileError.notFound { 38 return nil 39 } 40 } 41 42 func install(_ bytes: Data, id: String, completed: Bool) throws { 43 guard !bytes.isEmpty, bytes.count <= applicationBackupLimits().manifestBytes else { 44 throw TeraLocalBackupHost.failure("backup_capacity_exceeded") 45 } 46 // Opaque IDs are create-only: different bytes cannot replace prior evidence. 47 try access(directory: "manifests").installStagedBlob(bytes, reference: RadrootsStagedBlobReference( 48 blobID: name(id: id, completed: completed), sizeBytes: bytes.count 49 )) 50 } 51 52 /// Applies local custody policy only to exact objects just admitted by the 53 /// file owner. This never walks or recursively repairs unrelated paths. 54 static func protect(_ admittedURL: URL) throws { 55 let attributes = try admittedURL.resourceValues(forKeys: [.isSymbolicLinkKey]) 56 guard attributes.isSymbolicLink == false else { throw RadrootsAppleFileError.invalidRequest } 57 var url = admittedURL 58 var values = URLResourceValues() 59 values.isExcludedFromBackup = true 60 try url.setResourceValues(values) 61 #if os(iOS) 62 try FileManager.default.setAttributes( 63 [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: url.path 64 ) 65 #endif 66 } 67 68 private var accountDirectory: URL { 69 roots.dataRoot.appendingPathComponent("backups", isDirectory: true) 70 .appendingPathComponent(publicKey, isDirectory: true) 71 } 72 73 private func prepare(directory: String) throws { 74 let access = try access(directory: directory) 75 let marker = Data("tera.local.backup.v1\n".utf8) 76 try access.installStagedBlob(marker, reference: RadrootsStagedBlobReference(blobID: "owner_v1", sizeBytes: marker.count)) 77 try Self.protect(accountDirectory) 78 try Self.protect(access.roots.stagedBlobsRoot) 79 } 80 81 private func access(directory: String) throws -> RadrootsAppleFileAccess { 82 try RadrootsAppleFileAccess(roots: RadrootsAppleFileRoots( 83 appIdentifier: roots.appIdentifier, dataRoot: roots.dataRoot, 84 cacheRoot: roots.cacheRoot, temporaryRoot: roots.temporaryRoot, 85 stagedBlobsRoot: accountDirectory.appendingPathComponent(directory, isDirectory: true) 86 )) 87 } 88 89 private func name(id: String, completed: Bool) -> String { 90 "\(id)_\(completed ? "complete" : "candidate")" 91 } 92 }