mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 20:06:14 +00:00
test(ghidra): expect carved MIPS copies to be refused at target opening (#8227)
Since651afa445target opening refuses every ELF declaration beyond EOF, including carved copies, andac6e0e258moved 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:
1 parent
1dc97d8cec
commit
091892dfed
7 files changed
+207
-71
No files matched your search
+20
-15
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user