diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index 51f673abb3..c4cf9c974f 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -29,6 +29,13 @@ 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. +For carved ELFs whose section table extends beyond the captured file, REA +examines every complete captured section header and records the unavailable +headers as a limitation. A valid program-header ABI record can still establish +the supported declaration. Available section declarations must agree with it; +their duplicate, conflicting or unreadable records remain errors. A missing +program-header record does not make ABI declarations in uncaptured sections +absent or supply a default ABI. The field layout follows [`Elf_MIPS_ABIFlags_v0`](https://github.com/bminor/glibc/blob/master/elf/elf.h). @@ -92,6 +99,11 @@ 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. +For both byte orders, the lane also carves away the section table while retaining +all program-header payloads. It checks the actual Ghidra language, entry-address +decompilation, distinct artifact digest and missing-section limitations through +CLI Evidence and an MCP-produced snapshot. Caller-selected Ghidra heap limits +are passed to these child workflows. 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 diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index deec4ef92c..617c05cc90 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -2,7 +2,7 @@ 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 { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -60,6 +60,9 @@ const env = { JAVA_HOME: installation.javaHome, HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper", }; +for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) { + if (process.env[setting] !== undefined) env[setting] = process.env[setting]; +} const reports = []; let primaryFailure; try { @@ -227,6 +230,103 @@ try { fixture.sha256, "Analysis modified the source executable", ); + // Carved firmware keeps all program headers/load bytes but loses sections. + const read16 = (offset) => + byteOrder === "little" + ? sourceBytes.readUInt16LE(offset) + : sourceBytes.readUInt16BE(offset); + const read32 = (offset) => + byteOrder === "little" + ? sourceBytes.readUInt32LE(offset) + : sourceBytes.readUInt32BE(offset); + const sectionOffset = read32(32); + const cut = sectionOffset - 1; + const programOffset = read32(28); + const programCount = read16(44); + assert.equal(read16(42), 32); + assert.ok(cut >= programOffset + programCount * 32); + for (let i = 0; i < programCount; i += 1) { + const offset = programOffset + i * 32; + assert.ok(read32(offset + 4) + read32(offset + 16) <= cut); + } + const carvedBytes = sourceBytes.subarray(0, cut); + const carvedPath = join(workspace, `${byteOrder}-carved.elf`); + const carvedSha256 = createHash("sha256") + .update(carvedBytes) + .digest("hex"); + await writeFile(carvedPath, carvedBytes); + const carvedResolved = await parseBinaryTarget(carvedPath); + if (!carvedResolved.ok) throw carvedResolved.error; + assert.deepEqual( + carvedResolved.value.mips.abiFlags, + independent.mips.abiFlags, + ); + assert.match( + carvedResolved.value.mips.limitations.join(" "), + /section header table/u, + ); + const carvedTarget = await call("open_binary", { + path: carvedPath, + provider_id: "ghidra", + }); + assert.equal(carvedTarget.sha256, carvedSha256); + const carvedImage = await call("inspect_native_load_image"); + assertMipsLoadedImage(carvedImage.observations, byteOrder, carvedSha256); + const carvedProcedures = await call("list_procedures"); + assert.ok( + carvedProcedures.some( + (p) => + BigInt(p.address) === BigInt(independent.symbols.rea_mips_entry), + ), + ); + const carvedDossier = functionDossierSchema.parse( + await call("analyze_function", { + procedure: independent.symbols.rea_mips_entry, + }), + ); + assert.ok(carvedDossier.pseudocode.trim().length > 0); + const carvedSnapshotPath = join( + workspace, + `${byteOrder}-carved.snapshot.json`, + ); + await call("close_binary", { snapshot_path: carvedSnapshotPath }); + const carvedSnapshot = parseAnalysisSnapshot( + JSON.parse(await readFile(carvedSnapshotPath, "utf8")), + ); + assert.equal(carvedSnapshot.target.sha256, carvedSha256); + assert.ok( + carvedSnapshot.evidence_bundle.records.some((record) => + parseEvidence(record).limitations.some((item) => + item.includes("section header table"), + ), + ), + ); + const carvedCli = await execute( + process.execPath, + [ + entrypoint, + "function", + carvedPath, + independent.symbols.rea_mips_entry, + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const carvedEvidence = parseEvidence(JSON.parse(carvedCli.stdout)); + assert.equal(carvedEvidence.subject?.digest.sha256, carvedSha256); + assert.ok( + carvedEvidence.limitations.some((item) => + item.includes("section header table"), + ), + ); + assert.equal( + functionDossierSchema.parse(carvedEvidence.normalized_result).procedure + .address, + carvedDossier.procedure.address, + ); + assert.deepEqual(await readFile(carvedPath), carvedBytes); reports.push({ ...fixture, independent_reader: independent, @@ -241,6 +341,14 @@ try { "CLI/MCP and snapshot identity", ], procedures: procedures.length, + carved: { + sha256: carvedSha256, + source_bytes: carvedBytes.length, + section_header_offset: sectionOffset, + effective_language: carvedImage.observations.language_id, + limitations: carvedResolved.value.mips.limitations, + procedures: carvedProcedures.length, + }, status: "passed", }); } catch (cause) { diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 8b532b0d54..49e5c5dab2 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -373,7 +373,13 @@ const readExecutableMetadata = async ( if (!abiFlags.ok) return abiFlags; return ok({ ...parsed.value, - mips: { ...parsed.value.mips, abiFlags: abiFlags.value }, + mips: { + ...parsed.value.mips, + abiFlags: abiFlags.value.abiFlags, + ...(abiFlags.value.limitations.length === 0 + ? {} + : { limitations: abiFlags.value.limitations }), + }, }); }; diff --git a/src/application/MipsElfAbiFlags.ts b/src/application/MipsElfAbiFlags.ts index cf6a47de68..6558b77475 100644 --- a/src/application/MipsElfAbiFlags.ts +++ b/src/application/MipsElfAbiFlags.ts @@ -5,6 +5,12 @@ import type { } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; +/** Observed ABI declarations and coverage of their source tables. */ +export interface MipsAbiFlagsInspection { + readonly abiFlags: MipsAbiFlags | null; + readonly limitations: readonly string[]; +} + /** * Read ELF32 MIPS ABI declarations from the caller's already-open file. * Inspect both typed tables (not section names), including records beyond the @@ -15,7 +21,7 @@ export const readMipsElfAbiFlags = async ( handle: FileHandle, metadata: MipsElfMetadata, checkCancelled: () => void, -): Promise> => { +): Promise> => { checkCancelled(); if (metadata.elfClass !== 32) return err("MIPS ABI record inspection currently supports ELF32 only"); @@ -81,6 +87,7 @@ export const readMipsElfAbiFlags = async ( dataOffset: 4, sizeOffset: 16, recordType: 0x70000003, // PT_MIPS_ABIFLAGS + optional: false, }, { label: "section header table", @@ -92,6 +99,7 @@ export const readMipsElfAbiFlags = async ( dataOffset: 16, sizeOffset: 20, recordType: 0x7000002a, // SHT_MIPS_ABIFLAGS + optional: true, }, ]; // Extended numbering needs a separate section-zero reader. Refuse it rather @@ -99,6 +107,7 @@ export const readMipsElfAbiFlags = async ( if (u16(bytes, 44) === 0xffff || u16(bytes, 48) >= 0xff00) return err("extended or reserved MIPS ELF table numbering is unsupported"); let record: Buffer | undefined; + const limitations: string[] = []; for (const table of tables) { if (table.count === 0) { if (table.offset !== 0) @@ -107,16 +116,27 @@ export const readMipsElfAbiFlags = async ( } if (table.offset < 52 || table.stride !== table.entrySize) return err(`invalid MIPS ${table.label} offset or entry size`); + const count = table.optional + ? Math.min( + table.count, + Math.max(0, Math.floor((fileSize - table.offset) / table.entrySize)), + ) + : table.count; + if (count < table.count) + limitations.push( + `MIPS ${table.label} at file offset ${table.offset} extends beyond the captured file: examined ${count} of ${table.count} complete headers. ABI declarations in unavailable headers remain unknown.`, + ); + if (count === 0) continue; // 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, + count * table.entrySize, table.label, ); if (!contents.ok) return contents; let found = false; - for (let i = 0; i < table.count; i += 1) { + for (let i = 0; i < count; i += 1) { checkCancelled(); const position = i * table.entrySize; if (u32(contents.value, position + table.typeOffset) !== table.recordType) @@ -134,10 +154,10 @@ export const readMipsElfAbiFlags = async ( record = candidate.value; } } - if (record === undefined) return ok(null); + if (record === undefined) return ok({ abiFlags: null, limitations }); // Preserve declarations verbatim, including unknown versions/values. Provider // admission, not parsing, decides which interpretations have been verified. - return ok({ + const abiFlags: MipsAbiFlags = { version: u16(record, 0), isaLevel: record.readUInt8(2), isaRevision: record.readUInt8(3), @@ -149,5 +169,6 @@ export const readMipsElfAbiFlags = async ( ases: u32(record, 12), flags1: u32(record, 16), flags2: u32(record, 20), - }); + }; + return ok({ abiFlags, limitations }); }; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 06a47500eb..e7d3db050b 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -14,8 +14,10 @@ export interface MipsElfMetadata { readonly byteOrder: "little" | "big"; readonly type: number; readonly flags: number; - /** Undefined until inspected; null means neither typed table has a record. */ + /** Undefined until inspected; null means no ABI record was observed. */ readonly abiFlags?: MipsAbiFlags | null; + /** Unavailable declaration sources; missing records there remain unknown. */ + readonly limitations?: readonly string[]; } /** Declared fields of the 24-byte MIPS ABI flags record, not inferred semantics. */ diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index feb93ad0d1..0df6591a4f 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -31,7 +31,10 @@ export const ghidraMipsUnsupportedReason = ( 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."; + return [ + "This Ghidra MIPS lane requires an inspected ABI flags record; header flags alone do not establish the floating-point ABI.", + ...(metadata.limitations ?? []), + ].join(" "); 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) @@ -64,6 +67,9 @@ export const ghidraMipsProfileParameters = ( byte_order: target.mips.byteOrder, type: target.mips.type, flags: target.mips.flags, + ...(target.mips.limitations === undefined + ? {} + : { abi_flags_limitations: [...target.mips.limitations] }), abi_flags: abi === undefined || abi === null ? null diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 4ee4c8b828..0af34b3809 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -117,13 +117,17 @@ export const createGhidraProviderClient = (input: { }), ); let extensionFailure: AnalysisError | undefined; - const targetLimitations = - target.format === "dos-mz" + const targetLimitations = [ + ...(target.format === "dos-mz" ? [ "DOS MZ uses 16-bit x86 real mode with the Ghidra load segment 0x1000. Returned addresses are linear byte coordinates; they do not identify a unique segment:offset alias.", "Static DOS analysis does not emulate BIOS, DOS interrupts, device ports, or self-modifying unpacking code. Packed targets require a separately identified unpacked artifact for original-program analysis; appended overlays are not the initialized load module.", ] - : []; + : []), + ...(target.kind === "executable" && target.architecture === "mips" + ? (target.mips.limitations ?? []) + : []), + ]; const providerLimitations = installation.platform === "win32" ? windowsP0Limitations diff --git a/tests/boundary/filesystem/asarStreamingAllocation.test.ts b/tests/boundary/filesystem/asarStreamingAllocation.test.ts index 24584063d2..0af58d0df6 100644 --- a/tests/boundary/filesystem/asarStreamingAllocation.test.ts +++ b/tests/boundary/filesystem/asarStreamingAllocation.test.ts @@ -56,6 +56,41 @@ const waitForNoMatchingDescriptor = async (identity: Stats): Promise => { expect(await matchingDescriptorCount(identity)).toBe(0); }; +const createUnpackedFixture = async () => { + const root = await createTestTempDirectory("rea-asar-unpacked-"); + const source = join(root, "source"); + const archive = join(root, "fixture.asar"); + await mkdir(source); + await writeFile(join(source, "small.js"), "module.exports = 1;\n"); + await createPackageWithOptions(source, archive, { unpack: "small.js" }); + const unpackedPath = join(`${archive}.unpacked`, "small.js"); + return { + archive, + unpackedPath, + identity: await lstat(unpackedPath), + }; +}; + +const unpackedMember = async (reader: AsarArtifactReader) => { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + const member = entries.find(({ path }) => path === "small.js"); + if (member === undefined) throw new Error("Expected unpacked ASAR member"); + return member; +}; + +const expectRetainedHandleRecovery = async ( + reader: AsarArtifactReader, + identity: Stats, + closeCount: () => number, +): Promise => { + expect(closeCount()).toBe(1); + expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0); + await reader.close(); + expect(closeCount()).toBe(2); + await waitForNoMatchingDescriptor(identity); +}; + describe("ASAR entry streaming", () => { it.skipIf(process.platform === "win32")( "closes a packed member handle when its unopened output stream is destroyed", @@ -292,14 +327,7 @@ describe("ASAR read failures", () => { it.skipIf(process.platform === "win32")( "closes the opened unpacked file and retains ASAR context when handle stat fails", async () => { - const root = await createTestTempDirectory("rea-asar-unpacked-stat-"); - const source = join(root, "source"); - const archive = join(root, "fixture.asar"); - await mkdir(source); - await writeFile(join(source, "small.js"), "module.exports = 1;\n"); - await createPackageWithOptions(source, archive, { unpack: "small.js" }); - const unpackedPath = join(`${archive}.unpacked`, "small.js"); - const identity = await lstat(unpackedPath); + const { archive, unpackedPath, identity } = await createUnpackedFixture(); const ioError = Object.assign(new Error("device stat failed"), { code: "EIO", errno: -5, @@ -328,11 +356,7 @@ describe("ASAR read failures", () => { }); try { - const entries = []; - for await (const entry of reader.entries()) entries.push(entry); - const entry = entries.find(({ path }) => path === "small.js"); - if (entry === undefined) - throw new Error("Expected unpacked ASAR member"); + const entry = await unpackedMember(reader); await expect(reader.open(entry)).rejects.toMatchObject({ name: "ArtifactReaderFailure", @@ -349,11 +373,7 @@ describe("ASAR read failures", () => { reason: "device close failed", }, }); - expect(closeCount).toBe(1); - expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0); - await reader.close(); - expect(closeCount).toBe(2); - await waitForNoMatchingDescriptor(identity); + await expectRetainedHandleRecovery(reader, identity, () => closeCount); } finally { await reader.close(); } @@ -364,14 +384,7 @@ describe("ASAR read failures", () => { it.skipIf(process.platform === "win32")( "retries cleanup when cancellation arrives immediately after unpacked stat", async () => { - const root = await createTestTempDirectory("rea-asar-unpacked-abort-stat-"); - const source = join(root, "source"); - const archive = join(root, "fixture.asar"); - await mkdir(source); - await writeFile(join(source, "small.js"), "module.exports = 1;\n"); - await createPackageWithOptions(source, archive, { unpack: "small.js" }); - const unpackedPath = join(`${archive}.unpacked`, "small.js"); - const identity = await lstat(unpackedPath); + const { archive, unpackedPath, identity } = await createUnpackedFixture(); const controller = new AbortController(); let closeCount = 0; const reader = new AsarArtifactReader(archive, async (path, flags) => { @@ -392,10 +405,7 @@ it.skipIf(process.platform === "win32")( }); try { - const entries = []; - for await (const entry of reader.entries()) entries.push(entry); - const entry = entries.find(({ path }) => path === "small.js"); - if (entry === undefined) throw new Error("Expected unpacked ASAR member"); + const entry = await unpackedMember(reader); await expect(reader.open(entry, controller.signal)).rejects.toMatchObject( { @@ -406,11 +416,7 @@ it.skipIf(process.platform === "win32")( }, }, ); - expect(closeCount).toBe(1); - expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0); - await reader.close(); - expect(closeCount).toBe(2); - await waitForNoMatchingDescriptor(identity); + await expectRetainedHandleRecovery(reader, identity, () => closeCount); } finally { await reader.close(); } @@ -420,14 +426,7 @@ it.skipIf(process.platform === "win32")( it.skipIf(process.platform === "win32")( "preserves an unpacked identity failure when closing its handle fails", async () => { - const root = await createTestTempDirectory("rea-asar-unpacked-identity-"); - const source = join(root, "source"); - const archive = join(root, "fixture.asar"); - await mkdir(source); - await writeFile(join(source, "small.js"), "module.exports = 1;\n"); - await createPackageWithOptions(source, archive, { unpack: "small.js" }); - const unpackedPath = join(`${archive}.unpacked`, "small.js"); - const identity = await lstat(unpackedPath); + const { archive, unpackedPath, identity } = await createUnpackedFixture(); const closeError = new Error("first close failed"); let closeCount = 0; const reader = new AsarArtifactReader(archive, async (path, flags) => { @@ -449,10 +448,7 @@ it.skipIf(process.platform === "win32")( }); try { - const entries = []; - for await (const entry of reader.entries()) entries.push(entry); - const entry = entries.find(({ path }) => path === "small.js"); - if (entry === undefined) throw new Error("Expected unpacked ASAR member"); + const entry = await unpackedMember(reader); await expect(reader.open(entry)).rejects.toMatchObject({ name: "ArtifactReaderFailure", @@ -465,11 +461,7 @@ it.skipIf(process.platform === "win32")( reason: "first close failed", }, }); - expect(closeCount).toBe(1); - expect(await matchingDescriptorCount(identity)).toBeGreaterThan(0); - await reader.close(); - expect(closeCount).toBe(2); - await waitForNoMatchingDescriptor(identity); + await expectRetainedHandleRecovery(reader, identity, () => closeCount); } finally { await reader.close(); } @@ -479,14 +471,7 @@ it.skipIf(process.platform === "win32")( it.skipIf(process.platform === "win32")( "retains unpacked handles and snapshots across combined cleanup failures", async () => { - const root = await createTestTempDirectory("rea-asar-combined-cleanup-"); - const source = join(root, "source"); - const archive = join(root, "fixture.asar"); - await mkdir(source); - await writeFile(join(source, "small.js"), "module.exports = 1;\n"); - await createPackageWithOptions(source, archive, { unpack: "small.js" }); - const unpackedPath = join(`${archive}.unpacked`, "small.js"); - const identity = await lstat(unpackedPath); + const { archive, unpackedPath, identity } = await createUnpackedFixture(); const closeError = new Error("unpacked close denied"); const snapshotError = new Error("snapshot removal denied"); let closeCount = 0; @@ -519,10 +504,7 @@ it.skipIf(process.platform === "win32")( ); try { - const entries = []; - for await (const entry of reader.entries()) entries.push(entry); - const entry = entries.find(({ path }) => path === "small.js"); - if (entry === undefined) throw new Error("Expected unpacked ASAR member"); + const entry = await unpackedMember(reader); await expect(reader.open(entry)).rejects.toMatchObject({ reason: "io", diff --git a/tests/boundary/filesystem/mipsAbiFlags.test.ts b/tests/boundary/filesystem/mipsAbiFlags.test.ts index 44237008b2..1c5467d0a7 100644 --- a/tests/boundary/filesystem/mipsAbiFlags.test.ts +++ b/tests/boundary/filesystem/mipsAbiFlags.test.ts @@ -8,6 +8,7 @@ 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 { ok } from "../../../src/domain/result.js"; import { ghidraMipsProfileParameters, ghidraMipsUnsupportedReason, @@ -52,17 +53,44 @@ const fixture = (little = true): Buffer => { bytes.set([0, 0, 32, 2, 1, 1, 0, 5], abiOffset); return bytes; }; +const writeFixture = async (bytes: Buffer): Promise => { + const directory = await createTestTempDirectory("rea-mips-abi-test-"); + const path = join(directory, "fixture.elf"); + await writeFile(path, bytes); + return path; +}; +const resolveFixture = async (bytes: Buffer) => { + const path = await writeFixture(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.format, "elf"); + assert.equal(result.value.path, path); + assert.equal( + result.value.sha256, + createHash("sha256").update(bytes).digest("hex"), + ); + assert.deepEqual(await readFile(path), bytes); + return result.value; +}; 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 path = await writeFixture(bytes); const handle = await open(path, "r"); try { - return await readMipsElfAbiFlags(handle, metadata(little), checkCancelled); + const result = await readMipsElfAbiFlags( + handle, + metadata(little), + checkCancelled, + ); + return result.ok ? ok(result.value.abiFlags) : result; } finally { await handle.close(); } @@ -116,6 +144,62 @@ it("supports stripped section tables using PT_MIPS_ABIFLAGS", async () => { assert.ok(result.ok); assert.equal(result.value?.fpAbi, 5); }); +for (const little of [true, false]) { + for (const availableBytes of [0, 7, 47]) { + it(`resolves a carved ${little ? "LE" : "BE"} ELF with ${availableBytes} captured section-table bytes`, async () => { + const original = fixture(little); + const bytes = Buffer.concat([original, Buffer.alloc(availableBytes)]); + const sectionOffset = original.length + (availableBytes === 0 ? 1 : 0); + if (little) bytes.writeUInt32LE(sectionOffset, 32); + else bytes.writeUInt32BE(sectionOffset, 32); + const resolved = await resolveFixture(bytes); + assert.equal(resolved.mips.abiFlags?.fpAbi, 5); + assert.equal(ghidraMipsUnsupportedReason(resolved), null); + assert.match( + resolved.mips.limitations?.join(" ") ?? "", + /section header table/u, + ); + }); + } +} +it("keeps declarations in uncaptured section headers unknown when program ABI data is absent", async () => { + const bytes = fixture(); + bytes.writeUInt32LE(bytes.length + 1, 32); + bytes.writeUInt32LE(0, 52); + const resolved = await resolveFixture(bytes); + assert.equal(resolved.mips.abiFlags, null); + assert.match( + ghidraMipsUnsupportedReason(resolved) ?? "", + /inspected ABI.*unavailable headers remain unknown/u, + ); +}); +for (const failure of ["conflicting", "outside", "duplicate"]) { + it(`retains ${failure} ABI errors in a partly captured section table`, async () => { + const original = fixture(); + const count = failure === "duplicate" ? 2 : 1; + const bytes = Buffer.concat([original, Buffer.alloc(count * 40 + 7)]); + bytes.writeUInt32LE(original.length, 32); + bytes.writeUInt16LE(count + 1, 48); + original.copy(bytes, abiOffset + 24, abiOffset, abiOffset + 24); + bytes[abiOffset + 24 + 7] = 6; + for (let i = 0; i < count; i += 1) { + const position = original.length + i * 40; + bytes.writeUInt32LE(0x7000002a, position + 4); + bytes.writeUInt32LE( + failure === "outside" + ? 0xfffffff0 + : failure === "conflicting" + ? abiOffset + 24 + : abiOffset, + position + 16, + ); + bytes.writeUInt32LE(24, position + 20); + } + const result = await inspect(bytes); + assert.ok(!result.ok); + assert.match(result.error, new RegExp(failure, "u")); + }); +} it("supports a section-only ABI record without assuming its name", async () => { const bytes = fixture(); bytes.writeUInt32LE(0, 28); @@ -239,49 +323,21 @@ it("preserves the caller's cancellation error during table inspection", async () 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); + const resolved = await resolveFixture(bytes); + assert.equal(resolved.mips.byteOrder, little ? "little" : "big"); + assert.equal(resolved.mips.abiFlags?.fpAbi, 5); + assert.equal(ghidraMipsUnsupportedReason(resolved), null); }); } 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, - ); + const resolved = await resolveFixture(bytes); + assert.equal(resolved.mips.abiFlags, null); + assert.match(ghidraMipsUnsupportedReason(resolved) ?? "", /inspected ABI/u); }); it("projects a malformed out-of-probe ABI record as a target error", async () => {