mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
feat(ghidra): admit bounded PSP ELF through installed Allegrex (#1599)
* feat(ghidra): admit bounded PSP ELF through installed Allegrex Build on merged generic MIPS without weakening its policy. Bind caller-installed processor files into the analysis profile, check them again before launch, force and verify PSP language selection, retain limitations, and extend the public CLI/MCP verifier with source-owned PSP and target-switching checks. Local actual focused Vitest: 102 passed, one existing permission skip. Hosted static and real-provider results are pending. * fix(ghidra): validate PSPSDK ABI records in PSP admission * test(ghidra): bind PSP ABI verification to snapshot profiles * test(ghidra): declare independent PSP ABI reader contract * test(ghidra): retain the PSP fixture discriminated type * test(ghidra): contextually type PSP metadata as the MIPS target variant * test(ghidra): keep carved firmware checks scoped to generic MIPS fixtures * style(ghidra): format PSP language-override guard * style(test): format PSP language override regressions * fix(ghidra): bound PSP extension enumeration before allocation --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
0153b4d95b
commit
e4407dbed5
+1
-1
@@ -3,7 +3,7 @@
|
||||
This lane extends native ELF analysis; it does not execute MIPS code or rehost
|
||||
firmware. It advances the generic-MIPS portion of
|
||||
[#718](https://github.com/morluto/rea/issues/718), not the whole firmware roadmap.
|
||||
PSP/Allegrex remains a separate specialization tracked in
|
||||
PSP/Allegrex has a [separate static-ELF profile](ghidra-psp.md) tracked in
|
||||
[#1330](https://github.com/morluto/rea/issues/1330).
|
||||
|
||||
## Admission boundary
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# PSP static ELF analysis through Ghidra
|
||||
|
||||
This specialization builds on [generic MIPS support](ghidra-mips.md) for
|
||||
[#1330](https://github.com/morluto/rea/issues/1330). It reuses REA's existing
|
||||
CLI/MCP, Ghidra bridge, Evidence and snapshot workflows. Allegrex instructions,
|
||||
PSP loading and calling conventions remain owned by
|
||||
[ghidra-allegrex](https://github.com/kotcrab/ghidra-allegrex).
|
||||
|
||||
## Initial boundary
|
||||
|
||||
The initial profile is limited to Linux x64, little-endian ELF32 `ET_EXEC`,
|
||||
`EM_MIPS` and the PSPSDK declaration `e_flags=0x10a23001`. Those bits declare
|
||||
MIPS II, Allegrex, EABI32 and NOREORDER; they are not proof of every instruction's
|
||||
semantics. The profile also requires an inspected version-0 MIPS ABI flags
|
||||
record declaring ISA level 2/revision 0, 32-bit GPR and CPR1, no CPR2,
|
||||
single-precision hard float (`fpAbi=2`), no ISA extension or ASEs,
|
||||
`flags1=1` and `flags2=0`. These are the declarations independently reported by
|
||||
PSPDEV v20261001's GNU readelf 2.44 and its 24-byte section dump. Missing,
|
||||
uninspected, contradictory and other producer declarations are refused.
|
||||
The PSP profile is not an exception to the generic MIPS32r2/o32 policy; that
|
||||
separate gate is unchanged. Profile revision v2 records this corrected boundary.
|
||||
|
||||
PRX, relocatable/shared images, PBP/ISO/CSO containers, runtime emulation and live
|
||||
code overlays are outside this first profile. Other hosts and flag combinations
|
||||
are not silently admitted. File names do not identify a PSP target.
|
||||
|
||||
## Bring your own compatible extension
|
||||
|
||||
Install the extension in the selected Ghidra distribution at
|
||||
`$GHIDRA_INSTALL_DIR/Ghidra/Extensions/ghidra-allegrex`, with its
|
||||
`Module.manifest`, `extension.properties`, language files and loader JARs.
|
||||
User-home installations are not searched because REA uses an isolated analysis
|
||||
home. REA never downloads, installs, rebuilds or vendors the extension.
|
||||
|
||||
The published compatibility experiment used Ghidra **12.1.3**, extension
|
||||
**v21.4**, PSPDEV **v20261001** and JDK 21. New public-provider results must be
|
||||
reported separately from that historical bridge experiment. The extension's
|
||||
`version` property declares its compatible **Ghidra version**, not its release
|
||||
tag; the property must match the selected installation. Other same-version
|
||||
builds are not described as independently verified merely because they pass
|
||||
this metadata check. A malformed or unloadable extension still fails real import.
|
||||
|
||||
REA fingerprints installed extension files before resolving the analysis
|
||||
profile and checks that identity again immediately before launch. Symlinks,
|
||||
nonregular files, missing components and incompatible metadata are refused.
|
||||
Inspection is bounded to 1,024 entries, 16 directory levels and 64 MiB, with
|
||||
streamed file hashing and 64 KiB properties. Rebuilds or content changes yield a
|
||||
different profile; an old snapshot is not silently reused with the new profile.
|
||||
This is content identity, not a signature or a sandbox for installed code.
|
||||
Do not modify the Ghidra installation while a session is running.
|
||||
|
||||
## Ordinary public interfaces
|
||||
|
||||
```bash
|
||||
rea analyze /absolute/path/to/psp.elf --provider ghidra --json
|
||||
rea function /absolute/path/to/psp.elf function_name --provider ghidra --json
|
||||
```
|
||||
|
||||
MCP uses `open_binary` with an absolute path and `provider_id: "ghidra"`, then
|
||||
ordinary inventory and function operations. The loader is explicitly
|
||||
`PspElfLoader`, language `Allegrex:LE:32:default`, compiler `default`. The
|
||||
handshake must confirm the language and compiler before serving observations.
|
||||
Missing prerequisites return `PSP_EXTENSION_UNAVAILABLE` with an installation
|
||||
remedy; no provider fallback or automatic installation occurs.
|
||||
|
||||
Evidence retains the `mips` architecture family and its PSP-specific profile,
|
||||
including extension content identity. It also carries the VFPU and static-only
|
||||
limitations. The extension does not model VFPU prefix effects completely;
|
||||
nonempty decompiled code is not proof of correct vector behavior. Existing
|
||||
session-local metadata editing does not change executable bytes or turn a
|
||||
snapshot into a persistent Ghidra project.
|
||||
|
||||
## Verification
|
||||
|
||||
Use the existing real MIPS verifier with its PSP option. Required tools are
|
||||
caller-supplied `make`, `psp-config`, `psp-gcc`, `psp-readelf`, `psp-objdump`,
|
||||
`PSPDEV`, `GHIDRA_INSTALL_DIR` and a compatible `JAVA_HOME`.
|
||||
|
||||
```bash
|
||||
npm run build:cached
|
||||
node scripts/verify-real-ghidra-mips.mjs --psp
|
||||
# In a disposable installation without Allegrex, test actual public refusals:
|
||||
node scripts/verify-real-ghidra-mips.mjs --psp-missing-extension
|
||||
# Generic MIPS must remain generic with Allegrex installed:
|
||||
npm run verify:ghidra:mips
|
||||
```
|
||||
|
||||
The generic verifier also exercises a section-truncated firmware fixture. That
|
||||
check uses generic MIPS symbols and language expectations and runs only in generic
|
||||
mode; the PSP report explicitly records carved-PSP coverage as not run.
|
||||
|
||||
The maintained PSP fixture builds from `tests/conformance/psp/`, never executes,
|
||||
and is independent of commercial game data. PSP GNU tools supply ELF/ABI,
|
||||
raw ABI-record, symbol and encoding observations; REA's parsed target and saved
|
||||
snapshot must agree with the independent ABI declaration. Public CLI/MCP must
|
||||
recover known functions, a direct
|
||||
call, the immediate `0x1234`, BITREV, a global word and a marker. The verifier
|
||||
checks observed loader/language, original bytes, Evidence, snapshot reopening,
|
||||
and an A-B-A target switch with different global contents at the same address.
|
||||
A snapshot for A must not open B. Cleanup must finish before success is reported.
|
||||
|
||||
These checks are selected static facts, not full behavioral equivalence, VFPU
|
||||
semantics or arbitrary PSP compatibility. Independently published PSP binaries
|
||||
are supplemental calibration targets after payload classification and license
|
||||
review; unsupported PRX/PBP must not count as a successful static-ELF case.
|
||||
+311
-130
@@ -21,12 +21,26 @@ import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
|
||||
import { functionDossierSchema } from "../dist/domain/hopperValues.js";
|
||||
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
|
||||
import { buildMipsFixture } from "../tests/conformance/ghidra/mips-fixture.mjs";
|
||||
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
|
||||
import {
|
||||
buildPspFixture,
|
||||
assertPspLoadedImage,
|
||||
assertPspProbe,
|
||||
} from "../tests/conformance/ghidra/psp-fixture.mjs";
|
||||
import {
|
||||
requireMcpToolError,
|
||||
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-mips.mjs");
|
||||
const mode = process.argv[2];
|
||||
const psp = mode === "--psp" || mode === "--psp-missing-extension";
|
||||
const missingExtension = mode === "--psp-missing-extension";
|
||||
if (process.argv.length > 3 || (mode !== undefined && !psp))
|
||||
throw new Error(
|
||||
"Usage: node scripts/verify-real-ghidra-mips.mjs [--psp|--psp-missing-extension]",
|
||||
);
|
||||
const prefix = psp ? "rea_psp" : "rea_mips";
|
||||
if (!["linux", "darwin"].includes(process.platform))
|
||||
throw new Error(
|
||||
"MIPS verification requires a supported Linux/macOS Ghidra host",
|
||||
@@ -66,16 +80,29 @@ for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
|
||||
const reports = [];
|
||||
let primaryFailure;
|
||||
try {
|
||||
for (const byteOrder of ["little", "big"]) {
|
||||
const fixture = await buildMipsFixture(workspace, byteOrder);
|
||||
const independent = await inspectMipsReadelf(fixture.path, byteOrder);
|
||||
for (const byteOrder of psp ? ["little"] : ["little", "big"]) {
|
||||
const fixture = psp
|
||||
? await buildPspFixture(workspace)
|
||||
: await buildMipsFixture(workspace, byteOrder);
|
||||
const independent = psp
|
||||
? fixture
|
||||
: await inspectMipsReadelf(fixture.path, byteOrder);
|
||||
const resolved = await parseBinaryTarget(fixture.path);
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
assert.deepEqual(
|
||||
resolved.value.mips,
|
||||
independent.mips,
|
||||
"REA ELF/ABI interpretation disagrees with GNU readelf",
|
||||
);
|
||||
if (psp) {
|
||||
assert.equal(resolved.value.mips.flags, 0x10a23001);
|
||||
assert.deepEqual(
|
||||
resolved.value.mips,
|
||||
independent.mips,
|
||||
"REA PSP ELF/ABI interpretation disagrees with GNU readelf and raw ABI bytes",
|
||||
);
|
||||
} else {
|
||||
assert.deepEqual(
|
||||
resolved.value.mips,
|
||||
independent.mips,
|
||||
"REA ELF/ABI interpretation disagrees with GNU readelf",
|
||||
);
|
||||
}
|
||||
assert.equal(resolved.value.sha256, fixture.sha256);
|
||||
const snapshotPath = join(workspace, `${byteOrder}.snapshot.json`);
|
||||
const transport = new StdioClientTransport({
|
||||
@@ -99,6 +126,51 @@ try {
|
||||
};
|
||||
try {
|
||||
await client.connect(transport);
|
||||
if (missingExtension) {
|
||||
const rejected = await client.callTool(
|
||||
{
|
||||
name: "open_binary",
|
||||
arguments: { path: fixture.path, provider_id: "ghidra" },
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
const error = requireMcpToolError(rejected);
|
||||
assert.match(JSON.stringify(error), /PSP_EXTENSION_UNAVAILABLE/u);
|
||||
let cliError;
|
||||
try {
|
||||
await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"function",
|
||||
fixture.path,
|
||||
`${prefix}_entry`,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 60000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
} catch (cause) {
|
||||
cliError = cause;
|
||||
}
|
||||
assert.ok(
|
||||
cliError && Number.isInteger(cliError.code) && cliError.code !== 0,
|
||||
"Expected a typed public rejection, not a timeout or success",
|
||||
);
|
||||
assert.match(
|
||||
`${cliError.stdout}\n${cliError.stderr}`,
|
||||
/PSP_EXTENSION_UNAVAILABLE/u,
|
||||
);
|
||||
reports.push({
|
||||
path: fixture.path,
|
||||
status: "expected-missing-extension-refusal",
|
||||
cli_exit: cliError.code,
|
||||
mcp_error: error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const target = await call("open_binary", {
|
||||
path: fixture.path,
|
||||
provider_id: "ghidra",
|
||||
@@ -108,45 +180,56 @@ try {
|
||||
// ELF load-image verification is not the DOS comparison lane. Its retained
|
||||
// observations still expose the actual Ghidra language and loaded bytes.
|
||||
const image = await call("inspect_native_load_image");
|
||||
assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
|
||||
if (psp) assertPspLoadedImage(image.observations, fixture.sha256);
|
||||
else assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
|
||||
const procedures = await call("list_procedures");
|
||||
const entry = procedures.find((p) => p.value === "rea_mips_entry");
|
||||
const leaf = procedures.find((p) => p.value === "rea_mips_leaf");
|
||||
const entry = procedures.find((p) => p.value === `${prefix}_entry`);
|
||||
const leaf = procedures.find((p) => p.value === `${prefix}_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_mips_entry),
|
||||
BigInt(independent.symbols[`${prefix}_entry`]),
|
||||
);
|
||||
assert.equal(
|
||||
BigInt(leaf.address),
|
||||
BigInt(independent.symbols.rea_mips_leaf),
|
||||
BigInt(independent.symbols[`${prefix}_leaf`]),
|
||||
);
|
||||
const probe = independent.symbols.rea_mips_probe;
|
||||
const probe = independent.symbols[`${prefix}_probe`];
|
||||
assert.ok(
|
||||
procedures.some(
|
||||
(p) =>
|
||||
p.value === "rea_mips_probe" && BigInt(p.address) === BigInt(probe),
|
||||
p.value === `${prefix}_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) + 4n).toString(16)}`,
|
||||
});
|
||||
assertMipsProbe(move, branch, byteOrder, probe);
|
||||
if (psp) assertPspProbe(move, branch);
|
||||
else assertMipsProbe(move, branch, byteOrder, probe);
|
||||
const global = await call("read_bytes", {
|
||||
address: independent.symbols.rea_mips_global,
|
||||
address: independent.symbols[`${prefix}_global`],
|
||||
length: 4,
|
||||
});
|
||||
assert.equal(global.returned_bytes, 4);
|
||||
assertMipsGlobal(global.bytes_hex, byteOrder);
|
||||
const marker = await call("read_bytes", {
|
||||
address: independent.symbols.rea_mips_marker,
|
||||
length: 30,
|
||||
address: independent.symbols[`${prefix}_marker`],
|
||||
length: Buffer.byteLength(
|
||||
psp
|
||||
? "rea-psp-source-owned-fixture\0"
|
||||
: "rea-mips-source-owned-fixture\0",
|
||||
),
|
||||
});
|
||||
assert.equal(
|
||||
marker.bytes_hex,
|
||||
Buffer.from("rea-mips-source-owned-fixture\0").toString("hex"),
|
||||
Buffer.from(
|
||||
psp
|
||||
? "rea-psp-source-owned-fixture\0"
|
||||
: "rea-mips-source-owned-fixture\0",
|
||||
).toString("hex"),
|
||||
);
|
||||
const callees = await call("procedure_callees", {
|
||||
procedure: entry.address,
|
||||
@@ -186,11 +269,47 @@ try {
|
||||
JSON.parse(await readFile(snapshotPath, "utf8")),
|
||||
);
|
||||
assert.equal(snapshot.target.architecture, "mips");
|
||||
assert.equal(
|
||||
snapshot.binding.analysis_profile.parameters.mips_elf.byte_order,
|
||||
byteOrder,
|
||||
const abi = independent.mips.abiFlags;
|
||||
assert.deepEqual(
|
||||
snapshot.binding.analysis_profile.parameters.mips_elf,
|
||||
{
|
||||
elf_class: independent.mips.elfClass,
|
||||
byte_order: independent.mips.byteOrder,
|
||||
type: independent.mips.type,
|
||||
flags: independent.mips.flags,
|
||||
abi_flags: {
|
||||
version: abi.version,
|
||||
isa_level: abi.isaLevel,
|
||||
isa_revision: abi.isaRevision,
|
||||
gpr_size: abi.gprSize,
|
||||
cpr1_size: abi.cpr1Size,
|
||||
cpr2_size: abi.cpr2Size,
|
||||
fp_abi: abi.fpAbi,
|
||||
isa_extension: abi.isaExtension,
|
||||
ases: abi.ases,
|
||||
flags1: abi.flags1,
|
||||
flags2: abi.flags2,
|
||||
},
|
||||
},
|
||||
"Snapshot profile did not retain the independently inspected ELF/ABI declaration",
|
||||
);
|
||||
assert.ok(snapshot.evidence_bundle.records.length > 0);
|
||||
if (psp) {
|
||||
assert.equal(
|
||||
snapshot.binding.analysis_profile.parameters.language_id,
|
||||
"Allegrex:LE:32:default",
|
||||
);
|
||||
assert.match(
|
||||
snapshot.binding.analysis_profile.parameters.psp_extension.sha256,
|
||||
/^[a-f0-9]{64}$/u,
|
||||
);
|
||||
assert.ok(
|
||||
snapshot.evidence_bundle.records.some((record) =>
|
||||
JSON.stringify(record).includes("VFPU prefix"),
|
||||
),
|
||||
"PSP limitations missing from retained Evidence",
|
||||
);
|
||||
}
|
||||
for (const evidence of snapshot.evidence_bundle.records)
|
||||
assert.equal(parseEvidence(evidence).subject?.architecture, "mips");
|
||||
await call("open_binary", {
|
||||
@@ -201,6 +320,52 @@ try {
|
||||
assert.deepEqual(await call("list_procedures"), procedures);
|
||||
await call("close_binary");
|
||||
|
||||
if (psp) {
|
||||
const alternate = join(workspace, "alternate");
|
||||
await mkdir(alternate);
|
||||
const other = await buildPspFixture(alternate, 11);
|
||||
assert.notEqual(other.sha256, fixture.sha256);
|
||||
assert.equal(
|
||||
other.symbols.rea_psp_global,
|
||||
fixture.symbols.rea_psp_global,
|
||||
"Target-switch control must reuse the same global address",
|
||||
);
|
||||
for (const item of [fixture, other, fixture]) {
|
||||
const selected = await call("open_binary", {
|
||||
path: item.path,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(selected.sha256, item.sha256);
|
||||
const word = await call("read_bytes", {
|
||||
address: item.symbols.rea_psp_global,
|
||||
length: 4,
|
||||
});
|
||||
const expected = Buffer.alloc(4);
|
||||
expected.writeUInt32LE(item.global_value);
|
||||
assert.equal(word.bytes_hex, expected.toString("hex"));
|
||||
await call("close_binary");
|
||||
}
|
||||
const wrongSnapshot = await client.callTool(
|
||||
{
|
||||
name: "open_binary",
|
||||
arguments: {
|
||||
path: other.path,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: snapshotPath,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
requireMcpToolError(wrongSnapshot);
|
||||
// A bad snapshot must not poison the next clean public session.
|
||||
await call("open_binary", {
|
||||
path: fixture.path,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
await call("close_binary");
|
||||
}
|
||||
|
||||
// Same public operation through CLI, not a private injected provider.
|
||||
const cli = await execute(
|
||||
process.execPath,
|
||||
@@ -208,7 +373,7 @@ try {
|
||||
entrypoint,
|
||||
"function",
|
||||
fixture.path,
|
||||
"rea_mips_entry",
|
||||
`${prefix}_entry`,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
@@ -230,103 +395,120 @@ try {
|
||||
fixture.sha256,
|
||||
"Analysis modified the source executable",
|
||||
);
|
||||
// Carved firmware keeps all program headers/load bytes but loses sections.
|
||||
const read16 = (offset) =>
|
||||
byteOrder === "little"
|
||||
? sourceBytes.readUInt16LE(offset)
|
||||
: sourceBytes.readUInt16BE(offset);
|
||||
const read32 = (offset) =>
|
||||
byteOrder === "little"
|
||||
? sourceBytes.readUInt32LE(offset)
|
||||
: sourceBytes.readUInt32BE(offset);
|
||||
const sectionOffset = read32(32);
|
||||
const cut = sectionOffset - 1;
|
||||
const programOffset = read32(28);
|
||||
const programCount = read16(44);
|
||||
assert.equal(read16(42), 32);
|
||||
assert.ok(cut >= programOffset + programCount * 32);
|
||||
for (let i = 0; i < programCount; i += 1) {
|
||||
const offset = programOffset + i * 32;
|
||||
assert.ok(read32(offset + 4) + read32(offset + 16) <= cut);
|
||||
}
|
||||
const carvedBytes = sourceBytes.subarray(0, cut);
|
||||
const carvedPath = join(workspace, `${byteOrder}-carved.elf`);
|
||||
const carvedSha256 = createHash("sha256")
|
||||
.update(carvedBytes)
|
||||
.digest("hex");
|
||||
await writeFile(carvedPath, carvedBytes);
|
||||
const carvedResolved = await parseBinaryTarget(carvedPath);
|
||||
if (!carvedResolved.ok) throw carvedResolved.error;
|
||||
assert.deepEqual(
|
||||
carvedResolved.value.mips.abiFlags,
|
||||
independent.mips.abiFlags,
|
||||
);
|
||||
assert.match(
|
||||
carvedResolved.value.mips.limitations.join(" "),
|
||||
/section header table/u,
|
||||
);
|
||||
const carvedTarget = await call("open_binary", {
|
||||
path: carvedPath,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(carvedTarget.sha256, carvedSha256);
|
||||
const carvedImage = await call("inspect_native_load_image");
|
||||
assertMipsLoadedImage(carvedImage.observations, byteOrder, carvedSha256);
|
||||
const carvedProcedures = await call("list_procedures");
|
||||
assert.ok(
|
||||
carvedProcedures.some(
|
||||
(p) =>
|
||||
BigInt(p.address) === BigInt(independent.symbols.rea_mips_entry),
|
||||
),
|
||||
);
|
||||
const carvedDossier = functionDossierSchema.parse(
|
||||
await call("analyze_function", {
|
||||
procedure: independent.symbols.rea_mips_entry,
|
||||
}),
|
||||
);
|
||||
assert.ok(carvedDossier.pseudocode.trim().length > 0);
|
||||
const carvedSnapshotPath = join(
|
||||
workspace,
|
||||
`${byteOrder}-carved.snapshot.json`,
|
||||
);
|
||||
await call("close_binary", { snapshot_path: carvedSnapshotPath });
|
||||
const carvedSnapshot = parseAnalysisSnapshot(
|
||||
JSON.parse(await readFile(carvedSnapshotPath, "utf8")),
|
||||
);
|
||||
assert.equal(carvedSnapshot.target.sha256, carvedSha256);
|
||||
assert.ok(
|
||||
carvedSnapshot.evidence_bundle.records.some((record) =>
|
||||
parseEvidence(record).limitations.some((item) =>
|
||||
// This upstream carved-firmware proof is specific to the generic MIPS fixture.
|
||||
// PSP keeps its independent Allegrex/A-B-A checks; carved PSP is not verified.
|
||||
let carved;
|
||||
if (!psp) {
|
||||
// Carved firmware keeps all program headers/load bytes but loses sections.
|
||||
const read16 = (offset) =>
|
||||
byteOrder === "little"
|
||||
? sourceBytes.readUInt16LE(offset)
|
||||
: sourceBytes.readUInt16BE(offset);
|
||||
const read32 = (offset) =>
|
||||
byteOrder === "little"
|
||||
? sourceBytes.readUInt32LE(offset)
|
||||
: sourceBytes.readUInt32BE(offset);
|
||||
const sectionOffset = read32(32);
|
||||
const cut = sectionOffset - 1;
|
||||
const programOffset = read32(28);
|
||||
const programCount = read16(44);
|
||||
assert.equal(read16(42), 32);
|
||||
assert.ok(cut >= programOffset + programCount * 32);
|
||||
for (let i = 0; i < programCount; i += 1) {
|
||||
const offset = programOffset + i * 32;
|
||||
assert.ok(read32(offset + 4) + read32(offset + 16) <= cut);
|
||||
}
|
||||
const carvedBytes = sourceBytes.subarray(0, cut);
|
||||
const carvedPath = join(workspace, `${byteOrder}-carved.elf`);
|
||||
const carvedSha256 = createHash("sha256")
|
||||
.update(carvedBytes)
|
||||
.digest("hex");
|
||||
await writeFile(carvedPath, carvedBytes);
|
||||
const carvedResolved = await parseBinaryTarget(carvedPath);
|
||||
if (!carvedResolved.ok) throw carvedResolved.error;
|
||||
assert.deepEqual(
|
||||
carvedResolved.value.mips.abiFlags,
|
||||
independent.mips.abiFlags,
|
||||
);
|
||||
assert.match(
|
||||
carvedResolved.value.mips.limitations.join(" "),
|
||||
/section header table/u,
|
||||
);
|
||||
const carvedTarget = await call("open_binary", {
|
||||
path: carvedPath,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
assert.equal(carvedTarget.sha256, carvedSha256);
|
||||
const carvedImage = await call("inspect_native_load_image");
|
||||
assertMipsLoadedImage(
|
||||
carvedImage.observations,
|
||||
byteOrder,
|
||||
carvedSha256,
|
||||
);
|
||||
const carvedProcedures = await call("list_procedures");
|
||||
assert.ok(
|
||||
carvedProcedures.some(
|
||||
(p) =>
|
||||
BigInt(p.address) === BigInt(independent.symbols.rea_mips_entry),
|
||||
),
|
||||
);
|
||||
const carvedDossier = functionDossierSchema.parse(
|
||||
await call("analyze_function", {
|
||||
procedure: independent.symbols.rea_mips_entry,
|
||||
}),
|
||||
);
|
||||
assert.ok(carvedDossier.pseudocode.trim().length > 0);
|
||||
const carvedSnapshotPath = join(
|
||||
workspace,
|
||||
`${byteOrder}-carved.snapshot.json`,
|
||||
);
|
||||
await call("close_binary", { snapshot_path: carvedSnapshotPath });
|
||||
const carvedSnapshot = parseAnalysisSnapshot(
|
||||
JSON.parse(await readFile(carvedSnapshotPath, "utf8")),
|
||||
);
|
||||
assert.equal(carvedSnapshot.target.sha256, carvedSha256);
|
||||
assert.ok(
|
||||
carvedSnapshot.evidence_bundle.records.some((record) =>
|
||||
parseEvidence(record).limitations.some((item) =>
|
||||
item.includes("section header table"),
|
||||
),
|
||||
),
|
||||
);
|
||||
const carvedCli = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"function",
|
||||
carvedPath,
|
||||
independent.symbols.rea_mips_entry,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const carvedEvidence = parseEvidence(JSON.parse(carvedCli.stdout));
|
||||
assert.equal(carvedEvidence.subject?.digest.sha256, carvedSha256);
|
||||
assert.ok(
|
||||
carvedEvidence.limitations.some((item) =>
|
||||
item.includes("section header table"),
|
||||
),
|
||||
),
|
||||
);
|
||||
const carvedCli = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"function",
|
||||
carvedPath,
|
||||
independent.symbols.rea_mips_entry,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const carvedEvidence = parseEvidence(JSON.parse(carvedCli.stdout));
|
||||
assert.equal(carvedEvidence.subject?.digest.sha256, carvedSha256);
|
||||
assert.ok(
|
||||
carvedEvidence.limitations.some((item) =>
|
||||
item.includes("section header table"),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
functionDossierSchema.parse(carvedEvidence.normalized_result).procedure
|
||||
.address,
|
||||
carvedDossier.procedure.address,
|
||||
);
|
||||
assert.deepEqual(await readFile(carvedPath), carvedBytes);
|
||||
);
|
||||
assert.equal(
|
||||
functionDossierSchema.parse(carvedEvidence.normalized_result)
|
||||
.procedure.address,
|
||||
carvedDossier.procedure.address,
|
||||
);
|
||||
assert.deepEqual(await readFile(carvedPath), carvedBytes);
|
||||
carved = {
|
||||
sha256: carvedSha256,
|
||||
source_bytes: carvedBytes.length,
|
||||
section_header_offset: sectionOffset,
|
||||
effective_language: carvedImage.observations.language_id,
|
||||
limitations: carvedResolved.value.mips.limitations,
|
||||
procedures: carvedProcedures.length,
|
||||
};
|
||||
}
|
||||
reports.push({
|
||||
...fixture,
|
||||
independent_reader: independent,
|
||||
@@ -336,18 +518,17 @@ try {
|
||||
"independent ELF/ABI and symbols",
|
||||
"loaded Ghidra identity",
|
||||
"fixed instruction bytes and immediate",
|
||||
"conditional branch destination",
|
||||
psp
|
||||
? "Allegrex BITREV decoding and A-B-A target identity"
|
||||
: "conditional branch destination",
|
||||
"global word and marker bytes",
|
||||
"CLI/MCP and snapshot identity",
|
||||
],
|
||||
procedures: procedures.length,
|
||||
carved: {
|
||||
sha256: carvedSha256,
|
||||
source_bytes: carvedBytes.length,
|
||||
section_header_offset: sectionOffset,
|
||||
effective_language: carvedImage.observations.language_id,
|
||||
limitations: carvedResolved.value.mips.limitations,
|
||||
procedures: carvedProcedures.length,
|
||||
carved: carved ?? {
|
||||
status: "not_run",
|
||||
reason:
|
||||
"Carved-firmware verification covers generic MIPS fixtures; carved PSP coverage is not established.",
|
||||
},
|
||||
status: "passed",
|
||||
});
|
||||
|
||||
@@ -13,10 +13,13 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { GhidraInstallationInspection } from "./GhidraInstallation.js";
|
||||
import { ghidraMipsProfileParameters } from "./GhidraMipsProfile.js";
|
||||
|
||||
import {
|
||||
ghidraMipsProfileParameters,
|
||||
ghidraMipsUnsupportedReason,
|
||||
} from "./GhidraMipsProfile.js";
|
||||
ghidraProcessorUnsupportedReason,
|
||||
isGhidraPspTarget,
|
||||
} from "./GhidraPspProfile.js";
|
||||
import { resolveGhidraPspExtension } from "./GhidraPspExtension.js";
|
||||
import {
|
||||
NATIVE_AOT_MAX_REPORT_BYTES,
|
||||
NATIVE_AOT_MAX_SOURCE_BYTES,
|
||||
@@ -26,7 +29,7 @@ import {
|
||||
} from "../domain/native/nativeAotPe.js";
|
||||
|
||||
/** Resolve version-bound, deterministic semantics before Ghidra imports a target. */
|
||||
export const resolveGhidraAnalysisProfile = (
|
||||
export const resolveGhidraAnalysisProfile = async (
|
||||
target: BinaryTarget,
|
||||
identity: ProviderIdentity,
|
||||
installation: GhidraInstallationInspection,
|
||||
@@ -41,7 +44,7 @@ export const resolveGhidraAnalysisProfile = (
|
||||
return Promise.resolve(
|
||||
err(new ProviderAdapterError(identity.id, "resolve_analysis_profile")),
|
||||
);
|
||||
const mipsReason = ghidraMipsUnsupportedReason(target);
|
||||
const mipsReason = ghidraProcessorUnsupportedReason(target);
|
||||
if (mipsReason !== null)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
@@ -52,6 +55,19 @@ export const resolveGhidraAnalysisProfile = (
|
||||
),
|
||||
),
|
||||
);
|
||||
// A caller-selected generic processor cannot override this verified PSP loader.
|
||||
if (isGhidraPspTarget(target) && languageOverride !== undefined)
|
||||
return err(
|
||||
new AnalysisUnsupportedTargetError(
|
||||
"resolve_analysis_profile",
|
||||
target.path,
|
||||
"The bounded PSP Allegrex profile does not accept REA_GHIDRA_LANGUAGE_ID overrides.",
|
||||
),
|
||||
);
|
||||
const pspExtension = isGhidraPspTarget(target)
|
||||
? await resolveGhidraPspExtension(installation, signal)
|
||||
: null;
|
||||
if (pspExtension !== null && !pspExtension.ok) return pspExtension;
|
||||
const provider = { ...identity, version: installation.providerVersion };
|
||||
const dosMz = target.format === "dos-mz";
|
||||
const dosCom = target.format === "dos-com";
|
||||
@@ -151,6 +167,15 @@ export const resolveGhidraAnalysisProfile = (
|
||||
: {}),
|
||||
analyzer_preset: "ghidra-default",
|
||||
...ghidraMipsProfileParameters(target),
|
||||
...(pspExtension?.ok === true
|
||||
? {
|
||||
mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2",
|
||||
loader: "PspElfLoader",
|
||||
language_id: "Allegrex:LE:32:default",
|
||||
compiler_spec_id: "default",
|
||||
psp_extension: { ...pspExtension.value },
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "./GhidraAnalysisSeeds.js";
|
||||
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { mkdir, realpath } from "node:fs/promises";
|
||||
import { basename, dirname, join, posix, win32 } from "node:path";
|
||||
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
type GhidraExtension,
|
||||
} from "./extensions/GhidraExtensions.js";
|
||||
|
||||
import {
|
||||
inspectGhidraPspExtension,
|
||||
type GhidraPspExtension,
|
||||
} from "./GhidraPspExtension.js";
|
||||
|
||||
/** Private paths and identity material for one headless Ghidra import. */
|
||||
export interface GhidraLaunchSession {
|
||||
readonly runtimeRoot: string;
|
||||
@@ -101,6 +106,7 @@ export interface GhidraHeadlessLauncherOptions {
|
||||
/** Caller seeds committed by the analysis profile, applied before analysis. */
|
||||
readonly analysisSeeds?: GhidraSeedCommitment;
|
||||
readonly analysisExtensions?: readonly GhidraExtension[];
|
||||
readonly pspExtension?: GhidraPspExtension;
|
||||
/** Spawn seam for provider-boundary lifecycle tests. */
|
||||
readonly spawnProcess?: typeof spawnOwnedProviderProcess;
|
||||
}
|
||||
@@ -125,6 +131,31 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
const platform = this.options.platform ?? process.platform;
|
||||
let started: SpawnedOwnedProviderProcess | undefined;
|
||||
try {
|
||||
const psp = this.options.pspExtension;
|
||||
if (psp !== undefined) {
|
||||
const installedRoot = await realpath(
|
||||
join(
|
||||
dirname(this.options.analyzeHeadlessPath),
|
||||
"..",
|
||||
"Ghidra",
|
||||
"Extensions",
|
||||
"ghidra-allegrex",
|
||||
),
|
||||
);
|
||||
if (installedRoot !== psp.root)
|
||||
throw new GhidraLaunchError(
|
||||
"Allegrex profile does not belong to the selected Ghidra installation.",
|
||||
);
|
||||
const inspected = await inspectGhidraPspExtension(
|
||||
psp.root,
|
||||
psp.ghidra_version,
|
||||
options.signal,
|
||||
);
|
||||
if (inspected.sha256 !== psp.sha256)
|
||||
throw new GhidraLaunchError(
|
||||
"Installed Allegrex content changed since profile resolution; reopen the target to resolve a fresh profile.",
|
||||
);
|
||||
}
|
||||
await createGhidraRuntimeDirectories(paths, platform);
|
||||
const seedPath =
|
||||
this.options.analysisSeeds === undefined
|
||||
@@ -157,6 +188,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const headlessArguments = ghidraHeadlessArguments({
|
||||
platform,
|
||||
...(psp === undefined ? {} : { psp: true as const }),
|
||||
projectRoot: paths.projectRoot,
|
||||
targetPath: session.targetPath,
|
||||
bridgeScriptPath: this.options.bridgeScriptPath,
|
||||
@@ -376,6 +408,7 @@ export interface GhidraHeadlessArgumentOptions {
|
||||
readonly descriptorPath: string;
|
||||
readonly ghidraLogPath: string;
|
||||
readonly scriptLogPath: string;
|
||||
readonly psp?: true;
|
||||
readonly dosMz?: true;
|
||||
readonly dosCom?: true;
|
||||
readonly languageOverride?: GhidraLanguageOverride;
|
||||
@@ -393,6 +426,15 @@ export const ghidraHeadlessArguments = (
|
||||
(options.platform ?? process.platform) === "win32" ? win32 : posix;
|
||||
const bridgeDirectory = scriptPath.dirname(options.bridgeScriptPath);
|
||||
const override = options.languageOverride;
|
||||
if (
|
||||
options.psp === true &&
|
||||
(options.dosMz === true ||
|
||||
options.dosCom === true ||
|
||||
override !== undefined)
|
||||
)
|
||||
throw new Error(
|
||||
"A PSP ELF import cannot be combined with DOS or a generic Ghidra language override.",
|
||||
);
|
||||
if (
|
||||
override !== undefined &&
|
||||
(options.dosMz === true || options.dosCom === true)
|
||||
@@ -425,15 +467,24 @@ export const ghidraHeadlessArguments = (
|
||||
"-cspec",
|
||||
"default",
|
||||
]
|
||||
: override !== undefined
|
||||
: options.psp === true
|
||||
? [
|
||||
"-loader",
|
||||
"PspElfLoader",
|
||||
"-processor",
|
||||
override.languageId,
|
||||
...(override.compilerSpecId === undefined
|
||||
? []
|
||||
: ["-cspec", override.compilerSpecId]),
|
||||
"Allegrex:LE:32:default",
|
||||
"-cspec",
|
||||
"default",
|
||||
]
|
||||
: []),
|
||||
: override !== undefined
|
||||
? [
|
||||
"-processor",
|
||||
override.languageId,
|
||||
...(override.compilerSpecId === undefined
|
||||
? []
|
||||
: ["-cspec", override.compilerSpecId]),
|
||||
]
|
||||
: []),
|
||||
"-readOnly",
|
||||
"-deleteProject",
|
||||
"-log",
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("bounded Ghidra MIPS admission", () => {
|
||||
[{ type: 1 }, "ET_EXEC"],
|
||||
[{ type: 3 }, "ET_EXEC"],
|
||||
[{ type: 0xffa0 }, "ET_EXEC"],
|
||||
[{ flags: 0x10a23001 }, "Machine-specific"],
|
||||
[{ flags: 0x10a23001 }, "separate MIPS ABI flags"],
|
||||
[{ flags: 0x70001021 }, "o32"],
|
||||
[{ flags: 0x70000001 }, "o32"],
|
||||
[{ flags: 0x70002001 }, "o32"],
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
admitGhidraLanguageOverride,
|
||||
readGhidraLanguageCatalog,
|
||||
} from "./GhidraLanguageCatalog.js";
|
||||
import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js";
|
||||
import {
|
||||
ghidraProcessorUnsupportedReason,
|
||||
isGhidraPspTarget,
|
||||
} from "./GhidraPspProfile.js";
|
||||
import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js";
|
||||
import {
|
||||
CAPABILITIES,
|
||||
@@ -149,7 +152,12 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
};
|
||||
if (hostPlatform === "win32")
|
||||
return inspectWindowsP0TargetSupport(target, diagnostics);
|
||||
const mipsReason = ghidraMipsUnsupportedReason(target);
|
||||
const mipsReason =
|
||||
isGhidraPspTarget(target) &&
|
||||
(hostPlatform !== "linux" ||
|
||||
(this.installationHost?.architecture ?? process.arch) !== "x64")
|
||||
? "The initial PSP Allegrex profile is verified on Linux x64 only."
|
||||
: ghidraProcessorUnsupportedReason(target);
|
||||
if (mipsReason !== null)
|
||||
return {
|
||||
status: "unsupported",
|
||||
|
||||
@@ -49,6 +49,11 @@ import {
|
||||
ghidraInstallationDiagnostics,
|
||||
type GhidraInstallationInspection,
|
||||
} from "./GhidraInstallation.js";
|
||||
import {
|
||||
isGhidraPspTarget,
|
||||
GHIDRA_PSP_LIMITATIONS,
|
||||
} from "./GhidraPspProfile.js";
|
||||
import { ghidraPspExtensionSchema } from "./GhidraPspExtension.js";
|
||||
import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js";
|
||||
import { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
|
||||
import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js";
|
||||
@@ -353,6 +358,26 @@ export const createGhidraProviderClient = (input: {
|
||||
);
|
||||
if (!prerequisites.ok) return unavailableClient(prerequisites.error);
|
||||
const committedProfile = prerequisites.value.profile;
|
||||
const psp = isGhidraPspTarget(target);
|
||||
const pspExtension = psp
|
||||
? ghidraPspExtensionSchema.safeParse(
|
||||
committedProfile.parameters.psp_extension,
|
||||
)
|
||||
: null;
|
||||
if (
|
||||
pspExtension !== null &&
|
||||
(!pspExtension.success ||
|
||||
committedProfile.parameters.language_id !== "Allegrex:LE:32:default" ||
|
||||
committedProfile.parameters.loader !== "PspElfLoader")
|
||||
)
|
||||
return unavailableClient(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
"Resolve the supported PSP profile and installed Allegrex extension before opening the session.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const extensionProfile = ghidraExtensionSchema
|
||||
.array()
|
||||
.safeParse(committedProfile.parameters.analysis_extensions ?? []);
|
||||
@@ -444,6 +469,9 @@ export const createGhidraProviderClient = (input: {
|
||||
bridgeScriptPath: fileURLToPath(
|
||||
new URL("../../bridge/ghidra/ReaGhidraBridge.java", import.meta.url),
|
||||
),
|
||||
...(pspExtension?.success === true
|
||||
? { pspExtension: pspExtension.data }
|
||||
: {}),
|
||||
...(target.format === "dos-mz" ? { dosMz: true } : {}),
|
||||
...(target.format === "dos-com" ? { dosCom: true } : {}),
|
||||
...(languageOverride === undefined ? {} : { languageOverride }),
|
||||
@@ -475,6 +503,12 @@ export const createGhidraProviderClient = (input: {
|
||||
? {}
|
||||
: { expectedCompilerSpecId: languageOverride.compilerSpecId }),
|
||||
}),
|
||||
...(psp
|
||||
? {
|
||||
expectedLanguageId: "Allegrex:LE:32:default",
|
||||
expectedCompilerSpecId: "default",
|
||||
}
|
||||
: {}),
|
||||
...(context === undefined ? {} : { runId: context.runId }),
|
||||
logger: logger.child({ layer: "ghidra-bridge" }),
|
||||
});
|
||||
@@ -510,6 +544,7 @@ export const createGhidraProviderClient = (input: {
|
||||
const sessionLimitations = (): readonly string[] => [
|
||||
...providerLimitations,
|
||||
...targetLimitations,
|
||||
...(psp ? GHIDRA_PSP_LIMITATIONS : []),
|
||||
...ghidraExtensionLimitations(extensions),
|
||||
...seedLimitations,
|
||||
...(releaseLimitation === undefined ? [] : [releaseLimitation]),
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, opendir, realpath } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
openRegularFile,
|
||||
sameRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { GhidraInstallationInspection } from "./GhidraInstallation.js";
|
||||
|
||||
/** Installed processor code and data, distinct from REA's analysis-extension ABI. */
|
||||
export const ghidraPspExtensionSchema = z.strictObject({
|
||||
id: z.literal("ghidra-allegrex"),
|
||||
root: z.string().refine(isAbsolute),
|
||||
ghidra_version: z.string().min(1),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
});
|
||||
export type GhidraPspExtension = z.infer<typeof ghidraPspExtensionSchema>;
|
||||
|
||||
// Resource bounds for scanning a caller-installed extension, not target limits.
|
||||
const MAX_ENTRIES = 1024;
|
||||
const MAX_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_DEPTH = 16;
|
||||
const REQUIRED = [
|
||||
"Module.manifest",
|
||||
"extension.properties",
|
||||
"data/languages/allegrex.ldefs",
|
||||
"data/languages/allegrex.sla",
|
||||
"data/languages/allegrex.pspec",
|
||||
"data/languages/allegrex.cspec",
|
||||
];
|
||||
|
||||
/** Read-only fingerprint of all installed files; no home scanning or installation. */
|
||||
export const inspectGhidraPspExtension = async (
|
||||
root: string,
|
||||
ghidraVersion: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<GhidraPspExtension> => {
|
||||
signal?.throwIfAborted();
|
||||
if (!isAbsolute(root) || (await realpath(root)) !== root)
|
||||
throw new Error(
|
||||
"The installed Allegrex directory must be a canonical non-symlink path.",
|
||||
);
|
||||
const files: string[] = [];
|
||||
let entries = 1; // The root itself consumes one inventory entry.
|
||||
const visit = async (relative: string, depth: number): Promise<void> => {
|
||||
signal?.throwIfAborted();
|
||||
if (depth > MAX_DEPTH)
|
||||
throw new Error(
|
||||
"Allegrex extension directory nesting exceeds the inspection limit.",
|
||||
);
|
||||
const path = join(root, relative);
|
||||
const metadata = await lstat(path);
|
||||
if (metadata.isDirectory()) {
|
||||
const children: string[] = [];
|
||||
for await (const child of await opendir(path)) {
|
||||
signal?.throwIfAborted();
|
||||
if (++entries > MAX_ENTRIES)
|
||||
throw new Error(
|
||||
"Allegrex extension inventory exceeds the inspection limit.",
|
||||
);
|
||||
children.push(child.name);
|
||||
}
|
||||
children.sort();
|
||||
for (const child of children)
|
||||
await visit(
|
||||
relative === "" ? child : `${relative}/${child}`,
|
||||
depth + 1,
|
||||
);
|
||||
} else if (metadata.isFile()) {
|
||||
files.push(relative);
|
||||
} else
|
||||
throw new Error(
|
||||
`Allegrex extension entries must be regular files or directories: ${relative}`,
|
||||
);
|
||||
};
|
||||
await visit("", 0);
|
||||
for (const required of REQUIRED)
|
||||
if (!files.includes(required))
|
||||
throw new Error(`Missing installed Allegrex component: ${required}`);
|
||||
if (!files.some((path) => path.startsWith("lib/") && path.endsWith(".jar")))
|
||||
throw new Error("The installed Allegrex extension has no loader JAR.");
|
||||
const hash = createHash("sha256");
|
||||
let bytesRead = 0;
|
||||
let properties = "";
|
||||
for (const relative of files) {
|
||||
signal?.throwIfAborted();
|
||||
const path = join(root, relative);
|
||||
const handle = await openRegularFile(path, { symlinks: "reject", signal });
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
if (
|
||||
!Number.isSafeInteger(before.size) ||
|
||||
before.size > MAX_BYTES - bytesRead
|
||||
)
|
||||
throw new Error(
|
||||
"Allegrex extension bytes exceed the inspection limit.",
|
||||
);
|
||||
if (relative === "extension.properties" && before.size > 65536)
|
||||
throw new Error(
|
||||
"Allegrex extension properties exceed the inspection limit.",
|
||||
);
|
||||
hash.update(JSON.stringify([relative, before.size]));
|
||||
const buffer = Buffer.alloc(Math.min(65536, before.size + 1));
|
||||
let offset = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
while (offset < before.size) {
|
||||
signal?.throwIfAborted();
|
||||
const read = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, before.size - offset),
|
||||
offset,
|
||||
);
|
||||
if (read.bytesRead === 0)
|
||||
throw new Error(`Truncated Allegrex component: ${relative}`);
|
||||
const chunk = buffer.subarray(0, read.bytesRead);
|
||||
hash.update(chunk);
|
||||
if (relative === "extension.properties")
|
||||
chunks.push(Buffer.from(chunk));
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
if (
|
||||
!sameRegularFileState(before, await handle.stat()) ||
|
||||
!sameRegularFileState(before, await lstat(path))
|
||||
)
|
||||
throw new Error(
|
||||
`Allegrex component changed during inspection: ${relative}`,
|
||||
);
|
||||
bytesRead += before.size;
|
||||
if (relative === "extension.properties")
|
||||
properties = Buffer.concat(chunks).toString("utf8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
// Ghidra's extension.properties version is the compatible Ghidra version,
|
||||
// not the extension release (e.g. v21.4); content identity supplies the latter.
|
||||
const property = (name: string): string => {
|
||||
const values = properties.split(/\r?\n/u).flatMap((line) => {
|
||||
const match = /^\s*([A-Za-z][A-Za-z0-9]*)\s*=\s*(.*?)\s*$/u.exec(line);
|
||||
return match?.[1] === name ? [match[2] ?? ""] : [];
|
||||
});
|
||||
if (values.length !== 1)
|
||||
throw new Error(`Allegrex extension must declare exactly one ${name}.`);
|
||||
return values[0] ?? "";
|
||||
};
|
||||
if (
|
||||
property("name") !== "ghidra-allegrex" ||
|
||||
property("version") !== ghidraVersion
|
||||
)
|
||||
throw new Error(
|
||||
`Allegrex extension metadata does not match the selected Ghidra ${ghidraVersion}; install or rebuild a compatible extension.`,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
id: "ghidra-allegrex",
|
||||
root,
|
||||
ghidra_version: ghidraVersion,
|
||||
sha256: hash.digest("hex"),
|
||||
};
|
||||
};
|
||||
|
||||
/** Resolve prerequisites before opening or admitting cached PSP analysis. */
|
||||
export const resolveGhidraPspExtension = async (
|
||||
installation: GhidraInstallationInspection,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<GhidraPspExtension, AnalysisError>> => {
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError("resolve_analysis_profile"));
|
||||
try {
|
||||
if (
|
||||
installation.status !== "available" ||
|
||||
installation.platform !== "linux" ||
|
||||
installation.architecture !== "x64"
|
||||
)
|
||||
throw new Error(
|
||||
"The initial PSP profile requires a supported Linux x64 Ghidra installation.",
|
||||
);
|
||||
const root = join(
|
||||
await realpath(installation.installDir),
|
||||
"Ghidra",
|
||||
"Extensions",
|
||||
"ghidra-allegrex",
|
||||
);
|
||||
return ok(
|
||||
await inspectGhidraPspExtension(
|
||||
root,
|
||||
installation.providerVersion,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError("resolve_analysis_profile"));
|
||||
const reason = `PSP_EXTENSION_UNAVAILABLE: ${cause instanceof Error ? cause.message : String(cause)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`;
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
"resolve_analysis_profile",
|
||||
reason,
|
||||
{ cause, userMessage: reason },
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js";
|
||||
|
||||
/** Allegrex's ELF machine declaration, not a filename or inferred instruction. */
|
||||
export const isGhidraPspTarget = (target: BinaryTarget): boolean =>
|
||||
target.kind === "executable" &&
|
||||
target.format === "elf" &&
|
||||
target.architecture === "mips" &&
|
||||
target.mips !== undefined &&
|
||||
(target.mips.flags & 0x00ff0000) === 0x00a20000;
|
||||
|
||||
/** PSP has its own verified interpretation; generic MIPS rules remain unchanged. */
|
||||
export const ghidraProcessorUnsupportedReason = (
|
||||
target: BinaryTarget,
|
||||
): string | null => {
|
||||
if (
|
||||
target.kind !== "executable" ||
|
||||
target.architecture !== "mips" ||
|
||||
!isGhidraPspTarget(target)
|
||||
)
|
||||
return ghidraMipsUnsupportedReason(target);
|
||||
const metadata = target.mips;
|
||||
if (metadata === undefined) return "PSP analysis requires ELF metadata.";
|
||||
if (metadata.elfClass !== 32 || metadata.byteOrder !== "little")
|
||||
return "The PSP Allegrex profile requires little-endian ELF32.";
|
||||
if (metadata.type !== 2)
|
||||
return "The PSP Allegrex profile supports static ET_EXEC only; PRX, shared and relocatable images require separate verification.";
|
||||
// PSPSDK emits MIPS-II / Allegrex / EABI32 with NOREORDER. Do not accept
|
||||
// unknown producer flags or treat this as proof of instruction semantics.
|
||||
if (metadata.flags !== 0x10a23001)
|
||||
return "The PSP Allegrex profile requires the verified PSPSDK ELF declaration 0x10a23001; other ISA/ABI/ASE flags are not inferred.";
|
||||
const abi = metadata.abiFlags;
|
||||
if (abi === undefined || abi === null)
|
||||
return "The PSP Allegrex profile requires an inspected separate MIPS ABI flags record; missing declarations are not inferred.";
|
||||
// PSPDEV v20261001: GNU readelf 2.44 and the 24-byte record agree on
|
||||
// MIPS II, 32-bit registers, single-precision hard float and flags1=1.
|
||||
// This is a bounded producer declaration, not a claim about VFPU semantics.
|
||||
if (
|
||||
abi.version !== 0 ||
|
||||
abi.isaLevel !== 2 ||
|
||||
abi.isaRevision !== 0 ||
|
||||
abi.gprSize !== 1 ||
|
||||
abi.cpr1Size !== 1 ||
|
||||
abi.cpr2Size !== 0 ||
|
||||
abi.fpAbi !== 2 ||
|
||||
abi.isaExtension !== 0 ||
|
||||
abi.ases !== 0 ||
|
||||
abi.flags1 !== 1 ||
|
||||
abi.flags2 !== 0
|
||||
)
|
||||
return "The separate MIPS ABI flags record does not match the verified PSP MIPS-II/32-bit/single-float declaration; other versions, register modes and extension flags require separate verification.";
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Propagated with every PSP observation, including successful decompilation. */
|
||||
export const GHIDRA_PSP_LIMITATIONS = [
|
||||
"PSP/Allegrex analysis uses the caller-installed ghidra-allegrex extension. ELF declarations and selected instruction checks are not proof of runtime semantics.",
|
||||
"ghidra-allegrex does not model VFPU prefix effects completely in decompiled output. Verify vector behavior independently; pseudocode is not an execution oracle.",
|
||||
"This PSP profile covers static ELF32 ET_EXEC only. PRX relocations, PBP/ISO containers, live overlays and PSP emulation are outside this verified lane.",
|
||||
] as const;
|
||||
@@ -0,0 +1,506 @@
|
||||
import { mkdir, rename, symlink, truncate, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import type {
|
||||
BinaryTarget,
|
||||
MipsAbiFlags,
|
||||
} from "../../../src/domain/binaryTargetTypes.js";
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
|
||||
import { ghidraProcessorUnsupportedReason } from "../../../src/ghidra/GhidraPspProfile.js";
|
||||
import { inspectGhidraPspExtension } from "../../../src/ghidra/GhidraPspExtension.js";
|
||||
import {
|
||||
GhidraHeadlessLauncher,
|
||||
ghidraHeadlessArguments,
|
||||
} from "../../../src/ghidra/GhidraLauncher.js";
|
||||
import type { GhidraInstallationHost } from "../../../src/ghidra/GhidraInstallation.js";
|
||||
|
||||
// Synthetic installation files exercise the filesystem/profile boundary, not
|
||||
// Allegrex execution. Only the optional real CLI/MCP lane proves tool behavior.
|
||||
// Independently inspected with PSPDEV v20261001 / GNU readelf 2.44.
|
||||
const pspAbiFlags: MipsAbiFlags = {
|
||||
version: 0,
|
||||
isaLevel: 2,
|
||||
isaRevision: 0,
|
||||
gprSize: 1,
|
||||
cpr1Size: 1,
|
||||
cpr2Size: 0,
|
||||
fpAbi: 2,
|
||||
isaExtension: 0,
|
||||
ases: 0,
|
||||
flags1: 1,
|
||||
flags2: 0,
|
||||
};
|
||||
const target = {
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "mips",
|
||||
availableArchitectures: ["mips"],
|
||||
path: "/caller/psp.elf",
|
||||
sha256: "a".repeat(64),
|
||||
mips: {
|
||||
elfClass: 32,
|
||||
byteOrder: "little",
|
||||
type: 2,
|
||||
flags: 0x10a23001,
|
||||
abiFlags: pspAbiFlags,
|
||||
},
|
||||
} satisfies BinaryTarget;
|
||||
const host: GhidraInstallationHost = {
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
readText: () => "application.version=12.1.3\n",
|
||||
executable: () => true,
|
||||
probeJava: () => ({
|
||||
version: "21.0.12",
|
||||
major: 21,
|
||||
home: "/jdk-21",
|
||||
bits: 64,
|
||||
runtime: "jdk",
|
||||
}),
|
||||
};
|
||||
async function installation() {
|
||||
const directory = await createTestTempDirectory("rea-psp-profile-");
|
||||
const root = join(directory, "Ghidra", "Extensions", "ghidra-allegrex");
|
||||
const contents = {
|
||||
"Module.manifest": "",
|
||||
"extension.properties": "name=ghidra-allegrex\nversion=12.1.3\n",
|
||||
"data/languages/allegrex.ldefs": "synthetic language definition",
|
||||
"data/languages/allegrex.sla": "synthetic compiled processor",
|
||||
"data/languages/allegrex.pspec": "synthetic processor specification",
|
||||
"data/languages/allegrex.cspec": "synthetic compiler specification",
|
||||
"lib/extension.jar": "synthetic loader artifact",
|
||||
};
|
||||
for (const [path, text] of Object.entries(contents)) {
|
||||
await mkdir(dirname(join(root, path)), { recursive: true });
|
||||
await writeFile(join(root, path), text);
|
||||
}
|
||||
await mkdir(join(directory, "support"));
|
||||
const parsed = parseConfig({ GHIDRA_INSTALL_DIR: directory });
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
return {
|
||||
directory,
|
||||
root,
|
||||
config: parsed.value,
|
||||
provider: new GhidraProvider(parsed.value, silentLogger, {}, host),
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP installed-extension and profile boundary",
|
||||
() => {
|
||||
it("binds source interpretation and installed content, not mtimes or discovery order", async () => {
|
||||
const { provider, root } = await installation();
|
||||
expect(provider.inspectTargetSupport(target).status).toBe("supported");
|
||||
const first = await provider.resolveAnalysisProfile(target);
|
||||
if (!first.ok) throw first.error;
|
||||
const again = await provider.resolveAnalysisProfile(target);
|
||||
if (!again.ok) throw again.error;
|
||||
expect(again.value.profile?.digest).toBe(first.value.profile?.digest);
|
||||
expect(first.value.profile?.parameters).toMatchObject({
|
||||
architecture: "mips",
|
||||
language_id: "Allegrex:LE:32:default",
|
||||
compiler_spec_id: "default",
|
||||
loader: "PspElfLoader",
|
||||
mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2",
|
||||
mips_elf: {
|
||||
elf_class: 32,
|
||||
byte_order: "little",
|
||||
type: 2,
|
||||
flags: 0x10a23001,
|
||||
abi_flags: {
|
||||
version: 0,
|
||||
isa_level: 2,
|
||||
isa_revision: 0,
|
||||
gpr_size: 1,
|
||||
cpr1_size: 1,
|
||||
cpr2_size: 0,
|
||||
fp_abi: 2,
|
||||
isa_extension: 0,
|
||||
ases: 0,
|
||||
flags1: 1,
|
||||
flags2: 0,
|
||||
},
|
||||
},
|
||||
psp_extension: {
|
||||
id: "ghidra-allegrex",
|
||||
root,
|
||||
ghidra_version: "12.1.3",
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
},
|
||||
});
|
||||
await writeFile(
|
||||
join(root, "data/languages/allegrex.sla"),
|
||||
"changed processor semantics",
|
||||
);
|
||||
const changed = await provider.resolveAnalysisProfile(target);
|
||||
if (!changed.ok) throw changed.error;
|
||||
expect(changed.value.profile?.digest).not.toBe(
|
||||
first.value.profile?.digest,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails before starting a provider after resolved extension bytes change", async () => {
|
||||
const { directory, root } = await installation();
|
||||
const extension = await inspectGhidraPspExtension(root, "12.1.3");
|
||||
await writeFile(join(root, "lib/extension.jar"), "changed loader");
|
||||
const spawn = vi.fn();
|
||||
const launcher = new GhidraHeadlessLauncher({
|
||||
environment: {},
|
||||
analyzeHeadlessPath: join(directory, "support/analyzeHeadless"),
|
||||
javaHome: "/jdk-21",
|
||||
bridgeScriptPath: "/packaged/ReaGhidraBridge.java",
|
||||
pspExtension: extension,
|
||||
spawnProcess: spawn,
|
||||
});
|
||||
const launched = await launcher.launch({
|
||||
runtimeRoot: join(directory, "runtime"),
|
||||
transport: "unix-socket",
|
||||
endpointPath: join(directory, "socket"),
|
||||
token: "test-only-token",
|
||||
runId: "00000000-0000-4000-8000-000000000001",
|
||||
targetPath: target.path,
|
||||
targetSha256: target.sha256,
|
||||
providerVersion: "12.1.3",
|
||||
profileDigest: "b".repeat(64),
|
||||
});
|
||||
expect(launched.ok).toBe(false);
|
||||
if (launched.ok)
|
||||
throw new Error("Changed extension reached provider startup");
|
||||
expect(launched.error.cause).toBeInstanceOf(Error);
|
||||
expect(String(launched.error.cause)).toContain(
|
||||
"changed since profile resolution",
|
||||
);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a fresh extension when a saved profile's installation has disappeared", async () => {
|
||||
const { provider, root } = await installation();
|
||||
const before = await provider.resolveAnalysisProfile(target);
|
||||
if (!before.ok) throw before.error;
|
||||
await rename(root, `${root}-removed`);
|
||||
const missing = await provider.resolveAnalysisProfile(target);
|
||||
expect(missing.ok).toBe(false);
|
||||
if (missing.ok) throw new Error("Missing installation was accepted");
|
||||
expect(missing.error.message).toContain("PSP_EXTENSION_UNAVAILABLE");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["name=other\nversion=12.1.3\n", "does not match"],
|
||||
["name=ghidra-allegrex\nversion=12.1.4\n", "does not match"],
|
||||
[
|
||||
"name=ghidra-allegrex\nversion=12.1.3\nversion=12.1.3\n",
|
||||
"exactly one version",
|
||||
],
|
||||
])(
|
||||
"rejects incompatible or ambiguous extension metadata %s",
|
||||
async (properties, reason) => {
|
||||
const { root } = await installation();
|
||||
await writeFile(join(root, "extension.properties"), properties);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
reason,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not treat an uninstall marker or missing compiled language as installed", async () => {
|
||||
const { root } = await installation();
|
||||
await rename(
|
||||
join(root, "Module.manifest"),
|
||||
join(root, "Module.manifest.uninstalled"),
|
||||
);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"Module.manifest",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects symlinked code instead of hashing an untracked external producer", async () => {
|
||||
const { root, directory } = await installation();
|
||||
const path = join(root, "lib/extension.jar");
|
||||
await rename(path, join(directory, "external.jar"));
|
||||
await symlink(join(directory, "external.jar"), path);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"regular files",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds sparse extension input before reading or allocating its declared extent", async () => {
|
||||
const { root } = await installation();
|
||||
await truncate(join(root, "lib/extension.jar"), 64 * 1024 * 1024 + 1);
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"inspection limit",
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP extension inventory bounds",
|
||||
() => {
|
||||
it.each([1, 2])(
|
||||
"bounds the total inventory across %i directories",
|
||||
async (directories) => {
|
||||
const { root } = await installation();
|
||||
for (let directory = 0; directory < directories; directory++) {
|
||||
const path = join(root, `inventory-${directory}`);
|
||||
await mkdir(path);
|
||||
await Promise.all(
|
||||
Array.from({ length: 1024 / directories }, (_, index) =>
|
||||
writeFile(join(path, `entry-${index}`), ""),
|
||||
),
|
||||
);
|
||||
}
|
||||
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
|
||||
"inventory exceeds the inspection limit",
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP admission failures and explicit loader",
|
||||
() => {
|
||||
it("preserves already-aborted cancellation rather than reporting missing prerequisites", async () => {
|
||||
const { provider } = await installation();
|
||||
const result = await provider.resolveAnalysisProfile(target, {
|
||||
signal: AbortSignal.abort(),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Cancellation was ignored");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
|
||||
it("does not broaden the separately verified host platforms", async () => {
|
||||
const { config } = await installation();
|
||||
for (const platform of ["darwin", "win32"] as const) {
|
||||
const provider = new GhidraProvider(
|
||||
config,
|
||||
silentLogger,
|
||||
{},
|
||||
{ ...host, platform },
|
||||
);
|
||||
expect(provider.inspectTargetSupport(target).status).toBe(
|
||||
"unsupported",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ byteOrder: "big" }, "little-endian"],
|
||||
[{ elfClass: 64 }, "ELF32"],
|
||||
[{ type: 0xffa0 }, "ET_EXEC"],
|
||||
[{ flags: 0x12a23001 }, "0x10a23001"],
|
||||
[{ flags: 0x10a23021 }, "0x10a23001"],
|
||||
])(
|
||||
"refuses unverified PSP declarations %j before extension reads",
|
||||
async (change, reason) => {
|
||||
const { provider } = await installation();
|
||||
const altered = {
|
||||
...target,
|
||||
mips: { ...target.mips, ...change },
|
||||
} as BinaryTarget;
|
||||
expect(ghidraProcessorUnsupportedReason(altered)).toContain(reason);
|
||||
expect(provider.inspectTargetSupport(altered).status).toBe(
|
||||
"unsupported",
|
||||
);
|
||||
const result = await provider.resolveAnalysisProfile(altered);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Invalid target was admitted");
|
||||
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP inspected ABI declaration boundary",
|
||||
() => {
|
||||
it("retains independently inspected PSP ABI bytes through the real target reader", async () => {
|
||||
const { directory, provider } = await installation();
|
||||
const abiOffset = 8192;
|
||||
const bytes = Buffer.alloc(abiOffset + 24);
|
||||
bytes.set([0x7f, 0x45, 0x4c, 0x46, 1, 1, 1]);
|
||||
bytes.writeUInt16LE(2, 16);
|
||||
bytes.writeUInt16LE(8, 18);
|
||||
bytes.writeUInt32LE(1, 20);
|
||||
bytes.writeUInt32LE(52, 28);
|
||||
bytes.writeUInt32LE(0x10a23001, 36);
|
||||
bytes.writeUInt16LE(52, 40);
|
||||
bytes.writeUInt16LE(32, 42);
|
||||
bytes.writeUInt16LE(1, 44);
|
||||
bytes.writeUInt32LE(0x70000003, 52);
|
||||
bytes.writeUInt32LE(abiOffset, 56);
|
||||
bytes.writeUInt32LE(24, 68);
|
||||
bytes.writeUInt32LE(24, 72);
|
||||
// GNU readelf -x .MIPS.abiflags, not bytes generated by REA.
|
||||
bytes.set(
|
||||
Buffer.from("000002000101000200000000000000000100000000000000", "hex"),
|
||||
abiOffset,
|
||||
);
|
||||
const path = join(directory, "psp-with-abi.elf");
|
||||
await writeFile(path, bytes);
|
||||
const parsed = await parseBinaryTarget(path);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
if (
|
||||
parsed.value.kind !== "executable" ||
|
||||
parsed.value.architecture !== "mips"
|
||||
)
|
||||
throw new Error("PSP ELF was not recognized as MIPS");
|
||||
expect(parsed.value.mips.abiFlags).toEqual(pspAbiFlags);
|
||||
expect(provider.inspectTargetSupport(parsed.value).status).toBe(
|
||||
"supported",
|
||||
);
|
||||
const profile = await provider.resolveAnalysisProfile(parsed.value);
|
||||
if (!profile.ok) throw profile.error;
|
||||
expect(profile.value.profile?.parameters.mips_elf).toMatchObject({
|
||||
flags: 0x10a23001,
|
||||
abi_flags: { isa_level: 2, fp_abi: 2, flags1: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["version", 1],
|
||||
["isaLevel", 32],
|
||||
["isaRevision", 1],
|
||||
["gprSize", 2],
|
||||
["cpr1Size", 2],
|
||||
["cpr2Size", 1],
|
||||
["fpAbi", 1],
|
||||
["fpAbi", 3],
|
||||
["fpAbi", 5],
|
||||
["isaExtension", 1],
|
||||
["ases", 1],
|
||||
["flags1", 0],
|
||||
["flags1", 3],
|
||||
["flags2", 1],
|
||||
] satisfies [keyof MipsAbiFlags, number][])(
|
||||
"rejects unverified PSP ABI field %s=%i before extension discovery",
|
||||
async (field, value) => {
|
||||
const { provider, root } = await installation();
|
||||
await rename(root, `${root}-unavailable`);
|
||||
const altered: Extract<BinaryTarget, { architecture: "mips" }> = {
|
||||
...target,
|
||||
mips: {
|
||||
elfClass: 32,
|
||||
byteOrder: "little",
|
||||
type: 2,
|
||||
flags: 0x10a23001,
|
||||
abiFlags: { ...pspAbiFlags, [field]: value },
|
||||
},
|
||||
};
|
||||
expect(provider.inspectTargetSupport(altered).status).toBe(
|
||||
"unsupported",
|
||||
);
|
||||
const result = await provider.resolveAnalysisProfile(altered);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Invalid ABI was admitted");
|
||||
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
|
||||
expect(result.error.message).toContain(
|
||||
"separate MIPS ABI flags record",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, null])(
|
||||
"does not guess an uninspected or absent PSP ABI record: %s",
|
||||
async (abiFlags) => {
|
||||
const { provider, root } = await installation();
|
||||
await rename(root, `${root}-unavailable`);
|
||||
const altered: Extract<BinaryTarget, { architecture: "mips" }> = {
|
||||
...target,
|
||||
mips: {
|
||||
elfClass: 32,
|
||||
byteOrder: "little",
|
||||
type: 2,
|
||||
flags: 0x10a23001,
|
||||
...(abiFlags === undefined ? {} : { abiFlags }),
|
||||
},
|
||||
};
|
||||
const result = await provider.resolveAnalysisProfile(altered);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Missing ABI was inferred");
|
||||
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
|
||||
expect(result.error.message).toContain(
|
||||
"inspected separate MIPS ABI flags record",
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"PSP explicit loader boundary",
|
||||
() => {
|
||||
it("rejects mixing the PSP loader with a configured generic processor", async () => {
|
||||
const { directory } = await installation();
|
||||
const config = parseConfig({
|
||||
GHIDRA_INSTALL_DIR: directory,
|
||||
REA_GHIDRA_LANGUAGE_ID: "x86:LE:64:default",
|
||||
});
|
||||
if (!config.ok) throw config.error;
|
||||
const provider = new GhidraProvider(config.value, silentLogger, {}, host);
|
||||
const result = await provider.resolveAnalysisProfile(target);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok)
|
||||
throw new Error("Conflicting language override was accepted");
|
||||
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
|
||||
expect(result.error.message).toContain(
|
||||
"does not accept REA_GHIDRA_LANGUAGE_ID overrides",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps PSP and generic language-override launch paths mutually exclusive", () => {
|
||||
const paths = {
|
||||
projectRoot: "/project",
|
||||
targetPath: "/target.elf",
|
||||
bridgeScriptPath: "/bridge/Rea.java",
|
||||
descriptorPath: "/session",
|
||||
ghidraLogPath: "/log",
|
||||
scriptLogPath: "/scriptlog",
|
||||
};
|
||||
const languageOverride = { languageId: "x86:LE:64:default" };
|
||||
expect(() =>
|
||||
ghidraHeadlessArguments({ ...paths, psp: true, languageOverride }),
|
||||
).toThrow("PSP ELF import cannot be combined");
|
||||
expect(ghidraHeadlessArguments({ ...paths, languageOverride })).toEqual(
|
||||
expect.arrayContaining(["-processor", "x86:LE:64:default"]),
|
||||
);
|
||||
expect(ghidraHeadlessArguments({ ...paths, psp: true })).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-loader",
|
||||
"PspElfLoader",
|
||||
"-processor",
|
||||
"Allegrex:LE:32:default",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("forces the PSP loader/language only for the selected profile", () => {
|
||||
const paths = {
|
||||
projectRoot: "/project",
|
||||
targetPath: "/target.elf",
|
||||
bridgeScriptPath: "/bridge/Rea.java",
|
||||
descriptorPath: "/session",
|
||||
ghidraLogPath: "/log",
|
||||
scriptLogPath: "/scriptlog",
|
||||
};
|
||||
expect(ghidraHeadlessArguments({ ...paths, psp: true })).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-loader",
|
||||
"PspElfLoader",
|
||||
"-processor",
|
||||
"Allegrex:LE:32:default",
|
||||
"-cspec",
|
||||
"default",
|
||||
]),
|
||||
);
|
||||
expect(ghidraHeadlessArguments(paths)).not.toContain(
|
||||
"Allegrex:LE:32:default",
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { it } from "vitest";
|
||||
import { parsePspReadelfAbi } from "./psp-fixture.mjs";
|
||||
|
||||
// GNU readelf 2.44 output from the maintained PSPDEV v20261001 fixture.
|
||||
const observed = `MIPS ABI Flags Version: 0
|
||||
ISA: MIPS2
|
||||
GPR size: 32
|
||||
CPR1 size: 32
|
||||
CPR2 size: 0
|
||||
FP ABI: Hard float (single precision)
|
||||
ISA Extension: None
|
||||
ASEs:
|
||||
None
|
||||
FLAGS 1: 00000001
|
||||
FLAGS 2: 00000000
|
||||
`;
|
||||
|
||||
it("reads the independent PSP producer's single-float declaration", () => {
|
||||
assert.deepEqual(parsePspReadelfAbi(observed), {
|
||||
version: 0,
|
||||
isaLevel: 2,
|
||||
isaRevision: 0,
|
||||
gprSize: 1,
|
||||
cpr1Size: 1,
|
||||
cpr2Size: 0,
|
||||
fpAbi: 2,
|
||||
isaExtension: 0,
|
||||
ases: 0,
|
||||
flags1: 1,
|
||||
flags2: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ISA: MIPS2", "ISA: MIPS32r2"],
|
||||
["GPR size: 32", "GPR size: 64"],
|
||||
["Hard float (single precision)", "Hard float (double precision)"],
|
||||
["FLAGS 1: 00000001", "FLAGS 1: 00000003"],
|
||||
["FLAGS 2: 00000000", "FLAGS 2: 00000001"],
|
||||
["MIPS ABI Flags Version: 0", "MIPS ABI Flags Version: 1"],
|
||||
["CPR1 size: 32", "CPR1 size: 0"],
|
||||
["ISA Extension: None", "ISA Extension: R5900"],
|
||||
["GPR size: 32", "GPR size: 32\nGPR size: 32"],
|
||||
["FLAGS 1: 00000001", ""],
|
||||
])(
|
||||
"rejects unsupported or ambiguous independent ABI output: %s",
|
||||
(before, after) => {
|
||||
assert.throws(() => parsePspReadelfAbi(observed.replace(before, after)));
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { MipsAbiFlags } from "../../../src/domain/binaryTargetTypes.js";
|
||||
|
||||
export declare function parsePspReadelfAbi(text: string): MipsAbiFlags;
|
||||
@@ -0,0 +1,212 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
/** Build the maintained, non-PRX PSPSDK fixture, without executing the target. */
|
||||
export async function buildPspFixture(directory, globalValue = 7) {
|
||||
assert.ok(globalValue === 7 || globalValue === 11);
|
||||
const options = {
|
||||
cwd: directory,
|
||||
env: { ...process.env, LC_ALL: "C", LANG: "C" },
|
||||
timeout: 10000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
};
|
||||
const versions = {};
|
||||
for (const command of ["make", "psp-gcc", "psp-readelf", "psp-objdump"])
|
||||
versions[command] = (
|
||||
await execute(command, ["--version"], options)
|
||||
).stdout.trim();
|
||||
const sourceDigests = {};
|
||||
for (const name of ["main.c", "probe.S", "Makefile"]) {
|
||||
const source = new URL(`../psp/${name}`, import.meta.url);
|
||||
sourceDigests[name] = sha256(await readFile(source));
|
||||
await copyFile(source, join(directory, name));
|
||||
}
|
||||
const build = await execute(
|
||||
"make",
|
||||
[`REA_PSP_GLOBAL=${globalValue}`, "rea_psp_fixture.elf"],
|
||||
{ ...options, timeout: 60000 },
|
||||
);
|
||||
const path = join(directory, "rea_psp_fixture.elf");
|
||||
const bytes = await readFile(path);
|
||||
assert.ok(bytes.length >= 52);
|
||||
assert.equal(bytes.toString("hex", 0, 6), "7f454c460101");
|
||||
assert.equal(bytes.readUInt16LE(16), 2);
|
||||
assert.equal(bytes.readUInt16LE(18), 8);
|
||||
assert.equal(bytes.readUInt32LE(36), 0x10a23001);
|
||||
const readelf = await execute(
|
||||
"psp-readelf",
|
||||
["-h", "-A", "-sW", path],
|
||||
options,
|
||||
);
|
||||
assert.equal(readelf.stderr.trim(), "");
|
||||
assert.match(readelf.stdout, /Class:\s+ELF32/u);
|
||||
assert.match(readelf.stdout, /Data:\s+2's complement, little endian/u);
|
||||
assert.match(readelf.stdout, /Type:\s+EXEC\b/u);
|
||||
assert.match(readelf.stdout, /Flags:\s+0x10a23001\b/u);
|
||||
const abiFlags = parsePspReadelfAbi(readelf.stdout);
|
||||
const abiDump = await execute(
|
||||
"psp-readelf",
|
||||
["-x", ".MIPS.abiflags", path],
|
||||
options,
|
||||
);
|
||||
assert.equal(abiDump.stderr.trim(), "");
|
||||
const abiWords = abiDump.stdout
|
||||
.split("\n")
|
||||
.filter((line) => /^\s*0x[0-9a-f]+\s/u.test(line))
|
||||
.flatMap((line) => line.match(/\b[0-9a-f]{8}\b/gu) ?? []);
|
||||
assert.equal(
|
||||
abiWords.join(""),
|
||||
"000002000101000200000000000000000100000000000000",
|
||||
"PSPSDK ABI bytes differ from independently inspected build intent",
|
||||
);
|
||||
const symbols = {};
|
||||
for (const [name, type] of [
|
||||
["rea_psp_entry", "FUNC"],
|
||||
["rea_psp_leaf", "FUNC"],
|
||||
["rea_psp_probe", "FUNC"],
|
||||
["rea_psp_global", "OBJECT"],
|
||||
["rea_psp_marker", "OBJECT"],
|
||||
]) {
|
||||
const rows = readelf.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(/\s+/u))
|
||||
.filter((row) => row.length === 8 && row[7] === name);
|
||||
assert.equal(rows.length, 1, `Expected one defined PSP symbol: ${name}`);
|
||||
const row = rows[0];
|
||||
assert.match(row[1], /^[0-9a-fA-F]{8}$/u);
|
||||
assert.equal(row[3], type);
|
||||
assert.equal(row[4], "GLOBAL");
|
||||
assert.match(row[6], /^\d+$/u);
|
||||
symbols[name] = `0x${Number.parseInt(row[1], 16).toString(16)}`;
|
||||
}
|
||||
const objdump = await execute(
|
||||
"psp-objdump",
|
||||
["-d", "--disassemble=rea_psp_probe", path],
|
||||
options,
|
||||
);
|
||||
assert.equal(objdump.stderr.trim(), "");
|
||||
assert.match(objdump.stdout, /\bbitrev\b/u);
|
||||
return {
|
||||
path,
|
||||
sha256: sha256(bytes),
|
||||
source_sha256: sourceDigests,
|
||||
symbols,
|
||||
flags: "0x10a23001",
|
||||
mips: {
|
||||
elfClass: 32,
|
||||
byteOrder: "little",
|
||||
type: 2,
|
||||
flags: 0x10a23001,
|
||||
abiFlags,
|
||||
},
|
||||
abi_record_hex_dump: abiDump.stdout,
|
||||
global_value: globalValue,
|
||||
versions,
|
||||
readelf: readelf.stdout,
|
||||
objdump: objdump.stdout,
|
||||
build_stderr: build.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
/** Independent GNU fields for the pinned PSPSDK fixture, not REA's parser. */
|
||||
export function parsePspReadelfAbi(text) {
|
||||
const field = (expression, label) => {
|
||||
const matches = [...text.matchAll(expression)];
|
||||
assert.equal(
|
||||
matches.length,
|
||||
1,
|
||||
`Missing or ambiguous PSP readelf ${label}`,
|
||||
);
|
||||
return matches[0][1].trim();
|
||||
};
|
||||
const width = (name) => {
|
||||
const bits = Number(
|
||||
field(new RegExp(`^${name} size:\\s*(\\d+)\\s*$`, "gmu"), name),
|
||||
);
|
||||
assert.ok(bits === 0 || bits === 32, `Unverified PSP ${name} width`);
|
||||
return bits === 32 ? 1 : 0;
|
||||
};
|
||||
const isaLevel = Number(field(/^ISA:\s*MIPS(\d+)\s*$/gmu, "ISA"));
|
||||
assert.equal(
|
||||
field(/^FP ABI:\s*(.+)$/gmu, "FP ABI"),
|
||||
"Hard float (single precision)",
|
||||
);
|
||||
assert.equal(field(/^ISA Extension:\s*(.+)$/gmu, "ISA extension"), "None");
|
||||
assert.equal(field(/^ASEs:\s*\n[ \t]*(.+)$/gmu, "ASEs"), "None");
|
||||
const abi = {
|
||||
version: Number(
|
||||
field(/^MIPS ABI Flags Version:\s*(\d+)\s*$/gmu, "ABI version"),
|
||||
),
|
||||
isaLevel,
|
||||
isaRevision: 0,
|
||||
gprSize: width("GPR"),
|
||||
cpr1Size: width("CPR1"),
|
||||
cpr2Size: width("CPR2"),
|
||||
fpAbi: 2,
|
||||
isaExtension: 0,
|
||||
ases: 0,
|
||||
flags1: Number.parseInt(
|
||||
field(/^FLAGS 1:\s*([\da-f]+)\s*$/gmu, "flags1"),
|
||||
16,
|
||||
),
|
||||
flags2: Number.parseInt(
|
||||
field(/^FLAGS 2:\s*([\da-f]+)\s*$/gmu, "flags2"),
|
||||
16,
|
||||
),
|
||||
};
|
||||
assert.deepEqual(
|
||||
abi,
|
||||
{
|
||||
version: 0,
|
||||
isaLevel: 2,
|
||||
isaRevision: 0,
|
||||
gprSize: 1,
|
||||
cpr1Size: 1,
|
||||
cpr2Size: 0,
|
||||
fpAbi: 2,
|
||||
isaExtension: 0,
|
||||
ases: 0,
|
||||
flags1: 1,
|
||||
flags2: 0,
|
||||
},
|
||||
"PSPSDK ABI declaration differs from independently inspected build intent",
|
||||
);
|
||||
return abi;
|
||||
}
|
||||
|
||||
/** Observations from Ghidra, not an echo of the requested profile. */
|
||||
export function assertPspLoadedImage(image, targetSha256) {
|
||||
assert.equal(image.executable_format, "PSP Executable (ELF)");
|
||||
assert.equal(image.language_id, "Allegrex:LE:32:default");
|
||||
assert.equal(image.compiler_spec_id, "default");
|
||||
assert.equal(image.source_files.length, 1);
|
||||
assert.equal(image.source_files[0].original_sha256, targetSha256);
|
||||
assert.equal(image.source_files[0].modified_sha256, targetSha256);
|
||||
}
|
||||
|
||||
/** Fixed source encodings and independently chosen immediate, not pseudocode. */
|
||||
export function assertPspProbe(move, bitrev) {
|
||||
assert.equal(move.status, "decoded");
|
||||
assert.equal(move.length, 4);
|
||||
assert.equal(move.bytes, "34120224");
|
||||
assert.ok(
|
||||
move.operands.some((operand) =>
|
||||
operand.components.some(
|
||||
(part) =>
|
||||
part.kind === "immediate" &&
|
||||
part.value !== null &&
|
||||
BigInt(part.value) === 0x1234n,
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.equal(bitrev.status, "decoded");
|
||||
assert.equal(bitrev.length, 4);
|
||||
assert.match(bitrev.mnemonic, /^bitrev$/iu);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
TARGET = rea_psp_fixture
|
||||
OBJS = main.o probe.o
|
||||
BUILD_PRX = 0
|
||||
REA_PSP_GLOBAL ?= 7
|
||||
CFLAGS = -O0 -g -G0 -fno-inline -fno-builtin -DREA_PSP_GLOBAL=$(REA_PSP_GLOBAL)
|
||||
ASFLAGS = -g
|
||||
PSPSDK := $(shell psp-config --pspsdk-path)
|
||||
include $(PSPSDK)/lib/build.mak
|
||||
@@ -0,0 +1,20 @@
|
||||
#include <pspkernel.h>
|
||||
|
||||
PSP_MODULE_INFO("rea_psp_fixture", 0, 1, 0);
|
||||
#ifndef REA_PSP_GLOBAL
|
||||
#define REA_PSP_GLOBAL 7
|
||||
#endif
|
||||
volatile unsigned int rea_psp_global = REA_PSP_GLOBAL;
|
||||
const char rea_psp_marker[] = "rea-psp-source-owned-fixture";
|
||||
unsigned int rea_psp_probe(void);
|
||||
|
||||
__attribute__((noinline)) unsigned int rea_psp_leaf(unsigned int value) {
|
||||
return value * 3u + rea_psp_global;
|
||||
}
|
||||
__attribute__((noinline)) unsigned int rea_psp_entry(unsigned int selector) {
|
||||
unsigned int adjusted = selector > 10u ? selector : selector + 1u;
|
||||
return rea_psp_leaf(adjusted) + rea_psp_probe();
|
||||
}
|
||||
int main(void) {
|
||||
return (int)rea_psp_entry(11u);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* Source-owned Allegrex decoding probe. This fixture is never executed. */
|
||||
.set noreorder
|
||||
.text
|
||||
.align 2
|
||||
.globl rea_psp_probe
|
||||
.type rea_psp_probe, @function
|
||||
.ent rea_psp_probe
|
||||
rea_psp_probe:
|
||||
addiu $v0, $zero, 0x1234
|
||||
bitrev $v0, $v0
|
||||
jr $ra
|
||||
nop
|
||||
.end rea_psp_probe
|
||||
.size rea_psp_probe, .-rea_psp_probe
|
||||
Reference in New Issue
Block a user