diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index c4cf9c974f..3c5bf0df7a 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -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 diff --git a/docs/ghidra-psp.md b/docs/ghidra-psp.md new file mode 100644 index 0000000000..52938d5eee --- /dev/null +++ b/docs/ghidra-psp.md @@ -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. diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index 617c05cc90..95d81c4eb0 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -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", }); diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 4177a8495c..81e9a3e853 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -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 }, + } + : {}), }), }), ); diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index a27b167023..e203030e57 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -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", diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index 698a4cf630..c11b574752 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -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"], diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 3ee9a6210f..711fd6c655 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -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", diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 15eb5dbd93..dd172cd776 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -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]), diff --git a/src/ghidra/GhidraPspExtension.ts b/src/ghidra/GhidraPspExtension.ts new file mode 100644 index 0000000000..1a3769378d --- /dev/null +++ b/src/ghidra/GhidraPspExtension.ts @@ -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; + +// 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 => { + 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 => { + 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> => { + 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 }, + ), + ); + } +}; diff --git a/src/ghidra/GhidraPspProfile.ts b/src/ghidra/GhidraPspProfile.ts new file mode 100644 index 0000000000..b1ee12fe55 --- /dev/null +++ b/src/ghidra/GhidraPspProfile.ts @@ -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; diff --git a/tests/boundary/filesystem/ghidraPspExtension.test.ts b/tests/boundary/filesystem/ghidraPspExtension.test.ts new file mode 100644 index 0000000000..88e25939a8 --- /dev/null +++ b/tests/boundary/filesystem/ghidraPspExtension.test.ts @@ -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 = { + ...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 = { + ...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", + ); + }); + }, +); diff --git a/tests/conformance/ghidra/psp-checks.test.ts b/tests/conformance/ghidra/psp-checks.test.ts new file mode 100644 index 0000000000..36e067021a --- /dev/null +++ b/tests/conformance/ghidra/psp-checks.test.ts @@ -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))); + }, +); diff --git a/tests/conformance/ghidra/psp-fixture.d.mts b/tests/conformance/ghidra/psp-fixture.d.mts new file mode 100644 index 0000000000..b29757365f --- /dev/null +++ b/tests/conformance/ghidra/psp-fixture.d.mts @@ -0,0 +1,3 @@ +import type { MipsAbiFlags } from "../../../src/domain/binaryTargetTypes.js"; + +export declare function parsePspReadelfAbi(text: string): MipsAbiFlags; diff --git a/tests/conformance/ghidra/psp-fixture.mjs b/tests/conformance/ghidra/psp-fixture.mjs new file mode 100644 index 0000000000..db3c25861a --- /dev/null +++ b/tests/conformance/ghidra/psp-fixture.mjs @@ -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); +} diff --git a/tests/conformance/psp/Makefile b/tests/conformance/psp/Makefile new file mode 100644 index 0000000000..bf253ab705 --- /dev/null +++ b/tests/conformance/psp/Makefile @@ -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 diff --git a/tests/conformance/psp/main.c b/tests/conformance/psp/main.c new file mode 100644 index 0000000000..ae7b38e319 --- /dev/null +++ b/tests/conformance/psp/main.c @@ -0,0 +1,20 @@ +#include + +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); +} diff --git a/tests/conformance/psp/probe.S b/tests/conformance/psp/probe.S new file mode 100644 index 0000000000..43da2c17ab --- /dev/null +++ b/tests/conformance/psp/probe.S @@ -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