mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(artifacts): trace Mach-O dylib resolution in bundles
Add trace_dylib_resolution (CLI trace-dylib-resolution) to the artifact family. For the active Mach-O, or every executable in the active .app (the main app, XPC services, extensions, login items, helpers), it reports which file each dylib load reaches and how. A pure TypeScript reader decodes FAT and Mach-O load commands: LC_LOAD/WEAK/REEXPORT/LAZY/UPWARD_DYLIB, including the newer dylib_use_command encoding with weak, re-export, upward and delayed-init flags, plus LC_ID_DYLIB, LC_RPATH, LC_DYLD_ENVIRONMENT and code-signature presence. Malformed tables are reported with the failing command instead of being guessed. Resolution follows dyld: - @executable_path comes from each process root; - @loader_path comes from the loading image, or from the owner of the LC_RPATH entry being expanded; - @rpath searches the loader's rpaths first, then up the load chain; - dependencies load dependents-first in load-command order, once per process, and a matching install name reuses an already loaded image; - symlinks are followed inside the analyzed root, and escapes stop. Every candidate path is reported with its outcome. Each edge resolves to resolved, conditional (an outside-root candidate such as /usr/lib/swift precedes), unresolved, or undetermined. Absolute and shared-cache system paths are never checked against the host. Derived findings cover unresolved required or weak loads, absent earlier @rpath candidates (with library validation left unknown), and LC_DYLD_ENVIRONMENT. verify:macos-bundle now also: - checks every traced image against otool -l; - asserts each resolution status on a clang-built fixture that adds a missing Overrides rpath, an absolute rpath, and a delay-init library; - compares the predicted load order of two process roots with what dyld loads under DYLD_PRINT_LIBRARIES. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
55249704c5
commit
11b7f918c2
35 files changed
+2820
-29
No files matched your search
@@ -404,7 +404,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
||||
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||
| Artifact graph | 6 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, Mach-O dylib load resolution, and extraction |
|
||||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||
|
||||
+1
-1
@@ -216,7 +216,7 @@ rea setup
|
||||
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
|
||||
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
|
||||
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift دون تشغيل Hopper |
|
||||
| مخطط المخرجات | 5 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple والاستخراج |
|
||||
| مخطط المخرجات | 6 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وحل تحميل مكتبات Mach-O الديناميكية والاستخراج |
|
||||
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
|
||||
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
|
||||
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||
|
||||
+1
-1
@@ -205,7 +205,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
|
||||
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
|
||||
| macOS ネイティブ | 7 | Hopper を起動せずに扱える Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
|
||||
| 成果物グラフ | 5 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、抽出 |
|
||||
| 成果物グラフ | 6 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib 読み込みの解決、抽出 |
|
||||
| マネージド PE/CLI | 7 | .NET の識別、メタデータ、CIL 命令、ネイティブ依存関係、再構築インポート、ビルド比較 |
|
||||
| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
|
||||
| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッド逆コンパイル、静的参照 |
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
|
||||
| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
|
||||
| macOS 네이티브 | 7 | Hopper 실행 없이 처리하는 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
|
||||
| 아티팩트 그래프 | 5 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, 추출 |
|
||||
| 아티팩트 그래프 | 6 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, Mach-O dylib 로드 해석, 추출 |
|
||||
| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
|
||||
| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
|
||||
| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
|
||||
|
||||
+1
-1
@@ -205,7 +205,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
|
||||
| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
|
||||
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
|
||||
| 产物图 | 5 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录与提取 |
|
||||
| 产物图 | 6 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录、Mach-O dylib 加载解析与提取 |
|
||||
| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
|
||||
| 固件 | 2 | Linux 固件区域检查与显式提取 |
|
||||
| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
|
||||
|
||||
@@ -108,3 +108,69 @@ The projection does not read plists. Use `inspect_plist` on a bundle's
|
||||
`info_plist_path` or a launchd plist to read `CFBundleExecutable`, identifiers,
|
||||
`SMPrivilegedExecutables`, `BundleProgram`, URL schemes, and usage descriptions.
|
||||
Use `inspect_signature` on an executable candidate for code-signing claims.
|
||||
|
||||
## Dylib load resolution
|
||||
|
||||
`trace_dylib_resolution` answers which file each Mach-O dylib load reaches. It
|
||||
runs on the active Mach-O, or on every executable in the active `.app`. Each
|
||||
executable in a bundle (the main app, XPC services, app extensions, login items,
|
||||
privileged helpers, and helper tools) is a separate process root with its own
|
||||
`@executable_path`.
|
||||
|
||||
```sh
|
||||
rea trace-dylib-resolution MyApp.app --json
|
||||
rea trace-dylib-resolution MyApp.app --root Contents/MacOS/MyApp --architecture arm64 --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "trace_dylib_resolution",
|
||||
"arguments": { "roots": ["Contents/MacOS/MyApp"], "architecture": "arm64" }
|
||||
}
|
||||
```
|
||||
|
||||
Load commands are parsed in TypeScript from Mach-O and FAT headers, including
|
||||
the `dylib_use_command` encoding newer linkers emit for flags such as delayed
|
||||
initialization. Nothing is executed and no Apple tool is required, so the tool
|
||||
also works off macOS.
|
||||
|
||||
Resolution follows dyld:
|
||||
|
||||
- `@executable_path` is the directory of the process root's executable. It is
|
||||
undetermined when the root is a library.
|
||||
- `@loader_path` is the directory of the image that holds the load command, or
|
||||
of the image that owns the `LC_RPATH` entry being expanded.
|
||||
- `@rpath` tries each `LC_RPATH` of the loading image first, then those of the
|
||||
image that loaded it, up to the process root.
|
||||
- Dependencies load dependents-first in load-command order, and each image
|
||||
loads once per process. A later request whose install name matches an
|
||||
already loaded image reuses it.
|
||||
- Symlinks such as `Versions/Current` are followed inside the analyzed root.
|
||||
An escape through `..` or an absolute link is `escapes-target`.
|
||||
|
||||
Each edge lists every candidate path tried, with its outcome: `resolved`,
|
||||
`absent`, `not-mach-o`, `malformed`, `architecture-missing`, `outside-target`,
|
||||
`escapes-target`, or `undetermined`. The edge's `resolution` is one of:
|
||||
|
||||
| Status | Meaning |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. |
|
||||
| `conditional` | An image inside the root resolves, but an earlier candidate outside the root (for example `/usr/lib/swift`) would win if it exists on the running system. |
|
||||
| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
|
||||
| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
|
||||
|
||||
Absolute install names such as `/usr/lib/libSystem.B.dylib` are
|
||||
`outside-target`. On macOS 11 and later most of them live in the dyld shared
|
||||
cache rather than on disk, so REA does not check them against the host.
|
||||
|
||||
`findings` are derived, not observed:
|
||||
|
||||
- `required-load-unresolved` and `weak-load-unresolved`.
|
||||
- `earlier-rpath-candidate-absent`: a Mach-O placed at an earlier search path
|
||||
would load first. Whether code-signing library validation would reject it is
|
||||
not evaluated; use `inspect_signature`.
|
||||
- `dyld-environment-present`: `LC_DYLD_ENVIRONMENT` search paths are not modeled.
|
||||
|
||||
`verify:macos-bundle` checks the parser against `otool -l` for every traced
|
||||
image. It also compares the predicted load order of two process roots with the
|
||||
images dyld actually loads (`DYLD_PRINT_LIBRARIES`).
|
||||
@@ -12,6 +12,13 @@ input file and include the observed lipo slice offset for universal binaries.
|
||||
If that offset is unavailable, segment evidence locations are omitted with an
|
||||
explicit limitation; architecture inventory locations remain available.
|
||||
|
||||
- `trace_dylib_resolution` / `rea trace-dylib-resolution <app-or-mach-o>`
|
||||
parses Mach-O load commands in TypeScript and follows dyld's path expansion
|
||||
for every executable in an app bundle, or for one Mach-O within its directory.
|
||||
It reports each `@rpath`, `@loader_path` and `@executable_path` candidate with
|
||||
its outcome. Paths outside the analyzed root, including shared-cache system
|
||||
libraries, stay undetermined. See
|
||||
[Apple application analysis](apple-application-analysis.md#dylib-load-resolution).
|
||||
- `inspect_asset_catalog` / `rea inspect-asset-catalog <app>` reads compiled
|
||||
`Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw
|
||||
rendition fields, pagination and exact UI resource-name matches are returned.
|
||||
|
||||
+14
-11
@@ -7,10 +7,10 @@
|
||||
"client": "2.3.1",
|
||||
"core": "2.3.1"
|
||||
},
|
||||
"skill_version": "27"
|
||||
"skill_version": "28"
|
||||
},
|
||||
"tools": {
|
||||
"total": 131,
|
||||
"total": 132,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
@@ -98,13 +98,14 @@
|
||||
{
|
||||
"id": "artifact",
|
||||
"surface": "artifact-provider",
|
||||
"count": 5,
|
||||
"count": 6,
|
||||
"tools": [
|
||||
"decode_interface_builder",
|
||||
"extract_artifact",
|
||||
"inspect_artifact",
|
||||
"inspect_asset_catalog",
|
||||
"inspect_keyed_archive"
|
||||
"inspect_keyed_archive",
|
||||
"trace_dylib_resolution"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -376,7 +377,8 @@
|
||||
"extract_artifact",
|
||||
"inspect_artifact",
|
||||
"inspect_asset_catalog",
|
||||
"inspect_keyed_archive"
|
||||
"inspect_keyed_archive",
|
||||
"trace_dylib_resolution"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -566,7 +568,7 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 86,
|
||||
"primary_count": 87,
|
||||
"commands": [
|
||||
"address-to-file-offset",
|
||||
"analyze",
|
||||
@@ -645,6 +647,7 @@
|
||||
"trace",
|
||||
"trace-android-references",
|
||||
"trace-application-feature",
|
||||
"trace-dylib-resolution",
|
||||
"trace-javascript-semantics",
|
||||
"trace-native-ui-action",
|
||||
"trace-native-values",
|
||||
@@ -664,15 +667,15 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 86,
|
||||
"mcp_tools": 131,
|
||||
"cli_commands": 87,
|
||||
"mcp_tools": 132,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "db85863d0b0947f21a14fcffe1e0e293842c68a209beb4125900d797f1242ccd",
|
||||
"tools_sha256": "de9574754aec25583ad8afd7707decd98cf44be2045116df58382d543e0a531c",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "7521c178e76e19b8fd552c2ca7e1ed4930dd86d2bf01d035951d974e4f6edab9",
|
||||
"providers_sha256": "a7d885531ee25d42c51385444faec544d34cd7eba6250a7dc75c4faedd4454a3"
|
||||
"combined_sha256": "bc890fb6dd3df37c65925265dba308cf9dcfdd479a71b55ac73e268e5bbfdc5f",
|
||||
"providers_sha256": "56e95f15d7ee4cc262e9f758dc74db0eabf670fdfea0be878079f0286114e65b"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -77,7 +77,16 @@ extension, a login item, a privileged helper, launchd plists, and a helper tool,
|
||||
signed ad hoc. It packs the app as a directory, a `ditto` ZIP, and an APFS DMG,
|
||||
then checks that `inspect-artifact` plus `project-apple-application-graph`
|
||||
report the same bundle anatomy for all three through the CLI, with stdio MCP
|
||||
parity. It also checks that the DMG is detached afterwards. It runs in macOS CI.
|
||||
parity. It also checks that the DMG is detached afterwards. The same app
|
||||
covers `trace-dylib-resolution`:
|
||||
|
||||
- each resolution status and finding, with CLI/MCP parity;
|
||||
- for every traced image, dependencies, rpaths, and install names equal to
|
||||
`otool -l`;
|
||||
- for the main executable and an XPC service, a predicted load order equal to
|
||||
the images dyld actually loads under `DYLD_PRINT_LIBRARIES`.
|
||||
|
||||
It runs in macOS CI.
|
||||
|
||||
MCP SDK transport tests with recording providers remain integration tests.
|
||||
They are useful for schema drift and failure projection but do not prove that
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_7c7f2d7520a5b3ba8f4811c9ca9efcb4a7d9a7d7b68c57b396cc418c9cbea2ee",
|
||||
"manifest_id": "ecm_833521668577f2ca68cb5f93d29c32bf514a2f5cffd17c32f04584e341177e39",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "acc3d70d97ac210557d5e80c0cd84c236474b7162fddf67753c5a2518b66bb54"
|
||||
"sha256": "9e3fff18418fb7ee5d59de8ca3346fc6607cd810394893ffbcee4b134f2532e0"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -138,7 +138,11 @@ const char *core_chain(void) { return sibling_value() == 7 ? chain_variant() : "
|
||||
return join(version, "Core");
|
||||
};
|
||||
|
||||
/** The main executable, with a weak dependency deleted after linking. */
|
||||
/**
|
||||
* The main executable. It searches a missing `Overrides` rpath before
|
||||
* `Frameworks`, weakly links a library deleted after linking, and
|
||||
* delay-links a library, which the linker encodes as `dylib_use_command`.
|
||||
*/
|
||||
const buildMainExecutable = async (root, contents, coreBinary) => {
|
||||
const gone = join(contents, "Frameworks", "libgone.dylib");
|
||||
await clang(root, {
|
||||
@@ -147,22 +151,33 @@ const buildMainExecutable = async (root, contents, coreBinary) => {
|
||||
output: gone,
|
||||
flags: ["-dynamiclib", "-install_name", "@rpath/libgone.dylib"],
|
||||
});
|
||||
const delayed = join(contents, "Frameworks", "libdelay.dylib");
|
||||
await clang(root, {
|
||||
name: "delay",
|
||||
source: `int delay_value(void) { return 3; }\n`,
|
||||
output: delayed,
|
||||
flags: ["-dynamiclib", "-install_name", "@rpath/libdelay.dylib"],
|
||||
});
|
||||
const executable = join(contents, "MacOS", "MacFixture");
|
||||
await clang(root, {
|
||||
name: "main",
|
||||
source: `#include <stdio.h>
|
||||
const char *core_chain(void);
|
||||
int delay_value(void);
|
||||
extern int gone_value(void) __attribute__((weak_import));
|
||||
int main(void) {
|
||||
if (delay_value() != 3) return 1;
|
||||
printf("chain=%s weak=%s\\n", core_chain(), gone_value ? "present" : "absent");
|
||||
return 0;
|
||||
}
|
||||
`,
|
||||
output: executable,
|
||||
flags: [
|
||||
"-Wl,-rpath,@executable_path/../Overrides",
|
||||
"-Wl,-rpath,@executable_path/../Frameworks",
|
||||
coreBinary,
|
||||
`-Wl,-weak_library,${gone}`,
|
||||
`-Wl,-delay_library,${delayed}`,
|
||||
],
|
||||
});
|
||||
await rm(gone);
|
||||
@@ -191,7 +206,12 @@ const buildNestedBundles = async (root, contents, coreBinary) => {
|
||||
int main(void) { return core_chain()[0] == 'b'; }
|
||||
`,
|
||||
output: join(service, "MacOS", "Svc"),
|
||||
flags: ["-Wl,-rpath,@executable_path/../../../../Frameworks", coreBinary],
|
||||
// An absolute rpath outside the bundle precedes the bundled framework.
|
||||
flags: [
|
||||
"-Wl,-rpath,/opt/rea-fixture-absent/lib",
|
||||
"-Wl,-rpath,@executable_path/../../../../Frameworks",
|
||||
coreBinary,
|
||||
],
|
||||
});
|
||||
await write(
|
||||
join(service, "Info.plist"),
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
artifactCliEvidence,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "./artifact-e2e.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
const MAIN = "Contents/MacOS/MacFixture";
|
||||
const SERVICE = "Contents/XPCServices/Svc.xpc/Contents/MacOS/Svc";
|
||||
const CORE = "Contents/Frameworks/Core.framework/Versions/A/Core";
|
||||
const DEPENDENCY_COMMANDS = new Set([
|
||||
"LC_LOAD_DYLIB",
|
||||
"LC_LOAD_WEAK_DYLIB",
|
||||
"LC_REEXPORT_DYLIB",
|
||||
"LC_LAZY_LOAD_DYLIB",
|
||||
"LC_LOAD_UPWARD_DYLIB",
|
||||
]);
|
||||
|
||||
/** Dependencies, rpaths and install name of one slice as Apple's otool reports them. */
|
||||
const otoolLoadCommands = async (path, architecture) => {
|
||||
const { stdout } = await exec(
|
||||
"/usr/bin/xcrun",
|
||||
["otool", "-arch", architecture, "-l", path],
|
||||
{ maxBuffer: 64 * 1024 * 1024 },
|
||||
);
|
||||
const result = { dependencies: [], rpaths: [], install_name: null };
|
||||
for (const block of stdout.split(/\nLoad command \d+\n/u)) {
|
||||
const command = /^\s*cmd (\S+)/mu.exec(block)?.[1];
|
||||
const name = /^\s*name (.+?) \(offset \d+\)$/mu.exec(block)?.[1];
|
||||
if (command === "LC_RPATH")
|
||||
result.rpaths.push(/^\s*path (.+?) \(offset \d+\)$/mu.exec(block)?.[1]);
|
||||
else if (command === "LC_ID_DYLIB") result.install_name = name;
|
||||
else if (DEPENDENCY_COMMANDS.has(command))
|
||||
result.dependencies.push(`${command} ${name}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/** In-bundle images dyld actually loaded, in load order. */
|
||||
const loadedImages = async (app, executable) => {
|
||||
const { stderr } = await exec(join(app, executable), [], {
|
||||
env: { PATH: "/usr/bin:/bin", DYLD_PRINT_LIBRARIES: "1" },
|
||||
});
|
||||
const appRoot = await realpath(app);
|
||||
const loaded = [];
|
||||
for (const line of stderr.split("\n")) {
|
||||
const path = /^dyld\[\d+\]: <[0-9A-F-]+> (.+)$/u.exec(line)?.[1];
|
||||
if (path === undefined) continue;
|
||||
// Shared-cache libraries are reported by path but are not files on disk.
|
||||
const real = await realpath(path).catch(() => undefined);
|
||||
if (real?.startsWith(`${appRoot}/`) === true)
|
||||
loaded.push(relative(appRoot, real));
|
||||
}
|
||||
return loaded;
|
||||
};
|
||||
|
||||
/** Images a process root loads according to the trace, in first-load order. */
|
||||
const predictedImages = (trace, root) => {
|
||||
const loaded = [root];
|
||||
for (const edge of trace.edges)
|
||||
if (
|
||||
edge.root === root &&
|
||||
["resolved", "conditional"].includes(edge.resolution.status) &&
|
||||
!loaded.includes(edge.resolution.image)
|
||||
)
|
||||
loaded.push(edge.resolution.image);
|
||||
return loaded;
|
||||
};
|
||||
|
||||
const findEdge = (trace, root, loader, installName) => {
|
||||
const edge = trace.edges.find(
|
||||
(candidate) =>
|
||||
candidate.root === root &&
|
||||
candidate.loader === loader &&
|
||||
candidate.install_name === installName,
|
||||
);
|
||||
assert.ok(edge, `missing edge ${root}: ${loader} -> ${installName}`);
|
||||
return edge;
|
||||
};
|
||||
|
||||
const verifyResolutionShapes = (trace) => {
|
||||
const core = findEdge(
|
||||
trace,
|
||||
MAIN,
|
||||
MAIN,
|
||||
"@rpath/Core.framework/Versions/A/Core",
|
||||
);
|
||||
assert.deepEqual(
|
||||
core.candidates.map(({ source, outcome }) => [source, outcome]),
|
||||
[
|
||||
["rpath", "absent"],
|
||||
["rpath", "resolved"],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(core.resolution, { status: "resolved", image: CORE });
|
||||
const weak = findEdge(trace, MAIN, MAIN, "@rpath/libgone.dylib");
|
||||
assert.equal(weak.weak, true);
|
||||
assert.equal(weak.resolution.status, "unresolved");
|
||||
const delayed = findEdge(trace, MAIN, MAIN, "@rpath/libdelay.dylib");
|
||||
assert.equal(delayed.encoding, "dylib_use_command");
|
||||
assert.equal(delayed.delayed_init, true);
|
||||
const chain = findEdge(trace, MAIN, CORE, "@rpath/libchain.dylib");
|
||||
assert.equal(
|
||||
chain.resolution.image,
|
||||
"Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib",
|
||||
"the loading image's own LC_RPATH must be searched first",
|
||||
);
|
||||
const service = findEdge(
|
||||
trace,
|
||||
SERVICE,
|
||||
SERVICE,
|
||||
"@rpath/Core.framework/Versions/A/Core",
|
||||
);
|
||||
assert.equal(service.candidates[0]?.outcome, "outside-target");
|
||||
assert.deepEqual(service.resolution, { status: "conditional", image: CORE });
|
||||
const system = trace.edges.filter(({ install_name: name }) =>
|
||||
name.startsWith("/usr/lib/"),
|
||||
);
|
||||
assert.ok(system.length > 0);
|
||||
assert.ok(
|
||||
system.every(({ resolution }) => resolution.status === "undetermined"),
|
||||
);
|
||||
const kinds = trace.findings.map(({ kind, image }) => `${kind}:${image}`);
|
||||
assert.ok(kinds.includes(`weak-load-unresolved:${MAIN}`));
|
||||
assert.ok(kinds.includes(`earlier-rpath-candidate-absent:${MAIN}`));
|
||||
};
|
||||
|
||||
const verifyParserConformance = async (app, trace) => {
|
||||
let slices = 0;
|
||||
for (const image of trace.images)
|
||||
for (const slice of image.slices) {
|
||||
const expected = await otoolLoadCommands(
|
||||
join(app, image.path),
|
||||
slice.architecture,
|
||||
);
|
||||
const edges = trace.edges.filter(
|
||||
({ loader, architecture, root }) =>
|
||||
loader === image.path &&
|
||||
architecture === slice.architecture &&
|
||||
root ===
|
||||
trace.edges.find(
|
||||
(edge) =>
|
||||
edge.loader === image.path &&
|
||||
edge.architecture === slice.architecture,
|
||||
)?.root,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
dependencies: edges.map(
|
||||
({ command, install_name: name }) => `${command} ${name}`,
|
||||
),
|
||||
rpaths: slice.rpaths,
|
||||
install_name: slice.install_name,
|
||||
},
|
||||
expected,
|
||||
`${image.path} (${slice.architecture}) differs from otool -l`,
|
||||
);
|
||||
slices += 1;
|
||||
}
|
||||
return slices;
|
||||
};
|
||||
|
||||
/** Verify trace_dylib_resolution against otool and the real dyld on the fixture app. */
|
||||
export async function verifyDylibResolution(app) {
|
||||
const trace = (await artifactCliEvidence("trace-dylib-resolution", app))
|
||||
.normalized_result;
|
||||
await withArtifactMcp(app, async (client) => {
|
||||
assert.deepEqual(
|
||||
await artifactMcpResult(client, "trace_dylib_resolution"),
|
||||
trace,
|
||||
);
|
||||
const invalid = await client.callTool({
|
||||
name: "trace_dylib_resolution",
|
||||
arguments: { roots: ["../outside"] },
|
||||
});
|
||||
assert.equal(invalid.isError, true);
|
||||
});
|
||||
assert.equal(trace.coverage.status, "complete");
|
||||
assert.deepEqual(trace.roots.map(({ image }) => image).sort(), [
|
||||
"Contents/Frameworks/Core.framework/Versions/A/XPCServices/FwSvc.xpc/Contents/MacOS/FwSvc",
|
||||
"Contents/Helpers/rea-tool",
|
||||
"Contents/Library/LaunchServices/com.example.rea.helper",
|
||||
"Contents/Library/LoginItems/Login.app/Contents/MacOS/Login",
|
||||
MAIN,
|
||||
"Contents/PlugIns/Ext.appex/Contents/MacOS/Ext",
|
||||
SERVICE,
|
||||
]);
|
||||
verifyResolutionShapes(trace);
|
||||
const slices = await verifyParserConformance(app, trace);
|
||||
for (const root of [MAIN, SERVICE])
|
||||
assert.deepEqual(
|
||||
await loadedImages(app, root),
|
||||
predictedImages(trace, root),
|
||||
`${root} load order differs from DYLD_PRINT_LIBRARIES`,
|
||||
);
|
||||
return {
|
||||
roots: trace.roots.length,
|
||||
edges: trace.edges.length,
|
||||
otool_slices: slices,
|
||||
dyld_runtime_roots: 2,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildMacosBundleFixture,
|
||||
preflightMacosBundleFixture,
|
||||
} from "./lib/macos-bundle-fixture.mjs";
|
||||
import { verifyDylibResolution } from "./lib/macos-dylib-e2e.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
@@ -150,6 +151,7 @@ try {
|
||||
assert.deepEqual(anatomies[2], anatomies[0], "DMG anatomy differs");
|
||||
const { stdout: attached } = await exec("/usr/bin/hdiutil", ["info"]);
|
||||
assert.ok(!attached.includes(dmg), "DMG remained attached after inventory");
|
||||
const dylibs = await verifyDylibResolution(app);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -160,6 +162,7 @@ try {
|
||||
containers: containers.map(({ format }) => format),
|
||||
bundles: EXPECTED_BUNDLES.length,
|
||||
dmg_detached: true,
|
||||
dylib_resolution: dylibs,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "27"
|
||||
tool_count: 131
|
||||
catalog_digest: "7521c178e76e19b8fd552c2ca7e1ed4930dd86d2bf01d035951d974e4f6edab9"
|
||||
version: "28"
|
||||
tool_count: 132
|
||||
catalog_digest: "bc890fb6dd3df37c65925265dba308cf9dcfdd479a71b55ac73e268e5bbfdc5f"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -77,6 +77,13 @@ helpers, launchd plists, symlinks, and each bundle's `info_plist_path` and
|
||||
executable candidates. Roles are path conventions, not parsed plists; read the
|
||||
listed plists with `inspect_plist`.
|
||||
|
||||
After `open_binary` on a `.app` or Mach-O, use `trace_dylib_resolution` to see
|
||||
which file each `@rpath`, `@loader_path` and `@executable_path` load reaches for
|
||||
every executable in the bundle. It also lists missing or weak loads and earlier
|
||||
`@rpath` candidates that are absent. Narrow large bundles with `roots` or
|
||||
`architecture`. System paths stay undetermined because the dyld shared cache
|
||||
provides them.
|
||||
|
||||
Use `extract_artifact` when materialized files are needed. It takes no arguments
|
||||
and materializes all regular files into a fresh temporary directory chosen by
|
||||
REA. Symlinks and encrypted entries are inventory facts, not extractable files.
|
||||
|
||||
@@ -319,7 +319,8 @@ export class BinarySession
|
||||
lookupSnapshot: (target, profile, operation, parameters) =>
|
||||
operation === "decode_interface_builder" ||
|
||||
operation === "inspect_asset_catalog" ||
|
||||
operation === "inspect_keyed_archive"
|
||||
operation === "inspect_keyed_archive" ||
|
||||
operation === "trace_dylib_resolution"
|
||||
? undefined
|
||||
: this.lookupSnapshot(target, profile, operation, parameters),
|
||||
});
|
||||
@@ -342,7 +343,8 @@ export class BinarySession
|
||||
profile !== undefined &&
|
||||
name !== "decode_interface_builder" &&
|
||||
name !== "inspect_asset_catalog" &&
|
||||
name !== "inspect_keyed_archive"
|
||||
name !== "inspect_keyed_archive" &&
|
||||
name !== "trace_dylib_resolution"
|
||||
) {
|
||||
const evidence = createEvidence(
|
||||
profiled.value.subject ?? active.target,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ExecutionOptions,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import { inspectBundleKeyedArchive } from "./KeyedArchiveReader.js";
|
||||
import { traceDylibResolution } from "./DylibResolutionReader.js";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { inventoryArtifact } from "../application/ArtifactInventory.js";
|
||||
import { extractArtifact } from "../application/ArtifactExtraction.js";
|
||||
@@ -153,6 +154,8 @@ class ArtifactClient implements AnalysisClient {
|
||||
if (operation === "inspect_asset_catalog") {
|
||||
return await this.inspectAssetCatalog(parameters, options);
|
||||
}
|
||||
if (operation === "trace_dylib_resolution")
|
||||
return await this.traceDylibResolution(parameters, options);
|
||||
if (operation === "extract_artifact") {
|
||||
const parsed = artifactExtractionExecutionSchema.parse(parameters);
|
||||
const result = await extractArtifact(
|
||||
@@ -234,6 +237,37 @@ class ArtifactClient implements AnalysisClient {
|
||||
);
|
||||
}
|
||||
|
||||
private async traceDylibResolution(
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
options?: ExecutionOptions,
|
||||
) {
|
||||
if (this.target.kind !== "executable" || this.target.format !== "mach-o")
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"trace_dylib_resolution requires an active Mach-O or .app bundle target",
|
||||
);
|
||||
const bundle = this.target.sourcePath?.toLowerCase().endsWith(".app")
|
||||
? this.target.sourcePath
|
||||
: undefined;
|
||||
const result = await traceDylibResolution({
|
||||
rootPath: bundle ?? dirname(this.target.path),
|
||||
targetPath: this.target.path,
|
||||
targetSha256: this.target.sha256,
|
||||
enumerateRoots: bundle !== undefined,
|
||||
parameters,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
return ok(
|
||||
createAnalysisExecution(result, IDENTITY, {
|
||||
limitations: result.limitations,
|
||||
locations: result.images.map(({ path }) => ({
|
||||
kind: "artifact-path" as const,
|
||||
path,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async inspectArtifact(
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
options?: ExecutionOptions,
|
||||
|
||||
@@ -24,7 +24,8 @@ export const artifactCapabilities = (
|
||||
mutatesArtifact: false,
|
||||
launchesProcess:
|
||||
operation !== "decode_interface_builder" &&
|
||||
operation !== "inspect_keyed_archive",
|
||||
operation !== "inspect_keyed_archive" &&
|
||||
operation !== "trace_dylib_resolution",
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem:
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open, readlink, realpath } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
import {
|
||||
dylibResolutionInputSchema,
|
||||
dylibResolutionResultSchema,
|
||||
traceDylibLoading,
|
||||
type DylibResolutionResult,
|
||||
type DylibTreeEntry,
|
||||
type DylibTreeView,
|
||||
type MachoImageFacts,
|
||||
} from "../domain/dylibResolution.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
|
||||
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
|
||||
|
||||
const HASH_CHUNK_BYTES = 1024 * 1024;
|
||||
|
||||
/** Lazily probed, symlink-preserving view of one analyzed directory. */
|
||||
class FilesystemTreeView implements DylibTreeView {
|
||||
readonly #entries = new Map<string, Promise<DylibTreeEntry | undefined>>();
|
||||
readonly #images = new Map<string, Promise<MachoImageFacts>>();
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly signal?: AbortSignal,
|
||||
) {}
|
||||
|
||||
entry(path: string): Promise<DylibTreeEntry | undefined> {
|
||||
const cached = this.#entries.get(path);
|
||||
if (cached !== undefined) return cached;
|
||||
const pending = this.#readEntry(path);
|
||||
this.#entries.set(path, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
image(path: string): Promise<MachoImageFacts> {
|
||||
const cached = this.#images.get(path);
|
||||
if (cached !== undefined) return cached;
|
||||
const pending = readImage(join(this.root, path), this.signal);
|
||||
this.#images.set(path, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async #readEntry(path: string): Promise<DylibTreeEntry | undefined> {
|
||||
cancelled(this.signal);
|
||||
const absolute = join(this.root, path);
|
||||
try {
|
||||
const metadata = await lstat(absolute);
|
||||
if (metadata.isSymbolicLink())
|
||||
return { kind: "symlink", target: await readlink(absolute) };
|
||||
if (metadata.isDirectory()) return { kind: "directory" };
|
||||
return metadata.isFile() ? { kind: "file" } : undefined;
|
||||
} catch (cause: unknown) {
|
||||
if (missing(cause)) return undefined;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve dyld load paths for the active Mach-O or every executable in its bundle. */
|
||||
export const traceDylibResolution = async (options: {
|
||||
readonly rootPath: string;
|
||||
readonly targetPath: string;
|
||||
readonly targetSha256: string;
|
||||
readonly enumerateRoots: boolean;
|
||||
readonly parameters: unknown;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<DylibResolutionResult> => {
|
||||
const parsed = dylibResolutionInputSchema.safeParse(options.parameters);
|
||||
if (!parsed.success)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"roots must be normalized paths relative to the analyzed root",
|
||||
);
|
||||
const root = await realpath(options.rootPath);
|
||||
const target = relative(root, await realpath(options.targetPath))
|
||||
.split(sep)
|
||||
.join("/");
|
||||
const view = new FilesystemTreeView(root, options.signal);
|
||||
try {
|
||||
const roots =
|
||||
parsed.data.roots ??
|
||||
(options.enumerateRoots
|
||||
? await executableRoots(root, view, options.signal)
|
||||
: [target]);
|
||||
await requireMachoRoots(view, roots);
|
||||
const trace = await traceDylibLoading(view, {
|
||||
roots,
|
||||
...(parsed.data.architecture === undefined
|
||||
? {}
|
||||
: { architecture: parsed.data.architecture }),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
const digests = new Map<string, string>();
|
||||
for (const { path } of trace.images)
|
||||
digests.set(path, await fileSha256(join(root, path), options.signal));
|
||||
const targetDigest =
|
||||
digests.get(target) ??
|
||||
(await fileSha256(join(root, target), options.signal));
|
||||
if (targetDigest !== options.targetSha256)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`,
|
||||
);
|
||||
return dylibResolutionResultSchema.parse({
|
||||
...trace,
|
||||
root_path: options.rootPath,
|
||||
target_sha256: options.targetSha256,
|
||||
images: trace.images.map((image) => ({
|
||||
...image,
|
||||
sha256: digests.get(image.path),
|
||||
})),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (options.signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Dylib resolution was cancelled",
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/** Every Mach-O in the bundle with an executable slice is its own process root. */
|
||||
const executableRoots = async (
|
||||
root: string,
|
||||
view: FilesystemTreeView,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> => {
|
||||
const roots: string[] = [];
|
||||
for await (const entry of new DirectoryArtifactReader(root).entries(signal)) {
|
||||
if (
|
||||
entry.kind !== "file" ||
|
||||
!(await startsWithMachoMagic(entry.adapterKey))
|
||||
)
|
||||
continue;
|
||||
const facts = await view.image(entry.path);
|
||||
if (
|
||||
facts.status === "parsed" &&
|
||||
facts.slices.some(({ file_type: type }) => type === "execute")
|
||||
)
|
||||
roots.push(entry.path);
|
||||
}
|
||||
return roots.sort((left, right) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
};
|
||||
|
||||
const requireMachoRoots = async (
|
||||
view: FilesystemTreeView,
|
||||
roots: readonly string[],
|
||||
): Promise<void> => {
|
||||
for (const path of roots) {
|
||||
const entry = await view.entry(path);
|
||||
if (entry?.kind !== "file")
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
`Root ${path} is not a regular file in the analyzed root`,
|
||||
);
|
||||
if ((await view.image(path)).status === "not-mach-o")
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Root ${path} is not a Mach-O image`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const startsWithMachoMagic = async (path: string): Promise<boolean> => {
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const buffer = new Uint8Array(4);
|
||||
const { bytesRead } = await handle.read(buffer, 0, 4, 0);
|
||||
return hasMachoMagic(buffer.subarray(0, bytesRead));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
const readImage = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<MachoImageFacts> => {
|
||||
cancelled(signal);
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
return await readMachoImage(async (offset, length) => {
|
||||
cancelled(signal);
|
||||
const buffer = new Uint8Array(
|
||||
Math.max(0, Math.min(length, size - offset)),
|
||||
);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
||||
return buffer.subarray(0, bytesRead);
|
||||
}, size);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
const fileSha256 = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> => {
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const hash = createHash("sha256");
|
||||
const buffer = new Uint8Array(HASH_CHUNK_BYTES);
|
||||
for (;;) {
|
||||
cancelled(signal);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
||||
if (bytesRead === 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
return hash.digest("hex");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelled = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Dylib resolution was cancelled",
|
||||
);
|
||||
};
|
||||
|
||||
const missing = (cause: unknown): boolean =>
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR");
|
||||
@@ -0,0 +1,148 @@
|
||||
/** Little-endian Mach-O images and FAT containers built field by field for tests. */
|
||||
|
||||
export const LC = {
|
||||
LOAD_DYLIB: 0xc,
|
||||
ID_DYLIB: 0xd,
|
||||
LOAD_WEAK_DYLIB: 0x80000018,
|
||||
REEXPORT_DYLIB: 0x8000001f,
|
||||
LAZY_LOAD_DYLIB: 0x20,
|
||||
LOAD_UPWARD_DYLIB: 0x80000023,
|
||||
RPATH: 0x8000001c,
|
||||
DYLD_ENVIRONMENT: 0x27,
|
||||
CODE_SIGNATURE: 0x1d,
|
||||
} as const;
|
||||
|
||||
export const CPU = {
|
||||
arm64: { type: 0x0100000c, subtype: 0 },
|
||||
arm64e: { type: 0x0100000c, subtype: 0x80000002 },
|
||||
x86_64: { type: 0x01000007, subtype: 3 },
|
||||
} as const;
|
||||
|
||||
export const FILE_TYPE = { execute: 2, dylib: 6, bundle: 8 } as const;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/** One load command with a NUL-terminated string at `nameOffset`, padded to 8 bytes. */
|
||||
const commandWithString = (
|
||||
command: number,
|
||||
fields: readonly number[],
|
||||
text: string,
|
||||
): Uint8Array => {
|
||||
const nameOffset = 8 + fields.length * 4 + 4;
|
||||
const name = encoder.encode(text);
|
||||
const size = Math.ceil((nameOffset + name.length + 1) / 8) * 8;
|
||||
const bytes = new Uint8Array(size);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, command, true);
|
||||
view.setUint32(4, size, true);
|
||||
view.setUint32(8, nameOffset, true);
|
||||
fields.forEach((value, index) => view.setUint32(12 + index * 4, value, true));
|
||||
bytes.set(name, nameOffset);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** Classic `dylib_command`: timestamp, current and compatibility versions. */
|
||||
export const dylibCommand = (
|
||||
command: number,
|
||||
name: string,
|
||||
versions: { readonly current?: number; readonly compatibility?: number } = {},
|
||||
): Uint8Array =>
|
||||
commandWithString(
|
||||
command,
|
||||
[2, versions.current ?? 0x10000, versions.compatibility ?? 0x10000],
|
||||
name,
|
||||
);
|
||||
|
||||
/** `dylib_use_command`: marker, versions and `DYLIB_USE_*` flags. */
|
||||
export const dylibUseCommand = (
|
||||
command: number,
|
||||
name: string,
|
||||
flags: number,
|
||||
): Uint8Array =>
|
||||
commandWithString(command, [0x1a741800, 0x10000, 0x10000, flags], name);
|
||||
|
||||
export const rpathCommand = (path: string): Uint8Array =>
|
||||
commandWithString(LC.RPATH, [], path);
|
||||
|
||||
export const dyldEnvironmentCommand = (value: string): Uint8Array =>
|
||||
commandWithString(LC.DYLD_ENVIRONMENT, [], value);
|
||||
|
||||
export const codeSignatureCommand = (): Uint8Array => {
|
||||
const bytes = new Uint8Array(16);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, LC.CODE_SIGNATURE, true);
|
||||
view.setUint32(4, 16, true);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** A 64-bit (or 32-bit) little-endian Mach-O image made of the given commands. */
|
||||
export const machoImage = (options: {
|
||||
readonly cpu?: { readonly type: number; readonly subtype: number };
|
||||
readonly fileType?: number;
|
||||
readonly commands?: readonly Uint8Array[];
|
||||
readonly wide?: boolean;
|
||||
}): Uint8Array => {
|
||||
const wide = options.wide ?? true;
|
||||
const commands = options.commands ?? [];
|
||||
const headerSize = wide ? 32 : 28;
|
||||
const commandBytes = commands.reduce((total, item) => total + item.length, 0);
|
||||
const bytes = new Uint8Array(headerSize + commandBytes + 16);
|
||||
const view = new DataView(bytes.buffer);
|
||||
const cpu = options.cpu ?? CPU.arm64;
|
||||
view.setUint32(0, wide ? 0xfeedfacf : 0xfeedface, true);
|
||||
view.setUint32(4, cpu.type, true);
|
||||
view.setUint32(8, cpu.subtype, true);
|
||||
view.setUint32(12, options.fileType ?? FILE_TYPE.execute, true);
|
||||
view.setUint32(16, commands.length, true);
|
||||
view.setUint32(20, commandBytes, true);
|
||||
let offset = headerSize;
|
||||
for (const command of commands) {
|
||||
bytes.set(command, offset);
|
||||
offset += command.length;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** A big-endian FAT container whose slices start on 4 KiB boundaries. */
|
||||
export const fatImage = (
|
||||
slices: readonly {
|
||||
readonly cpu: { readonly type: number; readonly subtype: number };
|
||||
readonly bytes: Uint8Array;
|
||||
}[],
|
||||
wide = false,
|
||||
): Uint8Array => {
|
||||
const entrySize = wide ? 32 : 20;
|
||||
const offsets: number[] = [];
|
||||
let end = 4096;
|
||||
for (const slice of slices) {
|
||||
offsets.push(end);
|
||||
end += Math.ceil(slice.bytes.length / 4096) * 4096;
|
||||
}
|
||||
const bytes = new Uint8Array(end);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, wide ? 0xcafebabf : 0xcafebabe, false);
|
||||
view.setUint32(4, slices.length, false);
|
||||
slices.forEach((slice, index) => {
|
||||
const base = 8 + index * entrySize;
|
||||
const offset = offsets[index] ?? 0;
|
||||
view.setUint32(base, slice.cpu.type, false);
|
||||
view.setUint32(base + 4, slice.cpu.subtype, false);
|
||||
if (wide) {
|
||||
view.setBigUint64(base + 8, BigInt(offset), false);
|
||||
view.setBigUint64(base + 16, BigInt(slice.bytes.length), false);
|
||||
view.setUint32(base + 24, 12, false);
|
||||
} else {
|
||||
view.setUint32(base + 8, offset, false);
|
||||
view.setUint32(base + 12, slice.bytes.length, false);
|
||||
view.setUint32(base + 16, 12, false);
|
||||
}
|
||||
bytes.set(slice.bytes, offset);
|
||||
});
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** In-memory `ReadAt` over one buffer. */
|
||||
export const readerOf =
|
||||
(bytes: Uint8Array) =>
|
||||
(offset: number, length: number): Promise<Uint8Array> =>
|
||||
Promise.resolve(bytes.subarray(offset, offset + length));
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CPU,
|
||||
FILE_TYPE,
|
||||
LC,
|
||||
codeSignatureCommand,
|
||||
dyldEnvironmentCommand,
|
||||
dylibCommand,
|
||||
dylibUseCommand,
|
||||
fatImage,
|
||||
machoImage,
|
||||
readerOf,
|
||||
rpathCommand,
|
||||
} from "./MachoImage.fixture.js";
|
||||
import {
|
||||
MAX_LOAD_COMMAND_BYTES,
|
||||
hasMachoMagic,
|
||||
readMachoImage,
|
||||
} from "./MachoLoadCommandReader.js";
|
||||
|
||||
const read = (bytes: Uint8Array) =>
|
||||
readMachoImage(readerOf(bytes), bytes.length);
|
||||
|
||||
describe("Mach-O load command reader", () => {
|
||||
it("decodes dylib loading commands of a thin image", async () => {
|
||||
const image = machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [
|
||||
dylibCommand(LC.ID_DYLIB, "@rpath/Core.framework/Core"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib", {
|
||||
current: 0x050f0400,
|
||||
compatibility: 0x10000,
|
||||
}),
|
||||
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libweak.dylib"),
|
||||
dylibCommand(LC.REEXPORT_DYLIB, "@loader_path/libre.dylib"),
|
||||
dylibCommand(LC.LOAD_UPWARD_DYLIB, "@rpath/libup.dylib"),
|
||||
dylibCommand(LC.LAZY_LOAD_DYLIB, "@rpath/liblazy.dylib"),
|
||||
rpathCommand("@loader_path/Frameworks"),
|
||||
rpathCommand("/usr/lib/swift"),
|
||||
dyldEnvironmentCommand("DYLD_FRAMEWORK_PATH=@executable_path"),
|
||||
codeSignatureCommand(),
|
||||
],
|
||||
});
|
||||
const facts = await read(image);
|
||||
expect(facts.status).toBe("parsed");
|
||||
if (facts.status !== "parsed") return;
|
||||
expect(facts.slices).toHaveLength(1);
|
||||
const [slice] = facts.slices;
|
||||
expect(slice).toMatchObject({
|
||||
architecture: "arm64",
|
||||
file_type: "dylib",
|
||||
install_name: "@rpath/Core.framework/Core",
|
||||
rpaths: ["@loader_path/Frameworks", "/usr/lib/swift"],
|
||||
dyld_environment: ["DYLD_FRAMEWORK_PATH=@executable_path"],
|
||||
code_signature_present: true,
|
||||
});
|
||||
expect(
|
||||
slice?.dependencies.map(
|
||||
({ command, install_name: name, weak, reexport, upward }) => [
|
||||
command,
|
||||
name,
|
||||
weak,
|
||||
reexport,
|
||||
upward,
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
["LC_LOAD_DYLIB", "/usr/lib/libSystem.B.dylib", false, false, false],
|
||||
["LC_LOAD_WEAK_DYLIB", "@rpath/libweak.dylib", true, false, false],
|
||||
["LC_REEXPORT_DYLIB", "@loader_path/libre.dylib", false, true, false],
|
||||
["LC_LOAD_UPWARD_DYLIB", "@rpath/libup.dylib", false, false, true],
|
||||
["LC_LAZY_LOAD_DYLIB", "@rpath/liblazy.dylib", false, false, false],
|
||||
]);
|
||||
expect(slice?.dependencies[0]).toMatchObject({
|
||||
encoding: "dylib_command",
|
||||
current_version: "1295.4.0",
|
||||
compatibility_version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes dylib_use_command flags", async () => {
|
||||
const facts = await read(
|
||||
machoImage({
|
||||
commands: [
|
||||
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libdelay.dylib", 0x8),
|
||||
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libweakup.dylib", 0x1 | 0x4),
|
||||
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libre.dylib", 0x2),
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (facts.status !== "parsed") throw new Error(facts.status);
|
||||
expect(
|
||||
facts.slices[0]?.dependencies.map(
|
||||
({ encoding, weak, upward, reexport, delayed_init: delayed }) => ({
|
||||
encoding,
|
||||
weak,
|
||||
upward,
|
||||
reexport,
|
||||
delayed,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
encoding: "dylib_use_command",
|
||||
weak: false,
|
||||
upward: false,
|
||||
reexport: false,
|
||||
delayed: true,
|
||||
},
|
||||
{
|
||||
encoding: "dylib_use_command",
|
||||
weak: true,
|
||||
upward: true,
|
||||
reexport: false,
|
||||
delayed: false,
|
||||
},
|
||||
{
|
||||
encoding: "dylib_use_command",
|
||||
weak: false,
|
||||
upward: false,
|
||||
reexport: true,
|
||||
delayed: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads every slice of FAT and FAT_64 containers", async () => {
|
||||
for (const wide of [false, true]) {
|
||||
const facts = await read(
|
||||
fatImage(
|
||||
[
|
||||
{
|
||||
cpu: CPU.arm64e,
|
||||
bytes: machoImage({
|
||||
cpu: CPU.arm64e,
|
||||
commands: [rpathCommand("@executable_path/a")],
|
||||
}),
|
||||
},
|
||||
{
|
||||
cpu: CPU.x86_64,
|
||||
bytes: machoImage({
|
||||
cpu: CPU.x86_64,
|
||||
commands: [rpathCommand("@executable_path/b")],
|
||||
}),
|
||||
},
|
||||
],
|
||||
wide,
|
||||
),
|
||||
);
|
||||
if (facts.status !== "parsed") throw new Error(facts.status);
|
||||
expect(
|
||||
facts.slices.map(({ architecture, rpaths }) => [architecture, rpaths]),
|
||||
).toEqual([
|
||||
["arm64e", ["@executable_path/a"]],
|
||||
["x86_64", ["@executable_path/b"]],
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mach-O load command reader inputs", () => {
|
||||
it("names 32-bit and non-Mach-O inputs without guessing", async () => {
|
||||
const i386 = await read(
|
||||
machoImage({ cpu: { type: 7, subtype: 3 }, wide: false }),
|
||||
);
|
||||
expect(i386.status === "parsed" && i386.slices[0]?.architecture).toBe(
|
||||
"i386",
|
||||
);
|
||||
// A Java class file shares the FAT magic; its version is not a slice count.
|
||||
const javaClass = Uint8Array.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34]);
|
||||
expect(hasMachoMagic(javaClass)).toBe(true);
|
||||
expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
|
||||
expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({
|
||||
status: "not-mach-o",
|
||||
});
|
||||
expect(await read(Uint8Array.from([0xcf, 0xfa]))).toEqual({
|
||||
status: "not-mach-o",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mach-O load command reader failures", () => {
|
||||
const withCommandSize = (size: number): Uint8Array => {
|
||||
const image = machoImage({ commands: [rpathCommand("@loader_path")] });
|
||||
new DataView(image.buffer).setUint32(32 + 4, size, true);
|
||||
return image;
|
||||
};
|
||||
|
||||
it("reports malformed command tables with the failing command", async () => {
|
||||
expect(await read(withCommandSize(0))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("load command 0"),
|
||||
});
|
||||
expect(await read(withCommandSize(4096))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("invalid cmdsize"),
|
||||
});
|
||||
const extraCommand = machoImage({ commands: [rpathCommand("x")] });
|
||||
new DataView(extraCommand.buffer).setUint32(16, 2, true);
|
||||
expect(await read(extraCommand)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "load command 1 starts beyond sizeofcmds",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects strings that are unterminated, misplaced or not UTF-8", async () => {
|
||||
const unterminated = rpathCommand("abcd");
|
||||
unterminated.fill(0x41, 12);
|
||||
expect(await read(machoImage({ commands: [unterminated] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("unterminated"),
|
||||
});
|
||||
const misplaced = rpathCommand("abcd");
|
||||
new DataView(misplaced.buffer).setUint32(8, 4, true);
|
||||
expect(await read(machoImage({ commands: [misplaced] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("string offset 4"),
|
||||
});
|
||||
const invalid = rpathCommand("ab");
|
||||
invalid[12] = 0xff;
|
||||
expect(await read(machoImage({ commands: [invalid] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("not UTF-8"),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses truncated, oversized, big-endian and out-of-range images", async () => {
|
||||
const image = machoImage({ commands: [rpathCommand("@loader_path")] });
|
||||
expect(await read(image.subarray(0, 20))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "Mach-O header is truncated",
|
||||
});
|
||||
const oversized = machoImage({});
|
||||
new DataView(oversized.buffer).setUint32(
|
||||
20,
|
||||
MAX_LOAD_COMMAND_BYTES + 8,
|
||||
true,
|
||||
);
|
||||
expect(await read(oversized)).toMatchObject({ status: "unsupported" });
|
||||
const beyond = machoImage({ commands: [rpathCommand("x")] });
|
||||
new DataView(beyond.buffer).setUint32(20, beyond.length, true);
|
||||
expect(await read(beyond)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "load commands extend beyond the Mach-O slice",
|
||||
});
|
||||
const bigEndian = Uint8Array.from([0xfe, 0xed, 0xfa, 0xcf, 0, 0, 0, 0]);
|
||||
expect(await read(bigEndian)).toMatchObject({ status: "unsupported" });
|
||||
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
|
||||
expect(await read(fat.subarray(0, 4100))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "FAT architecture 0 extends beyond the file",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import type {
|
||||
MachoDependency,
|
||||
MachoImageFacts,
|
||||
MachoSlice,
|
||||
} from "../domain/dylibResolution.js";
|
||||
|
||||
/** Read `length` bytes at `offset`; shorter results mean end of file. */
|
||||
export type ReadAt = (offset: number, length: number) => Promise<Uint8Array>;
|
||||
|
||||
/** Load commands larger than this are refused instead of buffered. */
|
||||
export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024;
|
||||
/** Real universal binaries carry a handful of slices; Java class files share the FAT magic. */
|
||||
const MAX_FAT_ARCHITECTURES = 20;
|
||||
|
||||
const MH_MAGIC = 0xfeedface;
|
||||
const MH_MAGIC_64 = 0xfeedfacf;
|
||||
const MH_CIGAM = 0xcefaedfe;
|
||||
const MH_CIGAM_64 = 0xcffaedfe;
|
||||
const FAT_MAGIC = 0xcafebabe;
|
||||
const FAT_MAGIC_64 = 0xcafebabf;
|
||||
|
||||
const LC_ID_DYLIB = 0xd;
|
||||
const LC_LOAD_DYLIB = 0xc;
|
||||
const LC_LOAD_WEAK_DYLIB = 0x80000018;
|
||||
const LC_REEXPORT_DYLIB = 0x8000001f;
|
||||
const LC_LAZY_LOAD_DYLIB = 0x20;
|
||||
const LC_LOAD_UPWARD_DYLIB = 0x80000023;
|
||||
const LC_RPATH = 0x8000001c;
|
||||
const LC_DYLD_ENVIRONMENT = 0x27;
|
||||
const LC_CODE_SIGNATURE = 0x1d;
|
||||
|
||||
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
|
||||
const DYLIB_USE_MARKER = 0x1a741800;
|
||||
const DYLIB_USE_WEAK_LINK = 0x1;
|
||||
const DYLIB_USE_REEXPORT = 0x2;
|
||||
const DYLIB_USE_UPWARD = 0x4;
|
||||
const DYLIB_USE_DELAYED_INIT = 0x8;
|
||||
|
||||
const DEPENDENCY_COMMANDS: ReadonlyMap<number, MachoDependency["command"]> =
|
||||
new Map([
|
||||
[LC_LOAD_DYLIB, "LC_LOAD_DYLIB"],
|
||||
[LC_LOAD_WEAK_DYLIB, "LC_LOAD_WEAK_DYLIB"],
|
||||
[LC_REEXPORT_DYLIB, "LC_REEXPORT_DYLIB"],
|
||||
[LC_LAZY_LOAD_DYLIB, "LC_LAZY_LOAD_DYLIB"],
|
||||
[LC_LOAD_UPWARD_DYLIB, "LC_LOAD_UPWARD_DYLIB"],
|
||||
]);
|
||||
|
||||
/** Malformed or unsupported structure found while decoding one image. */
|
||||
class MachoFormatIssue extends Error {
|
||||
constructor(
|
||||
readonly status: "malformed" | "unsupported",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Report whether the first bytes carry a Mach-O or FAT magic number. */
|
||||
export const hasMachoMagic = (head: Uint8Array): boolean => {
|
||||
if (head.byteLength < 4) return false;
|
||||
const magic = new DataView(
|
||||
head.buffer,
|
||||
head.byteOffset,
|
||||
head.byteLength,
|
||||
).getUint32(0, false);
|
||||
return [
|
||||
MH_MAGIC,
|
||||
MH_MAGIC_64,
|
||||
MH_CIGAM,
|
||||
MH_CIGAM_64,
|
||||
FAT_MAGIC,
|
||||
FAT_MAGIC_64,
|
||||
].includes(magic);
|
||||
};
|
||||
|
||||
/** Decode every slice's dylib-loading load commands without reading code. */
|
||||
export const readMachoImage = async (
|
||||
readAt: ReadAt,
|
||||
size: number,
|
||||
): Promise<MachoImageFacts> => {
|
||||
try {
|
||||
const head = await readAt(0, Math.min(size, 8));
|
||||
if (head.byteLength < 8) return { status: "not-mach-o" };
|
||||
const view = viewOf(head);
|
||||
const magic = view.getUint32(0, false);
|
||||
if (magic === FAT_MAGIC || magic === FAT_MAGIC_64) {
|
||||
const count = view.getUint32(4, false);
|
||||
if (count === 0 || count >= MAX_FAT_ARCHITECTURES)
|
||||
return { status: "not-mach-o" };
|
||||
return {
|
||||
status: "parsed",
|
||||
slices: await readFatSlices(
|
||||
readAt,
|
||||
size,
|
||||
magic === FAT_MAGIC_64,
|
||||
count,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (magic === MH_CIGAM || magic === MH_CIGAM_64)
|
||||
return { status: "parsed", slices: [await readSlice(readAt, 0, size)] };
|
||||
if (magic === MH_MAGIC || magic === MH_MAGIC_64)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
"big-endian Mach-O images are not supported",
|
||||
);
|
||||
return { status: "not-mach-o" };
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof MachoFormatIssue)
|
||||
return { status: cause.status, reason: cause.message };
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const readFatSlices = async (
|
||||
readAt: ReadAt,
|
||||
size: number,
|
||||
wide: boolean,
|
||||
count: number,
|
||||
): Promise<MachoSlice[]> => {
|
||||
const entrySize = wide ? 32 : 20;
|
||||
const table = await readExact(readAt, 8, count * entrySize, "FAT table");
|
||||
const view = viewOf(table);
|
||||
const slices: MachoSlice[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const base = index * entrySize;
|
||||
const offset = wide
|
||||
? safeNumber(view.getBigUint64(base + 8, false))
|
||||
: view.getUint32(base + 8, false);
|
||||
const length = wide
|
||||
? safeNumber(view.getBigUint64(base + 16, false))
|
||||
: view.getUint32(base + 12, false);
|
||||
if (offset + length > size)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`FAT architecture ${index} extends beyond the file`,
|
||||
);
|
||||
slices.push(await readSlice(readAt, offset, length));
|
||||
}
|
||||
return slices;
|
||||
};
|
||||
|
||||
const readSlice = async (
|
||||
readAt: ReadAt,
|
||||
base: number,
|
||||
length: number,
|
||||
): Promise<MachoSlice> => {
|
||||
const header = viewOf(await readExact(readAt, base, 28, "Mach-O header"));
|
||||
const magic = header.getUint32(0, true);
|
||||
if (magic === MH_MAGIC_64 || magic === MH_MAGIC) {
|
||||
const wide = magic === MH_MAGIC_64;
|
||||
const headerSize = wide ? 32 : 28;
|
||||
const commandCount = header.getUint32(16, true);
|
||||
const commandBytes = header.getUint32(20, true);
|
||||
if (commandBytes > MAX_LOAD_COMMAND_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`load commands occupy ${commandBytes} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
|
||||
);
|
||||
if (headerSize + commandBytes > length)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
"load commands extend beyond the Mach-O slice",
|
||||
);
|
||||
const commands = await readExact(
|
||||
readAt,
|
||||
base + headerSize,
|
||||
commandBytes,
|
||||
"load commands",
|
||||
);
|
||||
return decodeCommands(commands, {
|
||||
cpuType: header.getUint32(4, true),
|
||||
cpuSubtype: header.getUint32(8, true),
|
||||
fileType: header.getUint32(12, true),
|
||||
commandCount,
|
||||
});
|
||||
}
|
||||
throw new MachoFormatIssue(
|
||||
magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64)
|
||||
? "unsupported"
|
||||
: "malformed",
|
||||
magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64)
|
||||
? "big-endian Mach-O slices are not supported"
|
||||
: `slice at offset ${base} has no Mach-O header`,
|
||||
);
|
||||
};
|
||||
|
||||
const decodeCommands = (
|
||||
bytes: Uint8Array,
|
||||
header: {
|
||||
readonly cpuType: number;
|
||||
readonly cpuSubtype: number;
|
||||
readonly fileType: number;
|
||||
readonly commandCount: number;
|
||||
},
|
||||
): MachoSlice => {
|
||||
const view = viewOf(bytes);
|
||||
const slice: MachoSlice = {
|
||||
architecture: architectureName(header.cpuType, header.cpuSubtype),
|
||||
file_type: fileTypeName(header.fileType),
|
||||
install_name: null,
|
||||
dependencies: [],
|
||||
rpaths: [],
|
||||
dyld_environment: [],
|
||||
code_signature_present: false,
|
||||
};
|
||||
let offset = 0;
|
||||
for (let index = 0; index < header.commandCount; index++) {
|
||||
if (offset + 8 > bytes.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} starts beyond sizeofcmds`,
|
||||
);
|
||||
const command = view.getUint32(offset, true);
|
||||
const size = view.getUint32(offset + 4, true);
|
||||
if (size < 8 || size % 4 !== 0 || offset + size > bytes.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
|
||||
);
|
||||
const body = bytes.subarray(offset, offset + size);
|
||||
decodeCommand(command, body, index, slice);
|
||||
offset += size;
|
||||
}
|
||||
return slice;
|
||||
};
|
||||
|
||||
const decodeCommand = (
|
||||
command: number,
|
||||
body: Uint8Array,
|
||||
index: number,
|
||||
slice: MachoSlice,
|
||||
): void => {
|
||||
if (command === LC_ID_DYLIB) {
|
||||
slice.install_name = commandString(body, 24, index);
|
||||
return;
|
||||
}
|
||||
if (command === LC_RPATH) {
|
||||
slice.rpaths.push(commandString(body, 12, index));
|
||||
return;
|
||||
}
|
||||
if (command === LC_DYLD_ENVIRONMENT) {
|
||||
slice.dyld_environment.push(commandString(body, 12, index));
|
||||
return;
|
||||
}
|
||||
if (command === LC_CODE_SIGNATURE) {
|
||||
slice.code_signature_present = true;
|
||||
return;
|
||||
}
|
||||
const name = DEPENDENCY_COMMANDS.get(command);
|
||||
if (name !== undefined)
|
||||
slice.dependencies.push(decodeDependency(name, body, index));
|
||||
};
|
||||
|
||||
const decodeDependency = (
|
||||
command: MachoDependency["command"],
|
||||
body: Uint8Array,
|
||||
index: number,
|
||||
): MachoDependency => {
|
||||
if (body.byteLength < 24)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} is too short for a dylib command`,
|
||||
);
|
||||
const view = viewOf(body);
|
||||
const nameOffset = view.getUint32(8, true);
|
||||
const usesFlags =
|
||||
nameOffset === 28 &&
|
||||
body.byteLength >= 28 &&
|
||||
view.getUint32(12, true) === DYLIB_USE_MARKER;
|
||||
const flags = usesFlags ? view.getUint32(24, true) : 0;
|
||||
return {
|
||||
command,
|
||||
encoding: usesFlags ? "dylib_use_command" : "dylib_command",
|
||||
install_name: commandString(body, usesFlags ? 28 : 24, index),
|
||||
weak:
|
||||
command === "LC_LOAD_WEAK_DYLIB" || (flags & DYLIB_USE_WEAK_LINK) !== 0,
|
||||
upward:
|
||||
command === "LC_LOAD_UPWARD_DYLIB" || (flags & DYLIB_USE_UPWARD) !== 0,
|
||||
reexport:
|
||||
command === "LC_REEXPORT_DYLIB" || (flags & DYLIB_USE_REEXPORT) !== 0,
|
||||
delayed_init: (flags & DYLIB_USE_DELAYED_INIT) !== 0,
|
||||
current_version: version(view.getUint32(16, true)),
|
||||
compatibility_version: version(view.getUint32(20, true)),
|
||||
};
|
||||
};
|
||||
|
||||
/** NUL-terminated UTF-8 string at a `lc_str` offset inside one command. */
|
||||
const commandString = (
|
||||
body: Uint8Array,
|
||||
minimumOffset: number,
|
||||
index: number,
|
||||
): string => {
|
||||
const offset = viewOf(body).getUint32(8, true);
|
||||
if (offset < minimumOffset || offset >= body.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} has string offset ${offset} outside the command`,
|
||||
);
|
||||
const end = body.indexOf(0, offset);
|
||||
if (end < 0)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} has an unterminated string`,
|
||||
);
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
body.subarray(offset, end),
|
||||
);
|
||||
} catch {
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} has a string that is not UTF-8`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const readExact = async (
|
||||
readAt: ReadAt,
|
||||
offset: number,
|
||||
length: number,
|
||||
label: string,
|
||||
): Promise<Uint8Array> => {
|
||||
const bytes = await readAt(offset, length);
|
||||
if (bytes.byteLength < length)
|
||||
throw new MachoFormatIssue("malformed", `${label} is truncated`);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const CPU_ARCH_ABI64 = 0x01000000;
|
||||
const CPU_ARCH_ABI64_32 = 0x02000000;
|
||||
const CPU_TYPE_X86 = 7;
|
||||
const CPU_TYPE_ARM = 12;
|
||||
const CPU_SUBTYPE_MASK = 0xff000000;
|
||||
const CPU_SUBTYPE_ARM64E = 2;
|
||||
const CPU_SUBTYPE_X86_64_H = 8;
|
||||
|
||||
const architectureName = (cpuType: number, cpuSubtype: number): string => {
|
||||
const subtype = cpuSubtype & ~CPU_SUBTYPE_MASK;
|
||||
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64))
|
||||
return subtype === CPU_SUBTYPE_ARM64E ? "arm64e" : "arm64";
|
||||
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64_32)) return "arm64_32";
|
||||
if (cpuType === (CPU_TYPE_X86 | CPU_ARCH_ABI64))
|
||||
return subtype === CPU_SUBTYPE_X86_64_H ? "x86_64h" : "x86_64";
|
||||
if (cpuType === CPU_TYPE_X86) return "i386";
|
||||
if (cpuType === CPU_TYPE_ARM) return "arm";
|
||||
return `cpu-${cpuType.toString(16)}`;
|
||||
};
|
||||
|
||||
const fileTypeName = (fileType: number): MachoSlice["file_type"] =>
|
||||
fileType === 2
|
||||
? "execute"
|
||||
: fileType === 6
|
||||
? "dylib"
|
||||
: fileType === 8
|
||||
? "bundle"
|
||||
: "other";
|
||||
|
||||
const version = (value: number): string =>
|
||||
`${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`;
|
||||
|
||||
const swap = (value: number): number =>
|
||||
(((value & 0xff) << 24) |
|
||||
((value & 0xff00) << 8) |
|
||||
((value >>> 8) & 0xff00) |
|
||||
(value >>> 24)) >>>
|
||||
0;
|
||||
|
||||
const safeNumber = (value: bigint): number => {
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER))
|
||||
throw new MachoFormatIssue("malformed", "FAT offset exceeds safe range");
|
||||
return Number(value);
|
||||
};
|
||||
|
||||
const viewOf = (bytes: Uint8Array): DataView =>
|
||||
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
@@ -15,6 +15,7 @@ export const registerArtifactCommands = (
|
||||
registerExtractionCommand(cli, logger);
|
||||
registerInterfaceBuilderCommand(cli, logger);
|
||||
registerAssetCatalogCommand(cli, logger);
|
||||
registerDylibResolutionCommand(cli, logger);
|
||||
cli.command(CLI_COMMANDS.inspectKeyedArchive, {
|
||||
description:
|
||||
"Inspect a Foundation keyed archive as an object-reference graph",
|
||||
@@ -58,6 +59,45 @@ export const registerArtifactCommands = (
|
||||
});
|
||||
};
|
||||
|
||||
const registerDylibResolutionCommand = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.traceDylibResolution, {
|
||||
description:
|
||||
"Trace dyld load-path resolution for a Mach-O or every executable in an app bundle",
|
||||
args: z.object({
|
||||
path: z.string().describe("Mach-O file or Apple .app bundle path"),
|
||||
}),
|
||||
options: z.object({
|
||||
root: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Process root relative to the bundle or the Mach-O's directory (repeatable)",
|
||||
),
|
||||
architecture: z
|
||||
.enum(["arm64", "arm64e", "x86_64"])
|
||||
.optional()
|
||||
.describe("Trace only this slice of each root"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.traceDylibResolution, () =>
|
||||
runProviderAnalysis(
|
||||
args.path,
|
||||
"trace_dylib_resolution",
|
||||
{
|
||||
...(options.root === undefined ? {} : { roots: options.root }),
|
||||
...(options.architecture === undefined
|
||||
? {}
|
||||
: { architecture: options.architecture }),
|
||||
},
|
||||
logger,
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const registerAssetCatalogCommand = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
|
||||
@@ -25,6 +25,7 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
extractArtifact: "extract-artifact",
|
||||
decodeInterfaceBuilder: "decode-interface-builder",
|
||||
inspectKeyedArchive: "inspect-keyed-archive",
|
||||
traceDylibResolution: "trace-dylib-resolution",
|
||||
inspectNativeDataType: "inspect-native-data-type",
|
||||
inspectNativeLoadImage: "inspect-native-load-image",
|
||||
readBytes: "read-bytes",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js";
|
||||
import { appleAssetCatalogInputSchema } from "../domain/appleAssetCatalog.js";
|
||||
import { dylibResolutionInputSchema } from "../domain/dylibResolution.js";
|
||||
import { keyedArchiveInputSchema } from "../domain/keyedArchive.js";
|
||||
/** Exact caller boundary for deterministic artifact inventory. */
|
||||
export const artifactInventoryInputSchema = z.strictObject({
|
||||
@@ -27,6 +28,10 @@ const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
|
||||
decode_interface_builder: {},
|
||||
inspect_asset_catalog: { offset: 0, limit: 1000 },
|
||||
inspect_keyed_archive: { path: "Contents/Resources/Model.plist" },
|
||||
trace_dylib_resolution: {
|
||||
roots: ["Contents/MacOS/App"],
|
||||
architecture: "arm64",
|
||||
},
|
||||
};
|
||||
|
||||
const artifact = <
|
||||
@@ -85,6 +90,11 @@ export const ARTIFACT_TOOL_CONTRACTS = [
|
||||
"Inspect compiled Assets.car metadata in an active Apple app bundle. Returns stable paginated catalog and rendition records with raw assetutil metadata and catalog digests; rendition bytes are not extracted.",
|
||||
appleAssetCatalogInputSchema,
|
||||
),
|
||||
artifact(
|
||||
"trace_dylib_resolution",
|
||||
"Trace how dyld would resolve each Mach-O dylib load for the active Mach-O, or for every executable in the active .app bundle (the main app, XPC services, extensions, login items, helpers). Expands @executable_path, @loader_path, and @rpath through each process's LC_RPATH stack in dependents-first load order, and reports every candidate path with its outcome, the resolved image and digest, weak, re-export, and upward flags, and derived findings such as unresolved required loads or absent earlier @rpath candidates. Pure header parsing: nothing is executed, and absolute or system paths outside the analyzed root stay undetermined. Select roots or one architecture to narrow large bundles.",
|
||||
dylibResolutionInputSchema,
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Provider operations; inventory remains an internal primitive of inspection. */
|
||||
|
||||
@@ -186,6 +186,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
decode_interface_builder: evidence,
|
||||
inspect_asset_catalog: nativeEvidence,
|
||||
inspect_keyed_archive: evidence,
|
||||
trace_dylib_resolution: evidence,
|
||||
inspect_managed_artifact: evidence,
|
||||
inspect_managed_members: evidence,
|
||||
inspect_managed_native_boundaries: evidence,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { artifactExtractionResultSchema } from "../domain/artifactGraph.js";
|
||||
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||
import { interfaceBuilderAnalysisSchema } from "../domain/interfaceBuilderGraph.js";
|
||||
import { dylibResolutionResultSchema } from "../domain/dylibResolution.js";
|
||||
import { keyedArchiveResultSchema } from "../domain/keyedArchive.js";
|
||||
import { appleAssetCatalogResultSchema } from "../domain/appleAssetCatalog.js";
|
||||
import {
|
||||
@@ -336,6 +337,7 @@ export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
decode_interface_builder: resultOf(interfaceBuilderAnalysisSchema),
|
||||
inspect_keyed_archive: resultOf(keyedArchiveResultSchema),
|
||||
inspect_asset_catalog: resultOf(appleAssetCatalogResultSchema),
|
||||
trace_dylib_resolution: resultOf(dylibResolutionResultSchema),
|
||||
};
|
||||
|
||||
/** Exact Evidence schema for execution-free managed static analysis. */
|
||||
|
||||
@@ -12,6 +12,7 @@ export class ArtifactOperationError extends AnalysisError {
|
||||
| "decode_interface_builder"
|
||||
| "inspect_asset_catalog"
|
||||
| "inspect_keyed_archive"
|
||||
| "trace_dylib_resolution"
|
||||
| "export_web_scripts"
|
||||
| "trace_web_module_imports"
|
||||
| "trace_web_source_location"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { DylibTreeEntry, DylibTreeView } from "./dylibResolution.js";
|
||||
|
||||
const MAX_SYMLINK_HOPS = 32;
|
||||
|
||||
export type PathLookup =
|
||||
| { readonly kind: "file"; readonly path: string }
|
||||
| { readonly kind: "absent" | "escapes" | "not-file" };
|
||||
|
||||
/**
|
||||
* Resolve a root-relative path one segment at a time, as `open` would:
|
||||
* symlinks are followed inside the root, `..` applies to the real parent,
|
||||
* and any escape above the root or through an absolute link stops.
|
||||
*/
|
||||
export const resolveTreePath = async (
|
||||
view: DylibTreeView,
|
||||
input: string,
|
||||
): Promise<PathLookup> => {
|
||||
const pending = segmentsOf(input);
|
||||
const resolved: string[] = [];
|
||||
let last: DylibTreeEntry | undefined;
|
||||
let hops = 0;
|
||||
while (pending.length > 0) {
|
||||
const segment = pending.shift() ?? "";
|
||||
if (segment === "..") {
|
||||
if (resolved.length === 0) return { kind: "escapes" };
|
||||
resolved.pop();
|
||||
last = { kind: "directory" };
|
||||
continue;
|
||||
}
|
||||
const path = [...resolved, segment].join("/");
|
||||
const entry = await view.entry(path);
|
||||
if (entry === undefined) return { kind: "absent" };
|
||||
if (entry.kind === "symlink") {
|
||||
hops += 1;
|
||||
if (hops > MAX_SYMLINK_HOPS) return { kind: "absent" };
|
||||
if (entry.target.startsWith("/")) return { kind: "escapes" };
|
||||
pending.unshift(...segmentsOf(entry.target));
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === "file" && pending.length > 0) return { kind: "absent" };
|
||||
resolved.push(segment);
|
||||
last = entry;
|
||||
}
|
||||
return last?.kind === "file"
|
||||
? { kind: "file", path: resolved.join("/") }
|
||||
: { kind: "not-file" };
|
||||
};
|
||||
|
||||
const segmentsOf = (path: string): string[] =>
|
||||
path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
||||
|
||||
export const directoryOf = (path: string): string => {
|
||||
const index = path.lastIndexOf("/");
|
||||
return index < 0 ? "" : path.slice(0, index);
|
||||
};
|
||||
|
||||
export const joinPath = (directory: string, rest: string): string =>
|
||||
directory === "" ? rest : rest === "" ? directory : `${directory}/${rest}`;
|
||||
|
||||
export type Expansion =
|
||||
| { readonly scope: "target"; readonly path: string }
|
||||
| { readonly scope: "outside"; readonly path: string }
|
||||
| { readonly scope: "undetermined"; readonly path: string };
|
||||
|
||||
/** Expand one dyld path prefix relative to its owning image. */
|
||||
export const expandPrefix = (
|
||||
raw: string,
|
||||
context: {
|
||||
readonly ownerDirectory: string;
|
||||
readonly executable: string | null;
|
||||
},
|
||||
): Expansion => {
|
||||
const variable = /^@(executable_path|loader_path)(?:\/(.*))?$/u.exec(raw);
|
||||
if (variable !== null) {
|
||||
const rest = variable[2] ?? "";
|
||||
if (variable[1] === "loader_path")
|
||||
return { scope: "target", path: joinPath(context.ownerDirectory, rest) };
|
||||
return context.executable === null
|
||||
? { scope: "undetermined", path: raw }
|
||||
: {
|
||||
scope: "target",
|
||||
path: joinPath(directoryOf(context.executable), rest),
|
||||
};
|
||||
}
|
||||
if (raw.startsWith("/")) return { scope: "outside", path: raw };
|
||||
return { scope: "undetermined", path: raw };
|
||||
};
|
||||
@@ -0,0 +1,440 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
traceDylibLoading,
|
||||
type DylibTreeEntry,
|
||||
type DylibTreeView,
|
||||
type MachoDependency,
|
||||
type MachoImageFacts,
|
||||
type MachoSlice,
|
||||
} from "./dylibResolution.js";
|
||||
|
||||
const slice = (overrides: Partial<MachoSlice> = {}): MachoSlice => ({
|
||||
architecture: "arm64",
|
||||
file_type: "dylib",
|
||||
install_name: null,
|
||||
dependencies: [],
|
||||
rpaths: [],
|
||||
dyld_environment: [],
|
||||
code_signature_present: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const dependency = (
|
||||
installName: string,
|
||||
overrides: Partial<MachoDependency> = {},
|
||||
): MachoDependency => ({
|
||||
command: "LC_LOAD_DYLIB",
|
||||
encoding: "dylib_command",
|
||||
install_name: installName,
|
||||
weak: false,
|
||||
upward: false,
|
||||
reexport: false,
|
||||
delayed_init: false,
|
||||
current_version: "1.0.0",
|
||||
compatibility_version: "1.0.0",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const parsed = (...slices: MachoSlice[]): MachoImageFacts => ({
|
||||
status: "parsed",
|
||||
slices,
|
||||
});
|
||||
|
||||
const executable = (overrides: Partial<MachoSlice> = {}): MachoImageFacts =>
|
||||
parsed(slice({ file_type: "execute", ...overrides }));
|
||||
|
||||
/** In-memory tree: files are images (or "data"), symlinks map to targets. */
|
||||
const memoryView = (
|
||||
files: Readonly<Record<string, MachoImageFacts | "data">>,
|
||||
symlinks: Readonly<Record<string, string>> = {},
|
||||
): DylibTreeView => {
|
||||
const entries = new Map<string, DylibTreeEntry>();
|
||||
const addParents = (path: string): void => {
|
||||
const segments = path.split("/");
|
||||
for (let length = 1; length < segments.length; length++)
|
||||
entries.set(segments.slice(0, length).join("/"), { kind: "directory" });
|
||||
};
|
||||
for (const path of Object.keys(files)) {
|
||||
addParents(path);
|
||||
entries.set(path, { kind: "file" });
|
||||
}
|
||||
for (const [path, target] of Object.entries(symlinks)) {
|
||||
addParents(path);
|
||||
entries.set(path, { kind: "symlink", target });
|
||||
}
|
||||
return {
|
||||
entry: (path) => Promise.resolve(entries.get(path)),
|
||||
image: (path) => {
|
||||
const facts = files[path];
|
||||
return Promise.resolve(
|
||||
facts === undefined || facts === "data"
|
||||
? { status: "not-mach-o" as const }
|
||||
: facts,
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const MAIN = "Contents/MacOS/App";
|
||||
|
||||
const edgeFor = (
|
||||
trace: Awaited<ReturnType<typeof traceDylibLoading>>,
|
||||
loader: string,
|
||||
installName: string,
|
||||
) => {
|
||||
const edge = trace.edges.find(
|
||||
(candidate) =>
|
||||
candidate.loader === loader && candidate.install_name === installName,
|
||||
);
|
||||
if (edge === undefined)
|
||||
throw new Error(`missing ${loader} -> ${installName}`);
|
||||
return edge;
|
||||
};
|
||||
|
||||
describe("dyld path expansion", () => {
|
||||
it("searches the loading image's rpaths before its loaders'", async () => {
|
||||
const core = "Contents/Frameworks/Core.framework/Versions/A/Core";
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: ["@executable_path/../Frameworks"],
|
||||
dependencies: [dependency("@rpath/Core.framework/Versions/A/Core")],
|
||||
}),
|
||||
[core]: parsed(
|
||||
slice({
|
||||
rpaths: ["@loader_path/Libraries"],
|
||||
dependencies: [dependency("@rpath/libchain.dylib")],
|
||||
}),
|
||||
),
|
||||
"Contents/Frameworks/libchain.dylib": parsed(slice()),
|
||||
"Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib":
|
||||
parsed(slice()),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
const chain = edgeFor(trace, core, "@rpath/libchain.dylib");
|
||||
expect(chain.via).toEqual([MAIN, core]);
|
||||
expect(chain.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
source: "rpath",
|
||||
rpath: "@loader_path/Libraries",
|
||||
rpath_owner: core,
|
||||
outcome: "resolved",
|
||||
}),
|
||||
]);
|
||||
expect(chain.resolution).toEqual({
|
||||
status: "resolved",
|
||||
image:
|
||||
"Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib",
|
||||
});
|
||||
});
|
||||
|
||||
it("expands @loader_path in an rpath relative to the image that owns it", async () => {
|
||||
const plugin = "Contents/PlugIns/Tool.bundle/Contents/MacOS/Tool";
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: ["@loader_path/../Shared"],
|
||||
dependencies: [
|
||||
dependency(
|
||||
`@executable_path/../PlugIns/Tool.bundle/Contents/MacOS/Tool`,
|
||||
),
|
||||
],
|
||||
}),
|
||||
[plugin]: parsed(
|
||||
slice({
|
||||
file_type: "bundle",
|
||||
dependencies: [dependency("@rpath/libshared.dylib")],
|
||||
}),
|
||||
),
|
||||
"Contents/Shared/libshared.dylib": parsed(slice()),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(edgeFor(trace, plugin, "@rpath/libshared.dylib").candidates).toEqual(
|
||||
[
|
||||
expect.objectContaining({
|
||||
path: "Contents/MacOS/../Shared/libshared.dylib",
|
||||
rpath_owner: MAIN,
|
||||
outcome: "resolved",
|
||||
resolved_path: "Contents/Shared/libshared.dylib",
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("gives each executable root its own @executable_path", async () => {
|
||||
const service = "Contents/XPCServices/Fetch.xpc/Contents/MacOS/Fetch";
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
dependencies: [dependency("@executable_path/libhere.dylib")],
|
||||
}),
|
||||
[service]: executable({
|
||||
dependencies: [dependency("@executable_path/libhere.dylib")],
|
||||
}),
|
||||
"Contents/MacOS/libhere.dylib": parsed(slice()),
|
||||
}),
|
||||
{ roots: [MAIN, service] },
|
||||
);
|
||||
expect(
|
||||
edgeFor(trace, MAIN, "@executable_path/libhere.dylib").resolution.status,
|
||||
).toBe("resolved");
|
||||
expect(
|
||||
edgeFor(trace, service, "@executable_path/libhere.dylib"),
|
||||
).toMatchObject({
|
||||
candidates: [
|
||||
{
|
||||
path: "Contents/XPCServices/Fetch.xpc/Contents/MacOS/libhere.dylib",
|
||||
outcome: "absent",
|
||||
},
|
||||
],
|
||||
resolution: { status: "unresolved", image: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("follows Versions/Current inside the root and stops at escaping links", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView(
|
||||
{
|
||||
[MAIN]: executable({
|
||||
rpaths: ["@executable_path/../Frameworks"],
|
||||
dependencies: [
|
||||
dependency("@rpath/Core.framework/Core"),
|
||||
dependency("@rpath/Escape.framework/Escape"),
|
||||
dependency("@executable_path/../../../outside.dylib"),
|
||||
],
|
||||
}),
|
||||
"Contents/Frameworks/Core.framework/Versions/A/Core": parsed(slice()),
|
||||
},
|
||||
{
|
||||
"Contents/Frameworks/Core.framework/Versions/Current": "A",
|
||||
"Contents/Frameworks/Core.framework/Core": "Versions/Current/Core",
|
||||
"Contents/Frameworks/Escape.framework":
|
||||
"/Library/Frameworks/Escape.framework",
|
||||
},
|
||||
),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(
|
||||
edgeFor(trace, MAIN, "@rpath/Core.framework/Core").resolution,
|
||||
).toEqual({
|
||||
status: "resolved",
|
||||
image: "Contents/Frameworks/Core.framework/Versions/A/Core",
|
||||
});
|
||||
for (const name of [
|
||||
"@rpath/Escape.framework/Escape",
|
||||
"@executable_path/../../../outside.dylib",
|
||||
])
|
||||
expect(edgeFor(trace, MAIN, name)).toMatchObject({
|
||||
candidates: [{ outcome: "escapes-target" }],
|
||||
resolution: { status: "undetermined", image: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dyld resolution outcomes", () => {
|
||||
it("keeps paths outside the root undetermined and in-root fallbacks conditional", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: ["/usr/lib/swift", "@executable_path/../Frameworks"],
|
||||
dependencies: [
|
||||
dependency("/usr/lib/libSystem.B.dylib"),
|
||||
dependency("@rpath/libswiftCore.dylib"),
|
||||
dependency("libleaf.dylib"),
|
||||
],
|
||||
}),
|
||||
"Contents/Frameworks/libswiftCore.dylib": parsed(slice()),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(edgeFor(trace, MAIN, "/usr/lib/libSystem.B.dylib")).toMatchObject({
|
||||
candidates: [
|
||||
{ outcome: "outside-target", path: "/usr/lib/libSystem.B.dylib" },
|
||||
],
|
||||
resolution: { status: "undetermined", image: null },
|
||||
});
|
||||
expect(edgeFor(trace, MAIN, "@rpath/libswiftCore.dylib")).toMatchObject({
|
||||
candidates: [
|
||||
{
|
||||
outcome: "outside-target",
|
||||
path: "/usr/lib/swift/libswiftCore.dylib",
|
||||
},
|
||||
{ outcome: "resolved" },
|
||||
],
|
||||
resolution: {
|
||||
status: "conditional",
|
||||
image: "Contents/Frameworks/libswiftCore.dylib",
|
||||
},
|
||||
});
|
||||
expect(edgeFor(trace, MAIN, "libleaf.dylib").resolution.status).toBe(
|
||||
"undetermined",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives unresolved and earlier-candidate findings separately for weak loads", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: [
|
||||
"@executable_path/../Overrides",
|
||||
"@executable_path/../Frameworks",
|
||||
],
|
||||
dependencies: [
|
||||
dependency("@rpath/libfound.dylib"),
|
||||
dependency("@rpath/libgone.dylib", {
|
||||
command: "LC_LOAD_WEAK_DYLIB",
|
||||
weak: true,
|
||||
}),
|
||||
dependency("@rpath/libmissing.dylib"),
|
||||
],
|
||||
dyld_environment: ["DYLD_LIBRARY_PATH=/tmp"],
|
||||
}),
|
||||
"Contents/Frameworks/libfound.dylib": parsed(slice()),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(
|
||||
trace.findings.map(({ kind, edge_index: index }) => [kind, index]),
|
||||
).toEqual([
|
||||
["earlier-rpath-candidate-absent", 0],
|
||||
["weak-load-unresolved", 1],
|
||||
["required-load-unresolved", 2],
|
||||
["dyld-environment-present", null],
|
||||
]);
|
||||
expect(trace.findings[0]?.explanation).toContain(
|
||||
"Contents/MacOS/../Overrides/libfound.dylib",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports non-Mach-O, malformed and wrong-architecture candidates", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: [
|
||||
"@executable_path/a",
|
||||
"@executable_path/b",
|
||||
"@executable_path/c",
|
||||
],
|
||||
dependencies: [dependency("@rpath/lib.dylib")],
|
||||
}),
|
||||
"Contents/MacOS/a/lib.dylib": "data",
|
||||
"Contents/MacOS/b/lib.dylib": {
|
||||
status: "malformed",
|
||||
reason: "load command 0 has invalid cmdsize 0",
|
||||
},
|
||||
"Contents/MacOS/c/lib.dylib": parsed(slice({ architecture: "x86_64" })),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(
|
||||
edgeFor(trace, MAIN, "@rpath/lib.dylib").candidates.map(
|
||||
({ outcome }) => outcome,
|
||||
),
|
||||
).toEqual(["not-mach-o", "malformed", "architecture-missing"]);
|
||||
expect(trace.coverage).toEqual({
|
||||
status: "partial",
|
||||
unparsed_images: ["Contents/MacOS/b/lib.dylib"],
|
||||
roots_without_architecture: [],
|
||||
});
|
||||
expect(trace.images.map(({ path }) => path)).toEqual([
|
||||
MAIN,
|
||||
"Contents/MacOS/b/lib.dylib",
|
||||
"Contents/MacOS/c/lib.dylib",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dyld load order", () => {
|
||||
it("loads each image once per process and reuses matching install names", async () => {
|
||||
const a = "Contents/Frameworks/libA.dylib";
|
||||
const b = "Contents/Frameworks/libB.dylib";
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: ["@executable_path/../Frameworks"],
|
||||
dependencies: [
|
||||
dependency("@rpath/libA.dylib"),
|
||||
dependency("@rpath/libB.dylib"),
|
||||
],
|
||||
}),
|
||||
[a]: parsed(
|
||||
slice({
|
||||
install_name: "@rpath/libA.dylib",
|
||||
dependencies: [
|
||||
dependency("@rpath/libB.dylib"),
|
||||
dependency("@rpath/libA.dylib"),
|
||||
],
|
||||
}),
|
||||
),
|
||||
[b]: parsed(
|
||||
slice({
|
||||
install_name: "@rpath/libB.dylib",
|
||||
dependencies: [dependency("@rpath/libA.dylib")],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(
|
||||
trace.edges.map(
|
||||
({ loader, install_name: name }) =>
|
||||
`${loader.split("/").at(-1)}>${name}`,
|
||||
),
|
||||
).toEqual([
|
||||
"App>@rpath/libA.dylib",
|
||||
"App>@rpath/libB.dylib",
|
||||
"libA.dylib>@rpath/libB.dylib",
|
||||
"libA.dylib>@rpath/libA.dylib",
|
||||
"libB.dylib>@rpath/libA.dylib",
|
||||
]);
|
||||
expect(edgeFor(trace, b, "@rpath/libA.dylib").candidates).toEqual([
|
||||
expect.objectContaining({ source: "already-loaded", resolved_path: a }),
|
||||
]);
|
||||
expect(edgeFor(trace, a, "@rpath/libA.dylib").install_name_matches).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves @executable_path undetermined for a library root and filters architectures", async () => {
|
||||
const library = "Contents/Frameworks/libroot.dylib";
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[library]: parsed(
|
||||
slice({ dependencies: [dependency("@executable_path/x.dylib")] }),
|
||||
),
|
||||
[MAIN]: executable(),
|
||||
}),
|
||||
{ roots: [library, MAIN], architecture: "x86_64" },
|
||||
);
|
||||
expect(trace.roots).toEqual([]);
|
||||
expect(trace.coverage.roots_without_architecture).toEqual([library, MAIN]);
|
||||
const arm = await traceDylibLoading(
|
||||
memoryView({
|
||||
[library]: parsed(
|
||||
slice({ dependencies: [dependency("@executable_path/x.dylib")] }),
|
||||
),
|
||||
}),
|
||||
{ roots: [library] },
|
||||
);
|
||||
expect(arm.edges[0]?.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
source: "executable_path",
|
||||
outcome: "undetermined",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops when cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(
|
||||
traceDylibLoading(memoryView({ [MAIN]: executable() }), {
|
||||
roots: [MAIN],
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,542 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
directoryOf,
|
||||
expandPrefix,
|
||||
joinPath,
|
||||
resolveTreePath,
|
||||
type Expansion,
|
||||
} from "./dyldPaths.js";
|
||||
import { digestSchema } from "./digests.js";
|
||||
|
||||
/** Normalized path below the analyzed root: no `.`/`..` or empty segments. */
|
||||
const ROOT_RELATIVE_PATH =
|
||||
/^(?!\/)(?!(?:.*\/)?\.{1,2}(?:\/|$))(?!.*\/\/)(?!.*\/$)[^\\\0]+$/u;
|
||||
const rootRelativePathSchema = z.string().min(1).regex(ROOT_RELATIVE_PATH);
|
||||
|
||||
/** Caller selection of process roots and one CPU slice. */
|
||||
export const dylibResolutionInputSchema = z.strictObject({
|
||||
roots: z.array(rootRelativePathSchema).min(1).optional(),
|
||||
architecture: z.enum(["arm64", "arm64e", "x86_64"]).optional(),
|
||||
});
|
||||
|
||||
const dependencyCommandSchema = z.enum([
|
||||
"LC_LOAD_DYLIB",
|
||||
"LC_LOAD_WEAK_DYLIB",
|
||||
"LC_REEXPORT_DYLIB",
|
||||
"LC_LAZY_LOAD_DYLIB",
|
||||
"LC_LOAD_UPWARD_DYLIB",
|
||||
]);
|
||||
|
||||
const dependencySchema = z.strictObject({
|
||||
command: dependencyCommandSchema,
|
||||
encoding: z.enum(["dylib_command", "dylib_use_command"]),
|
||||
install_name: z.string(),
|
||||
weak: z.boolean(),
|
||||
upward: z.boolean(),
|
||||
reexport: z.boolean(),
|
||||
delayed_init: z.boolean(),
|
||||
current_version: z.string(),
|
||||
compatibility_version: z.string(),
|
||||
});
|
||||
|
||||
/** Observed dylib-loading facts of one Mach-O slice. */
|
||||
export const machoSliceSchema = z.strictObject({
|
||||
architecture: z.string().min(1),
|
||||
file_type: z.enum(["execute", "dylib", "bundle", "other"]),
|
||||
install_name: z.string().nullable(),
|
||||
dependencies: z.array(dependencySchema),
|
||||
rpaths: z.array(z.string()),
|
||||
dyld_environment: z.array(z.string()),
|
||||
code_signature_present: z.boolean(),
|
||||
});
|
||||
|
||||
export type MachoDependency = z.infer<typeof dependencySchema>;
|
||||
export type MachoSlice = z.infer<typeof machoSliceSchema>;
|
||||
|
||||
/** Header parse of one file; malformed and unsupported images keep their reason. */
|
||||
export type MachoImageFacts =
|
||||
| { readonly status: "parsed"; readonly slices: readonly MachoSlice[] }
|
||||
| {
|
||||
readonly status: "malformed" | "unsupported";
|
||||
readonly reason: string;
|
||||
}
|
||||
| { readonly status: "not-mach-o" };
|
||||
|
||||
/** One observed path below the analyzed root. Symlink targets are not followed by the view. */
|
||||
export type DylibTreeEntry =
|
||||
| { readonly kind: "file" | "directory" }
|
||||
| { readonly kind: "symlink"; readonly target: string };
|
||||
|
||||
/** Read-only, lazily probed view of the analyzed root. Paths are root-relative. */
|
||||
export interface DylibTreeView {
|
||||
entry(path: string): Promise<DylibTreeEntry | undefined>;
|
||||
image(path: string): Promise<MachoImageFacts>;
|
||||
}
|
||||
|
||||
const candidateSchema = z.strictObject({
|
||||
path: z.string(),
|
||||
source: z.enum([
|
||||
"literal",
|
||||
"executable_path",
|
||||
"loader_path",
|
||||
"rpath",
|
||||
"already-loaded",
|
||||
]),
|
||||
rpath: z.string().nullable(),
|
||||
rpath_owner: z.string().nullable(),
|
||||
outcome: z.enum([
|
||||
"resolved",
|
||||
"absent",
|
||||
"not-mach-o",
|
||||
"malformed",
|
||||
"architecture-missing",
|
||||
"outside-target",
|
||||
"escapes-target",
|
||||
"undetermined",
|
||||
]),
|
||||
resolved_path: z.string().nullable(),
|
||||
});
|
||||
|
||||
const edgeSchema = z.strictObject({
|
||||
root: z.string(),
|
||||
architecture: z.string(),
|
||||
loader: z.string(),
|
||||
via: z.array(z.string()),
|
||||
...dependencySchema.shape,
|
||||
candidates: z.array(candidateSchema),
|
||||
resolution: z.strictObject({
|
||||
status: z.enum(["resolved", "conditional", "unresolved", "undetermined"]),
|
||||
image: z.string().nullable(),
|
||||
}),
|
||||
install_name_matches: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
const findingSchema = z.strictObject({
|
||||
kind: z.enum([
|
||||
"required-load-unresolved",
|
||||
"weak-load-unresolved",
|
||||
"earlier-rpath-candidate-absent",
|
||||
"dyld-environment-present",
|
||||
]),
|
||||
edge_index: z.number().int().nonnegative().nullable(),
|
||||
image: z.string().nullable(),
|
||||
basis: z.literal("derived"),
|
||||
explanation: z.string().min(1),
|
||||
});
|
||||
|
||||
const imageSchema = z.strictObject({
|
||||
path: z.string(),
|
||||
sha256: digestSchema,
|
||||
parse_status: z.enum(["parsed", "malformed", "unsupported"]),
|
||||
reason: z.string().nullable(),
|
||||
slices: z.array(machoSliceSchema.omit({ dependencies: true })),
|
||||
});
|
||||
|
||||
/** Static dyld load-path resolution for every selected process root. */
|
||||
export const dylibResolutionResultSchema = z.strictObject({
|
||||
root_path: z.string(),
|
||||
target_sha256: digestSchema,
|
||||
roots: z.array(
|
||||
z.strictObject({ image: z.string(), architecture: z.string() }),
|
||||
),
|
||||
images: z.array(imageSchema),
|
||||
edges: z.array(edgeSchema),
|
||||
findings: z.array(findingSchema),
|
||||
coverage: z.strictObject({
|
||||
status: z.enum(["complete", "partial"]),
|
||||
unparsed_images: z.array(z.string()),
|
||||
roots_without_architecture: z.array(z.string()),
|
||||
}),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
export type DylibResolutionResult = z.infer<typeof dylibResolutionResultSchema>;
|
||||
type Candidate = z.infer<typeof candidateSchema>;
|
||||
type Edge = z.infer<typeof edgeSchema>;
|
||||
type Finding = z.infer<typeof findingSchema>;
|
||||
|
||||
/** Result before the adapter adds file digests and the analyzed root. */
|
||||
export type DylibTrace = Omit<
|
||||
DylibResolutionResult,
|
||||
"root_path" | "target_sha256" | "images"
|
||||
> & {
|
||||
readonly images: readonly (Omit<
|
||||
DylibResolutionResult["images"][number],
|
||||
"sha256"
|
||||
> & { readonly sha256?: never })[];
|
||||
};
|
||||
|
||||
interface LoadedImage {
|
||||
readonly slice: MachoSlice;
|
||||
/** Images from the process root to this image; the rpath stack, outermost first. */
|
||||
readonly chain: readonly string[];
|
||||
}
|
||||
|
||||
interface ProcessContext {
|
||||
readonly view: DylibTreeView;
|
||||
readonly root: string;
|
||||
readonly architecture: string;
|
||||
readonly executable: string | null;
|
||||
readonly loaded: Map<string, LoadedImage>;
|
||||
readonly byInstallName: Map<string, string>;
|
||||
readonly images: Map<string, MachoImageFacts>;
|
||||
}
|
||||
|
||||
interface CandidateTemplate {
|
||||
readonly expansion: Expansion;
|
||||
readonly source: Candidate["source"];
|
||||
readonly rpath: string | null;
|
||||
readonly rpathOwner: string | null;
|
||||
}
|
||||
|
||||
const candidateTemplates = (
|
||||
dependency: MachoDependency,
|
||||
loader: string,
|
||||
context: ProcessContext,
|
||||
): CandidateTemplate[] => {
|
||||
const name = dependency.install_name;
|
||||
if (!name.startsWith("@rpath/")) {
|
||||
const source = name.startsWith("@executable_path")
|
||||
? "executable_path"
|
||||
: name.startsWith("@loader_path")
|
||||
? "loader_path"
|
||||
: "literal";
|
||||
return [
|
||||
{
|
||||
expansion: expandPrefix(name, {
|
||||
ownerDirectory: directoryOf(loader),
|
||||
executable: context.executable,
|
||||
}),
|
||||
source,
|
||||
rpath: null,
|
||||
rpathOwner: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
const rest = name.slice("@rpath/".length);
|
||||
const owners = [...(context.loaded.get(loader)?.chain ?? [loader])].reverse();
|
||||
return owners.flatMap((owner) =>
|
||||
(context.loaded.get(owner)?.slice.rpaths ?? []).map((rpath) => {
|
||||
const base = expandPrefix(rpath, {
|
||||
ownerDirectory: directoryOf(owner),
|
||||
executable: context.executable,
|
||||
});
|
||||
const expansion: Expansion = {
|
||||
scope: base.scope,
|
||||
path:
|
||||
base.scope === "outside"
|
||||
? `${base.path.replace(/\/+$/u, "")}/${rest}`
|
||||
: joinPath(base.path, rest),
|
||||
};
|
||||
return { expansion, source: "rpath" as const, rpath, rpathOwner: owner };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const evaluateCandidate = async (
|
||||
template: CandidateTemplate,
|
||||
context: ProcessContext,
|
||||
): Promise<Candidate> => {
|
||||
const base = {
|
||||
path: template.expansion.path,
|
||||
source: template.source,
|
||||
rpath: template.rpath,
|
||||
rpath_owner: template.rpathOwner,
|
||||
};
|
||||
if (template.expansion.scope === "outside")
|
||||
return { ...base, outcome: "outside-target", resolved_path: null };
|
||||
if (template.expansion.scope === "undetermined")
|
||||
return { ...base, outcome: "undetermined", resolved_path: null };
|
||||
const lookup = await resolveTreePath(context.view, template.expansion.path);
|
||||
if (lookup.kind === "escapes")
|
||||
return { ...base, outcome: "escapes-target", resolved_path: null };
|
||||
if (lookup.kind !== "file")
|
||||
return { ...base, outcome: "absent", resolved_path: null };
|
||||
const facts = await imageFacts(context, lookup.path);
|
||||
const outcome: Candidate["outcome"] =
|
||||
facts.status === "not-mach-o"
|
||||
? "not-mach-o"
|
||||
: facts.status !== "parsed"
|
||||
? "malformed"
|
||||
: facts.slices.some(
|
||||
({ architecture }) => architecture === context.architecture,
|
||||
)
|
||||
? "resolved"
|
||||
: "architecture-missing";
|
||||
return { ...base, outcome, resolved_path: lookup.path };
|
||||
};
|
||||
|
||||
const imageFacts = async (
|
||||
context: ProcessContext,
|
||||
path: string,
|
||||
): Promise<MachoImageFacts> => {
|
||||
const known = context.images.get(path);
|
||||
if (known !== undefined) return known;
|
||||
const facts = await context.view.image(path);
|
||||
context.images.set(path, facts);
|
||||
return facts;
|
||||
};
|
||||
|
||||
const resolution = (candidates: readonly Candidate[]): Edge["resolution"] => {
|
||||
let external = false;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.outcome === "resolved")
|
||||
return {
|
||||
status: external ? "conditional" : "resolved",
|
||||
image: candidate.resolved_path,
|
||||
};
|
||||
if (
|
||||
candidate.outcome === "outside-target" ||
|
||||
candidate.outcome === "escapes-target" ||
|
||||
candidate.outcome === "undetermined"
|
||||
)
|
||||
external = true;
|
||||
}
|
||||
return { status: external ? "undetermined" : "unresolved", image: null };
|
||||
};
|
||||
|
||||
const resolveDependency = async (
|
||||
dependency: MachoDependency,
|
||||
loader: string,
|
||||
context: ProcessContext,
|
||||
): Promise<Edge> => {
|
||||
const chain = context.loaded.get(loader)?.chain ?? [loader];
|
||||
const loaded = context.byInstallName.get(dependency.install_name);
|
||||
const candidates: Candidate[] = [];
|
||||
if (loaded !== undefined)
|
||||
candidates.push({
|
||||
path: dependency.install_name,
|
||||
source: "already-loaded",
|
||||
rpath: null,
|
||||
rpath_owner: null,
|
||||
outcome: "resolved",
|
||||
resolved_path: loaded,
|
||||
});
|
||||
else
|
||||
for (const template of candidateTemplates(dependency, loader, context)) {
|
||||
const candidate = await evaluateCandidate(template, context);
|
||||
candidates.push(candidate);
|
||||
// dyld stops at the first loadable candidate.
|
||||
if (candidate.outcome === "resolved") break;
|
||||
}
|
||||
const resolved = resolution(candidates);
|
||||
const image =
|
||||
resolved.image === null ? undefined : context.images.get(resolved.image);
|
||||
const slice =
|
||||
image?.status === "parsed"
|
||||
? image.slices.find(
|
||||
({ architecture }) => architecture === context.architecture,
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
root: context.root,
|
||||
architecture: context.architecture,
|
||||
loader,
|
||||
via: [...chain],
|
||||
...dependency,
|
||||
candidates,
|
||||
resolution: resolved,
|
||||
install_name_matches:
|
||||
slice === undefined || slice.install_name === null
|
||||
? null
|
||||
: slice.install_name === dependency.install_name,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Follow dyld4's dependents-first order: resolve every dependency of one
|
||||
* image, then recurse into the images it newly loaded, in load-command order.
|
||||
*/
|
||||
const traceProcess = async (
|
||||
context: ProcessContext,
|
||||
rootSlice: MachoSlice,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Edge[]> => {
|
||||
const edges: Edge[] = [];
|
||||
context.loaded.set(context.root, { slice: rootSlice, chain: [context.root] });
|
||||
if (rootSlice.install_name !== null)
|
||||
context.byInstallName.set(rootSlice.install_name, context.root);
|
||||
const pending = [context.root];
|
||||
while (pending.length > 0) {
|
||||
signal?.throwIfAborted();
|
||||
const loader = pending.pop() ?? "";
|
||||
const current = context.loaded.get(loader);
|
||||
if (current === undefined) continue;
|
||||
const created: string[] = [];
|
||||
for (const dependency of current.slice.dependencies) {
|
||||
const edge = await resolveDependency(dependency, loader, context);
|
||||
edges.push(edge);
|
||||
const image = edge.resolution.image;
|
||||
if (image === null || context.loaded.has(image)) continue;
|
||||
const facts = context.images.get(image);
|
||||
const slice =
|
||||
facts?.status === "parsed"
|
||||
? facts.slices.find(
|
||||
({ architecture }) => architecture === context.architecture,
|
||||
)
|
||||
: undefined;
|
||||
if (slice === undefined) continue;
|
||||
context.loaded.set(image, { slice, chain: [...current.chain, image] });
|
||||
if (
|
||||
slice.install_name !== null &&
|
||||
!context.byInstallName.has(slice.install_name)
|
||||
)
|
||||
context.byInstallName.set(slice.install_name, image);
|
||||
created.push(image);
|
||||
}
|
||||
pending.push(...created.reverse());
|
||||
}
|
||||
return edges;
|
||||
};
|
||||
|
||||
/** Trace dyld load paths from each process root through the analyzed root. */
|
||||
export const traceDylibLoading = async (
|
||||
view: DylibTreeView,
|
||||
request: {
|
||||
readonly roots: readonly string[];
|
||||
readonly architecture?: string;
|
||||
readonly signal?: AbortSignal;
|
||||
},
|
||||
): Promise<DylibTrace> => {
|
||||
const images = new Map<string, MachoImageFacts>();
|
||||
const roots: DylibTrace["roots"][number][] = [];
|
||||
const edges: Edge[] = [];
|
||||
const withoutArchitecture: string[] = [];
|
||||
for (const root of request.roots) {
|
||||
const facts = await view.image(root);
|
||||
images.set(root, facts);
|
||||
if (facts.status !== "parsed") continue;
|
||||
const slices = facts.slices.filter(
|
||||
({ architecture }) =>
|
||||
request.architecture === undefined ||
|
||||
architecture === request.architecture,
|
||||
);
|
||||
if (slices.length === 0) withoutArchitecture.push(root);
|
||||
for (const slice of slices) {
|
||||
roots.push({ image: root, architecture: slice.architecture });
|
||||
edges.push(
|
||||
...(await traceProcess(
|
||||
{
|
||||
view,
|
||||
root,
|
||||
architecture: slice.architecture,
|
||||
executable: slice.file_type === "execute" ? root : null,
|
||||
loaded: new Map(),
|
||||
byInstallName: new Map(),
|
||||
images,
|
||||
},
|
||||
slice,
|
||||
request.signal,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
const reported = [...images.entries()]
|
||||
.filter(([, facts]) => facts.status !== "not-mach-o")
|
||||
.sort(([left], [right]) => compare(left, right));
|
||||
const unparsed = reported
|
||||
.filter(([, facts]) => facts.status !== "parsed")
|
||||
.map(([path]) => path);
|
||||
return {
|
||||
roots,
|
||||
images: reported.map(([path, facts]) => ({
|
||||
path,
|
||||
parse_status:
|
||||
facts.status === "not-mach-o" ? "unsupported" : facts.status,
|
||||
reason:
|
||||
facts.status === "parsed" || facts.status === "not-mach-o"
|
||||
? null
|
||||
: facts.reason,
|
||||
slices:
|
||||
facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [],
|
||||
})),
|
||||
edges,
|
||||
findings: deriveFindings(edges, roots, images),
|
||||
coverage: {
|
||||
status:
|
||||
unparsed.length === 0 && withoutArchitecture.length === 0
|
||||
? "complete"
|
||||
: "partial",
|
||||
unparsed_images: unparsed,
|
||||
roots_without_architecture: withoutArchitecture,
|
||||
},
|
||||
limitations: DYLIB_RESOLUTION_LIMITATIONS,
|
||||
};
|
||||
};
|
||||
|
||||
const withoutDependencies = (
|
||||
slice: MachoSlice,
|
||||
): Omit<MachoSlice, "dependencies"> => ({
|
||||
architecture: slice.architecture,
|
||||
file_type: slice.file_type,
|
||||
install_name: slice.install_name,
|
||||
rpaths: slice.rpaths,
|
||||
dyld_environment: slice.dyld_environment,
|
||||
code_signature_present: slice.code_signature_present,
|
||||
});
|
||||
|
||||
const deriveFindings = (
|
||||
edges: readonly Edge[],
|
||||
roots: DylibTrace["roots"],
|
||||
images: ReadonlyMap<string, MachoImageFacts>,
|
||||
): Finding[] => {
|
||||
const findings: Finding[] = [];
|
||||
edges.forEach((edge, index) => {
|
||||
if (edge.resolution.status === "unresolved")
|
||||
findings.push({
|
||||
kind: edge.weak ? "weak-load-unresolved" : "required-load-unresolved",
|
||||
edge_index: index,
|
||||
image: edge.loader,
|
||||
basis: "derived",
|
||||
explanation: edge.weak
|
||||
? `No candidate for weak dependency ${edge.install_name} exists in the analyzed root; dyld continues without it.`
|
||||
: `No candidate for ${edge.install_name} exists in the analyzed root; dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`,
|
||||
});
|
||||
const resolvedAt = edge.candidates.findIndex(
|
||||
({ outcome }) => outcome === "resolved",
|
||||
);
|
||||
const earlier = edge.candidates
|
||||
.slice(0, Math.max(resolvedAt, 0))
|
||||
.filter(
|
||||
({ source, outcome }) => source === "rpath" && outcome === "absent",
|
||||
)
|
||||
.map(({ path }) => path);
|
||||
if (resolvedAt > 0 && earlier.length > 0)
|
||||
findings.push({
|
||||
kind: "earlier-rpath-candidate-absent",
|
||||
edge_index: index,
|
||||
image: edge.loader,
|
||||
basis: "derived",
|
||||
explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`,
|
||||
});
|
||||
});
|
||||
for (const { image, architecture } of roots) {
|
||||
const facts = images.get(image);
|
||||
const environment =
|
||||
facts?.status === "parsed"
|
||||
? (facts.slices.find((slice) => slice.architecture === architecture)
|
||||
?.dyld_environment ?? [])
|
||||
: [];
|
||||
if (environment.length > 0)
|
||||
findings.push({
|
||||
kind: "dyld-environment-present",
|
||||
edge_index: null,
|
||||
image,
|
||||
basis: "derived",
|
||||
explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); search paths they add are not modeled.`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
};
|
||||
|
||||
const DYLIB_RESOLUTION_LIMITATIONS = [
|
||||
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
|
||||
"Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
|
||||
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack of the first chain; a later request whose install name matches an already loaded image reuses it.",
|
||||
"@loader_path uses each image's symlink-resolved path within the analyzed root.",
|
||||
"Code-signing checks that can reject a found image, such as library validation and the hardened runtime, are not evaluated.",
|
||||
];
|
||||
|
||||
const compare = (left: string, right: string): number =>
|
||||
left < right ? -1 : left > right ? 1 : 0;
|
||||
@@ -30,7 +30,7 @@ interface GeneratedPayload {
|
||||
}
|
||||
|
||||
const GENERATED_PAYLOAD_JSON =
|
||||
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
|
||||
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
|
||||
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
||||
if (!isGeneratedPayload(generatedPayload))
|
||||
throw new TypeError("Generated MCP catalog payload is invalid");
|
||||
|
||||
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
|
||||
serverSdkVersion: "2.3.1",
|
||||
clientSdkVersion: "2.3.1",
|
||||
coreSdkVersion: "2.3.1",
|
||||
skillVersion: "27",
|
||||
skillVersion: "28",
|
||||
} as const;
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import {
|
||||
FILE_TYPE,
|
||||
LC,
|
||||
dylibCommand,
|
||||
machoImage,
|
||||
rpathCommand,
|
||||
} from "../../../src/artifacts/MachoImage.fixture.js";
|
||||
import { dylibResolutionResultSchema } from "../../../src/domain/dylibResolution.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const ENVIRONMENT = { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" };
|
||||
|
||||
const writeFiles = async (
|
||||
root: string,
|
||||
files: Readonly<Record<string, string | Uint8Array>>,
|
||||
): Promise<void> => {
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
await mkdir(dirname(join(root, path)), { recursive: true });
|
||||
await writeFile(join(root, path), content);
|
||||
}
|
||||
};
|
||||
|
||||
const fixtureApp = async (): Promise<string> => {
|
||||
const app = join(
|
||||
await createTestTempDirectory("rea-dylib-cli-"),
|
||||
"Fixture.app",
|
||||
);
|
||||
await writeFiles(app, {
|
||||
"Contents/Info.plist":
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
|
||||
"Contents/MacOS/App": machoImage({
|
||||
commands: [
|
||||
rpathCommand("@executable_path/../Frameworks"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
|
||||
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"),
|
||||
],
|
||||
}),
|
||||
"Contents/Frameworks/libcore.dylib": machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib")],
|
||||
}),
|
||||
"Contents/Helpers/tool": machoImage({
|
||||
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib")],
|
||||
}),
|
||||
});
|
||||
return app;
|
||||
};
|
||||
|
||||
describe("trace-dylib-resolution CLI", () => {
|
||||
cliTest("traces every executable root of an app bundle", async ({ cli }) => {
|
||||
const app = await fixtureApp();
|
||||
const result = await cli.run({
|
||||
arguments: ["trace-dylib-resolution", app, "--json"],
|
||||
environment: ENVIRONMENT,
|
||||
});
|
||||
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
||||
const trace = dylibResolutionResultSchema.parse(
|
||||
(result.json as { normalized_result: unknown }).normalized_result,
|
||||
);
|
||||
expect(trace.root_path).toBe(app);
|
||||
expect(trace.roots.map(({ image }) => image)).toEqual([
|
||||
"Contents/Helpers/tool",
|
||||
"Contents/MacOS/App",
|
||||
]);
|
||||
expect(
|
||||
trace.edges.map(({ loader, install_name: name, resolution }) => [
|
||||
loader,
|
||||
name,
|
||||
resolution.status,
|
||||
resolution.image,
|
||||
]),
|
||||
).toEqual([
|
||||
[
|
||||
"Contents/Helpers/tool",
|
||||
"@loader_path/libnone.dylib",
|
||||
"unresolved",
|
||||
null,
|
||||
],
|
||||
[
|
||||
"Contents/MacOS/App",
|
||||
"@rpath/libcore.dylib",
|
||||
"resolved",
|
||||
"Contents/Frameworks/libcore.dylib",
|
||||
],
|
||||
["Contents/MacOS/App", "@rpath/libgone.dylib", "unresolved", null],
|
||||
]);
|
||||
expect(trace.findings.map(({ kind }) => kind)).toEqual([
|
||||
"required-load-unresolved",
|
||||
"weak-load-unresolved",
|
||||
]);
|
||||
expect(
|
||||
trace.images.every(({ sha256 }) => /^[0-9a-f]{64}$/u.test(sha256)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"narrows roots and architecture, and rejects escaping roots",
|
||||
async ({ cli }) => {
|
||||
const app = await fixtureApp();
|
||||
const narrowed = await cli.run({
|
||||
arguments: [
|
||||
"trace-dylib-resolution",
|
||||
app,
|
||||
"--root",
|
||||
"Contents/MacOS/App",
|
||||
"--architecture",
|
||||
"arm64",
|
||||
"--json",
|
||||
],
|
||||
environment: ENVIRONMENT,
|
||||
});
|
||||
expect(narrowed.exitCode).toBe(0);
|
||||
expect(
|
||||
(narrowed.json as { normalized_result: { roots: unknown } })
|
||||
.normalized_result.roots,
|
||||
).toEqual([{ image: "Contents/MacOS/App", architecture: "arm64" }]);
|
||||
for (const root of ["../outside", "Contents/Info.plist"]) {
|
||||
const rejected = await cli.run({
|
||||
arguments: ["trace-dylib-resolution", app, "--root", root, "--json"],
|
||||
environment: ENVIRONMENT,
|
||||
});
|
||||
expect(rejected.exitCode).toBe(1);
|
||||
expect(rejected.json).toMatchObject({ error: "Analysis failed" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"resolves a standalone Mach-O against its own directory",
|
||||
async ({ cli }) => {
|
||||
const directory = await createTestTempDirectory("rea-dylib-standalone-");
|
||||
await writeFiles(directory, {
|
||||
tool: machoImage({
|
||||
commands: [
|
||||
dylibCommand(LC.LOAD_DYLIB, "@loader_path/lib/libhelper.dylib"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@loader_path/../escape.dylib"),
|
||||
],
|
||||
}),
|
||||
"lib/libhelper.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
|
||||
"unrelated.txt": "not traversed",
|
||||
});
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"trace-dylib-resolution",
|
||||
join(directory, "tool"),
|
||||
"--json",
|
||||
],
|
||||
environment: ENVIRONMENT,
|
||||
});
|
||||
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
||||
const trace = dylibResolutionResultSchema.parse(
|
||||
(result.json as { normalized_result: unknown }).normalized_result,
|
||||
);
|
||||
expect(trace.roots).toEqual([{ image: "tool", architecture: "arm64" }]);
|
||||
expect(trace.edges.map(({ resolution }) => resolution)).toEqual([
|
||||
{ status: "resolved", image: "lib/libhelper.dylib" },
|
||||
{ status: "undetermined", image: null },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
import {
|
||||
FILE_TYPE,
|
||||
LC,
|
||||
dylibCommand,
|
||||
machoImage,
|
||||
rpathCommand,
|
||||
} from "../../../src/artifacts/MachoImage.fixture.js";
|
||||
import { dylibResolutionResultSchema } from "../../../src/domain/dylibResolution.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const withClient = async (
|
||||
verify: (client: Client) => Promise<void>,
|
||||
): Promise<void> => {
|
||||
const session = createTestBinarySession(new ArtifactProvider());
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({ name: "dylib-mcp-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
await verify(client);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
};
|
||||
|
||||
const structuredResult = (value: unknown): unknown =>
|
||||
z.object({ result: z.unknown() }).parse(value).result;
|
||||
|
||||
it("traces dylib resolution for an opened app bundle and rejects a changed target", async () => {
|
||||
const app = join(
|
||||
await createTestTempDirectory("rea-dylib-mcp-"),
|
||||
"Fixture.app",
|
||||
);
|
||||
await mkdir(join(app, "Contents/MacOS"), { recursive: true });
|
||||
await mkdir(join(app, "Contents/Frameworks"), { recursive: true });
|
||||
await writeFile(
|
||||
join(app, "Contents/Info.plist"),
|
||||
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
|
||||
);
|
||||
const executable = join(app, "Contents/MacOS/App");
|
||||
await writeFile(
|
||||
executable,
|
||||
machoImage({
|
||||
commands: [
|
||||
rpathCommand("@executable_path/../Frameworks"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(app, "Contents/Frameworks/libcore.dylib"),
|
||||
machoImage({ fileType: FILE_TYPE.dylib }),
|
||||
);
|
||||
await withClient(async (client) => {
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: app },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
||||
const called = await client.callTool({
|
||||
name: "trace_dylib_resolution",
|
||||
arguments: {},
|
||||
});
|
||||
expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe(
|
||||
true,
|
||||
);
|
||||
const trace = dylibResolutionResultSchema.parse(
|
||||
structuredResult(called.structuredContent),
|
||||
);
|
||||
expect(trace.edges).toEqual([
|
||||
expect.objectContaining({
|
||||
install_name: "@rpath/libcore.dylib",
|
||||
resolution: {
|
||||
status: "resolved",
|
||||
image: "Contents/Frameworks/libcore.dylib",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const escaping = await client.callTool({
|
||||
name: "trace_dylib_resolution",
|
||||
arguments: { roots: ["Contents/../../outside"] },
|
||||
});
|
||||
expect(escaping.isError).toBe(true);
|
||||
|
||||
await writeFile(executable, machoImage({}));
|
||||
const changed = await client.callTool({
|
||||
name: "trace_dylib_resolution",
|
||||
arguments: {},
|
||||
});
|
||||
expect(changed.isError).toBe(true);
|
||||
expect(JSON.stringify(changed.structuredContent)).toContain("integrity");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects targets that are not Mach-O", async () => {
|
||||
const directory = await createTestTempDirectory("rea-dylib-mcp-plist-");
|
||||
const path = join(directory, "Info.plist");
|
||||
await writeFile(path, "<plist><dict/></plist>");
|
||||
await withClient(async (client) => {
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
const called = await client.callTool({
|
||||
name: "trace_dylib_resolution",
|
||||
arguments: {},
|
||||
});
|
||||
expect(called.isError).toBe(true);
|
||||
expect(JSON.stringify(called.structuredContent)).toContain("unavailable");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user