fix(targets): project invalid raw-binary loads as caller input (#2004)

* fix(targets): project invalid raw-binary loads as caller input

* fix(targets): name real load options and cover DOS COM kind pairing
This commit is contained in:
pablo 2026-10-12 09:34:45 +08:00 committed by GitHub
parent 5dfb9d050c
commit edf46248f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 230 additions and 44 deletions

View File

@ -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);

View File

@ -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 <directory>`. 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 <directory>`."
: 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")

View File

@ -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);

View File

@ -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. */

View File

@ -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");
},
);

View File

@ -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<RawBinaryAdmission, string> => {
}): Result<RawBinaryAdmission, RawBinaryAdmissionRejection> => {
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,

View File

@ -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") },
});
},

View File

@ -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<typeof parseBinaryTarget>[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",
},
});
});
});