diff --git a/docs/installation.md b/docs/installation.md index 9672455df7..b84d6033a7 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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. diff --git a/src/hopper/HopperAnalysisProfile.test.ts b/src/hopper/HopperAnalysisProfile.test.ts index 7c3c715f23..e5b5745049 100644 --- a/src/hopper/HopperAnalysisProfile.test.ts +++ b/src/hopper/HopperAnalysisProfile.test.ts @@ -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"], + }, + }); +}); diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 74618a60cd..85159ee315 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -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); diff --git a/src/hopper/HopperMachOImage.test.ts b/src/hopper/HopperMachOImage.test.ts index d503a055b5..e4f81ec6a2 100644 --- a/src/hopper/HopperMachOImage.test.ts +++ b/src/hopper/HopperMachOImage.test.ts @@ -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 => { + 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>, +) => { + 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); +}); diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index ed81ea7495..4b0a48cf51 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -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; -/** 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> => { 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)}`, }), ); } diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index e446e40448..684c8bb08d 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -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,