RadrootsAppleBackgroundTransferStore.swift (13966B)
1 import Darwin 2 import Foundation 3 4 public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore { 5 private static let maximumPersistenceBytes = 1024 * 1024 6 7 private struct Envelope: Codable { 8 let schemaVersion: Int 9 let snapshots: [RadrootsBackgroundTransferSnapshot] 10 11 init(snapshots: [RadrootsBackgroundTransferSnapshot]) { 12 schemaVersion = 1 13 self.snapshots = snapshots 14 } 15 } 16 17 private let roots: RadrootsAppleFileRoots 18 private let fileManager: FileManager 19 private let encoder: JSONEncoder 20 private let decoder: JSONDecoder 21 private let protectedData: RadrootsProtectedDataProvider 22 private let persistence: RadrootsFilePersistence 23 private var admissionScan: RadrootsAdmissionFileScan? 24 25 private struct Admission { 26 let descriptor: Int32 27 let url: URL 28 } 29 30 public init( 31 roots: RadrootsAppleFileRoots, fileManager: FileManager = .default, 32 protectedData: RadrootsProtectedDataProvider = .available 33 ) { 34 self.roots = roots 35 self.fileManager = fileManager 36 encoder = JSONEncoder() 37 decoder = JSONDecoder() 38 self.protectedData = protectedData 39 persistence = .live 40 encoder.outputFormatting = [.sortedKeys] 41 } 42 43 init(roots: RadrootsAppleFileRoots, persistence: RadrootsFilePersistence) { 44 self.roots = roots 45 fileManager = .default 46 encoder = JSONEncoder() 47 decoder = JSONDecoder() 48 protectedData = .available 49 self.persistence = persistence 50 encoder.outputFormatting = [.sortedKeys] 51 } 52 53 public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { 54 try withStoreLock { try loadSnapshotsSynchronously() } 55 } 56 57 public func withAdmission<Result: Sendable>( 58 for identifier: RadrootsBackgroundTransferIdentifier, 59 operation: @escaping @Sendable () async throws -> Result 60 ) async throws -> Result { 61 guard let admission = try await acquireAdmission(identifier) else { 62 throw RadrootsBackgroundTransferError.invalidRequest 63 } 64 // Only this attempt owns the descriptor across awaits. Persistence uses a 65 // separate short lock; neither reservation waits for another owner. 66 defer { retireAdmission(admission) } 67 return try await operation() 68 } 69 70 public func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool { 71 guard let admission = try await acquireAdmission(identifier) else { return true } 72 retireAdmission(admission) 73 return false 74 } 75 76 private func acquireAdmission(_ identifier: RadrootsBackgroundTransferIdentifier) async throws -> Admission? { 77 try requireProtectedData() 78 let coordination = try await acquireAdmissionCoordination() 79 defer { Darwin.close(coordination) } 80 do { 81 let name = RadrootsAppleFileDigest.sha256(Data(identifier.rawValue.utf8)) 82 let url = try roots.resolvedURL(for: RadrootsFileReference( 83 scope: .data, relativePath: "background_transfers/admissions/\(name).lock" 84 )) 85 guard let descriptor = try RadrootsAtomicFile.acquireExclusiveLock(at: url) else { return nil } 86 return Admission(descriptor: descriptor, url: url) 87 } catch RadrootsAppleFileError.transientFailure { 88 throw RadrootsBackgroundTransferError.unavailable 89 } catch { 90 throw RadrootsBackgroundTransferError.persistence(error) 91 } 92 } 93 94 private func admissionCoordinationURL() throws -> URL { 95 try roots.resolvedURL(for: RadrootsFileReference( 96 scope: .data, relativePath: "background_transfers/admissions/.coordination.lock" 97 )) 98 } 99 100 private func acquireAdmissionCoordination() async throws -> Int32 { 101 for _ in 0 ..< 16 { 102 try Task.checkCancellation() 103 try requireProtectedData() 104 do { 105 if let descriptor = try RadrootsAtomicFile.acquireExclusiveLock(at: admissionCoordinationURL()) { 106 return descriptor 107 } 108 } catch RadrootsAppleFileError.transientFailure { 109 // The leaf was not created within its bounded retry budget. 110 } catch { throw RadrootsBackgroundTransferError.persistence(error) } 111 await Task.yield() 112 } 113 throw RadrootsBackgroundTransferError.unavailable 114 } 115 116 private func retireAdmission(_ admission: Admission) { 117 do { 118 try requireProtectedData() 119 guard let coordination = try RadrootsAtomicFile.acquireExclusiveLock(at: admissionCoordinationURL()) else { 120 Darwin.close(admission.descriptor) 121 return 122 } 123 // Close the retired inode before releasing the acquisition gate. 124 defer { Darwin.close(admission.descriptor); Darwin.close(coordination) } 125 _ = try? RadrootsAtomicFile.removeEmptyLockedFile(at: admission.url, descriptor: admission.descriptor) 126 } catch { 127 // Cleanup failure never rewrites the operation result. A later 128 // explicit pass may reconcile the retained inactive inode. 129 Darwin.close(admission.descriptor) 130 } 131 } 132 133 /// Explicit metadata housekeeping. Each call visits at most 64 directory 134 /// entries and continues this store's pass. A new pass starts after its end; 135 /// restarting the owner starts over. Snapshots and upload leases are untouched. 136 public func collectInactiveAdmissions(limit: Int = 64) async throws -> RadrootsAdmissionCleanupResult { 137 guard (1 ... 64).contains(limit) else { throw RadrootsBackgroundTransferError.invalidRequest } 138 let coordination = try await acquireAdmissionCoordination() 139 defer { Darwin.close(coordination) } 140 do { 141 let directory = try admissionCoordinationURL().deletingLastPathComponent() 142 if admissionScan == nil { 143 admissionScan = try RadrootsAdmissionFileScan(url: directory) 144 } 145 guard let admissionScan else { throw RadrootsBackgroundTransferError.persistenceFailure } 146 let batch = try admissionScan.next(limit: limit) 147 var removed = 0 148 for name in batch.names where Self.isAdmissionFilename(name) { 149 if try admissionScan.removeInactive(name: name, coordination: coordination) { 150 removed += 1 151 } 152 } 153 if batch.reachedEnd { 154 self.admissionScan = nil 155 } 156 return RadrootsAdmissionCleanupResult(scannedEntries: batch.scanned, removedFiles: removed, reachedEnd: batch.reachedEnd) 157 } catch { 158 admissionScan = nil 159 throw RadrootsBackgroundTransferError.persistence(error) 160 } 161 } 162 163 private static func isAdmissionFilename(_ name: String) -> Bool { 164 name.utf8.count == 69 && name.hasSuffix(".lock") 165 && name.utf8.prefix(64).allSatisfy { (48 ... 57).contains($0) || (97 ... 102).contains($0) } 166 } 167 168 public func compareExchangeSnapshot( 169 expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot 170 ) async throws -> Bool { 171 try withStoreLock { 172 guard expected == nil || expected?.identifier == desired.identifier else { 173 throw RadrootsBackgroundTransferError.invalidRequest 174 } 175 var snapshots = try loadSnapshotsSynchronously() 176 let current = snapshots.first { $0.identifier == desired.identifier } 177 guard try current == (expected?.redactedForPersistence()) else { return false } 178 snapshots.removeAll { $0.identifier == desired.identifier } 179 try snapshots.append(desired.redactedForPersistence()) 180 try write(snapshots.sorted { $0.identifier < $1.identifier }) 181 return true 182 } 183 } 184 185 private func withStoreLock<T>(_ body: () throws -> T) throws -> T { 186 try requireProtectedData() 187 do { 188 let lockURL = try roots.resolvedURL( 189 for: RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.lock") 190 ) 191 return try RadrootsAtomicFile.withExclusiveLock(at: lockURL, body) 192 } catch let error as RadrootsBackgroundTransferError { 193 throw error 194 } catch { 195 throw RadrootsBackgroundTransferError.persistence(error) 196 } 197 } 198 199 private func loadSnapshotsSynchronously() throws -> [RadrootsBackgroundTransferSnapshot] { 200 try requireProtectedData() 201 do { 202 let url = try storeURL() 203 let legacyURL = try legacyStoreURL() 204 let isLegacy: Bool 205 if fileManager.fileExists(atPath: url.path) { 206 isLegacy = false 207 } else if fileManager.fileExists(atPath: legacyURL.path) { 208 isLegacy = true 209 } else { 210 return [] 211 } 212 let data = try RadrootsGovernedFileReader.read( 213 root: roots.root(for: isLegacy ? .cache : .data), 214 relativePath: "background_transfers/transfers.json", 215 maximumBytes: Self.maximumPersistenceBytes 216 ) 217 let decoded: [RadrootsBackgroundTransferSnapshot] 218 let usedLegacyEncoding: Bool 219 if let envelope = try? decoder.decode(Envelope.self, from: data) { 220 guard envelope.schemaVersion == 1 else { 221 throw RadrootsBackgroundTransferError.persistenceFailure 222 } 223 decoded = envelope.snapshots 224 usedLegacyEncoding = false 225 } else { 226 decoded = try decoder.decode([RadrootsBackgroundTransferSnapshot].self, from: data) 227 usedLegacyEncoding = true 228 } 229 let snapshots = try decoded.map { try $0.redactedForPersistence() }.sorted { left, right in 230 left.identifier < right.identifier 231 } 232 if isLegacy || usedLegacyEncoding { 233 try write(snapshots) 234 } 235 if fileManager.fileExists(atPath: legacyURL.path) { 236 try fileManager.removeItem(at: legacyURL) 237 } 238 return snapshots 239 } catch { 240 throw RadrootsBackgroundTransferError.persistence(error) 241 } 242 } 243 244 public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { 245 try withStoreLock { try saveSnapshotSynchronously(snapshot) } 246 } 247 248 private func saveSnapshotSynchronously(_ snapshot: RadrootsBackgroundTransferSnapshot) throws { 249 try requireProtectedData() 250 do { 251 var snapshots = try loadSnapshotsSynchronously() 252 snapshots.removeAll { $0.identifier == snapshot.identifier } 253 try snapshots.append(snapshot.redactedForPersistence()) 254 try write(snapshots.sorted { left, right in left.identifier < right.identifier }) 255 } catch let error as RadrootsBackgroundTransferError { 256 throw error 257 } catch { 258 throw RadrootsBackgroundTransferError.persistence(error) 259 } 260 } 261 262 public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws { 263 try withStoreLock { try removeSnapshotSynchronously(for: identifier) } 264 } 265 266 private func removeSnapshotSynchronously(for identifier: RadrootsBackgroundTransferIdentifier) throws { 267 try requireProtectedData() 268 do { 269 var snapshots = try loadSnapshotsSynchronously() 270 snapshots.removeAll { $0.identifier == identifier } 271 try write(snapshots) 272 } catch let error as RadrootsBackgroundTransferError { 273 throw error 274 } catch { 275 throw RadrootsBackgroundTransferError.persistence(error) 276 } 277 } 278 279 public func removeAllSnapshots() async throws { 280 try withStoreLock { try removeAllSnapshotsSynchronously() } 281 } 282 283 private func removeAllSnapshotsSynchronously() throws { 284 try requireProtectedData() 285 do { 286 for url in try [storeURL(), legacyStoreURL()] where fileManager.fileExists(atPath: url.path) { 287 try fileManager.removeItem(at: url) 288 } 289 } catch let error as RadrootsBackgroundTransferError { 290 throw error 291 } catch { 292 throw RadrootsBackgroundTransferError.persistence(error) 293 } 294 } 295 296 private func write(_ snapshots: [RadrootsBackgroundTransferSnapshot]) throws { 297 let url = try storeURL() 298 try fileManager.createDirectory( 299 at: url.deletingLastPathComponent(), withIntermediateDirectories: true 300 ) 301 let data = try encoder.encode(Envelope(snapshots: snapshots)) 302 guard data.count <= Self.maximumPersistenceBytes else { 303 throw RadrootsBackgroundTransferError.receiptCapacityExceeded 304 } 305 try persistence.install(data, url, .replace, false) 306 #if os(iOS) 307 try fileManager.setAttributes( 308 [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], 309 ofItemAtPath: url.path 310 ) 311 #endif 312 } 313 314 private func storeURL() throws -> URL { 315 try roots.resolvedURL( 316 for: RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.json") 317 ) 318 } 319 320 private func legacyStoreURL() throws -> URL { 321 try roots.resolvedURL( 322 for: RadrootsFileReference(scope: .cache, relativePath: "background_transfers/transfers.json") 323 ) 324 } 325 326 private func requireProtectedData() throws { 327 guard protectedData.currentState() == .available else { 328 throw RadrootsBackgroundTransferError.persistenceFailure 329 } 330 } 331 }