diff --git a/bridge/hopper_bridge.py b/bridge/hopper_bridge.py index 5594321726..660ff52ea2 100644 --- a/bridge/hopper_bridge.py +++ b/bridge/hopper_bridge.py @@ -9,7 +9,6 @@ import json import hmac import os import socket -import struct from typing import Any, Optional, Protocol, Sequence BAD_ADDRESSES = (-1, 0xFFFFFFFFFFFFFFFF, None) @@ -277,76 +276,6 @@ 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 = 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: - 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) @@ -873,8 +802,17 @@ 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)) - 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 offset > 9007199254740991: + raise CapabilityUnavailableError("Provider file offset exceeds the exact JSON integer range") + header_address = inverse(0) + reader = getattr(document, "readBytes", None) + header = reader(header_address, 12) if header_address not in BAD_ADDRESSES and document.getSegmentAtAddress(header_address) is not None and callable(reader) else None + return { + "address": _hex(address), + "provider_file_offset": offset, + "provider_source_path": document.getExecutableFilePath(), + "provider_image_header_hex": bytes(header).hex() if isinstance(header, (bytes, bytearray)) and len(header) == 12 else None, + } if method == "resolve_containing_procedure": address = _address(document, params.get("address")) procedure, reason = _containing_procedure(document, address) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index e23b94a4f0..4a98b90d8b 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -12,6 +12,21 @@ input file and include the observed lipo slice offset for universal binaries. If that offset is unavailable, segment evidence locations are omitted with an explicit limitation; architecture inventory locations remain available. +Hopper's `address_to_file_offset` combines the native image-relative mapping with +original-file coordinates only after checking the source against the session's +SHA-256. FAT tables and embedded Mach-O headers come from that same hashing pass. +A changed or nonregular source reports `artifact_changed`; an unreadable or +missing source reports its access or filesystem failure. Those errors retain the native offset, +loaded header and provider source path in `details.partial_observation`, with the +original-file coordinate explicitly unavailable. Restore the original bytes or +reopen the changed executable to obtain verified coordinates. Snapshot replay +remains historical evidence for its recorded artifact identity. + +A selected Hopper database's digest identifies the database rather than its +original executable. Original-file mapping is unavailable for that selection; +native mapping facts remain in partial evidence, and `read_bytes` still reads +the loaded database. Open the original executable to verify source coordinates. + Native Xcode command execution uses shared process supervision with a 60-second deadline and a 64 MiB aggregate stdout/stderr budget. Timeout, cancellation, stream failure, and output exhaustion retain the captured output, exit details, diff --git a/docs/testing.md b/docs/testing.md index 02b9b7b19f..4610c4fa0e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -227,8 +227,13 @@ 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 checks FAT64 preparation through -Hopper's native Mach-O loader, source-container mappings, profile identity, +runtime cleanup. Source byte changes, removal, permission denial (for non-root +callers), and nonregular replacement must retain native partial mapping facts, +reject unverified original-file coordinates, +and recover after restoration. Single-slice FAT cases also relocate the slice +without changing its loaded bytes. `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. diff --git a/scripts/lib/real-hopper-source-binding.mjs b/scripts/lib/real-hopper-source-binding.mjs new file mode 100644 index 0000000000..0fa6868cc4 --- /dev/null +++ b/scripts/lib/real-hopper-source-binding.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmod, mkdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { mcpTextValue } from "./mcp-verifier-results.mjs"; + +/** Exercise source replacement, removal, and restoration against a loaded native document. */ +export async function verifyHopperSourceBinding({ + client, + call, + path, + file, + address, + mapping, + relocate, +}) { + const originalHash = createHash("sha256").update(file).digest("hex"); + const changed = relocate === undefined ? Buffer.from(file) : relocate(file); + if (relocate === undefined) changed[changed.length - 1] ^= 1; + const changedHash = createHash("sha256").update(changed).digest("hex"); + const at = (delta) => `0x${(BigInt(address) + BigInt(delta)).toString(16)}`; + const observeFailure = async (delta) => { + const reply = await client.callTool( + { name: "address_to_file_offset", arguments: { address: at(delta) } }, + { timeout: 180_000 }, + ); + assert.equal( + reply.isError, + true, + "mapping unexpectedly accepted an unbound source", + ); + const error = JSON.parse(mcpTextValue(reply)).error; + const partial = error.details.partial_observation; + assert.equal(partial.operation, "address_to_file_offset"); + assert.equal(partial.address, at(delta)); + assert.equal( + partial.provider_file_offset, + mapping.provider_file_offset + delta, + ); + assert.equal(partial.original_file_offset.available, false); + assert.equal(typeof partial.original_file_offset.reason, "string"); + assert.match(partial.provider_image_header_hex, /^[a-f0-9]{24}$/u); + return error; + }; + try { + await writeFile(path, changed); + const error = await observeFailure(2); + assert.equal(error.code, "artifact_changed"); + assert.ok(error.details.reason.includes(originalHash)); + assert.ok(error.details.reason.includes(changedHash)); + assert.equal((await call("binary_session")).sha256, originalHash); + assert.equal( + (await call("read_bytes", { address: at(2), length: 1 })).bytes_hex, + file + .subarray(mapping.file_offset + 2, mapping.file_offset + 3) + .toString("hex"), + ); + } finally { + await writeFile(path, file); + } + const renamed = `${path}.temporarily-moved`; + await rename(path, renamed); + try { + const error = await observeFailure(3); + assert.equal(error.code, "target_unavailable"); + assert.match(error.details.reason, /ENOENT/u); + } finally { + await rename(renamed, path); + } + await rename(path, renamed); + try { + await mkdir(path); + const error = await observeFailure(4); + assert.equal(error.code, "artifact_changed"); + assert.match(error.details.reason, /no longer a regular file/u); + } finally { + await rm(path, { recursive: true, force: true }); + await rename(renamed, path); + } + const permissionDenied = process.getuid() !== 0; + if (permissionDenied) { + const mode = (await stat(path)).mode & 0o777; + try { + await chmod(path, 0); + const error = await observeFailure(5); + assert.equal(error.code, "access_denied"); + assert.ok(["EACCES", "EPERM"].includes(error.details.system_code)); + } finally { + await chmod(path, mode); + } + } + for (const delta of [2, 3, 4, ...(permissionDenied ? [5] : [])]) { + const recovered = await call("address_to_file_offset", { + address: at(delta), + }); + assert.equal(recovered.file_offset, mapping.file_offset + delta); + } + return { + replacement: relocate === undefined ? "same_size_bytes" : "relocated_slice", + missingSource: "verified", + nonregularSource: "verified", + permissionDenied: permissionDenied ? "verified" : "not_run_root", + nativePartialEvidence: "verified", + recovery: "verified", + }; +} + +/** Relocate the sole FAT slice while retaining its exact loaded bytes and CPU identity. */ +export function relocateSingleFatSlice(file) { + const wide = file.readUInt32BE(0) === 0xcafebabf; + assert.equal(file.readUInt32BE(4), 1); + const offset = wide + ? Number(file.readBigUInt64BE(16)) + : file.readUInt32BE(16); + const size = wide ? Number(file.readBigUInt64BE(24)) : file.readUInt32BE(20); + const relocated = Buffer.alloc(offset * 2 + size); + file.subarray(0, offset).copy(relocated); + file.subarray(offset, offset + size).copy(relocated, offset * 2); + if (wide) relocated.writeBigUInt64BE(BigInt(offset * 2), 16); + else relocated.writeUInt32BE(offset * 2, 16); + return relocated; +} diff --git a/scripts/verify-real-hopper-fat.mjs b/scripts/verify-real-hopper-fat.mjs index d4709e357c..b068b88406 100644 --- a/scripts/verify-real-hopper-fat.mjs +++ b/scripts/verify-real-hopper-fat.mjs @@ -22,6 +22,10 @@ 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"; +import { + verifyHopperSourceBinding, + relocateSingleFatSlice, +} from "./lib/real-hopper-source-binding.mjs"; const run = promisify(execFile); const verifyFat64 = process.argv[2] === "--fat64"; @@ -86,6 +90,7 @@ transport.stderr?.on("data", (chunk) => process.stderr.write(chunk)); const ajv = new Ajv({ strict: false }); addFormats(ajv); const observations = []; +const sourceBinding = []; try { await run(toolPaths.clang, [ "-isysroot", @@ -241,11 +246,13 @@ try { const procedures = await call("list_procedures"); const entry = procedures.find((item) => item.value.endsWith("rea_entry")); assert.ok(entry, "compiler fixture omitted rea_entry"); + let entryMapping; for (const address of [ entry.address, `0x${(BigInt(entry.address) + 1n).toString(16)}`, ]) { const mapping = await call("address_to_file_offset", { address }); + if (address === entry.address) entryMapping = mapping; const bytes = await call("read_bytes", { address, length: 16 }); assert.equal( mapping.file_offset, @@ -322,6 +329,20 @@ try { sourceHash, "native analysis modified source executable bytes", ); + sourceBinding.push({ + path, + ...(await verifyHopperSourceBinding({ + client, + call, + path, + file, + address: entry.address, + mapping: entryMapping, + ...(path === singleFat || path.endsWith("c-single-fat64") + ? { relocate: relocateSingleFatSlice } + : {}), + })), + }); await call("close_binary"); } for (const { path, reason } of invalidFat64) { @@ -405,6 +426,7 @@ console.log( { observations, cliMcpParity: true, + sourceBinding, fat64: verifyFat64 ? "verified" : "not_run", malformedFat64Rejected: invalidFat64.length, preparedImageRemovedOnMcpShutdown: preparedImagePath !== undefined, diff --git a/src/contracts/hopperMemoryContracts.ts b/src/contracts/hopperMemoryContracts.ts index 5e5a84e112..e4c49788a1 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. 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.", + "Map one provider-normalized virtual address to its original nonnegative file offset. Hopper verifies the original bytes against the selected executable digest and translates FAT image-relative offsets using the loaded header. Results preserve the provider coordinate, image base, and source path. Unmapped addresses fail explicitly; changed or unavailable sources return typed errors retaining native mapping facts as partial evidence.", inputSchema: z.object({ address, document }), }, ] as const; diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 31ae207dbf..4ba3bd9912 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -84,9 +84,11 @@ import type { PartialProcessCaptureObservation, ProcessCaptureCleanupReport, } from "./process/processCapture.js"; +import type { FileOffsetPartialObservation } from "./native/fileOffsetPartialObservation.js"; import type { NativeCallPartialObservation } from "./native/nativeCallPartialObservation.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = | PartialProcessCaptureObservation - | NativeCallPartialObservation; + | NativeCallPartialObservation + | FileOffsetPartialObservation; diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index 22ddc4921c..e0ba898205 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -136,7 +136,7 @@ export class AnalysisAccessDeniedError extends AnalysisError { readonly operation: string, readonly path: string, readonly systemCode: "EACCES" | "EPERM", - options?: ErrorOptions, + options?: AnalysisErrorOptions, ) { super( `Host filesystem read access denied (${systemCode}) for ${path} during ${operation}`, @@ -152,7 +152,7 @@ export class AnalysisArtifactChangedError extends AnalysisError { readonly operation: string, readonly path: string, readonly reason: string, - options?: ErrorOptions, + options?: AnalysisErrorOptions, ) { super(`Selected artifact changed during acquisition: ${reason}`, options); } diff --git a/src/domain/configurationErrors.ts b/src/domain/configurationErrors.ts index 444693b993..c7c07f75e6 100644 --- a/src/domain/configurationErrors.ts +++ b/src/domain/configurationErrors.ts @@ -1,4 +1,7 @@ -import { AnalysisError } from "./analysisErrorBase.js"; +import { + AnalysisError, + type AnalysisErrorOptions, +} from "./analysisErrorBase.js"; /** Runtime configuration could not be parsed safely. */ export class ConfigurationError extends AnalysisError { @@ -16,7 +19,7 @@ export class NoBinaryOpenError extends AnalysisError { } /** Additional constraints identified while admitting a filesystem target. */ -export interface BinaryTargetErrorOptions extends ErrorOptions { +export interface BinaryTargetErrorOptions extends AnalysisErrorOptions { readonly constraint?: "directory_requires_file"; } diff --git a/src/domain/native/fileOffsetPartialObservation.ts b/src/domain/native/fileOffsetPartialObservation.ts new file mode 100644 index 0000000000..5376790e7a --- /dev/null +++ b/src/domain/native/fileOffsetPartialObservation.ts @@ -0,0 +1,12 @@ +/** Native mapping facts retained when original-file coordinates cannot be verified. */ +export interface FileOffsetPartialObservation { + readonly operation: "address_to_file_offset"; + readonly address: string; + readonly provider_file_offset: number; + readonly provider_source_path: string | null; + readonly provider_image_header_hex: string | null; + readonly original_file_offset: { + readonly available: false; + readonly reason: string; + }; +} diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 19d50527ad..436d5f0b54 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -363,7 +363,6 @@ const bridgeBootstrapSource = ( `REA_TOKEN = ${JSON.stringify(session.token)}`, `REA_RUN_ID = ${JSON.stringify(session.runId)}`, `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'))`, "", diff --git a/src/hopper/HopperFileOffset.ts b/src/hopper/HopperFileOffset.ts new file mode 100644 index 0000000000..3856dbccdd --- /dev/null +++ b/src/hopper/HopperFileOffset.ts @@ -0,0 +1,322 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { open, type FileHandle } from "node:fs/promises"; +import { z } from "zod"; + +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { BinaryTargetError } from "../domain/configurationErrors.js"; +import type { BinaryTarget } from "../domain/binaryTarget.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import type { FileOffsetPartialObservation } from "../domain/native/fileOffsetPartialObservation.js"; +import { + readFatSliceDeclarations, + type FatSliceDeclaration, + validateFatSlice, +} from "../domain/apple/machoContainer.js"; +import { err, ok, type Result } from "../domain/result.js"; + +const OPERATION = "address_to_file_offset"; +const MAX_FAT_ARCHITECTURES = 128; +const PREFIX_BYTES = 8 + MAX_FAT_ARCHITECTURES * 32; +const observationSchema = z.strictObject({ + address: z.string().regex(/^0x[0-9a-f]+$/u), + provider_file_offset: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER), + provider_source_path: z.string().nullable(), + provider_image_header_hex: z + .string() + .regex(/^[a-f0-9]{24}$/u) + .nullable(), +}); +type Observation = z.infer; + +/** Bind native image-relative offsets to coordinates in the admitted source bytes. */ +export async function mapHopperFileOffset( + target: BinaryTarget, + value: JsonValue, + signal?: AbortSignal, +): Promise> { + const parsed = observationSchema.safeParse(value); + if (!parsed.success) + return err( + new AnalysisOutputError( + OPERATION, + "Hopper's native file mapping observation is malformed", + ), + ); + const observation = parsed.data; + const partial = (reason: string): FileOffsetPartialObservation => ({ + operation: OPERATION, + ...observation, + original_file_offset: { available: false, reason }, + }); + const unavailable = (reason: string) => + err( + new AnalysisCapabilityUnavailableError("hopper", OPERATION, reason, { + partialObservation: partial(reason), + }), + ); + if (target.kind !== "executable") + return unavailable( + "The selected database digest does not identify its original executable bytes. Open that executable to verify original-file coordinates; read_bytes still exposes the database's native bytes.", + ); + if (signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { + partialObservation: partial("Source verification was cancelled"), + }), + ); + try { + const captured = await snapshotFromPath(target.path, signal); + if (!captured.ok) + return err( + new AnalysisArtifactChangedError( + OPERATION, + target.path, + captured.error, + { + partialObservation: partial(captured.error), + }, + ), + ); + const snapshot = captured.value; + if (signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { + partialObservation: partial("Source verification was cancelled"), + }), + ); + if (snapshot.sha256 !== target.sha256) { + const reason = `Source ${target.path} differs from the admitted artifact: expected SHA-256 ${target.sha256}, observed ${snapshot.sha256}. Reopen the changed source for a new analysis identity.`; + return err( + new AnalysisArtifactChangedError(OPERATION, target.path, reason, { + partialObservation: partial(reason), + }), + ); + } + const mapped = originalOffset(snapshot, observation); + if (!mapped.ok) return unavailable(mapped.error); + return ok({ + address: observation.address, + file_offset: mapped.value + observation.provider_file_offset, + provider_file_offset: observation.provider_file_offset, + image_base_file_offset: mapped.value, + source_path: target.path, + }); + } catch (cause: unknown) { + if (signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { + cause, + partialObservation: partial("Source verification was cancelled"), + }), + ); + const reason = `Cannot verify original source bytes: ${cause instanceof Error ? cause.message : String(cause)}`; + const code = + typeof cause === "object" && cause !== null && "code" in cause + ? cause.code + : undefined; + if (code === "EACCES" || code === "EPERM") + return err( + new AnalysisAccessDeniedError(OPERATION, target.path, code, { + cause, + partialObservation: partial(reason), + }), + ); + return err( + new BinaryTargetError(target.path, reason, { + cause, + partialObservation: partial(reason), + }), + ); + } +} + +async function snapshotFromPath(path: string, signal?: AbortSignal) { + const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + try { + if (!(await file.stat()).isFile()) + return err( + `Source ${path} is no longer a regular file. Restore the admitted executable or reopen a regular source.`, + ); + return ok(await readSourceSnapshot(file, signal)); + } finally { + await file.close(); + } +} + +interface FatLayout { + readonly slices: readonly FatSliceDeclaration[]; + readonly tableEnd: number; +} + +function fatLayout(prefix: Buffer): Result { + const magic = prefix.length < 4 ? 0 : prefix.readUInt32BE(0); + if (![0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic)) + return ok(null); + if (prefix.length < 8) return err("The original FAT header is truncated"); + const littleEndian = magic === 0xbebafeca || magic === 0xbfbafeca; + const wide = magic === 0xcafebabf || magic === 0xbfbafeca; + const count = littleEndian ? prefix.readUInt32LE(4) : prefix.readUInt32BE(4); + if (count === 0 || count > MAX_FAT_ARCHITECTURES) + return err("FAT architecture count is outside the supported 1..128 range"); + const tableEnd = 8 + count * (wide ? 32 : 20); + const table = prefix.subarray(8, tableEnd); + const parsed = readFatSliceDeclarations(table, { count, wide, littleEndian }); + if (parsed.status !== "parsed") return err(parsed.reason); + if (wide) { + for (const slice of parsed.slices) + if ( + (littleEndian + ? table.readUInt32LE(slice.index * 32 + 28) + : table.readUInt32BE(slice.index * 32 + 28)) !== 0 + ) + return err( + `FAT64 architecture ${slice.index} has a nonzero reserved field`, + ); + } + return ok({ slices: parsed.slices, tableEnd }); +} + +async function readSourceSnapshot(file: FileHandle, signal?: AbortSignal) { + const prefix = Buffer.alloc(PREFIX_BYTES); + const hash = createHash("sha256"); + let size = 0; + let layout: Result | undefined; + const headers = new Map(); + const capture = (bytes: Buffer, position: number) => { + if (layout?.ok !== true || layout.value === null) return; + for (const slice of layout.value.slices) { + const start = Math.max(position, slice.offset); + const end = Math.min(position + bytes.length, slice.offset + 12); + if (end <= start) continue; + let header = headers.get(slice.index); + if (header === undefined) { + header = Buffer.alloc(12); + headers.set(slice.index, header); + } + bytes.copy( + header, + start - slice.offset, + start - position, + end - position, + ); + } + }; + for await (const chunk of file.createReadStream({ + start: 0, + autoClose: false, + ...(signal === undefined ? {} : { signal }), + })) { + const bytes = Buffer.from(chunk); + hash.update(bytes); + if (size < prefix.length) + bytes.copy(prefix, size, 0, Math.min(bytes.length, prefix.length - size)); + if (layout === undefined && size + bytes.length >= prefix.length) { + layout = fatLayout(prefix); + capture(prefix, 0); + } + capture(bytes, size); + size += bytes.length; + } + if (layout === undefined) { + layout = fatLayout(prefix.subarray(0, size)); + capture(prefix.subarray(0, size), 0); + } + return { layout, headers, size, sha256: hash.digest("hex") }; +} + +type SourceSnapshot = Awaited>; + +function originalOffset( + snapshot: SourceSnapshot, + observation: Observation, +): Result { + const { layout, size, headers } = snapshot; + if (!layout.ok) return layout; + if (layout.value === null) + return observation.provider_file_offset < size + ? ok(0) + : err("Provider mapping lies outside the admitted executable bytes"); + const header = + observation.provider_image_header_hex === null + ? null + : Buffer.from(observation.provider_image_header_hex, "hex"); + const loadedMagic = header?.readUInt32BE(0); + if ( + header === null || + loadedMagic === undefined || + ![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(loadedMagic) + ) + return err( + "Hopper did not expose the loaded Mach-O header needed to identify its FAT slice", + ); + const little = loadedMagic === 0xcefaedfe || loadedMagic === 0xcffaedfe; + const cpu = little ? header.readUInt32LE(4) : header.readUInt32BE(4); + const subtype = little ? header.readUInt32LE(8) : header.readUInt32BE(8); + const matches = []; + const ranges = [...layout.value.slices].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 err("Original FAT slices overlap"); + } + for (const slice of layout.value.slices) { + const reason = validateFatSlice(slice, size, layout.value.tableEnd); + if (reason !== null) return err(reason); + const embedded = headers.get(slice.index); + if (slice.size < 12 || embedded === undefined) + return err( + `FAT architecture ${slice.index} has a truncated Mach-O header`, + ); + const embeddedMagic = embedded.readUInt32BE(0); + if ( + ![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(embeddedMagic) + ) + return err( + `FAT architecture ${slice.index} does not contain a Mach-O image`, + ); + const embeddedLittle = + embeddedMagic === 0xcefaedfe || embeddedMagic === 0xcffaedfe; + const identityError = validateFatSlice(slice, size, layout.value.tableEnd, { + cpuType: embeddedLittle + ? embedded.readUInt32LE(4) + : embedded.readUInt32BE(4), + cpuSubtype: embeddedLittle + ? embedded.readUInt32LE(8) + : embedded.readUInt32BE(8), + }); + if (identityError !== null) return err(identityError); + if (slice.cpuType === cpu && slice.cpuSubtype === subtype) + matches.push(slice); + } + const selected = matches.length === 1 ? matches[0] : undefined; + if (selected === undefined) + return err( + `Original FAT slice matching Hopper's loaded header is ${matches.length === 0 ? "missing" : "ambiguous"}`, + ); + if (headers.get(selected.index)?.equals(header) !== true) + return err( + "Original FAT slice header disagrees with Hopper's loaded header", + ); + if (observation.provider_file_offset >= selected.size) + return err("Provider mapping lies outside the loaded FAT slice"); + if (!Number.isSafeInteger(selected.offset + observation.provider_file_offset)) + return err("Original file offset exceeds the exact JSON integer range"); + return ok(selected.offset); +} diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts index 093f263358..44b09b417f 100644 --- a/src/hopper/HopperProvider.ts +++ b/src/hopper/HopperProvider.ts @@ -23,6 +23,7 @@ import { hopperLoaderArgsForTarget, resolveHopperAnalysisProfile, } from "./HopperAnalysisProfile.js"; +import { mapHopperFileOffset } from "./HopperFileOffset.js"; import { HopperRegexSearch } from "./HopperRegexSearch.js"; import { HopperClient } from "./HopperClient.js"; import { hopperMachOImageSchema } from "./HopperMachOImage.js"; @@ -239,10 +240,16 @@ export class HopperProvider implements AnalysisProviderCandidate { parameters.mode === "regex" ? await regexSearch.execute(operation, parameters, options) : await client.callTool(operation, parameters, options); - return result.ok + if (!result.ok) return result; + const mapped = + operation === "address_to_file_offset" + ? await mapHopperFileOffset(target, result.value, options?.signal) + : result; + return mapped.ok ? { ok: true, - value: createAnalysisExecution(result.value, executionProvider, { + value: createAnalysisExecution(mapped.value, executionProvider, { + rawResult: result.value, ...(profile === undefined ? {} : { analysisProfile: profile }), limitations: [ ...(preparedImage === undefined @@ -266,7 +273,7 @@ export class HopperProvider implements AnalysisProviderCandidate { ], }), } - : result; + : mapped; }, runtimeLineageSnapshots: () => { const observation = client.runtimeLineage(); diff --git a/src/hopper/HopperProviderCapabilities.ts b/src/hopper/HopperProviderCapabilities.ts index 1d23731910..00f89fcdbf 100644 --- a/src/hopper/HopperProviderCapabilities.ts +++ b/src/hopper/HopperProviderCapabilities.ts @@ -79,6 +79,11 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( }), limitations: Object.freeze([ "Results depend on Hopper's completed static analysis.", + ...(operation === "address_to_file_offset" + ? [ + "Original-file coordinates require source bytes matching the selected executable digest. A changed, unavailable, or database-only source retains native mapping facts in partial error evidence.", + ] + : []), ...(operation === "search_strings" || operation === "search_procedures" ? [ "Regex mode uses ECMAScript Unicode semantics in a supervised worker with a five-second matching deadline. Cancellation or deadline failure stops matching without blocking Hopper; literal mode retains native casefold semantics.",