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