field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }