diff --git a/src/application/ReferenceSourceImportPolicy.ts b/src/application/ReferenceSourceImportPolicy.ts index e358c3636c..55a992d53a 100644 --- a/src/application/ReferenceSourceImportPolicy.ts +++ b/src/application/ReferenceSourceImportPolicy.ts @@ -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> => { + 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 > => { - const root = await resolveRoot(options.root); + const root = await resolveRoot(options.root, options.signal); if (!root.ok) return root; try { return ok({ diff --git a/src/reference/ReferenceSourceReaderErrors.ts b/src/reference/ReferenceSourceReaderErrors.ts index 0212d1a002..49877be725 100644 --- a/src/reference/ReferenceSourceReaderErrors.ts +++ b/src/reference/ReferenceSourceReaderErrors.ts @@ -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, diff --git a/src/reference/ReferenceSourceReaderValidate.ts b/src/reference/ReferenceSourceReaderValidate.ts index a941f674bd..750426e1ab 100644 --- a/src/reference/ReferenceSourceReaderValidate.ts +++ b/src/reference/ReferenceSourceReaderValidate.ts @@ -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}`)); } }; diff --git a/tests/boundary/cli/referenceSourceImportStatus.test.ts b/tests/boundary/cli/referenceSourceImportStatus.test.ts index dbc8bcaf1c..c15a1fe630 100644 --- a/tests/boundary/cli/referenceSourceImportStatus.test.ts +++ b/tests/boundary/cli/referenceSourceImportStatus.test.ts @@ -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); + } + }, + ); }); diff --git a/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts b/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts index a4810e312d..fd9e07149a 100644 --- a/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts +++ b/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts @@ -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 () => {