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:
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)
+ }
+}