diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 492408342..cb0e9c6dc 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -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) diff --git a/docs/installation.md b/docs/installation.md index 7ac8053f3..62e07ee76 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 ` to read the exact architecture names, then +`lipo -thin -output ` 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 diff --git a/docs/testing.md b/docs/testing.md index 3dad3fd7a..a58381590 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/package.json b/package.json index aaa41580c..9276c2e14 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-real-hopper-fat.mjs b/scripts/verify-real-hopper-fat.mjs new file mode 100644 index 000000000..516229cc4 --- /dev/null +++ b/scripts/verify-real-hopper-fat.mjs @@ -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, + ), +); diff --git a/src/application/binary/AnalysisProviderEvaluation.ts b/src/application/binary/AnalysisProviderEvaluation.ts index c8cd651b6..eb907b153 100644 --- a/src/application/binary/AnalysisProviderEvaluation.ts +++ b/src/application/binary/AnalysisProviderEvaluation.ts @@ -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>; + 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( diff --git a/src/application/binary/AnalysisProviderRegistry.test.ts b/src/application/binary/AnalysisProviderRegistry.test.ts index 5423044d8..935a72f8e 100644 --- a/src/application/binary/AnalysisProviderRegistry.test.ts +++ b/src/application/binary/AnalysisProviderRegistry.test.ts @@ -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 () => { diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index cfc2180c3..5fe08d030 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -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> { 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> { 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> { const { baseline, signal, target } = context; const results = await Promise.all( this.#providers.map( diff --git a/src/contracts/hopperMemoryContracts.ts b/src/contracts/hopperMemoryContracts.ts index ddebc3e81..5e5a84e11 100644 --- a/src/contracts/hopperMemoryContracts.ts +++ b/src/contracts/hopperMemoryContracts.ts @@ -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; diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 13a94b210..b60608ae1 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -147,6 +147,30 @@ export const officialOutputSchemas: Readonly> = { 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( diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 1b17bedb5..d168e0e3d 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -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> => { 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 => { + 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> => { + 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, diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index a07f9994b..b03d73fd3 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -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.", diff --git a/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts b/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts index 391b38cf2..13a3df281 100644 --- a/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts +++ b/tests/boundary/providers/hopper/hopperAnalysisProfile.test.ts @@ -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"),