diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e6883..c2ab4aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Route archive ZIP staging, temp workspace sync reads, secret-file commits, and atomic move/replace fallbacks through shared pinned-read or guarded-write primitives without applying private-directory modes to public paths. - Close guarded fallback write handles without following path names if post-write directory verification fails, avoiding descriptor leaks and unsafe cleanup in symlink-swap races. - Preserve empty-directory pruning and broken-symlink trash moves across guarded fallback paths. +- Preserve sync file-store read policy errors for directory and hardlink validation failures. ### Tests diff --git a/src/file-store.ts b/src/file-store.ts index a9aeaf0..5592c87 100644 --- a/src/file-store.ts +++ b/src/file-store.ts @@ -21,7 +21,7 @@ import { import { createJsonStore, type JsonFileStoreOptions, type JsonStore } from "./json-document-store.js"; import { isPathInside, resolveSafeRelativePath } from "./path.js"; import { root, type OpenResult, type ReadResult, type Root, type RootReadOptions } from "./root.js"; -import { openRootFileSync } from "./root-file.js"; +import { matchRootFileOpenFailure, openRootFileSync, type RootFileOpenFailure } from "./root-file.js"; import { writeSecretFileAtomic } from "./secret-file.js"; import { getFsSafeTestHooks } from "./test-hooks.js"; @@ -129,12 +129,40 @@ function assertMaxBytes(size: number, maxBytes?: number): void { } function isNotFound(error: unknown): boolean { + if (!error) { + return false; + } return error instanceof FsSafeError ? error.code === "not-found" : (error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR"; } +function handleSyncStoreReadOpenFailure(opened: RootFileOpenFailure): null { + return matchRootFileOpenFailure(opened, { + path: (failure) => { + if (isNotFound(failure.error)) { + return null; + } + throw new FsSafeError("path-mismatch", "store target changed during read", { + cause: failure.error instanceof Error ? failure.error : undefined, + }); + }, + validation: (failure) => { + // Validation failures mean the path existed but violated store policy + // (directory, hardlink, symlink race). Do not report them as missing. + throw new FsSafeError("path-mismatch", "store target failed read validation", { + cause: failure.error instanceof Error ? failure.error : undefined, + }); + }, + fallback: (failure) => { + throw new FsSafeError("path-mismatch", "store target changed during read", { + cause: failure.error instanceof Error ? failure.error : undefined, + }); + }, + }); +} + async function copyIntoRoot(params: { rootDir: string; relativePath: string; @@ -521,12 +549,7 @@ export function fileStoreSync(options: FileStoreOptions): FileStoreSync { rejectHardlinks: privateMode, }); if (!opened.ok) { - if (isNotFound(opened.error)) { - return null; - } - throw new FsSafeError("path-mismatch", "store target changed during read", { - cause: opened.error instanceof Error ? opened.error : undefined, - }); + return handleSyncStoreReadOpenFailure(opened); } try { assertMaxBytes(opened.stat.size, readOptions?.maxBytes ?? maxBytes); diff --git a/test/file-store-sync-read-validation.test.ts b/test/file-store-sync-read-validation.test.ts new file mode 100644 index 0000000..5a01d50 --- /dev/null +++ b/test/file-store-sync-read-validation.test.ts @@ -0,0 +1,42 @@ +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { fileStoreSync } from "../src/file-store.js"; + +const tempDirs: string[] = []; + +async function tempRoot(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("sync file-store read validation failures", () => { + it("surfaces directories as filesystem safety errors", async () => { + const root = await tempRoot("fs-safe-sync-store-validation-"); + await fs.mkdir(path.join(root, "not-a-file")); + const store = fileStoreSync({ rootDir: root, private: true }); + + expect(() => store.readTextIfExists("not-a-file")).toThrow( + expect.objectContaining({ code: "path-mismatch" }), + ); + }); + + it.runIf(process.platform !== "win32")("surfaces hardlinks as filesystem safety errors", async () => { + const root = await tempRoot("fs-safe-sync-store-hardlink-"); + const filePath = path.join(root, "value.txt"); + await fs.writeFile(filePath, "secret"); + fsSync.linkSync(filePath, path.join(root, "alias.txt")); + const store = fileStoreSync({ rootDir: root, private: true }); + + expect(() => store.readTextIfExists("value.txt")).toThrow( + expect.objectContaining({ code: "path-mismatch" }), + ); + }); +});