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

commit 342a9c08d4eff1bedc5f25900bb166aa9eb531e2
parent 68135576af9ac34126195178dabe82f02ff69497
Author: triesap <tyson@radroots.org>
Date:   Tue, 22 Sep 2026 14:47:36 +0000

storage: preserve native capacity and receipt failures

- Classify file and capture capacity without raw diagnostics
- Retain exact staged leases through ambiguous installation
- Distinguish receipt quota and preserve pending callbacks
- Verify full package tests and additive public error API

Diffstat:
MREADME | 8++++++++
MSources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift | 14+++++++++-----
MSources/RadrootsKit/RadrootsAppleBackgroundTransferStore.swift | 32++++++++++++++++++++++----------
MSources/RadrootsKit/RadrootsAppleFileAccess.swift | 8++++++++
MSources/RadrootsKit/RadrootsAppleFileError.swift | 31+++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsAppleFileOperations.swift | 2+-
MSources/RadrootsKit/RadrootsAppleMediaPicker.swift | 2++
MSources/RadrootsKit/RadrootsAtomicFile.swift | 78+++++++++++++++++++++++++++++++++++++++++++++---------------------------------
MSources/RadrootsKit/RadrootsBackgroundTransfer.swift | 13+++++++++++++
MSources/RadrootsKit/RadrootsCaptureIntake.swift | 2++
ASources/RadrootsKit/RadrootsFilePersistence.swift | 13+++++++++++++
MSources/RadrootsKit/RadrootsStagedBlobLease.swift | 39+++++++++++++++++++++++++--------------
ATests/RadrootsKitTests/RadrootsFileCapacityTests.swift | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsTransferCapacityTests.swift | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/api_baselines/apple_kit.txt | 8++++++++
15 files changed, 478 insertions(+), 63 deletions(-)

