cultcache-ts: the whole-store writer decodes the stamp with the decoder a read uses

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-22 23:40:21 +02:00
commit 1a3780621e
3 changed files with 24 additions and 8 deletions

View file

@ -27,7 +27,8 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
- A full `pushAll` reads the store's header under the lock first and refuses a store format
this runtime does not read (the same error a read gives), leaving the file as it was. A
file with no readable stamp is still replaced, so a whole write heals it. Soft `pushAll`
is unchanged.
is unchanged. The stamp is decoded by the decoder a read uses, so a stamp followed by bytes
that are not UTF-8 is refused by a whole write as a read refuses it.
### Dependencies
- `koffi` 3.3.0, loaded only on the first write.

View file

@ -1,3 +1,5 @@
import { decode } from "@msgpack/msgpack";
export const STORE_FORMAT_VERSION = "cultcache.store.v1";
const STORE_FORMAT_PREFIX = "cultcache.store.";
@ -36,8 +38,9 @@ function refuseUnreadFormat(header: string): void {
}
}
// The first element of a leading non-empty MessagePack array when it is a complete UTF-8
// string; anything else is undefined.
// The first element of a leading non-empty MessagePack array when it is a complete string;
// anything else is undefined. The string is decoded by the decoder a read uses, so a stamp
// gets one verdict from a read and from a whole write.
function peekHeader(bytes: Uint8Array): string | undefined {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const lead = bytes[0];
@ -47,6 +50,7 @@ function peekHeader(bytes: Uint8Array): string | undefined {
else if (lead === 0xdc && bytes.length >= 3 && view.getUint16(1) > 0) offset = 3;
else if (lead === 0xdd && bytes.length >= 5 && view.getUint32(1) > 0) offset = 5;
else return undefined;
const start = offset;
const tag = bytes[offset];
let length: number;
if (tag === undefined) return undefined;
@ -56,11 +60,8 @@ function peekHeader(bytes: Uint8Array): string | undefined {
else if (tag === 0xdb && bytes.length >= offset + 5) { length = view.getUint32(offset + 1); offset += 5; }
else return undefined;
if (bytes.length < offset + length) return undefined;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(offset, offset + length));
} catch {
return undefined;
}
const header = decode(bytes.subarray(start, offset + length));
return typeof header === "string" ? header : undefined;
}
/**

View file

@ -1540,6 +1540,20 @@ test("a store's header is judged from its first bytes, in every MessagePack arra
assert.equal(headerRefused(padded.subarray(2)), true);
});
test("a stamp followed by bytes that are not UTF-8 gets the verdict a read gives", async () => {
const stamp = [...Buffer.from("cultcache.store.v3", "utf8")];
const forms = { fixstr: [0x91, 0xb3, ...stamp, 0xff], str8: [0x91, 0xd9, 19, ...stamp, 0xff] };
const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" };
for (const [name, bytes] of Object.entries(forms)) {
const file = join(await mkdtemp(join(tmpdir(), "cultcache-stamp-")), "store.msgpack");
await writeFile(file, new Uint8Array(bytes));
const store = new SingleFileMessagePackBackingStore(file);
await assert.rejects(() => store.pullAll(), /not one this runtime reads/u, `${name} read`);
await assert.rejects(() => store.pushAll([envelope]), /not one this runtime reads/u, `${name} pushAll`);
assert.deepEqual([...(await readFile(file))], bytes, `${name} left as it was`);
}
});
test("bytes with no readable store stamp are not refused", () => {
const stamp = [...Buffer.from("cultcache.store.v3", "utf8")];
const cases: Record<string, number[]> = {