fix: correct native inventories and bridge error classification (#503)

* fix(hopper): preserve normalized inventory addresses

* test(hopper): isolate real inventory verification

* fix(native): preserve otool addresses and section alignment

* fix: preserve address spaces in call path evidence

* fix(hopper): distinguish request validation from provider faults

* fix(native): retain tool inventory facts and reject failed signatures
This commit is contained in:
morluto authored and GitHub committed 2026-10-04 17:25:09 +08:00
1 parent 4fbc501121
commit 405732a7f5
34 files changed
+1009 -161

No files matched your search

+40 -25
View File
@@ -128,6 +128,10 @@ class CapabilityUnavailableError(Exception):
"""The active Hopper build lacks one admitted public API operation."""
class InvalidRequestError(Exception):
"""Caller input failed bridge request or operation validation."""
EXHAUSTIVE_ANALYSIS_METHODS = frozenset(
(
"analyze_function",
@@ -182,8 +186,8 @@ def _document(name=None):
current = api.current_document()
if current is not None and current.getDocumentName() == name:
return current
raise ValueError("Hopper document name is ambiguous")
raise ValueError("Unknown Hopper document")
raise InvalidRequestError("Hopper document name is ambiguous")
raise InvalidRequestError("Unknown Hopper document")
if _selected_document is not None:
for candidate in documents:
if candidate.getDocumentName() == _selected_document:
@@ -193,7 +197,7 @@ def _document(name=None):
return session_document
current = api.current_document()
if current is None:
raise ValueError("No Hopper document is loaded")
raise CapabilityUnavailableError("No Hopper document is loaded")
return current
@@ -202,20 +206,20 @@ def _address(document, value=None):
if value is None:
return document.getCurrentAddress()
if not isinstance(value, str):
raise ValueError("Address must be a string")
raise InvalidRequestError("Address must be a string")
try:
return int(value, 16)
except ValueError:
result = document.getAddressForName(value)
if result in BAD_ADDRESSES:
raise ValueError("Unknown Hopper address or name")
raise InvalidRequestError("Unknown Hopper address or name")
return result
def _segment(document, address):
result = document.getSegmentAtAddress(address)
if result is None:
raise ValueError("Address is outside every segment")
raise InvalidRequestError("Address is outside every segment")
return result
@@ -223,7 +227,7 @@ def _procedure(document, value=None):
address = document.getCurrentAddress() if value is None else _address(document, value)
result = _segment(document, address).getProcedureAtAddress(address)
if result is None:
raise ValueError("No procedure exists at the requested address")
raise InvalidRequestError("No procedure exists at the requested address")
return result
@@ -283,7 +287,7 @@ def _procedure_references(document, params):
procedure = _procedure(document, params.get("procedure"))
direction = params.get("direction", "outgoing")
if direction not in ("incoming", "outgoing"):
raise ValueError("direction must be incoming or outgoing")
raise InvalidRequestError("direction must be incoming or outgoing")
addresses = _instruction_addresses(procedure)
edges = set()
for address in addresses:
@@ -371,13 +375,13 @@ def _search_inventory(document, kind):
def _search_results(document, kind, params):
pattern = params.get("pattern")
if not isinstance(pattern, str) or not pattern:
raise ValueError("pattern must be a non-empty string")
raise InvalidRequestError("pattern must be a non-empty string")
mode = params.get("mode", "literal")
if mode not in ("literal", "regex"):
raise ValueError("mode must be literal or regex")
raise InvalidRequestError("mode must be literal or regex")
case_sensitive = params.get("case_sensitive", False)
if not isinstance(case_sensitive, bool):
raise ValueError("case_sensitive must be a boolean")
raise InvalidRequestError("case_sensitive must be a boolean")
if mode == "literal":
needle = pattern if case_sensitive else pattern.casefold()
@@ -386,7 +390,7 @@ def _search_results(document, kind, params):
try:
expression = re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
except (re.error, OverflowError) as error:
raise ValueError("Invalid regex pattern") from error
raise InvalidRequestError("Invalid regex pattern") from error
matches = lambda value: expression.search(value) is not None
selected = []
@@ -623,9 +627,9 @@ def _dispatch(method, params):
if method == "read_bytes":
length = params.get("length", 256)
if isinstance(length, bool) or not isinstance(length, int):
raise ValueError("Byte-read length must be an integer")
raise InvalidRequestError("Byte-read length must be an integer")
if length < 1:
raise ValueError("Byte-read length must be at least 1")
raise InvalidRequestError("Byte-read length must be at least 1")
reader = getattr(document, "readBytes", None)
if not callable(reader):
raise CapabilityUnavailableError(
@@ -659,7 +663,7 @@ def _dispatch(method, params):
or offset in BAD_ADDRESSES
or offset < 0
):
raise ValueError("Address has no authoritative file-offset mapping")
raise InvalidRequestError("Address has no authoritative file-offset mapping")
return {"address": _hex(address), "file_offset": offset}
if method == "resolve_containing_procedure":
address = _address(document, params.get("address"))
@@ -695,7 +699,7 @@ def _dispatch(method, params):
else:
result = document.getInstructionStart(max(0, target - 1))
if result in BAD_ADDRESSES:
raise ValueError("No adjacent address")
raise InvalidRequestError("No adjacent address")
return _hex(result)
if method == "list_segments":
result = []
@@ -734,14 +738,14 @@ def _dispatch(method, params):
if requested is not None:
key = _hex(_address(document, requested))
values = {key: values[key]} if key in values else {}
return [{"address": _hex(address), "value": value} for address, value in sorted(values.items())]
return [{"address": address, "value": value} for address, value in values.items()]
if method == "list_names":
result = dict(_search_inventory(document, "name"))
requested = params.get("address")
if requested is not None:
key = _hex(_address(document, requested))
result = {key: result[key]} if key in result else {}
return [{"address": _hex(address), "value": value} for address, value in sorted(result.items(), key=lambda item: int(item[0]))]
return [{"address": address, "value": value} for address, value in result.items()]
if method in ("search_procedures", "search_strings"):
kind = "procedure" if method == "search_procedures" else "string"
return _search_results(document, kind, params)
@@ -794,7 +798,7 @@ def _dispatch(method, params):
address = _address(document, params.get("address"))
document.removeBookmarkAtAddress(address)
return not document.hasBookmarkAtAddress(address)
raise ValueError("Unknown bridge method")
raise InvalidRequestError("Unknown bridge method")
def _serve_connection(connection):
@@ -808,13 +812,24 @@ def _serve_connection(connection):
authenticated = False
should_stop = False
try:
request = json.loads(line.decode("utf-8"))
if set(request) != {"id", "token", "method", "params"}:
raise ValueError("Invalid bridge request shape")
try:
request = json.loads(line.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise InvalidRequestError("Invalid bridge request JSON") from error
if not isinstance(request, dict) or set(request) != {"id", "token", "method", "params"}:
raise InvalidRequestError("Invalid bridge request shape")
request_id = request["id"]
if isinstance(request_id, bool) or not isinstance(request_id, int) or request_id < 0:
raise ValueError("Invalid bridge request id")
if not isinstance(request["token"], str) or not hmac.compare_digest(request["token"], REA_TOKEN):
raise InvalidRequestError("Invalid bridge request id")
if (
not isinstance(request["method"], str) or not request["method"]
or not isinstance(request["params"], dict)
):
raise InvalidRequestError("Invalid bridge method or parameters")
if (
not isinstance(request["token"], str) or not request["token"].isascii()
or not hmac.compare_digest(request["token"], REA_TOKEN)
):
raise PermissionError("Invalid bridge capability")
authenticated = True
_write_message(file, {"id": request_id, "event": {
@@ -869,7 +884,7 @@ def _diagnostic_type(error):
return "capability_unavailable"
if isinstance(error, PermissionError):
return "authorization"
if isinstance(error, (ValueError, TypeError, KeyError)):
if isinstance(error, InvalidRequestError):
return "invalid_request"
return "bridge_exception"
+2 -2
View File
@@ -537,9 +537,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "d3d6a83063a35a555ecf939e16383822c948081435ffc020bab23786515cf78d",
"tools_sha256": "5748cec2ff7ec6b44193516a851d8668386d4b9fc679b93d6cc950907a59e213",
"prompts_sha256": "fa889fc671185ac9c968c6dc8309cf609e709cdc837dbbf39b7cdfadb84ac2d7",
"combined_sha256": "fd9ccf3ce95e870cf24abb98e8aca444ec3035de1f358765c5a4e2d1ea280c48",
"combined_sha256": "39c2c4c55c1197e5a8c05c829bf9ba024d2508ed3a59b672f95c5ed19c016a5c",
"providers_sha256": "c5a88a49990fafc8244972ee8ff543fc19995eb79db960da55f6a311e15340d1"
}
}
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_618953f9b8c54b9ee0bee16be32808f286d5ad978769b6ce3975b863ed312078",
"manifest_id": "ecm_e610b840572bebe6ace76b30a850e38eee696e1000e008a9e11ebc841115b9b3",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "1a60aa6554a323eaf600370916889c05b5daa8a7b5e4195e9055118bb5985212"
"sha256": "b1220b8f956506c2347c2c165e10cb3d2e557c23fab8d1ad0f95428a5d9fb184"
}
],
"claims": [
+47
View File
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { HOPPER_PROVIDER_IDENTITY } from "../../dist/hopper/HopperProvider.js";
import {
requireMcpResult,
requireEvidenceProvider,
} from "./mcp-verifier-results.mjs";
/** Verify real Hopper inventories and address filters through the MCP transport. */
export async function verifyHopperInventories(client, options) {
const inventoryCounts = {};
for (const operation of ["list_strings", "list_names"]) {
const called = await client.callTool(
{ name: operation, arguments: {} },
options,
);
const inventory = requireMcpResult(called, operation);
requireEvidenceProvider(called, operation, HOPPER_PROVIDER_IDENTITY.id);
assert.ok(
Array.isArray(inventory) && inventory.length > 0,
`${operation} omitted the real fixture inventory`,
);
let previous = -1n;
for (const item of inventory) {
assert.match(item.address, /^0x[0-9a-f]+$/u);
assert.equal(typeof item.value, "string");
const address = BigInt(item.address);
assert.ok(
address > previous,
`${operation} is not numerically address-sorted`,
);
previous = address;
}
const filtered = requireMcpResult(
await client.callTool(
{
name: operation,
arguments: { address: inventory[0].address },
},
options,
),
operation,
);
assert.deepEqual(filtered, [inventory[0]]);
inventoryCounts[operation] = inventory.length;
}
return inventoryCounts;
}
+3
View File
@@ -32,6 +32,7 @@ import { verifyHopperFunctionBasics } from "./lib/real-hopper-function-basics.mj
import { openAndVerifyLargeFixture } from "./lib/real-hopper-exhaustive-search.mjs";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { requireHopperSelection } from "./lib/real-hopper-selection.mjs";
import { verifyHopperInventories } from "./lib/real-hopper-inventory.mjs";
import { startUnrelatedHopperSentinel } from "./lib/unrelated-hopper-sentinel.mjs";
const execFileAsync = promisify(execFile);
const verifierRun = createVerifierRun();
@@ -310,6 +311,7 @@ try {
options,
);
requireSuccessfulTool(overview, "binary_overview");
const inventoryCounts = await verifyHopperInventories(client, options);
requireEvidenceProvider(
documents,
"list_documents",
@@ -442,6 +444,7 @@ try {
segmentCount: firstOverview.segment_count,
analyses: [firstAnalysis, secondAnalysis],
fixtureAnalysis,
inventoryCounts,
largeInventory,
fixtureManifest: fixtureTargets.manifestPath,
bundledMcpRunning,
+1 -1
View File
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
metadata:
version: "23"
tool_count: 122
catalog_digest: "fd9ccf3ce95e870cf24abb98e8aca444ec3035de1f358765c5a4e2d1ea280c48"
catalog_digest: "39c2c4c55c1197e5a8c05c829bf9ba024d2508ed3a59b672f95c5ed19c016a5c"
---
# REA
+20
View File
@@ -147,6 +147,26 @@ describe("call path reconstruction", () => {
);
});
it("preserves address spaces and encoded names when normalizing offsets", () => {
const result = run(
[
observe("0x1000", ["EXTERNAL:0x001000"]),
observe("EXTERNAL:0x1000", ["overlay%3Atext:0x00004000"]),
],
{
goal: { address: "overlay%3Atext:0X004000" },
},
);
expect(
result.paths.map((path) => path.nodes.map(({ address }) => address)),
).toEqual([["0x1000", "EXTERNAL:0x1000", "overlay%3Atext:0x4000"]]);
expect(() =>
run([observe("0x1000", [])], {
goal: { address: "overlay%ZZ:0x4000" },
}),
).toThrow();
});
it("requires an observed start function", () => {
expect(() => run([observe("0x2000", [])])).toThrow(
/supplied for start 0x1000/u,
+12 -4
View File
@@ -2,14 +2,22 @@ import { z } from "zod";
import { evidenceSchema } from "./evidence.js";
const addressSchema = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]*)$/u);
const addressSchema = z
.string()
.regex(/^(?:(?:[A-Za-z0-9._~-]|%[0-9A-F]{2})+:)?0x(?:0|[1-9a-f][0-9a-f]*)$/u);
const inputAddressSchema = z
.string()
.regex(/^0[xX][0-9a-fA-F]+$/u)
.transform((address) => `0x${BigInt(address).toString(16)}`);
.regex(/^(?:(?:[A-Za-z0-9._~-]|%[0-9a-fA-F]{2})+:)?0[xX][0-9a-fA-F]+$/u)
.transform((address) => {
const separator = address.lastIndexOf(":") + 1;
const space = address
.slice(0, separator)
.replace(/%[0-9a-fA-F]{2}/gu, (escape) => escape.toUpperCase());
return `${space}0x${BigInt(address.slice(separator)).toString(16)}`;
});
const evidenceIdSchema = z.string().regex(/^ev_[a-f0-9]{64}$/u);
/** Parse a caller-supplied hexadecimal address into canonical form. */
/** Normalize a hexadecimal offset while preserving its optional address space. */
export const parseCallPathAddress = (input: unknown): string =>
inputAddressSchema.parse(input);
+1 -1
View File
@@ -30,7 +30,7 @@ interface GeneratedPayload {
}
const GENERATED_PAYLOAD_JSON =
'{"auxiliaryProviders":[{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inventory_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"extract_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"decode_interface_builder","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_keyed_archive","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_asset_catalog","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null}],"identity":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"observe_native_ui","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":true,"mayShowUi":true,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"capture_native_ui_scenario","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"Line truncated
'{"auxiliaryProviders":[{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inventory_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"extract_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"decode_interface_builder","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_keyed_archive","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_asset_catalog","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null}],"identity":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"observe_native_ui","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":true,"mayShowUi":true,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"capture_native_ui_scenario","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"Line truncated
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
if (!isGeneratedPayload(generatedPayload))
throw new TypeError("Generated MCP catalog payload is invalid");
+16 -5
View File
@@ -330,23 +330,34 @@ class NativeMacOSClient implements AnalysisClient {
{ signal, acceptNonZero: true },
);
if (!display.ok) return display;
const unsigned = /not signed at all|code object is not signed/iu.test(
`${display.value.stdout}\n${display.value.stderr}`,
);
if (!unsigned && display.value.exitCode !== 0)
return err(
translateCommandFailure(
"inspect_signature",
new NativeCommandFailure(
"codesign",
"nonzero-exit",
display.value.exitCode,
),
),
);
const requirements = await this.#run(
"inspect_signature",
"codesign",
["-d", "-r-", this.target.path],
{ signal, acceptNonZero: true },
{ signal, acceptNonZero: unsigned },
);
if (!requirements.ok) return requirements;
const entitlements = await this.#run(
"inspect_signature",
"codesign",
["-d", "--entitlements", ":-", this.target.path],
{ signal, acceptNonZero: true },
{ signal, acceptNonZero: unsigned },
);
if (!entitlements.ok) return entitlements;
const unsigned = /not signed at all|code object is not signed/iu.test(
`${display.value.stdout}\n${display.value.stderr}`,
);
const parsed = parseCodeSignature(
`${display.value.stdout}\n${display.value.stderr}`,
unsigned,
+13 -1
View File
@@ -89,9 +89,14 @@ const normalizeMacho = (
const architectures = parseLipoArchitectures(byTool("lipo").stdout);
const load = parseOtoolLoadCommands(byTool("otool").stdout);
const imports = parseDyldSymbols(byTool("dyld_info", 0).stdout, "imports");
const imageBase =
load.segments.find(
(segment) => segment.file_offset === 0 && (segment.file_size ?? 0) > 0,
)?.vm_address ?? null;
const dyldExports = parseDyldSymbols(
byTool("dyld_info", 1).stdout,
"exports",
imageBase,
);
const exports = uniqueSymbols([
...dyldExports,
@@ -109,6 +114,12 @@ const normalizeMacho = (
]
: []),
...additionalLimitations,
...(imageBase === null &&
/^\s*offset\s+symbol\s*$/mu.test(byTool("dyld_info", 1).stdout)
? [
"Export virtual addresses are unavailable because no file-backed image base was observed.",
]
: []),
];
return inspectMachoSchema.parse({
format: "mach-o",
@@ -185,7 +196,8 @@ const uniqueSymbols = <Value extends { readonly name: string }>(
items: readonly Value[],
): Value[] => {
const unique = new Map<string, Value>();
for (const item of items) unique.set(item.name, item);
for (const item of items)
if (!unique.has(item.name)) unique.set(item.name, item);
return [...unique.values()].sort((left, right) =>
left.name.localeCompare(right.name),
);
+61 -22
View File
@@ -1,29 +1,68 @@
/** Parse bounded imports/exports from dyld_info's line-oriented tables. */
export const parseDyldSymbols = (output: string, mode: "imports" | "exports") =>
output.split(/\r?\n/u).flatMap((rawLine) => {
interface ParsedDyldSymbol {
readonly name: string;
readonly address: string | null;
readonly weak: boolean | null;
readonly reexport: boolean | null;
readonly source: string | null;
}
/** Decode Apple dyld inventory rows, projecting export offsets to VM addresses. */
export const parseDyldSymbols = (
output: string,
mode: "imports" | "exports",
imageBase: string | null = null,
) => {
const offsets = /^\s*offset\s+symbol\s*$/mu.test(output);
return output.split(/\r?\n/u).flatMap<ParsedDyldSymbol>((rawLine) => {
const line = rawLine.trim();
if (
line.length === 0 ||
/^(?:imports|exports|binding|address|segment|ordinal)\b/iu.test(line)
)
if (mode === "imports") {
const imported =
/^(?:0x[\da-f]+\s+)?(\S+)(?:\s+\[([^\]]+)\])?\s+\(from (.+)\)$/iu.exec(
line,
);
if (imported?.[1] !== undefined)
return [
{
name: imported[1],
address: null,
weak: /\bweak-import\b/u.test(imported[2] ?? "") ? true : null,
reexport: null,
source: imported[3] ?? null,
},
];
return [];
const tokens = line.split(/\s+/u);
const name = tokens.at(-1);
if (name === undefined || !/^(?:_|\$s|objc_|swift_)/u.test(name)) return [];
const addressToken = tokens.find((token) =>
/^0x[a-fA-F0-9]+$/u.test(token),
);
}
const reexport = /^\[re-export\]\s+(\S+)(?:\s+\(from (.+)\))?$/u.exec(line);
if (reexport?.[1] !== undefined)
return [
{
name: reexport[1],
address: null,
weak: null,
reexport: true,
source: reexport[2] ?? null,
},
];
const exported = /^(0x[\da-f]+)\s+(\S+)(?:\s+\[([^\]]+)\])?$/iu.exec(line);
if (exported?.[1] === undefined || exported[2] === undefined) return [];
const absolute = /\babsolute\b/u.test(exported[3] ?? "");
const address =
offsets && !absolute
? imageBase === null
? null
: `0x${(BigInt(imageBase) + BigInt(exported[1])).toString(16)}`
: canonicalHex(exported[1]);
return [
{
name,
address: addressToken ?? null,
weak: /\bweak\b/iu.test(line) ? true : null,
reexport: /\bre-?export\b/iu.test(line)
? true
: mode === "exports"
? false
: null,
source: mode === "imports" ? (tokens.at(-2) ?? null) : null,
name: exported[2],
address,
weak: /\bweak-def\b/u.test(exported[3] ?? "") ? true : null,
reexport: false,
source: null,
},
];
});
};
const canonicalHex = (value: string | undefined): string | null =>
value === undefined ? null : `0x${BigInt(value).toString(16)}`;
+9 -5
View File
@@ -207,7 +207,7 @@ const parseSections = (block: string): ParsedSection[] =>
address: hexField(fields, "addr"),
size: numberField(fields, "size"),
file_offset: numberField(fields, "offset"),
alignment: sectionAlignment(numberField(fields, "align")),
alignment: sectionAlignment(stringField(fields, "align")),
flags: (stringField(fields, "flags") ?? "")
.split(/\s+/u)
.filter((flag) => flag.length > 0),
@@ -285,12 +285,16 @@ const hexField = (
fields: Readonly<Record<string, string | number | null>>,
name: string,
): string | null => {
const value = numberField(fields, name);
return value === null ? null : `0x${value.toString(16)}`;
const value = stringField(fields, name);
if (value === null || !/^(?:0x[a-fA-F0-9]+|\d+)$/u.test(value)) return null;
return `0x${BigInt(value).toString(16)}`;
};
const stripOffsetSuffix = (value: string | null): string =>
value?.replace(/\s+\(offset\s+\d+\)$/u, "") ?? "unknown";
const sectionAlignment = (exponent: number | null): number | null =>
exponent === null || exponent > 52 ? null : 2 ** exponent;
const sectionAlignment = (raw: string | null): number | null => {
if (raw === null) return null;
const exponent = numeric(/^2\^(\d+)(?:\s+\(\d+\))?$/u.exec(raw)?.[1] ?? raw);
return exponent === null || exponent > 52 ? null : 2 ** exponent;
};
@@ -56,15 +56,17 @@ it("aggregates comparison Evidence and records an approved runtime gap", async (
}
}, 10_000);
it("builds a zero-hop exact-address path with dossier citations", async () => {
it("builds exact-address paths with qualified callees and dossier citations", async () => {
const { session, server, client } = await connected();
expect(session.recordEvidence(FUNCTION_COMPARISON_EXAMPLE.left).ok).toBe(
true,
);
const dossier = dossierWithCallees(["EXTERNAL:0x1000"]);
expect(session.recordEvidence(dossier).ok).toBe(true);
try {
const response = await client.callTool({
name: "build_call_path",
arguments: INVESTIGATION_EXAMPLES.build_call_path,
arguments: {
...INVESTIGATION_EXAMPLES.build_call_path,
functions: [dossier],
},
});
expect(response.isError).not.toBe(true);
const evidence = inlineEvidence(response.structuredContent);
@@ -75,18 +77,43 @@ it("builds a zero-hop exact-address path with dossier citations", async () => {
shortest_hops: 0,
paths: [expect.objectContaining({ hops: 0 })],
},
evidence_links: [FUNCTION_COMPARISON_EXAMPLE.left.evidence_id],
evidence_links: [dossier.evidence_id],
});
const externalPath = await client.callTool({
name: "build_call_path",
arguments: {
functions: [dossier],
start: { address: "0x1000" },
goal: { address: "EXTERNAL:0X001000" },
},
});
expect(externalPath.isError, JSON.stringify(externalPath)).not.toBe(true);
expect(inlineEvidence(externalPath.structuredContent)).toMatchObject({
normalized_result: {
status: "found",
shortest_hops: 1,
paths: [
{
nodes: [
{ address: "0x1000", evidence_links: [dossier.evidence_id] },
{
address: "EXTERNAL:0x1000",
evidence_links: [dossier.evidence_id],
},
],
},
],
},
});
} finally {
await close(session, server, client);
}
});
it("records a safe unresolved call-path question", async () => {
const { session, server, client } = await connected();
const dossierWithCallees = (addresses: readonly string[]) => {
const base = FUNCTION_COMPARISON_EXAMPLE.left;
if (base.subject === null) throw new Error("missing dossier subject");
const dossier = createEvidence(
return createEvidence(
{
path: base.subject.local_path,
sha256: base.subject.digest.sha256,
@@ -102,7 +129,7 @@ it("records a safe unresolved call-path question", async () => {
parameters: base.parameters,
result: jsonValueSchema.parse({
...jsonObjectSchema.parse(base.normalized_result),
callees: [{ address: "0x2000", name: "next" }],
callees: addresses.map((address) => ({ address, name: "next" })),
}),
rawResult: base.raw_result,
confidence: base.confidence,
@@ -112,6 +139,11 @@ it("records a safe unresolved call-path question", async () => {
evidenceLinks: base.evidence_links,
},
);
};
it("records a safe unresolved call-path question", async () => {
const { session, server, client } = await connected();
const dossier = dossierWithCallees(["0x2000"]);
expect(session.recordEvidence(dossier).ok).toBe(true);
try {
const response = await client.callTool({
@@ -22,6 +22,25 @@ const probeResultSchema = z.strictObject({
current_document: z.literal("fixture"),
current_address: z.literal("0x401000"),
strings: z.strictObject({ "0x401234": z.literal("fixture string") }),
inventory_replies: z.array(
z.strictObject({
id: z.number().int(),
result: z.array(
z.strictObject({ address: z.string(), value: z.string() }),
),
}),
),
provider_faults: z.array(
z.strictObject({
id: z.literal(1),
error: z.strictObject({
code: z.literal(-32000),
message: z.string(),
type: z.literal("bridge_exception"),
}),
}),
),
malformed_requests: z.array(z.unknown()),
session_document_reused: z.literal(true),
shared_document_shutdown: z.strictObject({
shutdown: z.literal(true),
@@ -87,6 +106,52 @@ describe("Hopper API facade", () => {
},
);
const result = probeResultSchema.parse(JSON.parse(stdout));
expect(result.provider_faults.map((reply) => reply.error.message)).toEqual([
"TypeError: Hopper bridge operation failed",
"ValueError: Hopper bridge operation failed",
"KeyError: Hopper bridge operation failed",
]);
expect(result.malformed_requests).toEqual([
...[0, 0, 0, 0, 0, 2, 3, 0, 4, 5, 6, 7].map((id) => ({
id,
error: {
code: -32000,
type: "invalid_request",
message: "Invalid Hopper bridge request",
},
})),
{
id: 8,
error: {
code: -32000,
type: "authorization",
message: "Invalid bridge capability",
},
},
{ id: 9, result: "0x401000" },
]);
expect(result.inventory_replies).toEqual([
{
id: 1,
result: [
{ address: "0x2", value: "string-2" },
{ address: "0x10", value: "string-16" },
{ address: "0x100", value: "string-256" },
],
},
{
id: 2,
result: [
{ address: "0x2", value: "name-2" },
{ address: "0x10", value: "name-16" },
{ address: "0x100", value: "name-256" },
],
},
{ id: 3, result: [{ address: "0x10", value: "string-16" }] },
{ id: 4, result: [{ address: "0x10", value: "name-16" }] },
{ id: 5, result: [] },
{ id: 6, result: [] },
]);
expect(stdout).not.toContain("supersecret");
expect(result.analysis_guard.message).toContain(
"requires completed Hopper background analysis",
@@ -0,0 +1,117 @@
import { expect, it } from "vitest";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import { parseDyldSymbols } from "../../../../src/native/parsers/dyldInfo.js";
import {
NativeFixtureRunner as FixtureRunner,
nativeFixture as fixture,
nativeMachoTarget as machoTarget,
} from "../../../fixtures/nativeCommands.js";
it("distinguishes a codesign execution failure from an observed unsigned artifact", async () => {
for (const kind of ["missing", "unsigned"] as const) {
const client = new NativeMacOSProvider(
new FixtureRunner(
{
codesign: await fixture(`dyld-inventory/codesign-${kind}.txt`),
},
1,
),
"darwin",
).createClient(machoTarget("/private/fixture"));
const execution = await client.execute("inspect_signature", {});
if (kind === "missing")
expect(execution).toMatchObject({
ok: false,
error: { _tag: "ProviderAdapterError" },
});
else {
expect(execution.ok).toBe(true);
if (execution.ok)
expect(execution.value.result).toMatchObject({ signed: false });
}
}
});
it("retains native inventory facts from captured Apple tool output", async () => {
const outputs: Record<string, string> = {};
for (const tool of ["file", "lipo", "otool", "nm", "dwarfdump", "vtool"])
outputs[tool] = await fixture(`dyld-inventory/${tool}.txt`);
outputs["dyld_info:-imports"] = await fixture("dyld-inventory/imports.txt");
const exportsOutput = await fixture("dyld-inventory/exports.txt");
outputs["dyld_info:-exports"] = exportsOutput;
const client = new NativeMacOSProvider(
new FixtureRunner(outputs),
"darwin",
).createClient(machoTarget("/private/fixture"));
const execution = await client.execute("inspect_macho", {});
expect(execution.ok).toBe(true);
if (!execution.ok) return;
expect(execution.value.result).toMatchObject({
imports: {
items: [
{ name: "_puts", address: null, source: "libSystem" },
{
name: "_fixture_weak",
address: null,
source: "<weak-def-coalesce>",
},
],
},
exports: {
items: expect.arrayContaining([
expect.objectContaining({
name: "_fixture_export",
address: "0x100000468",
}),
expect.objectContaining({
name: "_fixture_weak",
address: "0x100000460",
weak: true,
}),
expect.objectContaining({
name: "_fixture_absolute",
address: "0x42",
}),
]),
},
});
expect(
parseDyldSymbols(await fixture("dyld-imports.txt"), "imports"),
).toEqual([
{
name: "__Block_copy",
address: null,
weak: null,
reexport: null,
source: "libSystem",
},
{
name: "_objc_bp_assist_cfg_np",
address: null,
weak: true,
reexport: null,
source: "libSystem",
},
]);
expect(
parseDyldSymbols(
await fixture("dyld-inventory/library-reexports.txt"),
"exports",
),
).toEqual([
{
name: "__ZNKSt10bad_typeid4whatEv",
address: null,
weak: null,
reexport: true,
source: "libc++abi",
},
]);
expect(parseDyldSymbols(exportsOutput, "exports")).toContainEqual({
name: "_fixture_export",
address: null,
weak: null,
reexport: false,
source: null,
});
});
@@ -1,4 +1,4 @@
import { mkdir, rm, writeFile, readFile } from "node:fs/promises";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -9,13 +9,17 @@ import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.
import {
NativeCommandFailure,
XcrunCommandRunner,
type NativeCommandCapture,
type NativeCommandRunner,
} from "../../../../src/native/CommandRunner.js";
import type { BinaryTarget } from "../../../../src/domain/binaryTarget.js";
import { err, ok } from "../../../../src/domain/result.js";
import { parseLipoArchitectures } from "../../../../src/native/parsers/lipo.js";
import {
NativeFixtureRunner as FixtureRunner,
nativeFixture as fixture,
nativeMachoTarget as machoTarget,
} from "../../../fixtures/nativeCommands.js";
let directory: string | undefined;
afterEach(async () => {
if (directory !== undefined)
@@ -115,6 +119,42 @@ describe("native macOS provider inspection", () => {
});
});
it("preserves 64-bit addresses and alignment from captured otool output", async () => {
const runner = new FixtureRunner({
otool: await fixture("otool-high-address.txt"),
});
const client = new NativeMacOSProvider(runner, "darwin").createClient(
machoTarget("/private/fixture"),
);
const execution = await client.execute("inspect_macho", {});
expect(execution.ok).toBe(true);
if (!execution.ok) return;
expect(execution.value.result).toMatchObject({
segments: {
items: [
{
vm_address: "0xfffffff007004001",
vm_size: 4096,
file_offset: 0,
file_size: 185,
sections: {
items: [
{
address: "0xfffffff0070040b9",
size: 1,
file_offset: 184,
alignment: 1,
},
],
},
},
],
},
});
});
it("keeps Mach-O inspection available when optional vtool is unavailable", async () => {
const client = new NativeMacOSProvider(
new VtoolFailingRunner("unavailable"),
@@ -267,17 +307,6 @@ describe("native macOS provider failures and parsing", () => {
});
});
class FixtureRunner implements NativeCommandRunner {
constructor(
private readonly overrides: Readonly<Record<string, string>> = {},
) {}
async run(tool: string, arguments_: readonly string[]) {
const output = this.overrides[tool] ?? (await outputFor(tool, arguments_));
return ok(capture(tool, arguments_, output));
}
}
class FailingRunner implements NativeCommandRunner {
constructor(private readonly reason: NativeCommandFailure["reason"]) {}
@@ -306,68 +335,3 @@ class CountingRunner implements NativeCommandRunner {
return Promise.resolve(err(new NativeCommandFailure(tool, "unavailable")));
}
}
const outputFor = async (
tool: string,
arguments_: readonly string[],
): Promise<string> => {
if (tool === "lipo") return fixture("lipo-fat.txt");
if (tool === "otool") return fixture("otool-load.txt");
if (tool === "nm") return "_main\n_$s4Test3fooyyF\n";
if (tool === "dyld_info")
return fixture(
arguments_[0] === "-imports" ? "dyld-imports.txt" : "dyld-exports.txt",
);
if (tool === "dwarfdump")
return "UUID: 01234567-89AB-CDEF-0123-456789ABCDEF (arm64) fixture\n";
if (tool === "file")
return arguments_.at(-1)?.endsWith(".plist") === true
? "XML 1.0 document text\n"
: "Mach-O 64-bit executable arm64, little-endian\n";
if (tool === "vtool") return "Load command 3 LC_BUILD_VERSION\n";
if (tool === "swift-demangle") return fixture("demangle.txt");
if (tool === "plutil") return fixture("plist.json");
if (tool === "codesign") {
if (arguments_.includes("--entitlements"))
return fixture("entitlements.xml");
if (arguments_.includes("-r-"))
return "designated => identifier com.example.fixture\n";
return fixture("codesign.txt");
}
throw new Error(`Unexpected fixture tool ${tool}`);
};
const capture = (
tool: string,
arguments_: readonly string[],
output: string,
): NativeCommandCapture => ({
tool,
executable: `/usr/bin/${tool}`,
executableSha256: "a".repeat(64),
toolVersion: null,
versionReason: "fixture",
arguments: [...arguments_],
stdout: tool === "codesign" ? "" : output,
stderr: tool === "codesign" ? output : "",
stdoutBytes: Buffer.byteLength(tool === "codesign" ? "" : output),
stderrBytes: Buffer.byteLength(tool === "codesign" ? output : ""),
exitCode: 0,
signal: null,
});
const machoTarget = (path: string, sourcePath?: string): BinaryTarget => ({
path,
...(sourcePath === undefined ? {} : { sourcePath }),
sha256: "0".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"],
});
const fixture = (name: string): Promise<string> =>
readFile(
new URL(`../../../fixtures/native-macos/${name}`, import.meta.url),
"utf8",
);
+91 -1
View File
@@ -10,6 +10,7 @@ import threading
class FakeDocument:
def __init__(self):
self.analysis_active = True
self.address_error = None
def getDocumentName(self):
return "fixture"
@@ -24,8 +25,13 @@ class FakeDocument:
return self.analysis_active
def getCurrentAddress(self):
if self.address_error is not None:
raise self.address_error
return 0x401000
def getSegmentsList(self):
return [FakeInventorySegment()]
class FakeDocumentProvider:
document = FakeDocument()
@@ -47,8 +53,31 @@ class FakeStringSegment:
class FakeStringAddress:
def __init__(self, value="0x401234"):
self.value = value
def __str__(self):
return "0x401234"
return self.value
class FakeInventorySegment:
addresses = [FakeStringAddress("0x10"), 0x100, 0x2]
def getStringsList(self):
return [
("string-" + str(self.number(address)), address)
for address in self.addresses
]
def getNamedAddresses(self):
return self.addresses
def getNameAtAddress(self, address):
return "name-" + str(self.number(address))
@staticmethod
def number(address):
return address if isinstance(address, int) else int(str(address), 16)
class FakeStringsDocument:
@@ -66,6 +95,35 @@ def load_bridge(path):
return namespace
def bridge_replies(bridge, requests):
server_socket, client_socket = socket.socketpair()
worker = threading.Thread(
target=bridge["_serve_connection"], args=(server_socket,)
)
worker.start()
replies = []
client_file = client_socket.makefile("rwb")
try:
for request in requests:
line = request if isinstance(request, bytes) else json.dumps(request).encode("utf-8")
client_file.write(line + b"\n")
client_file.flush()
while True:
response = json.loads(client_file.readline().decode("utf-8"))
if "event" not in response:
replies.append(response)
break
finally:
client_file.close()
client_socket.close()
worker.join(timeout=1)
return replies
def request(method, params, request_id=1):
return {"id": request_id, "token": "probe-token", "method": method, "params": params}
def main():
bridge = load_bridge(sys.argv[1])
unavailable = None
@@ -98,6 +156,35 @@ def main():
}
bridge["REA_TOKEN"] = "probe-token"
FakeDocumentProvider.document.analysis_active = False
FakeDocumentProvider.current.analysis_active = False
inventories = bridge_replies(bridge, [
request(method, params, index)
for index, (method, params) in enumerate([
("list_strings", {}),
("list_names", {}),
("list_strings", {"address": "0x10"}),
("list_names", {"address": "0x10"}),
("list_strings", {"address": "0xff"}),
("list_names", {"address": "0xff"}),
], 1)
])
provider_faults = []
for error_type in (TypeError, ValueError, KeyError):
FakeDocumentProvider.current.address_error = error_type("credential=supersecret")
provider_faults.extend(bridge_replies(bridge, [request("current_address", {})]))
FakeDocumentProvider.current.address_error = None
malformed_requests = bridge_replies(bridge, [
b"not-json", b"\xff", None, 42, [],
request([], {}, 2), request("current_address", [], 3),
request("current_address", {}, True),
request("list_strings", {"address": True}, 4),
request("read_bytes", {"length": True}, 5),
request("search_strings", {"pattern": "a", "case_sensitive": 1}, 6),
request("unknown_method", {}, 7),
{**request("current_address", {}, 8), "token": "\u2603"},
request("current_address", {}, 9),
])
bridge["_dispatch"] = lambda method, params: (
(_ for _ in ()).throw(RuntimeError("credential=supersecret"))
if method == "fail"
@@ -132,6 +219,9 @@ def main():
"current_document": current,
"current_address": current_address,
"strings": strings,
"inventory_replies": inventories,
"provider_faults": provider_faults,
"malformed_requests": malformed_requests,
"session_document_reused": selected,
"shared_document_shutdown": retained,
"analysis_guard": analysis_guard,
+4 -3
View File
@@ -1,3 +1,4 @@
imports:
0x100001100 libSystem.B.dylib _malloc
0x100001108 libobjc.A.dylib _objc_msgSend weak
/usr/lib/libobjc.A.dylib [arm64e]:
-imports:
__Block_copy (from libSystem)
_objc_bp_assist_cfg_np [weak-import] (from libSystem)
+43
View File
@@ -0,0 +1,43 @@
# Apple native inventory captures
These outputs were captured from the installed Apple tools on macOS arm64.
The executable's filename in tool output is replaced with `fixture`. Import
hexadecimal indexes are binding ordinals, while the export table contains image
offsets. The absolute export is not relative to the image base. `nm` also reports
each exported name, exercising the merge that must preserve richer dyld facts.
Generate the executable locally; do not check its binary into the repository:
```sh
cat > /tmp/rea-inventory.c <<'C'
#include <stdio.h>
__attribute__((weak)) int fixture_weak(void) { return 2; }
int fixture_export(void) { return 1; }
__asm__(".globl _fixture_absolute\n.set _fixture_absolute, 0x42");
int main(void) { puts("fixture"); return fixture_export() + fixture_weak(); }
C
xcrun clang /tmp/rea-inventory.c -o /tmp/rea-inventory
xcrun file -b /tmp/rea-inventory
xcrun lipo -detailed_info /tmp/rea-inventory
xcrun otool -l /tmp/rea-inventory
xcrun nm -gjU /tmp/rea-inventory
xcrun dyld_info -imports /tmp/rea-inventory
xcrun dyld_info -exports /tmp/rea-inventory
xcrun dwarfdump --uuid /tmp/rea-inventory
xcrun vtool -show-build /tmp/rea-inventory
```
Addresses, UUIDs and build versions may vary with the installed toolchain; the
checked-in captures are the golden input. The test checks the virtual addresses
corresponding to those captured offsets and image base.
`../dyld-imports.txt` contains verbatim header and representative ordinary/weak
import rows from `xcrun dyld_info -imports /usr/lib/libobjc.A.dylib`.
`library-reexports.txt` contains the header and one reexport row from
`xcrun dyld_info -exports /usr/lib/libc++.1.dylib`. These are separate library
captures used to check their respective row formats.
`codesign-missing.txt` is stderr from `codesign -d --verbose=4` on a nonexistent
path (exit 1). `codesign-unsigned.txt` is stderr from the same command on a copy
of the fixture after `codesign --remove-signature` (exit 1). Only the latter is
an observation that the artifact is unsigned.
@@ -0,0 +1 @@
/tmp/does-not-exist-rea-types: No such file or directory
@@ -0,0 +1 @@
/tmp/rea-native-types/unsigned: code object is not signed at all
@@ -0,0 +1 @@
UUID: DD511BAA-DF23-31EA-A44A-0E09FC69C471 (arm64) fixture
@@ -0,0 +1,8 @@
fixture [arm64]:
-exports:
offset symbol
0x00000000 __mh_execute_header
0x00000042 _fixture_absolute [absolute]
0x00000468 _fixture_export
0x00000460 _fixture_weak [weak-def]
0x00000470 _main
+1
View File
@@ -0,0 +1 @@
Mach-O 64-bit executable arm64
@@ -0,0 +1,4 @@
fixture [arm64]:
-imports:
0x0000 _puts (from libSystem)
0x0001 _fixture_weak (from <weak-def-coalesce>)
@@ -0,0 +1,4 @@
/usr/lib/libc++.1.dylib [arm64e]:
-exports:
offset symbol
[re-export] __ZNKSt10bad_typeid4whatEv (from libc++abi)
+2
View File
@@ -0,0 +1,2 @@
input file fixture is not a fat file
Non-fat file: fixture is architecture: arm64
+5
View File
@@ -0,0 +1,5 @@
__mh_execute_header
_fixture_absolute
_fixture_export
_fixture_weak
_main
+195
View File
@@ -0,0 +1,195 @@
fixture:
Load command 0
cmd LC_SEGMENT_64
cmdsize 72
segname __PAGEZERO
vmaddr 0x0000000000000000
vmsize 0x0000000100000000
fileoff 0
filesize 0
maxprot 0x00000000
initprot 0x00000000
nsects 0
flags 0x0
Load command 1
cmd LC_SEGMENT_64
cmdsize 392
segname __TEXT
vmaddr 0x0000000100000000
vmsize 0x0000000000004000
fileoff 0
filesize 16384
maxprot 0x00000005
initprot 0x00000005
nsects 4
flags 0x0
Section
sectname __text
segname __TEXT
addr 0x0000000100000460
size 0x0000000000000050
offset 1120
align 2^2 (4)
reloff 0
nreloc 0
flags 0x80000400
reserved1 0
reserved2 0
Section
sectname __stubs
segname __TEXT
addr 0x00000001000004b0
size 0x0000000000000018
offset 1200
align 2^2 (4)
reloff 0
nreloc 0
flags 0x80000408
reserved1 0 (index into indirect symbol table)
reserved2 12 (size of stubs)
Section
sectname __cstring
segname __TEXT
addr 0x00000001000004c8
size 0x0000000000000008
offset 1224
align 2^0 (1)
reloff 0
nreloc 0
flags 0x00000002
reserved1 0
reserved2 0
Section
sectname __unwind_info
segname __TEXT
addr 0x00000001000004d0
size 0x0000000000000060
offset 1232
align 2^2 (4)
reloff 0
nreloc 0
flags 0x00000000
reserved1 0
reserved2 0
Load command 2
cmd LC_SEGMENT_64
cmdsize 152
segname __DATA_CONST
vmaddr 0x0000000100004000
vmsize 0x0000000000004000
fileoff 16384
filesize 16384
maxprot 0x00000003
initprot 0x00000003
nsects 1
flags 0x10
Section
sectname __got
segname __DATA_CONST
addr 0x0000000100004000
size 0x0000000000000010
offset 16384
align 2^3 (8)
reloff 0
nreloc 0
flags 0x00000006
reserved1 2 (index into indirect symbol table)
reserved2 0
Load command 3
cmd LC_SEGMENT_64
cmdsize 72
segname __LINKEDIT
vmaddr 0x0000000100008000
vmsize 0x0000000000004000
fileoff 32768
filesize 824
maxprot 0x00000001
initprot 0x00000001
nsects 0
flags 0x0
Load command 4
cmd LC_DYLD_CHAINED_FIXUPS
cmdsize 16
dataoff 32768
datasize 112
Load command 5
cmd LC_DYLD_EXPORTS_TRIE
cmdsize 16
dataoff 32880
datasize 96
Load command 6
cmd LC_SYMTAB
cmdsize 24
symoff 32984
nsyms 6
stroff 33096
strsize 88
Load command 7
cmd LC_DYSYMTAB
cmdsize 80
ilocalsym 0
nlocalsym 0
iextdefsym 0
nextdefsym 5
iundefsym 5
nundefsym 1
tocoff 0
ntoc 0
modtaboff 0
nmodtab 0
extrefsymoff 0
nextrefsyms 0
indirectsymoff 33080
nindirectsyms 4
extreloff 0
nextrel 0
locreloff 0
nlocrel 0
Load command 8
cmd LC_LOAD_DYLINKER
cmdsize 32
name /usr/lib/dyld (offset 12)
Load command 9
cmd LC_UUID
cmdsize 24
uuid DD511BAA-DF23-31EA-A44A-0E09FC69C471
Load command 10
cmd LC_BUILD_VERSION
cmdsize 32
platform 1
minos 26.0
sdk 26.5
ntools 1
tool 3
version 1267.0
Load command 11
cmd LC_SOURCE_VERSION
cmdsize 16
version 0.0
Load command 12
cmd LC_MAIN
cmdsize 24
entryoff 1136
stacksize 0
Load command 13
cmd LC_LOAD_DYLIB
cmdsize 56
name /usr/lib/libSystem.B.dylib (offset 24)
time stamp 2 Thu Jan 1 08:00:02 1970
current version 1356.0.0
compatibility version 1.0.0
Load command 14
cmd LC_FUNCTION_STARTS
cmdsize 16
dataoff 32976
datasize 8
Load command 15
cmd LC_DATA_IN_CODE
cmdsize 16
dataoff 32984
datasize 0
Load command 16
cmd LC_CODE_SIGNATURE
cmdsize 16
dataoff 33184
datasize 408
+10
View File
@@ -0,0 +1,10 @@
fixture:
Load command 10
cmd LC_BUILD_VERSION
cmdsize 32
platform MACOS
minos 26.0
sdk 26.5
ntools 1
tool LD
version 1267.0
+27
View File
@@ -0,0 +1,27 @@
# High-address otool capture
`otool-high-address.txt` is captured output from the installed Apple `otool`,
with only its first filename line changed to `fixture:`. It exercises addresses
above JavaScript's safe integer range and the actual `2^N (bytes)` alignment
notation. The minimal Mach-O is generated locally; no binary is checked in.
Reproduce the capture on macOS with Xcode command line tools:
```sh
python3 - <<'PY'
import struct
from pathlib import Path
base = 0xfffffff007004001
header = struct.pack('<8I', 0xfeedfacf, 0x0100000c, 0, 5, 1, 152, 0, 0)
segment = struct.pack(
'<II16sQQQQIIII', 0x19, 152, b'__TEXT', base, 0x1000, 0, 185, 5, 5, 1, 0,
)
section = struct.pack(
'<16s16sQQIIIIIIII', b'__text', b'__TEXT', base + 184, 1,
184, 0, 0, 0, 0x80000400, 0, 0, 0,
)
Path('/tmp/rea-high-address.macho').write_bytes(header + segment + section + b'\xc0')
PY
xcrun otool -l /tmp/rea-high-address.macho
```
+25
View File
@@ -0,0 +1,25 @@
fixture:
Load command 0
cmd LC_SEGMENT_64
cmdsize 152
segname __TEXT
vmaddr 0xfffffff007004001
vmsize 0x0000000000001000
fileoff 0
filesize 185
maxprot 0x00000005
initprot 0x00000005
nsects 1
flags 0x0
Section
sectname __text
segname __TEXT
addr 0xfffffff0070040b9
size 0x0000000000000001
offset 184
align 2^0 (1)
reloff 0
nreloc 0
flags 0x80000400
reserved1 0
reserved2 0
+93
View File
@@ -0,0 +1,93 @@
import { readFile } from "node:fs/promises";
import type { BinaryTarget } from "../../src/domain/binaryTarget.js";
import { ok } from "../../src/domain/result.js";
import type {
NativeCommandCapture,
NativeCommandRunner,
} from "../../src/native/CommandRunner.js";
/** Replay captured native command outputs through the production runner seam. */
export class NativeFixtureRunner implements NativeCommandRunner {
constructor(
private readonly overrides: Readonly<Record<string, string>> = {},
private readonly exitCode = 0,
) {}
async run(tool: string, arguments_: readonly string[]) {
const output =
this.overrides[`${tool}:${arguments_[0] ?? ""}`] ??
this.overrides[tool] ??
(await outputFor(tool, arguments_));
return ok({
...capture(tool, arguments_, output),
exitCode: this.exitCode,
});
}
}
const outputFor = async (
tool: string,
arguments_: readonly string[],
): Promise<string> => {
if (tool === "lipo") return nativeFixture("lipo-fat.txt");
if (tool === "otool") return nativeFixture("otool-load.txt");
if (tool === "nm") return "_main\n_$s4Test3fooyyF\n";
if (tool === "dyld_info")
return nativeFixture(
arguments_[0] === "-imports" ? "dyld-imports.txt" : "dyld-exports.txt",
);
if (tool === "dwarfdump")
return "UUID: 01234567-89AB-CDEF-0123-456789ABCDEF (arm64) fixture\n";
if (tool === "file")
return arguments_.at(-1)?.endsWith(".plist") === true
? "XML 1.0 document text\n"
: "Mach-O 64-bit executable arm64, little-endian\n";
if (tool === "vtool") return "Load command 3 LC_BUILD_VERSION\n";
if (tool === "swift-demangle") return nativeFixture("demangle.txt");
if (tool === "plutil") return nativeFixture("plist.json");
if (tool === "codesign") {
if (arguments_.includes("--entitlements"))
return nativeFixture("entitlements.xml");
if (arguments_.includes("-r-"))
return "designated => identifier com.example.fixture\n";
return nativeFixture("codesign.txt");
}
throw new Error(`Unexpected fixture tool ${tool}`);
};
const capture = (
tool: string,
arguments_: readonly string[],
output: string,
): NativeCommandCapture => ({
tool,
executable: `/usr/bin/${tool}`,
executableSha256: "a".repeat(64),
toolVersion: null,
versionReason: "fixture",
arguments: [...arguments_],
stdout: tool === "codesign" ? "" : output,
stderr: tool === "codesign" ? output : "",
stdoutBytes: Buffer.byteLength(tool === "codesign" ? "" : output),
stderrBytes: Buffer.byteLength(tool === "codesign" ? output : ""),
exitCode: 0,
signal: null,
});
/** Build the Mach-O target identity used by native boundary fixtures. */
export const nativeMachoTarget = (
path: string,
sourcePath?: string,
): BinaryTarget => ({
path,
...(sourcePath === undefined ? {} : { sourcePath }),
sha256: "0".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"],
});
/** Load one captured native tool output. */
export const nativeFixture = (name: string): Promise<string> =>
readFile(new URL(`./native-macos/${name}`, import.meta.url), "utf8");