feat(native): admit raw binaries with caller-supplied architecture and entry (#1868)

* feat(native): admit raw binaries with caller-supplied architecture and entry

* fix(native): honor raw-binary preparation and entry identity

* fix(ghidra): stream raw snapshot attestation with cancellation

* fix(native): preserve and validate raw lifecycle entries

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Matt Van Horn 2026-10-11 09:06:29 -07:00 committed by GitHub
parent d3e83b0899
commit 7c207c7951
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 2453 additions and 336 deletions

View File

@ -2,7 +2,7 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "38"
version: "39"
---
# REA

View File

@ -150,6 +150,13 @@ For headerless DOS COM, explicitly open with `format: "dos-com"` (CLI
analysis: the entry is `0x10100`, file offset zero, with imposed real-mode segment
context. PSP/stack/device state remains unmodeled. See [DOS guide](https://github.com/morluto/rea/blob/main/docs/ghidra-dos.md).
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at
that address. Hopper and IDA refuse this interpretation; Ghidra uses
`BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only,
and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
### Function annotations in Ghidra
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations. For naming only, `set_address_name` / `set_addresses_names` also work with Ghidra: they name function entries or data addresses (primary labels) and return compact per-address success booleans instead of a dossier.

View File

@ -182,10 +182,17 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
sessionDefaultAddressSpace =
currentProgram.getAddressFactory().getDefaultAddressSpace();
if (currentProgram.getExecutableFormat().equals("Raw Binary") &&
!currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO)
.getBoolean("REA DOS COM prepared", false)) {
throw new IllegalStateException("REA DOS COM entry/context preparation failed");
if (currentProgram.getExecutableFormat().equals("Raw Binary")) {
boolean dosCom = currentProgram.getLanguageID().getIdAsString()
.equals("x86:LE:16:Real Mode");
String preparation = dosCom ? "REA DOS COM prepared" : "REA raw binary prepared";
if (!currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO)
.getBoolean(preparation, false)) {
throw new IllegalStateException(
dosCom ? "REA DOS COM entry/context preparation failed" :
"REA raw binary entry preparation failed"
);
}
}
if (!Application.getApplicationVersion().equals(descriptor.providerVersion)) {
throw new IllegalStateException("Ghidra provider version does not match the session");

View File

@ -0,0 +1,36 @@
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Program;
/** Seed the caller-supplied raw-binary entry before default auto-analysis. */
public final class ReaGhidraPrepareRaw extends GhidraScript {
@Override
public void run() throws Exception {
String[] arguments = getScriptArgs();
if (arguments.length != 2) {
throw new IllegalArgumentException(
"Raw binary preparation requires the committed language and entry address"
);
}
String expectedLanguage = arguments[0];
String entryHex = arguments[1];
if (!currentProgram.getLanguageID().getIdAsString().equals(expectedLanguage) ||
!currentProgram.getExecutableFormat().equals("Raw Binary")) {
throw new IllegalArgumentException(
"Raw binary preparation requires the committed language and BinaryLoader import"
);
}
Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex);
Address start = currentProgram.getMinAddress();
if (entry == null || !entry.equals(start)) {
throw new IllegalArgumentException(
"Raw binary import must map file offset 0 at the caller-supplied entry"
);
}
addEntryPoint(entry);
if (!disassemble(entry) || createFunction(entry, "entry") == null) {
throw new IllegalStateException("Raw binary entry cannot be decoded as a function");
}
currentProgram.getOptions(Program.PROGRAM_INFO).setBoolean("REA raw binary prepared", true);
}
}

View File

@ -54,6 +54,7 @@ export default defineConfig({
{ text: "IDA", link: "/ida-provider" },
{ text: "Ghidra on Windows", link: "/windows-ghidra-p0" },
{ text: "Ghidra DOS analysis", link: "/ghidra-dos" },
{ text: "Ghidra raw binary", link: "/raw-binary" },
{ text: "Ghidra NativeAOT", link: "/ghidra-nativeaot" },
{ text: "Ghidra semantics", link: "/ghidra-provider" },
],

View File

@ -59,7 +59,8 @@ alone, use `rea instructions`. A macOS `.app` bundle can be supplied directly.
Run `rea --help` or a command's `--help` for arguments and output options.
The [generated catalog](product-catalog.json) lists all CLI commands and MCP
tools. Provider-specific target support is described in the
[native guide](native-investigation.md), [DOS guide](ghidra-dos.md), and
[native guide](native-investigation.md), [DOS guide](ghidra-dos.md),
[raw binary guide](raw-binary.md), and
[Windows Ghidra guide](windows-ghidra-p0.md).
## Choose a provider

72
docs/raw-binary.md Normal file
View File

@ -0,0 +1,72 @@
# Headerless raw-binary analysis with Ghidra
REA admits a headerless executable when the caller supplies the CPU family and
the address where file offset 0 should be mapped. That interpretation is not
inferred from a `.bin` suffix or from unrecognized bytes.
## Open and inspect
```bash
rea inspect /absolute/path/to/ecu.bin --target-format raw-binary --architecture arm --entry-point 0x8000000 --provider ghidra --json
rea function /absolute/path/to/ecu.bin 0x8000000 --target-format raw-binary --architecture arm --entry-point 0x8000000 --provider ghidra --json
rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-binary --architecture arm --entry-point 0x8000000 --provider ghidra --json
```
```json
{
"name": "open_binary",
"arguments": {
"path": "/absolute/path/to/ecu.bin",
"format": "raw-binary",
"architecture": "arm",
"entry_point": "0x8000000",
"provider_id": "ghidra"
}
}
```
`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a
hexadecimal address of the form `0x…`. The complete file is mapped starting at
that address, and Ghidra seeds an `entry` function there before auto-analysis.
ARM and AArch64 entries must be 4-byte aligned; x86 entries may be byte-aligned.
`open_binary` and an open `binary_session` return the canonical `entry_point`
for raw targets so callers can verify the selected mapping directly.
Omitted `format` keeps header-based detection; architecture and entry without
`format=raw-binary` are rejected.
Ghidra imports a private snapshot with `BinaryLoader` and these little-endian
generic languages:
| architecture | language_id |
| ------------ | ------------------- |
| x86 | `x86:LE:32:default` |
| x86_64 | `x86:LE:64:default` |
| arm | `ARM:LE:32:v7` |
| arm64 | `AARCH64:LE:64:v8A` |
Those settings, the caller-supplied entry, and `language_selection:
caller-supplied-v1` are committed in the analysis profile. A different entry
or architecture is a different profile; snapshots from header-detected targets
do not replay.
The mapped start is independent of Ghidra's program image base, which remains
`0x0` for BinaryLoader. Ghidra may report additional language-defined entry
points, including unmapped ARM vectors. Load-image verification requires the
caller-supplied entry and preserves those other entries as observations.
Independent raw verification streams the complete private snapshot digest and
extent with bounded memory and supports cancellation during the read.
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit
PE applications only.
## Limitations
- Headers are not discovered from file bytes. This increment does not guess
load addresses, vector tables, or memory maps.
- Big-endian, Thumb-only, Cortex-M, and other Ghidra processor variants are
unsupported.
- Reset vectors, memory-mapped I/O, and firmware boot sequences are not
emulated.
- Independent load-image verification covers source identity, mapped file
bytes, the bound language, and the caller-supplied entry. Original bytes
stay unchanged.

View File

@ -793,6 +793,14 @@ truncation constraint, while leading-zero coordinates still resolve correctly.
It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS
runtime or PC-98 device execution.
`npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image
through the compiled CLI and stdio MCP server. With the same Ghidra/JDK
prerequisites, it checks preparation and bridge startup, verified load mappings,
instruction and byte readback, canonical-entry session reuse, replacement when
the entry changes without `provider_id`, unchanged source bytes, and owned
cleanup. Other raw CPU families remain covered by deterministic tests rather
than this real-provider lane. See [raw binary analysis](raw-binary.md).
## Apple Interface Builder archives
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a

View File

@ -55,6 +55,7 @@
"bridge/ghidra/ReaGhidraPrepareCom.java",
"bridge/ghidra/ReaGhidraApplySeeds.java",
"bridge/ghidra/ReaGhidraNoReturnFix.java",
"bridge/ghidra/ReaGhidraPrepareRaw.java",
"bridge/android/ReaJadxBridge.java",
"bridge/mitmproxy/capture.py",
"scripts/rea.mjs",
@ -166,6 +167,7 @@
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
"verify:ghidra:raw": "npm run build:cached && node scripts/verify-real-ghidra-raw.mjs",
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
"verify:interface-builder": "npm run build:cached && node scripts/verify/apple/interface-builder.mjs",
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",

View File

@ -177,6 +177,7 @@ const scriptCollisions = [
"ReaGhidraPrepareCom.java",
"ReaGhidraApplySeeds.java",
"ReaGhidraNoReturnFix.java",
"ReaGhidraPrepareRaw.java",
];
await mkdir(callerDirectory);
for (const name of scriptCollisions) await mkdir(join(callerDirectory, name));

View File

@ -0,0 +1,219 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../dist/domain/evidence.js";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-raw.mjs");
if (!["linux", "darwin"].includes(process.platform))
throw new Error(
"Raw-binary verification requires a Linux or macOS Ghidra host.",
);
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error(
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
);
const installation = inspectGhidraInstallation({
environment: process.env,
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
assert.equal(installation.status, "available", JSON.stringify(installation));
const run = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-raw-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const bytes = Buffer.from("1eff2fe1", "hex"); // ARM little-endian bx lr.
const sha256 = createHash("sha256").update(bytes).digest("hex");
const path = join(workspace, "image.bin");
await writeFile(path, bytes);
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
TMPDIR: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { JAVA_HOME: process.env.JAVA_HOME }),
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-raw-proof", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let report;
try {
await client.connect(transport);
const profiles = [];
const sessions = [];
for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) {
// Omit provider_id to exercise reuse of the current provider route.
const opened = await call("open_binary", {
path,
format: "raw-binary",
architecture: "arm",
entry_point: entryPoint,
});
const entry = `0x${BigInt(entryPoint).toString(16)}`;
assert.equal(opened.entry_point, entry);
const image = await call("inspect_native_load_image");
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image),
);
assert.equal(image.normalized_result.base_address, entry);
assert.equal(image.normalized_result.entry.address, entry);
assert.equal(
image.normalized_result.observations.language_id,
"ARM:LE:32:v7",
);
assert.ok(
image.normalized_result.observations.entry_points.includes(entry),
);
assert.equal(image.analysis_profile.parameters.entry_point, entry);
profiles.push(image.analysis_profile.digest);
const status = await call("binary_session");
assert.equal(status.entry_point, entry);
sessions.push(status.analysis_run.run_id);
const memory = await call("read_bytes", {
address: entry,
length: bytes.length,
});
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const procedures = await call("list_procedures");
assert.ok(
procedures.normalized_result.some(
(procedure) => procedure.address === entry,
),
);
const instruction = await call("inspect_native_instruction", {
address: entry,
});
assert.equal(instruction.normalized_result.address, entry);
}
assert.equal(profiles[0], profiles[1]);
assert.notEqual(profiles[1], profiles[2]);
assert.equal(sessions[0], sessions[1]);
assert.notEqual(sessions[1], sessions[2]);
const rejected = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "arm64",
entry_point: "0x8000001",
},
});
assert.equal(rejected.isError, true);
assert.match(JSON.stringify(rejected.content), /must be 4-byte aligned/);
const unchanged = await call("binary_session");
assert.equal(unchanged.analysis_run.run_id, sessions[2]);
assert.equal(unchanged.entry_point, "0x9000000");
await call("close_binary");
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
path,
"--target-format",
"raw-binary",
"--architecture",
"arm",
"--entry-point",
"0x08000000",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cli = parseEvidence(JSON.parse(stdout));
assert.equal(cli.subject.digest.sha256, sha256);
assert.equal(cli.normalized_result.status, "verified");
assert.equal(cli.normalized_result.base_address, "0x8000000");
assert.equal(cli.analysis_profile.digest, profiles[0]);
assert.deepEqual(await readFile(path), bytes);
report = {
ok: true,
mocked: false,
lane: "ghidra-raw",
host: process.platform,
provider: { id: "ghidra", version: installation.providerVersion },
target: {
format: "raw-binary",
architecture: "arm",
sha256,
bytes: bytes.length,
},
transport: { cli: true, stdio_mcp: true },
canonical_entry_reuses_session: true,
changed_entry_replaces_session: true,
lifecycle_entry_preserved: true,
invalid_entry_preserves_active_session: true,
load_images_verified: true,
original_unchanged: true,
};
} catch (error) {
if (stderr.trim()) process.stderr.write(stderr);
throw error;
} finally {
await client.close();
await transport.close();
await rm(workspace, { recursive: true, force: true });
}
const verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
process.stdout.write(
JSON.stringify({
verifier_run: verifierRun,
...report,
cleanup: "complete",
}) + "\n",
);
async function call(name, args = {}) {
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
const projected = requireMcpOperationResult(result, name);
if (
name === "open_binary" ||
name === "close_binary" ||
name === "binary_session"
)
return projected;
const evidence = parseEvidence(result.structuredContent);
assert.equal(evidence.subject.digest.sha256, sha256);
assert.equal(evidence.subject.format, "raw-binary");
return evidence;
}

View File

@ -26,6 +26,8 @@ import {
validateDosComLength,
type ExecutableFormatHint,
} from "../domain/dosCom.js";
import { admitRawBinary } from "../domain/rawBinary.js";
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import { mzWindowsHeaderOffset } from "../domain/dosMz.js";
import {
hasZipSignature,
@ -45,6 +47,8 @@ export interface BinaryTargetResolverOptions {
readonly hostArchitecture?: NodeJS.Architecture;
readonly targetKind?: BinaryTarget["kind"];
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly signal?: AbortSignal;
}
@ -61,7 +65,7 @@ export const parseBinaryTarget = async (
> => {
const cwd = options.cwd ?? process.cwd();
const hostArchitecture = options.hostArchitecture ?? process.arch;
const { targetKind, formatHint, signal } = options;
const { targetKind, formatHint, signal, architecture, entryPoint } = options;
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
try {
throwIfTargetResolutionCancelled(signal);
@ -83,6 +87,40 @@ export const parseBinaryTarget = async (
throwIfTargetResolutionCancelled(signal);
const metadata = await handle.stat();
throwIfTargetResolutionCancelled(signal);
if (formatHint === "raw-binary") {
if (targetKind !== undefined && targetKind !== "executable")
return err(
new BinaryTargetError(
path,
"raw-binary format requires an executable target kind",
),
);
const admitted = admitRawBinary({
architecture,
entryPoint,
fileBytes: metadata.size,
});
if (!admitted.ok)
return err(new BinaryTargetError(path, admitted.error));
return ok({
path,
sourcePath: canonical,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "executable",
format: "raw-binary",
architecture: admitted.value.architecture,
availableArchitectures: [admitted.value.architecture],
entryPoint: admitted.value.entryPoint,
entryPointSource: admitted.value.entryPointSource,
});
}
if (architecture !== undefined || entryPoint !== undefined)
return err(
new BinaryTargetError(
path,
"architecture and entry_point apply only with format=raw-binary",
),
);
if (formatHint === "dos-com") {
if (targetKind !== undefined && targetKind !== "executable")
return err(
@ -194,7 +232,13 @@ const detectArtifactFormat = async (
): Promise<
| Exclude<
BinaryTarget["format"],
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com"
| "analysis-database"
| "mach-o"
| "elf"
| "pe"
| "dos-mz"
| "dos-com"
| "raw-binary"
>
| undefined
> => {
@ -244,7 +288,13 @@ const namedArtifactFormat = (
const isArchiveFormat = (
format: Exclude<
BinaryTarget["format"],
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz" | "dos-com"
| "analysis-database"
| "mach-o"
| "elf"
| "pe"
| "dos-mz"
| "dos-com"
| "raw-binary"
>,
): format is Extract<BinaryTarget, { kind: "archive" }>["format"] =>
isZipPackageFormat(format) || ["asar", "dmg", "pkg"].includes(format);

View File

@ -1,4 +1,5 @@
import type { ExecutableFormatHint } from "../domain/dosCom.js";
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
import type { BinarySession } from "./binary/BinarySession.js";
@ -91,6 +92,8 @@ export const runDirectAnalysis = async (
readonly signal?: AbortSignal;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
} = {},
): Promise<JsonValue> =>
withProcessCancellation(options.signal, (signal) =>
@ -101,6 +104,12 @@ export const runDirectAnalysis = async (
...(options.formatHint === undefined
? {}
: { formatHint: options.formatHint }),
...(options.architecture === undefined
? {}
: { architecture: options.architecture }),
...(options.entryPoint === undefined
? {}
: { entryPoint: options.entryPoint }),
...(options.providerId === undefined
? {}
: { providerId: options.providerId }),
@ -187,6 +196,8 @@ const runAnalysis = async (
readonly signal: AbortSignal;
readonly providerId?: AnalysisProviderSelector;
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
},
): Promise<JsonValue> => {
const { logger, signal, snapshotPath } = options;
@ -203,6 +214,12 @@ const runAnalysis = async (
...(options.formatHint === undefined
? {}
: { formatHint: options.formatHint }),
...(options.architecture === undefined
? {}
: { architecture: options.architecture }),
...(options.entryPoint === undefined
? {}
: { entryPoint: options.entryPoint }),
});
if (!prepared.ok) return cliError(prepared.error);
const { snapshot } = prepared.value;
@ -214,6 +231,12 @@ const runAnalysis = async (
...(options.formatHint === undefined
? {}
: { formatHint: options.formatHint }),
...(options.architecture === undefined
? {}
: { architecture: options.architecture }),
...(options.entryPoint === undefined
? {}
: { entryPoint: options.entryPoint }),
...(options.providerId === undefined
? {}
: { providerId: options.providerId }),
@ -246,6 +269,12 @@ const runAnalysis = async (
...(options.formatHint === undefined
? {}
: { formatHint: options.formatHint }),
...(options.architecture === undefined
? {}
: { architecture: options.architecture }),
...(options.entryPoint === undefined
? {}
: { entryPoint: options.entryPoint }),
...(snapshot === undefined ? {} : { snapshot }),
...(options.providerId === undefined
? {}
@ -440,6 +469,8 @@ const withProcessCancellation = async <Value>(
const prepareSnapshot = async (options: {
readonly path: string;
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly snapshotPath: string | undefined;
readonly signal: AbortSignal;
}): Promise<
@ -458,6 +489,12 @@ const prepareSnapshot = async (options: {
...(options.formatHint === undefined
? {}
: { formatHint: options.formatHint }),
...(options.architecture === undefined
? {}
: { architecture: options.architecture }),
...(options.entryPoint === undefined
? {}
: { entryPoint: options.entryPoint }),
});
if (!target.ok) return target;
if (!snapshotMatchesTarget(loaded.value.target, target.value))

View File

@ -1,4 +1,5 @@
import type { ExecutableFormatHint } from "../../domain/dosCom.js";
import type { BinaryArchitecture } from "../../domain/binaryTargetTypes.js";
import type { AnalysisProviderSelector } from "../../contracts/providerSelection.js";
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
import {
@ -22,6 +23,8 @@ export interface BinarySessionOpenOptions {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly snapshot?: AnalysisSnapshot;
readonly providerId?: AnalysisProviderSelector;
}
@ -72,6 +75,12 @@ export const resolveSessionOpen = async (
...(options.formatHint === undefined
? {}
: { formatHint: options.formatHint }),
...(options.architecture === undefined
? {}
: { architecture: options.architecture }),
...(options.entryPoint === undefined
? {}
: { entryPoint: options.entryPoint }),
...(options.signal === undefined ? {} : { signal: options.signal }),
});
if (!parsed.ok) return parsed;

View File

@ -1,6 +1,9 @@
import type { InvestigationRecordPort } from "../investigation/InvestigationRecordPort.js";
import type { ExecutableFormatHint } from "../../domain/dosCom.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import type {
BinaryArchitecture,
BinaryTarget,
} from "../../domain/binaryTargetTypes.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
@ -44,6 +47,8 @@ export interface BinarySessionPort
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
readonly formatHint?: ExecutableFormatHint;
readonly architecture?: BinaryArchitecture;
readonly entryPoint?: string;
readonly snapshot?: AnalysisSnapshot;
readonly providerId?: AnalysisProviderSelector;
},

View File

@ -1,4 +1,5 @@
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import { binaryTargetSummary } from "./BinaryTargetSummary.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js";
import type {
@ -92,11 +93,7 @@ export const binarySessionStatus = ({
: {
open: true,
...common,
path: target.path,
sha256: target.sha256,
format: target.format,
kind: target.kind,
architecture: target.architecture ?? null,
...binaryTargetSummary(target),
};
};

View File

@ -0,0 +1,11 @@
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
/** Preserve the admitted target interpretation in public lifecycle results. */
export const binaryTargetSummary = (target: BinaryTarget) => ({
path: target.path,
format: target.format,
kind: target.kind,
sha256: target.sha256,
architecture: target.architecture ?? null,
...(target.format === "raw-binary" ? { entry_point: target.entryPoint } : {}),
});

View File

@ -8,9 +8,8 @@ import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "pino";
import {
directAnalysisOptions,
formatSelectionOption,
providerSelectionOption,
directAnalysisOptionsFromCli,
nativeOpenOptionFields,
} from "./options.js";
import type { CliInstance } from "./types.js";
import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js";
@ -43,8 +42,7 @@ const registerOverviewCommands = (
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
});
cli.command(CLI_COMMANDS.analyze, {
description: "Get an overview of an app",
@ -75,12 +73,7 @@ const registerOverviewCommands = (
args.path,
"binary_overview",
{},
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
);
},
@ -97,12 +90,7 @@ const registerOverviewCommands = (
args.path,
"binary_overview",
{},
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -136,8 +124,7 @@ const registerDecompileCommand = (
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, "decompile", () =>
@ -152,12 +139,7 @@ const registerDecompileCommand = (
"procedure_pseudo_code",
),
},
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -168,13 +150,17 @@ const routesToJavaScriptAnalysis = async (
path: string,
options: {
readonly snapshot?: string | undefined;
readonly "target-format"?: "dos-com" | undefined;
readonly "target-format"?: "dos-com" | "raw-binary" | undefined;
readonly architecture?: string | undefined;
readonly "entry-point"?: string | undefined;
readonly provider?: string | undefined;
},
): Promise<boolean> =>
options.provider === undefined &&
options.snapshot === undefined &&
options["target-format"] === undefined &&
options.architecture === undefined &&
options["entry-point"] === undefined &&
(await isJavaScriptApplicationPath(path));
const isJavaScriptApplicationPath = async (path: string): Promise<boolean> => {
@ -215,8 +201,7 @@ const registerXrefsCommand = (
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, "xrefs", () =>
@ -231,12 +216,7 @@ const registerXrefsCommand = (
"xrefs",
),
},
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -270,8 +250,7 @@ const registerTraceCommand = (
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, "trace", () =>
@ -287,12 +266,7 @@ const registerTraceCommand = (
),
case_sensitive: options.caseSensitive,
},
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -326,8 +300,7 @@ const registerFunctionCommand = (
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, "function", () =>
@ -342,12 +315,7 @@ const registerFunctionCommand = (
"analyze_function",
),
},
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -381,8 +349,7 @@ const registerInstructionsCommand = (
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.instructions, () =>
@ -397,12 +364,7 @@ const registerInstructionsCommand = (
"read_function_instructions",
),
},
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -448,8 +410,7 @@ const registerSearchCommand = (
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, "search", () =>
@ -468,12 +429,7 @@ const registerSearchCommand = (
mode: options.mode,
case_sensitive: options.caseSensitive,
},
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});

View File

@ -5,9 +5,8 @@ import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "pino";
import {
directAnalysisOptions,
formatSelectionOption,
providerSelectionOption,
directAnalysisOptionsFromCli,
nativeOpenOptionFields,
} from "./options.js";
import type { CliInstance } from "./types.js";
@ -23,8 +22,7 @@ export const registerCoreNativeCommands = (
"Verify loaded native bytes, source mappings, relocations and entry",
args: z.object({ path: z.string().describe("Local executable path") }),
options: z.object({
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.inspectNativeLoadImage, () =>
@ -32,12 +30,7 @@ export const registerCoreNativeCommands = (
args.path,
"inspect_native_load_image",
{},
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -54,8 +47,7 @@ export const registerCoreNativeCommands = (
.min(1)
.default(256)
.describe("Requested byte count"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.readBytes, () =>
@ -63,12 +55,7 @@ export const registerCoreNativeCommands = (
args.path,
"read_bytes",
{ address: args.address, length: options.length },
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -79,8 +66,7 @@ export const registerCoreNativeCommands = (
address: z.string().describe("Exact provider memory address"),
}),
options: z.object({
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.addressToFileOffset, () =>
@ -88,12 +74,7 @@ export const registerCoreNativeCommands = (
args.path,
"address_to_file_offset",
{ address: args.address },
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -154,8 +135,7 @@ export const registerCoreNativeCommands = (
.max(5000)
.default(1000)
.describe("Maximum nodes on this page"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.traceNativeValues, () =>
@ -172,12 +152,7 @@ export const registerCoreNativeCommands = (
offset: options.offset,
limit: options.limit,
},
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -189,8 +164,7 @@ export const registerCoreNativeCommands = (
options: z.object({
type: z.string().optional().describe("Exact database type category path"),
address: z.string().optional().describe("Exact native address"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.inspectNativeDataType, () =>
@ -203,12 +177,7 @@ export const registerCoreNativeCommands = (
? {}
: { address: options.address }),
},
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -226,8 +195,7 @@ export const registerCoreNativeCommands = (
address: z.string().describe("Exact native address"),
}),
options: z.object({
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, command, () =>
@ -235,12 +203,7 @@ export const registerCoreNativeCommands = (
args.path,
operation,
{ address: args.address },
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -273,8 +236,7 @@ const registerNativeDispatchMetadataCommand = (
.min(1)
.optional()
.describe("Load or update the local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: async ({ args, options }) =>
logCliCommand(logger, "inspect-native-dispatch-metadata", () =>
@ -282,12 +244,7 @@ const registerNativeDispatchMetadataCommand = (
args.path,
"inspect_native_dispatch_metadata",
{ max_records: options.maxRecords },
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -332,8 +289,7 @@ const registerNativeUiActionCommand = (
.max(5_000)
.default(500)
.describe("Maximum returned graph edges"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: async ({ args, options }) =>
logCliCommand(logger, "trace-native-ui-action", () =>
@ -346,12 +302,7 @@ const registerNativeUiActionCommand = (
max_nodes: options.maxNodes,
max_edges: options.maxEdges,
},
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -374,8 +325,7 @@ const registerNativeApiCommand = (
.min(1)
.optional()
.describe("Load and update a local analysis snapshot"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, "inspect-native-api", () =>
@ -383,12 +333,7 @@ const registerNativeApiCommand = (
args.path,
"inspect_native_api",
{ procedure: args.address },
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});
@ -417,8 +362,7 @@ const registerAnnotationCommand = (
.string()
.optional()
.describe("Inline entry comment; empty text clears it"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
...nativeOpenOptionFields,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.annotateNativeFunction, () =>
@ -435,12 +379,7 @@ const registerAnnotationCommand = (
? {}
: { inline_comment: options["inline-comment"] }),
},
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
directAnalysisOptionsFromCli(logger, options),
),
),
});

View File

@ -2,6 +2,11 @@ import {
executableFormatHintSchema,
type ExecutableFormatHint,
} from "../domain/dosCom.js";
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import {
binaryArchitectureSchema,
rawBinaryEntryPointSchema,
} from "../domain/rawBinary.js";
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
import type { Logger } from "pino";
@ -15,16 +20,65 @@ export const providerSelectionOption = analysisProviderSelectorSchema
/** Explicit interpretation for headerless native executable formats. */
export const formatSelectionOption = executableFormatHintSchema
.optional()
.describe("Explicit headerless DOS COM interpretation");
.describe(
"Explicit headerless interpretation (dos-com or raw-binary); omission preserves header-based detection",
);
export const architectureSelectionOption = binaryArchitectureSchema
.optional()
.describe(
"Caller-supplied CPU family; required with --target-format raw-binary",
);
export const entryPointSelectionOption = rawBinaryEntryPointSchema
.optional()
.describe(
"Caller-supplied load and entry address (0xhex); required with --target-format raw-binary",
);
/** Shared CLI flags that bind a native target interpretation. */
export const nativeOpenOptionFields = {
"target-format": formatSelectionOption,
architecture: architectureSelectionOption,
"entry-point": entryPointSelectionOption,
provider: providerSelectionOption,
};
/** Native CLI flags consumed when opening a target for one-shot analysis. */
export interface NativeOpenCliFlags {
readonly snapshot?: string | undefined;
readonly provider?: AnalysisProviderSelector | undefined;
readonly "target-format"?: ExecutableFormatHint | undefined;
readonly architecture?: BinaryArchitecture | undefined;
readonly "entry-point"?: string | undefined;
}
export const directAnalysisOptions = (
logger: Logger,
snapshotPath: string | undefined,
providerId: AnalysisProviderSelector | undefined,
formatHint?: ExecutableFormatHint,
architecture?: BinaryArchitecture,
entryPoint?: string,
) => ({
logger,
snapshotPath,
...(formatHint === undefined ? {} : { formatHint }),
...(architecture === undefined ? {} : { architecture }),
...(entryPoint === undefined ? {} : { entryPoint }),
...(providerId === undefined ? {} : { providerId }),
});
/** Map native CLI flags onto the shared one-shot analysis options. */
export const directAnalysisOptionsFromCli = (
logger: Logger,
options: NativeOpenCliFlags,
) =>
directAnalysisOptions(
logger,
options.snapshot,
options.provider,
options["target-format"],
options.architecture,
options["entry-point"],
);

View File

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { openBinaryInputSchema } from "./sessionLifecycleInputs.js";
describe("open_binary raw-binary input contract", () => {
it("accepts header detection without architecture", () => {
expect(
openBinaryInputSchema.safeParse({ path: "/tmp/fixture.bin" }).success,
).toBe(true);
expect(
openBinaryInputSchema.safeParse({
path: "/tmp/legacy.com",
format: "dos-com",
}).success,
).toBe(true);
});
it("accepts raw-binary with architecture and entry", () => {
expect(
openBinaryInputSchema.safeParse({
path: "/tmp/ecu.bin",
format: "raw-binary",
architecture: "arm",
entry_point: "0x8000000",
}).success,
).toBe(true);
});
it("rejects raw-binary without architecture or entry", () => {
const parsed = openBinaryInputSchema.safeParse({
path: "/tmp/ecu.bin",
format: "raw-binary",
});
expect(parsed.success).toBe(false);
expect(
openBinaryInputSchema.safeParse({
path: "/tmp/ecu.bin",
format: "raw-binary",
architecture: "arm",
}).success,
).toBe(false);
});
it("rejects architecture without format=raw-binary", () => {
expect(
openBinaryInputSchema.safeParse({
path: "/tmp/ecu.bin",
architecture: "arm",
entry_point: "0x8000000",
}).success,
).toBe(false);
});
});

View File

@ -1,6 +1,11 @@
import { isAbsolute } from "node:path";
import { executableFormatHintSchema } from "../domain/dosCom.js";
import { localPathStringSchema } from "../domain/localPath.js";
import {
binaryArchitectureSchema,
rawBinaryEntryPointSchema,
rawBinarySelectionError,
} from "../domain/rawBinary.js";
import { z } from "zod";
import { analysisProviderSelectorSchema } from "./providerSelection.js";
@ -25,16 +30,51 @@ const binaryTargetPathSchema = localPathStringSchema
);
/** Input contract for opening a target with an optional staged snapshot. */
export const openBinaryInputSchema = z.strictObject({
path: binaryTargetPathSchema,
format: executableFormatHintSchema
.optional()
.describe(
"Explicit headerless DOS COM interpretation; omission preserves header-based detection",
),
provider_id: analysisProviderSelectorSchema.optional(),
snapshot_path: snapshotPathSchema.optional(),
});
export const openBinaryInputSchema = z
.strictObject({
path: binaryTargetPathSchema,
format: executableFormatHintSchema
.optional()
.describe(
"Explicit headerless interpretation: dos-com or raw-binary. Omission preserves header-based detection.",
),
architecture: binaryArchitectureSchema
.optional()
.describe(
"Caller-supplied CPU family. Required with format=raw-binary; rejected otherwise.",
),
entry_point: rawBinaryEntryPointSchema
.optional()
.describe(
"Caller-supplied load and entry address (0xhex). File offset 0 is mapped here. Required with format=raw-binary; rejected otherwise.",
),
provider_id: analysisProviderSelectorSchema.optional(),
snapshot_path: snapshotPathSchema.optional(),
})
.superRefine((value, context) => {
const pairing = rawBinarySelectionError({
...(value.format === undefined ? {} : { format: value.format }),
...(value.architecture === undefined
? {}
: { architecture: value.architecture }),
...(value.entry_point === undefined
? {}
: { entryPoint: value.entry_point }),
});
if (pairing === undefined) return;
context.addIssue({
code: "custom",
path:
value.format === "raw-binary"
? value.architecture === undefined
? ["architecture"]
: ["entry_point"]
: value.architecture === undefined
? ["entry_point"]
: ["architecture"],
message: pairing,
});
});
/** Input contract for closing a target after an optional atomic snapshot. */
export const closeBinaryInputSchema = z.strictObject({

View File

@ -69,7 +69,7 @@ export const exportEvidenceBundleInputSchema = z.strictObject({
export const SESSION_TOOL_CONTRACTS = [
session(
"open_binary",
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image; omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first.",
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first.",
openBinaryInputSchema,
),
session(

View File

@ -1,6 +1,10 @@
import { z } from "zod";
import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js";
import { BINARY_ARCHITECTURES } from "../domain/binaryTargetTypes.js";
import {
binaryArchitectureSchema,
rawBinaryEntryPointSchema,
} from "../domain/rawBinary.js";
import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunctionAnnotations.js";
import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js";
import { nativeUiResultSchema } from "../domain/native/nativeUiObservation.js";
@ -409,16 +413,33 @@ export const managedWorkflowOutputSchemas: Readonly<
),
};
const lifecycleTargetFields = {
path: z.string(),
kind: targetKindSchema,
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
};
const detectedLifecycleTargetSchema = z.object({
...lifecycleTargetFields,
format: targetFormatSchema.exclude(["raw-binary"]),
});
const rawLifecycleTargetSchema = z.object({
...lifecycleTargetFields,
format: z.literal("raw-binary"),
kind: z.literal("executable"),
architecture: binaryArchitectureSchema,
entry_point: rawBinaryEntryPointSchema.describe(
"Canonical caller-selected address where raw file offset 0 is mapped and execution is seeded.",
),
});
/** Exact structured-content schemas for target lifecycle operations. */
export const sessionOutputSchemas = {
open_binary: lifecycleResultOf(
z.object({
path: z.string(),
format: targetFormatSchema,
kind: targetKindSchema,
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
}),
z.discriminatedUnion("format", [
detectedLifecycleTargetSchema,
rawLifecycleTargetSchema,
]),
),
close_binary: lifecycleResultOf(
z.union([
@ -448,19 +469,15 @@ export const sessionOutputSchemas = {
),
binary_session: lifecycleResultOf(
z.union([
z.union([
sessionProvider.extend({
open: z.literal(false),
}),
sessionProvider.extend({
open: z.literal(true),
path: z.string(),
format: targetFormatSchema,
kind: targetKindSchema,
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
}),
]),
sessionProvider.extend({ open: z.literal(false) }),
sessionProvider.extend({
open: z.literal(true),
...detectedLifecycleTargetSchema.shape,
}),
sessionProvider.extend({
open: z.literal(true),
...rawLifecycleTargetSchema.shape,
}),
]),
),
export_evidence_bundle: lifecycleResultOf(

View File

@ -56,6 +56,7 @@ export const targetFormatSchema = z.enum([
"pe",
"dos-mz",
"dos-com",
"raw-binary",
"zip",
"ipa",
"apk",

View File

@ -8,6 +8,7 @@ import {
snapshotBinding,
snapshotEvidenceForQuery,
snapshotTarget,
snapshotMatchesTarget,
} from "./analysisSnapshot.js";
import {
ANALYSIS_SNAPSHOT_PROFILE,
@ -16,6 +17,7 @@ import {
} from "./analysisSnapshot.fixture.js";
import { createEvidence } from "./evidence.js";
import { createEvidenceBundle } from "./evidenceBundle.js";
import type { BinaryTarget } from "./binaryTargetTypes.js";
const snapshotWithHistoricalEvidence = () => {
const target = snapshotTarget(ANALYSIS_SNAPSHOT_TARGET);
@ -68,6 +70,54 @@ const snapshotWithHistoricalEvidence = () => {
};
describe("analysis snapshot contract", () => {
it("partitions raw snapshots and query identities by the canonical entry", () => {
const target: BinaryTarget = {
path: "/fixture/image.bin",
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
kind: "executable",
format: "raw-binary",
architecture: "arm",
availableArchitectures: ["arm"],
entryPoint: "0x8000000",
entryPointSource: "0x08000000",
};
const snapshot = snapshotTarget(target);
const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE);
const changed = {
...target,
entryPoint: "0x9000000",
entryPointSource: "0x09000000",
};
expect(
snapshotMatchesTarget(snapshot, {
...target,
entryPointSource: "0x8000000",
}),
).toBe(true);
expect(snapshotMatchesTarget(snapshot, changed)).toBe(false);
expect(analysisQueryId(snapshot, binding, "list_procedures", {})).not.toBe(
analysisQueryId(snapshotTarget(changed), binding, "list_procedures", {}),
);
const parsed = analysisSnapshotSchema.parse({
target: snapshot,
binding,
entries: [],
workflow_entries: [],
evidence_bundle: createEvidenceBundle([]),
});
expect(parsed.target).toHaveProperty("entry_point", target.entryPoint);
const { entry_point: _entry, ...legacy } = {
...snapshot,
entry_point: target.entryPoint,
};
expect(
analysisSnapshotSchema.safeParse({ ...parsed, target: legacy }).success,
).toBe(false);
expect(snapshotTarget(ANALYSIS_SNAPSHOT_TARGET)).not.toHaveProperty(
"entry_point",
);
});
it("preserves DOS MZ target and subject formats with the x86 family", () => {
const target = {
path: ANALYSIS_SNAPSHOT_TARGET.path,

View File

@ -40,6 +40,7 @@ import { digestSchema } from "./../domain/digests.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js";
import { freezeJsonSnapshot } from "./immutableJson.js";
import { rawBinaryEntryPointSchema } from "./rawBinary.js";
const architectureSchema = z.enum(BINARY_ARCHITECTURES);
const formatSchema = z.enum([
@ -49,6 +50,7 @@ const formatSchema = z.enum([
"pe",
"dos-mz",
"dos-com",
"raw-binary",
"zip",
"ipa",
"apk",
@ -80,12 +82,22 @@ const subjectSchema = z.object({
]),
architecture: architectureSchema.nullable(),
});
const targetSchema = z.object({
const targetIdentitySchema = z.object({
sha256: digestSchema,
kind: kindSchema,
format: formatSchema,
architecture: architectureSchema.nullable(),
});
const targetSchema = z.discriminatedUnion("format", [
targetIdentitySchema.extend({
format: z.enum(
formatSchema.options.filter((format) => format !== "raw-binary"),
),
}),
targetIdentitySchema.extend({
format: z.literal("raw-binary"),
entry_point: rawBinaryEntryPointSchema,
}),
]);
const bindingSchema = z
.object({
provider: committedProviderSchema,
@ -311,12 +323,16 @@ const canonicalJson = (value: unknown): string => {
/** Project a binary into the path-free identity used for cache invalidation. */
export const snapshotTarget = (
target: BinaryTarget,
): AnalysisSnapshotTarget => ({
sha256: target.sha256,
kind: target.kind,
format: target.format,
architecture: target.architecture ?? null,
});
): AnalysisSnapshotTarget => {
const identity = {
sha256: target.sha256,
kind: target.kind,
architecture: target.architecture ?? null,
};
return target.format === "raw-binary"
? { ...identity, format: target.format, entry_point: target.entryPoint }
: { ...identity, format: target.format };
};
/** Build the immutable provider/profile binding used by a cached snapshot. */
export const snapshotBinding = (
@ -335,7 +351,10 @@ export const snapshotMatchesTarget = (
snapshot.sha256 === target.sha256 &&
snapshot.kind === target.kind &&
snapshot.format === target.format &&
snapshot.architecture === (target.architecture ?? null);
snapshot.architecture === (target.architecture ?? null) &&
(snapshot.format !== "raw-binary" ||
(target.format === "raw-binary" &&
snapshot.entry_point === target.entryPoint));
/** Compare a snapshot binding with the selected concrete analysis profile. */
export const snapshotMatchesProfile = (

View File

@ -50,6 +50,7 @@ const artifactFormatSchema = z.enum([
"pe",
"dos-mz",
"dos-com",
"raw-binary",
"hopper",
"analysis-database",
"ipa",

View File

@ -78,6 +78,13 @@ export type BinaryTarget =
readonly executableRole?: never;
readonly managed?: never;
})
| (ExecutableTarget & {
readonly format: "raw-binary";
readonly entryPoint: string;
readonly entryPointSource: string;
readonly executableRole?: never;
readonly managed?: never;
})
| (ExecutableTarget & {
readonly format: "pe";
/** Provider-neutral PE image role observed from COFF characteristics. */

View File

@ -1,8 +1,8 @@
import { z } from "zod";
import { err, ok, type Result } from "./result.js";
/** Headerless DOS COM interpretation explicitly selected by the caller. */
export const executableFormatHintSchema = z.literal("dos-com");
/** Headerless executable interpretation explicitly selected by the caller. */
export const executableFormatHintSchema = z.enum(["dos-com", "raw-binary"]);
/** Explicit executable interpretation for formats with no identifying header. */
export type ExecutableFormatHint = z.infer<typeof executableFormatHintSchema>;

View File

@ -44,6 +44,7 @@ const subjectSchema = z.object({
"pe",
"dos-mz",
"dos-com",
"raw-binary",
"zip",
"ipa",
"apk",

View File

@ -84,7 +84,7 @@ export const nativeLoadImageCheckSchema = z.strictObject({
});
/** Read-only native image verification; verified applies only to declared coverage. */
export const nativeLoadImageSchema = z.discriminatedUnion("status", [
export const nativeLoadImageSchema = z.union([
z.strictObject({
status: z.literal("unsupported"),
reason: z.string().min(1),
@ -108,6 +108,23 @@ export const nativeLoadImageSchema = z.discriminatedUnion("status", [
checks: z.array(nativeLoadImageCheckSchema),
limitations: z.array(z.string().min(1)),
}),
z.strictObject({
status: z.enum(["verified", "mismatch"]),
format: z.literal("raw-binary"),
target_sha256: digest,
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]),
language_id: z.string().min(1),
compiler_spec_id: z.string().min(1),
base_address: address,
file_bytes: integer.min(1),
entry: z.strictObject({
address: address,
file_offset: z.literal(0),
}),
observations: nativeLoadImageObservationSchema,
checks: z.array(nativeLoadImageCheckSchema),
limitations: z.array(z.string().min(1)),
}),
]);
/** Provider-neutral native load-image result. */

View File

@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import { executableFormatHintSchema } from "./dosCom.js";
import {
admitRawBinary,
parseRawBinaryEntryPoint,
rawBinarySelectionError,
validateRawBinaryLength,
} from "./rawBinary.js";
describe("headerless format hints", () => {
it("admits dos-com and raw-binary names only", () => {
expect(executableFormatHintSchema.safeParse("dos-com").success).toBe(true);
expect(executableFormatHintSchema.safeParse("raw-binary").success).toBe(
true,
);
expect(executableFormatHintSchema.safeParse("raw").success).toBe(false);
});
});
describe("raw-binary entry addresses", () => {
it("canonicalizes equivalent hex spellings without dropping the source", () => {
const parsed = parseRawBinaryEntryPoint("0x08000000");
expect(parsed).toEqual({
ok: true,
value: {
source: "0x08000000",
canonical: "0x8000000",
value: 0x8000000n,
},
});
});
it.each(["8000000", "0x", "0xgg", "0X8000000", ""])(
"rejects malformed entry %s",
(value) => {
expect(parseRawBinaryEntryPoint(value).ok).toBe(false);
},
);
});
describe("raw-binary admission", () => {
it("admits a little-endian ARM image mapped at the caller entry", () => {
expect(
admitRawBinary({
architecture: "arm",
entryPoint: "0x8000000",
fileBytes: 4,
}),
).toEqual({
ok: true,
value: {
architecture: "arm",
entryPoint: "0x8000000",
entryPointSource: "0x8000000",
},
});
});
it("rejects an empty file", () => {
expect(validateRawBinaryLength(0).ok).toBe(false);
expect(
admitRawBinary({
architecture: "x86",
entryPoint: "0x1000",
fileBytes: 0,
}).ok,
).toBe(false);
});
it("rejects a 32-bit mapping that exceeds the architecture width", () => {
const result = admitRawBinary({
architecture: "arm",
entryPoint: "0xfffffffc",
fileBytes: 8,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected overflow rejection");
expect(result.error).toContain("arm address width");
});
it("allows a 64-bit mapping that would overflow 32-bit ARM", () => {
expect(
admitRawBinary({
architecture: "arm64",
entryPoint: "0xfffffffc",
fileBytes: 8,
}).ok,
).toBe(true);
});
it.each(["arm", "arm64"])(
"rejects unaligned %s entries before provider selection",
(architecture) => {
for (const entryPoint of ["0x8000001", "0x8000002", "0x8000003"]) {
expect(
admitRawBinary({ architecture, entryPoint, fileBytes: 4 }),
).toEqual({
ok: false,
error: `raw-binary ${architecture} entry_point ${entryPoint} must be 4-byte aligned`,
});
}
},
);
it.each(["x86", "x86_64"])(
"admits byte-aligned %s entries",
(architecture) => {
const admitted = admitRawBinary({
architecture,
entryPoint: "0x1001",
fileBytes: 1,
});
if (!admitted.ok) throw new Error(admitted.error);
expect(admitted.value.entryPoint).toBe("0x1001");
},
);
});
describe("raw-binary selection pairing", () => {
it("requires architecture and entry with format=raw-binary", () => {
expect(rawBinarySelectionError({ format: "raw-binary" })).toBe(
"format=raw-binary requires architecture and entry_point",
);
expect(
rawBinarySelectionError({
format: "raw-binary",
architecture: "arm",
entryPoint: "0x8000000",
}),
).toBeUndefined();
});
it("rejects architecture or entry without format=raw-binary", () => {
expect(
rawBinarySelectionError({ architecture: "arm", entryPoint: "0x1" }),
).toBe("architecture and entry_point apply only with format=raw-binary");
expect(
rawBinarySelectionError({ format: "dos-com", architecture: "x86" }),
).toBe("architecture and entry_point apply only with format=raw-binary");
});
});

123
src/domain/rawBinary.ts Normal file
View File

@ -0,0 +1,123 @@
import { z } from "zod";
import type { BinaryArchitecture } from "./binaryTargetTypes.js";
import type { ExecutableFormatHint } from "./dosCom.js";
import { err, ok, type Result } from "./result.js";
/** Caller-visible CPU families admitted for headerless raw binaries. */
export const BINARY_ARCHITECTURES = [
"x86",
"x86_64",
"arm",
"arm64",
] as const satisfies readonly BinaryArchitecture[];
/** Provider-neutral CPU family for a caller-supplied raw-binary load. */
export const binaryArchitectureSchema = z.enum(BINARY_ARCHITECTURES);
const RAW_BINARY_ENTRY_POINT = /^0x[0-9A-Fa-f]{1,16}$/u;
/** Hexadecimal load and entry address; file offset 0 is mapped here. */
export const rawBinaryEntryPointSchema = z
.string()
.regex(RAW_BINARY_ENTRY_POINT, {
message: "entry_point must be a hexadecimal address of the form 0x…",
});
/** Canonicalized caller-supplied raw-binary entry with the original spelling. */
export interface CanonicalRawBinaryEntry {
readonly source: string;
readonly canonical: string;
readonly value: bigint;
}
/** Parse a caller-supplied hex entry while preserving the source spelling. */
export const parseRawBinaryEntryPoint = (
value: string,
): Result<CanonicalRawBinaryEntry, string> => {
if (!RAW_BINARY_ENTRY_POINT.test(value))
return err("entry_point must be a hexadecimal address of the form 0x…");
const parsed = BigInt(value);
return ok({
source: value,
canonical: `0x${parsed.toString(16)}`,
value: parsed,
});
};
/** Reject empty or non-integral file extents for a headerless image. */
export const validateRawBinaryLength = (
length: number,
): Result<null, string> =>
Number.isSafeInteger(length) && length >= 1
? ok(null)
: err("raw-binary requires at least one file byte");
/** Admitted headerless load identity used by target resolution and profiles. */
export interface RawBinaryAdmission {
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly entryPoint: string;
readonly entryPointSource: string;
}
const addressWidth = (architecture: BinaryArchitecture): bigint =>
architecture === "x86" || architecture === "arm"
? 0xffffffffn
: 0xffffffffffffffffn;
/** Validate architecture, entry, and that the mapped image fits the CPU width. */
export const admitRawBinary = (input: {
readonly architecture: unknown;
readonly entryPoint: unknown;
readonly fileBytes: number;
}): Result<RawBinaryAdmission, string> => {
const architecture = binaryArchitectureSchema.safeParse(input.architecture);
if (!architecture.success)
return err(
"raw-binary format requires architecture (x86, x86_64, arm, or arm64)",
);
if (typeof input.entryPoint !== "string")
return err(
"raw-binary format requires entry_point as a hexadecimal address of the form 0x…",
);
const entry = parseRawBinaryEntryPoint(input.entryPoint);
if (!entry.ok) return entry;
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`,
);
const length = validateRawBinaryLength(input.fileBytes);
if (!length.ok) return length;
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 ok({
architecture: architecture.data,
entryPoint: entry.value.canonical,
entryPointSource: entry.value.source,
});
};
/** Describe the format/architecture/entry pairing without performing I/O. */
export const rawBinarySelectionError = (input: {
readonly format?: ExecutableFormatHint;
readonly architecture?: unknown;
readonly entryPoint?: unknown;
}): string | undefined => {
const hasArchitecture = input.architecture !== undefined;
const hasEntry = input.entryPoint !== undefined;
if (input.format === "raw-binary") {
if (!hasArchitecture || !hasEntry)
return "format=raw-binary requires architecture and entry_point";
return undefined;
}
if (hasArchitecture || hasEntry)
return "architecture and entry_point apply only with format=raw-binary";
return undefined;
};

View File

@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
skillVersion: "38",
skillVersion: "39",
} as const;

