From 3960e708e4fd350c6198eccf9bb9d9208d098292 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Fri, 12 Jun 2026 14:51:00 -0500 Subject: [PATCH] mst: keep (cid, encoded) cache consistent in nodeCid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the encoding-cache commit had a corruption bug: nodeCid (the rootCid path) re-serializes dirty nodes and clears dirty, but did not refresh Node.encoded — so a subsequent collectBlocks paired the NEW cid with the STALE cached bytes. any CAR written after the second commit cycle failed reload with BadBlockHash. nodeCid now caches the encoding alongside the cid. invariant stated in a comment: anything clearing dirty after serializing must keep the (cid, encoded) pair consistent. adds a regression test that runs five put/rootCid/collectBlocks cycles and verifies every emitted block hashes to its declared cid. Co-Authored-By: Claude Fable 5 --- src/internal/repo/mst.zig | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/internal/repo/mst.zig b/src/internal/repo/mst.zig index 9d22ae1..b112f19 100644 --- a/src/internal/repo/mst.zig +++ b/src/internal/repo/mst.zig @@ -502,9 +502,12 @@ pub const Mst = struct { else => return error.PartialTree, }; const encoded = try self.serializeNode(loaded); - defer self.allocator.free(encoded); const cid = try cbor.Cid.forDagCbor(self.allocator, encoded); loaded.cid = .{ .raw = try self.allocator.dupe(u8, cid.raw) }; + // cache the encoding alongside the cid: anything that clears `dirty` + // after serializing MUST keep the (cid, encoded) pair consistent, or + // a later collectBlocks emits the new cid with stale bytes + loaded.encoded = encoded; loaded.dirty = false; return cid; } @@ -1897,3 +1900,35 @@ test "parseCidString" { try std.testing.expectEqual(@as(u64, 0x12), cid.hashFn().?); try std.testing.expectEqual(@as(usize, 32), cid.digest().?.len); } + +test "collectBlocks emits consistent (cid, data) pairs across repeated commit cycles" { + // regression: nodeCid (the rootCid path) re-serialized dirty nodes and + // cleared `dirty` without refreshing the cached encoding, so the next + // collectBlocks paired the new cid with stale bytes — BadBlockHash on + // any CAR reload after the second commit. + const testing = std.testing; + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tree = Mst.init(arena); + var round: usize = 0; + while (round < 5) : (round += 1) { + var key_buf: [32]u8 = undefined; + const key = try std.fmt.bufPrint(&key_buf, "io.test.rec/key-{d:0>3}", .{round}); + var data_buf: [8]u8 = undefined; + const data = try std.fmt.bufPrint(&data_buf, "v{d}", .{round}); + const value_cid = try cbor.Cid.forDagCbor(arena, data); + try tree.put(key, value_cid); + + // the commit sequence: rootCid first (marks nodes clean), then collect + _ = try tree.rootCid(); + var blocks: std.ArrayList(car.Block) = .empty; + try tree.collectBlocks(&blocks); + + for (blocks.items) |block| { + const computed = try cbor.Cid.forDagCbor(arena, block.data); + try testing.expect(std.mem.eql(u8, computed.raw, block.cid_raw)); + } + } +} -- 2.51.2