feat(ghidra): add DOS MZ analysis and complete function extents (#556)

Validate DOS MZ load modules and import them with Ghidra's 16-bit
real-mode profile. Preserve complete observed inclusive function body
ranges through inventory, function analysis, Evidence and snapshots.

Add a public source-generated DOS CLI/MCP verification lane and
regressions for malformed headers, body membership and cache profiles.

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
Assisted-by: Suika (gensokyo-skills:suika-scatter-gather-planning)
This commit is contained in:
N0zoM1z0 2026-10-05 20:09:45 +08:00 committed by GitHub
parent 53fe7d6d3b
commit 4f099a10f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 2206 additions and 92 deletions

3
.gitignore vendored
View File

@ -42,3 +42,6 @@ test-results.xml
# OS
.DS_Store
Thumbs.db
# Local reference sources and analysis artifacts
/_reference/

View File

@ -3,3 +3,6 @@ docs/api
node_modules
CHANGELOG.md
package-lock.json
# Local reference sources and analysis artifacts
/_reference/

View File

@ -202,6 +202,8 @@ The adapter exposes **22 read-only operations** for functions, strings, symbols,
REA analyzes a temporary copy of the target and removes the temporary project when the session closes. Results identify what Ghidra observed and what it could not resolve. Decompilation produces pseudocode rather than the original source.
Ghidra also imports DOS MZ executables with an explicit 16-bit x86 real-mode profile. Function results include complete observed body ranges, distinguishing owned bytes from the enclosing span. See the [DOS analysis guide](docs/ghidra-dos.md) for addresses, packing, and verification boundaries.
Windows operations remain unavailable pending the controls described in the [Windows Ghidra P0 guide](docs/windows-ghidra-p0.md). See [Ghidra installation](docs/installation.md#ghidra), [provider evaluation](docs/provider-evaluation.md), and [testing](docs/testing.md) for configuration details, coverage, and real-provider verification.
To remove only REA-owned MCP registrations and the managed skill:

View File

@ -51,6 +51,8 @@ import ghidra.app.decompiler.DecompiledFunction;
import ghidra.framework.Application;
import ghidra.program.model.address.Address;
import ghidra.program.model.address.AddressSpace;
import ghidra.program.model.address.AddressRange;
import ghidra.program.model.address.AddressRangeIterator;
import ghidra.program.model.block.BasicBlockModel;
import ghidra.program.model.block.CodeBlock;
import ghidra.program.model.block.CodeBlockIterator;
@ -440,7 +442,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("name", procedureName(function));
result.addProperty("entrypoint", canonicalAddress(function.getEntryPoint()));
result.addProperty("basicblock_count", basicBlocks(function).size());
result.addProperty("length", function.getBody().getNumAddresses());
JsonObject body = functionBody(function);
result.addProperty("length", body.get("total_bytes").getAsLong());
result.add("body", body);
result.addProperty("signature", function.getPrototypeString(false, true));
result.add("locals", functionLocals(function));
result.add("classification", functionClassification(function));
@ -774,6 +778,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
limitations.add(
"Synthetic Ghidra entry-point references without actionable memory sources are omitted."
);
if (decompilation != null && decompilation.getErrorMessage() != null &&
!decompilation.getErrorMessage().isBlank()) {
limitations.add("Ghidra decompiler diagnostic: " + decompilation.getErrorMessage());
}
result.add("limitations", limitations);
return result;
}
@ -2351,9 +2359,67 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("address", canonicalAddress(function.getEntryPoint()));
result.addProperty("name", procedureName(function));
result.add("classification", functionClassification(function));
result.add("body", functionBody(function));
return result;
}
/** Observe the entire AddressSet; range endpoints are inclusive, not a guessed span. */
private static JsonObject functionBody(Function function) {
JsonArray ranges = new JsonArray();
BigInteger total = BigInteger.ZERO;
Address first = null;
Address last = null;
AddressSpace space = null;
boolean multipleSpaces = false;
AddressRangeIterator iterator = function.getBody().getAddressRanges(true);
while (iterator.hasNext()) {
AddressRange range = iterator.next();
Address start = range.getMinAddress();
Address end = range.getMaxAddress();
if (start.getAddressSpace().getAddressableUnitSize() != 1) {
throw new IllegalStateException("Function body byte coverage requires a byte-addressed space");
}
JsonObject value = new JsonObject();
value.addProperty("start", canonicalAddress(start));
value.addProperty("end", canonicalAddress(end));
ranges.add(value);
BigInteger begin = new BigInteger(Long.toUnsignedString(start.getOffset()));
BigInteger finish = new BigInteger(Long.toUnsignedString(end.getOffset()));
total = total.add(finish.subtract(begin).add(BigInteger.ONE));
if (first == null) {
first = start;
space = start.getAddressSpace();
} else if (!space.equals(start.getAddressSpace())) {
multipleSpaces = true;
}
last = end;
}
JsonObject result = new JsonObject();
result.addProperty("available", true);
result.addProperty("provenance", "ghidra-function-body-address-set");
result.add("ranges", ranges);
result.addProperty("total_bytes", safeBodyByteCount(total));
if (multipleSpaces) {
result.add("span_bytes", JsonNull.INSTANCE);
} else {
BigInteger span = first == null ? BigInteger.ZERO :
new BigInteger(Long.toUnsignedString(last.getOffset()))
.subtract(new BigInteger(Long.toUnsignedString(first.getOffset())))
.add(BigInteger.ONE);
result.addProperty("span_bytes", safeBodyByteCount(span));
}
result.addProperty("non_contiguous", ranges.size() > 1);
result.addProperty("contains_entry", function.getBody().contains(function.getEntryPoint()));
return result;
}
private static long safeBodyByteCount(BigInteger value) {
if (value.signum() < 0 || value.compareTo(BigInteger.valueOf(9007199254740991L)) > 0) {
throw new IllegalStateException("Function body byte coverage exceeds exact JSON integer range");
}
return value.longValueExact();
}
private static String exclusiveEnd(MemoryBlock block) {
Address next = block.getEnd().next();
if (next != null && next.getAddressSpace().equals(block.getStart().getAddressSpace())) {

105
docs/ghidra-dos.md Normal file
View File

@ -0,0 +1,105 @@
# DOS MZ analysis with Ghidra
REA can analyze DOS MZ executables through the bring-your-own Ghidra adapter.
The Linux x64 verification lane exercises actual 16-bit disassembly and
decompilation through the CLI and stdio MCP. It requires the same Ghidra
12.1.4 and 64-bit JDK 21 installation as other Ghidra sessions; no DOS emulator
or cross-compiler is required.
## Open and inspect
```bash
rea inspect /absolute/path/to/legacy.exe --provider ghidra --json
rea search /absolute/path/to/legacy.exe entry --kind procedures --provider ghidra --json
rea instructions /absolute/path/to/legacy.exe entry --provider ghidra --json
rea function /absolute/path/to/legacy.exe entry --provider ghidra --json
```
Use an observed procedure name or address from discovery when the target does
not name its entry function `entry`. MCP uses `open_binary`, `list_procedures`,
`procedure_info`, `read_function_instructions`, and `analyze_function` with the
same admitted target and provider semantics.
The classifier validates MZ page counts, initialized module length, header and
relocation-table bounds, relocation destinations, and the initialized entry
point. It distinguishes DOS load-module and relocation bytes from the Windows
new-header field. PE remains PE; NE, LE, and LX declarations are explicitly
unsupported. A damaged declared Windows header does not silently become DOS.
Inventory classification returns `unknown` when its bounded prefix cannot
establish the format; this is distinct from the complete target admission check.
Ghidra imports a private target snapshot with `MzLoader`, language
`x86:LE:16:Real Mode`, and compiler specification `default`. These settings,
the fixed Ghidra load segment `0x1000`, and the linear address convention are
committed in the analysis profile. The handshake rejects language or compiler
drift. Closing the session deletes the temporary project and snapshot.
The profile also commits `function_body_evidence: complete-inclusive-ranges-v1`,
so snapshots from older length-only Ghidra results do not satisfy the current
profile.
## Addresses and function extent
Returned default-space addresses are linear byte coordinates. A Ghidra
segment:offset address has linear value `segment * 16 + offset`; multiple
segment aliases can identify the same byte. REA accepts the returned linear
address for subsequent lookups and leaves instruction decoding context with
Ghidra. These coordinates are analysis load addresses, not file offsets or
observations of a running DOS system.
`procedure_info` and function dossiers include `body` evidence:
```json
{
"available": true,
"provenance": "ghidra-function-body-address-set",
"ranges": [
{ "start": "0x10000", "end": "0x1000d" },
{ "start": "0x10060", "end": "0x10065" }
],
"total_bytes": 20,
"span_bytes": 102,
"non_contiguous": true,
"contains_entry": true
}
```
Each end is **inclusive**. `total_bytes` counts the complete observed
AddressSet; `span_bytes` measures its enclosing span and is null across address
spaces. Gaps and shared tails must not be treated as owned bytes. This is
Ghidra's database observation, not a proof of original source ownership or
exhaustive code discovery. Providers that do not report complete ranges return
`available: false` and an explicit reason.
## Coverage boundaries
Static decompilation does not execute the target or emulate BIOS, DOS
interrupts, device ports, or self-modifying code. PC-98 hardware behavior and
recovered calling conventions can remain uncertain; provider pseudocode and
diagnostics are retained for analyst review.
For a packed executable, importing the original can analyze its unpacking
stub. Analyze a separately prepared unpacked copy to inspect the original
program's recovered code. Retain both artifact digests and the transformation
evidence; REA never silently replaces the admitted target. Appended overlays
are separate from the initialized MZ module. Raw COM files and DOS extenders
without an admitted MZ real-mode entry are outside this import boundary.
DOS MZ is available through Ghidra, with no Hopper support claim. It is outside
the Windows native PE P0 boundary. macOS DOS verification remains unverified.
## Real verification
```bash
npm run verify:ghidra:dos
```
The lane generates a small public fixture from source at runtime. It verifies
16-bit instruction bytes, a near call, a segment-relocated far call, actual
decompilation, disjoint function ranges, stable CLI/MCP observations, unchanged
input bytes, and session/process cleanup. It does not require external
executable fixtures, an emulator, or an existing analysis database.
Provider p-code representations can contain process-specific address-space selector
tokens. The lane preserves those reported values and compares stable dossier
observations rather than asserting cross-process identity of raw p-code.

View File

@ -537,9 +537,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "b22484858ac42d79f19926ff3cbe46a409c817740a2a7098c9df9d542e805ee7",
"tools_sha256": "49674f8c1bc073eb86d862d76cd196576750667529c115e20ffb8cf24824746e",
"prompts_sha256": "fa889fc671185ac9c968c6dc8309cf609e709cdc837dbbf39b7cdfadb84ac2d7",
"combined_sha256": "2bdfe5e63d5e41f19af4b42e701c0d9f2022415fe9ea6584d2cbb7094c55a48a",
"combined_sha256": "88df47ddee7253da2eb91f94cd6c84b3e0c1ac96272a23715a6420bb6006990a",
"providers_sha256": "c5a88a49990fafc8244972ee8ff543fc19995eb79db960da55f6a311e15340d1"
}
}

View File

