diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index a35af8453..350b6011f 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -450,6 +450,11 @@ public final class ReaGhidraBridge extends HeadlessScript { String seeds = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO) .getString("REA analysis seeds", null); if (seeds != null) result.add("analysis_seeds", JsonParser.parseString(seeds)); + // Written by ReaGhidraMipsSoftFloat after analysis; REA requires it + // exactly when the committed profile declares soft-float storage. + String softFloat = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO) + .getString("REA MIPS soft-float storage", null); + if (softFloat != null) result.add("mips_soft_float_storage", JsonParser.parseString(softFloat)); return result; } diff --git a/bridge/ghidra/ReaGhidraMipsSoftFloat.java b/bridge/ghidra/ReaGhidraMipsSoftFloat.java new file mode 100644 index 000000000..9d31f5f5b --- /dev/null +++ b/bridge/ghidra/ReaGhidraMipsSoftFloat.java @@ -0,0 +1,176 @@ +import ghidra.app.script.GhidraScript; +import ghidra.program.database.SpecExtension; +import ghidra.program.model.data.AbstractFloatDataType; +import ghidra.program.model.data.DataType; +import ghidra.program.model.data.DoubleDataType; +import ghidra.program.model.data.FloatDataType; +import ghidra.program.model.data.FunctionDefinition; +import ghidra.program.model.data.IntegerDataType; +import ghidra.program.model.data.ParameterDefinition; +import ghidra.program.model.data.TypeDef; +import ghidra.program.model.lang.CompilerSpec; +import ghidra.program.model.lang.PrototypeModel; +import ghidra.program.model.lang.Register; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.Parameter; +import ghidra.program.model.listing.Program; +import ghidra.program.model.listing.VariableStorage; +import ghidra.program.model.pcode.XmlEncode; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.regex.Pattern; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +/** + * Soft-float o32 passes and returns float/double values exactly where same-size integers go. + * Ghidra's MIPS32 compiler specifications model only hard-float o32, so every typed float + * prototype (functions, externals, thunks via their targets, and function-pointer definitions) + * would otherwise read $f12/$f14/$f0. Derive a program-specific prototype model from the + * program's own default o32 model with its floating-point entries and rules removed, install it + * as a specification extension, and assign it to every callable whose formal types use floats. + */ +public final class ReaGhidraMipsSoftFloat extends GhidraScript { + static final String REPORT_KEY = "REA MIPS soft-float storage"; + static final String POLICY = "o32-soft-float-prototype-v1"; + static final String PROTOTYPE = "__rea_soft_float"; + private static final Pattern FPU_REGISTER = Pattern.compile("f\\d+(_\\d+)?"); + + @Override + public void run() throws Exception { + String processor = currentProgram.getLanguage().getProcessor().toString(); + if (!processor.equals("MIPS") || currentProgram.getDefaultPointerSize() != 4) + throw new IllegalStateException( + "REA soft-float storage requires a 32-bit MIPS program, not " + processor); + CompilerSpec spec = currentProgram.getCompilerSpec(); + PrototypeModel base = spec.getDefaultCallingConvention(); + new SpecExtension(currentProgram) + .addReplaceCompilerSpecExtension(softFloatPrototype(base, spec), monitor); + PrototypeModel soft = currentProgram.getCompilerSpec().getCallingConvention(PROTOTYPE); + if (soft == null) + throw new IllegalStateException("REA soft-float prototype was not installed"); + requireIntegerStorage( + soft.getStorageLocations( + currentProgram, + new DataType[] { + FloatDataType.dataType, IntegerDataType.dataType, DoubleDataType.dataType, + FloatDataType.dataType, DoubleDataType.dataType + }, + false), + "soft-float prototype self-check"); + int functions = 0; + List callables = new ArrayList<>(); + currentProgram.getFunctionManager().getFunctions(true).forEach(callables::add); + currentProgram.getFunctionManager().getExternalFunctions().forEach(callables::add); + for (Function function : callables) { + // A thunk's signature is its target's; the target is in this list. + if (function.isThunk() || !usesFloat(function)) continue; + if (function.hasCustomVariableStorage()) function.setCustomVariableStorage(false); + function.setCallingConvention(PROTOTYPE); + List storage = new ArrayList<>(); + storage.add(function.getReturn().getVariableStorage()); + for (Parameter parameter : function.getParameters()) storage.add(parameter.getVariableStorage()); + requireIntegerStorage(storage.toArray(VariableStorage[]::new), function.getName(true)); + functions += 1; + } + int definitions = 0; + Iterator types = currentProgram.getDataTypeManager().getAllDataTypes(); + while (types.hasNext()) { + if (!(types.next() instanceof FunctionDefinition definition) || !usesFloat(definition)) + continue; + definition.setCallingConvention(PROTOTYPE); + definitions += 1; + } + // Written last: any failure above leaves no report, and REA refuses the session. + currentProgram.getOptions(Program.PROGRAM_INFO).setString( + REPORT_KEY, + "{\"policy\":\"" + POLICY + "\",\"functions\":" + functions + + ",\"definitions\":" + definitions + "}"); + println("REA MIPS soft-float prototype: functions=" + functions + " definitions=" + definitions); + } + + /** The default o32 model without floating-point registers or float-consuming rules. */ + private static String softFloatPrototype(PrototypeModel base, CompilerSpec spec) throws Exception { + XmlEncode encoder = new XmlEncode(); + base.encode(encoder, spec.getPcodeInjectLibrary()); + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + Document document = + factory.newDocumentBuilder().parse(new InputSource(new StringReader(encoder.toString()))); + Element root = document.getDocumentElement(); + if (!root.getTagName().equals("prototype")) + throw new IllegalStateException("Unexpected default prototype encoding " + root.getTagName()); + root.setAttribute("name", PROTOTYPE); + List removed = new ArrayList<>(); + collect(root.getElementsByTagName("pentry"), "metatype", "float", null, removed); + collect(root.getElementsByTagName("consume"), "storage", "float", "rule", removed); + if (removed.isEmpty()) + throw new IllegalStateException("Default prototype has no floating-point entries to remove"); + for (Node node : removed) node.getParentNode().removeChild(node); + StringWriter xml = new StringWriter(); + var transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + transformer.transform(new DOMSource(document), new StreamResult(xml)); + return xml.toString(); + } + + /** Select matching elements, or their nearest `ancestor` element when one is named. */ + private static void collect( + NodeList nodes, String attribute, String value, String ancestor, List selected) { + for (int index = 0; index < nodes.getLength(); index += 1) { + Element element = (Element) nodes.item(index); + if (!value.equals(element.getAttribute(attribute))) continue; + Node target = element; + while (ancestor != null && target != null && !ancestor.equals(target.getNodeName())) + target = target.getParentNode(); + if (target != null && !selected.contains(target)) selected.add(target); + } + } + + private static void requireIntegerStorage(VariableStorage[] storage, String owner) { + for (VariableStorage item : storage) { + if (item.isBadStorage() || item.isUnassignedStorage()) + throw new IllegalStateException("REA soft-float storage unresolved for " + owner); + List registers = item.getRegisters(); + if (registers == null) continue; // stack-only storage + for (Register register : registers) { + if (FPU_REGISTER.matcher(register.getName()).matches()) + throw new IllegalStateException( + "REA soft-float storage still uses " + register.getName() + " for " + owner); + } + } + } + + private static boolean usesFloat(Function function) { + if (isFloat(function.getReturn().getFormalDataType())) return true; + for (Parameter parameter : function.getParameters()) { + if (isFloat(parameter.getFormalDataType())) return true; + } + return false; + } + + private static boolean usesFloat(FunctionDefinition definition) { + if (isFloat(definition.getReturnType())) return true; + for (ParameterDefinition argument : definition.getArguments()) { + if (isFloat(argument.getDataType())) return true; + } + return false; + } + + private static boolean isFloat(DataType type) { + DataType current = type; + while (current instanceof TypeDef typeDef) current = typeDef.getBaseDataType(); + return current instanceof AbstractFloatDataType; + } +} diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index 3c5bf0df7..e2150e3fb 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -39,12 +39,14 @@ absent or supply a default ABI. The field layout follows [`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h). -Provider admission requires record version 0, MIPS32r2, 32-bit GPR/CPR1, no CPR2, -and a double-precision or FPXX ABI declaration with no ISA extension, ASE or -unknown general flags. Missing records remain unknown rather than defaulting -to a compatible ABI, and this initial lane refuses them. Unknown record values -are retained for diagnosis but do not gain provider support. These checks bind -the declared interpretation; the integer-only fixture does not establish full +Provider admission requires record version 0, MIPS32r2, 32-bit GPRs, no CPR2, +no ISA extension, ASE or unknown general flags, and one of two floating-point +declarations: 32-bit CPR1 with a double-precision or FPXX ABI, or no CPR1 with +the soft-float ABI. Other combinations, including single-float, FP64 and +unspecified ABIs, are refused. Missing records remain unknown rather than +defaulting to a compatible ABI, and this lane refuses them. Unknown record +values are retained for diagnosis but do not gain provider support. These checks +bind the declared interpretation; the fixtures do not establish full floating-point behavioral conformance. MIPS header and ABI record facts are committed in the analysis profile so @@ -53,6 +55,37 @@ identity is versioned independently of file identity. Evidence, lifecycle outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only. +### Soft-float prototype + +Ghidra's MIPS32 compiler specifications model hard-float o32 only: a typed +`float` or `double` parameter is bound to `$f12`/`$f14` and returned in `$f0`. +Soft-float o32 passes those values in integer registers and stack slots, so a +typed prototype (for example from DWARF) would make the decompiler read FPU +registers the code never writes and drop the real dataflow. That applies to +functions, external functions and indirect calls through typed function +pointers alike. + +For soft-float targets REA commits +`mips_soft_float_storage: o32-soft-float-prototype-v1` in the analysis profile +and runs `ReaGhidraMipsSoftFloat.java` after auto-analysis. The script encodes +the program's own default o32 prototype, removes its floating-point parameter +and return entries and the rules that consume floating-point storage, and +installs the result as the program-specific `__rea_soft_float` prototype through +a Ghidra specification extension. Deriving it keeps the byte-order-specific +aggregate rules and the hidden struct-return pointer. The script then assigns +that prototype, with dynamic storage, to every non-thunk function and external +function, and to every function-pointer definition, whose declared types use +`float` or `double`. Thunks follow their targets. Types are kept. + +The script fails, and writes no report, if the derived prototype still places a +float in an FPU register or any reassigned function does. It records the +function and definition counts last; REA requires that report exactly when the +profile commits the policy and refuses the session otherwise, before serving any +operation. Session Evidence carries the policy and counts as limitations. +Callables without typed float signatures keep Ghidra's default prototype, under +which Ghidra already infers integer-register parameters. Hard-float profiles do +not gain the policy, so their digests are unchanged. + ## Use With a caller-installed Ghidra and compatible JDK on a supported Linux/macOS host: @@ -70,7 +103,8 @@ REA's ELF parser. ## Verification -The source-owned freestanding fixture is `tests/conformance/c/mips.c`. The +The source-owned freestanding fixture is `tests/conformance/c/mips.c`; the +soft-float variant also links `tests/conformance/c/mips-soft-float.c`. The optional cross-target lane requires a caller-supplied Clang with MIPS targets, LLD, GNU `readelf`, Ghidra and its compatible JDK; ordinary host-native checks do not acquire these tools. `REA_MIPS_CLANG` selects Clang and @@ -81,7 +115,8 @@ These are test-time prerequisites, not REA runtime providers. GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra npm run verify:ghidra:mips ``` -The lane compiles both byte orders and never executes the targets. GNU readelf +The lane compiles both byte orders with hard-float and soft-float ABIs and never +executes the targets. GNU readelf independently supplies header/ABI facts and symbol addresses, compared with the production REA target resolver before provider startup. Its version, bounded command output and selected tool path are retained in the verification report. @@ -104,6 +139,12 @@ 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 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 +decompilation receives every source float constant without FPU inputs, +including an indirect call through a typed function pointer; and that the +snapshot profile and Evidence retain the policy and outcome counts. Pseudocode presence is a liveness check, not proof of semantic equivalence. Instruction decoding and reader agreement likewise do not establish runtime, delay-slot execution or complete floating-point semantics. Assertion regressions diff --git a/package.json b/package.json index c674258b8..9ade91edc 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "bridge/ghidra/ReaGhidraApplySeeds.java", "bridge/ghidra/ReaGhidraAnalysisOptions.java", "bridge/ghidra/ReaGhidraNoReturnFix.java", + "bridge/ghidra/ReaGhidraMipsSoftFloat.java", "bridge/ghidra/ReaGhidraPrepareRaw.java", "bridge/android/ReaJadxBridge.java", "bridge/mitmproxy/capture.py", diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index 95d81c4eb..2b6f07c85 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -15,6 +15,7 @@ import { assertMipsLoadedImage, assertMipsProbe, assertMipsGlobal, + assertMipsSoftFloatStorage, } from "../tests/conformance/ghidra/mips-checks.mjs"; import { parseEvidence } from "../dist/domain/evidence.js"; import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; @@ -77,16 +78,64 @@ const env = { for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) { if (process.env[setting] !== undefined) env[setting] = process.env[setting]; } + +/** + * Typed float signatures must use soft-float o32 storage, and callers must see + * the source constants through it rather than FPU inputs. + */ +async function verifySoftFloat(call, symbols, byteOrder) { + const boundaries = {}; + for (const name of [ + "rea_mips_soft_pick", + "rea_mips_soft_wide", + "rea_mips_soft_mixed", + "rea_mips_soft_struct", + ]) { + const api = await call("inspect_native_api", { procedure: symbols[name] }); + boundaries[name] = api.boundary; + } + assertMipsSoftFloatStorage(boundaries, byteOrder); + const caller = functionDossierSchema.parse( + await call("analyze_function", { + procedure: symbols.rea_mips_soft_caller, + }), + ); + // Includes the struct call and the indirect call through a typed pointer. + assert.doesNotMatch(caller.pseudocode, /\bin_f\d+\b/u); + for (const literal of [ + "1.5", + "2.5", + "3.25", + "4.75", + "5.5", + "6.125", + "7.5", + "8.5", + "9.5", + ]) + assert.ok( + caller.pseudocode.includes(literal), + `Soft-float caller lost argument ${literal}`, + ); + return { storage: boundaries }; +} const reports = []; let primaryFailure; try { - for (const byteOrder of psp ? ["little"] : ["little", "big"]) { + for (const [byteOrder, floatAbi] of psp + ? [["little", "hard"]] + : [ + ["little", "hard"], + ["big", "hard"], + ["little", "soft"], + ["big", "soft"], + ]) { const fixture = psp ? await buildPspFixture(workspace) - : await buildMipsFixture(workspace, byteOrder); + : await buildMipsFixture(workspace, byteOrder, floatAbi); const independent = psp ? fixture - : await inspectMipsReadelf(fixture.path, byteOrder); + : await inspectMipsReadelf(fixture.path, byteOrder, floatAbi); const resolved = await parseBinaryTarget(fixture.path); if (!resolved.ok) throw resolved.error; if (psp) { @@ -104,7 +153,10 @@ try { ); } assert.equal(resolved.value.sha256, fixture.sha256); - const snapshotPath = join(workspace, `${byteOrder}.snapshot.json`); + const snapshotPath = join( + workspace, + `${byteOrder}-${floatAbi}.snapshot.json`, + ); const transport = new StdioClientTransport({ command: process.execPath, args: [entrypoint, "mcp"], @@ -238,6 +290,10 @@ try { callees.includes(leaf.address), "Expected direct call not recovered", ); + const softFloat = + floatAbi === "soft" + ? await verifySoftFloat(call, independent.symbols, byteOrder) + : undefined; const assembly = await call("procedure_assembly", { procedure: entry.address, }); @@ -293,6 +349,19 @@ try { }, "Snapshot profile did not retain the independently inspected ELF/ABI declaration", ); + assert.equal( + snapshot.binding.analysis_profile.parameters.mips_soft_float_storage, + floatAbi === "soft" ? "o32-soft-float-prototype-v1" : undefined, + ); + assert.equal( + snapshot.evidence_bundle.records.some((record) => + /Soft-float prototype outcome: 4 functions and [1-9]\d* function-pointer definitions use __rea_soft_float\./u.test( + JSON.stringify(record), + ), + ), + floatAbi === "soft", + "Soft-float prototype outcome missing from, or unexpected in, retained Evidence", + ); assert.ok(snapshot.evidence_bundle.records.length > 0); if (psp) { assert.equal( @@ -419,7 +488,10 @@ try { assert.ok(read32(offset + 4) + read32(offset + 16) <= cut); } const carvedBytes = sourceBytes.subarray(0, cut); - const carvedPath = join(workspace, `${byteOrder}-carved.elf`); + const carvedPath = join( + workspace, + `${byteOrder}-${floatAbi}-carved.elf`, + ); const carvedSha256 = createHash("sha256") .update(carvedBytes) .digest("hex"); @@ -460,7 +532,7 @@ try { assert.ok(carvedDossier.pseudocode.trim().length > 0); const carvedSnapshotPath = join( workspace, - `${byteOrder}-carved.snapshot.json`, + `${byteOrder}-${floatAbi}-carved.snapshot.json`, ); await call("close_binary", { snapshot_path: carvedSnapshotPath }); const carvedSnapshot = parseAnalysisSnapshot( @@ -523,7 +595,11 @@ try { : "conditional branch destination", "global word and marker bytes", "CLI/MCP and snapshot identity", + ...(floatAbi === "soft" + ? ["soft-float o32 parameter/return storage and caller constants"] + : []), ], + ...(softFloat === undefined ? {} : { soft_float: softFloat }), procedures: procedures.length, carved: carved ?? { status: "not_run", @@ -534,7 +610,7 @@ try { }); } catch (cause) { throw new Error( - `MIPS ${byteOrder} verification failed; MCP stderr: ${stderr}`, + `MIPS ${byteOrder} ${floatAbi}-float verification failed; MCP stderr: ${stderr}`, { cause }, ); } finally { diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 94c95d6a6..83f9f2405 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -114,6 +114,8 @@ export interface GhidraHeadlessLauncherOptions { readonly analysisSeeds?: GhidraSeedCommitment; readonly analysisExtensions?: readonly GhidraExtension[]; readonly pspExtension?: GhidraPspExtension; + /** Remap typed float storage for the soft-float o32 ABI after analysis. */ + readonly mipsSoftFloat?: true; /** Spawn seam for provider-boundary lifecycle tests. */ readonly spawnProcess?: typeof spawnOwnedProviderProcess; } @@ -196,6 +198,9 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { const headlessArguments = ghidraHeadlessArguments({ platform, ...(psp === undefined ? {} : { psp: true as const }), + ...(this.options.mipsSoftFloat === undefined + ? {} + : { mipsSoftFloat: this.options.mipsSoftFloat }), projectRoot: paths.projectRoot, targetPath: session.targetPath, bridgeScriptPath: this.options.bridgeScriptPath, @@ -422,6 +427,7 @@ export interface GhidraHeadlessArgumentOptions { readonly ghidraLogPath: string; readonly scriptLogPath: string; readonly psp?: true; + readonly mipsSoftFloat?: true; readonly dosMz?: true; readonly dosCom?: true; readonly rawBinary?: { @@ -460,6 +466,16 @@ export const ghidraHeadlessArguments = ( throw new Error( "A PSP ELF import cannot be combined with a raw-binary import.", ); + if ( + options.mipsSoftFloat === true && + (options.psp === true || + options.dosMz === true || + options.dosCom === true || + rawBinary !== undefined) + ) + throw new Error( + "MIPS soft-float storage applies only to a generic MIPS ELF import.", + ); if ( override !== undefined && (options.dosMz === true || options.dosCom === true) @@ -578,6 +594,13 @@ export const ghidraHeadlessArguments = ( "-postScript", scriptPath.join(bridgeDirectory, "ReaGhidraNoReturnFix.java"), ]), + // After analysis and the no-return refit, before the bridge serves results. + ...(options.mipsSoftFloat === true + ? [ + "-postScript", + scriptPath.join(bridgeDirectory, "ReaGhidraMipsSoftFloat.java"), + ] + : []), "-postScript", // Ghidra checks the caller's cwd before scriptPath for a basename. Select // the packaged source explicitly so unrelated entries cannot shadow it. diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index c11b57475..6de175a1f 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { ok } from "../domain/result.js"; import { parseConfig } from "../config/parseConfig.js"; import type { BinaryTarget, @@ -10,6 +11,8 @@ import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js"; import { IdaProvider } from "../ida/IdaProvider.js"; import { GhidraProvider } from "./GhidraProvider.js"; import type { GhidraInstallationHost } from "./GhidraInstallation.js"; +import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js"; +import type { GhidraMipsSoftFloatReport } from "./GhidraMipsProfile.js"; // Fake installation inspection tests admission/profile contracts, not Ghidra. const host: GhidraInstallationHost = { @@ -56,6 +59,18 @@ const target = (change: Partial = {}): BinaryTarget => ({ ...change, }, }); +const abi = ( + change: Partial>, +): Partial => { + const base = target(); + if (base.kind !== "executable" || base.architecture !== "mips") + throw new Error("expected a MIPS fixture target"); + const flags = base.mips.abiFlags; + if (flags === undefined || flags === null) + throw new Error("expected fixture ABI flags"); + return { abiFlags: { ...flags, ...change } }; +}; +const softFloat = abi({ cpr1Size: 0, fpAbi: 3 }); describe("bounded Ghidra MIPS admission", () => { it("binds endian and flags without widening Windows P0", async () => { @@ -118,6 +133,11 @@ describe("bounded Ghidra MIPS admission", () => { [{ flags: 0x70001201 }, "EF_MIPS_FP64"], [{ flags: 0x70001401 }, "EF_MIPS_NAN2008"], [{ abiFlags: null }, "inspected ABI"], + [abi({ cpr1Size: 0, fpAbi: 1 }), "soft-float without CPR1"], + [abi({ cpr1Size: 1, fpAbi: 3 }), "soft-float without CPR1"], + [abi({ cpr1Size: 1, fpAbi: 2 }), "single-float"], + [abi({ cpr1Size: 1, fpAbi: 0 }), "unspecified"], + [abi({ cpr1Size: 2, fpAbi: 6 }), "FP64"], ] satisfies [Partial, string][])( "refuses an unverified MIPS interpretation %j", async (change, reason) => { @@ -140,3 +160,136 @@ describe("bounded Ghidra MIPS admission", () => { expect(ida.inspectTargetSupport(target()).status).toBe("unsupported"); }); }); + +describe("soft-float MIPS storage", () => { + const report: GhidraMipsSoftFloatReport = { + policy: "o32-soft-float-prototype-v1", + functions: 5, + definitions: 1, + }; + const session = async ( + value: BinaryTarget, + reported: GhidraMipsSoftFloatReport | undefined, + profileFor: BinaryTarget = value, + ) => { + const counts = { starts: 0, calls: 0, closes: 0 }; + const ghidra = new GhidraProvider(config, silentLogger, {}, host, () => ({ + start: () => { + counts.starts++; + return Promise.resolve( + ok({ + name: "REA Ghidra bridge" as const, + run_id: "11111111-1111-4111-8111-111111111111", + profile_digest: "a".repeat(64), + provider: { id: "ghidra" as const, version: "12.1.4" }, + read_only: false, + analysis_complete: true, + analysis_timed_out: false, + capabilities: [...GHIDRA_SESSION_CAPABILITIES], + target: { + name: "fixture.elf", + language_id: "MIPS:LE:32:default", + compiler_spec_id: "default", + image_base: "0x0", + default_address_space: "ram", + sha256: "a".repeat(64), + }, + ...(reported === undefined + ? {} + : { mips_soft_float_storage: reported }), + }), + ); + }, + callTool: () => { + counts.calls++; + return Promise.resolve(ok(null)); + }, + close: () => { + counts.closes++; + return Promise.resolve(ok(null)); + }, + })); + const resolved = await ghidra.resolveAnalysisProfile(profileFor); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a MIPS profile"); + return { + counts, + profile: resolved.value.profile, + client: ghidra.createClient(value, resolved.value.profile), + }; + }; + + it("admits soft-float o32 and commits its storage policy only for it", async () => { + const soft = target(softFloat); + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + expect(ghidra.inspectTargetSupport(soft).status).toBe("supported"); + const { profile } = await session(soft, report); + expect(profile.parameters.mips_soft_float_storage).toBe( + "o32-soft-float-prototype-v1", + ); + const hard = await session(target(), undefined); + expect(hard.profile.parameters).not.toHaveProperty( + "mips_soft_float_storage", + ); + }); + + it("admits the ODDSPREG default that soft-float toolchains emit, but no other flags", () => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + // GCC 5.2 kernel modules and LLVM/Rust firmware both record flags1 = 1. + expect( + ghidra.inspectTargetSupport( + target(abi({ cpr1Size: 0, fpAbi: 3, flags1: 1 })), + ).status, + ).toBe("supported"); + expect( + ghidra.inspectTargetSupport( + target(abi({ cpr1Size: 0, fpAbi: 3, flags1: 2 })), + ), + ).toMatchObject({ + status: "unsupported", + reason: expect.stringContaining("general flags"), + }); + }); + + it("reports the prototype outcome as session limitations", async () => { + const { client } = await session(target(softFloat), report); + const health = await client.execute("health", {}); + if (!health.ok) throw health.error; + expect(health.value.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("model hard-float o32 only"), + expect.stringContaining( + "5 functions and 1 function-pointer definitions use __rea_soft_float.", + ), + ]), + ); + }); + + it("fails closed on a soft-float session without a storage report before any analysis call", async () => { + const { counts, client } = await session(target(softFloat), undefined); + const failed = await client.execute("list_procedures", {}); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError"); + expect(counts).toEqual({ starts: 1, calls: 0, closes: 1 }); + }); + + it("rejects soft-float storage reported by a hard-float session", async () => { + const { counts, client } = await session(target(), report); + const failed = await client.execute("health", {}); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError"); + expect(counts).toEqual({ starts: 1, calls: 0, closes: 1 }); + }); + + it("refuses a profile whose storage policy disagrees with the target", async () => { + for (const [value, profileFor] of [ + [target(softFloat), target()], + [target(), target(softFloat)], + ] as const) { + const { counts, client } = await session(value, report, profileFor); + const failed = await client.execute("health", {}); + expect(failed.ok).toBe(false); + expect(counts.starts).toBe(0); + } + }); +}); diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index 0df6591a4..3604da691 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -1,6 +1,8 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; +const MIPS_FP_ABI_SOFT = 3; + /** Keep family recognition separate from the deliberately bounded Ghidra lane. */ export const ghidraMipsUnsupportedReason = ( target: BinaryTarget, @@ -39,17 +41,66 @@ export const ghidraMipsUnsupportedReason = ( return "This Ghidra MIPS lane requires ABI flags version 0; newer record semantics are unsupported."; if (abi.isaLevel !== 32 || abi.isaRevision !== 2) return "MIPS ABI flags must agree with the declared MIPS32r2 ISA."; - if (abi.gprSize !== 1 || abi.cpr1Size !== 1 || abi.cpr2Size !== 0) - return "This Ghidra MIPS lane requires 32-bit GPR/CPR1 and no CPR2; other register modes need separate verification."; - if (abi.fpAbi !== 1 && abi.fpAbi !== 5) - return "This Ghidra MIPS lane admits double-precision or FPXX ABI declarations only; soft-float, FP64 and unspecified modes need separate verification."; + if (abi.gprSize !== 1 || abi.cpr2Size !== 0) + return "This Ghidra MIPS lane requires 32-bit GPRs and no CPR2; other register modes need separate verification."; + const hardFloat = abi.cpr1Size === 1 && (abi.fpAbi === 1 || abi.fpAbi === 5); + const softFloat = abi.cpr1Size === 0 && abi.fpAbi === MIPS_FP_ABI_SOFT; + if (!hardFloat && !softFloat) + return "This Ghidra MIPS lane admits double-precision or FPXX ABI declarations with 32-bit CPR1, or soft-float without CPR1; single-float, FP64 and unspecified modes need separate verification."; if (abi.isaExtension !== 0 || abi.ases !== 0) return "MIPS ABI flags declare an ISA extension or ASE outside this standard-instruction lane."; + // MIPS_AFL_FLAGS1_ODDSPREG (bit 0) is the GCC and LLVM default for MIPS32 o32 + // whatever the FP ABI; soft-float objects from both carry it. With no CPR1 it + // selects nothing, so it is accepted for soft-float as well as hard-float. if ((abi.flags1 & ~1) !== 0 || abi.flags2 !== 0) return "MIPS ABI flags contain unsupported general flags."; return null; }; +/** Soft-float prototype policy applied by ReaGhidraMipsSoftFloat.java after analysis. */ +export const GHIDRA_MIPS_SOFT_FLOAT_STORAGE = "o32-soft-float-prototype-v1"; + +/** Whether the admitted target declares the soft-float o32 ABI. */ +export const isGhidraMipsSoftFloatTarget = (target: BinaryTarget): boolean => + target.kind === "executable" && + target.architecture === "mips" && + target.mips?.abiFlags?.cpr1Size === 0 && + target.mips.abiFlags.fpAbi === MIPS_FP_ABI_SOFT; + +/** Prototype outcome the post-analysis script records for the bridge handshake. */ +export interface GhidraMipsSoftFloatReport { + readonly policy: typeof GHIDRA_MIPS_SOFT_FLOAT_STORAGE; + readonly functions: number; + readonly definitions: number; +} + +/** Fail closed unless soft-float storage ran exactly for soft-float targets. */ +export const ghidraMipsSoftFloatFailure = ( + expected: boolean, + report: GhidraMipsSoftFloatReport | undefined, +): string | undefined => + expected === (report !== undefined) + ? undefined + : expected + ? "The Ghidra session did not report the committed MIPS soft-float prototype." + : "The Ghidra session reported MIPS soft-float storage that the profile did not commit."; + +/** Keep REA's soft-float prototype distinct from Ghidra's hard-float o32 model. */ +export const ghidraMipsSoftFloatLimitations = ( + expected: boolean, + report?: GhidraMipsSoftFloatReport, +): readonly string[] => + expected + ? [ + "Soft-float o32: Ghidra's MIPS32 compiler specifications model hard-float o32 only. After analysis REA installs a program-specific `__rea_soft_float` prototype, derived from the default o32 model without its floating-point registers and rules, and assigns it to every function, external function and function-pointer definition whose declared types use float or double, so those values keep their types and use integer registers and stack slots.", + ...(report === undefined + ? [] + : [ + `Soft-float prototype outcome: ${report.functions} functions and ${report.definitions} function-pointer definitions use __rea_soft_float.`, + ]), + ] + : []; + /** Commit source ELF interpretation without confusing target ISA with host CPU. */ export const ghidraMipsProfileParameters = ( target: BinaryTarget, @@ -87,6 +138,9 @@ export const ghidraMipsProfileParameters = ( flags2: abi.flags2, }, }, + ...(isGhidraMipsSoftFloatTarget(target) + ? { mips_soft_float_storage: GHIDRA_MIPS_SOFT_FLOAT_STORAGE } + : {}), mips_support_lane: "elf32-exec-o32-arch32r2-standard-v3", }; }; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 29a5d28e7..d5c0b24b6 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -56,6 +56,12 @@ import { GHIDRA_PSP_LIMITATIONS, } from "./GhidraPspProfile.js"; import { ghidraPspExtensionSchema } from "./GhidraPspExtension.js"; +import { + GHIDRA_MIPS_SOFT_FLOAT_STORAGE, + ghidraMipsSoftFloatFailure, + ghidraMipsSoftFloatLimitations, + isGhidraMipsSoftFloatTarget, +} from "./GhidraMipsProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; @@ -386,6 +392,20 @@ export const createGhidraProviderClient = (input: { }, }), ); + const softFloat = isGhidraMipsSoftFloatTarget(target); + if ( + softFloat !== + (committedProfile.parameters.mips_soft_float_storage === + GHIDRA_MIPS_SOFT_FLOAT_STORAGE) + ) + return unavailableClient( + new ProviderAdapterError("ghidra", "open_binary", { + diagnostics: { + reason: + "Resolve the soft-float MIPS storage policy into the analysis profile before opening the session.", + }, + }), + ); const extensionProfile = ghidraExtensionSchema .array() .safeParse(committedProfile.parameters.analysis_extensions ?? []); @@ -465,6 +485,10 @@ export const createGhidraProviderClient = (input: { let extensionFailure: AnalysisError | undefined; // Replaced by the reported outcome once the session handshake is checked. const seedLimitations: string[] = [...ghidraSeedLimitations(seeds)]; + // Replaced by the reported storage outcome once the handshake is checked. + const softFloatLimitations: string[] = [ + ...ghidraMipsSoftFloatLimitations(softFloat), + ]; const rawLanguage = target.format === "raw-binary" ? ghidraRawBinaryLanguage(target.architecture) @@ -508,6 +532,7 @@ export const createGhidraProviderClient = (input: { ...(pspExtension?.success === true ? { pspExtension: pspExtension.data } : {}), + ...(softFloat ? { mipsSoftFloat: true as const } : {}), ...(target.format === "dos-mz" ? { dosMz: true } : {}), ...(target.format === "dos-com" ? { dosCom: true } : {}), ...(rawLanguage === undefined || target.format !== "raw-binary" @@ -569,18 +594,37 @@ export const createGhidraProviderClient = (input: { operation: AnalysisOperation, info: GhidraSessionInfo, ): Promise => { + const softFloatReason = ghidraMipsSoftFloatFailure( + softFloat, + info.mips_soft_float_storage, + ); extensionFailure = ghidraExtensionFailure( extensions, info.analysis_extensions ?? [], operation, - ) ?? ghidraSeedFailure(seeds, info.analysis_seeds, operation); + ) ?? + ghidraSeedFailure(seeds, info.analysis_seeds, operation) ?? + (softFloatReason === undefined + ? undefined + : new ProviderAdapterError("ghidra", operation, { + diagnostics: { reason: softFloatReason }, + })); if (extensionFailure === undefined && info.analysis_seeds !== undefined) seedLimitations.splice( 0, seedLimitations.length, ...ghidraSeedLimitations(seeds, info.analysis_seeds), ); + if (extensionFailure === undefined) + softFloatLimitations.splice( + 0, + softFloatLimitations.length, + ...ghidraMipsSoftFloatLimitations( + softFloat, + info.mips_soft_float_storage, + ), + ); if (extensionFailure === undefined) return undefined; const closed = await client.close(); return closed.ok @@ -598,6 +642,7 @@ export const createGhidraProviderClient = (input: { ...providerLimitations, ...targetLimitations, ...(psp ? GHIDRA_PSP_LIMITATIONS : []), + ...softFloatLimitations, ...ghidraExtensionLimitations(extensions), ...seedLimitations, ...(releaseLimitation === undefined ? [] : [releaseLimitation]), @@ -648,7 +693,7 @@ export const createGhidraProviderClient = (input: { ? parseGhidraFunctionInput(operation, parameters) : parseGhidraInventoryInput(operation, parameters); if (!input.ok) return input; - if (extensions.length > 0 || seeds !== undefined) { + if (extensions.length > 0 || seeds !== undefined || softFloat) { const started = await client.start(options?.signal, options?.progress); if (!started.ok) return err( diff --git a/src/ghidra/GhidraSessionValues.test.ts b/src/ghidra/GhidraSessionValues.test.ts index 25a864f8d..a0430f35e 100644 --- a/src/ghidra/GhidraSessionValues.test.ts +++ b/src/ghidra/GhidraSessionValues.test.ts @@ -52,6 +52,40 @@ describe("Ghidra DOS import commitment", () => { }); }); +describe("Ghidra MIPS soft-float storage report", () => { + const valid = { + policy: "o32-soft-float-prototype-v1", + functions: 2, + definitions: 1, + }; + it("accepts the exact committed policy outcome", () => { + const parsed = parseGhidraSessionInfo( + { ...session(), mips_soft_float_storage: valid }, + expected, + ); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.mips_soft_float_storage).toEqual(valid); + }); + it.each([ + [ + "the superseded storage policy", + { policy: "o32-integer-registers-v1", remapped: 2, retyped: 1 }, + ], + ["a negative count", { ...valid, definitions: -1 }], + ["a fractional count", { ...valid, functions: 1.5 }], + ["a missing count", { policy: valid.policy, functions: 2 }], + ["an undeclared field", { ...valid, skipped: 0 }], + ["a non-object report", "functions"], + ])("rejects %s", (_label, report) => { + expect( + parseGhidraSessionInfo( + { ...session(), mips_soft_float_storage: report }, + expected, + ).ok, + ).toBe(false); + }); +}); + describe("Ghidra mutation handshake", () => { it("requires the exact mutation authority and rejects duplicate capabilities", () => { const value = session(); diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index 40487ead9..0ee2a6bf1 100644 --- a/src/ghidra/GhidraSessionValues.ts +++ b/src/ghidra/GhidraSessionValues.ts @@ -9,6 +9,7 @@ import { } from "./GhidraInventoryValues.js"; import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionValues.js"; import { ghidraExtensionResultSchema } from "./extensions/GhidraExtensions.js"; +import { GHIDRA_MIPS_SOFT_FLOAT_STORAGE } from "./GhidraMipsProfile.js"; /** Exact methods proved by the bridge handshake after auto-analysis. */ export const GHIDRA_SESSION_CAPABILITIES = [ @@ -42,6 +43,14 @@ const sessionInfoSchema = z analysis_timed_out: z.boolean(), analysis_extensions: z.array(ghidraExtensionResultSchema).optional(), analysis_seeds: ghidraSeedReportSchema.optional(), + mips_soft_float_storage: z + .object({ + policy: z.literal(GHIDRA_MIPS_SOFT_FLOAT_STORAGE), + functions: z.number().int().nonnegative(), + definitions: z.number().int().nonnegative(), + }) + .strict() + .optional(), capabilities: z.array(capabilitySchema), target: z .object({ diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index 7c2e0efb2..270bf02b3 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -191,6 +191,45 @@ describe("Ghidra headless launcher", () => { expect(arguments_).toContain("-readOnly"); expect(arguments_).toContain("-deleteProject"); }); + it("remaps soft-float storage after the no-return fix and before the bridge", () => { + const base = { + platform: "linux" as const, + projectRoot: "/tmp/project", + targetPath: "/tmp/target", + bridgeScriptPath: "/package/bridge/ReaGhidraBridge.java", + descriptorPath: "/tmp/session.json", + ghidraLogPath: "/tmp/ghidra.log", + scriptLogPath: "/tmp/script.log", + }; + const soft = "/package/bridge/ReaGhidraMipsSoftFloat.java"; + expect(ghidraHeadlessArguments(base)).not.toContain(soft); + const arguments_ = ghidraHeadlessArguments({ + ...base, + mipsSoftFloat: true, + }); + expect(arguments_[arguments_.indexOf(soft) - 1]).toBe("-postScript"); + expect( + arguments_.indexOf("/package/bridge/ReaGhidraNoReturnFix.java"), + ).toBeLessThan(arguments_.indexOf(soft)); + expect(arguments_.indexOf(soft)).toBeLessThan( + arguments_.indexOf("/package/bridge/ReaGhidraBridge.java"), + ); + for (const conflict of [ + { psp: true as const }, + { dosMz: true as const }, + { dosCom: true as const }, + { + rawBinary: { + languageId: "x86:LE:32:default", + compilerSpecId: "default", + baseAddr: "0x1000", + }, + }, + ]) + expect(() => + ghidraHeadlessArguments({ ...base, mipsSoftFloat: true, ...conflict }), + ).toThrow(/generic MIPS ELF/u); + }); }); describe("Ghidra configured import language and seeds", () => { diff --git a/tests/conformance/c/mips-soft-float.c b/tests/conformance/c/mips-soft-float.c new file mode 100644 index 000000000..7d579473c --- /dev/null +++ b/tests/conformance/c/mips-soft-float.c @@ -0,0 +1,53 @@ +/* Soft-float o32 storage fixture; linked with mips.c, never executed by REA. + * Without an FPU, float/double values travel in integer registers and stack + * slots: pick uses $a0/$a1/$a2 and returns in $v0, wide uses the $a0:$a1 pair, + * and mixed aligns d to $a2:$a3 so f and e spill to the stack at 16 and 24. + * structured returns through a hidden $a0 pointer, moving scale to $a1. The + * typed function pointer makes the caller's indirect call use a prototype that + * belongs to no function. No arithmetic, so no compiler-rt helpers are needed. + */ +volatile unsigned int rea_mips_soft_sink; + +__attribute__((noinline)) float rea_mips_soft_pick(float a, float b, int sel) +{ + return sel ? a : b; +} + +__attribute__((noinline)) double rea_mips_soft_wide(double a, int sel) +{ + return sel ? a : 0.5; +} + +__attribute__((noinline)) double rea_mips_soft_mixed(int i, double d, float f, double e) +{ + rea_mips_soft_sink = *(volatile unsigned int *)&f; + return i ? d : e; +} + +struct rea_mips_soft_pair { + double first; + double second; +}; + +__attribute__((noinline)) struct rea_mips_soft_pair rea_mips_soft_struct(float scale, int sel) +{ + struct rea_mips_soft_pair pair = { 0.25, 0.75 }; + rea_mips_soft_sink = *(volatile unsigned int *)&scale; + if (sel) + pair.first = 1.25; + return pair; +} + +/* Volatile so the indirect call through this typed pointer is not folded. */ +float (*volatile rea_mips_soft_callback)(float, float, int) = rea_mips_soft_pick; + +__attribute__((noinline)) unsigned int rea_mips_soft_caller(int sel) +{ + float picked = rea_mips_soft_pick(1.5f, 2.5f, sel); + double wide = rea_mips_soft_wide(3.25, sel); + double mixed = rea_mips_soft_mixed(sel, 4.75, 5.5f, 6.125); + struct rea_mips_soft_pair pair = rea_mips_soft_struct(7.5f, sel); + float called = rea_mips_soft_callback(8.5f, 9.5f, sel); + return *(unsigned int *)&picked ^ *(unsigned int *)&wide ^ *(unsigned int *)&mixed ^ + *(unsigned int *)&pair.first ^ *(unsigned int *)&called; +} diff --git a/tests/conformance/ghidra/mips-checks.d.mts b/tests/conformance/ghidra/mips-checks.d.mts index fb0fc459e..e3a809663 100644 --- a/tests/conformance/ghidra/mips-checks.d.mts +++ b/tests/conformance/ghidra/mips-checks.d.mts @@ -1,23 +1,19 @@ import type { MipsElfMetadata } from "../../../src/domain/binaryTargetTypes.js"; +export type MipsFloatAbi = "hard" | "soft"; export interface MipsReadelfFacts { mips: MipsElfMetadata; - symbols: Record< - | "rea_mips_entry" - | "rea_mips_leaf" - | "rea_mips_probe" - | "rea_mips_global" - | "rea_mips_marker", - string - >; + symbols: Record; } export function parseMipsReadelf( text: string, byteOrder: string, + floatAbi?: MipsFloatAbi, ): MipsReadelfFacts; export function inspectMipsReadelf( path: string, byteOrder: string, + floatAbi?: MipsFloatAbi, ): Promise< MipsReadelfFacts & { command: string; version: string; raw: string } >; @@ -33,3 +29,7 @@ export function assertMipsProbe( address: string, ): void; export function assertMipsGlobal(bytes: string, byteOrder: string): void; +export function assertMipsSoftFloatStorage( + boundaries: Record, + byteOrder: string, +): void; diff --git a/tests/conformance/ghidra/mips-checks.mjs b/tests/conformance/ghidra/mips-checks.mjs index 48a16e118..20b755788 100644 --- a/tests/conformance/ghidra/mips-checks.mjs +++ b/tests/conformance/ghidra/mips-checks.mjs @@ -15,8 +15,17 @@ const wordHex = (value, byteOrder) => { return bytes.toString("hex"); }; +const SOFT_FLOAT_SYMBOLS = [ + "rea_mips_soft_pick", + "rea_mips_soft_wide", + "rea_mips_soft_mixed", + "rea_mips_soft_struct", + "rea_mips_soft_caller", +]; + /** Parse only the documented GNU readelf fields needed by our tiny fixture. */ -export function parseMipsReadelf(text, byteOrder) { +export function parseMipsReadelf(text, byteOrder, floatAbi = "hard") { + assert.ok(floatAbi === "hard" || floatAbi === "soft"); language(byteOrder); const field = (expression, label) => { const matches = [...text.matchAll(expression)]; @@ -44,8 +53,14 @@ export function parseMipsReadelf(text, byteOrder) { const fpAbi = new Map([ ["Hard float (double precision)", 1], ["Hard float (32-bit CPU, Any FPU)", 5], + ["Soft float", 3], ]).get(fp); assert.ok(fpAbi !== undefined, `Unrecognized fixture FP ABI: ${fp}`); + assert.equal( + fpAbi === 3, + floatAbi === "soft", + `Fixture FP ABI ${fp} differs from the ${floatAbi}-float build intent`, + ); assert.equal(field(/^ISA Extension:\s*(.+)$/gmu, "ISA extension"), "None"); assert.equal(field(/^ASEs:\s*\n[ \t]*(.+)$/gmu, "ASEs"), "None"); const abiFlags = { @@ -76,6 +91,7 @@ export function parseMipsReadelf(text, byteOrder) { "rea_mips_probe", "rea_mips_global", "rea_mips_marker", + ...(floatAbi === "soft" ? SOFT_FLOAT_SYMBOLS : []), ]) { const value = field( new RegExp( @@ -93,7 +109,7 @@ export function parseMipsReadelf(text, byteOrder) { } /** Independent reader is a bounded test prerequisite, never an REA provider. */ -export async function inspectMipsReadelf(path, byteOrder) { +export async function inspectMipsReadelf(path, byteOrder, floatAbi = "hard") { const command = process.env.REA_MIPS_READELF ?? "readelf"; const options = { env: { ...process.env, LC_ALL: "C" }, @@ -117,7 +133,7 @@ export async function inspectMipsReadelf(path, byteOrder) { `readelf diagnostics: ${result.stderr}`, ); return { - ...parseMipsReadelf(result.stdout, byteOrder), + ...parseMipsReadelf(result.stdout, byteOrder, floatAbi), command, version: version.stdout.split("\n")[0], raw: result.stdout, @@ -178,3 +194,67 @@ export function assertMipsProbe(move, branch, byteOrder, address) { export function assertMipsGlobal(bytes, byteOrder) { assert.equal(bytes, wordHex(7, byteOrder)); } + +/** + * Soft-float o32 places float/double values exactly where a same-size integer + * goes: Ghidra joins register pairs most-significant word first. The struct + * return keeps its hidden $a0 pointer and its float in $a1. + */ +export function assertMipsSoftFloatStorage(boundaries, byteOrder) { + language(byteOrder); + const pair = (low, high) => + byteOrder === "little" ? `${high}:4,${low}:4` : `${low}:4,${high}:4`; + const expected = { + rea_mips_soft_pick: { + return: ["float", "v0:4"], + parameters: [ + ["a", "float", "a0:4"], + ["b", "float", "a1:4"], + ["sel", "int", "a2:4"], + ], + }, + rea_mips_soft_wide: { + return: ["double", pair("v0", "v1")], + parameters: [ + ["a", "double", pair("a0", "a1")], + ["sel", "int", "a2:4"], + ], + }, + rea_mips_soft_mixed: { + return: ["double", pair("v0", "v1")], + parameters: [ + ["i", "int", "a0:4"], + ["d", "double", pair("a2", "a3")], + ["f", "float", "Stack[0x10]:4"], + ["e", "double", "Stack[0x18]:8"], + ], + }, + rea_mips_soft_struct: { + return: ["rea_mips_soft_pair *", "v0:4"], + parameters: [ + ["__return_storage_ptr__", "rea_mips_soft_pair *", "a0:4 (auto)"], + ["scale", "float", "a1:4"], + ["sel", "int", "a2:4"], + ], + }, + }; + assert.deepEqual( + Object.keys(boundaries).sort(), + Object.keys(expected).sort(), + ); + for (const [name, want] of Object.entries(expected)) { + const boundary = boundaries[name]; + assert.deepEqual( + [boundary.return_type.data_type, boundary.return_type.storage], + want.return, + `${name} return storage`, + ); + assert.deepEqual( + [...boundary.parameters] + .sort((left, right) => left.ordinal - right.ordinal) + .map((item) => [item.name, item.data_type, item.storage]), + want.parameters, + `${name} parameter storage`, + ); + } +} diff --git a/tests/conformance/ghidra/mips-checks.test.ts b/tests/conformance/ghidra/mips-checks.test.ts index 778560ba8..7060c32d9 100644 --- a/tests/conformance/ghidra/mips-checks.test.ts +++ b/tests/conformance/ghidra/mips-checks.test.ts @@ -3,6 +3,7 @@ import { assertMipsGlobal, assertMipsLoadedImage, assertMipsProbe, + assertMipsSoftFloatStorage, parseMipsReadelf, } from "./mips-checks.mjs"; @@ -227,3 +228,117 @@ describe("MIPS real-verifier independent expectations", () => { expect(() => assertMipsGlobal("0700", "little")).toThrow(); }); }); + +describe("MIPS soft-float real-verifier expectations", () => { + const softReadelf = readelf + .replace("CPR1 size: 32", "CPR1 size: 0") + .replace("Hard float (32-bit CPU, Any FPU)", "Soft float") + .replace( + " 8: 00020240 32 FUNC GLOBAL DEFAULT 4 rea_mips_probe\n", + [ + " 8: 00020240 32 FUNC GLOBAL DEFAULT 4 rea_mips_probe", + " 9: 00020300 64 FUNC GLOBAL DEFAULT 4 rea_mips_soft_pick", + " 10: 00020340 64 FUNC GLOBAL DEFAULT 4 rea_mips_soft_wide", + " 11: 00020380 96 FUNC GLOBAL DEFAULT 4 rea_mips_soft_mixed", + " 12: 000203e0 96 FUNC GLOBAL DEFAULT 4 rea_mips_soft_struct", + " 13: 00020440 160 FUNC GLOBAL DEFAULT 4 rea_mips_soft_caller", + "", + ].join("\n"), + ); + + 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.symbols.rea_mips_soft_mixed).toBe("0x20380"); + }); + + it("rejects a float ABI that differs from the build intent", () => { + expect(() => parseMipsReadelf(softReadelf, "little", "hard")).toThrow( + /build intent/u, + ); + expect(() => parseMipsReadelf(readelf, "little", "soft")).toThrow( + /build intent/u, + ); + }); + + const boundaries = (byteOrder: "little" | "big") => { + const pair = (low: string, high: string) => + byteOrder === "little" ? `${high}:4,${low}:4` : `${low}:4,${high}:4`; + const item = ( + ordinal: number, + name: string, + dataType: string, + storage: string, + ) => ({ ordinal, name, data_type: dataType, storage }); + return { + rea_mips_soft_pick: { + return_type: { data_type: "float", storage: "v0:4" }, + parameters: [ + item(2, "sel", "int", "a2:4"), + item(0, "a", "float", "a0:4"), + item(1, "b", "float", "a1:4"), + ], + }, + rea_mips_soft_wide: { + return_type: { data_type: "double", storage: pair("v0", "v1") }, + parameters: [ + item(0, "a", "double", pair("a0", "a1")), + item(1, "sel", "int", "a2:4"), + ], + }, + rea_mips_soft_mixed: { + return_type: { data_type: "double", storage: pair("v0", "v1") }, + parameters: [ + item(0, "i", "int", "a0:4"), + item(1, "d", "double", pair("a2", "a3")), + item(2, "f", "float", "Stack[0x10]:4"), + item(3, "e", "double", "Stack[0x18]:8"), + ], + }, + rea_mips_soft_struct: { + return_type: { data_type: "rea_mips_soft_pair *", storage: "v0:4" }, + parameters: [ + item( + 0, + "__return_storage_ptr__", + "rea_mips_soft_pair *", + "a0:4 (auto)", + ), + item(1, "scale", "float", "a1:4"), + item(2, "sel", "int", "a2:4"), + ], + }, + }; + }; + + it("checks soft-float o32 storage, including pair order, alignment and spill", () => { + assertMipsSoftFloatStorage(boundaries("little"), "little"); + assertMipsSoftFloatStorage(boundaries("big"), "big"); + expect(() => + assertMipsSoftFloatStorage(boundaries("big"), "little"), + ).toThrow(); + const hardFloat = boundaries("little"); + const pickA = hardFloat.rea_mips_soft_pick.parameters.find( + (item) => item.ordinal === 0, + ); + if (pickA === undefined) throw new Error("fixture lacks pick parameter a"); + pickA.storage = "f12:4"; + expect(() => assertMipsSoftFloatStorage(hardFloat, "little")).toThrow(); + const unaligned = boundaries("little"); + const mixedD = unaligned.rea_mips_soft_mixed.parameters.find( + (item) => item.ordinal === 1, + ); + if (mixedD === undefined) + throw new Error("fixture lacks mixed parameter d"); + mixedD.storage = "a2:4,a1:4"; + expect(() => assertMipsSoftFloatStorage(unaligned, "little")).toThrow(); + const lostHiddenReturn = boundaries("little"); + lostHiddenReturn.rea_mips_soft_struct.parameters = [ + { ordinal: 0, name: "scale", data_type: "float", storage: "a0:4" }, + { ordinal: 1, name: "sel", data_type: "int", storage: "a1:4" }, + ]; + expect(() => + assertMipsSoftFloatStorage(lostHiddenReturn, "little"), + ).toThrow(); + }); +}); diff --git a/tests/conformance/ghidra/mips-fixture.mjs b/tests/conformance/ghidra/mips-fixture.mjs index 7a0017cb3..518d8a70f 100644 --- a/tests/conformance/ghidra/mips-fixture.mjs +++ b/tests/conformance/ghidra/mips-fixture.mjs @@ -8,18 +8,29 @@ import { promisify } from "node:util"; const execute = promisify(execFile); /** Build a source-owned freestanding ELF; never execute its target code. */ -export async function buildMipsFixture(directory, byteOrder) { +export async function buildMipsFixture( + directory, + byteOrder, + floatAbi = "hard", +) { if (byteOrder !== "little" && byteOrder !== "big") throw new Error("MIPS fixture byte order must be little or big"); + if (floatAbi !== "hard" && floatAbi !== "soft") + throw new Error("MIPS fixture float ABI must be hard or soft"); + const soft = floatAbi === "soft"; const compiler = process.env.REA_MIPS_CLANG ?? "clang"; const target = byteOrder === "little" ? "mipsel-linux-gnu" : "mips-linux-gnu"; const source = fileURLToPath(new URL("../c/mips.c", import.meta.url)); + const softSource = fileURLToPath( + new URL("../c/mips-soft-float.c", import.meta.url), + ); const probe = fileURLToPath(new URL("./mips-probe.S", import.meta.url)); - const path = join(directory, `${target}.elf`); + const path = join(directory, `${target}${soft ? "-soft-float" : ""}.elf`); const args = [ `--target=${target}`, "-march=mips32r2", "-mabi=32", + ...(soft ? ["-msoft-float"] : []), "-mno-abicalls", "-fno-pic", "-fuse-ld=lld", @@ -30,6 +41,7 @@ export async function buildMipsFixture(directory, byteOrder) { "-Wl,-e,rea_mips_entry", "-Wl,--build-id=none", source, + ...(soft ? [softSource] : []), probe, "-o", path, @@ -60,11 +72,19 @@ export async function buildMipsFixture(directory, byteOrder) { return { path, byte_order: byteOrder, + float_abi: floatAbi, flags: read32(36), sha256: createHash("sha256").update(bytes).digest("hex"), source_sha256: createHash("sha256") .update(await readFile(source)) .digest("hex"), + ...(soft + ? { + soft_source_sha256: createHash("sha256") + .update(await readFile(softSource)) + .digest("hex"), + } + : {}), probe_sha256: createHash("sha256") .update(await readFile(probe)) .digest("hex"),