fix(hopper): resolve FAT loaders and original file coordinates

This commit is contained in:
morluto 2026-10-09 03:39:44 +08:00
parent b2d5bc1109
commit 35f1ddd6bc
13 changed files with 489 additions and 112 deletions

View File

@ -10,6 +10,7 @@ import hmac
import os
import re
import socket
import struct
from typing import Any, Optional, Protocol, Sequence
BAD_ADDRESSES = (-1, 0xFFFFFFFFFFFFFFFF, None)
@ -277,6 +278,76 @@ def _procedure(document, value=None):
return result
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()
if not path:
raise CapabilityUnavailableError("Hopper did not identify the original executable for file-offset verification")
try:
with open(path, "rb") as source:
size = os.fstat(source.fileno()).st_size
header = source.read(8)
formats = {
b"\xca\xfe\xba\xbe": (">", False),
b"\xbe\xba\xfe\xca": ("<", False),
b"\xca\xfe\xba\xbf": (">", True),
b"\xbf\xba\xfe\xca": ("<", True),
}
fat = formats.get(header[:4])
if fat is None:
if offset >= size:
raise InvalidRequestError("Mapped offset is outside the original executable file at %s" % path)
return offset, path, 0
if len(header) != 8:
raise CapabilityUnavailableError("Original FAT executable has a truncated header at %s" % path)
byte_order, wide = fat
count = struct.unpack(byte_order + "I", header[4:])[0]
stride = 32 if wide else 20
table_end = 8 + count * stride
if count == 0 or table_end > size:
raise CapabilityUnavailableError("Original FAT executable has an invalid architecture table at %s" % path)
header_address = document.getAddressFromFileOffset(0)
reader = getattr(document, "readBytes", None)
if header_address in BAD_ADDRESSES or document.getSegmentAtAddress(header_address) is None or not callable(reader):
raise CapabilityUnavailableError("Hopper did not expose a readable loaded Mach-O header to identify its FAT slice")
loaded = reader(header_address, 12)
if not isinstance(loaded, (bytes, bytearray)) or len(loaded) != 12:
raise CapabilityUnavailableError("Hopper did not expose the loaded Mach-O header to identify its FAT slice")
loaded_order = {b"\xce\xfa\xed\xfe": "<", b"\xcf\xfa\xed\xfe": "<", b"\xfe\xed\xfa\xce": ">", b"\xfe\xed\xfa\xcf": ">"}.get(bytes(loaded[:4]))
if loaded_order is None:
raise CapabilityUnavailableError("Hopper's loaded header is not a supported Mach-O image")
identity = struct.unpack(loaded_order + "II", loaded[4:12])
selected = None
for index in range(count):
source.seek(8 + index * stride)
entry = source.read(stride)
if len(entry) != stride:
raise CapabilityUnavailableError("Original FAT executable architecture table changed during verification")
cpu, subtype, start, length, alignment = struct.unpack(byte_order + ("IIQQI" if wide else "IIIII"), entry[:28] if wide else entry)
if length < 12 or start < table_end or start > size or length > size - start or alignment > 52 or start % (1 << alignment):
raise CapabilityUnavailableError("Original FAT executable has an invalid slice range at index %s" % index)
if (cpu, subtype) != identity:
continue
source.seek(start)
if source.read(12) != bytes(loaded):
raise CapabilityUnavailableError("FAT slice declaration disagrees with Hopper's loaded header")
if selected is not None:
raise CapabilityUnavailableError("Multiple FAT slices match Hopper's loaded header; original-file mapping is ambiguous")
selected = (start, length)
if selected is None:
raise CapabilityUnavailableError("No original FAT slice matches Hopper's loaded header")
start, length = selected
if offset >= length:
raise InvalidRequestError("Mapped offset is outside Hopper's loaded FAT slice")
if start + offset > 9007199254740991:
raise CapabilityUnavailableError("Original file offset exceeds the exact JSON integer range")
return start + offset, path, start
except OSError as error:
raise CapabilityUnavailableError("Cannot read the original executable for file-offset verification at %s: %s" % (path, error)) from error
def _procedure_name(procedure):
entry = procedure.getEntryPoint()
return procedure.getSegment().getNameAtAddress(entry) or _hex(entry)
@ -738,7 +809,8 @@ def _dispatch(method, params):
# file header. An integer alone does not prove source-file provenance.
if inverse(offset) != address:
raise InvalidRequestError("Address has no authoritative file-offset mapping: reverse lookup disagrees at %s" % _hex(address))
return {"address": _hex(address), "file_offset": offset}
original_offset, source_path, image_base = _original_file_offset(document, offset)
return {"address": _hex(address), "file_offset": original_offset, "provider_file_offset": offset, "image_base_file_offset": image_base, "source_path": source_path}
if method == "resolve_containing_procedure":
address = _address(document, params.get("address"))
procedure, reason = _containing_procedure(document, address)

