From 0baad58e606f12a8dc6cef06253f85350378e5b1 Mon Sep 17 00:00:00 2001 From: The one -Niu <912906590@qq.com> Date: Tue, 13 Oct 2026 00:11:35 +0800 Subject: [PATCH] feat(ghidra): admit RISC-V ELF analysis through a pinned generic lane (#2151) * feat(ghidra): admit RISC-V ELF analysis through a pinned generic lane Refs #2063. Recognize `EM_RISCV` (243) as a provider-neutral family that carries its own ELF facts, and bound the first Ghidra lane to little-endian `ET_EXEC` images that declare the double-float ABI, pinned to `RISCV:LE:32:default` or `RISCV:LE:64:default` with the `gcc` compiler specification. Ghidra 12.1.4 is not reliable without the pin: its `riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves the 32-bit constraint with the comment that the loader "must choose between 'default' and 'AndeStar' variants". Measured on the installed release tree, a source-owned generic RV32 ELF with no Andes declaration imports as `RISCV:LE:32:AndeStar_v5:gcc`, while RV64 resolves to `RISCV:LE:64:default:gcc`. The lane therefore states the language per width and refuses any caller-selected processor outside it instead of inheriting the header guess. Refusals name the declaration rather than falling back to a guessed profile: big-endian images (Ghidra 12.1.4 ships no big-endian RISC-V language), non-`ET_EXEC` images, `EF_RISCV_RVE`, `EF_RISCV_TSO`, and every float ABI other than double, which the pinned generic compiler specifications model. The raw e_flags bits stay in Evidence as declarations, not as instruction semantics. Family recognition now has one owner for the ELF-metadata families: `ELF_METADATA_ARCHITECTURES`, `isElfMetadataArchitecture`, and `HeaderArchitecture` replace ten `Exclude` sites that would each have needed a second hand-edited member. `ghidraPinnedLanguage` is that owner for the import commitment: the session readback previously re-derived only the MIPS pin, so a profile committing any second pinned language failed with a language-mismatch error before the bridge started. Hopper and IDA refuse RISC-V by name, matching the MIPS wording. Caller-supplied raw-binary entry keeps its existing four-family list; this lane is ELF-header admission only. Verification on macOS 15.7.7 arm64, Node 24.18.0, Ghidra 12.1.4 PUBLIC and JDK 21.0.12.1, with the host decompiler built from the release sources for `os/mac_arm_64`: `npm run check` passes; `npm run test:fast` passes 396 test files; `npm run verify:test-discovery` discovers all 917 test files exactly once. Source-owned RV32 and RV64 double-float `ET_EXEC` fixtures complete `rea analyze --provider ghidra` and `rea decompile rea_riscv_entry`, committing `RISCV:LE:32:default` and `RISCV:LE:64:default` with `language_selection: provider-pinned-v1`; the soft-float RV64 shape is refused with the named float-ABI reason, and the full refusal matrix including the RV32 and width-mismatch shapes is covered by unit tests. No `verify:ghidra:riscv` lane script is included in this change; the runs above were performed manually against the installed engine. Fork pull requests receive no CI check runs, so the real-provider scope above is the evidence available for review. * fix(ghidra): bound the RISC-V lane by declared flags and document it Address review feedback on #2151. `e_flags` bits outside `EF_RISCV_RVC | EF_RISCV_FLOAT_ABI` are now refused with the offending value named, so a reserved or newer producer declaration cannot enter the verified generic lane by falling through the RVE, TSO and float-ABI checks. Admission reads `e_type` only, so the refusal text no longer calls `ET_EXEC` static: a fixed-address declaration does not establish static linkage, and `PT_INTERP` and dynamic dependencies are not inspected. The session records that boundary as a limitation rather than inferring a load model. Add `docs/ghidra-riscv.md` in the shape of the MIPS lane guide (admission boundary, refusal table, why the language is pinned per class, Evidence fields, and what the fixtures do not establish), and correct `docs/firmware-analysis.md`, which still told users that MIPS and RISC-V decompilation had not been added. Verification after the change: `npm run check` and `npm run docs:check` pass; the RISC-V module tests pass (17 lane cases plus 9 parser cases); on Ghidra 12.1.4 with JDK 21.0.12.1 the RV32 and RV64 double-float fixtures still analyze and decompile with `RISCV:LE:32:default` / `RISCV:LE:64:default` and `language_selection: provider-pinned-v1`, the soft-float fixture is still refused by name, and the new linkage limitation appears in the emitted Evidence. * fix(ghidra): align RISC-V pinning with admission Reuse the complete lane admission boundary when selecting a language, retain unsigned unsupported flag bits in diagnostics, and derive float ABI metadata from the source declaration. * test(ghidra): verify RISC-V CLI and MCP workflows Build source-owned RV32/RV64 fixtures with Clang and LLD, establish independent ELF and symbol facts with LLVM readelf, and verify real provider evidence, instruction bytes, calls, decompilation, schema projections, snapshot replay, refusal diagnostics, source immutability and cleanup. Verified on macOS ARM64 with Ghidra 12.1.4, JDK 21 and LLVM/LLD 23.1.2. Dynamic linkage, big-endian, Andes and RVE/TSO workflows remain unverified; ISA attributes are not inspected. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/firmware-analysis.md | 6 +- docs/ghidra-riscv.md | 105 ++++++ docs/testing.md | 1 + package.json | 1 + scripts/verify-real-ghidra-riscv.mjs | 391 +++++++++++++++++++++ src/domain/binaryTarget.riscv.test.ts | 98 ++++++ src/domain/binaryTarget.ts | 79 +++-- src/domain/binaryTargetTypes.ts | 55 ++- src/domain/peMachine.ts | 4 +- src/domain/rawBinary.ts | 7 +- src/ghidra/GhidraAnalysisProfile.ts | 34 +- src/ghidra/GhidraProvider.ts | 1 + src/ghidra/GhidraProviderClient.ts | 6 +- src/ghidra/GhidraPspProfile.ts | 3 + src/ghidra/GhidraRawBinaryLanguage.ts | 4 +- src/ghidra/GhidraRiscvProfile.test.ts | 256 ++++++++++++++ src/ghidra/GhidraRiscvProfile.ts | 129 +++++++ src/hopper/HopperAnalysisProfile.ts | 21 +- src/hopper/HopperMachOImage.ts | 6 +- src/hopper/HopperProvider.ts | 11 +- src/ida/IdaProvider.ts | 15 +- tests/conformance/c/riscv.c | 18 + tests/conformance/ghidra/README.md | 8 + tests/conformance/ghidra/riscv-fixture.mjs | 97 +++++ tests/conformance/ghidra/riscv-probe.S | 9 + 25 files changed, 1293 insertions(+), 72 deletions(-) create mode 100644 docs/ghidra-riscv.md create mode 100644 scripts/verify-real-ghidra-riscv.mjs create mode 100644 src/domain/binaryTarget.riscv.test.ts create mode 100644 src/ghidra/GhidraRiscvProfile.test.ts create mode 100644 src/ghidra/GhidraRiscvProfile.ts create mode 100644 tests/conformance/c/riscv.c create mode 100644 tests/conformance/ghidra/riscv-fixture.mjs create mode 100644 tests/conformance/ghidra/riscv-probe.S diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 1674d8fd11..1f4ff14d60 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -123,8 +123,10 @@ Select a verified native child for the existing tools: ``` Then use `analyze_function`, `inspect_native_api` or the other native primitives. -Native architecture and executable validation still apply. This milestone does -not add MIPS/PPC/RISC-V decompilation, raw flash load profiles, bootloader/device +Native architecture and executable validation still apply. Generic +[MIPS ELF](ghidra-mips.md) and [RISC-V ELF](ghidra-riscv.md) lanes are separate +documents with their own admission boundaries. This milestone does +not add PPC decompilation, raw flash load profiles, bootloader/device tree semantics, vulnerability scanning, emulation or hardware runtime capture. ## Resource and lifecycle policy diff --git a/docs/ghidra-riscv.md b/docs/ghidra-riscv.md new file mode 100644 index 0000000000..e180122d71 --- /dev/null +++ b/docs/ghidra-riscv.md @@ -0,0 +1,105 @@ +# RISC-V ELF analysis through Ghidra + +This lane extends native ELF analysis for `EM_RISCV` targets. It does not +execute RISC-V code, emulate a boot sequence, or rehost firmware. It addresses +[#2063](https://github.com/morluto/rea/issues/2063) and follows the same +lane shape as [generic MIPS support](ghidra-mips.md). + +## Admission boundary + +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, and caller-supplied headerless raw-binary entry still accepts only +its existing four families. + +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. + +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 15-register ABI 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 | + +`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 +dynamic dependencies are not inspected, and the session records that boundary as +a limitation rather than inferring a load model. + +## Why the language is pinned + +Ghidra's header-based selection is not reliable for RV32. The 12.1.4 +`riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves the +32-bit constraint unpinned with the comment that the loader "must choose between +'default' and 'AndeStar' variants". Measured against the installed release tree: + +| Fixture | Ghidra auto-selection | +| ----------------------------------------------- | ----------------------------- | +| RV64 ELF, `e_flags 0x5` (RVC, double-float ABI) | `RISCV:LE:64:default:gcc` | +| RV32 ELF, `e_flags 0x5`, no Andes declaration | `RISCV:LE:32:AndeStar_v5:gcc` | + +So an ordinary RV32 image lands on an Andes processor model unless the language +is stated. REA therefore pins `RISCV:LE:32:default` or `RISCV:LE:64:default` +with the `gcc` compiler specification per class, records +`language_selection: provider-pinned-v1`, and refuses a caller-selected +`REA_GHIDRA_LANGUAGE_ID` or `REA_GHIDRA_COMPILER_SPEC_ID` that disagrees with the +pin without changing the requested override. An override that matches the pin is +accepted and recorded as `configured-v1`. + +## 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. + +## Verification and limits + +Ghidra 12.1.4 PUBLIC with a 64-bit JDK 21+ is required, and on macOS the release +tree ships no host decompiler binary, so `Decompiler/os//decompile` and +`sleigh` must come from your own build; see +[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`: + +```sh +GHIDRA_INSTALL_DIR=/path/to/ghidra_12.1.4_PUBLIC \ +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 \ +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. + +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. diff --git a/docs/testing.md b/docs/testing.md index b80ff8f787..7bb14effeb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -308,6 +308,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 and unknown-name rejection | 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: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 e5de6a3dbe..dd0ef57d80 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,7 @@ "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", "verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs", "verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs", + "verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs", "verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs", "verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs", "verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs", diff --git a/scripts/verify-real-ghidra-riscv.mjs b/scripts/verify-real-ghidra-riscv.mjs new file mode 100644 index 0000000000..a87adeeb5b --- /dev/null +++ b/scripts/verify-real-ghidra-riscv.mjs @@ -0,0 +1,391 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import Ajv from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; +import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js"; +import { buildRiscvFixture } from "../tests/conformance/ghidra/riscv-fixture.mjs"; +import { + mcpTextValue, + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +if (process.argv.length !== 2) + throw new Error("Usage: node scripts/verify-real-ghidra-riscv.mjs"); +if (!["linux", "darwin"].includes(process.platform)) + throw new Error("This optional lane requires a Linux/macOS Ghidra host."); +if (process.env.GHIDRA_INSTALL_DIR === undefined) + throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR"); +for (const key of ["REA_GHIDRA_LANGUAGE_ID", "REA_GHIDRA_COMPILER_SPEC_ID"]) + if (process.env[key] !== undefined) + throw new Error( + `Unset ${key}: this lane verifies provider-pinned selection.`, + ); +const installation = inspectGhidraInstallation({ + environment: process.env, + installDir: process.env.GHIDRA_INSTALL_DIR, + ...(process.env.JAVA_HOME === undefined + ? {} + : { javaHome: process.env.JAVA_HOME }), +}); +assert.equal(installation.status, "available", JSON.stringify(installation)); +assert.equal(installation.providerVersion, "12.1.4"); + +const execute = promisify(execFile); +const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const run = createVerifierRun(); +const workspace = await mkdtemp(join(tmpdir(), "rea-riscv-proof-")); +const runtime = join(workspace, "runtime"); +await mkdir(runtime); +const env = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + TMPDIR: runtime, + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "ghidra", + REA_PROCESS_RUN_ID: run.run_id, + GHIDRA_INSTALL_DIR: installation.installDir, + JAVA_HOME: installation.javaHome, +}; +for (const key of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) + if (process.env[key] !== undefined) env[key] = process.env[key]; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", +}); +const client = new Client({ name: "rea-real-riscv-proof", version: "1" }); +let stderr = ""; +transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); +}); + +const reports = []; +let failure; +try { + await client.connect(transport); + const catalog = await client.listTools(); + const ajv = new Ajv({ strict: false }); + addFormats(ajv); + const validators = new Map(); + for (const tool of catalog.tools) { + assert.ok( + ajv.validateSchema(tool.outputSchema), + `${tool.name}: invalid output schema`, + ); + const existing = + typeof tool.outputSchema.$id === "string" + ? ajv.getSchema(tool.outputSchema.$id) + : undefined; + validators.set(tool.name, existing ?? ajv.compile(tool.outputSchema)); + } + for (const bits of [32, 64]) { + const fixture = await buildRiscvFixture(workspace, bits); + 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) => { + 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, + }); + }; + 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); + }; + const call = async (name, args = {}) => { + const reply = await client.callTool( + { name, arguments: args }, + { timeout: 240000 }, + ); + const projected = requireMcpOperationResult(reply, name); + const textual = JSON.parse(mcpTextValue(reply)); + assert.deepEqual(reply.structuredContent, textual); + const validate = validators.get(name); + assert.ok(validate, `${name} missing from advertised catalog`); + assert.ok( + validate(textual), + `${name}: ${JSON.stringify(validate.errors)}`, + ); + if (!["open_binary", "close_binary", "binary_session"].includes(name)) + assertEvidence(parseEvidence(textual)); + return projected; + }; + const opened = await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }); + assert.equal(opened.architecture, "riscv"); + assert.equal(opened.sha256, fixture.sha256); + const image = await call("inspect_native_load_image"); + assert.equal(image.observations.language_id, expectedLanguage); + assert.equal(image.observations.compiler_spec_id, "gcc"); + assert.ok(image.observations.source_files.length > 0); + for (const source of image.observations.source_files) { + assert.equal(source.original_sha256, fixture.sha256); + 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); + assert.ok(procedure, `${name} missing from Ghidra inventory`); + assert.equal(BigInt(procedure.address), BigInt(fixture.symbols[name])); + } + const probe = await call("inspect_native_instruction", { + address: fixture.symbols.rea_riscv_probe, + }); + assert.equal(probe.status, "decoded"); + assert.equal(probe.architecture, expectedLanguage); + assert.equal(probe.length, 4); + assert.equal(probe.bytes, "13051501"); // addi a0, a0, 17 + const mapping = await call("address_to_file_offset", { + address: fixture.symbols.rea_riscv_probe, + }); + const sourceBytes = await readFile(fixture.path); + assert.equal( + sourceBytes + .subarray(mapping.file_offset, mapping.file_offset + 4) + .toString("hex"), + probe.bytes, + ); + const global = await call("read_bytes", { + address: fixture.symbols.rea_riscv_global, + length: 4, + }); + assert.equal(global.bytes_hex, "07000000"); + const callees = await call("procedure_callees", { + procedure: fixture.symbols.rea_riscv_entry, + }); + for (const name of ["rea_riscv_leaf", "rea_riscv_probe"]) + assert.ok( + callees.some( + (address) => BigInt(address) === BigInt(fixture.symbols[name]), + ), + ); + const dossier = functionDossierSchema.parse( + await call("analyze_function", { + procedure: fixture.symbols.rea_riscv_entry, + }), + ); + 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 }); + } + const snapshotPath = join(workspace, `rv${bits}.snapshot.json`); + const saved = await call("close_binary", { snapshot_path: snapshotPath }); + assert.ok(saved.evidence_records > 0); + const snapshot = parseAnalysisSnapshot( + JSON.parse(await readFile(snapshotPath, "utf8")), + ); + assert.equal(snapshot.target.architecture, "riscv"); + assert.equal(snapshot.target.sha256, fixture.sha256); + assertProfile(snapshot.binding.analysis_profile); + for (const record of snapshot.evidence_bundle.records) + assertEvidence(parseEvidence(record)); + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + assert.deepEqual(await call("list_procedures"), procedures); + await call("close_binary"); + + for (const [command, arguments_] of [ + ["search", ["rea_riscv_", "--kind", "procedures"]], + ["function", ["rea_riscv_entry"]], + ]) { + const cli = await execute( + process.execPath, + [ + entrypoint, + command, + fixture.path, + ...arguments_, + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + 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", + ]) + assert.ok( + evidence.normalized_result.some((value) => value.value === name), + ); + else { + const cliDossier = functionDossierSchema.parse( + evidence.normalized_result, + ); + assert.equal(cliDossier.procedure.address, dossier.procedure.address); + assert.ok(cliDossier.pseudocode.trim().length > 0); + } + } + assert.equal( + createHash("sha256") + .update(await readFile(fixture.path)) + .digest("hex"), + fixture.sha256, + ); + reports.push({ + ...fixture, + language: expectedLanguage, + procedures: procedures.length, + refusals, + cli: true, + stdio_mcp: true, + snapshot_replay: true, + original_unchanged: true, + }); + } +} catch (cause) { + failure = cause; +} +for (const close of [() => client.close(), () => transport.close()]) { + try { + await close(); + } catch (cause) { + failure = new AggregateError( + failure === undefined ? [cause] : [failure, cause], + "Verification or cleanup failed", + ); + } +} +const completed = await completeVerifierRun(run); +if ( + completed.process_lineage.status !== "verified" || + completed.process_lineage.descendants.length !== 0 +) + throw new Error(`Cleanup unverified; retained workspace: ${workspace}`, { + cause: failure, + }); +if (failure !== undefined) + throw new Error( + `RISC-V verification failed; retained workspace: ${workspace}; MCP stderr: ${stderr}`, + { cause: failure }, + ); +assert.deepEqual( + await readdir(runtime), + [], + `Runtime resources survived server shutdown; retained workspace: ${workspace}`, +); +await rm(workspace, { recursive: true, force: true }); +console.log( + JSON.stringify( + { + verifier_run: completed, + lane: "ghidra-riscv", + mocked: false, + host: { platform: process.platform, architecture: process.arch }, + provider: { id: "ghidra", version: installation.providerVersion }, + fixtures: reports, + cleanup: "complete", + }, + null, + 2, + ), +); diff --git a/src/domain/binaryTarget.riscv.test.ts b/src/domain/binaryTarget.riscv.test.ts new file mode 100644 index 0000000000..fcc77cf891 --- /dev/null +++ b/src/domain/binaryTarget.riscv.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { parseExecutableHeader } from "./binaryTarget.js"; + +const header = ( + little: boolean, + bits: 32 | 64 = 32, + flags = 0x5, + type = 2, + machine = 243, +): Buffer => { + const bytes = Buffer.alloc(bits === 32 ? 52 : 64); + bytes.set([0x7f, 0x45, 0x4c, 0x46, bits === 32 ? 1 : 2, little ? 1 : 2, 1]); + if (little) { + bytes.writeUInt16LE(type, 16); + bytes.writeUInt16LE(machine, 18); + bytes.writeUInt32LE(flags, bits === 32 ? 36 : 48); + } else { + bytes.writeUInt16BE(type, 16); + bytes.writeUInt16BE(machine, 18); + bytes.writeUInt32BE(flags, bits === 32 ? 36 : 48); + } + return bytes; +}; + +describe("RISC-V ELF header boundaries", () => { + it.each([ + [true, 32], + [false, 32], + [true, 64], + [false, 64], + ] as const)( + "retains class, endian, e_type and e_flags (little=%s, class=%s)", + (little, bits) => { + const parsed = parseExecutableHeader(header(little, bits), "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toEqual({ + format: "elf", + architecture: "riscv", + availableArchitectures: ["riscv"], + riscv: { + elfClass: bits, + byteOrder: little ? "little" : "big", + type: 2, + flags: 0x5, + }, + }); + }, + ); + + it("reads ELF64 flags at their own offset", () => { + const bytes = header(true, 64); + bytes.writeUInt32LE(0xdeadbeef, 36); + bytes.writeUInt32LE(0x00000014, 48); + const parsed = parseExecutableHeader(bytes, "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toMatchObject({ + architecture: "riscv", + riscv: { elfClass: 64, flags: 0x14 }, + }); + }); + + it("does not attach ELF metadata to a family that declares none", () => { + const parsed = parseExecutableHeader(header(true, 64, 0x5, 2, 62), "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toEqual({ + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }); + }); + + it("rejects a truncated header before reading flags", () => { + const parsed = parseExecutableHeader(header(true).subarray(0, 39), "x64"); + expect(parsed).toEqual({ + ok: false, + error: + "truncated ELF header: an ELF32 header needs 52 bytes; the file has 39", + }); + }); + + it("does not turn invalid byte order into little-endian RISC-V", () => { + const bytes = header(true); + bytes[5] = 0; + expect(parseExecutableHeader(bytes, "x64")).toEqual({ + ok: false, + error: "unsupported ELF endianness", + }); + }); + + it("keeps an unknown e_machine outside the recognized family set", () => { + expect(parseExecutableHeader(header(true, 32, 0x5, 2, 244), "x64")).toEqual( + { + ok: false, + error: "unsupported ELF architecture", + }, + ); + }); +}); diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 79a9daefab..29818b674e 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -1,6 +1,9 @@ import type { BinaryArchitecture, + ElfFamilyMetadata, + HeaderArchitecture, MipsElfMetadata, + RiscvElfMetadata, UniversalMachOSlice, } from "./binaryTargetTypes.js"; import { err, ok, type Result } from "./result.js"; @@ -15,31 +18,28 @@ export type ExecutableMetadata = readonly availableArchitectures: readonly "mips"[]; readonly mips: MipsElfMetadata; } + | { + readonly format: "elf"; + readonly architecture: "riscv"; + readonly availableArchitectures: readonly "riscv"[]; + readonly riscv: RiscvElfMetadata; + } | { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly universalSlice?: never; } | { readonly format: "mach-o-universal"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly universalSlice: UniversalMachOSlice; } | { readonly format: "pe"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; readonly executableRole: | "application" | "shared-library" @@ -115,9 +115,9 @@ const parseFatMachO = ( const entrySize = is64 ? 32 : 20; if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize) return err("truncated or invalid FAT architecture table"); - const architectures: Exclude[] = []; + const architectures: HeaderArchitecture[] = []; const slices = new Map< - Exclude, + HeaderArchitecture, { readonly offset: bigint; readonly size: bigint } >(); for (let index = 0; index < count; index += 1) { @@ -191,20 +191,31 @@ const parseElf = ( const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18); const architecture = elfArchitecture(machine); if (architecture === undefined) return err("unsupported ELF architecture"); - if (architecture === "mips") - return ok({ - format: "elf", - architecture, - availableArchitectures: [architecture], - mips: { - elfClass: bytes[4] === 1 ? 32 : 64, - byteOrder: little ? "little" : "big", - type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16), - flags: little - ? bytes.readUInt32LE(bytes[4] === 1 ? 36 : 48) - : bytes.readUInt32BE(bytes[4] === 1 ? 36 : 48), - }, - }); + if (architecture === "mips" || architecture === "riscv") { + const elfClass = bytes[4] === 1 ? 32 : 64; + const flagsOffset = elfClass === 32 ? 36 : 48; + const metadata: ElfFamilyMetadata = { + elfClass, + byteOrder: little ? "little" : "big", + type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16), + flags: little + ? bytes.readUInt32LE(flagsOffset) + : bytes.readUInt32BE(flagsOffset), + }; + return architecture === "mips" + ? ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + mips: metadata, + }) + : ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + riscv: metadata, + }); + } return ok({ format: "elf", architecture, @@ -299,9 +310,7 @@ const peManagedStatus = ( ); }; -const machArchitecture = ( - cpu: number, -): Exclude | undefined => { +const machArchitecture = (cpu: number): HeaderArchitecture | undefined => { switch (cpu) { case 7: return "x86"; @@ -327,6 +336,8 @@ const elfArchitecture = (machine: number): BinaryArchitecture | undefined => { return "arm"; case 183: return "arm64"; + case 243: // EM_RISCV: XLEN, byte order and e_flags remain separate ELF facts. + return "riscv"; } return undefined; }; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index e73d86cec6..b4d44c1101 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -5,15 +5,44 @@ export const BINARY_ARCHITECTURES = [ "arm", "arm64", "mips", + "riscv", ] as const; export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number]; -/** Raw ELF facts; family recognition does not establish ISA or ABI support. */ -export interface MipsElfMetadata { +/** + * ELF header facts for a family whose ISA and ABI are not established by the + * family name alone. Consumers admit a lane from these declarations rather than + * re-reading the header. + */ +export interface ElfFamilyMetadata { readonly elfClass: 32 | 64; readonly byteOrder: "little" | "big"; readonly type: number; readonly flags: number; +} + +/** Families whose parsed target variant carries `ElfFamilyMetadata`. */ +export const ELF_METADATA_ARCHITECTURES = [ + "mips", + "riscv", +] as const satisfies readonly BinaryArchitecture[]; +export type ElfMetadataArchitecture = + (typeof ELF_METADATA_ARCHITECTURES)[number]; + +/** Families whose ISA and ABI need a separate ELF declaration to be admitted. */ +export const isElfMetadataArchitecture = ( + architecture: BinaryArchitecture, +): architecture is ElfMetadataArchitecture => + (ELF_METADATA_ARCHITECTURES as readonly string[]).includes(architecture); + +/** Recognized family with no ELF metadata member; the header alone is the fact. */ +export type HeaderArchitecture = Exclude< + BinaryArchitecture, + ElfMetadataArchitecture +>; + +/** Raw ELF facts; family recognition does not establish ISA or ABI support. */ +export interface MipsElfMetadata extends ElfFamilyMetadata { /** Undefined until inspected; null means no ABI record was observed. */ readonly abiFlags?: MipsAbiFlags | null; /** Unavailable declaration sources; missing records there remain unknown. */ @@ -44,6 +73,12 @@ export interface MipsAbiFlags { readonly flags2: number; } +/** + * ELF class and flags retain the width and ABI declarations used by this lane. + * ISA extensions in `.riscv.attributes` are not inspected by this header parser. + */ +export type RiscvElfMetadata = ElfFamilyMetadata; + /** Byte range of the selected slice inside its universal Mach-O container. */ export interface UniversalMachOSlice { readonly offset: number; @@ -67,11 +102,8 @@ type NonExecutableMetadata = { type ExecutableTarget = BinaryTargetIdentity & { readonly kind: "executable"; - readonly architecture: Exclude; - readonly availableArchitectures: readonly Exclude< - BinaryArchitecture, - "mips" - >[]; + readonly architecture: HeaderArchitecture; + readonly availableArchitectures: readonly HeaderArchitecture[]; }; /** @@ -88,6 +120,15 @@ export type BinaryTarget = readonly executableRole?: never; readonly managed?: never; }) + | (BinaryTargetIdentity & { + readonly kind: "executable"; + readonly format: "elf"; + readonly architecture: "riscv"; + readonly availableArchitectures: readonly "riscv"[]; + readonly riscv: RiscvElfMetadata; + readonly executableRole?: never; + readonly managed?: never; + }) | (ExecutableTarget & { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; readonly universalSlice?: never; diff --git a/src/domain/peMachine.ts b/src/domain/peMachine.ts index 16a5ace371..18981c88c6 100644 --- a/src/domain/peMachine.ts +++ b/src/domain/peMachine.ts @@ -1,6 +1,6 @@ -import type { BinaryArchitecture } from "./binaryTargetTypes.js"; +import type { HeaderArchitecture } from "./binaryTargetTypes.js"; -export type PeArchitecture = Exclude; +export type PeArchitecture = HeaderArchitecture; /** A decoded COFF machine and whether it carried a ReadyToRun OS override. */ export interface PeMachine { diff --git a/src/domain/rawBinary.ts b/src/domain/rawBinary.ts index d00af8d498..94fdf6ce71 100644 --- a/src/domain/rawBinary.ts +++ b/src/domain/rawBinary.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import type { BinaryArchitecture } from "./binaryTargetTypes.js"; +import type { + BinaryArchitecture, + HeaderArchitecture, +} from "./binaryTargetTypes.js"; import type { ExecutableFormatHint } from "./dosCom.js"; import { err, ok, type Result } from "./result.js"; @@ -55,7 +58,7 @@ export const validateRawBinaryLength = ( /** Admitted headerless load identity used by target resolution and profiles. */ export interface RawBinaryAdmission { - readonly architecture: Exclude; + readonly architecture: HeaderArchitecture; readonly entryPoint: string; readonly entryPointSource: string; } diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index a736cd0bf4..60edb0971b 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -17,6 +17,10 @@ import { ghidraMipsProfileParameters, ghidraMipsSharedLanguageOverride, } from "./GhidraMipsProfile.js"; +import { + ghidraRiscvLanguageOverride, + ghidraRiscvProfileParameters, +} from "./GhidraRiscvProfile.js"; import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js"; import { @@ -32,6 +36,17 @@ import { NATIVE_AOT_PROFILE_CONTRACT_REVISION, } from "../domain/native/nativeAotPe.js"; +/** + * The processor language REA pins for this target, or undefined when Ghidra may + * select from the header. The committed profile and the session readback both + * compare against this single owner rather than re-deriving a lane. + */ +export const ghidraPinnedLanguage = ( + target: BinaryTarget, +): GhidraLanguageOverride | undefined => + ghidraMipsSharedLanguageOverride(target) ?? + ghidraRiscvLanguageOverride(target); + /** Resolve version-bound, deterministic semantics before Ghidra imports a target. */ export const resolveGhidraAnalysisProfile = async ( target: BinaryTarget, @@ -136,22 +151,26 @@ export const resolveGhidraAnalysisProfile = async ( const dosMz = target.format === "dos-mz"; const dosCom = target.format === "dos-com"; const dos = dosMz || dosCom; - const mipsLanguage = ghidraMipsSharedLanguageOverride(target); + const pinnedLanguage = ghidraPinnedLanguage(target); if ( - mipsLanguage !== undefined && + pinnedLanguage !== undefined && languageOverride !== undefined && - (languageOverride.languageId !== mipsLanguage.languageId || + (languageOverride.languageId !== pinnedLanguage.languageId || (languageOverride.compilerSpecId !== undefined && - languageOverride.compilerSpecId !== mipsLanguage.compilerSpecId)) + languageOverride.compilerSpecId !== pinnedLanguage.compilerSpecId)) ) return err( new AnalysisUnsupportedTargetError( "resolve_analysis_profile", target.path, - "The caller-selected processor or compiler specification is outside the verified endian-matched MIPS32 shared-image profile. The requested override has not been changed.", + `The caller-selected processor or compiler specification is outside the verified ${ + target.architecture === "riscv" + ? "generic little-endian RISC-V" + : "endian-matched MIPS32 shared-image" + } profile. The requested override has not been changed.`, ), ); - const selectedLanguage = languageOverride ?? mipsLanguage; + const selectedLanguage = languageOverride ?? pinnedLanguage; // DOS imports already force an admitted loader, language, and context. if (dos && languageOverride !== undefined) return Promise.resolve( @@ -227,7 +246,7 @@ export const resolveGhidraAnalysisProfile = async ( ? "auto-default" : (selectedLanguage.compilerSpecId ?? "language-default"), ...(languageOverride === undefined - ? mipsLanguage === undefined + ? pinnedLanguage === undefined ? {} : { language_selection: "provider-pinned-v1" } : { language_selection: "configured-v1" }), @@ -251,6 +270,7 @@ export const resolveGhidraAnalysisProfile = async ( : {}), ...analyzerParameters, ...ghidraMipsProfileParameters(target), + ...ghidraRiscvProfileParameters(target), ...(pspExtension?.ok === true ? { mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2", diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index aa50440797..c48f589576 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -66,6 +66,7 @@ const SUPPORTED_ARCHITECTURES = new Set([ "arm", "arm64", "mips", + "riscv", ]); /** Ghidra candidate backed by an isolated ephemeral headless import. */ diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index fc0297789b..39c89c46ae 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -14,6 +14,7 @@ import { ghidraSeedLimitations, } from "./GhidraAnalysisSeeds.js"; import { + ghidraPinnedLanguage, ghidraProfileDisabledAnalyzers, ghidraProfileLanguageOverride, sameGhidraDisabledAnalyzers, @@ -61,10 +62,10 @@ import { ghidraMipsSoftFloatFailure, ghidraMipsRelocatableLimitations, ghidraMipsSharedLimitations, - ghidraMipsSharedLanguageOverride, ghidraMipsSoftFloatLimitations, isGhidraMipsSoftFloatTarget, } from "./GhidraMipsProfile.js"; +import { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js"; import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; @@ -453,7 +454,7 @@ export const createGhidraProviderClient = (input: { if ( !sameGhidraLanguageOverride( languageOverride, - config.ghidraLanguageOverride ?? ghidraMipsSharedLanguageOverride(target), + config.ghidraLanguageOverride ?? ghidraPinnedLanguage(target), ) ) return unavailableClient( @@ -523,6 +524,7 @@ export const createGhidraProviderClient = (input: { : []), ...ghidraMipsRelocatableLimitations(target), ...ghidraMipsSharedLimitations(target), + ...ghidraRiscvLimitations(target), ]; const providerLimitations = installation.platform === "win32" diff --git a/src/ghidra/GhidraPspProfile.ts b/src/ghidra/GhidraPspProfile.ts index b1ee12fe55..17260d038e 100644 --- a/src/ghidra/GhidraPspProfile.ts +++ b/src/ghidra/GhidraPspProfile.ts @@ -1,5 +1,6 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js"; +import { ghidraRiscvUnsupportedReason } from "./GhidraRiscvProfile.js"; /** Allegrex's ELF machine declaration, not a filename or inferred instruction. */ export const isGhidraPspTarget = (target: BinaryTarget): boolean => @@ -13,6 +14,8 @@ export const isGhidraPspTarget = (target: BinaryTarget): boolean => export const ghidraProcessorUnsupportedReason = ( target: BinaryTarget, ): string | null => { + if (target.kind === "executable" && target.architecture === "riscv") + return ghidraRiscvUnsupportedReason(target); if ( target.kind !== "executable" || target.architecture !== "mips" || diff --git a/src/ghidra/GhidraRawBinaryLanguage.ts b/src/ghidra/GhidraRawBinaryLanguage.ts index 9a4f254b28..ea024bdbaa 100644 --- a/src/ghidra/GhidraRawBinaryLanguage.ts +++ b/src/ghidra/GhidraRawBinaryLanguage.ts @@ -1,4 +1,4 @@ -import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; +import type { HeaderArchitecture } from "../domain/binaryTargetTypes.js"; /** Bound Ghidra language for a caller-supplied raw-binary architecture. */ export interface GhidraRawBinaryLanguage { @@ -13,7 +13,7 @@ export interface GhidraRawBinaryLanguage { * Big-endian, Thumb-only, and Cortex-M variants are out of scope. */ export const ghidraRawBinaryLanguage = ( - architecture: Exclude, + architecture: HeaderArchitecture, ): GhidraRawBinaryLanguage => { switch (architecture) { case "x86": diff --git a/src/ghidra/GhidraRiscvProfile.test.ts b/src/ghidra/GhidraRiscvProfile.test.ts new file mode 100644 index 0000000000..9bbb65cf32 --- /dev/null +++ b/src/ghidra/GhidraRiscvProfile.test.ts @@ -0,0 +1,256 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { parseConfig } from "../config/parseConfig.js"; +import type { + BinaryTarget, + RiscvElfMetadata, +} from "../domain/binaryTargetTypes.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 { ghidraRiscvLimitations } from "./GhidraRiscvProfile.js"; +import type { GhidraInstallationHost } from "./GhidraInstallation.js"; +import { + ghidraPinnedLanguage, + ghidraProfileLanguageOverride, + sameGhidraLanguageOverride, +} from "./GhidraAnalysisProfile.js"; + +// Abbreviated from Ghidra 12.1.4 Processors/RISCV/data/languages/riscv.ldefs. +// The release tree ships no big-endian or ELF-class-mismatched RISC-V language. +const RISCV_LDEFS = ` + + + RISC-V 64 little default + + + + RISC-V 32 little default + + + +`; + +// Fake installation inspection tests admission/profile contracts, not Ghidra. +const host: GhidraInstallationHost = { + platform: "linux", + architecture: "x64", + readText: () => "application.version=12.1.4\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.11", + major: 21, + home: "/jdk-21", + bits: 64, + runtime: "jdk", + }), +}; +const configOf = ( + installDir: string, + environment: Record = {}, +) => { + const parsed = parseConfig({ + GHIDRA_INSTALL_DIR: installDir, + ...environment, + }); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; +const target = (change: Partial = {}): BinaryTarget => ({ + path: "/fixture.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "riscv", + availableArchitectures: ["riscv"], + riscv: { + elfClass: 64, + byteOrder: "little", + type: 2, + flags: 0x5, + ...change, + }, +}); + +let installDir = ""; +beforeAll(async () => { + installDir = await mkdtemp(join(tmpdir(), "rea-ghidra-riscv-languages-")); + const languages = join(installDir, "Ghidra/Processors/RISCV/data/languages"); + await mkdir(languages, { recursive: true }); + await writeFile(join(languages, "riscv.ldefs"), RISCV_LDEFS); +}); +afterAll(async () => { + await rm(installDir, { recursive: true, force: true }); +}); + +const ghidra = (environment: Record = {}) => + new GhidraProvider(configOf(installDir, environment), silentLogger, {}, host); + +describe("bounded Ghidra RISC-V admission", () => { + it("pins the generic language per width instead of accepting the header guess", async () => { + const provider = ghidra(); + const rv64 = target(); + const rv32 = target({ elfClass: 32, flags: 0x4 }); + expect(provider.inspectTargetSupport(rv64).status).toBe("supported"); + expect(provider.inspectTargetSupport(rv32).status).toBe("supported"); + expect(ghidraPinnedLanguage(rv64)).toEqual({ + languageId: "RISCV:LE:64:default", + compilerSpecId: "gcc", + }); + expect(ghidraPinnedLanguage(rv32)).toEqual({ + languageId: "RISCV:LE:32:default", + compilerSpecId: "gcc", + }); + const wide = await provider.resolveAnalysisProfile(rv64); + const narrow = await provider.resolveAnalysisProfile(rv32); + if (!wide.ok || !narrow.ok) + throw new Error("expected admitted RISC-V profiles"); + expect(wide.value.profile?.parameters.riscv_elf).toEqual({ + elf_class: 64, + byte_order: "little", + type: 2, + flags: 5, + float_abi_double: true, + rvc_declared: true, + rve_declared: false, + tso_declared: false, + }); + expect(wide.value.profile?.parameters.riscv_support_lane).toBe( + "elf64-exec-le-double-float-generic-v1", + ); + expect(narrow.value.profile?.parameters.riscv_support_lane).toBe( + "elf32-exec-le-double-float-generic-v1", + ); + expect(narrow.value.profile?.parameters.riscv_elf).toMatchObject({ + elf_class: 32, + rvc_declared: false, + }); + expect(wide.value.profile?.digest).not.toBe(narrow.value.profile?.digest); + }); + + it("commits the pinned language the session readback expects", async () => { + // A pinned lane has one owner: the committed profile and the open_binary + // readback must agree without either re-deriving a different family's pin. + const provider = ghidra(); + for (const value of [target(), target({ elfClass: 32 })]) { + const resolved = await provider.resolveAnalysisProfile(value); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a RISC-V profile"); + expect( + sameGhidraLanguageOverride( + ghidraProfileLanguageOverride(resolved.value.profile.parameters), + ghidraPinnedLanguage(value), + ), + ).toBe(true); + } + }); + + it.each([ + [{ byteOrder: "big" }, "little-endian"], + [{ type: 3 }, "fixed-address ET_EXEC only"], + [{ type: 1 }, "fixed-address ET_EXEC only"], + [{ 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: 0x8000_0005 }, "(0x80000000)"], + [{ flags: 0x24 }, "bits outside the verified lane"], + ] satisfies [Partial, string][])( + "refuses an unverified RISC-V interpretation %j", + async (change, reason) => { + const provider = ghidra(); + const value = target(change); + expect(provider.inspectTargetSupport(value)).toMatchObject({ + status: "unsupported", + code: "architecture_unsupported", + reason: expect.stringContaining(reason), + }); + expect(await provider.resolveAnalysisProfile(value)).toEqual( + expect.objectContaining({ ok: false }), + ); + expect(ghidraPinnedLanguage(value)).toBeUndefined(); + }, + ); + + it("refuses a caller-selected processor outside the verified generic lane", async () => { + const resolved = await ghidra({ + REA_GHIDRA_LANGUAGE_ID: "RISCV:LE:32:AndeStar_v5", + }).resolveAnalysisProfile(target({ elfClass: 32 })); + expect(resolved.ok).toBe(false); + if (resolved.ok) throw new Error("expected a refusal"); + expect(resolved.error.message).toContain("generic little-endian RISC-V"); + }); + + it("keeps a caller-selected language that matches the pin as configured", async () => { + const resolved = await ghidra({ + REA_GHIDRA_LANGUAGE_ID: "RISCV:LE:64:default", + REA_GHIDRA_COMPILER_SPEC_ID: "gcc", + }).resolveAnalysisProfile(target()); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a configured profile"); + expect(resolved.value.profile.parameters.language_selection).toBe( + "configured-v1", + ); + expect(resolved.value.profile.parameters.language_id).toBe( + "RISCV:LE:64:default", + ); + }); + + 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"); + expect( + ghidraRiscvLimitations({ + path: "/other.elf", + sha256: "b".repeat(64), + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }), + ).toEqual([]); + }); + + it("keeps RISC-V out of unverified Hopper and IDA adapter lanes", () => { + const hopper = new HopperProvider( + configOf("/hopper"), + silentLogger, + {}, + "linux", + ); + expect(hopper.inspectTargetSupport(target()).status).toBe("unsupported"); + expect(hopperLoaderArgsForTarget(target()).ok).toBe(false); + expect(hopperLoaderArgsForTarget(target())).toEqual( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + message: expect.stringContaining("RISC-V"), + }), + }), + ); + const ida = new IdaProvider(configOf("/ida"), {}); + expect(ida.inspectTargetSupport(target()).status).toBe("unsupported"); + }); +}); diff --git a/src/ghidra/GhidraRiscvProfile.ts b/src/ghidra/GhidraRiscvProfile.ts new file mode 100644 index 0000000000..3ccd5fc4cb --- /dev/null +++ b/src/ghidra/GhidraRiscvProfile.ts @@ -0,0 +1,129 @@ +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; + +/** + * 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. + */ +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; + +const ET_REL = 1; +const ET_EXEC = 2; +const ET_DYN = 3; + +/** + * Bits this lane reads. Anything else in `e_flags` — reserved today or defined by + * a newer psABI — is refused rather than assumed compatible. + */ +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"; + +/** + * Keep family recognition separate from the deliberately bounded Ghidra lane. + * + * Ghidra 12.1.4 selects `RISCV:LE:32:AndeStar_v5` for an ordinary RV32 ELF: its + * `riscv.opinion` pins `variant="default"` for the 64-bit constraint and leaves + * the 32-bit one with the comment that the loader "must choose between 'default' + * and 'AndeStar' variants". The lane therefore pins the language instead of + * accepting the header-based guess. + */ +export const ghidraRiscvUnsupportedReason = ( + target: BinaryTarget, +): string | null => { + if (target.kind !== "executable" || target.architecture !== "riscv") + return null; + const metadata = target.riscv; + if (target.format !== "elf") + return "RISC-V analysis requires ELF header metadata; a family name alone does not select an ISA or ABI."; + if (metadata.byteOrder !== "little") + return "This Ghidra RISC-V lane supports little-endian images only; Ghidra 12.1.4 ships no big-endian RISC-V language."; + if (metadata.elfClass !== 32 && metadata.elfClass !== 64) + return "This Ghidra RISC-V lane requires ELF32 or ELF64; the class decides which generic language is pinned."; + if ((metadata.flags & EF_RISCV_RVE) !== 0) + return "EF_RISCV_RVE declares the embedded 15-register ABI, which needs its own processor specification and compiler verification."; + if ((metadata.flags & EF_RISCV_TSO) !== 0) + return "EF_RISCV_TSO declares the total-store-order memory model; this lane verifies the baseline model only."; + if (metadata.type !== ET_EXEC) + return `This Ghidra RISC-V lane supports fixed-address ET_EXEC only; ${ + metadata.type === ET_REL + ? "relocatable" + : metadata.type === ET_DYN + ? "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 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; +}; + +/** Pin a language only for declarations admitted by the same lane boundary. */ +export const ghidraRiscvLanguageOverride = ( + target: BinaryTarget, +): GhidraLanguageOverride | undefined => + target.kind === "executable" && + target.architecture === "riscv" && + ghidraRiscvUnsupportedReason(target) === null + ? { + languageId: `RISCV:LE:${target.riscv.elfClass}:default`, + compilerSpecId: "gcc", + } + : undefined; + +/** Commit source ELF interpretation without confusing target ISA with host CPU. */ +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", + } + : {}; + +/** 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.", + ] + : []; diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 927f7289b2..299862a97e 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -8,8 +8,10 @@ import type { import { createAnalysisProfile } from "../domain/analysisProfile.js"; import { isMachOFormat, + isElfMetadataArchitecture, type BinaryTarget, - type BinaryArchitecture, + type ElfMetadataArchitecture, + type HeaderArchitecture, } from "../domain/binaryTargetTypes.js"; import { AnalysisCancelledError, @@ -101,6 +103,15 @@ export const resolveHopperAnalysisProfile = async ( }); }; +/** Ghidra owns these lanes; Hopper names the family it declines rather than guessing. */ +const HOPPER_DECLINED_ARCHITECTURE_LABELS: Record< + ElfMetadataArchitecture, + string +> = { + mips: "MIPS", + riscv: "RISC-V", +}; + /** Derive Hopper's complete non-interactive CLI loader selection. */ export const hopperLoaderArgsForTarget = ( target: BinaryTarget, @@ -113,12 +124,12 @@ export const hopperLoaderArgsForTarget = ( if (target.kind !== "executable") return err(new ProviderAdapterError("hopper", "resolve_analysis_profile")); const architecture = target.architecture; - if (architecture === "mips") + if (isElfMetadataArchitecture(architecture)) return err( new AnalysisCapabilityUnavailableError( "hopper", "resolve_analysis_profile", - "REA's Hopper adapter does not admit MIPS targets. Select a verified Ghidra MIPS profile instead.", + `REA's Hopper adapter does not admit ${HOPPER_DECLINED_ARCHITECTURE_LABELS[architecture]} targets. Select a verified Ghidra ${HOPPER_DECLINED_ARCHITECTURE_LABELS[architecture]} profile instead.`, ), ); const flag = hopperArchitectureFlag(architecture); @@ -151,9 +162,7 @@ export const hopperLoaderArgsForTarget = ( } }; -const hopperArchitectureFlag = ( - architecture: Exclude, -): string => { +const hopperArchitectureFlag = (architecture: HeaderArchitecture): string => { switch (architecture) { case "x86": return "--intel-32"; diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index 4995333161..20a53105c3 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { isMachOFormat, + isElfMetadataArchitecture, type BinaryTarget, } from "../domain/binaryTargetTypes.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; @@ -47,7 +48,10 @@ export const resolveHopperMachOImage = async ( ), ); if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); - if (!isMachOFormat(target.format) || target.architecture === "mips") + if ( + !isMachOFormat(target.format) || + isElfMetadataArchitecture(target.architecture) + ) return invalid("Hopper FAT64 image preparation requires a Mach-O target"); let file: FileHandle; try { diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index c52eefe280..aec8c20829 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -16,7 +16,10 @@ import type { } from "../application/AnalysisProvider.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { AppConfig } from "../config/types.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isElfMetadataArchitecture, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; @@ -118,11 +121,13 @@ export class HopperProvider implements AnalysisProviderCandidate { reason: null, diagnostics, }; - if (target.architecture === "mips") + if (isElfMetadataArchitecture(target.architecture)) return { status: "unsupported", code: "architecture_unsupported", - reason: "REA's Hopper adapter does not admit MIPS targets.", + reason: `REA's Hopper adapter does not admit ${ + target.architecture === "mips" ? "MIPS" : "RISC-V" + } targets.`, diagnostics, }; if (target.format === "raw-binary") diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index 7fefce825a..c86ef5eb44 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -1,7 +1,10 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isElfMetadataArchitecture, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { err, ok } from "../domain/result.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { @@ -101,12 +104,16 @@ export class IdaProvider implements AnalysisProviderCandidate { target_kind: target.kind, target_format: target.format, }; - if (target.kind === "executable" && target.architecture === "mips") + if ( + target.kind === "executable" && + isElfMetadataArchitecture(target.architecture) + ) return { status: "unsupported", code: "architecture_unsupported", - reason: - "REA's IDA adapter has no verified MIPS lane. Select a supported Ghidra profile instead.", + reason: `REA's IDA adapter has no verified ${ + target.architecture === "mips" ? "MIPS" : "RISC-V" + } lane. Select a supported Ghidra profile instead.`, diagnostics, }; if (target.kind !== "executable") diff --git a/tests/conformance/c/riscv.c b/tests/conformance/c/riscv.c new file mode 100644 index 0000000000..c100c4f186 --- /dev/null +++ b/tests/conformance/c/riscv.c @@ -0,0 +1,18 @@ +/* Source-owned freestanding analysis fixture. Never execute this target. */ +volatile unsigned int rea_riscv_global = 7; +extern unsigned int rea_riscv_probe(unsigned int value); + +__attribute__((noinline)) unsigned int rea_riscv_leaf(unsigned int value) +{ + return value * 3u + rea_riscv_global; +} + +__attribute__((noinline)) double rea_riscv_float(double left, double right) +{ + return left + right; +} + +__attribute__((noinline)) unsigned int rea_riscv_entry(unsigned int selector) +{ + return rea_riscv_probe(rea_riscv_leaf(selector + 1u)); +} diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index ae9cc7b4c5..d2161e1ff3 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -3,6 +3,14 @@ These source-owned fixtures are compiled at verification time; generated 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. + - `inventory.c` produces separate x86-64 debug and stripped ELF executables. It covers imports, an external `puts` function, linker thunks, source symbols, direct calls, a targetless callback call, two referenced strings, and a diff --git a/tests/conformance/ghidra/riscv-fixture.mjs b/tests/conformance/ghidra/riscv-fixture.mjs new file mode 100644 index 0000000000..27b354094e --- /dev/null +++ b/tests/conformance/ghidra/riscv-fixture.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); + +/** Build and independently inspect a source-owned ELF; never execute it. */ +export async function buildRiscvFixture(directory, bits) { + assert.ok(bits === 32 || bits === 64); + 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 args = [ + `--target=riscv${bits}-unknown-elf`, + `-march=rv${bits}imafdc`, + `-mabi=${bits === 32 ? "ilp32d" : "lp64d"}`, + `--ld-path=${linker}`, + "-nostdlib", + "-static", + "-fno-pic", + "-O0", + "-g", + "-Wl,-e,rea_riscv_entry", + "-Wl,--build-id=none", + source, + probe, + "-o", + path, + ]; + const options = { timeout: 10000, maxBuffer: 1024 * 1024 }; + const compilerVersion = await execute(compiler, ["--version"], options); + 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 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, + ); + 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 }, + symbols, + toolchain: { + compiler: { command: compiler, version: compilerVersion.stdout.trim() }, + linker: { command: linker, version: linkerVersion.stdout.trim() }, + reader: { command: reader, version: readerVersion.stdout.trim() }, + arguments: args, + }, + sources: await Promise.all( + [source, probe].map(async (sourcePath) => ({ + path: sourcePath, + sha256: createHash("sha256") + .update(await readFile(sourcePath)) + .digest("hex"), + })), + ), + }; +} diff --git a/tests/conformance/ghidra/riscv-probe.S b/tests/conformance/ghidra/riscv-probe.S new file mode 100644 index 0000000000..701ca9582d --- /dev/null +++ b/tests/conformance/ghidra/riscv-probe.S @@ -0,0 +1,9 @@ +/* Fixed instruction oracle, independent of the compiler's C code generation. */ +.section .text +.option norvc +.global rea_riscv_probe +.type rea_riscv_probe, @function +rea_riscv_probe: + addi a0, a0, 17 + ret +.size rea_riscv_probe, .-rea_riscv_probe