diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index b4cf2e4ad..dd562d813 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -63,6 +63,7 @@ export default defineConfig({ { text: "Ghidra DOS analysis", link: "/ghidra-dos" }, { text: "Ghidra raw binary", link: "/raw-binary" }, { text: "Ghidra NativeAOT", link: "/ghidra-nativeaot" }, + { text: "Ghidra PowerPC", link: "/ghidra-ppc" }, { text: "Ghidra semantics", link: "/ghidra-provider" }, ], }, diff --git a/docs/ghidra-ppc.md b/docs/ghidra-ppc.md new file mode 100644 index 000000000..034158378 --- /dev/null +++ b/docs/ghidra-ppc.md @@ -0,0 +1,90 @@ +# PowerPC ELF analysis through Ghidra + +This lane extends native ELF analysis to 32-bit big-endian PowerPC; it does +not execute PowerPC code. It is the first milestone of +[#1741](https://github.com/morluto/rea/issues/1741). GameCube/Wii DOL +executables, PPC64 and other PowerPC variants are separate follow-ups. + +## Admission boundary + +ELF `EM_PPC` (20) is retained as the provider-neutral `powerpc` family. The +target also retains class, byte order, `e_type` and raw `e_flags`. Recognition +does not mean every provider or PowerPC variant is supported. `EM_PPC64` (21) +is not recognized as this family. + +The Ghidra lane admits ELF32 `ET_EXEC`, big endian. ELF64, little-endian, +relocatable and shared objects are refused with a specific unsupported +constraint. `EF_PPC_EMB` declares an embedded variant and is refused; the +remaining `e_flags` bits only declare relocation conventions and do not change +instruction decoding. + +## Bound language + +Ghidra's ELF loader selects `PowerPC:BE:32:e500:default` for a plain `EM_PPC` +header with `e_flags` 0. e500 is the embedded Book-E variant whose SPE unit +replaces the classic floating-point unit, so it misinterprets ordinary +750-family code. Nothing in the header distinguishes the two. + +The lane therefore imports with `PowerPC:BE:32:default` and compiler spec +`default`, commits that choice in the analysis profile as +`language_selection: "provider-pinned-v1"`, and checks the language Ghidra +reports after import. A different language set through +`REA_GHIDRA_LANGUAGE_ID` (and optionally `REA_GHIDRA_COMPILER_SPEC_ID`) +replaces the lane default and is admitted against the installed language +catalog as usual; for example `PowerPC:BE:32:e500` for genuine e500 code. + +PowerPC header facts are committed in the analysis profile so interpretation +changes invalidate profile-bound snapshots. The support-lane identity is +versioned independently of file identity. Evidence, lifecycle outputs and saved +snapshots preserve the `powerpc` family. Hopper and IDA adapters do not +implicitly gain PowerPC support. Windows Ghidra P0 remains PE x86/x86-64 only. + +## Use + +With a caller-installed Ghidra and compatible JDK on a supported Linux/macOS host: + +```bash +rea analyze /absolute/path/to/ppc.elf --provider ghidra --json +rea function /absolute/path/to/ppc.elf function_name --provider ghidra --json +``` + +MCP uses the existing tools, including `open_binary` with an absolute path and +`provider_id: "ghidra"`. No PowerPC-specific tool catalog or emulator is +introduced. + +## Verification + +The source-owned freestanding fixture is `tests/conformance/c/ppc.c`. The +optional cross-target lane requires a caller-supplied Clang with the PowerPC +target, LLD, GNU `readelf`, Ghidra and its compatible JDK; ordinary host-native +checks do not acquire these tools. `REA_PPC_CLANG` selects Clang and +`REA_PPC_READELF` selects GNU readelf (for example +`/opt/homebrew/opt/binutils/bin/readelf` on macOS). These are test-time +prerequisites, not REA runtime providers. + +```bash +GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra npm run verify:ghidra:ppc +``` + +The lane compiles the fixture for a 750-family core (`-mcpu=750`) and never +executes it. GNU readelf independently supplies header facts and symbol +addresses, compared with the production REA target resolver before provider +startup. Its version, bounded command output and selected tool path are +retained in the verification report. The parser deliberately recognizes only +this fixture's GNU output, rejecting missing, ambiguous or unexpected fields +rather than substituting defaults. + +Existing public MCP operations check Ghidra's **observed** language and +compiler specification and loaded-file hashes; an e500 import fails the lane. +The source-owned `ppc-probe.S` supplies fixed `addi` and conditional-branch +encodings: verification checks bytes, length, immediate and direct destination, +independent of decompiler variable names. Global and string bytes are checked at +independently reported symbol addresses. + +CLI/MCP discovery and decompilation, direct calls, target/profile/Evidence +identity, snapshot round-trip and final process cleanup remain covered. +Pseudocode presence is a liveness check, not proof of semantic equivalence. +Instruction decoding and reader agreement do not establish runtime or complete +floating-point semantics. Parser/profile tests are narrower checks, not +substitutes for this real-provider run. Report the actual lanes run and leave +unexecuted verification pending. diff --git a/package.json b/package.json index e09e51366..5ccf53c99 100644 --- a/package.json +++ b/package.json @@ -186,6 +186,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:ppc": "npm run build:cached && node scripts/verify-real-ghidra-ppc.mjs", "verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs", "verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs", "verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs", diff --git a/scripts/verify-real-ghidra-ppc.mjs b/scripts/verify-real-ghidra-ppc.mjs new file mode 100644 index 000000000..591d32b2b --- /dev/null +++ b/scripts/verify-real-ghidra-ppc.mjs @@ -0,0 +1,282 @@ +#!/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, rm } 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 { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; +import { + inspectPowerPcReadelf, + assertPowerPcLoadedImage, + assertPowerPcProbe, + assertPowerPcGlobal, +} from "../tests/conformance/ghidra/ppc-checks.mjs"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; +import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js"; +import { buildPowerPcFixture } from "../tests/conformance/ghidra/ppc-fixture.mjs"; +import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +// Optional cross-target lane. No tool acquisition and no target execution. +if (process.argv.length !== 2) + throw new Error("Usage: node scripts/verify-real-ghidra-ppc.mjs"); +if (!["linux", "darwin"].includes(process.platform)) + throw new Error( + "PowerPC verification requires a supported Linux/macOS Ghidra host", + ); +if (process.env.GHIDRA_INSTALL_DIR === undefined) + throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR"); +const installation = inspectGhidraInstallation({ + environment: process.env, + installDir: process.env.GHIDRA_INSTALL_DIR, + ...(process.env.JAVA_HOME === undefined + ? {} + : { javaHome: process.env.JAVA_HOME }), +}); +if (installation.status !== "available") + throw new Error( + `Ghidra prerequisite unavailable: ${JSON.stringify(installation)}`, + ); +const execute = promisify(execFile); +const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const run = createVerifierRun(); +const workspace = await mkdtemp(join(tmpdir(), "rea-ppc-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, + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper", +}; +for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) { + if (process.env[setting] !== undefined) env[setting] = process.env[setting]; +} +const reports = []; +let primaryFailure; +try { + { + const fixture = await buildPowerPcFixture(workspace); + const independent = await inspectPowerPcReadelf(fixture.path); + const resolved = await parseBinaryTarget(fixture.path); + if (!resolved.ok) throw resolved.error; + assert.deepEqual( + resolved.value.powerpc, + independent.powerpc, + "REA ELF interpretation disagrees with GNU readelf", + ); + assert.equal(resolved.value.sha256, fixture.sha256); + const snapshotPath = join(workspace, "ppc.snapshot.json"); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", + }); + const client = new Client({ name: "rea-real-ppc-proof", version: "1" }); + let stderr = ""; + transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); + }); + const call = async (name, args = {}) => { + const reply = await client.callTool( + { name, arguments: args }, + undefined, + { timeout: 180000 }, + ); + return requireMcpOperationResult(reply, name); + }; + try { + await client.connect(transport); + const target = await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }); + assert.equal(target.architecture, "powerpc"); + assert.equal(target.sha256, fixture.sha256); + // ELF load-image verification is not the DOS comparison lane. Its retained + // observations still expose the actual Ghidra language and loaded bytes; + // the plain loader would have chosen e500 for this header. + const image = await call("inspect_native_load_image"); + assertPowerPcLoadedImage(image.observations, fixture.sha256); + const procedures = await call("list_procedures"); + const entry = procedures.find((p) => p.value === "rea_ppc_entry"); + const leaf = procedures.find((p) => p.value === "rea_ppc_leaf"); + assert.ok(entry, "Entry function missing from real Ghidra inventory"); + assert.ok(leaf, "Leaf function missing from real Ghidra inventory"); + assert.equal( + BigInt(entry.address), + BigInt(independent.symbols.rea_ppc_entry), + ); + assert.equal( + BigInt(leaf.address), + BigInt(independent.symbols.rea_ppc_leaf), + ); + const probe = independent.symbols.rea_ppc_probe; + assert.ok( + procedures.some( + (p) => + p.value === "rea_ppc_probe" && BigInt(p.address) === BigInt(probe), + ), + ); + const move = await call("inspect_native_instruction", { address: probe }); + const branch = await call("inspect_native_instruction", { + address: `0x${(BigInt(probe) + 8n).toString(16)}`, + }); + assertPowerPcProbe(move, branch, probe); + const global = await call("read_bytes", { + address: independent.symbols.rea_ppc_global, + length: 4, + }); + assert.equal(global.returned_bytes, 4); + assertPowerPcGlobal(global.bytes_hex); + const marker = await call("read_bytes", { + address: independent.symbols.rea_ppc_marker, + length: 29, + }); + assert.equal( + marker.bytes_hex, + Buffer.from("rea-ppc-source-owned-fixture\0").toString("hex"), + ); + const callees = await call("procedure_callees", { + procedure: entry.address, + }); + assert.ok( + callees.includes(leaf.address), + "Expected direct call not recovered", + ); + const assembly = await call("procedure_assembly", { + procedure: entry.address, + }); + assert.ok(assembly.trim().length > 0); + const dossier = functionDossierSchema.parse( + await call("analyze_function", { procedure: entry.address }), + ); + assert.ok(dossier.pseudocode.trim().length > 0); + const mapping = await call("address_to_file_offset", { + address: entry.address, + }); + const memory = await call("read_bytes", { + address: entry.address, + length: 16, + }); + const sourceBytes = await readFile(fixture.path); + assert.equal(memory.returned_bytes, 16); + assert.equal( + memory.bytes_hex, + sourceBytes + .subarray(mapping.file_offset, mapping.file_offset + 16) + .toString("hex"), + ); + 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, "powerpc"); + assert.equal( + snapshot.binding.analysis_profile.parameters.language_id, + "PowerPC:BE:32:default", + ); + assert.ok(snapshot.evidence_bundle.records.length > 0); + for (const evidence of snapshot.evidence_bundle.records) + assert.equal(parseEvidence(evidence).subject?.architecture, "powerpc"); + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + assert.deepEqual(await call("list_procedures"), procedures); + await call("close_binary"); + + // Same public operation through CLI, not a private injected provider. + const cli = await execute( + process.execPath, + [ + entrypoint, + "function", + fixture.path, + "rea_ppc_entry", + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const evidence = parseEvidence(JSON.parse(cli.stdout)); + assert.equal(evidence.subject?.architecture, "powerpc"); + assert.equal(evidence.subject?.digest.sha256, fixture.sha256); + 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, + "Analysis modified the source executable", + ); + reports.push({ + ...fixture, + independent_reader: independent, + effective_language: image.observations.language_id, + effective_compiler_spec: image.observations.compiler_spec_id, + checks: [ + "independent ELF header and symbols", + "loaded Ghidra identity and bound language", + "fixed instruction bytes and immediate", + "conditional branch destination", + "global word and marker bytes", + "CLI/MCP and snapshot identity", + ], + procedures: procedures.length, + status: "passed", + }); + } catch (cause) { + throw new Error(`PowerPC verification failed; MCP stderr: ${stderr}`, { + cause, + }); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + } +} catch (cause) { + primaryFailure = cause; +} +const completed = await completeVerifierRun(run); +if ( + completed.process_lineage.status !== "verified" || + completed.process_lineage.descendants.length !== 0 +) + throw new Error( + `Cleanup unverified; retained diagnostic workspace: ${workspace}`, + { cause: primaryFailure }, + ); +if (primaryFailure !== undefined) + throw new Error( + `PowerPC verification failed; retained workspace: ${workspace}`, + { + cause: primaryFailure, + }, + ); +await rm(workspace, { recursive: true, force: true }); +console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2)); diff --git a/src/domain/binaryTarget.powerpc.test.ts b/src/domain/binaryTarget.powerpc.test.ts new file mode 100644 index 000000000..f32b4f367 --- /dev/null +++ b/src/domain/binaryTarget.powerpc.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { parseExecutableHeader } from "./binaryTarget.js"; + +const header = (little: boolean, bits: 32 | 64 = 32, machine = 20): 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(2, 16); + bytes.writeUInt16LE(machine, 18); + bytes.writeUInt32LE(1, 20); + bytes.writeUInt16LE(bytes.length, bits === 32 ? 40 : 52); + } else { + bytes.writeUInt16BE(2, 16); + bytes.writeUInt16BE(machine, 18); + bytes.writeUInt32BE(1, 20); + bytes.writeUInt16BE(bytes.length, bits === 32 ? 40 : 52); + } + return bytes; +}; + +describe("PowerPC ELF header boundaries", () => { + it.each([false, true])( + "retains source endian and flags (little=%s)", + (little) => { + const parsed = parseExecutableHeader(header(little), "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toEqual({ + format: "elf", + architecture: "powerpc", + availableArchitectures: ["powerpc"], + powerpc: { + elfClass: 32, + byteOrder: little ? "little" : "big", + type: 2, + flags: 0, + }, + }); + }, + ); + + it("reads ELF64 flags at their own offset without claiming support", () => { + const bytes = header(false, 64); + bytes.writeUInt32BE(0x80000000, 48); + const parsed = parseExecutableHeader(bytes, "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toMatchObject({ + architecture: "powerpc", + powerpc: { elfClass: 64, byteOrder: "big", flags: 0x80000000 }, + }); + }); + + it("does not recognize EM_PPC64 as the 32-bit family", () => { + expect(parseExecutableHeader(header(false, 64, 21), "x64")).toEqual({ + ok: false, + error: "unsupported ELF architecture", + }); + }); +}); diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 4e668e460..54798e1d1 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -5,6 +5,7 @@ import type { MipsElfMetadata, PeCodeViewRecord, RiscvElfMetadata, + PowerPcElfMetadata, UniversalMachOSlice, } from "./binaryTargetTypes.js"; import { err, ok, type Result } from "./result.js"; @@ -30,6 +31,12 @@ export type ExecutableMetadata = readonly availableArchitectures: readonly "riscv"[]; readonly riscv: RiscvElfMetadata; } + | { + readonly format: "elf"; + readonly architecture: "powerpc"; + readonly availableArchitectures: readonly "powerpc"[]; + readonly powerpc: PowerPcElfMetadata; + } | { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; readonly architecture: HeaderArchitecture; @@ -201,7 +208,11 @@ 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" || architecture === "riscv") { + if ( + architecture === "mips" || + architecture === "riscv" || + architecture === "powerpc" + ) { const flagsOffset = elfClass === 32 ? 36 : 48; const metadata: ElfFamilyMetadata = { elfClass, @@ -211,6 +222,13 @@ const parseElf = ( ? bytes.readUInt32LE(flagsOffset) : bytes.readUInt32BE(flagsOffset), }; + if (architecture === "powerpc") + return ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + powerpc: metadata, + }); return architecture === "mips" ? ok({ format: "elf", @@ -532,6 +550,8 @@ const elfArchitecture = ( return "x86_64"; case 8: // EM_MIPS: ISA, ABI and byte order remain separate ELF facts. return "mips"; + case 20: // EM_PPC; byte order and processor variant remain ELF declarations. + return "powerpc"; case 40: return "arm"; case 183: diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 8c16612bc..2cd6ae207 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -9,6 +9,7 @@ export const BINARY_ARCHITECTURES = [ "mips", "6502", "riscv", + "powerpc", ] as const; export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number]; @@ -36,6 +37,7 @@ export interface PeCodeViewRecord { export const ELF_METADATA_ARCHITECTURES = [ "mips", "riscv", + "powerpc", ] as const satisfies readonly BinaryArchitecture[]; export type ElfMetadataArchitecture = (typeof ELF_METADATA_ARCHITECTURES)[number]; @@ -90,6 +92,9 @@ export interface MipsAbiFlags { */ export type RiscvElfMetadata = ElfFamilyMetadata; +/** Raw PowerPC declarations; family identity does not select a processor variant. */ +export type PowerPcElfMetadata = ElfFamilyMetadata; + /** Byte range of the selected slice inside its universal Mach-O container. */ export interface UniversalMachOSlice { readonly offset: number; @@ -140,6 +145,15 @@ export type BinaryTarget = readonly executableRole?: never; readonly managed?: never; }) + | (BinaryTargetIdentity & { + readonly kind: "executable"; + readonly format: "elf"; + readonly architecture: "powerpc"; + readonly availableArchitectures: readonly "powerpc"[]; + readonly powerpc: PowerPcElfMetadata; + readonly executableRole?: never; + readonly managed?: never; + }) | (ExecutableTarget & { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; readonly universalSlice?: never; diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index d4bd5a433..8175b776b 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -21,6 +21,10 @@ import { ghidraRiscvLanguageOverride, ghidraRiscvProfileParameters, } from "./GhidraRiscvProfile.js"; +import { + ghidraPowerPcLanguage, + ghidraPowerPcProfileParameters, +} from "./GhidraPowerPcProfile.js"; import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js"; import { @@ -44,7 +48,9 @@ import { export const ghidraPinnedLanguage = ( target: BinaryTarget, ): GhidraLanguageOverride | undefined => - ghidraMipsLanguageOverride(target) ?? ghidraRiscvLanguageOverride(target); + ghidraMipsLanguageOverride(target) ?? + ghidraRiscvLanguageOverride(target) ?? + ghidraPowerPcLanguage(target); /** Resolve version-bound, deterministic semantics before Ghidra imports a target. */ export const resolveGhidraAnalysisProfile = async ( @@ -155,6 +161,7 @@ export const resolveGhidraAnalysisProfile = async ( const pinnedLanguage = ghidraPinnedLanguage(target); if ( pinnedLanguage !== undefined && + target.architecture !== "powerpc" && languageOverride !== undefined && (languageOverride.languageId !== pinnedLanguage.languageId || (languageOverride.compilerSpecId !== undefined && @@ -277,6 +284,7 @@ export const resolveGhidraAnalysisProfile = async ( ...analyzerParameters, ...ghidraMipsProfileParameters(target, languageOverride), ...ghidraRiscvProfileParameters(target), + ...ghidraPowerPcProfileParameters(target), ...(pspExtension?.ok === true ? { mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2", diff --git a/src/ghidra/GhidraPowerPcProfile.test.ts b/src/ghidra/GhidraPowerPcProfile.test.ts new file mode 100644 index 000000000..831a47f3b --- /dev/null +++ b/src/ghidra/GhidraPowerPcProfile.test.ts @@ -0,0 +1,166 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { parseConfig } from "../config/parseConfig.js"; +import type { + BinaryTarget, + PowerPcElfMetadata, +} 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 { + ghidraPinnedLanguage, + ghidraProfileLanguageOverride, +} from "./GhidraAnalysisProfile.js"; +import { GhidraProvider } from "./GhidraProvider.js"; +import type { GhidraInstallationHost } from "./GhidraInstallation.js"; + +// Abbreviated from Ghidra 12.1.4 Processors/PowerPC/data/languages/ppc.ldefs. +const PPC_LDEFS = ` + + + + + + + + +`; + +// 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 configFor = (environment: NodeJS.ProcessEnv) => { + const parsed = parseConfig({ GHIDRA_INSTALL_DIR: "/ghidra", ...environment }); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; +const config = configFor({}); +const target = (change: Partial = {}): BinaryTarget => ({ + path: "/fixture.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "powerpc", + availableArchitectures: ["powerpc"], + powerpc: { elfClass: 32, byteOrder: "big", type: 2, flags: 0, ...change }, +}); + +describe("bounded Ghidra PowerPC admission", () => { + it("binds the generic language instead of the loader's e500 choice", async () => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + expect(ghidra.inspectTargetSupport(target()).status).toBe("supported"); + const resolved = await ghidra.resolveAnalysisProfile(target()); + if (!resolved.ok) throw resolved.error; + const parameters = resolved.value.profile?.parameters ?? {}; + expect(parameters).toMatchObject({ + architecture: "powerpc", + language_id: "PowerPC:BE:32:default", + compiler_spec_id: "default", + language_selection: "provider-pinned-v1", + powerpc_elf: { elf_class: 32, byte_order: "big", type: 2, flags: 0 }, + powerpc_support_lane: "elf32-be-exec-generic-v1", + }); + expect(ghidraPinnedLanguage(target())).toEqual({ + languageId: "PowerPC:BE:32:default", + compilerSpecId: "default", + }); + expect(ghidraProfileLanguageOverride(parameters)).toEqual( + ghidraPinnedLanguage(target()), + ); + }); + + it("lets a configured language replace the lane default", async () => { + const installDir = await createTestTempDirectory("rea-ghidra-ppc-"); + const languages = join( + installDir, + "Ghidra/Processors/PowerPC/data/languages", + ); + await mkdir(languages, { recursive: true }); + await writeFile(join(languages, "ppc.ldefs"), PPC_LDEFS); + const ghidra = new GhidraProvider( + configFor({ + GHIDRA_INSTALL_DIR: installDir, + REA_GHIDRA_LANGUAGE_ID: "PowerPC:BE:32:e500", + }), + silentLogger, + {}, + host, + ); + const resolved = await ghidra.resolveAnalysisProfile(target()); + if (!resolved.ok) throw resolved.error; + const parameters = resolved.value.profile?.parameters ?? {}; + expect(parameters).toMatchObject({ + language_id: "PowerPC:BE:32:e500", + language_selection: "configured-v1", + }); + expect(ghidraProfileLanguageOverride(parameters)).toEqual({ + languageId: "PowerPC:BE:32:e500", + }); + }); + + it("commits header flags so a changed interpretation changes the profile", async () => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const left = await ghidra.resolveAnalysisProfile(target()); + const right = await ghidra.resolveAnalysisProfile( + target({ flags: 0x00010000 }), + ); + if (!left.ok) throw left.error; + if (!right.ok) throw right.error; + expect(left.value.profile?.digest).not.toBe(right.value.profile?.digest); + }); + + it("does not widen Windows P0", () => { + const windows = new GhidraProvider( + config, + silentLogger, + {}, + { ...host, platform: "win32" }, + ); + expect(windows.inspectTargetSupport(target()).status).toBe("unsupported"); + }); + + it.each([ + [{ elfClass: 64 }, "ELF32"], + [{ byteOrder: "little" }, "big-endian"], + [{ type: 1 }, "ET_EXEC"], + [{ type: 3 }, "ET_EXEC"], + [{ flags: 0x80000000 }, "EF_PPC_EMB"], + ] satisfies [Partial, string][])( + "refuses an unverified PowerPC interpretation %j", + async (change, reason) => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const value = target(change); + expect(ghidra.inspectTargetSupport(value)).toMatchObject({ + status: "unsupported", + reason: expect.stringContaining(reason), + }); + const resolved = await ghidra.resolveAnalysisProfile(value); + expect(resolved.ok).toBe(false); + }, + ); + + it("keeps PowerPC out of unverified Hopper and IDA adapter lanes", () => { + const hopper = new HopperProvider(config, silentLogger, {}, "linux"); + expect(hopper.inspectTargetSupport(target()).status).toBe("unsupported"); + expect(hopperLoaderArgsForTarget(target()).ok).toBe(false); + const ida = new IdaProvider(config, {}); + expect(ida.inspectTargetSupport(target()).status).toBe("unsupported"); + }); +}); diff --git a/src/ghidra/GhidraPowerPcProfile.ts b/src/ghidra/GhidraPowerPcProfile.ts new file mode 100644 index 000000000..c1655f252 --- /dev/null +++ b/src/ghidra/GhidraPowerPcProfile.ts @@ -0,0 +1,67 @@ +import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { JsonValue } from "../domain/jsonValue.js"; + +/** + * Generic 32-bit big-endian PowerPC. Ghidra's ELF loader selects the e500 + * (Book-E/SPE) variant for a plain EM_PPC header, so the lane binds this + * language instead of accepting the loader's choice. + */ +export const GHIDRA_POWERPC_LANGUAGE: GhidraLanguageOverride = { + languageId: "PowerPC:BE:32:default", + compilerSpecId: "default", +}; + +/** Keep family recognition separate from the deliberately bounded Ghidra lane. */ +export const ghidraPowerPcUnsupportedReason = ( + target: BinaryTarget, +): string | null => { + if (target.kind !== "executable" || target.architecture !== "powerpc") + return null; + const metadata = target.powerpc; + if (target.format !== "elf" || metadata === undefined) + return "PowerPC analysis requires ELF header metadata; a family name alone does not select a processor variant."; + if (metadata.elfClass !== 32) + return "This Ghidra PowerPC lane supports ELF32 only; PPC64 remains unsupported."; + if (metadata.byteOrder !== "big") + return "This Ghidra PowerPC lane supports big-endian ELF32 only; little-endian PowerPC needs separate verification."; + if (metadata.type !== 2) + return "This Ghidra PowerPC lane supports ET_EXEC only; relocatable objects and shared images need separate load verification."; + // EF_PPC_EMB (0x80000000) marks embedded variants; other bits only declare + // relocation conventions that do not change instruction decoding. + if ((metadata.flags & 0x80000000) !== 0) + return "EF_PPC_EMB declares an embedded PowerPC variant that needs a separately supported processor profile."; + return null; +}; + +/** + * The language the lane imports with, unless REA_GHIDRA_LANGUAGE_ID already + * selected one through the committed profile. + */ +export const ghidraPowerPcLanguage = ( + target: BinaryTarget, +): GhidraLanguageOverride | undefined => + target.kind === "executable" && target.architecture === "powerpc" + ? GHIDRA_POWERPC_LANGUAGE + : undefined; + +/** Commit source ELF interpretation without confusing target ISA with host CPU. */ +export const ghidraPowerPcProfileParameters = ( + target: BinaryTarget, +): Readonly> => { + if ( + target.kind !== "executable" || + target.architecture !== "powerpc" || + target.powerpc === undefined + ) + return {}; + return { + powerpc_elf: { + elf_class: target.powerpc.elfClass, + byte_order: target.powerpc.byteOrder, + type: target.powerpc.type, + flags: target.powerpc.flags, + }, + powerpc_support_lane: "elf32-be-exec-generic-v1", + }; +}; diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 0a1cde0f0..b0ee8787d 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -68,6 +68,7 @@ const SUPPORTED_ARCHITECTURES = new Set([ "mips", "6502", "riscv", + "powerpc", ]); /** Ghidra candidate backed by an isolated ephemeral headless import. */ diff --git a/src/ghidra/GhidraPspProfile.ts b/src/ghidra/GhidraPspProfile.ts index 17260d038..3550d62ab 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 { ghidraPowerPcUnsupportedReason } from "./GhidraPowerPcProfile.js"; import { ghidraRiscvUnsupportedReason } from "./GhidraRiscvProfile.js"; /** Allegrex's ELF machine declaration, not a filename or inferred instruction. */ @@ -14,6 +15,8 @@ export const isGhidraPspTarget = (target: BinaryTarget): boolean => export const ghidraProcessorUnsupportedReason = ( target: BinaryTarget, ): string | null => { + if (target.kind === "executable" && target.architecture === "powerpc") + return ghidraPowerPcUnsupportedReason(target); if (target.kind === "executable" && target.architecture === "riscv") return ghidraRiscvUnsupportedReason(target); if ( diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index a4848533f..79ba3ab50 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -110,6 +110,7 @@ const HOPPER_DECLINED_ARCHITECTURE_LABELS: Record< > = { mips: "MIPS", riscv: "RISC-V", + powerpc: "PowerPC", }; /** Derive Hopper's complete non-interactive CLI loader selection. */ diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index aec8c2082..f9826575b 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -126,7 +126,11 @@ export class HopperProvider implements AnalysisProviderCandidate { status: "unsupported", code: "architecture_unsupported", reason: `REA's Hopper adapter does not admit ${ - target.architecture === "mips" ? "MIPS" : "RISC-V" + target.architecture === "mips" + ? "MIPS" + : target.architecture === "riscv" + ? "RISC-V" + : "PowerPC" } targets.`, diagnostics, }; diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index 09011caa5..5ce924fcd 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -113,7 +113,11 @@ export class IdaProvider implements AnalysisProviderCandidate { status: "unsupported", code: "architecture_unsupported", reason: `REA's IDA adapter has no verified ${ - target.architecture === "mips" ? "MIPS" : "RISC-V" + target.architecture === "mips" + ? "MIPS" + : target.architecture === "riscv" + ? "RISC-V" + : "PowerPC" } lane. Select a supported Ghidra profile instead.`, diagnostics, }; diff --git a/tests/composition/analysis-sessions/powerpc-identity.test.ts b/tests/composition/analysis-sessions/powerpc-identity.test.ts new file mode 100644 index 000000000..3dd1d374a --- /dev/null +++ b/tests/composition/analysis-sessions/powerpc-identity.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { + createAnalysisSnapshotEntry, + parseAnalysisSnapshot, + serializeAnalysisSnapshot, + snapshotBinding, + snapshotTarget, + snapshotMatchesProfile, +} from "../../../src/domain/analysisSnapshot.js"; +import { sessionOutputSchemas } from "../../../src/contracts/toolOutputSchemaGroups.js"; + +const target: BinaryTarget = { + path: "/source-owned.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "powerpc", + availableArchitectures: ["powerpc"], + powerpc: { elfClass: 32, byteOrder: "big", type: 2, flags: 0 }, +}; +const provider = { id: "ghidra", name: "Ghidra", version: "12.1.4" }; +const profile = createAnalysisProfile(provider, { + powerpc_elf: { elf_class: 32, byte_order: "big", type: 2, flags: 0 }, +}); + +describe("PowerPC identity through lifecycle, Evidence and snapshot consumers", () => { + it("round-trips query bindings without relaxing integrity", () => { + const result = [{ address: "0x100101b0", value: "rea_ppc_entry" }]; + const parameters = {}; + const evidence = createEvidence(target, provider, { + operation: "list_procedures", + parameters, + result, + analysisProfile: profile, + }); + const binding = snapshotBinding(profile); + const entry = createAnalysisSnapshotEntry({ + target: snapshotTarget(target), + binding, + operation: "list_procedures", + parameters, + execution: { + result, + rawResult: null, + provider, + analysisProfile: profile, + limitations: [], + locations: [], + subject: target, + }, + }); + const snapshot = { + target: snapshotTarget(target), + binding, + entries: [entry], + workflow_entries: [], + evidence_bundle: createEvidenceBundle([evidence]), + }; + const restored = parseAnalysisSnapshot( + JSON.parse(serializeAnalysisSnapshot(snapshot)), + ); + expect(restored.target.architecture).toBe("powerpc"); + expect(restored.evidence_bundle.records[0]?.subject?.architecture).toBe( + "powerpc", + ); + expect(restored.entries[0]?.execution.subject?.architecture).toBe( + "powerpc", + ); + expect( + sessionOutputSchemas.open_binary.parse({ result: target }).result + .architecture, + ).toBe("powerpc"); + expect(() => + parseEvidence({ + ...evidence, + subject: { ...evidence.subject, architecture: "x86" }, + }), + ).toThrow("semantic identifier"); + const changed = createAnalysisProfile(provider, { + powerpc_elf: { + elf_class: 32, + byte_order: "big", + type: 2, + flags: 0x00010000, + }, + }); + expect(snapshotMatchesProfile(binding, changed)).toBe(false); + }); +}); diff --git a/tests/conformance/c/ppc.c b/tests/conformance/c/ppc.c new file mode 100644 index 000000000..3f62ca57e --- /dev/null +++ b/tests/conformance/c/ppc.c @@ -0,0 +1,14 @@ +/* Freestanding, source-owned static-analysis fixture. Never executed by REA. */ +volatile unsigned int rea_ppc_global = 7; +const char rea_ppc_marker[] = "rea-ppc-source-owned-fixture"; + +__attribute__((noinline)) unsigned int rea_ppc_leaf(unsigned int value) +{ + return value * 3u + rea_ppc_global; +} + +__attribute__((noinline)) unsigned int rea_ppc_entry(unsigned int selector) +{ + unsigned int adjusted = selector > 10u ? selector : selector + 1u; + return rea_ppc_leaf(adjusted) + (unsigned char)rea_ppc_marker[0]; +} diff --git a/tests/conformance/ghidra/ppc-checks.d.mts b/tests/conformance/ghidra/ppc-checks.d.mts new file mode 100644 index 000000000..38609de45 --- /dev/null +++ b/tests/conformance/ghidra/ppc-checks.d.mts @@ -0,0 +1,29 @@ +import type { PowerPcElfMetadata } from "../../../src/domain/binaryTargetTypes.js"; + +export interface PowerPcReadelfFacts { + powerpc: PowerPcElfMetadata; + symbols: Record< + | "rea_ppc_entry" + | "rea_ppc_leaf" + | "rea_ppc_probe" + | "rea_ppc_global" + | "rea_ppc_marker", + string + >; +} +export function parsePowerPcReadelf(text: string): PowerPcReadelfFacts; +export function inspectPowerPcReadelf( + path: string, +): Promise< + PowerPcReadelfFacts & { command: string; version: string; raw: string } +>; +export function assertPowerPcLoadedImage( + observations: unknown, + sha256: string, +): void; +export function assertPowerPcProbe( + move: unknown, + branch: unknown, + address: string, +): void; +export function assertPowerPcGlobal(bytes: string): void; diff --git a/tests/conformance/ghidra/ppc-checks.mjs b/tests/conformance/ghidra/ppc-checks.mjs new file mode 100644 index 000000000..1136f2602 --- /dev/null +++ b/tests/conformance/ghidra/ppc-checks.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); +// The lane binds this language; Ghidra's ELF loader alone selects e500. +const LANGUAGE = "PowerPC:BE:32:default"; +const wordHex = (value) => { + const bytes = Buffer.alloc(4); + bytes.writeUInt32BE(value); + return bytes.toString("hex"); +}; + +/** Parse only the documented GNU readelf fields needed by our tiny fixture. */ +export function parsePowerPcReadelf(text) { + const field = (expression, label) => { + const matches = [...text.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"), "ELF32"); + assert.equal( + field(/^\s*Data:.*?,\s*(little|big) endian\s*$/gmu, "byte order"), + "big", + ); + assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u); + assert.equal(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), "PowerPC"); + const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+)\s*$/gmu, "flags")); + assert.equal(flags, 0, "Fixture ELF flags differ from build intent"); + const symbols = {}; + for (const name of [ + "rea_ppc_entry", + "rea_ppc_leaf", + "rea_ppc_probe", + "rea_ppc_global", + "rea_ppc_marker", + ]) { + 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", + ), + `symbol ${name}`, + ); + symbols[name] = `0x${BigInt(`0x${value}`).toString(16)}`; + } + return { + powerpc: { elfClass: 32, byteOrder: "big", type: 2, flags }, + symbols, + }; +} + +/** Independent reader is a bounded test prerequisite, never an REA provider. */ +export async function inspectPowerPcReadelf(path) { + const command = process.env.REA_PPC_READELF ?? "readelf"; + const options = { + env: { ...process.env, LC_ALL: "C" }, + timeout: 10000, + maxBuffer: 1024 * 1024, + }; + const version = await execute(command, ["--version"], options); + assert.match( + version.stdout, + /^GNU readelf\b/u, + "This lane requires GNU readelf", + ); + const result = await execute(command, ["-h", "-s", "-W", path], options); + assert.equal( + result.stderr.trim(), + "", + `readelf diagnostics: ${result.stderr}`, + ); + return { + ...parsePowerPcReadelf(result.stdout), + command, + version: version.stdout.split("\n")[0], + raw: result.stdout, + }; +} + +/** Check effective Ghidra observations, not the requested analysis profile. */ +export function assertPowerPcLoadedImage(observations, sha256) { + assert.equal(observations.language_id, LANGUAGE); + assert.equal(observations.compiler_spec_id, "default"); + assert.ok( + observations.source_files.some( + (file) => + file.original_sha256 === sha256 && file.modified_sha256 === sha256, + ), + "Ghidra loaded source identity changed", + ); +} + +/** Verify the exact constant/conditional destination in ppc-probe.S. */ +export function assertPowerPcProbe(move, branch, address) { + const start = BigInt(address); + for (const [instruction, offset, word] of [ + [move, 0n, 0x38641234], + [branch, 8n, 0x41820008], + ]) { + assert.equal(instruction.status, "decoded"); + assert.equal(BigInt(instruction.address), start + offset); + assert.equal(instruction.architecture, LANGUAGE); + assert.equal(instruction.length, 4); + assert.equal(instruction.bytes, wordHex(word)); + } + assert.match(move.mnemonic, /^ADDI$/iu); + assert.ok( + move.operands.some((operand) => + operand.components.some( + (part) => + part.kind === "immediate" && + part.value !== null && + BigInt(part.value) === 0x1234n, + ), + ), + "ADDI immediate was not recovered", + ); + assert.match(branch.mnemonic, /^BEQ$/iu); + assert.equal(branch.flow.kind, "jump"); + assert.equal(branch.flow.conditional, true); + assert.equal(branch.flow.computed, false); + assert.deepEqual( + branch.flow.direct_destinations.map((value) => BigInt(value)), + [start + 16n], + ); +} + +/** Four bytes must encode the known source-owned global in big-endian order. */ +export function assertPowerPcGlobal(bytes) { + assert.equal(bytes, wordHex(7)); +} diff --git a/tests/conformance/ghidra/ppc-checks.test.ts b/tests/conformance/ghidra/ppc-checks.test.ts new file mode 100644 index 000000000..6328ce029 --- /dev/null +++ b/tests/conformance/ghidra/ppc-checks.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { + assertPowerPcGlobal, + assertPowerPcLoadedImage, + assertPowerPcProbe, + parsePowerPcReadelf, +} from "./ppc-checks.mjs"; + +// GNU readelf 2.47 output for the source-owned Clang 23 / LLD fixture. +// This tests the reader boundary, not Ghidra; the real lane rebuilds it. +const readelf = `ELF Header: + Magic: 7f 45 4c 46 01 02 01 00 00 00 00 00 00 00 00 00 + Class: ELF32 + Data: 2's complement, big endian + Version: 1 (current) + OS/ABI: UNIX - System V + ABI Version: 0 + Type: EXEC (Executable file) + Machine: PowerPC + Version: 0x1 + Entry point address: 0x100101b0 + Start of program headers: 52 (bytes into file) + Start of section headers: 2188 (bytes into file) + Flags: 0x0 + Size of this header: 52 (bytes) + Size of program headers: 32 (bytes) + Number of program headers: 6 + Size of section headers: 40 (bytes) + Number of section headers: 18 + Section header string table index: 16 + +Symbol table '.symtab' contains 7 entries: + Num: Value Size Type Bind Vis Ndx Name + 0: 00000000 0 NOTYPE LOCAL DEFAULT UND + 1: 00000000 0 FILE LOCAL DEFAULT ABS ppc.c + 2: 10010180 48 FUNC GLOBAL DEFAULT 4 rea_ppc_leaf + 3: 1002023c 4 OBJECT GLOBAL DEFAULT 5 rea_ppc_global + 4: 100101b0 116 FUNC GLOBAL DEFAULT 4 rea_ppc_entry + 5: 100000f4 29 OBJECT GLOBAL DEFAULT 1 rea_ppc_marker + 6: 10010224 24 FUNC GLOBAL DEFAULT 4 rea_ppc_probe +`; +const sha256 = "a".repeat(64); +const image = { + language_id: "PowerPC:BE:32:default", + compiler_spec_id: "default", + source_files: [{ original_sha256: sha256, modified_sha256: sha256 }], +}; +const move = { + address: "0x10010224", + status: "decoded", + architecture: "PowerPC:BE:32:default", + length: 4, + bytes: "38641234", + mnemonic: "addi", + operands: [ + { index: 2, components: [{ kind: "immediate", value: "0x1234" }] }, + ], +}; +const branch = { + address: "0x1001022c", + status: "decoded", + architecture: "PowerPC:BE:32:default", + length: 4, + bytes: "41820008", + mnemonic: "beq", + flow: { + kind: "jump", + conditional: true, + computed: false, + direct_destinations: ["0x10010234"], + }, +}; + +describe("PowerPC real-verifier independent expectations", () => { + it("reads GNU header and defined symbols without a Ghidra-derived oracle", () => { + const parsed = parsePowerPcReadelf(readelf); + expect(parsed.powerpc).toEqual({ + elfClass: 32, + byteOrder: "big", + type: 2, + flags: 0, + }); + expect(parsed.symbols.rea_ppc_probe).toBe("0x10010224"); + }); + + it.each([ + ["wrong class", "ELF32", "ELF64"], + ["wrong byte order", "big endian", "little endian"], + [ + "wrong machine", + "Machine: PowerPC", + "Machine: PowerPC64", + ], + ["wrong object type", "EXEC (Executable file)", "DYN (Shared object file)"], + [ + "wrong flags", + "Flags: 0x0", + "Flags: 0x80000000", + ], + ["missing symbol", "rea_ppc_probe", "unrelated_probe"], + ])( + "rejects %s instead of returning a guessed fixture interpretation", + (_name, from, to) => { + expect(() => parsePowerPcReadelf(readelf.replace(from, to))).toThrow(); + }, + ); + + it("rejects ambiguous producer fields", () => { + expect(() => parsePowerPcReadelf(`${readelf}\nClass: ELF32\n`)).toThrow( + /ambiguous/u, + ); + }); + + it("checks the effective loaded image, including Ghidra's e500 default", () => { + assertPowerPcLoadedImage(image, sha256); + expect(() => + assertPowerPcLoadedImage( + { ...image, language_id: "PowerPC:BE:32:e500" }, + sha256, + ), + ).toThrow(); + expect(() => + assertPowerPcLoadedImage( + { ...image, compiler_spec_id: "macosx" }, + sha256, + ), + ).toThrow(); + expect(() => + assertPowerPcLoadedImage( + { + ...image, + source_files: [ + { original_sha256: sha256, modified_sha256: "b".repeat(64) }, + ], + }, + sha256, + ), + ).toThrow(); + }); + + it("checks exact probe bytes, immediate and conditional destination", () => { + assertPowerPcProbe(move, branch, "0x10010224"); + }); + + it("rejects plausible nonempty but incorrect instruction observations", () => { + expect(() => + assertPowerPcProbe({ ...move, bytes: "38641235" }, branch, "0x10010224"), + ).toThrow(); + expect(() => + assertPowerPcProbe({ ...move, length: 2 }, branch, "0x10010224"), + ).toThrow(); + expect(() => + assertPowerPcProbe( + { + ...move, + operands: [ + { index: 2, components: [{ kind: "immediate", value: "0x1235" }] }, + ], + }, + branch, + "0x10010224", + ), + ).toThrow(); + expect(() => + assertPowerPcProbe( + move, + { + ...branch, + flow: { ...branch.flow, direct_destinations: ["0x10010230"] }, + }, + "0x10010224", + ), + ).toThrow(); + expect(() => + assertPowerPcProbe( + move, + { ...branch, flow: { ...branch.flow, conditional: false } }, + "0x10010224", + ), + ).toThrow(); + }); + + it("checks the big-endian global word", () => { + assertPowerPcGlobal("00000007"); + expect(() => assertPowerPcGlobal("07000000")).toThrow(); + }); +}); diff --git a/tests/conformance/ghidra/ppc-fixture.mjs b/tests/conformance/ghidra/ppc-fixture.mjs new file mode 100644 index 000000000..202c0dacf --- /dev/null +++ b/tests/conformance/ghidra/ppc-fixture.mjs @@ -0,0 +1,62 @@ +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 a source-owned freestanding ELF; never execute its target code. */ +export async function buildPowerPcFixture(directory) { + const compiler = process.env.REA_PPC_CLANG ?? "clang"; + const target = "powerpc-unknown-linux-gnu"; + const source = fileURLToPath(new URL("../c/ppc.c", import.meta.url)); + const probe = fileURLToPath(new URL("./ppc-probe.S", import.meta.url)); + const path = join(directory, `${target}.elf`); + const args = [ + `--target=${target}`, + // A 750-family core with the classic FPU, unlike the e500 SPE variant. + "-mcpu=750", + "-fno-pic", + "-fuse-ld=lld", + "-nostdlib", + "-static", + "-O0", + "-g", + "-Wl,-e,rea_ppc_entry", + "-Wl,--build-id=none", + source, + probe, + "-o", + path, + ]; + const version = await execute(compiler, ["--version"], { timeout: 10000 }); + await execute(compiler, args, { timeout: 60000 }); + const bytes = await readFile(path); + if ( + bytes.length < 52 || + bytes.toString("hex", 0, 4) !== "7f454c46" || + bytes[4] !== 1 || + bytes[5] !== 2 || + bytes.readUInt16BE(16) !== 2 || + bytes.readUInt16BE(18) !== 20 || + bytes.readUInt32BE(36) !== 0 + ) + throw new Error( + "Compiler did not produce the declared ELF32 big-endian PowerPC fixture", + ); + return { + path, + flags: bytes.readUInt32BE(36), + sha256: createHash("sha256").update(bytes).digest("hex"), + source_sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), + probe_sha256: createHash("sha256") + .update(await readFile(probe)) + .digest("hex"), + compiler: version.stdout.trim(), + arguments: args, + }; +} diff --git a/tests/conformance/ghidra/ppc-probe.S b/tests/conformance/ghidra/ppc-probe.S new file mode 100644 index 000000000..f67793942 --- /dev/null +++ b/tests/conformance/ghidra/ppc-probe.S @@ -0,0 +1,17 @@ +/* Source-owned 32-bit PowerPC instructions with fixed, reviewable encodings. + * No target execution. The conditional branch at +8 targets +16. + */ +.text +.p2align 2 +.globl rea_ppc_probe +.type rea_ppc_probe, @function +rea_ppc_probe: + addi 3, 4, 0x1234 + cmpwi 0, 4, 0 + beq 0, .Lzero + blr +.Lzero: + li 3, 7 + blr +.size rea_ppc_probe, .-rea_ppc_probe +.section .note.GNU-stack,"",@progbits