From 86c58c869557c3c5cab6f76471ada6b76328a5d7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 22:26:00 +0200 Subject: [PATCH] cultcache-ts 0.14.3: a full pushAll reads the store header under the lock and refuses a format it does not read refuseUnreadFormat is shared by isV1Snapshot and the writer's header peek; message text unchanged. Builds. Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 2 +- packages/cultcache-ts/CHANGELOG.md | 8 ++++ packages/cultcache-ts/package.json | 2 +- .../single-file-messagepack-backing-store.ts | 12 +++-- packages/cultcache-ts/src/store-format.ts | 48 +++++++++++++++++-- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9100d8db..f036d3d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1184,7 +1184,7 @@ }, "packages/cultcache-ts": { "name": "@gamecult/cultcache-ts", - "version": "0.14.2", + "version": "0.14.3", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.3", diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 5c7a1561..5d5fbc85 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.14.3] + +### Fixed +- 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. + ## [0.14.2] ### Added diff --git a/packages/cultcache-ts/package.json b/packages/cultcache-ts/package.json index c48e54e5..c165a22c 100644 --- a/packages/cultcache-ts/package.json +++ b/packages/cultcache-ts/package.json @@ -1,6 +1,6 @@ { "name": "@gamecult/cultcache-ts", - "version": "0.14.2", + "version": "0.14.3", "description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index f10b146b..f0641e66 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { documentPayloadBytes, documentPayloadFromBytes } from "./document"; import { checkPayloadDepth } from "./payload-depth"; import { commitStoreFile, withStoreWriteLock } from "./store-file"; -import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format"; +import { STORE_FORMAT_VERSION, isV1Snapshot, refuseUnreadStoreBytes, requireV1RecordSlots } from "./store-format"; import type { CacheBackingStore, CultCacheEnvelope, @@ -135,8 +135,14 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise { await this.#enqueue(async () => { - if (options.soft && (await readStore(this.filePath)) !== undefined) { - return; + // A whole write of a file it cannot read proceeds as before (a dangling link is + // replaced, R3); only a stamp it can read is judged. + const data = options.soft ? await readStore(this.filePath) : await readStore(this.filePath).catch(() => undefined); + if (data !== undefined) { + if (options.soft) { + return; + } + refuseUnreadStoreBytes(data); } await this.#writeAll(entries); diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts index 4f579505..bd75a172 100644 --- a/packages/cultcache-ts/src/store-format.ts +++ b/packages/cultcache-ts/src/store-format.ts @@ -12,13 +12,55 @@ export function isV1Snapshot(decoded: unknown): decoded is unknown[] { return false; } - if (decoded[0] !== STORE_FORMAT_VERSION) { + refuseUnreadFormat(decoded[0]); + return true; +} + +/** + * Refuses a store whose header names a format this runtime does not read, judging the header + * alone: the leading array's first element, when it is a complete string. Bytes with no such + * header (a legacy array, an empty, truncated or garbled file) are not refused here. + */ +export function refuseUnreadStoreBytes(bytes: Uint8Array): void { + const header = peekHeader(bytes); + if (header !== undefined && header.startsWith(STORE_FORMAT_PREFIX)) { + refuseUnreadFormat(header); + } +} + +function refuseUnreadFormat(header: string): void { + if (header !== STORE_FORMAT_VERSION) { throw new Error( - `CultCache store format ${describeHeader(decoded[0])} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" only.`, + `CultCache store format ${describeHeader(header)} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" only.`, ); } +} - return true; +// The first element of a leading non-empty MessagePack array when it is a complete UTF-8 +// string; anything else is undefined. +function peekHeader(bytes: Uint8Array): string | undefined { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const lead = bytes[0]; + let offset: number; + if (lead === undefined) return undefined; + if (lead >= 0x91 && lead <= 0x9f) offset = 1; + 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 tag = bytes[offset]; + let length: number; + if (tag === undefined) return undefined; + if (tag >= 0xa0 && tag <= 0xbf) { length = tag & 0x1f; offset += 1; } + else if (tag === 0xd9 && bytes.length >= offset + 2) { length = bytes[offset + 1]!; offset += 2; } + else if (tag === 0xda && bytes.length >= offset + 3) { length = view.getUint16(offset + 1); offset += 3; } + 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; + } } /**