From 7e449e88cf622a8895077bd5c08a78d793765dae Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Wed, 8 Jul 2026 14:08:48 -0500 Subject: [PATCH] cbor: accept RFC 8949 bytewise map key order on decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the production firehose (indigo/cbor-gen) emits RFC 7049 length-first key order, but current DAG-CBOR spec encoders (e.g. bluesky's jetstream simulator) emit RFC 8949 bytewise order. accept either on decode — canonical-form enforcement belongs to encode/verify, which hash raw bytes. still rejects duplicates and orders satisfying neither. Co-Authored-By: Claude Fable 5 --- src/internal/repo/cbor.zig | 61 ++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/src/internal/repo/cbor.zig b/src/internal/repo/cbor.zig index f6cfd1b..6cf88cb 100644 --- a/src/internal/repo/cbor.zig +++ b/src/internal/repo/cbor.zig @@ -340,20 +340,20 @@ fn decodeAt(allocator: Allocator, data: []const u8, pos: *usize, depth: usize) D if (!std.unicode.utf8ValidateSlice(entry.key)) return error.InvalidUtf8; pos.* = key_end; - // DAG-CBOR: keys must be sorted (shorter first, then lex) and unique + // two canonical map-key orderings coexist in the wild: + // RFC 7049 length-first (indigo/cbor-gen, the production + // firehose) and RFC 8949 bytewise (current DAG-CBOR spec, + // e.g. bluesky's jetstream simulator frame headers). + // accept either; still reject duplicates and orders that + // satisfy neither. if (i > 0) { const prev = entries[i - 1].key; - if (prev.len < entry.key.len) { - // ok — shorter key first - } else if (prev.len == entry.key.len) { - switch (std.mem.order(u8, prev, entry.key)) { - .lt => {}, // ok — lex order - .eq => return error.DuplicateMapKey, - .gt => return error.UnsortedMapKeys, - } - } else { - return error.UnsortedMapKeys; - } + const order = std.mem.order(u8, prev, entry.key); + if (order == .eq) return error.DuplicateMapKey; + const length_first_ok = prev.len < entry.key.len or + (prev.len == entry.key.len and order == .lt); + const bytewise_ok = order == .lt; + if (!length_first_ok and !bytewise_ok) return error.UnsortedMapKeys; } entry.value = try decodeAt(allocator, data, pos, depth + 1); @@ -1469,3 +1469,40 @@ test "real record: firehose post with emoji/langs/reply is byte-identical after const cid = try Cid.forDagCbor(alloc, re_encoded); try std.testing.expectEqualSlices(u8, &expected_digest, cid.digest().?); } + +test "decode accepts RFC 8949 bytewise map key order" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + // {"op": 1, "t": "#commit"} — bytewise order ("op" < "t"), violates + // length-first. emitted by bluesky's jetstream simulator frame headers. + const frame = [_]u8{ + 0xa2, // map(2) + 0x62, 'o', 'p', 0x01, // "op": 1 + 0x61, 't', 0x67, '#', 'c', 'o', 'm', 'm', 'i', 't', // "t": "#commit" + }; + const result = try decode(arena.allocator(), &frame); + try std.testing.expectEqual(@as(i64, 1), result.value.getInt("op").?); + try std.testing.expectEqualStrings("#commit", result.value.getString("t").?); +} + +test "decode still rejects duplicate and unsorted map keys" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + // {"t": ..., "op": ...} reversed: "t" first is length-first order — ok. + // but {"b":1, "a":1} satisfies neither ordering. + const unsorted = [_]u8{ + 0xa2, + 0x61, 'b', 0x01, + 0x61, 'a', 0x01, + }; + try std.testing.expectError(error.UnsortedMapKeys, decode(arena.allocator(), &unsorted)); + + const duplicate = [_]u8{ + 0xa2, + 0x61, 'a', 0x01, + 0x61, 'a', 0x02, + }; + try std.testing.expectError(error.DuplicateMapKey, decode(arena.allocator(), &duplicate)); +} -- 2.51.2