fix(hopper): resolve FAT loaders and original file coordinates
This commit is contained in:
parent
b2d5bc1109
commit
35f1ddd6bc
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -318,7 +318,19 @@ Analysis and annotation calls stay bound to the active target's native Hopper
|
|||
document, even when GUI focus changes or other documents have the same display
|
||||
name. Use `open_binary` to change targets. Byte reads stop at a segment boundary
|
||||
and return the readable prefix with `complete: false`. File-offset mapping checks
|
||||
the reverse lookup; synthetic external-symbol memory has no original file offset.
|
||||
the reverse lookup and original executable bounds; synthetic external-symbol
|
||||
memory has no original file offset. For FAT Mach-O, offsets refer to the original
|
||||
container file. Results retain Hopper's image-relative offset, the observed slice
|
||||
base, and the source executable path.
|
||||
Loader selection reads the Mach-O container header, including FAT files with a
|
||||
single architecture. FAT64 uses the same derived FAT/Mach-O loader chain;
|
||||
loading depends on the installed Hopper build. Hopper 6.1.0-demo timed out on
|
||||
a FAT64 fixture while FAT32 controls and its extracted thin image loaded.
|
||||
This does not establish that other builds reject FAT64. If loading fails, use
|
||||
`lipo -archs <source-file>` to read the exact architecture names, then
|
||||
`lipo -thin <architecture> <source-file> -output <thin-file>` and open the
|
||||
resulting image. FAT64 is distinct from the CPU architecture: FAT32 containers
|
||||
can contain 64-bit executable images.
|
||||
Cursor navigation returns the observed object start when Hopper snaps an interior
|
||||
address; adjacent-object navigation rejects unmapped inputs and document ends.
|
||||
Native API text rejects NUL characters and unpaired Unicode surrogates before
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
@ -3,7 +3,11 @@ import {
|
|||
type AnalysisProfileCommitment,
|
||||
} from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type { AnalysisProviderCandidate } from "../AnalysisProvider.js";
|
||||
|
|
@ -16,6 +20,7 @@ export interface AnalysisProviderCandidateEvaluation {
|
|||
readonly status: AnalysisProviderCandidateStatus;
|
||||
readonly profile?: AnalysisProfileCommitment;
|
||||
readonly compatibility?: Readonly<Record<string, JsonValue>>;
|
||||
readonly profileError?: AnalysisError;
|
||||
}
|
||||
|
||||
/** Resolve and validate one provider profile without starting its client. */
|
||||
|
|
@ -51,12 +56,27 @@ export const evaluateAnalysisProviderCandidate = async (
|
|||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (!resolved.ok && resolved.error instanceof AnalysisCancelledError)
|
||||
return err(resolved.error);
|
||||
if (!resolved.ok)
|
||||
return ok(
|
||||
rejectedProfile(candidate, status, resolved.error.message, {
|
||||
error_tag: resolved.error._tag,
|
||||
}),
|
||||
);
|
||||
if (!resolved.ok) {
|
||||
const error = resolved.error;
|
||||
const rejection =
|
||||
error instanceof AnalysisUnsupportedTargetError
|
||||
? {
|
||||
candidate,
|
||||
status: {
|
||||
...status,
|
||||
targetSupport: {
|
||||
status: "unsupported" as const,
|
||||
code: "target_format_unsupported" as const,
|
||||
reason: error.reason,
|
||||
diagnostics: { path: error.path, error_tag: error._tag },
|
||||
},
|
||||
},
|
||||
}
|
||||
: rejectedProfile(candidate, status, error.message, {
|
||||
error_tag: error._tag,
|
||||
});
|
||||
return ok({ ...rejection, profileError: error });
|
||||
}
|
||||
try {
|
||||
if (resolved.value.profile === null)
|
||||
return ok(
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
ProviderSelectionError,
|
||||
type ProviderSelectionFailureReason,
|
||||
|
|
@ -147,12 +148,7 @@ export class AnalysisProviderRegistry {
|
|||
target: BinaryTarget,
|
||||
requestedSelector?: AnalysisProviderSelector,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<
|
||||
Result<
|
||||
AnalysisProviderSelection,
|
||||
ProviderSelectionError | AnalysisCancelledError
|
||||
>
|
||||
> {
|
||||
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
|
||||
if (signalIsAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const parsedRequest =
|
||||
|
|
@ -224,12 +220,7 @@ export class AnalysisProviderRegistry {
|
|||
context: TargetSelectionContext,
|
||||
providerId: string,
|
||||
source: AnalysisProviderBinding["selectionSource"],
|
||||
): Promise<
|
||||
Result<
|
||||
AnalysisProviderSelection,
|
||||
ProviderSelectionError | AnalysisCancelledError
|
||||
>
|
||||
> {
|
||||
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
|
||||
const { baseline, signal, target } = context;
|
||||
const provider = this.#byId.get(providerId);
|
||||
if (provider === undefined)
|
||||
|
|
@ -253,6 +244,8 @@ export class AnalysisProviderRegistry {
|
|||
);
|
||||
if (!evaluation.ok) return evaluation;
|
||||
const evaluated = evaluation.value;
|
||||
if (evaluated.profileError !== undefined)
|
||||
return err(evaluated.profileError);
|
||||
const statuses = replaceCandidate(baseline, evaluated.status);
|
||||
if (
|
||||
evaluated.profile === undefined ||
|
||||
|
|
@ -275,12 +268,7 @@ export class AnalysisProviderRegistry {
|
|||
|
||||
async #selectAutomatically(
|
||||
context: TargetSelectionContext,
|
||||
): Promise<
|
||||
Result<
|
||||
AnalysisProviderSelection,
|
||||
ProviderSelectionError | AnalysisCancelledError
|
||||
>
|
||||
> {
|
||||
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
|
||||
const { baseline, signal, target } = context;
|
||||
const results = await Promise.all(
|
||||
this.#providers.map(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -147,6 +147,30 @@ export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
|||
z.object({
|
||||
address: z.string(),
|
||||
file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
||||
provider_file_offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.exactOptional()
|
||||
.describe(
|
||||
"Original coordinate returned by the provider's mapping API.",
|
||||
),
|
||||
image_base_file_offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.exactOptional()
|
||||
.describe(
|
||||
"File offset of the loaded image within its source container; zero for a thin executable.",
|
||||
),
|
||||
source_path: z
|
||||
.string()
|
||||
.exactOptional()
|
||||
.describe(
|
||||
"Observed original executable path used to verify the file mapping.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
procedure_references: resultOf(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import type {
|
|||
BinaryArchitecture,
|
||||
BinaryTarget,
|
||||
} from "../domain/binaryTarget.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { type AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
|
@ -29,7 +33,17 @@ export const resolveHopperAnalysisProfile = async (
|
|||
): Promise<Result<AnalysisProfileResolution, AnalysisError>> => {
|
||||
if (target.kind !== "executable" && target.kind !== "database")
|
||||
return ok({ profile: null, compatibility: {} });
|
||||
const derived = hopperLoaderArgsForTarget(target);
|
||||
let machoContainer: "thin" | "fat32" | "fat64" | undefined;
|
||||
if (target.kind === "executable" && target.format === "mach-o") {
|
||||
const header = await readMachoMagic(target.path, options.signal);
|
||||
if (!header.ok) return header;
|
||||
machoContainer = [0xcafebabf, 0xbfbafeca].includes(header.value)
|
||||
? "fat64"
|
||||
: [0xcafebabe, 0xbebafeca].includes(header.value)
|
||||
? "fat32"
|
||||
: "thin";
|
||||
}
|
||||
const derived = hopperLoaderArgsForTarget(target, machoContainer);
|
||||
if (!derived.ok) return derived;
|
||||
const loaderArgs =
|
||||
options.loaderArgsOverride.length === 0
|
||||
|
|
@ -53,6 +67,9 @@ export const resolveHopperAnalysisProfile = async (
|
|||
available_architectures: [
|
||||
...(target.availableArchitectures ?? []),
|
||||
].sort(),
|
||||
...(machoContainer === undefined
|
||||
? {}
|
||||
: { macho_container: machoContainer }),
|
||||
loader: {
|
||||
source:
|
||||
options.loaderArgsOverride.length === 0
|
||||
|
|
@ -71,7 +88,11 @@ export const resolveHopperAnalysisProfile = async (
|
|||
/** Derive Hopper's complete non-interactive CLI loader selection. */
|
||||
export const hopperLoaderArgsForTarget = (
|
||||
target: BinaryTarget,
|
||||
): Result<readonly string[], ProviderAdapterError> => {
|
||||
machoContainer?: "thin" | "fat32" | "fat64",
|
||||
): Result<
|
||||
readonly string[],
|
||||
ProviderAdapterError | AnalysisCapabilityUnavailableError
|
||||
> => {
|
||||
if (target.kind === "database") return ok([]);
|
||||
if (target.kind !== "executable")
|
||||
return err(new ProviderAdapterError("hopper", "resolve_analysis_profile"));
|
||||
|
|
@ -79,8 +100,16 @@ export const hopperLoaderArgsForTarget = (
|
|||
const flag = hopperArchitectureFlag(architecture);
|
||||
switch (target.format) {
|
||||
case "mach-o":
|
||||
if (machoContainer === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"hopper",
|
||||
"resolve_analysis_profile",
|
||||
"Mach-O container kind is unknown; resolve the Hopper analysis profile before starting its client.",
|
||||
),
|
||||
);
|
||||
return ok(
|
||||
(target.availableArchitectures?.length ?? 0) > 1
|
||||
machoContainer === "fat32" || machoContainer === "fat64"
|
||||
? ["-l", "FAT", flag, "-l", "Mach-O"]
|
||||
: ["-l", "Mach-O", flag],
|
||||
);
|
||||
|
|
@ -109,6 +138,46 @@ const hopperArchitectureFlag = (architecture: BinaryArchitecture): string => {
|
|||
}
|
||||
};
|
||||
|
||||
const readMachoMagic = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<number, AnalysisError>> => {
|
||||
if (signalIsAborted(signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const stream = createReadStream(path, { start: 0, end: 3 });
|
||||
const onAbort = (): void => {
|
||||
stream.destroy();
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
||||
if (signalIsAborted(signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const bytes = Buffer.concat(chunks);
|
||||
return bytes.length === 4
|
||||
? ok(bytes.readUInt32BE(0))
|
||||
: err(
|
||||
new BinaryTargetError(
|
||||
path,
|
||||
"Mach-O header became truncated before Hopper loader selection",
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return signalIsAborted(signal)
|
||||
? err(new AnalysisCancelledError("open_binary"))
|
||||
: err(
|
||||
new BinaryTargetError(
|
||||
path,
|
||||
`Cannot read the Mach-O container header: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
|
||||
const sha256File = async (
|
||||
path: string,
|
||||
signal: AbortSignal | undefined,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue