refactor: structure absolute directory failures

This commit is contained in:
Peter Steinberger 2026-05-07 10:32:16 +01:00
parent aa02b4fa42
commit f9e3d30d2d
No known key found for this signature in database
3 changed files with 143 additions and 55 deletions

View File

@ -47,6 +47,10 @@ locations, such as a configured output root. It does not enforce a root boundary
use `pathScope().ensureDir()` or `ensureDirectoryWithinRoot()` when the caller
supplies a path that must stay under a root.
The helper returns `{ ok: false, code, error }` for path-policy failures such as
relative paths, symlinks, non-directories, or directory swaps during creation.
Operational filesystem failures such as permissions or I/O errors are rethrown.
### Files and identity
| Export | Page | Notes |

View File

@ -6,7 +6,7 @@ import {
type AsyncDirectoryGuard,
createAsyncDirectoryGuard,
} from "./directory-guard.js";
import { FsSafeError } from "./errors.js";
import { FsSafeError, type FsSafeErrorCode } from "./errors.js";
export type AbsolutePathSymlinkPolicy = "reject" | "follow";
@ -27,15 +27,53 @@ export type EnsureAbsoluteDirectoryOptions = {
export type EnsureAbsoluteDirectoryResult =
| { ok: true; path: string }
| { ok: false; error: string };
| { ok: false; code: FsSafeErrorCode; error: FsSafeError };
function invalidDirectoryPath(scopeLabel: string): EnsureAbsoluteDirectoryResult {
type EnsureAbsoluteDirectoryFailure = Extract<EnsureAbsoluteDirectoryResult, { ok: false }>;
type DirectoryGuardCheckResult = { ok: true } | EnsureAbsoluteDirectoryFailure;
type DirectoryGuardCreateResult =
| { ok: true; guard: AsyncDirectoryGuard }
| EnsureAbsoluteDirectoryFailure;
function ensureDirectoryFailure(
code: FsSafeErrorCode,
message: string,
cause?: unknown,
): EnsureAbsoluteDirectoryFailure {
return {
ok: false,
error: `Invalid path: must be a real directory within ${scopeLabel}`,
code,
error: new FsSafeError(code, message, { cause }),
};
}
async function assertGuardResult(
guard: AsyncDirectoryGuard,
): Promise<DirectoryGuardCheckResult> {
try {
await assertAsyncDirectoryGuard(guard);
return { ok: true };
} catch (err) {
if (err instanceof FsSafeError) {
return { ok: false, code: err.code, error: err };
}
throw err;
}
}
async function createDirectoryGuardResult(
dir: string,
): Promise<DirectoryGuardCreateResult> {
try {
return { ok: true, guard: await createAsyncDirectoryGuard(dir) };
} catch (err) {
if (err instanceof FsSafeError) {
return { ok: false, code: err.code, error: err };
}
throw err;
}
}
export function assertAbsolutePathInput(filePath: string): string {
if (!filePath) {
throw new FsSafeError("invalid-path", "path is required");
@ -93,64 +131,90 @@ export async function ensureAbsoluteDirectory(
targetPath = assertAbsolutePathInput(dirPath);
} catch (err) {
if (err instanceof FsSafeError) {
return { ok: false, error: err.message };
return { ok: false, code: err.code, error: err };
}
throw err;
}
try {
const ancestor = await findExistingAncestorWithStat(targetPath);
if (!ancestor) {
return invalidDirectoryPath(scopeLabel);
}
const ancestor = await findExistingAncestorWithStat(targetPath);
if (!ancestor) {
return ensureDirectoryFailure(
"not-found",
`directory path must have a real existing ancestor within ${scopeLabel}`,
);
}
if (ancestor.stat.isSymbolicLink() || !ancestor.stat.isDirectory()) {
return invalidDirectoryPath(scopeLabel);
}
if (ancestor.stat.isSymbolicLink()) {
return ensureDirectoryFailure("symlink", `directory path traverses a symlink within ${scopeLabel}`);
}
if (!ancestor.stat.isDirectory()) {
return ensureDirectoryFailure("not-file", `path must be a real directory within ${scopeLabel}`);
}
const ancestorDir = ancestor.path;
const relativeDir = path.relative(ancestorDir, targetPath);
let current = ancestorDir;
let currentGuard: AsyncDirectoryGuard = {
dir: ancestorDir,
realPath: await fs.realpath(ancestorDir),
stat: ancestor.stat,
};
for (const segment of relativeDir.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
while (true) {
const ancestorDir = ancestor.path;
const relativeDir = path.relative(ancestorDir, targetPath);
let current = ancestorDir;
let currentGuard: AsyncDirectoryGuard = {
dir: ancestorDir,
realPath: await fs.realpath(ancestorDir),
stat: ancestor.stat,
};
for (const segment of relativeDir.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
while (true) {
const guardResult = await assertGuardResult(currentGuard);
if (!guardResult.ok) {
return guardResult;
}
try {
const stat = await fs.lstat(current);
if (stat.isSymbolicLink()) {
return ensureDirectoryFailure(
"symlink",
`directory path traverses a symlink within ${scopeLabel}`,
);
}
if (!stat.isDirectory()) {
return ensureDirectoryFailure(
"not-file",
`path must be a real directory within ${scopeLabel}`,
);
}
break;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
const parentStillValid = await assertGuardResult(currentGuard);
if (!parentStillValid.ok) {
return parentStillValid;
}
try {
await assertAsyncDirectoryGuard(currentGuard);
const stat = await fs.lstat(current);
if (stat.isSymbolicLink() || !stat.isDirectory()) {
return invalidDirectoryPath(scopeLabel);
}
break;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
try {
await assertAsyncDirectoryGuard(currentGuard);
await fs.mkdir(current, { mode: options.mode });
} catch (mkdirErr) {
if ((mkdirErr as NodeJS.ErrnoException).code === "EEXIST") {
continue;
}
throw mkdirErr;
await fs.mkdir(current, { mode: options.mode });
} catch (mkdirErr) {
if ((mkdirErr as NodeJS.ErrnoException).code === "EEXIST") {
continue;
}
throw mkdirErr;
}
}
const nextGuard = await createAsyncDirectoryGuard(current);
await assertAsyncDirectoryGuard(currentGuard);
currentGuard = nextGuard;
}
await assertAsyncDirectoryGuard(currentGuard);
return { ok: true, path: targetPath };
} catch {
return invalidDirectoryPath(scopeLabel);
const nextGuard = await createDirectoryGuardResult(current);
if (!nextGuard.ok) {
return nextGuard;
}
const previousGuardStillValid = await assertGuardResult(currentGuard);
if (!previousGuardStillValid.ok) {
return previousGuardStillValid;
}
currentGuard = nextGuard.guard;
}
const finalGuardResult = await assertGuardResult(currentGuard);
if (!finalGuardResult.ok) {
return finalGuardResult;
}
return { ok: true, path: targetPath };
}
export async function canonicalPathFromExistingAncestor(filePath: string): Promise<string> {

View File

@ -259,7 +259,7 @@ describe("absolute path helpers", () => {
ensureAbsoluteDirectory(path.join("..", "..", "..", "escape"), {
scopeLabel: "output directory",
}),
).resolves.toEqual({ ok: false, error: "path must be absolute" });
).resolves.toMatchObject({ ok: false, code: "invalid-path" });
});
it("rejects absolute directory creation when the existing target is not a directory", async () => {
@ -269,7 +269,7 @@ describe("absolute path helpers", () => {
await expect(
ensureAbsoluteDirectory(targetPath, { scopeLabel: "output directory" }),
).resolves.toMatchObject({ ok: false });
).resolves.toMatchObject({ ok: false, code: "not-file" });
});
it.runIf(process.platform !== "win32")(
@ -284,7 +284,7 @@ describe("absolute path helpers", () => {
ensureAbsoluteDirectory(path.join(linkDir, "nested"), {
scopeLabel: "output directory",
}),
).resolves.toMatchObject({ ok: false });
).resolves.toMatchObject({ ok: false, code: "symlink" });
await expect(fs.readdir(outside)).resolves.toEqual([]);
},
);
@ -313,7 +313,7 @@ describe("absolute path helpers", () => {
try {
await expect(
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory" }),
).resolves.toMatchObject({ ok: false });
).resolves.toMatchObject({ ok: false, code: "symlink" });
} finally {
lstatSpy.mockRestore();
}
@ -349,12 +349,32 @@ describe("absolute path helpers", () => {
try {
await expect(
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory" }),
).resolves.toMatchObject({ ok: false });
).resolves.toMatchObject({ ok: false, code: "not-file" });
} finally {
realpathSpy.mockRestore();
}
},
);
it("rethrows operational absolute directory creation failures", async () => {
const root = await fs.realpath(await tempRoot("fs-safe-absolute-dir-io-"));
const targetDir = path.join(root, "nested");
const realMkdir = fs.mkdir.bind(fs);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockImplementation(async (...args) => {
if (String(args[0]) === targetDir) {
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
}
return await realMkdir(...args);
});
try {
await expect(
ensureAbsoluteDirectory(targetDir, { scopeLabel: "output directory" }),
).rejects.toMatchObject({ code: "EACCES" });
} finally {
mkdirSpy.mockRestore();
}
});
});
describe("filesystem utility helpers", () => {