From fef6f730643dd60a80a109fc9751e1782b79ab4a Mon Sep 17 00:00:00 2001 From: Quentin Limo <85309073+3QL@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:01:24 +0200 Subject: [PATCH] feat(adb): complete ADB device support for Android reverse engineering (#1552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(adb): acquire installed APK sets from connected Android devices Add a read-only ADB device-acquisition family over the caller's adb binary: inspect_adb_client, list_adb_devices, inspect_adb_device, list_adb_packages, and pull_adb_package, exposed through the CLI and MCP with shared admission, Evidence provenance (binary, version, serial, transport), and fixed argv vectors that never reach a shell. pull_adb_package acquires the complete split set reported by pm path into a package-named directory that must not exist, digesting every file with SHA-256 and keeping per-file failure reasons and partial coverage. Device kind is reported as an inference with its basis; getprop whitelisting keeps build identity bounded. REA installs nothing and never manages devices or emulators; the first device query may start the caller's local adb server, recorded when observed. Verified against adb 34.0.5 on Linux with an Android 14 (API 34) x86_64 emulator: full 224-package inventory, a real two-APK split set pulled with byte-exact digests, and a 73.9 MB system-package pull. npm run verify:adb records the lane; parser goldens come from the same device. READMEs carry the new row in every supported language. * feat(adb): complete the adb surface with observation, transfer, and lifecycle tools Extend the ADB family from five to twenty-four operations, all through fixed adb argument vectors with no shell exposure: Observation: read_adb_logcat (bounded dump, count/buffer/pid), inspect_adb_package (dumpsys package projection), list_adb_processes (ps -A), list_adb_directory (ls -la with kinds and symlink targets), list_adb_features (hex GL versions), list_adb_services (AIDL slash-suffixed names), inspect_adb_display (wm size and density), inspect_adb_window (focus declarations), read_adb_setting (device-side nulls reported as such), capture_adb_screen (exec-out screencap with PNG dimensions), collect_adb_bugreport (digested device archive). Transfer and search: pull_adb_file and push_adb_file (device-side sha256sum verification, explicit overwrite authority), resolve_adb_packages (substring package search with exact-match flag — the find step of an easy get). Lifecycle for dynamic analysis, each declaring mutatesTarget: install_adb_package and uninstall_adb_package preserving the device's Success/Failure verdict, start_adb_app (resolve-activity then am start -W), start_adb_activity (typed extras as --es/--ez/--ei/--el/--ef), and stop_adb_app (am force-stop). Verified end-to-end against adb 34.0.5 on Linux with an Android 14 (API 34) x86_64 emulator: 287 services, 92 features, split-set pulls with byte-exact digests, verified push/pull roundtrip, sized screen capture, and the full install → resolve → start → observe-in-ps → force-stop → uninstall lifecycle. Fixed a v-mode regex incompatibility in the settings key pattern and added argument descriptions required by the CLI catalog audit. * fix(adb): quote remote shell operands and protect captured output * test(adb): cover bugreport destinations containing spaces --------- Co-authored-by: REA Contributor Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- README.md | 1 + README_ar.md | 1 + README_de.md | 1 + README_es.md | 1 + README_fa.md | 1 + README_fr.md | 1 + README_id.md | 1 + README_ja.md | 1 + README_ko.md | 1 + README_pl.md | 1 + README_pt-BR.md | 1 + README_ru.md | 1 + README_th.md | 1 + README_tr.md | 1 + README_uk.md | 1 + README_vi.md | 1 + README_zh-TW.md | 1 + README_zh.md | 1 + docs/adb-device-analysis.md | 164 ++ docs/testing.md | 31 + package.json | 1 + scripts/lib/catalog-builders.mjs | 9 + scripts/lib/catalog-core.mjs | 2 + scripts/verify-adb.mjs | 400 ++++ src/adb/AdbConfiguration.test.ts | 58 + src/adb/AdbConfiguration.ts | 144 ++ src/adb/AdbDeviceOutput.test.ts | 242 +++ src/adb/AdbDeviceOutput.ts | 248 +++ src/adb/AdbProvider.test.ts | 960 +++++++++ src/adb/AdbProvider.ts | 1776 +++++++++++++++++ src/adb/AdbShellOutput.ts | 433 ++++ src/application/adb/AdbDeviceAnalysisPort.ts | 23 + .../adb/AdbDeviceAnalysisService.test.ts | 96 + .../adb/AdbDeviceAnalysisService.ts | 46 + src/cli.ts | 2 + src/cli/adbCommands.ts | 393 ++++ src/cliCommandNames.ts | 24 + src/composition/adb.ts | 7 + src/contracts/adb/adbToolContracts.ts | 382 ++++ src/contracts/toolContractTypes.ts | 1 + src/contracts/toolContracts.ts | 2 + src/contracts/toolEffects.ts | 140 ++ src/domain/adb/adbDeviceAnalysis.ts | 706 +++++++ src/server/createServer.ts | 13 + src/server/registerAdbTools.ts | 66 + tests/boundary/cli/adbCli.test.ts | 77 + tests/boundary/mcp/adbMcp.test.ts | 397 ++++ vitest.config.ts | 2 +- 48 files changed, 6862 insertions(+), 1 deletion(-) create mode 100644 docs/adb-device-analysis.md create mode 100644 scripts/verify-adb.mjs create mode 100644 src/adb/AdbConfiguration.test.ts create mode 100644 src/adb/AdbConfiguration.ts create mode 100644 src/adb/AdbDeviceOutput.test.ts create mode 100644 src/adb/AdbDeviceOutput.ts create mode 100644 src/adb/AdbProvider.test.ts create mode 100644 src/adb/AdbProvider.ts create mode 100644 src/adb/AdbShellOutput.ts create mode 100644 src/application/adb/AdbDeviceAnalysisPort.ts create mode 100644 src/application/adb/AdbDeviceAnalysisService.test.ts create mode 100644 src/application/adb/AdbDeviceAnalysisService.ts create mode 100644 src/cli/adbCommands.ts create mode 100644 src/composition/adb.ts create mode 100644 src/contracts/adb/adbToolContracts.ts create mode 100644 src/domain/adb/adbDeviceAnalysis.ts create mode 100644 src/server/registerAdbTools.ts create mode 100644 tests/boundary/cli/adbCli.test.ts create mode 100644 tests/boundary/mcp/adbMcp.test.ts diff --git a/README.md b/README.md index f517b3896..fcf48f432 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ Additional tools and host support depend on the target: | Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) | | .NET assemblies | Metadata, CIL instructions, declared native dependencies and build comparisons | None (static only); [managed-code guide](docs/managed-code-analysis.md) | | Android APKs | Manifest declarations, classes, decompiled methods and references | Headless JADX and a full JDK on Linux/macOS/Windows x64; [Android guide](docs/android-analysis.md) | +| 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) | | 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 237e9ad77..57c3262b7 100644 --- a/README_ar.md +++ b/README_ar.md @@ -142,6 +142,7 @@ npx rea-agents@latest setup | تسجيلات الشبكة المحفوظة | الطلبات، والاستجابات، ومحتويات الحمولة المتاحة، ومواضعها في المصدر | HAR؛ وmitmdump على Linux لتسجيلات mitmproxy بصيغتها الأصلية؛ [دليل تسجيلات الشبكة](docs/web-network-captures.md) | | تجميعات .NET | البيانات الوصفية، وتعليمات CIL، واعتماديات الشيفرة الأصلية المعلنة، ومقارنات البناء | فحص ثابت؛ [دليل الشيفرة المُدارة](docs/managed-code-analysis.md) | | حزم Android APK | تصريحات ملف manifest، والأصناف، والدوال الناتجة عن فك الترجمة، والمراجع | JADX دون واجهة رسومية وJDK كامل على Linux/macOS/Windows x64؛ [دليل Android](docs/android-analysis.md) | +| أجهزة Android | الأجهزة والحزم والعمليات والسجلات والدامبات ولقطات الشاشة ونقل APK/الملفات | adb يوفّره المُستدعي (محاكي أو جهاز)؛ [دليل ADB](docs/adb-device-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 31f2ae4c2..b69d5b1eb 100644 --- a/README_de.md +++ b/README_de.md @@ -142,6 +142,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz | Gespeicherte Netzwerkaufzeichnungen | Anfragen, Antworten, zugängliche Nutzdaten und Quellpositionen | HAR; mitmdump unter Linux für Aufzeichnungen im nativen mitmproxy-Format; [Leitfaden zu Netzwerkaufzeichnungen](docs/web-network-captures.md) | | .NET-Assemblies | Metadaten, CIL-Anweisungen, deklarierte native Abhängigkeiten und Build-Vergleiche | Statische Untersuchung; [Leitfaden für verwalteten Code](docs/managed-code-analysis.md) | | Android-APKs | Manifest-Deklarationen, Klassen, dekompilierte Methoden und Verweise | JADX ohne grafische Oberfläche und ein vollständiges JDK unter Linux/macOS/Windows x64; [Android-Leitfaden](docs/android-analysis.md) | +| 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) | | 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 f032f173e..0bc4ad4c8 100644 --- a/README_es.md +++ b/README_es.md @@ -142,6 +142,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h | Capturas de red guardadas | Solicitudes, respuestas, contenido accesible de las cargas útiles y ubicaciones de origen | HAR; mitmdump en Linux para capturas en formato nativo de mitmproxy; [guía de capturas](docs/web-network-captures.md) | | Ensamblados .NET | Metadatos, instrucciones CIL, dependencias nativas declaradas y comparaciones de compilaciones | Inspección estática; [guía de código administrado](docs/managed-code-analysis.md) | | APK de Android | Declaraciones del manifiesto, clases, métodos descompilados y referencias | JADX sin interfaz gráfica y un JDK completo en Linux/macOS/Windows x64; [guía de Android](docs/android-analysis.md) | +| 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) | | 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 47bdcfc8e..ec515c66b 100644 --- a/README_fa.md +++ b/README_fa.md @@ -134,6 +134,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np | داده‌های ضبط‌شدهٔ شبکه | درخواست‌ها، پاسخ‌ها، payloadهای آشکارشده و مکان آن‌ها در منبع | HAR؛ ابزار mitmdump در لینوکس برای ضبط‌های بومی mitmproxy؛ [راهنمای ضبط شبکه](docs/web-network-captures.md) | | اسمبلی‌های .NET | فراداده، دستورالعمل‌های CIL، وابستگی‌های بومی اعلام‌شده و مقایسهٔ buildها | بررسی ایستا؛ [راهنمای کد مدیریت‌شده](docs/managed-code-analysis.md) | | فایل‌های APK اندروید | تعاریف Manifest، کلاس‌ها، متدهای دیکامپایل‌شده و ارجاعات | JADX بدون رابط گرافیکی و JDK کامل در Linux/macOS/Windows x64؛ [راهنمای اندروید](docs/android-analysis.md) | +| دستگاه‌های اندروید | دستگاه‌ها، بسته‌ها، فرایندها، لاگ‌ها، دامپ‌ها، اسکرین‌شات‌ها و انتقال APK/فایل | adb تأمین‌شده توسط فراخوان‌کننده (شبیه‌ساز یا دستگاه)؛ [راهنمای ADB](docs/adb-device-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 adbcb1631..0f627f8c2 100644 --- a/README_fr.md +++ b/README_fr.md @@ -143,6 +143,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les | Captures réseau enregistrées | Requêtes, réponses, payloads exposés et emplacements dans le code source | HAR ; mitmdump sous Linux pour les captures mitmproxy natives ; [guide des captures](docs/web-network-captures.md) | | Assemblys .NET | Métadonnées, instructions CIL, dépendances natives déclarées et comparaisons de builds | Inspection statique ; [guide du code managé](docs/managed-code-analysis.md) | | APK Android | Déclarations du manifeste, classes, méthodes décompilées et références | JADX headless et un JDK complet sous Linux/macOS/Windows x64 ; [guide Android](docs/android-analysis.md) | +| 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) | | 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 225c6d317..dfeef6ae6 100644 --- a/README_id.md +++ b/README_id.md @@ -146,6 +146,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis: | Rekaman jaringan tersimpan | Permintaan, respons, payload yang terekspos, dan lokasi sumber | HAR; mitmdump di Linux untuk rekaman native mitmproxy; [panduan rekaman](docs/web-network-captures.md) | | Assembly .NET | Metadata, instruksi CIL, dependensi native yang dideklarasikan, dan perbandingan build | Tidak ada (hanya analisis statis); [panduan kode terkelola](docs/managed-code-analysis.md) | | APK Android | Deklarasi manifest, kelas, metode hasil dekompilasi, dan referensi | JADX mode headless dan JDK lengkap di Linux/macOS/Windows x64; [panduan Android](docs/android-analysis.md) | +| 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) | | 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_ja.md b/README_ja.md index a0db801d1..1a377703e 100644 --- a/README_ja.md +++ b/README_ja.md @@ -142,6 +142,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm | 保存済みのネットワークキャプチャ | リクエスト、レスポンス、取得可能なペイロード、ソース内の位置 | HAR;mitmproxy ネイティブ形式のキャプチャには Linux 上の mitmdump;[キャプチャガイド](docs/web-network-captures.md) | | .NET アセンブリ | メタデータ、CIL 命令、宣言されたネイティブ依存関係、ビルドの比較 | 静的な検査;[マネージドコードのガイド](docs/managed-code-analysis.md) | | Android APK | マニフェストの宣言、クラス、逆コンパイルされたメソッド、参照 | Linux/macOS/Windows x64 上のヘッドレス JADX と完全な JDK;[Android ガイド](docs/android-analysis.md) | +| Android デバイス | デバイス、パッケージ、プロセス、ログ、ダンプ、スクリーンキャプチャ、APK/ファイル転送 | 呼び出し側が用意した adb(エミュレーターまたは実機);[ADB ガイド](docs/adb-device-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 d27b8f405..4791602eb 100644 --- a/README_ko.md +++ b/README_ko.md @@ -142,6 +142,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니 | 저장된 네트워크 캡처 | 요청, 응답, 접근 가능한 페이로드, 소스 위치 | HAR; mitmproxy 네이티브 캡처에는 Linux의 mitmdump; [캡처 가이드](docs/web-network-captures.md) | | .NET 어셈블리 | 메타데이터, CIL 명령, 선언된 네이티브 의존성, 빌드 비교 | 정적 검사; [관리 코드 가이드](docs/managed-code-analysis.md) | | Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](docs/android-analysis.md) | +| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](docs/adb-device-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 0eaf1810e..b5c6ffa99 100644 --- a/README_pl.md +++ b/README_pl.md @@ -142,6 +142,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na | Zapisane przechwycenia ruchu sieciowego | Żądania, odpowiedzi, dostępne dane przesyłane i lokalizacje źródłowe | HAR; mitmdump w systemie Linux dla natywnych przechwyceń mitmproxy; [przewodnik przechwytywania](docs/web-network-captures.md) | | Zestawy .NET | Metadane, instrukcje CIL, zadeklarowane zależności natywne i porównania kompilacji | Analiza statyczna; [przewodnik kodu zarządzanego](docs/managed-code-analysis.md) | | Pakiety APK dla Androida | Deklaracje manifestu, klasy, zdekompilowane metody i odwołania | JADX bez interfejsu graficznego i pełny JDK w systemach Linux/macOS/Windows x64; [przewodnik Androida](docs/android-analysis.md) | +| 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) | | 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 172286add..656a10daa 100644 --- a/README_pt-BR.md +++ b/README_pt-BR.md @@ -142,6 +142,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer | Capturas de rede salvas | Requisições, respostas, payloads acessíveis e localizações de origem | HAR; mitmdump no Linux para capturas no formato nativo do mitmproxy; [guia de capturas](docs/web-network-captures.md) | | Assemblies .NET | Metadados, instruções CIL, dependências nativas declaradas e comparações de builds | Inspeção estática; [guia de código gerenciado](docs/managed-code-analysis.md) | | APKs Android | Declarações do manifesto, classes, métodos descompilados e referências | JADX sem interface gráfica e um JDK completo no Linux/macOS/Windows x64; [guia de Android](docs/android-analysis.md) | +| 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) | | 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 8e8e744ca..6912c9848 100644 --- a/README_ru.md +++ b/README_ru.md @@ -142,6 +142,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так | Сохранённые сетевые захваты | Запросы, ответы, доступные полезные данные и позиции в источнике | HAR; mitmdump в Linux для захватов в собственном формате mitmproxy; [руководство по сетевым захватам](docs/web-network-captures.md) | | Сборки .NET | Метаданные, инструкции CIL, объявленные нативные зависимости и сравнения сборок | Статическое исследование; [руководство по управляемому коду](docs/managed-code-analysis.md) | | Android APK | Объявления манифеста, классы, декомпилированные методы и ссылки | JADX без графического интерфейса и полный JDK в Linux/macOS/Windows x64; [руководство по Android](docs/android-analysis.md) | +| Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](docs/adb-device-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 1e93f93b9..9c8e0fb5b 100644 --- a/README_th.md +++ b/README_th.md @@ -142,6 +142,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26 | ข้อมูลเครือข่ายที่บันทึกไว้ | คำขอ คำตอบ เพย์โหลดที่ปรากฏ และตำแหน่งต้นทาง | HAR; mitmdump บน Linux สำหรับข้อมูลที่บันทึกในรูปแบบเนทีฟของ mitmproxy; [คู่มือการบันทึกข้อมูล](docs/web-network-captures.md) | | แอสเซมบลี .NET | เมทาดาทา คำสั่ง CIL การพึ่งพาเนทีฟที่ประกาศไว้ และการเปรียบเทียบผลการบิลด์ | การตรวจสอบแบบสถิต; [คู่มือโค้ดแบบจัดการ](docs/managed-code-analysis.md) | | APK ของ Android | การประกาศใน manifest คลาส เมธอดที่ดีคอมไพล์ และการอ้างอิง | JADX แบบไม่มี GUI และ JDK แบบเต็มบน Linux/macOS/Windows x64; [คู่มือ Android](docs/android-analysis.md) | +| อุปกรณ์ Android | อุปกรณ์ แพ็กเกจ โพรเซส บันทึก ดัมพ์ ภาพหน้าจอ และการถ่ายโอน APK/ไฟล์ | adb ที่ผู้เรียกจัดเตรียม (โปรแกรมจำลองหรืออุปกรณ์จริง); [คู่มือ ADB](docs/adb-device-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 46e1ab81d..cad5ef758 100644 --- a/README_tr.md +++ b/README_tr.md @@ -142,6 +142,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç | Kaydedilmiş ağ yakalamaları | İstekler, yanıtlar, erişilebilir yük içerikleri ve kaynak konumları | HAR; mitmproxy'nin kendi biçimindeki yakalamalar için Linux üzerinde mitmdump; [ağ yakalama rehberi](docs/web-network-captures.md) | | .NET derlemeleri | Meta veriler, CIL talimatları, bildirilmiş yerel bağımlılıklar ve derleme karşılaştırmaları | Statik inceleme; [yönetilen kod rehberi](docs/managed-code-analysis.md) | | Android APK'leri | Manifest bildirimleri, sınıflar, tersine derlenmiş metotlar ve referanslar | Linux/macOS/Windows x64 üzerinde arayüzsüz JADX ve tam bir JDK; [Android rehberi](docs/android-analysis.md) | +| 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) | | 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 c71e6f695..0d3d3e94c 100644 --- a/README_uk.md +++ b/README_uk.md @@ -142,6 +142,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та | Збережені мережеві захоплення | Запити, відповіді, доступні корисні дані та місця в джерелі | HAR; mitmdump у Linux для захоплень у власному форматі mitmproxy; [посібник мережевих захоплень](docs/web-network-captures.md) | | Збірки .NET | Метадані, інструкції CIL, оголошені нативні залежності й порівняння збірок | Статична перевірка; [посібник керованого коду](docs/managed-code-analysis.md) | | Android APK | Оголошення маніфесту, класи, декомпільовані методи й посилання | JADX без графічного інтерфейсу й повний JDK у Linux/macOS/Windows x64; [посібник Android](docs/android-analysis.md) | +| Пристрої Android | Пристрої, пакети, процеси, журнали, дампи, знімки екрана та передавання APK/файлів | adb, наданий викликаючою стороною (емулятор або пристрій); [підручник ADB](docs/adb-device-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 dc8c45a04..c837f7f63 100644 --- a/README_vi.md +++ b/README_vi.md @@ -142,6 +142,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np | Bản ghi lưu lượng mạng đã lưu | Yêu cầu, phản hồi, payload có thể truy cập và vị trí nguồn | HAR; mitmdump trên Linux cho bản ghi định dạng gốc của mitmproxy; [hướng dẫn bản ghi mạng](docs/web-network-captures.md) | | Assembly .NET | Siêu dữ liệu, lệnh CIL, các phụ thuộc mã máy được khai báo và so sánh bản dựng | Kiểm tra tĩnh; [hướng dẫn mã được quản lý](docs/managed-code-analysis.md) | | APK Android | Khai báo manifest, lớp, phương thức được dịch ngược và tham chiếu | JADX không giao diện đồ họa và JDK đầy đủ trên Linux/macOS/Windows x64; [hướng dẫn Android](docs/android-analysis.md) | +| 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) | | 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 65167490f..df3eeaf28 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -142,6 +142,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | 已保存的網路擷取 | 請求、回應、可訪問的酬載和來源位置 | HAR;原生 mitmproxy 擷取需要 Linux 上的 mitmdump;[擷取指南](docs/web-network-captures.md) | | .NET 程式集 | 中繼資料、CIL 指令、聲明的原生依賴和建置對比 | 靜態檢查;[托管程式碼指南](docs/managed-code-analysis.md) | | Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) | +| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](docs/adb-device-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 458f2b240..25aaa8449 100644 --- a/README_zh.md +++ b/README_zh.md @@ -142,6 +142,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。 | 已保存的网络捕获 | 请求、响应、可访问的载荷和来源位置 | HAR;原生 mitmproxy 捕获需要 Linux 上的 mitmdump;[捕获指南](docs/web-network-captures.md) | | .NET 程序集 | 元数据、CIL 指令、声明的原生依赖和构建对比 | 静态检查;[托管代码指南](docs/managed-code-analysis.md) | | Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) | +| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](docs/adb-device-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/adb-device-analysis.md b/docs/adb-device-analysis.md new file mode 100644 index 000000000..aa9e3e251 --- /dev/null +++ b/docs/adb-device-analysis.md @@ -0,0 +1,164 @@ +# ADB device inspection and acquisition + +REA inspects connected Android devices, reads device state, and transfers +files and packages through the caller's `adb` binary. Emulators, USB +devices, and network devices participate alike: REA neither starts nor +manages them and never installs platform tools. Every operation composes a +fixed adb argument vector — caller values occupy positional arguments only +and are quoted when adb forwards them to the device shell. + +The adb client may start its local server on first device query; that is adb +client behavior, recorded in the tool result when observed. +`inspect_adb_client` probes only the binary and never contacts the server. + +## Supply tools + +Use any Android platform-tools `adb` (verified with 34.0.5). Select it +through the environment: + +```sh +# Optional: absolute path to the adb binary; without it, adb resolves from PATH. +export REA_ADB_PATH=/absolute/path/to/adb +``` + +REA never installs the Android SDK, an emulator, or a system image. +Readiness is `rea inspect-adb-client`: it runs `adb version` and reports the +resolved binary, its version, and where it was selected from. A missing +binary reports `capability_unavailable` with the `REA_ADB_PATH` remediation +rather than attempting an installation. + +## Answer an analyst question + +```sh +# Device identity +rea inspect-adb-client +rea list-adb-devices +rea inspect-adb-device emulator-5554 +rea inspect-adb-display emulator-5554 +rea list-adb-features emulator-5554 +rea list-adb-services emulator-5554 + +# Find and acquire an app (the easy-get flow) +rea resolve-adb-packages emulator-5554 whatsapp +rea list-adb-packages emulator-5554 --scope third_party +rea inspect-adb-package emulator-5554 com.whatsapp +rea pull-adb-package emulator-5554 com.whatsapp --output-directory /tmp/acq +rea inspect-android-package /tmp/acq/com.whatsapp/base.apk + +# Device state and files +rea list-adb-processes emulator-5554 +rea inspect-adb-window emulator-5554 +rea read-adb-setting emulator-5554 global adb_enabled +rea read-adb-logcat emulator-5554 --count 200 +rea list-adb-directory emulator-5554 /data/data/com.whatsapp +rea pull-adb-file emulator-5554 /data/local/tmp/notes.txt --output-directory /tmp/acq +rea push-adb-file emulator-5554 /tools/frida-server /data/local/tmp/frida-server +rea capture-adb-screen emulator-5554 --output-directory /tmp/shots +rea collect-adb-bugreport emulator-5554 --output-directory /tmp/bugs + +# Package lifecycle and dynamic analysis (device-mutating) +rea install-adb-package emulator-5554 /tmp/app.apk --replace +rea start-adb-app emulator-5554 com.example.app +rea read-adb-logcat emulator-5554 --pid 12345 +rea start-adb-activity emulator-5554 android.intent.action.VIEW --data-uri https://example.com +rea stop-adb-app emulator-5554 com.example.app +rea uninstall-adb-package emulator-5554 com.example.app +``` + +Equivalent MCP methods use the snake_case names (`list_adb_devices`, +`pull_adb_package`, `resolve_adb_packages`, …). Device operations take +`serial` exactly as `adb devices` reports it. The pulled APK set feeds the +existing analysis families directly, for example +`rea inspect-android-package /tmp/acq//base.apk`. + +## Operations + +| Tool | Kind | What it does | +| ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `inspect_adb_client` | read | Resolved binary, version, install path; never contacts the server | +| `list_adb_devices` | read | Serial, state, transport, model metadata, inferred emulator/physical kind with basis | +| `inspect_adb_device` | read | Fixed `getprop` whitelist: build, SDK, patch, fingerprint, product, ABIs, debuggable, `ro.kernel.qemu` | +| `inspect_adb_display` | read | `wm size` and `wm density` as declared | +| `list_adb_features` | read | `pm list features` (hardware/software features, hex GL versions) | +| `list_adb_services` | read | `service list` binder services and interfaces | +| `list_adb_processes` | read | `ps -A`: user, pid, ppid, rss, state, name | +| `inspect_adb_window` | read | `dumpsys window` focus declarations (what is on screen) | +| `read_adb_setting` | read | One `settings get` value; device-side null is reported as such | +| `read_adb_logcat` | read | Bounded `logcat -d -t` dump for one buffer, optional pid filter | +| `list_adb_packages` | read | `pm list packages -f` with scope; base APK device paths | +| `resolve_adb_packages` | read | Case-insensitive package-name substring search with exact-match flag | +| `inspect_adb_package` | read | `dumpsys package` projection: versions, install times, installer, flags, permission count | +| `list_adb_directory` | read | `ls -la`: kinds, permissions, owner, group, sizes, symlink targets | +| `pull_adb_package` | read | Complete split APK set from `pm path`, each file digested | +| `pull_adb_file` | read | One absolute device path pulled and digested | +| `capture_adb_screen` | read | One `screencap -p` frame (exec-out, no device-side file) with dimensions | +| `collect_adb_bugreport` | read | Device-generated bugreport zip, digested | +| `push_adb_file` | **mutates device** | One local file to a device path (e.g. pushing tools into `/data/local/tmp`); refuses existing targets without explicit overwrite; verifies via the device's own sha256sum | +| `install_adb_package` | **mutates device** | Local APK via `adb install [-r]`; preserves the device's Success/Failure verdict | +| `uninstall_adb_package` | **mutates device** | Removes a package and its data for user 0 | +| `start_adb_app` | **mutates device** | Resolves the package's launcher activity (`cmd package resolve-activity --brief`) and starts it (`am start -n`) | +| `start_adb_activity` | **mutates device** | Starts an intent (`am start -a` with optional URI, component, and up to 16 typed extras); preserves the activity manager's verdict | +| `stop_adb_app` | **mutates device** | Force-stops every process of a package, ending a dynamic-analysis run | + +## Interpret the result + +- **Inferences carry their basis.** Device `kind` reports + `kind_basis` (serial prefix or USB transport); APK `role` reports + `role_basis: file_name`; `emulator_observed` is the build's own + `ro.kernel.qemu` value, `null` when absent. +- **Device paths are not host paths.** `base_apk_device_path` values live on + the device filesystem. Only pulls and captures produce host-local paths, + each paired with byte size and SHA-256 digest. +- **Splits are part of the answer.** `pm path` enumerates the base APK and + every installed split; pulls cover the whole set. System packages whose + APKs keep other names report the `unknown` role rather than guessing. +- **Partial results keep facts.** Failed individual pulls return successful + artifacts with `coverage: "partial"` and per-file `failures`. Listings + that contain unrecognized lines report them as `unparsed_lines` and mark + partial coverage instead of dropping rows. +- **Mutations are explicit.** `push_adb_file`, `install_adb_package`, and + `uninstall_adb_package` declare `mutatesTarget` and never run implicitly: + pushes refuse existing device files unless `overwrite` is set and verify + the transfer with the device's own `sha256sum`; installs preserve the + device's `Failure [...]` reason verbatim. +- **Producer identity stays attached.** Every result embeds the producing + adb client (`binary_path`, `path_source`, `version`, `installed_path`). + +## Resource and lifecycle limits + +- Caller values reach adb only as positional arguments in fixed vectors; + no tool accepts an arbitrary shell command line, and UI interaction + (`input`, `am`, `monkey`) is deliberately not exposed. +- Per-invocation budgets: client/devices 10-20 s, shell reads 20-60 s with + 64 KiB-8 MiB capture caps (logcat 4 MiB, screen 32 MiB), transfers and + bugreports 600 s. Exceeded budgets report `resource_constraint`; stopped + commands report `provider_timeout`. +- Pull and capture outputs are additive: the destination must not already + exist. Re-running a completed pull refuses the existing directory instead + of overwriting evidence. +- The provider holds no long-lived processes; every invocation is one + bounded adb run. The adb server belongs to the caller. + +## Related capability and follow-up lanes + +UI interaction beyond intent starts (tapping, typing, monkey fuzzing, screen +recording) remains a separate lane: candidates such as +[mobile-device-mcp](https://github.com/srmorete/mobile-device-mcp) install +driver software on the device and pair with the Frida instrumentation lane +(#1359) behind explicit mutation-authority contracts. Network transports +beyond already-connected devices (`adb connect`) are not exposed yet. + +## Real verification + +`npm run verify:adb` exercises every operation against a live device. It +requires `adb` (or `REA_ADB_PATH`) and any attached emulator or phone; +`--serial` selects one when several are attached, `--pull PACKAGE` pulls a +real package and verifies the returned digests against the files on disk, +and `--install-apk PATH` drives a full install → resolve → uninstall +lifecycle with a lane-owned probe. + +Record: verified against adb 34.0.5 on Linux against an Android 14 +(API 34) x86_64 emulator — all operations above, a 224-package inventory, +287 services, 92 features, a real two-APK split set pulled with byte-exact +digests, a verified push/pull roundtrip, a 1.3 MB screen capture, and the +complete package lifecycle; see `docs/testing.md` for the lane summary. diff --git a/docs/testing.md b/docs/testing.md index 61440dfb1..d4c0366ae 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -545,6 +545,37 @@ Synthetic producer regressions run independently: npm run test:focused -- tests/boundary/process/jadxIntegration.test.ts tests/boundary/process/androidAnalysisMcp.test.ts ``` +## ADB device acquisition + +`npm run verify:adb` exercises every ADB operation against a live device or +emulator through the caller's adb binary (`REA_ADB_PATH` or PATH). No SDK +installation or emulator management is involved; the device-mutating tools +run only in the explicit lifecycle section (`--install-apk` drives a +lane-owned install → resolve → start → observe → force-stop → uninstall +probe). `--pull` acquires a real package and re-digests the pulled files on +disk against the returned SHA-256 values; `--serial` selects a device when +several are attached. + +Record: adb 34.0.5-debian on Linux against an Android 14 (API 34) x86_64 +emulator. The lane covered the complete observation surface — 337-process +listing, 287 binder services including AIDL `/`-suffixed names, 92 features +including hex GL versions, display size/density, window focus (legitimately +null on headless devices), `settings get global adb_enabled`, bounded +logcat, directory listings — plus a real two-APK split set +(`base.apk` plus `split_probe.apk`, built and installed through +`install-multiple`) pulled with byte-exact digests, a push/pull roundtrip +verified by the device's own sha256sum, a 1.3 MB screen capture with PNG +dimensions, `dumpsys package` projection, and the full lifecycle: unique +resolution before launch, launcher-activity start through +`cmd package resolve-activity` with `am start -W`, the started app visible +in the process listing, force-stop, uninstall, and zero matches after +removal. System-package pulls whose APKs keep non-`base.apk` names report +the `unknown` role with the file-name basis, verified with +`com.android.settings` (single 73.9 MB APK). Parser goldens for +`adb devices -l`, `getprop`, `pm list packages -f`, and `pm path` come from +the same device. See [ADB device analysis](adb-device-analysis.md) for +boundaries and budgets. + ## Optional NativeAOT Ghidra analysis This lane is separate from the default native lane. `build:fixtures:nativeaot` diff --git a/package.json b/package.json index ed36813a8..018594047 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs", "verify:legacy-simulator": "npm run build:cached && node scripts/verify/apple/legacy-simulator.mjs", "verify:architecture-guards": "node scripts/verify/architecture-guards.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:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs", "verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs", diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 7aefdf734..9218dc8e2 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -47,6 +47,11 @@ export const toolFamilyCatalog = (sources) => { surface: "android-provider", contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS, }, + { + id: "adb", + surface: "adb-provider", + contracts: sources.adbContracts.ADB_TOOL_CONTRACTS, + }, { id: "browser", surface: "browser-provider", @@ -188,6 +193,10 @@ export const providerCatalog = (sources) => { identity: sources.androidProvider.JADX_PROVIDER_IDENTITY, contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS, }, + { + identity: sources.adbProvider.ADB_PROVIDER_IDENTITY, + contracts: sources.adbContracts.ADB_TOOL_CONTRACTS, + }, { identity: sources.webNetworkCaptureProvider.HAR_CAPTURE_PROVIDER_IDENTITY, contracts: diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 7792c1932..4254177d6 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -53,6 +53,8 @@ const SOURCE_PATHS = { firmwareProvider: "dist/firmware/FirmwareRelease.js", androidContracts: "dist/contracts/android/androidToolContracts.js", androidProvider: "dist/android/JadxRelease.js", + adbContracts: "dist/contracts/adb/adbToolContracts.js", + adbProvider: "dist/adb/AdbProvider.js", managedWorkflowContracts: "dist/contracts/managed/managedWorkflowToolContracts.js", browserContracts: "dist/contracts/browserToolContracts.js", diff --git a/scripts/verify-adb.mjs b/scripts/verify-adb.mjs new file mode 100644 index 000000000..27c9cd5bf --- /dev/null +++ b/scripts/verify-adb.mjs @@ -0,0 +1,400 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { AdbDeviceAnalysisService } from "../dist/application/adb/AdbDeviceAnalysisService.js"; +import { createAdbDeviceAnalysisProvider } from "../dist/composition/adb.js"; + +const options = parseArguments(process.argv.slice(2)); +if (options.help) { + process.stdout.write( + "Usage: npm run verify:adb [-- --serial SERIAL --pull PACKAGE]\n" + + "Requires a caller-selected adb binary (REA_ADB_PATH or PATH) and a\n" + + "connected device or emulator. Verifies all five operations against\n" + + "the live device; --pull acquires a real package and re-digests the\n" + + "files on disk against the returned SHA-256 values.\n", + ); + process.exit(0); +} + +const provider = createAdbDeviceAnalysisProvider(process.env); +const service = new AdbDeviceAnalysisService(provider); +let failures = 0; +const check = (name, condition, detail = "") => { + const mark = condition ? "ok" : "FAIL"; + process.stdout.write( + `[${mark}] ${name}${detail === "" ? "" : ` — ${detail}`}\n`, + ); + if (!condition) failures += 1; +}; + +const run = async (operation, input) => { + const result = await service.execute(operation, input); + if (!result.ok) { + process.stdout.write(`[FAIL] ${operation} — ${result.error.message}\n`); + failures += 1; + return null; + } + return result.value.normalized_result; +}; + +const client = await run("inspect_adb_client", {}); +if (client !== null) { + check( + "adb client reports a version", + typeof client.client.version === "string" && client.client.version !== "", + `binary=${client.client.binary_path} version=${client.client.version}`, + ); +} + +const listing = await run("list_adb_devices", {}); +let serial = options.serial ?? null; +if (listing !== null) { + check( + "device listing parsed", + Array.isArray(listing.devices), + `${listing.devices.length} device(s)`, + ); + const usable = listing.devices.filter((device) => device.state === "device"); + if (serial === null && usable.length > 0) serial = usable[0].serial; + if (serial === null) { + process.stdout.write( + "[FAIL] no usable device is attached; start an emulator or connect a phone\n", + ); + failures += 1; + } else { + const selected = listing.devices.find((device) => device.serial === serial); + check( + "selected device present with a kind inference", + selected !== undefined && + ["emulator", "physical", "unknown"].includes(selected.kind), + `serial=${serial} kind=${selected?.kind} basis=${selected?.kind_basis}`, + ); + } +} + +if (serial !== null) { + const device = await run("inspect_adb_device", { serial }); + if (device !== null) { + check( + "device build identity projected", + device.properties.some( + (property) => property.name === "ro.build.version.sdk", + ), + `sdk=${ + device.properties.find((p) => p.name === "ro.build.version.sdk")?.value + } emulator_observed=${String(device.emulator_observed)}`, + ); + } + + // Observation tools: processes, window, display, settings, logcat. + const processes = await run("list_adb_processes", { serial }); + if (processes !== null) { + check( + "process listing is non-empty with a header", + processes.processes.length > 0 && typeof processes.columns === "string", + `${processes.processes.length} process(es)`, + ); + } + const window = await run("inspect_adb_window", { serial }); + if (window !== null) + check( + "window focus observation completed", + typeof window.focused_window === "string" || + window.focused_window === null, + `focused_window=${String(window.focused_window).slice(0, 60)} (null is legitimate on headless devices)`, + ); + const display = await run("inspect_adb_display", { serial }); + if (display !== null) + check( + "display reports a physical size", + display.physical_size !== null && + display.physical_size.width > 0 && + display.physical_size.height > 0, + `${display.physical_size?.width}x${display.physical_size?.height} @${String(display.physical_density)}`, + ); + const setting = await run("read_adb_setting", { + serial, + namespace: "global", + key: "adb_enabled", + }); + if (setting !== null) + check( + "settings read returns the device's own value", + setting.value === "1", + `adb_enabled=${String(setting.value)}`, + ); + const logcat = await run("read_adb_logcat", { + serial, + count: 20, + buffer: "main", + }); + if (logcat !== null) + check( + "logcat dump returns bounded lines", + logcat.lines.length <= 20, + `${logcat.lines.length} line(s), coverage=${logcat.coverage}`, + ); + const features = await run("list_adb_features", { serial }); + if (features !== null) + check( + "feature inventory is non-empty", + features.coverage === "complete" && features.features.length > 0, + `${features.features.length} feature(s)`, + ); + const services = await run("list_adb_services", { serial }); + if (services !== null) + check( + "service inventory is non-empty", + services.coverage === "complete" && services.services.length > 0, + `${services.services.length} service(s)`, + ); + const directory = await run("list_adb_directory", { + serial, + device_path: "/data/local/tmp", + }); + if (directory !== null) + check( + "directory listing completed", + directory.coverage === "complete", + `${directory.entries.length} entr(ies)`, + ); + + const packages = await run("list_adb_packages", { + serial, + scope: "third_party", + }); + let pullCandidate = options.pull ?? null; + if (packages !== null) { + // A third-party scope may legitimately be empty (clean emulator images); + // completeness is about unparsed lines, not row count. + check( + "third-party package listing reports complete coverage", + packages.coverage === "complete", + `${packages.packages.length} package(s)`, + ); + const allPackages = await run("list_adb_packages", { + serial, + scope: "all", + }); + if (allPackages !== null) { + check( + "full package inventory is non-empty", + allPackages.coverage === "complete" && allPackages.packages.length > 0, + `${allPackages.packages.length} package(s)`, + ); + } + if (pullCandidate === null && packages.packages.length > 0) + pullCandidate = packages.packages[0].package_name; + } + + if (pullCandidate !== null) { + const directory = await mkdtemp(join(tmpdir(), "rea-adb-verify-")); + try { + const pull = await run("pull_adb_package", { + serial, + package: pullCandidate, + output_directory: directory, + }); + if (pull !== null) { + // Installed /data/app packages name their APKs base.apk and + // split_*.apk; system packages keep arbitrary names, so the lane + // checks a non-empty artifact set and verifies each file on disk. + check( + "pull returned a non-empty artifact set", + pull.artifacts.length > 0, + `${pull.artifacts.length} artifact(s), coverage=${pull.coverage}, roles=${pull.artifacts.map((a) => a.role).join(",")}`, + ); + for (const artifact of pull.artifacts) { + const bytes = await stat(artifact.local_path).then( + (info) => info.size, + ); + const digest = createHash("sha256") + .update(await readFile(artifact.local_path)) + .digest("hex"); + check( + `artifact digest matches on disk: ${artifact.file_name}`, + bytes === artifact.bytes && digest === artifact.sha256, + `${bytes} bytes`, + ); + } + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + } + + // File transfer: push one lane-owned file, verify both digests, pull it + // back, then clean up the device copy through the same fixed argv surface. + const transferRoot = await mkdtemp(join(tmpdir(), "rea-adb-transfer-")); + try { + const payload = join(transferRoot, "rea-verify.txt"); + const payloadBytes = "rea adb transfer verification\n"; + await import("node:fs/promises").then((fs) => + fs.writeFile(payload, payloadBytes), + ); + const deviceTarget = `/data/local/tmp/rea-verify-${Date.now()}.txt`; + const pushed = await run("push_adb_file", { + serial, + local_path: payload, + device_path: deviceTarget, + overwrite: false, + }); + if (pushed !== null) { + check( + "push reports matching device-side digest", + pushed.device_digest_source === "device_sha256sum" && + pushed.device_sha256 === pushed.sha256, + `${pushed.bytes} bytes`, + ); + const pulled = await run("pull_adb_file", { + serial, + device_path: deviceTarget, + output_directory: transferRoot, + }); + if (pulled !== null) + check( + "pulled file digest matches the pushed digest", + pulled.sha256 === pushed.sha256, + `${pulled.bytes} bytes`, + ); + } + const screenRoot = await mkdtemp(join(tmpdir(), "rea-adb-screen-")); + try { + const screen = await run("capture_adb_screen", { + serial, + output_directory: screenRoot, + }); + if (screen !== null) + check( + "screen capture is a sized PNG", + screen.width !== null && screen.height !== null && screen.bytes > 0, + `${screen.width}x${screen.height}, ${screen.bytes} bytes`, + ); + } finally { + await rm(screenRoot, { recursive: true, force: true }); + } + if (options.pull !== null) { + const details = await run("inspect_adb_package", { + serial, + package: options.pull, + }); + if (details !== null) + check( + "dumpsys package projection completed", + details.coverage === "complete", + `versionCode=${String(details.version_code)}`, + ); + } + if (options.installApk !== null && options.installApk !== undefined) { + const probePackage = "com.rea.adb.probe"; + const installedProbe = await run("install_adb_package", { + serial, + apk_path: options.installApk, + replace: false, + }); + if (installedProbe !== null) { + check( + "install reports the APK digest", + typeof installedProbe.sha256 === "string" && + installedProbe.sha256.length === 64, + `${installedProbe.bytes} bytes`, + ); + const resolved = await run("resolve_adb_packages", { + serial, + query: "rea.adb.probe", + }); + if (resolved !== null) + check( + "resolve finds the installed probe uniquely", + resolved.matches.length === 1 && + resolved.matches[0]?.package_name === probePackage, + `${resolved.matches.length} match(es)`, + ); + const started = await run("start_adb_app", { + serial, + package: probePackage, + }); + if (started !== null) + check( + "app start resolves and launches the launcher activity", + typeof started.component === "string" && + started.component.startsWith(probePackage), + String(started.component), + ); + const observedProcesses = await run("list_adb_processes", { serial }); + if (observedProcesses !== null) + check( + "started app appears in the process listing", + observedProcesses.processes.some( + (process) => process.name === probePackage, + ), + ); + const stopped = await run("stop_adb_app", { + serial, + package: probePackage, + }); + if (stopped !== null) + check( + "force-stop completes the dynamic-analysis run", + stopped.package_name === probePackage, + ); + const removed = await run("uninstall_adb_package", { + serial, + package: probePackage, + }); + if (removed !== null) + check( + "uninstall completes the lifecycle", + removed.package_name === probePackage, + ); + const afterRemoval = await run("resolve_adb_packages", { + serial, + query: "rea.adb.probe", + }); + if (afterRemoval !== null) + check( + "resolve reports zero matches after removal", + afterRemoval.matches.length === 0, + ); + } + } + } finally { + await rm(transferRoot, { recursive: true, force: true }); + } +} + +try { + await provider.close(); +} catch { + // The provider owns nothing long-lived; closing is best-effort. +} + +if (failures > 0) { + process.stdout.write(`\n${failures} check(s) failed\n`); + process.exit(1); +} +process.stdout.write("\nall adb device checks passed\n"); + +function parseArguments(argv) { + const parsed = { help: false, serial: null, pull: null, installApk: null }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") parsed.help = true; + else if (argument === "--serial") { + parsed.serial = argv[index + 1] ?? null; + index += 1; + } else if (argument === "--pull") { + parsed.pull = argv[index + 1] ?? null; + index += 1; + } else if (argument === "--install-apk") { + parsed.installApk = argv[index + 1] ?? null; + index += 1; + } else { + process.stderr.write(`unknown argument: ${argument}\n`); + process.exit(2); + } + } + return parsed; +} diff --git a/src/adb/AdbConfiguration.test.ts b/src/adb/AdbConfiguration.test.ts new file mode 100644 index 000000000..ea5039d22 --- /dev/null +++ b/src/adb/AdbConfiguration.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + ADB_BINARY_ENV, + parseAdbVersionOutput, + resolveAdbBinary, +} from "./AdbConfiguration.js"; + +describe("resolveAdbBinary", () => { + it("prefers an absolute REA_ADB_PATH", () => { + expect(resolveAdbBinary({ [ADB_BINARY_ENV]: "/opt/tools/adb" })).toEqual({ + binary: "/opt/tools/adb", + pathSource: "environment", + }); + }); + + it("falls back to PATH resolution without the environment key", () => { + expect(resolveAdbBinary({})).toEqual({ + binary: "adb", + pathSource: "path", + }); + expect(resolveAdbBinary({ [ADB_BINARY_ENV]: " " })).toEqual({ + binary: "adb", + pathSource: "path", + }); + }); + + it("rejects a relative environment path explicitly", () => { + expect(() => resolveAdbBinary({ [ADB_BINARY_ENV]: "tools/adb" })).toThrow( + /must be an absolute path/u, + ); + }); +}); + +describe("parseAdbVersionOutput", () => { + it("reads the build version and install path from real output", () => { + expect( + parseAdbVersionOutput( + [ + "Android Debug Bridge version 1.0.41", + "Version 34.0.5-debian", + "Installed as /usr/lib/android-sdk/platform-tools/adb", + "", + ].join("\n"), + ), + ).toEqual({ + version: "34.0.5-debian", + installedPath: "/usr/lib/android-sdk/platform-tools/adb", + }); + }); + + it("reports null identity fields when the named lines are absent", () => { + expect(parseAdbVersionOutput("unexpected output")).toEqual({ + version: null, + installedPath: null, + }); + }); +}); diff --git a/src/adb/AdbConfiguration.ts b/src/adb/AdbConfiguration.ts new file mode 100644 index 000000000..15e2e40eb --- /dev/null +++ b/src/adb/AdbConfiguration.ts @@ -0,0 +1,144 @@ +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { isAbsolute } from "node:path"; + +import { + execFileOutput, + execFileOutputFailure, +} from "../process/ExecFileOutput.js"; +import type { ProviderAvailability } from "../application/AnalysisProvider.js"; +import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; +import type { JsonValue } from "../domain/jsonValue.js"; + +/** Environment key selecting the caller's adb binary. */ +export const ADB_BINARY_ENV = "REA_ADB_PATH"; + +const VERSION_TIMEOUT_MS = 10_000; +const VERSION_MAX_OUTPUT_BYTES = 64 * 1024; + +/** How the adb binary was selected for this provider instance. */ +export interface AdbBinarySelection { + readonly binary: string; + readonly pathSource: "environment" | "path"; +} + +/** + * Resolve the caller-supplied adb binary. `REA_ADB_PATH` must be absolute so + * the selection stays explicit; without it, adb is resolved from `PATH` by + * the platform exec at run time, like any bring-your-own tool. + */ +export const resolveAdbBinary = ( + environment: Readonly>, +): AdbBinarySelection => { + const configured = environment[ADB_BINARY_ENV]; + if (configured === undefined || configured.trim() === "") + return { binary: "adb", pathSource: "path" }; + if (!isAbsolute(configured)) + throw new AdbConfigurationFailure( + "not_absolute", + `${ADB_BINARY_ENV} must be an absolute path to the adb binary`, + { configured_path: configured }, + ); + return { binary: configured, pathSource: "environment" }; +}; + +export type AdbConfigurationFailureCode = + | "not_absolute" + | "binary_missing" + | "version_unresolved"; + +export class AdbConfigurationFailure extends Error { + constructor( + readonly code: AdbConfigurationFailureCode, + message: string, + readonly diagnostics: Readonly> = {}, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +export interface AdbClientIdentity { + readonly version: string | null; + readonly installedPath: string | null; +} + +/** + * Parse `adb version` output. The first line carries the protocol version, + * `Version ` the build version (no colon), and `Installed as` the binary path. + */ +export const parseAdbVersionOutput = (stdout: string): AdbClientIdentity => { + const build = /^Version\s+(\S.*)$/mu.exec(stdout); + const installed = /^Installed as\s+(.+)$/mu.exec(stdout); + return { + version: build === null ? null : build[1]!.trim(), + installedPath: installed === null ? null : installed[1]!.trim(), + }; +}; + +/** + * Probe the selected adb binary without contacting devices. `adb version` + * never starts the adb server, so this stays a pure client check. + */ +export const inspectAdbClient = async ( + selection: AdbBinarySelection, + signal?: AbortSignal, +): Promise => { + try { + const { stdout } = await execFileOutput(selection.binary, ["version"], { + timeout: VERSION_TIMEOUT_MS, + stopSignal: "SIGTERM", + maxBuffer: VERSION_MAX_OUTPUT_BYTES, + ...(signal === undefined ? {} : { signal }), + }); + return parseAdbVersionOutput(stdout); + } catch (cause) { + const failure = execFileOutputFailure(cause); + if (signal?.aborted || failure?.code === "ABORT_ERR") + throw new AnalysisCancelledError("inspect_adb_client", { cause }); + if (failure?.code === "ENOENT") + throw new AdbConfigurationFailure( + "binary_missing", + `The selected adb binary was not found: ${selection.binary}`, + { + selected_binary: selection.binary, + path_source: selection.pathSource, + remediation: `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary`, + }, + { cause }, + ); + throw new AdbConfigurationFailure( + "version_unresolved", + "The selected adb binary did not report a usable version", + { + selected_binary: selection.binary, + ...(failure === undefined + ? {} + : { + exit_code: failure.code, + stderr_excerpt: failure.stderr.slice(0, 512), + }), + }, + { cause }, + ); + } +}; + +/** Availability in the shape shared by every provider. */ +const rejectionCodeOf = ( + code: AdbConfigurationFailureCode, +): ProviderRejectionCode => + code === "binary_missing" + ? "executable_missing" + : code === "not_absolute" + ? "not_configured" + : "version_unresolved"; + +export const adbConfigurationAvailability = ( + cause: AdbConfigurationFailure, +): ProviderAvailability => + ({ + status: "unavailable", + code: rejectionCodeOf(cause.code), + reason: cause.message, + diagnostics: { ...cause.diagnostics }, + }) as ProviderAvailability; diff --git a/src/adb/AdbDeviceOutput.test.ts b/src/adb/AdbDeviceOutput.test.ts new file mode 100644 index 000000000..b85061cae --- /dev/null +++ b/src/adb/AdbDeviceOutput.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; + +import { + parseAdbDevicesOutput, + parseGetpropOutput, + parsePackageListOutput, + parsePmPathOutput, +} from "./AdbDeviceOutput.js"; + +describe("parseAdbDevicesOutput", () => { + it("parses emulator, USB, and network devices with their metadata", () => { + const output = [ + "List of devices attached", + "emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1", + "07ef9c2a device usb:1-1 product:raven model:Pixel_6_Pro device:raven transport_id:2", + "192.168.1.42:5555 device product:mock model:mock transport_id:3", + "", + ].join("\n"); + const { devices, unparsedLines, daemonStarted } = + parseAdbDevicesOutput(output); + expect(unparsedLines).toEqual([]); + expect(daemonStarted).toBe(false); + expect(devices).toEqual([ + { + serial: "emulator-5554", + state: "device", + transport: "tcp", + product: "sdk_gphone64_x86_64", + model: "sdk_gphone64_x86_64", + device: "emu64xa", + transport_id: 1, + kind: "emulator", + kind_basis: "emulator_serial_prefix", + }, + { + serial: "07ef9c2a", + state: "device", + transport: "usb", + product: "raven", + model: "Pixel_6_Pro", + device: "raven", + transport_id: 2, + kind: "physical", + kind_basis: "usb_transport", + }, + { + serial: "192.168.1.42:5555", + state: "device", + transport: "tcp", + product: "mock", + model: "mock", + device: null, + transport_id: 3, + kind: "unknown", + kind_basis: "none", + }, + ]); + }); + + it("recognizes daemon startup notices instead of failing the listing", () => { + const output = [ + "* daemon not running; starting now at tcp:5037", + "* daemon started successfully", + "List of devices attached", + "emulator-5554 device transport_id:1", + "", + ].join("\n"); + const { devices, daemonStarted } = parseAdbDevicesOutput(output); + expect(daemonStarted).toBe(true); + expect(devices).toHaveLength(1); + }); + + it("keeps non-device states such as unauthorized and offline", () => { + const output = [ + "List of devices attached", + "emulator-5556 offline transport_id:2", + "HT84L0240512 unauthorized usb:1-2 transport_id:3", + "", + ].join("\n"); + const { devices } = parseAdbDevicesOutput(output); + expect(devices.map((device) => [device.serial, device.state])).toEqual([ + ["emulator-5556", "offline"], + ["HT84L0240512", "unauthorized"], + ]); + }); + + it("returns unrecognized lines instead of dropping them", () => { + const output = [ + "List of devices attached", + "this is not a device line", + "", + ].join("\n"); + const { unparsedLines } = parseAdbDevicesOutput(output); + expect(unparsedLines).toEqual(["this is not a device line"]); + }); + + it("reports a non-numeric transport_id as unknown", () => { + const output = [ + "List of devices attached", + "emulator-5554 device transport_id:not-a-number", + "", + ].join("\n"); + const { devices } = parseAdbDevicesOutput(output); + expect(devices[0]?.transport_id).toBeNull(); + }); +}); + +describe("parseGetpropOutput", () => { + it("projects the whitelisted build identity from a real dump", () => { + const dump = [ + "[ro.build.version.release]: [14]", + "[ro.build.version.sdk]: [34]", + "[ro.build.version.security_patch]: [2024-06-05]", + "[ro.build.id]: [AP21.240617.014]", + "[ro.build.fingerprint]: [google/sdk_gphone64_x86_64/emu64xa:14/AP21.240617.014/12094027:userdebug/dev-keys]", + "[ro.product.brand]: [google]", + "[ro.product.device]: [emu64xa]", + "[ro.product.manufacturer]: [Google]", + "[ro.product.model]: [sdk_gphone64_x86_64]", + "[ro.product.cpu.abilist]: [x86_64,x86,arm64-v8a,armeabi-v7a,armeabi]", + "[ro.kernel.qemu]: [1]", + "[ro.debuggable]: [1]", + "[sys.unlisted.property]: [dropped]", + "", + ].join("\n"); + const { properties, emulatorObserved } = parseGetpropOutput(dump); + expect(emulatorObserved).toBe(true); + expect(properties).not.toContainEqual({ + name: "sys.unlisted.property", + value: "dropped", + }); + expect(properties).toEqual([ + { name: "ro.build.version.release", value: "14" }, + { name: "ro.build.version.sdk", value: "34" }, + { name: "ro.build.version.security_patch", value: "2024-06-05" }, + { name: "ro.build.id", value: "AP21.240617.014" }, + { + name: "ro.build.fingerprint", + value: + "google/sdk_gphone64_x86_64/emu64xa:14/AP21.240617.014/12094027:userdebug/dev-keys", + }, + { name: "ro.debuggable", value: "1" }, + { name: "ro.kernel.qemu", value: "1" }, + { name: "ro.product.brand", value: "google" }, + { name: "ro.product.device", value: "emu64xa" }, + { name: "ro.product.manufacturer", value: "Google" }, + { name: "ro.product.model", value: "sdk_gphone64_x86_64" }, + { + name: "ro.product.cpu.abilist", + value: "x86_64,x86,arm64-v8a,armeabi-v7a,armeabi", + }, + ]); + }); + + it("reports an explicit non-emulator build without inventing a value", () => { + const dump = [ + "[ro.build.version.release]: [13]", + "[ro.kernel.qemu]: [0]", + "", + ].join("\n"); + const { emulatorObserved } = parseGetpropOutput(dump); + expect(emulatorObserved).toBe(false); + }); + + it("reports the emulator indicator unknown when the build omits it", () => { + const { emulatorObserved } = parseGetpropOutput( + "[ro.build.version.sdk]: [33]\n", + ); + expect(emulatorObserved).toBeNull(); + }); +}); + +describe("parsePackageListOutput", () => { + it("parses package lines with their base APK device paths", () => { + const output = [ + "package:/data/app/~~xy==/com.example.npzyCvVb1I-YzKIsGFvVYw==/base.apk=com.example", + "package:/system/app/WebView/WebView.apk=com.android.webview", + "", + ].join("\n"); + const { packages, unparsedLines } = parsePackageListOutput(output); + expect(unparsedLines).toEqual([]); + expect(packages).toEqual([ + { + package_name: "com.example", + base_apk_device_path: + "/data/app/~~xy==/com.example.npzyCvVb1I-YzKIsGFvVYw==/base.apk", + }, + { + package_name: "com.android.webview", + base_apk_device_path: "/system/app/WebView/WebView.apk", + }, + ]); + }); + + it("keeps unrecognized lines for partial coverage reporting", () => { + const { packages, unparsedLines } = parsePackageListOutput( + "package:/data/app/x/base.apk=com.ok\ngarbage line\n", + ); + expect(packages).toHaveLength(1); + expect(unparsedLines).toEqual(["garbage line"]); + }); +}); + +describe("parsePmPathOutput", () => { + it("classifies base and split APKs from their file names", () => { + const output = [ + "package:/data/app/~~a==/com.example.b==/base.apk", + "package:/data/app/~~a==/com.example.b==/split_config.arm64_v8a.apk", + "package:/data/app/~~a==/com.example.b==/split_config.en.apk", + "", + ].join("\n"); + const { paths, unparsedLines } = parsePmPathOutput(output); + expect(unparsedLines).toEqual([]); + expect(paths).toEqual([ + { + device_path: "/data/app/~~a==/com.example.b==/base.apk", + file_name: "base.apk", + role: "base", + }, + { + device_path: + "/data/app/~~a==/com.example.b==/split_config.arm64_v8a.apk", + file_name: "split_config.arm64_v8a.apk", + role: "split", + }, + { + device_path: "/data/app/~~a==/com.example.b==/split_config.en.apk", + file_name: "split_config.en.apk", + role: "split", + }, + ]); + }); + + it("reports an unknown role for names that follow neither convention", () => { + const { paths } = parsePmPathOutput("package:/system/app/X/Y.apk\n"); + expect(paths[0]).toEqual({ + device_path: "/system/app/X/Y.apk", + file_name: "Y.apk", + role: "unknown", + }); + }); +}); diff --git a/src/adb/AdbDeviceOutput.ts b/src/adb/AdbDeviceOutput.ts new file mode 100644 index 000000000..44bbb53ba --- /dev/null +++ b/src/adb/AdbDeviceOutput.ts @@ -0,0 +1,248 @@ +/** + * Parsers for the exact adb and Package Manager output formats REA consumes. + * + * Each parser preserves what the tool actually printed: unparseable lines are + * retained rather than silently dropped, and derived classifications carry + * their evidence basis. + */ + +/** One device exactly as `adb devices -l` reports it. */ +export interface AdbDeviceEntry { + readonly serial: string; + readonly state: string; + readonly transport: "usb" | "tcp" | "unknown"; + readonly product: string | null; + readonly model: string | null; + readonly device: string | null; + readonly transport_id: number | null; + readonly kind: "emulator" | "physical" | "unknown"; + readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none"; +} + +/** + * Device states adb prints after the serial, longest first so two-word + * states match before their single-word prefixes. A line whose remainder + * starts with none of them is not a device line. + */ +const DEVICE_STATES = [ + "no permissions", + "unauthorized", + "authorizing", + "offline", + "recovery", + "sideload", + "connecting", + "bootloader", + "device", + "unknown", + "host", +] as const; + +const DEVICE_LINE = /^(\S+)\s+(.*)$/u; + +const metadataValue = (remainder: string, key: string): string | null => { + const match = new RegExp(`(?:^|\\s)${key}:(\\S+)`, "u").exec(remainder); + return match === null ? null : match[1]!; +}; + +/** + * Parse `adb devices -l` device lines. The banner, blank lines, and the + * daemon startup notices adb prints on first use are recognized separately; + * every other unrecognized line is returned for honest reporting. + */ +export const parseAdbDevicesOutput = ( + stdout: string, +): { + devices: readonly AdbDeviceEntry[]; + unparsedLines: readonly string[]; + daemonStarted: boolean; +} => { + const devices: AdbDeviceEntry[] = []; + const unparsedLines: string[] = []; + let daemonStarted = false; + for (const rawLine of stdout.split(/\r?\n/u)) { + const line = rawLine.trimEnd(); + if (line === "" || line.startsWith("List of devices attached")) continue; + if (line.startsWith("* daemon")) { + if (line.includes("started successfully")) daemonStarted = true; + continue; + } + const match = DEVICE_LINE.exec(line); + const state = DEVICE_STATES.find((candidate) => + match?.[2]?.startsWith(candidate), + ); + if (match === null || state === undefined) { + if (line.trim() !== "") unparsedLines.push(line); + continue; + } + const serial = match[1]!; + const remainder = match[2]!.slice(state.length); + const usb = metadataValue(remainder, "usb"); + const transportIdText = metadataValue(remainder, "transport_id"); + const parsedTransportId = + transportIdText === null || !/^\d+$/u.test(transportIdText) + ? null + : Number(transportIdText); + const isEmulator = /^emulator-\d+$/u.test(serial); + const kind = isEmulator + ? ("emulator" as const) + : usb !== null + ? ("physical" as const) + : ("unknown" as const); + devices.push({ + serial, + state, + transport: + usb !== null + ? "usb" + : isEmulator || serial.includes(":") + ? "tcp" + : "unknown", + product: metadataValue(remainder, "product"), + model: metadataValue(remainder, "model"), + device: metadataValue(remainder, "device"), + transport_id: parsedTransportId, + kind, + kind_basis: + kind === "emulator" + ? "emulator_serial_prefix" + : kind === "physical" + ? "usb_transport" + : "none", + }); + } + return { devices, unparsedLines, daemonStarted }; +}; + +/** + * Properties projected from a `getprop` dump. The whitelist keeps device + * inspection to build identity facts; a full dump is out of scope. + */ +const DEVICE_PROPERTY_WHITELIST = [ + "ro.build.version.release", + "ro.build.version.sdk", + "ro.build.version.security_patch", + "ro.build.id", + "ro.build.fingerprint", + "ro.build.characteristics", + "ro.debuggable", + "ro.kernel.qemu", + "ro.product.brand", + "ro.product.device", + "ro.product.manufacturer", + "ro.product.model", + "ro.product.cpu.abilist", +] as const; + +export interface AdbDeviceProperty { + readonly name: string; + readonly value: string; +} + +/** Parse a full `getprop` dump into `[name]: [value]` pairs. */ +export const parseGetpropOutput = ( + stdout: string, +): { + properties: readonly AdbDeviceProperty[]; + emulatorObserved: boolean | null; +} => { + const values = new Map(); + for (const rawLine of stdout.split(/\r?\n/u)) { + const match = /^\[([^\]]+)\]:\s*\[(.*)\]$/u.exec(rawLine.trim()); + if (match === null) continue; + values.set(match[1]!, match[2]!); + } + const properties = DEVICE_PROPERTY_WHITELIST.flatMap((name) => { + const value = values.get(name); + return value === undefined || value === "" + ? [] + : [{ name, value } satisfies AdbDeviceProperty]; + }); + const qemu = values.get("ro.kernel.qemu"); + return { + properties, + emulatorObserved: qemu === undefined ? null : qemu === "1", + }; +}; + +/** One installed package exactly as `pm list packages -f` reports it. */ +export interface AdbPackageEntry { + readonly package_name: string; + readonly base_apk_device_path: string; +} + +const PACKAGE_LINE = /^package:(.+)=([^\s=]+)$/u; + +/** Parse `pm list packages -f` lines, retaining unrecognized lines. */ +export const parsePackageListOutput = ( + stdout: string, +): { + packages: readonly AdbPackageEntry[]; + unparsedLines: readonly string[]; +} => { + const packages: AdbPackageEntry[] = []; + const unparsedLines: string[] = []; + for (const rawLine of stdout.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line === "") continue; + const match = PACKAGE_LINE.exec(line); + if (match === null) { + unparsedLines.push(line); + continue; + } + packages.push({ + package_name: match[2]!, + base_apk_device_path: match[1]!, + }); + } + return { packages, unparsedLines }; +}; + +/** One APK of an installed package exactly as `pm path` reports it. */ +export interface AdbPackagePathEntry { + readonly device_path: string; + readonly file_name: string; + readonly role: "base" | "split" | "unknown"; +} + +const fileNameOf = (devicePath: string): string => { + const parts = devicePath.split(/\//u); + return parts[parts.length - 1] ?? devicePath; +}; + +/** + * Parse `pm path PACKAGE` lines. The base/split role is derived from the file + * name (`base.apk`, `split_*.apk`), which is the only signal the output + * carries; any other name reports an unknown role. + */ +export const parsePmPathOutput = ( + stdout: string, +): { + paths: readonly AdbPackagePathEntry[]; + unparsedLines: readonly string[]; +} => { + const paths: AdbPackagePathEntry[] = []; + const unparsedLines: string[] = []; + for (const rawLine of stdout.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line === "") continue; + const match = /^package:(\S+)$/u.exec(line); + if (match === null) { + unparsedLines.push(line); + continue; + } + const devicePath = match[1]!; + const fileName = fileNameOf(devicePath); + paths.push({ + device_path: devicePath, + file_name: fileName, + role: + fileName === "base.apk" + ? "base" + : fileName.startsWith("split_") && fileName.endsWith(".apk") + ? "split" + : "unknown", + }); + } + return { paths, unparsedLines }; +}; diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts new file mode 100644 index 000000000..444fe5e83 --- /dev/null +++ b/src/adb/AdbProvider.test.ts @@ -0,0 +1,960 @@ +import { createHash } from "node:crypto"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { expect, onTestFinished, test } from "vitest"; + +import { AdbProvider } from "./AdbProvider.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js"; + +const it = test.skipIf(process.platform === "win32"); + +const VERSION_BLOCK = + "printf 'Android Debug Bridge version 1.0.41\\nVersion 34.0.5-stub\\nInstalled as %s\\n' \"$0\""; + +const writeAdbStub = async ( + body: (root: string) => string, +): Promise<{ readonly binary: string; readonly root: string }> => { + const root = await mkdtemp(join("/tmp", "rea-adb-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const binary = join(root, "adb"); + await writeFile(binary, `#!/bin/sh\n${body(root)}\n`, { mode: 0o755 }); + await chmod(binary, 0o755); + return { binary, root }; +}; + +const providerOver = (binary: string): AdbProvider => + new AdbProvider({ environment: { REA_ADB_PATH: binary } }); + +const evidenceResult = async ( + provider: AdbProvider, + request: AdbRequest, +): Promise> => { + const result = await provider.execute(request); + if (!result.ok) throw new Error(result.error.message); + return result.value.result as Record; +}; + +const refusedBy = async ( + provider: AdbProvider, + request: AdbRequest, +): Promise> => { + const result = await provider.execute(request); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected a refusal"); + return projectAnalysisError(result.error); +}; + +it("inspects the adb client without contacting the server", async () => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; fi`, + ); + const result = await evidenceResult(providerOver(binary), { + operation: "inspect_adb_client", + input: {}, + }); + expect(result.client).toMatchObject({ + path_source: "environment", + version: "34.0.5-stub", + installed_path: binary, + }); + expect(result.contacted_adb_server).toBe(false); +}); + +it("lists devices with inferred kinds and observed daemon startup", async () => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "devices" ]; then + printf '* daemon not running; starting now at tcp:5037\\n* daemon started successfully\\n' + printf 'List of devices attached\\n' + printf 'emulator-5554 device product:sdk_gphone64_x86_64 model:sdk_gphone64_x86_64 device:emu64xa transport_id:1\\n' + exit 0 +fi`, + ); + const result = await evidenceResult(providerOver(binary), { + operation: "list_adb_devices", + input: {}, + }); + expect(result.may_have_started_adb_server).toBe(true); + expect(result.devices).toEqual([ + { + serial: "emulator-5554", + state: "device", + transport: "tcp", + product: "sdk_gphone64_x86_64", + model: "sdk_gphone64_x86_64", + device: "emu64xa", + transport_id: 1, + kind: "emulator", + kind_basis: "emulator_serial_prefix", + }, + ]); +}); + +it("inspects a device through the getprop whitelist", async () => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ]; then + printf '[ro.build.version.sdk]: [34]\\n[ro.kernel.qemu]: [1]\\n[sys.private]: [x]\\n' + exit 0 +fi`, + ); + const result = await evidenceResult(providerOver(binary), { + operation: "inspect_adb_device", + input: { serial: "emulator-5554" }, + }); + expect(result.emulator_observed).toBe(true); + expect(result.properties).toEqual([ + { name: "ro.build.version.sdk", value: "34" }, + { name: "ro.kernel.qemu", value: "1" }, + ]); +}); + +it("passes the caller scope as an exact fixed argv vector", async () => { + const { binary, root } = await writeAdbStub( + (root) => `printf '%s\\n' "$@" >> ${join(root, "argv.log")} +if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "shell" ]; then printf 'package:/data/app/x/base.apk=com.example\\n'; exit 0; fi`, + ); + const result = await evidenceResult(providerOver(binary), { + operation: "list_adb_packages", + input: { serial: "emulator-5554", scope: "third_party" }, + }); + expect(result.coverage).toBe("complete"); + expect(result.packages).toEqual([ + { + package_name: "com.example", + base_apk_device_path: "/data/app/x/base.apk", + }, + ]); + const logged = (await readFile(join(root, "argv.log"), "utf8")) + .trim() + .split("\n"); + expect(logged).toEqual([ + "version", + "-s", + "emulator-5554", + "shell", + "pm", + "list", + "packages", + "-f", + "-3", + ]); +}); + +const pullStub = ( + paths: string, + pullBody: string, +): string => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$4" = "pm" ] && [ "$5" = "path" ]; then + printf '%s\\n' ${paths} + exit 0 +fi +if [ "$1" = "-s" ] && [ "$3" = "pull" ]; then +${pullBody} + exit 0 +fi +printf 'unexpected arguments: %s\\n' "$@" +exit 1`; + +/** Stub dispatching one canned stdout per first shell word (and optional second). */ +const shellStub = ( + dispatch: Readonly>, +): ((root: string) => string) => { + const cases = Object.entries(dispatch) + .map(([key, output]) => { + const [first, second] = key.split(":"); + const guard = + second === undefined + ? `[ "$4" = "${first}" ]` + : `[ "$4" = "${first}" ] && [ "$5" = "${second}" ]`; + return ` if ${guard}; then printf '%b' '${output.replace(/'/gu, "'\\''")}'; exit 0; fi`; + }) + .join("\n"); + return () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "shell" ]; then +${cases} + printf 'unexpected shell command: %s %s %s\\n' "$4" "$5" "$6" >&2 + exit 1 +fi +printf 'unexpected arguments: %s\\n' "$@" +exit 1`; +}; + +it("pulls a complete split set with digests into a new package directory", async () => { + const { binary, root } = await writeAdbStub(() => + pullStub( + "'package:/data/app/~~a==/com.example.b==/base.apk' 'package:/data/app/~~a==/com.example.b==/split_config.en.apk'", + ` printf 'apk-bytes-for-testing' > "$5" + printf '1 file pulled, 0 skipped.\\n'`, + ), + ); + const output = join(root, "pulls"); + const result = await evidenceResult(providerOver(binary), { + operation: "pull_adb_package", + input: { + serial: "emulator-5554", + package: "com.example", + output_directory: output, + }, + }); + expect(result.coverage).toBe("complete"); + expect(result.failures).toEqual([]); + const artifacts = result.artifacts as { + file_name: string; + local_path: string; + sha256: string; + role: string; + }[]; + expect( + artifacts.map((artifact) => [artifact.file_name, artifact.role]), + ).toEqual([ + ["base.apk", "base"], + ["split_config.en.apk", "split"], + ]); + const digest = createHash("sha256") + .update("apk-bytes-for-testing") + .digest("hex"); + for (const artifact of artifacts) expect(artifact.sha256).toBe(digest); + const pulled = await readFile( + join(output, "com.example", "base.apk"), + "utf8", + ); + expect(pulled).toBe("apk-bytes-for-testing"); +}); + +it("keeps successful artifacts when one split pull fails", async () => { + const { binary, root } = await writeAdbStub(() => + pullStub( + "'package:/data/app/y/base.apk' 'package:/data/app/y/split_dpi.apk'", + `case "$4" in + */split_dpi.apk) printf 'error: closed\\n' >&2; exit 1;; + *) printf 'apk-bytes-for-testing' > "$5"; printf '1 file pulled.\\n';; + esac`, + ), + ); + const result = await evidenceResult(providerOver(binary), { + operation: "pull_adb_package", + input: { + serial: "emulator-5554", + package: "com.partial", + output_directory: join(root, "pulls"), + }, + }); + expect(result.coverage).toBe("partial"); + expect(result.artifacts).toHaveLength(1); + expect(result.failures).toEqual([ + { + device_path: "/data/app/y/split_dpi.apk", + stage: "pull", + message: expect.stringContaining("closed") as unknown, + }, + ]); +}); + +it("refuses to reuse an existing package output directory", async () => { + const { binary, root } = await writeAdbStub(() => + pullStub("'package:/data/app/z/base.apk'", ` printf 'x' > "$5"`), + ); + const output = join(root, "existing"); + await mkdir(join(output, "com.example"), { recursive: true }); + const provider = providerOver(binary); + const result = await provider.execute({ + operation: "pull_adb_package", + input: { + serial: "emulator-5554", + package: "com.example", + output_directory: output, + }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unreadable_output", + }); + expect(result.error.message).toContain("already exists"); +}); + +it("reports an uninstalled package from empty pm path output", async () => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$4" = "pm" ] && [ "$5" = "path" ]; then exit 0; fi`, + ); + const projected = await refusedBy(providerOver(binary), { + operation: "pull_adb_package", + input: { + serial: "emulator-5554", + package: "com.absent", + output_directory: "/tmp/rea-adb-absent", + }, + }); + expect(projected).toMatchObject({ code: "unsupported_target" }); + expect(projected.message).toContain("no APK paths"); +}); + +it.each([ + ["not found", "error: device 'emulator-9' not found", "not found"], + ["offline", "error: device offline", "offline"], + ["unauthorized", "error: device unauthorized", "unauthorized"], +] as const)( + "reports the %s device state as a target refusal", + async (_, stderr, excerpt) => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ]; then printf '%s\\n' "${stderr.replace(/'/gu, "'\\''")}" >&2; exit 1; fi`, + ); + const projected = await refusedBy(providerOver(binary), { + operation: "inspect_adb_device", + input: { serial: "emulator-5554" }, + }); + expect(projected).toMatchObject({ code: "unsupported_target" }); + expect(projected.message).toContain(excerpt); + }, +); + +it("reports a missing selected binary as a capability gap with recovery", async () => { + const provider = new AdbProvider({ + environment: { REA_ADB_PATH: "/nonexistent/adb" }, + }); + const availability = await provider.inspectAvailability(); + expect(availability).toMatchObject({ status: "unavailable" }); + if (availability.status !== "unavailable") return; + expect(availability.diagnostics.remediation).toContain("REA_ADB_PATH"); + const result = await provider.execute({ + operation: "list_adb_devices", + input: {}, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ code: "capability_unavailable" }); + expect(projected.details).toMatchObject({ + provider_id: "adb", + reason: expect.stringContaining("not found") as unknown, + }); +}); + +it("cancels before spawning anything", async () => { + const { binary, root } = await writeAdbStub( + (root) => `: > ${join(root, "spawned")} +if [ "$1" = "version" ]; then ${VERSION_BLOCK}; fi`, + ); + const controller = new AbortController(); + controller.abort(); + const cancelled = await providerOver(binary).execute( + { operation: "list_adb_devices", input: {} }, + { signal: controller.signal }, + ); + expect(cancelled.ok).toBe(false); + if (cancelled.ok) return; + expect(projectAnalysisError(cancelled.error)).toMatchObject({ + code: "cancelled", + }); + expect( + await readFile(join(root, "spawned")).then( + () => true, + () => false, + ), + ).toBe(false); +}); + +it("reads a bounded logcat dump with the caller count and buffer", async () => { + const { binary } = await writeAdbStub( + shellStub({ + logcat: + "08-10 13:00:00.000 I/Tag( 123): first\\n08-10 13:00:01.000 I/Tag( 123): second\\n", + }), + ); + const result = await evidenceResult(providerOver(binary), { + operation: "read_adb_logcat", + input: { serial: "emulator-5554", count: 500, buffer: "main" }, + }); + expect(result.lines).toEqual([ + "08-10 13:00:00.000 I/Tag( 123): first", + "08-10 13:00:01.000 I/Tag( 123): second", + ]); +}); + +it("projects package details from dumpsys package output", async () => { + const { binary } = await writeAdbStub( + shellStub({ + "dumpsys:package": `Packages: + Package [com.example] (abcdef): + userId=10234 + versionName=1.2.3 + versionCode=45 minSdk=24 targetSdk=34 + firstInstallTime=2026-01-02 03:04:05 + lastUpdateTime=2026-02-03 04:05:06 + installerPackageName=com.android.vending + pkgFlags=[ HAS_CODE ALLOW_CLEAR_USER_DATA ] + requested permissions: + android.permission.INTERNET + android.permission.CAMERA +`, + }), + ); + const result = await evidenceResult(providerOver(binary), { + operation: "inspect_adb_package", + input: { serial: "emulator-5554", package: "com.example" }, + }); + expect(result).toMatchObject({ + version_name: "1.2.3", + version_code: 45, + first_install_time: "2026-01-02 03:04:05", + last_update_time: "2026-02-03 04:05:06", + installer_package_name: "com.android.vending", + user_id: 10234, + requested_permissions_count: 2, + coverage: "complete", + }); +}); + +it("reports an uninstalled package from dumpsys refusal output", async () => { + const { binary } = await writeAdbStub( + shellStub({ "dumpsys:package": "Unable to find package: com.absent\n" }), + ); + const projected = await refusedBy(providerOver(binary), { + operation: "inspect_adb_package", + input: { serial: "emulator-5554", package: "com.absent" }, + }); + expect(projected).toMatchObject({ code: "unsupported_target" }); +}); + +it("pulls one device file with a digest", async () => { + const { binary, root } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "pull" ]; then printf 'file-bytes' > "$5"; exit 0; fi +exit 1`, + ); + const output = join(root, "pulls"); + const result = await evidenceResult(providerOver(binary), { + operation: "pull_adb_file", + input: { + serial: "emulator-5554", + device_path: "/data/local/tmp/notes.txt", + output_directory: output, + }, + }); + const digest = createHash("sha256").update("file-bytes").digest("hex"); + expect(result).toMatchObject({ + device_path: "/data/local/tmp/notes.txt", + local_path: join(output, "notes.txt"), + bytes: 10, + sha256: digest, + }); +}); + +it("pushes a file and verifies the device-side digest", async () => { + const { binary, root } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi +if [ "$1" = "-s" ] && [ "$3" = "push" ]; then exit 0; fi +if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then printf '%s %s\\n' "8748b5d17d1ebcec7d46674fa6a8b92c0f95598a552f27c1c8a132eb58c04043" "$5"; exit 0; fi +exit 1`, + ); + const localFile = join(root, "payload.bin"); + await writeFile(localFile, "push-payload"); + const digest = createHash("sha256").update("push-payload").digest("hex"); + const pushed = await evidenceResult(providerOver(binary), { + operation: "push_adb_file", + input: { + serial: "emulator-5554", + local_path: localFile, + device_path: "/data/local/tmp/payload.bin", + overwrite: false, + }, + }); + expect(pushed).toMatchObject({ + bytes: 12, + sha256: digest, + device_sha256: digest, + overwritten: false, + device_digest_source: "device_sha256sum", + }); +}); + +it("reports a digest mismatch after push as a changed artifact", async () => { + const { binary, root } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 1; fi +if [ "$1" = "-s" ] && [ "$3" = "push" ]; then exit 0; fi +if [ "$1" = "-s" ] && [ "$4" = "sha256sum" ]; then printf '%s %s\\n' "$(printf 'a%.0s' 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64)" "$5"; exit 0; fi +exit 1`, + ); + const localFile = join(root, "payload.bin"); + await writeFile(localFile, "push-payload"); + const projected = await refusedBy(providerOver(binary), { + operation: "push_adb_file", + input: { + serial: "emulator-5554", + local_path: localFile, + device_path: "/data/local/tmp/payload.bin", + overwrite: false, + }, + }); + expect(projected).toMatchObject({ code: "artifact_changed" }); +}); + +it("refuses a push onto an existing device path without overwrite", async () => { + const { binary, root } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$4" = "test" ]; then exit 0; fi +exit 1`, + ); + const localFile = join(root, "payload.bin"); + await writeFile(localFile, "x"); + const result = await providerOver(binary).execute({ + operation: "push_adb_file", + input: { + serial: "emulator-5554", + local_path: localFile, + device_path: "/data/local/tmp/exists.bin", + overwrite: false, + }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unreadable_output", + }); + expect(result.error.message).toContain("overwrite"); +}); + +it("reports a local push source that does not exist", async () => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +exit 1`, + ); + const projected = await refusedBy(providerOver(binary), { + operation: "push_adb_file", + input: { + serial: "emulator-5554", + local_path: "/nonexistent/payload.bin", + device_path: "/data/local/tmp/payload.bin", + overwrite: false, + }, + }); + expect(projected).toMatchObject({ code: "invalid_request" }); +}); + +it("captures the screen as a digested PNG with its dimensions", async () => { + const { binary, root } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "exec-out" ]; then + printf '\\211PNG\\r\\n\\032\\n\\0\\0\\0\\rIHDR\\0\\0\\0\\004\\0\\0\\0\\003' + exit 0 +fi +exit 1`, + ); + const output = join(root, "captures"); + const result = await evidenceResult(providerOver(binary), { + operation: "capture_adb_screen", + input: { serial: "emulator-5554", output_directory: output }, + }); + expect(result).toMatchObject({ width: 4, height: 3 }); + expect( + await readFile(join(output, "screen.png")).then( + (bytes) => bytes.subarray(1, 4).toString("latin1") === "PNG", + () => false, + ), + ).toBe(true); +}); + +it("lists processes, features, and services through fixed commands", async () => { + const { binary } = await writeAdbStub( + shellStub({ + ps: "USER PID PPID VSZ RSS WCHAN ADDR S NAME\nu0_a1 1234 567 123456 7890 0 0 S com.example.app\n", + "pm:list": + "feature:android.hardware.camera\nfeature:android.hardware.nfc=1\n", + "service:list": + "Found 2 services:\n 1 package: [android.content.pm.IPackageManager]\n 2 activity: [android.app.IActivityManager]\n", + }), + ); + const processes = await evidenceResult(providerOver(binary), { + operation: "list_adb_processes", + input: { serial: "emulator-5554" }, + }); + expect(processes.processes).toEqual([ + { + user: "u0_a1", + pid: 1234, + ppid: 567, + rss: 7890, + state: "S", + name: "com.example.app", + }, + ]); + const features = await evidenceResult(providerOver(binary), { + operation: "list_adb_features", + input: { serial: "emulator-5554" }, + }); + expect(features.features).toEqual([ + { name: "android.hardware.camera", version: null }, + { name: "android.hardware.nfc", version: 1 }, + ]); + const services = await evidenceResult(providerOver(binary), { + operation: "list_adb_services", + input: { serial: "emulator-5554" }, + }); + expect(services.services).toEqual([ + { name: "package", interface: "android.content.pm.IPackageManager" }, + { name: "activity", interface: "android.app.IActivityManager" }, + ]); +}); + +it("lists a device directory with kinds and symlink targets", async () => { + const { binary } = await writeAdbStub( + shellStub({ + ls: "total 4\n-rw-rw---- u0_a1 u0_a1 4096 2026-01-02 11:00 app.db\nlrwxrwxrwx root root 2026-01-01 10:00 lib -> /apex/com.android.art/lib\n", + }), + ); + const result = await evidenceResult(providerOver(binary), { + operation: "list_adb_directory", + input: { + serial: "emulator-5554", + device_path: "/data/data/com.example.app", + }, + }); + expect(result.entries).toEqual([ + { + name: "app.db", + kind: "file", + permissions: "-rw-rw----", + owner: "u0_a1", + group: "u0_a1", + bytes: 4096, + date: "2026-01-02 11:00", + link_target: null, + }, + { + name: "lib", + kind: "symlink", + permissions: "lrwxrwxrwx", + owner: "root", + group: "root", + bytes: null, + date: "2026-01-01 10:00", + link_target: "/apex/com.android.art/lib", + }, + ]); +}); + +it("inspects the display and window focus", async () => { + const { binary } = await writeAdbStub( + shellStub({ + "wm:size": "Physical size: 1080x2340\n", + "wm:density": "Physical density: 420\n", + "dumpsys:window": + " mCurrentFocus=Window{abcdef u0 com.example.app/com.example.app.MainActivity}\n", + }), + ); + const display = await evidenceResult(providerOver(binary), { + operation: "inspect_adb_display", + input: { serial: "emulator-5554" }, + }); + expect(display.physical_size).toEqual({ width: 1080, height: 2340 }); + expect(display.physical_density).toBe(420); + const window = await evidenceResult(providerOver(binary), { + operation: "inspect_adb_window", + input: { serial: "emulator-5554" }, + }); + expect(window.focused_window).toBe( + "Window{abcdef u0 com.example.app/com.example.app.MainActivity}", + ); +}); + +it("reads one settings value and reports device-side nulls", async () => { + const { binary } = await writeAdbStub(shellStub({ "settings:get": "1\n" })); + const enabled = await evidenceResult(providerOver(binary), { + operation: "read_adb_setting", + input: { + serial: "emulator-5554", + namespace: "secure", + key: "adb_enabled", + }, + }); + expect(enabled.value).toBe("1"); + const { binary: nullBinary } = await writeAdbStub( + shellStub({ "settings:get": "null\n" }), + ); + const absent = await evidenceResult(providerOver(nullBinary), { + operation: "read_adb_setting", + input: { + serial: "emulator-5554", + namespace: "global", + key: "absent_key", + }, + }); + expect(absent.value).toBeNull(); + expect(absent.device_reported_null).toBe(true); +}); + +it("resolves packages by case-insensitive substring", async () => { + const { binary } = await writeAdbStub( + shellStub({ + "pm:list": + "package:/data/app/x/base.apk=com.whatsapp\\npackage:/data/app/y/base.apk=com.whatsapp.w4b\\npackage:/data/app/z/base.apk=com.example\\n", + }), + ); + const result = await evidenceResult(providerOver(binary), { + operation: "resolve_adb_packages", + input: { serial: "emulator-5554", query: "WhatsApp" }, + }); + expect(result.matches).toEqual([ + { + package_name: "com.whatsapp", + base_apk_device_path: "/data/app/x/base.apk", + }, + { + package_name: "com.whatsapp.w4b", + base_apk_device_path: "/data/app/y/base.apk", + }, + ]); + expect(result.exact_match).toBe(false); + expect(result.coverage).toBe("complete"); +}); + +it("installs a local APK and preserves the device's failure verdict", async () => { + const { binary, root } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'Success\\n'; exit 0; fi +exit 1`, + ); + const apk = join(root, "app.apk"); + await writeFile(apk, "apk"); + const digest = createHash("sha256").update("apk").digest("hex"); + const installed = await evidenceResult(providerOver(binary), { + operation: "install_adb_package", + input: { serial: "emulator-5554", apk_path: apk, replace: false }, + }); + expect(installed).toMatchObject({ + bytes: 3, + sha256: digest, + replaced: false, + }); + + const { binary: failing } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'Failure [INSTALL_FAILED_ALREADY_EXISTS]\\n'; exit 0; fi +exit 1`, + ); + const projected = await refusedBy(providerOver(failing), { + operation: "install_adb_package", + input: { + serial: "emulator-5554", + apk_path: apk, + replace: false, + }, + }); + expect(projected).toMatchObject({ code: "unsupported_target" }); + expect(projected.message).toContain("INSTALL_FAILED_ALREADY_EXISTS"); +}); + +it("uninstalls a package and reports the device's refusal for absent ones", async () => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Success\\n'; exit 0; fi +exit 1`, + ); + const removed = await evidenceResult(providerOver(binary), { + operation: "uninstall_adb_package", + input: { serial: "emulator-5554", package: "com.example" }, + }); + expect(removed.package_name).toBe("com.example"); + + const { binary: refusing } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 0; fi +exit 1`, + ); + const projected = await refusedBy(providerOver(refusing), { + operation: "uninstall_adb_package", + input: { serial: "emulator-5554", package: "com.absent" }, + }); + expect(projected).toMatchObject({ code: "unsupported_target" }); + expect(projected.message).toContain("not installed for 0"); +}); + +it("starts an app through resolved launcher component and force-stops it", async () => { + const { binary } = await writeAdbStub( + shellStub({ + "cmd:package": + "priority=0 preferredOrder=0 match=0x00108000 specificIndex=-1 isDefault=true\ncom.example/.Main", + "am:start": + "Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example/.Main }\n", + "am:force-stop": "", + }), + ); + const provider = providerOver(binary); + const started = await evidenceResult(provider, { + operation: "start_adb_app", + input: { serial: "emulator-5554", package: "com.example" }, + }); + expect(started).toMatchObject({ + package_name: "com.example", + component: "com.example/.Main", + started_activity: expect.stringContaining( + "cmp=com.example/.Main", + ) as unknown, + }); + const stopped = await evidenceResult(provider, { + operation: "stop_adb_app", + input: { serial: "emulator-5554", package: "com.example" }, + }); + expect(stopped.package_name).toBe("com.example"); +}); + +it("reports an app without a launcher activity as unsupported", async () => { + const { binary } = await writeAdbStub( + shellStub({ "cmd:package": "No activity found\n" }), + ); + const projected = await refusedBy(providerOver(binary), { + operation: "start_adb_app", + input: { serial: "emulator-5554", package: "com.library.only" }, + }); + expect(projected).toMatchObject({ code: "unsupported_target" }); + expect(projected.message).toContain("No launcher activity"); +}); + +it("starts an intent with typed extras as a fixed argv vector", async () => { + const { binary, root } = await writeAdbStub( + (root) => `printf '%s\\n' "$@" >> ${join(root, "argv.log")} +${shellStub({ "am:start": "Starting: Intent { act=android.intent.action.VIEW dat=https://example.com/ }\n" })(root)}`, + ); + const started = await evidenceResult(providerOver(binary), { + operation: "start_adb_activity", + input: { + serial: "emulator-5554", + action: "android.intent.action.VIEW", + data_uri: "https://example.com/", + extras: [ + { key: "level", type: "int", value: "3" }, + { key: "label", type: "string", value: "probe" }, + ], + }, + }); + expect(started.action).toBe("android.intent.action.VIEW"); + const logged = (await readFile(join(root, "argv.log"), "utf8")) + .trim() + .split("\n"); + expect(logged).toEqual([ + "version", + "-s", + "emulator-5554", + "shell", + "am", + "start", + "-W", + "-a", + "android.intent.action.VIEW", + "-d", + "https://example.com/", + "--ei", + "level", + "3", + "--es", + "label", + "probe", + ]); +}); + +it("rejects mistyped intent extras before touching the device", async () => { + const { binary } = await writeAdbStub( + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +: > should-not-exist`, + ); + const projected = await refusedBy(providerOver(binary), { + operation: "start_adb_activity", + input: { + serial: "emulator-5554", + action: "com.example.ACTION", + extras: [{ key: "flag", type: "boolean", value: "yes" }], + }, + }); + expect(projected).toMatchObject({ code: "invalid_request" }); +}); + +it("collects a device bugreport archive with its digest", async () => { + const { binary, root } = await writeAdbStub( + (root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +if [ "$1" = "-s" ] && [ "$3" = "bugreport" ]; then + printf 'bugreport-bytes' > "${join(root, "bugreport test.zip")}" + printf 'Bug report copied to ${join(root, "bugreport test.zip")}\\n' + exit 0 +fi +exit 1`, + ); + const result = await evidenceResult(providerOver(binary), { + operation: "collect_adb_bugreport", + input: { serial: "emulator-5554", output_directory: root }, + }); + const digest = createHash("sha256").update("bugreport-bytes").digest("hex"); + expect(result).toMatchObject({ + local_path: join(root, "bugreport test.zip"), + bytes: 15, + sha256: digest, + adb_reported_path: join(root, "bugreport test.zip"), + }); +}); + +it("quotes caller text before adb joins the remote shell command", async () => { + const { binary, root } = await writeAdbStub( + (root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +shift 3 +# Model adb's remote command joining, then execute the resulting shell string. +printf '#!/bin/sh\nprintf "%%s" "$@" > "${join(root, "observed")}"\n' > "${join(root, "ls")}" +chmod +x "${join(root, "ls")}" +PATH="${root}:$PATH" sh -c "$*" +`, + ); + const value = `/sdcard/a 'quoted'; touch ${join(root, "injected")}`; + await providerOver(binary).execute({ + operation: "list_adb_directory", + input: { serial: "device", device_path: value }, + }); + expect(await readFile(join(root, "observed"), "utf8")).toContain(value); + await expect(readFile(join(root, "injected"))).rejects.toThrow(); +}); + +it("preserves a screen output created while capture is running", async () => { + const { binary, root } = await writeAdbStub( + (root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi +printf 'existing evidence' > "${join(root, "captures", "screen.png")}" +printf '\\211PNG\\r\\n\\032\\n\\0\\0\\0\\rIHDR\\0\\0\\0\\004\\0\\0\\0\\003' +`, + ); + const result = await providerOver(binary).execute({ + operation: "capture_adb_screen", + input: { serial: "device", output_directory: join(root, "captures") }, + }); + expect(result.ok).toBe(false); + expect(await readFile(join(root, "captures", "screen.png"), "utf8")).toBe( + "existing evidence", + ); +}); + +it("reports cancellation arriving during the version probe", async () => { + const { binary } = await writeAdbStub(() => "exec sleep 30"); + const controller = new AbortController(); + const pending = providerOver(binary).execute( + { operation: "inspect_adb_client", input: {} }, + { signal: controller.signal }, + ); + const timer = setTimeout(() => controller.abort(), 50); + try { + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + } finally { + clearTimeout(timer); + } +}); diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts new file mode 100644 index 000000000..f2bafdb51 --- /dev/null +++ b/src/adb/AdbProvider.ts @@ -0,0 +1,1776 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream, constants as fsConstants } from "node:fs"; +import { access, lstat, mkdir, rm, stat, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; + +import type { + ExecutionOptions, + ProviderAvailability, +} from "../application/AnalysisProvider.js"; +import { + createAnalysisExecution, + type AnalysisExecution, +} from "../application/AnalysisProvider.js"; +import type { AdbRequest } from "../domain/adb/adbDeviceAnalysis.js"; +import { + AnalysisArtifactChangedError, + AnalysisCapabilityUnavailableError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, + AnalysisProtocolError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + execFileOutput, + execFileOutputFailure, +} from "../process/ExecFileOutput.js"; +import { + AdbConfigurationFailure, + inspectAdbClient, + resolveAdbBinary, + type AdbBinarySelection, + type AdbClientIdentity, +} from "./AdbConfiguration.js"; +import { + parseAdbDevicesOutput, + parseGetpropOutput, + parsePackageListOutput, + parsePmPathOutput, +} from "./AdbDeviceOutput.js"; +import { + parseAmStartOutput, + parseBugreportCopiedPath, + parseDumpsysPackageOutput, + parseFeatureListOutput, + parseLsOutput, + parsePackageChangeOutput, + parseProcessListOutput, + parsePngDimensions, + parseResolveActivityOutput, + parseServiceListOutput, + parseSettingsGetOutput, + parseWindowFocusOutput, + parseWmDensityOutput, + parseWmSizeOutput, +} from "./AdbShellOutput.js"; + +/** Stable identity for the ADB device-acquisition provider. */ +export const ADB_PROVIDER_IDENTITY = { + id: "adb", + name: "Android Debug Bridge", + version: null, +} as const; + +const VERSION_TIMEOUT_MS = 10_000; +const VERSION_MAX_OUTPUT_BYTES = 64 * 1024; +const DEVICES_TIMEOUT_MS = 20_000; +const DEVICES_MAX_OUTPUT_BYTES = 1024 * 1024; +const SHELL_TIMEOUT_MS = 30_000; +const GETPROP_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; +const PACKAGE_LIST_TIMEOUT_MS = 60_000; +const PACKAGE_LIST_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const PM_PATH_TIMEOUT_MS = 30_000; +const PM_PATH_MAX_OUTPUT_BYTES = 64 * 1024; +const PULL_TIMEOUT_MS = 600_000; +const PULL_MAX_OUTPUT_BYTES = 1024 * 1024; +const LOGCAT_TIMEOUT_MS = 60_000; +const LOGCAT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; +const DUMPSYS_PACKAGE_TIMEOUT_MS = 60_000; +const DUMPSYS_PACKAGE_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const DUMPSYS_WINDOW_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const LISTING_TIMEOUT_MS = 30_000; +const LISTING_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; +const SETTINGS_TIMEOUT_MS = 20_000; +const SETTINGS_MAX_OUTPUT_BYTES = 1024 * 1024; +const SCREEN_TIMEOUT_MS = 60_000; +const SCREEN_MAX_OUTPUT_BYTES = 32 * 1024 * 1024; +const BUGREPORT_TIMEOUT_MS = 600_000; +const BUGREPORT_MAX_OUTPUT_BYTES = 1024 * 1024; +const STDERR_EXCERPT_BYTES = 512; +const UNPARSED_LINES_REPORTED = 10; +const STOP_GRACE_MS = 5_000; + +/** Stream a local file digest without retaining large files in memory. */ +const sha256File = async (path: string): Promise => { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +}; + +/** adb joins shell operands into a remote POSIX command; quote at that boundary. */ +const quoteShellOperand = (value: string): string => + /^[A-Za-z0-9_./:=@+,\-]+$/u.test(value) + ? value + : "'" + value.replaceAll("'", "'\\''") + "'"; + +const adbArguments = ( + serial: string | undefined, + arguments_: readonly string[], +): readonly string[] => { + const operands = + arguments_[0] === "shell" + ? ["shell", ...arguments_.slice(1).map(quoteShellOperand)] + : [...arguments_]; + return serial === undefined ? operands : ["-s", serial, ...operands]; +}; + +interface AdbRunLimits { + readonly timeoutMs: number; + readonly maxOutputBytes: number; +} + +/** Run one bounded adb invocation and keep its exact failure shape. */ +const runAdb = async ( + operation: string, + targetPath: string, + binary: string, + arguments_: readonly string[], + limits: AdbRunLimits, + signal: AbortSignal | undefined, +): Promise> => { + if (signal?.aborted === true) + return err(new AnalysisCancelledError(operation)); + try { + return ok( + await execFileOutput(binary, arguments_, { + timeout: limits.timeoutMs, + maxBuffer: limits.maxOutputBytes, + stopSignal: "SIGTERM", + ...(signal === undefined ? {} : { signal }), + }), + ); + } catch (cause) { + if (signal?.aborted || execFileOutputFailure(cause)?.code === "ABORT_ERR") + return err(new AnalysisCancelledError(operation, { cause })); + const failure = execFileOutputFailure(cause); + if (failure === undefined) + return err( + new AnalysisProtocolError(`adb failed for ${operation}`, { cause }), + ); + if (failure.outputTruncated) + return err( + new AnalysisResourceConstraintError( + operation, + "memory", + `adb output exceeded the ${String(limits.maxOutputBytes)}-byte capture budget`, + { max_output_bytes: limits.maxOutputBytes }, + { cause }, + ), + ); + const excerpt = failure.stderr.trim().slice(0, STDERR_EXCERPT_BYTES); + if (/unauthorized/iu.test(excerpt)) + return err( + new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, { + cause, + }), + ); + if ( + /device[^\n]*not found|device offline|still authorizing|more than one device/iu.test( + excerpt, + ) + ) + return err( + new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, { + cause, + }), + ); + if ( + /No such file or directory|Permission denied|read: Permission denied/iu.test( + excerpt, + ) + ) + return err( + new AnalysisUnsupportedTargetError(operation, targetPath, excerpt, { + cause, + }), + ); + if (failure.killed) + return err( + new AnalysisTimeoutError(operation, limits.timeoutMs, { cause }), + ); + return err( + new AnalysisProtocolError( + excerpt === "" + ? `adb exited with code ${String(failure.code)} for ${operation}` + : excerpt, + { cause }, + ), + ); + } +}; + +/** Capture one adb invocation's raw binary stdout (screencap PNG). */ +const runAdbBinary = ( + operation: string, + binary: string, + arguments_: readonly string[], + limits: AdbRunLimits, + signal: AbortSignal | undefined, +): Promise> => + new Promise((settle) => { + if (signal?.aborted === true) + return settle(err(new AnalysisCancelledError(operation))); + let stopped: "abort" | "timeout" | undefined; + let deadline: NodeJS.Timeout | undefined; + let grace: NodeJS.Timeout | undefined; + const child = execFile( + binary, + [...arguments_], + { + encoding: "buffer" as const, + maxBuffer: limits.maxOutputBytes, + killSignal: "SIGTERM", + timeout: 0, + }, + (error, stdout) => { + signal?.removeEventListener("abort", onAbort); + clearTimeout(deadline); + clearTimeout(grace); + if (stopped === "abort") + return settle(err(new AnalysisCancelledError(operation))); + if (stopped === "timeout") + return settle( + err(new AnalysisTimeoutError(operation, limits.timeoutMs)), + ); + if (error !== null) + return settle( + err( + new AnalysisProtocolError( + `adb failed for ${operation}: ${error.message}`, + { cause: error }, + ), + ), + ); + settle(ok(stdout as Buffer)); + }, + ); + const stop = (reason: "abort" | "timeout"): void => { + if (stopped !== undefined) return; + stopped = reason; + child.kill("SIGTERM"); + grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS); + }; + const onAbort = (): void => stop("abort"); + signal?.addEventListener("abort", onAbort, { once: true }); + deadline = setTimeout(() => stop("timeout"), limits.timeoutMs); + }); + +const clientIdentityResult = ( + selection: AdbBinarySelection, + identity: AdbClientIdentity, +) => ({ + binary_path: selection.binary, + path_source: selection.pathSource, + version: identity.version, + installed_path: identity.installedPath, +}); + +type ClientIdentity = ReturnType; +type ProviderIdentity = { id: string; name: string; version: string | null }; + +const reported = ( + unparsedLines: readonly string[], + coverage: "complete" | "partial", +) => ({ + unparsed_lines: unparsedLines.slice(0, UNPARSED_LINES_REPORTED), + coverage: + coverage === "partial" || unparsedLines.length > 0 ? "partial" : "complete", +}); + +const unparsedOverflowLimitation = ( + unparsedLines: readonly string[], +): string[] => + unparsedLines.length > UNPARSED_LINES_REPORTED + ? [ + `unparsed_lines reports the first ${String(UNPARSED_LINES_REPORTED)} of ${String(unparsedLines.length)} unrecognized lines`, + ] + : []; + +/** Materialize the caller's output directory, refusing existing files inside. */ +const prepareOutputDirectory = async ( + operation: string, + directory: string, +): Promise> => { + const root = resolve(directory); + try { + await mkdir(root, { recursive: true }); + await access(root, fsConstants.W_OK); + } catch (cause) { + return err( + new AnalysisOutputError( + operation, + `The output directory is not writable or could not be created: ${root}`, + { cause }, + ), + ); + } + return ok(root); +}; + +const refuseExistingTarget = async ( + operation: string, + path: string, +): Promise => { + if ( + await lstat(path).then( + () => true, + () => false, + ) + ) + return new AnalysisOutputError( + operation, + `The output file already exists: ${path}`, + ); + return undefined; +}; + +export interface AdbProviderOptions { + readonly environment?: Readonly>; +} + +/** + * ADB device inspection and acquisition over a caller-selected adb binary. + * + * Every operation composes fixed adb argument vectors: caller values occupy + * positional arguments, escaped when adb forwards them to the device shell. The provider never + * starts an emulator, installs platform tools, or drives the device UI; the + * only device mutation is the caller-requested file push, which requires an + * explicit overwrite choice and verifies its transfer digest. + */ +export class AdbProvider { + private readonly environment: Readonly>; + + constructor(options: AdbProviderOptions = {}) { + this.environment = options.environment ?? process.env; + } + + private selection(): AdbBinarySelection { + return resolveAdbBinary(this.environment); + } + + /** Probe the selected adb binary; never contacts devices or the server. */ + async inspectAvailability( + signal?: AbortSignal, + ): Promise { + const selection = this.selection(); + try { + await inspectAdbClient(selection, signal); + return { + status: "available", + code: null, + reason: null, + diagnostics: { + selected_binary: selection.binary, + path_source: selection.pathSource, + }, + }; + } catch (cause) { + if (cause instanceof AdbConfigurationFailure) + return { + status: "unavailable", + code: + cause.code === "binary_missing" + ? "executable_missing" + : cause.code === "not_absolute" + ? "not_configured" + : "version_unresolved", + reason: cause.message, + diagnostics: { ...cause.diagnostics }, + }; + throw cause; + } + } + + /** The provider holds no long-lived resources; the adb server is caller-owned. */ + async close(): Promise {} + + async execute( + request: AdbRequest, + options?: ExecutionOptions, + ): Promise> { + const signal = options?.signal; + if (signal?.aborted === true) + return err(new AnalysisCancelledError(request.operation)); + const selection = this.selection(); + let identity: AdbClientIdentity; + try { + identity = await inspectAdbClient(selection, signal); + } catch (cause) { + if (cause instanceof AnalysisCancelledError) return err(cause); + if (cause instanceof AdbConfigurationFailure) + return err( + new AnalysisCapabilityUnavailableError( + ADB_PROVIDER_IDENTITY.id, + request.operation, + typeof cause.diagnostics.remediation === "string" + ? `${cause.message} ${cause.diagnostics.remediation}` + : cause.message, + { cause }, + ), + ); + return err( + new AnalysisProtocolError( + `adb version probe failed for ${request.operation}`, + { cause }, + ), + ); + } + const client = clientIdentityResult(selection, identity); + const provider: ProviderIdentity = { + ...ADB_PROVIDER_IDENTITY, + version: identity.version, + }; + const { operation } = request; + const shell = (arguments_: readonly string[], limits: AdbRunLimits) => + runAdb( + operation, + "serial" in request.input ? request.input.serial : operation, + selection.binary, + adbArguments( + "serial" in request.input ? request.input.serial : undefined, + ["shell", ...arguments_], + ), + limits, + signal, + ); + switch (operation) { + case "inspect_adb_client": + return ok( + createAnalysisExecution( + { client, contacted_adb_server: false }, + provider, + ), + ); + case "list_adb_devices": { + const ran = await runAdb( + operation, + "adb-devices", + selection.binary, + ["devices", "-l"], + { + timeoutMs: DEVICES_TIMEOUT_MS, + maxOutputBytes: DEVICES_MAX_OUTPUT_BYTES, + }, + signal, + ); + if (!ran.ok) return ran; + const { devices, unparsedLines, daemonStarted } = parseAdbDevicesOutput( + ran.value.stdout, + ); + if (unparsedLines.length > 0) + return err( + new AnalysisProtocolError( + `adb devices reported an unrecognized line: ${unparsedLines[0]}`, + ), + ); + return ok( + createAnalysisExecution( + { client, devices, may_have_started_adb_server: daemonStarted }, + provider, + { + limitations: [ + "Device kind is inferred from the emulator serial prefix and USB transport metadata, not from device attestation", + "Kind stays unknown for TCP transports unless the serial is an emulator port", + ], + }, + ), + ); + } + case "inspect_adb_device": { + const ran = await runAdb( + operation, + request.input.serial, + selection.binary, + adbArguments(request.input.serial, ["shell", "getprop"]), + { + timeoutMs: SHELL_TIMEOUT_MS, + maxOutputBytes: GETPROP_MAX_OUTPUT_BYTES, + }, + signal, + ); + if (!ran.ok) return ran; + const { properties, emulatorObserved } = parseGetpropOutput( + ran.value.stdout, + ); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + properties, + property_count: properties.length, + emulator_observed: emulatorObserved, + }, + provider, + { + limitations: [ + "Properties are a fixed whitelist of build identity facts, not the full getprop dump", + "emulator_observed is null when the build does not report ro.kernel.qemu", + ], + }, + ), + ); + } + case "list_adb_packages": { + const scopeFlag = + request.input.scope === "third_party" + ? "-3" + : request.input.scope === "system" + ? "-s" + : null; + const ran = await shell( + [ + "pm", + "list", + "packages", + "-f", + ...(scopeFlag === null ? [] : [scopeFlag]), + ], + { + timeoutMs: PACKAGE_LIST_TIMEOUT_MS, + maxOutputBytes: PACKAGE_LIST_MAX_OUTPUT_BYTES, + }, + ); + if (!ran.ok) return ran; + const { packages, unparsedLines } = parsePackageListOutput( + ran.value.stdout, + ); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + scope: request.input.scope, + packages, + ...reported(unparsedLines, "complete"), + }, + provider, + { + limitations: [ + "base_apk_device_path is a device filesystem path, not a host-local path", + ...unparsedOverflowLimitation(unparsedLines), + ], + }, + ), + ); + } + case "pull_adb_package": + return this.pullPackage( + operation, + selection, + client, + provider, + request.input, + signal, + ); + case "read_adb_logcat": { + const ran = await shell( + [ + "logcat", + "-d", + "-b", + request.input.buffer, + "-t", + String(request.input.count), + ...(request.input.pid === undefined + ? [] + : ["--pid", String(request.input.pid)]), + ], + { + timeoutMs: LOGCAT_TIMEOUT_MS, + maxOutputBytes: LOGCAT_MAX_OUTPUT_BYTES, + }, + ); + if (!ran.ok) return ran; + const lines = ran.value.stdout.split(/\r?\n/u); + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + const bounded = lines.slice(-request.input.count); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + count: request.input.count, + buffer: request.input.buffer, + pid: request.input.pid ?? null, + lines: bounded, + total_bytes: ran.value.stdout.length, + coverage: + bounded.length !== lines.length || + bounded.length === request.input.count + ? "partial" + : "complete", + }, + provider, + { + limitations: [ + "Reads a bounded dump (logcat -d); the newest lines are kept when the count cap trims", + "coverage is partial when the requested count or capture budget truncated the dump", + ], + }, + ), + ); + } + case "inspect_adb_package": { + const ran = await shell(["dumpsys", "package", request.input.package], { + timeoutMs: DUMPSYS_PACKAGE_TIMEOUT_MS, + maxOutputBytes: DUMPSYS_PACKAGE_MAX_OUTPUT_BYTES, + }); + if (!ran.ok) return ran; + const stdout = ran.value.stdout; + if (/Unable to find package:/u.test(stdout) || stdout.trim() === "") + return err( + new AnalysisUnsupportedTargetError( + operation, + request.input.serial, + `Package Manager did not report details for ${request.input.package}; it may not be installed for user 0`, + ), + ); + const details = parseDumpsysPackageOutput(stdout); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + package_name: request.input.package, + version_name: details.versionName, + version_code: details.versionCode, + first_install_time: details.firstInstallTime, + last_update_time: details.lastUpdateTime, + installer_package_name: details.installerPackageName, + user_id: details.userId, + pkg_flags: details.pkgFlags, + requested_permissions_count: details.requestedPermissions, + coverage: + details.versionCode === null && details.versionName === null + ? "partial" + : "complete", + }, + provider, + { + limitations: [ + "Fields are a fixed projection of dumpsys package output; version-dependent sections are ignored rather than guessed", + "requested_permissions_count counts the listed requested permissions section only", + ], + }, + ), + ); + } + case "pull_adb_file": + return this.pullFile( + operation, + selection, + client, + provider, + request.input, + signal, + ); + case "push_adb_file": + return this.pushFile( + operation, + selection, + client, + provider, + request.input, + signal, + ); + case "capture_adb_screen": + return this.captureScreen( + operation, + selection, + client, + provider, + request.input, + signal, + ); + case "collect_adb_bugreport": + return this.collectBugreport( + operation, + selection, + client, + provider, + request.input, + signal, + ); + case "list_adb_processes": { + const ran = await shell(["ps", "-A"], { + timeoutMs: LISTING_TIMEOUT_MS, + maxOutputBytes: LISTING_MAX_OUTPUT_BYTES, + }); + if (!ran.ok) return ran; + const { columns, processes, unparsedLines } = parseProcessListOutput( + ran.value.stdout, + ); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + columns, + processes, + ...reported(unparsedLines, "complete"), + }, + provider, + { limitations: unparsedOverflowLimitation(unparsedLines) }, + ), + ); + } + case "list_adb_directory": { + const ran = await shell(["ls", "-la", request.input.device_path], { + timeoutMs: LISTING_TIMEOUT_MS, + maxOutputBytes: LISTING_MAX_OUTPUT_BYTES, + }); + if (!ran.ok) return ran; + const { entries, unparsedLines } = parseLsOutput(ran.value.stdout); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + device_path: request.input.device_path, + entries, + ...reported(unparsedLines, "complete"), + }, + provider, + { + limitations: [ + "Entry kinds derive from the permission string's type character", + ...unparsedOverflowLimitation(unparsedLines), + ], + }, + ), + ); + } + case "list_adb_features": { + const ran = await shell(["pm", "list", "features"], { + timeoutMs: LISTING_TIMEOUT_MS, + maxOutputBytes: LISTING_MAX_OUTPUT_BYTES, + }); + if (!ran.ok) return ran; + const { features, unparsedLines } = parseFeatureListOutput( + ran.value.stdout, + ); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + features, + ...reported(unparsedLines, "complete"), + }, + provider, + { limitations: unparsedOverflowLimitation(unparsedLines) }, + ), + ); + } + case "list_adb_services": { + const ran = await shell(["service", "list"], { + timeoutMs: LISTING_TIMEOUT_MS, + maxOutputBytes: LISTING_MAX_OUTPUT_BYTES, + }); + if (!ran.ok) return ran; + const { services, unparsedLines } = parseServiceListOutput( + ran.value.stdout, + ); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + services, + ...reported(unparsedLines, "complete"), + }, + provider, + { limitations: unparsedOverflowLimitation(unparsedLines) }, + ), + ); + } + case "inspect_adb_display": { + const size = await shell(["wm", "size"], { + timeoutMs: SETTINGS_TIMEOUT_MS, + maxOutputBytes: SETTINGS_MAX_OUTPUT_BYTES, + }); + if (!size.ok) return size; + const density = await shell(["wm", "density"], { + timeoutMs: SETTINGS_TIMEOUT_MS, + maxOutputBytes: SETTINGS_MAX_OUTPUT_BYTES, + }); + if (!density.ok) return density; + const parsedSize = parseWmSizeOutput(size.value.stdout); + if (parsedSize.physical === null) + return err( + new AnalysisProtocolError( + "wm size did not report a physical size; the build may use an unsupported format", + ), + ); + const parsedDensity = parseWmDensityOutput(density.value.stdout); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + physical_size: parsedSize.physical, + override_size: parsedSize.override, + physical_density: parsedDensity.physical, + override_density: parsedDensity.override, + }, + provider, + { + limitations: [ + "Override values are present only when a build has overridden the physical values", + ], + }, + ), + ); + } + case "inspect_adb_window": { + const ran = await shell(["dumpsys", "window", "windows"], { + timeoutMs: SHELL_TIMEOUT_MS, + maxOutputBytes: DUMPSYS_WINDOW_MAX_OUTPUT_BYTES, + }); + if (!ran.ok) return ran; + const { focusedWindow, focusedApp } = parseWindowFocusOutput( + ran.value.stdout, + ); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + focused_window: focusedWindow, + focused_app: focusedApp, + observed_lines: [ + ...(focusedWindow === null ? [] : [focusedWindow]), + ...(focusedApp === null ? [] : [focusedApp]), + ], + }, + provider, + { + limitations: [ + "Focus values are the window manager's declarations at read time; no interaction occurred", + ], + }, + ), + ); + } + case "read_adb_setting": { + const ran = await shell( + ["settings", "get", request.input.namespace, request.input.key], + { + timeoutMs: SETTINGS_TIMEOUT_MS, + maxOutputBytes: SETTINGS_MAX_OUTPUT_BYTES, + }, + ); + if (!ran.ok) return ran; + const { value, deviceReportedNull } = parseSettingsGetOutput( + ran.value.stdout, + ); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + namespace: request.input.namespace, + key: request.input.key, + value, + device_reported_null: deviceReportedNull, + }, + provider, + { + limitations: [ + "Android prints the literal string null for absent keys; that is reported as a device-side null observation", + ], + }, + ), + ); + } + case "resolve_adb_packages": { + const ran = await shell(["pm", "list", "packages", "-f"], { + timeoutMs: PACKAGE_LIST_TIMEOUT_MS, + maxOutputBytes: PACKAGE_LIST_MAX_OUTPUT_BYTES, + }); + if (!ran.ok) return ran; + const { packages, unparsedLines } = parsePackageListOutput( + ran.value.stdout, + ); + const needle = request.input.query.toLowerCase(); + const matches = packages.filter((entry) => + entry.package_name.toLowerCase().includes(needle), + ); + return ok( + createAnalysisExecution( + { + client, + serial: request.input.serial, + query: request.input.query, + matches, + exact_match: + matches.length === 1 && + matches[0]!.package_name.toLowerCase() === needle, + coverage: unparsedLines.length > 0 ? "partial" : "complete", + }, + provider, + { + limitations: [ + "Matching is a case-insensitive substring over package names; application display labels are not part of pm output", + ...unparsedOverflowLimitation(unparsedLines), + ], + }, + ), + ); + } + case "install_adb_package": + return this.installPackage( + operation, + selection, + client, + provider, + request.input, + signal, + ); + case "uninstall_adb_package": + return this.uninstallPackage( + operation, + selection, + client, + provider, + request.input, + signal, + ); + case "start_adb_app": + return this.startApp( + operation, + selection, + client, + provider, + request.input, + signal, + ); + case "start_adb_activity": + return this.startActivity( + operation, + selection, + client, + provider, + request.input, + signal, + ); + case "stop_adb_app": + return this.stopApp( + operation, + selection, + client, + provider, + request.input, + signal, + ); + } + } + + private async pullPackage( + operation: "pull_adb_package", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + input: { + readonly serial: string; + readonly package: string; + readonly output_directory: string; + }, + signal: AbortSignal | undefined, + ): Promise> { + const { serial, package: packageName } = input; + const paths = await runAdb( + operation, + serial, + selection.binary, + adbArguments(serial, ["shell", "pm", "path", packageName]), + { + timeoutMs: PM_PATH_TIMEOUT_MS, + maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES, + }, + signal, + ); + if (!paths.ok) return paths; + const parsed = parsePmPathOutput(paths.value.stdout); + if (parsed.paths.length === 0 && parsed.unparsedLines.length === 0) + return err( + new AnalysisUnsupportedTargetError( + operation, + serial, + `Package Manager reported no APK paths for ${packageName}; it may not be installed for user 0`, + ), + ); + if (parsed.unparsedLines.length > 0) + return err( + new AnalysisProtocolError( + `pm path reported an unrecognized line: ${parsed.unparsedLines[0]}`, + ), + ); + const directory = await prepareOutputDirectory( + operation, + input.output_directory, + ); + if (!directory.ok) return directory; + const packageDirectory = join(directory.value, packageName); + try { + if ( + await stat(packageDirectory).then( + () => true, + () => false, + ) + ) + throw new Error( + `The package output directory already exists: ${packageDirectory}`, + ); + await mkdir(packageDirectory); + } catch (cause) { + return err( + new AnalysisOutputError( + operation, + cause instanceof Error ? cause.message : String(cause), + { cause }, + ), + ); + } + const artifacts: { + device_path: string; + file_name: string; + local_path: string; + bytes: number; + sha256: string; + role: "base" | "split" | "unknown"; + role_basis: "file_name"; + }[] = []; + const failures: { + device_path: string | null; + stage: "resolve_paths" | "pull" | "digest"; + message: string; + }[] = []; + const seenFileNames = new Set(); + for (const entry of parsed.paths) { + if (seenFileNames.has(entry.file_name)) { + failures.push({ + device_path: entry.device_path, + stage: "pull", + message: `Duplicate file name ${entry.file_name} in the APK set; refusing to overwrite`, + }); + continue; + } + seenFileNames.add(entry.file_name); + const localPath = join(packageDirectory, entry.file_name); + const pulled = await runAdb( + operation, + serial, + selection.binary, + adbArguments(serial, ["pull", entry.device_path, localPath]), + { timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES }, + signal, + ); + if (!pulled.ok) { + if (pulled.error instanceof AnalysisCancelledError) return pulled; + failures.push({ + device_path: entry.device_path, + stage: "pull", + message: pulled.error.message, + }); + continue; + } + try { + const info = await stat(localPath); + const sha256 = await sha256File(localPath); + artifacts.push({ + device_path: entry.device_path, + file_name: entry.file_name, + local_path: localPath, + bytes: info.size, + sha256, + role: entry.role, + role_basis: "file_name", + }); + } catch { + failures.push({ + device_path: entry.device_path, + stage: "digest", + message: `The pulled file could not be read back after pulling: ${localPath}`, + }); + } + } + if (artifacts.length === 0 && failures.length > 0) { + await rm(packageDirectory, { recursive: true, force: true }).catch( + () => undefined, + ); + return err( + new AnalysisProtocolError( + `Every APK pull failed for ${packageName} on ${serial}: ${failures[0]!.message}`, + ), + ); + } + return ok( + createAnalysisExecution( + { + client, + serial, + package_name: packageName, + output_directory: directory.value, + artifacts, + failures, + coverage: failures.length === 0 ? "complete" : "partial", + }, + provider, + { + limitations: [ + "APK role names derive from the pulled file names (base.apk, split_*.apk)", + "Pulled artifacts are host-local copies; the device retains the originals", + ], + }, + ), + ); + } + + private async pullFile( + operation: "pull_adb_file", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + input: { + readonly serial: string; + readonly device_path: string; + readonly output_directory: string; + }, + signal: AbortSignal | undefined, + ): Promise> { + const directory = await prepareOutputDirectory( + operation, + input.output_directory, + ); + if (!directory.ok) return directory; + const fileName = basename(input.device_path) || "pulled"; + const localPath = join(directory.value, fileName); + const existing = await refuseExistingTarget(operation, localPath); + if (existing !== undefined) return err(existing); + const pulled = await runAdb( + operation, + input.serial, + selection.binary, + adbArguments(input.serial, ["pull", input.device_path, localPath]), + { timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES }, + signal, + ); + if (!pulled.ok) return pulled; + try { + const info = await stat(localPath); + const sha256 = await sha256File(localPath); + return ok( + createAnalysisExecution( + { + client, + serial: input.serial, + device_path: input.device_path, + local_path: localPath, + bytes: info.size, + sha256, + }, + provider, + { + limitations: [ + "The pulled file is a host-local copy; the device retains the original", + ], + }, + ), + ); + } catch (cause) { + return err( + new AnalysisOutputError( + operation, + `The pulled file could not be read back after pulling: ${localPath}`, + { cause }, + ), + ); + } + } + + private async pushFile( + operation: "push_adb_file", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + input: { + readonly serial: string; + readonly local_path: string; + readonly device_path: string; + readonly overwrite: boolean; + }, + signal: AbortSignal | undefined, + ): Promise> { + const localPath = resolve(input.local_path); + const info = await stat(localPath).then( + (value) => value, + () => null, + ); + if (info === null || !info.isFile()) + return err( + new AnalysisInputError(operation, { + cause: new Error(`The local file does not exist: ${localPath}`), + }), + ); + const sha256 = await sha256File(localPath); + const existsProbe = await runAdb( + operation, + input.device_path, + selection.binary, + adbArguments(input.serial, ["shell", "test", "-e", input.device_path]), + { timeoutMs: SETTINGS_TIMEOUT_MS, maxOutputBytes: 1024 }, + signal, + ); + let deviceExists: boolean; + if (existsProbe.ok) deviceExists = true; + else if (existsProbe.error instanceof AnalysisProtocolError) + deviceExists = false; + else if (existsProbe.error instanceof AnalysisTimeoutError) + return existsProbe; + else if (existsProbe.error instanceof AnalysisCancelledError) + return existsProbe; + else if (existsProbe.error instanceof AnalysisResourceConstraintError) + return existsProbe; + else deviceExists = true; // authorization/refusal states surface on push instead + if (deviceExists && !input.overwrite) + return err( + new AnalysisOutputError( + operation, + `The device path already exists and overwrite was not requested: ${input.device_path}`, + ), + ); + const pushed = await runAdb( + operation, + input.device_path, + selection.binary, + adbArguments(input.serial, ["push", localPath, input.device_path]), + { timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES }, + signal, + ); + if (!pushed.ok) return pushed; + const deviceDigest = await runAdb( + operation, + input.device_path, + selection.binary, + adbArguments(input.serial, ["shell", "sha256sum", input.device_path]), + { timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: 1024 }, + signal, + ); + if (deviceDigest.ok) { + const digestMatch = /^([a-f0-9]{64})\s/u.exec(deviceDigest.value.stdout); + if (digestMatch !== null && digestMatch[1] !== sha256) + return err( + new AnalysisArtifactChangedError( + operation, + input.device_path, + `The device-reported digest does not match the pushed local file: ${localPath}`, + ), + ); + return ok( + createAnalysisExecution( + { + client, + serial: input.serial, + local_path: localPath, + device_path: input.device_path, + bytes: info.size, + sha256, + device_sha256: digestMatch?.[1] ?? null, + overwritten: deviceExists, + device_digest_source: "device_sha256sum", + }, + provider, + { + limitations: [ + "Push writes the caller's file to the device; this is the only device-mutating ADB tool", + ], + }, + ), + ); + } + return ok( + createAnalysisExecution( + { + client, + serial: input.serial, + local_path: localPath, + device_path: input.device_path, + bytes: info.size, + sha256, + device_sha256: null, + overwritten: deviceExists, + device_digest_source: "unavailable", + }, + provider, + { + limitations: [ + "The device did not answer sha256sum, so the transfer is not device-side verified", + "Push writes the caller's file to the device; this is the only device-mutating ADB tool", + ], + }, + ), + ); + } + + private async installPackage( + operation: "install_adb_package", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + input: { + readonly serial: string; + readonly apk_path: string; + readonly replace: boolean; + }, + signal: AbortSignal | undefined, + ): Promise> { + const apkPath = resolve(input.apk_path); + const info = await stat(apkPath).then( + (value) => value, + () => null, + ); + if (info === null || !info.isFile()) + return err( + new AnalysisInputError(operation, { + cause: new Error(`The local APK does not exist: ${apkPath}`), + }), + ); + const sha256 = await sha256File(apkPath); + const installed = await runAdb( + operation, + input.serial, + selection.binary, + adbArguments(input.serial, [ + "install", + ...(input.replace ? ["-r"] : []), + apkPath, + ]), + { timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES }, + signal, + ); + if (!installed.ok) return installed; + const verdict = parsePackageChangeOutput(installed.value.stdout); + if (verdict.status === "failure") + return err( + new AnalysisUnsupportedTargetError( + operation, + input.serial, + verdict.reason ?? "the device refused the installation", + ), + ); + return ok( + createAnalysisExecution( + { + client, + serial: input.serial, + apk_path: apkPath, + bytes: info.size, + sha256, + replaced: input.replace, + }, + provider, + { + limitations: [ + "Install changes device state; the installed APK's digest is the host file's digest", + ], + }, + ), + ); + } + + private async uninstallPackage( + operation: "uninstall_adb_package", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + input: { readonly serial: string; readonly package: string }, + signal: AbortSignal | undefined, + ): Promise> { + const uninstalled = await runAdb( + operation, + input.serial, + selection.binary, + adbArguments(input.serial, ["uninstall", input.package]), + { + timeoutMs: PACKAGE_LIST_TIMEOUT_MS, + maxOutputBytes: PULL_MAX_OUTPUT_BYTES, + }, + signal, + ); + if (!uninstalled.ok) return uninstalled; + const verdict = parsePackageChangeOutput(uninstalled.value.stdout); + if (verdict.status === "failure") + return err( + new AnalysisUnsupportedTargetError( + operation, + input.serial, + verdict.reason ?? "the device refused the removal", + ), + ); + return ok( + createAnalysisExecution( + { + client, + serial: input.serial, + package_name: input.package, + }, + provider, + { + limitations: [ + "Uninstall removes the package and its data for user 0; keeping data (-k) is not exposed", + ], + }, + ), + ); + } + + private async startApp( + operation: "start_adb_app", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + input: { readonly serial: string; readonly package: string }, + signal: AbortSignal | undefined, + ): Promise> { + const resolved = await runAdb( + operation, + input.serial, + selection.binary, + adbArguments(input.serial, [ + "shell", + "cmd", + "package", + "resolve-activity", + "--brief", + "-c", + "android.intent.category.LAUNCHER", + input.package, + ]), + { timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES }, + signal, + ); + if (!resolved.ok) return resolved; + const component = parseResolveActivityOutput(resolved.value.stdout); + if (component === null) + return err( + new AnalysisUnsupportedTargetError( + operation, + input.serial, + `No launcher activity is declared for ${input.package}`, + ), + ); + return this.runAmStart( + operation, + selection, + client, + provider, + input.serial, + ["-n", component], + { + client, + serial: input.serial, + package_name: input.package, + component, + }, + signal, + ); + } + + private async startActivity( + operation: "start_adb_activity", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + input: { + readonly serial: string; + readonly action: string; + readonly data_uri?: string | undefined; + readonly component?: string | undefined; + readonly extras: readonly { + readonly key: string; + readonly type: "string" | "boolean" | "int" | "long" | "float"; + readonly value: string; + }[]; + }, + signal: AbortSignal | undefined, + ): Promise> { + const extraFlags: Record< + "string" | "boolean" | "int" | "long" | "float", + string + > = { + string: "--es", + boolean: "--ez", + int: "--ei", + long: "--el", + float: "--ef", + }; + for (const extra of input.extras) + if (extra.type === "boolean" && !/^(true|false)$/u.test(extra.value)) + return err( + new AnalysisInputError(operation, { + cause: new Error( + `Boolean extra ${extra.key} must be true or false`, + ), + }), + ); + else if ( + (extra.type === "int" || extra.type === "long") && + !/^-?\d+$/u.test(extra.value) + ) + return err( + new AnalysisInputError(operation, { + cause: new Error( + `${extra.type === "int" ? "Integer" : "Long"} extra ${extra.key} must be an integer`, + ), + }), + ); + else if (extra.type === "float" && !/^-?\d+(\.\d+)?$/u.test(extra.value)) + return err( + new AnalysisInputError(operation, { + cause: new Error(`Float extra ${extra.key} must be a number`), + }), + ); + const arguments_ = [ + "-a", + input.action, + ...(input.data_uri === undefined ? [] : ["-d", input.data_uri]), + ...(input.component === undefined ? [] : ["-n", input.component]), + ...input.extras.flatMap((extra) => [ + extraFlags[extra.type], + extra.key, + extra.value, + ]), + ]; + return this.runAmStart( + operation, + selection, + client, + provider, + input.serial, + arguments_, + { + client, + serial: input.serial, + action: input.action, + data_uri: input.data_uri ?? null, + component: input.component ?? null, + extras: input.extras.map((extra) => ({ ...extra })), + }, + signal, + ); + } + + private async runAmStart( + operation: "start_adb_app" | "start_adb_activity", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + serial: string, + arguments_: readonly string[], + resultFields: Record, + signal: AbortSignal | undefined, + ): Promise> { + const started = await runAdb( + operation, + serial, + selection.binary, + adbArguments(serial, ["shell", "am", "start", "-W", ...arguments_]), + { timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES }, + signal, + ); + if (!started.ok) return started; + const verdict = parseAmStartOutput(started.value.stdout); + if (verdict.status === "refused") + return err( + new AnalysisUnsupportedTargetError( + operation, + serial, + verdict.reason ?? "am start refused the intent", + ), + ); + return ok( + createAnalysisExecution( + { + ...resultFields, + started_activity: verdict.startedActivity, + }, + provider, + { + limitations: [ + "Starting an activity changes device state; am start -W waits for launch completion but no lifecycle observation is captured", + ], + }, + ) as AnalysisExecution, + ); + } + + private async stopApp( + operation: "stop_adb_app", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + input: { readonly serial: string; readonly package: string }, + signal: AbortSignal | undefined, + ): Promise> { + const stopped = await runAdb( + operation, + input.serial, + selection.binary, + adbArguments(input.serial, ["shell", "am", "force-stop", input.package]), + { timeoutMs: SHELL_TIMEOUT_MS, maxOutputBytes: PM_PATH_MAX_OUTPUT_BYTES }, + signal, + ); + if (!stopped.ok) return stopped; + return ok( + createAnalysisExecution( + { + client, + serial: input.serial, + package_name: input.package, + }, + provider, + { + limitations: [ + "Force-stop succeeds even for packages with no running process; it reports completion, not prior state", + ], + }, + ), + ); + } + + private async captureScreen( + operation: "capture_adb_screen", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + input: { readonly serial: string; readonly output_directory: string }, + signal: AbortSignal | undefined, + ): Promise> { + const directory = await prepareOutputDirectory( + operation, + input.output_directory, + ); + if (!directory.ok) return directory; + const localPath = join(directory.value, "screen.png"); + const existing = await refuseExistingTarget(operation, localPath); + if (existing !== undefined) return err(existing); + const captured = await runAdbBinary( + operation, + selection.binary, + adbArguments(input.serial, ["exec-out", "screencap", "-p"]), + { timeoutMs: SCREEN_TIMEOUT_MS, maxOutputBytes: SCREEN_MAX_OUTPUT_BYTES }, + signal, + ); + if (!captured.ok) return captured; + const dimensions = parsePngDimensions(captured.value); + if (dimensions.width === null) + return err( + new AnalysisProtocolError( + "screencap did not return a PNG payload; the device may not expose a display", + ), + ); + try { + await writeFile(localPath, captured.value, { flag: "wx" }); + } catch (cause) { + return err( + new AnalysisOutputError( + operation, + `The screen output could not be created exclusively: ${localPath}`, + { cause }, + ), + ); + } + const sha256 = createHash("sha256").update(captured.value).digest("hex"); + return ok( + createAnalysisExecution( + { + client, + serial: input.serial, + local_path: localPath, + bytes: captured.value.byteLength, + sha256, + width: dimensions.width, + height: dimensions.height, + }, + provider, + { + limitations: [ + "The capture is one frame at read time; no recording or interaction occurred", + ], + }, + ), + ); + } + + private async collectBugreport( + operation: "collect_adb_bugreport", + selection: AdbBinarySelection, + client: ClientIdentity, + provider: ProviderIdentity, + input: { readonly serial: string; readonly output_directory: string }, + signal: AbortSignal | undefined, + ): Promise> { + const directory = await prepareOutputDirectory( + operation, + input.output_directory, + ); + if (!directory.ok) return directory; + const collected = await runAdb( + operation, + input.serial, + selection.binary, + adbArguments(input.serial, ["bugreport", directory.value]), + { + timeoutMs: BUGREPORT_TIMEOUT_MS, + maxOutputBytes: BUGREPORT_MAX_OUTPUT_BYTES, + }, + signal, + ); + if (!collected.ok) return collected; + const reportedPath = parseBugreportCopiedPath(collected.value.stdout); + if (reportedPath === null) + return err( + new AnalysisProtocolError( + "adb bugreport did not report an archive path; the build may use an unsupported format", + ), + ); + try { + const info = await stat(reportedPath); + const sha256 = await sha256File(reportedPath); + return ok( + createAnalysisExecution( + { + client, + serial: input.serial, + local_path: reportedPath, + bytes: info.size, + sha256, + adb_reported_path: reportedPath, + }, + provider, + { + limitations: [ + "The archive is device-generated: dumpstate, dumpsys, and logcat content is the device's own report", + ], + }, + ), + ); + } catch (cause) { + return err( + new AnalysisOutputError( + operation, + `The reported bugreport archive could not be read back: ${reportedPath}`, + { cause }, + ), + ); + } + } +} diff --git a/src/adb/AdbShellOutput.ts b/src/adb/AdbShellOutput.ts new file mode 100644 index 000000000..a67cf8e89 --- /dev/null +++ b/src/adb/AdbShellOutput.ts @@ -0,0 +1,433 @@ +/** + * Parsers for fixed observation commands REA runs through `adb shell`. + * + * Formats follow toybox output on Android 8+ (verified against an Android 14 + * emulator). Unparseable lines are retained rather than dropped so callers + * can see the format drift instead of silently losing rows. + */ + +export interface AdbProcessEntry { + readonly user: string; + readonly pid: number; + readonly ppid: number | null; + readonly rss: number | null; + readonly state: string | null; + readonly name: string; +} + +const PROCESS_LINE = + /^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/u; + +/** Parse `ps -A` output; the header line is preserved verbatim as `columns`. */ +export const parseProcessListOutput = ( + stdout: string, +): { + columns: string | null; + processes: readonly AdbProcessEntry[]; + unparsedLines: readonly string[]; +} => { + const processes: AdbProcessEntry[] = []; + const unparsedLines: string[] = []; + let columns: string | null = null; + for (const rawLine of stdout.split(/\r?\n/u)) { + const line = rawLine.trimEnd(); + if (line === "") continue; + if (/^USER\s+PID\s+PPID\b/u.test(line)) { + columns = line.trim(); + continue; + } + const match = PROCESS_LINE.exec(line); + if (match === null) { + unparsedLines.push(line); + continue; + } + processes.push({ + user: match[1]!, + pid: Number(match[2]!), + ppid: Number.isSafeInteger(Number(match[3]!)) ? Number(match[3]!) : null, + rss: Number.isSafeInteger(Number(match[5]!)) ? Number(match[5]!) : null, + state: match[8]!, + name: match[9]!.trim(), + }); + } + return { columns, processes, unparsedLines }; +}; + +export interface AdbDirectoryEntry { + readonly name: string; + readonly kind: "directory" | "file" | "symlink" | "other"; + readonly permissions: string | null; + readonly owner: string | null; + readonly group: string | null; + readonly bytes: number | null; + readonly date: string | null; + readonly link_target: string | null; +} + +const LS_LINE = + /^([bcdlps-][rwxstST-]{9})\s+(?:(\d+)\s+)?(\S+)\s+(\S+)\s+(?:(\d+)\s+)?([\d-]{10})\s+([\d:]{4,8})\s+(.+)$/u; + +/** Parse `ls -la` output, including `total` headers and symlink targets. */ +export const parseLsOutput = ( + stdout: string, +): { + entries: readonly AdbDirectoryEntry[]; + unparsedLines: readonly string[]; +} => { + const entries: AdbDirectoryEntry[] = []; + const unparsedLines: string[] = []; + for (const rawLine of stdout.split(/\r?\n/u)) { + const line = rawLine.trimEnd(); + if (line === "" || line.startsWith("total ")) continue; + const match = LS_LINE.exec(line); + if (match === null) { + if (line.trim() !== "") unparsedLines.push(line); + continue; + } + const permissions = match[1]!; + let name = match[8]!.trim(); + let linkTarget: string | null = null; + const linkArrow = name.indexOf(" -> "); + if (linkArrow !== -1 && permissions.startsWith("l")) { + linkTarget = name.slice(linkArrow + 4); + name = name.slice(0, linkArrow); + } + entries.push({ + name, + kind: permissions.startsWith("d") + ? "directory" + : permissions.startsWith("l") + ? "symlink" + : permissions.startsWith("-") + ? "file" + : "other", + permissions, + owner: match[3] ?? null, + group: match[4] ?? null, + bytes: match[5] === undefined ? null : Number(match[5]), + date: `${match[6] ?? ""} ${match[7] ?? ""}`.trim() || null, + link_target: linkTarget, + }); + } + return { entries, unparsedLines }; +}; + +export interface AdbFeatureEntry { + readonly name: string; + readonly version: number | null; +} + +const FEATURE_LINE = /^feature:([^=\s]+)(?:=(0x[0-9a-fA-F]+|\d+))?$/u; + +const parseFeatureVersion = (text: string): number => + text.startsWith("0x") ? Number.parseInt(text.slice(2), 16) : Number(text); + +/** Parse `pm list features` output, including hex GL versions. */ +export const parseFeatureListOutput = ( + stdout: string, +): { + features: readonly AdbFeatureEntry[]; + unparsedLines: readonly string[]; +} => { + const features: AdbFeatureEntry[] = []; + const unparsedLines: string[] = []; + for (const rawLine of stdout.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line === "") continue; + const match = FEATURE_LINE.exec(line); + if (match === null) { + unparsedLines.push(line); + continue; + } + features.push({ + name: match[1]!, + version: match[2] === undefined ? null : parseFeatureVersion(match[2]!), + }); + } + return { features, unparsedLines }; +}; + +export interface AdbServiceEntry { + readonly name: string; + readonly interface: string | null; +} + +const SERVICE_LINE = /^\s*(?:\d+\s+)?([A-Za-z0-9._$/-]+):\s*\[([^\]]*)\]\s*$/u; + +/** Parse `service list` output, tolerating numbered and plain formats. */ +export const parseServiceListOutput = ( + stdout: string, +): { + services: readonly AdbServiceEntry[]; + unparsedLines: readonly string[]; +} => { + const services: AdbServiceEntry[] = []; + const unparsedLines: string[] = []; + for (const rawLine of stdout.split(/\r?\n/u)) { + const line = rawLine.trimEnd(); + if (line.trim() === "" || /^Found \d+ services:/u.test(line.trim())) + continue; + const match = SERVICE_LINE.exec(line); + if (match === null) { + if (line.trim() !== "") unparsedLines.push(line.trim()); + continue; + } + services.push({ + name: match[1]!, + interface: match[2] === "" ? null : match[2]!, + }); + } + return { services, unparsedLines }; +}; + +const SIZE_PAIR = /^Physical size:\s+(\d+)x(\d+)$/u; +const SIZE_OVERRIDE = /^Override size:\s+(\d+)x(\d+)$/u; +const DENSITY_PHYSICAL = /^Physical density:\s+(\d+)$/u; +const DENSITY_OVERRIDE = /^Override density:\s+(\d+)$/u; + +/** Parse `wm size` output into physical and optional override sizes. */ +export const parseWmSizeOutput = ( + stdout: string, +): { + physical: { readonly width: number; readonly height: number } | null; + override: { readonly width: number; readonly height: number } | null; +} => { + let physical = null; + let override = null; + for (const line of stdout.split(/\r?\n/u)) { + const trimmed = line.trim(); + const physicalMatch = SIZE_PAIR.exec(trimmed); + if (physicalMatch !== null) + physical = { + width: Number(physicalMatch[1]!), + height: Number(physicalMatch[2]!), + }; + const overrideMatch = SIZE_OVERRIDE.exec(trimmed); + if (overrideMatch !== null) + override = { + width: Number(overrideMatch[1]!), + height: Number(overrideMatch[2]!), + }; + } + return { physical, override }; +}; + +/** Parse `wm density` output. Older builds print a bare number. */ +export const parseWmDensityOutput = ( + stdout: string, +): { + physical: number | null; + override: number | null; +} => { + let physical = null; + let override = null; + for (const line of stdout.split(/\r?\n/u)) { + const trimmed = line.trim(); + const physicalMatch = DENSITY_PHYSICAL.exec(trimmed); + if (physicalMatch !== null) physical = Number(physicalMatch[1]!); + const overrideMatch = DENSITY_OVERRIDE.exec(trimmed); + if (overrideMatch !== null) override = Number(overrideMatch[1]!); + } + return { physical, override }; +}; + +/** Parse the focus declarations from `dumpsys window windows`. */ +export const parseWindowFocusOutput = ( + stdout: string, +): { + focusedWindow: string | null; + focusedApp: string | null; +} => { + let focusedWindow = null; + let focusedApp = null; + for (const line of stdout.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (focusedWindow === null && trimmed.startsWith("mCurrentFocus=")) + focusedWindow = trimmed.slice("mCurrentFocus=".length).trim(); + if (focusedApp === null && trimmed.startsWith("mFocusedApp=")) + focusedApp = trimmed.slice("mFocusedApp=".length).trim(); + } + return { focusedWindow, focusedApp }; +}; + +export interface AdbPackageDetails { + readonly versionName: string | null; + readonly versionCode: number | null; + readonly firstInstallTime: string | null; + readonly lastUpdateTime: string | null; + readonly installerPackageName: string | null; + readonly userId: number | null; + readonly pkgFlags: string | null; + readonly requestedPermissions: number | null; +} + +const DETAIL_LINE = /^\s+([A-Za-z][A-Za-z0-9_]*)=(.*)$/u; + +/** + * Project `dumpsys package PACKAGE` output. Only the listed fields are + * extracted; the input is version-dependent and everything else is ignored + * by design rather than guessed. + */ +export const parseDumpsysPackageOutput = ( + stdout: string, +): AdbPackageDetails => { + const details: { + versionName: string | null; + versionCode: number | null; + firstInstallTime: string | null; + lastUpdateTime: string | null; + installerPackageName: string | null; + userId: number | null; + pkgFlags: string | null; + } = { + versionName: null, + versionCode: null, + firstInstallTime: null, + lastUpdateTime: null, + installerPackageName: null, + userId: null, + pkgFlags: null, + }; + let inPermissions = false; + let permissions = 0; + for (const line of stdout.split(/\r?\n/u)) { + if (line.trim().startsWith("requested permissions:")) { + inPermissions = true; + continue; + } + if (inPermissions && /^\s{2,}[A-Za-z0-9._]+$/.test(line)) { + permissions += 1; + continue; + } + inPermissions = false; + const match = DETAIL_LINE.exec(line); + if (match === null) continue; + const key = match[1]!; + const value = match[2]!.trim(); + if (key === "versionName" && details.versionName === null) + details.versionName = value === "" ? null : value; + else if (key === "versionCode" && details.versionCode === null) { + const parsed = /^(\d+)/u.exec(value); + if (parsed !== null) details.versionCode = Number(parsed[1]!); + } else if (key === "firstInstallTime" && details.firstInstallTime === null) + details.firstInstallTime = value === "" ? null : value; + else if (key === "lastUpdateTime" && details.lastUpdateTime === null) + details.lastUpdateTime = value === "" ? null : value; + else if ( + key === "installerPackageName" && + details.installerPackageName === null + ) + details.installerPackageName = value === "" ? null : value; + else if (key === "userId" && details.userId === null) { + const parsed = /^(\d+)/u.exec(value); + if (parsed !== null) details.userId = Number(parsed[1]!); + } else if (key === "pkgFlags" && details.pkgFlags === null) + details.pkgFlags = value === "" ? null : value; + } + return { ...details, requestedPermissions: permissions }; +}; + +/** + * Parse `settings get` output. Android prints the literal string `null` for + * absent keys; that is reported as a device-side null observation. + */ +export const parseSettingsGetOutput = ( + stdout: string, +): { + value: string | null; + deviceReportedNull: boolean; +} => { + const trimmed = stdout.replace(/\r?\n$/u, ""); + if (trimmed === "null") return { value: null, deviceReportedNull: true }; + return { value: trimmed, deviceReportedNull: false }; +}; + +/** Parse the host-side completion line from `adb bugreport`. */ +export const parseBugreportCopiedPath = (stdout: string): string | null => { + const match = /^Bug report copied to (.+)\r?$/mu.exec(stdout.trim()); + return match === null ? null : match[1]!; +}; + +/** + * Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]` + * with a zero exit code, so the text decides, not the exit status. + */ +export const parsePackageChangeOutput = ( + stdout: string, +): { + readonly status: "success" | "failure"; + readonly reason: string | null; +} => { + const text = stdout.trim(); + if (/^Success$/mu.test(text)) return { status: "success", reason: null }; + const failure = /Failure \[([^\]]+)\]/u.exec(text); + return { + status: "failure", + reason: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)), + }; +}; + +/** + * Parse `cmd package resolve-activity --brief` output: the component is the + * last non-empty line. + */ +export const parseResolveActivityOutput = (stdout: string): string | null => { + const lines = stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line !== "" && !/^No activity found$/iu.test(line)); + return lines.length === 0 ? null : lines[lines.length - 1]!; +}; + +/** + * Parse `am start` output. Success prints `Starting: Intent { ... }`; + * refusals print lines starting with `Error:` or `Warning:` and activity + * classes that do not exist name the component. + */ +export const parseAmStartOutput = ( + stdout: string, +): { + readonly status: "started" | "refused"; + readonly startedActivity: string | null; + readonly reason: string | null; +} => { + const text = stdout.trim(); + const starting = /^Starting:\s+Intent\s*\{(.*)\}\s*$/mu.exec(text); + if (starting !== null) + return { + status: "started", + startedActivity: starting[1]!.trim(), + reason: null, + }; + const error = /^(?:Error|Warning|Exception)(?::|\b)(.*)$/mu.exec(text); + return { + status: "refused", + startedActivity: null, + reason: + error?.[1]?.trim() ?? + (text === "" ? "am start returned no activity" : text.slice(0, 512)), + }; +}; + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +/** Read image dimensions from a PNG header, or null for other payloads. */ +export const parsePngDimensions = ( + bytes: Uint8Array, +): { + width: number | null; + height: number | null; +} => { + if ( + bytes.length < 24 || + !Buffer.from(bytes.subarray(0, 8)).equals(PNG_SIGNATURE) + ) + return { width: null, height: null }; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { + width: view.getUint32(16), + height: view.getUint32(20), + }; +}; diff --git a/src/application/adb/AdbDeviceAnalysisPort.ts b/src/application/adb/AdbDeviceAnalysisPort.ts new file mode 100644 index 000000000..a4b541a83 --- /dev/null +++ b/src/application/adb/AdbDeviceAnalysisPort.ts @@ -0,0 +1,23 @@ +import type { + AnalysisExecution, + ExecutionOptions, + ProviderAvailability, +} from "../AnalysisProvider.js"; +import type { AdbRequest } from "../../domain/adb/adbDeviceAnalysis.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Result } from "../../domain/result.js"; + +/** + * ADB device inspection boundary over a caller-selected adb binary. + * Implementation details belong to the provider adapter. + */ +export interface AdbDeviceAnalysisPort { + /** Probe the selected adb binary without contacting devices. */ + inspectAvailability(signal?: AbortSignal): Promise; + /** The provider holds no long-lived resources; safe to call always. */ + close(): Promise; + execute( + request: AdbRequest, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/adb/AdbDeviceAnalysisService.test.ts b/src/application/adb/AdbDeviceAnalysisService.test.ts new file mode 100644 index 000000000..6e3f62641 --- /dev/null +++ b/src/application/adb/AdbDeviceAnalysisService.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "vitest"; + +import { AdbDeviceAnalysisService } from "./AdbDeviceAnalysisService.js"; +import type { AdbDeviceAnalysisPort } from "./AdbDeviceAnalysisPort.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: "adb", name: "Android Debug Bridge", version: null }; + +const execution = createAnalysisExecution( + { + client: { + binary_path: "/usr/bin/adb", + path_source: "path", + version: "34.0.5-debian", + installed_path: "/usr/bin/adb", + }, + devices: [], + may_have_started_adb_server: false, + }, + identity, +); + +const refusedPort: AdbDeviceAnalysisPort = { + async inspectAvailability() { + throw new Error("must not probe"); + }, + async close() {}, + async execute() { + throw new Error("must not execute"); + }, +}; + +it.each([ + [{ serial: "" }], + [{ serial: "has space" }], + [{ serial: "emulator-5554", scope: "unknown" }], + [{ serial: "emulator-5554", extra: true }], +])("rejects invalid device selection before any effect: %j", async (input) => { + const service = new AdbDeviceAnalysisService(refusedPort); + expect(await service.execute("inspect_adb_device", input)).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); +}); + +it("rejects an unknown operation before any effect", async () => { + const service = new AdbDeviceAnalysisService(refusedPort); + expect( + await service.execute("inspect_adb_device" as never, {}), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); +}); + +it("composes observed Evidence with the caller parameters inline", async () => { + const port: AdbDeviceAnalysisPort = { + async inspectAvailability() { + return { status: "available", code: null, reason: null, diagnostics: {} }; + }, + async close() {}, + async execute(request) { + expect(request).toEqual({ + operation: "list_adb_devices", + input: {}, + }); + return ok(execution); + }, + }; + const result = await new AdbDeviceAnalysisService(port).execute( + "list_adb_devices", + {}, + ); + if (!result.ok) throw new Error(result.error.message); + const evidence = parseEvidence(result.value); + expect(evidence.provider).toEqual(identity); + expect(evidence.operation).toBe("list_adb_devices"); + expect(evidence.parameters).toEqual({}); + expect(evidence.confidence).toBe("observed"); +}); + +it("preserves cancellation before execution", async () => { + const controller = new AbortController(); + controller.abort(); + const service = new AdbDeviceAnalysisService(refusedPort); + const result = await service.execute( + "list_adb_devices", + {}, + { + signal: controller.signal, + }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisCancelledError); +}); diff --git a/src/application/adb/AdbDeviceAnalysisService.ts b/src/application/adb/AdbDeviceAnalysisService.ts new file mode 100644 index 000000000..64d6f98f3 --- /dev/null +++ b/src/application/adb/AdbDeviceAnalysisService.ts @@ -0,0 +1,46 @@ +import type { AdbDeviceAnalysisPort } from "./AdbDeviceAnalysisPort.js"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import { + adbRequestSchema, + type AdbOperation, +} from "../../domain/adb/adbDeviceAnalysis.js"; +import { + AnalysisCancelledError, + AnalysisInputError, +} from "../../domain/analysisErrorCore.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { jsonObjectSchema } from "../../domain/jsonValue.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +/** Shared CLI/MCP admission, execution, and Evidence composition for ADB. */ +export class AdbDeviceAnalysisService { + constructor(readonly provider: AdbDeviceAnalysisPort) {} + + /** Run one caller-selected ADB request and retain device provenance inline. */ + async execute( + operation: AdbOperation, + input: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted === true) + return err(new AnalysisCancelledError(operation)); + const request = adbRequestSchema.safeParse({ operation, input }); + if (!request.success) + return err(new AnalysisInputError(operation, { cause: request.error })); + const executed = await this.provider.execute(request.data, options); + if (!executed.ok) return executed; + const execution = executed.value; + return ok( + createEvidence(execution.subject ?? undefined, execution.provider, { + operation, + parameters: jsonObjectSchema.parse(request.data.input), + result: execution.result, + rawResult: execution.rawResult, + limitations: execution.limitations, + locations: execution.locations, + confidence: "observed", + }), + ); + } +} diff --git a/src/cli.ts b/src/cli.ts index 36dcd1275..3099e5071 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import { registerArtifactCommands } from "./cli/artifactCommands.js"; import { registerManagedCommands } from "./cli/managedCommands.js"; import { registerFirmwareCommands } from "./cli/firmwareCommands.js"; import { registerAndroidCommands } from "./cli/androidCommands.js"; +import { registerAdbCommands } from "./cli/adbCommands.js"; import { registerEvidenceCommands } from "./cliEvidenceCommands.js"; import { registerProcessCommands } from "./cli/processCommands.js"; import { registerBrowserCommands } from "./cli/browserCommands.js"; @@ -73,6 +74,7 @@ export const createCli = ( registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); registerManagedCommands(cli, logger, analysis.runProviderAnalysis); registerAndroidCommands(cli, logger, environment); + registerAdbCommands(cli, logger, environment); registerFirmwareCommands(cli, logger, environment); registerBinaryDiagnosticsCommands(cli, logger, environment); registerAnalysisViewCommands(cli, logger); diff --git a/src/cli/adbCommands.ts b/src/cli/adbCommands.ts new file mode 100644 index 000000000..677cf8f91 --- /dev/null +++ b/src/cli/adbCommands.ts @@ -0,0 +1,393 @@ +import { z } from "incur"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js"; +import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js"; +import { createAdbDeviceAnalysisProvider } from "../composition/adb.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { AdbOperation } from "../domain/adb/adbDeviceAnalysis.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; + +/** Expose the same explicit ADB requests through one-shot CLI commands. */ +export const registerAdbCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly>, + provider: AdbDeviceAnalysisPort = createAdbDeviceAnalysisProvider( + environment, + ), +): void => { + const service = new AdbDeviceAnalysisService(provider); + const execute = (name: string, operation: AdbOperation, input: unknown) => + withCommandCancellation((signal) => + logCliCommand(logger, name, async () => { + const result = await service.execute(operation, input, { signal }); + try { + await provider.close(); + } catch (cause) { + if (cause instanceof AnalysisError) + return projectAnalysisError(cause); + throw cause; + } + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ); + + const serial = z + .string() + .min(1) + .describe("Device serial exactly as `adb devices` reports it"); + + cli.command(CLI_COMMANDS.inspectAdbClient, { + description: + "Inspect the caller-selected adb client without contacting devices", + args: z.object({}), + run: () => execute(CLI_COMMANDS.inspectAdbClient, "inspect_adb_client", {}), + }); + cli.command(CLI_COMMANDS.listAdbDevices, { + description: + "List attached devices with serial, state, transport, and inferred kind", + args: z.object({}), + run: () => execute(CLI_COMMANDS.listAdbDevices, "list_adb_devices", {}), + }); + cli.command(CLI_COMMANDS.inspectAdbDevice, { + description: + "Inspect one device's build identity through a fixed getprop whitelist", + args: z.object({ serial }), + run: ({ args }) => + execute(CLI_COMMANDS.inspectAdbDevice, "inspect_adb_device", args), + }); + cli.command(CLI_COMMANDS.listAdbPackages, { + description: + "List installed packages on one device with base APK device paths", + args: z.object({ serial }), + options: z.object({ + scope: z + .enum(["all", "third_party", "system"]) + .default("all") + .describe("Restrict to third-party or system packages"), + }), + run: ({ args, options }) => + execute(CLI_COMMANDS.listAdbPackages, "list_adb_packages", { + serial: args.serial, + scope: options.scope, + }), + }); + cli.command(CLI_COMMANDS.pullAdbPackage, { + description: + "Pull one installed package's complete APK set with SHA-256 digests", + args: z.object({ + serial, + package: z.string().min(1).describe("Installed package name"), + }), + options: z.object({ + "output-directory": z + .string() + .min(1) + .describe( + "Local directory for the pulled APK set; a package-named subdirectory is created inside", + ), + }), + run: ({ args, options }) => + execute(CLI_COMMANDS.pullAdbPackage, "pull_adb_package", { + serial: args.serial, + package: args.package, + output_directory: options["output-directory"], + }), + }); + cli.command(CLI_COMMANDS.readAdbLogcat, { + description: "Read a bounded dump of the device log for one buffer", + args: z.object({ serial }), + options: z.object({ + count: z + .number() + .int() + .min(1) + .max(10_000) + .default(500) + .describe("Number of most recent lines to read"), + buffer: z + .enum(["main", "system", "radio", "events", "crash"]) + .default("main") + .describe("Log buffer to read"), + pid: z + .number() + .int() + .positive() + .optional() + .describe("Restrict to one process id"), + }), + run: ({ args, options }) => + execute(CLI_COMMANDS.readAdbLogcat, "read_adb_logcat", { + serial: args.serial, + count: options.count, + buffer: options.buffer, + ...(options.pid === undefined ? {} : { pid: options.pid }), + }), + }); + cli.command(CLI_COMMANDS.inspectAdbPackage, { + description: + "Inspect one installed package's version, installer, and flags through dumpsys", + args: z.object({ serial, package: z.string().min(1).describe("Package") }), + run: ({ args }) => + execute(CLI_COMMANDS.inspectAdbPackage, "inspect_adb_package", { + serial: args.serial, + package: args.package, + }), + }); + cli.command(CLI_COMMANDS.pullAdbFile, { + description: "Pull one device file to a local copy with a SHA-256 digest", + args: z.object({ + serial, + "device-path": z.string().min(1).describe("Absolute device path to pull"), + }), + options: z.object({ + "output-directory": z + .string() + .min(1) + .describe("Local directory for the pulled file"), + }), + run: ({ args, options }) => + execute(CLI_COMMANDS.pullAdbFile, "pull_adb_file", { + serial: args.serial, + device_path: args["device-path"], + output_directory: options["output-directory"], + }), + }); + cli.command(CLI_COMMANDS.pushAdbFile, { + description: + "Push one local file to the device; refuses existing targets without --overwrite", + args: z.object({ + serial, + "local-path": z.string().min(1).describe("Existing local file"), + "device-path": z.string().min(1).describe("Absolute device destination"), + }), + options: z.object({ + overwrite: z + .boolean() + .default(false) + .describe("Allow replacing an existing device file"), + }), + run: ({ args, options }) => + execute(CLI_COMMANDS.pushAdbFile, "push_adb_file", { + serial: args.serial, + local_path: args["local-path"], + device_path: args["device-path"], + overwrite: options.overwrite, + }), + }); + cli.command(CLI_COMMANDS.captureAdbScreen, { + description: "Capture one screen frame as a digested PNG", + args: z.object({ serial }), + options: z.object({ + "output-directory": z + .string() + .min(1) + .describe("Local directory for screen.png"), + }), + run: ({ args, options }) => + execute(CLI_COMMANDS.captureAdbScreen, "capture_adb_screen", { + serial: args.serial, + output_directory: options["output-directory"], + }), + }); + cli.command(CLI_COMMANDS.listAdbProcesses, { + description: "List running processes exactly as ps -A reports them", + args: z.object({ serial }), + run: ({ args }) => + execute(CLI_COMMANDS.listAdbProcesses, "list_adb_processes", args), + }); + cli.command(CLI_COMMANDS.listAdbDirectory, { + description: "List one device directory with kinds, permissions, and sizes", + args: z.object({ + serial, + "device-path": z + .string() + .min(1) + .describe("Absolute device directory path to list"), + }), + run: ({ args }) => + execute(CLI_COMMANDS.listAdbDirectory, "list_adb_directory", { + serial: args.serial, + device_path: args["device-path"], + }), + }); + cli.command(CLI_COMMANDS.listAdbFeatures, { + description: "List the device's declared hardware and software features", + args: z.object({ serial }), + run: ({ args }) => + execute(CLI_COMMANDS.listAdbFeatures, "list_adb_features", args), + }); + cli.command(CLI_COMMANDS.listAdbServices, { + description: "List the device's binder services and interfaces", + args: z.object({ serial }), + run: ({ args }) => + execute(CLI_COMMANDS.listAdbServices, "list_adb_services", args), + }); + cli.command(CLI_COMMANDS.inspectAdbDisplay, { + description: "Report the display's declared size and density", + args: z.object({ serial }), + run: ({ args }) => + execute(CLI_COMMANDS.inspectAdbDisplay, "inspect_adb_display", args), + }); + cli.command(CLI_COMMANDS.inspectAdbWindow, { + description: "Report the window manager's current focus declarations", + args: z.object({ serial }), + run: ({ args }) => + execute(CLI_COMMANDS.inspectAdbWindow, "inspect_adb_window", args), + }); + cli.command(CLI_COMMANDS.readAdbSetting, { + description: "Read one settings value from system, secure, or global", + args: z.object({ + serial, + namespace: z + .enum(["system", "secure", "global"]) + .describe("Settings namespace to read"), + key: z.string().min(1).describe("Setting key to read"), + }), + run: ({ args }) => + execute(CLI_COMMANDS.readAdbSetting, "read_adb_setting", { + serial: args.serial, + namespace: args.namespace, + key: args.key, + }), + }); + cli.command(CLI_COMMANDS.collectAdbBugreport, { + description: "Collect and digest one device-generated bugreport archive", + args: z.object({ serial }), + options: z.object({ + "output-directory": z + .string() + .min(1) + .describe("Local directory for the bugreport zip"), + }), + run: ({ args, options }) => + execute(CLI_COMMANDS.collectAdbBugreport, "collect_adb_bugreport", { + serial: args.serial, + output_directory: options["output-directory"], + }), + }); + cli.command(CLI_COMMANDS.resolveAdbPackages, { + description: "Find installed packages by case-insensitive name substring", + args: z.object({ + serial, + query: z.string().min(2).describe("Package-name substring to find"), + }), + run: ({ args }) => + execute(CLI_COMMANDS.resolveAdbPackages, "resolve_adb_packages", { + serial: args.serial, + query: args.query, + }), + }); + cli.command(CLI_COMMANDS.installAdbPackage, { + description: + "Install one local APK; --replace re-installs keeping the app's data", + args: z.object({ + serial, + "apk-path": z.string().min(1).describe("Local APK file to install"), + }), + options: z.object({ + replace: z + .boolean() + .default(false) + .describe("Replace an existing install and keep its data"), + }), + run: ({ args, options }) => + execute(CLI_COMMANDS.installAdbPackage, "install_adb_package", { + serial: args.serial, + apk_path: args["apk-path"], + replace: options.replace, + }), + }); + cli.command(CLI_COMMANDS.uninstallAdbPackage, { + description: "Uninstall one package and its data from the device", + args: z.object({ + serial, + package: z.string().min(1).describe("Installed package to uninstall"), + }), + run: ({ args }) => + execute(CLI_COMMANDS.uninstallAdbPackage, "uninstall_adb_package", { + serial: args.serial, + package: args.package, + }), + }); + cli.command(CLI_COMMANDS.startAdbApp, { + description: + "Start one installed app by resolving and launching its launcher activity", + args: z.object({ + serial, + package: z.string().min(1).describe("Installed package to start"), + }), + run: ({ args }) => + execute(CLI_COMMANDS.startAdbApp, "start_adb_app", { + serial: args.serial, + package: args.package, + }), + }); + cli.command(CLI_COMMANDS.startAdbActivity, { + description: + "Start an intent with am start (action, URI, component, extras)", + args: z.object({ + serial, + action: z.string().min(1).describe("Intent action to start"), + }), + options: z.object({ + "data-uri": z.string().min(1).optional().describe("Intent data URI (-d)"), + component: z + .string() + .min(1) + .optional() + .describe("Explicit component in package/activity form (-n)"), + extra: z + .array(z.string().min(1)) + .default([]) + .describe( + "Typed extra as type:key:value (string, boolean, int, long, float), repeatable", + ), + }), + run: ({ args, options }) => { + const extras = options.extra.map((entry) => { + const first = entry.indexOf(":"); + const second = entry.indexOf(":", first + 1); + if (first === -1 || second === -1) + throw new Error(`--extra expects type:key:value, received: ${entry}`); + return { + type: entry.slice(0, first) as + | "string" + | "boolean" + | "int" + | "long" + | "float", + key: entry.slice(first + 1, second), + value: entry.slice(second + 1), + }; + }); + return execute(CLI_COMMANDS.startAdbActivity, "start_adb_activity", { + serial: args.serial, + action: args.action, + ...(options["data-uri"] === undefined + ? {} + : { data_uri: options["data-uri"] }), + ...(options.component === undefined + ? {} + : { component: options.component }), + extras, + }); + }, + }); + cli.command(CLI_COMMANDS.stopAdbApp, { + description: "Force-stop every process of one package", + args: z.object({ + serial, + package: z.string().min(1).describe("Package to force-stop"), + }), + run: ({ args }) => + execute(CLI_COMMANDS.stopAdbApp, "stop_adb_app", { + serial: args.serial, + package: args.package, + }), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index dd0329eb0..99db69fb6 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -45,6 +45,30 @@ export const CLI_COMMANDS = Object.freeze({ inspectFirmwareRegions: "inspect-firmware-regions", extractFirmware: "extract-firmware", inspectAndroidPackage: "inspect-android-package", + inspectAdbClient: "inspect-adb-client", + listAdbDevices: "list-adb-devices", + inspectAdbDevice: "inspect-adb-device", + listAdbPackages: "list-adb-packages", + pullAdbPackage: "pull-adb-package", + readAdbLogcat: "read-adb-logcat", + inspectAdbPackage: "inspect-adb-package", + pullAdbFile: "pull-adb-file", + pushAdbFile: "push-adb-file", + captureAdbScreen: "capture-adb-screen", + listAdbProcesses: "list-adb-processes", + listAdbDirectory: "list-adb-directory", + listAdbFeatures: "list-adb-features", + listAdbServices: "list-adb-services", + inspectAdbDisplay: "inspect-adb-display", + inspectAdbWindow: "inspect-adb-window", + readAdbSetting: "read-adb-setting", + collectAdbBugreport: "collect-adb-bugreport", + resolveAdbPackages: "resolve-adb-packages", + installAdbPackage: "install-adb-package", + uninstallAdbPackage: "uninstall-adb-package", + startAdbApp: "start-adb-app", + startAdbActivity: "start-adb-activity", + stopAdbApp: "stop-adb-app", searchAndroidClasses: "search-android-classes", inspectAndroidClass: "inspect-android-class", inspectAndroidMethod: "inspect-android-method", diff --git a/src/composition/adb.ts b/src/composition/adb.ts new file mode 100644 index 000000000..b88d8b971 --- /dev/null +++ b/src/composition/adb.ts @@ -0,0 +1,7 @@ +import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js"; +import { AdbProvider } from "../adb/AdbProvider.js"; + +/** Construct one ADB provider bound to the caller-selected binary. */ +export const createAdbDeviceAnalysisProvider = ( + environment: Readonly> = process.env, +): AdbDeviceAnalysisPort => new AdbProvider({ environment }); diff --git a/src/contracts/adb/adbToolContracts.ts b/src/contracts/adb/adbToolContracts.ts new file mode 100644 index 000000000..30377069a --- /dev/null +++ b/src/contracts/adb/adbToolContracts.ts @@ -0,0 +1,382 @@ +import { + adbInputSchemas, + adbResultSchemas, +} from "../../domain/adb/adbDeviceAnalysis.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +import { toolContractMetadata } from "../toolEffects.js"; + +/** ADB device inspection and acquisition over a caller-supplied binary. */ +export const ADB_TOOL_CONTRACTS = [ + { + name: "inspect_adb_client", + ...toolContractMetadata("inspect_adb_client"), + description: + "Inspect the caller-selected adb client: resolved binary, version, and install path. Never contacts devices and never starts the adb server. REA never installs platform tools; select a binary with REA_ADB_PATH (absolute) or adb on PATH.", + kind: "adb-provider", + inputSchema: adbInputSchemas.inspect_adb_client, + outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_client), + examples: [{ title: "Check the adb client", input: {} }], + }, + { + name: "list_adb_devices", + ...toolContractMetadata("list_adb_devices"), + description: + "List attached devices exactly as `adb devices -l` reports them: serial, state, transport, model metadata, and an inferred emulator/physical kind with its evidence basis. Emulators and USB or network devices appear alike; REA neither starts nor manages them. May start the local adb server on first use.", + kind: "adb-provider", + inputSchema: adbInputSchemas.list_adb_devices, + outputSchema: evidenceResultOf(adbResultSchemas.list_adb_devices), + examples: [{ title: "List attached devices", input: {} }], + }, + { + name: "inspect_adb_device", + ...toolContractMetadata("inspect_adb_device"), + description: + "Inspect one device's build identity through a fixed `getprop` whitelist: Android and SDK versions, security patch, fingerprint, product identity, CPU ABIs, and debuggable flag, plus the observed emulator indicator ro.kernel.qemu. Read-only with respect to the device.", + kind: "adb-provider", + inputSchema: adbInputSchemas.inspect_adb_device, + outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_device), + examples: [ + { title: "Inspect one device", input: { serial: "emulator-5554" } }, + ], + }, + { + name: "list_adb_packages", + ...toolContractMetadata("list_adb_packages"), + description: + "List installed packages on one device with each base APK's device path, optionally scoped to third-party or system packages. Paths are device filesystem paths, not host-local. Read-only.", + kind: "adb-provider", + inputSchema: adbInputSchemas.list_adb_packages, + outputSchema: evidenceResultOf(adbResultSchemas.list_adb_packages), + examples: [ + { + title: "List third-party packages", + input: { serial: "emulator-5554", scope: "third_party" }, + }, + ], + }, + { + name: "pull_adb_package", + ...toolContractMetadata("pull_adb_package"), + description: + "Pull one installed package's complete APK set (base plus every split, as `pm path` reports) from the device into a caller-selected output directory, creating a package-named subdirectory that must not already exist. Every pulled file is digested with SHA-256; partial pulls keep successful artifacts and per-file failure reasons. The device keeps the originals.", + kind: "adb-provider", + inputSchema: adbInputSchemas.pull_adb_package, + outputSchema: evidenceResultOf(adbResultSchemas.pull_adb_package), + examples: [ + { + title: "Pull a package APK set", + input: { + serial: "emulator-5554", + package: "com.example.app", + output_directory: "/tmp/rea-pulls", + }, + }, + ], + }, + { + name: "read_adb_logcat", + ...toolContractMetadata("read_adb_logcat"), + description: + "Read a bounded dump of the device log (`logcat -d -t`) for one buffer, optionally restricted to a process id. No streaming, no clearing; the newest lines are kept when the count cap trims. Read-only.", + kind: "adb-provider", + inputSchema: adbInputSchemas.read_adb_logcat, + outputSchema: evidenceResultOf(adbResultSchemas.read_adb_logcat), + examples: [ + { + title: "Read the last 200 main-buffer lines", + input: { serial: "emulator-5554", count: 200, buffer: "main" }, + }, + ], + }, + { + name: "inspect_adb_package", + ...toolContractMetadata("inspect_adb_package"), + description: + "Inspect one installed package through `dumpsys package`: version name/code, install and update times, installer, user id, flags, and the requested-permission count. A fixed projection of version-dependent output; unknown sections are ignored rather than guessed. Read-only.", + kind: "adb-provider", + inputSchema: adbInputSchemas.inspect_adb_package, + outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_package), + examples: [ + { + title: "Inspect an installed package", + input: { serial: "emulator-5554", package: "com.example.app" }, + }, + ], + }, + { + name: "pull_adb_file", + ...toolContractMetadata("pull_adb_file"), + description: + "Pull one absolute device path (a database, shared-preferences XML, or any readable file) into a caller-selected output directory, digesting it with SHA-256. The local copy must not already exist. Read-only with respect to the device.", + kind: "adb-provider", + inputSchema: adbInputSchemas.pull_adb_file, + outputSchema: evidenceResultOf(adbResultSchemas.pull_adb_file), + examples: [ + { + title: "Pull an app database", + input: { + serial: "emulator-5554", + device_path: "/data/data/com.example.app/databases/app.db", + output_directory: "/tmp/rea-pulls", + }, + }, + ], + }, + { + name: "push_adb_file", + ...toolContractMetadata("push_adb_file"), + description: + "Push one local file to an absolute device path. The only device-mutating ADB tool: existing device files are refused unless overwrite is explicitly requested, and the transfer is verified by comparing the local digest with the device's own sha256sum when the device provides one.", + kind: "adb-provider", + inputSchema: adbInputSchemas.push_adb_file, + outputSchema: evidenceResultOf(adbResultSchemas.push_adb_file), + examples: [ + { + title: "Push a script to tmp", + input: { + serial: "emulator-5554", + local_path: "/tmp/frida-server", + device_path: "/data/local/tmp/frida-server", + }, + }, + ], + }, + { + name: "capture_adb_screen", + ...toolContractMetadata("capture_adb_screen"), + description: + "Capture one frame of the device screen as a PNG (`screencap -p` through exec-out, no device-side file) into a caller-selected output directory, digesting it and reporting its dimensions. No recording or interaction.", + kind: "adb-provider", + inputSchema: adbInputSchemas.capture_adb_screen, + outputSchema: evidenceResultOf(adbResultSchemas.capture_adb_screen), + examples: [ + { + title: "Capture the current screen", + input: { + serial: "emulator-5554", + output_directory: "/tmp/rea-captures", + }, + }, + ], + }, + { + name: "list_adb_processes", + ...toolContractMetadata("list_adb_processes"), + description: + "List running processes exactly as `ps -A` reports them: user, pid, ppid, rss, state, and name. Useful for pairing with instrumentation lanes. Read-only.", + kind: "adb-provider", + inputSchema: adbInputSchemas.list_adb_processes, + outputSchema: evidenceResultOf(adbResultSchemas.list_adb_processes), + examples: [{ title: "List processes", input: { serial: "emulator-5554" } }], + }, + { + name: "list_adb_directory", + ...toolContractMetadata("list_adb_directory"), + description: + "List one absolute device directory (`ls -la`): names, kinds, permissions, owner, group, sizes, dates, and symlink targets. Recognizes the standard errors for absent paths and denied reads. Read-only.", + kind: "adb-provider", + inputSchema: adbInputSchemas.list_adb_directory, + outputSchema: evidenceResultOf(adbResultSchemas.list_adb_directory), + examples: [ + { + title: "List an app's data directory", + input: { + serial: "emulator-5554", + device_path: "/data/data/com.example.app", + }, + }, + ], + }, + { + name: "list_adb_features", + ...toolContractMetadata("list_adb_features"), + description: + "List the device's declared hardware and software features (`pm list features`) for device fingerprinting. Read-only.", + kind: "adb-provider", + inputSchema: adbInputSchemas.list_adb_features, + outputSchema: evidenceResultOf(adbResultSchemas.list_adb_features), + examples: [{ title: "List features", input: { serial: "emulator-5554" } }], + }, + { + name: "list_adb_services", + ...toolContractMetadata("list_adb_services"), + description: + "List the device's binder services with their interfaces (`service list`). Read-only.", + kind: "adb-provider", + inputSchema: adbInputSchemas.list_adb_services, + outputSchema: evidenceResultOf(adbResultSchemas.list_adb_services), + examples: [{ title: "List services", input: { serial: "emulator-5554" } }], + }, + { + name: "inspect_adb_display", + ...toolContractMetadata("inspect_adb_display"), + description: + "Report the display's physical and any overridden size (`wm size`) and density (`wm density`) as the device declares them. Read-only; setting values is not exposed.", + kind: "adb-provider", + inputSchema: adbInputSchemas.inspect_adb_display, + outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_display), + examples: [ + { title: "Inspect the display", input: { serial: "emulator-5554" } }, + ], + }, + { + name: "inspect_adb_window", + ...toolContractMetadata("inspect_adb_window"), + description: + "Report the window manager's current focus declarations (`dumpsys window windows`: mCurrentFocus and mFocusedApp) — what is on screen at read time without any interaction. Read-only.", + kind: "adb-provider", + inputSchema: adbInputSchemas.inspect_adb_window, + outputSchema: evidenceResultOf(adbResultSchemas.inspect_adb_window), + examples: [ + { title: "Inspect window focus", input: { serial: "emulator-5554" } }, + ], + }, + { + name: "read_adb_setting", + ...toolContractMetadata("read_adb_setting"), + description: + "Read one settings value (`settings get`) from the system, secure, or global namespace. Absent keys report the device's own null observation. Read-only; writing settings is not exposed.", + kind: "adb-provider", + inputSchema: adbInputSchemas.read_adb_setting, + outputSchema: evidenceResultOf(adbResultSchemas.read_adb_setting), + examples: [ + { + title: "Read the ADB-enabled setting", + input: { + serial: "emulator-5554", + namespace: "secure", + key: "adb_enabled", + }, + }, + ], + }, + { + name: "collect_adb_bugreport", + ...toolContractMetadata("collect_adb_bugreport"), + description: + "Collect one device-generated bugreport archive (`adb bugreport`) into a caller-selected output directory, digesting the zip. The archive contains the device's own dumpstate, dumpsys, and logcat report; collection can take minutes. Read-only with respect to configuration.", + kind: "adb-provider", + inputSchema: adbInputSchemas.collect_adb_bugreport, + outputSchema: evidenceResultOf(adbResultSchemas.collect_adb_bugreport), + examples: [ + { + title: "Collect a bugreport", + input: { + serial: "emulator-5554", + output_directory: "/tmp/rea-bugreports", + }, + }, + ], + }, + { + name: "resolve_adb_packages", + ...toolContractMetadata("resolve_adb_packages"), + description: + "Find installed packages by case-insensitive substring of the package name (e.g. `whatsapp` → `com.whatsapp`), returning each match's base APK device path. The first step of an easy get: resolve, then pull_adb_package the exact name. Read-only.", + kind: "adb-provider", + inputSchema: adbInputSchemas.resolve_adb_packages, + outputSchema: evidenceResultOf(adbResultSchemas.resolve_adb_packages), + examples: [ + { + title: "Find a package by partial name", + input: { serial: "emulator-5554", query: "whatsapp" }, + }, + ], + }, + { + name: "install_adb_package", + ...toolContractMetadata("install_adb_package"), + description: + "Install one local APK on the device (`adb install [-r]`), recording the APK's host digest and byte size. A device-mutating tool: `replace` re-installs over an existing package while keeping its data; the device's own Success/Failure verdict is preserved with its reason.", + kind: "adb-provider", + inputSchema: adbInputSchemas.install_adb_package, + outputSchema: evidenceResultOf(adbResultSchemas.install_adb_package), + examples: [ + { + title: "Install an APK", + input: { serial: "emulator-5554", apk_path: "/tmp/app.apk" }, + }, + { + title: "Replace an existing install keeping data", + input: { + serial: "emulator-5554", + apk_path: "/tmp/app.apk", + replace: true, + }, + }, + ], + }, + { + name: "uninstall_adb_package", + ...toolContractMetadata("uninstall_adb_package"), + description: + "Uninstall one package from the device (`adb uninstall`), removing its data for user 0. A device-mutating tool; absent packages report the device's own refusal reason.", + kind: "adb-provider", + inputSchema: adbInputSchemas.uninstall_adb_package, + outputSchema: evidenceResultOf(adbResultSchemas.uninstall_adb_package), + examples: [ + { + title: "Uninstall a package", + input: { serial: "emulator-5554", package: "com.example.app" }, + }, + ], + }, + { + name: "start_adb_app", + ...toolContractMetadata("start_adb_app"), + description: + "Start one installed app by launching its declared launcher activity: the component is resolved through `cmd package resolve-activity --brief`, then started with `am start -n`. A device-mutating tool for dynamic-analysis workflows; pair with `read_adb_logcat` (pid filter) and `list_adb_processes` to observe the started app, and `stop_adb_app` to end the run.", + kind: "adb-provider", + inputSchema: adbInputSchemas.start_adb_app, + outputSchema: evidenceResultOf(adbResultSchemas.start_adb_app), + examples: [ + { + title: "Start an app", + input: { serial: "emulator-5554", package: "com.example.app" }, + }, + ], + }, + { + name: "start_adb_activity", + ...toolContractMetadata("start_adb_activity"), + description: + "Start an intent with `am start`: one action, optional data URI, optional explicit component, and up to 16 typed extras (string, boolean, int, long, float). A device-mutating tool; the activity manager's Starting/Error verdict is preserved verbatim.", + kind: "adb-provider", + inputSchema: adbInputSchemas.start_adb_activity, + outputSchema: evidenceResultOf(adbResultSchemas.start_adb_activity), + examples: [ + { + title: "Open a deep link", + input: { + serial: "emulator-5554", + action: "android.intent.action.VIEW", + data_uri: "https://example.com/path", + }, + }, + { + title: "Start with typed extras", + input: { + serial: "emulator-5554", + action: "com.example.APP_ACTION", + extras: [{ key: "level", type: "int", value: "3" }], + }, + }, + ], + }, + { + name: "stop_adb_app", + ...toolContractMetadata("stop_adb_app"), + description: + "Force-stop every process of one package (`am force-stop`), ending a dynamic-analysis run. A device-mutating tool; it succeeds whether or not the package had running processes.", + kind: "adb-provider", + inputSchema: adbInputSchemas.stop_adb_app, + outputSchema: evidenceResultOf(adbResultSchemas.stop_adb_app), + examples: [ + { + title: "Stop an app", + input: { serial: "emulator-5554", package: "com.example.app" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContractTypes.ts b/src/contracts/toolContractTypes.ts index c4234d25a..0e1cb43cc 100644 --- a/src/contracts/toolContractTypes.ts +++ b/src/contracts/toolContractTypes.ts @@ -25,6 +25,7 @@ export const TOOL_KINDS = [ "artifact-provider", "managed-provider", "android-provider", + "adb-provider", "firmware-provider", "browser-provider", "electron-provider", diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 212a748bd..7446a55fb 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -6,6 +6,7 @@ import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; 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 { ADB_TOOL_CONTRACTS } from "./adb/adbToolContracts.js"; import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managed/managedWorkflowToolContracts.js"; import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "./browserProviderToolContracts.js"; import { ELECTRON_TOOL_CONTRACTS } from "./javascript/electronToolContracts.js"; @@ -30,6 +31,7 @@ export const TOOL_CONTRACTS = [ ...EVM_TOOL_CONTRACTS, ...MANAGED_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, + ...ADB_TOOL_CONTRACTS, ...FIRMWARE_TOOL_CONTRACTS, ...MANAGED_WORKFLOW_TOOL_CONTRACTS, ...BROWSER_PROVIDER_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 35a51010b..2c4cc0240 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -97,6 +97,146 @@ export const TOOL_EFFECTS: Readonly> = { launchesProcess: true, writesFilesystem: true, }), + inspect_adb_client: effects({ + mutatesSession: true, + launchesProcess: true, + }), + list_adb_devices: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + inspect_adb_device: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + list_adb_packages: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + pull_adb_package: effects({ + mutatesSession: true, + launchesProcess: true, + writesFilesystem: true, + accessesNetwork: true, + idempotent: false, + }), + read_adb_logcat: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + inspect_adb_package: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + pull_adb_file: effects({ + mutatesSession: true, + launchesProcess: true, + writesFilesystem: true, + accessesNetwork: true, + idempotent: false, + }), + push_adb_file: effects({ + mutatesTarget: true, + mutatesSession: true, + launchesProcess: true, + writesFilesystem: true, + accessesNetwork: true, + idempotent: false, + }), + capture_adb_screen: effects({ + mutatesSession: true, + launchesProcess: true, + writesFilesystem: true, + accessesNetwork: true, + idempotent: false, + }), + list_adb_processes: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + list_adb_directory: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + list_adb_features: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + list_adb_services: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + inspect_adb_display: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + inspect_adb_window: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + read_adb_setting: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + collect_adb_bugreport: effects({ + mutatesSession: true, + launchesProcess: true, + writesFilesystem: true, + accessesNetwork: true, + idempotent: false, + }), + resolve_adb_packages: effects({ + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + }), + install_adb_package: effects({ + mutatesTarget: true, + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + idempotent: false, + }), + uninstall_adb_package: effects({ + mutatesTarget: true, + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + idempotent: false, + }), + start_adb_app: effects({ + mutatesTarget: true, + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + idempotent: false, + }), + start_adb_activity: effects({ + mutatesTarget: true, + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + idempotent: false, + }), + stop_adb_app: effects({ + mutatesTarget: true, + mutatesSession: true, + launchesProcess: true, + accessesNetwork: true, + idempotent: false, + }), search_android_classes: effects({ mutatesSession: true, launchesProcess: true, diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts new file mode 100644 index 000000000..31684e800 --- /dev/null +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -0,0 +1,706 @@ +import { z } from "zod"; +import { localPathStringSchema } from "../localPath.js"; + +/** + * Device serials as adb prints them: emulator-5554, USB identifiers, and + * host:port transports. The pattern keeps arguments free of whitespace and + * quotes; argv is positional in the provider, so this is admission hygiene + * rather than injection defense. + */ +const deviceSerialSchema = z + .string() + .min(1) + .max(256) + .regex( + /^[^\x00\s"']+$/u, + "Device serial cannot contain whitespace or quotes", + ); + +/** + * Android package names as Package Manager reports them. System entries can + * be a single segment (`android`), so at least one identifier is required. + */ +const packageNameSchema = z + .string() + .min(1) + .max(256) + .regex( + /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$/u, + "Package name must be dot-separated Java identifiers", + ); + +/** + * Caller intent for ADB-backed device inspection and acquisition. + * + * REA never installs platform tools, starts an emulator, or manages devices; + * every operation addresses devices the caller already connected, through the + * adb binary the caller selected. + */ +export const adbInputSchemas = { + inspect_adb_client: z.strictObject({}), + list_adb_devices: z.strictObject({}), + inspect_adb_device: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + }), + list_adb_packages: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + scope: z + .enum(["all", "third_party", "system"]) + .default("all") + .describe( + "Package scope: all packages, third-party only (-3), or system only (-s)", + ), + }), + pull_adb_package: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + package: packageNameSchema.describe( + "Installed package name whose APK set is pulled", + ), + output_directory: localPathStringSchema.describe( + "Local directory for the pulled APK set; REA creates a package-named subdirectory inside it", + ), + }), + read_adb_logcat: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + count: z + .number() + .int() + .min(1) + .max(10_000) + .default(500) + .describe("Number of most recent lines to read (logcat -d -t)"), + buffer: z + .enum(["main", "system", "radio", "events", "crash"]) + .default("main") + .describe("Log buffer to read (logcat -b)"), + pid: z + .number() + .int() + .positive() + .optional() + .describe("Restrict to one process id (logcat --pid)"), + }), + inspect_adb_package: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + package: packageNameSchema.describe("Installed package to inspect"), + }), + pull_adb_file: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + device_path: z + .string() + .min(1) + .regex(/^\/[^\x00]*$/u, "Device path must be absolute") + .describe("Absolute device filesystem path to pull"), + output_directory: localPathStringSchema.describe( + "Local directory for the pulled file; must not already contain the file", + ), + }), + push_adb_file: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + local_path: localPathStringSchema.describe( + "Existing local regular file to push", + ), + device_path: z + .string() + .min(1) + .regex(/^\/[^\x00]*$/u, "Device path must be absolute") + .describe("Absolute destination path on the device"), + overwrite: z + .boolean() + .default(false) + .describe("Allow replacing an existing device file; refused by default"), + }), + capture_adb_screen: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + output_directory: localPathStringSchema.describe( + "Local directory for the captured PNG; must not already contain it", + ), + }), + list_adb_processes: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + }), + list_adb_directory: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + device_path: z + .string() + .min(1) + .regex(/^\/[^\x00]*$/u, "Device path must be absolute") + .describe("Absolute device directory path to list (ls -la)"), + }), + list_adb_features: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + }), + list_adb_services: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + }), + inspect_adb_display: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + }), + inspect_adb_window: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + }), + read_adb_setting: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + namespace: z + .enum(["system", "secure", "global"]) + .describe("Settings namespace to read"), + key: z + .string() + .min(1) + .max(256) + .regex( + /^[A-Za-z0-9._-]+$/u, + "Setting keys are restricted to identifiers", + ) + .describe("Setting key to read"), + }), + collect_adb_bugreport: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + output_directory: localPathStringSchema.describe( + "Local directory for the bugreport archive; REA writes exactly one new zip into it", + ), + }), + resolve_adb_packages: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + query: z + .string() + .min(2) + .max(256) + .describe( + "Case-insensitive substring matched against installed package names (e.g. whatsapp)", + ), + }), + install_adb_package: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + apk_path: localPathStringSchema.describe( + "Existing local APK file to install", + ), + replace: z + .boolean() + .default(false) + .describe( + "Replace an existing installation and keep its data (adb install -r)", + ), + }), + uninstall_adb_package: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + package: packageNameSchema.describe("Installed package to uninstall"), + }), + start_adb_app: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + package: packageNameSchema.describe( + "Installed package whose launcher activity is started", + ), + }), + start_adb_activity: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + action: z + .string() + .min(1) + .max(256) + .regex( + /^[A-Za-z][A-Za-z0-9._-]*$/u, + "Intent action must be a dotted identifier such as android.intent.action.VIEW", + ) + .describe("Intent action to start"), + data_uri: z + .string() + .min(1) + .max(2048) + .regex(/^[^\x00\s]+$/u, "Data URI cannot contain whitespace or NUL") + .optional() + .describe("Intent data URI (-d)"), + component: z + .string() + .min(1) + .max(512) + .regex( + /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]*)?$/u, + "Component must be package/activity form", + ) + .optional() + .describe("Explicit component to start (-n)"), + extras: z + .array( + z.strictObject({ + key: z + .string() + .min(1) + .max(256) + .regex(/^[A-Za-z0-9._-]+$/u, "Extra keys are identifiers") + .describe("Extra key"), + type: z + .enum(["string", "boolean", "int", "long", "float"]) + .describe("Extra value type"), + value: z + .string() + .min(1) + .max(2048) + .regex(/^[^\x00]*$/u, "Extra values cannot contain NUL") + .describe("Extra value as text, parsed per type"), + }), + ) + .max(16) + .default([]) + .describe("Typed intent extras (--es/--ez/--ei/--el/--ef)"), + }), + stop_adb_app: z.strictObject({ + serial: deviceSerialSchema.describe( + "Device serial exactly as `adb devices` reports it", + ), + package: packageNameSchema.describe( + "Package whose processes are force-stopped", + ), + }), +} as const; + +/** Supported ADB-backed operations. */ +export type AdbOperation = keyof typeof adbInputSchemas; +/** Validated ADB requests carry their selected operation. */ +export type AdbRequest = { + [Name in AdbOperation]: { + operation: Name; + input: z.infer<(typeof adbInputSchemas)[Name]>; + }; +}[AdbOperation]; + +/** Validate operation and input together so their types remain correlated. */ +export const adbRequestSchema = z.discriminatedUnion("operation", [ + z.strictObject({ + operation: z.literal("inspect_adb_client"), + input: adbInputSchemas.inspect_adb_client, + }), + z.strictObject({ + operation: z.literal("list_adb_devices"), + input: adbInputSchemas.list_adb_devices, + }), + z.strictObject({ + operation: z.literal("inspect_adb_device"), + input: adbInputSchemas.inspect_adb_device, + }), + z.strictObject({ + operation: z.literal("list_adb_packages"), + input: adbInputSchemas.list_adb_packages, + }), + z.strictObject({ + operation: z.literal("pull_adb_package"), + input: adbInputSchemas.pull_adb_package, + }), + z.strictObject({ + operation: z.literal("read_adb_logcat"), + input: adbInputSchemas.read_adb_logcat, + }), + z.strictObject({ + operation: z.literal("inspect_adb_package"), + input: adbInputSchemas.inspect_adb_package, + }), + z.strictObject({ + operation: z.literal("pull_adb_file"), + input: adbInputSchemas.pull_adb_file, + }), + z.strictObject({ + operation: z.literal("push_adb_file"), + input: adbInputSchemas.push_adb_file, + }), + z.strictObject({ + operation: z.literal("capture_adb_screen"), + input: adbInputSchemas.capture_adb_screen, + }), + z.strictObject({ + operation: z.literal("list_adb_processes"), + input: adbInputSchemas.list_adb_processes, + }), + z.strictObject({ + operation: z.literal("list_adb_directory"), + input: adbInputSchemas.list_adb_directory, + }), + z.strictObject({ + operation: z.literal("list_adb_features"), + input: adbInputSchemas.list_adb_features, + }), + z.strictObject({ + operation: z.literal("list_adb_services"), + input: adbInputSchemas.list_adb_services, + }), + z.strictObject({ + operation: z.literal("inspect_adb_display"), + input: adbInputSchemas.inspect_adb_display, + }), + z.strictObject({ + operation: z.literal("inspect_adb_window"), + input: adbInputSchemas.inspect_adb_window, + }), + z.strictObject({ + operation: z.literal("read_adb_setting"), + input: adbInputSchemas.read_adb_setting, + }), + z.strictObject({ + operation: z.literal("collect_adb_bugreport"), + input: adbInputSchemas.collect_adb_bugreport, + }), + z.strictObject({ + operation: z.literal("resolve_adb_packages"), + input: adbInputSchemas.resolve_adb_packages, + }), + z.strictObject({ + operation: z.literal("install_adb_package"), + input: adbInputSchemas.install_adb_package, + }), + z.strictObject({ + operation: z.literal("uninstall_adb_package"), + input: adbInputSchemas.uninstall_adb_package, + }), + z.strictObject({ + operation: z.literal("start_adb_app"), + input: adbInputSchemas.start_adb_app, + }), + z.strictObject({ + operation: z.literal("start_adb_activity"), + input: adbInputSchemas.start_adb_activity, + }), + z.strictObject({ + operation: z.literal("stop_adb_app"), + input: adbInputSchemas.stop_adb_app, + }), +]); + +const client = z.strictObject({ + binary_path: z.string().min(1), + path_source: z.enum(["environment", "path"]), + version: z.string().min(1).nullable(), + installed_path: z.string().min(1).nullable(), +}); + +const device = z.strictObject({ + serial: z.string().min(1), + state: z.string().min(1), + transport: z.enum(["usb", "tcp", "unknown"]), + product: z.string().nullable(), + model: z.string().nullable(), + device: z.string().nullable(), + transport_id: z.number().int().positive().nullable(), + /** + * Inferred device kind with its evidence basis. The serial prefix and the + * `device:` metadata line are observations; the kind itself is derived. + */ + kind: z.enum(["emulator", "physical", "unknown"]), + kind_basis: z.enum(["emulator_serial_prefix", "usb_transport", "none"]), +}); + +const pulledArtifact = z.strictObject({ + device_path: z.string().min(1), + file_name: z.string().min(1), + local_path: z.string().min(1), + bytes: z.number().int().nonnegative(), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + role: z.enum(["base", "split", "unknown"]), + role_basis: z.literal("file_name"), +}); + +const pullFailure = z.strictObject({ + device_path: z.string().min(1).nullable(), + stage: z.enum(["resolve_paths", "pull", "digest"]), + message: z.string().min(1), +}); + +/** Portable ADB observations with explicit identity and coverage limitations. */ +export const adbResultSchemas = { + inspect_adb_client: z.strictObject({ + client, + /** + * `adb version` never contacts the adb server; this tool starts nothing. + * Recorded explicitly so the session keeps the distinction observable. + */ + contacted_adb_server: z.literal(false), + }), + list_adb_devices: z.strictObject({ + client, + devices: z.array(device), + /** + * Starting the local adb server is an adb client behavior on first use; + * REA observes whichever server state the caller's environment has. + */ + may_have_started_adb_server: z.boolean(), + }), + inspect_adb_device: z.strictObject({ + client, + serial: z.string().min(1), + properties: z.array( + z.strictObject({ name: z.string().min(1), value: z.string().min(1) }), + ), + property_count: z.number().int().nonnegative(), + emulator_observed: z.boolean().nullable(), + }), + list_adb_packages: z.strictObject({ + client, + serial: z.string().min(1), + scope: z.enum(["all", "third_party", "system"]), + packages: z.array( + z.strictObject({ + package_name: packageNameSchema, + /** Device filesystem path; not a host-local path. */ + base_apk_device_path: z.string().min(1), + }), + ), + unparsed_lines: z.array(z.string().min(1)), + coverage: z.enum(["complete", "partial"]), + }), + pull_adb_package: z.strictObject({ + client, + serial: z.string().min(1), + package_name: packageNameSchema, + output_directory: z.string().min(1), + artifacts: z.array(pulledArtifact), + failures: z.array(pullFailure), + coverage: z.enum(["complete", "partial"]), + }), + read_adb_logcat: z.strictObject({ + client, + serial: z.string().min(1), + count: z.number().int().positive(), + buffer: z.enum(["main", "system", "radio", "events", "crash"]), + pid: z.number().int().positive().nullable(), + lines: z.array(z.string()), + total_bytes: z.number().int().nonnegative(), + coverage: z.enum(["complete", "partial"]), + }), + inspect_adb_package: z.strictObject({ + client, + serial: z.string().min(1), + package_name: packageNameSchema, + version_name: z.string().nullable(), + version_code: z.number().int().nullable(), + first_install_time: z.string().nullable(), + last_update_time: z.string().nullable(), + installer_package_name: z.string().nullable(), + user_id: z.number().int().nullable(), + pkg_flags: z.string().nullable(), + requested_permissions_count: z.number().int().nonnegative().nullable(), + coverage: z.enum(["complete", "partial"]), + }), + pull_adb_file: z.strictObject({ + client, + serial: z.string().min(1), + device_path: z.string().min(1), + local_path: z.string().min(1), + bytes: z.number().int().nonnegative(), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + }), + push_adb_file: z.strictObject({ + client, + serial: z.string().min(1), + local_path: z.string().min(1), + device_path: z.string().min(1), + bytes: z.number().int().nonnegative(), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + device_sha256: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .nullable(), + overwritten: z.boolean(), + device_digest_source: z.enum(["device_sha256sum", "unavailable"]), + }), + capture_adb_screen: z.strictObject({ + client, + serial: z.string().min(1), + local_path: z.string().min(1), + bytes: z.number().int().nonnegative(), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + width: z.number().int().positive().nullable(), + height: z.number().int().positive().nullable(), + }), + list_adb_processes: z.strictObject({ + client, + serial: z.string().min(1), + columns: z.string().nullable(), + processes: z.array( + z.strictObject({ + user: z.string(), + pid: z.number().int().nonnegative(), + ppid: z.number().int().nullable(), + rss: z.number().int().nullable(), + state: z.string().nullable(), + name: z.string(), + }), + ), + unparsed_lines: z.array(z.string().min(1)), + coverage: z.enum(["complete", "partial"]), + }), + list_adb_directory: z.strictObject({ + client, + serial: z.string().min(1), + device_path: z.string().min(1), + entries: z.array( + z.strictObject({ + name: z.string().min(1), + kind: z.enum(["directory", "file", "symlink", "other"]), + permissions: z.string().nullable(), + owner: z.string().nullable(), + group: z.string().nullable(), + bytes: z.number().int().nullable(), + date: z.string().nullable(), + link_target: z.string().nullable(), + }), + ), + unparsed_lines: z.array(z.string().min(1)), + coverage: z.enum(["complete", "partial"]), + }), + list_adb_features: z.strictObject({ + client, + serial: z.string().min(1), + features: z.array( + z.strictObject({ + name: z.string().min(1), + version: z.number().int().nullable(), + }), + ), + unparsed_lines: z.array(z.string().min(1)), + coverage: z.enum(["complete", "partial"]), + }), + list_adb_services: z.strictObject({ + client, + serial: z.string().min(1), + services: z.array( + z.strictObject({ + name: z.string().min(1), + interface: z.string().nullable(), + }), + ), + unparsed_lines: z.array(z.string().min(1)), + coverage: z.enum(["complete", "partial"]), + }), + inspect_adb_display: z.strictObject({ + client, + serial: z.string().min(1), + physical_size: z.strictObject({ + width: z.number().int().positive(), + height: z.number().int().positive(), + }), + override_size: z + .strictObject({ + width: z.number().int().positive(), + height: z.number().int().positive(), + }) + .nullable(), + physical_density: z.number().int().positive().nullable(), + override_density: z.number().int().positive().nullable(), + }), + inspect_adb_window: z.strictObject({ + client, + serial: z.string().min(1), + focused_window: z.string().nullable(), + focused_app: z.string().nullable(), + observed_lines: z.array(z.string().min(1)), + }), + read_adb_setting: z.strictObject({ + client, + serial: z.string().min(1), + namespace: z.enum(["system", "secure", "global"]), + key: z.string().min(1), + value: z.string().nullable(), + device_reported_null: z.boolean(), + }), + collect_adb_bugreport: z.strictObject({ + client, + serial: z.string().min(1), + local_path: z.string().min(1), + bytes: z.number().int().nonnegative(), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + adb_reported_path: z.string().nullable(), + }), + resolve_adb_packages: z.strictObject({ + client, + serial: z.string().min(1), + query: z.string().min(1), + matches: z.array( + z.strictObject({ + package_name: packageNameSchema, + base_apk_device_path: z.string().min(1), + }), + ), + exact_match: z.boolean(), + coverage: z.enum(["complete", "partial"]), + }), + install_adb_package: z.strictObject({ + client, + serial: z.string().min(1), + apk_path: z.string().min(1), + bytes: z.number().int().nonnegative(), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + replaced: z.boolean(), + }), + uninstall_adb_package: z.strictObject({ + client, + serial: z.string().min(1), + package_name: packageNameSchema, + }), + start_adb_app: z.strictObject({ + client, + serial: z.string().min(1), + package_name: packageNameSchema, + component: z.string().min(1), + started_activity: z.string().nullable(), + }), + start_adb_activity: z.strictObject({ + client, + serial: z.string().min(1), + action: z.string().min(1), + data_uri: z.string().nullable(), + component: z.string().nullable(), + extras: z.array( + z.strictObject({ + key: z.string().min(1), + type: z.enum(["string", "boolean", "int", "long", "float"]), + value: z.string(), + }), + ), + started_activity: z.string().nullable(), + }), + stop_adb_app: z.strictObject({ + client, + serial: z.string().min(1), + package_name: packageNameSchema, + }), +} as const; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 1896355c6..1d49156c1 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -62,9 +62,13 @@ import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysi import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnalysisPort.js"; import { createFirmwareAnalysisProvider } from "../composition/firmware.js"; import { registerAndroidTools } from "./registerAndroidTools.js"; +import { registerAdbTools } from "./registerAdbTools.js"; import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js"; import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysisPort.js"; +import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js"; +import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js"; import { createAndroidAnalysisProvider } from "../composition/android.js"; +import { createAdbDeviceAnalysisProvider } from "../composition/adb.js"; import { registerManagedWorkflowTools } from "./registerManagedWorkflowTools.js"; import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js"; import { registerEvidenceTools } from "./registerEvidenceTools.js"; @@ -99,6 +103,7 @@ export interface CreateServerOptions { readonly webRuntime?: WebRuntimeService; readonly webNetworkCapture?: WebNetworkCaptureService; readonly androidAnalysis?: AndroidAnalysisPort; + readonly adbDeviceAnalysis?: AdbDeviceAnalysisPort; readonly browserObservation?: BrowserObservationPort; readonly browserScenarioCapture?: BrowserScenarioCapturePort; readonly electronObservation?: ElectronObservationPort; @@ -197,6 +202,8 @@ export const createServer = ( ); const android = options.androidAnalysis ?? createAndroidAnalysisProvider(environment); + const adbDevice = + options.adbDeviceAnalysis ?? createAdbDeviceAnalysisProvider(environment); const availability = installSessionToolAvailability( session, selectedOptions, @@ -239,6 +246,12 @@ export const createServer = ( if (result.status === "rejected") throw result.reason; }; registerConfiguredAnalysisTools(toolContext, android); + registerAdbTools( + server, + new AdbDeviceAnalysisService(adbDevice), + toolLogger, + recordEvidence, + ); registerObservationTools(toolContext); registerGuidedPrompts(server, analysis, session); if (session !== undefined) { diff --git a/src/server/registerAdbTools.ts b/src/server/registerAdbTools.ts new file mode 100644 index 000000000..4e5dc5e95 --- /dev/null +++ b/src/server/registerAdbTools.ts @@ -0,0 +1,66 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import type { ServerContext } from "@modelcontextprotocol/server"; +import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { ToolContract } from "../contracts/toolContractTypes.js"; +import type { AdbOperation } from "../domain/adb/adbDeviceAnalysis.js"; +import type { Logger } from "pino"; +import { logToolExecution } from "./toolLogging.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Bind ADB handlers to their exact named contracts through shared workflows. */ +export const registerAdbTools = ( + server: EvidenceMcpServer, + service: AdbDeviceAnalysisService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], +): void => { + const handler = (contract: ToolContract) => { + const name = contract.name; + return async (input: unknown, context: ServerContext) => { + const result = await logToolExecution(logger, name, () => + service.execute(name, input, { signal: context.mcpReq.signal }), + ); + if (!result.ok) return server.delivery.toCallToolResult(result, contract); + const recorded = recordEvidence?.(result.value); + return server.delivery.toEvidenceToolResult( + result.value, + contract, + recorded, + ); + }; + }; + for (const contract of [ + toolContract("inspect_adb_client"), + toolContract("list_adb_devices"), + toolContract("inspect_adb_device"), + toolContract("list_adb_packages"), + toolContract("pull_adb_package"), + toolContract("read_adb_logcat"), + toolContract("inspect_adb_package"), + toolContract("pull_adb_file"), + toolContract("push_adb_file"), + toolContract("capture_adb_screen"), + toolContract("list_adb_processes"), + toolContract("list_adb_directory"), + toolContract("list_adb_features"), + toolContract("list_adb_services"), + toolContract("inspect_adb_display"), + toolContract("inspect_adb_window"), + toolContract("read_adb_setting"), + toolContract("collect_adb_bugreport"), + toolContract("resolve_adb_packages"), + toolContract("install_adb_package"), + toolContract("uninstall_adb_package"), + toolContract("start_adb_app"), + toolContract("start_adb_activity"), + toolContract("stop_adb_app"), + ] as const) { + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); + } +}; diff --git a/tests/boundary/cli/adbCli.test.ts b/tests/boundary/cli/adbCli.test.ts new file mode 100644 index 000000000..0f5824237 --- /dev/null +++ b/tests/boundary/cli/adbCli.test.ts @@ -0,0 +1,77 @@ +import { expect, it } from "vitest"; + +import { createCli } from "../../../src/cli.js"; + +const serve = async ( + argv: readonly string[], + environment: Readonly> = {}, +): Promise<{ readonly stdout: string; readonly exitCode: number }> => { + const cli = createCli(environment); + let stdout = ""; + let exitCode = 0; + await cli.serve([...argv], { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (text) => { + stdout += text; + }, + }); + return { stdout, exitCode }; +}; + +it.each([ + ["inspect-adb-client"], + ["list-adb-devices"], + ["inspect-adb-device"], + ["list-adb-packages"], + ["pull-adb-package"], + ["read-adb-logcat"], + ["inspect-adb-package"], + ["pull-adb-file"], + ["push-adb-file"], + ["capture-adb-screen"], + ["list-adb-processes"], + ["list-adb-directory"], + ["list-adb-features"], + ["list-adb-services"], + ["inspect-adb-display"], + ["inspect-adb-window"], + ["read-adb-setting"], + ["collect-adb-bugreport"], + ["resolve-adb-packages"], + ["install-adb-package"], + ["uninstall-adb-package"], + ["start-adb-app"], + ["start-adb-activity"], + ["stop-adb-app"], +])("%s advertises a schema through the public CLI", async (command) => { + const result = await serve([command, "--schema", "--json"]); + expect(result.exitCode, command).toBe(0); + const schema = JSON.parse(result.stdout) as { args?: unknown }; + expect(schema).toBeTruthy(); +}); + +it("reports a missing adb binary with recovery guidance", async () => { + const result = await serve(["inspect-adb-client", "--json"], { + REA_ADB_PATH: "/nonexistent/adb", + }); + expect(result.stdout).toContain("capability_unavailable"); + expect(result.stdout).toContain("REA_ADB_PATH"); +}); + +it("validates device serials before contacting any device", async () => { + const result = await serve(["inspect-adb-device", "bad serial", "--json"]); + expect(result.stdout).toContain("invalid_input"); +}); + +it("requires the output directory option on pull", async () => { + const result = await serve([ + "pull-adb-package", + "emulator-5554", + "com.example", + "--json", + ]); + expect(result.exitCode).not.toBe(0); +}); diff --git a/tests/boundary/mcp/adbMcp.test.ts b/tests/boundary/mcp/adbMcp.test.ts new file mode 100644 index 000000000..5b87da373 --- /dev/null +++ b/tests/boundary/mcp/adbMcp.test.ts @@ -0,0 +1,397 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it, onTestFinished } from "vitest"; + +import type { AdbDeviceAnalysisPort } from "../../../src/application/adb/AdbDeviceAnalysisPort.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { toolContract } from "../../../src/contracts/toolContracts.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: "adb", name: "Android Debug Bridge", version: null }; + +const clientIdentity = { + binary_path: "/usr/bin/adb", + path_source: "path", + version: "34.0.5-debian", + installed_path: "/usr/bin/adb", +}; + +const results: Readonly> = { + inspect_adb_client: { + client: clientIdentity, + contacted_adb_server: false, + }, + list_adb_devices: { + client: clientIdentity, + devices: [ + { + serial: "emulator-5554", + state: "device", + transport: "tcp", + product: "sdk_gphone64_x86_64", + model: "sdk_gphone64_x86_64", + device: "emu64xa", + transport_id: 1, + kind: "emulator", + kind_basis: "emulator_serial_prefix", + }, + ], + may_have_started_adb_server: false, + }, + inspect_adb_device: { + client: clientIdentity, + serial: "emulator-5554", + properties: [{ name: "ro.build.version.sdk", value: "34" }], + property_count: 1, + emulator_observed: true, + }, + list_adb_packages: { + client: clientIdentity, + serial: "emulator-5554", + scope: "third_party", + packages: [ + { + package_name: "com.example", + base_apk_device_path: "/data/app/x/base.apk", + }, + ], + unparsed_lines: [], + coverage: "complete", + }, + pull_adb_package: { + client: clientIdentity, + serial: "emulator-5554", + package_name: "com.example", + output_directory: "/tmp/rea-pulls", + artifacts: [ + { + device_path: "/data/app/x/base.apk", + file_name: "base.apk", + local_path: "/tmp/rea-pulls/com.example/base.apk", + bytes: 2048, + sha256: "a".repeat(64), + role: "base", + role_basis: "file_name", + }, + ], + failures: [], + coverage: "complete", + }, + read_adb_logcat: { + client: clientIdentity, + serial: "emulator-5554", + count: 500, + buffer: "main", + pid: null, + lines: ["08-10 13:00:00.000 I/Tag( 123): message"], + total_bytes: 34, + coverage: "complete", + }, + inspect_adb_package: { + client: clientIdentity, + serial: "emulator-5554", + package_name: "com.example", + version_name: "1.2.3", + version_code: 45, + first_install_time: "2026-01-02 03:04:05", + last_update_time: "2026-02-03 04:05:06", + installer_package_name: null, + user_id: 10234, + pkg_flags: "[ HAS_CODE ]", + requested_permissions_count: 2, + coverage: "complete", + }, + pull_adb_file: { + client: clientIdentity, + serial: "emulator-5554", + device_path: "/data/local/tmp/notes.txt", + local_path: "/tmp/rea-pulls/notes.txt", + bytes: 10, + sha256: "a".repeat(64), + }, + push_adb_file: { + client: clientIdentity, + serial: "emulator-5554", + local_path: "/tmp/payload.bin", + device_path: "/data/local/tmp/payload.bin", + bytes: 12, + sha256: "a".repeat(64), + device_sha256: "a".repeat(64), + overwritten: false, + device_digest_source: "device_sha256sum", + }, + capture_adb_screen: { + client: clientIdentity, + serial: "emulator-5554", + local_path: "/tmp/rea-captures/screen.png", + bytes: 8192, + sha256: "a".repeat(64), + width: 1080, + height: 2340, + }, + list_adb_processes: { + client: clientIdentity, + serial: "emulator-5554", + columns: "USER PID PPID VSZ RSS WCHAN ADDR S NAME", + processes: [ + { + user: "u0_a1", + pid: 1234, + ppid: 567, + rss: 7890, + state: "S", + name: "com.example.app", + }, + ], + unparsed_lines: [], + coverage: "complete", + }, + list_adb_directory: { + client: clientIdentity, + serial: "emulator-5554", + device_path: "/data/local/tmp", + entries: [ + { + name: "notes.txt", + kind: "file", + permissions: "-rw-rw-rw-", + owner: "shell", + group: "shell", + bytes: 10, + date: "2026-01-01 10:00", + link_target: null, + }, + ], + unparsed_lines: [], + coverage: "complete", + }, + list_adb_features: { + client: clientIdentity, + serial: "emulator-5554", + features: [{ name: "android.hardware.camera", version: null }], + unparsed_lines: [], + coverage: "complete", + }, + list_adb_services: { + client: clientIdentity, + serial: "emulator-5554", + services: [ + { name: "package", interface: "android.content.pm.IPackageManager" }, + ], + unparsed_lines: [], + coverage: "complete", + }, + inspect_adb_display: { + client: clientIdentity, + serial: "emulator-5554", + physical_size: { width: 1080, height: 2340 }, + override_size: null, + physical_density: 420, + override_density: null, + }, + inspect_adb_window: { + client: clientIdentity, + serial: "emulator-5554", + focused_window: "Window{abcdef u0 com.example.app/Main}", + focused_app: null, + observed_lines: ["Window{abcdef u0 com.example.app/Main}"], + }, + read_adb_setting: { + client: clientIdentity, + serial: "emulator-5554", + namespace: "secure", + key: "adb_enabled", + value: "1", + device_reported_null: false, + }, + collect_adb_bugreport: { + client: clientIdentity, + serial: "emulator-5554", + local_path: "/tmp/rea-bugreports/bugreport.zip", + bytes: 1024, + sha256: "a".repeat(64), + adb_reported_path: "/tmp/rea-bugreports/bugreport.zip", + }, + resolve_adb_packages: { + client: clientIdentity, + serial: "emulator-5554", + query: "whatsapp", + matches: [ + { + package_name: "com.whatsapp", + base_apk_device_path: "/data/app/x/base.apk", + }, + ], + exact_match: true, + coverage: "complete", + }, + install_adb_package: { + client: clientIdentity, + serial: "emulator-5554", + apk_path: "/tmp/app.apk", + bytes: 2048, + sha256: "a".repeat(64), + replaced: false, + }, + uninstall_adb_package: { + client: clientIdentity, + serial: "emulator-5554", + package_name: "com.whatsapp", + }, + start_adb_app: { + client: clientIdentity, + serial: "emulator-5554", + package_name: "com.example.app", + component: "com.example.app/.Main", + started_activity: + "act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.example.app/.Main", + }, + start_adb_activity: { + client: clientIdentity, + serial: "emulator-5554", + action: "android.intent.action.VIEW", + data_uri: "https://example.com/", + component: null, + extras: [], + started_activity: "act=android.intent.action.VIEW dat=https://example.com/", + }, + stop_adb_app: { + client: clientIdentity, + serial: "emulator-5554", + package_name: "com.example.app", + }, +}; + +it("advertises exact valid ADB schemas and records inline evidence through MCP", async () => { + const provider: AdbDeviceAnalysisPort = { + async inspectAvailability() { + return { status: "available", code: null, reason: null, diagnostics: {} }; + }, + async close() {}, + async execute(request) { + return ok(createAnalysisExecution(results[request.operation], identity)); + }, + }; + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { adbDeviceAnalysis: provider }, + ); + onTestFinished(() => server.close()); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "adb-boundary", version: "0.0.0" }); + onTestFinished(() => client.close()); + await client.connect(clientTransport); + + const { tools } = await client.listTools(); + const names = tools.map((tool) => tool.name); + const operationNames = [ + "inspect_adb_client", + "list_adb_devices", + "inspect_adb_device", + "list_adb_packages", + "pull_adb_package", + "read_adb_logcat", + "inspect_adb_package", + "pull_adb_file", + "push_adb_file", + "capture_adb_screen", + "list_adb_processes", + "list_adb_directory", + "list_adb_features", + "list_adb_services", + "inspect_adb_display", + "inspect_adb_window", + "read_adb_setting", + "collect_adb_bugreport", + "resolve_adb_packages", + "install_adb_package", + "uninstall_adb_package", + "start_adb_app", + "start_adb_activity", + "stop_adb_app", + ] as const; + for (const name of operationNames) expect(names, name).toContain(name); + + const ajv = new Ajv2020({ strict: false, allErrors: true }); + for (const name of operationNames) { + const contract = toolContract(name); + const listed = tools.find((tool) => tool.name === name); + expect(listed, name).toBeTruthy(); + if (listed?.inputSchema === undefined) continue; + // The server may present a compacted form of the contract schema; what + // must hold is that the advertised schema compiles and accepts the + // contract's own examples, and rejects unknown input. + const validate = ajv.compile( + JSON.parse(JSON.stringify(listed.inputSchema)), + ); + for (const example of contract.examples) + expect(validate(example.input), `${name} example`).toBe(true); + expect(validate({ unknown_argument: true }), `${name} rejects`).toBe(false); + } + + const pulled = await client.callTool({ + name: "pull_adb_package", + arguments: { + serial: "emulator-5554", + package: "com.example", + output_directory: "/tmp/rea-pulls", + }, + }); + expect(pulled.isError).not.toBe(true); + const text = (pulled.content as { readonly text?: string }[]) + .map((part) => part.text ?? "") + .join(""); + const evidence = parseEvidence(JSON.parse(text)); + expect(evidence.provider.id).toBe("adb"); + expect(evidence.operation).toBe("pull_adb_package"); + expect(evidence.normalized_result).toMatchObject({ + artifacts: [{ file_name: "base.apk", role: "base", bytes: 2048 }], + coverage: "complete", + }); +}); + +it("rejects invalid tool input before reaching the provider", async () => { + const provider: AdbDeviceAnalysisPort = { + async inspectAvailability() { + return { status: "available", code: null, reason: null, diagnostics: {} }; + }, + async close() {}, + async execute() { + throw new Error("must not execute"); + }, + }; + const server = createServer( + { + kind: "session", + session: createTestBinarySession(() => { + throw new Error("must not start"); + }), + }, + { adbDeviceAnalysis: provider }, + ); + onTestFinished(() => server.close()); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "adb-boundary-invalid", version: "0.0.0" }); + onTestFinished(() => client.close()); + await client.connect(clientTransport); + + const rejected = await client.callTool({ + name: "inspect_adb_device", + arguments: { serial: "bad serial" }, + }); + expect(rejected.isError).toBe(true); + const text = (rejected.content as { readonly text?: string }[]) + .map((part) => part.text ?? "") + .join(""); + expect(text).toContain("Invalid arguments for tool inspect_adb_device"); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8c629580d..32edc6856 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,7 +37,7 @@ const TEST_PROJECTS = [ name: "adapters", include: [ "src/*.test.ts", - "src/{artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts", + "src/{adb,artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts", ], pool: "forks" as const, maxWorkers: MAX_TEST_WORKERS,