mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(browser): classify selected web trace file failures by cause (#1396)
trace_web_module_imports and trace_web_source_location reported a missing
or non-file manifest, source map or import map as artifact_operation_failed
("Artifact could not be read or written" / "unsafe or conflicting internal
path"). These are caller selections: report ENOENT/ENOTDIR and non-regular
files as invalid input on the selected field, and EACCES/EPERM as a host
access denial. A missing manifest-derived source file stays an artifact fault.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
498cec042a
commit
2b6e9ec03f
6 files changed
+138
-18
No files matched your search
@@ -25,6 +25,7 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
|
||||
async load(input: SelectedWebScriptInput, options?: ExecutionOptions) {
|
||||
let field: readonly (string | number)[] = ["manifest_path"];
|
||||
let targetPath = input.manifest_path;
|
||||
let callerSelected = true;
|
||||
try {
|
||||
const manifestBytes = await readWebArtifactJson(
|
||||
input.manifest_path,
|
||||
@@ -58,6 +59,7 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
|
||||
const root = resolve(dirname(input.manifest_path));
|
||||
const sourcePath = join(root, "files", portable);
|
||||
targetPath = sourcePath;
|
||||
callerSelected = false;
|
||||
await assertContained(root, sourcePath);
|
||||
const sourceBytes = await readStableArtifact(
|
||||
sourcePath,
|
||||
@@ -99,7 +101,7 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
|
||||
return err(
|
||||
webArtifactReadError(
|
||||
cause,
|
||||
{ operation: this.operation, field, targetPath },
|
||||
{ operation: this.operation, field, targetPath, callerSelected },
|
||||
options?.signal,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
@@ -15,6 +16,8 @@ export interface WebArtifactReadContext {
|
||||
readonly operation: "trace_web_module_imports" | "trace_web_source_location";
|
||||
readonly field: readonly (string | number)[];
|
||||
readonly targetPath: string;
|
||||
/** Whether the caller named this file, rather than a manifest deriving it. */
|
||||
readonly callerSelected: boolean;
|
||||
}
|
||||
|
||||
/** A malformed producer document, distinct from host I/O failures. */
|
||||
@@ -54,6 +57,10 @@ export const webArtifactReadError = (
|
||||
): AnalysisError => {
|
||||
if (signal?.aborted === true)
|
||||
return new AnalysisCancelledError(context.operation);
|
||||
const selection = context.callerSelected
|
||||
? selectedFileError(cause, context)
|
||||
: undefined;
|
||||
if (selection !== undefined) return selection;
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return new ArtifactOperationError(
|
||||
context.operation,
|
||||
@@ -91,3 +98,33 @@ export const webArtifactReadError = (
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Report a missing, non-regular or unreadable caller-selected file as the
|
||||
* caller's selection or a host permission denial, not an artifact fault.
|
||||
*/
|
||||
const selectedFileError = (
|
||||
cause: unknown,
|
||||
context: WebArtifactReadContext,
|
||||
): AnalysisError | undefined => {
|
||||
const selected = (message: string) =>
|
||||
new AnalysisInputError(context.operation, { cause }, [
|
||||
{ path: context.field, reason: "invalid_value", message },
|
||||
]);
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return cause.reason === "path" ? selected(cause.message) : undefined;
|
||||
const code =
|
||||
cause instanceof Error && "code" in cause ? String(cause.code) : "";
|
||||
if (code === "EACCES" || code === "EPERM")
|
||||
return new AnalysisAccessDeniedError(
|
||||
context.operation,
|
||||
context.targetPath,
|
||||
code,
|
||||
{ cause },
|
||||
);
|
||||
if (code === "ENOENT" || code === "ENOTDIR")
|
||||
return selected(
|
||||
`Selected file could not be read (${code}): ${context.targetPath}`,
|
||||
);
|
||||
return undefined;
|
||||
};
|
||||
@@ -48,6 +48,7 @@ export class LocalWebSourceLocationArtifacts implements WebSourceLocationArtifac
|
||||
operation: "trace_web_source_location",
|
||||
field: ["source_map", "path"],
|
||||
targetPath: input.source_map.path,
|
||||
callerSelected: true,
|
||||
},
|
||||
options?.signal,
|
||||
),
|
||||
|
||||
@@ -52,6 +52,7 @@ export class LocalWebModuleArtifacts implements WebModuleArtifactPort {
|
||||
operation: "trace_web_module_imports",
|
||||
field: ["import_map", "path"],
|
||||
targetPath: selected.path,
|
||||
callerSelected: true,
|
||||
},
|
||||
options?.signal,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { mkdir, readFile, rename, symlink, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, onTestFinished } from "vitest";
|
||||
import { exportWebScripts } from "../../../src/application/WebScriptExportService.js";
|
||||
import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleArtifacts.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
@@ -163,20 +171,86 @@ describe("selected captured module artifact boundary", () => {
|
||||
await writeFile(map, "[]");
|
||||
expect((await new LocalWebModuleArtifacts().load(input)).ok).toBe(false);
|
||||
});
|
||||
it("preserves missing filesystem diagnostics separately from malformed input", async () => {
|
||||
});
|
||||
|
||||
describe("caller-selected web module files", () => {
|
||||
const selectionIssue = (field: readonly string[], message: string) => ({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: field,
|
||||
reason: "invalid_value",
|
||||
message: expect.stringContaining(message),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
it("reports a missing or non-file manifest selection as invalid input", async () => {
|
||||
const root = await createTestTempDirectory("rea-module-missing-");
|
||||
await mkdir(join(root, "directory"));
|
||||
const missing = join(root, "missing.json");
|
||||
const directory = join(root, "directory");
|
||||
await mkdir(directory);
|
||||
for (const [manifest_path, message] of [
|
||||
[missing, `(ENOENT): ${missing}`],
|
||||
[join(missing, "manifest.json"), "(ENOENT)"],
|
||||
[directory, `Expected a regular file without a symlink: ${directory}`],
|
||||
] as const) {
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path,
|
||||
script_index: 0,
|
||||
});
|
||||
if (response.ok) throw new Error("expected selection failure");
|
||||
expect(projectAnalysisError(response.error)).toMatchObject(
|
||||
selectionIssue(["manifest_path"], message),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"reports an unreadable manifest as a host access denial",
|
||||
async () => {
|
||||
const { manifestPath } = await fixture();
|
||||
await chmod(manifestPath, 0o000);
|
||||
onTestFinished(() => chmod(manifestPath, 0o600));
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: manifestPath,
|
||||
script_index: 0,
|
||||
});
|
||||
if (response.ok) throw new Error("expected access denial");
|
||||
expect(projectAnalysisError(response.error)).toMatchObject({
|
||||
code: "access_denied",
|
||||
details: { path: manifestPath, system_code: "EACCES" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a missing import map selection as invalid input", async () => {
|
||||
const { root, manifestPath } = await fixture();
|
||||
const map = join(root, "missing-import-map.json");
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: join(root, "missing.json"),
|
||||
manifest_path: manifestPath,
|
||||
script_index: 0,
|
||||
import_map: { path: map, base_url: "https://app.test/" },
|
||||
});
|
||||
if (response.ok) throw new Error("expected missing import map");
|
||||
expect(projectAnalysisError(response.error)).toMatchObject(
|
||||
selectionIssue(["import_map", "path"], `(ENOENT): ${map}`),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a missing manifest-derived source as an artifact fault", async () => {
|
||||
const { manifestPath, sourcePath } = await fixture();
|
||||
await rm(sourcePath);
|
||||
const response = await new LocalWebModuleArtifacts().load({
|
||||
manifest_path: manifestPath,
|
||||
script_index: 0,
|
||||
});
|
||||
if (response.ok) throw new Error("expected missing file");
|
||||
if (response.ok) throw new Error("expected missing source");
|
||||
expect(response.error).toMatchObject({
|
||||
_tag: "ArtifactOperationError",
|
||||
reason: "io",
|
||||
});
|
||||
expect(projectAnalysisError(response.error).details?.detail).toContain(
|
||||
"ENOENT",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -56,23 +56,28 @@ it.each(["unavailable", "invalid-utf8", "oversized"])(
|
||||
});
|
||||
if (response.ok) throw new Error("expected map failure");
|
||||
const projection = projectAnalysisError(response.error);
|
||||
if (variant === "invalid-utf8")
|
||||
if (variant === "oversized")
|
||||
expect(response.error).toMatchObject({
|
||||
_tag: "ArtifactOperationError",
|
||||
reason: "limit",
|
||||
detail: expect.stringContaining(path),
|
||||
});
|
||||
else
|
||||
expect(projection).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["source_map", "path"],
|
||||
message: expect.stringContaining("UTF-8"),
|
||||
...(variant === "unavailable"
|
||||
? { reason: "invalid_value" }
|
||||
: { reason: "invalid_format" }),
|
||||
message: expect.stringContaining(
|
||||
variant === "unavailable" ? `(ENOENT): ${path}` : "UTF-8",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
else
|
||||
expect(response.error).toMatchObject({
|
||||
_tag: "ArtifactOperationError",
|
||||
reason: variant === "oversized" ? "limit" : "io",
|
||||
detail: expect.stringContaining(path),
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user