diff --git a/docs/providers/ghidra-mips.md b/docs/providers/ghidra-mips.md index e40e80d6360..aba0bda10db 100644 --- a/docs/providers/ghidra-mips.md +++ b/docs/providers/ghidra-mips.md @@ -8,6 +8,18 @@ PSP/Allegrex has a [separate static-ELF profile](ghidra-psp.md) tracked in ## Admission boundary +After building, `node scripts/verify-real-ghidra-mips-boundaries.mjs` checks both +byte orders through production CLI/MCP. GNU readelf independently decodes the +soft-float attribute fixture; declared-o32 MIPS I and unsupported GNU soft-float +inputs are refused, while recordless MIPS I and MIPS32 controls produce real +Ghidra dossiers and exact loaded bytes. These observations do not establish +MIPS I instruction-subset or load-delay correctness. Supply existing Ghidra/Java +and GNU readelf (`REA_READELF`). Source hashes are checked after inspection, +and no target is executed. Cleanup checks both process lineage and an independent +run-token sweep using each provider run ID retained from `binary_session` before +closing the target. Ghidra overrides the outer verifier token with that session +ID; checking the outer token alone cannot detect a reparented engine. + 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 diff --git a/knip.json b/knip.json index 4d24b67dd53..4c4688ae7ea 100644 --- a/knip.json +++ b/knip.json @@ -13,6 +13,7 @@ "scripts/verify/fridaBindingsCompatibility.ts", "scripts/verify/termux/browser.mjs", "scripts/verify-real-ghidra-noreturn.mjs", + "scripts/verify-real-ghidra-mips-boundaries.mjs", "scripts/verify-real-ghidra-decompiler-failure.mjs", "scripts/verify-real-ghidra-decompile-timeout.mjs", "tests/processOwnershipRunner.ts" diff --git a/scripts/lib/verifier-token-cleanup.d.mts b/scripts/lib/verifier-token-cleanup.d.mts new file mode 100644 index 00000000000..002cde7e61a --- /dev/null +++ b/scripts/lib/verifier-token-cleanup.d.mts @@ -0,0 +1,30 @@ +import type { + ProcessCleanupResult, + ProcessOwnershipHost, +} from "../../src/process/ProcessOwnership.js"; +import type { VerifierRunStart } from "./verifier-run.mjs"; + +/** Capture the prelaunch identities used by the canonical, PPID-independent sweep. */ +export function prepareVerifierTokenCleanup( + run: VerifierRunStart, + suppliedHost?: ProcessOwnershipHost, +): Promise<{ + /** Retain a validated provider-owned receipt before closing its session. */ + retainProviderRun( + runId: string, + providerScope?: { + readonly leaderPid: number; + readonly processGroupId: number; + readonly sampledProcessGroupIds?: readonly number[]; + }, + ): Promise; + verify(): Promise<{ + readonly cleaned: boolean; + readonly reason?: string; + readonly runs: readonly { + readonly run_id: string; + readonly result: ProcessCleanupResult; + }[]; + }>; + close(): Promise; +}>; diff --git a/scripts/lib/verifier-token-cleanup.mjs b/scripts/lib/verifier-token-cleanup.mjs new file mode 100644 index 00000000000..05f17242040 --- /dev/null +++ b/scripts/lib/verifier-token-cleanup.mjs @@ -0,0 +1,117 @@ +import { verifyNoTokenOwnedProcesses } from "../../dist/process/ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "../../dist/process/ProcessOwnershipEvidence.js"; +import { + createSystemProcessOwnershipHost, + readProcessOwnershipFacts, +} from "../../dist/process/ProcessOwnershipObservation.js"; +import { readRunTokenCarriers } from "../../dist/process/ProcessRunTokenObservations.js"; + +/** Capture the prelaunch identities used by the canonical, PPID-independent sweep. */ +export async function prepareVerifierTokenCleanup(run, suppliedHost) { + const host = suppliedHost ?? createSystemProcessOwnershipHost(); + try { + await host.prepare?.(); + const baseline = await host.captureBaseline?.(); + if (baseline === undefined) + throw new Error("Verifier process ownership baseline is unavailable"); + const launcher = (await host.listProcesses()).find( + ({ pid }) => pid === run.verifier_pid, + ); + if (launcher === undefined) + throw new Error("Verifier launcher is absent from the process snapshot"); + const relations = new Map(); + const retain = (runId, providerScope) => { + if (typeof runId !== "string" || runId.length === 0) + throw new Error("Provider run ownership receipt is unavailable"); + if (!relations.has(runId)) + relations.set(runId, { + leaderPid: providerScope?.leaderPid ?? run.verifier_pid, + processGroupId: + providerScope?.processGroupId ?? launcher.processGroupId, + ...(providerScope?.sampledProcessGroupIds === undefined + ? {} + : { sampledProcessGroupIds: providerScope.sampledProcessGroupIds }), + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + }); + }; + retain(run.run_id); + return { + retainProviderRun: async (runId, providerScope) => { + if (typeof runId !== "string" || runId.length === 0) + throw new Error("Provider run ownership receipt is unavailable"); + if (runId === run.run_id) + throw new Error( + "Provider receipt must identify its own run, not the verifier run", + ); + if (providerScope === undefined) { + // Discovery proves presence only. Identity/token/identity reads then + // establish scope anchors; the final canonical sweep proves cleanup. + const carriers = await readRunTokenCarriers(host, runId); + const facts = ( + await readProcessOwnershipFacts(carriers, host) + ).filter( + (fact) => + fact.state === "same-generation" && + fact.token.state === "readable" && + fact.token.runId === runId && + fact.identityAfter.state === "readable" && + fact.identityAfter.relation?.state === "observed", + ); + if (facts.length === 0) + throw new Error( + `No token-verified process ownership for provider run ${runId}`, + ); + providerScope = { + leaderPid: facts[0].process.pid, + processGroupId: facts[0].identityAfter.relation.processGroupId, + sampledProcessGroupIds: [ + ...new Set( + facts.map( + ({ identityAfter }) => identityAfter.relation.processGroupId, + ), + ), + ], + }; + } + retain(runId, providerScope); + const relation = relations.get(runId); + const active = await verifyNoTokenOwnedProcesses( + runId, + host, + baseline, + relation, + ); + if (relation.evidenceOwner.coverage.retainedOwned === 0) + throw new Error( + `No token-verified process ownership for provider run ${runId}: ${JSON.stringify(active)}`, + ); + }, + verify: async () => { + const runs = []; + for (const [runId, relation] of relations) { + runs.push({ + run_id: runId, + result: await verifyNoTokenOwnedProcesses( + runId, + host, + baseline, + relation, + ), + }); + } + const failed = runs.find(({ result }) => !result.cleaned); + return { + cleaned: failed === undefined, + ...(failed === undefined ? {} : { reason: failed.result.reason }), + runs, + }; + }, + close: async () => { + await host.close?.(); + }, + }; + } catch (cause) { + await host.close?.(); + throw cause; + } +} diff --git a/scripts/verify-real-ghidra-mips-boundaries.mjs b/scripts/verify-real-ghidra-mips-boundaries.mjs new file mode 100644 index 00000000000..c1b259a7c00 --- /dev/null +++ b/scripts/verify-real-ghidra-mips-boundaries.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, writeFile, 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 { buildMips1Fixture } from "../tests/conformance/ghidra/mips1-fixture.mjs"; +import { + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; +import { prepareVerifierTokenCleanup } from "./lib/verifier-token-cleanup.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { functionDossierSchema } from "../dist/domain/native/functionDossier.js"; + +// Static source-owned controls. Never execute an ELF or acquire a toolchain. +if (!["darwin", "linux"].includes(process.platform)) + throw new Error( + "MIPS boundary verification requires a Linux/macOS Ghidra host with verified process lineage.", + ); +if (!process.env.GHIDRA_INSTALL_DIR || !process.env.JAVA_HOME) + throw new Error( + "Supply GHIDRA_INSTALL_DIR and JAVA_HOME for the MIPS32 positive control.", + ); +const execute = promisify(execFile); +const run = createVerifierRun(); +const root = await mkdtemp(join(tmpdir(), "rea-mips-boundaries-")); +const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const cli = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +// Only declared tool prerequisites and resource settings reach REA children. +// Ambient REA seeds, analyzer controls and session settings cannot alter controls. +const env = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + TMPDIR: root, + REA_ANALYSIS_PROVIDER: "ghidra", + REA_LOG_LEVEL: "silent", + REA_PROCESS_RUN_ID: run.run_id, + GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR, + JAVA_HOME: process.env.JAVA_HOME, + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper", +}; +for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) { + if (process.env[setting] !== undefined) env[setting] = process.env[setting]; +} +const reports = []; +let primaryFailure; +const cleanup = await prepareVerifierTokenCleanup(run); +try { + for (const byteOrder of ["little", "big"]) { + const original = await buildMips1Fixture(root, byteOrder); + const initial = await readFile(original.path); + const legacy = Buffer.from(initial); + const u32 = (bytes, offset, value) => + byteOrder === "little" + ? bytes.writeUInt32LE(value, offset) + : bytes.writeUInt32BE(value, offset); + const u16 = (bytes, offset, value) => + byteOrder === "little" + ? bytes.writeUInt16LE(value, offset) + : bytes.writeUInt16BE(value, offset); + u32(legacy, 36, 0x50000001); // Existing recordless MIPS32 lane. + const legacyPath = join(root, `legacy-${byteOrder}.elf`); + await writeFile(legacyPath, legacy); + const recordlessMips1 = Buffer.from(initial); + u32(recordlessMips1, 36, 0x1); + const recordlessMips1Path = join(root, `recordless-mips1-${byteOrder}.elf`); + await writeFile(recordlessMips1Path, recordlessMips1); + const gnu = Buffer.alloc(0x1200); + legacy.copy(gnu); + u32(gnu, 68, gnu.length); + u32(gnu, 72, gnu.length); + u16(gnu, 48, 6); + u32(gnu, 0x1188 + 4, 0x6ffffff5); + u32(gnu, 0x1188 + 16, 0x11d0); + u32(gnu, 0x1188 + 20, 16); + gnu[0x11d0] = 0x41; + u32(gnu, 0x11d1, 15); + gnu.write("gnu\0", 0x11d5, "ascii"); + gnu[0x11d9] = 1; + u32(gnu, 0x11da, 7); + gnu[0x11de] = 4; // Tag_GNU_MIPS_ABI_FP + gnu[0x11df] = 3; // Soft float. + const gnuPath = join(root, `gnu-fp-${byteOrder}.elf`); + await writeFile(gnuPath, gnu); + const { stdout: attributes } = await execute( + process.env.REA_READELF ?? "readelf", + ["-A", gnuPath], + { env: { PATH: env.PATH, LC_ALL: "C" } }, + ); + assert.match(attributes, /Tag_GNU_MIPS_ABI_FP.*Soft float/iu); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [cli, "mcp"], + env, + stderr: "inherit", + }); + const client = new Client({ name: "rea-mips-boundaries", version: "1" }); + const invoke = (name, args = {}) => + client.callTool({ name, arguments: args }, { timeout: 240_000 }); + const call = async (name, args = {}) => + requireMcpOperationResult(await invoke(name, args), name); + try { + await client.connect(transport); + for (const [path, pattern] of [ + [ + original.path, + /requires EF_MIPS_ARCH_32R2 for headers that declare an ABI/u, + ], + [gnuPath, /GNU floating-point ABI attribute/u], + ]) { + const error = requireMcpToolError( + await invoke("open_binary", { path, provider_id: "ghidra" }), + ); + assert.match(JSON.stringify(error), pattern); + let stdout; + try { + await execute( + process.execPath, + [ + cli, + "function", + path, + original.entry, + "--provider", + "ghidra", + "--full-output", + "--json", + ], + { env, timeout: 240_000 }, + ); + assert.fail("CLI admitted an unsupported MIPS declaration"); + } catch (cause) { + assert.equal(cause.code, 1); + stdout = cause.stdout; + } + const reply = JSON.parse(stdout); + assert.equal(reply.ok, false); + assert.match(JSON.stringify(reply), pattern); + } + const providerRuns = []; + for (const [path, expected] of [ + [legacyPath, legacy], + [recordlessMips1Path, recordlessMips1], + ]) { + await call("open_binary", { path, provider_id: "ghidra" }); + // Composite open/health does not launch Ghidra; analyze_function does. + const dossier = functionDossierSchema.parse( + await call("analyze_function", { + procedure: original.entry, + detail: "complete", + }), + ); + assert.ok(dossier.pseudocode.length > 0); + const status = await call("binary_session"); + assert.equal(status.analysis_provider_binding?.provider.id, "ghidra"); + assert.equal(typeof status.analysis_run?.run_id, "string"); + // The session owner generated this ID, and the launcher installs it on + // Ghidra in place of the surrounding verifier's process-run token. + assert.equal(status.open, true); + const providerLineage = + status.analysis_run.process_lineage.snapshots?.find( + ({ provider }) => provider.id === "ghidra", + )?.observation; + const providerScope = + providerLineage?.status === "verified" + ? { + leaderPid: providerLineage.launcher_pid, + processGroupId: providerLineage.process_group_id, + sampledProcessGroupIds: providerLineage.descendants.map( + ({ process_group_id }) => process_group_id, + ), + } + : undefined; + // Capture native token-verified identities while the provider is live; + // A public lineage snapshot is optional; native ownership is mandatory. + await cleanup.retainProviderRun( + status.analysis_run.run_id, + providerScope, + ); + providerRuns.push({ + control: + path === legacyPath ? "recordless_mips32" : "recordless_mips1", + run_id: status.analysis_run.run_id, + ...(providerScope === undefined + ? { public_lineage: "not_observed" } + : providerScope), + }); + const loaded = await call("read_bytes", { + address: original.entry, + length: 24, + }); + assert.equal( + loaded.bytes_hex, + expected.subarray(0x1000, 0x1018).toString("hex"), + ); + await call("close_binary"); + } + assert.deepEqual(await readFile(original.path), initial); + assert.deepEqual(await readFile(gnuPath), gnu); + assert.deepEqual(await readFile(legacyPath), legacy); + assert.deepEqual(await readFile(recordlessMips1Path), recordlessMips1); + reports.push({ + byte_order: byteOrder, + provider_runs: providerRuns, + declared_o32_mips1: "refused by CLI and MCP", + recordless_mips1: + "real Ghidra dossier and exact loaded bytes; instruction subset/load-delay semantics unverified", + gnu_fp_attributes: + "independently decoded soft-float declaration; refused by CLI and MCP", + existing_mips32_control: "real Ghidra dossier and exact loaded bytes", + hashes: { + mips1: digest(initial), + gnu: digest(gnu), + legacy: digest(legacy), + recordless_mips1: digest(recordlessMips1), + }, + source_unchanged: true, + target_executed: false, + }); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + } +} catch (cause) { + primaryFailure = cause; +} +let tokenCleanup; +try { + tokenCleanup = await cleanup.verify(); +} finally { + await cleanup.close(); +} +const completed = await completeVerifierRun(run); +if ( + !tokenCleanup.cleaned || + completed.process_lineage.status !== "verified" || + completed.process_lineage.descendants.length !== 0 +) + throw new Error( + `Cleanup unverified (${JSON.stringify(tokenCleanup)}); retained diagnostic workspace: ${root}`, + { cause: primaryFailure }, + ); +if (primaryFailure !== undefined) + throw new Error( + `MIPS boundary verification failed; retained workspace: ${root}`, + { cause: primaryFailure }, + ); +await rm(root, { recursive: true, force: true }); +console.log( + JSON.stringify( + { ...completed, token_cleanup: tokenCleanup, status: "passed", reports }, + null, + 2, + ), +); diff --git a/tests/boundary/process/verifierTokenCleanup.test.ts b/tests/boundary/process/verifierTokenCleanup.test.ts new file mode 100644 index 00000000000..14458b11f72 --- /dev/null +++ b/tests/boundary/process/verifierTokenCleanup.test.ts @@ -0,0 +1,217 @@ +import { randomUUID } from "node:crypto"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { + createSystemProcessOwnershipHost, + observeProcessStartIdentity, + observeProcessStartIdentityExit, + signalProcessWithStartIdentity, +} from "../../../src/process/ProcessOwnershipObservation.js"; +import { expect, it, vi } from "vitest"; +import { prepareVerifierTokenCleanup } from "../../../scripts/lib/verifier-token-cleanup.mjs"; +import type { + ProcessOwnershipHost, + ProcessIdentityObservation, + ProcessTableEntry, +} from "../../../src/process/ProcessOwnership.js"; + +it("rejects a token-owned survivor after it is reparented outside the verifier tree", async () => { + const launcher: ProcessTableEntry = { + pid: 100, + parentPid: 50, + processGroupId: 100, + state: "S", + command: "verifier", + }; + const orphan: ProcessTableEntry = { + pid: 200, + parentPid: 1, + processGroupId: 200, + state: "S", + command: "Ghidra control", + }; + const foreign: ProcessTableEntry = { + pid: 300, + parentPid: 1, + processGroupId: 300, + state: "S", + command: "unrelated", + }; + let rows = [launcher]; + const close = vi.fn(); + const host: ProcessOwnershipHost = { + platform: "linux", + prepare: vi.fn(), + close, + captureBaseline: async () => [{ pid: launcher.pid, identity: "start:100" }], + listProcesses: async () => rows, + environment: async (pid) => + pid === orphan.pid ? { REA_PROCESS_RUN_ID: "provider-run" } : {}, + runTokens: async (selected) => + new Map( + selected.map(({ pid }) => [ + pid, + { + state: "readable", + runId: + pid === orphan.pid + ? "provider-run" + : pid === foreign.pid + ? "foreign-run" + : undefined, + } as const, + ]), + ), + processIdentities: async (selected) => + new Map( + selected.map((row) => [ + row.pid, + rows.some(({ pid }) => pid === row.pid) + ? ({ + state: "readable", + identity: `start:${row.pid}`, + lifecycle: { state: "observed", lifecycle: "non-zombie" }, + relation: { + state: "observed", + parentPid: row.parentPid, + processGroupId: row.processGroupId, + }, + } as const) + : ({ + state: "absent", + reason: "not-found-in-caller-pid-namespace", + } as const), + ]), + ), + signalGroup: vi.fn(), + }; + const cleanup = await prepareVerifierTokenCleanup( + { run_id: "owned-run", verifier_pid: 100, parent_pid: 50 }, + host, + ); + await expect(cleanup.retainProviderRun("owned-run")).rejects.toThrow( + "its own run", + ); + await expect(cleanup.retainProviderRun("")).rejects.toThrow( + "receipt is unavailable", + ); + rows = [launcher, orphan, foreign]; + expect(rows.filter(({ parentPid }) => parentPid === launcher.pid)).toEqual( + [], + ); + // The old outer-token-only sweep misses the real provider token. + expect(await cleanup.verify()).toMatchObject({ cleaned: true }); + await cleanup.retainProviderRun("provider-run"); + expect(await cleanup.verify()).toMatchObject({ + cleaned: false, + reason: "token-owned process remained after cleanup", + }); + rows = [launcher, foreign]; + expect(await cleanup.verify()).toMatchObject({ cleaned: true }); + await expect( + cleanup.retainProviderRun("unobserved-provider-run"), + ).rejects.toThrow("No token-verified process ownership"); + await cleanup.close(); + expect(close).toHaveBeenCalledOnce(); + expect(host.signalGroup).not.toHaveBeenCalled(); +}); + +it("closes the inspection host when baseline ownership cannot be established", async () => { + const close = vi.fn(); + const host: ProcessOwnershipHost = { + listProcesses: async () => [], + environment: async () => ({}), + signalGroup: vi.fn(), + close, + }; + await expect( + prepareVerifierTokenCleanup( + { run_id: "owned-run", verifier_pid: 100, parent_pid: 50 }, + host, + ), + ).rejects.toThrow("baseline is unavailable"); + expect(close).toHaveBeenCalledOnce(); +}); + +it.runIf(process.platform !== "win32")( + "retains the token installed by the production provider launcher after its parent exits", + async () => { + const run = { + run_id: randomUUID(), + verifier_pid: process.pid, + parent_pid: process.ppid, + }; + const providerRun = randomUUID(); + const host = createSystemProcessOwnershipHost(); + const cleanup = await prepareVerifierTokenCleanup(run, host); + let orphanPid: number | undefined; + let orphanIdentity: ProcessIdentityObservation | undefined; + try { + const code = `const {spawn}=require('node:child_process');const child=spawn(process.execPath,['-e','setTimeout(()=>{},15000)'],{detached:true,stdio:'ignore',env:process.env});process.stdout.write(JSON.stringify({run_id:process.env.REA_PROCESS_RUN_ID,pid:child.pid}));child.unref();`; + const launched = await spawnOwnedProviderProcess({ + command: process.execPath, + arguments: ["-e", code], + runId: providerRun, + env: { REA_PROCESS_RUN_ID: run.run_id }, + expectedCommand: null, + }); + let output = ""; + launched.process.stdout?.on("data", (chunk) => { + output += String(chunk); + }); + launched.process.stderr?.resume(); + await new Promise((resolve) => { + if (launched.process.exitCode !== null) resolve(); + else launched.process.once("close", resolve); + }); + const receipt = JSON.parse(output) as { run_id: string; pid: number }; + orphanPid = receipt.pid; + orphanIdentity = await observeProcessStartIdentity(orphanPid, host); + expect(receipt.run_id).toBe(providerRun); + expect(receipt.run_id).not.toBe(run.run_id); + expect(orphanIdentity?.state).toBe("readable"); + // This is the original false green: the wrapper token is absent, but + // the reparented provider process still owns its distinct native token. + expect(await cleanup.verify()).toMatchObject({ cleaned: true }); + await cleanup.retainProviderRun(receipt.run_id, { + leaderPid: launched.ownership.leaderPid, + processGroupId: launched.ownership.processGroupId, + }); + const result = await cleanup.verify(); + expect(result).toMatchObject({ cleaned: false }); + expect( + result.runs.find(({ run_id }) => run_id === providerRun)?.result, + ).toMatchObject({ + cleaned: false, + reason: "token-owned process remained after cleanup", + }); + const row = (await host.listProcesses()).find( + ({ pid }) => pid === orphanPid, + ); + expect(row?.parentPid).not.toBe(process.pid); + expect(row?.parentPid).not.toBe(launched.process.pid); + } finally { + try { + if (orphanPid !== undefined && orphanIdentity?.state === "readable") { + const pid = orphanPid; + const identity = orphanIdentity.identity; + expect( + await signalProcessWithStartIdentity(pid, identity, "SIGTERM", { + host, + }), + ).toBe("signaled"); + await vi.waitFor( + async () => { + expect( + await observeProcessStartIdentityExit(pid, identity, host), + ).toBe("exited"); + }, + { timeout: 3000, interval: 20 }, + ); + } + } finally { + await cleanup.close(); + } + } + }, + 20000, +); diff --git a/tests/conformance/ghidra/mips1-fixture.mjs b/tests/conformance/ghidra/mips1-fixture.mjs new file mode 100644 index 00000000000..57c33f7030e --- /dev/null +++ b/tests/conformance/ghidra/mips1-fixture.mjs @@ -0,0 +1,92 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** Source-owned explicit-o32 MIPS I refusal control. Its integer words do not establish load-delay semantics. */ +export async function buildMips1Fixture(directory, byteOrder) { + if (!["little", "big"].includes(byteOrder)) + throw new Error("Unknown fixture byte order"); + const image = Buffer.alloc(0x1188); + const u16 = (offset, value) => + byteOrder === "little" + ? image.writeUInt16LE(value, offset) + : image.writeUInt16BE(value, offset); + const u32 = (offset, value) => + byteOrder === "little" + ? image.writeUInt32LE(value, offset) + : image.writeUInt32BE(value, offset); + image.set([0x7f, 0x45, 0x4c, 0x46, 1, byteOrder === "little" ? 1 : 2, 1]); + u16(16, 2); + u16(18, 8); + u32(20, 1); + u32(24, 0x401000); + u32(28, 52); + u32(32, 0x10c0); + u32(36, 0x1001); + u16(40, 52); + u16(42, 32); + u16(44, 1); + u16(46, 40); + u16(48, 5); + u16(50, 4); + u32(52, 1); + u32(56, 0); + u32(60, 0x400000); + u32(64, 0x400000); + u32(68, image.length); + u32(72, image.length); + u32(76, 5); + u32(80, 0x1000); + // addiu v0,a0,7; beq a0,zero,done; sll v0,v0,1 (delay slot); + // addiu v0,v0,3; done: jr ra; nop. + const words = [0x24820007, 0x10800002, 0x00021040, 0x24420003, 0x03e00008, 0]; + words.forEach((word, index) => u32(0x1000 + index * 4, word)); + image.write("\0rea_mips1_entry\0", 0x1040, "ascii"); + u32(0x1070, 1); + u32(0x1074, 0x401000); + u32(0x1078, 24); + image[0x107c] = 0x12; + u16(0x107e, 1); + const names = "\0.text\0.symtab\0.strtab\0.shstrtab\0"; + image.write(names, 0x1080, "ascii"); + const section = ( + index, + name, + type, + flags, + address, + offset, + size, + link, + info, + align, + entrySize, + ) => { + const base = 0x10c0 + index * 40; + [ + names.indexOf(name), + type, + flags, + address, + offset, + size, + link, + info, + align, + entrySize, + ].forEach((value, n) => u32(base + n * 4, value)); + }; + section(1, ".text", 1, 6, 0x401000, 0x1000, 24, 0, 0, 4, 0); + section(2, ".symtab", 2, 0, 0, 0x1060, 32, 3, 1, 4, 16); + section(3, ".strtab", 3, 0, 0, 0x1040, 17, 0, 0, 1, 0); + section(4, ".shstrtab", 3, 0, 0, 0x1080, names.length, 0, 0, 1, 0); + const path = join(directory, `mips1-${byteOrder}.elf`); + await writeFile(path, image); + return { + path, + sha256: createHash("sha256").update(image).digest("hex"), + byte_order: byteOrder, + entry: "0x401000", + words, + }; +}