cultcache-ts: refusals echo a header only in its known shape; a dangling link is not absent
An unsupported header is echoed only as cultcache.store.v<digits>; any other is described by its byte length (a 2,914-character header was echoed whole). pullAll's ENOENT catch wrapped the whole decode, including the legacy-repair write, so a failed repair read as an empty store. It now covers only the read, and only nothing at the path is absent: a dangling symbolic link is refused instead of being replaced by the writer. A soft pushAll uses the same read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
53f6b54e15
commit
853e8db5e5
3 changed files with 127 additions and 62 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
|
|
@ -41,62 +41,57 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
}
|
||||
|
||||
async pullAll(): Promise<CultCacheEnvelope[]> {
|
||||
try {
|
||||
// Only a store that is not there is empty (ENOENT below). An empty file is not a
|
||||
// store, since no CultCache writer leaves one, so it is decoded and refused.
|
||||
const data = await readFile(this.filePath);
|
||||
const decoded = decode(data);
|
||||
const snapshot = decodeSnapshot(decoded);
|
||||
if (snapshot) {
|
||||
return snapshot.records.map((record) => {
|
||||
const catalogEntry = resolveCatalogEntryForRecord(record, snapshot.catalogBySchemaId);
|
||||
if (!catalogEntry) {
|
||||
throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`);
|
||||
}
|
||||
// An empty file is not a store, since no CultCache writer leaves one, so it is
|
||||
// decoded and refused. Only a read that finds nothing at the path is an empty store.
|
||||
const data = await readStore(this.filePath);
|
||||
if (data === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
key: record.key,
|
||||
type: catalogEntry.schemaName,
|
||||
schemaId: record.schemaId,
|
||||
catalogEntry,
|
||||
payload: record.payload,
|
||||
storedAt: record.storedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const legacy = decodeLegacyEnvelopeArray(decoded);
|
||||
if (legacy) {
|
||||
let repairedLegacyPayload = false;
|
||||
const normalized = legacy.map((entry) => {
|
||||
const payload = normalizePayload(entry.payload);
|
||||
if (payload !== entry.payload) {
|
||||
repairedLegacyPayload = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
payload,
|
||||
};
|
||||
});
|
||||
const parsed = envelopeArraySchema.parse(normalized) as CultCacheEnvelope[];
|
||||
|
||||
if (repairedLegacyPayload) {
|
||||
await this.#writeAll(parsed);
|
||||
const decoded = decode(data);
|
||||
const snapshot = decodeSnapshot(decoded);
|
||||
if (snapshot) {
|
||||
return snapshot.records.map((record) => {
|
||||
const catalogEntry = resolveCatalogEntryForRecord(record, snapshot.catalogBySchemaId);
|
||||
if (!catalogEntry) {
|
||||
throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new Error(`CultCache file ${this.filePath} is not a recognized CultCache MessagePack store.`);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
return {
|
||||
key: record.key,
|
||||
type: catalogEntry.schemaName,
|
||||
schemaId: record.schemaId,
|
||||
catalogEntry,
|
||||
payload: record.payload,
|
||||
storedAt: record.storedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const legacy = decodeLegacyEnvelopeArray(decoded);
|
||||
if (legacy) {
|
||||
let repairedLegacyPayload = false;
|
||||
const normalized = legacy.map((entry) => {
|
||||
const payload = normalizePayload(entry.payload);
|
||||
if (payload !== entry.payload) {
|
||||
repairedLegacyPayload = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
payload,
|
||||
};
|
||||
});
|
||||
const parsed = envelopeArraySchema.parse(normalized) as CultCacheEnvelope[];
|
||||
|
||||
if (repairedLegacyPayload) {
|
||||
await this.#writeAll(parsed);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new Error(`CultCache file ${this.filePath} is not a recognized CultCache MessagePack store.`);
|
||||
}
|
||||
|
||||
async push(entry: CultCacheEnvelope): Promise<void> {
|
||||
|
|
@ -122,16 +117,8 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
|
||||
async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise<void> {
|
||||
await this.#enqueue(async () => {
|
||||
if (options.soft) {
|
||||
try {
|
||||
await readFile(this.filePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (options.soft && (await readStore(this.filePath)) !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#writeAll(entries);
|
||||
|
|
@ -171,6 +158,33 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a store file, or returns undefined when nothing is at its path. A dangling symbolic
|
||||
* link is something: reading it as empty would let the writer rename a file over the link
|
||||
* and move the store off its volume. Any other failure to reach the file is thrown.
|
||||
*/
|
||||
async function readStore(path: string): Promise<Uint8Array | undefined> {
|
||||
try {
|
||||
return await readFile(path);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw Object.assign(new Error(`CultCache store ${path} is a symbolic link whose target does not exist.`), { code: "ENOENT" });
|
||||
}
|
||||
|
||||
async function renameWithRetry(source: string, destination: string): Promise<void> {
|
||||
let delayMs = 10;
|
||||
let lastError: unknown;
|
||||
|
|
|
|||
|
|
@ -14,13 +14,23 @@ export function isV1Snapshot(decoded: unknown): decoded is unknown[] {
|
|||
|
||||
if (decoded[0] !== STORE_FORMAT_VERSION) {
|
||||
throw new Error(
|
||||
`CultCache store format "${decoded[0]}" is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" only.`,
|
||||
`CultCache store format ${describeHeader(decoded[0])} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" only.`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A store header as a refusal may show it: echoed in the shape `cultcache.store.v<digits>`,
|
||||
* and otherwise described only by its length, since the bytes are the store's.
|
||||
*/
|
||||
function describeHeader(header: string): string {
|
||||
return /^cultcache\.store\.v[0-9]+$/u.test(header)
|
||||
? `"${header}"`
|
||||
: `an unrecognised ${STORE_FORMAT_PREFIX}* header of ${new TextEncoder().encode(header).length} bytes`;
|
||||
}
|
||||
|
||||
/** Refuses a persisted record with more slots than v1 defines, naming its key and schema id. */
|
||||
export function requireV1RecordSlots(record: unknown[]): void {
|
||||
if (record.length > PERSISTED_RECORD_SLOTS) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||
import { exec, execFile } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import { access, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { access, lstat, mkdir, mkdtemp, readdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { dirname, join, resolve, delimiter } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
|
@ -1491,6 +1491,47 @@ test("SingleFileMessagePackBackingStore reports a store it cannot reach as an I/
|
|||
}
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refusals never echo a value from the store", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-canary-"));
|
||||
const file = join(dir, "store.cc");
|
||||
const cases: Array<[unknown[], boolean | undefined]> = [
|
||||
[["cultcache.store.v12", [], []], true],
|
||||
[["cultcache.store.SECRET-HEADER", [], []], false],
|
||||
[["cultcache.store.v12SECRET", [], []], false],
|
||||
[["cultcache.store.v", [], []], false],
|
||||
[["cultcache.store.v1", ["SECRET-CATALOG-TEXT"], []], undefined],
|
||||
];
|
||||
for (const [value, echoed] of cases) {
|
||||
await writeFile(file, encode(value));
|
||||
const header = value[0] as string;
|
||||
await assert.rejects(
|
||||
() => new SingleFileMessagePackBackingStore(file).pullAll(),
|
||||
(error: Error) => {
|
||||
if (echoed) {
|
||||
return error.message.includes(`"${header}"`);
|
||||
}
|
||||
return !error.message.includes("SECRET")
|
||||
&& (echoed === undefined || (!error.message.includes(`"${header}"`) && error.message.includes(`of ${header.length} bytes`)));
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path and writes nothing", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-"));
|
||||
const volume = join(dir, "volume");
|
||||
await mkdir(volume);
|
||||
const file = join(dir, "store.cc");
|
||||
await symlink(join(volume, "store.cc"), file);
|
||||
const store = new SingleFileMessagePackBackingStore(file);
|
||||
const envelope = { key: "k", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" };
|
||||
await assert.rejects(() => store.pullAll(), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
|
||||
await assert.rejects(() => store.push(envelope), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
|
||||
await assert.rejects(() => store.pushAll([envelope], { soft: true }), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
|
||||
assert.ok((await lstat(file)).isSymbolicLink());
|
||||
assert.deepEqual(await readdir(volume), []);
|
||||
});
|
||||
|
||||
test("CultCache inspector refuses the same vectors by name", async () => {
|
||||
const inspect = async (name: string) => inspectCultCacheBytes(name, await readFile(join(variantVectors, name)));
|
||||
await assert.rejects(() => inspect("unknown-header.msgpack"), /cultcache\.store\.v9/u);
|
||||
|
|
|
|||
Loading…
Reference in a new issue