fix(store): preserve sync read validation failures

This commit is contained in:
Peter Steinberger 2026-05-06 23:53:33 +01:00
parent 261ca3cbc0
commit b8f079c999
No known key found for this signature in database
3 changed files with 73 additions and 7 deletions

View File

@ -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

View File

@ -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<null>(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);

View File

@ -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<string> {
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" }),
);
});
});