View File

@ -318,7 +318,19 @@ Analysis and annotation calls stay bound to the active target's native Hopper
document, even when GUI focus changes or other documents have the same display
name. Use `open_binary` to change targets. Byte reads stop at a segment boundary
and return the readable prefix with `complete: false`. File-offset mapping checks
the reverse lookup; synthetic external-symbol memory has no original file offset.
the reverse lookup and original executable bounds; synthetic external-symbol
memory has no original file offset. For FAT Mach-O, offsets refer to the original
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.
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

View File

@ -205,6 +205,18 @@ literal-trace results and invalid-address diagnostics agree with MCP.
No provider is mocked in this lane.
The Linux demo lane remains a separate `verify:hopper:linux` command.
`verify:hopper:fat` is a separate macOS lane requiring installed Hopper and the
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.
Cross-architecture fixture compilation is not required by `verify:hopper`.
Golden tests use immutable captured text inputs with producer/source provenance
under `tests/fixtures/golden/`. Expected results are reviewed for the semantic
claim; capture commands do not automatically approve new expected outputs.

View File

@ -138,6 +138,8 @@
"typecheck": "tsc --noEmit",
"verify:test-temp-hygiene": "npm run build:cached && node scripts/verify-test-temp-hygiene.mjs",
"verify:hopper": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper.mjs",
"verify:hopper:fat": "npm run build:cached && node scripts/verify-real-hopper-fat.mjs",
"verify:hopper:fat64": "npm run build:cached && node scripts/verify-real-hopper-fat.mjs --fat64",
"verify:apple-dispatch": "npm run build:cached && node scripts/verify/apple/dispatch.mjs",
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
"verify:keyed-archive": "npm run build:cached && node scripts/verify/apple/keyed-archive.mjs",

View File

