fix(root): guard fallback mutator parents

This commit is contained in:
Peter Steinberger 2026-05-06 20:57:24 +01:00
parent c8fabd7aee
commit ee0eb18a6d
No known key found for this signature in database
3 changed files with 84 additions and 17 deletions

49
src/directory-guard.ts Normal file
View File

@ -0,0 +1,49 @@
import type { Stats } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { FsSafeError } from "./errors.js";
import { sameFileIdentity } from "./file-identity.js";
import { isNotFoundPathError } from "./path.js";
export type AsyncDirectoryGuard = {
dir: string;
realPath: string;
stat: Stats;
};
export async function createAsyncDirectoryGuard(dir: string): Promise<AsyncDirectoryGuard> {
const stat = await fs.lstat(dir);
if (stat.isSymbolicLink() || !stat.isDirectory()) {
throw new FsSafeError("not-file", "directory component must be a directory");
}
return { dir, realPath: await fs.realpath(dir), stat };
}
export async function assertAsyncDirectoryGuard(guard: AsyncDirectoryGuard): Promise<void> {
const stat = await fs.lstat(guard.dir);
if (stat.isSymbolicLink() || !stat.isDirectory()) {
throw new FsSafeError("not-file", "directory component must be a directory");
}
if (!sameFileIdentity(stat, guard.stat) || (await fs.realpath(guard.dir)) !== guard.realPath) {
throw new FsSafeError("path-mismatch", "directory changed during operation");
}
}
export async function createNearestExistingDirectoryGuard(
rootReal: string,
targetPath: string,
): Promise<AsyncDirectoryGuard> {
let current = path.resolve(targetPath);
const root = path.resolve(rootReal);
while (current !== root) {
try {
return await createAsyncDirectoryGuard(current);
} catch (error) {
if (!isNotFoundPathError(error)) {
throw error;
}
current = path.dirname(current);
}
}
return await createAsyncDirectoryGuard(root);
}

18
src/path-stat.ts Normal file
View File

@ -0,0 +1,18 @@
import type { Stats } from "node:fs";
import type { PathStat } from "./types.js";
export function pathStatFromStats(stat: Stats): PathStat {
return {
dev: Number(stat.dev),
gid: Number(stat.gid),
ino: Number(stat.ino),
isDirectory: stat.isDirectory(),
isFile: stat.isFile(),
isSymbolicLink: stat.isSymbolicLink(),
mode: stat.mode,
mtimeMs: stat.mtimeMs,
nlink: stat.nlink,
size: stat.size,
uid: stat.uid,
};
}

View File

@ -6,6 +6,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { assertAsyncDirectoryGuard, createAsyncDirectoryGuard, createNearestExistingDirectoryGuard } from "./directory-guard.js";
import { FsSafeError } from "./errors.js";
import { sameFileIdentity } from "./file-identity.js";
import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js";
@ -24,6 +25,7 @@ import {
helperStat,
runPinnedHelper,
} from "./pinned-helper.js";
import { pathStatFromStats } from "./path-stat.js";
import { resolveRootPath } from "./root-path.js";
import {
assertValidRootRelativePath,
@ -1395,27 +1397,19 @@ async function resolvePinnedRootPathInRoot(
}
async function removePathFallback(resolved: { resolved: string }): Promise<void> {
const guard = await createAsyncDirectoryGuard(path.dirname(resolved.resolved));
await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("remove", resolved.resolved);
await assertAsyncDirectoryGuard(guard);
await fs.rm(resolved.resolved);
await assertAsyncDirectoryGuard(guard).catch(() => undefined);
}
async function mkdirPathFallback(resolved: { resolved: string }): Promise<void> {
async function mkdirPathFallback(resolved: { rootReal: string; resolved: string }): Promise<void> {
const guard = await createNearestExistingDirectoryGuard(resolved.rootReal, resolved.resolved);
await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("mkdir", resolved.resolved);
await assertAsyncDirectoryGuard(guard);
await fs.mkdir(resolved.resolved, { recursive: true });
}
function pathStatFromStats(stat: Stats): PathStat {
return {
dev: Number(stat.dev),
gid: Number(stat.gid),
ino: Number(stat.ino),
isDirectory: stat.isDirectory(),
isFile: stat.isFile(),
isSymbolicLink: stat.isSymbolicLink(),
mode: stat.mode,
mtimeMs: stat.mtimeMs,
nlink: stat.nlink,
size: stat.size,
uid: stat.uid,
};
await assertAsyncDirectoryGuard(guard);
}
async function statPathFallback(root: RootContext, relativePath: string): Promise<PathStat> {
@ -1524,6 +1518,11 @@ async function movePathFallback(
}
}
const sourceParentGuard = await createAsyncDirectoryGuard(path.dirname(source.resolved));
const targetParentGuard = await createNearestExistingDirectoryGuard(target.rootReal, path.dirname(target.resolved));
await getFsSafeTestHooks()?.beforeRootFallbackMutation?.("move", target.resolved);
await assertAsyncDirectoryGuard(sourceParentGuard);
await assertAsyncDirectoryGuard(targetParentGuard);
try {
await fs.rename(source.resolved, target.resolved);
} catch (error) {
@ -1539,6 +1538,7 @@ async function movePathFallback(
}
throw error;
}
await assertAsyncDirectoryGuard(targetParentGuard).catch(() => undefined);
}
async function writeFileFallback(