From d62d3082c27984a3983e08d363213e84dedfe929 Mon Sep 17 00:00:00 2001 From: zhugehyuk Date: Sat, 17 Oct 2026 00:57:34 +0900 Subject: [PATCH] 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) * 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) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../references/native-and-artifacts.md | 14 +- docs/raw-binary.md | 58 +++- docs/testing.md | 10 + package.json | 1 + scripts/verify-real-ghidra-m68k.mjs | 296 ++++++++++++++++++ src/contracts/toolOutputReferences.test.ts | 15 +- src/contracts/toolOutputSchemaGroups.ts | 10 +- src/domain/analysisSnapshot.test.ts | 125 ++++---- src/domain/analysisSnapshot.ts | 9 +- src/domain/binaryTarget.ts | 3 +- src/domain/binaryTargetTypes.ts | 13 +- src/domain/evidence.test.ts | 60 ++-- src/domain/evidence.ts | 6 +- src/domain/rawBinary.test.ts | 54 ++++ src/domain/rawBinary.ts | 16 +- src/ghidra/GhidraLoadImageValues.ts | 4 +- src/ghidra/GhidraProvider.behavior.test.ts | 31 ++ src/ghidra/GhidraProvider.ts | 3 +- src/ghidra/GhidraProviderClient.ts | 2 +- src/ghidra/GhidraRawBinaryLanguage.ts | 17 +- src/ghidra/GhidraRawBinaryLoadImage.test.ts | 46 +++ tests/boundary/cli/rawBinaryCli.test.ts | 17 +- tests/boundary/mcp/rawBinaryMcp.test.ts | 119 +++---- 23 files changed, 742 insertions(+), 187 deletions(-) create mode 100644 scripts/verify-real-ghidra-m68k.mjs diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 789b7a89ea..59cd056501 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -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 diff --git a/docs/raw-binary.md b/docs/raw-binary.md index 8d251d0102..0b91227a77 100644 --- a/docs/raw-binary.md +++ b/docs/raw-binary.md @@ -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 diff --git a/docs/testing.md b/docs/testing.md index e30b150ed9..0708237017 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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, diff --git a/package.json b/package.json index 998c768bd3..cd4a9ad48f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-real-ghidra-m68k.mjs b/scripts/verify-real-ghidra-m68k.mjs new file mode 100644 index 0000000000..537ebdf6cc --- /dev/null +++ b/scripts/verify-real-ghidra-m68k.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; +} diff --git a/src/contracts/toolOutputReferences.test.ts b/src/contracts/toolOutputReferences.test.ts index 1cd890bf3b..ba358fe027 100644 --- a/src/contracts/toolOutputReferences.test.ts +++ b/src/contracts/toolOutputReferences.test.ts @@ -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" }, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 6709f7445d..5d41fbce5b 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -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, diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index 6ea0a1b5f0..841585bf86 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -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", () => { diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index e0766ba154..a4cb5086c5 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -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"), diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 6be0f198f7..77787b3c5b 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -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 | undefined => { +): Exclude | undefined => { switch (machine) { case 3: return "x86"; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index e3377bec5e..1bcf67e96e 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -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. */ diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index 82ef1dc7b5..395fac43c7 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -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 = { diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 6205c9c135..b701945ed6 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -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"), diff --git a/src/domain/rawBinary.test.ts b/src/domain/rawBinary.test.ts index 5cb6ed1299..558e98e009 100644 --- a/src/domain/rawBinary.test.ts +++ b/src/domain/rawBinary.test.ts @@ -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( diff --git a/src/domain/rawBinary.ts b/src/domain/rawBinary.ts index 22d94f4d6c..c0983995b7 100644 --- a/src/domain/rawBinary.ts +++ b/src/domain/rawBinary.ts @@ -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({ diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index f06f4fdaa4..e5266c5df6 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -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.", ], }), diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index ab707d20f9..d6392109e9 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -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", () => { diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 5f1a8a418c..d07cd9df61 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -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 { diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 2f6ce6d13d..1c0ec2d29b 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -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.", ] : []), diff --git a/src/ghidra/GhidraRawBinaryLanguage.ts b/src/ghidra/GhidraRawBinaryLanguage.ts index e9046975d2..e29b747b22 100644 --- a/src/ghidra/GhidraRawBinaryLanguage.ts +++ b/src/ghidra/GhidraRawBinaryLanguage.ts @@ -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", diff --git a/src/ghidra/GhidraRawBinaryLoadImage.test.ts b/src/ghidra/GhidraRawBinaryLoadImage.test.ts index d09b3a5040..d2a4b597b3 100644 --- a/src/ghidra/GhidraRawBinaryLoadImage.test.ts +++ b/src/ghidra/GhidraRawBinaryLoadImage.test.ts @@ -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], diff --git a/tests/boundary/cli/rawBinaryCli.test.ts b/tests/boundary/cli/rawBinaryCli.test.ts index f12312d025..2e1b4b9dce 100644 --- a/tests/boundary/cli/rawBinaryCli.test.ts +++ b/tests/boundary/cli/rawBinaryCli.test.ts @@ -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) }, }); }, ); diff --git a/tests/boundary/mcp/rawBinaryMcp.test.ts b/tests/boundary/mcp/rawBinaryMcp.test.ts index 26a0e65010..d2c2326844 100644 --- a/tests/boundary/mcp/rawBinaryMcp.test.ts +++ b/tests/boundary/mcp/rawBinaryMcp.test.ts @@ -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-");