diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 953be50b3f..dd316e9a07 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -548,6 +548,11 @@ public final class ReaGhidraBridge extends HeadlessScript { 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)); + // Written by ReaGhidraRiscvCallingConvention after analysis; REA requires it + // exactly when the committed profile declares a RISC-V calling-convention policy. + String riscvCallingConvention = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO) + .getString("REA RISC-V calling convention", null); + if (riscvCallingConvention != null) result.add("riscv_calling_convention", JsonParser.parseString(riscvCallingConvention)); return result; } diff --git a/bridge/ghidra/ReaGhidraRiscvCallingConvention.java b/bridge/ghidra/ReaGhidraRiscvCallingConvention.java new file mode 100644 index 0000000000..89fbbc276c --- /dev/null +++ b/bridge/ghidra/ReaGhidraRiscvCallingConvention.java @@ -0,0 +1,283 @@ +import ghidra.app.script.GhidraScript; +import ghidra.program.database.SpecExtension; +import ghidra.program.model.data.DataType; +import ghidra.program.model.data.DoubleDataType; +import ghidra.program.model.data.Float16DataType; +import ghidra.program.model.data.FloatDataType; +import ghidra.program.model.data.FunctionDefinition; +import ghidra.program.model.data.IntegerDataType; +import ghidra.program.model.data.StructureDataType; +import ghidra.program.model.lang.CompilerSpec; +import ghidra.program.model.lang.Language; +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.Varnode; +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; + +/** + * Apply the committed RISC-V calling-convention policy after analysis. Ghidra's generic RV32 + * compiler specification models ilp32d. Each policy derives a program-specific prototype from the + * program's own default prototype, as its {@link Policy} row and docs/ghidra-riscv.md state. The + * script installs the prototype as a specification extension and assigns it, with dynamic + * storage, to every non-thunk function, external function and function definition. + */ +public final class ReaGhidraRiscvCallingConvention extends GhidraScript { + static final String REPORT_KEY = "REA RISC-V calling convention"; + /** RISC-V floating-point registers by ABI name: ft0-ft11, fs0-fs11 and fa0-fa7. */ + private static final Pattern FLOAT_REGISTER = Pattern.compile("f[ast]\\d+"); + + /** + * What a policy changes in the default ilp32d prototype. Every policy also passes values of 9 + * bytes or more, such as {@code long double}, by reference. + * + * @param floatEntryBytes the largest value a floating-point register may pass or return; 0 + * removes those entries and every rule that assigns floating-point storage + * @param preservesFloatRegisters whether fs0-fs11 stay preserved across calls + */ + private record Policy( + String id, String prototype, int floatEntryBytes, boolean preservesFloatRegisters) {} + + private static final List POLICIES = + List.of(new Policy("ilp32-prototype-v1", "__rea_ilp32", 0, false)); + + @Override + public void run() throws Exception { + String[] arguments = getScriptArgs(); + Policy policy = + POLICIES.stream() + .filter(candidate -> arguments.length == 1 && candidate.id().equals(arguments[0])) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "REA RISC-V calling convention requires one of the policies " + + POLICIES.stream().map(Policy::id).toList())); + Language language = currentProgram.getLanguage(); + if (!language.getProcessor().toString().equals("RISCV") + || currentProgram.getDefaultPointerSize() != 4) + throw new IllegalStateException( + "REA " + policy.id() + " requires a 32-bit RISC-V program, not " + + language.getLanguageID()); + CompilerSpec spec = currentProgram.getCompilerSpec(); + new SpecExtension(currentProgram) + .addReplaceCompilerSpecExtension( + derive(spec.getDefaultCallingConvention(), spec, language, policy), monitor); + PrototypeModel model = + currentProgram.getCompilerSpec().getCallingConvention(policy.prototype()); + if (model == null) + throw new IllegalStateException("REA " + policy.prototype() + " was not installed"); + selfCheck(model, policy, language); + 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()) continue; + if (function.hasCustomVariableStorage()) function.setCustomVariableStorage(false); + function.setCallingConvention(policy.prototype()); + if (policy.floatEntryBytes() == 0) { + List storage = new ArrayList<>(); + storage.add(function.getReturn().getVariableStorage()); + for (Parameter parameter : function.getParameters()) + storage.add(parameter.getVariableStorage()); + // An untyped function has no assigned storage yet; the decompiler applies the + // prototype when it recovers one. + requireIntegerStorage( + storage.toArray(VariableStorage[]::new), function.getName(true), true); + } + functions += 1; + } + int definitions = 0; + Iterator types = currentProgram.getDataTypeManager().getAllDataTypes(); + while (types.hasNext()) { + if (!(types.next() instanceof FunctionDefinition definition)) continue; + definition.setCallingConvention(policy.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.id() + "\",\"functions\":" + functions + + ",\"definitions\":" + definitions + "}"); + println( + "REA RISC-V " + policy.prototype() + ": functions=" + functions + + " definitions=" + definitions); + } + + /** The default ilp32d prototype changed as the policy states. */ + private static String derive( + PrototypeModel base, CompilerSpec spec, Language language, Policy policy) 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", policy.prototype()); + List floatEntries = new ArrayList<>(); + NodeList entries = root.getElementsByTagName("pentry"); + for (int index = 0; index < entries.getLength(); index += 1) { + Element entry = (Element) entries.item(index); + if (entry.getAttribute("storage").equals("float")) floatEntries.add(entry); + } + if (floatEntries.isEmpty()) + throw new IllegalStateException("Default prototype has no floating-point entries"); + if (policy.floatEntryBytes() == 0) { + List removed = new ArrayList<>(floatEntries); + NodeList rules = root.getElementsByTagName("rule"); + for (int index = 0; index < rules.getLength(); index += 1) { + Element rule = (Element) rules.item(index); + if (usesFloatStorage(rule)) removed.add(rule); + } + if (removed.size() == floatEntries.size()) + throw new IllegalStateException("Default prototype has no floating-point rules to remove"); + for (Node node : removed) node.getParentNode().removeChild(node); + } else + for (Element entry : floatEntries) + entry.setAttribute("maxsize", Integer.toString(policy.floatEntryBytes())); + // Values over 2 x XLEN go by reference. Returns need no rule: a value that the return + // registers cannot hold already becomes a caller buffer passed in a0. + NodeList inputs = root.getElementsByTagName("input"); + if (inputs.getLength() != 1) + throw new IllegalStateException("Default prototype has no single input list"); + Element input = (Element) inputs.item(0); + Element last = null; + for (Node child = input.getFirstChild(); child != null; child = child.getNextSibling()) + if (child instanceof Element element && element.getTagName().equals("rule")) last = element; + if (last == null || !joinsAnyValue(last)) + throw new IllegalStateException("Default prototype's input list does not end with a join"); + Element byReference = document.createElement("rule"); + Element datatype = document.createElement("datatype"); + datatype.setAttribute("name", "any"); + datatype.setAttribute("minsize", "9"); + byReference.appendChild(datatype); + byReference.appendChild(document.createElement("convert_to_ptr")); + input.insertBefore(byReference, last); + if (!policy.preservesFloatRegisters()) { + NodeList killedLists = root.getElementsByTagName("killedbycall"); + Element killed = killedLists.getLength() == 0 ? null : (Element) killedLists.item(0); + if (killed == null) { + killed = document.createElement("killedbycall"); + root.appendChild(killed); + } + List moved = new ArrayList<>(); + NodeList preservedLists = root.getElementsByTagName("unaffected"); + for (int list = 0; list < preservedLists.getLength(); list += 1) { + NodeList preserved = preservedLists.item(list).getChildNodes(); + for (int index = 0; index < preserved.getLength(); index += 1) { + if (preserved.item(index) instanceof Element varnode + && isFloatRegister(varnode, language)) + moved.add(varnode); + } + } + for (Element varnode : moved) { + varnode.getParentNode().removeChild(varnode); + killed.appendChild(varnode); + } + } + 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(); + } + + /** The installed prototype places each class of value as its policy states. */ + private void selfCheck(PrototypeModel model, Policy policy, Language language) { + StructureDataType pair = new StructureDataType("rea_float_pair", 0); + pair.add(FloatDataType.dataType); + pair.add(FloatDataType.dataType); + VariableStorage[] storage = + model.getStorageLocations( + currentProgram, + new DataType[] { + DoubleDataType.dataType, FloatDataType.dataType, DoubleDataType.dataType, + IntegerDataType.dataType, pair, Float16DataType.dataType + }, + false); + VariableStorage quad = storage[storage.length - 1]; + if (quad.size() != currentProgram.getDefaultPointerSize() || quad.getRegister() == null) + throw new IllegalStateException( + "REA " + policy.prototype() + " does not pass a 16-byte value by reference: " + quad); + if (policy.floatEntryBytes() == 0) + requireIntegerStorage(storage, policy.prototype() + " self-check", false); + if (policy.preservesFloatRegisters()) return; + for (Varnode preserved : model.getUnaffectedList()) { + Register register = language.getRegister(preserved.getAddress(), preserved.getSize()); + if (register != null && FLOAT_REGISTER.matcher(register.getName()).matches()) + throw new IllegalStateException( + "REA " + policy.prototype() + " still preserves " + register.getName()); + } + } + + private static boolean joinsAnyValue(Element rule) { + List children = new ArrayList<>(); + for (Node child = rule.getFirstChild(); child != null; child = child.getNextSibling()) + if (child instanceof Element element) children.add(element); + return children.size() == 2 + && children.get(0).getTagName().equals("datatype") + && children.get(0).getAttribute("name").equals("any") + && children.get(1).getTagName().equals("join"); + } + + /** Whether a rule's action, at any depth, assigns floating-point storage. */ + private static boolean usesFloatStorage(Element element) { + NodeList children = element.getChildNodes(); + for (int index = 0; index < children.getLength(); index += 1) { + if (!(children.item(index) instanceof Element child)) continue; + if (child.getAttribute("storage").equals("float") || usesFloatStorage(child)) return true; + } + return false; + } + + private static boolean isFloatRegister(Element varnode, Language language) { + if (!varnode.getTagName().equals("varnode") + || !varnode.getAttribute("space").equals("register")) + return false; + Register register = + language.getRegister( + language.getAddressFactory().getRegisterSpace() + .getAddress(Long.decode(varnode.getAttribute("offset"))), + Integer.parseInt(varnode.getAttribute("size"))); + return register != null && FLOAT_REGISTER.matcher(register.getName()).matches(); + } + + private static void requireIntegerStorage( + VariableStorage[] storage, String owner, boolean allowUnassigned) { + for (VariableStorage item : storage) { + if (item.isBadStorage() || (!allowUnassigned && item.isUnassignedStorage())) + throw new IllegalStateException("REA RISC-V storage unresolved for " + owner); + List registers = item.getRegisters(); + if (registers == null) continue; // stack-only storage + for (Register register : registers) { + if (FLOAT_REGISTER.matcher(register.getName()).matches()) + throw new IllegalStateException( + "REA RISC-V storage still uses " + register.getName() + " for " + owner); + } + } + } +} diff --git a/docs/ghidra-riscv.md b/docs/ghidra-riscv.md index afae01948a..0ef68a3e1b 100644 --- a/docs/ghidra-riscv.md +++ b/docs/ghidra-riscv.md @@ -9,29 +9,43 @@ lane shape as [generic MIPS support](ghidra-mips.md). ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The target also retains the raw ELF declarations that decide a lane: class, byte -order, `e_type`, and `e_flags`. Recognition does not mean every provider or -RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets -are unchanged. Headerless inputs use the separate [raw-binary admission +order, `e_type`, `e_flags`, and the ISA string that `.riscv.attributes` +declares. Recognition does not mean every provider or RISC-V variant is +supported. Existing PE, managed and Mach-O architecture sets are unchanged. +Headerless inputs use the separate [raw-binary admission boundary](raw-binary.md); raw RISC-V images are not supported. -The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that -declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`, -`EF_RISCV_TSO`, or any bit outside `EF_RISCV_RVC | EF_RISCV_FLOAT_ABI`. These are -header declarations, not proof that every instruction conforms to that ISA. +The Ghidra lane admits little-endian `ET_EXEC` images with no `EF_RISCV_RVE`, +`EF_RISCV_TSO`, or any bit outside `EF_RISCV_RVC | EF_RISCV_FLOAT_ABI`, in one +of three lanes: + +- ELF64 or ELF32 images that declare `EF_RISCV_FLOAT_ABI_DOUBLE` use Ghidra's + stock double-float model unchanged. Their `.riscv.attributes` is not read for + admission. +- ELF32 images that declare the soft-float ABI (`e_flags` float-ABI bits `0`, + ilp32) use REA's [ilp32 calling-convention policy](#ilp32). They must also + declare an RV32I base and only [decoded extensions](#isa-declarations) in + `Tag_RISCV_arch`. + +These are header declarations, not proof that every instruction conforms to +that ISA. Provider selection returns the specific unsupported constraint rather than guessing a compatible profile: -| Declaration | Outcome | -| ----------------------------------------------- | ---------------------------------------------------------------------------------------- | -| Little-endian ELF64 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:64:default` + `gcc` | -| Little-endian ELF32 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:32:default` + `gcc` | -| Big-endian | refused; Ghidra 12.1.4 ships no big-endian RISC-V language | -| `ET_REL`, `ET_DYN`, other `e_type` | refused with the declared value | -| `EF_RISCV_RVE` | refused; the embedded 16-register profile (x0–x15) needs its own processor specification | -| `EF_RISCV_TSO` | refused; the total-store-order memory model is unverified here | -| Soft, single, or quad float ABI | refused; the pinned generic compiler specification models double-float | -| Reserved or newer `e_flags` bits | refused with the offending bits named | +| Declaration | Outcome | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Little-endian ELF64 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:64:default` + `gcc`, lane `elf64-exec-le-double-float-generic-v1` | +| Little-endian ELF32 `ET_EXEC`, double-float ABI | admitted, `RISCV:LE:32:default` + `gcc`, lane `elf32-exec-le-double-float-generic-v1` | +| Little-endian ELF32 `ET_EXEC`, soft-float ABI, decoded extensions only | admitted, `RISCV:LE:32:default` + `gcc` with the `__rea_ilp32` prototype, lane `elf32-exec-le-soft-float-generic-v1` | +| ELF32 soft-float ABI without `Tag_RISCV_arch`, or with any other extension | refused; the reason names the extensions outside the decoded set | +| ELF32 single-float ABI (ilp32f) | refused until its [written policy](#ilp32f) is implemented and verified | +| ELF64 soft- or single-float ABI (lp64, lp64f), or the quad-float ABI | refused; no verified calling-convention policy | +| Big-endian | refused; Ghidra 12.1.4 ships no big-endian RISC-V language | +| `ET_REL`, `ET_DYN`, other `e_type` | refused with the declared value | +| `EF_RISCV_RVE` | refused; the embedded 16-register profile (x0–x15) needs its own processor specification | +| `EF_RISCV_TSO` | refused; the total-store-order memory model is unverified here | +| Reserved or newer `e_flags` bits | refused with the offending bits named | `e_type` is the only load-shape declaration this lane reads. A fixed-address `ET_EXEC` declaration does **not** establish static linkage: `PT_INTERP` and @@ -58,15 +72,193 @@ with the `gcc` compiler specification per class, records pin without changing the requested override. An override that matches the pin is accepted and recorded as `configured-v1`. +The soft-float lane pins the same `RISCV:LE:32:default` language. Every RV32 +language in Ghidra 12.1.4, including the deprecated soft-float `RV32I`, `RV32IC` +and `RV32IMC` variants in `old/riscv_deprecated.ldefs`, uses the same +`riscv.ilp32d.sla` decoder; only the compiler specification differs. REA keeps +the generic language and corrects the calling convention instead. + +## Calling conventions + +Ghidra's generic RV32 compiler specification, `riscv32-fp.cspec`, models the +ilp32d ABI. Its default prototype passes `float` and `double` values in +`fa0`–`fa7` and returns them in `fa0`, and it passes a structure of one or two +floating-point members in floating-point registers. An ilp32 image passes all +of these in integer registers. Measured on Ghidra 12.1.4 with an ilp32 build of +the fixture in `tests/conformance/c/riscv-float-abi.c`, the stock model +decompiles `rea_riscv_abi_pick(1.5f, 2.5f, sel)` as +`rea_riscv_abi_pick(in_fa0,in_fa1,0x3fc00000)`. + +### Decision record: D3, RISC-V float ABIs + +Decided in [#7477](https://github.com/morluto/rea/issues/7477). + +- **Choice.** + - Admit little-endian ELF32 `ET_EXEC` images that declare the soft-float ABI + (ilp32) through a new lane, `elf32-exec-le-soft-float-generic-v1`. Lane ids + are built from their fields: `elf-exec-le--float-generic-v1`. + - Keep the pinned `RISCV:LE:32:default` language and `gcc` compiler + specification. After analysis, `ReaGhidraRiscvCallingConvention.java` + derives the program-specific `__rea_ilp32` prototype from the program's + default prototype as the [ilp32 policy](#ilp32) states. It installs the + prototype through a Ghidra specification extension and assigns it, with + dynamic storage, to every non-thunk function, every external function and + every function definition. Thunks follow their targets. The script fails, + and writes no report, if the derived prototype or any function still + places a value in a floating-point register, or if the prototype does not + pass a 16-byte value by reference. + - One derivation serves every policy. A policy row says how it changes the + default prototype's floating-point entries and preserved registers; every + policy passes values of 9 bytes or more by reference. + - The profile commits `riscv_calling_convention: ilp32-prototype-v1`. The + bridge reports the policy and its function and definition counts, and REA + requires that report exactly when the profile commits the policy. + - Admission requires a `Tag_RISCV_arch` declaration with an RV32I base and + only extensions that Ghidra 12.1.4's generic RV32 language implements with + p-code ([ISA declarations](#isa-declarations)). + - Soft-float profiles add `float_abi` and `arch` to `riscv_elf`. Double-float + profiles keep their exact parameters, so their digests, sessions and + snapshots are unchanged. + - ilp32f keeps its [written policy](#ilp32f) and stays refused until its + prototype is implemented and verified. Quad-float, RVE, TSO, and ELF64 + soft- or single-float images stay refused. +- **Alternatives** (and what switching to each would take): + - Pin the deprecated soft-float languages (`RISCV:LE:32:RV32IMC` and others, + `riscv32.cspec`): REA's language catalog would have to read `old/`, the + variants cover only I, IC and IMC, Ghidra marks them deprecated, and no + stock specification models ilp32f. + - Assign `__rea_ilp32` only to callables whose types use floating-point + values, as the MIPS soft-float lane does: typed storage is the same, but + prototypes Ghidra infers for untyped code would keep the ilp32d model. In + an ilp32 image that contains F or D instructions, that model can still + claim `fa0`. Switching is one predicate in the Java script and a new + policy id. + - Admit with a limitation and no correction: a reduced-capability contract + that needs its own review, because arguments, returns and aggregates + become unverified. + - Keep refusing: the lane rows become refusals again. + - Admit images without `Tag_RISCV_arch`, or refuse a list of known + extensions instead of admitting a list: a declaration that REA cannot + check would be treated as compatible. Widening the decoded set later is + additive. + - Add `float_abi` and `arch` to double-float profiles as well: one shape for + every lane, but every existing RISC-V session and snapshot would be + invalidated. + - Ship ilp32 and ilp32f together (the question in #7477): ilp32f needs a + float-and-integer structure rule that Ghidra's model lacks, so it ships + separately with its own fixtures. +- **Semantic owner and consumers.** + - Owners: + - `src/domain/riscvIsaDeclaration.ts` owns the float-ABI vocabulary of + `e_flags` and the parsing of `.riscv.attributes` and `Tag_RISCV_arch`. + - `GHIDRA_RISCV_LANES`, `GHIDRA_RISCV_CALLING_CONVENTIONS` and + `GHIDRA_RISCV32_DECODED_EXTENSIONS` (`src/ghidra/GhidraRiscvProfile.ts`) + own admission, lane ids and the policy table. + - `bridge/ghidra/ReaGhidraRiscvCallingConvention.java` owns how each policy + becomes a Ghidra prototype. + - Consumers: target resolution, Ghidra provider admission, the analysis + profile, the provider client and launcher, the session handshake, project + history, the real-Ghidra lane and this page. Later work reuses the table: + ilp32f adds a row, and RISC-V ESP images (F6) select a row through D5. +- **Costs of changing it.** + - **Code:** S. A policy or lane is one table row plus the Java derivation. + - **Compatibility:** the soft-float lane id, `riscv_elf.float_abi`, + `riscv_elf.arch` and `riscv_calling_convention` are persisted in sessions, + snapshots and Evidence once released. A changed derivation ships as a new + policy id, such as `ilp32-prototype-v2`, which invalidates soft-float + snapshots only. + - **Migration:** none for admitted targets; double-float profiles are pinned + by tests. Previously refused soft-float targets become admitted. + - **Verification:** rerun `npm run verify:ghidra:riscv`. + +### ilp32 + +Policy `ilp32-prototype-v1`, prototype `__rea_ilp32`. XLEN is 32 bits and no +floating-point register passes a value. + +| Topic | psABI rule | `__rea_ilp32` | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Integer and pointer values | `a0`–`a7`, then the stack in 4-byte-aligned slots | The default integer and stack entries, unchanged | +| `float` | One integer register, or a stack slot | The `fa0`–`fa7` entries and the `float` rule are removed, so a `float` takes the next integer register | +| `double` and 8-byte values | A register pair with the low word in the lower register. Named arguments need no aligned pair. With one register left, the low word goes in `a7` and the high word on the stack | The default `join` rule with stack spill: Ghidra writes `a2:4,a1:4` for a pair and `Stack[0x0]:4,a7:4` for the split | +| Aggregates up to 8 bytes | One or two integer registers, laid out as in memory, also when every member is floating-point | The homogeneous-float-aggregate rule is removed; the `join` rule applies | +| Aggregates over 8 bytes | By reference: the caller passes the address of a copy | The default rule that converts structures and unions of 9 bytes or more to pointers | +| Return values | As the first argument of the same type: `a0`, or `a0` and `a1`. Larger values go to a caller buffer whose address is an implicit first argument in `a0` | The `fa0`/`fa1` return entries and floating-point rules are removed; Ghidra's hidden-return fallback supplies the pointer in `a0` | +| Variadic arguments | The integer convention. A variadic argument with 8-byte alignment and size takes an aligned (even-numbered) register pair, or the stack; after the first stack argument, the rest go on the stack | The default variadic rule (`join` with alignment), unchanged | +| Preserved registers | `s0`–`s11` and `sp`; `gp` and `tp` are not allocated. No floating-point register is preserved | The default preserved list without `fs0`–`fs11`, which join the killed-by-call list | +| Scalars over 8 bytes | By reference (`long double` is 16 bytes), and returned through a caller buffer | A rule before the final join converts any value of 9 bytes or more to a pointer; a return that size already goes to a caller buffer | + +Ghidra recovers the arguments of a variadic call from the registers the caller +writes, not from their types. A padding register that still holds an earlier +value can appear as an argument, and arguments after an unwritten register are +omitted. + +### ilp32f + +Written policy for `EF_RISCV_FLOAT_ABI_SINGLE` on ELF32. REA does not admit it +yet. ABI_FLEN is 32 bits. + +| Topic | psABI rule | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `float` | `fa0`–`fa7` while one is free, then the integer convention | +| `double` and other values wider than 32 bits | The integer convention, as in ilp32 | +| Structure of one or two floats (after flattening) | One floating-point register per member when enough are free, otherwise the integer convention | +| Structure of one float and one integer, in either order | One floating-point and one integer register when both are free, otherwise the integer convention | +| Unions | Never flattened; the integer convention | +| Return values | As the first argument: `fa0`, `fa0` and `fa1`, or `fa0` and `a0` | +| Variadic arguments | The integer convention, as in ilp32 | +| Preserved registers | `s0`–`s11`, `sp`, and `fs0`–`fs11` for values of 32 bits or fewer | + +The planned prototype limits the default floating-point entries to 4 bytes and +keeps the shared rule that passes values of 9 bytes or more by reference. +Observed on Ghidra 12.1.4, a `double` then takes an integer pair and a structure +of two floats takes `fa0` and `fa1`. Ghidra's model has no rule for a structure +of one float and one integer: it joins `struct { float; int; }` into `a1:a0` +instead of `fa0` and `a0`. The ilp32f policy needs that rule and its own +fixtures before it is admitted. + +### ilp32d and lp64d + +The double-float lanes use Ghidra's stock `riscv32-fp.cspec` and +`riscv64-fp.cspec` models unchanged. This page does not verify them. They share +the float-and-integer structure gap above. + +## ISA declarations + +The target reader finds the one `SHT_RISCV_ATTRIBUTES` section and reads +`Tag_RISCV_arch`, such as `rv32i2p1_m2p0_c2p0_zicsr2p0_zifencei2p0`. It +retains the string, the base and the extension names, or an unavailable reason +when the section is malformed, declares other vendors or scopes, or uses a +non-canonical string. A missing section or tag is recorded as absent. + +Calling-convention lanes admit an image only when it declares an RV32I base +and only extensions that Ghidra 12.1.4's generic RV32 language implements with +p-code: `m`, `a`, `f`, `d`, `c`, `zicsr`, `zifencei`, and the subset names +toolchains record, `zmmul`, `zaamo`, `zalrsc`, `zca`, `zcf` and `zcd`. Any +other extension is refused. In particular: + +- Ghidra 12.1.4 decodes no Zcb, Zcmp or Zcmt instruction. +- Its bit-manipulation, cryptography and packed-SIMD files follow draft + encodings and mark nearly every instruction unimplemented. +- Vendor extensions, such as Espressif's `xesploop`, `xespv` and `xespdsp`, + decode only as custom-opcode placeholders. + +ESP-IDF builds ESP32-C2 and C3 images as `rv32imc_zicsr_zifencei` and ESP32-C5, +C6, C61, H2 and H21 images as `rv32imac_zicsr_zifencei_zaamo_zalrsc`, all ilp32. +Those base strings are admitted. Builds that add `_zba_zbb_zbs`, +`_zcb_zcmp_zcmt` or an `xesp` extension are refused. + ## Evidence -The lane identity is `elf32-exec-le-double-float-generic-v1` or -`elf64-exec-le-double-float-generic-v1`, and `riscv_elf` retains `elf_class`, -`byte_order`, `type`, and the raw `flags` alongside the read-only booleans -`float_abi_double`, `rvc_declared`, `rve_declared`, and `tso_declared`. Those -fields report declarations the header makes; none of them is evidence of the -original compiler, of instruction conformance, or of a floating-point calling -convention beyond the ABI the pinned language models. +The lane identity is one of the lane ids above, and `riscv_elf` retains +`elf_class`, `byte_order`, `type`, and the raw `flags` alongside the read-only +booleans `float_abi_double`, `rvc_declared`, `rve_declared`, and +`tso_declared`. Soft-float profiles also retain `float_abi: soft`, the declared +`arch` string, and `riscv_calling_convention: ilp32-prototype-v1`. Those fields +report declarations the header and attributes make, and the policy REA applied; +none of them is evidence of the original compiler or of instruction +conformance. Session Evidence states the prototype and how many functions and +definitions use it. ## Verification and limits @@ -76,9 +268,9 @@ tree ships no host decompiler binary, so `Decompiler/os//decompile` an [installation](installation.md#ghidra). Run `npm run verify:ghidra:riscv` with an existing Ghidra 12.1.4 installation, -JDK 21+, and a RISC-V-capable Clang, LLD and LLVM readelf. The verifier acquires -no tools and never executes target code. Select installed tools explicitly when -they are not on `PATH`: +JDK 21+, and a RISC-V-capable Clang, LLD, LLVM readelf and LLVM objdump. The +verifier acquires no tools and never executes target code. Select installed +tools explicitly when they are not on `PATH`: ```sh GHIDRA_INSTALL_DIR=/path/to/ghidra_12.1.4_PUBLIC \ @@ -86,20 +278,52 @@ JAVA_HOME=/path/to/jdk-21 \ REA_RISCV_CLANG=/path/to/llvm/bin/clang \ REA_RISCV_LLD=/path/to/lld/bin/ld.lld \ REA_RISCV_READELF=/path/to/llvm/bin/llvm-readelf \ +REA_RISCV_OBJDUMP=/path/to/llvm/bin/llvm-objdump \ npm run verify:ghidra:riscv ``` -The lane builds the committed freestanding C and assembly sources into RV32 and -RV64 `ET_EXEC` fixtures. Independent LLVM readelf observations establish ELF -declarations and symbol addresses. Public CLI and stdio MCP workflows verify -function enumeration, decompilation, pinned language/compiler identity, decoded -probe bytes, global data, and direct calls. It validates advertised MCP output -schemas and both JSON representations, saves and reopens snapshots, rejects -header-mutated soft-float and unsupported high-bit declarations, and checks -unchanged source bytes and owned cleanup. Failures retain the diagnostic -workspace; successful runs remove it and report the observed process lineage. +The lane builds four `ET_EXEC` fixtures from committed freestanding C and +assembly sources: -Module tests cover admission and malformed headers. Call-graph completeness, -string inventories, dynamic linkage, Andes or RVE/TSO images, and big-endian -targets are **not** established by this lane. `.riscv.attributes` ISA extensions -are not inspected; the generic language pin remains an analysis convention. +- RV32 and RV64 double-float builds (`rv{32,64}imafdc`) of `riscv.c`. +- An RV32 soft-float build of `riscv-float-abi.c` with ESP32-C3's + `-march=rv32imc_zicsr_zifencei -mabi=ilp32`, typed with DWARF. +- The same objects linked with `--strip-all`. Every allocated section keeps + its address and bytes, so the typed build's symbols locate its functions. + +Independent LLVM readelf observations establish ELF declarations, +`Tag_RISCV_arch` and symbol addresses. Public CLI and stdio MCP workflows verify +function enumeration, decompilation, pinned language/compiler identity, the +committed lane and policy, decoded probe bytes, global data, and direct calls. +They validate advertised MCP output schemas and both JSON representations, save +and reopen snapshots, and check unchanged source bytes and owned cleanup. + +For the soft-float builds the lane also checks: + +- `fence.i` and `csrr` decode, as ESP32-C3's Zifencei and Zicsr declare. +- An LLVM objdump reading of `rea_riscv_abi_caller` finds each call-site word + where the [ilp32 policy](#ilp32) puts it: an unaligned `double` in `a1`/`a2`, + the `a7`/stack split, two floats in an integer pair, a large structure and a + `long double` passed by reference (the pointed-to copies hold the source + values), aligned variadic pairs, and an indirect call. +- `inspect_native_api` reports `__rea_ilp32` with exactly that storage for the + typed build. The stripped build has one integer word per parameter location + and returns in the typed build's registers, or in none when the result goes + through caller memory. +- The caller's pseudocode passes the source constants through that storage and + names no floating-point register. +- Retained Evidence states the prototype outcome; the stripped build has no + function definitions. + +Header-mutated copies check admission. The RV32 double-float image relabelled as +soft-float (`e_flags` `0x1`), refused before this lane, is admitted through the +soft-float lane over CLI and MCP. The soft-float image relabelled as +single-float, RV64 soft-float, and unsupported high bits are refused with their +reasons. Failures retain the diagnostic workspace; successful runs remove it and +report the observed process lineage. + +Module tests cover admission, malformed headers and attribute sections, the +handshake, and the conformance checks against recorded LLVM and Ghidra output. +Call-graph completeness, string inventories, dynamic linkage, Andes or RVE/TSO +images, big-endian targets, ilp32f images, and decoding beyond the probes are +**not** established by this lane. diff --git a/docs/testing.md b/docs/testing.md index 6113c5603f..dde7318eac 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1061,7 +1061,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build. | `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback, not-applicable and unknown analyzer names | Ghidra 12.1.4 and full JDK 21 | | `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 | -| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 double-float and RV32 ilp32 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf and objdump, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 | | `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites | diff --git a/package.json b/package.json index feaf70c71b..08d0c30856 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "bridge/ghidra/ReaGhidraAnalysisOptions.java", "bridge/ghidra/ReaGhidraNoReturnFix.java", "bridge/ghidra/ReaGhidraMipsSoftFloat.java", + "bridge/ghidra/ReaGhidraRiscvCallingConvention.java", "bridge/ghidra/ReaGhidraPrepareRaw.java", "bridge/ghidra/ReaGhidraPrepareDol.java", "bridge/android/ReaJadxBridge.java", diff --git a/scripts/verify-real-ghidra-riscv.mjs b/scripts/verify-real-ghidra-riscv.mjs index 0a79119de3..7a83878d66 100644 --- a/scripts/verify-real-ghidra-riscv.mjs +++ b/scripts/verify-real-ghidra-riscv.mjs @@ -23,7 +23,17 @@ import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; import { parseEvidence } from "../dist/domain/evidence.js"; import { functionDossierSchema } from "../dist/domain/native/functionDossier.js"; import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; -import { buildRiscvFixture } from "../tests/conformance/ghidra/riscv-fixture.mjs"; +import { + buildRiscvFixture, + stripRiscvFixture, +} from "../tests/conformance/ghidra/riscv-fixture.mjs"; +import { + RISCV_ILP32_ABI, + assertRiscvIlp32CallSites, + assertRiscvIlp32Caller, + assertRiscvIlp32Storage, + inspectRiscvCallSites, +} from "../tests/conformance/ghidra/riscv-checks.mjs"; import { mcpTextValue, requireMcpLifecycleResult, @@ -82,6 +92,29 @@ transport.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString()).slice(-65536); }); +/** The profile a fixture's lane must commit, from independent declarations. */ +const expectedLane = (fixture) => { + const soft = fixture.riscv.flags === 1; + return { + lane: `elf${fixture.riscv.elfClass}-exec-le-${soft ? "soft" : "double"}-float-generic-v1`, + callingConvention: soft ? "ilp32-prototype-v1" : undefined, + riscvElf: { + elf_class: fixture.riscv.elfClass, + byte_order: "little", + type: 2, + flags: fixture.riscv.flags, + float_abi_double: !soft, + ...(soft ? { float_abi: "soft", arch: fixture.riscv.isa.arch } : {}), + rvc_declared: true, + rve_declared: false, + tso_declared: false, + }, + }; +}; + +const OUTCOME = + /RISC-V calling-convention outcome: [1-9]\d* functions and (\d+) function definitions use __rea_ilp32\./u; + const reports = []; let failure; try { @@ -101,41 +134,55 @@ try { : undefined; validators.set(tool.name, existing ?? ajv.compile(tool.outputSchema)); } - for (const bits of [32, 64]) { - const fixture = await buildRiscvFixture(workspace, bits); + + const typedSoft = await buildRiscvFixture(workspace, 32, "soft"); + const callSites = await inspectRiscvCallSites(typedSoft.path); + assertRiscvIlp32CallSites(callSites.calls); + const fixtures = [ + await buildRiscvFixture(workspace, 32), + await buildRiscvFixture(workspace, 64), + typedSoft, + await stripRiscvFixture(workspace, typedSoft), + ]; + for (const fixture of fixtures) { + const bits = fixture.riscv.elfClass; + const soft = fixture.riscv.flags === 1; + const stripped = fixture.stripped === true; + const expected = expectedLane(fixture); const resolved = await parseBinaryTarget(fixture.path); if (!resolved.ok) throw resolved.error; assert.equal(resolved.value.architecture, "riscv"); assert.equal(resolved.value.sha256, fixture.sha256); assert.deepEqual(resolved.value.riscv, fixture.riscv); const expectedLanguage = `RISCV:LE:${bits}:default`; - const assertProfile = (profile) => { + const assertProfile = (profile, want = expected) => { assert.equal(profile.provider.id, "ghidra"); assert.equal(profile.provider.version, installation.providerVersion); const parameters = profile.parameters; assert.equal(parameters.language_id, expectedLanguage); assert.equal(parameters.compiler_spec_id, "gcc"); assert.equal(parameters.language_selection, "provider-pinned-v1"); - assert.equal( - parameters.riscv_support_lane, - `elf${bits}-exec-le-double-float-generic-v1`, - ); - assert.deepEqual(parameters.riscv_elf, { - elf_class: bits, - byte_order: "little", - type: 2, - flags: 5, - float_abi_double: true, - rvc_declared: true, - rve_declared: false, - tso_declared: false, - }); + assert.equal(parameters.riscv_support_lane, want.lane); + assert.equal(parameters.riscv_calling_convention, want.callingConvention); + assert.deepEqual(parameters.riscv_elf, want.riscvElf); }; const assertEvidence = (evidence) => { - assert.equal(evidence.provider.id, "ghidra"); assert.equal(evidence.subject.architecture, "riscv"); assert.equal(evidence.subject.digest.sha256, fixture.sha256); - assertProfile(evidence.analysis_profile); + // Composed workflows such as inspect_native_api commit the Ghidra + // profile as their upstream profile. The residual-unknown registry + // records what they could not establish, such as the types of a + // stripped function's parameters, without a profile of its own. + if (evidence.provider.id === "rea-workflow") + assertProfile( + evidence.analysis_profile.parameters.upstream_analysis_profile, + ); + else if (evidence.provider.id === "rea-unknown-registry") + assert.equal(evidence.analysis_profile, null); + else { + assert.equal(evidence.provider.id, "ghidra"); + assertProfile(evidence.analysis_profile); + } }; const call = async (name, args = {}) => { const reply = await client.callTool( @@ -176,13 +223,32 @@ try { assert.equal(source.modified_sha256, fixture.sha256); } const procedures = await call("list_procedures"); - for (const name of [ - "rea_riscv_entry", - "rea_riscv_leaf", - "rea_riscv_float", - "rea_riscv_probe", - ]) { - const procedure = procedures.find((value) => value.value === name); + const names = soft + ? [ + "rea_riscv_entry", + "rea_riscv_probe", + "rea_riscv_isa_probe", + "rea_riscv_abi_caller", + ...Object.keys(RISCV_ILP32_ABI), + ] + : [ + "rea_riscv_entry", + "rea_riscv_leaf", + "rea_riscv_float", + "rea_riscv_probe", + ]; + for (const name of [...names, "rea_riscv_global"]) + assert.ok( + fixture.symbols[name], + `${name} missing from the readelf symbols`, + ); + for (const name of names) { + // A stripped image has no names; the typed twin's addresses locate it. + const procedure = procedures.find((value) => + stripped + ? BigInt(value.address) === BigInt(fixture.symbols[name]) + : value.value === name, + ); assert.ok(procedure, `${name} missing from Ghidra inventory`); assert.equal(BigInt(procedure.address), BigInt(fixture.symbols[name])); } @@ -203,6 +269,22 @@ try { .toString("hex"), probe.bytes, ); + const isaProbe = []; + if (soft) { + // ESP32-C3's -march declares Zifencei and Zicsr; both must decode. + for (const [offset, bytes, mnemonic] of [ + [0n, "0f100000", /^fence\.i$/iu], + [4n, "f32200b0", /^csrr/iu], + ]) { + const instruction = await call("inspect_native_instruction", { + address: `0x${(BigInt(fixture.symbols.rea_riscv_isa_probe) + offset).toString(16)}`, + }); + assert.equal(instruction.status, "decoded"); + assert.equal(instruction.bytes, bytes); + assert.match(instruction.mnemonic, mnemonic); + isaProbe.push(instruction.mnemonic); + } + } const global = await call("read_bytes", { address: fixture.symbols.rea_riscv_global, length: 4, @@ -211,11 +293,14 @@ try { const callees = await call("procedure_callees", { procedure: fixture.symbols.rea_riscv_entry, }); - for (const name of ["rea_riscv_leaf", "rea_riscv_probe"]) + for (const name of soft + ? ["rea_riscv_abi_caller", "rea_riscv_isa_probe", "rea_riscv_probe"] + : ["rea_riscv_leaf", "rea_riscv_probe"]) assert.ok( callees.some( (address) => BigInt(address) === BigInt(fixture.symbols[name]), ), + `${name} missing from the entry's callees`, ); const dossier = functionDossierSchema.parse( await call("analyze_function", { @@ -224,60 +309,50 @@ try { }), ); assert.ok(dossier.pseudocode.trim().length > 0); - const floatCode = await call("procedure_pseudo_code", { - procedure: fixture.symbols.rea_riscv_float, - }); - assert.ok(typeof floatCode === "string" && floatCode.trim().length > 0); - assert.match(floatCode, /\bdouble\b/u); - - const refusals = []; - for (const [flags, reason] of [ - [1, "EF_RISCV_FLOAT_ABI_DOUBLE"], - [0x80000005, "(0x80000000)"], - ]) { - // Header-mutated copies test admission; they are not new compiler claims. - const rejectedPath = join( - workspace, - `rv${bits}-flags-${flags.toString(16)}.elf`, - ); - const rejectedBytes = Buffer.from(sourceBytes); - rejectedBytes.writeUInt32LE(flags, bits === 32 ? 36 : 48); - await writeFile(rejectedPath, rejectedBytes); - const reply = await client.callTool( - { - name: "open_binary", - arguments: { path: rejectedPath, provider_id: "ghidra" }, - }, - { timeout: 60000 }, - ); - const error = requireMcpToolError(reply); - assert.ok(JSON.stringify(error).includes(reason), JSON.stringify(error)); - await assert.rejects( - execute( - process.execPath, - [ - entrypoint, - "function", - rejectedPath, - "rea_riscv_entry", - "--provider", - "ghidra", - "--json", - ], - { - env, - timeout: 60000, - maxBuffer: 1024 * 1024, - }, - ), - (cause) => - Number.isInteger(cause.code) && - cause.code !== 0 && - `${cause.stdout}\n${cause.stderr}`.includes(reason), - ); - refusals.push({ flags, reason }); + if (!stripped) { + const floatCode = await call("procedure_pseudo_code", { + procedure: + fixture.symbols[soft ? "rea_riscv_abi_wide" : "rea_riscv_float"], + }); + assert.ok(typeof floatCode === "string" && floatCode.trim().length > 0); + assert.match(floatCode, /\bdouble\b/u); } - const snapshotPath = join(workspace, `rv${bits}.snapshot.json`); + let abi; + if (soft) { + const boundaries = {}; + for (const name of Object.keys(RISCV_ILP32_ABI)) + boundaries[name] = ( + await call("inspect_native_api", { procedure: fixture.symbols[name] }) + ).boundary; + assertRiscvIlp32Storage(boundaries, { stripped }); + const caller = functionDossierSchema.parse( + await call("analyze_function", { + procedure: fixture.symbols.rea_riscv_abi_caller, + detail: "complete", + }), + ); + assertRiscvIlp32Caller(caller.pseudocode, fixture.symbols, { + stripped, + }); + abi = { + storage: Object.fromEntries( + Object.entries(boundaries).map(([name, boundary]) => [ + name, + { + return: boundary.return_type.storage, + parameters: boundary.parameters.map( + (parameter) => parameter.storage, + ), + }, + ]), + ), + }; + } + + const snapshotPath = join( + workspace, + `${fixture.path.split("/").at(-1)}.snapshot.json`, + ); const saved = await call("close_binary", { snapshot_path: snapshotPath }); assert.ok(saved.evidence_records > 0); const snapshot = parseAnalysisSnapshot( @@ -288,6 +363,19 @@ try { assertProfile(snapshot.binding.analysis_profile); for (const record of snapshot.evidence_bundle.records) assertEvidence(parseEvidence(record)); + const outcomes = snapshot.evidence_bundle.records + .flatMap((record) => parseEvidence(record).limitations) + .map((limitation) => OUTCOME.exec(limitation)) + .filter((match) => match !== null); + assert.equal( + outcomes.length > 0, + soft, + "ilp32 prototype outcome missing from, or unexpected in, retained Evidence", + ); + // DWARF supplies the typed build's function definitions; stripping removes them. + if (soft) + for (const match of outcomes) + assert.equal(Number(match[1]) > 0, !stripped, match[0]); await call("open_binary", { path: fixture.path, provider_id: "ghidra", @@ -297,8 +385,11 @@ try { await call("close_binary"); for (const [command, arguments_] of [ - ["search", ["rea_riscv_", "--kind", "procedures"]], - ["function", ["rea_riscv_entry"]], + ...(stripped ? [] : [["search", ["rea_riscv_", "--kind", "procedures"]]]), + [ + "function", + [stripped ? fixture.symbols.rea_riscv_entry : "rea_riscv_entry"], + ], ]) { const cli = await execute( process.execPath, @@ -316,12 +407,7 @@ try { const evidence = parseEvidence(JSON.parse(cli.stdout)); assertEvidence(evidence); if (command === "search") - for (const name of [ - "rea_riscv_entry", - "rea_riscv_leaf", - "rea_riscv_float", - "rea_riscv_probe", - ]) + for (const name of names) assert.ok( evidence.normalized_result.some((value) => value.value === name), ); @@ -333,6 +419,92 @@ try { assert.ok(cliDossier.pseudocode.trim().length > 0); } } + const headers = []; + // Header-mutated copies test admission; they are not new compiler claims. + for (const [flags, outcome] of stripped + ? [] + : soft + ? [ + [0x3, "single-float ABI (ilp32f)"], + [0x80000001, "(0x80000000)"], + ] + : bits === 32 + ? [ + [0x1, "admitted"], + [0x80000005, "(0x80000000)"], + ] + : [ + [0x1, "soft-float ABI (lp64)"], + [0x80000005, "(0x80000000)"], + ]) { + const mutatedPath = join( + workspace, + `rv${bits}${soft ? "-soft" : ""}-flags-${flags.toString(16)}.elf`, + ); + const mutatedBytes = Buffer.from(sourceBytes); + mutatedBytes.writeUInt32LE(flags, bits === 32 ? 36 : 48); + await writeFile(mutatedPath, mutatedBytes); + const reply = await client.callTool( + { + name: "open_binary", + arguments: { path: mutatedPath, provider_id: "ghidra" }, + }, + { timeout: 240000 }, + ); + const cliArguments = [ + entrypoint, + "function", + mutatedPath, + "rea_riscv_entry", + "--provider", + "ghidra", + "--json", + ]; + const cliOptions = { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }; + if (outcome === "admitted") { + // The double-float image now declares ilp32: refused before, admitted + // now through the soft-float lane with the image's own ISA string. + assert.equal( + requireMcpLifecycleResult(reply, "open_binary").sha256, + createHash("sha256").update(mutatedBytes).digest("hex"), + ); + requireMcpLifecycleResult( + await client.callTool( + { name: "close_binary", arguments: {} }, + { timeout: 240000 }, + ), + "close_binary", + ); + const want = { + lane: "elf32-exec-le-soft-float-generic-v1", + callingConvention: "ilp32-prototype-v1", + riscvElf: { + ...expected.riscvElf, + flags, + float_abi_double: false, + float_abi: "soft", + arch: fixture.riscv.isa.arch, + }, + }; + const cli = await execute(process.execPath, cliArguments, cliOptions); + assertProfile( + parseEvidence(JSON.parse(cli.stdout)).analysis_profile, + want, + ); + headers.push({ flags, outcome, lane: want.lane }); + continue; + } + const error = requireMcpToolError(reply); + assert.ok(JSON.stringify(error).includes(outcome), JSON.stringify(error)); + await assert.rejects( + execute(process.execPath, cliArguments, cliOptions), + (cause) => + Number.isInteger(cause.code) && + cause.code !== 0 && + `${cause.stdout}\n${cause.stderr}`.includes(outcome), + ); + headers.push({ flags, outcome: "refused", reason: outcome }); + } assert.equal( createHash("sha256") .update(await readFile(fixture.path)) @@ -340,16 +512,38 @@ try { fixture.sha256, ); reports.push({ - ...fixture, + path: fixture.path, + sha256: fixture.sha256, + riscv: fixture.riscv, + stripped, + toolchain: fixture.toolchain, + sources: fixture.sources, language: expectedLanguage, + lane: expected.lane, + calling_convention: expected.callingConvention ?? null, procedures: procedures.length, - refusals, + headers, + ...(isaProbe.length === 0 ? {} : { isa_probe: isaProbe }), + ...(abi === undefined + ? {} + : { + abi, + prototype_outcome: outcomes.map((match) => match[0]), + }), cli: true, stdio_mcp: true, snapshot_replay: true, original_unchanged: true, }); } + reports.push({ + call_sites: { + fixture: typedSoft.path, + command: callSites.command, + version: callSites.version, + calls: callSites.calls.map((value) => value.target), + }, + }); } catch (cause) { failure = cause; } diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index e2801e8c81..3c2796577b 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -45,7 +45,11 @@ import type { } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; import { readMipsElfMetadata } from "./MipsElfMetadata.js"; -import { validateElfDeclaredRanges } from "../domain/elfDeclaredRanges.js"; +import { + validateElfDeclaredRanges, + type ElfDeclarationRead, +} from "../domain/elfDeclaredRanges.js"; +import { readRiscvIsaDeclaration } from "../domain/riscvIsaDeclaration.js"; import { resolveAppleBundleExecutable, type ResolvedAppBundle, @@ -1107,8 +1111,26 @@ const readElfMetadata = async ( const { fileSize, hostArchitecture, signal } = options; const parsed = parseExecutableHeader(bytes, hostArchitecture, fileSize); if (!parsed.ok || parsed.value.format !== "elf") return parsed; - const ranges = await readElfDeclaredRanges(handle, bytes, fileSize, signal); + const ranges = await readElfDeclarations( + handle, + bytes, + validateElfDeclaredRanges(bytes, fileSize), + signal, + ); if (!ranges.ok) return ranges; + if (parsed.value.architecture === "riscv") + return ok({ + ...parsed.value, + riscv: { + ...parsed.value.riscv, + isa: await readElfDeclarations( + handle, + bytes, + readRiscvIsaDeclaration(bytes, fileSize), + signal, + ), + }, + }); if (parsed.value.architecture !== "mips" || parsed.value.mips.elfClass !== 32) return parsed; const declarations = await readMipsElfMetadata( @@ -1133,15 +1155,18 @@ const readElfMetadata = async ( }); }; -/** Borrow the admitted descriptor; every request is at most one 64 KiB table chunk. */ -const readElfDeclaredRanges = async ( +/** + * Satisfy one ELF declaration reader from the admitted descriptor. Every + * request is at most one 64 KiB chunk, and the reader consumes each chunk + * before its next request reuses the window. + */ +const readElfDeclarations = async ( handle: FileHandle, prefix: Buffer, - fileSize: number, + steps: Generator, signal?: AbortSignal, -): Promise> => { - const ranges = validateElfDeclaredRanges(prefix, fileSize); - let next = ranges.next(); +): Promise => { + let next = steps.next(); let window: Buffer | undefined; while (!next.done) { throwIfTargetResolutionCancelled(signal); @@ -1166,7 +1191,7 @@ const readElfDeclaredRanges = async ( } bytes = bytes.subarray(0, length); } - next = ranges.next(bytes); + next = steps.next(bytes); } return next.value; }; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 10a881519e..a5213a9197 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -4,6 +4,7 @@ import type { HeaderArchitecture, MipsElfMetadata, PeCodeViewRecord, + RiscvElfMetadata, UniversalMachOSlice, UniversalMachOSliceIdentity, } from "./binaryTargetTypes.js"; @@ -47,7 +48,7 @@ export type ExecutableMetadata = readonly format: "elf"; readonly architecture: "riscv"; readonly availableArchitectures: readonly "riscv"[]; - readonly riscv: ElfFamilyMetadata; + readonly riscv: RiscvElfMetadata; } | { readonly format: "elf"; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index e9a78be453..ac63f7eacf 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -87,6 +87,32 @@ export interface MipsElfMetadata extends ElfFamilyMetadata { | { readonly status: "unavailable"; readonly reason: string }; } +/** Raw ELF facts; family recognition does not establish ISA or ABI support. */ +export interface RiscvElfMetadata extends ElfFamilyMetadata { + /** Undefined until `.riscv.attributes` is inspected. */ + readonly isa?: RiscvIsaDeclaration; +} + +/** The ISA an image declares in `.riscv.attributes`; not proof of every instruction. */ +export type RiscvIsaDeclaration = + | { + readonly status: "declared"; + /** `Tag_RISCV_arch` exactly as declared. */ + readonly arch: string; + readonly xlen: 32 | 64; + readonly base: "i" | "e"; + /** Extension names in declaration order, without versions. */ + readonly extensions: readonly string[]; + } + /** Every section header was examined and none declares `Tag_RISCV_arch`. */ + | { readonly status: "absent" } + | { + readonly status: "unavailable"; + readonly reason: string; + /** The declared string, when one was read but not understood. */ + readonly arch?: string; + }; + /** Declared fields of the 24-byte MIPS ABI flags record, not inferred semantics. */ export interface MipsAbiFlags { readonly version: number; @@ -164,8 +190,7 @@ export type BinaryTarget = readonly format: "elf"; readonly architecture: "riscv"; readonly availableArchitectures: readonly "riscv"[]; - /** ISA extensions in `.riscv.attributes` are not inspected by the header parser. */ - readonly riscv: ElfFamilyMetadata; + readonly riscv: RiscvElfMetadata; readonly executableRole?: never; readonly managed?: never; }) diff --git a/src/domain/riscvIsaDeclaration.test.ts b/src/domain/riscvIsaDeclaration.test.ts new file mode 100644 index 0000000000..940da80ecd --- /dev/null +++ b/src/domain/riscvIsaDeclaration.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "vitest"; +import { + parseRiscvAttributes, + riscvAbiName, + riscvFloatAbi, + riscvIsaDeclaration, +} from "./riscvIsaDeclaration.js"; + +const u32 = (value: number, little: boolean): number[] => { + const bytes = Buffer.alloc(4); + if (little) bytes.writeUInt32LE(value); + else bytes.writeUInt32BE(value); + return [...bytes]; +}; +const text = (value: string): number[] => [...Buffer.from(value, "latin1"), 0]; + +/** One vendor subsection holding one scoped list, as toolchains emit it. */ +const section = ( + attributes: readonly number[], + { + little = true, + vendor = "riscv", + scope = 1, + }: { + readonly little?: boolean; + readonly vendor?: string; + readonly scope?: number; + } = {}, +): Buffer => { + const scoped = [ + scope, + ...u32(1 + 4 + attributes.length, little), + ...attributes, + ]; + const name = text(vendor); + return Buffer.from([ + 0x41, + ...u32(4 + name.length + scoped.length, little), + ...name, + ...scoped, + ]); +}; + +// Byte-for-byte the `.riscv.attributes` section LLVM 21 writes for +// `-march=rv32imc -mabi=ilp32`: stack_align 16, then the ISA string. +const LLVM_RV32IMC = Buffer.from( + [ + "41350000", + "00726973", + "63760001", + "2b000000", + "04100572", + "76333269", + "3270315f", + "6d327030", + "5f633270", + "305f7a6d", + "6d756c31", + "70305f7a", + "63613170", + "3000", + ].join(""), + "hex", +); + +describe("RISC-V e_flags float ABI", () => { + it.each([ + [0x0, "soft", "ilp32", "lp64"], + [0x1, "soft", "ilp32", "lp64"], + [0x2, "single", "ilp32f", "lp64f"], + [0x3, "single", "ilp32f", "lp64f"], + [0x4, "double", "ilp32d", "lp64d"], + [0x5, "double", "ilp32d", "lp64d"], + [0x6, "quad", null, "lp64q"], + [0x1f, "quad", null, "lp64q"], + [0x8000_0001, "soft", "ilp32", "lp64"], + ] as const)( + "flags 0x%s declare the %s ABI", + (flags, floatAbi, rv32, rv64) => { + expect(riscvFloatAbi(flags)).toBe(floatAbi); + expect(riscvAbiName(32, floatAbi)).toBe(rv32); + expect(riscvAbiName(64, floatAbi)).toBe(rv64); + }, + ); +}); + +describe("RISC-V .riscv.attributes", () => { + it("reads the ISA string LLVM writes", () => { + expect(parseRiscvAttributes(LLVM_RV32IMC, true)).toEqual({ + ok: true, + value: "rv32i2p1_m2p0_c2p0_zmmul1p0_zca1p0", + }); + }); + + it.each([true, false])( + "reads lengths in the image's byte order (little=%s)", + (little) => { + expect( + parseRiscvAttributes( + section([5, ...text("rv32i2p1_c2p0")], { little }), + little, + ), + ).toEqual({ ok: true, value: "rv32i2p1_c2p0" }); + }, + ); + + it("skips tags it does not use by the psABI's odd-string, even-integer rule", () => { + expect( + parseRiscvAttributes( + section([ + 4, + 16, // Tag_RISCV_stack_align + 6, + 1, // Tag_RISCV_unaligned_access + 8, + 0x80, + 0x01, // two-byte ULEB128 + 5, + ...text("rv32i2p1"), + 7, + ...text("future string tag"), + 14, + 3, // Tag_RISCV_atomic_abi + ]), + true, + ), + ).toEqual({ ok: true, value: "rv32i2p1" }); + }); + + it("reports a section without an ISA string as null", () => { + expect(parseRiscvAttributes(section([4, 16]), true)).toEqual({ + ok: true, + value: null, + }); + expect(parseRiscvAttributes(Buffer.from([0x41]), true)).toEqual({ + ok: true, + value: null, + }); + }); + + it.each([ + ["an empty section", Buffer.alloc(0), "format version"], + ["another format version", Buffer.from([0x42]), "format version"], + [ + "a truncated subsection length", + Buffer.from([0x41, 0x05, 0x00]), + "truncated RISC-V attribute subsection length", + ], + [ + "a subsection longer than the section", + Buffer.from([0x41, 0xff, 0, 0, 0]), + "does not fit the section", + ], + [ + "another vendor", + section([5, ...text("rv32i2p1")], { vendor: "gnu" }), + "non-riscv vendor", + ], + [ + "a section-scoped list", + section([5, ...text("rv32i2p1")], { scope: 2 }), + "scoped to sections or symbols", + ], + [ + "a symbol-scoped list", + section([5, ...text("rv32i2p1")], { scope: 3 }), + "scoped to sections or symbols", + ], + [ + "two ISA strings", + section([5, ...text("rv32i2p1"), 5, ...text("rv32e2p0")]), + "duplicate RISC-V Tag_RISCV_arch", + ], + [ + "an unterminated string", + section([5, ...Buffer.from("rv32i")]), + "unterminated string value", + ], + ["an unterminated integer", section([4, 0x80]), "malformed integer value"], + [ + "an integer beyond 32 bits", + section([4, 0xff, 0xff, 0xff, 0xff, 0x7f]), + "malformed integer value", + ], + ] as const)("refuses %s", (_label, bytes, reason) => { + const parsed = parseRiscvAttributes(bytes, true); + expect(parsed.ok).toBe(false); + if (parsed.ok) return; + expect(parsed.error).toContain(reason); + }); + + it("refuses a second riscv subsection", () => { + const one = section([5, ...text("rv32i2p1")]); + expect( + parseRiscvAttributes(Buffer.concat([one, one.subarray(1)]), true), + ).toEqual({ + ok: false, + error: "duplicate riscv vendor attribute subsections", + }); + }); + + it("refuses a scope length that leaves its subsection", () => { + const bytes = section([5, ...text("rv32i2p1")]); + bytes.writeUInt32LE(0xff, 4 + 1 + 6 + 1); + expect(parseRiscvAttributes(bytes, true)).toEqual({ + ok: false, + error: "RISC-V attribute scope length does not fit its subsection", + }); + }); +}); + +describe("RISC-V Tag_RISCV_arch", () => { + it.each([ + // LLVM 21 for the lane's soft-float fixture. + [ + "rv32i2p1_m2p0_c2p0_zicsr2p0_zifencei2p0_zmmul1p0_zca1p0", + 32, + "i", + ["m", "c", "zicsr", "zifencei", "zmmul", "zca"], + ], + // GCC 14 for ESP-IDF's ESP32-C6 base string. + [ + "rv32i2p1_m2p0_a2p1_c2p0_zicsr2p0_zifencei2p0_zaamo1p0_zalrsc1p0_zmmul1p0_zca1p0", + 32, + "i", + ["m", "a", "c", "zicsr", "zifencei", "zaamo", "zalrsc", "zmmul", "zca"], + ], + // ESP32-P4 with Espressif's vendor extensions and versions on them. + [ + "rv32i2p1_m2p0_a2p1_f2p2_c2p0_zicsr2p0_xesploop1p0_xespv2p1", + 32, + "i", + ["m", "a", "f", "c", "zicsr", "xesploop", "xespv"], + ], + ["rv64i2p1_m2p0_a2p1_f2p2_d2p2_c2p0", 64, "i", ["m", "a", "f", "d", "c"]], + ["rv32e2p0_c2p0", 32, "e", ["c"]], + ["rv32i", 32, "i", []], + // Digits inside a name are not a version. + ["rv64i2p1_zve32x1p0_zvl128b1p0", 64, "i", ["zve32x", "zvl128b"]], + ] as const)("reads %s", (arch, xlen, base, extensions) => { + expect(riscvIsaDeclaration(arch)).toEqual({ + status: "declared", + arch, + xlen, + base, + extensions, + }); + }); + + it.each([ + ["", "canonical rv32/rv64"], + ["rv128i2p0", "canonical rv32/rv64"], + ["RV32I2P1", "canonical rv32/rv64"], + ["rv32imac", "canonical rv32/rv64"], + ["rv32i2p1__m2p0", 'non-canonical extension token ""'], + ["rv32i2p1_M2p0", 'non-canonical extension token "M2p0"'], + ["rv32i2p1_m2p0_m2p0", "declares extension m twice"], + ])("keeps %j unavailable with its raw value", (arch, reason) => { + const declaration = riscvIsaDeclaration(arch); + expect(declaration).toMatchObject({ status: "unavailable", arch }); + if (declaration.status !== "unavailable") return; + expect(declaration.reason).toContain(reason); + }); +}); diff --git a/src/domain/riscvIsaDeclaration.ts b/src/domain/riscvIsaDeclaration.ts new file mode 100644 index 0000000000..a8d84fa428 --- /dev/null +++ b/src/domain/riscvIsaDeclaration.ts @@ -0,0 +1,260 @@ +import type { RiscvIsaDeclaration } from "./binaryTargetTypes.js"; +import { + readElfTableDeclarations, + type ElfDeclarationRead, +} from "./elfDeclaredRanges.js"; +import { readElfSectionHeader } from "./elfSectionHeader.js"; +import { err, ok, type Result } from "./result.js"; + +/** `EF_RISCV_FLOAT_ABI` declarations in `e_flags`, named as the psABI names them. */ +export const RISCV_FLOAT_ABIS = ["soft", "single", "double", "quad"] as const; +export type RiscvFloatAbi = (typeof RISCV_FLOAT_ABIS)[number]; + +const EF_RISCV_FLOAT_ABI = 0x0006; + +/** The float ABI that bits 1–2 of an ELF `e_flags` word declare. */ +export const riscvFloatAbi = (flags: number): RiscvFloatAbi => + RISCV_FLOAT_ABIS[(flags & EF_RISCV_FLOAT_ABI) >>> 1]!; + +/** The psABI name of a class and float ABI; RV32 has no named quad-float ABI. */ +export const riscvAbiName = ( + elfClass: 32 | 64, + floatAbi: RiscvFloatAbi, +): string | null => + elfClass === 32 && floatAbi === "quad" + ? null + : `${elfClass === 32 ? "ilp32" : "lp64"}${ + { soft: "", single: "f", double: "d", quad: "q" }[floatAbi] + }`; + +/** `SHT_RISCV_ATTRIBUTES`, the psABI's build-attributes section type. */ +export const SHT_RISCV_ATTRIBUTES = 0x70000003; +/** Bound the section read before its declared size can drive allocation. */ +export const RISCV_ATTRIBUTES_MAX_BYTES = 4096; + +const FORMAT_VERSION = 0x41; +const TAG_FILE = 1; +const TAG_RISCV_ARCH = 5; + +/** + * Read `Tag_RISCV_arch` from a `.riscv.attributes` section; null means the + * section declares no ISA string. The psABI gives odd tags string values and + * even tags integer values, so tags REA does not use are skipped by that rule. + * Other vendors and section or symbol scopes fail closed: their declarations + * are not skipped. + */ +export const parseRiscvAttributes = ( + bytes: Uint8Array, + littleEndian: boolean, +): Result => { + if (bytes.length === 0 || bytes[0] !== FORMAT_VERSION) + return err("unsupported RISC-V attribute section format version"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const readU32 = (offset: number): number => + view.getUint32(offset, littleEndian); + const readUleb = ( + offset: number, + end: number, + ): { readonly value: number; readonly next: number } | undefined => { + let value = 0; + for (let index = 0; index < 5 && offset + index < end; index += 1) { + const byte = bytes[offset + index]!; + value += (byte & 0x7f) * 2 ** (7 * index); + if ((byte & 0x80) === 0) + return value <= 0xffffffff + ? { value, next: offset + index + 1 } + : undefined; + } + return undefined; + }; + const readString = ( + offset: number, + end: number, + ): { readonly value: string; readonly next: number } | undefined => { + const stop = bytes.indexOf(0, offset); + return stop < 0 || stop >= end + ? undefined + : { + value: Buffer.from(bytes.subarray(offset, stop)).toString("latin1"), + next: stop + 1, + }; + }; + + let arch: string | null = null; + let vendorSeen = false; + let position = 1; + while (position < bytes.length) { + if (bytes.length - position < 4) + return err("truncated RISC-V attribute subsection length"); + const length = readU32(position); + if (length < 4 || length > bytes.length - position) + return err("RISC-V attribute subsection length does not fit the section"); + const end = position + length; + const vendor = readString(position + 4, end); + if (vendor === undefined) + return err("unterminated RISC-V attribute vendor name"); + if (vendor.value !== "riscv") + return err( + "RISC-V ELF declares non-riscv vendor attributes that REA does not inspect", + ); + if (vendorSeen) return err("duplicate riscv vendor attribute subsections"); + vendorSeen = true; + + let cursor = vendor.next; + while (cursor < end) { + const scopeStart = cursor; + const scope = readUleb(cursor, end); + if (scope === undefined) + return err("malformed RISC-V attribute scope tag"); + if (scope.value !== TAG_FILE) + return err( + "RISC-V attributes scoped to sections or symbols are not inspected by REA", + ); + if (end - scope.next < 4) + return err("truncated RISC-V attribute scope length"); + const scopeLength = readU32(scope.next); + if ( + scopeLength < scope.next + 4 - scopeStart || + scopeLength > end - scopeStart + ) + return err("RISC-V attribute scope length does not fit its subsection"); + const scopeEnd = scopeStart + scopeLength; + cursor = scope.next + 4; + while (cursor < scopeEnd) { + const tag = readUleb(cursor, scopeEnd); + if (tag === undefined) return err("malformed RISC-V attribute tag"); + if (tag.value % 2 === 1) { + const value = readString(tag.next, scopeEnd); + if (value === undefined) + return err( + `unterminated string value for RISC-V attribute tag ${tag.value}`, + ); + if (tag.value === TAG_RISCV_ARCH) { + if (arch !== null) + return err("duplicate RISC-V Tag_RISCV_arch attributes"); + arch = value.value; + } + cursor = value.next; + } else { + const value = readUleb(tag.next, scopeEnd); + if (value === undefined) + return err( + `malformed integer value for RISC-V attribute tag ${tag.value}`, + ); + cursor = value.next; + } + } + } + position = end; + } + return ok(arch); +}; + +const ISA_BASE = /^rv(32|64)([ie])(?:\d+p\d+)?$/u; +const ISA_EXTENSION = /^([a-z][a-z0-9]*?)(?:\d+p\d+)?$/u; + +/** + * Interpret a `Tag_RISCV_arch` string in the psABI's canonical form, such as + * `rv32i2p1_m2p0_c2p0_zicsr2p0`: a base, then one underscore before each + * extension, each with an optional `p` version. A string in + * another form keeps its raw value and stays unavailable. + */ +export const riscvIsaDeclaration = (arch: string): RiscvIsaDeclaration => { + const unavailable = (reason: string): RiscvIsaDeclaration => ({ + status: "unavailable", + reason, + arch, + }); + const [first = "", ...rest] = arch.split("_"); + const base = ISA_BASE.exec(first); + if (base === null) + return unavailable( + "Tag_RISCV_arch does not begin with a canonical rv32/rv64 I or E base", + ); + const extensions: string[] = []; + for (const token of rest) { + const name = ISA_EXTENSION.exec(token)?.[1]; + if (name === undefined) + return unavailable( + `Tag_RISCV_arch contains a non-canonical extension token "${token}"`, + ); + if (extensions.includes(name)) + return unavailable(`Tag_RISCV_arch declares extension ${name} twice`); + extensions.push(name); + } + return { + status: "declared", + arch, + xlen: base[1] === "32" ? 32 : 64, + base: base[2] === "e" ? "e" : "i", + extensions, + }; +}; + +/** + * Find and interpret the image's `SHT_RISCV_ATTRIBUTES` section. Every section + * header is examined, so an absent declaration is observed, not assumed; two + * attribute sections leave the declaration unavailable. The caller satisfies + * each read from its own descriptor. + */ +export function* readRiscvIsaDeclaration( + prefix: Buffer, + fileSize: number, +): Generator { + const unavailable = (reason: string): RiscvIsaDeclaration => ({ + status: "unavailable", + reason, + }); + const declarations = yield* readElfTableDeclarations(prefix, fileSize); + if (!declarations.ok) return unavailable(declarations.error); + const { header, sections } = declarations.value; + const little = header.byteOrder === "little"; + let attributes: + | { readonly offset: bigint; readonly size: bigint } + | undefined; + const perRead = Math.max(1, Math.floor((64 * 1024) / sections.width)); + for (let index = 0; index < sections.count; index += perRead) { + const count = Math.min(perRead, sections.count - index); + const size = count * sections.width; + const bytes = yield { + offset: Number(sections.offset) + index * sections.width, + size, + }; + if (bytes.length !== size) + return unavailable("truncated ELF section header table"); + for (let item = 0; item < count; item += 1) { + const at = item * sections.width; + const parsed = readElfSectionHeader( + bytes.subarray(at, at + sections.width), + header.elfClass, + little, + ); + if (!parsed.ok) return unavailable(parsed.error.message); + if (parsed.value.type !== SHT_RISCV_ATTRIBUTES) continue; + if (attributes !== undefined) + return unavailable( + "the image has more than one RISC-V attribute section", + ); + attributes = { offset: parsed.value.offset, size: parsed.value.size }; + } + } + if (attributes === undefined) return { status: "absent" }; + if ( + attributes.size === 0n || + attributes.size > BigInt(RISCV_ATTRIBUTES_MAX_BYTES) + ) + return unavailable( + `RISC-V attribute section size ${String(attributes.size)} is outside REA's inspected bound of ${RISCV_ATTRIBUTES_MAX_BYTES} bytes`, + ); + if (attributes.offset > BigInt(fileSize) - attributes.size) + return unavailable("RISC-V attribute section lies outside the file"); + const size = Number(attributes.size); + const bytes = yield { offset: Number(attributes.offset), size }; + if (bytes.length !== size) + return unavailable("truncated RISC-V attribute section"); + const arch = parseRiscvAttributes(bytes, little); + if (!arch.ok) return unavailable(arch.error); + return arch.value === null + ? { status: "absent" } + : riscvIsaDeclaration(arch.value); +} diff --git a/src/ghidra/GhidraAdmissionError.ts b/src/ghidra/GhidraAdmissionError.ts index ed3e4af241..f6ecbf7890 100644 --- a/src/ghidra/GhidraAdmissionError.ts +++ b/src/ghidra/GhidraAdmissionError.ts @@ -6,11 +6,13 @@ const GHIDRA_ADMISSION_CODES = [ "existing_project_raw_binary", "existing_project_language_override", "existing_project_mips_soft_float", + "existing_project_riscv_calling_convention", "existing_project_analysis", "gamecube_dol_combined", "psp_combined", "psp_raw_binary", "mips_soft_float_scope", + "riscv_calling_convention_scope", "language_override_dos", "language_override_raw_binary", "raw_binary_dos", diff --git a/src/ghidra/GhidraBridgeSources.ts b/src/ghidra/GhidraBridgeSources.ts index e0066eab11..509eaa9811 100644 --- a/src/ghidra/GhidraBridgeSources.ts +++ b/src/ghidra/GhidraBridgeSources.ts @@ -10,4 +10,5 @@ export const GHIDRA_BRIDGE_SOURCES = Object.freeze({ analysisOptions: "ReaGhidraAnalysisOptions.java", noReturnFix: "ReaGhidraNoReturnFix.java", mipsSoftFloat: "ReaGhidraMipsSoftFloat.java", + riscvCallingConvention: "ReaGhidraRiscvCallingConvention.java", } as const); diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 410fc1d7b9..f916b276e1 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -7,6 +7,7 @@ import { type GhidraSeedCommitment, } from "./GhidraAnalysisSeeds.js"; import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; +import type { GhidraRiscvCallingConvention } from "./GhidraRiscvProfile.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { mkdir, readFile, realpath } from "node:fs/promises"; import { basename, dirname, join, posix, win32 } from "node:path"; @@ -252,6 +253,8 @@ export interface GhidraHeadlessLauncherOptions { readonly pspExtension?: GhidraPspExtension; /** Remap typed float storage for the soft-float o32 ABI after analysis. */ readonly mipsSoftFloat?: true; + /** Apply this committed RISC-V calling-convention policy after analysis. */ + readonly riscvCallingConvention?: GhidraRiscvCallingConvention; /** Spawn seam for provider-boundary lifecycle tests. */ readonly spawnProcess?: typeof spawnOwnedProviderProcess; /** LaunchSupport probe seam for Java-free launcher fixtures. */ @@ -415,6 +418,11 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { ...(this.options.mipsSoftFloat === undefined ? {} : { mipsSoftFloat: this.options.mipsSoftFloat }), + ...(this.options.riscvCallingConvention === undefined + ? {} + : { + riscvCallingConvention: this.options.riscvCallingConvention, + }), projectRoot: paths.projectRoot, targetPath: session.targetPath, ...(existingProject === undefined ? {} : { existingProject }), @@ -767,6 +775,7 @@ export interface GhidraHeadlessArgumentOptions { readonly scriptLogPath: string; readonly psp?: true; readonly mipsSoftFloat?: true; + readonly riscvCallingConvention?: GhidraRiscvCallingConvention; readonly dosMz?: true; readonly dosCom?: true; readonly rawBinary?: { @@ -796,6 +805,7 @@ type ExistingProjectOptions = Pick< | "rawBinary" | "languageOverride" | "mipsSoftFloat" + | "riscvCallingConvention" | "seedPath" | "disabledAnalyzers" > & { readonly analysisSeeds?: GhidraSeedCommitment }; @@ -824,6 +834,10 @@ const validateExistingProjectOptions = ( throw new GhidraAdmissionError("existing_project_language_override"); if (options.mipsSoftFloat === true) throw new GhidraAdmissionError("existing_project_mips_soft_float"); + if (options.riscvCallingConvention !== undefined) + throw new GhidraAdmissionError( + "existing_project_riscv_calling_convention", + ); if ( options.seedPath !== undefined || options.analysisSeeds !== undefined || @@ -853,6 +867,7 @@ export const ghidraHeadlessArguments = ( options.dosMz === true || options.dosCom === true || options.mipsSoftFloat === true || + options.riscvCallingConvention !== undefined || rawBinary !== undefined || override !== undefined) ) @@ -874,6 +889,15 @@ export const ghidraHeadlessArguments = ( rawBinary !== undefined) ) throw new GhidraAdmissionError("mips_soft_float_scope"); + if ( + options.riscvCallingConvention !== undefined && + (options.psp === true || + options.mipsSoftFloat === true || + options.dosMz === true || + options.dosCom === true || + rawBinary !== undefined) + ) + throw new GhidraAdmissionError("riscv_calling_convention_scope"); if ( override !== undefined && (options.dosMz === true || options.dosCom === true) @@ -1024,6 +1048,16 @@ export const ghidraHeadlessArguments = ( scriptPath.join(bridgeDirectory, GHIDRA_BRIDGE_SOURCES.mipsSoftFloat), ] : []), + ...(options.riscvCallingConvention === undefined + ? [] + : [ + "-postScript", + scriptPath.join( + bridgeDirectory, + GHIDRA_BRIDGE_SOURCES.riscvCallingConvention, + ), + options.riscvCallingConvention, + ]), "-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/GhidraProjectHistory.ts b/src/ghidra/GhidraProjectHistory.ts index ed386555e5..7572e6374d 100644 --- a/src/ghidra/GhidraProjectHistory.ts +++ b/src/ghidra/GhidraProjectHistory.ts @@ -19,6 +19,9 @@ export const ghidraProjectHistoryEvidence = ( ...(info.mips_soft_float_storage === undefined ? {} : { mips_soft_float_storage: info.mips_soft_float_storage }), + ...(info.riscv_calling_convention === undefined + ? {} + : { riscv_calling_convention: info.riscv_calling_convention }), }; if (Object.keys(reports).length === 0) return undefined; return createEvidence( diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 813d3dfc0a..915e2e0780 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -88,7 +88,12 @@ import { GHIDRA_PSP_LIMITATIONS, } from "./GhidraPspProfile.js"; import { ghidraPspExtensionSchema } from "./GhidraPspExtension.js"; -import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; +import { + ghidraRiscvCallingConvention, + ghidraRiscvCallingConventionFailure, + ghidraRiscvCallingConventionLimitations, + ghidraRiscvLimitations, +} from "./GhidraRiscvProfile.js"; import { ghidraPePdbLimitations } from "./GhidraPeProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraReleasePolicy.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; @@ -195,6 +200,19 @@ export const createGhidraProviderClient = (input: { }, }), ); + const riscvCallingConvention = ghidraRiscvCallingConvention(target); + if ( + riscvCallingConvention !== + committedProfile.parameters.riscv_calling_convention + ) + return err( + new ProviderAdapterError("ghidra", "open_binary", { + diagnostics: { + reason: + "Resolve the RISC-V calling-convention policy into the analysis profile before opening the session.", + }, + }), + ); const extensionProfile = ghidraExtensionSchema .array() .safeParse(committedProfile.parameters.analysis_extensions ?? []); @@ -349,6 +367,10 @@ export const createGhidraProviderClient = (input: { const softFloatLimitations: string[] = [ ...ghidraMipsSoftFloatLimitations(softFloat), ]; + // Replaced by the reported prototype outcome once the handshake is checked. + const riscvCallingConventionLimitations: string[] = [ + ...ghidraRiscvCallingConventionLimitations(riscvCallingConvention), + ]; // Replaced by the reported per-program outcome once the handshake is checked. const disabledAnalyzerLimitations: string[] = []; const rawLanguage = @@ -443,6 +465,9 @@ export const createGhidraProviderClient = (input: { ? { pspExtension: pspExtension.data } : {}), ...(softFloat ? { mipsSoftFloat: true as const } : {}), + ...(riscvCallingConvention === undefined + ? {} + : { riscvCallingConvention }), ...(target.format === "dos-mz" ? { dosMz: true } : {}), ...(target.format === "dos-com" ? { dosCom: true } : {}), ...(rawLanguage === undefined || target.format !== "raw-binary" @@ -542,10 +567,12 @@ export const createGhidraProviderClient = (input: { info, ); } else { - const softFloatReason = ghidraMipsSoftFloatFailure( - softFloat, - info.mips_soft_float_storage, - ); + const callingConventionReason = + ghidraMipsSoftFloatFailure(softFloat, info.mips_soft_float_storage) ?? + ghidraRiscvCallingConventionFailure( + riscvCallingConvention, + info.riscv_calling_convention, + ); extensionFailure = ghidraExtensionFailure( extensions, @@ -558,10 +585,10 @@ export const createGhidraProviderClient = (input: { info.disabled_analyzers, operation, ) ?? - (softFloatReason === undefined + (callingConventionReason === undefined ? undefined : new ProviderAdapterError("ghidra", operation, { - diagnostics: { reason: softFloatReason }, + diagnostics: { reason: callingConventionReason }, })); if (extensionFailure === undefined && info.analysis_seeds !== undefined) seedLimitations.splice( @@ -578,6 +605,15 @@ export const createGhidraProviderClient = (input: { info.mips_soft_float_storage, ), ); + if (extensionFailure === undefined) + riscvCallingConventionLimitations.splice( + 0, + riscvCallingConventionLimitations.length, + ...ghidraRiscvCallingConventionLimitations( + riscvCallingConvention, + info.riscv_calling_convention, + ), + ); if (extensionFailure === undefined) disabledAnalyzerLimitations.splice( 0, @@ -621,6 +657,7 @@ export const createGhidraProviderClient = (input: { ...targetLimitations, ...(psp ? GHIDRA_PSP_LIMITATIONS : []), ...softFloatLimitations, + ...riscvCallingConventionLimitations, ...ghidraExtensionLimitations(extensions), ...seedLimitations, ...disabledAnalyzerLimitations, @@ -767,7 +804,8 @@ export const createGhidraProviderClient = (input: { extensions.length > 0 || seeds !== undefined || disabledAnalyzers !== undefined || - softFloat + softFloat || + riscvCallingConvention !== undefined ) { const started = await startSession(operation, options); if (!started.ok) return started; diff --git a/src/ghidra/GhidraRiscvProfile.test.ts b/src/ghidra/GhidraRiscvProfile.test.ts index 5c38aca96d..f8c8dcd228 100644 --- a/src/ghidra/GhidraRiscvProfile.test.ts +++ b/src/ghidra/GhidraRiscvProfile.test.ts @@ -1,21 +1,36 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, parse, relative } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { parseConfig } from "../config/parseConfig.js"; import type { BinaryTarget, - ElfFamilyMetadata, + RiscvElfMetadata, } from "../domain/binaryTargetTypes.js"; +import { ok } from "../domain/result.js"; +import { riscvIsaDeclaration } from "../domain/riscvIsaDeclaration.js"; import { silentLogger } from "../logger.js"; import { HopperProvider } from "../hopper/HopperProvider.js"; import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js"; import { IdaProvider } from "../ida/IdaProvider.js"; import { GhidraProvider } from "./GhidraProvider.js"; import { + GHIDRA_RISCV32_DECODED_EXTENSIONS, + GHIDRA_RISCV_CALLING_CONVENTIONS, + GHIDRA_RISCV_CALLING_CONVENTION_IDS, + GHIDRA_RISCV_LANES, + ghidraRiscvCallingConvention, + ghidraRiscvCallingConventionFailure, ghidraRiscvLimitations, + ghidraRiscvSupportLane, ghidraRiscvUnsupportedReason, + type GhidraRiscvCallingConventionReport, } from "./GhidraRiscvProfile.js"; +import { ghidraProjectHistoryEvidence } from "./GhidraProjectHistory.js"; +import { + GHIDRA_SESSION_CAPABILITIES, + type GhidraSessionInfo, +} from "./GhidraSessionValues.js"; import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js"; import { ghidraPinnedLanguage, @@ -63,7 +78,7 @@ const configOf = ( if (!parsed.ok) throw parsed.error; return parsed.value; }; -const target = (change: Partial = {}): BinaryTarget => ({ +const target = (change: Partial = {}): BinaryTarget => ({ path: "/fixture.elf", sha256: "a".repeat(64), kind: "executable", @@ -78,6 +93,18 @@ const target = (change: Partial = {}): BinaryTarget => ({ ...change, }, }); +// ESP-IDF's ESP32-C3 base string, as LLVM records it for the lane's fixture. +const C3_ARCH = "rv32i2p1_m2p0_c2p0_zicsr2p0_zifencei2p0_zmmul1p0"; +const ilp32 = ( + arch: string = C3_ARCH, + change: Partial = {}, +): BinaryTarget => + target({ + elfClass: 32, + flags: 0x1, + isa: riscvIsaDeclaration(arch), + ...change, + }); let installDir = ""; beforeAll(async () => { @@ -205,12 +232,27 @@ const registerRiscvAdmissionAndCapabilityCases = () => { [{ type: 0xffa0 }, "e_type 65440"], [{ flags: 0xc }, "EF_RISCV_RVE"], [{ flags: 0x14 }, "EF_RISCV_TSO"], - [{ flags: 0x1 }, "soft-float convention"], - [{ flags: 0x3 }, "single-float"], - [{ flags: 0x7 }, "quad-float"], + [{ flags: 0x1 }, "soft-float ABI (lp64) declared by e_flags"], + [{ flags: 0x3 }, "single-float ABI (lp64f)"], + [{ flags: 0x7 }, "quad-float ABI (lp64q)"], + [{ elfClass: 32, flags: 0x3 }, "single-float ABI (ilp32f)"], + [{ elfClass: 32, flags: 0x7 }, "the quad-float ABI declared by e_flags"], + [{ elfClass: 32, flags: 0x1 }, "which was not inspected"], + [{ elfClass: 32, flags: 0x1, isa: { status: "absent" } }, "declares none"], + [ + { + elfClass: 32, + flags: 0x1, + isa: { + status: "unavailable", + reason: "truncated RISC-V attribute section", + }, + }, + "readable Tag_RISCV_arch declaration: truncated RISC-V attribute section", + ], [{ flags: 0x8000_0005 }, "(0x80000000)"], [{ flags: 0x24 }, "bits outside the verified lane"], - ] satisfies [Partial, string][])( + ] satisfies [Partial, string][])( "refuses an unverified RISC-V interpretation %j", async (change, reason) => { const provider = ghidra(); @@ -272,9 +314,14 @@ const registerRiscvAdmissionAndCapabilityCases = () => { it("states the linkage claim boundary in the lane limitations", () => { const notes = ghidraRiscvLimitations(target()); - expect(notes).toHaveLength(3); - expect(notes.join(" ")).toContain("does not establish static linkage"); - expect(notes[0]).toContain("RISCV:LE:64:default"); + // Facts, independent of the owner's wording and order: the pinned + // language and the static-linkage boundary. + expect(notes).toContainEqual( + expect.stringContaining("RISCV:LE:64:default"), + ); + expect(notes).toContainEqual( + expect.stringContaining("does not establish static linkage"), + ); expect( ghidraRiscvLimitations({ path: "/other.elf", @@ -309,7 +356,350 @@ const registerRiscvAdmissionAndCapabilityCases = () => { }); }; +describe("RISC-V lane and calling-convention tables", () => { + it("builds each lane id from its fields and keeps the double-float ids", () => { + expect(GHIDRA_RISCV_LANES.map(ghidraRiscvSupportLane)).toEqual([ + "elf64-exec-le-double-float-generic-v1", + "elf32-exec-le-double-float-generic-v1", + "elf32-exec-le-soft-float-generic-v1", + ]); + }); + + it("gives every non-double lane one policy and every policy one lane", () => { + const corrected = GHIDRA_RISCV_LANES.filter( + (lane) => lane.floatAbi !== "double", + ); + expect(corrected.map((lane) => lane.callingConvention)).toEqual( + GHIDRA_RISCV_CALLING_CONVENTION_IDS, + ); + expect( + GHIDRA_RISCV_LANES.filter((lane) => lane.floatAbi === "double").every( + (lane) => lane.callingConvention === undefined, + ), + ).toBe(true); + expect(GHIDRA_RISCV_CALLING_CONVENTIONS).toEqual({ + "ilp32-prototype-v1": { abi: "ilp32", prototype: "__rea_ilp32" }, + }); + }); + + it("keeps the decision record in docs/ghidra-riscv.md in step with the tables", async () => { + const docs = await readFile( + new URL("../../docs/ghidra-riscv.md", import.meta.url), + "utf8", + ); + for (const lane of GHIDRA_RISCV_LANES) + expect(docs).toContain(`\`${ghidraRiscvSupportLane(lane)}\``); + for (const id of GHIDRA_RISCV_CALLING_CONVENTION_IDS) { + const { abi, prototype } = GHIDRA_RISCV_CALLING_CONVENTIONS[id]; + expect(docs).toContain(`Policy \`${id}\`, prototype \`${prototype}\``); + expect(docs).toContain(`### ${abi}\n`); + } + const decoded = + /implements with\s+p-code: (.+?)\.\s+Any\s+other\s+extension/su.exec( + docs, + )?.[1]; + expect( + new Set( + [...(decoded ?? "").matchAll(/`([a-z0-9]+)`/gu)].map((m) => m[1]), + ), + ).toEqual(GHIDRA_RISCV32_DECODED_EXTENSIONS); + }); +}); + describe("bounded Ghidra RISC-V admission", () => { registerRiscvLanguageCommitmentCases(); registerRiscvAdmissionAndCapabilityCases(); }); + +describe("RISC-V ilp32 admission", () => { + it.each([ + ["ESP32-C2/C3 base", C3_ARCH], + [ + "ESP32-C5/C6/C61/H2/H21 base", + "rv32i2p1_m2p0_a2p1_c2p0_zicsr2p0_zifencei2p0_zaamo1p0_zalrsc1p0_zmmul1p0_zca1p0", + ], + [ + "soft-float ABI on F and D hardware", + "rv32i2p1_m2p0_a2p1_f2p2_d2p2_c2p0_zicsr2p0_zmmul1p0_zca1p0_zcf1p0_zcd1p0", + ], + ["the bare base", "rv32i2p1"], + ])("admits the %s", async (_label, arch) => { + const value = ilp32(arch); + expect(ghidra().inspectTargetSupport(value).status).toBe("supported"); + expect(ghidraPinnedLanguage(value)).toEqual({ + languageId: "RISCV:LE:32:default", + compilerSpecId: "gcc", + }); + expect(ghidraRiscvCallingConvention(value)).toBe("ilp32-prototype-v1"); + }); + + it.each([ + [ + "ESP-IDF's optional bit-manipulation and Zc extensions", + `${C3_ARCH}_zba1p0_zbb1p0_zbs1p0_zcb1p0_zcmp1p0_zcmt1p0`, + "outside the set this lane decodes: zba, zbb, zbs, zcb, zcmp, zcmt.", + ], + [ + "Espressif's vendor extensions", + "rv32i2p1_m2p0_c2p0_xesploop1p0_xespv2p1_xespdsp1p0", + "outside the set this lane decodes: xesploop, xespv, xespdsp.", + ], + ["an RV32E base", "rv32e2p0_m2p0_c2p0", "requires the RV32I base"], + ["an RV64 string", "rv64i2p1_m2p0_c2p0", "requires the RV32I base"], + [ + "a non-canonical string", + "rv32imac", + "readable Tag_RISCV_arch declaration: Tag_RISCV_arch does not begin", + ], + ])("refuses %s", async (_label, arch, reason) => { + const value = ilp32(arch); + expect(ghidra().inspectTargetSupport(value)).toMatchObject({ + status: "unsupported", + code: "architecture_unsupported", + reason: expect.stringContaining(reason), + }); + expect((await ghidra().resolveAnalysisProfile(value)).ok).toBe(false); + expect(ghidraPinnedLanguage(value)).toBeUndefined(); + expect(ghidraRiscvCallingConvention(value)).toBeUndefined(); + }); + + it("commits the soft-float lane, its declarations and its policy", async () => { + const resolved = await ghidra().resolveAnalysisProfile(ilp32()); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected an ilp32 profile"); + const parameters = resolved.value.profile.parameters; + expect(parameters.riscv_elf).toEqual({ + elf_class: 32, + byte_order: "little", + type: 2, + flags: 1, + float_abi_double: false, + float_abi: "soft", + arch: C3_ARCH, + rvc_declared: true, + rve_declared: false, + tso_declared: false, + }); + expect(parameters.riscv_calling_convention).toBe("ilp32-prototype-v1"); + expect(parameters.riscv_support_lane).toBe( + "elf32-exec-le-soft-float-generic-v1", + ); + expect(parameters.language_id).toBe("RISCV:LE:32:default"); + expect(parameters.language_selection).toBe("provider-pinned-v1"); + const other = await ghidra().resolveAnalysisProfile( + ilp32(`${C3_ARCH.replace("_zmmul1p0", "")}_zaamo1p0`), + ); + if (!other.ok || other.value.profile === null) + throw new Error("expected an ilp32 profile"); + expect(other.value.profile.digest).not.toBe(resolved.value.profile.digest); + }); + + it("keeps double-float profiles exactly as before, whatever the ISA declaration", async () => { + const plain = await ghidra().resolveAnalysisProfile( + target({ elfClass: 32, flags: 0x5 }), + ); + const declared = await ghidra().resolveAnalysisProfile( + target({ + elfClass: 32, + flags: 0x5, + isa: riscvIsaDeclaration("rv32i2p1_m2p0_a2p1_f2p2_d2p2_c2p0_zcmp1p0"), + }), + ); + if ( + !plain.ok || + !declared.ok || + plain.value.profile === null || + declared.value.profile === null + ) + throw new Error("expected double-float profiles"); + expect(plain.value.profile.parameters.riscv_elf).toEqual({ + elf_class: 32, + byte_order: "little", + type: 2, + flags: 5, + float_abi_double: true, + rvc_declared: true, + rve_declared: false, + tso_declared: false, + }); + expect(plain.value.profile.parameters).not.toHaveProperty( + "riscv_calling_convention", + ); + expect(declared.value.profile.digest).toBe(plain.value.profile.digest); + }); + + it("states the declared ABI, the applied prototype and the declared ISA", () => { + const notes = ghidraRiscvLimitations(ilp32()); + // One note names the declared ABI, the ABI the pinned language models + // and the prototype REA applies, in any wording and order. + expect(notes).toContainEqual( + expect.stringMatching( + /^(?=.*\(ilp32\))(?=.*\bilp32d\b)(?=.*`__rea_ilp32`)/su, + ), + ); + expect(notes).toContainEqual(expect.stringContaining(C3_ARCH)); + }); +}); + +const report: GhidraRiscvCallingConventionReport = { + policy: "ilp32-prototype-v1", + functions: 11, + definitions: 11, +}; +const sessionInfo = (reported?: GhidraRiscvCallingConventionReport) => ({ + 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: "RISCV:LE:32:default", + compiler_spec_id: "gcc", + image_base: "0x0", + default_address_space: "ram", + sha256: "a".repeat(64), + }, + ...(reported === undefined ? {} : { riscv_calling_convention: reported }), +}); +const session = async ( + value: BinaryTarget, + reported: GhidraRiscvCallingConventionReport | undefined, + profileFor: BinaryTarget = value, +) => { + const counts = { starts: 0, calls: 0, closes: 0 }; + const launches: unknown[] = []; + const provider = new GhidraProvider( + configOf(installDir), + silentLogger, + {}, + host, + (options) => { + launches.push(options.launcher); + return { + start: () => { + counts.starts++; + return Promise.resolve(ok(sessionInfo(reported))); + }, + callTool: () => { + counts.calls++; + return Promise.resolve(ok(null)); + }, + close: () => { + counts.closes++; + return Promise.resolve(ok(null)); + }, + }; + }, + ); + const resolved = await provider.resolveAnalysisProfile(profileFor); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a RISC-V profile"); + return { + counts, + launches, + client: provider.createClient(value, resolved.value.profile), + }; +}; + +describe("RISC-V calling-convention handshake", () => { + it.each([ + ["ilp32-prototype-v1", report, undefined], + [undefined, undefined, undefined], + [ + "ilp32-prototype-v1", + undefined, + "did not report the committed RISC-V calling-convention policy ilp32-prototype-v1", + ], + [undefined, report, "profile did not commit"], + ] as const)( + "expected %s with report %j fails with %s", + (expected, reported, failure) => { + const reason = ghidraRiscvCallingConventionFailure(expected, reported); + if (failure === undefined) expect(reason).toBeUndefined(); + else expect(reason).toContain(failure); + }, + ); + + it("launches the policy and reports its outcome as session limitations", async () => { + const { client, launches } = await session(ilp32(), report); + const health = await client.execute("health", {}); + if (!health.ok) throw health.error; + expect(launches).toEqual([ + expect.objectContaining({ + options: expect.objectContaining({ + riscvCallingConvention: "ilp32-prototype-v1", + }), + }), + ]); + expect(health.value.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "installs a program-specific `__rea_ilp32` prototype", + ), + expect.stringContaining( + "11 functions and 11 function definitions use __rea_ilp32.", + ), + expect.stringContaining("variadic call's arguments"), + ]), + ); + }); + + it("launches double-float sessions without a policy", async () => { + const { client, launches } = await session(target(), undefined); + expect((await client.execute("health", {})).ok).toBe(true); + expect(launches).toEqual([ + expect.objectContaining({ + options: expect.not.objectContaining({ + riscvCallingConvention: expect.anything(), + }), + }), + ]); + }); + + it.each([ + { + reason: "an ilp32 session without a report before any analysis call", + value: ilp32(), + reported: undefined, + operation: "list_procedures", + }, + { + reason: "a report from a double-float session", + value: target(), + reported: report, + operation: "health", + }, + ] as const)("rejects $reason", async ({ value, reported, operation }) => { + const { counts, client } = await session(value, reported); + const failed = await client.execute(operation, {}); + 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 policy disagrees with the target", async () => { + for (const [value, profileFor] of [ + [ilp32(), target({ elfClass: 32 })], + [target({ elfClass: 32 }), ilp32()], + ] as const) { + const { counts, client } = await session(value, report, profileFor); + expect((await client.execute("health", {})).ok).toBe(false); + expect(counts.starts).toBe(0); + } + }); + + it("keeps a caller project's stored report as history", async () => { + const resolved = await ghidra().resolveAnalysisProfile(ilp32()); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected an ilp32 profile"); + const info: GhidraSessionInfo = sessionInfo(report); + expect( + ghidraProjectHistoryEvidence(ilp32(), resolved.value.profile, info) + ?.normalized_result, + ).toEqual({ origin: "caller-project", riscv_calling_convention: report }); + }); +}); diff --git a/src/ghidra/GhidraRiscvProfile.ts b/src/ghidra/GhidraRiscvProfile.ts index acbd2b6a3c..92e08251c7 100644 --- a/src/ghidra/GhidraRiscvProfile.ts +++ b/src/ghidra/GhidraRiscvProfile.ts @@ -1,16 +1,87 @@ -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { + BinaryTarget, + RiscvElfMetadata, + RiscvIsaDeclaration, +} from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { + riscvAbiName, + riscvFloatAbi, + type RiscvFloatAbi, +} from "../domain/riscvIsaDeclaration.js"; import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; +/** Calling-convention policy ids an analysis profile can commit. */ +export const GHIDRA_RISCV_CALLING_CONVENTION_IDS = [ + "ilp32-prototype-v1", +] as const; +export type GhidraRiscvCallingConvention = + (typeof GHIDRA_RISCV_CALLING_CONVENTION_IDS)[number]; + /** - * Ghidra's generic RISC-V languages model the double-float ABI: the shipped - * `riscv.ldefs` maps `RISCV:LE:{32,64}:default` to the `riscv{32,64}-fp.cspec` - * compiler specification. Soft, single and quad ABI declarations therefore need - * their own verification rather than inheriting this lane. + * The ABI and prototype of each policy REA applies after analysis. + * `ReaGhidraRiscvCallingConvention.java` derives each prototype from the + * pinned language's default `riscv32-fp.cspec` model, which is ilp32d; + * docs/ghidra-riscv.md states the rules of each ABI. */ +export const GHIDRA_RISCV_CALLING_CONVENTIONS: Readonly< + Record< + GhidraRiscvCallingConvention, + { readonly abi: string; readonly prototype: string } + > +> = { + "ilp32-prototype-v1": { abi: "ilp32", prototype: "__rea_ilp32" }, +}; + +/** One admitted ELF class and declared float ABI. */ +export interface GhidraRiscvLane { + readonly elfClass: 32 | 64; + readonly floatAbi: RiscvFloatAbi; + /** Absent: Ghidra's stock double-float model applies unchanged. */ + readonly callingConvention?: GhidraRiscvCallingConvention; +} + +/** Every class and float ABI pair the Ghidra lane admits; others are refused. */ +export const GHIDRA_RISCV_LANES: readonly GhidraRiscvLane[] = [ + { elfClass: 64, floatAbi: "double" }, + { elfClass: 32, floatAbi: "double" }, + { elfClass: 32, floatAbi: "soft", callingConvention: "ilp32-prototype-v1" }, +]; + +/** Lane ids are built from their fields, so a new ABI or load model adds a value. */ +export const ghidraRiscvSupportLane = (lane: GhidraRiscvLane): string => + `elf${lane.elfClass}-exec-le-${lane.floatAbi}-float-generic-v1`; + +/** + * Extensions Ghidra 12.1.4's generic RV32 language implements with p-code, by + * the names `Tag_RISCV_arch` records. Lanes with a calling-convention policy + * admit an image only when it declares an RV32I base and only these. + */ +export const GHIDRA_RISCV32_DECODED_EXTENSIONS: ReadonlySet = new Set([ + "m", + "a", + "f", + "d", + "c", + "zicsr", + "zifencei", + "zmmul", + "zaamo", + "zalrsc", + "zca", + "zcf", + "zcd", +]); + +/** Outcome the post-analysis script records for the bridge handshake. */ +export interface GhidraRiscvCallingConventionReport { + readonly policy: GhidraRiscvCallingConvention; + readonly functions: number; + readonly definitions: number; +} + const EF_RISCV_RVC = 0x0001; const EF_RISCV_FLOAT_ABI = 0x0006; -const EF_RISCV_FLOAT_ABI_DOUBLE = 0x0004; const EF_RISCV_RVE = 0x0008; const EF_RISCV_TSO = 0x0010; @@ -24,13 +95,53 @@ const ET_DYN = 3; */ const EF_RISCV_DECLARED_BITS = EF_RISCV_RVC | EF_RISCV_FLOAT_ABI; -/** Header declarations outside the verified generic-language lane. */ -const riscvUnsupportedFloatAbi = (abi: number): string => - abi === 0 - ? "unspecified (soft-float convention)" - : abi === 0x0002 - ? "single-float" - : "quad-float"; +/** The lane a class and declared float ABI select, if the table admits one. */ +const ghidraRiscvLane = ( + metadata: RiscvElfMetadata, +): GhidraRiscvLane | undefined => { + const floatAbi = riscvFloatAbi(metadata.flags); + return GHIDRA_RISCV_LANES.find( + (lane) => lane.elfClass === metadata.elfClass && lane.floatAbi === floatAbi, + ); +}; + +/** Name what a refused float ABI lacks, from the table admission reads. */ +const ghidraRiscvFloatAbiRefusal = (metadata: RiscvElfMetadata): string => { + const floatAbi = riscvFloatAbi(metadata.flags); + const name = riscvAbiName(metadata.elfClass, floatAbi); + const admitted = GHIDRA_RISCV_LANES.filter( + (lane) => lane.elfClass === metadata.elfClass, + ).map((lane) => `${lane.floatAbi}-float`); + return `This Ghidra RISC-V lane admits ELF${metadata.elfClass} images that declare the ${admitted.join( + " or ", + )} ABI; the ${floatAbi}-float ABI${ + name === null ? "" : ` (${name})` + } declared by e_flags has no verified calling-convention policy.`; +}; + +/** Lanes with a calling-convention policy admit declared, decoded RV32I only. */ +const ghidraRiscvIsaRefusal = ( + isa: RiscvIsaDeclaration | undefined, +): string | null => { + if (isa === undefined) + return "This RISC-V lane requires the image's .riscv.attributes ISA declaration, which was not inspected."; + if (isa.status === "absent") + return "This RISC-V lane requires a Tag_RISCV_arch declaration in .riscv.attributes; the image declares none, so its extensions are unknown."; + if (isa.status === "unavailable") + return `This RISC-V lane requires a readable Tag_RISCV_arch declaration: ${isa.reason}.`; + if (isa.xlen !== 32 || isa.base !== "i") + return `Tag_RISCV_arch declares ${isa.arch}; this lane requires the RV32I base.`; + const outside = isa.extensions.filter( + (extension) => !GHIDRA_RISCV32_DECODED_EXTENSIONS.has(extension), + ); + return outside.length === 0 + ? null + : `Tag_RISCV_arch declares extensions outside the set this lane decodes: ${outside.join( + ", ", + )}. Ghidra 12.1.4's generic RV32 language implements only ${[ + ...GHIDRA_RISCV32_DECODED_EXTENSIONS, + ].join(", ")} with p-code; see docs/ghidra-riscv.md.`; +}; /** * Keep family recognition separate from the deliberately bounded Ghidra lane. @@ -65,17 +176,26 @@ export const ghidraRiscvUnsupportedReason = ( ? "shared and position-independent" : `e_type ${metadata.type}` } images need separate load verification. ET_EXEC alone does not establish static linkage, which this lane does not inspect.`; - const abi = metadata.flags & EF_RISCV_FLOAT_ABI; - if (abi !== EF_RISCV_FLOAT_ABI_DOUBLE) - return `This Ghidra RISC-V lane requires the EF_RISCV_FLOAT_ABI_DOUBLE declaration (${riscvUnsupportedFloatAbi( - abi, - )} observed); the pinned generic language uses the double-float compiler specification.`; + const lane = ghidraRiscvLane(metadata); + if (lane === undefined) return ghidraRiscvFloatAbiRefusal(metadata); const unsupportedFlags = (metadata.flags & ~EF_RISCV_DECLARED_BITS) >>> 0; if (unsupportedFlags !== 0) return `RISC-V e_flags declares bits outside the verified lane (0x${unsupportedFlags.toString(16)}); reserved or newer declarations are not treated as compatible with this profile.`; - return null; + return lane.callingConvention === undefined + ? null + : ghidraRiscvIsaRefusal(metadata.isa); }; +/** The calling-convention policy an admitted target's lane applies, if any. */ +export const ghidraRiscvCallingConvention = ( + target: BinaryTarget, +): GhidraRiscvCallingConvention | undefined => + target.kind === "executable" && + target.architecture === "riscv" && + ghidraRiscvUnsupportedReason(target) === null + ? ghidraRiscvLane(target.riscv)?.callingConvention + : undefined; + /** Pin a language only for declarations admitted by the same lane boundary. */ export const ghidraRiscvLanguageOverride = ( target: BinaryTarget, @@ -89,41 +209,99 @@ export const ghidraRiscvLanguageOverride = ( } : undefined; -/** Commit source ELF interpretation without confusing target ISA with host CPU. */ +/** + * Commit source ELF interpretation without confusing target ISA with host CPU. + * Lanes with a calling-convention policy also commit the declarations that + * admitted them; double-float profiles keep their original parameters. + */ export const ghidraRiscvProfileParameters = ( target: BinaryTarget, -): Readonly> => - target.kind === "executable" && - target.architecture === "riscv" && - target.riscv !== undefined - ? { - riscv_elf: { - elf_class: target.riscv.elfClass, - byte_order: target.riscv.byteOrder, - type: target.riscv.type, - flags: target.riscv.flags, - float_abi_double: - (target.riscv.flags & EF_RISCV_FLOAT_ABI) === - EF_RISCV_FLOAT_ABI_DOUBLE, - rvc_declared: (target.riscv.flags & EF_RISCV_RVC) !== 0, - rve_declared: (target.riscv.flags & EF_RISCV_RVE) !== 0, - tso_declared: (target.riscv.flags & EF_RISCV_TSO) !== 0, - }, - riscv_support_lane: - target.riscv.elfClass === 32 - ? "elf32-exec-le-double-float-generic-v1" - : "elf64-exec-le-double-float-generic-v1", - } - : {}; +): Readonly> => { + if ( + target.kind !== "executable" || + target.architecture !== "riscv" || + target.riscv === undefined + ) + return {}; + const floatAbi = riscvFloatAbi(target.riscv.flags); + const convention = ghidraRiscvCallingConvention(target); + const isa = target.riscv.isa; + return { + riscv_elf: { + elf_class: target.riscv.elfClass, + byte_order: target.riscv.byteOrder, + type: target.riscv.type, + flags: target.riscv.flags, + float_abi_double: floatAbi === "double", + rvc_declared: (target.riscv.flags & EF_RISCV_RVC) !== 0, + rve_declared: (target.riscv.flags & EF_RISCV_RVE) !== 0, + tso_declared: (target.riscv.flags & EF_RISCV_TSO) !== 0, + ...(convention === undefined || isa?.status !== "declared" + ? {} + : { float_abi: floatAbi, arch: isa.arch }), + }, + ...(convention === undefined + ? {} + : { riscv_calling_convention: convention }), + riscv_support_lane: ghidraRiscvSupportLane({ + elfClass: target.riscv.elfClass, + floatAbi, + }), + }; +}; /** The pinned language is an analysis convention, not proof of the producer. */ export const ghidraRiscvLimitations = ( target: BinaryTarget, -): readonly string[] => - target.kind === "executable" && target.architecture === "riscv" - ? [ - `RISC-V ELF: REA pins Ghidra's generic \`RISCV:LE:${target.riscv.elfClass}:default\` language with the \`gcc\` compiler specification instead of accepting the loader's header-based choice, which selects \`RISCV:LE:32:AndeStar_v5\` for ordinary RV32 images on Ghidra 12.1.4.`, - "The pinned generic language models the double-float ABI declared by `e_flags`; the declaration is recorded as an ELF fact and is not evidence of the original compiler or of verified instruction semantics.", - "Admission reads `e_type` only: a fixed-address ET_EXEC declaration does not establish static linkage, and program headers (`PT_INTERP`, dynamic dependencies) are not inspected by this lane.", - ] - : []; +): readonly string[] => { + if (target.kind !== "executable" || target.architecture !== "riscv") + return []; + const convention = ghidraRiscvCallingConvention(target); + const floatAbi = riscvFloatAbi(target.riscv.flags); + const isa = target.riscv.isa; + return [ + `RISC-V ELF: REA pins Ghidra's generic \`RISCV:LE:${target.riscv.elfClass}:default\` language with the \`gcc\` compiler specification instead of accepting the loader's header-based choice, which selects \`RISCV:LE:32:AndeStar_v5\` for ordinary RV32 images on Ghidra 12.1.4.`, + convention === undefined + ? "The pinned generic language models the double-float ABI declared by `e_flags`; the declaration is recorded as an ELF fact and is not evidence of the original compiler or of verified instruction semantics." + : `The ELF header declares the ${floatAbi}-float ABI (${ + GHIDRA_RISCV_CALLING_CONVENTIONS[convention].abi + }); the pinned language's compiler specification models ilp32d, so REA applies its \`${ + GHIDRA_RISCV_CALLING_CONVENTIONS[convention].prototype + }\` prototype instead. The declaration is recorded as an ELF fact and is not evidence of the original compiler.`, + ...(convention !== undefined && isa?.status === "declared" + ? [ + `\`.riscv.attributes\` declares \`${isa.arch}\`. Every declared extension is in the set this lane decodes; the declaration does not prove that every instruction belongs to it.`, + ] + : []), + "Admission reads `e_type` only: a fixed-address ET_EXEC declaration does not establish static linkage, and program headers (`PT_INTERP`, dynamic dependencies) are not inspected by this lane.", + ]; +}; + +/** Fail closed unless the bridge applied exactly the committed policy. */ +export const ghidraRiscvCallingConventionFailure = ( + expected: GhidraRiscvCallingConvention | undefined, + report: GhidraRiscvCallingConventionReport | undefined, +): string | undefined => + expected === report?.policy + ? undefined + : expected === undefined + ? "The Ghidra session reported a RISC-V calling-convention policy that the profile did not commit." + : `The Ghidra session did not report the committed RISC-V calling-convention policy ${expected}.`; + +/** State the prototype REA applies, and its outcome once the bridge reports it. */ +export const ghidraRiscvCallingConventionLimitations = ( + expected: GhidraRiscvCallingConvention | undefined, + report?: GhidraRiscvCallingConventionReport, +): readonly string[] => { + if (expected === undefined) return []; + const { abi, prototype } = GHIDRA_RISCV_CALLING_CONVENTIONS[expected]; + return [ + `RISC-V ${abi}: Ghidra's generic RV32 compiler specification models ilp32d. After analysis REA installs a program-specific \`${prototype}\` prototype, derived from the default prototype by policy ${expected} (docs/ghidra-riscv.md), and assigns it with dynamic storage to every function, external function and function definition. Thunks follow their targets.`, + ...(report === undefined + ? [] + : [ + `RISC-V calling-convention outcome: ${report.functions} functions and ${report.definitions} function definitions use ${prototype}.`, + ]), + "Ghidra recovers a variadic call's arguments from the registers the caller writes, not from their types: a padding register can appear as an argument, and arguments after an unwritten register are omitted.", + ]; +}; diff --git a/src/ghidra/GhidraSessionValues.test.ts b/src/ghidra/GhidraSessionValues.test.ts index f3831fc3fd..124585b8ac 100644 --- a/src/ghidra/GhidraSessionValues.test.ts +++ b/src/ghidra/GhidraSessionValues.test.ts @@ -142,6 +142,38 @@ describe("Ghidra MIPS soft-float storage report", () => { }); }); +describe("Ghidra RISC-V calling-convention report", () => { + const valid = { + policy: "ilp32-prototype-v1", + functions: 11, + definitions: 11, + }; + it("accepts the exact committed policy outcome", () => { + const parsed = parseGhidraSessionInfo( + { ...session(), riscv_calling_convention: valid }, + expected, + ); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.riscv_calling_convention).toEqual(valid); + }); + it.each([ + ["an unknown policy", { ...valid, policy: "ilp32f-prototype-v1" }], + ["the MIPS policy", { ...valid, policy: "o32-soft-float-prototype-v1" }], + ["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, prototype: "__rea_ilp32" }], + ["a non-object report", "ilp32-prototype-v1"], + ])("rejects %s", (_label, report) => { + expect( + parseGhidraSessionInfo( + { ...session(), riscv_calling_convention: report }, + expected, + ).ok, + ).toBe(false); + }); +}); + describe("Ghidra disabled analyzer report", () => { const valid = { format: "rea-ghidra-disabled-analyzers-v1", diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index 684330bd8d..917be2e46e 100644 --- a/src/ghidra/GhidraSessionValues.ts +++ b/src/ghidra/GhidraSessionValues.ts @@ -12,6 +12,7 @@ import { import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionValues.js"; import { ghidraExtensionResultSchema } from "./extensions/GhidraExtensions.js"; import { GHIDRA_MIPS_SOFT_FLOAT_STORAGE } from "./GhidraMipsProfile.js"; +import { GHIDRA_RISCV_CALLING_CONVENTION_IDS } from "./GhidraRiscvProfile.js"; /** Exact methods proved by the selected Program bridge handshake. */ export const GHIDRA_SESSION_CAPABILITIES = [ @@ -95,6 +96,14 @@ const sessionInfoSchema = z }) .strict() .optional(), + riscv_calling_convention: z + .object({ + policy: z.enum(GHIDRA_RISCV_CALLING_CONVENTION_IDS), + functions: z.int().nonnegative(), + definitions: z.int().nonnegative(), + }) + .strict() + .optional(), capabilities: z.array(capabilitySchema), target: z .object({ diff --git a/tests/boundary/filesystem/riscvIsaDeclaration.test.ts b/tests/boundary/filesystem/riscvIsaDeclaration.test.ts new file mode 100644 index 0000000000..b21f817aa3 --- /dev/null +++ b/tests/boundary/filesystem/riscvIsaDeclaration.test.ts @@ -0,0 +1,167 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import type { RiscvIsaDeclaration } from "../../../src/domain/binaryTargetTypes.js"; +import { ghidraRiscvUnsupportedReason } from "../../../src/ghidra/GhidraRiscvProfile.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const SHT_RISCV_ATTRIBUTES = 0x70000003; +const SECTION_TABLE = 128; +// Deliberately outside BinaryTargetResolver's 4 KiB probe. +const PAYLOADS = 8192; + +const attributes = (arch: string | null, vendor = "riscv"): Buffer => { + const list = [ + 4, + 16, // Tag_RISCV_stack_align + ...(arch === null ? [] : [5, ...Buffer.from(`${arch}\0`, "latin1")]), + ]; + const scoped = Buffer.alloc(5 + list.length); + scoped[0] = 1; // Tag_File + scoped.writeUInt32LE(scoped.length, 1); + scoped.set(list, 5); + const name = Buffer.from(`${vendor}\0`, "latin1"); + const header = Buffer.alloc(5); + header[0] = 0x41; + header.writeUInt32LE(4 + name.length + scoped.length, 1); + return Buffer.concat([header, name, scoped]); +}; + +/** An ELF32 RISC-V soft-float image with one section header per payload. */ +const elf = ( + sections: readonly { type: number; payload: Buffer }[], +): Buffer => { + const payloadBytes = sections.reduce( + (total, section) => total + section.payload.length, + 0, + ); + const bytes = Buffer.alloc(PAYLOADS + payloadBytes); + bytes.set([0x7f, 0x45, 0x4c, 0x46, 1, 1, 1]); + bytes.writeUInt16LE(2, 16); // ET_EXEC + bytes.writeUInt16LE(243, 18); // EM_RISCV + bytes.writeUInt32LE(1, 20); + bytes.writeUInt32LE(SECTION_TABLE, 32); + bytes.writeUInt32LE(0x1, 36); // RVC, soft-float ABI + bytes.writeUInt16LE(52, 40); + bytes.writeUInt16LE(32, 42); + bytes.writeUInt16LE(40, 46); + bytes.writeUInt16LE(1 + sections.length, 48); + let offset = PAYLOADS; + sections.forEach(({ type, payload }, index) => { + const header = SECTION_TABLE + (1 + index) * 40; + bytes.writeUInt32LE(type, header + 4); + bytes.writeUInt32LE(offset, header + 16); + bytes.writeUInt32LE(payload.length, header + 20); + payload.copy(bytes, offset); + offset += payload.length; + }); + return bytes; +}; + +const resolveIsa = async (bytes: Buffer) => { + const directory = await createTestTempDirectory("rea-riscv-isa-test-"); + const path = join(directory, "fixture.elf"); + await writeFile(path, bytes); + const resolved = await parseBinaryTarget(path); + if (!resolved.ok) throw resolved.error; + if ( + resolved.value.kind !== "executable" || + resolved.value.architecture !== "riscv" + ) + throw new Error("Expected a resolved RISC-V executable"); + return { + isa: resolved.value.riscv.isa, + refusal: ghidraRiscvUnsupportedReason(resolved.value), + }; +}; + +const C3_ARCH = "rv32i2p1_m2p0_c2p0_zicsr2p0_zifencei2p0_zmmul1p0"; + +describe("RISC-V ISA declarations at target resolution", () => { + it("reads Tag_RISCV_arch beyond the header probe and admits the ilp32 lane", async () => { + expect( + await resolveIsa( + elf([ + { type: 1, payload: Buffer.from([0x13, 0, 0, 0]) }, + { type: SHT_RISCV_ATTRIBUTES, payload: attributes(C3_ARCH) }, + ]), + ), + ).toEqual({ + isa: { + status: "declared", + arch: C3_ARCH, + xlen: 32, + base: "i", + extensions: ["m", "c", "zicsr", "zifencei", "zmmul"], + }, + refusal: null, + }); + }); + + it.each([ + ["no attribute section", [], { status: "absent" }, "declares none"], + [ + "an attribute section without Tag_RISCV_arch", + [{ type: SHT_RISCV_ATTRIBUTES, payload: attributes(null) }], + { status: "absent" }, + "declares none", + ], + [ + "another vendor's attributes", + [{ type: SHT_RISCV_ATTRIBUTES, payload: attributes(C3_ARCH, "gnu") }], + { + status: "unavailable", + reason: + "RISC-V ELF declares non-riscv vendor attributes that REA does not inspect", + }, + "readable Tag_RISCV_arch declaration", + ], + [ + "two attribute sections", + [ + { type: SHT_RISCV_ATTRIBUTES, payload: attributes(C3_ARCH) }, + { type: SHT_RISCV_ATTRIBUTES, payload: attributes(C3_ARCH) }, + ], + { + status: "unavailable", + reason: "the image has more than one RISC-V attribute section", + }, + "more than one RISC-V attribute section", + ], + [ + "an attribute section beyond the inspected bound", + [{ type: SHT_RISCV_ATTRIBUTES, payload: Buffer.alloc(4097, 0x41) }], + { + status: "unavailable", + reason: + "RISC-V attribute section size 4097 is outside REA's inspected bound of 4096 bytes", + }, + "inspected bound", + ], + [ + "an extension outside the decoded set", + [ + { + type: SHT_RISCV_ATTRIBUTES, + payload: attributes(`${C3_ARCH}_zcmp1p0`), + }, + ], + { + status: "declared", + arch: `${C3_ARCH}_zcmp1p0`, + xlen: 32, + base: "i", + extensions: ["m", "c", "zicsr", "zifencei", "zmmul", "zcmp"], + }, + "outside the set this lane decodes: zcmp.", + ], + ] as const)( + "keeps %s visible and refuses the ilp32 lane", + async (_label, sections, isa: RiscvIsaDeclaration, refusal) => { + const resolved = await resolveIsa(elf(sections)); + expect(resolved.isa).toEqual(isa); + expect(resolved.refusal).toContain(refusal); + }, + ); +}); diff --git a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts index 273fd3df95..38875cd4e3 100644 --- a/tests/boundary/providers/ghidra/ghidraLauncher.test.ts +++ b/tests/boundary/providers/ghidra/ghidraLauncher.test.ts @@ -236,6 +236,10 @@ describe("Ghidra GameCube DOL loader", () => { ["DOS MZ", { dosMz: true }], ["DOS COM", { dosCom: true }], ["MIPS soft-float", { mipsSoftFloat: true }], + [ + "RISC-V calling convention", + { riscvCallingConvention: "ilp32-prototype-v1" }, + ], [ "raw binary", { @@ -358,6 +362,67 @@ describe("Ghidra headless launcher", () => { }), ); }); + + it("runs the committed RISC-V policy after the no-return refit 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", + languageOverride: { + languageId: "RISCV:LE:32:default", + compilerSpecId: "gcc", + }, + }; + const script = "/package/bridge/ReaGhidraRiscvCallingConvention.java"; + // PSP, DOS and raw imports carry their own languages, so they omit the pin. + const { languageOverride: _pin, ...unpinned } = base; + expect(ghidraHeadlessArguments(base)).not.toContain(script); + const arguments_ = ghidraHeadlessArguments({ + ...base, + riscvCallingConvention: "ilp32-prototype-v1", + }); + const at = arguments_.indexOf(script); + expect(arguments_.slice(at - 1, at + 2)).toEqual([ + "-postScript", + script, + "ilp32-prototype-v1", + ]); + expect( + arguments_.indexOf("/package/bridge/ReaGhidraNoReturnFix.java"), + ).toBeLessThan(at); + expect(at).toBeLessThan( + arguments_.indexOf("/package/bridge/ReaGhidraBridge.java"), + ); + for (const conflict of [ + { psp: true as const }, + { mipsSoftFloat: true as const }, + { dosMz: true as const }, + { dosCom: true as const }, + { + rawBinary: { + languageId: "x86:LE:32:default", + compilerSpecId: "default", + baseAddr: "0x1000", + }, + }, + ]) + expect(() => + ghidraHeadlessArguments({ + ...unpinned, + riscvCallingConvention: "ilp32-prototype-v1", + ...conflict, + }), + ).toThrow( + expect.objectContaining({ + _tag: "GhidraAdmissionError", + code: "riscv_calling_convention_scope", + }), + ); + }); }); describe("Ghidra configured import language and seeds", () => { @@ -617,6 +682,10 @@ describe("Ghidra existing project", () => { ], [{ psp: true as const }, "existing_project_psp"], [{ mipsSoftFloat: true as const }, "existing_project_mips_soft_float"], + [ + { riscvCallingConvention: "ilp32-prototype-v1" as const }, + "existing_project_riscv_calling_convention", + ], [{ seedPath: "/tmp/runtime/seeds/seeds.tsv" }, "existing_project_analysis"], [ { disabledAnalyzers: ["Decompiler Parameter ID"] }, diff --git a/tests/conformance/c/riscv-float-abi.c b/tests/conformance/c/riscv-float-abi.c new file mode 100644 index 0000000000..ef54e3488f --- /dev/null +++ b/tests/conformance/c/riscv-float-abi.c @@ -0,0 +1,118 @@ +/* Source-owned RISC-V calling-convention fixture. Never execute this target. + * One source for every float ABI the lane verifies: float and double + * arguments and returns, a double that starts in an odd register, a double + * split between a7 and the stack, small structures of two floats and of a + * float and an int, a large structure and a long double passed and returned + * by reference, a variadic call with aligned double pairs, and an indirect + * call through a typed pointer. It moves floating-point values without arithmetic or + * comparisons, so a soft-float build links no compiler-rt helpers. The + * expected storage per ABI is in tests/conformance/ghidra/riscv-checks.mjs. + */ +#include + +volatile unsigned int rea_riscv_global = 7; +volatile unsigned int rea_riscv_abi_sink; +extern unsigned int rea_riscv_probe(unsigned int value); +extern unsigned int rea_riscv_isa_probe(unsigned int value); + +struct rea_riscv_abi_pair { + float x; + float y; +}; + +struct rea_riscv_abi_tagged { + float value; + int tag; +}; + +struct rea_riscv_abi_large { + double first; + double second; + float third; +}; + +__attribute__((noinline)) float rea_riscv_abi_pick(float a, float b, int sel) +{ + return sel ? a : b; +} + +__attribute__((noinline)) double rea_riscv_abi_wide(double a, int sel) +{ + return sel ? a : 0.5; +} + +__attribute__((noinline)) double rea_riscv_abi_mixed(int i, double d, float f, double e) +{ + rea_riscv_abi_sink = *(volatile unsigned int *)&f; + return i ? d : e; +} + +__attribute__((noinline)) double rea_riscv_abi_split(int a, int b, int c, int d, int e, int f, int g, double x) +{ + rea_riscv_abi_sink = (unsigned int)(a ^ b ^ c ^ d ^ e ^ f ^ g); + return x; +} + +__attribute__((noinline)) struct rea_riscv_abi_pair rea_riscv_abi_pair(struct rea_riscv_abi_pair value, float scale) +{ + struct rea_riscv_abi_pair swapped = { value.y, value.x }; + rea_riscv_abi_sink = *(volatile unsigned int *)&scale; + return swapped; +} + +__attribute__((noinline)) struct rea_riscv_abi_tagged rea_riscv_abi_tagged(struct rea_riscv_abi_tagged value, int tag) +{ + value.tag = tag; + return value; +} + +__attribute__((noinline)) struct rea_riscv_abi_large rea_riscv_abi_large(struct rea_riscv_abi_large value, float scale) +{ + value.third = scale; + return value; +} + +__attribute__((noinline)) long double rea_riscv_abi_quad(long double value, int sel) +{ + rea_riscv_abi_sink = (unsigned int)sel; + return value; +} + +__attribute__((noinline)) unsigned int rea_riscv_abi_variadic(int count, ...) +{ + va_list arguments; + unsigned int folded = 0; + va_start(arguments, count); + for (int index = 0; index < count; index += 1) { + double value = va_arg(arguments, double); + folded ^= ((volatile unsigned int *)&value)[1]; + folded ^= (unsigned int)va_arg(arguments, int); + } + va_end(arguments); + return folded; +} + +/* Volatile so the indirect call through this typed pointer is not folded. */ +float (*volatile rea_riscv_abi_callback)(float, float, int) = rea_riscv_abi_pick; + +__attribute__((noinline)) unsigned int rea_riscv_abi_caller(int sel) +{ + float picked = rea_riscv_abi_pick(1.5f, 2.5f, sel); + double wide = rea_riscv_abi_wide(3.25, sel); + double mixed = rea_riscv_abi_mixed(sel, 4.75, 5.5f, 6.125); + double split = rea_riscv_abi_split(1, 2, 3, 4, 5, 6, sel, 7.25); + struct rea_riscv_abi_pair pair = rea_riscv_abi_pair((struct rea_riscv_abi_pair){ 8.5f, 9.5f }, 10.5f); + struct rea_riscv_abi_tagged tagged = rea_riscv_abi_tagged((struct rea_riscv_abi_tagged){ 11.5f, 12 }, sel); + struct rea_riscv_abi_large large = rea_riscv_abi_large((struct rea_riscv_abi_large){ 13.5, 14.5, 15.5f }, 16.5f); + long double quad = rea_riscv_abi_quad(22.5L, sel); + unsigned int folded = rea_riscv_abi_variadic(2, 17.5, sel, 18.5, 19); + float called = rea_riscv_abi_callback(20.5f, 21.5f, sel); + return *(unsigned int *)&picked ^ ((unsigned int *)&wide)[1] ^ ((unsigned int *)&mixed)[1] ^ + ((unsigned int *)&split)[1] ^ *(unsigned int *)&pair.x ^ (unsigned int)tagged.tag ^ + ((unsigned int *)&large.second)[1] ^ ((unsigned int *)&quad)[3] ^ folded ^ *(unsigned int *)&called; +} + +__attribute__((noinline)) unsigned int rea_riscv_entry(unsigned int selector) +{ + return rea_riscv_probe(rea_riscv_isa_probe(rea_riscv_abi_caller((int)selector)) + rea_riscv_global); +} diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index 69b409fcaa..419a3bc7d8 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -5,11 +5,15 @@ binaries and Ghidra projects are never committed. - `../c/riscv.c` and `riscv-probe.S` produce source-owned RV32/RV64 ELF images with double-float ABI declarations and an independently known instruction. - `npm run verify:ghidra:riscv` exercises real CLI/MCP discovery, decompilation, - output schemas, profile and snapshot identity, refusal diagnostics, immutable - source bytes, and owned cleanup. It requires BYO Ghidra 12.1.4/JDK 21+ and - RISC-V Clang/LLD/LLVM readelf; `REA_RISCV_CLANG`, `REA_RISCV_LLD` and - `REA_RISCV_READELF` select existing tool commands. + `../c/riscv-float-abi.c`, `riscv-probe.S` and `riscv-isa-probe.S` produce + typed and stripped RV32 ilp32 images whose calls cover every rule of the ilp32 + policy; `riscv-checks.mjs` holds that policy's expected storage and the + call-site oracle. `npm run verify:ghidra:riscv` exercises real CLI/MCP + discovery, decompilation, argument storage, output schemas, profile and + snapshot identity, admission and refusal diagnostics, immutable source bytes, + and owned cleanup. It requires BYO Ghidra 12.1.4/JDK 21+ and RISC-V + Clang/LLD/LLVM readelf/LLVM objdump; `REA_RISCV_CLANG`, `REA_RISCV_LLD`, + `REA_RISCV_READELF` and `REA_RISCV_OBJDUMP` select existing tool commands. - `ReaDecodeProbe.java` decodes source-owned instruction bytes from a language's default context and checks each mnemonic, length and optional diff --git a/tests/conformance/ghidra/riscv-checks.d.mts b/tests/conformance/ghidra/riscv-checks.d.mts new file mode 100644 index 0000000000..b81faed33f --- /dev/null +++ b/tests/conformance/ghidra/riscv-checks.d.mts @@ -0,0 +1,53 @@ +export type RiscvWord = + | number + | "frame" + | { readonly frame: readonly number[] }; +export type RiscvAbiParameter = readonly [ + name: string, + type: string, + storage: string, + words?: Readonly>, + literal?: string, +]; +export const RISCV_ILP32_ABI: Readonly< + Record< + string, + { + readonly return: readonly [string, string]; + readonly parameters: readonly RiscvAbiParameter[]; + readonly variadic?: Readonly>; + } + > +>; +export const RISCV_ILP32_CALLBACK: { + readonly words: Readonly>; + readonly literals: readonly string[]; +}; +export interface RiscvCallSite { + readonly target: string | undefined; + readonly words: Readonly< + Record + >; + /** Caller memory at the call, by offset from the caller's incoming `sp`. */ + readonly memory: ReadonlyMap; +} +export function riscvStorageLocations(storage: string): readonly string[]; +export function assertRiscvIlp32TableConsistent(): void; +export function parseRiscvCallSites(listing: string): readonly RiscvCallSite[]; +export function inspectRiscvCallSites(path: string): Promise<{ + command: string; + version: string | undefined; + calls: readonly RiscvCallSite[]; +}>; +export function assertRiscvIlp32CallSites( + calls: readonly RiscvCallSite[], +): void; +export function assertRiscvIlp32Storage( + boundaries: Record, + options?: { readonly stripped?: boolean }, +): void; +export function assertRiscvIlp32Caller( + pseudocode: string, + addresses: Readonly>, + options?: { readonly stripped?: boolean }, +): void; diff --git a/tests/conformance/ghidra/riscv-checks.mjs b/tests/conformance/ghidra/riscv-checks.mjs new file mode 100644 index 0000000000..5770d3caa6 --- /dev/null +++ b/tests/conformance/ghidra/riscv-checks.mjs @@ -0,0 +1,512 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); + +/** + * The ilp32 storage of each function in tests/conformance/c/riscv-float-abi.c, + * as Ghidra prints it, with the words `rea_riscv_abi_caller`'s call site puts + * there. Ghidra joins a register pair most-significant word first. A word is + * an unsigned 32-bit value, "frame" for the address of a caller local, or + * `{ frame: [...] }` for such an address whose memory holds those words. A + * literal is how the typed caller's pseudocode should print the argument. + */ +export const RISCV_ILP32_ABI = { + rea_riscv_abi_pick: { + return: ["float", "a0:4"], + parameters: [ + ["a", "float", "a0:4", { a0: 0x3fc00000 }, "1.5"], + ["b", "float", "a1:4", { a1: 0x40200000 }, "2.5"], + ["sel", "int", "a2:4"], + ], + }, + rea_riscv_abi_wide: { + return: ["double", "a1:4,a0:4"], + parameters: [ + ["a", "double", "a1:4,a0:4", { a1: 0x400a0000, a0: 0 }, "3.25"], + ["sel", "int", "a2:4"], + ], + }, + // A named double needs no aligned pair, so d starts in a1. + rea_riscv_abi_mixed: { + return: ["double", "a1:4,a0:4"], + parameters: [ + ["i", "int", "a0:4"], + ["d", "double", "a2:4,a1:4", { a2: 0x40130000, a1: 0 }, "4.75"], + ["f", "float", "a3:4", { a3: 0x40b00000 }, "5.5"], + ["e", "double", "a5:4,a4:4", { a5: 0x40188000, a4: 0 }, "6.125"], + ], + }, + // Only a7 is left for x: its low word goes there, its high word on the stack. + rea_riscv_abi_split: { + return: ["double", "a1:4,a0:4"], + parameters: [ + ["a", "int", "a0:4", { a0: 1 }, "1"], + ["b", "int", "a1:4", { a1: 2 }, "2"], + ["c", "int", "a2:4", { a2: 3 }, "3"], + ["d", "int", "a3:4", { a3: 4 }, "4"], + ["e", "int", "a4:4", { a4: 5 }, "5"], + ["f", "int", "a5:4", { a5: 6 }, "6"], + ["g", "int", "a6:4"], + [ + "x", + "double", + "Stack[0x0]:4,a7:4", + { "Stack[0x0]": 0x401d0000, a7: 0 }, + "7.25", + ], + ], + }, + // Two floats in 8 bytes: an integer pair, not fa0 and fa1. + rea_riscv_abi_pair: { + return: ["rea_riscv_abi_pair", "a1:4,a0:4"], + parameters: [ + [ + "value", + "rea_riscv_abi_pair", + "a1:4,a0:4", + { a1: 0x41180000, a0: 0x41080000 }, + "(rea_riscv_abi_pair)0x4118000041080000", + ], + ["scale", "float", "a2:4", { a2: 0x41280000 }, "10.5"], + ], + }, + rea_riscv_abi_tagged: { + return: ["rea_riscv_abi_tagged", "a1:4,a0:4"], + parameters: [ + [ + "value", + "rea_riscv_abi_tagged", + "a1:4,a0:4", + { a1: 12, a0: 0x41380000 }, + "(rea_riscv_abi_tagged)0xc41380000", + ], + ["tag", "int", "a2:4"], + ], + }, + // Over 8 bytes: passed and returned through caller-allocated memory. + rea_riscv_abi_large: { + return: ["rea_riscv_abi_large *", "a0:4"], + parameters: [ + [ + "__return_storage_ptr__", + "rea_riscv_abi_large *", + "a0:4 (auto)", + { a0: "frame" }, + ], + [ + "value", + "rea_riscv_abi_large *", + "a1:4", + { a1: { frame: [0, 0x402b0000, 0, 0x402d0000, 0x41780000] } }, + ], + ["scale", "float", "a2:4", { a2: 0x41840000 }, "16.5"], + ], + }, + // A long double is 16 bytes: passed and returned through caller memory too. + rea_riscv_abi_quad: { + return: ["long double *", "a0:4"], + parameters: [ + [ + "__return_storage_ptr__", + "long double *", + "a0:4 (auto)", + { a0: "frame" }, + ], + [ + "value", + "long double *", + "a1:4", + { a1: { frame: [0, 0, 0, 0x40036800] } }, + ], + ["sel", "int", "a2:4"], + ], + }, + // Variadic doubles take aligned pairs, a2:a3 and a6:a7, so a1 and a5 are + // skipped; the last int follows on the stack. Ghidra does not type them. + rea_riscv_abi_variadic: { + return: ["uint", "a0:4"], + parameters: [["count", "int", "a0:4", { a0: 2 }, "2"]], + variadic: { + a2: 0, + a3: 0x40318000, + a6: 0, + a7: 0x40328000, + "Stack[0x0]": 19, + }, + }, +}; + +/** The indirect call through the typed pointer `rea_riscv_abi_callback`. */ +export const RISCV_ILP32_CALLBACK = { + words: { a0: 0x41a40000, a1: 0x41ac0000 }, + literals: ["20.5", "21.5"], +}; + +const LOCATION_ORDER = [ + "a0", + "a1", + "a2", + "a3", + "a4", + "a5", + "a6", + "a7", + "Stack[0x0]", +]; +const FLOAT_REGISTER = /\bf[ast]\d+\b/u; + +/** Locations named by a Ghidra storage string, such as `Stack[0x0]:4,a7:4`. */ +export function riscvStorageLocations(storage) { + return storage + .replace(/ \(auto\)$/u, "") + .split(",") + .map((piece) => { + const match = /^(.+):\d+$/u.exec(piece); + assert.ok(match !== null, `Unexpected storage ${storage}`); + return match[1]; + }); +} + +const ordered = (locations) => + [...locations].sort( + (left, right) => + LOCATION_ORDER.indexOf(left) - LOCATION_ORDER.indexOf(right), + ); + +/** How the decompiler prints a call-site word: small values in decimal. */ +const printed = (word) => + typeof word !== "number" + ? undefined + : word < 10 + ? String(word) + : `0x${word.toString(16)}`; +const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +// Any argument the table does not pin, including one with a cast. +const WILDCARD = "(?:\\([^()]*\\))?[^,()]+"; + +/** Every call site word sits where the function's storage says it does. */ +export function assertRiscvIlp32TableConsistent() { + for (const [name, { parameters }] of Object.entries(RISCV_ILP32_ABI)) + for (const [parameter, , storage, words] of parameters) + if (words !== undefined) + assert.deepEqual( + ordered(Object.keys(words)), + ordered(riscvStorageLocations(storage)), + `${name}(${parameter}) words and storage disagree`, + ); +} + +/** + * Reconstruct the argument words each call in an `llvm-objdump` listing of + * `rea_riscv_abi_caller` writes. It interprets only the instructions that + * Clang -O0 uses to set up arguments; any other write leaves the register + * unknown. Stack addresses are offsets from the caller's incoming `sp`. + */ +export function parseRiscvCallSites(listing) { + const registers = new Map([ + ["zero", { word: 0 }], + ["sp", { stack: 0 }], + ]); + const memory = new Map(); + let written = new Set(); + const calls = []; + const immediate = (text) => { + const value = Number.parseInt( + text, + text.replace(/^-/u, "").startsWith("0x") ? 16 : 10, + ); + assert.ok(Number.isSafeInteger(value), `Unexpected immediate ${text}`); + return value; + }; + const address = (operand) => { + const match = /^(-?(?:0x)?[\da-f]+)\((\w+)\)$/u.exec(operand); + assert.ok(match !== null, `Unexpected memory operand ${operand}`); + const base = registers.get(match[2]); + return base?.stack === undefined + ? undefined + : base.stack + immediate(match[1]); + }; + const isRegister = (operand) => + /^(?:zero|ra|sp|gp|tp|t[0-6]|s(?:[0-9]|1[01])|a[0-7])$/u.test( + operand ?? "", + ); + const set = (register, value) => { + if (register === "zero") return; + if (value === undefined) registers.delete(register); + else registers.set(register, value); + }; + for (const line of listing.split("\n")) { + const match = /^\s*[\da-f]+:\s+([a-z.]+)(?:\s+(.*))?$/u.exec(line); + if (match === null) continue; + const [, mnemonic, text = ""] = match; + const operands = text + .replace(/\s*<[^>]*>/u, "") + .split(",") + .map((operand) => operand.trim()) + .filter((operand) => operand.length > 0); + if ( + (mnemonic === "jal" && operands[0] !== "zero") || + mnemonic === "jalr" || + mnemonic === "call" + ) { + const sp = registers.get("sp").stack; + const words = {}; + for (const register of LOCATION_ORDER.slice(0, 8)) + words[register] = registers.get(register); + for (const offset of written) + if (offset >= sp) + words[`Stack[0x${(offset - sp).toString(16)}]`] = memory.get(offset); + calls.push({ + target: mnemonic === "jalr" ? "indirect" : /<([^>]+)>/u.exec(text)?.[1], + words, + memory: new Map(memory), + }); + for (const register of [ + ...LOCATION_ORDER.slice(0, 8), + "t0", + "t1", + "t2", + "t3", + "t4", + "t5", + "t6", + "ra", + ]) + registers.delete(register); + written = new Set(); + continue; + } + const [target, source, third] = operands; + switch (mnemonic) { + case "lui": + set(target, { word: (immediate(source) << 12) >>> 0 }); + break; + case "li": + set(target, { word: immediate(source) >>> 0 }); + break; + case "mv": + set(target, registers.get(source)); + break; + case "addi": { + const base = registers.get(source); + set( + target, + base?.word !== undefined + ? { word: (base.word + immediate(third)) >>> 0 } + : base?.stack !== undefined + ? { stack: base.stack + immediate(third) } + : undefined, + ); + break; + } + case "sw": { + const offset = address(source); + if (offset === undefined) break; + const value = registers.get(target); + if (value === undefined) memory.delete(offset); + else memory.set(offset, value); + written.add(offset); + break; + } + case "lw": { + const offset = address(source); + set(target, offset === undefined ? undefined : memory.get(offset)); + break; + } + default: + if ( + !/^(?:s[bhw]|b[a-z]*|j|jr|ret)$/u.test(mnemonic) && + isRegister(target) + ) + set(target, undefined); + } + } + return calls; +} + +/** Disassemble the typed fixture's caller with an independent LLVM tool. */ +export async function inspectRiscvCallSites(path) { + const command = process.env.REA_RISCV_OBJDUMP ?? "llvm-objdump"; + const options = { + env: { ...process.env, LC_ALL: "C" }, + timeout: 10000, + maxBuffer: 1024 * 1024, + }; + const version = await execute(command, ["--version"], options); + const { stdout, stderr } = await execute( + command, + [ + "-d", + "--no-show-raw-insn", + "--disassemble-symbols=rea_riscv_abi_caller", + path, + ], + options, + ); + assert.equal(stderr.trim(), "", `objdump diagnostics: ${stderr}`); + return { + command, + version: version.stdout.split("\n").find((line) => /version/iu.test(line)), + calls: parseRiscvCallSites(stdout), + }; +} + +const matchesWord = (call, actual, expected) => + typeof expected === "number" + ? actual?.word === expected + : actual?.stack !== undefined && + (expected === "frame" || + expected.frame.every( + (word, index) => + call.memory.get(actual.stack + 4 * index)?.word === word, + )); + +/** The compiler's call sites place every known word where the table says. */ +export function assertRiscvIlp32CallSites(calls) { + assertRiscvIlp32TableConsistent(); + const checks = [ + ...Object.entries(RISCV_ILP32_ABI).map(([name, abi]) => [ + name, + Object.assign( + {}, + ...abi.parameters.map(([, , , words]) => words ?? {}), + abi.variadic ?? {}, + ), + ]), + ["indirect", RISCV_ILP32_CALLBACK.words], + ]; + for (const [target, words] of checks) { + const call = calls.find((candidate) => candidate.target === target); + assert.ok(call !== undefined, `No call site for ${target}`); + for (const [location, expected] of Object.entries(words)) + assert.ok( + matchesWord(call, call.words[location], expected), + `${target}: ${location} holds ${JSON.stringify(call.words[location])}, not ${JSON.stringify(expected)}`, + ); + } +} + +/** + * Where a stripped build returns: in the typed build's registers, or in none + * when the result goes through caller memory addressed by `a0`. + */ +const strippedReturnStorage = (abi) => + abi.parameters.some(([name]) => name === "__return_storage_ptr__") + ? "" + : abi.return[1]; + +/** + * Typed functions keep their names and types and use exactly the table's + * storage. Stripped functions get one inferred word per parameter location, in + * order, and their return storage; recovered types are not compared. + */ +export function assertRiscvIlp32Storage(boundaries, { stripped = false } = {}) { + assert.deepEqual( + Object.keys(boundaries).sort(), + Object.keys(RISCV_ILP32_ABI).sort(), + ); + for (const [name, want] of Object.entries(RISCV_ILP32_ABI)) { + const boundary = boundaries[name]; + assert.equal(boundary.calling_convention, "__rea_ilp32", name); + const parameters = [...boundary.parameters].sort( + (left, right) => left.ordinal - right.ordinal, + ); + for (const value of [boundary.return_type, ...parameters]) + assert.doesNotMatch(value.storage ?? "", FLOAT_REGISTER, name); + if (stripped) { + assert.equal( + boundary.return_type.storage, + strippedReturnStorage(want), + `${name} inferred return storage`, + ); + if (want.variadic !== undefined) continue; + assert.deepEqual( + parameters.map((parameter) => parameter.storage), + ordered( + want.parameters.flatMap(([, , storage]) => + riscvStorageLocations(storage), + ), + ).map((location) => `${location}:4`), + `${name} inferred parameter storage`, + ); + continue; + } + assert.deepEqual( + [boundary.return_type.data_type, boundary.return_type.storage], + want.return, + `${name} return storage`, + ); + assert.deepEqual( + parameters.map((parameter) => [ + parameter.name, + parameter.data_type, + parameter.storage, + ]), + want.parameters.map(([parameter, type, storage]) => [ + parameter, + type, + storage, + ]), + `${name} parameter storage`, + ); + } +} + +/** + * The caller's pseudocode passes the call-site words through the corrected + * storage: typed literals in a typed build, words in register order in a + * stripped one, and never a floating-point register. + */ +export function assertRiscvIlp32Caller( + pseudocode, + addresses, + { stripped = false } = {}, +) { + assert.doesNotMatch(pseudocode, FLOAT_REGISTER); + for (const [name, abi] of Object.entries(RISCV_ILP32_ABI)) { + if (abi.variadic !== undefined) continue; + let call; + if (stripped) { + const words = Object.assign( + {}, + ...abi.parameters.map(([, , , values]) => values ?? {}), + ); + const locations = ordered( + abi.parameters.flatMap(([, , storage]) => + riscvStorageLocations(storage), + ), + ); + call = `FUN_ram_0*${BigInt(addresses[name]).toString(16)}\\(${locations + .map((location) => { + const word = printed(words[location]); + return word === undefined ? WILDCARD : escape(word); + }) + .join(",")}\\)`; + } else + call = `${name}\\(${abi.parameters + .map(([, , , , literal]) => + literal === undefined ? WILDCARD : escape(literal), + ) + .join(",")}\\)`; + assert.match(pseudocode, new RegExp(call, "u"), `${name} call arguments`); + } + assert.match( + pseudocode, + stripped + ? new RegExp( + `\\)\\s*\\(${Object.values(RISCV_ILP32_CALLBACK.words) + .map((word) => escape(printed(word))) + .join(",")},`, + "u", + ) + : new RegExp( + `\\(\\*rea_riscv_abi_callback\\)\\s*\\(${RISCV_ILP32_CALLBACK.literals + .map(escape) + .join(",")},${WILDCARD}\\)`, + "u", + ), + "indirect call arguments", + ); +} diff --git a/tests/conformance/ghidra/riscv-checks.test.ts b/tests/conformance/ghidra/riscv-checks.test.ts new file mode 100644 index 0000000000..4911809214 --- /dev/null +++ b/tests/conformance/ghidra/riscv-checks.test.ts @@ -0,0 +1,372 @@ +import { describe, expect, it } from "vitest"; +import { + RISCV_ILP32_ABI, + assertRiscvIlp32CallSites, + assertRiscvIlp32Caller, + assertRiscvIlp32Storage, + assertRiscvIlp32TableConsistent, + parseRiscvCallSites, + riscvStorageLocations, +} from "./riscv-checks.mjs"; + +// `llvm-objdump -d --no-show-raw-insn` of rea_riscv_abi_caller in the soft-float +// fixture, built by LLVM 19 at -O0 (tabs and runs of spaces collapsed). The real +// lane disassembles its own build; this tests the oracle, not Ghidra. +const LISTING = ` +11408: addi sp, sp, -0xe0 +1140a: sw ra, 0xdc(sp) +1140c: sw s0, 0xd8(sp) +1140e: addi s0, sp, 0xe0 +11410: sw a0, -0xc(s0) +11414: lw a2, -0xc(s0) +11418: lui a0, 0x3fc00 +1141c: lui a1, 0x40200 +11420: jal 0x110f4 +11422: sw a0, -0x10(s0) +11426: lw a2, -0xc(s0) +1142a: li a0, 0x0 +1142c: sw a0, -0xd4(s0) +11430: lui a1, 0x400a0 +11434: jal 0x11130 +11436: lw a4, -0xd4(s0) +1143a: sw a1, -0x14(s0) +1143e: sw a0, -0x18(s0) +11442: lw a0, -0xc(s0) +11446: lui a2, 0x40130 +1144a: lui a3, 0x40b00 +1144e: lui a5, 0x40188 +11452: mv a1, a4 +11454: jal 0x1117e +11456: lw a7, -0xd4(s0) +1145a: sw a1, -0x1c(s0) +1145e: sw a0, -0x20(s0) +11462: lw a6, -0xc(s0) +11466: mv a1, sp +11468: lui a0, 0x401d0 +1146c: sw a0, 0x0(a1) +1146e: li a0, 0x1 +11470: li a1, 0x2 +11472: sw a1, -0xd8(s0) +11476: li a2, 0x3 +11478: li a3, 0x4 +1147a: li a4, 0x5 +1147c: li a5, 0x6 +1147e: jal 0x111e6 +11480: sw a1, -0x24(s0) +11484: sw a0, -0x28(s0) +11488: lui a0, 0x41080 +1148c: sw a0, -0x38(s0) +11490: lui a0, 0x41180 +11494: sw a0, -0x34(s0) +11498: lw a1, -0x34(s0) +1149c: lw a0, -0x38(s0) +114a0: lui a2, 0x41280 +114a4: jal 0x11256 +114a6: sw a1, -0x2c(s0) +114aa: sw a0, -0x30(s0) +114ae: lui a0, 0x41380 +114b2: sw a0, -0x48(s0) +114b6: li a0, 0xc +114b8: sw a0, -0x44(s0) +114bc: lw a2, -0xc(s0) +114c0: lw a1, -0x44(s0) +114c4: lw a0, -0x48(s0) +114c8: jal 0x11296 +114ca: mv a2, a0 +114cc: lw a0, -0xd4(s0) +114d0: sw a2, -0xdc(s0) +114d4: mv a2, a1 +114d6: lw a1, -0xdc(s0) +114da: sw a2, -0x3c(s0) +114de: sw a1, -0x40(s0) +114e2: lui a1, 0x402b0 +114e6: sw a1, -0x74(s0) +114ea: sw a0, -0x78(s0) +114ee: lui a1, 0x402d0 +114f2: sw a1, -0x6c(s0) +114f6: sw a0, -0x70(s0) +114fa: lui a0, 0x41780 +114fe: sw a0, -0x68(s0) +11502: lw a0, -0x64(s0) +11506: sw a0, -0x7c(s0) +1150a: lw a0, -0x68(s0) +1150e: sw a0, -0x80(s0) +11512: lw a0, -0x6c(s0) +11516: sw a0, -0x84(s0) +1151a: lw a0, -0x70(s0) +1151e: sw a0, -0x88(s0) +11522: lw a0, -0x74(s0) +11526: sw a0, -0x8c(s0) +1152a: lw a0, -0x78(s0) +1152e: sw a0, -0x90(s0) +11532: addi a0, s0, -0x60 +11536: addi a1, s0, -0x90 +1153a: lui a2, 0x41840 +1153e: jal 0x112d2 +11540: lw a0, -0xd4(s0) +11544: lw a2, -0xc(s0) +11548: lui a1, 0x40037 +1154c: addi a1, a1, -0x800 +11550: sw a1, -0xc4(s0) +11554: sw a0, -0xc8(s0) +11558: sw a0, -0xcc(s0) +1155c: sw a0, -0xd0(s0) +11560: addi a0, s0, -0xc0 +11564: addi a1, s0, -0xd0 +11568: jal 0x11316 +1156a: lw a0, -0xd8(s0) +1156e: lw a6, -0xd4(s0) +11572: lw a1, -0xc0(s0) +11576: lw a2, -0xbc(s0) +1157a: lw a3, -0xb8(s0) +1157e: lw a4, -0xb4(s0) +11582: sw a4, -0x94(s0) +11586: sw a3, -0x98(s0) +1158a: sw a2, -0x9c(s0) +1158e: sw a1, -0xa0(s0) +11592: lw a4, -0xc(s0) +11596: mv a2, sp +11598: li a1, 0x13 +1159a: sw a1, 0x0(a2) +1159c: lui a3, 0x40318 +115a0: lui a7, 0x40328 +115a4: mv a2, a6 +115a6: jal 0x1136a +115a8: sw a0, -0xa4(s0) +115ac: lui a0, 0x12 +115b0: lw a3, 0x648(a0) +115b4: lw a2, -0xc(s0) +115b8: lui a0, 0x41a40 +115bc: lui a1, 0x41ac0 +115c0: jalr a3 +115c2: sw a0, -0xa8(s0) +115c6: lw a0, -0x10(s0) +115ca: lw a1, -0x14(s0) +115ce: xor a0, a0, a1 +115d0: lw a1, -0x1c(s0) +115d4: xor a0, a0, a1 +115d6: lw a1, -0x24(s0) +115da: xor a0, a0, a1 +115dc: lw a1, -0x30(s0) +115e0: xor a0, a0, a1 +115e2: lw a1, -0x3c(s0) +115e6: xor a0, a0, a1 +115e8: lw a1, -0x54(s0) +115ec: xor a0, a0, a1 +115ee: lw a1, -0x94(s0) +115f2: xor a0, a0, a1 +115f4: lw a1, -0xa4(s0) +115f8: xor a0, a0, a1 +115fa: lw a1, -0xa8(s0) +115fe: xor a0, a0, a1 +11600: lw ra, 0xdc(sp) +11602: lw s0, 0xd8(sp) +11604: addi sp, sp, 0xe0 +11606: ret +`; + +// Ghidra 12.1.4 HighFunction prototypes of the same build, printed as +// `name return | parameter:type@storage`. DERIVED uses __rea_ilp32. The two +// stock lines are Ghidra's ilp32d prototype, and the ilp32 prototype before it +// passed values over 8 bytes by reference. +const DERIVED = ` +rea_riscv_abi_pick float@a0:4 | a:float@a0:4 | b:float@a1:4 | sel:int@a2:4 +rea_riscv_abi_wide double@a1:4,a0:4 | a:double@a1:4,a0:4 | sel:int@a2:4 +rea_riscv_abi_mixed double@a1:4,a0:4 | i:int@a0:4 | d:double@a2:4,a1:4 | f:float@a3:4 | e:double@a5:4,a4:4 +rea_riscv_abi_split double@a1:4,a0:4 | a:int@a0:4 | b:int@a1:4 | c:int@a2:4 | d:int@a3:4 | e:int@a4:4 | f:int@a5:4 | g:int@a6:4 | x:double@Stack[0x0]:4,a7:4 +rea_riscv_abi_pair rea_riscv_abi_pair@a1:4,a0:4 | value:rea_riscv_abi_pair@a1:4,a0:4 | scale:float@a2:4 +rea_riscv_abi_tagged rea_riscv_abi_tagged@a1:4,a0:4 | value:rea_riscv_abi_tagged@a1:4,a0:4 | tag:int@a2:4 +rea_riscv_abi_large rea_riscv_abi_large *@a0:4 | __return_storage_ptr__:rea_riscv_abi_large *@a0:4 (auto) | value:rea_riscv_abi_large *@a1:4 | scale:float@a2:4 +rea_riscv_abi_quad long double *@a0:4 | __return_storage_ptr__:long double *@a0:4 (auto) | value:long double *@a1:4 | sel:int@a2:4 +rea_riscv_abi_variadic uint@a0:4 | count:int@a0:4 +`; +const DEFAULT_PICK = + "rea_riscv_abi_pick float@fa0:4 | a:float@fa0:4 | b:float@fa1:4 | sel:int@a0:4"; +const JOINED_QUAD = + "rea_riscv_abi_quad long double *@a0:4 | __return_storage_ptr__:long double *@a0:4 (auto) | value:long double@a4:4,a3:4,a2:4,a1:4 | sel:int@a5:4"; +const STRIPPED = ` +rea_riscv_abi_pick undefined4@a0:4 | param_1:undefined4@a0:4 | param_2:undefined4@a1:4 | param_3:int@a2:4 +rea_riscv_abi_wide undefined8@a1:4,a0:4 | param_1:undefined4@a0:4 | param_2:undefined4@a1:4 | param_3:int@a2:4 +rea_riscv_abi_mixed undefined8@a1:4,a0:4 | param_1:int@a0:4 | param_2:undefined4@a1:4 | param_3:undefined4@a2:4 | param_4:undefined4@a3:4 | param_5:undefined4@a4:4 | param_6:undefined4@a5:4 +rea_riscv_abi_split undefined8@a1:4,a0:4 | param_1:uint@a0:4 | param_2:uint@a1:4 | param_3:uint@a2:4 | param_4:uint@a3:4 | param_5:uint@a4:4 | param_6:uint@a5:4 | param_7:uint@a6:4 | param_8:undefined4@a7:4 | param_9:undefined4@Stack[0x0]:4 +rea_riscv_abi_pair undefined8@a1:4,a0:4 | param_1:undefined4@a0:4 | param_2:undefined4@a1:4 | param_3:undefined4@a2:4 +rea_riscv_abi_tagged undefined8@a1:4,a0:4 | param_1:undefined4@a0:4 | param_2:undefined4@a1:4 | param_3:undefined4@a2:4 +rea_riscv_abi_large void@ | param_1:undefined4 *@a0:4 | param_2:undefined4 *@a1:4 | param_3:undefined4@a2:4 +rea_riscv_abi_quad void@ | param_1:undefined4 *@a0:4 | param_2:undefined4 *@a1:4 | param_3:undefined4@a2:4 +rea_riscv_abi_variadic uint@a0:4 | param_1:int@a0:4 +`; + +/** Rebuild inspect_native_api boundaries from the recorded prototype lines. */ +const boundaries = (text: string, calling_convention = "__rea_ilp32") => + Object.fromEntries( + text + .trim() + .split("\n") + .map((line) => { + const [head = "", ...parameters] = line.split(" | "); + const name = head.slice(0, head.indexOf(" ")); + const [data_type, storage] = head.slice(name.length + 1).split("@"); + return [ + name, + { + calling_convention, + return_type: { data_type, storage }, + parameters: parameters.map((parameter, ordinal) => { + const at = parameter.indexOf(":"); + const [type, place] = parameter.slice(at + 1).split("@"); + return { + ordinal, + name: parameter.slice(0, at), + data_type: type, + storage: place, + }; + }), + }, + ]; + }), + ); + +// Call lines of rea_riscv_abi_caller's pseudocode from the same Ghidra runs. +const TYPED_CALLER = ` + local_10 = rea_riscv_abi_pick(1.5,2.5,sel); + local_18 = rea_riscv_abi_wide(3.25,local_c); + local_20 = rea_riscv_abi_mixed(local_c,4.75,5.5,6.125); + local_28 = rea_riscv_abi_split(1,2,3,4,5,6,local_c,7.25); + local_30 = rea_riscv_abi_pair((rea_riscv_abi_pair)0x4118000041080000,10.5); + local_40 = rea_riscv_abi_tagged((rea_riscv_abi_tagged)0xc41380000,local_c); + rea_riscv_abi_large(&rStack_60,&local_90,16.5); + rea_riscv_abi_quad((long_double *)&local_c0,(long_double *)&local_d0,local_c); + local_a4 = rea_riscv_abi_variadic(2,0x13,0,0x40318000,local_c); + fVar1 = (*rea_riscv_abi_callback)(20.5,21.5,local_c); +`; +const DEFAULT_CALLER = ` + fVar2 = rea_riscv_abi_pick(in_fa0,in_fa1,0x3fc00000); + dVar3 = rea_riscv_abi_wide((double)CONCAT44(extraout_var,fVar2),0); + (*rea_riscv_abi_callback)(extraout_fa0,extraout_fa1_00,0x41a40000); +`; +const STRIPPED_CALLER = ` + local_10 = FUN_ram_000110f4(0x3fc00000,0x40200000,param_1); + local_18 = FUN_ram_00011130(0,0x400a0000,local_c); + local_20 = FUN_ram_0001117e(local_c,0,0x40130000,0x40b00000,0,0x40188000); + local_28 = FUN_ram_000111e6(1,2,3,4,5,6,local_c,0,0x401d0000); + local_30 = FUN_ram_00011256(0x41080000,0x41180000,0x41280000); + local_40 = FUN_ram_00011296(0x41380000,0xc,local_c); + FUN_ram_000112d2(auStack_60,&local_90,0x41840000); + FUN_ram_00011316(&local_c0,&local_d0,local_c); + local_a4 = FUN_ram_0001136a(2,0x13,0,0x40318000,local_c); + uVar1 = (*(code *)PTR_FUN_ram_000110f4_ram_00012648) + (0x41a40000,0x41ac0000,local_c,PTR_FUN_ram_000110f4_ram_00012648); +`; +const ADDRESSES = { + rea_riscv_abi_pick: "0x110f4", + rea_riscv_abi_wide: "0x11130", + rea_riscv_abi_mixed: "0x1117e", + rea_riscv_abi_split: "0x111e6", + rea_riscv_abi_pair: "0x11256", + rea_riscv_abi_tagged: "0x11296", + rea_riscv_abi_large: "0x112d2", + rea_riscv_abi_quad: "0x11316", + rea_riscv_abi_variadic: "0x1136a", +}; + +describe("RISC-V ilp32 conformance checks", () => { + it("keeps every call-site word at its storage location", () => { + expect(() => assertRiscvIlp32TableConsistent()).not.toThrow(); + expect(riscvStorageLocations("Stack[0x0]:4,a7:4")).toEqual([ + "Stack[0x0]", + "a7", + ]); + expect(riscvStorageLocations("a0:4 (auto)")).toEqual(["a0"]); + }); + + it("recovers the words the compiler's call sites write", () => { + const calls = parseRiscvCallSites(LISTING); + expect(calls.map((call) => call.target)).toEqual([ + "rea_riscv_abi_pick", + "rea_riscv_abi_wide", + "rea_riscv_abi_mixed", + "rea_riscv_abi_split", + "rea_riscv_abi_pair", + "rea_riscv_abi_tagged", + "rea_riscv_abi_large", + "rea_riscv_abi_quad", + "rea_riscv_abi_variadic", + "indirect", + ]); + expect(() => assertRiscvIlp32CallSites(calls)).not.toThrow(); + // The variadic pairs skip a1, which still holds the stack argument. + expect(calls[8]?.words).toMatchObject({ + a1: { word: 19 }, + a3: { word: 0x40318000 }, + a7: { word: 0x40328000 }, + "Stack[0x0]": { word: 19 }, + }); + }); + + it("refuses a listing whose call site places a value elsewhere", () => { + const moved = LISTING.replace("lui a1, 0x40200", "lui a2, 0x40200"); + expect(() => assertRiscvIlp32CallSites(parseRiscvCallSites(moved))).toThrow( + /rea_riscv_abi_pick: a1/u, + ); + // The long double copy that a1 points at must hold 22.5L's words. + const copied = LISTING.replace("lui a1, 0x40037", "lui a1, 0x40038"); + expect(() => + assertRiscvIlp32CallSites(parseRiscvCallSites(copied)), + ).toThrow(/rea_riscv_abi_quad: a1/u); + }); + + it("accepts the derived prototypes and refuses Ghidra's stock ilp32d model", () => { + expect(() => assertRiscvIlp32Storage(boundaries(DERIVED))).not.toThrow(); + expect(() => + assertRiscvIlp32Storage(boundaries(STRIPPED), { stripped: true }), + ).not.toThrow(); + for (const stock of [DEFAULT_PICK, JOINED_QUAD]) + expect(() => + assertRiscvIlp32Storage({ + ...boundaries(DERIVED), + ...boundaries(stock), + }), + ).toThrow(); + expect(() => + assertRiscvIlp32Storage(boundaries(DERIVED, "__stdcall")), + ).toThrow(); + expect(Object.keys(boundaries(DERIVED))).toEqual( + Object.keys(RISCV_ILP32_ABI), + ); + }); + + it("checks a stripped build's return storage, not its recovered types", () => { + const stripped = (text: string) => + assertRiscvIlp32Storage(boundaries(text), { stripped: true }); + expect(() => + stripped(STRIPPED.replaceAll("undefined8@", "ulonglong@")), + ).not.toThrow(); + // A register-pair return that loses its high word is refused. + expect(() => + stripped( + STRIPPED.replace( + "rea_riscv_abi_wide undefined8@a1:4,a0:4", + "rea_riscv_abi_wide undefined4@a0:4", + ), + ), + ).toThrow(/rea_riscv_abi_wide inferred return storage/u); + }); + + it("reads the caller's arguments through the corrected storage", () => { + expect(() => assertRiscvIlp32Caller(TYPED_CALLER, ADDRESSES)).not.toThrow(); + expect(() => + assertRiscvIlp32Caller(STRIPPED_CALLER, ADDRESSES, { stripped: true }), + ).not.toThrow(); + expect(() => assertRiscvIlp32Caller(DEFAULT_CALLER, ADDRESSES)).toThrow(); + expect(() => + assertRiscvIlp32Caller( + STRIPPED_CALLER.replace( + "0x40130000,0x40b00000", + "0x40b00000,0x40130000", + ), + ADDRESSES, + { stripped: true }, + ), + ).toThrow(/rea_riscv_abi_mixed call arguments/u); + }); +}); diff --git a/tests/conformance/ghidra/riscv-fixture.mjs b/tests/conformance/ghidra/riscv-fixture.mjs index 27b354094e..65636d171c 100644 --- a/tests/conformance/ghidra/riscv-fixture.mjs +++ b/tests/conformance/ghidra/riscv-fixture.mjs @@ -8,19 +8,108 @@ import { promisify } from "node:util"; const execute = promisify(execFile); +/** One build per float ABI the lane verifies. */ +const BUILDS = { + double: { + sources: ["../c/riscv.c", "./riscv-probe.S"], + march: (bits) => `rv${bits}imafdc`, + mabi: (bits) => (bits === 32 ? "ilp32d" : "lp64d"), + flags: 5, + }, + // ESP-IDF's ESP32-C2 and C3 base string, built ilp32 as ESP-IDF does. + soft: { + sources: [ + "../c/riscv-float-abi.c", + "./riscv-probe.S", + "./riscv-isa-probe.S", + ], + march: () => "rv32imc_zicsr_zifencei", + mabi: () => "ilp32", + flags: 1, + }, +}; + +const readerOptions = { + env: { ...process.env, LC_ALL: "C" }, + timeout: 10000, + maxBuffer: 1024 * 1024, +}; + +/** Independent reading of LLVM readelf's `-h -S -s -A` report. */ +const readRiscvElf = async (reader, path, floatAbi) => { + const { stdout, stderr } = await execute( + reader, + ["-h", "-S", "-s", "-A", "-W", path], + readerOptions, + ); + assert.equal(stderr.trim(), "", "Unexpected ELF reader diagnostics"); + const field = (expression, label) => { + const matches = [...stdout.matchAll(expression)]; + assert.equal(matches.length, 1, `Missing or ambiguous readelf ${label}`); + return matches[0][1].trim(); + }; + const sections = [ + ...stdout.matchAll( + /^\s*\[\s*\d+\]\s+(\S+)\s+(\S+)\s+([\da-f]+)\s+([\da-f]+)\s+([\da-f]+)\s+[\da-f]+\s+(\S*)\s+\d+\s+\d+\s+\d+\s*$/gmu, + ), + ].map(([, name, type, address, offset, size, flags]) => ({ + name, + type, + address: Number.parseInt(address, 16), + offset: Number.parseInt(offset, 16), + size: Number.parseInt(size, 16), + allocated: flags.includes("A"), + })); + assert.ok(sections.length > 0, "Missing readelf section table"); + // LLVM readelf prints each attribute as Tag, TagName and Value lines. + const arch = field(/TagName: arch\s*\n\s*Value: (\S+)\s*$/gmu, "arch"); + const [base, ...tokens] = arch.split("_"); + const declared = /^rv(32|64)([ie])/u.exec(base); + assert.ok(declared !== null, `Unexpected Tag_RISCV_arch ${arch}`); + return { + stdout, + sections, + class: field(/^\s*Class:\s*(.+)$/gmu, "class"), + data: field(/^\s*Data:\s*(.+)$/gmu, "byte order"), + type: field(/^\s*Type:\s*(.+)$/gmu, "type"), + machine: field(/^\s*Machine:\s*(.+)$/gmu, "machine"), + flags: Number(field(/^\s*Flags:\s*(0x[\da-f]+).*$/gmu, "flags")), + isa: { + status: "declared", + arch, + xlen: Number(declared[1]), + base: declared[2], + extensions: tokens.map((token) => /^(.*?)(?:\d+p\d+)?$/u.exec(token)[1]), + }, + floatAbi, + }; +}; + +const sha256 = async (path) => + createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + /** Build and independently inspect a source-owned ELF; never execute it. */ -export async function buildRiscvFixture(directory, bits) { +export async function buildRiscvFixture(directory, bits, floatAbi = "double") { assert.ok(bits === 32 || bits === 64); + const build = BUILDS[floatAbi]; + assert.ok(build !== undefined, `No ${floatAbi}-float RISC-V fixture`); + assert.ok(floatAbi === "double" || bits === 32); const compiler = process.env.REA_RISCV_CLANG ?? "clang"; const linker = process.env.REA_RISCV_LLD ?? "ld.lld"; const reader = process.env.REA_RISCV_READELF ?? "llvm-readelf"; - const source = fileURLToPath(new URL("../c/riscv.c", import.meta.url)); - const probe = fileURLToPath(new URL("./riscv-probe.S", import.meta.url)); - const path = join(directory, `rv${bits}.elf`); + const sources = build.sources.map((path) => + fileURLToPath(new URL(path, import.meta.url)), + ); + const path = join( + directory, + `rv${bits}${floatAbi === "double" ? "" : `-${floatAbi}`}.elf`, + ); const args = [ `--target=riscv${bits}-unknown-elf`, - `-march=rv${bits}imafdc`, - `-mabi=${bits === 32 ? "ilp32d" : "lp64d"}`, + `-march=${build.march(bits)}`, + `-mabi=${build.mabi(bits)}`, `--ld-path=${linker}`, "-nostdlib", "-static", @@ -29,8 +118,7 @@ export async function buildRiscvFixture(directory, bits) { "-g", "-Wl,-e,rea_riscv_entry", "-Wl,--build-id=none", - source, - probe, + ...sources, "-o", path, ]; @@ -39,46 +127,37 @@ export async function buildRiscvFixture(directory, bits) { const linkerVersion = await execute(linker, ["--version"], options); const readerVersion = await execute(reader, ["--version"], options); await execute(compiler, args, { ...options, timeout: 60000 }); - const { stdout, stderr } = await execute(reader, ["-h", "-s", "-W", path], { - ...options, - env: { ...process.env, LC_ALL: "C" }, - }); - assert.equal(stderr.trim(), "", "Unexpected ELF reader diagnostics"); - const field = (expression, label) => { - const matches = [...stdout.matchAll(expression)]; - assert.equal(matches.length, 1, `Missing or ambiguous readelf ${label}`); - return matches[0][1].trim(); - }; - assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), `ELF${bits}`); - assert.match(field(/^\s*Data:\s*(.+)$/gmu, "byte order"), /little endian/u); - assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u); - assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^RISC-V\b/u); - const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+).*$/gmu, "flags")); - assert.equal(flags, 5, "Compiler did not declare RVC and double-float ABI"); + const elf = await readRiscvElf(reader, path, floatAbi); + assert.equal(elf.class, `ELF${bits}`); + assert.match(elf.data, /little endian/u); + assert.match(elf.type, /^EXEC\b/u); + assert.match(elf.machine, /^RISC-V\b/u); + assert.equal( + elf.flags, + build.flags, + `Compiler did not declare RVC and the ${floatAbi}-float ABI`, + ); + assert.equal(elf.isa.xlen, bits); + // Every source-owned global; the verifier asserts the ones it needs. const symbols = {}; - for (const name of [ - "rea_riscv_entry", - "rea_riscv_leaf", - "rea_riscv_float", - "rea_riscv_probe", - "rea_riscv_global", - ]) { - const value = field( - new RegExp( - `^\\s*\\d+:\\s+([\\da-f]+)\\s+\\d+\\s+(?:FUNC|OBJECT)\\s+GLOBAL\\s+\\S+\\s+\\d+\\s+${name}\\s*$`, - "gmu", - ), - name, - ); + for (const [, value, name] of elf.stdout.matchAll( + /^\s*\d+:\s+([\da-f]+)\s+\d+\s+(?:FUNC|OBJECT)\s+GLOBAL\s+\S+\s+\d+\s+(rea_riscv_\w+)\s*$/gmu, + )) { + assert.equal(symbols[name], undefined, `Ambiguous readelf symbol ${name}`); symbols[name] = `0x${BigInt(`0x${value}`).toString(16)}`; } return { path, - sha256: createHash("sha256") - .update(await readFile(path)) - .digest("hex"), - riscv: { elfClass: bits, byteOrder: "little", type: 2, flags }, + sha256: await sha256(path), + riscv: { + elfClass: bits, + byteOrder: "little", + type: 2, + flags: elf.flags, + isa: elf.isa, + }, symbols, + sections: elf.sections, toolchain: { compiler: { command: compiler, version: compilerVersion.stdout.trim() }, linker: { command: linker, version: linkerVersion.stdout.trim() }, @@ -86,12 +165,69 @@ export async function buildRiscvFixture(directory, bits) { arguments: args, }, sources: await Promise.all( - [source, probe].map(async (sourcePath) => ({ + sources.map(async (sourcePath) => ({ path: sourcePath, - sha256: createHash("sha256") - .update(await readFile(sourcePath)) - .digest("hex"), + sha256: await sha256(sourcePath), })), ), }; } + +/** + * Link the same objects again without symbols or DWARF. Every allocated + * section must keep its address and bytes, so the typed build's symbol + * addresses locate the same code in the stripped image. + */ +export async function stripRiscvFixture(directory, typed) { + const path = join( + directory, + typed.path.split("/").at(-1).replace(".elf", "-stripped.elf"), + ); + const args = [ + ...typed.toolchain.arguments.slice(0, -2), + "-Wl,--strip-all", + "-o", + path, + ]; + await execute(typed.toolchain.compiler.command, args, { + timeout: 60000, + maxBuffer: 1024 * 1024, + }); + const elf = await readRiscvElf(typed.toolchain.reader.command, path, "soft"); + assert.equal(elf.flags, typed.riscv.flags); + assert.deepEqual(elf.isa, typed.riscv.isa); + assert.ok( + !elf.sections.some( + (section) => + section.name === ".symtab" || section.name.startsWith(".debug"), + ), + "Stripped fixture still carries symbols or DWARF", + ); + const allocated = (sections) => + sections + .filter((section) => section.allocated) + .map(({ name, address, size }) => ({ name, address, size })); + assert.deepEqual(allocated(elf.sections), allocated(typed.sections)); + const [typedBytes, strippedBytes] = await Promise.all([ + readFile(typed.path), + readFile(path), + ]); + for (const section of elf.sections.filter((value) => value.allocated)) { + if (section.type === "NOBITS") continue; + const twin = typed.sections.find((value) => value.name === section.name); + assert.ok( + strippedBytes + .subarray(section.offset, section.offset + section.size) + .equals(typedBytes.subarray(twin.offset, twin.offset + twin.size)), + `Stripped ${section.name} bytes differ from the typed build`, + ); + } + return { + ...typed, + path, + sha256: await sha256(path), + stripped: true, + sections: elf.sections, + toolchain: { ...typed.toolchain, arguments: args }, + }; +} diff --git a/tests/conformance/ghidra/riscv-isa-probe.S b/tests/conformance/ghidra/riscv-isa-probe.S new file mode 100644 index 0000000000..e5459a2861 --- /dev/null +++ b/tests/conformance/ghidra/riscv-isa-probe.S @@ -0,0 +1,11 @@ +/* Zifencei and Zicsr oracle for the soft-float fixture, independent of the + * compiler's C code generation. Returns its argument unchanged. */ +.section .text +.option norvc +.global rea_riscv_isa_probe +.type rea_riscv_isa_probe, @function +rea_riscv_isa_probe: + fence.i + csrr t0, mcycle + ret +.size rea_riscv_isa_probe, .-rea_riscv_isa_probe