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:
parent
6809e74137
commit
86c58c8695
5 changed files with 64 additions and 8 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue