From bf29fb40ff758a19fc2ca44bf33b10059fc0e007 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 21:57:49 +0000 Subject: [PATCH] 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 --- packages/cultcache-ts/src/store-file.ts | 27 ++++++++++++- packages/cultcache-ts/test/cult-cache.test.ts | 40 ++++++++++++++----- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/cultcache-ts/src/store-file.ts b/packages/cultcache-ts/src/store-file.ts index 4c7015cc..e6988d23 100644 --- a/packages/cultcache-ts/src/store-file.ts +++ b/packages/cultcache-ts/src/store-file.ts @@ -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 { + 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; @@ -177,6 +201,7 @@ export async function withStoreWriteLock( ): Promise { const lockPath = `${storePath}.lock`; const timeoutMs = options.timeoutMs ?? LOCK_TIMEOUT_MS; + await refuseLinkedStore(storePath); await mkdir(dirname(lockPath), { recursive: true }); const started = Date.now(); diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 7c559af7..0ea29c62 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -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 `.lock`. import { spawn } from "node:child_process"; -import { withStoreWriteLock } from "../src/store-file"; type LockHolder = { exited: Promise; stderr: () => string };