mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(input)!: classify unresolvable source paths (#7847)
This commit is contained in:
1 parent
e79ad6ca8e
commit
5bef2b48b6
12 files changed
+175
-7
No files matched your search
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -70,6 +70,7 @@ export class EvidenceFileError extends AnalysisError {
|
||||
| "invalid-json"
|
||||
| "missing"
|
||||
| "path-limit"
|
||||
| "symlink-traversal"
|
||||
| "io",
|
||||
options?: AnalysisErrorOptions & {
|
||||
readonly path?: string;
|
||||
|
||||
@@ -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"] })] },
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user