mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
feat(ghidra): support raw big-endian m68k analysis (#4018)
* feat(ghidra): support raw big-endian m68k analysis Admit `m68k` for headerless raw-binary targets. Admission requires a 2-byte aligned entry and a mapping within 0x0..0xffffffff, and Ghidra imports with BinaryLoader using 68000:BE:32:default, the default compiler specification, and the `ram` default address space. `6502` and `m68k` now share one RAW_ONLY_ARCHITECTURES owner, so detected targets, Evidence subjects, snapshots, and lifecycle output contracts exclude both instead of repeating a 6502 literal. Ghidra's default 68000 language decodes the 68040 superset; separate 68020/68030/CPU32/ColdFire variants, Amiga/Atari containers, and reset vector discovery remain unsupported. Windows Ghidra P0 still admits raw 6502 only. Verified with Ghidra 12.1.4 on macOS against an 8-byte self-built fixture (nop; nop; moveq #1,d0; rts at 0x1000): load image verified in `ram`, entry decompiles to `return 1`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(ghidra): verify raw m68k analysis with real Ghidra Add npm run verify:ghidra:m68k. The verifier loads a source-owned 22-byte 68000 fixture (annotated hex, no assembler) at 0x1000 and checks, through the compiled CLI and stdio MCP server: the 68000:BE:32:default BinaryLoader import, load-image parity, exact mapped bytes, both functions, the bsr.b call reference, decompilation by address and file offset, rejection of a misaligned entry and of a mapping past 0xffffffff without replacing the active session, unchanged source bytes, and runtime cleanup. Run with Ghidra 12.1.4 and JDK 21 on macOS arm64. Linux has not been exercised; Windows Ghidra P0 does not admit m68k. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -246,13 +246,15 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
|
||||
|
||||
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
|
||||
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
|
||||
`arm`, `arm64`, or `6502`), and `entry_point` (`0x` hex). File offset 0 is mapped at
|
||||
`arm`, `arm64`, `6502`, or `m68k`), 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. `6502` selects NMOS 6502; the entry and
|
||||
complete file mapping must fit within `0x0000..0xffff`. 65C02, 65816, cartridge
|
||||
containers, bank switching, and mapper semantics are unsupported. Windows
|
||||
Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
|
||||
`BinaryLoader` with a generic language: big-endian for `m68k`, little-endian
|
||||
otherwise. Big-endian ARM, Thumb-only, and Cortex-M variants are unsupported.
|
||||
`6502` selects NMOS 6502; the entry and complete file mapping must fit within
|
||||
`0x0000..0xffff`. 65C02, 65816, cartridge containers, bank switching, and mapper
|
||||
semantics are unsupported. `m68k` requires an even entry and decodes the 68040
|
||||
superset of the 68000 family; Amiga/Atari containers and vector-table entry
|
||||
discovery are unsupported. Windows Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
|
||||
|
||||
### Function annotations in Ghidra
|
||||
|
||||
|
||||
+45
-13
@@ -25,27 +25,31 @@ rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-bina
|
||||
}
|
||||
```
|
||||
|
||||
`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, or `6502`. `entry_point` is a
|
||||
`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, `6502`, or `m68k`. `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 and 6502 entries may be byte-aligned.
|
||||
ARM and AArch64 entries must be 4-byte aligned; m68k entries must be 2-byte
|
||||
aligned; x86 and 6502 entries may be byte-aligned.
|
||||
For 6502, the entry and complete mapped file must fit within `0x0000..0xffff`;
|
||||
images that cross the 16-bit boundary are rejected before provider startup.
|
||||
For m68k, the mapping must fit within `0x0..0xffffffff`.
|
||||
`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 and compiler specifications:
|
||||
Ghidra imports a private snapshot with `BinaryLoader` and these generic
|
||||
languages and compiler specifications. Each family has one fixed byte order:
|
||||
m68k is big-endian and the others are little-endian.
|
||||
|
||||
| architecture | language_id | compiler_spec_id |
|
||||
| ------------ | -------------------- | ---------------- |
|
||||
| x86 | `x86:LE:32:default` | `gcc` |
|
||||
| x86_64 | `x86:LE:64:default` | `gcc` |
|
||||
| arm | `ARM:LE:32:v7` | `default` |
|
||||
| arm64 | `AARCH64:LE:64:v8A` | `default` |
|
||||
| 6502 | `6502:LE:16:default` | `default` |
|
||||
| architecture | language_id | compiler_spec_id |
|
||||
| ------------ | --------------------- | ---------------- |
|
||||
| x86 | `x86:LE:32:default` | `gcc` |
|
||||
| x86_64 | `x86:LE:64:default` | `gcc` |
|
||||
| arm | `ARM:LE:32:v7` | `default` |
|
||||
| arm64 | `AARCH64:LE:64:v8A` | `default` |
|
||||
| 6502 | `6502:LE:16:default` | `default` |
|
||||
| m68k | `68000:BE:32:default` | `default` |
|
||||
|
||||
Compiler specifications select analysis conventions; they do not identify the
|
||||
original compiler or ABI of a headerless target. Ghidra's x86 languages do not
|
||||
@@ -106,12 +110,40 @@ unchanged source bytes and complete runtime cleanup. Windows requires the
|
||||
matching native addon; the POSIX provider path has not been exercised by the
|
||||
Windows run.
|
||||
|
||||
## Motorola 68000
|
||||
|
||||
```bash
|
||||
rea function /absolute/path/to/image.bin 0x1000 --target-format raw-binary --architecture m68k --entry-point 0x1000 --provider ghidra --json
|
||||
rea inspect-native-load-image /absolute/path/to/image.bin --target-format raw-binary --architecture m68k --entry-point 0x1000 --provider ghidra --json
|
||||
```
|
||||
|
||||
Select only headerless big-endian 68000-family code with a known, even entry at
|
||||
file offset 0. Ghidra's `68000:BE:32:default` language decodes the 68040
|
||||
superset in the 32-bit `ram` address space. Instructions that a real MC68000
|
||||
would not execute are therefore not rejected, and 68020, 68030, CPU32, and
|
||||
ColdFire programs are not admitted as those variants. Addresses are not masked
|
||||
to the MC68000 24-bit bus. Evidence preserves `architecture: "m68k"`, the
|
||||
selected language, entry, file digest, and verified source mapping. Register and
|
||||
calling conventions are analysis conventions, not a recovered source ABI.
|
||||
|
||||
This lane does not interpret Amiga hunks, Atari ST, or other container headers,
|
||||
cartridge or console memory maps, or bank switching. REA does not read the
|
||||
initial stack pointer and reset vector from the first eight bytes; select the
|
||||
entry explicitly. Windows Ghidra P0 does not admit m68k.
|
||||
|
||||
`npm run verify:ghidra:m68k` uses a source-owned 22-byte fixture written as
|
||||
annotated hex in the verifier, so it needs no assembler. The lane checks real
|
||||
CLI/MCP load-image parity, mapped bytes, a decoded call, decompilation by address
|
||||
and file offset, misaligned and out-of-range mappings, unchanged source bytes and
|
||||
runtime cleanup. It has been run with Ghidra 12.1.4 on macOS; Linux has not been
|
||||
exercised.
|
||||
|
||||
## 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.
|
||||
- Big-endian ARM, Thumb-only, Cortex-M, and other Ghidra processor variants are
|
||||
unsupported. m68k is the only big-endian family.
|
||||
- Reset vectors, memory-mapped I/O, and firmware boot sequences are not
|
||||
emulated.
|
||||
- Independent load-image verification covers source identity, mapped file
|
||||
|
||||
@@ -976,6 +976,16 @@ and runtime cleanup. Windows additionally requires the matching native addon;
|
||||
its verifier lineage remains explicitly unavailable outside the provider's
|
||||
retained Job Object, as in the other Windows lanes.
|
||||
|
||||
`npm run verify:ghidra:m68k` needs no assembler: its 22-byte big-endian 68000
|
||||
fixture is source-owned annotated hex in the verifier, loaded at `0x1000`. With
|
||||
an installed Ghidra 12.1.4 and full JDK on Linux or macOS, it verifies the
|
||||
`68000:BE:32:default` BinaryLoader import, real CLI/stdio MCP load-image parity,
|
||||
exact source bytes, both functions, the `bsr.b` call reference, decompilation
|
||||
through address and file offset, rejection of a misaligned entry and of a mapping
|
||||
past `0xffffffff` without replacing the active session, and runtime cleanup.
|
||||
Windows Ghidra P0 does not admit m68k, so this is not a Windows lane. The lane
|
||||
has been run on macOS; Linux has not been exercised.
|
||||
|
||||
`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,
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"verify:ghidra:ppc": "npm run build:cached && node scripts/verify-real-ghidra-ppc.mjs",
|
||||
"verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs",
|
||||
"verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs",
|
||||
"verify:ghidra:m68k": "npm run build:cached && node scripts/verify-real-ghidra-m68k.mjs",
|
||||
"verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs",
|
||||
"verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs",
|
||||
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
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 { 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-m68k.mjs");
|
||||
// Windows Ghidra P0 admits raw 6502 only; m68k has no Windows lane.
|
||||
if (!["linux", "darwin"].includes(process.platform))
|
||||
throw new Error(
|
||||
"m68k 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 exec = promisify(execFile);
|
||||
const run = createVerifierRun();
|
||||
const root = fileURLToPath(new URL("..", import.meta.url));
|
||||
// Source-owned big-endian 68000 image, loaded at 0x1000:
|
||||
// 0x1000 7007 moveq #7,d0
|
||||
// 0x1002 610e bsr.b add_three (0x1012)
|
||||
// 0x1004 0c40000a cmpi.w #10,d0
|
||||
// 0x1008 6604 bne.b mismatch (0x100e)
|
||||
// 0x100a 702a moveq #42,d0
|
||||
// 0x100c 4e75 rts
|
||||
// 0x100e 7000 mismatch: moveq #0,d0
|
||||
// 0x1010 4e75 rts
|
||||
// 0x1012 5680 add_three: addq.l #3,d0
|
||||
// 0x1014 4e75 rts
|
||||
const bytes = Buffer.from(
|
||||
"7007610e0c40000a6604702a4e7570004e7556804e75",
|
||||
"hex",
|
||||
);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
assert.equal(
|
||||
sha256,
|
||||
"7679358409efee4c5c2465f9a47158c4d49e0415ba12b3170898440db7ea0bc9",
|
||||
);
|
||||
await mkdir(join(root, "build"), { recursive: true });
|
||||
const workspace = await mkdtemp(join(root, "build", "rea-m68k-proof-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
const path = join(workspace, "image.bin");
|
||||
await writeFile(path, bytes);
|
||||
const entrypoint = join(root, "scripts", "rea.mjs");
|
||||
const env = {
|
||||
...process.env,
|
||||
TMPDIR: runtime,
|
||||
TMP: runtime,
|
||||
TEMP: runtime,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
REA_PROCESS_RUN_ID: run.run_id,
|
||||
};
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-real-m68k-proof", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
let report;
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const opened = await call("open_binary", {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "m68k",
|
||||
entry_point: "0x1000",
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(opened.architecture, "m68k");
|
||||
assert.equal(opened.entry_point, "0x1000");
|
||||
const status = await call("binary_session");
|
||||
const image = evidence(await call("inspect_native_load_image"));
|
||||
assert.equal(
|
||||
image.normalized_result.status,
|
||||
"verified",
|
||||
JSON.stringify(image.normalized_result),
|
||||
);
|
||||
assert.equal(image.normalized_result.architecture, "m68k");
|
||||
assert.equal(image.normalized_result.base_address, "0x1000");
|
||||
assert.equal(image.normalized_result.language_id, "68000:BE:32:default");
|
||||
assert.equal(image.normalized_result.compiler_spec_id, "default");
|
||||
assert.equal(image.normalized_result.file_bytes, bytes.length);
|
||||
assert.equal(image.analysis_profile.parameters.loader, "BinaryLoader");
|
||||
assert.equal(
|
||||
image.analysis_profile.parameters.language_id,
|
||||
"68000:BE:32:default",
|
||||
);
|
||||
assert.equal(image.analysis_profile.parameters.compiler_spec_id, "default");
|
||||
assert.equal(image.analysis_profile.parameters.entry_point, "0x1000");
|
||||
const memory = evidence(
|
||||
await call("read_bytes", { address: "0x1000", length: bytes.length }),
|
||||
);
|
||||
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
|
||||
const procedures = evidence(await call("list_procedures"));
|
||||
assert.deepEqual(
|
||||
procedures.normalized_result.map((procedure) => procedure.address).sort(),
|
||||
["0x1000", "0x1012"],
|
||||
);
|
||||
const instruction = evidence(
|
||||
await call("inspect_native_instruction", { address: "0x1002" }),
|
||||
);
|
||||
assert.equal(instruction.normalized_result.mnemonic, "bsr.b");
|
||||
assert.equal(instruction.normalized_result.flow.kind, "call");
|
||||
assert.deepEqual(instruction.normalized_result.flow.direct_destinations, [
|
||||
"0x1012",
|
||||
]);
|
||||
assert.ok(
|
||||
instruction.normalized_result.references.some(
|
||||
(reference) =>
|
||||
reference.call &&
|
||||
reference.type === "UNCONDITIONAL_CALL" &&
|
||||
reference.target_address === "0x1012",
|
||||
),
|
||||
);
|
||||
const pseudo = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "0x1012" }),
|
||||
);
|
||||
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3)\b/u);
|
||||
const caller = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "0x1000" }),
|
||||
);
|
||||
assert.match(caller.normalized_result, /==\s*(?:10|0xa)\b/iu);
|
||||
assert.match(caller.normalized_result, /return\s+(?:42|0x2a)\s*;/iu);
|
||||
assert.match(caller.normalized_result, /return\s+0\s*;/u);
|
||||
const filePseudo = evidence(
|
||||
await call("procedure_pseudo_code", { procedure: "file-offset:0x12" }),
|
||||
);
|
||||
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
|
||||
assert.ok(
|
||||
filePseudo.locations.some(
|
||||
(location) => location.kind === "file-offset" && location.offset === 18,
|
||||
),
|
||||
);
|
||||
// Rejected selections must not replace the live session.
|
||||
for (const [entryPoint, reason] of [
|
||||
["0x1001", /raw-binary m68k entry_point 0x1001 must be 2-byte aligned/u],
|
||||
[
|
||||
"0xfffffff0",
|
||||
/raw-binary mapping from 0xfffffff0 must fit in the m68k address width/u,
|
||||
],
|
||||
]) {
|
||||
const rejected = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: {
|
||||
path,
|
||||
format: "raw-binary",
|
||||
architecture: "m68k",
|
||||
entry_point: entryPoint,
|
||||
},
|
||||
});
|
||||
assert.equal(rejected.isError, true);
|
||||
assert.match(JSON.stringify(rejected.content), reason);
|
||||
const preserved = await call("binary_session");
|
||||
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
|
||||
assert.equal(preserved.entry_point, "0x1000");
|
||||
}
|
||||
await call("close_binary");
|
||||
await client.ping();
|
||||
const { stdout } = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-load-image",
|
||||
path,
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"m68k",
|
||||
"--entry-point",
|
||||
"0x1000",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const cli = evidence(JSON.parse(stdout));
|
||||
assert.deepEqual(cli.normalized_result, image.normalized_result);
|
||||
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
|
||||
const fileCli = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"decompile",
|
||||
path,
|
||||
"--file-offset=0x12",
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"m68k",
|
||||
"--entry-point",
|
||||
"0x1000",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const fileCliEvidence = evidence(JSON.parse(fileCli.stdout));
|
||||
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
|
||||
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
|
||||
assert.deepEqual(await readFile(path), bytes);
|
||||
// Ghidra projects end with their sessions; on macOS the server keeps its
|
||||
// process-token helper under TMPDIR until shutdown, checked after close.
|
||||
assert.deepEqual(
|
||||
(await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")),
|
||||
[],
|
||||
"Owned Ghidra runtime survived close",
|
||||
);
|
||||
report = {
|
||||
ok: true,
|
||||
mocked: false,
|
||||
lane: "ghidra-m68k",
|
||||
host: process.platform,
|
||||
provider: image.provider,
|
||||
target: {
|
||||
sha256,
|
||||
bytes: bytes.length,
|
||||
architecture: "m68k",
|
||||
entry: "0x1000",
|
||||
},
|
||||
cli_mcp_load_image_parity: true,
|
||||
instructions_and_call_reference: true,
|
||||
decompilation: true,
|
||||
file_offset_decompilation_cli_mcp: true,
|
||||
misaligned_entry_preserves_session: true,
|
||||
invalid_mapping_preserves_session: true,
|
||||
source_unchanged: true,
|
||||
runtime_cleanup: "complete",
|
||||
};
|
||||
} catch (cause) {
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
throw cause;
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
await readdir(runtime),
|
||||
[],
|
||||
`Runtime resources survived server shutdown; retained workspace: ${workspace}`,
|
||||
);
|
||||
const verifierRun = await completeVerifierRun(run);
|
||||
assert.equal(verifierRun.process_lineage.status, "verified");
|
||||
assert.deepEqual(verifierRun.process_lineage.descendants, []);
|
||||
// Keep the workspace for diagnosis unless verification and cleanup both pass.
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
process.stdout.write(
|
||||
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
|
||||
);
|
||||
|
||||
async function call(name, args = {}) {
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 240000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
if (["open_binary", "binary_session", "close_binary"].includes(name))
|
||||
return requireMcpOperationResult(result, name);
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
function evidence(value) {
|
||||
const result = parseEvidence(value);
|
||||
assert.equal(result.subject.digest.sha256, sha256);
|
||||
assert.equal(result.subject.architecture, "m68k");
|
||||
assert.equal(result.subject.format, "raw-binary");
|
||||
return result;
|
||||
}
|
||||
@@ -6,17 +6,24 @@ import { z } from "zod";
|
||||
|
||||
import { toolContract } from "./toolContracts.js";
|
||||
import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js";
|
||||
import { RAW_ONLY_ARCHITECTURES } from "../domain/binaryTargetTypes.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
|
||||
it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"advertises the raw-only 6502 Evidence subject constraint in %s",
|
||||
(target) => {
|
||||
it.each(
|
||||
(["draft-2020-12", "draft-07"] as const).flatMap((target) =>
|
||||
RAW_ONLY_ARCHITECTURES.map(
|
||||
(architecture) => [target, architecture] as const,
|
||||
),
|
||||
),
|
||||
)(
|
||||
"advertises the raw-only Evidence subject constraint in %s for %s",
|
||||
(target, architecture) => {
|
||||
const valid = createEvidence(
|
||||
{
|
||||
path: "/fixture/image.bin",
|
||||
sha256: "a".repeat(64),
|
||||
format: "raw-binary",
|
||||
architecture: "6502",
|
||||
architecture,
|
||||
},
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "address_name", parameters: {}, result: "entry" },
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
managedMemberSummaryViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js";
|
||||
import { BINARY_ARCHITECTURES } from "../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
BINARY_ARCHITECTURES,
|
||||
RAW_ONLY_ARCHITECTURES,
|
||||
} from "../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
binaryArchitectureSchema,
|
||||
rawBinaryEntryPointSchema,
|
||||
@@ -465,7 +468,10 @@ const lifecycleTargetFields = {
|
||||
const detectedLifecycleTargetSchema = z.object({
|
||||
...lifecycleTargetFields,
|
||||
format: targetFormatSchema.exclude(["raw-binary"]),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(),
|
||||
architecture: z
|
||||
.enum(BINARY_ARCHITECTURES)
|
||||
.exclude([...RAW_ONLY_ARCHITECTURES])
|
||||
.nullable(),
|
||||
});
|
||||
const rawLifecycleTargetSchema = z.object({
|
||||
...lifecycleTargetFields,
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
} from "./analysisSnapshot.fixture.js";
|
||||
import { createEvidence } from "./evidence.js";
|
||||
import { createEvidenceBundle } from "./evidenceBundle.js";
|
||||
import type { BinaryTarget } from "./binaryTargetTypes.js";
|
||||
import {
|
||||
RAW_ONLY_ARCHITECTURES,
|
||||
type BinaryTarget,
|
||||
} from "./binaryTargetTypes.js";
|
||||
|
||||
const snapshotWithHistoricalEvidence = () => {
|
||||
const target = snapshotTarget(ANALYSIS_SNAPSHOT_TARGET);
|
||||
@@ -71,26 +74,29 @@ const snapshotWithHistoricalEvidence = () => {
|
||||
};
|
||||
|
||||
describe("analysis snapshot target identity", () => {
|
||||
it("keeps 6502 confined to executable raw snapshots", () => {
|
||||
const target = {
|
||||
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
|
||||
kind: "executable",
|
||||
architecture: "6502",
|
||||
};
|
||||
expect(
|
||||
analysisSnapshotSchema.shape.target.safeParse({
|
||||
...target,
|
||||
format: "pe",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
analysisSnapshotSchema.shape.target.safeParse({
|
||||
...target,
|
||||
format: "raw-binary",
|
||||
entry_point: "0x8001",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
it.each(RAW_ONLY_ARCHITECTURES)(
|
||||
"keeps %s confined to executable raw snapshots",
|
||||
(architecture) => {
|
||||
const target = {
|
||||
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
|
||||
kind: "executable",
|
||||
architecture,
|
||||
};
|
||||
expect(
|
||||
analysisSnapshotSchema.shape.target.safeParse({
|
||||
...target,
|
||||
format: "pe",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
analysisSnapshotSchema.shape.target.safeParse({
|
||||
...target,
|
||||
format: "raw-binary",
|
||||
entry_point: "0x8000",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("partitions raw snapshots and query identities by the canonical entry", () => {
|
||||
const target: BinaryTarget = {
|
||||
@@ -142,45 +148,48 @@ describe("analysis snapshot target identity", () => {
|
||||
});
|
||||
|
||||
describe("analysis snapshot nested subject contract", () => {
|
||||
it("rejects raw-only 6502 in nested detected execution subjects", () => {
|
||||
const { snapshot } = snapshotWithHistoricalEvidence();
|
||||
const entry = snapshot.entries[0];
|
||||
if (entry === undefined) throw new Error("Fixture has no snapshot entry");
|
||||
const malformedSubject = {
|
||||
...entry.execution.subject,
|
||||
format: "pe",
|
||||
architecture: "6502",
|
||||
};
|
||||
expect(
|
||||
analysisSnapshotSchema.safeParse({
|
||||
...snapshot,
|
||||
entries: [
|
||||
{
|
||||
...entry,
|
||||
execution: {
|
||||
...entry.execution,
|
||||
subject: malformedSubject,
|
||||
it.each(RAW_ONLY_ARCHITECTURES)(
|
||||
"rejects raw-only %s in nested detected execution subjects",
|
||||
(architecture) => {
|
||||
const { snapshot } = snapshotWithHistoricalEvidence();
|
||||
const entry = snapshot.entries[0];
|
||||
if (entry === undefined) throw new Error("Fixture has no snapshot entry");
|
||||
const malformedSubject = {
|
||||
...entry.execution.subject,
|
||||
format: "pe",
|
||||
architecture,
|
||||
};
|
||||
expect(
|
||||
analysisSnapshotSchema.safeParse({
|
||||
...snapshot,
|
||||
entries: [
|
||||
{
|
||||
...entry,
|
||||
execution: {
|
||||
...entry.execution,
|
||||
subject: malformedSubject,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
analysisSnapshotSchema.safeParse({
|
||||
...snapshot,
|
||||
workflow_entries: [
|
||||
{
|
||||
...entry,
|
||||
execution: {
|
||||
...entry.execution,
|
||||
analysis_profile: snapshot.binding.analysis_profile,
|
||||
subject: malformedSubject,
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
analysisSnapshotSchema.safeParse({
|
||||
...snapshot,
|
||||
workflow_entries: [
|
||||
{
|
||||
...entry,
|
||||
execution: {
|
||||
...entry.execution,
|
||||
analysis_profile: snapshot.binding.analysis_profile,
|
||||
subject: malformedSubject,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("analysis snapshot contract", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./analysisProfile.js";
|
||||
import {
|
||||
BINARY_ARCHITECTURES,
|
||||
RAW_ONLY_ARCHITECTURES,
|
||||
type BinaryTarget,
|
||||
} from "./binaryTargetTypes.js";
|
||||
import {
|
||||
@@ -93,7 +94,9 @@ const subjectFormatSchema = z.enum([
|
||||
const subjectSchema = z.discriminatedUnion("format", [
|
||||
subjectBaseSchema.extend({
|
||||
format: subjectFormatSchema.exclude(["raw-binary"]),
|
||||
architecture: architectureSchema.exclude(["6502"]).nullable(),
|
||||
architecture: architectureSchema
|
||||
.exclude([...RAW_ONLY_ARCHITECTURES])
|
||||
.nullable(),
|
||||
}),
|
||||
subjectBaseSchema.extend({
|
||||
format: z.literal("raw-binary"),
|
||||
@@ -110,7 +113,9 @@ const targetSchema = z.discriminatedUnion("format", [
|
||||
format: z.enum(
|
||||
formatSchema.options.filter((format) => format !== "raw-binary"),
|
||||
),
|
||||
architecture: architectureSchema.exclude(["6502"]).nullable(),
|
||||
architecture: architectureSchema
|
||||
.exclude([...RAW_ONLY_ARCHITECTURES])
|
||||
.nullable(),
|
||||
}),
|
||||
targetIdentitySchema.extend({
|
||||
format: z.literal("raw-binary"),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ElfFamilyMetadata,
|
||||
HeaderArchitecture,
|
||||
MipsElfMetadata,
|
||||
RawOnlyArchitecture,
|
||||
PeCodeViewRecord,
|
||||
RiscvElfMetadata,
|
||||
PowerPcElfMetadata,
|
||||
@@ -545,7 +546,7 @@ const machArchitecture = (cpu: number): HeaderArchitecture | undefined => {
|
||||
|
||||
const elfArchitecture = (
|
||||
machine: number,
|
||||
): Exclude<BinaryArchitecture, "6502"> | undefined => {
|
||||
): Exclude<BinaryArchitecture, RawOnlyArchitecture> | undefined => {
|
||||
switch (machine) {
|
||||
case 3:
|
||||
return "x86";
|
||||
|
||||
@@ -10,9 +10,20 @@ export const BINARY_ARCHITECTURES = [
|
||||
"6502",
|
||||
"riscv",
|
||||
"powerpc",
|
||||
"m68k",
|
||||
] as const;
|
||||
export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number];
|
||||
|
||||
/**
|
||||
* Families admitted only by explicit raw-binary selection. Header detection
|
||||
* never yields them, so detected targets and Evidence subjects exclude them.
|
||||
*/
|
||||
export const RAW_ONLY_ARCHITECTURES = [
|
||||
"6502",
|
||||
"m68k",
|
||||
] as const satisfies readonly BinaryArchitecture[];
|
||||
export type RawOnlyArchitecture = (typeof RAW_ONLY_ARCHITECTURES)[number];
|
||||
|
||||
/**
|
||||
* ELF header facts for a family whose ISA and ABI are not established by the
|
||||
* family name alone. Consumers admit a lane from these declarations rather than
|
||||
@@ -51,7 +62,7 @@ export const isElfMetadataArchitecture = (
|
||||
/** Recognized family with no ELF metadata member; the header alone is the fact. */
|
||||
export type HeaderArchitecture = Exclude<
|
||||
BinaryArchitecture,
|
||||
ElfMetadataArchitecture | "6502"
|
||||
ElfMetadataArchitecture | RawOnlyArchitecture
|
||||
>;
|
||||
|
||||
/** Raw ELF facts; family recognition does not establish ISA or ABI support. */
|
||||
|
||||
+33
-27
@@ -2,7 +2,10 @@ import { fc, it } from "@fast-check/vitest";
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { createAnalysisProfile } from "./analysisProfile.js";
|
||||
import type { BinaryTarget } from "./binaryTargetTypes.js";
|
||||
import {
|
||||
RAW_ONLY_ARCHITECTURES,
|
||||
type BinaryTarget,
|
||||
} from "./binaryTargetTypes.js";
|
||||
import { createEvidence, evidenceSchema, parseEvidence } from "./evidence.js";
|
||||
import { MAX_JSON_DEPTH, type JsonValue } from "./jsonValue.js";
|
||||
import { createEvidenceBundle } from "./evidenceBundle.js";
|
||||
@@ -19,32 +22,35 @@ const TARGET: BinaryTarget = {
|
||||
const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" };
|
||||
const PROFILE = createAnalysisProfile(PROVIDER, { loader: "default" });
|
||||
|
||||
it("rejects a detected Evidence subject with the raw-only 6502 architecture", () => {
|
||||
const selected = {
|
||||
path: TARGET.path,
|
||||
sha256: TARGET.sha256,
|
||||
format: "raw-binary" as const,
|
||||
architecture: "6502" as const,
|
||||
};
|
||||
expect(
|
||||
createEvidence(selected, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: null,
|
||||
}).subject,
|
||||
).toMatchObject({ format: "raw-binary", architecture: "6502" });
|
||||
const malformed = {
|
||||
...selected,
|
||||
format: "pe" as const,
|
||||
};
|
||||
expect(() =>
|
||||
createEvidence(malformed, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: null,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
it.each(RAW_ONLY_ARCHITECTURES)(
|
||||
"rejects a detected Evidence subject with the raw-only %s architecture",
|
||||
(architecture) => {
|
||||
const selected = {
|
||||
path: TARGET.path,
|
||||
sha256: TARGET.sha256,
|
||||
format: "raw-binary" as const,
|
||||
architecture,
|
||||
};
|
||||
expect(
|
||||
createEvidence(selected, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: null,
|
||||
}).subject,
|
||||
).toMatchObject({ format: "raw-binary", architecture });
|
||||
const malformed = {
|
||||
...selected,
|
||||
format: "pe" as const,
|
||||
};
|
||||
expect(() =>
|
||||
createEvidence(malformed, PROVIDER, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: null,
|
||||
}),
|
||||
).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves omitted relationship diagnostics and authenticates reported diagnostics", () => {
|
||||
const observation = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "./analysisProfile.js";
|
||||
import {
|
||||
BINARY_ARCHITECTURES,
|
||||
RAW_ONLY_ARCHITECTURES,
|
||||
type BinaryArchitecture,
|
||||
type BinaryTarget,
|
||||
} from "./binaryTargetTypes.js";
|
||||
@@ -77,7 +78,10 @@ const subjectBaseSchema = z.strictObject({
|
||||
const subjectSchema = z.discriminatedUnion("format", [
|
||||
subjectBaseSchema.extend({
|
||||
format: subjectFormatSchema.exclude(["raw-binary"]),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(),
|
||||
architecture: z
|
||||
.enum(BINARY_ARCHITECTURES)
|
||||
.exclude([...RAW_ONLY_ARCHITECTURES])
|
||||
.nullable(),
|
||||
}),
|
||||
subjectBaseSchema.extend({
|
||||
format: z.literal("raw-binary"),
|
||||
|
||||
@@ -151,6 +151,7 @@ describe("raw-binary admission", () => {
|
||||
"missing architecture",
|
||||
{ architecture: undefined, entryPoint: "0x8000000" },
|
||||
],
|
||||
["mismatched family name", { architecture: "68000", entryPoint: "0x1000" }],
|
||||
])("classifies a %s as caller input", (_name, selection) => {
|
||||
const result = admitRawBinary({ ...selection, fileBytes: 4 });
|
||||
expect(result.ok).toBe(false);
|
||||
@@ -171,6 +172,59 @@ describe("raw-binary admission", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("m68k raw-binary admission", () => {
|
||||
it("admits even m68k entries up to the 32-bit address boundary", () => {
|
||||
for (const [entryPoint, fileBytes] of [
|
||||
["0x0", 8],
|
||||
["0x1000", 8],
|
||||
["0xfffffffe", 2],
|
||||
] as const) {
|
||||
const result = admitRawBinary({
|
||||
architecture: "m68k",
|
||||
entryPoint,
|
||||
fileBytes,
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error.reason);
|
||||
expect(result.value).toEqual({
|
||||
architecture: "m68k",
|
||||
entryPoint,
|
||||
entryPointSource: entryPoint,
|
||||
});
|
||||
}
|
||||
});
|
||||
it.each(["0x1", "0x1001", "0x1003", "0xffffffff"])(
|
||||
"rejects the odd m68k entry %s before provider selection",
|
||||
(entryPoint) => {
|
||||
expect(
|
||||
admitRawBinary({ architecture: "m68k", entryPoint, fileBytes: 2 }),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
reason: `raw-binary m68k entry_point ${entryPoint} must be 2-byte aligned`,
|
||||
constraint: "load_selection_rejected",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
["0x100000000", 2],
|
||||
["0xfffffffe", 4],
|
||||
])(
|
||||
"rejects an m68k mapping at %s with %i bytes that exceeds 32 bits",
|
||||
(entryPoint, fileBytes) => {
|
||||
expect(
|
||||
admitRawBinary({ architecture: "m68k", entryPoint, fileBytes }),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
reason: `raw-binary mapping from ${entryPoint} must fit in the m68k address width`,
|
||||
constraint: "load_selection_rejected",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("raw-binary selection pairing", () => {
|
||||
it("requires architecture and entry with format=raw-binary", () => {
|
||||
expect(rawBinarySelectionError({ format: "raw-binary" })).toBe(
|
||||
|
||||
+13
-3
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import type {
|
||||
BinaryArchitecture,
|
||||
HeaderArchitecture,
|
||||
RawOnlyArchitecture,
|
||||
} from "./binaryTargetTypes.js";
|
||||
import type { ExecutableFormatHint } from "./dosCom.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
@@ -14,6 +15,7 @@ export const BINARY_ARCHITECTURES = [
|
||||
"arm",
|
||||
"arm64",
|
||||
"6502",
|
||||
"m68k",
|
||||
] as const satisfies readonly BinaryArchitecture[];
|
||||
|
||||
/** Provider-neutral CPU family for a caller-supplied raw-binary load. */
|
||||
@@ -59,7 +61,7 @@ export const validateRawBinaryLength = (
|
||||
|
||||
/** Admitted headerless load identity used by target resolution and profiles. */
|
||||
export interface RawBinaryAdmission {
|
||||
readonly architecture: HeaderArchitecture | "6502";
|
||||
readonly architecture: HeaderArchitecture | RawOnlyArchitecture;
|
||||
readonly entryPoint: string;
|
||||
readonly entryPointSource: string;
|
||||
}
|
||||
@@ -77,7 +79,9 @@ export interface RawBinaryAdmissionRejection {
|
||||
const addressWidth = (architecture: BinaryArchitecture): bigint =>
|
||||
architecture === "6502"
|
||||
? 0xffffn
|
||||
: architecture === "x86" || architecture === "arm"
|
||||
: architecture === "x86" ||
|
||||
architecture === "arm" ||
|
||||
architecture === "m68k"
|
||||
? 0xffffffffn
|
||||
: 0xffffffffffffffffn;
|
||||
|
||||
@@ -91,7 +95,7 @@ export const admitRawBinary = (input: {
|
||||
if (!architecture.success)
|
||||
return err({
|
||||
reason:
|
||||
"raw-binary format requires architecture (x86, x86_64, arm, arm64, or 6502)",
|
||||
"raw-binary format requires architecture (x86, x86_64, arm, arm64, 6502, or m68k)",
|
||||
constraint: "load_selection_rejected",
|
||||
});
|
||||
if (typeof input.entryPoint !== "string")
|
||||
@@ -114,6 +118,12 @@ export const admitRawBinary = (input: {
|
||||
reason: `raw-binary ${architecture.data} entry_point ${entry.value.canonical} must be 4-byte aligned`,
|
||||
constraint: "load_selection_rejected",
|
||||
});
|
||||
// 68000-family instruction words are fetched from even addresses.
|
||||
if (architecture.data === "m68k" && entry.value.value % 2n !== 0n)
|
||||
return err({
|
||||
reason: `raw-binary m68k entry_point ${entry.value.canonical} must be 2-byte aligned`,
|
||||
constraint: "load_selection_rejected",
|
||||
});
|
||||
const length = validateRawBinaryLength(input.fileBytes);
|
||||
if (!length.ok)
|
||||
return err({
|
||||
|
||||
@@ -453,10 +453,10 @@ export const attestGhidraRawBinaryLoadImage = (
|
||||
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, initialized and loaded non-overlay file bytes in the default address space, the caller-supplied entry, and the bound little-endian Ghidra language. File offset 0 is mapped at the entry address.",
|
||||
"Verification covers complete original/modified source identity, initialized and loaded non-overlay file bytes in the default address space, the caller-supplied entry, and the bound Ghidra language with its fixed byte order. 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.",
|
||||
"Language-defined uninitialized memory, such as 6502 zero page and stack, is preserved as observations but is not attested as file-backed bytes.",
|
||||
"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.",
|
||||
"Reset vectors, memory-mapped I/O, alternate-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.",
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -338,6 +338,37 @@ describe("Ghidra raw-binary profile", () => {
|
||||
const windows = provider(installationHost("win32"));
|
||||
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
|
||||
});
|
||||
it("binds raw m68k to the big-endian 68000 language outside Windows P0", async () => {
|
||||
const target: BinaryTarget = {
|
||||
path: "/tmp/m68k.bin",
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: "raw-binary",
|
||||
architecture: "m68k",
|
||||
availableArchitectures: ["m68k"],
|
||||
entryPoint: "0x1000",
|
||||
entryPointSource: "0x00001000",
|
||||
};
|
||||
const ghidra = provider();
|
||||
expect(ghidra.inspectTargetSupport(target).status).toBe("supported");
|
||||
const resolved = await ghidra.resolveAnalysisProfile(target);
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
expect(resolved.value.profile?.parameters).toMatchObject({
|
||||
target_format: "raw-binary",
|
||||
architecture: "m68k",
|
||||
loader: "BinaryLoader",
|
||||
language_id: "68000:BE:32:default",
|
||||
compiler_spec_id: "default",
|
||||
language_selection: "caller-supplied-v1",
|
||||
entry_point: "0x1000",
|
||||
});
|
||||
expect(
|
||||
provider(installationHost("win32")).inspectTargetSupport(target),
|
||||
).toMatchObject({
|
||||
status: "unsupported",
|
||||
code: "target_format_unsupported",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra Mach-O slice support", () => {
|
||||
|
||||
@@ -73,6 +73,7 @@ const SUPPORTED_ARCHITECTURES = new Set([
|
||||
"6502",
|
||||
"riscv",
|
||||
"powerpc",
|
||||
"m68k",
|
||||
]);
|
||||
|
||||
/** Ghidra candidate backed by an isolated ephemeral headless import. */
|
||||
@@ -211,7 +212,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
status: "unsupported",
|
||||
code: "architecture_unsupported",
|
||||
reason:
|
||||
"Ghidra v1 requires a supported x86, x86_64, arm, arm64, MIPS, RISC-V, or raw 6502 target profile.",
|
||||
"Ghidra v1 requires a supported x86, x86_64, arm, arm64, MIPS, RISC-V, raw 6502, or raw m68k target profile.",
|
||||
diagnostics,
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -548,7 +548,7 @@ export const createGhidraProviderClient = (input: {
|
||||
...(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, arm64, and NMOS 6502. Big-endian, Thumb-only, Cortex-M, 65C02, 65816, and other processor variants are unsupported in this increment.",
|
||||
"Little-endian generic Ghidra languages are used for x86, x86_64, arm, arm64, and NMOS 6502; m68k uses the big-endian 68000 default language, which decodes the 68040 superset. Big-endian ARM, Thumb-only, Cortex-M, 65C02, 65816, separate 68020/68030/CPU32/ColdFire variants, and other processor variants are unsupported in this increment.",
|
||||
"Static analysis does not emulate reset vectors, memory-mapped I/O, or firmware boot sequences.",
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -8,10 +8,11 @@ export interface GhidraRawBinaryLanguage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map caller-visible CPU families to little-endian generic Ghidra
|
||||
* languages with a supported generic compiler specification. These are
|
||||
* analysis conventions, not evidence of the target's original compiler.
|
||||
* Big-endian, Thumb-only, and Cortex-M variants are out of scope.
|
||||
* Map caller-visible CPU families to generic Ghidra languages with a
|
||||
* supported generic compiler specification. These are analysis conventions,
|
||||
* not evidence of the target's original compiler. Each family has one fixed
|
||||
* byte order: m68k is big-endian and the others are little-endian. Big-endian
|
||||
* ARM, Thumb-only, Cortex-M, and other processor variants are out of scope.
|
||||
*/
|
||||
export const ghidraRawBinaryLanguage = (
|
||||
architecture: RawBinaryAdmission["architecture"],
|
||||
@@ -23,6 +24,14 @@ export const ghidraRawBinaryLanguage = (
|
||||
compilerSpecId: "default",
|
||||
defaultAddressSpace: "RAM",
|
||||
};
|
||||
case "m68k":
|
||||
// Ghidra's default 68000 language decodes the 68040 superset; no
|
||||
// MC68000-only variant is declared in the 68000 processor module.
|
||||
return {
|
||||
languageId: "68000:BE:32:default",
|
||||
compilerSpecId: "default",
|
||||
defaultAddressSpace: "ram",
|
||||
};
|
||||
case "x86":
|
||||
return {
|
||||
languageId: "x86:LE:32:default",
|
||||
|
||||
@@ -5,6 +5,13 @@ import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js
|
||||
import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
|
||||
|
||||
describe("raw-binary Ghidra language binding", () => {
|
||||
it("maps the big-endian m68k family to the default 68000 language", () => {
|
||||
expect(ghidraRawBinaryLanguage("m68k")).toEqual({
|
||||
languageId: "68000:BE:32:default",
|
||||
compilerSpecId: "default",
|
||||
defaultAddressSpace: "ram",
|
||||
});
|
||||
});
|
||||
it("maps the four admitted families to little-endian generic languages", () => {
|
||||
expect(ghidraRawBinaryLanguage("x86")).toEqual({
|
||||
languageId: "x86:LE:32:default",
|
||||
@@ -68,6 +75,45 @@ describe("raw-binary mapped-block state", () => {
|
||||
});
|
||||
},
|
||||
);
|
||||
it.each(["ram", "RAM"])(
|
||||
"requires the bound m68k address space for %s observations",
|
||||
(space) => {
|
||||
const f = fixtureRawBinaryLoadImage();
|
||||
const target = {
|
||||
...f.target,
|
||||
architecture: "m68k" as const,
|
||||
availableArchitectures: ["m68k" as const],
|
||||
entryPoint: "0x1000",
|
||||
entryPointSource: "0x1000",
|
||||
};
|
||||
f.observation.language_id = "68000:BE:32:default";
|
||||
f.observation.default_address_space = space;
|
||||
f.observation.entry_points = [target.entryPoint];
|
||||
const mapping = f.observation.mappings[0];
|
||||
if (mapping === undefined)
|
||||
throw new Error("Missing source mapping fixture");
|
||||
mapping.start = target.entryPoint;
|
||||
mapping.end = "0x1003";
|
||||
mapping.address_space = space;
|
||||
expect(
|
||||
attestGhidraRawBinaryLoadImage(f.snapshot, target, f.observation),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
status: space === "ram" ? "verified" : "mismatch",
|
||||
language_id: "68000:BE:32:default",
|
||||
checks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "default_address_space",
|
||||
expected: "ram",
|
||||
observed: space,
|
||||
matched: space === "ram",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
["initialized", false],
|
||||
["loaded", false],
|
||||
|
||||
@@ -68,12 +68,15 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"rejects an unaligned AArch64 entry before provider startup",
|
||||
async ({ cli }) => {
|
||||
cliTest.for([
|
||||
["arm64", "c0035fd6", "0x8000001", "must be 4-byte aligned"],
|
||||
["m68k", "4e75", "0x1001", "must be 2-byte aligned"],
|
||||
] as const)(
|
||||
"rejects an unaligned %s entry before provider startup",
|
||||
async ([architecture, bytes, entryPoint, reason], { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-raw-cli-alignment-");
|
||||
const path = join(root, "image.bin");
|
||||
await writeFile(path, Buffer.from("c0035fd6", "hex"));
|
||||
await writeFile(path, Buffer.from(bytes, "hex"));
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"inspect",
|
||||
@@ -81,9 +84,9 @@ cliTest(
|
||||
"--target-format",
|
||||
"raw-binary",
|
||||
"--architecture",
|
||||
"arm64",
|
||||
architecture,
|
||||
"--entry-point",
|
||||
"0x8000001",
|
||||
entryPoint,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
@@ -93,7 +96,7 @@ cliTest(
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { reason: expect.stringContaining("must be 4-byte aligned") },
|
||||
details: { reason: expect.stringContaining(reason) },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { GHIDRA_PROVIDER_IDENTITY } from "../../../src/ghidra/GhidraProviderCapabilities.js";
|
||||
import { RAW_ONLY_ARCHITECTURES } from "../../../src/domain/binaryTargetTypes.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
|
||||
const connect = async (
|
||||
@@ -85,38 +86,42 @@ const withGhidraFixtureIdentity = (
|
||||
};
|
||||
};
|
||||
|
||||
it("keeps 6502 out of detected lifecycle output contracts", () => {
|
||||
const schema = toolContract("open_binary").outputSchema;
|
||||
const result = {
|
||||
path: "/fixture/image.bin",
|
||||
kind: "executable",
|
||||
sha256: "a".repeat(64),
|
||||
architecture: "6502",
|
||||
};
|
||||
expect(
|
||||
schema.safeParse({ result: { ...result, format: "pe" } }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
schema.safeParse({
|
||||
result: { ...result, format: "raw-binary", entry_point: "0x8001" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
const validateAdvertised = new Ajv2020({ strict: false }).compile(
|
||||
z.toJSONSchema(schema),
|
||||
);
|
||||
expect(validateAdvertised({ result: { ...result, format: "pe" } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
validateAdvertised({
|
||||
result: { ...result, format: "raw-binary", entry_point: "0x8001" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
it.each(RAW_ONLY_ARCHITECTURES)(
|
||||
"keeps %s out of detected lifecycle output contracts",
|
||||
(architecture) => {
|
||||
const schema = toolContract("open_binary").outputSchema;
|
||||
const result = {
|
||||
path: "/fixture/image.bin",
|
||||
kind: "executable",
|
||||
sha256: "a".repeat(64),
|
||||
architecture,
|
||||
};
|
||||
expect(
|
||||
schema.safeParse({ result: { ...result, format: "pe" } }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
schema.safeParse({
|
||||
result: { ...result, format: "raw-binary", entry_point: "0x8000" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
const validateAdvertised = new Ajv2020({ strict: false }).compile(
|
||||
z.toJSONSchema(schema),
|
||||
);
|
||||
expect(validateAdvertised({ result: { ...result, format: "pe" } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
validateAdvertised({
|
||||
result: { ...result, format: "raw-binary", entry_point: "0x8000" },
|
||||
}),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["arm", "0x08000000", "0x8000000", "1eff2fe1"],
|
||||
["6502", "0x008001", "0x8001", "a92a60"],
|
||||
["m68k", "0x00001000", "0x1000", "4e714e7170014e75"],
|
||||
])(
|
||||
"opens a headerless %s image when architecture and entry are supplied",
|
||||
async (architecture, entryPoint, canonical, bytes) => {
|
||||
@@ -144,32 +149,38 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
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.each([
|
||||
["arm64", "c0035fd6", "0x8000001", "must be 4-byte aligned"],
|
||||
["m68k", "4e75", "0x1001", "must be 2-byte aligned"],
|
||||
])(
|
||||
"rejects an unaligned %s entry before selecting or starting a provider",
|
||||
async (architecture, bytes, entryPoint, reason) => {
|
||||
const root = await createTestTempDirectory("rea-raw-mcp-alignment-");
|
||||
const path = join(root, "image.bin");
|
||||
await writeFile(path, Buffer.from(bytes, "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,
|
||||
entry_point: entryPoint,
|
||||
},
|
||||
});
|
||||
expect(opened.isError).toBe(true);
|
||||
expect(JSON.stringify(opened.content)).toContain(reason);
|
||||
expect(session.activeTarget()).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects raw-binary open requests that omit architecture", async () => {
|
||||
const root = await createTestTempDirectory("rea-raw-mcp-missing-");
|
||||
|
||||
Reference in New Issue
Block a user