From c85edf90bb40fceea2967d97c7d459a2ffb05dd3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 21:05:09 +0200 Subject: [PATCH] cultcache-ts slots: a default fills only a missing slot, a nil is null in a reference slot and refused in a value slot, bytes reads a str Held to tests/vectors/slot-nil/reads.msgpack, written by the C# reference (SlotNilVectorTests): every accepted row rewrites to the reference's bytes, nil in an int slot is refused. 94a26431c000c0 round-trips byte-identical. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-ts/src/codec.ts | 34 ++++++++--- .../cultcache-ts/test/union-parity.test.ts | 53 +++++++++++++++++- tests/vectors/slot-nil/reads.msgpack | Bin 0 -> 448 bytes 3 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 tests/vectors/slot-nil/reads.msgpack diff --git a/packages/cultcache-ts/src/codec.ts b/packages/cultcache-ts/src/codec.ts index 7dba8edc..1b559b3e 100644 --- a/packages/cultcache-ts/src/codec.ts +++ b/packages/cultcache-ts/src/codec.ts @@ -49,10 +49,20 @@ export const boolean: Codec = { decode: (raw) => (expect(raw, "a boolean", typeof raw === "boolean"), raw as boolean), }; +// The reference's byte[] reads a MessagePack str as its bytes and writes bin (an old-spec writer's output), so a str is +// accepted and re-encodes as bin. The wire reader has already decoded the str, so a str that held invalid UTF-8 (it +// reads back U+FFFD) is refused rather than turned into other bytes. export const bytes: Codec = { typeName: "System.Byte[]", encode: (value) => (expect(value, "bytes", value instanceof Uint8Array), value), - decode: (raw) => (expect(raw, "bytes", raw instanceof Uint8Array), raw as Uint8Array), + decode(raw) { + if (typeof raw === "string") { + if (raw.includes("�")) refuse("bytes", "a string that is not valid UTF-8"); + return new TextEncoder().encode(raw); + } + expect(raw, "bytes", raw instanceof Uint8Array); + return raw as Uint8Array; + }, }; // A C# integer slot: written as the smallest MessagePack integer and read from any integer encoding in range. A float is @@ -222,8 +232,10 @@ function pascalCase(name: string): string { } // A positional record: slot `key` holds field `name`. Gaps are written nil and skipped on read, extra trailing slots -// are skipped, and a slot missing from the payload takes its declared default or is refused. A nil in a slot that -// declares a default also takes the default (cultcache-py's rule). +// are skipped, and a slot missing from the payload takes its declared default or is refused. A default fills only a +// missing trailing slot, never a nil: as in the reference, a nil in a reference slot (string, bytes, record, union, +// list) reads as null and writes back as nil, so a record the reference wrote re-encodes byte-identical, and a nil in +// a value slot (int, float, bool) is refused unless the slot is nullable. export function slots(spec: SlotsSpec, typeName?: string): SlotsCodec { const keys = slotKeys(spec, "A slot record", 0, MAX_SLOT_KEY); if (keys.length === 0) { @@ -259,8 +271,10 @@ export function slots(spec: SlotsSpec, typeName?: expect(value, "a record", typeof value === "object" && value !== null); const out: unknown[] = new Array(length).fill(null); for (const field of fields) { - out[field.key] = at(`${field.name} (slot ${field.key})`, () => - field.codec.encode((value as Record)[field.name])); + const member = (value as Record)[field.name]; + out[field.key] = member === null && !field.codec.valueType + ? null + : at(`${field.name} (slot ${field.key})`, () => field.codec.encode(member)); } return out; }, @@ -269,13 +283,15 @@ export function slots(spec: SlotsSpec, typeName?: const input = raw as unknown[]; const out: Record = {}; for (const field of fields) { - const present = field.key < input.length && !(input[field.key] === null && field.hasDefault); + const present = field.key < input.length; if (!present && !field.hasDefault) { throw new Error(`${field.name} (slot ${field.key}): the slot is missing and declares no default.`); } - out[field.name] = present - ? at(`${field.name} (slot ${field.key})`, () => field.codec.decode(input[field.key])) - : defaultOf(field); + out[field.name] = !present + ? defaultOf(field) + : input[field.key] === null && !field.codec.valueType + ? null + : at(`${field.name} (slot ${field.key})`, () => field.codec.decode(input[field.key])); } return out as TValue; }, diff --git a/packages/cultcache-ts/test/union-parity.test.ts b/packages/cultcache-ts/test/union-parity.test.ts index 1502b83d..428b21ac 100644 --- a/packages/cultcache-ts/test/union-parity.test.ts +++ b/packages/cultcache-ts/test/union-parity.test.ts @@ -290,16 +290,20 @@ test("slots: a gap writes nil, a missing slot reads its default, one without a d const record = slots<{ a: string; c: string; d: number }>({ 0: ["a", string], 2: ["c", string], 4: ["d", int32, 9] }); assert.deepEqual(record.encode({ a: "x", c: "y", d: 1 }), ["x", null, "y", null, 1]); assert.deepEqual(record.decode(["x", null, "y"]), { a: "x", c: "y", d: 9 }); - assert.deepEqual(record.decode(["x", 5, "y", null, null]), { a: "x", c: "y", d: 9 }); + assert.deepEqual(record.decode(["x", 5, "y", null]), { a: "x", c: "y", d: 9 }); + assert.throws(() => record.decode(["x", 5, "y", null, null]), { message: "d (slot 4): expected an int32, found nil" }); assert.throws(() => record.decode(["x"]), /c \(slot 2\): the slot is missing and declares no default/u); assert.deepEqual(record.decode(["x", null, "y", null, 1, "extra", "slots"]), { a: "x", c: "y", d: 1 }); assert.throws(() => record.decode({ a: "x" }), /expected an array of slots/u); }); -test("slots: a default may be a function, and nil in a defaulted slot takes it", () => { +test("slots: a default may be a function and fills only a missing slot, never a nil", () => { const record = slots<{ tags: string[] }>({ 0: ["tags", list(string), () => ["fresh"]] }); assert.deepEqual(record.decode([]), { tags: ["fresh"] }); - assert.deepEqual(record.decode([null]), { tags: ["fresh"] }); + assert.deepEqual(record.decode([null]), { tags: null }); + const counted = slots<{ n: number }>({ 0: ["n", int32, 4] }); + assert.deepEqual(counted.decode([]), { n: 4 }); + assert.throws(() => counted.decode([null]), { message: "n (slot 0): expected an int32, found nil" }); }); test("a slot refusal names the field and slot and never the value", () => { @@ -740,3 +744,46 @@ test("the mixed-record vector is the bytes the reference writes for a record of assert.equal(hex(writeWire(mixed.encode({ id: "n1", i: 7, l: 8, f: 1.5, d: 2.5 }))), hex(committed)); assert.deepEqual(mixed.decode(readWire(committed)), { id: "n1", i: 7, l: 8, f: 1.5, d: 2.5 }); }); + +// tests/vectors/slot-nil is written by the C# reference (SlotNilVectorTests): what it reads and rewrites for a nil, a str in a +// byte[] slot, and a record short of its last slots. A runtime accepts what it accepts and rewrites the same bytes. +test("slots read and rewrite exactly what the C# reference does for nil, a str in bytes, and short records", async () => { + const inner = slots<{ x: string }>({ 0: ["x", string, ""] }); + const probe = slots<{ s: string; i: number; b: Uint8Array; r: { x: string }; l: string[] }>({ + 0: ["s", string, ""], + 1: ["i", int32, 0], + 2: ["b", bytes, () => new Uint8Array()], + 3: ["r", inner, null as unknown as { x: string }], + 4: ["l", list(string), () => []], + }); + const rows = readWire(new Uint8Array(await readFile(join(vectors, "..", "slot-nil", "reads.msgpack")))) as [string, Uint8Array, boolean, Uint8Array][]; + assert.equal(rows.length, 14); + let accepted = 0; + for (const [name, payload, wasAccepted, rewritten] of rows) { + if (wasAccepted) { + assert.equal(hex(writeWire(probe.encode(probe.decode(readWire(payload))))), hex(rewritten), name); + accepted += 1; + } else { + assert.throws(() => probe.decode(readWire(payload)), (error: Error) => !error.message.includes(hex(payload).slice(2)), name); + } + } + assert.equal(accepted, 12); +}); + +test("a nil reads as null and writes as nil in every reference slot, and a value slot refuses it", () => { + const record = slots<{ s: string; b: Uint8Array; i: number }>({ 0: ["s", string], 1: ["b", bytes], 2: ["i", int32, 5] }); + const read = record.decode([null, null, 3]); + assert.deepEqual(read, { s: null, b: null, i: 3 }); + assert.deepEqual(record.encode(read), [null, null, 3]); + assert.throws(() => record.encode({ s: "a", b: new Uint8Array(), i: null as unknown as number }), { message: "i (slot 2): expected an int32, found nil" }); + assert.throws(() => record.decode(["a", new Uint8Array(), null]), { message: "i (slot 2): expected an int32, found nil" }); +}); + +test("a bytes slot reads a MessagePack str as its bytes and writes bin, and refuses a str that was not valid UTF-8", () => { + const record = slots<{ b: Uint8Array }>({ 0: ["b", bytes] }); + const read = record.decode(readWire(fromHex("91a3616263"))); + assert.equal(hex(read.b), "616263"); + assert.equal(hex(writeWire(record.encode(read))), "91c403616263"); + assert.throws(() => record.decode(readWire(fromHex("91a2c328"))), { message: "b (slot 0): expected bytes, found a string that is not valid UTF-8" }); + assert.throws(() => record.decode([5]), { message: "b (slot 0): expected bytes, found number" }); +}); diff --git a/tests/vectors/slot-nil/reads.msgpack b/tests/vectors/slot-nil/reads.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..9218ac26ece3c75a6c59103a3d661085a7e23233 GIT binary patch literal 448 zcmZ9I+X{j}5QZ%S?eHo>?*C@&EcDW=AR3@ zZx2BLi=uQ47&~rO9@A!+vL_{h6(