diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md new file mode 100644 index 000000000..51f673abb --- /dev/null +++ b/docs/ghidra-mips.md @@ -0,0 +1,100 @@ +# MIPS ELF analysis through Ghidra + +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 +[#1330](https://github.com/morluto/rea/issues/1330). + +## Admission boundary + +ELF `EM_MIPS` is retained as the provider-neutral `mips` family. The target also +retains class, byte order, `e_type`, raw `e_flags`, and inspected ABI declarations. +Recognition does not mean every provider or MIPS variant is supported. Existing +PE, managed and Mach-O architecture sets are unchanged. + +The first Ghidra lane admits ELF32 `ET_EXEC`, little or big endian, with explicit +o32 and `EF_MIPS_ARCH_32R2` flags and no machine-specific or ASE encoding flags. +These are header declarations, not proof that every instruction conforms to +that ISA. ELF64, n32/n64, unspecified ABI, MIPS16/microMIPS, machine-specific +variants, shared/relocatable objects and PSP PRX are not covered by this lane. +Their family may be identified, but provider selection returns the specific +unsupported constraint rather than guessing a compatible profile. + +Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their +absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and +soft-float targets. Target resolution also reads the 24-byte ABI flags record +identified by `PT_MIPS_ABIFLAGS` and/or `SHT_MIPS_ABIFLAGS`, without relying on +section names or the record being inside the initial 4 KiB header probe. +Program and section declarations must agree; duplicate, conflicting, truncated +or out-of-file records are rejected. Stripped section tables and section-only +records are supported. Extended table numbering is explicitly unsupported. +The field layout follows +[`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h). + +Provider admission requires record version 0, MIPS32r2, 32-bit GPR/CPR1, no CPR2, +and a double-precision or FPXX ABI declaration with no ISA extension, ASE or +unknown general flags. Missing records remain unknown rather than defaulting +to a compatible ABI, and this initial lane refuses them. Unknown record values +are retained for diagnosis but do not gain provider support. These checks bind +the declared interpretation; the integer-only fixture does not establish full +floating-point behavioral conformance. + +MIPS header and ABI record facts are committed in the analysis profile so +interpretation changes invalidate profile-bound snapshots. The support-lane +identity is versioned independently of file identity. Evidence, lifecycle +outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters +do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only. + +## Use + +With a caller-installed Ghidra and compatible JDK on a supported Linux/macOS host: + +```bash +rea analyze /absolute/path/to/mips.elf --provider ghidra --json +rea function /absolute/path/to/mips.elf function_name --provider ghidra --json +``` + +MCP uses the existing tools, including `open_binary` with an absolute path and +`provider_id: "ghidra"`. No MIPS-specific tool catalog or emulator is introduced. +Inspect instructions, caller relationships and data alongside pseudocode. In +particular, delay-slot semantics are supplied by Ghidra, not reconstructed by +REA's ELF parser. + +## Verification + +The source-owned freestanding fixture is `tests/conformance/c/mips.c`. The +optional cross-target lane requires a caller-supplied Clang with MIPS targets, +LLD, GNU `readelf`, Ghidra and its compatible JDK; ordinary host-native checks +do not acquire these tools. `REA_MIPS_CLANG` selects Clang and +`REA_MIPS_READELF` selects GNU readelf (for example `greadelf` on macOS). +These are test-time prerequisites, not REA runtime providers. + +```bash +GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra npm run verify:ghidra:mips +``` + +The lane compiles both byte orders and never executes the targets. GNU readelf +independently supplies header/ABI facts and symbol addresses, compared with the +production REA target resolver before provider startup. Its version, bounded +command output and selected tool path are retained in the verification report. +The parser deliberately recognizes only this fixture's GNU output, rejecting +missing, ambiguous or unexpected fields rather than substituting defaults. + +Existing public MCP operations check Ghidra's **observed** language/compiler +specification and loaded-file hashes. The source-owned `mips-probe.S` supplies +fixed ADDIU and conditional-branch encodings: verification checks bytes, length, +immediate and direct destination, independent of decompiler variable names. +Global and string bytes are checked at independently reported symbol addresses. +ELF load-image results retain observations even though REA's independent +load-image comparison currently covers DOS, not ELF; this lane checks those +observations and does not relabel the upstream result as verified. + +CLI/MCP discovery and decompilation, direct calls, target/profile/Evidence +identity, snapshot round-trip and final process cleanup remain covered. +Pseudocode presence is a liveness check, not proof of semantic equivalence. +Instruction decoding and reader agreement likewise do not establish runtime, +delay-slot execution or complete floating-point semantics. Assertion regressions +reject incorrect byte order, constants, destinations and loaded identities. +Parser/profile tests are narrower checks, not substitutes for this real-provider +run. Report the actual lanes run and leave unexecuted verification pending. diff --git a/package.json b/package.json index 8722903ea..01a42e359 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs", "verify:adb": "npm run build:cached && node scripts/verify-adb.mjs", "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", + "verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs", "verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs", "verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs", "verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs", diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs new file mode 100644 index 000000000..deec4ef92 --- /dev/null +++ b/scripts/verify-real-ghidra-mips.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; +import { + inspectMipsReadelf, + assertMipsLoadedImage, + assertMipsProbe, + assertMipsGlobal, +} from "../tests/conformance/ghidra/mips-checks.mjs"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; +import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js"; +import { buildMipsFixture } from "../tests/conformance/ghidra/mips-fixture.mjs"; +import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +// Optional cross-target lane. No tool acquisition and no target execution. +if (process.argv.length !== 2) + throw new Error("Usage: node scripts/verify-real-ghidra-mips.mjs"); +if (!["linux", "darwin"].includes(process.platform)) + throw new Error( + "MIPS verification requires a supported Linux/macOS Ghidra host", + ); +if (process.env.GHIDRA_INSTALL_DIR === undefined) + throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR"); +const installation = inspectGhidraInstallation({ + environment: process.env, + installDir: process.env.GHIDRA_INSTALL_DIR, + ...(process.env.JAVA_HOME === undefined + ? {} + : { javaHome: process.env.JAVA_HOME }), +}); +if (installation.status !== "available") + throw new Error( + `Ghidra prerequisite unavailable: ${JSON.stringify(installation)}`, + ); +const execute = promisify(execFile); +const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const run = createVerifierRun(); +const workspace = await mkdtemp(join(tmpdir(), "rea-mips-proof-")); +const runtime = join(workspace, "runtime"); +await mkdir(runtime); +const env = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + TMPDIR: runtime, + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "ghidra", + REA_PROCESS_RUN_ID: run.run_id, + GHIDRA_INSTALL_DIR: installation.installDir, + JAVA_HOME: installation.javaHome, + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper", +}; +const reports = []; +let primaryFailure; +try { + for (const byteOrder of ["little", "big"]) { + const fixture = await buildMipsFixture(workspace, byteOrder); + const independent = 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", + ); + assert.equal(resolved.value.sha256, fixture.sha256); + const snapshotPath = join(workspace, `${byteOrder}.snapshot.json`); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", + }); + const client = new Client({ name: "rea-real-mips-proof", version: "1" }); + let stderr = ""; + transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); + }); + const call = async (name, args = {}) => { + const reply = await client.callTool( + { name, arguments: args }, + undefined, + { timeout: 180000 }, + ); + return requireMcpOperationResult(reply, name); + }; + try { + await client.connect(transport); + const target = await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }); + assert.equal(target.architecture, "mips"); + assert.equal(target.sha256, fixture.sha256); + // ELF load-image verification is not the DOS comparison lane. Its retained + // observations still expose the actual Ghidra language and loaded bytes. + const image = await call("inspect_native_load_image"); + 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"); + 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), + ); + assert.equal( + BigInt(leaf.address), + BigInt(independent.symbols.rea_mips_leaf), + ); + const probe = independent.symbols.rea_mips_probe; + assert.ok( + procedures.some( + (p) => + p.value === "rea_mips_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); + const global = await call("read_bytes", { + address: independent.symbols.rea_mips_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, + }); + assert.equal( + marker.bytes_hex, + Buffer.from("rea-mips-source-owned-fixture\0").toString("hex"), + ); + const callees = await call("procedure_callees", { + procedure: entry.address, + }); + assert.ok( + callees.includes(leaf.address), + "Expected direct call not recovered", + ); + const assembly = await call("procedure_assembly", { + procedure: entry.address, + }); + assert.ok(assembly.trim().length > 0); + const dossier = functionDossierSchema.parse( + await call("analyze_function", { procedure: entry.address }), + ); + assert.ok(dossier.pseudocode.trim().length > 0); + const mapping = await call("address_to_file_offset", { + address: entry.address, + }); + const memory = await call("read_bytes", { + address: entry.address, + length: 16, + }); + const sourceBytes = await readFile(fixture.path); + assert.equal(memory.returned_bytes, 16); + assert.equal( + memory.bytes_hex, + sourceBytes + .subarray(mapping.file_offset, mapping.file_offset + 16) + .toString("hex"), + ); + const saved = await call("close_binary", { + snapshot_path: snapshotPath, + }); + assert.ok(saved.evidence_records > 0); + const snapshot = parseAnalysisSnapshot( + JSON.parse(await readFile(snapshotPath, "utf8")), + ); + assert.equal(snapshot.target.architecture, "mips"); + assert.equal( + snapshot.binding.analysis_profile.parameters.mips_elf.byte_order, + byteOrder, + ); + assert.ok(snapshot.evidence_bundle.records.length > 0); + for (const evidence of snapshot.evidence_bundle.records) + assert.equal(parseEvidence(evidence).subject?.architecture, "mips"); + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + assert.deepEqual(await call("list_procedures"), procedures); + await call("close_binary"); + + // Same public operation through CLI, not a private injected provider. + const cli = await execute( + process.execPath, + [ + entrypoint, + "function", + fixture.path, + "rea_mips_entry", + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const evidence = parseEvidence(JSON.parse(cli.stdout)); + assert.equal(evidence.subject?.architecture, "mips"); + assert.equal(evidence.subject?.digest.sha256, fixture.sha256); + const cliDossier = functionDossierSchema.parse( + evidence.normalized_result, + ); + assert.equal(cliDossier.procedure.address, dossier.procedure.address); + assert.ok(cliDossier.pseudocode.trim().length > 0); + assert.equal( + createHash("sha256") + .update(await readFile(fixture.path)) + .digest("hex"), + fixture.sha256, + "Analysis modified the source executable", + ); + reports.push({ + ...fixture, + independent_reader: independent, + effective_language: image.observations.language_id, + effective_compiler_spec: image.observations.compiler_spec_id, + checks: [ + "independent ELF/ABI and symbols", + "loaded Ghidra identity", + "fixed instruction bytes and immediate", + "conditional branch destination", + "global word and marker bytes", + "CLI/MCP and snapshot identity", + ], + procedures: procedures.length, + status: "passed", + }); + } catch (cause) { + throw new Error( + `MIPS ${byteOrder} verification failed; MCP stderr: ${stderr}`, + { cause }, + ); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + } +} catch (cause) { + primaryFailure = cause; +} +const completed = await completeVerifierRun(run); +if ( + completed.process_lineage.status !== "verified" || + completed.process_lineage.descendants.length !== 0 +) + throw new Error( + `Cleanup unverified; retained diagnostic workspace: ${workspace}`, + { cause: primaryFailure }, + ); +if (primaryFailure !== undefined) + throw new Error( + `MIPS verification failed; retained workspace: ${workspace}`, + { + cause: primaryFailure, + }, + ); +await rm(workspace, { recursive: true, force: true }); +console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2)); diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index c1ec900bd..3163d17b3 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -17,6 +17,7 @@ import { import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; +import { readMipsElfAbiFlags } from "./MipsElfAbiFlags.js"; import { resolveAppBundleExecutable, type ResolvedAppBundle, @@ -352,7 +353,21 @@ const readExecutableMetadata = async ( } } } - return parseExecutableHeader(bytes, hostArchitecture, fileSize); + const parsed = parseExecutableHeader(bytes, hostArchitecture, fileSize); + if ( + !parsed.ok || + parsed.value.architecture !== "mips" || + parsed.value.mips.elfClass !== 32 + ) + return parsed; + const abiFlags = await readMipsElfAbiFlags(handle, parsed.value.mips, () => + throwIfTargetResolutionCancelled(signal), + ); + if (!abiFlags.ok) return abiFlags; + return ok({ + ...parsed.value, + mips: { ...parsed.value.mips, abiFlags: abiFlags.value }, + }); }; const readPeMetadata = async ( diff --git a/src/application/MipsElfAbiFlags.ts b/src/application/MipsElfAbiFlags.ts new file mode 100644 index 000000000..cf6a47de6 --- /dev/null +++ b/src/application/MipsElfAbiFlags.ts @@ -0,0 +1,153 @@ +import type { FileHandle } from "node:fs/promises"; +import type { + MipsAbiFlags, + MipsElfMetadata, +} from "../domain/binaryTargetTypes.js"; +import { err, ok, type Result } from "../domain/result.js"; + +/** + * Read ELF32 MIPS ABI declarations from the caller's already-open file. + * Inspect both typed tables (not section names), including records beyond the + * initial header probe. A missing record is unknown, not a default FP ABI. + * The caller supplies its cancellation check so its tagged error is preserved. + */ +export const readMipsElfAbiFlags = async ( + handle: FileHandle, + metadata: MipsElfMetadata, + checkCancelled: () => void, +): Promise> => { + checkCancelled(); + if (metadata.elfClass !== 32) + return err("MIPS ABI record inspection currently supports ELF32 only"); + const fileSize = (await handle.stat()).size; + checkCancelled(); + const read = async ( + offset: number, + length: number, + label: string, + ): Promise> => { + checkCancelled(); + if ( + !Number.isSafeInteger(fileSize) || + offset < 0 || + length < 0 || + offset > fileSize - length + ) + return err(`MIPS ${label} lies outside the file`); + const bytes = Buffer.alloc(length); + let total = 0; + while (total < length) { + checkCancelled(); + const observed = await handle.read( + bytes, + total, + length - total, + offset + total, + ); + checkCancelled(); + if (observed.bytesRead === 0) return err(`truncated MIPS ${label}`); + total += observed.bytesRead; + } + return ok(bytes); + }; + const header = await read(0, 52, "ELF32 header"); + if (!header.ok) return header; + const bytes = header.value; + const little = metadata.byteOrder === "little"; + const u16 = (data: Buffer, offset: number): number => + little ? data.readUInt16LE(offset) : data.readUInt16BE(offset); + const u32 = (data: Buffer, offset: number): number => + little ? data.readUInt32LE(offset) : data.readUInt32BE(offset); + if ( + bytes.toString("hex", 0, 4) !== "7f454c46" || + bytes[4] !== 1 || + bytes[5] !== (little ? 1 : 2) || + u16(bytes, 18) !== 8 || + u16(bytes, 16) !== metadata.type || + u32(bytes, 36) !== metadata.flags + ) + return err("MIPS ELF header no longer matches the resolved identity"); + if (bytes[6] !== 1 || u32(bytes, 20) !== 1 || u16(bytes, 40) !== 52) + return err("invalid MIPS ELF32 version or header size"); + + const tables = [ + { + label: "program header table", + offset: u32(bytes, 28), + count: u16(bytes, 44), + stride: u16(bytes, 42), + entrySize: 32, + typeOffset: 0, + dataOffset: 4, + sizeOffset: 16, + recordType: 0x70000003, // PT_MIPS_ABIFLAGS + }, + { + label: "section header table", + offset: u32(bytes, 32), + count: u16(bytes, 48), + stride: u16(bytes, 46), + entrySize: 40, + typeOffset: 4, + dataOffset: 16, + sizeOffset: 20, + recordType: 0x7000002a, // SHT_MIPS_ABIFLAGS + }, + ]; + // Extended numbering needs a separate section-zero reader. Refuse it rather + // than silently treating an unexamined table as having no ABI declarations. + if (u16(bytes, 44) === 0xffff || u16(bytes, 48) >= 0xff00) + return err("extended or reserved MIPS ELF table numbering is unsupported"); + let record: Buffer | undefined; + for (const table of tables) { + if (table.count === 0) { + if (table.offset !== 0) + return err(`unsupported extended or inconsistent MIPS ${table.label}`); + continue; + } + if (table.offset < 52 || table.stride !== table.entrySize) + return err(`invalid MIPS ${table.label} offset or entry size`); + // Counts are bounded by the ELF16 fields and strides by their exact ELF32 + // structure sizes. Never allocate according to an untrusted section size. + const contents = await read( + table.offset, + table.count * table.entrySize, + table.label, + ); + if (!contents.ok) return contents; + let found = false; + for (let i = 0; i < table.count; i += 1) { + checkCancelled(); + const position = i * table.entrySize; + if (u32(contents.value, position + table.typeOffset) !== table.recordType) + continue; + if (found) return err(`duplicate MIPS ABI records in ${table.label}`); + found = true; + const offset = u32(contents.value, position + table.dataOffset); + const size = u32(contents.value, position + table.sizeOffset); + if (size !== 24) + return err("unsupported MIPS ABI record size; expected 24 bytes"); + const candidate = await read(offset, 24, "ABI record"); + if (!candidate.ok) return candidate; + if (record !== undefined && !record.equals(candidate.value)) + return err("conflicting MIPS program/section ABI declarations"); + record = candidate.value; + } + } + if (record === undefined) return ok(null); + // Preserve declarations verbatim, including unknown versions/values. Provider + // admission, not parsing, decides which interpretations have been verified. + return ok({ + version: u16(record, 0), + isaLevel: record.readUInt8(2), + isaRevision: record.readUInt8(3), + gprSize: record.readUInt8(4), + cpr1Size: record.readUInt8(5), + cpr2Size: record.readUInt8(6), + fpAbi: record.readUInt8(7), + isaExtension: u32(record, 8), + ases: u32(record, 12), + flags1: u32(record, 16), + flags2: u32(record, 20), + }); +}; diff --git a/src/application/managed/ManagedApplicationGraphService.ts b/src/application/managed/ManagedApplicationGraphService.ts index 66ea6a08a..5144cc897 100644 --- a/src/application/managed/ManagedApplicationGraphService.ts +++ b/src/application/managed/ManagedApplicationGraphService.ts @@ -2,7 +2,11 @@ import { z } from "zod"; import { AnalysisProtocolError } from "../../domain/analysisErrorCore.js"; import { type AnalysisError } from "../../domain/analysisErrorBase.js"; -import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + createEvidence, + type Evidence, + type EvidenceSubjectTarget, +} from "../../domain/evidence.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; import { managedApplicationGraphResultSchema, @@ -68,14 +72,7 @@ const createManagedApplicationGraphEvidence = ( const subjectTarget = ( input: ProjectManagedApplicationGraphInput, -): - | { - readonly path: string; - readonly sha256: string; - readonly format: "pe"; - readonly architecture?: "x86" | "x86_64" | "arm" | "arm64"; - } - | undefined => { +): EvidenceSubjectTarget | undefined => { const subject = input.managed_artifact?.subject ?? input.managed_members?.subject ?? diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index b381662d0..cf954083a 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { BINARY_ARCHITECTURES } from "../domain/binaryTargetTypes.js"; import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunctionAnnotations.js"; import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js"; import { nativeUiResultSchema } from "../domain/native/nativeUiObservation.js"; @@ -415,7 +416,7 @@ export const sessionOutputSchemas = { format: targetFormatSchema, kind: targetKindSchema, sha256: z.string().regex(/^[a-f0-9]{64}$/u), - architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(), + architecture: z.enum(BINARY_ARCHITECTURES).nullable(), }), ), close_binary: lifecycleResultOf( @@ -456,7 +457,7 @@ export const sessionOutputSchemas = { format: targetFormatSchema, kind: targetKindSchema, sha256: z.string().regex(/^[a-f0-9]{64}$/u), - architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(), + architecture: z.enum(BINARY_ARCHITECTURES).nullable(), }), ]), ]), diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 1da8db11d..bb8043516 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -11,7 +11,10 @@ import { committedProviderSchema, type AnalysisProfileCommitment, } from "./analysisProfile.js"; -import type { BinaryTarget } from "./binaryTargetTypes.js"; +import { + BINARY_ARCHITECTURES, + type BinaryTarget, +} from "./binaryTargetTypes.js"; import { evidenceBundleForTarget, evidenceBundleSchema, @@ -31,7 +34,7 @@ import { import { digestSchema } from "./../domain/digests.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; -const architectureSchema = z.enum(["x86", "x86_64", "arm", "arm64"]); +const architectureSchema = z.enum(BINARY_ARCHITECTURES); const formatSchema = z.enum([ "analysis-database", "mach-o", diff --git a/src/domain/binaryTarget.mips.test.ts b/src/domain/binaryTarget.mips.test.ts new file mode 100644 index 000000000..e646daee1 --- /dev/null +++ b/src/domain/binaryTarget.mips.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { parseExecutableHeader } from "./binaryTarget.js"; + +const header = (little: boolean, bits: 32 | 64 = 32): Buffer => { + const bytes = Buffer.alloc(bits === 32 ? 52 : 64); + bytes.set([0x7f, 0x45, 0x4c, 0x46, bits === 32 ? 1 : 2, little ? 1 : 2, 1]); + if (little) { + bytes.writeUInt16LE(2, 16); + bytes.writeUInt16LE(8, 18); + bytes.writeUInt32LE(0x70001001, bits === 32 ? 36 : 48); + } else { + bytes.writeUInt16BE(2, 16); + bytes.writeUInt16BE(8, 18); + bytes.writeUInt32BE(0x70001001, bits === 32 ? 36 : 48); + } + return bytes; +}; + +describe("MIPS ELF header boundaries", () => { + it.each([true, false])( + "retains source endian and ABI/ISA flags (little=%s)", + (little) => { + const parsed = parseExecutableHeader(header(little), "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toEqual({ + format: "elf", + architecture: "mips", + availableArchitectures: ["mips"], + mips: { + elfClass: 32, + byteOrder: little ? "little" : "big", + type: 2, + flags: 0x70001001, + }, + }); + }, + ); + + it("reads ELF64 flags at their own offset without claiming o32 support", () => { + const bytes = header(false, 64); + bytes.writeUInt32BE(0xfeedface, 36); + bytes.writeUInt32BE(0x80000001, 48); + const parsed = parseExecutableHeader(bytes, "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toMatchObject({ + architecture: "mips", + mips: { elfClass: 64, byteOrder: "big", flags: 0x80000001 }, + }); + }); + + it("retains a PSP declaration without claiming Allegrex provider support", () => { + const bytes = header(true); + bytes.writeUInt32LE(0x10a23001, 36); + const parsed = parseExecutableHeader(bytes, "x64"); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value).toMatchObject({ + architecture: "mips", + mips: { flags: 0x10a23001 }, + }); + }); + + it("rejects a truncated header before reading flags", () => { + const parsed = parseExecutableHeader(header(true).subarray(0, 39), "x64"); + expect(parsed).toEqual({ + ok: false, + error: + "truncated ELF header: an ELF32 header needs 52 bytes; the file has 39", + }); + }); + + it("does not turn invalid byte order into little-endian MIPS", () => { + const bytes = header(true); + bytes[5] = 0; + expect(parseExecutableHeader(bytes, "x64")).toEqual({ + ok: false, + error: "unsupported ELF endianness", + }); + }); +}); diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 0e85a372e..2d0735b46 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -1,18 +1,33 @@ -import type { BinaryArchitecture } from "./binaryTargetTypes.js"; +import type { + BinaryArchitecture, + MipsElfMetadata, +} from "./binaryTargetTypes.js"; import { err, ok, type Result } from "./result.js"; import { mzWindowsHeaderOffset, parseDosMzHeader } from "./dosMz.js"; /** Format and architecture facts recovered from an executable header. */ export type ExecutableMetadata = + | { + readonly format: "elf"; + readonly architecture: "mips"; + readonly availableArchitectures: readonly "mips"[]; + readonly mips: MipsElfMetadata; + } | { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; - readonly architecture: BinaryArchitecture; - readonly availableArchitectures: readonly BinaryArchitecture[]; + readonly architecture: Exclude; + readonly availableArchitectures: readonly Exclude< + BinaryArchitecture, + "mips" + >[]; } | { readonly format: "pe"; - readonly architecture: BinaryArchitecture; - readonly availableArchitectures: readonly BinaryArchitecture[]; + readonly architecture: Exclude; + readonly availableArchitectures: readonly Exclude< + BinaryArchitecture, + "mips" + >[]; readonly executableRole: | "application" | "shared-library" @@ -88,7 +103,7 @@ const parseFatMachO = ( const entrySize = is64 ? 32 : 20; if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize) return err("truncated or invalid FAT architecture table"); - const architectures: BinaryArchitecture[] = []; + const architectures: Exclude[] = []; const sliceEnds = new Map(); for (let index = 0; index < count; index += 1) { const entry = 8 + index * entrySize; @@ -148,6 +163,20 @@ const parseElf = ( const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18); const architecture = elfArchitecture(machine); if (architecture === undefined) return err("unsupported ELF architecture"); + if (architecture === "mips") + return ok({ + format: "elf", + architecture, + availableArchitectures: [architecture], + mips: { + elfClass: bytes[4] === 1 ? 32 : 64, + byteOrder: little ? "little" : "big", + type: little ? bytes.readUInt16LE(16) : bytes.readUInt16BE(16), + flags: little + ? bytes.readUInt32LE(bytes[4] === 1 ? 36 : 48) + : bytes.readUInt32BE(bytes[4] === 1 ? 36 : 48), + }, + }); return ok({ format: "elf", architecture, @@ -239,7 +268,9 @@ const peManagedStatus = ( ); }; -const machArchitecture = (cpu: number): BinaryArchitecture | undefined => { +const machArchitecture = ( + cpu: number, +): Exclude | undefined => { switch (cpu) { case 7: return "x86"; @@ -259,6 +290,8 @@ const elfArchitecture = (machine: number): BinaryArchitecture | undefined => { return "x86"; case 62: return "x86_64"; + case 8: // EM_MIPS: ISA, ABI and byte order remain separate ELF facts. + return "mips"; case 40: return "arm"; case 183: @@ -267,7 +300,9 @@ const elfArchitecture = (machine: number): BinaryArchitecture | undefined => { return undefined; }; -const peArchitecture = (machine: number): BinaryArchitecture | undefined => { +const peArchitecture = ( + machine: number, +): Exclude | undefined => { switch (machine) { case 0x14c: return "x86"; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index edb914706..06a47500e 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -1,5 +1,37 @@ /** Provider-neutral CPU families detected from supported executable headers. */ -export type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64"; +export const BINARY_ARCHITECTURES = [ + "x86", + "x86_64", + "arm", + "arm64", + "mips", +] as const; +export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number]; + +/** Raw ELF facts; family recognition does not establish ISA or ABI support. */ +export interface MipsElfMetadata { + readonly elfClass: 32 | 64; + readonly byteOrder: "little" | "big"; + readonly type: number; + readonly flags: number; + /** Undefined until inspected; null means neither typed table has a record. */ + readonly abiFlags?: MipsAbiFlags | null; +} + +/** Declared fields of the 24-byte MIPS ABI flags record, not inferred semantics. */ +export interface MipsAbiFlags { + readonly version: number; + readonly isaLevel: number; + readonly isaRevision: number; + readonly gprSize: number; + readonly cpr1Size: number; + readonly cpr2Size: number; + readonly fpAbi: number; + readonly isaExtension: number; + readonly ases: number; + readonly flags1: number; + readonly flags2: number; +} interface BinaryTargetIdentity { readonly path: string; @@ -18,8 +50,11 @@ type NonExecutableMetadata = { type ExecutableTarget = BinaryTargetIdentity & { readonly kind: "executable"; - readonly architecture: BinaryArchitecture; - readonly availableArchitectures: readonly BinaryArchitecture[]; + readonly architecture: Exclude; + readonly availableArchitectures: readonly Exclude< + BinaryArchitecture, + "mips" + >[]; }; /** @@ -27,6 +62,15 @@ type ExecutableTarget = BinaryTargetIdentity & { * Each variant carries only metadata established for that exact file kind. */ export type BinaryTarget = + | (BinaryTargetIdentity & { + readonly kind: "executable"; + readonly format: "elf"; + readonly architecture: "mips"; + readonly availableArchitectures: readonly "mips"[]; + readonly mips: MipsElfMetadata; + readonly executableRole?: never; + readonly managed?: never; + }) | (ExecutableTarget & { readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; readonly executableRole?: never; diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index fe6c692a8..6c1ef36c1 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -7,7 +7,11 @@ import { analysisProfileSchema, type AnalysisProfileCommitment, } from "./analysisProfile.js"; -import type { BinaryTarget } from "./binaryTargetTypes.js"; +import { + BINARY_ARCHITECTURES, + type BinaryArchitecture, + type BinaryTarget, +} from "./binaryTargetTypes.js"; import { jsonObjectSchema, jsonValueSchema, @@ -57,7 +61,7 @@ const subjectSchema = z.object({ "javascript-bundle", "entitlements", ]), - architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(), + architecture: z.enum(BINARY_ARCHITECTURES).nullable(), local_path: z.string(), }); /** Source location attached to an evidence observation. */ @@ -171,7 +175,7 @@ export interface EvidenceSubjectTarget { readonly path: string; readonly sha256: string; readonly format: z.infer["format"]; - readonly architecture?: "x86" | "x86_64" | "arm" | "arm64"; + readonly architecture?: BinaryArchitecture; } type EvidenceAuthority = z.infer; type ExecutionEnvironment = z.infer; diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index eeb8646a4..84677fb5f 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -4,11 +4,18 @@ import type { } from "../application/AnalysisProvider.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; 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, + ghidraMipsUnsupportedReason, +} from "./GhidraMipsProfile.js"; /** Resolve version-bound, deterministic semantics before Ghidra imports a target. */ export const resolveGhidraAnalysisProfile = ( @@ -25,6 +32,17 @@ export const resolveGhidraAnalysisProfile = ( return Promise.resolve( err(new ProviderAdapterError(identity.id, "resolve_analysis_profile")), ); + const mipsReason = ghidraMipsUnsupportedReason(target); + if (mipsReason !== null) + return Promise.resolve( + err( + new AnalysisUnsupportedTargetError( + "resolve_analysis_profile", + target.path, + mipsReason, + ), + ), + ); const provider = { ...identity, version: installation.providerVersion }; const dosMz = target.format === "dos-mz"; const dosCom = target.format === "dos-com"; @@ -90,6 +108,7 @@ export const resolveGhidraAnalysisProfile = ( } : {}), analyzer_preset: "ghidra-default", + ...ghidraMipsProfileParameters(target), }), }), ); diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 4f5dfce63..18f029758 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -569,6 +569,19 @@ describe("Ghidra FAT native decompiler admission", () => { describe("Ghidra native decompiler admission", () => { it.each([ + { + name: "a MIPS decompiler at the Linux x64 path", + options: { + environment: {}, + installDir: INSTALL, + platform: "linux" as const, + architecture: "x64" as const, + }, + executableHeader: (path: string) => + path === LINUX_DECOMPILER + ? { bytes: elf(1, 1, 8), size: 52 } + : undefined, + }, { name: "a Linux x86-64 decompiler at the macOS arm64 path", options: { diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index d60d447ed..4ff7a6868 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -116,7 +116,9 @@ const assessNativeDecompiler = ( const alternative = parseExecutableHeader(probe.bytes, node, probe.size); if ( alternative.ok && - !alternative.value.availableArchitectures.includes(expectedArchitecture) + !alternative.value.availableArchitectures.some( + (available) => available === expectedArchitecture, + ) ) { parsed = alternative; break; diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts new file mode 100644 index 000000000..698a4cf63 --- /dev/null +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { parseConfig } from "../config/parseConfig.js"; +import type { + BinaryTarget, + MipsElfMetadata, +} from "../domain/binaryTargetTypes.js"; +import { silentLogger } from "../logger.js"; +import { HopperProvider } from "../hopper/HopperProvider.js"; +import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js"; +import { IdaProvider } from "../ida/IdaProvider.js"; +import { GhidraProvider } from "./GhidraProvider.js"; +import type { GhidraInstallationHost } from "./GhidraInstallation.js"; + +// Fake installation inspection tests admission/profile contracts, not Ghidra. +const host: GhidraInstallationHost = { + platform: "linux", + architecture: "x64", + readText: () => "application.version=12.1.4\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.11", + major: 21, + home: "/jdk-21", + bits: 64, + runtime: "jdk", + }), +}; +const parsed = parseConfig({ GHIDRA_INSTALL_DIR: "/ghidra" }); +if (!parsed.ok) throw parsed.error; +const config = parsed.value; +const target = (change: Partial = {}): BinaryTarget => ({ + path: "/fixture.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "mips", + availableArchitectures: ["mips"], + mips: { + elfClass: 32, + byteOrder: "little", + type: 2, + flags: 0x70001001, + abiFlags: { + version: 0, + isaLevel: 32, + isaRevision: 2, + gprSize: 1, + cpr1Size: 1, + cpr2Size: 0, + fpAbi: 5, + isaExtension: 0, + ases: 0, + flags1: 0, + flags2: 0, + }, + ...change, + }, +}); + +describe("bounded Ghidra MIPS admission", () => { + it("binds endian and flags without widening Windows P0", async () => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const little = target(); + const big = target({ byteOrder: "big" }); + expect(ghidra.inspectTargetSupport(little).status).toBe("supported"); + expect(ghidra.inspectTargetSupport(big).status).toBe("supported"); + const left = await ghidra.resolveAnalysisProfile(little); + const right = await ghidra.resolveAnalysisProfile(big); + if (!left.ok) throw left.error; + if (!right.ok) throw right.error; + expect(left.value.profile?.parameters.mips_elf).toEqual({ + elf_class: 32, + byte_order: "little", + type: 2, + flags: 0x70001001, + abi_flags: { + version: 0, + isa_level: 32, + isa_revision: 2, + gpr_size: 1, + cpr1_size: 1, + cpr2_size: 0, + fp_abi: 5, + isa_extension: 0, + ases: 0, + flags1: 0, + flags2: 0, + }, + }); + expect(left.value.profile?.digest).not.toBe(right.value.profile?.digest); + const windows = new GhidraProvider( + config, + silentLogger, + {}, + { + ...host, + platform: "win32", + }, + ); + expect(windows.inspectTargetSupport(little).status).toBe("unsupported"); + }); + + it.each([ + [{ elfClass: 64 }, "ELF32"], + [{ type: 1 }, "ET_EXEC"], + [{ type: 3 }, "ET_EXEC"], + [{ type: 0xffa0 }, "ET_EXEC"], + [{ flags: 0x10a23001 }, "Machine-specific"], + [{ flags: 0x70001021 }, "o32"], + [{ flags: 0x70000001 }, "o32"], + [{ flags: 0x70002001 }, "o32"], + [{ flags: 0x70003001 }, "o32"], + [{ flags: 0x70004001 }, "o32"], + [{ flags: 0x72001001 }, "ASE"], + [{ flags: 0x74001001 }, "ASE"], + [{ flags: 0x70911001 }, "Machine-specific"], + [{ flags: 0x60001001 }, "32R2"], + [{ flags: 0x70001201 }, "EF_MIPS_FP64"], + [{ flags: 0x70001401 }, "EF_MIPS_NAN2008"], + [{ abiFlags: null }, "inspected ABI"], + ] satisfies [Partial, string][])( + "refuses an unverified MIPS interpretation %j", + async (change, reason) => { + const ghidra = new GhidraProvider(config, silentLogger, {}, host); + const value = target(change); + expect(ghidra.inspectTargetSupport(value)).toMatchObject({ + status: "unsupported", + reason: expect.stringContaining(reason), + }); + const resolved = await ghidra.resolveAnalysisProfile(value); + expect(resolved.ok).toBe(false); + }, + ); + + it("keeps MIPS out of unverified Hopper and IDA adapter lanes", () => { + const hopper = new HopperProvider(config, silentLogger, {}, "linux"); + expect(hopper.inspectTargetSupport(target()).status).toBe("unsupported"); + expect(hopperLoaderArgsForTarget(target()).ok).toBe(false); + const ida = new IdaProvider(config, {}); + expect(ida.inspectTargetSupport(target()).status).toBe("unsupported"); + }); +}); diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts new file mode 100644 index 000000000..feb93ad0d --- /dev/null +++ b/src/ghidra/GhidraMipsProfile.ts @@ -0,0 +1,86 @@ +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import type { JsonValue } from "../domain/jsonValue.js"; + +/** Keep family recognition separate from the deliberately bounded Ghidra lane. */ +export const ghidraMipsUnsupportedReason = ( + target: BinaryTarget, +): string | null => { + if (target.kind !== "executable" || target.architecture !== "mips") + return null; + const metadata = target.mips; + if (target.format !== "elf" || metadata === undefined) + return "MIPS analysis requires ELF header metadata; a family name alone does not select an ISA or ABI."; + if (metadata.elfClass !== 32) + return "This Ghidra MIPS lane supports ELF32 only; MIPS64 and n64 remain unsupported."; + if (metadata.type !== 2) + return "This Ghidra MIPS lane supports ET_EXEC only; relocatable objects, shared images and PSP PRX need separate load verification."; + if ((metadata.flags & 0x0f000000) !== 0) + return "MIPS16, microMIPS and other ASE encodings are outside this standard-instruction MIPS lane."; + if ((metadata.flags & 0x00ff0000) !== 0) + return "Machine-specific MIPS variants, including PSP Allegrex, need a separately supported processor profile."; + if ((metadata.flags & 0xf0000000) !== 0x70000000) + return "This Ghidra MIPS lane requires EF_MIPS_ARCH_32R2; other ISA revisions need separate verification."; + if ( + (metadata.flags & 0x0000f000) !== 0x00001000 || + (metadata.flags & 0x20) !== 0 + ) + return "This Ghidra MIPS lane requires an explicit o32 ABI; unspecified, n32 and EABI targets remain unsupported."; + if ((metadata.flags & 0x00000200) !== 0) + return "EF_MIPS_FP64 requires separate verification of 64-bit floating-point register semantics in this Ghidra lane."; + if ((metadata.flags & 0x00000400) !== 0) + return "EF_MIPS_NAN2008 requires separate verification of NaN encoding semantics in this Ghidra lane."; + const abi = metadata.abiFlags; + if (abi === undefined || abi === null) + return "This Ghidra MIPS lane requires an inspected ABI flags record; header flags alone do not establish the floating-point ABI."; + if (abi.version !== 0) + return "This Ghidra MIPS lane requires ABI flags version 0; newer record semantics are unsupported."; + if (abi.isaLevel !== 32 || abi.isaRevision !== 2) + return "MIPS ABI flags must agree with the declared MIPS32r2 ISA."; + if (abi.gprSize !== 1 || abi.cpr1Size !== 1 || abi.cpr2Size !== 0) + return "This Ghidra MIPS lane requires 32-bit GPR/CPR1 and no CPR2; other register modes need separate verification."; + if (abi.fpAbi !== 1 && abi.fpAbi !== 5) + return "This Ghidra MIPS lane admits double-precision or FPXX ABI declarations only; soft-float, FP64 and unspecified modes need separate verification."; + if (abi.isaExtension !== 0 || abi.ases !== 0) + return "MIPS ABI flags declare an ISA extension or ASE outside this standard-instruction lane."; + if ((abi.flags1 & ~1) !== 0 || abi.flags2 !== 0) + return "MIPS ABI flags contain unsupported general flags."; + return null; +}; + +/** Commit source ELF interpretation without confusing target ISA with host CPU. */ +export const ghidraMipsProfileParameters = ( + target: BinaryTarget, +): Readonly> => { + if ( + target.kind !== "executable" || + target.architecture !== "mips" || + target.mips === undefined + ) + return {}; + const abi = target.mips.abiFlags; + return { + mips_elf: { + elf_class: target.mips.elfClass, + byte_order: target.mips.byteOrder, + type: target.mips.type, + flags: target.mips.flags, + abi_flags: + abi === undefined || abi === null + ? null + : { + 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, + }, + }, + mips_support_lane: "elf32-exec-o32-arch32r2-standard-v3", + }; +}; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index c0518297b..cd623a236 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -752,7 +752,7 @@ const sessionInfo = () => ({ const executableTarget = ( format: "mach-o" | "elf" | "pe", - architecture: NonNullable, + architecture: Extract["architecture"], ): BinaryTarget => format === "pe" ? peTarget(architecture) @@ -766,7 +766,7 @@ const executableTarget = ( }; const peTarget = ( - architecture: NonNullable, + architecture: Extract["architecture"], ): Extract => ({ path: "/tmp/fixture", sha256: "a".repeat(64), diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index d782827d9..e8958b172 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -30,6 +30,7 @@ import { type GhidraInstallationInspection, } from "./GhidraInstallation.js"; import { resolveGhidraAnalysisProfile } from "./GhidraAnalysisProfile.js"; +import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js"; import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js"; import { CAPABILITIES, @@ -47,7 +48,13 @@ import { windowsNativeCapabilities, } from "../process/WindowsAuthority.js"; -const SUPPORTED_ARCHITECTURES = new Set(["x86", "x86_64", "arm", "arm64"]); +const SUPPORTED_ARCHITECTURES = new Set([ + "x86", + "x86_64", + "arm", + "arm64", + "mips", +]); /** Ghidra candidate backed by an isolated ephemeral headless import. */ export class GhidraProvider implements AnalysisProviderCandidate { @@ -135,6 +142,14 @@ export class GhidraProvider implements AnalysisProviderCandidate { }; if (hostPlatform === "win32") return inspectWindowsP0TargetSupport(target, diagnostics); + const mipsReason = ghidraMipsUnsupportedReason(target); + if (mipsReason !== null) + return { + status: "unsupported", + code: "architecture_unsupported", + reason: mipsReason, + diagnostics, + }; if (target.format === "mach-o" && target.availableArchitectures.length > 1) return { status: "unsupported", @@ -148,7 +163,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { status: "unsupported", code: "architecture_unsupported", reason: - "Ghidra v1 requires a concrete x86, x86_64, arm, or arm64 target architecture.", + "Ghidra v1 requires a supported x86, x86_64, arm, arm64, or MIPS target profile.", diagnostics, }; return { diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 74fc29a68..3ac7640df 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -110,6 +110,14 @@ export const hopperLoaderArgsForTarget = ( if (target.kind !== "executable") return err(new ProviderAdapterError("hopper", "resolve_analysis_profile")); const architecture = target.architecture; + if (architecture === "mips") + return err( + new AnalysisCapabilityUnavailableError( + "hopper", + "resolve_analysis_profile", + "REA's Hopper adapter does not admit MIPS targets. Select a verified Ghidra MIPS profile instead.", + ), + ); const flag = hopperArchitectureFlag(architecture); switch (target.format) { case "mach-o": @@ -138,7 +146,9 @@ export const hopperLoaderArgsForTarget = ( } }; -const hopperArchitectureFlag = (architecture: BinaryArchitecture): string => { +const hopperArchitectureFlag = ( + architecture: Exclude, +): string => { switch (architecture) { case "x86": return "--intel-32"; diff --git a/src/hopper/HopperMachOImage.test.ts b/src/hopper/HopperMachOImage.test.ts index cb96a57a1..d503a055b 100644 --- a/src/hopper/HopperMachOImage.test.ts +++ b/src/hopper/HopperMachOImage.test.ts @@ -35,3 +35,25 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( } }, ); + +it("rejects an ELF target before attempting FAT64 source preparation", async () => { + const directory = await createTestTempDirectory("rea-hopper-mips-boundary-"); + // No file is created: format validation must precede any filesystem lookup. + const path = join(directory, "mips.elf"); + const result = await resolveHopperMachOImage({ + path, + sha256: "0".repeat(64), + kind: "executable", + format: "elf", + architecture: "mips", + availableArchitectures: ["mips"], + mips: { elfClass: 32, byteOrder: "little", type: 2, flags: 0x70001001 }, + }); + if (result.ok) + throw new Error("An ELF target entered Mach-O image preparation"); + expect(result.error).toMatchObject({ + _tag: "BinaryTargetError", + path, + reason: "Hopper FAT64 image preparation requires a Mach-O target", + }); +}); diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index b7e06df43..451289d92 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -44,6 +44,8 @@ export const resolveHopperMachOImage = async ( ), ); if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); + if (target.format !== "mach-o") + return invalid("Hopper FAT64 image preparation requires a Mach-O target"); let file: FileHandle; try { file = await open(target.path, "r"); diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index d9c0485f0..9fb147f5e 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -116,6 +116,13 @@ export class HopperProvider implements AnalysisProviderCandidate { reason: null, diagnostics, }; + if (target.architecture === "mips") + return { + status: "unsupported", + code: "architecture_unsupported", + reason: "REA's Hopper adapter does not admit MIPS targets.", + diagnostics, + }; if (target.format === "dos-mz" || target.format === "dos-com") return { status: "unsupported", diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts index 8213c7991..691a666c2 100644 --- a/src/ida/IdaProvider.ts +++ b/src/ida/IdaProvider.ts @@ -101,6 +101,14 @@ export class IdaProvider implements AnalysisProviderCandidate { target_kind: target.kind, target_format: target.format, }; + if (target.kind === "executable" && target.architecture === "mips") + return { + status: "unsupported", + code: "architecture_unsupported", + reason: + "REA's IDA adapter has no verified MIPS lane. Select a supported Ghidra profile instead.", + diagnostics, + }; return target.kind === "executable" ? { status: "supported", code: null, reason: null, diagnostics } : { diff --git a/tests/boundary/filesystem/mipsAbiFlags.test.ts b/tests/boundary/filesystem/mipsAbiFlags.test.ts new file mode 100644 index 000000000..44237008b --- /dev/null +++ b/tests/boundary/filesystem/mipsAbiFlags.test.ts @@ -0,0 +1,309 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { open, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { it } from "vitest"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import type { MipsElfMetadata } from "../../../src/domain/binaryTargetTypes.js"; +import { readMipsElfAbiFlags } from "../../../src/application/MipsElfAbiFlags.js"; +import { + ghidraMipsProfileParameters, + ghidraMipsUnsupportedReason, +} from "../../../src/ghidra/GhidraMipsProfile.js"; + +const metadata = (little = true): MipsElfMetadata => ({ + elfClass: 32, + byteOrder: little ? "little" : "big", + type: 2, + flags: 0x70001001, +}); +const abiOffset = 8192; // Deliberately outside BinaryTargetResolver's 4 KiB probe. +const fixture = (little = true): Buffer => { + const bytes = Buffer.alloc(abiOffset + 48); + bytes.set([0x7f, 0x45, 0x4c, 0x46, 1, little ? 1 : 2, 1]); + const u16 = (value: number, offset: number) => + little + ? bytes.writeUInt16LE(value, offset) + : bytes.writeUInt16BE(value, offset); + const u32 = (value: number, offset: number) => + little + ? bytes.writeUInt32LE(value, offset) + : bytes.writeUInt32BE(value, offset); + u16(2, 16); + u16(8, 18); + u32(1, 20); + u32(52, 28); + u32(128, 32); + u32(0x70001001, 36); + u16(52, 40); + u16(32, 42); + u16(1, 44); + u16(40, 46); + u16(2, 48); + u32(0x70000003, 52); + u32(abiOffset, 56); + u32(24, 68); + u32(24, 72); + u32(0x7000002a, 172); + u32(abiOffset, 184); + u32(24, 188); + bytes.set([0, 0, 32, 2, 1, 1, 0, 5], abiOffset); + return bytes; +}; +const inspect = async ( + bytes: Buffer, + little = true, + checkCancelled: () => void = () => {}, +) => { + const directory = await createTestTempDirectory("rea-mips-abi-test-"); + const path = join(directory, "fixture.elf"); + await writeFile(path, bytes); + const handle = await open(path, "r"); + try { + return await readMipsElfAbiFlags(handle, metadata(little), checkCancelled); + } finally { + await handle.close(); + } +}; +const target = (mips: MipsElfMetadata) => ({ + path: "/fixture.elf", + sha256: "a".repeat(64), + kind: "executable" as const, + format: "elf" as const, + architecture: "mips" as const, + availableArchitectures: ["mips"] as const, + mips, +}); + +for (const little of [true, false]) { + it(`reads a remote ABI record through both ELF tables (${little ? "LE" : "BE"})`, async () => { + const bytes = fixture(little); + const result = await inspect(bytes, little); + assert.ok(result.ok); + assert.deepEqual(result.value, { + version: 0, + isaLevel: 32, + isaRevision: 2, + gprSize: 1, + cpr1Size: 1, + cpr2Size: 0, + fpAbi: 5, + isaExtension: 0, + ases: 0, + flags1: 0, + flags2: 0, + }); + const resolved = target({ ...metadata(little), abiFlags: result.value }); + assert.equal(ghidraMipsUnsupportedReason(resolved), null); + const before = ghidraMipsProfileParameters(resolved); + bytes[abiOffset + 5] = 2; + bytes[abiOffset + 7] = 6; + const fp64 = await inspect(bytes, little); + assert.ok(fp64.ok); + const changed = target({ ...metadata(little), abiFlags: fp64.value }); + assert.match(ghidraMipsUnsupportedReason(changed) ?? "", /register modes/u); + assert.notDeepEqual(ghidraMipsProfileParameters(changed), before); + }); +} + +it("supports stripped section tables using PT_MIPS_ABIFLAGS", async () => { + const bytes = fixture(); + bytes.writeUInt32LE(0, 32); + bytes.writeUInt16LE(0, 48); + const result = await inspect(bytes); + assert.ok(result.ok); + assert.equal(result.value?.fpAbi, 5); +}); +it("supports a section-only ABI record without assuming its name", async () => { + const bytes = fixture(); + bytes.writeUInt32LE(0, 28); + bytes.writeUInt16LE(0, 44); + const result = await inspect(bytes); + assert.ok(result.ok); + assert.equal(result.value?.fpAbi, 5); +}); +it("keeps absent ABI declarations unknown and refuses provider admission", async () => { + const bytes = fixture(); + bytes.writeUInt32LE(0, 52); + bytes.writeUInt32LE(0, 172); + const result = await inspect(bytes); + assert.ok(result.ok); + assert.equal(result.value, null); + assert.match( + ghidraMipsUnsupportedReason(target({ ...metadata(), abiFlags: null })) ?? + "", + /inspected ABI/u, + ); + assert.match( + ghidraMipsUnsupportedReason(target(metadata())) ?? "", + /inspected ABI/u, + ); +}); +it("rejects contradictory program and section ABI declarations", async () => { + const bytes = fixture(); + bytes.copy(bytes, abiOffset + 24, abiOffset, abiOffset + 24); + bytes[abiOffset + 24 + 7] = 6; + bytes.writeUInt32LE(abiOffset + 24, 184); + const result = await inspect(bytes); + assert.ok(!result.ok); + assert.match(result.error, /conflicting/u); +}); +it("rejects duplicate ABI declarations in one table", async () => { + const bytes = fixture(); + bytes.copy(bytes, 84, 52, 84); + bytes.writeUInt16LE(2, 44); + const result = await inspect(bytes); + assert.ok(!result.ok); + assert.match(result.error, /duplicate/u); +}); +const malformed: [string, (bytes: Buffer) => void, RegExp][] = [ + ["record outside file", (b) => b.writeUInt32LE(0xfffffff0, 56), /outside/u], + ["table outside file", (b) => b.writeUInt32LE(0xfffffff0, 28), /outside/u], + ["short record", (b) => b.writeUInt32LE(23, 68), /record size/u], + ["oversized record", (b) => b.writeUInt32LE(0xffffffff, 68), /record size/u], + ["invalid table stride", (b) => b.writeUInt16LE(31, 42), /entry size/u], + ["extended program count", (b) => b.writeUInt16LE(0xffff, 44), /numbering/u], + ["extended section count", (b) => b.writeUInt16LE(0, 48), /extended/u], + ["changed identity", (b) => b.writeUInt32LE(0x60001001, 36), /identity/u], + ["invalid ELF version", (b) => b.writeUInt32LE(0, 20), /version/u], +]; +for (const [name, change, reason] of malformed) { + it(`rejects ${name} instead of using a guessed ABI`, async () => { + const bytes = fixture(); + change(bytes); + const result = await inspect(bytes); + assert.ok(!result.ok); + assert.match(result.error, reason); + }); +} +const unsupported: [string, (bytes: Buffer) => void, RegExp][] = [ + ["new record version", (b) => b.writeUInt16LE(1, abiOffset), /version 0/u], + [ + "conflicting ISA", + (b) => { + b[abiOffset + 3] = 6; + }, + /MIPS32r2/u, + ], + [ + "soft-float", + (b) => { + b[abiOffset + 5] = 0; + b[abiOffset + 7] = 3; + }, + /register modes/u, + ], + [ + "unspecified FP ABI", + (b) => { + b[abiOffset + 7] = 0; + }, + /unspecified/u, + ], + ["ISA extension", (b) => b.writeUInt32LE(1, abiOffset + 8), /extension/u], + ["ASE", (b) => b.writeUInt32LE(0x800, abiOffset + 12), /ASE/u], + [ + "reserved flags", + (b) => b.writeUInt32LE(2, abiOffset + 16), + /general flags/u, + ], +]; +for (const [name, change, reason] of unsupported) { + it(`retains ${name} while rejecting its unverified provider interpretation`, async () => { + const bytes = fixture(); + change(bytes); + const result = await inspect(bytes); + assert.ok(result.ok); + assert.ok(result.value !== null); + assert.match( + ghidraMipsUnsupportedReason( + target({ ...metadata(), abiFlags: result.value }), + ) ?? "", + reason, + ); + }); +} +it("preserves the caller's cancellation error during table inspection", async () => { + const cancelled = new Error("caller cancellation sentinel"); + let checks = 0; + await assert.rejects( + inspect(fixture(), true, () => { + checks += 1; + if (checks === 12) throw cancelled; + }), + (error) => error === cancelled, + ); +}); + +for (const little of [true, false]) { + it(`resolves full-file MIPS ABI identity before provider selection (${little ? "LE" : "BE"})`, async () => { + const directory = await createTestTempDirectory("rea-mips-resolution-"); + const path = join(directory, "fixture.elf"); + const bytes = fixture(little); + await writeFile(path, bytes); + const result = await parseBinaryTarget(path); + if (!result.ok) throw result.error; + assert.equal(result.value.architecture, "mips"); + assert.equal(result.value.format, "elf"); + assert.equal( + result.value.sha256, + createHash("sha256").update(bytes).digest("hex"), + ); + if ( + result.value.kind !== "executable" || + result.value.architecture !== "mips" + ) + throw new Error("Expected a resolved MIPS executable"); + assert.equal(result.value.mips.byteOrder, little ? "little" : "big"); + assert.equal(result.value.mips.abiFlags?.fpAbi, 5); + assert.equal(ghidraMipsUnsupportedReason(result.value), null); + assert.deepEqual(await readFile(path), bytes); + }); +} + +it("keeps missing ABI data unknown through the public target resolver", async () => { + const directory = await createTestTempDirectory("rea-mips-resolution-"); + const path = join(directory, "fixture.elf"); + const bytes = fixture(); + bytes.writeUInt32LE(0, 52); + bytes.writeUInt32LE(0, 172); + await writeFile(path, bytes); + const result = await parseBinaryTarget(path); + if (!result.ok) throw result.error; + if ( + result.value.kind !== "executable" || + result.value.architecture !== "mips" + ) + throw new Error("Expected a resolved MIPS executable"); + assert.equal(result.value.mips.abiFlags, null); + assert.match( + ghidraMipsUnsupportedReason(result.value) ?? "", + /inspected ABI/u, + ); +}); + +it("projects a malformed out-of-probe ABI record as a target error", async () => { + const directory = await createTestTempDirectory("rea-mips-resolution-"); + const path = join(directory, "fixture.elf"); + const bytes = fixture(); + bytes.writeUInt32LE(0xfffffff0, 56); + await writeFile(path, bytes); + const result = await parseBinaryTarget(path); + if (result.ok) throw new Error("Malformed ABI extent was accepted"); + assert.equal(result.error._tag, "BinaryTargetError"); + assert.match(result.error.message, /ABI record lies outside the file/u); + assert.deepEqual(await readFile(path), bytes); +}); + +it("preserves cancellation instead of turning it into malformed target data", async () => { + const directory = await createTestTempDirectory("rea-mips-resolution-"); + const path = join(directory, "fixture.elf"); + await writeFile(path, fixture()); + const controller = new AbortController(); + controller.abort(); + const result = await parseBinaryTarget(path, { signal: controller.signal }); + if (result.ok) throw new Error("Cancelled target resolution succeeded"); + assert.ok(result.error instanceof AnalysisCancelledError); +}); diff --git a/tests/composition/analysis-sessions/mips-identity.test.ts b/tests/composition/analysis-sessions/mips-identity.test.ts new file mode 100644 index 000000000..cf6c52fb7 --- /dev/null +++ b/tests/composition/analysis-sessions/mips-identity.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { + createAnalysisSnapshotEntry, + parseAnalysisSnapshot, + serializeAnalysisSnapshot, + snapshotBinding, + snapshotTarget, + snapshotMatchesProfile, +} from "../../../src/domain/analysisSnapshot.js"; +import { sessionOutputSchemas } from "../../../src/contracts/toolOutputSchemaGroups.js"; + +const target: BinaryTarget = { + path: "/source-owned.elf", + sha256: "a".repeat(64), + kind: "executable", + format: "elf", + architecture: "mips", + availableArchitectures: ["mips"], + mips: { elfClass: 32, byteOrder: "little", type: 2, flags: 0x70001001 }, +}; +const provider = { id: "ghidra", name: "Ghidra", version: "12.1.4" }; +const profile = createAnalysisProfile(provider, { + mips_elf: { elf_class: 32, byte_order: "little", type: 2, flags: 0x70001001 }, +}); + +describe("MIPS identity through lifecycle, Evidence and snapshot consumers", () => { + it("round-trips query bindings without relaxing integrity", () => { + const result = [{ address: "0x20150", value: "rea_mips_entry" }]; + const parameters = {}; + const evidence = createEvidence(target, provider, { + operation: "list_procedures", + parameters, + result, + analysisProfile: profile, + }); + const binding = snapshotBinding(profile); + const entry = createAnalysisSnapshotEntry({ + target: snapshotTarget(target), + binding, + operation: "list_procedures", + parameters, + execution: { + result, + rawResult: null, + provider, + analysisProfile: profile, + limitations: [], + locations: [], + subject: target, + }, + }); + const snapshot = { + target: snapshotTarget(target), + binding, + entries: [entry], + workflow_entries: [], + evidence_bundle: createEvidenceBundle([evidence]), + }; + const restored = parseAnalysisSnapshot( + JSON.parse(serializeAnalysisSnapshot(snapshot)), + ); + expect(restored.target.architecture).toBe("mips"); + expect(restored.evidence_bundle.records[0]?.subject?.architecture).toBe( + "mips", + ); + expect(restored.entries[0]?.execution.subject?.architecture).toBe("mips"); + expect( + sessionOutputSchemas.open_binary.parse({ result: target }).result + .architecture, + ).toBe("mips"); + expect(() => + parseEvidence({ + ...evidence, + subject: { ...evidence.subject, architecture: "x86" }, + }), + ).toThrow("semantic identifier"); + const changed = createAnalysisProfile(provider, { + mips_elf: { + elf_class: 32, + byte_order: "big", + type: 2, + flags: 0x70001001, + }, + }); + expect(snapshotMatchesProfile(binding, changed)).toBe(false); + }); +}); diff --git a/tests/conformance/c/mips.c b/tests/conformance/c/mips.c new file mode 100644 index 000000000..5ad20c699 --- /dev/null +++ b/tests/conformance/c/mips.c @@ -0,0 +1,14 @@ +/* Freestanding, source-owned static-analysis fixture. Never executed by REA. */ +volatile unsigned int rea_mips_global = 7; +const char rea_mips_marker[] = "rea-mips-source-owned-fixture"; + +__attribute__((noinline)) unsigned int rea_mips_leaf(unsigned int value) +{ + return value * 3u + rea_mips_global; +} + +__attribute__((noinline)) unsigned int rea_mips_entry(unsigned int selector) +{ + unsigned int adjusted = selector > 10u ? selector : selector + 1u; + return rea_mips_leaf(adjusted) + (unsigned char)rea_mips_marker[0]; +} diff --git a/tests/conformance/ghidra/mips-checks.d.mts b/tests/conformance/ghidra/mips-checks.d.mts new file mode 100644 index 000000000..fb0fc459e --- /dev/null +++ b/tests/conformance/ghidra/mips-checks.d.mts @@ -0,0 +1,35 @@ +import type { MipsElfMetadata } from "../../../src/domain/binaryTargetTypes.js"; + +export interface MipsReadelfFacts { + mips: MipsElfMetadata; + symbols: Record< + | "rea_mips_entry" + | "rea_mips_leaf" + | "rea_mips_probe" + | "rea_mips_global" + | "rea_mips_marker", + string + >; +} +export function parseMipsReadelf( + text: string, + byteOrder: string, +): MipsReadelfFacts; +export function inspectMipsReadelf( + path: string, + byteOrder: string, +): Promise< + MipsReadelfFacts & { command: string; version: string; raw: string } +>; +export function assertMipsLoadedImage( + observations: unknown, + byteOrder: string, + sha256: string, +): void; +export function assertMipsProbe( + move: unknown, + branch: unknown, + byteOrder: string, + address: string, +): void; +export function assertMipsGlobal(bytes: string, byteOrder: string): void; diff --git a/tests/conformance/ghidra/mips-checks.mjs b/tests/conformance/ghidra/mips-checks.mjs new file mode 100644 index 000000000..48a16e118 --- /dev/null +++ b/tests/conformance/ghidra/mips-checks.mjs @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); +const language = (byteOrder) => { + assert.ok(byteOrder === "little" || byteOrder === "big"); + return `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`; +}; +const wordHex = (value, byteOrder) => { + language(byteOrder); + const bytes = Buffer.alloc(4); + if (byteOrder === "little") bytes.writeUInt32LE(value); + else bytes.writeUInt32BE(value); + return bytes.toString("hex"); +}; + +/** Parse only the documented GNU readelf fields needed by our tiny fixture. */ +export function parseMipsReadelf(text, byteOrder) { + language(byteOrder); + const field = (expression, label) => { + const matches = [...text.matchAll(expression)]; + assert.equal(matches.length, 1, `Missing or ambiguous readelf ${label}`); + return matches[0][1].trim(); + }; + assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), "ELF32"); + assert.equal( + field(/^\s*Data:.*?,\s*(little|big) endian\s*$/gmu, "byte order"), + byteOrder, + ); + assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u); + assert.match(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), /^MIPS\b/u); + const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+),.*$/gmu, "flags")); + assert.equal(flags, 0x70001001, "Fixture ELF flags differ from build intent"); + assert.equal(field(/^ISA:\s*(.+)$/gmu, "ISA"), "MIPS32r2"); + const registerSize = (name) => { + const bits = Number( + field(new RegExp(`^${name} size:\\s*(\\d+)\\s*$`, "gmu"), name), + ); + assert.ok(bits === 0 || bits === 32, `Unexpected ${name} width`); + return bits === 32 ? 1 : 0; + }; + const fp = field(/^FP ABI:\s*(.+)$/gmu, "FP ABI"); + const fpAbi = new Map([ + ["Hard float (double precision)", 1], + ["Hard float (32-bit CPU, Any FPU)", 5], + ]).get(fp); + assert.ok(fpAbi !== undefined, `Unrecognized fixture FP ABI: ${fp}`); + assert.equal(field(/^ISA Extension:\s*(.+)$/gmu, "ISA extension"), "None"); + assert.equal(field(/^ASEs:\s*\n[ \t]*(.+)$/gmu, "ASEs"), "None"); + const abiFlags = { + version: Number( + field(/^MIPS ABI Flags Version:\s*(\d+)\s*$/gmu, "ABI version"), + ), + isaLevel: 32, + isaRevision: 2, + gprSize: registerSize("GPR"), + cpr1Size: registerSize("CPR1"), + cpr2Size: registerSize("CPR2"), + fpAbi, + 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, + ), + }; + const symbols = {}; + for (const name of [ + "rea_mips_entry", + "rea_mips_leaf", + "rea_mips_probe", + "rea_mips_global", + "rea_mips_marker", + ]) { + const value = field( + new RegExp( + `^\\s*\\d+:\\s+([\\da-f]+)\\s+\\d+\\s+(?:FUNC|OBJECT)\\s+GLOBAL\\s+\\S+\\s+\\d+\\s+${name}\\s*$`, + "gmu", + ), + `symbol ${name}`, + ); + symbols[name] = `0x${BigInt(`0x${value}`).toString(16)}`; + } + return { + mips: { elfClass: 32, byteOrder, type: 2, flags, abiFlags }, + symbols, + }; +} + +/** Independent reader is a bounded test prerequisite, never an REA provider. */ +export async function inspectMipsReadelf(path, byteOrder) { + const command = process.env.REA_MIPS_READELF ?? "readelf"; + const options = { + env: { ...process.env, LC_ALL: "C" }, + timeout: 10000, + maxBuffer: 1024 * 1024, + }; + const version = await execute(command, ["--version"], options); + assert.match( + version.stdout, + /^GNU readelf\b/u, + "This lane requires GNU readelf", + ); + const result = await execute( + command, + ["-h", "-A", "-s", "-W", path], + options, + ); + assert.equal( + result.stderr.trim(), + "", + `readelf diagnostics: ${result.stderr}`, + ); + return { + ...parseMipsReadelf(result.stdout, byteOrder), + command, + version: version.stdout.split("\n")[0], + raw: result.stdout, + }; +} + +/** Check effective Ghidra observations, not the requested analysis profile. */ +export function assertMipsLoadedImage(observations, byteOrder, sha256) { + assert.equal(observations.language_id, language(byteOrder)); + assert.equal(observations.compiler_spec_id, "default"); + assert.ok( + observations.source_files.some( + (file) => + file.original_sha256 === sha256 && file.modified_sha256 === sha256, + ), + "Ghidra loaded source identity changed", + ); +} + +/** Verify the exact constant/conditional destination in mips-probe.S. */ +export function assertMipsProbe(move, branch, byteOrder, address) { + const start = BigInt(address); + for (const [instruction, offset, word] of [ + [move, 0n, 0x24821234], + [branch, 4n, 0x10800003], + ]) { + assert.equal(instruction.status, "decoded"); + assert.equal(BigInt(instruction.address), start + offset); + assert.equal(instruction.architecture, language(byteOrder)); + assert.equal(instruction.length, 4); + assert.equal(instruction.bytes, wordHex(word, byteOrder)); + } + assert.match(move.mnemonic, /^ADDIU$/iu); + assert.ok( + move.operands.some( + (operand) => + operand.index === 2 && + operand.components.some( + (part) => + part.kind === "immediate" && + part.value !== null && + BigInt(part.value) === 0x1234n, + ), + ), + "ADDIU immediate was not recovered", + ); + assert.match(branch.mnemonic, /^BEQZ?$/iu); + assert.equal(branch.flow.kind, "jump"); + assert.equal(branch.flow.conditional, true); + assert.equal(branch.flow.computed, false); + assert.deepEqual( + branch.flow.direct_destinations.map((value) => BigInt(value)), + [start + 20n], + ); +} + +/** Four bytes must encode the known source-owned global in the declared order. */ +export function assertMipsGlobal(bytes, byteOrder) { + assert.equal(bytes, wordHex(7, byteOrder)); +} diff --git a/tests/conformance/ghidra/mips-checks.test.ts b/tests/conformance/ghidra/mips-checks.test.ts new file mode 100644 index 000000000..778560ba8 --- /dev/null +++ b/tests/conformance/ghidra/mips-checks.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { + assertMipsGlobal, + assertMipsLoadedImage, + assertMipsProbe, + parseMipsReadelf, +} from "./mips-checks.mjs"; + +// GNU readelf 2.40 output for the source-owned Clang 17 / LLD fixture. +// This tests the reader boundary, not Ghidra; the real lane rebuilds both orders. +const readelf = `ELF Header: + Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 + Class: ELF32 + Data: 2's complement, little endian + Version: 1 (current) + OS/ABI: UNIX - System V + ABI Version: 0 + Type: EXEC (Executable file) + Machine: MIPS R3000 + Version: 0x1 + Entry point address: 0x201b0 + Start of program headers: 52 (bytes into file) + Start of section headers: 2996 (bytes into file) + Flags: 0x70001001, noreorder, o32, mips32r2 + Size of this header: 52 (bytes) + Size of program headers: 32 (bytes) + Number of program headers: 7 + Size of section headers: 40 (bytes) + Number of section headers: 24 + Section header string table index: 22 + +Symbol table '.symtab' contains 9 entries: + Num: Value Size Type Bind Vis Ndx Name + 0: 00000000 0 NOTYPE LOCAL DEFAULT UND + 1: 00000000 0 FILE LOCAL DEFAULT ABS mips.c + 2: 00020254 0 NOTYPE LOCAL DEFAULT 4 .Lzero + 3: 00038250 0 NOTYPE LOCAL HIDDEN 6 _gp + 4: 00020170 64 FUNC GLOBAL DEFAULT 4 rea_mips_leaf + 5: 00030268 4 OBJECT GLOBAL DEFAULT 7 rea_mips_global + 6: 000201b0 136 FUNC GLOBAL DEFAULT 4 rea_mips_entry + 7: 00010148 30 OBJECT GLOBAL DEFAULT 3 rea_mips_marker + 8: 00020240 32 FUNC GLOBAL DEFAULT 4 rea_mips_probe + +MIPS ABI Flags Version: 0 + +ISA: MIPS32r2 +GPR size: 32 +CPR1 size: 32 +CPR2 size: 0 +FP ABI: Hard float (32-bit CPU, Any FPU) +ISA Extension: None +ASEs: + None +FLAGS 1: 00000000 +FLAGS 2: 00000000 +`; +const sha256 = "a".repeat(64); +const image = { + language_id: "MIPS:LE:32:default", + compiler_spec_id: "default", + source_files: [{ original_sha256: sha256, modified_sha256: sha256 }], +}; +const move = { + address: "0x20240", + status: "decoded", + architecture: "MIPS:LE:32:default", + length: 4, + bytes: "34128224", + mnemonic: "addiu", + operands: [ + { index: 2, components: [{ kind: "immediate", value: "0x1234" }] }, + ], +}; +const branch = { + address: "0x20244", + status: "decoded", + architecture: "MIPS:LE:32:default", + length: 4, + bytes: "03008010", + mnemonic: "beqz", + flow: { + kind: "jump", + conditional: true, + computed: false, + direct_destinations: ["0x20254"], + }, +}; + +describe("MIPS real-verifier independent expectations", () => { + it("reads GNU header, ABI and defined symbols without a Ghidra-derived oracle", () => { + const parsed = parseMipsReadelf(readelf, "little"); + expect(parsed.mips).toMatchObject({ + elfClass: 32, + byteOrder: "little", + flags: 0x70001001, + abiFlags: { + isaLevel: 32, + isaRevision: 2, + gprSize: 1, + cpr1Size: 1, + fpAbi: 5, + }, + }); + expect(parsed.symbols.rea_mips_probe).toBe("0x20240"); + expect( + parseMipsReadelf(readelf.replace("little endian", "big endian"), "big") + .mips.byteOrder, + ).toBe("big"); + }); + + it.each([ + ["wrong class", "ELF32", "ELF64"], + ["wrong byte order", "little endian", "big endian"], + ["wrong machine", "MIPS R3000", "AArch64"], + ["wrong object type", "EXEC (Executable file)", "DYN (Shared object file)"], + ["wrong flags", "0x70001001", "0x60001001"], + ["wrong ISA", "ISA: MIPS32r2", "ISA: MIPS32r6"], + ["unknown FP ABI", "Hard float (32-bit CPU, Any FPU)", "unknown"], + ["unknown register width", "GPR size: 32", "GPR size: 64"], + ["missing symbol", "rea_mips_probe", "unrelated_probe"], + ["missing ABI", "MIPS ABI Flags Version: 0", "ABI omitted"], + ])( + "rejects %s instead of returning a guessed fixture interpretation", + (_name, from, to) => { + expect(() => + parseMipsReadelf(readelf.replace(from, to), "little"), + ).toThrow(); + }, + ); + + it("rejects ambiguous producer fields", () => { + expect(() => + parseMipsReadelf(`${readelf}\nClass: ELF32\n`, "little"), + ).toThrow(/ambiguous/u); + }); + + it("checks the effective loaded image, not requested profile metadata", () => { + assertMipsLoadedImage(image, "little", sha256); + expect(() => + assertMipsLoadedImage( + { ...image, language_id: "MIPS:BE:32:default" }, + "little", + sha256, + ), + ).toThrow(); + expect(() => + assertMipsLoadedImage( + { ...image, compiler_spec_id: "other" }, + "little", + sha256, + ), + ).toThrow(); + expect(() => + assertMipsLoadedImage( + { + ...image, + source_files: [ + { original_sha256: sha256, modified_sha256: "b".repeat(64) }, + ], + }, + "little", + sha256, + ), + ).toThrow(); + }); + + it("checks exact probe bytes, immediate and conditional destination in both orders", () => { + assertMipsProbe(move, branch, "little", "0x20240"); + assertMipsProbe( + { ...move, architecture: "MIPS:BE:32:default", bytes: "24821234" }, + { ...branch, architecture: "MIPS:BE:32:default", bytes: "10800003" }, + "big", + "0x20240", + ); + }); + + it("rejects plausible nonempty but incorrect instruction observations", () => { + expect(() => + assertMipsProbe( + { ...move, bytes: "35128224" }, + branch, + "little", + "0x20240", + ), + ).toThrow(); + expect(() => + assertMipsProbe({ ...move, length: 2 }, branch, "little", "0x20240"), + ).toThrow(); + expect(() => + assertMipsProbe( + { + ...move, + operands: [ + { index: 2, components: [{ kind: "immediate", value: "0x1235" }] }, + ], + }, + branch, + "little", + "0x20240", + ), + ).toThrow(); + expect(() => + assertMipsProbe( + move, + { + ...branch, + flow: { ...branch.flow, direct_destinations: ["0x20250"] }, + }, + "little", + "0x20240", + ), + ).toThrow(); + expect(() => + assertMipsProbe( + move, + { ...branch, flow: { ...branch.flow, conditional: false } }, + "little", + "0x20240", + ), + ).toThrow(); + }); + + it("checks all four global bytes in the declared order", () => { + assertMipsGlobal("07000000", "little"); + assertMipsGlobal("00000007", "big"); + expect(() => assertMipsGlobal("00000007", "little")).toThrow(); + expect(() => assertMipsGlobal("0700", "little")).toThrow(); + }); +}); diff --git a/tests/conformance/ghidra/mips-fixture.mjs b/tests/conformance/ghidra/mips-fixture.mjs new file mode 100644 index 000000000..7a0017cb3 --- /dev/null +++ b/tests/conformance/ghidra/mips-fixture.mjs @@ -0,0 +1,74 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); + +/** Build a source-owned freestanding ELF; never execute its target code. */ +export async function buildMipsFixture(directory, byteOrder) { + if (byteOrder !== "little" && byteOrder !== "big") + throw new Error("MIPS fixture byte order must be little or big"); + const compiler = process.env.REA_MIPS_CLANG ?? "clang"; + const target = byteOrder === "little" ? "mipsel-linux-gnu" : "mips-linux-gnu"; + const source = fileURLToPath(new URL("../c/mips.c", import.meta.url)); + const probe = fileURLToPath(new URL("./mips-probe.S", import.meta.url)); + const path = join(directory, `${target}.elf`); + const args = [ + `--target=${target}`, + "-march=mips32r2", + "-mabi=32", + "-mno-abicalls", + "-fno-pic", + "-fuse-ld=lld", + "-nostdlib", + "-static", + "-O0", + "-g", + "-Wl,-e,rea_mips_entry", + "-Wl,--build-id=none", + source, + probe, + "-o", + path, + ]; + const version = await execute(compiler, ["--version"], { timeout: 10000 }); + await execute(compiler, args, { timeout: 60000 }); + const bytes = await readFile(path); + const read16 = + byteOrder === "little" + ? bytes.readUInt16LE.bind(bytes) + : bytes.readUInt16BE.bind(bytes); + const read32 = + byteOrder === "little" + ? bytes.readUInt32LE.bind(bytes) + : bytes.readUInt32BE.bind(bytes); + if ( + bytes.length < 52 || + bytes.toString("hex", 0, 4) !== "7f454c46" || + bytes[4] !== 1 || + bytes[5] !== (byteOrder === "little" ? 1 : 2) || + read16(16) !== 2 || + read16(18) !== 8 || + read32(36) !== 0x70001001 + ) + throw new Error( + "Compiler did not produce the declared ELF32/o32/MIPS32r2 fixture", + ); + return { + path, + byte_order: byteOrder, + flags: read32(36), + sha256: createHash("sha256").update(bytes).digest("hex"), + source_sha256: createHash("sha256") + .update(await readFile(source)) + .digest("hex"), + probe_sha256: createHash("sha256") + .update(await readFile(probe)) + .digest("hex"), + compiler: version.stdout.trim(), + arguments: args, + }; +} diff --git a/tests/conformance/ghidra/mips-probe.S b/tests/conformance/ghidra/mips-probe.S new file mode 100644 index 000000000..69c9d9366 --- /dev/null +++ b/tests/conformance/ghidra/mips-probe.S @@ -0,0 +1,24 @@ +/* Source-owned standard MIPS32 instructions with fixed, reviewable encodings. + * No target execution. The branch at +4 targets +20; its delay slot is a NOP. + */ +.set push +.set noreorder +.text +.p2align 2 +.globl rea_mips_probe +.type rea_mips_probe, @function +.ent rea_mips_probe +rea_mips_probe: + addiu $2, $4, 0x1234 + beq $4, $0, .Lzero + nop + jr $31 + nop +.Lzero: + addiu $2, $0, 7 + jr $31 + nop +.end rea_mips_probe +.size rea_mips_probe, .-rea_mips_probe +.set pop +.section .note.GNU-stack,"",@progbits