From 8a4f6410bf25b215a89c8be488850d6766d55977 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Fri, 2 Oct 2026 22:06:53 -0700 Subject: [PATCH] serve: reject float records at JSON encode, as upstream does zat 0.4.2+ decodes finite 64-bit floats, so after the zat bump a float record stopped failing at decode and was emitted to subscribers as a JSON number. upstream decodes the float too but cbor.ToJSON rejects it as outside the atproto data model (atmos cbor/json.go), and stream on zat 0.4.0 never emitted one. writeCborValue now returns WriteFailed for floats, which lands in the existing encode-error path. the hot-tail test goes back to its original 64-bit float fixture, and a wire test pins the rejection; both fail without the change. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/internal/ingest/ingest.zig | 8 +++----- src/internal/serve/wire.zig | 27 ++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/internal/ingest/ingest.zig b/src/internal/ingest/ingest.zig index e9ee978..c5a532d 100644 --- a/src/internal/ingest/ingest.zig +++ b/src/internal/ingest/ingest.zig @@ -959,11 +959,9 @@ test "repair hot tail skips an undecodable archived record without failing inges defer consumer.deinit(); // A complete repository can contain opaque record bytes outside - // DAG-CBOR. A float decoder error killed the experiment while a verifier - // repair was filling the hot tail from its fetched repository. zat 0.4.2 - // accepts finite 64-bit floats, so this uses a 32-bit float, which - // DAG-CBOR still forbids. - const float_record = "\xa1\x61x\xfa\x3f\x80\x00\x00"; + // DAG-CBOR. This exact float decoder error killed the experiment while a + // verifier repair was filling the hot tail from its fetched repository. + const float_record = "\xa1\x61x\xfb\x3f\xf0\x00\x00\x00\x00\x00\x00"; try appendArchivedTailRow(&consumer, .{ .seq = 42, .witnessed_at = 100, diff --git a/src/internal/serve/wire.zig b/src/internal/serve/wire.zig index 6b94341..0842ee7 100644 --- a/src/internal/serve/wire.zig +++ b/src/internal/serve/wire.zig @@ -194,7 +194,9 @@ fn writeCborValue(s: *std.json.Stringify, value: cbor.Value) !void { switch (value) { .unsigned => |v| try s.write(v), .negative => |v| try s.write(v), - .float => |v| try s.write(v), + // upstream's cbor.ToJSON rejects floats as outside the atproto data + // model (atmos cbor/json.go), so the record is unencodable, not emitted + .float => return error.WriteFailed, // record text can be any bytes a PDS chose to write; the same // array-instead-of-string hazard applies to it as to the envelope .text => |v| try writeString(s, v), @@ -356,6 +358,29 @@ test "record with cid link and bytes" { try testing.expect(std.mem.indexOf(u8, json, "\"blob\":{\"$bytes\":\"AQID\"}") != null); } +// Mirrors upstream: atmos decodes a finite float64 but cbor.ToJSON rejects it +// as outside the atproto data model, so the subscribe encoder errors instead +// of emitting a JSON number. +test "record with a float is unencodable" { + var out: std.Io.Writer.Allocating = .init(testing.allocator); + defer out.deinit(); + const cid = try cbor.Cid.forDagCbor(testing.allocator, "x"); + defer testing.allocator.free(cid.raw); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const record = try cbor.decodeAll(arena.allocator(), "\xa1\x61x\xfb\x3f\xf0\x00\x00\x00\x00\x00\x00"); + try testing.expectError(error.WriteFailed, encodeCommit(testing.allocator, &out.writer, .{ + .did = "did:plc:abc", + .time_us = 1, + .rev = "3l3qo2vutsw2b", + .operation = "create", + .collection = "app.bsky.feed.post", + .rkey = "k", + .record = record, + .cid = cid, + })); +} + // === network.bsky.jetstream.subscribeEvents: proposal-0015 wire === // One xrpc.v1.json message frame per event: // {"$type":"message","payload":{"$type":"network.bsky.jetstream.subscribeEvents#", ...}} -- 2.51.2