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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-22 22:26:00 +02:00
commit 86c58c8695
5 changed files with 64 additions and 8 deletions

2
package-lock.json generated
View file

@ -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",

View file

@ -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

View file

@ -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",

View file

@ -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<void> {
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);

View file

@ -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;
}
}
/**