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 6b88be1850529477e6192e10bd0666444d74bb1c
parent 1dc3ca9d212c22e5a1ba7196f9fce858c18fbe36
Author: triesap <tyson@radroots.org>
Date:   Tue, 15 Sep 2026 01:11:03 +0000

transfer: reserve durable native execution identity

- Reserve each attempt across awaits and atomically persist redacted state
- Bind immutable upload leases and callbacks to recoverable execution UUIDs
- Preserve terminal receipts and ambiguous effects across cancellation and restart
- Verify independent owners, exact API and complete native test suites

Diffstat:
MSources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift | 1259+++++++++++++------------------------------------------------------------------
ASources/RadrootsKit/RadrootsAppleBackgroundTransferAdapters.swift | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsAppleBackgroundTransferCoordinator.swift | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsAppleBackgroundTransferFileResolver.swift | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsAppleBackgroundTransferStore.swift | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsAppleBackgroundURLSession.swift | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsAppleBackgroundURLSessionDelegate.swift | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsAtomicFile.swift | 47+++++++++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsBackgroundTransfer.swift | 1069+++++++++----------------------------------------------------------------------
ASources/RadrootsKit/RadrootsBackgroundTransferRequest.swift | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsBackgroundTransferResponse.swift | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsBackgroundTransferSnapshot.swift | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsBackgroundTransferTransition.swift | 17+++++++++++++++++
ASources/RadrootsKit/RadrootsBackgroundTransferValidation.swift | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsBackgroundTransferValues.swift | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsBackgroundURLTaskDescriptor.swift | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsStagedBlobLease.swift | 27+++++++++++++++++++++++++--
ASources/RadrootsKit/RadrootsTransferCompletion.swift | 9+++++++++
MSources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift | 365++++++++++++++++++++++++++++++++++++++++++-------------------------------------
MTests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift | 1000++++++++++++++++++-------------------------------------------------------------
MTests/RadrootsKitTests/RadrootsAppleErrorSafetyTests.swift | 289+++++++++++++++++++++++++++++++++++++++++++------------------------------------
ATests/RadrootsKitTests/RadrootsBackgroundTransferReceiptTests.swift | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsBackgroundTransferRecoveryTests.swift | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsBackgroundTransferTestSupport.swift | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsNativeAdmissionTests.swift | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsNativeUploadLeaseTests.swift | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/api_baselines/apple_kit.txt | 42++++++++++++++++++++++++++++++++++++------
27 files changed, 4057 insertions(+), 3093 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift @@ -1,314 +1,47 @@ import Foundation -public struct RadrootsAppleBackgroundTransferAdapters: Sendable { - public let now: @Sendable () -> Date - public let enqueue: @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void - public let cancel: @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void - public let activeTransferIdentifiers: @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier> - public let handleBackgroundEvents: @Sendable (String, @escaping @Sendable () -> Void) async -> Void - - public init( - now: @escaping @Sendable () -> Date = Date.init, - enqueue: @escaping @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void, - cancel: @escaping @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void, - activeTransferIdentifiers: - @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>, - handleBackgroundEvents: - @escaping @Sendable (String, @escaping @Sendable () -> Void) async -> Void - ) { - self.now = now - self.enqueue = enqueue - self.cancel = cancel - self.activeTransferIdentifiers = activeTransferIdentifiers - self.handleBackgroundEvents = handleBackgroundEvents - } - - public static let unavailable = Self( - enqueue: { _ in - throw RadrootsBackgroundTransferError.unavailable - }, - cancel: { _ in - throw RadrootsBackgroundTransferError.unavailable - }, - activeTransferIdentifiers: { - throw RadrootsBackgroundTransferError.unavailable - }, handleBackgroundEvents: { _, completionHandler in completionHandler() } - ) - - public static func live( - sessionIdentifier: String, store: any RadrootsBackgroundTransferStore, - fileResolver: any RadrootsBackgroundTransferFileResolver, - downloadStagingRoot: URL, now: @escaping @Sendable () -> Date = Date.init - ) throws -> Self { - #if os(iOS) - let normalizedSessionIdentifier = - try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier) - let session = RadrootsAppleBackgroundURLSession( - identifier: normalizedSessionIdentifier, store: store, fileResolver: fileResolver, - downloadStagingRoot: downloadStagingRoot, - now: now - ) - #if targetEnvironment(simulator) - let simulatorSession = RadrootsAppleBackgroundURLSession( - identifier: normalizedSessionIdentifier, store: store, fileResolver: fileResolver, - downloadStagingRoot: downloadStagingRoot, - now: now, usesForegroundSession: true - ) - #endif - return Self( - now: now, - enqueue: { request in - #if targetEnvironment(simulator) - if request.networkPolicy == .simulatorLoopbackHTTP { - try await simulatorSession.enqueue(request) - return - } - #endif - try await session.enqueue(request) - }, - cancel: { identifier in - #if targetEnvironment(simulator) - await simulatorSession.cancel(identifier) - #endif - await session.cancel(identifier) - }, - activeTransferIdentifiers: { - var identifiers = await session.activeTransferIdentifiers() - #if targetEnvironment(simulator) - identifiers.formUnion(await simulatorSession.activeTransferIdentifiers()) - #endif - return identifiers - }, - handleBackgroundEvents: { identifier, completionHandler in - await session.handleBackgroundEvents( - identifier: identifier, completionHandler: completionHandler) - } - ) - #else - return .unavailable - #endif - } -} - public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { - private let store: any RadrootsBackgroundTransferStore - private let adapters: RadrootsAppleBackgroundTransferAdapters - - public init( - store: any RadrootsBackgroundTransferStore, adapters: RadrootsAppleBackgroundTransferAdapters - ) { - self.store = store - self.adapters = adapters - } - - public init(roots: RadrootsAppleFileRoots, sessionIdentifier: String) throws { - let store = RadrootsAppleBackgroundTransferStore(roots: roots) - let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) - let downloadStagingRoot = try roots.resolvedURL( - for: RadrootsFileReference( - scope: .temporary, - relativePath: - "background_transfers/\(RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier))/downloads" - ), - allowRootDirectory: true - ) - self.store = store - adapters = try .live( - sessionIdentifier: sessionIdentifier, store: store, fileResolver: resolver, - downloadStagingRoot: downloadStagingRoot - ) - } - - public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws - -> RadrootsBackgroundTransferHandle - { - guard - try await loadStoredSnapshots().contains(where: { $0.identifier == request.identifier }) - == false - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - return try await schedule(request) - } - - public func retry(_ request: RadrootsBackgroundTransferRequest) async throws - -> RadrootsBackgroundTransferHandle - { - guard - let existing = try await loadStoredSnapshots().first(where: { - $0.identifier == request.identifier - }), - existing.state == .failed || existing.state == .interrupted || existing.state == .cancelled - || existing.state == .expired, - try existing.request.redactedForPersistence() == request.redactedForPersistence() - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - do { - guard try await !adapters.activeTransferIdentifiers().contains(request.identifier) else { - throw RadrootsBackgroundTransferError.invalidRequest - } - } catch let error as RadrootsBackgroundTransferError { - throw error - } catch { - throw RadrootsBackgroundTransferError.transferFailure - } - return try await schedule(request) - } + private let store: any RadrootsBackgroundTransferStore + private let adapters: RadrootsAppleBackgroundTransferAdapters + private var admissions: Set<RadrootsBackgroundTransferIdentifier> = [] + private var stoppedAdmissions: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferState] = [:] - private func schedule(_ request: RadrootsBackgroundTransferRequest) async throws - -> RadrootsBackgroundTransferHandle - { - try await saveStoredSnapshot( - RadrootsBackgroundTransferSnapshot( - request: request, state: .queued, updatedAt: adapters.now())) - do { try await adapters.enqueue(request) } catch { - do { - try await saveStoredSnapshot( - RadrootsBackgroundTransferSnapshot( - request: request, state: .failed, failure: .enqueueFailed, - updatedAt: adapters.now() - ) - ) - } catch { - throw RadrootsBackgroundTransferError.persistenceFailure - } - throw RadrootsBackgroundTransferError.transferFailure - } - let current = try await loadStoredSnapshots().first { $0.identifier == request.identifier } - if current?.state == .queued { - try await saveStoredSnapshot( - RadrootsBackgroundTransferSnapshot( - request: request, state: .running, updatedAt: adapters.now()) - ) - } - return RadrootsBackgroundTransferHandle(request: request) - } - - public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { - if let existing = try await loadStoredSnapshots().first(where: { $0.identifier == identifier }) { - guard - existing.state == .queued || existing.state == .running || existing.state == .interrupted - else { return } - try await saveStoredSnapshot( - RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .cancelled, progress: existing.progress, - possibleRemoteOrphan: existing.request.isUpload && existing.state == .running, - updatedAt: adapters.now() - ) - ) + public init(store: any RadrootsBackgroundTransferStore, adapters: RadrootsAppleBackgroundTransferAdapters) { + self.store = store + self.adapters = adapters } - do { try await adapters.cancel(identifier) } catch { - throw RadrootsBackgroundTransferError.transferFailure - } - } - public func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { - if let existing = try await loadStoredSnapshots().first(where: { $0.identifier == identifier }) { - guard - existing.state == .queued || existing.state == .running || existing.state == .interrupted - else { return } - try await saveStoredSnapshot( - RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .expired, progress: existing.progress, - failure: .expired, - possibleRemoteOrphan: existing.request.isUpload && existing.state == .running, - updatedAt: adapters.now() - ) - ) + public init(roots: RadrootsAppleFileRoots, sessionIdentifier: String) throws { + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) + let name = try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier) + let downloadRoot = try roots.resolvedURL(for: RadrootsFileReference( + scope: .temporary, relativePath: "background_transfers/\(name)/downloads" + ), allowRootDirectory: true) + self.store = store + adapters = try .live(sessionIdentifier: name, store: store, fileResolver: resolver, + downloadStagingRoot: downloadRoot) } - do { try await adapters.cancel(identifier) } catch { - throw RadrootsBackgroundTransferError.transferFailure - } - } - public func settle( - _ identifier: RadrootsBackgroundTransferIdentifier, - verification: RadrootsBackgroundTransferVerification - ) async throws { - guard - let existing = try await loadStoredSnapshots().first(where: { $0.identifier == identifier }), - existing.state == .awaitingVerification - else { - throw RadrootsBackgroundTransferError.invalidRequest + public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle { + try reserve(request.identifier) + defer { release(request.identifier) } + return try await admission(request, retry: false) } - switch verification { - case .accepted: - try await saveStoredSnapshot( - RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .completed, progress: existing.progress, - response: existing.response, - downloadedArtifact: existing.downloadedArtifact, updatedAt: adapters.now() - ) - ) - case .rejected(let failure): - try await saveStoredSnapshot( - RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .failed, progress: existing.progress, - failure: failure, - possibleRemoteOrphan: existing.request.isUpload, updatedAt: adapters.now() - ) - ) - } - } - public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws - -> RadrootsBackgroundTransferSnapshot? - { - try await snapshots().first { $0.identifier == identifier } - } - - public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - let activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier> - do { activeIdentifiers = try await adapters.activeTransferIdentifiers() } catch { - throw RadrootsBackgroundTransferError.transferFailure - } - let storedSnapshots = try await loadStoredSnapshots() - let storedIdentifiers = Set(storedSnapshots.map(\.identifier)) - for orphanedIdentifier in activeIdentifiers.subtracting(storedIdentifiers) { - do { try await adapters.cancel(orphanedIdentifier) } catch { - throw RadrootsBackgroundTransferError.transferFailure - } - } - var reconciled: [RadrootsBackgroundTransferSnapshot] = [] - for snapshot in storedSnapshots { - if activeIdentifiers.contains(snapshot.identifier), - snapshot.state == .queued || snapshot.state == .interrupted - { - let runningSnapshot = try RadrootsBackgroundTransferSnapshot( - request: snapshot.request, state: .running, progress: snapshot.progress, - failure: snapshot.failure, - updatedAt: adapters.now() - ) - try await saveStoredSnapshot(runningSnapshot) - reconciled.append(runningSnapshot) - } else if !activeIdentifiers.contains(snapshot.identifier), - snapshot.state == .queued || snapshot.state == .running - { - let interruptedSnapshot = try RadrootsBackgroundTransferSnapshot( - request: snapshot.request, state: .interrupted, progress: snapshot.progress, - failure: .interrupted, - possibleRemoteOrphan: snapshot.request.isUpload && snapshot.state == .running, - updatedAt: adapters.now() - ) - try await saveStoredSnapshot(interruptedSnapshot) - reconciled.append(interruptedSnapshot) - } else { - reconciled.append(snapshot) - } + public func retry(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle { + try reserve(request.identifier) + defer { release(request.identifier) } + return try await admission(request, retry: true) } - return reconciled.sorted { left, right in left.identifier < right.identifier } - } - - public func handleEventsForBackgroundURLSession( - identifier: String, completionHandler: @escaping @Sendable () -> Void - ) async { - await adapters.handleBackgroundEvents(identifier, completionHandler) - } - private func loadStoredSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + private func admission( + _ request: RadrootsBackgroundTransferRequest, retry: Bool + ) async throws -> RadrootsBackgroundTransferHandle { do { - return try await store.loadSnapshots() + return try await store.withAdmission(for: request.identifier) { + try await self.admitReserved(request, retry: retry) + } } catch let error as RadrootsBackgroundTransferError { throw error } catch { @@ -316,796 +49,216 @@ public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer { } } - private func saveStoredSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { - do { - try await store.saveSnapshot(snapshot) - } catch let error as RadrootsBackgroundTransferError { - throw error - } catch { - throw RadrootsBackgroundTransferError.persistenceFailure + private func admitReserved( + _ request: RadrootsBackgroundTransferRequest, retry: Bool + ) async throws -> RadrootsBackgroundTransferHandle { + let existing = try await load(request.identifier) + if retry { + guard let existing, [.failed, .interrupted, .cancelled, .expired].contains(existing.state), + try existing.request.redactedForPersistence() == request.redactedForPersistence() + else { throw RadrootsBackgroundTransferError.invalidRequest } + } else if existing != nil { + throw RadrootsBackgroundTransferError.invalidRequest } + guard try await !activeIdentifiers().contains(request.identifier) else { + throw RadrootsBackgroundTransferError.invalidRequest + } + return try await schedule(request, replacing: existing) } -} - -enum RadrootsStagedBackgroundDownloadResult: Sendable, Equatable { - case file(URL) - case failure -} - -struct RadrootsBackgroundHTTPResult: Sendable, Equatable { - let statusCode: Int? - let mediaType: String? - let body: Data? - let contentEncoding: String? - let bodyExceeded: Bool - let mediaTypeWasMalformed: Bool - - init( - statusCode: Int?, mediaType: String?, body: Data?, contentEncoding: String? = nil, - bodyExceeded: Bool, - mediaTypeWasMalformed: Bool = false - ) { - self.statusCode = statusCode - self.mediaType = mediaType - self.body = body - self.contentEncoding = contentEncoding - self.bodyExceeded = bodyExceeded - self.mediaTypeWasMalformed = mediaTypeWasMalformed - } -} - -actor RadrootsAppleBackgroundTransferCoordinator { - private let sessionIdentifier: String - private let store: any RadrootsBackgroundTransferStore - private let fileResolver: any RadrootsBackgroundTransferFileResolver - private let now: @Sendable () -> Date - private let fileManager: FileManager - private var completionHandlers: [@Sendable () -> Void] - private var unclaimedFinishedEventCount: Int - - init( - sessionIdentifier: String, store: any RadrootsBackgroundTransferStore, - fileResolver: any RadrootsBackgroundTransferFileResolver, - now: @escaping @Sendable () -> Date = Date.init, fileManager: FileManager = .default - ) { - self.sessionIdentifier = sessionIdentifier - self.store = store - self.fileResolver = fileResolver - self.now = now - self.fileManager = fileManager - completionHandlers = [] - unclaimedFinishedEventCount = 0 - } - - func updateProgress( - identifier: RadrootsBackgroundTransferIdentifier, bytesTransferred: Int64, - totalBytesExpected: Int64? - ) async { - guard let existing = try? await snapshot(for: identifier), - existing.state == .running || existing.state == .queued - else { return } - guard - let progress = Self.progress( - bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected, - fallback: existing.progress - ) - else { return } - try? await store.saveSnapshot( - try RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .running, progress: progress, - failure: existing.failure, - response: existing.response, possibleRemoteOrphan: existing.possibleRemoteOrphan, - updatedAt: now() - ) - ) - } - - func complete( - identifier: RadrootsBackgroundTransferIdentifier, platformError: Error?, - stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?, - httpResult: RadrootsBackgroundHTTPResult, bytesTransferred: Int64, - totalBytesExpected: Int64? - ) async { - guard let existing = try? await snapshot(for: identifier), - existing.state == .queued || existing.state == .running || existing.state == .interrupted - else { return } - if UInt64(max(bytesTransferred, 0)) > existing.request.maximumTransferBytes - || totalBytesExpected.map({ UInt64(max($0, 0)) > existing.request.maximumTransferBytes }) - == true - { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail( - existing: existing, - code: .transferTooLarge, - possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 - ) - return - } - if httpResult.bodyExceeded { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail( - existing: existing, code: .responseTooLarge, - possibleRemoteOrphan: existing.request.isUpload) - return - } - if httpResult.mediaTypeWasMalformed { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail( - existing: existing, code: .responseMediaType, - possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0) - return - } - if platformError != nil { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail( - existing: existing, code: .platformFailure, - possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 - ) - return - } - guard let statusCode = httpResult.statusCode else { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail( - existing: existing, code: .responseMissing, - possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 - ) - return - } - guard (200...299).contains(statusCode) else { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail( - existing: existing, - code: .httpStatus, - possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 - ) - return - } - let response: RadrootsBackgroundTransferResponse - do { - response = try Self.validatedResponse(for: existing.request, httpResult: httpResult) - } catch let validationError as RadrootsBackgroundResponseValidationError { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail( - existing: existing, code: validationError.failure, - possibleRemoteOrphan: existing.request.isUpload) - return - } catch { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail( - existing: existing, code: .responseInvalid, - possibleRemoteOrphan: existing.request.isUpload) - return - } - switch existing.request.operation { - case .download(let destination): - await completeDownload( - existing: existing, destination: destination, stagedDownloadResult: stagedDownloadResult, - response: response, mediaType: httpResult.mediaType, - bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected - ) - case .upload: - await completeUpload( - existing: existing, response: response, bytesTransferred: bytesTransferred, - totalBytesExpected: totalBytesExpected - ) - } - } - - func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) { - guard identifier == sessionIdentifier else { - completionHandler() - return - } - if unclaimedFinishedEventCount > 0 { - unclaimedFinishedEventCount -= 1 - completionHandler() - return - } - guard completionHandlers.count < 8 else { - completionHandler() - return - } - completionHandlers.append(completionHandler) - } - - func finishBackgroundEvents(identifier: String?) { - guard identifier == nil || identifier == sessionIdentifier else { return } - guard !completionHandlers.isEmpty else { - unclaimedFinishedEventCount = min(unclaimedFinishedEventCount + 1, 8) - return - } - let handlers = completionHandlers - completionHandlers.removeAll() - for handler in handlers { - handler() - } - } - - private func completeUpload( - existing: RadrootsBackgroundTransferSnapshot, response: RadrootsBackgroundTransferResponse, - bytesTransferred: Int64, - totalBytesExpected: Int64? - ) async { - let progress = - Self.progress( - bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected, - fallback: existing.progress) - ?? existing.progress - try? await store.saveSnapshot( - try RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .awaitingVerification, progress: progress, - response: response, updatedAt: now() - ) - ) - } - - private func completeDownload( - existing: RadrootsBackgroundTransferSnapshot, destination: RadrootsBackgroundTransferLocalFile, - stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?, - response: RadrootsBackgroundTransferResponse, mediaType: String?, - bytesTransferred: Int64, totalBytesExpected: Int64? - ) async { - guard case .file(let stagedFileURL) = stagedDownloadResult else { - await fail(existing: existing, code: .downloadStagingFailure) - return - } - do { - guard case .file(let destinationReference) = destination else { - throw RadrootsBackgroundTransferError.invalidRequest - } - let destinationURL = try fileResolver.resolve(destination) - let fileSize = try Self.fileSize(at: stagedFileURL, fileManager: fileManager) - guard fileSize > 0, UInt64(fileSize) <= existing.request.maximumTransferBytes else { - throw RadrootsBackgroundTransferError.transferFailure - } - let downloadedArtifact = try RadrootsBackgroundDownloadedArtifact( - file: destinationReference, - sha256: RadrootsAppleFileDigest.sha256(at: stagedFileURL), - byteSize: UInt64(fileSize), - mediaType: mediaType - ) - try fileManager.createDirectory( - at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) - try Self.moveReplacingItem(from: stagedFileURL, to: destinationURL, fileManager: fileManager) - #if os(iOS) - try fileManager.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: destinationURL.path - ) - #endif - let progress = - Self.progress( - bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected, - fallback: existing.progress - ) - ?? existing.progress - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .awaitingVerification, progress: progress, - response: response, - downloadedArtifact: downloadedArtifact, updatedAt: now() - ) - ) - } catch { - Self.removeStagedDownload(.file(stagedFileURL), fileManager: fileManager) - await fail(existing: existing, code: .destinationFailure) - } - } - - private func fail( - existing: RadrootsBackgroundTransferSnapshot, - code: RadrootsBackgroundTransferFailure, - possibleRemoteOrphan: Bool = false - ) async { - try? await store.saveSnapshot( - try RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .failed, progress: existing.progress, failure: code, - possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: now() - ) - ) - } - - private func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws - -> RadrootsBackgroundTransferSnapshot? - { - try await store.loadSnapshots().first { $0.identifier == identifier } - } - - private static func progress( - bytesTransferred: Int64, totalBytesExpected: Int64?, - fallback: RadrootsBackgroundTransferProgress - ) - -> RadrootsBackgroundTransferProgress? - { - let safeBytesTransferred = max(bytesTransferred, fallback.bytesTransferred) - let safeTotalBytesExpected = - totalBytesExpected.flatMap { value -> Int64? in value >= safeBytesTransferred ? value : nil } - ?? fallback.totalBytesExpected.flatMap { value -> Int64? in - value >= safeBytesTransferred ? value : nil - } - return try? RadrootsBackgroundTransferProgress( - bytesTransferred: safeBytesTransferred, totalBytesExpected: safeTotalBytesExpected) - } - - private static func fileSize(at url: URL, fileManager _: FileManager) throws -> Int64 { - let values = try url.resourceValues(forKeys: [.fileSizeKey]) - return Int64(values.fileSize ?? 0) - } - - private static func moveReplacingItem( - from source: URL, to destination: URL, fileManager: FileManager - ) throws { - guard fileManager.fileExists(atPath: destination.path) else { - try fileManager.moveItem(at: source, to: destination) - return - } - let backup = destination.deletingLastPathComponent().appendingPathComponent( - ".radroots-transfer-backup-\(UUID().uuidString.lowercased())" - ) - try fileManager.moveItem(at: destination, to: backup) - do { - try fileManager.moveItem(at: source, to: destination) - try fileManager.removeItem(at: backup) - } catch { - if fileManager.fileExists(atPath: destination.path) { - try? fileManager.removeItem(at: destination) - } - if fileManager.fileExists(atPath: backup.path) { - try? fileManager.moveItem(at: backup, to: destination) - } - throw error - } - } - - private static func validatedResponse( - for request: RadrootsBackgroundTransferRequest, httpResult: RadrootsBackgroundHTTPResult - ) throws - -> RadrootsBackgroundTransferResponse - { - guard let statusCode = httpResult.statusCode else { - throw RadrootsBackgroundResponseValidationError(failure: .responseMissing) - } - if request.responsePolicy == .discard { - return try RadrootsBackgroundTransferResponse( - statusCode: statusCode, mediaType: nil, contentEncoding: nil, body: nil) - } - guard let body = httpResult.body else { - throw RadrootsBackgroundResponseValidationError(failure: .responseMissing) - } - guard let mediaType = httpResult.mediaType, - request.responsePolicy.acceptedMediaTypes.contains(mediaType) - else { - throw RadrootsBackgroundResponseValidationError(failure: .responseMediaType) - } - guard body.count <= request.responsePolicy.maximumBodyBytes else { - throw RadrootsBackgroundResponseValidationError(failure: .responseTooLarge) - } - guard httpResult.contentEncoding == nil || httpResult.contentEncoding == "identity" else { - throw RadrootsBackgroundResponseValidationError(failure: .responseContentEncoding) - } - return try RadrootsBackgroundTransferResponse( - statusCode: statusCode, mediaType: mediaType, - contentEncoding: httpResult.contentEncoding, body: body) - } - - private static func removeStagedDownload( - _ result: RadrootsStagedBackgroundDownloadResult?, fileManager: FileManager - ) { - guard case .file(let url) = result, fileManager.fileExists(atPath: url.path) else { return } - try? fileManager.removeItem(at: url) - } -} - -struct RadrootsBackgroundURLTaskDescriptor: Sendable, Equatable { - private static let prefix = "radroots-transfer-v1" - - let identifier: RadrootsBackgroundTransferIdentifier - let maximumTransferBytes: UInt64 - let maximumResponseBodyBytes: Int - init(request: RadrootsBackgroundTransferRequest) { - identifier = request.identifier - maximumTransferBytes = request.maximumTransferBytes - maximumResponseBodyBytes = request.responsePolicy.maximumBodyBytes - } - - init?(taskDescription: String?) { - guard let taskDescription else { return nil } - let components = taskDescription.split(separator: "|", omittingEmptySubsequences: false) - if components.count == 4, - components[0] == Substring(Self.prefix), - let identifier = try? RadrootsBackgroundTransferIdentifier(String(components[1])), - let maximumTransferBytes = UInt64(components[2]), - let maximumResponseBodyBytes = Int(components[3]), - maximumTransferBytes > 0, - maximumTransferBytes <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes, - (0...65_536).contains(maximumResponseBodyBytes) - { - self.identifier = identifier - self.maximumTransferBytes = maximumTransferBytes - self.maximumResponseBodyBytes = maximumResponseBodyBytes - return - } - guard let legacyIdentifier = try? RadrootsBackgroundTransferIdentifier(taskDescription) else { - return nil + private func reserve(_ identifier: RadrootsBackgroundTransferIdentifier) throws { + guard admissions.count < 256, admissions.insert(identifier).inserted else { + throw RadrootsBackgroundTransferError.invalidRequest + } } - identifier = legacyIdentifier - maximumTransferBytes = RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes - maximumResponseBodyBytes = 65_536 - } - - var taskDescription: String { - "\(Self.prefix)|\(identifier.rawValue)|\(maximumTransferBytes)|\(maximumResponseBodyBytes)" - } -} -#if os(iOS) - private actor RadrootsAppleBackgroundURLSession { - private let identifier: String - private let fileResolver: any RadrootsBackgroundTransferFileResolver - private let downloadStagingRoot: URL - private let coordinator: RadrootsAppleBackgroundTransferCoordinator - private let fileManager: FileManager - private let usesForegroundSession: Bool - private var session: URLSession? - private var sessionDelegate: RadrootsAppleBackgroundURLSessionDelegate? - private var sessionDelegateQueue: OperationQueue? - - init( - identifier: String, store: any RadrootsBackgroundTransferStore, - fileResolver: any RadrootsBackgroundTransferFileResolver, - downloadStagingRoot: URL, now: @escaping @Sendable () -> Date, - usesForegroundSession: Bool = false - ) { - self.identifier = identifier - self.fileResolver = fileResolver - self.downloadStagingRoot = downloadStagingRoot - fileManager = .default - self.usesForegroundSession = usesForegroundSession - coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: identifier, store: store, fileResolver: fileResolver, now: now - ) + private func release(_ identifier: RadrootsBackgroundTransferIdentifier) { + admissions.remove(identifier) + stoppedAdmissions.removeValue(forKey: identifier) } - func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws { - let session = backgroundSession() - var urlRequest = URLRequest(url: request.remoteURL) - urlRequest.httpMethod = request.method.rawValue - for (key, value) in request.headers { - urlRequest.setValue(value, forHTTPHeaderField: key) - } - let task: URLSessionTask - switch request.operation { - case .download: task = session.downloadTask(with: urlRequest) - case .upload(let source): - let sourceURL = try fileResolver.resolve(source) - let sourceData = try fileResolver.read( - source, - maximumBytes: Int(request.maximumTransferBytes) + private func schedule( + _ request: RadrootsBackgroundTransferRequest, replacing existing: RadrootsBackgroundTransferSnapshot? + ) async throws -> RadrootsBackgroundTransferHandle { + guard !Task.isCancelled, stoppedAdmissions[request.identifier] == nil else { + throw RadrootsBackgroundTransferError.transferFailure + } + let queued = try RadrootsBackgroundTransferSnapshot( + request: request.redactedForPersistence(), updatedAt: adapters.now(), executionID: UUID() ) - if let expectedDigest = request.expectedSourceSHA256, - RadrootsAppleFileDigest.sha256(sourceData) != expectedDigest - { - throw RadrootsBackgroundTransferError.invalidRequest + guard try await exchange(existing, queued) else { throw RadrootsBackgroundTransferError.invalidRequest } + if Task.isCancelled { + stoppedAdmissions[request.identifier] = .cancelled } - task = session.uploadTask(with: urlRequest, fromFile: sourceURL) - } - task.taskDescription = RadrootsBackgroundURLTaskDescriptor(request: request).taskDescription - sessionDelegate?.registerResponseBodyLimit( - request.responsePolicy.maximumBodyBytes, taskIdentifier: task.taskIdentifier) - task.resume() - } - - func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async { - let tasks = await allTasks() - for task in tasks - where RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier - == identifier - { - task.cancel() - } - } - - func activeTransferIdentifiers() async -> Set<RadrootsBackgroundTransferIdentifier> { - let tasks = await allTasks() - let identifiers = tasks.compactMap { task -> RadrootsBackgroundTransferIdentifier? in - RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier - } - return Set(identifiers) - } - - func handleBackgroundEvents( - identifier: String, completionHandler: @escaping @Sendable () -> Void - ) async { - _ = backgroundSession() - await coordinator.handleBackgroundEvents( - identifier: identifier, completionHandler: completionHandler) - } - - private func allTasks() async -> [URLSessionTask] { - await withCheckedContinuation { continuation in - backgroundSession().getAllTasks { tasks in continuation.resume(returning: tasks) } - } - } - - private func backgroundSession() -> URLSession { - if let session { - return session - } - removeOrphanedDownloadStagingFiles() - let configuration: URLSessionConfiguration - if usesForegroundSession { - configuration = .ephemeral - } else { - configuration = URLSessionConfiguration.background(withIdentifier: identifier) - configuration.sessionSendsLaunchEvents = true - configuration.isDiscretionary = false - } - let delegateQueue = OperationQueue() - delegateQueue.name = "org.radroots.background-transfer.\(identifier)" - delegateQueue.maxConcurrentOperationCount = 1 - let delegate = RadrootsAppleBackgroundURLSessionDelegate( - coordinator: coordinator, downloadStagingRoot: downloadStagingRoot, fileManager: fileManager - ) - let session = URLSession( - configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) - self.session = session - sessionDelegate = delegate - sessionDelegateQueue = delegateQueue - return session - } - - private func removeOrphanedDownloadStagingFiles() { - guard - let urls = try? fileManager.contentsOfDirectory( - at: downloadStagingRoot, includingPropertiesForKeys: nil) - else { return } - for url in urls where url.pathExtension == "download" { - try? fileManager.removeItem(at: url) - } - } - } - - private final class RadrootsAppleBackgroundURLSessionDelegate: NSObject, - URLSessionDownloadDelegate, URLSessionDataDelegate, - URLSessionTaskDelegate, @unchecked Sendable - { - private static let absoluteMaximumResponseBodyBytes = 65536 - private let coordinator: RadrootsAppleBackgroundTransferCoordinator - private let downloadStagingRoot: URL - private let fileManager: FileManager - private let lock = NSLock() - private var stagedDownloadResultsByTaskIdentifier: [Int: RadrootsStagedBackgroundDownloadResult] - private var responseBodyLimitsByTaskIdentifier: [Int: Int] - private var responseBodiesByTaskIdentifier: [Int: Data] - private var exceededResponseBodyTaskIdentifiers: Set<Int> - - init( - coordinator: RadrootsAppleBackgroundTransferCoordinator, downloadStagingRoot: URL, - fileManager: FileManager - ) { - self.coordinator = coordinator - self.downloadStagingRoot = downloadStagingRoot - self.fileManager = fileManager - stagedDownloadResultsByTaskIdentifier = [:] - responseBodyLimitsByTaskIdentifier = [:] - responseBodiesByTaskIdentifier = [:] - exceededResponseBodyTaskIdentifiers = [] - } - - func urlSession( - _: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL - ) { - guard let identifier = transferIdentifier(from: downloadTask) else { return } - let result: RadrootsStagedBackgroundDownloadResult - do { - guard - let descriptor = RadrootsBackgroundURLTaskDescriptor( - taskDescription: downloadTask.taskDescription), - let fileSize = try location.resourceValues(forKeys: [.fileSizeKey]).fileSize, - fileSize >= 0, - UInt64(fileSize) <= descriptor.maximumTransferBytes - else { - throw RadrootsBackgroundTransferError.transferFailure + if let state = stoppedAdmissions[request.identifier] { + _ = try await exchange(queued, queued.transitioned(to: state, at: adapters.now(), + failure: state == .expired ? .expired : nil, + possibleRemoteOrphan: request.isUpload)) + throw RadrootsBackgroundTransferError.transferFailure + } + do { + guard let executionID = queued.executionID else { throw RadrootsBackgroundTransferError.invalidRequest } + try await adapters.enqueue(request, executionID) + } catch { + if stoppedAdmissions[request.identifier] != nil { + try? await adapters.cancel(request.identifier) + } + if let current = try await load(request.identifier), current.executionID == queued.executionID, + current.state == .queued || current.state == .running { + _ = try await exchange(current, current.transitioned(to: .failed, at: adapters.now(), + failure: .enqueueFailed, + possibleRemoteOrphan: request.isUpload)) + } + throw RadrootsBackgroundTransferError.transferFailure } - try fileManager.createDirectory(at: downloadStagingRoot, withIntermediateDirectories: true) - let destination = downloadStagingRoot.appendingPathComponent( - "\(identifier.rawValue)-\(downloadTask.taskIdentifier).download" - ).standardizedFileURL - if fileManager.fileExists(atPath: destination.path) { - try fileManager.removeItem(at: destination) + if stoppedAdmissions[request.identifier] != nil || Task.isCancelled { + try await stop(request.identifier, state: stoppedAdmissions[request.identifier] ?? .cancelled) + } else if let current = try await load(request.identifier), current.executionID == queued.executionID, + current.state == .queued { + _ = try await exchange(current, current.transitioned(to: .running, at: adapters.now())) } - try fileManager.moveItem(at: location, to: destination) - try fileManager.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: destination.path - ) - result = .file(destination) - } catch { result = .failure } - recordDownloadResult(result, taskIdentifier: downloadTask.taskIdentifier) - } - - func urlSession( - _: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, - totalBytesWritten: Int64, - totalBytesExpectedToWrite: Int64 - ) { - guard let identifier = transferIdentifier(from: downloadTask) else { return } - if exceedsTransferLimit( - task: downloadTask, - bytesTransferred: totalBytesWritten, - totalBytesExpected: totalBytesExpectedToWrite - ) { - downloadTask.cancel() - return - } - Task { - await coordinator.updateProgress( - identifier: identifier, bytesTransferred: totalBytesWritten, - totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToWrite) - ) - } + return RadrootsBackgroundTransferHandle(request: request) } - func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - let shouldCancel = appendResponseBody(data, task: dataTask) - if shouldCancel { - dataTask.cancel() - } + public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + try await stop(identifier, state: .cancelled) } - func urlSession( - _: URLSession, task: URLSessionTask, didSendBodyData _: Int64, totalBytesSent: Int64, - totalBytesExpectedToSend: Int64 - ) { - guard let identifier = transferIdentifier(from: task) else { return } - if exceedsTransferLimit( - task: task, bytesTransferred: totalBytesSent, totalBytesExpected: totalBytesExpectedToSend) - { - task.cancel() - return - } - Task { - await coordinator.updateProgress( - identifier: identifier, bytesTransferred: totalBytesSent, - totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend) - ) - } + public func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + try await stop(identifier, state: .expired) } - func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - let bytesTransferred = max(max(task.countOfBytesReceived, task.countOfBytesSent), 0) - let expected = Self.expectedByteCount( - max(task.countOfBytesExpectedToReceive, task.countOfBytesExpectedToSend)) - let stagedDownloadResult = takeDownloadResult(taskIdentifier: task.taskIdentifier) - let httpResult = takeHTTPResult(for: task) - guard let identifier = transferIdentifier(from: task) else { - if case .file(let url) = stagedDownloadResult { - try? fileManager.removeItem(at: url) + private func stop( + _ identifier: RadrootsBackgroundTransferIdentifier, + state: RadrootsBackgroundTransferState + ) async throws { + if admissions.contains(identifier) { + stoppedAdmissions[identifier] = state + } + if let existing = try await load(identifier) { + guard [.queued, .running, .interrupted].contains(existing.state) else { + if admissions.contains(identifier), [.cancelled, .expired].contains(existing.state) { + do { + try await adapters.cancel(identifier) + } catch { + throw RadrootsBackgroundTransferError.transferFailure + } + } + return + } + let desired = try existing.transitioned(to: state, at: adapters.now(), + failure: state == .expired ? .expired : nil, + possibleRemoteOrphan: existing.request.isUpload) + guard try await exchange(existing, desired) else { + throw RadrootsBackgroundTransferError.transferFailure + } + } + do { + try await adapters.cancel(identifier) + } catch { + throw RadrootsBackgroundTransferError.transferFailure } - return - } - Task { - await coordinator.complete( - identifier: identifier, platformError: error, stagedDownloadResult: stagedDownloadResult, - httpResult: httpResult, - bytesTransferred: bytesTransferred, totalBytesExpected: expected - ) - } - } - - func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { - Task { - await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier) - } - } - - func urlSession( - _: URLSession, - task _: URLSessionTask, - willPerformHTTPRedirection _: HTTPURLResponse, - newRequest _: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void - ) { - completionHandler(nil) } - private func recordDownloadResult( - _ result: RadrootsStagedBackgroundDownloadResult, taskIdentifier: Int - ) { - lock.lock() - defer { lock.unlock() } - stagedDownloadResultsByTaskIdentifier[taskIdentifier] = result + public func settle( + _ identifier: RadrootsBackgroundTransferIdentifier, verification: RadrootsBackgroundTransferVerification + ) async throws { + guard let existing = try await load(identifier), existing.state == .awaitingVerification else { + throw RadrootsBackgroundTransferError.invalidRequest + } + let desired: RadrootsBackgroundTransferSnapshot = switch verification { + case .accepted: + try existing.transitioned(to: .completed, at: adapters.now()) + case let .rejected(failure): + try existing.transitioned(to: .failed, at: adapters.now(), failure: failure, + possibleRemoteOrphan: existing.request.isUpload) + } + guard try await exchange(existing, desired) else { throw RadrootsBackgroundTransferError.transferFailure } } - private func takeDownloadResult(taskIdentifier: Int) -> RadrootsStagedBackgroundDownloadResult? { - lock.lock() - defer { lock.unlock() } - return stagedDownloadResultsByTaskIdentifier.removeValue(forKey: taskIdentifier) + public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws + -> RadrootsBackgroundTransferSnapshot? { + try await snapshots().first { $0.identifier == identifier } } - func registerResponseBodyLimit(_ limit: Int, taskIdentifier: Int) { - lock.lock() - defer { lock.unlock() } - responseBodyLimitsByTaskIdentifier[taskIdentifier] = min( - max(limit, 0), Self.absoluteMaximumResponseBodyBytes) + public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + let active = try await activeIdentifiers() + let stored = try await loadAll() + let identifiers = Set(stored.map(\.identifier)) + for orphan in active.subtracting(identifiers) where !admissions.contains(orphan) { + guard try await !admissionIsActive(orphan) else { continue } + do { + try await adapters.cancel(orphan) + } catch { + throw RadrootsBackgroundTransferError.transferFailure + } + } + for existing in stored where !admissions.contains(existing.identifier) { + guard try await !admissionIsActive(existing.identifier) else { continue } + let desired: RadrootsBackgroundTransferSnapshot + if active.contains(existing.identifier), [.queued, .interrupted].contains(existing.state) { + desired = try existing.transitioned(to: .running, at: adapters.now(), failure: existing.failure) + } else if !active.contains(existing.identifier), [.queued, .running].contains(existing.state) { + desired = try existing.transitioned(to: .interrupted, at: adapters.now(), failure: .interrupted, + possibleRemoteOrphan: existing.request.isUpload) + } else { + continue + } + _ = try await exchange(existing, desired) + } + return try await loadAll() } - private func appendResponseBody(_ data: Data, task: URLSessionDataTask) -> Bool { - lock.lock() - defer { lock.unlock() } - let taskIdentifier = task.taskIdentifier - guard !exceededResponseBodyTaskIdentifiers.contains(taskIdentifier) else { return false } - let limit = - responseBodyLimitsByTaskIdentifier[taskIdentifier] - ?? RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)? - .maximumResponseBodyBytes - ?? Self.absoluteMaximumResponseBodyBytes - guard limit > 0 else { return false } - let currentCount = responseBodiesByTaskIdentifier[taskIdentifier]?.count ?? 0 - guard data.count <= limit - currentCount else { - responseBodiesByTaskIdentifier.removeValue(forKey: taskIdentifier) - exceededResponseBodyTaskIdentifiers.insert(taskIdentifier) - return true - } - responseBodiesByTaskIdentifier[taskIdentifier, default: Data()].append(data) - return false + public func handleEventsForBackgroundURLSession( + identifier: String, completionHandler: @escaping @Sendable () -> Void + ) async { + await adapters.handleBackgroundEvents(identifier, completionHandler) } - private func takeHTTPResult(for task: URLSessionTask) -> RadrootsBackgroundHTTPResult { - lock.lock() - let body = responseBodiesByTaskIdentifier.removeValue(forKey: task.taskIdentifier) - responseBodyLimitsByTaskIdentifier.removeValue(forKey: task.taskIdentifier) - let exceeded = exceededResponseBodyTaskIdentifiers.remove(task.taskIdentifier) != nil - lock.unlock() - - guard let response = task.response as? HTTPURLResponse else { - return RadrootsBackgroundHTTPResult( - statusCode: nil, mediaType: nil, body: body, bodyExceeded: exceeded) - } - let rawMediaType = response.value(forHTTPHeaderField: "Content-Type") - let mediaType = rawMediaType.flatMap { - try? RadrootsBackgroundTransferValidation.normalizedMediaType($0) - } - let contentEncoding = response.value(forHTTPHeaderField: "Content-Encoding")? - .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return RadrootsBackgroundHTTPResult( - statusCode: response.statusCode, mediaType: mediaType, body: body, - contentEncoding: contentEncoding, bodyExceeded: exceeded, - mediaTypeWasMalformed: rawMediaType != nil && mediaType == nil) + private func load(_ identifier: RadrootsBackgroundTransferIdentifier) async throws + -> RadrootsBackgroundTransferSnapshot? { + try await loadAll().first { $0.identifier == identifier } } - private func transferIdentifier(from task: URLSessionTask) - -> RadrootsBackgroundTransferIdentifier? - { - RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier + private func loadAll() async throws -> [RadrootsBackgroundTransferSnapshot] { + do { + return try await store.loadSnapshots() + } catch let error as RadrootsBackgroundTransferError { + throw error + } catch { + throw RadrootsBackgroundTransferError.persistenceFailure + } } - private func exceedsTransferLimit( - task: URLSessionTask, bytesTransferred: Int64, totalBytesExpected: Int64 - ) -> Bool { - guard - let descriptor = RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription) - else { return true } - return bytesTransferred > 0 && UInt64(bytesTransferred) > descriptor.maximumTransferBytes - || totalBytesExpected > 0 && UInt64(totalBytesExpected) > descriptor.maximumTransferBytes + private func exchange( + _ expected: RadrootsBackgroundTransferSnapshot?, _ desired: RadrootsBackgroundTransferSnapshot + ) async throws -> Bool { + do { + return try await store.compareExchangeSnapshot(expected: expected, desired: desired) + } catch let error as RadrootsBackgroundTransferError { + throw error + } catch { + throw RadrootsBackgroundTransferError.persistenceFailure + } } - private static func expectedByteCount(_ value: Int64) -> Int64? { - value >= 0 ? value : nil + private func activeIdentifiers() async throws -> Set<RadrootsBackgroundTransferIdentifier> { + do { + return try await adapters.activeTransferIdentifiers() + } catch let error as RadrootsBackgroundTransferError { + throw error + } catch { + throw RadrootsBackgroundTransferError.transferFailure + } } - } -#endif -extension RadrootsBackgroundTransferRequest { - fileprivate var isUpload: Bool { - if case .upload = operation { - return true + private func admissionIsActive(_ identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool { + do { + return try await store.admissionIsActive(for: identifier) + } catch let error as RadrootsBackgroundTransferError { + throw error + } catch { + throw RadrootsBackgroundTransferError.persistenceFailure + } } - return false - } -} - -private struct RadrootsBackgroundResponseValidationError: Error { - let failure: RadrootsBackgroundTransferFailure } diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferAdapters.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferAdapters.swift @@ -0,0 +1,92 @@ +import Foundation + +public struct RadrootsAppleBackgroundTransferAdapters: Sendable { + public let now: @Sendable () -> Date + public let enqueue: @Sendable (RadrootsBackgroundTransferRequest, UUID) async throws -> Void + public let cancel: @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void + public let activeTransferIdentifiers: @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier> + public let handleBackgroundEvents: @Sendable (String, @escaping @Sendable () -> Void) async -> Void + + public init( + now: @escaping @Sendable () -> Date = Date.init, + enqueue: @escaping @Sendable (RadrootsBackgroundTransferRequest, UUID) async throws -> Void, + cancel: @escaping @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void, + activeTransferIdentifiers: + @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>, + handleBackgroundEvents: + @escaping @Sendable (String, @escaping @Sendable () -> Void) async -> Void + ) { + self.now = now + self.enqueue = enqueue + self.cancel = cancel + self.activeTransferIdentifiers = activeTransferIdentifiers + self.handleBackgroundEvents = handleBackgroundEvents + } + + public static let unavailable = Self( + enqueue: { _, _ in + throw RadrootsBackgroundTransferError.unavailable + }, + cancel: { _ in + throw RadrootsBackgroundTransferError.unavailable + }, + activeTransferIdentifiers: { + throw RadrootsBackgroundTransferError.unavailable + }, handleBackgroundEvents: { _, completionHandler in completionHandler() } + ) + + public static func live( + sessionIdentifier: String, store: any RadrootsBackgroundTransferStore, + fileResolver: any RadrootsBackgroundTransferFileResolver, + downloadStagingRoot: URL, now: @escaping @Sendable () -> Date = Date.init + ) throws -> Self { + #if os(iOS) + let normalizedSessionIdentifier = + try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier) + let session = RadrootsAppleBackgroundURLSession( + identifier: normalizedSessionIdentifier, store: store, fileResolver: fileResolver, + downloadStagingRoot: downloadStagingRoot, + now: now + ) + #if targetEnvironment(simulator) + let simulatorSession = RadrootsAppleBackgroundURLSession( + identifier: normalizedSessionIdentifier, store: store, fileResolver: fileResolver, + downloadStagingRoot: downloadStagingRoot, + now: now, usesForegroundSession: true + ) + #endif + return Self( + now: now, + enqueue: { request, executionID in + #if targetEnvironment(simulator) + if request.networkPolicy == .simulatorLoopbackHTTP { + try await simulatorSession.enqueue(request, executionID: executionID) + return + } + #endif + try await session.enqueue(request, executionID: executionID) + }, + cancel: { identifier in + #if targetEnvironment(simulator) + await simulatorSession.cancel(identifier) + #endif + await session.cancel(identifier) + }, + activeTransferIdentifiers: { + var identifiers = await session.activeTransferIdentifiers() + #if targetEnvironment(simulator) + await identifiers.formUnion(simulatorSession.activeTransferIdentifiers()) + #endif + return identifiers + }, + handleBackgroundEvents: { identifier, completionHandler in + await session.handleBackgroundEvents( + identifier: identifier, completionHandler: completionHandler + ) + } + ) + #else + return .unavailable + #endif + } +} diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferCoordinator.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferCoordinator.swift @@ -0,0 +1,321 @@ +import Foundation + +actor RadrootsTransferCoordinator { + private let sessionIdentifier: String + private let store: any RadrootsBackgroundTransferStore + private let fileResolver: any RadrootsBackgroundTransferFileResolver + private let now: @Sendable () -> Date + private let fileManager: FileManager + private var completionHandlers: [@Sendable () -> Void] + private var unclaimedFinishedEventCount: Int + + init( + sessionIdentifier: String, store: any RadrootsBackgroundTransferStore, + fileResolver: any RadrootsBackgroundTransferFileResolver, + now: @escaping @Sendable () -> Date = Date.init, fileManager: FileManager = .default + ) { + self.sessionIdentifier = sessionIdentifier + self.store = store + self.fileResolver = fileResolver + self.now = now + self.fileManager = fileManager + completionHandlers = [] + unclaimedFinishedEventCount = 0 + } + + func updateProgress( + identifier: RadrootsBackgroundTransferIdentifier, bytesTransferred: Int64, + totalBytesExpected: Int64?, executionID: UUID? = nil + ) async { + guard let existing = try? await snapshot(for: identifier), existing.executionID == executionID, + existing.state == .running || existing.state == .queued + else { return } + guard + let progress = Self.progress( + bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected, + fallback: existing.progress + ) + else { return } + _ = try? await store.compareExchangeSnapshot(expected: existing, desired: + try RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .running, progress: progress, + failure: existing.failure, + response: existing.response, possibleRemoteOrphan: existing.possibleRemoteOrphan, + updatedAt: now(), executionID: existing.executionID, uploadLease: existing.uploadLease + )) + } + + func complete( + identifier: RadrootsBackgroundTransferIdentifier, completion: RadrootsTransferCompletion, + executionID: UUID? = nil + ) async { + guard let existing = try? await snapshot(for: identifier), existing.executionID == executionID, + [.queued, .running, .interrupted].contains(existing.state) + else { return } + if let failure = Self.completionFailure(request: existing.request, completion: completion) { + Self.removeStagedDownload(completion.stagedDownloadResult, fileManager: fileManager) + await fail(existing: existing, code: failure, possibleRemoteOrphan: existing.request.isUpload) + return + } + let response: RadrootsBackgroundTransferResponse + do { + response = try Self.validatedResponse(for: existing.request, httpResult: completion.httpResult) + } catch { + Self.removeStagedDownload(completion.stagedDownloadResult, fileManager: fileManager) + await fail(existing: existing, + code: (error as? RadrootsTransferResponseError)?.failure ?? .responseInvalid, + possibleRemoteOrphan: existing.request.isUpload) + return + } + switch existing.request.operation { + case let .download(destination): + await completeDownload( + existing: existing, + destination: destination, + response: response, + completion: completion + ) + case .upload: + await completeUpload(existing: existing, response: response, bytesTransferred: completion.bytesTransferred, + totalBytesExpected: completion.totalBytesExpected) + } + } + + private static func completionFailure( + request: RadrootsBackgroundTransferRequest, completion: RadrootsTransferCompletion + ) -> RadrootsBackgroundTransferFailure? { + if UInt64(max(completion.bytesTransferred, 0)) > request.maximumTransferBytes + || completion.totalBytesExpected.map({ UInt64(max($0, 0)) > request.maximumTransferBytes }) == true { + return .transferTooLarge + } + if completion.httpResult.bodyExceeded { + return .responseTooLarge + } + if completion.httpResult.mediaTypeWasMalformed { + return .responseMediaType + } + if completion.platformError != nil { + return .platformFailure + } + guard let status = completion.httpResult.statusCode else { return .responseMissing } + return (200 ... 299).contains(status) ? nil : .httpStatus + } + + func releaseUploadLease(executionID: UUID?) { + guard let executionID else { return } + // Only the native terminal callback calls this. Cancellation intent alone + // cannot release bytes still owned by URLSession. Failed cleanup remains + // an owned orphan for later reconciliation. + try? fileResolver.releaseUploadLease(executionID: executionID) + } + + func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) { + guard identifier == sessionIdentifier else { + completionHandler() + return + } + if unclaimedFinishedEventCount > 0 { + unclaimedFinishedEventCount -= 1 + completionHandler() + return + } + guard completionHandlers.count < 8 else { + completionHandler() + return + } + completionHandlers.append(completionHandler) + } + + func finishBackgroundEvents(identifier: String?) { + guard identifier == nil || identifier == sessionIdentifier else { return } + guard !completionHandlers.isEmpty else { + unclaimedFinishedEventCount = min(unclaimedFinishedEventCount + 1, 8) + return + } + let handlers = completionHandlers + completionHandlers.removeAll() + for handler in handlers { + handler() + } + } +} + +extension RadrootsTransferCoordinator { + private func completeUpload( + existing: RadrootsBackgroundTransferSnapshot, response: RadrootsBackgroundTransferResponse, + bytesTransferred: Int64, + totalBytesExpected: Int64? + ) async { + let progress = + Self.progress( + bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected, + fallback: existing.progress + ) + ?? existing.progress + _ = try? await store.compareExchangeSnapshot(expected: existing, desired: + try RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .awaitingVerification, progress: progress, + response: response, updatedAt: now(), executionID: existing.executionID, + uploadLease: existing.uploadLease + )) + } + + private func completeDownload( + existing: RadrootsBackgroundTransferSnapshot, destination: RadrootsBackgroundTransferLocalFile, + response: RadrootsBackgroundTransferResponse, completion: RadrootsTransferCompletion + ) async { + let stagedDownloadResult = completion.stagedDownloadResult + let mediaType = completion.httpResult.mediaType + let bytesTransferred = completion.bytesTransferred + let totalBytesExpected = completion.totalBytesExpected + guard case let .file(stagedFileURL) = stagedDownloadResult else { + await fail(existing: existing, code: .downloadStagingFailure) + return + } + do { + guard case let .file(destinationReference) = destination else { + throw RadrootsBackgroundTransferError.invalidRequest + } + let destinationURL = try fileResolver.resolve(destination) + let fileSize = try Self.fileSize(at: stagedFileURL, fileManager: fileManager) + guard fileSize > 0, UInt64(fileSize) <= existing.request.maximumTransferBytes else { + throw RadrootsBackgroundTransferError.transferFailure + } + let downloadedArtifact = try RadrootsBackgroundDownloadedArtifact( + file: destinationReference, + sha256: RadrootsAppleFileDigest.sha256(at: stagedFileURL), + byteSize: UInt64(fileSize), + mediaType: mediaType + ) + try fileManager.createDirectory( + at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try Self.moveReplacingItem(from: stagedFileURL, to: destinationURL, fileManager: fileManager) + #if os(iOS) + try fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: destinationURL.path + ) + #endif + let progress = + Self.progress( + bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected, + fallback: existing.progress + ) + ?? existing.progress + _ = try await store.compareExchangeSnapshot(expected: existing, desired: + RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .awaitingVerification, progress: progress, + response: response, + downloadedArtifact: downloadedArtifact, updatedAt: now(), executionID: existing.executionID, + uploadLease: existing.uploadLease + )) + } catch { + Self.removeStagedDownload(.file(stagedFileURL), fileManager: fileManager) + await fail(existing: existing, code: .destinationFailure) + } + } + + private func fail( + existing: RadrootsBackgroundTransferSnapshot, + code: RadrootsBackgroundTransferFailure, + possibleRemoteOrphan: Bool = false + ) async { + _ = try? await store.compareExchangeSnapshot(expected: existing, desired: + try RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .failed, progress: existing.progress, failure: code, + possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: now(), executionID: existing.executionID, + uploadLease: existing.uploadLease + )) + } + + private func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws + -> RadrootsBackgroundTransferSnapshot? { + try await store.loadSnapshots().first { $0.identifier == identifier } + } + + private static func progress( + bytesTransferred: Int64, totalBytesExpected: Int64?, + fallback: RadrootsBackgroundTransferProgress + ) + -> RadrootsBackgroundTransferProgress? { + let safeBytesTransferred = max(bytesTransferred, fallback.bytesTransferred) + let safeTotalBytesExpected = + totalBytesExpected.flatMap { value -> Int64? in value >= safeBytesTransferred ? value : nil } + ?? fallback.totalBytesExpected.flatMap { value -> Int64? in + value >= safeBytesTransferred ? value : nil + } + return try? RadrootsBackgroundTransferProgress( + bytesTransferred: safeBytesTransferred, totalBytesExpected: safeTotalBytesExpected + ) + } + + private static func fileSize(at url: URL, fileManager _: FileManager) throws -> Int64 { + let values = try url.resourceValues(forKeys: [.fileSizeKey]) + return Int64(values.fileSize ?? 0) + } + + private static func moveReplacingItem( + from source: URL, to destination: URL, fileManager: FileManager + ) throws { + guard fileManager.fileExists(atPath: destination.path) else { + try fileManager.moveItem(at: source, to: destination) + return + } + let backup = destination.deletingLastPathComponent().appendingPathComponent( + ".radroots-transfer-backup-\(UUID().uuidString.lowercased())" + ) + try fileManager.moveItem(at: destination, to: backup) + do { + try fileManager.moveItem(at: source, to: destination) + try fileManager.removeItem(at: backup) + } catch { + if fileManager.fileExists(atPath: destination.path) { + try? fileManager.removeItem(at: destination) + } + if fileManager.fileExists(atPath: backup.path) { + try? fileManager.moveItem(at: backup, to: destination) + } + throw error + } + } + + private static func validatedResponse( + for request: RadrootsBackgroundTransferRequest, httpResult: RadrootsBackgroundHTTPResult + ) throws + -> RadrootsBackgroundTransferResponse { + guard let statusCode = httpResult.statusCode else { + throw RadrootsTransferResponseError(failure: .responseMissing) + } + if request.responsePolicy == .discard { + return try RadrootsBackgroundTransferResponse( + statusCode: statusCode, mediaType: nil, contentEncoding: nil, body: nil + ) + } + guard let body = httpResult.body else { + throw RadrootsTransferResponseError(failure: .responseMissing) + } + guard let mediaType = httpResult.mediaType, + request.responsePolicy.acceptedMediaTypes.contains(mediaType) + else { + throw RadrootsTransferResponseError(failure: .responseMediaType) + } + guard body.count <= request.responsePolicy.maximumBodyBytes else { + throw RadrootsTransferResponseError(failure: .responseTooLarge) + } + guard httpResult.contentEncoding == nil || httpResult.contentEncoding == "identity" else { + throw RadrootsTransferResponseError(failure: .responseContentEncoding) + } + return try RadrootsBackgroundTransferResponse( + statusCode: statusCode, mediaType: mediaType, + contentEncoding: httpResult.contentEncoding, body: body + ) + } + + private static func removeStagedDownload( + _ result: RadrootsStagedBackgroundDownloadResult?, fileManager: FileManager + ) { + guard case let .file(url) = result, fileManager.fileExists(atPath: url.path) else { return } + try? fileManager.removeItem(at: url) + } +} diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferFileResolver.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferFileResolver.swift @@ -0,0 +1,88 @@ +import Foundation + +public struct RadrootsAppleBackgroundTransferFileResolver: RadrootsBackgroundTransferFileResolver, + Sendable { + private let roots: RadrootsAppleFileRoots + + public init(roots: RadrootsAppleFileRoots) { + self.roots = roots + } + + public func prepareUploadLease( + for request: RadrootsBackgroundTransferRequest, executionID: UUID, + existing: RadrootsStagedBlobReference? + ) throws -> RadrootsStagedBlobLease { + guard case let .upload(source) = request.operation else { throw RadrootsBackgroundTransferError.invalidRequest } + let owner = RadrootsAppleFileAccess(roots: roots) + let identifier = executionID.uuidString.lowercased() + if let existing { + guard request.expectedSourceSHA256 == nil || request.expectedSourceSHA256 == existing.blobID else { + throw RadrootsBackgroundTransferError.invalidRequest + } + return try owner.leaseStagedBlob(existing, expectedSHA256: existing.blobID, identifier: identifier) + } + let bytes = try read(source, maximumBytes: Int(request.maximumTransferBytes)) + let digest = RadrootsAppleFileDigest.sha256(bytes) + guard request.expectedSourceSHA256 == nil || request.expectedSourceSHA256 == digest else { + throw RadrootsBackgroundTransferError.invalidRequest + } + return try owner.leaseUploadBytes(bytes, identifier: identifier) + } + + public func releaseUploadLease(executionID: UUID) throws { + try RadrootsAppleFileAccess(roots: roots).releaseUploadLease(executionID: executionID) + } + + public func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL { + let candidate: URL + let root: URL + switch file { + case let .file(reference): + candidate = try roots.resolvedURL(for: reference) + root = roots.root(for: reference.scope) + case let .stagedBlob(blob): + candidate = try roots.stagedBlobURL(for: blob) + root = roots.stagedBlobsRoot + } + let resolvedRoot = root.resolvingSymlinksInPath().standardizedFileURL + let resolvedCandidate = candidate.resolvingSymlinksInPath().standardizedFileURL + guard resolvedCandidate.path.hasPrefix(resolvedRoot.path + "/") else { + throw RadrootsBackgroundTransferError.invalidRequest + } + return candidate + } + + public func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data { + guard maximumBytes >= 0 else { + throw RadrootsBackgroundTransferError.invalidRequest + } + let root: URL + let relativePath: String + let expectedBytes: Int? + switch file { + case let .file(reference): + root = roots.root(for: reference.scope) + relativePath = reference.relativePath + expectedBytes = nil + case let .stagedBlob(blob): + root = roots.stagedBlobsRoot + relativePath = blob.blobID + expectedBytes = blob.sizeBytes + } + do { + let data = try RadrootsGovernedFileReader.read( + root: root, + relativePath: relativePath, + maximumBytes: maximumBytes + ) + guard expectedBytes == nil || expectedBytes == data.count else { + throw RadrootsBackgroundTransferError.invalidRequest + } + return data + } catch let error as RadrootsBackgroundTransferError { + throw error + } catch { + throw RadrootsBackgroundTransferError.invalidRequest + } + } +} diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferStore.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferStore.swift @@ -0,0 +1,234 @@ +import Darwin +import Foundation + +public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore { + private static let maximumPersistenceBytes = 1024 * 1024 + + private struct Envelope: Codable { + let schemaVersion: Int + let snapshots: [RadrootsBackgroundTransferSnapshot] + + init(snapshots: [RadrootsBackgroundTransferSnapshot]) { + schemaVersion = 1 + self.snapshots = snapshots + } + } + + private let roots: RadrootsAppleFileRoots + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let protectedData: RadrootsProtectedDataProvider + + public init( + roots: RadrootsAppleFileRoots, fileManager: FileManager = .default, + protectedData: RadrootsProtectedDataProvider = .available + ) { + self.roots = roots + self.fileManager = fileManager + encoder = JSONEncoder() + decoder = JSONDecoder() + self.protectedData = protectedData + encoder.outputFormatting = [.sortedKeys] + } + + public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + try withStoreLock { try loadSnapshotsSynchronously() } + } + + public func withAdmission( + for identifier: RadrootsBackgroundTransferIdentifier, + operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle + ) async throws -> RadrootsBackgroundTransferHandle { + guard let descriptor = try acquireAdmission(identifier) else { + throw RadrootsBackgroundTransferError.invalidRequest + } + // Only this attempt owns the descriptor across awaits. Persistence uses a + // separate short lock; neither reservation waits for another owner. + defer { Darwin.close(descriptor) } + return try await operation() + } + + public func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool { + guard let descriptor = try acquireAdmission(identifier) else { return true } + Darwin.close(descriptor) + return false + } + + private func acquireAdmission(_ identifier: RadrootsBackgroundTransferIdentifier) throws -> Int32? { + try requireProtectedData() + do { + let name = RadrootsAppleFileDigest.sha256(Data(identifier.rawValue.utf8)) + let url = try roots.resolvedURL(for: RadrootsFileReference( + scope: .data, relativePath: "background_transfers/admissions/\(name).lock" + )) + return try RadrootsAtomicFile.acquireExclusiveLock(at: url) + } catch { + throw RadrootsBackgroundTransferError.persistenceFailure + } + } + + public func compareExchangeSnapshot( + expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot + ) async throws -> Bool { + try withStoreLock { + guard expected == nil || expected?.identifier == desired.identifier else { + throw RadrootsBackgroundTransferError.invalidRequest + } + var snapshots = try loadSnapshotsSynchronously() + let current = snapshots.first { $0.identifier == desired.identifier } + guard try current == (expected?.redactedForPersistence()) else { return false } + snapshots.removeAll { $0.identifier == desired.identifier } + try snapshots.append(desired.redactedForPersistence()) + try write(snapshots.sorted { $0.identifier < $1.identifier }) + return true + } + } + + private func withStoreLock<T>(_ body: () throws -> T) throws -> T { + try requireProtectedData() + do { + let lockURL = try roots.resolvedURL( + for: RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.lock") + ) + return try RadrootsAtomicFile.withExclusiveLock(at: lockURL, body) + } catch let error as RadrootsBackgroundTransferError { + throw error + } catch { + throw RadrootsBackgroundTransferError.persistenceFailure + } + } + + private func loadSnapshotsSynchronously() throws -> [RadrootsBackgroundTransferSnapshot] { + try requireProtectedData() + do { + let url = try storeURL() + let legacyURL = try legacyStoreURL() + let isLegacy: Bool + if fileManager.fileExists(atPath: url.path) { + isLegacy = false + } else if fileManager.fileExists(atPath: legacyURL.path) { + isLegacy = true + } else { + return [] + } + let data = try RadrootsGovernedFileReader.read( + root: roots.root(for: isLegacy ? .cache : .data), + relativePath: "background_transfers/transfers.json", + maximumBytes: Self.maximumPersistenceBytes + ) + let decoded: [RadrootsBackgroundTransferSnapshot] + let usedLegacyEncoding: Bool + if let envelope = try? decoder.decode(Envelope.self, from: data) { + guard envelope.schemaVersion == 1 else { + throw RadrootsBackgroundTransferError.persistenceFailure + } + decoded = envelope.snapshots + usedLegacyEncoding = false + } else { + decoded = try decoder.decode([RadrootsBackgroundTransferSnapshot].self, from: data) + usedLegacyEncoding = true + } + let snapshots = try decoded.map { try $0.redactedForPersistence() }.sorted { left, right in + left.identifier < right.identifier + } + if isLegacy || usedLegacyEncoding { + try write(snapshots) + } + if fileManager.fileExists(atPath: legacyURL.path) { + try fileManager.removeItem(at: legacyURL) + } + return snapshots + } catch { + throw RadrootsBackgroundTransferError.persistenceFailure + } + } + + public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { + try withStoreLock { try saveSnapshotSynchronously(snapshot) } + } + + private func saveSnapshotSynchronously(_ snapshot: RadrootsBackgroundTransferSnapshot) throws { + try requireProtectedData() + do { + var snapshots = try loadSnapshotsSynchronously() + snapshots.removeAll { $0.identifier == snapshot.identifier } + try snapshots.append(snapshot.redactedForPersistence()) + try write(snapshots.sorted { left, right in left.identifier < right.identifier }) + } catch let error as RadrootsBackgroundTransferError { + throw error + } catch { + throw RadrootsBackgroundTransferError.persistenceFailure + } + } + + public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws { + try withStoreLock { try removeSnapshotSynchronously(for: identifier) } + } + + private func removeSnapshotSynchronously(for identifier: RadrootsBackgroundTransferIdentifier) throws { + try requireProtectedData() + do { + var snapshots = try loadSnapshotsSynchronously() + snapshots.removeAll { $0.identifier == identifier } + try write(snapshots) + } catch let error as RadrootsBackgroundTransferError { + throw error + } catch { + throw RadrootsBackgroundTransferError.persistenceFailure + } + } + + public func removeAllSnapshots() async throws { + try withStoreLock { try removeAllSnapshotsSynchronously() } + } + + private func removeAllSnapshotsSynchronously() throws { + try requireProtectedData() + do { + for url in try [storeURL(), legacyStoreURL()] where fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + } catch let error as RadrootsBackgroundTransferError { + throw error + } catch { + throw RadrootsBackgroundTransferError.persistenceFailure + } + } + + private func write(_ snapshots: [RadrootsBackgroundTransferSnapshot]) throws { + let url = try storeURL() + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + let data = try encoder.encode(Envelope(snapshots: snapshots)) + guard data.count <= Self.maximumPersistenceBytes else { + throw RadrootsBackgroundTransferError.persistenceFailure + } + try RadrootsAtomicFile.install(data, at: url) + #if os(iOS) + try fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: url.path + ) + #endif + } + + private func storeURL() throws -> URL { + try roots.resolvedURL( + for: RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.json") + ) + } + + private func legacyStoreURL() throws -> URL { + try roots.resolvedURL( + for: RadrootsFileReference(scope: .cache, relativePath: "background_transfers/transfers.json") + ) + } + + private func requireProtectedData() throws { + guard protectedData.currentState() == .available else { + throw RadrootsBackgroundTransferError.persistenceFailure + } + } +} diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundURLSession.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundURLSession.swift @@ -0,0 +1,175 @@ +import Foundation + +#if os(iOS) + + actor RadrootsAppleBackgroundURLSession { + private let identifier: String + private let fileResolver: any RadrootsBackgroundTransferFileResolver + private let downloadStagingRoot: URL + private let coordinator: RadrootsTransferCoordinator + private let fileManager: FileManager + private let usesForegroundSession: Bool + private var session: URLSession? + private var sessionDelegate: RadrootsTransferSessionDelegate? + private var sessionDelegateQueue: OperationQueue? + private let store: any RadrootsBackgroundTransferStore + private var admissions: Set<RadrootsBackgroundTransferIdentifier> = [] + private var cancelledAdmissions: Set<RadrootsBackgroundTransferIdentifier> = [] + + init( + identifier: String, store: any RadrootsBackgroundTransferStore, + fileResolver: any RadrootsBackgroundTransferFileResolver, + downloadStagingRoot: URL, now: @escaping @Sendable () -> Date, + usesForegroundSession: Bool = false + ) { + self.identifier = identifier + self.store = store + self.fileResolver = fileResolver + self.downloadStagingRoot = downloadStagingRoot + fileManager = .default + self.usesForegroundSession = usesForegroundSession + coordinator = RadrootsTransferCoordinator( + sessionIdentifier: identifier, store: store, fileResolver: fileResolver, now: now + ) + } + + func enqueue(_ request: RadrootsBackgroundTransferRequest, executionID: UUID) async throws { + guard admissions.count < 256, admissions.insert(request.identifier).inserted else { + throw RadrootsBackgroundTransferError.invalidRequest + } + defer { + admissions.remove(request.identifier) + cancelledAdmissions.remove(request.identifier) + } + if try await alreadyAdmitted(request.identifier, executionID: executionID) { + return + } + guard let snapshot = try await store.loadSnapshots().first(where: { $0.identifier == request.identifier }), + snapshot.executionID == executionID, snapshot.state == .queued, + try snapshot.request == request.redactedForPersistence(), + !cancelledAdmissions.contains(request.identifier) + else { throw RadrootsBackgroundTransferError.invalidRequest } + let session = backgroundSession() + var urlRequest = URLRequest(url: request.remoteURL) + urlRequest.httpMethod = request.method.rawValue + for (key, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: key) + } + let task: URLSessionTask + switch request.operation { + case .download: task = session.downloadTask(with: urlRequest) + case .upload: + let lease = try fileResolver.prepareUploadLease( + for: request, + executionID: executionID, + existing: snapshot.uploadLease + ) + let leased = try RadrootsBackgroundTransferSnapshot( + request: snapshot.request, state: .queued, updatedAt: snapshot.updatedAt, + executionID: executionID, uploadLease: lease.blob + ) + guard try await store.compareExchangeSnapshot(expected: snapshot, desired: leased), + !cancelledAdmissions.contains(request.identifier), !Task.isCancelled + else { throw RadrootsBackgroundTransferError.transferFailure } + task = session.uploadTask(with: urlRequest, fromFile: lease.fileURL) + } + task.taskDescription = RadrootsBackgroundURLTaskDescriptor(request: request, executionID: executionID) + .taskDescription + sessionDelegate?.registerResponseBodyLimit( + request.responsePolicy.maximumBodyBytes, taskIdentifier: task.taskIdentifier + ) + task.resume() + } + + private func alreadyAdmitted(_ identifier: RadrootsBackgroundTransferIdentifier, + executionID: UUID) async throws -> Bool { + let tasks = await allTasks() + let existingTasks = tasks.filter { + RadrootsBackgroundURLTaskDescriptor(taskDescription: $0.taskDescription)?.identifier == identifier + } + if !existingTasks.isEmpty { + guard existingTasks.count == 1, + RadrootsBackgroundURLTaskDescriptor(taskDescription: existingTasks[0].taskDescription)? + .executionID == executionID + else { throw RadrootsBackgroundTransferError.invalidRequest } + return true + } + return false + } + + func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async { + if admissions.contains(identifier) { + cancelledAdmissions.insert(identifier) + } + let tasks = await allTasks() + for task in tasks + where RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier + == identifier { + task.cancel() + } + } + + func activeTransferIdentifiers() async -> Set<RadrootsBackgroundTransferIdentifier> { + let tasks = await allTasks() + let identifiers = tasks.compactMap { task -> RadrootsBackgroundTransferIdentifier? in + RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier + } + return Set(identifiers) + } + + func handleBackgroundEvents( + identifier: String, completionHandler: @escaping @Sendable () -> Void + ) async { + _ = backgroundSession() + await coordinator.handleBackgroundEvents( + identifier: identifier, completionHandler: completionHandler + ) + } + + private func allTasks() async -> [URLSessionTask] { + await withCheckedContinuation { continuation in + backgroundSession().getAllTasks { tasks in continuation.resume(returning: tasks) } + } + } + + private func backgroundSession() -> URLSession { + if let session { + return session + } + removeOrphanedDownloadStagingFiles() + let configuration: URLSessionConfiguration + if usesForegroundSession { + configuration = .ephemeral + } else { + configuration = URLSessionConfiguration.background(withIdentifier: identifier) + configuration.sessionSendsLaunchEvents = true + configuration.isDiscretionary = false + } + let delegateQueue = OperationQueue() + delegateQueue.name = "org.radroots.background-transfer.\(identifier)" + delegateQueue.maxConcurrentOperationCount = 1 + let delegate = RadrootsTransferSessionDelegate( + coordinator: coordinator, downloadStagingRoot: downloadStagingRoot, fileManager: fileManager + ) + let session = URLSession( + configuration: configuration, delegate: delegate, delegateQueue: delegateQueue + ) + self.session = session + sessionDelegate = delegate + sessionDelegateQueue = delegateQueue + return session + } + + private func removeOrphanedDownloadStagingFiles() { + guard + let urls = try? fileManager.contentsOfDirectory( + at: downloadStagingRoot, includingPropertiesForKeys: nil + ) + else { return } + for url in urls where url.pathExtension == "download" { + try? fileManager.removeItem(at: url) + } + } + } + +#endif diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundURLSessionDelegate.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundURLSessionDelegate.swift @@ -0,0 +1,247 @@ +import Foundation + +#if os(iOS) + final class RadrootsTransferSessionDelegate: NSObject, + URLSessionDownloadDelegate, URLSessionDataDelegate, + URLSessionTaskDelegate, @unchecked Sendable { + private static let absoluteMaximumResponseBodyBytes = 65536 + private let coordinator: RadrootsTransferCoordinator + private let downloadStagingRoot: URL + private let fileManager: FileManager + private let lock = NSLock() + private var stagedDownloadResultsByTaskIdentifier: [Int: RadrootsStagedBackgroundDownloadResult] + private var responseBodyLimitsByTaskIdentifier: [Int: Int] + private var responseBodiesByTaskIdentifier: [Int: Data] + private var exceededResponseBodyTaskIdentifiers: Set<Int> + + init( + coordinator: RadrootsTransferCoordinator, downloadStagingRoot: URL, + fileManager: FileManager + ) { + self.coordinator = coordinator + self.downloadStagingRoot = downloadStagingRoot + self.fileManager = fileManager + stagedDownloadResultsByTaskIdentifier = [:] + responseBodyLimitsByTaskIdentifier = [:] + responseBodiesByTaskIdentifier = [:] + exceededResponseBodyTaskIdentifiers = [] + } + + func urlSession( + _: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL + ) { + guard let identifier = transferIdentifier(from: downloadTask) else { return } + let result: RadrootsStagedBackgroundDownloadResult + do { + guard + let descriptor = RadrootsBackgroundURLTaskDescriptor( + taskDescription: downloadTask.taskDescription + ), + let fileSize = try location.resourceValues(forKeys: [.fileSizeKey]).fileSize, + fileSize >= 0, + UInt64(fileSize) <= descriptor.maximumTransferBytes + else { + throw RadrootsBackgroundTransferError.transferFailure + } + try fileManager.createDirectory(at: downloadStagingRoot, withIntermediateDirectories: true) + let destination = downloadStagingRoot.appendingPathComponent( + "\(identifier.rawValue)-\(downloadTask.taskIdentifier).download" + ).standardizedFileURL + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + try fileManager.moveItem(at: location, to: destination) + try fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: destination.path + ) + result = .file(destination) + } catch { + result = .failure + } + recordDownloadResult(result, taskIdentifier: downloadTask.taskIdentifier) + } + + func urlSession( + _: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + guard let identifier = transferIdentifier(from: downloadTask) else { return } + if exceedsTransferLimit( + task: downloadTask, + bytesTransferred: totalBytesWritten, + totalBytesExpected: totalBytesExpectedToWrite + ) { + downloadTask.cancel() + return + } + Task { + await coordinator.updateProgress( + identifier: identifier, bytesTransferred: totalBytesWritten, + totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToWrite), + executionID: RadrootsBackgroundURLTaskDescriptor(taskDescription: downloadTask.taskDescription)? + .executionID + ) + } + } + + func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + let shouldCancel = appendResponseBody(data, task: dataTask) + if shouldCancel { + dataTask.cancel() + } + } + + func urlSession( + _: URLSession, task: URLSessionTask, didSendBodyData _: Int64, totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + guard let identifier = transferIdentifier(from: task) else { return } + if exceedsTransferLimit( + task: task, bytesTransferred: totalBytesSent, totalBytesExpected: totalBytesExpectedToSend + ) { + task.cancel() + return + } + Task { + await coordinator.updateProgress( + identifier: identifier, bytesTransferred: totalBytesSent, + totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend) + ) + } + } + + func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let bytesTransferred = max(max(task.countOfBytesReceived, task.countOfBytesSent), 0) + let expected = Self.expectedByteCount( + max(task.countOfBytesExpectedToReceive, task.countOfBytesExpectedToSend) + ) + let stagedDownloadResult = takeDownloadResult(taskIdentifier: task.taskIdentifier) + let httpResult = takeHTTPResult(for: task) + guard let identifier = transferIdentifier(from: task) else { + if case let .file(url) = stagedDownloadResult { + try? fileManager.removeItem(at: url) + } + return + } + Task { + await coordinator.complete( + identifier: identifier, + completion: RadrootsTransferCompletion(platformError: error, + stagedDownloadResult: stagedDownloadResult, + httpResult: httpResult, + bytesTransferred: bytesTransferred, + totalBytesExpected: expected), + executionID: RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.executionID + ) + await coordinator.releaseUploadLease( + executionID: RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.executionID + ) + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + Task { + await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier) + } + } + + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest _: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } + + private func recordDownloadResult( + _ result: RadrootsStagedBackgroundDownloadResult, taskIdentifier: Int + ) { + lock.lock() + defer { lock.unlock() } + stagedDownloadResultsByTaskIdentifier[taskIdentifier] = result + } + + private func takeDownloadResult(taskIdentifier: Int) -> RadrootsStagedBackgroundDownloadResult? { + lock.lock() + defer { lock.unlock() } + return stagedDownloadResultsByTaskIdentifier.removeValue(forKey: taskIdentifier) + } + + func registerResponseBodyLimit(_ limit: Int, taskIdentifier: Int) { + lock.lock() + defer { lock.unlock() } + responseBodyLimitsByTaskIdentifier[taskIdentifier] = min( + max(limit, 0), Self.absoluteMaximumResponseBodyBytes + ) + } + + private func appendResponseBody(_ data: Data, task: URLSessionDataTask) -> Bool { + lock.lock() + defer { lock.unlock() } + let taskIdentifier = task.taskIdentifier + guard !exceededResponseBodyTaskIdentifiers.contains(taskIdentifier) else { return false } + let limit = + responseBodyLimitsByTaskIdentifier[taskIdentifier] + ?? RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)? + .maximumResponseBodyBytes + ?? Self.absoluteMaximumResponseBodyBytes + guard limit > 0 else { return false } + let currentCount = responseBodiesByTaskIdentifier[taskIdentifier]?.count ?? 0 + guard data.count <= limit - currentCount else { + responseBodiesByTaskIdentifier.removeValue(forKey: taskIdentifier) + exceededResponseBodyTaskIdentifiers.insert(taskIdentifier) + return true + } + responseBodiesByTaskIdentifier[taskIdentifier, default: Data()].append(data) + return false + } + + private func takeHTTPResult(for task: URLSessionTask) -> RadrootsBackgroundHTTPResult { + lock.lock() + let body = responseBodiesByTaskIdentifier.removeValue(forKey: task.taskIdentifier) + responseBodyLimitsByTaskIdentifier.removeValue(forKey: task.taskIdentifier) + let exceeded = exceededResponseBodyTaskIdentifiers.remove(task.taskIdentifier) != nil + lock.unlock() + + guard let response = task.response as? HTTPURLResponse else { + return RadrootsBackgroundHTTPResult( + statusCode: nil, mediaType: nil, body: body, bodyExceeded: exceeded + ) + } + let rawMediaType = response.value(forHTTPHeaderField: "Content-Type") + let mediaType = rawMediaType.flatMap { + try? RadrootsBackgroundTransferValidation.normalizedMediaType($0) + } + let contentEncoding = response.value(forHTTPHeaderField: "Content-Encoding")? + .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return RadrootsBackgroundHTTPResult( + statusCode: response.statusCode, mediaType: mediaType, body: body, + contentEncoding: contentEncoding, bodyExceeded: exceeded, + mediaTypeWasMalformed: rawMediaType != nil && mediaType == nil + ) + } + + private func transferIdentifier(from task: URLSessionTask) + -> RadrootsBackgroundTransferIdentifier? { + RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier + } + + private func exceedsTransferLimit( + task: URLSessionTask, bytesTransferred: Int64, totalBytesExpected: Int64 + ) -> Bool { + guard + let descriptor = RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription) + else { return true } + return bytesTransferred > 0 && UInt64(bytesTransferred) > descriptor.maximumTransferBytes + || totalBytesExpected > 0 && UInt64(totalBytesExpected) > descriptor.maximumTransferBytes + } + + private static func expectedByteCount(_ value: Int64) -> Int64? { + value >= 0 ? value : nil + } + } +#endif diff --git a/Sources/RadrootsKit/RadrootsAtomicFile.swift b/Sources/RadrootsKit/RadrootsAtomicFile.swift @@ -9,6 +9,53 @@ enum RadrootsAtomicFile { enum Phase: CaseIterable { case afterWriteChunk, afterWrite, beforeFileSync, beforeInstall, beforeDirectorySync } static let maximumBytes = 512 * 1024 * 1024 + /// A short, synchronous cross-owner transaction. Contention fails closed + /// instead of blocking a cooperative executor or awaiting under a lock. + static func withExclusiveLock<T>(at url: URL, _ body: () throws -> T) throws -> T { + guard let descriptor = try acquireExclusiveLock(at: url) else { + throw RadrootsAppleFileError.permanentFailure + } + defer { Darwin.close(descriptor) } + return try body() + } + + /// The caller owns and closes the returned descriptor. Nil denotes only + /// active contention; invalid paths and I/O failures remain errors. + static func acquireExclusiveLock(at url: URL) throws -> Int32? { + let parts = url.path.split(separator: "/").map(String.init) + guard url.isFileURL, !url.path.utf8.contains(0), let leaf = parts.last, + parts.allSatisfy({ $0 != "." && $0 != ".." }) + else { throw RadrootsAppleFileError.invalidRequest } + let directory = try Directory.open(Array(parts.dropLast()), create: true) + defer { Darwin.close(directory.descriptor) } + let descriptor = leaf.withCString { + Darwin.openat( + directory.descriptor, + $0, + O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK | O_EXLOCK, + 0o600 + ) + } + guard descriptor >= 0 else { + if errno == EWOULDBLOCK { + return nil + } + throw RadrootsAppleFileError.permanentFailure + } + var value = stat() + guard Darwin.fstat(descriptor, &value) == 0, value.st_mode & S_IFMT == S_IFREG + else { + Darwin.close(descriptor) + throw RadrootsAppleFileError.permanentFailure + } + do { + try directory.validate() + } catch { + Darwin.close(descriptor); throw error + } + return descriptor + } + static func install(_ data: Data, at url: URL, mode: Mode = .replace, readOnly: Bool = false) throws { try install(data, at: url, mode: mode, readOnly: readOnly, fault: nil) } diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift @@ -8,84 +8,80 @@ public enum RadrootsBackgroundTransferError: Error, Equatable, Sendable { } extension RadrootsBackgroundTransferError: LocalizedError { - public var errorDescription: String? { - switch self { + public var errorDescription: String? { + switch self { case .invalidRequest: "The background transfer request is invalid." 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." + } } - } } public struct RadrootsBackgroundTransferIdentifier: Sendable, Equatable, Hashable, Comparable, - Codable -{ - public let rawValue: String + Codable { + public let rawValue: String - public init(_ value: String) throws { - rawValue = try RadrootsBackgroundTransferValidation.normalizedIdentifier(value) - } + public init(_ value: String) throws { + rawValue = try RadrootsBackgroundTransferValidation.normalizedIdentifier(value) + } - public static func generated() -> Self { - Self(validatedRawValue: UUID().uuidString.lowercased()) - } + public static func generated() -> Self { + Self(validatedRawValue: UUID().uuidString.lowercased()) + } - public static func < (lhs: Self, rhs: Self) -> Bool { - lhs.rawValue < rhs.rawValue - } + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } - private init(validatedRawValue: String) { - rawValue = validatedRawValue - } + private init(validatedRawValue: String) { + rawValue = validatedRawValue + } - private enum CodingKeys: String, CodingKey { case rawValue } + private enum CodingKeys: String, CodingKey { case rawValue } - public init(from decoder: any Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - try self.init(values.decode(String.self, forKey: .rawValue)) - } + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init(values.decode(String.self, forKey: .rawValue)) + } - public func encode(to encoder: any Encoder) throws { - var values = encoder.container(keyedBy: CodingKeys.self) - try values.encode(rawValue, forKey: .rawValue) - } + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(rawValue, forKey: .rawValue) + } } public enum RadrootsBackgroundTransferMethod: String, Sendable, Equatable, Hashable, Codable, - CaseIterable -{ - case get = "GET" - case post = "POST" - case put = "PUT" + CaseIterable { + case get = "GET" + case post = "POST" + case put = "PUT" } public enum RadrootsBackgroundTransferLocalFile: Sendable, Equatable, Hashable, Codable { - case file(RadrootsFileReference) - case stagedBlob(RadrootsStagedBlobReference) + case file(RadrootsFileReference) + case stagedBlob(RadrootsStagedBlobReference) } public enum RadrootsBackgroundTransferOperation: Sendable, Equatable, Hashable, Codable { - case download(destination: RadrootsBackgroundTransferLocalFile) - case upload(source: RadrootsBackgroundTransferLocalFile) + case download(destination: RadrootsBackgroundTransferLocalFile) + case upload(source: RadrootsBackgroundTransferLocalFile) } public enum RadrootsBackgroundTransferState: String, Sendable, Equatable, Hashable, Codable, - CaseIterable -{ - case queued - case running - case awaitingVerification - case completed - case failed - case cancelled - case expired - case interrupted + CaseIterable { + case queued + case running + case awaitingVerification + case completed + case failed + case cancelled + case expired + case interrupted } public enum RadrootsBackgroundTransferFailure: String, Sendable, Equatable, Hashable, Codable, - CaseIterable -{ + CaseIterable { case enqueueFailed = "background_transfer_enqueue_failed" case expired = "background_transfer_expired" case interrupted = "background_transfer_interrupted" @@ -103,946 +99,107 @@ public enum RadrootsBackgroundTransferFailure: String, Sendable, Equatable, Hash } public enum RadrootsBackgroundTransferNetworkPolicy: String, Sendable, Equatable, Hashable, Codable { - case publicHTTPS - case simulatorLoopbackHTTP -} - -public struct RadrootsBackgroundTransferResponsePolicy: Sendable, Equatable, Hashable, Codable { - public let maximumBodyBytes: Int - public let acceptedMediaTypes: [String] - - public init(maximumBodyBytes: Int = 0, acceptedMediaTypes: [String] = []) throws { - guard (0...65536).contains(maximumBodyBytes), acceptedMediaTypes.count <= 8 else { - throw RadrootsBackgroundTransferError.invalidRequest - } - let normalized = try acceptedMediaTypes.map { - try RadrootsBackgroundTransferValidation.normalizedMediaType($0) - } - guard Set(normalized).count == normalized.count, (maximumBodyBytes == 0) == normalized.isEmpty - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - self.maximumBodyBytes = maximumBodyBytes - self.acceptedMediaTypes = normalized - } - - public static let discard = Self(maximumBodyBytes: 0, acceptedMediaTypes: [], validated: ()) - - public static func boundedJSON(maximumBodyBytes: Int = 16384) throws -> Self { - try Self(maximumBodyBytes: maximumBodyBytes, acceptedMediaTypes: ["application/json"]) - } - - private init(maximumBodyBytes: Int, acceptedMediaTypes: [String], validated _: Void) { - self.maximumBodyBytes = maximumBodyBytes - self.acceptedMediaTypes = acceptedMediaTypes - } - - private enum CodingKeys: String, CodingKey { - case maximumBodyBytes - case acceptedMediaTypes - } - - public init(from decoder: any Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - maximumBodyBytes: values.decode(Int.self, forKey: .maximumBodyBytes), - acceptedMediaTypes: values.decode([String].self, forKey: .acceptedMediaTypes) - ) - } - - public func encode(to encoder: any Encoder) throws { - var values = encoder.container(keyedBy: CodingKeys.self) - try values.encode(maximumBodyBytes, forKey: .maximumBodyBytes) - try values.encode(acceptedMediaTypes, forKey: .acceptedMediaTypes) - } -} - -public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, Codable { - public let statusCode: Int - public let mediaType: String? - public let contentEncoding: String? - public let body: Data? - - public init( - statusCode: Int, mediaType: String?, contentEncoding: String? = nil, body: Data? - ) throws { - guard (100...599).contains(statusCode), body?.count ?? 0 <= 65536 else { - throw RadrootsBackgroundTransferError.invalidRequest - } - self.statusCode = statusCode - self.mediaType = try mediaType.map { - try RadrootsBackgroundTransferValidation.normalizedMediaType($0) - } - let normalizedEncoding = contentEncoding?.trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - guard normalizedEncoding == nil || normalizedEncoding == "identity" else { - throw RadrootsBackgroundTransferError.invalidRequest - } - self.contentEncoding = normalizedEncoding - self.body = body - } - - private enum CodingKeys: String, CodingKey { - case statusCode - case mediaType - case contentEncoding - case body - } - - public init(from decoder: any Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - statusCode: values.decode(Int.self, forKey: .statusCode), - mediaType: values.decodeIfPresent(String.self, forKey: .mediaType), - contentEncoding: values.decodeIfPresent(String.self, forKey: .contentEncoding), - body: values.decodeIfPresent(Data.self, forKey: .body) - ) - } - - public func encode(to encoder: any Encoder) throws { - var values = encoder.container(keyedBy: CodingKeys.self) - try values.encode(statusCode, forKey: .statusCode) - try values.encodeIfPresent(mediaType, forKey: .mediaType) - try values.encodeIfPresent(contentEncoding, forKey: .contentEncoding) - try values.encodeIfPresent(body, forKey: .body) - } -} - -public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, Codable, - CustomDebugStringConvertible -{ - public static let defaultMaximumTransferBytes: UInt64 = 64 * 1024 * 1024 - public static let absoluteMaximumTransferBytes: UInt64 = 512 * 1024 * 1024 - - public let identifier: RadrootsBackgroundTransferIdentifier - public let remoteURL: URL - public let method: RadrootsBackgroundTransferMethod - public let operation: RadrootsBackgroundTransferOperation - public let headers: [String: String] - public let metadata: [String: String] - public let networkPolicy: RadrootsBackgroundTransferNetworkPolicy - public let responsePolicy: RadrootsBackgroundTransferResponsePolicy - public let expectedSourceSHA256: String? - public let maximumTransferBytes: UInt64 - - public init( - identifier: RadrootsBackgroundTransferIdentifier = .generated(), remoteURL: URL, - method: RadrootsBackgroundTransferMethod, - operation: RadrootsBackgroundTransferOperation, headers: [String: String] = [:], - metadata: [String: String] = [:], - networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS, - responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard, - expectedSourceSHA256: String? = nil, - maximumTransferBytes: UInt64 = Self.defaultMaximumTransferBytes - ) throws { - try RadrootsBackgroundTransferValidation.validate( - remoteURL: remoteURL, method: method, operation: operation, headers: headers, - metadata: metadata, networkPolicy: networkPolicy, - responsePolicy: responsePolicy, expectedSourceSHA256: expectedSourceSHA256, - maximumTransferBytes: maximumTransferBytes - ) - self.identifier = identifier - self.remoteURL = remoteURL - self.method = method - self.operation = operation - self.headers = headers - self.metadata = metadata - self.networkPolicy = networkPolicy - self.responsePolicy = responsePolicy - self.expectedSourceSHA256 = expectedSourceSHA256 - self.maximumTransferBytes = maximumTransferBytes - } - - public var debugDescription: String { - "RadrootsBackgroundTransferRequest(identifier: \(identifier.rawValue), method: \(method.rawValue), operation: \(operation.redactedLabel), headers: <redacted>, metadataKeys: \(metadata.keys.sorted()), maximumTransferBytes: \(maximumTransferBytes), responseBodyLimit: \(responsePolicy.maximumBodyBytes))" - } - - func redactedForPersistence() throws -> Self { - try Self( - identifier: identifier, remoteURL: remoteURL, method: method, operation: operation, - headers: [:], metadata: [:], - networkPolicy: networkPolicy, responsePolicy: responsePolicy, - expectedSourceSHA256: expectedSourceSHA256, - maximumTransferBytes: maximumTransferBytes - ) - } - - private enum CodingKeys: String, CodingKey { - case identifier - case remoteURL - case method - case operation - case metadata - case networkPolicy - case responsePolicy - case expectedSourceSHA256 - case maximumTransferBytes - } - - public init(from decoder: any Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - identifier: values.decode(RadrootsBackgroundTransferIdentifier.self, forKey: .identifier), - remoteURL: values.decode(URL.self, forKey: .remoteURL), - method: values.decode(RadrootsBackgroundTransferMethod.self, forKey: .method), - operation: values.decode(RadrootsBackgroundTransferOperation.self, forKey: .operation), - headers: [:], - metadata: values.decode([String: String].self, forKey: .metadata), - networkPolicy: values.decodeIfPresent( - RadrootsBackgroundTransferNetworkPolicy.self, forKey: .networkPolicy) ?? .publicHTTPS, - responsePolicy: values.decodeIfPresent( - RadrootsBackgroundTransferResponsePolicy.self, forKey: .responsePolicy) ?? .discard, - expectedSourceSHA256: values.decodeIfPresent(String.self, forKey: .expectedSourceSHA256), - maximumTransferBytes: values.decodeIfPresent(UInt64.self, forKey: .maximumTransferBytes) - ?? Self.defaultMaximumTransferBytes - ) - } - - public func encode(to encoder: any Encoder) throws { - var values = encoder.container(keyedBy: CodingKeys.self) - try values.encode(identifier, forKey: .identifier) - try values.encode(remoteURL, forKey: .remoteURL) - try values.encode(method, forKey: .method) - try values.encode(operation, forKey: .operation) - try values.encode(metadata, forKey: .metadata) - try values.encode(networkPolicy, forKey: .networkPolicy) - try values.encode(responsePolicy, forKey: .responsePolicy) - try values.encodeIfPresent(expectedSourceSHA256, forKey: .expectedSourceSHA256) - try values.encode(maximumTransferBytes, forKey: .maximumTransferBytes) - } -} - -public struct RadrootsBackgroundDownloadedArtifact: Sendable, Equatable, Hashable, Codable, - CustomDebugStringConvertible -{ - public let file: RadrootsFileReference - public let sha256: String - public let byteSize: UInt64 - public let mediaType: String? - - public init(file: RadrootsFileReference, sha256: String, byteSize: UInt64, mediaType: String?) - throws - { - guard sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, byteSize > 0, - byteSize <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - try RadrootsBackgroundTransferValidation.validateLocalFile(.file(file)) - self.file = file - self.sha256 = sha256 - self.byteSize = byteSize - self.mediaType = try mediaType.map { - try RadrootsBackgroundTransferValidation.normalizedMediaType($0) - } - } - - public var debugDescription: String { - "RadrootsBackgroundDownloadedArtifact(sha256: \(sha256), byteSize: \(byteSize), mediaType: \(mediaType ?? "none"), file: <redacted>)" - } -} - -public enum RadrootsBackgroundTransferVerification: Sendable, Equatable { - case accepted - case rejected(failure: RadrootsBackgroundTransferFailure) -} - -public struct RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, Codable, - CustomDebugStringConvertible -{ - public let identifier: RadrootsBackgroundTransferIdentifier - public let request: RadrootsBackgroundTransferRequest - - public init(request: RadrootsBackgroundTransferRequest) { - identifier = request.identifier - self.request = request - } - - public var debugDescription: String { - "RadrootsBackgroundTransferHandle(identifier: \(identifier.rawValue), request: \(request.debugDescription))" - } - - private enum CodingKeys: String, CodingKey { - case identifier - case request - } - - public init(from decoder: any Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - let identifier = try values.decode( - RadrootsBackgroundTransferIdentifier.self, forKey: .identifier) - let request = try values.decode(RadrootsBackgroundTransferRequest.self, forKey: .request) - guard identifier == request.identifier else { - throw RadrootsBackgroundTransferError.invalidRequest - } - self.init(request: request) - } - - public func encode(to encoder: any Encoder) throws { - var values = encoder.container(keyedBy: CodingKeys.self) - try values.encode(identifier, forKey: .identifier) - try values.encode(request, forKey: .request) - } -} - -public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable, Codable { - public let bytesTransferred: Int64 - public let totalBytesExpected: Int64? - - public init(bytesTransferred: Int64, totalBytesExpected: Int64? = nil) throws { - guard bytesTransferred >= 0 else { - throw RadrootsBackgroundTransferError.invalidRequest - } - if let totalBytesExpected { - guard totalBytesExpected >= 0 else { - throw RadrootsBackgroundTransferError.invalidRequest - } - guard totalBytesExpected >= bytesTransferred else { - throw RadrootsBackgroundTransferError.invalidRequest - } - } - self.bytesTransferred = bytesTransferred - self.totalBytesExpected = totalBytesExpected - } - - public static let zero = RadrootsBackgroundTransferProgress( - validatedBytesTransferred: 0, totalBytesExpected: nil) - - private init(validatedBytesTransferred: Int64, totalBytesExpected: Int64?) { - bytesTransferred = validatedBytesTransferred - self.totalBytesExpected = totalBytesExpected - } - - private enum CodingKeys: String, CodingKey { - case bytesTransferred - case totalBytesExpected - } - - public init(from decoder: any Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - bytesTransferred: values.decode(Int64.self, forKey: .bytesTransferred), - totalBytesExpected: values.decodeIfPresent(Int64.self, forKey: .totalBytesExpected) - ) - } - - public func encode(to encoder: any Encoder) throws { - var values = encoder.container(keyedBy: CodingKeys.self) - try values.encode(bytesTransferred, forKey: .bytesTransferred) - try values.encodeIfPresent(totalBytesExpected, forKey: .totalBytesExpected) - } -} - -public struct RadrootsBackgroundTransferSnapshot: Sendable, Equatable, Hashable, Codable, - CustomDebugStringConvertible -{ - public let identifier: RadrootsBackgroundTransferIdentifier - public let request: RadrootsBackgroundTransferRequest - public let state: RadrootsBackgroundTransferState - public let progress: RadrootsBackgroundTransferProgress - public let failure: RadrootsBackgroundTransferFailure? - public let response: RadrootsBackgroundTransferResponse? - public let downloadedArtifact: RadrootsBackgroundDownloadedArtifact? - public let possibleRemoteOrphan: Bool - public let updatedAt: Date - - public init( - request: RadrootsBackgroundTransferRequest, state: RadrootsBackgroundTransferState = .queued, - progress: RadrootsBackgroundTransferProgress = .zero, - failure: RadrootsBackgroundTransferFailure? = nil, - response: RadrootsBackgroundTransferResponse? = nil, - downloadedArtifact: RadrootsBackgroundDownloadedArtifact? = nil, - possibleRemoteOrphan: Bool = false, updatedAt: Date = Date() - ) throws { - guard updatedAt.timeIntervalSinceReferenceDate.isFinite else { - throw RadrootsBackgroundTransferError.invalidRequest - } - guard response == nil || state == .awaitingVerification || state == .completed else { - throw RadrootsBackgroundTransferError.invalidRequest - } - if let response { - try RadrootsBackgroundTransferValidation.validate( - response: response, policy: request.responsePolicy) - } - if possibleRemoteOrphan { - guard case .upload = request.operation, - state == .failed || state == .interrupted || state == .cancelled || state == .expired - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - } - if let downloadedArtifact { - guard case .download(let destination) = request.operation, - destination == .file(downloadedArtifact.file), - state == .awaitingVerification || state == .completed - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - } - identifier = request.identifier - self.request = request - self.state = state - self.progress = progress - self.failure = failure - self.response = response - self.downloadedArtifact = downloadedArtifact - self.possibleRemoteOrphan = possibleRemoteOrphan - self.updatedAt = updatedAt - } - - public var debugDescription: String { - let statusCode = response?.statusCode.description ?? "none" - return - "RadrootsBackgroundTransferSnapshot(identifier: \(identifier.rawValue), state: \(state.rawValue), bytesTransferred: \(progress.bytesTransferred), statusCode: \(statusCode), downloadedArtifact: \(downloadedArtifact == nil ? "none" : "present"), possibleRemoteOrphan: \(possibleRemoteOrphan))" - } - - func redactedForPersistence() throws -> Self { - try Self( - request: request.redactedForPersistence(), state: state, progress: progress, - failure: failure, response: response, - downloadedArtifact: downloadedArtifact, possibleRemoteOrphan: possibleRemoteOrphan, - updatedAt: updatedAt - ) - } - - private enum CodingKeys: String, CodingKey { - case request - case state - case progress - case failure - case response - case downloadedArtifact - case possibleRemoteOrphan - case updatedAt - } - - public init(from decoder: any Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - request: values.decode(RadrootsBackgroundTransferRequest.self, forKey: .request), - state: values.decode(RadrootsBackgroundTransferState.self, forKey: .state), - progress: values.decode(RadrootsBackgroundTransferProgress.self, forKey: .progress), - failure: values.decodeIfPresent(RadrootsBackgroundTransferFailure.self, forKey: .failure), - response: values.decodeIfPresent(RadrootsBackgroundTransferResponse.self, forKey: .response), - downloadedArtifact: values.decodeIfPresent( - RadrootsBackgroundDownloadedArtifact.self, forKey: .downloadedArtifact), - possibleRemoteOrphan: values.decodeIfPresent(Bool.self, forKey: .possibleRemoteOrphan) - ?? false, - updatedAt: values.decode(Date.self, forKey: .updatedAt) - ) - } - - public func encode(to encoder: any Encoder) throws { - var values = encoder.container(keyedBy: CodingKeys.self) - try values.encode(request, forKey: .request) - try values.encode(state, forKey: .state) - try values.encode(progress, forKey: .progress) - try values.encodeIfPresent(failure, forKey: .failure) - try values.encodeIfPresent(response, forKey: .response) - try values.encodeIfPresent(downloadedArtifact, forKey: .downloadedArtifact) - try values.encode(possibleRemoteOrphan, forKey: .possibleRemoteOrphan) - try values.encode(updatedAt, forKey: .updatedAt) - } + case publicHTTPS + case simulatorLoopbackHTTP } public protocol RadrootsBackgroundTransferStore: Sendable { - func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] - func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws - func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws - func removeAllSnapshots() async throws + /// Serializes admission across suspension and independent owners. This is a + /// nonblocking reservation, separate from short persistence transactions. + func withAdmission( + for identifier: RadrootsBackgroundTransferIdentifier, + operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle + ) async throws -> RadrootsBackgroundTransferHandle + func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool + /// Atomically installs a redacted snapshot only if the exact expected value + /// still owns the identifier. Nil reserves a previously absent identifier. + func compareExchangeSnapshot( + expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot + ) async throws -> Bool + func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] + func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws + func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws + func removeAllSnapshots() async throws } public protocol RadrootsBackgroundTransfer: Sendable { - func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws - -> RadrootsBackgroundTransferHandle - func retry(_ request: RadrootsBackgroundTransferRequest) async throws - -> RadrootsBackgroundTransferHandle - func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws - func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws - func settle( - _ identifier: RadrootsBackgroundTransferIdentifier, - verification: RadrootsBackgroundTransferVerification) async throws - func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws - -> RadrootsBackgroundTransferSnapshot? - func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] - func handleEventsForBackgroundURLSession( - identifier: String, completionHandler: @escaping @Sendable () -> Void) async + func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle + func retry(_ request: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle + func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws + func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws + func settle( + _ identifier: RadrootsBackgroundTransferIdentifier, + verification: RadrootsBackgroundTransferVerification + ) async throws + func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws + -> RadrootsBackgroundTransferSnapshot? + func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] + func handleEventsForBackgroundURLSession( + identifier: String, completionHandler: @escaping @Sendable () -> Void + ) async } public protocol RadrootsBackgroundTransferFileResolver: Sendable { - func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL - func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data -} - -public struct RadrootsAppleBackgroundTransferFileResolver: RadrootsBackgroundTransferFileResolver, - Sendable -{ - private let roots: RadrootsAppleFileRoots - - public init(roots: RadrootsAppleFileRoots) { - self.roots = roots - } - - public func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL { - let candidate: URL - let root: URL - switch file { - case .file(let reference): - candidate = try roots.resolvedURL(for: reference) - root = roots.root(for: reference.scope) - case .stagedBlob(let blob): - candidate = try roots.stagedBlobURL(for: blob) - root = roots.stagedBlobsRoot - } - let resolvedRoot = root.resolvingSymlinksInPath().standardizedFileURL - let resolvedCandidate = candidate.resolvingSymlinksInPath().standardizedFileURL - guard resolvedCandidate.path.hasPrefix(resolvedRoot.path + "/") else { - throw RadrootsBackgroundTransferError.invalidRequest - } - return candidate - } - - public func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data { - guard maximumBytes >= 0 else { - throw RadrootsBackgroundTransferError.invalidRequest - } - let root: URL - let relativePath: String - let expectedBytes: Int? - switch file { - case .file(let reference): - root = roots.root(for: reference.scope) - relativePath = reference.relativePath - expectedBytes = nil - case .stagedBlob(let blob): - root = roots.stagedBlobsRoot - relativePath = blob.blobID - expectedBytes = blob.sizeBytes - } - do { - let data = try RadrootsGovernedFileReader.read( - root: root, - relativePath: relativePath, - maximumBytes: maximumBytes - ) - guard expectedBytes == nil || expectedBytes == data.count else { - throw RadrootsBackgroundTransferError.invalidRequest - } - return data - } catch let error as RadrootsBackgroundTransferError { - throw error - } catch { - throw RadrootsBackgroundTransferError.invalidRequest - } - } -} - -public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore { - private static let maximumPersistenceBytes = 1024 * 1024 - - private struct Envelope: Codable { - let schemaVersion: Int - let snapshots: [RadrootsBackgroundTransferSnapshot] - - init(snapshots: [RadrootsBackgroundTransferSnapshot]) { - schemaVersion = 1 - self.snapshots = snapshots - } - } - - private let roots: RadrootsAppleFileRoots - private let fileManager: FileManager - private let encoder: JSONEncoder - private let decoder: JSONDecoder - private let protectedData: RadrootsProtectedDataProvider - - public init( - roots: RadrootsAppleFileRoots, fileManager: FileManager = .default, - protectedData: RadrootsProtectedDataProvider = .available - ) { - self.roots = roots - self.fileManager = fileManager - encoder = JSONEncoder() - decoder = JSONDecoder() - self.protectedData = protectedData - encoder.outputFormatting = [.sortedKeys] - } - - public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - try requireProtectedData() - do { - let url = try storeURL() - let legacyURL = try legacyStoreURL() - let isLegacy: Bool - if fileManager.fileExists(atPath: url.path) { - isLegacy = false - } else if fileManager.fileExists(atPath: legacyURL.path) { - isLegacy = true - } else { - return [] - } - let data = try RadrootsGovernedFileReader.read( - root: roots.root(for: isLegacy ? .cache : .data), - relativePath: "background_transfers/transfers.json", - maximumBytes: Self.maximumPersistenceBytes - ) - let decoded: [RadrootsBackgroundTransferSnapshot] - let usedLegacyEncoding: Bool - if let envelope = try? decoder.decode(Envelope.self, from: data) { - guard envelope.schemaVersion == 1 else { - throw RadrootsBackgroundTransferError.persistenceFailure - } - decoded = envelope.snapshots - usedLegacyEncoding = false - } else { - decoded = try decoder.decode([RadrootsBackgroundTransferSnapshot].self, from: data) - usedLegacyEncoding = true - } - let snapshots = try decoded.map { try $0.redactedForPersistence() }.sorted { left, right in - left.identifier < right.identifier - } - if isLegacy || usedLegacyEncoding { - try write(snapshots) - } - if fileManager.fileExists(atPath: legacyURL.path) { - try fileManager.removeItem(at: legacyURL) - } - return snapshots - } catch { - throw RadrootsBackgroundTransferError.persistenceFailure - } - } - - public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { - try requireProtectedData() - do { - var snapshots = try await loadSnapshots() - snapshots.removeAll { $0.identifier == snapshot.identifier } - try snapshots.append(snapshot.redactedForPersistence()) - try write(snapshots.sorted { left, right in left.identifier < right.identifier }) - } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure - } - } - - public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws { - try requireProtectedData() - do { - var snapshots = try await loadSnapshots() - snapshots.removeAll { $0.identifier == identifier } - try write(snapshots) - } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure - } - } - - public func removeAllSnapshots() async throws { - try requireProtectedData() - do { - for url in try [storeURL(), legacyStoreURL()] where fileManager.fileExists(atPath: url.path) { - try fileManager.removeItem(at: url) - } - } catch let error as RadrootsBackgroundTransferError { throw error } catch { - throw RadrootsBackgroundTransferError.persistenceFailure - } - } - - private func write(_ snapshots: [RadrootsBackgroundTransferSnapshot]) throws { - let url = try storeURL() - try fileManager.createDirectory( - at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - let data = try encoder.encode(Envelope(snapshots: snapshots)) - guard data.count <= Self.maximumPersistenceBytes else { - throw RadrootsBackgroundTransferError.persistenceFailure - } - try data.write(to: url, options: [.atomic]) - #if os(iOS) - try fileManager.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: url.path) - #endif - } - - private func storeURL() throws -> URL { - try roots.resolvedURL( - for: RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.json")) - } - - private func legacyStoreURL() throws -> URL { - try roots.resolvedURL( - for: RadrootsFileReference(scope: .cache, relativePath: "background_transfers/transfers.json") - ) - } - - private func requireProtectedData() throws { - guard protectedData.currentState() == .available else { - throw RadrootsBackgroundTransferError.persistenceFailure - } - } + func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL + func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data + func prepareUploadLease( + for request: RadrootsBackgroundTransferRequest, executionID: UUID, + existing: RadrootsStagedBlobReference? + ) throws -> RadrootsStagedBlobLease + func releaseUploadLease(executionID: UUID) throws } public struct RadrootsUnavailableBackgroundTransfer: RadrootsBackgroundTransfer, Sendable { public init() {} - public func enqueue(_: RadrootsBackgroundTransferRequest) async throws - -> RadrootsBackgroundTransferHandle - { - throw RadrootsBackgroundTransferError.unavailable - } - - public func retry(_: RadrootsBackgroundTransferRequest) async throws - -> RadrootsBackgroundTransferHandle - { - throw RadrootsBackgroundTransferError.unavailable - } - - public func cancel(_: RadrootsBackgroundTransferIdentifier) async throws { + public func enqueue(_: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle { throw RadrootsBackgroundTransferError.unavailable - } - - public func expire(_: RadrootsBackgroundTransferIdentifier) async throws { - throw RadrootsBackgroundTransferError.unavailable - } - - public func settle( - _: RadrootsBackgroundTransferIdentifier, - verification _: RadrootsBackgroundTransferVerification - ) async throws { - throw RadrootsBackgroundTransferError.unavailable - } - - public func snapshot(for _: RadrootsBackgroundTransferIdentifier) async throws - -> RadrootsBackgroundTransferSnapshot? - { - throw RadrootsBackgroundTransferError.unavailable - } - - public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - throw RadrootsBackgroundTransferError.unavailable - } - - public func handleEventsForBackgroundURLSession( - identifier _: String, completionHandler: @escaping @Sendable () -> Void - ) async { - completionHandler() - } -} - -public enum RadrootsBackgroundTransferValidation { - public static func normalizedIdentifier(_ value: String) throws -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { - throw RadrootsBackgroundTransferError.invalidRequest - } - guard trimmed.count <= 128 else { - throw RadrootsBackgroundTransferError.invalidRequest - } - guard - trimmed.range(of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$", options: .regularExpression) - != nil - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - guard !trimmed.contains("..") else { - throw RadrootsBackgroundTransferError.invalidRequest - } - return trimmed - } - - public static func validate( - remoteURL: URL, method: RadrootsBackgroundTransferMethod, - operation: RadrootsBackgroundTransferOperation, headers: [String: String], - metadata: [String: String], networkPolicy: RadrootsBackgroundTransferNetworkPolicy, - responsePolicy: RadrootsBackgroundTransferResponsePolicy, expectedSourceSHA256: String?, - maximumTransferBytes: UInt64 - ) throws { - try validate(remoteURL: remoteURL, networkPolicy: networkPolicy) - try validate(method: method, operation: operation) - try validate(headers: headers) - try validate(metadata: metadata) - guard maximumTransferBytes > 0, - maximumTransferBytes <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - if case .download = operation, responsePolicy != .discard { - throw RadrootsBackgroundTransferError.invalidRequest - } - if let expectedSourceSHA256 { - guard case .upload = operation, - expectedSourceSHA256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - } - if case .upload(.stagedBlob(let blob)) = operation, - UInt64(blob.sizeBytes) > maximumTransferBytes - { - throw RadrootsBackgroundTransferError.invalidRequest } - } - private static func validate( - remoteURL: URL, networkPolicy: RadrootsBackgroundTransferNetworkPolicy - ) throws { - guard let components = URLComponents(url: remoteURL, resolvingAgainstBaseURL: false), - components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, - components.user == nil, - components.password == nil, components.query == nil, components.fragment == nil - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - let scheme = components.scheme?.lowercased() - switch networkPolicy { - case .publicHTTPS: - guard scheme == "https" else { - throw RadrootsBackgroundTransferError.invalidRequest - } - case .simulatorLoopbackHTTP: - #if os(iOS) && !targetEnvironment(simulator) - throw RadrootsBackgroundTransferError.invalidRequest - #else - guard scheme == "http", let host = components.host?.lowercased(), - host == "localhost" || host == "127.0.0.1" || host == "::1" - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - #endif - } - } - - private static func validate( - method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation - ) throws { - switch operation { - case .download(let destination): - guard method == .get else { - throw RadrootsBackgroundTransferError.invalidRequest - } - guard case .file = destination else { - throw RadrootsBackgroundTransferError.invalidRequest - } - try validateLocalFile(destination) - case .upload(let source): - guard method == .post || method == .put else { - throw RadrootsBackgroundTransferError.invalidRequest - } - try validateLocalFile(source) + public func retry(_: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle { + throw RadrootsBackgroundTransferError.unavailable } - } - static func validateLocalFile(_ localFile: RadrootsBackgroundTransferLocalFile) throws { - switch localFile { - case .file(let reference): - let path = reference.relativePath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !path.isEmpty, !NSString(string: path).isAbsolutePath, - !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." } - ) - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - case .stagedBlob(let blob): - guard - (try? RadrootsStagedBlobReference( - blobID: blob.blobID, sizeBytes: blob.sizeBytes, mediaType: blob.mediaType, - filenameHint: blob.filenameHint - )) != nil - else { - throw RadrootsBackgroundTransferError.invalidRequest - } + public func cancel(_: RadrootsBackgroundTransferIdentifier) async throws { + throw RadrootsBackgroundTransferError.unavailable } - } - private static func validate(headers: [String: String]) throws { - guard headers.count <= 32 else { - throw RadrootsBackgroundTransferError.invalidRequest - } - for (key, value) in headers { - try validateSafeText(key, field: "background transfer header name", maximumLength: 80) - guard key.range(of: "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$", options: .regularExpression) != nil, - !["connection", "content-length", "host", "transfer-encoding"].contains(key.lowercased()) - else { - throw RadrootsBackgroundTransferError.invalidRequest - } - try validateSafeText(value, field: "background transfer header value", maximumLength: 8192) + public func expire(_: RadrootsBackgroundTransferIdentifier) async throws { + throw RadrootsBackgroundTransferError.unavailable } - } - private static func validate(metadata: [String: String]) throws { - guard metadata.count <= 32 else { - throw RadrootsBackgroundTransferError.invalidRequest - } - for (key, value) in metadata { - try validateSafeText(key, field: "background transfer metadata key", maximumLength: 80) - try validateSafeText(value, field: "background transfer metadata value", maximumLength: 500) - let unsafeKey = key.lowercased() - guard - !["auth", "token", "secret", "credential", "cookie", "header", "path", "url"].contains( - where: { unsafeKey.contains($0) }) - else { - throw RadrootsBackgroundTransferError.invalidRequest - } + public func settle( + _: RadrootsBackgroundTransferIdentifier, + verification _: RadrootsBackgroundTransferVerification + ) async throws { + throw RadrootsBackgroundTransferError.unavailable } - } - static func normalizedMediaType(_ value: String) throws -> String { - let normalized = - value.split(separator: ";", maxSplits: 1).first?.trimmingCharacters( - in: .whitespacesAndNewlines - ).lowercased() ?? "" - guard - normalized.range(of: "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", options: .regularExpression) - != nil, - normalized.utf8.count <= 127 - else { - throw RadrootsBackgroundTransferError.invalidRequest + public func snapshot(for _: RadrootsBackgroundTransferIdentifier) async throws + -> RadrootsBackgroundTransferSnapshot? { + throw RadrootsBackgroundTransferError.unavailable } - return normalized - } - static func validate( - response: RadrootsBackgroundTransferResponse, policy: RadrootsBackgroundTransferResponsePolicy - ) throws { - guard (200...299).contains(response.statusCode) else { - throw RadrootsBackgroundTransferError.invalidRequest - } - if policy.maximumBodyBytes == 0 { - guard response.body == nil, response.mediaType == nil else { - throw RadrootsBackgroundTransferError.invalidRequest - } - return - } - guard let body = response.body, body.count <= policy.maximumBodyBytes, - let mediaType = response.mediaType, - policy.acceptedMediaTypes.contains(mediaType) - else { - throw RadrootsBackgroundTransferError.invalidRequest + public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + throw RadrootsBackgroundTransferError.unavailable } - } - private static func validateSafeText(_ value: String, field: String, maximumLength: Int) throws { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - throw RadrootsBackgroundTransferError.invalidRequest + public func handleEventsForBackgroundURLSession( + identifier _: String, completionHandler: @escaping @Sendable () -> Void + ) async { + completionHandler() } - guard trimmed.count <= maximumLength else { - throw RadrootsBackgroundTransferError.invalidRequest - } - guard doesNotContainControlCharacters(trimmed) else { - throw RadrootsBackgroundTransferError.invalidRequest - } - } - - private static func doesNotContainControlCharacters(_ value: String) -> Bool { - value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) } - } } extension RadrootsBackgroundTransferOperation { - fileprivate var redactedLabel: String { - switch self { - case .download: "download" - case .upload: "upload" + var redactedLabel: String { + switch self { + case .download: "download" + case .upload: "upload" + } } - } } diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferRequest.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferRequest.swift @@ -0,0 +1,108 @@ +import Foundation + +public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, Codable, + CustomDebugStringConvertible { + public static let defaultMaximumTransferBytes: UInt64 = 64 * 1024 * 1024 + public static let absoluteMaximumTransferBytes: UInt64 = 512 * 1024 * 1024 + + public let identifier: RadrootsBackgroundTransferIdentifier + public let remoteURL: URL + public let method: RadrootsBackgroundTransferMethod + public let operation: RadrootsBackgroundTransferOperation + public let headers: [String: String] + public let metadata: [String: String] + public let networkPolicy: RadrootsBackgroundTransferNetworkPolicy + public let responsePolicy: RadrootsBackgroundTransferResponsePolicy + public let expectedSourceSHA256: String? + public let maximumTransferBytes: UInt64 + + public init( + identifier: RadrootsBackgroundTransferIdentifier = .generated(), remoteURL: URL, + method: RadrootsBackgroundTransferMethod, + operation: RadrootsBackgroundTransferOperation, headers: [String: String] = [:], + metadata: [String: String] = [:], + networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS, + responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard, + expectedSourceSHA256: String? = nil, + maximumTransferBytes: UInt64 = Self.defaultMaximumTransferBytes + ) throws { + try RadrootsBackgroundTransferValidation.validate( + remoteURL: remoteURL, method: method, operation: operation, headers: headers, + metadata: metadata, networkPolicy: networkPolicy, + responsePolicy: responsePolicy, expectedSourceSHA256: expectedSourceSHA256, + maximumTransferBytes: maximumTransferBytes + ) + self.identifier = identifier + self.remoteURL = remoteURL + self.method = method + self.operation = operation + self.headers = headers + self.metadata = metadata + self.networkPolicy = networkPolicy + self.responsePolicy = responsePolicy + self.expectedSourceSHA256 = expectedSourceSHA256 + self.maximumTransferBytes = maximumTransferBytes + } + + public var debugDescription: String { + "RadrootsBackgroundTransferRequest(identifier: \(identifier.rawValue), method: \(method.rawValue), " + + "operation: \(operation.redactedLabel), headers: <redacted>, metadataKeys: \(metadata.keys.sorted()), " + + "maximumTransferBytes: \(maximumTransferBytes), responseBodyLimit: \(responsePolicy.maximumBodyBytes))" + } + + func redactedForPersistence() throws -> Self { + try Self( + identifier: identifier, remoteURL: remoteURL, method: method, operation: operation, + headers: [:], metadata: [:], + networkPolicy: networkPolicy, responsePolicy: responsePolicy, + expectedSourceSHA256: expectedSourceSHA256, + maximumTransferBytes: maximumTransferBytes + ) + } + + private enum CodingKeys: String, CodingKey { + case identifier + case remoteURL + case method + case operation + case metadata + case networkPolicy + case responsePolicy + case expectedSourceSHA256 + case maximumTransferBytes + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + identifier: values.decode(RadrootsBackgroundTransferIdentifier.self, forKey: .identifier), + remoteURL: values.decode(URL.self, forKey: .remoteURL), + method: values.decode(RadrootsBackgroundTransferMethod.self, forKey: .method), + operation: values.decode(RadrootsBackgroundTransferOperation.self, forKey: .operation), + headers: [:], + metadata: values.decode([String: String].self, forKey: .metadata), + networkPolicy: values.decodeIfPresent( + RadrootsBackgroundTransferNetworkPolicy.self, forKey: .networkPolicy + ) ?? .publicHTTPS, + responsePolicy: values.decodeIfPresent( + RadrootsBackgroundTransferResponsePolicy.self, forKey: .responsePolicy + ) ?? .discard, + expectedSourceSHA256: values.decodeIfPresent(String.self, forKey: .expectedSourceSHA256), + maximumTransferBytes: values.decodeIfPresent(UInt64.self, forKey: .maximumTransferBytes) + ?? Self.defaultMaximumTransferBytes + ) + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(identifier, forKey: .identifier) + try values.encode(remoteURL, forKey: .remoteURL) + try values.encode(method, forKey: .method) + try values.encode(operation, forKey: .operation) + try values.encode(metadata, forKey: .metadata) + try values.encode(networkPolicy, forKey: .networkPolicy) + try values.encode(responsePolicy, forKey: .responsePolicy) + try values.encodeIfPresent(expectedSourceSHA256, forKey: .expectedSourceSHA256) + try values.encode(maximumTransferBytes, forKey: .maximumTransferBytes) + } +} diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferResponse.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferResponse.swift @@ -0,0 +1,102 @@ +import Foundation + +public struct RadrootsBackgroundTransferResponsePolicy: Sendable, Equatable, Hashable, Codable { + public let maximumBodyBytes: Int + public let acceptedMediaTypes: [String] + + public init(maximumBodyBytes: Int = 0, acceptedMediaTypes: [String] = []) throws { + guard (0 ... 65536).contains(maximumBodyBytes), acceptedMediaTypes.count <= 8 else { + throw RadrootsBackgroundTransferError.invalidRequest + } + let normalized = try acceptedMediaTypes.map { + try RadrootsBackgroundTransferValidation.normalizedMediaType($0) + } + guard Set(normalized).count == normalized.count, (maximumBodyBytes == 0) == normalized.isEmpty + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + self.maximumBodyBytes = maximumBodyBytes + self.acceptedMediaTypes = normalized + } + + public static let discard = Self(maximumBodyBytes: 0, acceptedMediaTypes: [], validated: ()) + + public static func boundedJSON(maximumBodyBytes: Int = 16384) throws -> Self { + try Self(maximumBodyBytes: maximumBodyBytes, acceptedMediaTypes: ["application/json"]) + } + + private init(maximumBodyBytes: Int, acceptedMediaTypes: [String], validated _: Void) { + self.maximumBodyBytes = maximumBodyBytes + self.acceptedMediaTypes = acceptedMediaTypes + } + + private enum CodingKeys: String, CodingKey { + case maximumBodyBytes + case acceptedMediaTypes + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + maximumBodyBytes: values.decode(Int.self, forKey: .maximumBodyBytes), + acceptedMediaTypes: values.decode([String].self, forKey: .acceptedMediaTypes) + ) + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(maximumBodyBytes, forKey: .maximumBodyBytes) + try values.encode(acceptedMediaTypes, forKey: .acceptedMediaTypes) + } +} + +public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, Codable { + public let statusCode: Int + public let mediaType: String? + public let contentEncoding: String? + public let body: Data? + + public init( + statusCode: Int, mediaType: String?, contentEncoding: String? = nil, body: Data? + ) throws { + guard (100 ... 599).contains(statusCode), body?.count ?? 0 <= 65536 else { + throw RadrootsBackgroundTransferError.invalidRequest + } + self.statusCode = statusCode + self.mediaType = try mediaType.map { + try RadrootsBackgroundTransferValidation.normalizedMediaType($0) + } + let normalizedEncoding = contentEncoding?.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard normalizedEncoding == nil || normalizedEncoding == "identity" else { + throw RadrootsBackgroundTransferError.invalidRequest + } + self.contentEncoding = normalizedEncoding + self.body = body + } + + private enum CodingKeys: String, CodingKey { + case statusCode + case mediaType + case contentEncoding + case body + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + statusCode: values.decode(Int.self, forKey: .statusCode), + mediaType: values.decodeIfPresent(String.self, forKey: .mediaType), + contentEncoding: values.decodeIfPresent(String.self, forKey: .contentEncoding), + body: values.decodeIfPresent(Data.self, forKey: .body) + ) + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(statusCode, forKey: .statusCode) + try values.encodeIfPresent(mediaType, forKey: .mediaType) + try values.encodeIfPresent(contentEncoding, forKey: .contentEncoding) + try values.encodeIfPresent(body, forKey: .body) + } +} diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferSnapshot.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferSnapshot.swift @@ -0,0 +1,134 @@ +import Foundation + +public struct RadrootsBackgroundTransferSnapshot: Sendable, Equatable, Hashable, Codable, + CustomDebugStringConvertible { + public let identifier: RadrootsBackgroundTransferIdentifier + public let request: RadrootsBackgroundTransferRequest + public let state: RadrootsBackgroundTransferState + public let progress: RadrootsBackgroundTransferProgress + public let failure: RadrootsBackgroundTransferFailure? + public let response: RadrootsBackgroundTransferResponse? + public let downloadedArtifact: RadrootsBackgroundDownloadedArtifact? + public let possibleRemoteOrphan: Bool + public let updatedAt: Date + public let executionID: UUID? + public let uploadLease: RadrootsStagedBlobReference? + + public init( + request: RadrootsBackgroundTransferRequest, state: RadrootsBackgroundTransferState = .queued, + progress: RadrootsBackgroundTransferProgress = .zero, + failure: RadrootsBackgroundTransferFailure? = nil, + response: RadrootsBackgroundTransferResponse? = nil, + downloadedArtifact: RadrootsBackgroundDownloadedArtifact? = nil, + possibleRemoteOrphan: Bool = false, updatedAt: Date = Date(), + executionID: UUID? = nil, uploadLease: RadrootsStagedBlobReference? = nil + ) throws { + guard updatedAt.timeIntervalSinceReferenceDate.isFinite else { + throw RadrootsBackgroundTransferError.invalidRequest + } + if let uploadLease { + guard executionID != nil, request.isUpload, + uploadLease.blobID.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, + uploadLease.sizeBytes >= 0, UInt64(uploadLease.sizeBytes) <= request.maximumTransferBytes, + request.expectedSourceSHA256 == nil || request.expectedSourceSHA256 == uploadLease.blobID + else { throw RadrootsBackgroundTransferError.invalidRequest } + } + guard response == nil || state == .awaitingVerification || state == .completed else { + throw RadrootsBackgroundTransferError.invalidRequest + } + if let response { + try RadrootsBackgroundTransferValidation.validate( + response: response, policy: request.responsePolicy + ) + } + if possibleRemoteOrphan { + guard case .upload = request.operation, + state == .failed || state == .interrupted || state == .cancelled || state == .expired + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + } + if let downloadedArtifact { + guard case let .download(destination) = request.operation, + destination == .file(downloadedArtifact.file), + state == .awaitingVerification || state == .completed + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + } + identifier = request.identifier + self.request = request + self.state = state + self.progress = progress + self.failure = failure + self.response = response + self.downloadedArtifact = downloadedArtifact + self.possibleRemoteOrphan = possibleRemoteOrphan + self.updatedAt = updatedAt + self.executionID = executionID + self.uploadLease = uploadLease + } + + public var debugDescription: String { + let statusCode = response?.statusCode.description ?? "none" + return "RadrootsBackgroundTransferSnapshot(identifier: \(identifier.rawValue), state: \(state.rawValue), " + + "bytesTransferred: \(progress.bytesTransferred), statusCode: \(statusCode), " + + "downloadedArtifact: \(downloadedArtifact == nil ? "none" : "present"), " + + "possibleRemoteOrphan: \(possibleRemoteOrphan))" + } + + func redactedForPersistence() throws -> Self { + try Self( + request: request.redactedForPersistence(), state: state, progress: progress, + failure: failure, response: response, + downloadedArtifact: downloadedArtifact, possibleRemoteOrphan: possibleRemoteOrphan, + updatedAt: updatedAt, executionID: executionID, uploadLease: uploadLease + ) + } + + private enum CodingKeys: String, CodingKey { + case request + case state + case progress + case failure + case response + case downloadedArtifact + case possibleRemoteOrphan + case updatedAt + case executionID + case uploadLease + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + request: values.decode(RadrootsBackgroundTransferRequest.self, forKey: .request), + state: values.decode(RadrootsBackgroundTransferState.self, forKey: .state), + progress: values.decode(RadrootsBackgroundTransferProgress.self, forKey: .progress), + failure: values.decodeIfPresent(RadrootsBackgroundTransferFailure.self, forKey: .failure), + response: values.decodeIfPresent(RadrootsBackgroundTransferResponse.self, forKey: .response), + downloadedArtifact: values.decodeIfPresent( + RadrootsBackgroundDownloadedArtifact.self, forKey: .downloadedArtifact + ), + possibleRemoteOrphan: values.decodeIfPresent(Bool.self, forKey: .possibleRemoteOrphan) + ?? false, + updatedAt: values.decode(Date.self, forKey: .updatedAt), + executionID: values.decodeIfPresent(UUID.self, forKey: .executionID), + uploadLease: values.decodeIfPresent(RadrootsStagedBlobReference.self, forKey: .uploadLease) + ) + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(request, forKey: .request) + try values.encode(state, forKey: .state) + try values.encode(progress, forKey: .progress) + try values.encodeIfPresent(failure, forKey: .failure) + try values.encodeIfPresent(response, forKey: .response) + try values.encodeIfPresent(downloadedArtifact, forKey: .downloadedArtifact) + try values.encode(possibleRemoteOrphan, forKey: .possibleRemoteOrphan) + try values.encode(updatedAt, forKey: .updatedAt) + try values.encodeIfPresent(executionID, forKey: .executionID) + try values.encodeIfPresent(uploadLease, forKey: .uploadLease) + } +} diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferTransition.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferTransition.swift @@ -0,0 +1,17 @@ +import Foundation + +extension RadrootsBackgroundTransferSnapshot { + func transitioned( + to state: RadrootsBackgroundTransferState, at date: Date, + failure: RadrootsBackgroundTransferFailure? = nil, + possibleRemoteOrphan: Bool = false + ) throws -> Self { + try Self( + request: request, state: state, progress: progress, failure: failure, + response: state == .completed ? response : nil, + downloadedArtifact: state == .completed ? downloadedArtifact : nil, + possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: date, + executionID: executionID, uploadLease: uploadLease + ) + } +} diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferValidation.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferValidation.swift @@ -0,0 +1,210 @@ +import Foundation + +public enum RadrootsBackgroundTransferValidation { + public static func normalizedIdentifier(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { + throw RadrootsBackgroundTransferError.invalidRequest + } + guard trimmed.count <= 128 else { + throw RadrootsBackgroundTransferError.invalidRequest + } + guard + trimmed.range(of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$", options: .regularExpression) + != nil + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + guard !trimmed.contains("..") else { + throw RadrootsBackgroundTransferError.invalidRequest + } + return trimmed + } + + public static func validate( + remoteURL: URL, method: RadrootsBackgroundTransferMethod, + operation: RadrootsBackgroundTransferOperation, headers: [String: String], + metadata: [String: String], networkPolicy: RadrootsBackgroundTransferNetworkPolicy, + responsePolicy: RadrootsBackgroundTransferResponsePolicy, expectedSourceSHA256: String?, + maximumTransferBytes: UInt64 + ) throws { + try validate(remoteURL: remoteURL, networkPolicy: networkPolicy) + try validate(method: method, operation: operation) + try validate(headers: headers) + try validate(metadata: metadata) + guard maximumTransferBytes > 0, + maximumTransferBytes <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + if case .download = operation, responsePolicy != .discard { + throw RadrootsBackgroundTransferError.invalidRequest + } + if let expectedSourceSHA256 { + guard case .upload = operation, + expectedSourceSHA256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + } + if case let .upload(.stagedBlob(blob)) = operation, + UInt64(blob.sizeBytes) > maximumTransferBytes { + throw RadrootsBackgroundTransferError.invalidRequest + } + } + + private static func validate( + remoteURL: URL, networkPolicy: RadrootsBackgroundTransferNetworkPolicy + ) throws { + guard let components = URLComponents(url: remoteURL, resolvingAgainstBaseURL: false), + components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + components.user == nil, + components.password == nil, components.query == nil, components.fragment == nil + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + let scheme = components.scheme?.lowercased() + switch networkPolicy { + case .publicHTTPS: + guard scheme == "https" else { + throw RadrootsBackgroundTransferError.invalidRequest + } + case .simulatorLoopbackHTTP: + #if os(iOS) && !targetEnvironment(simulator) + throw RadrootsBackgroundTransferError.invalidRequest + #else + guard scheme == "http", let host = components.host?.lowercased(), + host == "localhost" || host == "127.0.0.1" || host == "::1" + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + #endif + } + } + + private static func validate( + method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation + ) throws { + switch operation { + case let .download(destination): + guard method == .get else { + throw RadrootsBackgroundTransferError.invalidRequest + } + guard case .file = destination else { + throw RadrootsBackgroundTransferError.invalidRequest + } + try validateLocalFile(destination) + case let .upload(source): + guard method == .post || method == .put else { + throw RadrootsBackgroundTransferError.invalidRequest + } + try validateLocalFile(source) + } + } + + static func validateLocalFile(_ localFile: RadrootsBackgroundTransferLocalFile) throws { + switch localFile { + case let .file(reference): + let path = reference.relativePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty, !NSString(string: path).isAbsolutePath, + !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." }) + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + case let .stagedBlob(blob): + guard + (try? RadrootsStagedBlobReference( + blobID: blob.blobID, sizeBytes: blob.sizeBytes, mediaType: blob.mediaType, + filenameHint: blob.filenameHint + )) != nil + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + } + } + + private static func validate(headers: [String: String]) throws { + guard headers.count <= 32 else { + throw RadrootsBackgroundTransferError.invalidRequest + } + for (key, value) in headers { + try validateSafeText(key, field: "background transfer header name", maximumLength: 80) + guard key.range(of: "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$", options: .regularExpression) != nil, + !["connection", "content-length", "host", "transfer-encoding"].contains(key.lowercased()) + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + try validateSafeText(value, field: "background transfer header value", maximumLength: 8192) + } + } + + private static func validate(metadata: [String: String]) throws { + guard metadata.count <= 32 else { + throw RadrootsBackgroundTransferError.invalidRequest + } + for (key, value) in metadata { + try validateSafeText(key, field: "background transfer metadata key", maximumLength: 80) + try validateSafeText(value, field: "background transfer metadata value", maximumLength: 500) + let unsafeKey = key.lowercased() + guard + !["auth", "token", "secret", "credential", "cookie", "header", "path", "url"].contains( + where: { unsafeKey.contains($0) } + ) + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + } + } + + static func normalizedMediaType(_ value: String) throws -> String { + let normalized = + value.split(separator: ";", maxSplits: 1).first?.trimmingCharacters( + in: .whitespacesAndNewlines + ).lowercased() ?? "" + guard + normalized.range(of: "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$", options: .regularExpression) + != nil, + normalized.utf8.count <= 127 + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + return normalized + } + + static func validate( + response: RadrootsBackgroundTransferResponse, policy: RadrootsBackgroundTransferResponsePolicy + ) throws { + guard (200 ... 299).contains(response.statusCode) else { + throw RadrootsBackgroundTransferError.invalidRequest + } + if policy.maximumBodyBytes == 0 { + guard response.body == nil, response.mediaType == nil else { + throw RadrootsBackgroundTransferError.invalidRequest + } + return + } + guard let body = response.body, body.count <= policy.maximumBodyBytes, + let mediaType = response.mediaType, + policy.acceptedMediaTypes.contains(mediaType) + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + } + + private static func validateSafeText(_ value: String, field _: String, maximumLength: Int) throws { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsBackgroundTransferError.invalidRequest + } + guard trimmed.count <= maximumLength else { + throw RadrootsBackgroundTransferError.invalidRequest + } + guard doesNotContainControlCharacters(trimmed) else { + throw RadrootsBackgroundTransferError.invalidRequest + } + } + + private static func doesNotContainControlCharacters(_ value: String) -> Bool { + value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) } + } +} diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferValues.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferValues.swift @@ -0,0 +1,122 @@ +import Foundation + +public struct RadrootsBackgroundDownloadedArtifact: Sendable, Equatable, Hashable, Codable, + CustomDebugStringConvertible { + public let file: RadrootsFileReference + public let sha256: String + public let byteSize: UInt64 + public let mediaType: String? + + public init(file: RadrootsFileReference, sha256: String, byteSize: UInt64, mediaType: String?) + throws { + guard sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, byteSize > 0, + byteSize <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes + else { + throw RadrootsBackgroundTransferError.invalidRequest + } + try RadrootsBackgroundTransferValidation.validateLocalFile(.file(file)) + self.file = file + self.sha256 = sha256 + self.byteSize = byteSize + self.mediaType = try mediaType.map { + try RadrootsBackgroundTransferValidation.normalizedMediaType($0) + } + } + + public var debugDescription: String { + "RadrootsBackgroundDownloadedArtifact(sha256: \(sha256), byteSize: \(byteSize), " + + "mediaType: \(mediaType ?? "none"), file: <redacted>)" + } +} + +public enum RadrootsBackgroundTransferVerification: Sendable, Equatable { + case accepted + case rejected(failure: RadrootsBackgroundTransferFailure) +} + +public struct RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, Codable, + CustomDebugStringConvertible { + public let identifier: RadrootsBackgroundTransferIdentifier + public let request: RadrootsBackgroundTransferRequest + + public init(request: RadrootsBackgroundTransferRequest) { + identifier = request.identifier + self.request = request + } + + public var debugDescription: String { + "RadrootsBackgroundTransferHandle(identifier: \(identifier.rawValue), request: \(request.debugDescription))" + } + + private enum CodingKeys: String, CodingKey { + case identifier + case request + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let identifier = try values.decode( + RadrootsBackgroundTransferIdentifier.self, forKey: .identifier + ) + let request = try values.decode(RadrootsBackgroundTransferRequest.self, forKey: .request) + guard identifier == request.identifier else { + throw RadrootsBackgroundTransferError.invalidRequest + } + self.init(request: request) + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(identifier, forKey: .identifier) + try values.encode(request, forKey: .request) + } +} + +public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable, Codable { + public let bytesTransferred: Int64 + public let totalBytesExpected: Int64? + + public init(bytesTransferred: Int64, totalBytesExpected: Int64? = nil) throws { + guard bytesTransferred >= 0 else { + throw RadrootsBackgroundTransferError.invalidRequest + } + if let totalBytesExpected { + guard totalBytesExpected >= 0 else { + throw RadrootsBackgroundTransferError.invalidRequest + } + guard totalBytesExpected >= bytesTransferred else { + throw RadrootsBackgroundTransferError.invalidRequest + } + } + self.bytesTransferred = bytesTransferred + self.totalBytesExpected = totalBytesExpected + } + + public static let zero = RadrootsBackgroundTransferProgress( + validatedBytesTransferred: 0, totalBytesExpected: nil + ) + + private init(validatedBytesTransferred: Int64, totalBytesExpected: Int64?) { + bytesTransferred = validatedBytesTransferred + self.totalBytesExpected = totalBytesExpected + } + + private enum CodingKeys: String, CodingKey { + case bytesTransferred + case totalBytesExpected + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + bytesTransferred: values.decode(Int64.self, forKey: .bytesTransferred), + totalBytesExpected: values.decodeIfPresent(Int64.self, forKey: .totalBytesExpected) + ) + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(bytesTransferred, forKey: .bytesTransferred) + try values.encodeIfPresent(totalBytesExpected, forKey: .totalBytesExpected) + } +} diff --git a/Sources/RadrootsKit/RadrootsBackgroundURLTaskDescriptor.swift b/Sources/RadrootsKit/RadrootsBackgroundURLTaskDescriptor.swift @@ -0,0 +1,101 @@ +import Foundation + +enum RadrootsStagedBackgroundDownloadResult: Sendable, Equatable { + case file(URL) + case failure +} + +struct RadrootsBackgroundHTTPResult: Sendable, Equatable { + let statusCode: Int? + let mediaType: String? + let body: Data? + let contentEncoding: String? + let bodyExceeded: Bool + let mediaTypeWasMalformed: Bool + + init( + statusCode: Int?, mediaType: String?, body: Data?, contentEncoding: String? = nil, + bodyExceeded: Bool, + mediaTypeWasMalformed: Bool = false + ) { + self.statusCode = statusCode + self.mediaType = mediaType + self.body = body + self.contentEncoding = contentEncoding + self.bodyExceeded = bodyExceeded + self.mediaTypeWasMalformed = mediaTypeWasMalformed + } +} + +struct RadrootsBackgroundURLTaskDescriptor: Sendable, Equatable { + private static let prefix = "radroots-transfer-v1" + + let identifier: RadrootsBackgroundTransferIdentifier + let maximumTransferBytes: UInt64 + let maximumResponseBodyBytes: Int + let executionID: UUID? + + init(request: RadrootsBackgroundTransferRequest, executionID: UUID? = nil) { + identifier = request.identifier + maximumTransferBytes = request.maximumTransferBytes + maximumResponseBodyBytes = request.responsePolicy.maximumBodyBytes + self.executionID = executionID + } + + init?(taskDescription: String?) { + guard let taskDescription else { return nil } + let components = taskDescription.split(separator: "|", omittingEmptySubsequences: false) + if components.count == 5, components[0] == "radroots-transfer-v2", + let executionID = UUID(uuidString: String(components[4])), + let previous = Self(taskDescription: ([Self.prefix] + components[1 ... 3].map(String.init)) + .joined(separator: "|")) { + identifier = previous.identifier + maximumTransferBytes = previous.maximumTransferBytes + maximumResponseBodyBytes = previous.maximumResponseBodyBytes + self.executionID = executionID + return + } + if components.count == 4, + components[0] == Substring(Self.prefix), + let identifier = try? RadrootsBackgroundTransferIdentifier(String(components[1])), + let maximumTransferBytes = UInt64(components[2]), + let maximumResponseBodyBytes = Int(components[3]), + maximumTransferBytes > 0, + maximumTransferBytes <= RadrootsBackgroundTransferRequest.absoluteMaximumTransferBytes, + (0 ... 65536).contains(maximumResponseBodyBytes) { + self.identifier = identifier + self.maximumTransferBytes = maximumTransferBytes + self.maximumResponseBodyBytes = maximumResponseBodyBytes + executionID = nil + return + } + guard let legacyIdentifier = try? RadrootsBackgroundTransferIdentifier(taskDescription) else { + return nil + } + identifier = legacyIdentifier + maximumTransferBytes = RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes + maximumResponseBodyBytes = 65536 + executionID = nil + } + + var taskDescription: String { + if let executionID { + return "radroots-transfer-v2|\(identifier.rawValue)|\(maximumTransferBytes)|" + + "\(maximumResponseBodyBytes)|\(executionID.uuidString.lowercased())" + } + return "\(Self.prefix)|\(identifier.rawValue)|\(maximumTransferBytes)|\(maximumResponseBodyBytes)" + } +} + +extension RadrootsBackgroundTransferRequest { + var isUpload: Bool { + if case .upload = operation { + return true + } + return false + } +} + +struct RadrootsTransferResponseError: Error { + let failure: RadrootsBackgroundTransferFailure +} diff --git a/Sources/RadrootsKit/RadrootsStagedBlobLease.swift b/Sources/RadrootsKit/RadrootsStagedBlobLease.swift @@ -22,6 +22,25 @@ public struct RadrootsStagedBlobLease: Sendable, Equatable, CustomDebugStringCon } extension RadrootsAppleFileAccess { + func leaseUploadBytes(_ bytes: Data, identifier: String) throws -> RadrootsStagedBlobLease { + let digest = RadrootsAppleFileDigest.sha256(bytes) + let blob = try RadrootsStagedBlobReference(blobID: digest, sizeBytes: bytes.count) + 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) + } catch { + try validateLease(lease) + try RadrootsAtomicFile.synchronizeExisting(at: url) + } + try validateLease(lease) + return lease + } + + func releaseUploadLease(executionID: UUID) throws { + try RadrootsAtomicFile.remove(at: leaseURL(executionID.uuidString.lowercased())) + } + /// Installs an exact reference without removing a prior file first. Matching /// installs are idempotent. An opaque ID cannot replace different bytes; /// a SHA256 ID can repair corrupted bytes only with its verified preimage. @@ -93,7 +112,9 @@ extension RadrootsAppleFileAccess { guard try leaseURL(lease.identifier) == lease.fileURL else { throw RadrootsAppleFileError.invalidRequest } do { try validateLease(lease) - } catch RadrootsAppleFileError.notFound { return } + } catch RadrootsAppleFileError.notFound { + return + } try RadrootsAtomicFile.remove(at: lease.fileURL) } @@ -114,7 +135,9 @@ extension RadrootsAppleFileAccess { ) } catch RadrootsGovernedFileReadError.unavailable { throw RadrootsAppleFileError.notFound - } catch { throw RadrootsAppleFileError.permanentFailure } + } catch { + throw RadrootsAppleFileError.permanentFailure + } guard bytes.count == lease.blob.sizeBytes, RadrootsAppleFileDigest.sha256(bytes) == lease.sha256 else { throw RadrootsAppleFileError.permanentFailure } diff --git a/Sources/RadrootsKit/RadrootsTransferCompletion.swift b/Sources/RadrootsKit/RadrootsTransferCompletion.swift @@ -0,0 +1,9 @@ +import Foundation + +struct RadrootsTransferCompletion: Sendable { + let platformError: (any Error)? + let stagedDownloadResult: RadrootsStagedBackgroundDownloadResult? + let httpResult: RadrootsBackgroundHTTPResult + let bytesTransferred: Int64 + let totalBytesExpected: Int64? +} diff --git a/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift b/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift @@ -2,203 +2,226 @@ import Foundation import RadrootsKit public actor RadrootsInMemoryBackgroundTransferStore: RadrootsBackgroundTransferStore { + private var admissions: Set<RadrootsBackgroundTransferIdentifier> = [] + + public func withAdmission( + for identifier: RadrootsBackgroundTransferIdentifier, + operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle + ) async throws -> RadrootsBackgroundTransferHandle { + guard admissions.insert(identifier).inserted else { throw RadrootsBackgroundTransferError.invalidRequest } + defer { admissions.remove(identifier) } + return try await operation() + } + + public func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool { + admissions.contains(identifier) + } + private var snapshotsByIdentifier: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] - public init(snapshots: [RadrootsBackgroundTransferSnapshot] = []) { - snapshotsByIdentifier = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.identifier, $0) }) - } + public func compareExchangeSnapshot( + expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot + ) async throws -> Bool { + guard expected == nil || expected?.identifier == desired.identifier else { + throw RadrootsBackgroundTransferError.invalidRequest + } + guard snapshotsByIdentifier[desired.identifier] == expected else { return false } + snapshotsByIdentifier[desired.identifier] = desired + return true + } - public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - snapshotsByIdentifier.values.sorted { left, right in - left.identifier < right.identifier + public init(snapshots: [RadrootsBackgroundTransferSnapshot] = []) { + snapshotsByIdentifier = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.identifier, $0) }) } - } - public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { - snapshotsByIdentifier[snapshot.identifier] = snapshot - } + public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + snapshotsByIdentifier.values.sorted { left, right in + left.identifier < right.identifier + } + } - public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws { - snapshotsByIdentifier.removeValue(forKey: identifier) - } + public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { + snapshotsByIdentifier[snapshot.identifier] = snapshot + } + + public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws { + snapshotsByIdentifier.removeValue(forKey: identifier) + } - public func removeAllSnapshots() async throws { - snapshotsByIdentifier.removeAll() - } + public func removeAllSnapshots() async throws { + snapshotsByIdentifier.removeAll() + } } public actor RadrootsFakeBackgroundTransfer: RadrootsBackgroundTransfer { - private let store: any RadrootsBackgroundTransferStore - private var enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> - private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest] - private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier] - private var handledBackgroundEventIdentifiersValue: [String] - private let updatedAt: Date - - public init( - store: any RadrootsBackgroundTransferStore = RadrootsInMemoryBackgroundTransferStore(), - enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()), - updatedAt: Date = Date(timeIntervalSince1970: 0) - ) { - self.store = store - self.enqueueOutcome = enqueueOutcome - enqueuedRequestsValue = [] - cancelledIdentifiersValue = [] - handledBackgroundEventIdentifiersValue = [] - self.updatedAt = updatedAt - } - - public func setEnqueueOutcome(_ outcome: Result<Void, RadrootsBackgroundTransferError>) { - enqueueOutcome = outcome - } - - public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws - -> RadrootsBackgroundTransferHandle - { - enqueuedRequestsValue.append(request) - switch enqueueOutcome { - case .success: - let snapshot = try RadrootsBackgroundTransferSnapshot( - request: request, - state: .queued, - updatedAt: updatedAt - ) - try await store.saveSnapshot(snapshot) - return RadrootsBackgroundTransferHandle(request: request) - case .failure(let error): - throw error - } - } - - public func retry(_ request: RadrootsBackgroundTransferRequest) async throws - -> RadrootsBackgroundTransferHandle - { - try await enqueue(request) - } - - public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { - cancelledIdentifiersValue.append(identifier) + private let store: any RadrootsBackgroundTransferStore + private var enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> + private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest] + private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier] + private var handledBackgroundEventIdentifiersValue: [String] + private let updatedAt: Date + + public init( + store: any RadrootsBackgroundTransferStore = RadrootsInMemoryBackgroundTransferStore(), + enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()), + updatedAt: Date = Date(timeIntervalSince1970: 0) + ) { + self.store = store + self.enqueueOutcome = enqueueOutcome + enqueuedRequestsValue = [] + cancelledIdentifiersValue = [] + handledBackgroundEventIdentifiersValue = [] + self.updatedAt = updatedAt + } + + public func setEnqueueOutcome(_ outcome: Result<Void, RadrootsBackgroundTransferError>) { + enqueueOutcome = outcome + } + + public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle { + enqueuedRequestsValue.append(request) + switch enqueueOutcome { + case .success: + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: request, + state: .queued, + updatedAt: updatedAt + ) + try await store.saveSnapshot(snapshot) + return RadrootsBackgroundTransferHandle(request: request) + case let .failure(error): + throw error + } + } + + public func retry(_ request: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle { + try await enqueue(request) + } + + public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + cancelledIdentifiersValue.append(identifier) if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) { - let snapshot = try RadrootsBackgroundTransferSnapshot( - request: existing.request, - state: .cancelled, - progress: existing.progress, - updatedAt: updatedAt - ) - try await store.saveSnapshot(snapshot) - } - } - - public func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { - cancelledIdentifiersValue.append(identifier) + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .cancelled, + progress: existing.progress, + updatedAt: updatedAt + ) + try await store.saveSnapshot(snapshot) + } + } + + public func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + cancelledIdentifiersValue.append(identifier) if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) { - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot( - request: existing.request, - state: .expired, - progress: existing.progress, + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .expired, + progress: existing.progress, failure: .expired, - updatedAt: updatedAt - ) - ) + updatedAt: updatedAt + ) + ) + } } - } - public func settle( - _ identifier: RadrootsBackgroundTransferIdentifier, - verification: RadrootsBackgroundTransferVerification - ) async throws { - guard - let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) - else { + public func settle( + _ identifier: RadrootsBackgroundTransferIdentifier, + verification: RadrootsBackgroundTransferVerification + ) async throws { + guard + let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) + else { throw RadrootsBackgroundTransferError.transferFailure - } - switch verification { - case .accepted: - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot( - request: existing.request, - state: .completed, - progress: existing.progress, - response: existing.response, - downloadedArtifact: existing.downloadedArtifact, - updatedAt: updatedAt - ) - ) - case .rejected(let failure): - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot( - request: existing.request, - state: .failed, - progress: existing.progress, + } + switch verification { + case .accepted: + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .completed, + progress: existing.progress, + response: existing.response, + downloadedArtifact: existing.downloadedArtifact, + updatedAt: updatedAt + ) + ) + case let .rejected(failure): + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .failed, + progress: existing.progress, failure: failure, - updatedAt: updatedAt - ) - ) + updatedAt: updatedAt + ) + ) + } } - } - public func complete(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { - guard - let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) - else { + public func complete(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + guard + let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) + else { throw RadrootsBackgroundTransferError.transferFailure + } + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .completed, + progress: existing.progress, + updatedAt: updatedAt + ) + try await store.saveSnapshot(snapshot) } - let snapshot = try RadrootsBackgroundTransferSnapshot( - request: existing.request, - state: .completed, - progress: existing.progress, - updatedAt: updatedAt - ) - try await store.saveSnapshot(snapshot) - } public func fail( _ identifier: RadrootsBackgroundTransferIdentifier, failure: RadrootsBackgroundTransferFailure ) async throws { - guard - let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) - else { + guard + let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) + else { throw RadrootsBackgroundTransferError.transferFailure - } - let snapshot = try RadrootsBackgroundTransferSnapshot( - request: existing.request, - state: .failed, - progress: existing.progress, + } + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .failed, + progress: existing.progress, failure: failure, - updatedAt: updatedAt - ) - try await store.saveSnapshot(snapshot) - } - - public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws - -> RadrootsBackgroundTransferSnapshot? - { - try await store.loadSnapshots().first { $0.identifier == identifier } - } - - public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - try await store.loadSnapshots() - } - - public func handleEventsForBackgroundURLSession( - identifier: String, - completionHandler: @escaping @Sendable () -> Void - ) async { - handledBackgroundEventIdentifiersValue.append(identifier) - completionHandler() - } - - public var enqueuedRequests: [RadrootsBackgroundTransferRequest] { - enqueuedRequestsValue - } - - public var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] { - cancelledIdentifiersValue - } - - public var handledBackgroundEventIdentifiers: [String] { - handledBackgroundEventIdentifiersValue - } + updatedAt: updatedAt + ) + try await store.saveSnapshot(snapshot) + } + + public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws + -> RadrootsBackgroundTransferSnapshot? { + try await store.loadSnapshots().first { $0.identifier == identifier } + } + + public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + try await store.loadSnapshots() + } + + public func handleEventsForBackgroundURLSession( + identifier: String, + completionHandler: @escaping @Sendable () -> Void + ) async { + handledBackgroundEventIdentifiersValue.append(identifier) + completionHandler() + } + + public var enqueuedRequests: [RadrootsBackgroundTransferRequest] { + enqueuedRequestsValue + } + + public var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] { + cancelledIdentifiersValue + } + + public var handledBackgroundEventIdentifiers: [String] { + handledBackgroundEventIdentifiersValue + } } diff --git a/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift @@ -1,835 +1,285 @@ import Darwin import Foundation +@testable import RadrootsKit import RadrootsKitTesting import Testing -@testable import RadrootsKit - @Test func appleBackgroundTransferPersistsRunningSnapshotAfterEnqueue() async throws { - let store = RadrootsInMemoryBackgroundTransferStore() - let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 100)) - let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) - let request = try appleTransferRequest(identifier: "field.transfer.enqueue") - - let handle = try await transfer.enqueue(request) - - #expect(handle.identifier == request.identifier) - #expect(await probe.enqueuedRequests == [request]) - let snapshot = try await transfer.snapshot(for: request.identifier) - #expect(snapshot?.state == .running) - #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 100)) + let store = RadrootsInMemoryBackgroundTransferStore() + let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 100)) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) + let request = try appleTransferRequest(identifier: "field.transfer.enqueue") + + let handle = try await transfer.enqueue(request) + + #expect(handle.identifier == request.identifier) + #expect(await probe.enqueuedRequests == [request]) + let snapshot = try await transfer.snapshot(for: request.identifier) + #expect(snapshot?.state == .running) + #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 100)) } @Test func appleBackgroundTransferRejectsDuplicateIdentifiersBeforeAdapterMutation() async throws { - let store = RadrootsInMemoryBackgroundTransferStore() - let probe = RadrootsAppleBackgroundTransferProbe() - let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) - let request = try appleTransferRequest(identifier: "field.transfer.duplicate") + let store = RadrootsInMemoryBackgroundTransferStore() + let probe = RadrootsAppleBackgroundTransferProbe() + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) + let request = try appleTransferRequest(identifier: "field.transfer.duplicate") - _ = try await transfer.enqueue(request) - await #expect( - throws: RadrootsBackgroundTransferError.invalidRequest - ) { _ = try await transfer.enqueue(request) - } + await #expect( + throws: RadrootsBackgroundTransferError.invalidRequest + ) { + _ = try await transfer.enqueue(request) + } - #expect(await probe.enqueuedRequests == [request]) + #expect(await probe.enqueuedRequests == [request]) } @Test func appleBackgroundTransferRecordsFailedSnapshotWhenAdapterRejectsEnqueue() async throws { - let store = RadrootsInMemoryBackgroundTransferStore() - let probe = RadrootsAppleBackgroundTransferProbe( - now: Date(timeIntervalSince1970: 200), + let store = RadrootsInMemoryBackgroundTransferStore() + let probe = RadrootsAppleBackgroundTransferProbe( + now: Date(timeIntervalSince1970: 200), enqueueOutcome: .failure(.transferFailure) - ) - let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) - let request = try appleTransferRequest(identifier: "field.transfer.failed") + ) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) + let request = try appleTransferRequest(identifier: "field.transfer.failed") - await #expect( + await #expect( throws: RadrootsBackgroundTransferError.transferFailure - ) { - _ = try await transfer.enqueue(request) - } + ) { + _ = try await transfer.enqueue(request) + } - let snapshot = try await transfer.snapshot(for: request.identifier) - #expect(snapshot?.state == .failed) + let snapshot = try await transfer.snapshot(for: request.identifier) + #expect(snapshot?.state == .failed) #expect(snapshot?.failure == .enqueueFailed) - #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 200)) + #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 200)) } @Test func appleBackgroundTransferCancelsThroughAdapterAndUpdatesStore() async throws { - let store = RadrootsInMemoryBackgroundTransferStore() - let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 300)) - let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) - let request = try appleTransferRequest(identifier: "field.transfer.cancel") - - _ = try await transfer.enqueue(request) - try await transfer.cancel(request.identifier) - - #expect(await probe.cancelledIdentifiers == [request.identifier]) - #expect(try await transfer.snapshot(for: request.identifier)?.state == .cancelled) - #expect( - try await transfer.snapshot(for: request.identifier)?.updatedAt - == Date(timeIntervalSince1970: 300)) -} - -@Test func appleBackgroundTransferReconcilesQueuedSnapshotsWithActiveRecoveredTasks() async throws { - let request = try appleTransferRequest(identifier: "field.transfer.recovered") - let queued = try RadrootsBackgroundTransferSnapshot( - request: request, state: .queued, updatedAt: Date(timeIntervalSince1970: 1)) - let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [queued]) - let probe = RadrootsAppleBackgroundTransferProbe( - now: Date(timeIntervalSince1970: 400), activeIdentifiers: [request.identifier]) - let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) - - let snapshots = try await transfer.snapshots() - - #expect(snapshots.count == 1) - #expect(snapshots.first?.state == .running) - #expect(snapshots.first?.updatedAt == Date(timeIntervalSince1970: 400)) - #expect(try await store.loadSnapshots().first?.state == .running) -} + let store = RadrootsInMemoryBackgroundTransferStore() + let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 300)) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) + let request = try appleTransferRequest(identifier: "field.transfer.cancel") -@Test func appleBackgroundTransferMarksMissingRecoveredUploadInterrupted() async throws { - let request = try appleUploadRequest(identifier: "field.transfer.interrupted") - let running = try RadrootsBackgroundTransferSnapshot( - request: request, state: .running, - progress: RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10), - updatedAt: Date(timeIntervalSince1970: 1) - ) - let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [running]) - let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 401)) - let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) - - let snapshot = try #require(try await transfer.snapshots().first) - - #expect(snapshot.state == .interrupted) - #expect(snapshot.failure == .interrupted) - #expect(snapshot.possibleRemoteOrphan) - #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 401)) -} - -@Test func appleBackgroundTransferForwardsBackgroundCompletionHandlers() async { - let probe = RadrootsAppleBackgroundTransferProbe() - let transfer = RadrootsAppleBackgroundTransfer( - store: RadrootsInMemoryBackgroundTransferStore(), adapters: probe.adapters()) - let completion = RadrootsCompletionProbe() - - await transfer.handleEventsForBackgroundURLSession( - identifier: "org.radroots.field-ios.background.transfer" - ) { - completion.markCompleted() - } - - #expect( - await probe.handledBackgroundEventIdentifiers == ["org.radroots.field-ios.background.transfer"]) - #expect(completion.completed) -} - -@Test func appleBackgroundTransferCoordinatorMovesCompletedDownloadToDestination() async throws { - let roots = try appleTransferRoots() - let store = RadrootsInMemoryBackgroundTransferStore() - let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: resolver, - now: { Date(timeIntervalSince1970: 500) } - ) - let request = try appleTransferRequest(identifier: "field.transfer.completed") - let running = try RadrootsBackgroundTransferSnapshot( - request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1)) - try await store.saveSnapshot(running) - let stagingRoot = roots.temporaryRoot.appendingPathComponent( - "background-transfer-tests", isDirectory: true) - try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true) - let stagedFile = stagingRoot.appendingPathComponent("download.bin") - let payload = Data("downloaded".utf8) - try payload.write(to: stagedFile) - - await coordinator.complete( - identifier: request.identifier, platformError: nil, stagedDownloadResult: .file(stagedFile), - httpResult: RadrootsBackgroundHTTPResult( - statusCode: 200, mediaType: "image/png", body: nil, bodyExceeded: false), - bytesTransferred: 0, totalBytesExpected: nil - ) - - let snapshot = try await store.loadSnapshots().first - let destination = try resolver.resolve( - .file(RadrootsFileReference(scope: .cache, relativePath: "field.transfer.completed.json"))) - #expect(snapshot?.state == .awaitingVerification) - #expect(snapshot?.progress.bytesTransferred == Int64(payload.count)) - #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 500)) - #expect(snapshot?.response?.statusCode == 200) - #expect(snapshot?.downloadedArtifact?.byteSize == UInt64(payload.count)) - #expect(snapshot?.downloadedArtifact?.mediaType == "image/png") - let downloadedDigest = try RadrootsAppleFileDigest.sha256(at: destination) - #expect(snapshot?.downloadedArtifact?.sha256 == downloadedDigest) - #expect(try Data(contentsOf: destination) == payload) - #expect(!FileManager.default.fileExists(atPath: stagedFile.path)) -} - -@Test func appleBackgroundTransferCoordinatorRejectsMalformedResponseMediaType() async throws { - let roots = try appleTransferRoots() - let store = RadrootsInMemoryBackgroundTransferStore() - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) - ) - let request = try appleUploadRequest(identifier: "field.transfer.malformed-media-type") - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot(request: request, state: .running)) - - await coordinator.complete( - identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, - httpResult: RadrootsBackgroundHTTPResult( - statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false, - mediaTypeWasMalformed: true), - bytesTransferred: 10, totalBytesExpected: 10 - ) - - let snapshot = try #require(try await store.loadSnapshots().first) - #expect(snapshot.state == .failed) - #expect(snapshot.failure == .responseMediaType) - #expect(snapshot.possibleRemoteOrphan) -} + _ = try await transfer.enqueue(request) + try await transfer.cancel(request.identifier) -@Test func appleBackgroundTransferCoordinatorRecordsFailedDownloadSnapshot() async throws { - let roots = try appleTransferRoots() - let store = RadrootsInMemoryBackgroundTransferStore() - let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: resolver, - now: { Date(timeIntervalSince1970: 600) } - ) - let request = try appleTransferRequest(identifier: "field.transfer.download.failed") - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot( - request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1)) - ) - - await coordinator.complete( - identifier: request.identifier, platformError: nil, stagedDownloadResult: .failure, - httpResult: successfulHTTPResult(), - bytesTransferred: 0, totalBytesExpected: nil - ) - - let snapshot = try await store.loadSnapshots().first - #expect(snapshot?.state == .failed) - #expect(snapshot?.failure == .downloadStagingFailure) - #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 600)) + #expect(await probe.cancelledIdentifiers == [request.identifier]) + #expect(try await transfer.snapshot(for: request.identifier)?.state == .cancelled) + #expect( + try await transfer.snapshot(for: request.identifier)?.updatedAt + == Date(timeIntervalSince1970: 300) + ) } -@Test func appleBackgroundTransferCoordinatorCompletesUploadWithProgress() async throws { - let roots = try appleTransferRoots() - let store = RadrootsInMemoryBackgroundTransferStore() - let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: resolver, - now: { Date(timeIntervalSince1970: 700) } - ) - let request = try appleUploadRequest(identifier: "field.transfer.upload.completed") - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot( - request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1)) - ) - - await coordinator.updateProgress( - identifier: request.identifier, bytesTransferred: 4, totalBytesExpected: 10) - await coordinator.complete( - identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, - httpResult: successfulHTTPResult(), - bytesTransferred: 10, totalBytesExpected: 10 - ) - - let snapshot = try await store.loadSnapshots().first - #expect(snapshot?.state == .awaitingVerification) - #expect(snapshot?.progress.bytesTransferred == 10) - #expect(snapshot?.progress.totalBytesExpected == 10) - #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 700)) - #expect(snapshot?.response?.statusCode == 200) -} +@Test func appleBackgroundTransferReconcilesQueuedSnapshotsWithActiveRecoveredTasks() async throws { + let request = try appleTransferRequest(identifier: "field.transfer.recovered") + let queued = try RadrootsBackgroundTransferSnapshot( + request: request, state: .queued, updatedAt: Date(timeIntervalSince1970: 1) + ) + let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [queued]) + let probe = RadrootsAppleBackgroundTransferProbe( + now: Date(timeIntervalSince1970: 400), activeIdentifiers: [request.identifier] + ) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) -@Test func appleBackgroundTransferCoordinatorPersistsBoundedDescriptorResponse() async throws { - let roots = try appleTransferRoots() - let store = RadrootsInMemoryBackgroundTransferStore() - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), - now: { Date(timeIntervalSince1970: 710) } - ) - let body = Data(#"{"url":"https://cdn.radroots.org/a.png"}"#.utf8) - let request = try appleUploadRequest( - identifier: "field.transfer.upload.descriptor", - responsePolicy: .boundedJSON(maximumBodyBytes: 1024) - ) - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot(request: request, state: .running)) - - await coordinator.complete( - identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, - httpResult: RadrootsBackgroundHTTPResult( - statusCode: 200, mediaType: "application/json", body: body, bodyExceeded: false), - bytesTransferred: 10, totalBytesExpected: 10 - ) - - let snapshot = try #require(try await store.loadSnapshots().first) - #expect(snapshot.state == .awaitingVerification) - #expect(snapshot.response?.body == body) - #expect(snapshot.response?.mediaType == "application/json") - #expect(!snapshot.possibleRemoteOrphan) - - await coordinator.complete( - identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, - httpResult: RadrootsBackgroundHTTPResult( - statusCode: 500, mediaType: nil, body: nil, bodyExceeded: false), bytesTransferred: 10, - totalBytesExpected: 10 - ) - #expect(try await store.loadSnapshots().first?.state == .awaitingVerification) -} + let snapshots = try await transfer.snapshots() -@Test func appleBackgroundTransferCoordinatorRejectsEncodedDescriptorResponse() async throws { - let roots = try appleTransferRoots() - let store = RadrootsInMemoryBackgroundTransferStore() - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) - ) - let request = try appleUploadRequest( - identifier: "field.transfer.upload.encoded", - responsePolicy: .boundedJSON(maximumBodyBytes: 1024) - ) - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot(request: request, state: .running)) - - await coordinator.complete( - identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, - httpResult: RadrootsBackgroundHTTPResult( - statusCode: 200, mediaType: "application/json", body: Data("{}".utf8), - contentEncoding: "gzip", bodyExceeded: false), - bytesTransferred: 10, totalBytesExpected: 10 - ) - - let snapshot = try #require(try await store.loadSnapshots().first) - #expect(snapshot.state == .failed) - #expect(snapshot.failure == .responseContentEncoding) - #expect(snapshot.possibleRemoteOrphan) + #expect(snapshots.count == 1) + #expect(snapshots.first?.state == .running) + #expect(snapshots.first?.updatedAt == Date(timeIntervalSince1970: 400)) + #expect(try await store.loadSnapshots().first?.state == .running) } -@Test func appleBackgroundTransferCoordinatorRejectsStatusAndOversizedResponse() async throws { - let roots = try appleTransferRoots() - let store = RadrootsInMemoryBackgroundTransferStore() - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), - now: { Date(timeIntervalSince1970: 720) } - ) - let statusRequest = try appleUploadRequest(identifier: "field.transfer.upload.status") - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot(request: statusRequest, state: .running)) - await coordinator.complete( - identifier: statusRequest.identifier, platformError: nil, stagedDownloadResult: nil, - httpResult: RadrootsBackgroundHTTPResult( - statusCode: 503, mediaType: nil, body: nil, bodyExceeded: false), bytesTransferred: 10, - totalBytesExpected: 10 - ) - #expect( - try await store.loadSnapshots().first?.failure == .httpStatus) - - let bodyRequest = try appleUploadRequest( - identifier: "field.transfer.upload.oversized", - responsePolicy: .boundedJSON(maximumBodyBytes: 32) - ) - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot(request: bodyRequest, state: .running)) - await coordinator.complete( - identifier: bodyRequest.identifier, platformError: CancellationError(), - stagedDownloadResult: nil, - httpResult: RadrootsBackgroundHTTPResult( - statusCode: 200, mediaType: "application/json", body: nil, bodyExceeded: true), - bytesTransferred: 10, totalBytesExpected: 10 - ) - let oversized = try #require( - try await store.loadSnapshots().first { $0.identifier == bodyRequest.identifier }) - #expect(oversized.state == .failed) - #expect(oversized.failure == .responseTooLarge) - #expect(oversized.possibleRemoteOrphan) -} +@Test func appleBackgroundTransferMarksMissingRecoveredUploadInterrupted() async throws { + let request = try appleUploadRequest(identifier: "field.transfer.interrupted") + let running = try RadrootsBackgroundTransferSnapshot( + request: request, state: .running, + progress: RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10), + updatedAt: Date(timeIntervalSince1970: 1) + ) + let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [running]) + let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 401)) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) -@Test func appleBackgroundTransferCoordinatorDoesNotResurrectCancelledTransfer() async throws { - let roots = try appleTransferRoots() - let store = RadrootsInMemoryBackgroundTransferStore() - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) - ) - let request = try appleUploadRequest(identifier: "field.transfer.upload.cancelled") - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot(request: request, state: .cancelled)) - - await coordinator.complete( - identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, - httpResult: successfulHTTPResult(), - bytesTransferred: 10, totalBytesExpected: 10 - ) - - #expect(try await store.loadSnapshots().first?.state == .cancelled) -} + let snapshot = try #require(try await transfer.snapshots().first) -@Test func appleBackgroundTransferRecoversCompletionLostWhileProtectedDataIsLocked() async throws { - let roots = try appleTransferRoots() - let protectedData = RadrootsProtectedDataProbe(state: .available) - let store = RadrootsAppleBackgroundTransferStore( - roots: roots, protectedData: RadrootsProtectedDataProvider { protectedData.state }) - let request = try appleUploadRequest(identifier: "field.transfer.upload.locked") - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot(request: request, state: .running)) - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) - ) - - protectedData.state = .locked - await coordinator.complete( - identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, - httpResult: successfulHTTPResult(), - bytesTransferred: 10, totalBytesExpected: 10 - ) - protectedData.state = .available - - let transfer = RadrootsAppleBackgroundTransfer( - store: store, adapters: RadrootsAppleBackgroundTransferProbe().adapters()) - let recovered = try #require(try await transfer.snapshots().first) - #expect(recovered.state == .interrupted) - #expect(recovered.possibleRemoteOrphan) + #expect(snapshot.state == .interrupted) + #expect(snapshot.failure == .interrupted) + #expect(snapshot.possibleRemoteOrphan) + #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 401)) } -@Test func appleBackgroundTransferCoordinatorInvokesStoredCompletionHandlerAfterFinishedEvents() - async throws -{ - let roots = try appleTransferRoots() - let store = RadrootsInMemoryBackgroundTransferStore() - let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: resolver - ) - let completion = RadrootsCompletionProbe() - let secondCompletion = RadrootsCompletionProbe() - let unrelated = RadrootsCompletionProbe() - - await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { - completion.markCompleted() - } - #expect(!completion.completed) - await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { - secondCompletion.markCompleted() - } - - await coordinator.handleBackgroundEvents(identifier: "other.session") { - unrelated.markCompleted() - } - #expect(unrelated.completed) - - await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") - #expect(completion.completed) - #expect(secondCompletion.completed) - await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") - #expect(completion.completionCount == 1) - #expect(secondCompletion.completionCount == 1) -} +@Test func appleBackgroundTransferForwardsBackgroundCompletionHandlers() async { + let probe = RadrootsAppleBackgroundTransferProbe() + let transfer = RadrootsAppleBackgroundTransfer( + store: RadrootsInMemoryBackgroundTransferStore(), adapters: probe.adapters() + ) + let completion = RadrootsCompletionProbe() -@Test func appleBackgroundTransferCoordinatorClaimsFinishBeforeHandlerExactlyOnce() async throws { - let roots = try appleTransferRoots() - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", - store: RadrootsInMemoryBackgroundTransferStore(), - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) - ) - let completion = RadrootsCompletionProbe() - - await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") - await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { + await transfer.handleEventsForBackgroundURLSession( + identifier: "org.radroots.field-ios.background.transfer" + ) { completion.markCompleted() } - #expect(completion.completionCount == 1) -} - -@Test func appleBackgroundTransferRequiresExplicitVerificationSettlement() async throws { - let request = try appleUploadRequest(identifier: "field.transfer.settlement") - let response = try RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil) - let store = RadrootsInMemoryBackgroundTransferStore( - snapshots: [ - try RadrootsBackgroundTransferSnapshot( - request: request, - state: .awaitingVerification, - response: response - ) - ] - ) - let transfer = RadrootsAppleBackgroundTransfer( - store: store, - adapters: RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 800)).adapters() - ) - - try await transfer.settle(request.identifier, verification: .accepted) - - #expect(try await store.loadSnapshots().first?.state == .completed) - #expect(try await store.loadSnapshots().first?.response == response) - await #expect( - throws: RadrootsBackgroundTransferError.invalidRequest - ) { - try await transfer.settle(request.identifier, verification: .accepted) - } -} - -@Test func appleBackgroundTransferRejectedVerificationRemainsRecoverableWithoutClaimingSuccess() - async throws -{ - let request = try appleUploadRequest(identifier: "field.transfer.rejected") - let store = RadrootsInMemoryBackgroundTransferStore( - snapshots: [ - try RadrootsBackgroundTransferSnapshot( - request: request, - state: .awaitingVerification, - response: RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil) - ) - ] - ) - let transfer = RadrootsAppleBackgroundTransfer( - store: store, - adapters: RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 850)).adapters() - ) - - try await transfer.settle( - request.identifier, - verification: .rejected(failure: .verificationRejected) - ) - - let snapshot = try #require(try await store.loadSnapshots().first) - #expect(snapshot.state == .failed) - #expect(snapshot.failure == .verificationRejected) - #expect(snapshot.possibleRemoteOrphan) - #expect(snapshot.response == nil) -} - -@Test func appleBackgroundTransferExpirationAndRetryPreserveStableIdentity() async throws { - let request = try appleUploadRequest(identifier: "field.transfer.expire-retry") - let store = RadrootsInMemoryBackgroundTransferStore() - let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 900)) - let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) - - _ = try await transfer.enqueue(request) - try await transfer.expire(request.identifier) - #expect(try await store.loadSnapshots().first?.state == .expired) - #expect(await probe.cancelledIdentifiers == [request.identifier]) - - let retried = try await transfer.retry(request) - #expect(retried.identifier == request.identifier) - #expect(try await store.loadSnapshots().first?.state == .running) - #expect(await probe.enqueuedRequests == [request, request]) -} - -@Test func appleBackgroundTransferRefusesRetryWhilePlatformTaskIsStillActive() async throws { - let request = try appleUploadRequest(identifier: "field.transfer.active-retry") - let store = RadrootsInMemoryBackgroundTransferStore( - snapshots: [try RadrootsBackgroundTransferSnapshot(request: request, state: .interrupted)] - ) - let probe = RadrootsAppleBackgroundTransferProbe(activeIdentifiers: [request.identifier]) - let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) - - await #expect( - throws: RadrootsBackgroundTransferError.invalidRequest - ) { - _ = try await transfer.retry(request) - } - #expect(await probe.enqueuedRequests.isEmpty) - #expect(try await store.loadSnapshots().first?.state == .interrupted) -} - -@Test func appleBackgroundTransferTerminalCancellationIsIdempotent() async throws { - let request = try appleUploadRequest(identifier: "field.transfer.terminal-cancel") - let store = RadrootsInMemoryBackgroundTransferStore( - snapshots: [ - try RadrootsBackgroundTransferSnapshot(request: request, state: .awaitingVerification) - ] - ) - let probe = RadrootsAppleBackgroundTransferProbe() - let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) - - try await transfer.cancel(request.identifier) - try await transfer.expire(request.identifier) - - #expect(try await store.loadSnapshots().first?.state == .awaitingVerification) - #expect(await probe.cancelledIdentifiers.isEmpty) -} - -@Test func appleBackgroundTransferCoordinatorRejectsTransfersBeyondImmutableBound() async throws { - let roots = try appleTransferRoots() - let store = RadrootsInMemoryBackgroundTransferStore() - let coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, - fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) - ) - let request = try appleUploadRequest( - identifier: "field.transfer.too-large", maximumTransferBytes: 5) - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot(request: request, state: .running)) - - await coordinator.complete( - identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, - httpResult: successfulHTTPResult(), - bytesTransferred: 6, totalBytesExpected: 6 - ) - - let snapshot = try #require(try await store.loadSnapshots().first) - #expect(snapshot.state == .failed) - #expect(snapshot.failure == .transferTooLarge) - #expect(snapshot.possibleRemoteOrphan) -} - -@Test func appleBackgroundURLTaskDescriptorRetainsRelaunchBoundsAndMigratesLegacyIdentity() throws { - let request = try appleUploadRequest( - identifier: "field.transfer.task-descriptor", - responsePolicy: .boundedJSON(maximumBodyBytes: 1_024), - maximumTransferBytes: 4_096 - ) - - let descriptor = RadrootsBackgroundURLTaskDescriptor(request: request) - #expect( - RadrootsBackgroundURLTaskDescriptor(taskDescription: descriptor.taskDescription) == descriptor) - - let legacy = try #require( - RadrootsBackgroundURLTaskDescriptor(taskDescription: request.identifier.rawValue)) - #expect(legacy.identifier == request.identifier) - #expect( - legacy.maximumTransferBytes == RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes) - #expect(legacy.maximumResponseBodyBytes == 65_536) - #expect( - RadrootsBackgroundURLTaskDescriptor(taskDescription: "radroots-transfer-v1|unsafe|0|0") == nil) + #expect( + await probe.handledBackgroundEventIdentifiers == ["org.radroots.field-ios.background.transfer"] + ) + #expect(completion.completed) } -#if os(iOS) && targetEnvironment(simulator) - @Test func appleBackgroundTransferLiveURLSessionDownloadsIntoRustVerificationBoundary() - async throws - { - guard let origin = ProcessInfo.processInfo.environment["RADROOTS_URLSESSION_TEST_ORIGIN"], - let remoteURL = URL(string: origin)?.appendingPathComponent("Package.swift") - else { return } +@Test func appleBackgroundTransferCoordinatorMovesCompletedDownloadToDestination() async throws { let roots = try appleTransferRoots() - defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } + let store = RadrootsInMemoryBackgroundTransferStore() let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) - let transfer = try RadrootsAppleBackgroundTransfer( - roots: roots, - sessionIdentifier: "org.radroots.tests.background-transfer.\(UUID().uuidString.lowercased())" + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: resolver, + now: { Date(timeIntervalSince1970: 500) } ) - let destination = RadrootsFileReference( - scope: .cache, relativePath: "live-urlsession/package.swift") - let request = try RadrootsBackgroundTransferRequest( - identifier: RadrootsBackgroundTransferIdentifier("field.transfer.live-urlsession"), - remoteURL: remoteURL, - method: .get, - operation: .download(destination: .file(destination)), - networkPolicy: .simulatorLoopbackHTTP, - maximumTransferBytes: 64 * 1_024 + let request = try appleTransferRequest(identifier: "field.transfer.completed") + let running = try RadrootsBackgroundTransferSnapshot( + request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1) + ) + try await store.saveSnapshot(running) + let stagingRoot = roots.temporaryRoot.appendingPathComponent( + "background-transfer-tests", isDirectory: true + ) + try FileManager.default.createDirectory(at: stagingRoot, withIntermediateDirectories: true) + let stagedFile = stagingRoot.appendingPathComponent("download.bin") + let payload = Data("downloaded".utf8) + try payload.write(to: stagedFile) + + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: .file(stagedFile), + httpResult: RadrootsBackgroundHTTPResult( + statusCode: 200, mediaType: "image/png", body: nil, bodyExceeded: false + ), + bytesTransferred: 0, + totalBytesExpected: nil + ) ) - _ = try await transfer.enqueue(request) - let deadline = Date().addingTimeInterval(15) - var snapshot = try await transfer.snapshot(for: request.identifier) - while snapshot?.state == .queued || snapshot?.state == .running { - guard Date() < deadline else { - throw RadrootsBackgroundTransferError.transferFailure - } - try await Task.sleep(nanoseconds: 50_000_000) - snapshot = try await transfer.snapshot(for: request.identifier) - } - - let completedTransfer = try #require(snapshot) - #expect(completedTransfer.state == .awaitingVerification) - let downloadedArtifact = try #require(completedTransfer.downloadedArtifact) - let destinationURL = try resolver.resolve(.file(destination)) - let downloadedBytes = try Data(contentsOf: destinationURL) - #expect(!downloadedBytes.isEmpty) - #expect(downloadedArtifact.byteSize == UInt64(downloadedBytes.count)) - #expect(downloadedArtifact.sha256 == (try RadrootsAppleFileDigest.sha256(at: destinationURL))) - #expect(downloadedArtifact.mediaType == "application/octet-stream") - - try await transfer.settle(request.identifier, verification: .accepted) - #expect(try await transfer.snapshot(for: request.identifier)?.state == .completed) - } -#endif - -@Test func appleBackgroundTransferCancelsRecoveredPlatformOrphans() async throws { - let identifier = try RadrootsBackgroundTransferIdentifier("field.transfer.platform-orphan") - let probe = RadrootsAppleBackgroundTransferProbe(activeIdentifiers: [identifier]) - let transfer = RadrootsAppleBackgroundTransfer( - store: RadrootsInMemoryBackgroundTransferStore(), adapters: probe.adapters()) - - #expect(try await transfer.snapshots().isEmpty) - #expect(await probe.cancelledIdentifiers == [identifier]) + let snapshot = try await store.loadSnapshots().first + let destination = try resolver.resolve( + .file(RadrootsFileReference(scope: .cache, relativePath: "field.transfer.completed.json")) + ) + #expect(snapshot?.state == .awaitingVerification) + #expect(snapshot?.progress.bytesTransferred == Int64(payload.count)) + #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 500)) + #expect(snapshot?.response?.statusCode == 200) + #expect(snapshot?.downloadedArtifact?.byteSize == UInt64(payload.count)) + #expect(snapshot?.downloadedArtifact?.mediaType == "image/png") + let downloadedDigest = try RadrootsAppleFileDigest.sha256(at: destination) + #expect(snapshot?.downloadedArtifact?.sha256 == downloadedDigest) + #expect(try Data(contentsOf: destination) == payload) + #expect(!FileManager.default.fileExists(atPath: stagedFile.path)) } -private actor RadrootsAppleBackgroundTransferProbe { - private let nowValue: Date - private let enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> - private var activeIdentifiersValue: Set<RadrootsBackgroundTransferIdentifier> - private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest] - private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier] - private var handledBackgroundEventIdentifiersValue: [String] - - init( - now: Date = Date(timeIntervalSince1970: 0), - enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()), - activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier> = [] - ) { - nowValue = now - self.enqueueOutcome = enqueueOutcome - activeIdentifiersValue = activeIdentifiers - enqueuedRequestsValue = [] - cancelledIdentifiersValue = [] - handledBackgroundEventIdentifiersValue = [] - } - - nonisolated func adapters() -> RadrootsAppleBackgroundTransferAdapters { - RadrootsAppleBackgroundTransferAdapters( - now: { self.nowValue }, enqueue: { request in try await self.enqueue(request) }, - cancel: { identifier in await self.cancel(identifier) }, - activeTransferIdentifiers: { await self.activeIdentifiers() }, - handleBackgroundEvents: { identifier, completionHandler in - await self.handleBackgroundEvents( - identifier: identifier, completionHandler: completionHandler) - } +@Test func appleBackgroundTransferCoordinatorRejectsMalformedResponseMediaType() async throws { + let roots = try appleTransferRoots() + let store = RadrootsInMemoryBackgroundTransferStore() + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) + ) + let request = try appleUploadRequest(identifier: "field.transfer.malformed-media-type") + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot(request: request, state: .running) ) - } - private func enqueue(_ request: RadrootsBackgroundTransferRequest) throws { - enqueuedRequestsValue.append(request) - switch enqueueOutcome { - case .success: activeIdentifiersValue.insert(request.identifier) - case .failure(let error): throw error - } - } - - private func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) { - cancelledIdentifiersValue.append(identifier) - activeIdentifiersValue.remove(identifier) - } - - private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> { - activeIdentifiersValue - } - - private func handleBackgroundEvents( - identifier: String, completionHandler: @escaping @Sendable () -> Void - ) { - handledBackgroundEventIdentifiersValue.append(identifier) - completionHandler() - } - - var enqueuedRequests: [RadrootsBackgroundTransferRequest] { - enqueuedRequestsValue - } - - var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] { - cancelledIdentifiersValue - } - - var handledBackgroundEventIdentifiers: [String] { - handledBackgroundEventIdentifiersValue - } -} + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: nil, + httpResult: RadrootsBackgroundHTTPResult( + statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false, + mediaTypeWasMalformed: true + ), + bytesTransferred: 10, + totalBytesExpected: 10 + ) + ) -private final class RadrootsCompletionProbe: @unchecked Sendable { - private let lock = NSLock() - private var completionCountValue = 0 - - func markCompleted() { - lock.lock() - completionCountValue += 1 - lock.unlock() - } - - var completed: Bool { - completionCount > 0 - } - - var completionCount: Int { - lock.lock() - defer { lock.unlock() } - return completionCountValue - } + let snapshot = try #require(try await store.loadSnapshots().first) + #expect(snapshot.state == .failed) + #expect(snapshot.failure == .responseMediaType) + #expect(snapshot.possibleRemoteOrphan) } -private final class RadrootsProtectedDataProbe: @unchecked Sendable { - private let lock = NSLock() - private var stateValue: RadrootsProtectedDataState - - init(state: RadrootsProtectedDataState) { - stateValue = state - } +@Test func appleBackgroundTransferCoordinatorRecordsFailedDownloadSnapshot() async throws { + let roots = try appleTransferRoots() + let store = RadrootsInMemoryBackgroundTransferStore() + let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: resolver, + now: { Date(timeIntervalSince1970: 600) } + ) + let request = try appleTransferRequest(identifier: "field.transfer.download.failed") + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1) + ) + ) - var state: RadrootsProtectedDataState { - get { - lock.lock() - defer { lock.unlock() } - return stateValue - } - set { - lock.lock() - stateValue = newValue - lock.unlock() - } - } -} + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: .failure, + httpResult: successfulHTTPResult(), + bytesTransferred: 0, + totalBytesExpected: nil + ) + ) -private func appleTransferRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest { - try RadrootsBackgroundTransferRequest( - identifier: RadrootsBackgroundTransferIdentifier(identifier), - remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, - method: .get, - operation: .download( - destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json"))) - ) + let snapshot = try await store.loadSnapshots().first + #expect(snapshot?.state == .failed) + #expect(snapshot?.failure == .downloadStagingFailure) + #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 600)) } -private func appleUploadRequest( - identifier: String, - responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard, - maximumTransferBytes: UInt64 = RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes -) throws - -> RadrootsBackgroundTransferRequest -{ - try RadrootsBackgroundTransferRequest( - identifier: RadrootsBackgroundTransferIdentifier(identifier), - remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, - method: .put, - operation: .upload( - source: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json"))), - responsePolicy: responsePolicy, maximumTransferBytes: maximumTransferBytes - ) -} +@Test func appleBackgroundTransferCoordinatorCompletesUploadWithProgress() async throws { + let roots = try appleTransferRoots() + let store = RadrootsInMemoryBackgroundTransferStore() + let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: resolver, + now: { Date(timeIntervalSince1970: 700) } + ) + let request = try appleUploadRequest(identifier: "field.transfer.upload.completed") + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1) + ) + ) -private func successfulHTTPResult() -> RadrootsBackgroundHTTPResult { - RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false) -} + await coordinator.updateProgress( + identifier: request.identifier, bytesTransferred: 4, totalBytesExpected: 10 + ) + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: nil, + httpResult: successfulHTTPResult(), + bytesTransferred: 10, + totalBytesExpected: 10 + ) + ) -private func appleTransferRoots() throws -> RadrootsAppleFileRoots { - let unresolvedRoot = FileManager.default.temporaryDirectory.appendingPathComponent( - "radroots-apple-background-transfer-\(UUID().uuidString)", isDirectory: true - ) - try FileManager.default.createDirectory(at: unresolvedRoot, withIntermediateDirectories: false) - guard let resolvedPointer = unresolvedRoot.path.withCString({ Darwin.realpath($0, nil) }) else { - throw RadrootsBackgroundTransferError.persistenceFailure - } - defer { Darwin.free(resolvedPointer) } - let root = URL(fileURLWithPath: String(cString: resolvedPointer), isDirectory: true) - return try RadrootsAppleFileRoots( - appIdentifier: "org.radroots.tests", - dataRoot: root.appendingPathComponent("data", isDirectory: true), - cacheRoot: root.appendingPathComponent("cache", isDirectory: true), - temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) - ) + let snapshot = try await store.loadSnapshots().first + #expect(snapshot?.state == .awaitingVerification) + #expect(snapshot?.progress.bytesTransferred == 10) + #expect(snapshot?.progress.totalBytesExpected == 10) + #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 700)) + #expect(snapshot?.response?.statusCode == 200) } diff --git a/Tests/RadrootsKitTests/RadrootsAppleErrorSafetyTests.swift b/Tests/RadrootsKitTests/RadrootsAppleErrorSafetyTests.swift @@ -1,96 +1,28 @@ import Foundation +@testable import RadrootsKit import RadrootsKitTesting import Testing -@testable import RadrootsKit - @Test func publicAppleErrorsUseClosedPathFreeDescriptions() { - let errors: [any Error] = [ - RadrootsCaptureIntakeError.invalidRequest, - RadrootsCaptureIntakeError.unavailable, - RadrootsCaptureIntakeError.permissionDenied, - RadrootsCaptureIntakeError.userCancelled, - RadrootsCaptureIntakeError.transientFailure, - RadrootsCaptureIntakeError.permanentFailure, - RadrootsBackgroundTransferError.invalidRequest, - RadrootsBackgroundTransferError.unavailable, - RadrootsBackgroundTransferError.transferFailure, - RadrootsBackgroundTransferError.persistenceFailure, - RadrootsDocumentInterchangeError.invalidRequest, - RadrootsDocumentInterchangeError.notFound, - RadrootsDocumentInterchangeError.userCancelled, - RadrootsDocumentInterchangeError.permissionDenied, - RadrootsDocumentInterchangeError.transientFailure, - RadrootsDocumentInterchangeError.permanentFailure, - RadrootsAppLocalStateResetError.invalidRequest, - RadrootsAppLocalStateResetError.fileSystemFailure, - RadrootsAppLocalStateResetError.keychainFailure, - RadrootsAppleMediaPreparationError.invalidRequest, - RadrootsAppleMediaPreparationError.unavailable, - RadrootsAppleMediaPreparationError.preparationFailure, - RadrootsTelemetryError.invalidRequest, - RadrootsExternalActionError.invalidRequest, - RadrootsExternalActionError.blockedByPolicy, - RadrootsExternalActionError.unavailable, - RadrootsExternalActionError.transientFailure, - RadrootsExternalActionError.permanentFailure, - RadrootsAppleFileError.invalidRequest, - RadrootsAppleFileError.notFound, - RadrootsAppleFileError.permissionDenied, - RadrootsAppleFileError.transientFailure, - RadrootsAppleFileError.permanentFailure, - RadrootsLocationServicesError.invalidRequest, - RadrootsLocationServicesError.permissionDenied, - RadrootsLocationServicesError.unavailable, - RadrootsLocationServicesError.timeout, - RadrootsLocationServicesError.cancelled, - RadrootsLocationServicesError.transientFailure, - RadrootsLocationServicesError.permanentFailure, - RadrootsUserPresenceError.invalidRequest, - RadrootsUserPresenceError.userCancelled, - RadrootsUserPresenceError.permissionDenied, - RadrootsUserPresenceError.unavailable, - RadrootsUserPresenceError.timeout, - RadrootsUserPresenceError.transientFailure, - RadrootsUserPresenceError.permanentFailure, - RadrootsBackgroundTaskError.invalidRequest, - RadrootsBackgroundTaskError.unavailable, - RadrootsBackgroundTaskError.schedulerFailure, - RadrootsAppleSecurityError.invalidRequest, - RadrootsAppleSecurityError.notFound, - RadrootsAppleSecurityError.permissionDenied, - RadrootsAppleSecurityError.userCancelled, - RadrootsAppleSecurityError.transientFailure, - RadrootsAppleSecurityError.unavailable, - RadrootsAppleSecurityError.permanentFailure, - RadrootsAppleSecurityError.keychainFailure, - RadrootsAppleMobileStoreError.invalidPublicKey, - RadrootsAppleMobileStoreError.protectedDataUnavailable, - RadrootsAppleMobileStoreError.invalidDirectoryLayout, - RadrootsAppleMobileStoreError.fileSystemFailure, - RadrootsVerifiedArtifactAccessError.invalidDescriptor, - RadrootsVerifiedArtifactAccessError.protectedDataUnavailable, - RadrootsVerifiedArtifactAccessError.artifactUnavailable, - RadrootsVerifiedArtifactAccessError.artifactCorrupt, - RadrootsVerifiedArtifactAccessError.fileSystemFailure, - ] + let errors = publicAppleErrorExamples let forbidden = [ "/Users/example/private.sqlite", "https://secret.example.invalid/token", - "nsec1secretcanary", + "nsec1secretcanary" ] for error in errors { let renderings = [ String(describing: error), String(reflecting: error), - (error as NSError).localizedDescription, + (error as NSError).localizedDescription ] #expect(renderings.allSatisfy { !$0.isEmpty }) #expect( renderings.allSatisfy { rendering in forbidden.allSatisfy { !rendering.contains($0) } - }) + } + ) } } @@ -118,6 +50,154 @@ import Testing } @Test func throwingAppleAdaptersMapDependencyDiagnosticsToClosedErrors() async throws { + try await verifyBackgroundTaskErrorMapping() + try await verifyPermissionErrorMapping() + try await verifyTransferErrorMapping() +} + +@Test func fileSystemDiagnosticsCannotEscapeTheClosedFileErrorBoundary() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-error-safety-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let invalidDataRoot = root.appendingPathComponent("private.sqlite") + try Data("not a directory".utf8).write(to: invalidDataRoot) + let roots = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.error-safety", + dataRoot: invalidDataRoot, + cacheRoot: root.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: root.appendingPathComponent("temporary", isDirectory: true) + ) + let access = RadrootsAppleFileAccess(roots: roots) + + do { + try access.write( + .inline(Data("payload".utf8)), + to: RadrootsFileReference(scope: .data, relativePath: "secret.example.invalid") + ) + Issue.record("expected the invalid filesystem layout to fail") + } catch let error as RadrootsAppleFileError { + #expect(error == .permanentFailure) + #expect(!String(reflecting: error).contains("private.sqlite")) + #expect(!(error.errorDescription ?? "").contains("secret.example.invalid")) + } catch { + Issue.record("unexpected dependency-owned error: \(type(of: error))") + } +} + +private func dependencyCanaryError() -> NSError { + NSError( + domain: "/Users/example/private.sqlite", + code: 9, + userInfo: [NSLocalizedDescriptionKey: "https://secret.example.invalid/token"] + ) +} + +private actor ThrowingBackgroundTransferStore: RadrootsBackgroundTransferStore { + func withAdmission( + for _: RadrootsBackgroundTransferIdentifier, + operation _: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle + ) async throws -> RadrootsBackgroundTransferHandle { + throw dependencyCanaryError() + } + + func admissionIsActive(for _: RadrootsBackgroundTransferIdentifier) async throws -> Bool { + throw dependencyCanaryError() + } + + func compareExchangeSnapshot( + expected _: RadrootsBackgroundTransferSnapshot?, desired _: RadrootsBackgroundTransferSnapshot + ) async throws -> Bool { + throw dependencyCanaryError() + } + + func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + throw dependencyCanaryError() + } + + func saveSnapshot(_: RadrootsBackgroundTransferSnapshot) async throws { + throw dependencyCanaryError() + } + + func removeSnapshot(for _: RadrootsBackgroundTransferIdentifier) async throws { + throw dependencyCanaryError() + } + + func removeAllSnapshots() async throws { + throw dependencyCanaryError() + } +} + +private let publicAppleErrorExamples: [any Error] = [ + RadrootsCaptureIntakeError.invalidRequest, + RadrootsCaptureIntakeError.unavailable, + RadrootsCaptureIntakeError.permissionDenied, + RadrootsCaptureIntakeError.userCancelled, + RadrootsCaptureIntakeError.transientFailure, + RadrootsCaptureIntakeError.permanentFailure, + RadrootsBackgroundTransferError.invalidRequest, + RadrootsBackgroundTransferError.unavailable, + RadrootsBackgroundTransferError.transferFailure, + RadrootsBackgroundTransferError.persistenceFailure, + RadrootsDocumentInterchangeError.invalidRequest, + RadrootsDocumentInterchangeError.notFound, + RadrootsDocumentInterchangeError.userCancelled, + RadrootsDocumentInterchangeError.permissionDenied, + RadrootsDocumentInterchangeError.transientFailure, + RadrootsDocumentInterchangeError.permanentFailure, + RadrootsAppLocalStateResetError.invalidRequest, + RadrootsAppLocalStateResetError.fileSystemFailure, + RadrootsAppLocalStateResetError.keychainFailure, + RadrootsAppleMediaPreparationError.invalidRequest, + RadrootsAppleMediaPreparationError.unavailable, + RadrootsAppleMediaPreparationError.preparationFailure, + RadrootsTelemetryError.invalidRequest, + RadrootsExternalActionError.invalidRequest, + RadrootsExternalActionError.blockedByPolicy, + RadrootsExternalActionError.unavailable, + RadrootsExternalActionError.transientFailure, + RadrootsExternalActionError.permanentFailure, + RadrootsAppleFileError.invalidRequest, + RadrootsAppleFileError.notFound, + RadrootsAppleFileError.permissionDenied, + RadrootsAppleFileError.transientFailure, + RadrootsAppleFileError.permanentFailure, + RadrootsLocationServicesError.invalidRequest, + RadrootsLocationServicesError.permissionDenied, + RadrootsLocationServicesError.unavailable, + RadrootsLocationServicesError.timeout, + RadrootsLocationServicesError.cancelled, + RadrootsLocationServicesError.transientFailure, + RadrootsLocationServicesError.permanentFailure, + RadrootsUserPresenceError.invalidRequest, + RadrootsUserPresenceError.userCancelled, + RadrootsUserPresenceError.permissionDenied, + RadrootsUserPresenceError.unavailable, + RadrootsUserPresenceError.timeout, + RadrootsUserPresenceError.transientFailure, + RadrootsUserPresenceError.permanentFailure, + RadrootsBackgroundTaskError.invalidRequest, + RadrootsBackgroundTaskError.unavailable, + RadrootsBackgroundTaskError.schedulerFailure, + RadrootsAppleSecurityError.invalidRequest, + RadrootsAppleSecurityError.notFound, + RadrootsAppleSecurityError.permissionDenied, + RadrootsAppleSecurityError.userCancelled, + RadrootsAppleSecurityError.transientFailure, + RadrootsAppleSecurityError.unavailable, + RadrootsAppleSecurityError.permanentFailure, + RadrootsAppleSecurityError.keychainFailure, + RadrootsAppleMobileStoreError.invalidPublicKey, + RadrootsAppleMobileStoreError.protectedDataUnavailable, + RadrootsAppleMobileStoreError.invalidDirectoryLayout, + RadrootsAppleMobileStoreError.fileSystemFailure, + RadrootsVerifiedArtifactAccessError.invalidDescriptor, + RadrootsVerifiedArtifactAccessError.protectedDataUnavailable, + RadrootsVerifiedArtifactAccessError.artifactUnavailable, + RadrootsVerifiedArtifactAccessError.artifactCorrupt, + RadrootsVerifiedArtifactAccessError.fileSystemFailure +] + +private func verifyBackgroundTaskErrorMapping() async throws { let backgroundTasks = RadrootsAppleBackgroundTaskScheduler( adapters: RadrootsAppleBackgroundTaskSchedulerAdapters( now: { Date(timeIntervalSince1970: 1) }, @@ -139,7 +219,9 @@ import Testing await #expect(throws: RadrootsBackgroundTaskError.schedulerFailure) { _ = try await backgroundTasks.pendingTasks() } +} +private func verifyPermissionErrorMapping() async throws { let location = RadrootsAppleLocationServices( adapters: RadrootsAppleLocationServicesAdapters( now: { Date(timeIntervalSince1970: 1) }, @@ -166,7 +248,9 @@ import Testing await #expect(throws: RadrootsUserPresenceError.permanentFailure) { _ = try await presence.verify(request) } +} +private func verifyTransferErrorMapping() async throws { let transferRequest = try RadrootsBackgroundTransferRequest( identifier: RadrootsBackgroundTransferIdentifier("error-safety.transfer"), remoteURL: #require(URL(string: "https://radroots.org/error-safety")), @@ -189,7 +273,7 @@ import Testing store: RadrootsInMemoryBackgroundTransferStore(), adapters: RadrootsAppleBackgroundTransferAdapters( now: { Date(timeIntervalSince1970: 1) }, - enqueue: { _ in throw dependencyCanaryError() }, + enqueue: { _, _ in throw dependencyCanaryError() }, cancel: { _ in }, activeTransferIdentifiers: { [] }, handleBackgroundEvents: { _, completion in completion() } @@ -199,58 +283,3 @@ import Testing _ = try await adapterFailure.enqueue(transferRequest) } } - -@Test func fileSystemDiagnosticsCannotEscapeTheClosedFileErrorBoundary() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("radroots-error-safety-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - let invalidDataRoot = root.appendingPathComponent("private.sqlite") - try Data("not a directory".utf8).write(to: invalidDataRoot) - let roots = try RadrootsAppleFileRoots( - appIdentifier: "org.radroots.error-safety", - dataRoot: invalidDataRoot, - cacheRoot: root.appendingPathComponent("cache", isDirectory: true), - temporaryRoot: root.appendingPathComponent("temporary", isDirectory: true) - ) - let access = RadrootsAppleFileAccess(roots: roots) - - do { - try access.write( - .inline(Data("payload".utf8)), - to: RadrootsFileReference(scope: .data, relativePath: "secret.example.invalid") - ) - Issue.record("expected the invalid filesystem layout to fail") - } catch let error as RadrootsAppleFileError { - #expect(error == .permanentFailure) - #expect(!String(reflecting: error).contains("private.sqlite")) - #expect(!(error.errorDescription ?? "").contains("secret.example.invalid")) - } catch { - Issue.record("unexpected dependency-owned error: \(type(of: error))") - } -} - -private func dependencyCanaryError() -> NSError { - NSError( - domain: "/Users/example/private.sqlite", - code: 9, - userInfo: [NSLocalizedDescriptionKey: "https://secret.example.invalid/token"] - ) -} - -private actor ThrowingBackgroundTransferStore: RadrootsBackgroundTransferStore { - func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - throw dependencyCanaryError() - } - - func saveSnapshot(_: RadrootsBackgroundTransferSnapshot) async throws { - throw dependencyCanaryError() - } - - func removeSnapshot(for _: RadrootsBackgroundTransferIdentifier) async throws { - throw dependencyCanaryError() - } - - func removeAllSnapshots() async throws { - throw dependencyCanaryError() - } -} diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferReceiptTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferReceiptTests.swift @@ -0,0 +1,314 @@ +import Darwin +import Foundation +@testable import RadrootsKit +import RadrootsKitTesting +import Testing + +@Test func appleBackgroundTransferCoordinatorPersistsBoundedDescriptorResponse() async throws { + let roots = try appleTransferRoots() + let store = RadrootsInMemoryBackgroundTransferStore() + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), + now: { Date(timeIntervalSince1970: 710) } + ) + let body = Data(#"{"url":"https://cdn.radroots.org/a.png"}"#.utf8) + let request = try appleUploadRequest( + identifier: "field.transfer.upload.descriptor", + responsePolicy: .boundedJSON(maximumBodyBytes: 1024) + ) + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot(request: request, state: .running) + ) + + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: nil, + httpResult: RadrootsBackgroundHTTPResult( + statusCode: 200, mediaType: "application/json", body: body, bodyExceeded: false + ), + bytesTransferred: 10, + totalBytesExpected: 10 + ) + ) + + let snapshot = try #require(try await store.loadSnapshots().first) + #expect(snapshot.state == .awaitingVerification) + #expect(snapshot.response?.body == body) + #expect(snapshot.response?.mediaType == "application/json") + #expect(!snapshot.possibleRemoteOrphan) + + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: nil, + httpResult: RadrootsBackgroundHTTPResult( + statusCode: 500, mediaType: nil, body: nil, bodyExceeded: false + ), + bytesTransferred: 10, + totalBytesExpected: 10 + ) + ) + #expect(try await store.loadSnapshots().first?.state == .awaitingVerification) +} + +@Test func appleBackgroundTransferCoordinatorRejectsEncodedDescriptorResponse() async throws { + let roots = try appleTransferRoots() + let store = RadrootsInMemoryBackgroundTransferStore() + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) + ) + let request = try appleUploadRequest( + identifier: "field.transfer.upload.encoded", + responsePolicy: .boundedJSON(maximumBodyBytes: 1024) + ) + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot(request: request, state: .running) + ) + + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: nil, + httpResult: RadrootsBackgroundHTTPResult( + statusCode: 200, mediaType: "application/json", body: Data("{}".utf8), + contentEncoding: "gzip", bodyExceeded: false + ), + bytesTransferred: 10, + totalBytesExpected: 10 + ) + ) + + let snapshot = try #require(try await store.loadSnapshots().first) + #expect(snapshot.state == .failed) + #expect(snapshot.failure == .responseContentEncoding) + #expect(snapshot.possibleRemoteOrphan) +} + +@Test func appleBackgroundTransferCoordinatorRejectsStatusAndOversizedResponse() async throws { + let roots = try appleTransferRoots() + let store = RadrootsInMemoryBackgroundTransferStore() + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), + now: { Date(timeIntervalSince1970: 720) } + ) + let statusRequest = try appleUploadRequest(identifier: "field.transfer.upload.status") + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot(request: statusRequest, state: .running) + ) + await coordinator.complete( + identifier: statusRequest.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: nil, + httpResult: RadrootsBackgroundHTTPResult( + statusCode: 503, mediaType: nil, body: nil, bodyExceeded: false + ), + bytesTransferred: 10, + totalBytesExpected: 10 + ) + ) + #expect(try await store.loadSnapshots().first?.failure == .httpStatus) + + let bodyRequest = try appleUploadRequest( + identifier: "field.transfer.upload.oversized", + responsePolicy: .boundedJSON(maximumBodyBytes: 32) + ) + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot(request: bodyRequest, state: .running) + ) + await coordinator.complete( + identifier: bodyRequest.identifier, + completion: RadrootsTransferCompletion( + platformError: CancellationError(), + stagedDownloadResult: nil, + httpResult: RadrootsBackgroundHTTPResult( + statusCode: 200, mediaType: "application/json", body: nil, bodyExceeded: true + ), + bytesTransferred: 10, + totalBytesExpected: 10 + ) + ) + let oversized = try #require( + try await store.loadSnapshots().first { $0.identifier == bodyRequest.identifier } + ) + #expect(oversized.state == .failed) + #expect(oversized.failure == .responseTooLarge) + #expect(oversized.possibleRemoteOrphan) +} + +@Test func appleBackgroundTransferCoordinatorDoesNotResurrectCancelledTransfer() async throws { + let roots = try appleTransferRoots() + let store = RadrootsInMemoryBackgroundTransferStore() + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) + ) + let request = try appleUploadRequest(identifier: "field.transfer.upload.cancelled") + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot(request: request, state: .cancelled) + ) + + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: nil, + httpResult: successfulHTTPResult(), + bytesTransferred: 10, + totalBytesExpected: 10 + ) + ) + + #expect(try await store.loadSnapshots().first?.state == .cancelled) +} + +@Test func appleBackgroundTransferRecoversCompletionLostWhileProtectedDataIsLocked() async throws { + let roots = try appleTransferRoots() + let protectedData = RadrootsProtectedDataProbe(state: .available) + let store = RadrootsAppleBackgroundTransferStore( + roots: roots, protectedData: RadrootsProtectedDataProvider { protectedData.state } + ) + let request = try appleUploadRequest(identifier: "field.transfer.upload.locked") + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot(request: request, state: .running) + ) + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) + ) + + protectedData.state = .locked + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: nil, + httpResult: successfulHTTPResult(), + bytesTransferred: 10, + totalBytesExpected: 10 + ) + ) + protectedData.state = .available + + let transfer = RadrootsAppleBackgroundTransfer( + store: store, adapters: RadrootsAppleBackgroundTransferProbe().adapters() + ) + let recovered = try #require(try await transfer.snapshots().first) + #expect(recovered.state == .interrupted) + #expect(recovered.possibleRemoteOrphan) +} + +@Test func appleBackgroundTransferCoordinatorInvokesStoredCompletionHandlerAfterFinishedEvents() + async throws { + let roots = try appleTransferRoots() + let store = RadrootsInMemoryBackgroundTransferStore() + let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: resolver + ) + let completion = RadrootsCompletionProbe() + let secondCompletion = RadrootsCompletionProbe() + let unrelated = RadrootsCompletionProbe() + + await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { + completion.markCompleted() + } + #expect(!completion.completed) + await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { + secondCompletion.markCompleted() + } + + await coordinator.handleBackgroundEvents(identifier: "other.session") { + unrelated.markCompleted() + } + #expect(unrelated.completed) + + await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") + #expect(completion.completed) + #expect(secondCompletion.completed) + await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") + #expect(completion.completionCount == 1) + #expect(secondCompletion.completionCount == 1) +} + +@Test func appleBackgroundTransferCoordinatorClaimsFinishBeforeHandlerExactlyOnce() async throws { + let roots = try appleTransferRoots() + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", + store: RadrootsInMemoryBackgroundTransferStore(), + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) + ) + let completion = RadrootsCompletionProbe() + + await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") + await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { + completion.markCompleted() + } + + #expect(completion.completionCount == 1) +} + +@Test func appleBackgroundTransferRequiresExplicitVerificationSettlement() async throws { + let request = try appleUploadRequest(identifier: "field.transfer.settlement") + let response = try RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil) + let store = try RadrootsInMemoryBackgroundTransferStore( + snapshots: [ + RadrootsBackgroundTransferSnapshot( + request: request, + state: .awaitingVerification, + response: response + ) + ] + ) + let transfer = RadrootsAppleBackgroundTransfer( + store: store, + adapters: RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 800)).adapters() + ) + + try await transfer.settle(request.identifier, verification: .accepted) + + #expect(try await store.loadSnapshots().first?.state == .completed) + #expect(try await store.loadSnapshots().first?.response == response) + await #expect( + throws: RadrootsBackgroundTransferError.invalidRequest + ) { + try await transfer.settle(request.identifier, verification: .accepted) + } +} + +@Test func appleBackgroundTransferRejectedVerificationRemainsRecoverableWithoutClaimingSuccess() + async throws { + let request = try appleUploadRequest(identifier: "field.transfer.rejected") + let store = try RadrootsInMemoryBackgroundTransferStore( + snapshots: [ + RadrootsBackgroundTransferSnapshot( + request: request, + state: .awaitingVerification, + response: RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil) + ) + ] + ) + let transfer = RadrootsAppleBackgroundTransfer( + store: store, + adapters: RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 850)).adapters() + ) + + try await transfer.settle( + request.identifier, + verification: .rejected(failure: .verificationRejected) + ) + + let snapshot = try #require(try await store.loadSnapshots().first) + #expect(snapshot.state == .failed) + #expect(snapshot.failure == .verificationRejected) + #expect(snapshot.possibleRemoteOrphan) + #expect(snapshot.response == nil) +} diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferRecoveryTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferRecoveryTests.swift @@ -0,0 +1,174 @@ +import Darwin +import Foundation +@testable import RadrootsKit +import RadrootsKitTesting +import Testing + +@Test func appleBackgroundTransferExpirationAndRetryPreserveStableIdentity() async throws { + let request = try appleUploadRequest(identifier: "field.transfer.expire-retry") + let store = RadrootsInMemoryBackgroundTransferStore() + let probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 900)) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) + + _ = try await transfer.enqueue(request) + try await transfer.expire(request.identifier) + #expect(try await store.loadSnapshots().first?.state == .expired) + #expect(await probe.cancelledIdentifiers == [request.identifier]) + + let retried = try await transfer.retry(request) + #expect(retried.identifier == request.identifier) + #expect(try await store.loadSnapshots().first?.state == .running) + #expect(await probe.enqueuedRequests == [request, request]) +} + +@Test func appleBackgroundTransferRefusesRetryWhilePlatformTaskIsStillActive() async throws { + let request = try appleUploadRequest(identifier: "field.transfer.active-retry") + let store = try RadrootsInMemoryBackgroundTransferStore( + snapshots: [RadrootsBackgroundTransferSnapshot(request: request, state: .interrupted)] + ) + let probe = RadrootsAppleBackgroundTransferProbe(activeIdentifiers: [request.identifier]) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) + + await #expect( + throws: RadrootsBackgroundTransferError.invalidRequest + ) { + _ = try await transfer.retry(request) + } + #expect(await probe.enqueuedRequests.isEmpty) + #expect(try await store.loadSnapshots().first?.state == .interrupted) +} + +@Test func appleBackgroundTransferTerminalCancellationIsIdempotent() async throws { + let request = try appleUploadRequest(identifier: "field.transfer.terminal-cancel") + let store = try RadrootsInMemoryBackgroundTransferStore( + snapshots: [ + RadrootsBackgroundTransferSnapshot(request: request, state: .awaitingVerification) + ] + ) + let probe = RadrootsAppleBackgroundTransferProbe() + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) + + try await transfer.cancel(request.identifier) + try await transfer.expire(request.identifier) + + #expect(try await store.loadSnapshots().first?.state == .awaitingVerification) + #expect(await probe.cancelledIdentifiers.isEmpty) +} + +@Test func appleBackgroundTransferCoordinatorRejectsTransfersBeyondImmutableBound() async throws { + let roots = try appleTransferRoots() + let store = RadrootsInMemoryBackgroundTransferStore() + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "org.radroots.field-ios.background.transfer", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots) + ) + let request = try appleUploadRequest( + identifier: "field.transfer.too-large", maximumTransferBytes: 5 + ) + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot(request: request, state: .running) + ) + + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: nil, + httpResult: successfulHTTPResult(), + bytesTransferred: 6, + totalBytesExpected: 6 + ) + ) + + let snapshot = try #require(try await store.loadSnapshots().first) + #expect(snapshot.state == .failed) + #expect(snapshot.failure == .transferTooLarge) + #expect(snapshot.possibleRemoteOrphan) +} + +@Test func appleBackgroundURLTaskDescriptorRetainsRelaunchBoundsAndMigratesLegacyIdentity() throws { + let request = try appleUploadRequest( + identifier: "field.transfer.task-descriptor", + responsePolicy: .boundedJSON(maximumBodyBytes: 1024), + maximumTransferBytes: 4096 + ) + + let descriptor = RadrootsBackgroundURLTaskDescriptor(request: request) + #expect( + RadrootsBackgroundURLTaskDescriptor(taskDescription: descriptor.taskDescription) == descriptor + ) + + let legacy = try #require( + RadrootsBackgroundURLTaskDescriptor(taskDescription: request.identifier.rawValue) + ) + #expect(legacy.identifier == request.identifier) + #expect( + legacy.maximumTransferBytes == RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes + ) + #expect(legacy.maximumResponseBodyBytes == 65536) + #expect( + RadrootsBackgroundURLTaskDescriptor(taskDescription: "radroots-transfer-v1|unsafe|0|0") == nil + ) +} + +#if os(iOS) && targetEnvironment(simulator) + @Test func appleBackgroundTransferLiveURLSessionDownloadsIntoRustVerificationBoundary() + async throws { + guard let origin = ProcessInfo.processInfo.environment["RADROOTS_URLSESSION_TEST_ORIGIN"], + let remoteURL = URL(string: origin)?.appendingPathComponent("Package.swift") + else { return } + let roots = try appleTransferRoots() + defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } + let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) + let transfer = try RadrootsAppleBackgroundTransfer( + roots: roots, + sessionIdentifier: "org.radroots.tests.background-transfer.\(UUID().uuidString.lowercased())" + ) + let destination = RadrootsFileReference( + scope: .cache, relativePath: "live-urlsession/package.swift" + ) + let request = try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier("field.transfer.live-urlsession"), + remoteURL: remoteURL, + method: .get, + operation: .download(destination: .file(destination)), + networkPolicy: .simulatorLoopbackHTTP, + maximumTransferBytes: 64 * 1024 + ) + + _ = try await transfer.enqueue(request) + let deadline = Date().addingTimeInterval(15) + var snapshot = try await transfer.snapshot(for: request.identifier) + while snapshot?.state == .queued || snapshot?.state == .running { + guard Date() < deadline else { + throw RadrootsBackgroundTransferError.transferFailure + } + try await Task.sleep(nanoseconds: 50_000_000) + snapshot = try await transfer.snapshot(for: request.identifier) + } + + let completedTransfer = try #require(snapshot) + #expect(completedTransfer.state == .awaitingVerification) + let downloadedArtifact = try #require(completedTransfer.downloadedArtifact) + let destinationURL = try resolver.resolve(.file(destination)) + let downloadedBytes = try Data(contentsOf: destinationURL) + #expect(!downloadedBytes.isEmpty) + #expect(downloadedArtifact.byteSize == UInt64(downloadedBytes.count)) + #expect(try downloadedArtifact.sha256 == (RadrootsAppleFileDigest.sha256(at: destinationURL))) + #expect(downloadedArtifact.mediaType == "application/octet-stream") + + try await transfer.settle(request.identifier, verification: .accepted) + #expect(try await transfer.snapshot(for: request.identifier)?.state == .completed) + } +#endif + +@Test func appleBackgroundTransferCancelsRecoveredPlatformOrphans() async throws { + let identifier = try RadrootsBackgroundTransferIdentifier("field.transfer.platform-orphan") + let probe = RadrootsAppleBackgroundTransferProbe(activeIdentifiers: [identifier]) + let transfer = RadrootsAppleBackgroundTransfer( + store: RadrootsInMemoryBackgroundTransferStore(), adapters: probe.adapters() + ) + + #expect(try await transfer.snapshots().isEmpty) + #expect(await probe.cancelledIdentifiers == [identifier]) +} diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferTestSupport.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferTestSupport.swift @@ -0,0 +1,169 @@ +import Darwin +import Foundation +@testable import RadrootsKit +import RadrootsKitTesting +import Testing + +actor RadrootsAppleBackgroundTransferProbe { + private let nowValue: Date + private let enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> + private var activeIdentifiersValue: Set<RadrootsBackgroundTransferIdentifier> + private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest] + private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier] + private var handledBackgroundEventIdentifiersValue: [String] + + init( + now: Date = Date(timeIntervalSince1970: 0), + enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()), + activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier> = [] + ) { + nowValue = now + self.enqueueOutcome = enqueueOutcome + activeIdentifiersValue = activeIdentifiers + enqueuedRequestsValue = [] + cancelledIdentifiersValue = [] + handledBackgroundEventIdentifiersValue = [] + } + + nonisolated func adapters() -> RadrootsAppleBackgroundTransferAdapters { + RadrootsAppleBackgroundTransferAdapters( + now: { self.nowValue }, enqueue: { request, _ in try await self.enqueue(request) }, + cancel: { identifier in await self.cancel(identifier) }, + activeTransferIdentifiers: { await self.activeIdentifiers() }, + handleBackgroundEvents: { identifier, completionHandler in + await self.handleBackgroundEvents( + identifier: identifier, completionHandler: completionHandler + ) + } + ) + } + + private func enqueue(_ request: RadrootsBackgroundTransferRequest) throws { + enqueuedRequestsValue.append(request) + switch enqueueOutcome { + case .success: activeIdentifiersValue.insert(request.identifier) + case let .failure(error): throw error + } + } + + private func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) { + cancelledIdentifiersValue.append(identifier) + activeIdentifiersValue.remove(identifier) + } + + private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> { + activeIdentifiersValue + } + + private func handleBackgroundEvents( + identifier: String, completionHandler: @escaping @Sendable () -> Void + ) { + handledBackgroundEventIdentifiersValue.append(identifier) + completionHandler() + } + + var enqueuedRequests: [RadrootsBackgroundTransferRequest] { + enqueuedRequestsValue + } + + var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] { + cancelledIdentifiersValue + } + + var handledBackgroundEventIdentifiers: [String] { + handledBackgroundEventIdentifiersValue + } +} + +final class RadrootsCompletionProbe: @unchecked Sendable { + private let lock = NSLock() + private var completionCountValue = 0 + + func markCompleted() { + lock.lock() + completionCountValue += 1 + lock.unlock() + } + + var completed: Bool { + completionCount > 0 + } + + var completionCount: Int { + lock.lock() + defer { lock.unlock() } + return completionCountValue + } +} + +final class RadrootsProtectedDataProbe: @unchecked Sendable { + private let lock = NSLock() + private var stateValue: RadrootsProtectedDataState + + init(state: RadrootsProtectedDataState) { + stateValue = state + } + + var state: RadrootsProtectedDataState { + get { + lock.lock() + defer { lock.unlock() } + return stateValue + } + set { + lock.lock() + stateValue = newValue + lock.unlock() + } + } +} + +func appleTransferRequest(identifier: String) throws -> RadrootsBackgroundTransferRequest { + try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier(identifier), + remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, + method: .get, + operation: .download( + destination: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json")) + ) + ) +} + +func appleUploadRequest( + identifier: String, + responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard, + maximumTransferBytes: UInt64 = RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes +) throws + -> RadrootsBackgroundTransferRequest { + try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier(identifier), + remoteURL: URL(string: "https://radroots.org/\(identifier).json")!, + method: .put, + operation: .upload( + source: .file(RadrootsFileReference(scope: .cache, relativePath: "\(identifier).json")) + ), + responsePolicy: responsePolicy, maximumTransferBytes: maximumTransferBytes + ) +} + +func successfulHTTPResult() -> RadrootsBackgroundHTTPResult { + RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false) +} + +func appleTransferRoots() throws -> RadrootsAppleFileRoots { + let unresolvedRoot = FileManager.default.temporaryDirectory.appendingPathComponent( + "radroots-apple-background-transfer-\(UUID().uuidString)", isDirectory: true + ) + try FileManager.default.createDirectory(at: unresolvedRoot, withIntermediateDirectories: false) + guard let resolvedPointer = unresolvedRoot.path.withCString({ Darwin.realpath($0, nil) }) else { + throw RadrootsBackgroundTransferError.persistenceFailure + } + defer { Darwin.free(resolvedPointer) } + let root = URL(fileURLWithPath: String(cString: resolvedPointer), isDirectory: true) + return try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.tests", + dataRoot: root.appendingPathComponent("data", isDirectory: true), + cacheRoot: root.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) + ) +} diff --git a/Tests/RadrootsKitTests/RadrootsNativeAdmissionTests.swift b/Tests/RadrootsKitTests/RadrootsNativeAdmissionTests.swift @@ -0,0 +1,284 @@ +import Darwin +import Foundation +@testable import RadrootsKit +import RadrootsKitTesting +import Testing + +@Test func nativeAdmissionReservesBeforeAwaitAndPreservesQueuedOwnership() async throws { + let fixture = try NativeAdmissionFixture() + defer { fixture.remove() } + let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let gate = NativeAdmissionGate() + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: gate.adapters()) + let request = try nativeAdmissionRequest() + let first = Task { try await transfer.enqueue(request) } + await gate.waitUntilEntered() + await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) { + _ = try await transfer.enqueue(request) + } + let conflicting = try nativeAdmissionRequest(remote: "https://example.org/other") + await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) { + _ = try await transfer.enqueue(conflicting) + } + let secondStore = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let secondOwner = RadrootsAppleBackgroundTransfer(store: secondStore, adapters: gate.adapters()) + #expect(try await secondOwner.snapshots().first?.state == .queued) + await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) { + _ = try await secondOwner.enqueue(request) + } + let queued = try #require(try await transfer.snapshots().first) + #expect(queued.state == .queued) + #expect(queued.request.headers.isEmpty && queued.request.metadata.isEmpty) + #expect(queued.executionID != nil) + await gate.finish() + #expect(try await first.value.identifier == request.identifier) + #expect(await gate.admittedCount == 1) + #expect(try await !secondStore.admissionIsActive(for: request.identifier)) + #expect(try await transfer.snapshot(for: request.identifier)?.state == .running) +} + +@Test func nativeAdmissionDifferentIdentifiersCanProceedWhileAnotherAwaitIsHeld() async throws { + let store = RadrootsInMemoryBackgroundTransferStore() + let firstGate = NativeAdmissionGate() + let secondGate = NativeAdmissionGate() + let first = RadrootsAppleBackgroundTransfer(store: store, adapters: firstGate.adapters()) + let second = RadrootsAppleBackgroundTransfer(store: store, adapters: secondGate.adapters()) + let original = try nativeAdmissionRequest() + let other = try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier("native.admission.other"), + remoteURL: original.remoteURL, method: original.method, operation: original.operation + ) + let firstTask = Task { try await first.enqueue(original) } + await firstGate.waitUntilEntered() + let secondTask = Task { try await second.enqueue(other) } + await secondGate.waitUntilEntered() + #expect(try await store.loadSnapshots().count == 2) + await secondGate.finish() + #expect(try await secondTask.value.identifier == other.identifier) + await firstGate.finish() + #expect(try await firstTask.value.identifier == original.identifier) + #expect(await firstGate.admittedCount == 1) + #expect(await secondGate.admittedCount == 1) +} + +@Test func nativeAdmissionCancellationWhileEnqueueSuspendsCancelsLateTask() async throws { + let store = RadrootsInMemoryBackgroundTransferStore() + let gate = NativeAdmissionGate() + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: gate.adapters()) + let request = try nativeAdmissionRequest() + let pending = Task { try await transfer.enqueue(request) } + await gate.waitUntilEntered() + try await transfer.cancel(request.identifier) + await gate.finish() + _ = try await pending.value + #expect(await gate.active.isEmpty) + let snapshot = try #require(try await store.loadSnapshots().first) + #expect(snapshot.state == .cancelled && snapshot.possibleRemoteOrphan) +} + +@Test func nativeAdmissionErrorAfterEffectPreservesUnknownOutcomeAndExistingReceipt() async throws { + let store = RadrootsInMemoryBackgroundTransferStore() + let request = try nativeAdmissionRequest() + let adapters = RadrootsAppleBackgroundTransferAdapters( + enqueue: { _, executionID in + let existing = try #require(try await store.loadSnapshots().first) + #expect(existing.executionID == executionID) + let completed = try RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .awaitingVerification, + response: RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: nil, body: nil), + executionID: executionID + ) + #expect(try await store.compareExchangeSnapshot(expected: existing, desired: completed)) + throw RadrootsBackgroundTransferError.transferFailure + }, cancel: { _ in }, activeTransferIdentifiers: { [] }, + handleBackgroundEvents: { _, completion in completion() } + ) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: adapters) + await #expect(throws: RadrootsBackgroundTransferError.transferFailure) { _ = try await transfer.enqueue(request) } + #expect(try await store.loadSnapshots().first?.state == .awaitingVerification) + let lost = try RadrootsBackgroundTransferSnapshot(request: request, state: .queued, executionID: UUID()) + let recoveredStore = RadrootsInMemoryBackgroundTransferStore(snapshots: [lost]) + let recovered = RadrootsAppleBackgroundTransfer(store: recoveredStore, adapters: adapters) + let interrupted = try #require(try await recovered.snapshots().first) + #expect(interrupted.state == .interrupted && interrupted.possibleRemoteOrphan) +} + +@Test func nativeAdmissionStaleAndDuplicateCallbacksCannotOverwriteNewAttempt() async throws { + let fixture = try NativeAdmissionFixture() + defer { fixture.remove() } + let request = try nativeAdmissionRequest() + let generation = UUID() + let running = try RadrootsBackgroundTransferSnapshot(request: request, state: .running, executionID: generation) + let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [running]) + let coordinator = RadrootsTransferCoordinator( + sessionIdentifier: "tests", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: fixture.roots) + ) + let response = RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false) + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, httpResult: response, + bytesTransferred: 3, totalBytesExpected: 3), + executionID: UUID() + ) + #expect(try await store.loadSnapshots().first == running) + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, httpResult: response, + bytesTransferred: 3, totalBytesExpected: 3), + executionID: generation + ) + let completed = try #require(try await store.loadSnapshots().first) + #expect(completed.state == .awaitingVerification && completed.executionID == generation) + await coordinator.updateProgress(identifier: request.identifier, bytesTransferred: 1, totalBytesExpected: 3, + executionID: generation) + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion(platformError: RadrootsBackgroundTransferError.transferFailure, + stagedDownloadResult: nil, httpResult: response, bytesTransferred: 1, + totalBytesExpected: 3), + executionID: generation + ) + #expect(try await store.loadSnapshots().first == completed) +} + +@Test func nativeAdmissionPersistsExactLeaseAndGenerationAcrossRestart() async throws { + let fixture = try NativeAdmissionFixture() + defer { fixture.remove() } + let file = RadrootsFileReference(scope: .cache, relativePath: "body") + let bytes = Data("exact upload bytes".utf8) + let fileOwner = RadrootsAppleFileAccess(roots: fixture.roots) + try fileOwner.write(.inline(bytes), to: file) + let request = try nativeAdmissionRequest(expectedSHA256: RadrootsAppleFileDigest.sha256(bytes)) + let generation = UUID() + let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: fixture.roots) + let lease = try resolver.prepareUploadLease(for: request, executionID: generation, existing: nil) + let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let queued = try RadrootsBackgroundTransferSnapshot( + request: request, + executionID: generation, + uploadLease: lease.blob + ) + #expect(try await store.compareExchangeSnapshot(expected: nil, desired: queued)) + try fileOwner.reset(scope: .cache) + let reopened = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let saved = try #require(try await reopened.loadSnapshots().first) + #expect(saved.request.headers.isEmpty && saved.request.metadata.isEmpty) + #expect(saved.executionID == generation && saved.uploadLease == lease.blob) + let recovered = try resolver.prepareUploadLease( + for: saved.request, + executionID: generation, + existing: saved.uploadLease + ) + #expect(recovered == lease) + #expect(try Data(contentsOf: recovered.fileURL) == bytes) + let descriptor = RadrootsBackgroundURLTaskDescriptor(request: request, executionID: generation) + #expect(RadrootsBackgroundURLTaskDescriptor(taskDescription: descriptor.taskDescription) == descriptor) + #expect(RadrootsBackgroundURLTaskDescriptor(taskDescription: "radroots-transfer-v2|bad|0|0|invalid") == nil) + try resolver.releaseUploadLease(executionID: generation) + #expect(!FileManager.default.fileExists(atPath: lease.fileURL.path)) +} + +@Test func nativeAdmissionStoreClaimsOnceAndRejectsStaleTransition() async throws { + let fixture = try NativeAdmissionFixture() + defer { fixture.remove() } + let request = try nativeAdmissionRequest() + let results = await withTaskGroup(of: Bool.self) { group in + for _ in 0 ..< 8 { + group.addTask { + let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let desired = try? RadrootsBackgroundTransferSnapshot(request: request, executionID: UUID()) + guard let desired else { return false } + return await (try? store.compareExchangeSnapshot(expected: nil, desired: desired)) ?? false + } + } + var results: [Bool] = [] + for await result in group { + results.append(result) + } + return results + } + #expect(results.filter(\.self).count == 1) + let store = RadrootsAppleBackgroundTransferStore(roots: fixture.roots) + let original = try #require(try await store.loadSnapshots().first) + let cancelled = try original.transitioned(to: .cancelled, at: Date(), possibleRemoteOrphan: true) + #expect(try await store.compareExchangeSnapshot(expected: original, desired: cancelled)) + #expect(try await !store.compareExchangeSnapshot(expected: original, + desired: original.transitioned(to: .running, at: Date()))) + #expect(try await store.loadSnapshots().first == cancelled) +} + +private actor NativeAdmissionGate { + private var entered = false + private var observer: CheckedContinuation<Void, Never>? + private var pending: CheckedContinuation<Void, Never>? + private(set) var active: Set<RadrootsBackgroundTransferIdentifier> = [] + private(set) var admittedCount = 0 + + nonisolated func adapters() -> RadrootsAppleBackgroundTransferAdapters { + RadrootsAppleBackgroundTransferAdapters(enqueue: { request, _ in await self.enqueue(request) }, + cancel: { identifier in await self.cancel(identifier) }, + activeTransferIdentifiers: { await self.active }, + handleBackgroundEvents: { _, completion in completion() }) + } + + func waitUntilEntered() async { + if entered { + return + } + await withCheckedContinuation { observer = $0 } + } + + func finish() { + pending?.resume(); pending = nil + } + + func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) { + active.remove(identifier) + } + + private func enqueue(_ request: RadrootsBackgroundTransferRequest) async { + admittedCount += 1 + entered = true + observer?.resume() + observer = nil + await withCheckedContinuation { pending = $0 } + active.insert(request.identifier) + } +} + +private func nativeAdmissionRequest( + remote: String = "https://example.org/upload", expectedSHA256: String? = nil +) throws -> RadrootsBackgroundTransferRequest { + try RadrootsBackgroundTransferRequest(identifier: RadrootsBackgroundTransferIdentifier("native.admission"), + remoteURL: #require(URL(string: remote)), method: .put, + operation: .upload(source: .file(RadrootsFileReference( + scope: .cache, + relativePath: "body" + ))), + headers: ["Authorization": "test-only-authority"], + metadata: ["label": "test-only-private-metadata"], + expectedSourceSHA256: expectedSHA256) +} + +private struct NativeAdmissionFixture { + let base: URL + let roots: RadrootsAppleFileRoots + init() throws { + let raw = FileManager.default.temporaryDirectory.appendingPathComponent("native-admission-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: raw, withIntermediateDirectories: true) + let pointer = try #require(raw.path.withCString { Darwin.realpath($0, nil) }) + defer { Darwin.free(pointer) } + base = URL(fileURLWithPath: String(cString: pointer), isDirectory: true) + roots = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.tests", + dataRoot: base.appendingPathComponent("data"), + cacheRoot: base.appendingPathComponent("cache"), + temporaryRoot: base.appendingPathComponent("tmp") + ) + } + + func remove() { + try? FileManager.default.removeItem(at: base) + } +} diff --git a/Tests/RadrootsKitTests/RadrootsNativeUploadLeaseTests.swift b/Tests/RadrootsKitTests/RadrootsNativeUploadLeaseTests.swift @@ -0,0 +1,151 @@ +#if os(iOS) && targetEnvironment(simulator) + import Foundation + import Network + @testable import RadrootsKit + import RadrootsKitTesting + import Testing + + @Test func nativeUploadConsumesLeaseAfterOriginalChangesBeforeTaskCreation() async throws { + let server = try NativeLeaseHTTPServer() + defer { server.stop() } + let port = try await server.start() + let roots = try appleTransferRoots() + defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } + let bytes = Data("immutable native upload body".utf8) + let source = RadrootsFileReference(scope: .cache, relativePath: "native_upload") + try RadrootsAppleFileAccess(roots: roots).write(.inline(bytes), to: source) + let resolver = ReplacingNativeLeaseResolver(roots: roots, source: source) + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + let adapters = try RadrootsAppleBackgroundTransferAdapters.live( + sessionIdentifier: "org.radroots.native-lease.\(UUID().uuidString.lowercased())", + store: store, fileResolver: resolver, downloadStagingRoot: roots.temporaryRoot + ) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: adapters) + let request = try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier("native.lease.upload"), + remoteURL: #require(URL(string: "http://127.0.0.1:\(port)/upload")), method: .put, + operation: .upload(source: .file(source)), networkPolicy: .simulatorLoopbackHTTP, + expectedSourceSHA256: RadrootsAppleFileDigest.sha256(bytes), maximumTransferBytes: 65536 + ) + _ = try await transfer.enqueue(request) + await #expect(throws: RadrootsBackgroundTransferError.invalidRequest) { _ = try await transfer.enqueue(request) + } + let deadline = Date().addingTimeInterval(15) + var snapshot = try await transfer.snapshot(for: request.identifier) + while snapshot?.state == .queued || snapshot?.state == .running { + guard Date() < deadline else { throw RadrootsBackgroundTransferError.transferFailure } + try await Task.sleep(for: .milliseconds(25)) + snapshot = try await transfer.snapshot(for: request.identifier) + } + let completed = try #require(snapshot) + #expect(completed.state == .awaitingVerification) + #expect(completed.uploadLease?.blobID == request.expectedSourceSHA256) + #expect(server.bodies == [bytes]) + #expect(try resolver.read(.file(source), maximumBytes: 65536) != bytes) + try await transfer.settle(request.identifier, verification: .accepted) + #expect(try await transfer.snapshot(for: request.identifier)?.state == .completed) + } + + private struct ReplacingNativeLeaseResolver: RadrootsBackgroundTransferFileResolver { + let roots: RadrootsAppleFileRoots + let source: RadrootsFileReference + private var live: RadrootsAppleBackgroundTransferFileResolver { + .init(roots: roots) + } + + func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL { + try live.resolve(file) + } + + func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data { + try live.read(file, maximumBytes: maximumBytes) + } + + func prepareUploadLease( + for request: RadrootsBackgroundTransferRequest, executionID: UUID, existing: RadrootsStagedBlobReference? + ) throws -> RadrootsStagedBlobLease { + let lease = try live.prepareUploadLease(for: request, executionID: executionID, existing: existing) + try RadrootsAppleFileAccess(roots: roots).write(.inline(Data("replaced original".utf8)), to: source) + return lease + } + + func releaseUploadLease(executionID: UUID) throws { + try live.releaseUploadLease(executionID: executionID) + } + } + + /// Network callbacks use one private queue; observed result copies use a lock. + private final class NativeLeaseHTTPServer: @unchecked Sendable { + private let listener: NWListener + private let queue = DispatchQueue(label: "org.radroots.tests.native-lease-http") + private let lock = NSLock() + private var receivedBodies: [Data] = [] + private var connections: [NWConnection] = [] + + init() throws { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any) + listener = try NWListener(using: parameters) + } + + var bodies: [Data] { + lock.withLock { receivedBodies } + } + + func start() async throws -> UInt16 { + listener.newConnectionHandler = { [weak self] connection in + guard let self else { connection.cancel(); return } + connections.append(connection) + connection.start(queue: queue) + read(connection, accumulated: Data()) + } + listener.start(queue: queue) + for _ in 0 ..< 500 { + if let port = listener.port, port.rawValue > 0 { + return port.rawValue + } + try await Task.sleep(for: .milliseconds(10)) + } + throw RadrootsBackgroundTransferError.unavailable + } + + func stop() { + listener.cancel() + queue.async { for connection in self.connections { + connection.cancel() + } } + } + + private func read(_ connection: NWConnection, accumulated: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, complete, error in + var bytes = accumulated + if let data { + bytes.append(data) + } + guard error == nil, bytes.count <= 131_072 else { connection.cancel(); return } + if let body = self.body(in: bytes) { + self.lock.withLock { self.receivedBodies.append(body) } + let response = Data("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8) + connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() }) + } else if complete { + connection.cancel() + } else { + self.read(connection, accumulated: bytes) + } + } + } + + private func body(in bytes: Data) -> Data? { + guard let separator = bytes.range(of: Data("\r\n\r\n".utf8)), + let header = String(data: bytes[..<separator.lowerBound], encoding: .utf8), + header.hasPrefix("PUT /upload HTTP/1.1\r\n") + else { return nil } + let length = header.components(separatedBy: "\r\n").first { + $0.lowercased().hasPrefix("content-length:") + }.flatMap { Int($0.dropFirst("content-length:".count).trimmingCharacters(in: .whitespaces)) } + guard let length, (0 ... 65536).contains(length), + bytes.count - separator.upperBound == length else { return nil } + return Data(bytes[separator.upperBound...]) + } + } +#endif diff --git a/contracts/api_baselines/apple_kit.txt b/contracts/api_baselines/apple_kit.txt @@ -715,13 +715,17 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 10:conformsTo 56:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV 22:s:s16SendableMetatypeP 22:Swift.SendableMetatype 12:relationship 11:RadrootsKit 13:requirementOf 105:s:11RadrootsKit0A10FileAccessP16sweepStagedBlobs9olderThanSayAA0aF13BlobReferenceVG10Foundation4DateV_tKF 30:s:11RadrootsKit0A10FileAccessP 0: 12:relationship 11:RadrootsKit 13:requirementOf 106:s:11RadrootsKit0A11SecureStoreP3put_3for6policyy10Foundation4DataV_AA0acD3KeyVAA0A18SecretAccessPolicyVtKF 31:s:11RadrootsKit0A11SecureStoreP 0: +12:relationship 11:RadrootsKit 13:requirementOf 106:s:11RadrootsKit0A23BackgroundTransferStoreP23compareExchangeSnapshot8expected7desiredSbAA0acdH0VSg_AHtYaKF 43:s:11RadrootsKit0A23BackgroundTransferStoreP 0: +12:relationship 11:RadrootsKit 13:requirementOf 106:s:11RadrootsKit0A30BackgroundTransferFileResolverP18releaseUploadLease11executionIDy10Foundation4UUIDV_tKF 50:s:11RadrootsKit0A30BackgroundTransferFileResolverP 0: 12:relationship 11:RadrootsKit 13:requirementOf 107:s:11RadrootsKit0A18BackgroundTransferP6settle_12verificationyAA0acD10IdentifierV_AA0acD12VerificationOtYaKF 38:s:11RadrootsKit0A18BackgroundTransferP 0: 12:relationship 11:RadrootsKit 13:requirementOf 108:s:11RadrootsKit0A10FileAccessP05stageC0_9mediaType12filenameHintAA0A19StagedBlobReferenceVAA0acL0V_SSSgAKtKF 30:s:11RadrootsKit0A10FileAccessP 0: 12:relationship 11:RadrootsKit 13:requirementOf 109:s:11RadrootsKit0A30BackgroundTransferFileResolverP4read_12maximumBytes10Foundation4DataVAA0acd5LocalE0O_SitKF 50:s:11RadrootsKit0A30BackgroundTransferFileResolverP 0: 12:relationship 11:RadrootsKit 13:requirementOf 113:s:11RadrootsKit0A18BackgroundTransferP015handleEventsForC10URLSession10identifier17completionHandlerySS_yyYbctYaF 38:s:11RadrootsKit0A18BackgroundTransferP 0: 12:relationship 11:RadrootsKit 13:requirementOf 116:s:11RadrootsKit0A10FileAccessP9stageBlob_9mediaType12filenameHintAA0a6StagedF9ReferenceV10Foundation4DataV_SSSgALtKF 30:s:11RadrootsKit0A10FileAccessP 0: +12:relationship 11:RadrootsKit 13:requirementOf 120:s:11RadrootsKit0A23BackgroundTransferStoreP13withAdmission3for9operationAA0acD6HandleVAA0acD10IdentifierV_AHyYaYbKctYaKF 43:s:11RadrootsKit0A23BackgroundTransferStoreP 0: 12:relationship 11:RadrootsKit 13:requirementOf 126:s:11RadrootsKit0A10FileAccessP013stageExternalC0_9mediaType12filenameHintAA0A19StagedBlobReferenceV10Foundation3URLV_SSSgALtKF 30:s:11RadrootsKit0A10FileAccessP 0: 12:relationship 11:RadrootsKit 13:requirementOf 146:s:11RadrootsKit0A10FileAccessP012copyExternalC0_2to9mediaType17suggestedFilenameAA0A16ImportedDocumentV10Foundation3URLV_AA0aC9ReferenceVSSSgAOtKF 30:s:11RadrootsKit0A10FileAccessP 0: +12:relationship 11:RadrootsKit 13:requirementOf 171:s:11RadrootsKit0A30BackgroundTransferFileResolverP18prepareUploadLease3for11executionID8existingAA0a10StagedBlobI0VAA0acD7RequestV_10Foundation4UUIDVAA0anO9ReferenceVSgtKF 50:s:11RadrootsKit0A30BackgroundTransferFileResolverP 0: 12:relationship 11:RadrootsKit 13:requirementOf 52:s:11RadrootsKit0A10FileAccessP16resetStagedBlobsyyKF 30:s:11RadrootsKit0A10FileAccessP 0: 12:relationship 11:RadrootsKit 13:requirementOf 52:s:11RadrootsKit0A9TelemetryP6recordyyAA0aC5EventVYaF 28:s:11RadrootsKit0A9TelemetryP 0: 12:relationship 11:RadrootsKit 13:requirementOf 53:s:11RadrootsKit0A11SecureStoreP6deleteyyAA0acD3KeyVKF 31:s:11RadrootsKit0A11SecureStoreP 0: @@ -767,6 +771,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 13:requirementOf 89:s:11RadrootsKit0A24PermissionStatusProviderP8snapshot3forAA0aC8SnapshotVAA0aC4KindO_tYaKF 44:s:11RadrootsKit0A24PermissionStatusProviderP 0: 12:relationship 11:RadrootsKit 13:requirementOf 91:s:11RadrootsKit0A10FileAccessP4read_4modeAA0aC10ReadResultOAA0aC9ReferenceV_AA0acG4ModeOtKF 30:s:11RadrootsKit0A10FileAccessP 0: 12:relationship 11:RadrootsKit 13:requirementOf 91:s:11RadrootsKit0A15ExternalActionsP7canOpenyAA0aC16ActionCapabilityVAA0acG11DestinationVYaF 35:s:11RadrootsKit0A15ExternalActionsP 0: +12:relationship 11:RadrootsKit 13:requirementOf 93:s:11RadrootsKit0A23BackgroundTransferStoreP17admissionIsActive3forSbAA0acD10IdentifierV_tYaKF 43:s:11RadrootsKit0A23BackgroundTransferStoreP 0: 12:relationship 11:RadrootsKit 13:requirementOf 93:s:11RadrootsKit0A30BackgroundTransferFileResolverP7resolvey10Foundation3URLVAA0acd5LocalE0OKF 50:s:11RadrootsKit0A30BackgroundTransferFileResolverP 0: 12:relationship 11:RadrootsKit 13:requirementOf 94:s:11RadrootsKit0A18BackgroundTransferP8snapshot3forAA0acD8SnapshotVSgAA0acD10IdentifierV_tYaKF 38:s:11RadrootsKit0A18BackgroundTransferP 0: 12:relationship 11:RadrootsKit 13:requirementOf 98:s:11RadrootsKit0A24PermissionStatusProviderP9snapshots3forSayAA0aC8SnapshotVGSayAA0aC4KindOG_tYaKF 44:s:11RadrootsKit0A24PermissionStatusProviderP 0: @@ -785,6 +790,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 103:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV010permissionE03forAA0adE0OSo015UNAuthorizationE0V_tFZ 49:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 104:s:11RadrootsKit0A23CaptureIntakeValidationO28normalizedScannerOutputKindsySayAA0a8DocumentgH4KindOGAGKFZ 43:s:11RadrootsKit0A23CaptureIntakeValidationO 0: 12:relationship 11:RadrootsKit 8:memberOf 104:s:11RadrootsKit0A26BackgroundTransferProgressV16bytesTransferred18totalBytesExpectedACs5Int64V_AGSgtKcfc 46:s:11RadrootsKit0A26BackgroundTransferProgressV 0: +12:relationship 11:RadrootsKit 8:memberOf 104:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV7enqueueyyAA0adE7RequestV_10Foundation4UUIDVtYaYbKcvp 51:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 105:s:11RadrootsKit0A15AppleFileAccessC17installStagedBlob_9referencey10Foundation4DataV_AA0agH9ReferenceVtKF 35:s:11RadrootsKit0A15AppleFileAccessC 0: 12:relationship 11:RadrootsKit 8:memberOf 105:s:11RadrootsKit0A26AppleIdentityMetadataStoreC9namespace12userDefaults9keyPrefixACSS_So06NSUserI0CSStKcfc 46:s:11RadrootsKit0A26AppleIdentityMetadataStoreC 0: 12:relationship 11:RadrootsKit 8:memberOf 105:s:11RadrootsKit0A29UnavailableBackgroundTransferV8snapshot3forAA0adE8SnapshotVSgAA0adE10IdentifierV_tYaKF 49:s:11RadrootsKit0A29UnavailableBackgroundTransferV 0: @@ -804,6 +810,8 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 110:s:11RadrootsKit0A19DocumentScanRequestV10outputKind16destinationScopeAcA0ac13ScannerOutputG0O_AA0a4FileI0Otcfc 39:s:11RadrootsKit0A19DocumentScanRequestV 0: 12:relationship 11:RadrootsKit 8:memberOf 110:s:11RadrootsKit0A23AppleTelemetryLogRecordV9subsystem8category5level15renderedMessageACSS_SSAA0aD5LevelOSStcfc 43:s:11RadrootsKit0A23AppleTelemetryLogRecordV 0: 12:relationship 11:RadrootsKit 8:memberOf 111:s:11RadrootsKit0A27DocumentPresentationAdapterO11contentType08forMediaG007UniformG11Identifiers6UTTypeVSSSg_tFZ 47:s:11RadrootsKit0A27DocumentPresentationAdapterO 0: +12:relationship 11:RadrootsKit 8:memberOf 111:s:11RadrootsKit0A28AppleBackgroundTransferStoreC23compareExchangeSnapshot8expected7desiredSbAA0adeI0VSg_AHtYaKF 48:s:11RadrootsKit0A28AppleBackgroundTransferStoreC 0: +12:relationship 11:RadrootsKit 8:memberOf 111:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV18releaseUploadLease11executionIDy10Foundation4UUIDV_tKF 55:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV 0: 12:relationship 11:RadrootsKit 8:memberOf 112:s:11RadrootsKit0A18AppLocalStateResetO23clearApplicationSupport13appIdentifier11fileManagerySS_So06NSFileM0CtKFZ 38:s:11RadrootsKit0A18AppLocalStateResetO 0: 12:relationship 11:RadrootsKit 8:memberOf 112:s:11RadrootsKit0A23AppleBackgroundTransferC6settle_12verificationyAA0adE10IdentifierV_AA0adE12VerificationOtYaKF 43:s:11RadrootsKit0A23AppleBackgroundTransferC 0: 12:relationship 11:RadrootsKit 8:memberOf 112:s:11RadrootsKit0A28LocationServicesAvailabilityV08locationD7Enabled13authorizationACSb_AA0aC13AuthorizationOtcfc 48:s:11RadrootsKit0A28LocationServicesAvailabilityV 0: @@ -832,6 +840,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 125:s:11RadrootsKit0A18MediaImportRequestV07allowedC5Kinds14selectionLimit16destinationScopeACSayAA0aC4KindOG_SiAA0a4FileK0OtKcfc 38:s:11RadrootsKit0A18MediaImportRequestV 0: 12:relationship 11:RadrootsKit 8:memberOf 125:s:11RadrootsKit0A21CurrentLocationResultV7reading13authorization15servedFromCacheAcA0aD7ReadingV_AA0aD13AuthorizationOSbtKcfc 41:s:11RadrootsKit0A21CurrentLocationResultV 0: 12:relationship 11:RadrootsKit 8:memberOf 125:s:11RadrootsKit0A22CurrentLocationRequestV14timeoutSeconds21desiredAccuracyMeters023maximumCachedReadingAgeG0ACSd_SdSgAGtKcfc 42:s:11RadrootsKit0A22CurrentLocationRequestV 0: +12:relationship 11:RadrootsKit 8:memberOf 125:s:11RadrootsKit0A28AppleBackgroundTransferStoreC13withAdmission3for9operationAA0adE6HandleVAA0adE10IdentifierV_AHyYaYbKctYaKF 48:s:11RadrootsKit0A28AppleBackgroundTransferStoreC 0: 12:relationship 11:RadrootsKit 8:memberOf 126:s:11RadrootsKit0A24ExternalActionValidationO12appleMapsURL10coordinate5label10Foundation0H0VAA0A18LocationCoordinateV_SSSgtKFZ 44:s:11RadrootsKit0A24ExternalActionValidationO 0: 12:relationship 11:RadrootsKit 8:memberOf 126:s:11RadrootsKit0A26BackgroundTransferResponseV10statusCode9mediaType15contentEncoding4bodyACSi_SSSgAH10Foundation4DataVSgtKcfc 46:s:11RadrootsKit0A26BackgroundTransferResponseV 0: 12:relationship 11:RadrootsKit 8:memberOf 126:s:11RadrootsKit0A27DocumentPresentationAdapterO12contentTypes3forSay22UniformTypeIdentifiers6UTTypeVGAA0aC13ImportRequestV_tFZ 47:s:11RadrootsKit0A27DocumentPresentationAdapterO 0: @@ -865,6 +874,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 174:s:11RadrootsKit0A17OpaqueSignRequestV11operationID12signerHandle12publicKeyHex6digest7purpose24deadlineUnixMillisecondsACSS_S2S10Foundation4DataVAA0acD7PurposeOs6UInt64VtKcfc 37:s:11RadrootsKit0A17OpaqueSignRequestV 0: 12:relationship 11:RadrootsKit 8:memberOf 175:s:11RadrootsKit0A34DocumentImportPresentationModifierV7request10fileAccess12onCompletionAC7SwiftUI7BindingVyAA0acD7RequestVSgG_AA0a4FileI0_pys6ResultOyAA0acdQ0Vs5Error_pGctcfc 54:s:11RadrootsKit0A34DocumentImportPresentationModifierV 0: 12:relationship 11:RadrootsKit 8:memberOf 175:s:7SwiftUI4ViewP11RadrootsKitE24radrootsDocumentImporter7request10fileAccess12onCompletionQrAA7BindingVyAD0dG13ImportRequestVSgG_AD0d4FileK0_pys6ResultOyAD0dgoR0Vs5Error_pGctF 16:s:7SwiftUI4ViewP 16:SwiftUICore.View +12:relationship 11:RadrootsKit 8:memberOf 176:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV18prepareUploadLease3for11executionID8existingAA0a10StagedBlobJ0VAA0adE7RequestV_10Foundation4UUIDVAA0aoP9ReferenceVSgtKF 55:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV 0: 12:relationship 11:RadrootsKit 8:memberOf 182:s:11RadrootsKit0A16AppleMobileStoreO7prepare5roots12publicKeyHex25protectedDataAvailability11fileManagerAA0acdE13ConfigurationVAA0aC9FileRootsV_SSAA0ac9ProtectedlM0OSo06NSFileO0CtKFZ 36:s:11RadrootsKit0A16AppleMobileStoreO 0: 12:relationship 11:RadrootsKit 8:memberOf 182:s:11RadrootsKit0A21BackgroundTaskRequestV10identifier4kind17earliestBeginDate27requiresNetworkConnectivity0K13ExternalPowerAcA0acD10IdentifierV_AA0acD4KindO10Foundation0J0VSgS2btKcfc 41:s:11RadrootsKit0A21BackgroundTaskRequestV 0: 12:relationship 11:RadrootsKit 8:memberOf 184:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV3now012notificationE006cameraE006photosE0010microphoneE008locationE0AC10Foundation4DateVyYbc_AA0adE0OyYaYbcANyYbcANyYbcANyYbcANyYbctcfc 49:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV 0: @@ -873,14 +883,14 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 198:s:11RadrootsKit0A22BackgroundTaskSnapshotV10identifier4kind17earliestBeginDate11submittedAt27requiresNetworkConnectivity0M13ExternalPowerAcA0acD10IdentifierV_AA0acD4KindO10Foundation0J0VSgAPS2btKcfc 42:s:11RadrootsKit0A22BackgroundTaskSnapshotV 0: 12:relationship 11:RadrootsKit 8:memberOf 202:s:11RadrootsKit0A15ScannedDocumentV4file10outputKind17suggestedFilename9mediaType9pageCount9sizeBytes10capturedAtAcA0A13FileReferenceV_AA0ad13ScannerOutputG0OS2Ss6UInt16Vs6UInt64V10Foundation4DateVtKcfc 35:s:11RadrootsKit0A15ScannedDocumentV 0: 12:relationship 11:RadrootsKit 8:memberOf 213:s:11RadrootsKit0A10MediaAssetV6source4kind4file9mediaType17suggestedFilename9sizeBytes10pixelWidth0N6Height10capturedAtAcA0aC6SourceO_AA0aC4KindOAA0A13FileReferenceVS2Ss6UInt64Vs6UInt32VSgAW10Foundation4DateVtKcfc 30:s:11RadrootsKit0A10MediaAssetV 0: -12:relationship 11:RadrootsKit 8:memberOf 213:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV3now7enqueue6cancel06activeE11Identifiers06handleD6EventsAC10Foundation4DateVyYbc_yAA0adE7RequestVYaYbKcyAA0adE10IdentifierVYaYbKcShyAOGyYaYbKcySS_yyYbctYaYbctcfc 51:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 220:s:11RadrootsKit0A15IdentityCustodyC13configuration11secureStore08metadataG012userPresence13protectedData3nowAcA0acD13ConfigurationV_AA0a6SecureG0_pAA0ac8MetadataG0_pAA0a4UserJ0_pAA0a9ProtectedL8ProviderVs6UInt64VyYbctcfc 35:s:11RadrootsKit0A15IdentityCustodyC 0: +12:relationship 11:RadrootsKit 8:memberOf 223:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV3now7enqueue6cancel06activeE11Identifiers06handleD6EventsAC10Foundation4DateVyYbc_yAA0adE7RequestV_AI4UUIDVtYaYbKcyAA0adE10IdentifierVYaYbKcShyAQGyYaYbKcySS_yyYbctYaYbctcfc 51:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 228:s:11RadrootsKit0A29AppleLocationServicesAdaptersV3now08locationE7Enabled19authorizationStatus29requestWhenInUseAuthorization0l7CurrentD0AC10Foundation4DateVyYbc_SbyYbcAA0adP0OyYbcAMSdYaYbKcAA0aD7ReadingVAA0aqD7RequestVYaYbKctcfc 49:s:11RadrootsKit0A29AppleLocationServicesAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 229:s:11RadrootsKit0A18AppleMediaPreparerC20blossomUploadRequest13preparedImage9remoteURL13authorization13networkPolicy10identifierAA0a18BackgroundTransferH0VAA0ac8PreparedJ0V_10Foundation0L0VSSAA0aqr7NetworkO0OAA0aqR10IdentifierVtKF 38:s:11RadrootsKit0A18AppleMediaPreparerC 0: 12:relationship 11:RadrootsKit 8:memberOf 243:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV3now8register6submit6cancel0K3All12pendingTasksAC10Foundation4DateVyYbc_SbAA0acdE12RegistrationVYaYbKcyAA0adE7RequestVYaYbKcyAA0adE10IdentifierVYaYbcyyYaYbcSayAA0adE8SnapshotVGyYaYbKctcfc 56:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV 0: -12:relationship 11:RadrootsKit 8:memberOf 266:s:11RadrootsKit0A26BackgroundTransferSnapshotV7request5state8progress7failure8response18downloadedArtifact20possibleRemoteOrphan9updatedAtAcA0acD7RequestV_AA0acD5StateOAA0acD8ProgressVAA0acD7FailureOSgAA0acD8ResponseVSgAA0ac10DownloadedL0VSgSb10Foundation4DateVtKcfc 46:s:11RadrootsKit0A26BackgroundTransferSnapshotV 0: 12:relationship 11:RadrootsKit 8:memberOf 275:s:11RadrootsKit0A28BackgroundTransferValidationO8validate9remoteURL6method9operation7headers8metadata13networkPolicy08responseN020expectedSourceSHA25607maximumD5Bytesy10Foundation0H0V_AA0acD6MethodOAA0acD9OperationOSDyS2SGAuA0acd7NetworkN0OAA0acd8ResponseN0VSSSgs6UInt64VtKFZ 48:s:11RadrootsKit0A28BackgroundTransferValidationO 0: 12:relationship 11:RadrootsKit 8:memberOf 295:s:11RadrootsKit0A25BackgroundTransferRequestV10identifier9remoteURL6method9operation7headers8metadata13networkPolicy08responseN020expectedSourceSHA25607maximumD5BytesAcA0acD10IdentifierV_10Foundation0H0VAA0acD6MethodOAA0acD9OperationOSDyS2SGAwA0acd7NetworkN0OAA0acd8ResponseN0VSSSgs6UInt64VtKcfc 45:s:11RadrootsKit0A25BackgroundTransferRequestV 0: +12:relationship 11:RadrootsKit 8:memberOf 331:s:11RadrootsKit0A26BackgroundTransferSnapshotV7request5state8progress7failure8response18downloadedArtifact20possibleRemoteOrphan9updatedAt11executionID11uploadLeaseAcA0acD7RequestV_AA0acD5StateOAA0acD8ProgressVAA0acD7FailureOSgAA0acD8ResponseVSgAA0ac10DownloadedL0VSgSb10Foundation4DateVA1_4UUIDVSgAA0A19StagedBlobReferenceVSgtKcfc 46:s:11RadrootsKit0A26BackgroundTransferSnapshotV 0: 12:relationship 11:RadrootsKit 8:memberOf 37:s:11RadrootsKit0A9FileEntryV4nameSSvp 28:s:11RadrootsKit0A9FileEntryV 0: 12:relationship 11:RadrootsKit 8:memberOf 39:s:11RadrootsKit0A13NoopTelemetryVACycfc 33:s:11RadrootsKit0A13NoopTelemetryV 0: 12:relationship 11:RadrootsKit 8:memberOf 39:s:11RadrootsKit0A9FileScopeO4datayA2CmF 28:s:11RadrootsKit0A9FileScopeO 0: @@ -1548,6 +1558,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A22BackgroundTaskSnapshotV17earliestBeginDate10Foundation0H0VSgvp 42:s:11RadrootsKit0A22BackgroundTaskSnapshotV 0: 12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A25BackgroundTransferRequestV014defaultMaximumD5Bytess6UInt64VvpZ 45:s:11RadrootsKit0A25BackgroundTransferRequestV 0: 12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A25BackgroundTransferRequestV14responsePolicyAA0acd8ResponseG0Vvp 45:s:11RadrootsKit0A25BackgroundTransferRequestV 0: +12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A26BackgroundTransferSnapshotV11executionID10Foundation4UUIDVSgvp 46:s:11RadrootsKit0A26BackgroundTransferSnapshotV 0: 12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A28AppleBackgroundTaskSchedulerC8adaptersAcA0acdeF8AdaptersV_tcfc 48:s:11RadrootsKit0A28AppleBackgroundTaskSchedulerC 0: 12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A28AppleLoggerTelemetryAdaptersV4emitACyAA0acE9LogRecordVYbc_tcfc 48:s:11RadrootsKit0A28AppleLoggerTelemetryAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 81:s:11RadrootsKit0A29ApplePermissionStatusProviderC8adaptersAcA0acdE8AdaptersV_tcfc 49:s:11RadrootsKit0A29ApplePermissionStatusProviderC 0: @@ -1571,7 +1582,6 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 84:s:11RadrootsKit0A29AppleMobileStoreConfigurationV14ownerDirectory10Foundation3URLVvp 49:s:11RadrootsKit0A29AppleMobileStoreConfigurationV 0: 12:relationship 11:RadrootsKit 8:memberOf 84:s:11RadrootsKit0A29UnavailableBackgroundTransferV9snapshotsSayAA0adE8SnapshotVGyYaKF 49:s:11RadrootsKit0A29UnavailableBackgroundTransferV 0: 12:relationship 11:RadrootsKit 8:memberOf 84:s:11RadrootsKit0A31AppleBackgroundTaskRegistrationV10identifierAA0adE10IdentifierVvp 51:s:11RadrootsKit0A31AppleBackgroundTaskRegistrationV 0: -12:relationship 11:RadrootsKit 8:memberOf 84:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV7enqueueyyAA0adE7RequestVYaYbKcvp 51:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 84:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV3now10Foundation4DateVyYbcvp 56:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 85:s:11RadrootsKit0A14AppleFileRootsV17normalizedRootURL_5field10Foundation0H0VAH_SStKFZ 34:s:11RadrootsKit0A14AppleFileRootsV 0: 12:relationship 11:RadrootsKit 8:memberOf 85:s:11RadrootsKit0A18UserPresencePolicyO39deviceOwnerAuthenticationWithBiometricsyA2CmF 38:s:11RadrootsKit0A18UserPresencePolicyO 0: @@ -1606,6 +1616,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A15AppleFileAccessC14readStagedBloby10Foundation4DataVAA0agH9ReferenceVKF 35:s:11RadrootsKit0A15AppleFileAccessC 0: 12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A15IdentityCustodyC21repairCorruptMetadata5labelAA0aC8SnapshotVSSSg_tYaKF 35:s:11RadrootsKit0A15IdentityCustodyC 0: 12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A24TelemetryRedactionPolicyV11replacement19maximumStringLengthACSS_Sitcfc 44:s:11RadrootsKit0A24TelemetryRedactionPolicyV 0: +12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A26BackgroundTransferSnapshotV11uploadLeaseAA0A19StagedBlobReferenceVSgvp 46:s:11RadrootsKit0A26BackgroundTransferSnapshotV 0: 12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A27BackgroundTransferLocalFileO10stagedBlobyAcA0a6StagedH9ReferenceVcACmF 47:s:11RadrootsKit0A27BackgroundTransferLocalFileO 0: 12:relationship 11:RadrootsKit 8:memberOf 89:s:11RadrootsKit0A29UnavailableBackgroundTransferV5retryyAA0adE6HandleVAA0adE7RequestVYaKF 49:s:11RadrootsKit0A29UnavailableBackgroundTransferV 0: 12:relationship 11:RadrootsKit 8:memberOf 90:s:11RadrootsKit0A20AppleDocumentScannerC10fileAccess15callbackTimeoutAcA0ac4FileG0C_Sdtcfc 40:s:11RadrootsKit0A20AppleDocumentScannerC 0: @@ -1651,6 +1662,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 97:s:11RadrootsKit0A29AppleMobileStoreConfigurationV27applicationSupportDirectory10Foundation3URLVvp 49:s:11RadrootsKit0A29AppleMobileStoreConfigurationV 0: 12:relationship 11:RadrootsKit 8:memberOf 97:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerV6submityAA0adE8SnapshotVAA0adE7RequestVYaKF 54:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerV 0: 12:relationship 11:RadrootsKit 8:memberOf 98:s:11RadrootsKit0A19StagedBlobReferenceV6blobID9sizeBytes9mediaType12filenameHintACSS_SiSSSgAHtKcfc 39:s:11RadrootsKit0A19StagedBlobReferenceV 0: +12:relationship 11:RadrootsKit 8:memberOf 98:s:11RadrootsKit0A28AppleBackgroundTransferStoreC17admissionIsActive3forSbAA0adE10IdentifierV_tYaKF 48:s:11RadrootsKit0A28AppleBackgroundTransferStoreC 0: 12:relationship 11:RadrootsKit 8:memberOf 98:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV7resolvey10Foundation3URLVAA0ade5LocalF0OKF 55:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV 0: 12:relationship 11:RadrootsKit 8:memberOf 98:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV8registerySbAA0acdE12RegistrationVYaYbKcvp 56:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV 0: 12:relationship 11:RadrootsKit 8:memberOf 99:s:11RadrootsKit0A23AppleBackgroundTransferC8snapshot3forAA0adE8SnapshotVSgAA0adE10IdentifierV_tYaKF 43:s:11RadrootsKit0A23AppleBackgroundTransferC 0: @@ -1732,15 +1744,18 @@ radroots.apple-kit.public-api.v1 12:relationship 18:RadrootsKitTesting 8:memberOf 106:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC8snapshot3for0aB00aF8SnapshotVAF0aF4KindO_tYaKF 59:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 107:s:18RadrootsKitTesting0A22FakeBackgroundTransferC8snapshot3for0aB00aeF8SnapshotVSgAF0aeF10IdentifierV_tYaKF 49:s:18RadrootsKitTesting0A22FakeBackgroundTransferC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 110:s:18RadrootsKitTesting0A15FakeMediaPickerC16setImportOutcomeyys6ResultOy0aB00aehJ0VAG0A18CaptureIntakeErrorOGF 42:s:18RadrootsKitTesting0A15FakeMediaPickerC 0: +12:relationship 18:RadrootsKitTesting 8:memberOf 110:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC17admissionIsActive3forSb0aB00afG10IdentifierV_tYaKF 58:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 112:s:18RadrootsKitTesting0A20FakeLocationServicesC010setCurrentE7Outcomeyys6ResultOy0aB00aE7ReadingVAG0aeF5ErrorOGF 47:s:18RadrootsKitTesting0A20FakeLocationServicesC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 113:s:18RadrootsKitTesting0A29InMemoryIdentityMetadataStoreC14replaceRawData_3fory10Foundation0K0VSg_0aB00afG4SlotOtF 56:s:18RadrootsKitTesting0A29InMemoryIdentityMetadataStoreC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 118:s:18RadrootsKitTesting0A19FakeDocumentScannerC14setScanOutcomeyys6ResultOy0aB00a7ScannedE0VAG0A18CaptureIntakeErrorOGF 46:s:18RadrootsKitTesting0A19FakeDocumentScannerC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 120:s:18RadrootsKitTesting0A16FakeUserPresenceC6status19verificationOutcomeAC0aB00aeF6StatusV_s6ResultOySbAF0aeF5ErrorOGtcfc 43:s:18RadrootsKitTesting0A16FakeUserPresenceC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 120:s:18RadrootsKitTesting0A22FakeBackgroundTransferC6settle_12verificationy0aB00aeF10IdentifierV_AF0aeF12VerificationOtYaKF 49:s:18RadrootsKitTesting0A22FakeBackgroundTransferC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 123:s:18RadrootsKitTesting0A19InMemorySecureStoreC3put_3for6policyy10Foundation4DataV_0aB00afG3KeyVAJ0A18SecretAccessPolicyVtKF 46:s:18RadrootsKitTesting0A19InMemorySecureStoreC 0: +12:relationship 18:RadrootsKitTesting 8:memberOf 123:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC23compareExchangeSnapshot8expected7desiredSb0aB00afgK0VSg_AItYaKF 58:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 124:s:18RadrootsKitTesting0A22FakeBackgroundTransferC015handleEventsForE10URLSession10identifier17completionHandlerySS_yyYbctYaF 49:s:18RadrootsKitTesting0A22FakeBackgroundTransferC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 128:s:18RadrootsKitTesting0A25UITestLaunchConfigurationV13deterministic11environment9arguments8language6localeACSDyS2SG_SaySSGS2StFZ 52:s:18RadrootsKitTesting0A25UITestLaunchConfigurationV 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 128:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC03setG0_3for10observedAty0aB00afG0O_AG0aF4KindO10Foundation4DateVSgtF 59:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC 0: +12:relationship 18:RadrootsKitTesting 8:memberOf 137:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC13withAdmission3for9operation0aB00afG6HandleVAG0afG10IdentifierV_AIyYaYbKctYaKF 58:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 142:s:18RadrootsKitTesting0A19FakeDocumentScannerC7support11scanOutcomeAC0aB00aeF7SupportV_s6ResultOyAF0a7ScannedE0VAF0A18CaptureIntakeErrorOGtcfc 46:s:18RadrootsKitTesting0A19FakeDocumentScannerC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 143:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC8statuses07defaultG010observedAtACSDy0aB00aF4KindOAG0afG0OG_AK10Foundation4DateVtcfc 59:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC 0: 12:relationship 18:RadrootsKitTesting 8:memberOf 148:s:18RadrootsKitTesting0A22FakeBackgroundTransferC5store14enqueueOutcome9updatedAtAC0aB00aeF5Store_p_s6ResultOyytAG0aeF5ErrorOG10Foundation4DateVtcfc 49:s:18RadrootsKitTesting0A22FakeBackgroundTransferC 0: @@ -1941,12 +1956,12 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 10:swift.init 198:s:11RadrootsKit0A22BackgroundTaskSnapshotV10identifier4kind17earliestBeginDate11submittedAt27requiresNetworkConnectivity0M13ExternalPowerAcA0acD10IdentifierV_AA0acD4KindO10Foundation0J0VSgAPS2btKcfc 102:init(identifier:kind:earliestBeginDate:submittedAt:requiresNetworkConnectivity:requiresExternalPower:) 231:init(identifier: RadrootsBackgroundTaskIdentifier, kind: RadrootsBackgroundTaskKind, earliestBeginDate: Date? = nil, submittedAt: Date = Date(), requiresNetworkConnectivity: Bool = false, requiresExternalPower: Bool = false) throws 6:symbol 11:RadrootsKit 10:swift.init 202:s:11RadrootsKit0A15ScannedDocumentV4file10outputKind17suggestedFilename9mediaType9pageCount9sizeBytes10capturedAtAcA0A13FileReferenceV_AA0ad13ScannerOutputG0OS2Ss6UInt16Vs6UInt64V10Foundation4DateVtKcfc 81:init(file:outputKind:suggestedFilename:mediaType:pageCount:sizeBytes:capturedAt:) 189:init(file: RadrootsFileReference, outputKind: RadrootsDocumentScannerOutputKind, suggestedFilename: String, mediaType: String, pageCount: UInt16, sizeBytes: UInt64, capturedAt: Date) throws 6:symbol 11:RadrootsKit 10:swift.init 213:s:11RadrootsKit0A10MediaAssetV6source4kind4file9mediaType17suggestedFilename9sizeBytes10pixelWidth0N6Height10capturedAtAcA0aC6SourceO_AA0aC4KindOAA0A13FileReferenceVS2Ss6UInt64Vs6UInt32VSgAW10Foundation4DateVtKcfc 95:init(source:kind:file:mediaType:suggestedFilename:sizeBytes:pixelWidth:pixelHeight:capturedAt:) 232:init(source: RadrootsMediaSource, kind: RadrootsMediaKind, file: RadrootsFileReference, mediaType: String, suggestedFilename: String, sizeBytes: UInt64, pixelWidth: UInt32? = nil, pixelHeight: UInt32? = nil, capturedAt: Date) throws -6:symbol 11:RadrootsKit 10:swift.init 213:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV3now7enqueue6cancel06activeE11Identifiers06handleD6EventsAC10Foundation4DateVyYbc_yAA0adE7RequestVYaYbKcyAA0adE10IdentifierVYaYbKcShyAOGyYaYbKcySS_yyYbctYaYbctcfc 74:init(now:enqueue:cancel:activeTransferIdentifiers:handleBackgroundEvents:) 438:init(now: @escaping @Sendable () -> Date = Date.init, enqueue: @escaping @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void, cancel: @escaping @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void, activeTransferIdentifiers: @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>, handleBackgroundEvents: @escaping @Sendable (String, @escaping @Sendable () -> Void) async -> Void) 6:symbol 11:RadrootsKit 10:swift.init 220:s:11RadrootsKit0A15IdentityCustodyC13configuration11secureStore08metadataG012userPresence13protectedData3nowAcA0acD13ConfigurationV_AA0a6SecureG0_pAA0ac8MetadataG0_pAA0a4UserJ0_pAA0a9ProtectedL8ProviderVs6UInt64VyYbctcfc 77:init(configuration:secureStore:metadataStore:userPresence:protectedData:now:) 353:init(configuration: RadrootsIdentityCustodyConfiguration, secureStore: any RadrootsSecureStore, metadataStore: any RadrootsIdentityMetadataStore, userPresence: any RadrootsUserPresence, protectedData: RadrootsProtectedDataProvider = .available, now: @escaping @Sendable () -> UInt64 = {\n UInt64(Date().timeIntervalSince1970 * 1000)\n }) +6:symbol 11:RadrootsKit 10:swift.init 223:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV3now7enqueue6cancel06activeE11Identifiers06handleD6EventsAC10Foundation4DateVyYbc_yAA0adE7RequestV_AI4UUIDVtYaYbKcyAA0adE10IdentifierVYaYbKcShyAQGyYaYbKcySS_yyYbctYaYbctcfc 74:init(now:enqueue:cancel:activeTransferIdentifiers:handleBackgroundEvents:) 444:init(now: @escaping @Sendable () -> Date = Date.init, enqueue: @escaping @Sendable (RadrootsBackgroundTransferRequest, UUID) async throws -> Void, cancel: @escaping @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void, activeTransferIdentifiers: @escaping @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier>, handleBackgroundEvents: @escaping @Sendable (String, @escaping @Sendable () -> Void) async -> Void) 6:symbol 11:RadrootsKit 10:swift.init 228:s:11RadrootsKit0A29AppleLocationServicesAdaptersV3now08locationE7Enabled19authorizationStatus29requestWhenInUseAuthorization0l7CurrentD0AC10Foundation4DateVyYbc_SbyYbcAA0adP0OyYbcAMSdYaYbKcAA0aD7ReadingVAA0aqD7RequestVYaYbKctcfc 107:init(now:locationServicesEnabled:authorizationStatus:requestWhenInUseAuthorization:requestCurrentLocation:) 419:init(now: @escaping @Sendable () -> Date = Date.init, locationServicesEnabled: @escaping @Sendable () -> Bool, authorizationStatus: @escaping @Sendable () -> RadrootsLocationAuthorization, requestWhenInUseAuthorization: @escaping @Sendable (TimeInterval) async throws -> RadrootsLocationAuthorization, requestCurrentLocation: @escaping @Sendable (RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading) 6:symbol 11:RadrootsKit 10:swift.init 243:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV3now8register6submit6cancel0K3All12pendingTasksAC10Foundation4DateVyYbc_SbAA0acdE12RegistrationVYaYbKcyAA0adE7RequestVYaYbKcyAA0adE10IdentifierVYaYbcyyYaYbcSayAA0adE8SnapshotVGyYaYbKctcfc 56:init(now:register:submit:cancel:cancelAll:pendingTasks:) 443:init(now: @escaping @Sendable () -> Date = Date.init, register: @escaping @Sendable (RadrootsAppleBackgroundTaskRegistration) async throws -> Bool, submit: @escaping @Sendable (RadrootsBackgroundTaskRequest) async throws -> Void, cancel: @escaping @Sendable (RadrootsBackgroundTaskIdentifier) async -> Void, cancelAll: @escaping @Sendable () async -> Void, pendingTasks: @escaping @Sendable () async throws -> [RadrootsBackgroundTaskSnapshot]) -6:symbol 11:RadrootsKit 10:swift.init 266:s:11RadrootsKit0A26BackgroundTransferSnapshotV7request5state8progress7failure8response18downloadedArtifact20possibleRemoteOrphan9updatedAtAcA0acD7RequestV_AA0acD5StateOAA0acD8ProgressVAA0acD7FailureOSgAA0acD8ResponseVSgAA0ac10DownloadedL0VSgSb10Foundation4DateVtKcfc 96:init(request:state:progress:failure:response:downloadedArtifact:possibleRemoteOrphan:updatedAt:) 390:init(request: RadrootsBackgroundTransferRequest, state: RadrootsBackgroundTransferState = .queued, progress: RadrootsBackgroundTransferProgress = .zero, failure: RadrootsBackgroundTransferFailure? = nil, response: RadrootsBackgroundTransferResponse? = nil, downloadedArtifact: RadrootsBackgroundDownloadedArtifact? = nil, possibleRemoteOrphan: Bool = false, updatedAt: Date = Date()) throws 6:symbol 11:RadrootsKit 10:swift.init 295:s:11RadrootsKit0A25BackgroundTransferRequestV10identifier9remoteURL6method9operation7headers8metadata13networkPolicy08responseN020expectedSourceSHA25607maximumD5BytesAcA0acD10IdentifierV_10Foundation0H0VAA0acD6MethodOAA0acD9OperationOSDyS2SGAwA0acd7NetworkN0OAA0acd8ResponseN0VSSSgs6UInt64VtKcfc 132:init(identifier:remoteURL:method:operation:headers:metadata:networkPolicy:responsePolicy:expectedSourceSHA256:maximumTransferBytes:) 493:init(identifier: RadrootsBackgroundTransferIdentifier = .generated(), remoteURL: URL, method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation, headers: [String : String] = [:], metadata: [String : String] = [:], networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS, responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard, expectedSourceSHA256: String? = nil, maximumTransferBytes: UInt64 = Self.defaultMaximumTransferBytes) throws +6:symbol 11:RadrootsKit 10:swift.init 331:s:11RadrootsKit0A26BackgroundTransferSnapshotV7request5state8progress7failure8response18downloadedArtifact20possibleRemoteOrphan9updatedAt11executionID11uploadLeaseAcA0acD7RequestV_AA0acD5StateOAA0acD8ProgressVAA0acD7FailureOSgAA0acD8ResponseVSgAA0ac10DownloadedL0VSgSb10Foundation4DateVA1_4UUIDVSgAA0A19StagedBlobReferenceVSgtKcfc 120:init(request:state:progress:failure:response:downloadedArtifact:possibleRemoteOrphan:updatedAt:executionID:uploadLease:) 465:init(request: RadrootsBackgroundTransferRequest, state: RadrootsBackgroundTransferState = .queued, progress: RadrootsBackgroundTransferProgress = .zero, failure: RadrootsBackgroundTransferFailure? = nil, response: RadrootsBackgroundTransferResponse? = nil, downloadedArtifact: RadrootsBackgroundDownloadedArtifact? = nil, possibleRemoteOrphan: Bool = false, updatedAt: Date = Date(), executionID: UUID? = nil, uploadLease: RadrootsStagedBlobReference? = nil) throws 6:symbol 11:RadrootsKit 10:swift.init 39:s:11RadrootsKit0A13NoopTelemetryVACycfc 6:init() 6:init() 6:symbol 11:RadrootsKit 10:swift.init 47:s:11RadrootsKit0A18IdentityPassphraseVyACSSKcfc 8:init(_:) 28:init(_ value: String) throws 6:symbol 11:RadrootsKit 10:swift.init 48:s:11RadrootsKit0A9MediaKindO8rawValueACSgSS_tcfc 15:init(rawValue:) 23:init?(rawValue: String) @@ -2057,12 +2072,16 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 12:swift.method 105:s:11RadrootsKit0A15AppleFileAccessC17installStagedBlob_9referencey10Foundation4DataV_AA0agH9ReferenceVtKF 31:installStagedBlob(_:reference:) 83:func installStagedBlob(_ data: Data, reference: RadrootsStagedBlobReference) throws 6:symbol 11:RadrootsKit 12:swift.method 105:s:11RadrootsKit0A29UnavailableBackgroundTransferV8snapshot3forAA0adE8SnapshotVSgAA0adE10IdentifierV_tYaKF 14:snapshot(for:) 110:func snapshot(for _: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? 6:symbol 11:RadrootsKit 12:swift.method 106:s:11RadrootsKit0A11SecureStoreP3put_3for6policyy10Foundation4DataV_AA0acD3KeyVAA0A18SecretAccessPolicyVtKF 18:put(_:for:policy:) 99:func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws +6:symbol 11:RadrootsKit 12:swift.method 106:s:11RadrootsKit0A23BackgroundTransferStoreP23compareExchangeSnapshot8expected7desiredSbAA0acdH0VSg_AHtYaKF 42:compareExchangeSnapshot(expected:desired:) 141:func compareExchangeSnapshot(expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot) async throws -> Bool +6:symbol 11:RadrootsKit 12:swift.method 106:s:11RadrootsKit0A30BackgroundTransferFileResolverP18releaseUploadLease11executionIDy10Foundation4UUIDV_tKF 32:releaseUploadLease(executionID:) 49:func releaseUploadLease(executionID: UUID) throws 6:symbol 11:RadrootsKit 12:swift.method 107:s:11RadrootsKit0A18BackgroundTransferP6settle_12verificationyAA0acD10IdentifierV_AA0acD12VerificationOtYaKF 23:settle(_:verification:) 130:func settle(_ identifier: RadrootsBackgroundTransferIdentifier, verification: RadrootsBackgroundTransferVerification) async throws 6:symbol 11:RadrootsKit 12:swift.method 108:s:11RadrootsKit0A10FileAccessP05stageC0_9mediaType12filenameHintAA0A19StagedBlobReferenceVAA0acL0V_SSSgAKtKF 36:stageFile(_:mediaType:filenameHint:) 145:@discardableResult func stageFile(_ file: RadrootsFileReference, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference 6:symbol 11:RadrootsKit 12:swift.method 109:s:11RadrootsKit0A14AppleFileRootsV11resolvedURL3for18allowRootDirectory10Foundation0G0VAA0aD9ReferenceV_SbtKF 36:resolvedURL(for:allowRootDirectory:) 97:func resolvedURL(for file: RadrootsFileReference, allowRootDirectory: Bool = false) throws -> URL 6:symbol 11:RadrootsKit 12:swift.method 109:s:11RadrootsKit0A30BackgroundTransferFileResolverP4read_12maximumBytes10Foundation4DataVAA0acd5LocalE0O_SitKF 21:read(_:maximumBytes:) 88:func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data 6:symbol 11:RadrootsKit 12:swift.method 110:s:11RadrootsKit0A15AppleFileAccessC16sweepStagedBlobs9olderThanSayAA0aG13BlobReferenceVG10Foundation4DateV_tKF 28:sweepStagedBlobs(olderThan:) 104:@discardableResult func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] 6:symbol 11:RadrootsKit 12:swift.method 110:s:11RadrootsKit0A15IdentityCustodyC013migrateLegacyC04from5labelAA0aC8SnapshotVAA0A14SecureStoreKeyV_SSSgtYaKF 34:migrateLegacyIdentity(from:label:) 148:@discardableResult func migrateLegacyIdentity(from legacyKey: RadrootsSecureStoreKey, label: String? = nil) async throws -> RadrootsIdentitySnapshot +6:symbol 11:RadrootsKit 12:swift.method 111:s:11RadrootsKit0A28AppleBackgroundTransferStoreC23compareExchangeSnapshot8expected7desiredSbAA0adeI0VSg_AHtYaKF 42:compareExchangeSnapshot(expected:desired:) 141:func compareExchangeSnapshot(expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot) async throws -> Bool +6:symbol 11:RadrootsKit 12:swift.method 111:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV18releaseUploadLease11executionIDy10Foundation4UUIDV_tKF 32:releaseUploadLease(executionID:) 49:func releaseUploadLease(executionID: UUID) throws 6:symbol 11:RadrootsKit 12:swift.method 112:s:11RadrootsKit0A23AppleBackgroundTransferC6settle_12verificationyAA0adE10IdentifierV_AA0adE12VerificationOtYaKF 23:settle(_:verification:) 130:func settle(_ identifier: RadrootsBackgroundTransferIdentifier, verification: RadrootsBackgroundTransferVerification) async throws 6:symbol 11:RadrootsKit 12:swift.method 113:s:11RadrootsKit0A15AppleFileAccessC05stageD0_9mediaType12filenameHintAA0A19StagedBlobReferenceVAA0adM0V_SSSgAKtKF 36:stageFile(_:mediaType:filenameHint:) 157:@discardableResult func stageFile(_ file: RadrootsFileReference, mediaType: String? = nil, filenameHint: String? = nil) throws -> RadrootsStagedBlobReference 6:symbol 11:RadrootsKit 12:swift.method 113:s:11RadrootsKit0A18BackgroundTransferP015handleEventsForC10URLSession10identifier17completionHandlerySS_yyYbctYaF 66:handleEventsForBackgroundURLSession(identifier:completionHandler:) 117:func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async @@ -2074,9 +2093,11 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 12:swift.method 118:s:11RadrootsKit0A23AppleBackgroundTransferC015handleEventsForD10URLSession10identifier17completionHandlerySS_yyYbctYaF 66:handleEventsForBackgroundURLSession(identifier:completionHandler:) 117:func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async 6:symbol 11:RadrootsKit 12:swift.method 118:s:11RadrootsKit0A29UnavailableBackgroundTransferV6settle_12verificationyAA0adE10IdentifierV_AA0adE12VerificationOtYaKF 23:settle(_:verification:) 121:func settle(_: RadrootsBackgroundTransferIdentifier, verification _: RadrootsBackgroundTransferVerification) async throws 6:symbol 11:RadrootsKit 12:swift.method 119:s:11RadrootsKit0A24AppleKeychainSecureStoreC3put_3for6policyy10Foundation4DataV_AA0aeF3KeyVAA0A18SecretAccessPolicyVtKF 18:put(_:for:policy:) 120:func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy = .secureLocalSecret) throws +6:symbol 11:RadrootsKit 12:swift.method 120:s:11RadrootsKit0A23BackgroundTransferStoreP13withAdmission3for9operationAA0acD6HandleVAA0acD10IdentifierV_AHyYaYbKctYaKF 29:withAdmission(for:operation:) 205:func withAdmission(for identifier: RadrootsBackgroundTransferIdentifier, operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle) async throws -> RadrootsBackgroundTransferHandle 6:symbol 11:RadrootsKit 12:swift.method 121:s:11RadrootsKit0A15AppleFileAccessC9stageBlob_9mediaType12filenameHintAA0a6StagedG9ReferenceV10Foundation4DataV_SSSgALtKF 36:stageBlob(_:mediaType:filenameHint:) 140:@discardableResult func stageBlob(_ data: Data, mediaType: String? = nil, filenameHint: String? = nil) throws -> RadrootsStagedBlobReference 6:symbol 11:RadrootsKit 12:swift.method 123:s:11RadrootsKit0A26PreparedExportFileDocumentV11fileWrapper13configurationSo06NSFileH0C7SwiftUI0eF18WriteConfigurationV_tKF 27:fileWrapper(configuration:) 110:func fileWrapper(configuration _: RadrootsPreparedExportFileDocument.WriteConfiguration) throws -> FileWrapper 6:symbol 11:RadrootsKit 12:swift.method 124:s:11RadrootsKit0A29UnavailableBackgroundTransferV015handleEventsForD10URLSession10identifier17completionHandlerySS_yyYbctYaF 66:handleEventsForBackgroundURLSession(identifier:completionHandler:) 119:func handleEventsForBackgroundURLSession(identifier _: String, completionHandler: @escaping @Sendable () -> Void) async +6:symbol 11:RadrootsKit 12:swift.method 125:s:11RadrootsKit0A28AppleBackgroundTransferStoreC13withAdmission3for9operationAA0adE6HandleVAA0adE10IdentifierV_AHyYaYbKctYaKF 29:withAdmission(for:operation:) 205:func withAdmission(for identifier: RadrootsBackgroundTransferIdentifier, operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle) async throws -> RadrootsBackgroundTransferHandle 6:symbol 11:RadrootsKit 12:swift.method 126:s:11RadrootsKit0A10FileAccessP013stageExternalC0_9mediaType12filenameHintAA0A19StagedBlobReferenceV10Foundation3URLV_SSSgALtKF 44:stageExternalFile(_:mediaType:filenameHint:) 140:@discardableResult func stageExternalFile(_ sourceURL: URL, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference 6:symbol 11:RadrootsKit 12:swift.method 131:s:11RadrootsKit0A15AppleFileAccessC013stageExternalD0_9mediaType12filenameHintAA0A19StagedBlobReferenceV10Foundation3URLV_SSSgALtKF 44:stageExternalFile(_:mediaType:filenameHint:) 152:@discardableResult func stageExternalFile(_ sourceURL: URL, mediaType: String? = nil, filenameHint: String? = nil) throws -> RadrootsStagedBlobReference 6:symbol 11:RadrootsKit 12:swift.method 146:s:11RadrootsKit0A10FileAccessP012copyExternalC0_2to9mediaType17suggestedFilenameAA0A16ImportedDocumentV10Foundation3URLV_AA0aC9ReferenceVSSSgAOtKF 51:copyExternalFile(_:to:mediaType:suggestedFilename:) 173:@discardableResult func copyExternalFile(_ sourceURL: URL, to file: RadrootsFileReference, mediaType: String?, suggestedFilename: String?) throws -> RadrootsImportedDocument @@ -2084,7 +2105,9 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 12:swift.method 152:s:11RadrootsKit0A15IdentityCustodyC014importPortableC0_10passphrase15replaceExistingAA0aC8SnapshotVAA0aC19PortabilityEnvelopeV_AA0aC10PassphraseVSbtYaKF 53:importPortableIdentity(_:passphrase:replaceExisting:) 207:@discardableResult func importPortableIdentity(_ envelope: RadrootsIdentityPortabilityEnvelope, passphrase: RadrootsIdentityPassphrase, replaceExisting: Bool = false) async throws -> RadrootsIdentitySnapshot 6:symbol 11:RadrootsKit 12:swift.method 154:s:7SwiftUI4ViewP11RadrootsKitE24radrootsDocumentExporter14preparedExport12onCompletionQrAA7BindingVyAD0d8PreparedjG0VSgG_ys6ResultOyAD0djgO0Vs5Error_pGctF 54:radrootsDocumentExporter(preparedExport:onCompletion:) 195:@MainActor func radrootsDocumentExporter(preparedExport: Binding<RadrootsPreparedExportDocument?>, onCompletion: @escaping (Result<RadrootsExportDocumentResult, any Error>) -> Void) -> some View\n 6:symbol 11:RadrootsKit 12:swift.method 159:s:11RadrootsKit0A18OpaqueSignerBridgeC6submit_10completionAA0acD12CancellationCAA0aC11SignRequestV_ys6ResultOyAA0aC9SignatureVAA0A20IdentityCustodyErrorOGYbctF 21:submit(_:completion:) 209:@discardableResult func submit(_ request: RadrootsOpaqueSignRequest, completion: @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void) -> RadrootsOpaqueSignerCancellation +6:symbol 11:RadrootsKit 12:swift.method 171:s:11RadrootsKit0A30BackgroundTransferFileResolverP18prepareUploadLease3for11executionID8existingAA0a10StagedBlobI0VAA0acD7RequestV_10Foundation4UUIDVAA0anO9ReferenceVSgtKF 45:prepareUploadLease(for:executionID:existing:) 164:func prepareUploadLease(for request: RadrootsBackgroundTransferRequest, executionID: UUID, existing: RadrootsStagedBlobReference?) throws -> RadrootsStagedBlobLease 6:symbol 11:RadrootsKit 12:swift.method 175:s:7SwiftUI4ViewP11RadrootsKitE24radrootsDocumentImporter7request10fileAccess12onCompletionQrAA7BindingVyAD0dG13ImportRequestVSgG_AD0d4FileK0_pys6ResultOyAD0dgoR0Vs5Error_pGctF 58:radrootsDocumentImporter(request:fileAccess:onCompletion:) 223:@MainActor func radrootsDocumentImporter(request: Binding<RadrootsDocumentImportRequest?>, fileAccess: any RadrootsFileAccess, onCompletion: @escaping (Result<RadrootsDocumentImportResult, any Error>) -> Void) -> some View\n +6:symbol 11:RadrootsKit 12:swift.method 176:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV18prepareUploadLease3for11executionID8existingAA0a10StagedBlobJ0VAA0adE7RequestV_10Foundation4UUIDVAA0aoP9ReferenceVSgtKF 45:prepareUploadLease(for:executionID:existing:) 164:func prepareUploadLease(for request: RadrootsBackgroundTransferRequest, executionID: UUID, existing: RadrootsStagedBlobReference?) throws -> RadrootsStagedBlobLease 6:symbol 11:RadrootsKit 12:swift.method 229:s:11RadrootsKit0A18AppleMediaPreparerC20blossomUploadRequest13preparedImage9remoteURL13authorization13networkPolicy10identifierAA0a18BackgroundTransferH0VAA0ac8PreparedJ0V_10Foundation0L0VSSAA0aqr7NetworkO0OAA0aqR10IdentifierVtKF 85:blossomUploadRequest(preparedImage:remoteURL:authorization:networkPolicy:identifier:) 287:func blossomUploadRequest(preparedImage: RadrootsApplePreparedImage, remoteURL: URL, authorization: String, networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS, identifier: RadrootsBackgroundTransferIdentifier = .generated()) throws -> RadrootsBackgroundTransferRequest 6:symbol 11:RadrootsKit 12:swift.method 46:s:11RadrootsKit0A15IdentityCustodyC04lockC0yyF 14:lockIdentity() 19:func lockIdentity() 6:symbol 11:RadrootsKit 12:swift.method 51:s:11RadrootsKit0A14SecureStoreKeyV10normalizedACyKF 12:normalized() 50:func normalized() throws -> RadrootsSecureStoreKey @@ -2215,6 +2238,7 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 12:swift.method 91:s:11RadrootsKit0A29UnavailableBackgroundTransferV7enqueueyAA0adE6HandleVAA0adE7RequestVYaKF 11:enqueue(_:) 99:func enqueue(_: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle 6:symbol 11:RadrootsKit 12:swift.method 92:s:11RadrootsKit0A15AppleFileAccessC13prepareExportyAA0a8PreparedG8DocumentVAA0agI7RequestVKF 17:prepareExport(_:) 120:@discardableResult func prepareExport(_ request: RadrootsExportDocumentRequest) throws -> RadrootsPreparedExportDocument 6:symbol 11:RadrootsKit 12:swift.method 93:s:11RadrootsKit0A21AppleLocationServicesC07currentD0yAA0a7CurrentD6ResultVAA0agD7RequestVYaKF 19:currentLocation(_:) 109:func currentLocation(_ request: RadrootsCurrentLocationRequest) async throws -> RadrootsCurrentLocationResult +6:symbol 11:RadrootsKit 12:swift.method 93:s:11RadrootsKit0A23BackgroundTransferStoreP17admissionIsActive3forSbAA0acD10IdentifierV_tYaKF 23:admissionIsActive(for:) 97:func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool 6:symbol 11:RadrootsKit 12:swift.method 93:s:11RadrootsKit0A30BackgroundTransferFileResolverP7resolvey10Foundation3URLVAA0acd5LocalE0OKF 11:resolve(_:) 71:func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL 6:symbol 11:RadrootsKit 12:swift.method 93:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerV12pendingTasksSayAA0adE8SnapshotVGyYaKF 14:pendingTasks() 68:func pendingTasks() async throws -> [RadrootsBackgroundTaskSnapshot] 6:symbol 11:RadrootsKit 12:swift.method 94:s:11RadrootsKit0A18BackgroundTransferP8snapshot3forAA0acD8SnapshotVSgAA0acD10IdentifierV_tYaKF 14:snapshot(for:) 119:func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? @@ -2225,6 +2249,7 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 12:swift.method 96:s:11RadrootsKit0A20AppleExternalActionsC7canOpenyAA0aD16ActionCapabilityVAA0adH11DestinationVYaF 11:canOpen(_:) 104:func canOpen(_ destination: RadrootsExternalActionDestination) async -> RadrootsExternalActionCapability 6:symbol 11:RadrootsKit 12:swift.method 97:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerV6submityAA0adE8SnapshotVAA0adE7RequestVYaKF 10:submit(_:) 92:func submit(_: RadrootsBackgroundTaskRequest) async throws -> RadrootsBackgroundTaskSnapshot 6:symbol 11:RadrootsKit 12:swift.method 98:s:11RadrootsKit0A24PermissionStatusProviderP9snapshots3forSayAA0aC8SnapshotVGSayAA0aC4KindOG_tYaKF 15:snapshots(for:) 96:func snapshots(for kinds: [RadrootsPermissionKind]) async throws -> [RadrootsPermissionSnapshot] +6:symbol 11:RadrootsKit 12:swift.method 98:s:11RadrootsKit0A28AppleBackgroundTransferStoreC17admissionIsActive3forSbAA0adE10IdentifierV_tYaKF 23:admissionIsActive(for:) 97:func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool 6:symbol 11:RadrootsKit 12:swift.method 98:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV7resolvey10Foundation3URLVAA0ade5LocalF0OKF 11:resolve(_:) 71:func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL 6:symbol 11:RadrootsKit 12:swift.method 99:s:11RadrootsKit0A23AppleBackgroundTransferC8snapshot3forAA0adE8SnapshotVSgAA0adE10IdentifierV_tYaKF 14:snapshot(for:) 119:func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? 6:symbol 11:RadrootsKit 12:swift.struct 28:s:11RadrootsKit0A9FileEntryV 17:RadrootsFileEntry 24:struct RadrootsFileEntry @@ -2316,6 +2341,7 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 13:swift.func.op 49:s:11RadrootsKit0A14TelemetryLevelO1loiySbAC_ACtFZ 7:<(_:_:) 80:static func < (lhs: RadrootsTelemetryLevel, rhs: RadrootsTelemetryLevel) -> Bool 6:symbol 11:RadrootsKit 13:swift.func.op 59:s:11RadrootsKit0A24BackgroundTaskIdentifierV1loiySbAC_ACtFZ 7:<(_:_:) 100:static func < (lhs: RadrootsBackgroundTaskIdentifier, rhs: RadrootsBackgroundTaskIdentifier) -> Bool 6:symbol 11:RadrootsKit 13:swift.func.op 63:s:11RadrootsKit0A28BackgroundTransferIdentifierV1loiySbAC_ACtFZ 7:<(_:_:) 108:static func < (lhs: RadrootsBackgroundTransferIdentifier, rhs: RadrootsBackgroundTransferIdentifier) -> Bool +6:symbol 11:RadrootsKit 14:swift.property 104:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV7enqueueyyAA0adE7RequestV_10Foundation4UUIDVtYaYbKcvp 7:enqueue 85:let enqueue: @Sendable (RadrootsBackgroundTransferRequest, UUID) async throws -> Void 6:symbol 11:RadrootsKit 14:swift.property 105:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV06activeE11IdentifiersShyAA0adE10IdentifierVGyYaYbKcvp 25:activeTransferIdentifiers 101:let activeTransferIdentifiers: @Sendable () async throws -> Set<RadrootsBackgroundTransferIdentifier> 6:symbol 11:RadrootsKit 14:swift.property 106:s:11RadrootsKit0A29AppleLocationServicesAdaptersV014requestCurrentD0yAA0aD7ReadingVAA0ahD7RequestVYaYbKcvp 22:requestCurrentLocation 110:let requestCurrentLocation: @Sendable (RadrootsCurrentLocationRequest) async throws -> RadrootsLocationReading 6:symbol 11:RadrootsKit 14:swift.property 37:s:11RadrootsKit0A9FileEntryV4nameSSvp 4:name 16:let name: String @@ -2586,12 +2612,12 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 14:swift.property 81:s:11RadrootsKit0A17ShareTransferItemV14preparedExportAA0a8PreparedG8DocumentVSgvp 14:preparedExport 59:var preparedExport: RadrootsPreparedExportDocument? { get } 6:symbol 11:RadrootsKit 14:swift.property 81:s:11RadrootsKit0A22BackgroundTaskSnapshotV17earliestBeginDate10Foundation0H0VSgvp 17:earliestBeginDate 28:let earliestBeginDate: Date? 6:symbol 11:RadrootsKit 14:swift.property 81:s:11RadrootsKit0A25BackgroundTransferRequestV14responsePolicyAA0acd8ResponseG0Vvp 14:responsePolicy 60:let responsePolicy: RadrootsBackgroundTransferResponsePolicy +6:symbol 11:RadrootsKit 14:swift.property 81:s:11RadrootsKit0A26BackgroundTransferSnapshotV11executionID10Foundation4UUIDVSgvp 11:executionID 22:let executionID: UUID? 6:symbol 11:RadrootsKit 14:swift.property 82:s:11RadrootsKit0A28AppleExternalActionsAdaptersV7openURLySb10Foundation0H0VYaYbcvp 7:openURL 42:let openURL: @Sendable (URL) async -> Bool 6:symbol 11:RadrootsKit 14:swift.property 82:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV012notificationE0AA0adE0OyYaYbcvp 18:notificationStatus 70:let notificationStatus: @Sendable () async -> RadrootsPermissionStatus 6:symbol 11:RadrootsKit 14:swift.property 83:s:11RadrootsKit0A22DocumentScannerSupportV20supportedOutputKindsSayAA0acdG4KindOGvp 20:supportedOutputKinds 61:let supportedOutputKinds: [RadrootsDocumentScannerOutputKind] 6:symbol 11:RadrootsKit 14:swift.property 84:s:11RadrootsKit0A29AppleMobileStoreConfigurationV14ownerDirectory10Foundation3URLVvp 14:ownerDirectory 23:let ownerDirectory: URL 6:symbol 11:RadrootsKit 14:swift.property 84:s:11RadrootsKit0A31AppleBackgroundTaskRegistrationV10identifierAA0adE10IdentifierVvp 10:identifier 48:let identifier: RadrootsBackgroundTaskIdentifier -6:symbol 11:RadrootsKit 14:swift.property 84:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV7enqueueyyAA0adE7RequestVYaYbKcvp 7:enqueue 79:let enqueue: @Sendable (RadrootsBackgroundTransferRequest) async throws -> Void 6:symbol 11:RadrootsKit 14:swift.property 84:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV3now10Foundation4DateVyYbcvp 3:now 29:let now: @Sendable () -> Date 6:symbol 11:RadrootsKit 14:swift.property 85:s:11RadrootsKit0A28IdentityCustodyConfigurationV12secretPolicyAA0a12SecretAccessG0Vvp 12:secretPolicy 44:let secretPolicy: RadrootsSecretAccessPolicy 6:symbol 11:RadrootsKit 14:swift.property 85:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV06handleD6EventsyySS_yyYbctYaYbcvp 22:handleBackgroundEvents 92:let handleBackgroundEvents: @Sendable (String, @escaping @Sendable () -> Void) async -> Void @@ -2600,6 +2626,7 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 14:swift.property 86:s:11RadrootsKit0A28LocationServicesAvailabilityV32canRequestWhenInUseAuthorizationSbvp 32:canRequestWhenInUseAuthorization 50:var canRequestWhenInUseAuthorization: Bool { get } 6:symbol 11:RadrootsKit 14:swift.property 87:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV6cancelyyAA0adE10IdentifierVYaYbKcvp 6:cancel 81:let cancel: @Sendable (RadrootsBackgroundTransferIdentifier) async throws -> Void 6:symbol 11:RadrootsKit 14:swift.property 88:s:11RadrootsKit0A36AppleBackgroundTaskSchedulerAdaptersV6submityyAA0adE7RequestVYaYbKcvp 6:submit 74:let submit: @Sendable (RadrootsBackgroundTaskRequest) async throws -> Void +6:symbol 11:RadrootsKit 14:swift.property 89:s:11RadrootsKit0A26BackgroundTransferSnapshotV11uploadLeaseAA0A19StagedBlobReferenceVSgvp 11:uploadLease 45:let uploadLease: RadrootsStagedBlobReference? 6:symbol 11:RadrootsKit 14:swift.property 90:s:11RadrootsKit0A25AppleUserPresenceAdaptersV6verifyyAA0adE6ResultVAA0adE7RequestVYaYbKcvp 6:verify 94:let verify: @Sendable (RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult 6:symbol 11:RadrootsKit 14:swift.property 90:s:11RadrootsKit0A26BackgroundTransferSnapshotV18downloadedArtifactAA0ac10DownloadedG0VSgvp 18:downloadedArtifact 61:let downloadedArtifact: RadrootsBackgroundDownloadedArtifact? 6:symbol 11:RadrootsKit 14:swift.property 90:s:11RadrootsKit0A28AppleExternalActionsAdaptersV14appSettingsURL10Foundation0I0VSgyYaYbcvp 14:appSettingsURL 46:let appSettingsURL: @Sendable () async -> URL? @@ -2992,13 +3019,16 @@ radroots.apple-kit.public-api.v1 6:symbol 18:RadrootsKitTesting 12:swift.method 106:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC8snapshot3for0aB00aF8SnapshotVAF0aF4KindO_tYaKF 14:snapshot(for:) 90:func snapshot(for kind: RadrootsPermissionKind) async throws -> RadrootsPermissionSnapshot 6:symbol 18:RadrootsKitTesting 12:swift.method 107:s:18RadrootsKitTesting0A22FakeBackgroundTransferC8snapshot3for0aB00aeF8SnapshotVSgAF0aeF10IdentifierV_tYaKF 14:snapshot(for:) 119:func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? 6:symbol 18:RadrootsKitTesting 12:swift.method 110:s:18RadrootsKitTesting0A15FakeMediaPickerC16setImportOutcomeyys6ResultOy0aB00aehJ0VAG0A18CaptureIntakeErrorOGF 20:setImportOutcome(_:) 95:func setImportOutcome(_ outcome: Result<RadrootsMediaImportResult, RadrootsCaptureIntakeError>) +6:symbol 18:RadrootsKitTesting 12:swift.method 110:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC17admissionIsActive3forSb0aB00afG10IdentifierV_tYaKF 23:admissionIsActive(for:) 97:func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool 6:symbol 18:RadrootsKitTesting 12:swift.method 112:s:18RadrootsKitTesting0A20FakeLocationServicesC010setCurrentE7Outcomeyys6ResultOy0aB00aE7ReadingVAG0aeF5ErrorOGF 29:setCurrentLocationOutcome(_:) 105:func setCurrentLocationOutcome(_ outcome: Result<RadrootsLocationReading, RadrootsLocationServicesError>) 6:symbol 18:RadrootsKitTesting 12:swift.method 113:s:18RadrootsKitTesting0A29InMemoryIdentityMetadataStoreC14replaceRawData_3fory10Foundation0K0VSg_0aB00afG4SlotOtF 22:replaceRawData(_:for:) 74:func replaceRawData(_ data: Data?, for slot: RadrootsIdentityMetadataSlot) 6:symbol 18:RadrootsKitTesting 12:swift.method 118:s:18RadrootsKitTesting0A19FakeDocumentScannerC14setScanOutcomeyys6ResultOy0aB00a7ScannedE0VAG0A18CaptureIntakeErrorOGF 18:setScanOutcome(_:) 91:func setScanOutcome(_ outcome: Result<RadrootsScannedDocument, RadrootsCaptureIntakeError>) 6:symbol 18:RadrootsKitTesting 12:swift.method 120:s:18RadrootsKitTesting0A22FakeBackgroundTransferC6settle_12verificationy0aB00aeF10IdentifierV_AF0aeF12VerificationOtYaKF 23:settle(_:verification:) 130:func settle(_ identifier: RadrootsBackgroundTransferIdentifier, verification: RadrootsBackgroundTransferVerification) async throws 6:symbol 18:RadrootsKitTesting 12:swift.method 123:s:18RadrootsKitTesting0A19InMemorySecureStoreC3put_3for6policyy10Foundation4DataV_0aB00afG3KeyVAJ0A18SecretAccessPolicyVtKF 18:put(_:for:policy:) 99:func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws +6:symbol 18:RadrootsKitTesting 12:swift.method 123:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC23compareExchangeSnapshot8expected7desiredSb0aB00afgK0VSg_AItYaKF 42:compareExchangeSnapshot(expected:desired:) 141:func compareExchangeSnapshot(expected: RadrootsBackgroundTransferSnapshot?, desired: RadrootsBackgroundTransferSnapshot) async throws -> Bool 6:symbol 18:RadrootsKitTesting 12:swift.method 124:s:18RadrootsKitTesting0A22FakeBackgroundTransferC015handleEventsForE10URLSession10identifier17completionHandlerySS_yyYbctYaF 66:handleEventsForBackgroundURLSession(identifier:completionHandler:) 117:func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async 6:symbol 18:RadrootsKitTesting 12:swift.method 128:s:18RadrootsKitTesting0A32InMemoryPermissionStatusProviderC03setG0_3for10observedAty0aB00afG0O_AG0aF4KindO10Foundation4DateVSgtF 28:setStatus(_:for:observedAt:) 109:func setStatus(_ status: RadrootsPermissionStatus, for kind: RadrootsPermissionKind, observedAt: Date? = nil) +6:symbol 18:RadrootsKitTesting 12:swift.method 137:s:18RadrootsKitTesting0A31InMemoryBackgroundTransferStoreC13withAdmission3for9operation0aB00afG6HandleVAG0afG10IdentifierV_AIyYaYbKctYaKF 29:withAdmission(for:operation:) 205:func withAdmission(for identifier: RadrootsBackgroundTransferIdentifier, operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle) async throws -> RadrootsBackgroundTransferHandle 6:symbol 18:RadrootsKitTesting 12:swift.method 54:s:18RadrootsKitTesting0A18RecordingTelemetryC5resetyyF 7:reset() 12:func reset() 6:symbol 18:RadrootsKitTesting 12:swift.method 67:s:18RadrootsKitTesting0A16FakeUserPresenceC9setStatusyy0aB00aefH0VF 13:setStatus(_:) 52:func setStatus(_ status: RadrootsUserPresenceStatus) 6:symbol 18:RadrootsKitTesting 12:swift.method 68:s:18RadrootsKitTesting0A15FakeMediaPickerC10setSupportyy0aB00aefH0VF 14:setSupport(_:) 54:func setSupport(_ support: RadrootsMediaPickerSupport)