mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user