Compare commits

...
Author SHA1 Message Date
morluto 8f947c859c fix(apple): qualify rpath findings for conditional searches 2026-10-08 23:52:43 +08:00
morluto a446f2a413 chore: sync branch with latest main 2026-10-08 23:52:43 +08:00
morluto 5be3eeb1e6 fix(apple): distinguish dyld logging from path overrides 2026-10-08 23:46:06 +08:00
morluto 0cbef126a5 chore: merge main and reconcile CI projections 2026-10-08 23:45:07 +08:00
morluto 4adca806f9 fix(ci): restore generated projections in test shards 2026-10-08 23:32:34 +08:00
morluto 22e6110acb fix(apple): preserve modern cache subtype metadata 2026-10-08 23:26:33 +08:00
morluto 8289ede5db chore: synchronize dyld cache branch with latest main guidance 2026-10-08 23:16:56 +08:00
morluto e4d61d401d fix(apple): distinguish cache loadability and propagate search uncertainty 2026-10-08 23:16:28 +08:00
4e98543e90 fix(apple): admit only compatible dyld subcache mappings (#1090)
UUID, architecture, and cache VM offset are one admission decision, so a
matching UUID cannot publish contradictory mappings. A shared-cache trace
hit also requires the mapped bytes to parse as a Mach-O the process can load.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-08 22:51:06 +08:00
morluto 03bbecd941 chore: merge main into feat/dyld-shared-cache 2026-10-08 21:26:50 +08:00
morluto 2645851adf chore: merge main into feat/dyld-shared-cache 2026-10-08 21:22:08 +08:00
morluto d39543145e fix(apple): validate complete image mappings with indexed VM lookup 2026-10-08 21:13:43 +08:00
morluto b23f8288b4 fix(apple): validate cache file kinds and combined VM ranges 2026-10-08 20:52:20 +08:00
morluto cb8d0f37f4 chore: merge main into feat/dyld-shared-cache 2026-10-08 19:57:47 +08:00
morluto 6b99236c36 fix(apple): disclose untraversed cache dependencies in trace results 2026-10-08 19:55:51 +08:00
morluto fb999f84b8 refactor(apple): normalize host paths at the filesystem boundary 2026-10-08 19:09:40 +08:00
morluto fe3cac8aa8 fix(apple): normalize simulator cache platforms without losing raw fields 2026-10-08 18:19:37 +08:00
morluto 17f2bc7b0d fix(apple): discover cache companions from canonical main file 2026-10-08 17:46:43 +08:00
morluto f4f82179d9 fix(apple): preserve selected cache path in trace evidence 2026-10-08 17:22:52 +08:00
morluto 0f6dd6966c chore: merge main into feat/dyld-shared-cache 2026-10-08 17:02:53 +08:00
morluto 92fcab2c68 fix(apple): retain caller-selected cache path in results 2026-10-08 16:51:39 +08:00
morluto dbc7ebc2b6 fix(apple): recheck cache identity around digest reads 2026-10-08 16:36:44 +08:00
morluto d4ba8f661b fix(apple): map Intel version-min to simulators; fail truncated cache hashes 2026-10-08 16:23:05 +08:00
github-actions[bot] 24c8e13909 chore: regenerate generated metadata 2026-10-08 08:12:50 +00:00
morluto e1597a6f3d fix(apple): reject empty caches and bind digests to resolution traces 2026-10-08 16:06:51 +08:00
morluto d5921db039 chore: merge main into feat/dyld-shared-cache 2026-10-08 15:53:51 +08:00
morluto 663f4c4dc5 fix(apple): decode legacy cache-type polarity by header generation 2026-10-08 15:47:47 +08:00
morluto d081ffbdce fix(apple): harden dyld cache reads, mappings, and resolution findings 2026-10-08 15:36:31 +08:00
Kaoru0822andClaude Opus 5.5 cd622e0e95 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Bring in #927's merge of main (#1037 at skill version 30):
- README_ja/ar/ko/zh: keep the merged tables and count
  inspect_dyld_shared_cache in application workflows (13 -> 14).
- Skill: #927 now uses version 31, so this branch moves to 32.
- Regenerate the product catalog, conformance manifest, skill digest and
  package metadata (138 tools).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:33:22 +00:00
Kaoru0822andClaude Opus 5.5 f2f83c7499 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Bring in #927's merge of main (#964 at skill version 29):
- README_ja/ar/ko/zh: keep the merged tables and count
  inspect_dyld_shared_cache in application workflows (13 -> 14).
- Skill: #927 now uses version 30, so this branch moves to 31.
- Regenerate the product catalog, conformance manifest, skill digest and
  package metadata (137 tools).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:25:40 +00:00
Kaoru0822andClaude Opus 5.5 d180d8667d Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Bring in #927's merge of main and resolve conflicts:
- README.md: main's simplified English README has no tool-family table.
- README_ja/ar/ko/zh: keep main's reworded tables and count
  inspect_dyld_shared_cache in application workflows (13 -> 14).
- Skill: #927 now uses version 29, so this branch moves to 30.
- Regenerate the product catalog, conformance manifest, skill digest and
  package metadata (136 tools). The MCP tool catalog is now built rather
  than committed (#997), so it is removed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:52:57 +00:00
Kaoru0822 3d6a31ed41 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache 2026-10-07 14:49:09 +00:00
Kaoru0822andClaude Opus 5.5 09344aa33d chore(apple): drop an unused DylibCandidate re-export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:21:12 +00:00
Kaoru0822 8e53f794f1 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
# Conflicts:
#	README.md
#	README_ar.md
#	README_ja.md
#	README_ko.md
#	README_zh.md
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
2026-10-07 14:13:29 +00:00
Kaoru0822andClaude Opus 5.5 858aa9fd7d fix(apple): hash the dyld cache through the handle its header was read from
main_file_sha256 now comes from the main cache file's open handle, so a path
replaced during inspection cannot attach another file's digest to the parsed
header and image list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:35:58 +00:00
Kaoru0822 ed40d2e001 Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache 2026-10-07 13:33:40 +00:00
Kaoru0822andClaude Opus 5.5 3b4969e3dc Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Carry #952's Apple ownership into the cache work: the cache reader and
fixture move to src/artifacts/apple/, the cache facts, platforms and
resolution schema to src/domain/apple/, the cache service to
src/application/apple/, and the lane to scripts/verify/apple/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:32:48 +00:00
Kaoru0822andClaude Opus 5.5 1d943c5eec Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
Carry conditional-load propagation, parsed-image digest binding, and
segment-wise root resolution into the shared-cache trace. Also:

- Make cache compatibility directional: an arm64e cache serves arm64
  processes and an x86_64h cache serves x86_64 ones, not the reverse.
- Require the complete 24-byte LC_BUILD_VERSION with its declared tool
  records, and the 16-byte LC_VERSION_MIN_* command, before recording a
  platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:00:59 +00:00
Kaoru0822andClaude Opus 5.5 305474df6c fix(apple): gate shared-cache answers on platform and verified subcaches
- Use a supplied cache for a process only when it serves the process's CPU
  family and one of its LC_BUILD_VERSION or LC_VERSION_MIN platforms matches
  the cache's platform or alternate platform; slices now report platforms.
  A limitation names each root the cache was not used for, and why.
- Treat an image listed in the cache but mapped in a missing or
  UUID-mismatched subcache as undetermined, list it in
  coverage.unverified_shared_cache_images, and mark coverage partial.
- Keep a cache hit conditional when an earlier candidate is undeterminable.
- Open a development cache's named subcaches beside its base name.
- Describe absolute paths as not evaluated only when no cache is supplied.
- Preflight dyld_info in verify:dyld-shared-cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:36:43 +00:00
Kaoru0822 202661f83b Merge branch 'feat/dylib-resolution' into feat/dyld-shared-cache
# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
2026-10-07 12:26:24 +00:00
Kaoru0822 22beae9768 Merge remote-tracking branch 'origin/feat/dylib-resolution' into feat/dyld-shared-cache
# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/domain/dylibResolution.test.ts
#	src/domain/dylibResolution.ts
#	src/domain/dylibResolutionFindings.ts
#	src/generatedMcpToolCatalog.ts
2026-10-07 11:52:10 +00:00
Kaoru0822andClaude Opus 5.5 ade11fa02d feat(apple): inspect dyld shared caches and resolve system paths
macOS 11 and later ship system libraries only inside the dyld shared
cache. trace_dylib_resolution therefore left every /usr/lib and /System
load undetermined, and REA had no way to see what a cache contains.

inspect_dyld_shared_cache (CLI inspect-dyld-shared-cache) reads a cache
file set selected by path, without an open target. Its pure TypeScript
reader parses modern and legacy dyld_cache_header layouts and reports:
- UUID, architecture, platform, OS version, Mac Catalyst alternate,
  cache type, shared region, max slide;
- every VM mapping;
- each subcache by file suffix, checked by UUID. A missing or
  mismatched subcache makes coverage partial;
- the complete image list.
For each requested install path it reads that image's load commands
through the cache mappings with the existing Mach-O reader.

trace_dylib_resolution accepts an optional shared_cache. Absolute
candidates found in a cache that serves the process's CPU family resolve
with the new outcome and status `shared-cache`. That status also takes
precedence over later in-bundle fallbacks such as a back-deployed
libswiftCore. The result names the cache UUID and OS version it used.

verify:dyld-shared-cache checks every image in the host cache (3,649 on
macOS 26.6), with dependencies and their re-export, weak, upward and
delay-init attributes, against `dyld_info -all_dyld_cache
-linked_dylibs`. It also checks CLI/MCP parity, and that each
shared-cache edge in the fixture trace is a library dyld actually loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:43:15 +00:00
42 changed files with 3435 additions and 255 deletions
+3 -6
View File
@@ -102,12 +102,8 @@ jobs:
with:
name: rea-dist
path: dist
- name: Restore generated outputs from downloaded build
run: |
node scripts/generate-mcp-tool-catalog.mjs
node scripts/generate-skill-metadata.mjs
node scripts/generate-product-catalog.mjs
node scripts/generate-completion-ledger.mjs
- name: Generate projections from downloaded build
run: npm run build:projections
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
@@ -321,6 +317,7 @@ jobs:
- run: npm run verify:keyed-archive
- run: npm run verify:asset-catalog
- run: npm run verify:macos-bundle
- run: npm run verify:dyld-shared-cache
- name: Native installer and compiled NIB hierarchy regressions
run: npm run test:focused -- tests/boundary/filesystem/installScript.test.ts tests/boundary/filesystem/nibNativeHierarchy.test.ts tests/boundary/filesystem/interfaceBuilderBundle.test.ts
- name: Real binary metadata and Interface Builder integration
+2 -2
View File
@@ -214,7 +214,7 @@ rea setup
## منصة أدوات التحقيق
| عائلة الأدوات | العدد | الاستخدام |
| ---------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ---------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
| أدوات الملفات الثنائية | 10 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة وسجلات الأعطال على Linux |
@@ -225,7 +225,7 @@ rea setup
| مراقبة المتصفح | 12 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
| سير عمل التطبيقات | 13 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
| سير عمل التطبيقات | 14 | تصدير النصوص البرمجية الملتقطة، فحص ذاكرة dyld المشتركة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
## خطة العمل
+2 -2
View File
@@ -237,7 +237,7 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して
## 調査ツールカタログ
| ツール分類 | 数 | 用途 |
| -------------------- | --: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| -------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
| ネイティブバイナリ | 10 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウト、静的な保護機構の証拠と記録済みクラッシュ |
@@ -248,7 +248,7 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して
| ブラウザー観察 | 12 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP の検出、スクリーンショット、キャプチャ比較 |
| Electron 解析 | 5 | レンダラーの観察、静的なアプリのマッピング、静的結果と実行時結果の照合 |
| JavaScript 実行時 | 2 | Node/Electron Inspector の対象検出、スクリプトの位置、実行コンテキストのイベント |
| アプリワークフロー | 13 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 |
| アプリワークフロー | 14 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、dyld 共有キャッシュの検査、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 |
| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
## ロードマップ
+1 -1
View File
@@ -215,7 +215,7 @@ REA는 1~5단계의 바이너리 분석을 처리합니다. 6단계는 에이전
| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
| 앱 워크플로 | 14 | 캡처한 웹 스크립트 내보내기, dyld 공유 캐시 검사, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
## 로드맵
+1 -1
View File
@@ -214,7 +214,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
| 应用工作流 | 14 | 捕获的网站脚本导出、dyld 共享缓存检查、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
## 路线图
+63 -3
View File
@@ -153,15 +153,16 @@ Resolution follows dyld:
Each edge lists every candidate path tried, with its outcome: `resolved`,
`absent`, `not-mach-o`, `malformed`, `unsupported` (recognized Mach-O outside
parser coverage, such as big-endian images), `architecture-missing`,
`outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's
`outside-target`, `shared-cache`, `escapes-target`, or `undetermined`. Slices follow dyld's
graded architectures, so an `x86_64h` process also loads `x86_64`. 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. |
| `conditional` | An image inside the root, or in the supplied shared cache, resolves, but an earlier candidate that REA cannot decide (for example `/opt/vendor/lib` or `/usr/lib/swift` without a cache) 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. |
| `shared-cache` | An absolute install path, or an `@rpath` candidate outside the root, was found in the dyld shared cache passed as `shared_cache` (CLI `--shared-cache`). dyld loads the cached image, so later in-root candidates are unused. |
A `conditional` image is still traced, because it is the image the analyzed
root supplies. Every edge below it has `loader_conditional: true`, and a
@@ -185,3 +186,62 @@ cache rather than on disk, so REA does not check them against the host.
`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`).
With `shared_cache`, absolute candidates are looked up in that cache's image
list. A process uses the cache only when both of these hold:
- the cache serves its CPU family (an arm64e cache serves arm64 and arm64e);
- one of its `LC_BUILD_VERSION` or `LC_VERSION_MIN_*` platforms (reported per
slice as `platforms`) matches the cache's platform or alternate platform.
An iOS cache extracted from an IPSW is therefore not used for a macOS
executable. A limitation names each process root the cache was not used for,
and why. The result's `shared_cache` names the cache path, UUID, architecture
and OS version. Cached images are not traversed.
An image listed in the cache but mapped in a missing or UUID-mismatched
subcache is `undetermined`. It is listed in
`coverage.unverified_shared_cache_images` and makes coverage partial. Without a
cache, system paths stay `undetermined`.
## dyld shared cache
`inspect_dyld_shared_cache` reads a dyld shared cache file set selected by path.
On current macOS this is
`/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e`;
a cache extracted from an IPSW also works. It runs without an open target.
```sh
rea inspect-dyld-shared-cache /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e --image /usr/lib/libSystem.B.dylib --json
```
```json
{
"name": "inspect_dyld_shared_cache",
"arguments": {
"cache_path": "/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e",
"images": ["/usr/lib/libSystem.B.dylib"]
}
}
```
The result reports:
- the magic, architecture, UUID, platform and OS version, any alternate platform
(Mac Catalyst), cache type, the development/production subtype for multi-caches
(including its raw ID when unknown), shared region and maximum slide;
- every VM mapping, with the file that holds it;
- every subcache by file suffix, with its UUID checked against the main cache.
A missing or mismatched subcache makes coverage `partial`, and images mapped
there are `unmapped`;
- the complete image list with addresses.
For each requested install path, the tool reads that image's load commands
(install name, dependencies with their re-export, weak, upward and delayed-init
flags, and rpaths) through the cache mappings. A path the cache does not
contain is `absent`. Older single-file caches are read from their legacy header
fields.
Nothing is executed, slid or extracted. Slide info, local symbols and the
cache's code signature are not parsed. The Evidence subject is the main cache
file, identified by its digest.
+6
View File
@@ -98,6 +98,12 @@ an XML graph golden. `verify:asset-catalog` compiles source-owned colors with
`actool`, invokes real `assetutil`, then checks CLI/MCP results, exact catalog
digest, every raw metadata field, pagination and malformed input rejection.
Neither artifact workflow requires Hopper or launches it. Both run in macOS CI.
`verify:dyld-shared-cache` reads the host's dyld shared cache. It checks that
the image list, and every image's dependencies with their attributes, equal
`dyld_info -all_dyld_cache -linked_dylibs`, and that `inspect-dyld-shared-cache`
agrees between CLI and stdio MCP. It then traces the clang-built fixture app
with `--shared-cache` and checks that every `shared-cache` edge names a library
dyld actually loads under `DYLD_PRINT_LIBRARIES`.
`verify:macos-bundle` needs only macOS with Command Line Tools. It compiles a
source-owned app with `clang`: a versioned framework, XPC services, an app
extension, a login item, a privileged helper, launchd plists, and a helper tool,
+3 -1
View File
@@ -87,7 +87,8 @@
"mcp-catalog:check": "node scripts/generate-mcp-tool-catalog.mjs --check",
"prebuild": "npm run deps:check && npm run metadata:check",
"build": "node scripts/run-exclusive.mjs build npm run build:unlocked",
"build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs",
"build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && npm run build:projections",
"build:projections": "node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs",
"precheck": "npm run deps:check",
"check": "turbo run typecheck lint format:check knip metadata:check",
"precheck:fast": "npm run deps:check",
@@ -141,6 +142,7 @@
"verify:apple-dispatch": "npm run build:cached && node scripts/verify/apple/dispatch.mjs",
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
"verify:keyed-archive": "npm run build:cached && node scripts/verify/apple/keyed-archive.mjs",
"verify:dyld-shared-cache": "npm run build:cached && node scripts/verify/apple/dyld-shared-cache.mjs",
"verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
+4 -1
View File
@@ -38,7 +38,7 @@ export async function artifactCliEvidence(command, target, arguments_ = []) {
return evidence;
}
/** Run a real stdio MCP subprocess, owning and closing its transport. */
/** Run a real stdio MCP subprocess, owning and closing its transport; open `target` unless it is null. */
export async function withArtifactMcp(target, verify) {
const transport = new StdioClientTransport({
command: process.execPath,
@@ -49,11 +49,14 @@ export async function withArtifactMcp(target, verify) {
const client = new Client({ name: "artifact-real-e2e", version: "1" });
try {
await client.connect(transport);
// Target-free tools pass `null` and need no open session.
if (target !== null) {
const opened = await client.callTool({
name: "open_binary",
arguments: { path: target },
});
assert.notEqual(opened.isError, true, JSON.stringify(opened));
}
await verify(client);
} finally {
await client.close();
+5 -2
View File
@@ -243,7 +243,8 @@ export const providerCatalog = (sources) => {
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
({ name }) =>
name !== "project_android_application_graph" &&
name !== "project_apple_application_graph",
name !== "project_apple_application_graph" &&
name !== "inspect_dyld_shared_cache",
),
},
{
@@ -255,7 +256,9 @@ export const providerCatalog = (sources) => {
{
identity: sources.artifactProviders.APPLE_APPLICATION_PROVIDER,
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
({ name }) => name === "project_apple_application_graph",
({ name }) =>
name === "project_apple_application_graph" ||
name === "inspect_dyld_shared_cache",
),
},
{
+222
View File
@@ -0,0 +1,222 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { access, mkdtemp, open, realpath, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { DyldSharedCache } from "../../../dist/artifacts/apple/DyldSharedCacheReader.js";
import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
} from "../../lib/artifact-e2e.mjs";
import {
buildMacosBundleFixture,
preflightMacosBundleFixture,
} from "../../fixtures/apple/macos-bundle.mjs";
const exec = promisify(execFile);
await preflightMacosBundleFixture();
try {
await exec("/usr/bin/xcrun", ["--find", "dyld_info"]);
} catch (cause) {
throw new Error(
"dyld shared cache verification requires dyld_info from Command Line Tools (xcrun --find dyld_info failed)",
{ cause },
);
}
/** The cache this host's dyld uses for native processes. */
const hostCache = async () => {
const names =
process.arch === "arm64"
? ["dyld_shared_cache_arm64e"]
: ["dyld_shared_cache_x86_64h", "dyld_shared_cache_x86_64"];
for (const directory of [
"/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld",
"/System/Library/dyld",
])
for (const name of names)
try {
await access(join(directory, name));
return join(directory, name);
} catch {
// Try the next documented location.
}
throw new Error("No dyld shared cache was found in the documented locations");
};
/** `dyld_info -all_dyld_cache -linked_dylibs`: path -> "attributes|load path" lines. */
const dyldInfoImages = async () => {
const { stdout } = await exec(
"/usr/bin/xcrun",
["dyld_info", "-all_dyld_cache", "-linked_dylibs"],
{ maxBuffer: 256 * 1024 * 1024 },
);
const images = new Map();
let current;
for (const line of stdout.split("\n")) {
const head = /^(\/.+) \[(\S+)\]:$/u.exec(line);
if (head !== null) {
current = [];
images.set(head[1], current);
continue;
}
const dependency = /^\s{8}(.*?)\s*(\/\S.*)$/u.exec(line);
if (dependency !== null && current !== undefined)
current.push(
`${dependency[1].split(/\s+/u).filter(Boolean).sort().join(",")}|${dependency[2]}`,
);
}
return images;
};
const attributes = (dependency) =>
[
dependency.reexport ? "re-export" : null,
dependency.weak ? "weak-link" : null,
dependency.upward ? "upward" : null,
dependency.delayed_init ? "delay-init" : null,
]
.filter((value) => value !== null)
.sort()
.join(",");
const cachePath = await hostCache();
const root = await mkdtemp(join(tmpdir(), "rea-dyld-cache-"));
try {
const expected = await dyldInfoImages();
const cache = await DyldSharedCache.open(cachePath);
let compared = 0;
try {
assert.deepEqual(
cache.images.map(({ path }) => path).sort(),
[...expected.keys()].sort(),
"image list differs from dyld_info",
);
for (const [path, dependencies] of expected) {
const read = await cache.imageFacts(path);
assert.equal(read?.facts.status, "parsed", path);
assert.deepEqual(
read.facts.slices[0].dependencies.map(
(dependency) =>
`${attributes(dependency)}|${dependency.install_name}`,
),
dependencies,
`${path} dependencies differ from dyld_info`,
);
compared += 1;
}
} finally {
await cache.close();
}
const inspected = await artifactCli("inspect-dyld-shared-cache", cachePath, [
"--image",
"/usr/lib/libSystem.B.dylib",
]);
assert.equal(inspected.images_total, expected.size);
assert.equal(inspected.coverage.status, "complete");
assert.equal(inspected.inspected_images[0].status, "parsed");
// Check the producer header directly, independently of REA's header projection.
const handle = await open(cachePath, "r");
try {
const header = Buffer.alloc(0x1cc);
const { bytesRead } = await handle.read(header, 0, header.length, 0);
const multi = header.readBigUInt64LE(0x68) === 2n;
const hasSubtype =
bytesRead === header.length && header.readUInt32LE(0x10) >= header.length;
const subtype = multi && hasSubtype ? header.readUInt32LE(0x1c8) : null;
assert.deepEqual(
inspected.cache_subtype,
subtype === null
? null
: {
id: subtype,
name:
subtype === 0
? "development"
: subtype === 1
? "production"
: null,
},
"multi-cache subtype differs from the producer header",
);
} finally {
await handle.close();
}
await withArtifactMcp(null, async (client) => {
const viaMcp = await artifactMcpResult(
client,
"inspect_dyld_shared_cache",
{
cache_path: cachePath,
images: ["/usr/lib/libSystem.B.dylib"],
},
);
assert.deepEqual(viaMcp, inspected);
});
const { app } = await buildMacosBundleFixture(root);
const trace = await artifactCli("trace-dylib-resolution", app, [
"--shared-cache",
cachePath,
]);
assert.equal(trace.shared_cache.uuid, inspected.uuid);
const main = "Contents/MacOS/MacFixture";
const cached = trace.edges.filter(
({ root: edgeRoot, resolution }) =>
edgeRoot === main && resolution.status === "shared-cache",
);
assert.ok(
cached.some(
({ install_name: name }) => name === "/usr/lib/libSystem.B.dylib",
),
);
assert.ok(
trace.edges.every(({ install_name: name, resolution }) =>
name.startsWith("/usr/lib/") || name.startsWith("/System/")
? resolution.status === "shared-cache"
: true,
),
"a system install path was not found in the host cache",
);
const { stderr } = await exec(join(app, main), [], {
env: { PATH: "/usr/bin:/bin", DYLD_PRINT_LIBRARIES: "1" },
});
const loaded = new Set(
stderr
.split("\n")
.map((line) => /^dyld\[\d+\]: <[0-9A-F-]+> (.+)$/u.exec(line)?.[1])
.filter((path) => path !== undefined),
);
for (const { resolution } of cached)
assert.ok(
loaded.has(resolution.image),
`${resolution.image} was not loaded by dyld`,
);
process.stdout.write(
`${JSON.stringify({
ok: true,
mocked: false,
cli: true,
stdio_mcp: true,
cache: {
architecture: inspected.architecture,
os_version: inspected.os_version,
images: inspected.images_total,
type: inspected.cache_type,
subtype: inspected.cache_subtype,
},
images_matching_dyld_info: compared,
trace_shared_cache_edges: cached.length,
dyld_loaded_every_cached_edge: true,
host: await realpath(cachePath),
})}\n`,
);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -103,8 +103,10 @@ 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.
`architecture`. Pass `shared_cache` (the host cache or one from an IPSW) to
resolve system paths to `shared-cache`; without it they stay undetermined.
Use `inspect_dyld_shared_cache` with that cache path to list its images and read
a cached library's dependencies without an open target.
Use `extract_artifact` when materialized files are needed. It takes no arguments
and materializes all regular files into a fresh temporary directory chosen by
@@ -0,0 +1,195 @@
import { resolve } from "node:path";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { DyldSharedCache } from "../../artifacts/apple/DyldSharedCacheReader.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import {
DYLD_SHARED_CACHE_LIMITATIONS,
dyldSharedCacheResultSchema,
inspectDyldSharedCacheInputSchema,
type DyldSharedCacheResult,
type InspectedCacheImage,
} from "../../domain/apple/dyldSharedCache.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import { APPLE_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
const OPERATION = "inspect_dyld_shared_cache" as const;
/** Read one requested image through the cache, keeping absence explicit. */
const inspectImage = async (
cache: DyldSharedCache,
path: string,
): Promise<InspectedCacheImage> => {
const image = cache.find(path);
const base = { path, reason: null, slices: [] };
if (image === undefined)
return { ...base, status: "absent", address: null, file: null };
const address = `0x${image.address.toString(16)}`;
const read = await cache.imageFacts(path);
if (read === undefined)
return {
...base,
status: "unmapped",
address,
file: null,
reason: "The image address lies in no readable cache mapping.",
};
const { facts, file } = read;
if (facts.status === "parsed")
return {
...base,
status: "parsed",
address,
file,
slices: [...facts.slices],
};
return {
...base,
status: facts.status === "not-mach-o" ? "malformed" : facts.status,
address,
file,
reason:
facts.status === "not-mach-o"
? "The cached image is not a Mach-O image."
: facts.reason,
};
};
const failure = (cause: unknown): AnalysisError => {
if (cause instanceof ArtifactReaderFailure)
return new ArtifactOperationError(
OPERATION,
cause.reason,
undefined,
cause.message,
);
if (cause instanceof Error && "code" in cause) {
if (cause.code === "EISDIR")
return new ArtifactOperationError(
OPERATION,
"path",
undefined,
cause.message,
);
if (cause.code === "ENOENT" || cause.code === "ENOTDIR")
return new ArtifactOperationError(
OPERATION,
"path",
undefined,
"The cache file does not exist.",
);
if (cause.code === "EACCES" || cause.code === "EPERM")
return new ArtifactOperationError(
OPERATION,
"unavailable",
undefined,
"The cache file is not readable.",
);
}
if (cause instanceof Error && cause.name === "AbortError")
return new ArtifactOperationError(OPERATION, "cancelled");
return new ArtifactOperationError(
OPERATION,
"io",
undefined,
cause instanceof Error ? cause.message : String(cause),
);
};
/** Inspect a dyld shared cache file set selected by path. */
export const inspectDyldSharedCache = async (
rawInput: unknown,
signal?: AbortSignal,
): Promise<Result<DyldSharedCacheResult, AnalysisError>> => {
const parsed = inspectDyldSharedCacheInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(new AnalysisInputError(OPERATION, { cause: parsed.error }));
// Keep the caller-selected spelling for result and Evidence metadata; only
// filesystem access uses the resolved path.
const selectedPath = parsed.data.cache_path;
const cachePath = resolve(selectedPath);
let cache: DyldSharedCache | undefined;
try {
cache = await DyldSharedCache.open(cachePath, signal);
const inspected: InspectedCacheImage[] = [];
for (const path of parsed.data.images ?? []) {
signal?.throwIfAborted();
inspected.push(await inspectImage(cache, path));
}
const unreadable = cache.header.subcaches
.filter(({ status }) => status !== "present")
.map(({ suffix }) => suffix);
// Hash through the open handles the header was parsed from, so a path
// replacement mid-inspection cannot attribute new bytes to old facts.
const mainSha = await cache.mainSha256(signal);
const subcacheSha = await cache.subcacheSha256(signal);
return ok(
dyldSharedCacheResultSchema.parse({
cache_path: selectedPath,
// The header and image list were read through this same handle.
main_file_sha256: mainSha,
...cache.header,
subcache_sha256: subcacheSha,
images_total: cache.images.length,
images: cache.images.map(({ path, address }) => ({
path,
address: `0x${address.toString(16)}`,
})),
inspected_images: inspected,
coverage: {
status: unreadable.length === 0 ? "complete" : "partial",
unreadable_subcaches: unreadable,
},
limitations: [
...DYLD_SHARED_CACHE_LIMITATIONS,
...(unreadable.length === 0
? []
: [
`Subcaches ${unreadable.join(", ")} are missing or do not match the main cache UUID; images mapped there are unmapped.`,
]),
],
}),
);
} catch (cause: unknown) {
return err(failure(cause));
} finally {
await cache?.close();
}
};
/** Inspect a dyld shared cache and wrap the observation as Evidence. */
export const inspectDyldSharedCacheEvidence = async (
rawInput: unknown,
signal?: AbortSignal,
): Promise<Result<Evidence, AnalysisError>> => {
const inspected = await inspectDyldSharedCache(rawInput, signal);
if (!inspected.ok) return inspected;
const result = inspected.value;
return ok(
createEvidence(
{
path: result.cache_path,
sha256: result.main_file_sha256,
format: "file",
},
APPLE_APPLICATION_PROVIDER,
{
operation: OPERATION,
parameters: {
cache_path: result.cache_path,
images: result.inspected_images.map(({ path }) => path),
},
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "observed",
authority: "shipped-artifact",
environment: null,
limitations: result.limitations,
},
),
);
};
@@ -0,0 +1,138 @@
/** Synthetic dyld shared caches laid out field by field for tests. */
export interface CacheFixtureImage {
readonly path: string;
readonly bytes: Uint8Array;
/** Place the image in the `.01` subcache instead of the main file. */
readonly inSubcache?: boolean;
}
export interface CacheFixture {
readonly main: Uint8Array;
readonly subcaches: readonly {
readonly suffix: string;
readonly bytes: Uint8Array;
}[];
}
const MAIN_BASE = 0x180000000n;
const SUB_BASE = 0x190000000n;
const MODERN_HEADER = 0x228;
const LEGACY_HEADER = 0x98;
const uuid = (seed: number): Buffer =>
Buffer.from(
Array.from({ length: 16 }, (_, index) => (seed * 31 + index) & 0xff),
);
const align = (value: number): number => Math.ceil(value / 0x1000) * 0x1000;
const writeHeader = (
buffer: Buffer,
options: {
readonly headerSize: number;
readonly uuidSeed: number;
readonly architecture: string;
},
): void => {
buffer.write(`dyld_v1 ${options.architecture}`.padEnd(15, " "), 0, "latin1");
buffer.writeUInt32LE(options.headerSize, 0x10);
buffer.writeUInt32LE(1, 0x14);
uuid(options.uuidSeed).copy(buffer, 0x58);
};
const writeMapping = (
buffer: Buffer,
at: number,
address: bigint,
size: number,
): void => {
buffer.writeBigUInt64LE(address, at);
buffer.writeBigUInt64LE(BigInt(size), at + 8);
buffer.writeBigUInt64LE(0n, at + 16);
buffer.writeUInt32LE(5, at + 24);
buffer.writeUInt32LE(5, at + 28);
};
/** Build a main cache (modern or legacy header) plus an optional `.01` subcache. */
export const dyldCacheFixture = (
images: readonly CacheFixtureImage[],
options: {
readonly legacy?: boolean;
readonly architecture?: string;
readonly subcacheUuidSeed?: number;
} = {},
): CacheFixture => {
const architecture = options.architecture ?? "arm64e";
const legacy = options.legacy === true;
const headerSize = legacy ? LEGACY_HEADER : MODERN_HEADER;
const sub = images.filter(({ inSubcache }) => inSubcache === true);
const main = images.filter(({ inSubcache }) => inSubcache !== true);
const mappingTable = headerSize;
const imageTable = mappingTable + 32;
const subTable = imageTable + images.length * 32;
const pathsAt = subTable + (sub.length > 0 && !legacy ? 56 : 0);
const paths = images.map(({ path }) => Buffer.from(`${path}\0`));
let cursor = align(
pathsAt + paths.reduce((total, path) => total + path.length, 0),
);
const mainOffsets = main.map((image) => {
const offset = cursor;
cursor = align(cursor + image.bytes.length);
return offset;
});
const mainFile = Buffer.alloc(cursor);
writeHeader(mainFile, { headerSize, uuidSeed: 1, architecture });
writeMapping(mainFile, mappingTable, MAIN_BASE, mainFile.length);
if (legacy) {
mainFile.writeUInt32LE(imageTable, 0x18);
mainFile.writeUInt32LE(images.length, 0x1c);
} else {
mainFile.writeUInt32LE(1, 0xd8);
mainFile.writeBigUInt64LE(MAIN_BASE, 0xe0);
mainFile.writeBigUInt64LE(0x20000000n, 0xe8);
mainFile.writeBigUInt64LE(0x10000000n, 0xf0);
mainFile.writeUInt32LE(0x001a0600, 0x16c);
mainFile.writeUInt32LE(imageTable, 0x1c0);
mainFile.writeUInt32LE(images.length, 0x1c4);
}
let pathCursor = pathsAt;
let subCursor = MODERN_HEADER + 32;
const subOffsets = new Map<string, number>();
for (const image of sub) {
subCursor = align(subCursor);
subOffsets.set(image.path, subCursor);
subCursor += image.bytes.length;
}
images.forEach((image, index) => {
const row = imageTable + index * 32;
const mainIndex = main.indexOf(image);
const address =
mainIndex >= 0
? MAIN_BASE + BigInt(mainOffsets[mainIndex] ?? 0)
: SUB_BASE + BigInt(subOffsets.get(image.path) ?? 0);
mainFile.writeBigUInt64LE(address, row);
mainFile.writeUInt32LE(pathCursor, row + 24);
const path = paths[index] ?? Buffer.alloc(1);
path.copy(mainFile, pathCursor);
pathCursor += path.length;
if (mainIndex >= 0)
Buffer.from(image.bytes).copy(mainFile, mainOffsets[mainIndex] ?? 0);
});
if (sub.length === 0 || legacy) return { main: mainFile, subcaches: [] };
mainFile.writeUInt32LE(subTable, 0x188);
mainFile.writeUInt32LE(1, 0x18c);
uuid(2).copy(mainFile, subTable);
mainFile.writeBigUInt64LE(SUB_BASE - MAIN_BASE, subTable + 16);
mainFile.write(".01", subTable + 24, "latin1");
const subFile = Buffer.alloc(align(subCursor));
writeHeader(subFile, {
headerSize: MODERN_HEADER,
uuidSeed: options.subcacheUuidSeed ?? 2,
architecture,
});
writeMapping(subFile, MODERN_HEADER, SUB_BASE, subFile.length);
for (const image of sub)
Buffer.from(image.bytes).copy(subFile, subOffsets.get(image.path) ?? 0);
return { main: mainFile, subcaches: [{ suffix: ".01", bytes: subFile }] };
};
@@ -0,0 +1,780 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { open, realpath, type FileHandle } from "node:fs/promises";
import { applePlatform } from "../../domain/apple/applePlatforms.js";
import type { MachoImageFacts } from "../../domain/apple/dylibResolution.js";
import type {
DyldCacheMapping,
DyldCacheSubcache,
DyldSharedCacheHeader,
} from "../../domain/apple/dyldSharedCache.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import { readMachoImage } from "./MachoLoadCommandReader.js";
const MAGIC_PREFIX = "dyld_v1";
const DEVELOPMENT_EXTENSION = ".development";
/** Header fields are valid only below `mappingOffset`; real headers are under 1 KiB. */
const MAX_HEADER_BYTES = 64 * 1024;
const MAX_IMAGES = 200_000;
const MAX_MAPPINGS = 64;
const MAX_SUBCACHES = 256;
const MAX_PATH_BYTES = 4096;
const BLOCK_BYTES = 64 * 1024;
const HASH_CHUNK_BYTES = 1024 * 1024;
/** Byte offsets of `dyld_cache_header` fields in Apple's open-source dyld. */
const FIELD = {
mappingOffset: 0x10,
mappingCount: 0x14,
imagesOffsetOld: 0x18,
imagesCountOld: 0x1c,
uuid: 0x58,
cacheType: 0x68,
platform: 0xd8,
formatBits: 0xdc,
sharedRegionStart: 0xe0,
sharedRegionSize: 0xe8,
maxSlide: 0xf0,
osVersion: 0x16c,
altPlatform: 0x170,
altOsVersion: 0x174,
subCacheArrayOffset: 0x188,
subCacheArrayCount: 0x18c,
symbolFileUuid: 0x190,
imagesOffset: 0x1c0,
imagesCount: 0x1c4,
cacheSubType: 0x1c8,
} as const;
const hex = (value: bigint | number): string => `0x${value.toString(16)}`;
const uuidText = (bytes: Buffer): string => {
const text = bytes.toString("hex").toUpperCase();
return `${text.slice(0, 8)}-${text.slice(8, 12)}-${text.slice(12, 16)}-${text.slice(16, 20)}-${text.slice(20)}`;
};
const safeNumber = (value: bigint, label: string): number => {
if (value > BigInt(Number.MAX_SAFE_INTEGER))
throw new ArtifactReaderFailure(
"format",
`dyld cache ${label} exceeds the safe range`,
);
return Number(value);
};
/** Block-cached positional reads over one cache file. */
class CacheFile {
readonly #blocks = new Map<number, Buffer>();
private constructor(
readonly suffix: string,
readonly handle: FileHandle,
readonly size: number,
/** Device, inode, size, and nanosecond change times of the file as opened. */
readonly identity: string,
) {}
static async open(path: string, suffix: string): Promise<CacheFile> {
const handle = await open(
await realpath(path),
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
try {
const metadata = await handle.stat({ bigint: true });
if (!metadata.isFile())
throw new ArtifactReaderFailure(
"path",
`Dyld cache path is not a regular file: ${path}`,
);
return new CacheFile(
suffix,
handle,
safeNumber(metadata.size, "file size"),
CacheFile.identify(metadata),
);
} catch (cause: unknown) {
await handle.close();
throw cause;
}
}
static identify(metadata: {
readonly dev: bigint;
readonly ino: bigint;
readonly size: bigint;
readonly mtimeNs: bigint;
readonly ctimeNs: bigint;
}): string {
return [
metadata.dev,
metadata.ino,
metadata.size,
metadata.mtimeNs,
metadata.ctimeNs,
].join(":");
}
/** Whether the open file still matches the identity recorded at open. */
async unchanged(): Promise<boolean> {
return (
CacheFile.identify(await this.handle.stat({ bigint: true })) ===
this.identity
);
}
async read(offset: number, length: number): Promise<Buffer> {
if (offset < 0 || offset + length > this.size)
throw new ArtifactReaderFailure(
"format",
`dyld cache read at ${hex(offset)} extends beyond ${this.suffix === "" ? "the main file" : `subcache ${this.suffix}`}`,
);
if (length > BLOCK_BYTES) {
const bytes = Buffer.alloc(length);
await this.readInto(bytes, 0, length, offset);
return bytes;
}
const chunks: Buffer[] = [];
for (let position = offset; position < offset + length;) {
const index = Math.floor(position / BLOCK_BYTES);
let block = this.#blocks.get(index);
if (block === undefined) {
const start = index * BLOCK_BYTES;
block = Buffer.alloc(Math.min(BLOCK_BYTES, this.size - start));
await this.readInto(block, 0, block.length, start);
this.#blocks.set(index, block);
}
const from = position - index * BLOCK_BYTES;
const take = Math.min(block.length - from, offset + length - position);
chunks.push(block.subarray(from, from + take));
position += take;
}
return chunks.length === 1
? (chunks[0] ?? Buffer.alloc(0))
: Buffer.concat(chunks);
}
/** Positional read that retries short reads; slow filesystems may return partial data. */
private async readInto(
target: Buffer,
targetOffset: number,
length: number,
position: number,
): Promise<void> {
let done = 0;
while (done < length) {
const { bytesRead } = await this.handle.read(
target,
targetOffset + done,
length - done,
position + done,
);
if (bytesRead === 0)
throw new ArtifactReaderFailure(
"format",
`dyld cache read at ${hex(position)} is truncated`,
);
done += bytesRead;
}
}
async cString(offset: number): Promise<string> {
const bytes = await this.read(
offset,
Math.min(MAX_PATH_BYTES, this.size - offset),
);
const end = bytes.indexOf(0);
if (end < 0)
throw new ArtifactReaderFailure(
"format",
`dyld cache string at ${hex(offset)} is unterminated`,
);
return bytes.toString("utf8", 0, end);
}
}
interface CacheRegion {
readonly file: CacheFile;
readonly address: number;
readonly size: number;
readonly fileOffset: number;
}
/** Validated disjoint regions are sorted once; all VM lookups use logarithmic search. */
const cacheRegionAt = (
regions: readonly CacheRegion[],
address: number,
): CacheRegion | undefined => {
let low = 0;
let high = regions.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
const region = regions[middle];
if (region !== undefined && region.address <= address) low = middle + 1;
else high = middle;
}
const region = regions[low - 1];
return region !== undefined && address < region.address + region.size
? region
: undefined;
};
interface ParsedHeader {
readonly magic: string;
readonly uuid: string;
readonly mappings: readonly {
readonly address: bigint;
readonly size: bigint;
readonly fileOffset: bigint;
readonly maxProt: number;
readonly initProt: number;
}[];
readonly header: Buffer;
readonly length: number;
}
const has = (parsed: ParsedHeader, offset: number, width: number): boolean =>
parsed.length >= offset + width;
const readHeader = async (file: CacheFile): Promise<ParsedHeader> => {
if (file.size < 0x20)
throw new ArtifactReaderFailure(
"format",
"File is too short to be a dyld shared cache",
);
const prefix = await file.read(0, 0x20);
const magic = prefix.toString("latin1", 0, 16).replace(/\0+$/u, "");
if (!magic.startsWith(MAGIC_PREFIX))
throw new ArtifactReaderFailure(
"format",
"File is not a dyld shared cache",
);
const mappingOffset = prefix.readUInt32LE(FIELD.mappingOffset);
const mappingCount = prefix.readUInt32LE(FIELD.mappingCount);
if (
mappingOffset < 0x20 ||
mappingOffset > MAX_HEADER_BYTES ||
mappingCount === 0 ||
mappingCount > MAX_MAPPINGS
)
throw new ArtifactReaderFailure(
"format",
"dyld cache header has an invalid mapping table",
);
const header = await file.read(0, mappingOffset);
const table = await file.read(mappingOffset, mappingCount * 32);
const mappings = Array.from({ length: mappingCount }, (_, index) => ({
address: table.readBigUInt64LE(index * 32),
size: table.readBigUInt64LE(index * 32 + 8),
fileOffset: table.readBigUInt64LE(index * 32 + 16),
maxProt: table.readUInt32LE(index * 32 + 24),
initProt: table.readUInt32LE(index * 32 + 28),
}));
const parsed = { magic, uuid: "", mappings, header, length: mappingOffset };
return {
...parsed,
uuid: has(parsed, FIELD.uuid, 16)
? uuidText(header.subarray(FIELD.uuid, FIELD.uuid + 16))
: "",
};
};
const cacheArchitecture = (magic: string): string =>
magic.slice(MAGIC_PREFIX.length).trim();
/** Lowest mapping address: the subcache base `cacheVMOffset` is measured from. */
const mappingBase = (parsed: ParsedHeader): bigint => {
const first = parsed.mappings[0];
if (first === undefined)
throw new ArtifactReaderFailure("format", "dyld cache has no mappings");
return parsed.mappings.reduce(
(lowest, mapping) => (mapping.address < lowest ? mapping.address : lowest),
first.address,
);
};
const version = (value: number): string | null =>
value === 0
? null
: `${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`;
/** dyld_cache_header.formatBits simulator bit follows the 8-bit version and disk flag. */
const SIMULATOR_CACHE_FLAG = 1 << 9;
const SIMULATOR_PLATFORMS: ReadonlyMap<number, number> = new Map([
[2, 7],
[3, 8],
[4, 9],
[11, 12],
]);
const cachePlatform = (id: number | null, simulator: boolean | null) =>
id === null
? null
: applePlatform(
simulator === true ? (SIMULATOR_PLATFORMS.get(id) ?? id) : id,
);
/**
* Decode `cacheType` according to header generation. Modern headers
* (those carrying `cacheSubType`) use `0 = development`, `1 = production`;
* older single-file headers use the historical `1 = development`,
* `0 = optimized` polarity.
*/
const decodeCacheType = (
cacheType: number | null,
modern: boolean,
): DyldSharedCacheHeader["cache_type"] => {
if (cacheType === null) return null;
if (modern)
return cacheType === 0
? "development"
: cacheType === 1
? "production"
: cacheType === 2
? "multi-cache"
: null;
return cacheType === 1
? "development"
: cacheType === 0
? "production"
: null;
};
/** A main dyld shared cache file and the subcaches it names. */
export class DyldSharedCache {
readonly #byPath: ReadonlyMap<
string,
{ readonly path: string; readonly address: number }
>;
private constructor(
readonly header: DyldSharedCacheHeader,
readonly images: readonly {
readonly path: string;
readonly address: number;
}[],
private readonly files: readonly CacheFile[],
private readonly regions: readonly CacheRegion[],
) {
this.#byPath = new Map(images.map((image) => [image.path, image]));
}
/** Open the main cache file and every subcache beside it. */
static async open(
path: string,
signal?: AbortSignal,
): Promise<DyldSharedCache> {
// Inspection and tracing share this boundary: companion discovery follows
// the actual main cache file, while adapters keep the selected path spelling.
const canonicalPath = await realpath(path);
const main = await CacheFile.open(canonicalPath, "");
const files = [main];
try {
const parsed = await readHeader(main);
const { named, entries: subcaches } =
await DyldSharedCache.#subcacheEntries(main, parsed);
// With named suffixes, dyld strips a development main file's extension
// first: dyld_shared_cache_arm64e.development -> dyld_shared_cache_arm64e.01.development.
const base =
named && canonicalPath.endsWith(DEVELOPMENT_EXTENSION)
? canonicalPath.slice(0, -DEVELOPMENT_EXTENSION.length)
: canonicalPath;
const statuses: DyldCacheSubcache[] = [];
const parsedFiles = [{ file: main, parsed }];
for (const entry of subcaches) {
signal?.throwIfAborted();
let file: CacheFile;
try {
file = await CacheFile.open(`${base}${entry.suffix}`, entry.suffix);
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
cause.code === "ENOENT"
) {
statuses.push({ ...entry, status: "missing", observed_uuid: null });
continue;
}
throw cause;
}
files.push(file);
const subParsed = await readHeader(file);
// UUID, architecture, and VM offset are one admission decision.
// A later overlap or image-address check cannot repair a companion
// that already contradicts the main header.
const status = DyldSharedCache.#admitSubcache(parsed, entry, subParsed);
statuses.push({
...entry,
status,
observed_uuid: subParsed.uuid,
});
if (status === "present") parsedFiles.push({ file, parsed: subParsed });
}
const header = DyldSharedCache.#summary(parsed, parsedFiles, statuses);
const images = await DyldSharedCache.#images(main, parsed, signal);
const regions = parsedFiles.flatMap(({ file, parsed: item }) =>
item.mappings.map((mapping) => {
const fileOffset = safeNumber(
mapping.fileOffset,
"mapping file offset",
);
const size = safeNumber(mapping.size, "mapping size");
const address = safeNumber(mapping.address, "mapping address");
// A truncated file that declares mappings beyond its bytes cannot
// map those addresses; drop the unverified extent.
if (
fileOffset < 0 ||
size <= 0 ||
fileOffset + size > file.size ||
!Number.isSafeInteger(address + size)
)
throw new ArtifactReaderFailure(
"format",
`dyld cache mapping in ${file.suffix === "" ? "the main file" : `subcache ${file.suffix}`} extends beyond its file`,
);
return {
file,
address,
size,
fileOffset,
};
}),
);
const ordered = regions.toSorted(
(left, right) => left.address - right.address,
);
for (let index = 1; index < ordered.length; index++) {
const previous = ordered[index - 1];
const current = ordered[index];
if (
previous !== undefined &&
current !== undefined &&
current.address < previous.address + previous.size
)
throw new ArtifactReaderFailure(
"format",
`Dyld cache VM mappings overlap at ${hex(current.address)} between ${previous.file.suffix || "main"} and ${current.file.suffix || "main"}`,
);
}
if (statuses.every(({ status }) => status === "present")) {
const unmapped = images.find(
({ address }) => cacheRegionAt(ordered, address) === undefined,
);
if (unmapped !== undefined)
throw new ArtifactReaderFailure(
"format",
`Complete dyld cache has an unmapped image address ${hex(unmapped.address)}: ${unmapped.path}`,
);
}
return new DyldSharedCache(header, images, files, ordered);
} catch (cause: unknown) {
await Promise.allSettled(files.map(({ handle }) => handle.close()));
throw cause;
}
}
static async #subcacheEntries(
main: CacheFile,
parsed: ParsedHeader,
): Promise<{
/** Entries carry explicit file suffixes (dyld_subcache_entry v2). */
readonly named: boolean;
readonly entries: {
readonly suffix: string;
readonly uuid: string;
readonly vm_offset: string;
}[];
}> {
if (!has(parsed, FIELD.subCacheArrayCount, 4))
return { named: false, entries: [] };
const offset = parsed.header.readUInt32LE(FIELD.subCacheArrayOffset);
const count = parsed.header.readUInt32LE(FIELD.subCacheArrayCount);
// Headers that include cacheSubType use entries with an explicit file suffix.
const named = has(parsed, FIELD.cacheSubType, 4);
if (count === 0) return { named, entries: [] };
if (count > MAX_SUBCACHES)
throw new ArtifactReaderFailure(
"format",
"dyld cache lists too many subcaches",
);
const size = named ? 56 : 24;
const table = await main.read(offset, count * size);
const entries = Array.from({ length: count }, (_, index) => {
const base = index * size;
const suffix = named
? table.toString("latin1", base + 24, base + 56).replace(/\0.*$/su, "")
: `.${index + 1}`;
if (!/^\.[A-Za-z0-9.]+$/u.test(suffix))
throw new ArtifactReaderFailure(
"format",
"dyld subcache has an unsafe file suffix",
);
return {
suffix,
uuid: uuidText(table.subarray(base, base + 16)),
vm_offset: hex(table.readBigUInt64LE(base + 16)),
};
});
return { named, entries };
}
/**
* Whether a companion file is the subcache the main header named.
* UUID, magic, and `cacheVMOffset` are checked together; failure to match
* the UUID leaves the cache partial, while a matching UUID that contradicts
* the main header is a format error and contributes no mappings.
*/
static #admitSubcache(
main: ParsedHeader,
entry: {
readonly suffix: string;
readonly uuid: string;
readonly vm_offset: string;
},
sub: ParsedHeader,
): "present" | "uuid-mismatch" {
if (sub.uuid !== entry.uuid) return "uuid-mismatch";
if (sub.magic !== main.magic)
throw new ArtifactReaderFailure(
"format",
`dyld subcache ${entry.suffix} architecture ${cacheArchitecture(sub.magic)} does not match the main cache architecture ${cacheArchitecture(main.magic)}`,
);
const mainBase = mappingBase(main);
const actual = mappingBase(sub);
const expected = mainBase + BigInt(entry.vm_offset);
if (actual !== expected)
throw new ArtifactReaderFailure(
"format",
`dyld subcache ${entry.suffix} mapping base ${hex(actual)} disagrees with cache VM offset ${entry.vm_offset} from main base ${hex(mainBase)}`,
);
return "present";
}
static #summary(
parsed: ParsedHeader,
files: readonly {
readonly file: CacheFile;
readonly parsed: ParsedHeader;
}[],
subcaches: readonly DyldCacheSubcache[],
): DyldSharedCacheHeader {
const header = parsed.header;
const u32 = (offset: number): number | null =>
has(parsed, offset, 4) ? header.readUInt32LE(offset) : null;
const u64 = (offset: number): string | null =>
has(parsed, offset, 8) ? hex(header.readBigUInt64LE(offset)) : null;
const platform = u32(FIELD.platform);
const altPlatform = u32(FIELD.altPlatform);
const formatBits = u32(FIELD.formatBits);
const simulator =
formatBits === null ? null : (formatBits & SIMULATOR_CACHE_FLAG) !== 0;
const cacheType = has(parsed, FIELD.cacheType, 8)
? Number(header.readBigUInt64LE(FIELD.cacheType))
: null;
const cacheSubType = cacheType === 2 ? u32(FIELD.cacheSubType) : null;
const symbols = has(parsed, FIELD.symbolFileUuid, 16)
? header.subarray(FIELD.symbolFileUuid, FIELD.symbolFileUuid + 16)
: undefined;
const mappings: DyldCacheMapping[] = files.flatMap(
({ file, parsed: item }) =>
item.mappings.map((mapping) => ({
file: file.suffix,
address: hex(mapping.address),
size: hex(mapping.size),
file_offset: hex(mapping.fileOffset),
max_protection: mapping.maxProt,
initial_protection: mapping.initProt,
})),
);
return {
magic: parsed.magic,
architecture: cacheArchitecture(parsed.magic),
uuid: parsed.uuid,
platform: cachePlatform(platform, simulator),
header_platform: platform === null ? null : applePlatform(platform),
header_alt_platform:
altPlatform === null || altPlatform === 0
? null
: applePlatform(altPlatform),
simulator,
os_version: version(u32(FIELD.osVersion) ?? 0),
alt_platform:
altPlatform === null || altPlatform === 0
? null
: cachePlatform(altPlatform, simulator),
alt_os_version: version(u32(FIELD.altOsVersion) ?? 0),
cache_type: decodeCacheType(
cacheType,
has(parsed, FIELD.cacheSubType, 4),
),
cache_subtype:
cacheSubType !== null
? {
id: cacheSubType,
name:
cacheSubType === 0
? "development"
: cacheSubType === 1
? "production"
: null,
}
: null,
shared_region:
u64(FIELD.sharedRegionStart) === null
? null
: {
start: u64(FIELD.sharedRegionStart) ?? "0x0",
size: u64(FIELD.sharedRegionSize) ?? "0x0",
},
max_slide: u64(FIELD.maxSlide),
mappings,
subcaches: [...subcaches],
symbols_file_uuid:
symbols === undefined || symbols.every((byte) => byte === 0)
? null
: uuidText(symbols),
};
}
static async #images(
main: CacheFile,
parsed: ParsedHeader,
signal?: AbortSignal,
): Promise<{ readonly path: string; readonly address: number }[]> {
const modern = has(parsed, FIELD.imagesCount, 4);
const offset = parsed.header.readUInt32LE(
modern ? FIELD.imagesOffset : FIELD.imagesOffsetOld,
);
const count = parsed.header.readUInt32LE(
modern ? FIELD.imagesCount : FIELD.imagesCountOld,
);
if (count > MAX_IMAGES)
throw new ArtifactReaderFailure(
"limit",
`dyld cache lists more than ${MAX_IMAGES} images`,
);
const table = await main.read(offset, count * 32);
const images: { path: string; address: number }[] = [];
for (let index = 0; index < count; index++) {
if (index % 512 === 0) signal?.throwIfAborted();
images.push({
address: safeNumber(table.readBigUInt64LE(index * 32), "image address"),
path: await main.cString(table.readUInt32LE(index * 32 + 24)),
});
}
return images;
}
/** Locate an image by its exact install path. */
find(
path: string,
): { readonly path: string; readonly address: number } | undefined {
return this.#byPath.get(path);
}
/**
* Whether an install path is listed and its address lies in a mapping of an
* admitted file. This is address coverage: `imageFacts` has to parse a
* compatible Mach-O before the path is a shared-cache load.
*/
locate(path: string): "mapped" | "unverified" | "absent" {
const image = this.find(path);
if (image === undefined) return "absent";
return this.#region(image.address) === undefined ? "unverified" : "mapped";
}
#region(address: number) {
return cacheRegionAt(this.regions, address);
}
/** Parse one cached image's load commands through the cache's VM mappings. */
async imageFacts(
path: string,
): Promise<
{ readonly file: string; readonly facts: MachoImageFacts } | undefined
> {
const image = this.find(path);
if (image === undefined) return undefined;
const region = this.#region(image.address);
if (region === undefined) return undefined;
const base = region.fileOffset + (image.address - region.address);
const available = region.size - (image.address - region.address);
const facts = await readMachoImage(
async (offset, length) =>
region.file.read(
base + offset,
Math.max(0, Math.min(length, available - offset)),
),
available,
);
return { file: region.file.suffix, facts };
}
/** Digest of the main file through the handle its header was parsed from. */
async mainSha256(signal?: AbortSignal): Promise<string> {
const main = this.files[0];
if (main === undefined)
throw new ArtifactReaderFailure("format", "dyld cache has no main file");
return this.hashFile(main, signal);
}
/** SHA-256 of every readable subcache, keyed by suffix, through their open handles. */
async subcacheSha256(signal?: AbortSignal): Promise<Record<string, string>> {
const out: Record<string, string> = {};
for (const file of this.files.slice(1)) {
signal?.throwIfAborted();
out[file.suffix] = await this.hashFile(file, signal);
}
return out;
}
private async hashFile(
file: CacheFile,
signal?: AbortSignal,
): Promise<string> {
// In-place rewrites through the same inode keep this handle valid, so a
// digest is bound to its bytes only when the identity is unchanged.
if (!(await file.unchanged()))
throw new ArtifactReaderFailure(
"integrity",
"dyld cache file changed before its digest was read",
);
const hash = createHash("sha256");
const buffer = Buffer.alloc(HASH_CHUNK_BYTES);
let position = 0;
for (;;) {
signal?.throwIfAborted();
if (position >= file.size) break;
const { bytesRead } = await file.handle.read(
buffer,
0,
Math.min(buffer.length, file.size - position),
position,
);
// A file truncated after open must not yield the surviving prefix's
// digest as the identity of the recorded size.
if (bytesRead === 0)
throw new ArtifactReaderFailure(
"integrity",
"dyld cache file changed while its digest was read",
);
hash.update(buffer.subarray(0, bytesRead));
position += bytesRead;
}
if (position !== file.size)
throw new ArtifactReaderFailure(
"integrity",
"dyld cache file changed while its digest was read",
);
if (!(await file.unchanged()))
throw new ArtifactReaderFailure(
"integrity",
"dyld cache file changed while its digest was read",
);
return hash.digest("hex");
}
async close(): Promise<void> {
await Promise.allSettled(this.files.map(({ handle }) => handle.close()));
}
}
+69 -5
View File
@@ -1,7 +1,7 @@
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 { join, relative, resolve, sep } from "node:path";
import { resolveTreePath } from "../../domain/apple/dyldPaths.js";
import {
@@ -9,12 +9,18 @@ import {
dylibResolutionResultSchema,
traceDylibLoading,
type DylibResolutionResult,
type DylibSharedCacheView,
type DylibTreeEntry,
type DylibTreeView,
type MachoImageFacts,
} from "../../domain/apple/dylibResolution.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { DyldSharedCache } from "./DyldSharedCacheReader.js";
import {
filesystemDyldLinkTarget,
filesystemDyldLookupPath,
} from "./FilesystemDyldPaths.js";
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
const HASH_CHUNK_BYTES = 1024 * 1024;
@@ -61,11 +67,14 @@ class FilesystemTreeView implements DylibTreeView {
async #readEntry(path: string): Promise<DylibTreeEntry | undefined> {
cancelled(this.signal);
const absolute = join(this.root, path);
const absolute = filesystemDyldLookupPath(this.root, path);
try {
const metadata = await lstat(absolute);
if (metadata.isSymbolicLink())
return { kind: "symlink", target: await readlink(absolute) };
return {
kind: "symlink",
target: filesystemDyldLinkTarget(await readlink(absolute)),
};
if (metadata.isDirectory()) return { kind: "directory" };
return metadata.isFile() ? { kind: "file" } : undefined;
} catch (cause: unknown) {
@@ -85,11 +94,18 @@ export const traceDylibResolution = async (options: {
readonly signal?: AbortSignal;
}): Promise<DylibResolutionResult> => {
const parsed = dylibResolutionInputSchema.safeParse(options.parameters);
if (!parsed.success)
if (!parsed.success) {
const issue = parsed.error.issues[0];
const at =
issue !== undefined && issue.path.length > 0
? ` (${issue.path.join(".")} ${issue.message})`
: "";
throw new ArtifactReaderFailure(
"path",
"roots must be normalized paths relative to the analyzed root",
`dylib resolution parameters are invalid${at}`,
);
}
let cache: DyldSharedCache | undefined;
// Canonicalization is inside the translated region: a revoked permission
// keeps its reason and path.
let root = options.rootPath;
@@ -99,6 +115,7 @@ export const traceDylibResolution = async (options: {
.split(sep)
.join("/");
const view = new FilesystemTreeView(root, options.signal);
cache = await openSharedCache(parsed.data.shared_cache, options.signal);
const { roots: requested, unclassified } =
parsed.data.roots !== undefined
? { roots: parsed.data.roots, unclassified: [] }
@@ -112,6 +129,7 @@ export const traceDylibResolution = async (options: {
...(parsed.data.architecture === undefined
? {}
: { architecture: parsed.data.architecture }),
...(cache === undefined ? {} : { sharedCache: sharedCacheView(cache) }),
...(options.signal === undefined ? {} : { signal: options.signal }),
});
// Each digest must describe the same file whose headers were parsed.
@@ -137,10 +155,24 @@ export const traceDylibResolution = async (options: {
"integrity",
`Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`,
);
// Bind the trace to the cache bytes that produced it: a replaced cache
// can retain header UUID/architecture while changing resolutions.
const cacheIdentity =
cache === undefined || parsed.data.shared_cache === undefined
? null
: {
path: parsed.data.shared_cache,
uuid: cache.header.uuid,
architecture: cache.header.architecture,
os_version: cache.header.os_version,
main_file_sha256: await cache.mainSha256(options.signal),
subcache_sha256: await cache.subcacheSha256(options.signal),
};
return dylibResolutionResultSchema.parse({
...trace,
root_path: options.rootPath,
target_sha256: options.targetSha256,
shared_cache: cacheIdentity,
images: trace.images.map((image) => ({
...image,
sha256: digests.get(image.path),
@@ -161,9 +193,41 @@ export const traceDylibResolution = async (options: {
{ cause },
);
throw cause;
} finally {
await cache?.close();
}
};
const openSharedCache = async (
path: string | undefined,
signal?: AbortSignal,
): Promise<DyldSharedCache | undefined> => {
if (path === undefined) return undefined;
try {
return await DyldSharedCache.open(resolve(path), signal);
} catch (cause: unknown) {
if (missing(cause))
throw new ArtifactReaderFailure(
"path",
"shared_cache does not name an existing dyld shared cache file",
{ cause },
);
throw cause;
}
};
const sharedCacheView = (cache: DyldSharedCache): DylibSharedCacheView => ({
architecture: cache.header.architecture,
platforms: [cache.header.platform, cache.header.alt_platform].filter(
(platform) => platform !== null,
),
unavailableSubcaches: cache.header.subcaches
.filter(({ status }) => status !== "present")
.map(({ suffix, status }) => `${suffix} (${status})`),
lookup: (path) => cache.locate(path),
image: async (path) => (await cache.imageFacts(path))?.facts,
});
/**
* Every Mach-O in the bundle with an executable slice is its own process root.
* Mach-O files that do not parse are returned separately: whether they are
@@ -0,0 +1,53 @@
import { expect, it } from "vitest";
import { resolveTreePath } from "../../domain/apple/dyldPaths.js";
import type { DylibTreeEntry } from "../../domain/apple/dylibResolution.js";
import {
filesystemDyldLinkTarget,
filesystemDyldLookupPath,
} from "./FilesystemDyldPaths.js";
it("normalizes Windows relative links before containment checks without probing an escaped path", async () => {
const requested: string[] = [];
const result = await resolveTreePath(
{
entry: async (path: string): Promise<DylibTreeEntry | undefined> => {
requested.push(path);
return path === "link"
? {
kind: "symlink",
target: filesystemDyldLinkTarget("..\\outside.dylib", "win32"),
}
: undefined;
},
image: async () => ({ status: "not-mach-o" }),
},
"link",
);
expect(result).toEqual({ kind: "escapes" });
expect(requested).toEqual(["link"]);
});
it("keeps POSIX backslash bytes and preserves Windows dot segments for segment-wise traversal", () => {
expect(filesystemDyldLinkTarget("..\\outside.dylib", "darwin")).toBe(
"..\\outside.dylib",
);
expect(filesystemDyldLinkTarget("nested\\..\\target", "win32")).toBe(
"nested/../target",
);
expect(filesystemDyldLinkTarget("C:\\outside", "win32")).toBe("/C:/outside");
expect(filesystemDyldLinkTarget("C:/literal-posix-directory", "darwin")).toBe(
"C:/literal-posix-directory",
);
expect(() => filesystemDyldLinkTarget("C:drive-relative", "win32")).toThrow(
"Windows drive working directory",
);
});
it("does not let Windows reinterpret a literal backslash-bearing loader name as traversal", () => {
expect(() =>
filesystemDyldLookupPath("C:\\root", "..\\outside.dylib", "win32"),
).toThrow("cannot be represented on Windows");
expect(
filesystemDyldLookupPath("/root", "literal\\name.dylib", "darwin"),
).toBe("/root/literal\\name.dylib");
});
@@ -0,0 +1,39 @@
import { posix, win32 } from "node:path";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
/**
* Convert host filesystem separators into the tree view's POSIX traversal
* namespace. Dot segments stay intact so they apply after symlink expansion;
* POSIX backslash filename bytes are never reinterpreted as separators.
*/
export const filesystemDyldLinkTarget = (
reported: string,
platform: NodeJS.Platform = process.platform,
): string => {
if (platform !== "win32") return reported;
const portable = reported.replaceAll("\\", "/");
if (win32.isAbsolute(reported))
return portable.startsWith("/") ? portable : `/${portable}`;
if (/^[A-Za-z]:/u.test(reported))
throw new ArtifactReaderFailure(
"unavailable",
`Drive-relative symlink target depends on a Windows drive working directory: ${reported}`,
);
return portable;
};
/** Map a portable lookup to the host without allowing extra native separators to escape it. */
export const filesystemDyldLookupPath = (
root: string,
lookup: string,
platform: NodeJS.Platform = process.platform,
): string => {
if (platform === "win32" && lookup.includes("\\"))
throw new ArtifactReaderFailure(
"unavailable",
`Literal backslashes in a POSIX dyld lookup cannot be represented on Windows: ${lookup}. Analyze this tree on a POSIX host.`,
);
return platform === "win32"
? win32.join(root, lookup)
: posix.join(root, lookup);
};
+13
View File
@@ -10,6 +10,7 @@ export const LC = {
RPATH: 0x8000001c,
DYLD_ENVIRONMENT: 0x27,
CODE_SIGNATURE: 0x1d,
BUILD_VERSION: 0x32,
} as const;
export const CPU = {
@@ -75,6 +76,18 @@ export const codeSignatureCommand = (): Uint8Array => {
return bytes;
};
/** `build_version_command` for a `PLATFORM_*` value, with no tools. */
export const buildVersionCommand = (platform: number): Uint8Array => {
const bytes = new Uint8Array(24);
const view = new DataView(bytes.buffer);
view.setUint32(0, LC.BUILD_VERSION, true);
view.setUint32(4, 24, true);
view.setUint32(8, platform, true);
view.setUint32(12, 0x000f0000, true);
view.setUint32(16, 0x001a0000, 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 };
@@ -4,6 +4,7 @@ import {
CPU,
FILE_TYPE,
LC,
buildVersionCommand,
codeSignatureCommand,
dyldEnvironmentCommand,
dylibCommand,
@@ -40,6 +41,9 @@ describe("Mach-O load command reader", () => {
rpathCommand("/usr/lib/swift"),
dyldEnvironmentCommand("DYLD_FRAMEWORK_PATH=@executable_path"),
codeSignatureCommand(),
// A zippered image builds for macOS and Mac Catalyst.
buildVersionCommand(1),
buildVersionCommand(6),
],
});
const facts = await read(image);
@@ -54,6 +58,10 @@ describe("Mach-O load command reader", () => {
rpaths: ["@loader_path/Frameworks", "/usr/lib/swift"],
dyld_environment: ["DYLD_FRAMEWORK_PATH=@executable_path"],
code_signature_present: true,
platforms: [
{ id: 1, name: "macos" },
{ id: 6, name: "maccatalyst" },
],
});
expect(
slice?.dependencies.map(
@@ -166,6 +174,37 @@ describe("Mach-O load command reader", () => {
});
describe("Mach-O load command reader inputs", () => {
it("reads platforms from LC_VERSION_MIN_* and keeps unknown ids", async () => {
const versionMin = new Uint8Array(16);
const view = new DataView(versionMin.buffer);
view.setUint32(0, 0x25, true);
view.setUint32(4, 16, true);
const facts = await read(
machoImage({ commands: [versionMin, buildVersionCommand(99)] }),
);
expect(facts.status === "parsed" && facts.slices[0]?.platforms).toEqual([
{ id: 2, name: "ios" },
{ id: 99, name: null },
]);
// Legacy version-min commands on Intel slices denote simulators.
const intel = await read(
machoImage({ cpu: CPU.x86_64, commands: [versionMin] }),
);
expect(intel.status === "parsed" && intel.slices[0]?.platforms).toEqual([
{ id: 7, name: "ios-simulator" },
]);
// A 16-byte LC_BUILD_VERSION, and one that declares a tool it lacks.
const truncated = buildVersionCommand(1).slice(0, 16);
new DataView(truncated.buffer).setUint32(4, 16, true);
const missingTool = buildVersionCommand(1);
new DataView(missingTool.buffer).setUint32(20, 1, true);
for (const command of [truncated, missingTool])
expect(await read(machoImage({ commands: [command] }))).toEqual({
status: "malformed",
reason: "load command 0 is too short for a platform version",
});
});
it("names 32-bit and non-Mach-O inputs without guessing", async () => {
const i386 = await read(
machoImage({ cpu: { type: 7, subtype: 3 }, wide: false }),
+52 -1
View File
@@ -1,3 +1,4 @@
import { applePlatform } from "../../domain/apple/applePlatforms.js";
import type {
MachoDependency,
MachoImageFacts,
@@ -34,6 +35,14 @@ const LC_LOAD_UPWARD_DYLIB = 0x80000023;
const LC_RPATH = 0x8000001c;
const LC_DYLD_ENVIRONMENT = 0x27;
const LC_CODE_SIGNATURE = 0x1d;
const LC_BUILD_VERSION = 0x32;
/** LC_VERSION_MIN_* commands imply their platform. */
const VERSION_MIN_PLATFORMS: ReadonlyMap<number, number> = new Map([
[0x24, 1],
[0x25, 2],
[0x2f, 3],
[0x30, 4],
]);
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
const DYLIB_USE_MARKER = 0x1a741800;
@@ -242,6 +251,7 @@ const decodeCommands = (
rpaths: [],
dyld_environment: [],
code_signature_present: false,
platforms: [],
};
let offset = 0;
for (let index = 0; index < header.commandCount; index++) {
@@ -258,9 +268,16 @@ const decodeCommands = (
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
);
const body = bytes.subarray(offset, offset + size);
decodeCommand(command, body, index, slice);
decodeCommand(command, body, index, slice, header.cpuType);
offset += size;
}
// A header that declares fewer commands than its sizeofcmds could hide
// dependency loads in the unconsumed bytes.
if (offset !== bytes.byteLength)
throw new MachoFormatIssue(
"malformed",
`load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`,
);
return slice;
};
@@ -269,6 +286,7 @@ const decodeCommand = (
body: Uint8Array,
index: number,
slice: MachoSlice,
cpuType: number,
): void => {
if (command === LC_ID_DYLIB) {
slice.install_name = commandString(body, 24, index);
@@ -292,6 +310,39 @@ const decodeCommand = (
slice.code_signature_present = true;
return;
}
const implied = VERSION_MIN_PLATFORMS.get(command);
if (command === LC_BUILD_VERSION || implied !== undefined) {
// version_min_command is 16 bytes; build_version_command is 24 bytes
// followed by ntools 8-byte build_tool_version records.
const required =
implied !== undefined
? 16
: body.byteLength < 24
? 24
: 24 + viewOf(body).getUint32(20, true) * 8;
if (body.byteLength < required)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for a platform version`,
);
const declared = implied ?? viewOf(body).getUint32(8, true);
// Legacy LC_VERSION_MIN_* commands on Intel slices denote simulator
// platforms; Apple's decoder branches on the CPU type for these commands.
const intel = cpuType === 7 || cpuType === (7 | 0x01000000);
const id =
implied !== undefined && intel
? declared === 2
? 7
: declared === 3
? 8
: declared === 4
? 9
: declared
: declared;
if (!slice.platforms.some((platform) => platform.id === id))
slice.platforms.push(applePlatform(id));
return;
}
const name = DEPENDENCY_COMMANDS.get(command);
if (name !== undefined)
slice.dependencies.push(decodeDependency(name, body, index));
+35
View File
@@ -3,6 +3,7 @@ import { Cli, z } from "incur";
import { projectAndroidApplicationEvidence } from "../application/android/AndroidApplicationService.js";
import { projectAppleApplicationEvidence } from "../application/apple/AppleApplicationService.js";
import { inspectDyldSharedCacheEvidence } from "../application/apple/DyldSharedCacheService.js";
import {
compareApplicationVersionsEvidenceValidated,
compareJavaScriptExportShapesEvidenceValidated,
@@ -83,6 +84,7 @@ export const registerApplicationCommands = (
workflow: compareJavaScriptExportShapesEvidenceValidated,
});
registerObligationLedgerCommand(cli, logger);
registerDyldSharedCacheCommand(cli, logger);
registerCoverageCommand(cli, logger);
registerJsonCommand({
cli,
@@ -114,6 +116,39 @@ export const registerApplicationCommands = (
});
};
/** `inspect-dyld-shared-cache <cache> [--image <install path>]...` */
const registerDyldSharedCacheCommand = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.inspectDyldSharedCache, {
description:
"Inspect a dyld shared cache file set and, optionally, cached images",
args: z.object({
cachePath: z.string().describe("Main dyld shared cache file"),
}),
options: z.object({
image: z
.array(z.string())
.optional()
.describe("Install path of a cached image to inspect (repeatable)"),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.inspectDyldSharedCache, async () => {
const result = await inspectDyldSharedCacheEvidence({
cache_path: args.cachePath,
...(options.image === undefined ? {} : { images: options.image }),
});
return result.ok
? jsonValueSchema.parse(result.value)
: {
error: "Application workflow failed",
...projectAnalysisError(result.error),
};
}),
});
};
const registerObligationLedgerCommand = (
cli: CliInstance,
logger: Logger,
+9
View File
@@ -80,6 +80,12 @@ const registerDylibResolutionCommand = (
.enum(["arm64", "arm64e", "x86_64"])
.optional()
.describe("Trace only this slice of each root"),
sharedCache: z
.string()
.optional()
.describe(
"Main dyld shared cache file used to answer system install paths",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.traceDylibResolution, () =>
@@ -91,6 +97,9 @@ const registerDylibResolutionCommand = (
...(options.architecture === undefined
? {}
: { architecture: options.architecture }),
...(options.sharedCache === undefined
? {}
: { shared_cache: options.sharedCache }),
},
logger,
),
+1
View File
@@ -30,6 +30,7 @@ export const CLI_COMMANDS = Object.freeze({
decodeInterfaceBuilder: "decode-interface-builder",
inspectKeyedArchive: "inspect-keyed-archive",
traceDylibResolution: "trace-dylib-resolution",
inspectDyldSharedCache: "inspect-dyld-shared-cache",
inspectNativeDataType: "inspect-native-data-type",
inspectNativeLoadImage: "inspect-native-load-image",
readBytes: "read-bytes",
+26
View File
@@ -1,4 +1,8 @@
import { applicationVersionComparisonResultSchema } from "../domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
import {
dyldSharedCacheResultSchema,
inspectDyldSharedCacheInputSchema,
} from "../domain/apple/dyldSharedCache.js";
import { applicationFeatureTraceResultSchema } from "../domain/javascript/javascriptFeatureTraceSchemas.js";
import { javaScriptSemanticTraceResultSchema } from "../domain/javascript/javascriptSemanticTraceSchemas.js";
import { javaScriptExportShapeComparisonResultSchema } from "../domain/javascript/javascriptExportShapeComparisonSchemas.js";
@@ -295,6 +299,25 @@ export const APPLICATION_TOOL_CONTRACTS = [
},
],
},
{
name: "inspect_dyld_shared_cache",
...toolContractMetadata("inspect_dyld_shared_cache"),
description:
"Inspect an Apple dyld shared cache file set selected by path, such as /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e or one extracted from an IPSW. Reports the cache UUID, architecture, platform and OS version, VM mappings, every subcache with UUID verification, and the complete image list. For each requested install path, it reads that image's load commands (install name, dependencies, rpaths) through the cache mappings. Pure file parsing: nothing is executed, slid, or extracted.",
kind: "application",
inputSchema: inspectDyldSharedCacheInputSchema,
outputSchema: evidenceResultOf(dyldSharedCacheResultSchema),
examples: [
{
title: "Inspect the host's arm64e shared cache and libSystem",
input: {
cache_path:
"/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e",
images: ["/usr/lib/libSystem.B.dylib"],
},
},
],
},
] as const satisfies readonly ToolContract[];
/** Resolve one named application contract without relying on array position. */
@@ -325,6 +348,9 @@ export function applicationToolContract(
export function applicationToolContract(
name: "project_apple_application_graph",
): (typeof APPLICATION_TOOL_CONTRACTS)[8];
export function applicationToolContract(
name: "inspect_dyld_shared_cache",
): (typeof APPLICATION_TOOL_CONTRACTS)[9];
export function applicationToolContract(
name: (typeof APPLICATION_TOOL_CONTRACTS)[number]["name"],
): (typeof APPLICATION_TOOL_CONTRACTS)[number] {
+1 -1
View File
@@ -92,7 +92,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [
),
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.",
"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. Absolute or system paths outside the analyzed root stay outside-target unless shared_cache names a dyld shared cache for the process's CPU family and platform; listed images then resolve to shared-cache. Select roots or one architecture to narrow large bundles.",
dylibResolutionInputSchema,
),
] as const satisfies readonly ToolContract[];
+1
View File
@@ -285,6 +285,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
evaluate_reconstruction_coverage: effects(),
project_android_application_graph: evidence,
project_apple_application_graph: evidence,
inspect_dyld_shared_cache: evidence,
open_binary: effects({ mutatesSession: true, launchesProcess: true }),
close_binary: effects({
mutatesSession: true,
+35
View File
@@ -0,0 +1,35 @@
import { z } from "zod";
/** `PLATFORM_*` values from Apple's `mach-o/loader.h`. */
const APPLE_PLATFORM_NAMES: Readonly<Record<number, string>> = {
1: "macos",
2: "ios",
3: "tvos",
4: "watchos",
5: "bridgeos",
6: "maccatalyst",
7: "ios-simulator",
8: "tvos-simulator",
9: "watchos-simulator",
10: "driverkit",
11: "visionos",
12: "visionos-simulator",
};
/** A reported platform number and its `loader.h` name, when known. */
export const applePlatformSchema = z.strictObject({
id: z.number().int(),
name: z.string().nullable(),
});
export type ApplePlatform = z.infer<typeof applePlatformSchema>;
/** Name a reported platform number without discarding unknown values. */
export const applePlatform = (id: number): ApplePlatform => ({
id,
name: APPLE_PLATFORM_NAMES[id] ?? null,
});
/** Display one platform, falling back to its number. */
export const applePlatformLabel = ({ id, name }: ApplePlatform): string =>
name ?? `platform ${id}`;
+11
View File
@@ -37,6 +37,7 @@ export const resolveTreePath = async (
if (entry.kind === "symlink") {
hops += 1;
if (hops > MAX_SYMLINK_HOPS) return { kind: "absent" };
// Tree providers convert host link syntax to this POSIX namespace.
if (entry.target.startsWith("/")) return { kind: "escapes" };
pending.unshift(...segmentsOf(entry.target));
continue;
@@ -108,3 +109,13 @@ export const compatibleSlice = (
}
return undefined;
};
/**
* Whether a cache serves a process architecture. Compatibility is directional:
* an arm64e cache also serves arm64 processes and an x86_64h cache also serves
* x86_64 ones, but a generic cache lacks the arm64e or x86_64h ABI.
*/
export const cacheServes = (cache: string, process: string): boolean =>
cache === process ||
(cache === "arm64e" && process === "arm64") ||
(cache === "x86_64h" && process === "x86_64");
+102
View File
@@ -0,0 +1,102 @@
import { z } from "zod";
import { applePlatformSchema } from "./applePlatforms.js";
import { digestSchema } from "../digests.js";
import { machoSliceSchema } from "./dylibResolution.js";
const hexSchema = z.string().regex(/^0x[0-9a-f]+$/u);
const uuidSchema = z
.string()
.regex(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/u);
/** One cached image selected by its exact install path. */
export const inspectDyldSharedCacheInputSchema = z.strictObject({
cache_path: z.string().min(1),
images: z.array(z.string().min(1)).min(1).optional(),
});
const mappingSchema = z.object({
file: z.string(),
address: hexSchema,
size: hexSchema,
file_offset: hexSchema,
max_protection: z.number().int(),
initial_protection: z.number().int(),
});
const subcacheSchema = z.object({
suffix: z.string(),
uuid: uuidSchema,
vm_offset: hexSchema,
status: z.enum(["present", "missing", "uuid-mismatch"]),
observed_uuid: uuidSchema.nullable(),
});
const headerSchema = z.object({
magic: z.string(),
architecture: z.string(),
uuid: z.string(),
platform: applePlatformSchema.nullable(),
/** Producer platform fields before simulator-family normalization. */
header_platform: applePlatformSchema.nullable().default(null),
header_alt_platform: applePlatformSchema.nullable().default(null),
simulator: z.boolean().nullable().default(null),
os_version: z.string().nullable(),
alt_platform: applePlatformSchema.nullable(),
alt_os_version: z.string().nullable(),
cache_type: z.enum(["development", "production", "multi-cache"]).nullable(),
cache_subtype: z
.object({
id: z.number().int().nonnegative(),
name: z.enum(["development", "production"]).nullable(),
})
.nullable()
.default(null)
.describe(
"Multi-cache subtype from the producer header: 0 is development, 1 is production; unknown values retain their ID with a null name. Null when absent or not applicable.",
),
shared_region: z.object({ start: hexSchema, size: hexSchema }).nullable(),
max_slide: hexSchema.nullable(),
mappings: z.array(mappingSchema),
subcaches: z.array(subcacheSchema),
symbols_file_uuid: uuidSchema.nullable(),
});
const inspectedImageSchema = z.object({
path: z.string(),
status: z.enum(["parsed", "malformed", "unsupported", "absent", "unmapped"]),
address: hexSchema.nullable(),
file: z.string().nullable(),
reason: z.string().nullable(),
slices: z.array(machoSliceSchema),
});
/** Header, mappings, subcaches, and the complete image list of one cache. */
export const dyldSharedCacheResultSchema = z.object({
cache_path: z.string(),
main_file_sha256: digestSchema,
...headerSchema.shape,
images_total: z.number().int().nonnegative(),
images: z.array(z.object({ path: z.string(), address: hexSchema })),
inspected_images: z.array(inspectedImageSchema),
/** SHA-256 of every readable subcache backing inspected images, keyed by suffix. */
subcache_sha256: z.record(z.string(), digestSchema).default({}),
coverage: z.object({
status: z.enum(["complete", "partial"]),
unreadable_subcaches: z.array(z.string()),
}),
limitations: z.array(z.string()),
});
export type DyldCacheMapping = z.infer<typeof mappingSchema>;
export type DyldCacheSubcache = z.infer<typeof subcacheSchema>;
export type DyldSharedCacheHeader = z.infer<typeof headerSchema>;
export type DyldSharedCacheResult = z.infer<typeof dyldSharedCacheResultSchema>;
export type InspectedCacheImage = z.infer<typeof inspectedImageSchema>;
/** Limitations that bound claims made from one cache file set. */
export const DYLD_SHARED_CACHE_LIMITATIONS = [
"The cache describes the operating system it was built for; it is evidence about this cache file set, not about another host.",
"Image headers are read through the cache's VM mappings; slide info, local symbols, and code signatures are not parsed, and images are not extracted.",
"Install paths are matched exactly against the cache image table; dyld alias canonicalization is not modeled, so an alias may report absent or undetermined though dyld would load it from the cache.",
];
+367 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
traceDylibLoading,
type DylibSharedCacheView,
type DylibTreeEntry,
type DylibTreeView,
type MachoDependency,
@@ -17,6 +18,7 @@ const slice = (overrides: Partial<MachoSlice> = {}): MachoSlice => ({
rpaths: [],
dyld_environment: [],
code_signature_present: true,
platforms: [{ id: 1, name: "macos" }],
...overrides,
});
@@ -144,7 +146,7 @@ describe("dyld path expansion", () => {
}),
[plugin]: parsed(
slice({
file_type: "bundle",
file_type: "dylib",
dependencies: [dependency("@rpath/libshared.dylib")],
}),
),
@@ -300,13 +302,26 @@ describe("dyld resolution outcomes", () => {
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.edges.map(({ resolution }) => resolution.status)).toEqual([
"conditional",
"undetermined",
"undetermined",
]);
expect(trace.findings[0]?.explanation).toContain(
"Contents/MacOS/../Overrides/libfound.dylib",
);
expect(trace.edges[0]?.candidates.map(({ outcome }) => outcome)).toEqual([
"absent",
"resolved",
]);
expect(trace.findings[0]?.explanation).toContain(
"Unmodeled environment overrides",
);
expect(trace.findings[0]?.explanation).toContain(
"does not establish which image dyld will load",
);
});
it("reports non-Mach-O, malformed and wrong-architecture candidates", async () => {
@@ -338,6 +353,7 @@ describe("dyld resolution outcomes", () => {
status: "partial",
unparsed_images: ["Contents/MacOS/b/lib.dylib"],
roots_without_architecture: [],
unverified_shared_cache_images: [],
});
expect(trace.images.map(({ path }) => path)).toEqual([
MAIN,
@@ -512,6 +528,7 @@ describe("dyld slice compatibility and coverage", () => {
status: "partial",
unparsed_images: [broken],
roots_without_architecture: [],
unverified_shared_cache_images: [],
});
expect(trace.images.find(({ path }) => path === broken)?.reason).toBe(
"load command 0 has invalid cmdsize 0",
@@ -519,6 +536,229 @@ describe("dyld slice compatibility and coverage", () => {
});
});
/** A macOS arm64e cache that lists libSystem and libswiftCore. */
const cache = (
overrides: Partial<DylibSharedCacheView> = {},
): DylibSharedCacheView => ({
architecture: "arm64e",
platforms: [
{ id: 1, name: "macos" },
{ id: 6, name: "maccatalyst" },
],
unavailableSubcaches: [],
lookup: (path: string) =>
[
"/usr/lib/libSystem.B.dylib",
"/usr/lib/swift/libswiftCore.dylib",
].includes(path)
? "mapped"
: "absent",
image: () => Promise.resolve(parsed(slice({ architecture: "arm64e" }))),
...overrides,
});
describe("shared cache resolution", () => {
it("resolves cached system paths before bundled fallbacks", 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("/usr/lib/libgone.dylib"),
],
}),
"Contents/Frameworks/libswiftCore.dylib": parsed(slice()),
}),
{ roots: [MAIN], sharedCache: cache() },
);
expect(
trace.edges.map(({ install_name: name, resolution, candidates }) => [
name,
resolution,
candidates.map(({ outcome }) => outcome),
]),
).toEqual([
[
"/usr/lib/libSystem.B.dylib",
{ status: "shared-cache", image: "/usr/lib/libSystem.B.dylib" },
["shared-cache"],
],
[
"@rpath/libswiftCore.dylib",
{ status: "shared-cache", image: "/usr/lib/swift/libswiftCore.dylib" },
["shared-cache"],
],
[
"/usr/lib/libgone.dylib",
{ status: "undetermined", image: null },
["outside-target"],
],
]);
expect(trace.images.map(({ path }) => path)).toEqual([MAIN]);
expect(trace.coverage.status).toBe("complete");
expect(trace.limitations).toContainEqual(
expect.stringContaining("transitive dependencies were not traversed"),
);
expect(trace.limitations).toContainEqual(
expect.stringContaining(
"looked up only in the supplied dyld shared cache",
),
);
expect(trace.limitations).not.toContainEqual(
expect.stringContaining("are not evaluated, including /System"),
);
});
it("does not treat mapped bytes as a load until they parse as a compatible Mach-O", async () => {
const root = memoryView({
[MAIN]: executable({
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
});
const notMachO = await traceDylibLoading(root, {
roots: [MAIN],
sharedCache: cache({
image: () => Promise.resolve({ status: "not-mach-o" }),
}),
});
expect(notMachO.edges[0]?.resolution).toEqual({
status: "undetermined",
image: null,
});
expect(notMachO.coverage).toMatchObject({
status: "partial",
unverified_shared_cache_images: ["/usr/lib/libSystem.B.dylib"],
});
expect(notMachO.images.map(({ path }) => path)).toEqual([MAIN]);
const wrongArchitecture = await traceDylibLoading(root, {
roots: [MAIN],
sharedCache: cache({
image: () => Promise.resolve(parsed(slice({ architecture: "x86_64" }))),
}),
});
expect(wrongArchitecture.edges[0]?.candidates[0]?.outcome).toBe(
"undetermined",
);
});
it("keeps a cache hit conditional after an earlier undeterminable candidate", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: ["/opt/vendor/lib", "/usr/lib/swift"],
dependencies: [dependency("@rpath/libswiftCore.dylib")],
}),
}),
{ roots: [MAIN], sharedCache: cache() },
);
expect(trace.edges[0]?.resolution).toEqual({
status: "conditional",
image: "/usr/lib/swift/libswiftCore.dylib",
});
expect(trace.edges[0]?.candidates.map(({ outcome }) => outcome)).toEqual([
"outside-target",
"shared-cache",
]);
});
});
describe("shared cache applicability", () => {
it("does not consult a cache for another CPU family or platform", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
architecture: "x86_64",
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
"Contents/MacOS/Phone": executable({
platforms: [{ id: 2, name: "ios" }],
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
"Contents/MacOS/Old": executable({
platforms: [],
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
}),
{
roots: [MAIN, "Contents/MacOS/Phone", "Contents/MacOS/Old"],
sharedCache: cache(),
},
);
expect(trace.edges.map(({ candidates }) => candidates[0]?.outcome)).toEqual(
["outside-target", "outside-target", "outside-target"],
);
expect(trace.limitations).toEqual(
expect.arrayContaining([
`The supplied shared cache was not used for ${MAIN} (x86_64): the arm64e cache does not serve x86_64 processes. Its absolute paths stay outside-target.`,
"The supplied shared cache was not used for Contents/MacOS/Phone (arm64): the cache serves macos and maccatalyst processes, not ios. Its absolute paths stay outside-target.",
"The supplied shared cache was not used for Contents/MacOS/Old (arm64): the process records no LC_BUILD_VERSION or LC_VERSION_MIN platform. Its absolute paths stay outside-target.",
]),
);
const generic = await traceDylibLoading(
memoryView({
[MAIN]: executable({
architecture: "arm64e",
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
}),
{ roots: [MAIN], sharedCache: cache({ architecture: "arm64" }) },
);
// A generic arm64 cache lacks the arm64e ABI.
expect(generic.edges[0]?.candidates[0]?.outcome).toBe("outside-target");
const unknownPlatform = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
}),
{ roots: [MAIN], sharedCache: cache({ platforms: [] }) },
);
expect(unknownPlatform.edges[0]?.candidates[0]?.outcome).toBe(
"outside-target",
);
});
it("leaves images in unavailable subcaches undetermined", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
}),
{
roots: [MAIN],
sharedCache: cache({
unavailableSubcaches: [".01 (missing)"],
lookup: () => "unverified",
}),
},
);
expect(trace.edges[0]?.resolution).toEqual({
status: "undetermined",
image: null,
});
expect(trace.coverage).toMatchObject({
status: "partial",
unverified_shared_cache_images: ["/usr/lib/libSystem.B.dylib"],
});
expect(trace.limitations).toContainEqual(
expect.stringContaining("Subcaches .01 (missing)"),
);
});
it("states that absolute paths are not evaluated without a cache", async () => {
const trace = await traceDylibLoading(
memoryView({ [MAIN]: executable({}) }),
{ roots: [MAIN] },
);
expect(trace.limitations).toContainEqual(
expect.stringContaining("Pass shared_cache"),
);
});
});
describe("lazily loaded dependencies", () => {
it("resolves lazy loads without traversing them or reporting launch failure", async () => {
const trace = await traceDylibLoading(
@@ -603,3 +843,127 @@ describe("conditional loads", () => {
).toContain(`${VENDOR} loads only conditionally; if it loads,`);
});
});
describe("dependency loadability", () => {
it.each(["execute", "bundle", "other"] as const)(
"does not load cached or in-root %s images as dependencies",
async (fileType) => {
for (const useCache of [true, false]) {
const path = useCache
? "/usr/lib/libSystem.B.dylib"
: "@executable_path/libinvalid.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({ dependencies: [dependency(path)] }),
"Contents/MacOS/libinvalid.dylib": parsed(
slice({ file_type: fileType }),
),
}),
{
roots: [MAIN],
...(useCache
? {
sharedCache: cache({
image: async () =>
parsed(
slice({ architecture: "arm64e", file_type: fileType }),
),
}),
}
: {}),
},
);
expect(trace.edges[0]?.resolution).toEqual({
status: "undetermined",
image: null,
});
}
},
);
it.each([{ platforms: [] }, { platforms: [{ id: 2, name: "ios" }] }])(
"keeps cached unknown or incompatible platforms uncertain (%j)",
async ({ platforms }) => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [dependency("/usr/lib/libSystem.B.dylib")],
}),
}),
{
roots: [MAIN],
sharedCache: cache({
image: async () =>
parsed(slice({ architecture: "arm64e", platforms })),
}),
},
);
expect(trace.edges[0]?.resolution.status).toBe("undetermined");
expect(trace.coverage.status).toBe("partial");
},
);
});
it("propagates embedded search-path uncertainty through found, missing, lazy and descendant edges", async () => {
const child = "Contents/MacOS/child.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: ["DYLD_LIBRARY_PATH=/tmp"],
dependencies: [
dependency("@executable_path/child.dylib"),
dependency("@executable_path/missing.dylib"),
dependency("@executable_path/lazy.dylib", {
command: "LC_LAZY_LOAD_DYLIB",
}),
dependency("/usr/lib/libSystem.B.dylib"),
],
}),
[child]: parsed(
slice({ dependencies: [dependency("@loader_path/gone.dylib")] }),
),
}),
{ roots: [MAIN], sharedCache: cache() },
);
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
"conditional",
"undetermined",
"undetermined",
"conditional",
"undetermined",
]);
expect(trace.edges.at(-1)?.loader_conditional).toBe(true);
expect(trace.findings.map(({ kind }) => kind)).toEqual([
"dyld-environment-present",
]);
});
it.each([
"DYLD_PRINT_LIBRARIES=1",
"DYLD_PRINT_RPATHS=1",
"DYLD_LIBRARY_PATH_LOG=/tmp",
"DYLD_LIBRARY_PATH",
])(
"retains %s as an observation without adding path uncertainty",
async (setting) => {
const child = "Contents/MacOS/child.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dyld_environment: [setting],
dependencies: [dependency("@executable_path/child.dylib")],
}),
[child]: parsed(slice()),
}),
{ roots: [MAIN] },
);
expect(trace.edges[0]?.resolution.status).toBe("resolved");
expect(trace.coverage.status).toBe("complete");
expect(
trace.images.find(({ path }) => path === MAIN)?.slices[0]
?.dyld_environment,
).toEqual([setting]);
expect(trace.findings.map(({ kind }) => kind)).toContain(
"dyld-environment-present",
);
},
);
+178 -177
View File
@@ -1,184 +1,71 @@
import { z } from "zod";
import {
compatibleSlice,
cacheServes,
directoryOf,
expandPrefix,
joinPath,
resolveTreePath,
type Expansion,
} from "./dyldPaths.js";
import { digestSchema } from "../digests.js";
import {
DYLIB_RESOLUTION_LIMITATIONS,
deriveFindings,
cacheMismatch,
sharedCacheLimitations,
type UnservedRoot,
} from "./dylibResolutionFindings.js";
import type {
MachoDependency,
MachoSlice,
MachoImageFacts,
DylibTreeView,
DylibSharedCacheView,
DylibCandidate,
DylibEdge,
DylibTrace,
} from "./dylibResolutionSchema.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);
export {
dylibResolutionInputSchema,
machoSliceSchema,
dylibResolutionResultSchema,
type MachoDependency,
type MachoSlice,
type MachoImageFacts,
type DylibTreeEntry,
type DylibTreeView,
type DylibSharedCacheView,
type DylibResolutionResult,
type DylibEdge,
type DylibFinding,
type DylibTrace,
} from "./dylibResolutionSchema.js";
/** 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",
"unsupported",
"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(),
/**
* The loader was reached through a conditional edge, so it might not load;
* this edge describes what happens only if it does.
*/
loader_conditional: z.boolean(),
});
const findingSchema = z.strictObject({
kind: z.enum([
"required-load-unresolved",
"weak-load-unresolved",
"lazy-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>;
export type DylibEdge = z.infer<typeof edgeSchema>;
export type DylibFinding = z.infer<typeof findingSchema>;
type Candidate = DylibCandidate;
type Edge = DylibEdge;
/** 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 })[];
};
// Apple's PathOverrides consumes these variable names. Print/logging variables
// remain observations and do not change which library a search selects.
const DYLD_PATH_VARIABLES: ReadonlySet<string> = new Set([
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"DYLD_FALLBACK_FRAMEWORK_PATH",
"DYLD_VERSIONED_LIBRARY_PATH",
"DYLD_VERSIONED_FRAMEWORK_PATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_IMAGE_SUFFIX",
"DYLD_ROOT_PATH",
"DYLD_OVERLAY_PATH",
]);
const hasDyldPathOverrides = (slice: MachoSlice): boolean =>
slice.dyld_environment.some((setting) => {
const delimiter = setting.indexOf("=");
return (
delimiter > 0 && DYLD_PATH_VARIABLES.has(setting.slice(0, delimiter))
);
});
interface LoadedImage {
readonly slice: MachoSlice;
@@ -192,10 +79,16 @@ interface ProcessContext {
readonly view: DylibTreeView;
readonly root: string;
readonly architecture: string;
readonly platforms: MachoSlice["platforms"];
readonly searchPathsUnknown: boolean;
readonly executable: string | null;
readonly loaded: Map<string, LoadedImage>;
readonly byInstallName: Map<string, string>;
readonly images: Map<string, MachoImageFacts>;
/** The supplied cache, when it serves this process's architecture and platform. */
readonly sharedCache: DylibSharedCacheView | undefined;
/** Cached install paths whose bytes or loadability could not be verified. */
readonly unverifiedCacheImages: Set<string>;
}
interface CandidateTemplate {
@@ -259,8 +152,39 @@ const evaluateCandidate = async (
rpath: template.rpath,
rpath_owner: template.rpathOwner,
};
if (template.expansion.scope === "outside")
if (template.expansion.scope === "outside") {
const sharedCache = context.sharedCache;
const cached = sharedCache?.lookup(template.expansion.path) ?? "absent";
if (cached === "mapped" && sharedCache !== undefined) {
// Address coverage is not a load. Confirm a compatible Mach-O without
// retaining its commands, so cache images stay leaves.
const facts = await sharedCache.image(template.expansion.path);
if (
facts?.status === "parsed" &&
facts.slices.some(
(slice) =>
cacheServes(slice.architecture, context.architecture) &&
slice.file_type === "dylib" &&
slice.platforms.some(({ id }) =>
context.platforms.some((platform) => platform.id === id),
),
)
)
return {
...base,
outcome: "shared-cache",
resolved_path: template.expansion.path,
};
context.unverifiedCacheImages.add(template.expansion.path);
return { ...base, outcome: "undetermined", resolved_path: null };
}
// Listed in the cache, but its subcache is unavailable: dyld might load it.
if (cached === "unverified") {
context.unverifiedCacheImages.add(template.expansion.path);
return { ...base, outcome: "undetermined", resolved_path: null };
}
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);
@@ -269,13 +193,24 @@ const evaluateCandidate = async (
if (lookup.kind !== "file")
return { ...base, outcome: "absent", resolved_path: null };
const facts = await imageFacts(context, lookup.path);
const outcome: Candidate["outcome"] =
facts.status !== "parsed"
? facts.status
: compatibleSlice(facts.slices, context.architecture) === undefined
? "architecture-missing"
: "resolved";
return { ...base, outcome, resolved_path: lookup.path };
if (facts.status !== "parsed")
return { ...base, outcome: facts.status, resolved_path: lookup.path };
const slice = compatibleSlice(facts.slices, context.architecture);
if (slice === undefined)
return {
...base,
outcome: "architecture-missing",
resolved_path: lookup.path,
};
if (slice.file_type !== "dylib")
return { ...base, outcome: "unsupported", resolved_path: lookup.path };
if (
!slice.platforms.some(({ id }) =>
context.platforms.some((platform) => platform.id === id),
)
)
return { ...base, outcome: "undetermined", resolved_path: lookup.path };
return { ...base, outcome: "resolved", resolved_path: lookup.path };
};
const imageFacts = async (
@@ -297,6 +232,11 @@ const resolution = (candidates: readonly Candidate[]): Edge["resolution"] => {
status: external ? "conditional" : "resolved",
image: candidate.resolved_path,
};
if (candidate.outcome === "shared-cache")
return {
status: external ? "conditional" : "shared-cache",
image: candidate.resolved_path,
};
// dyld might load these; REA cannot decide them from the analyzed root.
if (
candidate.outcome === "outside-target" ||
@@ -332,9 +272,19 @@ const resolveDependency = async (
const candidate = await evaluateCandidate(template, context);
candidates.push(candidate);
// dyld stops at the first loadable candidate.
if (candidate.outcome === "resolved") break;
if (
candidate.outcome === "resolved" ||
candidate.outcome === "shared-cache"
)
break;
}
const searched = resolution(candidates);
const modeled = resolution(candidates);
const searched: Edge["resolution"] = context.searchPathsUnknown
? {
...modeled,
status: modeled.image === null ? "undetermined" : "conditional",
}
: modeled;
// Reusing an image that itself loads only conditionally is conditional too.
const resolved: Edge["resolution"] =
loaded !== undefined &&
@@ -428,6 +378,7 @@ export const traceDylibLoading = async (
/** Mach-O files whose root role could not be classified because they did not parse. */
readonly unclassified?: readonly string[];
readonly architecture?: string;
readonly sharedCache?: DylibSharedCacheView;
readonly signal?: AbortSignal;
},
): Promise<DylibTrace> => {
@@ -437,6 +388,8 @@ export const traceDylibLoading = async (
const roots: DylibTrace["roots"][number][] = [];
const edges: Edge[] = [];
const withoutArchitecture: string[] = [];
const unserved: UnservedRoot[] = [];
const unverifiedCacheImages = new Set<string>();
for (const root of request.roots) {
const facts = await view.image(root);
images.set(root, facts);
@@ -449,16 +402,31 @@ export const traceDylibLoading = async (
if (slices.length === 0) withoutArchitecture.push(root);
for (const slice of slices) {
roots.push({ image: root, architecture: slice.architecture });
const mismatch =
request.sharedCache === undefined
? undefined
: cacheMismatch(request.sharedCache, slice);
if (mismatch !== undefined)
unserved.push({
image: root,
architecture: slice.architecture,
reason: mismatch,
});
edges.push(
...(await traceProcess(
{
view,
root,
architecture: slice.architecture,
platforms: slice.platforms,
searchPathsUnknown: hasDyldPathOverrides(slice),
executable: slice.file_type === "execute" ? root : null,
loaded: new Map(),
byInstallName: new Map(),
images,
sharedCache:
mismatch === undefined ? request.sharedCache : undefined,
unverifiedCacheImages,
},
slice,
request.signal,
@@ -472,6 +440,17 @@ export const traceDylibLoading = async (
const unparsed = reported
.filter(([, facts]) => facts.status !== "parsed")
.map(([path]) => path);
// LC_DYLD_ENVIRONMENT can prepend search paths dyld honors at launch; its
// settings are reported but not modeled, so resolutions stay conditional.
const environmentRoots = roots.filter(({ image, architecture }) => {
const facts = images.get(image);
return (
facts?.status === "parsed" &&
facts.slices.some(
(s) => s.architecture === architecture && hasDyldPathOverrides(s),
)
);
});
return {
roots,
images: reported.map(([path, facts]) => ({
@@ -489,13 +468,34 @@ export const traceDylibLoading = async (
findings: deriveFindings(edges, roots, images),
coverage: {
status:
unparsed.length === 0 && withoutArchitecture.length === 0
unparsed.length === 0 &&
withoutArchitecture.length === 0 &&
unverifiedCacheImages.size === 0 &&
environmentRoots.length === 0
? "complete"
: "partial",
unparsed_images: unparsed,
roots_without_architecture: withoutArchitecture,
unverified_shared_cache_images: [...unverifiedCacheImages].sort(compare),
},
limitations: DYLIB_RESOLUTION_LIMITATIONS,
limitations: [
...DYLIB_RESOLUTION_LIMITATIONS,
...sharedCacheLimitations(request.sharedCache, unserved),
...(edges.some(
(edge) =>
edge.command !== "LC_LAZY_LOAD_DYLIB" &&
edge.candidates.some(({ outcome }) => outcome === "shared-cache"),
)
? [
"Cache-backed images are leaf references: their load commands and transitive dependencies were not traversed. Complete coverage describes the selected roots and in-root images, not a complete process-wide dependency graph. Use inspect_dyld_shared_cache with selected images to inspect cached load commands.",
]
: []),
...(environmentRoots.length === 0
? []
: [
`Roots ${environmentRoots.map(({ image }) => image).join(", ")} set dyld environment variables through LC_DYLD_ENVIRONMENT; their resolutions are conditional because those search paths are not modeled.`,
]),
],
};
};
@@ -508,6 +508,7 @@ const withoutDependencies = (
rpaths: slice.rpaths,
dyld_environment: slice.dyld_environment,
code_signature_present: slice.code_signature_present,
platforms: slice.platforms,
});
const compare = (left: string, right: string): number =>
+80 -6
View File
@@ -1,8 +1,12 @@
import { applePlatformLabel } from "./applePlatforms.js";
import { cacheServes } from "./dyldPaths.js";
import type {
DylibEdge as Edge,
DylibFinding as Finding,
DylibSharedCacheView,
DylibTrace,
MachoImageFacts,
MachoSlice,
} from "./dylibResolution.js";
export const deriveFindings = (
@@ -15,21 +19,40 @@ export const deriveFindings = (
if (edge.resolution.status === "unresolved")
findings.push(unresolvedFinding(edge, index));
const resolvedAt = edge.candidates.findIndex(
({ outcome }) => outcome === "resolved",
({ outcome }) => outcome === "resolved" || outcome === "shared-cache",
);
const earlier = edge.candidates
const earlierAbsent = edge.candidates
.slice(0, Math.max(resolvedAt, 0))
.filter(
({ source, outcome }) => source === "rpath" && outcome === "absent",
)
.map(({ path }) => path);
if (resolvedAt > 0 && earlier.length > 0)
const earlierUnknown = edge.candidates
.slice(0, Math.max(resolvedAt, 0))
.some(({ outcome }) =>
[
"outside-target",
"escapes-target",
"unsupported",
"undetermined",
].includes(outcome),
);
const loaderQualifies =
edge.loader_conditional === true
? " This loader itself loads only conditionally; if it never loads, this search never occurs."
: "";
const unknownQualifies = earlierUnknown
? " An earlier candidate outside the analyzed root may still win at runtime, so this fallback loads only conditionally."
: edge.resolution.status === "conditional"
? " Unmodeled environment overrides or other process search inputs may take precedence; placing an image at this rpath does not establish which image dyld will load."
: "";
if (resolvedAt > 0 && earlierAbsent.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).`,
explanation: `The modeled dyld search checks ${earlierAbsent.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A compatible Mach-O placed at an earlier modeled path could take precedence over that fallback, subject to unmodeled search inputs and code-signing library validation; library validation is not evaluated here (see inspect_signature).${loaderQualifies}${unknownQualifies}`,
});
});
for (const { image, architecture } of roots) {
@@ -45,7 +68,7 @@ export const deriveFindings = (
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.`,
explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); any image-selection overrides they request are not modeled. Diagnostic settings alone do not alter resolution.`,
});
}
return findings;
@@ -84,10 +107,61 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
export const DYLIB_RESOLUTION_LIMITATIONS = [
"LC_LAZY_LOAD_DYLIB dependencies are resolved but not traversed, because dyld loads them only on first use.",
"Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
"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 and conditionality 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.",
"Dylib compatibility versions are not compared. Apple's linker documentation describes a load-time rejection when an image's compatibility version is older than the loader requires, but dyld on macOS 26 was observed to load such an image.",
];
/** A process root the supplied shared cache cannot answer for. */
export interface UnservedRoot {
readonly image: string;
readonly architecture: string;
readonly reason: string;
}
/**
* Why the cache cannot serve a process slice, or undefined when it can:
* dyld maps only a cache built for the process's CPU family and platform.
*/
export const cacheMismatch = (
cache: DylibSharedCacheView,
slice: Pick<MachoSlice, "architecture" | "platforms">,
): string | undefined => {
if (!cacheServes(cache.architecture, slice.architecture))
return `the ${cache.architecture} cache does not serve ${slice.architecture} processes`;
if (cache.platforms.length === 0)
return "the cache header does not record its platform";
if (slice.platforms.length === 0)
return "the process records no LC_BUILD_VERSION or LC_VERSION_MIN platform";
if (
!slice.platforms.some(({ id }) =>
cache.platforms.some((platform) => platform.id === id),
)
)
return `the cache serves ${cache.platforms.map(applePlatformLabel).join(" and ")} processes, not ${slice.platforms.map(applePlatformLabel).join(" or ")}`;
return undefined;
};
export const sharedCacheLimitations = (
cache: DylibSharedCacheView | undefined,
unserved: readonly UnservedRoot[],
): string[] => {
if (cache === undefined)
return [
"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. Pass shared_cache to look them up in a cache.",
];
return [
"Absolute install names and rpaths were looked up only in the supplied dyld shared cache; paths it does not list stay outside-target because the host filesystem outside the analyzed root is not read. Shared-cache hits assume no on-disk override (root or unzippered twin) though dyld checks the filesystem first; treat them as conditional when the external disk state is unknown. On another OS build the cache contents, and therefore these outcomes, can differ.",
...unserved.map(
({ image, architecture, reason }) =>
`The supplied shared cache was not used for ${image} (${architecture}): ${reason}. Its absolute paths stay outside-target.`,
),
...(cache.unavailableSubcaches.length === 0
? []
: [
`Subcaches ${cache.unavailableSubcaches.join(", ")} of the supplied cache are unavailable; install paths listed there are undetermined and reported in coverage.unverified_shared_cache_images.`,
]),
];
};
+214
View File
@@ -0,0 +1,214 @@
import { z } from "zod";
import { applePlatformSchema, type ApplePlatform } from "./applePlatforms.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(),
/** Main dyld shared cache file whose image list answers system install paths. */
shared_cache: z.string().min(1).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(),
/** LC_BUILD_VERSION or LC_VERSION_MIN_* platforms; zippered images list two. */
platforms: z.array(applePlatformSchema),
});
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>;
}
/** Image list of one caller-supplied dyld shared cache. */
export interface DylibSharedCacheView {
readonly architecture: string;
/** The cache's platform and alternate platform; empty when the header predates them. */
readonly platforms: readonly ApplePlatform[];
/** Subcaches whose bytes are missing or do not match, such as `.03 (missing)`. */
readonly unavailableSubcaches: readonly string[];
/**
* `mapped`: listed and inside an admitted mapping. That is address coverage,
* not a load: a shared-cache hit also requires `image` to parse a slice this
* process can load as a dylib on its platform. `unverified`: listed, but the subcache holding it is
* unavailable. `absent`: not listed.
*/
lookup(path: string): "mapped" | "unverified" | "absent";
/**
* Load commands at a mapped install path. Undefined when `lookup` is not
* `mapped`. Tracing uses this only to confirm the hit; cached load commands
* are not traversed.
*/
image(path: string): Promise<MachoImageFacts | undefined>;
}
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",
"unsupported",
"architecture-missing",
"outside-target",
"shared-cache",
"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",
"shared-cache",
"unresolved",
"undetermined",
]),
image: z.string().nullable(),
}),
install_name_matches: z.boolean().nullable(),
/**
* The loader was reached through a conditional edge, so it might not load;
* this edge describes what happens only if it does.
*/
loader_conditional: z.boolean(),
});
const findingSchema = z.strictObject({
kind: z.enum([
"required-load-unresolved",
"weak-load-unresolved",
"lazy-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,
shared_cache: z
.strictObject({
path: z.string(),
uuid: z.string(),
architecture: z.string(),
os_version: z.string().nullable(),
main_file_sha256: digestSchema,
subcache_sha256: z.record(z.string(), digestSchema).default({}),
})
.nullable(),
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()),
/** Listed cache paths whose bytes or process-compatible dylib representation could not be verified. */
unverified_shared_cache_images: z.array(z.string()),
}),
limitations: z.array(z.string().min(1)),
});
export type DylibResolutionResult = z.infer<typeof dylibResolutionResultSchema>;
export type DylibCandidate = z.infer<typeof candidateSchema>;
export type DylibEdge = z.infer<typeof edgeSchema>;
export type DylibFinding = z.infer<typeof findingSchema>;
/** Result before the adapter adds file digests and the analyzed root. */
export type DylibTrace = Omit<
DylibResolutionResult,
"root_path" | "target_sha256" | "shared_cache" | "images"
> & {
readonly images: readonly (Omit<
DylibResolutionResult["images"][number],
"sha256"
> & { readonly sha256?: never })[];
};
+1
View File
@@ -13,6 +13,7 @@ export class ArtifactOperationError extends AnalysisError {
| "inspect_asset_catalog"
| "inspect_keyed_archive"
| "trace_dylib_resolution"
| "inspect_dyld_shared_cache"
| "export_web_scripts"
| "trace_web_module_imports"
| "trace_web_source_location"
+2
View File
@@ -7,6 +7,7 @@ import { registerCompareSourceToBundleTool } from "./registerApplicationTools/co
import { registerCompareJavaScriptExportShapesTool } from "./registerApplicationTools/compareExportShapes.js";
import { registerCoverageTools } from "./registerApplicationTools/coverage.js";
import { registerReconstructionObligationLedgerTool } from "./registerApplicationTools/obligationLedger.js";
import { registerInspectDyldSharedCacheTool } from "./registerApplicationTools/inspectDyldSharedCache.js";
import { registerProjectMobileApplicationGraphTools } from "./registerApplicationTools/projectMobileApplicationGraph.js";
import type { ApplicationToolRegistration } from "./registerApplicationTools/types.js";
@@ -25,4 +26,5 @@ export const registerApplicationTools = (
registerReconstructionObligationLedgerTool(server, options);
registerCoverageTools(server, options);
registerProjectMobileApplicationGraphTools(server, options);
registerInspectDyldSharedCacheTool(server, options);
};
@@ -0,0 +1,28 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { inspectDyldSharedCacheEvidence } from "../../application/apple/DyldSharedCacheService.js";
import { applicationToolContract } from "../../contracts/applicationToolContracts.js";
import { logToolExecution } from "../toolLogging.js";
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
import { toCallToolResult } from "../toolResult.js";
import { recordResult } from "./helpers.js";
import type { ApplicationToolRegistration } from "./types.js";
/** Register the target-free dyld shared cache inspection tool. */
export const registerInspectDyldSharedCacheTool = (
server: McpServer,
options: ApplicationToolRegistration,
): void => {
const contract = applicationToolContract("inspect_dyld_shared_cache");
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
async (input: unknown, context) => {
const result = await logToolExecution(options.logger, contract.name, () =>
inspectDyldSharedCacheEvidence(input, context.mcpReq.signal),
);
if (!result.ok) return toCallToolResult(result, contract);
return recordResult(options, contract, result.value);
},
);
};
+86 -4
View File
@@ -6,10 +6,12 @@ import { describe, expect } from "vitest";
import {
FILE_TYPE,
LC,
buildVersionCommand,
dylibCommand,
machoImage,
rpathCommand,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
import { dyldCacheFixture } from "../../../src/artifacts/apple/DyldSharedCache.fixture.js";
import { dylibResolutionResultSchema } from "../../../src/domain/apple/dylibResolution.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -36,6 +38,7 @@ const fixtureApp = async (): Promise<string> => {
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
"Contents/MacOS/App": machoImage({
commands: [
buildVersionCommand(1),
rpathCommand("@executable_path/../Frameworks"),
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"),
@@ -43,10 +46,16 @@ const fixtureApp = async (): Promise<string> => {
}),
"Contents/Frameworks/libcore.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib")],
commands: [
buildVersionCommand(1),
dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib"),
],
}),
"Contents/Helpers/tool": machoImage({
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib")],
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib"),
],
}),
});
return app;
@@ -145,7 +154,9 @@ describe("trace-dylib-resolution CLI", () => {
}
},
);
});
describe("trace-dylib-resolution CLI inputs", () => {
cliTest(
"resolves a standalone Mach-O against its own directory",
async ({ cli }) => {
@@ -153,11 +164,15 @@ describe("trace-dylib-resolution CLI", () => {
await writeFiles(directory, {
tool: machoImage({
commands: [
buildVersionCommand(1),
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 }),
"lib/libhelper.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
"unrelated.txt": "not traversed",
});
const result = await cli.run({
@@ -199,6 +214,69 @@ describe("trace-dylib-resolution CLI", () => {
});
},
);
cliTest(
"answers system install paths from a supplied shared cache",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-dylib-cache-");
await writeFiles(directory, {
tool: machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libnotcached.dylib"),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/swift/libswiftCore.dylib"),
],
}),
// The .01 subcache holding libswiftCore is not written.
dyld_shared_cache_arm64e: dyldCacheFixture([
{
path: "/usr/lib/libSystem.B.dylib",
bytes: machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
},
{
path: "/usr/lib/swift/libswiftCore.dylib",
bytes: machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
inSubcache: true,
},
]).main,
});
const result = await cli.run({
arguments: [
"trace-dylib-resolution",
join(directory, "tool"),
"--shared-cache",
join(directory, "dyld_shared_cache_arm64e"),
"--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.shared_cache).toMatchObject({
path: join(directory, "dyld_shared_cache_arm64e"),
architecture: "arm64e",
os_version: "26.6.0",
});
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
"shared-cache",
"undetermined",
"undetermined",
]);
expect(trace.coverage).toMatchObject({
status: "partial",
unverified_shared_cache_images: ["/usr/lib/swift/libswiftCore.dylib"],
});
},
);
});
describe.skipIf(process.getuid?.() === 0)(
@@ -211,10 +289,14 @@ describe.skipIf(process.getuid?.() === 0)(
await writeFiles(directory, {
tool: machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libsecret.dylib"),
],
}),
"libsecret.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
"libsecret.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
});
await chmod(join(directory, "libsecret.dylib"), 0o000);
try {
+1 -1
View File
@@ -185,7 +185,7 @@ describe("canonical product catalog", () => {
expect(
catalog.providers.find(({ id }) => id === APPLE_APPLICATION_PROVIDER.id)
?.capabilities,
).toEqual(["project_apple_application_graph"]);
).toEqual(["inspect_dyld_shared_cache", "project_apple_application_graph"]);
expect(
catalog.providers.find(({ id }) => id === MANAGED_WORKFLOW_PROVIDER.id)
?.capabilities,
@@ -0,0 +1,436 @@
import { createHash } from "node:crypto";
import { rm, symlink, writeFile } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import {
dyldCacheFixture,
type CacheFixture,
} from "../../../src/artifacts/apple/DyldSharedCache.fixture.js";
import {
FILE_TYPE,
CPU,
buildVersionCommand,
LC,
dylibCommand,
dylibUseCommand,
machoImage,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
import {
inspectDyldSharedCache,
inspectDyldSharedCacheEvidence,
} from "../../../src/application/apple/DyldSharedCacheService.js";
import { dyldSharedCacheResultSchema } from "../../../src/domain/apple/dyldSharedCache.js";
import { traceDylibResolution } from "../../../src/artifacts/apple/DylibResolutionReader.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("rejects a directory and overlapping combined VM mappings with specific reasons", async () => {
const directory = await createTestTempDirectory("rea-cache-invalid-regions-");
expect(await inspectDyldSharedCache({ cache_path: directory })).toMatchObject(
{ ok: false, error: { reason: "path" } },
);
const fixture = dyldCacheFixture(IMAGES);
const original = fixture.subcaches[0];
if (original === undefined) throw new Error("missing subcache fixture");
const subcache = Buffer.from(original.bytes);
const main = Buffer.from(fixture.main);
const mainTable = main.readUInt32LE(0x10);
const subTable = subcache.readUInt32LE(0x10);
subcache.writeBigUInt64LE(main.readBigUInt64LE(mainTable), subTable);
// Keep the declared VM offset consistent so the failure is the overlap.
main.writeBigUInt64LE(0n, main.readUInt32LE(0x188) + 16);
const path = await writeCache({
...fixture,
main,
subcaches: [{ ...original, bytes: subcache }],
});
const result = await inspectDyldSharedCache({ cache_path: path });
expect(result).toMatchObject({
ok: false,
error: { reason: "format", detail: expect.stringContaining("overlap") },
});
});
const dylib = (
installName: string,
dependencies: readonly Uint8Array[] = [],
platform = 1,
) =>
machoImage({
fileType: FILE_TYPE.dylib,
cpu: CPU.arm64e,
commands: [
buildVersionCommand(platform),
dylibCommand(LC.ID_DYLIB, installName),
...dependencies,
],
});
it("rejects a same-UUID subcache whose architecture or VM offset disagrees", async () => {
const fixture = dyldCacheFixture(IMAGES);
const original = fixture.subcaches[0];
if (original === undefined) throw new Error("missing subcache fixture");
const wrongArchitecture = Buffer.from(original.bytes);
wrongArchitecture.write("dyld_v1 x86_64".padEnd(15, " "), 0, "latin1");
const architecturePath = await writeCache({
...fixture,
subcaches: [{ ...original, bytes: wrongArchitecture }],
});
expect(
await inspectDyldSharedCache({ cache_path: architecturePath }),
).toMatchObject({
ok: false,
error: {
reason: "format",
detail: expect.stringContaining("does not match"),
},
});
const main = Buffer.from(fixture.main);
main.writeBigUInt64LE(0n, main.readUInt32LE(0x188) + 16);
const offsetPath = await writeCache({ ...fixture, main });
expect(
await inspectDyldSharedCache({ cache_path: offsetPath }),
).toMatchObject({
ok: false,
error: { reason: "format", detail: expect.stringContaining("disagrees") },
});
});
it("does not resolve a cached path whose bytes are not a Mach-O image", async () => {
const path = await writeCache(
dyldCacheFixture([
{ path: "/usr/lib/libSystem.B.dylib", bytes: Buffer.alloc(64, 0) },
]),
);
const targetPath = join(dirname(path), "tool");
const bytes = machoImage({
fileType: FILE_TYPE.execute,
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
],
});
await writeFile(targetPath, bytes);
const trace = await traceDylibResolution({
rootPath: dirname(path),
targetPath,
targetSha256: createHash("sha256").update(bytes).digest("hex"),
enumerateRoots: false,
parameters: { shared_cache: path },
});
expect(trace.edges[0]?.candidates[0]?.outcome).toBe("undetermined");
expect(trace.edges[0]?.resolution).toEqual({
status: "undetermined",
image: null,
});
expect(trace.coverage.unverified_shared_cache_images).toEqual([
"/usr/lib/libSystem.B.dylib",
]);
expect(trace.images.map(({ path: image }) => image)).not.toContain(
"/usr/lib/libSystem.B.dylib",
);
});
it("rejects unmapped images when no unavailable subcache can explain the address", async () => {
const fixture = dyldCacheFixture(IMAGES);
const main = Buffer.from(fixture.main);
main.writeBigUInt64LE(0x700000000n, main.readUInt32LE(0x1c0));
const path = await writeCache({ ...fixture, main });
expect(await inspectDyldSharedCache({ cache_path: path })).toMatchObject({
ok: false,
error: {
reason: "format",
detail: expect.stringContaining("unmapped image address"),
},
});
});
const writeCache = async (
fixture: CacheFixture,
name = "dyld_shared_cache_arm64e",
): Promise<string> => {
const directory = await createTestTempDirectory("rea-dyld-cache-");
const base = join(directory, "dyld_shared_cache_arm64e");
await writeFile(join(directory, name), fixture.main);
for (const { suffix, bytes } of fixture.subcaches)
await writeFile(`${base}${suffix}`, bytes);
return join(directory, name);
};
const IMAGES = [
{
path: "/usr/lib/libSystem.B.dylib",
bytes: dylib("/usr/lib/libSystem.B.dylib", [
dylibCommand(LC.REEXPORT_DYLIB, "/usr/lib/system/libcache.dylib"),
]),
},
{
path: "/usr/lib/swift/libswiftCore.dylib",
bytes: dylib("/usr/lib/swift/libswiftCore.dylib", [
dylibUseCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib", 0x4 | 0x8),
]),
inSubcache: true,
},
];
it.each([
{ type: 2, subtype: 0, expected: { id: 0, name: "development" } },
{ type: 2, subtype: 1, expected: { id: 1, name: "production" } },
{ type: 2, subtype: 9, expected: { id: 9, name: null } },
{ type: 0, subtype: 1, expected: null },
{ type: 1, subtype: 0, expected: null },
])(
"preserves applicable multi-cache subtype $subtype for type $type",
async ({ type, subtype, expected }) => {
const fixture = dyldCacheFixture(IMAGES);
const main = Buffer.from(fixture.main);
main.writeBigUInt64LE(BigInt(type), 0x68);
main.writeUInt32LE(subtype, 0x1c8);
const path = await writeCache({ ...fixture, main });
const result = await inspectDyldSharedCache({ cache_path: path });
if (!result.ok) throw result.error;
const cache = dyldSharedCacheResultSchema.parse(result.value);
expect(cache.cache_type).toBe(
type === 2 ? "multi-cache" : type === 0 ? "development" : "production",
);
expect(cache.cache_subtype).toEqual(expected);
},
);
it("keeps a legacy multi-cache subtype unknown when its header lacks the field", async () => {
const fixture = dyldCacheFixture([], { legacy: true });
const main = Buffer.from(fixture.main);
main.writeBigUInt64LE(2n, 0x68);
const path = await writeCache({ ...fixture, main });
const result = await inspectDyldSharedCache({ cache_path: path });
if (!result.ok) throw result.error;
expect(result.value.cache_subtype).toBeNull();
});
describe("dyld shared cache inspection", () => {
it("reads the header, subcaches, image list and cached load commands", async () => {
const path = await writeCache(dyldCacheFixture(IMAGES));
const result = await inspectDyldSharedCache({
cache_path: path,
images: ["/usr/lib/swift/libswiftCore.dylib", "/usr/lib/missing.dylib"],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const cache = dyldSharedCacheResultSchema.parse(result.value);
expect(cache).toMatchObject({
architecture: "arm64e",
platform: { id: 1, name: "macos" },
os_version: "26.6.0",
shared_region: { start: "0x180000000", size: "0x20000000" },
images_total: 2,
coverage: { status: "complete", unreadable_subcaches: [] },
});
expect(cache.subcaches).toEqual([
expect.objectContaining({
suffix: ".01",
status: "present",
vm_offset: "0x10000000",
}),
]);
expect(cache.images.map(({ path: image }) => image)).toEqual(
IMAGES.map(({ path: image }) => image),
);
const [swift, missing] = cache.inspected_images;
expect(swift).toMatchObject({ status: "parsed", file: ".01" });
expect(swift?.slices[0]?.dependencies).toEqual([
expect.objectContaining({
install_name: "/usr/lib/libSystem.B.dylib",
encoding: "dylib_use_command",
upward: true,
delayed_init: true,
}),
]);
expect(missing).toEqual({
path: "/usr/lib/missing.dylib",
status: "absent",
address: null,
file: null,
reason: null,
slices: [],
});
});
it("reports missing and mismatched subcaches as partial coverage", async () => {
const mismatched = await writeCache(
dyldCacheFixture(IMAGES, { subcacheUuidSeed: 9 }),
);
const result = await inspectDyldSharedCache({
cache_path: mismatched,
images: ["/usr/lib/swift/libswiftCore.dylib"],
});
expect(result.ok && result.value).toMatchObject({
subcaches: [{ suffix: ".01", status: "uuid-mismatch" }],
inspected_images: [{ status: "unmapped" }],
coverage: { status: "partial", unreadable_subcaches: [".01"] },
});
const missing = await writeCache(dyldCacheFixture(IMAGES));
await rm(`${missing}.01`);
const without = await inspectDyldSharedCache({ cache_path: missing });
expect(without.ok && without.value.subcaches[0]).toMatchObject({
status: "missing",
observed_uuid: null,
});
});
it("finds a development cache's subcaches beside its base name", async () => {
const path = await writeCache(
dyldCacheFixture(IMAGES),
"dyld_shared_cache_arm64e.development",
);
const result = await inspectDyldSharedCache({
cache_path: path,
images: ["/usr/lib/swift/libswiftCore.dylib"],
});
expect(result.ok && result.value).toMatchObject({
subcaches: [{ suffix: ".01", status: "present" }],
inspected_images: [{ status: "parsed", file: ".01" }],
});
});
it("reads legacy single-file headers without later fields", async () => {
const path = await writeCache(
dyldCacheFixture(IMAGES.slice(0, 1), { legacy: true }),
);
const result = await inspectDyldSharedCache({
cache_path: path,
images: ["/usr/lib/libSystem.B.dylib"],
});
expect(result.ok && result.value).toMatchObject({
platform: null,
os_version: null,
subcaches: [],
images_total: 1,
inspected_images: [{ status: "parsed" }],
});
});
it("separates malformed input, missing files and non-cache files", async () => {
expect(await inspectDyldSharedCache({ images: [] })).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
const directory = await createTestTempDirectory("rea-dyld-cache-bad-");
expect(
await inspectDyldSharedCache({ cache_path: join(directory, "absent") }),
).toMatchObject({ ok: false, error: { reason: "path" } });
const notCache = join(directory, "not-a-cache");
await writeFile(notCache, Buffer.alloc(64, 0x41));
expect(
await inspectDyldSharedCache({ cache_path: notCache }),
).toMatchObject({
ok: false,
error: { reason: "format" },
});
});
it("wraps the observation as Evidence about the main cache file", async () => {
const path = await writeCache(dyldCacheFixture(IMAGES));
const evidence = await inspectDyldSharedCacheEvidence({ cache_path: path });
expect(evidence.ok && evidence.value).toMatchObject({
operation: "inspect_dyld_shared_cache",
subject: { local_path: path, format: "file" },
confidence: "observed",
});
});
});
it("retains a relative caller-selected cache path in trace and inspection metadata", async () => {
const path = await writeCache(dyldCacheFixture(IMAGES));
const selected = relative(process.cwd(), path);
const targetPath = join(dirname(path), "tool");
const bytes = machoImage({
fileType: FILE_TYPE.execute,
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
],
});
await writeFile(targetPath, bytes);
const trace = await traceDylibResolution({
rootPath: dirname(path),
targetPath,
targetSha256: createHash("sha256").update(bytes).digest("hex"),
enumerateRoots: false,
parameters: { shared_cache: selected },
});
expect(trace.shared_cache?.path).toBe(selected);
expect(trace.shared_cache?.main_file_sha256).toMatch(/^[a-f0-9]{64}$/u);
expect(trace.edges[0]?.resolution.status).toBe("shared-cache");
const inspected = await inspectDyldSharedCacheEvidence({
cache_path: selected,
});
if (!inspected.ok) throw inspected.error;
expect(inspected.value.parameters).toMatchObject({ cache_path: selected });
});
it("discovers companions beside a symlink target while preserving selected metadata", async () => {
const path = await writeCache(
dyldCacheFixture(IMAGES),
"dyld_shared_cache_arm64e.development",
);
const aliases = await createTestTempDirectory("rea-cache-alias-");
const alias = join(aliases, "selected-cache");
await symlink(path, alias);
const result = await inspectDyldSharedCache({
cache_path: alias,
images: ["/usr/lib/swift/libswiftCore.dylib"],
});
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
cache_path: alias,
subcaches: [{ status: "present", suffix: ".01" }],
inspected_images: [{ status: "parsed", file: ".01" }],
coverage: { status: "complete", unreadable_subcaches: [] },
});
});
it.each([
[2, 7],
[3, 8],
[4, 9],
[11, 12],
])(
"decodes simulator cache family %i into platform %i and retains raw metadata",
async (raw, normalized) => {
const fixture = dyldCacheFixture([
{
path: "/usr/lib/libSystem.B.dylib",
bytes: dylib("/usr/lib/libSystem.B.dylib", [], normalized),
},
]);
const main = Buffer.from(fixture.main);
main.writeUInt32LE(raw, 0xd8);
main.writeUInt32LE(1 << 9, 0xdc);
const path = await writeCache({ ...fixture, main });
const result = await inspectDyldSharedCache({ cache_path: path });
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
platform: { id: normalized },
header_platform: { id: raw },
simulator: true,
});
const targetPath = join(dirname(path), "simulator-tool");
const bytes = machoImage({
fileType: FILE_TYPE.execute,
commands: [
buildVersionCommand(normalized),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
],
});
await writeFile(targetPath, bytes);
const trace = await traceDylibResolution({
rootPath: dirname(path),
targetPath,
targetSha256: createHash("sha256").update(bytes).digest("hex"),
enumerateRoots: false,
parameters: { shared_cache: path },
});
expect(trace.edges[0]?.resolution.status).toBe("shared-cache");
},
);
@@ -0,0 +1,80 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, expect, it } from "vitest";
import { z } from "zod";
import { dyldCacheFixture } from "../../../src/artifacts/apple/DyldSharedCache.fixture.js";
import {
FILE_TYPE,
LC,
dylibCommand,
machoImage,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
import { dyldSharedCacheResultSchema } from "../../../src/domain/apple/dyldSharedCache.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const resources: Array<{ close(): Promise<unknown> }> = [];
afterEach(async () => {
for (const resource of resources.splice(0)) await resource.close();
});
it("inspects a dyld shared cache through MCP without a target", async () => {
const session = createTestBinarySession(() => {
throw new Error("shared cache inspection must not launch a provider");
});
const server = createServer(session, session);
const client = new Client({ name: "dyld-shared-cache-test", version: "1" });
resources.push(client, server, session);
const [a, b] = InMemoryTransport.createLinkedPair();
await server.connect(b);
await client.connect(a);
const directory = await createTestTempDirectory("rea-dyld-cache-mcp-");
const path = join(directory, "dyld_shared_cache_arm64e");
const fixture = dyldCacheFixture([
{
path: "/usr/lib/libSystem.B.dylib",
bytes: machoImage({
fileType: FILE_TYPE.dylib,
commands: [dylibCommand(LC.ID_DYLIB, "/usr/lib/libSystem.B.dylib")],
}),
},
]);
const main = Buffer.from(fixture.main);
main.writeBigUInt64LE(2n, 0x68);
main.writeUInt32LE(1, 0x1c8);
await writeFile(path, main);
const tools = await client.listTools();
expect(tools.tools.map(({ name }) => name)).toContain(
"inspect_dyld_shared_cache",
);
const called = await client.callTool({
name: "inspect_dyld_shared_cache",
arguments: { cache_path: path, images: ["/usr/lib/libSystem.B.dylib"] },
});
expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe(
true,
);
const result = dyldSharedCacheResultSchema.parse(
z.object({ result: z.unknown() }).parse(called.structuredContent).result,
);
expect(result).toMatchObject({
architecture: "arm64e",
cache_type: "multi-cache",
cache_subtype: { id: 1, name: "production" },
images_total: 1,
inspected_images: [
{ path: "/usr/lib/libSystem.B.dylib", status: "parsed" },
],
});
const missing = await client.callTool({
name: "inspect_dyld_shared_cache",
arguments: { cache_path: join(directory, "absent") },
});
expect(missing.isError).toBe(true);
});
+14 -3
View File
@@ -8,6 +8,7 @@ import { z } from "zod";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import {
FILE_TYPE,
buildVersionCommand,
LC,
dylibCommand,
machoImage,
@@ -55,6 +56,7 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe
executable,
machoImage({
commands: [
buildVersionCommand(1),
rpathCommand("@executable_path/../Frameworks"),
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
],
@@ -62,7 +64,10 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe
);
await writeFile(
join(app, "Contents/Frameworks/libcore.dylib"),
machoImage({ fileType: FILE_TYPE.dylib }),
machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
);
await withClient(async (client) => {
const opened = await client.callTool({
@@ -129,12 +134,18 @@ it("traces a standalone Mach-O file whose name ends in .app", async () => {
await writeFile(
join(directory, "Tool.app"),
machoImage({
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libcore.dylib")],
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libcore.dylib"),
],
}),
);
await writeFile(
join(directory, "libcore.dylib"),
machoImage({ fileType: FILE_TYPE.dylib }),
machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
);
await withClient(async (client) => {
const opened = await client.callTool({