diff --git a/README b/README @@ -17,6 +17,14 @@ extbuild-enabled checkout, first run `cargo extbuild doctor` and route those commands through `cargo extbuild run --`. +## Storage capacity + +File, capture and transfer adapters report typed capacity failures without raw +filesystem details. A failed write may already have installed bytes; retain the +original identity and reconcile before retrying. The transfer receipt envelope +has a separate bounded-capacity error. Neither error authorizes deletion of +unresolved receipts, staged media or leases, or automatic network retry. + ## Copyright Except as otherwise noted, all files in the `apple_kit` distribution are diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift @@ -59,7 +59,7 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -90,7 +90,7 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -145,6 +145,7 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { guard let executionID = queued.executionID else { throw RadrootsBackgroundTransferError.invalidRequest } try await adapters.enqueue(request, executionID) } catch { + let persistenceFailure = RadrootsBackgroundTransferError.persistence(error) if stoppedAdmissions[request.identifier] != nil { try? await adapters.cancel(request.identifier) } @@ -155,6 +156,9 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { failure: .enqueueFailed, possibleRemoteOrphan: request.isUpload)) } + if persistenceFailure == .spaceInsufficient || persistenceFailure == .receiptCapacityExceeded { + throw persistenceFailure + } throw RadrootsBackgroundTransferError.transferFailure } if stoppedAdmissions[request.identifier] != nil || Task.isCancelled { @@ -275,7 +279,7 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -287,7 +291,7 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -307,7 +311,7 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } } diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferStore.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferStore.swift @@ -19,6 +19,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto private let encoder: JSONEncoder private let decoder: JSONDecoder private let protectedData: RadrootsProtectedDataProvider + private let persistence: RadrootsFilePersistence private var admissionScan: RadrootsAdmissionFileScan? private struct Admission { @@ -35,6 +36,17 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto encoder = JSONEncoder() decoder = JSONDecoder() self.protectedData = protectedData + persistence = .live + encoder.outputFormatting = [.sortedKeys] + } + + init(roots: RadrootsAppleFileRoots, persistence: RadrootsFilePersistence) { + self.roots = roots + fileManager = .default + encoder = JSONEncoder() + decoder = JSONDecoder() + protectedData = .available + self.persistence = persistence encoder.outputFormatting = [.sortedKeys] } @@ -75,7 +87,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto } catch RadrootsAppleFileError.transientFailure { throw RadrootsBackgroundTransferError.unavailable } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -95,7 +107,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto } } catch RadrootsAppleFileError.transientFailure { // The leaf was not created within its bounded retry budget. - } catch { throw RadrootsBackgroundTransferError.persistenceFailure } + } catch { throw RadrootsBackgroundTransferError.persistence(error) } await Task.yield() } throw RadrootsBackgroundTransferError.unavailable @@ -144,7 +156,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto return RadrootsAdmissionCleanupResult(scannedEntries: batch.scanned, removedFiles: removed, reachedEnd: batch.reachedEnd) } catch { admissionScan = nil - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -180,7 +192,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -225,7 +237,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto } return snapshots } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -243,7 +255,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -260,7 +272,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -277,7 +289,7 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.persistence(error) } } @@ -288,9 +300,9 @@ public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferSto ) let data = try encoder.encode(Envelope(snapshots: snapshots)) guard data.count <= Self.maximumPersistenceBytes else { - throw RadrootsBackgroundTransferError.persistenceFailure + throw RadrootsBackgroundTransferError.receiptCapacityExceeded } - try RadrootsAtomicFile.install(data, at: url) + try persistence.install(data, url, .replace, false) #if os(iOS) try fileManager.setAttributes( [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], diff --git a/Sources/RadrootsKit/RadrootsAppleFileAccess.swift b/Sources/RadrootsKit/RadrootsAppleFileAccess.swift @@ -6,10 +6,18 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess { public let roots: RadrootsAppleFileRoots let fileManager: FileManager + let persistence: RadrootsFilePersistence public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) { self.roots = roots self.fileManager = fileManager + persistence = .live + } + + init(roots: RadrootsAppleFileRoots, persistence: RadrootsFilePersistence) { + self.roots = roots + fileManager = .default + self.persistence = persistence } public func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws { diff --git a/Sources/RadrootsKit/RadrootsAppleFileError.swift b/Sources/RadrootsKit/RadrootsAppleFileError.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation public enum RadrootsAppleFileError: Error, Equatable, Sendable { @@ -6,6 +7,8 @@ public enum RadrootsAppleFileError: Error, Equatable, Sendable { case permissionDenied case transientFailure case permanentFailure + /// Capacity is exhausted. Earlier installation may still require reconciliation. + case spaceInsufficient } extension RadrootsAppleFileError: LocalizedError { @@ -16,6 +19,34 @@ extension RadrootsAppleFileError: LocalizedError { case .permissionDenied: "File access was denied." case .transientFailure: "The file operation could not be completed temporarily." case .permanentFailure: "The file operation could not be completed." + case .spaceInsufficient: "There is not enough storage space to complete the file operation." } } } + +extension RadrootsAppleFileError { + /// Call only with errno captured from an actually failed system call. + static func posix(_ code: Int32) -> Self { + code == ENOSPC || code == EDQUOT ? .spaceInsufficient : .permanentFailure + } + + static func classified(_ error: any Error) -> Self { + if let typed = error as? Self { return typed } + var current = error as NSError + // Foundation may wrap a POSIX failure. Bound traversal even for a + // malformed/cyclic error chain; never retain paths or diagnostic text. + for _ in 0 ..< 8 { + if current.domain == NSPOSIXErrorDomain, + current.code == Int(ENOSPC) || current.code == Int(EDQUOT) { + return .spaceInsufficient + } + if current.domain == NSCocoaErrorDomain, + current.code == CocoaError.Code.fileWriteOutOfSpace.rawValue { + return .spaceInsufficient + } + guard let underlying = current.userInfo[NSUnderlyingErrorKey] as? NSError else { break } + current = underlying + } + return .permanentFailure + } +} diff --git a/Sources/RadrootsKit/RadrootsAppleFileOperations.swift b/Sources/RadrootsKit/RadrootsAppleFileOperations.swift @@ -170,7 +170,7 @@ extension RadrootsAppleFileAccess { } catch let error as RadrootsDocumentInterchangeError { throw error } catch { - throw RadrootsAppleFileError.permanentFailure + throw RadrootsAppleFileError.classified(error) } } } diff --git a/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift b/Sources/RadrootsKit/RadrootsAppleMediaPicker.swift @@ -202,6 +202,8 @@ public final class RadrootsAppleMediaPicker: RadrootsMediaPicker, @unchecked Sen .transientFailure case .permanentFailure: .permanentFailure + case .spaceInsufficient: + .spaceInsufficient } } } diff --git a/Sources/RadrootsKit/RadrootsAtomicFile.swift b/Sources/RadrootsKit/RadrootsAtomicFile.swift @@ -5,7 +5,7 @@ import Foundation /// preserves the prior destination; an error after publication is ambiguous and /// callers must inspect/recover the exact destination before acknowledging it. enum RadrootsAtomicFile { - enum Mode { case replace, create } + enum Mode: Sendable { case replace, create } enum Phase: CaseIterable { case afterWriteChunk, afterWrite, beforeFileSync, beforeInstall, beforeDirectorySync } static let maximumBytes = 512 * 1024 * 1024 @@ -57,12 +57,16 @@ enum RadrootsAtomicFile { } if opened.0 >= 0 { descriptor = opened.0; break } if opened.1 == EWOULDBLOCK || !create && opened.1 == ENOENT { return nil } - guard create, opened.1 == ENOENT else { throw RadrootsAppleFileError.permanentFailure } + guard create, opened.1 == ENOENT else { throw RadrootsAppleFileError.posix(opened.1) } if attempt == 3 { throw RadrootsAppleFileError.transientFailure } } var value = stat() - guard Darwin.fstat(descriptor, &value) == 0, value.st_mode & S_IFMT == S_IFREG - else { + guard Darwin.fstat(descriptor, &value) == 0 else { + let failure = RadrootsAppleFileError.posix(errno) + Darwin.close(descriptor) + throw failure + } + guard value.st_mode & S_IFMT == S_IFREG else { Darwin.close(descriptor) throw RadrootsAppleFileError.permanentFailure } @@ -91,17 +95,18 @@ enum RadrootsAtomicFile { throw RadrootsAppleFileError.invalidRequest } var owned = stat() - guard Darwin.fstat(descriptor, &owned) == 0 else { throw RadrootsAppleFileError.permanentFailure } + guard Darwin.fstat(descriptor, &owned) == 0 else { throw RadrootsAppleFileError.posix(errno) } guard owned.st_mode & S_IFMT == S_IFREG, owned.st_size == 0 else { return false } try directory.validate() var current = stat() let found = name.withCString { Darwin.fstatat(directory.descriptor, $0, &current, AT_SYMLINK_NOFOLLOW) } if found != 0, errno == ENOENT { return false } - guard found == 0 else { throw RadrootsAppleFileError.permanentFailure } + guard found == 0 else { throw RadrootsAppleFileError.posix(errno) } guard current.st_mode & S_IFMT == S_IFREG, current.st_size == 0, current.st_dev == owned.st_dev, current.st_ino == owned.st_ino else { return false } - guard name.withCString({ Darwin.unlinkat(directory.descriptor, $0, 0) }) == 0, - Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.permanentFailure } + guard name.withCString({ Darwin.unlinkat(directory.descriptor, $0, 0) }) == 0 + else { throw RadrootsAppleFileError.posix(errno) } + guard Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } try directory.validate() return true } @@ -111,9 +116,10 @@ enum RadrootsAtomicFile { } static func installForTesting( - _ data: Data, at url: URL, mode: Mode = .replace, fault: @escaping (Phase) throws -> Void + _ data: Data, at url: URL, mode: Mode = .replace, readOnly: Bool = false, + fault: @escaping (Phase) throws -> Void ) throws { - try install(data, at: url, mode: mode, readOnly: false, fault: fault) + try install(data, at: url, mode: mode, readOnly: readOnly, fault: fault) } static func remove(at url: URL) throws { @@ -124,13 +130,13 @@ enum RadrootsAtomicFile { let directory = try Directory.open(Array(parts.dropLast()), create: false) defer { Darwin.close(directory.descriptor) } var value = stat() - guard leaf.withCString({ Darwin.fstatat(directory.descriptor, $0, &value, AT_SYMLINK_NOFOLLOW) }) == 0, - value.st_mode & S_IFMT == S_IFREG - else { throw RadrootsAppleFileError.permanentFailure } + guard leaf.withCString({ Darwin.fstatat(directory.descriptor, $0, &value, AT_SYMLINK_NOFOLLOW) }) == 0 + else { throw RadrootsAppleFileError.posix(errno) } + guard value.st_mode & S_IFMT == S_IFREG else { throw RadrootsAppleFileError.permanentFailure } try directory.validate() - guard leaf.withCString({ Darwin.unlinkat(directory.descriptor, $0, 0) }) == 0, - Darwin.fsync(directory.descriptor) == 0 - else { throw RadrootsAppleFileError.permanentFailure } + guard leaf.withCString({ Darwin.unlinkat(directory.descriptor, $0, 0) }) == 0 + else { throw RadrootsAppleFileError.posix(errno) } + guard Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } try directory.validate() } @@ -144,14 +150,17 @@ enum RadrootsAtomicFile { let descriptor = leaf.withCString { Darwin.openat(directory.descriptor, $0, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) } - guard descriptor >= 0 else { throw RadrootsAppleFileError.permanentFailure } + guard descriptor >= 0 else { throw RadrootsAppleFileError.posix(errno) } defer { Darwin.close(descriptor) } var before = stat() var after = stat() - guard Darwin.fstat(descriptor, &before) == 0, before.st_mode & S_IFMT == S_IFREG, - Darwin.fsync(descriptor) == 0, Darwin.fsync(directory.descriptor) == 0, - leaf.withCString({ Darwin.fstatat(directory.descriptor, $0, &after, AT_SYMLINK_NOFOLLOW) }) == 0, - before.st_dev == after.st_dev, before.st_ino == after.st_ino, + guard Darwin.fstat(descriptor, &before) == 0 else { throw RadrootsAppleFileError.posix(errno) } + guard before.st_mode & S_IFMT == S_IFREG else { throw RadrootsAppleFileError.permanentFailure } + guard Darwin.fsync(descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } + guard Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } + guard leaf.withCString({ Darwin.fstatat(directory.descriptor, $0, &after, AT_SYMLINK_NOFOLLOW) }) == 0 + else { throw RadrootsAppleFileError.posix(errno) } + guard before.st_dev == after.st_dev, before.st_ino == after.st_ino, before.st_size == after.st_size else { throw RadrootsAppleFileError.permanentFailure } try directory.validate() @@ -173,7 +182,7 @@ enum RadrootsAtomicFile { let descriptor = temporary.withCString { Darwin.openat(directory.descriptor, $0, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0o600) } - guard descriptor >= 0 else { throw RadrootsAppleFileError.permanentFailure } + guard descriptor >= 0 else { throw RadrootsAppleFileError.posix(errno) } defer { Darwin.close(descriptor) } // Keep interrupted files identifiable. Normal failure cleanup is safe; // abrupt process loss leaves the same reserved temporary prefix. @@ -181,10 +190,10 @@ enum RadrootsAtomicFile { try writeAll(data, to: descriptor, fault: fault) try fault?(.afterWrite) if readOnly, Darwin.fchmod(descriptor, 0o400) != 0 { - throw RadrootsAppleFileError.permanentFailure + throw RadrootsAppleFileError.posix(errno) } try fault?(.beforeFileSync) - guard Darwin.fsync(descriptor) == 0 else { throw RadrootsAppleFileError.permanentFailure } + guard Darwin.fsync(descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } try directory.validate() try fault?(.beforeInstall) try directory.validate() @@ -204,9 +213,9 @@ enum RadrootsAtomicFile { } } } - guard installed == 0 else { throw RadrootsAppleFileError.permanentFailure } + guard installed == 0 else { throw RadrootsAppleFileError.posix(errno) } try fault?(.beforeDirectorySync) - guard Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.permanentFailure } + guard Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } try directory.validate() } @@ -216,8 +225,10 @@ enum RadrootsAtomicFile { while offset < bytes.count { guard let base = bytes.baseAddress else { throw RadrootsAppleFileError.invalidRequest } let count = Darwin.write(descriptor, base.advanced(by: offset), min(64 * 1024, bytes.count - offset)) - if count < 0, errno == EINTR { - continue + if count < 0 { + let code = errno + if code == EINTR { continue } + throw RadrootsAppleFileError.posix(code) } guard count > 0 else { throw RadrootsAppleFileError.permanentFailure } offset += count @@ -232,7 +243,8 @@ enum RadrootsAtomicFile { init(_ descriptor: Int32) throws { var value = stat() - guard Darwin.fstat(descriptor, &value) == 0, value.st_mode & S_IFMT == S_IFDIR else { + guard Darwin.fstat(descriptor, &value) == 0 else { throw RadrootsAppleFileError.posix(errno) } + guard value.st_mode & S_IFMT == S_IFDIR else { throw RadrootsAppleFileError.permanentFailure } device = value.st_dev @@ -247,21 +259,21 @@ enum RadrootsAtomicFile { static func open(_ parts: [String], create: Bool) throws -> Self { var descriptor = Darwin.open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) - guard descriptor >= 0 else { throw RadrootsAppleFileError.permanentFailure } + guard descriptor >= 0 else { throw RadrootsAppleFileError.posix(errno) } do { var identities = try [Identity(descriptor)] for part in parts { if create { let result = part.withCString { Darwin.mkdirat(descriptor, $0, 0o700) } - guard result == 0 || errno == EEXIST else { throw RadrootsAppleFileError.permanentFailure } + guard result == 0 || errno == EEXIST else { throw RadrootsAppleFileError.posix(errno) } if result == 0, Darwin.fsync(descriptor) != 0 { - throw RadrootsAppleFileError.permanentFailure + throw RadrootsAppleFileError.posix(errno) } } let next = part.withCString { Darwin.openat(descriptor, $0, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) } - guard next >= 0 else { throw RadrootsAppleFileError.permanentFailure } + guard next >= 0 else { throw RadrootsAppleFileError.posix(errno) } Darwin.close(descriptor) descriptor = next try identities.append(Identity(descriptor)) diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift @@ -5,6 +5,10 @@ public enum RadrootsBackgroundTransferError: Error, Equatable, Sendable { case unavailable case transferFailure case persistenceFailure + /// Filesystem capacity is exhausted; existing effects still require reconciliation. + case spaceInsufficient + /// The bounded receipt envelope is full. This is distinct from free disk space. + case receiptCapacityExceeded } extension RadrootsBackgroundTransferError: LocalizedError { @@ -14,10 +18,19 @@ extension RadrootsBackgroundTransferError: LocalizedError { case .unavailable: "Background transfer is unavailable." case .transferFailure: "The background transfer could not be completed." case .persistenceFailure: "The background transfer state could not be saved." + case .spaceInsufficient: "There is not enough storage space to save the transfer state." + case .receiptCapacityExceeded: "The transfer receipt store has reached its capacity." } } } +extension RadrootsBackgroundTransferError { + static func persistence(_ error: any Error) -> Self { + if let typed = error as? Self, typed == .spaceInsufficient || typed == .receiptCapacityExceeded { return typed } + return RadrootsAppleFileError.classified(error) == .spaceInsufficient ? .spaceInsufficient : .persistenceFailure + } +} + public struct RadrootsBackgroundTransferIdentifier: Sendable, Equatable, Hashable, Comparable, Codable { diff --git a/Sources/RadrootsKit/RadrootsCaptureIntake.swift b/Sources/RadrootsKit/RadrootsCaptureIntake.swift @@ -7,6 +7,7 @@ public enum RadrootsCaptureIntakeError: Error, Equatable, Sendable { case userCancelled case transientFailure case permanentFailure + case spaceInsufficient } extension RadrootsCaptureIntakeError: LocalizedError { @@ -18,6 +19,7 @@ extension RadrootsCaptureIntakeError: LocalizedError { case .userCancelled: "Capture was cancelled." case .transientFailure: "Capture could not be completed temporarily." case .permanentFailure: "Capture could not be completed." + case .spaceInsufficient: "There is not enough storage space to save the capture." } } } diff --git a/Sources/RadrootsKit/RadrootsFilePersistence.swift b/Sources/RadrootsKit/RadrootsFilePersistence.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Immutable per-owner mechanics. Internal injection exercises real callers +/// without process-global faults or a public persistence bypass. +struct RadrootsFilePersistence: Sendable { + let install: @Sendable (Data, URL, RadrootsAtomicFile.Mode, Bool) throws -> Void + let synchronize: @Sendable (URL) throws -> Void + + static let live = Self( + install: { try RadrootsAtomicFile.install($0, at: $1, mode: $2, readOnly: $3) }, + synchronize: { try RadrootsAtomicFile.synchronizeExisting(at: $0) } + ) +} diff --git a/Sources/RadrootsKit/RadrootsStagedBlobLease.swift b/Sources/RadrootsKit/RadrootsStagedBlobLease.swift @@ -28,10 +28,9 @@ extension RadrootsAppleFileAccess { let url = try leaseURL(identifier) let lease = RadrootsStagedBlobLease(identifier: identifier, blob: blob, sha256: digest, fileURL: url) do { - try RadrootsAtomicFile.install(bytes, at: url, mode: .create, readOnly: true) + try persistence.install(bytes, url, .create, true) } catch { - try validateLease(lease) - try RadrootsAtomicFile.synchronizeExisting(at: url) + try recoverLease(lease, originalError: error) } try validateLease(lease) return lease @@ -49,25 +48,27 @@ extension RadrootsAppleFileAccess { guard data.count == reference.sizeBytes else { throw RadrootsAppleFileError.invalidRequest } let url = try roots.stagedBlobURL(for: reference) do { - try RadrootsAtomicFile.install(data, at: url, mode: .create) + try persistence.install(data, url, .create, false) } catch { let originalError = error + let existing: Data? do { - let existing = try readStagedBlob(reference) - if existing == data { - try RadrootsAtomicFile.synchronizeExisting(at: url) - return - } + existing = try readStagedBlob(reference) } catch RadrootsAppleFileError.notFound { throw originalError } catch { // Corrupt size/bytes may be repaired only by the exact content // identity below. Symlink traversal still fails in the writer. + existing = nil + } + if existing == data { + try persistence.synchronize(url) + return } guard RadrootsAppleFileDigest.sha256(data) == reference.blobID else { throw RadrootsAppleFileError.permanentFailure } - try RadrootsAtomicFile.install(data, at: url) + try persistence.install(data, url, .replace, false) } } @@ -86,7 +87,7 @@ extension RadrootsAppleFileAccess { let lease = RadrootsStagedBlobLease(identifier: identifier, blob: blob, sha256: expectedSHA256, fileURL: url) do { try validateLease(lease) - try RadrootsAtomicFile.synchronizeExisting(at: url) + try persistence.synchronize(url) return lease } catch RadrootsAppleFileError.notFound { // Only definitive absence admits creation; protected/corrupt or @@ -97,12 +98,11 @@ extension RadrootsAppleFileAccess { throw RadrootsAppleFileError.permanentFailure } do { - try RadrootsAtomicFile.install(bytes, at: url, mode: .create, readOnly: true) + try persistence.install(bytes, url, .create, true) } catch { // A competing identical admission or an ambiguous directory sync // can be recovered only by checking and flushing the exact lease. - try validateLease(lease) - try RadrootsAtomicFile.synchronizeExisting(at: url) + try recoverLease(lease, originalError: error) } try validateLease(lease) return lease @@ -126,6 +126,17 @@ extension RadrootsAppleFileAccess { .appendingPathComponent(identifier) } + private func recoverLease(_ lease: RadrootsStagedBlobLease, originalError: any Error) throws { + do { + try validateLease(lease) + } catch RadrootsAppleFileError.notFound { + throw originalError + } + // A matching file may have been installed despite the original error. + // Only a successful flush establishes a reusable durable lease. + try persistence.synchronize(lease.fileURL) + } + private func validateLease(_ lease: RadrootsStagedBlobLease) throws { let bytes: Data do { diff --git a/Tests/RadrootsKitTests/RadrootsFileCapacityTests.swift b/Tests/RadrootsKitTests/RadrootsFileCapacityTests.swift @@ -0,0 +1,143 @@ +import Darwin +import Foundation +@testable import RadrootsKit +import Testing + +@Test func fileCapacityClassificationIsBoundedTypedAndRedacted() throws { + for code in [ENOSPC, EDQUOT] { + let raw = NSError(domain: NSPOSIXErrorDomain, code: Int(code), userInfo: [NSFilePathErrorKey: "/private/value"]) + #expect(RadrootsAppleFileError.posix(code) == .spaceInsufficient) + #expect(RadrootsAppleFileError.classified(raw) == .spaceInsufficient) + let wrapped = NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.fileWriteUnknown.rawValue, + userInfo: [NSUnderlyingErrorKey: raw]) + #expect(RadrootsAppleFileError.classified(wrapped) == .spaceInsufficient) + #expect(RadrootsBackgroundTransferError.persistence(wrapped) == .spaceInsufficient) + } + #expect(RadrootsAppleFileError.classified(CocoaError(.fileWriteOutOfSpace)) == .spaceInsufficient) + #expect(RadrootsAppleFileError.classified(RadrootsAppleFileError.notFound) == .notFound) + #expect(RadrootsAppleFileError.posix(EIO) == .permanentFailure) + #expect(RadrootsAppleFileError.classified(NSError(domain: "unrelated", code: Int(ENOSPC))) == .permanentFailure) + var nested = NSError(domain: NSPOSIXErrorDomain, code: Int(ENOSPC)) + for _ in 0 ..< 8 { nested = NSError(domain: "wrapper", code: 0, userInfo: [NSUnderlyingErrorKey: nested]) } + #expect(RadrootsAppleFileError.classified(nested) == .permanentFailure) + #expect(RadrootsAppleFileError.spaceInsufficient.errorDescription == "There is not enough storage space to complete the file operation.") + #expect(String(describing: RadrootsAppleFileError.spaceInsufficient) == "spaceInsufficient") + #expect(RadrootsAppleMediaPicker.adapt(fileError: .spaceInsufficient) == .spaceInsufficient) + #expect(RadrootsAppleMediaPicker.adapt(error: RadrootsAppleFileError.spaceInsufficient) == .spaceInsufficient) + #expect(RadrootsCaptureIntakeError.spaceInsufficient.errorDescription == "There is not enough storage space to save the capture.") + #expect(RadrootsBackgroundTransferError.persistence(.receiptCapacityExceeded as RadrootsBackgroundTransferError) == .receiptCapacityExceeded) + #expect(RadrootsBackgroundTransferError.persistence(RadrootsBackgroundTransferError.invalidRequest) == .persistenceFailure) +} + +@Test func fileCapacityReachesActualFoundationAndLockCallers() throws { + let fixture = try CapacityFixture() + defer { fixture.remove() } + let access = RadrootsAppleFileAccess(roots: fixture.roots) + #expect(throws: RadrootsAppleFileError.spaceInsufficient) { + try access.classifiedFileSystemOperation { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOSPC)) + } + } + for code in [ENOSPC, EDQUOT] { + #expect(throws: RadrootsAppleFileError.spaceInsufficient) { + _ = try RadrootsAtomicFile.acquireExclusiveLock(at: fixture.base.appendingPathComponent("lock"), + injectedOpenError: { _ in code }) + } + } + #expect(!FileManager.default.fileExists(atPath: fixture.base.appendingPathComponent("lock").path)) + let directory = fixture.base.appendingPathComponent("directory") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + errno = ENOSPC + #expect(throws: RadrootsAppleFileError.permanentFailure) { + try RadrootsAtomicFile.synchronizeExisting(at: directory) + } +} + +@Test func fileCapacityPreservesOldOrAmbiguousBytesAtEveryInstallBoundary() throws { + for phase in RadrootsAtomicFile.Phase.allCases { + let fixture = try CapacityFixture() + defer { fixture.remove() } + let url = fixture.base.appendingPathComponent("value") + let old = Data("acknowledged".utf8), pending = Data(repeating: 42, count: 100_000) + try RadrootsAtomicFile.install(old, at: url) + #expect(throws: RadrootsAppleFileError.spaceInsufficient) { + try RadrootsAtomicFile.installForTesting(pending, at: url) { current in + if current == phase { throw RadrootsAppleFileError.posix(ENOSPC) } + } + } + #expect(try Data(contentsOf: url) == (phase == .beforeDirectorySync ? pending : old)) + try RadrootsAtomicFile.install(pending, at: url) + #expect(try Data(contentsOf: url) == pending) + } +} + +@Test func fileCapacityPreservesOriginalFailureAndSourceWhenLeaseIsAbsent() throws { + let fixture = try CapacityFixture() + defer { fixture.remove() } + let live = RadrootsAppleFileAccess(roots: fixture.roots) + let bytes = Data("pending upload".utf8), digest = RadrootsAppleFileDigest.sha256(bytes) + let blob = try live.stageBlob(bytes) + let access = RadrootsAppleFileAccess(roots: fixture.roots, persistence: capacityPersistence(.beforeInstall)) + #expect(throws: RadrootsAppleFileError.spaceInsufficient) { + _ = try access.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "pending") + } + #expect(throws: RadrootsAppleFileError.spaceInsufficient) { + _ = try access.leaseUploadBytes(bytes, identifier: "upload") + } + #expect(try live.readStagedBlob(blob) == bytes) + let lease = try live.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "pending") + #expect(try Data(contentsOf: lease.fileURL) == bytes) + let prior = try live.leaseUploadBytes(Data("other".utf8), identifier: "conflict") + #expect(throws: RadrootsAppleFileError.permanentFailure) { + _ = try access.leaseUploadBytes(bytes, identifier: "conflict") + } + #expect(try Data(contentsOf: prior.fileURL) == Data("other".utf8)) +} + +@Test func fileCapacityDoesNotSwallowMatchingStagedBlobOrLeaseSyncFailure() throws { + let fixture = try CapacityFixture() + defer { fixture.remove() } + let bytes = Data("same exact bytes".utf8), digest = RadrootsAppleFileDigest.sha256(bytes) + let blob = try RadrootsStagedBlobReference(blobID: "opaque", sizeBytes: bytes.count) + let faulted = RadrootsAppleFileAccess(roots: fixture.roots, + persistence: capacityPersistence(.beforeDirectorySync, failSync: true)) + #expect(throws: RadrootsAppleFileError.spaceInsufficient) { try faulted.installStagedBlob(bytes, reference: blob) } + let live = RadrootsAppleFileAccess(roots: fixture.roots) + #expect(try live.readStagedBlob(blob) == bytes) + #expect(throws: RadrootsAppleFileError.spaceInsufficient) { try faulted.installStagedBlob(bytes, reference: blob) } + #expect(throws: RadrootsAppleFileError.spaceInsufficient) { + _ = try faulted.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "ambiguous") + } + #expect(throws: RadrootsAppleFileError.spaceInsufficient) { + _ = try faulted.leaseUploadBytes(bytes, identifier: "ambiguous_upload") + } + let lease = try live.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "ambiguous") + #expect(try Data(contentsOf: lease.fileURL) == bytes) + #expect(throws: RadrootsAppleFileError.spaceInsufficient) { + _ = try faulted.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "ambiguous") + } + try live.installStagedBlob(bytes, reference: blob) + let recovered = RadrootsAppleFileAccess(roots: fixture.roots, persistence: capacityPersistence(.beforeDirectorySync)) + let exact = try recovered.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "reflushed") + #expect(try Data(contentsOf: exact.fileURL) == bytes) + let attributes = try FileManager.default.attributesOfItem(atPath: exact.fileURL.path) + #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o400) +} + +func capacityPersistence(_ phase: RadrootsAtomicFile.Phase, failSync: Bool = false) -> RadrootsFilePersistence { + RadrootsFilePersistence(install: { bytes, url, mode, readOnly in + try RadrootsAtomicFile.installForTesting(bytes, at: url, mode: mode, readOnly: readOnly) { current in + if current == phase { throw RadrootsAppleFileError.posix(ENOSPC) } + } + }, synchronize: { url in + if failSync { throw RadrootsAppleFileError.posix(EDQUOT) } + try RadrootsAtomicFile.synchronizeExisting(at: url) + }) +} + +struct CapacityFixture { + let roots: RadrootsAppleFileRoots + var base: URL { roots.dataRoot.deletingLastPathComponent() } + init() throws { roots = try appleTransferRoots() } + func remove() { try? FileManager.default.removeItem(at: base) } +} diff --git a/Tests/RadrootsKitTests/RadrootsTransferCapacityTests.swift b/Tests/RadrootsKitTests/RadrootsTransferCapacityTests.swift @@ -0,0 +1,148 @@ +import Darwin +import Foundation +@testable import RadrootsKit +import Testing + +@Test func transferCapacityRetainsPriorOrAmbiguousReceiptWithoutSuccess() async throws { + for phase in [RadrootsAtomicFile.Phase.beforeInstall, .beforeDirectorySync] { + let fixture = try CapacityFixture() + defer { fixture.remove() } + let live = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let request = try appleUploadRequest(identifier: "original") + let original = try RadrootsBackgroundTransferSnapshot(request: request, state: .running, executionID: UUID()) + try await live.saveSnapshot(original) + let receipt = try RadrootsBackgroundTransferSnapshot(request: request, state: .awaitingVerification, + response: RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil), + executionID: original.executionID) + let faulted = RadrootsAppleBackgroundTransferStore(roots: fixture.roots, persistence: capacityPersistence(phase)) + await #expect(throws: RadrootsBackgroundTransferError.spaceInsufficient) { + _ = try await faulted.compareExchangeSnapshot(expected: original, desired: receipt) + } + let reopened = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let observed = try #require(try await reopened.loadSnapshots().first) + #expect(observed == (phase == .beforeDirectorySync ? receipt : original)) + if observed == original { + #expect(try await reopened.compareExchangeSnapshot(expected: original, desired: receipt)) + } else { + #expect(try await !reopened.compareExchangeSnapshot(expected: original, desired: receipt)) + } + #expect(try await reopened.loadSnapshots() == [receipt]) + } +} + +@Test func transferCapacityFailureDoesNotEnqueueOrReidentifyOriginalRequest() async throws { + let fixture = try CapacityFixture() + defer { fixture.remove() } + let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots, persistence: capacityPersistence(.beforeInstall)) + let probe = RadrootsAppleBackgroundTransferProbe() + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) + let request = try appleUploadRequest(identifier: "original_request") + await #expect(throws: RadrootsBackgroundTransferError.spaceInsufficient) { _ = try await transfer.enqueue(request) } + #expect(await probe.enqueuedRequests.isEmpty) + #expect(try await store.loadSnapshots().isEmpty) + let live = RadrootsAppleBackgroundTransfer(store: RadrootsAppleBackgroundTransferStore(roots: fixture.roots), + adapters: probe.adapters()) + let handle = try await live.enqueue(request) + #expect(handle.identifier == request.identifier) + #expect(await probe.enqueuedRequests == [request]) +} + +@Test func transferCapacityFromUploadLeaseReachesCallerAndRetainsAttempt() async throws { + let fixture = try CapacityFixture() + defer { fixture.remove() } + let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let adapters = RadrootsAppleBackgroundTransferAdapters(enqueue: { _, _ in + throw RadrootsAppleFileError.spaceInsufficient + }, cancel: { _ in }, activeTransferIdentifiers: { [] }, handleBackgroundEvents: { _, done in done() }) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: adapters) + let request = try appleUploadRequest(identifier: "lease_failure") + await #expect(throws: RadrootsBackgroundTransferError.spaceInsufficient) { _ = try await transfer.enqueue(request) } + let retained = try #require(try await store.loadSnapshots().first) + #expect(retained.identifier == request.identifier) + #expect(retained.executionID != nil) + #expect(retained.state == .failed) + #expect(retained.possibleRemoteOrphan) +} + +@Test func transferReceiptQuotaRetainsEveryPriorReceiptAndIsNotDiskCapacity() async throws { + let fixture = try CapacityFixture() + defer { fixture.remove() } + let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let body = try JSONSerialization.data(withJSONObject: ["padding": String(repeating: "a", count: 65000)]) + var retained: [RadrootsBackgroundTransferSnapshot] = [] + var refused = false + for index in 0 ..< 16 { + let request = try appleUploadRequest(identifier: "receipt_\(index)", responsePolicy: .boundedJSON(maximumBodyBytes: 65536)) + let receipt = try RadrootsBackgroundTransferSnapshot(request: request, state: .awaitingVerification, + response: RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: "application/json", body: body), + executionID: UUID()) + do { + try await store.saveSnapshot(receipt) + retained.append(receipt) + } catch let error as RadrootsBackgroundTransferError { + #expect(error == .receiptCapacityExceeded) + refused = true + break + } + } + #expect(refused) + #expect(!retained.isEmpty) + let reopened = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + #expect(try await reopened.loadSnapshots() == retained.sorted { $0.identifier < $1.identifier }) + // Cache purge cannot authorize deleting unresolved receipts or claim the + // envelope quota has become available. + try RadrootsAppleFileAccess(roots: fixture.roots).reset(scope: .cache) + #expect(try await reopened.loadSnapshots() == retained.sorted { $0.identifier < $1.identifier }) +} + +@Test func transferCapacityRetainsPendingCallbackAndDefersAcknowledgement() async throws { + let fixture = try CapacityFixture() + defer { fixture.remove() } + let gate = CapacityFaultGate() + defer { gate.enabled = false } + let persistence = RadrootsFilePersistence(install: { bytes, url, mode, readOnly in + try RadrootsAtomicFile.installForTesting(bytes, at: url, mode: mode, readOnly: readOnly) { phase in + if phase == .beforeInstall, gate.enabled { throw RadrootsAppleFileError.spaceInsufficient } + } + }, synchronize: { try RadrootsAtomicFile.synchronizeExisting(at: $0) }) + let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots, persistence: persistence) + let request = try appleUploadRequest(identifier: "pending_receipt") + let original = try RadrootsBackgroundTransferSnapshot(request: request, state: .running) + try await store.saveSnapshot(original) + gate.enabled = true + let coordinator = RadrootsTransferCoordinator(sessionIdentifier: "capacity.test", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: fixture.roots)) + let pending = Task { + await coordinator.complete(identifier: request.identifier, + completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, + httpResult: successfulHTTPResult(), bytesTransferred: 10, totalBytesExpected: 10)) + } + for _ in 0 ..< 100 { + if await coordinator.hasPendingReceipts { break } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(await coordinator.hasPendingReceipts) + let completion = RadrootsCompletionProbe() + await coordinator.handleBackgroundEvents(identifier: "capacity.test") { completion.markCompleted() } + await coordinator.finishBackgroundEvents(identifier: "capacity.test") + #expect(!completion.completed) + #expect(try await store.loadSnapshots() == [original]) + gate.enabled = false + await pending.value + #expect(completion.completionCount == 1) + let reopened = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let receipt = try #require(try await reopened.loadSnapshots().first) + #expect(receipt.identifier == request.identifier) + #expect(receipt.state == .awaitingVerification) + #expect(receipt.response?.statusCode == 200) +} + +/// The test mutates one Boolean across actor calls; every access holds this lock. +private final class CapacityFaultGate: @unchecked Sendable { + private let lock = NSLock() + private var value = false + var enabled: Bool { + get { lock.lock(); defer { lock.unlock() }; return value } + set { lock.lock(); value = newValue; lock.unlock() } + } +} diff --git a/contracts/api_baselines/apple_kit.txt b/contracts/api_baselines/apple_kit.txt @@ -1130,6 +1130,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 58:s:11RadrootsKit0A22PreparedExportDocumentV9mediaTypeSSSgvp 42:s:11RadrootsKit0A22PreparedExportDocumentV 0: 12:relationship 11:RadrootsKit 8:memberOf 58:s:11RadrootsKit0A28AppleExternalActionsAdaptersV4liveACvpZ 48:s:11RadrootsKit0A28AppleExternalActionsAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 58:s:11RadrootsKit0A28AppleLoggerTelemetryAdaptersV4liveACvpZ 48:s:11RadrootsKit0A28AppleLoggerTelemetryAdaptersV 0: +12:relationship 11:RadrootsKit 8:memberOf 59:s:11RadrootsKit0A14AppleFileErrorO17spaceInsufficientyA2CmF 34:s:11RadrootsKit0A14AppleFileErrorO 0: 12:relationship 11:RadrootsKit 8:memberOf 59:s:11RadrootsKit0A14TelemetryEventV6fieldsSayAA0aC5FieldVGvp 34:s:11RadrootsKit0A14TelemetryEventV 0: 12:relationship 11:RadrootsKit 8:memberOf 59:s:11RadrootsKit0A15StagedBlobLeaseV4blobAA0acD9ReferenceVvp 35:s:11RadrootsKit0A15StagedBlobLeaseV 0: 12:relationship 11:RadrootsKit 8:memberOf 59:s:11RadrootsKit0A16ImportedDocumentV17suggestedFilenameSSvp 36:s:11RadrootsKit0A16ImportedDocumentV 0: @@ -1239,6 +1240,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 63:s:11RadrootsKit0A14SecureStoreKeyV11serviceName0F6PrefixS2S_tKF 34:s:11RadrootsKit0A14SecureStoreKeyV 0: 12:relationship 11:RadrootsKit 8:memberOf 63:s:11RadrootsKit0A15LocationReadingV20speedMetersPerSecondSdSgvp 35:s:11RadrootsKit0A15LocationReadingV 0: 12:relationship 11:RadrootsKit 8:memberOf 63:s:11RadrootsKit0A16ImportedDocumentV4fileAA0A13FileReferenceVvp 36:s:11RadrootsKit0A16ImportedDocumentV 0: +12:relationship 11:RadrootsKit 8:memberOf 63:s:11RadrootsKit0A18CaptureIntakeErrorO17spaceInsufficientyA2CmF 38:s:11RadrootsKit0A18CaptureIntakeErrorO 0: 12:relationship 11:RadrootsKit 8:memberOf 63:s:11RadrootsKit0A18UserPresenceStatusV7supportAA0acD7SupportOvp 38:s:11RadrootsKit0A18UserPresenceStatusV 0: 12:relationship 11:RadrootsKit 8:memberOf 63:s:11RadrootsKit0A19BackgroundTaskErrorO16errorDescriptionSSSgvp 39:s:11RadrootsKit0A19BackgroundTaskErrorO 0: 12:relationship 11:RadrootsKit 8:memberOf 63:s:11RadrootsKit0A19BackgroundTaskErrorO16schedulerFailureyA2CmF 39:s:11RadrootsKit0A19BackgroundTaskErrorO 0: @@ -1380,6 +1382,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A21LocationAuthorizationO19authorizedWhenInUseyA2CmF 41:s:11RadrootsKit0A21LocationAuthorizationO 0: 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A22PreparedExportDocumentV7fileURL10Foundation0G0Vvp 42:s:11RadrootsKit0A22PreparedExportDocumentV 0: 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A23AppLocalStateResetErrorO17fileSystemFailureyA2CmF 43:s:11RadrootsKit0A23AppLocalStateResetErrorO 0: +12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A23BackgroundTransferErrorO17spaceInsufficientyA2CmF 43:s:11RadrootsKit0A23BackgroundTransferErrorO 0: 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A24AppleKeychainSecureStoreC13servicePrefixACSS_tcfc 44:s:11RadrootsKit0A24AppleKeychainSecureStoreC 0: 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A24BackgroundTransferHandleV4fromACs7Decoder_p_tKcfc 44:s:11RadrootsKit0A24BackgroundTransferHandleV 0: 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A24DocumentInterchangeErrorO16errorDescriptionSSSgvp 44:s:11RadrootsKit0A24DocumentInterchangeErrorO 0: @@ -1507,6 +1510,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 74:s:11RadrootsKit0A18OpaqueSignerBridgeC7custodyAcA0A15IdentityCustodyC_tcfc 38:s:11RadrootsKit0A18OpaqueSignerBridgeC 0: 12:relationship 11:RadrootsKit 8:memberOf 74:s:11RadrootsKit0A21BackgroundTaskRequestV10identifierAA0acD10IdentifierVvp 41:s:11RadrootsKit0A21BackgroundTaskRequestV 0: 12:relationship 11:RadrootsKit 8:memberOf 74:s:11RadrootsKit0A21BackgroundTaskRequestV27requiresNetworkConnectivitySbvp 41:s:11RadrootsKit0A21BackgroundTaskRequestV 0: +12:relationship 11:RadrootsKit 8:memberOf 74:s:11RadrootsKit0A23BackgroundTransferErrorO23receiptCapacityExceededyA2CmF 43:s:11RadrootsKit0A23BackgroundTransferErrorO 0: 12:relationship 11:RadrootsKit 8:memberOf 74:s:11RadrootsKit0A25BackgroundTransferRequestV9operationAA0acD9OperationOvp 45:s:11RadrootsKit0A25BackgroundTransferRequestV 0: 12:relationship 11:RadrootsKit 8:memberOf 74:s:11RadrootsKit0A26BackgroundTransferProgressV16bytesTransferreds5Int64Vvp 46:s:11RadrootsKit0A26BackgroundTransferProgressV 0: 12:relationship 11:RadrootsKit 8:memberOf 74:s:11RadrootsKit0A27VerifiedArtifactAccessErrorO19artifactUnavailableyA2CmF 47:s:11RadrootsKit0A27VerifiedArtifactAccessErrorO 0: @@ -2818,6 +2822,7 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 15:swift.enum.case 58:s:11RadrootsKit0A19BackgroundTaskErrorO11unavailableyA2CmF 39:RadrootsBackgroundTaskError.unavailable 16:case unavailable 6:symbol 11:RadrootsKit 15:swift.enum.case 58:s:11RadrootsKit0A19ExternalActionErrorO11unavailableyA2CmF 39:RadrootsExternalActionError.unavailable 16:case unavailable 6:symbol 11:RadrootsKit 15:swift.enum.case 58:s:11RadrootsKit0A20FileMaintenanceEntryV4KindO5otheryA2EmF 39:RadrootsFileMaintenanceEntry.Kind.other 10:case other +6:symbol 11:RadrootsKit 15:swift.enum.case 59:s:11RadrootsKit0A14AppleFileErrorO17spaceInsufficientyA2CmF 40:RadrootsAppleFileError.spaceInsufficient 22:case spaceInsufficient 6:symbol 11:RadrootsKit 15:swift.enum.case 59:s:11RadrootsKit0A17UserPresenceErrorO14invalidRequestyA2CmF 40:RadrootsUserPresenceError.invalidRequest 19:case invalidRequest 6:symbol 11:RadrootsKit 15:swift.enum.case 59:s:11RadrootsKit0A18AppleSecurityErrorO13userCancelledyA2CmF 40:RadrootsAppleSecurityError.userCancelled 18:case userCancelled 6:symbol 11:RadrootsKit 15:swift.enum.case 59:s:11RadrootsKit0A18CaptureIntakeErrorO13userCancelledyA2CmF 40:RadrootsCaptureIntakeError.userCancelled 18:case userCancelled @@ -2856,6 +2861,7 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 15:swift.enum.case 62:s:11RadrootsKit0A21LocationAuthorizationO13notDeterminedyA2CmF 43:RadrootsLocationAuthorization.notDetermined 18:case notDetermined 6:symbol 11:RadrootsKit 15:swift.enum.case 62:s:11RadrootsKit0A23BackgroundTransferErrorO11unavailableyA2CmF 43:RadrootsBackgroundTransferError.unavailable 16:case unavailable 6:symbol 11:RadrootsKit 15:swift.enum.case 62:s:11RadrootsKit0A23BackgroundTransferStateO11interruptedyA2CmF 43:RadrootsBackgroundTransferState.interrupted 16:case interrupted +6:symbol 11:RadrootsKit 15:swift.enum.case 63:s:11RadrootsKit0A18CaptureIntakeErrorO17spaceInsufficientyA2CmF 44:RadrootsCaptureIntakeError.spaceInsufficient 22:case spaceInsufficient 6:symbol 11:RadrootsKit 15:swift.enum.case 63:s:11RadrootsKit0A19BackgroundTaskErrorO16schedulerFailureyA2CmF 44:RadrootsBackgroundTaskError.schedulerFailure 21:case schedulerFailure 6:symbol 11:RadrootsKit 15:swift.enum.case 63:s:11RadrootsKit0A19ExternalActionErrorO16permanentFailureyA2CmF 44:RadrootsExternalActionError.permanentFailure 21:case permanentFailure 6:symbol 11:RadrootsKit 15:swift.enum.case 63:s:11RadrootsKit0A19ExternalActionErrorO16transientFailureyA2CmF 44:RadrootsExternalActionError.transientFailure 21:case transientFailure @@ -2906,6 +2912,7 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 15:swift.enum.case 68:s:11RadrootsKit0A20IdentityCustodyErrorO20userPresenceRequiredyA2CmF 49:RadrootsIdentityCustodyError.userPresenceRequired 25:case userPresenceRequired 6:symbol 11:RadrootsKit 15:swift.enum.case 68:s:11RadrootsKit0A21LocationAuthorizationO19authorizedWhenInUseyA2CmF 49:RadrootsLocationAuthorization.authorizedWhenInUse 24:case authorizedWhenInUse 6:symbol 11:RadrootsKit 15:swift.enum.case 68:s:11RadrootsKit0A23AppLocalStateResetErrorO17fileSystemFailureyA2CmF 49:RadrootsAppLocalStateResetError.fileSystemFailure 22:case fileSystemFailure +6:symbol 11:RadrootsKit 15:swift.enum.case 68:s:11RadrootsKit0A23BackgroundTransferErrorO17spaceInsufficientyA2CmF 49:RadrootsBackgroundTransferError.spaceInsufficient 22:case spaceInsufficient 6:symbol 11:RadrootsKit 15:swift.enum.case 68:s:11RadrootsKit0A24DocumentInterchangeErrorO16permanentFailureyA2CmF 49:RadrootsDocumentInterchangeError.permanentFailure 21:case permanentFailure 6:symbol 11:RadrootsKit 15:swift.enum.case 68:s:11RadrootsKit0A24DocumentInterchangeErrorO16permissionDeniedyA2CmF 49:RadrootsDocumentInterchangeError.permissionDenied 21:case permissionDenied 6:symbol 11:RadrootsKit 15:swift.enum.case 68:s:11RadrootsKit0A24DocumentInterchangeErrorO16transientFailureyA2CmF 49:RadrootsDocumentInterchangeError.transientFailure 21:case transientFailure @@ -2933,6 +2940,7 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 15:swift.enum.case 73:s:11RadrootsKit0A11FilePayloadO10stagedBlobyAcA0a6StagedF9ReferenceVcACmF 34:RadrootsFilePayload.stagedBlob(_:) 44:case stagedBlob(RadrootsStagedBlobReference) 6:symbol 11:RadrootsKit 15:swift.enum.case 73:s:11RadrootsKit0A21AppleMobileStoreErrorO24protectedDataUnavailableyA2CmF 54:RadrootsAppleMobileStoreError.protectedDataUnavailable 29:case protectedDataUnavailable 6:symbol 11:RadrootsKit 15:swift.enum.case 73:s:11RadrootsKit0A25BackgroundTransferFailureO20verificationRejectedyA2CmF 54:RadrootsBackgroundTransferFailure.verificationRejected 25:case verificationRejected +6:symbol 11:RadrootsKit 15:swift.enum.case 74:s:11RadrootsKit0A23BackgroundTransferErrorO23receiptCapacityExceededyA2CmF 55:RadrootsBackgroundTransferError.receiptCapacityExceeded 28:case receiptCapacityExceeded 6:symbol 11:RadrootsKit 15:swift.enum.case 74:s:11RadrootsKit0A27VerifiedArtifactAccessErrorO19artifactUnavailableyA2CmF 55:RadrootsVerifiedArtifactAccessError.artifactUnavailable 24:case artifactUnavailable 6:symbol 11:RadrootsKit 15:swift.enum.case 75:s:11RadrootsKit0A17ShareTransferItemV7PayloadO3urlyAE10Foundation3URLVcAEmF 41:RadrootsShareTransferItem.Payload.url(_:) 13:case url(URL) 6:symbol 11:RadrootsKit 15:swift.enum.case 75:s:11RadrootsKit0A19UserPresenceSupportO28biometricsOrDeviceCredentialyA2CmF 56:RadrootsUserPresenceSupport.biometricsOrDeviceCredential 33:case biometricsOrDeviceCredential