From a40ee741c2508062f2733d17484f91fc100a82d6 Mon Sep 17 00:00:00 2001 From: Abdul Samad Siddiqui <160336057+AbdulSamad94@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:00:16 +0500 Subject: [PATCH] test(ghidra): align MIPS verifier ABI and snapshot expectations Fixes #3415. Retain independently inspected GNU attribute facts and compare exact snapshot wire fields. Preserve strict metadata comparisons, specific incomplete-ABI refusals and source immutability checks. Original implementation by @AbdulSamad94. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --- scripts/verify-real-ghidra-mips.mjs | 133 ++++++---------- tests/conformance/ghidra/mips-checks.d.mts | 19 ++- tests/conformance/ghidra/mips-checks.mjs | 66 +++++++- tests/conformance/ghidra/mips-checks.test.ts | 108 ++++++++++++- tests/conformance/ghidra/psp-checks.test.ts | 156 +++++++++++++++++-- tests/conformance/ghidra/psp-fixture.d.mts | 4 +- tests/conformance/ghidra/psp-fixture.mjs | 72 +++++++-- 7 files changed, 437 insertions(+), 121 deletions(-) diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index 12c00366d6..350ee3b908 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -12,6 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; import { inspectMipsReadelf, + expectedMipsElfProfile, inspectLegacyMipsReadelf, assertMipsLoadedImage, assertMipsProbe, @@ -120,6 +121,17 @@ 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; try { @@ -475,28 +487,9 @@ try { JSON.parse(await readFile(snapshotPath, "utf8")), ); assert.equal(snapshot.target.architecture, "mips"); - const abi = independent.mips.abiFlags; assert.deepEqual( snapshot.binding.analysis_profile.parameters.mips_elf, - { - elf_class: independent.mips.elfClass, - byte_order: independent.mips.byteOrder, - type: independent.mips.type, - flags: independent.mips.flags, - abi_flags: { - version: abi.version, - isa_level: abi.isaLevel, - isa_revision: abi.isaRevision, - gpr_size: abi.gprSize, - cpr1_size: abi.cpr1Size, - cpr2_size: abi.cpr2Size, - fp_abi: abi.fpAbi, - isa_extension: abi.isaExtension, - ases: abi.ases, - flags1: abi.flags1, - flags2: abi.flags2, - }, - }, + expectedMipsElfProfile(independent.mips), "Snapshot profile did not retain the independently inspected ELF/ABI declaration", ); assert.equal( @@ -617,7 +610,8 @@ try { // PSP keeps its independent Allegrex/A-B-A checks; carved PSP is not verified. let carved; if (!psp) { - // Carved firmware keeps all program headers/load bytes but loses sections. + // 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) @@ -655,79 +649,50 @@ try { carvedResolved.value.mips.limitations.join(" "), /section header table/u, ); - const carvedTarget = await call("open_binary", { - path: carvedPath, - provider_id: "ghidra", - }); - assert.equal(carvedTarget.sha256, carvedSha256); - const carvedImage = await call("inspect_native_load_image"); - assertMipsLoadedImage( - carvedImage.observations, - byteOrder, - carvedSha256, - ); - const carvedProcedures = await call("list_procedures"); - assert.ok( - carvedProcedures.some( - (p) => - BigInt(p.address) === BigInt(independent.symbols.rea_mips_entry), + const carvedError = requireMcpToolError( + await client.callTool( + { + name: "open_binary", + arguments: { path: carvedPath, provider_id: "ghidra" }, + }, + { timeout: 60000 }, ), ); - const carvedDossier = functionDossierSchema.parse( - await call("analyze_function", { - procedure: independent.symbols.rea_mips_entry, - }), - ); - assert.ok(carvedDossier.pseudocode.trim().length > 0); - const carvedSnapshotPath = join( - workspace, - `${byteOrder}-${floatAbi}-carved.snapshot.json`, - ); - await call("close_binary", { snapshot_path: carvedSnapshotPath }); - const carvedSnapshot = parseAnalysisSnapshot( - JSON.parse(await readFile(carvedSnapshotPath, "utf8")), - ); - assert.equal(carvedSnapshot.target.sha256, carvedSha256); + assertIncompleteMipsInspection(carvedError); + let carvedCliError; + try { + await execute( + process.execPath, + [ + entrypoint, + "function", + carvedPath, + independent.symbols.rea_mips_entry, + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 60000, maxBuffer: 1024 * 1024 }, + ); + } catch (cause) { + carvedCliError = cause; + } assert.ok( - carvedSnapshot.evidence_bundle.records.some((record) => - parseEvidence(record).limitations.some((item) => - item.includes("section header table"), - ), - ), - ); - const carvedCli = await execute( - process.execPath, - [ - entrypoint, - "function", - carvedPath, - independent.symbols.rea_mips_entry, - "--provider", - "ghidra", - "--json", - ], - { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, - ); - const carvedEvidence = parseEvidence(JSON.parse(carvedCli.stdout)); - assert.equal(carvedEvidence.subject?.digest.sha256, carvedSha256); - assert.ok( - carvedEvidence.limitations.some((item) => - item.includes("section header table"), - ), - ); - assert.equal( - functionDossierSchema.parse(carvedEvidence.normalized_result) - .procedure.address, - carvedDossier.procedure.address, + carvedCliError && + Number.isInteger(carvedCliError.code) && + carvedCliError.code !== 0, + "Expected an incomplete-ABI refusal, not a timeout or success", ); + assertIncompleteMipsInspection(JSON.parse(carvedCliError.stdout)); assert.deepEqual(await readFile(carvedPath), carvedBytes); carved = { sha256: carvedSha256, source_bytes: carvedBytes.length, section_header_offset: sectionOffset, - effective_language: carvedImage.observations.language_id, + status: "expected-incomplete-abi-refusal", limitations: carvedResolved.value.mips.limitations, - procedures: carvedProcedures.length, + cli_exit: carvedCliError.code, + mcp_error: carvedError, }; } reports.push({ diff --git a/tests/conformance/ghidra/mips-checks.d.mts b/tests/conformance/ghidra/mips-checks.d.mts index 09ba928c6b..4d76808de3 100644 --- a/tests/conformance/ghidra/mips-checks.d.mts +++ b/tests/conformance/ghidra/mips-checks.d.mts @@ -1,10 +1,25 @@ -import type { MipsElfMetadata } from "../../../src/domain/binaryTargetTypes.js"; +import type { + MipsAbiFlags, + MipsElfMetadata, + MipsGnuAttributes, +} from "../../../src/domain/binaryTargetTypes.js"; + +import type { JsonValue } from "../../../src/domain/jsonValue.js"; + +export type MipsReadelfMetadata = MipsElfMetadata & { + readonly abiFlags: MipsAbiFlags; + readonly gnuAttributes: MipsGnuAttributes | null; +}; +export function expectedMipsElfProfile( + mips: MipsReadelfMetadata, +): Readonly>; export type MipsFloatAbi = "hard" | "soft"; export interface MipsReadelfFacts { - mips: MipsElfMetadata; + mips: MipsReadelfMetadata & { readonly gnuAttributes: null }; symbols: Record; } +export function parseMipsReadelfSectionTypes(text: string): readonly string[]; export function parseMipsReadelf( text: string, byteOrder: string, diff --git a/tests/conformance/ghidra/mips-checks.mjs b/tests/conformance/ghidra/mips-checks.mjs index e5c6d335c4..d3c5ba678e 100644 --- a/tests/conformance/ghidra/mips-checks.mjs +++ b/tests/conformance/ghidra/mips-checks.mjs @@ -23,6 +23,65 @@ const SOFT_FLOAT_SYMBOLS = [ "rea_mips_soft_caller", ]; +/** Complete indexed ELF32 section types shared by the independent fixture readers. */ +export function parseMipsReadelfSectionTypes(text) { + const counts = [ + ...text.matchAll(/^\s*Number of section headers:\s*(\d+)\s*$/gmu), + ]; + assert.equal(counts.length, 1, "Missing or ambiguous readelf section count"); + const sectionCount = Number(counts[0][1]); + assert.ok(sectionCount > 0, "Fixture must have a section header table"); + const sections = [...text.matchAll(/^[ \t]*\[[ \t]*(\d+)\][ \t]+(.+)$/gmu)]; + assert.equal( + sections.length, + sectionCount, + "Incomplete readelf section table", + ); + return sections.map((section, index) => { + assert.equal(Number(section[1]), index, "Ambiguous readelf section index"); + const columns = + /^(?:\S+[ \t]+)?(\S+)[ \t]+[\da-f]{8}[ \t]+[\da-f]+[ \t]+[\da-f]+[ \t]+[\da-f]{2}[ \t]+(?:[A-Za-z]+[ \t]+)?\d+[ \t]+\d+[ \t]+\d+[ \t]*$/u.exec( + section[2], + ); + assert.ok(columns !== null, "Malformed readelf section header"); + return columns[1]; + }); +} + +/** Independent wire-format expectation shared by generic and PSP snapshots. */ +export function expectedMipsElfProfile(mips) { + const abi = mips.abiFlags; + assert.ok(abi != null, "Fixture ABI declarations must be inspected"); + assert.notEqual( + mips.gnuAttributes, + undefined, + "Fixture GNU attributes must be inspected", + ); + return { + elf_class: mips.elfClass, + byte_order: mips.byteOrder, + type: mips.type, + flags: mips.flags, + gnu_attributes: + mips.gnuAttributes === null + ? null + : { fp_abi: mips.gnuAttributes.fpAbi, msa: mips.gnuAttributes.msa }, + abi_flags: { + version: abi.version, + isa_level: abi.isaLevel, + isa_revision: abi.isaRevision, + gpr_size: abi.gprSize, + cpr1_size: abi.cpr1Size, + cpr2_size: abi.cpr2Size, + fp_abi: abi.fpAbi, + isa_extension: abi.isaExtension, + ases: abi.ases, + flags1: abi.flags1, + flags2: abi.flags2, + }, + }; +} + /** * Parse only the documented GNU readelf fields needed by our tiny fixtures. * `rel` objects carry their own symbol checks in mips-relocatable-checks.mjs. @@ -51,6 +110,10 @@ export function parseMipsReadelf( objectType === "rel" ? /^REL\b/u : /^EXEC\b/u, ); assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^MIPS\b/u); + assert.ok( + !parseMipsReadelfSectionTypes(text).includes("GNU_ATTRIBUTES"), + "Fixture unexpectedly declares GNU attributes", + ); const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+),.*$/gmu, "flags")); assert.equal(flags, 0x70001001, "Fixture ELF flags differ from build intent"); assert.equal(field(/^ISA:\s*(.+)$/gmu, "ISA"), "MIPS32r2"); @@ -123,6 +186,7 @@ export function parseMipsReadelf( type: objectType === "rel" ? 1 : 2, flags, abiFlags, + gnuAttributes: null, }, symbols, }; @@ -144,7 +208,7 @@ export async function inspectMipsReadelf(path, byteOrder, floatAbi = "hard") { ); const result = await execute( command, - ["-h", "-A", "-s", "-W", path], + ["-h", "-A", "-S", "-s", "-W", path], options, ); assert.equal( diff --git a/tests/conformance/ghidra/mips-checks.test.ts b/tests/conformance/ghidra/mips-checks.test.ts index 7060c32d90..a4a5b1923e 100644 --- a/tests/conformance/ghidra/mips-checks.test.ts +++ b/tests/conformance/ghidra/mips-checks.test.ts @@ -7,7 +7,7 @@ import { parseMipsReadelf, } from "./mips-checks.mjs"; -// GNU readelf 2.40 output for the source-owned Clang 17 / LLD fixture. +// GNU readelf 2.44 output for the source-owned Clang 19 / LLD 19 fixture. // This tests the reader boundary, not Ghidra; the real lane rebuilds both orders. const readelf = `ELF Header: Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 @@ -21,7 +21,7 @@ const readelf = `ELF Header: Version: 0x1 Entry point address: 0x201b0 Start of program headers: 52 (bytes into file) - Start of section headers: 2996 (bytes into file) + Start of section headers: 2624 (bytes into file) Flags: 0x70001001, noreorder, o32, mips32r2 Size of this header: 52 (bytes) Size of program headers: 32 (bytes) @@ -30,6 +30,38 @@ const readelf = `ELF Header: Number of section headers: 24 Section header string table index: 22 +Section Headers: + [Nr] Name Type Addr Off Size ES Flg Lk Inf Al + [ 0] NULL 00000000 000000 000000 00 0 0 0 + [ 1] .MIPS.abiflags MIPS_ABIFLAGS 00010118 000118 000018 18 A 0 0 8 + [ 2] .reginfo MIPS_REGINFO 00010130 000130 000018 18 A 0 0 4 + [ 3] .rodata PROGBITS 00010148 000148 00001e 00 A 0 0 1 + [ 4] .text PROGBITS 00020170 000170 0000f0 00 AX 0 0 16 + [ 5] .data PROGBITS 00030260 000260 000000 00 WA 0 0 16 + [ 6] .got PROGBITS 00030260 000260 000008 00 WAp 0 0 16 + [ 7] .sdata PROGBITS 00030268 000268 000004 00 WAp 0 0 4 + [ 8] .bss NOBITS 00030270 00026c 000000 00 WA 0 0 16 + [ 9] .mdebug.abi32 PROGBITS 00000000 00026c 000000 00 0 0 1 + [10] .pdr PROGBITS 00000000 00026c 000060 00 0 0 4 + [11] .debug_abbrev MIPS_DWARF 00000000 0002cc 0000b3 00 0 0 1 + [12] .debug_info MIPS_DWARF 00000000 00037f 00015e 00 0 0 1 + [13] .debug_str_offsets MIPS_DWARF 00000000 0004dd 000040 00 0 0 1 + [14] .debug_str MIPS_DWARF 00000000 00051d 0000ef 01 MS 0 0 1 + [15] .debug_addr MIPS_DWARF 00000000 00060c 000018 00 0 0 1 + [16] .comment PROGBITS 00000000 000624 00003e 01 MS 0 0 1 + [17] .debug_frame MIPS_DWARF 00000000 000664 00004c 00 0 0 4 + [18] .debug_line MIPS_DWARF 00000000 0006b0 0000f8 00 0 0 1 + [19] .debug_line_str MIPS_DWARF 00000000 0007a8 000098 01 MS 0 0 1 + [20] .debug_aranges MIPS_DWARF 00000000 000840 000020 00 0 0 1 + [21] .symtab SYMTAB 00000000 000860 000090 10 23 4 4 + [22] .shstrtab STRTAB 00000000 0008f0 0000f0 00 0 0 1 + [23] .strtab STRTAB 00000000 0009e0 00005f 00 0 0 1 +Key to Flags: + W (write), A (alloc), X (execute), M (merge), S (strings), I (info), + L (link order), O (extra OS processing required), G (group), T (TLS), + C (compressed), x (unknown), o (OS specific), E (exclude), + D (mbind), p (processor specific) + Symbol table '.symtab' contains 9 entries: Num: Value Size Type Bind Vis Ndx Name 0: 00000000 0 NOTYPE LOCAL DEFAULT UND @@ -54,6 +86,15 @@ ASEs: None FLAGS 1: 00000000 FLAGS 2: 00000000 + +Static GOT: + Canonical gp value: 00038250 + + Reserved entries: + Address Access Value + 00030260 -32752(gp) 00000000 + 00030264 -32748(gp) 80000000 + `; const sha256 = "a".repeat(64); const image = { @@ -94,6 +135,7 @@ describe("MIPS real-verifier independent expectations", () => { elfClass: 32, byteOrder: "little", flags: 0x70001001, + gnuAttributes: null, abiFlags: { isaLevel: 32, isaRevision: 2, @@ -229,6 +271,67 @@ describe("MIPS real-verifier independent expectations", () => { }); }); +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", + (objectType) => { + for (const byteOrder of ["little", "big"]) { + const text = readelf + .replace("little endian", `${byteOrder} endian`) + .replace( + "EXEC (Executable file)", + objectType === "rel" + ? "REL (Relocatable file)" + : "EXEC (Executable file)", + ); + expect( + parseMipsReadelf(text, byteOrder, "hard", objectType).mips + .gnuAttributes, + ).toBeNull(); + } + }, + ); + + it("rejects GNU attribute sections even when their name is unrelated", () => { + expect(() => + parseMipsReadelf( + readelf.replace( + ".comment PROGBITS", + ".comment GNU_ATTRIBUTES", + ), + "little", + ), + ).toThrow(/GNU attributes/u); + }); + + it("uses the section type rather than its name to establish absence", () => { + expect( + parseMipsReadelf( + readelf.replace( + ".comment PROGBITS", + ".gnu.attributes PROGBITS", + ), + "little", + ).mips.gnuAttributes, + ).toBeNull(); + }); + + it.each([ + [ + "missing table", + readelf.replace( + /Section Headers:[\s\S]*?Key to Flags:/u, + "Key to Flags:", + ), + ], + ["missing entry", readelf.replace(/^.*\[ 5\].*\n/gmu, "")], + ["duplicate index", readelf.replace("[ 5]", "[ 4]")], + ["malformed entry", readelf.replace(/^.*\[ 5\].*$/gmu, " [ 5] malformed")], + ])("refuses GNU attribute absence from a %s", (_name, text) => { + expect(() => parseMipsReadelf(text, "little")).toThrow(/section/u); + }); +}); + describe("MIPS soft-float real-verifier expectations", () => { const softReadelf = readelf .replace("CPR1 size: 32", "CPR1 size: 0") @@ -249,6 +352,7 @@ describe("MIPS soft-float real-verifier expectations", () => { it("reads the soft-float ABI record and its fixture symbols", () => { const parsed = parseMipsReadelf(softReadelf, "little", "soft"); expect(parsed.mips.abiFlags).toMatchObject({ cpr1Size: 0, fpAbi: 3 }); + expect(parsed.mips.gnuAttributes).toBeNull(); expect(parsed.symbols.rea_mips_soft_mixed).toBe("0x20380"); }); diff --git a/tests/conformance/ghidra/psp-checks.test.ts b/tests/conformance/ghidra/psp-checks.test.ts index 36e067021a..b67c38a110 100644 --- a/tests/conformance/ghidra/psp-checks.test.ts +++ b/tests/conformance/ghidra/psp-checks.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import { it } from "vitest"; -import { parsePspReadelfAbi } from "./psp-fixture.mjs"; +import { expectedMipsElfProfile } from "./mips-checks.mjs"; +import { ghidraMipsProfileParameters } from "../../../src/ghidra/GhidraMipsProfile.js"; +import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js"; +import { parsePspReadelfMips } from "./psp-fixture.mjs"; // GNU readelf 2.44 output from the maintained PSPDEV v20261001 fixture. const observed = `MIPS ABI Flags Version: 0 @@ -16,19 +19,42 @@ FLAGS 1: 00000001 FLAGS 2: 00000000 `; +// Constructed section-table controls combined with the captured ABI declaration; +// this does not establish a real PSPSDK or Allegrex-provider run. +const metadata = `ELF Header: + Class: ELF32 + Data: 2's complement, little endian + Type: EXEC (Executable file) + Machine: MIPS R3000 + Flags: 0x10a23001, noreorder, allegrex, eabi32, mips2 + Number of section headers: 3 +Section Headers: + [Nr] Name Type Addr Off Size ES Flg Lk Inf Al + [ 0] NULL 00000000 000000 000000 00 0 0 0 + [ 1] .MIPS.abiflags MIPS_ABIFLAGS 08800118 000118 000018 18 A 0 0 8 + [ 2] .text PROGBITS 08800130 000130 000010 00 AX 0 0 16 +${observed}`; + it("reads the independent PSP producer's single-float declaration", () => { - assert.deepEqual(parsePspReadelfAbi(observed), { - version: 0, - isaLevel: 2, - isaRevision: 0, - gprSize: 1, - cpr1Size: 1, - cpr2Size: 0, - fpAbi: 2, - isaExtension: 0, - ases: 0, - flags1: 1, - flags2: 0, + assert.deepEqual(parsePspReadelfMips(metadata), { + elfClass: 32, + byteOrder: "little", + type: 2, + flags: 0x10a23001, + gnuAttributes: null, + abiFlags: { + version: 0, + isaLevel: 2, + isaRevision: 0, + gprSize: 1, + cpr1Size: 1, + cpr2Size: 0, + fpAbi: 2, + isaExtension: 0, + ases: 0, + flags1: 1, + flags2: 0, + }, }); }); @@ -46,6 +72,108 @@ it.each([ ])( "rejects unsupported or ambiguous independent ABI output: %s", (before, after) => { - assert.throws(() => parsePspReadelfAbi(observed.replace(before, after))); + assert.throws(() => parsePspReadelfMips(metadata.replace(before, after))); + }, +); + +it.each([ + [ + "missing table", + metadata.replace( + /Section Headers:[\s\S]*?MIPS ABI Flags/u, + "MIPS ABI Flags", + ), + ], + ["incomplete table", metadata.replace(/^.*\[ 2\].*\n/gmu, "")], + ["duplicate index", metadata.replace("[ 2]", "[ 1]")], + [ + "GNU section under another name", + metadata.replace( + ".text PROGBITS", + ".text GNU_ATTRIBUTES", + ), + ], +])("refuses PSP GNU attribute absence from a %s", (_name, text) => { + assert.throws(() => parsePspReadelfMips(text), /section|GNU attributes/u); +}); + +it.each([ + ["Class: ELF32", "Class: ELF64"], + ["little endian", "big endian"], + ["Type: EXEC", "Type: REL"], + ["Flags: 0x10a23001", "Flags: 0x70001001"], + ["Class: ELF32", "Class: ELF32\n Class: ELF32"], +])("refuses contradictory or ambiguous PSP headers: %s", (before, after) => { + assert.throws(() => parsePspReadelfMips(metadata.replace(before, after))); +}); + +// GNU readelf 2.40 independently decoded this declaration from a constructed +// ELF32/PSP-shaped control, not a PSPSDK build or a real Allegrex import. +const gnuDeclaration = `Attribute Section: gnu +File Attributes + Tag_GNU_MIPS_ABI_FP: Hard float (single precision) +`; +const gnuMetadata = metadata.replace( + ".text PROGBITS", + ".text GNU_ATTRIBUTES", +); + +it.each(["before", "after"])( + "retains the independent single-float GNU declaration %s the ABI dump", + (position) => { + const text = + position === "before" + ? gnuMetadata.replace(observed, `${gnuDeclaration}\n${observed}`) + : `${gnuMetadata}\n${gnuDeclaration}`; + assert.deepEqual(parsePspReadelfMips(text), { + ...parsePspReadelfMips(metadata), + gnuAttributes: { fpAbi: 2, msa: null }, + }); + }, +); + +it.each([ + ["unknown vendor", gnuDeclaration.replace("gnu", "aeabi")], + ["wrong float ABI", gnuDeclaration.replace("single", "double")], + ["unknown tag", `${gnuDeclaration} Tag_GNU_Unknown: 1\n`], + [ + "unsupported scope", + gnuDeclaration.replace("File Attributes", "Section Attributes: 1"), + ], + ["duplicate declaration", `${gnuDeclaration}${gnuDeclaration}`], +])("refuses %s rather than inventing PSP GNU facts", (_name, declaration) => { + assert.throws( + () => parsePspReadelfMips(`${gnuMetadata}\n${declaration}`), + /GNU attributes/u, + ); +}); + +it("refuses undeclared GNU attributes rather than reporting absence", () => { + assert.throws( + () => parsePspReadelfMips(`${metadata}\n${gnuDeclaration}`), + /GNU attributes/u, + ); +}); + +it.each([ + ["absent", metadata], + ["declared", `${gnuMetadata}\n${gnuDeclaration}`], +])( + "matches production snapshot serialization for %s GNU facts", + (_name, text) => { + const mips = parsePspReadelfMips(text); + const target = { + path: "/fixture/psp.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "mips", + availableArchitectures: ["mips"], + mips, + } satisfies BinaryTarget; + assert.deepEqual( + expectedMipsElfProfile(mips), + ghidraMipsProfileParameters(target).mips_elf, + ); }, ); diff --git a/tests/conformance/ghidra/psp-fixture.d.mts b/tests/conformance/ghidra/psp-fixture.d.mts index b29757365f..9d4b4329e7 100644 --- a/tests/conformance/ghidra/psp-fixture.d.mts +++ b/tests/conformance/ghidra/psp-fixture.d.mts @@ -1,3 +1,3 @@ -import type { MipsAbiFlags } from "../../../src/domain/binaryTargetTypes.js"; +import type { MipsReadelfMetadata } from "./mips-checks.mjs"; -export declare function parsePspReadelfAbi(text: string): MipsAbiFlags; +export declare function parsePspReadelfMips(text: string): MipsReadelfMetadata; diff --git a/tests/conformance/ghidra/psp-fixture.mjs b/tests/conformance/ghidra/psp-fixture.mjs index db3c25861a..c468621de3 100644 --- a/tests/conformance/ghidra/psp-fixture.mjs +++ b/tests/conformance/ghidra/psp-fixture.mjs @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import { copyFile, readFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; +import { parseMipsReadelfSectionTypes } from "./mips-checks.mjs"; const execute = promisify(execFile); const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -42,15 +43,11 @@ export async function buildPspFixture(directory, globalValue = 7) { assert.equal(bytes.readUInt32LE(36), 0x10a23001); const readelf = await execute( "psp-readelf", - ["-h", "-A", "-sW", path], + ["-h", "-A", "-S", "-sW", path], options, ); assert.equal(readelf.stderr.trim(), ""); - assert.match(readelf.stdout, /Class:\s+ELF32/u); - assert.match(readelf.stdout, /Data:\s+2's complement, little endian/u); - assert.match(readelf.stdout, /Type:\s+EXEC\b/u); - assert.match(readelf.stdout, /Flags:\s+0x10a23001\b/u); - const abiFlags = parsePspReadelfAbi(readelf.stdout); + const mips = parsePspReadelfMips(readelf.stdout); const abiDump = await execute( "psp-readelf", ["-x", ".MIPS.abiflags", path], @@ -99,13 +96,7 @@ export async function buildPspFixture(directory, globalValue = 7) { source_sha256: sourceDigests, symbols, flags: "0x10a23001", - mips: { - elfClass: 32, - byteOrder: "little", - type: 2, - flags: 0x10a23001, - abiFlags, - }, + mips, abi_record_hex_dump: abiDump.stdout, global_value: globalValue, versions, @@ -115,8 +106,8 @@ export async function buildPspFixture(directory, globalValue = 7) { }; } -/** Independent GNU fields for the pinned PSPSDK fixture, not REA's parser. */ -export function parsePspReadelfAbi(text) { +/** Complete independent fields for the pinned PSPSDK fixture, not REA's parser. */ +export function parsePspReadelfMips(text) { const field = (expression, label) => { const matches = [...text.matchAll(expression)]; assert.equal( @@ -126,6 +117,13 @@ export function parsePspReadelfAbi(text) { ); return matches[0][1].trim(); }; + assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), "ELF32"); + assert.equal( + field(/^\s*Data:.*?,\s*(little|big) endian\s*$/gmu, "byte order"), + "little", + ); + assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u); + assert.match(field(/^\s*Flags:\s*(.+)$/gmu, "flags"), /^0x10a23001\b/u); const width = (name) => { const bits = Number( field(new RegExp(`^${name} size:\\s*(\\d+)\\s*$`, "gmu"), name), @@ -178,7 +176,49 @@ export function parsePspReadelfAbi(text) { }, "PSPSDK ABI declaration differs from independently inspected build intent", ); - return abi; + return { + elfClass: 32, + byteOrder: "little", + type: 2, + flags: 0x10a23001, + abiFlags: abi, + gnuAttributes: parsePspGnuAttributes(text), + }; +} + +// The maintained PSP fixture is single-float. Retain that independently decoded +// GNU declaration when present, and refuse other tags/vendors/scopes rather +// than confusing an uninspected or unsupported declaration with absence. +function parsePspGnuAttributes(text) { + const count = parseMipsReadelfSectionTypes(text).filter( + (type) => type === "GNU_ATTRIBUTES", + ).length; + const headers = [...text.matchAll(/^Attribute Section: (.+)$/gmu)]; + if (count === 0) { + assert.equal( + headers.length, + 0, + "GNU attributes without a declared section", + ); + return null; + } + assert.equal(count, 1, "Ambiguous GNU attributes sections"); + assert.equal(headers.length, 1, "Missing or ambiguous GNU attributes output"); + const header = headers[0]; + assert.equal(header[1].trim(), "gnu", "Unsupported GNU attributes vendor"); + const tail = text.slice(header.index + header[0].length); + // GNU attributes may precede or follow the architecture-specific ABI dump. + const end = tail.search(/^MIPS ABI Flags Version:/mu); + const lines = (end < 0 ? tail : tail.slice(0, end)) + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + assert.deepEqual( + lines, + ["File Attributes", "Tag_GNU_MIPS_ABI_FP: Hard float (single precision)"], + "Unsupported or incomplete PSP GNU attributes declaration", + ); + return { fpAbi: 2, msa: null }; } /** Observations from Ghidra, not an echo of the requested profile. */