commit f87bc1bc95cc3b6d1d8309a6c5de5eb6b2d13cd0
parent d03a020cf684a868ec643e162acab241e9370dc4
Author: triesap <tyson@radroots.org>
Date: Wed, 17 Jun 2026 17:09:52 -0700
apple-kit: add background transfer contracts
- add transfer request, snapshot, progress, and store contracts
- add file-root and staged-blob resolver support
- persist transfer snapshots through AppleKit file roots
- cover fake transfer behavior and recovery tests
Diffstat:
5 files changed, 822 insertions(+), 5 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift
@@ -0,0 +1,405 @@
+import Foundation
+
+public enum RadrootsBackgroundTransferError: Error, Equatable, Sendable {
+ case invalidRequest(String)
+ case unavailable(String)
+ case transferFailure(String)
+ case persistenceFailure(String)
+}
+
+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
+ }
+ }
+}
+
+public struct RadrootsBackgroundTransferIdentifier: Sendable, Equatable, Hashable, Comparable, Codable {
+ public let rawValue: String
+
+ public init(_ value: String) throws {
+ self.rawValue = try RadrootsBackgroundTransferValidation.normalizedIdentifier(value)
+ }
+
+ 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 enum RadrootsBackgroundTransferMethod: String, Sendable, Equatable, Hashable, Codable, CaseIterable {
+ case get = "GET"
+ case post = "POST"
+ case put = "PUT"
+}
+
+public enum RadrootsBackgroundTransferLocalFile: Sendable, Equatable, Hashable, Codable {
+ case file(RadrootsFileReference)
+ case stagedBlob(RadrootsStagedBlobReference)
+}
+
+public enum RadrootsBackgroundTransferOperation: Sendable, Equatable, Hashable, Codable {
+ case download(destination: RadrootsBackgroundTransferLocalFile)
+ case upload(source: RadrootsBackgroundTransferLocalFile)
+}
+
+public enum RadrootsBackgroundTransferState: String, Sendable, Equatable, Hashable, Codable, CaseIterable {
+ case queued
+ case running
+ case completed
+ case failed
+ case cancelled
+}
+
+public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, Codable {
+ 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 init(
+ identifier: RadrootsBackgroundTransferIdentifier = .generated(),
+ remoteURL: URL,
+ method: RadrootsBackgroundTransferMethod,
+ operation: RadrootsBackgroundTransferOperation,
+ headers: [String: String] = [:],
+ metadata: [String: String] = [:]
+ ) throws {
+ try RadrootsBackgroundTransferValidation.validate(
+ remoteURL: remoteURL,
+ method: method,
+ operation: operation,
+ headers: headers,
+ metadata: metadata
+ )
+ self.identifier = identifier
+ self.remoteURL = remoteURL
+ self.method = method
+ self.operation = operation
+ self.headers = headers
+ self.metadata = metadata
+ }
+}
+
+public struct RadrootsBackgroundTransferHandle: Sendable, Equatable, Hashable, Codable {
+ public let identifier: RadrootsBackgroundTransferIdentifier
+ public let request: RadrootsBackgroundTransferRequest
+
+ public init(request: RadrootsBackgroundTransferRequest) {
+ self.identifier = request.identifier
+ self.request = request
+ }
+}
+
+public struct RadrootsBackgroundTransferProgress: Sendable, Equatable, Hashable, Codable {
+ public let bytesTransferred: Int64
+ public let totalBytesExpected: Int64?
+
+ public init(bytesTransferred: Int64, totalBytesExpected: Int64? = nil) throws {
+ guard bytesTransferred >= 0 else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer bytes transferred cannot be negative")
+ }
+ if let totalBytesExpected {
+ guard totalBytesExpected >= 0 else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer expected byte count cannot be negative")
+ }
+ guard totalBytesExpected >= bytesTransferred else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer expected byte count cannot be less than transferred bytes")
+ }
+ }
+ self.bytesTransferred = bytesTransferred
+ self.totalBytesExpected = totalBytesExpected
+ }
+
+ public static let zero = RadrootsBackgroundTransferProgress(validatedBytesTransferred: 0, totalBytesExpected: nil)
+
+ private init(validatedBytesTransferred: Int64, totalBytesExpected: Int64?) {
+ self.bytesTransferred = validatedBytesTransferred
+ self.totalBytesExpected = totalBytesExpected
+ }
+}
+
+public struct RadrootsBackgroundTransferSnapshot: Sendable, Equatable, Hashable, Codable {
+ public let identifier: RadrootsBackgroundTransferIdentifier
+ public let request: RadrootsBackgroundTransferRequest
+ public let state: RadrootsBackgroundTransferState
+ public let progress: RadrootsBackgroundTransferProgress
+ public let errorMessage: String?
+ public let updatedAt: Date
+
+ public init(
+ request: RadrootsBackgroundTransferRequest,
+ state: RadrootsBackgroundTransferState = .queued,
+ progress: RadrootsBackgroundTransferProgress = .zero,
+ errorMessage: String? = nil,
+ updatedAt: Date = Date()
+ ) throws {
+ guard updatedAt.timeIntervalSinceReferenceDate.isFinite else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer updated date must be finite")
+ }
+ self.identifier = request.identifier
+ self.request = request
+ self.state = state
+ self.progress = progress
+ self.errorMessage = try RadrootsBackgroundTransferValidation.normalizedOptionalMessage(errorMessage)
+ self.updatedAt = updatedAt
+ }
+}
+
+public protocol RadrootsBackgroundTransferStore: Sendable {
+ func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot]
+ func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws
+ func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws
+ func removeAllSnapshots() async throws
+}
+
+public protocol RadrootsBackgroundTransfer: Sendable {
+ func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle
+ func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws
+ func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot?
+ func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot]
+}
+
+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 func resolve(_ file: RadrootsBackgroundTransferLocalFile) throws -> URL {
+ switch file {
+ case .file(let reference):
+ try roots.resolvedURL(for: reference)
+ case .stagedBlob(let blob):
+ try roots.stagedBlobURL(for: blob)
+ }
+ }
+}
+
+public final class RadrootsAppleBackgroundTransferStore: RadrootsBackgroundTransferStore, @unchecked Sendable {
+ private let roots: RadrootsAppleFileRoots
+ private let fileManager: FileManager
+ private let encoder: JSONEncoder
+ private let decoder: JSONDecoder
+
+ public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) {
+ self.roots = roots
+ self.fileManager = fileManager
+ self.encoder = JSONEncoder()
+ self.decoder = JSONDecoder()
+ 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
+ }
+ }
+
+ 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
+ })
+ }
+
+ public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ var snapshots = try await loadSnapshots()
+ snapshots.removeAll { $0.identifier == identifier }
+ try write(snapshots)
+ }
+
+ public func removeAllSnapshots() async throws {
+ let url = try storeURL()
+ guard fileManager.fileExists(atPath: url.path) else {
+ return
+ }
+ 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)
+ try data.write(to: url, options: [.atomic])
+ }
+
+ private func storeURL() throws -> URL {
+ try roots.resolvedURL(
+ for: RadrootsFileReference(
+ scope: .cache,
+ relativePath: "background_transfers/transfers.json"
+ )
+ )
+ }
+}
+
+public struct RadrootsUnavailableBackgroundTransfer: RadrootsBackgroundTransfer, Sendable {
+ private let reason: String
+
+ public init(reason: String = "background transfer is unavailable on this platform") {
+ let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.reason = trimmedReason.isEmpty ? "background transfer is unavailable on this platform" : trimmedReason
+ }
+
+ public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle {
+ throw RadrootsBackgroundTransferError.unavailable(reason)
+ }
+
+ public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ throw RadrootsBackgroundTransferError.unavailable(reason)
+ }
+
+ public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? {
+ throw RadrootsBackgroundTransferError.unavailable(reason)
+ }
+
+ public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ throw RadrootsBackgroundTransferError.unavailable(reason)
+ }
+}
+
+public enum RadrootsBackgroundTransferValidation {
+ public static func normalizedIdentifier(_ value: String) throws -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !trimmed.isEmpty else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must not be empty")
+ }
+ guard trimmed.count <= 128 else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier is too long")
+ }
+ guard trimmed.range(
+ of: "^[a-z0-9][a-z0-9._-]*[a-z0-9]$|^[a-z0-9]$",
+ options: .regularExpression
+ ) != nil else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must use lowercase safe identifier characters")
+ }
+ guard !trimmed.contains("..") else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer identifier cannot contain empty path components")
+ }
+ return trimmed
+ }
+
+ public static func validate(
+ remoteURL: URL,
+ method: RadrootsBackgroundTransferMethod,
+ operation: RadrootsBackgroundTransferOperation,
+ headers: [String: String],
+ metadata: [String: String]
+ ) throws {
+ try validate(remoteURL: remoteURL)
+ try validate(method: method, operation: operation)
+ try validate(headers: headers)
+ try validate(metadata: metadata)
+ }
+
+ public static func normalizedOptionalMessage(_ value: String?) throws -> String? {
+ guard let value else {
+ return nil
+ }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ return nil
+ }
+ guard trimmed.count <= 240 else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer message is too long")
+ }
+ guard doesNotContainControlCharacters(trimmed) else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer message cannot contain control characters")
+ }
+ return trimmed
+ }
+
+ private static func validate(remoteURL: URL) 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")
+ }
+ }
+
+ 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:
+ guard method == .post || method == .put else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background upload transfers must use POST or PUT")
+ }
+ }
+ }
+
+ private static func validate(headers: [String: String]) throws {
+ guard headers.count <= 32 else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer header count is too large")
+ }
+ for (key, value) in headers {
+ try validateSafeText(key, field: "background transfer header name", maximumLength: 80)
+ try validateSafeText(value, field: "background transfer header value", maximumLength: 500)
+ }
+ }
+
+ private static func validate(metadata: [String: String]) throws {
+ guard metadata.count <= 32 else {
+ throw RadrootsBackgroundTransferError.invalidRequest("background transfer metadata count is too large")
+ }
+ for (key, value) in metadata {
+ try validateSafeText(key, field: "background transfer metadata key", maximumLength: 80)
+ try validateSafeText(value, field: "background transfer metadata value", maximumLength: 500)
+ }
+ }
+
+ private static func validateSafeText(_ value: String, field: String, maximumLength: Int) throws {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ throw RadrootsBackgroundTransferError.invalidRequest("\(field) must not be empty")
+ }
+ guard trimmed.count <= maximumLength else {
+ throw RadrootsBackgroundTransferError.invalidRequest("\(field) is too long")
+ }
+ guard doesNotContainControlCharacters(trimmed) else {
+ throw RadrootsBackgroundTransferError.invalidRequest("\(field) cannot contain control characters")
+ }
+ }
+
+ private static func doesNotContainControlCharacters(_ value: String) -> Bool {
+ value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) }
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsFileAccess.swift b/Sources/RadrootsKit/RadrootsFileAccess.swift
@@ -1,13 +1,13 @@
import Foundation
-public enum RadrootsFileScope: Sendable, Equatable, CaseIterable {
+public enum RadrootsFileScope: Sendable, Equatable, CaseIterable, Codable {
case data
case cache
case temporary
case logs
}
-public struct RadrootsFileReference: Sendable, Equatable, Hashable {
+public struct RadrootsFileReference: Sendable, Equatable, Hashable, Codable {
public let scope: RadrootsFileScope
public let relativePath: String
@@ -39,7 +39,7 @@ public struct RadrootsFileEntry: Sendable, Equatable, Hashable {
}
}
-public struct RadrootsStagedBlobReference: Sendable, Equatable, Hashable {
+public struct RadrootsStagedBlobReference: Sendable, Equatable, Hashable, Codable {
public let blobID: String
public let sizeBytes: Int
public let mediaType: String?
@@ -413,8 +413,7 @@ public final class RadrootsAppleFileAccess: RadrootsFileAccess {
}
private func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL {
- let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID)
- return roots.stagedBlobsRoot.appendingPathComponent(normalizedBlobID).standardizedFileURL
+ try roots.stagedBlobURL(for: blob)
}
private var preparedExportsRoot: URL {
@@ -616,6 +615,11 @@ public struct RadrootsAppleFileRoots: Sendable, Equatable {
return candidateURL
}
+ public func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL {
+ let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID)
+ return stagedBlobsRoot.appendingPathComponent(normalizedBlobID).standardizedFileURL
+ }
+
public static func normalizedAppIdentifier(_ appIdentifier: String) throws -> String {
let trimmed = appIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
diff --git a/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift b/Sources/RadrootsKitTesting/RadrootsBackgroundTransferTesting.swift
@@ -0,0 +1,124 @@
+import Foundation
+import RadrootsKit
+
+public actor RadrootsInMemoryBackgroundTransferStore: RadrootsBackgroundTransferStore {
+ private var snapshotsByIdentifier: [RadrootsBackgroundTransferIdentifier: RadrootsBackgroundTransferSnapshot]
+
+ public init(snapshots: [RadrootsBackgroundTransferSnapshot] = []) {
+ self.snapshotsByIdentifier = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.identifier, $0) })
+ }
+
+ public func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ snapshotsByIdentifier.values.sorted { left, right in
+ left.identifier < right.identifier
+ }
+ }
+
+ public func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws {
+ snapshotsByIdentifier[snapshot.identifier] = snapshot
+ }
+
+ public func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ snapshotsByIdentifier.removeValue(forKey: identifier)
+ }
+
+ public func removeAllSnapshots() async throws {
+ snapshotsByIdentifier.removeAll()
+ }
+}
+
+public actor RadrootsFakeBackgroundTransfer: RadrootsBackgroundTransfer {
+ private let store: any RadrootsBackgroundTransferStore
+ private var enqueueOutcome: Result<Void, RadrootsBackgroundTransferError>
+ private var enqueuedRequestsValue: [RadrootsBackgroundTransferRequest]
+ private var cancelledIdentifiersValue: [RadrootsBackgroundTransferIdentifier]
+ private let updatedAt: Date
+
+ public init(
+ store: any RadrootsBackgroundTransferStore = RadrootsInMemoryBackgroundTransferStore(),
+ enqueueOutcome: Result<Void, RadrootsBackgroundTransferError> = .success(()),
+ updatedAt: Date = Date(timeIntervalSince1970: 0)
+ ) {
+ self.store = store
+ self.enqueueOutcome = enqueueOutcome
+ self.enqueuedRequestsValue = []
+ self.cancelledIdentifiersValue = []
+ self.updatedAt = updatedAt
+ }
+
+ public func setEnqueueOutcome(_ outcome: Result<Void, RadrootsBackgroundTransferError>) {
+ enqueueOutcome = outcome
+ }
+
+ public func enqueue(_ request: RadrootsBackgroundTransferRequest) async throws -> RadrootsBackgroundTransferHandle {
+ enqueuedRequestsValue.append(request)
+ switch enqueueOutcome {
+ case .success:
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .queued,
+ updatedAt: updatedAt
+ )
+ try await store.saveSnapshot(snapshot)
+ return RadrootsBackgroundTransferHandle(request: request)
+ case .failure(let error):
+ throw error
+ }
+ }
+
+ public func cancel(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ cancelledIdentifiersValue.append(identifier)
+ if let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) {
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .cancelled,
+ progress: existing.progress,
+ updatedAt: updatedAt
+ )
+ try await store.saveSnapshot(snapshot)
+ }
+ }
+
+ public func complete(_ identifier: RadrootsBackgroundTransferIdentifier) async throws {
+ guard let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) else {
+ throw RadrootsBackgroundTransferError.transferFailure("background transfer snapshot not found")
+ }
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .completed,
+ progress: existing.progress,
+ updatedAt: updatedAt
+ )
+ try await store.saveSnapshot(snapshot)
+ }
+
+ public func fail(_ identifier: RadrootsBackgroundTransferIdentifier, message: String) async throws {
+ guard let existing = try await store.loadSnapshots().first(where: { $0.identifier == identifier }) else {
+ throw RadrootsBackgroundTransferError.transferFailure("background transfer snapshot not found")
+ }
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: existing.request,
+ state: .failed,
+ progress: existing.progress,
+ errorMessage: message,
+ updatedAt: updatedAt
+ )
+ try await store.saveSnapshot(snapshot)
+ }
+
+ public func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot? {
+ try await store.loadSnapshots().first { $0.identifier == identifier }
+ }
+
+ public func snapshots() async throws -> [RadrootsBackgroundTransferSnapshot] {
+ try await store.loadSnapshots()
+ }
+
+ public var enqueuedRequests: [RadrootsBackgroundTransferRequest] {
+ enqueuedRequestsValue
+ }
+
+ public var cancelledIdentifiers: [RadrootsBackgroundTransferIdentifier] {
+ cancelledIdentifiersValue
+ }
+}
diff --git a/Tests/RadrootsKitTestingTests/RadrootsBackgroundTransferTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsBackgroundTransferTestingTests.swift
@@ -0,0 +1,87 @@
+import Foundation
+import Testing
+import RadrootsKit
+import RadrootsKitTesting
+
+@Test func inMemoryBackgroundTransferStorePersistsSnapshotsInIdentifierOrder() async throws {
+ let first = try RadrootsBackgroundTransferSnapshot(
+ request: testTransferRequest(identifier: "field.transfer.b")
+ )
+ let second = try RadrootsBackgroundTransferSnapshot(
+ request: testTransferRequest(identifier: "field.transfer.a")
+ )
+ let store = RadrootsInMemoryBackgroundTransferStore()
+
+ try await store.saveSnapshot(first)
+ try await store.saveSnapshot(second)
+
+ #expect(try await store.loadSnapshots().map(\.identifier.rawValue) == [
+ "field.transfer.a",
+ "field.transfer.b"
+ ])
+
+ try await store.removeSnapshot(for: second.identifier)
+ #expect(try await store.loadSnapshots() == [first])
+
+ try await store.removeAllSnapshots()
+ #expect(try await store.loadSnapshots().isEmpty)
+}
+
+@Test func fakeBackgroundTransferEnqueuesSnapshotsAndHandlesCancellation() async throws {
+ let transfer = RadrootsFakeBackgroundTransfer(
+ updatedAt: Date(timeIntervalSince1970: 42)
+ )
+ let request = try testTransferRequest(identifier: "field.transfer.enqueue")
+
+ let handle = try await transfer.enqueue(request)
+
+ #expect(handle.identifier == request.identifier)
+ #expect(await transfer.enqueuedRequests == [request])
+ #expect(try await transfer.snapshot(for: request.identifier)?.state == .queued)
+ #expect(try await transfer.snapshot(for: request.identifier)?.updatedAt == Date(timeIntervalSince1970: 42))
+
+ try await transfer.cancel(request.identifier)
+
+ #expect(await transfer.cancelledIdentifiers == [request.identifier])
+ #expect(try await transfer.snapshot(for: request.identifier)?.state == .cancelled)
+}
+
+@Test func fakeBackgroundTransferCanCompleteAndFailSnapshots() async throws {
+ let transfer = RadrootsFakeBackgroundTransfer()
+ let completed = try testTransferRequest(identifier: "field.transfer.completed")
+ let failed = try testTransferRequest(identifier: "field.transfer.failed")
+
+ _ = try await transfer.enqueue(completed)
+ _ = try await transfer.enqueue(failed)
+ try await transfer.complete(completed.identifier)
+ try await transfer.fail(failed.identifier, message: "network unavailable")
+
+ #expect(try await transfer.snapshot(for: completed.identifier)?.state == .completed)
+ let failedSnapshot = try await transfer.snapshot(for: failed.identifier)
+ #expect(failedSnapshot?.state == .failed)
+ #expect(failedSnapshot?.errorMessage == "network unavailable")
+}
+
+@Test func fakeBackgroundTransferCanReturnEnqueueFailures() async throws {
+ let transfer = RadrootsFakeBackgroundTransfer(
+ enqueueOutcome: .failure(.transferFailure("queue unavailable"))
+ )
+ let request = try testTransferRequest(identifier: "field.transfer.failure")
+
+ await #expect(throws: RadrootsBackgroundTransferError.transferFailure("queue unavailable")) {
+ _ = try await transfer.enqueue(request)
+ }
+ #expect(await transfer.enqueuedRequests == [request])
+ #expect(try await transfer.snapshots().isEmpty)
+}
+
+private func testTransferRequest(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"))
+ )
+ )
+}
diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift
@@ -0,0 +1,197 @@
+import Foundation
+import Testing
+@testable import RadrootsKit
+
+@Test func backgroundTransferIdentifierNormalizesAndRejectsUnsafeValues() throws {
+ let identifier = try RadrootsBackgroundTransferIdentifier(" FIELD-IOS.TRANSFER_1 ")
+
+ #expect(identifier.rawValue == "field-ios.transfer_1")
+
+ #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must not be empty")) {
+ _ = try RadrootsBackgroundTransferIdentifier(" ")
+ }
+ #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier must use lowercase safe identifier characters")) {
+ _ = try RadrootsBackgroundTransferIdentifier("/escape")
+ }
+ #expect(throws: RadrootsBackgroundTransferError.invalidRequest("background transfer identifier cannot contain empty path components")) {
+ _ = try RadrootsBackgroundTransferIdentifier("field..transfer")
+ }
+}
+
+@Test func backgroundTransferRequestValidatesUrlMethodAndHeaders() throws {
+ let destination = RadrootsBackgroundTransferLocalFile.file(
+ RadrootsFileReference(scope: .cache, relativePath: "downloads/relay.json")
+ )
+ let request = try RadrootsBackgroundTransferRequest(
+ identifier: RadrootsBackgroundTransferIdentifier("field.transfer.download"),
+ remoteURL: 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")) {
+ _ = try RadrootsBackgroundTransferRequest(
+ remoteURL: URL(string: "http://radroots.org/relay.json")!,
+ method: .get,
+ operation: .download(destination: destination)
+ )
+ }
+ #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)
+ )
+ }
+ #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"]
+ )
+ }
+}
+
+@Test func backgroundTransferUploadRequiresUploadMethod() throws {
+ 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)
+ )
+
+ #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)
+ )
+ }
+}
+
+@Test func backgroundTransferProgressAndSnapshotValidateBounds() throws {
+ let request = try testDownloadRequest(identifier: "field.transfer.snapshot")
+ let progress = try RadrootsBackgroundTransferProgress(bytesTransferred: 5, totalBytesExpected: 10)
+ let snapshot = try RadrootsBackgroundTransferSnapshot(
+ request: request,
+ state: .running,
+ progress: progress,
+ errorMessage: " running ",
+ updatedAt: Date(timeIntervalSince1970: 1)
+ )
+
+ #expect(snapshot.identifier == request.identifier)
+ #expect(snapshot.state == .running)
+ #expect(snapshot.progress == progress)
+ #expect(snapshot.errorMessage == "running")
+
+ #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)
+ )
+ }
+}
+
+@Test func appleBackgroundTransferFileResolverUsesOnlyFileRootsAndStagedBlobs() throws {
+ let roots = try testBackgroundTransferRoots()
+ let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots)
+ let file = RadrootsBackgroundTransferLocalFile.file(
+ RadrootsFileReference(scope: .data, relativePath: "exports/diagnostics.json")
+ )
+ let blob = try RadrootsStagedBlobReference(blobID: "blob-1", sizeBytes: 2)
+
+ #expect(try resolver.resolve(file) == roots.dataRoot.appendingPathComponent("exports/diagnostics.json").standardizedFileURL)
+ #expect(try resolver.resolve(.stagedBlob(blob)) == roots.stagedBlobsRoot.appendingPathComponent("blob-1").standardizedFileURL)
+
+ #expect(throws: RadrootsAppleFileError.invalidRequest("file relative path must not be absolute")) {
+ _ = try resolver.resolve(.file(RadrootsFileReference(scope: .data, relativePath: "/tmp/escape")))
+ }
+ #expect(throws: RadrootsAppleFileError.invalidRequest("staged blob id contains invalid characters")) {
+ _ = try resolver.resolve(.stagedBlob(RadrootsStagedBlobReference(blobID: "../escape", sizeBytes: 1)))
+ }
+}
+
+@Test func appleBackgroundTransferStorePersistsAndRecoversSnapshots() async throws {
+ let roots = try testBackgroundTransferRoots()
+ let store = RadrootsAppleBackgroundTransferStore(roots: roots)
+ let first = try RadrootsBackgroundTransferSnapshot(
+ request: testDownloadRequest(identifier: "field.transfer.b"),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+ let second = try RadrootsBackgroundTransferSnapshot(
+ request: testDownloadRequest(identifier: "field.transfer.a"),
+ state: .running,
+ updatedAt: Date(timeIntervalSince1970: 3)
+ )
+
+ try await store.saveSnapshot(first)
+ try await store.saveSnapshot(second)
+
+ let recoveredStore = RadrootsAppleBackgroundTransferStore(roots: roots)
+ #expect(try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == [
+ "field.transfer.a",
+ "field.transfer.b"
+ ])
+
+ try await recoveredStore.removeSnapshot(for: second.identifier)
+ #expect(try await recoveredStore.loadSnapshots().map(\.identifier.rawValue) == ["field.transfer.b"])
+
+ try await recoveredStore.removeAllSnapshots()
+ #expect(try await recoveredStore.loadSnapshots().isEmpty)
+}
+
+@Test func unavailableBackgroundTransferThrowsTypedErrors() async throws {
+ let transfer = RadrootsUnavailableBackgroundTransfer(reason: "missing background transfer support")
+ let request = try testDownloadRequest(identifier: "field.transfer.unavailable")
+
+ await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) {
+ _ = try await transfer.enqueue(request)
+ }
+ await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) {
+ try await transfer.cancel(request.identifier)
+ }
+ await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) {
+ _ = try await transfer.snapshot(for: request.identifier)
+ }
+ await #expect(throws: RadrootsBackgroundTransferError.unavailable("missing background transfer support")) {
+ _ = try await transfer.snapshots()
+ }
+}
+
+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"))
+ )
+ )
+}
+
+private func testBackgroundTransferRoots() throws -> RadrootsAppleFileRoots {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("radroots-background-transfer-\(UUID().uuidString)", isDirectory: true)
+ return try RadrootsAppleFileRoots(
+ appIdentifier: "org.radroots.tests",
+ dataRoot: root.appendingPathComponent("data", isDirectory: true),
+ cacheRoot: root.appendingPathComponent("cache", isDirectory: true),
+ temporaryRoot: root.appendingPathComponent("tmp", isDirectory: true)
+ )
+}