diff --git a/.changeset/violet-clocks-listen.md b/.changeset/violet-clocks-listen.md new file mode 100644 index 0000000..ee5c5c0 --- /dev/null +++ b/.changeset/violet-clocks-listen.md @@ -0,0 +1,5 @@ +--- +"@germ-network/atprototypes": patch +--- + +add JSONDecoder.atproto (dataDecodingStrategy .atprotoBase64) accepting the data model's unpadded base64, route XRPC response parsing through it, and emit $bytes unpadded diff --git a/Sources/AtprotoTypes/Primitives/AtprotoJSONDecoder.swift b/Sources/AtprotoTypes/Primitives/AtprotoJSONDecoder.swift new file mode 100644 index 0000000..530d062 --- /dev/null +++ b/Sources/AtprotoTypes/Primitives/AtprotoJSONDecoder.swift @@ -0,0 +1,45 @@ +// +// AtprotoJSONDecoder.swift +// AtprotoTypes +// +// Created by Mark @ Germ on 7/15/26. +// + +import Foundation + +extension JSONDecoder.DataDecodingStrategy { + //The atproto data model serializes bytes as base64 WITHOUT padding — the form + //spec-compliant PDSes emit when re-serializing a record from CBOR — which + //Foundation's default .base64 strategy rejects unless the byte length happens + //to align to a multiple of 3. Accepts both the unpadded (spec) and padded + //(records we previously wrote) forms. + public static var atprotoBase64: JSONDecoder.DataDecodingStrategy { + .custom { decoder in + let container = try decoder.singleValueContainer() + let encoded = try container.decode(String.self) + let remainder = encoded.count % 4 + let padded = + remainder == 0 + ? encoded + : encoded + String(repeating: "=", count: 4 - remainder) + guard let bytes = Data(base64Encoded: padded) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid base64 in bytes field" + ) + } + return bytes + } + } +} + +extension JSONDecoder { + //A decoder configured for atproto data-model JSON. XRPC response parsing + //reads through this; anything else decoding data-model JSON (records, + //lexicon bytes) should too. + public static var atproto: JSONDecoder { + let decoder = JSONDecoder() + decoder.dataDecodingStrategy = .atprotoBase64 + return decoder + } +} diff --git a/Sources/AtprotoTypes/Primitives/Bytes.swift b/Sources/AtprotoTypes/Primitives/Bytes.swift index 6f1ff4f..21528b4 100644 --- a/Sources/AtprotoTypes/Primitives/Bytes.swift +++ b/Sources/AtprotoTypes/Primitives/Bytes.swift @@ -18,5 +18,18 @@ extension Atproto.Primitive { public enum CodingKeys: String, CodingKey { case bytes = "$bytes" } + + //Decoding stays synthesized: whether unpadded base64 is accepted is the + //DECODER's choice — use JSONDecoder.atproto (or .dataDecodingStrategy = + //.atprotoBase64) when reading data-model JSON; a default JSONDecoder keeps + //Foundation's strict padded-only behavior. + // + //Encoding emits the data model's canonical form: base64 without padding. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + var encoded = bytes.base64EncodedString() + while encoded.hasSuffix("=") { encoded.removeLast() } + try container.encode(encoded, forKey: .bytes) + } } } diff --git a/Sources/AtprotoTypes/XRPC/ResponseParsing.swift b/Sources/AtprotoTypes/XRPC/ResponseParsing.swift index a14e837..fd5a624 100644 --- a/Sources/AtprotoTypes/XRPC/ResponseParsing.swift +++ b/Sources/AtprotoTypes/XRPC/ResponseParsing.swift @@ -80,7 +80,7 @@ extension Atproto.XRPC.ResponseParsing { case .ok: return .ok(try parseSuccess(body: fullResponse.data)) case .badRequest: - let errorObject = try JSONDecoder() + let errorObject = try JSONDecoder.atproto .decode( Atproto.XRPC.ErrorResponse.self, from: fullResponse.data @@ -91,7 +91,7 @@ extension Atproto.XRPC.ResponseParsing { ) } case let status where Self.recognizedStatuses.contains(status): - let errorObject = try JSONDecoder() + let errorObject = try JSONDecoder.atproto .decode( Atproto.XRPC.ErrorResponse.self, from: fullResponse.data @@ -117,7 +117,7 @@ extension Atproto.XRPC.ResponseParsing { let result = body.isEmpty ? nil : body return try (result as? Output).tryUnwrap default: - return try JSONDecoder() + return try JSONDecoder.atproto .decode(Output.self, from: body) } } diff --git a/Tests/AtprotoTypesTests/BytesTests.swift b/Tests/AtprotoTypesTests/BytesTests.swift new file mode 100644 index 0000000..70c2a3b --- /dev/null +++ b/Tests/AtprotoTypesTests/BytesTests.swift @@ -0,0 +1,77 @@ +// +// BytesTests.swift +// AtprotoTypes +// +// Created by Mark @ Germ on 7/15/26. +// + +import AtprotoTypes +import Foundation +import Testing + +struct BytesTests { + //The atproto data model's bytes are base64 without padding. A spec-compliant + //PDS (bsky's own included) re-serializes records that way, so lengths that + //aren't a multiple of 3 bytes arrive needing padding Foundation won't infer. + //33 bytes encodes to exactly 44 chars (no padding — the case that always + //worked); 34 bytes needs "==" (the case the default strategy rejects). + @Test("The atproto decoder accepts unpadded and padded base64", arguments: [32, 33, 34]) + func atprotoDecoderAcceptsBothPaddings(count: Int) throws { + let value = Atproto.Primitive.Bytes(bytes: Data(repeating: 0xA5, count: count)) + + let padded = value.bytes.base64EncodedString() + var unpadded = padded + while unpadded.hasSuffix("=") { unpadded.removeLast() } + + for encoded in [padded, unpadded] { + let json = "{\"$bytes\": \"\(encoded)\"}" + let decoded = try JSONDecoder.atproto.decode( + Atproto.Primitive.Bytes.self, + from: json.utf8Data + ) + #expect(decoded == value) + } + } + + //the tolerance is a decoder option, not a property of Bytes: a default + //JSONDecoder keeps Foundation's strict padded-only behavior + @Test func defaultDecoderStaysStrict() throws { + //34 bytes -> unpadded base64 length isn't a multiple of 4 + var unpadded = Data(repeating: 0xA5, count: 34).base64EncodedString() + while unpadded.hasSuffix("=") { unpadded.removeLast() } + let json = "{\"$bytes\": \"\(unpadded)\"}" + #expect(throws: DecodingError.self) { + let _ = try JSONDecoder().decode( + Atproto.Primitive.Bytes.self, + from: json.utf8Data + ) + } + } + + @Test("Encodes without padding and round-trips through the atproto decoder") + func encodeUnpadded() throws { + //34 bytes -> padded base64 would end in "==" + let value = Atproto.Primitive.Bytes(bytes: Data(repeating: 0x5A, count: 34)) + + let encoded = try JSONEncoder().encode(value) + let json = try #require(String(data: encoded, encoding: .utf8)) + #expect(!json.contains("=")) + + let decoded = try JSONDecoder.atproto.decode( + Atproto.Primitive.Bytes.self, + from: encoded + ) + #expect(decoded == value) + } + + @Test("Rejects invalid base64") + func rejectsInvalidBase64() throws { + let json = "{\"$bytes\": \"not*base64!\"}" + #expect(throws: DecodingError.self) { + let _ = try JSONDecoder.atproto.decode( + Atproto.Primitive.Bytes.self, + from: json.utf8Data + ) + } + } +}