refactor(macho): centralize FAT declaration facts

This commit is contained in:
morluto committed 2026-10-24 11:09:17 +08:00
1 parent 80b7bbc8bd
commit cd10ee1f8a
7 files changed
+381 -61

No files matched your search

@@ -12,7 +12,9 @@ import { javaClassFile } from "../../../tests/fixtures/javaClassFile.js";
import { parseExecutableHeader } from "../../domain/binaryTarget.js";
import {
classifyFatContainerIdentity,
findOverlappingFatSlices,
readFatContainerHeader,
readFatSliceDeclarations,
readMachoHeaderPrefixFacts,
} from "../../domain/apple/machoContainer.js";
@@ -29,6 +31,94 @@ const classify = (bytes: Uint8Array) => {
});
};
describe("FAT declaration facts", () => {
it.each([
{ wide: false, littleEndian: false },
{ wide: false, littleEndian: true },
{ wide: true, littleEndian: false },
{ wide: true, littleEndian: true },
])(
"retains raw fields and reserved-field absence for wide=$wide littleEndian=$littleEndian",
async ({ wide, littleEndian }) => {
const arm64e = machoImage({ cpu: CPU.arm64e });
const x86 = machoImage({ cpu: CPU.x86_64 });
const bytes = fatImage(
[
{ cpu: CPU.arm64e, bytes: arm64e },
{ cpu: CPU.x86_64, bytes: x86 },
],
wide,
littleEndian,
);
if (wide)
new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
).setUint32(8 + 32 + 28, 0x89abcdef, littleEndian);
const declarations = readFatSliceDeclarations(
bytes.subarray(8, 8 + 2 * (wide ? 32 : 20)),
{ count: 2, wide, littleEndian },
);
expect(declarations).toEqual({
status: "parsed",
slices: [
{
index: 0,
offset: 4096,
size: arm64e.byteLength,
alignmentExponent: 12,
cpuType: CPU.arm64e.type,
cpuSubtype: CPU.arm64e.subtype,
reserved: wide ? 0 : null,
},
{
index: 1,
offset: 8192,
size: x86.byteLength,
alignmentExponent: 12,
cpuType: CPU.x86_64.type,
cpuSubtype: CPU.x86_64.subtype,
reserved: wide ? 0x89abcdef : null,
},
],
});
// Physical facts do not impose Hopper's reserved-field admission policy.
expect(classify(bytes)).toEqual({ state: "mach-o" });
expect(
await readMachoImage(readerOf(bytes), bytes.byteLength),
).toMatchObject({
status: "parsed",
});
},
);
it("finds overlap in physical order while retaining declarations in table order", () => {
const bytes = fatImage([
{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
]);
const header = readFatContainerHeader(bytes, bytes.byteLength);
if (header.state !== "parsed") throw new Error("expected parsed FAT table");
const first = { ...header.slices[0]!, offset: 128, size: 64 };
const second = { ...header.slices[1]!, offset: 96, size: 64 };
const tableOrder = [first, second];
const overlap = findOverlappingFatSlices(tableOrder);
expect(overlap).toEqual([second, first]);
expect(overlap?.[0]).toBe(second);
expect(overlap?.[1]).toBe(first);
expect(tableOrder).toEqual([first, second]);
expect(
findOverlappingFatSlices([first, { ...second, size: 32 }]),
).toBeNull();
expect(
findOverlappingFatSlices([first, { ...second, size: 16 }]),
).toBeNull();
expect(findOverlappingFatSlices([first])).toBeNull();
expect(findOverlappingFatSlices([])).toBeNull();
});
});
describe("FAT Mach-O identity", () => {
it("does not treat known CPU declarations as proof when slices are ar archives", async () => {
const archive = arArchive();
+21
View File
@@ -12,6 +12,8 @@ export interface FatSliceDeclaration {
readonly alignmentExponent: number;
readonly cpuType: number;
readonly cpuSubtype: number;
/** FAT64's raw reserved field; FAT32 has no corresponding field. */
readonly reserved: number | null;
}
export interface FatSliceRangeIssue {
@@ -273,11 +275,30 @@ export const readFatSliceDeclarations = (
alignmentExponent: view.getUint32(base + (wide ? 24 : 16), littleEndian),
cpuType: view.getUint32(base, littleEndian),
cpuSubtype: view.getUint32(base + 4, littleEndian),
reserved: wide ? view.getUint32(base + 28, littleEndian) : null,
});
}
return { status: "parsed", slices };
};
/** Find an overlapping pair without changing the declarations' table order. */
export const findOverlappingFatSlices = (
declarations: readonly FatSliceDeclaration[],
): readonly [FatSliceDeclaration, FatSliceDeclaration] | null => {
const ranges = [...declarations].sort((a, b) => a.offset - b.offset);
for (let index = 1; index < ranges.length; index++) {
const previous = ranges[index - 1];
const current = ranges[index];
if (
previous !== undefined &&
current !== undefined &&
current.offset < previous.offset + previous.size
)
return [previous, current];
}
return null;
};
/** Validate byte-range and table/header identity facts shared by Mach-O readers. */
export const validateFatSlice = (
declaration: FatSliceDeclaration,
+10 -28
View File
@@ -25,10 +25,12 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { FileOffsetPartialObservation } from "../domain/native/fileOffsetPartialObservation.js";
import {
findOverlappingFatSlices,
readFatSliceDeclarations,
readMachoHeaderPrefixFacts,
type FatSliceDeclaration,
validateFatSlice,
validateFatSliceIdentity,
} from "../domain/apple/machoContainer.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -351,17 +353,11 @@ function fatLayout(prefix: Buffer): Result<FatLayout | null, string> {
const table = prefix.subarray(8, tableEnd);
const parsed = readFatSliceDeclarations(table, { count, wide, littleEndian });
if (parsed.status !== "parsed") return err(parsed.reason);
if (wide) {
for (const slice of parsed.slices)
if (
(littleEndian
? table.readUInt32LE(slice.index * 32 + 28)
: table.readUInt32BE(slice.index * 32 + 28)) !== 0
)
return err(
`FAT64 architecture ${slice.index} has a nonzero reserved field`,
);
}
for (const slice of parsed.slices)
if (slice.reserved !== null && slice.reserved !== 0)
return err(
`FAT64 architecture ${slice.index} has a nonzero reserved field`,
);
return ok({ slices: parsed.slices, tableEnd });
}
@@ -436,17 +432,8 @@ function originalOffset(
"Hopper did not expose the loaded Mach-O header needed to identify its FAT slice",
);
const matches = [];
const ranges = [...layout.value.slices].sort((a, b) => a.offset - b.offset);
for (let index = 1; index < ranges.length; index++) {
const previous = ranges[index - 1];
const current = ranges[index];
if (
previous !== undefined &&
current !== undefined &&
current.offset < previous.offset + previous.size
)
return err("Original FAT slices overlap");
}
if (findOverlappingFatSlices(layout.value.slices) !== null)
return err("Original FAT slices overlap");
for (const slice of layout.value.slices) {
const reason = validateFatSlice(slice, size, layout.value.tableEnd);
if (reason !== null) return err(reason);
@@ -460,12 +447,7 @@ function originalOffset(
return err(
`FAT architecture ${slice.index} does not contain a Mach-O image`,
);
const identityError = validateFatSlice(
slice,
size,
layout.value.tableEnd,
embeddedIdentity,
);
const identityError = validateFatSliceIdentity(slice, embeddedIdentity);
if (identityError !== null) return err(identityError);
if (
slice.cpuType === loadedIdentity.cpuType &&
+9 -23
View File
@@ -14,9 +14,11 @@ import { BinaryTargetError } from "../domain/configurationErrors.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
findOverlappingFatSlices,
readFatSliceDeclarations,
readMachoHeaderPrefixFacts,
validateFatSlice,
validateFatSliceIdentity,
type FatSliceDeclaration,
} from "../domain/apple/machoContainer.js";
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
@@ -366,7 +368,6 @@ const inspectHopperMachOImage = async (
const architecture = target.architecture;
const cpuTypes = { x86: 7, x86_64: 0x1000007, arm: 12, arm64: 0x100000c };
const matches = [];
const ranges = [];
for (const slice of parsed.slices) {
if (signal?.aborted)
return err(
@@ -374,15 +375,10 @@ const inspectHopperMachOImage = async (
);
const rangeError = validateFatSlice(slice, size, tableEnd);
if (rangeError !== null) return invalid(rangeError);
if (wide) {
const reserved = littleEndian
? table.readUInt32LE(slice.index * 32 + 28)
: table.readUInt32BE(slice.index * 32 + 28);
if (reserved !== 0)
return invalid(
`FAT64 architecture ${slice.index} has a nonzero reserved field`,
);
}
if (slice.reserved !== null && slice.reserved !== 0)
return invalid(
`FAT64 architecture ${slice.index} has a nonzero reserved field`,
);
if (slice.size < 12)
return invalid(
`FAT architecture ${slice.index} has a truncated Mach-O header`,
@@ -395,22 +391,12 @@ const inspectHopperMachOImage = async (
return invalid(
`FAT architecture ${slice.index} does not contain a Mach-O image`,
);
const identityError = validateFatSlice(slice, size, tableEnd, identity);
const identityError = validateFatSliceIdentity(slice, identity);
if (identityError !== null) return invalid(identityError);
ranges.push(slice);
if (slice.cpuType === cpuTypes[architecture]) matches.push(slice);
}
ranges.sort((a, b) => a.offset - b.offset);
for (let index = 1; index < ranges.length; index++) {
const previous = ranges[index - 1];
const current = ranges[index];
if (
previous !== undefined &&
current !== undefined &&
current.offset < previous.offset + previous.size
)
return invalid("FAT slices overlap");
}
if (findOverlappingFatSlices(parsed.slices) !== null)
return invalid("FAT slices overlap");
const selected = selectHopperSlice(matches, architecture);
if (selected === undefined)
return invalid(
+19
View File
@@ -70,6 +70,25 @@ const registerThinCpuFamilyControls = (): void => {
});
};
it.each([false, true])(
"retains the shared FAT declaration without imposing Hopper policy (wide=%s)",
(wide) => {
const thin = machoImage({ cpu: CPU.arm64e });
const fat = Buffer.from(fatImage([{ cpu: CPU.arm64e, bytes: thin }], wide));
if (wide) fat.writeUInt32BE(0x89abcdef, 8 + 28);
expect(selectMachoSlice(fat, "arm64").fatDeclaration).toEqual({
index: 0,
offset: 4096,
size: thin.byteLength,
cpuType: CPU.arm64e.type,
cpuSubtype: CPU.arm64e.subtype,
alignmentExponent: 12,
reserved: wide ? 0x89abcdef : null,
tableEnd: 8 + (wide ? 32 : 20),
});
},
);
describe("native Mach-O slice selection", () => {
it("rejects misaligned load commands", () => {
expect(() =>
+3 -10
View File
@@ -11,6 +11,7 @@ import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import {
readFatSliceDeclarations,
validateFatSlice,
validateFatSliceIdentity,
type FatSliceDeclaration,
} from "../domain/apple/machoContainer.js";
@@ -34,15 +35,7 @@ export interface MachoSlice {
slice: number;
sliceEnd: number;
/** FAT table identity, absent for a thin image. */
fatDeclaration?: {
offset: number;
size: number;
cpuType: number;
cpuSubtype: number;
alignmentExponent: number;
index: number;
tableEnd: number;
};
fatDeclaration?: FatSliceDeclaration & { readonly tableEnd: number };
}
/** Parsed little-endian 64-bit Mach-O layout for the selected architecture slice. */
@@ -193,7 +186,7 @@ export const parseMachoLayout = (
const cpuSubtype = readHeaderUInt32(slice + 8);
const fat = selection.fatDeclaration;
if (fat !== undefined) {
const issue = validateFatSlice(fat, bytes.length, fat.tableEnd, {
const issue = validateFatSliceIdentity(fat, {
cpuType,
cpuSubtype,
});
@@ -0,0 +1,229 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import {
CPU,
fatImage,
machoImage,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js";
import { mapHopperFileOffset } from "../../../src/hopper/HopperFileOffset.js";
import { resolveHopperMachOImage } from "../../../src/hopper/HopperMachOImage.js";
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const sourceTarget = async (bytes: Uint8Array, sliceSize: number) => {
const directory = await createTestTempDirectory(
"rea-hopper-fat-coordinates-",
);
const path = join(directory, "source.fat");
await writeFile(path, bytes);
return {
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
kind: "executable",
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["arm64"],
universalSlice: { offset: 4096, size: sliceSize },
} satisfies BinaryTarget;
};
const resolveImage = (
target: BinaryTarget & { readonly kind: "executable" },
) => {
const resources = createInspectionResources();
onTestFinished(() => resources.close());
return resolveHopperMachOImage(target, { resourceScope: resources.owner });
};
const loadedObservation = (header: Uint8Array) => ({
address: "0x1000",
provider_file_offset: 8,
provider_source_path: null,
provider_image_header_hex: Buffer.from(header)
.subarray(0, 12)
.toString("hex"),
});
it.each([
{ wide: false, littleEndian: false },
{ wide: false, littleEndian: true },
{ wide: true, littleEndian: false },
{ wide: true, littleEndian: true },
])(
"keeps launch selection separate from loaded-image coordinates (wide=$wide littleEndian=$littleEndian)",
async ({ wide, littleEndian }) => {
const plain = machoImage({ cpu: CPU.arm64 });
const arm64e = machoImage({ cpu: CPU.arm64e });
const bytes = fatImage(
[
{ cpu: CPU.arm64, bytes: plain },
{ cpu: CPU.arm64e, bytes: arm64e },
],
wide,
littleEndian,
);
const target = await sourceTarget(bytes, plain.byteLength);
const selected = await resolveImage(target);
expect(selected).toEqual({
ok: true,
value: {
method: wide ? "fat64_thin_slice" : "fat32_thin_slice",
architecture_count: 2,
container_table_sha256: createHash("sha256")
.update(bytes.subarray(0, 8 + 2 * (wide ? 32 : 20)))
.digest("hex"),
source_offset: 4096,
byte_length: plain.byteLength,
cpu_type: CPU.arm64.type,
cpu_subtype: CPU.arm64.subtype,
},
});
const mapped = await mapHopperFileOffset(target, loadedObservation(arm64e));
expect(mapped).toEqual({
ok: true,
value: {
address: "0x1000",
file_offset: 8192 + 8,
provider_file_offset: 8,
image_base_file_offset: 8192,
source_path: target.path,
},
});
},
);
it.each([false, true])(
"refuses a nonzero FAT64 reserved field in both consumers (littleEndian=%s)",
async (littleEndian) => {
const thin = machoImage({ cpu: CPU.arm64 });
const bytes = fatImage(
[{ cpu: CPU.arm64, bytes: thin }],
true,
littleEndian,
);
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(
8 + 28,
0x89abcdef,
littleEndian,
);
const target = await sourceTarget(bytes, thin.byteLength);
const reason = "FAT64 architecture 0 has a nonzero reserved field";
expect(await resolveImage(target)).toMatchObject({
ok: false,
error: { _tag: "BinaryTargetError", reason },
});
expect(
await mapHopperFileOffset(target, loadedObservation(thin)),
).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
reason,
partialObservation: {
...loadedObservation(thin),
original_file_offset: { available: false, reason },
},
},
});
},
);
it.each([
{ wide: false, littleEndian: false },
{ wide: false, littleEndian: true },
{ wide: true, littleEndian: false },
{ wide: true, littleEndian: true },
])(
"refuses overlapping valid slice headers in both consumers (wide=$wide littleEndian=$littleEndian)",
async ({ wide, littleEndian }) => {
const plain = machoImage({ cpu: CPU.arm64 });
const arm64e = machoImage({ cpu: CPU.arm64e });
const bytes = fatImage(
[
{ cpu: CPU.arm64, bytes: plain },
{ cpu: CPU.arm64e, bytes: arm64e },
],
wide,
littleEndian,
);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
// Both headers and ranges remain valid; the first range contains the second.
if (wide)
view.setBigUint64(
8 + 16,
4096n + BigInt(arm64e.byteLength),
littleEndian,
);
else view.setUint32(8 + 12, 4096 + arm64e.byteLength, littleEndian);
const target = await sourceTarget(bytes, 4096 + arm64e.byteLength);
expect(await resolveImage(target)).toMatchObject({
ok: false,
error: { _tag: "BinaryTargetError", reason: "FAT slices overlap" },
});
expect(
await mapHopperFileOffset(target, loadedObservation(arm64e)),
).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
reason: "Original FAT slices overlap",
},
});
},
);
it("requires a unique exact raw CPU identity for loaded-image attribution", async () => {
const arm64e = machoImage({ cpu: CPU.arm64e });
const bytes = fatImage([
{ cpu: CPU.arm64e, bytes: arm64e },
{ cpu: CPU.arm64e, bytes: arm64e },
]);
const target = await sourceTarget(bytes, arm64e.byteLength);
expect(
await mapHopperFileOffset(target, loadedObservation(arm64e)),
).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
reason: "Original FAT slice matching Hopper's loaded header is ambiguous",
},
});
const withoutCapabilities = Buffer.from(arm64e);
withoutCapabilities.writeUInt32LE(2, 8);
expect(
await mapHopperFileOffset(target, loadedObservation(withoutCapabilities)),
).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
reason: "Original FAT slice matching Hopper's loaded header is missing",
},
});
});
it("checks all 12 loaded-header bytes after matching its CPU identity", async () => {
const thin = machoImage({ cpu: CPU.arm64 });
const target = await sourceTarget(
fatImage([{ cpu: CPU.arm64, bytes: thin }]),
thin.byteLength,
);
const loadedHeader = Buffer.alloc(12);
// A big-endian header claims the same CPU identity but is not the source header.
loadedHeader.writeUInt32BE(0xfeedfacf, 0);
loadedHeader.writeUInt32BE(CPU.arm64.type, 4);
loadedHeader.writeUInt32BE(CPU.arm64.subtype, 8);
expect(
await mapHopperFileOffset(target, loadedObservation(loadedHeader)),
).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCapabilityUnavailableError",
reason: "Original FAT slice header disagrees with Hopper's loaded header",
},
});
});