diff --git a/src/application/WebScriptExportService.ts b/src/application/WebScriptExportService.ts index e8529ea181..d35341f437 100644 --- a/src/application/WebScriptExportService.ts +++ b/src/application/WebScriptExportService.ts @@ -8,6 +8,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { publishWebScripts } from "../browser/assets/PublishWebScripts.js"; import { selectScriptCapture } from "../browser/assets/ScriptCaptureAdapters.js"; import { + AnalysisAccessDeniedError, AnalysisCancelledError, AnalysisInputError, AnalysisOutputError, @@ -60,9 +61,9 @@ export const exportWebScriptsValidated = async ( ); try { options.signal?.throwIfAborted(); - const bytes = await readFile(input.capture_path, { - signal: options.signal, - }); + const read = await readCapture(input.capture_path, options.signal); + if (!read.ok) return read; + const bytes = read.value; const loaded = parseCapture(bytes); if (!loaded.ok) return loaded; options.signal?.throwIfAborted(); @@ -142,6 +143,40 @@ export const exportWebScriptsValidated = async ( } }; +/** + * Read the caller-selected capture. A missing, non-file or unreadable + * selection is a caller or host-permission failure, not an export failure. + */ +const readCapture = async ( + path: string, + signal: AbortSignal | undefined, +): Promise> => { + try { + return ok(await readFile(path, { signal })); + } catch (cause: unknown) { + const code = + cause instanceof Error && "code" in cause ? String(cause.code) : ""; + if (code === "EACCES" || code === "EPERM") + return err( + new AnalysisAccessDeniedError(OPERATION, path, code, { cause }), + ); + if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") + return err( + new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["capture_path"], + reason: "invalid_value", + message: + code === "EISDIR" + ? `Selected capture is a directory, not a file: ${path}` + : `Selected capture could not be read (${code}): ${path}`, + }, + ]), + ); + throw cause; + } +}; + const parseCapture = ( bytes: Buffer, ): Result, AnalysisError> => { diff --git a/tests/boundary/filesystem/webScriptExport.test.ts b/tests/boundary/filesystem/webScriptExport.test.ts index cae439fead..ebc13ca481 100644 --- a/tests/boundary/filesystem/webScriptExport.test.ts +++ b/tests/boundary/filesystem/webScriptExport.test.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; import { access, + chmod, + mkdir, readFile, readdir, symlink, @@ -8,13 +10,14 @@ import { } from "node:fs/promises"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; import { exportWebScripts } from "../../../src/application/WebScriptExportService.js"; import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js"; import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { @@ -247,9 +250,18 @@ describe("captured script publication failures and cleanup", () => { capture_path: `${input.capture_path}.missing`, }); if (missing.ok) throw new Error("Expected unavailable input"); - expect(missing.error.userMessage).toContain( - `${input.capture_path}.missing`, - ); + expect(projectAnalysisError(missing.error)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["capture_path"], + reason: "invalid_value", + message: expect.stringContaining(`${input.capture_path}.missing`), + }, + ], + }, + }); const relative = await exportWebScripts({ ...input, capture_path: "capture.json", @@ -258,3 +270,48 @@ describe("captured script publication failures and cleanup", () => { expect(relative.error._tag).toBe("AnalysisInputError"); }); }); + +describe("captured script selection failures", () => { + it("reports a directory selected as the capture as invalid input", async () => { + const { root, input } = await setup(); + const directory = join(root, "captures"); + await mkdir(directory); + + const result = await exportWebScripts({ + ...input, + capture_path: directory, + }); + if (result.ok) throw new Error("Expected invalid input"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["capture_path"], + reason: "invalid_value", + message: expect.stringContaining(directory), + }, + ], + }, + }); + await expect(access(input.output_directory)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "reports an unreadable capture as a host access denial", + async () => { + const { input } = await setup(); + await chmod(input.capture_path, 0o000); + onTestFinished(() => chmod(input.capture_path, 0o600)); + + const result = await exportWebScripts(input); + if (result.ok) throw new Error("Expected access denial"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "access_denied", + details: { path: input.capture_path, system_code: "EACCES" }, + }); + }, + ); +});