From 091892dfedd9f50cc68c7bbb63e44d5ca1754fcb Mon Sep 17 00:00:00 2001 From: Entrpi <133157614+Entrpi@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:24:17 +1100 Subject: [PATCH] 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. --- docs/ghidra-mips.md | 35 ++++---- docs/ghidra-psp.md | 6 +- scripts/verify-real-ghidra-mips.mjs | 82 +++++++------------ .../boundary/filesystem/mipsAbiFlags.test.ts | 30 ++++++- tests/conformance/ghidra/mips-checks.d.mts | 9 ++ tests/conformance/ghidra/mips-checks.mjs | 47 +++++++++++ tests/conformance/ghidra/mips-checks.test.ts | 69 ++++++++++++++++ 7 files changed, 207 insertions(+), 71 deletions(-) diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index 6145174453..96085a704d 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -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 diff --git a/docs/ghidra-psp.md b/docs/ghidra-psp.md index 52938d5eee..9f74e69174 100644 --- a/docs/ghidra-psp.md +++ b/docs/ghidra-psp.md @@ -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, diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index bbfa4b382d..b2e02568c0 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -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, }; diff --git a/tests/boundary/filesystem/mipsAbiFlags.test.ts b/tests/boundary/filesystem/mipsAbiFlags.test.ts index afbde808c7..64cdd2fd92 100644 --- a/tests/boundary/filesystem/mipsAbiFlags.test.ts +++ b/tests/boundary/filesystem/mipsAbiFlags.test.ts @@ -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(); diff --git a/tests/conformance/ghidra/mips-checks.d.mts b/tests/conformance/ghidra/mips-checks.d.mts index 4d76808de3..6b80923223 100644 --- a/tests/conformance/ghidra/mips-checks.d.mts +++ b/tests/conformance/ghidra/mips-checks.d.mts @@ -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, byteOrder: string, diff --git a/tests/conformance/ghidra/mips-checks.mjs b/tests/conformance/ghidra/mips-checks.mjs index d3c5ba678e..253013d4ca 100644 --- a/tests/conformance/ghidra/mips-checks.mjs +++ b/tests/conformance/ghidra/mips-checks.mjs @@ -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 diff --git a/tests/conformance/ghidra/mips-checks.test.ts b/tests/conformance/ghidra/mips-checks.test.ts index a4a5b1923e..dca7933f39 100644 --- a/tests/conformance/ghidra/mips-checks.test.ts +++ b/tests/conformance/ghidra/mips-checks.test.ts @@ -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",