diff --git a/src/browser/assets/LocalWebScriptArtifacts.ts b/src/browser/assets/LocalWebScriptArtifacts.ts index 985756a738..c5d6ea8ed0 100644 --- a/src/browser/assets/LocalWebScriptArtifacts.ts +++ b/src/browser/assets/LocalWebScriptArtifacts.ts @@ -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, ), ); diff --git a/src/browser/assets/WebArtifactReads.ts b/src/browser/assets/WebArtifactReads.ts index ee38a90168..cd142f62fc 100644 --- a/src/browser/assets/WebArtifactReads.ts +++ b/src/browser/assets/WebArtifactReads.ts @@ -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; +}; diff --git a/src/browser/assets/WebSourceLocationArtifacts.ts b/src/browser/assets/WebSourceLocationArtifacts.ts index aa286654e6..21c483bd81 100644 --- a/src/browser/assets/WebSourceLocationArtifacts.ts +++ b/src/browser/assets/WebSourceLocationArtifacts.ts @@ -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, ), diff --git a/src/browser/modules/WebModuleArtifacts.ts b/src/browser/modules/WebModuleArtifacts.ts index c8ffb4aee0..287e6e8dea 100644 --- a/src/browser/modules/WebModuleArtifacts.ts +++ b/src/browser/modules/WebModuleArtifacts.ts @@ -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, ), diff --git a/tests/boundary/filesystem/webModuleArtifacts.test.ts b/tests/boundary/filesystem/webModuleArtifacts.test.ts index f7ac99983f..62b23432a0 100644 --- a/tests/boundary/filesystem/webModuleArtifacts.test.ts +++ b/tests/boundary/filesystem/webModuleArtifacts.test.ts @@ -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", - ); }); }); diff --git a/tests/boundary/filesystem/webSourceLocationArtifacts.test.ts b/tests/boundary/filesystem/webSourceLocationArtifacts.test.ts index 96aa4657f1..c443a88aca 100644 --- a/tests/boundary/filesystem/webSourceLocationArtifacts.test.ts +++ b/tests/boundary/filesystem/webSourceLocationArtifacts.test.ts @@ -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), - }); }, );