Files
rea/src/domain/binaryTarget.ts
T
Jay-Alexander Elliotandgithub-actions[bot] bddddccdee feat(native): admit bounded MIPS ELF analysis through Ghidra (#1362)
* feat(native): admit bounded MIPS ELF analysis through Ghidra

Retain MIPS ELF identity and interpretation through Evidence and snapshots.
Keep provider admission bounded to ELF32 ET_EXEC o32 MIPS32r2, without
implicitly widening Hopper, IDA or Windows P0 coverage. Add source-owned
fixtures and an optional real-provider verifier; real Ghidra verification
remains pending in this contribution environment.

Refs #718

* fix(ghidra): bound MIPS floating-point mode admission

Reject explicit EF_MIPS_FP64 and EF_MIPS_NAN2008 declarations until their
semantics have a real-provider verification lane. Add PSP PRX/Allegrex,
o64/EABI and floating-point-mode refusal regressions. Document that the
header gate is not a full .MIPS.abiflags check and advance the profile lane
identity to invalidate older interpretation bindings.

Validation: 24 standalone checks against the transpiled production profile
module passed; the two new flag cases failed before this change. Clang 17
and LLD compiled the source-owned fixture for both endian targets. The
complete Vitest/typecheck suite and real Ghidra verification remain pending.

Refs morluto/rea#718; PSP support remains follow-up morluto/rea#1330.

* fix(native): validate MIPS ABI records before Ghidra admission

Read bounded ELF32 PT_MIPS_ABIFLAGS and SHT_MIPS_ABIFLAGS records through
the already-open target handle. Preserve declared ABI fields, reject
conflicting/duplicate/truncated records, and keep missing records unknown.
Wire inspection into target resolution before provider selection. Require
compatible declarations for the deliberately narrow Ghidra lane and bind
all ABI fields into profile identity (v3) for snapshot invalidation.

Clang 17 produced eight source-owned fixtures spanning both byte orders
and FPXX/FP32/FP64/soft-float modes with identical ELF header flags. The
new reader and policy distinguish them. 24 focused regression test bodies
passed after TypeScript transpilation using node:test in place of Vitest;
strict isolated TypeScript checking of the reader and target/result types
passed. These are limited checks, not full Vitest/project typechecking or
real-Ghidra verification, which remain pending.

Advances morluto/rea#718. PSP/Allegrex remains morluto/rea#1330 follow-up.

* fix(native): repair MIPS integration boundaries and test resolver behavior

Keep ELF targets out of Hopper Mach-O preparation and scope existing PE fixture types correctly. Move ABI tests into the filesystem boundary lane, use shared cleanup, add resolver regressions, apply the pinned formatter, and register the real MIPS verifier through the existing package-command pattern. Validated with repository-pinned Node/npm, check:ci, actual focused Vitest, discovery and docs:check. Real Ghidra conformance remains pending.

* fix(test): honor caller-selected JDK in real MIPS verification

* test(native): strengthen MIPS Ghidra conformance

Cross-check the source-owned fixture with bounded GNU readelf inspection, compare production ELF/ABI interpretation and independent symbol addresses, and verify effective Ghidra language/compiler identity through existing public operations. Add a source-owned fixed-encoding probe for immediate and conditional branch checks plus global/string byte assertions.

Add 16 assertion/producer-boundary regression cases without widening production admission, changing provider contracts, or introducing proprietary dependencies. Local focused suite: 99 passed, one existing root-permission test skipped. Typecheck, lint/module boundaries, formatting and 700-file discovery pass; full hosted checks and real Ghidra verification remain pending for this checkpoint.

* fix(ghidra): preserve native-tool architecture checks after MIPS integration

The conflict-free upstream merge failed TypeScript compilation because includes on the union of MIPS-only and other architecture arrays requires never. Use equality-based membership without casts or changing accepted native hosts. Extend the existing native-decompiler rejection table with a MIPS executable at a Linux x64 tool path; retain upstream FAT and unknown-header behavior. Hosted verification follows this commit; no success is claimed yet.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-10 23:04:27 +08:00

320 lines
10 KiB
TypeScript

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: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
}
| {
readonly format: "pe";
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
readonly executableRole:
| "application"
| "shared-library"
| "non-executable";
readonly managed: boolean;
};
/**
* Parse supported executable headers without I/O. The caller supplies the host
* architecture used for deterministic FAT Mach-O slice selection.
*/
export const parseExecutableHeader = (
bytes: Buffer,
hostArchitecture: NodeJS.Architecture,
fileSize = bytes.length,
): Result<ExecutableMetadata, string> => {
if (
bytes.length >= 4 &&
bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
)
return parseElf(bytes, fileSize);
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a)
return parseMz(bytes, fileSize);
if (bytes.length < 8) return err("truncated or unsupported binary header");
const magic = bytes.readUInt32BE(0);
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
return parseThinMachO(bytes, magic, fileSize);
if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic))
return parseFatMachO(bytes, magic, hostArchitecture, fileSize);
return err("unsupported binary format");
};
const parseThinMachO = (
bytes: Buffer,
magic: number,
fileSize: number,
): Result<ExecutableMetadata, string> => {
const is64 = magic === 0xfeedfacf || magic === 0xcffaedfe;
const headerSize = is64 ? 32 : 28;
if (bytes.length < headerSize)
return err(
`truncated Mach-O header: a ${is64 ? 64 : 32}-bit header needs ${headerSize} bytes; the file has ${fileSize}`,
);
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
const read = (offset: number): number =>
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const architecture = machArchitecture(read(4));
if (architecture === undefined) return err("unsupported Mach-O architecture");
const commandBytes = read(20);
if (headerSize + commandBytes > fileSize)
return err(
`truncated Mach-O load commands: the header declares ${commandBytes} bytes after its ${headerSize}-byte header; the file has ${fileSize}`,
);
return ok({
format: "mach-o",
architecture,
availableArchitectures: [architecture],
});
};
const parseFatMachO = (
bytes: Buffer,
magic: number,
host: NodeJS.Architecture,
fileSize: number,
): Result<ExecutableMetadata, string> => {
const little = magic === 0xbebafeca || magic === 0xbfbafeca;
const is64 = magic === 0xcafebabf || magic === 0xbfbafeca;
if (bytes.length < 8) return err("truncated FAT header");
const read = (offset: number): number =>
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const count = read(4);
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: Exclude<BinaryArchitecture, "mips">[] = [];
const sliceEnds = new Map<BinaryArchitecture, bigint>();
for (let index = 0; index < count; index += 1) {
const entry = 8 + index * entrySize;
const architecture = machArchitecture(read(entry));
if (architecture === undefined || architectures.includes(architecture))
continue;
architectures.push(architecture);
const offset = is64
? little
? bytes.readBigUInt64LE(entry + 8)
: bytes.readBigUInt64BE(entry + 8)
: BigInt(read(entry + 8));
const size = is64
? little
? bytes.readBigUInt64LE(entry + 16)
: bytes.readBigUInt64BE(entry + 16)
: BigInt(read(entry + 12));
sliceEnds.set(architecture, offset + size);
}
const preferred =
host === "arm64"
? "arm64"
: host === "x64"
? "x86_64"
: host === "ia32"
? "x86"
: host === "arm"
? "arm"
: undefined;
if (preferred === undefined || !architectures.includes(preferred))
return err(`FAT binary has no host-compatible ${host} architecture`);
const sliceEnd = sliceEnds.get(preferred) ?? 0n;
if (sliceEnd > BigInt(fileSize))
return err(
`truncated FAT slice: the ${preferred} slice ends at byte ${sliceEnd}; the file has ${fileSize}`,
);
return ok({
format: "mach-o",
architecture: preferred,
availableArchitectures: architectures,
});
};
const parseElf = (
bytes: Buffer,
fileSize: number,
): Result<ExecutableMetadata, string> => {
if (bytes.length < 6) return err("truncated ELF header");
if (bytes[4] !== 1 && bytes[4] !== 2) return err("unsupported ELF class");
const little = bytes[5] === 1;
if (!little && bytes[5] !== 2) return err("unsupported ELF endianness");
const headerSize = bytes[4] === 1 ? 52 : 64;
if (bytes.length < headerSize)
return err(
`truncated ELF header: an ELF${bytes[4] === 1 ? 32 : 64} header needs ${headerSize} bytes; the file has ${fileSize}`,
);
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,
availableArchitectures: [architecture],
});
};
const parseMz = (
bytes: Buffer,
fileSize: number,
): Result<ExecutableMetadata, string> => {
const offset = mzWindowsHeaderOffset(bytes);
if (offset !== null) {
if (offset < 64 || offset > bytes.length - 24)
return err("invalid or truncated Windows executable header in MZ image");
const signature = bytes.toString("ascii", offset, offset + 2);
if (["NE", "LE", "LX"].includes(signature))
return err(`unsupported ${signature} executable in MZ image`);
return parsePeRecord(bytes.subarray(offset));
}
const parsed = parseDosMzHeader(bytes, fileSize);
return parsed.ok
? ok({
format: "dos-mz",
architecture: "x86",
availableArchitectures: ["x86"],
})
: parsed;
};
/** Parse a bounded PE signature, COFF record, and optional header without I/O. */
export const parsePeRecord = (
record: Buffer,
): Result<ExecutableMetadata, string> => {
if (
record.length < 24 ||
record.toString("binary", 0, 4) !== "PE\u0000\u0000"
)
return err("invalid or truncated PE header");
const architecture = peArchitecture(record.readUInt16LE(4));
if (architecture === undefined) return err("unsupported PE architecture");
const optionalHeaderSize = record.readUInt16LE(20);
if (optionalHeaderSize > 4096 || record.length < 24 + optionalHeaderSize)
return err("invalid or truncated PE optional header");
const optionalHeader = record.subarray(24, 24 + optionalHeaderSize);
const managed = peManagedStatus(optionalHeader, architecture);
if (!managed.ok) return managed;
const characteristics = record.readUInt16LE(22);
return ok({
format: "pe",
architecture,
availableArchitectures: [architecture],
executableRole:
(characteristics & 0x0002) === 0
? "non-executable"
: (characteristics & 0x2000) === 0
? "application"
: "shared-library",
managed: managed.value,
});
};
const peManagedStatus = (
optionalHeader: Buffer,
architecture: BinaryArchitecture,
): Result<boolean, string> => {
if (optionalHeader.length < 2) return err("truncated PE optional header");
const magic = optionalHeader.readUInt16LE(0);
const expectedMagic =
architecture === "x86_64" || architecture === "arm64" ? 0x20b : 0x10b;
if (magic !== expectedMagic)
return err("PE optional-header magic does not match its architecture");
const directoryOffset = magic === 0x20b ? 112 : 96;
const directoryCountOffset = directoryOffset - 4;
if (optionalHeader.length < directoryCountOffset + 4)
return err("truncated PE optional header");
const directoryCount = optionalHeader.readUInt32LE(directoryCountOffset);
const requiredBytes = directoryOffset + directoryCount * 8;
if (
!Number.isSafeInteger(requiredBytes) ||
requiredBytes > optionalHeader.length
)
return err("truncated PE data-directory table");
if (directoryCount <= 14) return ok(false);
const cliDirectory = directoryOffset + 14 * 8;
return ok(
optionalHeader.readUInt32LE(cliDirectory) !== 0 ||
optionalHeader.readUInt32LE(cliDirectory + 4) !== 0,
);
};
const machArchitecture = (
cpu: number,
): Exclude<BinaryArchitecture, "mips"> | undefined => {
switch (cpu) {
case 7:
return "x86";
case 0x01000007:
return "x86_64";
case 12:
return "arm";
case 0x0100000c:
return "arm64";
}
return undefined;
};
const elfArchitecture = (machine: number): BinaryArchitecture | undefined => {
switch (machine) {
case 3:
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:
return "arm64";
}
return undefined;
};
const peArchitecture = (
machine: number,
): Exclude<BinaryArchitecture, "mips"> | undefined => {
switch (machine) {
case 0x14c:
return "x86";
case 0x8664:
return "x86_64";
case 0x1c0:
case 0x1c2:
case 0x1c4:
return "arm";
case 0xaa64:
return "arm64";
}
return undefined;
};