mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
+12
-6
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user