diff --git a/src/application/javascript/JavaScriptApplicationService.test.ts b/src/application/javascript/JavaScriptApplicationService.test.ts index 7ccdad6d6..02c26096e 100644 --- a/src/application/javascript/JavaScriptApplicationService.test.ts +++ b/src/application/javascript/JavaScriptApplicationService.test.ts @@ -76,7 +76,7 @@ describe("JavaScript application failure diagnostics", () => { expect(JSON.parse(JSON.stringify(projection))).toEqual(projection); }); - it("keeps filesystem failures distinct from engine failures", async () => { + it("reports a missing selection as invalid input, not an engine failure", async () => { const root = await createTestTempDirectory("rea-js-missing-"); const result = await analyzeJavaScriptApplication({ input_path: join(root, "missing"), @@ -84,8 +84,11 @@ describe("JavaScript application failure diagnostics", () => { }); if (result.ok) throw new Error("Expected missing artifact failure"); expect(projectAnalysisError(result.error)).toMatchObject({ - code: "artifact_operation_failed", - details: { operation: "analyze_javascript_application", reason: "io" }, + code: "invalid_request", + details: { + operation: "analyze_javascript_application", + issues: [{ path: ["input_path"], reason: "invalid_value" }], + }, }); }); diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index 7576c8f4b..28f6691ee 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -5,7 +5,12 @@ import { analyzeJavaScriptApplicationInputSchema, parseOwnedJavaScriptApplicationAnalysisSteps, } from "../../domain/javascript/javascriptApplicationAnalysis.js"; -import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisAccessDeniedError, + AnalysisInputError, + AnalysisOutputError, + AnalysisUnsupportedTargetError, +} from "../../domain/analysisErrorCore.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import { type AnalysisError } from "../../domain/analysisErrorBase.js"; import type { Evidence } from "../../domain/evidence.js"; @@ -100,6 +105,13 @@ export const analyzeJavaScriptApplicationValidated = async ( assertJavaScriptAnalysisActive(options.signal); return ok(evidence); } catch (cause: unknown) { + // Input selection failures are already typed by their cause. + if ( + cause instanceof AnalysisInputError || + cause instanceof AnalysisUnsupportedTargetError || + cause instanceof AnalysisAccessDeniedError + ) + return err(cause); if (cause instanceof ArtifactReaderFailure) return err( new ArtifactOperationError( diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 3c34207b8..b61d257c2 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -1,6 +1,12 @@ -import { lstat, realpath } from "node:fs/promises"; +import { constants } from "node:fs"; +import { access, lstat, realpath } from "node:fs/promises"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { + AnalysisAccessDeniedError, + AnalysisInputError, + AnalysisUnsupportedTargetError, +} from "../../domain/analysisErrorCore.js"; import { createJavaScriptArtifactReader as createReader } from "../../artifacts/javascript/JavaScriptArtifactReader.js"; import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js"; @@ -58,7 +64,7 @@ export const reconstructJavaScriptArtifact = async ( }; const input = javascriptArtifactReconstructionInputSchema.parse(rawInput); abortIfNeeded(signal); - const path = await realpath(input.input_path); + const path = await resolveSelectedInput(input.input_path); const format = await resolveFormat(path, input); const snapshot = await scanCanonicalArtifactInventory(path, { signal }); if (snapshot.manifest.root_format !== format) @@ -159,6 +165,37 @@ export const reconstructJavaScriptArtifact = async ( } }; +const OPERATION = "analyze_javascript_application"; + +/** + * Resolve the caller-selected input. A missing or unreadable selection is a + * caller or host-permission failure, not a damaged artifact. + */ +const resolveSelectedInput = async (inputPath: string): Promise => { + try { + const path = await realpath(inputPath); + await access(path, constants.R_OK); + return path; + } catch (cause: unknown) { + const code = + cause instanceof Error && "code" in cause ? String(cause.code) : ""; + if (code === "EACCES" || code === "EPERM") + throw new AnalysisAccessDeniedError(OPERATION, inputPath, code, { + cause, + }); + if (code === "ENOENT" || code === "ENOTDIR") + throw new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["input_path"], + reason: "invalid_value", + message: `No file or directory exists at the selected input path (${code}): ${inputPath}`, + }, + ]); + throw cause; + } +}; + +/** The selected input's kind, checked against the caller's requested format. */ const resolveFormat = async ( path: string, input: JavaScriptArtifactReconstructionInput, @@ -170,15 +207,24 @@ const resolveFormat = async ( ? "asar" : undefined; if (observed === undefined) - throw new ArtifactReaderFailure( - "format", - `JavaScript artifact reconstruction accepts only a directory or .asar file: ${path}`, + throw new AnalysisUnsupportedTargetError( + OPERATION, + input.input_path, + "JavaScript application analysis accepts only a directory or an .asar file", + { + remediationAction: + "Select an extracted application directory, an app bundle, or an .asar file such as Contents/Resources/app.asar.", + }, ); if (input.format !== "auto" && input.format !== observed) - throw new ArtifactReaderFailure( - "format", - `Requested ${input.format} input but observed ${observed}: ${path}`, - ); + throw new AnalysisInputError(OPERATION, undefined, [ + { + path: ["format"], + reason: "invalid_value", + message: `Requested ${input.format} input, but the selected input is ${observed === "asar" ? "an .asar file" : "a directory"}: ${input.input_path}`, + expected: ["auto", observed], + }, + ]); return observed; }; diff --git a/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts b/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts index 27e3bd9c1..36843cfa0 100644 --- a/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts +++ b/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts @@ -99,8 +99,8 @@ cliTest( ], }); expect(output.json).toMatchObject({ - code: "artifact_operation_failed", - details: { reason: "io" }, + code: "invalid_request", + details: { issues: [{ path: ["input_path"], reason: "invalid_value" }] }, }); }, ); diff --git a/tests/boundary/cli/javascriptApplicationProgress.test.ts b/tests/boundary/cli/javascriptApplicationProgress.test.ts index 26bf3e5af..a4d855592 100644 --- a/tests/boundary/cli/javascriptApplicationProgress.test.ts +++ b/tests/boundary/cli/javascriptApplicationProgress.test.ts @@ -62,7 +62,7 @@ cliTest( "--json", ], }); - expect(output.json).toMatchObject({ code: "artifact_operation_failed" }); + expect(output.json).toMatchObject({ code: "invalid_request" }); const updates = output.stderr .trim() .split("\n") diff --git a/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts b/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts new file mode 100644 index 000000000..04d5de8df --- /dev/null +++ b/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts @@ -0,0 +1,102 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it, onTestFinished } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; + +const analyzeFailure = async (input: Record) => { + const result = await analyzeJavaScriptApplication(input); + if (result.ok) throw new Error("Expected the selection to be refused"); + return projectAnalysisError(result.error); +}; + +const application = async () => { + const root = await createTestTempDirectory("rea-javascript-selection-"); + const directory = join(root, "app"); + await mkdir(directory); + await writeFile(join(directory, "index.js"), "module.exports = 1;\n"); + return { root, directory }; +}; + +describe("JavaScript application input selection", () => { + it("reports a missing input path as invalid input, not an artifact read failure", async () => { + const { root } = await application(); + const missing = join(root, "missing"); + + expect(await analyzeFailure({ input_path: missing })).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + { + path: ["input_path"], + reason: "invalid_value", + message: expect.stringContaining(missing), + }, + ], + }, + }); + }); + + it("reports a file that is neither a directory nor an .asar as an unsupported target", async () => { + const { root } = await application(); + const binary = join(root, "program"); + await writeFile(binary, Buffer.from([0xcf, 0xfa, 0xed, 0xfe])); + + expect(await analyzeFailure({ input_path: binary })).toMatchObject({ + code: "unsupported_target", + category: "unsupported_target", + remediation: { action: expect.stringContaining(".asar") }, + details: { + operation: "analyze_javascript_application", + path: binary, + }, + }); + }); + + it.each([ + ["a directory", "directory", "asar"], + ["an .asar file", "asar", "directory"], + ] as const)( + "reports %s selected with a contradicting format as an invalid request", + async (_label, observed, format) => { + const { root, directory } = await application(); + const selected = + observed === "directory" ? directory : join(root, "app.asar"); + if (observed === "asar") await writeFile(selected, "not inspected"); + + expect( + await analyzeFailure({ input_path: selected, format }), + ).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["format"], + reason: "invalid_value", + expected: ["auto", observed], + }, + ], + }, + }); + }, + ); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "reports an unreadable selected directory as a host access denial", + async () => { + const { directory } = await application(); + await chmod(directory, 0o000); + onTestFinished(() => chmod(directory, 0o700)); + + expect(await analyzeFailure({ input_path: directory })).toMatchObject({ + code: "access_denied", + details: { path: directory, system_code: "EACCES" }, + }); + }, + ); +}); diff --git a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts index 3df383e7a..5651d5390 100644 --- a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts @@ -268,8 +268,13 @@ it("preserves cancellation and explicit format diagnostics", async () => { await expect( reconstructJavaScriptArtifact({ input_path: root, format: "asar" }), ).rejects.toMatchObject({ - reason: "format", - message: expect.stringContaining(root), + issues: [ + { + path: ["format"], + reason: "invalid_value", + message: expect.stringContaining(root), + }, + ], }); });