diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index b0b8e6a0d..cd424a7e0 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -26,7 +26,10 @@ import { validateDosComLength, type ExecutableFormatHint, } from "../domain/dosCom.js"; -import { admitRawBinary } from "../domain/rawBinary.js"; +import { + admitRawBinary, + rawBinarySelectionError, +} from "../domain/rawBinary.js"; import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; import { mzWindowsHeaderOffset } from "../domain/dosMz.js"; import { @@ -93,7 +96,7 @@ export const parseBinaryTarget = async ( new BinaryTargetError( path, "raw-binary format requires an executable target kind", - { constraint: "file_format_rejected" }, + { constraint: "load_selection_rejected" }, ), ); const admitted = admitRawBinary({ @@ -103,8 +106,8 @@ export const parseBinaryTarget = async ( }); if (!admitted.ok) return err( - new BinaryTargetError(path, admitted.error, { - constraint: "file_format_rejected", + new BinaryTargetError(path, admitted.error.reason, { + constraint: admitted.error.constraint, }), ); return ok({ @@ -119,12 +122,16 @@ export const parseBinaryTarget = async ( entryPointSource: admitted.value.entryPointSource, }); } - if (architecture !== undefined || entryPoint !== undefined) + const pairing = rawBinarySelectionError({ + ...(formatHint === undefined ? {} : { format: formatHint }), + architecture, + entryPoint, + }); + if (pairing !== undefined) return err( - new BinaryTargetError( - path, - "architecture and entry_point apply only with format=raw-binary", - ), + new BinaryTargetError(path, pairing, { + constraint: "load_selection_rejected", + }), ); if (formatHint === "dos-com") { if (targetKind !== undefined && targetKind !== "executable") @@ -132,7 +139,7 @@ export const parseBinaryTarget = async ( new BinaryTargetError( path, "DOS COM format requires an executable target kind", - { constraint: "file_format_rejected" }, + { constraint: "load_selection_rejected" }, ), ); const length = validateDosComLength(metadata.size); diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 9e4ee3a2a..3e6aa049c 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -76,6 +76,11 @@ export const analysisErrorRemediationAction = ( error.constraint === "directory_requires_file" ) return "For a JavaScript/Electron application directory, call analyze_javascript_application with input_path or run `rea analyze `. For binary analysis, select its executable file."; + if ( + error instanceof BinaryTargetError && + error.constraint === "load_selection_rejected" + ) + return "Correct the reported load argument (--target-format, --architecture, or --entry-point on the CLI; format, architecture, or entry_point through MCP) and retry."; if ( error instanceof BinaryTargetError && error.constraint === "file_format_rejected" @@ -105,6 +110,11 @@ export const analysisErrorRemediationAction = ( export const analysisErrorCategory = ( error: AnalysisError, ): AnalysisErrorProjection["category"] => { + if ( + error instanceof BinaryTargetError && + error.constraint === "load_selection_rejected" + ) + return "invalid_input"; if ( error instanceof BinaryTargetError && (error.constraint === "wasm_requires_static_inspection" || @@ -237,7 +247,8 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => { if (error instanceof BinaryTargetError) return error.constraint === "directory_requires_file" ? "open_binary accepts files and macOS app bundles. This target is a directory; JavaScript/Electron application directories can be analyzed directly with analyze_javascript_application or `rea analyze `." - : error.constraint === "file_format_rejected" + : error.constraint === "file_format_rejected" || + error.constraint === "load_selection_rejected" ? `${error.message}.` : `${error.message}. Check that the path exists, is readable, and points to a supported file.`; if (error._tag === "ProcessCaptureError") diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 803d73b4e..6c0699eb8 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -105,9 +105,11 @@ const underlyingErrorCode = ( if (error instanceof ArtifactOperationError) return artifactOperationCode(error); if (error instanceof BinaryTargetError) - return error.systemCode === undefined - ? "target_unavailable" - : "access_denied"; + return error.constraint === "load_selection_rejected" + ? "invalid_request" + : error.systemCode === undefined + ? "target_unavailable" + : "access_denied"; if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason); if (error instanceof UnknownRegistryError) return unknownRegistryCode(error.reason); diff --git a/src/domain/configurationErrors.ts b/src/domain/configurationErrors.ts index ab60e354c..686e5c42f 100644 --- a/src/domain/configurationErrors.ts +++ b/src/domain/configurationErrors.ts @@ -41,7 +41,8 @@ export interface BinaryTargetErrorOptions extends AnalysisErrorOptions { readonly constraint?: | "directory_requires_file" | "wasm_requires_static_inspection" - | "file_format_rejected"; + | "file_format_rejected" + | "load_selection_rejected"; } /** A supplied target path could not be safely opened as a supported app or binary. */ diff --git a/src/domain/rawBinary.test.ts b/src/domain/rawBinary.test.ts index ca4d7f0d6..f22e5fced 100644 --- a/src/domain/rawBinary.test.ts +++ b/src/domain/rawBinary.test.ts @@ -55,15 +55,20 @@ describe("raw-binary admission", () => { }, }); }); - it("rejects an empty file", () => { + it("rejects an empty file as a target refusal while naming the extent", () => { expect(validateRawBinaryLength(0).ok).toBe(false); - expect( - admitRawBinary({ - architecture: "x86", - entryPoint: "0x1000", - fileBytes: 0, - }).ok, - ).toBe(false); + const result = admitRawBinary({ + architecture: "x86", + entryPoint: "0x1000", + fileBytes: 0, + }); + expect(result).toEqual({ + ok: false, + error: { + reason: "raw-binary requires at least one file byte", + constraint: "file_format_rejected", + }, + }); }); it("rejects a 32-bit mapping that exceeds the architecture width", () => { const result = admitRawBinary({ @@ -73,7 +78,8 @@ describe("raw-binary admission", () => { }); expect(result.ok).toBe(false); if (result.ok) throw new Error("expected overflow rejection"); - expect(result.error).toContain("arm address width"); + expect(result.error.reason).toContain("arm address width"); + expect(result.error.constraint).toBe("load_selection_rejected"); }); it("allows a 64-bit mapping that would overflow 32-bit ARM", () => { expect( @@ -92,11 +98,27 @@ describe("raw-binary admission", () => { admitRawBinary({ architecture, entryPoint, fileBytes: 4 }), ).toEqual({ ok: false, - error: `raw-binary ${architecture} entry_point ${entryPoint} must be 4-byte aligned`, + error: { + reason: `raw-binary ${architecture} entry_point ${entryPoint} must be 4-byte aligned`, + constraint: "load_selection_rejected", + }, }); } }, ); + it.each([ + ["malformed entry", { architecture: "arm", entryPoint: "0xgg" }], + ["missing entry", { architecture: "arm", entryPoint: undefined }], + [ + "missing architecture", + { architecture: undefined, entryPoint: "0x8000000" }, + ], + ])("classifies a %s as caller input", (_name, selection) => { + const result = admitRawBinary({ ...selection, fileBytes: 4 }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected caller-input rejection"); + expect(result.error.constraint).toBe("load_selection_rejected"); + }); it.each(["x86", "x86_64"])( "admits byte-aligned %s entries", (architecture) => { @@ -105,7 +127,7 @@ describe("raw-binary admission", () => { entryPoint: "0x1001", fileBytes: 1, }); - if (!admitted.ok) throw new Error(admitted.error); + if (!admitted.ok) throw new Error(admitted.error.reason); expect(admitted.value.entryPoint).toBe("0x1001"); }, ); diff --git a/src/domain/rawBinary.ts b/src/domain/rawBinary.ts index a389bf7c3..d00af8d49 100644 --- a/src/domain/rawBinary.ts +++ b/src/domain/rawBinary.ts @@ -60,6 +60,16 @@ export interface RawBinaryAdmission { readonly entryPointSource: string; } +/** Why a headerless load was refused, with its owning classification. */ +export interface RawBinaryAdmissionRejection { + readonly reason: string; + /** + * `load_selection_rejected` names a caller-input selection problem; + * `file_format_rejected` names a refusal caused by the target file itself. + */ + readonly constraint: "load_selection_rejected" | "file_format_rejected"; +} + const addressWidth = (architecture: BinaryArchitecture): bigint => architecture === "x86" || architecture === "arm" ? 0xffffffffn @@ -70,33 +80,47 @@ export const admitRawBinary = (input: { readonly architecture: unknown; readonly entryPoint: unknown; readonly fileBytes: number; -}): Result => { +}): Result => { const architecture = binaryArchitectureSchema.safeParse(input.architecture); if (!architecture.success) - return err( - "raw-binary format requires architecture (x86, x86_64, arm, or arm64)", - ); + return err({ + reason: + "raw-binary format requires architecture (x86, x86_64, arm, or arm64)", + constraint: "load_selection_rejected", + }); if (typeof input.entryPoint !== "string") - return err( - "raw-binary format requires entry_point as a hexadecimal address of the form 0x…", - ); + return err({ + reason: + "raw-binary format requires entry_point as a hexadecimal address of the form 0x…", + constraint: "load_selection_rejected", + }); const entry = parseRawBinaryEntryPoint(input.entryPoint); - if (!entry.ok) return entry; + if (!entry.ok) + return err({ + reason: entry.error, + constraint: "load_selection_rejected", + }); if ( (architecture.data === "arm" || architecture.data === "arm64") && entry.value.value % 4n !== 0n ) - return err( - `raw-binary ${architecture.data} entry_point ${entry.value.canonical} must be 4-byte aligned`, - ); + return err({ + reason: `raw-binary ${architecture.data} entry_point ${entry.value.canonical} must be 4-byte aligned`, + constraint: "load_selection_rejected", + }); const length = validateRawBinaryLength(input.fileBytes); - if (!length.ok) return length; + if (!length.ok) + return err({ + reason: length.error, + constraint: "file_format_rejected", + }); const last = entry.value.value + BigInt(input.fileBytes) - 1n; const max = addressWidth(architecture.data); if (entry.value.value > max || last > max) - return err( - `raw-binary mapping from ${entry.value.canonical} must fit in the ${architecture.data} address width`, - ); + return err({ + reason: `raw-binary mapping from ${entry.value.canonical} must fit in the ${architecture.data} address width`, + constraint: "load_selection_rejected", + }); return ok({ architecture: architecture.data, entryPoint: entry.value.canonical, diff --git a/tests/boundary/cli/rawBinaryCli.test.ts b/tests/boundary/cli/rawBinaryCli.test.ts index b16d296ce..1cff4cac6 100644 --- a/tests/boundary/cli/rawBinaryCli.test.ts +++ b/tests/boundary/cli/rawBinaryCli.test.ts @@ -32,7 +32,7 @@ cliTest( }); expect(missing.exitCode).toBe(1); expect(missing.json).toMatchObject({ - code: "target_unavailable", + code: "invalid_request", details: { reason: expect.stringContaining("architecture") }, }); }, @@ -62,7 +62,7 @@ cliTest( }); expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ - code: "target_unavailable", + code: "invalid_request", details: { reason: expect.stringContaining("must be 4-byte aligned") }, }); }, diff --git a/tests/boundary/filesystem/targetDiagnostics.test.ts b/tests/boundary/filesystem/targetDiagnostics.test.ts index 85b7a7045..5d1953760 100644 --- a/tests/boundary/filesystem/targetDiagnostics.test.ts +++ b/tests/boundary/filesystem/targetDiagnostics.test.ts @@ -130,7 +130,6 @@ describe("target route diagnostics", () => { }, }); }); - it("identifies the directory opening constraint and gives the direct analysis route", async () => { const directory = await createTestTempDirectory("rea-js-target-"); await writeFile(join(directory, "index.js"), "export const answer = 42;\n"); @@ -201,3 +200,123 @@ describe("target route diagnostics", () => { }, ); }); + +describe("raw-binary load rejection diagnostics", () => { + const rawBinaryRejection = async ( + options: Parameters[1], + bytes = Buffer.alloc(64), + ) => { + const directory = await createTestTempDirectory("rea-raw-load-"); + const path = join(directory, "image.bin"); + await writeFile(path, bytes); + const result = await parseBinaryTarget(path, options); + if (result.ok) throw new Error("invalid raw-binary load was admitted"); + return projectAnalysisError(result.error); + }; + + it("projects a misaligned arm entry as caller input without path advice", async () => { + const projected = await rawBinaryRejection({ + formatHint: "raw-binary", + architecture: "arm", + entryPoint: "0x1", + }); + expect(projected).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + // invalid_request is retryable: correcting the load argument succeeds. + retryable: true, + message: + "Cannot open artifact: raw-binary arm entry_point 0x1 must be 4-byte aligned.", + details: { + reason: "raw-binary arm entry_point 0x1 must be 4-byte aligned", + constraint: "load_selection_rejected", + }, + remediation: { + action: + "Correct the reported load argument (--target-format, --architecture, or --entry-point on the CLI; format, architecture, or entry_point through MCP) and retry.", + }, + }); + expect(projected.message).not.toContain("Check that the path"); + }); + + it("projects an x86 entry beyond the address width as caller input", async () => { + const projected = await rawBinaryRejection({ + formatHint: "raw-binary", + architecture: "x86", + entryPoint: "0xffffffffff", + }); + expect(projected).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { constraint: "load_selection_rejected" }, + }); + expect(projected.message).toContain("x86 address width"); + expect(projected.message).not.toContain("Check that the path"); + }); + + it("projects architecture and entry without raw-binary format as caller input", async () => { + const projected = await rawBinaryRejection({ + architecture: "arm", + entryPoint: "0x0", + }); + expect(projected).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + message: + "Cannot open artifact: architecture and entry_point apply only with format=raw-binary.", + details: { constraint: "load_selection_rejected" }, + }); + expect(projected.message).not.toContain("Check that the path"); + }); + + it("keeps an empty raw-binary file a target refusal", async () => { + const projected = await rawBinaryRejection( + { + formatHint: "raw-binary", + architecture: "arm", + entryPoint: "0x8000000", + }, + Buffer.alloc(0), + ); + expect(projected).toMatchObject({ + code: "target_unavailable", + category: "unsupported_target", + details: { + reason: "raw-binary requires at least one file byte", + constraint: "file_format_rejected", + }, + }); + }); + + it("projects the raw-binary target-kind pairing as caller input", async () => { + const projected = await rawBinaryRejection({ + formatHint: "raw-binary", + targetKind: "database", + architecture: "x86", + entryPoint: "0x1000", + }); + expect(projected).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { + reason: "raw-binary format requires an executable target kind", + constraint: "load_selection_rejected", + }, + }); + }); + + it("projects the DOS COM target-kind pairing as caller input too", async () => { + const projected = await rawBinaryRejection({ + formatHint: "dos-com", + targetKind: "database", + }); + expect(projected).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { + reason: "DOS COM format requires an executable target kind", + constraint: "load_selection_rejected", + }, + }); + }); +});