TeraMediaCleanup.swift (6033B)
1 import Foundation 2 import RadrootsKit 3 import TeraKitBindings 4 import UIKit 5 6 enum TeraMediaCleanupResult: Sendable, Equatable { 7 case retained 8 case completed(removed: Int, exhaustedBudget: Bool) 9 } 10 11 /// An explicit startup pass. It never starts an OS session, signs, publishes, 12 /// walks export/lease trees, or deletes a file by a reconstructed pathname. 13 enum TeraMediaCleanup { 14 static func run( 15 roots: RadrootsAppleFileRoots, now: Date = Date(), 16 protectedData: @Sendable () async -> Bool = protectedDataAvailable 17 ) async -> TeraMediaCleanupResult { 18 guard await protectedData(), !Task.isCancelled else { return .retained } 19 do { 20 guard let reservation = try RadrootsAppleFileMaintenance(root: roots.dataRoot).reserveMaintenance() else { return .retained } 21 defer { withExtendedLifetime(reservation) {} } 22 let limits = mediaCleanupLimits() 23 guard var remaining = Int(exactly: limits.directoryEntries), let removals = Int(exactly: limits.removals) else { return .retained } 24 let children = try rootChildren(reservation: reservation, remaining: &remaining) 25 if let users = children[TeraMediaProcessUse.directory] { 26 guard users.kind == .directory, 27 try TeraMediaProcessUse.hasNoLiveUsers( 28 roots: roots, scan: reservation.openDirectory(relativePath: TeraMediaProcessUse.directory), remaining: &remaining 29 ) 30 else { return .retained } 31 } 32 // Future backup consumers must join the admission protocol before this 33 // conservative exclusion can be relaxed by their owning checkpoint. 34 guard children["backups"] == nil else { return .retained } 35 guard let staged = children["staged_blobs"] else { return .completed(removed: 0, exhaustedBudget: false) } 36 guard staged.kind == .directory, 37 roots.stagedBlobsRoot.standardizedFileURL == roots.dataRoot.appendingPathComponent("staged_blobs", isDirectory: true).standardizedFileURL 38 else { return .retained } 39 let snapshots = try await RadrootsAppleBackgroundTransferStore(roots: roots).loadSnapshots() 40 let native = try nativeHashes(snapshots, limit: limits.nativeReferences) 41 let inventory = try await inspectMediaReferences(applicationSupportDirectory: roots.dataRoot.path, nativeHashes: native) 42 guard await protectedData() else { return .retained } 43 try Task.checkCancellation() 44 try reservation.validate() 45 let scan = try reservation.openDirectory(relativePath: "staged_blobs") 46 return try collect(scan: scan, inventory: inventory, now: now, remaining: remaining, removals: removals) 47 } catch { 48 // Includes cancellation and an ambiguous unlink. No old proof is reused. 49 return .retained 50 } 51 } 52 53 private static func rootChildren( 54 reservation: RadrootsFileMaintenanceReservation, remaining: inout Int 55 ) throws -> [String: RadrootsFileMaintenanceEntry] { 56 let scan = try reservation.openDirectory() 57 var children: [String: RadrootsFileMaintenanceEntry] = [:] 58 while remaining > 0 { 59 let page = try scan.next(limit: min(64, remaining)) 60 remaining -= page.scannedEntries 61 for entry in page.entries where [TeraMediaProcessUse.directory, "staged_blobs", "backups"].contains(entry.name) { 62 children[entry.name] = entry 63 } 64 if page.reachedEnd { 65 return children 66 } 67 } 68 throw RadrootsAppleFileError.transientFailure 69 } 70 71 static func nativeHashes(_ snapshots: [RadrootsBackgroundTransferSnapshot], limit: UInt64) throws -> [String] { 72 guard UInt64(snapshots.count) <= limit / 3 else { throw RadrootsAppleFileError.invalidRequest } 73 var hashes: [String] = [] 74 for snapshot in snapshots { 75 // Preserve completed history too: a native status is not a Rust receipt. 76 guard let expected = snapshot.request.expectedSourceSHA256 else { throw RadrootsAppleFileError.invalidRequest } 77 hashes.append(expected) 78 let source: RadrootsBackgroundTransferLocalFile = switch snapshot.request.operation { 79 case let .upload(value): value 80 case let .download(value): value 81 } 82 switch source { 83 case let .stagedBlob(blob): hashes.append(blob.blobID) 84 case let .file(file): 85 if file.scope == .data, file.relativePath.hasPrefix("staged_blobs/") { 86 hashes.append(String(file.relativePath.dropFirst("staged_blobs/".count))) 87 } 88 } 89 if let lease = snapshot.uploadLease { 90 hashes.append(lease.blobID) 91 } 92 } 93 return hashes 94 } 95 96 private static func collect( 97 scan: RadrootsFileMaintenanceScan, inventory: FfiMediaReferenceInventory, 98 now: Date, remaining: Int, removals: Int 99 ) throws -> TeraMediaCleanupResult { 100 guard let nowMS = milliseconds(now, rounding: .down) else { return .retained } 101 var remaining = remaining 102 var removed = 0 103 while remaining > 0, removed < removals { 104 try Task.checkCancellation() 105 let page = try scan.next(limit: min(64, remaining)) 106 remaining -= page.scannedEntries 107 for entry in page.entries { 108 guard removed < removals else { return .completed(removed: removed, exhaustedBudget: true) } 109 guard entry.kind == .regularFile, let modified = milliseconds(entry.modifiedAt, rounding: .up), 110 inventory.permitsOrphan(name: entry.name, modifiedMs: modified, nowMs: nowMS) 111 else { continue } 112 if try scan.remove(entry) { 113 removed += 1 114 } 115 } 116 if page.reachedEnd { 117 return .completed(removed: removed, exhaustedBudget: false) 118 } 119 } 120 return .completed(removed: removed, exhaustedBudget: true) 121 } 122 123 private static func milliseconds(_ date: Date, rounding: FloatingPointRoundingRule) -> UInt64? { 124 let value = date.timeIntervalSince1970 * 1000 125 guard value.isFinite, value > 0, value < Double(Int64.max) else { return nil } 126 return UInt64(value.rounded(rounding)) 127 } 128 129 private static func protectedDataAvailable() async -> Bool { 130 await MainActor.run { UIApplication.shared.isProtectedDataAvailable } 131 } 132 }