fix(hopper): load verified FAT64 slices with native Mach-O semantics

This commit is contained in:
morluto
2026-10-09 05:12:36 +08:00
parent 458771cb58
commit 0a49d5bfb9
10 changed files with 630 additions and 54 deletions
+1 -1
View File
@@ -282,7 +282,7 @@ def _original_file_offset(document, offset):
"""Translate Hopper's image-relative mapping using the observed loaded header."""
if offset > 9007199254740991:
raise CapabilityUnavailableError("Provider file offset exceeds the exact JSON integer range")
path = document.getExecutableFilePath()
path = globals().get("REA_ORIGINAL_EXECUTABLE_PATH") or document.getExecutableFilePath()
if not path:
raise CapabilityUnavailableError("Hopper did not identify the original executable for file-offset verification")
try:
+15 -8
View File
@@ -323,14 +323,21 @@ 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. FAT64 uses the same derived FAT/Mach-O loader chain;
loading depends on the installed Hopper build. Hopper 6.1.0-demo timed out on
a FAT64 fixture while FAT32 controls and its extracted thin image loaded.
This does not establish that other builds reject FAT64. If loading fails, use
`lipo -archs <source-file>` to read the exact architecture names, then
`lipo -thin <architecture> <source-file> -output <thin-file>` and open the
resulting image. FAT64 is distinct from the CPU architecture: FAT32 containers
can contain 64-bit executable images.
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.
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.
The prepared image and its owned document close together, including on MCP exit;
if document closure is unconfirmed, REA retains the backing image and reports
`cleanup_incomplete` with its path. Ordinary documents retain their existing
MCP-exit behavior.
Startup deadlines report missing bridge readiness and preserve the launcher
outcome; a successful helper exit does not prove that a loader dialog completed.
Cursor navigation returns the observed object start when Hopper snaps an interior
address; adjacent-object navigation rejects unmapped inputs and document ends.
Native API text rejects NUL characters and unpaired Unicode surrogates before
+5 -6
View File
@@ -217,12 +217,11 @@ The Linux demo lane remains a separate `verify:hopper:linux` command.
existing Xcode clang/lipo toolchain. It compiles arm64/x86-64 thin executables and
one- and two-slice FAT32 containers, verifies exact/interior address mappings
against bytes in the original files, checks CLI/MCP parity, and checks owned
runtime cleanup. `verify:hopper:fat64` additionally requires the installed Hopper
to load a compiled FAT64 container and verifies its original-file mappings. It
fails on a native load or analysis failure rather than treating a timeout as
proof of unsupported format. This additional lane remains unverified on Hopper
6.1.0-demo: FAT64 timed out while FAT32 controls and the extracted thin image
loaded successfully.
runtime cleanup. `verify:hopper:fat64` additionally checks FAT64 preparation through
Hopper's native Mach-O loader, source-container mappings, profile identity,
malformed and ambiguous slice rejection, and temporary-image lifetime. Both
lanes have been verified on Hopper 6.1.0-demo; this establishes REA's prepared
FAT64 workflow, not native FAT64-loader support.
Cross-architecture fixture compilation is not required by `verify:hopper`.
Golden tests use immutable captured text inputs with producer/source provenance
+203 -31
View File
@@ -1,6 +1,14 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { access, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import {
access,
mkdtemp,
readFile,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
@@ -10,7 +18,7 @@ import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import Ajv from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { requireMcpResult } from "./lib/mcp-verifier-results.mjs";
import { mcpTextValue, requireMcpResult } from "./lib/mcp-verifier-results.mjs";
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js";
import { parseConfig } from "../dist/config.js";
@@ -56,6 +64,9 @@ const fat = join(directory, "c-fat");
const fat64 = join(directory, "c-fat64");
const singleFat = join(directory, "c-single-fat");
const owned = new Set();
const extraFat64 = [];
const invalidFat64 = [];
let preparedImagePath;
const before = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
@@ -114,6 +125,86 @@ try {
"-output",
singleFat,
]);
if (verifyFat64) {
const original = await readFile(fat64);
const swapped = Buffer.from(original);
swapped.writeUInt32BE(0xbfbafeca, 0);
swapped.writeUInt32LE(original.readUInt32BE(4), 4);
for (let index = 0; index < original.readUInt32BE(4); index++) {
const offset = 8 + index * 32;
for (const field of [0, 4, 24, 28])
swapped.writeUInt32LE(
original.readUInt32BE(offset + field),
offset + field,
);
for (const field of [8, 16])
swapped.writeBigUInt64LE(
original.readBigUInt64BE(offset + field),
offset + field,
);
}
const little = join(directory, "c-fat64-little-endian");
await writeFile(little, swapped);
extraFat64.push(little);
const single = join(directory, "c-single-fat64");
await run(toolPaths.lipo, [
"-create",
process.arch === "arm64" ? arm : intel,
"-fat64",
"-output",
single,
]);
extraFat64.push(single);
for (const [name, reason, mutate] of [
[
"reserved",
/nonzero reserved field/u,
(bytes) => bytes.writeUInt32BE(1, 36),
],
[
"range",
/extends beyond the file/u,
(bytes) => bytes.writeBigUInt64BE(BigInt(bytes.length), 24),
],
[
"identity",
/disagrees with its Mach-O header/u,
(bytes) => bytes.writeUInt32BE(bytes.readUInt32BE(12) + 1, 12),
],
[
"overlap",
/slices overlap/u,
(bytes) => {
const cpu = process.arch === "arm64" ? 0x100000c : 0x1000007;
const base = bytes.readUInt32BE(8) === cpu ? 8 : 40;
bytes.copy(bytes, base === 8 ? 40 : 8, base, base + 32);
},
],
]) {
const bytes = Buffer.from(original);
mutate(bytes);
const path = join(directory, `c-fat64-invalid-${name}`);
await writeFile(path, bytes);
invalidFat64.push({ path, reason });
}
const hostBytes = await readFile(process.arch === "arm64" ? arm : intel);
const secondOffset = Math.ceil((16384 + hostBytes.length) / 16384) * 16384;
const ambiguous = Buffer.alloc(secondOffset + hostBytes.length);
ambiguous.writeUInt32BE(0xcafebabf, 0);
ambiguous.writeUInt32BE(2, 4);
for (const [index, offset] of [16384, secondOffset].entries()) {
const base = 8 + index * 32;
ambiguous.writeUInt32BE(hostBytes.readUInt32LE(4), base);
ambiguous.writeUInt32BE(hostBytes.readUInt32LE(8), base + 4);
ambiguous.writeBigUInt64BE(BigInt(offset), base + 8);
ambiguous.writeBigUInt64BE(BigInt(hostBytes.length), base + 16);
ambiguous.writeUInt32BE(14, base + 24);
hostBytes.copy(ambiguous, offset);
}
const path = join(directory, "c-fat64-ambiguous");
await writeFile(path, ambiguous);
invalidFat64.push({ path, reason: /selection.*ambiguous/u });
}
await client.connect(transport);
assert.ok(transport.pid !== null);
owned.add(transport.pid);
@@ -135,18 +226,18 @@ try {
);
return result;
};
let fatExpected;
let fatAddress;
const cliMappings = new Map();
for (const path of [
arm,
intel,
fat,
singleFat,
...(verifyFat64 ? [fat64] : []),
...(verifyFat64 ? [fat64, ...extraFat64] : []),
]) {
console.error(`Verifying Hopper source-file mappings for ${path}`);
await call("open_binary", { path });
const file = await readFile(path);
const sourceHash = createHash("sha256").update(file).digest("hex");
await call("open_binary", { path });
const procedures = await call("list_procedures");
const entry = procedures.find((item) => item.value.endsWith("rea_entry"));
assert.ok(entry, "compiler fixture omitted rea_entry");
@@ -168,7 +259,12 @@ try {
.toString("hex"),
bytes.bytes_hex,
);
if (path === fat || path === singleFat || path === fat64) {
if (
path === fat ||
path === singleFat ||
path === fat64 ||
extraFat64.includes(path)
) {
assert.ok(
mapping.image_base_file_offset > 0,
"FAT mapping omitted its physical slice offset",
@@ -184,35 +280,109 @@ try {
"fixture did not distinguish image-relative and original-file coordinates",
);
} else assert.equal(mapping.image_base_file_offset, 0);
if (path === fat) {
fatExpected = mapping;
fatAddress = address;
if (path === fat || path === fat64) {
cliMappings.set(path, { mapping, address });
}
observations.push({ path, address, ...mapping, matched_bytes: 16 });
}
if (path === fat64 || extraFat64.includes(path)) {
const session = await call("binary_session");
assert.equal(session.path, path);
assert.equal(session.sha256, sourceHash);
const profile = session.analysis_provider_binding.analysis_profile;
assert.equal(
profile.parameters.prepared_image.method,
"fat64_thin_slice",
);
assert.equal(
profile.parameters.prepared_image.source_offset,
observations.at(-1).image_base_file_offset,
);
const document = await call("current_document");
await call("set_comment", {
address: entry.address,
comment: "source-bound FAT64 preparation",
});
await call("open_binary", { path });
assert.deepEqual(
(await call("binary_session")).analysis_provider_binding
.analysis_profile,
profile,
);
assert.equal(await call("current_document"), document);
assert.equal(
await call("comment", { address: entry.address }),
"source-bound FAT64 preparation",
);
}
assert.equal(
createHash("sha256")
.update(await readFile(path))
.digest("hex"),
sourceHash,
"native analysis modified source executable bytes",
);
await call("close_binary");
}
const pending = run(
process.execPath,
[
"scripts/rea.mjs",
"address-to-file-offset",
fat,
fatAddress,
"--provider",
"hopper",
"--format",
"json",
],
{
timeout: 180_000,
env: { ...process.env, HOPPER_LOADER_ARGS_JSON: "[]" },
},
);
assert.ok(pending.child.pid !== undefined);
owned.add(pending.child.pid);
const cli = JSON.parse((await pending).stdout);
assert.deepEqual(cli.normalized_result, fatExpected);
for (const { path, reason } of invalidFat64) {
const reply = await client.callTool({
name: "open_binary",
arguments: { path },
});
assert.equal(reply.isError, true, "malformed FAT64 unexpectedly launched");
assert.match(mcpTextValue(reply), reason);
}
if (verifyFat64) {
await call("open_binary", { path: fat64 });
await call("list_procedures");
const resources = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
owned,
)),
].filter((path) => !before.has(path));
for (const root of resources) {
const image = join(root, "image.macho");
if (
await access(image).then(
() => true,
() => false,
)
)
preparedImagePath = image;
}
assert.ok(
preparedImagePath,
"prepared native image was not owned by this session",
);
await client.close();
await transport.close();
await assert.rejects(access(preparedImagePath), { code: "ENOENT" });
}
for (const [path, expected] of cliMappings) {
const pending = run(
process.execPath,
[
"scripts/rea.mjs",
"address-to-file-offset",
path,
expected.address,
"--provider",
"hopper",
"--format",
"json",
],
{
timeout: 180_000,
env: { ...process.env, HOPPER_LOADER_ARGS_JSON: "[]" },
},
);
assert.ok(pending.child.pid !== undefined);
owned.add(pending.child.pid);
const cli = JSON.parse((await pending).stdout);
assert.deepEqual(cli.normalized_result, expected.mapping);
}
} finally {
await client.close();
await transport.close();
@@ -236,6 +406,8 @@ console.log(
observations,
cliMcpParity: true,
fat64: verifyFat64 ? "verified" : "not_run",
malformedFat64Rejected: invalidFat64.length,
preparedImageRemovedOnMcpShutdown: preparedImagePath !== undefined,
cleanShutdown: true,
},
null,
+40 -3
View File
@@ -23,6 +23,11 @@ import {
} from "./LinuxPrivateDisplayProbe.js";
import writeFileAtomic from "write-file-atomic";
import {
prepareHopperMachOImage,
type HopperMachOImage,
} from "./HopperMachOImage.js";
import { acquireHopperTargetLease } from "./HopperTargetLease.js";
import type { HopperTargetLease } from "./HopperTargetLease.js";
@@ -41,11 +46,13 @@ export interface BridgeSession {
export type BridgeLaunch =
| (ProviderProcessLaunch & {
readonly shutdownMode: "bridge-request";
readonly preparedImagePath?: string;
/** The owned helper may hand off to an external GUI application. */
readonly providerLifetime: "launcher-process" | "external-application";
})
| (Extract<ProviderProcessLaunch, { readonly ownsProcessLifetime: true }> & {
readonly shutdownMode: "process-cleanup";
readonly preparedImagePath?: string;
readonly providerLifetime: "launcher-process";
readonly cleanup: NonNullable<ProviderProcessLaunch["cleanup"]>;
});
@@ -69,6 +76,10 @@ interface SharedHopperApplicationLauncherOptions {
readonly targetKind: "executable" | "database";
readonly loaderArgs: readonly string[];
readonly bridgeScriptPath: string;
readonly preparedImage?: {
readonly image: HopperMachOImage;
readonly sourceSha256: string;
};
}
/** Explicit launcher contract; the Linux adapter verifies its pinned Hopper build before execution. */
@@ -125,16 +136,30 @@ export class HopperApplicationLauncher implements BridgeLauncher {
const lease = leaseResult.value;
let leaseTransferred = false;
try {
let loadedPath = this.options.targetPath;
if (this.options.preparedImage !== undefined) {
const prepared = await prepareHopperMachOImage(
this.options.targetPath,
this.options.preparedImage.sourceSha256,
this.options.preparedImage.image,
join(session.directory, "image.macho"),
options.signal,
);
if (!prepared.ok) return prepared;
loadedPath = prepared.value;
}
const ownsProcessLifetime = usesLinuxDemo(this.options);
const bootstrap = await this.#writeBootstrap(
session,
ownsProcessLifetime,
loadedPath,
);
if (!bootstrap.ok) return bootstrap;
const launched = await this.#launchPreparedTarget({
session,
signal: options.signal,
bootstrapPath: bootstrap.value,
loadedPath,
ownsProcessLifetime,
lease,
});
@@ -149,6 +174,7 @@ export class HopperApplicationLauncher implements BridgeLauncher {
readonly session: BridgeSession;
readonly signal: AbortSignal | undefined;
readonly bootstrapPath: string;
readonly loadedPath: string;
readonly ownsProcessLifetime: boolean;
readonly lease: HopperTargetLease | undefined;
}): Promise<
@@ -167,7 +193,7 @@ export class HopperApplicationLauncher implements BridgeLauncher {
"-Y",
bootstrapPath,
action,
this.options.targetPath,
input.loadedPath,
];
try {
if (signal?.aborted === true) return err(new HopperCancelledError());
@@ -217,6 +243,9 @@ export class HopperApplicationLauncher implements BridgeLauncher {
return err(new HopperStartError({ cause }));
}
const ownedLauncher = {
...(this.options.preparedImage === undefined
? {}
: { preparedImagePath: input.loadedPath }),
process: started.process,
ownsProcessLifetime: true as const,
ownership: started.ownership,
@@ -270,12 +299,18 @@ export class HopperApplicationLauncher implements BridgeLauncher {
async #writeBootstrap(
session: BridgeSession,
ownsProcessLifetime: boolean,
loadedPath: string,
): Promise<Result<string, HopperStartError>> {
const bootstrapPath = `${session.directory}/bootstrap.py`;
try {
await writeFile(
bootstrapPath,
bridgeBootstrapSource(session, this.options, ownsProcessLifetime),
bridgeBootstrapSource(
session,
this.options,
ownsProcessLifetime,
loadedPath,
),
{ encoding: "utf8", mode: 0o600 },
);
await chmod(bootstrapPath, 0o600);
@@ -321,12 +356,14 @@ const bridgeBootstrapSource = (
session: BridgeSession,
options: SharedHopperApplicationLauncherOptions,
ownsProcessLifetime: boolean,
loadedPath: string,
): string =>
[
`REA_SOCKET = ${JSON.stringify(session.socketPath)}`,
`REA_TOKEN = ${JSON.stringify(session.token)}`,
`REA_RUN_ID = ${JSON.stringify(session.runId)}`,
`REA_TARGET_PATH = ${JSON.stringify(options.targetPath)}`,
`REA_TARGET_PATH = ${JSON.stringify(loadedPath)}`,
`REA_ORIGINAL_EXECUTABLE_PATH = ${options.targetKind === "executable" ? JSON.stringify(options.targetPath) : "None"}`,
`REA_OWNS_PROCESS_LIFETIME = ${ownsProcessLifetime ? "True" : "False"}`,
`exec(compile(open(${JSON.stringify(options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(options.bridgeScriptPath)}, 'exec'))`,
"",
+22 -1
View File
@@ -19,6 +19,11 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { type AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import {
resolveHopperMachOImage,
type HopperMachOImage,
} from "./HopperMachOImage.js";
interface HopperProfileOptions {
readonly launcherPath: string;
readonly loaderArgsOverride: readonly string[];
@@ -43,7 +48,20 @@ export const resolveHopperAnalysisProfile = async (
? "fat32"
: "thin";
}
const derived = hopperLoaderArgsForTarget(target, machoContainer);
let preparedImage: HopperMachOImage | undefined;
if (
target.kind === "executable" &&
machoContainer === "fat64" &&
options.loaderArgsOverride.length === 0
) {
const resolved = await resolveHopperMachOImage(target, options.signal);
if (!resolved.ok) return resolved;
preparedImage = resolved.value;
}
const derived = hopperLoaderArgsForTarget(
target,
preparedImage === undefined ? machoContainer : "thin",
);
if (!derived.ok) return derived;
const loaderArgs =
options.loaderArgsOverride.length === 0
@@ -70,6 +88,9 @@ export const resolveHopperAnalysisProfile = async (
...(machoContainer === undefined
? {}
: { macho_container: machoContainer }),
...(preparedImage === undefined
? {}
: { prepared_image: { ...preparedImage } }),
loader: {
source:
options.loaderArgsOverride.length === 0
+47
View File
@@ -1,3 +1,8 @@
import { access, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import { EventEmitter } from "node:events";
import { Socket } from "node:net";
@@ -120,3 +125,45 @@ describe("Hopper shutdown rejection diagnostics", () => {
);
});
});
it("keeps a prepared backing image when native document closure cannot be confirmed", async () => {
const parent = await createTestTempDirectory("rea-hopper-backing-lifetime-");
const runtimeRoot = await PrivateRuntimeRoot.create({ parent });
const preparedImagePath = join(runtimeRoot.path, "image.macho");
await writeFile(preparedImagePath, "owned backing bytes");
const { logger } = loggerHarness();
try {
const launch = {
...processCleanupLaunch(),
preparedImagePath,
shutdownMode: "bridge-request" as const,
providerLifetime: "external-application" as const,
};
const result = await cleanupHopperSession({
socket: new Socket(),
launch,
runtimeRoot,
processSupervisor: undefined,
activeRequest: null,
retainDocument: true,
progress: undefined,
logger,
onDiagnostic: undefined,
request: () => Promise.reject(failure("document closure unobserved")),
releaseTransport: () => undefined,
});
expect(result).toMatchObject({
ok: false,
error: {
cleanupResources: expect.arrayContaining([
"hopper-document",
runtimeRoot.path,
]),
},
});
await expect(access(preparedImagePath)).resolves.toBeUndefined();
} finally {
await runtimeRoot.close();
await rm(parent, { recursive: true, force: true });
}
});
+17 -3
View File
@@ -65,7 +65,7 @@ export const cleanupHopperSession = async (
await report(
input.progress,
0,
input.retainDocument
input.retainDocument && input.launch?.preparedImagePath === undefined
? "detaching REA from the Hopper document"
: "requesting Hopper document shutdown",
);
@@ -75,7 +75,20 @@ export const cleanupHopperSession = async (
await stopProcess(input, state);
recordUnconfirmedDocument(input, state);
await report(input.progress, 0.75, "removing Hopper private runtime files");
await closeRuntimeRoot(input.runtimeRoot, state);
if (
input.launch?.preparedImagePath !== undefined &&
!state.shutdownConfirmed
) {
const resource = input.runtimeRoot?.path ?? input.launch.preparedImagePath;
state.resources.add(resource);
state.issues.push({
resource,
reason:
"Prepared image retained because native document closure was not confirmed",
});
} else {
await closeRuntimeRoot(input.runtimeRoot, state);
}
return cleanupOutcome(input, state);
};
@@ -99,7 +112,8 @@ const requestShutdown = async (
return;
}
const method =
input.retainDocument || input.launch?.shutdownMode === "process-cleanup"
(input.retainDocument && input.launch?.preparedImagePath === undefined) ||
input.launch?.shutdownMode === "process-cleanup"
? "shutdown"
: "shutdown_document";
const shutdown = await input
+242
View File
@@ -0,0 +1,242 @@
import { createHash } from "node:crypto";
import { open, type FileHandle } from "node:fs/promises";
import { z } from "zod";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
readFatSliceDeclarations,
validateFatSlice,
} from "../domain/apple/machoContainer.js";
import {
HopperCancelledError,
HopperStartError,
} from "../domain/hopperErrors.js";
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"),
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),
byte_length: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
cpu_type: z.number().int().nonnegative(),
cpu_subtype: z.number().int().nonnegative(),
});
export type HopperMachOImage = z.infer<typeof hopperMachOImageSchema>;
/** Validate the selected FAT64 slice without depending on Apple's lipo tool. */
export const resolveHopperMachOImage = async (
target: BinaryTarget & { readonly kind: "executable" },
signal?: AbortSignal,
): Promise<
Result<HopperMachOImage, BinaryTargetError | AnalysisCancelledError>
> => {
const invalid = (reason: string) =>
err(new BinaryTargetError(target.path, reason));
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
let file: FileHandle;
try {
file = await open(target.path, "r");
} catch (cause: unknown) {
return invalid(
`Cannot read FAT64 source for Hopper image preparation: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
try {
const read = async (offset: number, length: number): Promise<Buffer> => {
const bytes = Buffer.alloc(length);
const { bytesRead } = await file.read(bytes, 0, length, offset);
return bytes.subarray(0, bytesRead);
};
const header = await read(0, 8);
const magic = header.length === 8 ? header.readUInt32BE(0) : 0;
if (magic !== 0xcafebabf && magic !== 0xbfbafeca)
return invalid(
"FAT64 source header changed before Hopper image preparation",
);
const littleEndian = magic === 0xbfbafeca;
const count = littleEndian
? header.readUInt32LE(4)
: header.readUInt32BE(4);
const size = (await file.stat()).size;
const tableEnd = 8 + count * 32;
if (count === 0 || count > 128 || tableEnd > size)
return invalid(
"FAT64 architecture table is truncated or exceeds the supported 128 entries",
);
const table = await read(8, count * 32);
const parsed = readFatSliceDeclarations(table, {
count,
wide: true,
littleEndian,
});
if (parsed.status === "malformed") return invalid(parsed.reason);
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(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 (slice.size < 12)
return invalid(
`FAT64 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`);
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`,
);
const little = thinMagic === 0xcefaedfe || thinMagic === 0xcffaedfe;
const identity = {
cpuType: little ? embedded.readUInt32LE(4) : embedded.readUInt32BE(4),
cpuSubtype: little
? embedded.readUInt32LE(8)
: embedded.readUInt32BE(8),
};
const identityError = validateFatSlice(slice, size, tableEnd, identity);
if (identityError !== null) return invalid(identityError);
ranges.push(slice);
if (slice.cpuType === cpuTypes[target.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("FAT64 slices overlap");
}
const selected = matches.length === 1 ? matches[0] : undefined;
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`,
);
return ok({
method: "fat64_thin_slice",
architecture_count: count,
container_table_sha256: createHash("sha256")
.update(header)
.update(table)
.digest("hex"),
source_offset: selected.offset,
byte_length: selected.size,
cpu_type: selected.cpuType,
cpu_subtype: selected.cpuSubtype,
});
} catch (cause: unknown) {
return invalid(
`Cannot validate FAT64 image: ${cause instanceof Error ? cause.message : String(cause)}`,
);
} finally {
await file.close();
}
};
/** Copy a selected slice and verify the entire original source in the same pass. */
export const prepareHopperMachOImage = async (
sourcePath: string,
sourceSha256: string,
image: HopperMachOImage,
destination: string,
signal?: AbortSignal,
): Promise<Result<string, HopperStartError | HopperCancelledError>> => {
if (signal?.aborted) return err(new HopperCancelledError());
try {
const source = await open(sourcePath, "r");
try {
const output = await open(destination, "wx", 0o600);
try {
const hash = createHash("sha256");
const tableHash = createHash("sha256");
const tableEnd = 8 + image.architecture_count * 32;
const thinHeader: Buffer[] = [];
let position = 0;
let copied = 0;
for await (const chunk of source.createReadStream({
autoClose: false,
})) {
if (signal?.aborted) return err(new HopperCancelledError());
const bytes = Buffer.from(chunk);
hash.update(bytes);
if (position < tableEnd)
tableHash.update(
bytes.subarray(0, Math.min(bytes.length, tableEnd - position)),
);
const start = Math.max(0, image.source_offset - position);
const end = Math.min(
bytes.length,
image.source_offset + image.byte_length - position,
);
if (end > start) {
if (copied < 12)
thinHeader.push(
bytes.subarray(start, Math.min(end, start + 12 - copied)),
);
await output.writeFile(bytes.subarray(start, end));
copied += end - start;
}
position += bytes.length;
}
const observed = hash.digest("hex");
const header = Buffer.concat(thinHeader);
const magic = header.length === 12 ? header.readUInt32BE(0) : 0;
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
const headerMatches =
header.length === 12 &&
[0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic) &&
(little ? header.readUInt32LE(4) : header.readUInt32BE(4)) ===
image.cpu_type &&
(little ? header.readUInt32LE(8) : header.readUInt32BE(8)) ===
image.cpu_subtype;
if (
tableHash.digest("hex") !== image.container_table_sha256 ||
!headerMatches
)
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.`,
}),
);
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.`,
}),
);
return ok(destination);
} finally {
await output.close();
}
} finally {
await source.close();
}
} catch (cause: unknown) {
return signal?.aborted
? err(new HopperCancelledError())
: err(
new HopperStartError({
cause,
userMessage: `Cannot prepare the selected FAT64 image from ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`,
}),
);
}
};
+38 -1
View File
@@ -24,6 +24,7 @@ import {
resolveHopperAnalysisProfile,
} from "./HopperAnalysisProfile.js";
import { HopperClient } from "./HopperClient.js";
import { hopperMachOImageSchema } from "./HopperMachOImage.js";
import {
CAPABILITIES,
@@ -158,7 +159,30 @@ export class HopperProvider implements AnalysisProviderCandidate {
),
close: () => Promise.resolve(),
};
const container = profile?.parameters.macho_container;
const preparation = profile?.parameters.prepared_image;
const parsedImage =
preparation === undefined
? undefined
: hopperMachOImageSchema.safeParse(preparation);
if (parsedImage?.success === false)
return {
execute: (operation) =>
Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
"Hopper prepared-image profile is malformed; resolve the analysis profile again.",
),
),
),
close: () => Promise.resolve(),
};
const preparedImage = parsedImage?.data;
const container =
preparedImage === undefined
? profile?.parameters.macho_container
: "thin";
const derivedLoaderArgs = hopperLoaderArgsForTarget(
target,
container === "thin" || container === "fat32" || container === "fat64"
@@ -176,6 +200,14 @@ export class HopperProvider implements AnalysisProviderCandidate {
launcherPath: this.config.hopperLauncherPath,
targetPath: target.path,
targetKind: target.kind,
...(preparedImage === undefined
? {}
: {
preparedImage: {
image: preparedImage,
sourceSha256: target.sha256,
},
}),
loaderArgs:
this.config.hopperLoaderArgs.length > 0
? this.config.hopperLoaderArgs
@@ -206,6 +238,11 @@ export class HopperProvider implements AnalysisProviderCandidate {
value: createAnalysisExecution(result.value, executionProvider, {
...(profile === undefined ? {} : { analysisProfile: profile }),
limitations: [
...(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.",
]),
...(CAPABILITIES.find(
(descriptor) => descriptor.operation === operation,
)?.limitations ?? []),