@ -0,0 +1,244 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { access, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { constants } from "node:fs";
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 { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js";
import { parseConfig } from "../dist/config.js";
const run = promisify(execFile);
const verifyFat64 = process.argv[2] === "--fat64";
assert.deepEqual(process.argv.slice(2), verifyFat64 ? ["--fat64"] : []);
if (process.platform !== "darwin" || !["arm64", "x64"].includes(process.arch))
throw new Error(
"Hopper FAT verification requires macOS arm64 or x64 and its installed clang/lipo toolchain",
);
const config = parseConfig(process.env);
if (!config.ok) throw config.error;
await access(config.value.hopperLauncherPath, constants.X_OK).catch((cause) => {
throw new Error(
`Hopper FAT verification requires the configured executable: ${config.value.hopperLauncherPath}`,
{ cause },
);
});
const toolPaths = {};
for (const command of ["clang", "lipo"])
toolPaths[command] = (
await run("xcrun", ["--find", command]).catch((cause) => {
throw new Error(`Hopper FAT verification requires ${command}`, { cause });
})
).stdout.trim();
const sdk = (
await run("xcrun", ["--sdk", "macosx", "--show-sdk-path"]).catch((cause) => {
throw new Error(
"Hopper FAT verification requires the installed macOS SDK",
{ cause },
);
})
).stdout.trim();
const directory = await realpath(await mkdtemp(join(tmpdir(), "hopper-fat-")));
const source = fileURLToPath(
new URL("../tests/conformance/c/fixture.c", import.meta.url),
);
const arm = join(directory, "c-arm64");
const intel = join(directory, "c-x86_64");
const fat = join(directory, "c-fat");
const fat64 = join(directory, "c-fat64");
const singleFat = join(directory, "c-single-fat");
const owned = new Set();
const before = await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
);
const client = new Client({ name: "hopper-fat-verifier", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: ["dist/main.js"],
env: {
...process.env,
REA_ANALYSIS_PROVIDER: "hopper",
HOPPER_LOADER_ARGS_JSON: "[]",
},
stderr: "pipe",
});
transport.stderr?.on("data", (chunk) => process.stderr.write(chunk));
const ajv = new Ajv({ strict: false });
addFormats(ajv);
const observations = [];
try {
await run(toolPaths.clang, [
"-isysroot",
sdk,
"-arch",
"arm64",
"-O0",
"-fno-inline",
source,
"-o",
arm,
]);
await run(toolPaths.clang, [
"-isysroot",
sdk,
"-arch",
"x86_64",
"-O0",
"-fno-inline",
source,
"-o",
intel,
]);
await run(toolPaths.lipo, ["-create", arm, intel, "-output", fat]);
if (verifyFat64)
await run(toolPaths.lipo, [
"-create",
arm,
intel,
"-fat64",
"-output",
fat64,
]);
await run(toolPaths.lipo, [
"-create",
process.arch === "arm64" ? arm : intel,
"-output",
singleFat,
]);
await client.connect(transport);
assert.ok(transport.pid !== null);
owned.add(transport.pid);
const catalog = await client.listTools();
const call = async (name, args = {}) => {
const reply = await client.callTool(
{ name, arguments: args },
{ timeout: 180_000 },
);
const result = requireMcpResult(reply, name);
const schema = catalog.tools.find(
(tool) => tool.name === name,
)?.outputSchema;
assert.ok(schema, `${name} omitted its output schema`);
assert.equal(
ajv.validate(schema, reply.structuredContent),
true,
JSON.stringify(ajv.errors),
);
return result;
};
let fatExpected;
let fatAddress;
for (const path of [
arm,
intel,
fat,
singleFat,
...(verifyFat64 ? [fat64] : []),
]) {
console.error(`Verifying Hopper source-file mappings for ${path}`);
await call("open_binary", { path });
const file = await readFile(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");
for (const address of [
entry.address,
`0x${(BigInt(entry.address) + 1n).toString(16)}`,
]) {
const mapping = await call("address_to_file_offset", { address });
const bytes = await call("read_bytes", { address, length: 16 });
assert.equal(
mapping.file_offset,
mapping.image_base_file_offset + mapping.provider_file_offset,
);
assert.equal(mapping.source_path, path);
assert.equal(bytes.complete, true);
assert.equal(
file
.subarray(mapping.file_offset, mapping.file_offset + 16)
.toString("hex"),
bytes.bytes_hex,
);
if (path === fat || path === singleFat || path === fat64) {
assert.ok(
mapping.image_base_file_offset > 0,
"FAT mapping omitted its physical slice offset",
);
assert.notEqual(
file
.subarray(
mapping.provider_file_offset,
mapping.provider_file_offset + 16,
)
.toString("hex"),
bytes.bytes_hex,
"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;
}
observations.push({ path, address, ...mapping, matched_bytes: 16 });
}
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);
} finally {
await client.close();
await transport.close();
await rm(directory, { recursive: true, force: true });
}
const retained = [
...(await snapshotHopperRuntime(
"/tmp",
HOPPER_TARGET_LEASE_DIRECTORY,
owned,
)),
].filter((path) => !before.has(path));
assert.deepEqual(
retained,
[],
"FAT verification retained owned Hopper sessions or leases",
);
console.log(
JSON.stringify(
{
observations,
cliMcpParity: true,
fat64: verifyFat64 ? "verified" : "not_run",
cleanShutdown: true,
},
null,
2,
),
);

View File

@ -3,7 +3,11 @@ import {
type AnalysisProfileCommitment,
} from "../../domain/analysisProfile.js";
import type { BinaryTarget } from "../../domain/binaryTarget.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisUnsupportedTargetError,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { AnalysisProviderCandidate } from "../AnalysisProvider.js";
@ -16,6 +20,7 @@ export interface AnalysisProviderCandidateEvaluation {
readonly status: AnalysisProviderCandidateStatus;
readonly profile?: AnalysisProfileCommitment;
readonly compatibility?: Readonly<Record<string, JsonValue>>;
readonly profileError?: AnalysisError;
}
/** Resolve and validate one provider profile without starting its client. */
@ -51,12 +56,27 @@ export const evaluateAnalysisProviderCandidate = async (
return err(new AnalysisCancelledError("open_binary"));
if (!resolved.ok && resolved.error instanceof AnalysisCancelledError)
return err(resolved.error);
if (!resolved.ok)
return ok(
rejectedProfile(candidate, status, resolved.error.message, {
error_tag: resolved.error._tag,
}),
);
if (!resolved.ok) {
const error = resolved.error;
const rejection =
error instanceof AnalysisUnsupportedTargetError
? {
candidate,
status: {
...status,
targetSupport: {
status: "unsupported" as const,
code: "target_format_unsupported" as const,
reason: error.reason,
diagnostics: { path: error.path, error_tag: error._tag },
},
},
}
: rejectedProfile(candidate, status, error.message, {
error_tag: error._tag,
});
return ok({ ...rejection, profileError: error });
}
try {
if (resolved.value.profile === null)
return ok(

View File

@ -212,15 +212,10 @@ describe("analysis provider registry: binding failures and cancellation", () =>
failed.provider,
]).select(DATABASE_TARGET, "failed");
if (explicit.ok) throw new Error("expected provider selection rejection");
expect(explicit.error).toBeInstanceOf(ProviderSelectionError);
if (explicit.error instanceof ProviderSelectionError)
expect(explicit.error.rejections).toMatchObject([
{
providerId: "failed",
code: "version_unresolved",
diagnostics: { error_tag: "ProviderAdapterError" },
},
]);
expect(explicit.error).toMatchObject({
_tag: "ProviderAdapterError",
providerId: "failed",
});
});
it("cancels a pending profile probe even when the adapter ignores its signal", async () => {

View File

@ -6,6 +6,7 @@ import {
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
import type { BinaryTarget } from "../../domain/binaryTarget.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
ProviderSelectionError,
type ProviderSelectionFailureReason,
@ -147,12 +148,7 @@ export class AnalysisProviderRegistry {
target: BinaryTarget,
requestedSelector?: AnalysisProviderSelector,
options: { readonly signal?: AbortSignal } = {},
): Promise<
Result<
AnalysisProviderSelection,
ProviderSelectionError | AnalysisCancelledError
>
> {
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
if (signalIsAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
const parsedRequest =
@ -224,12 +220,7 @@ export class AnalysisProviderRegistry {
context: TargetSelectionContext,
providerId: string,
source: AnalysisProviderBinding["selectionSource"],
): Promise<
Result<
AnalysisProviderSelection,
ProviderSelectionError | AnalysisCancelledError
>
> {
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
const { baseline, signal, target } = context;
const provider = this.#byId.get(providerId);
if (provider === undefined)
@ -253,6 +244,8 @@ export class AnalysisProviderRegistry {
);
if (!evaluation.ok) return evaluation;
const evaluated = evaluation.value;
if (evaluated.profileError !== undefined)
return err(evaluated.profileError);
const statuses = replaceCandidate(baseline, evaluated.status);
if (
evaluated.profile === undefined ||
@ -275,12 +268,7 @@ export class AnalysisProviderRegistry {
async #selectAutomatically(
context: TargetSelectionContext,
): Promise<
Result<
AnalysisProviderSelection,
ProviderSelectionError | AnalysisCancelledError
>
> {
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
const { baseline, signal, target } = context;
const results = await Promise.all(
this.#providers.map(

View File

@ -16,7 +16,7 @@ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [
{
name: "address_to_file_offset",
description:
"Map one provider-normalized virtual address to its original nonnegative file offset. Unmapped addresses fail explicitly, and providers without an authoritative mapping API return typed capability unavailability.",
"Map one provider-normalized virtual address to its original nonnegative file offset. Hopper translates FAT Mach-O image-relative offsets using the observed loaded header, preserving its provider coordinate, image base, and source path. Unmapped addresses fail explicitly, and unavailable or ambiguous original-file mappings return typed capability unavailability.",
inputSchema: z.object({ address, document }),
},
] as const;

View File

@ -147,6 +147,30 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
z.object({
address: z.string(),
file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
provider_file_offset: z
.number()
.int()
.min(0)
.max(Number.MAX_SAFE_INTEGER)
.exactOptional()
.describe(
"Original coordinate returned by the provider's mapping API.",
),
image_base_file_offset: z
.number()
.int()
.min(0)
.max(Number.MAX_SAFE_INTEGER)
.exactOptional()
.describe(
"File offset of the loaded image within its source container; zero for a thin executable.",
),
source_path: z
.string()
.exactOptional()
.describe(
"Observed original executable path used to verify the file mapping.",
),
}),
),
procedure_references: resultOf(

View File

@ -10,7 +10,11 @@ import type {
BinaryArchitecture,
BinaryTarget,
} from "../domain/binaryTarget.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
} from "../domain/analysisErrorCore.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { type AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
@ -29,7 +33,17 @@ export const resolveHopperAnalysisProfile = async (
): Promise<Result<AnalysisProfileResolution, AnalysisError>> => {
if (target.kind !== "executable" && target.kind !== "database")
return ok({ profile: null, compatibility: {} });
const derived = hopperLoaderArgsForTarget(target);
let machoContainer: "thin" | "fat32" | "fat64" | undefined;
if (target.kind === "executable" && target.format === "mach-o") {
const header = await readMachoMagic(target.path, options.signal);
if (!header.ok) return header;
machoContainer = [0xcafebabf, 0xbfbafeca].includes(header.value)
? "fat64"
: [0xcafebabe, 0xbebafeca].includes(header.value)
? "fat32"
: "thin";
}
const derived = hopperLoaderArgsForTarget(target, machoContainer);
if (!derived.ok) return derived;
const loaderArgs =
options.loaderArgsOverride.length === 0
@ -53,6 +67,9 @@ export const resolveHopperAnalysisProfile = async (
available_architectures: [
...(target.availableArchitectures ?? []),
].sort(),
...(machoContainer === undefined
? {}
: { macho_container: machoContainer }),
loader: {
source:
options.loaderArgsOverride.length === 0
@ -71,7 +88,11 @@ export const resolveHopperAnalysisProfile = async (
/** Derive Hopper's complete non-interactive CLI loader selection. */
export const hopperLoaderArgsForTarget = (
target: BinaryTarget,
): Result<readonly string[], ProviderAdapterError> => {
machoContainer?: "thin" | "fat32" | "fat64",
): Result<
readonly string[],
ProviderAdapterError | AnalysisCapabilityUnavailableError
> => {
if (target.kind === "database") return ok([]);
if (target.kind !== "executable")
return err(new ProviderAdapterError("hopper", "resolve_analysis_profile"));
@ -79,8 +100,16 @@ export const hopperLoaderArgsForTarget = (
const flag = hopperArchitectureFlag(architecture);
switch (target.format) {
case "mach-o":
if (machoContainer === undefined)
return err(
new AnalysisCapabilityUnavailableError(
"hopper",
"resolve_analysis_profile",
"Mach-O container kind is unknown; resolve the Hopper analysis profile before starting its client.",
),
);
return ok(
(target.availableArchitectures?.length ?? 0) > 1
machoContainer === "fat32" || machoContainer === "fat64"
? ["-l", "FAT", flag, "-l", "Mach-O"]
: ["-l", "Mach-O", flag],
);
@ -109,6 +138,46 @@ const hopperArchitectureFlag = (architecture: BinaryArchitecture): string => {
}
};
const readMachoMagic = async (
path: string,
signal?: AbortSignal,
): Promise<Result<number, AnalysisError>> => {
if (signalIsAborted(signal))
return err(new AnalysisCancelledError("open_binary"));
const stream = createReadStream(path, { start: 0, end: 3 });
const onAbort = (): void => {
stream.destroy();
};
signal?.addEventListener("abort", onAbort, { once: true });
try {
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
if (signalIsAborted(signal))
return err(new AnalysisCancelledError("open_binary"));
const bytes = Buffer.concat(chunks);
return bytes.length === 4
? ok(bytes.readUInt32BE(0))
: err(
new BinaryTargetError(
path,
"Mach-O header became truncated before Hopper loader selection",
),
);
} catch (cause: unknown) {
return signalIsAborted(signal)
? err(new AnalysisCancelledError("open_binary"))
: err(
new BinaryTargetError(
path,
`Cannot read the Mach-O container header: ${cause instanceof Error ? cause.message : String(cause)}`,
{ cause },
),
);
} finally {
signal?.removeEventListener("abort", onAbort);
}
};
const sha256File = async (
path: string,
signal: AbortSignal | undefined,

View File

@ -158,7 +158,13 @@ export class HopperProvider implements AnalysisProviderCandidate {
),
close: () => Promise.resolve(),
};
const derivedLoaderArgs = hopperLoaderArgsForTarget(target);
const container = profile?.parameters.macho_container;
const derivedLoaderArgs = hopperLoaderArgsForTarget(
target,
container === "thin" || container === "fat32" || container === "fat64"
? container
: undefined,
);
if (!derivedLoaderArgs.ok)
return {
execute: () => Promise.resolve(err(derivedLoaderArgs.error)),
@ -200,7 +206,9 @@ export class HopperProvider implements AnalysisProviderCandidate {
value: createAnalysisExecution(result.value, executionProvider, {
...(profile === undefined ? {} : { analysisProfile: profile }),
limitations: [
"Results depend on Hopper's completed static analysis.",
...(CAPABILITIES.find(
(descriptor) => descriptor.operation === operation,
)?.limitations ?? []),
...(operation === "read_bytes"
? [
"Hopper byte reads stop at the containing segment's exclusive end; a readable prefix is returned with complete=false when the requested range crosses it.",

View File

@ -32,29 +32,8 @@ describe("Hopper analysis profiles", () => {
});
it.each([
[target("mach-o", "arm64"), ["-l", "Mach-O", "--aarch64"]],
[target("elf", "x86_64"), ["-l", "ELF", "--intel-64"]],
[target("pe", "x86"), ["-l", "WinPE", "--intel-32"]],
[
{
path: "/tmp/fixture",
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"] as const,
} satisfies BinaryTarget,
["-l", "FAT", "--aarch64", "-l", "Mach-O"],
],
[
{
path: "/tmp/fixture.hop",
sha256: "b".repeat(64),
kind: "database",
format: "analysis-database",
} satisfies BinaryTarget,
[],
],
] as const)("derives complete non-interactive options", (input, expected) => {
expect(hopperLoaderArgsForTarget(input)).toEqual({
ok: true,
@ -63,54 +42,6 @@ describe("Hopper analysis profiles", () => {
expect("loaderArgs" in input).toBe(false);
});
it("commits launcher build and configured overrides without target coupling", async () => {
directory = await createTestTempDirectory("rea-hopper-profile-");
const launcher = join(directory, "hopper");
await writeFile(launcher, "Hopper build A");
const input = target("mach-o", "arm64");
const derived = await resolveHopperAnalysisProfile(input, {
launcherPath: launcher,
loaderArgsOverride: [],
provider: HOPPER_PROVIDER_IDENTITY,
});
const overridden = await resolveHopperAnalysisProfile(input, {
launcherPath: launcher,
loaderArgsOverride: ["-l", "Mach-O", "--intel-64"],
provider: HOPPER_PROVIDER_IDENTITY,
});
if (
!derived.ok ||
derived.value.profile === null ||
!overridden.ok ||
overridden.value.profile === null
)
throw new Error("fixture profiles did not resolve");
expect(derived.value.compatibility).toEqual({
loaderArgs: ["-l", "Mach-O", "--aarch64"],
});
expect(overridden.value.compatibility).toEqual({
loaderArgs: ["-l", "Mach-O", "--intel-64"],
});
expect(overridden.value.profile.digest).not.toBe(
derived.value.profile.digest,
);
expect(derived.value.profile.provider.version).toMatch(
/^launcher-sha256:[a-f0-9]{64}$/u,
);
await writeFile(launcher, "Hopper build B");
const changedBuild = await resolveHopperAnalysisProfile(input, {
launcherPath: launcher,
loaderArgsOverride: [],
provider: HOPPER_PROVIDER_IDENTITY,
});
if (!changedBuild.ok || changedBuild.value.profile === null)
throw new Error("changed fixture profile did not resolve");
expect(changedBuild.value.profile.provider.version).not.toBe(
derived.value.profile.provider.version,
);
});
it("keeps compatibility output but declines cache identity when version is unresolved", async () => {
const resolved = await resolveHopperAnalysisProfile(
target("elf", "x86_64"),