cultcache-ts: a record refusal names only a string key; pin link writes for R3
The five-slot refusal ran a non-string key or schema id through String(), quoting stored bytes. A string is named; anything else is described by its type. A push through a live link, and a pushAll through a dangling one, replace the link with a regular file. Tests named for R3 pin that until R3 decides whether writes resolve or refuse links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
549b745ee6
commit
b810810cf3
2 changed files with 61 additions and 1 deletions
|
|
@ -31,11 +31,30 @@ function describeHeader(header: string): string {
|
|||
: `an unrecognised ${STORE_FORMAT_PREFIX}* header of ${new TextEncoder().encode(header).length} bytes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A record key or schema id as a refusal may show it: a string is an identity and is named;
|
||||
* anything else is described by its type, since the value is the store's.
|
||||
*/
|
||||
function describeIdentity(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
const kind = Array.isArray(value)
|
||||
? "array"
|
||||
: value instanceof Uint8Array
|
||||
? "bytes"
|
||||
: value === null
|
||||
? "nil"
|
||||
: typeof value;
|
||||
return `<${kind}>`;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
throw new Error(
|
||||
`CultCache record "${String(record[0])}" (schema "${String(record[1])}") has ${record.length} slots, more than ` +
|
||||
`CultCache record ${describeIdentity(record[0])} (schema ${describeIdentity(record[1])}) has ${record.length} slots, more than ` +
|
||||
`the ${PERSISTED_RECORD_SLOTS} of a ${STORE_FORMAT_VERSION} record, so this is not a valid store.`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1532,6 +1532,47 @@ test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path a
|
|||
assert.deepEqual(await readdir(volume), []);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore record refusal names only a string key", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-record-key-"));
|
||||
const file = join(dir, "store.cc");
|
||||
const secret = new TextEncoder().encode("SECRET-BYTES");
|
||||
for (const [key, schema] of [[["SECRET-IN-ARRAY"], "s"], [secret, "s"], ["k", { SECRET: 1 }]] as const) {
|
||||
await writeFile(file, encode(["cultcache.store.v1", [], [[key, schema, "t", Uint8Array.of(0x90), "extra"]]]));
|
||||
await assert.rejects(
|
||||
() => new SingleFileMessagePackBackingStore(file).pullAll(),
|
||||
(error: Error) => !error.message.includes("SECRET") && !error.message.includes("83,69,67") && error.message.includes("not a valid store"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// What a write does through a symbolic link today. R3 decides whether a write resolves the link
|
||||
// or refuses it; these pin the current behaviour so that change is deliberate.
|
||||
test("R3 decides resolve-or-refuse: a push through a live link replaces the link", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-live-link-"));
|
||||
const volume = join(dir, "volume");
|
||||
await mkdir(volume);
|
||||
const target = join(volume, "store.cc");
|
||||
const envelope = { key: "a", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" };
|
||||
await new SingleFileMessagePackBackingStore(target).push(envelope);
|
||||
const before = await readFile(target);
|
||||
const link = join(dir, "store.cc");
|
||||
await symlink(target, link);
|
||||
await new SingleFileMessagePackBackingStore(link).push({ ...envelope, key: "b" });
|
||||
assert.ok((await lstat(link)).isFile());
|
||||
assert.deepEqual(await readFile(target), before);
|
||||
});
|
||||
|
||||
test("R3 decides resolve-or-refuse: a pushAll through a dangling link replaces the link", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-push-all-"));
|
||||
const volume = join(dir, "volume");
|
||||
await mkdir(volume);
|
||||
const link = join(dir, "store.cc");
|
||||
await symlink(join(volume, "store.cc"), link);
|
||||
await new SingleFileMessagePackBackingStore(link).pushAll([{ key: "a", type: "t", payload: Uint8Array.of(0x90), storedAt: "2026-09-30T00:00:00Z" }]);
|
||||
assert.ok((await lstat(link)).isFile());
|
||||
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