diff --git a/README.md b/README.md index fe061ddcf9..83b8f03bc7 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ Additional tools and host support depend on the target: | Android devices | Devices, packages, processes, logs, dumps, screen captures, and APK/file transfers | Caller-supplied adb (emulator or hardware); [ADB guide](docs/adb-device-analysis.md) | | JEB projects | Engine-backed project units, artifact digests and type/method pseudo-code | A caller-started JEB client serving MCP, selected with REA_JEB_MCP_URL; [JEB guide](docs/jeb-analysis.md) | | Android resources | Decoded manifest, string tables, locales and version facts | Caller-supplied Apktool launcher; [resource guide](docs/apktool-resource-analysis.md) | +| Flutter apps | Dart snapshot hash, per-ABI library digests and engine build provenance | None (static APK parsing); [Flutter guide](docs/flutter-analysis.md) | | Firmware | Regions, extraction results and native-analysis handoffs | Binwalk / Unblob on Linux; [firmware guide](docs/firmware-analysis.md) | | Packages and resources | File inventories, digests, plists, Apple bundle anatomy and extracted resources | [Artifact and JavaScript guide](docs/javascript-artifact-reconstruction.md), [Apple applications](docs/apple-application-analysis.md) | | Process behavior | Terminal output, interactions, exit and filesystem observations, and run comparisons | Linux/macOS with a native PTY; [process capture](docs/process-capture.md) | diff --git a/README_ar.md b/README_ar.md index d45af6edcc..6b050084f1 100644 --- a/README_ar.md +++ b/README_ar.md @@ -144,6 +144,7 @@ npx rea-agents@latest setup | أجهزة Android | الأجهزة والحزم والعمليات والسجلات والدامبات ولقطات الشاشة ونقل APK/الملفات | adb يوفّره المُستدعي (محاكي أو جهاز)؛ [دليل ADB](docs/adb-device-analysis.md) | | مشاريع JEB | وحدات المشروع من المحرك وبصمات المصنّفات والكود الزائف للأنواع/الدوال | عميل JEB يبدأه المُستدعي ويقدّم MCP، ويُختار عبر REA_JEB_MCP_URL؛ [دليل JEB](docs/jeb-analysis.md) | | موارد Android | البيان المفكوك الترميز وجداول السلاسل واللغات وحقائق الإصدار | مُشغّل Apktool يوفّره المُستدعي؛ [دليل الموارد](docs/apktool-resource-analysis.md) | +| تطبيقات Flutter | بصمة لقطة Dart وهشوش المكتبات لكل ABI وأصل بناء المحرك | لا شيء (تحليل APK ساكن)؛ [دليل Flutter](docs/flutter-analysis.md) | | البرامج الثابتة | المناطق، ونتائج الاستخراج، وما يُمرّر إلى تحليل الشيفرة الأصلية | Binwalk / Unblob على Linux؛ [دليل البرامج الثابتة](docs/firmware-analysis.md) | | الحزم والموارد | قوائم الملفات، والبصمات، وملفات plist، وبنية حزم Apple، والموارد المستخرجة | [دليل تحليل الملفات وJavaScript](docs/javascript-artifact-reconstruction.md)، [تطبيقات Apple](docs/apple-application-analysis.md) | | سلوك العمليات | مخرجات الطرفية، والتفاعلات، وملاحظات الخروج ونظام الملفات، ومقارنات التشغيل | Linux/macOS مع PTY أصلي؛ [تسجيل سلوك العمليات](docs/process-capture.md) | diff --git a/README_de.md b/README_de.md index 530527b899..d10f5f4b7b 100644 --- a/README_de.md +++ b/README_de.md @@ -144,6 +144,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz | Android-Geräte | Geräte, Pakete, Prozesse, Logs, Dumps, Bildschirmaufnahmen sowie APK- und Dateiübertragungen | Vom Aufrufer bereitgestelltes adb (Emulator oder Gerät); [ADB-Leitfaden](docs/adb-device-analysis.md) | | JEB-Projekte | Engine-gestützte Projekteinheiten, Artefakt-Prüfsummen und Pseudokode von Typen/Methoden | Ein vom Aufrufer gestarteter JEB-Client mit MCP, ausgewählt über REA_JEB_MCP_URL; [JEB-Leitfaden](docs/jeb-analysis.md) | | Android-Ressourcen | Dekodiertes Manifest, String-Tabellen, Sprachen und Versionsfakten | Vom Aufrufer bereitgestellter Apktool-Starter; [Ressourcen-Leitfaden](docs/apktool-resource-analysis.md) | +| Flutter-Apps | Dart-Snapshot-Hash, Per-ABI-Bibliotheksprüfsummen und Engine-Build-Provenienz | Keine (statische APK-Analyse); [Flutter-Leitfaden](docs/flutter-analysis.md) | | Firmware | Bereiche, Extraktionsergebnisse und Übergaben an die native Analyse | Binwalk / Unblob unter Linux; [Firmware-Leitfaden](docs/firmware-analysis.md) | | Pakete und Ressourcen | Dateiinventare, Hashwerte, plists, Aufbau von Apple-Bundles und extrahierte Ressourcen | [Artefakt- und JavaScript-Leitfaden](docs/javascript-artifact-reconstruction.md), [Apple-Anwendungen](docs/apple-application-analysis.md) | | Prozessverhalten | Terminalausgabe, Interaktionen, Beobachtungen zu Prozessende und Dateisystem sowie Laufvergleiche | Linux/macOS mit nativer PTY; [Prozessaufzeichnung](docs/process-capture.md) | diff --git a/README_es.md b/README_es.md index f23bd2df74..883cef8638 100644 --- a/README_es.md +++ b/README_es.md @@ -144,6 +144,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h | Dispositivos Android | Dispositivos, paquetes, procesos, registros, volcados, capturas de pantalla y transferencias de APK/archivos | adb suministrado por el autor de la llamada (emulador o dispositivo); [guía de ADB](docs/adb-device-analysis.md) | | Proyectos JEB | Unidades de proyecto del motor, resúmenes de artefactos y pseudocódigo de tipos/métodos | Un cliente JEB iniciado por el autor de la llamada que sirve MCP, seleccionado con REA_JEB_MCP_URL; [guía de JEB](docs/jeb-analysis.md) | | Recursos de Android | Manifiesto decodificado, tablas de cadenas, idiomas y datos de versión | Lanzador de Apktool suministrado por el autor de la llamada; [guía de recursos](docs/apktool-resource-analysis.md) | +| Aplicaciones Flutter | Hash de snapshot de Dart, resúmenes de bibliotecas por ABI y procedencia de compilación del motor | Ninguna (análisis APK estático); [guía de Flutter](docs/flutter-analysis.md) | | Firmware | Regiones, resultados de extracción y derivaciones al análisis nativo | Binwalk / Unblob en Linux; [guía de firmware](docs/firmware-analysis.md) | | Paquetes y recursos | Inventarios de archivos, resúmenes criptográficos, plists, estructura de paquetes de Apple y recursos extraídos | [Guía de artefactos y JavaScript](docs/javascript-artifact-reconstruction.md), [aplicaciones de Apple](docs/apple-application-analysis.md) | | Comportamiento de procesos | Salida de la terminal, interacciones, observaciones de salida y del sistema de archivos, y comparaciones entre ejecuciones | Linux/macOS con una PTY nativa; [captura de procesos](docs/process-capture.md) | diff --git a/README_fa.md b/README_fa.md index 4a9885eef7..21ab30faeb 100644 --- a/README_fa.md +++ b/README_fa.md @@ -136,6 +136,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np | دستگاه‌های اندروید | دستگاه‌ها، بسته‌ها، فرایندها، لاگ‌ها، دامپ‌ها، اسکرین‌شات‌ها و انتقال APK/فایل | adb تأمین‌شده توسط فراخوان‌کننده (شبیه‌ساز یا دستگاه)؛ [راهنمای ADB](docs/adb-device-analysis.md) | | پروژه‌های JEB | واحدهای پروژه از موتور، هش‌های مصنوع و شبه‌کدِ نوع/متد | یک کلاینت JEB که توسط فراخوان‌کننده اجرا شده و MCP ارائه می‌دهد و با REA_JEB_MCP_URL انتخاب می‌شود؛ [راهنمای JEB](docs/jeb-analysis.md) | | منابع اندروید | مانیفست رمزگشایی‌شده، جداول رشته‌ها، زبان‌ها و اطلاعات نسخه | راه‌انداز Apktool تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای منابع](docs/apktool-resource-analysis.md) | +| برنامه‌های فلاتر | هش اسنپ‌شات دارت، هش کتابخانه‌ها به تفکیک ABI و اصالت بیلد موتور | هیچ (تحلیل ایستا APK)؛ [راهنمای فلاتر](docs/flutter-analysis.md) | | Firmware | ناحیه‌ها، نتایج استخراج و تحویل به ابزارهای تحلیل بومی | Binwalk / Unblob در لینوکس؛ [راهنمای Firmware](docs/firmware-analysis.md) | | بسته‌ها و منابع | فهرست فایل‌ها، digestها، فایل‌های plist، ساختار bundle اپل و منابع استخراج‌شده | [راهنمای آرتیفکت و JavaScript](docs/javascript-artifact-reconstruction.md)، [برنامه‌های اپل](docs/apple-application-analysis.md) | | رفتار فرایند | خروجی ترمینال، تعاملات، وضعیت خروج، مشاهدات سیستم فایل و مقایسهٔ اجراها | Linux/macOS با PTY بومی؛ [ضبط فرایند](docs/process-capture.md) | diff --git a/README_fr.md b/README_fr.md index c025cd14f9..5282fdc727 100644 --- a/README_fr.md +++ b/README_fr.md @@ -148,6 +148,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les | Appareils Android | Appareils, paquets, processus, journaux, dumps, captures d'écran et transferts d'APK/fichiers | adb fourni par l'appelant (émulateur ou appareil) ; [guide ADB](docs/adb-device-analysis.md) | | Projets JEB | Unités de projet fournies par le moteur, condensés d'artefacts et pseudo-code de types/méthodes | Un client JEB démarré par l'appelant servant MCP, sélectionné via REA_JEB_MCP_URL ; [guide JEB](docs/jeb-analysis.md) | | Ressources Android | Manifeste décodé, tables de chaînes, langues et faits de version | Lanceur Apktool fourni par l'appelant ; [guide des ressources](docs/apktool-resource-analysis.md) | +| Applications Flutter | Empreinte de snapshot Dart, condensés de bibliothèques par ABI et provenance de build du moteur | Aucune (analyse APK statique) ; [guide Flutter](docs/flutter-analysis.md) | | Firmware | Régions, résultats d'extraction et passage de relais vers l'analyse native | Binwalk / Unblob sous Linux ; [guide firmware](docs/firmware-analysis.md) | | Paquets et ressources | Inventaires de fichiers, empreintes, plists, anatomie des bundles Apple et ressources extraites | [Guide artefacts et JavaScript](docs/javascript-artifact-reconstruction.md), [applications Apple](docs/apple-application-analysis.md) | | Comportement des processus | Sortie du terminal, interactions, code de sortie, observations du système de fichiers et comparaisons | Linux/macOS avec un PTY natif ; [capture de processus](docs/process-capture.md) | diff --git a/README_id.md b/README_id.md index 0ca8713121..7dc588fa0f 100644 --- a/README_id.md +++ b/README_id.md @@ -151,6 +151,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Perangkat Android | Perangkat, paket, proses, log, dump, tangkapan layar, serta transfer APK/berkas | adb buatan pemanggil (emulator atau perangkat); [panduan ADB](docs/adb-device-analysis.md) | | Proyek JEB | Unit proyek bertenaga mesin, hash artefak, dan pseudokode tipe/metode | Klien JEB yang dijalankan pemanggil dan menyajikan MCP, dipilih dengan REA_JEB_MCP_URL; [panduan JEB](docs/jeb-analysis.md) | | Resource Android | Manifest terdekode, tabel string, lokal, dan fakta versi | Peluncur Apktool buatan pemanggil; [panduan resource](docs/apktool-resource-analysis.md) | +| Aplikasi Flutter | Hash snapshot Dart, hash pustaka per ABI, dan asal build engine | Tidak ada (parsing APK statis); [panduan Flutter](docs/flutter-analysis.md) | | Firmware | Wilayah, hasil ekstraksi, dan pengalihan ke analisis native | Binwalk / Unblob di Linux; [panduan firmware](docs/firmware-analysis.md) | | Paket dan sumber daya | Inventaris berkas, digest, plist, struktur paket Apple, dan sumber daya yang diekstrak | [Panduan artefak dan JavaScript](docs/javascript-artifact-reconstruction.md), [aplikasi Apple](docs/apple-application-analysis.md) | | Perilaku proses | Keluaran terminal, interaksi, pengamatan proses keluar dan sistem berkas, serta perbandingan eksekusi | Linux/macOS dengan PTY native; [perekaman proses](docs/process-capture.md) | diff --git a/README_it.md b/README_it.md index af908746d4..fe9a6eb764 100644 --- a/README_it.md +++ b/README_it.md @@ -148,6 +148,7 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument | Dispositivi Android | Dispositivi, pacchetti, processi, log, dump, screenshot e trasferimenti APK/file | adb fornito dal chiamante (emulatore o hardware); [guida ADB](docs/adb-device-analysis.md) | | Progetti JEB | Unità di progetto dal motore, digest degli artifact e pseudocodice di tipi/metodi | Client JEB avviato dal chiamante via MCP, selezionato con REA_JEB_MCP_URL; [guida JEB](docs/jeb-analysis.md) | | Risorse Android | Manifest decodificato, tabelle stringhe, lingue e dati di versione | Launcher Apktool fornito dal chiamante; [guida alle risorse](docs/apktool-resource-analysis.md) | +| App Flutter | Hash dello snapshot Dart, digest delle librerie per ABI e provenienza della build del motore | Nessuno (analisi APK statica); [guida Flutter](docs/flutter-analysis.md) | | Firmware | Regioni, risultati di estrazione e passaggi all'analisi nativa | Binwalk / Unblob su Linux; [guida firmware](docs/firmware-analysis.md) | | Pacchetti e risorse | Inventari file, digest, plist, anatomia dei bundle Apple e risorse estratte | [Guida artifact e JavaScript](docs/javascript-artifact-reconstruction.md), [Applicazioni Apple](docs/apple-application-analysis.md) | | Comportamento dei processi | Output del terminale, interazioni, osservazioni su uscita e filesystem ed confronti di esecuzione | Linux/macOS con un PTY nativo; [cattura dei processi](docs/process-capture.md) | diff --git a/README_ja.md b/README_ja.md index 370fb591d8..607315297a 100644 --- a/README_ja.md +++ b/README_ja.md @@ -144,6 +144,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm | Android デバイス | デバイス、パッケージ、プロセス、ログ、ダンプ、スクリーンキャプチャ、APK/ファイル転送 | 呼び出し側が用意した adb(エミュレーターまたは実機);[ADB ガイド](docs/adb-device-analysis.md) | | JEBプロジェクト | エンジンによるプロジェクトユニット、アーティファクトのダイジェスト、型/メソッドの擬似コード | 呼び出し側が起動し MCP を提供する JEB クライアント(REA_JEB_MCP_URL で選択);[JEB ガイド](docs/jeb-analysis.md) | | Android リソース | デコードされたマニフェスト、文字列テーブル、ロケール、バージョン情報 | 呼び出し側が用意した Apktool ランチャー;[リソースガイド](docs/apktool-resource-analysis.md) | +| Flutter アプリ | Dart スナップショットハッシュ、ABI ごとのライブラリダイジェスト、エンジンビルドの来歴 | 不要(静的 APK 解析);[Flutter ガイド](docs/flutter-analysis.md) | | ファームウェア | 領域、抽出結果、ネイティブ分析への引き継ぎ | Linux 上の Binwalk / Unblob;[ファームウェアガイド](docs/firmware-analysis.md) | | パッケージとリソース | ファイル一覧、ダイジェスト、plist、Apple バンドルの構造、抽出されたリソース | [アーティファクトと JavaScript のガイド](docs/javascript-artifact-reconstruction.md)、[Apple アプリケーション](docs/apple-application-analysis.md) | | プロセスの動作 | ターミナル出力、対話、終了とファイルシステムの観測結果、実行の比較 | ネイティブ PTY を備えた Linux/macOS;[プロセスキャプチャ](docs/process-capture.md) | diff --git a/README_ko.md b/README_ko.md index 70301cfbcb..d2df0703c7 100644 --- a/README_ko.md +++ b/README_ko.md @@ -144,6 +144,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니 | Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](docs/adb-device-analysis.md) | | JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](docs/jeb-analysis.md) | | Android 리소스 | 디코딩된 매니페스트, 문자열 테이블, 로캘, 버전 정보 | 호출자 제공 Apktool 런처; [리소스 가이드](docs/apktool-resource-analysis.md) | +| Flutter 앱 | Dart 스냅샷 해시, ABI별 라이브러리 해시, 엔진 빌드 출처 | 없음 (정적 APK 파싱); [Flutter 가이드](docs/flutter-analysis.md) | | 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | Linux의 Binwalk / Unblob; [펌웨어 가이드](docs/firmware-analysis.md) | | 패키지 및 리소스 | 파일 목록, 다이제스트, plist, Apple 번들 구조, 추출한 리소스 | [아티팩트 및 JavaScript 가이드](docs/javascript-artifact-reconstruction.md), [Apple 애플리케이션](docs/apple-application-analysis.md) | | 프로세스 동작 | 터미널 출력, 상호작용, 종료 및 파일 시스템 관찰 결과, 실행 비교 | 네이티브 PTY를 지원하는 Linux/macOS; [프로세스 캡처](docs/process-capture.md) | diff --git a/README_pl.md b/README_pl.md index f3db2fbd2d..30ea43edaa 100644 --- a/README_pl.md +++ b/README_pl.md @@ -144,6 +144,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na | Urządzenia z Androidem | Urządzenia, pakiety, procesy, logi, zrzuty, zrzuty ekranu oraz transfery APK/plików | adb dostarczony przez wywołującego (emulator lub urządzenie); [przewodnik ADB](docs/adb-device-analysis.md) | | Projekty JEB | Jednostki projektu obsługiwane przez silnik, skróty artefaktów i pseudokod typów/metod | Uruchomiony przez wywołującego klient JEB udostępniający MCP, wybrany przez REA_JEB_MCP_URL; [przewodnik JEB](docs/jeb-analysis.md) | | Zasoby Androida | Zdekodowany manifest, tablice ciągów, języki i informacje o wersji | Należący do wywołującego launcher Apktool; [przewodnik zasobów](docs/apktool-resource-analysis.md) | +| Aplikacje Flutter | Hash migawki Dart, sumy bibliotek per ABI i pochodzenie kompilacji silnika | Brak (statyczna analiza APK); [przewodnik Flutter](docs/flutter-analysis.md) | | Oprogramowanie układowe | Regiony, wyniki wyodrębniania i przekazanie do analizy kodu natywnego | Binwalk / Unblob w systemie Linux; [przewodnik oprogramowania układowego](docs/firmware-analysis.md) | | Pakiety i zasoby | Spisy plików, skróty, pliki plist, struktura pakietów Apple i wyodrębnione zasoby | [Przewodnik artefaktów i JavaScript](docs/javascript-artifact-reconstruction.md), [aplikacje Apple](docs/apple-application-analysis.md) | | Zachowanie procesów | Wyjście terminala, interakcje, obserwacje zakończenia i systemu plików oraz porównania uruchomień | Linux/macOS z natywnym PTY; [przechwytywanie procesów](docs/process-capture.md) | diff --git a/README_pt-BR.md b/README_pt-BR.md index 662225a218..a84f6e0db6 100644 --- a/README_pt-BR.md +++ b/README_pt-BR.md @@ -144,6 +144,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer | Dispositivos Android | Dispositivos, pacotes, processos, logs, despejos, capturas de tela e transferências de APK/arquivos | adb fornecido pelo chamador (emulador ou aparelho); [guia de ADB](docs/adb-device-analysis.md) | | Projetos JEB | Unidades de projeto do motor, hashes de artefatos e pseudocódigo de tipos/métodos | Um cliente JEB iniciado pelo chamador servindo MCP, selecionado com REA_JEB_MCP_URL; [guia de JEB](docs/jeb-analysis.md) | | Recursos do Android | Manifesto decodificado, tabelas de strings, idiomas e fatos de versão | Lançador do Apktool fornecido pelo chamador; [guia de recursos](docs/apktool-resource-analysis.md) | +| Aplicativos Flutter | Hash de snapshot Dart, hashes de bibliotecas por ABI e procedência de build do motor | Nenhuma (análise estática de APK); [guia Flutter](docs/flutter-analysis.md) | | Firmware | Regiões, resultados de extração e encaminhamentos à análise nativa | Binwalk / Unblob no Linux; [guia de firmware](docs/firmware-analysis.md) | | Pacotes e recursos | Inventários de arquivos, digests, plists, estrutura de bundles Apple e recursos extraídos | [Guia de artefatos e JavaScript](docs/javascript-artifact-reconstruction.md), [aplicativos Apple](docs/apple-application-analysis.md) | | Comportamento de processos | Saída do terminal, interações, observações de encerramento e do sistema de arquivos, e comparações entre execuções | Linux/macOS com PTY nativo; [captura de processos](docs/process-capture.md) | diff --git a/README_ru.md b/README_ru.md index a22c620ce3..914ef09da3 100644 --- a/README_ru.md +++ b/README_ru.md @@ -144,6 +144,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так | Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](docs/adb-device-analysis.md) | | Проекты JEB | Единицы проекта от движка, дайджесты артефактов и псевдокод типов/методов | Запущенный вызывающей стороной клиент JEB с интерфейсом MCP, выбирается через REA_JEB_MCP_URL; [руководство по JEB](docs/jeb-analysis.md) | | Ресурсы Android | Декодированный манифест, таблицы строк, языки и сведения о версии | Запуск Apktool, предоставленный вызывающей стороной; [руководство по ресурсам](docs/apktool-resource-analysis.md) | +| Приложения Flutter | Хеш снапшота Dart, дайджесты библиотек по ABI и происхождение сборки движка | Не требуется (статический разбор APK); [руководство по Flutter](docs/flutter-analysis.md) | | Прошивки | Области, результаты извлечения и передача в нативный анализ | Binwalk / Unblob в Linux; [руководство по прошивкам](docs/firmware-analysis.md) | | Пакеты и ресурсы | Списки файлов, хеши, plist, структура пакетов Apple и извлечённые ресурсы | [Руководство по артефактам и JavaScript](docs/javascript-artifact-reconstruction.md), [приложения Apple](docs/apple-application-analysis.md) | | Поведение процессов | Вывод терминала, взаимодействия, наблюдения за завершением и файловой системой, сравнения запусков | Linux/macOS с нативным PTY; [захват процессов](docs/process-capture.md) | diff --git a/README_th.md b/README_th.md index 7f87a81cf0..2abe5a0782 100644 --- a/README_th.md +++ b/README_th.md @@ -144,6 +144,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26 | อุปกรณ์ Android | อุปกรณ์ แพ็กเกจ โพรเซส บันทึก ดัมพ์ ภาพหน้าจอ และการถ่ายโอน APK/ไฟล์ | adb ที่ผู้เรียกจัดเตรียม (โปรแกรมจำลองหรืออุปกรณ์จริง); [คู่มือ ADB](docs/adb-device-analysis.md) | | โปรเจกต์ JEB | หน่วยโปรเจกต์จากเอนจิน, แฮชของอาร์ติแฟกต์ และซูโดโค้ดของไทป์/เมท็อด | ไคลเอนต์ JEB ที่ผู้เรียกเริ่มและให้บริการ MCP เลือกด้วย REA_JEB_MCP_URL; [คู่มือ JEB](docs/jeb-analysis.md) | | ทรัพยากร Android | ไฟล์ manifest ที่ถอดรหัสแล้ว ตารางสตริง ภาษา และข้อมูลเวอร์ชัน | ตัวเรียก Apktool ที่ผู้เรียกจัดเตรียม; [คู่มือทรัพยากร](docs/apktool-resource-analysis.md) | +| แอป Flutter | แฮชสแนปชอต Dart, แฮชไลบรารีราย ABI และที่มาของบิลด์เอนจิน | ไม่ต้อง (วิเคราะห์ APK แบบ static); [คู่มือ Flutter](docs/flutter-analysis.md) | | เฟิร์มแวร์ | พื้นที่ข้อมูล ผลการแตกไฟล์ และการส่งต่อไปวิเคราะห์เนทีฟ | Binwalk / Unblob บน Linux; [คู่มือเฟิร์มแวร์](docs/firmware-analysis.md) | | แพ็กเกจและทรัพยากร | รายการไฟล์ ค่าแฮช plist โครงสร้างบันเดิลของ Apple และทรัพยากรที่แตกออกมา | [คู่มืออาร์ติแฟกต์และ JavaScript](docs/javascript-artifact-reconstruction.md), [แอปพลิเคชัน Apple](docs/apple-application-analysis.md) | | พฤติกรรมของโปรเซส | เอาต์พุตเทอร์มินัล การโต้ตอบ ข้อมูลการสิ้นสุดและระบบไฟล์ที่สังเกตได้ และการเปรียบเทียบการรัน | Linux/macOS ที่มี PTY เนทีฟ; [การบันทึกโปรเซส](docs/process-capture.md) | diff --git a/README_tr.md b/README_tr.md index abc40f0880..28caee2602 100644 --- a/README_tr.md +++ b/README_tr.md @@ -144,6 +144,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç | Android cihazları | Cihazlar, paketler, süreçler, günlükler, dökümler, ekran görüntüleri ve APK/dosya aktarımları | Çağıranın sağladığı adb (emülatör veya cihaz); [ADB rehberi](docs/adb-device-analysis.md) | | JEB projeleri | Motor destekli proje birimleri, artefakt özetleri ve tür/metot sözdekodu | Çağıranın başlattığı, MCP sunan bir JEB istemcisi, REA_JEB_MCP_URL ile seçilir; [JEB rehberi](docs/jeb-analysis.md) | | Android kaynakları | Çözülmüş manifest, dize tabloları, diller ve sürüm bilgileri | Çağıranın sağladığı Apktool başlatıcısı; [kaynak rehberi](docs/apktool-resource-analysis.md) | +| Flutter uygulamaları | Dart anlık görüntü karması, ABI başına kitaplık özetleri ve motor derleme kökeni | Yok (statik APK çözümleme); [Flutter rehberi](docs/flutter-analysis.md) | | Donanım yazılımı | Bölgeler, çıkarma sonuçları ve yerel kod analizine devirler | Linux üzerinde Binwalk / Unblob; [donanım yazılımı rehberi](docs/firmware-analysis.md) | | Paketler ve kaynaklar | Dosya envanterleri, özet değerleri, plist'ler, Apple paket yapısı ve çıkarılmış kaynaklar | [Artefakt ve JavaScript rehberi](docs/javascript-artifact-reconstruction.md), [Apple uygulamaları](docs/apple-application-analysis.md) | | Süreç davranışı | Terminal çıktısı, etkileşimler, sonlanma ve dosya sistemi gözlemleri, çalıştırma karşılaştırmaları | Yerel PTY ile Linux/macOS; [süreç yakalama](docs/process-capture.md) | diff --git a/README_uk.md b/README_uk.md index bf824a4f1e..a97624ec8d 100644 --- a/README_uk.md +++ b/README_uk.md @@ -144,6 +144,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та | Пристрої Android | Пристрої, пакети, процеси, журнали, дампи, знімки екрана та передавання APK/файлів | adb, наданий викликаючою стороною (емулятор або пристрій); [підручник ADB](docs/adb-device-analysis.md) | | Проєкти JEB | Єдиниці проєкту від рушія, дайджести артефактів і псевдокод типів/методів | Запущений викликаною стороною клієнт JEB з MCP, обирається через REA_JEB_MCP_URL; [посібник JEB](docs/jeb-analysis.md) | | Ресурси Android | Декодований маніфест, таблиці рядків, мови та відомості про версію | Засіб запуску Apktool, наданий викликаючою стороною; [підручник ресурсів](docs/apktool-resource-analysis.md) | +| Додатки Flutter | Хеш знімка Dart, дайджести бібліотек за ABI та походження збірки рушія | Не потрібні (статичний розбір APK); [підручник Flutter](docs/flutter-analysis.md) | | Прошивки | Області, результати видобування й передавання до нативного аналізу | Binwalk / Unblob у Linux; [посібник прошивок](docs/firmware-analysis.md) | | Пакети й ресурси | Переліки файлів, хеші, plist, структура пакетів Apple та видобуті ресурси | [Посібник артефактів і JavaScript](docs/javascript-artifact-reconstruction.md), [застосунки Apple](docs/apple-application-analysis.md) | | Поведінка процесів | Вивід термінала, взаємодії, спостереження за завершенням і файловою системою та порівняння запусків | Linux/macOS із нативним PTY; [захоплення процесів](docs/process-capture.md) | diff --git a/README_vi.md b/README_vi.md index 9bb23d09a8..be4eac7c25 100644 --- a/README_vi.md +++ b/README_vi.md @@ -144,6 +144,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np | Thiết bị Android | Thiết bị, gói, tiến trình, nhật ký, dump, ảnh chụp màn hình và truyền APK/tệp | adb do nơi gọi cung cấp (trình giả lập hoặc thiết bị); [hướng dẫn ADB](docs/adb-device-analysis.md) | | Dự án JEB | Đơn vị dự án từ engine, hash của artifact và mã giả của kiểu/phương thức | Một ứng dụng JEB do nơi gọi khởi động, phục vụ MCP, chọn bằng REA_JEB_MCP_URL; [hướng dẫn JEB](docs/jeb-analysis.md) | | Tài nguyên Android | Manifest đã giải mã, bảng chuỗi, ngôn ngữ và thông tin phiên bản | Trình khởi chạy Apktool do nơi gọi cung cấp; [hướng dẫn tài nguyên](docs/apktool-resource-analysis.md) | +| Ứng dụng Flutter | Băm snapshot Dart, hash thư viện theo ABI và nguồn gốc build engine | Không cần (phân tích APK tĩnh); [hướng dẫn Flutter](docs/flutter-analysis.md) | | Phần sụn | Các vùng, kết quả trích xuất và dữ liệu chuyển sang phân tích mã máy | Binwalk / Unblob trên Linux; [hướng dẫn phần sụn](docs/firmware-analysis.md) | | Gói và tài nguyên | Danh sách tệp, giá trị băm, plist, cấu trúc bundle Apple và tài nguyên được trích xuất | [Hướng dẫn hiện vật phần mềm và JavaScript](docs/javascript-artifact-reconstruction.md), [ứng dụng Apple](docs/apple-application-analysis.md) | | Hành vi tiến trình | Đầu ra terminal, tương tác, quan sát khi thoát và hệ thống tệp, cùng so sánh các lần chạy | Linux/macOS có PTY gốc; [ghi lại tiến trình](docs/process-capture.md) | diff --git a/README_zh-TW.md b/README_zh-TW.md index 6c80ce6f2d..841e4d4795 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -144,6 +144,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](docs/adb-device-analysis.md) | | JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](docs/jeb-analysis.md) | | Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](docs/apktool-resource-analysis.md) | +| Flutter 應用 | Dart 快照雜湊、依 ABI 的程式庫雜湊及引擎建置來源 | 無(靜態 APK 解析);[Flutter 指南](docs/flutter-analysis.md) | | 韌體 | 區域、提取結果和轉交原生分析的內容 | Linux 上的 Binwalk / Unblob;[韌體指南](docs/firmware-analysis.md) | | 軟體包與資源 | 檔案清單、摘要、plist、Apple bundle 結構和提取的資源 | [製品與 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 應用](docs/apple-application-analysis.md) | | 處理程序行為 | 終端輸出、互動、退出和檔案系統觀察結果,以及執行對比 | 支援原生 PTY 的 Linux/macOS;[處理程序擷取](docs/process-capture.md) | diff --git a/README_zh.md b/README_zh.md index e47aac3bcf..3d079c9704 100644 --- a/README_zh.md +++ b/README_zh.md @@ -144,6 +144,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](docs/adb-device-analysis.md) | | JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](docs/jeb-analysis.md) | | Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](docs/apktool-resource-analysis.md) | +| Flutter 应用 | Dart 快照哈希、按 ABI 的库摘要及引擎构建溯源 | 无(静态 APK 解析);[Flutter 指南](docs/flutter-analysis.md) | | 固件 | 区域、提取结果和转交原生分析的内容 | Linux 上的 Binwalk / Unblob;[固件指南](docs/firmware-analysis.md) | | 软件包与资源 | 文件清单、摘要、plist、Apple bundle 结构和提取的资源 | [制品与 JavaScript 指南](docs/javascript-artifact-reconstruction.md)、[Apple 应用](docs/apple-application-analysis.md) | | 进程行为 | 终端输出、交互、退出和文件系统观察结果,以及运行对比 | 支持原生 PTY 的 Linux/macOS;[进程捕获](docs/process-capture.md) | diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md new file mode 100644 index 0000000000..75c23dd661 --- /dev/null +++ b/docs/flutter-analysis.md @@ -0,0 +1,156 @@ +# Flutter build identification + +REA identifies a Flutter app's build facts by **pure parsing** of its APK: +the Dart VM snapshot hash, per-ABI library digests, the engine's GNU +build-id, and any labeled version strings. No engine, no tool installation, +no execution, and no external lookups — this family answers "which Dart SDK +build is this, and what exactly is in the payload?" before any deeper +native analysis. + +This is the triage step for Flutter reversing: the snapshot hash is the +artifact that maps to a Dart SDK release through an external dataset (such +as the one reFlutter maintains), and knowing the SDK version is what makes +SDK-layout-specific snapshot parsers applicable. + +## Answer an analyst question + +```sh +rea identify-flutter-build /targets/Example.apk +rea inspect-dart-aot /targets/Example.apk +rea inspect-dart-aot /targets/Example.apk --abi armeabi-v7a +``` + +The equivalent MCP methods are `identify_flutter_build` (with `path`) and +`inspect_dart_aot` (with `path` and an optional `abi`; the default prefers +arm64-v8a, then x86_64, then x86, then armeabi-v7a, then the remaining +ABIs alphabetically). + +## What the AOT inspection returns + +`inspect_dart_aot` reads one ABI's `libapp.so` in full and reports: + +- **Snapshot sections** — the four `_kDart*Snapshot*` symbols located + through the ELF **dynamic symbol table** (ELF32 and ELF64, little-endian, + every read bounds-checked), each with file offset, size, and — for data + sections, which carry the `f5f5dcdc` magic — the parsed header kind and + length (the stored length excludes the four magic bytes). Data headers + whose declared snapshot length does not fit their symbol retain their + observed fields and report partial coverage. Complete structural coverage + currently requires kind 3, the FullAOT value in the supported + [Dart 2.14 layout](https://github.com/dart-lang/sdk/blob/2.14.0/runtime/vm/snapshot.h). + Kind numbers vary across Dart releases; other values are preserved with + partial coverage, without guessing their interpretation. + A payload without exported snapshot symbols is refused rather + than guessed at. +- **The snapshot hash** from the header at the start of each exported data + symbol. Complete hash coverage requires a valid header from both the VM + and isolate; duplicate records inside one symbol cannot stand in for the + other's header. Aliased or overlapping data symbols report partial coverage. +- **A string-pool projection from the isolate data section**: distinct + `package:` source URIs (the app's own and its dependencies' Dart files — + a dependency inventory), `dart:` SDK URIs (which SDK libraries the app + uses), and identifier-shaped tokens, each bounded with counts and + `coverage: "partial"` when truncated. On the reference target this counts + 756 package URIs and 201 dart URIs on arm64-v8a and x86_64, and 212 / 48 + on armeabi-v7a, retaining up to 500 of each in the response. + `libapp.coverage` describes structural snapshot checks; + `string_pool.coverage` describes the bounded projection. Overall coverage + is partial when either facet is partial. + +## Interpret the result + +- **`flutter_detected`** is true when any `lib//libapp.so` exists. A + negative answer reports complete coverage: absence is an answer. +- **`snapshot_hash`** is the Dart build hash observed 20 bytes after the + snapshot magic (`f5 f5 dc dc`) inside `libapp.so`. Identification scans + byte patterns; AOT inspection reads the exported VM and isolate headers. + `snapshot_hash_sources` counts the valid records observed by the chosen + operation, and when they **disagree** the distinct values are + kept in `snapshot_hash_candidates` with `snapshot_hash: null` and + `coverage: "partial"` rather than silently picking one. +- **`elf_header_valid`** checks the supported little-endian ELF32/ELF64 + header, table entry widths, and full table extents against the library's + actual byte length. Truncated, malformed, or unsupported headers report + false and partial coverage while retaining byte-scanned hashes. This + header check does not establish valid table contents or exported symbols. +- **`libflutter` facts** are per-ABI observations: byte size, SHA-256 + digest, GNU build IDs from ELF-declared `PT_NOTE` / `SHT_NOTE` ranges, + and any _labeled_ strings the build + carries. Release engine builds often strip the `Dart VM version` string — + absence is reported as `null`, not guessed. Android clang/LLVM toolchain + lines are reported verbatim as build provenance. + `build_id_candidates` preserves distinct IDs. `build_id` is populated only + when all declared note ranges were examined and one ID was found; + conflicting IDs or malformed/bounded note metadata make coverage partial. + Matching bytes outside declared note ranges cannot supply a build ID. +- Labeled strings must fit their complete grammar. `dart_version_capped` + and `toolchain_capped` independently report labels omitted by grammar or + projection bounds; either makes identification coverage partial. Values + split across read chunks are retained without publishing truncated prefixes. +- **`coverage: "partial"`** also appears when an ABI carries `libapp.so` + without `libflutter.so` (or vice versa). +- The APK itself is digested before parsing, so `target.sha256` binds the + observation to the exact input. + +## Limits + +- **Owned snapshots.** Both operations copy the admitted APK into a + provider-owned, read-only snapshot before digesting and parsing, so a + concurrently modified original cannot mix into one observation; failed + reader closure and snapshot cleanup are retained and retried on `close()`. + The snapshot is removed only after all owned readers and file descriptors + close successfully. Copying pins one admitted descriptor and compares its + identity, size, mode and timestamps after the copy. Source permission + failures remain `access_denied`, and source identity changes remain + `artifact_changed`. Provider-owned storage failures report a + `resource_constraint` with their native I/O cause. Cancellation is checked + during copying, metadata/symbol walks, string scanning, and after cleanup. +- **Bounded reads.** APK snapshots are bounded at 2 GiB, including while + copying. Identification permits 768 MiB of cumulative library extraction; + declared sizes are checked before extraction. Library reads are bounded at 256 MiB each and ZIP + metadata at 8 MiB; the AOT image buffer is allocated at exactly the + declared size before reading. Exceeded budgets report + `resource_constraint`. +- **Engine notes.** Engine libraries are spooled into the owned root. ELF + headers, note tables and note payloads share an 8 MiB metadata budget; + up to 64 distinct build IDs are retained. Exceeding either bound preserves + the candidates already examined and reports partial coverage. +- **Metadata and candidate capacity.** Both operations refuse duplicate + relevant library paths. Inventory is limited to 32 ABIs and 1024 bytes per + relevant library path, independently of compressed size. Identification + refuses more than 64 distinct snapshot hashes with `resource_constraint` + rather than silently dropping conflicting evidence. +- **Selected ABI.** AOT inspection applies the library budget to the selected + ABI after selection; an oversized unselected library does not block it. + Undefined dynamic-symbol imports are not treated as snapshot exports. + Section-stripped images require a bounded symbol count from `DT_HASH` or + `DT_GNU_HASH`; layouts without either are refused. Symbol names and their + terminators must fit their linked string table. +- REA performs **no hash-to-SDK lookup**: mapping the snapshot hash to a + Dart SDK release requires an external dataset and is deliberately out of + scope for this family. +- The AOT string pool is a **byte-pattern projection, not deserialized + cluster semantics**: typed class and function recovery requires an + SDK-specific parser (the snapshot layout changes per release — exactly + why the hash is the key), and `class_like_tokens` include incidental + matches. JEB's GUI Dart unit and native analysis of `libapp.so` cover + some ground today. + +## Real verification + +`npm run verify:flutter -- --apk PATH [--plain-apk PATH]` runs both tools +through compiled CLI and stdio MCP, compares Evidence, and checks every ABI. +Use `--expectations scripts/fixtures/flutter-gallery-expectations.json` with +the Gallery 2.9.2 release APK to check exact per-ABI engine labels, +URI counts and the snapshot hash against its pinned SHA-256. The verifier +requires complete structural snapshot coverage on every ABI and explicitly +reports the bounded pool projection as partial/unverified when truncated. +Other APKs need their own expectations file to verify exact engine labels +and URI counts; otherwise those facets are reported unverified. +The optional plain APK checks negative detection. Record: Flutter Gallery 2.9.2 (112 MB, three +ABIs) — every ABI reports the same agreeing hash +`65817c30a78bb44c3dc3771876b6010a` from two snapshot sections, per-ABI +engine build-ids, no labeled Dart version (as expected for a release +engine), and the Android clang toolchain line; a plain aapt2-built APK +reports not-detected with complete coverage. The suite uses self-contained +synthetic ELF images and scanner payloads; it does not read private local files. diff --git a/docs/testing.md b/docs/testing.md index ec065b14d7..d0748b7605 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -497,6 +497,36 @@ with the reported target identity, apktool.yml metadata projection [Apktool resource analysis](apktool-resource-analysis.md) for boundaries and budgets. +## Flutter build identification + +`npm run verify:flutter -- --apk PATH [--plain-apk PATH]` runs both pure +static operations against a real Flutter APK, plus an optional non-Flutter +APK for the negative check. No engine or external tool is involved. Record: +Flutter Gallery 2.9.2 (112 MB, arm64-v8a / armeabi-v7a / x86_64) — every +ABI reports the same agreeing snapshot hash +(`65817c30a78bb44c3dc3771876b6010a`, two sections each) read 20 bytes after +the Dart snapshot magic, per-ABI engine GNU build-ids, no labeled Dart +version string (expected for a release engine build), and the Android +clang/LLVM toolchain line; the AOT inspection locates all four snapshot +symbols through the dynamic symbol table on both ELF64 and ELF32 images, +validates the data-section headers, agrees with the identification hash, +and counts 756 `package:` and 201 `dart:` URIs on arm64-v8a and x86_64, +and 212 / 48 on armeabi-v7a, from each isolate data section. Responses +retain at most 500 URIs per family and 2000 class-like tokens. Gallery's +pool projection and overall AOT coverage are partial while its structural +`libapp.coverage` is complete. A plain aapt2-built APK reports not-detected with +complete coverage. The suite uses self-contained synthetic ELF images and recorded scanner +prefixes, with no private local-file dependency. The verifier now compares +compiled CLI and stdio MCP Evidence on every ABI, including data-header kind +and declared lengths. Add +`--expectations scripts/fixtures/flutter-gallery-expectations.json` for the +documented Gallery APK to assert its pinned digest, hash, exact engine labels +and per-ABI URI counts. The verifier requires complete structural snapshot +coverage, reports bounded pool coverage as partial/unverified, and reports +exact labels/counts on other APKs unverified without an expectations file. +See [Flutter build identification](flutter-analysis.md) for boundaries and +budgets. + ## ADB device acquisition `npm run verify:adb` exercises every ADB operation against a live device or diff --git a/package.json b/package.json index 0393f84c37..ea47ce8509 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "verify:legacy-simulator": "npm run build:cached && node scripts/verify/apple/legacy-simulator.mjs", "verify:architecture-guards": "node scripts/verify/architecture-guards.mjs", "verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs", + "verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs", "verify:adb": "npm run build:cached && node scripts/verify-adb.mjs", "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", "verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs", diff --git a/scripts/fixtures/flutter-gallery-expectations.json b/scripts/fixtures/flutter-gallery-expectations.json new file mode 100644 index 0000000000..909d86e661 --- /dev/null +++ b/scripts/fixtures/flutter-gallery-expectations.json @@ -0,0 +1,38 @@ +{ + "target_sha256": "659b5fc6a4319dfe4f117d00ad4e7ec004915ebcadcb96628797fb3544040e29", + "snapshot_hash": "65817c30a78bb44c3dc3771876b6010a", + "engines": { + "x86_64": { + "dart_version": null, + "toolchain_lines": [ + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" + ] + }, + "armeabi-v7a": { + "dart_version": null, + "toolchain_lines": [ + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" + ] + }, + "arm64-v8a": { + "dart_version": null, + "toolchain_lines": [ + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" + ] + } + }, + "string_pools": { + "x86_64": { + "package_uri_count_at_least": 756, + "dart_uri_count_at_least": 201 + }, + "armeabi-v7a": { + "package_uri_count_at_least": 212, + "dart_uri_count_at_least": 48 + }, + "arm64-v8a": { + "package_uri_count_at_least": 756, + "dart_uri_count_at_least": 201 + } + } +} diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 787d6255d6..2ecafd9ae9 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -49,6 +49,11 @@ export const toolFamilyCatalog = (sources) => { surface: "android-provider", contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS, }, + { + id: "flutter", + surface: "flutter-provider", + contracts: sources.flutterContracts.FLUTTER_TOOL_CONTRACTS, + }, { id: "frida", surface: "instrumentation-provider", @@ -231,6 +236,10 @@ export const providerCatalog = (sources) => { identity: sources.androidProvider.JADX_PROVIDER_IDENTITY, contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS, }, + { + identity: sources.flutterProvider.FLUTTER_PROVIDER_IDENTITY, + contracts: sources.flutterContracts.FLUTTER_TOOL_CONTRACTS, + }, { identity: sources.apktoolProvider.APKTOOL_PROVIDER_IDENTITY, contracts: sources.apktoolContracts.APKTOOL_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 546b7359c4..6287f257b7 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -58,8 +58,12 @@ const SOURCE_PATHS = { firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js", firmwareProvider: "dist/firmware/FirmwareRelease.js", androidContracts: "dist/contracts/android/androidToolContracts.js", + flutterContracts: "dist/contracts/flutter/flutterToolContracts.js", + flutterProvider: "dist/flutter/FlutterBuildProvider.js", androidProvider: "dist/android/JadxRelease.js", apktoolContracts: "dist/contracts/apktool/apktoolToolContracts.js", + flutterContracts: "dist/contracts/flutter/flutterToolContracts.js", + flutterProvider: "dist/flutter/FlutterBuildProvider.js", apktoolProvider: "dist/apktool/ApktoolProvider.js", jebContracts: "dist/contracts/jeb/jebToolContracts.js", jebProvider: "dist/jeb/JebProvider.js", diff --git a/scripts/verify-flutter.mjs b/scripts/verify-flutter.mjs new file mode 100644 index 0000000000..061ad8b20e --- /dev/null +++ b/scripts/verify-flutter.mjs @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { toolContract } from "../dist/contracts/toolContracts.js"; +import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; +import { hashStableFile } from "../dist/filesystem/StableFileHash.js"; +import { mcpTextValue } from "./lib/mcp-verifier-results.mjs"; + +const options = parseArguments(process.argv.slice(2)); +if (options.help) { + process.stdout.write( + "Usage: npm run verify:flutter -- --apk PATH [--plain-apk PATH] [--expectations JSON]\n" + + "Checks real APK parsing through compiled CLI and stdio MCP. Optional\n" + + "expectations bind exact engine labels, URI counts and snapshot hash to an APK digest.\n", + ); + process.exit(0); +} +if (options.apk === null) { + process.stderr.write("No Flutter APK was provided; pass --apk PATH.\n"); + process.exit(2); +} +const expectations = + options.expectations === null + ? null + : JSON.parse(await readFile(options.expectations, "utf8")); +const root = await PrivateRuntimeRoot.create({ + prefix: "rea-flutter-verifier-", +}); +const entrypoint = resolve("scripts/rea.mjs"); +const environment = Object.fromEntries( + Object.entries(process.env).filter(([, value]) => typeof value === "string"), +); +Object.assign(environment, { + TMPDIR: root.path, + TMP: root.path, + TEMP: root.path, +}); +const client = new Client({ name: "flutter-verifier", version: "1" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "pipe", +}); +const execute = promisify(execFile); +try { + await client.connect(transport); + const apk = resolve(options.apk); + const identity = await hashStableFile(apk); + const identified = await run("identify_flutter_build", { path: apk }); + assert.deepEqual(identified.target, { path: apk, ...identity }); + assert.equal(identified.flutter_detected, true); + assert.equal(identified.coverage, "complete"); + const abis = identified.abis.filter((abi) => abi.libapp.present); + assert.ok(abis.length > 0); + const hashes = new Set(); + for (const abi of abis) { + assert.equal(abi.libapp.elf_header_valid, true); + assert.equal(abi.libapp.snapshot_hash_sources, 2); + assert.ok(abi.libapp.snapshot_hash); + hashes.add(abi.libapp.snapshot_hash); + assert.equal(abi.libflutter.present, true); + assert.match(abi.libflutter.build_id ?? "", /^(?:[0-9a-f]{2}){4,64}$/u); + assert.deepEqual(abi.libflutter.build_id_candidates, [ + abi.libflutter.build_id, + ]); + } + assert.equal(hashes.size, 1, "Every ABI must agree on the snapshot hash"); + if (expectations !== null) { + assert.equal(identity.sha256, expectations.target_sha256); + assert.deepEqual([...hashes], [expectations.snapshot_hash]); + assert.deepEqual( + abis.map((abi) => abi.abi).sort(), + Object.keys(expectations.engines).sort(), + ); + for (const abi of abis) { + assert.deepEqual( + { + dart_version: abi.libflutter.dart_version, + toolchain_lines: abi.libflutter.toolchain_lines, + }, + expectations.engines[abi.abi], + ); + assert.equal(abi.libflutter.dart_version_capped, false); + assert.equal(abi.libflutter.toolchain_capped, false); + } + process.stdout.write( + "[ok] Exact per-ABI engine labels match digest-bound expectations\n", + ); + } else { + process.stdout.write( + "[unverified] Exact engine labels and their absence need --expectations JSON\n", + ); + } + // The same checks apply to the default selection and each remaining ABI. + const preferred = await run("inspect_dart_aot", { path: apk }); + for (const abi of abis) { + const aot = + abi.abi === preferred.abi + ? preferred + : await run("inspect_dart_aot", { path: apk, abi: abi.abi }); + assert.equal(aot.abi, abi.abi); + assert.equal( + aot.libapp.coverage, + "complete", + `Snapshot decoding must be complete on ${abi.abi}`, + ); + assert.equal(aot.coverage, aot.string_pool.coverage); + if (aot.coverage !== "complete") { + process.stdout.write( + `[unverified] ${abi.abi}: full string-pool projection (returned pool is bounded and partial)\n`, + ); + } + assert.deepEqual(aot.target, identified.target); + assert.equal(aot.libapp.sha256, abi.libapp.sha256); + assert.equal(aot.libapp.bytes, abi.libapp.bytes); + assert.equal(aot.libapp.snapshot_hash_sources, 2); + assert.equal(aot.libapp.snapshot_hash, abi.libapp.snapshot_hash); + assert.deepEqual( + aot.libapp.sections.map((section) => section.name).sort(), + [ + "_kDartVmSnapshotData", + "_kDartVmSnapshotInstructions", + "_kDartIsolateSnapshotData", + "_kDartIsolateSnapshotInstructions", + ].sort(), + ); + for (const section of aot.libapp.sections) { + assert.ok(section.offset + section.size <= aot.libapp.bytes); + if (!section.name.endsWith("Data")) continue; + assert.equal(section.magic_valid, true); + assert.equal( + section.kind, + 3, + "This verifier requires an AOT app snapshot", + ); + assert.ok( + section.header_length >= 48 && + section.header_length + 4 <= section.size, + `Snapshot extent must fit ${section.name} on ${abi.abi}`, + ); + } + assert.ok(aot.string_pool.package_uri_count_at_least > 0); + assert.ok(aot.string_pool.dart_uri_count_at_least > 0); + if (expectations?.string_pools !== undefined) { + const expected = expectations.string_pools[abi.abi]; + assert.ok( + expected, + `Pinned string-pool counts are required on ${abi.abi}`, + ); + assert.equal( + aot.string_pool.package_uri_count_at_least, + expected.package_uri_count_at_least, + ); + assert.equal( + aot.string_pool.dart_uri_count_at_least, + expected.dart_uri_count_at_least, + ); + process.stdout.write( + `[ok] ${abi.abi}: exact pinned package and Dart URI counts\n`, + ); + } else { + process.stdout.write( + `[unverified] ${abi.abi}: exact string-pool counts need --expectations JSON\n`, + ); + } + process.stdout.write( + `[ok] ${abi.abi}: four symbols, bounded AOT headers, agreeing hashes and string pool\n`, + ); + } + if (options.plainApk !== null) { + const plain = resolve(options.plainApk); + const negative = await run("identify_flutter_build", { path: plain }); + assert.deepEqual(negative.target, { + path: plain, + ...(await hashStableFile(plain)), + }); + assert.equal(negative.flutter_detected, false); + assert.equal(negative.coverage, "complete"); + } + assert.deepEqual( + await hashStableFile(apk), + identity, + "Verification must preserve its input", + ); +} finally { + // Server shutdown retries retained provider cleanup and must settle before success. + try { + await client.close(); + await transport.close(); + assert.deepEqual( + await readdir(root.path), + [], + "Provider snapshots must be released before verification succeeds", + ); + } finally { + await root.close(); + } +} +process.stdout.write( + "all asserted Flutter CLI/MCP checks passed; unverified facets are reported above\n", +); + +async function run(operation, input) { + const contract = toolContract(operation); + const args = [ + entrypoint, + operation.replaceAll("_", "-"), + input.path, + "--json", + ]; + if (input.abi !== undefined) args.push("--abi", input.abi); + const { stdout } = await execute(process.execPath, args, { + env: environment, + timeout: 60_000, + maxBuffer: 8 * 1024 * 1024, + }); + const cli = contract.outputSchema.parse(JSON.parse(stdout)); + const response = await client.callTool( + { name: operation, arguments: input }, + { timeout: 60_000 }, + ); + assert.notEqual(response.isError, true, mcpTextValue(response)); + const mcp = contract.outputSchema.parse(JSON.parse(mcpTextValue(response))); + assert.deepEqual(cli.normalized_result, mcp.normalized_result); + assert.deepEqual(cli.subject, mcp.subject); + assert.equal(cli.subject.local_path, input.path); + assert.equal(cli.subject.digest.sha256, cli.normalized_result.target.sha256); + assert.deepEqual(cli.limitations, mcp.limitations); + process.stdout.write( + `[ok] ${operation}${input.abi === undefined ? "" : ` ${input.abi}`}: CLI/MCP Evidence parity\n`, + ); + return cli.normalized_result; +} + +function parseArguments(argv) { + const parsed = { help: false, apk: null, plainApk: null, expectations: null }; + const names = { + "--apk": "apk", + "--plain-apk": "plainApk", + "--expectations": "expectations", + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") parsed.help = true; + else if ( + Object.hasOwn(names, argument) && + typeof argv[index + 1] === "string" && + !argv[index + 1].startsWith("--") + ) { + parsed[names[argument]] = argv[++index]; + } else throw new Error(`Unknown argument or missing value: ${argument}`); + } + return parsed; +} diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 76c5cafbb8..ac825bc049 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -453,6 +453,8 @@ const workflowAvailabilityFor = ({ remediation: "Run process capture on a supported Linux or macOS host.", }; if (kind === "application") return { reason: "available", remediation: null }; + if (kind === "flutter-provider") + return { reason: "available", remediation: null }; const electron = electronProviderAvailability(name, kind, policy); if (electron !== null) return electron; if (kind === "runtime-provider") diff --git a/src/application/flutter/FlutterBuildAnalysisPort.ts b/src/application/flutter/FlutterBuildAnalysisPort.ts new file mode 100644 index 0000000000..2a52d060ef --- /dev/null +++ b/src/application/flutter/FlutterBuildAnalysisPort.ts @@ -0,0 +1,23 @@ +import type { + AnalysisExecution, + ExecutionOptions, + ProviderAvailability, +} from "../AnalysisProvider.js"; +import type { FlutterRequest } from "../../domain/flutter/flutterBuildAnalysis.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Result } from "../../domain/result.js"; + +/** + * Flutter build-identification boundary by pure APK parsing. + * Implementation details belong to the provider adapter. + */ +export interface FlutterBuildAnalysisPort { + /** Pure parsing; always available. */ + inspectAvailability(signal?: AbortSignal): Promise; + /** The provider holds no long-lived resources; safe to call always. */ + close(): Promise; + execute( + request: FlutterRequest, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/flutter/FlutterBuildAnalysisService.test.ts b/src/application/flutter/FlutterBuildAnalysisService.test.ts new file mode 100644 index 0000000000..1ecf31483a --- /dev/null +++ b/src/application/flutter/FlutterBuildAnalysisService.test.ts @@ -0,0 +1,93 @@ +import { expect, it } from "vitest"; + +import { FlutterBuildAnalysisService } from "./FlutterBuildAnalysisService.js"; +import type { FlutterBuildAnalysisPort } from "./FlutterBuildAnalysisPort.js"; +import { createAnalysisExecution } from "../AnalysisProvider.js"; +import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { parseEvidence } from "../../domain/evidence.js"; +import { ok } from "../../domain/result.js"; + +const identity = { + id: "flutter", + name: "Flutter build identification", + version: null, +}; + +const execution = createAnalysisExecution( + { + target: { path: "/targets/app.apk", bytes: 10, sha256: "a".repeat(64) }, + flutter_detected: true, + abis: [], + coverage: "complete", + }, + identity, +); + +const refusing: FlutterBuildAnalysisPort = { + async inspectAvailability() { + throw new Error("must not probe"); + }, + async close() {}, + async execute() { + throw new Error("must not execute"); + }, +}; + +const malformedSelections: readonly unknown[] = [ + { path: "/targets/app.apk", extra: true }, + { path: 7 }, + {}, + { path: "/targets/app.apk", abi: "not a locale" }, +]; +it.each(malformedSelections)("refuses %j at admission", async (input) => { + const settled = await new FlutterBuildAnalysisService(refusing).execute( + "identify_flutter_build", + input, + ); + expect(settled.ok).toBe(false); + if (settled.ok) return; + expect(settled.error._tag).toBe("AnalysisInputError"); +}); + +it("passes the validated request to the provider and seals Evidence", async () => { + const seen: unknown[] = []; + const recording: FlutterBuildAnalysisPort = { + async inspectAvailability() { + return { status: "available", code: null, reason: null, diagnostics: {} }; + }, + async close() {}, + async execute(request) { + seen.push(request); + return ok(execution); + }, + }; + const result = await new FlutterBuildAnalysisService(recording).execute( + "identify_flutter_build", + { path: "/targets/app.apk" }, + ); + expect(seen).toEqual([ + { + operation: "identify_flutter_build", + input: { path: "/targets/app.apk" }, + }, + ]); + if (!result.ok) throw new Error(result.error.message); + const evidence = parseEvidence(result.value); + expect(evidence.provider).toEqual(identity); + expect(evidence.confidence).toBe("observed"); +}); + +it("settles an already-aborted call as cancellation before execution", async () => { + const controller = new AbortController(); + controller.abort(); + const settled = await new FlutterBuildAnalysisService(refusing).execute( + "identify_flutter_build", + { path: "/targets/app.apk" }, + { + signal: controller.signal, + }, + ); + expect(settled.ok).toBe(false); + if (settled.ok) return; + expect(settled.error).toBeInstanceOf(AnalysisCancelledError); +}); diff --git a/src/application/flutter/FlutterBuildAnalysisService.ts b/src/application/flutter/FlutterBuildAnalysisService.ts new file mode 100644 index 0000000000..13ddfbba4e --- /dev/null +++ b/src/application/flutter/FlutterBuildAnalysisService.ts @@ -0,0 +1,31 @@ +import type { FlutterBuildAnalysisPort } from "./FlutterBuildAnalysisPort.js"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import { + flutterRequestSchema, + type FlutterOperation, +} from "../../domain/flutter/flutterBuildAnalysis.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { executeProviderOperation } from "../ProviderOperationExecution.js"; +import type { Result } from "../../domain/result.js"; + +/** Shared CLI/MCP admission, execution, and Evidence composition for Flutter. */ +export class FlutterBuildAnalysisService { + constructor(readonly provider: FlutterBuildAnalysisPort) {} + + /** Run one caller-selected Flutter request and retain target identity inline. */ + execute( + operation: FlutterOperation, + input: unknown, + options?: ExecutionOptions, + ): Promise> { + return executeProviderOperation( + operation, + input, + options, + flutterRequestSchema, + this.provider, + "observed", + ); + } +} diff --git a/src/artifacts/RegularFileAdmission.test.ts b/src/artifacts/RegularFileAdmission.test.ts index 818e75ef17..17926799a1 100644 --- a/src/artifacts/RegularFileAdmission.test.ts +++ b/src/artifacts/RegularFileAdmission.test.ts @@ -1,4 +1,4 @@ -import type { Stats } from "node:fs"; +import type { BigIntStats, Stats } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import { afterEach, expect, it, vi } from "vitest"; @@ -6,6 +6,7 @@ import { NonRegularFileReadError, openRegularFile, RegularFileAdmissionFailure, + sameRegularFileState, } from "../filesystem/RegularFile.js"; const { mockOpen } = vi.hoisted(() => ({ mockOpen: vi.fn() })); @@ -66,3 +67,24 @@ it.each(["cancellation after open", "non-regular metadata", "stat failure"])( else expect(cause.cause).toBe(cancellation); }, ); + +it("compares available nanosecond timestamps without rounding away source changes", () => { + const before = { + dev: 1n, + ino: 2n, + size: 3n, + mode: 0o100400n, + mtimeMs: 10n, + ctimeMs: 20n, + mtimeNs: 10_000_001n, + ctimeNs: 20_000_001n, + isFile: () => true, + } as BigIntStats; + expect(sameRegularFileState(before, { ...before })).toBe(true); + expect( + sameRegularFileState(before, { ...before, mtimeNs: before.mtimeNs + 1n }), + ).toBe(false); + expect( + sameRegularFileState(before, { ...before, ctimeNs: before.ctimeNs + 1n }), + ).toBe(false); +}); diff --git a/src/cli.ts b/src/cli.ts index 0ad7613d89..36c05f6c98 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,6 +21,7 @@ import { registerManagedCommands } from "./cli/managedCommands.js"; import { registerFirmwareCommands } from "./cli/firmwareCommands.js"; import { registerAndroidCommands } from "./cli/androidCommands.js"; import { registerApktoolCommands } from "./cli/apktoolCommands.js"; +import { registerFlutterCommands } from "./cli/flutterCommands.js"; import { registerJebCommands } from "./cli/jebCommands.js"; import { registerAdbCommands } from "./cli/adbCommands.js"; import { registerEvidenceCommands } from "./cliEvidenceCommands.js"; @@ -98,6 +99,7 @@ export const createCli = ( registerManagedCommands(cli, logger, analysis.runProviderAnalysis); registerAndroidCommands(cli, logger, environment); registerApktoolCommands(cli, logger, environment); + registerFlutterCommands(cli, logger); registerJebCommands(cli, logger, environment); registerAdbCommands(cli, logger, environment); registerFirmwareCommands(cli, logger, environment); diff --git a/src/cli/flutterCommands.ts b/src/cli/flutterCommands.ts new file mode 100644 index 0000000000..a1be8e6487 --- /dev/null +++ b/src/cli/flutterCommands.ts @@ -0,0 +1,97 @@ +import { z } from "incur"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js"; +import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js"; +import { createFlutterBuildAnalysisProvider } from "../composition/flutter.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { FlutterOperation } from "../domain/flutter/flutterBuildAnalysis.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; + +/** One flutter command: CLI shape and the request it produces. */ +interface FlutterCommandSpec { + readonly command: string; + readonly description: string; + readonly argumentSchema: z.ZodObject; + readonly optionSchema?: z.ZodObject; + readonly operation: FlutterOperation; + readonly toInput: ( + arguments_: Record, + options: Record, + ) => unknown; +} + +/** Expose the same explicit Flutter requests through one-shot CLI commands. */ +export const registerFlutterCommands = ( + cli: CliInstance, + logger: Logger, + provider: FlutterBuildAnalysisPort = createFlutterBuildAnalysisProvider(), +): void => { + const specs: readonly FlutterCommandSpec[] = [ + { + command: CLI_COMMANDS.identifyFlutterBuild, + description: + "Identify a Flutter build's Dart snapshot hash and engine facts by pure APK parsing", + argumentSchema: z.object({ + path: z.string().min(1).describe("Local APK file to inspect"), + }), + operation: "identify_flutter_build", + toInput: (arguments_) => arguments_, + }, + { + command: CLI_COMMANDS.inspectDartAot, + description: + "Inspect one ABI's Dart AOT snapshot: sections, headers, and the string pool", + argumentSchema: z.object({ + path: z + .string() + .min(1) + .describe("Local APK carrying a Flutter payload"), + }), + optionSchema: z.object({ + abi: z + .string() + .min(2) + .max(16) + .optional() + .describe("ABI under lib/ to inspect; defaults to the preferred one"), + }), + operation: "inspect_dart_aot", + toInput: (arguments_, options) => ({ + path: arguments_.path, + ...(options.abi === undefined ? {} : { abi: options.abi }), + }), + }, + ]; + for (const spec of specs) { + cli.command(spec.command, { + description: spec.description, + ...(spec.optionSchema === undefined + ? { args: spec.argumentSchema } + : { args: spec.argumentSchema, options: spec.optionSchema }), + run: ({ args, options }) => + withCommandCancellation((signal) => + logCliCommand(logger, spec.command, async () => { + const settled = await new FlutterBuildAnalysisService( + provider, + ).execute(spec.operation, spec.toInput(args, options ?? {}), { + signal, + }); + try { + await provider.close(); + } catch (cause) { + if (cause instanceof AnalysisError) + return projectAnalysisError(cause); + throw cause; + } + return settled.ok + ? settled.value + : projectAnalysisError(settled.error); + }), + ), + }); + } +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 3994100af8..8fa0505f1b 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -52,6 +52,8 @@ export const CLI_COMMANDS = Object.freeze({ inspectFirmwareRegions: "inspect-firmware-regions", extractFirmware: "extract-firmware", inspectAndroidPackage: "inspect-android-package", + identifyFlutterBuild: "identify-flutter-build", + inspectDartAot: "inspect-dart-aot", inspectApktoolClient: "inspect-apktool-client", decodeAndroidResources: "decode-android-resources", inspectAdbClient: "inspect-adb-client", diff --git a/src/composition/flutter.ts b/src/composition/flutter.ts new file mode 100644 index 0000000000..75013f77a8 --- /dev/null +++ b/src/composition/flutter.ts @@ -0,0 +1,6 @@ +import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js"; +import { FlutterBuildProvider } from "../flutter/FlutterBuildProvider.js"; + +/** Construct one Flutter provider; it needs no external tools. */ +export const createFlutterBuildAnalysisProvider = + (): FlutterBuildAnalysisPort => new FlutterBuildProvider(); diff --git a/src/contracts/flutter/flutterToolContracts.ts b/src/contracts/flutter/flutterToolContracts.ts new file mode 100644 index 0000000000..9a663d4921 --- /dev/null +++ b/src/contracts/flutter/flutterToolContracts.ts @@ -0,0 +1,45 @@ +import { + flutterInputSchemas, + flutterResultSchemas, +} from "../../domain/flutter/flutterBuildAnalysis.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "../toolEffects.js"; + +/** Flutter build identification by pure APK parsing. */ +export const FLUTTER_TOOL_CONTRACTS = [ + { + name: "identify_flutter_build", + ...toolContractMetadata("identify_flutter_build"), + description: + "Identify a Flutter app's build facts by pure parsing: the Dart VM snapshot hash from every lib//libapp.so AOT section (vm and isolate, cross-checked), per-ABI library digests, the engine library's GNU build-id, and any labeled Dart version or toolchain strings libflutter.so carries. The snapshot hash is the artifact that maps to a Dart SDK release through an external dataset; REA performs no lookup. No engine, no execution, no external tools.", + kind: "flutter-provider", + inputSchema: flutterInputSchemas.identify_flutter_build, + outputSchema: evidenceResultOf(flutterResultSchemas.identify_flutter_build), + examples: [ + { + title: "Identify a Flutter build", + input: { path: "/targets/Example.apk" }, + }, + ], + }, + { + name: "inspect_dart_aot", + ...toolContractMetadata("inspect_dart_aot"), + description: + "Inspect one ABI's Dart AOT snapshot inside an APK: the four snapshot symbols located through the ELF dynamic symbol table with their offsets, sizes, and section headers (magic, kind, length), the snapshot hash, and a bounded string-pool projection from the isolate data section (package: source URIs, dart: SDK URIs, identifier-like tokens) exposing the app's dependency structure. String-pool entries are byte-pattern observations, not deserialized cluster semantics; typed class and function recovery needs an SDK-specific parser. No engine, no execution.", + kind: "flutter-provider", + inputSchema: flutterInputSchemas.inspect_dart_aot, + outputSchema: evidenceResultOf(flutterResultSchemas.inspect_dart_aot), + examples: [ + { + title: "Inspect the preferred ABI's AOT snapshot", + input: { path: "/targets/Example.apk" }, + }, + { + title: "Inspect one ABI explicitly", + input: { path: "/targets/Example.apk", abi: "arm64-v8a" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContractTypes.ts b/src/contracts/toolContractTypes.ts index 832660d04c..0ba2ca321c 100644 --- a/src/contracts/toolContractTypes.ts +++ b/src/contracts/toolContractTypes.ts @@ -26,6 +26,7 @@ export const TOOL_KINDS = [ "managed-provider", "android-provider", "apktool-provider", + "flutter-provider", "jeb-provider", "adb-provider", "firmware-provider", diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index fb0b7d6608..8725f30408 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -9,6 +9,7 @@ import { MANAGED_TOOL_CONTRACTS } from "./managed/managedToolContracts.js"; import { FIRMWARE_TOOL_CONTRACTS } from "./firmware/firmwareToolContracts.js"; import { ANDROID_TOOL_CONTRACTS } from "./android/androidToolContracts.js"; import { APKTOOL_TOOL_CONTRACTS } from "./apktool/apktoolToolContracts.js"; +import { FLUTTER_TOOL_CONTRACTS } from "./flutter/flutterToolContracts.js"; import { JEB_TOOL_CONTRACTS } from "./jeb/jebToolContracts.js"; import { ADB_TOOL_CONTRACTS } from "./adb/adbToolContracts.js"; import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managed/managedWorkflowToolContracts.js"; @@ -39,6 +40,7 @@ export const TOOL_CONTRACTS = [ ...MANAGED_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, ...APKTOOL_TOOL_CONTRACTS, + ...FLUTTER_TOOL_CONTRACTS, ...JEB_TOOL_CONTRACTS, ...ADB_TOOL_CONTRACTS, ...FIRMWARE_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index bc9632740b..151c7d5f49 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -27,6 +27,9 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { "decodes bytecode in REA's bundled worker without executing it", inspect_wasm_artifact: "WABT validates and decompiles the module without executing it", + identify_flutter_build: + "identifies the build through execution-free APK parsing", + inspect_dart_aot: "inspects snapshot bytes without executing them", inspect_analysis_view: "reports retained WASM execution/network states without performing execution", inspect_managed_artifact: "reads metadata without executing target code", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 8169e10e90..11a268c0e9 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -167,6 +167,14 @@ export const TOOL_EFFECTS: Readonly> = { launchesProcess: true, writesFilesystem: true, }), + identify_flutter_build: effects({ + mutatesSession: true, + writesFilesystem: true, + }), + inspect_dart_aot: effects({ + mutatesSession: true, + writesFilesystem: true, + }), inspect_apktool_client: effects({ mutatesSession: true, launchesProcess: true, diff --git a/src/domain/flutter/flutterBuildAnalysis.ts b/src/domain/flutter/flutterBuildAnalysis.ts new file mode 100644 index 0000000000..068149f68f --- /dev/null +++ b/src/domain/flutter/flutterBuildAnalysis.ts @@ -0,0 +1,173 @@ +import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; +import { digestSchema } from "../digests.js"; + +/** + * Caller intent for Flutter build identification. This family is pure + * parsing: it reads an APK's `lib//libapp.so` and `libflutter.so` + * directly and runs no engine, tool, or device. + */ +export const flutterInputSchemas = { + identify_flutter_build: z.strictObject({ + path: localPathStringSchema.describe("Local APK file to inspect"), + }), + inspect_dart_aot: z.strictObject({ + path: localPathStringSchema.describe( + "Local APK file carrying a Flutter AOT payload", + ), + abi: z + .string() + .min(2) + .max(16) + .regex( + /^[a-z0-9_\x2d]+$/u, + "ABI name as it appears under lib/, e.g. arm64-v8a", + ) + .optional() + .describe( + "ABI to inspect; defaults to arm64-v8a, x86_64, x86, armeabi-v7a, then remaining ABIs alphabetically", + ), + }), +} as const; + +/** Supported Flutter-backed operations. */ +export type FlutterOperation = keyof typeof flutterInputSchemas; +/** Validated Flutter requests carry their selected operation. */ +export type FlutterRequest = { + [Name in FlutterOperation]: { + operation: Name; + input: z.infer<(typeof flutterInputSchemas)[Name]>; + }; +}[FlutterOperation]; + +/** Validate operation and input together so their types remain correlated. */ +export const flutterRequestSchema = z.discriminatedUnion("operation", [ + z.strictObject({ + operation: z.literal("identify_flutter_build"), + input: flutterInputSchemas.identify_flutter_build, + }), + z.strictObject({ + operation: z.literal("inspect_dart_aot"), + input: flutterInputSchemas.inspect_dart_aot, + }), +]); + +/** One hardware ABI's Flutter payload inside the APK. */ +export const flutterResultSchemas = { + identify_flutter_build: z.strictObject({ + target: z.strictObject({ + path: localPathStringSchema, + bytes: z.number().int().nonnegative(), + sha256: digestSchema, + }), + flutter_detected: z.boolean(), + abis: z.array( + z.strictObject({ + abi: z.string().min(1), + libapp: z.strictObject({ + present: z.boolean(), + bytes: z.number().int().nonnegative().nullable(), + sha256: digestSchema.nullable(), + /** Supported ELF header with table extents inside the library; table contents are not validated. */ + elf_header_valid: z.boolean().nullable(), + /** Number of Dart snapshot sections found (vm + isolate). */ + snapshot_hash_sources: z.number().int().nonnegative(), + /** Distinct hashes found; more than one means the sections disagree. */ + snapshot_hash_candidates: z.array( + z.string().regex(/^[0-9a-f]{32}$/u), + ), + /** The single hash when every snapshot section agrees. */ + snapshot_hash: z + .string() + .regex(/^[0-9a-f]{32}$/u) + .nullable(), + }), + libflutter: z.strictObject({ + present: z.boolean(), + bytes: z.number().int().nonnegative().nullable(), + sha256: digestSchema.nullable(), + build_id: z + .string() + .regex(/^[0-9a-f]{8,128}$/u) + .nullable() + .describe( + "The single GNU build ID when ELF note coverage is complete and unambiguous", + ), + build_id_candidates: z + .array(z.string().regex(/^[0-9a-f]{8,128}$/u)) + .max(64) + .describe( + "Distinct GNU build IDs observed in ELF-owned note ranges; conflicting IDs remain explicit", + ), + dart_version: z + .string() + .nullable() + .describe( + "Labeled 'Dart VM version' string, when a build carries one", + ), + toolchain_lines: z.array(z.string().min(1)), + /** Dart labels omitted by grammar or single-version projection bounds. */ + dart_version_capped: z.boolean(), + /** Toolchain labels omitted by grammar or projection bounds. */ + toolchain_capped: z.boolean(), + }), + }), + ), + coverage: z.enum(["complete", "partial"]), + }), + inspect_dart_aot: z.strictObject({ + target: z.strictObject({ + path: localPathStringSchema, + bytes: z.number().int().nonnegative(), + sha256: digestSchema, + }), + abi: z.string().min(1), + libapp: z.strictObject({ + coverage: z + .enum(["complete", "partial"]) + .describe( + "Coverage of ELF symbols, independent data headers, AOT kind, extents and agreeing hashes", + ), + bytes: z.number().int().nonnegative(), + sha256: digestSchema, + snapshot_hash: z + .string() + .regex(/^[0-9a-f]{32}$/u) + .nullable(), + /** Distinct hashes found; more than one means the sections disagree. */ + snapshot_hash_candidates: z.array(z.string().regex(/^[0-9a-f]{32}$/u)), + /** Distinct exported data headers with a valid hash at the header's fixed offset. */ + snapshot_hash_sources: z.number().int().nonnegative(), + /** Snapshot symbols read from the dynamic symbol table. */ + sections: z.array( + z.strictObject({ + name: z.string().min(1), + offset: z.number().int().nonnegative(), + size: z.number().int().nonnegative(), + magic_valid: z.boolean(), + kind: z.number().int().nonnegative().nullable(), + /** Raw declared snapshot length, excluding the four magic bytes. */ + header_length: z.number().int().nonnegative().nullable(), + }), + ), + }), + string_pool: z.strictObject({ + coverage: z + .enum(["complete", "partial"]) + .describe( + "Coverage of the bounded printable-run projection; independent of snapshot header validity", + ), + /** Distinct package: source URIs, sorted, bounded. */ + package_uris: z.array(z.string().min(1)), + package_uri_count_at_least: z.number().int().nonnegative(), + /** Distinct dart: SDK URIs, sorted, bounded. */ + dart_uris: z.array(z.string().min(1)), + dart_uri_count_at_least: z.number().int().nonnegative(), + /** Identifier-like tokens, bounded; includes incidental matches. */ + class_like_tokens: z.array(z.string().min(1)), + class_like_token_count_at_least: z.number().int().nonnegative(), + printable_run_count: z.number().int().nonnegative(), + }), + coverage: z.enum(["complete", "partial"]), + }), +} as const; diff --git a/src/filesystem/RegularFile.ts b/src/filesystem/RegularFile.ts index a5c2391cfc..a3037c7281 100644 --- a/src/filesystem/RegularFile.ts +++ b/src/filesystem/RegularFile.ts @@ -95,5 +95,8 @@ export const sameRegularFileState = ( expected.ino === observed.ino && expected.size === observed.size && expected.mode === observed.mode && - expected.mtimeMs === observed.mtimeMs && - expected.ctimeMs === observed.ctimeMs; + ("mtimeNs" in expected && "mtimeNs" in observed + ? expected.mtimeNs === observed.mtimeNs && + expected.ctimeNs === observed.ctimeNs + : expected.mtimeMs === observed.mtimeMs && + expected.ctimeMs === observed.ctimeMs); diff --git a/src/flutter/DartElf.test.ts b/src/flutter/DartElf.test.ts new file mode 100644 index 0000000000..b2b777de36 --- /dev/null +++ b/src/flutter/DartElf.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; + +import { readElfSymbols, readSnapshotHeader } from "./DartElf.js"; +import { + REFERENCE_HASH, + buildElf64Image, + buildStrippedElf64Image, + snapshotSection, +} from "./FlutterBuild.fixture.js"; + +const readElfSymbol = async (bytes: Buffer, name: string) => { + const result = await readElfSymbols(bytes, [name]); + return result.symbols[0] === undefined + ? { failure: result.failures[0]! } + : { symbol: result.symbols[0] }; +}; + +/** + * Build a synthetic ELF64 little-endian image with one PT_LOAD segment + * and a dynamic symbol table naming the Dart snapshot sections. This keeps + * the golden tests self-contained instead of reading the author's disk. + */ +const GALLERY_HASH = REFERENCE_HASH; + +const imageWithSections = (): Buffer => { + const elf = buildElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x400, size: 52 }, + { name: "_kDartIsolateSnapshotData", value: 0x800, size: 52 }, + ]); + const section = snapshotSection(GALLERY_HASH); + return Buffer.concat([ + elf, + Buffer.alloc(0x400 - elf.length), + section, + Buffer.alloc(0x800 - 0x400 - section.length), + section, + ]); +}; + +/** The same layout without section headers: symbols live in PT_DYNAMIC. */ +const strippedImageWithSections = (): Buffer => { + const elf = buildStrippedElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x400, size: 52 }, + { name: "_kDartIsolateSnapshotData", value: 0x800, size: 52 }, + ]); + const section = snapshotSection(GALLERY_HASH); + return Buffer.concat([ + elf, + Buffer.alloc(0x400 - elf.length), + section, + Buffer.alloc(0x800 - 0x400 - section.length), + section, + ]); +}; + +describe("readElfSymbol", () => { + it("locates snapshot symbols in a synthetic ELF64 image", async () => { + const image = imageWithSections(); + const isolateData = await readElfSymbol(image, "_kDartIsolateSnapshotData"); + if ("failure" in isolateData) throw new Error(isolateData.failure.reason); + expect(isolateData.symbol.offset).toBe(0x800); + expect(isolateData.symbol.size).toBe(52); + const header = readSnapshotHeader(image, isolateData.symbol.offset); + expect(header.magicValid).toBe(true); + expect(header.kind).toBe(3); + }); + + it("refuses a non-ELF image instead of guessing", async () => { + const result = await readElfSymbol(Buffer.from("not an elf at all"), "x"); + expect("failure" in result && result.failure.reason).toBe( + "not an ELF image", + ); + }); + + it("refuses an unknown symbol", async () => { + const result = await readElfSymbol(buildElf64Image([]), "_kDartAbsent"); + expect("failure" in result).toBe(true); + }); + + it("locates snapshot symbols in a section-stripped ET_DYN image", async () => { + const image = strippedImageWithSections(); + const isolateData = await readElfSymbol(image, "_kDartIsolateSnapshotData"); + if ("failure" in isolateData) throw new Error(isolateData.failure.reason); + expect(isolateData.symbol.offset).toBe(0x800); + expect(isolateData.symbol.size).toBe(52); + const header = readSnapshotHeader(image, isolateData.symbol.offset); + expect(header.magicValid).toBe(true); + expect(header.kind).toBe(3); + }); + + it("refuses a stripped image whose PT_DYNAMIC carries no symbols", async () => { + // Drop the PT_DYNAMIC program header by leaving only PT_LOAD. + const image = strippedImageWithSections(); + image.writeUInt16LE(1, 0x38); // e_phnum + const result = await readElfSymbol(image, "_kDartVmSnapshotData"); + expect("failure" in result && result.failure.reason).toBe( + "symbol _kDartVmSnapshotData is not in the dynamic symbol table", + ); + }); + + it("keeps refusing a stripped image that is not ET_DYN", async () => { + const image = buildStrippedElf64Image([]); + image.writeUInt16LE(1, 0x10); // ET_REL: the dynamic fallback must not apply + const result = await readElfSymbol(image, "_kDartVmSnapshotData"); + expect("failure" in result && result.failure.reason).toBe( + "ELF section header table is missing or out of bounds", + ); + }); + + it.each([false, true])( + "ignores undefined entries with loadable values (stripped: %s)", + async (stripped) => { + const image = stripped + ? strippedImageWithSections() + : imageWithSections(); + const table = stripped + ? Number( + image.readBigUInt64LE( + Number(image.readBigUInt64LE(64 + 56 + 8)) + 8, + ), + ) + : Number( + image.readBigUInt64LE( + Number(image.readBigUInt64LE(0x28)) + 64 + 0x18, + ), + ); + for (const sectionIndex of [ + 0, + 0xfff2, + 0xffff, + ...(stripped ? [] : [0x1234]), + ]) { + image.writeUInt16LE(sectionIndex, table + 6); + expect( + await readElfSymbol(image, "_kDartVmSnapshotData"), + ).toHaveProperty("failure"); + expect( + await readElfSymbol(image, "_kDartIsolateSnapshotData"), + ).toMatchObject({ symbol: { offset: 0x800, size: 52 } }); + } + }, + ); +}); + +describe("ELF symbol-table ownership", () => { + it.each([false, true])( + "keeps a symbol terminator inside dynstr (stripped: %s)", + async (stripped) => { + const image = stripped + ? strippedImageWithSections() + : imageWithSections(); + const name = "_kDartVmSnapshotData"; + if (stripped) { + const dynamic = Number(image.readBigUInt64LE(64 + 56 + 8)); + image.writeBigUInt64LE(BigInt(name.length + 1), dynamic + 2 * 16 + 8); // DT_STRSZ, leading NUL included. + } else { + const shoff = Number(image.readBigUInt64LE(0x28)); + image.writeBigUInt64LE(BigInt(name.length + 1), shoff + 2 * 64 + 0x20); + } + expect((await readElfSymbols(image, [name])).symbols).toEqual([]); + }, + ); + + it("uses DT_HASH's count when strings precede symbols instead of reading following payload", async () => { + const image = strippedImageWithSections(); + const dynamic = Number(image.readBigUInt64LE(64 + 56 + 8)); + const symbols = Number(image.readBigUInt64LE(dynamic + 8)); + image.copy(image, 0x300, symbols, symbols + 48); + image.writeBigUInt64LE(0x300n, dynamic + 8); + const hash = Number(image.readBigUInt64LE(dynamic + 4 * 16 + 8)); + image.writeUInt32LE(1, hash + 4); // Only the VM entry belongs to dynsym. + const result = await readElfSymbols(image, [ + "_kDartVmSnapshotData", + "_kDartIsolateSnapshotData", + ]); + expect(result.symbols).toEqual([ + { name: "_kDartVmSnapshotData", offset: 0x400, size: 52 }, + ]); + expect(result.failures).toContainEqual({ + reason: + "symbol _kDartIsolateSnapshotData is not in the dynamic symbol table", + }); + image.writeBigUInt64LE(0x60000001n, dynamic + 4 * 16); // No recognized count table. + expect( + (await readElfSymbols(image, ["_kDartVmSnapshotData"])).failures, + ).toContainEqual({ + reason: "Stripped ELF has no DT_HASH or DT_GNU_HASH symbol count", + }); + }); + + it("bounds GNU hash chains and resolves both requested symbols in one inventory", async () => { + const image = strippedImageWithSections(); + const dynamic = Number(image.readBigUInt64LE(64 + 56 + 8)); + const hash = Number(image.readBigUInt64LE(dynamic + 4 * 16 + 8)); + image.writeBigUInt64LE(0x6ffffef5n, dynamic + 4 * 16); + image.fill(0, hash, hash + 32); + image.writeUInt32LE(1, hash); // nbuckets + image.writeUInt32LE(1, hash + 4); // symoffset + image.writeUInt32LE(1, hash + 8); // bloom words + image.writeUInt32LE(1, hash + 24); // bucket: last chain begins at symbol 1 + image.writeUInt32LE(1, hash + 28); // chain terminator; count is 2 + const names = ["_kDartVmSnapshotData", "_kDartIsolateSnapshotData"]; + expect( + (await readElfSymbols(image, names)).symbols.map((symbol) => symbol.name), + ).toEqual(names); + image.writeUInt32LE(0xffffffff, hash + 24); + expect((await readElfSymbols(image, names)).symbols).toEqual([]); + }); +}); + +describe("readSnapshotHeader", () => { + it("reads a synthesized data header", async () => { + const header = readSnapshotHeader(snapshotSection(GALLERY_HASH), 0); + expect(header.magicValid).toBe(true); + expect(header.kind).toBe(3); + }); + + it("reports nulls for sections without the data magic", async () => { + const header = readSnapshotHeader(Buffer.from([0, 0, 0, 0, 0, 0, 0, 0]), 0); + expect(header).toEqual({ + magicValid: false, + kind: null, + headerLength: null, + hash: null, + complete: false, + }); + }); +}); diff --git a/src/flutter/DartElf.ts b/src/flutter/DartElf.ts new file mode 100644 index 0000000000..48285737df --- /dev/null +++ b/src/flutter/DartElf.ts @@ -0,0 +1,506 @@ +import { Buffer } from "node:buffer"; +import { setImmediate as yieldToEventLoop } from "node:timers/promises"; +import { + SNAPSHOT_HASH_LENGTH, + SNAPSHOT_HASH_OFFSET, + SNAPSHOT_HASH_PATTERN, +} from "./FlutterPayloadScan.js"; + +/** Bounds-checked little-endian reader over one loaded buffer. */ +class BoundedReader { + constructor(private readonly bytes: Buffer) {} + + u8(offset: number): number | null { + return offset < this.bytes.length ? this.bytes.readUInt8(offset) : null; + } + + u16(offset: number): number | null { + return offset + 2 <= this.bytes.length + ? this.bytes.readUInt16LE(offset) + : null; + } + + u32(offset: number): number | null { + return offset + 4 <= this.bytes.length + ? this.bytes.readUInt32LE(offset) + : null; + } + + u64(offset: number): number | null { + const low = this.u32(offset); + const high = this.u32(offset + 4); + if (low === null || high === null || high > 0x001fffff) return null; + return high * 0x1_0000_0000 + low; + } + + cString(offset: number, tableEnd: number): string | null { + if (offset < 0 || offset >= tableEnd || tableEnd > this.bytes.length) + return null; + const end = this.bytes + .subarray(offset, Math.min(tableEnd, offset + 257)) + .indexOf(0); + return end < 0 + ? null + : this.bytes.subarray(offset, offset + end).toString("latin1"); + } +} + +export interface ElfSymbol { + readonly name: string; + /** File offset mapped from the virtual address through PT_LOAD. */ + readonly offset: number; + readonly size: number; +} + +export interface ElfParseFailure { + readonly reason: string; +} + +const SHT_DYNSYM = 11; +const PT_LOAD = 1; +const PT_DYNAMIC = 2; +/** Position-independent executable images carry PT_DYNAMIC. */ +const ET_DYN = 3; +const DT_NULL = 0; +const DT_STRTAB = 5; +const DT_SYMTAB = 6; +const DT_STRSZ = 10; +const DT_SYMENT = 11; + +/** ELF32 needs 52 bytes, ELF64 needs 64; retain at most this prefix. */ +export const ELF_HEADER_MAX_BYTES = 64; + +/** + * Decode the supported ELF header and validate its table extents against + * the complete image size. The buffer may contain only the header prefix; + * this does not validate table contents or symbol availability. + */ +export const readElfHeader = ( + bytes: Buffer, + totalBytes: number = bytes.length, +) => { + const reader = new BoundedReader(bytes); + const magic = reader.u32(0); + if (magic !== 0x464c457f) return { failure: { reason: "not an ELF image" } }; + const elfClass = reader.u8(4); + if (elfClass !== 1 && elfClass !== 2) + return { failure: { reason: `unsupported ELF class ${String(elfClass)}` } }; + const is64 = elfClass === 2; + const headerBytes = is64 ? ELF_HEADER_MAX_BYTES : 52; + if ( + bytes.length < headerBytes || + !Number.isSafeInteger(totalBytes) || + totalBytes < headerBytes + ) + return { failure: { reason: "ELF header is truncated" } }; + if (reader.u8(5) !== 1) + return { failure: { reason: "unsupported ELF byte order" } }; + if (reader.u8(6) !== 1 || reader.u32(0x14) !== 1) + return { failure: { reason: "unsupported ELF version" } }; + if (reader.u16(is64 ? 0x34 : 0x28) !== headerBytes) + return { failure: { reason: "unsupported ELF header size" } }; + const e_type = reader.u16(0x10); + if (e_type !== 1 && e_type !== 2 && e_type !== ET_DYN) + return { failure: { reason: "unsupported ELF type" } }; + const e_shoff = is64 ? reader.u64(0x28) : reader.u32(0x20); + const e_shentsize = reader.u16(is64 ? 0x3a : 0x2e); + const e_shnum = reader.u16(is64 ? 0x3c : 0x30); + const e_phoff = is64 ? reader.u64(0x20) : reader.u32(0x1c); + const e_phentsize = reader.u16(is64 ? 0x36 : 0x2a); + const e_phnum = reader.u16(is64 ? 0x38 : 0x2c); + const tableFits = ( + offset: number | null, + width: number | null, + count: number | null, + minimumWidth: number, + ): boolean => + offset !== null && + width !== null && + count !== null && + offset >= headerBytes && + offset <= totalBytes && + width >= minimumWidth && + count > 0 && + count <= Math.floor((totalBytes - offset) / width); + const MIN_SHENT = is64 ? 64 : 40; + // Section-stripped ET_DYN images keep their exported symbols reachable + // through PT_DYNAMIC instead of section headers; every other shape still + // needs a usable section header table. + // A nonzero shoff with shnum=0 uses extended numbering, which this + // parser does not support; it must not masquerade as a stripped image. + const dynamicFallback = e_type === ET_DYN && e_shoff === 0 && e_shnum === 0; + if ( + e_shoff === null || + e_shentsize === null || + e_shnum === null || + (!dynamicFallback && !tableFits(e_shoff, e_shentsize, e_shnum, MIN_SHENT)) + ) + return { + failure: { + reason: "ELF section header table is missing or out of bounds", + }, + }; + const MIN_PHENT = is64 ? 56 : 32; + if ( + e_phoff === null || + e_phentsize === null || + e_phnum === null || + e_phnum === 0xffff || + !tableFits(e_phoff, e_phentsize, e_phnum, MIN_PHENT) + ) + return { + failure: { + reason: "ELF program header table is missing or out of bounds", + }, + }; + + return { + header: { + is64, + dynamicFallback, + e_shoff, + e_shentsize, + e_shnum, + e_phoff, + e_phentsize, + e_phnum, + }, + }; +}; + +export interface ElfSymbolInventory { + readonly symbols: readonly ElfSymbol[]; + readonly failures: readonly ElfParseFailure[]; +} + +/** Decode the ELF tables once and resolve only the requested definitions. */ +export const readElfSymbols = async ( + bytes: Buffer, + symbolNames: readonly string[], + signal?: AbortSignal, +): Promise => { + signal?.throwIfAborted(); + const failure = (reason: string): ElfSymbolInventory => ({ + symbols: [], + failures: [{ reason }], + }); + const parsed = readElfHeader(bytes); + if (parsed.failure !== undefined) return failure(parsed.failure.reason); + const { + is64, + dynamicFallback, + e_shoff, + e_shentsize, + e_shnum, + e_phoff, + e_phentsize, + e_phnum, + } = parsed.header; + const reader = new BoundedReader(bytes); + const entrySize = is64 ? 24 : 16; + const segments: { vaddr: number; offset: number; filesz: number }[] = []; + let dynamicSegment: { offset: number; filesz: number } | null = null; + for (let index = 0; index < e_phnum; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const base = e_phoff + index * e_phentsize; + const type = reader.u32(base); + if (type !== PT_LOAD && type !== PT_DYNAMIC) continue; + const vaddr = is64 ? reader.u64(base + 0x10) : reader.u32(base + 8); + const offset = is64 ? reader.u64(base + 8) : reader.u32(base + 4); + const filesz = is64 ? reader.u64(base + 0x20) : reader.u32(base + 0x10); + if ( + vaddr === null || + offset === null || + filesz === null || + offset > bytes.length || + filesz > bytes.length - offset + ) + return failure(`ELF segment ${String(index)} is not file-backed`); + if (type === PT_LOAD) segments.push({ vaddr, offset, filesz }); + else if (dynamicSegment !== null) + return failure("Multiple PT_DYNAMIC segments are ambiguous"); + else dynamicSegment = { offset, filesz }; + } + const toOffset = (vaddr: number, size: number): number | null => { + const segment = segments.find( + (candidate) => + vaddr >= candidate.vaddr && + vaddr - candidate.vaddr <= candidate.filesz && + size <= candidate.filesz - (vaddr - candidate.vaddr), + ); + return segment === undefined + ? null + : segment.offset + vaddr - segment.vaddr; + }; + if (segments.length === 0) + return failure("ELF image has no PT_LOAD segments"); + const tables: { + offset: number; + count: number; + stringOffset: number; + stringSize: number; + }[] = []; + if (!dynamicFallback) { + for (let index = 0; index < e_shnum; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const header = e_shoff + index * e_shentsize; + if (reader.u32(header + 4) !== SHT_DYNSYM) continue; + const offset = is64 + ? reader.u64(header + 0x18) + : reader.u32(header + 0x10); + const size = is64 ? reader.u64(header + 0x20) : reader.u32(header + 0x14); + const width = is64 + ? reader.u64(header + 0x38) + : reader.u32(header + 0x24); + const link = reader.u32(header + (is64 ? 0x28 : 0x18)); + if ( + offset === null || + size === null || + width !== entrySize || + offset > bytes.length || + size > bytes.length - offset || + size % entrySize !== 0 + ) + return failure("Dynamic symbol table is malformed"); + if (link === null || link >= e_shnum) + return failure("Dynamic symbol string section link is invalid"); + const stringHeader = e_shoff + link * e_shentsize; + const stringOffset = is64 + ? reader.u64(stringHeader + 0x18) + : reader.u32(stringHeader + 0x10); + const stringSize = is64 + ? reader.u64(stringHeader + 0x20) + : reader.u32(stringHeader + 0x14); + if ( + reader.u32(stringHeader + 4) !== 3 || + stringOffset === null || + stringSize === null || + stringOffset > bytes.length || + stringSize > bytes.length - stringOffset + ) + return failure( + "Dynamic symbol strings are out of bounds or not a string table", + ); + tables.push({ + offset, + count: size / entrySize, + stringOffset, + stringSize, + }); + } + } else if (dynamicSegment !== null) { + const tags = new Map(); + const dynamicEntrySize = is64 ? 16 : 8; + let terminated = false; + for ( + let base = dynamicSegment.offset; + base + dynamicEntrySize <= dynamicSegment.offset + dynamicSegment.filesz; + base += dynamicEntrySize + ) { + if ((base - dynamicSegment.offset) % (4096 * dynamicEntrySize) === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const tag = is64 ? reader.u64(base) : reader.u32(base); + const value = is64 ? reader.u64(base + 8) : reader.u32(base + 4); + if (tag === null || value === null) + return failure("Dynamic entry is not representable"); + if (tag === DT_NULL) { + terminated = true; + break; + } + if ( + ![DT_SYMTAB, DT_STRTAB, DT_STRSZ, DT_SYMENT, 4, 0x6ffffef5].includes( + tag, + ) + ) + continue; + if (tags.has(tag) && tags.get(tag) !== value) + return failure("Conflicting dynamic table tags"); + tags.set(tag, value); + } + const sym = tags.get(DT_SYMTAB); + const str = tags.get(DT_STRTAB); + const stringSize = tags.get(DT_STRSZ); + if ( + !terminated || + sym === undefined || + str === undefined || + stringSize === undefined || + tags.get(DT_SYMENT) !== entrySize + ) + return failure( + "Stripped ELF lacks a complete dynamic symbol-table description", + ); + // DT_HASH's nchain is the dynsym count. GNU hash instead records the + // first hashed index and bounded buckets/chains. No payload-to-EOF walk. + let count: number; + const sysv = tags.get(4); + const gnu = tags.get(0x6ffffef5); + if (sysv !== undefined) { + const at = toOffset(sysv, 8); + if (at === null) return failure("DT_HASH header is not file-backed"); + const buckets = reader.u32(at)!; + count = reader.u32(at + 4)!; + if (toOffset(sysv, 8 + 4 * (buckets + count)) === null) + return failure("DT_HASH table is truncated"); + } else if (gnu !== undefined) { + const at = toOffset(gnu, 16); + if (at === null) return failure("DT_GNU_HASH header is not file-backed"); + const buckets = reader.u32(at)!; + const firstSymbol = reader.u32(at + 4)!; + const bloomWords = reader.u32(at + 8)!; + const prefixBytes = 16 + bloomWords * (is64 ? 8 : 4) + buckets * 4; + if ( + buckets === 0 || + bloomWords === 0 || + toOffset(gnu, prefixBytes) === null + ) + return failure("DT_GNU_HASH buckets are malformed"); + let last = 0; + const bucketsAt = at + 16 + bloomWords * (is64 ? 8 : 4); + for (let index = 0; index < buckets; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const value = reader.u32(bucketsAt + index * 4)!; + if (value !== 0 && value < firstSymbol) + return failure("DT_GNU_HASH bucket precedes its first symbol"); + last = Math.max(last, value); + } + count = firstSymbol; + if (last !== 0) { + let index = last; + while (true) { + if ((index - last) % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const span = prefixBytes + (index - firstSymbol + 1) * 4; + if (toOffset(gnu, span) === null) + return failure("DT_GNU_HASH chain is unterminated"); + const chain = reader.u32(at + span - 4)!; + index += 1; + if ((chain & 1) !== 0) { + count = index; + break; + } + } + } + } else + return failure("Stripped ELF has no DT_HASH or DT_GNU_HASH symbol count"); + const offset = toOffset(sym, count * entrySize); + const stringOffset = toOffset(str, stringSize); + if (offset === null || stringOffset === null) + return failure("Dynamic symbol or string table is not file-backed"); + tables.push({ offset, count, stringOffset, stringSize }); + } + const wanted = new Set(symbolNames); + const symbols = new Map(); + const ambiguous = new Set(); + const failures: ElfParseFailure[] = []; + for (const table of tables) { + for (let index = 0; index < table.count; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const base = table.offset + index * entrySize; + const nameOffset = reader.u32(base)!; + const sectionIndex = reader.u16(base + (is64 ? 6 : 14))!; + const defined = + sectionIndex === 0xfff1 || + (sectionIndex > 0 && + sectionIndex < 0xff00 && + (dynamicFallback || sectionIndex < e_shnum)); + if (!defined || nameOffset >= table.stringSize) continue; + const name = reader.cString( + table.stringOffset + nameOffset, + table.stringOffset + table.stringSize, + ); + if (name === null || !wanted.has(name)) continue; + const value = is64 ? reader.u64(base + 8) : reader.u32(base + 4); + const size = is64 ? reader.u64(base + 16) : reader.u32(base + 8); + if (value === null || size === null) { + failures.push({ reason: `Symbol ${name} has unrepresentable bounds` }); + continue; + } + const offset = toOffset(value, size); + if (offset === null) { + failures.push({ reason: `Symbol ${name} has no file-backed range` }); + continue; + } + const previous = symbols.get(name); + if ( + previous !== undefined && + (previous.offset !== offset || previous.size !== size) + ) + ambiguous.add(name); + else symbols.set(name, { name, offset, size }); + } + } + for (const name of ambiguous) { + symbols.delete(name); + failures.push({ reason: `Symbol ${name} has conflicting definitions` }); + } + for (const name of wanted) + if (!symbols.has(name)) + failures.push({ + reason: `symbol ${name} is not in the dynamic symbol table`, + }); + return { symbols: [...symbols.values()], failures }; +}; + +/** Read one Dart snapshot section header from its file offset. */ +export const readSnapshotHeader = ( + bytes: Buffer, + offset: number, +): { + magicValid: boolean; + kind: number | null; + headerLength: number | null; + hash: string | null; + complete: boolean; +} => { + const reader = new BoundedReader(bytes); + // The Dart magic bytes f5 f5 dc dc read little-endian. Kind and length + // fields are only meaningful in data-section headers; sections without + // the magic (instructions carry a different layout) report nulls. + const magic = reader.u32(offset); + if (magic !== 0xdcdcf5f5) + return { + magicValid: false, + kind: null, + headerLength: null, + hash: null, + complete: false, + }; + const headerLength = reader.u64(offset + 4); + const kind = reader.u64(offset + 12); + const candidate = bytes + .subarray( + offset + SNAPSHOT_HASH_OFFSET, + offset + SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH, + ) + .toString("latin1"); + const hash = SNAPSHOT_HASH_PATTERN.test(candidate) ? candidate : null; + // Dart's stored length excludes the four magic bytes and includes the + // 20-byte fixed header and the version hash. Aligned data may follow it + // inside the exported symbol, but the serialized snapshot must fit. + // Kind 3 is FullAOT in the supported Dart 2.14 layout; enum values change + // across SDK versions, so other kinds remain uninterpreted observations. + const complete = + headerLength !== null && + headerLength >= SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH - 4 && + headerLength <= bytes.length - offset - 4 && + kind === 3 && + hash !== null; + return { magicValid: true, kind, headerLength, hash, complete }; +}; diff --git a/src/flutter/ElfBuildId.ts b/src/flutter/ElfBuildId.ts new file mode 100644 index 0000000000..3c04d0473a --- /dev/null +++ b/src/flutter/ElfBuildId.ts @@ -0,0 +1,158 @@ +import type { FileHandle } from "node:fs/promises"; +import { setImmediate as yieldToEventLoop } from "node:timers/promises"; +import { + readElfHeader, + ELF_HEADER_MAX_BYTES, + type ElfParseFailure, +} from "./DartElf.js"; + +const MAX_METADATA_BYTES = 8 * 1024 * 1024; +const MAX_BUILD_IDS = 64; + +export interface ElfBuildIds { + readonly candidates: readonly string[]; + readonly failures: readonly ElfParseFailure[]; +} + +/** Read only ELF-declared PT_NOTE/SHT_NOTE ranges from one owned engine image. */ +export const readElfBuildIds = async ( + file: FileHandle, + totalBytes: number, + signal: AbortSignal | undefined, +): Promise => { + const candidates = new Set(); + const failure = (reason: string): ElfBuildIds => ({ + candidates: [...candidates], + failures: [{ reason }], + }); + let examined = 0; + const read = async (offset: number, size: number): Promise => { + signal?.throwIfAborted(); + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(size) || + offset < 0 || + size < 0 || + size > totalBytes - offset || + size > MAX_METADATA_BYTES - examined + ) + return null; + examined += size; + const bytes = Buffer.alloc(size); + let copied = 0; + while (copied < size) { + const result = await file.read( + bytes, + copied, + size - copied, + offset + copied, + ); + signal?.throwIfAborted(); + if (result.bytesRead === 0) return null; + copied += result.bytesRead; + } + return bytes; + }; + const prefix = await read(0, Math.min(totalBytes, ELF_HEADER_MAX_BYTES)); + if (prefix === null) return failure("ELF header could not be read"); + const parsed = readElfHeader(prefix, totalBytes); + if (parsed.failure !== undefined) return failure(parsed.failure.reason); + const h = parsed.header; + const ranges = new Map(); + for (const table of [ + { + offset: h.e_phoff, + width: h.e_phentsize, + count: h.e_phnum, + program: true, + }, + { + offset: h.e_shoff, + width: h.e_shentsize, + count: h.e_shnum, + program: false, + }, + ]) { + const bytes = await read(table.offset, table.width * table.count); + if (bytes === null) + return failure( + "ELF note tables exceed the 8 MiB metadata budget or are truncated", + ); + for (let index = 0; index < table.count; index += 1) { + if (index % 4096 === 0) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + } + const base = index * table.width; + if ( + bytes.readUInt32LE(base + (table.program ? 0 : 4)) !== + (table.program ? 4 : 7) + ) + continue; + const offsetAt = + base + (table.program ? (h.is64 ? 8 : 4) : h.is64 ? 0x18 : 0x10); + const sizeAt = + base + (table.program ? (h.is64 ? 0x20 : 0x10) : h.is64 ? 0x20 : 0x14); + const offset = h.is64 + ? Number(bytes.readBigUInt64LE(offsetAt)) + : bytes.readUInt32LE(offsetAt); + const size = h.is64 + ? Number(bytes.readBigUInt64LE(sizeAt)) + : bytes.readUInt32LE(sizeAt); + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(size) || + size > totalBytes - offset + ) + return failure("ELF note range is not file-backed"); + ranges.set(`${offset}:${size}`, { offset, size }); + } + } + const align4 = (value: number): number => Math.ceil(value / 4) * 4; + for (const range of ranges.values()) { + const notes = await read(range.offset, range.size); + if (notes === null) + return failure( + "ELF notes exceed the 8 MiB metadata budget or are truncated", + ); + let yieldedAt = 0; + for (let at = 0; at < notes.length;) { + if (at - yieldedAt >= 64 * 1024) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + yieldedAt = at; + } + if (notes.length - at < 12) { + if (notes.subarray(at).some((byte) => byte !== 0)) + return failure("ELF note header is truncated"); + break; + } + const namesz = notes.readUInt32LE(at); + const descsz = notes.readUInt32LE(at + 4); + const type = notes.readUInt32LE(at + 8); + const descriptor = at + 12 + align4(namesz); + const end = descriptor + descsz; + if (end > notes.length) + return failure("ELF note descriptor exceeds its declared note range"); + if ( + namesz === 4 && + type === 3 && + notes.subarray(at + 12, at + 16).equals(Buffer.from("GNU\0")) + ) { + if (descsz < 4 || descsz > 64) + return failure( + "GNU build-id descriptor exceeds the 4–64 byte projection", + ); + const candidate = notes.subarray(descriptor, end).toString("hex"); + if (!candidates.has(candidate) && candidates.size === MAX_BUILD_IDS) + return failure( + "ELF build-id candidates exceed the 64-value evidence budget", + ); + candidates.add(candidate); + } + at = align4(end); + } + } + signal?.throwIfAborted(); + return { candidates: [...candidates], failures: [] }; +}; diff --git a/src/flutter/FlutterBuild.fixture.ts b/src/flutter/FlutterBuild.fixture.ts new file mode 100644 index 0000000000..635ea04fb0 --- /dev/null +++ b/src/flutter/FlutterBuild.fixture.ts @@ -0,0 +1,267 @@ +import { Buffer } from "node:buffer"; + +/** The reference Gallery snapshot hash for synthetic fixtures. */ +export const REFERENCE_HASH = "65817c30a78bb44c3dc3771876b6010a"; + +/** A synthetic AOT image with four symbols, two data sections, and a pool. */ +export const aotFixtureImage = (hash: string = REFERENCE_HASH): Buffer => { + const elf = buildElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x1000, size: 52 }, + { name: "_kDartVmSnapshotInstructions", value: 0x1800, size: 16 }, + { name: "_kDartIsolateSnapshotData", value: 0x2000, size: 0x600 }, + { name: "_kDartIsolateSnapshotInstructions", value: 0x2600, size: 16 }, + ]); + const pool = Buffer.concat([ + Buffer.from( + "package:adaptive_breakpoints/src/adaptive_breakpoints.dart\x00", + ), + Buffer.from("package:animations/src/fade_scale_transition.dart\x00"), + Buffer.from("dart:_http\x00dart:_http/crypto.dart\x00"), + Buffer.from("AdaptiveBreakpoint\x00FadeScaleTransition\x00"), + ]); + const vmSection = snapshotSection(hash); + const isolateSection = Buffer.concat([ + snapshotSection(hash), + Buffer.from([0]), + pool, + ]); + return Buffer.concat([ + elf, + Buffer.alloc(0x1000 - elf.length), + vmSection, + Buffer.alloc(0x1800 - 0x1000 - vmSection.length), + Buffer.alloc(16), + Buffer.alloc(0x2000 - 0x1800 - 16), + isolateSection, + Buffer.alloc(0x600 - isolateSection.length), + Buffer.alloc(16), + ]); +}; + +export const buildElf64Image = ( + symbols: ReadonlyArray<{ name: string; value: number; size: number }>, +): Buffer => { + const ELF_HEADER = 64; + const PHENT = 56; + const PHNUM = 1; + const SHENT = 64; + const phoff = ELF_HEADER; + const shoffAligned = phoff + PHENT * PHNUM; + // Place payload data after the three section headers so nothing overlaps. + const dynsymOff = shoffAligned + 3 * SHENT + 16; + const dynstrOff = dynsymOff + symbols.length * 24; + const image = Buffer.alloc(dynstrOff + 256); + // ELF header + image.writeUInt32LE(0x464c457f, 0); + image[4] = 2; // ELFCLASS64 + image[5] = 1; // little-endian + image[6] = 1; // ELF identification version + image.writeUInt16LE(3, 0x10); + image.writeUInt16LE(0x003e, 0x12); // e_machine x86_64 + image.writeUInt32LE(1, 0x14); // e_version + image.writeUInt16LE(ELF_HEADER, 0x34); + image.writeBigUInt64LE(BigInt(phoff), 0x20); + image.writeBigUInt64LE(BigInt(shoffAligned), 0x28); + image.writeUInt16LE(PHENT, 0x36); + image.writeUInt16LE(PHNUM, 0x38); + image.writeUInt16LE(SHENT, 0x3a); + image.writeUInt16LE(2, 0x3c); // e_shnum: null + dynsym + // Program header: one PT_LOAD mapping identity + const ph = phoff; + image.writeUInt32LE(1, ph); // PT_LOAD + image.writeBigUInt64LE(BigInt(0), ph + 8); // offset + image.writeBigUInt64LE(BigInt(0), ph + 0x10); // vaddr + image.writeBigUInt64LE( + BigInt( + Math.max( + image.length, + ...symbols.map((symbol) => symbol.value + symbol.size), + ), + ), + ph + 0x20, + ); // p_filesz covers the materialized payload + // Section headers + const sh = shoffAligned; + // null section stays zero + const dyn = sh + SHENT; + image.writeUInt32LE(11, dyn + 4); // SHT_DYNSYM + image.writeBigUInt64LE(BigInt(dynsymOff), dyn + 0x18); + image.writeBigUInt64LE(BigInt(symbols.length * 24), dyn + 0x20); + image.writeBigUInt64LE(BigInt(24), dyn + 0x38); + image.writeUInt32LE(2, dyn + 0x28); // sh_link -> strtab section index + // dynstr section header + const str = dyn + SHENT; + image.writeUInt32LE(3, str + 4); // SHT_STRTAB + image.writeBigUInt64LE(BigInt(dynstrOff), str + 0x18); + image.writeBigUInt64LE(BigInt(256), str + 0x20); + image.writeUInt16LE(3, 0x3c); // e_shnum includes strtab + // dynstr: leading NUL then names + let strCursor = dynstrOff + 1; + const nameOffsets = symbols.map((symbol) => { + image.write(symbol.name, strCursor, "latin1"); + const at = strCursor - dynstrOff; + strCursor += symbol.name.length + 1; + return at; + }); + // dynsym entries + symbols.forEach((symbol, index) => { + const base = dynsymOff + index * 24; + image.writeUInt32LE(nameOffsets[index]!, base); + image.writeBigUInt64LE(BigInt(symbol.value), base + 8); + image.writeBigUInt64LE(BigInt(symbol.size), base + 16); + image.writeUInt16LE(1, base + 6); // Defined export (not SHN_UNDEF). + image[base + 4] = 0x10; // GLOBAL FUNC + }); + return image; +}; + +/** + * Build a section-stripped ELF64 image (e_shnum 0, ET_DYN) whose exported + * symbols are reachable only through the PT_DYNAMIC tag-value pairs, the + * way release toolchains ship `libapp.so`. + */ +export const buildStrippedElf64Image = ( + symbols: ReadonlyArray<{ name: string; value: number; size: number }>, +): Buffer => { + const ELF_HEADER = 64; + const PHENT = 56; + const PHNUM = 2; + const dynamicEntry = 16; + // DT_SYMTAB, DT_STRTAB, DT_STRSZ, DT_SYMENT, DT_HASH, DT_NULL. + const DYNAMIC_ENTRIES = 6; + const phoff = ELF_HEADER; + const dynamicOff = phoff + PHENT * PHNUM; + const dynsymOff = dynamicOff + DYNAMIC_ENTRIES * dynamicEntry; + const dynstrOff = dynsymOff + symbols.length * 24; + const strtabBytes = 256; + const hashOff = dynstrOff + strtabBytes; + const image = Buffer.alloc(hashOff + 8 + 4 * (1 + symbols.length)); + image.writeUInt32LE(1, hashOff); + image.writeUInt32LE(symbols.length, hashOff + 4); + // ELF header + image.writeUInt32LE(0x464c457f, 0); + image[4] = 2; // ELFCLASS64 + image[5] = 1; // little-endian + image[6] = 1; + image.writeUInt16LE(3, 0x10); + image.writeUInt16LE(0x003e, 0x12); // e_machine x86_64 + image.writeUInt32LE(1, 0x14); + image.writeUInt16LE(ELF_HEADER, 0x34); + image.writeBigUInt64LE(BigInt(phoff), 0x20); + image.writeUInt16LE(PHENT, 0x36); + image.writeUInt16LE(PHNUM, 0x38); + // e_shoff, e_shentsize, and e_shnum stay zero: no section headers exist. + // PT_LOAD identity-maps the file so symbol vaddrs resolve to offsets. + const load = phoff; + image.writeUInt32LE(1, load); + image.writeBigUInt64LE(BigInt(0), load + 8); + image.writeBigUInt64LE(BigInt(0), load + 0x10); + image.writeBigUInt64LE( + BigInt( + Math.max( + image.length, + ...symbols.map((symbol) => symbol.value + symbol.size), + ), + ), + load + 0x20, + ); // p_filesz covers fixture symbols + // PT_DYNAMIC locates the symbol table for stripped images. + const dyn = phoff + PHENT; + image.writeUInt32LE(2, dyn); + image.writeBigUInt64LE(BigInt(dynamicOff), dyn + 8); // p_offset + image.writeBigUInt64LE(BigInt(dynamicOff), dyn + 0x10); // p_vaddr + image.writeBigUInt64LE(BigInt(DYNAMIC_ENTRIES * dynamicEntry), dyn + 0x20); + const pairs: ReadonlyArray = [ + [6, dynsymOff], + [5, dynstrOff], + [10, strtabBytes], + [11, 24], + [4, hashOff], + ]; + pairs.forEach(([tag, value], index) => { + const base = dynamicOff + index * dynamicEntry; + image.writeBigUInt64LE(BigInt(tag), base); + image.writeBigUInt64LE(BigInt(value), base + 8); + }); + // The final entry stays zero: DT_NULL terminates the walk. + // dynstr: leading NUL then names + let strCursor = dynstrOff + 1; + const nameOffsets = symbols.map((symbol) => { + image.write(symbol.name, strCursor, "latin1"); + const at = strCursor - dynstrOff; + strCursor += symbol.name.length + 1; + return at; + }); + symbols.forEach((symbol, index) => { + const base = dynsymOff + index * 24; + image.writeUInt32LE(nameOffsets[index]!, base); + image.writeBigUInt64LE(BigInt(symbol.value), base + 8); + image.writeBigUInt64LE(BigInt(symbol.size), base + 16); + image.writeUInt16LE(1, base + 6); // Defined even when section headers are stripped. + }); + return image; +}; + +/** A minimal Dart snapshot data section carrying a 32-hex hash. */ +export const snapshotSection = (hash: string): Buffer => + Buffer.concat([ + Buffer.from([ + 0xf5, 0xf5, 0xdc, 0xdc, 48, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, + ]), + Buffer.from(hash, "latin1"), + ]); + +/** A real ELF container declaring GNU note ownership, with an unrelated payload prefix. */ +export const engineFixtureImage = (options: { + readonly notes: Buffer; + readonly prefix?: Buffer; + readonly labels?: readonly string[]; + readonly bits?: 32 | 64; + readonly ownership?: "program" | "section" | "both"; +}): Buffer => { + const is64 = options.bits !== 32; + const ownership = options.ownership ?? "program"; + const program = ownership !== "section"; + const section = ownership !== "program"; + const header = is64 ? 64 : 52; + const phent = is64 ? 56 : 32; + const shent = is64 ? 64 : 40; + const noteAt = 0x400; + const labels = Buffer.from(`\0${(options.labels ?? []).join("\0")}\0`); + const image = Buffer.alloc(noteAt + options.notes.length + labels.length); + image.writeUInt32LE(0x464c457f); + image[4] = is64 ? 2 : 1; + image[5] = 1; + image[6] = 1; + image.writeUInt16LE(3, 0x10); + image.writeUInt32LE(1, 0x14); + image.writeUInt16LE(header, is64 ? 0x34 : 0x28); + const word = (value: number, offset: number): void => { + if (is64) image.writeBigUInt64LE(BigInt(value), offset); + else image.writeUInt32LE(value, offset); + }; + word(header, is64 ? 0x20 : 0x1c); + image.writeUInt16LE(phent, is64 ? 0x36 : 0x2a); + image.writeUInt16LE(program ? 2 : 1, is64 ? 0x38 : 0x2c); + image.writeUInt32LE(1, header); // PT_LOAD + word(image.length, header + (is64 ? 0x20 : 0x10)); + if (program) { + const ph = header + phent; + image.writeUInt32LE(4, ph); // PT_NOTE + word(noteAt, ph + (is64 ? 8 : 4)); + word(options.notes.length, ph + (is64 ? 0x20 : 0x10)); + } + if (section) { + word(0x100, is64 ? 0x28 : 0x20); + image.writeUInt16LE(shent, is64 ? 0x3a : 0x2e); + image.writeUInt16LE(2, is64 ? 0x3c : 0x30); + const sh = 0x100 + shent; + image.writeUInt32LE(7, sh + 4); // SHT_NOTE + word(noteAt, sh + (is64 ? 0x18 : 0x10)); + word(options.notes.length, sh + (is64 ? 0x20 : 0x14)); + } + options.prefix?.copy(image, 0x300, 0, 0x100); + options.notes.copy(image, noteAt); + labels.copy(image, noteAt + options.notes.length); + return image; +}; diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts new file mode 100644 index 0000000000..2c088b2c9c --- /dev/null +++ b/src/flutter/FlutterBuildProvider.ts @@ -0,0 +1,1230 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdtemp, open as openFile, rm, stat } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setImmediate as yieldToEventLoop } from "node:timers/promises"; + +import type { + ExecutionOptions, + ProviderAvailability, +} from "../application/AnalysisProvider.js"; +import { + createAnalysisExecution, + type AnalysisExecution, +} from "../application/AnalysisProvider.js"; +import type { FlutterRequest } from "../domain/flutter/flutterBuildAnalysis.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + ArtifactReaderFailure, + type ArtifactEntry, +} from "../artifacts/ArtifactReader.js"; +import { ZipArtifactReader } from "../artifacts/ZipArtifactReader.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { + openRegularFile, + RegularFileAdmissionFailure, + sameRegularFileState, +} from "../filesystem/RegularFile.js"; +import { + MAX_SNAPSHOT_HASH_CANDIDATES, + LabeledStringScanner, + SnapshotHashScanner, +} from "./FlutterPayloadScan.js"; +import { + ELF_HEADER_MAX_BYTES, + readElfHeader, + readElfSymbols, + readSnapshotHeader, +} from "./DartElf.js"; +import { readElfBuildIds } from "./ElfBuildId.js"; + +/** Stable identity for the Flutter build-identification provider. */ +export const FLUTTER_PROVIDER_IDENTITY = { + id: "flutter", + name: "Flutter build identification", + version: null, +} as const; + +const APK_METADATA_READ_MAX_BYTES = 8 * 1024 * 1024; +const LIBRARY_MAX_BYTES = 256 * 1024 * 1024; +/** Cumulative cap across every library entry in one identification. */ +const TOTAL_EXTRACTION_MAX_BYTES = 768 * 1024 * 1024; +/** The APK itself must fit within this budget before any copy. */ +const APK_MAX_BYTES = 2 * 1024 * 1024 * 1024; +// At most 32 ABI projections (two libraries each). With bounded hashes and +// engine labels this keeps expanded result metadata below 512 KiB for ordinary +// ABI names, independently of the archive's compressed-byte budget. +const MAX_FLUTTER_ABIS = 32; +const MAX_LIBRARY_PATH_BYTES = 1024; +const MAX_POOL_URIS = 500; +const MAX_POOL_TOKENS = 2_000; +/** A complete AOT image carries exactly one VM and one isolate section. */ +const SYMBOL_PAIR_COUNT = 2; +const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/u; + +/** + * Stream a local file digest without retaining large files in memory. + * Aborts between chunks so a cancellation cannot run a digest to completion. + */ +const sha256File = async ( + operation: string, + path: string, + signal: AbortSignal | undefined, +): Promise => { + const hash = createHash("sha256"); + const stream = createReadStream(path, signal === undefined ? {} : { signal }); + for await (const chunk of stream) { + if (signal?.aborted === true) + throw new AnalysisCancelledError(operation, { cause: signal.reason }); + hash.update(chunk as Buffer); + } + return hash.digest("hex"); +}; + +/** One owned snapshot root: a read-only copy of the admitted input. */ +interface OwnedSnapshot { + readonly root: string; + readonly path: string; + readonly bytes: number; + readonly sha256: string; +} + +interface LibraryFacts { + readonly libapp: { + present: boolean; + bytes: number | null; + sha256: string | null; + elfHeaderValid: boolean | null; + sources: number; + candidates: readonly string[]; + }; + readonly libflutter: { + present: boolean; + bytes: number | null; + sha256: string | null; + buildId: string | null; + buildIdCandidates: readonly string[]; + buildIdFailures: readonly string[]; + dartVersion: string | null; + dartVersionCapped: boolean; + toolchainLines: readonly string[]; + toolchainCapped: boolean; + }; +} + +const emptyFacts = (): LibraryFacts => ({ + libapp: { + present: false, + bytes: null, + sha256: null, + elfHeaderValid: null, + sources: 0, + candidates: [], + }, + libflutter: { + present: false, + bytes: null, + sha256: null, + buildId: null, + buildIdCandidates: [], + buildIdFailures: [], + dartVersion: null, + dartVersionCapped: false, + toolchainLines: [], + toolchainCapped: false, + }, +}); + +/** Translate a ZIP reader rejection into the typed analysis error algebra. */ +const isCancellation = (cause: unknown): boolean => + cause instanceof AnalysisCancelledError || + (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") || + (cause instanceof Error && cause.name === "AbortError") || + (typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ABORT_ERR"); + +const mapInputFailure = ( + operation: string, + path: string, + cause: unknown, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (isCancellation(cause)) + return new AnalysisCancelledError(operation, { cause }); + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + return new AnalysisAccessDeniedError(operation, path, cause.code, { + cause, + }); + if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") + return new AnalysisResourceConstraintError( + operation, + "memory", + cause.message, + { max_metadata_read_bytes: APK_METADATA_READ_MAX_BYTES }, + { cause }, + ); + const message = cause instanceof Error ? cause.message : String(cause); + return new AnalysisInputError(operation, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `The APK could not be read as a package: ${path}: ${message}`, + }, + ]); +}; + +/** Provider-owned storage failures must never be attributed to caller input. */ +const mapOwnedIoFailure = ( + operation: string, + cause: unknown, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (isCancellation(cause)) + return new AnalysisCancelledError(operation, { cause }); + if ( + cause instanceof Error && + "code" in cause && + typeof cause.code === "string" + ) + return new AnalysisResourceConstraintError( + operation, + "file-size", + `Provider-owned Flutter snapshot storage failed (${cause.code}): ${cause.message}`, + null, + { cause }, + ); + return new AnalysisProtocolError(operation, { cause }); +}; + +/** ZIP format failures describe caller bytes; its descriptor is provider-owned. */ +const mapPackageFailure = ( + operation: string, + path: string, + cause: unknown, +): AnalysisError => + cause instanceof Error && "code" in cause && typeof cause.code === "string" + ? mapOwnedIoFailure(operation, cause) + : mapInputFailure(operation, path, cause); + +interface FlutterLibrary { + readonly entry: ArtifactEntry; + readonly abi: string; + readonly kind: "libapp" | "libflutter"; +} + +/** One inventory owns occurrence ambiguity and metadata expansion for both tools. */ +const collectFlutterLibraries = async ( + reader: ZipArtifactReader, + operation: string, + path: string, + signal: AbortSignal | undefined, +): Promise => { + const libraries: FlutterLibrary[] = []; + const paths = new Set(); + const abis = new Set(); + try { + for await (const entry of reader.entries(signal)) { + if (entry.kind !== "file") continue; + const match = /^lib\/([^/]+)\/(libapp|libflutter)\.so$/u.exec(entry.path); + if (match === null) continue; + if (paths.has(entry.path)) + throw new AnalysisInputError(operation, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: `The selected APK has duplicate Flutter library members: ${path}: ${entry.path}`, + }, + ]); + paths.add(entry.path); + abis.add(match[1]!); + if ( + abis.size > MAX_FLUTTER_ABIS || + Buffer.byteLength(entry.path) > MAX_LIBRARY_PATH_BYTES + ) + throw new AnalysisResourceConstraintError( + operation, + "memory", + "Flutter library inventory exceeds the bounded ABI projection budget", + { + max_flutter_abis: MAX_FLUTTER_ABIS, + max_library_path_bytes: MAX_LIBRARY_PATH_BYTES, + }, + ); + libraries.push({ + entry, + abi: match[1]!, + kind: match[2] === "libapp" ? "libapp" : "libflutter", + }); + } + return libraries; + } catch (cause) { + throw mapPackageFailure(operation, path, cause); + } +}; + +/** The generator owns iterator closure, including early extraction failures. */ +async function* openEntrySafely( + reader: ZipArtifactReader, + entry: ArtifactEntry, + operation: string, + path: string, + signal: AbortSignal | undefined, +): AsyncGenerator { + try { + yield* await reader.open(entry, signal); + } catch (cause) { + throw mapPackageFailure(operation, path, cause); + } +} + +const checkApkSize = (operation: string, bytes: number): void => { + if (bytes > APK_MAX_BYTES) + throw new AnalysisResourceConstraintError( + operation, + "file-size", + `The selected APK exceeds the ${String(APK_MAX_BYTES)}-byte snapshot budget`, + { max_apk_bytes: APK_MAX_BYTES, observed_bytes: bytes }, + ); +}; + +export interface FlutterProviderOptions { + /** Deterministic root removal for tests; production removes recursively. */ + readonly removeRoot?: (path: string) => Promise; +} + +/** + * Flutter build identification by pure parsing: reads `lib//libapp.so` + * and `libflutter.so` through the hardened ZIP reader and reports the Dart + * snapshot hash, GNU build-id, and any labeled version strings verbatim. + * Runs no engine, installs nothing, and performs no external lookups. + */ +export class FlutterBuildProvider { + private readonly ownedRoots = new Map< + string, + Set<{ close(): Promise }> + >(); + private readonly removeRoot: (path: string) => Promise; + private readonly cleanupFailures = new Map(); + + constructor(options: FlutterProviderOptions = {}) { + this.removeRoot = + options.removeRoot ?? + ((path) => rm(path, { recursive: true, force: true })); + } + + /** + * Snapshot the admitted input into a provider-owned, read-only copy so a + * concurrently modified original cannot mix into one observation. The + * digest is taken from the snapshot, and every later read uses the + * snapshot path. + */ + private async ownSnapshot( + operation: string, + path: string, + signal: AbortSignal | undefined, + ): Promise { + let root: string; + try { + root = await mkdtemp(join(tmpdir(), "rea-flutter-")); + } catch (cause) { + throw mapOwnedIoFailure(operation, cause); + } + this.ownedRoots.set(root, new Set()); + const snapshotPath = join(root, "input.apk"); + const readSource = async (read: () => Promise): Promise => { + try { + return await read(); + } catch (cause) { + throw mapInputFailure(operation, path, cause); + } + }; + try { + // Pin the admitted source: capture its identity before the copy and + // revalidate after, so an in-place replacement or swap mid-copy is + // refused instead of publishing mixed bytes under the original path. + // The copy itself is streamed and abort-checked so cancellation + // stops the write promptly instead of draining the whole file. + let source: OwnedFileHandle; + try { + source = this.ownResource( + root, + new OwnedFileHandle( + await openRegularFile(path, { symlinks: "follow", signal }), + ), + ); + } catch (cause) { + if (cause instanceof RegularFileAdmissionFailure) { + this.ownResource(root, cause.owner); + throw mapInputFailure(operation, path, cause.cause); + } + throw mapInputFailure(operation, path, cause); + } + const before = await readSource(() => + source.handle.stat({ bigint: true }), + ); + checkApkSize(operation, Number(before.size)); + const output = this.ownResource( + root, + new OwnedFileHandle(await openFile(snapshotPath, "wx")), + ); + const chunk = Buffer.alloc(64 * 1024); + let copiedBytes = 0; + while (copiedBytes <= Number(before.size)) { + signal?.throwIfAborted(); + const { bytesRead } = await readSource(() => + source.handle.read( + chunk, + 0, + Math.min(chunk.length, Number(before.size) - copiedBytes + 1), + copiedBytes, + ), + ); + signal?.throwIfAborted(); + if (bytesRead === 0) break; + copiedBytes += bytesRead; + if (BigInt(copiedBytes) > before.size) + throw new AnalysisArtifactChangedError( + operation, + path, + "Selected file grew during snapshotting", + ); + await output.handle.writeFile(chunk.subarray(0, bytesRead)); + } + await this.releaseResource(root, output); + const [after, currentPath] = await Promise.all([ + readSource(() => source.handle.stat({ bigint: true })), + stat(path, { bigint: true }).catch((cause: unknown) => { + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + throw mapInputFailure(operation, path, cause); + throw new AnalysisArtifactChangedError( + operation, + path, + "Selected path became unavailable during snapshotting", + { cause }, + ); + }), + ]); + if ( + BigInt(copiedBytes) !== before.size || + !sameRegularFileState(before, after) || + !sameRegularFileState(before, currentPath) + ) + throw new AnalysisArtifactChangedError( + operation, + path, + "Selected file identity or contents changed during snapshotting", + ); + await this.releaseResource(root, source); + await chmod(snapshotPath, 0o400); + const info = await stat(snapshotPath); + const sha256 = await sha256File(operation, snapshotPath, signal); + return { root, path: snapshotPath, bytes: info.size, sha256 }; + } catch (cause) { + await this.discardRoot(root); + if (signal?.aborted === true) + throw new AnalysisCancelledError(operation, { cause }); + throw mapOwnedIoFailure(operation, cause); + } + } + + private readerFor(snapshot: OwnedSnapshot): ZipArtifactReader { + return this.ownResource( + snapshot.root, + new ZipArtifactReader(snapshot.path, "apk", APK_METADATA_READ_MAX_BYTES), + ); + } + + private ownResource }>( + root: string, + resource: T, + ): T { + this.ownedRoots.get(root)!.add(resource); + return resource; + } + + private async releaseResource( + root: string, + resource: { close(): Promise }, + ): Promise { + await resource.close(); + this.ownedRoots.get(root)?.delete(resource); + } + + /** Close every owned handle before removing its root; retain any failure. */ + private async discardRoot(root: string): Promise { + const resources = this.ownedRoots.get(root); + if (resources === undefined) return true; + const attempts = await Promise.allSettled( + [...resources].map((resource) => this.releaseResource(root, resource)), + ); + const failures = attempts.flatMap((attempt) => + attempt.status === "rejected" ? [attempt.reason as unknown] : [], + ); + if (failures.length > 0) { + this.cleanupFailures.set(root, failures); + return false; + } + try { + await this.removeRoot(root); + this.ownedRoots.delete(root); + this.cleanupFailures.delete(root); + return true; + } catch (cause) { + this.cleanupFailures.set(root, [cause]); + return false; + } + } + + /** Pure parsing; the provider is always available. */ + async inspectAvailability(): Promise { + return { + status: "available", + code: null, + reason: null, + diagnostics: { engine: "static-apk-parsing" }, + }; + } + + /** Retain failed snapshot cleanup ownership and retry it on close. */ + async close(): Promise { + const failures: unknown[] = []; + for (const root of [...this.ownedRoots.keys()]) { + if (!(await this.discardRoot(root))) + failures.push( + new AggregateError( + this.cleanupFailures.get(root)!, + `snapshot root retained: ${root}`, + ), + ); + } + if (failures.length > 0) + throw new AggregateError(failures, "Flutter snapshot cleanup failed"); + } + + async execute( + request: FlutterRequest, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted === true) + return err(new AnalysisCancelledError(request.operation)); + const { path } = request.input; + const signal = options?.signal; + let snapshot: OwnedSnapshot | undefined; + let result: Result; + try { + snapshot = await this.ownSnapshot(request.operation, path, signal); + const reader = this.readerFor(snapshot); + const libraries = await collectFlutterLibraries( + reader, + request.operation, + path, + signal, + ); + result = + request.operation === "inspect_dart_aot" + ? await this.inspectDartAot( + path, + request.input.abi, + signal, + snapshot, + reader, + libraries, + ) + : await this.identifyFlutterBuild( + path, + signal, + snapshot, + reader, + libraries, + ); + } catch (cause) { + result = err(mapOwnedIoFailure(request.operation, cause)); + } finally { + if (snapshot !== undefined) await this.discardRoot(snapshot.root); + } + if (signal?.aborted === true) + return err( + new AnalysisCancelledError(request.operation, { cause: signal.reason }), + ); + return result; + } + + private async identifyFlutterBuild( + path: string, + signal: AbortSignal | undefined, + snapshot: OwnedSnapshot, + reader: ZipArtifactReader, + flutterEntries: readonly FlutterLibrary[], + ): Promise> { + const operation = "identify_flutter_build"; + for (const { entry } of flutterEntries) { + if (entry.declaredSize !== null && entry.declaredSize > LIBRARY_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + operation, + "file-size", + `${entry.path} exceeds the library read budget`, + { max_library_bytes: LIBRARY_MAX_BYTES, entry_path: entry.path }, + ), + ); + } + // Preflight: refuse before any I/O when the declared entry sizes + // already exceed the cumulative budget, even though streaming might + // discover smaller actual sizes. + const declaredTotal = flutterEntries.reduce( + (total, { entry }) => total + (entry.declaredSize ?? 0), + 0, + ); + if (declaredTotal > TOTAL_EXTRACTION_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + operation, + "file-size", + `declared library sizes total ${String(declaredTotal)} bytes, exceeding the ${String(TOTAL_EXTRACTION_MAX_BYTES)}-byte cumulative budget before any extraction`, + { + max_total_bytes: TOTAL_EXTRACTION_MAX_BYTES, + declared_total: declaredTotal, + }, + ), + ); + + const facts = new Map(); + let extractedTotal = 0; + let libraryIndex = 0; + for (const { entry, abi, kind } of flutterEntries) { + const stream = openEntrySafely(reader, entry, operation, path, signal); + const hash = createHash("sha256"); + const snapshotScanner = new SnapshotHashScanner(); + const labeledScanner = new LabeledStringScanner(); + const engineFile = + kind === "libflutter" + ? this.ownResource( + snapshot.root, + new OwnedFileHandle( + await openFile( + join(snapshot.root, `engine-${libraryIndex++}.so`), + "wx+", + ), + ), + ) + : null; + let bytes = 0; + const headerPrefix = Buffer.alloc(ELF_HEADER_MAX_BYTES); + let headerBytes = 0; + for await (const chunk of stream) { + if (signal?.aborted === true) + return err( + new AnalysisCancelledError(operation, { + cause: signal.reason, + }), + ); + const view = chunk as Buffer; + headerBytes += view.copy( + headerPrefix, + headerBytes, + 0, + Math.min(view.length, headerPrefix.length - headerBytes), + ); + bytes += view.byteLength; + if (bytes > LIBRARY_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + operation, + "file-size", + `${entry.path} exceeds the ${String(LIBRARY_MAX_BYTES)}-byte read budget while streaming`, + { + max_library_bytes: LIBRARY_MAX_BYTES, + entry_path: entry.path, + }, + ), + ); + hash.update(view); + if (kind === "libapp") snapshotScanner.push(view); + else { + labeledScanner.push(view); + await engineFile!.handle.writeFile(view); + } + // Enforce the cumulative cap inside the chunk loop so refusal + // happens before the remaining bytes are streamed and hashed. + if (extractedTotal + bytes > TOTAL_EXTRACTION_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + operation, + "file-size", + `cumulative library extraction across ${String(flutterEntries.length)} entries exceeds the ${String(TOTAL_EXTRACTION_MAX_BYTES)}-byte budget`, + { max_total_bytes: TOTAL_EXTRACTION_MAX_BYTES }, + ), + ); + } + extractedTotal += bytes; + const digest = hash.digest("hex"); + const current = facts.get(abi) ?? emptyFacts(); + if (kind === "libapp") { + const scan = snapshotScanner.result(); + if (scan.capped) + return err( + new AnalysisResourceConstraintError( + operation, + "memory", + "Snapshot hash candidates exceed the retained evidence budget", + { + max_snapshot_hash_candidates: MAX_SNAPSHOT_HASH_CANDIDATES, + entry_path: entry.path, + }, + ), + ); + facts.set(abi, { + ...current, + libapp: { + present: true, + bytes, + sha256: digest, + elfHeaderValid: + readElfHeader(headerPrefix.subarray(0, headerBytes), bytes) + .header !== undefined, + sources: scan.sources, + candidates: scan.candidates, + }, + }); + } else { + const labeled = labeledScanner.result(); + const buildIds = await readElfBuildIds( + engineFile!.handle, + bytes, + signal, + ); + await this.releaseResource(snapshot.root, engineFile!); + facts.set(abi, { + ...current, + libflutter: { + present: true, + bytes, + sha256: digest, + buildId: + buildIds.failures.length === 0 && buildIds.candidates.length === 1 + ? buildIds.candidates[0]! + : null, + buildIdCandidates: buildIds.candidates, + buildIdFailures: buildIds.failures.map((failure) => failure.reason), + ...labeled, + }, + }); + } + } + const abis = [...facts.entries()] + .map(([abi, value]) => ({ + abi, + libapp: { + present: value.libapp.present, + bytes: value.libapp.bytes, + sha256: value.libapp.sha256, + elf_header_valid: value.libapp.elfHeaderValid, + snapshot_hash_sources: value.libapp.sources, + snapshot_hash_candidates: [...value.libapp.candidates], + snapshot_hash: + value.libapp.candidates.length === 1 + ? value.libapp.candidates[0]! + : null, + }, + libflutter: { + present: value.libflutter.present, + bytes: value.libflutter.bytes, + sha256: value.libflutter.sha256, + build_id: value.libflutter.buildId, + build_id_candidates: [...value.libflutter.buildIdCandidates], + dart_version: value.libflutter.dartVersion, + dart_version_capped: value.libflutter.dartVersionCapped, + toolchain_lines: [...value.libflutter.toolchainLines], + toolchain_capped: value.libflutter.toolchainCapped, + }, + })) + .sort((left, right) => left.abi.localeCompare(right.abi)); + const flutterDetected = abis.some((entry) => entry.libapp.present); + const partial = + abis.some( + (entry) => + (entry.libapp.present && + (entry.libapp.snapshot_hash === null || + entry.libapp.snapshot_hash_sources !== 2 || + entry.libapp.elf_header_valid !== true)) || + entry.libflutter.toolchain_capped || + entry.libflutter.dart_version_capped || + entry.libapp.present !== entry.libflutter.present, + ) || + [...facts.values()].some( + (value) => + value.libflutter.buildIdFailures.length > 0 || + value.libflutter.buildIdCandidates.length > 1, + ); + return ok( + createAnalysisExecution( + { + target: { path, bytes: snapshot.bytes, sha256: snapshot.sha256 }, + flutter_detected: flutterDetected, + abis, + coverage: partial ? "partial" : "complete", + }, + FLUTTER_PROVIDER_IDENTITY, + { + subject: { path, format: "apk", sha256: snapshot.sha256 }, + locations: [{ kind: "artifact-path", path }], + limitations: [ + "The APK is read through an owned read-only snapshot; concurrent changes to the original cannot mix into this observation", + "The snapshot hash identifies a Dart SDK build only against an external mapping dataset; REA performs no lookup", + "Release engine builds often carry no labeled Dart version string; absence is reported, not guessed", + "Engine build IDs come only from ELF-declared PT_NOTE/SHT_NOTE records; conflicting or incompletely examined notes preserve candidates and report partial coverage", + ...[...facts].flatMap(([abi, value]) => [ + ...value.libflutter.buildIdFailures.map( + (reason) => `lib/${abi}/libflutter.so: ${reason}`, + ), + ...(value.libflutter.buildIdCandidates.length > 1 + ? [`lib/${abi}/libflutter.so has conflicting GNU build IDs`] + : []), + ]), + ...abis + .filter((entry) => entry.libflutter.toolchain_capped) + .map( + (entry) => + `lib/${entry.abi}/libflutter.so has toolchain labels omitted by the complete-label grammar or the 4-line projection`, + ), + ...abis + .filter((entry) => entry.libflutter.dart_version_capped) + .map( + (entry) => + `lib/${entry.abi}/libflutter.so has Dart version labels omitted by the complete-label grammar or the single-version projection`, + ), + "The snapshot hash sits 20 bytes after each snapshot magic in the AOT payload; findings are observations of that layout", + "ELF header validity checks the supported header layout and declared table extents; identification does not resolve symbols or validate table contents", + ...abis + .filter( + (entry) => + entry.libapp.present && + entry.libapp.snapshot_hash_sources !== 2, + ) + .map( + (entry) => + `lib/${entry.abi}/libapp.so carries ${String(entry.libapp.snapshot_hash_sources)} snapshot section(s); a complete AOT payload carries two, so the library is truncated or nonstandard`, + ), + ...abis + .filter( + (entry) => + entry.libapp.present && + entry.libapp.elf_header_valid !== true, + ) + .map( + (entry) => + `lib/${entry.abi}/libapp.so has a truncated, malformed, or unsupported ELF header or table extent; byte-scanned hashes remain observations only`, + ), + ...(flutterDetected + ? [] + : [ + "No lib/*/libapp.so was found; the target does not carry a Flutter AOT payload", + ]), + ], + }, + ), + ); + } + + private async inspectDartAot( + path: string, + abi: string | undefined, + signal: AbortSignal | undefined, + snapshot: OwnedSnapshot, + reader: ZipArtifactReader, + libraries: readonly FlutterLibrary[], + ): Promise> { + const candidates = libraries.filter((library) => library.kind === "libapp"); + if (candidates.length === 0) + return err( + new AnalysisUnsupportedTargetError( + "inspect_dart_aot", + path, + "No lib//libapp.so was found; the target carries no Dart AOT payload", + ), + ); + const preference = ["arm64-v8a", "x86_64", "x86", "armeabi-v7a"]; + const selected = + abi === undefined + ? [...candidates].sort( + (left, right) => + (preference.indexOf(left.abi) + 1 || preference.length + 1) - + (preference.indexOf(right.abi) + 1 || preference.length + 1) || + left.abi.localeCompare(right.abi), + )[0]! + : candidates.find((candidate) => candidate.abi === abi); + if (selected === undefined) + return err( + new AnalysisUnsupportedTargetError( + "inspect_dart_aot", + path, + `No libapp.so was found for ABI ${abi ?? "(none)"}; available: ${candidates + .map((candidate) => candidate.abi) + .join(", ")}`, + ), + ); + const declared = selected.entry.declaredSize ?? LIBRARY_MAX_BYTES + 1; + if (declared > LIBRARY_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + "inspect_dart_aot", + "file-size", + `lib/${selected.abi}/libapp.so declares ${String(declared)} bytes, exceeding the ${String(LIBRARY_MAX_BYTES)}-byte read budget`, + { max_library_bytes: LIBRARY_MAX_BYTES }, + ), + ); + const imagePath = join(snapshot.root, "libapp.so"); + const imageHash = createHash("sha256"); + const imageWriter = this.ownResource( + snapshot.root, + new OwnedFileHandle(await openFile(imagePath, "wx")), + ); + { + const stream = openEntrySafely( + reader, + selected.entry, + "inspect_dart_aot", + path, + signal, + ); + let total = 0; + for await (const chunk of stream) { + if (signal?.aborted === true) + return err(new AnalysisCancelledError("inspect_dart_aot")); + const view = chunk as Buffer; + total += view.byteLength; + if (total > LIBRARY_MAX_BYTES) + return err( + new AnalysisResourceConstraintError( + "inspect_dart_aot", + "file-size", + `lib/${selected.abi}/libapp.so exceeds the ${String(LIBRARY_MAX_BYTES)}-byte read budget while streaming`, + { max_library_bytes: LIBRARY_MAX_BYTES }, + ), + ); + imageHash.update(view); + await imageWriter.handle.writeFile(view); + } + } + await this.releaseResource(snapshot.root, imageWriter); + const imageSha256 = imageHash.digest("hex"); + // Allocate exactly the budget-checked size before reading, so the + // buffer cannot exceed the declared library size plus one byte. + const image = Buffer.alloc(declared + 1); + let imageLength = 0; + { + const file = this.ownResource( + snapshot.root, + new OwnedFileHandle(await openFile(imagePath, "r")), + ); + while (imageLength < image.length) { + if (signal?.aborted === true) + return err(new AnalysisCancelledError("inspect_dart_aot")); + const result = await file.handle.read( + image, + imageLength, + image.length - imageLength, + null, + ); + signal?.throwIfAborted(); + if (result.bytesRead === 0) break; + imageLength += result.bytesRead; + } + await this.releaseResource(snapshot.root, file); + } + signal?.throwIfAborted(); + const bounded = image.subarray(0, imageLength); + const symbolNames = [ + "_kDartVmSnapshotData", + "_kDartVmSnapshotInstructions", + "_kDartIsolateSnapshotData", + "_kDartIsolateSnapshotInstructions", + ]; + const sections: { + name: string; + offset: number; + size: number; + magic_valid: boolean; + kind: number | null; + header_length: number | null; + }[] = []; + const dataHeaders: { + name: string; + offset: number; + size: number; + header: ReturnType; + }[] = []; + let isolateData: Buffer | null = null; + const inventory = await readElfSymbols(bounded, symbolNames, signal); + for (const symbol of inventory.symbols) { + const name = symbol.name; + const header = name.endsWith("Data") + ? readSnapshotHeader( + bounded.subarray(symbol.offset, symbol.offset + symbol.size), + 0, + ) + : null; + sections.push({ + name, + offset: symbol.offset, + size: symbol.size, + magic_valid: header?.magicValid ?? false, + kind: header?.kind ?? null, + header_length: header?.headerLength ?? null, + }); + if (header !== null) dataHeaders.push({ ...symbol, header }); + if (name === "_kDartIsolateSnapshotData") + isolateData = image.subarray( + symbol.offset, + symbol.offset + symbol.size, + ); + } + // Each exported data symbol owns one header at its start. Matching + // records elsewhere in a symbol are not evidence for the other + // symbol's hash. Count a physical header only once for aliased exports. + const observedOffsets = new Set(); + let aliasedDataSymbols = false; + const hashes: string[] = []; + for (const section of dataHeaders) { + if (observedOffsets.has(section.offset)) { + aliasedDataSymbols = true; + continue; + } + observedOffsets.add(section.offset); + if (section.header.hash !== null) hashes.push(section.header.hash); + } + const hashScan = { + sources: hashes.length, + candidates: [...new Set(hashes)], + }; + const overlappingDataSymbols = dataHeaders.some((section, index) => + dataHeaders + .slice(0, index) + .some( + (previous) => + previous.offset < section.offset + section.size && + section.offset < previous.offset + previous.size, + ), + ); + if (sections.length === 0) + return err( + new AnalysisUnsupportedTargetError( + "inspect_dart_aot", + path, + `No recognizable Dart snapshot symbols were found: ${inventory.failures.map((failure) => failure.reason).join("; ")}`, + ), + ); + const pool = await projectStringPool(isolateData, signal); + const snapshotPartial = + inventory.failures.length > 0 || + aliasedDataSymbols || + overlappingDataSymbols || + sections.length < symbolNames.length || + dataHeaders.length !== SYMBOL_PAIR_COUNT || + dataHeaders.some((section) => !section.header.complete) || + hashScan.candidates.length !== 1 || + hashScan.sources !== SYMBOL_PAIR_COUNT; + const poolPartial = + isolateData === null || + pool.collectionCapped || + pool.packageUriCount > MAX_POOL_URIS || + pool.dartUriCount > MAX_POOL_URIS || + pool.classLikeTokenCount > MAX_POOL_TOKENS; + return ok( + createAnalysisExecution( + { + target: { path, bytes: snapshot.bytes, sha256: snapshot.sha256 }, + abi: selected.abi, + libapp: { + coverage: snapshotPartial ? "partial" : "complete", + bytes: imageLength, + sha256: imageSha256, + snapshot_hash: + hashScan.candidates.length === 1 ? hashScan.candidates[0]! : null, + snapshot_hash_candidates: [...hashScan.candidates], + snapshot_hash_sources: hashScan.sources, + sections, + }, + string_pool: { + coverage: poolPartial ? "partial" : "complete", + package_uris: pool.packageUris.slice(0, MAX_POOL_URIS), + package_uri_count_at_least: pool.packageUriCount, + dart_uris: pool.dartUris.slice(0, MAX_POOL_URIS), + dart_uri_count_at_least: pool.dartUriCount, + class_like_tokens: pool.classLikeTokens.slice(0, MAX_POOL_TOKENS), + class_like_token_count_at_least: pool.classLikeTokenCount, + printable_run_count: pool.printableRunCount, + }, + coverage: snapshotPartial || poolPartial ? "partial" : "complete", + }, + FLUTTER_PROVIDER_IDENTITY, + { + subject: { path, format: "apk", sha256: snapshot.sha256 }, + locations: [{ kind: "artifact-path", path }], + limitations: [ + "The APK is read through an owned read-only snapshot; concurrent changes to the original cannot mix into this observation", + "Snapshot sections are located through the ELF dynamic symbol table; a payload without exported snapshot symbols is refused rather than guessed", + "Snapshot hash sources are the headers at the start of the VM and isolate data symbols; complete decoding supports kind 3 (the Dart 2.14 FullAOT layout), a valid hash and a declared length that fits its symbol, including the four magic bytes; other kind values remain observed with partial coverage", + "String-pool entries are byte-pattern observations from the isolate data section, not deserialized cluster semantics; typed class and function recovery needs an SDK-specific parser", + "class_like_tokens are identifier-shaped runs and include incidental matches", + "string-pool counts are lower bounds: collection stops at twice each projection bound, and any value beyond that is counted only through the capped flag", + ...inventory.failures.map((failure) => failure.reason), + ...(sections.length < symbolNames.length + ? [ + `Only ${String(sections.length)} of 4 snapshot symbols were present`, + ] + : []), + ...(aliasedDataSymbols + ? [ + "Two or more snapshot data symbols share one header offset; its hash counts as one source instead of two agreeing ones", + ] + : []), + ...(overlappingDataSymbols && !aliasedDataSymbols + ? [ + "Snapshot data symbol ranges overlap; independent VM/isolate coverage is not established", + ] + : []), + ...dataHeaders + .filter((section) => !section.header.complete) + .map( + (section) => + `${section.name} has an incomplete or invalid snapshot header: kind ${String(section.header.kind)} (AOT app requires 3), declared length ${String(section.header.headerLength)} excludes the four magic bytes, symbol size ${String(section.size)}, valid hash ${String(section.header.hash !== null)}`, + ), + ...(pool.collectionCapped + ? [ + "String-pool collection or printable-run capacity was exceeded; counts are lower bounds and overflowed runs are omitted", + ] + : []), + ...(pool.dartUriCount > MAX_POOL_URIS + ? [ + `dart_uris reports ${String(MAX_POOL_URIS)} of at least ${String(pool.dartUriCount)} distinct URIs`, + ] + : []), + ...(pool.packageUriCount > MAX_POOL_URIS + ? [ + `package_uris reports ${String(MAX_POOL_URIS)} of at least ${String(pool.packageUriCount)} distinct URIs`, + ] + : []), + ...(pool.classLikeTokenCount > MAX_POOL_TOKENS + ? [ + `class_like_tokens reports ${String(MAX_POOL_TOKENS)} of at least ${String(pool.classLikeTokenCount)} distinct tokens`, + ] + : []), + ], + }, + ), + ); + } +} + +/** + * Categorize printable runs from one snapshot data section. Collection stops + * admitting new distinct entries once each category reaches its projection + * bound times a slack factor, so allocation and sorting stay proportional to + * the response rather than to an adversarial payload; counts still reflect + * how far scanning proceeded before the collection cap tripped. + */ +const projectStringPool = async ( + section: Buffer | null, + signal: AbortSignal | undefined, +): Promise<{ + packageUris: string[]; + packageUriCount: number; + dartUris: string[]; + dartUriCount: number; + classLikeTokens: string[]; + classLikeTokenCount: number; + printableRunCount: number; + collectionCapped: boolean; +}> => { + const packages = new Set(); + const darts = new Set(); + const tokens = new Set(); + let runCount = 0; + let collectionCapped = false; + const COLLECTION_SLACK = 2; + /** One printable run never materializes beyond this length. */ + const MAX_RUN_BYTES = 1024; + const packageBudget = MAX_POOL_URIS * COLLECTION_SLACK; + const dartBudget = MAX_POOL_URIS * COLLECTION_SLACK; + const tokenBudget = MAX_POOL_TOKENS * COLLECTION_SLACK; + if (section !== null) { + let run = ""; + const consider = (text: string): void => { + if (text.length < 5) return; + runCount += 1; + if (text.startsWith("package:")) { + if (!packages.has(text) && packages.size >= packageBudget) + collectionCapped = true; + else packages.add(text); + } else if (text.startsWith("dart:")) { + if (!darts.has(text) && darts.size >= dartBudget) + collectionCapped = true; + else darts.add(text); + } else if ( + text.length <= 128 && + text.length >= 5 && + IDENTIFIER_PATTERN.test(text) + ) { + if (!tokens.has(text) && tokens.size >= tokenBudget) + collectionCapped = true; + else tokens.add(text); + } + }; + let overflowed = false; + for (let start = 0; start < section.length; start += 64 * 1024) { + await yieldToEventLoop(); + signal?.throwIfAborted(); + for (const byte of section.subarray(start, start + 64 * 1024)) { + if (byte >= 0x20 && byte < 0x7f) { + // A single unbounded run (an adversarial NUL-free section) never + // materializes: growth stops at the cap and the entire run is + // discarded at its delimiter, since a truncated prefix would be + // fabricated evidence rather than an observation. + if (run.length < MAX_RUN_BYTES) run += String.fromCharCode(byte); + else { + overflowed = true; + collectionCapped = true; + } + } else { + if (!overflowed) consider(run); + overflowed = false; + run = ""; + } + } + } + if (!overflowed) consider(run); + } + signal?.throwIfAborted(); + return { + packageUris: [...packages].sort(), + packageUriCount: packages.size, + dartUris: [...darts].sort(), + dartUriCount: darts.size, + classLikeTokens: [...tokens].sort(), + classLikeTokenCount: tokens.size, + printableRunCount: runCount, + collectionCapped, + }; +}; diff --git a/src/flutter/FlutterPayloadScan.test.ts b/src/flutter/FlutterPayloadScan.test.ts new file mode 100644 index 0000000000..681a373a10 --- /dev/null +++ b/src/flutter/FlutterPayloadScan.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; + +import { + LabeledStringScanner, + SnapshotHashScanner, +} from "./FlutterPayloadScan.js"; + +/** Real payload prefix from Flutter Gallery 2.9.2's arm64 libapp.so. */ +const realSnapshotSection = (hash: string): Uint8Array => + Uint8Array.from([ + 0xf5, + 0xf5, + 0xdc, + 0xdc, + 0x49, + 0x0d, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x03, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ...Array.from(hash, (character) => character.charCodeAt(0)), + ]); + +describe("SnapshotHashScanner", () => { + it("reads the hash from real vm and isolate sections that agree", () => { + const scanner = new SnapshotHashScanner(); + scanner.push(realSnapshotSection("65817c30a78bb44c3dc3771876b6010a")); + scanner.push(Uint8Array.from([0, 0, 0])); + scanner.push(realSnapshotSection("65817c30a78bb44c3dc3771876b6010a")); + expect(scanner.result()).toEqual({ + capped: false, + sources: 2, + candidates: ["65817c30a78bb44c3dc3771876b6010a"], + }); + }); + + it("keeps both hashes when sections disagree", () => { + const scanner = new SnapshotHashScanner(); + scanner.push(realSnapshotSection("11111111111111111111111111111111")); + scanner.push(realSnapshotSection("22222222222222222222222222222222")); + expect(scanner.result()).toEqual({ + capped: false, + sources: 2, + candidates: [ + "11111111111111111111111111111111", + "22222222222222222222222222222222", + ], + }); + }); + + it("survives chunk boundaries that split the magic and hash", () => { + const scanner = new SnapshotHashScanner(); + const payload = realSnapshotSection("65817c30a78bb44c3dc3771876b6010a"); + for (let offset = 0; offset < payload.length; offset += 7) + scanner.push(payload.subarray(offset, offset + 7)); + expect(scanner.result()).toEqual({ + capped: false, + sources: 1, + candidates: ["65817c30a78bb44c3dc3771876b6010a"], + }); + }); + + it("ignores a magic without a following hash", () => { + const scanner = new SnapshotHashScanner(); + scanner.push(Uint8Array.from([0xf5, 0xf5, 0xdc, 0xdc, 0xff, 0xff, 0xff])); + expect(scanner.result()).toEqual({ + capped: false, + sources: 0, + candidates: [], + }); + }); +}); + +it("distinguishes a full candidate set from omitted conflicts", () => { + const scanner = new SnapshotHashScanner(); + for (let index = 0; index < 64; index += 1) + scanner.push(realSnapshotSection(index.toString(16).padStart(32, "0"))); + expect(scanner.result().capped).toBe(false); + scanner.push(realSnapshotSection("0".repeat(32))); + expect(scanner.result().capped).toBe(false); + scanner.push(realSnapshotSection("f".repeat(32))); + expect(scanner.result()).toMatchObject({ sources: 66, capped: true }); + expect(scanner.result().candidates).toHaveLength(64); +}); + +describe("LabeledStringScanner", () => { + const assertSingleToolchainLine = (line: string): void => { + const scanner = new LabeledStringScanner(); + scanner.push( + Uint8Array.from( + Array.from(`\x00${line}\x00`, (character) => character.charCodeAt(0)), + ), + ); + expect(scanner.result().toolchainLines).toEqual([line]); + }; + it("collects a full clang line within the 500-character bound", () => { + assertSingleToolchainLine( + "Android (5900059 based on r365631c1) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)", + ); + }); + + it("reads a labeled Dart VM version string when a build carries one", () => { + const scanner = new LabeledStringScanner(); + scanner.push( + Uint8Array.from( + Array.from( + "\x00Dart VM version: 3.1.0 (stable) somewhere\x00", + (character) => character.charCodeAt(0), + ), + ), + ); + expect(scanner.result().dartVersion).toBe( + "Dart VM version: 3.1.0 (stable) somewhere", + ); + }); + + it("collects the labeled Android clang line", () => { + assertSingleToolchainLine( + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc) (based on LLVM 9.0.8svn)", + ); + }); + + it("does not claim a toolchain cap for unrelated overlong strings", () => { + const scanner = new LabeledStringScanner(); + const license = `Apache License Version 2.0 ${"x".repeat(700)}`; + scanner.push( + Uint8Array.from( + Array.from(`\x00${license}\x00`, (character) => + character.charCodeAt(0), + ), + ), + ); + const result = scanner.result(); + expect(result.toolchainCapped).toBe(false); + expect(result.toolchainLines).toEqual([]); + }); + + it("claims the cap only for an overlong run shaped like a toolchain line", () => { + const overlongToolchain = `Android (${"r".repeat(700)}) clang version 9`; + for (const [text, toolchainCapped, dartVersionCapped] of [ + [overlongToolchain, true, false], + [`Dart VM version: ${"3".repeat(700)}`, false, true], + [`based on LLVM 9 ${"x".repeat(700)}`, true, false], + ] as const) { + const scanner = new LabeledStringScanner(); + scanner.push( + Uint8Array.from( + Array.from(`\x00${text}\x00`, (character) => character.charCodeAt(0)), + ), + ); + expect(scanner.result()).toMatchObject({ + toolchainCapped, + dartVersionCapped, + dartVersion: null, + toolchainLines: [], + }); + } + }); + + it.each([ + { label: "Dart", line: `Dart VM version:${"x".repeat(200)}`, dart: true }, + { + label: "Android clang", + line: `Android (${"r".repeat(80)}) clang version ${"x".repeat(500)}`, + dart: false, + }, + { label: "LLVM", line: `based on LLVM 9${"x".repeat(80)}`, dart: false }, + ])( + "requires the complete $label run at its own grammar boundary", + ({ line, dart }) => { + for (const suffix of ["", "x", "x".repeat(1000)]) { + const payload = Buffer.from(`${line}${suffix}\x00`); + for (const chunkSize of [1, 7, payload.length]) { + const scanner = new LabeledStringScanner(); + for (let offset = 0; offset < payload.length; offset += chunkSize) + scanner.push(payload.subarray(offset, offset + chunkSize)); + const valid = suffix === ""; + expect(scanner.result()).toEqual({ + dartVersion: dart && valid ? line : null, + dartVersionCapped: dart && !valid, + toolchainLines: !dart && valid ? [line] : [], + toolchainCapped: !dart && !valid, + }); + } + } + }, + ); + + it("preserves whitespace and separates distinct Dart-label omissions", () => { + assertSingleToolchainLine("based on LLVM 9 "); + const scanner = new LabeledStringScanner(); + scanner.push(Buffer.from("Dart VM version: 3.1\0Dart VM version: 3.2\0")); + expect(scanner.result()).toMatchObject({ + dartVersion: "Dart VM version: 3.1", + dartVersionCapped: true, + toolchainCapped: false, + }); + }); +}); diff --git a/src/flutter/FlutterPayloadScan.ts b/src/flutter/FlutterPayloadScan.ts new file mode 100644 index 0000000000..e1a69d5944 --- /dev/null +++ b/src/flutter/FlutterPayloadScan.ts @@ -0,0 +1,217 @@ +import { Buffer } from "node:buffer"; + +/** + * Streaming scanners for Flutter payload libraries inside an APK. + * + * All facts are observations of byte patterns at read time: the Dart + * snapshot hash sits 20 bytes after each snapshot magic, the GNU build-id + * is an ELF note, and version facts are labeled ASCII strings. Nothing is + * inferred or looked up externally. + */ + +/** First index of `needle` in `haystack` at or after `from`, or -1. */ +const findBytes = ( + haystack: Uint8Array, + needle: Uint8Array, + from: number, +): number => { + if (needle.length === 0) return from; + const last = haystack.length - needle.length; + for (let index = from; index <= last; index += 1) { + let matched = true; + for (let probe = 0; probe < needle.length; probe += 1) { + if (haystack[index + probe] !== needle[probe]) { + matched = false; + break; + } + } + if (matched) return index; + } + return -1; +}; + +/** Magic starting every Dart VM and isolate snapshot section. */ +export const DART_SNAPSHOT_MAGIC = Uint8Array.from([0xf5, 0xf5, 0xdc, 0xdc]); +export const SNAPSHOT_HASH_OFFSET = 20; +export const SNAPSHOT_HASH_LENGTH = 32; +export const SNAPSHOT_HASH_PATTERN = /^[0-9a-f]{32}$/u; + +export interface SnapshotHashScan { + /** Snapshot sections found. */ + readonly sources: number; + readonly capped: boolean; + /** Distinct 32-hex hashes following the magic, in first-seen order. */ + readonly candidates: readonly string[]; +} + +/** + * Incremental scanner for Dart snapshot magics and the hash that follows. + * Feed chunks in order; a trailing carry keeps a magic-to-hash span that a + * chunk boundary would otherwise split. + */ +/** Hard cap on distinct hashes retained before partial coverage reports. */ +export const MAX_SNAPSHOT_HASH_CANDIDATES = 64; + +export class SnapshotHashScanner { + #sources = 0; + #capped = false; + private readonly candidates: string[] = []; + private readonly candidateSet = new Set(); + #buffer = new Uint8Array(0); + /** Absolute stream offset from which unprocessed bytes begin. */ + #base = 0; + #absoluteScanFrom = 0; + + push(chunk: Uint8Array): void { + const joined = new Uint8Array(this.#buffer.length + chunk.length); + joined.set(this.#buffer, 0); + joined.set(chunk, this.#buffer.length); + // Resume after the last fully processed hash so carried bytes are not + // counted twice across chunk boundaries. + let offset = Math.max(0, this.#absoluteScanFrom - this.#base); + let pendingMagic: number | null = null; + for (;;) { + const magic = findBytes(joined, DART_SNAPSHOT_MAGIC, offset); + if (magic === -1) break; + if (magic + SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH > joined.length) { + pendingMagic = magic; + break; + } + const candidate = Buffer.from( + joined.slice( + magic + SNAPSHOT_HASH_OFFSET, + magic + SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH, + ), + ).toString("latin1"); + const isValidHash = SNAPSHOT_HASH_PATTERN.test(candidate); + if (isValidHash && !this.candidateSet.has(candidate)) { + if (this.candidates.length === MAX_SNAPSHOT_HASH_CANDIDATES) + this.#capped = true; + else { + this.candidateSet.add(candidate); + this.candidates.push(candidate); + } + } + // Only valid hash records count toward source agreement; a complete + // magic record whose field is not lowercase hex is not a source. + if (isValidHash) this.#sources += 1; + offset = magic + DART_SNAPSHOT_MAGIC.length; + this.#absoluteScanFrom = + this.#base + magic + SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH; + } + // Keep a pending magic for the next chunk; otherwise keep only the + // trailing bytes where a magic could still begin, so magic-free + // streams never accumulate. + const keepFrom = + pendingMagic ?? + Math.max( + this.#absoluteScanFrom - this.#base, + joined.length - (DART_SNAPSHOT_MAGIC.length - 1), + ); + this.#buffer = joined.slice(keepFrom); + this.#base += keepFrom; + if (pendingMagic !== null) this.#absoluteScanFrom = this.#base; + } + + result(): SnapshotHashScan { + return { + sources: this.#sources, + candidates: [...this.candidates], + capped: this.#capped, + }; + } +} + +/** Labeled strings worth reporting when a build carries them. */ +export interface LabeledStringScan { + readonly dartVersion: string | null; + readonly dartVersionCapped: boolean; + readonly toolchainLines: readonly string[]; + /** True when further distinct matching lines were observed past the cap. */ + readonly toolchainCapped: boolean; +} + +const DART_VERSION_PATTERN = /^Dart VM version:[^\x00\n]{0,200}$/u; +const TOOLCHAIN_PATTERN = + /^(?:Android \([^)\x00\n]{0,80}\) clang version [^\x00\n]{0,500}|based on LLVM \d[^\x00\n]{0,80})$/u; +const MAX_TOOLCHAIN_LINES = 4; +/** + * Carry spans chunk boundaries for either accepted pattern. The toolchain + * pattern accepts up to 605 printable bytes (9 + 80 + 16 + 500), so the + * carry must hold at least that to preserve a line split at a boundary. + */ +const CARRY_BYTES = 640; + +/** + * Incremental ASCII string collector that keeps only labeled version and + * toolchain facts. A match is committed only when a printable run is + * terminated by a delimiter, so a value split across chunk boundaries is + * never committed truncated; the carry holds the longest accepted match + * so boundaries in either pattern survive. + */ +export class LabeledStringScanner { + #dartVersion: string | null = null; + #dartVersionCapped = false; + private readonly toolchainLines: string[] = []; + private carry = ""; + private overflowed = false; + #ended = false; + #toolchainCapped = false; + + push(chunk: Uint8Array): void { + for (const byte of chunk) { + if (byte >= 0x20 && byte < 0x7f) { + if (this.carry.length < CARRY_BYTES) + this.carry += String.fromCharCode(byte); + else this.overflowed = true; + } else { + this.#consider(this.carry); + this.carry = ""; + this.overflowed = false; + } + } + } + + /** Commit any retained tail, e.g. when the stream ends mid-string. */ + end(): void { + if (this.#ended) return; + this.#ended = true; + this.#consider(this.carry); + this.carry = ""; + this.overflowed = false; + } + + #consider(text: string): void { + // Keep a bounded prefix plus overflow state, never a sliding suffix. + // Every accepted label must consume its whole printable run so a + // pattern's shorter grammar cannot publish a fabricated prefix. + if (text.startsWith("Dart VM version:")) { + if (this.overflowed || !DART_VERSION_PATTERN.test(text)) + this.#dartVersionCapped = true; + else if (this.#dartVersion === null) this.#dartVersion = text; + else if (this.#dartVersion !== text) this.#dartVersionCapped = true; + return; + } + if (!text.startsWith("Android (") && !text.startsWith("based on LLVM ")) + return; + if (this.overflowed || !TOOLCHAIN_PATTERN.test(text)) { + this.#toolchainCapped = true; + return; + } + const line = text; + if (this.toolchainLines.includes(line)) return; + if (this.toolchainLines.length < MAX_TOOLCHAIN_LINES) + this.toolchainLines.push(line); + else this.#toolchainCapped = true; + } + + result(): LabeledStringScan { + if (!this.#ended) this.end(); + return { + dartVersion: this.#dartVersion, + dartVersionCapped: this.#dartVersionCapped, + toolchainLines: [...this.toolchainLines], + toolchainCapped: this.#toolchainCapped, + }; + } +} diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 5a9fac0dbe..ec7a9f231a 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -75,6 +75,7 @@ import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnaly import { createFirmwareAnalysisProvider } from "../composition/firmware.js"; import { registerAndroidTools } from "./registerAndroidTools.js"; import { registerApktoolTools } from "./registerApktoolTools.js"; +import { registerFlutterTools } from "./registerFlutterTools.js"; import { registerJebTools } from "./registerJebTools.js"; import { registerAdbTools } from "./registerAdbTools.js"; import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js"; @@ -82,8 +83,11 @@ import { JebAnalysisService } from "../application/jeb/JebAnalysisService.js"; import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysisPort.js"; import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js"; import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js"; +import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js"; +import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js"; import { createAndroidAnalysisProvider } from "../composition/android.js"; import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js"; +import { createFlutterBuildAnalysisProvider } from "../composition/flutter.js"; import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js"; import { createJebAnalysisProvider } from "../composition/jeb.js"; import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js"; @@ -126,6 +130,7 @@ export interface CreateServerOptions { readonly webNetworkCapture?: WebNetworkCaptureService; readonly androidAnalysis?: AndroidAnalysisPort; readonly apktoolAnalysis?: ApktoolResourceAnalysisPort; + readonly flutterAnalysis?: FlutterBuildAnalysisPort; readonly jebAnalysis?: JebAnalysisPort; readonly adbDeviceAnalysis?: AdbDeviceAnalysisPort; readonly browserObservation?: BrowserObservationPort; @@ -266,6 +271,8 @@ export const createServer = ( ); const android = options.androidAnalysis ?? createAndroidAnalysisProvider(environment); + const flutter = + options.flutterAnalysis ?? createFlutterBuildAnalysisProvider(); const fridaInstrumentation = options.fridaInstrumentation ?? new FridaInstrumentationManager(); const wasmArtifact = wasmArtifactModule.create( @@ -287,6 +294,7 @@ export const createServer = ( apktool.close(), jeb.close(), adbDevice.close(), + flutter.close(), evmInterface.close(), webSourceLocation.close(), webModuleTrace.close(), @@ -374,6 +382,13 @@ export const createServer = ( recordEvidence, analysisAdmission, ); + registerFlutterTools( + server, + new FlutterBuildAnalysisService(flutter), + toolLogger, + recordEvidence, + analysisAdmission, + ); registerApktoolTools( server, new ApktoolResourceAnalysisService(apktool), diff --git a/src/server/registerFlutterTools.ts b/src/server/registerFlutterTools.ts new file mode 100644 index 0000000000..5b601c5f7e --- /dev/null +++ b/src/server/registerFlutterTools.ts @@ -0,0 +1,35 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import type { Logger } from "pino"; +import { createContractToolHandler } from "./contractToolHandler.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind Flutter handlers to their exact named contracts through admission. */ +export const registerFlutterTools = ( + server: EvidenceMcpServer, + service: FlutterBuildAnalysisService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const handler = createContractToolHandler( + server, + service, + logger, + recordEvidence, + withAdmittedAnalysis, + ); + for (const contract of [ + toolContract("identify_flutter_build"), + toolContract("inspect_dart_aot"), + ] as const) { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); + } +}; diff --git a/tests/acceptance/flutterAnalysis.test.ts b/tests/acceptance/flutterAnalysis.test.ts new file mode 100644 index 0000000000..457dce96bc --- /dev/null +++ b/tests/acceptance/flutterAnalysis.test.ts @@ -0,0 +1,154 @@ +import { createHash } from "node:crypto"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { + aotFixtureImage, + engineFixtureImage, + REFERENCE_HASH, +} from "../../src/flutter/FlutterBuild.fixture.js"; +import { + createFlutterApk, + createDuplicateFlutterApk, +} from "../fixtures/flutterApk.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "CLI and stdio MCP retain Flutter identity, selected ABI and duplicate-member refusals", + async ({ cli }) => { + const image = aotFixtureImage(); + Buffer.from("Login\0").copy(image, 0x2300); + const engine = engineFixtureImage({ + notes: Buffer.from( + "040000000800000003000000474e55000123456789abcdef", + "hex", + ), + prefix: Buffer.from( + "040000000800000003000000474e55001111111111111111", + "hex", + ), + labels: ["Android (test) clang version 9.0.8"], + }); + const path = await createFlutterApk("selected target.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: image }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: engine, + }, + { path: "lib/x86_64/libapp.so", bytes: image }, + { + path: "lib/x86_64/libflutter.so", + bytes: engine, + }, + ]); + const { call } = await connectLocalToolsMcp(); + for (const [name, abi] of [ + ["identify_flutter_build", undefined], + ["inspect_dart_aot", undefined], + ["inspect_dart_aot", "x86_64"], + ] as const) { + const result = await cli.run({ + arguments: [ + name.replaceAll("_", "-"), + path, + "--json", + ...(abi === undefined ? [] : ["--abi", abi]), + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract(name).outputSchema.parse(result.json); + const response = await call(name, { + path, + ...(abi === undefined ? {} : { abi }), + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcp = parseEvidence(response.structuredContent); + expect(mcp.normalized_result).toEqual(evidence.normalized_result); + expect(mcp.subject).toEqual(evidence.subject); + expect(mcp.subject).toMatchObject({ + local_path: path, + format: "apk", + digest: { sha256: evidence.normalized_result.target.sha256 }, + }); + expect(mcp.evidence_id).toBe(evidence.evidence_id); + if (name === "inspect_dart_aot") { + expect(evidence.normalized_result).toMatchObject({ + abi: abi ?? "arm64-v8a", + libapp: { + coverage: "complete", + snapshot_hash: REFERENCE_HASH, + sha256: createHash("sha256").update(image).digest("hex"), + }, + string_pool: { + coverage: "complete", + class_like_tokens: expect.arrayContaining(["Login"]), + }, + }); + } else { + const identified = toolContract( + "identify_flutter_build", + ).outputSchema.parse(result.json).normalized_result; + expect(identified.coverage).toBe("complete"); + for (const abi of identified.abis) { + expect(abi.libflutter).toMatchObject({ + build_id: "0123456789abcdef", + build_id_candidates: ["0123456789abcdef"], + }); + } + } + } + const duplicate = await createDuplicateFlutterApk(); + for (const name of [ + "identify_flutter_build", + "inspect_dart_aot", + ] as const) { + const result = await cli.run({ + arguments: [name.replaceAll("_", "-"), duplicate, "--json"], + }); + expect(result.exitCode).toBe(1); + const mcp = parseMcpToolError(await call(name, { path: duplicate })); + expect(mcp.error).toEqual(result.json); + expect(mcp.error).toMatchObject({ + code: "invalid_request", + details: { issues: [{ message: expect.stringContaining(duplicate) }] }, + }); + } + }, +); + +cliTest( + "CLI and MCP retain unsupported snapshot kinds as partial evidence", + async ({ cli }) => { + const image = aotFixtureImage(); + for (const offset of [0x1000, 0x2000]) + image.writeBigUInt64LE(4n, offset + 12); + const path = await createFlutterApk("unsupported-kind.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: image }, + ]); + const result = await cli.run({ + arguments: ["inspect-dart-aot", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_dart_aot").outputSchema.parse( + result.json, + ); + const { call } = await connectLocalToolsMcp(); + const response = await call("inspect_dart_aot", { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(parseEvidence(response.structuredContent).normalized_result).toEqual( + evidence.normalized_result, + ); + expect(evidence.normalized_result).toMatchObject({ + coverage: "partial", + libapp: { coverage: "partial", snapshot_hash: REFERENCE_HASH }, + string_pool: { coverage: "complete" }, + }); + expect( + evidence.normalized_result.libapp.sections + .filter((section) => section.name.endsWith("Data")) + .map((section) => section.kind), + ).toEqual([4, 4]); + }, +); diff --git a/tests/boundary/cli/flutterCli.test.ts b/tests/boundary/cli/flutterCli.test.ts new file mode 100644 index 0000000000..e9e32c99de --- /dev/null +++ b/tests/boundary/cli/flutterCli.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from "vitest"; + +import { serveCli } from "./cliServeHarness.js"; + +it.each(["identify-flutter-build", "inspect-dart-aot"])( + "%s advertises a schema through the public CLI", + async (command) => { + const result = await serveCli([command, "--schema", "--json"]); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toBeTruthy(); + }, +); + +it("reports an unreadable target with an input refusal", async () => { + const result = await serveCli([ + "identify-flutter-build", + "/nonexistent/app.apk", + "--json", + ]); + expect(result.stdout).toContain("invalid_request"); +}); diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts new file mode 100644 index 0000000000..4a1c8248fe --- /dev/null +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -0,0 +1,1332 @@ +import { createReadStream } from "node:fs"; +import { open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Readable } from "node:stream"; +import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it, onTestFinished, vi } from "vitest"; + +import { + aotFixtureImage, + engineFixtureImage, + buildElf64Image, + REFERENCE_HASH, +} from "../../../src/flutter/FlutterBuild.fixture.js"; +import { snapshotSection } from "../../../src/flutter/FlutterBuild.fixture.js"; +import { FlutterBuildProvider } from "../../../src/flutter/FlutterBuildProvider.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { + AnalysisCancelledError, + AnalysisResourceConstraintError, +} from "../../../src/domain/analysisErrorCore.js"; +import { + flutterResultSchemas, + type FlutterOperation, +} from "../../../src/domain/flutter/flutterBuildAnalysis.js"; +import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js"; +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; +import { + createFlutterApk as buildApk, + createDuplicateFlutterApk, +} from "../../fixtures/flutterApk.js"; +import * as regularFile from "../../../src/filesystem/RegularFile.js"; +import * as fsPromises from "node:fs/promises"; +import * as dartElf from "../../../src/flutter/DartElf.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, createReadStream: vi.fn(original.createReadStream) }; +}); +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { ...original }; +}); + +const buildIdNote = (): Buffer => + Buffer.concat([ + Buffer.from([4, 0, 0, 0, 20, 0, 0, 0, 3, 0, 0, 0, 0x47, 0x4e, 0x55, 0]), + Buffer.from([ + 0xe8, 0xd9, 0x07, 0xef, 0x7c, 0x17, 0x5e, 0xc4, 0x31, 0x3f, 0x47, 0x11, + 0x10, 0x19, 0xad, 0xc9, 0x58, 0x8c, 0x5f, 0x61, + ]), + ]); + +const TOOLCHAIN_LINE = + "Android (5900059 based on r365631c) clang version 9.0.8 (based on LLVM 9.0.8svn)"; + +const libapp = aotFixtureImage; + +const libflutter = (): Buffer => + engineFixtureImage({ notes: buildIdNote(), labels: [TOOLCHAIN_LINE] }); + +/** + * Self-contained Dart AOT image: a synthetic ELF64 carrying the four + * snapshot symbols, two snapshot sections, and a string pool, so goldens + * do not depend on any file outside the repository. + */ +const aotImage = aotFixtureImage; + +/** Two data symbols at one offset and size: aliased exports. */ +const aliasedDataImage = (): Buffer => { + const elf = buildElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x1000, size: 52 }, + { name: "_kDartIsolateSnapshotData", value: 0x1000, size: 52 }, + ]); + return Buffer.concat([ + elf, + Buffer.alloc(0x1000 - elf.length), + snapshotSection(REFERENCE_HASH), + ]); +}; + +/** Two data symbols whose snapshot hashes disagree. */ +const conflictingHashImage = (): Buffer => { + const elf = buildElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x1000, size: 52 }, + { name: "_kDartIsolateSnapshotData", value: 0x1200, size: 52 }, + ]); + const vm = snapshotSection("11111111111111111111111111111111"); + const isolate = snapshotSection("22222222222222222222222222222222"); + return Buffer.concat([ + elf, + Buffer.alloc(0x1000 - elf.length), + vm, + Buffer.alloc(0x1200 - 0x1000 - vm.length), + isolate, + ]); +}; + +const analyzeImage = async (bytes: Buffer, operation: FlutterOperation) => { + const path = await buildApk("image.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes }, + { path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (!result.ok) throw result.error; + return result.value; +}; + +it.each([32, 64] as const)( + "reads only ELF-owned notes in an ELF%s engine", + async (bits) => { + const fake = Buffer.from(buildIdNote()); + fake.fill(0x11, 16); + for (const ownership of ["program", "section", "both"] as const) { + const path = await buildApk("owned-notes.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: engineFixtureImage({ + bits, + ownership, + prefix: fake, + notes: buildIdNote(), + }), + }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.coverage).toBe("complete"); + expect(value.abis[0]!.libflutter).toMatchObject({ + build_id: "e8d907ef7c175ec4313f47111019adc9588c5f61", + build_id_candidates: ["e8d907ef7c175ec4313f47111019adc9588c5f61"], + }); + } + }, +); + +it.each(["conflicting", "truncated", "capacity"] as const)( + "preserves %s engine-note uncertainty", + async (shape) => { + const second = Buffer.from(buildIdNote()); + second.fill(0x11, 16); + const notes = + shape === "conflicting" + ? Buffer.concat([buildIdNote(), second]) + : shape === "truncated" + ? Buffer.concat([buildIdNote(), Buffer.from([1])]) + : Buffer.concat( + Array.from({ length: 65 }, (_, index) => { + const note = Buffer.from(buildIdNote()); + note.writeUInt32LE(index, 16); + return note; + }), + ); + const path = await buildApk("uncertain-notes.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: engineFixtureImage({ notes }), + }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.coverage).toBe("partial"); + expect(value.abis[0]!.libflutter.build_id).toBeNull(); + expect(value.abis[0]!.libflutter.build_id_candidates).toHaveLength( + shape === "conflicting" ? 2 : shape === "truncated" ? 1 : 64, + ); + }, +); + +it.each([0, 1, 2, 4, 0xffff])( + "retains snapshot kind %s with partial decoding coverage", + async (kind) => { + const image = aotImage(); + image.writeBigUInt64LE(BigInt(kind), 0x1000 + 12); + image.writeBigUInt64LE(BigInt(kind), 0x2000 + 12); + const execution = await analyzeImage(image, "inspect_dart_aot"); + const value = flutterResultSchemas.inspect_dart_aot.parse(execution.result); + expect(value).toMatchObject({ + coverage: "partial", + libapp: { coverage: "partial" }, + string_pool: { coverage: "complete" }, + }); + expect( + value.libapp.sections + .filter((section) => section.name.endsWith("Data")) + .map((section) => section.kind), + ).toEqual([kind, kind]); + expect(value.libapp.snapshot_hash).toBe(REFERENCE_HASH); + }, +); + +it.each(["absent", "range", "metadata-budget"] as const)( + "preserves %s engine-note coverage at the ELF boundary", + async (shape) => { + const engine = engineFixtureImage({ + notes: + shape === "metadata-budget" + ? Buffer.alloc(8 * 1024 * 1024) + : Buffer.alloc(0), + }); + if (shape === "range") + engine.writeBigUInt64LE(BigInt(engine.length + 1), 64 + 56 + 8); + const path = await buildApk("note-bounds.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { path: "lib/arm64-v8a/libflutter.so", bytes: engine }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.coverage).toBe(shape === "absent" ? "complete" : "partial"); + expect(value.abis[0]!.libflutter).toMatchObject({ + build_id: null, + build_id_candidates: [], + }); + if (shape !== "absent") + expect(result.value.limitations.join(" ")).toContain( + shape === "range" ? "not file-backed" : "metadata budget", + ); + }, +); + +it.each([ + ["root", "EACCES"], + ["open", "EPERM"], + ["write", "ENOSPC"], + ["close", "EIO"], + ["chmod", "EACCES"], + ["stat", "EIO"], + ["digest", "EACCES"], + ["zip-library", "EIO"], +] as const)( + "classifies owned snapshot %s failure (%s) separately from source input", + async (stage, code) => { + const path = await buildApk("owned-io.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const cause = Object.assign(new Error("owned snapshot failure"), { code }); + const native = + await vi.importActual( + "node:fs/promises", + ); + if (stage === "zip-library") + vi.spyOn(ZipArtifactReader.prototype, "open").mockRejectedValueOnce( + cause, + ); + if (stage === "root") + vi.spyOn(fsPromises, "mkdtemp").mockRejectedValueOnce(cause); + if (stage === "chmod") + vi.spyOn(fsPromises, "chmod").mockRejectedValueOnce(cause); + if (stage === "stat") + vi.spyOn(fsPromises, "stat").mockImplementation((...args) => { + if (args[0] !== path) return Promise.reject(cause); + return native.stat(...args); + }); + if (["open", "write", "close"].includes(stage)) + vi.spyOn(fsPromises, "open").mockImplementation(async (...args) => { + if (args[1] === "wx" && stage === "open") throw cause; + const handle = await native.open(...args); + if (args[1] === "wx") { + if (stage === "write") + vi.spyOn(handle, "writeFile").mockRejectedValueOnce(cause); + if (stage === "close") + vi.spyOn(handle, "close").mockRejectedValueOnce(cause); + } + return handle; + }); + if (stage === "digest") + vi.mocked(createReadStream).mockImplementation( + () => + Readable.from( + (async function* () { + yield Buffer.alloc(0); + throw cause; + })(), + ) as ReturnType, + ); + onTestFinished(() => { + vi.restoreAllMocks(); + vi.mocked(createReadStream).mockReset(); + }); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (result.ok) throw new Error("expected owned I/O failure"); + expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); + expect(result.error.cause).toBe(cause); + expect(result.error.message).toContain( + "Provider-owned Flutter snapshot storage", + ); + }, +); + +it.each(["close", "elf", "pool", "cleanup"] as const)( + "honors cancellation during final %s work", + async (stage) => { + const path = await buildApk("late-cancellation.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const controller = new AbortController(); + const originalClose = OwnedFileHandle.prototype.close; + let closed = 0; + vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementation( + async function (this: OwnedFileHandle) { + await originalClose.call(this); + if (++closed === 4) { + if (stage === "close") controller.abort(); + if (stage === "elf") setImmediate(() => controller.abort()); + } + }, + ); + if (stage === "pool") { + const original = dartElf.readElfSymbols; + vi.spyOn(dartElf, "readElfSymbols").mockImplementation( + async (...args) => { + const value = await original(...args); + setImmediate(() => controller.abort()); + return value; + }, + ); + } + onTestFinished(() => { + vi.restoreAllMocks(); + }); + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + await rm(root, { recursive: true, force: true }); + if (stage === "cleanup") controller.abort(); + }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute( + { operation: "inspect_dart_aot", input: { path } }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("late cancellation must prevent success"); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + }, +); + +it("projects each payload shape through the owned snapshot", async () => { + const hash = REFERENCE_HASH; + const agreeBoth = await buildApk("agree.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: libapp(hash) }, + { path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() }, + { path: "lib/x86_64/libapp.so", bytes: libapp(hash) }, + { path: "lib/x86_64/libflutter.so", bytes: libflutter() }, + ]); + const half = await buildApk("half.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: libapp(hash) }, + ]); + const disagree = await buildApk("disagree.apk", [ + { + path: "lib/arm64-v8a/libapp.so", + bytes: Buffer.concat([ + snapshotSection("11111111111111111111111111111111"), + snapshotSection("22222222222222222222222222222222"), + ]), + }, + { path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() }, + ]); + const plain = await buildApk("plain.apk", [ + { path: "classes.dex", bytes: Buffer.from("dex\n0123456789") }, + { path: "lib/arm64-v8a/libnative.so", bytes: Buffer.from("plain-elf") }, + ]); + const provider = new FlutterBuildProvider(); + const identify = async (apk: string) => { + const outcome = await provider.execute({ + operation: "identify_flutter_build", + input: { path: apk }, + }); + if (!outcome.ok) throw new Error(outcome.error.message); + return outcome.value.result as { + flutter_detected: boolean; + coverage: string; + abis: { + abi: string; + libapp: { + present: boolean; + snapshot_hash: string | null; + snapshot_hash_sources: number; + snapshot_hash_candidates: string[]; + }; + libflutter: { present: boolean; build_id: string | null }; + }[]; + }; + }; + + const both = await identify(agreeBoth); + expect(both.flutter_detected).toBe(true); + expect(both.coverage).toBe("complete"); + expect(both.abis.map((abi) => abi.abi)).toEqual(["arm64-v8a", "x86_64"]); + for (const abi of both.abis) { + expect(abi.libapp.snapshot_hash).toBe(hash); + expect(abi.libapp.snapshot_hash_sources).toBe(2); + expect(abi.libflutter.build_id).toBe( + "e8d907ef7c175ec4313f47111019adc9588c5f61", + ); + } + + const engineless = await identify(half); + expect(engineless.coverage).toBe("partial"); + expect(engineless.abis[0]?.libapp.present).toBe(true); + expect(engineless.abis[0]?.libflutter.present).toBe(false); + + const conflicted = await identify(disagree); + expect(conflicted.coverage).toBe("partial"); + expect(conflicted.abis[0]?.libapp.snapshot_hash).toBeNull(); + expect(conflicted.abis[0]?.libapp.snapshot_hash_candidates).toEqual([ + "11111111111111111111111111111111", + "22222222222222222222222222222222", + ]); + + const negative = await identify(plain); + expect(negative.flutter_detected).toBe(false); + expect(negative.abis).toEqual([]); + expect(negative.coverage).toBe("complete"); +}); + +it("rejects a target that is not a readable file", async () => { + const projected = await (async () => { + const result = await new FlutterBuildProvider().execute({ + operation: "identify_flutter_build", + input: { path: "/nonexistent/app.apk" }, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a refusal"); + return projectAnalysisError(result.error); + })(); + expect(projected).toMatchObject({ code: "invalid_request" }); +}); + +it("refuses an APK without a Dart payload", async () => { + const apk = await buildApk("noflutter.apk", [ + { path: "classes.dex", bytes: Buffer.from("dex\n0123456789") }, + ]); + const result = await new FlutterBuildProvider().execute({ + operation: "inspect_dart_aot", + input: { path: apk }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unsupported_target", + }); +}); + +it("refuses a requested ABI the payload does not carry", async () => { + const apk = await buildApk("oneabi.apk", [ + { + path: "lib/arm64-v8a/libapp.so", + bytes: libapp(REFERENCE_HASH), + }, + ]); + const result = await new FlutterBuildProvider().execute({ + operation: "inspect_dart_aot", + input: { path: apk, abi: "x86_64" }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unsupported_target", + }); + expect(result.error.message).toContain("arm64-v8a"); +}); + +it("inspects a full synthetic AOT image end to end", async () => { + const apk = await buildApk("realgallery.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const result = await new FlutterBuildProvider().execute({ + operation: "inspect_dart_aot", + input: { path: apk }, + }); + if (!result.ok) throw new Error(result.error.message); + const value = result.value.result as { + abi: string; + libapp: { + snapshot_hash: string | null; + snapshot_hash_candidates: string[]; + snapshot_hash_sources: number; + sections: { name: string; magic_valid: boolean }[]; + }; + string_pool: { + package_uri_count_at_least: number; + dart_uri_count_at_least: number; + }; + coverage: string; + }; + expect(value.abi).toBe("arm64-v8a"); + expect(value.libapp.snapshot_hash).toBe(REFERENCE_HASH); + expect(value.libapp.snapshot_hash_candidates).toEqual([REFERENCE_HASH]); + expect(value.libapp.snapshot_hash_sources).toBe(2); + expect(value.libapp.sections.map((section) => section.name)).toEqual([ + "_kDartVmSnapshotData", + "_kDartVmSnapshotInstructions", + "_kDartIsolateSnapshotData", + "_kDartIsolateSnapshotInstructions", + ]); + expect( + value.libapp.sections + .filter((section) => section.name.endsWith("Data")) + .every((section) => section.magic_valid), + ).toBe(true); + expect(value.string_pool.package_uri_count_at_least).toBe(2); + expect(value.string_pool.dart_uri_count_at_least).toBe(2); + // All four symbols present, both data magics valid, pool under budget: + // the honest answer for a complete payload is complete coverage. + expect(value.coverage).toBe("complete"); +}); + +it("reports aliased snapshot data symbols as partial coverage", async () => { + const apk = await buildApk("aliased.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aliasedDataImage() }, + ]); + const result = await new FlutterBuildProvider().execute({ + operation: "inspect_dart_aot", + input: { path: apk }, + }); + if (!result.ok) throw new Error(result.error.message); + const value = result.value.result as { + coverage: string; + libapp: { + snapshot_hash: string | null; + snapshot_hash_candidates: string[]; + snapshot_hash_sources: number; + }; + }; + // The aliased range is scanned once, so one candidate no longer counts + // as two agreeing sources. + expect(value.coverage).toBe("partial"); + expect(value.libapp.snapshot_hash_sources).toBe(1); + expect(value.libapp.snapshot_hash_candidates).toEqual([REFERENCE_HASH]); + expect(result.value.limitations).toContain( + "Two or more snapshot data symbols share one header offset; its hash counts as one source instead of two agreeing ones", + ); +}); + +it("preserves conflicting snapshot hash candidates from the AOT image", async () => { + const apk = await buildApk("conflicted.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: conflictingHashImage() }, + ]); + const result = await new FlutterBuildProvider().execute({ + operation: "inspect_dart_aot", + input: { path: apk }, + }); + if (!result.ok) throw new Error(result.error.message); + const value = result.value.result as { + coverage: string; + libapp: { + snapshot_hash: string | null; + snapshot_hash_candidates: string[]; + snapshot_hash_sources: number; + }; + }; + expect(value.coverage).toBe("partial"); + expect(value.libapp.snapshot_hash).toBeNull(); + expect(value.libapp.snapshot_hash_sources).toBe(2); + expect(value.libapp.snapshot_hash_candidates).toEqual([ + "11111111111111111111111111111111", + "22222222222222222222222222222222", + ]); +}); + +it("accepts an APK whose path contains spaces through the owned snapshot", async () => { + const apk = await buildApk("flutter spaced.apk", [ + { + path: "lib/arm64-v8a/libapp.so", + bytes: libapp(REFERENCE_HASH), + }, + { path: "lib/arm64-v8a/libflutter.so", bytes: libflutter() }, + ]); + expect(apk).toContain(" "); + const result = await new FlutterBuildProvider().execute({ + operation: "identify_flutter_build", + input: { path: apk }, + }); + if (!result.ok) throw new Error(result.error.message); + const value = result.value.result as { flutter_detected: boolean }; + expect(value.flutter_detected).toBe(true); +}); + +it("retains failed snapshot cleanup and retries it on close", async () => { + const apk = await buildApk("cleanup.apk", [ + { + path: "lib/arm64-v8a/libapp.so", + bytes: libapp(REFERENCE_HASH), + }, + ]); + const spaces: string[] = []; + let failing = true; + const provider = new FlutterBuildProvider({ + removeRoot: async (path) => { + if (failing) throw new Error("cleanup refused"); + spaces.push(path); + await rm(path, { recursive: true, force: true }); + }, + }); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path: apk }, + }); + expect(result.ok).toBe(true); + await expect(provider.close()).rejects.toThrow(/snapshot cleanup failed/u); + failing = false; + await provider.close(); + expect(spaces.length).toBeGreaterThanOrEqual(1); +}); + +it("cancels before creating any snapshot root", async () => { + const apk = await buildApk("cancel.apk", [ + { + path: "lib/arm64-v8a/libapp.so", + bytes: libapp(REFERENCE_HASH), + }, + ]); + const controller = new AbortController(); + controller.abort(); + const provider = new FlutterBuildProvider(); + const result = await provider.execute( + { operation: "identify_flutter_build", input: { path: apk } }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + await provider.close(); +}); + +it.each([1, 20, 47, 48, 61])( + "identifies a valid ELF when ZIP chunks are only %i bytes", + async (chunkBytes) => { + const originalOpen = ZipArtifactReader.prototype.open; + const spy = vi + .spyOn(ZipArtifactReader.prototype, "open") + .mockImplementation(async function ( + this: ZipArtifactReader, + entry, + signal, + ) { + const source = await originalOpen.call(this, entry, signal); + return Readable.from( + (async function* () { + for await (const chunk of source) { + const bytes = chunk as Buffer; + for (let offset = 0; offset < bytes.length; offset += chunkBytes) + yield bytes.subarray(offset, offset + chunkBytes); + } + })(), + ); + }); + onTestFinished(() => spy.mockRestore()); + const execution = await analyzeImage(aotImage(), "identify_flutter_build"); + const value = flutterResultSchemas.identify_flutter_build.parse( + execution.result, + ); + expect(value.coverage).toBe("complete"); + expect(value.abis[0]?.libapp).toMatchObject({ + elf_header_valid: true, + snapshot_hash: REFERENCE_HASH, + snapshot_hash_sources: 2, + }); + }, +); + +it.each([ + { + name: "zeroed offsets", + corrupt: (image: Buffer) => image.fill(0, 0x20, 0x30), + }, + { + name: "program table past EOF", + corrupt: (image: Buffer) => + image.writeBigUInt64LE(BigInt(image.length + 1), 0x20), + }, + { + name: "section table past EOF", + corrupt: (image: Buffer) => + image.writeBigUInt64LE(BigInt(image.length - 1), 0x28), + }, + { + name: "zero program entry width", + corrupt: (image: Buffer) => image.writeUInt16LE(0, 0x36), + }, + { + name: "short section entry width", + corrupt: (image: Buffer) => image.writeUInt16LE(1, 0x3a), + }, + { + name: "absent program table", + corrupt: (image: Buffer) => image.writeUInt16LE(0, 0x38), + }, + { + name: "unsupported class", + corrupt: (image: Buffer) => image.writeUInt8(3, 4), + }, + { + name: "unsupported byte order", + corrupt: (image: Buffer) => image.writeUInt8(2, 5), + }, + { + name: "unsafe table offset", + corrupt: (image: Buffer) => image.writeBigUInt64LE(1n << 63n, 0x20), + }, + { + name: "table inside header", + corrupt: (image: Buffer) => image.writeBigUInt64LE(1n, 0x20), + }, + { + name: "extended section count", + corrupt: (image: Buffer) => image.writeUInt16LE(0, 0x3c), + }, +])( + "retains hash observations with partial coverage for $name", + async ({ corrupt }) => { + const image = aotImage(); + corrupt(image); + const execution = await analyzeImage(image, "identify_flutter_build"); + const value = flutterResultSchemas.identify_flutter_build.parse( + execution.result, + ); + expect(value.coverage).toBe("partial"); + expect(value.abis[0]?.libapp).toMatchObject({ + elf_header_valid: false, + snapshot_hash: REFERENCE_HASH, + snapshot_hash_sources: 2, + }); + expect(execution.limitations.join("\n")).toContain( + "ELF header or table extent", + ); + }, +); + +it.each([0, 4, 20, 47, 61])( + "reports a %i-byte ELF prefix as partial without throwing", + async (length) => { + const execution = await analyzeImage( + aotImage().subarray(0, length), + "identify_flutter_build", + ); + const value = flutterResultSchemas.identify_flutter_build.parse( + execution.result, + ); + expect(value.coverage).toBe("partial"); + expect(value.abis[0]?.libapp.elf_header_valid).toBe(false); + }, +); + +it("accepts a section-stripped ELF32 header and bounds its program table", async () => { + const image = Buffer.alloc(52 + 32 + 104); + image.writeUInt32LE(0x464c457f); + image.set([1, 1, 1], 4); // ELF32, little-endian, version 1 + image.writeUInt16LE(3, 0x10); // ET_DYN + image.writeUInt32LE(1, 0x14); + image.writeUInt32LE(52, 0x1c); + image.writeUInt16LE(52, 0x28); + image.writeUInt16LE(32, 0x2a); + image.writeUInt16LE(1, 0x2c); + image.writeUInt32LE(1, 52); // PT_LOAD + image.writeUInt32LE(image.length, 52 + 16); + snapshotSection(REFERENCE_HASH).copy(image, 84); + snapshotSection(REFERENCE_HASH).copy(image, 136); + const good = await analyzeImage(image, "identify_flutter_build"); + expect( + flutterResultSchemas.identify_flutter_build.parse(good.result).coverage, + ).toBe("complete"); + image.writeUInt32LE(image.length - 31, 0x1c); // One byte short of the table. + const bad = await analyzeImage(image, "identify_flutter_build"); + expect( + flutterResultSchemas.identify_flutter_build.parse(bad.result).coverage, + ).toBe("partial"); +}); + +it.each([0n, 15n, 47n, 49n, 1n << 63n])( + "retains malformed snapshot length %s as partial evidence", + async (length) => { + const image = aotImage(); + image.writeBigUInt64LE(length, 0x1004); + const execution = await analyzeImage(image, "inspect_dart_aot"); + const value = flutterResultSchemas.inspect_dart_aot.parse(execution.result); + expect(value.coverage).toBe("partial"); + expect(value.libapp.snapshot_hash).toBe(REFERENCE_HASH); + expect(value.libapp.sections[0]).toMatchObject({ + magic_valid: true, + header_length: + length > BigInt(Number.MAX_SAFE_INTEGER) ? null : Number(length), + }); + expect(execution.limitations.join("\n")).toContain( + "_kDartVmSnapshotData has an incomplete or invalid snapshot header", + ); + }, +); + +it("does not let two VM records replace a missing isolate header hash", async () => { + const image = aotImage(); + const elf = buildElf64Image([ + { name: "_kDartVmSnapshotData", value: 0x1000, size: 104 }, + { name: "_kDartVmSnapshotInstructions", value: 0x1800, size: 16 }, + { name: "_kDartIsolateSnapshotData", value: 0x2000, size: 0x600 }, + { name: "_kDartIsolateSnapshotInstructions", value: 0x2600, size: 16 }, + ]); + elf.copy(image); + snapshotSection(REFERENCE_HASH).copy(image, 0x1000 + 52); + image[0x2000 + 20] = 0x78; // Keep the isolate magic but invalidate its hash. + const execution = await analyzeImage(image, "inspect_dart_aot"); + const value = flutterResultSchemas.inspect_dart_aot.parse(execution.result); + expect(value.libapp.sections).toHaveLength(4); + expect(value.coverage).toBe("partial"); + expect(value.libapp.snapshot_hash_sources).toBe(1); + expect(value.libapp.snapshot_hash_candidates).toEqual([REFERENCE_HASH]); + expect(execution.limitations.join("\n")).toContain( + "_kDartIsolateSnapshotData has an incomplete or invalid snapshot header", + ); +}); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s preserves an oversized APK refusal as a resource constraint", + async (operation) => { + const root = await createTestTempDirectory("rea-flutter-size-"); + const path = join(root, "oversized.apk"); + const file = await open(path, "wx"); + try { + await file.truncate(2 * 1024 ** 3 + 1); // Sparse file: no multi-GiB allocation. + } finally { + await file.close(); + } + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("expected a size refusal"); + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); + expect(result.error).toMatchObject({ + operation, + reportedLimits: { + max_apk_bytes: 2 * 1024 ** 3, + observed_bytes: 2 * 1024 ** 3 + 1, + }, + }); + }, +); + +it.each([ + { operation: "identify_flutter_build" }, + { operation: "inspect_dart_aot" }, +] as const)( + "preserves native stream aborts during $operation digest", + async ({ operation }) => { + const path = await buildApk("abort.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const controller = new AbortController(); + const nativeFs = await vi.importActual("node:fs"); + const streamMock = vi.mocked(createReadStream); + let abortCode: string | undefined; + streamMock.mockImplementation((streamPath, options) => { + const stream = nativeFs.createReadStream(streamPath, options); + if (streamPath !== path) { + stream.once("error", (cause: NodeJS.ErrnoException) => { + abortCode = cause.code; + }); + stream.once("readable", () => + controller.abort(new Error("caller requested stop")), + ); + } + return stream; + }); + onTestFinished(() => { + streamMock.mockReset(); + }); + const removedRoots: string[] = []; + const provider = new FlutterBuildProvider({ + removeRoot: async (root) => { + await rm(root, { recursive: true, force: true }); + removedRoots.push(root); + }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute( + { operation, input: { path } }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("expected cancellation"); + expect(abortCode).toBe("ABORT_ERR"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error).toMatchObject({ operation }); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(removedRoots).toHaveLength(1); + }, +); + +it.each([undefined, "arm64-v8a", "x86_64", "mips"])( + "applies the library cap only to selected ABI %s", + async (abi) => { + const path = await buildApk("mixed-sizes.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { path: "lib/x86_64/libapp.so", bytes: aotImage() }, + ]); + const originalEntries = ZipArtifactReader.prototype.entries; + const inventory = vi + .spyOn(ZipArtifactReader.prototype, "entries") + .mockImplementation(async function* (this: ZipArtifactReader, signal) { + for await (const entry of originalEntries.call(this, signal)) + yield entry.path.includes("x86_64") + ? { ...entry, declaredSize: 256 * 1024 ** 2 + 1 } + : entry; + }); + const reads = vi.spyOn(ZipArtifactReader.prototype, "open"); + onTestFinished(() => { + inventory.mockRestore(); + reads.mockRestore(); + }); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "inspect_dart_aot", + input: { path, ...(abi === undefined ? {} : { abi }) }, + }); + if (abi === "x86_64" || abi === "mips") { + if (result.ok) throw new Error("expected selected-ABI refusal"); + expect(projectAnalysisError(result.error).code).toBe( + abi === "x86_64" ? "resource_constraint" : "unsupported_target", + ); + expect(reads).not.toHaveBeenCalled(); + } else { + if (!result.ok) throw result.error; + expect( + flutterResultSchemas.inspect_dart_aot.parse(result.value.result).abi, + ).toBe("arm64-v8a"); + expect(reads.mock.calls.map(([entry]) => entry.path)).toEqual([ + "lib/arm64-v8a/libapp.so", + ]); + } + }, +); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s preserves a real ZIP metadata budget refusal", + async (operation) => { + const root = await createTestTempDirectory("rea-flutter-metadata-"); + const path = join(root, "large-directory.apk"); + const writer = new ZipWriter(new Uint8ArrayWriter(), { + useWebWorkers: false, + }); + const comment = "x".repeat(65535); + for (let index = 0; index < 129; index += 1) + await writer.add( + `assets/empty-${String(index)}`, + new Uint8ArrayReader(new Uint8Array()), + { comment, level: 0 }, + ); + await writeFile(path, await writer.close()); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("expected metadata capacity refusal"); + expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); + expect(result.error).toMatchObject({ + operation, + reportedLimits: { max_metadata_read_bytes: 8 * 1024 ** 2 }, + }); + expect(result.error.message).toContain("ZIP metadata read requires"); + }, +); + +it("refuses an AOT image whose snapshot names are all undefined imports", async () => { + const image = aotImage(); + const table = Number( + image.readBigUInt64LE(Number(image.readBigUInt64LE(0x28)) + 64 + 0x18), + ); + for (let index = 0; index < 4; index += 1) + image.writeUInt16LE(0, table + index * 24 + 6); + await expect(analyzeImage(image, "inspect_dart_aot")).rejects.toMatchObject({ + _tag: "AnalysisUnsupportedTargetError", + }); +}); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s retains a reader whose close failed before removing its root", + async (operation) => { + const path = await buildApk("reader-close.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const readers = new Set(); + const originalClose = ZipArtifactReader.prototype.close; + let failClose = true; + const close = vi + .spyOn(ZipArtifactReader.prototype, "close") + .mockImplementation(async function (this: ZipArtifactReader) { + readers.add(this); + if (failClose) throw new Error("file handle close refused"); + await originalClose.call(this); + }); + const removeRoot = vi.fn((root: string) => + rm(root, { recursive: true, force: true }), + ); + const provider = new FlutterBuildProvider({ removeRoot }); + onTestFinished(async () => { + failClose = false; + await provider.close(); + for (const reader of readers) await originalClose.call(reader); + close.mockRestore(); + }); + const result = await provider.execute({ operation, input: { path } }); + if (!result.ok) throw result.error; + expect(removeRoot).not.toHaveBeenCalled(); + await expect(provider.close()).rejects.toThrow("snapshot cleanup failed"); + expect(removeRoot).not.toHaveBeenCalled(); + failClose = false; + await provider.close(); + expect(close).toHaveBeenCalledTimes(3); + expect(removeRoot).toHaveBeenCalledTimes(1); + }, +); + +it("reports omitted Dart labels independently of toolchain labels", async () => { + const path = await buildApk("dart-label.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: Buffer.concat([ + libflutter(), + Buffer.from(`Dart VM version:${"x".repeat(201)}\0`), + ]), + }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.coverage).toBe("partial"); + expect(value.abis[0]?.libflutter).toMatchObject({ + dart_version: null, + dart_version_capped: true, + toolchain_capped: false, + }); +}); + +it.each([0, 1, 2, 3])( + "retains failed local file-handle closure at acquisition stage %i", + async (stage) => { + const path = await buildApk("file-close.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + // Close order: snapshot writer, admitted source, extracted-image writer, image reader. + const owners: OwnedFileHandle[] = []; + const originalClose = OwnedFileHandle.prototype.close; + let failing = true; + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockImplementation(async function (this: OwnedFileHandle) { + if (!owners.includes(this)) owners.push(this); + if (failing && owners[stage] === this) + throw new Error("handle close refused"); + await originalClose.call(this); + }); + const removeRoot = vi.fn((root: string) => + rm(root, { recursive: true, force: true }), + ); + const provider = new FlutterBuildProvider({ removeRoot }); + onTestFinished(async () => { + failing = false; + await provider.close(); + for (const owner of owners) await originalClose.call(owner); + close.mockRestore(); + }); + const result = await provider.execute({ + operation: "inspect_dart_aot", + input: { path }, + }); + expect(result.ok).toBe(false); + expect(removeRoot).not.toHaveBeenCalled(); + await expect(provider.close()).rejects.toThrow("snapshot cleanup failed"); + failing = false; + await provider.close(); + expect(removeRoot).toHaveBeenCalledTimes(1); + }, +); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s rejects duplicate real ZIP members before extracting either occurrence", + async (operation) => { + const path = await createDuplicateFlutterApk(); + const extract = vi.spyOn(ZipArtifactReader.prototype, "open"); + onTestFinished(() => extract.mockRestore()); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("Duplicate libraries must be refused"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + }); + expect(JSON.stringify(projectAnalysisError(result.error))).toContain( + "duplicate Flutter library members", + ); + expect(JSON.stringify(projectAnalysisError(result.error))).toContain(path); + expect(extract).not.toHaveBeenCalled(); + }, +); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s refuses ABI metadata expansion before opening empty libraries", + async (operation) => { + const path = await buildApk( + "many-abis.apk", + Array.from({ length: 33 }, (_, index) => ({ + path: `lib/abi_${String(index)}/libapp.so`, + bytes: Buffer.alloc(0), + })), + ); + const extract = vi.spyOn(ZipArtifactReader.prototype, "open"); + onTestFinished(() => extract.mockRestore()); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("ABI expansion must be refused"); + expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); + expect(extract).not.toHaveBeenCalled(); + }, +); + +it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( + "%s uses the selected path in malformed-APK diagnostics", + async (operation) => { + const root = await createTestTempDirectory("rea-flutter-malformed-"); + const path = join(root, "selected.apk"); + await writeFile(path, "not a ZIP"); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ operation, input: { path } }); + if (result.ok) throw new Error("Malformed APK must be refused"); + const error = projectAnalysisError(result.error); + expect(error).toMatchObject({ + code: "invalid_request", + details: { issues: [{ message: expect.stringContaining(path) }] }, + }); + expect(JSON.stringify(error)).not.toContain("input.apk"); + }, +); + +it.each(["EACCES", "EPERM"] as const)( + "preserves %s at the shared source admission boundary", + async (code) => { + const admitted = vi + .spyOn(regularFile, "openRegularFile") + .mockRejectedValue(Object.assign(new Error("denied"), { code })); + onTestFinished(() => admitted.mockRestore()); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + for (const operation of [ + "identify_flutter_build", + "inspect_dart_aot", + ] as const) { + const result = await provider.execute({ + operation, + input: { path: "/selected.apk" }, + }); + if (result.ok) throw new Error("Denied read must fail"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "access_denied", + }); + } + }, +); + +it.each(["EACCES", "EPERM"] as const)( + "preserves %s during opened-source reads and path revalidation", + async (code) => { + const path = await buildApk("source-denied.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const original = regularFile.openRegularFile; + const cause = Object.assign(new Error("source denied"), { code }); + for (const stage of ["read", "revalidate"] as const) { + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const admitted = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementation(async (...args) => { + const handle = await original(...args); + if (stage === "read") + vi.spyOn(handle, "read").mockRejectedValueOnce(cause); + return handle; + }); + const pathStat = + stage === "revalidate" + ? vi.spyOn(fsPromises, "stat").mockRejectedValueOnce(cause) + : undefined; + try { + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (result.ok) throw new Error("Denied source must fail"); + expect(projectAnalysisError(result.error).code).toBe("access_denied"); + expect(result.error.cause).toBe(cause); + } finally { + admitted.mockRestore(); + pathStat?.mockRestore(); + } + } + }, +); + +it("detects same-size source rewrites with restored mtime through the opened descriptor", async () => { + const path = await buildApk("changing.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const before = await stat(path); + const content = await readFile(path); + const nativeAdmission = regularFile.openRegularFile; + const admitted = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementation(async (...args) => { + const handle = await nativeAdmission(...args); + // Admission returns an open descriptor; mutate its file before the final + // descriptor stat, preserving the size and restoring the visible mtime. + const nativeStat = handle.stat.bind(handle); + let calls = 0; + vi.spyOn(handle, "stat").mockImplementation(async (...statArgs) => { + calls += 1; + if (calls === 2) { + await new Promise((resolve) => setTimeout(resolve, 10)); + await writeFile(path, content); + await utimes(path, before.atime, before.mtime); + } + return nativeStat(...statArgs); + }); + return handle; + }); + onTestFinished(() => admitted.mockRestore()); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (result.ok) throw new Error("Concurrent source rewrite must fail"); + expect(projectAnalysisError(result.error).code).toBe("artifact_changed"); +}); + +it("refuses truncated hash-candidate evidence and includes five-character tokens", async () => { + const image = aotImage(); + Buffer.from("Login\0").copy(image, 0x2300); + const aot = await analyzeImage(image, "inspect_dart_aot"); + expect( + flutterResultSchemas.inspect_dart_aot.parse(aot.result).string_pool + .class_like_tokens, + ).toContain("Login"); + const bytes = Buffer.concat( + Array.from({ length: 65 }, (_, index) => + snapshotSection(index.toString(16).padStart(32, "0")), + ), + ); + const path = await buildApk("many-hashes.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (result.ok) throw new Error("Incomplete candidate set must be explicit"); + expect(projectAnalysisError(result.error).code).toBe("resource_constraint"); +}); + +it("stops a cancelled descriptor copy before writing its next chunk and releases every owner", async () => { + const path = await buildApk("copy-cancel.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + ]); + const controller = new AbortController(); + const nativeAdmission = regularFile.openRegularFile; + const admitted = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementation(async (...args) => { + const handle = await nativeAdmission(...args); + const nativeRead = handle.read.bind(handle); + vi.spyOn(handle, "read").mockImplementation(async (...readArgs) => { + const result = await nativeRead(...readArgs); + controller.abort(new Error("cancelled while copy read was pending")); + return result; + }); + return handle; + }); + onTestFinished(() => admitted.mockRestore()); + const removeRoot = vi.fn((root: string) => + rm(root, { recursive: true, force: true }), + ); + const provider = new FlutterBuildProvider({ removeRoot }); + onTestFinished(() => provider.close()); + const result = await provider.execute( + { operation: "identify_flutter_build", input: { path } }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Cancelled copying must fail"); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(removeRoot).toHaveBeenCalledTimes(1); +}); diff --git a/tests/boundary/mcp/flutterMcp.test.ts b/tests/boundary/mcp/flutterMcp.test.ts new file mode 100644 index 0000000000..646f545113 --- /dev/null +++ b/tests/boundary/mcp/flutterMcp.test.ts @@ -0,0 +1,183 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it, onTestFinished } from "vitest"; + +import type { FlutterBuildAnalysisPort } from "../../../src/application/flutter/FlutterBuildAnalysisPort.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { ok } from "../../../src/domain/result.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; + +const identity = { + id: "flutter", + name: "Flutter build identification", + version: null, +}; + +const SNAPSHOT_HASH = "65817c30a78bb44c3dc3771876b6010a"; + +/** One shared target identity for both fake operation results. */ +const target = { + path: "/targets/gallery.apk", + bytes: 117817550, + sha256: "a".repeat(64), +}; + +const inspectExecution = () => + createAnalysisExecution( + { + target, + abi: "arm64-v8a", + libapp: { + coverage: "complete", + bytes: 24691632, + sha256: "b".repeat(64), + snapshot_hash: SNAPSHOT_HASH, + snapshot_hash_candidates: [SNAPSHOT_HASH], + snapshot_hash_sources: 2, + sections: [ + { + name: "_kDartVmSnapshotData", + offset: 4096, + size: 52, + magic_valid: true, + kind: 3, + header_length: 48, + }, + { + name: "_kDartIsolateSnapshotData", + offset: 8192, + size: 1536, + magic_valid: true, + kind: 3, + header_length: 48, + }, + ], + }, + string_pool: { + coverage: "complete", + package_uris: [ + "package:adaptive_breakpoints/src/adaptive_breakpoints.dart", + ], + package_uri_count_at_least: 1, + dart_uris: ["dart:_http"], + dart_uri_count_at_least: 1, + class_like_tokens: ["AdaptiveBreakpoint"], + class_like_token_count_at_least: 1, + printable_run_count: 4, + }, + coverage: "complete", + }, + identity, + ); + +const identifyExecution = () => + createAnalysisExecution( + { + target, + flutter_detected: true, + abis: [ + { + abi: "arm64-v8a", + libapp: { + present: true, + bytes: 24691632, + sha256: "b".repeat(64), + elf_header_valid: true, + snapshot_hash_sources: 2, + snapshot_hash_candidates: [SNAPSHOT_HASH], + snapshot_hash: SNAPSHOT_HASH, + }, + libflutter: { + present: true, + bytes: 9073432, + sha256: "c".repeat(64), + build_id: "e8d907ef7c175ec4313f47111019adc9588c5f61", + build_id_candidates: ["e8d907ef7c175ec4313f47111019adc9588c5f61"], + dart_version: null, + dart_version_capped: false, + toolchain_lines: ["Android (5900059) clang version 9.0.8"], + toolchain_capped: false, + }, + }, + ], + coverage: "complete", + }, + identity, + ); + +/** A recording-free provider answering both Flutter operations inline. */ +const fakeProvider = (): FlutterBuildAnalysisPort => ({ + async inspectAvailability() { + return { status: "available", code: null, reason: null, diagnostics: {} }; + }, + async close() {}, + async execute(request) { + return ok( + request.operation === "inspect_dart_aot" + ? inspectExecution() + : identifyExecution(), + ); + }, +}); + +it("advertises an exact valid Flutter schema and records inline evidence through MCP", async () => { + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { flutterAnalysis: fakeProvider() }, + ); + onTestFinished(() => server.close()); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "flutter-boundary", version: "0.0.0" }); + onTestFinished(() => client.close()); + await client.connect(clientTransport); + + const { tools } = await client.listTools(); + assertAdvertisedMcpContracts( + ["identify_flutter_build", "inspect_dart_aot"], + tools, + ); + + const identified = await client.callTool({ + name: "identify_flutter_build", + arguments: { path: "/targets/gallery.apk" }, + }); + expect(identified.isError).not.toBe(true); + const text = (identified.content as { readonly text?: string }[]) + .map((part) => part.text ?? "") + .join(""); + const evidence = parseEvidence(JSON.parse(text)); + expect(evidence.normalized_result).toMatchObject({ + flutter_detected: true, + abis: [ + { + abi: "arm64-v8a", + libapp: { snapshot_hash: SNAPSHOT_HASH }, + }, + ], + }); + + const inspected = await client.callTool({ + name: "inspect_dart_aot", + arguments: { path: "/targets/gallery.apk", abi: "arm64-v8a" }, + }); + expect(inspected.isError).not.toBe(true); + const inspectedText = (inspected.content as { readonly text?: string }[]) + .map((part) => part.text ?? "") + .join(""); + const inspectedEvidence = parseEvidence(JSON.parse(inspectedText)); + expect(inspectedEvidence.normalized_result).toMatchObject({ + abi: "arm64-v8a", + libapp: { + snapshot_hash: SNAPSHOT_HASH, + snapshot_hash_candidates: [SNAPSHOT_HASH], + snapshot_hash_sources: 2, + }, + }); +}); diff --git a/tests/fixtures/flutterApk.ts b/tests/fixtures/flutterApk.ts new file mode 100644 index 0000000000..068dee929a --- /dev/null +++ b/tests/fixtures/flutterApk.ts @@ -0,0 +1,39 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { aotFixtureImage } from "../../src/flutter/FlutterBuild.fixture.js"; +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +/** Materialize real ZIP members with test-scoped exact-path cleanup. */ +export const createFlutterApk = async ( + name: string, + files: ReadonlyArray<{ readonly path: string; readonly bytes: Buffer }>, +): Promise => { + const root = await createTestTempDirectory("rea-flutter-"); + const writer = new ZipWriter(new Uint8ArrayWriter(), { + useWebWorkers: false, + }); + for (const file of files) + await writer.add(file.path, new Uint8ArrayReader(file.bytes)); + const path = join(root, name); + await writeFile(path, await writer.close()); + return path; +}; + +/** ZIP permits repeated names; the writer refuses them, so rewrite both records. */ +export const createDuplicateFlutterApk = async (): Promise => { + const path = await createFlutterApk("duplicates.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotFixtureImage() }, + { path: "lib/arm64-v8a/libxxx.so", bytes: Buffer.alloc(64) }, + ]); + const bytes = await readFile(path); + const from = Buffer.from("lib/arm64-v8a/libxxx.so"); + for ( + let at = bytes.indexOf(from); + at >= 0; + at = bytes.indexOf(from, at + from.length) + ) + bytes.write("lib/arm64-v8a/libapp.so", at); + await writeFile(path, bytes); + return path; +}; diff --git a/vitest.config.ts b/vitest.config.ts index 716b3b0e51..74dbf53056 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -51,7 +51,7 @@ const TEST_PROJECTS = [ name: "adapters", include: [ "src/*.test.ts", - "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts", + "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,server,wasm,windows}/**/*.test.ts", ], pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS,