feat(apktool): decode Android resource tables through a caller-supplied launcher (#1556)

* feat(apktool): decode Android resource tables through a caller-supplied launcher

Add an Apktool family with two operations over the caller's apktool
launcher (REA_APKTOOL_COMMAND, absolute, or apktool on PATH):

- inspect_apktool_client: resolved launcher, selection source, and
  version; decodes nothing.
- decode_android_resources: apktool d --no-src in a provider-owned
  workspace that is removed before the result returns. Projects the
  decoded AndroidManifest.xml (bounded at 512 KiB), apktool.yml version
  and SDK facts exactly as printed, string resources verbatim (default or
  one locale, bounded at 2000 entries, nested-markup entries counted as
  unparsed), available string locales, and the decoded file count with a
  hard cap. The target APK is digested with SHA-256 before decoding so
  Evidence binds the observation to the exact input.

Smali production is skipped by design: Java-source recovery belongs to
the JADX family. Workspace cleanup happens before results return; an
unremovable workspace is named in the limitations instead of abandoned.

Verified against apktool 2.7.0-dirty (Debian packaging) on Linux with
OpenJDK 25 against a signed aapt2-built probe APK with two resource
locales: launcher version, on-disk digest agreement, metadata projection,
manifest package agreement, and both string tables (default and de).
Parser goldens come from the same decode. READMEs carry the new
'Android resources' row in every supported language.

* fix(apktool): own snapshots and bound decoded resource reads

* docs(apktool): describe owned resources and bounded projection

---------

Co-authored-by: REA Contributor <contributor@rea.tools>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 23:04:12 +08:00
1 parent 749e58772a
commit 96fd98cce0
47 files changed
+2208 -2

No files matched your search

+1
View File
@@ -160,6 +160,7 @@ Additional tools and host support depend on the target:
| 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) |
| JEB projects | Engine-backed project units, artifact digests and type/method pseudo-code | A caller-started JEB client serving MCP, selected with REA_JEB_MCP_URL; [JEB guide](docs/jeb-analysis.md) |
| Android resources | Decoded manifest, string tables, locales and version facts | Caller-supplied Apktool launcher; [resource guide](docs/apktool-resource-analysis.md) |
| 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) |
+1
View File
@@ -144,6 +144,7 @@ npx rea-agents@latest setup
| حزم Android APK | تصريحات ملف manifest، والأصناف، والدوال الناتجة عن فك الترجمة، والمراجع | JADX دون واجهة رسومية وJDK كامل على Linux/macOS/Windows x64؛ [دليل Android](docs/android-analysis.md) |
| أجهزة Android | الأجهزة والحزم والعمليات والسجلات والدامبات ولقطات الشاشة ونقل APK/الملفات | adb يوفّره المُستدعي (محاكي أو جهاز)؛ [دليل ADB](docs/adb-device-analysis.md) |
| مشاريع JEB | وحدات المشروع من المحرك وبصمات المصنّفات والكود الزائف للأنواع/الدوال | عميل JEB يبدأه المُستدعي ويقدّم MCP، ويُختار عبر REA_JEB_MCP_URL؛ [دليل JEB](docs/jeb-analysis.md) |
| موارد Android | البيان المفكوك الترميز وجداول السلاسل واللغات وحقائق الإصدار | مُشغّل Apktool يوفّره المُستدعي؛ [دليل الموارد](docs/apktool-resource-analysis.md) |
| البرامج الثابتة | المناطق، ونتائج الاستخراج، وما يُمرّر إلى تحليل الشيفرة الأصلية | 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) |
+1
View File
@@ -144,6 +144,7 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz
| 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) |
| JEB-Projekte | Engine-gestützte Projekteinheiten, Artefakt-Prüfsummen und Pseudokode von Typen/Methoden | Ein vom Aufrufer gestarteter JEB-Client mit MCP, ausgewählt über REA_JEB_MCP_URL; [JEB-Leitfaden](docs/jeb-analysis.md) |
| Android-Ressourcen | Dekodiertes Manifest, String-Tabellen, Sprachen und Versionsfakten | Vom Aufrufer bereitgestellter Apktool-Starter; [Ressourcen-Leitfaden](docs/apktool-resource-analysis.md) |
| 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) |
+1
View File
@@ -144,6 +144,7 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h
| 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) |
| Proyectos JEB | Unidades de proyecto del motor, resúmenes de artefactos y pseudocódigo de tipos/métodos | Un cliente JEB iniciado por el autor de la llamada que sirve MCP, seleccionado con REA_JEB_MCP_URL; [guía de JEB](docs/jeb-analysis.md) |
| Recursos de Android | Manifiesto decodificado, tablas de cadenas, idiomas y datos de versión | Lanzador de Apktool suministrado por el autor de la llamada; [guía de recursos](docs/apktool-resource-analysis.md) |
| 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) |
+1
View File
@@ -136,6 +136,7 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np
| فایل‌های APK اندروید | تعاریف Manifest، کلاس‌ها، متدهای دیکامپایل‌شده و ارجاعات | JADX بدون رابط گرافیکی و JDK کامل در Linux/macOS/Windows x64؛ [راهنمای اندروید](docs/android-analysis.md) |
| دستگاه‌های اندروید | دستگاه‌ها، بسته‌ها، فرایندها، لاگ‌ها، دامپ‌ها، اسکرین‌شات‌ها و انتقال APK/فایل | adb تأمین‌شده توسط فراخوان‌کننده (شبیه‌ساز یا دستگاه)؛ [راهنمای ADB](docs/adb-device-analysis.md) |
| پروژه‌های JEB | واحدهای پروژه از موتور، هش‌های مصنوع و شبه‌کدِ نوع/متد | یک کلاینت JEB که توسط فراخوان‌کننده اجرا شده و MCP ارائه می‌دهد و با REA_JEB_MCP_URL انتخاب می‌شود؛ [راهنمای JEB](docs/jeb-analysis.md) |
| منابع اندروید | مانیفست رمزگشایی‌شده، جداول رشته‌ها، زبان‌ها و اطلاعات نسخه | راه‌انداز Apktool تأمین‌شده توسط فراخوان‌کننده؛ [راهنمای منابع](docs/apktool-resource-analysis.md) |
| 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) |
+1
View File
@@ -145,6 +145,7 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les
| 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) |
| Projets JEB | Unités de projet fournies par le moteur, condensés d'artefacts et pseudo-code de types/méthodes | Un client JEB démarré par l'appelant servant MCP, sélectionné via REA_JEB_MCP_URL ; [guide JEB](docs/jeb-analysis.md) |
| Ressources Android | Manifeste décodé, tables de chaînes, langues et faits de version | Lanceur Apktool fourni par l'appelant ; [guide des ressources](docs/apktool-resource-analysis.md) |
| 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) |
+1
View File
@@ -148,6 +148,7 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
| 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) |
| Proyek JEB | Unit proyek bertenaga mesin, hash artefak, dan pseudokode tipe/metode | Klien JEB yang dijalankan pemanggil dan menyajikan MCP, dipilih dengan REA_JEB_MCP_URL; [panduan JEB](docs/jeb-analysis.md) |
| Resource Android | Manifest terdekode, tabel string, lokal, dan fakta versi | Peluncur Apktool buatan pemanggil; [panduan resource](docs/apktool-resource-analysis.md) |
| 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) |
+1
View File
@@ -144,6 +144,7 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm
| Android APK | マニフェストの宣言、クラス、逆コンパイルされたメソッド、参照 | Linux/macOS/Windows x64 上のヘッドレス JADX と完全な JDK;[Android ガイド](docs/android-analysis.md) |
| Android デバイス | デバイス、パッケージ、プロセス、ログ、ダンプ、スクリーンキャプチャ、APK/ファイル転送 | 呼び出し側が用意した adb(エミュレーターまたは実機);[ADB ガイド](docs/adb-device-analysis.md) |
| JEBプロジェクト | エンジンによるプロジェクトユニット、アーティファクトのダイジェスト、型/メソッドの擬似コード | 呼び出し側が起動し MCP を提供する JEB クライアント(REA_JEB_MCP_URL で選択);[JEB ガイド](docs/jeb-analysis.md) |
| Android リソース | デコードされたマニフェスト、文字列テーブル、ロケール、バージョン情報 | 呼び出し側が用意した Apktool ランチャー;[リソースガイド](docs/apktool-resource-analysis.md) |
| ファームウェア | 領域、抽出結果、ネイティブ分析への引き継ぎ | 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) |
+1
View File
@@ -144,6 +144,7 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
| Android APK | 매니페스트 선언, 클래스, 디컴파일된 메서드, 참조 | Linux/macOS/Windows x64의 헤드리스 JADX와 전체 JDK; [Android 가이드](docs/android-analysis.md) |
| Android 기기 | 기기, 패키지, 프로세스, 로그, 덤프, 화면 캡처, APK/파일 전송 | 호출자 제공 adb(에뮬레이터 또는 실제 기기); [ADB 가이드](docs/adb-device-analysis.md) |
| JEB 프로젝트 | 엔진 기반 프로젝트 유닛, 아티팩트 해시, 타입/메서드 의사코드 | 호출자가 시작하고 MCP를 제공하는 JEB 클라이언트(REA_JEB_MCP_URL로 선택); [JEB 가이드](docs/jeb-analysis.md) |
| Android 리소스 | 디코딩된 매니페스트, 문자열 테이블, 로캘, 버전 정보 | 호출자 제공 Apktool 런처; [리소스 가이드](docs/apktool-resource-analysis.md) |
| 펌웨어 | 영역, 추출 결과, 네이티브 분석으로 전달할 정보 | 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) |
+1
View File
@@ -144,6 +144,7 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na
| 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) |
| Projekty JEB | Jednostki projektu obsługiwane przez silnik, skróty artefaktów i pseudokod typów/metod | Uruchomiony przez wywołującego klient JEB udostępniający MCP, wybrany przez REA_JEB_MCP_URL; [przewodnik JEB](docs/jeb-analysis.md) |
| Zasoby Androida | Zdekodowany manifest, tablice ciągów, języki i informacje o wersji | Należący do wywołującego launcher Apktool; [przewodnik zasobów](docs/apktool-resource-analysis.md) |
| 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) |
+1
View File
@@ -144,6 +144,7 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer
| 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) |
| Projetos JEB | Unidades de projeto do motor, hashes de artefatos e pseudocódigo de tipos/métodos | Um cliente JEB iniciado pelo chamador servindo MCP, selecionado com REA_JEB_MCP_URL; [guia de JEB](docs/jeb-analysis.md) |
| Recursos do Android | Manifesto decodificado, tabelas de strings, idiomas e fatos de versão | Lançador do Apktool fornecido pelo chamador; [guia de recursos](docs/apktool-resource-analysis.md) |
| 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) |
+1
View File
@@ -144,6 +144,7 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
| Android APK | Объявления манифеста, классы, декомпилированные методы и ссылки | JADX без графического интерфейса и полный JDK в Linux/macOS/Windows x64; [руководство по Android](docs/android-analysis.md) |
| Устройства Android | Устройства, пакеты, процессы, журналы, дампы, снимки экрана и передача APK/файлов | adb, предоставленный вызывающей стороной (эмулятор или устройство); [руководство по ADB](docs/adb-device-analysis.md) |
| Проекты JEB | Единицы проекта от движка, дайджесты артефактов и псевдокод типов/методов | Запущенный вызывающей стороной клиент JEB с интерфейсом MCP, выбирается через REA_JEB_MCP_URL; [руководство по JEB](docs/jeb-analysis.md) |
| Ресурсы Android | Декодированный манифест, таблицы строк, языки и сведения о версии | Запуск Apktool, предоставленный вызывающей стороной; [руководство по ресурсам](docs/apktool-resource-analysis.md) |
| Прошивки | Области, результаты извлечения и передача в нативный анализ | 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) |
+1
View File
@@ -144,6 +144,7 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
| APK ของ Android | การประกาศใน manifest คลาส เมธอดที่ดีคอมไพล์ และการอ้างอิง | JADX แบบไม่มี GUI และ JDK แบบเต็มบน Linux/macOS/Windows x64; [คู่มือ Android](docs/android-analysis.md) |
| อุปกรณ์ Android | อุปกรณ์ แพ็กเกจ โพรเซส บันทึก ดัมพ์ ภาพหน้าจอ และการถ่ายโอน APK/ไฟล์ | adb ที่ผู้เรียกจัดเตรียม (โปรแกรมจำลองหรืออุปกรณ์จริง); [คู่มือ ADB](docs/adb-device-analysis.md) |
| โปรเจกต์ JEB | หน่วยโปรเจกต์จากเอนจิน, แฮชของอาร์ติแฟกต์ และซูโดโค้ดของไทป์/เมท็อด | ไคลเอนต์ JEB ที่ผู้เรียกเริ่มและให้บริการ MCP เลือกด้วย REA_JEB_MCP_URL; [คู่มือ JEB](docs/jeb-analysis.md) |
| ทรัพยากร Android | ไฟล์ manifest ที่ถอดรหัสแล้ว ตารางสตริง ภาษา และข้อมูลเวอร์ชัน | ตัวเรียก Apktool ที่ผู้เรียกจัดเตรียม; [คู่มือทรัพยากร](docs/apktool-resource-analysis.md) |
| เฟิร์มแวร์ | พื้นที่ข้อมูล ผลการแตกไฟล์ และการส่งต่อไปวิเคราะห์เนทีฟ | 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) |
+1
View File
@@ -144,6 +144,7 @@ REA; Node.js 22.x (>=22.19), 24.x (>=24.11) veya 26+ ve npm gerektirir. Ek araç
| 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) |
| JEB projeleri | Motor destekli proje birimleri, artefakt özetleri ve tür/metot sözdekodu | Çağıranın başlattığı, MCP sunan bir JEB istemcisi, REA_JEB_MCP_URL ile seçilir; [JEB rehberi](docs/jeb-analysis.md) |
| Android kaynakları | Çözülmüş manifest, dize tabloları, diller ve sürüm bilgileri | Çağıranın sağladığı Apktool başlatıcısı; [kaynak rehberi](docs/apktool-resource-analysis.md) |
| 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) |
+1
View File
@@ -144,6 +144,7 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
| Android APK | Оголошення маніфесту, класи, декомпільовані методи й посилання | JADX без графічного інтерфейсу й повний JDK у Linux/macOS/Windows x64; [посібник Android](docs/android-analysis.md) |
| Пристрої Android | Пристрої, пакети, процеси, журнали, дампи, знімки екрана та передавання APK/файлів | adb, наданий викликаючою стороною (емулятор або пристрій); [підручник ADB](docs/adb-device-analysis.md) |
| Проєкти JEB | Єдиниці проєкту від рушія, дайджести артефактів і псевдокод типів/методів | Запущений викликаною стороною клієнт JEB з MCP, обирається через REA_JEB_MCP_URL; [посібник JEB](docs/jeb-analysis.md) |
| Ресурси Android | Декодований маніфест, таблиці рядків, мови та відомості про версію | Засіб запуску Apktool, наданий викликаючою стороною; [підручник ресурсів](docs/apktool-resource-analysis.md) |
| Прошивки | Області, результати видобування й передавання до нативного аналізу | 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) |
+1
View File
@@ -144,6 +144,7 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np
| 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) |
| Dự án JEB | Đơn vị dự án từ engine, hash của artifact và mã giả của kiểu/phương thức | Một ứng dụng JEB do nơi gọi khởi động, phục vụ MCP, chọn bằng REA_JEB_MCP_URL; [hướng dẫn JEB](docs/jeb-analysis.md) |
| Tài nguyên Android | Manifest đã giải mã, bảng chuỗi, ngôn ngữ và thông tin phiên bản | Trình khởi chạy Apktool do nơi gọi cung cấp; [hướng dẫn tài nguyên](docs/apktool-resource-analysis.md) |
| 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) |
+1
View File
@@ -144,6 +144,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| Android APK | 清單聲明、類別、反編譯的方法和引用 | Linux/macOS/Windows x64 上的無界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) |
| Android 裝置 | 裝置、套件、程序、日誌、傾印、螢幕擷取以及 APK/檔案傳輸 | 呼叫端提供的 adb(模擬器或實體裝置);[ADB 指南](docs/adb-device-analysis.md) |
| JEB 專案 | 引擎支援的專案單元、成品摘要和型別/方法的虛擬碼 | 由呼叫端啟動、提供 MCP 的 JEB 用戶端,透過 REA_JEB_MCP_URL 選擇;[JEB 指南](docs/jeb-analysis.md) |
| Android 資源 | 解碼後的清單、字串表、語系和版本資訊 | 呼叫端提供的 Apktool 啟動器;[資源指南](docs/apktool-resource-analysis.md) |
| 韌體 | 區域、提取結果和轉交原生分析的內容 | 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) |
+1
View File
@@ -144,6 +144,7 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| Android APK | 清单声明、类、反编译的方法和引用 | Linux/macOS/Windows x64 上的无界面 JADX 和完整 JDK;[Android 指南](docs/android-analysis.md) |
| Android 设备 | 设备、软件包、进程、日志、转储、屏幕截图以及 APK/文件传输 | 调用方提供的 adb(模拟器或实体设备);[ADB 指南](docs/adb-device-analysis.md) |
| JEB 项目 | 引擎支持的项目单元、工件摘要和类型/方法的伪代码 | 由调用方启动、提供 MCP 的 JEB 客户端,通过 REA_JEB_MCP_URL 选择;[JEB 指南](docs/jeb-analysis.md) |
| Android 资源 | 解码后的清单、字符串表、语言区域和版本信息 | 调用方提供的 Apktool 启动器;[资源指南](docs/apktool-resource-analysis.md) |
| 固件 | 区域、提取结果和转交原生分析的内容 | 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) |
+86
View File
@@ -0,0 +1,86 @@
# Apktool resource decoding
REA decodes Android resource tables and the binary manifest through the
caller's Apktool launcher. This completes the static triad for Android
targets: Java/Kotlin sources through the JADX family, engine-grade
decompilation through JEB, and resources through this family. REA never
installs Apktool or a Java runtime.
## Supply tools
Use Apktool 2.7.0 or newer (verified with 2.7.0) and a Java runtime the
launcher can find. Select the launcher through the environment:
```sh
# Optional: absolute path to the apktool launcher (upstream wrapper or a
# java -jar script); without it, apktool resolves from PATH.
export REA_APKTOOL_COMMAND=/absolute/path/apktool
```
Readiness is `rea inspect-apktool-client`: it runs `apktool --version` and
reports the resolved launcher, its selection source, and its version. A
missing launcher reports `capability_unavailable` with the
`REA_APKTOOL_COMMAND` remediation rather than attempting an installation.
## Answer an analyst question
```sh
rea inspect-apktool-client
rea decode-android-resources /targets/Example.apk
rea decode-android-resources /targets/Example.apk --locale de
rea decode-android-resources /targets/Example.apk --no-strings
```
Equivalent MCP methods are `inspect_apktool_client` and
`decode_android_resources`. The decode request takes `path`, an optional
`locale` (project `res/values-<locale>/strings.xml` instead of the default
table), and `include_strings` (default true). Regional locales such as `pt-BR`
select Android's `values-pt-rBR`; Android qualifiers such as `pt-rBR` and
`b+zh+Hans+CN` may also be supplied directly.
## Interpret the result
- **An owned APK snapshot is digested and decoded.** `target.sha256` and
`target.bytes` bind the observation to the exact APK that was decoded.
- **Metadata comes from apktool.yml as apktool printed it.** Version codes
keep their own spelling (`'7'` versus `7`); `package_name` is read from
the decoded manifest, not inferred.
- **The manifest is the decoded text**, bounded at 512 KiB and reported as
truncated when larger.
- **Strings are projected verbatim.** Escaped entities stay escaped, and
entries containing nested markup (`<b>`, `<xliff:g>`) are counted as
unparsed rather than flattened — the limitation names the count. The
default table is bounded to 2000 entries with partial coverage when more
exist; `locales` lists every `values-<locale>` that has a strings table.
- **Smali is skipped by design** (`apktool d --no-src`): Java-source
recovery belongs to the JADX family. Resource smali edits and rebuild are
a follow-up lane with mutation authority.
- **Workspaces are owned and short-lived.** Decoding happens in a
provider-owned temporary directory that is removed before the result is
returned. The APK snapshot and framework cache both live inside this directory.
If removal fails, the result names the residual path and the provider retains
ownership for a cleanup retry on close, including failed decodes.
## Resource and lifecycle limits
- The decode runs with a 600 s deadline and a 4 MiB output capture budget;
a stopped decode reports `provider_timeout` and exceeded budgets report
`resource_constraint`.
- Manifest reads are bounded to 512 KiB before decoding UTF-8. Strings XML
reads are bounded to 8 MiB; larger documents are skipped with partial
coverage and an explicit limit, rather than reported as missing.
- `decoded_file_count` is capped at 100,000 files; the cap is reported in
the limitations when hit.
- Apktool's own decode failures (`AndrolibException`, directory traversal
refusals) surface as `unsupported_target` with apktool's reason preserved
verbatim.
## Real verification
`npm run verify:apktool -- --apk PATH` exercises both operations against a
real launcher. Record: apktool 2.7.0-dirty (Debian packaging) on Linux with
OpenJDK 25 against a signed aapt2-built probe APK carrying two resource
locales — launcher version, on-disk digest agreement, apktool.yml metadata
(1.2.3, SDK 24–34), manifest package agreement, default and `de` string
tables projected. Parser goldens in the test suite come from the same
decode. See `docs/testing.md` for the lane summary.
+14
View File
@@ -564,6 +564,20 @@ Synthetic producer regressions run independently:
npm run test:focused -- tests/boundary/process/jadxIntegration.test.ts tests/boundary/process/androidAnalysisMcp.test.ts
```
## Apktool resource decoding
`npm run verify:apktool -- --apk PATH` exercises both Apktool operations
against a real launcher (`REA_APKTOOL_COMMAND` or PATH) and one APK. No
download or build step is involved. Record: apktool 2.7.0-dirty (Debian
packaging) on Linux with OpenJDK 25, against a signed aapt2-built probe APK
carrying two resource locales — launcher version, on-disk digest agreement
with the reported target identity, apktool.yml metadata projection
(1.2.3, SDK 24–34), manifest package agreement, and both the default and
`de` string tables projected through the locale option. Parser goldens in
`src/apktool/ApktoolDecodeOutput.test.ts` come from the same decode. See
[Apktool resource analysis](apktool-resource-analysis.md) for boundaries and
budgets.
## ADB device acquisition
`npm run verify:adb` exercises every ADB operation against a live device or
+1
View File
@@ -147,6 +147,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:apktool": "npm run build:cached && node scripts/verify-apktool.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",
+9
View File
@@ -47,6 +47,11 @@ export const toolFamilyCatalog = (sources) => {
surface: "android-provider",
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
},
{
id: "apktool",
surface: "apktool-provider",
contracts: sources.apktoolContracts.APKTOOL_TOOL_CONTRACTS,
},
{
id: "jeb",
surface: "jeb-provider",
@@ -207,6 +212,10 @@ export const providerCatalog = (sources) => {
identity: sources.androidProvider.JADX_PROVIDER_IDENTITY,
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
},
{
identity: sources.apktoolProvider.APKTOOL_PROVIDER_IDENTITY,
contracts: sources.apktoolContracts.APKTOOL_TOOL_CONTRACTS,
},
{
identity: sources.jebProvider.JEB_PROVIDER_IDENTITY,
contracts: sources.jebContracts.JEB_TOOL_CONTRACTS,
+2
View File
@@ -53,6 +53,8 @@ const SOURCE_PATHS = {
firmwareProvider: "dist/firmware/FirmwareRelease.js",
androidContracts: "dist/contracts/android/androidToolContracts.js",
androidProvider: "dist/android/JadxRelease.js",
apktoolContracts: "dist/contracts/apktool/apktoolToolContracts.js",
apktoolProvider: "dist/apktool/ApktoolProvider.js",
jebContracts: "dist/contracts/jeb/jebToolContracts.js",
jebProvider: "dist/jeb/JebProvider.js",
adbContracts: "dist/contracts/adb/adbToolContracts.js",
+127
View File
@@ -0,0 +1,127 @@
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 { ApktoolResourceAnalysisService } from "../dist/application/apktool/ApktoolResourceAnalysisService.js";
import { createApktoolResourceAnalysisProvider } from "../dist/composition/apktool.js";
const options = parseArguments(process.argv.slice(2));
if (options.help) {
process.stdout.write(
"Usage: npm run verify:apktool [-- --apk PATH]\n" +
"Requires a caller-selected apktool launcher (REA_APKTOOL_COMMAND or\n" +
"PATH) and a Java runtime. Without --apk, a small signed probe APK is\n" +
"expected at the path given by --apk; nothing is downloaded or built.\n",
);
process.exit(0);
}
const provider = createApktoolResourceAnalysisProvider(process.env);
const service = new ApktoolResourceAnalysisService(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_apktool_client", {});
if (client !== null) {
check(
"apktool launcher reports a version",
typeof client.client.apktool_version === "string" &&
client.client.apktool_version !== "",
`command=${client.client.command} version=${client.client.apktool_version}`,
);
}
if (options.apk !== null) {
const decoded = await run("decode_android_resources", {
path: options.apk,
include_strings: true,
});
if (decoded !== null) {
const bytes = await stat(options.apk).then((info) => info.size);
const digest = createHash("sha256")
.update(await readFile(options.apk))
.digest("hex");
check(
"decoded target identity matches the file on disk",
decoded.target.bytes === bytes && decoded.target.sha256 === digest,
`${bytes} bytes`,
);
check(
"metadata carries version and SDK facts",
typeof decoded.metadata.version_name === "string" &&
decoded.metadata.min_sdk_version !== null &&
decoded.metadata.target_sdk_version !== null,
`version=${String(decoded.metadata.version_name)} sdk=${String(decoded.metadata.min_sdk_version)}..${String(decoded.metadata.target_sdk_version)}`,
);
check(
"decoded manifest names the package",
typeof decoded.metadata.package_name === "string" &&
decoded.manifest !== null &&
decoded.manifest.includes(decoded.metadata.package_name),
String(decoded.metadata.package_name),
);
check(
"string resources projected",
decoded.strings.length > 0,
`${decoded.strings.length} string(s)`,
);
if (decoded.locales.length > 0) {
const locale = await run("decode_android_resources", {
path: options.apk,
include_strings: true,
locale: decoded.locales[0],
});
if (locale !== null)
check(
`locale table projected for ${String(decoded.locales[0])}`,
Array.isArray(locale.strings),
`${locale.strings.length} string(s)`,
);
}
}
}
try {
await provider.close();
} catch {
// The provider owns per-call workspaces only; closing is best-effort.
}
if (failures > 0) {
process.stdout.write(`\n${failures} check(s) failed\n`);
process.exit(1);
}
process.stdout.write("\nall apktool checks passed\n");
function parseArguments(argv) {
const parsed = { help: false, apk: 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 === "--apk") {
parsed.apk = argv[index + 1] ?? null;
index += 1;
} else {
process.stderr.write(`unknown argument: ${argument}\n`);
process.exit(2);
}
}
return parsed;
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
APKTOOL_COMMAND_ENV,
parseApktoolVersionOutput,
resolveApktoolCommand,
} from "./ApktoolConfiguration.js";
describe("resolveApktoolCommand", () => {
it("prefers an absolute REA_APKTOOL_COMMAND", () => {
expect(
resolveApktoolCommand({
[APKTOOL_COMMAND_ENV]: "/usr/local/bin/apktool",
}),
).toEqual({
command: "/usr/local/bin/apktool",
commandSource: "environment",
});
});
it("falls back to PATH resolution without the environment key", () => {
expect(resolveApktoolCommand({})).toEqual({
command: "apktool",
commandSource: "path",
});
});
it("rejects a relative environment path explicitly", () => {
expect(() =>
resolveApktoolCommand({ [APKTOOL_COMMAND_ENV]: "bin/apktool" }),
).toThrow(/must be an absolute path/u);
});
});
describe("parseApktoolVersionOutput", () => {
it("reads the version from real apktool output", () => {
expect(parseApktoolVersionOutput("2.7.0-dirty\n")).toEqual({
version: "2.7.0-dirty",
});
});
it("reports null when the launcher prints nothing usable", () => {
expect(parseApktoolVersionOutput(" \n")).toEqual({ version: null });
});
});
+140
View File
@@ -0,0 +1,140 @@
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 apktool launcher. */
export const APKTOOL_COMMAND_ENV = "REA_APKTOOL_COMMAND";
const VERSION_TIMEOUT_MS = 20_000;
const VERSION_MAX_OUTPUT_BYTES = 64 * 1024;
/** How the apktool launcher was selected for this provider instance. */
export interface ApktoolCommandSelection {
readonly command: string;
readonly commandSource: "environment" | "path";
}
/**
* Resolve the caller-supplied apktool launcher. `REA_APKTOOL_COMMAND` must
* be absolute so the selection stays explicit; without it, apktool is
* resolved from `PATH` at run time. Both the upstream wrapper script and
* `java -jar` launcher scripts work: REA only runs `<command> --version`
* and `<command> d ...`.
*/
export const resolveApktoolCommand = (
environment: Readonly<Record<string, string | undefined>>,
): ApktoolCommandSelection => {
const configured = environment[APKTOOL_COMMAND_ENV];
if (configured === undefined || configured.trim() === "")
return { command: "apktool", commandSource: "path" };
if (!isAbsolute(configured))
throw new ApktoolConfigurationFailure(
"not_absolute",
`${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`,
{ configured_command: configured },
);
return { command: configured, commandSource: "environment" };
};
export type ApktoolConfigurationFailureCode =
| "not_absolute"
| "command_missing"
| "version_unresolved";
export class ApktoolConfigurationFailure extends Error {
constructor(
readonly code: ApktoolConfigurationFailureCode,
message: string,
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
options?: ErrorOptions,
) {
super(message, options);
}
}
export interface ApktoolClientIdentity {
readonly version: string | null;
}
/** Read the version from `apktool --version` output (first non-empty line). */
export const parseApktoolVersionOutput = (
stdout: string,
): ApktoolClientIdentity => {
const line = stdout
.split(/\r?\n/u)
.map((entry) => entry.trim())
.find((entry) => entry !== "");
return { version: line === undefined ? null : line };
};
/** Probe the selected apktool launcher without decoding anything. */
export const inspectApktoolClient = async (
selection: ApktoolCommandSelection,
signal?: AbortSignal,
): Promise<ApktoolClientIdentity> => {
try {
const { stdout } = await execFileOutput(selection.command, ["--version"], {
timeout: VERSION_TIMEOUT_MS,
stopSignal: "SIGTERM",
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
...(signal === undefined ? {} : { signal }),
});
return parseApktoolVersionOutput(stdout);
} catch (cause) {
const failure = execFileOutputFailure(cause);
if (signal?.aborted || failure?.code === "ABORT_ERR")
throw new AnalysisCancelledError("inspect_apktool_client", { cause });
if (failure?.code === "ENOENT")
throw new ApktoolConfigurationFailure(
"command_missing",
`The selected apktool launcher was not found: ${selection.command}`,
{
selected_command: selection.command,
command_source: selection.commandSource,
remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script)`,
},
{ cause },
);
throw new ApktoolConfigurationFailure(
"version_unresolved",
"The selected apktool launcher did not report a usable version",
{
selected_command: selection.command,
...(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: ApktoolConfigurationFailureCode,
): ProviderRejectionCode =>
code === "command_missing"
? "executable_missing"
: code === "not_absolute"
? "not_configured"
: "version_unresolved";
export const apktoolConfigurationAvailability = (
cause: ApktoolConfigurationFailure,
): ProviderAvailability =>
({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
diagnostics: { ...cause.diagnostics },
}) as ProviderAvailability;
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
parseApktoolYml,
parseDecodedStrings,
parseManifestPackage,
} from "./ApktoolDecodeOutput.js";
const REAL_YML = [
"!!brut.androlib.meta.MetaInfo",
"apkFileName: probe.apk",
"compressionType: false",
"doNotCompress:",
"- resources.arsc",
"isFrameworkApk: false",
"packageInfo:",
" forcedPackageId: '127'",
" renameManifestPackage: null",
"sdkInfo:",
" minSdkVersion: '24'",
" targetSdkVersion: '34'",
"sharedLibrary: false",
"sparseResources: false",
"unknownFiles: {}",
"usesFramework:",
" ids:",
" - 1",
" tag: null",
"version: 2.7.0-dirty",
"versionInfo:",
" versionCode: '7'",
" versionName: 1.2.3",
"",
].join("\n");
describe("parseApktoolYml", () => {
it("projects version and SDK facts from a real apktool.yml", () => {
expect(parseApktoolYml(REAL_YML)).toEqual({
versionName: "1.2.3",
versionCode: "7",
minSdkVersion: "24",
targetSdkVersion: "34",
});
});
it("accepts quoted version values some builds emit", () => {
expect(parseApktoolYml("versionName: '2.0.0'\nversionCode: 45\n")).toEqual({
versionName: "2.0.0",
versionCode: "45",
minSdkVersion: null,
targetSdkVersion: null,
});
});
it("reports null facts for unrelated YAML", () => {
expect(parseApktoolYml("version: 2.7.0\n")).toEqual({
versionName: null,
versionCode: null,
minSdkVersion: null,
targetSdkVersion: null,
});
});
});
describe("parseManifestPackage", () => {
it("reads the package attribute from a decoded manifest", () => {
const manifest =
'<?xml version="1.0" encoding="utf-8" standalone="no"?><manifest xmlns:android="http://schemas.android.com/apk/res/android" android:compileSdkVersion="34" package="com.rea.apktool.probe" platformBuildVersionCode="34">';
expect(parseManifestPackage(manifest)).toBe("com.rea.apktool.probe");
});
it("reports null when no package attribute exists", () => {
expect(parseManifestPackage("<manifest>\n</manifest>\n")).toBeNull();
});
});
describe("parseDecodedStrings", () => {
it("projects entries from a real decoded strings.xml", () => {
const stringsXml = [
'<?xml version="1.0" encoding="utf-8"?>',
"<resources>",
' <string name="api_endpoint">https://api.example.com/v1</string>',
' <string name="app_name">ApktoolProbe</string>',
' <string name="debug_flag">true</string>',
"</resources>",
"",
].join("\n");
expect(parseDecodedStrings(stringsXml)).toEqual({
entries: [
{ name: "api_endpoint", value: "https://api.example.com/v1" },
{ name: "app_name", value: "ApktoolProbe" },
{ name: "debug_flag", value: "true" },
],
unparsedCount: 0,
});
});
it("counts entries with nested markup instead of flattening them", () => {
const stringsXml =
'<resources><string name="styled">Hello <b>world</b></string><string name="plain">ok</string></resources>';
expect(parseDecodedStrings(stringsXml)).toEqual({
entries: [{ name: "plain", value: "ok" }],
unparsedCount: 1,
});
});
it("preserves escaped entities verbatim", () => {
const stringsXml =
'<resources><string name="escaped">a &amp; b &lt;c&gt;</string></resources>';
expect(parseDecodedStrings(stringsXml).entries[0]?.value).toBe(
"a &amp; b &lt;c&gt;",
);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Parsers for the decoded workspace `apktool d --no-src` produces.
*
* Apktool's apktool.yml is a small YAML document; the facts REA projects
* are flat enough that line-oriented extraction stays honest without a
* YAML dependency, and unrecognized content is ignored rather than guessed.
*/
export interface ApktoolMetadata {
readonly versionName: string | null;
readonly versionCode: string | null;
readonly minSdkVersion: string | null;
readonly targetSdkVersion: string | null;
}
/**
* Project version and SDK facts from apktool.yml. Values keep apktool's
* own spelling (version codes can print as `45` or `45.1`).
*/
export const parseApktoolYml = (yml: string): ApktoolMetadata => {
const metadata: {
versionName: string | null;
versionCode: string | null;
minSdkVersion: string | null;
targetSdkVersion: string | null;
} = {
versionName: null,
versionCode: null,
minSdkVersion: null,
targetSdkVersion: null,
};
for (const line of yml.split(/\r?\n/u)) {
const versionName = /^\s*versionName:\s*(?:'([^']*)'|(.*))$/u.exec(line);
if (versionName !== null && metadata.versionName === null)
metadata.versionName = (versionName[1] ?? versionName[2] ?? "").trim();
const versionCode = /^\s*versionCode:\s*(?:'([^']*)'|(.*))$/u.exec(line);
if (versionCode !== null && metadata.versionCode === null)
metadata.versionCode = (versionCode[1] ?? versionCode[2] ?? "").trim();
const minSdk = /minSdkVersion:\s*'?([0-9]+)'?/u.exec(line);
if (minSdk !== null && metadata.minSdkVersion === null)
metadata.minSdkVersion = minSdk[1]!;
const targetSdk = /targetSdkVersion:\s*'?([0-9]+)'?/u.exec(line);
if (targetSdk !== null && metadata.targetSdkVersion === null)
metadata.targetSdkVersion = targetSdk[1]!;
}
return metadata;
};
/** Read the package attribute from decoded AndroidManifest.xml text. */
export const parseManifestPackage = (manifest: string): string | null => {
const match = /<manifest[^>]*\spackage="([^"]+)"/u.exec(manifest);
return match === null ? null : match[1]!;
};
export interface DecodedStringEntry {
readonly name: string;
readonly value: string;
}
/**
* Project `<string name="...">value</string>` entries from one decoded
* strings.xml. Values are preserved exactly as apktool printed them —
* escaped entities stay escaped — and entries containing nested markup
* are counted as unparsed rather than flattened.
*/
export const parseDecodedStrings = (
stringsXml: string,
): {
entries: readonly DecodedStringEntry[];
unparsedCount: number;
} => {
const entries: DecodedStringEntry[] = [];
let unparsedCount = 0;
const pattern = /<string\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/string>/gu;
for (
let match = pattern.exec(stringsXml);
match !== null;
match = pattern.exec(stringsXml)
) {
const value = match[2]!;
if (value.includes("<")) {
unparsedCount += 1;
continue;
}
entries.push({ name: match[1]!, value: value.trim() });
}
return { entries, unparsedCount };
};
+370
View File
@@ -0,0 +1,370 @@
import { createHash } from "node:crypto";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, onTestFinished, test } from "vitest";
import { ApktoolProvider } from "./ApktoolProvider.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { apktoolRequestSchema } from "../domain/apktool/apktoolResourceAnalysis.js";
import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.js";
const it = test.skipIf(process.platform === "win32");
const VERSION_BLOCK = "printf '2.7.0-stub\\n'";
const REAL_YML = [
"!!brut.androlib.meta.MetaInfo",
"sdkInfo:",
" minSdkVersion: '24'",
" targetSdkVersion: '34'",
"versionInfo:",
" versionCode: '7'",
" versionName: 1.2.3",
"",
].join("\n");
const REAL_MANIFEST = [
'<?xml version="1.0" encoding="utf-8" standalone="no"?>',
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.rea.apktool.probe">',
' <application android:label="@string/app_name" android:debuggable="true"/>',
"</manifest>",
"",
].join("\n");
const REAL_STRINGS = [
'<?xml version="1.0" encoding="utf-8"?>',
"<resources>",
' <string name="api_endpoint">https://api.example.com/v1</string>',
' <string name="app_name">ApktoolProbe</string>',
"</resources>",
"",
].join("\n");
const DE_STRINGS = [
'<?xml version="1.0" encoding="utf-8"?>',
"<resources>",
' <string name="app_name">ApktoolProbe DE</string>',
"</resources>",
"",
].join("\n");
const writeApktoolStub = async (
body: (root: string) => string,
): Promise<{ readonly command: string; readonly root: string }> => {
const root = await mkdtemp(join("/tmp", "rea-apktool-stub-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const command = join(root, "apktool");
await writeFile(command, `#!/bin/sh\n${body(root)}\n`, { mode: 0o755 });
await chmod(command, 0o755);
return { command, root };
};
const providerOver = (command: string): ApktoolProvider =>
new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: command },
});
const evidenceResult = async (
provider: ApktoolProvider,
request: ApktoolRequest,
): Promise<Record<string, unknown>> => {
const result = await provider.execute(request);
if (!result.ok) throw new Error(result.error.message);
return result.value.result as Record<string, unknown>;
};
const refusedBy = async (
provider: ApktoolProvider,
request: ApktoolRequest,
): Promise<ReturnType<typeof projectAnalysisError>> => {
const result = await provider.execute(request);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a refusal");
return projectAnalysisError(result.error);
};
/** Stub writing a real-shaped decoded workspace into the -o directory. */
const decodeStub = (
options: {
readonly withStrings?: boolean;
readonly withGerman?: boolean;
readonly failDecode?: boolean;
} = {},
): ((root: string) => string) => {
const withStrings = options.withStrings ?? true;
const withGerman = options.withGerman ?? true;
return () => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi
if [ "$1" = "d" ]; then
${
options.failDecode === true
? `printf 'Exception in thread "main" brut.androlib.AndrolibException: Could not decode the APK\\n' >&2; exit 1;`
: `printf '%s\\n' '${REAL_YML.replace(/'/gu, "'\\''")}' > "$5/apktool.yml"
printf '%s\\n' '${REAL_MANIFEST.replace(/'/gu, "'\\''")}' > "$5/AndroidManifest.xml"
mkdir -p "$5/res/values"${withGerman ? ' "$5/res/values-de"' : ""}
${withStrings ? `printf '%s\\n' '${REAL_STRINGS.replace(/'/gu, "'\\''")}' > "$5/res/values/strings.xml"` : ""}
${withGerman ? `printf '%s\\n' '${DE_STRINGS.replace(/'/gu, "'\\''")}' > "$5/res/values-de/strings.xml"` : ""}
printf 'I: Using Apktool on stub\\n'
exit 0`
}
fi
printf 'unexpected arguments: %s\\n' "$@"
exit 1`;
};
const apkFixture = async (
root: string,
): Promise<{
readonly path: string;
readonly bytes: number;
readonly sha256: string;
}> => {
const path = join(root, "probe.apk");
const bytes = "apk-bytes-for-apktool";
await writeFile(path, bytes);
return {
path,
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
};
};
it("inspects the apktool launcher", async () => {
const { command } = await writeApktoolStub(
() => `if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi`,
);
const result = await evidenceResult(providerOver(command), {
operation: "inspect_apktool_client",
input: {},
});
expect(result.client).toEqual({
command,
command_source: "environment",
apktool_version: "2.7.0-stub",
});
});
it("decodes resources with target identity, metadata, and strings", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.target).toEqual({
path: apk.path,
bytes: apk.bytes,
sha256: apk.sha256,
});
expect(result.metadata).toEqual({
version_name: "1.2.3",
version_code: "7",
min_sdk_version: "24",
target_sdk_version: "34",
package_name: "com.rea.apktool.probe",
});
expect(result.manifest).toContain('package="com.rea.apktool.probe"');
expect(result.strings).toEqual([
{ name: "api_endpoint", value: "https://api.example.com/v1" },
{ name: "app_name", value: "ApktoolProbe" },
]);
expect(result.locales).toEqual(["de"]);
expect(result.locale).toBeNull();
expect(result.decoded_file_count).toBeGreaterThan(2);
expect(result.coverage).toBe("complete");
});
it("projects one locale's strings on request", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "de" },
});
expect(result.locale).toBe("de");
expect(result.strings).toEqual([
{ name: "app_name", value: "ApktoolProbe DE" },
]);
});
it("notes a missing locale table instead of failing", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "fr" },
});
expect(result.strings).toEqual([]);
});
it("notes a missing default strings table without strings", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ withStrings: false }),
);
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.strings).toEqual([]);
});
it("reports apktool's own refusal for undecodable targets", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ failDecode: true }),
);
const apk = await apkFixture(root);
const projected = await refusedBy(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(projected).toMatchObject({ code: "unsupported_target" });
expect(projected.message).toContain("Could not decode");
});
it("reports a missing launcher as a capability gap with recovery", async () => {
const provider = new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: "/nonexistent/apktool" },
});
const availability = await provider.inspectAvailability();
expect(availability).toMatchObject({
status: "unavailable",
code: "executable_missing",
});
const projected = await refusedBy(provider, {
operation: "inspect_apktool_client",
input: {},
});
expect(projected).toMatchObject({ code: "capability_unavailable" });
expect(projected.details).toMatchObject({
provider_id: "apktool",
reason: expect.stringContaining("REA_APKTOOL_COMMAND") as unknown,
});
});
it("rejects a target that is not a readable file", async () => {
const { command, root } = await writeApktoolStub(decodeStub());
const projected = await refusedBy(providerOver(command), {
operation: "decode_android_resources",
input: { path: join(root, "absent.apk"), include_strings: true },
});
expect(projected).toMatchObject({ code: "invalid_request" });
});
it("cancels before spawning anything", async () => {
const { command, root } = await writeApktoolStub(
(root) => `: > ${join(root, "spawned")}
if [ "$1" = "--version" ]; then ${VERSION_BLOCK}; exit 0; fi`,
);
const controller = new AbortController();
controller.abort();
const result = await providerOver(command).execute(
{
operation: "decode_android_resources",
input: { path: "/targets/x.apk", include_strings: true },
},
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
});
expect(
await readFile(join(root, "spawned")).then(
() => true,
() => false,
),
).toBe(false);
});
it("uses an owned APK snapshot and framework cache and resolves regional locales", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub()(root).replace(
'if [ "$1" = "d" ]; then',
`if [ "$1" = "d" ]; then
[ "$6" = "--frame-path" ] || exit 7
[ -d "$7" ] || exit 8
printf 'changed original' > "${join(root, "probe.apk")}"
cat "$8" > "${join(root, "snapshot-bytes")}"
mkdir -p "$5/res/values-pt-rBR"
printf '<resources><string name="greeting">Olá</string></resources>' > "$5/res/values-pt-rBR/strings.xml"`,
),
);
const apk = await apkFixture(root);
const result = await evidenceResult(providerOver(command), {
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true, locale: "pt-BR" },
});
expect(result.target).toMatchObject({
sha256: apk.sha256,
bytes: apk.bytes,
path: apk.path,
});
expect(await readFile(join(root, "snapshot-bytes"), "utf8")).toBe(
"apk-bytes-for-apktool",
);
expect(result.strings).toEqual([{ name: "greeting", value: "Olá" }]);
});
it("marks an oversized strings document partial without claiming it is missing", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub()(root).replace(
" printf 'I: Using Apktool on stub",
' dd if=/dev/zero of="$5/res/values/strings.xml" bs=1048576 count=9 2>/dev/null\n printf \'I: Using Apktool on stub',
),
);
const apk = await apkFixture(root);
const result = await providerOver(command).execute({
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.result).toMatchObject({
coverage: "partial",
strings: [],
});
expect(result.value.limitations.join(" ")).toContain("exceeds");
expect(result.value.limitations.join(" ")).not.toContain("No default");
});
it("retains failed workspace cleanup and retries it on close", async () => {
const { command, root } = await writeApktoolStub(
decodeStub({ failDecode: true }),
);
const apk = await apkFixture(root);
let attempts = 0;
let owned: string | undefined;
const provider = new ApktoolProvider({
environment: { REA_APKTOOL_COMMAND: command },
removeWorkspace: async (path) => {
owned = path;
if (++attempts === 1) throw new Error("busy");
await rm(path, { recursive: true, force: true });
},
});
const result = await provider.execute({
operation: "decode_android_resources",
input: { path: apk.path, include_strings: true },
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.cleanup?.resources).toContain(owned);
await provider.close();
expect(attempts).toBe(2);
});
it("accepts a BCP47 resource qualifier through the public request schema", async () => {
const { command, root } = await writeApktoolStub((root) =>
decodeStub()(root).replace(
" printf 'I: Using Apktool on stub",
' mkdir -p "$5/res/values-b+zh+Hans+CN"\n printf \'<resources><string name="greeting">你好</string></resources>\' > "$5/res/values-b+zh+Hans+CN/strings.xml"\n printf \'I: Using Apktool on stub',
),
);
const apk = await apkFixture(root);
const request = apktoolRequestSchema.parse({
operation: "decode_android_resources",
input: { path: apk.path, locale: "b+zh+Hans+CN" },
});
const result = await evidenceResult(providerOver(command), request);
expect(result.strings).toEqual([{ name: "greeting", value: "你好" }]);
});
+569
View File
@@ -0,0 +1,569 @@
import { createHash } from "node:crypto";
import { createReadStream, constants as fsConstants } from "node:fs";
import {
access,
chmod,
copyFile,
mkdir,
mkdtemp,
open,
readdir,
rm,
stat,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExecutionOptions,
ProviderAvailability,
} from "../application/AnalysisProvider.js";
import {
createAnalysisExecution,
type AnalysisExecution,
} from "../application/AnalysisProvider.js";
import type { ApktoolRequest } from "../domain/apktool/apktoolResourceAnalysis.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisInputError,
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 {
ApktoolConfigurationFailure,
inspectApktoolClient,
resolveApktoolCommand,
type ApktoolCommandSelection,
type ApktoolClientIdentity,
} from "./ApktoolConfiguration.js";
import {
parseApktoolYml,
parseDecodedStrings,
parseManifestPackage,
} from "./ApktoolDecodeOutput.js";
/** Stable identity for the Apktool resource-decoding provider. */
export const APKTOOL_PROVIDER_IDENTITY = {
id: "apktool",
name: "Apktool",
version: null,
} as const;
const DECODE_TIMEOUT_MS = 600_000;
const DECODE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
const MANIFEST_MAX_BYTES = 512 * 1024;
const STRINGS_XML_MAX_BYTES = 8 * 1024 * 1024;
const MAX_STRINGS = 2_000;
const MAX_DECODED_FILES_COUNTED = 100_000;
const STDERR_EXCERPT_BYTES = 512;
/** Stream a local file digest without retaining large APKs in memory. */
const sha256File = async (path: string): Promise<string> => {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
};
/** Read at most the byte budget plus one byte, before decoding UTF-8. */
const readBoundedText = async (
path: string,
budget: number,
): Promise<{ text: string | null; truncated: boolean }> => {
const file = await open(path, "r").catch((cause: unknown) => {
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
throw cause;
});
if (file === null) return { text: null, truncated: false };
try {
const bytes = Buffer.alloc(budget + 1);
let length = 0;
while (length < bytes.length) {
const result = await file.read(
bytes,
length,
bytes.length - length,
null,
);
if (result.bytesRead === 0) break;
length += result.bytesRead;
}
const truncated = length > budget;
// A prefix may end within a UTF-8 character; omit those trailing bytes.
const decoder = new TextDecoder("utf-8");
return {
text: decoder.decode(bytes.subarray(0, Math.min(length, budget)), {
stream: truncated,
}),
truncated,
};
} finally {
await file.close();
}
};
const localeQualifier = (locale: string): string =>
locale.replace(/^([a-z]{2,3})-([A-Z]{2}|[0-9]{3})$/u, "$1-r$2");
interface DecodeLimits {
readonly timeoutMs: number;
readonly maxOutputBytes: number;
}
const runApktool = async (
operation: string,
command: string,
arguments_: readonly string[],
limits: DecodeLimits,
signal: AbortSignal | undefined,
): Promise<Result<{ stdout: string; stderr: string }, AnalysisError>> => {
if (signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
try {
return ok(
await execFileOutput(command, 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(`apktool failed for ${operation}`, { cause }),
);
if (failure.outputTruncated)
return err(
new AnalysisResourceConstraintError(
operation,
"memory",
`apktool 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 (failure.killed)
return err(
new AnalysisTimeoutError(operation, limits.timeoutMs, { cause }),
);
return err(
new AnalysisUnsupportedTargetError(
operation,
command,
excerpt === ""
? `apktool exited with code ${String(failure.code)} for ${operation}`
: excerpt,
{ cause },
),
);
}
};
/** Count files in the decoded workspace with a hard ceiling. */
const countDecodedFiles = async (
root: string,
): Promise<{ count: number; capped: boolean }> => {
let count = 0;
let capped = false;
const walk = async (directory: string): Promise<void> => {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (count >= MAX_DECODED_FILES_COUNTED) {
capped = true;
return;
}
const path = join(directory, entry.name);
if (entry.isDirectory()) await walk(path);
else if (entry.isFile()) count += 1;
}
};
await walk(root);
return { count, capped };
};
/** Locale codes present as res/values-<locale>/strings.xml. */
const listStringLocales = async (
workspace: string,
): Promise<readonly string[]> => {
const res = join(workspace, "res");
const entries = await readdir(res, { withFileTypes: true }).catch(() => []);
const locales: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const match = /^values-(.+)$/u.exec(entry.name);
if (match === null) continue;
const strings = join(res, entry.name, "strings.xml");
if (
await access(strings).then(
() => true,
() => false,
)
)
locales.push(match[1]!);
}
return locales.sort();
};
export interface ApktoolProviderOptions {
readonly environment?: Readonly<Record<string, string | undefined>>;
readonly removeWorkspace?: (path: string) => Promise<void>;
}
/**
* Apktool resource decoding over a caller-selected launcher.
*
* Decoding happens in a provider-owned temporary workspace that is removed
* once the requested facts are projected; smali production is skipped
* (`--no-src`) because Java-source recovery belongs to the JADX family.
*/
export class ApktoolProvider {
private readonly environment: Readonly<Record<string, string | undefined>>;
private readonly workspaces = new Set<string>();
private readonly removeWorkspace: (path: string) => Promise<void>;
constructor(options: ApktoolProviderOptions = {}) {
this.environment = options.environment ?? process.env;
this.removeWorkspace =
options.removeWorkspace ??
((path) => rm(path, { recursive: true, force: true }));
}
private selection(): ApktoolCommandSelection {
return resolveApktoolCommand(this.environment);
}
/** Probe the selected apktool launcher; decodes nothing. */
async inspectAvailability(
signal?: AbortSignal,
): Promise<ProviderAvailability> {
const selection = this.selection();
try {
await inspectApktoolClient(selection, signal);
return {
status: "available",
code: null,
reason: null,
diagnostics: {
selected_command: selection.command,
command_source: selection.commandSource,
},
};
} catch (cause) {
if (cause instanceof ApktoolConfigurationFailure)
return {
status: "unavailable",
code:
cause.code === "command_missing"
? "executable_missing"
: cause.code === "not_absolute"
? "not_configured"
: "version_unresolved",
reason: cause.message,
diagnostics: { ...cause.diagnostics },
};
throw cause;
}
}
/** Retain failed cleanup ownership and retry it on close. */
async close(): Promise<void> {
const failures: unknown[] = [];
for (const path of this.workspaces) {
try {
await this.removeWorkspace(path);
this.workspaces.delete(path);
} catch (cause) {
failures.push(cause);
}
}
if (failures.length > 0)
throw new AggregateError(failures, "Apktool workspace cleanup failed");
}
async execute(
request: ApktoolRequest,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>> {
const signal = options?.signal;
if (signal?.aborted === true)
return err(new AnalysisCancelledError(request.operation));
const selection = this.selection();
let identity: ApktoolClientIdentity;
try {
identity = await inspectApktoolClient(selection, signal);
} catch (cause) {
if (cause instanceof AnalysisCancelledError) return err(cause);
if (cause instanceof ApktoolConfigurationFailure)
return err(
new AnalysisCapabilityUnavailableError(
APKTOOL_PROVIDER_IDENTITY.id,
request.operation,
typeof cause.diagnostics.remediation === "string"
? `${cause.message} ${cause.diagnostics.remediation}`
: cause.message,
{ cause },
),
);
return err(
new AnalysisProtocolError(
`apktool version probe failed for ${request.operation}`,
{ cause },
),
);
}
const client = {
command: selection.command,
command_source: selection.commandSource,
apktool_version: identity.version,
};
const provider = {
...APKTOOL_PROVIDER_IDENTITY,
version: identity.version,
};
if (request.operation === "inspect_apktool_client")
return ok(createAnalysisExecution({ client }, provider));
return this.decodeResources(
request.operation,
selection,
client,
provider,
request.input,
signal,
);
}
private async decodeResources(
operation: "decode_android_resources",
selection: ApktoolCommandSelection,
client: {
command: string;
command_source: string;
apktool_version: string | null;
},
provider: { id: string; name: string; version: string | null },
input: {
readonly path: string;
readonly include_strings: boolean;
readonly locale?: string | undefined;
},
signal: AbortSignal | undefined,
): Promise<Result<AnalysisExecution, AnalysisError>> {
const info = await stat(input.path).then(
(value) => value,
() => null,
);
if (info === null || !info.isFile())
return err(
new AnalysisInputError(operation, {
cause: new Error(
`The selected APK is not a readable file: ${input.path}`,
),
}),
);
const readable = await access(input.path, fsConstants.R_OK).then(
() => true,
() => false,
);
if (!readable)
return err(
new AnalysisInputError(operation, {
cause: new Error(`The selected APK is not readable: ${input.path}`),
}),
);
const root = await mkdtemp(join(tmpdir(), "rea-apktool-"));
this.workspaces.add(root);
const workspace = join(root, "decoded");
const snapshot = join(root, "input.apk");
const framework = join(root, "framework");
let cleanupFailure: string | null = null;
// The workspace is removed before the result is returned, so an
// unremovable workspace is reported instead of silently abandoned.
const removeWorkspace = async (): Promise<void> => {
try {
await this.removeWorkspace(root);
this.workspaces.delete(root);
} catch {
cleanupFailure = `The decode workspace could not be removed: ${root}`;
}
};
const failureWithCleanup = (failure: AnalysisError): AnalysisError =>
cleanupFailure === null
? failure
: failure instanceof AnalysisCancelledError
? new AnalysisCancelledError(operation, {
cause: failure,
cleanup: { reason: cleanupFailure, resources: [root] },
})
: new AnalysisProtocolError(failure.message, {
cause: failure,
cleanup: { reason: cleanupFailure, resources: [root] },
});
try {
await copyFile(input.path, snapshot, fsConstants.COPYFILE_EXCL);
await chmod(snapshot, 0o400);
const snapshotInfo = await stat(snapshot);
const sha256 = await sha256File(snapshot);
await mkdir(workspace);
await mkdir(framework);
const decoded = await runApktool(
operation,
selection.command,
[
"d",
"--no-src",
"-f",
"-o",
workspace,
"--frame-path",
framework,
snapshot,
],
{
timeoutMs: DECODE_TIMEOUT_MS,
maxOutputBytes: DECODE_MAX_OUTPUT_BYTES,
},
signal,
);
if (!decoded.ok) {
await removeWorkspace();
return err(failureWithCleanup(decoded.error));
}
const yml = await readBoundedText(
join(workspace, "apktool.yml"),
MANIFEST_MAX_BYTES,
);
if (yml.text === null || yml.truncated) {
throw new AnalysisProtocolError(
`apktool produced missing or oversized apktool.yml: ${input.path}`,
);
}
const metadata = parseApktoolYml(yml.text);
const manifestRead = await readBoundedText(
join(workspace, "AndroidManifest.xml"),
MANIFEST_MAX_BYTES,
);
const manifest = manifestRead.text;
const packageName =
manifest === null ? null : parseManifestPackage(manifest);
const locales = await listStringLocales(workspace);
const stringsDirectory =
input.locale === undefined
? "values"
: `values-${localeQualifier(input.locale)}`;
const stringsRead = input.include_strings
? await readBoundedText(
join(workspace, "res", stringsDirectory, "strings.xml"),
STRINGS_XML_MAX_BYTES,
)
: { text: null, truncated: false };
// Do not parse an incomplete XML document as though its projection were complete.
const stringsXml = stringsRead.truncated ? null : stringsRead.text;
const parsedStrings =
stringsXml === null
? { entries: [], unparsedCount: 0 }
: parseDecodedStrings(stringsXml);
const bounded = parsedStrings.entries.slice(0, MAX_STRINGS);
const files = await countDecodedFiles(workspace);
const coverage =
(stringsXml !== null && parsedStrings.entries.length > MAX_STRINGS) ||
manifestRead.truncated ||
stringsRead.truncated ||
parsedStrings.unparsedCount > 0 ||
files.capped
? "partial"
: "complete";
await removeWorkspace();
return ok(
createAnalysisExecution(
{
client,
target: { path: input.path, bytes: snapshotInfo.size, sha256 },
metadata: {
version_name: metadata.versionName,
version_code: metadata.versionCode,
min_sdk_version: metadata.minSdkVersion,
target_sdk_version: metadata.targetSdkVersion,
package_name: packageName,
},
manifest,
strings: bounded,
locale: input.locale ?? null,
locales,
decoded_file_count: files.count,
coverage,
},
provider,
{
limitations: [
"Decoding skips smali (--no-src); Java-source recovery belongs to the JADX family",
...(input.locale !== undefined &&
stringsRead.text === null &&
input.include_strings
? [`No strings.xml was found for locale ${input.locale}`]
: []),
...(input.include_strings &&
stringsRead.text === null &&
input.locale === undefined
? [
"No default res/values/strings.xml was found in the decoded workspace",
]
: []),
...(parsedStrings.unparsedCount > 0
? [
`${String(parsedStrings.unparsedCount)} string entries with nested markup were not projected`,
]
: []),
...(parsedStrings.entries.length > MAX_STRINGS
? [
`strings reports the first ${String(MAX_STRINGS)} of ${String(parsedStrings.entries.length)} entries`,
]
: []),
...(manifestRead.truncated
? [
`manifest is truncated at ${String(MANIFEST_MAX_BYTES)} bytes`,
]
: []),
...(stringsRead.truncated
? [
`strings.xml exceeds the ${String(STRINGS_XML_MAX_BYTES)}-byte projection budget`,
]
: []),
...(files.capped
? [
`decoded_file_count is capped at ${String(MAX_DECODED_FILES_COUNTED)}`,
]
: []),
...(cleanupFailure === null ? [] : [cleanupFailure]),
],
},
),
);
} catch (cause) {
await removeWorkspace();
return err(
failureWithCleanup(
cause instanceof AnalysisCancelledError ||
cause instanceof AnalysisProtocolError
? cause
: new AnalysisProtocolError("Apktool resource projection failed", {
cause,
}),
),
);
}
}
}
@@ -0,0 +1,23 @@
import type {
AnalysisExecution,
ExecutionOptions,
ProviderAvailability,
} from "../AnalysisProvider.js";
import type { ApktoolRequest } from "../../domain/apktool/apktoolResourceAnalysis.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Result } from "../../domain/result.js";
/**
* Apktool resource-decoding boundary over a caller-selected launcher.
* Implementation details belong to the provider adapter.
*/
export interface ApktoolResourceAnalysisPort {
/** Probe the selected apktool launcher without decoding anything. */
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability>;
/** The provider owns only per-call workspaces; safe to call always. */
close(): Promise<void>;
execute(
request: ApktoolRequest,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>>;
}
@@ -0,0 +1,85 @@
import { expect, it } from "vitest";
import { ApktoolResourceAnalysisService } from "./ApktoolResourceAnalysisService.js";
import type { ApktoolResourceAnalysisPort } from "./ApktoolResourceAnalysisPort.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: "apktool", name: "Apktool", version: null };
const execution = createAnalysisExecution(
{
client: {
command: "/usr/bin/apktool",
command_source: "path",
apktool_version: "2.7.0-dirty",
},
},
identity,
);
const refusedPort: ApktoolResourceAnalysisPort = {
async inspectAvailability() {
throw new Error("must not probe");
},
async close() {},
async execute() {
throw new Error("must not execute");
},
};
it.each([
[{ path: "/targets/app.apk", include_strings: "yes" }],
[{ path: "/targets/app.apk", locale: "not a locale" }],
[{ path: "/targets/app.apk", extra: true }],
])("rejects invalid decode selection before any effect: %j", async (input) => {
const service = new ApktoolResourceAnalysisService(refusedPort);
expect(
await service.execute("decode_android_resources", input),
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
});
it("composes observed Evidence with the caller parameters inline", async () => {
const port: ApktoolResourceAnalysisPort = {
async inspectAvailability() {
return { status: "available", code: null, reason: null, diagnostics: {} };
},
async close() {},
async execute(request) {
expect(request).toEqual({
operation: "decode_android_resources",
input: { path: "/targets/app.apk", include_strings: true },
});
return ok(execution);
},
};
const result = await new ApktoolResourceAnalysisService(port).execute(
"decode_android_resources",
{ path: "/targets/app.apk", include_strings: true },
);
if (!result.ok) throw new Error(result.error.message);
const evidence = parseEvidence(result.value);
expect(evidence.provider).toEqual(identity);
expect(evidence.operation).toBe("decode_android_resources");
expect(evidence.parameters).toEqual({
path: "/targets/app.apk",
include_strings: true,
});
expect(evidence.confidence).toBe("observed");
});
it("preserves cancellation before execution", async () => {
const controller = new AbortController();
controller.abort();
const service = new ApktoolResourceAnalysisService(refusedPort);
const result = await service.execute(
"decode_android_resources",
{ path: "/targets/app.apk", include_strings: true },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
@@ -0,0 +1,46 @@
import type { ApktoolResourceAnalysisPort } from "./ApktoolResourceAnalysisPort.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
apktoolRequestSchema,
type ApktoolOperation,
} from "../../domain/apktool/apktoolResourceAnalysis.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 Apktool. */
export class ApktoolResourceAnalysisService {
constructor(readonly provider: ApktoolResourceAnalysisPort) {}
/** Run one caller-selected Apktool request and retain target identity inline. */
async execute(
operation: ApktoolOperation,
input: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(operation));
const request = apktoolRequestSchema.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",
}),
);
}
}
+2
View File
@@ -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 { registerApktoolCommands } from "./cli/apktoolCommands.js";
import { registerJebCommands } from "./cli/jebCommands.js";
import { registerAdbCommands } from "./cli/adbCommands.js";
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
@@ -75,6 +76,7 @@ export const createCli = (
registerArtifactCommands(cli, logger, analysis.runProviderAnalysis);
registerManagedCommands(cli, logger, analysis.runProviderAnalysis);
registerAndroidCommands(cli, logger, environment);
registerApktoolCommands(cli, logger, environment);
registerJebCommands(cli, logger, environment);
registerAdbCommands(cli, logger, environment);
registerFirmwareCommands(cli, logger, environment);
+77
View File
@@ -0,0 +1,77 @@
import { z } from "incur";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js";
import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { ApktoolOperation } from "../domain/apktool/apktoolResourceAnalysis.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
/** Expose the same explicit Apktool requests through one-shot CLI commands. */
export const registerApktoolCommands = (
cli: CliInstance,
logger: Logger,
environment: Readonly<Record<string, string | undefined>>,
provider: ApktoolResourceAnalysisPort = createApktoolResourceAnalysisProvider(
environment,
),
): void => {
const service = new ApktoolResourceAnalysisService(provider);
const execute = (name: string, operation: ApktoolOperation, 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);
}),
);
cli.command(CLI_COMMANDS.inspectApktoolClient, {
description:
"Inspect the caller-selected apktool launcher without decoding anything",
args: z.object({}),
run: () =>
execute(CLI_COMMANDS.inspectApktoolClient, "inspect_apktool_client", {}),
});
cli.command(CLI_COMMANDS.decodeAndroidResources, {
description:
"Decode one APK's manifest, metadata, and string resources with apktool",
args: z.object({
path: z.string().min(1).describe("Local APK file to decode"),
}),
options: z.object({
locale: z
.string()
.min(2)
.max(16)
.regex(
/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/u,
"Locale must look like de or pt-BR",
)
.optional()
.describe(
"Project strings from res/values-<locale> instead of the default",
),
"no-strings": z
.boolean()
.default(false)
.describe("Skip projecting string resources"),
}),
run: ({ args, options }) =>
execute(CLI_COMMANDS.decodeAndroidResources, "decode_android_resources", {
path: args.path,
include_strings: !options["no-strings"],
...(options.locale === undefined ? {} : { locale: options.locale }),
}),
});
};
+2
View File
@@ -46,6 +46,8 @@ export const CLI_COMMANDS = Object.freeze({
inspectFirmwareRegions: "inspect-firmware-regions",
extractFirmware: "extract-firmware",
inspectAndroidPackage: "inspect-android-package",
inspectApktoolClient: "inspect-apktool-client",
decodeAndroidResources: "decode-android-resources",
inspectAdbClient: "inspect-adb-client",
listAdbDevices: "list-adb-devices",
inspectAdbDevice: "inspect-adb-device",
+7
View File
@@ -0,0 +1,7 @@
import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js";
import { ApktoolProvider } from "../apktool/ApktoolProvider.js";
/** Construct one Apktool provider bound to the caller-selected launcher. */
export const createApktoolResourceAnalysisProvider = (
environment: Readonly<Record<string, string | undefined>> = process.env,
): ApktoolResourceAnalysisPort => new ApktoolProvider({ environment });
@@ -0,0 +1,42 @@
import {
apktoolInputSchemas,
apktoolResultSchemas,
} from "../../domain/apktool/apktoolResourceAnalysis.js";
import type { ToolContract } from "../toolContractTypes.js";
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
import { toolContractMetadata } from "../toolEffects.js";
/** Apktool resource decoding served by a caller-supplied launcher. */
export const APKTOOL_TOOL_CONTRACTS = [
{
name: "inspect_apktool_client",
...toolContractMetadata("inspect_apktool_client"),
description:
"Inspect the caller-selected apktool launcher: resolved command, selection source, and version. Decodes nothing. REA never installs Apktool or Java; select a launcher with REA_APKTOOL_COMMAND (absolute) or apktool on PATH.",
kind: "apktool-provider",
inputSchema: apktoolInputSchemas.inspect_apktool_client,
outputSchema: evidenceResultOf(apktoolResultSchemas.inspect_apktool_client),
examples: [{ title: "Check the Apktool launcher", input: {} }],
},
{
name: "decode_android_resources",
...toolContractMetadata("decode_android_resources"),
description:
"Decode one local APK's resources with `apktool d --no-src` in a provider-owned workspace that is removed after projection: the readable AndroidManifest.xml, apktool.yml version and SDK facts, the app's string resources (default or one locale, bounded to 2000 entries), available string locales, and the decoded file count. The target APK is digest with SHA-256 before decoding; smali is skipped because Java-source recovery belongs to the JADX family.",
kind: "apktool-provider",
inputSchema: apktoolInputSchemas.decode_android_resources,
outputSchema: evidenceResultOf(
apktoolResultSchemas.decode_android_resources,
),
examples: [
{
title: "Decode an APK's resources",
input: { path: "/targets/Example.apk" },
},
{
title: "Decode one locale's strings",
input: { path: "/targets/Example.apk", locale: "de" },
},
],
},
] as const satisfies readonly ToolContract[];
+1
View File
@@ -25,6 +25,7 @@ export const TOOL_KINDS = [
"artifact-provider",
"managed-provider",
"android-provider",
"apktool-provider",
"jeb-provider",
"adb-provider",
"firmware-provider",
+2
View File
@@ -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 { APKTOOL_TOOL_CONTRACTS } from "./apktool/apktoolToolContracts.js";
import { JEB_TOOL_CONTRACTS } from "./jeb/jebToolContracts.js";
import { ADB_TOOL_CONTRACTS } from "./adb/adbToolContracts.js";
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managed/managedWorkflowToolContracts.js";
@@ -32,6 +33,7 @@ export const TOOL_CONTRACTS = [
...EVM_TOOL_CONTRACTS,
...MANAGED_TOOL_CONTRACTS,
...ANDROID_TOOL_CONTRACTS,
...APKTOOL_TOOL_CONTRACTS,
...JEB_TOOL_CONTRACTS,
...ADB_TOOL_CONTRACTS,
...FIRMWARE_TOOL_CONTRACTS,
+9
View File
@@ -98,6 +98,15 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
launchesProcess: true,
writesFilesystem: true,
}),
inspect_apktool_client: effects({
mutatesSession: true,
launchesProcess: true,
}),
decode_android_resources: effects({
mutatesSession: true,
launchesProcess: true,
writesFilesystem: true,
}),
inspect_jeb_client: effects({
mutatesSession: true,
accessesNetwork: true,
@@ -0,0 +1,103 @@
import { z } from "zod";
import { localPathStringSchema } from "../localPath.js";
import { digestSchema } from "../digests.js";
/** Upper bound for one decoded resource projection in one response. */
const MAX_MANIFEST_BYTES = 512 * 1024;
const MAX_STRINGS = 2_000;
/**
* Caller intent for Apktool-backed resource decoding. Apktool is
* bring-your-own: REA never installs it, and decoding happens in a
* provider-owned workspace that is removed after the facts are projected.
*/
export const apktoolInputSchemas = {
inspect_apktool_client: z.strictObject({}),
decode_android_resources: z.strictObject({
path: localPathStringSchema.describe("Local APK file to decode"),
include_strings: z
.boolean()
.default(true)
.describe(
`Project res/values/strings.xml entries (bounded to ${String(MAX_STRINGS)} entries)`,
),
locale: z
.string()
.min(2)
.max(16)
.regex(
/^(?:[a-z]{2,3}(?:-(?:r?[A-Z]{2}|r?[0-9]{3}))?|b\+[a-z]{2,3}(?:\+[A-Za-z0-9]{2,8})*)$/u,
"Locale must look like de, pt-BR, pt-rBR, or b+zh+Hans+CN",
)
.optional()
.describe(
"Project strings from res/values-<locale>/strings.xml instead of the default table",
),
}),
} as const;
/** Supported Apktool-backed operations. */
export type ApktoolOperation = keyof typeof apktoolInputSchemas;
/** Validated Apktool requests carry their selected operation. */
export type ApktoolRequest = {
[Name in ApktoolOperation]: {
operation: Name;
input: z.infer<(typeof apktoolInputSchemas)[Name]>;
};
}[ApktoolOperation];
/** Validate operation and input together so their types remain correlated. */
export const apktoolRequestSchema = z.discriminatedUnion("operation", [
z.strictObject({
operation: z.literal("inspect_apktool_client"),
input: apktoolInputSchemas.inspect_apktool_client,
}),
z.strictObject({
operation: z.literal("decode_android_resources"),
input: apktoolInputSchemas.decode_android_resources,
}),
]);
const client = z.strictObject({
command: z.string().min(1),
command_source: z.enum(["environment", "path"]),
apktool_version: z.string().min(1).nullable(),
});
/** Portable Apktool observations with explicit coverage limitations. */
export const apktoolResultSchemas = {
inspect_apktool_client: z.strictObject({
client,
}),
decode_android_resources: z.strictObject({
client,
target: z.strictObject({
path: localPathStringSchema,
bytes: z.number().int().nonnegative(),
sha256: digestSchema,
}),
metadata: z.strictObject({
version_name: z.string().nullable(),
version_code: z
.string()
.nullable()
.describe(
"Version code exactly as apktool.yml prints it, e.g. '45' or '45.1'",
),
min_sdk_version: z.string().nullable(),
target_sdk_version: z.string().nullable(),
package_name: z.string().nullable(),
}),
manifest: z.string().nullable(),
strings: z.array(
z.strictObject({
name: z.string().min(1),
value: z.string(),
}),
),
locale: z.string().nullable(),
locales: z.array(z.string().min(1)),
decoded_file_count: z.number().int().nonnegative(),
coverage: z.enum(["complete", "partial"]),
}),
} as const;
+22 -1
View File
@@ -64,13 +64,17 @@ 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 { registerApktoolTools } from "./registerApktoolTools.js";
import { registerJebTools } from "./registerJebTools.js";
import { registerAdbTools } from "./registerAdbTools.js";
import { AndroidAnalysisService } from "../application/android/AndroidAnalysisService.js";
import { JebAnalysisService } from "../application/jeb/JebAnalysisService.js";
import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysisPort.js";
import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js";
import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js";
import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
import { createAndroidAnalysisProvider } from "../composition/android.js";
import { createApktoolResourceAnalysisProvider } from "../composition/apktool.js";
import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js";
import { createJebAnalysisProvider } from "../composition/jeb.js";
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
@@ -109,6 +113,7 @@ export interface CreateServerOptions {
readonly webRuntime?: WebRuntimeService;
readonly webNetworkCapture?: WebNetworkCaptureService;
readonly androidAnalysis?: AndroidAnalysisPort;
readonly apktoolAnalysis?: ApktoolResourceAnalysisPort;
readonly jebAnalysis?: JebAnalysisPort;
readonly adbDeviceAnalysis?: AdbDeviceAnalysisPort;
readonly browserObservation?: BrowserObservationPort;
@@ -209,6 +214,9 @@ export const createServer = (
);
const android =
options.androidAnalysis ?? createAndroidAnalysisProvider(environment);
const apktool =
options.apktoolAnalysis ??
createApktoolResourceAnalysisProvider(environment);
const jeb = options.jebAnalysis ?? createJebAnalysisProvider(environment);
const adbDevice =
options.adbDeviceAnalysis ?? createAdbDeviceAnalysisProvider(environment);
@@ -246,6 +254,12 @@ export const createServer = (
"Android provider cleanup failed",
);
});
void apktool.close().catch((cause: unknown) => {
logger.error(
{ error: cause instanceof Error ? cause.message : String(cause) },
"Apktool provider cleanup failed",
);
});
void jeb.close().catch((cause: unknown) => {
logger.error(
{ error: cause instanceof Error ? cause.message : String(cause) },
@@ -258,6 +272,7 @@ export const createServer = (
const results = await Promise.allSettled([
closeServer(),
android.close(),
apktool.close(),
jeb.close(),
]);
for (const result of results)
@@ -270,6 +285,12 @@ export const createServer = (
toolLogger,
recordEvidence,
);
registerApktoolTools(
server,
new ApktoolResourceAnalysisService(apktool),
toolLogger,
recordEvidence,
);
registerObservationTools(toolContext);
registerGuidedPrompts(server, analysis, session);
if (session !== undefined) {
+44
View File
@@ -0,0 +1,44 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { ServerContext } from "@modelcontextprotocol/server";
import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { ToolContract } from "../contracts/toolContractTypes.js";
import type { ApktoolOperation } from "../domain/apktool/apktoolResourceAnalysis.js";
import type { Logger } from "pino";
import { logToolExecution } from "./toolLogging.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
/** Bind Apktool handlers to their exact named contracts. */
export const registerApktoolTools = (
server: EvidenceMcpServer,
service: ApktoolResourceAnalysisService,
logger: Logger,
recordEvidence?: EvidenceWriter["recordEvidence"],
): void => {
const handler = (contract: ToolContract<ApktoolOperation>) => {
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_apktool_client"),
toolContract("decode_android_resources"),
] as const) {
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
handler(contract),
);
}
};
+51
View File
@@ -0,0 +1,51 @@
import { expect, it } from "vitest";
import { createCli } from "../../../src/cli.js";
const serve = async (
argv: readonly string[],
environment: Readonly<Record<string, string | undefined>> = {},
): 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-apktool-client", "decode-android-resources"])(
"%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 apktool launcher with recovery guidance", async () => {
const result = await serve(["inspect-apktool-client", "--json"], {
REA_APKTOOL_COMMAND: "/nonexistent/apktool",
});
expect(result.stdout).toContain("capability_unavailable");
expect(result.stdout).toContain("REA_APKTOOL_COMMAND");
});
it("validates the locale option before launching apktool", async () => {
const result = await serve([
"decode-android-resources",
"/targets/app.apk",
"--locale",
"not a locale",
"--json",
]);
expect(result.stdout).toContain("invalid");
});
+108
View File
@@ -0,0 +1,108 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it, onTestFinished } from "vitest";
import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.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: "apktool", name: "Apktool", version: "2.7.0-dirty" };
const clientIdentity = {
command: "/usr/bin/apktool",
command_source: "path",
apktool_version: "2.7.0-dirty",
};
const results: Readonly<Record<string, unknown>> = {
inspect_apktool_client: { client: clientIdentity },
decode_android_resources: {
client: clientIdentity,
target: {
path: "/targets/probe.apk",
bytes: 8524,
sha256: "a".repeat(64),
},
metadata: {
version_name: "1.2.3",
version_code: "7",
min_sdk_version: "24",
target_sdk_version: "34",
package_name: "com.rea.apktool.probe",
},
manifest:
'<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.rea.apktool.probe"/>',
strings: [{ name: "app_name", value: "ApktoolProbe" }],
locale: null,
locales: ["de"],
decoded_file_count: 5,
coverage: "complete",
},
};
it("advertises exact valid Apktool schemas and records inline evidence through MCP", async () => {
const provider: ApktoolResourceAnalysisPort = {
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 },
{ apktoolAnalysis: provider },
);
onTestFinished(() => server.close());
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "apktool-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_apktool_client",
"decode_android_resources",
] 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;
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 decoded = await client.callTool({
name: "decode_android_resources",
arguments: { path: "/targets/probe.apk" },
});
expect(decoded.isError).not.toBe(true);
const text = (decoded.content as { readonly text?: string }[])
.map((part) => part.text ?? "")
.join("");
const evidence = parseEvidence(JSON.parse(text));
expect(evidence.provider.id).toBe("apktool");
expect(evidence.normalized_result).toMatchObject({
metadata: { package_name: "com.rea.apktool.probe", version_name: "1.2.3" },
coverage: "complete",
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ const TEST_PROJECTS = [
name: "adapters",
include: [
"src/*.test.ts",
"src/{jeb,adb,artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts",
"src/{apktool,jeb,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,