mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
4fbc501121
commit
405732a7f5
34 files changed
+1009
-161
No files matched your search
+40
-25
@@ -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"
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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)}`;
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
input file fixture is not a fat file
|
||||
Non-fat file: fixture is architecture: arm64
|
||||
@@ -0,0 +1,5 @@
|
||||
__mh_execute_header
|
||||
_fixture_absolute
|
||||
_fixture_export
|
||||
_fixture_weak
|
||||
_main
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
Vendored
+93
@@ -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");
|
||||
Reference in new issue
Block a user