Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f947c859c | ||
|
|
a446f2a413 | ||
|
|
5be3eeb1e6 | ||
|
|
0cbef126a5 | ||
|
|
4adca806f9 | ||
|
|
22e6110acb | ||
|
|
8289ede5db | ||
|
|
e4d61d401d | ||
|
|
4e98543e90 | ||
|
|
03bbecd941 | ||
|
|
2645851adf | ||
|
|
d39543145e | ||
|
|
b23f8288b4 | ||
|
|
cb8d0f37f4 | ||
|
|
6b99236c36 | ||
|
|
fb999f84b8 | ||
|
|
fe3cac8aa8 | ||
|
|
17f2bc7b0d | ||
|
|
f4f82179d9 | ||
|
|
0f6dd6966c | ||
|
|
92fcab2c68 | ||
|
|
dbc7ebc2b6 | ||
|
|
d4ba8f661b | ||
|
|
24c8e13909 | ||
|
|
e1597a6f3d | ||
|
|
d5921db039 | ||
|
|
663f4c4dc5 | ||
|
|
d081ffbdce | ||
|
|
cd622e0e95 | ||
|
|
f2f83c7499 | ||
|
|
d180d8667d | ||
|
|
3d6a31ed41 | ||
|
|
09344aa33d | ||
|
|
8e53f794f1 | ||
|
|
858aa9fd7d | ||
|
|
ed40d2e001 | ||
|
|
3b4969e3dc | ||
|
|
1d943c5eec | ||
|
|
305474df6c | ||
|
|
202661f83b | ||
|
|
22beae9768 | ||
|
|
ade11fa02d |
@@ -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
@@ -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
@@ -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
@@ -215,7 +215,7 @@ REA는 1~5단계의 바이너리 분석을 처리합니다. 6단계는 에이전
|
||||
| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
||||
| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 앱 워크플로 | 14 | 캡처한 웹 스크립트 내보내기, dyld 공유 캐시 검사, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
|
||||
|
||||
## 로드맵
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
||||
| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 应用工作流 | 14 | 捕获的网站脚本导出、dyld 共享缓存检查、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||
|
||||
## 路线图
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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");
|
||||
|
||||
@@ -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.",
|
||||
];
|
||||
@@ -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
@@ -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 =>
|
||||
|
||||
@@ -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.`,
|
||||
]),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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 })[];
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user