From 4036c827e6a4a5294c66b248aec5999c92a03063 Mon Sep 17 00:00:00 2001 From: "Mark (dev)" Date: Wed, 15 Jul 2026 10:21:41 -0700 Subject: [PATCH 1/2] Accept the data model's unpadded base64 in $bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthesized Codable used JSONDecoder's .base64 Data strategy, which requires padding the atproto data model omits — so records re-serialized by a spec-compliant PDS (e.g. blacksky's rsky) failed to decode whenever the byte length wasn't a multiple of 3. Decode now tolerates both forms and encoding emits the spec's unpadded form. Co-Authored-By: Claude Fable 5 --- .changeset/violet-clocks-listen.md | 5 ++ Sources/AtprotoTypes/Primitives/Bytes.swift | 30 ++++++++++ Tests/AtprotoTypesTests/BytesTests.swift | 62 +++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 .changeset/violet-clocks-listen.md create mode 100644 Tests/AtprotoTypesTests/BytesTests.swift diff --git a/.changeset/violet-clocks-listen.md b/.changeset/violet-clocks-listen.md new file mode 100644 index 0000000..03e6ccf --- /dev/null +++ b/.changeset/violet-clocks-listen.md @@ -0,0 +1,5 @@ +--- +"@germ-network/atprototypes": patch +--- + +accept the data model's unpadded base64 in $bytes, and emit it on encode diff --git a/Sources/AtprotoTypes/Primitives/Bytes.swift b/Sources/AtprotoTypes/Primitives/Bytes.swift index 6f1ff4f..814ae21 100644 --- a/Sources/AtprotoTypes/Primitives/Bytes.swift +++ b/Sources/AtprotoTypes/Primitives/Bytes.swift @@ -18,5 +18,35 @@ extension Atproto.Primitive { public enum CodingKeys: String, CodingKey { case bytes = "$bytes" } + + //The atproto data model encodes $bytes as base64 WITHOUT padding, which + //Foundation's base64 decoder rejects — so synthesized Codable (JSONDecoder's + //.base64 Data strategy) fails on any spec-compliant PDS whose byte length + //isn't a multiple of 3. Accept both unpadded (spec) and padded (records we + //previously wrote) forms on decode; emit the spec's unpadded form on encode. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let encoded = try container.decode(String.self, forKey: .bytes) + 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( + forKey: .bytes, + in: container, + debugDescription: "Invalid base64 in $bytes" + ) + } + self.bytes = bytes + } + + 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/Tests/AtprotoTypesTests/BytesTests.swift b/Tests/AtprotoTypesTests/BytesTests.swift new file mode 100644 index 0000000..c7568bd --- /dev/null +++ b/Tests/AtprotoTypesTests/BytesTests.swift @@ -0,0 +1,62 @@ +// +// 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 is base64 without padding. A spec-compliant + //PDS (e.g. rsky/blacksky) 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 synthesized Codable rejected). + @Test("Decodes unpadded, padded, and alignment-free base64", arguments: [32, 33, 34]) + func decodeBothPaddings(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().decode( + Atproto.Primitive.Bytes.self, + from: json.utf8Data + ) + #expect(decoded == value) + } + } + + @Test("Encodes without padding and round-trips") + 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().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().decode( + Atproto.Primitive.Bytes.self, + from: json.utf8Data + ) + } + } +} From d2d08071198a6f1bd628cf58bd3beb65b94905ae Mon Sep 17 00:00:00 2001 From: "Mark (dev)" Date: Wed, 15 Jul 2026 12:58:35 -0700 Subject: [PATCH 2/2] Make unpadded-base64 tolerance a decoder option Move the padding tolerance off Bytes' init(from:) onto the decoder: JSONDecoder.atproto (dataDecodingStrategy .atprotoBase64) accepts the data model's unpadded form for EVERY Data field, not just $bytes, and XRPC response parsing reads through it. A default JSONDecoder keeps Foundation's strict behavior. Co-Authored-By: Claude Fable 5 --- .changeset/violet-clocks-listen.md | 2 +- .../Primitives/AtprotoJSONDecoder.swift | 45 +++++++++++++++++++ Sources/AtprotoTypes/Primitives/Bytes.swift | 29 +++--------- .../AtprotoTypes/XRPC/ResponseParsing.swift | 6 +-- Tests/AtprotoTypesTests/BytesTests.swift | 33 ++++++++++---- 5 files changed, 79 insertions(+), 36 deletions(-) create mode 100644 Sources/AtprotoTypes/Primitives/AtprotoJSONDecoder.swift diff --git a/.changeset/violet-clocks-listen.md b/.changeset/violet-clocks-listen.md index 03e6ccf..ee5c5c0 100644 --- a/.changeset/violet-clocks-listen.md +++ b/.changeset/violet-clocks-listen.md @@ -2,4 +2,4 @@ "@germ-network/atprototypes": patch --- -accept the data model's unpadded base64 in $bytes, and emit it on encode +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 814ae21..21528b4 100644 --- a/Sources/AtprotoTypes/Primitives/Bytes.swift +++ b/Sources/AtprotoTypes/Primitives/Bytes.swift @@ -19,29 +19,12 @@ extension Atproto.Primitive { case bytes = "$bytes" } - //The atproto data model encodes $bytes as base64 WITHOUT padding, which - //Foundation's base64 decoder rejects — so synthesized Codable (JSONDecoder's - //.base64 Data strategy) fails on any spec-compliant PDS whose byte length - //isn't a multiple of 3. Accept both unpadded (spec) and padded (records we - //previously wrote) forms on decode; emit the spec's unpadded form on encode. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let encoded = try container.decode(String.self, forKey: .bytes) - 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( - forKey: .bytes, - in: container, - debugDescription: "Invalid base64 in $bytes" - ) - } - self.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() 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 index c7568bd..70c2a3b 100644 --- a/Tests/AtprotoTypesTests/BytesTests.swift +++ b/Tests/AtprotoTypesTests/BytesTests.swift @@ -10,13 +10,13 @@ import Foundation import Testing struct BytesTests { - //The atproto data model's $bytes is base64 without padding. A spec-compliant - //PDS (e.g. rsky/blacksky) re-serializes records that way, so lengths that + //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 synthesized Codable rejected). - @Test("Decodes unpadded, padded, and alignment-free base64", arguments: [32, 33, 34]) - func decodeBothPaddings(count: Int) throws { + //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() @@ -25,7 +25,7 @@ struct BytesTests { for encoded in [padded, unpadded] { let json = "{\"$bytes\": \"\(encoded)\"}" - let decoded = try JSONDecoder().decode( + let decoded = try JSONDecoder.atproto.decode( Atproto.Primitive.Bytes.self, from: json.utf8Data ) @@ -33,7 +33,22 @@ struct BytesTests { } } - @Test("Encodes without padding and round-trips") + //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)) @@ -42,7 +57,7 @@ struct BytesTests { let json = try #require(String(data: encoded, encoding: .utf8)) #expect(!json.contains("=")) - let decoded = try JSONDecoder().decode( + let decoded = try JSONDecoder.atproto.decode( Atproto.Primitive.Bytes.self, from: encoded ) @@ -53,7 +68,7 @@ struct BytesTests { func rejectsInvalidBase64() throws { let json = "{\"$bytes\": \"not*base64!\"}" #expect(throws: DecodingError.self) { - let _ = try JSONDecoder().decode( + let _ = try JSONDecoder.atproto.decode( Atproto.Primitive.Bytes.self, from: json.utf8Data )