apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

commit 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:
MPackage.swift | 3+++
MSources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift | 885+++++++++++++++++++++++++++++++++++++++----------------------------------------
ASources/RadrootsKit/RadrootsAppleMediaPreparation.swift | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsBackgroundTransfer.swift | 600++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
MTests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift | 380++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
ATests/RadrootsKitTests/RadrootsAppleMediaPreparationTests.swift | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift | 227++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
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)) }