fix(input)!: classify unresolvable source paths (#7847)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 01:52:34 +08:00
1 parent e79ad6ca8e
commit 5bef2b48b6
12 files changed
+175 -7

No files matched your search

+2 -1
View File
@@ -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";
};
+3
View File
@@ -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 }),
@@ -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,
});
}
};
+10 -2
View File
@@ -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;
+3 -1
View File
@@ -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",
);
+3
View File
@@ -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."
+1
View File
@@ -70,6 +70,7 @@ export class EvidenceFileError extends AnalysisError {
| "invalid-json"
| "missing"
| "path-limit"
| "symlink-traversal"
| "io",
options?: AnalysisErrorOptions & {
readonly path?: string;
+15 -1
View File
@@ -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.",
}
: {}),
};
};
@@ -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;
@@ -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,
});
}
};
@@ -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" })],
});
},
);
@@ -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"] })] },
});
},
);