fix(reference): distinguish root access failures from invalid input

Share root filesystem failure classification between import preflight and traversal. Missing roots remain invalid selections; permission and I/O errors retain their reason and reach the CLI as execution failures. Preserve cancellation priority and expose unexpected exceptions.

Validation: compiled CLI permission regression, direct reader error details, import/traversal and unreadable-directory boundaries passed. Windows-only cases were skipped on macOS; static, architecture and documentation checks passed.
This commit is contained in:
morluto
2026-10-11 03:53:50 +08:00
parent 2014ee7d68
commit 3967cfa43e
5 changed files with 113 additions and 10 deletions
+24 -5
View File
@@ -10,6 +10,7 @@ import {
type ReferenceSourceImportError,
type ReferenceSourceImportOptions,
} from "./ReferenceSourceImportTypes.js";
import { rootFilesystemFailure } from "../reference/ReferenceSourceReaderErrors.js";
/** Validated inputs ready for filesystem traversal. */
export interface PreparedReferenceSourceImport {
@@ -27,21 +28,39 @@ const failure = (
message,
});
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;
const resolveRoot = async (
requestedRoot: string,
signal?: AbortSignal,
): Promise<Result<string, ReferenceSourceImportError>> => {
if (isAborted(signal))
return err(failure("cancelled", "Reference source import cancelled"));
try {
if (!(await stat(requestedRoot)).isDirectory())
const metadata = await stat(requestedRoot);
if (isAborted(signal))
return err(failure("cancelled", "Reference source import cancelled"));
if (!metadata.isDirectory())
return err(
failure("invalid-root", "Reference source root is not a directory"),
);
const canonicalRoot = await realpath(resolve(requestedRoot));
if (isAborted(signal))
return err(failure("cancelled", "Reference source import cancelled"));
return ok(canonicalRoot);
} catch (cause: unknown) {
void cause;
return err(
failure("invalid-root", "Reference source root could not be resolved"),
if (isAborted(signal))
return err(failure("cancelled", "Reference source import cancelled"));
const rootFailure = rootFilesystemFailure(
cause,
"Reference source root could not be resolved",
);
if (rootFailure === undefined) throw cause;
const message =
rootFailure.code === "invalid-root"
? "Reference source root could not be resolved"
: rootFailure.message;
return err(failure(rootFailure.code, message));
}
};
@@ -83,7 +102,7 @@ export const prepareReferenceSourceImport = async (
): Promise<
Result<PreparedReferenceSourceImport, ReferenceSourceImportError>
> => {
const root = await resolveRoot(options.root);
const root = await resolveRoot(options.root, options.signal);
if (!root.ok) return root;
try {
return ok({
@@ -49,6 +49,26 @@ export const filesystemFailureDetail = (
: undefined;
};
/** Preserve root I/O detail while separating bad selections from access failures. */
export const rootFilesystemFailure = (
cause: unknown,
operation: string,
):
| {
readonly code: "invalid-root" | "io";
readonly message: string;
}
| undefined => {
if (!(cause instanceof Error)) return undefined;
const code: unknown = Reflect.get(cause, "code");
if (typeof code !== "string" || !FILESYSTEM_ERROR_CODES.has(code))
return undefined;
return {
code: code === "ENOENT" || code === "ENOTDIR" ? "invalid-root" : "io",
message: `${operation}: ${cause.message}`,
};
};
export const entryFailure = (
...[path, kind, code, message, size]: readonly [
string,
@@ -7,6 +7,7 @@ import {
cancelled,
failure,
filesystemFailureDetail,
rootFilesystemFailure,
} from "./ReferenceSourceReaderErrors.js";
import { isPathWithinRoot } from "../domain/localPath.js";
import {
@@ -120,11 +121,11 @@ export const prepareRoot = async (
return ok({ canonicalRoot, rootIdentity: canonicalMetadata });
} catch (cause: unknown) {
if (isAborted(signal)) return err(cancelled());
const message = filesystemFailureDetail(
const rootFailure = rootFilesystemFailure(
cause,
"Reference source root could not be resolved",
);
if (message === undefined) throw cause;
return err(failure("invalid-root", `${message}: ${root}`));
if (rootFailure === undefined) throw cause;
return err(failure(rootFailure.code, `${rootFailure.message}: ${root}`));
}
};
@@ -1,4 +1,4 @@
import { readFile, writeFile } from "node:fs/promises";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
@@ -20,6 +20,9 @@ const unsupportedHostOutput = {
"Safe no-follow file opens are unavailable on this host. Import the source tree with REA on Linux (including WSL) or macOS.",
};
const permissionChecksUnavailable =
process.platform === "win32" || process.getuid?.() === 0;
describe("compiled Windows reference-source import", () => {
for (const scenario of [
{ fullOutput: false, logging: false },
@@ -132,4 +135,38 @@ describe("compiled reference-source import preflight failures", () => {
},
);
}
cliTest.skipIf(permissionChecksUnavailable)(
"classifies a blocked parent directory as an execution failure",
async ({ cli }) => {
const directory = await createTestTempDirectory(
"rea-reference-cli-blocked-root-",
);
const parent = join(directory, "blocked-parent");
const root = join(parent, "input");
await mkdir(root, { recursive: true });
await chmod(parent, 0);
try {
const result = await cli.run({
arguments: ["import-reference-source", root, "--json"],
cwd: directory,
environment: {
HOME: directory,
USERPROFILE: directory,
XDG_CONFIG_HOME: directory,
XDG_CACHE_HOME: directory,
},
});
expect(result.json).toEqual({
error: "Import failed",
category: "execution_failure",
message:
"Reference source files could not be read. Check directory permissions and try again.",
});
expect(result.exitCode).toBe(1);
} finally {
await chmod(parent, 0o700);
}
},
);
});
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { chmod, writeFile } from "node:fs/promises";
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -20,6 +20,32 @@ const permissionChecksUnavailable =
process.platform === "win32" || process.getuid?.() === 0;
describe("reference import native entry failure details", () => {
it.skipIf(permissionChecksUnavailable)(
"classifies a root behind a blocked parent as an I/O failure",
async () => {
const directory = await createTestTempDirectory(
"rea-reference-root-permission-",
);
const parent = join(directory, "blocked-parent");
const root = join(parent, "source");
await mkdir(root, { recursive: true });
await chmod(parent, 0);
try {
const read = await readReferenceSource(root);
expect(read).toMatchObject({
ok: false,
error: {
tag: "reference-source-reader",
code: "io",
message: expect.stringContaining("EACCES"),
},
});
} finally {
await chmod(parent, 0o700);
}
},
);
it.skipIf(permissionChecksUnavailable)(
"retains the actual operating-system reason for an unreadable file",
async () => {