diff --git a/docs/advanced.md b/docs/advanced.md index d03b7cb..fb0e7e2 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -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 | diff --git a/src/absolute-path.ts b/src/absolute-path.ts index 3a7b62f..d1395ff 100644 --- a/src/absolute-path.ts +++ b/src/absolute-path.ts @@ -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; +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 { + 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 { + 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 { diff --git a/test/coverage-gaps.test.ts b/test/coverage-gaps.test.ts index f935d93..4787959 100644 --- a/test/coverage-gaps.test.ts +++ b/test/coverage-gaps.test.ts @@ -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", () => {