cultcache-ts: a write at a store path that is a link is refused before lock and replace (StoreLinkedError)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f1e72adf87
commit
bf29fb40ff
2 changed files with 56 additions and 11 deletions
|
|
@ -5,7 +5,7 @@
|
|||
// lazily inside `loadKernel`, so a consumer that never writes a store never loads it.
|
||||
|
||||
import { closeSync, openSync, constants as fsConstants } from "node:fs";
|
||||
import { mkdir, open, rename, rm } from "node:fs/promises";
|
||||
import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
|
|
@ -25,6 +25,30 @@ export class StoreReplacedNotDurableError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/** Thrown when a write is asked of a store path that is a link: a replace there would swap the link for a regular file. */
|
||||
export class StoreLinkedError extends Error {
|
||||
constructor(readonly path: string) {
|
||||
super(`CultCache store ${path} is a link, and a write at it would replace the link; configure the link's target as the store path.`);
|
||||
this.name = "StoreLinkedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Refuses a link at the store path (file or directory link; libuv reports Windows junctions as links). Absent is not a link. */
|
||||
export async function refuseLinkedStore(storePath: string): Promise<void> {
|
||||
let linked = false;
|
||||
try {
|
||||
linked = (await lstat(storePath)).isSymbolicLink();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (linked) {
|
||||
throw new StoreLinkedError(storePath);
|
||||
}
|
||||
}
|
||||
|
||||
/** The file operations of one commit. Tests record or fail them; production uses `nodeStoreFileOps`. */
|
||||
export interface StoreFileOps {
|
||||
createExclusive(path: string): Promise<FileHandle>;
|
||||
|
|
@ -177,6 +201,7 @@ export async function withStoreWriteLock<T>(
|
|||
): Promise<T> {
|
||||
const lockPath = `${storePath}.lock`;
|
||||
const timeoutMs = options.timeoutMs ?? LOCK_TIMEOUT_MS;
|
||||
await refuseLinkedStore(storePath);
|
||||
await mkdir(dirname(lockPath), { recursive: true });
|
||||
|
||||
const started = Date.now();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { CultCache } from "../src/cult-cache";
|
|||
import { inspectCultCacheBytes } from "../src/cult-cache-inspector";
|
||||
import { defineDocumentRegistry, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document";
|
||||
import { SingleFileMessagePackBackingStore, readSingleFileStore, readStoreBytes } from "../src/single-file-messagepack-backing-store";
|
||||
import { StoreLinkedError, withStoreWriteLock } from "../src/store-file";
|
||||
import { SchemaConflictError, StoreUnreadableError, refuseUnreadStoreBytes } from "../src/store-format";
|
||||
import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types";
|
||||
|
||||
|
|
@ -2466,8 +2467,8 @@ test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path a
|
|||
() => store.pullAll(),
|
||||
(error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("symbolic link") && error.message.includes(file),
|
||||
);
|
||||
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");
|
||||
await assert.rejects(() => store.push(envelope), (error: unknown) => error instanceof StoreLinkedError);
|
||||
await assert.rejects(() => store.pushAll([envelope], { soft: true }), (error: unknown) => error instanceof StoreLinkedError);
|
||||
assert.ok((await lstat(file)).isSymbolicLink());
|
||||
assert.deepEqual(await readdir(volume), []);
|
||||
});
|
||||
|
|
@ -2508,9 +2509,9 @@ test("SingleFileMessagePackBackingStore record refusal names only a string key",
|
|||
}
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
// A write at a store path that is a link is refused: a replace there would swap the link for a regular file and
|
||||
// detach the store from its target. Reads through a live link keep working.
|
||||
test("a write at a live link is refused and nothing changes, while a read through it still works", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-live-link-"));
|
||||
const volume = join(dir, "volume");
|
||||
await mkdir(volume);
|
||||
|
|
@ -2518,11 +2519,31 @@ test("R3 decides resolve-or-refuse: a push through a live link replaces the link
|
|||
const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(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");
|
||||
const volumeBefore = (await readdir(volume)).sort();
|
||||
const link = join(dir, "link.cc");
|
||||
await symlink(target, link);
|
||||
await new SingleFileMessagePackBackingStore(link).push({ ...envelope, key: "b" });
|
||||
assert.ok((await lstat(link)).isFile());
|
||||
const store = new SingleFileMessagePackBackingStore(link);
|
||||
const refused = (error: unknown) => error instanceof StoreLinkedError && error.path === link && error.message.includes(link);
|
||||
await assert.rejects(() => store.push({ ...envelope, key: "b" }), refused);
|
||||
await assert.rejects(() => store.pushAll([{ ...envelope, key: "b" }]), refused);
|
||||
await assert.rejects(() => store.delete(envelope), refused);
|
||||
assert.ok((await lstat(link)).isSymbolicLink());
|
||||
assert.deepEqual(await readFile(target), before);
|
||||
assert.deepEqual((await readdir(dir)).sort(), ["link.cc", "volume"]);
|
||||
assert.deepEqual((await readdir(volume)).sort(), volumeBefore);
|
||||
assert.deepEqual((await store.pullAll()).map((e) => e.key), ["a"]);
|
||||
});
|
||||
|
||||
test("a write at a live directory link is refused as a link", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-dir-link-"));
|
||||
const realDir = join(dir, "real");
|
||||
await mkdir(realDir);
|
||||
const link = join(dir, "dirlink");
|
||||
await symlink(realDir, link);
|
||||
const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" };
|
||||
await assert.rejects(() => new SingleFileMessagePackBackingStore(link).push(envelope), (error: unknown) => error instanceof StoreLinkedError);
|
||||
assert.ok((await lstat(link)).isSymbolicLink());
|
||||
assert.deepEqual(await readdir(realDir), []);
|
||||
});
|
||||
|
||||
test("a pushAll through a dangling link is refused like push, and the link and its volume are left as they were", { skip: process.platform === "win32" }, async () => {
|
||||
|
|
@ -2532,7 +2553,7 @@ test("a pushAll through a dangling link is refused like push, and the link and i
|
|||
const link = join(dir, "store.cc");
|
||||
await symlink(join(volume, "store.cc"), link);
|
||||
const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" };
|
||||
await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([envelope]), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
|
||||
await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([envelope]), (error: unknown) => error instanceof StoreLinkedError);
|
||||
assert.ok((await lstat(link)).isSymbolicLink());
|
||||
assert.deepEqual(await readdir(volume), []);
|
||||
});
|
||||
|
|
@ -2827,7 +2848,6 @@ test("every registration refusal is a SchemaConflictError naming the id and both
|
|||
// Cross-runtime store lock: each runtime's writer excludes the others on `<file>.lock`.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { withStoreWriteLock } from "../src/store-file";
|
||||
|
||||
type LockHolder = { exited: Promise<number | null>; stderr: () => string };
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue