feat(ghidra): admit 32-bit big-endian PowerPC ELF (#1747)
* feat(ghidra): admit 32-bit big-endian PowerPC ELF Recognize EM_PPC as a provider-neutral `powerpc` family that keeps its ELF class, byte order, type and flags, and admit ELF32 big-endian ET_EXEC through a bounded Ghidra lane. ELF64, little-endian, relocatable/shared objects and EF_PPC_EMB are refused with a specific reason; Hopper and IDA do not gain PowerPC support, and Windows Ghidra P0 is unchanged. Ghidra's ELF loader selects PowerPC:BE:32:e500:default for a plain EM_PPC header, which misreads 750-family code. The lane binds PowerPC:BE:32:default/default in the profile (language_selection "admitted-lane-v1"), imports with it and checks the reported language. A language set through REA_GHIDRA_LANGUAGE_ID still replaces the lane default. Add a source-owned fixture, fixed-encoding probe, GNU readelf cross-check and the optional `verify:ghidra:ppc` real-provider lane, plus docs/ghidra-ppc.md. Refs #1741 * style(ghidra): format shared PowerPC profile integration * fix(ghidra): preserve caller-selected PowerPC languages --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
parent
7e9fb6f1bb
commit
c86b09a109
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<language_definitions>
|
||||
<language processor="PowerPC" endian="big" size="32" variant="default"
|
||||
id="PowerPC:BE:32:default">
|
||||
<compiler name="default" spec="ppc_32.cspec" id="default"/>
|
||||
</language>
|
||||
<language processor="PowerPC" endian="big" size="32" variant="e500"
|
||||
id="PowerPC:BE:32:e500">
|
||||
<compiler name="default" spec="ppc_32_e500_be.cspec" id="default"/>
|
||||
</language>
|
||||
</language_definitions>
|
||||
`;
|
||||
|
||||
// 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<PowerPcElfMetadata> = {}): 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<PowerPcElfMetadata>, 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Record<string, JsonValue>> => {
|
||||
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",
|
||||
};
|
||||
};
|
||||
|
|
@ -68,6 +68,7 @@ const SUPPORTED_ARCHITECTURES = new Set([
|
|||
"mips",
|
||||
"6502",
|
||||
"riscv",
|
||||
"powerpc",
|
||||
]);
|
||||
|
||||
/** Ghidra candidate backed by an isolated ephemeral headless import. */
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue