From 5bef2b48b6022d1e1599d775d900a920d6d4b730 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:52:34 +0800 Subject: [PATCH] fix(input)!: classify unresolvable source paths (#7847) --- src/application/JsonFiles.ts | 3 +- src/application/ReferenceSourceImport.ts | 3 + .../ReferenceSourceImportPolicy.ts | 8 ++- src/application/ReferenceSourceImportTypes.ts | 12 +++- src/application/process/ProcessCli.ts | 4 +- src/domain/analysisErrorPresentation.ts | 3 + src/domain/evidenceErrors.ts | 1 + src/reference/ReferenceSourceReaderErrors.ts | 16 ++++- src/reference/ReferenceSourceReaderTypes.ts | 1 + .../ReferenceSourceReaderValidate.ts | 8 ++- .../acceptance/sourcePathFailuresCli.test.ts | 70 +++++++++++++++++++ .../filesystem/sourcePathFailures.test.ts | 53 ++++++++++++++ 12 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 tests/acceptance/sourcePathFailuresCli.test.ts create mode 100644 tests/boundary/filesystem/sourcePathFailures.test.ts diff --git a/src/application/JsonFiles.ts b/src/application/JsonFiles.ts index c9e1d26d89..f104a7e57d 100644 --- a/src/application/JsonFiles.ts +++ b/src/application/JsonFiles.ts @@ -460,9 +460,10 @@ const stagedCleanupFailure = < /** A missing path or parent is a selection error, not a permission failure. */ const fileAccessFailureReason = ( cause: unknown, -): "missing" | "io" | "path-limit" => { +): "missing" | "io" | "path-limit" | "symlink-traversal" => { const code = fileErrorCode(cause); if (code === "ENAMETOOLONG") return "path-limit"; + if (code === "ELOOP") return "symlink-traversal"; return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "io"; }; diff --git a/src/application/ReferenceSourceImport.ts b/src/application/ReferenceSourceImport.ts index 9a7ab69f2d..db90752bc0 100644 --- a/src/application/ReferenceSourceImport.ts +++ b/src/application/ReferenceSourceImport.ts @@ -283,6 +283,9 @@ const combineReadFailures = ( ); return { ...failure(code, message), + ...(primary.remediationAction === undefined || code !== primary.code + ? {} + : { remediationAction: primary.remediationAction }), ...(cleanup === undefined ? {} : { cleanup }), ...(partial === undefined ? {} : { partial }), ...(cause === undefined ? {} : { cause }), diff --git a/src/application/ReferenceSourceImportPolicy.ts b/src/application/ReferenceSourceImportPolicy.ts index b4adfd2737..e9ceeaa97e 100644 --- a/src/application/ReferenceSourceImportPolicy.ts +++ b/src/application/ReferenceSourceImportPolicy.ts @@ -104,7 +104,13 @@ const resolveRoot = async ( "Reference source root could not be resolved", ); if (rootFailure === undefined) throw cause; - return err({ ...failure(rootFailure.code, rootFailure.message), cause }); + return err({ + ...failure(rootFailure.code, rootFailure.message), + ...(rootFailure.remediationAction === undefined + ? {} + : { remediationAction: rootFailure.remediationAction }), + cause, + }); } }; diff --git a/src/application/ReferenceSourceImportTypes.ts b/src/application/ReferenceSourceImportTypes.ts index 45236c64dd..d9c2324b74 100644 --- a/src/application/ReferenceSourceImportTypes.ts +++ b/src/application/ReferenceSourceImportTypes.ts @@ -21,6 +21,7 @@ export interface ReferenceSourceImportError { readonly tag: "reference-source-import"; readonly code: "cancelled" | "invalid-root" | "unsupported" | "io" | "parse"; readonly message: string; + readonly remediationAction?: string; readonly cleanup?: AnalysisCleanupObservation; readonly partial?: ReferenceSourceRead; readonly cause?: unknown; @@ -42,7 +43,8 @@ const IMPORT_FAILURE_GUIDANCE = { export const projectReferenceSourceImportError = ( error: ReferenceSourceImportError, ): AnalysisErrorProjection => { - const guidance = IMPORT_FAILURE_GUIDANCE[error.code]; + const guidance = + error.remediationAction ?? IMPORT_FAILURE_GUIDANCE[error.code]; const message = `${error.message} ${guidance}`; const partial = error.partial; const options: AnalysisErrorOptions = { @@ -77,7 +79,13 @@ export const projectReferenceSourceImportError = ( case "invalid-root": primary = new AnalysisInputError( "import_reference_source", - { ...options, userMessage: message }, + { + ...options, + userMessage: message, + ...(error.remediationAction === undefined + ? {} + : { remediationAction: error.remediationAction }), + }, [{ path: ["root"], reason: "invalid_value", message: error.message }], ); break; diff --git a/src/application/process/ProcessCli.ts b/src/application/process/ProcessCli.ts index 007c41767c..fea70d23bf 100644 --- a/src/application/process/ProcessCli.ts +++ b/src/application/process/ProcessCli.ts @@ -202,7 +202,9 @@ const readJson = async ( operation, loaded.error.reason === "invalid-json" ? `Process input file is not valid UTF-8 JSON: ${path} (${detail}). Repair the file, then try again.` - : `Process input file could not be read: ${path} (${detail}). Check the reported filesystem constraint and retry.`, + : loaded.error.reason === "symlink-traversal" + ? `Process input file could not be read: ${path} (${detail}). Repair the symbolic-link chain or select another file.` + : `Process input file could not be read: ${path} (${detail}). Check the reported filesystem constraint and retry.`, ["input_file"], "invalid_value", ); diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index f480c9652b..fcab0bb0fd 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -265,6 +265,7 @@ export const analysisErrorCategory = ( if ( error instanceof EvidenceFileError && (error.reason === "missing" || + error.reason === "symlink-traversal" || error.reason === "not-file" || error.reason === "wrong-kind" || error.reason === "exists") @@ -569,6 +570,8 @@ const evidenceFileMessage = ({ return "The output file was published, but staging cleanup remains incomplete."; if (reason === "path-limit") return "Filesystem path-length constraints prevented reading or atomic publication at the selected path. Choose a shorter local path."; + if (reason === "symlink-traversal") + return "The selected path cannot be resolved because its symbolic-link chain loops or exceeds the host traversal limit. Repair the chain or select another path."; if (reason === "missing") return operation === "read" ? "Evidence file does not exist at the selected path. Check the path and try again." diff --git a/src/domain/evidenceErrors.ts b/src/domain/evidenceErrors.ts index 356f8fee3b..167de2a6b7 100644 --- a/src/domain/evidenceErrors.ts +++ b/src/domain/evidenceErrors.ts @@ -70,6 +70,7 @@ export class EvidenceFileError extends AnalysisError { | "invalid-json" | "missing" | "path-limit" + | "symlink-traversal" | "io", options?: AnalysisErrorOptions & { readonly path?: string; diff --git a/src/reference/ReferenceSourceReaderErrors.ts b/src/reference/ReferenceSourceReaderErrors.ts index a8b68399c4..5f3515ca8a 100644 --- a/src/reference/ReferenceSourceReaderErrors.ts +++ b/src/reference/ReferenceSourceReaderErrors.ts @@ -87,6 +87,7 @@ export const rootFilesystemFailure = ( | { readonly code: "invalid-root" | "io"; readonly message: string; + readonly remediationAction?: string; } | undefined => { if (!(cause instanceof Error)) return undefined; @@ -94,8 +95,21 @@ export const rootFilesystemFailure = ( if (typeof code !== "string" || !FILESYSTEM_ERROR_CODES.has(code)) return undefined; return { - code: code === "ENOENT" || code === "ENOTDIR" ? "invalid-root" : "io", + code: ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes(code) + ? "invalid-root" + : "io", message: `${operation}: ${cause.message}`, + ...(code === "ELOOP" + ? { + remediationAction: + "Repair the symbolic-link chain or select another reference source directory.", + } + : code === "ENAMETOOLONG" + ? { + remediationAction: + "Select a reference source path within the operating system's path-length limits.", + } + : {}), }; }; diff --git a/src/reference/ReferenceSourceReaderTypes.ts b/src/reference/ReferenceSourceReaderTypes.ts index 6899259842..c7422db3ea 100644 --- a/src/reference/ReferenceSourceReaderTypes.ts +++ b/src/reference/ReferenceSourceReaderTypes.ts @@ -86,6 +86,7 @@ export interface ReferenceSourceReaderError { readonly tag: "reference-source-reader"; readonly code: "cancelled" | "invalid-root" | "io" | "unsupported"; readonly message: string; + readonly remediationAction?: string; readonly cleanup?: AnalysisCleanupObservation; /** Captured entries before failure; this does not establish complete tree coverage. */ readonly partial?: ReferenceSourceRead; diff --git a/src/reference/ReferenceSourceReaderValidate.ts b/src/reference/ReferenceSourceReaderValidate.ts index e3eca29b15..0d95aab0c9 100644 --- a/src/reference/ReferenceSourceReaderValidate.ts +++ b/src/reference/ReferenceSourceReaderValidate.ts @@ -127,6 +127,12 @@ export const prepareRoot = async ( "Reference source root could not be resolved", ); if (rootFailure === undefined) throw cause; - return err(failure(rootFailure.code, `${rootFailure.message}: ${root}`)); + return err({ + ...failure(rootFailure.code, `${rootFailure.message}: ${root}`), + ...(rootFailure.remediationAction === undefined + ? {} + : { remediationAction: rootFailure.remediationAction }), + cause, + }); } }; diff --git a/tests/acceptance/sourcePathFailuresCli.test.ts b/tests/acceptance/sourcePathFailuresCli.test.ts new file mode 100644 index 0000000000..e0821d37b4 --- /dev/null +++ b/tests/acceptance/sourcePathFailuresCli.test.ts @@ -0,0 +1,70 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest.skipIf(process.platform === "win32")( + "reports unresolvable source and scenario selections without permission advice", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-source-path-failures-"); + await symlink("loop-b", join(root, "loop-a")); + await symlink("loop-a", join(root, "loop-b")); + await mkdir(join(root, "valid")); + await writeFile( + join(root, "valid", "index.js"), + "export const value = 1;\n", + ); + await writeFile(join(root, "file"), "not a directory"); + + for (const [path, errno, guidance] of [ + [join(root, "loop-a"), "ELOOP", "symbolic-link"], + [join(root, "x".repeat(300)), "ENAMETOOLONG", "path-length"], + ] as const) { + const result = await cli.run({ + arguments: ["import-reference-source", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining(errno), + remediation: { action: expect.stringContaining(guidance) }, + details: { issues: [expect.objectContaining({ path: ["root"] })] }, + }); + } + + const loop = join(root, "loop-a"); + const capture = await cli.run({ + arguments: ["capture-process", loop, "--json"], + }); + expect(capture.exitCode).toBe(1); + expect(capture.json).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining("ELOOP"), + details: { issues: [expect.objectContaining({ path: ["input_file"] })] }, + }); + expect(JSON.stringify(capture.json)).toContain(loop); + expect(JSON.stringify(capture.json)).toContain("symbolic-link"); + + for (const path of [join(root, "missing"), join(root, "file")]) { + const result = await cli.run({ + arguments: ["import-reference-source", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + }); + } + const valid = await cli.run({ + arguments: ["import-reference-source", join(root, "valid"), "--json"], + }); + expect(valid.exitCode, valid.stderr).toBe(0); + expect(valid.json).toMatchObject({ + entries: [expect.objectContaining({ path: "index.js" })], + }); + }, +); diff --git a/tests/boundary/filesystem/sourcePathFailures.test.ts b/tests/boundary/filesystem/sourcePathFailures.test.ts new file mode 100644 index 0000000000..beb66c4e41 --- /dev/null +++ b/tests/boundary/filesystem/sourcePathFailures.test.ts @@ -0,0 +1,53 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { captureProcessScenarioFile } from "../../../src/application/process/ProcessCli.js"; +import { projectReferenceSourceImportError } from "../../../src/application/ReferenceSourceImportTypes.js"; +import { readJsonFile } from "../../../src/application/JsonFiles.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; + +it.skipIf(process.platform === "win32")( + "preserves root-selection causes for a loop and an overlong component", + async () => { + const root = await createTestTempDirectory("rea-source-path-causes-"); + await symlink("loop-b", join(root, "loop-a")); + await symlink("loop-a", join(root, "loop-b")); + await mkdir(join(root, "valid")); + await writeFile(join(root, "valid", "source.js"), "export const x = 1;"); + for (const [path, errno, advice] of [ + [join(root, "loop-a"), "ELOOP", "symbolic-link"], + [join(root, "x".repeat(300)), "ENAMETOOLONG", "path-length"], + ] as const) { + const result = await importReferenceSource({ + root: path, + caller: "source-path-causes", + policy: { secretPatterns: [] }, + }); + if (result.ok) throw new Error("expected invalid root"); + expect(projectReferenceSourceImportError(result.error)).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining(errno), + remediation: { action: expect.stringContaining(advice) }, + details: { issues: [expect.objectContaining({ path: ["root"] })] }, + }); + } + const path = join(root, "loop-a"); + const read = await readJsonFile(path); + if (read.ok) throw new Error("expected loop refusal"); + expect(projectAnalysisError(read.error)).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining("symbolic-link"), + }); + expect(await captureProcessScenarioFile(path)).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining("ELOOP"), + details: { issues: [expect.objectContaining({ path: ["input_file"] })] }, + }); + }, +);