commit 265015fc537bfe507f12d801b9765be9764fbc95
parent 80f40b55cdf903c1b6ebf46fef4f0da3437e8469
Author: triesap <tyson@radroots.org>
Date: Fri, 7 Aug 2026 20:00:51 +0000
feat(media): harden Blossom background transfers
- normalize bounded images into stable content-addressed staging files
- persist redacted versioned transfer descriptors and bounded responses
- recover interruption cancellation and orphan states without callback races
- cover lock restart tamper and HTTP failure paths with deterministic tests
Diffstat:
7 files changed, 1610 insertions(+), 800 deletions(-)
diff --git a/Package.swift b/Package.swift
@@ -35,6 +35,9 @@ let package = Package(
.linkedFramework("UserNotifications"),
.linkedFramework("AVFoundation"),
.linkedFramework("Photos"),
+ .linkedFramework("CoreGraphics"),
+ .linkedFramework("ImageIO"),
+ .linkedFramework("UniformTypeIdentifiers"),
.linkedFramework("CoreLocation"),
.linkedFramework("BackgroundTasks", .when(platforms: [.iOS]))
]
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift
@@ -22,139 +22,88 @@ public struct RadrootsAppleBackgroundTransferAdapters: Sendable {
}
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")
- },
+ 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()
- }
- )
+ }, handleBackgroundEvents: { _, completionHandler in completionHandler() })
public static func live(
- sessionIdentifier: String,
- store: any RadrootsBackgroundTransferStore,
- fileResolver: any RadrootsBackgroundTransferFileResolver,
- downloadStagingRoot: URL,
- now: @escaping @Sendable () -> Date = Date.init
+ 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)
- }
- )
+ 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
+ return .unavailable
#endif
}
}
-public final class RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer, Sendable {
+public actor RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer {
private let store: any RadrootsBackgroundTransferStore
private let adapters: RadrootsAppleBackgroundTransferAdapters
- public init(
- store: any RadrootsBackgroundTransferStore,
- adapters: RadrootsAppleBackgroundTransferAdapters
- ) {
+ public init(store: any RadrootsBackgroundTransferStore, adapters: RadrootsAppleBackgroundTransferAdapters) {
self.store = store
self.adapters = adapters
}
- public convenience init(
- roots: RadrootsAppleFileRoots,
- sessionIdentifier: String
- ) throws {
+ 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/\(try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier))/downloads"
- ),
- allowRootDirectory: true
- )
- try self.init(
- store: store,
- adapters: .live(
- sessionIdentifier: sessionIdentifier,
- store: store,
- fileResolver: resolver,
- downloadStagingRoot: downloadStagingRoot
- )
- )
+ relativePath:
+ "background_transfers/\(try RadrootsBackgroundTransferValidation.normalizedIdentifier(sessionIdentifier))/downloads"),
+ allowRootDirectory: true)
+ self.store = store
+ self.adapters = try .live(
+ sessionIdentifier: sessionIdentifier, store: store, fileResolver: resolver, downloadStagingRoot: downloadStagingRoot)
}
public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle {
- try await store.saveSnapshot(
- try RadrootsBackgroundTransferSnapshot(
- request: request,
- state: .queued,
- updatedAt: adapters.now()
- )
- )
- do {
- try await adapters.enqueue(request)
- let current = try await store.loadSnapshots().first { $0.identifier == request.identifier }
- if current?.state == .queued {
+ 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(try RadrootsBackgroundTransferSnapshot(request: request, state: .queued, updatedAt: adapters.now()))
+ do { try await adapters.enqueue(request) } catch {
+ do {
try await store.saveSnapshot(
try RadrootsBackgroundTransferSnapshot(
- request: request,
- state: .running,
- updatedAt: adapters.now()
- )
- )
+ request: request, state: .failed, errorMessage: "background_transfer_enqueue_failed", updatedAt: adapters.now()))
+ } catch {
+ throw RadrootsBackgroundTransferError.persistenceFailure("background transfer enqueue failure could not be persisted")
}
- return RadrootsBackgroundTransferHandle(request: request)
- } catch {
- let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
+ 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(
- try RadrootsBackgroundTransferSnapshot(
- request: request,
- state: .failed,
- errorMessage: message,
- updatedAt: adapters.now()
- )
- )
- throw error
+ try RadrootsBackgroundTransferSnapshot(request: request, state: .running, updatedAt: adapters.now()))
}
+ return RadrootsBackgroundTransferHandle(request: request)
}
public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
- try await adapters.cancel(identifier)
+ 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(
try RadrootsBackgroundTransferSnapshot(
- request: existing.request,
- state: .cancelled,
- progress: existing.progress,
- updatedAt: adapters.now()
- )
- )
+ request: existing.request, state: .cancelled, progress: existing.progress, updatedAt: adapters.now()))
}
}
@@ -163,40 +112,48 @@ public final class RadrootsAppleBackgroundTransfer: RadrootsBackgroundTransfer,
}
public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
- let activeIdentifiers = try await adapters.activeTransferIdentifiers()
+ 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()
- )
+ 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
- }
+ return reconciled.sorted { left, right in left.identifier < right.identifier }
}
- public func handleEventsForBackgroundURLSession(
- identifier: String,
- completionHandler: @escaping @Sendable () -> Void
- ) async {
+ public func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async {
await adapters.handleBackgroundEvents(identifier, completionHandler)
}
}
enum RadrootsStagedBackgroundDownloadResult: Sendable, Equatable {
case file(URL)
- case failure(String)
+ case failure
+}
+
+struct RadrootsBackgroundHTTPResult: Sendable, Equatable {
+ let statusCode: Int?
+ let mediaType: String?
+ let body: Data?
+ let bodyExceeded: Bool
}
actor RadrootsAppleBackgroundTransferCoordinator {
@@ -205,193 +162,179 @@ actor RadrootsAppleBackgroundTransferCoordinator {
private let fileResolver: any RadrootsBackgroundTransferFileResolver
private let now: @Sendable () -> Date
private let fileManager: FileManager
- private var completionHandlersByIdentifier: [String: @Sendable () -> Void]
+ 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
+ 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
- self.completionHandlersByIdentifier = [:]
+ self.completionHandlers = []
+ self.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
- }
+ 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,
- updatedAt: now()
- )
- )
+ 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?,
- bytesTransferred: Int64,
+ identifier: RadrootsBackgroundTransferIdentifier, platformError: Error?,
+ stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?, httpResult: RadrootsBackgroundHTTPResult, bytesTransferred: Int64,
totalBytesExpected: Int64?
) async {
- guard let existing = try? await snapshot(for: identifier), existing.state != .cancelled else {
+ 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
}
- if let platformError {
- await fail(existing: existing, message: Self.failureMessage(for: platformError))
+ 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 .download(let destination):
await completeDownload(
- existing: existing,
- destination: destination,
- stagedDownloadResult: stagedDownloadResult,
- bytesTransferred: bytesTransferred,
- totalBytesExpected: totalBytesExpected
- )
+ existing: existing, destination: destination, stagedDownloadResult: stagedDownloadResult, response: response,
+ bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected)
case .upload:
await completeUpload(
- existing: existing,
- bytesTransferred: bytesTransferred,
- totalBytesExpected: totalBytesExpected
- )
+ existing: existing, response: response, bytesTransferred: bytesTransferred, totalBytesExpected: totalBytesExpected)
}
}
- func handleBackgroundEvents(
- identifier: String,
- completionHandler: @escaping @Sendable () -> Void
- ) {
+ func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) {
guard identifier == sessionIdentifier else {
completionHandler()
return
}
- completionHandlersByIdentifier[identifier] = completionHandler
+ 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 {
+ guard identifier == nil || identifier == sessionIdentifier else { return }
+ guard !completionHandlers.isEmpty else {
+ unclaimedFinishedEventCount = min(unclaimedFinishedEventCount + 1, 8)
return
}
- completionHandlersByIdentifier.removeValue(forKey: sessionIdentifier)?()
+ let handlers = completionHandlers
+ completionHandlers.removeAll()
+ for handler in handlers { handler() }
}
private func completeUpload(
- existing: RadrootsBackgroundTransferSnapshot,
- bytesTransferred: Int64,
+ existing: RadrootsBackgroundTransferSnapshot, response: RadrootsBackgroundTransferResponse, bytesTransferred: Int64,
totalBytesExpected: Int64?
) async {
- let progress = Self.progress(
- bytesTransferred: bytesTransferred,
- totalBytesExpected: totalBytesExpected,
- fallback: existing.progress
- ) ?? existing.progress
+ 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,
- updatedAt: now()
- )
- )
+ request: existing.request, state: .completed, progress: progress, response: response, updatedAt: now()))
}
private func completeDownload(
- existing: RadrootsBackgroundTransferSnapshot,
- destination: RadrootsBackgroundTransferLocalFile,
- stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?,
- bytesTransferred: Int64,
- totalBytesExpected: Int64?
+ existing: RadrootsBackgroundTransferSnapshot, destination: RadrootsBackgroundTransferLocalFile,
+ stagedDownloadResult: RadrootsStagedBackgroundDownloadResult?, response: RadrootsBackgroundTransferResponse,
+ bytesTransferred: Int64, totalBytesExpected: Int64?
) async {
guard case .file(let stagedFileURL) = stagedDownloadResult else {
- let message: String
- if case .failure(let failureMessage) = stagedDownloadResult {
- message = failureMessage
- } else {
- message = "background download finished without a staged file"
- }
- await fail(existing: existing, message: message)
+ 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)
- if fileManager.fileExists(atPath: destinationURL.path) {
- try fileManager.removeItem(at: destinationURL)
- }
- try fileManager.moveItem(at: stagedFileURL, to: destinationURL)
+ 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
+ let progress =
+ Self.progress(
+ bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected, fallback: existing.progress)
+ ?? existing.progress
try await store.saveSnapshot(
try RadrootsBackgroundTransferSnapshot(
- request: existing.request,
- state: .completed,
- progress: progress,
- updatedAt: now()
- )
- )
+ request: existing.request, state: .completed, progress: progress, response: response, updatedAt: now()))
} catch {
- await fail(existing: existing, message: Self.failureMessage(for: error))
+ Self.removeStagedDownload(.file(stagedFileURL), fileManager: fileManager)
+ await fail(existing: existing, code: "background_transfer_destination_failure")
}
}
- private func fail(existing: RadrootsBackgroundTransferSnapshot, message: String) async {
+ 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: Self.sanitizedMessage(message),
- updatedAt: now()
- )
- )
+ 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? {
+ 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
- )
+ 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 {
@@ -399,272 +342,316 @@ actor RadrootsAppleBackgroundTransferCoordinator {
return Int64(values.fileSize ?? 0)
}
- private static func failureMessage(for error: Error) -> String {
- if let localized = error as? LocalizedError,
- let description = localized.errorDescription {
- return sanitizedMessage(description)
+ 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
}
- return sanitizedMessage(String(describing: error))
}
- private static func sanitizedMessage(_ value: String) -> String {
- let scalars = value.unicodeScalars.map { scalar in
- CharacterSet.controlCharacters.contains(scalar) ? UnicodeScalar(32)! : scalar
+ private static func validatedResponse(for request: RadrootsBackgroundTransferRequest, httpResult: RadrootsBackgroundHTTPResult) throws
+ -> RadrootsBackgroundTransferResponse
+ {
+ guard let statusCode = httpResult.statusCode else {
+ throw RadrootsBackgroundTransferError.transferFailure("background_transfer_response_missing")
}
- let withoutControls = String(String.UnicodeScalarView(scalars))
- let trimmed = withoutControls.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else {
- return "background transfer failed"
+ if request.responsePolicy == .discard {
+ return try RadrootsBackgroundTransferResponse(statusCode: statusCode, mediaType: nil, body: nil)
}
- return String(trimmed.prefix(240))
+ 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)
}
-}
-
-#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
- self.fileManager = .default
- self.coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: identifier,
- store: store,
- fileResolver: fileResolver,
- now: now
- )
+ 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)
}
+}
- 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):
- task = try session.uploadTask(with: urlRequest, fromFile: fileResolver.resolve(source))
- }
- task.taskDescription = request.identifier.rawValue
- task.resume()
- }
+#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
+ self.fileManager = .default
+ self.coordinator = RadrootsAppleBackgroundTransferCoordinator(
+ sessionIdentifier: identifier, store: store, fileResolver: fileResolver, now: now)
+ }
+
+ 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")
+ }
+ if case .stagedBlob(let 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 cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async {
- let tasks = await allTasks()
- for task in tasks where task.taskDescription == identifier.rawValue {
- task.cancel()
+ func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async {
+ let tasks = await allTasks()
+ for task in tasks where task.taskDescription == identifier.rawValue { task.cancel() }
}
- }
- func activeTransferIdentifiers() async -> Set<RadrootsBackgroundTransferIdentifier> {
- let tasks = await allTasks()
- let identifiers = tasks.compactMap { task -> RadrootsBackgroundTransferIdentifier? in
- guard let taskDescription = task.taskDescription else {
- return nil
+ 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 try? RadrootsBackgroundTransferIdentifier(taskDescription)
+ return Set(identifiers)
}
- return Set(identifiers)
- }
- func handleBackgroundEvents(
- identifier: String,
- completionHandler: @escaping @Sendable () -> Void
- ) async {
- await coordinator.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler)
- }
+ func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) async {
+ _ = backgroundSession()
+ await coordinator.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler)
+ }
- private func allTasks() async -> [URLSessionTask] {
- await withCheckedContinuation { continuation in
- backgroundSession().getAllTasks { tasks in
- continuation.resume(returning: tasks)
+ private func allTasks() async -> [URLSessionTask] {
+ await withCheckedContinuation { continuation in
+ backgroundSession().getAllTasks { tasks in continuation.resume(returning: tasks) }
}
}
- }
- private func backgroundSession() -> URLSession {
- if let session {
+ 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
+ self.sessionDelegate = delegate
+ self.sessionDelegateQueue = delegateQueue
return session
}
- 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
- self.sessionDelegate = delegate
- self.sessionDelegateQueue = delegateQueue
- return session
- }
-}
-
-private final class RadrootsAppleBackgroundURLSessionDelegate: NSObject, URLSessionDownloadDelegate, URLSessionTaskDelegate, @unchecked Sendable {
- private let coordinator: RadrootsAppleBackgroundTransferCoordinator
- private let downloadStagingRoot: URL
- private let fileManager: FileManager
- private let lock = NSLock()
- private var stagedDownloadResultsByTaskIdentifier: [Int: RadrootsStagedBackgroundDownloadResult]
- init(
- coordinator: RadrootsAppleBackgroundTransferCoordinator,
- downloadStagingRoot: URL,
- fileManager: FileManager
- ) {
- self.coordinator = coordinator
- self.downloadStagingRoot = downloadStagingRoot
- self.fileManager = fileManager
- self.stagedDownloadResultsByTaskIdentifier = [:]
- }
+ private func removeOrphanedDownloadStagingFiles() {
+ guard let urls = try? fileManager.contentsOfDirectory(at: downloadStagingRoot, includingPropertiesForKeys: nil) else { return }
+ for url in urls where url.pathExtension == "download" { try? fileManager.removeItem(at: url) }
+ }
+ }
+
+ private final class RadrootsAppleBackgroundURLSessionDelegate: NSObject, URLSessionDownloadDelegate, URLSessionDataDelegate,
+ URLSessionTaskDelegate, @unchecked Sendable
+ {
+ private static let absoluteMaximumResponseBodyBytes = 65_536
+ 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
+ self.stagedDownloadResultsByTaskIdentifier = [:]
+ self.responseBodyLimitsByTaskIdentifier = [:]
+ self.responseBodiesByTaskIdentifier = [:]
+ self.exceededResponseBodyTaskIdentifiers = []
+ }
+
+ func urlSession(_ session: 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(
+ _ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: 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(
- _ session: URLSession,
- downloadTask: URLSessionDownloadTask,
- didFinishDownloadingTo location: URL
- ) {
- guard let identifier = transferIdentifier(from: downloadTask) else {
- return
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
+ let shouldCancel = appendResponseBody(data, taskIdentifier: dataTask.taskIdentifier)
+ if shouldCancel { dataTask.cancel() }
}
- 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)
+
+ func urlSession(
+ _ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: 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))
}
- try fileManager.moveItem(at: location, to: destination)
- result = .file(destination)
- } catch {
- result = .failure(Self.failureMessage(for: error))
}
- recordDownloadResult(result, taskIdentifier: downloadTask.taskIdentifier)
- }
- func urlSession(
- _ session: URLSession,
- downloadTask: URLSessionDownloadTask,
- didWriteData bytesWritten: 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(_ session: 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(
- _ session: URLSession,
- task: URLSessionTask,
- didSendBodyData bytesSent: Int64,
- totalBytesSent: Int64,
- totalBytesExpectedToSend: Int64
- ) {
- guard let identifier = transferIdentifier(from: task) else {
- return
+ func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
+ Task { await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier) }
}
- Task {
- await coordinator.updateProgress(
- identifier: identifier,
- bytesTransferred: totalBytesSent,
- totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend)
- )
+
+ private func recordDownloadResult(_ result: RadrootsStagedBackgroundDownloadResult, taskIdentifier: Int) {
+ lock.lock()
+ defer { lock.unlock() }
+ stagedDownloadResultsByTaskIdentifier[taskIdentifier] = result
}
- }
- func urlSession(
- _ session: URLSession,
- task: URLSessionTask,
- didCompleteWithError error: Error?
- ) {
- guard let identifier = transferIdentifier(from: task) else {
- return
+ private func takeDownloadResult(taskIdentifier: Int) -> RadrootsStagedBackgroundDownloadResult? {
+ lock.lock()
+ defer { lock.unlock() }
+ return stagedDownloadResultsByTaskIdentifier.removeValue(forKey: taskIdentifier)
}
- let bytesTransferred = max(max(task.countOfBytesReceived, task.countOfBytesSent), 0)
- let expected = Self.expectedByteCount(max(task.countOfBytesExpectedToReceive, task.countOfBytesExpectedToSend))
- let stagedDownloadResult = takeDownloadResult(taskIdentifier: task.taskIdentifier)
- Task {
- await coordinator.complete(
- identifier: identifier,
- platformError: error,
- stagedDownloadResult: stagedDownloadResult,
- bytesTransferred: bytesTransferred,
- totalBytesExpected: expected
- )
+
+ func registerResponseBodyLimit(_ limit: Int, taskIdentifier: Int) {
+ lock.lock()
+ defer { lock.unlock() }
+ responseBodyLimitsByTaskIdentifier[taskIdentifier] = min(max(limit, 0), Self.absoluteMaximumResponseBodyBytes)
}
- }
- func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
- Task {
- await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier)
+ 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 recordDownloadResult(
- _ result: RadrootsStagedBackgroundDownloadResult,
- taskIdentifier: Int
- ) {
- lock.lock()
- defer { lock.unlock() }
- stagedDownloadResultsByTaskIdentifier[taskIdentifier] = result
- }
+ 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 takeDownloadResult(taskIdentifier: Int) -> RadrootsStagedBackgroundDownloadResult? {
- lock.lock()
- defer { lock.unlock() }
- return stagedDownloadResultsByTaskIdentifier.removeValue(forKey: taskIdentifier)
- }
+ 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)
+ }
- private func transferIdentifier(from task: URLSessionTask) -> RadrootsBackgroundTransferIdentifier? {
- guard let taskDescription = task.taskDescription else {
- return nil
+ private func transferIdentifier(from task: URLSessionTask) -> RadrootsBackgroundTransferIdentifier? {
+ guard let taskDescription = task.taskDescription else { return nil }
+ return try? RadrootsBackgroundTransferIdentifier(taskDescription)
}
- return try? RadrootsBackgroundTransferIdentifier(taskDescription)
+
+ private static func expectedByteCount(_ value: Int64) -> Int64? { value >= 0 ? value : nil }
+
}
+#endif
- private static func expectedByteCount(_ value: Int64) -> Int64? {
- value >= 0 ? value : nil
+extension RadrootsBackgroundTransferRequest {
+ fileprivate var isUpload: Bool {
+ if case .upload = operation { return true }
+ return false
}
+}
- private static func failureMessage(for error: Error) -> String {
- if let localized = error as? LocalizedError,
- let description = localized.errorDescription {
- return description
+extension RadrootsBackgroundTransferError {
+ fileprivate var stableCode: String {
+ let value: String
+ switch self {
+ case .invalidRequest(let message), .unavailable(let message), .transferFailure(let message), .persistenceFailure(let message):
+ value = message
+ }
+ guard value.range(of: "^background_transfer_[a-z0-9_]+$", options: .regularExpression) != nil else {
+ return "background_transfer_response_invalid"
}
- return String(describing: error)
+ return value
}
}
-#endif
diff --git a/Sources/RadrootsKit/RadrootsAppleMediaPreparation.swift b/Sources/RadrootsKit/RadrootsAppleMediaPreparation.swift
@@ -0,0 +1,210 @@
+import CryptoKit
+import Foundation
+import ImageIO
+import UniformTypeIdentifiers
+
+public enum RadrootsAppleMediaPreparationError: Error, Equatable, Sendable {
+ case invalidRequest(String)
+ case unavailable(String)
+ case preparationFailure(String)
+}
+
+extension RadrootsAppleMediaPreparationError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .invalidRequest(let message), .unavailable(let message), .preparationFailure(let message): message
+ }
+ }
+}
+
+public struct RadrootsAppleImagePreparationRequest: Sendable, Equatable, Hashable {
+ public let source: RadrootsBackgroundTransferLocalFile
+ public let maximumInputBytes: Int
+ public let maximumOutputBytes: Int
+ public let maximumPixelCount: Int
+ public let maximumDimension: Int
+
+ public init(
+ source: RadrootsBackgroundTransferLocalFile, maximumInputBytes: Int = 40 * 1_024 * 1_024,
+ maximumOutputBytes: Int = 10 * 1_024 * 1_024, maximumPixelCount: Int = 40_000_000, maximumDimension: Int = 4_096
+ ) throws {
+ guard (1...(40 * 1_024 * 1_024)).contains(maximumInputBytes), (1...(10 * 1_024 * 1_024)).contains(maximumOutputBytes),
+ (1...40_000_000).contains(maximumPixelCount), (1...8_192).contains(maximumDimension)
+ else { throw RadrootsAppleMediaPreparationError.invalidRequest("image preparation limits are invalid") }
+ do { try RadrootsBackgroundTransferValidation.validateLocalFile(source) } catch {
+ throw RadrootsAppleMediaPreparationError.invalidRequest("image source handle is invalid")
+ }
+ self.source = source
+ self.maximumInputBytes = maximumInputBytes
+ self.maximumOutputBytes = maximumOutputBytes
+ self.maximumPixelCount = maximumPixelCount
+ self.maximumDimension = maximumDimension
+ }
+}
+
+public struct RadrootsApplePreparedImage: Sendable, Equatable, Hashable, CustomDebugStringConvertible {
+ public let file: RadrootsStagedBlobReference
+ public let sha256: String
+ public let width: UInt32
+ public let height: UInt32
+
+ public init(file: RadrootsStagedBlobReference, sha256: String, width: UInt32, height: UInt32) throws {
+ guard sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, width > 0, height > 0, file.sizeBytes > 0,
+ file.mediaType == "image/png"
+ else { throw RadrootsAppleMediaPreparationError.invalidRequest("prepared image commitment is invalid") }
+ self.file = file
+ self.sha256 = sha256
+ self.width = width
+ self.height = height
+ }
+
+ public var debugDescription: String {
+ "RadrootsApplePreparedImage(sha256: \(sha256), sizeBytes: \(file.sizeBytes), width: \(width), height: \(height))"
+ }
+}
+
+public actor RadrootsAppleMediaPreparer {
+ private let roots: RadrootsAppleFileRoots
+ private let resolver: RadrootsAppleBackgroundTransferFileResolver
+ private let fileManager: FileManager
+ private let protectedData: RadrootsProtectedDataProvider
+
+ public init(
+ roots: RadrootsAppleFileRoots, fileManager: FileManager = .default, protectedData: RadrootsProtectedDataProvider = .available
+ ) {
+ self.roots = roots
+ self.resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ self.fileManager = fileManager
+ self.protectedData = protectedData
+ }
+
+ public func prepareImage(_ request: RadrootsAppleImagePreparationRequest) async throws -> RadrootsApplePreparedImage {
+ do { return try await prepareValidatedImage(request) } catch is CancellationError { throw CancellationError() } catch let error
+ as RadrootsAppleMediaPreparationError
+ { throw error } catch { throw RadrootsAppleMediaPreparationError.preparationFailure("image preparation failed") }
+ }
+
+ private func prepareValidatedImage(_ request: RadrootsAppleImagePreparationRequest) async throws -> RadrootsApplePreparedImage {
+ try Task.checkCancellation()
+ try requireProtectedData()
+ let sourceURL = try resolver.resolve(request.source)
+ let sourceValues = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey])
+ guard sourceValues.isRegularFile == true, sourceValues.isSymbolicLink != true, let inputBytes = sourceValues.fileSize,
+ inputBytes > 0, inputBytes <= request.maximumInputBytes
+ else { throw RadrootsAppleMediaPreparationError.invalidRequest("image source is unavailable or exceeds its byte limit") }
+ guard let source = CGImageSourceCreateWithURL(sourceURL as CFURL, [kCGImageSourceShouldCache: false] as CFDictionary),
+ CGImageSourceGetCount(source) == 1
+ else { throw RadrootsAppleMediaPreparationError.invalidRequest("image source must contain exactly one decodable image") }
+ let dimensions = try Self.sourceDimensions(source)
+ guard dimensions.pixelCount <= request.maximumPixelCount else {
+ throw RadrootsAppleMediaPreparationError.invalidRequest("image source exceeds its pixel limit")
+ }
+ let thumbnailOptions: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true,
+ kCGImageSourceShouldCacheImmediately: true, kCGImageSourceThumbnailMaxPixelSize: request.maximumDimension
+ ]
+ guard let normalizedImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) else {
+ throw RadrootsAppleMediaPreparationError.preparationFailure("image normalization failed")
+ }
+ try Task.checkCancellation()
+
+ let temporaryURL = roots.temporaryRoot.appendingPathComponent("media_preparation", isDirectory: true).appendingPathComponent(
+ "\(UUID().uuidString.lowercased()).png"
+ ).standardizedFileURL
+ defer { if fileManager.fileExists(atPath: temporaryURL.path) { try? fileManager.removeItem(at: temporaryURL) } }
+ try fileManager.createDirectory(at: temporaryURL.deletingLastPathComponent(), withIntermediateDirectories: true)
+ #if os(iOS)
+ try fileManager.setAttributes(
+ [.protectionKey: FileProtectionType.complete], ofItemAtPath: temporaryURL.deletingLastPathComponent().path)
+ #endif
+ guard let destination = CGImageDestinationCreateWithURL(temporaryURL as CFURL, UTType.png.identifier as CFString, 1, nil) else {
+ throw RadrootsAppleMediaPreparationError.preparationFailure("image destination could not be created")
+ }
+ CGImageDestinationAddImage(destination, normalizedImage, [:] as CFDictionary)
+ guard CGImageDestinationFinalize(destination) else {
+ throw RadrootsAppleMediaPreparationError.preparationFailure("image encoding failed")
+ }
+ try Task.checkCancellation()
+ let outputSize = try Self.fileSize(at: temporaryURL)
+ guard outputSize > 0, outputSize <= request.maximumOutputBytes else {
+ throw RadrootsAppleMediaPreparationError.invalidRequest("normalized image exceeds its byte limit")
+ }
+ let digest = try RadrootsAppleFileDigest.sha256(at: temporaryURL)
+ let staged = try RadrootsStagedBlobReference(
+ blobID: digest, sizeBytes: outputSize, mediaType: "image/png", filenameHint: "\(digest).png")
+ let stagedURL = try roots.stagedBlobURL(for: staged)
+ try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
+ if fileManager.fileExists(atPath: stagedURL.path) {
+ let existingSize = try Self.fileSize(at: stagedURL)
+ let existingDigest = try RadrootsAppleFileDigest.sha256(at: stagedURL)
+ if existingSize != outputSize || existingDigest != digest {
+ try fileManager.removeItem(at: stagedURL)
+ try fileManager.moveItem(at: temporaryURL, to: stagedURL)
+ }
+ } else {
+ try fileManager.moveItem(at: temporaryURL, to: stagedURL)
+ }
+ #if os(iOS)
+ try fileManager.setAttributes(
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: stagedURL.path)
+ #endif
+ return try RadrootsApplePreparedImage(
+ file: staged, sha256: digest, width: UInt32(normalizedImage.width), height: UInt32(normalizedImage.height))
+ }
+
+ public func blossomUploadRequest(
+ preparedImage: RadrootsApplePreparedImage, remoteURL: URL, authorization: String,
+ networkPolicy: RadrootsBackgroundTransferNetworkPolicy = .publicHTTPS,
+ identifier: RadrootsBackgroundTransferIdentifier = .generated()
+ ) throws -> RadrootsBackgroundTransferRequest {
+ do {
+ let fileURL = try roots.stagedBlobURL(for: preparedImage.file)
+ guard try Self.fileSize(at: fileURL) == preparedImage.file.sizeBytes,
+ try RadrootsAppleFileDigest.sha256(at: fileURL) == preparedImage.sha256
+ else { throw RadrootsAppleMediaPreparationError.invalidRequest("prepared image no longer matches its commitment") }
+ return try RadrootsBackgroundTransferRequest(
+ identifier: identifier, remoteURL: remoteURL, method: .put, operation: .upload(source: .stagedBlob(preparedImage.file)),
+ headers: ["Authorization": authorization, "Content-Type": "image/png"],
+ metadata: ["purpose": "blossom_upload", "sha256": preparedImage.sha256], networkPolicy: networkPolicy,
+ responsePolicy: .boundedJSON(), expectedSourceSHA256: preparedImage.sha256)
+ } catch let error as RadrootsAppleMediaPreparationError { throw error } catch let error as RadrootsBackgroundTransferError {
+ throw error
+ } catch { throw RadrootsAppleMediaPreparationError.preparationFailure("prepared image commitment could not be verified") }
+ }
+
+ private func requireProtectedData() throws {
+ guard protectedData.currentState() == .available else {
+ throw RadrootsAppleMediaPreparationError.unavailable("image preparation protected data is unavailable")
+ }
+ }
+
+ private static func sourceDimensions(_ source: CGImageSource) throws -> (width: Int, height: Int, pixelCount: Int) {
+ guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
+ let width = (properties[kCGImagePropertyPixelWidth] as? NSNumber)?.intValue,
+ let height = (properties[kCGImagePropertyPixelHeight] as? NSNumber)?.intValue, width > 0, height > 0, width <= Int.max / height
+ else { throw RadrootsAppleMediaPreparationError.invalidRequest("image dimensions are invalid") }
+ return (width, height, width * height)
+ }
+
+ private static func fileSize(at url: URL) throws -> Int {
+ guard let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
+ throw RadrootsAppleMediaPreparationError.preparationFailure("prepared image size is unavailable")
+ }
+ return size
+ }
+
+}
+
+enum RadrootsAppleFileDigest {
+ static func sha256(at url: URL) throws -> String {
+ let handle = try FileHandle(forReadingFrom: url)
+ defer { try? handle.close() }
+ var hasher = CryptoKit.SHA256()
+ while true {
+ let chunk = try handle.read(upToCount: 64 * 1_024) ?? Data()
+ if chunk.isEmpty { break }
+ hasher.update(data: chunk)
+ }
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift
@@ -10,14 +10,10 @@ public enum RadrootsBackgroundTransferError: Error, Equatable, Sendable {
extension RadrootsBackgroundTransferError: LocalizedError {
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
+ case .invalidRequest(let message): message
+ case .unavailable(let message): message
+ case .transferFailure(let message): message
+ case .persistenceFailure(let message): message
}
}
}
@@ -25,20 +21,24 @@ extension RadrootsBackgroundTransferError: LocalizedError {
public struct RadrootsBackgroundTransferIdentifier: Sendable, Equatable, Hashable, Comparable, Codable {
public let rawValue: String
- public init(_ value: String) throws {
- self.rawValue = try RadrootsBackgroundTransferValidation.normalizedIdentifier(value)
- }
+ public init(_ value: String) throws { self.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 }
+
+ private init(validatedRawValue: String) { self.rawValue = validatedRawValue }
- public static func < (lhs: Self, rhs: Self) -> Bool {
- lhs.rawValue < rhs.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))
}
- private init(validatedRawValue: String) {
- self.rawValue = validatedRawValue
+ public func encode(to encoder: any Encoder) throws {
+ var values = encoder.container(keyedBy: CodingKeys.self)
+ try values.encode(rawValue, forKey: .rawValue)
}
}
@@ -64,41 +64,174 @@ public enum RadrootsBackgroundTransferState: String, Sendable, Equatable, Hashab
case completed
case failed
case cancelled
+ case interrupted
+}
+
+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...65_536).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 = 16_384) 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 <= 65_536 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 {
+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] = [:]
+ 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
- )
+ 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: try values.decodeIfPresent(RadrootsBackgroundTransferNetworkPolicy.self, forKey: .networkPolicy) ?? .publicHTTPS,
+ responsePolicy: try values.decodeIfPresent(RadrootsBackgroundTransferResponsePolicy.self, forKey: .responsePolicy) ?? .discard,
+ expectedSourceSHA256: try 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 RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, Codable {
+public struct RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, Codable, CustomDebugStringConvertible {
public let identifier: RadrootsBackgroundTransferIdentifier
public let request: RadrootsBackgroundTransferRequest
@@ -106,6 +239,31 @@ public struct RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, C
self.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 {
@@ -121,7 +279,8 @@ public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable,
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")
+ throw RadrootsBackgroundTransferError.invalidRequest(
+ "background transfer expected byte count cannot be less than transferred bytes")
}
}
self.bytesTransferred = bytesTransferred
@@ -134,33 +293,107 @@ public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable,
self.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 {
+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,
- updatedAt: Date = Date()
+ 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")
+ }
+ }
self.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: try 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 protocol RadrootsBackgroundTransferStore: Sendable {
@@ -175,96 +408,156 @@ public protocol RadrootsBackgroundTransfer: Sendable {
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 handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async
}
-public protocol RadrootsBackgroundTransferFileResolver: Sendable {
- func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL
-}
+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 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):
- try roots.resolvedURL(for: reference)
+ candidate = try roots.resolvedURL(for: reference)
+ root = roots.root(for: reference.scope)
case .stagedBlob(let blob):
- try roots.stagedBlobURL(for: 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 final class RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore, @unchecked Sendable {
+public actor RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore {
+ private struct Envelope: Codable {
+ let schemaVersion: Int
+ let snapshots: [RadrootsBackgroundTransferSnapshot]
+
+ init(snapshots: [RadrootsBackgroundTransferSnapshot]) {
+ self.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) {
+ public init(
+ roots: RadrootsAppleFileRoots, fileManager: FileManager = .default, protectedData: RadrootsProtectedDataProvider = .available
+ ) {
self.roots = roots
self.fileManager = fileManager
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
+ self.protectedData = protectedData
self.encoder.outputFormatting = [.sortedKeys]
}
public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
- let url = try storeURL()
- guard fileManager.fileExists(atPath: url.path) else {
- return []
- }
- let data = try Data(contentsOf: url)
- return try decoder.decode([RadrootsBackgroundTransferSnapshot].self, from: data)
- .sorted { left, right in
- left.identifier < right.identifier
+ 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 {
- var snapshots = try await loadSnapshots()
- snapshots.removeAll { $0.identifier == snapshot.identifier }
- snapshots.append(snapshot)
- try write(snapshots.sorted { left, right in
- left.identifier < right.identifier
- })
+ try requireProtectedData()
+ do {
+ var snapshots = try await loadSnapshots()
+ snapshots.removeAll { $0.identifier == snapshot.identifier }
+ snapshots.append(try 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 {
- var snapshots = try await loadSnapshots()
- snapshots.removeAll { $0.identifier == identifier }
- try write(snapshots)
+ 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 {
- let url = try storeURL()
- guard fileManager.fileExists(atPath: url.path) else {
- return
+ try requireProtectedData()
+ do {
+ for url in [try storeURL(), try 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")
}
- try fileManager.removeItem(at: url)
}
private func write(_ snapshots: [RadrootsBackgroundTransferSnapshot]) throws {
let url = try storeURL()
try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
- let data = try encoder.encode(snapshots)
+ 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: .cache,
- relativePath: "background_transfers/transfers.json"
- )
- )
+ 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")
+ }
}
}
@@ -292,10 +585,7 @@ public struct RadrootsUnavailableBackgroundTransfer: RadrootsBackgroundTransfer,
throw RadrootsBackgroundTransferError.unavailable(reason)
}
- public func handleEventsForBackgroundURLSession(
- identifier: String,
- completionHandler: @escaping @Sendable () -> Void
- ) async {
+ public func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async {
completionHandler()
}
}
@@ -309,11 +599,9 @@ public enum RadrootsBackgroundTransferValidation {
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.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")
@@ -322,58 +610,82 @@ public enum RadrootsBackgroundTransferValidation {
}
public static func validate(
- remoteURL: URL,
- method: RadrootsBackgroundTransferMethod,
- operation: RadrootsBackgroundTransferOperation,
- headers: [String: String],
- metadata: [String: String]
+ remoteURL: URL, method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation, headers: [String: String],
+ metadata: [String: String], networkPolicy: RadrootsBackgroundTransferNetworkPolicy,
+ responsePolicy: RadrootsBackgroundTransferResponsePolicy, expectedSourceSHA256: String?
) throws {
- try validate(remoteURL: remoteURL)
+ 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
- }
+ 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 !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) throws {
+ private static func validate(remoteURL: URL, networkPolicy: RadrootsBackgroundTransferNetworkPolicy) throws {
guard let components = URLComponents(url: remoteURL, resolvingAgainstBaseURL: false),
- components.scheme?.lowercased() == "https",
- components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
- components.user == nil,
- components.password == nil else {
- throw RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL must use https with a host and no credentials")
+ 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 {
+ private static func validate(method: RadrootsBackgroundTransferMethod, operation: RadrootsBackgroundTransferOperation) throws {
switch operation {
- case .download:
- guard method == .get else {
- throw RadrootsBackgroundTransferError.invalidRequest("background download transfers must use GET")
- }
- case .upload:
+ case .download(let destination):
+ guard method == .get else { throw RadrootsBackgroundTransferError.invalidRequest("background download transfers must use GET") }
+ 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") }
}
}
@@ -383,7 +695,10 @@ public enum RadrootsBackgroundTransferValidation {
}
for (key, value) in headers {
try validateSafeText(key, field: "background transfer header name", maximumLength: 80)
- try validateSafeText(value, field: "background transfer header value", maximumLength: 500)
+ 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: 8_192)
}
}
@@ -394,17 +709,39 @@ public enum RadrootsBackgroundTransferValidation {
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") }
}
}
- 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")
+ 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")
}
- guard trimmed.count <= maximumLength else {
- throw RadrootsBackgroundTransferError.invalidRequest("\(field) is too long")
+ 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")
}
@@ -414,3 +751,12 @@ public enum RadrootsBackgroundTransferValidation {
value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) }
}
}
+
+extension RadrootsBackgroundTransferOperation {
+ fileprivate var redactedLabel: String {
+ switch self {
+ case .download: "download"
+ case .upload: "upload"
+ }
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift
@@ -1,6 +1,7 @@
import Foundation
-import Testing
import RadrootsKitTesting
+import Testing
+
@testable import RadrootsKit
@Test func appleBackgroundTransferPersistsRunningSnapshotAfterEnqueue() async throws {
@@ -18,22 +19,34 @@ import RadrootsKitTesting
#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")
+
+ _ = 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])
+}
+
@Test func appleBackgroundTransferRecordsFailedSnapshotWhenAdapterRejectsEnqueue() async throws {
let store = RadrootsInMemoryBackgroundTransferStore()
let probe = RadrootsAppleBackgroundTransferProbe(
- now: Date(timeIntervalSince1970: 200),
- enqueueOutcome: .failure(.transferFailure("adapter rejected transfer"))
- )
+ 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("adapter rejected transfer")) {
+ 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 == "adapter rejected transfer")
+ #expect(snapshot?.errorMessage == "background_transfer_enqueue_failed")
#expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 200))
}
@@ -53,16 +66,9 @@ import RadrootsKitTesting
@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 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 probe = RadrootsAppleBackgroundTransferProbe(now: Date(timeIntervalSince1970: 400), activeIdentifiers: [request.identifier])
let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: probe.adapters())
let snapshots = try await transfer.snapshots()
@@ -73,12 +79,26 @@ import RadrootsKitTesting
#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))
+}
+
@Test func appleBackgroundTransferForwardsBackgroundCompletionHandlers() async throws {
let probe = RadrootsAppleBackgroundTransferProbe()
- let transfer = RadrootsAppleBackgroundTransfer(
- store: RadrootsInMemoryBackgroundTransferStore(),
- adapters: probe.adapters()
- )
+ let transfer = RadrootsAppleBackgroundTransfer(store: RadrootsInMemoryBackgroundTransferStore(), adapters: probe.adapters())
let completion = RadrootsCompletionProbe()
await transfer.handleEventsForBackgroundURLSession(identifier: "org.radroots.field-ios.background.transfer") {
@@ -94,17 +114,10 @@ import RadrootsKitTesting
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) }
- )
+ 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)
- )
+ 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)
@@ -113,18 +126,15 @@ import RadrootsKitTesting
try payload.write(to: stagedFile)
await coordinator.complete(
- identifier: request.identifier,
- platformError: nil,
- stagedDownloadResult: .file(stagedFile),
- bytesTransferred: 0,
- totalBytesExpected: nil
- )
+ identifier: request.identifier, platformError: nil, stagedDownloadResult: .file(stagedFile), httpResult: successfulHTTPResult(),
+ 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 == .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))
}
@@ -134,31 +144,19 @@ import RadrootsKitTesting
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) }
- )
+ 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(
- try RadrootsBackgroundTransferSnapshot(
- request: request,
- state: .running,
- updatedAt: Date(timeIntervalSince1970: 1)
- )
- )
+ try RadrootsBackgroundTransferSnapshot(request: request, state: .running, updatedAt: Date(timeIntervalSince1970: 1)))
await coordinator.complete(
- identifier: request.identifier,
- platformError: nil,
- stagedDownloadResult: .failure("temporary file unavailable"),
- bytesTransferred: 0,
- totalBytesExpected: nil
- )
+ 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 == "temporary file unavailable")
+ #expect(snapshot?.errorMessage == "background_transfer_download_staging_failure")
#expect(snapshot?.updatedAt == Date(timeIntervalSince1970: 600))
}
@@ -167,38 +165,117 @@ import RadrootsKitTesting
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) }
- )
+ 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(
- try RadrootsBackgroundTransferSnapshot(
- request: request,
- state: .running,
- updatedAt: Date(timeIntervalSince1970: 1)
- )
- )
-
- await coordinator.updateProgress(
- identifier: request.identifier,
- bytesTransferred: 4,
- totalBytesExpected: 10
- )
+ try 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,
- bytesTransferred: 10,
- totalBytesExpected: 10
- )
+ 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)
+}
+
+@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: 1_024))
+ try await store.saveSnapshot(try 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)
+}
+
+@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(try 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(try 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(try 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(try 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 {
@@ -206,25 +283,37 @@ import RadrootsKitTesting
let store = RadrootsInMemoryBackgroundTransferStore()
let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
let coordinator = RadrootsAppleBackgroundTransferCoordinator(
- sessionIdentifier: "org.radroots.field-ios.background.transfer",
- store: store,
- fileResolver: resolver
- )
+ 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()
- }
+ 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()
- }
+ 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() }
+
+ #expect(completion.completionCount == 1)
}
private actor RadrootsAppleBackgroundTransferProbe {
@@ -236,8 +325,7 @@ private actor RadrootsAppleBackgroundTransferProbe {
private var handledBackgroundEventIdentifiersValue: [String]
init(
- now: Date = Date(timeIntervalSince1970: 0),
- enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()),
+ now: Date = Date(timeIntervalSince1970: 0), enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()),
activeIdentifiers: Set<RadrootsBackgroundTransferIdentifier> = []
) {
self.nowValue = now
@@ -250,31 +338,18 @@ private actor RadrootsAppleBackgroundTransferProbe {
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()
- },
+ now: { self.nowValue }, enqueue: { request in try await self.enqueue(request) },
+ cancel: { identifier in await self.cancel(identifier) }, activeTransferIdentifiers: { await self.activeIdentifiers() },
handleBackgroundEvents: { identifier, completionHandler in
await self.handleBackgroundEvents(identifier: identifier, completionHandler: completionHandler)
- }
- )
+ })
}
private func enqueue(_ request: RadrootsBackgroundTransferRequest) throws {
enqueuedRequestsValue.append(request)
switch enqueueOutcome {
- case .success:
- activeIdentifiersValue.insert(request.identifier)
- case .failure(let error):
- throw error
+ case .success: activeIdentifiersValue.insert(request.identifier)
+ case .failure(let error): throw error
}
}
@@ -283,72 +358,83 @@ private actor RadrootsAppleBackgroundTransferProbe {
activeIdentifiersValue.remove(identifier)
}
- private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> {
- activeIdentifiersValue
- }
+ private func activeIdentifiers() -> Set<RadrootsBackgroundTransferIdentifier> { activeIdentifiersValue }
- private func handleBackgroundEvents(
- identifier: String,
- completionHandler: @escaping @Sendable () -> Void
- ) {
+ private func handleBackgroundEvents(identifier: String, completionHandler: @escaping @Sendable () -> Void) {
handledBackgroundEventIdentifiersValue.append(identifier)
completionHandler()
}
- var enqueuedRequests: [RadrootsBackgroundTransferRequest] {
- enqueuedRequestsValue
- }
+ var enqueuedRequests: [RadrootsBackgroundTransferRequest] { enqueuedRequestsValue }
- var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] {
- cancelledIdentifiersValue
- }
+ var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] { cancelledIdentifiersValue }
- var handledBackgroundEventIdentifiers: [String] {
- handledBackgroundEventIdentifiersValue
- }
+ var handledBackgroundEventIdentifiers: [String] { handledBackgroundEventIdentifiersValue }
}
private final class RadrootsCompletionProbe: @unchecked Sendable {
- private var completedValue = false
+ private let lock = NSLock()
+ private var completionCountValue = 0
func markCompleted() {
- completedValue = true
+ lock.lock()
+ completionCountValue += 1
+ lock.unlock()
}
- var completed: Bool {
- completedValue
+ 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) { self.stateValue = state }
+
+ var state: RadrootsProtectedDataState {
+ get {
+ lock.lock()
+ defer { lock.unlock() }
+ return stateValue
+ }
+ 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"))
- )
- )
+ 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) throws -> RadrootsBackgroundTransferRequest {
+private func appleUploadRequest(identifier: String, responsePolicy: RadrootsBackgroundTransferResponsePolicy = .discard) 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"))
- )
- )
+ 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)
+}
+
+private func successfulHTTPResult() -> RadrootsBackgroundHTTPResult {
+ 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)
+ 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),
+ appIdentifier: "org.radroots.tests", dataRoot: root.appendingPathComponent("data", isDirectory: true),
cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
- temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true)
- )
+ temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true))
}
diff --git a/Tests/RadrootsKitTests/RadrootsAppleMediaPreparationTests.swift b/Tests/RadrootsKitTests/RadrootsAppleMediaPreparationTests.swift
@@ -0,0 +1,105 @@
+import CoreGraphics
+import Foundation
+import ImageIO
+import Testing
+import UniformTypeIdentifiers
+
+@testable import RadrootsKit
+
+@Test func appleMediaPreparationNormalizesAndCommitsStableFinalBytes() async throws {
+ let roots = try mediaPreparationRoots()
+ let sourceReference = RadrootsFileReference(scope: .cache, relativePath: "capture/source.jpg")
+ let sourceURL = try roots.resolvedURL(for: sourceReference)
+ try writeOrientedImageWithMetadata(to: sourceURL)
+ let preparer = RadrootsAppleMediaPreparer(roots: roots)
+ let request = try RadrootsAppleImagePreparationRequest(source: .file(sourceReference))
+
+ let first = try await preparer.prepareImage(request)
+ let second = try await preparer.prepareImage(request)
+
+ #expect(first == second)
+ #expect(first.width == 3)
+ #expect(first.height == 2)
+ #expect(first.file.mediaType == "image/png")
+ #expect(first.file.sizeBytes > 0)
+ #expect(first.sha256.count == 64)
+ #expect(!first.debugDescription.contains(roots.temporaryRoot.path))
+ let outputURL = try roots.stagedBlobURL(for: first.file)
+ let outputSource = try #require(CGImageSourceCreateWithURL(outputURL as CFURL, nil))
+ let outputProperties = try #require(CGImageSourceCopyPropertiesAtIndex(outputSource, 0, nil) as? [CFString: Any])
+ #expect(outputProperties[kCGImagePropertyGPSDictionary] == nil)
+ #expect((outputProperties[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1 == 1)
+}
+
+@Test func appleMediaPreparationBuildsBoundedBlossomUploadRequest() async throws {
+ let roots = try mediaPreparationRoots()
+ let sourceReference = RadrootsFileReference(scope: .cache, relativePath: "capture/source.jpg")
+ try writeOrientedImageWithMetadata(to: roots.resolvedURL(for: sourceReference))
+ let preparer = RadrootsAppleMediaPreparer(roots: roots)
+ let prepared = try await preparer.prepareImage(RadrootsAppleImagePreparationRequest(source: .file(sourceReference)))
+
+ let request = try await preparer.blossomUploadRequest(
+ preparedImage: prepared, remoteURL: URL(string: "https://blossom.radroots.org/upload")!, authorization: "Nostr signed-event",
+ identifier: RadrootsBackgroundTransferIdentifier("field.media.upload"))
+
+ #expect(request.method == .put)
+ #expect(request.operation == .upload(source: .stagedBlob(prepared.file)))
+ #expect(request.headers["Authorization"] == "Nostr signed-event")
+ #expect(request.headers["Content-Type"] == "image/png")
+ #expect(request.metadata["sha256"] == prepared.sha256)
+ #expect(request.responsePolicy == (try .boundedJSON()))
+ #expect(request.expectedSourceSHA256 == prepared.sha256)
+
+ let preparedURL = try roots.stagedBlobURL(for: prepared.file)
+ var tampered = try Data(contentsOf: preparedURL)
+ tampered[tampered.startIndex] ^= 0x01
+ try tampered.write(to: preparedURL, options: .atomic)
+ await #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest("prepared image no longer matches its commitment")) {
+ _ = try await preparer.blossomUploadRequest(
+ preparedImage: prepared, remoteURL: URL(string: "https://blossom.radroots.org/upload")!, authorization: "Nostr signed-event")
+ }
+}
+
+@Test func appleMediaPreparationEnforcesByteAndProtectedDataBounds() async throws {
+ let roots = try mediaPreparationRoots()
+ let sourceReference = RadrootsFileReference(scope: .cache, relativePath: "capture/source.jpg")
+ try writeOrientedImageWithMetadata(to: roots.resolvedURL(for: sourceReference))
+
+ let bounded = RadrootsAppleMediaPreparer(roots: roots)
+ await #expect(throws: RadrootsAppleMediaPreparationError.invalidRequest("image source is unavailable or exceeds its byte limit")) {
+ _ = try await bounded.prepareImage(RadrootsAppleImagePreparationRequest(source: .file(sourceReference), maximumInputBytes: 1))
+ }
+
+ let locked = RadrootsAppleMediaPreparer(roots: roots, protectedData: RadrootsProtectedDataProvider { .locked })
+ await #expect(throws: RadrootsAppleMediaPreparationError.unavailable("image preparation protected data is unavailable")) {
+ _ = try await locked.prepareImage(RadrootsAppleImagePreparationRequest(source: .file(sourceReference)))
+ }
+}
+
+private func writeOrientedImageWithMetadata(to url: URL) throws {
+ let colorSpace = CGColorSpaceCreateDeviceRGB()
+ let context = try #require(
+ CGContext(
+ data: nil, width: 2, height: 3, bitsPerComponent: 8, bytesPerRow: 8, space: colorSpace,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue))
+ context.setFillColor(CGColor(red: 0.2, green: 0.7, blue: 0.3, alpha: 1))
+ context.fill(CGRect(x: 0, y: 0, width: 2, height: 3))
+ let image = try #require(context.makeImage())
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ let destination = try #require(CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil))
+ let properties: [CFString: Any] = [
+ kCGImagePropertyOrientation: 6, kCGImagePropertyGPSDictionary: [kCGImagePropertyGPSLatitude: 45.0],
+ kCGImageDestinationLossyCompressionQuality: 0.9
+ ]
+ CGImageDestinationAddImage(destination, image, properties as CFDictionary)
+ try #require(CGImageDestinationFinalize(destination))
+}
+
+private func mediaPreparationRoots() throws -> RadrootsAppleFileRoots {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "radroots-media-preparation-\(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,5 +1,6 @@
import Foundation
import Testing
+
@testable import RadrootsKit
@Test func backgroundTransferIdentifierNormalizesAndRejectsUnsafeValues() throws {
@@ -10,74 +11,72 @@ import Testing
#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 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 destination = RadrootsBackgroundTransferLocalFile.file(RadrootsFileReference(scope: .cache, relativePath: "downloads/relay.json"))
let request = try RadrootsBackgroundTransferRequest(
identifier: RadrootsBackgroundTransferIdentifier("field.transfer.download"),
- remoteURL: URL(string: "https://radroots.org/relay.json")!,
- method: .get,
- operation: .download(destination: destination),
- headers: ["Accept": "application/json"],
- metadata: ["purpose": "diagnostics"]
- )
+ remoteURL: 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")
- #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer remote URL must use https with a host and no credentials")) {
+ let longAuthorization = String(repeating: "a", count: 2_048)
+ let authorized = try RadrootsBackgroundTransferRequest(
+ remoteURL: 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: URL(string: "http://radroots.org/relay.json")!,
- method: .get,
- operation: .download(destination: destination)
- )
+ remoteURL: 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: URL(string: "https://radroots.org/relay.json?token=secret")!, method: .get,
+ operation: .download(destination: destination))
+ }
+ let simulatorRequest = try RadrootsBackgroundTransferRequest(
+ remoteURL: 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: URL(string: "https://radroots.org/relay.json")!,
- method: .post,
- operation: .download(destination: destination)
- )
+ remoteURL: 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: URL(string: "https://radroots.org/relay.json")!,
- method: .get,
- operation: .download(destination: destination),
- headers: ["Accept": "application/json\ntext/plain"]
- )
+ remoteURL: 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: URL(string: "https://radroots.org/relay.json")!, method: .get, operation: .download(destination: destination),
+ headers: ["Content-Length": "4"])
}
}
@Test func backgroundTransferUploadRequiresUploadMethod() throws {
- let source = RadrootsBackgroundTransferLocalFile.stagedBlob(
- try RadrootsStagedBlobReference(blobID: "upload", sizeBytes: 12)
- )
+ let source = RadrootsBackgroundTransferLocalFile.stagedBlob(try RadrootsStagedBlobReference(blobID: "upload", sizeBytes: 12))
let request = try RadrootsBackgroundTransferRequest(
- remoteURL: URL(string: "https://radroots.org/upload")!,
- method: .put,
- operation: .upload(source: source)
- )
+ remoteURL: 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: URL(string: "https://radroots.org/upload")!,
- method: .get,
- operation: .upload(source: source)
- )
+ remoteURL: URL(string: "https://radroots.org/upload")!, method: .get, operation: .upload(source: source))
}
}
@@ -85,40 +84,95 @@ import Testing
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)
- )
+ 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")) {
- _ = try RadrootsBackgroundTransferProgress(bytesTransferred: 10, totalBytesExpected: 5)
- }
+ #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 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: 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(try 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))
+}
+
+@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()
}
}
@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 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")))
}
@@ -131,23 +185,15 @@ import Testing
let roots = try testBackgroundTransferRoots()
let store = RadrootsAppleBackgroundTransferStore(roots: roots)
let first = try RadrootsBackgroundTransferSnapshot(
- request: testDownloadRequest(identifier: "field.transfer.b"),
- updatedAt: Date(timeIntervalSince1970: 2)
- )
+ 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)
- )
+ 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"
- ])
+ #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"])
@@ -156,6 +202,40 @@ import Testing
#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(try RadrootsBackgroundTransferSnapshot(request: request))
+ }
+ }
+ try await group.waitForAll()
+ }
+
+ #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: URL(string: "https://blossom.radroots.org/upload")!, method: .put,
+ operation: .upload(source: .file(RadrootsFileReference(scope: .cache, relativePath: "prepared.png"))),
+ responsePolicy: .boundedJSON(maximumBodyBytes: 1_024))
+ let response = try RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: "application/json", body: body)
+ try await store.saveSnapshot(try 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")
@@ -176,22 +256,15 @@ import Testing
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"))
- )
- )
+ 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)
+ 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),
+ appIdentifier: "org.radroots.tests", dataRoot: root.appendingPathComponent("data", isDirectory: true),
cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
- temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true)
- )
+ temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true))
}