View File

@ -14,6 +14,7 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import type { GhidraInstallationInspection } from "./GhidraInstallation.js";
import { ghidraMipsProfileParameters } from "./GhidraMipsProfile.js";
import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
import {
ghidraProcessorUnsupportedReason,
@ -69,6 +70,57 @@ export const resolveGhidraAnalysisProfile = async (
: null;
if (pspExtension !== null && !pspExtension.ok) return pspExtension;
const provider = { ...identity, version: installation.providerVersion };
if (target.format === "raw-binary") {
if (languageOverride !== undefined)
return Promise.resolve(
err(
new AnalysisUnsupportedTargetError(
"resolve_analysis_profile",
target.path,
"REA_GHIDRA_LANGUAGE_ID cannot override the admitted raw-binary import.",
),
),
);
const language = ghidraRawBinaryLanguage(target.architecture);
return Promise.resolve(
ok({
profile: createAnalysisProfile(provider, {
target_kind: target.kind,
target_format: target.format,
architecture: target.architecture,
available_architectures: [...target.availableArchitectures].sort(),
import_mode: "ephemeral-source-immutable",
annotation_policy: "atomic-function-entry-metadata-v1",
load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1",
function_references: "complete-body-and-entry-reference-manager-v2",
string_inventory_evidence: "defined-data-coverage-v1",
location_resolution: "explicit-address-exact-entry-symbol-first-v3",
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_references: "exact-address-incoming-v1",
function_boundary_observations: "ghidra-terminal-call-limitations-v1",
no_return_repair:
installation.platform === "win32"
? "disabled-windows-p0"
: "returning-imports-decoded-return-v2",
process_launch:
installation.platform === "win32"
? "official-headless-script-v1"
: "inspected-jvm-launch-support-v1",
load_image_evidence: "independent-raw-mapping-context-v2",
jump_table_evidence: "typed-case-default-blocks-v1",
decompiler_jump_loads: true,
loader: "BinaryLoader",
language_id: language.languageId,
compiler_spec_id: language.compilerSpecId,
language_selection: "caller-supplied-v1",
entry_point: target.entryPoint,
entry_seed: "external-entry-and-function-before-analysis-v1",
analyzer_preset: "ghidra-default",
}),
}),
);
}
const dosMz = target.format === "dos-mz";
const dosCom = target.format === "dos-com";
const dos = dosMz || dosCom;

View File

@ -1,4 +1,6 @@
import { randomBytes, randomUUID } from "node:crypto";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import type { Socket } from "node:net";
import type { ProgressReporter } from "../application/ProgressReporter.js";
@ -43,11 +45,12 @@ import {
type GhidraSessionInfo,
} from "./GhidraSessionValues.js";
import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js";
import { readFile } from "node:fs/promises";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import {
openRegularFile,
RegularFileChangedError,
RegularFileAdmissionFailure,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
@ -286,12 +289,66 @@ export class GhidraClient {
);
}
/** Read the private immutable source for independent import verification. */
async readTargetSnapshot(
/** Read the private immutable source for format parsers that require captured bytes. */
readTargetSnapshot(
maximumBytes?: number,
signal?: AbortSignal,
): Promise<Result<GhidraTargetSnapshot, GhidraSessionError>> {
if (this.#snapshotPath === undefined)
return this.#withTargetSnapshot(async (handle, initial, path) => {
if (
maximumBytes !== undefined &&
(!Number.isSafeInteger(maximumBytes) || maximumBytes < 0)
)
throw new RangeError("Snapshot byte limit must be a safe integer");
if (maximumBytes !== undefined && initial.size > maximumBytes)
return {
kind: "over-capacity",
sourceBytesAtLeast: initial.size,
maximumBytes,
};
const bytes = await readBoundedFileBytes(
handle,
maximumBytes ?? initial.size,
signal,
initial.size,
);
if (bytes === undefined || bytes.length !== initial.size)
throw new RegularFileChangedError(path);
return { kind: "captured", bytes };
}, signal);
}
/** Measure the complete snapshot identity without retaining its bytes in memory. */
hashTargetSnapshot(
signal?: AbortSignal,
): Promise<
Result<
{ readonly sha256: string; readonly bytes: number },
GhidraSessionError
>
> {
return this.#withTargetSnapshot(async (handle, initial, path) => {
const hash = createHash("sha256");
let bytes = 0;
for await (const chunk of readFileHandleChunks(handle, {
end: initial.size,
signal,
})) {
bytes += chunk.length;
if (bytes > initial.size) throw new RegularFileChangedError(path);
hash.update(chunk);
}
if (bytes !== initial.size) throw new RegularFileChangedError(path);
return { sha256: hash.digest("hex"), bytes };
}, signal);
}
async #withTargetSnapshot<T>(
read: (handle: FileHandle, initial: Stats, path: string) => Promise<T>,
signal?: AbortSignal,
): Promise<Result<T, GhidraSessionError>> {
const path = this.#snapshotPath;
if (path === undefined)
return err(
this.#failure(
"protocol",
@ -299,77 +356,42 @@ export class GhidraClient {
),
);
try {
if (maximumBytes !== undefined) {
signal?.throwIfAborted();
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0)
throw new RangeError("Snapshot byte limit must be a safe integer");
const handle = await openRegularFile(this.#snapshotPath, {
symlinks: "reject",
});
const owner = new OwnedFileHandle(handle);
this.#snapshotOwners.add(owner);
let result: GhidraTargetSnapshot;
let readFailure: unknown;
try {
const metadata = await handle.stat();
signal?.throwIfAborted();
if (metadata.size > maximumBytes)
result = {
kind: "over-capacity",
sourceBytesAtLeast: metadata.size,
maximumBytes,
};
else {
const bytes = await readBoundedFileBytes(
handle,
maximumBytes,
signal,
metadata.size,
);
signal?.throwIfAborted();
const after = await handle.stat();
if (
bytes !== undefined &&
bytes.length === metadata.size &&
sameRegularFileState(metadata, after)
) {
result = { kind: "captured", bytes };
} else if (
bytes === undefined &&
after.isFile() &&
after.size > maximumBytes
) {
result = {
kind: "over-capacity",
sourceBytesAtLeast: after.size,
maximumBytes,
};
} else {
throw new RegularFileChangedError(this.#snapshotPath);
}
}
} catch (cause: unknown) {
readFailure = cause;
}
try {
await owner.close();
this.#snapshotOwners.delete(owner);
} catch (closeFailure: unknown) {
throw new AggregateError(
readFailure === undefined
? [closeFailure]
: [readFailure, closeFailure],
"Ghidra target snapshot read or descriptor cleanup failed",
);
}
if (readFailure !== undefined) throw readFailure;
return ok(result!);
}
return ok({
kind: "captured",
bytes: await readFile(this.#snapshotPath),
const handle = await openRegularFile(path, {
symlinks: "reject",
signal,
});
const owner = new OwnedFileHandle(handle);
this.#snapshotOwners.add(owner);
let result: Result<T, unknown>;
try {
const initial = await handle.stat();
signal?.throwIfAborted();
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
throw new RangeError("Snapshot size must be a safe integer");
const value = await read(handle, initial, path);
signal?.throwIfAborted();
const after = await handle.stat();
signal?.throwIfAborted();
if (!sameRegularFileState(initial, after))
throw new RegularFileChangedError(path);
result = ok(value);
} catch (cause: unknown) {
result = err(cause);
}
try {
await owner.close();
this.#snapshotOwners.delete(owner);
} catch (closeFailure: unknown) {
throw new AggregateError(
result.ok ? [closeFailure] : [result.error, closeFailure],
"Ghidra target snapshot read or descriptor cleanup failed",
);
}
if (!result.ok) throw result.error;
return result;
} catch (cause: unknown) {
if (cause instanceof RegularFileAdmissionFailure)
this.#snapshotOwners.add(cause.owner);
return err(
this.#failure(
signal?.aborted === true ? "cancelled" : "protocol",

View File

@ -101,6 +101,11 @@ export interface GhidraHeadlessLauncherOptions {
/** Select the admitted 16-bit real-mode MZ import instead of auto-detection. */
readonly dosMz?: true;
readonly dosCom?: true;
readonly rawBinary?: {
readonly languageId: string;
readonly compilerSpecId: string;
readonly baseAddr: string;
};
/** Explicit import language committed by the analysis profile. */
readonly languageOverride?: GhidraLanguageOverride;
/** Caller seeds committed by the analysis profile, applied before analysis. */
@ -201,6 +206,9 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
...(this.options.dosMz === undefined
? {}
: { dosMz: this.options.dosMz }),
...(this.options.rawBinary === undefined
? {}
: { rawBinary: this.options.rawBinary }),
...(this.options.languageOverride === undefined
? {}
: { languageOverride: this.options.languageOverride }),
@ -411,6 +419,11 @@ export interface GhidraHeadlessArgumentOptions {
readonly psp?: true;
readonly dosMz?: true;
readonly dosCom?: true;
readonly rawBinary?: {
readonly languageId: string;
readonly compilerSpecId: string;
readonly baseAddr: string;
};
readonly languageOverride?: GhidraLanguageOverride;
/** Canonical seed snapshot passed to the pre-analysis seed script. */
readonly seedPath?: string;
@ -426,6 +439,7 @@ export const ghidraHeadlessArguments = (
(options.platform ?? process.platform) === "win32" ? win32 : posix;
const bridgeDirectory = scriptPath.dirname(options.bridgeScriptPath);
const override = options.languageOverride;
const rawBinary = options.rawBinary;
if (
options.psp === true &&
(options.dosMz === true ||
@ -435,6 +449,10 @@ export const ghidraHeadlessArguments = (
throw new Error(
"A PSP ELF import cannot be combined with DOS or a generic Ghidra language override.",
);
if (options.psp === true && rawBinary !== undefined)
throw new Error(
"A PSP ELF import cannot be combined with a raw-binary import.",
);
if (
override !== undefined &&
(options.dosMz === true || options.dosCom === true)
@ -442,6 +460,17 @@ export const ghidraHeadlessArguments = (
throw new Error(
"A Ghidra language override cannot be combined with a DOS import.",
);
if (override !== undefined && rawBinary !== undefined)
throw new Error(
"A Ghidra language override cannot be combined with a raw-binary import.",
);
if (
rawBinary !== undefined &&
(options.dosMz === true || options.dosCom === true)
)
throw new Error(
"A raw-binary import cannot be combined with a DOS import.",
);
return [
options.projectRoot,
"rea-project",
@ -467,24 +496,35 @@ export const ghidraHeadlessArguments = (
"-cspec",
"default",
]
: options.psp === true
? [
"-loader",
"PspElfLoader",
"-processor",
"Allegrex:LE:32:default",
"-cspec",
"default",
]
: override !== undefined
: rawBinary === undefined
? options.psp === true
? [
"-loader",
"PspElfLoader",
"-processor",
override.languageId,
...(override.compilerSpecId === undefined
? []
: ["-cspec", override.compilerSpecId]),
"Allegrex:LE:32:default",
"-cspec",
"default",
]
: []),
: override !== undefined
? [
"-processor",
override.languageId,
...(override.compilerSpecId === undefined
? []
: ["-cspec", override.compilerSpecId]),
]
: []
: [
"-loader",
"BinaryLoader",
"-loader-baseAddr",
rawBinary.baseAddr,
"-processor",
rawBinary.languageId,
"-cspec",
rawBinary.compilerSpecId,
]),
"-readOnly",
"-deleteProject",
"-log",
@ -499,6 +539,14 @@ export const ghidraHeadlessArguments = (
scriptPath.join(bridgeDirectory, "ReaGhidraPrepareCom.java"),
]
: []),
...(rawBinary === undefined
? []
: [
"-preScript",
scriptPath.join(bridgeDirectory, "ReaGhidraPrepareRaw.java"),
rawBinary.languageId,
rawBinary.baseAddr,
]),
// Seeds follow any loader preparation and precede default auto-analysis.
...(options.seedPath === undefined
? []

View File

@ -12,17 +12,15 @@ import { err, ok, type Result } from "../domain/result.js";
import {
attestGhidraDosComLoadImage,
attestGhidraDosLoadImage,
attestGhidraRawBinaryLoadImage,
} from "./GhidraLoadImageValues.js";
import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js";
import type { GhidraTargetSnapshot } from "./GhidraClient.js";
import type { GhidraClient } from "./GhidraClient.js";
import type { GhidraSessionError } from "./GhidraSessionError.js";
type LoadImageSnapshotClient = {
readonly readTargetSnapshot?: (
maximumBytes?: number,
signal?: AbortSignal,
) => Promise<Result<GhidraTargetSnapshot, GhidraSessionError>>;
};
type LoadImageSnapshotClient = Partial<
Pick<GhidraClient, "hashTargetSnapshot" | "readTargetSnapshot">
>;
type NativeLoadImageAttestationInput = {
readonly target: BinaryTarget;
@ -37,7 +35,7 @@ type NativeLoadImageAttestationInput = {
const isSignalAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
/** Independently verify measured load-image observations for admitted DOS targets. */
/** Independently verify measured load-image observations for admitted DOS and raw-binary targets. */
export const attestGhidraNativeLoadImage = async (
input: NativeLoadImageAttestationInput,
): Promise<Result<JsonValue, AnalysisError>> => {
@ -71,7 +69,7 @@ export const attestGhidraNativeLoadImage = async (
nativeLoadImageSchema.parse({
status: "unsupported",
reason:
"Independent load-image verification currently supports DOS MZ and explicit DOS COM targets only.",
"Independent load-image verification currently supports DOS MZ, explicit DOS COM, and caller-supplied raw-binary targets only.",
observations: {
...observations,
metadata_recovery: [
@ -98,13 +96,17 @@ export const attestGhidraNativeLoadImage = async (
);
}
}
if (target.format !== "dos-mz" && target.format !== "dos-com") {
if (
target.format !== "dos-mz" &&
target.format !== "dos-com" &&
target.format !== "raw-binary"
) {
return ok(
jsonValueSchema.parse(
nativeLoadImageSchema.parse({
status: "unsupported",
reason:
"Independent load-image verification currently supports DOS MZ and explicit DOS COM targets only.",
"Independent load-image verification currently supports DOS MZ, explicit DOS COM, and caller-supplied raw-binary targets only.",
observations,
limitations: [
"Other formats expose measured mappings and source identities; no format-specific verification was performed.",
@ -113,6 +115,33 @@ export const attestGhidraNativeLoadImage = async (
),
);
}
if (target.format === "raw-binary") {
if (client.hashTargetSnapshot === undefined)
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason:
"The Ghidra client does not expose its immutable target snapshot identity for independent raw-binary load-image verification.",
},
}),
);
const snapshot = await client.hashTargetSnapshot(signal);
if (!snapshot.ok) return err(mapSessionError(snapshot.error));
if (isSignalAborted(signal))
return err(new AnalysisCancelledError(operation));
const attested = attestGhidraRawBinaryLoadImage(
snapshot.value,
target,
observations,
);
return attested.ok
? ok(jsonValueSchema.parse(attested.value))
: err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: { reason: attested.error },
}),
);
}
if (client.readTargetSnapshot === undefined)
return err(
new ProviderAdapterError("ghidra", operation, {
@ -122,14 +151,14 @@ export const attestGhidraNativeLoadImage = async (
},
}),
);
const snapshot = await client.readTargetSnapshot();
const snapshot = await client.readTargetSnapshot(undefined, signal);
if (!snapshot.ok) return err(mapSessionError(snapshot.error));
if (snapshot.value.kind !== "captured")
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason:
"The uncapped DOS load-image snapshot was unexpectedly not captured.",
"The uncapped load-image snapshot was unexpectedly not captured.",
},
}),
);

View File

@ -7,8 +7,10 @@ import {
} from "../domain/native/nativeLoadImage.js";
import { parseDosMzHeader, type DosMzHeader } from "../domain/dosMz.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { validateDosComLength } from "../domain/dosCom.js";
import { err, ok, type Result } from "../domain/result.js";
import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
const sha256 = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
@ -325,3 +327,111 @@ export const attestGhidraDosComLoadImage = (
}),
);
};
const hexAddress = (value: bigint): string => `0x${value.toString(16)}`;
/** Verify a caller-supplied raw-binary mapping against measured Ghidra import facts. */
export const attestGhidraRawBinaryLoadImage = (
snapshot: { readonly sha256: string; readonly bytes: number },
target: Extract<BinaryTarget, { format: "raw-binary" }>,
observations: NativeLoadImageObservation,
): Result<NativeLoadImage, string> => {
if (snapshot.sha256 !== target.sha256)
return err(
"Ghidra raw-binary snapshot digest does not match the admitted target",
);
const language = ghidraRawBinaryLanguage(target.architecture);
const base = BigInt(target.entryPoint);
const last = hexAddress(base + BigInt(snapshot.bytes) - 1n);
const checks: NativeLoadImageCheck[] = [];
const check = (
name: string,
expected: JsonValue,
observed: JsonValue,
fileOffset: number | null = null,
location: string | null = null,
): void => {
checks.push({
name,
matched: JSON.stringify(expected) === JSON.stringify(observed),
expected,
observed,
file_offset: fileOffset,
address: location,
});
};
const source = observations.source_files[0];
const mapping = observations.mappings[0];
check("executable_format", "Raw Binary", observations.executable_format);
check("language_id", language.languageId, observations.language_id);
check(
"compiler_spec_id",
language.compilerSpecId,
observations.compiler_spec_id,
);
// BinaryLoader's block base is independent of the Program image base.
check("image_base", "0x0", observations.image_base);
check("source_file_count", 1, observations.source_files.length);
check("source_file_size", snapshot.bytes, source?.size ?? null);
check("original_file_sha256", target.sha256, source?.original_sha256 ?? null);
check("modified_file_sha256", target.sha256, source?.modified_sha256 ?? null);
check("mapping_count", 1, observations.mappings.length);
check(
"mapping.file_offset",
0,
mapping?.file_offset ?? null,
0,
mapping?.start ?? null,
);
check(
"mapping.start",
target.entryPoint,
mapping?.start ?? null,
0,
mapping?.start ?? null,
);
check("mapping.end", last, mapping?.end ?? null, 0, mapping?.end ?? null);
check(
"mapping.length",
snapshot.bytes,
mapping?.length ?? null,
0,
mapping?.start ?? null,
);
check(
"mapping.sha256",
snapshot.sha256,
mapping?.sha256 ?? null,
0,
mapping?.start ?? null,
);
check(
"entry_point",
target.entryPoint,
observations.entry_points.find((entry) => entry === target.entryPoint) ??
null,
);
check("relocation_records", [], observations.relocations);
return ok(
nativeLoadImageSchema.parse({
status: checks.every((item) => item.matched) ? "verified" : "mismatch",
format: "raw-binary",
target_sha256: target.sha256,
architecture: target.architecture,
language_id: language.languageId,
compiler_spec_id: language.compilerSpecId,
base_address: target.entryPoint,
file_bytes: snapshot.bytes,
entry: { address: target.entryPoint, file_offset: 0 },
observations,
checks,
limitations: [
"Raw binary is a headerless interpretation explicitly selected by the caller, not format identification from file bytes.",
"Verification covers complete original/modified source identity, mapped file bytes, the caller-supplied entry, and the bound little-endian Ghidra language. File offset 0 is mapped at the entry address.",
"Ghidra may report additional language-defined external entries outside the mapped image. They are preserved as observations; verification requires the caller-supplied entry and does not attest other entries.",
"Reset vectors, memory-mapped I/O, big-endian and Thumb-only variants, and runtime boot behavior are not modeled or verified. No original executable bytes are changed.",
"Mapping ends are inclusive; a mismatching digest identifies its source range, not the first differing byte.",
],
}),
);
};

View File

@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js";
import {
nativeAotPeDigest,
nativeAotPeFixture,
@ -177,6 +178,50 @@ describe("Ghidra provider", () => {
});
});
describe("Ghidra raw-binary profile", () => {
it("commits BinaryLoader language and caller-supplied entry", async () => {
const ghidra = provider();
const target: BinaryTarget = {
path: "/tmp/ecu.bin",
sha256: "a".repeat(64),
kind: "executable",
format: "raw-binary",
architecture: "arm",
availableArchitectures: ["arm"],
entryPoint: "0x8000000",
entryPointSource: "0x08000000",
};
expect(ghidra.inspectTargetSupport(target).status).toBe("supported");
const resolved = await ghidra.resolveAnalysisProfile(target);
expect(resolved.ok && resolved.value).toMatchObject({
profile: {
parameters: {
target_format: "raw-binary",
architecture: "arm",
loader: "BinaryLoader",
language_id: "ARM:LE:32:v7",
compiler_spec_id: "default",
language_selection: "caller-supplied-v1",
entry_point: "0x8000000",
},
},
});
const shifted = await ghidra.resolveAnalysisProfile({
...target,
entryPoint: "0x8001000",
entryPointSource: "0x8001000",
});
expect(resolved.ok && shifted.ok).toBe(true);
if (!resolved.ok || !shifted.ok || resolved.value.profile === null)
throw new Error("expected raw-binary profiles");
expect(shifted.value.profile?.digest).not.toBe(
resolved.value.profile.digest,
);
const windows = provider({ ...installationHost(), platform: "win32" });
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
});
});
describe("Ghidra Mach-O slice support", () => {
it("refuses universal targets whose selected slice cannot be enforced", () => {
const ghidra = provider();
@ -718,6 +763,88 @@ describe("Ghidra result projection", () => {
});
describe("Ghidra measured load-image projection", () => {
it.each(["verified", "changed-snapshot", "cancelled"] as const)(
"streams raw snapshot identity and preserves %s outcomes",
async (state) => {
const fixture = fixtureRawBinaryLoadImage();
const controller = new AbortController();
const signals: (AbortSignal | undefined)[] = [];
const ghidra = provider(installationHost(), () => ({
start: () => Promise.resolve(ok(sessionInfo())),
callTool: (operation) => {
if (operation !== "inspect_native_load_image")
throw new Error(`unexpected operation ${operation}`);
return Promise.resolve(
ok(jsonValueSchema.parse(fixture.observation)),
);
},
readTargetSnapshot: () => {
throw new Error(
"raw verification must not capture the complete file",
);
},
hashTargetSnapshot: (signal) => {
signals.push(signal);
if (state === "cancelled") {
controller.abort();
return Promise.resolve(
err(
new GhidraSessionError(
"cancelled",
"Snapshot hashing cancelled",
),
),
);
}
return Promise.resolve(
ok({
...fixture.snapshot,
...(state === "changed-snapshot"
? { sha256: "f".repeat(64) }
: {}),
}),
);
},
close: () => Promise.resolve(ok(null)),
}));
const resolved = await ghidra.resolveAnalysisProfile(fixture.target);
if (!resolved.ok) throw resolved.error;
if (resolved.value.profile === null)
throw new Error("expected raw profile");
const client = ghidra.createClient(
fixture.target,
resolved.value.profile,
);
try {
const result = await client.execute(
"inspect_native_load_image",
{},
{ signal: controller.signal },
);
expect(signals).toEqual([controller.signal]);
if (state === "verified") {
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
status: "verified",
observations: fixture.observation,
});
} else {
expect(result).toMatchObject({
ok: false,
error: {
_tag:
state === "cancelled"
? "AnalysisCancelledError"
: "ProviderAdapterError",
},
});
}
} finally {
await client.close();
}
},
);
it.each([
"verified",
"mismatch",

View File

@ -87,12 +87,18 @@ import {
windowsNativeAuthorityUnavailableReason,
hasWindowsNativeAuthority,
} from "../process/WindowsAuthority.js";
import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
/** Production seam for exercising provider projection without a real process. */
export type GhidraProviderClientFactory = (
options: GhidraClientOptions,
) => Pick<GhidraClient, "start" | "callTool" | "close"> &
Partial<Pick<GhidraClient, "runtimeLineage" | "readTargetSnapshot">>;
Partial<
Pick<
GhidraClient,
"runtimeLineage" | "readTargetSnapshot" | "hashTargetSnapshot"
>
>;
type GhidraProviderClientInstance = ReturnType<GhidraProviderClientFactory>;
@ -440,6 +446,10 @@ export const createGhidraProviderClient = (input: {
let extensionFailure: AnalysisError | undefined;
// Replaced by the reported outcome once the session handshake is checked.
const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)];
const rawLanguage =
target.format === "raw-binary"
? ghidraRawBinaryLanguage(target.architecture)
: undefined;
const targetLimitations = [
...(target.format === "dos-mz"
? [
@ -447,6 +457,13 @@ export const createGhidraProviderClient = (input: {
"Static DOS analysis does not emulate BIOS, DOS interrupts, device ports, or self-modifying unpacking code. Packed targets require a separately identified unpacked artifact for original-program analysis; appended overlays are not the initialized load module.",
]
: []),
...(target.format === "raw-binary"
? [
"Headerless raw-binary analysis uses the caller-supplied architecture and entry address; file offset 0 is mapped at that address. Headers are not discovered from file bytes.",
"Little-endian generic Ghidra languages are used for x86, x86_64, arm, and arm64. Big-endian, Thumb-only, Cortex-M, and other processor variants are unsupported in this increment.",
"Static analysis does not emulate reset vectors, memory-mapped I/O, or firmware boot sequences.",
]
: []),
...(target.kind === "executable" && target.architecture === "mips"
? (target.mips.limitations ?? [])
: []),
@ -474,6 +491,15 @@ export const createGhidraProviderClient = (input: {
: {}),
...(target.format === "dos-mz" ? { dosMz: true } : {}),
...(target.format === "dos-com" ? { dosCom: true } : {}),
...(rawLanguage === undefined || target.format !== "raw-binary"
? {}
: {
rawBinary: {
languageId: rawLanguage.languageId,
compilerSpecId: rawLanguage.compilerSpecId,
baseAddr: target.entryPoint,
},
}),
...(languageOverride === undefined ? {} : { languageOverride }),
...(seeds === undefined ? {} : { analysisSeeds: seeds }),
platform: installation.platform,
@ -495,13 +521,18 @@ export const createGhidraProviderClient = (input: {
expectedLanguageId: "x86:LE:16:Real Mode",
expectedCompilerSpecId: "default",
}
: languageOverride === undefined
? {}
: rawLanguage === undefined
? languageOverride === undefined
? {}
: {
expectedLanguageId: languageOverride.languageId,
...(languageOverride.compilerSpecId === undefined
? {}
: { expectedCompilerSpecId: languageOverride.compilerSpecId }),
}
: {
expectedLanguageId: languageOverride.languageId,
...(languageOverride.compilerSpecId === undefined
? {}
: { expectedCompilerSpecId: languageOverride.compilerSpecId }),
expectedLanguageId: rawLanguage.languageId,
expectedCompilerSpecId: rawLanguage.compilerSpecId,
}),
...(psp
? {

View File

@ -0,0 +1,26 @@
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
/** Bound Ghidra language for a caller-supplied raw-binary architecture. */
export interface GhidraRawBinaryLanguage {
readonly languageId: string;
readonly compilerSpecId: "default";
}
/**
* Map the four caller-visible CPU families to little-endian generic Ghidra
* languages. Big-endian, Thumb-only, and Cortex-M variants are out of scope.
*/
export const ghidraRawBinaryLanguage = (
architecture: Exclude<BinaryArchitecture, "mips">,
): GhidraRawBinaryLanguage => {
switch (architecture) {
case "x86":
return { languageId: "x86:LE:32:default", compilerSpecId: "default" };
case "x86_64":
return { languageId: "x86:LE:64:default", compilerSpecId: "default" };
case "arm":
return { languageId: "ARM:LE:32:v7", compilerSpecId: "default" };
case "arm64":
return { languageId: "AARCH64:LE:64:v8A", compilerSpecId: "default" };
}
};

View File

@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
import type { NativeLoadImageObservation } from "../domain/native/nativeLoadImage.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
const hash = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
export const fixtureRawBinaryLoadImage = (): {
snapshot: { sha256: string; bytes: number };
target: Extract<BinaryTarget, { format: "raw-binary" }>;
observation: NativeLoadImageObservation;
} => {
const bytes = Buffer.from("1eff2fe1", "hex");
const digest = hash(bytes);
return {
snapshot: { sha256: digest, bytes: bytes.length },
target: {
path: "/tmp/ecu.bin",
sha256: digest,
kind: "executable",
format: "raw-binary",
architecture: "arm",
availableArchitectures: ["arm"],
entryPoint: "0x8000000",
entryPointSource: "0x08000000",
},
observation: {
executable_format: "Raw Binary",
language_id: "ARM:LE:32:v7",
compiler_spec_id: "default",
image_base: "0x0",
default_address_space: "ram",
source_files: [
{
name: "ecu.bin",
size: bytes.length,
original_sha256: digest,
modified_sha256: digest,
},
],
mappings: [
{
block: "ram",
start: "0x8000000",
end: "0x8000003",
address_space: "ram",
initialized: true,
loaded: true,
overlay: false,
length: bytes.length,
source_file_index: 0,
file_offset: 0,
sha256: digest,
},
],
relocations: [],
entry_points: ["0x8000000"],
entry_context: [],
},
};
};

View File

@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { attestGhidraRawBinaryLoadImage } from "./GhidraLoadImageValues.js";
import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js";
import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
describe("raw-binary Ghidra language binding", () => {
it("maps the four admitted families to little-endian generic languages", () => {
expect(ghidraRawBinaryLanguage("x86")).toEqual({
languageId: "x86:LE:32:default",
compilerSpecId: "default",
});
expect(ghidraRawBinaryLanguage("x86_64")).toEqual({
languageId: "x86:LE:64:default",
compilerSpecId: "default",
});
expect(ghidraRawBinaryLanguage("arm")).toEqual({
languageId: "ARM:LE:32:v7",
compilerSpecId: "default",
});
expect(ghidraRawBinaryLanguage("arm64")).toEqual({
languageId: "AARCH64:LE:64:v8A",
compilerSpecId: "default",
});
});
});
describe("raw-binary independent load-image checks", () => {
it("verifies mapped bytes at the caller-supplied entry", () => {
const f = fixtureRawBinaryLoadImage();
const result = attestGhidraRawBinaryLoadImage(
f.snapshot,
f.target,
f.observation,
);
expect(result).toMatchObject({
ok: true,
value: {
status: "verified",
format: "raw-binary",
architecture: "arm",
language_id: "ARM:LE:32:v7",
base_address: "0x8000000",
file_bytes: 4,
entry: { address: "0x8000000", file_offset: 0 },
},
});
});
it("retains mismatch evidence for a drifted language", () => {
const f = fixtureRawBinaryLoadImage();
f.observation.language_id = "x86:LE:32:default";
const result = attestGhidraRawBinaryLoadImage(
f.snapshot,
f.target,
f.observation,
);
expect(result).toMatchObject({
ok: true,
value: { status: "mismatch", observations: f.observation },
});
});
it("preserves additional language entries while requiring the caller entry", () => {
const f = fixtureRawBinaryLoadImage();
f.observation.entry_points = ["0x0", f.target.entryPoint, "0xffff0000"];
expect(
attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation),
).toMatchObject({
ok: true,
value: { status: "verified", observations: f.observation },
});
f.observation.entry_points = ["0x0", "0xffff0000"];
expect(
attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation),
).toMatchObject({
ok: true,
value: {
status: "mismatch",
checks: expect.arrayContaining([
expect.objectContaining({
name: "entry_point",
matched: false,
observed: null,
}),
]),
},
});
});
it("rejects stale snapshot identity", () => {
const f = fixtureRawBinaryLoadImage();
expect(
attestGhidraRawBinaryLoadImage(
f.snapshot,
{ ...f.target, sha256: "f".repeat(64) },
f.observation,
).ok,
).toBe(false);
});
});

View File

@ -140,6 +140,7 @@ export const hopperLoaderArgsForTarget = (
return ok(["-l", "WinPE", flag]);
case "dos-mz":
case "dos-com":
case "raw-binary":
return err(
new ProviderAdapterError("hopper", "resolve_analysis_profile"),
);

View File

@ -5,6 +5,27 @@ import { silentLogger } from "../logger.js";
import { HopperProvider } from "./HopperProvider.js";
describe("Hopper provider capabilities", () => {
it("declines headerless raw-binary targets with a Ghidra-directed reason", () => {
const config = parseConfig({});
if (!config.ok) throw new Error("expected valid configuration");
const provider = new HopperProvider(config.value, silentLogger, {});
expect(
provider.inspectTargetSupport({
path: "/tmp/ecu.bin",
sha256: "a".repeat(64),
kind: "executable",
format: "raw-binary",
architecture: "arm",
availableArchitectures: ["arm"],
entryPoint: "0x8000000",
entryPointSource: "0x8000000",
}),
).toMatchObject({
status: "unsupported",
code: "target_format_unsupported",
reason: expect.stringContaining("Ghidra"),
});
});
it("declines DOS MZ targets with a provider-specific support reason", () => {
const config = parseConfig({});
if (!config.ok) throw new Error("expected valid configuration");

View File

@ -123,6 +123,14 @@ export class HopperProvider implements AnalysisProviderCandidate {
reason: "REA's Hopper adapter does not admit MIPS targets.",
diagnostics,
};
if (target.format === "raw-binary")
return {
status: "unsupported",
code: "target_format_unsupported",
reason:
"Headerless raw-binary analysis requires the Ghidra adapter with a caller-supplied architecture and entry point.",
diagnostics,
};
if (target.format === "dos-mz" || target.format === "dos-com")
return {
status: "unsupported",

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { parseConfig } from "../config/parseConfig.js";
import { IdaProvider } from "./IdaProvider.js";
describe("IDA provider target support", () => {
it("declines headerless raw-binary targets", () => {
const config = parseConfig({});
if (!config.ok) throw new Error("expected valid configuration");
const provider = new IdaProvider(config.value, {});
expect(
provider.inspectTargetSupport({
path: "/tmp/ecu.bin",
sha256: "a".repeat(64),
kind: "executable",
format: "raw-binary",
architecture: "arm",
availableArchitectures: ["arm"],
entryPoint: "0x8000000",
entryPointSource: "0x8000000",
}),
).toMatchObject({
status: "unsupported",
code: "target_format_unsupported",
reason: expect.stringContaining("Ghidra"),
});
});
});

View File

@ -109,15 +109,23 @@ export class IdaProvider implements AnalysisProviderCandidate {
"REA's IDA adapter has no verified MIPS lane. Select a supported Ghidra profile instead.",
diagnostics,
};
return target.kind === "executable"
? { status: "supported", code: null, reason: null, diagnostics }
: {
status: "unsupported",
code: "target_kind_unsupported",
reason:
"IDA MCP integration admits executable inputs, not analysis databases. Select the original input binary.",
diagnostics,
};
if (target.kind !== "executable")
return {
status: "unsupported",
code: "target_kind_unsupported",
reason:
"IDA MCP integration admits executable inputs, not analysis databases. Select the original input binary.",
diagnostics,
};
if (target.format === "raw-binary")
return {
status: "unsupported",
code: "target_format_unsupported",
reason:
"IDA does not admit headerless raw binaries. Open the target with Ghidra and supply architecture and entry_point.",
diagnostics,
};
return { status: "supported", code: null, reason: null, diagnostics };
}
async resolveAnalysisProfile(
_target: BinaryTarget,

View File

@ -12,6 +12,7 @@ import { ProcessCaptureResourceScope } from "../process/capture/ProcessCaptureLi
import { ProcessCaptureError } from "../process/capture/ProcessCaptureError.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import { binaryTargetSummary } from "../application/binary/BinaryTargetSummary.js";
import { UnknownRegistryError } from "../domain/unknownRegistryError.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { Evidence } from "../domain/evidence.js";
@ -203,6 +204,12 @@ const registerOpenLifecycleTool = ({
session.open(input.path, {
signal: context.mcpReq.signal,
...(input.format === undefined ? {} : { formatHint: input.format }),
...(input.architecture === undefined
? {}
: { architecture: input.architecture }),
...(input.entry_point === undefined
? {}
: { entryPoint: input.entry_point }),
...(input.provider_id === undefined
? {}
: { providerId: input.provider_id }),
@ -213,13 +220,7 @@ const registerOpenLifecycleTool = ({
? server.delivery.toCallToolResult(
{
ok: true,
value: {
path: opened.value.path,
format: opened.value.format,
kind: opened.value.kind,
sha256: opened.value.sha256,
architecture: opened.value.architecture ?? null,
},
value: binaryTargetSummary(opened.value),
},
openContract,
)

View File

@ -0,0 +1,103 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest(
"rejects a headerless image until architecture and entry are supplied",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-raw-cli-");
const path = join(root, "ecu.bin");
await writeFile(path, Buffer.from("1eff2fe1", "hex"));
const before = await cli.run({
arguments: ["inspect", path, "--json"],
environment: {
HOME: root,
XDG_CONFIG_HOME: root,
XDG_CACHE_HOME: root,
},
});
expect(before.exitCode).not.toBe(0);
expect(JSON.stringify(before.json)).not.toContain("raw-binary");
const missing = await cli.run({
arguments: ["inspect", path, "--target-format", "raw-binary", "--json"],
environment: {
HOME: root,
XDG_CONFIG_HOME: root,
XDG_CACHE_HOME: root,
},
});
expect(missing.exitCode).toBe(1);
expect(missing.json).toMatchObject({
code: "target_unavailable",
details: { reason: expect.stringContaining("architecture") },
});
},
);
cliTest(
"rejects an unaligned AArch64 entry before provider startup",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-raw-cli-alignment-");
const path = join(root, "image.bin");
await writeFile(path, Buffer.from("c0035fd6", "hex"));
const result = await cli.run({
arguments: [
"inspect",
path,
"--target-format",
"raw-binary",
"--architecture",
"arm64",
"--entry-point",
"0x8000001",
"--provider",
"ghidra",
"--json",
],
environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root },
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "target_unavailable",
details: { reason: expect.stringContaining("must be 4-byte aligned") },
});
},
);
cliTest(
"stops Hopper from claiming a caller-supplied raw-binary image",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-raw-cli-hopper-");
const path = join(root, "ecu.bin");
await writeFile(path, Buffer.from("1eff2fe1", "hex"));
const result = await cli.run({
arguments: [
"inspect",
path,
"--target-format",
"raw-binary",
"--architecture",
"arm",
"--entry-point",
"0x8000000",
"--provider",
"hopper",
"--json",
],
environment: {
HOME: root,
XDG_CONFIG_HOME: root,
XDG_CACHE_HOME: root,
},
});
expect(result.exitCode).toBe(1);
expect(JSON.stringify(result.json)).toMatch(/Hopper|ghidra|Ghidra|hopper/i);
expect(JSON.stringify(result.json)).not.toMatch(
/unsupported binary format/i,
);
},
);

View File

@ -0,0 +1,105 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("raw-binary admission", () => {
it("requires explicit architecture and entry even with a misleading suffix", async () => {
const directory = await createTestTempDirectory("rea-raw-target-");
const path = join(directory, "fixture.js");
const bytes = Buffer.from("1eff2fe1", "hex");
await writeFile(path, bytes);
const target = await parseBinaryTarget(path, {
cwd: directory,
hostArchitecture: "arm64",
formatHint: "raw-binary",
architecture: "arm",
entryPoint: "0x08000000",
});
expect(target).toMatchObject({
ok: true,
value: {
path,
format: "raw-binary",
kind: "executable",
architecture: "arm",
availableArchitectures: ["arm"],
entryPoint: "0x8000000",
entryPointSource: "0x08000000",
sha256: createHash("sha256").update(bytes).digest("hex"),
},
});
expect(await readFile(path)).toEqual(bytes);
const detected = await parseBinaryTarget(path);
expect(detected.ok && detected.value.format).not.toBe("raw-binary");
});
it("rejects raw-binary without architecture and entry", async () => {
const directory = await createTestTempDirectory("rea-raw-missing-");
const path = join(directory, "fixture.bin");
await writeFile(path, Buffer.from("c3", "hex"));
expect(
await parseBinaryTarget(path, {
cwd: directory,
hostArchitecture: "x64",
formatHint: "raw-binary",
}),
).toMatchObject({
ok: false,
error: {
_tag: "BinaryTargetError",
reason: expect.stringContaining("architecture"),
},
});
});
it("rejects architecture without format=raw-binary", async () => {
const directory = await createTestTempDirectory("rea-raw-unpaired-");
const path = join(directory, "fixture.bin");
await writeFile(path, Buffer.from("c3", "hex"));
expect(
await parseBinaryTarget(path, {
cwd: directory,
hostArchitecture: "x64",
architecture: "arm",
entryPoint: "0x8000000",
}),
).toMatchObject({
ok: false,
error: {
_tag: "BinaryTargetError",
reason: expect.stringContaining("format=raw-binary"),
},
});
});
it("rejects an empty image", async () => {
const directory = await createTestTempDirectory("rea-raw-empty-");
const path = join(directory, "fixture.bin");
await writeFile(path, Buffer.alloc(0));
expect(
await parseBinaryTarget(path, {
cwd: directory,
hostArchitecture: "x64",
formatHint: "raw-binary",
architecture: "x86",
entryPoint: "0x1000",
}),
).toMatchObject({ ok: false, error: { _tag: "BinaryTargetError" } });
});
it("rejects a contradictory target kind", async () => {
const directory = await createTestTempDirectory("rea-raw-kind-");
const path = join(directory, "fixture.bin");
await writeFile(path, Buffer.from("c3", "hex"));
expect(
await parseBinaryTarget(path, {
cwd: directory,
hostArchitecture: "x64",
targetKind: "database",
formatHint: "raw-binary",
architecture: "x86",
entryPoint: "0x1000",
}),
).toMatchObject({ ok: false, error: { _tag: "BinaryTargetError" } });
});
});

View File

@ -0,0 +1,219 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { createServer } from "../../../src/server/createServer.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import type { AnalysisProvider } from "../../../src/application/AnalysisProvider.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { ok } from "../../../src/domain/result.js";
import { observed } from "../../fixtures/analysisExecution.js";
import {
createCacheProvider,
createTestBinarySession,
} from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { silentLogger } from "../../../src/logger.js";
import type { CallToolResult } from "@modelcontextprotocol/server";
const connect = async (
provider: AnalysisProvider = createCacheProvider([]),
) => {
const session = createTestBinarySession(provider);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const client = new Client({ name: "raw-binary-mcp", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
return { client, session };
};
const structured = (result: CallToolResult): Record<string, unknown> => {
if (
typeof result.structuredContent !== "object" ||
result.structuredContent === null
)
throw new Error("missing structured result");
return z.record(z.string(), z.unknown()).parse(result.structuredContent);
};
it("opens a headerless image when architecture and entry are supplied", async () => {
const root = await createTestTempDirectory("rea-raw-mcp-");
const path = join(root, "ecu.bin");
await writeFile(path, Buffer.from("1eff2fe1", "hex"));
const { client } = await connect();
const opened = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "arm",
entry_point: "0x08000000",
},
});
expect(opened.isError).not.toBe(true);
expect(structured(opened).result).toMatchObject({
path,
format: "raw-binary",
kind: "executable",
architecture: "arm",
entry_point: "0x8000000",
});
});
it("rejects an unaligned AArch64 entry before selecting or starting a provider", async () => {
const root = await createTestTempDirectory("rea-raw-mcp-alignment-");
const path = join(root, "image.bin");
await writeFile(path, Buffer.from("c0035fd6", "hex"));
const { client, session } = await connect({
...createCacheProvider([]),
resolveAnalysisProfile: () => {
throw new Error("unaligned input must not select a provider");
},
createClient: () => {
throw new Error("unaligned input must not launch a provider");
},
});
const opened = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "arm64",
entry_point: "0x8000001",
},
});
expect(opened.isError).toBe(true);
expect(JSON.stringify(opened.content)).toContain("must be 4-byte aligned");
expect(session.activeTarget()).toBeUndefined();
});
it("rejects raw-binary open requests that omit architecture", async () => {
const root = await createTestTempDirectory("rea-raw-mcp-missing-");
const path = join(root, "ecu.bin");
await writeFile(path, Buffer.from("1eff2fe1", "hex"));
const { client } = await connect();
const opened = await client.callTool({
name: "open_binary",
arguments: { path, format: "raw-binary" },
});
expect(opened.isError).toBe(true);
});
it("reuses a canonical raw entry and replaces the session when the entry changes", async () => {
const root = await createTestTempDirectory("rea-raw-mcp-reopen-");
const path = join(root, "ecu.bin");
await writeFile(path, Buffer.from("1eff2fe1", "hex"));
const fixture = createCacheProvider([]);
const resolved: string[] = [];
const created: string[] = [];
const closed: string[] = [];
const { client, session } = await connect({
...fixture,
resolveAnalysisProfile: (target) => {
if (target.format !== "raw-binary")
throw new Error("expected raw binary");
resolved.push(target.entryPoint);
return Promise.resolve(
ok({
profile: createAnalysisProfile(
{ ...fixture.identity(), version: "1" },
{
entry_point: target.entryPoint,
},
),
}),
);
},
createClient: (target) => {
if (target.format !== "raw-binary")
throw new Error("expected raw binary");
created.push(target.entryPoint);
return {
execute: (operation) => {
if (operation !== "health" && operation !== "address_name")
throw new Error(`unexpected fixture operation: ${operation}`);
return Promise.resolve(observed(target.entryPoint));
},
close: () => {
closed.push(target.entryPoint);
return Promise.resolve(ok(null));
},
};
},
});
const tools = (await client.listTools()).tools;
const validateLifecycle = (
name: "open_binary" | "binary_session",
response: CallToolResult,
) => {
const tool = tools.find((candidate) => candidate.name === name);
if (tool?.outputSchema === undefined)
throw new Error(`missing ${name} output schema`);
const schema = z.record(z.string(), z.unknown()).parse(tool.outputSchema);
const validate = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(schema);
expect(
validate(response.structuredContent),
JSON.stringify(validate.errors),
).toBe(true);
const parsed = toolContract(name).outputSchema.parse(
response.structuredContent,
);
const target = z.record(z.string(), z.unknown()).parse(parsed.result);
const { entry_point: _entry, ...missingEntry } = target;
expect(validate({ ...structured(response), result: missingEntry })).toBe(
false,
);
return target;
};
for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) {
const opened = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "arm",
entry_point: entryPoint,
},
});
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const canonical = `0x${BigInt(entryPoint).toString(16)}`;
expect(validateLifecycle("open_binary", opened).entry_point).toBe(
canonical,
);
const status = await client.callTool({
name: "binary_session",
arguments: {},
});
expect(status.isError, JSON.stringify(status)).not.toBe(true);
expect(validateLifecycle("binary_session", status).entry_point).toBe(
canonical,
);
const result = await client.callTool({
name: "address_name",
arguments: { address: entryPoint },
});
expect(result.isError, JSON.stringify(result)).not.toBe(true);
expect(structured(result).normalized_result).toBe(canonical);
}
expect(session.analysisProfile()?.parameters.entry_point).toBe("0x9000000");
expect(resolved).toEqual(["0x8000000", "0x9000000"]);
expect(created).toEqual(["0x8000000", "0x9000000"]);
expect(closed).toEqual(["0x8000000"]);
});

View File

@ -12,6 +12,7 @@ import type { ProgressUpdate } from "../../../../src/application/ProgressReporte
import { ok } from "../../../../src/domain/result.js";
import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js";
import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js";
import { GHIDRA_PROCESS_DIAGNOSTIC_BYTES } from "../../../../src/ghidra/GhidraDiagnostics.js";
import { GhidraHeadlessLauncher } from "../../../../src/ghidra/GhidraLauncher.js";
import type { GhidraDiagnostic } from "../../../../src/ghidra/GhidraClientTypes.js";
@ -122,12 +123,14 @@ const clientFor = (
readonly transport?: GhidraTransportKind;
readonly runId?: string;
readonly logger?: Logger;
readonly targetPath?: string;
readonly targetSha256?: string;
} = {},
): GhidraClient => {
const client = new GhidraClient({
launcher,
targetPath: fixturePath,
targetSha256: TARGET_SHA256,
targetPath: options.targetPath ?? fixturePath,
targetSha256: options.targetSha256 ?? TARGET_SHA256,
transport: options.transport ?? HOST_TRANSPORT,
providerVersion: PROVIDER_VERSION,
profileDigest: PROFILE_DIGEST,
@ -223,6 +226,80 @@ it("keeps one shared startup despite a failed progress sink and stops reports af
});
describe("GhidraClient captured source snapshot", () => {
it("retains a snapshot descriptor whose close failed until session cleanup succeeds", async () => {
const client = clientFor(new FixtureLauncher());
const started = await client.start();
if (!started.ok) throw started.error;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockRejectedValueOnce(new Error("snapshot descriptor close failed"));
try {
await expect(client.hashTargetSnapshot()).resolves.toMatchObject({
ok: false,
error: { kind: "protocol" },
});
const owner = close.mock.contexts[0];
expect(owner).toBeInstanceOf(OwnedFileHandle);
const closed = await client.close();
if (!closed.ok) throw closed.error;
expect(
close.mock.contexts.filter((context) => context === owner),
).toHaveLength(2);
} finally {
close.mockRestore();
}
});
it("hashes complete private snapshots with bounded chunks and abortable reads", async () => {
const root = await createTestTempDirectory("rea-ghidra-hash-");
const source = join(root, "firmware.bin");
const bytes = Buffer.alloc(512 * 1024 + 3, 0xa5);
const sha256 = createHash("sha256").update(bytes).digest("hex");
await writeFile(source, bytes);
const client = clientFor(new FixtureLauncher(), {
targetPath: source,
targetSha256: sha256,
});
await expect(client.hashTargetSnapshot()).resolves.toMatchObject({
ok: false,
error: { kind: "protocol" },
});
const started = await client.start();
if (!started.ok) throw started.error;
// Observe the admitted private snapshot even if the original path changes.
await writeFile(source, "changed original");
const allocate = Buffer.allocUnsafe;
const allocations = vi.spyOn(Buffer, "allocUnsafe");
try {
const identity = await client.hashTargetSnapshot();
if (!identity.ok) throw identity.error;
expect(identity.value).toEqual({ sha256, bytes: bytes.length });
expect(allocations.mock.calls.length).toBeGreaterThan(1);
expect(
Math.max(...allocations.mock.calls.map(([size]) => size)),
).toBeLessThan(bytes.length);
const controller = new AbortController();
allocations.mockImplementationOnce((size) => {
controller.abort();
return allocate(size);
});
await expect(
client.hashTargetSnapshot(controller.signal),
).resolves.toMatchObject({ ok: false, error: { kind: "cancelled" } });
} finally {
allocations.mockRestore();
}
await expect(client.hashTargetSnapshot()).resolves.toMatchObject({
ok: true,
value: { sha256, bytes: bytes.length },
});
await client.close();
await expect(client.hashTargetSnapshot()).resolves.toMatchObject({
ok: false,
error: { kind: "protocol" },
});
});
it("exposes the immutable import snapshot only during the live session", async () => {
const client = clientFor(new FixtureLauncher());
await expect(client.readTargetSnapshot()).resolves.toMatchObject({

View File

@ -113,6 +113,39 @@ describe("Ghidra COM loader", () => {
});
});
describe("Ghidra raw-binary loader", () => {
it("loads caller-supplied architecture and entry before auto-analysis", () => {
const arguments_ = ghidraHeadlessArguments({
projectRoot: "/tmp/project",
targetPath: "/tmp/ecu.bin",
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
descriptorPath: "/tmp/session.json",
ghidraLogPath: "/tmp/ghidra.log",
scriptLogPath: "/tmp/script.log",
rawBinary: {
languageId: "ARM:LE:32:v7",
compilerSpecId: "default",
baseAddr: "0x8000000",
},
});
expectOptions(arguments_, [
["-loader", "BinaryLoader"],
["-loader-baseAddr", "0x8000000"],
["-processor", "ARM:LE:32:v7"],
["-cspec", "default"],
]);
const prepare = arguments_.indexOf("-preScript");
expect(arguments_[prepare + 1]).toBe(
join("/package/bridge", "ReaGhidraPrepareRaw.java"),
);
expect(arguments_[prepare + 2]).toBe("ARM:LE:32:v7");
expect(arguments_[prepare + 3]).toBe("0x8000000");
expect(prepare).toBeLessThan(arguments_.indexOf("-postScript"));
expect(arguments_).toContain("-readOnly");
expect(arguments_).toContain("-deleteProject");
});
});
describe("Ghidra headless launcher", () => {
it("forces the admitted real-mode language and loader for DOS MZ", () => {
const arguments_ = ghidraHeadlessArguments({
@ -208,6 +241,20 @@ describe("Ghidra configured import language and seeds", () => {
).toThrow(/cannot be combined with a DOS import/u);
});
it("never combines a configured language with a raw-binary import", () => {
expect(() =>
ghidraHeadlessArguments({
...base,
languageOverride: { languageId: "x86:LE:32:default" },
rawBinary: {
languageId: "ARM:LE:32:v7",
compilerSpecId: "default",
baseAddr: "0x8000000",
},
}),
).toThrow(/cannot be combined with a raw-binary import/u);
});
it.each(["darwin", "win32"] as const)(
"applies seeds after loader preparation and before analysis scripts on %s",
(platform) => {

View File

@ -31,6 +31,27 @@ describe("Hopper analysis profiles", () => {
});
});
it("does not map caller-supplied raw binaries onto a Hopper loader", () => {
expect(
hopperLoaderArgsForTarget({
path: "/tmp/ecu.bin",
sha256: "a".repeat(64),
kind: "executable",
format: "raw-binary",
architecture: "arm",
availableArchitectures: ["arm"],
entryPoint: "0x8000000",
entryPointSource: "0x8000000",
}),
).toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
operation: "resolve_analysis_profile",
},
});
});
it.each([
[target("elf", "x86_64"), ["-l", "ELF", "--intel-64"]],
[target("pe", "x86"), ["-l", "WinPE", "--intel-32"]],