@ -116,6 +116,18 @@ the CLI and stdio MCP, including special filenames, unresolved discovery
locations, and independently resolved loaded scripts. Double-quote filenames
are tested on POSIX only because Windows does not support them.
## DOS Ghidra analysis
`npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation
on Linux x64 or macOS x64/arm64. It generates a source-owned MZ fixture without
a DOS emulator or compiler, then checks real 16-bit decoding, segment
relocation, near/far calls, decompilation, disjoint function body ranges,
stable CLI/MCP observations, unchanged source bytes, and owned process/project
cleanup. Raw p-code address-space selector tokens are reported separately from
the stable observation comparison. Linux x64 is verified; macOS DOS remains
unverified. This lane is separate from host-native and optional cross-format
verification. See [DOS analysis](ghidra-dos.md).
## Apple Interface Builder archives
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a

View File

@ -1,5 +1,5 @@
{
"manifest_id": "ecm_f54aca28e223fe07c31926bc79eaccc3664e1cc5459d65672df2e788b24ef9e8",
"manifest_id": "ecm_76203e23e7c067abbcc12c975ce71661056d9b09ac73fb10ecfc8373d9b10dc1",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "7efe0bbed43114110279e3b8f49d29e3894f3e025cc6c1e630a8fbc6900d5186"
"sha256": "5f5abf280da3fdf4d72cf450939f468d767a8f35dae333731f7650232ddc3ce5"
}
],
"claims": [

View File

@ -121,6 +121,7 @@
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
"verify:keyed-archive": "npm run build:cached && node scripts/verify-keyed-archive.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
"verify:interface-builder": "npm run build:cached && node scripts/verify-interface-builder.mjs",
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",

View File

@ -0,0 +1,502 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, 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 {
functionBodySchema,
functionDossierSchema,
} from "../dist/domain/hopperValues.js";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { buildDosMzFixture } from "../tests/conformance/ghidra/dos-mz-fixture.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
// This lane intentionally has no game paths, binary seeds or compiler dependency.
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-dos.mjs");
if (!["linux", "darwin"].includes(process.platform))
throw new Error(
"verify:ghidra:dos supports Linux/macOS hosts only; Windows P0 is PE x86-64 only.",
);
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error(
"verify:ghidra:dos prerequisite missing: GHIDRA_INSTALL_DIR (bring your own Ghidra).",
);
const installation = inspectGhidraInstallation({
installDir: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { javaHome: process.env.JAVA_HOME }),
});
if (installation.status !== "available")
throw new Error(
`verify:ghidra:dos prerequisite unavailable: ${JSON.stringify(installation)}`,
);
const captureDirectory = process.env.REA_DOS_PROOF_CAPTURE_DIR;
if (captureDirectory !== undefined) {
assert.ok(
isAbsolute(captureDirectory),
"REA_DOS_PROOF_CAPTURE_DIR must be absolute",
);
await mkdir(captureDirectory, { recursive: true, mode: 0o700 });
}
const run = createVerifierRun();
const workspace = await mkdtemp(join(tmpdir(), "rea-dos-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const fixture = buildDosMzFixture();
const targetPath = join(workspace, "fixture.exe");
await writeFile(targetPath, fixture.bytes, { mode: 0o600 });
const sha256 = digest(fixture.bytes);
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
const env = {
PATH: process.env.PATH ?? "/usr/bin:/bin",
TMPDIR: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR,
...(process.env.JAVA_HOME === undefined
? {}
: { JAVA_HOME: process.env.JAVA_HOME }),
};
const address = (offset) =>
`0x${(fixture.load_segment * 16 + offset).toString(16)}`;
const expected = {
entry: address(fixture.entry_offset),
near: address(fixture.near_offset),
far: address(fixture.far_offset),
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-dos-proof", version: "1" });
let opened = false;
let stderr = "";
let report;
try {
await client.connect(transport);
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
const target = await call("open_binary", {
path: targetPath,
provider: "ghidra",
});
opened = true;
assert.equal(target.format, "dos-mz");
assert.equal(target.kind, "executable");
assert.equal(target.architecture, "x86");
assert.equal(target.sha256, sha256);
const procedures = await call("list_procedures");
assert.ok(
Array.isArray(procedures),
"Public procedure inventory is not an array",
);
const identities = Object.fromEntries(
Object.entries(expected).map(([key, value]) => {
const item = procedures.find((candidate) => candidate.address === value);
assert.ok(
item,
`Source-owned ${key} procedure ${value} was not discovered from MZ entry/call flow`,
);
return [key, item];
}),
);
const session = await call("binary_session");
const profile = session.analysis_provider_binding?.analysis_profile;
assert.ok(profile, "Public session omitted committed profile");
assertProfile(profile);
assert.equal(session.sha256, sha256);
assert.equal(session.open, true);
const facts = {};
for (const [key, identity] of Object.entries(identities)) {
const procedure = identity.address; // Use returned addresses, never symbol-name heuristics.
assert.match(procedure, /^0x[0-9a-f]+$/u);
const resolved = await call("procedure_address", { procedure });
assert.equal(resolved, procedure, "Flat address round-trip drifted");
const info = await call("procedure_info", { procedure });
const dossierResult = await evidenceCall("analyze_function", { procedure });
const dossier = functionDossierSchema.parse(
dossierResult.normalized_result,
);
assertBody(info.body, procedure);
assertBody(dossier.procedure.body, procedure);
assert.equal(info.entrypoint, procedure);
assert.equal(info.length, info.body.total_bytes);
assert.deepEqual(dossier.procedure.body, info.body);
const containingQueries = [procedure, info.body.ranges.at(-1).end];
for (const query of containingQueries) {
const containing = await call("resolve_containing_procedure", {
address: query,
});
assert.equal(containing.found, true);
assert.equal(containing.query_address, query);
assert.equal(containing.procedure.address, procedure);
assert.deepEqual(containing.procedure.body, info.body);
}
if (key === "entry") {
const gap = await call("resolve_containing_procedure", {
address: address(0x10),
});
assert.equal(
gap.found,
false,
"Gap inside enclosing span was treated as function body",
);
assert.equal(gap.reason, "not_in_procedure");
}
assert.ok(
dossier.pseudocode.trim().length > 0,
"Real DOS decompilation returned empty pseudocode",
);
assert.equal(dossier.procedure.address, procedure);
const window = await call("read_function_instructions", { procedure });
assert.deepEqual(window.procedure.body, info.body);
assert.ok(window.instructions.length > 0);
if (key === "entry") {
assert.deepEqual(info.body.ranges, [
{ start: procedure, end: address(fixture.entry_first_bytes - 1) },
{
start: address(fixture.entry_tail_offset),
end: address(
fixture.entry_tail_offset + fixture.entry_tail_bytes - 1,
),
},
]);
assert.equal(
info.body.total_bytes,
fixture.entry_first_bytes + fixture.entry_tail_bytes,
);
assert.equal(
info.body.span_bytes,
fixture.entry_tail_offset + fixture.entry_tail_bytes,
);
assert.equal(info.body.non_contiguous, true);
} else {
const size =
key === "near" ? fixture.near_body_bytes : fixture.far_body_bytes;
assert.deepEqual(info.body.ranges, [
{
start: procedure,
end: address(
(key === "near" ? fixture.near_offset : fixture.far_offset) +
size -
1,
),
},
]);
assert.equal(info.body.total_bytes, size);
assert.equal(info.body.span_bytes, size);
}
const cli = await cliEvidence("function", procedure);
if (captureDirectory !== undefined) {
await writeFile(
join(captureDirectory, `public-fixture-${key}-cli.json`),
`${JSON.stringify(cli, null, 2)}\n`,
{ mode: 0o600 },
);
await writeFile(
join(captureDirectory, `public-fixture-${key}-mcp.json`),
`${JSON.stringify(dossierResult, null, 2)}\n`,
{ mode: 0o600 },
);
}
const cliDossier = functionDossierSchema.parse(cli.normalized_result);
assert.deepEqual(
stableDossierObservations(cliDossier),
stableDossierObservations(dossier),
"CLI/MCP stable DOS dossier observations differ",
);
facts[key] = {
address: procedure,
body: info.body,
instruction_count: window.instructions.length,
pseudocode_nonempty: true,
pseudocode: dossier.pseudocode,
cli_mcp_observations_equal: true,
containing_procedure_body_equal: true,
limitations: dossier.limitations,
};
}
const move = await call("inspect_native_instruction", {
address: identities.entry.address,
});
assert.equal(move.status, "decoded");
assert.equal(move.length, 3);
assert.equal(hexBytes(move.bytes), "b83412");
assert.match(move.mnemonic, /^MOV$/iu);
assert.ok(
move.operands
.flatMap((operand) => operand.components)
.some(
(part) =>
part.kind === "register" &&
/^AX$/iu.test(part.text) &&
part.bit_width === 16,
),
"DOS entry was not decoded as 16-bit AX",
);
const nearMemory = await call("inspect_native_instruction", {
address: identities.near.address,
});
assert.equal(nearMemory.status, "decoded");
assert.equal(nearMemory.length, 2);
assert.equal(hexBytes(nearMemory.bytes), "fe00");
assert.match(nearMemory.mnemonic, /^INC$/iu);
const nearCall = await call("inspect_native_instruction", {
address: address(fixture.near_call_offset),
});
const farCall = await call("inspect_native_instruction", {
address: address(fixture.far_call_offset),
});
assert.equal(hexBytes(nearCall.bytes), "e81a00");
// Source far segment 0004 is relocated by the independently fixed 1000 load segment.
assert.equal(hexBytes(farCall.bytes), "9a00000410");
for (const [key, instruction] of [
["near", nearCall],
["far", farCall],
]) {
assert.equal(instruction.flow.kind, "call");
assert.ok(
instruction.flow.direct_destinations.includes(identities[key].address),
);
const targets = await call("resolve_native_call_targets", {
address: instruction.address,
});
assert.equal(targets.status, "direct");
assert.ok(
targets.targets.some(
(target) => target.address === identities[key].address,
),
);
}
const invalid = await client.callTool({
name: "procedure_info",
arguments: { procedure: "0xffffffffffffffff" },
});
assert.equal(invalid.isError, true, "Unmapped address did not fail");
const cliWindow = await cliEvidence("instructions", identities.entry.address);
assert.deepEqual(
cliWindow.normalized_result,
await call("read_function_instructions", {
procedure: identities.entry.address,
}),
);
assert.equal(
digest(await readFile(targetPath)),
sha256,
"Analysis changed original bytes",
);
await call("close_binary");
opened = false;
assert.equal((await call("binary_session")).open, false);
report = {
ok: true,
mocked: false,
lane: "ghidra-dos",
host: process.platform,
provider: { id: "ghidra", version: installation.providerVersion },
target: {
format: "dos-mz",
architecture: "x86",
sha256,
bytes: fixture.bytes.length,
},
profile: {
digest: profile.digest,
loader: profile.parameters.loader,
language_id: profile.parameters.language_id,
compiler_spec_id: profile.parameters.compiler_spec_id,
load_segment: profile.parameters.load_segment,
},
transport: { cli: true, stdio_mcp: true },
functions: facts,
instruction_oracles: {
entry_bytes: hexBytes(move.bytes),
near_memory_bytes: hexBytes(nearMemory.bytes),
near_call_bytes: hexBytes(nearCall.bytes),
relocated_far_call_bytes: hexBytes(farCall.bytes),
near_target: expected.near,
far_target: expected.far,
},
invalid_address_rejected: true,
original_unchanged: true,
limitations: [
"Read-only import/decompilation evidence, not DOS runtime execution or whole-program semantic equivalence.",
"The complete body is Ghidra's observed AddressSet, not independently proven historical compiler ownership.",
"Language/compiler identity is bound by the production authenticated bridge handshake; profile loader is the declared import policy.",
"Native high-pcode flow is excluded from cross-runtime parity: LOAD/STORE address-space selector constants are process-specific in current Ghidra output. Both dossiers are still strictly parsed.",
"Windows P0 remains unsupported for DOS MZ.",
],
};
} catch (error) {
if (stderr.trim())
process.stderr.write(`DOS verifier MCP diagnostics:\n${stderr}\n`);
throw error;
} finally {
try {
if (opened) {
try {
await call("close_binary");
} catch (error) {
process.stderr.write(`DOS cleanup close failed: ${String(error)}\n`);
}
}
try {
await client.close();
} finally {
await transport.close();
}
assert.equal(
digest(await readFile(targetPath)),
sha256,
"Original changed during cleanup",
);
const leftovers = (await readdir(runtime)).filter((name) =>
name.startsWith("rea-ghidra-"),
);
assert.deepEqual(leftovers, [], "Owned Ghidra runtime survived close");
} finally {
await rm(workspace, { recursive: true, force: true });
}
}
assert.ok(report, "No DOS proof report was produced");
const verifierRun = await completeVerifierRun(run);
if (verifierRun.process_lineage.status === "verified")
assert.deepEqual(
verifierRun.process_lineage.descendants,
[],
"Owned descendants survived verifier cleanup",
);
process.stdout.write(
`${JSON.stringify({ verifier_run: verifierRun, ...report, cleanup: "complete" })}\n`,
);
function digest(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function hexBytes(bytes) {
assert.equal(typeof bytes, "string");
return bytes.replace(/\s/gu, "").toLowerCase();
}
function assertProfile(profile) {
assert.equal(
profile.parameters.function_body_evidence,
"complete-inclusive-ranges-v1",
);
assert.equal(profile.provider.id, "ghidra");
assert.equal(profile.parameters.target_format, "dos-mz");
assert.equal(profile.parameters.import_mode, "ephemeral-read-only");
assert.equal(profile.parameters.loader, "MzLoader");
assert.equal(profile.parameters.language_id, "x86:LE:16:Real Mode");
assert.equal(profile.parameters.compiler_spec_id, "default");
assert.equal(profile.parameters.load_segment, "0x1000");
}
function assertBody(input, entry) {
const body = functionBodySchema.parse(input);
assert.equal(body.available, true);
assert.equal(body.provenance, "ghidra-function-body-address-set");
assert.equal(body.contains_entry, true);
let total = 0n;
let previousEnd = null;
for (const range of body.ranges) {
assert.match(range.start, /^0x[0-9a-f]+$/u);
assert.match(range.end, /^0x[0-9a-f]+$/u);
const start = BigInt(range.start),
end = BigInt(range.end);
assert.ok(start <= end);
if (previousEnd !== null) assert.ok(start > previousEnd + 1n);
previousEnd = end;
total += end - start + 1n;
}
assert.ok(
body.ranges.some(
(range) =>
BigInt(range.start) <= BigInt(entry) &&
BigInt(entry) <= BigInt(range.end),
),
);
assert.equal(BigInt(body.total_bytes), total);
assert.equal(body.non_contiguous, body.ranges.length > 1);
assert.equal(
BigInt(body.span_bytes),
BigInt(body.ranges.at(-1).end) - BigInt(body.ranges[0].start) + 1n,
);
}
async function call(name, arguments_ = {}) {
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
assert.ok(
result.structuredContent && "result" in result.structuredContent,
`${name} omitted structured result`,
);
return result.structuredContent.result;
}
async function evidenceCall(name, arguments_) {
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
const evidence = parseEvidence({
...result.structuredContent?.evidence,
normalized_result: result.structuredContent?.result,
});
assert.equal(evidence.subject.digest.sha256, sha256);
assert.equal(evidence.provider.id, "rea-workflow");
assert.equal(evidence.analysis_profile.provider.id, "rea-workflow");
assertProfile(evidence.analysis_profile.parameters.upstream_analysis_profile);
return evidence;
}
async function cliEvidence(command, procedure) {
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
command,
targetPath,
procedure,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const evidence = parseEvidence(JSON.parse(stdout));
assert.equal(evidence.subject.digest.sha256, sha256);
assert.equal(evidence.provider.id, "ghidra");
assertProfile(evidence.analysis_profile);
assert.equal(digest(await readFile(targetPath)), sha256);
return evidence;
}
// Preserve all body/instruction/decompilation/reference observations; raw high-pcode
// address-space selector tokens are process-specific and are not a semantic oracle.
function stableDossierObservations(dossier) {
const { native_value_flow: flow, ...observations } = dossier;
assert.ok(flow, "Dossier omitted native value-flow availability");
return observations;
}

View File

@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
metadata:
version: "23"
tool_count: 122
catalog_digest: "2bdfe5e63d5e41f19af4b42e701c0d9f2022415fe9ea6584d2cbb7094c55a48a"
catalog_digest: "88df47ddee7253da2eb91f94cd6c84b3e0c1ac96272a23715a6420bb6006990a"
---
# REA

View File

@ -10,6 +10,7 @@ import type {
ArtifactOccurrence,
} from "../domain/artifactGraph.js";
import { zipPackageFormatForPath } from "../domain/zipPackageFormat.js";
import { mzWindowsHeaderOffset, parseDosMzHeader } from "../domain/dosMz.js";
/** Mutable internal occurrence used until root-bound IDs are known. */
export interface MutableOccurrence {
@ -131,7 +132,9 @@ export const createRootNode = (input: {
input.format,
)
? "container"
: classifyArtifactPath(input.path).kind,
: input.format === "dos-mz"
? "executable"
: classifyArtifactPath(input.path).kind,
format: input.format,
executable: false,
contentState: "materialized",
@ -283,6 +286,7 @@ export const classifyArtifactPath = (
export const classifyArtifactContent = (
path: string,
prefix: Buffer,
fileSize = prefix.length,
): Pick<ArtifactNode, "kind" | "format"> => {
const byPath = classifyArtifactPath(path);
if (prefix.length >= 4) {
@ -307,11 +311,32 @@ export const classifyArtifactContent = (
format: "elf",
};
}
if (prefix.length >= 2 && prefix[0] === 0x4d && prefix[1] === 0x5a)
return {
kind: byPath.kind === "native-addon" ? "native-addon" : "executable",
format: "pe",
};
if (prefix.length >= 2 && prefix[0] === 0x4d && prefix[1] === 0x5a) {
if (
!Number.isSafeInteger(fileSize) ||
fileSize < prefix.length ||
prefix.length < Math.min(fileSize, 64)
)
return { kind: "unknown", format: "unknown" };
const windowsOffset = mzWindowsHeaderOffset(prefix);
if (windowsOffset !== null) {
if (
windowsOffset >= 64 &&
windowsOffset <= prefix.length - 4 &&
prefix
.subarray(windowsOffset, windowsOffset + 4)
.equals(Buffer.from([0x50, 0x45, 0, 0]))
)
return {
kind: byPath.kind === "native-addon" ? "native-addon" : "executable",
format: "pe",
};
} else if (parseDosMzHeader(prefix, fileSize).ok) {
return { kind: "executable", format: "dos-mz" };
}
// Missing bounded evidence and malformed MZ bytes both remain unclassified.
return { kind: "unknown", format: "unknown" };
}
return byPath;
};

View File

@ -1,6 +1,7 @@
import { open } from "node:fs/promises";
import { classifyArtifactPath } from "../ArtifactGraphConstruction.js";
import { classifyArtifactContent } from "../ArtifactGraphConstruction.js";
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "./hash.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
import {
hasZipSignature,
@ -27,21 +28,13 @@ export const classifyRoot = async (
if (extensionFormat !== undefined) return extensionFormat;
const handle = await open(path, "r");
try {
const magic = Buffer.alloc(4);
const magic = Buffer.alloc(ARTIFACT_CLASSIFICATION_PREFIX_BYTES);
const observed = await handle.read(magic, 0, magic.length, 0);
if (hasZipSignature(magic.subarray(0, observed.bytesRead))) return "zip";
if (observed.bytesRead === 4) {
const header = magic.readUInt32BE(0);
if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(header))
return "mach-o-universal";
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(header))
return "mach-o";
if (magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return "elf";
}
if (observed.bytesRead >= 2 && magic[0] === 0x4d && magic[1] === 0x5a)
return "pe";
const prefix = magic.subarray(0, observed.bytesRead);
if (hasZipSignature(prefix)) return "zip";
return classifyArtifactContent(path, prefix, (await handle.stat()).size)
.format;
} finally {
await handle.close();
}
return classifyArtifactPath(path).format;
};

View File

@ -10,6 +10,9 @@ export type HashResult = {
readonly prefix: Buffer;
};
/** Bounded classification evidence; this is not an executable format size limit. */
export const ARTIFACT_CLASSIFICATION_PREFIX_BYTES = 8_192;
export const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
@ -31,8 +34,11 @@ export const hashReadable = async (
const chunk = streamChunkToBuffer(raw);
bytes += chunk.length;
hash.update(chunk);
if (prefixBytes < 16) {
const selected = chunk.subarray(0, 16 - prefixBytes);
if (prefixBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES) {
const selected = chunk.subarray(
0,
ARTIFACT_CLASSIFICATION_PREFIX_BYTES - prefixBytes,
);
prefixes.push(selected);
prefixBytes += selected.length;
}

View File

@ -211,7 +211,7 @@ const digestArtifactEntry = async (
const classified =
entry.kind === "slice"
? ({ kind: "universal-slice", format: "mach-o" } as const)
: classifyArtifactContent(logicalPath, digest.prefix);
: classifyArtifactContent(logicalPath, digest.prefix, digest.bytes);
return {
node: createArtifactNode({
sha256: digest.sha256,

View File

@ -16,6 +16,7 @@ import { isPathWithinRoot } from "../domain/localPath.js";
import { BinaryTargetError } from "../domain/errors.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
import { mzWindowsHeaderOffset } from "../domain/dosMz.js";
import {
hasZipSignature,
zipPackageFormatForPath,
@ -98,7 +99,7 @@ const detectArtifactFormat = async (
): Promise<
| Exclude<
BinaryTarget["format"],
"analysis-database" | "mach-o" | "elf" | "pe"
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz"
>
| undefined
> => {
@ -140,7 +141,7 @@ const namedArtifactFormat = (
const isArchiveFormat = (
format: Exclude<
BinaryTarget["format"],
"analysis-database" | "mach-o" | "elf" | "pe"
"analysis-database" | "mach-o" | "elf" | "pe" | "dos-mz"
>,
): format is Extract<BinaryTarget, { kind: "archive" }>["format"] =>
["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(format);
@ -247,9 +248,35 @@ const readExecutableMetadata = async (
const prefix = Buffer.alloc(4096);
const prefixRead = await handle.read(prefix, 0, prefix.length, 0);
const bytes = prefix.subarray(0, prefixRead.bytesRead);
if (bytes.length >= 64 && bytes[0] === 0x4d && bytes[1] === 0x5a) {
const offset = bytes.readUInt32LE(0x3c);
return readPeMetadata(handle, offset);
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) {
const offset = mzWindowsHeaderOffset(bytes);
if (offset !== null) {
if (offset < 64)
return err("invalid Windows new-header offset in MZ image");
return readPeMetadata(handle, offset);
}
// MZ relocation records may extend past the initial probe; their format
// bounds are at most 65535 records inside a 65535-paragraph header.
const tableEnd =
bytes.length >= 28
? bytes.readUInt16LE(24) + bytes.readUInt16LE(6) * 4
: 0;
const headerBytes = bytes.length >= 28 ? bytes.readUInt16LE(8) * 16 : 0;
const fileSize = (await handle.stat()).size;
if (
tableEnd > bytes.length &&
tableEnd <= headerBytes &&
tableEnd <= fileSize
) {
const table = Buffer.alloc(tableEnd);
const observed = await handle.read(table, 0, table.length, 0);
return parseExecutableHeader(
table.subarray(0, observed.bytesRead),
hostArchitecture,
fileSize,
);
}
return parseExecutableHeader(bytes, hostArchitecture, fileSize);
}
if (bytes.length >= 8) {
const magic = bytes.readUInt32BE(0);
@ -283,6 +310,9 @@ const readPeMetadata = async (
);
if (fileHeaderRead.bytesRead !== fileHeader.length)
return err("invalid or truncated PE header");
const signature = fileHeader.toString("ascii", 0, 2);
if (["NE", "LE", "LX"].includes(signature))
return err(`unsupported ${signature} executable in MZ image`);
const optionalHeaderSize = fileHeader.readUInt16LE(20);
if (optionalHeaderSize > 4096) return err("invalid PE optional header size");
const record = Buffer.alloc(24 + optionalHeaderSize);

View File

@ -36,7 +36,7 @@ const functionWorkflow = <Name extends FunctionWorkflowName>(
export const FUNCTION_WORKFLOW_TOOL_CONTRACTS = [
functionWorkflow(
"analyze_function",
"Build a dossier for one native function identified by symbol or provider-returned address. Returns identity, provider-specific pseudocode and assembly, comments, calls, references, referenced strings/names, local CFG blocks, and available native API boundary observations.",
"Build a dossier for one native function identified by symbol or provider-returned address. Returns identity with complete inclusive body ranges or explicit unknown, provider-specific pseudocode and assembly, comments, calls, references, referenced strings/names, local CFG blocks, and available native API boundary observations.",
),
functionWorkflow(
"inspect_native_api",

View File

@ -225,7 +225,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"procedure_info",
"Return provider metadata for one procedure identified by symbol or address: entrypoint, signature, locals, size, and block count.",
"Return provider metadata for one procedure identified by symbol or address: entrypoint, signature, locals, size, block count, and complete inclusive body ranges when observed. Body byte count and enclosing span remain distinct; unavailable extent is explicit.",
z.object({ procedure, document }),
),
official(

View File

@ -5,6 +5,7 @@ import {
procedureIdentitySchema,
localVariableSchema,
functionDossierSchema,
functionBodySchema,
} from "../domain/hopperValues.js";
import { analysisProfileSchema } from "../domain/analysisProfile.js";
import { evidenceEnvelopeSchema } from "../domain/evidence.js";
@ -49,6 +50,7 @@ export const targetFormatSchema = z.enum([
"mach-o",
"elf",
"pe",
"dos-mz",
"zip",
"ipa",
"apk",
@ -467,6 +469,10 @@ export const procedureInfoOutput = resultOf(
signature: nullableText,
locals: z.array(localVariable),
classification: procedureClassificationSchema.nullable().default(null),
body: functionBodySchema.default({
available: false,
reason: "The provider did not report complete function body ranges.",
}),
}),
);

View File

@ -16,6 +16,50 @@ import { createEvidence } from "./evidence.js";
import { createEvidenceBundle } from "./evidenceBundle.js";
describe("analysis snapshot contract", () => {
it("preserves DOS MZ target and subject formats with the x86 family", () => {
const target = {
path: ANALYSIS_SNAPSHOT_TARGET.path,
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
kind: "executable" as const,
format: "dos-mz" as const,
architecture: "x86" as const,
availableArchitectures: ["x86" as const],
};
const evidence = createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "analyze_function",
parameters: {},
result: null,
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
});
const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE);
const parsed = analysisSnapshotSchema.parse({
target: snapshotTarget(target),
binding,
entries: [
{
query_id: `query_${"0".repeat(64)}`,
operation: "analyze_function",
parameters: {},
execution: {
result: null,
raw_result: null,
provider: binding.provider,
limitations: [],
locations: [],
subject: target,
},
},
],
evidence_bundle: createEvidenceBundle([evidence]),
});
expect(parsed.target).toMatchObject({
format: "dos-mz",
architecture: "x86",
});
expect(parsed.entries[0]?.execution.subject?.format).toBe("dos-mz");
expect(parsed.evidence_bundle.records[0]?.subject?.format).toBe("dos-mz");
});
it("accepts snapshots with more than ten thousand analysis entries", () => {
const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE);
const entry = {

View File

@ -35,6 +35,7 @@ const formatSchema = z.enum([
"mach-o",
"elf",
"pe",
"dos-mz",
"zip",
"ipa",
"apk",

View File

@ -48,6 +48,7 @@ const artifactFormatSchema = z.enum([
"mach-o",
"elf",
"pe",
"dos-mz",
"hopper",
"analysis-database",
"ipa",

View File

@ -59,3 +59,19 @@ export const pe = (
}
return bytes;
};
/** Build a DOS MZ image with a 32-byte header and a 16-bit entry point. */
export const dosMz = (moduleSize = 128): Buffer => {
const size = 32 + moduleSize;
const bytes = Buffer.alloc(size);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt16LE(size % 512, 2);
bytes.writeUInt16LE(Math.ceil(size / 512), 4);
bytes.writeUInt16LE(2, 8);
bytes.writeUInt16LE(0xffff, 12);
bytes.writeUInt16LE(28, 24);
bytes.set([0xb8, 0x00, 0x4c, 0xcd, 0x21], 32);
// This is module data, not an e_lfanew field in a Windows DOS stub.
if (size >= 64) bytes.writeUInt32LE(0xfedcba98, 60);
return bytes;
};

View File

@ -1,9 +1,19 @@
import { describe, expect, it } from "vitest";
import { parseExecutableHeader } from "./binaryTarget.js";
import { elf, fat, pe, thinMach } from "./binaryTarget.fixture.js";
import { dosMz, elf, fat, pe, thinMach } from "./binaryTarget.fixture.js";
describe("executable header parsing", () => {
it("recognizes a DOS MZ without interpreting load-module bytes as e_lfanew", () => {
expect(parseExecutableHeader(dosMz(), "x64")).toEqual({
ok: true,
value: {
format: "dos-mz",
architecture: "x86",
availableArchitectures: ["x86"],
},
});
});
it.each([
[elf(1, 1, 62), "elf", "x86_64"],
[elf(2, 2, 183), "elf", "arm64"],

View File

@ -1,12 +1,13 @@
import type { BinaryArchitecture } from "./binaryTargetTypes.js";
import { err, ok, type Result } from "./result.js";
import { mzWindowsHeaderOffset, parseDosMzHeader } from "./dosMz.js";
export type { BinaryArchitecture, BinaryTarget } from "./binaryTargetTypes.js";
/** Format and architecture facts recovered from an executable header. */
export type ExecutableMetadata =
| {
readonly format: "mach-o" | "elf";
readonly format: "mach-o" | "elf" | "dos-mz";
readonly architecture: BinaryArchitecture;
readonly availableArchitectures: readonly BinaryArchitecture[];
}
@ -28,6 +29,7 @@ export type ExecutableMetadata =
export const parseExecutableHeader = (
bytes: Buffer,
hostArchitecture: NodeJS.Architecture,
fileSize = bytes.length,
): Result<ExecutableMetadata, string> => {
if (
bytes.length >= 4 &&
@ -35,7 +37,7 @@ export const parseExecutableHeader = (
)
return parseElf(bytes);
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a)
return parsePe(bytes);
return parseMz(bytes, fileSize);
if (bytes.length < 8) return err("truncated or unsupported binary header");
const magic = bytes.readUInt32BE(0);
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
@ -116,11 +118,27 @@ const parseElf = (bytes: Buffer): Result<ExecutableMetadata, string> => {
});
};
const parsePe = (bytes: Buffer): Result<ExecutableMetadata, string> => {
if (bytes.length < 64) return err("truncated PE DOS header");
const offset = bytes.readUInt32LE(0x3c);
if (offset > bytes.length - 24) return err("invalid or truncated PE header");
return parsePeRecord(bytes.subarray(offset));
const parseMz = (
bytes: Buffer,
fileSize: number,
): Result<ExecutableMetadata, string> => {
const offset = mzWindowsHeaderOffset(bytes);
if (offset !== null) {
if (offset < 64 || offset > bytes.length - 24)
return err("invalid or truncated Windows executable header in MZ image");
const signature = bytes.toString("ascii", offset, offset + 2);
if (["NE", "LE", "LX"].includes(signature))
return err(`unsupported ${signature} executable in MZ image`);
return parsePeRecord(bytes.subarray(offset));
}
const parsed = parseDosMzHeader(bytes, fileSize);
return parsed.ok
? ok({
format: "dos-mz",
architecture: "x86",
availableArchitectures: ["x86"],
})
: parsed;
};
/** Parse a bounded PE signature, COFF record, and optional header without I/O. */

View File

@ -26,7 +26,7 @@ type ExecutableTarget = BinaryTargetIdentity & {
*/
export type BinaryTarget =
| (ExecutableTarget & {
readonly format: "mach-o" | "elf";
readonly format: "mach-o" | "elf" | "dos-mz";
readonly executableRole?: never;
readonly managed?: never;
})

130
src/domain/dosMz.test.ts Normal file
View File

@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { dosMz, pe } from "./binaryTarget.fixture.js";
import { parseExecutableHeader } from "./binaryTarget.js";
import { mzWindowsHeaderOffset, parseDosMzHeader } from "./dosMz.js";
describe("DOS MZ load-module validation", () => {
it("separates initialized module and appended overlay bytes", () => {
const image = dosMz(480);
const bytes = Buffer.concat([image, Buffer.from("overlay")]);
expect(parseDosMzHeader(bytes)).toEqual({
ok: true,
value: {
headerBytes: 32,
imageBytes: 512,
moduleBytes: 480,
overlayBytes: 7,
relocationCount: 0,
relocationTableOffset: 28,
entrySegment: 0,
entryOffset: 0,
},
});
});
it("accepts relocation records overlapping the Windows new-header field", () => {
const bytes = Buffer.alloc(224);
dosMz().copy(bytes, 0, 0, 28);
bytes.writeUInt16LE(bytes.length, 2);
bytes.writeUInt16LE(6, 8);
bytes.writeUInt16LE(2, 6);
bytes.writeUInt16LE(62, 24);
bytes.writeUInt16LE(1, 62);
bytes.writeUInt16LE(8, 66);
expect(bytes.readUInt32LE(60)).toBe(65536);
expect(mzWindowsHeaderOffset(bytes)).toBeNull();
expect(parseExecutableHeader(bytes, "x64")).toMatchObject({
ok: true,
value: { format: "dos-mz" },
});
expect(parseDosMzHeader(bytes)).toMatchObject({
ok: true,
value: { headerBytes: 96, relocationCount: 2, moduleBytes: 128 },
});
});
it("uses the actual file length rather than the bounded prefix length", () => {
const bytes = dosMz(8192);
expect(parseDosMzHeader(bytes.subarray(0, 64), bytes.length).ok).toBe(true);
expect(parseDosMzHeader(bytes.subarray(0, 64))).toMatchObject({
ok: false,
error: "truncated DOS MZ load module",
});
});
it("preserves PE classification when low relocation records do not overlap e_lfanew", () => {
const bytes = pe(0x8664);
bytes.writeUInt16LE(4, 8);
bytes.writeUInt16LE(1, 6);
bytes.writeUInt16LE(28, 24);
expect(mzWindowsHeaderOffset(bytes)).toBe(64);
expect(parseExecutableHeader(bytes, "x64")).toMatchObject({
ok: true,
value: { format: "pe", architecture: "x86_64" },
});
expect(parseDosMzHeader(bytes).ok).toBe(false);
});
it.each([
["page count", (bytes: Buffer) => bytes.writeUInt16LE(0, 4)],
["final-page size", (bytes: Buffer) => bytes.writeUInt16LE(512, 2)],
["header size", (bytes: Buffer) => bytes.writeUInt16LE(1, 8)],
["entry point", (bytes: Buffer) => bytes.writeUInt16LE(128, 20)],
[
"relocation-table bounds",
(bytes: Buffer) => {
bytes.writeUInt16LE(2, 6);
},
],
[
"relocation target",
(bytes: Buffer) => {
bytes.writeUInt16LE(1, 6);
bytes.writeUInt16LE(127, 28);
},
],
] as const)("rejects invalid %s", (_name, damage) => {
const bytes = dosMz();
damage(bytes);
expect(parseDosMzHeader(bytes).ok).toBe(false);
});
it("rejects a truncated relocation table even when the file size is known", () => {
const bytes = dosMz();
bytes.writeUInt16LE(1, 6);
expect(parseDosMzHeader(bytes.subarray(0, 30), bytes.length)).toMatchObject(
{
ok: false,
error: "truncated DOS MZ relocation table",
},
);
});
it.each(["NE", "LE", "LX"])(
"distinguishes unsupported %s from DOS",
(signature) => {
const bytes = pe(0x8664);
bytes.writeUInt16LE(4, 8);
bytes.writeUInt16LE(64, 24);
bytes.write(signature, 64, "ascii");
expect(parseExecutableHeader(bytes, "x64")).toEqual({
ok: false,
error: `unsupported ${signature} executable in MZ image`,
});
},
);
it("does not fall back to DOS when a Windows declaration is damaged", () => {
const bytes = Buffer.alloc(256);
dosMz().copy(bytes, 0, 0, 28);
bytes.writeUInt16LE(256, 2);
bytes.writeUInt16LE(4, 8);
bytes.writeUInt16LE(64, 24);
bytes.writeUInt32LE(192, 60);
expect(parseDosMzHeader(bytes).ok).toBe(false);
expect(parseExecutableHeader(bytes, "x64")).toMatchObject({ ok: false });
bytes.writeUInt32LE(0xfffffff0, 60);
expect(parseExecutableHeader(bytes, "x64")).toMatchObject({ ok: false });
});
});

112
src/domain/dosMz.ts Normal file
View File

@ -0,0 +1,112 @@
import { err, ok, type Result } from "./result.js";
/** DOS load-module coordinates before applying the loader's segment relocation. */
export interface DosMzHeader {
readonly headerBytes: number;
readonly imageBytes: number;
readonly moduleBytes: number;
readonly overlayBytes: number;
readonly relocationCount: number;
readonly relocationTableOffset: number;
readonly entrySegment: number;
readonly entryOffset: number;
}
/**
* Locate a Windows new-header declaration without interpreting a short DOS
* header's load-module or relocation-table bytes at 0x3c as e_lfanew. A
* new-header pointer must lie in a sufficiently large header and outside
* actual relocation records. Zero-sized legacy stubs remain eligible for
* PE signature validation.
*/
export const mzWindowsHeaderOffset = (bytes: Buffer): number | null => {
if (bytes.length < 64) return null;
const headerBytes = bytes.readUInt16LE(8) * 16;
if (headerBytes !== 0 && headerBytes < 64) return null;
const relocationCount = bytes.readUInt16LE(6);
const tableStart = bytes.readUInt16LE(24);
if (
headerBytes !== 0 &&
relocationCount > 0 &&
tableStart < 64 &&
tableStart + relocationCount * 4 > 60
)
return null;
const offset = bytes.readUInt32LE(60);
return offset === 0 ? null : offset;
};
/**
* Validate a DOS MZ load module using a bounded header/table and the actual
* file length. Appended overlay bytes are retained separately; they are not
* silently included in the initialized load module.
*/
export const parseDosMzHeader = (
bytes: Buffer,
fileSize = bytes.length,
): Result<DosMzHeader, string> => {
if (bytes.length < 28 || bytes.toString("ascii", 0, 2) !== "MZ")
return err("invalid or truncated DOS MZ header");
if (!Number.isSafeInteger(fileSize) || fileSize < bytes.length)
return err("invalid DOS MZ file length");
if (mzWindowsHeaderOffset(bytes) !== null)
return err("MZ declares a Windows new header; it is not a DOS-only image");
const lastPageBytes = bytes.readUInt16LE(2);
const pages = bytes.readUInt16LE(4);
if (pages === 0 || lastPageBytes > 511)
return err("invalid DOS MZ page count or final-page size");
const imageBytes =
(pages - 1) * 512 + (lastPageBytes === 0 ? 512 : lastPageBytes);
const headerBytes = bytes.readUInt16LE(8) * 16;
if (headerBytes < 28 || headerBytes >= imageBytes)
return err("invalid DOS MZ header size or empty load module");
if (imageBytes > fileSize) return err("truncated DOS MZ load module");
const relocationCount = bytes.readUInt16LE(6);
const relocationTableOffset = bytes.readUInt16LE(24);
const tableEnd = relocationTableOffset + relocationCount * 4;
if (
relocationCount > 0 &&
(relocationTableOffset < 28 || tableEnd > headerBytes)
)
return err("DOS MZ relocation table lies outside its header");
if (relocationCount > 0 && tableEnd > bytes.length)
return err("truncated DOS MZ relocation table");
const moduleBytes = imageBytes - headerBytes;
const relocations = validateRelocations(
bytes,
relocationTableOffset,
relocationCount,
moduleBytes,
);
if (!relocations.ok) return relocations;
const entryOffset = bytes.readUInt16LE(20);
const entrySegment = bytes.readUInt16LE(22);
if (entrySegment * 16 + entryOffset >= moduleBytes)
return err("DOS MZ entry point lies outside its initialized load module");
return ok({
headerBytes,
imageBytes,
moduleBytes,
overlayBytes: fileSize - imageBytes,
relocationCount,
relocationTableOffset,
entrySegment,
entryOffset,
});
};
const validateRelocations = (
bytes: Buffer,
relocationTableOffset: number,
relocationCount: number,
moduleBytes: number,
): Result<void, string> => {
for (let index = 0; index < relocationCount; index += 1) {
const record = relocationTableOffset + index * 4;
const offset = bytes.readUInt16LE(record);
const segment = bytes.readUInt16LE(record + 2);
if (segment * 16 + offset + 2 > moduleBytes)
return err(`DOS MZ relocation ${index} points outside its load module`);
}
return ok(undefined);
};

View File

@ -190,3 +190,26 @@ it("derives byte-stable bundle manifests independent of record order", () => {
captures: [{ evidence_id: captureEvidence.evidence_id }],
});
});
describe("DOS analysis evidence identity", () => {
it("retains DOS MZ identity independently of PE identity", () => {
const target = {
path: TARGET.path,
sha256: TARGET.sha256,
kind: "executable" as const,
format: "dos-mz" as const,
architecture: "x86" as const,
availableArchitectures: ["x86" as const],
};
const observation = { operation: "health", parameters: {}, result: true };
const evidence = createEvidence(target, PROVIDER, observation);
expect(parseEvidence(evidence).subject).toMatchObject({
format: "dos-mz",
architecture: "x86",
});
expect(evidence.evidence_id).not.toBe(
createEvidence({ ...target, format: "pe" }, PROVIDER, observation)
.evidence_id,
);
});
});

View File

@ -27,6 +27,7 @@ const subjectSchema = z.object({
"mach-o",
"elf",
"pe",
"dos-mz",
"zip",
"ipa",
"apk",

View File

@ -5,10 +5,22 @@ export const ghidraFunctionClassification = () => ({
provenance: "ghidra-function-manager" as const,
});
/** Wire-boundary fixture, not evidence that a real Ghidra run was performed. */
export const ghidraFunctionBody = () => ({
available: true as const,
provenance: "ghidra-function-body-address-set",
ranges: [{ start: "0x401000", end: "0x401005" }],
total_bytes: 6,
span_bytes: 6,
non_contiguous: false,
contains_entry: true,
});
export const ghidraFunctionIdentity = () => ({
address: "0x401000",
name: "fixture_main",
classification: ghidraFunctionClassification(),
body: ghidraFunctionBody(),
});
const ghidraReferenceKind = () => ({

View File

@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
functionBodySchema,
procedureIdentitySchema,
parseDocuments,
parseListCount,
parseNames,
@ -80,3 +82,132 @@ describe("Hopper boundary values", () => {
},
);
});
describe("provider-neutral inclusive function body evidence", () => {
const observed = () => ({
available: true,
provenance: "test-address-set",
ranges: [
{ start: "0x1000", end: "0x1002" },
{ start: "0x1010", end: "0x1011" },
],
total_bytes: 5,
span_bytes: 18,
non_contiguous: true,
contains_entry: true,
});
it("represents missing Hopper extent explicitly as unknown", () => {
expect(
procedureIdentitySchema.parse({ address: "0x1000", name: "main" }).body,
).toEqual({
available: false,
reason: "The provider did not report complete function body ranges.",
});
});
it("counts inclusive one-byte ranges without assuming an enclosing continuous body", () => {
expect(
functionBodySchema.safeParse({
...observed(),
ranges: [{ start: "0x1000", end: "0x1000" }],
total_bytes: 1,
span_bytes: 1,
non_contiguous: false,
}).success,
).toBe(true);
expect(functionBodySchema.parse(observed())).toMatchObject({
total_bytes: 5,
span_bytes: 18,
});
});
it("preserves address-space identity and leaves a multi-space span unknown", () => {
expect(
functionBodySchema.safeParse({
...observed(),
ranges: [
{ start: "a:0x1000", end: "a:0x1000" },
{ start: "b:0x1000", end: "b:0x1001" },
],
total_bytes: 3,
span_bytes: null,
}).success,
).toBe(true);
expect(
functionBodySchema.safeParse({
...observed(),
ranges: [{ start: "a:0x1000", end: "b:0x1000" }],
}).success,
).toBe(false);
});
it("allows an observed empty set without manufacturing entry membership", () => {
expect(
procedureIdentitySchema.safeParse({
address: "0x1000",
name: "external",
body: {
...observed(),
ranges: [],
total_bytes: 0,
span_bytes: 0,
non_contiguous: false,
contains_entry: false,
},
}).success,
).toBe(true);
});
it("checks entry membership using address space and inclusive endpoints", () => {
expect(
procedureIdentitySchema.safeParse({
address: "0x1002",
name: "last",
body: observed(),
}).success,
).toBe(true);
expect(
procedureIdentitySchema.safeParse({
address: "0x1003",
name: "gap",
body: observed(),
}).success,
).toBe(false);
expect(
procedureIdentitySchema.safeParse({
address: "other:0x1000",
name: "other",
body: observed(),
}).success,
).toBe(false);
});
it.each([
{ total_bytes: 18 },
{ span_bytes: 5 },
{ non_contiguous: false },
{
ranges: [
{ start: "0x1000", end: "0x1002" },
{ start: "0x1003", end: "0x1004" },
],
},
{
ranges: [
{ start: "0x1000", end: "0x1002" },
{ start: "0x1002", end: "0x1004" },
],
},
{
ranges: [
{ start: "0x1010", end: "0x1011" },
{ start: "0x1000", end: "0x1002" },
],
},
{
ranges: [{ start: "0x0", end: "0x20000000000000" }],
total_bytes: Number.MAX_SAFE_INTEGER,
span_bytes: Number.MAX_SAFE_INTEGER,
non_contiguous: false,
},
])("rejects contradictory or unsafe range facts %j", (change) => {
expect(
functionBodySchema.safeParse({ ...observed(), ...change }).success,
).toBe(false);
});
});

View File

@ -60,6 +60,123 @@ const segmentSchema = z.object({
const unavailableAnalysisFactSchema = z
.object({ available: z.literal(false), reason: z.string() })
.strict();
/** Complete observed function-body ranges; every range endpoint is inclusive. */
export const functionBodySchema = z.discriminatedUnion("available", [
unavailableAnalysisFactSchema,
z
.strictObject({
available: z.literal(true),
provenance: z.string().min(1),
ranges: z.array(
z.strictObject({
start: z.string(),
end: z
.string()
.describe("Inclusive last address of this observed body range."),
}),
),
total_bytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
span_bytes: z
.number()
.int()
.min(0)
.max(Number.MAX_SAFE_INTEGER)
.nullable(),
non_contiguous: z.boolean(),
contains_entry: z.boolean(),
})
.superRefine((body, context) => {
let total = 0n;
const bounds = new Map<string, { start: bigint; end: bigint }>();
const closedSpaces = new Set<string>();
let previousSpace: string | null = null;
for (const range of body.ranges) {
const start = addressCoordinate(range.start);
const end = addressCoordinate(range.end);
if (
start === null ||
end === null ||
start.space !== end.space ||
end.offset < start.offset
) {
context.addIssue({
code: "custom",
message: "Function body range has invalid inclusive endpoints",
});
return;
}
if (previousSpace !== null && previousSpace !== start.space)
closedSpaces.add(previousSpace);
const previous = bounds.get(start.space);
if (
closedSpaces.has(start.space) ||
(previous !== undefined && start.offset <= previous.end + 1n)
) {
context.addIssue({
code: "custom",
message:
"Function body ranges must be ordered, disjoint and maximal within each address space",
});
return;
}
bounds.set(start.space, {
start: previous?.start ?? start.offset,
end: end.offset,
});
previousSpace = start.space;
total += end.offset - start.offset + 1n;
}
const onlyBounds =
bounds.size === 1 ? [...bounds.values()][0] : undefined;
const span =
bounds.size === 0
? 0n
: onlyBounds === undefined
? null
: onlyBounds.end - onlyBounds.start + 1n;
const reportedSpan =
body.span_bytes === null ? null : BigInt(body.span_bytes);
if (
total !== BigInt(body.total_bytes) ||
span !== reportedSpan ||
body.non_contiguous !== body.ranges.length > 1
)
context.addIssue({
code: "custom",
message:
"Function body counts, enclosing span or contiguity disagree with inclusive ranges",
});
}),
]);
const unknownFunctionBody = () =>
functionBodySchema.default({
available: false,
reason: "The provider did not report complete function body ranges.",
});
/** Check the declared entry membership against complete, inclusive body ranges. */
export const functionBodyEntryAgrees = (
body: z.infer<typeof functionBodySchema>,
address: string,
): boolean => {
if (!body.available) return true;
const entry = addressCoordinate(address);
if (entry === null) return false;
const contains = body.ranges.some((range) => {
const start = addressCoordinate(range.start);
const end = addressCoordinate(range.end);
return (
start !== null &&
end !== null &&
start.space === entry.space &&
entry.offset >= start.offset &&
entry.offset <= end.offset
);
});
return contains === body.contains_entry;
};
export const procedureClassificationSchema = z
.object({
external: z.boolean(),
@ -73,8 +190,17 @@ export const procedureIdentitySchema = z
address: z.string(),
name: z.string(),
classification: procedureClassificationSchema.nullable().default(null),
body: unknownFunctionBody(),
})
.strict();
.strict()
.superRefine((identity, context) => {
if (!functionBodyEntryAgrees(identity.body, identity.address))
context.addIssue({
code: "custom",
message:
"Function body entry membership disagrees with procedure identity",
});
});
export const localVariableSchema = z
.object({
description: z.string(),
@ -125,15 +251,10 @@ const referenceEdgeSchema = z
.strict();
export const functionDossierSchema = z
.object({
procedure: z
.object({
address: z.string(),
name: z.string(),
classification: procedureClassificationSchema.nullable().default(null),
signature: z.string().nullable(),
locals: z.array(localVariableSchema),
})
.strict(),
procedure: procedureIdentitySchema.safeExtend({
signature: z.string().nullable(),
locals: z.array(localVariableSchema),
}),
pseudocode: z.string(),
assembly: z.array(z.string()),
comments: z.array(

View File

@ -238,7 +238,7 @@ const familiesFor = (format: string, path: string): RuntimeFamily[] => {
if (format === "javascript-bundle") families.add("javascript");
if (format === "jvm-class" || /(?:^|\/)[^/]+\.class$/iu.test(path))
families.add("jvm");
if (["elf", "mach-o", "mach-o-universal", "pe"].includes(format))
if (["elf", "mach-o", "mach-o-universal", "pe", "dos-mz"].includes(format))
families.add("native");
if (format === "webassembly" || /(?:^|\/)[^/]+\.wasm$/iu.test(path))
families.add("webassembly");

File diff suppressed because one or more lines are too long

View File

@ -28,6 +28,7 @@ export const resolveGhidraAnalysisProfile = (
err(new ProviderAdapterError(identity.id, "resolve_analysis_profile")),
);
const provider = { ...identity, version: installation.providerVersion };
const dosMz = target.format === "dos-mz";
return Promise.resolve(
ok({
profile: createAnalysisProfile(provider, {
@ -38,14 +39,21 @@ export const resolveGhidraAnalysisProfile = (
...(target.availableArchitectures ?? []),
].sort(),
import_mode: "ephemeral-read-only",
loader: "auto-from-header",
language_id: "auto-from-header",
compiler_spec_id: "auto-default",
function_body_evidence: "complete-inclusive-ranges-v1",
loader: dosMz ? "MzLoader" : "auto-from-header",
language_id: dosMz ? "x86:LE:16:Real Mode" : "auto-from-header",
compiler_spec_id: dosMz ? "default" : "auto-default",
...(dosMz
? {
load_segment: "0x1000",
address_coordinates: "linear-byte-offset",
}
: {}),
analyzer_preset: "ghidra-default",
}),
compatibility: {
languageId: "auto",
compilerSpecId: "auto",
languageId: dosMz ? "x86:LE:16:Real Mode" : "auto",
compilerSpecId: dosMz ? "default" : "auto",
},
}),
);

View File

@ -347,6 +347,12 @@ export class GhidraClient {
providerVersion: this.#options.providerVersion,
profileDigest: this.#options.profileDigest,
targetSha256: this.#options.targetSha256,
...(this.#options.expectedLanguageId === undefined
? {}
: { expectedLanguageId: this.#options.expectedLanguageId }),
...(this.#options.expectedCompilerSpecId === undefined
? {}
: { expectedCompilerSpecId: this.#options.expectedCompilerSpecId }),
});
if (parsed.ok) return parsed;
return err(

View File

@ -39,6 +39,8 @@ export interface GhidraClientOptions {
readonly transport?: GhidraTransportKind;
readonly providerVersion: string;
readonly profileDigest: string;
readonly expectedLanguageId?: string;
readonly expectedCompilerSpecId?: string;
readonly startupTimeoutMs?: number;
readonly onDiagnostic?: (event: GhidraDiagnostic) => void;
readonly logger?: Logger;

View File

@ -9,6 +9,7 @@ import {
} from "./GhidraFunctionValues.js";
import {
ghidraFunctionClassification,
ghidraFunctionBody,
ghidraFunctionDossier,
ghidraFunctionIdentity,
ghidraNativeApiBoundary,
@ -96,6 +97,7 @@ describe("Ghidra function-analysis result values", () => {
},
],
classification: ghidraFunctionClassification(),
body: ghidraFunctionBody(),
}),
).toMatchObject({ ok: true });
expect(
@ -321,3 +323,109 @@ const malformedOutputs = (): Array<
],
];
};
// Inclusive ranges are a wire-boundary fixture, not a claim of real-provider acceptance.
describe("complete Ghidra function body range evidence", () => {
const body = () => ({
available: true,
provenance: "ghidra-function-body-address-set",
ranges: [
{ start: "0x401000", end: "0x401002" },
{ start: "0x401010", end: "0x401011" },
],
total_bytes: 5,
span_bytes: 18,
non_contiguous: true,
contains_entry: true,
});
const info = () => ({
name: "fixture_main",
entrypoint: "0x401000",
basicblock_count: 2,
length: 5,
signature: null,
locals: [],
classification: ghidraFunctionClassification(),
body: body(),
});
it("preserves inclusive disjoint ranges and distinguishes owned bytes from enclosing span", () => {
expect(parseGhidraFunctionResult("procedure_info", info())).toMatchObject({
ok: true,
value: { body: body(), length: 5 },
});
});
it("rejects omitted body evidence instead of accepting a length as complete coverage", () => {
const { body: omitted, ...withoutBody } = info();
expect(omitted.total_bytes).toBe(5);
expect(parseGhidraFunctionResult("procedure_info", withoutBody).ok).toBe(
false,
);
});
it("requires local entry coverage but permits an observed empty external body", () => {
const empty = {
...body(),
ranges: [],
total_bytes: 0,
span_bytes: 0,
non_contiguous: false,
contains_entry: false,
};
expect(
parseGhidraFunctionResult("procedure_info", {
...info(),
body: empty,
length: 0,
}).ok,
).toBe(false);
expect(
parseGhidraFunctionResult("procedure_info", {
...info(),
classification: { ...ghidraFunctionClassification(), external: true },
body: empty,
length: 0,
}).ok,
).toBe(true);
});
it("rejects a missing body on nested dossier identities", () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const { body: omitted, ...identity } = ghidraFunctionIdentity();
expect(omitted.total_bytes).toBe(6);
expect(
parseGhidraFunctionResult("analyze_function", {
...dossier,
callers: [identity],
}).ok,
).toBe(false);
});
it("requires body length and Ghidra-specific body provenance", () => {
expect(
parseGhidraFunctionResult("procedure_info", { ...info(), length: 18 }).ok,
).toBe(false);
expect(
parseGhidraFunctionResult("procedure_info", {
...info(),
body: { ...body(), provenance: "unreviewed" },
}).ok,
).toBe(false);
});
it.each([
{ total_bytes: 18 },
{ span_bytes: 5 },
{ non_contiguous: false },
{ contains_entry: false },
{
ranges: [
{ start: "0x401000", end: "0x401002" },
{ start: "0x401002", end: "0x401004" },
],
},
{ ranges: [{ start: "0x401002", end: "0x401000" }] },
])("rejects contradictory complete body facts %j", (change) => {
expect(
parseGhidraFunctionResult("procedure_info", {
...info(),
body: { ...body(), ...change },
}).ok,
).toBe(false);
});
});

View File

@ -7,6 +7,7 @@ import {
import { AnalysisInputError, AnalysisOutputError } from "../domain/errors.js";
import {
functionBodyEntryAgrees,
functionInstructionWindowSchema,
functionDossierSchema,
type FunctionDossier,
@ -18,6 +19,9 @@ import { err, ok, type Result } from "../domain/result.js";
import {
ghidraIdentifierSchema,
ghidraCanonicalAddressSchema,
ghidraFunctionClassificationSchema as classification,
ghidraFunctionBodySchema as ghidraFunctionBody,
ghidraProcedureIdentitySchema as procedureIdentity,
} from "./GhidraInventoryValues.js";
/** Read-only function operations admitted by the Ghidra adapter. */
@ -104,21 +108,6 @@ export const parseGhidraFunctionInput = (
: err(new AnalysisInputError(operation, { cause: parsed.error }));
};
const classification = z
.object({
external: z.boolean(),
thunk: z.boolean(),
thunk_target: ghidraCanonicalAddressSchema.nullable(),
provenance: z.literal("ghidra-function-manager"),
})
.strict();
const procedureIdentity = z
.object({
address: ghidraCanonicalAddressSchema,
name: z.string().min(1),
classification,
})
.strict();
const localVariable = z
.object({
description: z.string(),
@ -179,8 +168,21 @@ const procedureInfo = z
signature: z.string().nullable(),
locals: z.array(localVariable),
classification,
body: ghidraFunctionBody,
})
.strict();
.strict()
.superRefine((value, context) => {
if (
!functionBodyEntryAgrees(value.body, value.entrypoint) ||
(value.body.available &&
(value.length !== value.body.total_bytes ||
(!value.classification.external && !value.body.contains_entry)))
)
context.addIssue({
code: "custom",
message: "Ghidra function body disagrees with entry point or length",
});
});
const ghidraNativeApiBoundary = nativeApiBoundarySchema.superRefine(
(value, context) => {
@ -263,6 +265,10 @@ const ghidraNativeValueFlow = nativeValueFlowSchema.superRefine(
const ghidraFunctionDossier = functionDossierSchema
.extend({
procedure: functionDossierSchema.shape.procedure.safeExtend({
classification,
body: ghidraFunctionBody,
}),
native_api: ghidraNativeApiBoundary,
native_value_flow: ghidraNativeValueFlow,
})
@ -307,7 +313,13 @@ const ghidraFunctionDossier = functionDossierSchema
];
if (
identities.some(
({ classification: facts }) => !classification.safeParse(facts).success,
(identity) =>
!classification.safeParse(identity.classification).success ||
!ghidraFunctionBody.safeParse(identity.body).success ||
!functionBodyEntryAgrees(identity.body, identity.address) ||
(identity.body.available &&
identity.classification?.external === false &&
!identity.body.contains_entry),
)
)
context.addIssue({

View File

@ -64,3 +64,122 @@ describe("Ghidra inventory boundary values", () => {
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
});
// Wire-boundary fixture only; real-provider acceptance belongs to the DOS verifier.
describe("containing-procedure complete body evidence", () => {
const identity = () => ({
address: "0x1000",
name: "fixture_function",
classification: {
external: false,
thunk: false,
thunk_target: null,
provenance: "ghidra-function-manager",
},
body: {
available: true,
provenance: "ghidra-function-body-address-set",
ranges: [
{ start: "0x1000", end: "0x1002" },
{ start: "0x1020", end: "0x1021" },
],
total_bytes: 5,
span_bytes: 34,
non_contiguous: true,
contains_entry: true,
},
});
const found = () => ({
query_address: "0x1001",
found: true,
procedure: identity(),
});
it("accepts the Java producer identity including complete inclusive body ranges", () => {
const value = found();
expect(
parseGhidraInventoryResult("resolve_containing_procedure", value),
).toEqual({ ok: true, value });
expect(
parseGhidraInventoryResult("resolve_containing_procedure", {
...value,
query_address: "0x1021",
}).ok,
).toBe(true);
});
it("rejects a missing body rather than treating a legacy identity as complete", () => {
const { body: omitted, ...legacy } = identity();
expect(omitted.total_bytes).toBe(5);
expect(
parseGhidraInventoryResult("resolve_containing_procedure", {
...found(),
procedure: legacy,
}).ok,
).toBe(false);
});
it.each([
{ total_bytes: 34 },
{ span_bytes: 5 },
{ contains_entry: false },
{ provenance: "unreviewed" },
{
ranges: [
{ start: "0x1000", end: "0x1002" },
{ start: "bad space:0x1020", end: "bad space:0x1021" },
],
span_bytes: null,
},
{ non_contiguous: false },
{ ranges: [{ start: "0X1000", end: "0x1002" }] },
{
ranges: [
{ start: "0x1000", end: "0x1002" },
{ start: "0x1002", end: "0x1004" },
],
},
])("rejects malformed or contradictory body evidence %j", (change) => {
const procedure = identity();
expect(
parseGhidraInventoryResult("resolve_containing_procedure", {
...found(),
procedure: { ...procedure, body: { ...procedure.body, ...change } },
}).ok,
).toBe(false);
});
it("accepts canonically encoded address-space endpoints with an unknown enclosing span", () => {
const procedure = identity();
const body = {
...procedure.body,
ranges: [
{ start: "0x1000", end: "0x1002" },
{ start: "other%20space:0x1020", end: "other%20space:0x1021" },
],
span_bytes: null,
};
expect(
parseGhidraInventoryResult("resolve_containing_procedure", {
...found(),
procedure: { ...procedure, body },
}).ok,
).toBe(true);
});
it("does not accept a found query that lies in the gap between body ranges", () => {
expect(
parseGhidraInventoryResult("resolve_containing_procedure", {
...found(),
query_address: "0x1010",
}).ok,
).toBe(false);
});
it("retains explicit not-found outcomes without fabricating a body", () => {
const value = {
query_address: "0x1010",
found: false,
procedure: null,
reason: "not_in_procedure",
};
expect(
parseGhidraInventoryResult("resolve_containing_procedure", value),
).toEqual({ ok: true, value });
});
});

View File

@ -1,6 +1,10 @@
import { z } from "zod";
import { AnalysisInputError, AnalysisOutputError } from "../domain/errors.js";
import {
functionBodySchema,
functionBodyEntryAgrees,
} from "../domain/hopperValues.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
@ -87,9 +91,52 @@ const procedureFacts = z
thunk_target: canonicalAddress.nullable(),
})
.strict();
const functionClassification = procedureFacts
/** Ghidra FunctionManager classification, independent of body ownership inference. */
export const ghidraFunctionClassificationSchema = procedureFacts
.extend({ provenance: z.literal("ghidra-function-manager") })
.strict();
/** Require complete canonical AddressSet evidence on every Ghidra function identity. */
export const ghidraFunctionBodySchema = functionBodySchema.superRefine(
(body, context) => {
if (
!body.available ||
body.provenance !== "ghidra-function-body-address-set" ||
body.ranges.some(
({ start, end }) =>
!ghidraCanonicalAddressSchema.safeParse(start).success ||
!ghidraCanonicalAddressSchema.safeParse(end).success,
)
)
context.addIssue({
code: "custom",
message:
"Ghidra omitted complete canonical function-body AddressSet evidence",
});
},
);
/** Strict provider identity shared by inventory and function-analysis results. */
export const ghidraProcedureIdentitySchema = z
.object({
address: ghidraCanonicalAddressSchema,
name: z.string().min(1),
classification: ghidraFunctionClassificationSchema,
body: ghidraFunctionBodySchema,
})
.strict()
.superRefine((value, context) => {
if (
!functionBodyEntryAgrees(value.body, value.address) ||
(value.body.available &&
!value.classification.external &&
!value.body.contains_entry)
)
context.addIssue({
code: "custom",
message: "Ghidra function body disagrees with its entry point",
});
});
const stringFacts = z
.object({
encoding: z.string().min(1),
@ -138,15 +185,24 @@ const containingProcedure = z.discriminatedUnion("found", [
.object({
query_address: canonicalAddress,
found: z.literal(true),
procedure: z
.object({
address: canonicalAddress,
name: z.string().min(1),
classification: functionClassification,
})
.strict(),
procedure: ghidraProcedureIdentitySchema,
})
.strict(),
.strict()
.superRefine((value, context) => {
const body = value.procedure.body;
if (
body.available &&
!functionBodyEntryAgrees(
{ ...body, contains_entry: true },
value.query_address,
)
)
context.addIssue({
code: "custom",
message:
"Found containing procedure body does not contain the query address",
});
}),
z
.object({
query_address: canonicalAddress,

View File

@ -61,6 +61,8 @@ export interface GhidraHeadlessLauncherOptions {
readonly bridgeScriptPath: string;
readonly platform?: NodeJS.Platform;
readonly comSpec?: string;
/** Select the admitted 16-bit real-mode MZ import instead of auto-detection. */
readonly dosMz?: true;
}
/** Launch Ghidra without copying scripts into or modifying its installation. */
@ -109,6 +111,9 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
descriptorPath: paths.descriptorPath,
ghidraLogPath: paths.ghidraLogPath,
scriptLogPath: paths.scriptLogPath,
...(this.options.dosMz === undefined
? {}
: { dosMz: this.options.dosMz }),
});
const command = ghidraHeadlessCommand({
platform,
@ -255,6 +260,7 @@ export interface GhidraHeadlessArgumentOptions {
readonly descriptorPath: string;
readonly ghidraLogPath: string;
readonly scriptLogPath: string;
readonly dosMz?: true;
}
/** Build the complete read-only headless invocation in deterministic order. */
@ -265,6 +271,16 @@ export const ghidraHeadlessArguments = (
"rea-project",
"-import",
options.targetPath,
...(options.dosMz === true
? [
"-loader",
"MzLoader",
"-processor",
"x86:LE:16:Real Mode",
"-cspec",
"default",
]
: []),
"-readOnly",
"-deleteProject",
"-log",

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { parseConfig } from "../config.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import {
GHIDRA_PROVIDER_IDENTITY,
GHIDRA_PROVIDER_TOOL_CONTRACTS,
@ -39,6 +40,55 @@ const provider = (
};
describe("Ghidra provider", () => {
it("separates complete body evidence from legacy length-only cache profiles", async () => {
const resolved = await provider().resolveAnalysisProfile(
executableTarget("elf", "x86_64"),
);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("expected a committed Ghidra profile");
const profile = resolved.value.profile;
const legacy = createAnalysisProfile(
profile.provider,
Object.fromEntries(
Object.entries(profile.parameters).filter(
([key]) => key !== "function_body_evidence",
),
),
);
expect(profile.digest).not.toBe(legacy.digest);
expect(profile.parameters.function_body_evidence).toBe(
"complete-inclusive-ranges-v1",
);
});
it("commits DOS loader and real-mode language while retaining the CPU family", async () => {
const ghidra = provider();
const target: BinaryTarget = {
path: "/tmp/legacy.exe",
sha256: "a".repeat(64),
kind: "executable",
format: "dos-mz",
architecture: "x86",
availableArchitectures: ["x86"],
};
expect(ghidra.inspectTargetSupport(target).status).toBe("supported");
const resolved = await ghidra.resolveAnalysisProfile(target);
expect(resolved.ok && resolved.value).toMatchObject({
profile: {
parameters: {
target_format: "dos-mz",
architecture: "x86",
loader: "MzLoader",
language_id: "x86:LE:16:Real Mode",
compiler_spec_id: "default",
load_segment: "0x1000",
address_coordinates: "linear-byte-offset",
},
},
});
const windows = provider({ ...installationHost(), platform: "win32" });
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
});
it("discovers the exact installation once without launching Ghidra", () => {
let probeCount = 0;
const host: GhidraInstallationHost = {

View File

@ -189,6 +189,13 @@ export class GhidraProvider implements AnalysisProviderCandidate {
);
if (!prerequisites.ok) return unavailableClient(prerequisites.error);
const committedProfile = prerequisites.value.profile;
const targetLimitations =
target.format === "dos-mz"
? [
"DOS MZ uses 16-bit x86 real mode with the Ghidra load segment 0x1000. Returned addresses are linear byte coordinates; they do not identify a unique segment:offset alias.",
"Static DOS analysis does not emulate BIOS, DOS interrupts, device ports, or self-modifying unpacking code. Packed targets require a separately identified unpacked artifact for original-program analysis; appended overlays are not the initialized load module.",
]
: [];
const providerLimitations =
installation.platform === "win32"
? windowsP0Limitations
@ -206,6 +213,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
bridgeScriptPath: fileURLToPath(
new URL("../../bridge/ghidra/ReaGhidraBridge.java", import.meta.url),
),
...(target.format === "dos-mz" ? { dosMz: true } : {}),
platform: installation.platform,
}),
targetPath: target.path,
@ -216,6 +224,12 @@ export class GhidraProvider implements AnalysisProviderCandidate {
: "unix-socket",
providerVersion: prerequisites.value.providerVersion,
profileDigest: committedProfile.digest,
...(target.format === "dos-mz"
? {
expectedLanguageId: "x86:LE:16:Real Mode",
expectedCompilerSpecId: "default",
}
: {}),
...(context === undefined ? {} : { runId: context.runId }),
logger: this.logger.child({ layer: "ghidra-bridge" }),
});
@ -240,7 +254,11 @@ export class GhidraProvider implements AnalysisProviderCandidate {
return ok(
createAnalysisExecution(started.value, committedProfile.provider, {
analysisProfile: committedProfile,
limitations: [...healthLimitations, ...providerLimitations],
limitations: [
...healthLimitations,
...providerLimitations,
...targetLimitations,
],
}),
);
}
@ -263,7 +281,11 @@ export class GhidraProvider implements AnalysisProviderCandidate {
createAnalysisExecution(result.value, committedProfile.provider, {
rawResult: called.value,
analysisProfile: committedProfile,
limitations: [...limitationsFor(operation), ...providerLimitations],
limitations: [
...limitationsFor(operation),
...providerLimitations,
...targetLimitations,
],
}),
);
},

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
GHIDRA_SESSION_CAPABILITIES,
parseGhidraSessionInfo,
} from "./GhidraSessionValues.js";
const expected = {
runId: "d6fcbb66-e829-4ff6-a535-0035aec63139",
providerVersion: "12.1.4",
profileDigest: "a".repeat(64),
targetSha256: "b".repeat(64),
expectedLanguageId: "x86:LE:16:Real Mode",
expectedCompilerSpecId: "default",
};
const session = (
language = expected.expectedLanguageId,
compiler = "default",
) => ({
name: "REA Ghidra bridge",
run_id: expected.runId,
profile_digest: expected.profileDigest,
provider: { id: "ghidra", version: expected.providerVersion },
read_only: true,
analysis_complete: true,
analysis_timed_out: false,
capabilities: [...GHIDRA_SESSION_CAPABILITIES],
target: {
name: "legacy.exe",
language_id: language,
compiler_spec_id: compiler,
image_base: "0x0",
default_address_space: "ram",
sha256: expected.targetSha256,
},
});
describe("Ghidra DOS import commitment", () => {
it("accepts the exact admitted real-mode language and compiler", () => {
expect(parseGhidraSessionInfo(session(), expected).ok).toBe(true);
});
it("rejects a 32-bit interpretation even when artifact and profile hashes match", () => {
expect(
parseGhidraSessionInfo(session("x86:LE:32:default"), expected).ok,
).toBe(false);
});
it("rejects compiler drift independently of the language", () => {
expect(
parseGhidraSessionInfo(session(undefined, "windows"), expected).ok,
).toBe(false);
});
});

View File

@ -56,6 +56,8 @@ export const parseGhidraSessionInfo = (
readonly providerVersion: string;
readonly profileDigest: string;
readonly targetSha256: string;
readonly expectedLanguageId?: string;
readonly expectedCompilerSpecId?: string;
},
): Result<GhidraSessionInfo, Error> => {
const parsed = sessionInfoSchema.safeParse(value);
@ -65,6 +67,11 @@ export const parseGhidraSessionInfo = (
parsed.data.provider.version !== expected.providerVersion ||
parsed.data.profile_digest !== expected.profileDigest ||
parsed.data.target.sha256 !== expected.targetSha256 ||
(expected.expectedLanguageId !== undefined &&
parsed.data.target.language_id !== expected.expectedLanguageId) ||
(expected.expectedCompilerSpecId !== undefined &&
parsed.data.target.compiler_spec_id !==
expected.expectedCompilerSpecId) ||
new Set(parsed.data.capabilities).size !==
GHIDRA_SESSION_CAPABILITIES.length ||
GHIDRA_SESSION_CAPABILITIES.some(

View File

@ -90,6 +90,10 @@ export const hopperLoaderArgsForTarget = (
return ok(["-l", "ELF", flag]);
case "pe":
return ok(["-l", "WinPE", flag]);
case "dos-mz":
return err(
new ProviderAdapterError("hopper", "resolve_analysis_profile"),
);
}
};

View File

@ -8,6 +8,26 @@ import {
} from "./HopperProvider.js";
describe("Hopper provider capabilities", () => {
it("declines DOS MZ targets with a provider-specific support reason", () => {
const config = parseConfig({});
if (!config.ok) throw new Error("expected valid configuration");
const provider = new HopperProvider(config.value, silentLogger);
expect(
provider.inspectTargetSupport({
path: "/tmp/legacy.exe",
sha256: "a".repeat(64),
kind: "executable",
format: "dos-mz",
architecture: "x86",
availableArchitectures: ["x86"],
}),
).toMatchObject({
status: "unsupported",
code: "target_format_unsupported",
reason: expect.stringContaining("Ghidra"),
});
});
it("publishes deterministic descriptors and resists caller mutation", () => {
const config = parseConfig({});
expect(config.ok).toBe(true);

View File

@ -154,6 +154,13 @@ export class HopperProvider implements AnalysisProviderCandidate {
reason: null,
diagnostics,
};
if (target.format === "dos-mz")
return {
status: "unsupported",
code: "target_format_unsupported",
reason: "DOS MZ analysis requires the Ghidra 16-bit real-mode adapter.",
diagnostics,
};
return {
status: "supported",
code: null,

View File

@ -0,0 +1,145 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { classifyArtifactContent } from "../../../src/application/ArtifactGraphConstruction.js";
import { classifyRoot } from "../../../src/application/ArtifactInventory/classify.js";
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../../src/application/ArtifactInventory/hash.js";
import { scanCanonicalArtifactInventory } from "../../../src/application/ArtifactInventory/scanCanonical.js";
import { targetFormatSchema } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
import { identifyRuntimes } from "../../../src/domain/runtimeIdentification.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("MZ artifact classification", () => {
it("rejects a Windows header pointer overlapping the DOS header", () => {
expect(classifyArtifactContent("invalid.exe", peFixture(32))).toEqual({
kind: "unknown",
format: "unknown",
});
});
it("distinguishes DOS, observed PE, malformed MZ and unavailable prefix evidence", () => {
const dos = dosFixture();
expect(classifyArtifactContent("LEGACY.EXE", dos)).toEqual({
kind: "executable",
format: "dos-mz",
});
expect(
classifyArtifactContent("LEGACY.EXE", dos.subarray(0, 16), dos.length),
).toEqual({ kind: "unknown", format: "unknown" });
expect(classifyArtifactContent("bad.exe", Buffer.from("MZbroken"))).toEqual(
{ kind: "unknown", format: "unknown" },
);
expect(classifyArtifactContent("NATIVE.EXE", peFixture(128))).toMatchObject(
{
format: "pe",
},
);
const distantPe = peFixture(ARTIFACT_CLASSIFICATION_PREFIX_BYTES + 64);
expect(
classifyArtifactContent(
"NATIVE.EXE",
distantPe.subarray(0, ARTIFACT_CLASSIFICATION_PREFIX_BYTES),
distantPe.length,
),
).toEqual({ kind: "unknown", format: "unknown" });
expect(classifyArtifactContent("NATIVE.EXE", distantPe).format).toBe("pe");
const distantRelocations = dosWithDistantRelocations();
expect(
classifyArtifactContent("LEGACY.EXE", distantRelocations).format,
).toBe("dos-mz");
expect(
classifyArtifactContent(
"LEGACY.EXE",
distantRelocations.subarray(0, ARTIFACT_CLASSIFICATION_PREFIX_BYTES),
distantRelocations.length,
).format,
).toBe("unknown");
expect(targetFormatSchema.parse("dos-mz")).toBe("dos-mz");
});
it("keeps standalone and embedded DOS identities through inventory and runtime evidence", async () => {
const directory = await createTestTempDirectory("rea-mz-inventory-");
const dosPath = join(directory, "LEGACY.EXE");
const pePath = join(directory, "NATIVE.EXE");
await writeFile(dosPath, dosFixture());
await writeFile(pePath, peFixture(128));
expect(await classifyRoot(dosPath, false)).toBe("dos-mz");
expect(await classifyRoot(pePath, false)).toBe("pe");
const standalone = artifactInventoryResultSchema.parse(
await scanCanonicalArtifactInventory(dosPath),
);
expect(standalone.manifest.root_format).toBe("dos-mz");
expect(standalone.nodes).toContainEqual(
expect.objectContaining({ kind: "executable", format: "dos-mz" }),
);
const tree = join(directory, "tree");
await mkdir(tree);
await writeFile(join(tree, "LEGACY.EXE"), dosFixture());
await writeFile(join(tree, "NATIVE.EXE"), peFixture(128));
const inventory = artifactInventoryResultSchema.parse(
await scanCanonicalArtifactInventory(tree),
);
expect(inventory.nodes.map(({ format }) => format)).toEqual(
expect.arrayContaining(["dos-mz", "pe"]),
);
const evidence = createEvidence(
{
path: tree,
sha256: inventory.manifest.root_sha256,
format: "directory",
},
{ id: "inventory-fixture", name: "Inventory fixture", version: null },
{
operation: "inventory_artifact",
parameters: {},
result: jsonValueSchema.parse(inventory),
},
);
const runtimes = identifyRuntimes({ inventory_evidence: [evidence] });
expect(runtimes.runtimes).toContainEqual(
expect.objectContaining({
family: "native",
inspection: "provider-selection-required",
observations: expect.arrayContaining([
expect.objectContaining({ path: "LEGACY.EXE", format: "dos-mz" }),
expect.objectContaining({ path: "NATIVE.EXE", format: "pe" }),
]),
}),
);
});
});
const dosFixture = (): Buffer => {
const bytes = Buffer.alloc(512);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt16LE(1, 4);
bytes.writeUInt16LE(2, 8);
bytes.writeUInt16LE(28, 24);
return bytes;
};
const peFixture = (offset: number): Buffer => {
const bytes = Buffer.alloc(Math.max(512, offset + 24));
bytes.write("MZ", 0, "ascii");
bytes.writeUInt16LE(4, 8);
bytes.writeUInt16LE(64, 24);
bytes.writeUInt32LE(offset, 60);
bytes.set([0x50, 0x45, 0, 0], offset);
return bytes;
};
const dosWithDistantRelocations = (): Buffer => {
const bytes = Buffer.alloc(16_384);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt16LE(32, 4);
bytes.writeUInt16LE(1, 6);
bytes.writeUInt16LE(768, 8);
bytes.writeUInt16LE(ARTIFACT_CLASSIFICATION_PREFIX_BYTES + 2, 24);
return bytes;
};

View File

@ -4,7 +4,11 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { pe, thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import {
dosMz,
pe,
thinMach,
} from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("binary target I/O", () => {
@ -158,3 +162,21 @@ describe("binary target I/O", () => {
});
});
});
describe("DOS binary target I/O", () => {
it("validates a complete DOS file beyond the initial metadata probe", async () => {
const directory = await createTestTempDirectory("rea-dos-target-");
const path = join(directory, "legacy.exe");
const bytes = dosMz(8192);
await writeFile(path, bytes);
expect(await parseBinaryTarget(path)).toMatchObject({
ok: true,
value: { format: "dos-mz", architecture: "x86", kind: "executable" },
});
await writeFile(path, bytes.subarray(0, bytes.length - 1));
expect(await parseBinaryTarget(path)).toMatchObject({
ok: false,
error: { message: "Cannot open artifact: truncated DOS MZ load module" },
});
});
});

View File

@ -65,6 +65,27 @@ afterEach(async () => {
});
describe("Ghidra headless launcher", () => {
it("forces the admitted real-mode language and loader for DOS MZ", () => {
const arguments_ = ghidraHeadlessArguments({
projectRoot: "/tmp/project",
targetPath: "/tmp/target",
bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java",
descriptorPath: "/tmp/session.json",
ghidraLogPath: "/tmp/ghidra.log",
scriptLogPath: "/tmp/script.log",
dosMz: true,
});
expect(arguments_.slice(4, 10)).toEqual([
"-loader",
"MzLoader",
"-processor",
"x86:LE:16:Real Mode",
"-cspec",
"default",
]);
expect(arguments_).toContain("-readOnly");
expect(arguments_).toContain("-deleteProject");
});
it("builds a read-only import in deterministic order", () => {
expect(
ghidraHeadlessArguments({

View File

@ -21,6 +21,16 @@ afterEach(async () => {
});
describe("Hopper analysis profiles", () => {
it("does not map DOS MZ to the 32-bit Hopper loader", () => {
expect(hopperLoaderArgsForTarget(target("dos-mz", "x86"))).toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
operation: "resolve_analysis_profile",
},
});
});
it.each([
[target("mach-o", "arm64"), ["-l", "Mach-O", "--aarch64"]],
[target("elf", "x86_64"), ["-l", "ELF", "--intel-64"]],
@ -143,7 +153,7 @@ describe("Hopper analysis profiles", () => {
});
const target = (
format: "mach-o" | "elf" | "pe",
format: "mach-o" | "elf" | "pe" | "dos-mz",
architecture: "x86" | "x86_64" | "arm" | "arm64",
): BinaryTarget =>
format === "pe"

View File

@ -0,0 +1,53 @@
/** Build a public, source-owned 16-bit DOS MZ fixture without a compiler or binary seed. */
export function buildDosMzFixture() {
const headerBytes = 64;
const moduleBytes = 128;
const bytes = Buffer.alloc(headerBytes + moduleBytes);
const word = (offset, value) => bytes.writeUInt16LE(value, offset);
word(0, 0x5a4d); // MZ, not a PE stub.
word(2, bytes.length); // Bytes in the final 512-byte page.
word(4, 1); // One page.
word(6, 1); // One relocation.
word(8, headerBytes / 16);
word(10, 0);
word(12, 0xffff);
word(14, 8); // SS relative to load segment.
word(16, 0x100); // SP.
word(20, 0); // IP.
word(22, 0); // CS relative to load segment.
word(24, 0x1c); // Relocation table in the header.
word(0x1c, 9); // Far-call segment word at module+9.
word(0x1e, 0);
// Entry: MOV AX,1234h; near CALL +20h; relocated far CALL 0004:0000;
// Conditional JZ to a separated terminal block, else RET. A conditional edge
// keeps the tail in entry rather than creating an unconditional tail-call thunk.
// A RET after DOS exit bounds static analysis even without interrupt semantics.
// REA never executes this code.
bytes.set(
[
0xb8, 0x34, 0x12, 0xe8, 0x1a, 0x00, 0x9a, 0x00, 0x00, 0x04, 0x00, 0x74,
0x53, 0xc3,
],
headerBytes,
);
bytes.set([0xb8, 0x00, 0x4c, 0xcd, 0x21, 0xc3], headerBytes + 0x60);
bytes.set([0xfe, 0x00, 0x40, 0xc3], headerBytes + 0x20); // INC byte [BX+SI]; INC AX; RET.
bytes.set([0x05, 0x02, 0x00, 0xcb], headerBytes + 0x40); // ADD AX,2; RETF.
return {
bytes,
header_bytes: headerBytes,
module_bytes: moduleBytes,
load_segment: 0x1000,
entry_offset: 0,
near_offset: 0x20,
far_offset: 0x40,
near_call_offset: 3,
far_call_offset: 6,
relocation_offset: 9,
entry_first_bytes: 14,
entry_tail_offset: 0x60,
entry_tail_bytes: 6,
near_body_bytes: 4,
far_body_bytes: 4,
};
}