From 1a3780621e3693458df1425f9162ce260bca873f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 23:40:21 +0200 Subject: [PATCH] cultcache-ts: the whole-store writer decodes the stamp with the decoder a read uses Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-ts/CHANGELOG.md | 3 ++- packages/cultcache-ts/src/store-format.ts | 15 ++++++++------- packages/cultcache-ts/test/cult-cache.test.ts | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 5570c98d..6304d347 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -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. diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts index bd75a172..197dc659 100644 --- a/packages/cultcache-ts/src/store-format.ts +++ b/packages/cultcache-ts/src/store-format.ts @@ -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; } /** diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 367f152e..9a411a94 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -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 = {