fix(store): preserve sync read validation failures
This commit is contained in:
parent
261ca3cbc0
commit
b8f079c999
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
42
test/file-store-sync-read-validation.test.ts
Normal file
42
test/file-store-sync-read-validation.test.ts
Normal 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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user