refactor: structure absolute directory failures
This commit is contained in:
parent
aa02b4fa42
commit
f9e3d30d2d
@ -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 |
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user