fix(hopper): bind file mappings to verified source bytes

Read source digests, FAT tables and embedded headers from one opened source stream before translating native offsets. Preserve native mapping observations through changed, missing, nonregular and access-denied source failures; keep filesystem work outside the Hopper API thread.

Exercise source drift, relocated slices, missing files, permissions and recovery through real Hopper CLI/MCP verification for thin, FAT32 and FAT64 inputs.
This commit is contained in:
morluto committed 2026-10-09 06:13:41 +08:00
1 parent ae06dcf83f
commit 82626c4499
14 files changed
+536 -85

No files matched your search

+11 -73
View File
@@ -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)
+15
View File
@@ -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,
+7 -2
View File
@@ -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.
+121
View File
@@ -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;
}
+22
View File
@@ -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,
+1 -1
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. 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;
+3 -1
View File
@@ -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;
+2 -2
View File
@@ -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);
}
+5 -2
View File
@@ -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";
}
@@ -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;
};
}
-1
View File
@@ -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'))`,
"",
+322
View File
@@ -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<typeof observationSchema>;
/** Bind native image-relative offsets to coordinates in the admitted source bytes. */
export async function mapHopperFileOffset(
target: BinaryTarget,
value: JsonValue,
signal?: AbortSignal,
): Promise<Result<JsonValue, AnalysisError>> {
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<FatLayout | null, string> {
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<FatLayout | null, string> | undefined;
const headers = new Map<number, Buffer>();
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<ReturnType<typeof readSourceSnapshot>>;
function originalOffset(
snapshot: SourceSnapshot,
observation: Observation,
): Result<number, string> {
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);
}
+10 -3
View File
@@ -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();
+5
View File
@@ -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.",