apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

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 }