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 dc770ec0b94a9b7ab245dba7ab8d5e46f65c109e
parent bd8242d04357adb1d37bf519458a866caf92cd3d
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 20:35:47 +0000

fix: preserve upload response encoding evidence

Diffstat:
MSources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift | 19+++++++++++++++----
MSources/RadrootsKit/RadrootsBackgroundTransfer.swift | 15++++++++++++++-
MTests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift | 28++++++++++++++++++++++++++++
MTests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift | 3++-
4 files changed, 59 insertions(+), 6 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsAppleBackgroundTransfer.swift @@ -331,16 +331,19 @@ struct RadrootsBackgroundHTTPResult: Sendable, Equatable { let statusCode: Int? let mediaType: String? let body: Data? + let contentEncoding: String? let bodyExceeded: Bool let mediaTypeWasMalformed: Bool init( - statusCode: Int?, mediaType: String?, body: Data?, bodyExceeded: Bool, + statusCode: Int?, mediaType: String?, body: Data?, contentEncoding: String? = nil, + bodyExceeded: Bool, mediaTypeWasMalformed: Bool = false ) { self.statusCode = statusCode self.mediaType = mediaType self.body = body + self.contentEncoding = contentEncoding self.bodyExceeded = bodyExceeded self.mediaTypeWasMalformed = mediaTypeWasMalformed } @@ -662,7 +665,7 @@ actor RadrootsAppleBackgroundTransferCoordinator { } if request.responsePolicy == .discard { return try RadrootsBackgroundTransferResponse( - statusCode: statusCode, mediaType: nil, body: nil) + statusCode: statusCode, mediaType: nil, contentEncoding: nil, body: nil) } guard let body = httpResult.body else { throw RadrootsBackgroundTransferError.transferFailure("background_transfer_response_missing") @@ -677,8 +680,13 @@ actor RadrootsAppleBackgroundTransferCoordinator { throw RadrootsBackgroundTransferError.transferFailure( "background_transfer_response_too_large") } + guard httpResult.contentEncoding == nil || httpResult.contentEncoding == "identity" else { + throw RadrootsBackgroundTransferError.transferFailure( + "background_transfer_response_content_encoding") + } return try RadrootsBackgroundTransferResponse( - statusCode: statusCode, mediaType: mediaType, body: body) + statusCode: statusCode, mediaType: mediaType, + contentEncoding: httpResult.contentEncoding, body: body) } private static func removeStagedDownload( @@ -1076,8 +1084,11 @@ struct RadrootsBackgroundURLTaskDescriptor: Sendable, Equatable { 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, bodyExceeded: exceeded, + statusCode: response.statusCode, mediaType: mediaType, body: body, + contentEncoding: contentEncoding, bodyExceeded: exceeded, mediaTypeWasMalformed: rawMediaType != nil && mediaType == nil) } diff --git a/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift b/Sources/RadrootsKit/RadrootsBackgroundTransfer.swift @@ -144,9 +144,12 @@ public struct RadrootsBackgroundTransferResponsePolicy: Sendable, Equatable, Has public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, Codable { public let statusCode: Int public let mediaType: String? + public let contentEncoding: String? public let body: Data? - public init(statusCode: Int, mediaType: String?, body: Data?) throws { + public init( + statusCode: Int, mediaType: String?, contentEncoding: String? = nil, body: Data? + ) throws { guard (100...599).contains(statusCode), body?.count ?? 0 <= 65536 else { throw RadrootsBackgroundTransferError.invalidRequest( "background transfer response is invalid") @@ -155,12 +158,20 @@ public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, self.mediaType = try mediaType.map { try RadrootsBackgroundTransferValidation.normalizedMediaType($0) } + let normalizedEncoding = contentEncoding?.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard normalizedEncoding == nil || normalizedEncoding == "identity" else { + throw RadrootsBackgroundTransferError.invalidRequest( + "background transfer response content encoding is denied") + } + self.contentEncoding = normalizedEncoding self.body = body } private enum CodingKeys: String, CodingKey { case statusCode case mediaType + case contentEncoding case body } @@ -169,6 +180,7 @@ public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, try self.init( statusCode: values.decode(Int.self, forKey: .statusCode), mediaType: values.decodeIfPresent(String.self, forKey: .mediaType), + contentEncoding: values.decodeIfPresent(String.self, forKey: .contentEncoding), body: values.decodeIfPresent(Data.self, forKey: .body) ) } @@ -177,6 +189,7 @@ public struct RadrootsBackgroundTransferResponse: Sendable, Equatable, Hashable, var values = encoder.container(keyedBy: CodingKeys.self) try values.encode(statusCode, forKey: .statusCode) try values.encodeIfPresent(mediaType, forKey: .mediaType) + try values.encodeIfPresent(contentEncoding, forKey: .contentEncoding) try values.encodeIfPresent(body, forKey: .body) } } diff --git a/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsAppleBackgroundTransferTests.swift @@ -289,6 +289,34 @@ import Testing #expect(try await store.loadSnapshots().first?.state == .awaitingVerification) } +@Test func appleBackgroundTransferCoordinatorRejectsEncodedDescriptorResponse() 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.encoded", + responsePolicy: .boundedJSON(maximumBodyBytes: 1024) + ) + try await store.saveSnapshot( + RadrootsBackgroundTransferSnapshot(request: request, state: .running)) + + await coordinator.complete( + identifier: request.identifier, platformError: nil, stagedDownloadResult: nil, + httpResult: RadrootsBackgroundHTTPResult( + statusCode: 200, mediaType: "application/json", body: Data("{}".utf8), + contentEncoding: "gzip", bodyExceeded: false), + bytesTransferred: 10, totalBytesExpected: 10 + ) + + let snapshot = try #require(try await store.loadSnapshots().first) + #expect(snapshot.state == .failed) + #expect(snapshot.errorMessage == "background_transfer_response_content_encoding") + #expect(snapshot.possibleRemoteOrphan) +} + @Test func appleBackgroundTransferCoordinatorRejectsStatusAndOversizedResponse() async throws { let roots = try appleTransferRoots() let store = RadrootsInMemoryBackgroundTransferStore() diff --git a/Tests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift b/Tests/RadrootsKitTests/RadrootsBackgroundTransferTests.swift @@ -361,7 +361,7 @@ import Testing responsePolicy: .boundedJSON(maximumBodyBytes: 1024) ) let response = try RadrootsBackgroundTransferResponse( - statusCode: 200, mediaType: "application/json", body: body) + statusCode: 200, mediaType: "application/json", contentEncoding: "identity", body: body) try await store.saveSnapshot( RadrootsBackgroundTransferSnapshot(request: request, state: .completed, response: response)) @@ -369,6 +369,7 @@ import Testing try await RadrootsAppleBackgroundTransferStore(roots: roots).loadSnapshots().first) #expect(recovered.response?.body == body) #expect(recovered.response?.mediaType == "application/json") + #expect(recovered.response?.contentEncoding == "identity") } @Test func unavailableBackgroundTransferThrowsTypedErrors() async throws {