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 bd8242d04357adb1d37bf519458a866caf92cd3d
parent af6047761cd445e8861e11b7df15b1fc52c1fbbf
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 19:19:16 +0000

feat(transfer): harden verified background artifacts

Diffstat:
MSources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift | 1659+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
MSources/RadrootsKit/RadrootsBackgroundTransfer.swift | 1696+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
ASources/RadrootsKit/RadrootsVerifiedArtifactAccess.swift | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift | 314++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
MTests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift | 1110+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
MTests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift | 606++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
ATests/RadrootsKitTests/RadrootsVerifiedArtifactAccessTests.swift | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 3608 insertions(+), 2093 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift @@ -1,714 +1,1129 @@ 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("background transfer is unavailable on this platform") }, - cancel: { _ in throw RadrootsBackgroundTransferError.unavailable("background transfer is unavailable on this platform") }, - activeTransferIdentifiers: { - throw RadrootsBackgroundTransferError.unavailable("background transfer is unavailable on this platform") - }, handleBackgroundEvents: { _, completionHandler in completionHandler() } - ) + 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( + "background transfer is unavailable on this platform") + }, + cancel: { _ in + throw RadrootsBackgroundTransferError.unavailable( + "background transfer is unavailable on this platform") + }, + activeTransferIdentifiers: { + throw RadrootsBackgroundTransferError.unavailable( + "background transfer is unavailable on this platform") + }, 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 + ) + return Self( + now: now, enqueue: { request in try await session.enqueue(request) }, + cancel: { identifier in await session.cancel(identifier) }, + activeTransferIdentifiers: { await session.activeTransferIdentifiers() }, + handleBackgroundEvents: { identifier, completionHandler in + await session.handleBackgroundEvents( + identifier: identifier, completionHandler: completionHandler) + } + ) + #else + return .unavailable + #endif + } - public static func live( - sessionIdentifier: String, store: any RadrootsBackgroundTransferStore, fileResolver: any RadrootsBackgroundTransferFileResolver, - downloadStagingRoot: URL, now: @escaping @Sendable () -> Date = Date.init + #if os(iOS) && targetEnvironment(simulator) + static func simulatorForegroundIntegration( + 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 - ) - return Self( - now: now, enqueue: { request in try await session.enqueue(request) }, - cancel: { identifier in await session.cancel(identifier) }, - activeTransferIdentifiers: { await session.activeTransferIdentifiers() }, - handleBackgroundEvents: { identifier, completionHandler in - await session.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler) - } - ) - #else - return .unavailable - #endif + let normalizedSessionIdentifier = + try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier) + let session = RadrootsAppleBackgroundURLSession( + identifier: normalizedSessionIdentifier, store: store, fileResolver: fileResolver, + downloadStagingRoot: downloadStagingRoot, now: now, usesForegroundSession: true + ) + return Self( + now: now, enqueue: { request in try await session.enqueue(request) }, + cancel: { identifier in await session.cancel(identifier) }, + activeTransferIdentifiers: { await session.activeTransferIdentifiers() }, + handleBackgroundEvents: { identifier, completionHandler in + await session.handleBackgroundEvents( + identifier: identifier, completionHandler: completionHandler) + } + ) } + #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 - ) + 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 store.loadSnapshots().contains(where: { $0.identifier == request.identifier }) + == false + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer identifier already exists") } + return try await schedule(request) + } - public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle { - guard try await store.loadSnapshots().contains(where: { $0.identifier == request.identifier }) == false else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier already exists") - } - try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request, state: .queued, updatedAt: adapters.now())) - do { try await adapters.enqueue(request) } catch { - do { - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot( - request: request, state: .failed, errorMessage: "background_transfer_enqueue_failed", updatedAt: adapters.now() - ) - ) - } catch { - throw RadrootsBackgroundTransferError.persistenceFailure("background transfer enqueue failure could not be persisted") - } - throw RadrootsBackgroundTransferError.transferFailure("background transfer enqueue failed") - } - let current = try await store.loadSnapshots().first { $0.identifier == request.identifier } - if current?.state == .queued { - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot(request: request, state: .running, updatedAt: adapters.now()) - ) - } - return RadrootsBackgroundTransferHandle(request: request) + public func retry(_ request: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle + { + guard + let existing = try await store.loadSnapshots().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("background transfer is not retryable") + } + do { + guard try await !adapters.activeTransferIdentifiers().contains(request.identifier) else { + throw RadrootsBackgroundTransferError.invalidRequest("background transfer is still active") + } + } catch let error as RadrootsBackgroundTransferError { + throw error + } catch { + throw RadrootsBackgroundTransferError.transferFailure("background transfer recovery failed") } + return try await schedule(request) + } - public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { - do { try await adapters.cancel(identifier) } catch { - throw RadrootsBackgroundTransferError.transferFailure("background transfer cancellation failed") - } - if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) { - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .cancelled, progress: existing.progress, updatedAt: adapters.now() - ) - ) - } + private func schedule(_ request: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle + { + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: request, state: .queued, updatedAt: adapters.now())) + do { try await adapters.enqueue(request) } catch { + do { + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: request, state: .failed, errorMessage: "background_transfer_enqueue_failed", + updatedAt: adapters.now() + ) + ) + } catch { + throw RadrootsBackgroundTransferError.persistenceFailure( + "background transfer enqueue failure could not be persisted") + } + throw RadrootsBackgroundTransferError.transferFailure("background transfer enqueue failed") + } + let current = try await store.loadSnapshots().first { $0.identifier == request.identifier } + if current?.state == .queued { + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: request, state: .running, updatedAt: adapters.now()) + ) + } + return RadrootsBackgroundTransferHandle(request: request) + } + + public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) + { + guard + existing.state == .queued || existing.state == .running || existing.state == .interrupted + else { return } + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .cancelled, progress: existing.progress, + possibleRemoteOrphan: existing.request.isUpload && existing.state == .running, + updatedAt: adapters.now() + ) + ) } + do { try await adapters.cancel(identifier) } catch { + throw RadrootsBackgroundTransferError.transferFailure( + "background transfer cancellation failed") + } + } - public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? { - try await snapshots().first { $0.identifier == identifier } + public func expire(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) + { + guard + existing.state == .queued || existing.state == .running || existing.state == .interrupted + else { return } + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .expired, progress: existing.progress, + errorMessage: "background_transfer_expired", + possibleRemoteOrphan: existing.request.isUpload && existing.state == .running, + updatedAt: adapters.now() + ) + ) } + do { try await adapters.cancel(identifier) } catch { + throw RadrootsBackgroundTransferError.transferFailure( + "background transfer expiration cancellation failed") + } + } - public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - let activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier> - do { activeIdentifiers = try await adapters.activeTransferIdentifiers() } catch { - throw RadrootsBackgroundTransferError.transferFailure("background transfer recovery failed") - } - let storedSnapshots = try await store.loadSnapshots() - var reconciled: [RadrootsBackgroundTransferSnapshot] = [] - for snapshot in storedSnapshots { - if activeIdentifiers.contains(snapshot.identifier), snapshot.state == .queued { - let runningSnapshot = try RadrootsBackgroundTransferSnapshot( - request: snapshot.request, state: .running, progress: snapshot.progress, errorMessage: snapshot.errorMessage, - updatedAt: adapters.now() - ) - try await store.saveSnapshot(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, - errorMessage: "background_transfer_interrupted", - possibleRemoteOrphan: snapshot.request.isUpload && snapshot.state == .running, updatedAt: adapters.now() - ) - try await store.saveSnapshot(interruptedSnapshot) - reconciled.append(interruptedSnapshot) - } else { - reconciled.append(snapshot) - } - } - return reconciled.sorted { left, right in left.identifier < right.identifier } + public func settle( + _ identifier: RadrootsBackgroundTransferIdentifier, + verification: RadrootsBackgroundTransferVerification + ) async throws { + guard + let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }), + existing.state == .awaitingVerification + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer is not awaiting verification") + } + switch verification { + case .accepted: + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .completed, progress: existing.progress, + response: existing.response, + downloadedArtifact: existing.downloadedArtifact, updatedAt: adapters.now() + ) + ) + case .rejected(let code): + guard code.range(of: "^background_transfer_[a-z0-9_]+$", options: .regularExpression) != nil + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer verification code is invalid") + } + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .failed, progress: existing.progress, + errorMessage: code, + possibleRemoteOrphan: existing.request.isUpload, updatedAt: adapters.now() + ) + ) } + } - public func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async { - await adapters.handleBackgroundEvents(identifier, completionHandler) + 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("background transfer recovery failed") + } + let storedSnapshots = try await store.loadSnapshots() + let storedIdentifiers = Set(storedSnapshots.map(\.identifier)) + for orphanedIdentifier in activeIdentifiers.subtracting(storedIdentifiers) { + do { try await adapters.cancel(orphanedIdentifier) } catch { + throw RadrootsBackgroundTransferError.transferFailure( + "background transfer orphan cancellation failed") + } + } + 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, + errorMessage: snapshot.errorMessage, + updatedAt: adapters.now() + ) + try await store.saveSnapshot(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, + errorMessage: "background_transfer_interrupted", + possibleRemoteOrphan: snapshot.request.isUpload && snapshot.state == .running, + updatedAt: adapters.now() + ) + try await store.saveSnapshot(interruptedSnapshot) + reconciled.append(interruptedSnapshot) + } else { + reconciled.append(snapshot) + } } + 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) + } } enum RadrootsStagedBackgroundDownloadResult: Sendable, Equatable { - case file(URL) - case failure + case file(URL) + case failure } struct RadrootsBackgroundHTTPResult: Sendable, Equatable { - let statusCode: Int? - let mediaType: String? - let body: Data? - let bodyExceeded: Bool + let statusCode: Int? + let mediaType: String? + let body: Data? + let bodyExceeded: Bool + let mediaTypeWasMalformed: Bool + + init( + statusCode: Int?, mediaType: String?, body: Data?, bodyExceeded: Bool, + mediaTypeWasMalformed: Bool = false + ) { + self.statusCode = statusCode + self.mediaType = mediaType + self.body = body + 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 + 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 - } + 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, errorMessage: existing.errorMessage, - response: existing.response, possibleRemoteOrphan: existing.possibleRemoteOrphan, updatedAt: now() - ) - ) - } + 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, + errorMessage: existing.errorMessage, + 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 httpResult.bodyExceeded { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail(existing: existing, code: "background_transfer_response_too_large", possibleRemoteOrphan: existing.request.isUpload) - return - } - if platformError != nil { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail( - existing: existing, code: "background_transfer_platform_failure", - possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 - ) - return - } - guard let statusCode = httpResult.statusCode else { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail( - existing: existing, code: "background_transfer_response_missing", - possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 - ) - return - } - guard (200 ... 299).contains(statusCode) else { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail(existing: existing, code: "background_transfer_http_status_\(statusCode)") - return - } - let response: RadrootsBackgroundTransferResponse - do { response = try Self.validatedResponse(for: existing.request, httpResult: httpResult) } catch let transferError - as RadrootsBackgroundTransferError - { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail(existing: existing, code: transferError.stableCode, possibleRemoteOrphan: existing.request.isUpload) - return - } catch { - Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) - await fail(existing: existing, code: "background_transfer_response_invalid", possibleRemoteOrphan: existing.request.isUpload) - return - } - switch existing.request.operation { - case let .download(destination): - await completeDownload( - existing: existing, destination: destination, stagedDownloadResult: stagedDownloadResult, response: response, - bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected - ) - case .upload: - await completeUpload( - existing: existing, response: response, bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected - ) - } + 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: "background_transfer_transfer_too_large", + possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 + ) + return + } + if httpResult.bodyExceeded { + Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) + await fail( + existing: existing, code: "background_transfer_response_too_large", + possibleRemoteOrphan: existing.request.isUpload) + return + } + if httpResult.mediaTypeWasMalformed { + Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) + await fail( + existing: existing, code: "background_transfer_response_media_type", + possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0) + return + } + if platformError != nil { + Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) + await fail( + existing: existing, code: "background_transfer_platform_failure", + possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 + ) + return + } + guard let statusCode = httpResult.statusCode else { + Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) + await fail( + existing: existing, code: "background_transfer_response_missing", + possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 + ) + return } + guard (200...299).contains(statusCode) else { + Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) + await fail( + existing: existing, + code: "background_transfer_http_status_\(statusCode)", + possibleRemoteOrphan: existing.request.isUpload && bytesTransferred > 0 + ) + return + } + let response: RadrootsBackgroundTransferResponse + do { + response = try Self.validatedResponse(for: existing.request, httpResult: httpResult) + } catch let transferError + as RadrootsBackgroundTransferError + { + Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) + await fail( + existing: existing, code: transferError.stableCode, + possibleRemoteOrphan: existing.request.isUpload) + return + } catch { + Self.removeStagedDownload(stagedDownloadResult, fileManager: fileManager) + await fail( + existing: existing, code: "background_transfer_response_invalid", + 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 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() - } + 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: .completed, progress: progress, response: response, updatedAt: now() - ) + 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: "background_transfer_download_staging_failure") + return + } + do { + guard case .file(let destinationReference) = destination else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer download destination is invalid") + } + 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( + "background_transfer_transfer_too_large") + } + 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: "background_transfer_destination_failure") } + } - private func completeDownload( - existing: RadrootsBackgroundTransferSnapshot, destination: RadrootsBackgroundTransferLocalFile, - stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?, response: RadrootsBackgroundTransferResponse, - bytesTransferred: Int64, totalBytesExpected: Int64? - ) async { - guard case let .file(stagedFileURL) = stagedDownloadResult else { - await fail(existing: existing, code: "background_transfer_download_staging_failure") - return - } - do { - let destinationURL = try fileResolver.resolve(destination) - 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 fileSize = try Self.fileSize(at: destinationURL, fileManager: fileManager) - let progress = - Self.progress( - bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected, fallback: existing.progress - ) - ?? existing.progress - try await store.saveSnapshot( - RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .completed, progress: progress, response: response, updatedAt: now() - ) - ) - } catch { - Self.removeStagedDownload(.file(stagedFileURL), fileManager: fileManager) - await fail(existing: existing, code: "background_transfer_destination_failure") - } - } + private func fail( + existing: RadrootsBackgroundTransferSnapshot, code: String, possibleRemoteOrphan: Bool = false + ) async { + try? await store.saveSnapshot( + try RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .failed, progress: existing.progress, errorMessage: code, + possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: now() + ) + ) + } - private func fail(existing: RadrootsBackgroundTransferSnapshot, code: String, possibleRemoteOrphan: Bool = false) async { - try? await store.saveSnapshot( - try RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .failed, progress: existing.progress, errorMessage: 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 func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? { - try await store.loadSnapshots().first { $0.identifier == identifier } + private static func validatedResponse( + for request: RadrootsBackgroundTransferRequest, httpResult: RadrootsBackgroundHTTPResult + ) throws + -> RadrootsBackgroundTransferResponse + { + guard let statusCode = httpResult.statusCode else { + throw RadrootsBackgroundTransferError.transferFailure("background_transfer_response_missing") + } + if request.responsePolicy == .discard { + return try RadrootsBackgroundTransferResponse( + statusCode: statusCode, mediaType: nil, body: nil) + } + guard let body = httpResult.body else { + throw RadrootsBackgroundTransferError.transferFailure("background_transfer_response_missing") + } + guard let mediaType = httpResult.mediaType, + request.responsePolicy.acceptedMediaTypes.contains(mediaType) + else { + throw RadrootsBackgroundTransferError.transferFailure( + "background_transfer_response_media_type") + } + guard body.count <= request.responsePolicy.maximumBodyBytes else { + throw RadrootsBackgroundTransferError.transferFailure( + "background_transfer_response_too_large") } + return try RadrootsBackgroundTransferResponse( + statusCode: statusCode, mediaType: mediaType, body: body) + } - private static func progress(bytesTransferred: Int64, totalBytesExpected: Int64?, fallback: RadrootsBackgroundTransferProgress) - -> RadrootsBackgroundTransferProgress? + 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) { - 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) + self.identifier = identifier + self.maximumTransferBytes = maximumTransferBytes + self.maximumResponseBodyBytes = maximumResponseBodyBytes + return } - - private static func fileSize(at url: URL, fileManager _: FileManager) throws -> Int64 { - let values = try url.resourceValues(forKeys: [.fileSizeKey]) - return Int64(values.fileSize ?? 0) + guard let legacyIdentifier = try? RadrootsBackgroundTransferIdentifier(taskDescription) else { + return nil } + identifier = legacyIdentifier + maximumTransferBytes = RadrootsBackgroundTransferRequest.defaultMaximumTransferBytes + maximumResponseBodyBytes = 65_536 + } - 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 - } + 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 static func validatedResponse(for request: RadrootsBackgroundTransferRequest, httpResult: RadrootsBackgroundHTTPResult) throws - -> RadrootsBackgroundTransferResponse - { - guard let statusCode = httpResult.statusCode else { - throw RadrootsBackgroundTransferError.transferFailure("background_transfer_response_missing") - } - if request.responsePolicy == .discard { - return try RadrootsBackgroundTransferResponse(statusCode: statusCode, mediaType: nil, body: nil) + 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 values = try sourceURL.resourceValues(forKeys: [ + .fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey, + ]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background upload source must be a regular file") } - guard let body = httpResult.body else { - throw RadrootsBackgroundTransferError.transferFailure("background_transfer_response_missing") + guard let fileSize = values.fileSize, fileSize >= 0, + UInt64(fileSize) <= request.maximumTransferBytes + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background upload source exceeds its byte limit") } - guard let mediaType = httpResult.mediaType, request.responsePolicy.acceptedMediaTypes.contains(mediaType) else { - throw RadrootsBackgroundTransferError.transferFailure("background_transfer_response_media_type") + if case .stagedBlob(let blob) = source, values.fileSize != blob.sizeBytes { + throw RadrootsBackgroundTransferError.invalidRequest( + "background upload source size does not match its handle") } - guard body.count <= request.responsePolicy.maximumBodyBytes else { - throw RadrootsBackgroundTransferError.transferFailure("background_transfer_response_too_large") + if let expectedDigest = request.expectedSourceSHA256, + try RadrootsAppleFileDigest.sha256(at: sourceURL) != expectedDigest + { + throw RadrootsBackgroundTransferError.invalidRequest( + "background upload source does not match its digest") } - return try RadrootsBackgroundTransferResponse(statusCode: statusCode, mediaType: mediaType, body: body) + task = session.uploadTask(with: urlRequest, fromFile: sourceURL) + } + task.taskDescription = RadrootsBackgroundURLTaskDescriptor(request: request).taskDescription + sessionDelegate?.registerResponseBodyLimit( + request.responsePolicy.maximumBodyBytes, taskIdentifier: task.taskIdentifier) + task.resume() } - 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) + func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async { + let tasks = await allTasks() + for task in tasks + where RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier + == identifier + { + task.cancel() + } } -} -#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 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 - ) { - self.identifier = identifier - self.fileResolver = fileResolver - self.downloadStagingRoot = downloadStagingRoot - fileManager = .default - coordinator = RadrootsAppleBackgroundTransferCoordinator( - sessionIdentifier: identifier, store: store, fileResolver: fileResolver, now: now - ) - } + 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 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 let .upload(source): - let sourceURL = try fileResolver.resolve(source) - let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey]) - guard values.isRegularFile == true, values.isSymbolicLink != true else { - throw RadrootsBackgroundTransferError.invalidRequest("background upload source must be a regular file") - } - if case let .stagedBlob(blob) = source, values.fileSize != blob.sizeBytes { - throw RadrootsBackgroundTransferError.invalidRequest("background upload source size does not match its handle") - } - if let expectedDigest = request.expectedSourceSHA256, try RadrootsAppleFileDigest.sha256(at: sourceURL) != expectedDigest { - throw RadrootsBackgroundTransferError.invalidRequest("background upload source does not match its digest") - } - task = session.uploadTask(with: urlRequest, fromFile: sourceURL) - } - task.taskDescription = request.identifier.rawValue - sessionDelegate?.registerResponseBodyLimit(request.responsePolicy.maximumBodyBytes, taskIdentifier: task.taskIdentifier) - task.resume() - } + func handleBackgroundEvents( + identifier: String, completionHandler: @escaping @Sendable () -> Void + ) async { + _ = backgroundSession() + await coordinator.handleBackgroundEvents( + identifier: identifier, completionHandler: completionHandler) + } - func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async { - let tasks = await allTasks() - for task in tasks where task.taskDescription == identifier.rawValue { - task.cancel() - } - } + private func allTasks() async -> [URLSessionTask] { + await withCheckedContinuation { continuation in + backgroundSession().getAllTasks { tasks in continuation.resume(returning: tasks) } + } + } - func activeTransferIdentifiers() async -> Set<RadrootsBackgroundTransferIdentifier> { - let tasks = await allTasks() - let identifiers = tasks.compactMap { task -> RadrootsBackgroundTransferIdentifier? in - guard let taskDescription = task.taskDescription else { return nil } - return try? RadrootsBackgroundTransferIdentifier(taskDescription) - } - return Set(identifiers) - } + 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 + } - func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) async { - _ = backgroundSession() - await coordinator.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler) - } + 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 func allTasks() async -> [URLSessionTask] { - await withCheckedContinuation { continuation in - backgroundSession().getAllTasks { tasks in continuation.resume(returning: tasks) } - } - } + 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> - private func backgroundSession() -> URLSession { - if let session { - return session - } - removeOrphanedDownloadStagingFiles() - let 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 - } + init( + coordinator: RadrootsAppleBackgroundTransferCoordinator, downloadStagingRoot: URL, + fileManager: FileManager + ) { + self.coordinator = coordinator + self.downloadStagingRoot = downloadStagingRoot + self.fileManager = fileManager + stagedDownloadResultsByTaskIdentifier = [:] + responseBodyLimitsByTaskIdentifier = [:] + responseBodiesByTaskIdentifier = [:] + exceededResponseBodyTaskIdentifiers = [] + } - 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) - } + 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( + "background_transfer_transfer_too_large") } + 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) } - 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, 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) + ) + } + } - func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - guard let identifier = transferIdentifier(from: downloadTask) else { return } - let result: RadrootsStagedBackgroundDownloadResult - do { - 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, dataTask: URLSessionDataTask, didReceive data: Data) { + let shouldCancel = appendResponseBody(data, task: dataTask) + if shouldCancel { + dataTask.cancel() + } + } - func urlSession( - _: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, - totalBytesExpectedToWrite: Int64 - ) { - guard let identifier = transferIdentifier(from: downloadTask) else { return } - Task { - await coordinator.updateProgress( - identifier: identifier, bytesTransferred: totalBytesWritten, - totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToWrite) - ) - } - } + 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, dataTask: URLSessionDataTask, didReceive data: Data) { - let shouldCancel = appendResponseBody(data, taskIdentifier: dataTask.taskIdentifier) - if shouldCancel { - dataTask.cancel() - } + 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) } + return + } + Task { + await coordinator.complete( + identifier: identifier, platformError: error, stagedDownloadResult: stagedDownloadResult, + httpResult: httpResult, + bytesTransferred: bytesTransferred, totalBytesExpected: expected + ) + } + } - func urlSession( - _: URLSession, task: URLSessionTask, didSendBodyData _: Int64, totalBytesSent: Int64, - totalBytesExpectedToSend: Int64 - ) { - guard let identifier = transferIdentifier(from: task) else { return } - Task { - await coordinator.updateProgress( - identifier: identifier, bytesTransferred: totalBytesSent, - totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend) - ) - } - } + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + Task { + await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier) + } + } - 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, platformError: error, stagedDownloadResult: stagedDownloadResult, httpResult: httpResult, - bytesTransferred: bytesTransferred, totalBytesExpected: expected - ) - } - } + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest _: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } - func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { - Task { await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier) } - } + private func recordDownloadResult( + _ result: RadrootsStagedBackgroundDownloadResult, taskIdentifier: Int + ) { + lock.lock() + defer { lock.unlock() } + stagedDownloadResultsByTaskIdentifier[taskIdentifier] = result + } - 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) + } - 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) + } - 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 appendResponseBody(_ data: Data, taskIdentifier: Int) -> Bool { - lock.lock() - defer { lock.unlock() } - guard !exceededResponseBodyTaskIdentifiers.contains(taskIdentifier) else { return false } - let limit = responseBodyLimitsByTaskIdentifier[taskIdentifier] ?? 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() - 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 mediaType = response.value(forHTTPHeaderField: "Content-Type").flatMap { - try? RadrootsBackgroundTransferValidation.normalizedMediaType($0) - } - return RadrootsBackgroundHTTPResult(statusCode: response.statusCode, mediaType: mediaType, body: body, bodyExceeded: exceeded) - } + 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) + } + return RadrootsBackgroundHTTPResult( + statusCode: response.statusCode, mediaType: mediaType, body: body, bodyExceeded: exceeded, + mediaTypeWasMalformed: rawMediaType != nil && mediaType == nil) + } - private func transferIdentifier(from task: URLSessionTask) -> RadrootsBackgroundTransferIdentifier? { - guard let taskDescription = task.taskDescription else { return nil } - return try? RadrootsBackgroundTransferIdentifier(taskDescription) - } + private func transferIdentifier(from task: URLSessionTask) + -> RadrootsBackgroundTransferIdentifier? + { + RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier + } - private static func expectedByteCount(_ value: Int64) -> Int64? { - value >= 0 ? value : nil - } + 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 -private extension RadrootsBackgroundTransferRequest { - var isUpload: Bool { - if case .upload = operation { - return true - } - return false +extension RadrootsBackgroundTransferRequest { + fileprivate var isUpload: Bool { + if case .upload = operation { + return true } + return false + } } -private extension RadrootsBackgroundTransferError { - var stableCode: String { - let value: String = switch self { - case let .invalidRequest(message), let .unavailable(message), let .transferFailure(message), let .persistenceFailure(message): - message - } - guard value.range(of: "^background_transfer_[a-z0-9_]+$", options: .regularExpression) != nil else { - return "background_transfer_response_invalid" - } - return value +extension RadrootsBackgroundTransferError { + fileprivate var stableCode: String { + let value: String = + switch self { + case .invalidRequest(let message), .unavailable(let message), .transferFailure(let message), + .persistenceFailure(let message): + message + } + guard value.range(of: "^background_transfer_[a-z0-9_]+$", options: .regularExpression) != nil + else { + return "background_transfer_response_invalid" } + return value + } } diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift @@ -1,789 +1,1045 @@ import Foundation public enum RadrootsBackgroundTransferError: Error, Equatable, Sendable { - case invalidRequest(String) - case unavailable(String) - case transferFailure(String) - case persistenceFailure(String) + case invalidRequest(String) + case unavailable(String) + case transferFailure(String) + case persistenceFailure(String) } extension RadrootsBackgroundTransferError: LocalizedError { - public var errorDescription: String? { - switch self { - case let .invalidRequest(message): message - case let .unavailable(message): message - case let .transferFailure(message): message - case let .persistenceFailure(message): message - } - } + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): message + case .unavailable(let message): message + case .transferFailure(let message): message + case .persistenceFailure(let message): message + } + } } -public struct RadrootsBackgroundTransferIdentifier: Sendable, Equatable, Hashable, Comparable, Codable { - public let rawValue: String +public struct RadrootsBackgroundTransferIdentifier: Sendable, Equatable, Hashable, Comparable, + 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" +public enum RadrootsBackgroundTransferMethod: String, Sendable, Equatable, Hashable, Codable, + 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 completed - case failed - case cancelled - case interrupted +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 } -public enum RadrootsBackgroundTransferNetworkPolicy: String, Sendable, Equatable, Hashable, Codable { - case publicHTTPS - case simulatorLoopbackHTTP +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("background transfer response policy is invalid") - } - let normalized = try acceptedMediaTypes.map { try RadrootsBackgroundTransferValidation.normalizedMediaType($0) } - guard Set(normalized).count == normalized.count, (maximumBodyBytes == 0) == normalized.isEmpty else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer response policy is invalid") - } - 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 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( + "background transfer response policy is invalid") + } + let normalized = try acceptedMediaTypes.map { + try RadrootsBackgroundTransferValidation.normalizedMediaType($0) + } + guard Set(normalized).count == normalized.count, (maximumBodyBytes == 0) == normalized.isEmpty + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer response policy is invalid") + } + 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 body: Data? - - public init(statusCode: Int, mediaType: String?, body: Data?) throws { - guard (100 ... 599).contains(statusCode), body?.count ?? 0 <= 65536 else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer response is invalid") - } - self.statusCode = statusCode - self.mediaType = try mediaType.map { try RadrootsBackgroundTransferValidation.normalizedMediaType($0) } - self.body = body - } - - private enum CodingKeys: String, CodingKey { - case statusCode - case mediaType - 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), - 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(body, forKey: .body) - } + public let statusCode: Int + public let mediaType: String? + public let body: Data? + + public init(statusCode: Int, mediaType: String?, body: Data?) throws { + guard (100...599).contains(statusCode), body?.count ?? 0 <= 65536 else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer response is invalid") + } + self.statusCode = statusCode + self.mediaType = try mediaType.map { + try RadrootsBackgroundTransferValidation.normalizedMediaType($0) + } + self.body = body + } + + private enum CodingKeys: String, CodingKey { + case statusCode + case mediaType + 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), + 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(body, forKey: .body) + } } -public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, Codable, CustomDebugStringConvertible { - 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 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 - ) throws { - try RadrootsBackgroundTransferValidation.validate( - remoteURL: remoteURL, method: method, operation: operation, headers: headers, metadata: metadata, networkPolicy: networkPolicy, - responsePolicy: responsePolicy, expectedSourceSHA256: expectedSourceSHA256 - ) - 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 - } - - public var debugDescription: String { - "RadrootsBackgroundTransferRequest(identifier: \(identifier.rawValue), method: \(method.rawValue), operation: \(operation.redactedLabel), headers: <redacted>, metadataKeys: \(metadata.keys.sorted()), 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 - ) - } - - private enum CodingKeys: String, CodingKey { - case identifier - case remoteURL - case method - case operation - case metadata - case networkPolicy - case responsePolicy - case expectedSourceSHA256 - } - - 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) - ) - } - - 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) - } +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 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 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( + "background transfer downloaded artifact is invalid") + } + 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 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("background transfer handle identifier does not match its request") - } - self.init(request: request) - } +public enum RadrootsBackgroundTransferVerification: Sendable, Equatable { + case accepted + case rejected(code: String) +} - 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 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( + "background transfer handle identifier does not match its request") + } + 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("background transfer bytes transferred cannot be negative") - } - if let totalBytesExpected { - guard totalBytesExpected >= 0 else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer expected byte count cannot be negative") - } - guard totalBytesExpected >= bytesTransferred else { - throw RadrootsBackgroundTransferError.invalidRequest( - "background transfer expected byte count cannot be less than transferred bytes" - ) - } - } - 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 let bytesTransferred: Int64 + public let totalBytesExpected: Int64? + + public init(bytesTransferred: Int64, totalBytesExpected: Int64? = nil) throws { + guard bytesTransferred >= 0 else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer bytes transferred cannot be negative") + } + if let totalBytesExpected { + guard totalBytesExpected >= 0 else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer expected byte count cannot be negative") + } + guard totalBytesExpected >= bytesTransferred else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer expected byte count cannot be less than transferred bytes" ) - } - - 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) - } + } + } + 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 errorMessage: String? - public let response: RadrootsBackgroundTransferResponse? - public let possibleRemoteOrphan: Bool - public let updatedAt: Date - - public init( - request: RadrootsBackgroundTransferRequest, state: RadrootsBackgroundTransferState = .queued, - progress: RadrootsBackgroundTransferProgress = .zero, errorMessage: String? = nil, - response: RadrootsBackgroundTransferResponse? = nil, possibleRemoteOrphan: Bool = false, updatedAt: Date = Date() - ) throws { - guard updatedAt.timeIntervalSinceReferenceDate.isFinite else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer updated date must be finite") - } - guard response == nil || state == .completed else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer response requires completed state") - } - if let response { - try RadrootsBackgroundTransferValidation.validate(response: response, policy: request.responsePolicy) - } - if possibleRemoteOrphan { - guard case .upload = request.operation, state == .failed || state == .interrupted else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer orphan marker is invalid") - } - } - identifier = request.identifier - self.request = request - self.state = state - self.progress = progress - self.errorMessage = try RadrootsBackgroundTransferValidation.normalizedOptionalMessage(errorMessage) - self.response = response - 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), possibleRemoteOrphan: \(possibleRemoteOrphan))" - } - - func redactedForPersistence() throws -> Self { - try Self( - request: request.redactedForPersistence(), state: state, progress: progress, errorMessage: errorMessage, response: response, - possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: updatedAt - ) - } - - private enum CodingKeys: String, CodingKey { - case request - case state - case progress - case errorMessage - case response - 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), - errorMessage: values.decodeIfPresent(String.self, forKey: .errorMessage), - response: values.decodeIfPresent(RadrootsBackgroundTransferResponse.self, forKey: .response), - 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(errorMessage, forKey: .errorMessage) - try values.encodeIfPresent(response, forKey: .response) - try values.encode(possibleRemoteOrphan, forKey: .possibleRemoteOrphan) - try values.encode(updatedAt, forKey: .updatedAt) - } +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 errorMessage: String? + 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, errorMessage: String? = nil, + response: RadrootsBackgroundTransferResponse? = nil, + downloadedArtifact: RadrootsBackgroundDownloadedArtifact? = nil, + possibleRemoteOrphan: Bool = false, updatedAt: Date = Date() + ) throws { + guard updatedAt.timeIntervalSinceReferenceDate.isFinite else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer updated date must be finite") + } + guard response == nil || state == .awaitingVerification || state == .completed else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer response requires verification state") + } + 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( + "background transfer orphan marker is invalid") + } + } + if let downloadedArtifact { + guard case .download(let destination) = request.operation, + destination == .file(downloadedArtifact.file), + state == .awaitingVerification || state == .completed + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer downloaded artifact state is invalid") + } + } + identifier = request.identifier + self.request = request + self.state = state + self.progress = progress + self.errorMessage = try RadrootsBackgroundTransferValidation.normalizedOptionalMessage( + errorMessage) + 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, + errorMessage: errorMessage, response: response, + downloadedArtifact: downloadedArtifact, possibleRemoteOrphan: possibleRemoteOrphan, + updatedAt: updatedAt + ) + } + + private enum CodingKeys: String, CodingKey { + case request + case state + case progress + case errorMessage + 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), + errorMessage: values.decodeIfPresent(String.self, forKey: .errorMessage), + 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(errorMessage, forKey: .errorMessage) + try values.encodeIfPresent(response, forKey: .response) + try values.encodeIfPresent(downloadedArtifact, forKey: .downloadedArtifact) + try values.encode(possibleRemoteOrphan, forKey: .possibleRemoteOrphan) + try values.encode(updatedAt, forKey: .updatedAt) + } } 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 + 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 cancel(_ identifier: RadrootsBackgroundTransferIdentifier) 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 } - -public struct RadrootsAppleBackgroundTransferFileResolver: RadrootsBackgroundTransferFileResolver, Sendable { - private let roots: RadrootsAppleFileRoots - - public init(roots: RadrootsAppleFileRoots) { - self.roots = roots - } +public protocol RadrootsBackgroundTransferFileResolver: Sendable { + func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL +} - 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("background transfer local file resolved outside its root") - } - return candidate - } +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( + "background transfer local file resolved outside its root") + } + return candidate + } } public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore { - private struct Envelope: Codable { - let schemaVersion: Int - let snapshots: [RadrootsBackgroundTransferSnapshot] - - init(snapshots: [RadrootsBackgroundTransferSnapshot]) { - schemaVersion = 1 - self.snapshots = snapshots + 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 sourceURL: URL + let isLegacy: Bool + if fileManager.fileExists(atPath: url.path) { + sourceURL = url + isLegacy = false + } else if fileManager.fileExists(atPath: legacyURL.path) { + sourceURL = legacyURL + isLegacy = true + } else { + return [] + } + let data = try Data(contentsOf: sourceURL) + let decoded: [RadrootsBackgroundTransferSnapshot] + let usedLegacyEncoding: Bool + if let envelope = try? decoder.decode(Envelope.self, from: data) { + guard envelope.schemaVersion == 1 else { + throw RadrootsBackgroundTransferError.persistenceFailure( + "background transfer persistence version is unsupported") } - } - - 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 sourceURL: URL - let isLegacy: Bool - if fileManager.fileExists(atPath: url.path) { - sourceURL = url - isLegacy = false - } else if fileManager.fileExists(atPath: legacyURL.path) { - sourceURL = legacyURL - isLegacy = true - } else { - return [] - } - let data = try Data(contentsOf: sourceURL) - let decoded: [RadrootsBackgroundTransferSnapshot] - let usedLegacyEncoding: Bool - if let envelope = try? decoder.decode(Envelope.self, from: data) { - guard envelope.schemaVersion == 1 else { - throw RadrootsBackgroundTransferError.persistenceFailure("background transfer persistence version is unsupported") - } - 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("background transfer persistence could not be read") } - } - - 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("background transfer persistence could not be written") - } - } - - 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("background transfer persistence could not be written") - } - } - - 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("background transfer persistence could not be removed") - } - } - - 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)) - 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("background transfer protected data is unavailable") - } - } + 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( + "background transfer persistence could not be read") + } + } + + 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( + "background transfer persistence could not be written") + } + } + + 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( + "background transfer persistence could not be written") + } + } + + 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( + "background transfer persistence could not be removed") + } + } + + 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)) + 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( + "background transfer protected data is unavailable") + } + } } public struct RadrootsUnavailableBackgroundTransfer: RadrootsBackgroundTransfer, Sendable { - private let reason: String - - public init(reason: String = "background transfer is unavailable on this platform") { - let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) - self.reason = trimmedReason.isEmpty ? "background transfer is unavailable on this platform" : trimmedReason - } - - public func enqueue(_: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle { - throw RadrootsBackgroundTransferError.unavailable(reason) - } - - public func cancel(_: RadrootsBackgroundTransferIdentifier) async throws { - throw RadrootsBackgroundTransferError.unavailable(reason) - } - - public func snapshot(for _: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? { - throw RadrootsBackgroundTransferError.unavailable(reason) - } - - public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - throw RadrootsBackgroundTransferError.unavailable(reason) - } - - public func handleEventsForBackgroundURLSession(identifier _: String, completionHandler: @escaping @Sendable () -> Void) async { - completionHandler() - } + private let reason: String + + public init(reason: String = "background transfer is unavailable on this platform") { + let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + self.reason = + trimmedReason.isEmpty ? "background transfer is unavailable on this platform" : trimmedReason + } + + public func enqueue(_: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle + { + throw RadrootsBackgroundTransferError.unavailable(reason) + } + + public func retry(_: RadrootsBackgroundTransferRequest) async throws + -> RadrootsBackgroundTransferHandle + { + throw RadrootsBackgroundTransferError.unavailable(reason) + } + + public func cancel(_: RadrootsBackgroundTransferIdentifier) async throws { + throw RadrootsBackgroundTransferError.unavailable(reason) + } + + public func expire(_: RadrootsBackgroundTransferIdentifier) async throws { + throw RadrootsBackgroundTransferError.unavailable(reason) + } + + public func settle( + _: RadrootsBackgroundTransferIdentifier, + verification _: RadrootsBackgroundTransferVerification + ) async throws { + throw RadrootsBackgroundTransferError.unavailable(reason) + } + + public func snapshot(for _: RadrootsBackgroundTransferIdentifier) async throws + -> RadrootsBackgroundTransferSnapshot? + { + throw RadrootsBackgroundTransferError.unavailable(reason) + } + + public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + throw RadrootsBackgroundTransferError.unavailable(reason) + } + + 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("background transfer identifier must not be empty") - } - guard trimmed.count <= 128 else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier is too long") - } - guard trimmed.range(of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$", options: .regularExpression) != nil else { - throw RadrootsBackgroundTransferError.invalidRequest( - "background transfer identifier must use lowercase safe identifier characters" - ) - } - guard !trimmed.contains("..") else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier cannot contain empty path components") - } - return trimmed - } - - public static func validate( - remoteURL: URL, method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation, headers: [String: String], - metadata: [String: String], networkPolicy: RadrootsBackgroundTransferNetworkPolicy, - responsePolicy: RadrootsBackgroundTransferResponsePolicy, expectedSourceSHA256: String? - ) throws { - try validate(remoteURL: remoteURL, networkPolicy: networkPolicy) - try validate(method: method, operation: operation) - try validate(headers: headers) - try validate(metadata: metadata) - if case .download = operation, responsePolicy != .discard { - throw RadrootsBackgroundTransferError.invalidRequest("background downloads must discard the separate response body") - } - if let expectedSourceSHA256 { - guard case .upload = operation, expectedSourceSHA256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer source digest is invalid") - } - } - } - - public static func normalizedOptionalMessage(_ value: String?) throws -> String? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard trimmed.count <= 240 else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer message is too long") } - guard doesNotContainControlCharacters(trimmed) else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer message cannot contain control characters") - } - return trimmed - } - - 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("background transfer remote URL is unsafe") } - let scheme = components.scheme?.lowercased() - switch networkPolicy { - case .publicHTTPS: - guard scheme == "https" else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL must use public HTTPS") - } - case .simulatorLoopbackHTTP: - #if os(iOS) && !targetEnvironment(simulator) - throw RadrootsBackgroundTransferError.invalidRequest("background transfer simulator policy is unavailable on this device") - #else - guard scheme == "http", let host = components.host?.lowercased(), - host == "localhost" || host == "127.0.0.1" || host == "::1" - else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer simulator URL must use loopback HTTP") } - #endif - } - } - - private static func validate(method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation) throws { - switch operation { - case let .download(destination): - guard method == .get else { throw RadrootsBackgroundTransferError.invalidRequest("background download transfers must use GET") } - try validateLocalFile(destination) - case let .upload(source): - guard method == .post || method == .put else { - throw RadrootsBackgroundTransferError.invalidRequest("background upload transfers must use POST or PUT") - } - 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("background transfer local file is unsafe") } - case let .stagedBlob(blob): - guard - (try? RadrootsStagedBlobReference( - blobID: blob.blobID, sizeBytes: blob.sizeBytes, mediaType: blob.mediaType, filenameHint: blob.filenameHint - )) != nil - else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer staged blob is invalid") } - } - } - - private static func validate(headers: [String: String]) throws { - guard headers.count <= 32 else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer header count is too large") + public static func normalizedIdentifier(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer identifier must not be empty") + } + guard trimmed.count <= 128 else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer identifier is too long") + } + guard + trimmed.range(of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$", options: .regularExpression) + != nil + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer identifier must use lowercase safe identifier characters" + ) + } + guard !trimmed.contains("..") else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer identifier cannot contain empty path components") + } + 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( + "background transfer byte limit is invalid") + } + if case .download = operation, responsePolicy != .discard { + throw RadrootsBackgroundTransferError.invalidRequest( + "background downloads must discard the separate response body") + } + if let expectedSourceSHA256 { + guard case .upload = operation, + expectedSourceSHA256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer source digest is invalid") + } + } + if case .upload(.stagedBlob(let blob)) = operation, + UInt64(blob.sizeBytes) > maximumTransferBytes + { + throw RadrootsBackgroundTransferError.invalidRequest( + "background upload source exceeds its byte limit") + } + } + + public static func normalizedOptionalMessage(_ value: String?) throws -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard trimmed.count <= 240 else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer message is too long") + } + guard doesNotContainControlCharacters(trimmed) else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer message cannot contain control characters") + } + return trimmed + } + + 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( + "background transfer remote URL is unsafe") + } + let scheme = components.scheme?.lowercased() + switch networkPolicy { + case .publicHTTPS: + guard scheme == "https" else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer remote URL must use public HTTPS") + } + case .simulatorLoopbackHTTP: + #if os(iOS) && !targetEnvironment(simulator) + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer simulator policy is unavailable on this device") + #else + guard scheme == "http", let host = components.host?.lowercased(), + host == "localhost" || host == "127.0.0.1" || host == "::1" + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer simulator URL must use loopback HTTP") } - 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("background transfer header name is unsafe") } - 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("background transfer metadata count is too large") - } - 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("background transfer metadata key is sensitive") } - } - } - - 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("background transfer media type is invalid") } - return normalized - } - - static func validate(response: RadrootsBackgroundTransferResponse, policy: RadrootsBackgroundTransferResponsePolicy) throws { - guard (200 ... 299).contains(response.statusCode) else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer response status is invalid") - } - if policy.maximumBodyBytes == 0 { - guard response.body == nil, response.mediaType == nil else { - throw RadrootsBackgroundTransferError.invalidRequest("background transfer response body is not permitted") - } - return - } - guard let body = response.body, body.count <= policy.maximumBodyBytes, let mediaType = response.mediaType, - policy.acceptedMediaTypes.contains(mediaType) - else { throw RadrootsBackgroundTransferError.invalidRequest("background transfer response violates its policy") } - } - - private static func validateSafeText(_ value: String, field: String, maximumLength: Int) throws { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { throw RadrootsBackgroundTransferError.invalidRequest("\(field) must not be empty") } - guard trimmed.count <= maximumLength else { throw RadrootsBackgroundTransferError.invalidRequest("\(field) is too long") } - guard doesNotContainControlCharacters(trimmed) else { - throw RadrootsBackgroundTransferError.invalidRequest("\(field) cannot contain control characters") - } - } - - private static func doesNotContainControlCharacters(_ value: String) -> Bool { - value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) } - } + #endif + } + } + + private static func validate( + method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation + ) throws { + switch operation { + case .download(let destination): + guard method == .get else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background download transfers must use GET") + } + guard case .file = destination else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background downloads require a scoped destination file") + } + try validateLocalFile(destination) + case .upload(let source): + guard method == .post || method == .put else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background upload transfers must use POST or PUT") + } + try validateLocalFile(source) + } + } + + 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( + "background transfer local file is unsafe") + } + case .stagedBlob(let blob): + guard + (try? RadrootsStagedBlobReference( + blobID: blob.blobID, sizeBytes: blob.sizeBytes, mediaType: blob.mediaType, + filenameHint: blob.filenameHint + )) != nil + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer staged blob is invalid") + } + } + } + + private static func validate(headers: [String: String]) throws { + guard headers.count <= 32 else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer header count is too large") + } + 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( + "background transfer header name is unsafe") + } + 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( + "background transfer metadata count is too large") + } + 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( + "background transfer metadata key is sensitive") + } + } + } + + 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( + "background transfer media type is invalid") + } + return normalized + } + + static func validate( + response: RadrootsBackgroundTransferResponse, policy: RadrootsBackgroundTransferResponsePolicy + ) throws { + guard (200...299).contains(response.statusCode) else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer response status is invalid") + } + if policy.maximumBodyBytes == 0 { + guard response.body == nil, response.mediaType == nil else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer response body is not permitted") + } + return + } + guard let body = response.body, body.count <= policy.maximumBodyBytes, + let mediaType = response.mediaType, + policy.acceptedMediaTypes.contains(mediaType) + else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer response violates its policy") + } + } + + private static func validateSafeText(_ value: String, field: String, maximumLength: Int) throws { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsBackgroundTransferError.invalidRequest("\(field) must not be empty") + } + guard trimmed.count <= maximumLength else { + throw RadrootsBackgroundTransferError.invalidRequest("\(field) is too long") + } + guard doesNotContainControlCharacters(trimmed) else { + throw RadrootsBackgroundTransferError.invalidRequest( + "\(field) cannot contain control characters") + } + } + + private static func doesNotContainControlCharacters(_ value: String) -> Bool { + value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) } + } } -private extension RadrootsBackgroundTransferOperation { - var redactedLabel: String { - switch self { - case .download: "download" - case .upload: "upload" - } +extension RadrootsBackgroundTransferOperation { + fileprivate var redactedLabel: String { + switch self { + case .download: "download" + case .upload: "upload" } + } } diff --git a/Sources/RadrootsKit/RadrootsVerifiedArtifactAccess.swift b/Sources/RadrootsKit/RadrootsVerifiedArtifactAccess.swift @@ -0,0 +1,161 @@ +import Foundation + +public enum RadrootsVerifiedArtifactAccessError: Error, Equatable, Sendable { + case invalidDescriptor + case protectedDataUnavailable + case artifactUnavailable + case artifactCorrupt + case fileSystemFailure +} + +public struct RadrootsVerifiedArtifactDescriptor: Sendable, Equatable, Hashable { + public let artifactID: String + public let byteSize: UInt64 + public let mediaType: String + public let fileExtension: String + public let width: UInt32 + public let height: UInt32 + + public init( + artifactID: String, + byteSize: UInt64, + mediaType: String, + width: UInt32, + height: UInt32 + ) throws { + let normalizedMediaType = + mediaType + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard artifactID.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, + let fileExtension = Self.fileExtension(for: normalizedMediaType), + byteSize > 0, + byteSize <= 512 * 1024 * 1024, + width > 0, + height > 0, + width <= 16_384, + height <= 16_384, + UInt64(width) * UInt64(height) <= 100_000_000 + else { + throw RadrootsVerifiedArtifactAccessError.invalidDescriptor + } + self.artifactID = artifactID + self.byteSize = byteSize + self.mediaType = normalizedMediaType + self.fileExtension = fileExtension + self.width = width + self.height = height + } + + fileprivate var filename: String { + "\(artifactID).\(fileExtension)" + } + + private static func fileExtension(for mediaType: String) -> String? { + switch mediaType { + case "image/gif": "gif" + case "image/jpeg": "jpg" + case "image/png": "png" + case "image/webp": "webp" + default: nil + } + } +} + +public struct RadrootsVerifiedArtifactFile: Sendable, Equatable, CustomDebugStringConvertible { + public let descriptor: RadrootsVerifiedArtifactDescriptor + public let fileURL: URL + + fileprivate init(descriptor: RadrootsVerifiedArtifactDescriptor, fileURL: URL) { + self.descriptor = descriptor + self.fileURL = fileURL + } + + public var debugDescription: String { + "RadrootsVerifiedArtifactFile(artifactID: \(descriptor.artifactID), byteSize: \(descriptor.byteSize), mediaType: \(descriptor.mediaType), fileURL: <redacted>)" + } +} + +/// Opens only Rust-approved, content-addressed inbound media artifacts. +/// +/// Rust remains the verification and cache authority. This Apple adapter +/// independently rechecks the immutable file immediately before handing its +/// local URL to a native renderer and refuses access while protected data is +/// unavailable. +public struct RadrootsAppleVerifiedArtifactAccess: Sendable { + private let ownerDirectory: URL + private let protectedData: RadrootsProtectedDataProvider + + public init( + mobileStore: RadrootsAppleMobileStoreConfiguration, + protectedData: RadrootsProtectedDataProvider = .available + ) { + ownerDirectory = mobileStore.ownerDirectory.standardizedFileURL + self.protectedData = protectedData + } + + public func open(_ descriptor: RadrootsVerifiedArtifactDescriptor) throws + -> RadrootsVerifiedArtifactFile + { + guard protectedData.currentState() == .available else { + throw RadrootsVerifiedArtifactAccessError.protectedDataUnavailable + } + let cacheDirectory = + ownerDirectory + .appendingPathComponent("inbound_media.v1", isDirectory: true) + .standardizedFileURL + let candidate = + cacheDirectory + .appendingPathComponent(descriptor.filename, isDirectory: false) + .standardizedFileURL + guard candidate.deletingLastPathComponent() == cacheDirectory else { + throw RadrootsVerifiedArtifactAccessError.invalidDescriptor + } + + do { + try Self.requireOrdinaryDirectory(ownerDirectory) + try Self.requireOrdinaryDirectory(cacheDirectory) + guard FileManager.default.fileExists(atPath: candidate.path) else { + throw RadrootsVerifiedArtifactAccessError.artifactUnavailable + } + let values = try candidate.resourceValues(forKeys: [ + .isRegularFileKey, + .isSymbolicLinkKey, + .fileSizeKey, + ]) + guard values.isRegularFile == true, + values.isSymbolicLink != true, + values.fileSize.flatMap(UInt64.init) == descriptor.byteSize + else { + throw RadrootsVerifiedArtifactAccessError.artifactCorrupt + } + guard try RadrootsAppleFileDigest.sha256(at: candidate) == descriptor.artifactID else { + throw RadrootsVerifiedArtifactAccessError.artifactCorrupt + } + #if os(iOS) + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: candidate.path + ) + #endif + return RadrootsVerifiedArtifactFile(descriptor: descriptor, fileURL: candidate) + } catch let error as RadrootsVerifiedArtifactAccessError { + throw error + } catch let error as CocoaError where error.code == .fileReadNoSuchFile { + throw RadrootsVerifiedArtifactAccessError.artifactUnavailable + } catch { + throw RadrootsVerifiedArtifactAccessError.fileSystemFailure + } + } + + public func revalidate(_ artifact: RadrootsVerifiedArtifactFile) throws -> Bool { + try open(artifact.descriptor) == artifact + } + + private static func requireOrdinaryDirectory(_ directory: URL) throws { + let values = try directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory == true, values.isSymbolicLink != true else { + throw RadrootsVerifiedArtifactAccessError.artifactCorrupt + } + } +} diff --git a/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift b/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift @@ -2,137 +2,207 @@ import Foundation import RadrootsKit public actor RadrootsInMemoryBackgroundTransferStore: RadrootsBackgroundTransferStore { - private var snapshotsByIdentifier: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] + private var snapshotsByIdentifier: + [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot] - public init(snapshots: [RadrootsBackgroundTransferSnapshot] = []) { - snapshotsByIdentifier = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.identifier, $0) }) - } + public init(snapshots: [RadrootsBackgroundTransferSnapshot] = []) { + snapshotsByIdentifier = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.identifier, $0) }) + } - public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { - snapshotsByIdentifier.values.sorted { left, right in - left.identifier < right.identifier - } + public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + snapshotsByIdentifier.values.sorted { left, right in + left.identifier < right.identifier } + } - public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { - snapshotsByIdentifier[snapshot.identifier] = snapshot - } + 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 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 let .failure(error): - throw error - } - } - - 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 complete(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { - guard let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) else { - throw RadrootsBackgroundTransferError.transferFailure("background transfer snapshot not found") - } - let snapshot = try RadrootsBackgroundTransferSnapshot( - request: existing.request, - state: .completed, - progress: existing.progress, - updatedAt: updatedAt + 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) + 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) + 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, + errorMessage: "background_transfer_expired", + updatedAt: updatedAt ) - try await store.saveSnapshot(snapshot) - } - - public func fail(_ identifier: RadrootsBackgroundTransferIdentifier, message: String) async throws { - guard let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) else { - throw RadrootsBackgroundTransferError.transferFailure("background transfer snapshot not found") - } - let snapshot = try RadrootsBackgroundTransferSnapshot( - request: existing.request, - state: .failed, - progress: existing.progress, - errorMessage: message, - updatedAt: updatedAt + ) + } + } + + 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( + "background transfer snapshot not found") + } + 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 ) - 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 - } + ) + case .rejected(let code): + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .failed, + progress: existing.progress, + errorMessage: code, + updatedAt: updatedAt + ) + ) + } + } + + public func complete(_ identifier: RadrootsBackgroundTransferIdentifier) async throws { + guard + let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) + else { + throw RadrootsBackgroundTransferError.transferFailure( + "background transfer snapshot not found") + } + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .completed, + progress: existing.progress, + updatedAt: updatedAt + ) + try await store.saveSnapshot(snapshot) + } + + public func fail(_ identifier: RadrootsBackgroundTransferIdentifier, message: String) async throws + { + guard + let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) + else { + throw RadrootsBackgroundTransferError.transferFailure( + "background transfer snapshot not found") + } + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: existing.request, + state: .failed, + progress: existing.progress, + errorMessage: message, + 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,480 +1,806 @@ import Foundation -@testable import RadrootsKit import RadrootsKitTesting import Testing -@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) +@testable import RadrootsKit - #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 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)) } @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( + "background transfer identifier already exists") + ) { _ = try await transfer.enqueue(request) - await #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier already exists")) { - _ = 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), enqueueOutcome: .failure(.transferFailure("adapter rejected transfer")) - ) - let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) - let request = try appleTransferRequest(identifier: "field.transfer.failed") - - await #expect(throws: RadrootsBackgroundTransferError.transferFailure("background transfer enqueue failed")) { - _ = try await transfer.enqueue(request) - } + let store = RadrootsInMemoryBackgroundTransferStore() + let probe = RadrootsAppleBackgroundTransferProbe( + now: Date(timeIntervalSince1970: 200), + enqueueOutcome: .failure(.transferFailure("adapter rejected transfer")) + ) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters()) + let request = try appleTransferRequest(identifier: "field.transfer.failed") + + await #expect( + throws: RadrootsBackgroundTransferError.transferFailure("background transfer enqueue failed") + ) { + _ = try await transfer.enqueue(request) + } - let snapshot = try await transfer.snapshot(for: request.identifier) - #expect(snapshot?.state == .failed) - #expect(snapshot?.errorMessage == "background_transfer_enqueue_failed") - #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 200)) + let snapshot = try await transfer.snapshot(for: request.identifier) + #expect(snapshot?.state == .failed) + #expect(snapshot?.errorMessage == "background_transfer_enqueue_failed") + #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)) + 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 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) } @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.errorMessage == "background_transfer_interrupted") - #expect(snapshot.possibleRemoteOrphan) - #expect(snapshot.updatedAt == Date(timeIntervalSince1970: 401)) + 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.errorMessage == "background_transfer_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) + 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: successfulHTTPResult(), - bytesTransferred: 0, totalBytesExpected: nil - ) + 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)) +} - let snapshot = try await store.loadSnapshots().first - let destination = try resolver.resolve(.file(RadrootsFileReference(scope: .cache, relativePath: "field.transfer.completed.json"))) - #expect(snapshot?.state == .completed) - #expect(snapshot?.progress.bytesTransferred == Int64(payload.count)) - #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 500)) - #expect(snapshot?.response?.statusCode == 200) - #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.errorMessage == "background_transfer_response_media_type") + #expect(snapshot.possibleRemoteOrphan) } @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?.errorMessage == "background_transfer_download_staging_failure") - #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 600)) + 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?.errorMessage == "background_transfer_download_staging_failure") + #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 600)) } @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 == .completed) - #expect(snapshot?.progress.bytesTransferred == 10) - #expect(snapshot?.progress.totalBytesExpected == 10) - #expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 700)) - #expect(snapshot?.response?.statusCode == 200) + 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 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 == .completed) - #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 == .completed) + 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) } @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?.errorMessage == "background_transfer_http_status_503") - - 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.errorMessage == "background_transfer_response_too_large") - #expect(oversized.possibleRemoteOrphan) + 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?.errorMessage == "background_transfer_http_status_503") + + 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.errorMessage == "background_transfer_response_too_large") + #expect(oversized.possibleRemoteOrphan) } @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 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) } @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) + 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) } -@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 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 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") { completion.markCompleted() } + 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") + { completion.markCompleted() } + + #expect(completion.completionCount == 1) +} - #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( + "background transfer is not awaiting verification") + ) { + try await transfer.settle(request.identifier, verification: .accepted) + } } -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 = [] - } +@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(code: "background_transfer_rust_verification_failed") + ) + + let snapshot = try #require(try await store.loadSnapshots().first) + #expect(snapshot.state == .failed) + #expect(snapshot.errorMessage == "background_transfer_rust_verification_failed") + #expect(snapshot.possibleRemoteOrphan) + #expect(snapshot.response == nil) +} - 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 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]) +} - private func enqueue(_ request: RadrootsBackgroundTransferRequest) throws { - enqueuedRequestsValue.append(request) - switch enqueueOutcome { - case .success: activeIdentifiersValue.insert(request.identifier) - case let .failure(error): throw error - } - } +@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("background transfer is still active") + ) { + _ = try await transfer.retry(request) + } + #expect(await probe.enqueuedRequests.isEmpty) + #expect(try await store.loadSnapshots().first?.state == .interrupted) +} - private func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) { - cancelledIdentifiersValue.append(identifier) - activeIdentifiersValue.remove(identifier) - } +@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) +} - private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> { - activeIdentifiersValue - } +@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.errorMessage == "background_transfer_transfer_too_large") + #expect(snapshot.possibleRemoteOrphan) +} - private func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) { - handledBackgroundEventIdentifiersValue.append(identifier) - completionHandler() - } +@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) +} - var enqueuedRequests: [RadrootsBackgroundTransferRequest] { - enqueuedRequestsValue - } +#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 store = RadrootsInMemoryBackgroundTransferStore() + let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) + let stagingRoot = roots.temporaryRoot.appendingPathComponent( + "live-urlsession", isDirectory: true) + let adapters = try RadrootsAppleBackgroundTransferAdapters.simulatorForegroundIntegration( + sessionIdentifier: "org.radroots.tests.background-transfer.\(UUID().uuidString.lowercased())", + store: store, + fileResolver: resolver, + downloadStagingRoot: stagingRoot + ) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: adapters) + 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 + ) - var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] { - cancelledIdentifiersValue + _ = 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( + "background_transfer_live_test_timed_out") + } + try await Task.sleep(nanoseconds: 50_000_000) + snapshot = try await transfer.snapshot(for: request.identifier) } - var handledBackgroundEventIdentifiers: [String] { - handledBackgroundEventIdentifiersValue - } + 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]) } -private final class RadrootsCompletionProbe: @unchecked Sendable { - private let lock = NSLock() - private var completionCountValue = 0 - - func markCompleted() { - lock.lock() - completionCountValue += 1 - lock.unlock() - } +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) + } + ) + } - var completed: Bool { - completionCount > 0 + 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 + } +} - var completionCount: Int { - lock.lock() - defer { lock.unlock() } - return completionCountValue - } +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 + } } private final class RadrootsProtectedDataProbe: @unchecked Sendable { - private let lock = NSLock() - private var stateValue: RadrootsProtectedDataState - - init(state: RadrootsProtectedDataState) { - stateValue = state + private let lock = NSLock() + private var stateValue: RadrootsProtectedDataState + + init(state: RadrootsProtectedDataState) { + stateValue = state + } + + var state: RadrootsProtectedDataState { + get { + lock.lock() + defer { lock.unlock() } + return stateValue } - - var state: RadrootsProtectedDataState { - get { - lock.lock() - defer { lock.unlock() } - return stateValue - } - set { - lock.lock() - stateValue = newValue - lock.unlock() - } + set { + lock.lock() + stateValue = newValue + lock.unlock() } + } } 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"))) - ) + 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"))) + ) } -private func appleUploadRequest(identifier: String, responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard) throws - -> RadrootsBackgroundTransferRequest +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 - ) + 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 + ) } private func successfulHTTPResult() -> RadrootsBackgroundHTTPResult { - RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false) + RadrootsBackgroundHTTPResult(statusCode: 200, mediaType: nil, body: nil, bodyExceeded: false) } private func appleTransferRoots() throws -> RadrootsAppleFileRoots { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "radroots-apple-background-transfer-\(UUID().uuidString)", 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 root = FileManager.default.temporaryDirectory.appendingPathComponent( + "radroots-apple-background-transfer-\(UUID().uuidString)", 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/RadrootsBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift @@ -1,289 +1,421 @@ import Foundation -@testable import RadrootsKit import Testing -@Test func backgroundTransferIdentifierNormalizesAndRejectsUnsafeValues() throws { - let identifier = try RadrootsBackgroundTransferIdentifier(" FIELD-IOS.TRANSFER_1 ") - - #expect(identifier.rawValue == "field-ios.transfer_1") +@testable import RadrootsKit - #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must not be empty")) { - _ = try RadrootsBackgroundTransferIdentifier(" ") - } - #expect( - throws: RadrootsBackgroundTransferError.invalidRequest( - "background transfer identifier must use lowercase safe identifier characters" - ) - ) { _ = try RadrootsBackgroundTransferIdentifier("/escape") } - #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier cannot contain empty path components")) { - _ = try RadrootsBackgroundTransferIdentifier("field..transfer") - } +@Test func backgroundTransferIdentifierNormalizesAndRejectsUnsafeValues() throws { + let identifier = try RadrootsBackgroundTransferIdentifier(" FIELD-IOS.TRANSFER_1 ") + + #expect(identifier.rawValue == "field-ios.transfer_1") + + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer identifier must not be empty") + ) { + _ = try RadrootsBackgroundTransferIdentifier(" ") + } + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer identifier must use lowercase safe identifier characters" + ) + ) { _ = try RadrootsBackgroundTransferIdentifier("/escape") } + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer identifier cannot contain empty path components") + ) { + _ = try RadrootsBackgroundTransferIdentifier("field..transfer") + } } @Test func backgroundTransferRequestValidatesUrlMethodAndHeaders() throws { - let destination = RadrootsBackgroundTransferLocalFile.file(RadrootsFileReference(scope: .cache, relativePath: "downloads/relay.json")) - let request = try RadrootsBackgroundTransferRequest( - identifier: RadrootsBackgroundTransferIdentifier("field.transfer.download"), - remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, operation: .download(destination: destination), - headers: ["Accept": "application/json"], metadata: ["purpose": "diagnostics"] + let destination = RadrootsBackgroundTransferLocalFile.file( + RadrootsFileReference(scope: .cache, relativePath: "downloads/relay.json")) + let request = try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier("field.transfer.download"), + remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, + operation: .download(destination: destination), + headers: ["Accept": "application/json"], metadata: ["purpose": "diagnostics"] + ) + + #expect(request.method == .get) + #expect(request.operation == .download(destination: destination)) + #expect(request.headers["Accept"] == "application/json") + + let longAuthorization = String(repeating: "a", count: 2048) + let authorized = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, + operation: .download(destination: destination), + headers: ["Authorization": longAuthorization] + ) + #expect(authorized.headers["Authorization"] == longAuthorization) + + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer remote URL must use public HTTPS") + ) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "http://radroots.org/relay.json")), method: .get, + operation: .download(destination: destination) ) - - #expect(request.method == .get) - #expect(request.operation == .download(destination: destination)) - #expect(request.headers["Accept"] == "application/json") - - let longAuthorization = String(repeating: "a", count: 2048) - let authorized = try RadrootsBackgroundTransferRequest( - remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, operation: .download(destination: destination), - headers: ["Authorization": longAuthorization] + } + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer remote URL is unsafe") + ) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "https://radroots.org/relay.json?token=secret")), + method: .get, + operation: .download(destination: destination) ) - #expect(authorized.headers["Authorization"] == longAuthorization) - - #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL must use public HTTPS")) { - _ = try RadrootsBackgroundTransferRequest( - remoteURL: #require(URL(string: "http://radroots.org/relay.json")), method: .get, operation: .download(destination: destination) - ) - } - #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL is unsafe")) { - _ = try RadrootsBackgroundTransferRequest( - remoteURL: #require(URL(string: "https://radroots.org/relay.json?token=secret")), method: .get, - operation: .download(destination: destination) - ) - } - let simulatorRequest = try RadrootsBackgroundTransferRequest( - remoteURL: #require(URL(string: "http://127.0.0.1:21100/relay.json")), method: .get, operation: .download(destination: destination), - networkPolicy: .simulatorLoopbackHTTP + } + let simulatorRequest = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "http://127.0.0.1:21100/relay.json")), method: .get, + operation: .download(destination: destination), + networkPolicy: .simulatorLoopbackHTTP + ) + #expect(simulatorRequest.networkPolicy == .simulatorLoopbackHTTP) + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background download transfers must use GET") + ) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .post, + operation: .download(destination: destination) ) - #expect(simulatorRequest.networkPolicy == .simulatorLoopbackHTTP) - #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background download transfers must use GET")) { - _ = try RadrootsBackgroundTransferRequest( - remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .post, operation: .download(destination: destination) - ) - } - #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer header value cannot contain control characters")) { - _ = try RadrootsBackgroundTransferRequest( - remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, operation: .download(destination: destination), - headers: ["Accept": "application/json\ntext/plain"] - ) - } - #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer header name is unsafe")) { - _ = try RadrootsBackgroundTransferRequest( - remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, operation: .download(destination: destination), - headers: ["Content-Length": "4"] - ) - } + } + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer header value cannot contain control characters") + ) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, + operation: .download(destination: destination), + headers: ["Accept": "application/json\ntext/plain"] + ) + } + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer header name is unsafe") + ) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "https://radroots.org/relay.json")), method: .get, + operation: .download(destination: destination), + headers: ["Content-Length": "4"] + ) + } } @Test func backgroundTransferUploadRequiresUploadMethod() throws { - let source = try RadrootsBackgroundTransferLocalFile.stagedBlob(RadrootsStagedBlobReference(blobID: "upload", sizeBytes: 12)) - - let request = try RadrootsBackgroundTransferRequest( - remoteURL: #require(URL(string: "https://radroots.org/upload")), method: .put, operation: .upload(source: source) + let source = try RadrootsBackgroundTransferLocalFile.stagedBlob( + RadrootsStagedBlobReference(blobID: "upload", sizeBytes: 12)) + + let request = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "https://radroots.org/upload")), method: .put, + operation: .upload(source: source) + ) + + #expect(request.operation == .upload(source: source)) + + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background upload transfers must use POST or PUT") + ) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "https://radroots.org/upload")), method: .get, + operation: .upload(source: source) ) + } +} - #expect(request.operation == .upload(source: source)) - - #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background upload transfers must use POST or PUT")) { - _ = try RadrootsBackgroundTransferRequest( - remoteURL: #require(URL(string: "https://radroots.org/upload")), method: .get, operation: .upload(source: source) - ) - } +@Test func backgroundTransferRequestsEnforceTransferByteBounds() throws { + let destination = RadrootsBackgroundTransferLocalFile.file( + RadrootsFileReference(scope: .cache, relativePath: "bounded.bin") + ) + let request = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "https://radroots.org/bounded.bin")), + method: .get, + operation: .download(destination: destination), + maximumTransferBytes: 1024 + ) + + #expect(request.maximumTransferBytes == 1024) + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer byte limit is invalid") + ) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "https://radroots.org/unbounded.bin")), + method: .get, + operation: .download(destination: destination), + maximumTransferBytes: 0 + ) + } + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background downloads require a scoped destination file") + ) { + _ = try RadrootsBackgroundTransferRequest( + remoteURL: #require(URL(string: "https://radroots.org/blob.bin")), + method: .get, + operation: .download( + destination: .stagedBlob(try RadrootsStagedBlobReference(blobID: "download", sizeBytes: 1)) + ) + ) + } } @Test func backgroundTransferProgressAndSnapshotValidateBounds() throws { - let request = try testDownloadRequest(identifier: "field.transfer.snapshot") - let progress = try RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10) - let snapshot = try RadrootsBackgroundTransferSnapshot( - request: request, state: .running, progress: progress, errorMessage: " running ", updatedAt: Date(timeIntervalSince1970: 1) + let request = try testDownloadRequest(identifier: "field.transfer.snapshot") + let progress = try RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10) + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: request, state: .running, progress: progress, errorMessage: " running ", + updatedAt: Date(timeIntervalSince1970: 1) + ) + + #expect(snapshot.identifier == request.identifier) + #expect(snapshot.state == .running) + #expect(snapshot.progress == progress) + #expect(snapshot.errorMessage == "running") + #expect(snapshot.response == nil) + #expect(!snapshot.possibleRemoteOrphan) + + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer expected byte count cannot be less than transferred bytes" ) - - #expect(snapshot.identifier == request.identifier) - #expect(snapshot.state == .running) - #expect(snapshot.progress == progress) - #expect(snapshot.errorMessage == "running") - #expect(snapshot.response == nil) - #expect(!snapshot.possibleRemoteOrphan) - - #expect( - throws: RadrootsBackgroundTransferError.invalidRequest( - "background transfer expected byte count cannot be less than transferred bytes" - ) - ) { _ = try RadrootsBackgroundTransferProgress(bytesTransferred: 10, totalBytesExpected: 5) } - #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer updated date must be finite")) { - _ = try RadrootsBackgroundTransferSnapshot(request: request, updatedAt: Date(timeIntervalSinceReferenceDate: .infinity)) - } + ) { _ = try RadrootsBackgroundTransferProgress(bytesTransferred: 10, totalBytesExpected: 5) } + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer updated date must be finite") + ) { + _ = try RadrootsBackgroundTransferSnapshot( + request: request, updatedAt: Date(timeIntervalSinceReferenceDate: .infinity)) + } } @Test func backgroundTransferPersistenceRedactsSecretsAndValidatesDecode() async throws { - let roots = try testBackgroundTransferRoots() - let store = RadrootsAppleBackgroundTransferStore(roots: roots) - let request = try RadrootsBackgroundTransferRequest( - identifier: RadrootsBackgroundTransferIdentifier("field.transfer.redacted"), remoteURL: #require(URL(string: "https://radroots.org/upload")), - method: .put, operation: .upload(source: .file(RadrootsFileReference(scope: .cache, relativePath: "upload.png"))), - headers: ["Authorization": "Nostr secret-token"], - metadata: ["purpose": "blossom_upload", "sha256": String(repeating: "a", count: 64)] - ) - try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request)) - - let persistedURL = roots.dataRoot.appendingPathComponent("background_transfers/transfers.json") - let persisted = try String(contentsOf: persistedURL, encoding: .utf8) - #expect(!request.debugDescription.contains("secret-token")) - #expect(!RadrootsBackgroundTransferHandle(request: request).debugDescription.contains("secret-token")) - let snapshotDebugDescription = try RadrootsBackgroundTransferSnapshot(request: request).debugDescription - #expect(!snapshotDebugDescription.contains("secret-token")) - #expect(!persisted.contains("secret-token")) - #expect(!persisted.contains("blossom_upload")) - #expect(!persisted.contains(String(repeating: "a", count: 64))) - #expect(persisted.contains("\"schemaVersion\":1")) - let recovered = try #require(try await store.loadSnapshots().first) - #expect(recovered.request.headers.isEmpty) - #expect(recovered.request.metadata.isEmpty) - - let malformed = persisted.replacingOccurrences(of: "field.transfer.redacted", with: "../unsafe") - try Data(malformed.utf8).write(to: persistedURL, options: .atomic) - await #expect(throws: RadrootsBackgroundTransferError.persistenceFailure("background transfer persistence could not be read")) { - _ = try await store.loadSnapshots() - } + let roots = try testBackgroundTransferRoots() + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + let request = try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier("field.transfer.redacted"), + remoteURL: #require(URL(string: "https://radroots.org/upload")), + method: .put, + operation: .upload( + source: .file(RadrootsFileReference(scope: .cache, relativePath: "upload.png"))), + headers: ["Authorization": "Nostr secret-token"], + metadata: ["purpose": "blossom_upload", "sha256": String(repeating: "a", count: 64)] + ) + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request)) + + let persistedURL = roots.dataRoot.appendingPathComponent("background_transfers/transfers.json") + let persisted = try String(contentsOf: persistedURL, encoding: .utf8) + #expect(!request.debugDescription.contains("secret-token")) + #expect( + !RadrootsBackgroundTransferHandle(request: request).debugDescription.contains("secret-token")) + let snapshotDebugDescription = try RadrootsBackgroundTransferSnapshot(request: request) + .debugDescription + #expect(!snapshotDebugDescription.contains("secret-token")) + #expect(!persisted.contains("secret-token")) + #expect(!persisted.contains("blossom_upload")) + #expect(!persisted.contains(String(repeating: "a", count: 64))) + #expect(persisted.contains("\"schemaVersion\":1")) + let recovered = try #require(try await store.loadSnapshots().first) + #expect(recovered.request.headers.isEmpty) + #expect(recovered.request.metadata.isEmpty) + + let malformed = persisted.replacingOccurrences(of: "field.transfer.redacted", with: "../unsafe") + try Data(malformed.utf8).write(to: persistedURL, options: .atomic) + await #expect( + throws: RadrootsBackgroundTransferError.persistenceFailure( + "background transfer persistence could not be read") + ) { + _ = try await store.loadSnapshots() + } } @Test func appleBackgroundTransferStoreMigratesLegacyCachePersistence() async throws { - let roots = try testBackgroundTransferRoots() - let legacyURL = roots.cacheRoot.appendingPathComponent("background_transfers/transfers.json") - try FileManager.default.createDirectory(at: legacyURL.deletingLastPathComponent(), withIntermediateDirectories: true) - let snapshot = try RadrootsBackgroundTransferSnapshot(request: testDownloadRequest(identifier: "field.transfer.legacy")) - try JSONEncoder().encode([snapshot]).write(to: legacyURL, options: .atomic) - - let store = RadrootsAppleBackgroundTransferStore(roots: roots) - #expect(try await store.loadSnapshots().map(\.identifier.rawValue) == ["field.transfer.legacy"]) - #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) - #expect(FileManager.default.fileExists(atPath: roots.dataRoot.appendingPathComponent("background_transfers/transfers.json").path)) + let roots = try testBackgroundTransferRoots() + let legacyURL = roots.cacheRoot.appendingPathComponent("background_transfers/transfers.json") + try FileManager.default.createDirectory( + at: legacyURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let snapshot = try RadrootsBackgroundTransferSnapshot( + request: testDownloadRequest(identifier: "field.transfer.legacy")) + try JSONEncoder().encode([snapshot]).write(to: legacyURL, options: .atomic) + + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + #expect(try await store.loadSnapshots().map(\.identifier.rawValue) == ["field.transfer.legacy"]) + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + #expect( + FileManager.default.fileExists( + atPath: roots.dataRoot.appendingPathComponent("background_transfers/transfers.json").path)) } @Test func appleBackgroundTransferStoreFailsClosedWhileProtectedDataIsLocked() async throws { - let roots = try testBackgroundTransferRoots() - let store = RadrootsAppleBackgroundTransferStore(roots: roots, protectedData: RadrootsProtectedDataProvider { .locked }) - - await #expect(throws: RadrootsBackgroundTransferError.persistenceFailure("background transfer protected data is unavailable")) { - _ = try await store.loadSnapshots() - } + let roots = try testBackgroundTransferRoots() + let store = RadrootsAppleBackgroundTransferStore( + roots: roots, protectedData: RadrootsProtectedDataProvider { .locked }) + + await #expect( + throws: RadrootsBackgroundTransferError.persistenceFailure( + "background transfer protected data is unavailable") + ) { + _ = try await store.loadSnapshots() + } } @Test func appleBackgroundTransferFileResolverUsesOnlyFileRootsAndStagedBlobs() throws { - let roots = try testBackgroundTransferRoots() - let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) - let file = RadrootsBackgroundTransferLocalFile.file(RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json")) - let blob = try RadrootsStagedBlobReference(blobID: "blob-1", sizeBytes: 2) - - #expect(try resolver.resolve(file) == roots.dataRoot.appendingPathComponent("exports/diagnostics.json").standardizedFileURL) - #expect(try resolver.resolve(.stagedBlob(blob)) == roots.stagedBlobsRoot.appendingPathComponent("blob-1").standardizedFileURL) - - try FileManager.default.createDirectory(at: roots.dataRoot, withIntermediateDirectories: true) - let outside = roots.dataRoot.deletingLastPathComponent().appendingPathComponent("outside.bin") - try Data("outside".utf8).write(to: outside) - let symlink = roots.dataRoot.appendingPathComponent("escape.bin") - try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: outside) - #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer local file resolved outside its root")) { - _ = try resolver.resolve(.file(RadrootsFileReference(scope: .data, relativePath: "escape.bin"))) - } - - #expect(throws: RadrootsAppleFileError.invalidRequest("file relative path must not be absolute")) { - _ = try resolver.resolve(.file(RadrootsFileReference(scope: .data, relativePath: "/tmp/escape"))) - } - #expect(throws: RadrootsAppleFileError.invalidRequest("staged blob id contains invalid characters")) { - _ = try resolver.resolve(.stagedBlob(RadrootsStagedBlobReference(blobID: "../escape", sizeBytes: 1))) - } + let roots = try testBackgroundTransferRoots() + let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) + let file = RadrootsBackgroundTransferLocalFile.file( + RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json")) + let blob = try RadrootsStagedBlobReference(blobID: "blob-1", sizeBytes: 2) + + #expect( + try resolver.resolve(file) + == roots.dataRoot.appendingPathComponent("exports/diagnostics.json").standardizedFileURL) + #expect( + try resolver.resolve(.stagedBlob(blob)) + == roots.stagedBlobsRoot.appendingPathComponent("blob-1").standardizedFileURL) + + try FileManager.default.createDirectory(at: roots.dataRoot, withIntermediateDirectories: true) + let outside = roots.dataRoot.deletingLastPathComponent().appendingPathComponent("outside.bin") + try Data("outside".utf8).write(to: outside) + let symlink = roots.dataRoot.appendingPathComponent("escape.bin") + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: outside) + #expect( + throws: RadrootsBackgroundTransferError.invalidRequest( + "background transfer local file resolved outside its root") + ) { + _ = try resolver.resolve(.file(RadrootsFileReference(scope: .data, relativePath: "escape.bin"))) + } + + #expect(throws: RadrootsAppleFileError.invalidRequest("file relative path must not be absolute")) + { + _ = try resolver.resolve( + .file(RadrootsFileReference(scope: .data, relativePath: "/tmp/escape"))) + } + #expect( + throws: RadrootsAppleFileError.invalidRequest("staged blob id contains invalid characters") + ) { + _ = try resolver.resolve( + .stagedBlob(RadrootsStagedBlobReference(blobID: "../escape", sizeBytes: 1))) + } } @Test func appleBackgroundTransferStorePersistsAndRecoversSnapshots() async throws { - let roots = try testBackgroundTransferRoots() - let store = RadrootsAppleBackgroundTransferStore(roots: roots) - let first = try RadrootsBackgroundTransferSnapshot( - request: testDownloadRequest(identifier: "field.transfer.b"), updatedAt: Date(timeIntervalSince1970: 2) - ) - let second = try RadrootsBackgroundTransferSnapshot( - request: testDownloadRequest(identifier: "field.transfer.a"), state: .running, updatedAt: Date(timeIntervalSince1970: 3) - ) - - try await store.saveSnapshot(first) - try await store.saveSnapshot(second) - - let recoveredStore = RadrootsAppleBackgroundTransferStore(roots: roots) - #expect(try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == ["field.transfer.a", "field.transfer.b"]) - - try await recoveredStore.removeSnapshot(for: second.identifier) - #expect(try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == ["field.transfer.b"]) - - try await recoveredStore.removeAllSnapshots() - #expect(try await recoveredStore.loadSnapshots().isEmpty) + let roots = try testBackgroundTransferRoots() + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + let first = try RadrootsBackgroundTransferSnapshot( + request: testDownloadRequest(identifier: "field.transfer.b"), + updatedAt: Date(timeIntervalSince1970: 2) + ) + let second = try RadrootsBackgroundTransferSnapshot( + request: testDownloadRequest(identifier: "field.transfer.a"), state: .running, + updatedAt: Date(timeIntervalSince1970: 3) + ) + + try await store.saveSnapshot(first) + try await store.saveSnapshot(second) + + let recoveredStore = RadrootsAppleBackgroundTransferStore(roots: roots) + #expect( + try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == [ + "field.transfer.a", "field.transfer.b", + ]) + + try await recoveredStore.removeSnapshot(for: second.identifier) + #expect( + try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == ["field.transfer.b"]) + + try await recoveredStore.removeAllSnapshots() + #expect(try await recoveredStore.loadSnapshots().isEmpty) } @Test func appleBackgroundTransferStoreSerializesConcurrentWriters() async throws { - let roots = try testBackgroundTransferRoots() - let store = RadrootsAppleBackgroundTransferStore(roots: roots) - - try await withThrowingTaskGroup(of: Void.self) { group in - for index in 0 ..< 32 { - group.addTask { - let request = try testDownloadRequest(identifier: "field.transfer.concurrent-\(index)") - try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request)) - } - } - try await group.waitForAll() + let roots = try testBackgroundTransferRoots() + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + + try await withThrowingTaskGroup(of: Void.self) { group in + for index in 0..<32 { + group.addTask { + let request = try testDownloadRequest(identifier: "field.transfer.concurrent-\(index)") + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request)) + } } + try await group.waitForAll() + } - #expect(try await store.loadSnapshots().count == 32) + #expect(try await store.loadSnapshots().count == 32) } @Test func appleBackgroundTransferStorePersistsBoundedResponseAcrossRestart() async throws { - let roots = try testBackgroundTransferRoots() - let store = RadrootsAppleBackgroundTransferStore(roots: roots) - let body = Data(#"{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#.utf8) - let request = try RadrootsBackgroundTransferRequest( - identifier: RadrootsBackgroundTransferIdentifier("field.transfer.descriptor"), - remoteURL: #require(URL(string: "https://blossom.radroots.org/upload")), method: .put, - operation: .upload(source: .file(RadrootsFileReference(scope: .cache, relativePath: "prepared.png"))), - responsePolicy: .boundedJSON(maximumBodyBytes: 1024) - ) - let response = try RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: "application/json", body: body) - try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request, state: .completed, response: response)) - - let recovered = try #require(try await RadrootsAppleBackgroundTransferStore(roots: roots).loadSnapshots().first) - #expect(recovered.response?.body == body) - #expect(recovered.response?.mediaType == "application/json") + let roots = try testBackgroundTransferRoots() + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + let body = Data( + #"{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#.utf8) + let request = try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier("field.transfer.descriptor"), + remoteURL: #require(URL(string: "https://blossom.radroots.org/upload")), method: .put, + operation: .upload( + source: .file(RadrootsFileReference(scope: .cache, relativePath: "prepared.png"))), + responsePolicy: .boundedJSON(maximumBodyBytes: 1024) + ) + let response = try RadrootsBackgroundTransferResponse( + statusCode: 200, mediaType: "application/json", body: body) + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot(request: request, state: .completed, response: response)) + + let recovered = try #require( + try await RadrootsAppleBackgroundTransferStore(roots: roots).loadSnapshots().first) + #expect(recovered.response?.body == body) + #expect(recovered.response?.mediaType == "application/json") } @Test func unavailableBackgroundTransferThrowsTypedErrors() async throws { - let transfer = RadrootsUnavailableBackgroundTransfer(reason: "missing background transfer support") - let request = try testDownloadRequest(identifier: "field.transfer.unavailable") - - await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) { - _ = try await transfer.enqueue(request) - } - await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) { - try await transfer.cancel(request.identifier) - } - await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) { - _ = try await transfer.snapshot(for: request.identifier) - } - await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) { - _ = try await transfer.snapshots() - } + let transfer = RadrootsUnavailableBackgroundTransfer( + reason: "missing background transfer support") + let request = try testDownloadRequest(identifier: "field.transfer.unavailable") + + await #expect( + throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support") + ) { + _ = try await transfer.enqueue(request) + } + await #expect( + throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support") + ) { + try await transfer.cancel(request.identifier) + } + await #expect( + throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support") + ) { + _ = try await transfer.snapshot(for: request.identifier) + } + await #expect( + throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support") + ) { + _ = try await transfer.snapshots() + } } private func testDownloadRequest(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"))) - ) + 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"))) + ) } private func testBackgroundTransferRoots() throws -> RadrootsAppleFileRoots { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( - "radroots-background-transfer-\(UUID().uuidString)", 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 root = FileManager.default.temporaryDirectory.appendingPathComponent( + "radroots-background-transfer-\(UUID().uuidString)", 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/RadrootsVerifiedArtifactAccessTests.swift b/Tests/RadrootsKitTests/RadrootsVerifiedArtifactAccessTests.swift @@ -0,0 +1,155 @@ +import Foundation +import Testing + +@testable import RadrootsKit + +private let verifiedArtifactPublicKey = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + +@Test func verifiedArtifactAccessRechecksExactProtectedBytes() throws { + let fixture = try VerifiedArtifactFixture() + defer { fixture.remove() } + let data = Data("verified inbound media".utf8) + let digest = try fixture.install(data, mediaType: "image/png") + let descriptor = try RadrootsVerifiedArtifactDescriptor( + artifactID: digest, + byteSize: UInt64(data.count), + mediaType: "image/png", + width: 12, + height: 8 + ) + + let artifact = try fixture.access.open(descriptor) + + #expect(try Data(contentsOf: artifact.fileURL) == data) + #expect(try fixture.access.revalidate(artifact)) + #expect(!artifact.debugDescription.contains(artifact.fileURL.path)) +} + +@Test func verifiedArtifactAccessFailsClosedForLockMissingTamperAndSymlink() throws { + let fixture = try VerifiedArtifactFixture() + defer { fixture.remove() } + let data = Data("immutable".utf8) + let digest = try fixture.install(data, mediaType: "image/jpeg") + let descriptor = try RadrootsVerifiedArtifactDescriptor( + artifactID: digest, + byteSize: UInt64(data.count), + mediaType: "image/jpeg", + width: 4, + height: 4 + ) + + fixture.protectedData.state = .locked + #expect(throws: RadrootsVerifiedArtifactAccessError.protectedDataUnavailable) { + _ = try fixture.access.open(descriptor) + } + fixture.protectedData.state = .available + + try Data("tampered".utf8).write(to: fixture.artifactURL(for: descriptor), options: .atomic) + #expect(throws: RadrootsVerifiedArtifactAccessError.artifactCorrupt) { + _ = try fixture.access.open(descriptor) + } + try FileManager.default.removeItem(at: fixture.artifactURL(for: descriptor)) + #expect(throws: RadrootsVerifiedArtifactAccessError.artifactUnavailable) { + _ = try fixture.access.open(descriptor) + } + + let outside = fixture.root.appendingPathComponent("outside.jpg") + try data.write(to: outside) + try FileManager.default.createSymbolicLink( + at: fixture.artifactURL(for: descriptor), withDestinationURL: outside) + #expect(throws: RadrootsVerifiedArtifactAccessError.artifactCorrupt) { + _ = try fixture.access.open(descriptor) + } +} + +@Test func verifiedArtifactDescriptorRejectsUngovernedMetadata() throws { + #expect(throws: RadrootsVerifiedArtifactAccessError.invalidDescriptor) { + _ = try RadrootsVerifiedArtifactDescriptor( + artifactID: String(repeating: "A", count: 64), byteSize: 1, mediaType: "image/png", width: 1, + height: 1 + ) + } + #expect(throws: RadrootsVerifiedArtifactAccessError.invalidDescriptor) { + _ = try RadrootsVerifiedArtifactDescriptor( + artifactID: String(repeating: "a", count: 64), byteSize: 1, mediaType: "image/svg+xml", + width: 1, height: 1 + ) + } + #expect(throws: RadrootsVerifiedArtifactAccessError.invalidDescriptor) { + _ = try RadrootsVerifiedArtifactDescriptor( + artifactID: String(repeating: "a", count: 64), byteSize: 1, mediaType: "image/png", + width: 16_384, height: 16_384 + ) + } +} + +private final class VerifiedArtifactProtectedData: @unchecked Sendable { + private let lock = NSLock() + private var value = RadrootsProtectedDataState.available + + var state: RadrootsProtectedDataState { + get { + lock.lock() + defer { lock.unlock() } + return value + } + set { + lock.lock() + value = newValue + lock.unlock() + } + } +} + +private struct VerifiedArtifactFixture { + let root: URL + let store: RadrootsAppleMobileStoreConfiguration + let protectedData: VerifiedArtifactProtectedData + let access: RadrootsAppleVerifiedArtifactAccess + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("radroots-verified-artifact-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + let roots = try RadrootsAppleFileRoots( + appIdentifier: "org.radroots.tests", + dataRoot: root.appendingPathComponent("data", isDirectory: true), + cacheRoot: root.appendingPathComponent("cache", isDirectory: true), + temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true) + ) + store = try RadrootsAppleMobileStore.prepare( + roots: roots, + publicKeyHex: verifiedArtifactPublicKey, + protectedDataAvailability: .available + ) + let protectedData = VerifiedArtifactProtectedData() + self.protectedData = protectedData + access = RadrootsAppleVerifiedArtifactAccess( + mobileStore: store, + protectedData: RadrootsProtectedDataProvider { protectedData.state } + ) + } + + func install(_ data: Data, mediaType: String) throws -> String { + let temporary = root.appendingPathComponent("digest-source") + try data.write(to: temporary) + let digest = try RadrootsAppleFileDigest.sha256(at: temporary) + let fileExtension = mediaType == "image/jpeg" ? "jpg" : "png" + let directory = store.ownerDirectory.appendingPathComponent( + "inbound_media.v1", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + try data.write(to: directory.appendingPathComponent("\(digest).\(fileExtension)")) + return digest + } + + func artifactURL(for descriptor: RadrootsVerifiedArtifactDescriptor) -> URL { + store.ownerDirectory + .appendingPathComponent("inbound_media.v1", isDirectory: true) + .appendingPathComponent("\(descriptor.artifactID).\(descriptor.fileExtension)") + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +}