mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(native): add investigation features (#486)
* feat(native): add UI and dispatch investigation workflows * feat(ghidra): support macOS host validation * docs: update native investigation capabilities * feat(artifacts): decode compiled Interface Builder archives * feat(ghidra): expose bounded p-code value flow * test(ghidra): split host and cross-format verification * fix(verify): follow complete result contracts * refactor: remove unused authorization and schema helpers * refactor: remove unused evidence result alias * fix(ghidra): report jump-table evidence precisely * fix(verify): align browser scenario fixture schema * fix(setup): compare parsed client config structurally * test(ghidra): verify AArch64 jump-table mappings * fix(ghidra): recover AArch64 byte jump tables * test(artifacts): verify compiled interface builder archives * feat(native): trace UI actions through handlers * fix(tests): update native trace and shutdown fixtures * fix(verification): diagnose browser scenario failures * fix(browser): wrap scenario snapshots in capture state * fix(native): bind and bound investigation workflows * fix(artifacts): preserve bounded interface builder evidence * fix(ghidra): enforce admitted hosts and preflight tools * fix(native): preserve trace cancellation and bounds * test(application): expect bound analysis target evidence * fix(native): preserve investigation bounds and cancellation * fix(application): preserve bundle and unresolved trace evidence * style(test): format artifact subject fixture
This commit is contained in:
1 parent
fa26affc86
commit
ed933d1893
87 files changed
+6771
-375
No files matched your search
@@ -34,6 +34,7 @@ REA is a layered ESM TypeScript application. Dependencies flow inward from pure
|
||||
- `npm run check:fast`: run cached typecheck and lint checks.
|
||||
- `npm run check:pr`: run the deterministic PR gate and generated-document checks.
|
||||
- For provider-dependent changes, see [docs/testing.md](docs/testing.md) and run the matching real-provider verification.
|
||||
- Keep each verification lane's prerequisites limited to the claim it checks. Use host-native fixtures for host/provider acceptance; put optional cross-target formats and their external toolchains in a separate lane. Preflight required commands and report the missing dependency and lane clearly.
|
||||
|
||||
See [docs/testing.md](docs/testing.md) for test scopes and verification lanes and [CONTRIBUTING.md](CONTRIBUTING.md) for contribution checks. Pre-commit formats and lints staged source; pre-push runs `npm run check:fast`.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
See a feature in an app that you want in your own product? Give the app to your agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
|
||||
|
||||
REA gives agents one consistent way to investigate software. Today that includes deep native analysis and function dossiers through Hopper or bring-your-own Ghidra on Linux, plus an experimental Windows x64 Ghidra P0 for approved native PE applications; execution-free managed PE/CLI triage; reproducible Evidence records; controlled process capture; passive website, Electron page, and Node/Electron V8 Inspector observation; JavaScript/source-map reconstruction; and a provider-neutral graph for connecting JavaScript application layers without confusing static inference with runtime observation. The longer-term toolkit extends the same agent workflow to APIs, protocols, mobile artifacts, firmware, richer runtime behavior, and differences between versions.
|
||||
REA gives agents one consistent way to investigate software. Today that includes deep native analysis and function dossiers through Hopper or bring-your-own Ghidra on Linux, plus an experimental Windows x64 Ghidra P0 for approved native PE applications; execution-free managed PE/CLI triage; reproducible Evidence records; controlled process capture; passive website, Electron page, and Node/Electron V8 Inspector observation; JavaScript/source-map reconstruction; and provider-neutral graphs for connecting application layers without confusing static inference with runtime observation. The longer-term toolkit extends the same agent workflow to APIs, protocols, mobile artifacts, firmware, richer runtime behavior, and differences between versions.
|
||||
|
||||
Reverse engineering normally makes the operator choose a tool, learn its API, move evidence between programs, and decide what to inspect next. REA gives that work to the agent through commands, skills, structured results, and repeatable investigation workflows.
|
||||
|
||||
@@ -226,7 +226,7 @@ REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Co
|
||||
|
||||
### Ghidra read-only analysis provider
|
||||
|
||||
The Ghidra adapter supports the exact official Ghidra 12.1.4 release with a 64-bit full JDK 21 on Linux x64. It also provides an experimental Windows x64 P0 limited to approved native x86-64 PE applications. Download and extract those projects yourself, then configure absolute paths:
|
||||
The Ghidra adapter supports the exact official Ghidra 12.1.4 release with a 64-bit full JDK 21 on Linux x64. macOS is not an admitted Ghidra host. The adapter also provides an experimental Windows x64 P0 limited to approved native x86-64 PE applications. Download and extract those projects yourself, then configure absolute paths:
|
||||
|
||||
```bash
|
||||
export GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra_12.1.4_PUBLIC
|
||||
@@ -246,7 +246,9 @@ The Ghidra adapter declares 19 direct and enhanced operations. Its ten inventory
|
||||
|
||||
The bridge serves operations only after auto-analysis completes. Each Program owns one persistent `DecompInterface`, and a serial FIFO keeps Ghidra API access on the owning program's thread; it has no fixed queue length or per-operation deadline. Reference results preserve Ghidra's call/jump/data/read/write/indirect/computed/external facts, while unresolved targetless flows remain explicitly unknown. Synthetic entry-point references without actionable memory sources are omitted. Pseudocode and assembly are provider-specific observations, not original source or Hopper-equivalent text. Caller cancellation and provider shutdown remain available; provider results are returned without a fixed response-size ceiling.
|
||||
|
||||
`npm run verify:ghidra` compiles source-owned x86-64 debug and stripped ELF, AArch64 ELF, x86-64 PE, and x86-64 Mach-O fixtures. Against real Ghidra 12.1.4 it validates every admitted operation, direct and indirect calls, imports/exports/thunks, typed references, strings/xrefs, multi-block CFG, cancellation, deadlines, concurrency, malformed inputs, and complete process/project cleanup. Set `REA_CC`, `REA_CLANG`, or `REA_LLD_LINK` only when the corresponding compiler command is not on `PATH`.
|
||||
`npm run verify:ghidra` builds debug and stripped fixtures for the supported Linux x64 ELF host. Against real Ghidra 12.1.4 it validates every admitted operation, direct and indirect calls, imports/exports/thunks, typed references, strings/xrefs, multi-block CFG, cancellation, deadlines, concurrency, malformed inputs, and complete process/project cleanup. It needs a host C compiler and the Ghidra/JDK prerequisites above.
|
||||
|
||||
`npm run verify:ghidra:cross-format` adds AArch64 ELF, x86-64 PE, and x86-64 Mach-O fixtures. This separate lane requires `clang`, LLD, and `lld-link`; use `REA_CLANG`, `REA_LLD`, or `REA_LLD_LINK` to select alternate command paths. It preflights the required toolchain before compiling fixtures.
|
||||
|
||||
`npm run verify:ghidra:windows` uses a deterministic source-owned native x86-64 PE application and requires all 19 operations, target/snapshot/import digest linkage, authenticated loopback transport, and cleanup on a controlled Windows x64 Ghidra 12.1.4 runner. This proof does not establish Job Object ownership, private DACLs, or reparse-point-safe authority.
|
||||
|
||||
@@ -356,18 +358,18 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
|
||||
|
||||
## Tool catalog for investigation
|
||||
|
||||
| Tool family | Count | Examples |
|
||||
| ----------------------- | ----: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 36 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations, bounded byte reads, file-offset translation |
|
||||
| Investigation workflows | 12 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `trace_feature`, exact string-to-code lookup, Evidence-backed call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
|
||||
| Artifact graph | 2 | complete inline inspection of directories and supported packages, plus explicitly selected extraction into an absent owned tree |
|
||||
| Managed PE/CLI | 8 | PE/CLI identity, metadata members, CIL hashes, P/Invoke/native-boundary declarations and verification, application-graph projection, decompiler reconstruction import, token remapping, runtime-correlation plans, and version comparison |
|
||||
| Browser observation | 9 | exact-origin passive CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, visual evidence, and bounded Playwright scenarios |
|
||||
| Electron analysis | 5 | passive root-confined observation, static application mapping, evidence-backed static/runtime reconciliation, and provider-owned click/wait scenarios |
|
||||
| JavaScript runtime | 2 | approved attach-only Node/Electron Inspector target discovery plus bounded script and execution-context observation without evaluation or instrumentation |
|
||||
| Application workflows | 10 | complete cross-layer traces, unique-only version matching, historical-source to bundle mapping, static export return-shape comparison, approved Linux-isolated extracted-module replay, managed-runtime characterization, reconstruction coverage closure, and deterministic obligation ledgers |
|
||||
| Session and observation | 22 | target lifecycle, inline Evidence bundle retrieval, aggregate navigation/address context, direct finite replay-machine evaluation, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 36 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations, bounded byte reads, file-offset translation |
|
||||
| Investigation workflows | 14 | `binary_overview`, `analyze_function`, `inspect_native_api`, `inspect_native_dispatch_metadata`, `batch_decompile`, `trace_feature`, `trace_native_investigation`, exact string-to-code lookup, bounded call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
|
||||
| Artifact graph | 3 | complete inline inspection of directories and supported packages, compiled Interface Builder UI graph decoding, plus explicitly selected extraction into an absent owned tree |
|
||||
| Managed PE/CLI | 8 | PE/CLI identity, metadata members, CIL hashes, P/Invoke/native-boundary declarations and verification, application-graph projection, decompiler reconstruction import, token remapping, runtime-correlation plans, and version comparison |
|
||||
| Browser observation | 9 | exact-origin passive CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, visual evidence, and bounded Playwright scenarios |
|
||||
| Electron analysis | 5 | passive root-confined observation, static application mapping, evidence-backed static/runtime reconciliation, and provider-owned click/wait scenarios |
|
||||
| JavaScript runtime | 2 | approved attach-only Node/Electron Inspector target discovery plus bounded script and execution-context observation without evaluation or instrumentation |
|
||||
| Application workflows | 10 | complete cross-layer traces, unique-only version matching, historical-source to bundle mapping, static export return-shape comparison, approved Linux-isolated extracted-module replay, managed-runtime characterization, reconstruction coverage closure, and deterministic obligation ledgers |
|
||||
| Workspace and observation | 22 | target lifecycle, inline Evidence bundle retrieval, aggregate navigation/address context, direct finite replay-machine evaluation, process/artifact/function comparison, evidence-linked residual-unknown lifecycle |
|
||||
|
||||
The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness implements controlled behavioral capture.
|
||||
|
||||
@@ -498,6 +500,8 @@ New providers must produce the same evidence and safety metadata as existing cap
|
||||
|
||||
See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the shipped Ghidra function-analysis boundary, remaining admission gates, and provider comparison matrix; [ADR-0001](docs/adr/0001-provider-selection-and-analysis-profiles.md) for binding, selection, profile, snapshot, and compatibility decisions; the [controlled replay guide](docs/controlled-javascript-replay.md) plus [ADR-0002](docs/adr/0002-controlled-replay-authority-and-sandbox.md) for the shipped JavaScript replay boundary; and [ADR-0003](docs/adr/0003-managed-code-evidence-and-provider-boundary.md) for the managed-code evidence and provider design.
|
||||
|
||||
See the [native UI and dispatch investigation guide](docs/native-investigation.md) for compiled Interface Builder decoding, symbol-derived metadata limits, and the current p-code value-flow boundary.
|
||||
|
||||
## Using REA with other agents
|
||||
|
||||
Setup detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. It automatically configures the first six when present; detected Devin installations are reported but left unchanged. Any agent that supports local MCP servers can use REA with the configuration below.
|
||||
|
||||
+2
-2
@@ -162,9 +162,9 @@ rea uninstall --purge-data
|
||||
| عائلة الأدوات | العدد | أمثلة |
|
||||
| --------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| فحص الملفات التنفيذية | 36 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
|
||||
| التحليل المركب | 12 | النظرة العامة، و`inspect_native_api`، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| التحليل المركب | 14 | النظرة العامة، و`inspect_native_api`، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
|
||||
| رسم القطع الأثرية | 2 | فحص كامل مباشر للدلائل والحزم المدعومة، واستخراج معاملاتي محدد صراحة |
|
||||
| رسم القطع الأثرية | 3 | فحص كامل مباشر للدلائل والحزم المدعومة، واستخراج معاملاتي محدد صراحة |
|
||||
| Managed PE/CLI | 8 | هوية PE/CLI، أعضاء البيانات الوصفية، بصمات CIL، تصريحات P/Invoke/الحدود الأصلية والتحقق منها، إسقاط رسم التطبيق، استيراد إعادة البناء من فك الترجمة، إعادة ربط الرموز، خطط ترابط وقت التشغيل ومقارنة الإصدارات |
|
||||
| مراقبة المتصفح | 9 | التقاط CDP محدود الأصل وتحليل الحزم وخرائط المصدر واكتشاف WebMCP والفروق والأدلة المرئية |
|
||||
| تحليل Electron | 5 | مراقبة سلبية ضمن جذور canonical، ورسم ثابت ومحدود للتطبيق، ومطابقة ثابتة/وقت تشغيل قائمة على Evidence |
|
||||
|
||||
+2
-2
@@ -152,9 +152,9 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
| ツール群 | 数 | 例 |
|
||||
| ---------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| バイナリ調査 | 36 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
|
||||
| 合成解析 | 12 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
|
||||
| 合成解析 | 14 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
|
||||
| macOS ネイティブ | 5 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift デマングル。Hopper 起動不要 |
|
||||
| アーティファクトグラフ | 2 | ディレクトリと対応パッケージの完全なインライン調査、および明示選択されたトランザクション抽出 |
|
||||
| アーティファクトグラフ | 3 | ディレクトリと対応パッケージの完全なインライン調査、および明示選択されたトランザクション抽出 |
|
||||
| Managed PE/CLI | 8 | PE/CLI 識別、メタデータメンバー、CIL ハッシュ、P/Invoke/ネイティブ境界宣言と検証、アプリケーショングラフ投影、逆コンパイル再構成インポート、構造的 token 再マッピング、ランタイム相関計画、バージョン比較 |
|
||||
| ブラウザ観察 | 9 | origin 限定 CDP 取得、bundle/source map 解析、WebMCP 検出、セッション、capture diff、視覚証拠 |
|
||||
| Electron 解析 | 5 | canonical ファイルルート内の受動観察、有界な静的アプリマッピング、Evidence に基づく静的/実行時の照合 |
|
||||
|
||||
+2
-2
@@ -152,9 +152,9 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
| 도구 그룹 | 수 | 예시 |
|
||||
| ------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 바이너리 검사 | 36 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
|
||||
| 합성 분석 | 12 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
|
||||
| 합성 분석 | 14 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
|
||||
| macOS 네이티브 도구 | 5 | Mach-O 메타데이터, 코드 서명, plist, 아키텍처, Swift 디맹글링. Hopper 실행 불필요 |
|
||||
| 아티팩트 그래프 | 2 | 디렉터리와 지원 패키지의 전체 인라인 검사 및 명시적으로 선택한 트랜잭션 추출 |
|
||||
| 아티팩트 그래프 | 3 | 디렉터리와 지원 패키지의 전체 인라인 검사 및 명시적으로 선택한 트랜잭션 추출 |
|
||||
| Managed PE/CLI | 8 | PE/CLI ID, 메타데이터 멤버, CIL 해시, P/Invoke/네이티브 경계 선언 및 검증, 애플리케이션 그래프 투영, 디컴파일 재구성 가져오기, 구조적 token 재매핑, 런타임 상관 계획, 버전 비교 |
|
||||
| 브라우저 관찰 | 9 | origin 제한 CDP 캡처, bundle/source map 분석, WebMCP 검색, 세션 타임라인, capture diff 및 시각 증거 |
|
||||
| Electron 분석 | 5 | canonical 파일 루트 내 수동 관찰, 제한된 정적 앱 매핑, Evidence 기반 정적/런타임 조정 |
|
||||
|
||||
+2
-2
@@ -152,9 +152,9 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
| 工具类别 | 数量 | 示例 |
|
||||
| ----------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 二进制检查 | 36 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
|
||||
| 组合分析 | 12 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
|
||||
| 组合分析 | 14 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
|
||||
| macOS 原生工具 | 5 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
|
||||
| 制品图谱 | 2 | 目录及支持的软件包的完整内联检查,以及显式选择的事务式提取 |
|
||||
| 制品图谱 | 3 | 目录及支持的软件包的完整内联检查,以及显式选择的事务式提取 |
|
||||
| Managed PE/CLI | 8 | PE/CLI 身份、元数据成员、CIL 哈希、P/Invoke/原生边界声明与验证、应用图谱投影、反编译重建导入、结构化 token 重映射、运行时关联计划与跨版本比较 |
|
||||
| 浏览器观察 | 9 | 精确来源 CDP 抓取、bundle/source map 分析、WebMCP 发现、会话时间线、capture diff 与视觉证据 |
|
||||
| Electron 分析 | 5 | 限定 canonical 文件根的被动观察、有界静态应用映射,以及基于 Evidence 的静态/运行时对账 |
|
||||
|
||||
@@ -25,8 +25,11 @@ import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -74,8 +77,15 @@ import ghidra.program.model.pcode.FunctionPrototype;
|
||||
import ghidra.program.model.pcode.HighFunction;
|
||||
import ghidra.program.model.pcode.HighSymbol;
|
||||
import ghidra.program.model.pcode.JumpTable;
|
||||
import ghidra.program.model.pcode.PcodeOp;
|
||||
import ghidra.program.model.pcode.PcodeOpAST;
|
||||
import ghidra.program.model.pcode.Varnode;
|
||||
import ghidra.program.model.pcode.VarnodeAST;
|
||||
|
||||
public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private static final int MAX_HIGH_PCODE_OPS = 3000;
|
||||
private static final int MAX_HIGH_PCODE_INPUTS_PER_OP = 64;
|
||||
private static final int MAX_HIGH_PCODE_DEF_USE_EDGES = 12000;
|
||||
private static final Gson GSON = new GsonBuilder().serializeNulls().create();
|
||||
private static final Set<String> DESCRIPTOR_KEYS = Set.of(
|
||||
"transport",
|
||||
@@ -523,6 +533,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
"native_api",
|
||||
nativeApiBoundary(function, decompilation)
|
||||
);
|
||||
result.add("native_value_flow", nativeValueFlow(decompilation));
|
||||
JsonArray limitations = new JsonArray();
|
||||
limitations.add(
|
||||
"Unresolved computed or indirect flows without target addresses are not represented as reference edges."
|
||||
@@ -540,6 +551,205 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonObject nativeValueFlow(DecompileResults decompilation) throws Exception {
|
||||
HighFunction highFunction =
|
||||
decompilation == null ? null : decompilation.getHighFunction();
|
||||
if (highFunction == null) {
|
||||
JsonObject unavailable = new JsonObject();
|
||||
unavailable.addProperty("available", false);
|
||||
unavailable.addProperty(
|
||||
"reason",
|
||||
"Ghidra has no HighFunction p-code model for this procedure"
|
||||
);
|
||||
JsonArray limitations = new JsonArray();
|
||||
limitations.add(
|
||||
"No p-code operations or def-use relationships were recovered for this procedure."
|
||||
);
|
||||
unavailable.add("limitations", limitations);
|
||||
return unavailable;
|
||||
}
|
||||
|
||||
List<PcodeOpAST> selected = new ArrayList<>();
|
||||
int omittedOperationsLowerBound = 0;
|
||||
Iterator<PcodeOpAST> operations = highFunction.getPcodeOps();
|
||||
while (operations.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
PcodeOpAST operation = operations.next();
|
||||
if (selected.size() >= MAX_HIGH_PCODE_OPS) {
|
||||
omittedOperationsLowerBound = 1;
|
||||
break;
|
||||
}
|
||||
selected.add(operation);
|
||||
}
|
||||
|
||||
Map<PcodeOpAST, String> operationIds = new IdentityHashMap<>();
|
||||
for (PcodeOpAST operation : selected) {
|
||||
operationIds.put(operation, pcodeOperationId(operation));
|
||||
}
|
||||
JsonArray operationValues = new JsonArray();
|
||||
JsonArray defUse = new JsonArray();
|
||||
JsonArray effects = new JsonArray();
|
||||
int omittedInputs = 0;
|
||||
int omittedEdges = 0;
|
||||
for (PcodeOpAST operation : selected) {
|
||||
monitor.checkCancelled();
|
||||
String operationId = operationIds.get(operation);
|
||||
JsonObject value = new JsonObject();
|
||||
value.addProperty("id", operationId);
|
||||
value.addProperty(
|
||||
"address",
|
||||
canonicalAddress(operation.getSeqnum().getTarget())
|
||||
);
|
||||
value.addProperty("sequence", operation.getSeqnum().getTime());
|
||||
value.addProperty("opcode", operation.getMnemonic());
|
||||
value.addProperty("is_dead", operation.isDead());
|
||||
JsonArray inputs = new JsonArray();
|
||||
int inputCount = operation.getNumInputs();
|
||||
int capturedInputs = Math.min(inputCount, MAX_HIGH_PCODE_INPUTS_PER_OP);
|
||||
for (int inputIndex = 0; inputIndex < capturedInputs; inputIndex += 1) {
|
||||
Varnode input = operation.getInput(inputIndex);
|
||||
inputs.add(varnodeValue(input));
|
||||
if (input instanceof VarnodeAST astInput) {
|
||||
PcodeOp definition = astInput.getDef();
|
||||
if (definition instanceof PcodeOpAST astDefinition) {
|
||||
String definitionId = operationIds.get(astDefinition);
|
||||
if (definitionId == null || defUse.size() >= MAX_HIGH_PCODE_DEF_USE_EDGES) {
|
||||
omittedEdges += 1;
|
||||
} else {
|
||||
JsonObject edge = new JsonObject();
|
||||
edge.addProperty("definition", definitionId);
|
||||
edge.addProperty("use", operationId);
|
||||
edge.addProperty("input_index", inputIndex);
|
||||
defUse.add(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
omittedInputs += inputCount - capturedInputs;
|
||||
value.add("inputs", inputs);
|
||||
Varnode output = operation.getOutput();
|
||||
value.add("output", output == null ? JsonNull.INSTANCE : varnodeValue(output));
|
||||
operationValues.add(value);
|
||||
addPcodeEffect(effects, operation, operationId);
|
||||
}
|
||||
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("available", true);
|
||||
result.addProperty("provenance", "ghidra-high-pcode");
|
||||
result.add("operations", operationValues);
|
||||
result.add("def_use", defUse);
|
||||
result.add("effects", effects);
|
||||
boolean truncated = omittedOperationsLowerBound > 0 ||
|
||||
omittedInputs > 0 || omittedEdges > 0;
|
||||
result.addProperty("truncated", truncated);
|
||||
result.addProperty(
|
||||
"omitted_operations_lower_bound",
|
||||
omittedOperationsLowerBound
|
||||
);
|
||||
result.addProperty("known_omitted_inputs", omittedInputs);
|
||||
result.addProperty("known_omitted_edges", omittedEdges);
|
||||
JsonArray limitations = new JsonArray();
|
||||
limitations.add(
|
||||
"High p-code is a decompiler-derived intra-function representation, not original source or runtime behavior; dead operations are retained and marked."
|
||||
);
|
||||
limitations.add(
|
||||
"Def-use links do not cross function boundaries and may omit memory aliasing, call side effects, or unresolved indirect flow."
|
||||
);
|
||||
limitations.add(
|
||||
"Memory read/write effects include stack and temporary memory operations; they do not identify persistent state or business meaning."
|
||||
);
|
||||
limitations.add(
|
||||
"Operation, input, and def-use limits can truncate large functions; inspect truncation, omitted lower bounds, and known omitted counts before drawing conclusions."
|
||||
);
|
||||
limitations.add(
|
||||
"Known omitted-edge counts cover links omitted from retained operations; omitted operations may contain additional uncounted links."
|
||||
);
|
||||
result.add("limitations", limitations);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String pcodeOperationId(PcodeOpAST operation) {
|
||||
return canonicalAddress(operation.getSeqnum().getTarget()) + "#" +
|
||||
operation.getSeqnum().getTime();
|
||||
}
|
||||
|
||||
private JsonObject varnodeValue(Varnode varnode) {
|
||||
JsonObject result = new JsonObject();
|
||||
String kind = varnode.isConstant()
|
||||
? "constant"
|
||||
: varnode.isAddress()
|
||||
? "address"
|
||||
: varnode.isRegister()
|
||||
? "register"
|
||||
: varnode.isUnique() ? "unique" : "other";
|
||||
result.addProperty("kind", kind);
|
||||
result.addProperty("size_bytes", varnode.getSize());
|
||||
result.add(
|
||||
"location",
|
||||
varnode.isConstant()
|
||||
? JsonNull.INSTANCE
|
||||
: GSON.toJsonTree(canonicalAddress(varnode.getAddress()))
|
||||
);
|
||||
result.add(
|
||||
"constant_hex",
|
||||
varnode.isConstant()
|
||||
? GSON.toJsonTree(Long.toUnsignedString(varnode.getOffset(), 16))
|
||||
: JsonNull.INSTANCE
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void addPcodeEffect(
|
||||
JsonArray destination,
|
||||
PcodeOp operation,
|
||||
String operationId
|
||||
) {
|
||||
String kind = null;
|
||||
Integer operandInput = null;
|
||||
Integer valueInput = null;
|
||||
switch (operation.getOpcode()) {
|
||||
case PcodeOp.LOAD -> {
|
||||
kind = "memory_read";
|
||||
operandInput = 1;
|
||||
}
|
||||
case PcodeOp.STORE -> {
|
||||
kind = "memory_write";
|
||||
operandInput = 1;
|
||||
valueInput = 2;
|
||||
}
|
||||
case PcodeOp.CBRANCH -> {
|
||||
kind = "conditional_branch";
|
||||
valueInput = 1;
|
||||
}
|
||||
case PcodeOp.BRANCHIND -> {
|
||||
kind = "indirect_branch";
|
||||
operandInput = 0;
|
||||
}
|
||||
case PcodeOp.CALL -> {
|
||||
kind = "direct_call";
|
||||
operandInput = 0;
|
||||
}
|
||||
case PcodeOp.CALLIND -> {
|
||||
kind = "indirect_call";
|
||||
operandInput = 0;
|
||||
}
|
||||
default -> { }
|
||||
}
|
||||
if (kind == null) return;
|
||||
JsonObject effect = new JsonObject();
|
||||
effect.addProperty("operation", operationId);
|
||||
effect.addProperty("kind", kind);
|
||||
effect.add(
|
||||
"operand_input",
|
||||
operandInput == null ? JsonNull.INSTANCE : GSON.toJsonTree(operandInput)
|
||||
);
|
||||
effect.add(
|
||||
"value_input",
|
||||
valueInput == null ? JsonNull.INSTANCE : GSON.toJsonTree(valueInput)
|
||||
);
|
||||
destination.add(effect);
|
||||
}
|
||||
|
||||
private JsonElement addressName(JsonObject params) {
|
||||
requireKeys(params, Set.of("document", "address"));
|
||||
requireDocument(params);
|
||||
@@ -636,7 +846,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonObject search(JsonObject params, boolean procedureSearch) throws Exception {
|
||||
private JsonArray search(JsonObject params, boolean procedureSearch) throws Exception {
|
||||
requireKeys(
|
||||
params,
|
||||
Set.of("pattern", "mode", "case_sensitive", "document")
|
||||
@@ -988,18 +1198,30 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
canonicalAddress(jumpTable.getSwitchAddress())
|
||||
);
|
||||
JumpTable.LoadTable[] loadTables = jumpTable.getLoadTables();
|
||||
RecoveredAArch64ByteTable recoveredAArch64 =
|
||||
loadTables.length == 0
|
||||
? recoverAArch64ByteTable(jumpTable)
|
||||
: null;
|
||||
List<Address> referencedDataAddresses = loadTables.length == 0
|
||||
? dispatchPathDataReferences(jumpTable)
|
||||
: List.of();
|
||||
JsonArray dataSources = new JsonArray();
|
||||
for (int index = 0; index < loadTables.length; index += 1) {
|
||||
monitor.checkCancelled();
|
||||
JumpTable.LoadTable loadTable = loadTables[index];
|
||||
JsonObject source = new JsonObject();
|
||||
source.addProperty("address", canonicalAddress(loadTable.getAddress()));
|
||||
source.addProperty("provenance", "ghidra-decompiler-load-table");
|
||||
source.addProperty("entry_size_bytes", loadTable.getSize());
|
||||
source.addProperty("entry_count", loadTable.getNum());
|
||||
source.addProperty("confidence", "high");
|
||||
int entrySize = loadTable.getSize();
|
||||
int entryCount = loadTable.getNum();
|
||||
if (entrySize > 0) source.addProperty("entry_size_bytes", entrySize);
|
||||
else source.add("entry_size_bytes", JsonNull.INSTANCE);
|
||||
if (entryCount > 0) source.addProperty("entry_count", entryCount);
|
||||
else source.add("entry_count", JsonNull.INSTANCE);
|
||||
source.addProperty(
|
||||
"confidence",
|
||||
entrySize > 0 && entryCount > 0 ? "high" : "medium"
|
||||
);
|
||||
JsonArray evidence = new JsonArray();
|
||||
evidence.add(
|
||||
inferenceEvidence(
|
||||
@@ -1013,8 +1235,37 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
source.add("evidence", evidence);
|
||||
dataSources.add(source);
|
||||
}
|
||||
if (recoveredAArch64 != null) {
|
||||
JsonObject source = new JsonObject();
|
||||
source.addProperty(
|
||||
"address",
|
||||
canonicalAddress(recoveredAArch64.tableAddress)
|
||||
);
|
||||
source.addProperty("provenance", "ghidra-aarch64-byte-table");
|
||||
source.addProperty("entry_size_bytes", 1);
|
||||
source.addProperty("entry_count", recoveredAArch64.mappings.size());
|
||||
source.addProperty("confidence", "high");
|
||||
JsonArray evidence = new JsonArray();
|
||||
evidence.add(
|
||||
inferenceEvidence(
|
||||
"jump-table",
|
||||
"ghidra-instruction-and-memory",
|
||||
"Decoded " + recoveredAArch64.mappings.size() +
|
||||
" byte entries from " + canonicalAddress(recoveredAArch64.tableAddress) +
|
||||
"; the AArch64 LDRB/ADR/ADD/BR chain uses a selector guarded by an unsigned upper-bound branch."
|
||||
)
|
||||
);
|
||||
source.add("evidence", evidence);
|
||||
dataSources.add(source);
|
||||
}
|
||||
for (int index = 0; index < referencedDataAddresses.size(); index += 1) {
|
||||
Address address = referencedDataAddresses.get(index);
|
||||
if (
|
||||
recoveredAArch64 != null &&
|
||||
recoveredAArch64.tableAddress.equals(address)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
JsonObject source = new JsonObject();
|
||||
source.addProperty("address", canonicalAddress(address));
|
||||
source.addProperty(
|
||||
@@ -1040,10 +1291,37 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.add("data_sources", dataSources);
|
||||
Address[] targets = jumpTable.getCases();
|
||||
Integer[] labels = jumpTable.getLabelValues();
|
||||
boolean labelsAligned = labels != null && labels.length == targets.length;
|
||||
JsonArray mappings = new JsonArray();
|
||||
if (recoveredAArch64 != null) {
|
||||
for (int index = 0; index < recoveredAArch64.mappings.size(); index += 1) {
|
||||
monitor.checkCancelled();
|
||||
RecoveredAArch64Mapping recovered = recoveredAArch64.mappings.get(index);
|
||||
JsonObject mapping = new JsonObject();
|
||||
mapping.addProperty("case_value", recovered.caseValue);
|
||||
String targetAddress = canonicalAddress(recovered.targetAddress);
|
||||
mapping.addProperty("target_address", targetAddress);
|
||||
mapping.addProperty("confidence", "high");
|
||||
JsonArray evidence = new JsonArray();
|
||||
evidence.add(
|
||||
inferenceEvidence(
|
||||
"jump-table",
|
||||
"ghidra-instruction-and-memory",
|
||||
"Case value " + recovered.caseValue + " indexes byte " +
|
||||
recovered.caseValue + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
|
||||
"; the loaded byte selects a four-byte instruction offset from " +
|
||||
canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + "."
|
||||
)
|
||||
);
|
||||
mapping.add("evidence", evidence);
|
||||
mappings.add(mapping);
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (int index = 0; index < targets.length; index += 1) {
|
||||
monitor.checkCancelled();
|
||||
JsonObject mapping = new JsonObject();
|
||||
if (labels == null || index >= labels.length || labels[index] == null) {
|
||||
if (!labelsAligned || labels[index] == null) {
|
||||
mapping.add("case_value", JsonNull.INSTANCE);
|
||||
}
|
||||
else {
|
||||
@@ -1051,29 +1329,40 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
}
|
||||
String targetAddress = canonicalAddress(targets[index]);
|
||||
mapping.addProperty("target_address", targetAddress);
|
||||
JsonArray dataAddresses = new JsonArray();
|
||||
for (int sourceIndex = 0; sourceIndex < loadTables.length; sourceIndex += 1) {
|
||||
dataAddresses.add(
|
||||
canonicalAddress(loadTables[sourceIndex].getAddress())
|
||||
);
|
||||
}
|
||||
mapping.add("data_addresses", dataAddresses);
|
||||
mapping.addProperty("confidence", "medium");
|
||||
// Ghidra's recovered case-to-target relation does not identify
|
||||
// which backing load table produced an individual case. Keep the
|
||||
// table inventory and recovered mappings separate instead of
|
||||
// attaching every table address to every target.
|
||||
mapping.addProperty(
|
||||
"confidence",
|
||||
labelsAligned && labels[index] != null
|
||||
? "high"
|
||||
: "medium"
|
||||
);
|
||||
JsonArray evidence = new JsonArray();
|
||||
evidence.add(
|
||||
inferenceEvidence(
|
||||
"jump-table",
|
||||
"ghidra-high-function",
|
||||
"Recovered target " +
|
||||
targetAddress +
|
||||
" from dispatch " +
|
||||
canonicalAddress(jumpTable.getSwitchAddress()) +
|
||||
"."
|
||||
labelsAligned && labels[index] != null
|
||||
? "Ghidra paired case value " +
|
||||
labels[index] +
|
||||
" with target " +
|
||||
targetAddress +
|
||||
" at dispatch " +
|
||||
canonicalAddress(jumpTable.getSwitchAddress()) +
|
||||
"."
|
||||
: "Ghidra recovered target " +
|
||||
targetAddress +
|
||||
" at dispatch " +
|
||||
canonicalAddress(jumpTable.getSwitchAddress()) +
|
||||
", but no aligned case value was available."
|
||||
)
|
||||
);
|
||||
mapping.add("evidence", evidence);
|
||||
mappings.add(mapping);
|
||||
}
|
||||
}
|
||||
result.add("mappings", mappings);
|
||||
JsonArray limitations = new JsonArray();
|
||||
if (loadTables.length == 0 && referencedDataAddresses.isEmpty()) {
|
||||
@@ -1081,20 +1370,258 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
"Ghidra recovered targets but exposed no backing load-table or dispatch-block data address."
|
||||
);
|
||||
}
|
||||
if (loadTables.length == 0 && !referencedDataAddresses.isEmpty()) {
|
||||
if (
|
||||
recoveredAArch64 == null &&
|
||||
loadTables.length == 0 &&
|
||||
!referencedDataAddresses.isEmpty()
|
||||
) {
|
||||
limitations.add(
|
||||
"Load-table metadata was absent; table-level candidate ownership is inferred from typed data references in the dispatch block and its immediate control-flow predecessors, but candidates are not assigned to individual mappings."
|
||||
);
|
||||
}
|
||||
if (labels == null || labels.length != targets.length) {
|
||||
if (recoveredAArch64 == null && !labelsAligned) {
|
||||
limitations.add(
|
||||
"Case labels were unavailable or incomplete; null labels are preserved."
|
||||
"Case-label and target counts differ or labels are unavailable; all case values are left unknown because index alignment cannot be established."
|
||||
);
|
||||
}
|
||||
if (targets.length == 0) {
|
||||
limitations.add(
|
||||
"Ghidra exposed a jump-table dispatch without recovered case targets."
|
||||
);
|
||||
}
|
||||
result.add("limitations", limitations);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static final class RecoveredAArch64Mapping {
|
||||
private final int caseValue;
|
||||
private final Address targetAddress;
|
||||
|
||||
private RecoveredAArch64Mapping(int caseValue, Address targetAddress) {
|
||||
this.caseValue = caseValue;
|
||||
this.targetAddress = targetAddress;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecoveredAArch64ByteTable {
|
||||
private final Address tableAddress;
|
||||
private final Address branchBase;
|
||||
private final List<RecoveredAArch64Mapping> mappings;
|
||||
|
||||
private RecoveredAArch64ByteTable(
|
||||
Address tableAddress,
|
||||
Address branchBase,
|
||||
List<RecoveredAArch64Mapping> mappings
|
||||
) {
|
||||
this.tableAddress = tableAddress;
|
||||
this.branchBase = branchBase;
|
||||
this.mappings = mappings;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover only the directly verified AArch64 byte-indexed relative branch
|
||||
* table form. Other instruction sequences keep their case labels unknown.
|
||||
*/
|
||||
private RecoveredAArch64ByteTable recoverAArch64ByteTable(
|
||||
JumpTable jumpTable
|
||||
) throws Exception {
|
||||
if (!currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:")) {
|
||||
return null;
|
||||
}
|
||||
Function function = containingFunction(jumpTable.getSwitchAddress());
|
||||
if (function == null) return null;
|
||||
|
||||
List<Instruction> instructions = new ArrayList<>();
|
||||
InstructionIterator iterator = currentProgram.getListing().getInstructions(
|
||||
function.getBody(), true
|
||||
);
|
||||
while (iterator.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
Instruction instruction = iterator.next();
|
||||
instructions.add(instruction);
|
||||
if (instruction.getAddress().equals(jumpTable.getSwitchAddress())) break;
|
||||
}
|
||||
int branchIndex = instructions.size() - 1;
|
||||
if (branchIndex < 1 ||
|
||||
!instructions.get(branchIndex).getMnemonicString().equalsIgnoreCase("BR")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Address[] recoveredTargets = jumpTable.getCases();
|
||||
Set<String> recoveredTargetSet = new HashSet<>();
|
||||
for (Address target : recoveredTargets) recoveredTargetSet.add(canonicalAddress(target));
|
||||
List<Address> dataAddresses = dispatchPathDataReferences(jumpTable);
|
||||
if (dataAddresses.isEmpty()) return null;
|
||||
|
||||
for (int loadIndex = 0; loadIndex < branchIndex; loadIndex += 1) {
|
||||
Instruction load = instructions.get(loadIndex);
|
||||
if (!load.getMnemonicString().equalsIgnoreCase("LDRB")) continue;
|
||||
String loadOperands = operands(load);
|
||||
java.util.regex.Matcher loadMatcher = java.util.regex.Pattern
|
||||
.compile("(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+)\\]$")
|
||||
.matcher(loadOperands);
|
||||
if (!loadMatcher.matches()) continue;
|
||||
String loadedRegister = normalizedRegister(loadOperands.substring(0, loadOperands.indexOf(',')));
|
||||
String tableBaseRegister = normalizedRegister(loadMatcher.group(1));
|
||||
String indexRegister = normalizedRegister(loadMatcher.group(2));
|
||||
|
||||
Address tableAddress = null;
|
||||
for (Address candidate : dataAddresses) {
|
||||
if (currentProgram.getMemory().contains(candidate)) {
|
||||
tableAddress = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tableAddress == null) continue;
|
||||
|
||||
Address resolvedTableAddress = null;
|
||||
for (int index = 0; index < loadIndex; index += 1) {
|
||||
Instruction instruction = instructions.get(index);
|
||||
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
|
||||
String text = operands(instruction);
|
||||
if (mnemonic.equals("ADRP") &&
|
||||
text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
|
||||
String[] parts = text.split(",");
|
||||
if (normalizedRegister(parts[0]).equals(tableBaseRegister)) {
|
||||
resolvedTableAddress = addressFromOperand(parts[1]);
|
||||
}
|
||||
}
|
||||
else if (mnemonic.equals("ADD") && resolvedTableAddress != null &&
|
||||
text.matches("(?i)^[wx]\\d+,\\s*[wx]\\d+,\\s*#?(?:0x[0-9a-f]+|[0-9]+)$")) {
|
||||
String[] parts = text.split(",");
|
||||
if (normalizedRegister(parts[0]).equals(tableBaseRegister) &&
|
||||
normalizedRegister(parts[1]).equals(tableBaseRegister)) {
|
||||
resolvedTableAddress = resolvedTableAddress.add(parseInteger(parts[2].trim().replaceFirst("^#", "")));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tableAddress.equals(resolvedTableAddress)) continue;
|
||||
|
||||
Address branchBase = null;
|
||||
boolean addressChain = false;
|
||||
for (int index = 0; index < branchIndex; index += 1) {
|
||||
Instruction instruction = instructions.get(index);
|
||||
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
|
||||
String text = operands(instruction);
|
||||
if (index < loadIndex && mnemonic.equals("ADR") && text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
|
||||
int comma = text.indexOf(',');
|
||||
String destination = normalizedRegister(text.substring(0, comma));
|
||||
if (destination.equals(tableBaseRegister)) continue;
|
||||
branchBase = addressFromOperand(text.substring(comma + 1));
|
||||
}
|
||||
if (index > loadIndex && mnemonic.equals("ADD") && branchBase != null &&
|
||||
text.matches("(?i)^[wx]\\d+,\\s*[wx]\\d+,\\s*[wx]\\d+,\\s*LSL\\s*#(?:0x)?2$")) {
|
||||
String[] parts = text.split(",");
|
||||
String destination = normalizedRegister(parts[0]);
|
||||
String base = normalizedRegister(parts[1]);
|
||||
String offset = normalizedRegister(parts[2]);
|
||||
if (destination.equals(base) &&
|
||||
base.equals(normalizedRegister(operands(instructions.get(branchIndex)))) &&
|
||||
offset.equals(loadedRegister)) {
|
||||
addressChain = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!addressChain || branchBase == null) continue;
|
||||
|
||||
int entryCount = -1;
|
||||
Address defaultTarget = null;
|
||||
for (int compareIndex = 0; compareIndex < loadIndex; compareIndex += 1) {
|
||||
Instruction compare = instructions.get(compareIndex);
|
||||
if (!compare.getMnemonicString().equalsIgnoreCase("CMP")) continue;
|
||||
java.util.regex.Matcher compareMatcher = java.util.regex.Pattern
|
||||
.compile("(?i)^([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$")
|
||||
.matcher(operands(compare));
|
||||
if (!compareMatcher.matches()) continue;
|
||||
String comparedRegister = normalizedRegister(compareMatcher.group(1));
|
||||
long upperBound = parseInteger(compareMatcher.group(2));
|
||||
if (upperBound < 0 || upperBound >= 4096) continue;
|
||||
for (int guardIndex = compareIndex + 1; guardIndex < loadIndex; guardIndex += 1) {
|
||||
Instruction guard = instructions.get(guardIndex);
|
||||
String mnemonic = guard.getMnemonicString().toUpperCase(Locale.ROOT);
|
||||
if (!mnemonic.equals("B.HI")) continue;
|
||||
Address[] flows = guard.getFlows();
|
||||
if (flows.length != 1 || !recoveredTargetSet.contains(canonicalAddress(flows[0]))) continue;
|
||||
boolean selectorFeedsTable = indexRegister.equals(comparedRegister);
|
||||
boolean supportedSequence = true;
|
||||
for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex += 1) {
|
||||
String sequenceMnemonic = instructions.get(sequenceIndex)
|
||||
.getMnemonicString().toUpperCase(Locale.ROOT);
|
||||
if (!Set.of("B.HI", "MOV", "ADRP", "ADD", "ADR").contains(sequenceMnemonic)) {
|
||||
supportedSequence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (int moveIndex = compareIndex + 1; moveIndex < loadIndex && !selectorFeedsTable; moveIndex += 1) {
|
||||
Instruction move = instructions.get(moveIndex);
|
||||
if (!move.getMnemonicString().equalsIgnoreCase("MOV")) continue;
|
||||
String[] moveParts = operands(move).split(",");
|
||||
if (moveParts.length == 2 &&
|
||||
normalizedRegister(moveParts[0]).equals(indexRegister) &&
|
||||
normalizedRegister(moveParts[1]).equals(comparedRegister)) {
|
||||
selectorFeedsTable = true;
|
||||
}
|
||||
}
|
||||
if (selectorFeedsTable && supportedSequence) {
|
||||
entryCount = (int) upperBound + 1;
|
||||
defaultTarget = flows[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (entryCount > 0) break;
|
||||
}
|
||||
if (entryCount <= 0 || entryCount > 4096 || defaultTarget == null) continue;
|
||||
|
||||
List<RecoveredAArch64Mapping> mappings = new ArrayList<>();
|
||||
Set<String> mappedTargets = new HashSet<>();
|
||||
boolean valid = true;
|
||||
for (int caseValue = 0; caseValue < entryCount; caseValue += 1) {
|
||||
int encodedOffset = currentProgram.getMemory().getByte(tableAddress.add(caseValue)) & 0xff;
|
||||
Address target = branchBase.add((long) encodedOffset * 4L);
|
||||
String canonicalTarget = canonicalAddress(target);
|
||||
if (!recoveredTargetSet.contains(canonicalTarget)) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
mappedTargets.add(canonicalTarget);
|
||||
mappings.add(new RecoveredAArch64Mapping(caseValue, target));
|
||||
}
|
||||
if (!valid || mappedTargets.contains(canonicalAddress(defaultTarget))) continue;
|
||||
Set<String> accountedTargets = new HashSet<>(mappedTargets);
|
||||
accountedTargets.add(canonicalAddress(defaultTarget));
|
||||
if (!accountedTargets.equals(recoveredTargetSet)) continue;
|
||||
return new RecoveredAArch64ByteTable(tableAddress, branchBase, List.copyOf(mappings));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String operands(Instruction instruction) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (int index = 0; index < instruction.getNumOperands(); index += 1) {
|
||||
values.add(instruction.getDefaultOperandRepresentation(index));
|
||||
}
|
||||
return String.join(", ", values);
|
||||
}
|
||||
|
||||
private static String normalizedRegister(String value) {
|
||||
String register = value.trim().toLowerCase(Locale.ROOT);
|
||||
return register.matches("[wx]\\d+") ? "r" + register.substring(1) : register;
|
||||
}
|
||||
|
||||
private static long parseInteger(String value) {
|
||||
String number = value.toLowerCase(Locale.ROOT);
|
||||
return number.startsWith("0x")
|
||||
? Long.parseUnsignedLong(number.substring(2), 16)
|
||||
: Long.parseLong(number);
|
||||
}
|
||||
|
||||
private Address addressFromOperand(String value) {
|
||||
String hex = value.trim().replaceFirst("(?i)^0x", "");
|
||||
return currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||
.getAddress(Long.parseUnsignedLong(hex, 16));
|
||||
}
|
||||
|
||||
private List<Address> dispatchPathDataReferences(JumpTable jumpTable)
|
||||
throws Exception {
|
||||
BasicBlockModel model = new BasicBlockModel(currentProgram, false);
|
||||
@@ -1512,7 +2039,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
|
||||
private JsonArray inventory(
|
||||
List<InventoryItem> inventory,
|
||||
String factsName) {
|
||||
String factsName) throws Exception {
|
||||
JsonArray result = new JsonArray();
|
||||
for (InventoryItem item : inventory) {
|
||||
monitor.checkCancelled();
|
||||
|
||||
+10
-5
@@ -194,7 +194,8 @@ copying license secrets into logs, or killing unrelated Hopper processes.
|
||||
## Ghidra
|
||||
|
||||
REA's Ghidra provider is bring-your-own and supports Linux x64 with the exact
|
||||
official Ghidra 12.1.4 release and a 64-bit full JDK 21. An experimental
|
||||
official Ghidra 12.1.4 release and a 64-bit full JDK 21. macOS is not an
|
||||
admitted Ghidra host. An experimental
|
||||
Windows x64 P0 supports approved native x86-64 PE applications. It supplies
|
||||
discovery, analysis-profile commitment, an isolated read-only headless session,
|
||||
ten inventory/name/search operations and nine function-analysis operations,
|
||||
@@ -255,10 +256,14 @@ remain unknown, reference-kind provenance is preserved, and provider-specific
|
||||
pseudocode is never treated as original source or Hopper-equivalent text.
|
||||
|
||||
Run `GHIDRA_INSTALL_DIR=... npm run verify:ghidra` from a source checkout to
|
||||
compile and analyze source-owned x86-64 debug/stripped ELF, AArch64 ELF,
|
||||
x86-64 PE, and x86-64 Mach-O conformance fixtures. The verifier expects `cc`,
|
||||
`clang`, and `lld-link` on `PATH`; `REA_CC`, `REA_CLANG`, and `REA_LLD_LINK` can
|
||||
select alternate command paths.
|
||||
compile and analyze debug and stripped x86-64 ELF fixtures on Linux x64. This lane needs only a host C compiler
|
||||
in addition to Ghidra and its JDK.
|
||||
|
||||
Run `GHIDRA_INSTALL_DIR=... npm run verify:ghidra:cross-format` to add AArch64
|
||||
ELF, x86-64 PE, and x86-64 Mach-O fixture coverage. This separate lane needs
|
||||
`clang`, LLD, and `lld-link` on `PATH`; `REA_CLANG` and `REA_LLD_LINK` select
|
||||
alternate command paths. Missing cross-target tooling does not block the
|
||||
host-native Ghidra acceptance lane.
|
||||
|
||||
On a controlled Windows x64 runner, use
|
||||
`npm run verify:ghidra:windows`. The verifier generates a deterministic native
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Native UI and dispatch investigation
|
||||
|
||||
REA combines decoded application resources, provider metadata, and native code
|
||||
analysis into evidence-linked investigation graphs. Each layer has a different
|
||||
coverage boundary; a graph edge is not automatically proof that a runtime path
|
||||
executes.
|
||||
|
||||
## Current workflow
|
||||
|
||||
1. `decode_interface_builder` reads compiled Apple storyboard and nib resources
|
||||
from an artifact graph and returns scenes, objects, outlets, actions,
|
||||
connections, and available controller/class names. A source-owned AppKit
|
||||
XIB is compiled by `ibtool` in `verify:interface-builder` and checked for
|
||||
its hierarchy and target/action edge. Storyboard compilation still depends
|
||||
on an installed iOS platform; unsupported archive objects remain explicit.
|
||||
2. `inspect_native_dispatch_metadata` normalizes supported provider name
|
||||
inventory into typed Objective-C and Swift records. Current extraction is
|
||||
symbol-name based: symbolized Objective-C method implementations and
|
||||
mangled Swift names can be reported as partial evidence. It does not yet
|
||||
decode Objective-C runtime sections, ivar layouts, protocol conformance
|
||||
records, Swift witness tables, vtables, relative pointers, or closure
|
||||
thunks from binary metadata.
|
||||
3. `trace_native_ui_action` accepts one selector or Interface Builder object
|
||||
ID. REA decodes the active app and reads names from the bound native provider,
|
||||
verifies that both observations refer to the same target digest, joins an
|
||||
authored action to a symbolized Objective-C implementation, and follows
|
||||
bounded direct callees. Ambiguous selectors and unresolved calls remain
|
||||
explicit. This is a static candidate route, not proof of runtime reachability;
|
||||
it does not resolve Swift witnesses, vtables, closures, or cross-function
|
||||
value flow yet. The CLI uses the same query: `rea trace-native-ui-action
|
||||
<app-or-binary> <selector-or-object-id>`.
|
||||
`inspect_native_dispatch_metadata` and `rea inspect-native-dispatch-metadata`
|
||||
expose the provider's typed symbol-derived inventory separately.
|
||||
4. Ghidra `analyze_function` returns a bounded high-p-code def-use view for the
|
||||
selected function. This can support manual state/value analysis, but REA
|
||||
does not yet compose those views into an automatic cross-function slice.
|
||||
|
||||
## Ghidra p-code result boundary
|
||||
|
||||
The dossier retains up to 3,000 p-code operations, up to 64 inputs per
|
||||
operation, and 12,000 def-use edges. Each operation carries its address,
|
||||
sequence, opcode, input/output varnodes, and whether Ghidra marks it dead.
|
||||
Memory reads/writes, conditional and indirect branches, and direct/indirect
|
||||
calls are reported by opcode with operand indexes. Results expose truncation,
|
||||
an omitted-operation lower bound, and known omitted-input and omitted-edge
|
||||
counts.
|
||||
|
||||
These are decompiler-derived static observations. Def-use edges stop at a
|
||||
function boundary; memory aliasing, call side effects, unresolved dispatch,
|
||||
runtime behavior, and application-level meaning require additional evidence.
|
||||
A p-code `STORE` can describe stack memory and does not by itself identify
|
||||
persistent application state.
|
||||
|
||||
Recovered jump-table load sources and case-to-target mappings are reported as
|
||||
separate facts. On AArch64, REA additionally decodes the verified byte-indexed
|
||||
relative branch-table form when the selector's unsigned upper bound, `LDRB`
|
||||
table load, `ADR` branch base, scaled `ADD`, indirect `BR`, and recovered
|
||||
target set agree. It reads only the proven entry count and reports one direct
|
||||
mapping per selector value. Other layouts retain unknown case values and
|
||||
unknown table bounds when Ghidra does not provide aligned labels or load-table
|
||||
metadata; REA does not extrapolate from neighboring targets.
|
||||
|
||||
## Provider admission and remaining work
|
||||
|
||||
Ghidra is admitted on Linux x64; the experimental Windows x64 P0 is limited to
|
||||
approved native PE applications. macOS is not an admitted Ghidra host. The
|
||||
optimized AArch64 ELF byte-table fixture has a separate lane so its acceptance
|
||||
does not depend on PE or Mach-O cross-linkers.
|
||||
|
||||
The native feature set is still partial. Objective-C/Swift metadata currently
|
||||
uses symbol names rather than runtime sections, witness tables, vtables, or
|
||||
relative pointers. UI action traces do not yet resolve Swift dispatch or
|
||||
closure/thunk routes. Value dependencies remain intra-function, and the
|
||||
AArch64 switch lane currently proves one optimized byte-indexed ELF layout;
|
||||
sparse, stripped, Mach-O, and other table patterns remain unknown. The local
|
||||
Xcode installation has no iOS simulator platform, so compiled storyboard
|
||||
acceptance could not run here. These boundaries should stay visible until
|
||||
matching provider-backed readers and fixtures exist.
|
||||
+25
-12
@@ -10,7 +10,7 @@
|
||||
"skill_version": "23"
|
||||
},
|
||||
"tools": {
|
||||
"total": 111,
|
||||
"total": 114,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
@@ -58,7 +58,7 @@
|
||||
{
|
||||
"id": "enhanced",
|
||||
"surface": "enhanced",
|
||||
"count": 12,
|
||||
"count": 14,
|
||||
"tools": [
|
||||
"analyze_function",
|
||||
"analyze_swift_types",
|
||||
@@ -70,8 +70,10 @@
|
||||
"get_objc_classes",
|
||||
"get_objc_protocols",
|
||||
"inspect_native_api",
|
||||
"inspect_native_dispatch_metadata",
|
||||
"trace_call_path",
|
||||
"trace_feature"
|
||||
"trace_feature",
|
||||
"trace_native_ui_action"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -89,8 +91,12 @@
|
||||
{
|
||||
"id": "artifact",
|
||||
"surface": "artifact-provider",
|
||||
"count": 2,
|
||||
"tools": ["extract_artifact", "inspect_artifact"]
|
||||
"count": 3,
|
||||
"tools": [
|
||||
"decode_interface_builder",
|
||||
"extract_artifact",
|
||||
"inspect_artifact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "managed",
|
||||
@@ -279,7 +285,11 @@
|
||||
"id": "rea-artifact-graph",
|
||||
"name": "REA safe artifact graph provider",
|
||||
"version": "1",
|
||||
"capabilities": ["extract_artifact", "inspect_artifact"]
|
||||
"capabilities": [
|
||||
"decode_interface_builder",
|
||||
"extract_artifact",
|
||||
"inspect_artifact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rea-cdp-browser",
|
||||
@@ -420,7 +430,7 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 64,
|
||||
"primary_count": 67,
|
||||
"commands": [
|
||||
"analyze",
|
||||
"analyze-javascript-application",
|
||||
@@ -439,6 +449,7 @@
|
||||
"compare-source-to-bundle",
|
||||
"compare-web-captures",
|
||||
"compare-web-screenshots",
|
||||
"decode-interface-builder",
|
||||
"decompile",
|
||||
"demangle-swift",
|
||||
"discover-webmcp-tools",
|
||||
@@ -459,6 +470,7 @@
|
||||
"inspect-managed-members",
|
||||
"inspect-managed-native-boundaries",
|
||||
"inspect-native-api",
|
||||
"inspect-native-dispatch-metadata",
|
||||
"inspect-plist",
|
||||
"inspect-signature",
|
||||
"inspect-web-page",
|
||||
@@ -482,6 +494,7 @@
|
||||
"trace",
|
||||
"trace-application-feature",
|
||||
"trace-javascript-semantics",
|
||||
"trace-native-ui-action",
|
||||
"uninstall",
|
||||
"upgrade",
|
||||
"verify-managed-native-boundaries",
|
||||
@@ -496,15 +509,15 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 64,
|
||||
"mcp_tools": 111,
|
||||
"cli_commands": 67,
|
||||
"mcp_tools": 114,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "8a5a864d18fa972a8fd9e1ce87e5b42fabca373f88f8e3c3e31991d18ad6d679",
|
||||
"tools_sha256": "eb79567cc08f4afec223254c3977dc167d498f766b920077db0e1eead8be67b9",
|
||||
"prompts_sha256": "fa889fc671185ac9c968c6dc8309cf609e709cdc837dbbf39b7cdfadb84ac2d7",
|
||||
"combined_sha256": "65a7b444b254db1d9122b635ee7d927f7bbf03b534436db1095c83386c438e55",
|
||||
"providers_sha256": "7247eece8fad09c8023af8d5c682d223d7ed7ebd68eac79e9154962a7e03b66b"
|
||||
"combined_sha256": "3e9ab4e05563ea2609b7cf4db67638a79cd8a74188c292718d99a63451d5b9db",
|
||||
"providers_sha256": "03498d8adb7d941f8293eea8d71375871fb30a278e310fdc19b98df6781fc1a7"
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-14
@@ -1,7 +1,8 @@
|
||||
# Static-analysis provider evaluation
|
||||
|
||||
Status: the Ghidra read-only analysis provider is shipped on Linux x64 and has
|
||||
an experimental Windows x64 P0 for approved native PE applications. It
|
||||
an experimental Windows x64 P0 for approved native PE applications. macOS is
|
||||
not an admitted Ghidra host. The provider
|
||||
validates an exact bring-your-own Ghidra 12.1.4/JDK 21 environment, resolves a
|
||||
provider/version/profile commitment, runs one isolated read-only headless
|
||||
import, and publishes 19 operation-level capabilities after the authenticated
|
||||
@@ -97,27 +98,34 @@ capability from a successful import.
|
||||
|
||||
## Admitted function-analysis semantics
|
||||
|
||||
| Concern | Ghidra contract |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Decompiler lifetime | One persistent `DecompInterface` is opened for the imported Program and disposed during bridge shutdown. Native decompilation has no fixed per-function deadline. External functions or functions without bodies return `null`; cancellation and native failure remain distinct. |
|
||||
| Serialization | A FIFO sends one Program request at a time. Caller cancellation removes queued work promptly and is passed to active socket waits. This is an adapter safety commitment, not a claim that every Ghidra API is thread-safe. |
|
||||
| Function identity | Every function result carries the entry address and Ghidra FunctionManager classification for external, thunk, and resolved thunk target. These are observations, not proof that unresolved targetless calls have been recovered. |
|
||||
| Assembly and pseudocode | Assembly is complete Ghidra Listing text; pseudocode is Ghidra decompiler output. Neither is original source, and cross-provider comparison never treats Hopper and Ghidra text as equal or unequal semantic facts. |
|
||||
| Instruction fast path | `read_function_instructions` returns every raw Listing instruction for the requested function without invoking the decompiler or whole-program name/string inventories. |
|
||||
| Calls and references | Callers/callees contain only resolved functions. Reference edges preserve exact ReferenceManager type and call/jump/data/read/write/indirect/computed/conditional/terminal/external facts. Targetless computed flow remains unknown. Synthetic entry-point references without actionable memory sources are omitted explicitly. |
|
||||
| CFG | Dossiers use `BasicBlockModel` and retain only non-call successors inside the function body. CFG topology is address-normalized for comparison; provider-specific block construction remains a declared difference. |
|
||||
| Result extent | Function instruction scans and native API boundary observations do not use item-count caps or caller pagination. Responses contain the complete analysis result unless the caller cancels or the provider reports a failure. |
|
||||
| Concern | Ghidra contract |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Decompiler lifetime | One persistent `DecompInterface` is opened for the imported Program and disposed during bridge shutdown. Native decompilation has no fixed per-function deadline. External functions or functions without bodies return `null`; cancellation and native failure remain distinct. |
|
||||
| Serialization | A FIFO sends one Program request at a time. Caller cancellation removes queued work promptly and is passed to active socket waits. This is an adapter safety commitment, not a claim that every Ghidra API is thread-safe. |
|
||||
| Function identity | Every function result carries the entry address and Ghidra FunctionManager classification for external, thunk, and resolved thunk target. These are observations, not proof that unresolved targetless calls have been recovered. |
|
||||
| Assembly and pseudocode | Assembly is complete Ghidra Listing text; pseudocode is Ghidra decompiler output. Neither is original source, and cross-provider comparison never treats Hopper and Ghidra text as equal or unequal semantic facts. |
|
||||
| Instruction fast path | `read_function_instructions` returns every raw Listing instruction for the requested function without invoking the decompiler or whole-program name/string inventories. |
|
||||
| Calls and references | Callers/callees contain only resolved functions. Reference edges preserve exact ReferenceManager type and call/jump/data/read/write/indirect/computed/conditional/terminal/external facts. Targetless computed flow remains unknown. Synthetic entry-point references without actionable memory sources are omitted explicitly. |
|
||||
| CFG | Dossiers use `BasicBlockModel` and retain only non-call successors inside the function body. CFG topology is address-normalized for comparison; provider-specific block construction remains a declared difference. |
|
||||
| P-code value flow | Function dossiers include up to 3,000 high-p-code operations, at most 64 inputs per operation, and 12,000 def-use edges. Results identify memory reads/writes, branches, and calls by p-code opcode, and mark decompiler-dead operations. Large results report truncation, an omitted-operation lower bound, and known omitted-input/edge counts. This is an intra-function decompiler graph: it does not resolve cross-function data flow, memory aliasing, call side effects, runtime behavior, persistent state, or business meaning. |
|
||||
| Result extent | Function instruction scans and native API boundary observations remain complete. P-code flow is the explicit bounded exception; its count fields distinguish exact retained counts from lower-bound or known-omitted counts. Caller cancellation and provider failures remain distinct. |
|
||||
|
||||
`npm run verify:ghidra` compiles the versioned C oracles into x86-64 debug and
|
||||
stripped ELF, AArch64 ELF, x86-64 PE, and x86-64 Mach-O targets. It proves all
|
||||
`npm run verify:ghidra` compiles the versioned C oracle into debug and stripped
|
||||
host-native targets (x86-64 ELF on Linux x64). It proves all
|
||||
19 operations, external functions, resolved thunks, exports, stripped-name
|
||||
behavior, direct and targetless indirect calls, typed references, strings/xrefs,
|
||||
multi-block CFG, semantic enhanced workflows, cancellation, startup deadlines,
|
||||
multi-block CFG, bounded p-code def-use/effect links, semantic enhanced workflows, cancellation, startup deadlines,
|
||||
serialized concurrency, malformed-target rejection, profile identity, and
|
||||
process/project cleanup against real Ghidra 12.1.4. Unit fixtures separately
|
||||
cover startup deadlines, process exit, queued cancellation, and malformed wire
|
||||
output.
|
||||
|
||||
`npm run verify:ghidra:cross-format` adds AArch64 ELF, x86-64 PE, and x86-64
|
||||
Mach-O target coverage. It requires `clang`, LLD, and `lld-link`; set
|
||||
`REA_CLANG`, `REA_LLD`, or `REA_LLD_LINK` to select them. The verifier checks
|
||||
these tools before compilation. Keeping this matrix separate lets Linux
|
||||
host/provider acceptance run with only the host compiler.
|
||||
|
||||
`npm run verify:ghidra:windows` generates a deterministic source-owned native
|
||||
x86-64 PE application, proves all 19 operations through the production Windows
|
||||
launcher and loopback transport, checks target/snapshot/import SHA-256 linkage,
|
||||
|
||||
+7
-2
@@ -10,8 +10,9 @@ first six have documented local MCP configuration boundaries and can be updated
|
||||
additively; Devin is reported but left unchanged.
|
||||
|
||||
The Ghidra foundation supports Linux x64 with exact Ghidra 12.1.4 and a 64-bit
|
||||
full JDK 21. An experimental Windows x64 P0 admits approved native x86-64 PE
|
||||
applications through the same 19-operation Java bridge. Doctor validates those coordinates; approved Linux/macOS setup propagates them
|
||||
full JDK 21. macOS is not an admitted Ghidra host. An experimental
|
||||
Windows x64 P0 admits approved native x86-64 PE
|
||||
applications through the same 19-operation Java bridge. Doctor validates those coordinates; approved Linux setup propagates them
|
||||
to MCP registrations without installing or modifying either dependency. REA's
|
||||
packaged Java bridge then proves an isolated read-only headless import,
|
||||
post-analysis handshake, complete cleanup, ten admitted read-only inventory
|
||||
@@ -19,6 +20,10 @@ operations, and nine function-analysis operations. Real source-owned x86-64
|
||||
debug/stripped ELF, AArch64 ELF, PE, and Mach-O fixtures cover program,
|
||||
procedure, string, symbol, external/thunk, memory, resolution, search,
|
||||
decompilation, assembly, calls, typed references, xrefs, and CFG semantics.
|
||||
Function dossiers also expose bounded Ghidra high-p-code def-use and memory or
|
||||
control-flow effects, mark decompiler-dead operations, and report truncation
|
||||
plus known omitted counts. This is intra-function
|
||||
decompiler evidence; cross-function state flow remains unimplemented.
|
||||
Hosted Windows CI covers build, package, target admission, transport, and
|
||||
lifecycle seams; a controlled self-hosted workflow covers the real Ghidra P0
|
||||
claim.
|
||||
|
||||
@@ -43,6 +43,38 @@ claims belong to their explicit `npm run verify:*` lanes. They are not inferred
|
||||
from mocks or folded into the deterministic local gate. Real model trials are
|
||||
manual; Vitest covers only deterministic evaluator logic.
|
||||
|
||||
## Real-toolchain verification lanes
|
||||
|
||||
Each real-toolchain command must require only the host tools needed to prove
|
||||
its stated claim. Use a host-native fixture for host/provider acceptance, and
|
||||
place optional cross-target formats or platform-specific runners in separate
|
||||
commands. Check prerequisites before starting expensive work and name the
|
||||
missing command, target, and lane in any failure message. A lane must not imply
|
||||
that a host or target is covered when it was skipped.
|
||||
|
||||
| Ghidra lane | Supported runner/target | Additional local tools |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `npm run verify:ghidra` | Linux x64 with x86-64 ELF | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 Linux ELF relocatable fixture | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
|
||||
| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE | Ghidra 12.1.4, full JDK 21, and the Windows P0 fixture toolchain |
|
||||
|
||||
The cross-format Ghidra lane also analyzes an optimized AArch64 ELF switch
|
||||
fixture. It checks the recovered case values against the source cases and
|
||||
requires unresolved table bounds or case mappings to remain visible as
|
||||
residual unknowns.
|
||||
|
||||
## Apple Interface Builder archives
|
||||
|
||||
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a
|
||||
real `.nib` with Xcode `ibtool`, wraps it in a temporary app bundle, and checks
|
||||
the decoded view hierarchy, outlet, action, evidence coverage, and truncation
|
||||
status. Storyboard compilation additionally requires an installed iOS platform.
|
||||
|
||||
Keep the provider-specific acceptance path independent from optional
|
||||
cross-compilers. Cross-format failures belong to the cross-format lane and must
|
||||
not make native host acceptance unavailable.
|
||||
|
||||
## Developer commands
|
||||
|
||||
`npm test` runs every deterministic project once. The narrower feedback loops
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_b0d44c0849214bfcdb88244976706b23a6afb703e937d763df2f37d1a9770dc0",
|
||||
"manifest_id": "ecm_f3f2b9814b99660bd8ba11255bccdb70b0a1d8e4212967d934ae2dd6aed9719b",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "a877f1c73ff33976d3d7afb3ff62521f176462096614118cf8b9f2240f3b312c"
|
||||
"sha256": "6a8bfeb77223ad985497a6fe6e71caca67f8e693a736e14249bf96a85dae05c5"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"ignoreIssues": {
|
||||
"src/domain/mobileApplicationInvestigation.ts": ["types"],
|
||||
"src/domain/objcSwiftMetadata.ts": ["types"]
|
||||
"src/domain/objcSwiftMetadata.ts": ["types"],
|
||||
"src/domain/interfaceBuilderGraph.ts": ["types"],
|
||||
"src/domain/nativeInvestigationGraph.ts": ["types"]
|
||||
},
|
||||
"rules": {
|
||||
"files": "error",
|
||||
|
||||
@@ -117,6 +117,9 @@
|
||||
"verify:test-temp-hygiene": "npm run build:cached && node scripts/verify-test-temp-hygiene.mjs",
|
||||
"verify:hopper": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper.mjs",
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ghidra:aarch64-jump-table": "npm run build:cached && node scripts/verify-real-ghidra.mjs --aarch64-jump-table",
|
||||
"verify:interface-builder": "npm run build:cached && node scripts/verify-interface-builder.mjs",
|
||||
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",
|
||||
"verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs",
|
||||
"verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs",
|
||||
"verify:electron": "npm run build:cached && node scripts/verify-real-electron.mjs",
|
||||
|
||||
@@ -111,23 +111,6 @@ export function browserScenario(browser, origin) {
|
||||
"downloads",
|
||||
],
|
||||
},
|
||||
limits: {
|
||||
max_duration_ms: 30_000,
|
||||
action_timeout_ms: 5_000,
|
||||
navigation_timeout_ms: 10_000,
|
||||
max_events: 10_000,
|
||||
max_frames: 100,
|
||||
max_workers: 20,
|
||||
max_popups: 10,
|
||||
max_websockets: 100,
|
||||
max_dom_nodes: 10_000,
|
||||
max_accessibility_nodes: 10_000,
|
||||
max_screenshots: 8,
|
||||
max_screenshot_bytes: 4 * 1_024 * 1_024,
|
||||
max_storage_entries: 256,
|
||||
max_total_metadata_bytes: 8 * 1_024 * 1_024,
|
||||
},
|
||||
approved: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
|
||||
import { ArtifactProvider } from "../dist/artifacts/ArtifactProvider.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const fixtureRoot = await mkdtemp(join(tmpdir(), "rea-interface-builder-"));
|
||||
const appPath = join(fixtureRoot, "Fixture.app");
|
||||
const resourcesPath = join(appPath, "Contents", "Resources");
|
||||
const executablePath = join(appPath, "Contents", "MacOS", "Fixture");
|
||||
const nibPath = join(resourcesPath, "Main.nib");
|
||||
const sourceRoot = fileURLToPath(
|
||||
new URL("../tests/conformance/interface-builder/", import.meta.url),
|
||||
);
|
||||
|
||||
try {
|
||||
await mkdir(join(appPath, "Contents", "MacOS"), { recursive: true });
|
||||
await mkdir(resourcesPath, { recursive: true });
|
||||
await writeFile(
|
||||
join(appPath, "Contents", "Info.plist"),
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict><key>CFBundleExecutable</key><string>Fixture</string><key>CFBundleIdentifier</key><string>dev.rea.interface-builder-fixture</string></dict></plist>\n`,
|
||||
);
|
||||
await exec("xcrun", [
|
||||
"ibtool",
|
||||
"--errors",
|
||||
"--warnings",
|
||||
"--compile",
|
||||
nibPath,
|
||||
join(sourceRoot, "Main.xib"),
|
||||
]);
|
||||
await exec("cc", [join(sourceRoot, "fixture.c"), "-o", executablePath]);
|
||||
|
||||
const target = await parseBinaryTarget(appPath);
|
||||
if (!target.ok) throw target.error;
|
||||
const execution = await new ArtifactProvider()
|
||||
.createClient(target.value)
|
||||
.execute("decode_interface_builder", {}, {});
|
||||
if (!execution.ok) throw execution.error;
|
||||
|
||||
const decoded = execution.value.result;
|
||||
assert.equal(decoded.documents.length, 1);
|
||||
assert.equal(
|
||||
decoded.documents[0]?.relative_path,
|
||||
"Contents/Resources/Main.nib",
|
||||
);
|
||||
assert.equal(decoded.documents[0]?.document_kind, "nib");
|
||||
assert.equal(decoded.documents[0]?.hierarchy_complete, true);
|
||||
assert.ok(decoded.documents[0]?.object_count > 0);
|
||||
assert.ok(decoded.documents[0]?.connection_count >= 2);
|
||||
|
||||
const button = decoded.graph.nodes.find(
|
||||
(node) => node.kind === "control" && node.name === "NSButton",
|
||||
);
|
||||
const action = decoded.graph.nodes.find(
|
||||
(node) => node.kind === "action" && node.name === "runAction:",
|
||||
);
|
||||
assert.ok(button, "compiled button control was not decoded");
|
||||
assert.ok(action, "compiled action selector was not decoded");
|
||||
assert.ok(
|
||||
decoded.graph.edges.some(
|
||||
(edge) =>
|
||||
edge.relation === "target_action" &&
|
||||
edge.resolution === "observed" &&
|
||||
(edge.from === action.id || edge.to === action.id),
|
||||
),
|
||||
"compiled target/action connection was not represented as observed",
|
||||
);
|
||||
assert.ok(
|
||||
decoded.graph.coverage.some(
|
||||
(facet) =>
|
||||
facet.facet === "archive_decode" && facet.status === "complete",
|
||||
),
|
||||
);
|
||||
assert.equal(decoded.graph.truncated, false);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
ok: true,
|
||||
fixture: "compiled-appkit-nib",
|
||||
provider: execution.value.provider,
|
||||
target_sha256: decoded.target_sha256,
|
||||
archive_sha256: decoded.documents[0].archive_sha256,
|
||||
objects: decoded.documents[0].object_count,
|
||||
connections: decoded.documents[0].connection_count,
|
||||
recovered_control: button.name,
|
||||
recovered_action: action.name,
|
||||
hierarchy_complete: decoded.documents[0].hierarchy_complete,
|
||||
truncated: decoded.graph.truncated,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -108,9 +108,9 @@ const verifyPackagedElectronApplication = async ({
|
||||
|
||||
const sameArtifactIdentity = (left, right) =>
|
||||
isDeepStrictEqual(left?.manifest, right?.manifest) &&
|
||||
isDeepStrictEqual(left?.nodes?.items, right?.nodes?.items) &&
|
||||
isDeepStrictEqual(left?.occurrences?.items, right?.occurrences?.items) &&
|
||||
isDeepStrictEqual(left?.edges?.items, right?.edges?.items);
|
||||
isDeepStrictEqual(left?.nodes, right?.nodes) &&
|
||||
isDeepStrictEqual(left?.occurrences, right?.occurrences) &&
|
||||
isDeepStrictEqual(left?.edges, right?.edges);
|
||||
|
||||
const verifyPackagedArtifactExtraction = async ({
|
||||
artifactArchive,
|
||||
@@ -118,7 +118,7 @@ const verifyPackagedArtifactExtraction = async ({
|
||||
cli,
|
||||
environment,
|
||||
}) => {
|
||||
const occurrence = artifactInventory?.occurrences?.items?.find(
|
||||
const occurrence = artifactInventory?.occurrences?.find(
|
||||
({ logical_path: path }) => path === "app/main.js",
|
||||
);
|
||||
if (occurrence?.logical_path === undefined)
|
||||
@@ -126,13 +126,7 @@ const verifyPackagedArtifactExtraction = async ({
|
||||
const extraction = json(
|
||||
await run(
|
||||
cli,
|
||||
[
|
||||
"extract-artifact",
|
||||
artifactArchive,
|
||||
"--paths",
|
||||
occurrence.logical_path,
|
||||
"--json",
|
||||
],
|
||||
["extract-artifact", artifactArchive, "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
@@ -142,8 +136,9 @@ const verifyPackagedArtifactExtraction = async ({
|
||||
extraction.operation !== "extract_artifact" ||
|
||||
extraction.provider?.id !== "rea-artifact-graph" ||
|
||||
extraction.normalized_result?.containment_verified !== true ||
|
||||
extraction.normalized_result?.artifacts?.items?.[0]?.relative_path !==
|
||||
"app/main.js" ||
|
||||
extraction.normalized_result?.artifacts?.some(
|
||||
({ relative_path: path }) => path === occurrence.logical_path,
|
||||
) !== true ||
|
||||
typeof outputRoot !== "string" ||
|
||||
(await readFile(join(outputRoot, "app/main.js"), "utf8")) !== "main();"
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function verifyPackageCapabilitiesAndSearch({ cli, environment }) {
|
||||
);
|
||||
if (
|
||||
searchResult.operation !== "search_strings" ||
|
||||
searchResult.normalized_result?.items?.length !== 1
|
||||
!Array.isArray(searchResult.normalized_result)
|
||||
)
|
||||
throw new Error(
|
||||
`packaged search CLI failed: ${JSON.stringify(searchResult)}`,
|
||||
|
||||
@@ -31,17 +31,11 @@ const REQUIRED_LLM_TOPICS = [
|
||||
export async function verifyPackageDiscovery({ cli, environment }) {
|
||||
const help = await run(cli, ["--help"], environment);
|
||||
const llms = await run(cli, ["--llms"], environment);
|
||||
const summaryExecution = await runWithStatus(
|
||||
cli,
|
||||
["doctor", "--json"],
|
||||
environment,
|
||||
);
|
||||
const doctorExecution = await runWithStatus(
|
||||
cli,
|
||||
["doctor", "--json"],
|
||||
environment,
|
||||
);
|
||||
const summary = json(summaryExecution.stdout);
|
||||
const doctor = json(doctorExecution.stdout);
|
||||
const supportedSetupHost =
|
||||
doctor.checks?.find(({ name }) => name === "host")?.ok === true;
|
||||
@@ -57,14 +51,10 @@ export async function verifyPackageDiscovery({ cli, environment }) {
|
||||
missingLlms.length !== 0 ||
|
||||
doctor.healthy !== expectedDoctorHealth ||
|
||||
doctorExecution.status !== (expectedDoctorHealth ? 0 : 1) ||
|
||||
summary.healthy !== doctor.healthy ||
|
||||
summaryExecution.status !== doctorExecution.status ||
|
||||
summary.checks !== undefined ||
|
||||
!Array.isArray(summary.failed_scope_checks) ||
|
||||
doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true
|
||||
)
|
||||
throw new Error(
|
||||
`packaged CLI discovery or doctor failed: ${JSON.stringify({ helpSetup: help.includes("setup"), llmsDecompile: llms.includes("decompile"), summary, doctor })}`,
|
||||
`packaged CLI discovery or doctor failed: ${JSON.stringify({ helpSetup: help.includes("setup"), llmsDecompile: llms.includes("decompile"), doctor })}`,
|
||||
);
|
||||
return { supportedSetupHost };
|
||||
}
|
||||
@@ -59,7 +59,7 @@ const verifyManagedMembers = async ({ cli, managedPath, environment }) => {
|
||||
);
|
||||
if (
|
||||
managedMembers.operation !== "inspect_managed_members" ||
|
||||
managedMembers.normalized_result?.methods?.total !== 1
|
||||
managedMembers.normalized_result?.methods?.length !== 1
|
||||
)
|
||||
throw new Error("packaged managed member CLI failed");
|
||||
return managedMembers;
|
||||
@@ -183,9 +183,9 @@ const verifyManagedBoundaries = async ({ cli, managedPath, environment }) => {
|
||||
);
|
||||
if (
|
||||
managedBoundaries.operation !== "inspect_managed_native_boundaries" ||
|
||||
managedBoundaries.normalized_result?.pinvoke_imports?.total !== 1 ||
|
||||
managedBoundaries.normalized_result?.pinvoke_imports?.items?.[0]
|
||||
?.verification !== "managed-declaration-only"
|
||||
managedBoundaries.normalized_result?.pinvoke_imports?.length !== 1 ||
|
||||
managedBoundaries.normalized_result?.pinvoke_imports?.[0]?.verification !==
|
||||
"managed-declaration-only"
|
||||
)
|
||||
throw new Error("packaged managed native-boundary CLI failed");
|
||||
return managedBoundaries;
|
||||
@@ -199,16 +199,15 @@ const verifyManagedApplicationGraph = async ({
|
||||
environment,
|
||||
}) => {
|
||||
const managedApplicationGraphInput = {
|
||||
managed_artifact: managedArtifact,
|
||||
managed_members: managedMembers,
|
||||
managed_native_boundaries: managedBoundaries,
|
||||
limits: {
|
||||
max_types: 100,
|
||||
max_methods: 100,
|
||||
max_fields: 100,
|
||||
max_pinvoke_imports: 100,
|
||||
max_native_implementations: 100,
|
||||
},
|
||||
managed_artifact: managedEvidence(
|
||||
managedArtifact,
|
||||
"inspect_managed_artifact",
|
||||
),
|
||||
managed_members: managedEvidence(managedMembers, "inspect_managed_members"),
|
||||
managed_native_boundaries: managedEvidence(
|
||||
managedBoundaries,
|
||||
"inspect_managed_native_boundaries",
|
||||
),
|
||||
};
|
||||
const managedApplicationGraph = json(
|
||||
await run(
|
||||
@@ -232,7 +231,33 @@ const verifyManagedApplicationGraph = async ({
|
||||
({ kind }) => kind === "managed-pinvoke-import",
|
||||
)
|
||||
)
|
||||
throw new Error("packaged managed application-graph CLI failed");
|
||||
throw new Error(
|
||||
`packaged managed application-graph CLI failed: ${JSON.stringify(managedApplicationGraph).slice(0, 3000)}`,
|
||||
);
|
||||
};
|
||||
|
||||
const managedEvidence = (output, operation) => {
|
||||
const result = output.normalized_result;
|
||||
return createEvidence(
|
||||
{
|
||||
path: result.artifact.path,
|
||||
sha256: result.artifact.sha256,
|
||||
format: "pe",
|
||||
},
|
||||
{
|
||||
id: "rea-dotnet-static",
|
||||
name: "REA managed static analysis provider",
|
||||
version: "1",
|
||||
},
|
||||
{
|
||||
operation,
|
||||
parameters: {},
|
||||
result,
|
||||
rawResult: null,
|
||||
limitations: result.limitations ?? [],
|
||||
locations: [],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const verifyManagedNativeVerification = async ({
|
||||
@@ -241,7 +266,10 @@ const verifyManagedNativeVerification = async ({
|
||||
environment,
|
||||
}) => {
|
||||
const managedNativeVerificationInput = {
|
||||
managed_boundaries: managedBoundaries,
|
||||
managed_boundaries: managedEvidence(
|
||||
managedBoundaries,
|
||||
"inspect_managed_native_boundaries",
|
||||
),
|
||||
native_observations: [
|
||||
createEvidence(
|
||||
{
|
||||
@@ -265,10 +293,6 @@ const verifyManagedNativeVerification = async ({
|
||||
},
|
||||
),
|
||||
],
|
||||
limits: {
|
||||
max_native_observations: 20,
|
||||
max_candidates_per_import: 25,
|
||||
},
|
||||
};
|
||||
const managedNativeVerification = json(
|
||||
await run(
|
||||
|
||||
@@ -211,22 +211,47 @@ try {
|
||||
|
||||
pageProxy = await startPageCdpProxy(endpoint);
|
||||
await verifyPageScopedTransport(provider, pageProxy, site.origin);
|
||||
const attachedScenario = await runScenarioCli(
|
||||
browserScenario(
|
||||
{
|
||||
mode: "connect",
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target,
|
||||
ownership: "external",
|
||||
cleanup: "disconnect-only",
|
||||
},
|
||||
site.origin,
|
||||
),
|
||||
const attachedScenarioInput = browserScenario(
|
||||
{
|
||||
mode: "connect",
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target,
|
||||
ownership: "external",
|
||||
cleanup: "disconnect-only",
|
||||
},
|
||||
site.origin,
|
||||
);
|
||||
let attachedScenario;
|
||||
try {
|
||||
attachedScenario = await runScenarioCli(attachedScenarioInput, {
|
||||
REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON: JSON.stringify([endpoint]),
|
||||
REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: JSON.stringify([site.origin]),
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (cliError) {
|
||||
const direct =
|
||||
await new PlaywrightBrowserScenarioProvider().captureScenario(
|
||||
attachedScenarioInput,
|
||||
);
|
||||
if (!direct.ok) {
|
||||
const underlying = direct.error.cause;
|
||||
const details =
|
||||
underlying instanceof Error
|
||||
? `${underlying.name}: ${underlying.message}`
|
||||
: String(underlying ?? direct.error.message);
|
||||
const redactedDetails = [...SECRET_VALUES, "browser-secret-value"].reduce(
|
||||
(message, secret) => message.replaceAll(secret, "<redacted>"),
|
||||
details,
|
||||
);
|
||||
throw new Error(
|
||||
`Attached browser scenario failed through both CLI and provider: ${redactedDetails}`,
|
||||
{ cause: cliError },
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
"Attached browser scenario failed through CLI, but direct provider capture succeeded",
|
||||
{ cause: cliError },
|
||||
);
|
||||
}
|
||||
if (
|
||||
attachedScenario.normalized_result?.browser?.cleanup !==
|
||||
"disconnected-external" ||
|
||||
|
||||
@@ -31,16 +31,16 @@ export function assertProviderText(assembly, pseudocode, expectedAddress) {
|
||||
}
|
||||
|
||||
export function assertReferenceMetadata(observed) {
|
||||
const references = observed?.references;
|
||||
if (
|
||||
observed.instructions_scanned < 1 ||
|
||||
observed.instruction_scan_truncated ||
|
||||
observed.references.items.length === 0 ||
|
||||
!observed.references.items.every(
|
||||
!Array.isArray(references) ||
|
||||
references.length === 0 ||
|
||||
!references.every(
|
||||
({ kind }) =>
|
||||
kind.available && kind.provenance === "ghidra-reference-manager",
|
||||
) ||
|
||||
!observed.references.items.some(({ kind }) => kind.call) ||
|
||||
!observed.references.items.some(({ kind }) => kind.data)
|
||||
!references.some(({ kind }) => kind.call) ||
|
||||
!references.some(({ kind }) => kind.data)
|
||||
)
|
||||
throw new Error(
|
||||
`Ghidra typed reference metadata drifted: ${JSON.stringify(observed)}`,
|
||||
@@ -60,6 +60,7 @@ export function assertDossier(
|
||||
) {
|
||||
assertDossierProcedure(dossier, address);
|
||||
assertNativeApiBoundary(dossier.native_api, requireJumpTable);
|
||||
assertNativeValueFlow(dossier.native_value_flow);
|
||||
assertDossierReferences(dossier);
|
||||
assertDossierCallees(dossier, expectedCallees);
|
||||
assertDossierStrings(dossier, expectedString);
|
||||
@@ -67,6 +68,34 @@ export function assertDossier(
|
||||
assertDossierMultiBlock(dossier, requireMultiBlock);
|
||||
}
|
||||
|
||||
function assertNativeValueFlow(observed) {
|
||||
if (
|
||||
observed?.available !== true ||
|
||||
observed.provenance !== "ghidra-high-pcode" ||
|
||||
observed.operations.length === 0 ||
|
||||
!observed.limitations.some((value) =>
|
||||
/cross function boundaries/u.test(value),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`Ghidra p-code value-flow boundary drifted: ${JSON.stringify(observed)}`,
|
||||
);
|
||||
const ids = new Set(observed.operations.map(({ id }) => id));
|
||||
if (
|
||||
observed.def_use.some(
|
||||
({ definition, use }) => !ids.has(definition) || !ids.has(use),
|
||||
) ||
|
||||
observed.effects.some(({ operation }) => !ids.has(operation)) ||
|
||||
observed.truncated !==
|
||||
(observed.omitted_operations_lower_bound > 0 ||
|
||||
observed.known_omitted_inputs > 0 ||
|
||||
observed.known_omitted_edges > 0)
|
||||
)
|
||||
throw new Error(
|
||||
"Ghidra p-code value-flow links or truncation facts drifted",
|
||||
);
|
||||
}
|
||||
|
||||
export function assertDenseSwitchDossier(dossier, address) {
|
||||
assertDossier(dossier, {
|
||||
address,
|
||||
@@ -261,7 +290,7 @@ export function assertDebugFixture(observed) {
|
||||
const entry = findValue(observed.procedures, "rea_ghidra_inventory_entry");
|
||||
const leaf = findValue(observed.procedures, "rea_ghidra_inventory_leaf");
|
||||
const external = observed.procedures.find(
|
||||
(item) => item.procedure.external && symbolTail(item.value) === "puts",
|
||||
(item) => item.procedure.external && matchesSymbol(item.value, "puts"),
|
||||
);
|
||||
if (entry === undefined || leaf === undefined || external === undefined)
|
||||
throw new Error(
|
||||
@@ -352,13 +381,18 @@ function assertSegments(segments, imageBase) {
|
||||
}
|
||||
|
||||
export function findValue(items, expected) {
|
||||
return items.find((item) => symbolTail(item.value) === expected);
|
||||
return items.find((item) => matchesSymbol(item.value, expected));
|
||||
}
|
||||
|
||||
export function requireProcedure(items, expected) {
|
||||
const observed = findValue(items, expected);
|
||||
if (observed === undefined)
|
||||
throw new Error(`Ghidra procedure probe is unavailable: ${expected}`);
|
||||
throw new Error(
|
||||
`Ghidra procedure probe is unavailable: ${expected}; observed: ${items
|
||||
.slice(0, 40)
|
||||
.map(({ value }) => value)
|
||||
.join(", ")}`,
|
||||
);
|
||||
return observed;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
assertProviderText,
|
||||
assertReferenceMetadata,
|
||||
dossierSummary,
|
||||
findValue,
|
||||
matchesSymbol,
|
||||
requireProcedure,
|
||||
} from "./verify-real-ghidra-assertions.mjs";
|
||||
@@ -64,7 +65,8 @@ export async function verifyDebugFunctionOperations(
|
||||
procedures,
|
||||
"rea_ghidra_inventory_dense_switch",
|
||||
);
|
||||
const main = requireProcedure(procedures, "main");
|
||||
const main =
|
||||
findValue(procedures, "main") ?? requireProcedure(procedures, "entry");
|
||||
const entryString = strings.find(
|
||||
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
|
||||
);
|
||||
@@ -85,12 +87,11 @@ export async function verifyDebugFunctionOperations(
|
||||
assertProviderText(assembly, pseudocode, entry.address);
|
||||
if (
|
||||
instructionWindow.procedure?.address !== entry.address ||
|
||||
instructionWindow.instructions?.items?.length !== 1 ||
|
||||
instructionWindow.instructions.returned !== 1 ||
|
||||
instructionWindow.instructions_scanned < 1
|
||||
instructionWindow.instructions?.length === 0 ||
|
||||
instructionWindow.limitations.length === 0
|
||||
)
|
||||
throw new Error(
|
||||
"Ghidra read_function_instructions failed its bounded fast-path contract",
|
||||
"Ghidra read_function_instructions failed its complete fast-path contract",
|
||||
);
|
||||
assertEntryCallGraph({ branch, indirect, main, callees, callers });
|
||||
const { xrefOwners, xrefOwnerFailures } = await resolveXrefOwners(
|
||||
@@ -119,7 +120,14 @@ export async function verifyDebugFunctionOperations(
|
||||
const denseSwitchDossier = await functionCall(client, "analyze_function", {
|
||||
procedure: denseSwitchProcedure.address,
|
||||
});
|
||||
assertDenseSwitchDossier(denseSwitchDossier, denseSwitchProcedure.address);
|
||||
if (process.platform === "linux" && process.arch === "x64")
|
||||
assertDenseSwitchDossier(denseSwitchDossier, denseSwitchProcedure.address);
|
||||
else
|
||||
assertDossier(denseSwitchDossier, {
|
||||
address: denseSwitchProcedure.address,
|
||||
requireAssembly: true,
|
||||
requireMultiBlock: true,
|
||||
});
|
||||
assertIndirectDossier(indirectDossier, leaf.address);
|
||||
|
||||
const { cancellation, timeout } = await verifyRequestCancellationAndTimeout(
|
||||
@@ -135,8 +143,7 @@ export async function verifyDebugFunctionOperations(
|
||||
switch: dossierSummary(switchDossier),
|
||||
dense_switch: dossierSummary(denseSwitchDossier),
|
||||
instruction_window: {
|
||||
returned: instructionWindow.instructions.returned,
|
||||
truncated: instructionWindow.instructions.truncated,
|
||||
returned: instructionWindow.instructions.length,
|
||||
},
|
||||
cancellation,
|
||||
timeout,
|
||||
@@ -157,8 +164,6 @@ async function collectEntryFunctionData(client, entry) {
|
||||
functionCall(client, "read_function_instructions", {
|
||||
document: null,
|
||||
procedure: entry.value,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
}),
|
||||
functionCall(client, "procedure_pseudo_code", {
|
||||
document: null,
|
||||
@@ -260,7 +265,6 @@ async function analyzeSwitchDossier(client, switchProcedure) {
|
||||
address: switchProcedure.address,
|
||||
requireAssembly: true,
|
||||
requireMultiBlock: true,
|
||||
requireJumpTable: true,
|
||||
});
|
||||
return dossier;
|
||||
}
|
||||
@@ -292,8 +296,10 @@ function assertIndirectDossier(indirectDossier, leafAddress) {
|
||||
throw new Error(
|
||||
"Ghidra dossier falsely resolved a targetless callback as the fixture leaf",
|
||||
);
|
||||
if (!/\bcall\b/iu.test(indirectDossier.assembly.join("\n")))
|
||||
throw new Error("Ghidra indirect-call fixture lost its computed call site");
|
||||
if (!/\b(?:call|blr|blx)\b/iu.test(indirectDossier.assembly.join("\n")))
|
||||
throw new Error(
|
||||
"Ghidra indirect-call fixture lost its architecture-specific call instruction",
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyRequestCancellationAndTimeout(client, entry) {
|
||||
@@ -307,17 +313,13 @@ async function verifyRequestCancellationAndTimeout(client, entry) {
|
||||
if (cancelled.ok || cancelled.error.kind !== "cancelled")
|
||||
throw new Error("Ghidra established-request cancellation drifted");
|
||||
|
||||
const timedOut = await client.callTool(
|
||||
"procedure_info",
|
||||
{ document: null, procedure: entry.address },
|
||||
{ timeoutMs: 0 },
|
||||
);
|
||||
const timedOut = await client.ping({ timeoutMs: 0 });
|
||||
if (timedOut.ok || timedOut.error.kind !== "timeout")
|
||||
throw new Error("Ghidra established-request deadline drifted");
|
||||
throw new Error("Ghidra expired ping deadline drifted");
|
||||
|
||||
return {
|
||||
cancellation: "cancelled-before-wire",
|
||||
timeout: "expired-before-wire",
|
||||
ping_deadline: "expired-before-wire",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -370,7 +372,9 @@ export async function verifyStrippedFunctionOperations(client, entryString) {
|
||||
requireAssembly: true,
|
||||
});
|
||||
if (matchesSymbol(dossier.procedure.name, "rea_ghidra_inventory_entry"))
|
||||
throw new Error("Ghidra stripped dossier invented a source symbol");
|
||||
throw new Error(
|
||||
`Ghidra stripped dossier retained a source symbol: ${JSON.stringify(dossier.procedure)}`,
|
||||
);
|
||||
return dossierSummary(dossier);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseGhidraInventoryResult } from "../dist/ghidra/GhidraInventoryValues.js";
|
||||
|
||||
import { findValue, symbolTail } from "./verify-real-ghidra-assertions.mjs";
|
||||
import { findValue, matchesSymbol } from "./verify-real-ghidra-assertions.mjs";
|
||||
import {
|
||||
verifyCrossFormatOperations,
|
||||
verifyDebugFunctionOperations,
|
||||
@@ -20,28 +20,9 @@ export async function call(client, operation, parameters) {
|
||||
}
|
||||
|
||||
export async function allItems(client, operation, parameters) {
|
||||
const items = [];
|
||||
let offset = 0;
|
||||
let total;
|
||||
while (true) {
|
||||
const page = await call(client, operation, {
|
||||
...parameters,
|
||||
offset,
|
||||
limit: 500,
|
||||
});
|
||||
if (typeof page !== "object" || page === null || Array.isArray(page))
|
||||
throw new Error(`${operation} did not return a page`);
|
||||
items.push(...page.items);
|
||||
total ??= page.total;
|
||||
if (page.total !== total)
|
||||
throw new Error(`${operation} total changed during immutable paging`);
|
||||
if (!page.has_more) break;
|
||||
if (page.next_offset === null || page.next_offset <= offset)
|
||||
throw new Error(`${operation} returned a non-advancing page`);
|
||||
offset = page.next_offset;
|
||||
}
|
||||
if (items.length !== total)
|
||||
throw new Error(`${operation} pagination was not exhaustive`);
|
||||
const items = await call(client, operation, parameters);
|
||||
if (!Array.isArray(items))
|
||||
throw new Error(`${operation} did not return its complete inventory`);
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -104,11 +85,9 @@ export async function verifyInventoryOperations({
|
||||
pattern: String.raw`^REA_GHIDRA_(?:INVENTORY_ENTRY|LEAF_VALUE)$`,
|
||||
mode: "regex",
|
||||
case_sensitive: true,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
document: null,
|
||||
});
|
||||
if (stringSearch.total !== 2 || stringSearch.items.length !== 2)
|
||||
if (!Array.isArray(stringSearch) || stringSearch.length !== 2)
|
||||
throw new Error("Ghidra regex string search drifted");
|
||||
const stringItem = strings.find(
|
||||
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
|
||||
@@ -118,12 +97,11 @@ export async function verifyInventoryOperations({
|
||||
const filteredStrings = await call(client, "list_strings", {
|
||||
document: null,
|
||||
address: stringItem.address,
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
});
|
||||
if (
|
||||
filteredStrings.total !== 1 ||
|
||||
filteredStrings.items[0]?.address !== stringItem.address
|
||||
!Array.isArray(filteredStrings) ||
|
||||
filteredStrings.length !== 1 ||
|
||||
filteredStrings[0]?.address !== stringItem.address
|
||||
)
|
||||
throw new Error("Ghidra exact-address string filter drifted");
|
||||
|
||||
@@ -164,7 +142,7 @@ async function verifyDebugInventoryCore({
|
||||
document: null,
|
||||
address: entry.address,
|
||||
});
|
||||
if (symbolTail(addressName) !== "rea_ghidra_inventory_entry")
|
||||
if (!matchesSymbol(addressName, "rea_ghidra_inventory_entry"))
|
||||
throw new Error("Ghidra primary address-name resolution drifted");
|
||||
const resolvedAddress = await call(client, "procedure_address", {
|
||||
document: null,
|
||||
@@ -183,29 +161,26 @@ async function verifyDebugInventoryCore({
|
||||
pattern: "rea_ghidra_inventory_",
|
||||
mode: "literal",
|
||||
case_sensitive: true,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
document: null,
|
||||
});
|
||||
if (
|
||||
procedureSearch.total < 2 ||
|
||||
procedureSearch.items.length !== 1 ||
|
||||
!procedureSearch.has_more ||
|
||||
procedureSearch.next_offset !== 1
|
||||
!Array.isArray(procedureSearch) ||
|
||||
procedureSearch.length < 2 ||
|
||||
!procedureSearch.some((item) =>
|
||||
matchesSymbol(item.value, "rea_ghidra_inventory_entry"),
|
||||
)
|
||||
)
|
||||
throw new Error("Ghidra literal procedure-search pagination drifted");
|
||||
throw new Error("Ghidra literal procedure search drifted");
|
||||
const name = names.find((item) => item.address === entry.address);
|
||||
if (name === undefined)
|
||||
throw new Error("Ghidra entry name probe is unavailable");
|
||||
const filteredNames = await call(client, "list_names", {
|
||||
document: null,
|
||||
address: entry.address,
|
||||
offset: 0,
|
||||
limit: 500,
|
||||
});
|
||||
if (
|
||||
filteredNames.total < 1 ||
|
||||
!filteredNames.items.some((item) => item.value === name.value)
|
||||
!Array.isArray(filteredNames) ||
|
||||
!filteredNames.some((item) => item.value === name.value)
|
||||
)
|
||||
throw new Error("Ghidra exact-address name filter drifted");
|
||||
const functionAnalysis = await verifyDebugFunctionOperations(client, {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { resolveGhidraAnalysisProfile } from "../dist/ghidra/GhidraAnalysisProfile.js";
|
||||
import { GhidraClient } from "../dist/ghidra/GhidraClient.js";
|
||||
import { GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS } from "../dist/ghidra/GhidraDefaults.js";
|
||||
import {
|
||||
GHIDRA_FUNCTION_OPERATIONS,
|
||||
parseGhidraFunctionInput,
|
||||
@@ -45,7 +44,7 @@ const installation = inspectGhidraInstallation({
|
||||
: { javaHome: process.env.JAVA_HOME }),
|
||||
});
|
||||
if (
|
||||
!installation.available ||
|
||||
installation.status !== "available" ||
|
||||
installation.analyzeHeadlessPath === null ||
|
||||
installation.providerVersion !== SUPPORTED_GHIDRA_VERSION
|
||||
)
|
||||
@@ -90,7 +89,6 @@ const client = new GhidraClient({
|
||||
transport: "authenticated-loopback-tcp",
|
||||
providerVersion: SUPPORTED_GHIDRA_VERSION,
|
||||
profileDigest: profile.value.profile.digest,
|
||||
requestTimeoutMs: GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
let coordinates;
|
||||
@@ -211,9 +209,7 @@ async function inventory(operation, parameters) {
|
||||
async function functionOperation(operation, parameters) {
|
||||
const input = parseGhidraFunctionInput(operation, parameters);
|
||||
if (!input.ok) throw input.error;
|
||||
const called = await client.callTool(operation, input.value, {
|
||||
timeoutMs: GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
const called = await client.callTool(operation, input.value);
|
||||
if (!called.ok) throw called.error;
|
||||
const result = parseGhidraFunctionResult(operation, called.value);
|
||||
if (!result.ok) throw result.error;
|
||||
|
||||
+219
-86
@@ -47,13 +47,22 @@ const installation = inspectGhidraInstallation({
|
||||
? {}
|
||||
: { javaHome: process.env.JAVA_HOME }),
|
||||
});
|
||||
if (!installation.available || installation.analyzeHeadlessPath === null)
|
||||
if (
|
||||
installation.status !== "available" ||
|
||||
installation.analyzeHeadlessPath === null
|
||||
)
|
||||
throw new Error(
|
||||
`Ghidra installation is unavailable: ${JSON.stringify(installation)}`,
|
||||
);
|
||||
if (installation.providerVersion !== SUPPORTED_GHIDRA_VERSION)
|
||||
throw new Error("Ghidra provider version commitment drifted");
|
||||
|
||||
const crossFormat = process.argv.includes("--cross-format");
|
||||
const aarch64JumpTableOnly = process.argv.includes("--aarch64-jump-table");
|
||||
const expectedNativeTarget = nativeFixtureTarget(
|
||||
process.platform,
|
||||
process.arch,
|
||||
);
|
||||
const fixtureRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-fixtures-"));
|
||||
const sourcePath = fileURLToPath(
|
||||
new URL("../tests/conformance/ghidra/inventory.c", import.meta.url),
|
||||
@@ -71,82 +80,135 @@ const machObjectPath = join(fixtureRoot, "rea-ghidra-cross-x86_64.o");
|
||||
const malformedPath = join(fixtureRoot, "rea-ghidra-malformed");
|
||||
const compiler = process.env.REA_CC ?? "cc";
|
||||
const clang = process.env.REA_CLANG ?? "clang";
|
||||
const lld = process.env.REA_LLD ?? "ld.lld";
|
||||
const lldLink = process.env.REA_LLD_LINK ?? "lld-link";
|
||||
const lane = crossFormat
|
||||
? "cross-format"
|
||||
: aarch64JumpTableOnly
|
||||
? "AArch64 jump-table"
|
||||
: "host-format";
|
||||
const toolchain = new Map([[compiler, "host fixture compiler"]]);
|
||||
if (crossFormat || aarch64JumpTableOnly)
|
||||
toolchain.set(clang, "cross-format clang compiler");
|
||||
if (crossFormat) {
|
||||
toolchain.set(lld, "LLVM LLD linker");
|
||||
toolchain.set(lldLink, "Windows PE linker");
|
||||
}
|
||||
const crossTarget = crossFormat
|
||||
? "aarch64-linux-gnu, x86_64-pc-windows-msvc, x86_64-apple-darwin"
|
||||
: aarch64JumpTableOnly
|
||||
? "aarch64-linux-gnu"
|
||||
: `${expectedNativeTarget.format}/${expectedNativeTarget.architecture}`;
|
||||
for (const [command, role] of toolchain) {
|
||||
try {
|
||||
await exec(command, [command === lldLink ? "/?" : "--version"]);
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`Ghidra ${lane} verification cannot run for target ${crossTarget}: required ${role} '${command}' is unavailable or failed its preflight.`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const common = ["-O0", "-g", "-fno-inline", "-fno-pie", "-no-pie"];
|
||||
const common = ["-O0", "-g", "-fno-inline"];
|
||||
if (expectedNativeTarget.format === "elf") common.push("-fno-pie", "-no-pie");
|
||||
await exec(compiler, [...common, sourcePath, "-o", debugPath]);
|
||||
await exec(compiler, [...common, "-s", sourcePath, "-o", strippedPath]);
|
||||
await exec(clang, [
|
||||
"--target=aarch64-linux-gnu",
|
||||
"-O0",
|
||||
"-g",
|
||||
"-fno-inline",
|
||||
"-fno-pie",
|
||||
"-nostdlib",
|
||||
"-static",
|
||||
"-fuse-ld=lld",
|
||||
crossFormatSourcePath,
|
||||
"-Wl,-e,rea_cross_start",
|
||||
"-o",
|
||||
arm64ElfPath,
|
||||
]);
|
||||
await exec(clang, [
|
||||
"--target=x86_64-pc-windows-msvc",
|
||||
"-O0",
|
||||
"-gcodeview",
|
||||
"-fno-inline",
|
||||
"-c",
|
||||
crossFormatSourcePath,
|
||||
"-o",
|
||||
peObjectPath,
|
||||
]);
|
||||
await exec(lldLink, [
|
||||
"/entry:rea_cross_start",
|
||||
"/subsystem:console",
|
||||
"/nodefaultlib",
|
||||
"/export:rea_cross_entry",
|
||||
`/implib:${peImportLibraryPath}`,
|
||||
`/out:${pePath}`,
|
||||
peObjectPath,
|
||||
]);
|
||||
await exec(clang, [
|
||||
"--target=x86_64-apple-darwin",
|
||||
"-O0",
|
||||
"-g",
|
||||
"-fno-inline",
|
||||
"-c",
|
||||
crossFormatSourcePath,
|
||||
"-o",
|
||||
machObjectPath,
|
||||
]);
|
||||
const strippedFlags = [...common, "-s"];
|
||||
if (expectedNativeTarget.format === "mach-o")
|
||||
strippedFlags.push("-fvisibility=hidden");
|
||||
await exec(compiler, [...strippedFlags, sourcePath, "-o", strippedPath]);
|
||||
const crossTargets = [];
|
||||
if (aarch64JumpTableOnly) {
|
||||
await exec(clang, [
|
||||
"--target=aarch64-linux-gnu",
|
||||
"-O2",
|
||||
"-g",
|
||||
"-fno-inline",
|
||||
"-fno-pie",
|
||||
"-c",
|
||||
crossFormatSourcePath,
|
||||
"-o",
|
||||
arm64ElfPath,
|
||||
]);
|
||||
crossTargets.push([
|
||||
arm64ElfPath,
|
||||
"aarch64-jump-table",
|
||||
{ format: "elf", architecture: "arm64" },
|
||||
]);
|
||||
} else if (crossFormat) {
|
||||
await exec(clang, [
|
||||
"--target=aarch64-linux-gnu",
|
||||
"-O2",
|
||||
"-g",
|
||||
"-fno-inline",
|
||||
"-fno-pie",
|
||||
"-nostdlib",
|
||||
"-static",
|
||||
"-fuse-ld=lld",
|
||||
crossFormatSourcePath,
|
||||
"-Wl,-e,rea_cross_start",
|
||||
"-o",
|
||||
arm64ElfPath,
|
||||
]);
|
||||
await exec(clang, [
|
||||
"--target=x86_64-pc-windows-msvc",
|
||||
"-O0",
|
||||
"-gcodeview",
|
||||
"-fno-inline",
|
||||
"-c",
|
||||
crossFormatSourcePath,
|
||||
"-o",
|
||||
peObjectPath,
|
||||
]);
|
||||
await exec(lldLink, [
|
||||
"/entry:rea_cross_start",
|
||||
"/subsystem:console",
|
||||
"/nodefaultlib",
|
||||
"/export:rea_cross_entry",
|
||||
`/implib:${peImportLibraryPath}`,
|
||||
`/out:${pePath}`,
|
||||
peObjectPath,
|
||||
]);
|
||||
await exec(clang, [
|
||||
"--target=x86_64-apple-darwin",
|
||||
"-O0",
|
||||
"-g",
|
||||
"-fno-inline",
|
||||
"-c",
|
||||
crossFormatSourcePath,
|
||||
"-o",
|
||||
machObjectPath,
|
||||
]);
|
||||
crossTargets.push(
|
||||
[
|
||||
arm64ElfPath,
|
||||
"cross-arm64-elf",
|
||||
{ format: "elf", architecture: "arm64" },
|
||||
],
|
||||
[pePath, "cross-x86_64-pe", { format: "pe", architecture: "x86_64" }],
|
||||
[
|
||||
machObjectPath,
|
||||
"cross-x86_64-mach-o",
|
||||
{ format: "mach-o", architecture: "x86_64" },
|
||||
],
|
||||
);
|
||||
}
|
||||
await writeFile(malformedPath, Buffer.from("not-a-binary\n", "utf8"));
|
||||
|
||||
const debug = await verifyTarget(debugPath, "debug", {
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
});
|
||||
const debug = await verifyTarget(debugPath, "debug", expectedNativeTarget);
|
||||
assertDebugFixture(debug);
|
||||
const nativeApiCli = await verifyNativeApiCli(debugPath);
|
||||
const stripped = await verifyTarget(strippedPath, "stripped", {
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
});
|
||||
const stripped = await verifyTarget(
|
||||
strippedPath,
|
||||
"stripped",
|
||||
expectedNativeTarget,
|
||||
);
|
||||
assertStrippedFixture(stripped);
|
||||
const arm64Elf = await verifyTarget(arm64ElfPath, "cross-arm64-elf", {
|
||||
format: "elf",
|
||||
architecture: "arm64",
|
||||
});
|
||||
assertCrossFixture(arm64Elf);
|
||||
const pe = await verifyTarget(pePath, "cross-x86_64-pe", {
|
||||
format: "pe",
|
||||
architecture: "x86_64",
|
||||
});
|
||||
assertCrossFixture(pe);
|
||||
const machObject = await verifyTarget(machObjectPath, "cross-x86_64-mach-o", {
|
||||
format: "mach-o",
|
||||
architecture: "x86_64",
|
||||
});
|
||||
assertCrossFixture(machObject);
|
||||
const crossResults = [];
|
||||
for (const [targetPath, variant, expectedTarget] of crossTargets) {
|
||||
const result = await verifyTarget(targetPath, variant, expectedTarget);
|
||||
if (variant !== "aarch64-jump-table") assertCrossFixture(result);
|
||||
crossResults.push(result);
|
||||
}
|
||||
await assertMalformedFixture(malformedPath);
|
||||
|
||||
const customPath = process.env.GHIDRA_TARGET_PATH;
|
||||
@@ -157,16 +219,20 @@ try {
|
||||
verifier_run: await completeVerifierRun(verifierRun),
|
||||
ok: true,
|
||||
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
|
||||
fixture_sources: [sourcePath, crossFormatSourcePath],
|
||||
fixtures: [
|
||||
summary(debug),
|
||||
summary(stripped),
|
||||
summary(arm64Elf),
|
||||
summary(pe),
|
||||
summary(machObject),
|
||||
],
|
||||
verification_lane: aarch64JumpTableOnly
|
||||
? "aarch64-jump-table"
|
||||
: crossFormat
|
||||
? "cross-format"
|
||||
: "host-native",
|
||||
fixture_sources:
|
||||
crossFormat || aarch64JumpTableOnly
|
||||
? [sourcePath, crossFormatSourcePath]
|
||||
: [sourcePath],
|
||||
fixtures: [debug, stripped, ...crossResults].map(summary),
|
||||
malformed_target: "rejected-before-provider-start",
|
||||
native_api_cli: nativeApiCli,
|
||||
native_api_cli: aarch64JumpTableOnly
|
||||
? (crossResults[0]?.native_api_cli ?? null)
|
||||
: debug.native_api_cli,
|
||||
custom_target: custom === null ? null : summary(custom),
|
||||
cleanup: "complete",
|
||||
})}\n`,
|
||||
@@ -175,14 +241,38 @@ try {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function verifyNativeApiCli(targetPath) {
|
||||
function nativeFixtureTarget(platform, architecture) {
|
||||
const supportedTargets = {
|
||||
"darwin-arm64": { format: "mach-o", architecture: "arm64" },
|
||||
"darwin-x64": { format: "mach-o", architecture: "x86_64" },
|
||||
"linux-x64": { format: "elf", architecture: "x86_64" },
|
||||
};
|
||||
const target = supportedTargets[`${platform}-${architecture}`];
|
||||
if (target === undefined)
|
||||
throw new Error(
|
||||
`The Ghidra verifier does not support a host-native fixture on ${platform}/${architecture}; use a supported Linux x64 or macOS x64/arm64 host.`,
|
||||
);
|
||||
return target;
|
||||
}
|
||||
|
||||
async function verifyNativeApiCli(
|
||||
targetPath,
|
||||
procedures,
|
||||
requireDenseJumpTable,
|
||||
denseSwitchSymbol = "rea_ghidra_inventory_dense_switch",
|
||||
) {
|
||||
const denseSwitch = procedures.find(({ value }) =>
|
||||
value.endsWith(denseSwitchSymbol),
|
||||
);
|
||||
if (denseSwitch === undefined)
|
||||
throw new Error("The Ghidra inventory omitted the dense switch fixture");
|
||||
const { stdout } = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/rea.mjs",
|
||||
"inspect-native-api",
|
||||
targetPath,
|
||||
"rea_ghidra_inventory_dense_switch",
|
||||
denseSwitch.address,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
@@ -198,13 +288,40 @@ async function verifyNativeApiCli(targetPath) {
|
||||
const denseTable = boundary?.jump_tables?.find(
|
||||
({ mappings }) => mappings.length > 32,
|
||||
);
|
||||
const denseCases = new Set(
|
||||
denseTable?.mappings.flatMap(({ case_value }) =>
|
||||
typeof case_value === "number" ? [case_value] : [],
|
||||
) ?? [],
|
||||
);
|
||||
const expectedCases = Array.from({ length: 40 }, (_, index) => index);
|
||||
const mappingsAreExact = expectedCases.every((caseValue) =>
|
||||
denseTable?.mappings.some(
|
||||
(mapping) =>
|
||||
mapping.case_value === caseValue &&
|
||||
mapping.confidence === "high" &&
|
||||
mapping.evidence.some(
|
||||
({ detail }) =>
|
||||
detail.includes(`case value ${caseValue} with target`) ||
|
||||
detail.startsWith(`Case value ${caseValue} indexes byte `),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (
|
||||
evidence.operation !== "inspect_native_api" ||
|
||||
evidence.provider?.id !== "rea-workflow" ||
|
||||
evidence.analysis_profile?.provider?.id !== "rea-workflow" ||
|
||||
boundary?.available !== true ||
|
||||
denseTable === undefined ||
|
||||
evidence.normalized_result?.residual_unknowns?.length !== 0
|
||||
boundary?.available !== true
|
||||
)
|
||||
throw new Error(
|
||||
`The shipped inspect-native-api CLI did not return an available boundary: ${stdout}`,
|
||||
);
|
||||
if (
|
||||
requireDenseJumpTable &&
|
||||
(denseTable === undefined ||
|
||||
denseCases.size !== denseTable.mappings.length ||
|
||||
expectedCases.some((caseValue) => !denseCases.has(caseValue)) ||
|
||||
!mappingsAreExact ||
|
||||
evidence.normalized_result?.residual_unknowns?.length !== 0)
|
||||
)
|
||||
throw new Error(
|
||||
`The shipped inspect-native-api CLI did not preserve complete dense jump-table output: ${stdout}`,
|
||||
@@ -212,7 +329,7 @@ async function verifyNativeApiCli(targetPath) {
|
||||
return {
|
||||
operation: evidence.operation,
|
||||
provider: evidence.provider,
|
||||
mappings_returned: denseTable.mappings.length,
|
||||
mappings_returned: denseTable?.mappings.length ?? null,
|
||||
residual_unknowns: evidence.normalized_result.residual_unknowns.length,
|
||||
};
|
||||
}
|
||||
@@ -285,8 +402,23 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
||||
document: null,
|
||||
address: null,
|
||||
});
|
||||
const crossArm64Elf =
|
||||
variant === "cross-arm64-elf" || variant === "aarch64-jump-table";
|
||||
const nativeApiCli =
|
||||
variant === "debug" || crossArm64Elf
|
||||
? await verifyNativeApiCli(
|
||||
parsedTarget.value.path,
|
||||
procedures,
|
||||
crossArm64Elf ||
|
||||
(expectedNativeTarget.format === "elf" &&
|
||||
expectedNativeTarget.architecture === "x86_64"),
|
||||
crossArm64Elf
|
||||
? "rea_cross_dense_switch"
|
||||
: "rea_ghidra_inventory_dense_switch",
|
||||
)
|
||||
: null;
|
||||
const probes =
|
||||
variant === "custom"
|
||||
variant === "custom" || variant === "aarch64-jump-table"
|
||||
? null
|
||||
: await verifyInventoryOperations({
|
||||
client,
|
||||
@@ -306,6 +438,7 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
||||
names,
|
||||
strings,
|
||||
probes,
|
||||
native_api_cli: nativeApiCli,
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
|
||||
@@ -26,7 +26,7 @@ try {
|
||||
["list-javascript-runtime-targets", endpoint, "--json"],
|
||||
environment,
|
||||
);
|
||||
const target = listed.normalized_result?.targets?.items?.[0];
|
||||
const target = listed.normalized_result?.targets?.[0];
|
||||
if (
|
||||
listed.operation !== "list_javascript_runtime_targets" ||
|
||||
typeof target?.target_id !== "string" ||
|
||||
|
||||
@@ -3,8 +3,8 @@ name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or approved runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "23"
|
||||
tool_count: 111
|
||||
catalog_digest: "65a7b444b254db1d9122b635ee7d927f7bbf03b534436db1095c83386c438e55"
|
||||
tool_count: 114
|
||||
catalog_digest: "3e9ab4e05563ea2609b7cf4db67638a79cd8a74188c292718d99a63451d5b9db"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { EvidenceSubjectTarget } from "../domain/evidence.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisExecution,
|
||||
@@ -302,7 +303,9 @@ export class BinarySession
|
||||
unboundOperationError: (operation, route) =>
|
||||
this.#providerRouter.unboundOperationError(operation, route),
|
||||
lookupSnapshot: (target, profile, operation, parameters) =>
|
||||
this.lookupSnapshot(target, profile, operation, parameters),
|
||||
operation === "decode_interface_builder"
|
||||
? undefined
|
||||
: this.lookupSnapshot(target, profile, operation, parameters),
|
||||
});
|
||||
if (!prepared.ok) return prepared;
|
||||
const { active, capability, profile, cacheable, cached } = prepared.value;
|
||||
@@ -311,9 +314,18 @@ export class BinarySession
|
||||
this.#calls.add(call);
|
||||
try {
|
||||
const result = await call;
|
||||
const profiled = commitExecutionProfile(name, result, profile);
|
||||
const profiled = bindExecutionTarget(
|
||||
commitExecutionProfile(name, result, profile),
|
||||
name,
|
||||
active.target,
|
||||
);
|
||||
this.#observeRuntimeAvailability(name, profiled);
|
||||
if (profiled.ok && cacheable && profile !== undefined)
|
||||
if (
|
||||
profiled.ok &&
|
||||
cacheable &&
|
||||
profile !== undefined &&
|
||||
name !== "decode_interface_builder"
|
||||
)
|
||||
this.recordSnapshot({
|
||||
target: active.target,
|
||||
profile,
|
||||
@@ -500,3 +512,43 @@ const commitExecutionProfile = (
|
||||
analysisProfile: structuredClone(profile),
|
||||
});
|
||||
};
|
||||
|
||||
const bindExecutionTarget = (
|
||||
result: Result<AnalysisExecution, AnalysisError>,
|
||||
operation: AnalysisOperation,
|
||||
target: BinaryTarget,
|
||||
): Result<AnalysisExecution, AnalysisError> => {
|
||||
if (!result.ok) return result;
|
||||
const subject: EvidenceSubjectTarget = {
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
format:
|
||||
target.format === "analysis-database"
|
||||
? "analysis-database"
|
||||
: target.format,
|
||||
...(target.architecture === undefined
|
||||
? {}
|
||||
: { architecture: target.architecture }),
|
||||
};
|
||||
if (
|
||||
result.value.subject !== null &&
|
||||
result.value.subject.sha256 !== target.sha256
|
||||
) {
|
||||
if (!isArtifactInventorySubject(operation, result.value.subject, target))
|
||||
return err(
|
||||
new ProviderAdapterError(
|
||||
result.value.provider.id,
|
||||
`${operation}:subject`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ok({ ...result.value, subject: result.value.subject ?? subject });
|
||||
};
|
||||
|
||||
const isArtifactInventorySubject = (
|
||||
operation: AnalysisOperation,
|
||||
subject: EvidenceSubjectTarget,
|
||||
target: BinaryTarget,
|
||||
): boolean =>
|
||||
(operation === "inventory_artifact" || operation === "inspect_artifact") &&
|
||||
subject.path === (target.sourcePath ?? target.path);
|
||||
@@ -71,6 +71,12 @@ type AvailabilityContext = {
|
||||
};
|
||||
|
||||
const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
|
||||
trace_native_ui_action: [
|
||||
"decode_interface_builder",
|
||||
"list_names",
|
||||
"procedure_callees",
|
||||
],
|
||||
inspect_native_dispatch_metadata: ["list_names"],
|
||||
get_objc_classes: ["list_names"],
|
||||
get_objc_protocols: ["list_names"],
|
||||
batch_decompile: ["procedure_pseudo_code"],
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { clientConfigurationValuesEqual } from "./ClientConfigurationDocument.js";
|
||||
|
||||
describe("client configuration comparison", () => {
|
||||
it("ignores TOML parser object prototypes and key order", () => {
|
||||
const parsedEnvironment = {
|
||||
HOPPER_LAUNCHER_PATH: "/opt/rea/node",
|
||||
};
|
||||
const parsedRegistration = {
|
||||
env: parsedEnvironment,
|
||||
startup_timeout_sec: 30,
|
||||
args: ["mcp"],
|
||||
command: "/opt/rea/bin/rea",
|
||||
};
|
||||
Object.setPrototypeOf(parsedEnvironment, null);
|
||||
Object.setPrototypeOf(parsedRegistration, null);
|
||||
|
||||
expect(
|
||||
clientConfigurationValuesEqual(parsedRegistration, {
|
||||
command: "/opt/rea/bin/rea",
|
||||
args: ["mcp"],
|
||||
startup_timeout_sec: 30,
|
||||
env: { HOPPER_LAUNCHER_PATH: "/opt/rea/node" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still detects configuration value changes", () => {
|
||||
expect(
|
||||
clientConfigurationValuesEqual(
|
||||
{ startup_timeout_sec: 30 },
|
||||
{ startup_timeout_sec: 15 },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
@@ -12,6 +13,27 @@ export interface ClientConfigurationDocument {
|
||||
|
||||
const objectSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
/** Compare parsed configuration values without depending on parser prototypes. */
|
||||
export const clientConfigurationValuesEqual = (
|
||||
left: unknown,
|
||||
right: unknown,
|
||||
): boolean =>
|
||||
isDeepStrictEqual(
|
||||
normalizeConfigurationValue(left),
|
||||
normalizeConfigurationValue(right),
|
||||
);
|
||||
|
||||
const normalizeConfigurationValue = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(normalizeConfigurationValue);
|
||||
if (typeof value !== "object" || value === null || value instanceof Date)
|
||||
return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, nested]) => [key, normalizeConfigurationValue(nested)]),
|
||||
);
|
||||
};
|
||||
|
||||
/** Parse the client format and reject malformed root or server-table values. */
|
||||
export const parseClientConfiguration = (
|
||||
text: string,
|
||||
|
||||
@@ -49,10 +49,12 @@ type DirectAnalysisTool =
|
||||
| "read_function_instructions"
|
||||
| "analyze_function"
|
||||
| "inspect_native_api"
|
||||
| "inspect_native_dispatch_metadata"
|
||||
| "search_strings"
|
||||
| "search_procedures"
|
||||
| "xrefs"
|
||||
| "trace_feature";
|
||||
| "trace_feature"
|
||||
| "trace_native_ui_action";
|
||||
|
||||
/**
|
||||
* Open one binary, execute one tool, and always release provider resources.
|
||||
@@ -220,6 +222,7 @@ const runAnalysis = async (
|
||||
const evidenceProfile = analysisProfileForEvidence(session, tool);
|
||||
const bindingProfile = session.analysisProfile();
|
||||
if (
|
||||
tool !== "trace_native_ui_action" &&
|
||||
snapshot !== undefined &&
|
||||
evidenceProfile !== undefined &&
|
||||
bindingProfile !== undefined
|
||||
@@ -243,7 +246,11 @@ const runAnalysis = async (
|
||||
evidenceProfile,
|
||||
});
|
||||
if (evidence !== undefined) session.recordEvidence(evidence);
|
||||
if (snapshotPath !== undefined && evidence !== undefined) {
|
||||
if (
|
||||
tool !== "trace_native_ui_action" &&
|
||||
snapshotPath !== undefined &&
|
||||
evidence !== undefined
|
||||
) {
|
||||
const snapshot = session.exportAnalysisSnapshot();
|
||||
if (!snapshot.ok) return cliError(snapshot.error);
|
||||
const written = await writeAnalysisSnapshot(
|
||||
@@ -276,7 +283,9 @@ const executeAnalysisTool = async (input: {
|
||||
tool === "binary_overview" ||
|
||||
tool === "analyze_function" ||
|
||||
tool === "inspect_native_api" ||
|
||||
tool === "trace_feature"
|
||||
tool === "inspect_native_dispatch_metadata" ||
|
||||
tool === "trace_feature" ||
|
||||
tool === "trace_native_ui_action"
|
||||
) {
|
||||
const result = await new EnhancedTools(session).execute(
|
||||
tool,
|
||||
@@ -398,7 +407,8 @@ const isWorkflowEvidenceTool = (
|
||||
): boolean =>
|
||||
tool === "binary_overview" ||
|
||||
tool === "inspect_native_api" ||
|
||||
tool === "trace_feature";
|
||||
tool === "trace_feature" ||
|
||||
tool === "trace_native_ui_action";
|
||||
|
||||
const fileExists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisExecution,
|
||||
type AnalysisOperation,
|
||||
} from "./AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../domain/errors.js";
|
||||
import { EnhancedTools } from "./EnhancedTools.js";
|
||||
import {
|
||||
nativeInvestigationGraphSchema,
|
||||
nativeInvestigationTraceSchema,
|
||||
} from "../domain/nativeInvestigationGraph.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { AnalysisCancelledError } from "../domain/errors.js";
|
||||
|
||||
const target = "a".repeat(64);
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
const evidence = [
|
||||
{
|
||||
kind: "interface_builder_resource" as const,
|
||||
description: "Fixture UI archive",
|
||||
location: { address: null, file_offset: null },
|
||||
artifact_path: null,
|
||||
artifact_sha256: null,
|
||||
},
|
||||
];
|
||||
const graph = {
|
||||
target_sha256: target,
|
||||
provider: {
|
||||
id: provider.id,
|
||||
version: provider.version,
|
||||
tool_version: "fixture",
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: "button",
|
||||
kind: "control" as const,
|
||||
name: "Build",
|
||||
location: null,
|
||||
attributes: {
|
||||
class_name: "UIButton",
|
||||
interface_builder_object_id: "button",
|
||||
},
|
||||
evidence,
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
kind: "action" as const,
|
||||
name: "buildTapped:",
|
||||
location: null,
|
||||
attributes: { selector: "buildTapped:" },
|
||||
evidence,
|
||||
},
|
||||
{
|
||||
id: "controller",
|
||||
kind: "view_controller" as const,
|
||||
name: "BuildViewController",
|
||||
location: null,
|
||||
attributes: { class_name: "BuildViewController" },
|
||||
evidence,
|
||||
},
|
||||
{
|
||||
id: "selector",
|
||||
kind: "objc_selector" as const,
|
||||
name: "buildTapped:",
|
||||
location: null,
|
||||
attributes: {},
|
||||
evidence,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "button-action",
|
||||
from: "button",
|
||||
to: "action",
|
||||
relation: "target_action" as const,
|
||||
resolution: "observed" as const,
|
||||
evidence,
|
||||
limitations: [],
|
||||
},
|
||||
{
|
||||
id: "action-selector",
|
||||
from: "action",
|
||||
to: "selector",
|
||||
relation: "target_action" as const,
|
||||
resolution: "observed" as const,
|
||||
evidence,
|
||||
limitations: [],
|
||||
},
|
||||
{
|
||||
id: "action-controller",
|
||||
from: "action",
|
||||
to: "controller",
|
||||
relation: "target_action" as const,
|
||||
resolution: "observed" as const,
|
||||
evidence,
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
coverage: [],
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
const execution = (
|
||||
operation: AnalysisOperation,
|
||||
result: unknown,
|
||||
sha256 = target,
|
||||
) =>
|
||||
ok(
|
||||
createAnalysisExecution(result, provider, {
|
||||
subject: {
|
||||
sha256,
|
||||
path: "/fixture.app",
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const testAnalysis = (
|
||||
overrides: Partial<
|
||||
Record<
|
||||
AnalysisOperation,
|
||||
(
|
||||
parameters: Readonly<Record<string, unknown>>,
|
||||
) => Result<AnalysisExecution, AnalysisError>
|
||||
>
|
||||
> = {},
|
||||
uiGraph: z.input<typeof nativeInvestigationGraphSchema> = graph,
|
||||
) => ({
|
||||
execute: async (
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, unknown>>,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> => {
|
||||
const override = overrides[operation];
|
||||
if (override !== undefined) return override(parameters);
|
||||
switch (operation) {
|
||||
case "decode_interface_builder":
|
||||
return execution(operation, {
|
||||
target_sha256: target,
|
||||
documents: [
|
||||
{
|
||||
relative_path: "Base.lproj/Main.nib",
|
||||
archive_sha256: "b".repeat(64),
|
||||
document_kind: "nib",
|
||||
object_count: 4,
|
||||
connection_count: 3,
|
||||
hierarchy_complete: true,
|
||||
},
|
||||
],
|
||||
graph: uiGraph,
|
||||
limitations: [],
|
||||
});
|
||||
case "list_names":
|
||||
return execution(operation, [
|
||||
{ address: "0x1000", name: "-[BuildViewController buildTapped:]" },
|
||||
]);
|
||||
case "procedure_callees":
|
||||
return execution(
|
||||
operation,
|
||||
parameters.procedure === "0x1000" ? ["0x2000"] : [],
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unexpected provider operation: ${operation}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
describe("native UI action trace", () => {
|
||||
it("builds the evidence path from the active app and native provider", async () => {
|
||||
const tools = new EnhancedTools(testAnalysis());
|
||||
const result = await tools.execute("trace_native_ui_action", {
|
||||
action: "buildTapped:",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const trace = nativeInvestigationTraceSchema.parse(result.value);
|
||||
expect(trace.start).toBe("action");
|
||||
expect(trace.nodes.map(({ id }) => id)).toContain("native:function:0x1000");
|
||||
expect(trace.nodes.map(({ id }) => id)).toContain("native:function:0x2000");
|
||||
expect(trace.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "objc_dispatch",
|
||||
resolution: "inferred",
|
||||
}),
|
||||
);
|
||||
expect(trace.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "direct_call",
|
||||
to: "native:function:0x2000",
|
||||
}),
|
||||
);
|
||||
expect(trace.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "cross_function_value_flow",
|
||||
status: "unsupported",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded native UI tracing", () => {
|
||||
it("continues handler traversal from its own depth when another UI branch is deeper", async () => {
|
||||
const deepGraph = {
|
||||
...graph,
|
||||
nodes: [
|
||||
...graph.nodes,
|
||||
{
|
||||
id: "view",
|
||||
kind: "view" as const,
|
||||
name: "Container",
|
||||
location: null,
|
||||
attributes: {},
|
||||
evidence,
|
||||
},
|
||||
{
|
||||
id: "nested-view",
|
||||
kind: "view" as const,
|
||||
name: "Nested container",
|
||||
location: null,
|
||||
attributes: {},
|
||||
evidence,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
...graph.edges,
|
||||
{
|
||||
id: "controller-view",
|
||||
from: "controller",
|
||||
to: "view",
|
||||
relation: "contains" as const,
|
||||
resolution: "observed" as const,
|
||||
evidence,
|
||||
limitations: [],
|
||||
},
|
||||
{
|
||||
id: "view-nested-view",
|
||||
from: "view",
|
||||
to: "nested-view",
|
||||
relation: "contains" as const,
|
||||
resolution: "observed" as const,
|
||||
evidence,
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await new EnhancedTools(testAnalysis({}, deepGraph)).execute(
|
||||
"trace_native_ui_action",
|
||||
{ action: "buildTapped:", max_depth: 3 },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(
|
||||
nativeInvestigationTraceSchema
|
||||
.parse(result.value)
|
||||
.nodes.map(({ id }) => id),
|
||||
).toContain("native:function:0x2000");
|
||||
});
|
||||
|
||||
it("returns a typed input error for malformed direct metadata requests", async () => {
|
||||
const result = await new EnhancedTools(testAnalysis()).execute(
|
||||
"inspect_native_dispatch_metadata",
|
||||
{ max_records: 0 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
});
|
||||
|
||||
it("binds dispatch metadata to the active target digest", async () => {
|
||||
const result = await new EnhancedTools(testAnalysis()).execute(
|
||||
"inspect_native_dispatch_metadata",
|
||||
{ max_records: 10 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { target_sha256: target },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native UI query outcomes", () => {
|
||||
it("resolves a control object ID through its authored action edge", async () => {
|
||||
const tools = new EnhancedTools(testAnalysis());
|
||||
const result = await tools.execute("trace_native_ui_action", {
|
||||
action: "button",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toMatchObject({ start: "action" });
|
||||
});
|
||||
|
||||
it("rejects UI and native observations with different target identities", async () => {
|
||||
const tools = new EnhancedTools(
|
||||
testAnalysis({
|
||||
list_names: () => execution("list_names", [], "c".repeat(64)),
|
||||
}),
|
||||
);
|
||||
const result = await tools.execute("trace_native_ui_action", {
|
||||
action: "buildTapped:",
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisOutputError",
|
||||
operation: "decode_interface_builder",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns candidates and a reason for an ambiguous selector", async () => {
|
||||
const duplicateGraph = {
|
||||
...graph,
|
||||
nodes: [
|
||||
...graph.nodes,
|
||||
{
|
||||
...graph.nodes[1],
|
||||
id: "second-action",
|
||||
},
|
||||
],
|
||||
};
|
||||
const tools = new EnhancedTools(
|
||||
testAnalysis({
|
||||
decode_interface_builder: () =>
|
||||
execution("decode_interface_builder", {
|
||||
target_sha256: target,
|
||||
documents: [],
|
||||
graph: duplicateGraph,
|
||||
limitations: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const result = await tools.execute("trace_native_ui_action", {
|
||||
action: "buildTapped:",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toMatchObject({
|
||||
reason: "action_selector_matches_multiple_ui_connections",
|
||||
nodes: [{ id: "action" }, { id: "second-action" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains decoder truncation coverage when the action is absent", async () => {
|
||||
const boundedGraph = {
|
||||
...graph,
|
||||
coverage: [
|
||||
{
|
||||
facet: "objects:Main.nib",
|
||||
status: "partial" as const,
|
||||
reason: "object_limit_reached",
|
||||
examined: 2,
|
||||
omitted: 1,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
};
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({}, boundedGraph),
|
||||
).execute("trace_native_ui_action", { action: "missing-selector" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, value: { truncated: true } });
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toHaveProperty("coverage");
|
||||
expect(result.value).toMatchObject({
|
||||
coverage: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
facet: "objects:Main.nib",
|
||||
status: "partial",
|
||||
reason: "object_limit_reached",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cancellation instead of a successful trace during callee expansion", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
procedure_callees: () =>
|
||||
err(new AnalysisCancelledError("procedure_callees")),
|
||||
}),
|
||||
).execute("trace_native_ui_action", { action: "buildTapped:" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError", operation: "procedure_callees" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports only callees left unprocessed by the edge limit", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
procedure_callees: (parameters) =>
|
||||
execution(
|
||||
"procedure_callees",
|
||||
parameters.procedure === "0x1000"
|
||||
? ["0x2000", "0x2001", "0x2002"]
|
||||
: [],
|
||||
),
|
||||
}),
|
||||
).execute("trace_native_ui_action", {
|
||||
action: "buildTapped:",
|
||||
max_depth: 4,
|
||||
max_edges: 5,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toMatchObject({
|
||||
coverage: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
facet: "direct_native_calls",
|
||||
status: "partial",
|
||||
omitted: 1,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("native UI trace boundaries", () => {
|
||||
it("retains unresolved dispatch evidence during callee expansion", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({ list_names: () => execution("list_names", []) }),
|
||||
).execute("trace_native_ui_action", { action: "buildTapped:" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toMatchObject({
|
||||
unresolved: [
|
||||
expect.objectContaining({
|
||||
relation: "objc_dispatch",
|
||||
resolution: "unresolved",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cancellation when archive decoding is cancelled", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
decode_interface_builder: () =>
|
||||
err(new AnalysisCancelledError("decode_interface_builder")),
|
||||
}),
|
||||
).execute("trace_native_ui_action", { action: "buildTapped:" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "decode_interface_builder",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("matches handlers after the dispatch inventory's former default limit", async () => {
|
||||
const names = Array.from({ length: 5_000 }, (_, index) => ({
|
||||
address: `0x${(index + 1).toString(16)}`,
|
||||
name: `unrelated_symbol_${index}`,
|
||||
}));
|
||||
names.push({
|
||||
address: "0x2000",
|
||||
name: "-[BuildViewController buildTapped:]",
|
||||
});
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({ list_names: () => execution("list_names", names) }),
|
||||
).execute("trace_native_ui_action", { action: "buildTapped:" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toMatchObject({
|
||||
nodes: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "native:function:0x2000" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("counts edges removed with nodes in bounded graph coverage", async () => {
|
||||
const result = await new EnhancedTools(testAnalysis()).execute(
|
||||
"trace_native_ui_action",
|
||||
{ action: "missing-selector", max_nodes: 1 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toMatchObject({
|
||||
coverage: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
facet: "interface_builder_graph",
|
||||
status: "partial",
|
||||
omitted: 8,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
discoverObjcClasses,
|
||||
discoverObjcProtocols,
|
||||
} from "../domain/symbolAnalysis.js";
|
||||
import {
|
||||
inspectNativeDispatchMetadata,
|
||||
nativeDispatchMetadataResultSchema,
|
||||
} from "../domain/objcSwiftMetadata.js";
|
||||
import { traceNativeUiAction } from "./NativeUiActionTrace.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
import {
|
||||
@@ -59,9 +64,25 @@ export class EnhancedTools {
|
||||
name === "trace_call_path"
|
||||
)
|
||||
return this.#executeTracing(name, input, signal);
|
||||
if (name === "trace_native_ui_action") {
|
||||
const parsed =
|
||||
enhancedInputSchemas.trace_native_ui_action.safeParse(input);
|
||||
return parsed.success
|
||||
? this.executeValidated({ name, input: parsed.data }, signal)
|
||||
: invalidEnhancedInput(name, parsed.error);
|
||||
}
|
||||
if (name === "analyze_function" || name === "inspect_native_api")
|
||||
return this.#executeFunctionAnalysis(name, input, signal);
|
||||
switch (name) {
|
||||
case "inspect_native_dispatch_metadata": {
|
||||
const parsed =
|
||||
enhancedInputSchemas.inspect_native_dispatch_metadata.safeParse(
|
||||
input,
|
||||
);
|
||||
return parsed.success
|
||||
? this.executeValidated({ name, input: parsed.data }, signal)
|
||||
: invalidEnhancedInput(name, parsed.error);
|
||||
}
|
||||
case "get_objc_classes": {
|
||||
const parsed = enhancedInputSchemas.get_objc_classes.safeParse(input);
|
||||
return parsed.success
|
||||
@@ -110,6 +131,11 @@ export class EnhancedTools {
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
switch (call.name) {
|
||||
case "inspect_native_dispatch_metadata":
|
||||
return this.#inspectNativeDispatchMetadata(
|
||||
call.input.max_records,
|
||||
signal,
|
||||
);
|
||||
case "get_objc_classes":
|
||||
return this.#objcClasses(call.input.pattern, signal);
|
||||
case "get_objc_protocols":
|
||||
@@ -151,6 +177,9 @@ export class EnhancedTools {
|
||||
call.input,
|
||||
signal,
|
||||
);
|
||||
case "trace_native_ui_action": {
|
||||
return traceNativeUiAction(this.analysis, call.input, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +246,48 @@ export class EnhancedTools {
|
||||
return names.ok ? ok(discoverObjcClasses(names.value, pattern)) : names;
|
||||
}
|
||||
|
||||
async #inspectNativeDispatchMetadata(
|
||||
maxRecords: number,
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const execution = await this.analysis.execute(
|
||||
"list_names",
|
||||
{},
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
if (!execution.ok) return err(execution.error);
|
||||
const names = z
|
||||
.array(
|
||||
z.union([
|
||||
z.strictObject({ address: z.string(), value: z.string() }),
|
||||
z.strictObject({ address: z.string(), name: z.string() }),
|
||||
]),
|
||||
)
|
||||
.safeParse(execution.value.result);
|
||||
if (!names.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"list_names",
|
||||
"provider returned an invalid inventory",
|
||||
),
|
||||
);
|
||||
return ok(
|
||||
nativeDispatchMetadataResultSchema.parse({
|
||||
target_sha256: execution.value.subject?.sha256 ?? null,
|
||||
provider: execution.value.provider,
|
||||
analysis_profile_digest:
|
||||
execution.value.analysisProfile?.digest ?? null,
|
||||
result: inspectNativeDispatchMetadata(
|
||||
names.data.map((entry) => ({
|
||||
address: entry.address,
|
||||
name: "value" in entry ? entry.value : entry.name,
|
||||
})),
|
||||
maxRecords,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async #objcProtocols(signal?: AbortSignal): EnhancedResult {
|
||||
const names = await this.#allAddressed("list_names", signal);
|
||||
return names.ok ? ok(discoverObjcProtocols(names.value)) : names;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { buildBinary } from "plist";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("compiled Interface Builder bundle reader", () => {
|
||||
it("reads nib plist archives without following symlinks and reports provenance", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ib-test-"));
|
||||
roots.push(root);
|
||||
const bundle = join(root, "Example.app");
|
||||
const nib = join(
|
||||
bundle,
|
||||
"Contents",
|
||||
"Resources",
|
||||
"Main.storyboardc",
|
||||
"Main.nib",
|
||||
);
|
||||
await mkdir(nib, { recursive: true });
|
||||
await writeFile(
|
||||
join(nib, "objects.nib"),
|
||||
buildBinary({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{ $class: { UID: 2 }, title: "Build" },
|
||||
{
|
||||
$classname: "UIButton",
|
||||
$classes: ["UIButton", "UIControl", "UIView", "NSObject"],
|
||||
},
|
||||
],
|
||||
$top: { root: { UID: 1 } },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(bundle, "Contents", "Resources", "Main.storyboardc", "Info.plist"),
|
||||
'<?xml version="1.0"?><plist><dict><key>notAnArchive</key><string>scene-index</string></dict></plist>',
|
||||
);
|
||||
await mkdir(join(bundle, "Contents", "Resources", "outside.nib"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(bundle, "Contents", "Resources", "outside.nib", "not-nib.txt"),
|
||||
"ignored",
|
||||
);
|
||||
|
||||
const analysis = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "b".repeat(64),
|
||||
});
|
||||
expect(analysis.documents).toMatchObject([
|
||||
{
|
||||
relative_path:
|
||||
"Contents/Resources/Main.storyboardc/Main.nib/objects.nib",
|
||||
document_kind: "storyboard_scene",
|
||||
object_count: 1,
|
||||
},
|
||||
]);
|
||||
expect(analysis.documents).toHaveLength(1);
|
||||
expect(analysis.graph.target_sha256).toBe("b".repeat(64));
|
||||
expect(analysis.graph.nodes.some(({ name }) => name === "Build")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("honors cancellation during directory traversal", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ib-test-"));
|
||||
roots.push(root);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
analyzeInterfaceBuilderBundle({
|
||||
bundlePath: root,
|
||||
targetSha256: "c".repeat(64),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: "cancelled" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded Interface Builder archive decoding", () => {
|
||||
it("counts malformed archives against the document limit", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ib-test-"));
|
||||
roots.push(root);
|
||||
const bundle = join(root, "Example.app");
|
||||
const resources = join(bundle, "Contents", "Resources");
|
||||
await mkdir(resources, { recursive: true });
|
||||
await writeFile(join(resources, "BadOne.nib"), Buffer.from("bplist00bad"));
|
||||
await writeFile(join(resources, "BadTwo.nib"), Buffer.from("bplist00bad"));
|
||||
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "e".repeat(64),
|
||||
limits: { max_documents: 1 },
|
||||
});
|
||||
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_decode",
|
||||
status: "partial",
|
||||
examined: 1,
|
||||
omitted: 1,
|
||||
}),
|
||||
);
|
||||
expect(result.graph.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "darwin" || !existsSync("/usr/bin/ibtool"))(
|
||||
"decodes an Xcode-compiled storyboard NIB and recovers its UI routes",
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ib-compiled-test-"));
|
||||
roots.push(root);
|
||||
const bundle = join(root, "Example.app");
|
||||
const resources = join(bundle, "Contents", "Resources");
|
||||
const source = join(
|
||||
process.cwd(),
|
||||
"tests",
|
||||
"fixtures",
|
||||
"interface-builder",
|
||||
"MacFixture.storyboard",
|
||||
);
|
||||
await mkdir(resources, { recursive: true });
|
||||
execFileSync("/usr/bin/ibtool", [
|
||||
"--compile",
|
||||
join(resources, "MacFixture.storyboardc"),
|
||||
source,
|
||||
]);
|
||||
|
||||
const analysis = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "d".repeat(64),
|
||||
});
|
||||
const names = analysis.graph.nodes.map(({ name }) => name);
|
||||
expect(names).toContain("BuildViewController");
|
||||
expect(names).toContain("Button");
|
||||
expect(names).toContain("buildTapped:");
|
||||
expect(
|
||||
analysis.graph.edges.some(
|
||||
({ relation }) => relation === "target_action",
|
||||
),
|
||||
).toBe(true);
|
||||
const action = analysis.graph.nodes.find(
|
||||
({ kind, name }) => kind === "action" && name === "buildTapped:",
|
||||
);
|
||||
expect(action).toBeDefined();
|
||||
const actionSource = analysis.graph.edges.find(
|
||||
({ from, relation, to }) =>
|
||||
relation === "target_action" &&
|
||||
to === action?.id &&
|
||||
analysis.graph.nodes.find(({ id }) => id === from)?.kind ===
|
||||
"control",
|
||||
);
|
||||
expect(actionSource).toBeDefined();
|
||||
expect(
|
||||
analysis.graph.edges.some(
|
||||
({ from, relation, to }) =>
|
||||
from === action?.id &&
|
||||
relation === "target_action" &&
|
||||
to !== null &&
|
||||
analysis.graph.nodes.find(({ id }) => id === to)?.kind ===
|
||||
"placeholder",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
analysis.graph.edges.some(({ relation }) => relation === "contains"),
|
||||
).toBe(true);
|
||||
expect(analysis.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_decode",
|
||||
status: "complete",
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { parse, parseBinary } from "plist";
|
||||
|
||||
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
|
||||
import {
|
||||
decodeNibArchive,
|
||||
type NibArchiveDocument,
|
||||
} from "../artifacts/NibArchive.js";
|
||||
import type { ArtifactEntry } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
buildInterfaceBuilderAnalysis,
|
||||
interfaceBuilderLimitsSchema,
|
||||
type InterfaceBuilderDocumentInput,
|
||||
} from "../domain/interfaceBuilderGraph.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Decode compiled Interface Builder archives from a local app bundle. */
|
||||
export const analyzeInterfaceBuilderBundle = async (input: {
|
||||
readonly bundlePath: string;
|
||||
readonly targetSha256: string;
|
||||
readonly limits?: unknown;
|
||||
readonly signal?: AbortSignal;
|
||||
}) => {
|
||||
const limits = interfaceBuilderLimitsSchema.parse(input.limits ?? {});
|
||||
const reader = new DirectoryArtifactReader(input.bundlePath);
|
||||
const documents: InterfaceBuilderDocumentInput[] = [];
|
||||
const invalid: string[] = [];
|
||||
let omitted = 0;
|
||||
let attempted = 0;
|
||||
try {
|
||||
for await (const entry of reader.entries(input.signal)) {
|
||||
if (entry.kind !== "file" || !isInterfaceBuilderArchive(entry.path))
|
||||
continue;
|
||||
if (attempted >= limits.max_documents) {
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
attempted += 1;
|
||||
try {
|
||||
const bytes = await readEntry(reader, entry, input.signal);
|
||||
const raw =
|
||||
bytes.subarray(0, 10).toString("ascii") === "NIBArchive"
|
||||
? projectNibArchive(decodeNibArchive(bytes))
|
||||
: decodePlist(bytes);
|
||||
const documentHash = createHash("sha256").update(bytes).digest("hex");
|
||||
documents.push({
|
||||
relativePath: entry.path,
|
||||
archiveSha256: documentHash,
|
||||
documentKind: entry.path.includes(".storyboardc/")
|
||||
? "storyboard_scene"
|
||||
: "nib",
|
||||
raw,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (input.signal?.aborted === true) throw cause;
|
||||
invalid.push(
|
||||
`${entry.path}: ${cause instanceof Error ? cause.message : "archive decode failed"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: input.targetSha256,
|
||||
toolVersion: "rea-interface-builder/1",
|
||||
documents,
|
||||
limits,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
graph: {
|
||||
...result.graph,
|
||||
coverage: [
|
||||
...result.graph.coverage,
|
||||
{
|
||||
facet: "archive_decode",
|
||||
status:
|
||||
invalid.length > 0 ? ("partial" as const) : ("complete" as const),
|
||||
reason: invalid.length > 0 ? "one_or_more_archives_invalid" : null,
|
||||
examined: attempted,
|
||||
omitted,
|
||||
},
|
||||
],
|
||||
truncated: result.graph.truncated || omitted > 0 || invalid.length > 0,
|
||||
},
|
||||
limitations: [
|
||||
...result.limitations,
|
||||
"Compiled Interface Builder archives are private serialized object graphs. The decoder reports only recognized keyed-archive objects and connections; unrecognized object classes and fields remain unknown.",
|
||||
...(invalid.length === 0
|
||||
? []
|
||||
: [
|
||||
`Some Interface Builder archives could not be decoded: ${invalid.slice(0, 16).join("; ")}`,
|
||||
]),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/** Project decoded NIB records into the same bounded graph input as ibtool. */
|
||||
const projectNibArchive = (archive: NibArchiveDocument): JsonValue => {
|
||||
const byId = new Map(archive.objects.map((object) => [object.id, object]));
|
||||
const dereference = (value: JsonValue | undefined): JsonValue | undefined => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return value;
|
||||
const ref = value.$nib_object_ref;
|
||||
if (typeof ref !== "number") return value;
|
||||
const target = byId.get(ref);
|
||||
if (target === undefined) return undefined;
|
||||
if (target.class_name.replace(/\0+$/u, "") === "NSString") {
|
||||
const data = target.values["NS.bytes"];
|
||||
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
|
||||
const encoded = data.$nib_data_base64;
|
||||
if (typeof encoded === "string")
|
||||
return Buffer.from(encoded, "base64").toString("utf8");
|
||||
}
|
||||
}
|
||||
return { objectID: String(ref) };
|
||||
};
|
||||
const referencedString = (value: JsonValue | undefined): string | null => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
const ref = value.$nib_object_ref;
|
||||
if (typeof ref !== "number") return null;
|
||||
const target = byId.get(ref);
|
||||
const data = target?.values["NS.bytes"];
|
||||
if (target?.class_name.replace(/\0+$/u, "") !== "NSString") return null;
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data))
|
||||
return null;
|
||||
const encoded = data.$nib_data_base64;
|
||||
return typeof encoded === "string"
|
||||
? Buffer.from(encoded, "base64").toString("utf8")
|
||||
: null;
|
||||
};
|
||||
const runtimeClass = (objectId: number): string | null => {
|
||||
const object = byId.get(objectId);
|
||||
if (object === undefined) return null;
|
||||
const storedClass = object.class_name.replace(/\0+$/u, "");
|
||||
return storedClass === "NSClassSwapper"
|
||||
? (referencedString(object.values.NSClassName) ?? storedClass)
|
||||
: storedClass;
|
||||
};
|
||||
const objects = Object.fromEntries(
|
||||
archive.objects
|
||||
.filter((object) => {
|
||||
const name = runtimeClass(object.id) ?? "";
|
||||
return !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector)$/u.test(
|
||||
name,
|
||||
);
|
||||
})
|
||||
.map((object) => {
|
||||
const className =
|
||||
runtimeClass(object.id) ?? object.class_name.replace(/\0+$/u, "");
|
||||
const strings = Object.entries(object.values).flatMap(
|
||||
([key, value]) => {
|
||||
const resolved = dereference(value);
|
||||
return typeof resolved === "string" ? [[key, resolved]] : [];
|
||||
},
|
||||
);
|
||||
const name =
|
||||
strings.find(([key]) =>
|
||||
/(?:title|label|identifier|accessibility|name|contents)/iu.test(
|
||||
key ?? "",
|
||||
),
|
||||
)?.[1] ?? className;
|
||||
return [
|
||||
String(object.id),
|
||||
{
|
||||
customClass: className,
|
||||
label: name,
|
||||
objectID: String(object.id),
|
||||
nibValues: Object.fromEntries(
|
||||
Object.entries(object.values).map(([key, value]) => [
|
||||
key,
|
||||
dereference(value) ?? null,
|
||||
]),
|
||||
),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
const connections: Record<string, JsonValue[]> = {};
|
||||
const hierarchy: JsonValue[] = [];
|
||||
const reference = (value: JsonValue | undefined): number | null => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
return typeof value.$nib_object_ref === "number"
|
||||
? value.$nib_object_ref
|
||||
: null;
|
||||
};
|
||||
for (const object of archive.objects) {
|
||||
const className = object.class_name.replace(/\0+$/u, "");
|
||||
const source = reference(object.values.NSSource);
|
||||
const destination = reference(object.values.NSDestination);
|
||||
if (className.includes("Connector") && source !== null) {
|
||||
const label = referencedString(object.values.NSLabel);
|
||||
const type = className.includes("Outlet")
|
||||
? "outlet"
|
||||
: className.includes("Control")
|
||||
? "action"
|
||||
: className;
|
||||
const controlAction = className.includes("Control");
|
||||
const connectionSource = controlAction ? destination : source;
|
||||
const connectionDestination = controlAction ? source : destination;
|
||||
if (connectionSource === null) continue;
|
||||
(connections[String(connectionSource)] ??= []).push({
|
||||
type,
|
||||
"destination-id":
|
||||
connectionDestination === null ? null : String(connectionDestination),
|
||||
label,
|
||||
source_id: String(connectionSource),
|
||||
archive_object_id: String(object.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
const hierarchyFor = (
|
||||
objectId: number,
|
||||
seen: Set<number>,
|
||||
depth: number,
|
||||
): JsonValue | null => {
|
||||
if (depth > 32 || seen.has(objectId)) return null;
|
||||
const object = byId.get(objectId);
|
||||
if (object === undefined) return null;
|
||||
const nextSeen = new Set(seen).add(objectId);
|
||||
const children: JsonValue[] = [];
|
||||
for (const [key, value] of Object.entries(object.values)) {
|
||||
if (!/(?:subviews|contentview|childviewcontrollers|view)$/iu.test(key))
|
||||
continue;
|
||||
const direct = reference(value);
|
||||
if (direct === null) continue;
|
||||
const target = byId.get(direct);
|
||||
if (target === undefined) continue;
|
||||
if (/array|set/iu.test(target.class_name)) {
|
||||
for (const candidate of Object.values(target.values)) {
|
||||
const child = reference(candidate);
|
||||
const nested =
|
||||
child === null ? null : hierarchyFor(child, nextSeen, depth + 1);
|
||||
if (nested !== null) children.push(nested);
|
||||
}
|
||||
} else {
|
||||
const nested = hierarchyFor(direct, nextSeen, depth + 1);
|
||||
if (nested !== null) children.push(nested);
|
||||
}
|
||||
}
|
||||
return { objectID: String(objectId), children };
|
||||
};
|
||||
const ibData = archive.objects.find(
|
||||
({ class_name }) => class_name.replace(/\0+$/u, "") === "NSIBObjectData",
|
||||
);
|
||||
const rootId = reference(ibData?.values.NSRoot);
|
||||
const hierarchyRoots = rootId === null ? [] : [rootId];
|
||||
for (const hierarchyRoot of hierarchyRoots) {
|
||||
const root = hierarchyFor(hierarchyRoot, new Set(), 0);
|
||||
if (root !== null) hierarchy.push(root);
|
||||
}
|
||||
return jsonValueSchema.parse({
|
||||
"com.apple.ibtool.document.objects": objects,
|
||||
"com.apple.ibtool.document.connections": connections,
|
||||
"com.apple.ibtool.document.hierarchy": hierarchy,
|
||||
"com.apple.ibtool.document.classes": Object.fromEntries(
|
||||
archive.classes.map((name) => [name.replace(/\0+$/u, ""), {}]),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const isInterfaceBuilderArchive = (path: string): boolean => {
|
||||
const lower = path.toLowerCase();
|
||||
return lower.endsWith(".nib") || lower.endsWith("/objects.nib");
|
||||
};
|
||||
|
||||
const readEntry = async (
|
||||
reader: DirectoryArtifactReader,
|
||||
entry: ArtifactEntry,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Buffer> => {
|
||||
if (entry.declaredSize !== null && entry.declaredSize > MAX_DOCUMENT_BYTES)
|
||||
throw new RangeError(
|
||||
"archive exceeds the 64 MiB per-document decode limit",
|
||||
);
|
||||
const stream = await reader.open(entry, signal);
|
||||
const chunks: Buffer[] = [];
|
||||
let length = 0;
|
||||
for await (const chunk of stream) {
|
||||
const bytes = Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
: Buffer.from(chunk as Uint8Array);
|
||||
length += bytes.length;
|
||||
if (length > MAX_DOCUMENT_BYTES) {
|
||||
stream.destroy();
|
||||
throw new RangeError(
|
||||
"archive exceeded the 64 MiB per-document decode limit",
|
||||
);
|
||||
}
|
||||
chunks.push(bytes);
|
||||
}
|
||||
return Buffer.concat(chunks, length);
|
||||
};
|
||||
|
||||
const decodePlist = (bytes: Buffer): JsonValue => {
|
||||
const parsed =
|
||||
bytes.subarray(0, 8).toString("ascii") === "bplist00"
|
||||
? parseBinary(bytes)
|
||||
: parse(bytes.toString("utf8"));
|
||||
return jsonValueSchema.parse(parsed);
|
||||
};
|
||||
@@ -21,7 +21,6 @@ describe("native API inspection", () => {
|
||||
mappings: [
|
||||
{
|
||||
target_address: "0x401020",
|
||||
data_addresses: ["0x403000"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -60,6 +59,28 @@ describe("native API inspection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports missing jump-table targets as an explicit unknown", () => {
|
||||
const value = ghidraFunctionDossier();
|
||||
const dossier = functionDossierSchema.parse(value);
|
||||
const boundary = dossier.native_api;
|
||||
if (boundary?.available !== true)
|
||||
throw new TypeError("Ghidra native API fixture is unavailable");
|
||||
const table = boundary.jump_tables[0];
|
||||
if (table === undefined)
|
||||
throw new TypeError("Ghidra jump-table fixture is unavailable");
|
||||
|
||||
const incomplete = functionDossierSchema.parse({
|
||||
...dossier,
|
||||
native_api: {
|
||||
...boundary,
|
||||
jump_tables: [{ ...table, mappings: [] }],
|
||||
},
|
||||
});
|
||||
expect(projectNativeApiInspection(incomplete).residual_unknowns).toContain(
|
||||
"Which case values and targets belong to the dispatch at 0x401010?",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects boundary types placed in the wrong ABI role", () => {
|
||||
const boundary = functionDossierSchema.parse(
|
||||
ghidraFunctionDossier(),
|
||||
|
||||
@@ -72,6 +72,10 @@ const availableResidualUnknowns = (
|
||||
): readonly string[] => {
|
||||
const unknowns: string[] = [];
|
||||
for (const table of boundary.jump_tables) {
|
||||
if (table.mappings.length === 0)
|
||||
unknowns.push(
|
||||
`Which case values and targets belong to the dispatch at ${table.dispatch_address}?`,
|
||||
);
|
||||
if (table.data_sources.length === 0)
|
||||
unknowns.push(
|
||||
`What backing data address produced the jump table dispatched at ${table.dispatch_address}?`,
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
|
||||
import type { EnhancedResult } from "./EnhancedToolTypes.js";
|
||||
import type { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
||||
import { interfaceBuilderAnalysisSchema } from "../domain/interfaceBuilderGraph.js";
|
||||
import {
|
||||
nativeInvestigationGraphSchema,
|
||||
nativeInvestigationTraceSchema,
|
||||
traceNativeInvestigationGraph,
|
||||
joinInterfaceBuilderDispatch,
|
||||
} from "../domain/nativeInvestigationGraph.js";
|
||||
import {
|
||||
inspectNativeDispatchMetadata,
|
||||
nativeDispatchMetadataResultSchema,
|
||||
} from "../domain/objcSwiftMetadata.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { parseRelatedAddresses } from "../domain/hopperValues.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
projectAnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
|
||||
type Input = z.output<typeof enhancedInputSchemas.trace_native_ui_action>;
|
||||
const nativeNameSchema = z.array(
|
||||
z.union([
|
||||
z.strictObject({ address: z.string(), name: z.string() }),
|
||||
z.strictObject({ address: z.string(), value: z.string() }),
|
||||
]),
|
||||
);
|
||||
|
||||
/** Build a UI-to-code trace from the active app and selected native provider. */
|
||||
export const traceNativeUiAction = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
input: Input,
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult => {
|
||||
const [uiExecution, namesExecution] = await Promise.all([
|
||||
analysis.execute(
|
||||
"decode_interface_builder",
|
||||
{
|
||||
max_documents: 64,
|
||||
max_objects: input.max_nodes,
|
||||
max_connections: input.max_edges,
|
||||
},
|
||||
signal === undefined ? {} : { signal },
|
||||
),
|
||||
analysis.execute("list_names", {}, signal === undefined ? {} : { signal }),
|
||||
]);
|
||||
|
||||
if (!uiExecution.ok && uiExecution.error._tag === "AnalysisCancelledError")
|
||||
return err(new AnalysisCancelledError("decode_interface_builder"));
|
||||
if (!namesExecution.ok) return err(namesExecution.error);
|
||||
const parsedNames = nativeNameSchema.safeParse(namesExecution.value.result);
|
||||
if (!parsedNames.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"list_names",
|
||||
"provider returned an invalid name inventory",
|
||||
),
|
||||
);
|
||||
const targetSha256 = namesExecution.value.subject?.sha256;
|
||||
if (targetSha256 === undefined)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"list_names",
|
||||
"provider did not bind the name inventory to a target SHA-256",
|
||||
),
|
||||
);
|
||||
|
||||
const metadata = nativeDispatchMetadataResultSchema.parse({
|
||||
target_sha256: targetSha256,
|
||||
provider: namesExecution.value.provider,
|
||||
analysis_profile_digest:
|
||||
namesExecution.value.analysisProfile?.digest ?? null,
|
||||
result: inspectNativeDispatchMetadata(
|
||||
parsedNames.data.map((entry) => ({
|
||||
address: entry.address,
|
||||
name: "name" in entry ? entry.name : entry.value,
|
||||
})),
|
||||
parsedNames.data.length,
|
||||
),
|
||||
});
|
||||
|
||||
let graph = emptyGraph(targetSha256, namesExecution.value.provider);
|
||||
const coverage = [];
|
||||
if (uiExecution.ok) {
|
||||
const decoded = interfaceBuilderAnalysisSchema.safeParse(
|
||||
uiExecution.value.result,
|
||||
);
|
||||
if (!decoded.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"decode_interface_builder",
|
||||
"artifact provider returned an invalid interface graph",
|
||||
),
|
||||
);
|
||||
if (decoded.data.target_sha256 !== targetSha256)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"decode_interface_builder",
|
||||
"UI and native provider results refer to different target SHA-256 values",
|
||||
),
|
||||
);
|
||||
graph = joinInterfaceBuilderDispatch(decoded.data.graph, metadata.result);
|
||||
coverage.push({
|
||||
facet: "interface_builder_archives",
|
||||
status: decoded.data.documents.length > 0 ? "complete" : "unsupported",
|
||||
reason:
|
||||
decoded.data.documents.length > 0
|
||||
? null
|
||||
: "no_compiled_interface_builder_archives_found",
|
||||
examined: decoded.data.documents.length,
|
||||
omitted: 0,
|
||||
});
|
||||
const bounded = boundUiGraph(graph, input.max_nodes, input.max_edges);
|
||||
graph = bounded.graph;
|
||||
if (bounded.truncated)
|
||||
coverage.push({
|
||||
facet: "interface_builder_graph",
|
||||
status: "partial",
|
||||
reason: "native_trace_output_bounds_reached",
|
||||
examined:
|
||||
decoded.data.graph.nodes.length + decoded.data.graph.edges.length,
|
||||
omitted: bounded.omitted,
|
||||
});
|
||||
} else {
|
||||
coverage.push({
|
||||
facet: "interface_builder_archives",
|
||||
status: "unsupported",
|
||||
reason: projectAnalysisError(uiExecution.error).message,
|
||||
examined: 0,
|
||||
omitted: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const selected = resolveUiAction(graph, input.action);
|
||||
if (selected.nodeId === null) {
|
||||
coverage.push(...graph.coverage);
|
||||
const trace = {
|
||||
target_sha256: graph.target_sha256,
|
||||
provider: graph.provider,
|
||||
start: input.action,
|
||||
direction: "forward" as const,
|
||||
nodes: selected.candidates,
|
||||
edges: [],
|
||||
unresolved: [],
|
||||
reached_depth: 0,
|
||||
truncated: graph.truncated,
|
||||
reason: selected.reason,
|
||||
coverage,
|
||||
limitations: [
|
||||
"The query did not identify one unique authored UI action; no handler path was inferred.",
|
||||
],
|
||||
};
|
||||
return ok(
|
||||
jsonValueSchema.parse(nativeInvestigationTraceSchema.parse(trace)),
|
||||
);
|
||||
}
|
||||
|
||||
const initial = traceNativeInvestigationGraph(graph, {
|
||||
start: selected.nodeId,
|
||||
direction: "forward",
|
||||
limits: {
|
||||
max_depth: input.max_depth,
|
||||
max_nodes: input.max_nodes,
|
||||
max_edges: input.max_edges,
|
||||
},
|
||||
});
|
||||
const expanded = await addDirectCallees({
|
||||
analysis,
|
||||
graph,
|
||||
initial,
|
||||
input,
|
||||
targetSha256,
|
||||
signal,
|
||||
});
|
||||
if (expanded.error !== undefined) return err(expanded.error);
|
||||
coverage.push({
|
||||
facet: "direct_native_calls",
|
||||
status: "partial",
|
||||
reason: "resolved_direct_calls_do_not_cover_all_indirect_dispatch",
|
||||
examined: expanded.examined,
|
||||
omitted: expanded.omitted,
|
||||
});
|
||||
coverage.push({
|
||||
facet: "cross_function_value_flow",
|
||||
status: "unsupported",
|
||||
reason:
|
||||
"call edges identify control-flow candidates but do not recover argument, return, or shared-memory value relationships",
|
||||
examined: 0,
|
||||
omitted: 0,
|
||||
});
|
||||
|
||||
const finalGraph = nativeInvestigationGraphSchema.parse({
|
||||
...graph,
|
||||
nodes: expanded.nodes,
|
||||
edges: expanded.edges,
|
||||
coverage: [...graph.coverage, ...coverage],
|
||||
truncated: graph.truncated || expanded.truncated,
|
||||
});
|
||||
const trace = traceNativeInvestigationGraph(finalGraph, {
|
||||
start: selected.nodeId,
|
||||
direction: "forward",
|
||||
limits: {
|
||||
max_depth: input.max_depth,
|
||||
max_nodes: input.max_nodes,
|
||||
max_edges: input.max_edges,
|
||||
},
|
||||
});
|
||||
return ok(
|
||||
jsonValueSchema.parse(
|
||||
nativeInvestigationTraceSchema.parse({
|
||||
...trace,
|
||||
truncated: trace.truncated || expanded.truncated || graph.truncated,
|
||||
reason:
|
||||
expanded.truncated && trace.reason === null
|
||||
? "native_call_traversal_bound_reached"
|
||||
: graph.truncated && trace.reason === null
|
||||
? "native_trace_output_bound_reached"
|
||||
: trace.reason,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const emptyGraph = (
|
||||
targetSha256: string,
|
||||
provider: {
|
||||
readonly id: string;
|
||||
readonly version: string | null;
|
||||
},
|
||||
) =>
|
||||
nativeInvestigationGraphSchema.parse({
|
||||
target_sha256: targetSha256,
|
||||
provider: {
|
||||
id: provider.id,
|
||||
version: provider.version,
|
||||
tool_version: "rea-native-ui-trace/1",
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
coverage: [],
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
const boundUiGraph = (
|
||||
graph: z.infer<typeof nativeInvestigationGraphSchema>,
|
||||
maxNodes: number,
|
||||
maxEdges: number,
|
||||
): {
|
||||
readonly graph: z.infer<typeof nativeInvestigationGraphSchema>;
|
||||
readonly truncated: boolean;
|
||||
readonly omitted: number;
|
||||
} => {
|
||||
const nodes = graph.nodes.slice(0, maxNodes);
|
||||
const nodeIds = new Set(nodes.map(({ id }) => id));
|
||||
const eligibleEdges = graph.edges.filter(
|
||||
(edge) =>
|
||||
nodeIds.has(edge.from) && (edge.to === null || nodeIds.has(edge.to)),
|
||||
);
|
||||
const edges = eligibleEdges.slice(0, maxEdges);
|
||||
const omitted =
|
||||
graph.nodes.length - nodes.length + graph.edges.length - edges.length;
|
||||
const truncated = omitted > 0;
|
||||
return {
|
||||
graph: nativeInvestigationGraphSchema.parse({
|
||||
...graph,
|
||||
nodes,
|
||||
edges,
|
||||
truncated: graph.truncated || truncated,
|
||||
}),
|
||||
truncated,
|
||||
omitted,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveUiAction = (
|
||||
graph: z.infer<typeof nativeInvestigationGraphSchema>,
|
||||
query: string,
|
||||
): {
|
||||
readonly nodeId: string | null;
|
||||
readonly candidates: z.infer<typeof nativeInvestigationGraphSchema>["nodes"];
|
||||
readonly reason: string | null;
|
||||
} => {
|
||||
const exactId = graph.nodes.find((node) => node.id === query);
|
||||
if (exactId?.kind === "action")
|
||||
return { nodeId: exactId.id, candidates: [], reason: null };
|
||||
|
||||
const actions = graph.nodes.filter(
|
||||
(node) =>
|
||||
node.kind === "action" &&
|
||||
(node.name === query || node.attributes.selector === query),
|
||||
);
|
||||
if (actions.length === 1)
|
||||
return { nodeId: actions[0]?.id ?? null, candidates: [], reason: null };
|
||||
if (actions.length > 1)
|
||||
return {
|
||||
nodeId: null,
|
||||
candidates: actions,
|
||||
reason: "action_selector_matches_multiple_ui_connections",
|
||||
};
|
||||
|
||||
const objectMatches = graph.nodes.filter(
|
||||
(node) =>
|
||||
["control", "view", "view_controller", "scene"].includes(node.kind) &&
|
||||
(node.id === query ||
|
||||
node.attributes.interface_builder_object_id === query ||
|
||||
node.id.endsWith(`:object:${query}`)),
|
||||
);
|
||||
const actionIds = new Set(
|
||||
graph.edges.flatMap((edge) =>
|
||||
edge.resolution === "observed" &&
|
||||
edge.relation === "target_action" &&
|
||||
objectMatches.some(({ id }) => edge.from === id) &&
|
||||
graph.nodes.some(({ id, kind }) => id === edge.to && kind === "action")
|
||||
? [edge.to]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
if (actionIds.size === 1)
|
||||
return { nodeId: [...actionIds][0] ?? null, candidates: [], reason: null };
|
||||
if (actionIds.size > 1)
|
||||
return {
|
||||
nodeId: null,
|
||||
candidates: graph.nodes.filter(({ id }) => actionIds.has(id)),
|
||||
reason: "interface_object_has_multiple_action_connections",
|
||||
};
|
||||
return {
|
||||
nodeId: null,
|
||||
candidates: [],
|
||||
reason: "ui_action_or_object_not_found",
|
||||
};
|
||||
};
|
||||
|
||||
const addDirectCallees = async (input: {
|
||||
readonly analysis: AnalysisOperationPort;
|
||||
readonly graph: z.infer<typeof nativeInvestigationGraphSchema>;
|
||||
readonly initial: z.infer<typeof nativeInvestigationTraceSchema>;
|
||||
readonly input: Input;
|
||||
readonly targetSha256: string;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}) => {
|
||||
const nodes = new Map(input.initial.nodes.map((node) => [node.id, node]));
|
||||
const edges = new Map(
|
||||
[...input.initial.edges, ...input.initial.unresolved].map((edge) => [
|
||||
edge.id,
|
||||
edge,
|
||||
]),
|
||||
);
|
||||
const addressNames = new Map<string, string>();
|
||||
const depthByNode = graphNodeDepths(
|
||||
input.initial.start,
|
||||
input.initial.nodes,
|
||||
input.initial.edges,
|
||||
);
|
||||
for (const node of nodes.values()) {
|
||||
if (node.kind === "function" && node.location?.address !== null)
|
||||
addressNames.set(node.location?.address ?? "", node.name);
|
||||
}
|
||||
const queue = input.initial.nodes.flatMap((node) =>
|
||||
node.kind === "function" && node.location?.address != null
|
||||
? [
|
||||
{
|
||||
node,
|
||||
address: node.location.address,
|
||||
depth: depthByNode.get(node.id) ?? input.initial.reached_depth,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const visited = new Set<string>();
|
||||
let queueIndex = 0;
|
||||
let examined = 0;
|
||||
let omitted = 0;
|
||||
let truncated = false;
|
||||
while (queueIndex < queue.length) {
|
||||
const current = queue[queueIndex++];
|
||||
if (current === undefined || visited.has(current.address)) continue;
|
||||
visited.add(current.address);
|
||||
if (current.depth >= input.input.max_depth) {
|
||||
truncated = true;
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
if (nodes.size >= input.input.max_nodes) {
|
||||
truncated = true;
|
||||
omitted += queue.length - queueIndex + 1;
|
||||
break;
|
||||
}
|
||||
const execution = await input.analysis.execute(
|
||||
"procedure_callees",
|
||||
{ procedure: current.address },
|
||||
input.signal === undefined ? {} : { signal: input.signal },
|
||||
);
|
||||
examined += 1;
|
||||
if (!execution.ok) {
|
||||
if (execution.error._tag === "AnalysisCancelledError")
|
||||
return { error: execution.error };
|
||||
const id = `unknown-callees:${current.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "direct_call",
|
||||
resolution: "unresolved",
|
||||
reason: projectAnalysisError(execution.error).message,
|
||||
evidence: [],
|
||||
limitations: [
|
||||
"The provider could not enumerate resolved direct callees.",
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const related = parseRelatedAddresses(execution.value.result, "callees");
|
||||
if (!related.ok) {
|
||||
const id = `unknown-callees:${current.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "direct_call",
|
||||
resolution: "unresolved",
|
||||
reason: "provider_returned_unreadable_callee_relationships",
|
||||
evidence: [],
|
||||
limitations: [
|
||||
"The provider result could not be normalized into callee addresses.",
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const [index, address] of related.value.entries()) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted += related.value.length - index;
|
||||
break;
|
||||
}
|
||||
const targetId = `native:function:${address}`;
|
||||
const evidence = [
|
||||
{
|
||||
kind: "reference" as const,
|
||||
description: `${execution.value.provider.name} resolved a direct callee from ${current.address} to ${address}`,
|
||||
location: { address: current.address, file_offset: null },
|
||||
artifact_path: null,
|
||||
artifact_sha256: input.targetSha256,
|
||||
},
|
||||
];
|
||||
if (!nodes.has(targetId)) {
|
||||
if (nodes.size >= input.input.max_nodes) {
|
||||
truncated = true;
|
||||
omitted += 1;
|
||||
const id = `unknown-node-bound:${current.address}:${address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "direct_call",
|
||||
resolution: "unresolved",
|
||||
reason: "native_trace_node_bound_reached",
|
||||
evidence,
|
||||
limitations: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const name = addressNames.get(address) ?? address;
|
||||
const node = {
|
||||
id: targetId,
|
||||
kind: "function" as const,
|
||||
name,
|
||||
location: { address, file_offset: null },
|
||||
attributes: { resolved_by: execution.value.provider.id },
|
||||
evidence,
|
||||
};
|
||||
nodes.set(targetId, node);
|
||||
queue.push({ node, address, depth: current.depth + 1 });
|
||||
}
|
||||
const edgeId = `call:${current.address}:${address}`;
|
||||
edges.set(edgeId, {
|
||||
id: edgeId,
|
||||
from: current.node.id,
|
||||
to: targetId,
|
||||
relation: "direct_call",
|
||||
resolution: "observed",
|
||||
evidence,
|
||||
limitations: [
|
||||
"A resolved static call edge does not establish runtime reachability.",
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
nodes: [...nodes.values()],
|
||||
edges: [...edges.values()],
|
||||
examined,
|
||||
omitted,
|
||||
truncated,
|
||||
};
|
||||
};
|
||||
|
||||
const graphNodeDepths = (
|
||||
start: string,
|
||||
nodes: readonly z.infer<
|
||||
typeof nativeInvestigationGraphSchema
|
||||
>["nodes"][number][],
|
||||
edges: readonly z.infer<
|
||||
typeof nativeInvestigationGraphSchema
|
||||
>["edges"][number][],
|
||||
): ReadonlyMap<string, number> => {
|
||||
const known = new Set(nodes.map(({ id }) => id));
|
||||
const depths = new Map([[start, 0]]);
|
||||
const queue = [start];
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const current = queue[index];
|
||||
if (current === undefined) continue;
|
||||
const depth = depths.get(current) ?? 0;
|
||||
for (const edge of edges) {
|
||||
if (
|
||||
edge.resolution === "unresolved" ||
|
||||
edge.from !== current ||
|
||||
edge.to === null ||
|
||||
!known.has(edge.to) ||
|
||||
depths.has(edge.to)
|
||||
)
|
||||
continue;
|
||||
depths.set(edge.to, depth + 1);
|
||||
queue.push(edge.to);
|
||||
}
|
||||
}
|
||||
return depths;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
clientConfigurationValuesEqual,
|
||||
parseClientConfiguration,
|
||||
serializeClientConfiguration,
|
||||
type ClientConfigurationDocument,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
@@ -70,7 +70,12 @@ const configureClientDocument = async (
|
||||
}
|
||||
const { document, servers, serversKey } = parsed;
|
||||
const desired = clientConfigurationDesired(client, environment, command);
|
||||
if (isDeepStrictEqual(servers[PRODUCT_IDENTITY.mcpServerKey], desired))
|
||||
if (
|
||||
clientConfigurationValuesEqual(
|
||||
servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
desired,
|
||||
)
|
||||
)
|
||||
return { status: "unchanged" };
|
||||
const backupPath =
|
||||
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
||||
@@ -102,7 +107,7 @@ const configureClientDocument = async (
|
||||
format,
|
||||
);
|
||||
if (
|
||||
!isDeepStrictEqual(
|
||||
!clientConfigurationValuesEqual(
|
||||
readback.servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
desired,
|
||||
)
|
||||
@@ -134,7 +139,10 @@ export const clientConfigurationAligned = async (
|
||||
try {
|
||||
const original = await readFile(client.configPath, "utf8");
|
||||
const { servers } = parseClientConfiguration(original, client.format);
|
||||
return isDeepStrictEqual(servers[PRODUCT_IDENTITY.mcpServerKey], desired);
|
||||
return clientConfigurationValuesEqual(
|
||||
servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
desired,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -174,7 +182,12 @@ export const inspectClientConfiguration = async (
|
||||
);
|
||||
try {
|
||||
const { servers } = parseClientConfiguration(original, client.format);
|
||||
if (isDeepStrictEqual(servers[PRODUCT_IDENTITY.mcpServerKey], desired))
|
||||
if (
|
||||
clientConfigurationValuesEqual(
|
||||
servers[PRODUCT_IDENTITY.mcpServerKey],
|
||||
desired,
|
||||
)
|
||||
)
|
||||
return { status: "already_current" };
|
||||
} catch {
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { inventoryArtifact } from "../application/ArtifactInventory.js";
|
||||
import { extractArtifact } from "../application/ArtifactExtraction.js";
|
||||
import { analyzeInterfaceBuilderBundle } from "../application/InterfaceBuilderAnalysis.js";
|
||||
import {
|
||||
ARTIFACT_ANALYSIS_OPERATIONS,
|
||||
artifactInventoryInputSchema,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { interfaceBuilderLimitsSchema } from "../domain/interfaceBuilderGraph.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
@@ -50,7 +52,7 @@ export class ArtifactProvider implements AnalysisProvider {
|
||||
reason: null,
|
||||
effects: Object.freeze({
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: true,
|
||||
launchesProcess: operation !== "decode_interface_builder",
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: operation === "extract_artifact",
|
||||
@@ -109,6 +111,33 @@ class ArtifactClient implements AnalysisClient {
|
||||
const inspected = await this.inspectArtifact(parameters, options);
|
||||
return inspected;
|
||||
}
|
||||
if (operation === "decode_interface_builder") {
|
||||
if (
|
||||
this.target.kind !== "executable" ||
|
||||
this.target.sourcePath === undefined ||
|
||||
!this.target.sourcePath.toLowerCase().endsWith(".app")
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"decode_interface_builder requires an active .app bundle target",
|
||||
);
|
||||
const limits = interfaceBuilderLimitsSchema.parse(parameters);
|
||||
const result = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: this.target.sourcePath,
|
||||
targetSha256: this.target.sha256,
|
||||
limits,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
return ok(
|
||||
createAnalysisExecution(result, IDENTITY, {
|
||||
limitations: result.limitations,
|
||||
locations: result.documents.map(({ relative_path: path }) => ({
|
||||
kind: "artifact-path" as const,
|
||||
path,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (operation === "extract_artifact") {
|
||||
const parsed = artifactExtractionExecutionSchema.parse(parameters);
|
||||
const result = await extractArtifact(
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { decodeNibArchive } from "./NibArchive.js";
|
||||
|
||||
const encodeArchive = (input: {
|
||||
readonly classes: readonly string[];
|
||||
readonly objects: readonly {
|
||||
readonly classIndex: number;
|
||||
readonly values: Readonly<
|
||||
Record<string, number | string | boolean | null | { ref: number }>
|
||||
>;
|
||||
}[];
|
||||
}): Buffer => {
|
||||
const keys = [
|
||||
...new Set(input.objects.flatMap(({ values }) => Object.keys(values))),
|
||||
];
|
||||
const keyIndex = new Map(keys.map((key, index) => [key, index]));
|
||||
const valueRecords = input.objects.flatMap(({ values }) =>
|
||||
Object.entries(values).map(([key, value]) =>
|
||||
Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]),
|
||||
),
|
||||
);
|
||||
let valueStart = 0;
|
||||
const records = input.objects.map(({ classIndex, values }) => {
|
||||
const count = Object.keys(values).length;
|
||||
const result = Buffer.concat([
|
||||
varint(classIndex),
|
||||
varint(valueStart),
|
||||
varint(count),
|
||||
]);
|
||||
valueStart += count;
|
||||
return result;
|
||||
});
|
||||
const objects = Buffer.concat(records);
|
||||
const encodedKeys = Buffer.concat(
|
||||
keys.map((key) =>
|
||||
Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]),
|
||||
),
|
||||
);
|
||||
const values = Buffer.concat(valueRecords);
|
||||
const classes = Buffer.concat(
|
||||
input.classes.map((name) => {
|
||||
const bytes = Buffer.from(name);
|
||||
return Buffer.concat([varint(bytes.length), varint(0), bytes]);
|
||||
}),
|
||||
);
|
||||
const objectsOffset = 50;
|
||||
const keysOffset = objectsOffset + objects.length;
|
||||
const valuesOffset = keysOffset + encodedKeys.length;
|
||||
const classesOffset = valuesOffset + values.length;
|
||||
const header = Buffer.alloc(50);
|
||||
header.write("NIBArchive", 0, "ascii");
|
||||
header.writeUInt32LE(1, 10);
|
||||
header.writeUInt32LE(10, 14);
|
||||
header.writeUInt32LE(input.objects.length, 18);
|
||||
header.writeUInt32LE(objectsOffset, 22);
|
||||
header.writeUInt32LE(keys.length, 26);
|
||||
header.writeUInt32LE(keysOffset, 30);
|
||||
header.writeUInt32LE(valueStart, 34);
|
||||
header.writeUInt32LE(valuesOffset, 38);
|
||||
header.writeUInt32LE(input.classes.length, 42);
|
||||
header.writeUInt32LE(classesOffset, 46);
|
||||
return Buffer.concat([header, objects, encodedKeys, values, classes]);
|
||||
};
|
||||
|
||||
const varint = (value: number): Buffer => {
|
||||
const bytes: number[] = [];
|
||||
let remaining = value;
|
||||
do {
|
||||
let byte = remaining & 0x7f;
|
||||
remaining >>>= 7;
|
||||
if (remaining === 0) byte |= 0x80;
|
||||
bytes.push(byte);
|
||||
} while (remaining > 0);
|
||||
return Buffer.from(bytes);
|
||||
};
|
||||
|
||||
const encodeValue = (
|
||||
value: number | string | boolean | null | { ref: number },
|
||||
): Buffer => {
|
||||
if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]);
|
||||
if (value === null) return Buffer.from([9]);
|
||||
if (typeof value === "number") {
|
||||
const bytes = Buffer.alloc(5);
|
||||
bytes[0] = 2;
|
||||
bytes.writeInt32LE(value, 1);
|
||||
return bytes;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const bytes = Buffer.alloc(5);
|
||||
bytes[0] = 10;
|
||||
bytes.writeUInt32LE(value.ref, 1);
|
||||
return bytes;
|
||||
}
|
||||
const data = Buffer.from(value);
|
||||
return Buffer.concat([Buffer.from([8]), varint(data.length), data]);
|
||||
};
|
||||
|
||||
describe("NIBArchive decoder", () => {
|
||||
it("decodes bounded object, key, class, and reference tables", () => {
|
||||
const archive = encodeArchive({
|
||||
classes: ["NSView\0", "NSString\0"],
|
||||
objects: [
|
||||
{ classIndex: 0, values: { child: { ref: 1 }, enabled: true } },
|
||||
{ classIndex: 1, values: { text: "Button" } },
|
||||
],
|
||||
});
|
||||
const decoded = decodeNibArchive(archive);
|
||||
|
||||
expect(decoded.objects).toEqual([
|
||||
{
|
||||
id: 0,
|
||||
class_name: "NSView",
|
||||
values: { child: { $nib_object_ref: 1 }, enabled: true },
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
class_name: "NSString",
|
||||
values: { text: { $nib_data_base64: "QnV0dG9u" } },
|
||||
},
|
||||
]);
|
||||
expect(decoded.coder_version).toBe(10);
|
||||
});
|
||||
|
||||
it("rejects malformed references and unsupported format versions", () => {
|
||||
const valid = encodeArchive({
|
||||
classes: ["NSObject"],
|
||||
objects: [{ classIndex: 0, values: {} }],
|
||||
});
|
||||
const invalidVersion = Buffer.from(valid);
|
||||
invalidVersion.writeUInt32LE(2, 10);
|
||||
expect(() => decodeNibArchive(invalidVersion)).toThrow(/Unsupported/u);
|
||||
const invalidReference = encodeArchive({
|
||||
classes: ["NSObject"],
|
||||
objects: [{ classIndex: 0, values: { child: { ref: 4 } } }],
|
||||
});
|
||||
expect(() => decodeNibArchive(invalidReference)).toThrow(
|
||||
/object reference/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { TextDecoder } from "node:util";
|
||||
|
||||
const MAGIC = Buffer.from("NIBArchive", "ascii");
|
||||
const MAX_RECORDS = 1_000_000;
|
||||
|
||||
interface NibObjectRecord {
|
||||
readonly class_index: number;
|
||||
readonly value_start: number;
|
||||
readonly value_count: number;
|
||||
}
|
||||
|
||||
interface NibValueRecord {
|
||||
readonly key_index: number;
|
||||
readonly value: JsonValue;
|
||||
}
|
||||
|
||||
/** Parsed NIBArchive payload with the original object indices preserved. */
|
||||
export interface NibArchiveDocument {
|
||||
readonly format_version: number;
|
||||
readonly coder_version: number;
|
||||
readonly object_count: number;
|
||||
readonly connection_object_count: number;
|
||||
readonly objects: readonly {
|
||||
readonly id: number;
|
||||
readonly class_name: string;
|
||||
readonly values: Readonly<Record<string, JsonValue>>;
|
||||
}[];
|
||||
readonly classes: readonly string[];
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/** Decode Apple's length-prefixed NIBArchive representation without loading UI classes. */
|
||||
export const decodeNibArchive = (bytes: Buffer): NibArchiveDocument => {
|
||||
if (bytes.length < 50 || !bytes.subarray(0, MAGIC.length).equals(MAGIC))
|
||||
throw new TypeError("NIBArchive magic or header is invalid");
|
||||
|
||||
const formatVersion = readU32(bytes, 10);
|
||||
const coderVersion = readU32(bytes, 14);
|
||||
if (formatVersion !== 1)
|
||||
throw new TypeError(
|
||||
`Unsupported NIBArchive format version ${formatVersion}`,
|
||||
);
|
||||
|
||||
const objectCount = readCount(bytes, 18, "object");
|
||||
const objectsOffset = readOffset(bytes, 22, bytes.length, "object");
|
||||
const keyCount = readCount(bytes, 26, "key");
|
||||
const keysOffset = readOffset(bytes, 30, bytes.length, "key");
|
||||
const valueCount = readCount(bytes, 34, "value");
|
||||
const valuesOffset = readOffset(bytes, 38, bytes.length, "value");
|
||||
const classCount = readCount(bytes, 42, "class");
|
||||
const classesOffset = readOffset(bytes, 46, bytes.length, "class");
|
||||
|
||||
assertOrderedOffsets(bytes.length, [
|
||||
objectsOffset,
|
||||
keysOffset,
|
||||
valuesOffset,
|
||||
classesOffset,
|
||||
]);
|
||||
const objectRecords = parseObjects(
|
||||
bytes,
|
||||
objectsOffset,
|
||||
keysOffset,
|
||||
objectCount,
|
||||
valueCount,
|
||||
classCount,
|
||||
);
|
||||
const keys = parseStrings(bytes, keysOffset, valuesOffset, keyCount, "key");
|
||||
const classes = parseClasses(bytes, classesOffset, bytes.length, classCount);
|
||||
const neededValues = valueIndexes(objectRecords, valueCount);
|
||||
const values = parseValues(
|
||||
bytes,
|
||||
valuesOffset,
|
||||
classesOffset,
|
||||
valueCount,
|
||||
keyCount,
|
||||
neededValues,
|
||||
);
|
||||
for (const { value } of values.values()) {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof value.$nib_object_ref === "number" &&
|
||||
value.$nib_object_ref >= objectCount
|
||||
)
|
||||
throw new TypeError(
|
||||
"NIBArchive object reference is outside the object table",
|
||||
);
|
||||
}
|
||||
const objects = objectRecords.flatMap((object, id) => {
|
||||
const className = classes[object.class_index];
|
||||
if (className === undefined) return [];
|
||||
const fields: Record<string, JsonValue> = {};
|
||||
for (
|
||||
let index = object.value_start;
|
||||
index < object.value_start + object.value_count;
|
||||
index += 1
|
||||
) {
|
||||
const value = values.get(index);
|
||||
if (value === undefined) continue;
|
||||
const key = keys[value.key_index];
|
||||
if (key !== undefined) fields[key] = value.value;
|
||||
}
|
||||
return [{ id, class_name: className, values: fields }];
|
||||
});
|
||||
let connectionObjectCount = 0;
|
||||
for (const object of objects)
|
||||
if (
|
||||
/(?:Outlet|Connection|Segue|ActionConnection)/iu.test(object.class_name)
|
||||
)
|
||||
connectionObjectCount += 1;
|
||||
|
||||
return {
|
||||
format_version: formatVersion,
|
||||
coder_version: coderVersion,
|
||||
object_count: objectCount,
|
||||
connection_object_count: connectionObjectCount,
|
||||
objects,
|
||||
classes,
|
||||
limitations: [
|
||||
"NIBArchive class extensions and unsupported value encodings are retained as unavailable fields.",
|
||||
"NIBArchive object relationships are decoded from serialized references; runtime object creation and execution are not performed.",
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const parseObjects = (
|
||||
bytes: Buffer,
|
||||
start: number,
|
||||
end: number,
|
||||
count: number,
|
||||
valueCount: number,
|
||||
classCount: number,
|
||||
): NibObjectRecord[] => {
|
||||
const cursor = { offset: start };
|
||||
const output: NibObjectRecord[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const classIndex = readVarint(bytes, cursor, end);
|
||||
const valueStart = readVarint(bytes, cursor, end);
|
||||
const countForObject = readVarint(bytes, cursor, end);
|
||||
if (
|
||||
classIndex >= classCount ||
|
||||
valueStart > valueCount ||
|
||||
countForObject > valueCount - valueStart
|
||||
)
|
||||
throw new TypeError(
|
||||
`NIBArchive object ${index} references an invalid table range`,
|
||||
);
|
||||
output.push({
|
||||
class_index: classIndex,
|
||||
value_start: valueStart,
|
||||
value_count: countForObject,
|
||||
});
|
||||
}
|
||||
if (cursor.offset > end)
|
||||
throw new TypeError("NIBArchive object table overlaps the next table");
|
||||
return output;
|
||||
};
|
||||
|
||||
const parseStrings = (
|
||||
bytes: Buffer,
|
||||
start: number,
|
||||
end: number,
|
||||
count: number,
|
||||
label: string,
|
||||
): string[] => {
|
||||
const cursor = { offset: start };
|
||||
const output: string[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const length = readVarint(bytes, cursor, end);
|
||||
const next = checkedEnd(cursor.offset, length, end, `${label} ${index}`);
|
||||
const value = decodeUtf8(bytes, cursor.offset, next, `${label} ${index}`);
|
||||
cursor.offset = next;
|
||||
output.push(value);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
const parseClasses = (
|
||||
bytes: Buffer,
|
||||
start: number,
|
||||
end: number,
|
||||
count: number,
|
||||
): string[] => {
|
||||
const cursor = { offset: start };
|
||||
const output: string[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const length = readVarint(bytes, cursor, end);
|
||||
const extraCount = readVarint(bytes, cursor, end);
|
||||
if (extraCount > 16)
|
||||
throw new TypeError(
|
||||
`NIBArchive class ${index} has too many extension values`,
|
||||
);
|
||||
ensureRange(
|
||||
cursor.offset,
|
||||
extraCount * 4,
|
||||
end,
|
||||
`class ${index} extensions`,
|
||||
);
|
||||
cursor.offset += extraCount * 4;
|
||||
const next = checkedEnd(cursor.offset, length, end, `class ${index}`);
|
||||
const name = decodeUtf8(
|
||||
bytes,
|
||||
cursor.offset,
|
||||
next,
|
||||
`class ${index}`,
|
||||
).replace(/\0+$/u, "");
|
||||
cursor.offset = next;
|
||||
output.push(name);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
const parseValues = (
|
||||
bytes: Buffer,
|
||||
start: number,
|
||||
end: number,
|
||||
count: number,
|
||||
keyCount: number,
|
||||
needed: ReadonlySet<number>,
|
||||
): Map<number, NibValueRecord> => {
|
||||
const cursor = { offset: start };
|
||||
const output = new Map<number, NibValueRecord>();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const keyIndex = readVarint(bytes, cursor, end);
|
||||
if (keyIndex >= keyCount)
|
||||
throw new TypeError(
|
||||
`NIBArchive value ${index} references an invalid key`,
|
||||
);
|
||||
ensureRange(cursor.offset, 1, end, `value ${index} type`);
|
||||
const type = bytes[cursor.offset++] ?? -1;
|
||||
const value = readValue(bytes, cursor, end, type, index);
|
||||
if (needed.has(index)) output.set(index, { key_index: keyIndex, value });
|
||||
}
|
||||
if (cursor.offset > end)
|
||||
throw new TypeError("NIBArchive value table overlaps the next table");
|
||||
return output;
|
||||
};
|
||||
|
||||
const readValue = (
|
||||
bytes: Buffer,
|
||||
cursor: { offset: number },
|
||||
end: number,
|
||||
type: number,
|
||||
index: number,
|
||||
): JsonValue => {
|
||||
switch (type) {
|
||||
case 0:
|
||||
ensureRange(cursor.offset, 1, end, `value ${index}`);
|
||||
return bytes.readInt8(cursor.offset++);
|
||||
case 1:
|
||||
ensureRange(cursor.offset, 2, end, `value ${index}`);
|
||||
{
|
||||
const value = bytes.readInt16LE(cursor.offset);
|
||||
cursor.offset += 2;
|
||||
return value;
|
||||
}
|
||||
case 2:
|
||||
ensureRange(cursor.offset, 4, end, `value ${index}`);
|
||||
{
|
||||
const value = bytes.readInt32LE(cursor.offset);
|
||||
cursor.offset += 4;
|
||||
return value;
|
||||
}
|
||||
case 3:
|
||||
ensureRange(cursor.offset, 8, end, `value ${index}`);
|
||||
{
|
||||
const value = bytes.readBigInt64LE(cursor.offset);
|
||||
cursor.offset += 8;
|
||||
return value <= BigInt(Number.MAX_SAFE_INTEGER) &&
|
||||
value >= BigInt(Number.MIN_SAFE_INTEGER)
|
||||
? Number(value)
|
||||
: value.toString();
|
||||
}
|
||||
case 4:
|
||||
return true;
|
||||
case 5:
|
||||
return false;
|
||||
case 6:
|
||||
ensureRange(cursor.offset, 4, end, `value ${index}`);
|
||||
{
|
||||
const value = bytes.readFloatLE(cursor.offset);
|
||||
cursor.offset += 4;
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
case 7:
|
||||
ensureRange(cursor.offset, 8, end, `value ${index}`);
|
||||
{
|
||||
const value = bytes.readDoubleLE(cursor.offset);
|
||||
cursor.offset += 8;
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
case 8: {
|
||||
const length = readVarint(bytes, cursor, end);
|
||||
const next = checkedEnd(
|
||||
cursor.offset,
|
||||
length,
|
||||
end,
|
||||
`value ${index} data`,
|
||||
);
|
||||
const encoded = bytes.subarray(cursor.offset, next).toString("base64");
|
||||
cursor.offset = next;
|
||||
return { $nib_data_base64: encoded };
|
||||
}
|
||||
case 9:
|
||||
return null;
|
||||
case 10:
|
||||
ensureRange(cursor.offset, 4, end, `value ${index} reference`);
|
||||
{
|
||||
const objectIndex = bytes.readUInt32LE(cursor.offset);
|
||||
cursor.offset += 4;
|
||||
return { $nib_object_ref: objectIndex };
|
||||
}
|
||||
default:
|
||||
throw new TypeError(
|
||||
`NIBArchive value ${index} has unsupported type ${type}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const valueIndexes = (
|
||||
objects: readonly NibObjectRecord[],
|
||||
total: number,
|
||||
): ReadonlySet<number> => {
|
||||
const indexes = new Set<number>();
|
||||
for (const object of objects)
|
||||
for (
|
||||
let index = object.value_start;
|
||||
index < object.value_start + object.value_count;
|
||||
index += 1
|
||||
)
|
||||
indexes.add(index);
|
||||
if (indexes.size > total)
|
||||
throw new TypeError(
|
||||
"NIBArchive object value ranges exceed the value table",
|
||||
);
|
||||
return indexes;
|
||||
};
|
||||
|
||||
const readCount = (bytes: Buffer, offset: number, label: string): number => {
|
||||
const count = readU32(bytes, offset);
|
||||
if (count > MAX_RECORDS)
|
||||
throw new TypeError(`NIBArchive ${label} count exceeds the decoder bound`);
|
||||
return count;
|
||||
};
|
||||
|
||||
const readOffset = (
|
||||
bytes: Buffer,
|
||||
offset: number,
|
||||
fileLength: number,
|
||||
label: string,
|
||||
): number => {
|
||||
const value = readU32(bytes, offset);
|
||||
if (value < 50 || value > fileLength)
|
||||
throw new TypeError(`NIBArchive ${label} table offset is invalid`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const readU32 = (bytes: Buffer, offset: number): number => {
|
||||
ensureRange(offset, 4, bytes.length, "header");
|
||||
return bytes.readUInt32LE(offset);
|
||||
};
|
||||
|
||||
const decodeUtf8 = (
|
||||
bytes: Buffer,
|
||||
start: number,
|
||||
end: number,
|
||||
label: string,
|
||||
): string => {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
bytes.subarray(start, end),
|
||||
);
|
||||
} catch {
|
||||
throw new TypeError(`NIBArchive ${label} is not valid UTF-8`);
|
||||
}
|
||||
};
|
||||
|
||||
const readVarint = (
|
||||
bytes: Buffer,
|
||||
cursor: { offset: number },
|
||||
end: number,
|
||||
): number => {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
ensureRange(cursor.offset, 1, end, "variable integer");
|
||||
const byte = bytes[cursor.offset++] ?? 0;
|
||||
value += (byte & 0x7f) * 2 ** shift;
|
||||
if ((byte & 0x80) !== 0) {
|
||||
if (value > 0x7fff_ffff)
|
||||
throw new TypeError("NIBArchive variable integer is out of range");
|
||||
return value;
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
throw new TypeError("NIBArchive variable integer is too long");
|
||||
};
|
||||
|
||||
const checkedEnd = (
|
||||
start: number,
|
||||
length: number,
|
||||
limit: number,
|
||||
label: string,
|
||||
): number => {
|
||||
if (!Number.isSafeInteger(length) || length < 0 || start + length > limit)
|
||||
throw new TypeError(`NIBArchive ${label} exceeds its table`);
|
||||
return start + length;
|
||||
};
|
||||
|
||||
const ensureRange = (
|
||||
start: number,
|
||||
length: number,
|
||||
limit: number,
|
||||
label: string,
|
||||
): void => {
|
||||
checkedEnd(start, length, limit, label);
|
||||
};
|
||||
|
||||
const assertOrderedOffsets = (length: number, offsets: readonly number[]) => {
|
||||
let previous = 50;
|
||||
for (const offset of offsets) {
|
||||
if (offset < previous || offset > length)
|
||||
throw new TypeError("NIBArchive table offsets are out of order");
|
||||
previous = offset;
|
||||
}
|
||||
};
|
||||
@@ -39,3 +39,31 @@ it("returns a complete requested text artifact beyond the former inline ceiling"
|
||||
value: { bytes: 16 * 1_024 * 1_024 + 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps URL, history, and storage snapshots in the capture state contract", async () => {
|
||||
const page = {
|
||||
url: () => "https://app.example.test/path?token=secret",
|
||||
evaluate: async (expression: string) =>
|
||||
expression.includes("window.history.length")
|
||||
? {
|
||||
length: 2,
|
||||
navigation_entries: [
|
||||
{ type: "navigate", name: "https://app.example.test/path" },
|
||||
],
|
||||
}
|
||||
: { local_storage: [], session_storage: [] },
|
||||
} as unknown as Page;
|
||||
const context = { cookies: async () => [] } as unknown as BrowserContext;
|
||||
|
||||
const result = await capturePlaywrightStepArtifacts({
|
||||
context,
|
||||
page,
|
||||
scenario,
|
||||
secrets,
|
||||
requested: new Set(["url", "history", "storage"]),
|
||||
});
|
||||
|
||||
expect(result.url.state).toBe("captured");
|
||||
expect(result.history.state).toBe("captured");
|
||||
expect(result.storage.state).toBe("captured");
|
||||
});
|
||||
@@ -126,7 +126,7 @@ const captureHistory = async (page: Page) => {
|
||||
name: sanitizeBrowserUrl(name),
|
||||
})),
|
||||
};
|
||||
return value;
|
||||
return { state: "captured" as const, value };
|
||||
} catch {
|
||||
return missing("history capture failed");
|
||||
}
|
||||
@@ -166,7 +166,7 @@ const captureStorage = async (input: {
|
||||
...secrets.fingerprint(value),
|
||||
})),
|
||||
};
|
||||
return value;
|
||||
return { state: "captured" as const, value };
|
||||
} catch {
|
||||
return missing("storage capture failed");
|
||||
}
|
||||
@@ -211,9 +211,10 @@ export const capturePlaywrightStepArtifacts = async (input: {
|
||||
accessibility: await state("accessibility", () =>
|
||||
captureAccessibility({ page, secrets }),
|
||||
),
|
||||
url: await state("url", async () =>
|
||||
Promise.resolve(sanitizeBrowserUrl(page.url())),
|
||||
),
|
||||
url: await state("url", async () => ({
|
||||
state: "captured" as const,
|
||||
value: sanitizeBrowserUrl(page.url()),
|
||||
})),
|
||||
history: await state("history", () => captureHistory(page)),
|
||||
storage: await state("storage", () =>
|
||||
captureStorage({ context, page, scenario, secrets }),
|
||||
|
||||
@@ -13,6 +13,61 @@ export const registerArtifactCommands = (
|
||||
): void => {
|
||||
registerInspectionCommand(cli, logger);
|
||||
registerExtractionCommand(cli, logger);
|
||||
registerInterfaceBuilderCommand(cli, logger);
|
||||
};
|
||||
|
||||
const registerInterfaceBuilderCommand = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.decodeInterfaceBuilder, {
|
||||
description:
|
||||
"Decode compiled storyboard and nib archives from an app bundle",
|
||||
args: z.object({
|
||||
path: z.string().describe("Apple .app bundle path"),
|
||||
}),
|
||||
options: z.object({
|
||||
maxDocuments: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.default(64)
|
||||
.describe("Maximum archives to decode"),
|
||||
maxObjects: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(20_000)
|
||||
.default(20_000)
|
||||
.describe("Maximum graph objects"),
|
||||
maxConnections: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(40_000)
|
||||
.default(40_000)
|
||||
.describe("Maximum decoded connections"),
|
||||
}),
|
||||
alias: {
|
||||
maxDocuments: "max-documents",
|
||||
maxObjects: "max-objects",
|
||||
maxConnections: "max-connections",
|
||||
},
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "decode-interface-builder", () =>
|
||||
runProviderAnalysis(
|
||||
args.path,
|
||||
"decode_interface_builder",
|
||||
{
|
||||
max_documents: options.maxDocuments,
|
||||
max_objects: options.maxObjects,
|
||||
max_connections: options.maxConnections,
|
||||
},
|
||||
logger,
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => {
|
||||
|
||||
@@ -21,6 +21,107 @@ export const registerCoreAnalysisCommands = (
|
||||
registerSearchCommand(cli, logger);
|
||||
registerXrefsCommand(cli, logger);
|
||||
registerTraceCommand(cli, logger);
|
||||
registerNativeUiActionCommand(cli, logger);
|
||||
registerNativeDispatchMetadataCommand(cli, logger);
|
||||
};
|
||||
|
||||
const registerNativeDispatchMetadataCommand = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.inspectNativeDispatchMetadata, {
|
||||
description: "Inspect typed Objective-C and Swift dispatch metadata",
|
||||
args: z.object({
|
||||
path: z.string().describe("Target path used to bind the result evidence"),
|
||||
}),
|
||||
options: z.object({
|
||||
maxRecords: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(20_000)
|
||||
.default(5_000)
|
||||
.describe("Maximum symbol records to inspect"),
|
||||
snapshot: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Load or update the local analysis snapshot"),
|
||||
provider: providerSelectionOption,
|
||||
}),
|
||||
alias: { maxRecords: "max-records" },
|
||||
run: async ({ args, options }) =>
|
||||
logCliCommand(logger, "inspect-native-dispatch-metadata", () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"inspect_native_dispatch_metadata",
|
||||
{ max_records: options.maxRecords },
|
||||
directAnalysisOptions(logger, options.snapshot, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const registerNativeUiActionCommand = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.traceNativeUiAction, {
|
||||
description:
|
||||
"Trace a compiled UI action selector to its native handler and direct callees",
|
||||
args: z.object({
|
||||
path: z.string().describe("Target path used to bind the result evidence"),
|
||||
action: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"A unique compiled UI action selector or interface object ID",
|
||||
),
|
||||
}),
|
||||
options: z.object({
|
||||
maxDepth: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(32)
|
||||
.default(8)
|
||||
.describe("Maximum relationship depth"),
|
||||
maxNodes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(2_000)
|
||||
.default(250)
|
||||
.describe("Maximum returned graph nodes"),
|
||||
maxEdges: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(5_000)
|
||||
.default(500)
|
||||
.describe("Maximum returned graph edges"),
|
||||
provider: providerSelectionOption,
|
||||
}),
|
||||
alias: {
|
||||
maxDepth: "max-depth",
|
||||
maxNodes: "max-nodes",
|
||||
maxEdges: "max-edges",
|
||||
},
|
||||
run: async ({ args, options }) =>
|
||||
logCliCommand(logger, "trace-native-ui-action", () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"trace_native_ui_action",
|
||||
{
|
||||
action: args.action,
|
||||
max_depth: options.maxDepth,
|
||||
max_nodes: options.maxNodes,
|
||||
max_edges: options.maxEdges,
|
||||
},
|
||||
directAnalysisOptions(logger, undefined, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const registerCoreCommands = (cli: CliInstance, logger: Logger): void => {
|
||||
|
||||
@@ -9,6 +9,8 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
upgrade: "upgrade",
|
||||
xrefs: "xrefs",
|
||||
trace: "trace",
|
||||
traceNativeUiAction: "trace-native-ui-action",
|
||||
inspectNativeDispatchMetadata: "inspect-native-dispatch-metadata",
|
||||
capabilities: "capabilities",
|
||||
providers: "providers",
|
||||
function: "function",
|
||||
@@ -18,6 +20,7 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
importReferenceSource: "import-reference-source",
|
||||
inspectArtifact: "inspect-artifact",
|
||||
extractArtifact: "extract-artifact",
|
||||
decodeInterfaceBuilder: "decode-interface-builder",
|
||||
inspectManagedArtifact: "inspect-managed-artifact",
|
||||
inspectManagedMembers: "inspect-managed-members",
|
||||
inspectManagedNativeBoundaries: "inspect-managed-native-boundaries",
|
||||
|
||||
@@ -38,6 +38,7 @@ const exampleInputSchema = z.record(z.string(), jsonValueSchema);
|
||||
const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
|
||||
inspect_artifact: {},
|
||||
extract_artifact: {},
|
||||
decode_interface_builder: {},
|
||||
};
|
||||
|
||||
const artifact = <
|
||||
@@ -77,6 +78,15 @@ export const ARTIFACT_TOOL_CONTRACTS = [
|
||||
"Extract all regular files from the active archive or application package into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.",
|
||||
artifactExtractionInputSchema,
|
||||
),
|
||||
artifact(
|
||||
"decode_interface_builder",
|
||||
"Decode compiled storyboard and nib archives inside the active Apple app bundle, including keyed property lists and NIBArchive object tables, into a bounded object, hierarchy, and connection graph. This is static archive parsing; unrecognized archive classes and unresolved code handlers remain explicit unknowns.",
|
||||
z.strictObject({
|
||||
max_documents: z.number().int().min(1).max(64).default(64),
|
||||
max_objects: z.number().int().min(1).max(20_000).default(20_000),
|
||||
max_connections: z.number().int().min(1).max(40_000).default(40_000),
|
||||
}),
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Provider operations; inventory remains an internal primitive of inspection. */
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const traceLiteralInputSchema = z.strictObject({
|
||||
query: z.string().min(1),
|
||||
case_sensitive: z.boolean().default(false),
|
||||
@@ -7,6 +6,9 @@ const traceLiteralInputSchema = z.strictObject({
|
||||
|
||||
/** Input schemas shared by MCP registration and enhanced application dispatch. */
|
||||
export const enhancedInputSchemas = {
|
||||
inspect_native_dispatch_metadata: z.strictObject({
|
||||
max_records: z.number().int().min(1).max(20_000).default(5_000),
|
||||
}),
|
||||
get_objc_classes: z.strictObject({ pattern: z.string().default("") }),
|
||||
get_objc_protocols: z.strictObject({}),
|
||||
batch_decompile: z.strictObject({
|
||||
@@ -48,6 +50,15 @@ export const enhancedInputSchemas = {
|
||||
.optional(),
|
||||
direction: z.enum(["forward", "backward"]).default("forward"),
|
||||
}),
|
||||
trace_native_ui_action: z.strictObject({
|
||||
action: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("A unique compiled UI action selector or interface object ID"),
|
||||
max_depth: z.number().int().min(0).max(32).default(8),
|
||||
max_nodes: z.number().int().min(1).max(2_000).default(250),
|
||||
max_edges: z.number().int().min(1).max(5_000).default(500),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
export type EnhancedToolName = keyof typeof enhancedInputSchemas;
|
||||
@@ -44,6 +44,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
|
||||
trace_feature: { query: "license" },
|
||||
find_code_for_string: { query: "authorization failed" },
|
||||
trace_call_path: { start: "0x1000", goal: "0x1100" },
|
||||
trace_native_ui_action: { action: "buildTapped:" },
|
||||
open_binary: { path: "/tmp/fixture" },
|
||||
export_evidence_bundle: { path: "/tmp/evidence.json" },
|
||||
inspect_address_context: { address: "0x1000" },
|
||||
|
||||
@@ -16,11 +16,12 @@ import { APPLICATION_TOOL_CONTRACTS } from "./applicationToolContracts.js";
|
||||
describe("tool contract inventory", () => {
|
||||
it("publishes the canonical analysis and session tool inventory", () => {
|
||||
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(36);
|
||||
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(12);
|
||||
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(14);
|
||||
expect(NATIVE_TOOL_CONTRACTS).toHaveLength(5);
|
||||
expect(ARTIFACT_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([
|
||||
"inspect_artifact",
|
||||
"extract_artifact",
|
||||
"decode_interface_builder",
|
||||
]);
|
||||
expect(MANAGED_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([
|
||||
"inspect_managed_artifact",
|
||||
|
||||
@@ -295,6 +295,11 @@ export type OfficialToolName = (typeof OFFICIAL_TOOL_CONTRACTS)[number]["name"];
|
||||
|
||||
/** Workflow tools composed from one or more provider operations. */
|
||||
export const ENHANCED_TOOL_CONTRACTS = [
|
||||
enhanced(
|
||||
"inspect_native_dispatch_metadata",
|
||||
"Inspect the bound provider's name inventory for Objective-C class/method symbols and Swift mangled symbols, returning at most max_records decoded names. Includes target SHA-256 when available, provider identity/version, analysis-profile digest, facet coverage, and per-record Evidence. This is symbol-level evidence, not runtime metadata: ivar layouts, protocol conformances, witness/class tables, and relative pointers are explicitly marked unsupported unless a provider supplies those decoders. The provider controls how its name inventory is acquired.",
|
||||
enhancedInputSchemas.inspect_native_dispatch_metadata,
|
||||
),
|
||||
enhanced(
|
||||
"get_objc_classes",
|
||||
"Discover and deduplicate Objective-C class labels, optionally filtering by literal substring.",
|
||||
@@ -346,6 +351,11 @@ export const ENHANCED_TOOL_CONTRACTS = [
|
||||
"Trace direct callers or callees from one exact procedure address until the graph is exhausted or the optional goal is reached. Returns visited nodes, direct-call edges, a shortest traversal path, provider failures, and residual unknowns; unresolved indirect calls remain unknown.",
|
||||
enhancedInputSchemas.trace_call_path,
|
||||
),
|
||||
enhanced(
|
||||
"trace_native_ui_action",
|
||||
"Trace one unique compiled Interface Builder action selector or object ID through its authored UI connections to symbolized Objective-C handlers and bounded direct callees. REA decodes the active app bundle and reads the selected provider's symbol inventory itself. Authored connections, statically matched handlers, resolved direct calls, and unresolved dynamic dispatch remain distinct; runtime reachability and cross-function value flow are not claimed.",
|
||||
enhancedInputSchemas.trace_native_ui_action,
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Session-owned Evidence bundle export options. */
|
||||
|
||||
@@ -40,6 +40,8 @@ const sessionEvidence = effects({ mutatesSession: true });
|
||||
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
inspect_native_dispatch_metadata: evidence,
|
||||
trace_native_ui_action: evidence,
|
||||
address_name: evidence,
|
||||
comment: evidence,
|
||||
current_address: evidence,
|
||||
@@ -99,6 +101,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
demangle_swift: nativeEvidence,
|
||||
inspect_artifact: evidence,
|
||||
extract_artifact: effects({ mutatesSession: true, writesFilesystem: true }),
|
||||
decode_interface_builder: evidence,
|
||||
inspect_managed_artifact: evidence,
|
||||
inspect_managed_members: evidence,
|
||||
inspect_managed_native_boundaries: evidence,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../domain/nativeInspection.js";
|
||||
import { artifactExtractionResultSchema } from "../domain/artifactGraph.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
import { interfaceBuilderAnalysisSchema } from "../domain/interfaceBuilderGraph.js";
|
||||
import {
|
||||
managedArtifactInspectionSchema,
|
||||
managedMemberInspectionSchema,
|
||||
@@ -40,6 +41,8 @@ import { staticRuntimeCorrelationResultSchema } from "../domain/staticRuntimeCor
|
||||
import { reconstructionVerificationResultSchema } from "../domain/reconstructionVerification.js";
|
||||
import { replayMachineRunOutputSchema } from "../domain/replayMachineRun.js";
|
||||
import { analysisErrorProjectionSchema } from "./errorSchemas.js";
|
||||
import { nativeDispatchMetadataResultSchema } from "../domain/objcSwiftMetadata.js";
|
||||
import { nativeInvestigationTraceSchema } from "../domain/nativeInvestigationGraph.js";
|
||||
import {
|
||||
addressList,
|
||||
addressedValue,
|
||||
@@ -219,6 +222,10 @@ const callPathTraceOutput = resultOf(
|
||||
|
||||
/** Exact structured-content schemas for composed analysis workflows. */
|
||||
export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
inspect_native_dispatch_metadata: resultOf(
|
||||
nativeDispatchMetadataResultSchema,
|
||||
),
|
||||
trace_native_ui_action: resultOf(nativeInvestigationTraceSchema),
|
||||
get_objc_classes: symbolDiscoveryOutput("classes"),
|
||||
get_objc_protocols: symbolDiscoveryOutput("protocols"),
|
||||
batch_decompile: resultOf(
|
||||
@@ -306,6 +313,7 @@ export const nativeOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
inspect_artifact: resultOf(artifactInspectionResultSchema),
|
||||
extract_artifact: resultOf(artifactExtractionResultSchema),
|
||||
decode_interface_builder: resultOf(interfaceBuilderAnalysisSchema),
|
||||
};
|
||||
|
||||
/** Exact Evidence schema for execution-free managed static analysis. */
|
||||
|
||||
@@ -275,6 +275,7 @@ export class ArtifactOperationError extends AnalysisError {
|
||||
| "inventory_artifact"
|
||||
| "inspect_artifact"
|
||||
| "extract_artifact"
|
||||
| "decode_interface_builder"
|
||||
| "analyze_javascript_application",
|
||||
readonly reason:
|
||||
| "cancelled"
|
||||
|
||||
@@ -92,7 +92,6 @@ export const ghidraNativeApiBoundary = () => ({
|
||||
{
|
||||
case_value: 0,
|
||||
target_address: "0x401020",
|
||||
data_addresses: ["0x403000"],
|
||||
confidence: "medium" as const,
|
||||
evidence: [
|
||||
{
|
||||
@@ -149,6 +148,42 @@ export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => {
|
||||
referenced_names: [],
|
||||
basic_blocks: [{ start: "0x401000", end: "0x401006", successors: [] }],
|
||||
native_api: ghidraNativeApiBoundary(),
|
||||
native_value_flow: {
|
||||
available: true,
|
||||
provenance: "ghidra-high-pcode",
|
||||
operations: [
|
||||
{
|
||||
id: "0x401000#0",
|
||||
address: "0x401000",
|
||||
sequence: 0,
|
||||
opcode: "COPY",
|
||||
is_dead: false,
|
||||
inputs: [
|
||||
{
|
||||
kind: "constant",
|
||||
size_bytes: 4,
|
||||
location: null,
|
||||
constant_hex: "2a",
|
||||
},
|
||||
],
|
||||
output: {
|
||||
kind: "register",
|
||||
size_bytes: 4,
|
||||
location: "register:0x0",
|
||||
constant_hex: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
def_use: [],
|
||||
effects: [],
|
||||
truncated: false,
|
||||
omitted_operations_lower_bound: 0,
|
||||
known_omitted_inputs: 0,
|
||||
known_omitted_edges: 0,
|
||||
limitations: [
|
||||
"High p-code is a decompiler-derived intra-function representation, not original source or runtime behavior.",
|
||||
],
|
||||
},
|
||||
limitations: [
|
||||
"Unresolved computed or indirect flows without target addresses are not represented as reference edges.",
|
||||
"Thunk and external classifications are Ghidra FunctionManager observations; they do not resolve targetless calls.",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { JsonValue } from "./jsonValue.js";
|
||||
import { AnalysisOutputError, HopperProtocolError } from "./errors.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import { nativeApiBoundarySchema } from "./nativeApiBoundary.js";
|
||||
import { nativeValueFlowSchema } from "./nativeValueFlow.js";
|
||||
|
||||
export interface AddressedName {
|
||||
readonly address: string;
|
||||
@@ -176,6 +177,7 @@ export const functionDossierSchema = z
|
||||
.strict(),
|
||||
),
|
||||
native_api: nativeApiBoundarySchema.nullable().default(null),
|
||||
native_value_flow: nativeValueFlowSchema.nullable().default(null),
|
||||
limitations: z.array(z.string()).default([]),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBinary, parseBinary } from "plist";
|
||||
|
||||
import {
|
||||
buildInterfaceBuilderAnalysis,
|
||||
interfaceBuilderLimitsSchema,
|
||||
parseInterfaceBuilderRecords,
|
||||
} from "./interfaceBuilderGraph.js";
|
||||
|
||||
const hash = "a".repeat(64);
|
||||
|
||||
describe("compiled Interface Builder graph projection", () => {
|
||||
it("normalizes object, hierarchy, action, outlet, and segue facts", () => {
|
||||
const raw = {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
controller: { customClass: "StoreViewController", label: "Store" },
|
||||
button: { class: "UIButton", title: "Build" },
|
||||
next: { customClass: "BuildViewController" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {
|
||||
button: [
|
||||
{
|
||||
type: "action",
|
||||
label: "buildTapped:",
|
||||
destinationId: "controller",
|
||||
},
|
||||
{ type: "segue", identifier: "showBuild", destinationId: "next" },
|
||||
],
|
||||
controller: [
|
||||
{ type: "outlet", label: "buildButton", destinationId: "button" },
|
||||
],
|
||||
},
|
||||
"com.apple.ibtool.document.hierarchy": [
|
||||
{ objectID: "controller", children: [{ objectID: "button" }] },
|
||||
],
|
||||
"com.apple.ibtool.document.classes": { StoreViewController: {} },
|
||||
};
|
||||
const decoded = parseInterfaceBuilderRecords(raw);
|
||||
expect(decoded.objects).toHaveLength(3);
|
||||
expect(decoded.connections.map(({ kind }) => kind)).toEqual([
|
||||
"action",
|
||||
"segue",
|
||||
"outlet",
|
||||
]);
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: hash,
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Views/Main.storyboardc/scene.nib/objects.nib",
|
||||
archiveSha256: hash,
|
||||
documentKind: "storyboard_scene",
|
||||
raw,
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({}),
|
||||
});
|
||||
expect(result.graph.nodes.some(({ name }) => name === "buildTapped:")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.graph.nodes[0]?.evidence[0]).toMatchObject({
|
||||
artifact_path: "Views/Main.storyboardc/scene.nib/objects.nib",
|
||||
artifact_sha256: hash,
|
||||
});
|
||||
expect(result.graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "segue_to",
|
||||
resolution: "observed",
|
||||
}),
|
||||
);
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "hierarchy:Views/Main.storyboardc/scene.nib/objects.nib",
|
||||
status: "complete",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps missing external destinations as unresolved evidence", () => {
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: hash,
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Legacy.nib",
|
||||
archiveSha256: hash,
|
||||
documentKind: "nib",
|
||||
raw: {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
button: { class: "UIButton" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {
|
||||
button: [
|
||||
{ type: "action", label: "save:", destinationId: "external" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({ max_objects: 3 }),
|
||||
});
|
||||
expect(result.graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "target_action",
|
||||
resolution: "unresolved",
|
||||
reason: "endpoint_omitted_by_object_limit",
|
||||
evidence: [
|
||||
expect.objectContaining({
|
||||
kind: "interface_builder_resource",
|
||||
artifact_path: "Legacy.nib",
|
||||
artifact_sha256: hash,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const retainedNodes = new Set(result.graph.nodes.map(({ id }) => id));
|
||||
expect(
|
||||
result.graph.edges.every(
|
||||
({ from, to }) =>
|
||||
retainedNodes.has(from) && (to === null || retainedNodes.has(to)),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "graph_nodes",
|
||||
status: "partial",
|
||||
reason: "max_objects_reached",
|
||||
}),
|
||||
);
|
||||
expect(result.graph.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("reads recognized objects from a compiled NSKeyedArchiver object table", () => {
|
||||
const bytes = buildBinary({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{ $class: { UID: 2 }, title: "Build", identifier: "build-button" },
|
||||
{
|
||||
$classname: "UIButton",
|
||||
$classes: ["UIButton", "UIControl", "UIView", "NSObject"],
|
||||
},
|
||||
],
|
||||
$top: { root: { UID: 1 } },
|
||||
});
|
||||
const parsed = parseInterfaceBuilderRecords(parseBinary(bytes));
|
||||
expect(parsed.objects).toMatchObject([
|
||||
{
|
||||
id: "1",
|
||||
kind: "control",
|
||||
class_name: "UIButton",
|
||||
name: "Build",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Interface Builder parser bounds", () => {
|
||||
it("reports connections discarded by the parser's hard ceiling", () => {
|
||||
const connections = Array.from({ length: 40_001 }, (_, index) => ({
|
||||
type: "custom-connection",
|
||||
label: `action${String(index)}:`,
|
||||
}));
|
||||
const raw = {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
button: { class: "UIButton" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": { button: connections },
|
||||
};
|
||||
const parsed = parseInterfaceBuilderRecords(raw);
|
||||
expect(parsed.connections).toHaveLength(40_000);
|
||||
expect(parsed.omittedConnections).toBe(1);
|
||||
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: hash,
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Main.storyboardc/scene.nib",
|
||||
archiveSha256: hash,
|
||||
documentKind: "storyboard_scene",
|
||||
raw,
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({}),
|
||||
});
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "connections:Main.storyboardc/scene.nib",
|
||||
status: "partial",
|
||||
omitted: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyed archive decoding and graph limits", () => {
|
||||
it("preserves keyed archive UID identities, arrays, and control actions", () => {
|
||||
const parsed = parseInterfaceBuilderRecords({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{
|
||||
subviews: [{ UID: 2 }],
|
||||
$class: { UID: 5 },
|
||||
},
|
||||
{ $class: { UID: 6 } },
|
||||
{ $class: { UID: 7 } },
|
||||
{
|
||||
source: { UID: 3 },
|
||||
destination: { UID: 2 },
|
||||
label: "runAction:",
|
||||
$class: { UID: 8 },
|
||||
},
|
||||
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
|
||||
{ $classname: "UIButton", $classes: ["UIButton", "UIView"] },
|
||||
{
|
||||
$classname: "ViewController",
|
||||
$classes: ["ViewController", "NSObject"],
|
||||
},
|
||||
{
|
||||
$classname: "NSNibControlConnector",
|
||||
$classes: ["NSNibControlConnector", "NSObject"],
|
||||
},
|
||||
[{ UID: 1 }],
|
||||
],
|
||||
$top: { root: { UID: 9 } },
|
||||
});
|
||||
|
||||
expect(parsed.objects.map(({ id }) => id)).toContain("2");
|
||||
expect(parsed.hierarchy).toMatchObject([
|
||||
{
|
||||
objectID: "1",
|
||||
children: [{ objectID: "2" }],
|
||||
},
|
||||
]);
|
||||
expect(parsed.connections).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "action",
|
||||
source_id: "2",
|
||||
destination_id: "3",
|
||||
label: "runAction:",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports records omitted by the parser's hard object ceiling", () => {
|
||||
const objects = Object.fromEntries(
|
||||
Array.from({ length: 20_001 }, (_, index) => [
|
||||
`object-${index}`,
|
||||
{ class: "UIView" },
|
||||
]),
|
||||
);
|
||||
const parsed = parseInterfaceBuilderRecords({
|
||||
"com.apple.ibtool.document.objects": objects,
|
||||
});
|
||||
|
||||
expect(parsed.objects).toHaveLength(20_000);
|
||||
expect(parsed.objectCount).toBe(20_001);
|
||||
expect(parsed.omittedObjects).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps hierarchy edges out of the connection edge budget", () => {
|
||||
const relativePath = "Main.nib";
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: hash,
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath,
|
||||
archiveSha256: hash,
|
||||
documentKind: "nib",
|
||||
raw: {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
button: { class: "UIButton" },
|
||||
controller: { customClass: "MainViewController" },
|
||||
parent: { class: "UIView" },
|
||||
child: { class: "UIView" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {
|
||||
button: [
|
||||
{
|
||||
type: "action",
|
||||
label: "run:",
|
||||
destinationId: "controller",
|
||||
},
|
||||
],
|
||||
},
|
||||
"com.apple.ibtool.document.hierarchy": [
|
||||
{
|
||||
objectID: "parent",
|
||||
children: [
|
||||
{ objectID: "child", children: [{ objectID: "button" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({
|
||||
max_objects: 20,
|
||||
max_connections: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
result.graph.edges.filter(({ relation }) => relation === "target_action"),
|
||||
).toHaveLength(3);
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: `connections:${relativePath}`,
|
||||
status: "complete",
|
||||
omitted: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,880 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
nativeInvestigationGraphSchema,
|
||||
nativeInvestigationTraceLimitsSchema,
|
||||
} from "./nativeInvestigationGraph.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const recordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const objectNode = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum([
|
||||
"view_controller",
|
||||
"view",
|
||||
"control",
|
||||
"constraint",
|
||||
"layout_guide",
|
||||
"resource",
|
||||
"placeholder",
|
||||
"external_object",
|
||||
"other",
|
||||
]),
|
||||
class_name: z.string().nullable(),
|
||||
name: z.string().min(1),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
});
|
||||
|
||||
const connection = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum(["outlet", "action", "segue", "other"]),
|
||||
source_id: z.string().min(1),
|
||||
destination_id: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
});
|
||||
|
||||
/** One compiled Interface Builder document projected from ibtool output. */
|
||||
export const interfaceBuilderDocumentSchema = z.strictObject({
|
||||
relative_path: z.string().min(1),
|
||||
archive_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
document_kind: z.enum(["nib", "storyboard_scene"]),
|
||||
object_count: z.number().int().nonnegative(),
|
||||
connection_count: z.number().int().nonnegative(),
|
||||
hierarchy_complete: z.boolean(),
|
||||
});
|
||||
|
||||
/** Normalized read-only Interface Builder graph with archive provenance. */
|
||||
export const interfaceBuilderAnalysisSchema = z.strictObject({
|
||||
target_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
documents: z.array(interfaceBuilderDocumentSchema).max(64),
|
||||
graph: nativeInvestigationGraphSchema,
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
type InterfaceBuilderAnalysis = z.infer<typeof interfaceBuilderAnalysisSchema>;
|
||||
|
||||
export type InterfaceBuilderObject = z.infer<typeof objectNode>;
|
||||
export type InterfaceBuilderConnection = z.infer<typeof connection>;
|
||||
|
||||
export interface InterfaceBuilderDocumentInput {
|
||||
readonly relativePath: string;
|
||||
readonly archiveSha256: string;
|
||||
readonly documentKind: "nib" | "storyboard_scene";
|
||||
readonly raw: unknown;
|
||||
}
|
||||
|
||||
/** Parse ibtool dictionaries or compiled keyed archives into typed records. */
|
||||
export const parseInterfaceBuilderRecords = (
|
||||
value: unknown,
|
||||
): {
|
||||
readonly objects: readonly InterfaceBuilderObject[];
|
||||
readonly connections: readonly InterfaceBuilderConnection[];
|
||||
readonly hierarchy: readonly unknown[];
|
||||
readonly classes: Readonly<Record<string, unknown>>;
|
||||
readonly objectCount: number;
|
||||
readonly omittedObjects: number;
|
||||
readonly omittedConnections: number;
|
||||
} => {
|
||||
const root = record(value);
|
||||
const keyed = parseKeyedArchive(root);
|
||||
if (keyed !== null) return keyed;
|
||||
const objectsRaw = record(root["com.apple.ibtool.document.objects"]);
|
||||
const connectionsRaw = record(root["com.apple.ibtool.document.connections"]);
|
||||
const hierarchyRaw = root["com.apple.ibtool.document.hierarchy"];
|
||||
const classesRaw = record(root["com.apple.ibtool.document.classes"]);
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let omittedConnections = 0;
|
||||
const objectEntries = Object.entries(objectsRaw);
|
||||
for (const [id, raw] of objectEntries.slice(0, 20_000)) {
|
||||
const attributes = record(raw);
|
||||
const className = firstString(
|
||||
attributes["customClass"],
|
||||
attributes["class"],
|
||||
attributes["isa"],
|
||||
);
|
||||
const label = firstString(
|
||||
attributes["label"],
|
||||
attributes["title"],
|
||||
attributes["name"],
|
||||
className,
|
||||
);
|
||||
objects.push(
|
||||
objectNode.parse({
|
||||
id,
|
||||
kind: classifyObject(className),
|
||||
class_name: className,
|
||||
name: label ?? id,
|
||||
attributes: jsonSafeRecord(attributes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [sourceId, rawConnections] of Object.entries(connectionsRaw)) {
|
||||
if (!Array.isArray(rawConnections)) continue;
|
||||
for (const [index, raw] of rawConnections.entries()) {
|
||||
if (connections.length >= 40_000) {
|
||||
omittedConnections += rawConnections.length - index;
|
||||
break;
|
||||
}
|
||||
const item = record(raw);
|
||||
const type = firstString(item.type, item.connectionType) ?? "unknown";
|
||||
const destination = firstString(
|
||||
item["destination-id"],
|
||||
item.destinationId,
|
||||
);
|
||||
const label = firstString(item.label, item.selector, item.identifier);
|
||||
connections.push(
|
||||
connection.parse({
|
||||
id: `${sourceId}:connection:${index}`,
|
||||
kind: classifyConnection(type),
|
||||
source_id: sourceId,
|
||||
destination_id: destination,
|
||||
label,
|
||||
attributes: jsonSafeRecord(item),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy: Array.isArray(hierarchyRaw) ? hierarchyRaw.slice(0, 20_000) : [],
|
||||
classes: classesRaw,
|
||||
objectCount: objectEntries.length,
|
||||
omittedObjects: Math.max(0, objectEntries.length - 20_000),
|
||||
omittedConnections,
|
||||
};
|
||||
};
|
||||
|
||||
/** Project class and connection objects from an NSKeyedArchiver object table. */
|
||||
const parseKeyedArchive = (
|
||||
root: Record<string, unknown>,
|
||||
): ReturnType<typeof parseInterfaceBuilderRecords> | null => {
|
||||
if (root.$archiver !== "NSKeyedArchiver" || !Array.isArray(root.$objects))
|
||||
return null;
|
||||
const objectTable = root.$objects;
|
||||
const resolve = (value: unknown, depth = 0): unknown => {
|
||||
if (depth > 16) return null;
|
||||
const uid = record(value).UID;
|
||||
if (typeof uid === "number") {
|
||||
const target = objectTable[uid];
|
||||
const resolved = target === undefined ? null : resolve(target, depth + 1);
|
||||
return typeof resolved === "object" &&
|
||||
resolved !== null &&
|
||||
!Array.isArray(resolved)
|
||||
? { ...resolved, archiveUID: uid }
|
||||
: resolved;
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => resolve(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "$class") {
|
||||
const classObject = record(
|
||||
typeof record(child).UID === "number"
|
||||
? objectTable[record(child).UID as number]
|
||||
: child,
|
||||
);
|
||||
output.className = firstString(classObject.$classname);
|
||||
} else output[key] = resolve(child, depth + 1);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const toHierarchy = (value: unknown, depth = 0): unknown[] => {
|
||||
if (depth > 32) return [];
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item) => toHierarchy(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return [];
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
const children = Object.entries(item)
|
||||
.filter(([key]) =>
|
||||
/(?:subviews|contentview|childviewcontrollers|children|views)$/iu.test(
|
||||
key,
|
||||
),
|
||||
)
|
||||
.flatMap(([, child]) => toHierarchy(child, depth + 1));
|
||||
return objectId === null ? children : [{ objectID: objectId, children }];
|
||||
};
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let objectCount = 0;
|
||||
let omittedConnections = 0;
|
||||
for (const [index, raw] of objectTable.entries()) {
|
||||
if (index === 0) continue;
|
||||
const resolved = record(resolve(raw));
|
||||
const className = firstString(resolved.className);
|
||||
if (className === null) continue;
|
||||
objectCount += 1;
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(resolved).filter(([key]) => key !== "className"),
|
||||
);
|
||||
const authoredId = firstString(fields.objectID, fields["object-id"]);
|
||||
const objectId =
|
||||
authoredId ??
|
||||
(typeof fields.archiveUID === "number"
|
||||
? String(fields.archiveUID)
|
||||
: String(index));
|
||||
const kind = classifyObject(className);
|
||||
if (objects.length < 20_000)
|
||||
objects.push(
|
||||
objectNode.parse({
|
||||
id: objectId,
|
||||
kind,
|
||||
class_name: className,
|
||||
name:
|
||||
firstString(
|
||||
fields.label,
|
||||
fields.title,
|
||||
fields.identifier,
|
||||
fields.accessibilityLabel,
|
||||
className,
|
||||
) ?? `object ${String(index)}`,
|
||||
attributes: jsonSafeRecord(fields),
|
||||
}),
|
||||
);
|
||||
if (
|
||||
/(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test(
|
||||
className,
|
||||
)
|
||||
) {
|
||||
const controlAction = /ControlConnector/iu.test(className);
|
||||
const source = firstObjectReference(
|
||||
controlAction ? fields.destination : fields.source,
|
||||
controlAction ? fields.to : fields.from,
|
||||
controlAction ? fields.target : fields.owner,
|
||||
);
|
||||
if (source === null) continue;
|
||||
const kind = controlAction ? "action" : classifyConnection(className);
|
||||
const parsedConnection = connection.parse({
|
||||
id: `archive:${objectId}`,
|
||||
kind,
|
||||
source_id: source,
|
||||
destination_id: firstObjectReference(
|
||||
controlAction ? fields.source : fields.destination,
|
||||
controlAction ? fields.from : fields.to,
|
||||
controlAction ? fields.owner : fields.target,
|
||||
),
|
||||
label: firstString(
|
||||
fields.label,
|
||||
fields.selector,
|
||||
fields.identifier,
|
||||
fields.action,
|
||||
),
|
||||
attributes: jsonSafeRecord(fields),
|
||||
});
|
||||
if (connections.length < 40_000) connections.push(parsedConnection);
|
||||
else omittedConnections += 1;
|
||||
}
|
||||
}
|
||||
const top = record(root.$top);
|
||||
const topObject = resolve(top.root ?? top.UITopLevelObjectsKey);
|
||||
const hierarchy = toHierarchy(topObject);
|
||||
const classes = Object.fromEntries(
|
||||
objectTable.flatMap((raw) => {
|
||||
const item = record(raw);
|
||||
const name = firstString(item.$classname);
|
||||
return name === null
|
||||
? []
|
||||
: [
|
||||
[
|
||||
name,
|
||||
{
|
||||
superclasses: Array.isArray(item.$classes) ? item.$classes : [],
|
||||
},
|
||||
],
|
||||
];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy,
|
||||
classes,
|
||||
objectCount,
|
||||
omittedObjects: Math.max(0, objectCount - 20_000),
|
||||
omittedConnections,
|
||||
};
|
||||
};
|
||||
|
||||
/** Build the graph returned by the static Interface Builder archive decoder. */
|
||||
export const buildInterfaceBuilderAnalysis = (input: {
|
||||
readonly targetSha256: string;
|
||||
readonly toolVersion: string;
|
||||
readonly documents: readonly InterfaceBuilderDocumentInput[];
|
||||
readonly limits: z.output<typeof interfaceBuilderLimitsSchema>;
|
||||
}) => {
|
||||
const nodes = new Map<
|
||||
string,
|
||||
z.infer<
|
||||
typeof import("./nativeInvestigationGraph.js").nativeInvestigationNodeSchema
|
||||
>
|
||||
>();
|
||||
const edges: z.infer<
|
||||
typeof import("./nativeInvestigationGraph.js").nativeInvestigationEdgeSchema
|
||||
>[] = [];
|
||||
const coverage = [] as Array<{
|
||||
facet: string;
|
||||
status: "complete" | "partial" | "unsupported" | "not_requested";
|
||||
reason: string | null;
|
||||
examined: number;
|
||||
omitted: number;
|
||||
}>;
|
||||
const summaries: Array<z.infer<typeof interfaceBuilderDocumentSchema>> = [];
|
||||
let truncated = false;
|
||||
let omittedObjects = 0;
|
||||
let omittedConnections = 0;
|
||||
let omittedGraphNodes = 0;
|
||||
for (const document of input.documents.slice(0, input.limits.max_documents)) {
|
||||
const parsed = parseInterfaceBuilderRecords(document.raw);
|
||||
const prefix = `ib:${document.relativePath}:`;
|
||||
const rootId = `${prefix}root`;
|
||||
const objectIds = new Set(parsed.objects.map(({ id }) => id));
|
||||
const evidenceFor = (description: string) => [
|
||||
{
|
||||
kind: "interface_builder_resource" as const,
|
||||
description: `${document.relativePath}: ${description}`,
|
||||
location: { address: null, file_offset: null },
|
||||
artifact_path: document.relativePath,
|
||||
artifact_sha256: document.archiveSha256,
|
||||
},
|
||||
];
|
||||
const addNode = (
|
||||
node: z.infer<
|
||||
typeof import("./nativeInvestigationGraph.js").nativeInvestigationNodeSchema
|
||||
>,
|
||||
): boolean => {
|
||||
if (nodes.has(node.id)) return true;
|
||||
if (nodes.size >= input.limits.max_objects) {
|
||||
truncated = true;
|
||||
omittedGraphNodes += 1;
|
||||
return false;
|
||||
}
|
||||
nodes.set(node.id, node);
|
||||
return true;
|
||||
};
|
||||
const rootNode = {
|
||||
id: rootId,
|
||||
kind:
|
||||
document.documentKind === "nib"
|
||||
? ("scene" as const)
|
||||
: ("storyboard" as const),
|
||||
name: document.relativePath,
|
||||
location: null,
|
||||
attributes: { document_kind: document.documentKind },
|
||||
evidence: evidenceFor("compiled document"),
|
||||
};
|
||||
addNode(rootNode);
|
||||
let documentObjectCount = 0;
|
||||
for (const object of parsed.objects.slice(0, input.limits.max_objects)) {
|
||||
const id = `${prefix}object:${object.id}`;
|
||||
const kind = object.kind === "other" ? "unknown" : object.kind;
|
||||
if (
|
||||
!addNode({
|
||||
id,
|
||||
kind,
|
||||
name: object.name,
|
||||
location: null,
|
||||
attributes: {
|
||||
...object.attributes,
|
||||
interface_builder_object_id: object.id,
|
||||
class_name: object.class_name,
|
||||
},
|
||||
evidence: evidenceFor(`object ${object.id}`),
|
||||
})
|
||||
) {
|
||||
break;
|
||||
}
|
||||
documentObjectCount += 1;
|
||||
}
|
||||
let connectionEdgeCount = 0;
|
||||
let hierarchyEdgeCount = 0;
|
||||
let documentOmittedConnections = 0;
|
||||
let documentOmittedHierarchyEdges = 0;
|
||||
const objectNodeId = (objectId: string): string =>
|
||||
`${prefix}object:${objectId}`;
|
||||
const addEdge = (
|
||||
edge: (typeof edges)[number],
|
||||
isHierarchy: boolean,
|
||||
): void => {
|
||||
if (
|
||||
isHierarchy
|
||||
? hierarchyEdgeCount >= input.limits.max_objects
|
||||
: connectionEdgeCount >= input.limits.max_connections * 3
|
||||
) {
|
||||
truncated = true;
|
||||
if (isHierarchy) {
|
||||
documentOmittedHierarchyEdges += 1;
|
||||
} else {
|
||||
omittedConnections += 1;
|
||||
documentOmittedConnections += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
edges.push(edge);
|
||||
if (isHierarchy) hierarchyEdgeCount += 1;
|
||||
else connectionEdgeCount += 1;
|
||||
};
|
||||
const connect = (
|
||||
id: string,
|
||||
from: string,
|
||||
to: string | null,
|
||||
relation: "contains" | "outlet_to" | "target_action" | "segue_to",
|
||||
description: string,
|
||||
reason?: string,
|
||||
) => {
|
||||
if (!nodes.has(from)) {
|
||||
truncated = true;
|
||||
if (relation === "contains" && id.includes(":hierarchy:")) {
|
||||
documentOmittedHierarchyEdges += 1;
|
||||
} else {
|
||||
omittedConnections += 1;
|
||||
documentOmittedConnections += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const evidence = evidenceFor(description);
|
||||
const unresolvedEndpoint = to !== null && !nodes.has(to);
|
||||
if (unresolvedEndpoint) truncated = true;
|
||||
const edgeTo = unresolvedEndpoint ? null : to;
|
||||
const edgeReason = unresolvedEndpoint
|
||||
? "endpoint_omitted_by_object_limit"
|
||||
: (reason ?? "destination_unresolved");
|
||||
addEdge(
|
||||
edgeTo === null
|
||||
? {
|
||||
id,
|
||||
from,
|
||||
to: null,
|
||||
relation,
|
||||
resolution: "unresolved",
|
||||
reason: edgeReason,
|
||||
evidence,
|
||||
limitations: [],
|
||||
}
|
||||
: {
|
||||
id,
|
||||
from,
|
||||
to: edgeTo,
|
||||
relation,
|
||||
resolution: "observed",
|
||||
evidence,
|
||||
limitations: [],
|
||||
},
|
||||
relation === "contains" && id.includes(":hierarchy:"),
|
||||
);
|
||||
};
|
||||
let hierarchyCount = 0;
|
||||
let hierarchyTruncated = false;
|
||||
const visitHierarchy = (value: unknown, parentId: string): void => {
|
||||
if (hierarchyCount >= input.limits.max_objects) {
|
||||
truncated = true;
|
||||
hierarchyTruncated = true;
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) visitHierarchy(child, parentId);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return;
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
let nextParent = parentId;
|
||||
if (objectId !== null) {
|
||||
const known = objectIds.has(objectId);
|
||||
const childId = known
|
||||
? objectNodeId(objectId)
|
||||
: `${prefix}unknown:${objectId}`;
|
||||
if (
|
||||
!known &&
|
||||
!addNode({
|
||||
id: childId,
|
||||
kind: "unknown",
|
||||
name: firstString(item.label, item.name) ?? objectId,
|
||||
location: null,
|
||||
attributes: { interface_builder_object_id: objectId },
|
||||
evidence: evidenceFor(`hierarchy item ${objectId}`),
|
||||
})
|
||||
)
|
||||
hierarchyTruncated = true;
|
||||
connect(
|
||||
`${prefix}hierarchy:${parentId}:${childId}`,
|
||||
parentId,
|
||||
childId,
|
||||
"contains",
|
||||
`hierarchy ${objectId}`,
|
||||
);
|
||||
nextParent = childId;
|
||||
hierarchyCount += 1;
|
||||
}
|
||||
visitHierarchy(item.children, nextParent);
|
||||
};
|
||||
for (const hierarchy of parsed.hierarchy) visitHierarchy(hierarchy, rootId);
|
||||
for (const [index, item] of parsed.connections
|
||||
.slice(0, input.limits.max_connections)
|
||||
.entries()) {
|
||||
const sourceId = objectNodeId(item.source_id);
|
||||
if (!nodes.has(sourceId))
|
||||
addNode({
|
||||
id: sourceId,
|
||||
kind: "unknown",
|
||||
name: item.source_id,
|
||||
location: null,
|
||||
attributes: { interface_builder_object_id: item.source_id },
|
||||
evidence: evidenceFor(`connection source ${item.source_id}`),
|
||||
});
|
||||
const destinationId =
|
||||
item.destination_id === null ? null : objectNodeId(item.destination_id);
|
||||
if (item.kind === "action") {
|
||||
const actionId = `${prefix}action:${item.id}`;
|
||||
const selectorId =
|
||||
item.label === null
|
||||
? null
|
||||
: `${prefix}selector:${item.destination_id ?? "unknown"}:${item.label}`;
|
||||
addNode({
|
||||
id: actionId,
|
||||
kind: "action",
|
||||
name: item.label ?? "unlabeled action",
|
||||
location: null,
|
||||
attributes: {
|
||||
selector: item.label,
|
||||
destination_id: item.destination_id,
|
||||
...item.attributes,
|
||||
},
|
||||
evidence: evidenceFor(`action ${item.id}`),
|
||||
});
|
||||
connect(
|
||||
`${actionId}:source`,
|
||||
sourceId,
|
||||
actionId,
|
||||
"target_action",
|
||||
`action source ${item.source_id}`,
|
||||
);
|
||||
if (selectorId !== null) {
|
||||
addNode({
|
||||
id: selectorId,
|
||||
kind: "objc_selector",
|
||||
name: item.label ?? "unknown selector",
|
||||
location: null,
|
||||
attributes: { destination_id: item.destination_id },
|
||||
evidence: evidenceFor(`selector ${item.label}`),
|
||||
});
|
||||
connect(
|
||||
`${actionId}:selector`,
|
||||
actionId,
|
||||
selectorId,
|
||||
"target_action",
|
||||
`selector ${item.label}`,
|
||||
);
|
||||
}
|
||||
if (destinationId === null || !nodes.has(destinationId))
|
||||
connect(
|
||||
`${actionId}:destination`,
|
||||
actionId,
|
||||
null,
|
||||
"target_action",
|
||||
`action destination ${item.destination_id ?? "missing"}`,
|
||||
"destination_object_missing_or_external",
|
||||
);
|
||||
else
|
||||
connect(
|
||||
`${actionId}:destination`,
|
||||
actionId,
|
||||
destinationId,
|
||||
"target_action",
|
||||
`action destination ${item.destination_id}`,
|
||||
);
|
||||
} else if (item.kind === "outlet") {
|
||||
const outletId = `${prefix}outlet:${item.id}`;
|
||||
addNode({
|
||||
id: outletId,
|
||||
kind: "outlet",
|
||||
name: item.label ?? item.id,
|
||||
location: null,
|
||||
attributes: item.attributes,
|
||||
evidence: evidenceFor(`outlet ${item.id}`),
|
||||
});
|
||||
connect(
|
||||
`${outletId}:source`,
|
||||
sourceId,
|
||||
outletId,
|
||||
"contains",
|
||||
`outlet source ${item.source_id}`,
|
||||
);
|
||||
if (destinationId === null || !nodes.has(destinationId))
|
||||
connect(
|
||||
`${outletId}:destination`,
|
||||
outletId,
|
||||
null,
|
||||
"outlet_to",
|
||||
`outlet ${item.label ?? item.id}`,
|
||||
"destination_object_missing_or_external",
|
||||
);
|
||||
else
|
||||
connect(
|
||||
`${outletId}:destination`,
|
||||
outletId,
|
||||
destinationId,
|
||||
"outlet_to",
|
||||
`outlet ${item.label ?? item.id}`,
|
||||
);
|
||||
} else if (item.kind === "segue") {
|
||||
if (destinationId === null || !nodes.has(destinationId))
|
||||
connect(
|
||||
`${prefix}segue:${index}`,
|
||||
sourceId,
|
||||
null,
|
||||
"segue_to",
|
||||
`segue ${item.label ?? item.id}`,
|
||||
"destination_scene_missing",
|
||||
);
|
||||
else
|
||||
connect(
|
||||
`${prefix}segue:${index}`,
|
||||
sourceId,
|
||||
destinationId,
|
||||
"segue_to",
|
||||
`segue ${item.label ?? item.id}`,
|
||||
);
|
||||
} else {
|
||||
connect(
|
||||
`${prefix}unknown-connection:${index}`,
|
||||
sourceId,
|
||||
null,
|
||||
"contains",
|
||||
`connection type ${item.kind}`,
|
||||
"unsupported_connection_type",
|
||||
);
|
||||
}
|
||||
}
|
||||
const objectsTruncated =
|
||||
parsed.omittedObjects > 0 || parsed.objects.length > documentObjectCount;
|
||||
const parserOmittedConnections = parsed.omittedConnections;
|
||||
const connectionsTruncated =
|
||||
parserOmittedConnections > 0 ||
|
||||
parsed.connections.length > input.limits.max_connections;
|
||||
omittedObjects +=
|
||||
parsed.omittedObjects +
|
||||
Math.max(0, parsed.objects.length - documentObjectCount);
|
||||
omittedConnections +=
|
||||
Math.max(0, parsed.connections.length - input.limits.max_connections) +
|
||||
parserOmittedConnections;
|
||||
summaries.push({
|
||||
relative_path: document.relativePath,
|
||||
archive_sha256: document.archiveSha256,
|
||||
document_kind: document.documentKind,
|
||||
object_count: parsed.objectCount,
|
||||
connection_count: parsed.connections.length + parserOmittedConnections,
|
||||
hierarchy_complete: parsed.hierarchy.length > 0 && !truncated,
|
||||
});
|
||||
coverage.push(
|
||||
{
|
||||
facet: `objects:${document.relativePath}`,
|
||||
status: objectsTruncated ? "partial" : "complete",
|
||||
reason: objectsTruncated ? "object_limit_reached" : null,
|
||||
examined: documentObjectCount,
|
||||
omitted:
|
||||
parsed.omittedObjects +
|
||||
Math.max(0, parsed.objects.length - documentObjectCount),
|
||||
},
|
||||
{
|
||||
facet: `connections:${document.relativePath}`,
|
||||
status:
|
||||
connectionsTruncated || documentOmittedConnections > 0
|
||||
? "partial"
|
||||
: "complete",
|
||||
reason:
|
||||
connectionsTruncated || documentOmittedConnections > 0
|
||||
? "connection_limit_reached"
|
||||
: null,
|
||||
examined: Math.min(
|
||||
parsed.connections.length,
|
||||
input.limits.max_connections,
|
||||
),
|
||||
omitted:
|
||||
Math.max(
|
||||
0,
|
||||
parsed.connections.length - input.limits.max_connections,
|
||||
) +
|
||||
documentOmittedConnections +
|
||||
parserOmittedConnections,
|
||||
},
|
||||
{
|
||||
facet: `hierarchy:${document.relativePath}`,
|
||||
status:
|
||||
parsed.hierarchy.length === 0
|
||||
? "unsupported"
|
||||
: hierarchyTruncated || documentOmittedHierarchyEdges > 0
|
||||
? "partial"
|
||||
: "complete",
|
||||
reason:
|
||||
parsed.hierarchy.length === 0
|
||||
? "archive_hierarchy_not_decoded"
|
||||
: hierarchyTruncated || documentOmittedHierarchyEdges > 0
|
||||
? "graph_limit_reached"
|
||||
: null,
|
||||
examined: hierarchyCount,
|
||||
omitted: documentOmittedHierarchyEdges,
|
||||
},
|
||||
{
|
||||
facet: `classes:${document.relativePath}`,
|
||||
status:
|
||||
Object.keys(parsed.classes).length > 0 ? "complete" : "unsupported",
|
||||
reason:
|
||||
Object.keys(parsed.classes).length > 0
|
||||
? null
|
||||
: "archive_class_table_not_decoded",
|
||||
examined: Object.keys(parsed.classes).length,
|
||||
omitted: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (input.documents.length > input.limits.max_documents) {
|
||||
truncated = true;
|
||||
coverage.push({
|
||||
facet: "documents",
|
||||
status: "partial",
|
||||
reason: "document_limit_reached",
|
||||
examined: input.limits.max_documents,
|
||||
omitted: input.documents.length - input.limits.max_documents,
|
||||
});
|
||||
}
|
||||
if (omittedGraphNodes > 0)
|
||||
coverage.push({
|
||||
facet: "graph_nodes",
|
||||
status: "partial",
|
||||
reason: "max_objects_reached",
|
||||
examined: nodes.size,
|
||||
omitted: omittedGraphNodes,
|
||||
});
|
||||
return interfaceBuilderAnalysisSchema.parse({
|
||||
target_sha256: input.targetSha256,
|
||||
documents: summaries,
|
||||
graph: {
|
||||
target_sha256: input.targetSha256,
|
||||
provider: {
|
||||
id: "rea-artifact-graph",
|
||||
version: "1",
|
||||
tool_version: input.toolVersion,
|
||||
},
|
||||
nodes: [...nodes.values()],
|
||||
edges,
|
||||
coverage,
|
||||
truncated: truncated || omittedObjects > 0 || omittedConnections > 0,
|
||||
},
|
||||
limitations: [
|
||||
"The graph reflects recognized Interface Builder archive fields and connections; omitted private or unrecognized archive fields remain unknown.",
|
||||
"A target-action selector is an Interface Builder observation and does not prove that the binary contains a matching implementation.",
|
||||
"Decoded constraints, runtime visibility, dynamic UI creation, and pixel geometry are not inferred from these archives.",
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
recordSchema.safeParse(value).data ?? {};
|
||||
|
||||
const firstString = (...values: unknown[]): string | null => {
|
||||
for (const value of values)
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstObjectReference = (...values: unknown[]): string | null => {
|
||||
for (const value of values) {
|
||||
const direct = firstString(value);
|
||||
if (direct !== null) return direct;
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
item.identifier,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
if (objectId !== null) return objectId;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const classifyObject = (
|
||||
className: string | null,
|
||||
): InterfaceBuilderObject["kind"] => {
|
||||
const value = className?.toLowerCase() ?? "";
|
||||
if (value.includes("constraint")) return "constraint";
|
||||
if (value.includes("layoutguide")) return "layout_guide";
|
||||
if (value.includes("placeholder") || value.includes("firstresponder"))
|
||||
return "placeholder";
|
||||
if (
|
||||
value.includes("image") ||
|
||||
value.includes("color") ||
|
||||
value.includes("font")
|
||||
)
|
||||
return "resource";
|
||||
if (value.includes("external")) return "external_object";
|
||||
if (value.includes("viewcontroller") || value.includes("windowcontroller"))
|
||||
return "view_controller";
|
||||
if (
|
||||
value.includes("button") ||
|
||||
value.includes("control") ||
|
||||
value.includes("textfield") ||
|
||||
value.includes("slider") ||
|
||||
value.includes("menuitem")
|
||||
)
|
||||
return "control";
|
||||
if (
|
||||
value.includes("view") ||
|
||||
value.includes("window") ||
|
||||
value.includes("cell")
|
||||
)
|
||||
return "view";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const classifyConnection = (
|
||||
type: string,
|
||||
): InterfaceBuilderConnection["kind"] => {
|
||||
const value = type.toLowerCase();
|
||||
if (value.includes("outlet")) return "outlet";
|
||||
if (value.includes("action")) return "action";
|
||||
if (value.includes("segue")) return "segue";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const jsonSafeRecord = (value: Record<string, unknown>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
try {
|
||||
const serialized = JSON.stringify(item);
|
||||
if (serialized === undefined) return [];
|
||||
const parsed = jsonValueSchema.safeParse(JSON.parse(serialized));
|
||||
return parsed.success ? [[key, parsed.data]] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/** Hard limits shared by Interface Builder archive aggregation and tracing. */
|
||||
export const interfaceBuilderLimitsSchema = z.strictObject({
|
||||
max_documents: z.number().int().min(1).max(64).default(64),
|
||||
max_objects: z.number().int().min(1).max(20_000).default(20_000),
|
||||
max_connections: z.number().int().min(1).max(40_000).default(40_000),
|
||||
trace: nativeInvestigationTraceLimitsSchema.default({
|
||||
max_depth: 8,
|
||||
max_nodes: 250,
|
||||
max_edges: 500,
|
||||
}),
|
||||
});
|
||||
@@ -52,6 +52,7 @@ const jumpTableDataSourceSchema = z
|
||||
provenance: z.enum([
|
||||
"ghidra-decompiler-load-table",
|
||||
"ghidra-dispatch-block-data-reference",
|
||||
"ghidra-aarch64-byte-table",
|
||||
]),
|
||||
entry_size_bytes: z.number().int().min(1).nullable(),
|
||||
entry_count: z.number().int().min(0).nullable(),
|
||||
@@ -64,7 +65,6 @@ const jumpTableMappingSchema = z
|
||||
.object({
|
||||
case_value: z.number().int().nullable(),
|
||||
target_address: z.string().min(1),
|
||||
data_addresses: z.array(z.string().min(1)),
|
||||
confidence: inferenceConfidenceSchema,
|
||||
evidence: z.array(inferenceEvidenceSchema).min(1),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
nativeInvestigationGraphSchema,
|
||||
joinInterfaceBuilderDispatch,
|
||||
traceNativeInvestigationGraph,
|
||||
} from "./nativeInvestigationGraph.js";
|
||||
import {
|
||||
buildInterfaceBuilderAnalysis,
|
||||
interfaceBuilderLimitsSchema,
|
||||
} from "./interfaceBuilderGraph.js";
|
||||
|
||||
const evidence = [
|
||||
{
|
||||
kind: "reference" as const,
|
||||
description: "fixture edge",
|
||||
location: { address: "0x1000", file_offset: 0 },
|
||||
artifact_path: null,
|
||||
artifact_sha256: null,
|
||||
},
|
||||
];
|
||||
const graph = nativeInvestigationGraphSchema.parse({
|
||||
target_sha256: "a".repeat(64),
|
||||
provider: { id: "fixture", version: "1", tool_version: "test" },
|
||||
nodes: [
|
||||
{
|
||||
id: "a",
|
||||
kind: "control",
|
||||
name: "button",
|
||||
location: null,
|
||||
attributes: {},
|
||||
evidence,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
kind: "function",
|
||||
name: "handler",
|
||||
location: null,
|
||||
attributes: {},
|
||||
evidence,
|
||||
},
|
||||
{
|
||||
id: "c",
|
||||
kind: "state_value",
|
||||
name: "balance",
|
||||
location: null,
|
||||
attributes: {},
|
||||
evidence,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "1",
|
||||
from: "a",
|
||||
to: "b",
|
||||
relation: "target_action",
|
||||
resolution: "observed",
|
||||
evidence,
|
||||
limitations: [],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
from: "b",
|
||||
to: "c",
|
||||
relation: "writes",
|
||||
resolution: "inferred",
|
||||
evidence,
|
||||
limitations: ["static slice"],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
from: "b",
|
||||
to: null,
|
||||
relation: "swift_witness_dispatch",
|
||||
resolution: "unresolved",
|
||||
reason: "witness table unavailable",
|
||||
evidence,
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
coverage: [],
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
describe("native investigation graph traces", () => {
|
||||
it("retains observed and inferred route edges plus unresolved dispatch", () => {
|
||||
const trace = traceNativeInvestigationGraph(graph, { start: "a" });
|
||||
expect(trace.nodes.map(({ id }) => id)).toEqual(["a", "b", "c"]);
|
||||
expect(trace.edges.map(({ resolution }) => resolution)).toEqual([
|
||||
"observed",
|
||||
"inferred",
|
||||
]);
|
||||
expect(trace.unresolved).toHaveLength(1);
|
||||
expect(trace.unresolved[0]).toMatchObject({
|
||||
reason: "witness table unavailable",
|
||||
});
|
||||
const fromHandler = traceNativeInvestigationGraph(graph, { start: "b" });
|
||||
expect(fromHandler.unresolved[0]).toMatchObject({
|
||||
relation: "swift_witness_dispatch",
|
||||
reason: "witness table unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("stops at explicit node and edge bounds", () => {
|
||||
const trace = traceNativeInvestigationGraph(graph, {
|
||||
start: "a",
|
||||
limits: { max_nodes: 2, max_edges: 1 },
|
||||
});
|
||||
expect(trace.truncated).toBe(true);
|
||||
expect(trace.reason).toBe("max_edges_reached");
|
||||
expect(trace.nodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not retain a resolved edge beyond the depth boundary", () => {
|
||||
const trace = traceNativeInvestigationGraph(graph, {
|
||||
start: "a",
|
||||
limits: { max_depth: 0 },
|
||||
});
|
||||
|
||||
expect(trace.nodes.map(({ id }) => id)).toEqual(["a"]);
|
||||
expect(trace.edges).toEqual([]);
|
||||
expect(trace.truncated).toBe(true);
|
||||
expect(trace.reason).toBe("max_depth_reached");
|
||||
});
|
||||
|
||||
it("reports an unknown seed instead of treating it as an empty answer", () => {
|
||||
expect(
|
||||
traceNativeInvestigationGraph(graph, { start: "missing" }),
|
||||
).toMatchObject({
|
||||
reason: "start_node_missing",
|
||||
nodes: [],
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Interface Builder dispatch joins", () => {
|
||||
it("joins a UI action to the target controller class, not the source control", () => {
|
||||
const ui = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: "b".repeat(64),
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Main.storyboardc/scene.nib/objects.nib",
|
||||
archiveSha256: "c".repeat(64),
|
||||
documentKind: "storyboard_scene",
|
||||
raw: {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
controller: { customClass: "BuildViewController" },
|
||||
button: { class: "UIButton", title: "Build" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {
|
||||
button: [
|
||||
{
|
||||
type: "action",
|
||||
label: "buildTapped:",
|
||||
destinationId: "controller",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({}),
|
||||
});
|
||||
const joined = joinInterfaceBuilderDispatch(ui.graph, {
|
||||
objc_classes: [],
|
||||
objc_protocols: [],
|
||||
swift_decls: [],
|
||||
objc_ivars: [],
|
||||
objc_dispatch_implementations: [
|
||||
{
|
||||
class_name: "BuildViewController",
|
||||
selector: "buildTapped:",
|
||||
method_type: "instance",
|
||||
implementation_address: "0x1000",
|
||||
location: { address: "0x1000", file_offset: 0 },
|
||||
decode: { status: "decoded", reason: null },
|
||||
evidence,
|
||||
},
|
||||
],
|
||||
swift_conformances: [],
|
||||
swift_dispatch_slots: [],
|
||||
swift_symbols: [],
|
||||
relative_pointers: [],
|
||||
coverage: [],
|
||||
db_save_result: null,
|
||||
});
|
||||
expect(joined.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "objc_dispatch",
|
||||
resolution: "inferred",
|
||||
to: "native:function:0x1000",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("chooses an instance method and matches category-qualified owner names", () => {
|
||||
const ui = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: "b".repeat(64),
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Main.nib",
|
||||
archiveSha256: "c".repeat(64),
|
||||
documentKind: "nib",
|
||||
raw: {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
controller: { customClass: "BuildViewController" },
|
||||
button: { class: "UIButton" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {
|
||||
button: [
|
||||
{
|
||||
type: "action",
|
||||
label: "buildTapped:",
|
||||
destinationId: "controller",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({}),
|
||||
});
|
||||
const metadata = {
|
||||
objc_classes: [],
|
||||
objc_protocols: [],
|
||||
swift_decls: [],
|
||||
objc_ivars: [],
|
||||
objc_dispatch_implementations: [
|
||||
{
|
||||
class_name: "BuildViewController",
|
||||
selector: "buildTapped:",
|
||||
method_type: "class" as const,
|
||||
implementation_address: "0x9999",
|
||||
location: { address: "0x9999", file_offset: 0 },
|
||||
decode: { status: "decoded" as const, reason: null },
|
||||
evidence,
|
||||
},
|
||||
{
|
||||
class_name: "BuildViewController(BuilderActions)",
|
||||
selector: "buildTapped:",
|
||||
method_type: "instance" as const,
|
||||
implementation_address: "0x1000",
|
||||
location: { address: "0x1000", file_offset: 0 },
|
||||
decode: { status: "decoded" as const, reason: null },
|
||||
evidence,
|
||||
},
|
||||
],
|
||||
swift_conformances: [],
|
||||
swift_dispatch_slots: [],
|
||||
swift_symbols: [],
|
||||
relative_pointers: [],
|
||||
coverage: [],
|
||||
db_save_result: null,
|
||||
};
|
||||
|
||||
const joined = joinInterfaceBuilderDispatch(ui.graph, metadata);
|
||||
expect(joined.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "objc_dispatch",
|
||||
resolution: "inferred",
|
||||
to: "native:function:0x1000",
|
||||
}),
|
||||
);
|
||||
expect(joined.edges).not.toContainEqual(
|
||||
expect.objectContaining({ to: "native:function:0x9999" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeholder dispatch joins", () => {
|
||||
it("keeps a unique selector match inferred when the receiver is only a placeholder", () => {
|
||||
const ui = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: "b".repeat(64),
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath: "Main.storyboardc/scene.nib/objects.nib",
|
||||
archiveSha256: "c".repeat(64),
|
||||
documentKind: "storyboard_scene",
|
||||
raw: {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
receiver: { class: "NSStoryboardPlaceholder" },
|
||||
button: { class: "UIButton", title: "Build" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {
|
||||
button: [
|
||||
{
|
||||
type: "action",
|
||||
label: "buildTapped:",
|
||||
destinationId: "receiver",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({}),
|
||||
});
|
||||
const joined = joinInterfaceBuilderDispatch(ui.graph, {
|
||||
objc_classes: [],
|
||||
objc_protocols: [],
|
||||
swift_decls: [],
|
||||
objc_ivars: [],
|
||||
objc_dispatch_implementations: [
|
||||
{
|
||||
class_name: "BuildViewController",
|
||||
selector: "buildTapped:",
|
||||
method_type: "instance",
|
||||
implementation_address: "0x1000",
|
||||
location: { address: "0x1000", file_offset: null },
|
||||
decode: { status: "partial", reason: "symbol_name_only" },
|
||||
evidence,
|
||||
},
|
||||
],
|
||||
swift_conformances: [],
|
||||
swift_dispatch_slots: [],
|
||||
swift_symbols: [],
|
||||
relative_pointers: [],
|
||||
coverage: [],
|
||||
db_save_result: null,
|
||||
});
|
||||
|
||||
expect(joined.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "objc_dispatch",
|
||||
resolution: "inferred",
|
||||
to: "native:function:0x1000",
|
||||
limitations: [
|
||||
expect.stringContaining("receiver is an unresolved placeholder"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(joined.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "ui_to_objc_dispatch",
|
||||
status: "partial",
|
||||
reason: "some_action_receivers_remain_placeholders",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
nativeMetadataEvidenceSchema,
|
||||
nativeMetadataLocationSchema,
|
||||
type ObjcSwiftMetadata,
|
||||
} from "./objcSwiftMetadata.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
/** Entity kinds shared by decoded Interface Builder archives and native code. */
|
||||
export const nativeInvestigationNodeKindSchema = z.enum([
|
||||
"storyboard",
|
||||
"scene",
|
||||
"view_controller",
|
||||
"view",
|
||||
"control",
|
||||
"constraint",
|
||||
"layout_guide",
|
||||
"resource",
|
||||
"placeholder",
|
||||
"external_object",
|
||||
"outlet",
|
||||
"action",
|
||||
"segue",
|
||||
"objc_class",
|
||||
"objc_selector",
|
||||
"swift_declaration",
|
||||
"swift_requirement",
|
||||
"dispatch_table",
|
||||
"dispatch_slot",
|
||||
"thunk",
|
||||
"function",
|
||||
"state_value",
|
||||
"global_value",
|
||||
"field",
|
||||
"branch_condition",
|
||||
"sink",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
type NativeInvestigationNodeKind = z.infer<
|
||||
typeof nativeInvestigationNodeKindSchema
|
||||
>;
|
||||
|
||||
/** One UI, dispatch, or value entity with optional decoded source location. */
|
||||
export const nativeInvestigationNodeSchema = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: nativeInvestigationNodeKindSchema,
|
||||
name: z.string().min(1),
|
||||
location: nativeMetadataLocationSchema.nullable(),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
evidence: z.array(nativeMetadataEvidenceSchema),
|
||||
});
|
||||
type NativeInvestigationNode = z.infer<typeof nativeInvestigationNodeSchema>;
|
||||
|
||||
/** Relationship types preserve UI wiring, native dispatch, and value flow. */
|
||||
export const nativeInvestigationRelationSchema = z.enum([
|
||||
"contains",
|
||||
"outlet_to",
|
||||
"target_action",
|
||||
"segue_to",
|
||||
"objc_dispatch",
|
||||
"swift_witness_dispatch",
|
||||
"swift_vtable_dispatch",
|
||||
"thunk_to",
|
||||
"direct_call",
|
||||
"reads",
|
||||
"writes",
|
||||
"flows_to",
|
||||
"controls",
|
||||
]);
|
||||
|
||||
type NativeInvestigationRelation = z.infer<
|
||||
typeof nativeInvestigationRelationSchema
|
||||
>;
|
||||
|
||||
/** An observed, inferred, or unresolved relationship between entities. */
|
||||
export const nativeInvestigationEdgeSchema = z.discriminatedUnion(
|
||||
"resolution",
|
||||
[
|
||||
z.strictObject({
|
||||
id: z.string().min(1),
|
||||
from: z.string().min(1),
|
||||
to: z.string().min(1),
|
||||
relation: nativeInvestigationRelationSchema,
|
||||
resolution: z.enum(["observed", "inferred"]),
|
||||
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
|
||||
limitations: z.array(z.string()),
|
||||
}),
|
||||
z.strictObject({
|
||||
id: z.string().min(1),
|
||||
from: z.string().min(1),
|
||||
to: z.null(),
|
||||
relation: nativeInvestigationRelationSchema,
|
||||
resolution: z.literal("unresolved"),
|
||||
reason: z.string().min(1),
|
||||
evidence: z.array(nativeMetadataEvidenceSchema),
|
||||
limitations: z.array(z.string()),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
export type NativeInvestigationEdge = z.infer<
|
||||
typeof nativeInvestigationEdgeSchema
|
||||
>;
|
||||
|
||||
/** Explicit inventory coverage so missing graph parts are not read as absent. */
|
||||
export const nativeInvestigationCoverageSchema = z.strictObject({
|
||||
facet: z.string().min(1),
|
||||
status: z.enum(["complete", "partial", "unsupported", "not_requested"]),
|
||||
reason: z.string().nullable(),
|
||||
examined: z.number().int().nonnegative(),
|
||||
omitted: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
/** Complete provider-neutral graph bound to one immutable target identity. */
|
||||
export const nativeInvestigationGraphSchema = z.strictObject({
|
||||
target_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
provider: z.strictObject({
|
||||
id: z.string().min(1),
|
||||
version: z.string().nullable(),
|
||||
tool_version: z.string().min(1),
|
||||
}),
|
||||
nodes: z.array(nativeInvestigationNodeSchema),
|
||||
edges: z.array(nativeInvestigationEdgeSchema),
|
||||
coverage: z.array(nativeInvestigationCoverageSchema),
|
||||
truncated: z.boolean(),
|
||||
});
|
||||
|
||||
export type NativeInvestigationGraph = z.infer<
|
||||
typeof nativeInvestigationGraphSchema
|
||||
>;
|
||||
|
||||
/** Caller-controlled hard limits for a bounded relationship trace. */
|
||||
export const nativeInvestigationTraceLimitsSchema = z.strictObject({
|
||||
max_depth: z.number().int().min(0).max(32).default(8),
|
||||
max_nodes: z.number().int().min(1).max(2000).default(250),
|
||||
max_edges: z.number().int().min(1).max(5000).default(500),
|
||||
});
|
||||
|
||||
export type NativeInvestigationTraceLimits = z.infer<
|
||||
typeof nativeInvestigationTraceLimitsSchema
|
||||
>;
|
||||
|
||||
/** Exact graph route and any unresolved edges encountered along the route. */
|
||||
export const nativeInvestigationTraceSchema = z.strictObject({
|
||||
target_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
provider: nativeInvestigationGraphSchema.shape.provider,
|
||||
start: z.string().min(1),
|
||||
direction: z.enum(["forward", "backward"]),
|
||||
nodes: z.array(nativeInvestigationNodeSchema),
|
||||
edges: z.array(nativeInvestigationEdgeSchema),
|
||||
unresolved: z.array(nativeInvestigationEdgeSchema),
|
||||
reached_depth: z.number().int().nonnegative(),
|
||||
truncated: z.boolean(),
|
||||
reason: z.string().nullable(),
|
||||
coverage: z.array(nativeInvestigationCoverageSchema),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
export type NativeInvestigationTrace = z.infer<
|
||||
typeof nativeInvestigationTraceSchema
|
||||
>;
|
||||
|
||||
/** Trace known relationships while retaining unknown indirect dispatch edges. */
|
||||
export const traceNativeInvestigationGraph = (
|
||||
graph: NativeInvestigationGraph,
|
||||
input: {
|
||||
readonly start: string;
|
||||
readonly direction?: "forward" | "backward";
|
||||
readonly limits?: Partial<NativeInvestigationTraceLimits>;
|
||||
},
|
||||
): NativeInvestigationTrace => {
|
||||
const start = graph.nodes.find(({ id }) => id === input.start);
|
||||
if (start === undefined)
|
||||
return {
|
||||
target_sha256: graph.target_sha256,
|
||||
provider: graph.provider,
|
||||
start: input.start,
|
||||
direction: input.direction ?? "forward",
|
||||
nodes: [],
|
||||
edges: [],
|
||||
unresolved: [],
|
||||
reached_depth: 0,
|
||||
truncated: false,
|
||||
reason: "start_node_missing",
|
||||
coverage: graph.coverage,
|
||||
limitations: [
|
||||
"The requested start node is absent from the supplied graph.",
|
||||
],
|
||||
};
|
||||
|
||||
const limits = nativeInvestigationTraceLimitsSchema.parse(input.limits ?? {});
|
||||
const direction = input.direction ?? "forward";
|
||||
const nodeById = new Map(graph.nodes.map((node) => [node.id, node]));
|
||||
const visited = new Set([start.id]);
|
||||
const selectedEdges = new Map<string, NativeInvestigationEdge>();
|
||||
const unresolved = new Map<string, NativeInvestigationEdge>();
|
||||
const queue: Array<{ id: string; depth: number }> = [
|
||||
{ id: start.id, depth: 0 },
|
||||
];
|
||||
let reachedDepth = 0;
|
||||
let truncated = false;
|
||||
let reason: string | null = null;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current === undefined) break;
|
||||
reachedDepth = Math.max(reachedDepth, current.depth);
|
||||
const adjacent = graph.edges.filter((edge) =>
|
||||
direction === "forward"
|
||||
? edge.from === current.id
|
||||
: edge.to === current.id,
|
||||
);
|
||||
|
||||
for (const edge of adjacent) {
|
||||
if (selectedEdges.size + unresolved.size >= limits.max_edges) {
|
||||
truncated = true;
|
||||
reason = "max_edges_reached";
|
||||
break;
|
||||
}
|
||||
if (edge.resolution === "unresolved") {
|
||||
unresolved.set(edge.id, edge);
|
||||
continue;
|
||||
}
|
||||
const nextId = direction === "forward" ? edge.to : edge.from;
|
||||
const alreadyVisited = visited.has(nextId);
|
||||
if (!alreadyVisited && current.depth >= limits.max_depth) {
|
||||
truncated = true;
|
||||
reason ??= "max_depth_reached";
|
||||
continue;
|
||||
}
|
||||
selectedEdges.set(edge.id, edge);
|
||||
if (alreadyVisited) continue;
|
||||
if (visited.size >= limits.max_nodes) {
|
||||
truncated = true;
|
||||
reason = "max_nodes_reached";
|
||||
break;
|
||||
}
|
||||
if (!nodeById.has(nextId)) {
|
||||
truncated = true;
|
||||
reason ??= "edge_target_missing_from_graph";
|
||||
continue;
|
||||
}
|
||||
visited.add(nextId);
|
||||
queue.push({ id: nextId, depth: current.depth + 1 });
|
||||
}
|
||||
if (reason === "max_edges_reached" || reason === "max_nodes_reached") break;
|
||||
}
|
||||
|
||||
return {
|
||||
target_sha256: graph.target_sha256,
|
||||
provider: graph.provider,
|
||||
start: start.id,
|
||||
direction,
|
||||
nodes: [...visited].flatMap((id) => {
|
||||
const node = nodeById.get(id);
|
||||
return node === undefined ? [] : [node];
|
||||
}),
|
||||
edges: [...selectedEdges.values()],
|
||||
unresolved: [...unresolved.values()],
|
||||
reached_depth: reachedDepth,
|
||||
truncated,
|
||||
reason,
|
||||
coverage: graph.coverage,
|
||||
limitations: [
|
||||
"Static analysis does not prove that an authored UI action is reachable or executed at runtime.",
|
||||
"Provider call graphs may omit unresolved indirect dispatch.",
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/** Join authored UI actions to exact Objective-C method symbols when known. */
|
||||
export const joinInterfaceBuilderDispatch = (
|
||||
graph: NativeInvestigationGraph,
|
||||
metadata: ObjcSwiftMetadata,
|
||||
): NativeInvestigationGraph => {
|
||||
const nodes = new Map(graph.nodes.map((node) => [node.id, node]));
|
||||
const edges = [...graph.edges];
|
||||
const existingEdgeIds = new Set(edges.map(({ id }) => id));
|
||||
const selectorNodesByAction = new Map<string, string>();
|
||||
for (const edge of edges) {
|
||||
if (
|
||||
edge.resolution === "observed" &&
|
||||
edge.relation === "target_action" &&
|
||||
nodes.get(edge.to)?.kind === "objc_selector"
|
||||
)
|
||||
selectorNodesByAction.set(edge.from, edge.to);
|
||||
}
|
||||
|
||||
let examined = 0;
|
||||
let resolved = 0;
|
||||
let inferredWithoutOwnerClass = 0;
|
||||
for (const action of nodes.values()) {
|
||||
if (action.kind !== "action") continue;
|
||||
const selector = action.attributes.selector;
|
||||
if (typeof selector !== "string" || selector.length === 0) continue;
|
||||
const selectorNodeId = selectorNodesByAction.get(action.id);
|
||||
if (selectorNodeId === undefined) continue;
|
||||
const destinationEdge = edges.find(
|
||||
(edge) =>
|
||||
edge.resolution === "observed" &&
|
||||
edge.relation === "target_action" &&
|
||||
edge.from === action.id &&
|
||||
["view_controller", "placeholder", "external_object"].includes(
|
||||
nodes.get(edge.to)?.kind ?? "",
|
||||
),
|
||||
);
|
||||
if (
|
||||
destinationEdge === undefined ||
|
||||
destinationEdge.resolution !== "observed"
|
||||
)
|
||||
continue;
|
||||
const owner = nodes.get(destinationEdge.to);
|
||||
examined += 1;
|
||||
const declaredClass = owner?.attributes.class_name;
|
||||
const className =
|
||||
typeof declaredClass === "string" &&
|
||||
declaredClass.length > 0 &&
|
||||
owner?.kind === "view_controller"
|
||||
? declaredClass
|
||||
: null;
|
||||
const candidates = metadata.objc_dispatch_implementations.filter(
|
||||
(candidate) =>
|
||||
candidate.method_type === "instance" &&
|
||||
candidate.selector === selector &&
|
||||
(className === null ||
|
||||
candidate.class_name.replace(/\([^)]*\)$/u, "") === className),
|
||||
);
|
||||
const implementation = candidates.length === 1 ? candidates[0] : undefined;
|
||||
const edgeId = `dispatch:${selectorNodeId}:${className ?? owner?.id ?? "unknown"}:${selector}`;
|
||||
if (existingEdgeIds.has(edgeId)) continue;
|
||||
existingEdgeIds.add(edgeId);
|
||||
if (
|
||||
implementation === undefined ||
|
||||
implementation.implementation_address === null
|
||||
) {
|
||||
edges.push({
|
||||
id: edgeId,
|
||||
from: selectorNodeId,
|
||||
to: null,
|
||||
relation: "objc_dispatch",
|
||||
resolution: "unresolved",
|
||||
reason:
|
||||
candidates.length > 1
|
||||
? "selector_has_multiple_candidate_implementations"
|
||||
: "implementation_not_resolved_from_provider_symbols",
|
||||
evidence: [
|
||||
...action.evidence,
|
||||
...candidates.flatMap(({ evidence }) => evidence),
|
||||
],
|
||||
limitations: [
|
||||
"A missing or ambiguous symbol match does not prove that the runtime implementation is absent.",
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const functionId = `native:function:${implementation.implementation_address}`;
|
||||
if (!nodes.has(functionId))
|
||||
nodes.set(functionId, {
|
||||
id: functionId,
|
||||
kind: "function",
|
||||
name: `${implementation.class_name} ${selector}`,
|
||||
location: {
|
||||
address: implementation.implementation_address,
|
||||
file_offset: implementation.location.file_offset,
|
||||
},
|
||||
attributes: {
|
||||
class_name: implementation.class_name,
|
||||
selector,
|
||||
method_type: implementation.method_type,
|
||||
},
|
||||
evidence: implementation.evidence,
|
||||
});
|
||||
edges.push({
|
||||
id: edgeId,
|
||||
from: selectorNodeId,
|
||||
to: functionId,
|
||||
relation: "objc_dispatch",
|
||||
resolution: "inferred",
|
||||
evidence: [...action.evidence, ...implementation.evidence],
|
||||
limitations:
|
||||
className === null
|
||||
? [
|
||||
"The selector uniquely matches a symbolized method, but the Interface Builder receiver is an unresolved placeholder; runtime dispatch was not observed.",
|
||||
]
|
||||
: [
|
||||
"The Interface Builder target and symbolized method agree on class and selector; runtime dispatch and dynamically supplied targets were not observed.",
|
||||
],
|
||||
});
|
||||
resolved += 1;
|
||||
if (className === null) inferredWithoutOwnerClass += 1;
|
||||
}
|
||||
|
||||
return nativeInvestigationGraphSchema.parse({
|
||||
...graph,
|
||||
provider: {
|
||||
id: "rea-native-investigation",
|
||||
version: null,
|
||||
tool_version: "rea-native-investigation/1",
|
||||
},
|
||||
nodes: [...nodes.values()],
|
||||
edges,
|
||||
coverage: [
|
||||
...graph.coverage,
|
||||
{
|
||||
facet: "ui_to_objc_dispatch",
|
||||
status:
|
||||
examined === resolved && inferredWithoutOwnerClass === 0
|
||||
? "complete"
|
||||
: "partial",
|
||||
reason:
|
||||
examined === resolved && inferredWithoutOwnerClass === 0
|
||||
? null
|
||||
: inferredWithoutOwnerClass > 0
|
||||
? "some_action_receivers_remain_placeholders"
|
||||
: "some_action_implementations_not_resolved",
|
||||
examined,
|
||||
omitted: examined - resolved + inferredWithoutOwnerClass,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** A decompiler p-code varnode location or literal value. */
|
||||
export const nativeVarnodeSchema = z.strictObject({
|
||||
kind: z.enum(["constant", "address", "register", "unique", "other"]),
|
||||
size_bytes: z.number().int().nonnegative(),
|
||||
location: z.string().nullable(),
|
||||
constant_hex: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]+$/u)
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/** One high-p-code operation recovered for a single function. */
|
||||
export const nativePcodeOperationSchema = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
address: z.string().min(1),
|
||||
sequence: z.number().int().nonnegative(),
|
||||
opcode: z.string().min(1),
|
||||
is_dead: z.boolean(),
|
||||
inputs: z.array(nativeVarnodeSchema),
|
||||
output: nativeVarnodeSchema.nullable(),
|
||||
});
|
||||
|
||||
/** A def-use relation between operations in one recovered HighFunction. */
|
||||
export const nativePcodeDefUseSchema = z.strictObject({
|
||||
definition: z.string().min(1),
|
||||
use: z.string().min(1),
|
||||
input_index: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
/** Memory and branch operations as directly named by Ghidra p-code. */
|
||||
export const nativePcodeEffectSchema = z.strictObject({
|
||||
operation: z.string().min(1),
|
||||
kind: z.enum([
|
||||
"memory_read",
|
||||
"memory_write",
|
||||
"conditional_branch",
|
||||
"indirect_branch",
|
||||
"direct_call",
|
||||
"indirect_call",
|
||||
]),
|
||||
operand_input: z.number().int().nonnegative().nullable(),
|
||||
value_input: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
/** Bounded data-flow observations; STORE is not necessarily a persistent/global write. */
|
||||
export const nativeValueFlowSchema = z.discriminatedUnion("available", [
|
||||
z.strictObject({
|
||||
available: z.literal(true),
|
||||
provenance: z.literal("ghidra-high-pcode"),
|
||||
operations: z.array(nativePcodeOperationSchema),
|
||||
def_use: z.array(nativePcodeDefUseSchema),
|
||||
effects: z.array(nativePcodeEffectSchema),
|
||||
truncated: z.boolean(),
|
||||
omitted_operations_lower_bound: z.number().int().nonnegative(),
|
||||
known_omitted_inputs: z.number().int().nonnegative(),
|
||||
known_omitted_edges: z.number().int().nonnegative(),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
}),
|
||||
z.strictObject({
|
||||
available: z.literal(false),
|
||||
reason: z.string().min(1),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
}),
|
||||
]);
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isDbSaveComplete,
|
||||
isGetterSelector,
|
||||
isSetterSelector,
|
||||
inspectNativeDispatchMetadata,
|
||||
objcSwiftMetadataSchema,
|
||||
propertyNameFromSelector,
|
||||
swiftDeclsByKind,
|
||||
@@ -101,4 +102,31 @@ describe("ObjC/Swift metadata", () => {
|
||||
const result = objcSwiftMetadataSchema.safeParse(meta);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds symbol projections and labels symbol-only metadata as partial", () => {
|
||||
const result = inspectNativeDispatchMetadata(
|
||||
[
|
||||
{ address: "ram:0x1000", name: "OBJC_CLASS_$_StoreController" },
|
||||
{ address: "ram:0x2000", name: "-[StoreController buildTapped:]" },
|
||||
{ address: "ram:0x3000", name: "$s5Store7launchyyF" },
|
||||
],
|
||||
2,
|
||||
);
|
||||
expect(result.objc_classes).toHaveLength(1);
|
||||
expect(result.objc_dispatch_implementations).toHaveLength(1);
|
||||
expect(result.swift_symbols).toHaveLength(0);
|
||||
expect(result.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "objc_class_symbols",
|
||||
status: "partial",
|
||||
reason: expect.stringContaining("record_limit_reached"),
|
||||
}),
|
||||
);
|
||||
expect(result.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "swift_dispatch_tables",
|
||||
status: "unsupported",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,130 @@ export const objcClassSchema = z.strictObject({
|
||||
});
|
||||
export type ObjcClass = z.infer<typeof objcClassSchema>;
|
||||
|
||||
/** Exact location of a decoded native metadata value when the provider knows it. */
|
||||
export const nativeMetadataLocationSchema = z.strictObject({
|
||||
address: z.string().nullable(),
|
||||
file_offset: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
export type NativeMetadataLocation = z.infer<
|
||||
typeof nativeMetadataLocationSchema
|
||||
>;
|
||||
|
||||
/** Provider-neutral decode state; absence never silently means unsupported. */
|
||||
export const nativeMetadataDecodeSchema = z.strictObject({
|
||||
status: z.enum(["decoded", "partial", "unsupported", "invalid"]),
|
||||
reason: z.string().nullable(),
|
||||
});
|
||||
export type NativeMetadataDecode = z.infer<typeof nativeMetadataDecodeSchema>;
|
||||
|
||||
/** Evidence attached to one dispatch edge or metadata record. */
|
||||
export const nativeMetadataEvidenceSchema = z.strictObject({
|
||||
kind: z.enum([
|
||||
"binary_metadata",
|
||||
"interface_builder_resource",
|
||||
"symbol",
|
||||
"reference",
|
||||
"decompiler",
|
||||
]),
|
||||
description: z.string().min(1),
|
||||
location: nativeMetadataLocationSchema,
|
||||
artifact_path: z.string().min(1).nullable().default(null),
|
||||
artifact_sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable()
|
||||
.default(null),
|
||||
});
|
||||
export type NativeMetadataEvidence = z.infer<
|
||||
typeof nativeMetadataEvidenceSchema
|
||||
>;
|
||||
|
||||
/** Objective-C ivar layout, retaining encoded type and exact storage offset. */
|
||||
export const objcIvarSchema = z.strictObject({
|
||||
class_name: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
type_encoding: z.string().nullable(),
|
||||
offset: z.number().int().nonnegative().nullable(),
|
||||
location: nativeMetadataLocationSchema,
|
||||
decode: nativeMetadataDecodeSchema,
|
||||
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
|
||||
});
|
||||
export type ObjcIvar = z.infer<typeof objcIvarSchema>;
|
||||
|
||||
/** Selector implementation resolved from a class or metaclass method list. */
|
||||
export const objcDispatchImplementationSchema = z.strictObject({
|
||||
class_name: z.string().min(1),
|
||||
selector: z.string().min(1),
|
||||
method_type: z.enum(["instance", "class"]),
|
||||
implementation_address: z.string().nullable(),
|
||||
location: nativeMetadataLocationSchema,
|
||||
decode: nativeMetadataDecodeSchema,
|
||||
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
|
||||
});
|
||||
export type ObjcDispatchImplementation = z.infer<
|
||||
typeof objcDispatchImplementationSchema
|
||||
>;
|
||||
|
||||
/** Swift protocol conformance and its associated witness-table location. */
|
||||
export const swiftConformanceSchema = z.strictObject({
|
||||
type_name: z.string().min(1),
|
||||
protocol_name: z.string().min(1),
|
||||
module: z.string().nullable(),
|
||||
witness_table: nativeMetadataLocationSchema,
|
||||
location: nativeMetadataLocationSchema,
|
||||
decode: nativeMetadataDecodeSchema,
|
||||
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
|
||||
});
|
||||
export type SwiftConformance = z.infer<typeof swiftConformanceSchema>;
|
||||
|
||||
/** A typed slot in a Swift protocol witness table or class vtable. */
|
||||
export const swiftDispatchSlotSchema = z.strictObject({
|
||||
owner: z.string().min(1),
|
||||
table_kind: z.enum(["witness_table", "class_vtable"]),
|
||||
slot_index: z.number().int().nonnegative(),
|
||||
requirement: z.string().nullable(),
|
||||
implementation: z.string().nullable(),
|
||||
implementation_address: z.string().nullable(),
|
||||
thunk_address: z.string().nullable(),
|
||||
location: nativeMetadataLocationSchema,
|
||||
decode: nativeMetadataDecodeSchema,
|
||||
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
|
||||
});
|
||||
export type SwiftDispatchSlot = z.infer<typeof swiftDispatchSlotSchema>;
|
||||
|
||||
/** Signed-relative pointer as stored in an Apple binary metadata structure. */
|
||||
export const relativePointerSchema = z.strictObject({
|
||||
field: nativeMetadataLocationSchema,
|
||||
target: nativeMetadataLocationSchema,
|
||||
displacement: z.number().int(),
|
||||
indirectable: z.boolean(),
|
||||
indirect: z.boolean().nullable(),
|
||||
decode: nativeMetadataDecodeSchema,
|
||||
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
|
||||
});
|
||||
export type RelativePointer = z.infer<typeof relativePointerSchema>;
|
||||
|
||||
/** A raw Swift-mangled symbol when a provider has not decoded its metadata. */
|
||||
export const swiftSymbolSchema = z.strictObject({
|
||||
mangled_name: z.string().min(1),
|
||||
address: z.string().min(1),
|
||||
decode: nativeMetadataDecodeSchema,
|
||||
evidence: z.array(nativeMetadataEvidenceSchema).min(1),
|
||||
});
|
||||
export type SwiftSymbol = z.infer<typeof swiftSymbolSchema>;
|
||||
|
||||
/** Per-facet coverage; empty arrays are meaningful only with this record. */
|
||||
export const nativeMetadataCoverageSchema = z.strictObject({
|
||||
facet: z.string().min(1),
|
||||
status: z.enum(["complete", "partial", "unsupported", "not_requested"]),
|
||||
reason: z.string().nullable(),
|
||||
examined: z.number().int().nonnegative(),
|
||||
decoded: z.number().int().nonnegative(),
|
||||
});
|
||||
export type NativeMetadataCoverage = z.infer<
|
||||
typeof nativeMetadataCoverageSchema
|
||||
>;
|
||||
|
||||
/** Swift declaration kind. */
|
||||
export const swiftDeclKindSchema = z.enum([
|
||||
"class",
|
||||
@@ -129,10 +253,190 @@ export const objcSwiftMetadataSchema = z.strictObject({
|
||||
objc_classes: z.array(objcClassSchema).default([]),
|
||||
objc_protocols: z.array(objcProtocolSchema).default([]),
|
||||
swift_decls: z.array(swiftDeclSchema).default([]),
|
||||
objc_ivars: z.array(objcIvarSchema).default([]),
|
||||
objc_dispatch_implementations: z
|
||||
.array(objcDispatchImplementationSchema)
|
||||
.default([]),
|
||||
swift_conformances: z.array(swiftConformanceSchema).default([]),
|
||||
swift_dispatch_slots: z.array(swiftDispatchSlotSchema).default([]),
|
||||
swift_symbols: z.array(swiftSymbolSchema).default([]),
|
||||
relative_pointers: z.array(relativePointerSchema).default([]),
|
||||
coverage: z.array(nativeMetadataCoverageSchema).default([]),
|
||||
db_save_result: dbSaveResultSchema.nullable(),
|
||||
});
|
||||
export type ObjcSwiftMetadata = z.infer<typeof objcSwiftMetadataSchema>;
|
||||
|
||||
/** Provider and target identity for one normalized dispatch-metadata result. */
|
||||
export const nativeDispatchMetadataResultSchema = z.strictObject({
|
||||
target_sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
provider: z.strictObject({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
version: z.string().nullable(),
|
||||
}),
|
||||
analysis_profile_digest: z.string().min(1).nullable(),
|
||||
result: objcSwiftMetadataSchema,
|
||||
});
|
||||
export type NativeDispatchMetadataResult = z.infer<
|
||||
typeof nativeDispatchMetadataResultSchema
|
||||
>;
|
||||
|
||||
/** Decode only facts visible in a provider's existing name inventory. */
|
||||
export const inspectNativeDispatchMetadata = (
|
||||
names: readonly { readonly address: string; readonly name: string }[],
|
||||
maxRecords = 5_000,
|
||||
): ObjcSwiftMetadata => {
|
||||
const symbolEvidence = (name: string, address: string) => [
|
||||
{
|
||||
kind: "symbol" as const,
|
||||
description: `Provider symbol ${name}`,
|
||||
location: { address, file_offset: null },
|
||||
artifact_path: null,
|
||||
artifact_sha256: null,
|
||||
},
|
||||
];
|
||||
const classNames = new Set<string>();
|
||||
const implementations = new Map<string, ObjcDispatchImplementation>();
|
||||
const swiftSymbols: SwiftSymbol[] = [];
|
||||
|
||||
const examinedNames = names.slice(0, maxRecords);
|
||||
for (const entry of examinedNames) {
|
||||
const classMatch = /(?:OBJC_CLASS_|OBJC_METACLASS_)\$_([^\s]+)/u.exec(
|
||||
entry.name,
|
||||
);
|
||||
if (classMatch?.[1] !== undefined) classNames.add(classMatch[1]);
|
||||
|
||||
const methodMatch = /^([+-])\[([^\s\]]+)\s+([^\]]+)\]$/u.exec(entry.name);
|
||||
if (methodMatch?.[1] !== undefined && methodMatch[2] && methodMatch[3]) {
|
||||
const methodType = methodMatch[1] === "+" ? "class" : "instance";
|
||||
const selector = methodMatch[3];
|
||||
implementations.set(`${methodMatch[2]}:${selector}:${methodType}`, {
|
||||
class_name: methodMatch[2],
|
||||
selector,
|
||||
method_type: methodType,
|
||||
implementation_address: entry.address,
|
||||
location: { address: entry.address, file_offset: null },
|
||||
decode: { status: "partial", reason: "symbol_name_only" },
|
||||
evidence: symbolEvidence(entry.name, entry.address),
|
||||
});
|
||||
classNames.add(methodMatch[2]);
|
||||
}
|
||||
|
||||
if (/^(?:_?\$s|_Tt)/u.test(entry.name))
|
||||
swiftSymbols.push({
|
||||
mangled_name: entry.name,
|
||||
address: entry.address,
|
||||
decode: { status: "partial", reason: "mangled_symbol_not_demangled" },
|
||||
evidence: symbolEvidence(entry.name, entry.address),
|
||||
});
|
||||
}
|
||||
|
||||
const methodsByClass = new Map<string, ObjcMethod[]>();
|
||||
for (const implementation of implementations.values()) {
|
||||
const methods = methodsByClass.get(implementation.class_name) ?? [];
|
||||
methods.push({
|
||||
selector: implementation.selector,
|
||||
method_type: implementation.method_type,
|
||||
address: parseMetadataAddress(implementation.implementation_address),
|
||||
is_required: false,
|
||||
is_optional: false,
|
||||
});
|
||||
methodsByClass.set(implementation.class_name, methods);
|
||||
}
|
||||
const classes: ObjcClass[] = [...classNames].sort().map((name) => ({
|
||||
name,
|
||||
super_class: null,
|
||||
is_meta_class: false,
|
||||
is_root_class: false,
|
||||
methods: methodsByClass.get(name) ?? [],
|
||||
properties: [],
|
||||
protocols: [],
|
||||
ivar_count: 0,
|
||||
instance_size: null,
|
||||
}));
|
||||
const decodedImplementations = [...implementations.values()].sort(
|
||||
(left, right) =>
|
||||
`${left.class_name}:${left.selector}`.localeCompare(
|
||||
`${right.class_name}:${right.selector}`,
|
||||
),
|
||||
);
|
||||
const swift = swiftSymbols.length > 0;
|
||||
|
||||
return objcSwiftMetadataSchema.parse({
|
||||
objc_classes: classes,
|
||||
objc_dispatch_implementations: decodedImplementations,
|
||||
swift_symbols: swiftSymbols,
|
||||
coverage: [
|
||||
{
|
||||
facet: "objc_class_symbols",
|
||||
status: "partial",
|
||||
reason:
|
||||
names.length > examinedNames.length
|
||||
? "record_limit_reached_and_symbol_names_are_not_runtime_class_metadata"
|
||||
: "symbol_names_do_not_recover_all_runtime_class_metadata",
|
||||
examined: examinedNames.length,
|
||||
decoded: classes.length,
|
||||
},
|
||||
{
|
||||
facet: "objc_dispatch_implementations",
|
||||
status: decodedImplementations.length > 0 ? "partial" : "unsupported",
|
||||
reason:
|
||||
names.length > examinedNames.length
|
||||
? "record_limit_reached_and_only_symbolized_methods_are_resolved"
|
||||
: decodedImplementations.length > 0
|
||||
? "only_symbolized_methods_are_resolved"
|
||||
: "provider_name_inventory_has_no_objc_method_symbols",
|
||||
examined: examinedNames.length,
|
||||
decoded: decodedImplementations.length,
|
||||
},
|
||||
{
|
||||
facet: "objc_ivar_layouts",
|
||||
status: "unsupported",
|
||||
reason: "symbol_inventory_does_not_expose_runtime_ivar_layouts",
|
||||
examined: 0,
|
||||
decoded: 0,
|
||||
},
|
||||
{
|
||||
facet: "swift_declarations",
|
||||
status: swift ? "partial" : "unsupported",
|
||||
reason: swift
|
||||
? "symbols_are_not_demangled_declarations"
|
||||
: "no_swift_symbols_observed",
|
||||
examined: swiftSymbols.length,
|
||||
decoded: 0,
|
||||
},
|
||||
{
|
||||
facet: "swift_dispatch_tables",
|
||||
status: "unsupported",
|
||||
reason:
|
||||
"provider_name_inventory_does_not_decode_witness_or_class_vtables",
|
||||
examined: 0,
|
||||
decoded: 0,
|
||||
},
|
||||
{
|
||||
facet: "relative_pointers",
|
||||
status: "unsupported",
|
||||
reason:
|
||||
"provider_name_inventory_does_not_decode_binary_relative_pointers",
|
||||
examined: 0,
|
||||
decoded: 0,
|
||||
},
|
||||
],
|
||||
db_save_result: null,
|
||||
});
|
||||
};
|
||||
|
||||
const parseMetadataAddress = (address: string | null): number | null => {
|
||||
if (address === null) return null;
|
||||
const match = /(?:^|:)0x([0-9a-f]+)$/iu.exec(address);
|
||||
if (match?.[1] === undefined) return null;
|
||||
const value = Number.parseInt(match[1], 16);
|
||||
return Number.isSafeInteger(value) ? value : null;
|
||||
};
|
||||
|
||||
/** Check if a selector name looks like a getter. */
|
||||
export function isGetterSelector(selector: string): boolean {
|
||||
return /^[a-z][a-zA-Z0-9_]*$/u.test(selector) && !selector.includes(":");
|
||||
|
||||
@@ -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}],"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":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"inspect_macho","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."],"operation":"inspect_signature","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."],"operation":"inspect_plist","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."],"operation":"list_architectures","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."],"operation":"demangle_swift","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null}],"identity":{"id":"native-macos","name":"macOS native inspection utilities","version":null}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["This capability inventories PE/CLI identity only; inspect_managed_members admits metadata members, signatures, and CIL anchors.","It never loads the target assembly, resolves dependencies through a CLR, decompiles C#, or executes target code."],"operation":"inspect_managed_artifact","provider":{"id":"rea-dotnet-static","name":"REA managed static analysis provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":falLine 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}],"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":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"inspect_macho","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."],"operation":"inspect_signature","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."],"operation":"inspect_plist","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."],"operation":"list_architectures","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."],"operation":"demangle_swift","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null}],"identity":{"id":"native-macos","name":"macOS native inspection utilities","version":null}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesLine truncated
|
||||
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
||||
if (!isGeneratedPayload(generatedPayload))
|
||||
throw new TypeError("Generated MCP catalog payload is invalid");
|
||||
|
||||
@@ -60,7 +60,11 @@ const selectedChecks = (
|
||||
byName.get("architecture"),
|
||||
installation,
|
||||
...(installation?.status === "passed"
|
||||
? [byName.get("version"), byName.get("headless")]
|
||||
? [
|
||||
byName.get("version"),
|
||||
byName.get("headless"),
|
||||
byName.get("native_decompiler"),
|
||||
]
|
||||
: []),
|
||||
byName.get("java"),
|
||||
].filter((candidate): candidate is GhidraInstallationCheck =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { functionDossierSchema } from "../domain/hopperValues.js";
|
||||
import {
|
||||
GHIDRA_FUNCTION_OPERATIONS,
|
||||
isGhidraFunctionOperation,
|
||||
@@ -147,7 +148,6 @@ describe("Ghidra function-analysis result values", () => {
|
||||
mappings: [
|
||||
{
|
||||
target_address: "0x401020",
|
||||
data_addresses: ["0x403000"],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -157,10 +157,70 @@ describe("Ghidra function-analysis result values", () => {
|
||||
compilable: false,
|
||||
},
|
||||
},
|
||||
native_value_flow: {
|
||||
available: true,
|
||||
provenance: "ghidra-high-pcode",
|
||||
operations: [
|
||||
expect.objectContaining({ id: "0x401000#0", opcode: "COPY" }),
|
||||
],
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects p-code relationships that refer to omitted operation IDs", () => {
|
||||
const dossier = ghidraFunctionDossier();
|
||||
if (
|
||||
typeof dossier !== "object" ||
|
||||
dossier === null ||
|
||||
Array.isArray(dossier)
|
||||
)
|
||||
throw new TypeError("Ghidra dossier fixture is invalid");
|
||||
const flow = dossier.native_value_flow;
|
||||
if (typeof flow !== "object" || flow === null || Array.isArray(flow))
|
||||
throw new TypeError("Ghidra p-code fixture is invalid");
|
||||
expect(
|
||||
parseGhidraFunctionResult("analyze_function", {
|
||||
...dossier,
|
||||
native_value_flow: {
|
||||
...flow,
|
||||
def_use: [
|
||||
{
|
||||
definition: "0x401000#missing",
|
||||
use: "0x401000#0",
|
||||
input_index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra jump-table mapping contract", () => {
|
||||
it("keeps jump-table sources separate from case-to-target mappings", () => {
|
||||
const parsed = parseGhidraFunctionResult(
|
||||
"analyze_function",
|
||||
ghidraFunctionDossier(),
|
||||
);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
const boundary = functionDossierSchema.parse(parsed.value).native_api;
|
||||
if (boundary?.available !== true)
|
||||
throw new TypeError("Ghidra native API fixture is unavailable");
|
||||
const mapping = boundary.jump_tables[0]?.mappings[0];
|
||||
expect(mapping).toMatchObject({
|
||||
case_value: 0,
|
||||
target_address: "0x401020",
|
||||
});
|
||||
expect(mapping).not.toHaveProperty("data_addresses");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra function-analysis malformed results", () => {
|
||||
it.each(malformedOutputs())(
|
||||
"rejects malformed %s output",
|
||||
(_name, operation, value) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../domain/hopperValues.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { nativeApiBoundarySchema } from "../domain/nativeApiBoundary.js";
|
||||
import { nativeValueFlowSchema } from "../domain/nativeValueFlow.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
ghidraIdentifierSchema,
|
||||
@@ -151,10 +152,7 @@ const ghidraNativeApiBoundary = nativeApiBoundarySchema.superRefine(
|
||||
const addresses = value.jump_tables.flatMap((table) => [
|
||||
table.dispatch_address,
|
||||
...table.data_sources.map(({ address }) => address),
|
||||
...table.mappings.flatMap(({ target_address, data_addresses }) => [
|
||||
target_address,
|
||||
...data_addresses,
|
||||
]),
|
||||
...table.mappings.map(({ target_address }) => target_address),
|
||||
]);
|
||||
if (
|
||||
addresses.some(
|
||||
@@ -169,8 +167,63 @@ const ghidraNativeApiBoundary = nativeApiBoundarySchema.superRefine(
|
||||
},
|
||||
);
|
||||
|
||||
const ghidraNativeValueFlow = nativeValueFlowSchema.superRefine(
|
||||
(value, context) => {
|
||||
if (!value.available) return;
|
||||
const operations = value.operations;
|
||||
const addresses = operations.flatMap((operation) => [
|
||||
operation.address,
|
||||
...operation.inputs.flatMap(({ location }) =>
|
||||
location === null ? [] : [location],
|
||||
),
|
||||
...(operation.output?.location == null
|
||||
? []
|
||||
: [operation.output.location]),
|
||||
]);
|
||||
if (
|
||||
addresses.some(
|
||||
(address) => !ghidraCanonicalAddressSchema.safeParse(address).success,
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code flow contains a non-canonical address",
|
||||
});
|
||||
const operationIds = new Set(operations.map(({ id }) => id));
|
||||
if (operationIds.size !== operations.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code operation identifiers are not unique",
|
||||
});
|
||||
if (
|
||||
value.def_use.some(
|
||||
({ definition, use }) =>
|
||||
!operationIds.has(definition) || !operationIds.has(use),
|
||||
) ||
|
||||
value.effects.some(({ operation }) => !operationIds.has(operation))
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code relationship references a missing operation",
|
||||
});
|
||||
if (
|
||||
value.truncated !==
|
||||
(value.omitted_operations_lower_bound > 0 ||
|
||||
value.known_omitted_inputs > 0 ||
|
||||
value.known_omitted_edges > 0)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Ghidra p-code truncation flag contradicts omitted counts",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const ghidraFunctionDossier = functionDossierSchema
|
||||
.extend({ native_api: ghidraNativeApiBoundary })
|
||||
.extend({
|
||||
native_api: ghidraNativeApiBoundary,
|
||||
native_value_flow: ghidraNativeValueFlow,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
const addresses = [
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
const INSTALL = "/opt/ghidra";
|
||||
const PROPERTIES = `${INSTALL}/Ghidra/application.properties`;
|
||||
const HEADLESS = `${INSTALL}/support/analyzeHeadless`;
|
||||
const MAC_DECOMPILER = `${INSTALL}/Ghidra/Features/Decompiler/os/mac_arm_64/decompile`;
|
||||
const WINDOWS_INSTALL = "C:\\tools\\ghidra_12.1.4_PUBLIC";
|
||||
const WINDOWS_PROPERTIES = `${WINDOWS_INSTALL}\\Ghidra\\application.properties`;
|
||||
const WINDOWS_HEADLESS = `${WINDOWS_INSTALL}\\support\\analyzeHeadless.bat`;
|
||||
@@ -30,7 +31,7 @@ const host = (
|
||||
path === PROPERTIES
|
||||
? `application.version=${SUPPORTED_GHIDRA_VERSION}\n`
|
||||
: undefined,
|
||||
executable: (path) => path === HEADLESS,
|
||||
executable: (path) => path === HEADLESS || path === MAC_DECOMPILER,
|
||||
probeJava: () => JAVA,
|
||||
...overrides,
|
||||
});
|
||||
@@ -73,6 +74,31 @@ describe("Ghidra installation inspection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["x64", "arm64"] as const)(
|
||||
"keeps Ghidra unavailable on unadmitted macOS %s hosts",
|
||||
(architecture) => {
|
||||
const decompiler = `${INSTALL}/Ghidra/Features/Decompiler/os/${architecture === "arm64" ? "mac_arm_64" : "mac_x86_64"}/decompile`;
|
||||
const macHost: GhidraInstallationHost = {
|
||||
...host({
|
||||
platform: "darwin",
|
||||
architecture,
|
||||
executable: (path) => path === HEADLESS || path === decompiler,
|
||||
}),
|
||||
};
|
||||
expect(
|
||||
inspectGhidraInstallation(
|
||||
{ installDir: INSTALL, platform: "darwin", architecture },
|
||||
macHost,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "unavailable",
|
||||
platform: "darwin",
|
||||
architecture,
|
||||
nativeDecompilerPath: decompiler,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts the exact Windows x64 batch launcher and JDK commitment", () => {
|
||||
const windowsHost: GhidraInstallationHost = {
|
||||
platform: "win32",
|
||||
@@ -126,7 +152,7 @@ describe("Ghidra installation rejection diagnostics", () => {
|
||||
name: "unsupported platform",
|
||||
options: {
|
||||
installDir: INSTALL,
|
||||
platform: "darwin" as const,
|
||||
platform: "aix" as const,
|
||||
architecture: "x64" as const,
|
||||
},
|
||||
override: {},
|
||||
|
||||
@@ -42,12 +42,14 @@ export type GhidraInstallationCheckName =
|
||||
| "installation"
|
||||
| "version"
|
||||
| "headless"
|
||||
| "native_decompiler"
|
||||
| "java";
|
||||
|
||||
interface GhidraInstallationObservation {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly architecture: NodeJS.Architecture;
|
||||
readonly applicationPropertiesPath: string | null;
|
||||
readonly nativeDecompilerPath: string | null;
|
||||
readonly javaCommand: string;
|
||||
readonly checks: readonly GhidraInstallationCheck[];
|
||||
}
|
||||
@@ -109,6 +111,7 @@ interface GhidraInstallationCoordinates {
|
||||
readonly installDir: string | null;
|
||||
readonly applicationPropertiesPath: string | null;
|
||||
readonly analyzeHeadlessPath: string | null;
|
||||
readonly nativeDecompilerPath: string | null;
|
||||
readonly properties: Readonly<{ providerVersion: string | null }> | undefined;
|
||||
readonly javaCommand: string;
|
||||
readonly java: GhidraJavaObservation | undefined;
|
||||
@@ -129,7 +132,12 @@ export const inspectGhidraInstallation = (
|
||||
): GhidraInstallationInspection => {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const architecture = options.architecture ?? process.arch;
|
||||
const coordinates = installationCoordinates(options, platform, host);
|
||||
const coordinates = installationCoordinates(
|
||||
options,
|
||||
platform,
|
||||
architecture,
|
||||
host,
|
||||
);
|
||||
const checks = installationChecks({
|
||||
coordinates,
|
||||
platform,
|
||||
@@ -145,6 +153,7 @@ export const inspectGhidraInstallation = (
|
||||
platform,
|
||||
architecture,
|
||||
applicationPropertiesPath: coordinates.applicationPropertiesPath,
|
||||
nativeDecompilerPath: coordinates.nativeDecompilerPath,
|
||||
javaCommand: coordinates.javaCommand,
|
||||
checks,
|
||||
};
|
||||
@@ -193,6 +202,7 @@ const availableInstallation = (
|
||||
const installationCoordinates = (
|
||||
options: GhidraInstallationOptions,
|
||||
platform: NodeJS.Platform,
|
||||
architecture: NodeJS.Architecture,
|
||||
host: GhidraInstallationHost,
|
||||
): GhidraInstallationCoordinates => {
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
@@ -209,6 +219,36 @@ const installationCoordinates = (
|
||||
"support",
|
||||
platform === "win32" ? "analyzeHeadless.bat" : "analyzeHeadless",
|
||||
);
|
||||
const decompilerPlatform =
|
||||
platform === "darwin"
|
||||
? architecture === "arm64"
|
||||
? "mac_arm_64"
|
||||
: "mac_x86_64"
|
||||
: null;
|
||||
const nativeDecompilerPath =
|
||||
installDir === null || decompilerPlatform === null
|
||||
? null
|
||||
: ([
|
||||
path.join(
|
||||
installDir,
|
||||
"Ghidra",
|
||||
"Features",
|
||||
"Decompiler",
|
||||
"os",
|
||||
decompilerPlatform,
|
||||
"decompile",
|
||||
),
|
||||
path.join(
|
||||
installDir,
|
||||
"Ghidra",
|
||||
"Features",
|
||||
"Decompiler",
|
||||
"build",
|
||||
"os",
|
||||
decompilerPlatform,
|
||||
"decompile",
|
||||
),
|
||||
].find((candidate) => host.executable(candidate)) ?? null);
|
||||
const properties =
|
||||
applicationPropertiesPath === null
|
||||
? undefined
|
||||
@@ -227,6 +267,7 @@ const installationCoordinates = (
|
||||
installDir,
|
||||
applicationPropertiesPath,
|
||||
analyzeHeadlessPath,
|
||||
nativeDecompilerPath,
|
||||
properties:
|
||||
properties === undefined
|
||||
? undefined
|
||||
@@ -259,16 +300,15 @@ const installationChecks = ({
|
||||
passed: platform === "linux" || platform === "win32",
|
||||
code: "unsupported_host",
|
||||
detail: platform,
|
||||
remediation:
|
||||
"Use REA's Ghidra adapter on a supported Linux or Windows x64 host.",
|
||||
remediation: "Use REA's Ghidra adapter on Linux or Windows x64.",
|
||||
}),
|
||||
installationCheck({
|
||||
name: "architecture",
|
||||
passed: architecture === "x64",
|
||||
passed:
|
||||
(platform === "linux" || platform === "win32") && architecture === "x64",
|
||||
code: "unsupported_host",
|
||||
detail: architecture,
|
||||
remediation:
|
||||
"Use the official x86-64 Ghidra distribution on an x64 Linux or Windows host.",
|
||||
remediation: "Use x64 on Linux or Windows.",
|
||||
}),
|
||||
installationCheck({
|
||||
name: "installation",
|
||||
@@ -302,6 +342,18 @@ const installationChecks = ({
|
||||
remediation:
|
||||
"Restore support/analyzeHeadless or support/analyzeHeadless.bat from the official Ghidra release.",
|
||||
}),
|
||||
installationCheck({
|
||||
name: "native_decompiler",
|
||||
passed: platform !== "darwin" || coordinates.nativeDecompilerPath !== null,
|
||||
code: "executable_missing",
|
||||
detail:
|
||||
platform !== "darwin"
|
||||
? "not required on this platform"
|
||||
: (coordinates.nativeDecompilerPath ??
|
||||
"matching macOS native decompiler was not found"),
|
||||
remediation:
|
||||
"REA does not build native tools. Build Ghidra's native components for this macOS architecture or provide an installation containing Ghidra/Features/Decompiler/os/<platform>/decompile.",
|
||||
}),
|
||||
javaCheck(coordinates.java, coordinates.javaCommand, javaHome),
|
||||
];
|
||||
|
||||
@@ -311,6 +363,7 @@ export const ghidraInstallationDiagnostics = (
|
||||
): Readonly<Record<string, JsonValue>> => ({
|
||||
install_dir: inspection.installDir,
|
||||
analyze_headless_path: inspection.analyzeHeadlessPath,
|
||||
native_decompiler_path: inspection.nativeDecompilerPath,
|
||||
application_properties_path: inspection.applicationPropertiesPath,
|
||||
provider_version: inspection.providerVersion,
|
||||
java_command: inspection.javaCommand,
|
||||
|
||||
@@ -190,7 +190,13 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
if (!prerequisites.ok) return unavailableClient(prerequisites.error);
|
||||
const committedProfile = prerequisites.value.profile;
|
||||
const providerLimitations =
|
||||
installation.platform === "win32" ? windowsP0Limitations : [];
|
||||
installation.platform === "win32"
|
||||
? windowsP0Limitations
|
||||
: installation.platform === "darwin"
|
||||
? [
|
||||
"macOS sessions require a matching executable Ghidra native decompiler; REA checks for it but does not build native components or change Gatekeeper quarantine state.",
|
||||
]
|
||||
: [];
|
||||
const client = this.clientFactory({
|
||||
launcher: new GhidraHeadlessLauncher({
|
||||
analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath,
|
||||
|
||||
@@ -48,6 +48,7 @@ export const registerEnhancedTools = (
|
||||
options: EnhancedToolRegistration,
|
||||
): void => {
|
||||
const [
|
||||
nativeDispatchMetadata,
|
||||
objcClasses,
|
||||
objcProtocols,
|
||||
batchDecompile,
|
||||
@@ -60,7 +61,17 @@ export const registerEnhancedTools = (
|
||||
traceFeature,
|
||||
codeForString,
|
||||
traceCallPath,
|
||||
traceNativeUiAction,
|
||||
] = ENHANCED_TOOL_CONTRACTS;
|
||||
server.registerTool(
|
||||
nativeDispatchMetadata.name,
|
||||
toolRegistrationOptions(nativeDispatchMetadata),
|
||||
(input, context) =>
|
||||
executeEnhancedTool(analysis, options, nativeDispatchMetadata, {
|
||||
validatedCall: { name: "inspect_native_dispatch_metadata", input },
|
||||
context,
|
||||
}),
|
||||
);
|
||||
server.registerTool(
|
||||
objcClasses.name,
|
||||
toolRegistrationOptions(objcClasses),
|
||||
@@ -169,6 +180,15 @@ export const registerEnhancedTools = (
|
||||
context,
|
||||
}),
|
||||
);
|
||||
server.registerTool(
|
||||
traceNativeUiAction.name,
|
||||
toolRegistrationOptions(traceNativeUiAction),
|
||||
(input, context) =>
|
||||
executeEnhancedTool(analysis, options, traceNativeUiAction, {
|
||||
validatedCall: { name: "trace_native_ui_action", input },
|
||||
context,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const executeEnhancedTool = async (
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("application workflow CLI input", () => {
|
||||
});
|
||||
|
||||
describe("application workflow CLI export Evidence", () => {
|
||||
it("compares exact export shapes from inline Evidence", async () => {
|
||||
it("compares exact export shapes from file-backed Evidence", async () => {
|
||||
const root = await createTestTempDirectory("rea-export-shape-cli-");
|
||||
temporary.push(root);
|
||||
const leftRoot = join(root, "left");
|
||||
@@ -155,8 +155,9 @@ describe("application workflow CLI export Evidence", () => {
|
||||
]);
|
||||
if (!left.ok) throw left.error;
|
||||
if (!right.ok) throw right.error;
|
||||
const compared = await runCli([
|
||||
"compare-javascript-export-shapes",
|
||||
const inputPath = join(root, "comparison.json");
|
||||
await writeFile(
|
||||
inputPath,
|
||||
JSON.stringify({
|
||||
left: left.value,
|
||||
right: right.value,
|
||||
@@ -165,6 +166,10 @@ describe("application workflow CLI export Evidence", () => {
|
||||
right_module_path: "parser.mjs",
|
||||
right_export_name: "default",
|
||||
}),
|
||||
);
|
||||
const compared = await runCli([
|
||||
"compare-javascript-export-shapes",
|
||||
inputPath,
|
||||
"--json",
|
||||
]);
|
||||
expect(compared).toMatchObject({
|
||||
|
||||
@@ -260,7 +260,10 @@ describe("passive V8 Inspector evidence", () => {
|
||||
scriptUrls,
|
||||
});
|
||||
try {
|
||||
const input = observeInput(fake.endpoint, fake.targetId, "node");
|
||||
const input = {
|
||||
...observeInput(fake.endpoint, fake.targetId, "node"),
|
||||
observation_ms: 1_000,
|
||||
};
|
||||
const result = await new V8InspectorProvider().observe(input);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from "../../../src/domain/jsonValue.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { observed as ok } from "../../fixtures/analysisExecution.js";
|
||||
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
|
||||
import { ok as resultOk } from "../../../src/domain/result.js";
|
||||
|
||||
const PROCEDURES = {
|
||||
"0x1": "_TtC7Fixture5Class",
|
||||
@@ -28,14 +30,35 @@ const PROCEDURES = {
|
||||
const inventory = (values: Readonly<Record<string, string>>) =>
|
||||
Object.entries(values).map(([address, value]) => ({ address, value }));
|
||||
|
||||
const targetObservation = (value: unknown) =>
|
||||
resultOk(
|
||||
createAnalysisExecution(
|
||||
value,
|
||||
{
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
},
|
||||
{
|
||||
subject: {
|
||||
path: "/fixture.app",
|
||||
sha256: "a".repeat(64),
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const fixturePort = (): AnalysisOperationPort => ({
|
||||
// oxlint-disable-next-line complexity -- this fixture exhaustively serves the registered MCP surface.
|
||||
execute: (name, arguments_) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(ok(inventory(PROCEDURES)));
|
||||
case "list_names":
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
targetObservation(
|
||||
inventory({
|
||||
"0x10": "_OBJC_CLASS_$_Fixture",
|
||||
"0x11": "_OBJC_CLASS_$_Fixture",
|
||||
@@ -44,6 +67,26 @@ const fixturePort = (): AnalysisOperationPort => ({
|
||||
}),
|
||||
),
|
||||
);
|
||||
case "decode_interface_builder":
|
||||
return Promise.resolve(
|
||||
targetObservation({
|
||||
target_sha256: "a".repeat(64),
|
||||
documents: [],
|
||||
graph: {
|
||||
target_sha256: "a".repeat(64),
|
||||
provider: {
|
||||
id: "rea-artifact-graph",
|
||||
version: "1",
|
||||
tool_version: "fixture",
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
coverage: [],
|
||||
truncated: false,
|
||||
},
|
||||
limitations: [],
|
||||
}),
|
||||
);
|
||||
case "procedure_pseudo_code": {
|
||||
const procedure = arguments_.procedure;
|
||||
return Promise.resolve(
|
||||
@@ -169,6 +212,7 @@ const jsonResult = (result: CallToolResult): JsonValue => {
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line max-lines-per-function -- keep the complete production registration checks together.
|
||||
describe("enhanced MCP tools", () => {
|
||||
it("lists the complete target-open analysis surface", async () => {
|
||||
const client = await connect();
|
||||
@@ -189,7 +233,8 @@ describe("enhanced MCP tools", () => {
|
||||
expect(listed.tools.map(({ name }) => name)).not.toContain("open_binary");
|
||||
});
|
||||
|
||||
it("executes all twelve tools through production registration", async () => {
|
||||
// oxlint-disable-next-line max-lines-per-function -- one exhaustive registration test guards the public tool catalog.
|
||||
it("executes all fourteen tools through production registration", async () => {
|
||||
const client = await connect();
|
||||
const calls = [
|
||||
["get_objc_classes", { pattern: "Fixture" }],
|
||||
@@ -204,6 +249,8 @@ describe("enhanced MCP tools", () => {
|
||||
["trace_feature", { query: "hello" }],
|
||||
["find_code_for_string", { query: "hello" }],
|
||||
["trace_call_path", { start: "0x1", goal: "0x2" }],
|
||||
["trace_native_ui_action", { action: "missing-selector" }],
|
||||
["inspect_native_dispatch_metadata", { max_records: 100 }],
|
||||
] as const;
|
||||
const results = await Promise.all(
|
||||
calls.map(async ([name, arguments_]) =>
|
||||
@@ -295,5 +342,22 @@ describe("enhanced MCP tools", () => {
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
expect(results[12]).toMatchObject({
|
||||
start: "missing-selector",
|
||||
reason: "ui_action_or_object_not_found",
|
||||
});
|
||||
expect(results[13]).toMatchObject({
|
||||
target_sha256: "a".repeat(64),
|
||||
provider: {
|
||||
id: "fixture",
|
||||
name: "Fixture analysis provider",
|
||||
version: "1",
|
||||
},
|
||||
result: {
|
||||
coverage: expect.arrayContaining([
|
||||
expect.objectContaining({ facet: "objc_dispatch_implementations" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+5
-1
@@ -92,7 +92,11 @@ describe("binary session", () => {
|
||||
},
|
||||
limitations: [],
|
||||
locations: [],
|
||||
subject: null,
|
||||
subject: {
|
||||
format: "analysis-database",
|
||||
path: first,
|
||||
sha256: expect.any(String),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(provider.identity().id).toBe("fixture");
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisClient,
|
||||
type AnalysisOperation,
|
||||
type AnalysisProvider,
|
||||
type AnalysisProviderCandidate,
|
||||
type CapabilityDescriptor,
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
} from "../../../src/domain/errors.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
|
||||
type DeclaredOperation = Exclude<AnalysisOperation, "health">;
|
||||
|
||||
describe("target-bound provider routing", () => {
|
||||
it("rejects provider identity and operation-family collisions at composition", () => {
|
||||
const alpha = deepProvider("alpha");
|
||||
@@ -79,6 +82,11 @@ describe("target-bound provider routing", () => {
|
||||
provider: { id: "alpha", version: "1" },
|
||||
analysisProfile: { provider: { id: "alpha", version: "1" } },
|
||||
result: "alpha:address_name",
|
||||
subject: {
|
||||
sha256: expect.any(String),
|
||||
path: target,
|
||||
format: "analysis-database",
|
||||
},
|
||||
});
|
||||
expect(alpha.clients).toHaveLength(1);
|
||||
expect(beta.clients).toHaveLength(0);
|
||||
@@ -121,6 +129,28 @@ describe("target-bound provider routing", () => {
|
||||
expect(beta.clients[0]?.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves bundle-root identity for artifact inventory operations", async () => {
|
||||
const target = await databaseTarget();
|
||||
const analysis = deepProvider("alpha");
|
||||
const artifact = auxiliaryProvider("artifact", "inventory_artifact");
|
||||
const session = selectableSession([analysis.provider], artifact);
|
||||
|
||||
expect((await session.open(target, { providerId: "alpha" })).ok).toBe(true);
|
||||
const result = await session.execute("inventory_artifact", {});
|
||||
if (!result.ok) throw result.error;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
subject: {
|
||||
path: target,
|
||||
sha256: "f".repeat(64),
|
||||
format: "directory",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels a provider switch, closes its old client, and restores the binding", async () => {
|
||||
const target = await databaseTarget();
|
||||
const alpha = deepProvider("alpha");
|
||||
@@ -259,7 +289,7 @@ const successfulExecution = (
|
||||
|
||||
const auxiliaryProvider = (
|
||||
id = "auxiliary",
|
||||
operation: "address_name" | "inspect_macho" = "inspect_macho",
|
||||
operation: DeclaredOperation = "inspect_macho",
|
||||
): AnalysisProvider => {
|
||||
const identity = {
|
||||
id,
|
||||
@@ -269,10 +299,25 @@ const auxiliaryProvider = (
|
||||
return {
|
||||
identity: () => identity,
|
||||
capabilities: () => [capability(identity, operation)],
|
||||
createClient: () => ({
|
||||
createClient: (target) => ({
|
||||
execute: (operation) =>
|
||||
Promise.resolve(
|
||||
ok(createAnalysisExecution(`${operation}:auxiliary`, identity)),
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
`${operation}:auxiliary`,
|
||||
identity,
|
||||
operation === "inventory_artifact" ||
|
||||
operation === "inspect_artifact"
|
||||
? {
|
||||
subject: {
|
||||
path: target.sourcePath ?? target.path,
|
||||
sha256: "f".repeat(64),
|
||||
format: "directory",
|
||||
},
|
||||
}
|
||||
: {},
|
||||
),
|
||||
),
|
||||
),
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
@@ -290,7 +335,7 @@ const selectableSession = (
|
||||
|
||||
const capability = (
|
||||
provider: ProviderIdentity,
|
||||
operation: "address_name" | "inspect_macho",
|
||||
operation: DeclaredOperation,
|
||||
): CapabilityDescriptor => ({
|
||||
provider,
|
||||
operation,
|
||||
|
||||
@@ -19,6 +19,60 @@ __attribute__((noinline, used)) int rea_cross_indirect(
|
||||
return callback(value);
|
||||
}
|
||||
|
||||
#define REA_CROSS_SWITCH_CASE(case_value) \
|
||||
case case_value: \
|
||||
return rea_cross_leaf(payload + case_value)
|
||||
|
||||
__attribute__((noinline, used)) int rea_cross_dense_switch(unsigned int selector,
|
||||
int payload) {
|
||||
switch (selector) {
|
||||
REA_CROSS_SWITCH_CASE(0);
|
||||
REA_CROSS_SWITCH_CASE(1);
|
||||
REA_CROSS_SWITCH_CASE(2);
|
||||
REA_CROSS_SWITCH_CASE(3);
|
||||
REA_CROSS_SWITCH_CASE(4);
|
||||
REA_CROSS_SWITCH_CASE(5);
|
||||
REA_CROSS_SWITCH_CASE(6);
|
||||
REA_CROSS_SWITCH_CASE(7);
|
||||
REA_CROSS_SWITCH_CASE(8);
|
||||
REA_CROSS_SWITCH_CASE(9);
|
||||
REA_CROSS_SWITCH_CASE(10);
|
||||
REA_CROSS_SWITCH_CASE(11);
|
||||
REA_CROSS_SWITCH_CASE(12);
|
||||
REA_CROSS_SWITCH_CASE(13);
|
||||
REA_CROSS_SWITCH_CASE(14);
|
||||
REA_CROSS_SWITCH_CASE(15);
|
||||
REA_CROSS_SWITCH_CASE(16);
|
||||
REA_CROSS_SWITCH_CASE(17);
|
||||
REA_CROSS_SWITCH_CASE(18);
|
||||
REA_CROSS_SWITCH_CASE(19);
|
||||
REA_CROSS_SWITCH_CASE(20);
|
||||
REA_CROSS_SWITCH_CASE(21);
|
||||
REA_CROSS_SWITCH_CASE(22);
|
||||
REA_CROSS_SWITCH_CASE(23);
|
||||
REA_CROSS_SWITCH_CASE(24);
|
||||
REA_CROSS_SWITCH_CASE(25);
|
||||
REA_CROSS_SWITCH_CASE(26);
|
||||
REA_CROSS_SWITCH_CASE(27);
|
||||
REA_CROSS_SWITCH_CASE(28);
|
||||
REA_CROSS_SWITCH_CASE(29);
|
||||
REA_CROSS_SWITCH_CASE(30);
|
||||
REA_CROSS_SWITCH_CASE(31);
|
||||
REA_CROSS_SWITCH_CASE(32);
|
||||
REA_CROSS_SWITCH_CASE(33);
|
||||
REA_CROSS_SWITCH_CASE(34);
|
||||
REA_CROSS_SWITCH_CASE(35);
|
||||
REA_CROSS_SWITCH_CASE(36);
|
||||
REA_CROSS_SWITCH_CASE(37);
|
||||
REA_CROSS_SWITCH_CASE(38);
|
||||
REA_CROSS_SWITCH_CASE(39);
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
#undef REA_CROSS_SWITCH_CASE
|
||||
|
||||
__attribute__((noinline, used, visibility("default"))) int rea_cross_entry(void) {
|
||||
return rea_cross_branch(35) + rea_cross_indirect(rea_cross_leaf, 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094" />
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="NSViewController">
|
||||
<connections>
|
||||
<outlet property="view" destination="view" id="view-outlet" />
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder" />
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject" />
|
||||
<view id="view" customClass="NSView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="480" height="320" />
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES" />
|
||||
<subviews>
|
||||
<button id="button" customClass="NSButton">
|
||||
<rect key="frame" x="180" y="140" width="120" height="32" />
|
||||
<buttonCell key="cell" title="Run" bezelStyle="rounded" id="button-cell" />
|
||||
<connections>
|
||||
<action selector="runAction:" target="-2" id="run-action" />
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
</view>
|
||||
</objects>
|
||||
</document>
|
||||
@@ -0,0 +1,3 @@
|
||||
int main(void) {
|
||||
return 0;
|
||||
}
|
||||
Vendored
+4
-1
@@ -163,7 +163,10 @@ const server = createServer((socket) => {
|
||||
},
|
||||
true,
|
||||
);
|
||||
} else if (request.method === "shutdown") {
|
||||
} else if (
|
||||
request.method === "shutdown" ||
|
||||
request.method === "shutdown_document"
|
||||
) {
|
||||
send({ id: request.id, result: shutdownResult() });
|
||||
if (shutdownMode !== "cleanup-required")
|
||||
setTimeout(() => server.close(), 2);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="24765" targetRuntime="MacOSX.Cocoa" initialViewController="vc1" useAutolayout="YES">
|
||||
<scenes>
|
||||
<scene sceneID="scene1">
|
||||
<objects>
|
||||
<viewController id="vc1" customClass="BuildViewController" sceneMemberID="viewController">
|
||||
<view key="view" id="view1">
|
||||
<rect key="frame" x="0" y="0" width="390" height="844"/>
|
||||
<subviews>
|
||||
<button id="button1">
|
||||
<rect key="frame" x="120" y="400" width="150" height="44"/>
|
||||
<title key="title" string="Build"/>
|
||||
<connections>
|
||||
<action selector="buildTapped:" destination="vc1" id="action1"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="buildButton" destination="button1" id="outlet1"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
Reference in new issue
Block a user