test(ghidra): expect carved MIPS copies to be refused at target opening (#8227)

Since 651afa445 target opening refuses every ELF declaration beyond EOF,
including carved copies, and ac6e0e258 moved the carved MIPS boundary tests
to that contract. The real-Ghidra MIPS lane still expected a carved image to
resolve with section-table limitations and be refused later by Ghidra
admission, so verify:ghidra:mips failed at its first carved fixture.

The lane now requires open_binary and the CLI to refuse the carved copy as
target_unavailable (file_format_rejected) with the exact declared
section-table range and captured size. The carve and that expectation live in
the shared MIPS checks, and a boundary regression applies them to the
production resolver and error projection, so a later contract change fails
without real Ghidra. The MIPS and PSP guides describe the current contract.
This commit is contained in:
Entrpi authored and GitHub committed 2026-10-31 15:24:17 +08:00
1 parent 1dc97d8cec
commit 091892dfed
7 files changed
+207 -71

No files matched your search

+20 -15
View File
@@ -39,9 +39,9 @@ bridge-reported `language_id` after import. A caller-selected
pin; combining the pinned profile with an environment override is a typed
refusal. A header that declares an ABI field but carries no record is still
refused. Two further boundaries apply: an inspection that could not examine
every ABI declaration source (for example a carved ELF whose section table
extends past the captured bytes without a program-header record) does not
establish the recordless admission, and an `EF_MIPS_ARCH_1` or
every ABI declaration source does not establish the recordless admission
(target opening already refuses a carved ELF whose section table extends past
the captured bytes, as described below), and an `EF_MIPS_ARCH_1` or
`EF_MIPS_ARCH_32` header that _declares_ an ABI is refused rather than
relabeled, because the declared-ABI lane is verified for
`EF_MIPS_ARCH_32R2` headers only. The recordless MIPS-I lane pins the same
@@ -77,13 +77,16 @@ argument ABI and unknown nonzero MSA values remain unsupported. Without a
separate ABI flags record, FP attribute values are admitted only when absent,
`ANY` (0), double precision (1), or FPXX (5), the values covered by the
recordless profile. Parsed values are retained in the MIPS analysis profile.
For carved ELFs whose section table extends
beyond the captured file, REA examines every complete captured section header
and records the unavailable headers as a limitation. The provider refuses an
incomplete ABI declaration inspection even when a program-header ABI record is
valid, because an unavailable section header could carry an unsupported or
conflicting declaration. A missing program-header record does not make ABI
declarations in uncaptured sections absent or supply a default ABI.
A carved ELF whose section header table extends beyond the captured file is
refused when the target is opened, before provider selection, as is every ELF
declaration beyond EOF ([installation](installation.md#ghidra)). The refusal
names the declared range and the file size. It applies even when a
program-header ABI record was captured: an uncaptured section header could
carry an unsupported or conflicting declaration, so the captured bytes are not
analyzed as a complete smaller program. The provider also refuses any ABI
declaration inspection that reports unexamined headers. A missing
program-header record does not make ABI declarations in uncaptured sections
absent or supply a default ABI.
The field layout follows
[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h).
@@ -257,11 +260,13 @@ observations and does not relabel the upstream result as verified.
CLI/MCP discovery and decompilation, direct calls, target/profile/Evidence
identity, snapshot round-trip and final process cleanup remain covered.
For both byte orders, the lane also carves away the section table while retaining
all program-header payloads. It checks the actual Ghidra language, entry-address
decompilation, distinct artifact digest and missing-section limitations through
CLI Evidence and an MCP-produced snapshot. Caller-selected Ghidra heap limits
are passed to these child workflows.
For both byte orders, the lane also cuts the image one byte before its section
header table, after checking that every program header and payload stays
captured. `open_binary` and the CLI must both refuse that copy before provider
selection as `target_unavailable` with constraint `file_format_rejected`,
naming the declared section-table range and the captured size, and leave it
unchanged. Caller-selected Ghidra heap limits are passed to the lane's CLI and
MCP child workflows.
For soft-float fixtures the lane also checks, through `inspect_native_api`, the
exact parameter and return types and storage of float, double, mixed (pair
alignment and stack spill) and struct-returning functions; that a caller's
+3 -3
View File
@@ -85,9 +85,9 @@ node scripts/verify-real-ghidra-mips.mjs --psp-missing-extension
npm run verify:ghidra:mips
```
The generic verifier also exercises a section-truncated firmware fixture. That
check uses generic MIPS symbols and language expectations and runs only in generic
mode; the PSP report explicitly records carved-PSP coverage as not run.
The generic verifier also checks that a section-truncated firmware copy is
refused when the target is opened. That check runs only in generic mode; the
PSP report explicitly records carved-PSP coverage as not run.
The maintained PSP fixture builds from `tests/conformance/psp/`, never executes,
and is independent of commercial game data. PSP GNU tools supply ELF/ABI,
+30 -52
View File
@@ -2,7 +2,14 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import {
mkdtemp,
mkdir,
readFile,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -18,6 +25,8 @@ import {
assertMipsProbe,
assertMipsGlobal,
assertMipsSoftFloatStorage,
assertMipsCarvedTargetRefusal,
carveMipsElfBeforeSectionTable,
} from "../tests/conformance/ghidra/mips-checks.mjs";
import { parseEvidence } from "../dist/domain/evidence.js";
import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
@@ -122,16 +131,6 @@ async function verifySoftFloat(call, symbols, byteOrder) {
);
return { storage: boundaries };
}
function assertIncompleteMipsInspection(error) {
assert.equal(error.code, "capability_unavailable");
assert.equal(error.details.selection_reason, "target_unsupported");
const rejection = error.details.rejections.find(
(candidate) => candidate.provider_id === "ghidra",
);
assert.equal(rejection?.code, "architecture_unsupported");
assert.match(rejection.reason, /ABI declaration inspection is incomplete/u);
assert.match(rejection.reason, /section header table/u);
}
const reports = [];
let primaryFailure;
@@ -617,45 +616,20 @@ try {
// PSP keeps its independent Allegrex/A-B-A checks; carved PSP is not verified.
let carved;
if (!psp) {
// A program-header ABI record survives carving, but GNU declarations in
// uncaptured section headers remain unknown: both adapters must refuse.
const read16 = (offset) =>
byteOrder === "little"
? sourceBytes.readUInt16LE(offset)
: sourceBytes.readUInt16BE(offset);
const read32 = (offset) =>
byteOrder === "little"
? sourceBytes.readUInt32LE(offset)
: sourceBytes.readUInt32BE(offset);
const sectionOffset = read32(32);
const cut = sectionOffset - 1;
const programOffset = read32(28);
const programCount = read16(44);
assert.equal(read16(42), 32);
assert.ok(cut >= programOffset + programCount * 32);
for (let i = 0; i < programCount; i += 1) {
const offset = programOffset + i * 32;
assert.ok(read32(offset + 4) + read32(offset + 16) <= cut);
}
const carvedBytes = sourceBytes.subarray(0, cut);
// The carved copy keeps every program header and payload but declares
// a section table it did not capture. Both adapters refuse it when
// opening the target; it is not analyzed as a smaller program.
const carve = carveMipsElfBeforeSectionTable(sourceBytes, byteOrder);
const carvedPath = join(
workspace,
`${byteOrder}-${floatAbi}-carved.elf`,
);
const carvedSha256 = createHash("sha256")
.update(carvedBytes)
.update(carve.bytes)
.digest("hex");
await writeFile(carvedPath, carvedBytes);
const carvedResolved = await parseBinaryTarget(carvedPath);
if (!carvedResolved.ok) throw carvedResolved.error;
assert.deepEqual(
carvedResolved.value.mips.abiFlags,
independent.mips.abiFlags,
);
assert.match(
carvedResolved.value.mips.limitations.join(" "),
/section header table/u,
);
await writeFile(carvedPath, carve.bytes);
// Target errors name the canonical path that was opened.
const openedPath = await realpath(carvedPath);
const carvedError = requireMcpToolError(
await client.callTool(
{
@@ -665,7 +639,7 @@ try {
{ timeout: 60000 },
),
);
assertIncompleteMipsInspection(carvedError);
assertMipsCarvedTargetRefusal(carvedError, openedPath, carve.reason);
let carvedCliError;
try {
await execute(
@@ -688,16 +662,20 @@ try {
carvedCliError &&
Number.isInteger(carvedCliError.code) &&
carvedCliError.code !== 0,
"Expected an incomplete-ABI refusal, not a timeout or success",
"Expected a target-opening refusal, not a timeout or success",
);
assertIncompleteMipsInspection(JSON.parse(carvedCliError.stdout));
assert.deepEqual(await readFile(carvedPath), carvedBytes);
assertMipsCarvedTargetRefusal(
JSON.parse(carvedCliError.stdout),
openedPath,
carve.reason,
);
assert.deepEqual(await readFile(carvedPath), carve.bytes);
carved = {
sha256: carvedSha256,
source_bytes: carvedBytes.length,
section_header_offset: sectionOffset,
status: "expected-incomplete-abi-refusal",
limitations: carvedResolved.value.mips.limitations,
source_bytes: carve.bytes.length,
section_header_offset: carve.sectionOffset,
status: "expected-target-refusal",
reason: carve.reason,
cli_exit: carvedCliError.code,
mcp_error: carvedError,
};
+29 -1
View File
@@ -1,10 +1,15 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { open, readFile, writeFile } from "node:fs/promises";
import { open, readFile, realpath, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import {
assertMipsCarvedTargetRefusal,
carveMipsElfBeforeSectionTable,
} from "../../conformance/ghidra/mips-checks.mjs";
import { jsonObjectSchema } from "../../../src/domain/jsonValue.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { expectBinaryTargetError } from "../../fixtures/binaryTargetAdmission.js";
@@ -305,6 +310,29 @@ it("refuses uncaptured section declarations while retaining unknown ABI evidence
assert.equal(resolved.mips.limitations, undefined);
assert.equal(ghidraMipsUnsupportedReason(resolved), null);
});
for (const little of [true, false]) {
it(`matches the real MIPS lane's carved-copy refusal (${little ? "LE" : "BE"})`, async () => {
// Linker layout puts the section table last, so the lane's carve keeps
// every program header and payload while dropping the whole table.
const original = fixture(little);
const linked = Buffer.concat([original, original.subarray(128, 208)]);
if (little) linked.writeUInt32LE(original.length, 32);
else linked.writeUInt32BE(original.length, 32);
const carve = carveMipsElfBeforeSectionTable(
linked,
little ? "little" : "big",
);
const path = await writeFixture(carve.bytes);
const error = expectBinaryTargetError(await parseBinaryTarget(path));
// The real lane applies the same check to MCP and CLI error projections.
assertMipsCarvedTargetRefusal(
projectAnalysisError(error),
await realpath(path),
carve.reason,
);
assert.deepEqual(await readFile(path), carve.bytes);
});
}
for (const failure of ["conflicting", "outside", "duplicate"]) {
it(`retains ${failure} ABI errors in a partly captured section table`, async () => {
const original = fixture();
@@ -45,6 +45,15 @@ export function assertMipsProbe(
address: string,
): void;
export function assertMipsGlobal(bytes: string, byteOrder: string): void;
export function carveMipsElfBeforeSectionTable(
bytes: Buffer,
byteOrder: string,
): { bytes: Buffer; sectionOffset: number; reason: string };
export function assertMipsCarvedTargetRefusal(
error: unknown,
path: string,
reason: string,
): void;
export function assertMipsSoftFloatStorage(
boundaries: Record<string, unknown>,
byteOrder: string,
+47
View File
@@ -361,6 +361,53 @@ export function assertMipsGlobal(bytes, byteOrder) {
assert.equal(bytes, wordHex(7, byteOrder));
}
/**
* Cut a complete ELF32 MIPS image one byte before its section header table,
* keeping every program header and payload. The section table is then the
* only out-of-file declaration, so its declared range names the refusal.
*/
export function carveMipsElfBeforeSectionTable(bytes, byteOrder) {
language(byteOrder);
const little = byteOrder === "little";
const read16 = (offset) =>
little ? bytes.readUInt16LE(offset) : bytes.readUInt16BE(offset);
const read32 = (offset) =>
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const programOffset = read32(28);
const sectionOffset = read32(32);
const programCount = read16(44);
const sectionCount = read16(48);
assert.equal(read16(42), 32);
assert.equal(read16(46), 40);
// Extended numbering would move the counts into uncaptured section zero.
assert.ok(sectionCount !== 0 && programCount !== 0xffff);
assert.notEqual(read16(50), 0xffff);
const cut = sectionOffset - 1;
assert.ok(cut >= programOffset + programCount * 32);
for (let i = 0; i < programCount; i += 1) {
const offset = programOffset + i * 32;
assert.ok(read32(offset + 4) + read32(offset + 16) <= cut);
}
return {
bytes: bytes.subarray(0, cut),
sectionOffset,
reason: `truncated ELF section header table: declared file range ${sectionOffset}..${sectionOffset + sectionCount * 40} exceeds the ${cut}-byte file`,
};
}
/**
* Opening the carved copy fails before provider selection, naming the declared
* range and the captured size; it is not admitted as a smaller program.
*/
export function assertMipsCarvedTargetRefusal(error, path, reason) {
assert.equal(error.code, "target_unavailable");
assert.deepEqual(error.details, {
path,
reason,
constraint: "file_format_rejected",
});
}
/**
* Soft-float o32 places float/double values exactly where a same-size integer
* goes: Ghidra joins register pairs most-significant word first. The struct
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import {
assertMipsCarvedTargetRefusal,
assertMipsGlobal,
assertMipsLoadedImage,
assertMipsProbe,
assertMipsSoftFloatStorage,
carveMipsElfBeforeSectionTable,
parseMipsReadelf,
} from "./mips-checks.mjs";
@@ -271,6 +273,73 @@ describe("MIPS real-verifier independent expectations", () => {
});
});
describe("MIPS real-verifier carved-copy expectations", () => {
it.each(["little", "big"] as const)(
"carves before the %s-endian section table only when every payload stays captured",
(byteOrder) => {
const bytes = Buffer.alloc(200);
const u16 = (offset: number, value: number) =>
byteOrder === "little"
? bytes.writeUInt16LE(value, offset)
: bytes.writeUInt16BE(value, offset);
const u32 = (offset: number, value: number) =>
byteOrder === "little"
? bytes.writeUInt32LE(value, offset)
: bytes.writeUInt32BE(value, offset);
u32(28, 52);
u32(32, 120);
u16(42, 32);
u16(44, 1);
u16(46, 40);
u16(48, 2);
u32(56, 100); // PT payload ends at the last captured byte.
u32(68, 19);
const carve = carveMipsElfBeforeSectionTable(bytes, byteOrder);
expect(carve.bytes.length).toBe(119);
expect(carve.reason).toBe(
"truncated ELF section header table: declared file range 120..200 exceeds the 119-byte file",
);
u32(68, 20);
expect(() => carveMipsElfBeforeSectionTable(bytes, byteOrder)).toThrow();
},
);
it("accepts only a target-opening refusal of the carved copy", () => {
const path = "/work/little-hard-carved.elf";
const reason =
"truncated ELF section header table: declared file range 2620..3580 exceeds the 2619-byte file";
const refusal = {
code: "target_unavailable",
details: { path, reason, constraint: "file_format_rejected" },
};
assertMipsCarvedTargetRefusal(refusal, path, reason);
for (const observed of [
// Superseded: a resolved copy refused later by Ghidra admission.
{
code: "capability_unavailable",
details: {
selection_reason: "target_unsupported",
rejections: [
{
provider_id: "ghidra",
code: "architecture_unsupported",
reason: `MIPS ABI declaration inspection is incomplete. ${reason}`,
},
],
},
},
{
...refusal,
details: { ...refusal.details, reason: reason.replace("2619", "2620") },
},
{ ...refusal, details: { path, reason } },
])
expect(() =>
assertMipsCarvedTargetRefusal(observed, path, reason),
).toThrow();
});
});
describe("MIPS real-verifier GNU attribute coverage", () => {
it.each(["exec", "rel"] as const)(
"reports verified GNU attribute absence for both byte orders of %s fixtures",