commit ad5659709df6ca19a8e5517caf6c5f8396d1539e parent d8f92ee33c21c104398c0d853f8826f2c613eeeb Author: triesap <tyson@radroots.org> Date: Tue, 15 Sep 2026 01:48:54 +0000 transfer: bound receipts and protect authorization - Retain receipts through storage errors and progress contention - Order finished-event acknowledgement behind durable receipt work - Bound response bodies and metadata while redacting transient authority - Verify native transport cases, recovery barriers and exact public API Diffstat:
17 files changed, 742 insertions(+), 114 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransferCoordinator.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransferCoordinator.swift @@ -8,6 +8,12 @@ actor RadrootsTransferCoordinator { private let fileManager: FileManager private var completionHandlers: [@Sendable () -> Void] private var unclaimedFinishedEventCount: Int + private var pendingReceiptCount = 0 + private var deferredFinishedEvents = 0 + + var hasPendingReceipts: Bool { + pendingReceiptCount > 0 + } init( sessionIdentifier: String, store: any RadrootsBackgroundTransferStore, @@ -49,7 +55,9 @@ actor RadrootsTransferCoordinator { identifier: RadrootsBackgroundTransferIdentifier, completion: RadrootsTransferCompletion, executionID: UUID? = nil ) async { - guard let existing = try? await snapshot(for: identifier), existing.executionID == executionID, + pendingReceiptCount += 1 + defer { receiptFinished() } + guard let existing = await recoverableSnapshot(for: identifier), existing.executionID == executionID, [.queued, .running, .interrupted].contains(existing.state) else { return } if let failure = Self.completionFailure(request: existing.request, completion: completion) { @@ -87,6 +95,12 @@ actor RadrootsTransferCoordinator { if completion.httpResult.destinationMismatch { return .responseInvalid } + if let failure = completion.httpResult.headerFailure { + return failure + } + if let encoding = completion.httpResult.contentEncoding, encoding != "identity" { + return .responseContentEncoding + } if UInt64(max(completion.bytesTransferred, 0)) > request.maximumTransferBytes || completion.totalBytesExpected.map({ UInt64(max($0, 0)) > request.maximumTransferBytes }) == true { return .transferTooLarge @@ -122,15 +136,15 @@ actor RadrootsTransferCoordinator { completionHandler() return } - guard completionHandlers.count < 8 else { - completionHandler() - return - } completionHandlers.append(completionHandler) } func finishBackgroundEvents(identifier: String?) { guard identifier == nil || identifier == sessionIdentifier else { return } + guard pendingReceiptCount == 0 else { + deferredFinishedEvents = min(deferredFinishedEvents + 1, 8) + return + } guard !completionHandlers.isEmpty else { unclaimedFinishedEventCount = min(unclaimedFinishedEventCount + 1, 8) return @@ -155,12 +169,16 @@ extension RadrootsTransferCoordinator { fallback: existing.progress ) ?? existing.progress - _ = try? await store.compareExchangeSnapshot(expected: existing, desired: - try RadrootsBackgroundTransferSnapshot( + do { + let desired = try RadrootsBackgroundTransferSnapshot( request: existing.request, state: .awaitingVerification, progress: progress, response: response, updatedAt: now(), executionID: existing.executionID, uploadLease: existing.uploadLease - )) + ) + await persistTerminal(desired) + } catch { + await fail(existing: existing, code: .responseInvalid, possibleRemoteOrphan: true) + } } private func completeDownload( @@ -190,46 +208,56 @@ extension RadrootsTransferCoordinator { byteSize: UInt64(fileSize), mediaType: mediaType ) - try fileManager.createDirectory( - at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true - ) - try Self.moveReplacingItem(from: stagedFileURL, to: destinationURL, fileManager: fileManager) - #if os(iOS) - try fileManager.setAttributes( - [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], - ofItemAtPath: destinationURL.path - ) - #endif + try installDownload(stagedFileURL, at: destinationURL) let progress = Self.progress( bytesTransferred: max(bytesTransferred, fileSize), totalBytesExpected: totalBytesExpected, fallback: existing.progress ) ?? existing.progress - _ = try await store.compareExchangeSnapshot(expected: existing, desired: - RadrootsBackgroundTransferSnapshot( - request: existing.request, state: .awaitingVerification, progress: progress, - response: response, - downloadedArtifact: downloadedArtifact, updatedAt: now(), executionID: existing.executionID, - uploadLease: existing.uploadLease - )) + let desired = try RadrootsBackgroundTransferSnapshot( + request: existing.request, state: .awaitingVerification, progress: progress, + response: response, + downloadedArtifact: downloadedArtifact, updatedAt: now(), executionID: existing.executionID, + uploadLease: existing.uploadLease + ) + await persistTerminal(desired) } catch { Self.removeStagedDownload(.file(stagedFileURL), fileManager: fileManager) await fail(existing: existing, code: .destinationFailure) } } + private func installDownload(_ source: URL, at destination: URL) throws { + try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try Self.moveReplacingItem(from: source, to: destination, fileManager: fileManager) + #if os(iOS) + try fileManager.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: destination.path + ) + #endif + try RadrootsAtomicFile.synchronizeExisting(at: destination) + } + private func fail( existing: RadrootsBackgroundTransferSnapshot, code: RadrootsBackgroundTransferFailure, possibleRemoteOrphan: Bool = false ) async { - _ = try? await store.compareExchangeSnapshot(expected: existing, desired: - try RadrootsBackgroundTransferSnapshot( + while true { + let observed = now() + let timestamp = observed.timeIntervalSinceReferenceDate.isFinite ? observed : existing.updatedAt + let desired = try? RadrootsBackgroundTransferSnapshot( request: existing.request, state: .failed, progress: existing.progress, failure: code, - possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: now(), executionID: existing.executionID, + possibleRemoteOrphan: possibleRemoteOrphan, updatedAt: timestamp, executionID: existing.executionID, uploadLease: existing.uploadLease - )) + ) + if let desired { + await persistTerminal(desired); return + } + await Self.persistenceRetryDelay() + } } private func snapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws @@ -237,6 +265,51 @@ extension RadrootsTransferCoordinator { try await store.loadSnapshots().first { $0.identifier == identifier } } + private func recoverableSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async + -> RadrootsBackgroundTransferSnapshot? { + while true { + do { + return try await snapshot(for: identifier) + } catch { + await Self.persistenceRetryDelay() + } + } + } + + /// Progress and reconciliation may replace a snapshot during an await. Retry + /// against the current exact value without changing the receipt's attempt. + /// Store failure retains the receipt and blocks finished-event acknowledgement. + private func persistTerminal(_ desired: RadrootsBackgroundTransferSnapshot) async { + while true { + do { + guard let current = try await snapshot(for: desired.identifier), + current.executionID == desired.executionID, current.request == desired.request, + [.queued, .running, .interrupted].contains(current.state) + else { return } + if try await store.compareExchangeSnapshot(expected: current, desired: desired) { + return + } + } catch { /* Retain receipt ownership until storage becomes available. */ } + await Self.persistenceRetryDelay() + } + } + + private static func persistenceRetryDelay() async { + // The OS acknowledgement barrier must survive caller cancellation. A + // separately owned delay avoids a cancelled task spinning on sleep. + await Task.detached { try? await Task.sleep(for: .milliseconds(100)) }.value + } + + private func receiptFinished() { + pendingReceiptCount -= 1 + guard pendingReceiptCount == 0, deferredFinishedEvents > 0 else { return } + let events = deferredFinishedEvents + deferredFinishedEvents = 0 + for _ in 0 ..< events { + finishBackgroundEvents(identifier: sessionIdentifier) + } + } + private static func progress( bytesTransferred: Int64, totalBytesExpected: Int64?, fallback: RadrootsBackgroundTransferProgress diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundURLSession.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundURLSession.swift @@ -118,7 +118,7 @@ import Foundation let identifiers = tasks.compactMap { task -> RadrootsBackgroundTransferIdentifier? in RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.identifier } - return Set(identifiers) + return Set(identifiers).union(sessionDelegate?.callbacks.pendingIdentifiers ?? []) } func handleBackgroundEvents( diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundURLSessionDelegate.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundURLSessionDelegate.swift @@ -4,15 +4,13 @@ import Foundation final class RadrootsTransferSessionDelegate: NSObject, URLSessionDownloadDelegate, URLSessionDataDelegate, URLSessionTaskDelegate, @unchecked Sendable { - private static let absoluteMaximumResponseBodyBytes = 65536 private let coordinator: RadrootsTransferCoordinator 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> + let callbacks = RadrootsTransferCallbackQueue() + private let responses = RadrootsTransferResponseCollector() init( coordinator: RadrootsTransferCoordinator, downloadStagingRoot: URL, @@ -22,9 +20,6 @@ import Foundation self.downloadStagingRoot = downloadStagingRoot self.fileManager = fileManager stagedDownloadResultsByTaskIdentifier = [:] - responseBodyLimitsByTaskIdentifier = [:] - responseBodiesByTaskIdentifier = [:] - exceededResponseBodyTaskIdentifiers = [] } func urlSession( @@ -91,7 +86,8 @@ import Foundation } func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - let shouldCancel = appendResponseBody(data, task: dataTask) + let shouldCancel = responses.append(data, taskIdentifier: dataTask.taskIdentifier, + fallbackLimit: responseLimit(dataTask)) if shouldCancel { dataTask.cancel() } @@ -101,9 +97,14 @@ import Foundation _: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void ) { - completionHandler(RadrootsNativeDestinationPolicy.responseMatches( + let accepted = RadrootsNativeDestinationPolicy.responseMatches( response.url, original: dataTask.originalRequest, current: dataTask.currentRequest - ) ? .allow : .cancel) + ) && responses.begin( + response, + taskIdentifier: dataTask.taskIdentifier, + fallbackLimit: responseLimit(dataTask) + ) + completionHandler(accepted ? .allow : .cancel) } func urlSession( @@ -120,7 +121,8 @@ import Foundation Task { await coordinator.updateProgress( identifier: identifier, bytesTransferred: totalBytesSent, - totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend) + totalBytesExpected: Self.expectedByteCount(totalBytesExpectedToSend), + executionID: RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)?.executionID ) } } @@ -138,7 +140,7 @@ import Foundation } return } - Task { + callbacks.enqueue(receipt: identifier) { [coordinator] in await coordinator.complete( identifier: identifier, completion: RadrootsTransferCompletion(platformError: error, @@ -155,7 +157,7 @@ import Foundation } func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { - Task { + callbacks.enqueue { [coordinator] in await coordinator.finishBackgroundEvents(identifier: session.configuration.identifier) } } @@ -185,60 +187,22 @@ import Foundation } func registerResponseBodyLimit(_ limit: Int, taskIdentifier: Int) { - lock.lock() - defer { lock.unlock() } - responseBodyLimitsByTaskIdentifier[taskIdentifier] = min( - max(limit, 0), Self.absoluteMaximumResponseBodyBytes - ) + responses.register(limit, taskIdentifier: taskIdentifier) } - private func appendResponseBody(_ data: Data, task: URLSessionDataTask) -> Bool { - lock.lock() - defer { lock.unlock() } - let taskIdentifier = task.taskIdentifier - guard !exceededResponseBodyTaskIdentifiers.contains(taskIdentifier) else { return false } - let limit = - responseBodyLimitsByTaskIdentifier[taskIdentifier] - ?? RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)? - .maximumResponseBodyBytes - ?? Self.absoluteMaximumResponseBodyBytes - guard limit > 0 else { return false } - let currentCount = responseBodiesByTaskIdentifier[taskIdentifier]?.count ?? 0 - guard data.count <= limit - currentCount else { - responseBodiesByTaskIdentifier.removeValue(forKey: taskIdentifier) - exceededResponseBodyTaskIdentifiers.insert(taskIdentifier) - return true - } - responseBodiesByTaskIdentifier[taskIdentifier, default: Data()].append(data) - return false + private func responseLimit(_ task: URLSessionTask) -> Int { + RadrootsBackgroundURLTaskDescriptor(taskDescription: task.taskDescription)? + .maximumResponseBodyBytes ?? 65536 } private func takeHTTPResult(for task: URLSessionTask) -> RadrootsBackgroundHTTPResult { - lock.lock() - let body = responseBodiesByTaskIdentifier.removeValue(forKey: task.taskIdentifier) - responseBodyLimitsByTaskIdentifier.removeValue(forKey: task.taskIdentifier) - let exceeded = exceededResponseBodyTaskIdentifiers.remove(task.taskIdentifier) != nil - lock.unlock() - - guard let response = task.response as? HTTPURLResponse else { - return RadrootsBackgroundHTTPResult( - statusCode: nil, mediaType: nil, body: body, bodyExceeded: exceeded - ) - } - let rawMediaType = response.value(forHTTPHeaderField: "Content-Type") - let mediaType = rawMediaType.flatMap { - try? RadrootsBackgroundTransferValidation.normalizedMediaType($0) - } - let contentEncoding = response.value(forHTTPHeaderField: "Content-Encoding")? - .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return RadrootsBackgroundHTTPResult( - statusCode: response.statusCode, mediaType: mediaType, body: body, - contentEncoding: contentEncoding, bodyExceeded: exceeded, - mediaTypeWasMalformed: rawMediaType != nil && mediaType == nil, - destinationMismatch: !RadrootsNativeDestinationPolicy.responseMatches( - response.url, original: task.originalRequest, current: task.currentRequest - ) - ) + responses.take(taskIdentifier: task.taskIdentifier, response: task.response as? HTTPURLResponse, + destinationMismatch: task.response != nil && !RadrootsNativeDestinationPolicy + .responseMatches( + task.response?.url, + original: task.originalRequest, + current: task.currentRequest + )) } private func transferIdentifier(from task: URLSessionTask) diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferRequest.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferRequest.swift @@ -46,7 +46,7 @@ public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, public var debugDescription: String { "RadrootsBackgroundTransferRequest(identifier: \(identifier.rawValue), method: \(method.rawValue), " - + "operation: \(operation.redactedLabel), headers: <redacted>, metadataKeys: \(metadata.keys.sorted()), " + + "operation: \(operation.redactedLabel), headers: <redacted>, metadata: <redacted>, " + "maximumTransferBytes: \(maximumTransferBytes), responseBodyLimit: \(responsePolicy.maximumBodyBytes))" } @@ -99,7 +99,8 @@ public struct RadrootsBackgroundTransferRequest: Sendable, Equatable, Hashable, try values.encode(remoteURL, forKey: .remoteURL) try values.encode(method, forKey: .method) try values.encode(operation, forKey: .operation) - try values.encode(metadata, forKey: .metadata) + // Headers and host metadata are transient, including direct Codable exports. + try values.encode([String: String](), forKey: .metadata) try values.encode(networkPolicy, forKey: .networkPolicy) try values.encode(responsePolicy, forKey: .responsePolicy) try values.encodeIfPresent(expectedSourceSHA256, forKey: .expectedSourceSHA256) diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferResponse.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferResponse.swift @@ -50,12 +50,18 @@ public struct RadrootsBackgroundTransferResponsePolicy: Sendable, Equatable, Has } } -public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, Codable { +public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, Codable, + CustomDebugStringConvertible { public let statusCode: Int public let mediaType: String? public let contentEncoding: String? public let body: Data? + public var debugDescription: String { + "RadrootsBackgroundTransferResponse(statusCode: \(statusCode), " + + "bodyBytes: \(body?.count ?? 0), content: <redacted>)" + } + public init( statusCode: Int, mediaType: String?, contentEncoding: String? = nil, body: Data? ) throws { diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransferValidation.swift b/Sources/RadrootsKit/RadrootsBackgroundTransferValidation.swift @@ -166,6 +166,11 @@ public enum RadrootsBackgroundTransferValidation { else { throw RadrootsBackgroundTransferError.invalidRequest } + if mediaType == "application/json" { + guard (try? JSONSerialization.jsonObject(with: body)) is [String: Any] else { + throw RadrootsBackgroundTransferError.invalidRequest + } + } } private static func validateSafeText(_ value: String, field _: String, maximumLength: Int) throws { diff --git a/Sources/RadrootsKit/RadrootsBackgroundURLTaskDescriptor.swift b/Sources/RadrootsKit/RadrootsBackgroundURLTaskDescriptor.swift @@ -13,11 +13,13 @@ struct RadrootsBackgroundHTTPResult: Sendable, Equatable { let bodyExceeded: Bool let mediaTypeWasMalformed: Bool let destinationMismatch: Bool + let headerFailure: RadrootsBackgroundTransferFailure? init( statusCode: Int?, mediaType: String?, body: Data?, contentEncoding: String? = nil, bodyExceeded: Bool, - mediaTypeWasMalformed: Bool = false, destinationMismatch: Bool = false + mediaTypeWasMalformed: Bool = false, destinationMismatch: Bool = false, + headerFailure: RadrootsBackgroundTransferFailure? = nil ) { self.statusCode = statusCode self.mediaType = mediaType @@ -26,6 +28,7 @@ struct RadrootsBackgroundHTTPResult: Sendable, Equatable { self.bodyExceeded = bodyExceeded self.mediaTypeWasMalformed = mediaTypeWasMalformed self.destinationMismatch = destinationMismatch + self.headerFailure = headerFailure } } diff --git a/Sources/RadrootsKit/RadrootsTransferCallbackQueue.swift b/Sources/RadrootsKit/RadrootsTransferCallbackQueue.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Delegate callbacks arrive on a serial OS queue. Preserve that order across +/// Swift suspension so finished events cannot overtake receipt persistence. +final class RadrootsTransferCallbackQueue: @unchecked Sendable { + private let lock = NSLock() + private var tail: Task<Void, Never>? + private var receipts: [RadrootsBackgroundTransferIdentifier: Int] = [:] + + var pendingIdentifiers: Set<RadrootsBackgroundTransferIdentifier> { + lock.withLock { Set(receipts.keys) } + } + + func enqueue( + receipt identifier: RadrootsBackgroundTransferIdentifier? = nil, + operation: @escaping @Sendable () async -> Void + ) { + lock.withLock { + if let identifier { + receipts[identifier, default: 0] += 1 + } + let previous = tail + tail = Task { + await previous?.value + await operation() + if let identifier { + self.finished(identifier) + } + } + } + } + + private func finished(_ identifier: RadrootsBackgroundTransferIdentifier) { + lock.withLock { + let remaining = (receipts[identifier] ?? 1) - 1 + if remaining == 0 { + receipts.removeValue(forKey: identifier) + } else { + receipts[identifier] = remaining + } + } + } +} diff --git a/Sources/RadrootsKit/RadrootsTransferResponseCollector.swift b/Sources/RadrootsKit/RadrootsTransferResponseCollector.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Bounds bytes as they arrive. Header strings are bounded before normalization; +/// no request headers or arbitrary response headers enter the receipt. +final class RadrootsTransferResponseCollector: @unchecked Sendable { + private static let maximumBodyBytes = 65536 + private let lock = NSLock() + private var limits: [Int: Int] = [:] + private var bodies: [Int: Data] = [:] + private var counts: [Int: Int] = [:] + private var failures: [Int: RadrootsBackgroundTransferFailure] = [:] + + func register(_ limit: Int, taskIdentifier: Int) { + lock.withLock { limits[taskIdentifier] = min(max(limit, 0), Self.maximumBodyBytes) } + } + + func begin(_ response: URLResponse, taskIdentifier: Int, fallbackLimit: Int) -> Bool { + lock.withLock { + guard let http = response as? HTTPURLResponse else { + failures[taskIdentifier] = .responseInvalid + return false + } + let limit = effectiveLimit(taskIdentifier, fallback: fallbackLimit) + let failure = Self.headerFailure(http) ?? ( + response.expectedContentLength > Int64(limit) ? .responseTooLarge : nil + ) + if let failure { + failures[taskIdentifier] = failure + } + return failure == nil + } + } + + /// Returns true when the task must be cancelled, including discard-policy + /// responses that exceed the absolute transport bound. + func append(_ data: Data, taskIdentifier: Int, fallbackLimit: Int) -> Bool { + lock.withLock { + guard failures[taskIdentifier] == nil else { return true } + let limit = effectiveLimit(taskIdentifier, fallback: fallbackLimit) + let count = counts[taskIdentifier] ?? 0 + guard data.count <= limit - count else { + bodies.removeValue(forKey: taskIdentifier) + failures[taskIdentifier] = .responseTooLarge + return true + } + counts[taskIdentifier] = count + data.count + if (limits[taskIdentifier] ?? fallbackLimit) > 0 { + bodies[taskIdentifier, default: Data()].append(data) + } + return false + } + } + + func take(taskIdentifier: Int, response: HTTPURLResponse?, + destinationMismatch: Bool) -> RadrootsBackgroundHTTPResult { + lock.withLock { + let body = bodies.removeValue(forKey: taskIdentifier) + let failure = failures.removeValue(forKey: taskIdentifier) ?? response.flatMap(Self.headerFailure) + limits.removeValue(forKey: taskIdentifier) + counts.removeValue(forKey: taskIdentifier) + let rawType = response?.value(forHTTPHeaderField: "Content-Type") + let mediaType = rawType.flatMap { value -> String? in + guard value.utf8.count <= 256 else { return nil } + return try? RadrootsBackgroundTransferValidation.normalizedMediaType(value) + } + let rawEncoding = response?.value(forHTTPHeaderField: "Content-Encoding") + let encoding = rawEncoding.flatMap { value -> String? in + guard value.utf8.count <= 32 else { return "invalid" } + return value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + return RadrootsBackgroundHTTPResult( + statusCode: response?.statusCode, mediaType: mediaType, body: body, contentEncoding: encoding, + bodyExceeded: failure == .responseTooLarge, mediaTypeWasMalformed: rawType != nil && mediaType == nil, + destinationMismatch: destinationMismatch, headerFailure: failure + ) + } + } + + private func effectiveLimit(_ identifier: Int, fallback: Int) -> Int { + let limit = limits[identifier] ?? min(max(fallback, 0), Self.maximumBodyBytes) + return limit == 0 ? Self.maximumBodyBytes : limit + } + + private static func headerFailure(_ response: HTTPURLResponse) -> RadrootsBackgroundTransferFailure? { + if let value = response.value(forHTTPHeaderField: "Content-Encoding"), + value.utf8.count > 32 || value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != "identity" { + return .responseContentEncoding + } + if let value = response.value(forHTTPHeaderField: "Content-Type"), + value.utf8.count > 256 || (try? RadrootsBackgroundTransferValidation.normalizedMediaType(value)) == nil { + return .responseMediaType + } + return nil + } +} diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferReceiptTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferReceiptTests.swift @@ -169,7 +169,7 @@ import Testing #expect(try await store.loadSnapshots().first?.state == .cancelled) } -@Test func appleBackgroundTransferRecoversCompletionLostWhileProtectedDataIsLocked() async throws { +@Test func appleBackgroundTransferRetainsReceiptWhileProtectedDataIsLocked() async throws { let roots = try appleTransferRoots() let protectedData = RadrootsProtectedDataProbe(state: .available) let store = RadrootsAppleBackgroundTransferStore( @@ -185,24 +185,35 @@ import Testing ) protectedData.state = .locked - await coordinator.complete( - identifier: request.identifier, - completion: RadrootsTransferCompletion( - platformError: nil, - stagedDownloadResult: nil, - httpResult: successfulHTTPResult(), - bytesTransferred: 10, - totalBytesExpected: 10 + let pending = Task { + await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, + httpResult: successfulHTTPResult(), bytesTransferred: 10, + totalBytesExpected: 10) ) - ) + } + for _ in 0 ..< 100 { + if await coordinator.hasPendingReceipts { + break + } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(await coordinator.hasPendingReceipts) + let completion = RadrootsCompletionProbe() + await coordinator.handleBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") { + completion.markCompleted() + } + await coordinator.finishBackgroundEvents(identifier: "org.radroots.field-ios.background.transfer") + #expect(!completion.completed) 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) + await pending.value + #expect(completion.completed) + let reopened = RadrootsAppleBackgroundTransferStore(roots: roots) + let recovered = try #require(try await reopened.loadSnapshots().first) + #expect(recovered.state == .awaitingVerification) + #expect(recovered.response?.statusCode == 200) + #expect(!recovered.possibleRemoteOrphan) } @Test func appleBackgroundTransferCoordinatorInvokesStoredCompletionHandlerAfterFinishedEvents() diff --git a/Tests/RadrootsKitTests/RadrootsNativeProgressReceiptTests.swift b/Tests/RadrootsKitTests/RadrootsNativeProgressReceiptTests.swift @@ -0,0 +1,50 @@ +#if os(iOS) && targetEnvironment(simulator) + import Foundation + @testable import RadrootsKit + import RadrootsKitTesting + import Testing + + @Test func nativeUploadDelegatePreservesExecutionIdentityForProgress() async throws { + let roots = try appleTransferRoots() + defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } + let request = try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier("progress.identity"), + remoteURL: #require(URL(string: "http://127.0.0.1:8080/upload")), method: .put, + operation: .upload(source: .file(RadrootsFileReference(scope: .cache, relativePath: "body"))), + networkPolicy: .simulatorLoopbackHTTP + ) + let executionID = UUID() + let running = try RadrootsBackgroundTransferSnapshot( + request: request, + state: .running, + executionID: executionID + ) + let store = RadrootsInMemoryBackgroundTransferStore(snapshots: [running]) + let coordinator = RadrootsTransferCoordinator(sessionIdentifier: "progress.tests", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver( + roots: roots + )) + let delegate = RadrootsTransferSessionDelegate(coordinator: coordinator, + downloadStagingRoot: roots.temporaryRoot, fileManager: .default) + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + var nativeRequest = URLRequest(url: request.remoteURL) + nativeRequest.httpMethod = "PUT" + let task = session.uploadTask(with: nativeRequest, from: Data(repeating: 1, count: 10)) + task.taskDescription = RadrootsBackgroundURLTaskDescriptor(request: request, executionID: executionID) + .taskDescription + delegate.urlSession(session, task: task, didSendBodyData: 5, totalBytesSent: 5, totalBytesExpectedToSend: 10) + for _ in 0 ..< 100 { + if try await store.loadSnapshots().first?.progress.bytesTransferred == 5 { + break + } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(try await store.loadSnapshots().first?.progress.bytesTransferred == 5) + task.taskDescription = RadrootsBackgroundURLTaskDescriptor(request: request, executionID: UUID()) + .taskDescription + delegate.urlSession(session, task: task, didSendBodyData: 5, totalBytesSent: 10, totalBytesExpectedToSend: 10) + try await Task.sleep(for: .milliseconds(50)) + #expect(try await store.loadSnapshots().first?.progress.bytesTransferred == 5) + } +#endif diff --git a/Tests/RadrootsKitTests/RadrootsNativeRedirectServer.swift b/Tests/RadrootsKitTests/RadrootsNativeRedirectServer.swift @@ -11,6 +11,7 @@ private var connections: [NWConnection] = [] private var received = 0 private var location: String? + private var wireResponse: Data? init() throws { let parameters = NWParameters.tcp @@ -26,6 +27,10 @@ lock.withLock { self.location = location } } + func respond(with bytes: Data) { + lock.withLock { wireResponse = bytes } + } + func start() async throws -> UInt16 { listener.newConnectionHandler = { [weak self] connection in guard let self else { connection.cancel(); return } @@ -60,7 +65,8 @@ if self.hasBody(bytes) { let destination = self.lock.withLock { self.received += 1; return self.location } let status = destination.map { "307 Temporary Redirect\r\nLocation: \($0)" } ?? "200 OK" - let response = Data("HTTP/1.1 \(status)\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8) + let response = self.lock.withLock { self.wireResponse } + ?? Data("HTTP/1.1 \(status)\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8) connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() }) } else if complete { connection.cancel() diff --git a/Tests/RadrootsKitTests/RadrootsNativeResponseTests.swift b/Tests/RadrootsKitTests/RadrootsNativeResponseTests.swift @@ -0,0 +1,65 @@ +#if os(iOS) && targetEnvironment(simulator) + import Foundation + @testable import RadrootsKit + import Testing + + @Test(arguments: ["valid", "declared", "chunked", "missing", "status", "type", "shape", "encoded"]) + func nativeResponseEnforcesActualTransportBounds(_ name: String) async throws { + let server = try NativeRedirectServer() + defer { server.stop() } + let port = try await server.start() + server.respond(with: nativeResponseWire(name)) + let roots = try appleTransferRoots() + defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } + let source = RadrootsFileReference(scope: .cache, relativePath: "response_body") + try RadrootsAppleFileAccess(roots: roots).write(.inline(Data("isolated fixture body".utf8)), to: source) + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + let adapters = try RadrootsAppleBackgroundTransferAdapters.live( + sessionIdentifier: "org.radroots.response.\(UUID().uuidString.lowercased())", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver(roots: roots), + downloadStagingRoot: roots.temporaryRoot + ) + let transfer = RadrootsAppleBackgroundTransfer(store: store, adapters: adapters) + let request = try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier("response.\(name)"), + remoteURL: #require(URL(string: "http://127.0.0.1:\(port)/upload")), method: .put, + operation: .upload(source: .file(source)), networkPolicy: .simulatorLoopbackHTTP, + responsePolicy: .boundedJSON(maximumBodyBytes: 32) + ) + _ = try await transfer.enqueue(request) + let deadline = Date().addingTimeInterval(15) + var snapshot = try await store.loadSnapshots().first + while snapshot?.state == .queued || snapshot?.state == .running { + guard Date() < deadline else { throw RadrootsBackgroundTransferError.transferFailure } + try await Task.sleep(for: .milliseconds(25)) + snapshot = try await store.loadSnapshots().first + } + let failures: [String: RadrootsBackgroundTransferFailure] = [ + "declared": .responseTooLarge, "chunked": .responseTooLarge, "missing": .responseMissing, + "status": .httpStatus, "type": .responseMediaType, "shape": .responseInvalid, + "encoded": .responseContentEncoding + ] + #expect(snapshot?.state == (name == "valid" ? .awaitingVerification : .failed)) + #expect(snapshot?.failure == failures[name]) + #expect(server.requestCount == 1) + if name == "valid" { + #expect(snapshot?.response?.body == Data("{}".utf8)) + } + } + + private func nativeResponseWire(_ name: String) -> Data { + let status = name == "status" ? "503 Unavailable" : "200 OK" + let type = name == "type" ? "image/jpeg" : "application/json" + var headers = "HTTP/1.1 \(status)\r\nContent-Type: \(type)\r\nConnection: close\r\n" + if name == "encoded" { + headers += "Content-Encoding: gzip\r\n" + } + if name == "chunked" { + return Data((headers + "Transfer-Encoding: chunked\r\n\r\n20\r\n" + String(repeating: "x", count: 32) + + "\r\n1\r\nx\r\n0\r\n\r\n").utf8) + } + let body = name == "missing" ? "" : name == "shape" ? "[" : "{}" + let length = name == "declared" ? 1024 : body.utf8.count + return Data((headers + "Content-Length: \(length)\r\n\r\n" + body).utf8) + } +#endif diff --git a/Tests/RadrootsKitTests/RadrootsReceiptBarrierTests.swift b/Tests/RadrootsKitTests/RadrootsReceiptBarrierTests.swift @@ -0,0 +1,170 @@ +import Darwin +import Foundation +@testable import RadrootsKit +import RadrootsKitTesting +import Testing + +@Test func receiptBarrierRetriesProgressConflictAndStoreFailureBeforeAcknowledgement() async throws { + let roots = try appleTransferRoots() + defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } + let request = try appleUploadRequest(identifier: "receipt.barrier", responsePolicy: .boundedJSON()) + let generation = UUID() + let snapshot = try RadrootsBackgroundTransferSnapshot(request: request, state: .running, executionID: generation) + let store = ReceiptBarrierStore(snapshot: snapshot) + let resolver = RadrootsAppleBackgroundTransferFileResolver(roots: roots) + let coordinator = RadrootsTransferCoordinator(sessionIdentifier: "receipt.tests", store: store, + fileResolver: resolver) + let callbacks = RadrootsTransferCallbackQueue() + let acknowledgement = RadrootsCompletionProbe() + let body = Data(#"{"url":"https://example.org/blob"}"#.utf8) + callbacks.enqueue(receipt: request.identifier) { + await coordinator.complete(identifier: request.identifier, + completion: RadrootsTransferCompletion( + platformError: nil, + stagedDownloadResult: nil, + httpResult: RadrootsBackgroundHTTPResult(statusCode: 200, + mediaType: "application/json", + body: body, + bodyExceeded: false), + bytesTransferred: 10, + totalBytesExpected: 10 + ), executionID: generation) + } + try await store.waitUntilEntered() + #expect(await store.entered) + #expect(callbacks.pendingIdentifiers == [request.identifier]) + for _ in 0 ..< 10 { + await coordinator.handleBackgroundEvents(identifier: "receipt.tests") { acknowledgement.markCompleted() } + } + callbacks.enqueue { await coordinator.finishBackgroundEvents(identifier: "receipt.tests") } + #expect(!acknowledgement.completed) + await store.release() + for _ in 0 ..< 500 { + if acknowledgement.completionCount == 10 { + break + } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(acknowledgement.completionCount == 10) + #expect(await store.terminalAttempts == 4) + #expect(callbacks.pendingIdentifiers.isEmpty) + let receipt = try #require(try await store.loadSnapshots().first) + #expect(receipt.state == .awaitingVerification && receipt.executionID == generation) + #expect(receipt.response?.body == body && receipt.progress.bytesTransferred == 10) +} + +@Test func receiptBarrierSurvivesActualStoreLockAndRecoversAfterPersistence() async throws { + let roots = try appleTransferRoots() + defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + let request = try appleUploadRequest(identifier: "receipt.lock") + try await store.saveSnapshot(RadrootsBackgroundTransferSnapshot(request: request, state: .running)) + let lockURL = try roots.resolvedURL(for: RadrootsFileReference(scope: .data, + relativePath: "background_transfers/transfers.lock")) + let descriptor = try #require(try RadrootsAtomicFile.acquireExclusiveLock(at: lockURL)) + let coordinator = RadrootsTransferCoordinator(sessionIdentifier: "locked.receipt", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver( + roots: roots + )) + let pending = Task { await coordinator.complete( + identifier: request.identifier, + completion: RadrootsTransferCompletion(platformError: nil, stagedDownloadResult: nil, + httpResult: successfulHTTPResult(), + bytesTransferred: 10, + totalBytesExpected: 10) + ) } + for _ in 0 ..< 100 { + if await coordinator.hasPendingReceipts { + break + } + try await Task.sleep(for: .milliseconds(10)) + } + let acknowledgement = RadrootsCompletionProbe() + await coordinator.handleBackgroundEvents(identifier: "locked.receipt") { acknowledgement.markCompleted() } + await coordinator.finishBackgroundEvents(identifier: "locked.receipt") + #expect(!acknowledgement.completed) + pending.cancel() + Darwin.close(descriptor) + await pending.value + #expect(acknowledgement.completed) + let restarted = RadrootsAppleBackgroundTransferStore(roots: roots) + #expect(try await restarted.loadSnapshots().first?.state == .awaitingVerification) +} + +private actor ReceiptBarrierStore: RadrootsBackgroundTransferStore { + private let storage: RadrootsInMemoryBackgroundTransferStore + private var gate: CheckedContinuation<Void, Never>? + private(set) var entered = false + private(set) var terminalAttempts = 0 + + func waitUntilEntered() async throws { + for _ in 0 ..< 100 { + if entered { + return + } + try await Task.sleep(for: .milliseconds(10)) + } + throw RadrootsBackgroundTransferError.transferFailure + } + + init(snapshot: RadrootsBackgroundTransferSnapshot) { + storage = .init(snapshots: [snapshot]) + } + + func release() { + gate?.resume(); gate = nil + } + + func compareExchangeSnapshot(expected: RadrootsBackgroundTransferSnapshot?, + desired: RadrootsBackgroundTransferSnapshot) async throws -> Bool { + if desired.state == .awaitingVerification { + terminalAttempts += 1 + if terminalAttempts == 1 { + entered = true + await withCheckedContinuation { gate = $0 } + let current = try #require(expected) + let progress = try RadrootsBackgroundTransferSnapshot(request: current.request, state: .running, + progress: RadrootsBackgroundTransferProgress( + bytesTransferred: 5, + totalBytesExpected: 10 + ), + executionID: current.executionID) + try await storage.saveSnapshot(progress) + } else if terminalAttempts <= 3 { + throw RadrootsBackgroundTransferError.persistenceFailure + } + } + let exchanged = try await storage.compareExchangeSnapshot(expected: expected, desired: desired) + if desired.state == .awaitingVerification, terminalAttempts == 4, exchanged { + // The write took effect, but its caller lost the successful return. + throw RadrootsBackgroundTransferError.persistenceFailure + } + return exchanged + } + + func withAdmission(for identifier: RadrootsBackgroundTransferIdentifier, + operation: @escaping @Sendable () async throws -> RadrootsBackgroundTransferHandle) + async throws -> RadrootsBackgroundTransferHandle { + try await storage.withAdmission(for: identifier, operation: operation) + } + + func admissionIsActive(for identifier: RadrootsBackgroundTransferIdentifier) async throws -> Bool { + try await storage.admissionIsActive(for: identifier) + } + + func loadSnapshots() async throws -> [RadrootsBackgroundTransferSnapshot] { + try await storage.loadSnapshots() + } + + func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws { + try await storage.saveSnapshot(snapshot) + } + + func removeSnapshot(for identifier: RadrootsBackgroundTransferIdentifier) async throws { + try await storage.removeSnapshot(for: identifier) + } + + func removeAllSnapshots() async throws { + try await storage.removeAllSnapshots() + } +} diff --git a/Tests/RadrootsKitTests/RadrootsReceiptPrivacyTests.swift b/Tests/RadrootsKitTests/RadrootsReceiptPrivacyTests.swift @@ -0,0 +1,37 @@ +import Foundation +@testable import RadrootsKit +import Testing + +@Test func receiptPersistenceAndDiagnosticsExcludeTransientAuthorityAndHostMetadata() async throws { + let roots = try appleTransferRoots() + defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } + let markers = ["AUTH_SENTINEL_809d", "DRAFT_SENTINEL_81ac", "LOCATION_SENTINEL_717d"] + let request = try RadrootsBackgroundTransferRequest( + identifier: RadrootsBackgroundTransferIdentifier("privacy.receipt"), + remoteURL: #require(URL(string: "https://example.org/upload")), method: .put, + operation: .upload(source: .file(RadrootsFileReference(scope: .cache, relativePath: "body"))), + headers: ["Authorization": markers[0]], metadata: [markers[1]: markers[2]], responsePolicy: .boundedJSON() + ) + let response = try RadrootsBackgroundTransferResponse(statusCode: 200, mediaType: "application/json", + body: Data(#"{"url":"https://example.org/blob"}"#.utf8)) + let snapshot = try RadrootsBackgroundTransferSnapshot(request: request, state: .awaitingVerification, + response: response, executionID: UUID()) + let store = RadrootsAppleBackgroundTransferStore(roots: roots) + try await store.saveSnapshot(snapshot) + let serializedRequest = try JSONEncoder().encode(request) + let serializedSnapshot = try JSONEncoder().encode(snapshot) + let persistedFile = RadrootsFileReference(scope: .data, relativePath: "background_transfers/transfers.json") + let persistedURL = try roots.resolvedURL(for: persistedFile) + let persisted = try Data(contentsOf: persistedURL) + let diagnostics = [String(reflecting: request), String(describing: request), String(reflecting: snapshot), + String(describing: snapshot), String(reflecting: response), String(describing: response)] + for marker in markers { + #expect(![serializedRequest, serializedSnapshot, persisted].contains { $0.range(of: Data(marker.utf8)) != nil }) + #expect(!diagnostics.contains { $0.contains(marker) }) + } + #expect(!diagnostics.contains { $0.contains("https://example.org/blob") }) + let recovered = try #require(try await store.loadSnapshots().first) + #expect(recovered.request.headers.isEmpty && recovered.request.metadata.isEmpty) + #expect(recovered.response == response && recovered.executionID == snapshot.executionID) + #expect(request.headers["Authorization"] == markers[0]) +} diff --git a/Tests/RadrootsKitTests/RadrootsResponseCollectorTests.swift b/Tests/RadrootsKitTests/RadrootsResponseCollectorTests.swift @@ -0,0 +1,96 @@ +import Foundation +@testable import RadrootsKit +import RadrootsKitTesting +import Testing + +@Test func responseCollectorBoundsDeclaredChunkedAndDiscardedBodies() throws { + let url = try #require(URL(string: "https://example.org/upload")) + let collector = RadrootsTransferResponseCollector() + collector.register(8, taskIdentifier: 1) + let declared = try #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, + headerFields: [ + "Content-Type": "application/json", + "Content-Length": "9" + ])) + #expect(!collector.begin(declared, taskIdentifier: 1, fallbackLimit: 8)) + #expect(collector.take(taskIdentifier: 1, response: declared, destinationMismatch: false).bodyExceeded) + collector.register(8, taskIdentifier: 2) + let chunked = try #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, + headerFields: [ + "Content-Type": "application/json", + "Transfer-Encoding": "chunked" + ])) + #expect(collector.begin(chunked, taskIdentifier: 2, fallbackLimit: 8)) + #expect(!collector.append(Data(repeating: 1, count: 8), taskIdentifier: 2, fallbackLimit: 8)) + #expect(collector.append(Data([1]), taskIdentifier: 2, fallbackLimit: 8)) + let rejected = collector.take(taskIdentifier: 2, response: chunked, destinationMismatch: false) + #expect(rejected.body == nil && rejected.bodyExceeded) + collector.register(0, taskIdentifier: 3) + #expect(!collector.append(Data(repeating: 1, count: 65536), taskIdentifier: 3, fallbackLimit: 0)) + #expect(collector.append(Data([1]), taskIdentifier: 3, fallbackLimit: 0)) + #expect(collector.take(taskIdentifier: 3, response: chunked, destinationMismatch: false).body == nil) +} + +@Test(arguments: ["gzip", "br", "identity, gzip", String(repeating: "x", count: 1024)]) +func responseCollectorRejectsEncodingBeforeCollecting(_ encoding: String) throws { + let url = try #require(URL(string: "https://example.org/upload")) + let response = try #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, + headerFields: [ + "Content-Encoding": encoding, + "Content-Type": "application/json" + ])) + let collector = RadrootsTransferResponseCollector() + #expect(!collector.begin(response, taskIdentifier: 1, fallbackLimit: 32)) + #expect(collector.append(Data("{}".utf8), taskIdentifier: 1, fallbackLimit: 32)) + let result = collector.take(taskIdentifier: 1, response: response, destinationMismatch: false) + #expect(result.headerFailure == .responseContentEncoding && result.body == nil) +} + +@Test(arguments: ["", "[]", "null", "\"scalar\"", "{broken"]) +func responseValidationRejectsMissingOrMalformedDescriptorShape(_ raw: String) async throws { + let roots = try appleTransferRoots() + defer { try? FileManager.default.removeItem(at: roots.dataRoot.deletingLastPathComponent()) } + let request = try appleUploadRequest(identifier: "response.shape", responsePolicy: .boundedJSON()) + let store = try RadrootsInMemoryBackgroundTransferStore(snapshots: [RadrootsBackgroundTransferSnapshot( + request: request, state: .running + )]) + let coordinator = RadrootsTransferCoordinator(sessionIdentifier: "shape.tests", store: store, + fileResolver: RadrootsAppleBackgroundTransferFileResolver( + roots: roots + )) + await coordinator.complete(identifier: request.identifier, completion: RadrootsTransferCompletion( + platformError: nil, stagedDownloadResult: nil, httpResult: RadrootsBackgroundHTTPResult( + statusCode: 200, + mediaType: "application/json", + body: Data(raw.utf8), + bodyExceeded: false + ), bytesTransferred: 10, + totalBytesExpected: 10 + )) + #expect(try await store.loadSnapshots().first?.failure == .responseInvalid) +} + +@Test func responseCollectorBoundsMetadataAndPreservesSafeVerificationFields() throws { + let url = try #require(URL(string: "https://example.org/upload")) + let collector = RadrootsTransferResponseCollector() + let oversized = try #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": "application/json;" + String( + repeating: "x", + count: 1024 + )])) + #expect(!collector.begin(oversized, taskIdentifier: 1, fallbackLimit: 32)) + #expect(collector.take(taskIdentifier: 1, response: oversized, destinationMismatch: false).mediaTypeWasMalformed) + let response = try #require(HTTPURLResponse(url: url, statusCode: 201, httpVersion: nil, + headerFields: [ + "Content-Type": "application/json", + "Content-Encoding": "identity", + "Set-Cookie": "secret-marker" + ])) + #expect(collector.begin(response, taskIdentifier: 2, fallbackLimit: 32)) + #expect(!collector.append(Data("{}".utf8), taskIdentifier: 2, fallbackLimit: 32)) + let receipt = collector.take(taskIdentifier: 2, response: response, destinationMismatch: false) + #expect(receipt.statusCode == 201 && receipt.mediaType == "application/json" && receipt + .contentEncoding == "identity") + #expect(receipt.body == Data("{}".utf8)) + #expect(!String(reflecting: receipt).contains("secret-marker")) +} diff --git a/contracts/api_baselines/apple_kit.txt b/contracts/api_baselines/apple_kit.txt @@ -576,6 +576,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 10:conformsTo 46:s:11RadrootsKit0A26BackgroundTransferProgressV 4:s:Se 15:Swift.Decodable 12:relationship 11:RadrootsKit 10:conformsTo 46:s:11RadrootsKit0A26BackgroundTransferResponseV 13:s:s8SendableP 14:Swift.Sendable 12:relationship 11:RadrootsKit 10:conformsTo 46:s:11RadrootsKit0A26BackgroundTransferResponseV 22:s:s16SendableMetatypeP 22:Swift.SendableMetatype +12:relationship 11:RadrootsKit 10:conformsTo 46:s:11RadrootsKit0A26BackgroundTransferResponseV 34:s:s28CustomDebugStringConvertibleP 34:Swift.CustomDebugStringConvertible 12:relationship 11:RadrootsKit 10:conformsTo 46:s:11RadrootsKit0A26BackgroundTransferResponseV 4:s:SE 15:Swift.Encodable 12:relationship 11:RadrootsKit 10:conformsTo 46:s:11RadrootsKit0A26BackgroundTransferResponseV 4:s:SH 14:Swift.Hashable 12:relationship 11:RadrootsKit 10:conformsTo 46:s:11RadrootsKit0A26BackgroundTransferResponseV 4:s:SQ 15:Swift.Equatable @@ -1349,6 +1350,7 @@ radroots.apple-kit.public-api.v1 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A25BackgroundTransferFailureO15responseMissingyA2CmF 45:s:11RadrootsKit0A25BackgroundTransferFailureO 0: 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A25BackgroundTransferRequestV6methodAA0acD6MethodOvp 45:s:11RadrootsKit0A25BackgroundTransferRequestV 0: 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A26AppleMediaPreparationErrorO14invalidRequestyA2CmF 46:s:11RadrootsKit0A26AppleMediaPreparationErrorO 0: +12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A26BackgroundTransferResponseV16debugDescriptionSSvp 46:s:11RadrootsKit0A26BackgroundTransferResponseV 0: 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A26BackgroundTransferSnapshotV16debugDescriptionSSvp 46:s:11RadrootsKit0A26BackgroundTransferSnapshotV 0: 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A28BackgroundDownloadedArtifactV8byteSizes6UInt64Vvp 48:s:11RadrootsKit0A28BackgroundDownloadedArtifactV 0: 12:relationship 11:RadrootsKit 8:memberOf 68:s:11RadrootsKit0A29ExternalActionDestinationKindO11appSettingsyA2CmF 49:s:11RadrootsKit0A29ExternalActionDestinationKindO 0: @@ -2520,6 +2522,7 @@ radroots.apple-kit.public-api.v1 6:symbol 11:RadrootsKit 14:swift.property 68:s:11RadrootsKit0A22PreparedExportDocumentV7fileURL10Foundation0G0Vvp 7:fileURL 16:let fileURL: URL 6:symbol 11:RadrootsKit 14:swift.property 68:s:11RadrootsKit0A24DocumentInterchangeErrorO16errorDescriptionSSSgvp 16:errorDescription 37:var errorDescription: String? { get } 6:symbol 11:RadrootsKit 14:swift.property 68:s:11RadrootsKit0A25BackgroundTransferRequestV6methodAA0acD6MethodOvp 6:method 44:let method: RadrootsBackgroundTransferMethod +6:symbol 11:RadrootsKit 14:swift.property 68:s:11RadrootsKit0A26BackgroundTransferResponseV16debugDescriptionSSvp 16:debugDescription 36:var debugDescription: String { get } 6:symbol 11:RadrootsKit 14:swift.property 68:s:11RadrootsKit0A26BackgroundTransferSnapshotV16debugDescriptionSSvp 16:debugDescription 36:var debugDescription: String { get } 6:symbol 11:RadrootsKit 14:swift.property 68:s:11RadrootsKit0A28BackgroundDownloadedArtifactV8byteSizes6UInt64Vvp 8:byteSize 20:let byteSize: UInt64 6:symbol 11:RadrootsKit 14:swift.property 69:s:11RadrootsKit0A16IdentitySnapshotV8identityAA0aC12PublicRecordVSgvp 8:identity 43:let identity: RadrootsIdentityPublicRecord?