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:
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 {