mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(native): read embedded cargo-auditable Rust dependency lists (#2456)
* feat(native): read embedded cargo-auditable Rust dependency lists * fix(native): trim PE section padding and compute dependency cycles iteratively * fix(native): ignore SHT_NOBITS sections and validate dependency edges linearly * fix(native): bound compiler path scanning and retained hashes * fix(rust): pass package arrays as named parameterized test cases * fix(rust): admit graph expansion and preserve resource units * fix(rust): reject malformed packages, false cycles, and hidden compiler paths Fail the first malformed cargo-auditable package before Zod collects an issue per field. Require cyclic dependency indices to match the cycles in package dependencies. Keep read-only ELF ranges when a section name does not fit the bounded name window, so a rustc path in that range is still reported. * fix(ci): repair integrated checks and Hopper test baseline * fix(go): verify Mach-O sections against segments --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
59 files changed
+2767
-12
No files matched your search
@@ -122,6 +122,14 @@ explicit path or endpoint and do not need it.
|
||||
absent metadata does not prove a binary is not Go, and unparsed text records
|
||||
remain explicit. See the
|
||||
[Go binary guide](https://github.com/morluto/rea/blob/main/docs/go-binaries.md).
|
||||
- Embedded cargo-auditable Rust dependency metadata: `inspect_rust_binary`
|
||||
with the explicit local ELF, PE, or thin Mach-O file. This built-in
|
||||
target-free inspection requires no Rust installation or disassembler. Preserve
|
||||
package order, dependency indices, and the section file range. A missing
|
||||
`.dep-v0` section is null metadata, not proof the binary has no dependencies.
|
||||
A rustc commit hash is an inference from a read-only `/rustc/<40-hex>/` path.
|
||||
See the
|
||||
[Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
|
||||
|
||||
- Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect
|
||||
with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema
|
||||
|
||||
@@ -11,6 +11,18 @@ unparsed module-text line. A missing record is an unknown, not proof of a
|
||||
non-Go binary; observed metadata does not authenticate its claims. Function,
|
||||
source and type recovery remain a separate native-analysis question.
|
||||
|
||||
## Rust dependency metadata
|
||||
|
||||
Use `inspect_rust_binary` for an explicit local ELF, PE, or thin Mach-O when
|
||||
the question concerns the cargo-auditable crate list embedded in `.dep-v0`
|
||||
(Mach-O: `__DATA,.dep-v0`). No `open_binary`, Rust installation, or target
|
||||
execution is required. The CLI equivalent is `inspect-rust-binary PATH`.
|
||||
Preserve artifact SHA-256, package order, dependency indices, and the section
|
||||
file range. A missing section is null metadata, not proof the binary has no
|
||||
dependencies. An omitted package kind stays null. A rustc commit hash is
|
||||
inferred from a read-only `/rustc/<40-hex>/` path and does not by itself
|
||||
identify the compiler. See [Rust binaries](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
|
||||
|
||||
## SQLite snapshots
|
||||
|
||||
Use `inspect_sqlite_database` with an explicit local database snapshot for its
|
||||
|
||||
@@ -155,6 +155,7 @@ Additional tools and host support depend on the target:
|
||||
| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://rea.tools/guides/native/) |
|
||||
| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
|
||||
| Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required |
|
||||
| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) |
|
||||
| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) |
|
||||
| SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits |
|
||||
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: "Native binaries", link: "/native-investigation" },
|
||||
{ text: "Go build metadata", link: "/go-binaries" },
|
||||
{ text: "Rust dependencies", link: "/rust-binaries" },
|
||||
{
|
||||
text: "JavaScript and Electron",
|
||||
link: "/javascript-artifact-reconstruction",
|
||||
|
||||
+12
@@ -83,6 +83,18 @@ The shared CLI/MCP workflow reports compiler version, modules, replacements,
|
||||
build settings, original bytes and SHA-256. See [Go binaries](go-binaries.md)
|
||||
for supported containers and absent or malformed metadata.
|
||||
|
||||
Inspect an embedded cargo-auditable Rust dependency list without installing
|
||||
Rust or configuring a disassembler:
|
||||
|
||||
```bash
|
||||
rea inspect-rust-binary /absolute/path/to/program --json
|
||||
```
|
||||
|
||||
The same workflow reports package names, versions, sources, the root package,
|
||||
dependency edges, and the section's file range. A missing `.dep-v0` section is
|
||||
null metadata, not proof that the binary has no dependencies. See
|
||||
[Rust binaries](rust-binaries.md).
|
||||
|
||||
Configure [Hopper or Ghidra](installation.md#hopper), or the
|
||||
[IDA adapter](ida-provider.md), before analyzing a native target. Substitute
|
||||
your target, search text and function name or address in these examples:
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
|
||||
|
||||
| Task | Guides |
|
||||
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
|
||||
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
|
||||
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [JEB](jeb-analysis.md) |
|
||||
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
|
||||
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md) |
|
||||
|
||||
@@ -135,6 +135,7 @@ npx rea-agents@latest setup
|
||||
| الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](https://rea.tools/ar/guides/native/) |
|
||||
| بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../binary-diagnostics.md) |
|
||||
| شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](https://rea.tools/ar/guides/javascript/) |
|
||||
| المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](https://rea.tools/ar/guides/browser/) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz
|
||||
| Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](https://rea.tools/de/guides/native/) |
|
||||
| Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../binary-diagnostics.md) |
|
||||
| EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](https://rea.tools/de/guides/javascript/) |
|
||||
| Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](https://rea.tools/de/guides/browser/) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h
|
||||
| Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](https://rea.tools/es/guides/native/) |
|
||||
| Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../binary-diagnostics.md) |
|
||||
| Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](https://rea.tools/es/guides/javascript/) |
|
||||
| Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](https://rea.tools/es/guides/browser/) |
|
||||
|
||||
@@ -127,6 +127,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، بههمراه np
|
||||
| باینریهای بومی | شبهکد، اسمبلی، رشتهها، نمادها، فراخوانیها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/fa/guides/native/) |
|
||||
| ساختار ELF آفلاین | بخشها، سگمنتها، نمادها و relocationهای اصلی و گزینههای احتمالی کاهش آسیبپذیری ایستا | pwntools تأمینشده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../binary-diagnostics.md) |
|
||||
| بایتکد EVM | selectorهای dispatch، offsetهای بایتی، آرگومانهای استنباطشده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| کرشهای ثبتشدهٔ لینوکس | یادداشتهای خام، رجیسترها/سیگنالهای تمام threadهای ثبتشده و گزینههای احتمالی نگاشت | pwntools تأمینشده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرشهای ثبتشده](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | ماژولها، importها، source mapها، مسیرها، IPC و ارتباط افزونههای بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/fa/guides/javascript/) |
|
||||
| وبسایتها | ساختار صفحه، اسکریپتها، مشاهدات شبکه و اسکرینشاتهای درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/fa/guides/browser/) |
|
||||
|
||||
@@ -139,6 +139,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les
|
||||
| Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](../binary-diagnostics.md) |
|
||||
| Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Crashs Linux enregistrés | Notes brutes, registres et signaux de chaque thread enregistré, et correspondances de mappings facultatives | pwntools fourni par l'utilisateur sous Linux x64 ; GDB/pwndbg en option ; [crashs enregistrés](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](https://rea.tools/fr/guides/javascript/) |
|
||||
| Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](https://rea.tools/fr/guides/browser/) |
|
||||
|
||||
@@ -142,6 +142,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
|
||||
| Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](../binary-diagnostics.md) |
|
||||
| Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/id/guides/javascript/) |
|
||||
| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/id/guides/browser/) |
|
||||
|
||||
@@ -139,6 +139,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument
|
||||
| Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](../binary-diagnostics.md) |
|
||||
| Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` |
|
||||
| Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Crash Linux registrati | Note grezze, registri/segnai di ogni thread registrato e candidati mapping opzionali | pwntools fornito dal chiamante; GDB/pwndbg opzionale; [crash registrati](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](https://rea.tools/it/guides/javascript/) |
|
||||
| Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](https://rea.tools/it/guides/browser/) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm
|
||||
| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](https://rea.tools/ja/guides/native/) |
|
||||
| オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../binary-diagnostics.md) |
|
||||
| EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/ja/guides/javascript/) |
|
||||
| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/ja/guides/browser/) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
|
||||
| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) |
|
||||
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) |
|
||||
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) |
|
||||
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na
|
||||
| Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](https://rea.tools/pl/guides/native/) |
|
||||
| Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../binary-diagnostics.md) |
|
||||
| Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](https://rea.tools/pl/guides/javascript/) |
|
||||
| Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](https://rea.tools/pl/guides/browser/) |
|
||||
|
||||
@@ -135,6 +135,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer
|
||||
| Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](https://rea.tools/pt-BR/guides/native/) |
|
||||
| Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../binary-diagnostics.md) |
|
||||
| Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](https://rea.tools/pt-BR/guides/javascript/) |
|
||||
| Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](https://rea.tools/pt-BR/guides/browser/) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
|
||||
| Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](https://rea.tools/ru/guides/native/) |
|
||||
| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) |
|
||||
| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](https://rea.tools/ru/guides/javascript/) |
|
||||
| Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](https://rea.tools/ru/guides/browser/) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
|
||||
| ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](https://rea.tools/th/guides/native/) |
|
||||
| โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../binary-diagnostics.md) |
|
||||
| ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](https://rea.tools/th/guides/javascript/) |
|
||||
| เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](https://rea.tools/th/guides/browser/) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
|
||||
| Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](https://rea.tools/uk/guides/native/) |
|
||||
| Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../binary-diagnostics.md) |
|
||||
| Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](https://rea.tools/uk/guides/javascript/) |
|
||||
| Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](https://rea.tools/uk/guides/browser/) |
|
||||
|
||||
@@ -135,6 +135,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np
|
||||
| Tệp nhị phân mã máy | Mã giả, hợp ngữ, chuỗi, ký hiệu, lời gọi và tham chiếu | Hopper, Ghidra hoặc IDA; [phân tích mã máy](https://rea.tools/vi/guides/native/) |
|
||||
| Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) |
|
||||
| Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](https://rea.tools/vi/guides/javascript/) |
|
||||
| Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](https://rea.tools/vi/guides/browser/) |
|
||||
|
||||
@@ -139,6 +139,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
|
||||
| Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go |
|
||||
| WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](https://rea.tools/zh-TW/guides/javascript/) |
|
||||
| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](https://rea.tools/zh-TW/guides/browser/) |
|
||||
|
||||
@@ -139,6 +139,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
|
||||
| Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go |
|
||||
| WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
|
||||
| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) |
|
||||
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
|
||||
| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) |
|
||||
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/zh-CN/guides/javascript/) |
|
||||
| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](https://rea.tools/zh-CN/guides/browser/) |
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Rust binary dependency metadata
|
||||
|
||||
`inspect_rust_binary` / `inspect-rust-binary` answers which crate names,
|
||||
versions, sources, and dependency edges are embedded by cargo-auditable in one
|
||||
selected local executable or library. It reads the file without running it and
|
||||
requires no Rust installation, active binary target, Hopper, Ghidra, or IDA.
|
||||
|
||||
```bash
|
||||
rea inspect-rust-binary /absolute/path/to/application --json
|
||||
```
|
||||
|
||||
The CLI resolves relative paths against the caller's working directory. MCP
|
||||
requires an absolute filesystem path and preserves that exact selection:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_rust_binary",
|
||||
"arguments": { "path": "/artifacts/application" }
|
||||
}
|
||||
```
|
||||
|
||||
## Observations
|
||||
|
||||
The inline Evidence result contains the artifact path, SHA-256, byte length,
|
||||
container format, architecture, word size, and byte order. When a `.dep-v0`
|
||||
section is present, `auditable_metadata` contains:
|
||||
|
||||
- `section_name`, `.dep-v0` for ELF and PE or `__DATA,.dep-v0` for thin Mach-O;
|
||||
- `file_offset` and `section_bytes`, the exact file range of the compressed section;
|
||||
- `compressed_bytes` and `uncompressed_bytes`;
|
||||
- `format_version`, the embedded integer, or `null` when the field is omitted;
|
||||
- `packages`, in the section's original order, with `name`, `version`, `source`,
|
||||
`kind`, `dependencies`, and `root`;
|
||||
- `root_package_index`, the single package marked `root`, or `null` when none is marked;
|
||||
- `dependency_edges`, pairs of package indices in package-then-dependency order;
|
||||
- `cyclic_dependency_indices`, sorted unique indices that participate in a cycle.
|
||||
|
||||
An omitted `kind` stays `null`. The embedded format treats that omission as
|
||||
runtime, and that default is not stored in the section. An omitted dependency
|
||||
list is an empty array. An omitted `root` flag is false.
|
||||
|
||||
`rustc_commit` is present only when a `/rustc/<40-hex>/` path is found in
|
||||
read-only data. Its `evidence_kind` is `inferred`. The hash is not a claim that
|
||||
that compiler produced the binary. Several distinct hashes leave a single
|
||||
commit unknown.
|
||||
|
||||
## Limits and failures
|
||||
|
||||
The selected file is limited to 256 MiB. A dependency section is limited to 1
|
||||
MiB compressed. Inflated JSON is limited to the smaller of 8 MiB and 64 times
|
||||
the compressed size. The complete report is limited to 8 MiB. Empty sections,
|
||||
truncated or trailing zlib bytes, invalid JSON, unknown fields, dependency
|
||||
indices outside the package list, more than one root package, and compressed
|
||||
dependency sections are typed failures. A cycle is reported and is not walked.
|
||||
|
||||
A missing section returns success with `auditable_metadata: null` and a
|
||||
limitation. Absence of `.dep-v0` does not mean the binary has no dependencies.
|
||||
Embedded names and versions are file content. They do not authenticate the
|
||||
packages or the compiler.
|
||||
|
||||
Universal Mach-O containers are unsupported. Select a thin slice first. ELF,
|
||||
PE, and thin Mach-O are the supported containers.
|
||||
|
||||
Package and dependency counts are checked against the minimum required JSON
|
||||
representation before graph allocation or schema cloning. Oversized graphs
|
||||
report the output byte budget; the compiler-path capacity reports
|
||||
`maximum_rustc_hashes` in hashes, independently of byte limits.
|
||||
@@ -28,6 +28,7 @@ export const toolFamilyCatalog = (sources) => {
|
||||
...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS,
|
||||
...sources.evmContracts.EVM_TOOL_CONTRACTS,
|
||||
...sources.goContracts.GO_TOOL_CONTRACTS,
|
||||
...sources.rustContracts.RUST_TOOL_CONTRACTS,
|
||||
...sources.wasmContracts.WASM_TOOL_CONTRACTS,
|
||||
...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS,
|
||||
...sources.captureContracts.CAPTURE_TOOL_CONTRACTS,
|
||||
@@ -225,6 +226,10 @@ export const providerCatalog = (sources) => {
|
||||
identity: sources.goProvider.GO_BINARY_PROVIDER_IDENTITY,
|
||||
contracts: sources.goContracts.GO_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.rustProvider.RUST_BINARY_PROVIDER_IDENTITY,
|
||||
contracts: sources.rustContracts.RUST_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.sqliteDomain.SQLITE_PROVIDER_IDENTITY,
|
||||
contracts: sources.sqliteContracts.SQLITE_TOOL_CONTRACTS,
|
||||
|
||||
@@ -59,6 +59,8 @@ const SOURCE_PATHS = {
|
||||
evmProvider: "dist/evm/EvmoleRelease.js",
|
||||
goContracts: "dist/contracts/go/goToolContracts.js",
|
||||
goProvider: "dist/go/GoBinaryProvider.js",
|
||||
rustContracts: "dist/contracts/rust/rustToolContracts.js",
|
||||
rustProvider: "dist/rust/RustBinaryProvider.js",
|
||||
artifactContracts: "dist/contracts/artifactToolContracts.js",
|
||||
managedContracts: "dist/contracts/managed/managedToolContracts.js",
|
||||
firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js",
|
||||
|
||||
@@ -8,6 +8,7 @@ const MIGRATED_PROVIDER_ROOTS = new Set([
|
||||
"sqlite",
|
||||
"evm",
|
||||
"go",
|
||||
"rust",
|
||||
"wasm",
|
||||
"captures",
|
||||
"signals",
|
||||
|
||||
@@ -343,7 +343,7 @@ const workflowAvailabilityFor = ({
|
||||
kind,
|
||||
policy,
|
||||
}: AvailabilityContext): Availability | null => {
|
||||
if (name === "inspect_go_binary")
|
||||
if (name === "inspect_go_binary" || name === "inspect_rust_binary")
|
||||
return { reason: "available", remediation: null };
|
||||
if (name === "inspect_sqlite_database") {
|
||||
const capability = sqliteDatabaseCapability(process.versions.node);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type {
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type {
|
||||
InspectRustBinaryInput,
|
||||
RustBinary,
|
||||
} from "../../domain/rust/rustBinary.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Replaceable offline dependency reader with no target-execution authority. */
|
||||
export interface RustBinaryPort {
|
||||
readonly identity: ProviderIdentity;
|
||||
inspect(
|
||||
input: InspectRustBinaryInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<RustBinary, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { RustBinary } from "../../domain/rust/rustBinary.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import { RustBinaryService } from "./RustBinaryService.js";
|
||||
|
||||
const identity = {
|
||||
id: "fixture-rust",
|
||||
name: "Source-owned Rust reader seam",
|
||||
version: "1",
|
||||
};
|
||||
const report: RustBinary = {
|
||||
artifact: { path: "/artifacts/program", sha256: "a".repeat(64), bytes: 128 },
|
||||
format: "elf",
|
||||
architecture: "amd64",
|
||||
bits: 64,
|
||||
byte_order: "little",
|
||||
auditable_metadata: {
|
||||
section_name: ".dep-v0",
|
||||
file_offset: 16,
|
||||
section_bytes: 8,
|
||||
compressed_bytes: 8,
|
||||
uncompressed_bytes: 20,
|
||||
format_version: 1,
|
||||
packages: [
|
||||
{
|
||||
name: "sample",
|
||||
version: "0.1.0",
|
||||
source: "local",
|
||||
kind: null,
|
||||
dependencies: [],
|
||||
root: true,
|
||||
},
|
||||
],
|
||||
root_package_index: 0,
|
||||
dependency_edges: [],
|
||||
cyclic_dependency_indices: [],
|
||||
},
|
||||
rustc_commit: null,
|
||||
limitations: [
|
||||
"No /rustc/<40-hex>/ path was found in read-only data, so a rustc commit hash is unknown.",
|
||||
],
|
||||
};
|
||||
|
||||
it("rejects a relative path and extra arguments before reading", async () => {
|
||||
let calls = 0;
|
||||
const service = new RustBinaryService({
|
||||
identity,
|
||||
inspect: () => {
|
||||
calls += 1;
|
||||
return Promise.resolve(ok(report));
|
||||
},
|
||||
});
|
||||
const relative = await service.inspect({ path: "program" });
|
||||
const extra = await service.inspect({
|
||||
path: report.artifact.path,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(relative.ok).toBe(false);
|
||||
expect(extra.ok).toBe(false);
|
||||
if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError);
|
||||
if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("records the selected artifact and the dependency section range", async () => {
|
||||
const result = await new RustBinaryService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(report)),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toMatchObject({
|
||||
confidence: "observed",
|
||||
subject: { format: "elf", architecture: "x86_64" },
|
||||
normalized_result: report,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: report.artifact.path },
|
||||
{ kind: "file-offset-range", start: 16, end: 24 },
|
||||
],
|
||||
});
|
||||
const mismatched: RustBinary = {
|
||||
...report,
|
||||
artifact: { ...report.artifact, path: "/artifacts/other" },
|
||||
};
|
||||
const invalid = await new RustBinaryService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(mismatched)),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(invalid.ok).toBe(false);
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it("rejects a cycle claim the package dependencies do not contain", async () => {
|
||||
if (report.auditable_metadata === null) throw new Error("expected metadata");
|
||||
const invalidReport: RustBinary = {
|
||||
...report,
|
||||
auditable_metadata: {
|
||||
...report.auditable_metadata,
|
||||
cyclic_dependency_indices: [0],
|
||||
},
|
||||
};
|
||||
const result = await new RustBinaryService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(invalidReport)),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ from_index: 1, to_index: 0 },
|
||||
{ from_index: 0, to_index: 1 },
|
||||
])("rejects provider edges referencing missing packages: %j", async (edge) => {
|
||||
if (report.auditable_metadata === null) throw new Error("expected metadata");
|
||||
const invalidReport: RustBinary = {
|
||||
...report,
|
||||
auditable_metadata: {
|
||||
...report.auditable_metadata,
|
||||
dependency_edges: [edge],
|
||||
},
|
||||
};
|
||||
const result = await new RustBinaryService({
|
||||
identity,
|
||||
inspect: () => Promise.resolve(ok(invalidReport)),
|
||||
}).inspect({ path: report.artifact.path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { RustBinaryPort } from "./RustBinaryPort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
inspectRustBinaryInputSchema,
|
||||
rustBinarySchema,
|
||||
} from "../../domain/rust/rustBinary.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
|
||||
const OPERATION = "inspect_rust_binary";
|
||||
|
||||
const architectureForSubject = (architecture: string) => {
|
||||
switch (architecture) {
|
||||
case "386":
|
||||
return "x86";
|
||||
case "amd64":
|
||||
return "x86_64";
|
||||
default:
|
||||
return BINARY_ARCHITECTURES.find(
|
||||
(supported) => supported === architecture,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Shared exact input validation and artifact-bound Evidence for CLI and MCP. */
|
||||
export class RustBinaryService {
|
||||
constructor(readonly provider: RustBinaryPort) {}
|
||||
|
||||
/** Inspect one explicitly selected file without acquiring a binary session. */
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const input = inspectRustBinaryInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
if (!isAbsolute(input.data.path))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inspected = await this.provider.inspect(input.data, options);
|
||||
if (!inspected.ok) return inspected;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const report = rustBinarySchema.safeParse(inspected.value);
|
||||
if (!report.success || report.data.artifact.path !== input.data.path)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Rust dependency reader returned malformed metadata or changed the selected artifact identity.",
|
||||
),
|
||||
);
|
||||
return ok(evidenceFor(this.provider.identity, input.data, report.data));
|
||||
}
|
||||
}
|
||||
|
||||
const evidenceFor = (
|
||||
provider: RustBinaryPort["identity"],
|
||||
parameters: { readonly path: string },
|
||||
value: ReturnType<typeof rustBinarySchema.parse>,
|
||||
): Evidence => {
|
||||
const architecture = architectureForSubject(value.architecture);
|
||||
const section = value.auditable_metadata;
|
||||
return createEvidence(
|
||||
{
|
||||
path: value.artifact.path,
|
||||
sha256: value.artifact.sha256,
|
||||
format: value.format === "macho" ? "mach-o" : value.format,
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
},
|
||||
provider,
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters,
|
||||
result: value,
|
||||
confidence: "observed",
|
||||
limitations: value.limitations,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: value.artifact.path },
|
||||
...(section === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
kind: "file-offset-range" as const,
|
||||
start: section.file_offset,
|
||||
end: section.file_offset + section.section_bytes,
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { snapshotEnvironment } from "./process/snapshotEnvironment.js";
|
||||
import { createDirectAnalysis } from "./composition/directAnalysis.js";
|
||||
import { registerEvmCommands } from "./cli/evmCommands.js";
|
||||
import { registerGoCommands } from "./cli/goCommands.js";
|
||||
import { registerRustCommands } from "./cli/rustCommands.js";
|
||||
import { registerSqliteCommands } from "./cli/sqliteCommands.js";
|
||||
import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js";
|
||||
import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js";
|
||||
@@ -101,6 +102,7 @@ export const createCli = (
|
||||
registerSignalCommands(cli, logger, environment);
|
||||
registerEvmCommands(cli, logger, environment);
|
||||
registerGoCommands(cli, logger);
|
||||
registerRustCommands(cli, logger);
|
||||
registerSqliteCommands(cli, logger, environment);
|
||||
registerArtifactCommands(cli, logger, analysis.runProviderAnalysis);
|
||||
registerManagedCommands(cli, logger, analysis.runProviderAnalysis);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "incur";
|
||||
import { resolve } from "node:path";
|
||||
import { createRustBinaryService } from "../composition/rust.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
|
||||
/** Resolve caller paths and inspect embedded Rust dependencies through the shared workflow. */
|
||||
export const registerRustCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
): void => {
|
||||
const service = createRustBinaryService();
|
||||
cli.command(CLI_COMMANDS.inspectRustBinary, {
|
||||
description:
|
||||
"Inspect an embedded cargo-auditable Rust dependency list without target execution",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local ELF, PE or thin Mach-O image"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => {
|
||||
const result = await service.inspect(
|
||||
{ path: resolve(args.path) },
|
||||
{ signal },
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -50,6 +50,7 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
inspectRecordedCrash: "inspect-recorded-crash",
|
||||
inspectBinaryLayout: "inspect-binary-layout",
|
||||
inspectGoBinary: "inspect-go-binary",
|
||||
inspectRustBinary: "inspect-rust-binary",
|
||||
inspectPeResources: "inspect-pe-resources",
|
||||
inspectAnalysisView: "inspect-analysis-view",
|
||||
listFridaDevices: "list-frida-devices",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { RustBinaryService } from "../application/rust/RustBinaryService.js";
|
||||
import { RustBinaryProvider } from "../rust/RustBinaryProvider.js";
|
||||
|
||||
/** Compose bundled offline inspection without a Rust toolchain or any child process. */
|
||||
export const createRustBinaryService = (): RustBinaryService =>
|
||||
new RustBinaryService(new RustBinaryProvider());
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
inspectRustBinaryInputSchema,
|
||||
rustBinarySchema,
|
||||
} from "../../domain/rust/rustBinary.js";
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
|
||||
/** Static Rust dependency metadata is independent of active native-analysis providers. */
|
||||
export const RUST_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_rust_binary",
|
||||
...toolContractMetadata("inspect_rust_binary"),
|
||||
kind: "artifact-provider",
|
||||
description:
|
||||
"Inspect the cargo-auditable crate dependency list embedded in an explicit local ELF, PE or thin Mach-O image. Returns the artifact SHA-256, the .dep-v0 section file offset and size, compressed and uncompressed sizes, format version, packages in their original order, the root package index, and dependency edges by index. Reads zlib-compressed JSON without installing Rust, opening a disassembler, executing the target, writing temporary files or accessing the network. A missing section is null metadata with a limitation and is not proof that the binary has no dependencies. A rustc commit hash is reported only as an inference when a /rustc/<40-hex>/ path is present in read-only data. Resource guards bound files to 256 MiB, dependency sections to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the compressed size, and the complete report to 8 MiB. Malformed, unsupported and oversized inputs receive typed failures rather than partial success.",
|
||||
inputSchema: inspectRustBinaryInputSchema,
|
||||
outputSchema: evidenceResultOf(rustBinarySchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect an embedded Rust dependency list",
|
||||
input: { path: "/artifacts/application" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -3,6 +3,7 @@ import { SIGNAL_TOOL_CONTRACTS } from "./signals/signalToolContracts.js";
|
||||
import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js";
|
||||
import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js";
|
||||
import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js";
|
||||
import { RUST_TOOL_CONTRACTS } from "./rust/rustToolContracts.js";
|
||||
import { SQLITE_TOOL_CONTRACTS } from "./sqlite/sqliteToolContracts.js";
|
||||
import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js";
|
||||
import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js";
|
||||
@@ -40,6 +41,7 @@ export const TOOL_CONTRACTS = [
|
||||
...ARTIFACT_TOOL_CONTRACTS,
|
||||
...EVM_TOOL_CONTRACTS,
|
||||
...GO_TOOL_CONTRACTS,
|
||||
...RUST_TOOL_CONTRACTS,
|
||||
...WASM_TOOL_CONTRACTS,
|
||||
...SQLITE_TOOL_CONTRACTS,
|
||||
...CAPTURE_TOOL_CONTRACTS,
|
||||
|
||||
@@ -19,6 +19,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
resolve_native_call_targets: "states that it does not execute code",
|
||||
inspect_pe_resources: "states that it does not execute the image",
|
||||
inspect_go_binary: "reads embedded metadata without executing the target",
|
||||
inspect_rust_binary:
|
||||
"reads an embedded dependency list without executing the target",
|
||||
inspect_sqlite_database:
|
||||
"uses a trusted inspector worker without executing application code or caller SQL",
|
||||
inspect_binary_layout:
|
||||
|
||||
@@ -54,6 +54,7 @@ const sessionEvidence = effects({ mutatesSession: true });
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
inspect_go_binary: evidence,
|
||||
inspect_rust_binary: evidence,
|
||||
inspect_sqlite_database: effects({
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
|
||||
export const RUST_AUDITABLE_FORMAT_VERSION = 1;
|
||||
|
||||
const index = z.number().int().nonnegative();
|
||||
const rustcHash = z.string().regex(/^[0-9a-f]{40}$/u);
|
||||
|
||||
/** One embedded package, with omitted kind and root preserved as null and false. */
|
||||
export const rustPackageSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
source: z.string().min(1),
|
||||
kind: z.enum(["runtime", "build"]).nullable(),
|
||||
dependencies: z.array(index),
|
||||
root: z.boolean(),
|
||||
});
|
||||
|
||||
const dependencyEdgeSchema = z.strictObject({
|
||||
from_index: index,
|
||||
to_index: index,
|
||||
});
|
||||
|
||||
/** zlib JSON from one `.dep-v0` section, in the section's package order. */
|
||||
export const rustAuditableMetadataSchema = z.strictObject({
|
||||
section_name: z.enum([".dep-v0", "__DATA,.dep-v0"]),
|
||||
file_offset: index,
|
||||
section_bytes: z.number().int().positive(),
|
||||
compressed_bytes: z.number().int().positive(),
|
||||
uncompressed_bytes: z.number().int().positive(),
|
||||
format_version: z.literal(RUST_AUDITABLE_FORMAT_VERSION).nullable(),
|
||||
packages: z.array(rustPackageSchema),
|
||||
root_package_index: index.nullable(),
|
||||
dependency_edges: z.array(dependencyEdgeSchema),
|
||||
cyclic_dependency_indices: z.array(index),
|
||||
});
|
||||
|
||||
/** Inferred compiler id from read-only `/rustc/<40-hex>/` paths. */
|
||||
export const rustcCommitSchema = z.strictObject({
|
||||
evidence_kind: z.literal("inferred"),
|
||||
hashes: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
hash: rustcHash,
|
||||
file_offset: index,
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
/** One explicit local executable or library, independent of a disassembler target. */
|
||||
export const inspectRustBinaryInputSchema = z.strictObject({
|
||||
path: localPathStringSchema.describe(
|
||||
"Absolute filesystem path to the selected executable or library",
|
||||
),
|
||||
});
|
||||
|
||||
/** Bounded cargo-auditable observation. A missing section is null, not an empty dependency list. */
|
||||
export const rustBinarySchema = z
|
||||
.strictObject({
|
||||
artifact: z.strictObject({
|
||||
path: localPathStringSchema,
|
||||
sha256: digestSchema,
|
||||
bytes: index,
|
||||
}),
|
||||
format: z.enum(["elf", "pe", "macho"]),
|
||||
architecture: z.string(),
|
||||
bits: z.union([z.literal(32), z.literal(64)]),
|
||||
byte_order: z.enum(["little", "big"]),
|
||||
auditable_metadata: rustAuditableMetadataSchema.nullable(),
|
||||
rustc_commit: rustcCommitSchema.nullable(),
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((report, context) => {
|
||||
const metadata = report.auditable_metadata;
|
||||
if (metadata !== null) {
|
||||
checkSection(report.artifact.bytes, metadata, context);
|
||||
checkPackages(metadata, context);
|
||||
}
|
||||
checkRustc(report.artifact.bytes, report.rustc_commit, context);
|
||||
});
|
||||
|
||||
export type RustPackage = z.infer<typeof rustPackageSchema>;
|
||||
export type RustAuditableMetadata = z.infer<typeof rustAuditableMetadataSchema>;
|
||||
export type RustBinary = z.infer<typeof rustBinarySchema>;
|
||||
export type InspectRustBinaryInput = z.infer<
|
||||
typeof inspectRustBinaryInputSchema
|
||||
>;
|
||||
|
||||
const issue = (
|
||||
context: z.RefinementCtx,
|
||||
path: (string | number)[],
|
||||
message: string,
|
||||
): void => {
|
||||
context.addIssue({ code: "custom", path, message });
|
||||
};
|
||||
|
||||
const inside = (fileBytes: number, offset: number, size: number): boolean =>
|
||||
offset <= fileBytes && size <= fileBytes - offset;
|
||||
|
||||
const checkSection = (
|
||||
fileBytes: number,
|
||||
metadata: RustAuditableMetadata,
|
||||
context: z.RefinementCtx,
|
||||
): void => {
|
||||
if (
|
||||
!inside(fileBytes, metadata.file_offset, metadata.section_bytes) ||
|
||||
metadata.compressed_bytes !== metadata.section_bytes
|
||||
)
|
||||
issue(
|
||||
context,
|
||||
["auditable_metadata", "file_offset"],
|
||||
"Dependency section range is outside the selected artifact or disagrees with its compressed size.",
|
||||
);
|
||||
};
|
||||
|
||||
const checkPackages = (
|
||||
metadata: RustAuditableMetadata,
|
||||
context: z.RefinementCtx,
|
||||
): void => {
|
||||
const roots: number[] = [];
|
||||
const edgesByPackage = new Map<
|
||||
number,
|
||||
z.infer<typeof dependencyEdgeSchema>[]
|
||||
>();
|
||||
for (const [position, edge] of metadata.dependency_edges.entries()) {
|
||||
if (
|
||||
edge.from_index >= metadata.packages.length ||
|
||||
edge.to_index >= metadata.packages.length
|
||||
) {
|
||||
issue(
|
||||
context,
|
||||
["auditable_metadata", "dependency_edges", position],
|
||||
"Dependency edge index is outside the package list.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const grouped = edgesByPackage.get(edge.from_index);
|
||||
if (grouped === undefined) edgesByPackage.set(edge.from_index, [edge]);
|
||||
else grouped.push(edge);
|
||||
}
|
||||
for (const [packageIndex, pkg] of metadata.packages.entries()) {
|
||||
if (pkg.root) roots.push(packageIndex);
|
||||
const edges = edgesByPackage.get(packageIndex) ?? [];
|
||||
if (
|
||||
edges.length !== pkg.dependencies.length ||
|
||||
edges.some((edge, index) => edge.to_index !== pkg.dependencies[index])
|
||||
)
|
||||
issue(
|
||||
context,
|
||||
["auditable_metadata", "dependency_edges"],
|
||||
"Dependency edges do not match package dependency indices.",
|
||||
);
|
||||
if (pkg.dependencies.some((target) => target >= metadata.packages.length))
|
||||
issue(
|
||||
context,
|
||||
["auditable_metadata", "packages", packageIndex, "dependencies"],
|
||||
"Dependency index is outside the package list.",
|
||||
);
|
||||
}
|
||||
const root = metadata.root_package_index;
|
||||
if (
|
||||
root === null ? roots.length !== 0 : roots.length !== 1 || roots[0] !== root
|
||||
)
|
||||
issue(
|
||||
context,
|
||||
["auditable_metadata", "root_package_index"],
|
||||
"Root package index does not match packages marked root.",
|
||||
);
|
||||
const cycles = cyclicDependencyIndices(metadata.packages);
|
||||
if (
|
||||
cycles.length !== metadata.cyclic_dependency_indices.length ||
|
||||
cycles.some(
|
||||
(index, position) =>
|
||||
metadata.cyclic_dependency_indices[position] !== index,
|
||||
)
|
||||
)
|
||||
issue(
|
||||
context,
|
||||
["auditable_metadata", "cyclic_dependency_indices"],
|
||||
"Cyclic dependency indices do not match the cycles in package dependencies.",
|
||||
);
|
||||
};
|
||||
|
||||
/** Package indices that participate in a dependency cycle, in ascending order. */
|
||||
export const cyclicDependencyIndices = (
|
||||
packages: readonly { readonly dependencies: readonly number[] }[],
|
||||
): number[] => {
|
||||
const edges = packages.map((pkg) => pkg.dependencies);
|
||||
const order = dependencyPostorder(edges);
|
||||
const reversed = reversedDependencyEdges(edges);
|
||||
const seen = new Uint8Array(edges.length);
|
||||
const cyclic = new Set<number>();
|
||||
for (let cursor = order.length - 1; cursor >= 0; cursor -= 1) {
|
||||
const start = order[cursor];
|
||||
if (start === undefined || seen[start] === 1) continue;
|
||||
const component = dependencyComponent(start, reversed, seen);
|
||||
if (dependencyComponentInCycle(component, edges))
|
||||
for (const index of component) cyclic.add(index);
|
||||
}
|
||||
return [...cyclic].sort((left, right) => left - right);
|
||||
};
|
||||
|
||||
interface DependencyWalkFrame {
|
||||
readonly index: number;
|
||||
edge: number;
|
||||
}
|
||||
|
||||
const dependencyPostorder = (
|
||||
edges: readonly (readonly number[])[],
|
||||
): number[] => {
|
||||
const seen = new Uint8Array(edges.length);
|
||||
const order: number[] = [];
|
||||
for (let start = 0; start < edges.length; start += 1) {
|
||||
if (seen[start] === 1) continue;
|
||||
const stack: DependencyWalkFrame[] = [{ index: start, edge: 0 }];
|
||||
seen[start] = 1;
|
||||
while (stack.length > 0) {
|
||||
const frame = stack[stack.length - 1];
|
||||
if (frame === undefined) break;
|
||||
const next = edges[frame.index]?.[frame.edge];
|
||||
if (next === undefined) {
|
||||
stack.pop();
|
||||
order.push(frame.index);
|
||||
continue;
|
||||
}
|
||||
frame.edge += 1;
|
||||
if (seen[next] === 0) {
|
||||
seen[next] = 1;
|
||||
stack.push({ index: next, edge: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return order;
|
||||
};
|
||||
|
||||
const reversedDependencyEdges = (
|
||||
edges: readonly (readonly number[])[],
|
||||
): number[][] => {
|
||||
const reversed = Array.from({ length: edges.length }, () => [] as number[]);
|
||||
for (let from = 0; from < edges.length; from += 1)
|
||||
for (const to of edges[from] ?? []) reversed[to]?.push(from);
|
||||
return reversed;
|
||||
};
|
||||
|
||||
const dependencyComponent = (
|
||||
start: number,
|
||||
reversed: readonly (readonly number[])[],
|
||||
seen: Uint8Array,
|
||||
): number[] => {
|
||||
const component: number[] = [];
|
||||
const stack = [start];
|
||||
seen[start] = 1;
|
||||
while (stack.length > 0) {
|
||||
const index = stack.pop();
|
||||
if (index === undefined) break;
|
||||
component.push(index);
|
||||
for (const next of reversed[index] ?? []) {
|
||||
if (seen[next] === 0) {
|
||||
seen[next] = 1;
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
const dependencyComponentInCycle = (
|
||||
component: readonly number[],
|
||||
edges: readonly (readonly number[])[],
|
||||
): boolean => {
|
||||
if (component.length > 1) return true;
|
||||
const only = component[0];
|
||||
return only !== undefined && (edges[only]?.includes(only) ?? false);
|
||||
};
|
||||
|
||||
const checkRustc = (
|
||||
fileBytes: number,
|
||||
commit: RustBinary["rustc_commit"],
|
||||
context: z.RefinementCtx,
|
||||
): void => {
|
||||
if (commit === null) return;
|
||||
const seen = new Set<string>();
|
||||
for (const [position, entry] of commit.hashes.entries()) {
|
||||
if (seen.has(entry.hash) || !inside(fileBytes, entry.file_offset, 48))
|
||||
issue(
|
||||
context,
|
||||
["rustc_commit", "hashes", position],
|
||||
"rustc path hash is duplicated or its file offset is outside the artifact.",
|
||||
);
|
||||
seen.add(entry.hash);
|
||||
}
|
||||
};
|
||||
@@ -34,6 +34,16 @@ export interface GoZeroFillMapping {
|
||||
readonly size: bigint;
|
||||
}
|
||||
|
||||
/** One named file section. Invalid declared ranges stay visible with `file_backed: false`. */
|
||||
export interface GoImageSection {
|
||||
readonly name: string;
|
||||
readonly offset: number;
|
||||
readonly size: number;
|
||||
readonly file_backed: boolean;
|
||||
readonly read_only: boolean;
|
||||
readonly compressed: boolean;
|
||||
}
|
||||
|
||||
/** Native container facts and its producer-defined build-info search area. */
|
||||
export interface GoBinaryContainer {
|
||||
readonly format: "elf" | "pe" | "macho";
|
||||
@@ -42,6 +52,7 @@ export interface GoBinaryContainer {
|
||||
readonly byte_order: "little" | "big";
|
||||
readonly mappings: readonly GoFileMapping[];
|
||||
readonly zero_fills: readonly GoZeroFillMapping[];
|
||||
readonly sections: readonly GoImageSection[];
|
||||
readonly search: GoFileMapping | null;
|
||||
}
|
||||
|
||||
@@ -110,6 +121,32 @@ export class GoBinaryReader {
|
||||
}
|
||||
}
|
||||
|
||||
/** Admit a declared file span, or mark it unbacked when it leaves the file. */
|
||||
export const declaredFileRange = (
|
||||
fileBytes: number,
|
||||
offset: bigint,
|
||||
size: bigint,
|
||||
): {
|
||||
readonly offset: number;
|
||||
readonly size: number;
|
||||
readonly file_backed: boolean;
|
||||
} => {
|
||||
if (
|
||||
offset < 0n ||
|
||||
size < 0n ||
|
||||
offset > BigInt(Number.MAX_SAFE_INTEGER) ||
|
||||
size > BigInt(Number.MAX_SAFE_INTEGER) ||
|
||||
offset > BigInt(fileBytes) ||
|
||||
size > BigInt(fileBytes) - offset
|
||||
)
|
||||
return { offset: 0, size: 0, file_backed: false };
|
||||
return {
|
||||
offset: Number(offset),
|
||||
size: Number(size),
|
||||
file_backed: size > 0n,
|
||||
};
|
||||
};
|
||||
|
||||
/** Map a virtual span without losing precision or selecting an ambiguous alias. */
|
||||
export const mappedFileOffset = (
|
||||
mappings: readonly GoFileMapping[],
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface GoBinaryImage {
|
||||
|
||||
/** Inspect bounded executable bytes using native mappings, without loading or executing code. */
|
||||
export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => {
|
||||
const container = readContainer(bytes);
|
||||
const container = readGoBinaryContainer(bytes);
|
||||
const { format, architecture, bits, byte_order } = container;
|
||||
const search = container.search;
|
||||
const header = findHeader(bytes, search);
|
||||
@@ -184,7 +184,8 @@ interface LocatedString {
|
||||
readonly location: GoStringLocation;
|
||||
}
|
||||
|
||||
const readContainer = (bytes: Buffer): GoBinaryContainer => {
|
||||
/** Identify an ELF, PE, or thin Mach-O image and its named file sections. */
|
||||
export const readGoBinaryContainer = (bytes: Buffer): GoBinaryContainer => {
|
||||
if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])))
|
||||
return readGoElfImage(bytes);
|
||||
if (bytes.subarray(0, 2).equals(Buffer.from("MZ")))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
declaredFileRange,
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryReader,
|
||||
mappedFileOffset,
|
||||
type GoBinaryContainer,
|
||||
type GoFileMapping,
|
||||
type GoImageSection,
|
||||
type GoZeroFillMapping,
|
||||
} from "./GoBinaryContainer.js";
|
||||
import { readElfFixedHeader } from "../domain/elfFixedHeader.js";
|
||||
@@ -100,6 +102,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
);
|
||||
const mappings: GoFileMapping[] = [];
|
||||
const zeroFills: GoZeroFillMapping[] = [];
|
||||
const sections: GoImageSection[] = [];
|
||||
let fallback: GoFileMapping | null = null;
|
||||
for (let index = 0; index < phCount; index++) {
|
||||
const offset = phStart + index * phWidth;
|
||||
@@ -158,6 +161,15 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
"malformed",
|
||||
"ELF section name offset is outside its string table",
|
||||
);
|
||||
recordElfSection(sections, {
|
||||
bytes,
|
||||
names,
|
||||
nameOffset,
|
||||
reader,
|
||||
bits,
|
||||
wide,
|
||||
header: offset,
|
||||
});
|
||||
// Comparing the complete fixed name avoids quadratic scans through malicious string tables.
|
||||
const expectedName = Buffer.from(".go.buildinfo\0");
|
||||
if (
|
||||
@@ -213,10 +225,105 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
byte_order: reader.little ? "little" : "big",
|
||||
mappings,
|
||||
zero_fills: zeroFills,
|
||||
sections,
|
||||
search: search ?? fallback,
|
||||
};
|
||||
};
|
||||
|
||||
const recordElfSection = (
|
||||
sections: GoImageSection[],
|
||||
input: {
|
||||
readonly bytes: Buffer;
|
||||
readonly names: { readonly offset: number; readonly size: number };
|
||||
readonly nameOffset: number;
|
||||
readonly reader: GoBinaryReader;
|
||||
readonly bits: 32 | 64;
|
||||
readonly wide: boolean;
|
||||
readonly header: number;
|
||||
},
|
||||
): void => {
|
||||
const decoded = elfSectionName(input.bytes, input.names, input.nameOffset);
|
||||
const header = {
|
||||
reader: input.reader,
|
||||
bits: input.bits,
|
||||
wide: input.wide,
|
||||
header: input.header,
|
||||
};
|
||||
switch (decoded.status) {
|
||||
case "named":
|
||||
sections.push(elfImageSection({ ...header, name: decoded.name }));
|
||||
return;
|
||||
case "truncated": {
|
||||
// Names past this window stay out of .dep-v0 matching. Read-only
|
||||
// bytes can still hold the only compiler path.
|
||||
const section = elfImageSection({ ...header, name: "" });
|
||||
if (section.read_only) sections.push(section);
|
||||
return;
|
||||
}
|
||||
case "unnamed":
|
||||
return;
|
||||
default: {
|
||||
const unreachable: never = decoded;
|
||||
return unreachable;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const elfSectionName = (
|
||||
bytes: Buffer,
|
||||
names: { readonly offset: number; readonly size: number },
|
||||
nameOffset: number,
|
||||
):
|
||||
| { readonly status: "named"; readonly name: string }
|
||||
| { readonly status: "unnamed" }
|
||||
| { readonly status: "truncated" } => {
|
||||
const start = names.offset + nameOffset;
|
||||
const limit = Math.min(start + 64, names.offset + names.size);
|
||||
const nul = bytes.subarray(start, limit).indexOf(0);
|
||||
if (nul === 0) return { status: "unnamed" };
|
||||
if (nul < 0) return { status: "truncated" };
|
||||
return {
|
||||
status: "named",
|
||||
name: bytes.toString("latin1", start, start + nul),
|
||||
};
|
||||
};
|
||||
|
||||
const elfImageSection = ({
|
||||
reader,
|
||||
bits,
|
||||
wide,
|
||||
header,
|
||||
name,
|
||||
}: {
|
||||
readonly reader: GoBinaryReader;
|
||||
readonly bits: 32 | 64;
|
||||
readonly wide: boolean;
|
||||
readonly header: number;
|
||||
readonly name: string;
|
||||
}): GoImageSection => {
|
||||
const type = reader.u32(header + 4);
|
||||
const flags = reader.word(header + 8, bits);
|
||||
const declared = declaredFileRange(
|
||||
reader.bytes.length,
|
||||
reader.word(header + (wide ? 24 : 16), bits),
|
||||
reader.word(header + (wide ? 32 : 20), bits),
|
||||
);
|
||||
// SHT_NOBITS offsets and sizes do not describe stored bytes.
|
||||
const range =
|
||||
type === SHT_NOBITS ? { ...declared, file_backed: false } : declared;
|
||||
const compressed = (flags & 0x800n) !== 0n;
|
||||
const readOnly =
|
||||
type === 1 &&
|
||||
range.file_backed &&
|
||||
!compressed &&
|
||||
(flags & 1n) === 0n &&
|
||||
(flags & 4n) === 0n &&
|
||||
(flags & 2n) !== 0n;
|
||||
return { name, ...range, read_only: readOnly, compressed };
|
||||
};
|
||||
|
||||
const SHT_NOBITS = 8;
|
||||
|
||||
const elfArchitecture = (machine: number): string => {
|
||||
const names = new Map([
|
||||
[3, "386"],
|
||||
|
||||
+86
-5
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
declaredFileRange,
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryReader,
|
||||
mappedFileOffset,
|
||||
type GoBinaryContainer,
|
||||
type GoFileMapping,
|
||||
type GoImageSection,
|
||||
type GoZeroFillMapping,
|
||||
} from "./GoBinaryContainer.js";
|
||||
|
||||
@@ -30,6 +32,7 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
);
|
||||
const mappings: GoFileMapping[] = [];
|
||||
const zeroFills: GoZeroFillMapping[] = [];
|
||||
const sections: GoImageSection[] = [];
|
||||
const named: GoFileMapping[] = [];
|
||||
let fallback: GoFileMapping | null = null;
|
||||
let offset = commandStart;
|
||||
@@ -105,11 +108,25 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
fallback = mapping;
|
||||
for (let sectionIndex = 0; sectionIndex < sectionCount; sectionIndex++) {
|
||||
const section = offset + segmentHeader + sectionIndex * sectionWidth;
|
||||
if (
|
||||
fixedName(reader.range(section, 16, "Mach-O section name")) !==
|
||||
"__go_buildinfo"
|
||||
)
|
||||
continue;
|
||||
const sectionName = fixedName(
|
||||
reader.range(section, 16, "Mach-O section name"),
|
||||
);
|
||||
sections.push(
|
||||
machoImageSection({
|
||||
reader,
|
||||
bits,
|
||||
wide,
|
||||
header: section,
|
||||
name: `${fixedName(reader.range(section + 16, 16, "Mach-O section segment"))},${sectionName}`,
|
||||
protection,
|
||||
unmapped: unmapped || mapping === undefined,
|
||||
segmentAddress: address,
|
||||
segmentMemorySize: memorySize,
|
||||
segmentFileOffset: fileOffset,
|
||||
segmentFileSize: fileSize,
|
||||
}),
|
||||
);
|
||||
if (sectionName !== "__go_buildinfo") continue;
|
||||
if (unmapped)
|
||||
throw new GoBinaryFormatFailure(
|
||||
"malformed",
|
||||
@@ -175,10 +192,74 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
byte_order: little ? "little" : "big",
|
||||
mappings,
|
||||
zero_fills: zeroFills,
|
||||
sections,
|
||||
search: named[0] ?? fallback,
|
||||
};
|
||||
};
|
||||
|
||||
const machoImageSection = ({
|
||||
reader,
|
||||
bits,
|
||||
wide,
|
||||
header,
|
||||
name,
|
||||
protection,
|
||||
unmapped,
|
||||
segmentAddress,
|
||||
segmentMemorySize,
|
||||
segmentFileOffset,
|
||||
segmentFileSize,
|
||||
}: {
|
||||
readonly reader: GoBinaryReader;
|
||||
readonly bits: 32 | 64;
|
||||
readonly wide: boolean;
|
||||
readonly header: number;
|
||||
readonly name: string;
|
||||
readonly protection: number;
|
||||
readonly unmapped: boolean;
|
||||
readonly segmentAddress: bigint;
|
||||
readonly segmentMemorySize: bigint;
|
||||
readonly segmentFileOffset: bigint;
|
||||
readonly segmentFileSize: bigint;
|
||||
}): GoImageSection => {
|
||||
const flags = reader.u32(header + (wide ? 64 : 56));
|
||||
const zeroFill = [1, 0xc, 0x12].includes(flags & 0xff);
|
||||
const sectionAddress = reader.word(header + 32, bits);
|
||||
const sectionSize = reader.word(header + (wide ? 40 : 36), bits);
|
||||
const sectionOffset = BigInt(reader.u32(header + (wide ? 48 : 40)));
|
||||
const addressDelta = sectionAddress - segmentAddress;
|
||||
const fileDelta = sectionOffset - segmentFileOffset;
|
||||
const inSegmentMemory =
|
||||
addressDelta >= 0n &&
|
||||
addressDelta <= segmentMemorySize &&
|
||||
sectionSize <= segmentMemorySize - addressDelta;
|
||||
const inSegmentFile =
|
||||
fileDelta >= 0n &&
|
||||
fileDelta <= segmentFileSize &&
|
||||
sectionSize <= segmentFileSize - fileDelta;
|
||||
const range = unmapped
|
||||
? { offset: 0, size: 0, file_backed: false }
|
||||
: declaredFileRange(
|
||||
reader.bytes.length,
|
||||
BigInt(reader.u32(header + (wide ? 48 : 40))),
|
||||
reader.word(header + (wide ? 40 : 36), bits),
|
||||
);
|
||||
const fileBacked =
|
||||
range.file_backed && !zeroFill && inSegmentMemory && inSegmentFile;
|
||||
const readOnly =
|
||||
fileBacked &&
|
||||
(protection & 1) !== 0 &&
|
||||
(protection & 2) === 0 &&
|
||||
(flags & 0x80000400) === 0;
|
||||
return {
|
||||
name,
|
||||
...range,
|
||||
file_backed: fileBacked,
|
||||
read_only: readOnly,
|
||||
compressed: false,
|
||||
};
|
||||
};
|
||||
|
||||
const fixedName = (bytes: Buffer): string => {
|
||||
const nul = bytes.indexOf(0);
|
||||
return bytes.subarray(0, nul < 0 ? bytes.length : nul).toString("latin1");
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { observeMzWindowsHeader, parseDosMzHeader } from "../domain/dosMz.js";
|
||||
import {
|
||||
declaredFileRange,
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryReader,
|
||||
type GoBinaryContainer,
|
||||
type GoFileMapping,
|
||||
type GoImageSection,
|
||||
type GoZeroFillMapping,
|
||||
} from "./GoBinaryContainer.js";
|
||||
|
||||
@@ -86,14 +88,30 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
);
|
||||
const mappings: GoFileMapping[] = [];
|
||||
const zeroFills: GoZeroFillMapping[] = [];
|
||||
const sections: GoImageSection[] = [];
|
||||
let search: GoFileMapping | null = null;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const entry = start + index * 40;
|
||||
const sectionName = peSectionName(
|
||||
reader.range(entry, 8, "PE section name"),
|
||||
);
|
||||
const virtualSize = reader.u32(entry + 8);
|
||||
const virtualAddress = reader.u32(entry + 12);
|
||||
const size = reader.u32(entry + 16);
|
||||
const offset = reader.u32(entry + 20);
|
||||
const characteristics = reader.u32(entry + 36);
|
||||
// SizeOfRawData includes file-alignment padding. VirtualSize is the content.
|
||||
const contentSize = virtualSize === 0 ? size : Math.min(virtualSize, size);
|
||||
if (sectionName.length > 0)
|
||||
sections.push(
|
||||
peImageSection({
|
||||
reader,
|
||||
name: sectionName,
|
||||
size: contentSize,
|
||||
offset,
|
||||
characteristics,
|
||||
}),
|
||||
);
|
||||
const address = imageBase + BigInt(virtualAddress);
|
||||
if (address + BigInt(Math.max(size, virtualSize)) > 1n << BigInt(bits))
|
||||
throw new GoBinaryFormatFailure(
|
||||
@@ -138,6 +156,39 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => {
|
||||
byte_order: "little",
|
||||
mappings,
|
||||
zero_fills: zeroFills,
|
||||
sections,
|
||||
search,
|
||||
};
|
||||
};
|
||||
|
||||
const peSectionName = (bytes: Buffer): string => {
|
||||
const nul = bytes.indexOf(0);
|
||||
return bytes.toString("latin1", 0, nul < 0 ? bytes.length : nul);
|
||||
};
|
||||
|
||||
const peImageSection = ({
|
||||
reader,
|
||||
name,
|
||||
size,
|
||||
offset,
|
||||
characteristics,
|
||||
}: {
|
||||
readonly reader: GoBinaryReader;
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
readonly offset: number;
|
||||
readonly characteristics: number;
|
||||
}): GoImageSection => {
|
||||
const range = declaredFileRange(
|
||||
reader.bytes.length,
|
||||
BigInt(offset),
|
||||
BigInt(size),
|
||||
);
|
||||
const readOnly =
|
||||
range.file_backed &&
|
||||
(characteristics & 0x40000000) !== 0 &&
|
||||
(characteristics & 0x80000000) === 0 &&
|
||||
(characteristics & 0x20000000) === 0 &&
|
||||
(characteristics & 0x40) !== 0;
|
||||
return { name, ...range, read_only: readOnly, compressed: false };
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
RUST_SECTION_BYTES,
|
||||
readAuditableSection,
|
||||
RustAuditableFormatFailure,
|
||||
RustAuditableResourceFailure,
|
||||
} from "./RustAuditableReader.js";
|
||||
|
||||
const section = (document: unknown) =>
|
||||
readAuditableSection(
|
||||
".dep-v0",
|
||||
0,
|
||||
deflateSync(Buffer.from(JSON.stringify(document))),
|
||||
);
|
||||
|
||||
it("preserves package order, omitted kind, and the root index", () => {
|
||||
const parsed = section({
|
||||
packages: [
|
||||
{ name: "itoa", version: "1.0.18", source: "crates.io" },
|
||||
{
|
||||
name: "sample",
|
||||
version: "0.1.0",
|
||||
source: "local",
|
||||
dependencies: [0],
|
||||
root: true,
|
||||
kind: "runtime",
|
||||
},
|
||||
],
|
||||
format: 1,
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
section_name: ".dep-v0",
|
||||
format_version: 1,
|
||||
root_package_index: 1,
|
||||
compressed_bytes: parsed.section_bytes,
|
||||
packages: [
|
||||
{
|
||||
name: "itoa",
|
||||
version: "1.0.18",
|
||||
source: "crates.io",
|
||||
kind: null,
|
||||
dependencies: [],
|
||||
root: false,
|
||||
},
|
||||
{
|
||||
name: "sample",
|
||||
version: "0.1.0",
|
||||
kind: "runtime",
|
||||
dependencies: [0],
|
||||
root: true,
|
||||
},
|
||||
],
|
||||
dependency_edges: [{ from_index: 1, to_index: 0 }],
|
||||
cyclic_dependency_indices: [],
|
||||
});
|
||||
expect(parsed.uncompressed_bytes).toBeGreaterThan(parsed.compressed_bytes);
|
||||
});
|
||||
|
||||
it("reports dependency cycles and keeps an omitted format version unknown", () => {
|
||||
const parsed = section({
|
||||
packages: [
|
||||
{ name: "a", version: "1", source: "local", dependencies: [1] },
|
||||
{ name: "b", version: "1", source: "local", dependencies: [0] },
|
||||
],
|
||||
});
|
||||
expect(parsed.cyclic_dependency_indices).toEqual([0, 1]);
|
||||
expect(parsed.format_version).toBeNull();
|
||||
expect(parsed.root_package_index).toBeNull();
|
||||
});
|
||||
|
||||
it("includes every package that participates in a dependency cycle", () => {
|
||||
const parsed = section({
|
||||
packages: [
|
||||
{ name: "a", version: "1", source: "local", dependencies: [1, 2] },
|
||||
{ name: "b", version: "1", source: "local", dependencies: [0] },
|
||||
{ name: "c", version: "1", source: "local", dependencies: [1] },
|
||||
],
|
||||
});
|
||||
expect(parsed.cyclic_dependency_indices).toEqual([0, 1, 2]);
|
||||
const self = section({
|
||||
packages: [{ name: "a", version: "1", source: "local", dependencies: [0] }],
|
||||
});
|
||||
expect(self.cyclic_dependency_indices).toEqual([0]);
|
||||
});
|
||||
|
||||
it("finishes a long acyclic dependency chain", () => {
|
||||
const count = 20_000;
|
||||
const parsed = section({
|
||||
packages: Array.from({ length: count }, (_, index) => ({
|
||||
name: `p${index.toString(36)}`,
|
||||
version: "1",
|
||||
source: "local",
|
||||
...(index + 1 < count ? { dependencies: [index + 1] } : {}),
|
||||
})),
|
||||
});
|
||||
expect(parsed.packages).toHaveLength(count);
|
||||
expect(parsed.cyclic_dependency_indices).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects out-of-range indices, multiple roots, and schema drift", () => {
|
||||
expect(() =>
|
||||
section({
|
||||
packages: [
|
||||
{ name: "a", version: "1", source: "local", dependencies: [2] },
|
||||
],
|
||||
}),
|
||||
).toThrow(RustAuditableFormatFailure);
|
||||
expect(() =>
|
||||
section({
|
||||
packages: [
|
||||
{ name: "a", version: "1", source: "local", root: true },
|
||||
{ name: "b", version: "1", source: "local", root: true },
|
||||
],
|
||||
}),
|
||||
).toThrow(/more than one root/);
|
||||
expect(() =>
|
||||
section({
|
||||
packages: [{ name: "a", version: "1", source: "local" }],
|
||||
extra: true,
|
||||
}),
|
||||
).toThrow(/package schema/);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON, trailing zlib bytes, and an empty section", () => {
|
||||
expect(() =>
|
||||
readAuditableSection(".dep-v0", 0, deflateSync(Buffer.from("{"))),
|
||||
).toThrow(/JSON is invalid/);
|
||||
const trailed = Buffer.concat([
|
||||
deflateSync(Buffer.from('{"packages":[]}')),
|
||||
Buffer.from([0]),
|
||||
]);
|
||||
expect(() => readAuditableSection(".dep-v0", 0, trailed)).toThrow(
|
||||
/zlib stream/,
|
||||
);
|
||||
expect(() => readAuditableSection(".dep-v0", 0, Buffer.alloc(0))).toThrow(
|
||||
/empty/,
|
||||
);
|
||||
expect(() =>
|
||||
readAuditableSection(".dep-v0", 0, Buffer.from([1, 2, 3])),
|
||||
).toThrow(RustAuditableFormatFailure);
|
||||
});
|
||||
|
||||
it("stops an inflate that exceeds the section ratio", () => {
|
||||
const raw = Buffer.alloc(8000);
|
||||
const compressed = deflateSync(raw);
|
||||
expect(compressed.length * 64).toBeLessThan(raw.length);
|
||||
expect(() => readAuditableSection(".dep-v0", 0, compressed)).toThrow(
|
||||
RustAuditableResourceFailure,
|
||||
);
|
||||
expect(() =>
|
||||
readAuditableSection(".dep-v0", 0, Buffer.alloc(RUST_SECTION_BYTES + 1)),
|
||||
).toThrow(/1 MiB/);
|
||||
});
|
||||
|
||||
it.each([{ packages: [] }, { packages: [{ future_package_field: true }] }])(
|
||||
"rejects an unknown format before interpreting its packages: %j",
|
||||
({ packages }) => {
|
||||
expect(() => section({ format: 99, packages })).toThrow(
|
||||
expect.objectContaining({
|
||||
name: "RustAuditableFormatFailure",
|
||||
kind: "unsupported",
|
||||
message: "Unsupported cargo-auditable format version 99",
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects the first malformed package before schema issues accumulate", () => {
|
||||
const stored = (document: unknown) =>
|
||||
deflateSync(Buffer.from(JSON.stringify(document)), { level: 0 });
|
||||
const emptyPackages = stored({
|
||||
format: 1,
|
||||
packages: Array.from({ length: 80_000 }, () => ({})),
|
||||
});
|
||||
expect(emptyPackages.length).toBeLessThan(RUST_SECTION_BYTES);
|
||||
expect(() => readAuditableSection(".dep-v0", 0, emptyPackages)).toThrow(
|
||||
/Dependency package 0 does not match the package schema/,
|
||||
);
|
||||
const invalidDependencies = stored({
|
||||
format: 1,
|
||||
packages: [
|
||||
{
|
||||
name: "a",
|
||||
version: "1",
|
||||
source: "local",
|
||||
dependencies: Array.from({ length: 1_000 }, () => null),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(() => readAuditableSection(".dep-v0", 0, invalidDependencies)).toThrow(
|
||||
/Dependency package 0 does not match the package schema/,
|
||||
);
|
||||
});
|
||||
|
||||
it("admits dependency expansion before allocating normalized or reverse edges", () => {
|
||||
const document = {
|
||||
packages: [
|
||||
{
|
||||
name: "a",
|
||||
version: "1",
|
||||
source: "local",
|
||||
dependencies: Array<number>(400_000).fill(0),
|
||||
},
|
||||
],
|
||||
};
|
||||
// An uncompressed zlib stream clears the ratio guard while remaining below
|
||||
// the one-MiB section limit, isolating the graph representation admission.
|
||||
const payload = deflateSync(Buffer.from(JSON.stringify(document)), {
|
||||
level: 0,
|
||||
});
|
||||
expect(payload.length).toBeLessThan(RUST_SECTION_BYTES);
|
||||
expect(() => readAuditableSection(".dep-v0", 0, payload)).toThrow(
|
||||
expect.objectContaining({
|
||||
constraint: {
|
||||
resource: "transport",
|
||||
reported_limits: { maximum_output_bytes: 8 * 1024 * 1024 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import { deflateSync, inflateSync } from "node:zlib";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
RUST_AUDITABLE_FORMAT_VERSION,
|
||||
cyclicDependencyIndices,
|
||||
type RustAuditableMetadata,
|
||||
type RustPackage,
|
||||
} from "../domain/rust/rustBinary.js";
|
||||
|
||||
/** Selected-file admission limit shared with the stable artifact reader. */
|
||||
export const RUST_BINARY_INPUT_BYTES = 256 * 1024 * 1024;
|
||||
/** Compressed `.dep-v0` section limit. */
|
||||
export const RUST_SECTION_BYTES = 1024 * 1024;
|
||||
/** Inflated JSON may grow by at most this multiple of the section size. */
|
||||
export const RUST_INFLATE_RATIO = 64;
|
||||
/** Absolute inflated JSON limit, applied together with the ratio. */
|
||||
export const RUST_UNCOMPRESSED_BYTES = 8 * 1024 * 1024;
|
||||
/** Complete normalized report limit. */
|
||||
export const RUST_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const JSON_DEPTH = 32;
|
||||
const packageSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
source: z.string().min(1),
|
||||
kind: z.enum(["runtime", "build"]).optional(),
|
||||
dependencies: z.array(z.number().int().nonnegative()).optional(),
|
||||
root: z.boolean().optional(),
|
||||
});
|
||||
const documentVersionSchema = z.object({
|
||||
format: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
const documentSchema = documentVersionSchema
|
||||
.extend({
|
||||
format: z.literal(RUST_AUDITABLE_FORMAT_VERSION).optional(),
|
||||
packages: z.array(packageSchema),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Malformed or unsupported dependency-section bytes. */
|
||||
export class RustAuditableFormatFailure extends Error {
|
||||
constructor(
|
||||
readonly kind: "malformed" | "unsupported",
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "RustAuditableFormatFailure";
|
||||
}
|
||||
}
|
||||
|
||||
/** A resource admission limit with explicit semantic units. */
|
||||
export type RustAuditableConstraint =
|
||||
| {
|
||||
readonly resource: "memory";
|
||||
readonly reported_limits: { readonly maximum_bytes: number };
|
||||
}
|
||||
| {
|
||||
readonly resource: "transport";
|
||||
readonly reported_limits: { readonly maximum_output_bytes: number };
|
||||
}
|
||||
| {
|
||||
readonly resource: "capacity";
|
||||
readonly reported_limits: { readonly maximum_rustc_hashes: number };
|
||||
};
|
||||
|
||||
/** Admission refused before retained output or graph expansion. */
|
||||
export class RustAuditableResourceFailure extends Error {
|
||||
constructor(
|
||||
readonly constraint: RustAuditableConstraint,
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "RustAuditableResourceFailure";
|
||||
}
|
||||
}
|
||||
|
||||
/** Parsed section payload. Cycles are listed and are not walked. */
|
||||
export const readAuditableSection = (
|
||||
sectionName: RustAuditableMetadata["section_name"],
|
||||
fileOffset: number,
|
||||
bytes: Buffer,
|
||||
): RustAuditableMetadata => {
|
||||
if (bytes.length === 0)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency section is empty",
|
||||
);
|
||||
if (bytes.length > RUST_SECTION_BYTES)
|
||||
throw new RustAuditableResourceFailure(
|
||||
{
|
||||
resource: "memory",
|
||||
reported_limits: { maximum_bytes: RUST_SECTION_BYTES },
|
||||
},
|
||||
"Dependency section exceeds the 1 MiB compressed limit",
|
||||
);
|
||||
const inflated = inflateSection(bytes);
|
||||
const document = parseDocument(inflated);
|
||||
const packages = normalizePackages(document.packages);
|
||||
const root = rootIndex(packages);
|
||||
return {
|
||||
section_name: sectionName,
|
||||
file_offset: fileOffset,
|
||||
section_bytes: bytes.length,
|
||||
compressed_bytes: bytes.length,
|
||||
uncompressed_bytes: inflated.length,
|
||||
format_version: document.format ?? null,
|
||||
packages,
|
||||
root_package_index: root,
|
||||
dependency_edges: packages.flatMap((pkg, fromIndex) =>
|
||||
pkg.dependencies.map((toIndex) => ({
|
||||
from_index: fromIndex,
|
||||
to_index: toIndex,
|
||||
})),
|
||||
),
|
||||
cyclic_dependency_indices: cyclicDependencyIndices(packages),
|
||||
};
|
||||
};
|
||||
|
||||
const inflateSection = (bytes: Buffer): Buffer => {
|
||||
const maximum = Math.min(
|
||||
RUST_UNCOMPRESSED_BYTES,
|
||||
bytes.length * RUST_INFLATE_RATIO,
|
||||
);
|
||||
try {
|
||||
const inflated = inflateSync(bytes, {
|
||||
maxOutputLength: maximum,
|
||||
...(rejectsTrailingJunk() ? { rejectGarbageAfterEnd: true } : {}),
|
||||
});
|
||||
if (
|
||||
!rejectsTrailingJunk() &&
|
||||
shortestPrefix(bytes, inflated, maximum) !== bytes.length
|
||||
)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency section has bytes after the zlib stream",
|
||||
);
|
||||
return inflated;
|
||||
} catch (cause) {
|
||||
if (cause instanceof RustAuditableFormatFailure) throw cause;
|
||||
throw inflateFailure(cause, maximum);
|
||||
}
|
||||
};
|
||||
|
||||
let trailingJunkRejected: boolean | undefined;
|
||||
|
||||
const rejectsTrailingJunk = (): boolean => {
|
||||
if (trailingJunkRejected !== undefined) return trailingJunkRejected;
|
||||
const trailed = Buffer.concat([
|
||||
deflateSync(Buffer.from("a")),
|
||||
Buffer.from([0]),
|
||||
]);
|
||||
try {
|
||||
inflateSync(trailed, { rejectGarbageAfterEnd: true });
|
||||
trailingJunkRejected = false;
|
||||
} catch (cause) {
|
||||
trailingJunkRejected =
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
cause.code === "ERR_TRAILING_JUNK_AFTER_STREAM_END";
|
||||
}
|
||||
return trailingJunkRejected;
|
||||
};
|
||||
|
||||
const shortestPrefix = (
|
||||
input: Buffer,
|
||||
output: Buffer,
|
||||
maximum: number,
|
||||
): number => {
|
||||
let low = 0;
|
||||
let high = input.length;
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (prefixMatches(input.subarray(0, mid), output, maximum)) high = mid;
|
||||
else low = mid + 1;
|
||||
}
|
||||
return high;
|
||||
};
|
||||
|
||||
const prefixMatches = (
|
||||
prefix: Buffer,
|
||||
output: Buffer,
|
||||
maximum: number,
|
||||
): boolean => {
|
||||
try {
|
||||
return inflateSync(prefix, { maxOutputLength: maximum }).equals(output);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const inflateFailure = (cause: unknown, maximum: number): Error => {
|
||||
const code =
|
||||
cause instanceof Error && "code" in cause ? String(cause.code) : "";
|
||||
if (code === "ERR_BUFFER_TOO_LARGE")
|
||||
return new RustAuditableResourceFailure(
|
||||
{ resource: "memory", reported_limits: { maximum_bytes: maximum } },
|
||||
"Inflated dependency JSON exceeds the size limit",
|
||||
);
|
||||
if (
|
||||
code === "ERR_TRAILING_JUNK_AFTER_STREAM_END" ||
|
||||
code.startsWith("Z_") ||
|
||||
code === "ERR_INVALID_ARG_TYPE"
|
||||
)
|
||||
return new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency section is not a complete zlib stream",
|
||||
);
|
||||
return cause instanceof Error ? cause : new Error(String(cause));
|
||||
};
|
||||
|
||||
const parseDocument = (bytes: Buffer): z.infer<typeof documentSchema> => {
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch (cause) {
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency JSON is not valid UTF-8",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
assertDepth(text);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch (cause) {
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency section JSON is invalid",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
const version = documentVersionSchema.safeParse(value);
|
||||
if (!version.success)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency JSON does not match the format schema",
|
||||
{ cause: version.error },
|
||||
);
|
||||
if (
|
||||
version.data.format !== undefined &&
|
||||
version.data.format !== RUST_AUDITABLE_FORMAT_VERSION
|
||||
)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"unsupported",
|
||||
`Unsupported cargo-auditable format version ${version.data.format}`,
|
||||
);
|
||||
admitGraphExpansion(value);
|
||||
// One malformed package must fail before Zod retains an issue for every field.
|
||||
rejectMalformedPackages(value);
|
||||
const parsed = documentSchema.safeParse(value);
|
||||
if (!parsed.success)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency JSON does not match the package schema",
|
||||
{ cause: parsed.error },
|
||||
);
|
||||
return parsed.data;
|
||||
};
|
||||
|
||||
// These are lower bounds on the required normalized representation, not
|
||||
// guessed per-object heap sizes. Count source arrays before Zod clones them,
|
||||
// normalization builds edge objects, or SCC analysis creates reverse edges.
|
||||
const MIN_PACKAGE_OUTPUT_BYTES = JSON.stringify({
|
||||
name: "a",
|
||||
version: "a",
|
||||
source: "a",
|
||||
kind: null,
|
||||
dependencies: [],
|
||||
root: true,
|
||||
}).length;
|
||||
const MIN_EDGE_OUTPUT_BYTES = JSON.stringify({
|
||||
from_index: 0,
|
||||
to_index: 0,
|
||||
}).length;
|
||||
const admitGraphExpansion = (value: unknown): void => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("packages" in value) ||
|
||||
!Array.isArray(value.packages)
|
||||
)
|
||||
return;
|
||||
const packages: readonly unknown[] = value.packages;
|
||||
let minimumBytes =
|
||||
packages.length * MIN_PACKAGE_OUTPUT_BYTES +
|
||||
Math.max(0, packages.length - 1);
|
||||
let edges = 0;
|
||||
const admit = () => {
|
||||
const edgeBytes = edges * MIN_EDGE_OUTPUT_BYTES + Math.max(0, edges - 1);
|
||||
if (minimumBytes + edgeBytes > RUST_OUTPUT_BYTES)
|
||||
throw new RustAuditableResourceFailure(
|
||||
{
|
||||
resource: "transport",
|
||||
reported_limits: { maximum_output_bytes: RUST_OUTPUT_BYTES },
|
||||
},
|
||||
"Dependency graph exceeds the complete report byte budget before expansion",
|
||||
);
|
||||
};
|
||||
admit();
|
||||
for (const pkg of packages) {
|
||||
// Shape and index validity remain owned by documentSchema/rootIndex.
|
||||
if (
|
||||
typeof pkg !== "object" ||
|
||||
pkg === null ||
|
||||
!("dependencies" in pkg) ||
|
||||
!Array.isArray(pkg.dependencies)
|
||||
)
|
||||
continue;
|
||||
const count = pkg.dependencies.length;
|
||||
edges += count;
|
||||
minimumBytes += Math.max(0, count * 2 - 1);
|
||||
admit();
|
||||
}
|
||||
};
|
||||
|
||||
const PACKAGE_FIELDS = new Set([
|
||||
"name",
|
||||
"version",
|
||||
"source",
|
||||
"kind",
|
||||
"dependencies",
|
||||
"root",
|
||||
]);
|
||||
|
||||
const rejectMalformedPackages = (value: unknown): void => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
!("packages" in value) ||
|
||||
!Array.isArray(value.packages)
|
||||
)
|
||||
return;
|
||||
for (const [index, pkg] of value.packages.entries()) {
|
||||
if (!packageRecord(pkg))
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
`Dependency package ${String(index)} does not match the package schema`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const packageRecord = (
|
||||
pkg: unknown,
|
||||
): pkg is {
|
||||
readonly dependencies?: readonly unknown[];
|
||||
} => {
|
||||
if (typeof pkg !== "object" || pkg === null || Array.isArray(pkg))
|
||||
return false;
|
||||
const record = pkg as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!PACKAGE_FIELDS.has(key)) return false;
|
||||
}
|
||||
if (
|
||||
!nonEmptyString(record.name) ||
|
||||
!nonEmptyString(record.version) ||
|
||||
!nonEmptyString(record.source)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
record.kind !== undefined &&
|
||||
record.kind !== "runtime" &&
|
||||
record.kind !== "build"
|
||||
)
|
||||
return false;
|
||||
if (record.root !== undefined && typeof record.root !== "boolean")
|
||||
return false;
|
||||
const dependencies = record.dependencies;
|
||||
if (dependencies === undefined) return true;
|
||||
return (
|
||||
Array.isArray(dependencies) &&
|
||||
dependencies.every(
|
||||
(entry) =>
|
||||
typeof entry === "number" && Number.isInteger(entry) && entry >= 0,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const nonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === "string" && value.length > 0;
|
||||
|
||||
const assertDepth = (text: string): void => {
|
||||
let depth = 0;
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
for (const char of text) {
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === '"') quoted = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
quoted = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "{" || char === "[") {
|
||||
depth += 1;
|
||||
if (depth > JSON_DEPTH)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency JSON nesting exceeds 32 levels",
|
||||
);
|
||||
} else if ((char === "}" || char === "]") && depth > 0) depth -= 1;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePackages = (
|
||||
packages: z.infer<typeof documentSchema>["packages"],
|
||||
): RustPackage[] =>
|
||||
packages.map((pkg) => ({
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
source: pkg.source,
|
||||
kind: pkg.kind ?? null,
|
||||
dependencies: pkg.dependencies ?? [],
|
||||
root: pkg.root ?? false,
|
||||
}));
|
||||
|
||||
const rootIndex = (packages: readonly RustPackage[]): number | null => {
|
||||
const roots = packages.flatMap((pkg, index) => (pkg.root ? [index] : []));
|
||||
if (roots.length > 1)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency JSON declares more than one root package",
|
||||
);
|
||||
for (const [index, pkg] of packages.entries()) {
|
||||
const outside = pkg.dependencies.find(
|
||||
(target) => target >= packages.length,
|
||||
);
|
||||
if (outside !== undefined)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
`Dependency index ${String(outside)} from package ${String(index)} is outside the package list`,
|
||||
);
|
||||
}
|
||||
return roots[0] ?? null;
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
GoBinaryFormatFailure,
|
||||
GoBinaryResourceFailure,
|
||||
type GoImageSection,
|
||||
} from "../go/GoBinaryContainer.js";
|
||||
import { readGoBinaryContainer } from "../go/GoBinaryImage.js";
|
||||
import type {
|
||||
RustAuditableMetadata,
|
||||
RustBinary,
|
||||
} from "../domain/rust/rustBinary.js";
|
||||
import {
|
||||
readAuditableSection,
|
||||
RustAuditableFormatFailure,
|
||||
RustAuditableResourceFailure,
|
||||
} from "./RustAuditableReader.js";
|
||||
|
||||
const RUSTC_PREFIX = Buffer.from("/rustc/");
|
||||
const HASH_BYTES = 40;
|
||||
/** One toolchain normally leaves one hash; a small cap bounds retained output. */
|
||||
const MAX_RUSTC_HASHES = 64;
|
||||
|
||||
/** Container identity plus optional dependency metadata and inferred rustc commits. */
|
||||
export interface RustBinaryImage {
|
||||
readonly format: RustBinary["format"];
|
||||
readonly architecture: string;
|
||||
readonly bits: 32 | 64;
|
||||
readonly byte_order: RustBinary["byte_order"];
|
||||
readonly auditable_metadata: RustAuditableMetadata | null;
|
||||
readonly rustc_commit: RustBinary["rustc_commit"];
|
||||
}
|
||||
|
||||
/** Read dependency metadata from container sections without executing the image. */
|
||||
export const readRustBinaryImage = (bytes: Buffer): RustBinaryImage => {
|
||||
const container = readContainer(bytes);
|
||||
const dependency = dependencySection(container.format, container.sections);
|
||||
return {
|
||||
format: container.format,
|
||||
architecture: container.architecture,
|
||||
bits: container.bits,
|
||||
byte_order: container.byte_order,
|
||||
auditable_metadata:
|
||||
dependency === null
|
||||
? null
|
||||
: readDependency(bytes, container.format, dependency),
|
||||
rustc_commit: rustcCommit(bytes, container.sections),
|
||||
};
|
||||
};
|
||||
|
||||
const readContainer = (bytes: Buffer) => {
|
||||
try {
|
||||
return readGoBinaryContainer(bytes);
|
||||
} catch (cause) {
|
||||
if (cause instanceof GoBinaryFormatFailure)
|
||||
throw new RustAuditableFormatFailure(cause.kind, cause.message, {
|
||||
cause,
|
||||
});
|
||||
if (cause instanceof GoBinaryResourceFailure)
|
||||
throw new RustAuditableResourceFailure(
|
||||
{
|
||||
resource: "memory",
|
||||
reported_limits: { maximum_bytes: cause.maximum_bytes },
|
||||
},
|
||||
cause.message,
|
||||
{
|
||||
cause,
|
||||
},
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const dependencySection = (
|
||||
format: RustBinaryImage["format"],
|
||||
sections: readonly GoImageSection[],
|
||||
): GoImageSection | null => {
|
||||
const expected = format === "macho" ? "__DATA,.dep-v0" : ".dep-v0";
|
||||
const matches = sections.filter((section) => section.name === expected);
|
||||
if (matches.length > 1)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Image contains more than one dependency section",
|
||||
);
|
||||
const section = matches[0];
|
||||
if (section === undefined) return null;
|
||||
if (section.compressed)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"unsupported",
|
||||
"Compressed dependency sections are unsupported",
|
||||
);
|
||||
if (!section.file_backed)
|
||||
throw new RustAuditableFormatFailure(
|
||||
"malformed",
|
||||
"Dependency section is not file-backed",
|
||||
);
|
||||
return section;
|
||||
};
|
||||
|
||||
const readDependency = (
|
||||
bytes: Buffer,
|
||||
format: RustBinaryImage["format"],
|
||||
section: GoImageSection,
|
||||
): RustAuditableMetadata =>
|
||||
readAuditableSection(
|
||||
format === "macho" ? "__DATA,.dep-v0" : ".dep-v0",
|
||||
section.offset,
|
||||
bytes.subarray(section.offset, section.offset + section.size),
|
||||
);
|
||||
|
||||
const rustcCommit = (
|
||||
bytes: Buffer,
|
||||
sections: readonly GoImageSection[],
|
||||
): RustBinaryImage["rustc_commit"] => {
|
||||
const hashes: { hash: string; file_offset: number }[] = [];
|
||||
const seen = new Set<string>();
|
||||
// Overlapping declared sections are scanned once, so total work stays
|
||||
// bounded by the file size however many sections reference a range.
|
||||
for (const range of mergedReadOnlyRanges(sections))
|
||||
collectHashes(bytes, range, seen, hashes);
|
||||
return hashes.length === 0 ? null : { evidence_kind: "inferred", hashes };
|
||||
};
|
||||
|
||||
const mergedReadOnlyRanges = (
|
||||
sections: readonly GoImageSection[],
|
||||
): { readonly offset: number; readonly end: number }[] => {
|
||||
const ranges = sections
|
||||
.filter((section) => section.read_only && section.size > 0)
|
||||
.map((section) => ({
|
||||
offset: section.offset,
|
||||
end: section.offset + section.size,
|
||||
}))
|
||||
.sort((left, right) => left.offset - right.offset);
|
||||
const merged: { offset: number; end: number }[] = [];
|
||||
for (const range of ranges) {
|
||||
const last = merged.at(-1);
|
||||
if (last !== undefined && range.offset <= last.end)
|
||||
last.end = Math.max(last.end, range.end);
|
||||
else merged.push({ ...range });
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
const collectHashes = (
|
||||
bytes: Buffer,
|
||||
range: { readonly offset: number; readonly end: number },
|
||||
seen: Set<string>,
|
||||
hashes: { hash: string; file_offset: number }[],
|
||||
): void => {
|
||||
const region = bytes.subarray(range.offset, range.end);
|
||||
let cursor = 0;
|
||||
while (cursor < region.length) {
|
||||
const at = region.indexOf(RUSTC_PREFIX, cursor);
|
||||
if (at < 0) return;
|
||||
const start = at + RUSTC_PREFIX.length;
|
||||
const hash = hashAt(region, start);
|
||||
if (hash !== null && !seen.has(hash)) {
|
||||
if (seen.size >= MAX_RUSTC_HASHES)
|
||||
throw new RustAuditableResourceFailure(
|
||||
{
|
||||
resource: "capacity",
|
||||
reported_limits: { maximum_rustc_hashes: MAX_RUSTC_HASHES },
|
||||
},
|
||||
`Read-only data contains more than ${MAX_RUSTC_HASHES} distinct rustc commit paths.`,
|
||||
);
|
||||
seen.add(hash);
|
||||
hashes.push({ hash, file_offset: range.offset + at });
|
||||
}
|
||||
cursor = at + 1;
|
||||
}
|
||||
};
|
||||
|
||||
const hashAt = (region: Buffer, start: number): string | null => {
|
||||
const end = start + HASH_BYTES;
|
||||
if (end >= region.length || region[end] !== 0x2f) return null;
|
||||
for (let index = start; index < end; index += 1) {
|
||||
const byte = region[index] ?? 0;
|
||||
const hex =
|
||||
(byte >= 0x30 && byte <= 0x39) || (byte >= 0x61 && byte <= 0x66);
|
||||
if (!hex) return null;
|
||||
}
|
||||
return region.toString("latin1", start, end);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
||||
import type { RustBinaryPort } from "../application/rust/RustBinaryPort.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
rustBinarySchema,
|
||||
type InspectRustBinaryInput,
|
||||
type RustBinary,
|
||||
} from "../domain/rust/rustBinary.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { readRustBinaryImage } from "./RustBinaryImage.js";
|
||||
import {
|
||||
RUST_BINARY_INPUT_BYTES,
|
||||
RUST_OUTPUT_BYTES,
|
||||
RustAuditableFormatFailure,
|
||||
RustAuditableResourceFailure,
|
||||
} from "./RustAuditableReader.js";
|
||||
|
||||
/** Bundled reader identity. Embedded package versions are separate observed facts. */
|
||||
export const RUST_BINARY_PROVIDER_IDENTITY = {
|
||||
id: "rust-auditable",
|
||||
name: "REA Rust dependency reader",
|
||||
version: "1",
|
||||
} as const;
|
||||
const OPERATION = "inspect_rust_binary";
|
||||
|
||||
/** Stable in-process dependency inspection without subprocesses, temporary files, or a Rust toolchain. */
|
||||
export class RustBinaryProvider implements RustBinaryPort {
|
||||
readonly identity = RUST_BINARY_PROVIDER_IDENTITY;
|
||||
|
||||
/** Read one dependency section and any inferred rustc commit from the selected file. */
|
||||
async inspect(
|
||||
input: InspectRustBinaryInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<RustBinary, AnalysisError>> {
|
||||
try {
|
||||
const snapshot = await readStableArtifact(
|
||||
input.path,
|
||||
RUST_BINARY_INPUT_BYTES,
|
||||
options?.signal,
|
||||
);
|
||||
options?.signal?.throwIfAborted();
|
||||
const image = readRustBinaryImage(snapshot.bytes);
|
||||
const report = {
|
||||
...image,
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: snapshot.sha256,
|
||||
bytes: snapshot.bytes.length,
|
||||
},
|
||||
limitations: limitationsFor(image),
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(report), "utf8") > RUST_OUTPUT_BYTES)
|
||||
throw outputLimit(input.path);
|
||||
const parsed = rustBinarySchema.safeParse(report);
|
||||
if (!parsed.success)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Rust reader produced an invalid dependency report.",
|
||||
{ cause: parsed.error },
|
||||
);
|
||||
options?.signal?.throwIfAborted();
|
||||
return ok(parsed.data);
|
||||
} catch (cause: unknown) {
|
||||
return err(failureFor(cause, input.path, options?.signal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outputLimit = (path: string) =>
|
||||
new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"transport",
|
||||
`Complete Rust metadata for ${path} exceeds the inline output budget; no partial success is returned.`,
|
||||
{ maximum_output_bytes: RUST_OUTPUT_BYTES },
|
||||
);
|
||||
|
||||
const limitationsFor = (
|
||||
image: ReturnType<typeof readRustBinaryImage>,
|
||||
): string[] => [
|
||||
"Embedded dependency metadata is file content. It does not authenticate package names, versions, sources, or the compiler that produced the binary.",
|
||||
"Only the .dep-v0 dependency section and read-only compiler path strings are inspected. Functions, types, and runtime behavior are not recovered.",
|
||||
"Resource guards bound the selected file to 256 MiB, a dependency section to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the section size, and the complete report to 8 MiB. Package and dependency counts are admitted against that output budget before schema cloning, graph objects, or reverse-edge allocation.",
|
||||
...(image.auditable_metadata === null
|
||||
? [
|
||||
"No .dep-v0 section was found. Absence of .dep-v0 does not mean the binary has no dependencies.",
|
||||
]
|
||||
: []),
|
||||
...(image.auditable_metadata?.packages.some((pkg) => pkg.kind === null)
|
||||
? [
|
||||
"An omitted package kind is left null. The embedded format treats an omitted kind as runtime, and that default was not stored in the section.",
|
||||
]
|
||||
: []),
|
||||
...(image.auditable_metadata !== null &&
|
||||
image.auditable_metadata.cyclic_dependency_indices.length > 0
|
||||
? [
|
||||
"Dependency indices contain a cycle. The cycle is reported and was not traversed.",
|
||||
]
|
||||
: []),
|
||||
...rustcLimitation(image.rustc_commit),
|
||||
];
|
||||
|
||||
const rustcLimitation = (
|
||||
commit: ReturnType<typeof readRustBinaryImage>["rustc_commit"],
|
||||
): string[] => {
|
||||
if (commit === null)
|
||||
return [
|
||||
"No /rustc/<40-hex>/ path was found in read-only data, so a rustc commit hash is unknown.",
|
||||
];
|
||||
if (commit.hashes.length === 1)
|
||||
return [
|
||||
"The rustc commit hash is inferred from a /rustc/<40-hex>/ path in read-only data. It does not by itself prove which compiler produced the binary.",
|
||||
];
|
||||
return [
|
||||
"Read-only data contains more than one /rustc/<40-hex>/ path, so a single rustc commit is unknown.",
|
||||
];
|
||||
};
|
||||
|
||||
const failureFor = (
|
||||
cause: unknown,
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (signal?.aborted) return new AnalysisCancelledError(OPERATION);
|
||||
if (cause instanceof RustAuditableResourceFailure)
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
cause.constraint.resource,
|
||||
`Selected artifact ${path}: ${cause.message}`,
|
||||
cause.constraint.reported_limits,
|
||||
{ cause },
|
||||
);
|
||||
if (cause instanceof RustAuditableFormatFailure)
|
||||
return cause.kind === "unsupported"
|
||||
? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, {
|
||||
cause,
|
||||
})
|
||||
: new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: `Selected artifact ${path}: ${cause.message}`,
|
||||
},
|
||||
]);
|
||||
return artifactFailure(cause, path);
|
||||
};
|
||||
|
||||
const artifactFailure = (cause: unknown, path: string): AnalysisError => {
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
return artifactReaderFailure(cause, path);
|
||||
if (cause instanceof Error && "code" in cause) {
|
||||
if (cause.code === "EACCES" || cause.code === "EPERM")
|
||||
return new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
|
||||
cause,
|
||||
});
|
||||
if (
|
||||
["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes(
|
||||
String(cause.code),
|
||||
)
|
||||
)
|
||||
return new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
return new ProviderAdapterError(RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
path,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const artifactReaderFailure = (
|
||||
cause: ArtifactReaderFailure,
|
||||
path: string,
|
||||
): AnalysisError => {
|
||||
if (cause.reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(OPERATION, path, cause.message, {
|
||||
cause,
|
||||
});
|
||||
if (cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(OPERATION);
|
||||
if (cause.reason === "limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"file-size",
|
||||
cause.message,
|
||||
{ maximum_input_bytes: RUST_BINARY_INPUT_BYTES },
|
||||
{ cause },
|
||||
);
|
||||
if (cause.reason === "path")
|
||||
return new AnalysisInputError(OPERATION, { cause }, [
|
||||
{ path: ["path"], reason: "invalid_format", message: cause.message },
|
||||
]);
|
||||
return new ProviderAdapterError(RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, {
|
||||
cause,
|
||||
diagnostics: { path, reason: cause.message },
|
||||
});
|
||||
};
|
||||
@@ -22,6 +22,9 @@ import { createRecordedCrashService } from "../composition/binaryDiagnostics.js"
|
||||
import { createGoBinaryService } from "../composition/go.js";
|
||||
import type { GoBinaryService } from "../application/go/GoBinaryService.js";
|
||||
import { registerGoTools } from "./registerGoTools.js";
|
||||
import { createRustBinaryService } from "../composition/rust.js";
|
||||
import type { RustBinaryService } from "../application/rust/RustBinaryService.js";
|
||||
import { registerRustTools } from "./registerRustTools.js";
|
||||
import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js";
|
||||
import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js";
|
||||
import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js";
|
||||
@@ -141,6 +144,7 @@ export interface CreateServerOptions {
|
||||
readonly logger?: Logger;
|
||||
readonly binaryLayout?: BinaryLayoutService;
|
||||
readonly goBinary?: GoBinaryService;
|
||||
readonly rustBinary?: RustBinaryService;
|
||||
readonly recordedCrash?: RecordedCrashService;
|
||||
readonly firmwareAnalysis?: FirmwareAnalysisPort;
|
||||
readonly javascriptRecovery?: JavaScriptRecoveryPort;
|
||||
@@ -582,6 +586,13 @@ const registerConfiguredAnalysisTools = (
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
registerRustTools(
|
||||
server,
|
||||
options.rustBinary ?? createRustBinaryService(),
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
registerWasmTools(
|
||||
server,
|
||||
wasmArtifact,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { RustBinaryService } from "../application/rust/RustBinaryService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { createEvidenceToolHandler } from "./contractToolHandler.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
|
||||
/** Bind the exact Rust contract to its shared workflow and owning session Evidence ledger. */
|
||||
export const registerRustTools = (
|
||||
server: EvidenceMcpServer,
|
||||
service: RustBinaryService,
|
||||
logger: Logger,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
): void => {
|
||||
const contract = toolContract("inspect_rust_binary");
|
||||
const handler = createEvidenceToolHandler(
|
||||
server,
|
||||
(input, options) => service.inspect(input, options),
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
handler(contract),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import { createRustImage } from "../fixtures/rust/image.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"reports cargo-auditable packages through the public CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-rust-cli-");
|
||||
const path = join(root, "sample");
|
||||
await writeFile(path, createRustImage({ format: "macho" }));
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-rust-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_rust_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
expect(evidence.normalized_result.auditable_metadata).toMatchObject({
|
||||
section_name: "__DATA,.dep-v0",
|
||||
format_version: 1,
|
||||
root_package_index: 1,
|
||||
packages: [
|
||||
{ name: "itoa", version: "1.0.18" },
|
||||
{ name: "sample", version: "0.1.0", root: true },
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest("returns null metadata when .dep-v0 is absent", async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-rust-cli-missing-");
|
||||
const path = join(root, "plain");
|
||||
await writeFile(path, createRustImage({ format: "elf", omitSection: true }));
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-rust-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_rust_binary").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
expect(evidence.normalized_result.auditable_metadata).toBeNull();
|
||||
expect(evidence.normalized_result.limitations.join("\n")).toContain(
|
||||
"does not mean the binary has no dependencies",
|
||||
);
|
||||
});
|
||||
|
||||
cliTest("reports malformed zlib as invalid input", async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-rust-cli-zlib-");
|
||||
const path = join(root, "broken.elf");
|
||||
await writeFile(
|
||||
path,
|
||||
createRustImage({
|
||||
format: "elf",
|
||||
payload: deflateSync(Buffer.from("{")),
|
||||
includeRustc: false,
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-rust-binary", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { operation: "inspect_rust_binary" },
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
machoImage,
|
||||
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js";
|
||||
import { readGoMachoImage } from "../../../src/go/GoMachoImage.js";
|
||||
import {
|
||||
createGoBinaryFixture,
|
||||
GO_MODULE_TEXT,
|
||||
@@ -519,6 +520,26 @@ it("rejects build-info declared inside an unmapped Mach-O segment", () => {
|
||||
expect(() => readGoBinaryImage(fixture.bytes)).toThrowError(/unmapped/i);
|
||||
});
|
||||
|
||||
it("does not mark Mach-O section bytes outside their owning segment as read-only", () => {
|
||||
const fixture = createGoBinaryFixture({ format: "macho" });
|
||||
const command = 32;
|
||||
const section = command + 72;
|
||||
fixture.bytes.write("__TEXT", command + 8, "ascii");
|
||||
fixture.bytes.writeBigUInt64LE(1024n, command + 48);
|
||||
fixture.bytes.writeUInt32LE(1, command + 56);
|
||||
fixture.bytes.writeUInt32LE(1, command + 60);
|
||||
fixture.bytes.write("__debug_info", section, "ascii");
|
||||
fixture.bytes.write("__TEXT", section + 16, "ascii");
|
||||
fixture.bytes.writeBigUInt64LE(0x10c00n, section + 32);
|
||||
fixture.bytes.writeBigUInt64LE(128n, section + 40);
|
||||
fixture.bytes.writeUInt32LE(3072, section + 48);
|
||||
const image = readGoMachoImage(fixture.bytes);
|
||||
expect(image.sections[0]).toMatchObject({
|
||||
file_backed: false,
|
||||
read_only: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["elf", "pe", "macho"] as const)(
|
||||
"refuses file aliases overlapping only part of a zero-filled %s module header",
|
||||
(format) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js";
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
sessionEvidence,
|
||||
} from "./ghidraMcpHarness.js";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { GhidraClient } from "../../../src/ghidra/GhidraClient.js";
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import {
|
||||
createRustImage,
|
||||
createRustHashOverflowImage,
|
||||
} from "../../fixtures/rust/image.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js";
|
||||
|
||||
const connect = async (mcp: TestMcp) => {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Rust metadata must not start a deep provider");
|
||||
});
|
||||
const client = await mcp.connect(createServer({ kind: "session", session }));
|
||||
return { client, session };
|
||||
};
|
||||
|
||||
mcpTest(
|
||||
"advertises inspect_rust_binary and reads an embedded dependency list",
|
||||
async ({ mcp }) => {
|
||||
const { client, session } = await connect(mcp);
|
||||
try {
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_rust_binary",
|
||||
);
|
||||
expect(advertised?.annotations).toMatchObject({
|
||||
readOnlyHint: false,
|
||||
idempotentHint: true,
|
||||
});
|
||||
const root = await createTestTempDirectory("rea-rust-mcp-");
|
||||
const path = join(root, "sample.elf");
|
||||
await writeFile(path, createRustImage({ format: "elf" }));
|
||||
const response = await client.callTool({
|
||||
name: "inspect_rust_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
|
||||
const evidence = toolContract("inspect_rust_binary").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
expect(evidence.normalized_result.auditable_metadata?.packages).toEqual([
|
||||
expect.objectContaining({ name: "itoa", version: "1.0.18" }),
|
||||
expect.objectContaining({
|
||||
name: "sample",
|
||||
version: "0.1.0",
|
||||
root: true,
|
||||
}),
|
||||
]);
|
||||
const missing = join(root, "plain.elf");
|
||||
await writeFile(
|
||||
missing,
|
||||
createRustImage({ format: "pe", omitSection: true }),
|
||||
);
|
||||
const absent = await client.callTool({
|
||||
name: "inspect_rust_binary",
|
||||
arguments: { path: missing },
|
||||
});
|
||||
expect(absent.isError).not.toBe(true);
|
||||
const absentEvidence = toolContract(
|
||||
"inspect_rust_binary",
|
||||
).outputSchema.parse(absent.structuredContent);
|
||||
expect(absentEvidence.normalized_result.auditable_metadata).toBeNull();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
mcpTest(
|
||||
"requires an absolute path for inspect_rust_binary",
|
||||
async ({ mcp }) => {
|
||||
const { client, session } = await connect(mcp);
|
||||
try {
|
||||
const response = await client.callTool({
|
||||
name: "inspect_rust_binary",
|
||||
arguments: { path: "sample" },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { operation: "inspect_rust_binary" },
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
mcpTest(
|
||||
"preserves rustc hash capacity units through the public error contract",
|
||||
async ({ mcp }) => {
|
||||
const { client, session } = await connect(mcp);
|
||||
try {
|
||||
const root = await createTestTempDirectory("rea-rust-mcp-");
|
||||
const path = join(root, "many-hashes.elf");
|
||||
await writeFile(path, createRustHashOverflowImage());
|
||||
const response = await client.callTool({
|
||||
name: "inspect_rust_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
expect(parseMcpToolError(response).error).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
operation: "inspect_rust_binary",
|
||||
resource: "capacity",
|
||||
reported_limits: { maximum_rustc_hashes: 64 },
|
||||
},
|
||||
});
|
||||
expect(parseMcpToolError(response).error.details).not.toHaveProperty(
|
||||
"reported_limits.maximum_bytes",
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,141 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { fat } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import {
|
||||
createRustImage,
|
||||
createRustHashOverflowImage,
|
||||
RUSTC_HASH,
|
||||
rustDependencyPayload,
|
||||
} from "../../fixtures/rust/image.js";
|
||||
import { readRustBinaryImage } from "../../../src/rust/RustBinaryImage.js";
|
||||
import { RustAuditableFormatFailure } from "../../../src/rust/RustAuditableReader.js";
|
||||
|
||||
const packages = [
|
||||
{
|
||||
name: "itoa",
|
||||
version: "1.0.18",
|
||||
source: "crates.io",
|
||||
kind: null,
|
||||
dependencies: [],
|
||||
root: false,
|
||||
},
|
||||
{
|
||||
name: "sample",
|
||||
version: "0.1.0",
|
||||
source: "local",
|
||||
kind: null,
|
||||
dependencies: [0],
|
||||
root: true,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(["elf", "pe"] as const)(
|
||||
"reads a .dep-v0 section and an inferred rustc hash from %s",
|
||||
(format) => {
|
||||
const image = readRustBinaryImage(createRustImage({ format }));
|
||||
expect(image.format).toBe(format);
|
||||
expect(image.auditable_metadata).toMatchObject({
|
||||
section_name: ".dep-v0",
|
||||
file_offset: 1024,
|
||||
section_bytes: rustDependencyPayload().length,
|
||||
format_version: 1,
|
||||
root_package_index: 1,
|
||||
packages,
|
||||
dependency_edges: [{ from_index: 1, to_index: 0 }],
|
||||
});
|
||||
expect(image.rustc_commit).toEqual({
|
||||
evidence_kind: "inferred",
|
||||
hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reads a Mach-O __DATA,.dep-v0 section", () => {
|
||||
const image = readRustBinaryImage(createRustImage({ format: "macho" }));
|
||||
expect(image).toMatchObject({
|
||||
format: "macho",
|
||||
architecture: "arm64",
|
||||
auditable_metadata: {
|
||||
section_name: "__DATA,.dep-v0",
|
||||
file_offset: 1024,
|
||||
root_package_index: 1,
|
||||
packages,
|
||||
},
|
||||
rustc_commit: {
|
||||
evidence_kind: "inferred",
|
||||
hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores PE file-alignment padding after the dependency content", () => {
|
||||
const bytes = createRustImage({ format: "pe", includeRustc: false });
|
||||
const payloadBytes = rustDependencyPayload().length;
|
||||
const entry = 264;
|
||||
expect(bytes.readUInt32LE(entry + 8)).toBe(payloadBytes);
|
||||
bytes.writeUInt32LE(512, entry + 16);
|
||||
const image = readRustBinaryImage(bytes);
|
||||
expect(image.auditable_metadata).toMatchObject({
|
||||
section_name: ".dep-v0",
|
||||
section_bytes: payloadBytes,
|
||||
packages,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null metadata when the dependency section is absent", () => {
|
||||
const image = readRustBinaryImage(
|
||||
createRustImage({ format: "elf", omitSection: true }),
|
||||
);
|
||||
expect(image.auditable_metadata).toBeNull();
|
||||
expect(image.rustc_commit?.hashes).toEqual([
|
||||
{ hash: RUSTC_HASH, file_offset: 2048 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a universal Mach-O container", () => {
|
||||
expect(() => readRustBinaryImage(fat([0x0100000c]))).toThrow(
|
||||
RustAuditableFormatFailure,
|
||||
);
|
||||
expect(() => readRustBinaryImage(fat([0x0100000c]))).toThrow(/thin slice/);
|
||||
});
|
||||
|
||||
it("does not read SHT_NOBITS dependency sections as stored bytes", () => {
|
||||
const bytes = createRustImage({ format: "elf" });
|
||||
// The fixture writes the .dep-v0 section header at byte 384.
|
||||
bytes.writeUInt32LE(8, 384 + 4);
|
||||
let metadata: unknown;
|
||||
try {
|
||||
metadata = readRustBinaryImage(bytes).auditable_metadata;
|
||||
} catch (cause: unknown) {
|
||||
expect(cause).toBeInstanceOf(RustAuditableFormatFailure);
|
||||
return;
|
||||
}
|
||||
expect(metadata).toBeNull();
|
||||
});
|
||||
|
||||
it("reads a rustc path from a read-only ELF section whose name exceeds 64 bytes", () => {
|
||||
const bytes = createRustImage({ format: "elf", omitSection: true });
|
||||
// omitSection places the rustc section header at 384 and shstrtab at 320.
|
||||
const nameOffset = bytes.readUInt32LE(384);
|
||||
const tableOffset = Number(bytes.readBigUInt64LE(320 + 24));
|
||||
const longName = Buffer.concat([Buffer.alloc(65, 0x61), Buffer.from([0])]);
|
||||
longName.copy(bytes, tableOffset + nameOffset);
|
||||
bytes.writeBigUInt64LE(BigInt(nameOffset + longName.length), 320 + 32);
|
||||
const image = readRustBinaryImage(bytes);
|
||||
expect(image.auditable_metadata).toBeNull();
|
||||
expect(image.rustc_commit).toEqual({
|
||||
evidence_kind: "inferred",
|
||||
hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses read-only data with more distinct rustc paths than it retains", () => {
|
||||
const bytes = createRustHashOverflowImage();
|
||||
expect(() => readRustBinaryImage(bytes)).toThrow(
|
||||
expect.objectContaining({
|
||||
constraint: {
|
||||
resource: "capacity",
|
||||
reported_limits: { maximum_rustc_hashes: 64 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
Vendored
+263
@@ -0,0 +1,263 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
|
||||
/** zlib payload matching the embedded dependency JSON shape. */
|
||||
export const rustDependencyPayload = (
|
||||
document: unknown = {
|
||||
packages: [
|
||||
{ name: "itoa", version: "1.0.18", source: "crates.io" },
|
||||
{
|
||||
name: "sample",
|
||||
version: "0.1.0",
|
||||
source: "local",
|
||||
dependencies: [0],
|
||||
root: true,
|
||||
},
|
||||
],
|
||||
format: 1,
|
||||
},
|
||||
): Buffer => deflateSync(Buffer.from(JSON.stringify(document)));
|
||||
|
||||
export const RUSTC_HASH = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const RUSTC_PATH = `/rustc/${RUSTC_HASH}/library/core`;
|
||||
|
||||
/** Synthetic ELF, PE, or thin Mach-O with an optional dependency section. */
|
||||
export const createRustImage = (options: {
|
||||
readonly format: "elf" | "pe" | "macho";
|
||||
readonly payload?: Buffer;
|
||||
readonly omitSection?: boolean;
|
||||
readonly includeRustc?: boolean;
|
||||
}): Buffer => {
|
||||
const payload = options.payload ?? rustDependencyPayload();
|
||||
const rustc = options.includeRustc === false ? null : Buffer.from(RUSTC_PATH);
|
||||
const bytes = Buffer.alloc(4096);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const little = (offset: number, value: number, width: 16 | 32 | 64) => {
|
||||
if (width === 16) view.setUint16(offset, value, true);
|
||||
else if (width === 32) view.setUint32(offset, value, true);
|
||||
else view.setBigUint64(offset, BigInt(value), true);
|
||||
};
|
||||
const image = {
|
||||
bytes,
|
||||
little,
|
||||
payload,
|
||||
omitSection: options.omitSection === true,
|
||||
rustc,
|
||||
};
|
||||
if (options.format === "elf") writeElf(image);
|
||||
else if (options.format === "pe") writePe(image);
|
||||
else writeMacho(image);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
interface RustImageWrite {
|
||||
readonly bytes: Buffer;
|
||||
readonly little: (offset: number, value: number, width: 16 | 32 | 64) => void;
|
||||
readonly payload: Buffer;
|
||||
readonly omitSection: boolean;
|
||||
readonly rustc: Buffer | null;
|
||||
}
|
||||
|
||||
const writeElf = ({
|
||||
bytes,
|
||||
little,
|
||||
payload,
|
||||
omitSection,
|
||||
rustc,
|
||||
}: RustImageWrite): void => {
|
||||
bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]);
|
||||
little(16, 2, 16);
|
||||
little(18, 62, 16);
|
||||
little(20, 1, 32);
|
||||
little(32, 128, 64);
|
||||
little(40, 256, 64);
|
||||
little(52, 64, 16);
|
||||
little(54, 56, 16);
|
||||
little(56, 1, 16);
|
||||
little(58, 64, 16);
|
||||
const names = omitSection
|
||||
? Buffer.from("\0.shstrtab\0.rodata\0")
|
||||
: Buffer.from("\0.shstrtab\0.dep-v0\0.rodata\0");
|
||||
little(60, rustc === null && omitSection ? 0 : omitSection ? 3 : 4, 16);
|
||||
little(62, rustc === null && omitSection ? 0 : 1, 16);
|
||||
little(128, 1, 32);
|
||||
little(132, 6, 32);
|
||||
little(144, 0x400000, 64);
|
||||
little(160, bytes.length, 64);
|
||||
little(168, bytes.length, 64);
|
||||
if (rustc === null && omitSection) return;
|
||||
names.copy(bytes, 768);
|
||||
const shstr = 320;
|
||||
little(shstr, 1, 32);
|
||||
little(shstr + 4, 3, 32);
|
||||
little(shstr + 24, 768, 64);
|
||||
little(shstr + 32, names.length, 64);
|
||||
let next = 384;
|
||||
if (!omitSection) {
|
||||
little(next, 11, 32);
|
||||
little(next + 4, 1, 32);
|
||||
little(next + 24, 1024, 64);
|
||||
little(next + 32, payload.length, 64);
|
||||
payload.copy(bytes, 1024);
|
||||
next += 64;
|
||||
}
|
||||
if (rustc !== null) {
|
||||
const name = omitSection ? 11 : 19;
|
||||
little(next, name, 32);
|
||||
little(next + 4, 1, 32);
|
||||
little(next + 8, 2, 64);
|
||||
little(next + 16, 0x400000 + 2048, 64);
|
||||
little(next + 24, 2048, 64);
|
||||
little(next + 32, rustc.length, 64);
|
||||
rustc.copy(bytes, 2048);
|
||||
}
|
||||
};
|
||||
|
||||
const writePe = ({
|
||||
bytes,
|
||||
little,
|
||||
payload,
|
||||
omitSection,
|
||||
rustc,
|
||||
}: RustImageWrite): void => {
|
||||
bytes.write("MZ", 0, "ascii");
|
||||
little(60, 128, 32);
|
||||
bytes.write("PE\0\0", 128, "ascii");
|
||||
little(132, 0x8664, 16);
|
||||
const count = Number(!omitSection) + Number(rustc !== null);
|
||||
little(134, count, 16);
|
||||
little(148, 112, 16);
|
||||
little(152, 0x20b, 16);
|
||||
little(176, 0x140000000, 64);
|
||||
const writeSection = (section: {
|
||||
readonly entry: number;
|
||||
readonly name: string;
|
||||
readonly offset: number;
|
||||
readonly body: Buffer;
|
||||
readonly address: number;
|
||||
}) => {
|
||||
bytes.write(section.name, section.entry, "ascii");
|
||||
little(section.entry + 8, section.body.length, 32);
|
||||
little(section.entry + 12, section.address, 32);
|
||||
little(section.entry + 16, section.body.length, 32);
|
||||
little(section.entry + 20, section.offset, 32);
|
||||
little(section.entry + 36, 0x40000040, 32);
|
||||
section.body.copy(bytes, section.offset);
|
||||
};
|
||||
let entry = 264;
|
||||
if (!omitSection) {
|
||||
writeSection({
|
||||
entry,
|
||||
name: ".dep-v0",
|
||||
offset: 1024,
|
||||
body: payload,
|
||||
address: 0x1000,
|
||||
});
|
||||
entry += 40;
|
||||
}
|
||||
if (rustc !== null)
|
||||
writeSection({
|
||||
entry,
|
||||
name: ".rdata",
|
||||
offset: 2048,
|
||||
body: rustc,
|
||||
address: 0x2000,
|
||||
});
|
||||
};
|
||||
|
||||
const writeMacho = ({
|
||||
bytes,
|
||||
little,
|
||||
payload,
|
||||
omitSection,
|
||||
rustc,
|
||||
}: RustImageWrite): void => {
|
||||
const commands = Number(rustc !== null) + Number(!omitSection);
|
||||
little(0, 0xfeedfacf, 32);
|
||||
little(4, 0x0100000c, 32);
|
||||
little(12, 2, 32);
|
||||
little(16, commands, 32);
|
||||
little(20, commands * 152, 32);
|
||||
let command = 32;
|
||||
if (rustc !== null) {
|
||||
writeMachoSegment({
|
||||
bytes,
|
||||
little,
|
||||
command,
|
||||
segment: "__TEXT",
|
||||
section: "__cstring",
|
||||
body: rustc,
|
||||
fileOffset: 2048,
|
||||
protection: 5,
|
||||
flags: 2,
|
||||
});
|
||||
command += 152;
|
||||
}
|
||||
if (!omitSection)
|
||||
writeMachoSegment({
|
||||
bytes,
|
||||
little,
|
||||
command,
|
||||
segment: "__DATA",
|
||||
section: ".dep-v0",
|
||||
body: payload,
|
||||
fileOffset: 1024,
|
||||
protection: 3,
|
||||
flags: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const writeMachoSegment = ({
|
||||
bytes,
|
||||
little,
|
||||
command,
|
||||
segment,
|
||||
section,
|
||||
body,
|
||||
fileOffset,
|
||||
protection,
|
||||
flags,
|
||||
}: {
|
||||
readonly bytes: Buffer;
|
||||
readonly little: RustImageWrite["little"];
|
||||
readonly command: number;
|
||||
readonly segment: string;
|
||||
readonly section: string;
|
||||
readonly body: Buffer;
|
||||
readonly fileOffset: number;
|
||||
readonly protection: number;
|
||||
readonly flags: number;
|
||||
}): void => {
|
||||
little(command, 0x19, 32);
|
||||
little(command + 4, 152, 32);
|
||||
bytes.write(segment, command + 8, "ascii");
|
||||
little(command + 24, 0x100000000, 64);
|
||||
little(command + 32, 0x2000, 64);
|
||||
little(command + 40, fileOffset, 64);
|
||||
little(command + 48, body.length, 64);
|
||||
little(command + 56, protection, 32);
|
||||
little(command + 60, protection, 32);
|
||||
little(command + 64, 1, 32);
|
||||
const header = command + 72;
|
||||
bytes.write(section, header, "ascii");
|
||||
bytes.write(segment, header + 16, "ascii");
|
||||
little(header + 32, 0x100000000, 64);
|
||||
little(header + 40, body.length, 64);
|
||||
little(header + 48, fileOffset, 32);
|
||||
little(header + 64, flags, 32);
|
||||
body.copy(bytes, fileOffset);
|
||||
};
|
||||
|
||||
/** Read-only ELF data exceeding the distinct rustc-hash retention capacity. */
|
||||
export const createRustHashOverflowImage = (): Buffer => {
|
||||
const bytes = createRustImage({ format: "elf", omitSection: true });
|
||||
// Without .dep-v0 the read-only section header is the second one, at 384.
|
||||
// Section names end before byte 800, so the hash block starts there.
|
||||
bytes.writeBigUInt64LE(800n, 384 + 24);
|
||||
bytes.writeBigUInt64LE(3296n, 384 + 32);
|
||||
bytes.fill(0, 800);
|
||||
for (let index = 0; index < 65; index += 1) {
|
||||
const hash = index.toString(16).padStart(40, "0");
|
||||
bytes.write(`/rustc/${hash}/`, 800 + index * 48, "latin1");
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
@@ -41,6 +41,9 @@ it
|
||||
}
|
||||
// A rejection must come from the fixture we are about to launch. An
|
||||
// existing Xvfb would otherwise mask a broken executable-identity check.
|
||||
// The copied launcher has a unique file identity and has not run yet.
|
||||
// The synthetic proc root isolates the positive identity check from
|
||||
// unrelated restricted host processes while retaining display-name checks.
|
||||
await assertCleanLinuxHopperRuntime(
|
||||
alternateMode ? [launcher] : [],
|
||||
procRoot,
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ const TEST_PROJECTS = [
|
||||
name: "adapters",
|
||||
include: [
|
||||
"src/*.test.ts",
|
||||
"src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts",
|
||||
"src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,rust,server,wasm,windows}/**/*.test.ts",
|
||||
],
|
||||
pool: "forks" as const,
|
||||
maxWorkers: MAX_TEST_WORKERS,
|
||||
|
||||
Reference in new issue
Block a user