mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
refactor: consolidate parsed image ownership across consumers
Retain validated DOS relocation records in their domain owner and consume those facts in Ghidra load-image preparation. Preserve ordered duplicate aliases, overlapping writes, and original/final byte evidence. Share FAT header facts across generic, native, and Hopper readers without merging their distinct admission policies. Replace managed method bodies' two identical mapping lookups with one owned offset-and-extent result. Validation: 660 focused tests passed across 19 suites; typecheck, lint, and test discovery passed. Hopper/Ghidra checks used fixtures and simulated provider observations; no real external provider execution was performed.
This commit is contained in:
1 parent
a7f50e3386
commit
8a65787507
13 files changed
+365
-88
No files matched your search
@@ -112,18 +112,62 @@ const MACHO_HEADER_MAGICS: readonly number[] = [
|
||||
0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe,
|
||||
];
|
||||
|
||||
interface FatHeaderFormat {
|
||||
readonly magic: number;
|
||||
readonly wide: boolean;
|
||||
readonly littleEndian: boolean;
|
||||
}
|
||||
|
||||
export type FatHeaderPrefix =
|
||||
| { readonly state: "unknown"; readonly requiredBytes: 4 }
|
||||
| { readonly state: "not-fat"; readonly magic: number }
|
||||
| (FatHeaderFormat &
|
||||
(
|
||||
| { readonly state: "incomplete"; readonly requiredBytes: 8 }
|
||||
| {
|
||||
readonly state: "parsed";
|
||||
readonly count: number;
|
||||
readonly tableEnd: number;
|
||||
}
|
||||
));
|
||||
|
||||
/** Observe the fixed FAT header without table, count-limit or container-identity admission. */
|
||||
export const readFatHeaderPrefix = (bytes: Uint8Array): FatHeaderPrefix => {
|
||||
if (bytes.byteLength < 4) return { state: "unknown", requiredBytes: 4 };
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const magic = view.getUint32(0, false);
|
||||
if (![FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic))
|
||||
return { state: "not-fat", magic };
|
||||
const format = {
|
||||
magic,
|
||||
wide: magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64,
|
||||
littleEndian: magic === FAT_CIGAM || magic === FAT_CIGAM_64,
|
||||
};
|
||||
if (bytes.byteLength < 8)
|
||||
return { state: "incomplete", ...format, requiredBytes: 8 };
|
||||
const count = view.getUint32(4, format.littleEndian);
|
||||
return {
|
||||
state: "parsed",
|
||||
...format,
|
||||
count,
|
||||
tableEnd: 8 + count * (format.wide ? 32 : 20),
|
||||
};
|
||||
};
|
||||
|
||||
/** Parse a bounded FAT header and table without treating the magic alone as proof. */
|
||||
export const readFatContainerHeader = (
|
||||
bytes: Uint8Array,
|
||||
fileSize: number,
|
||||
): FatContainerHeader => {
|
||||
if (!hasFatMagic(bytes)) return { state: "not-fat" };
|
||||
const prefix = readFatHeaderPrefix(bytes);
|
||||
if (prefix.state === "unknown" || prefix.state === "not-fat")
|
||||
return { state: "not-fat" };
|
||||
if (!Number.isSafeInteger(fileSize) || fileSize < bytes.byteLength)
|
||||
return {
|
||||
state: "malformed",
|
||||
reason: "FAT file size is invalid or contradicts retained bytes",
|
||||
};
|
||||
const table = readFatContainerTable(bytes);
|
||||
const table = decodeFatContainerTable(bytes, prefix);
|
||||
if (table.state !== "parsed") {
|
||||
if (table.state === "needs-data" && table.requiredBytes > fileSize) {
|
||||
return table.requiredBytes === 8 && fileSize < 8
|
||||
@@ -144,23 +188,24 @@ export const readFatContainerHeader = (
|
||||
};
|
||||
|
||||
/** Decode bounded FAT declarations without assuming the final file size. */
|
||||
export const readFatContainerTable = (bytes: Uint8Array): FatContainerTable => {
|
||||
if (!hasFatMagic(bytes)) return { state: "not-fat" };
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const magic = view.getUint32(0, false);
|
||||
if (bytes.byteLength < 8) {
|
||||
return { state: "needs-data", requiredBytes: 8 };
|
||||
}
|
||||
const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64;
|
||||
const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64;
|
||||
const count = view.getUint32(4, littleEndian);
|
||||
export const readFatContainerTable = (bytes: Uint8Array): FatContainerTable =>
|
||||
decodeFatContainerTable(bytes, readFatHeaderPrefix(bytes));
|
||||
|
||||
const decodeFatContainerTable = (
|
||||
bytes: Uint8Array,
|
||||
prefix: FatHeaderPrefix,
|
||||
): FatContainerTable => {
|
||||
if (prefix.state === "unknown" || prefix.state === "not-fat")
|
||||
return { state: "not-fat" };
|
||||
if (prefix.state === "incomplete")
|
||||
return { state: "needs-data", requiredBytes: prefix.requiredBytes };
|
||||
const { magic, littleEndian, wide, count, tableEnd } = prefix;
|
||||
if (count === 0)
|
||||
return {
|
||||
state: "malformed",
|
||||
reason: "FAT header declares no architectures",
|
||||
};
|
||||
if (count > MAX_FAT_ARCHITECTURES) return { state: "not-fat" };
|
||||
const tableEnd = 8 + count * (wide ? 32 : 20);
|
||||
if (tableEnd > bytes.byteLength)
|
||||
return { state: "needs-data", requiredBytes: tableEnd };
|
||||
const declarations = readFatSliceDeclarations(bytes.subarray(8, tableEnd), {
|
||||
@@ -181,14 +226,6 @@ export const readFatContainerTable = (bytes: Uint8Array): FatContainerTable => {
|
||||
: { state: "malformed", reason: declarations.reason };
|
||||
};
|
||||
|
||||
const hasFatMagic = (bytes: Uint8Array): boolean => {
|
||||
if (bytes.byteLength < 4) return false;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return [FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(
|
||||
view.getUint32(0, false),
|
||||
);
|
||||
};
|
||||
|
||||
/** Confirm FAT identity from observed slice headers, not CPU declarations. */
|
||||
export const classifyFatContainerIdentity = (
|
||||
header: Extract<FatContainerHeader, { readonly state: "parsed" }>,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
readFatHeaderPrefix,
|
||||
readFatContainerHeader,
|
||||
} from "./machoContainer.js";
|
||||
|
||||
const formats = [
|
||||
{ magic: 0xcafebabe, wide: false, littleEndian: false },
|
||||
{ magic: 0xcafebabf, wide: true, littleEndian: false },
|
||||
{ magic: 0xbebafeca, wide: false, littleEndian: true },
|
||||
{ magic: 0xbfbafeca, wide: true, littleEndian: true },
|
||||
] as const;
|
||||
|
||||
it.each(formats)(
|
||||
"preserves incomplete FAT format evidence ($magic)",
|
||||
(format) => {
|
||||
const bytes = Buffer.alloc(8);
|
||||
bytes.writeUInt32BE(format.magic);
|
||||
for (let observed = 0; observed < 8; observed++)
|
||||
expect(readFatHeaderPrefix(bytes.subarray(0, observed))).toEqual(
|
||||
observed < 4
|
||||
? { state: "unknown", requiredBytes: 4 }
|
||||
: { state: "incomplete", ...format, requiredBytes: 8 },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(formats)(
|
||||
"retains raw zero and maximum counts without policy or table allocation ($magic)",
|
||||
(format) => {
|
||||
const backing = Buffer.alloc(27, 0xa5);
|
||||
const window = backing.subarray(7, 15);
|
||||
const view = new DataView(
|
||||
window.buffer,
|
||||
window.byteOffset,
|
||||
window.byteLength,
|
||||
);
|
||||
view.setUint32(0, format.magic, false);
|
||||
for (const count of [0, 0xffffffff]) {
|
||||
view.setUint32(4, count, format.littleEndian);
|
||||
const before = Buffer.from(backing);
|
||||
const parsed = readFatHeaderPrefix(backing.subarray(7));
|
||||
expect(backing.equals(before)).toBe(true);
|
||||
expect(parsed).toEqual({
|
||||
state: "parsed",
|
||||
...format,
|
||||
count,
|
||||
tableEnd: 8 + count * (format.wide ? 32 : 20),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("distinguishes observed non-FAT bytes from unavailable magic", () => {
|
||||
const bytes = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
|
||||
expect(readFatHeaderPrefix(bytes)).toEqual({
|
||||
state: "not-fat",
|
||||
magic: 0x7f454c46,
|
||||
});
|
||||
expect(readFatHeaderPrefix(bytes.subarray(0, 3))).toEqual({
|
||||
state: "unknown",
|
||||
requiredBytes: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps full-header file extent admission ahead of zero-count admission", () => {
|
||||
const bytes = Buffer.alloc(8);
|
||||
bytes.writeUInt32BE(0xcafebabe);
|
||||
expect(readFatContainerHeader(bytes, 7)).toEqual({
|
||||
state: "malformed",
|
||||
reason: "FAT file size is invalid or contradicts retained bytes",
|
||||
});
|
||||
expect(readFatContainerHeader(bytes, 8)).toEqual({
|
||||
state: "malformed",
|
||||
reason: "FAT header declares no architectures",
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ describe("DOS MZ load-module validation", () => {
|
||||
overlayBytes: 7,
|
||||
relocationCount: 0,
|
||||
relocationTableOffset: 28,
|
||||
relocations: [],
|
||||
entrySegment: 0,
|
||||
entryOffset: 0,
|
||||
},
|
||||
@@ -380,3 +381,41 @@ describe("MZ header padding observations", () => {
|
||||
expect(parseExecutableHeader(bytes, "x64")).toMatchObject({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
it("retains ordered DOS relocation aliases and their source/module target coordinates", () => {
|
||||
const bytes = Buffer.alloc(128);
|
||||
bytes.write("MZ");
|
||||
bytes.writeUInt16LE(128, 2);
|
||||
bytes.writeUInt16LE(1, 4);
|
||||
bytes.writeUInt16LE(4, 6);
|
||||
bytes.writeUInt16LE(4, 8);
|
||||
bytes.writeUInt16LE(28, 24);
|
||||
bytes.writeUInt16LE(0, 28);
|
||||
bytes.writeUInt16LE(1, 30);
|
||||
bytes.writeUInt16LE(16, 32);
|
||||
bytes.writeUInt16LE(0, 34);
|
||||
bytes.writeUInt16LE(16, 36);
|
||||
bytes.writeUInt16LE(0, 38);
|
||||
bytes.writeUInt16LE(17, 40);
|
||||
bytes.writeUInt16LE(0, 42);
|
||||
const parsed = parseDosMzHeader(bytes);
|
||||
if (!parsed.ok) throw new Error(parsed.error);
|
||||
expect(parsed.value.relocations).toEqual([
|
||||
{ segment: 1, offset: 0, moduleOffset: 16, fileOffset: 80 },
|
||||
{ segment: 0, offset: 16, moduleOffset: 16, fileOffset: 80 },
|
||||
{ segment: 0, offset: 16, moduleOffset: 16, fileOffset: 80 },
|
||||
{ segment: 0, offset: 17, moduleOffset: 17, fileOffset: 81 },
|
||||
]);
|
||||
// Parsed facts are snapshots, independent of later source-buffer edits.
|
||||
bytes.writeUInt16LE(63, 32);
|
||||
expect(parsed.value.relocations[1]).toEqual({
|
||||
segment: 0,
|
||||
offset: 16,
|
||||
moduleOffset: 16,
|
||||
fileOffset: 80,
|
||||
});
|
||||
expect(parseDosMzHeader(bytes)).toEqual({
|
||||
ok: false,
|
||||
error: "DOS MZ relocation 1 points outside its load module",
|
||||
});
|
||||
});
|
||||
+25
-5
@@ -11,6 +11,14 @@ export const dosMzImageByteLength = (bytes: Buffer): Result<number, string> => {
|
||||
return ok((pages - 1) * 512 + (lastPageBytes === 0 ? 512 : lastPageBytes));
|
||||
};
|
||||
|
||||
/** One validated relocation, retaining raw words and both target coordinate spaces. */
|
||||
export interface DosMzRelocation {
|
||||
readonly segment: number;
|
||||
readonly offset: number;
|
||||
readonly moduleOffset: number;
|
||||
readonly fileOffset: number;
|
||||
}
|
||||
|
||||
/** DOS load-module coordinates before applying the loader's segment relocation. */
|
||||
export interface DosMzHeader {
|
||||
readonly headerBytes: number;
|
||||
@@ -19,6 +27,7 @@ export interface DosMzHeader {
|
||||
readonly overlayBytes: number;
|
||||
readonly relocationCount: number;
|
||||
readonly relocationTableOffset: number;
|
||||
readonly relocations: readonly DosMzRelocation[];
|
||||
readonly entrySegment: number;
|
||||
readonly entryOffset: number;
|
||||
}
|
||||
@@ -174,10 +183,11 @@ export const parseDosMzHeader = (
|
||||
if (relocationCount > 0 && tableEnd > bytes.length)
|
||||
return err("truncated DOS MZ relocation table");
|
||||
const moduleBytes = imageBytes - headerBytes;
|
||||
const relocations = validateRelocations(
|
||||
const relocations = readRelocations(
|
||||
bytes,
|
||||
relocationTableOffset,
|
||||
relocationCount,
|
||||
headerBytes,
|
||||
moduleBytes,
|
||||
);
|
||||
if (!relocations.ok) return relocations;
|
||||
@@ -192,6 +202,7 @@ export const parseDosMzHeader = (
|
||||
overlayBytes: fileSize - imageBytes,
|
||||
relocationCount,
|
||||
relocationTableOffset,
|
||||
relocations: relocations.value,
|
||||
entrySegment,
|
||||
entryOffset,
|
||||
});
|
||||
@@ -212,18 +223,27 @@ export const parseMzHeaderEvidence = (
|
||||
windowsHeader: observeMzWindowsHeader(bytes, fileSize, signatureEvidence),
|
||||
});
|
||||
|
||||
const validateRelocations = (
|
||||
const readRelocations = (
|
||||
bytes: Buffer,
|
||||
relocationTableOffset: number,
|
||||
relocationCount: number,
|
||||
headerBytes: number,
|
||||
moduleBytes: number,
|
||||
): Result<void, string> => {
|
||||
): Result<readonly DosMzRelocation[], string> => {
|
||||
const relocations: DosMzRelocation[] = [];
|
||||
for (let index = 0; index < relocationCount; index += 1) {
|
||||
const record = relocationTableOffset + index * 4;
|
||||
const offset = bytes.readUInt16LE(record);
|
||||
const segment = bytes.readUInt16LE(record + 2);
|
||||
if (segment * 16 + offset + 2 > moduleBytes)
|
||||
const moduleOffset = segment * 16 + offset;
|
||||
if (moduleOffset + 2 > moduleBytes)
|
||||
return err(`DOS MZ relocation ${index} points outside its load module`);
|
||||
relocations.push({
|
||||
segment,
|
||||
offset,
|
||||
moduleOffset,
|
||||
fileOffset: headerBytes + moduleOffset,
|
||||
});
|
||||
}
|
||||
return ok(undefined);
|
||||
return ok(relocations);
|
||||
};
|
||||
@@ -136,8 +136,10 @@ export const methodBody = (
|
||||
};
|
||||
}
|
||||
try {
|
||||
const offset = pe.rvaToOffset(rva, 1, "method.body");
|
||||
const methodExtent = pe.rvaAvailableBytes(rva, "method.body");
|
||||
const { offset, available: methodExtent } = pe.rvaFileRange(
|
||||
rva,
|
||||
"method.body",
|
||||
);
|
||||
const header = readMethodBodyHeader(bytes, offset);
|
||||
if (
|
||||
header.size > methodExtent ||
|
||||
|
||||
@@ -72,7 +72,10 @@ export interface ManagedPeLayout {
|
||||
readonly cliDirectoryState: "absent" | "present" | "unreadable";
|
||||
readonly cliIssue: ManagedParseIssue | null;
|
||||
rvaToOffset(rva: number, size: number, scope: string): number;
|
||||
rvaAvailableBytes(rva: number, scope: string): number;
|
||||
rvaFileRange(
|
||||
rva: number,
|
||||
scope: string,
|
||||
): { readonly offset: number; readonly available: number };
|
||||
}
|
||||
|
||||
interface RvaMapping {
|
||||
@@ -496,7 +499,7 @@ export const readManagedPeLayout = (bytes: Buffer): ManagedPeLayout => {
|
||||
cliIssue,
|
||||
rvaToOffset: (rva, size, scope) =>
|
||||
mapRva({ bytes, sections, rva, size, scope }),
|
||||
rvaAvailableBytes: (rva, scope) =>
|
||||
resolveRva({ bytes, sections, rva, size: 1, scope }).available,
|
||||
rvaFileRange: (rva, scope) =>
|
||||
resolveRva({ bytes, sections, rva, size: 1, scope }),
|
||||
};
|
||||
};
|
||||
@@ -486,3 +486,73 @@ describe("independent DOS MZ import verification", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies duplicate aliases and overlapping DOS fixups in declared order with original/final byte evidence", () => {
|
||||
const bytes = Buffer.alloc(128);
|
||||
bytes.write("MZ");
|
||||
bytes.writeUInt16LE(128, 2);
|
||||
bytes.writeUInt16LE(1, 4);
|
||||
bytes.writeUInt16LE(4, 6);
|
||||
bytes.writeUInt16LE(4, 8);
|
||||
bytes.writeUInt16LE(28, 24);
|
||||
bytes.writeUInt16LE(0, 28);
|
||||
bytes.writeUInt16LE(1, 30);
|
||||
bytes.writeUInt16LE(16, 32);
|
||||
bytes.writeUInt16LE(0, 34);
|
||||
bytes.writeUInt16LE(16, 36);
|
||||
bytes.writeUInt16LE(0, 38);
|
||||
bytes.writeUInt16LE(17, 40);
|
||||
bytes.writeUInt16LE(0, 42);
|
||||
bytes.set([1, 0, 2], 80);
|
||||
const before = Buffer.from(bytes);
|
||||
const prepared = prepareGhidraDosLoadImage(bytes, bytes.length);
|
||||
if (!prepared.ok) throw new Error(prepared.error);
|
||||
expect(bytes.equals(before)).toBe(true);
|
||||
expect(
|
||||
prepared.value.modified
|
||||
.subarray(80, 83)
|
||||
.equals(Buffer.from([1, 0x30, 0x12])),
|
||||
).toBe(true);
|
||||
expect(prepared.value.expectedRelocations).toEqual([
|
||||
{
|
||||
address: "0x10010",
|
||||
segment: 0x1001,
|
||||
offset: 0,
|
||||
status: "APPLIED",
|
||||
type: 0,
|
||||
values: [1, 0, 0x1001],
|
||||
original_bytes_hex: "0100",
|
||||
memory_bytes_hex: "0130",
|
||||
},
|
||||
{
|
||||
address: "0x10010",
|
||||
segment: 0x1000,
|
||||
offset: 16,
|
||||
status: "APPLIED",
|
||||
type: 0,
|
||||
values: [0, 16, 0x2001],
|
||||
original_bytes_hex: "0110",
|
||||
memory_bytes_hex: "0130",
|
||||
},
|
||||
{
|
||||
address: "0x10010",
|
||||
segment: 0x1000,
|
||||
offset: 16,
|
||||
status: "APPLIED",
|
||||
type: 0,
|
||||
values: [0, 16, 0x3001],
|
||||
original_bytes_hex: "0120",
|
||||
memory_bytes_hex: "0130",
|
||||
},
|
||||
{
|
||||
address: "0x10011",
|
||||
segment: 0x1000,
|
||||
offset: 17,
|
||||
status: "APPLIED",
|
||||
type: 0,
|
||||
values: [0, 17, 0x1230],
|
||||
original_bytes_hex: "3002",
|
||||
memory_bytes_hex: "3012",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -78,24 +78,32 @@ export const prepareGhidraDosLoadImage = (
|
||||
const loadSegment = 0x1000;
|
||||
const modified = Buffer.from(bytes.subarray(0, header.imageBytes));
|
||||
const applied = [];
|
||||
for (let index = 0; index < header.relocationCount; index += 1) {
|
||||
const table = header.relocationTableOffset + index * 4;
|
||||
const offset = bytes.readUInt16LE(table);
|
||||
const segment = bytes.readUInt16LE(table + 2);
|
||||
const fileOffset = header.headerBytes + segment * 16 + offset;
|
||||
for (const {
|
||||
segment,
|
||||
offset,
|
||||
moduleOffset,
|
||||
fileOffset,
|
||||
} of header.relocations) {
|
||||
const original = modified
|
||||
.subarray(fileOffset, fileOffset + 2)
|
||||
.toString("hex");
|
||||
const relocated =
|
||||
(modified.readUInt16LE(fileOffset) + loadSegment) & 0xffff;
|
||||
modified.writeUInt16LE(relocated, fileOffset);
|
||||
applied.push({ segment, offset, fileOffset, original, relocated });
|
||||
applied.push({
|
||||
segment,
|
||||
offset,
|
||||
moduleOffset,
|
||||
fileOffset,
|
||||
original,
|
||||
relocated,
|
||||
});
|
||||
}
|
||||
// Application records describe their own fixup; the bridge reads memory
|
||||
// once all fixups have completed, including later overlapping writes.
|
||||
const expectedRelocations = applied.map(
|
||||
({ segment, offset, fileOffset, original, relocated }) => ({
|
||||
address: address(loadSegment * 16 + segment * 16 + offset),
|
||||
({ segment, offset, moduleOffset, fileOffset, original, relocated }) => ({
|
||||
address: address(loadSegment * 16 + moduleOffset),
|
||||
segment: (loadSegment + segment) & 0xffff,
|
||||
offset,
|
||||
status: "APPLIED",
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { FileOffsetPartialObservation } from "../domain/native/fileOffsetPa
|
||||
import {
|
||||
findOverlappingFatSlices,
|
||||
readFatSliceDeclarations,
|
||||
readFatHeaderPrefix,
|
||||
readMachoHeaderPrefixFacts,
|
||||
type FatSliceDeclaration,
|
||||
validateFatSlice,
|
||||
@@ -340,16 +341,13 @@ interface FatLayout {
|
||||
}
|
||||
|
||||
function fatLayout(prefix: Buffer): Result<FatLayout | null, string> {
|
||||
const magic = prefix.length < 4 ? 0 : prefix.readUInt32BE(0);
|
||||
if (![0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic))
|
||||
return ok(null);
|
||||
if (prefix.length < 8) return err("The original FAT header is truncated");
|
||||
const littleEndian = magic === 0xbebafeca || magic === 0xbfbafeca;
|
||||
const wide = magic === 0xcafebabf || magic === 0xbfbafeca;
|
||||
const count = littleEndian ? prefix.readUInt32LE(4) : prefix.readUInt32BE(4);
|
||||
const header = readFatHeaderPrefix(prefix);
|
||||
if (header.state === "unknown" || header.state === "not-fat") return ok(null);
|
||||
if (header.state === "incomplete")
|
||||
return err("The original FAT header is truncated");
|
||||
const { littleEndian, wide, count, tableEnd } = header;
|
||||
if (count === 0 || count > MAX_FAT_ARCHITECTURES)
|
||||
return err("FAT architecture count is outside the supported 1..128 range");
|
||||
const tableEnd = 8 + count * (wide ? 32 : 20);
|
||||
const table = prefix.subarray(8, tableEnd);
|
||||
const parsed = readFatSliceDeclarations(table, { count, wide, littleEndian });
|
||||
if (parsed.status !== "parsed") return err(parsed.reason);
|
||||
|
||||
@@ -786,3 +786,26 @@ it("retains an owned partial thin image and exact cancellation reason after a wr
|
||||
expect(Buffer.compare(await readFile(destination), partial)).toBe(0);
|
||||
await expect(access(destination)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([0, 129])(
|
||||
"keeps Hopper FAT count admission explicit for count %i",
|
||||
async (count) => {
|
||||
const directory = await createTestTempDirectory("rea-hopper-fat-count-");
|
||||
const fixture = buildFat32Container([
|
||||
{ cpuType: CPU_TYPES.arm64, cpuSubtype: 0 },
|
||||
]);
|
||||
fixture.container.writeUInt32BE(count, 4);
|
||||
const path = await writeFixture(directory, "counts.fat", fixture);
|
||||
const result = await resolveHopperMachOImage(
|
||||
fat32Target(path, fixture, "arm64"),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Invalid FAT count was admitted");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "BinaryTargetError",
|
||||
path,
|
||||
reason:
|
||||
"FAT architecture table is truncated or exceeds the supported 128 entries",
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -19,6 +19,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import {
|
||||
findOverlappingFatSlices,
|
||||
readFatSliceDeclarations,
|
||||
readFatHeaderPrefix,
|
||||
readMachoHeaderPrefixFacts,
|
||||
validateFatSlice,
|
||||
validateFatSliceIdentity,
|
||||
@@ -285,7 +286,9 @@ const inspectHopperMachOImage = async (
|
||||
return bytes.subarray(0, bytesRead);
|
||||
};
|
||||
const header = await read(0, 8);
|
||||
const magic = header.length === 8 ? header.readUInt32BE(0) : 0;
|
||||
const fatPrefix = readFatHeaderPrefix(header);
|
||||
const magic =
|
||||
header.length === 8 && fatPrefix.state !== "unknown" ? fatPrefix.magic : 0;
|
||||
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) {
|
||||
if (target.format !== "mach-o")
|
||||
return invalid("Thin Mach-O source disagrees with its admitted format");
|
||||
@@ -336,24 +339,17 @@ const inspectHopperMachOImage = async (
|
||||
cpu_subtype: facts.identity.cpuSubtype,
|
||||
});
|
||||
}
|
||||
const isFatMagic = [0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes(
|
||||
magic,
|
||||
);
|
||||
if (!isFatMagic)
|
||||
if (fatPrefix.state !== "parsed")
|
||||
return invalid(
|
||||
"Mach-O source header changed before Hopper image preparation",
|
||||
);
|
||||
const wide = magic === 0xcafebabf || magic === 0xbfbafeca;
|
||||
const littleEndian = magic === 0xbebafeca || magic === 0xbfbafeca;
|
||||
const entrySize = wide ? 32 : 20;
|
||||
const count = littleEndian ? header.readUInt32LE(4) : header.readUInt32BE(4);
|
||||
const { wide, littleEndian, count, tableEnd } = fatPrefix;
|
||||
const size = (await file.stat()).size;
|
||||
const tableEnd = 8 + count * entrySize;
|
||||
if (count === 0 || count > 128 || tableEnd > size)
|
||||
return invalid(
|
||||
"FAT architecture table is truncated or exceeds the supported 128 entries",
|
||||
);
|
||||
const table = await read(8, count * entrySize);
|
||||
const table = await read(8, tableEnd - 8);
|
||||
const parsed = readFatSliceDeclarations(table, {
|
||||
count,
|
||||
wide,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFatContainerHeader } from "../domain/apple/machoContainer.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -340,3 +341,26 @@ describe("native Mach-O fixup load command boundaries", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([0, 129])(
|
||||
"preserves native FAT selection policy separately from generic identity admission (count %i)",
|
||||
(count) => {
|
||||
const bytes = Buffer.alloc(8);
|
||||
bytes.writeUInt32BE(0xcafebabe);
|
||||
bytes.writeUInt32BE(count, 4);
|
||||
expect(readFatContainerHeader(bytes, bytes.length)).toEqual(
|
||||
count === 0
|
||||
? { state: "malformed", reason: "FAT header declares no architectures" }
|
||||
: { state: "not-fat" },
|
||||
);
|
||||
expect(() => selectMachoSlice(bytes, "arm64")).toThrow(
|
||||
count === 0
|
||||
? new TypeError(
|
||||
"Requested FAT architecture is absent (FAT header byte order: big-endian)",
|
||||
)
|
||||
: new RangeError(
|
||||
"Malformed FAT architecture table (FAT header byte order: big-endian)",
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
|
||||
import {
|
||||
readFatSliceDeclarations,
|
||||
readFatHeaderPrefix,
|
||||
validateFatSlice,
|
||||
validateFatSliceIdentity,
|
||||
type FatSliceDeclaration,
|
||||
@@ -66,45 +67,24 @@ export interface MachoLayout {
|
||||
|
||||
const hex = (value: bigint) => `0x${value.toString(16)}`;
|
||||
|
||||
/** Classify a FAT magic word; thin Mach-O returns null. */
|
||||
export const fatHeaderFormat = (
|
||||
magic: number,
|
||||
): { readonly littleEndian: boolean; readonly fat64: boolean } | null => {
|
||||
switch (magic) {
|
||||
case 0xcafebabe:
|
||||
return { littleEndian: false, fat64: false };
|
||||
case 0xcafebabf:
|
||||
return { littleEndian: false, fat64: true };
|
||||
case 0xbebafeca:
|
||||
return { littleEndian: true, fat64: false };
|
||||
case 0xbfbafeca:
|
||||
return { littleEndian: true, fat64: true };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Select the thin Mach-O slice for `architecture` inside a FAT or thin container. */
|
||||
export const selectMachoSlice = (
|
||||
bytes: Buffer,
|
||||
architecture: string,
|
||||
): MachoSlice => {
|
||||
const magic = bytes.readUInt32BE(0);
|
||||
const header = fatHeaderFormat(magic);
|
||||
if (header === null) return { slice: 0, sliceEnd: bytes.length };
|
||||
const { littleEndian, fat64 } = header;
|
||||
const stride = fat64 ? 32 : 20;
|
||||
const readUInt32 = littleEndian
|
||||
? (offset: number) => bytes.readUInt32LE(offset)
|
||||
: (offset: number) => bytes.readUInt32BE(offset);
|
||||
const header = readFatHeaderPrefix(bytes);
|
||||
if (header.state === "unknown")
|
||||
throw new RangeError("Truncated Mach-O header");
|
||||
if (header.state === "not-fat") return { slice: 0, sliceEnd: bytes.length };
|
||||
const { littleEndian, wide } = header;
|
||||
const byteOrder = littleEndian ? "little-endian" : "big-endian";
|
||||
const malformed = (reason: string) =>
|
||||
new RangeError(`${reason} (FAT header byte order: ${byteOrder})`);
|
||||
const invalid = (reason: string) =>
|
||||
new TypeError(`${reason} (FAT header byte order: ${byteOrder})`);
|
||||
if (bytes.length < 8) throw malformed("Malformed FAT architecture table");
|
||||
const count = readUInt32(4);
|
||||
const headerEnd = 8 + count * stride;
|
||||
if (header.state === "incomplete")
|
||||
throw malformed("Malformed FAT architecture table");
|
||||
const { count, tableEnd: headerEnd } = header;
|
||||
if (count > 128 || headerEnd > bytes.length)
|
||||
throw malformed("Malformed FAT architecture table");
|
||||
const policy = MACHO_ARCHITECTURE_POLICIES[architecture];
|
||||
@@ -112,7 +92,7 @@ export const selectMachoSlice = (
|
||||
throw invalid("Requested FAT architecture is absent");
|
||||
const declarations = readFatSliceDeclarations(bytes.subarray(8, headerEnd), {
|
||||
count,
|
||||
wide: fat64,
|
||||
wide,
|
||||
littleEndian,
|
||||
});
|
||||
if (declarations.status !== "parsed") throw malformed(declarations.reason);
|
||||
|
||||
Reference in new issue
Block a user