RadrootsStagedBlobLease.swift (7283B)
1 import Foundation 2 3 /// An owner-managed immutable copy. Keep it until the native consumer is 4 /// definitively finished; releasing the original staged blob cannot remove it. 5 /// A full explicit data-root reset still removes all owned application state. 6 public struct RadrootsStagedBlobLease: Sendable, Equatable, CustomDebugStringConvertible { 7 public let identifier: String 8 public let blob: RadrootsStagedBlobReference 9 public let sha256: String 10 public let fileURL: URL 11 12 fileprivate init(identifier: String, blob: RadrootsStagedBlobReference, sha256: String, fileURL: URL) { 13 self.identifier = identifier 14 self.blob = blob 15 self.sha256 = sha256 16 self.fileURL = fileURL 17 } 18 19 public var debugDescription: String { 20 "RadrootsStagedBlobLease(identifier: \(identifier), sha256: \(sha256), sizeBytes: \(blob.sizeBytes))" 21 } 22 } 23 24 extension RadrootsAppleFileAccess { 25 func leaseUploadBytes(_ bytes: Data, identifier: String) throws -> RadrootsStagedBlobLease { 26 let digest = RadrootsAppleFileDigest.sha256(bytes) 27 let blob = try RadrootsStagedBlobReference(blobID: digest, sizeBytes: bytes.count) 28 let url = try leaseURL(identifier) 29 let lease = RadrootsStagedBlobLease(identifier: identifier, blob: blob, sha256: digest, fileURL: url) 30 do { 31 try persistence.install(bytes, url, .create, true) 32 } catch { 33 try recoverLease(lease, originalError: error) 34 } 35 try validateLease(lease) 36 return lease 37 } 38 39 func releaseUploadLease(executionID: UUID) throws { 40 try RadrootsAtomicFile.remove(at: leaseURL(executionID.uuidString.lowercased())) 41 } 42 43 /// Installs an exact reference without removing a prior file first. Matching 44 /// installs are idempotent. An opaque ID cannot replace different bytes; 45 /// a SHA256 ID can repair corrupted bytes only with its verified preimage. 46 public func installStagedBlob(_ data: Data, reference: RadrootsStagedBlobReference) throws { 47 try Self.validateStagedReference(reference) 48 guard data.count == reference.sizeBytes else { throw RadrootsAppleFileError.invalidRequest } 49 let url = try roots.stagedBlobURL(for: reference) 50 do { 51 try persistence.install(data, url, .create, false) 52 } catch { 53 let originalError = error 54 let existing: Data? 55 do { 56 existing = try readStagedBlob(reference) 57 } catch RadrootsAppleFileError.notFound { 58 throw originalError 59 } catch { 60 // Corrupt size/bytes may be repaired only by the exact content 61 // identity below. Symlink traversal still fails in the writer. 62 existing = nil 63 } 64 if existing == data { 65 try persistence.synchronize(url) 66 return 67 } 68 guard RadrootsAppleFileDigest.sha256(data) == reference.blobID else { 69 throw RadrootsAppleFileError.permanentFailure 70 } 71 try persistence.install(data, url, .replace, false) 72 } 73 } 74 75 /// Reuses the same immutable lease on retry, even after the source blob is 76 /// gone. The host persists the identifier with its native operation before 77 /// admission; this generic owner does not invent transfer/domain identity. 78 public func leaseStagedBlob( 79 _ blob: RadrootsStagedBlobReference, expectedSHA256: String, 80 identifier: String = UUID().uuidString.lowercased() 81 ) throws -> RadrootsStagedBlobLease { 82 try Self.validateStagedReference(blob) 83 guard expectedSHA256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { 84 throw RadrootsAppleFileError.invalidRequest 85 } 86 let url = try leaseURL(identifier) 87 let lease = RadrootsStagedBlobLease(identifier: identifier, blob: blob, sha256: expectedSHA256, fileURL: url) 88 do { 89 try validateLease(lease) 90 try persistence.synchronize(url) 91 return lease 92 } catch RadrootsAppleFileError.notFound { 93 // Only definitive absence admits creation; protected/corrupt or 94 // substituted existing leases must never be overwritten. 95 } 96 let bytes = try readStagedBlob(blob) 97 guard RadrootsAppleFileDigest.sha256(bytes) == expectedSHA256 else { 98 throw RadrootsAppleFileError.permanentFailure 99 } 100 do { 101 try persistence.install(bytes, url, .create, true) 102 } catch { 103 // A competing identical admission or an ambiguous directory sync 104 // can be recovered only by checking and flushing the exact lease. 105 try recoverLease(lease, originalError: error) 106 } 107 try validateLease(lease) 108 return lease 109 } 110 111 public func releaseStagedBlobLease(_ lease: RadrootsStagedBlobLease) throws { 112 guard try leaseURL(lease.identifier) == lease.fileURL else { throw RadrootsAppleFileError.invalidRequest } 113 do { 114 try validateLease(lease) 115 } catch RadrootsAppleFileError.notFound { 116 return 117 } 118 try RadrootsAtomicFile.remove(at: lease.fileURL) 119 } 120 121 private func leaseURL(_ identifier: String) throws -> URL { 122 guard identifier.utf8.count <= 128, 123 try RadrootsStagedBlobReference.normalizedBlobID(identifier) == identifier 124 else { throw RadrootsAppleFileError.invalidRequest } 125 return roots.dataRoot.appendingPathComponent("staged_blob_leases", isDirectory: true) 126 .appendingPathComponent(identifier) 127 } 128 129 private func recoverLease(_ lease: RadrootsStagedBlobLease, originalError: any Error) throws { 130 do { 131 try validateLease(lease) 132 } catch RadrootsAppleFileError.notFound { 133 throw originalError 134 } 135 // A matching file may have been installed despite the original error. 136 // Only a successful flush establishes a reusable durable lease. 137 try persistence.synchronize(lease.fileURL) 138 } 139 140 private func validateLease(_ lease: RadrootsStagedBlobLease) throws { 141 let bytes: Data 142 do { 143 bytes = try RadrootsGovernedFileReader.read( 144 root: lease.fileURL.deletingLastPathComponent(), relativePath: lease.identifier, 145 maximumBytes: lease.blob.sizeBytes 146 ) 147 } catch RadrootsGovernedFileReadError.unavailable { 148 throw RadrootsAppleFileError.notFound 149 } catch { 150 throw RadrootsAppleFileError.permanentFailure 151 } 152 guard bytes.count == lease.blob.sizeBytes, RadrootsAppleFileDigest.sha256(bytes) == lease.sha256 else { 153 throw RadrootsAppleFileError.permanentFailure 154 } 155 } 156 157 private static func validateStagedReference(_ blob: RadrootsStagedBlobReference) throws { 158 guard (0 ... RadrootsAtomicFile.maximumBytes).contains(blob.sizeBytes), 159 try RadrootsStagedBlobReference(blobID: blob.blobID, sizeBytes: blob.sizeBytes, 160 mediaType: blob.mediaType, filenameHint: blob.filenameHint) == blob 161 else { throw RadrootsAppleFileError.invalidRequest } 162 } 163 }