fix(hopper): prepare deterministic FAT32 analysis slices

Use the validated thin-image preparation path for FAT32 and FAT64. Prefer ordinary arm64 then arm64e, masking capability bits only for ranking while preserving raw slice identity; reject unresolved ambiguity. Bump the analysis profile epoch.

Validation: slice/profile fixtures cover raw subtypes, ambiguity, malformed tables and source integrity. Real Hopper execution was not run.

Related: #3072, #3092

Co-authored-by: akushonkamen <akushonkamen@163.com>
This commit is contained in:
morluto
2026-10-14 16:53:14 +08:00
parent ee5cfbe05c
commit cf806ea02a
6 changed files with 482 additions and 43 deletions
+12 -6
View File
@@ -639,12 +639,18 @@ memory has no original file offset. For FAT Mach-O, offsets refer to the origina
container file. Results retain Hopper's image-relative offset, the observed slice
base, and the source executable path.
Loader selection reads the Mach-O container header, including FAT files with a
single architecture. For FAT64, REA validates the architecture table and selected
Mach-O header, prepares a private thin image, and loads it with Hopper's native
Mach-O loader. It checks the entire source's SHA-256 while copying the slice;
source identity and reported offsets still refer to the original container.
An ambiguous architecture subtype requires an explicitly extracted thin image;
REA does not guess. Configured loader arguments remain explicit overrides.
single architecture. For FAT32 and FAT64 containers, REA validates the
architecture table and selected Mach-O header, prepares a private thin image,
and loads it with Hopper's native Mach-O loader instead of Hopper's implicit
FAT slice choice. For arm64 targets, REA ranks subtype bases 0 (plain arm64) and
2 (arm64e), ignoring capability bits for ranking while retaining the raw subtype
for slice identity. These slices' function-entry encodings decode under
`--aarch64`, ahead of Apple's arm64e.x1 variant (base subtype 12), which is
selected only when it is the arm64 family's only candidate. REA checks the
entire source's SHA-256 while copying the slice; source identity and reported
offsets still refer to the original container. Any other ambiguous architecture
subtype requires an explicitly extracted thin image; REA does not guess.
Configured loader arguments remain explicit overrides.
FAT64 is distinct from the CPU architecture: FAT32 containers can contain 64-bit
executable images. This preparation avoids the Raw Binary loader dialog observed
with native FAT64 loading on Hopper 6.1.0-demo.
+71 -1
View File
@@ -1,6 +1,14 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { hopperLoaderArgsForTarget } from "./HopperAnalysisProfile.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import {
hopperLoaderArgsForTarget,
resolveHopperAnalysisProfile,
} from "./HopperAnalysisProfile.js";
it("routes physical universal Mach-O containers through Hopper's FAT loader", () => {
const result = hopperLoaderArgsForTarget(
@@ -21,3 +29,65 @@ it("routes physical universal Mach-O containers through Hopper's FAT loader", ()
value: ["-l", "FAT", "--aarch64", "-l", "Mach-O"],
});
});
it("prepares a verified thin arm64e slice for a FAT32 system container", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-profile-");
const writeUInt32 = (buffer: Buffer, offset: number, value: number): void => {
buffer.writeUInt32BE(value, offset);
};
const slices = [
{ cpuType: 0x01000007, cpuSubtype: 3 },
{ cpuType: 0x0100000c, cpuSubtype: 0x80000002 },
{ cpuType: 0x0100000c, cpuSubtype: 0x8000000c },
];
const container = Buffer.alloc(3 * 4096 + 64);
writeUInt32(container, 0, 0xcafebabe);
writeUInt32(container, 4, slices.length);
slices.forEach((slice, index) => {
const offset = 4096 * (index + 1);
const entry = 8 + index * 20;
writeUInt32(container, entry, slice.cpuType);
writeUInt32(container, entry + 4, slice.cpuSubtype);
writeUInt32(container, entry + 8, offset);
writeUInt32(container, entry + 12, 64);
writeUInt32(container, entry + 16, 12);
writeUInt32(container, offset, 0xfeedfacf);
writeUInt32(container, offset + 4, slice.cpuType);
writeUInt32(container, offset + 8, slice.cpuSubtype);
});
const arm64eOffset = 8192;
const path = join(directory, "system.fat");
await writeFile(path, container);
const result = await resolveHopperAnalysisProfile(
{
path,
sha256: createHash("sha256").update(container).digest("hex"),
kind: "executable",
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"],
universalSlice: { offset: arm64eOffset, size: 64 },
},
{
launcherPath: path,
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper Disassembler", version: null },
},
);
if (!result.ok) throw new Error(result.error.message);
const parameters = result.value.profile?.parameters;
expect(parameters).toMatchObject({
macho_container: "fat32",
prepared_image: {
method: "fat32_thin_slice",
source_offset: arm64eOffset,
byte_length: 64,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
},
loader: {
source: "derived",
arguments: ["-l", "Mach-O", "--aarch64"],
},
});
});
+1 -1
View File
@@ -54,7 +54,7 @@ export const resolveHopperAnalysisProfile = async (
let preparedImage: HopperMachOImage | undefined;
if (
target.kind === "executable" &&
machoContainer === "fat64" &&
(machoContainer === "fat64" || machoContainer === "fat32") &&
options.loaderArgsOverride.length === 0
) {
const resolved = await resolveHopperMachOImage(target, options.signal);
+331 -4
View File
@@ -1,11 +1,109 @@
import { chmod, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { chmod, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { resolveHopperMachOImage } from "./HopperMachOImage.js";
import {
prepareHopperMachOImage,
resolveHopperMachOImage,
} from "./HopperMachOImage.js";
const CPU_TYPES = { x86_64: 0x01000007, arm64: 0x0100000c } as const;
const FAT32_ALIGNMENT = 12;
const FAT32_SLICE_SIZE = 64;
const ARM64E = 2;
const ARM64E_X1 = 12;
const ARM64E_WITH_CAPABILITIES = 0x80000002;
const ARM64E_X1_WITH_CAPABILITIES = 0x8000000c;
interface FatFixtureSlice {
readonly cpuType: number;
readonly cpuSubtype: number;
/** Thin-slice byte order, independent of the container's table byte order. */
readonly little?: boolean;
}
interface Fat32Fixture {
readonly container: Buffer;
readonly sliceOffsets: readonly number[];
}
/** Synthesize a FAT32 container whose slices are 4096-aligned thin headers. */
const buildFat32Container = (
slices: readonly FatFixtureSlice[],
options: { readonly littleEndianTable?: boolean } = {},
): Fat32Fixture => {
const littleTable = options.littleEndianTable === true;
const writeTableUInt32 = littleTable
? (buffer: Buffer, offset: number, value: number): number =>
buffer.writeUInt32LE(value, offset)
: (buffer: Buffer, offset: number, value: number): number =>
buffer.writeUInt32BE(value, offset);
const unit = 2 ** FAT32_ALIGNMENT;
const sliceOffsets = slices.map((_slice, index) => unit * (index + 1));
const lastOffset = sliceOffsets[sliceOffsets.length - 1] ?? unit;
const container = Buffer.alloc(lastOffset + FAT32_SLICE_SIZE, 0);
// The magic VALUE is 0xcafebabe in the table's byte order: little-endian
// storage yields the FAT_CIGAM magic a big-endian reader observes.
writeTableUInt32(container, 0, 0xcafebabe);
writeTableUInt32(container, 4, slices.length);
for (const [index, slice] of slices.entries()) {
const entry = 8 + index * 20;
const offset = sliceOffsets[index]!;
writeTableUInt32(container, entry, slice.cpuType);
writeTableUInt32(container, entry + 4, slice.cpuSubtype);
writeTableUInt32(container, entry + 8, offset);
writeTableUInt32(container, entry + 12, FAT32_SLICE_SIZE);
writeTableUInt32(container, entry + 16, FAT32_ALIGNMENT);
const writeThinUInt32 =
slice.little === true
? (buffer: Buffer, fieldOffset: number, value: number): number =>
buffer.writeUInt32LE(value, fieldOffset)
: (buffer: Buffer, fieldOffset: number, value: number): number =>
buffer.writeUInt32BE(value, fieldOffset);
writeThinUInt32(container, offset, 0xfeedfacf);
writeThinUInt32(container, offset + 4, slice.cpuType);
writeThinUInt32(container, offset + 8, slice.cpuSubtype);
}
return { container, sliceOffsets };
};
const writeFixture = async (
directory: string,
name: string,
fixture: Fat32Fixture,
): Promise<string> => {
const path = join(directory, name);
await writeFile(path, fixture.container);
return path;
};
const fat32Target = (
path: string,
fixture: Fat32Fixture,
architecture: "arm64" | "x86_64",
) => ({
path,
sha256: createHash("sha256").update(fixture.container).digest("hex"),
kind: "executable" as const,
format: "mach-o-universal" as const,
architecture,
availableArchitectures: ["x86_64", "arm64"] as const,
universalSlice: { offset: fixture.sliceOffsets[0]!, size: FAT32_SLICE_SIZE },
});
const requireResolved = (
result: Awaited<ReturnType<typeof resolveHopperMachOImage>>,
) => {
if (!result.ok)
throw new Error(
`FAT fixture resolution failed: ${JSON.stringify(projectAnalysisError(result.error))}`,
);
return result.value;
};
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports a FAT64 source that became unreadable as a host read denial",
@@ -36,7 +134,7 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
},
);
it("rejects an ELF target before attempting FAT64 source preparation", async () => {
it("rejects an ELF target before attempting FAT source preparation", async () => {
const directory = await createTestTempDirectory("rea-hopper-mips-boundary-");
// No file is created: format validation must precede any filesystem lookup.
const path = join(directory, "mips.elf");
@@ -54,6 +152,235 @@ it("rejects an ELF target before attempting FAT64 source preparation", async ()
expect(result.error).toMatchObject({
_tag: "BinaryTargetError",
path,
reason: "Hopper FAT64 image preparation requires a Mach-O target",
reason: "Hopper FAT image preparation requires a Mach-O target",
});
});
it("selects the plain-arm64-decodable arm64e slice from an Apple-style FAT32 container", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-arm64e-");
// /bin/* system binaries: x86_64, arm64e (cpusubtype 2), arm64e.x1 (subtype 12).
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.x86_64, cpuSubtype: 3 },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
]);
const path = await writeFixture(directory, "system.fat", fixture);
const result = requireResolved(
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
);
expect(result).toMatchObject({
method: "fat32_thin_slice",
architecture_count: 3,
source_offset: fixture.sliceOffsets[1],
byte_length: FAT32_SLICE_SIZE,
cpu_type: CPU_TYPES.arm64,
cpu_subtype: ARM64E,
});
});
it("ranks capability-bearing arm64 subtypes by base and retains raw identity", async () => {
const directory = await createTestTempDirectory(
"rea-hopper-fat32-capabilities-",
);
const fixture = buildFat32Container([
{
cpuType: CPU_TYPES.arm64,
cpuSubtype: ARM64E_X1_WITH_CAPABILITIES,
},
{
cpuType: CPU_TYPES.arm64,
cpuSubtype: ARM64E_WITH_CAPABILITIES,
},
]);
const path = await writeFixture(directory, "system.fat", fixture);
const selected = requireResolved(
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
);
expect(selected).toMatchObject({
source_offset: fixture.sliceOffsets[1],
cpu_subtype: ARM64E_WITH_CAPABILITIES,
});
});
it("fails closed when capability variants share the preferred arm64e base", async () => {
const directory = await createTestTempDirectory(
"rea-hopper-fat32-capability-ambiguity-",
);
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.arm64, cpuSubtype: 0x80000002 },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: 0x81000002 },
]);
const path = await writeFixture(directory, "ambiguous.fat", fixture);
const result = await resolveHopperMachOImage(
fat32Target(path, fixture, "arm64"),
);
if (result.ok || result.error._tag !== "BinaryTargetError")
throw new Error(
"Expected ambiguous FAT selection to return a BinaryTargetError",
);
expect(result.error.reason).toContain("selection for arm64 is ambiguous");
});
it("prefers cpusubtype 2 over 12 regardless of FAT32 table order", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-order-");
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
]);
const path = await writeFixture(directory, "reordered.fat", fixture);
const result = requireResolved(
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
);
expect(result).toMatchObject({
cpu_subtype: ARM64E,
source_offset: fixture.sliceOffsets[1],
});
});
it("prefers a plain arm64 slice over arm64e in a FAT32 container", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-plain-");
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.arm64, cpuSubtype: 0 },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
]);
const path = await writeFixture(directory, "plain.fat", fixture);
const result = requireResolved(
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
);
expect(result).toMatchObject({
cpu_subtype: 0,
source_offset: fixture.sliceOffsets[0],
});
});
it("keeps a lone arm64e.x1 FAT32 slice selectable to preserve today's open behavior", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-lone-");
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
]);
const path = await writeFixture(directory, "lone.fat", fixture);
const result = requireResolved(
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
);
expect(result).toMatchObject({ cpu_subtype: ARM64E_X1 });
});
it("still fails closed when two arm64e.x1 FAT32 slices compete", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-ambig-");
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
]);
const path = await writeFixture(directory, "ambiguous.fat", fixture);
const result = await resolveHopperMachOImage(
fat32Target(path, fixture, "arm64"),
);
if (result.ok) throw new Error("Ambiguous FAT32 selection succeeded");
expect(
projectAnalysisError(result.error).details?.reason ?? result.error,
).toMatch(/selection.*ambiguous/u);
});
it("selects the single x86_64 slice of a FAT32 container for an x86_64 target", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-intel-");
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
{ cpuType: CPU_TYPES.x86_64, cpuSubtype: 3 },
]);
const path = await writeFixture(directory, "universal.fat", fixture);
const result = requireResolved(
await resolveHopperMachOImage(fat32Target(path, fixture, "x86_64")),
);
expect(result).toMatchObject({
cpu_type: CPU_TYPES.x86_64,
cpu_subtype: 3,
source_offset: fixture.sliceOffsets[1],
});
});
it("parses a little-endian FAT32 table without tying it to slice byte order", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-le-");
const fixture = buildFat32Container(
[
{ cpuType: CPU_TYPES.x86_64, cpuSubtype: 3 },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
],
{ littleEndianTable: true },
);
const path = await writeFixture(directory, "little.fat", fixture);
const result = requireResolved(
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
);
expect(result).toMatchObject({
method: "fat32_thin_slice",
cpu_subtype: ARM64E,
source_offset: fixture.sliceOffsets[1],
});
});
it("rejects a FAT32 slice whose declared range extends beyond the file", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-range-");
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
]);
fixture.container.writeUInt32BE(fixture.container.length + 16, 8 + 12);
const path = await writeFixture(directory, "overrange.fat", fixture);
const result = await resolveHopperMachOImage(
fat32Target(path, fixture, "arm64"),
);
if (result.ok) throw new Error("An overranging FAT32 slice was accepted");
expect(
projectAnalysisError(result.error).details?.reason ?? result.error,
).toMatch(/extends beyond the file/u);
});
it("copies exactly the selected FAT32 slice bytes and verifies the source", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-copy-");
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.x86_64, cpuSubtype: 3 },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
]);
const path = await writeFixture(directory, "system.fat", fixture);
const target = fat32Target(path, fixture, "arm64");
const resolved = requireResolved(await resolveHopperMachOImage(target));
const destination = join(directory, "image.macho");
const prepared = await prepareHopperMachOImage(
path,
target.sha256,
resolved,
destination,
);
if (!prepared.ok) throw new Error(prepared.error.userMessage);
const arm64eOffset = fixture.sliceOffsets[1]!;
const copied = fixture.container.subarray(
arm64eOffset,
arm64eOffset + FAT32_SLICE_SIZE,
);
expect(await readFile(destination)).toEqual(copied);
});
it("rejects FAT32 preparation when the source table changed after resolution", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-race-");
const fixture = buildFat32Container([
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
]);
const path = await writeFixture(directory, "system.fat", fixture);
const target = fat32Target(path, fixture, "arm64");
const resolved = requireResolved(await resolveHopperMachOImage(target));
const mutated = Buffer.from(fixture.container);
mutated.writeUInt32BE(0x01000007, 8);
await writeFile(path, mutated);
const destination = join(directory, "image.macho");
const prepared = await prepareHopperMachOImage(
path,
target.sha256,
resolved,
destination,
);
if (prepared.ok)
throw new Error("A mutated FAT32 source passed verification");
expect(prepared.error.userMessage).toMatch(/no longer agrees/u);
});
+66 -30
View File
@@ -8,7 +8,10 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
readFatSliceDeclarations,
validateFatSlice,
type FatSliceDeclaration,
} from "../domain/apple/machoContainer.js";
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import type { HeaderArchitecture } from "../domain/binaryTargetTypes.js";
import {
HopperCancelledError,
HopperStartError,
@@ -17,7 +20,10 @@ import { err, ok, type Result } from "../domain/result.js";
/** Stable source coordinates committed before preparing an owned thin image. */
export const hopperMachOImageSchema = z.strictObject({
method: z.literal("fat64_thin_slice"),
method: z.union([
z.literal("fat32_thin_slice"),
z.literal("fat64_thin_slice"),
]),
architecture_count: z.number().int().min(1).max(128),
container_table_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
source_offset: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
@@ -28,7 +34,7 @@ export const hopperMachOImageSchema = z.strictObject({
export type HopperMachOImage = z.infer<typeof hopperMachOImageSchema>;
/** Validate the selected FAT64 slice without depending on Apple's lipo tool. */
/** Validate the selected FAT slice without depending on Apple's lipo tool. */
export const resolveHopperMachOImage = async (
target: BinaryTarget & { readonly kind: "executable" },
signal?: AbortSignal,
@@ -45,14 +51,14 @@ export const resolveHopperMachOImage = async (
);
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
if (target.format !== "mach-o" && target.format !== "mach-o-universal")
return invalid("Hopper FAT64 image preparation requires a Mach-O target");
return invalid("Hopper FAT image preparation requires a Mach-O target");
let file: FileHandle;
try {
file = await open(target.path, "r");
} catch (cause: unknown) {
// Keep the OS error so a read-permission denial reports access_denied.
return invalid(
`Cannot read FAT64 source for Hopper image preparation: ${cause instanceof Error ? cause.message : String(cause)}`,
`Cannot read FAT source for Hopper image preparation: ${cause instanceof Error ? cause.message : String(cause)}`,
cause,
);
}
@@ -64,24 +70,29 @@ export const resolveHopperMachOImage = async (
};
const header = await read(0, 8);
const magic = header.length === 8 ? header.readUInt32BE(0) : 0;
if (magic !== 0xcafebabf && magic !== 0xbfbafeca)
const isFatMagic = [
0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca,
].includes(magic);
if (!isFatMagic)
return invalid(
"FAT64 source header changed before Hopper image preparation",
"FAT source header changed before Hopper image preparation",
);
const littleEndian = magic === 0xbfbafeca;
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 size = (await file.stat()).size;
const tableEnd = 8 + count * 32;
const tableEnd = 8 + count * entrySize;
if (count === 0 || count > 128 || tableEnd > size)
return invalid(
"FAT64 architecture table is truncated or exceeds the supported 128 entries",
"FAT architecture table is truncated or exceeds the supported 128 entries",
);
const table = await read(8, count * 32);
const table = await read(8, count * entrySize);
const parsed = readFatSliceDeclarations(table, {
count,
wide: true,
wide,
littleEndian,
});
if (parsed.status === "malformed") return invalid(parsed.reason);
@@ -93,24 +104,26 @@ export const resolveHopperMachOImage = async (
return err(new AnalysisCancelledError("open_binary"));
const rangeError = validateFatSlice(slice, size, tableEnd);
if (rangeError !== null) return invalid(rangeError);
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 (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.size < 12)
return invalid(
`FAT64 architecture ${slice.index} has a truncated Mach-O header`,
`FAT architecture ${slice.index} has a truncated Mach-O header`,
);
const embedded = await read(slice.offset, 12);
if (embedded.length !== 12)
return invalid(`FAT64 architecture ${slice.index} became truncated`);
return invalid(`FAT architecture ${slice.index} became truncated`);
const thinMagic = embedded.readUInt32BE(0);
if (![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(thinMagic))
return invalid(
`FAT64 architecture ${slice.index} does not contain a Mach-O image`,
`FAT architecture ${slice.index} does not contain a Mach-O image`,
);
const little = thinMagic === 0xcefaedfe || thinMagic === 0xcffaedfe;
const identity = {
@@ -133,15 +146,15 @@ export const resolveHopperMachOImage = async (
current !== undefined &&
current.offset < previous.offset + previous.size
)
return invalid("FAT64 slices overlap");
return invalid("FAT slices overlap");
}
const selected = matches.length === 1 ? matches[0] : undefined;
const selected = selectHopperSlice(matches, target.architecture);
if (selected === undefined)
return invalid(
`FAT64 selection for ${target.architecture} is ${matches.length === 0 ? "missing" : "ambiguous"}; extract the exact architecture into a thin image before opening it`,
`FAT selection for ${target.architecture} is ${matches.length === 0 ? "missing" : "ambiguous"}; extract the exact architecture into a thin image before opening it`,
);
return ok({
method: "fat64_thin_slice",
method: wide ? "fat64_thin_slice" : "fat32_thin_slice",
architecture_count: count,
container_table_sha256: createHash("sha256")
.update(header)
@@ -154,13 +167,35 @@ export const resolveHopperMachOImage = async (
});
} catch (cause: unknown) {
return invalid(
`Cannot validate FAT64 image: ${cause instanceof Error ? cause.message : String(cause)}`,
`Cannot validate FAT image: ${cause instanceof Error ? cause.message : String(cause)}`,
);
} finally {
await file.close();
}
};
/**
* Plain arm64 (cpusubtype 0) and arm64e (cpusubtype 2) function-entry encodings
* decode under Hopper's --aarch64; Apple's arm64e.x1 variant (cpusubtype 12)
* does not, so it is selected only when it is the arm64 family's only candidate.
* Every other ambiguity stays fail-closed.
*/
const selectHopperSlice = (
matches: readonly FatSliceDeclaration[],
architecture: HeaderArchitecture,
): FatSliceDeclaration | undefined => {
if (matches.length === 1) return matches[0];
if (architecture !== "arm64") return undefined;
for (const cpuSubtype of [0, 2]) {
const preferred = matches.filter(
(slice) => machoCpuSubtypeBase(slice.cpuSubtype) === cpuSubtype,
);
if (preferred.length === 1) return preferred[0];
if (preferred.length > 1) return undefined;
}
return undefined;
};
/** Copy a selected slice and verify the entire original source in the same pass. */
export const prepareHopperMachOImage = async (
sourcePath: string,
@@ -170,6 +205,7 @@ export const prepareHopperMachOImage = async (
signal?: AbortSignal,
): Promise<Result<string, HopperStartError | HopperCancelledError>> => {
if (signal?.aborted) return err(new HopperCancelledError());
const tableEntrySize = image.method === "fat64_thin_slice" ? 32 : 20;
try {
const source = await open(sourcePath, "r");
try {
@@ -177,7 +213,7 @@ export const prepareHopperMachOImage = async (
try {
const hash = createHash("sha256");
const tableHash = createHash("sha256");
const tableEnd = 8 + image.architecture_count * 32;
const tableEnd = 8 + image.architecture_count * tableEntrySize;
const thinHeader: Buffer[] = [];
let position = 0;
let copied = 0;
@@ -223,13 +259,13 @@ export const prepareHopperMachOImage = async (
)
return err(
new HopperStartError({
userMessage: `FAT64 source ${sourcePath} no longer agrees with its committed architecture table or selected Mach-O header. Open the stable source again.`,
userMessage: `FAT source ${sourcePath} no longer agrees with its committed architecture table or selected Mach-O header. Open the stable source again.`,
}),
);
if (observed !== sourceSha256 || copied !== image.byte_length)
return err(
new HopperStartError({
userMessage: `FAT64 source ${sourcePath} changed before launch: expected SHA-256 ${sourceSha256}, observed ${observed}; copied ${copied} of ${image.byte_length} selected bytes. Open the stable source again.`,
userMessage: `FAT source ${sourcePath} changed before launch: expected SHA-256 ${sourceSha256}, observed ${observed}; copied ${copied} of ${image.byte_length} selected bytes. Open the stable source again.`,
}),
);
return ok(destination);
@@ -245,7 +281,7 @@ export const prepareHopperMachOImage = async (
: err(
new HopperStartError({
cause,
userMessage: `Cannot prepare the selected FAT64 image from ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`,
userMessage: `Cannot prepare the selected FAT image from ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`,
}),
);
}
+1 -1
View File
@@ -288,7 +288,7 @@ export class HopperProvider implements AnalysisProviderCandidate {
...(preparedImage === undefined
? []
: [
"REA loaded a verified thin Mach-O slice from the original FAT64 source. Identity and file offsets refer to the original file. The owned temporary image and its Hopper document close together, including on MCP shutdown.",
"REA loaded a verified thin Mach-O slice from the original FAT source. Identity and file offsets refer to the original file. The owned temporary image and its Hopper document close together, including on MCP shutdown.",
]),
...(CAPABILITIES.find(
(descriptor) => descriptor.operation === operation,