mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(ghidra): admit carved MIPS ELF with incomplete section tables (#1629)
* fix(ghidra): inspect carved MIPS ABI declarations without missing sections * test: share filesystem fixtures and cleanup assertions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Result<MipsAbiFlags | null, string>> => {
|
||||
): Promise<Result<MipsAbiFlagsInspection, string>> => {
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -56,6 +56,41 @@ const waitForNoMatchingDescriptor = async (identity: Stats): Promise<void> => {
|
||||
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<void> => {
|
||||
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",
|
||||
|
||||
@@ -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<string> => {
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user