diff --git a/Cargo.lock b/Cargo.lock index 7b416310..c8a7180b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,6 +108,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -210,6 +219,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -219,6 +237,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dalvik" version = "0.2.0" @@ -244,6 +272,16 @@ dependencies = [ "scroll_derive 0.9.5", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -287,8 +325,10 @@ name = "flutterdec-adapter" version = "0.1.0-alpha.4" dependencies = [ "anyhow", + "flutterdec-loader", "serde", "serde_json", + "sha2", "tempfile", ] @@ -339,6 +379,7 @@ version = "0.1.0-alpha.4" dependencies = [ "capstone", "flutterdec-adapter", + "flutterdec-loader", "regex", "serde", ] @@ -370,6 +411,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -780,6 +831,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -867,6 +929,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 6a5e54dd..18ada707 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ regex = "1" goblin = "0.10" zip = { version = "8", default-features = false, features = ["deflate"] } tempfile = "3" +sha2 = "0.10" walkdir = "2" capstone = "0.14" cpp_demangle = "0.5" diff --git a/README.md b/README.md index f6f4d2b9..41c9d4e9 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,11 @@ For APK inputs, `info` reports Android startup summary fields such as: If adapter metadata is available, `info` also reports package and compatibility signals such as: - `app_package_counts_top` -- `adapter_kind` -- `adapter_snapshot_hash_match` +- `requested_backend`, `resolved_backend`, `backend_fallback_reason` +- `producer_id`, `producer_trust`, `compatibility_record_sha256` +- `snapshot_identity_is_exact` +- `identity_rejection` (why no adapter could be selected, when none could) +- `model_capabilities` - `compatibility_warnings` 2. Install the adapter for the detected Dart hash: @@ -359,19 +362,26 @@ Adapter backend selection: - `--adapter-backend internal`: force the internal adapter only - `--adapter-backend blutter`: require the Blutter backend and fail if unavailable - `--adapter-backend r2-flutter`: require the r2flutter backend and fail if unavailable -- `--require-snapshot-hash-match`: fail when the adapter-reported snapshot hash does not match the loader snapshot hash +- `--require-snapshot-hash-match`: fail unless the snapshot identity came from a real header What the backends actually recover: | Backend | Function names | Classes | ObjectPool | | --- | --- | --- | --- | -| `internal` | none (`sub_` placeholders) | none | carved strings, no real index space | -| `blutter` | exact, from Blutter dumps | yes | Blutter `pp.txt` entries | -| `r2flutter` | exact, from the AOT instruction table | yes, with fields and methods | real slots, resolvable from `x27` displacements | +| `internal` | none at all; code ranges are unnamed | none | carved strings, ordinal index space | +| `blutter` | scraped from Blutter's rendered source, heuristic | yes | Blutter `pp.txt` entries, ordinal index space | +| `r2flutter` | exact, from the AOT instruction table | yes, library attribution unavailable | real slots, resolvable from `x27` displacements | -Only backends that recover the real `ObjectPool` layout report `pool_geometry`. Without -it `flutterdec` leaves pool references unresolved rather than attaching a value from an -unrelated index space, and says so in `report.json.pool_metadata.hints_suppressed_reason`. +The model says which of these it did: every domain carries a capability level +(`complete` / `partial` / `unavailable`) and every recovered fact carries a provenance +(`exact` / `derived` / `heuristic`). A function whose name was not recovered has no +name rather than a `sub_` placeholder, and `flutterdec` labels it from its entry +address at emit time. + +Only a backend that recovers the real `ObjectPool` layout claims a hardware index +space. Without one, `flutterdec` leaves pool references unresolved rather than +attaching a value from an unrelated index space, and says so in +`report.json.pool_metadata.hints_suppressed_reason`. r2flutter backend environment knobs: @@ -424,7 +434,7 @@ Main outputs under `-o `: - `android_startup.dart_entrypoints` entries can carry `function_name`, `library_uri`, and `app_bundle_path` when those values are directly recoverable from APK bytecode or simple helper return propagation - `android_startup.bootstrap_chain` summarizes observed Android embedder startup stages per source method, including ownership, stage ordering, completeness, and missing steps - `engine_symbol_ingestion` for auto-loaded local engine symbol cache matches keyed by `libflutter.so` build id -- `bootflow_discovery` entries tagged by `source` (`adapter`, `manifest`, `apk_startup`) +- `bootflow_discovery` entries tagged by `source` (`android_manifest`, `apk_startup`, `model_name_pattern`) and by `provenance` (`derived` or `heuristic`, never exact) ## See The Pipeline diff --git a/adapters/python/adapter_template.py b/adapters/python/adapter_template.py index c14a2659..fb63b1ef 100644 --- a/adapters/python/adapter_template.py +++ b/adapters/python/adapter_template.py @@ -1,8 +1,22 @@ #!/usr/bin/env python3 +"""Checked-in reference producer for the flutterdec adapter boundary. + +One invocation reads an adapter protocol v1 request, runs exactly one backend, +and writes a ProgramModel v4 plus a protocol v1 result. There is no v2/v3 path. + +The rule every backend here follows is that an unrecovered fact is absent, not +invented. A function whose name was not recovered has no name; a class whose +library was not recovered has no library; a producer that carved strings out of +the data image says its pool indexes are ordinal and its index space is +unavailable, rather than handing the host positions that look like `ObjectPool` +entries. Every domain that came back empty carries a diagnostic saying so, so +"nothing was there" and "we did not look" stay distinguishable. +""" from __future__ import annotations import argparse import fcntl +import hashlib import json import os import re @@ -15,10 +29,250 @@ from pathlib import Path from typing import Dict, List, Optional, Set, Tuple +PROTOCOL_MAJOR = 1 +MODEL_VERSION = 4 -def _read_bytes(path: str) -> bytes: - with open(path, "rb") as f: - return f.read() +VM_DATA = "vm_data" +ISOLATE_DATA = "isolate_data" +VM_INSTRUCTIONS = "vm_instructions" +ISOLATE_INSTRUCTIONS = "isolate_instructions" + +DOMAINS = ( + "libraries", + "classes", + "class_relationships", + "functions", + "function_names", + "object_pool", + "pool_index_space", +) + +COMPLETE = "complete" +PARTIAL = "partial" +UNAVAILABLE = "unavailable" + +EXACT = "exact" +DERIVED = "derived" +HEURISTIC = "heuristic" + +# Mirrors `validate::PLACEHOLDER_NAMES`. A carved string that is one of these is +# an admission of ignorance wearing a value's clothes, and the host rejects the +# whole model over one of them, so they are filtered at the source. +PLACEHOLDER_NAMES = frozenset( + [ + "", + "-", + "?", + "??", + "???", + "n/a", + "na", + "none", + "null", + "nil", + "todo", + "tbd", + "unknown", + "", + "unnamed", + "anonymous", + "placeholder", + "undefined", + ] +) + + +class BackendUnavailable(Exception): + """The backend's tooling is not installed. Distinct from it failing.""" + + +class BackendFailed(Exception): + """The backend ran and could not produce a model.""" + + +def _is_placeholder(text: str) -> bool: + return text.strip().lower() in PLACEHOLDER_NAMES + + +def _usable_value(text: str) -> bool: + return bool(text) and not _is_placeholder(text) + + +# -------------------------------------------------------------------------- +# protocol v1 +# -------------------------------------------------------------------------- + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _load_request(path: Path) -> dict: + try: + request = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"request is not readable JSON: {exc}") from exc + if request.get("protocol_major") != PROTOCOL_MAJOR: + raise ValueError( + f"unsupported protocol major {request.get('protocol_major')!r}; this adapter implements {PROTOCOL_MAJOR}" + ) + if request.get("model_major") != MODEL_VERSION: + raise ValueError( + f"unsupported model major {request.get('model_major')!r}; this adapter emits {MODEL_VERSION}" + ) + for key in ("identity", "compatibility", "producer", "inputs", "output", "requested_backend"): + if key not in request: + raise ValueError(f"request is missing {key}") + return request + + +def _handle(request: dict, region: str) -> dict: + for handle in request["inputs"]: + if handle.get("region") == region: + return handle + raise ValueError(f"request omits input region {region}") + + +def _read_region(request: dict, region: str) -> bytes: + handle = _handle(request, region) + path = Path(handle["path"]) + try: + data = path.read_bytes() + except OSError as exc: + raise FileNotFoundError(f"input {region} is not readable: {exc}") from exc + # The digest is the point of the handle: without it "the adapter read the + # snapshot" only means "the adapter read a file". + if len(data) != handle["size"]: + raise ValueError( + f"input {region} is {len(data)} bytes, request declared {handle['size']}" + ) + actual = _sha256(data) + if actual != handle["sha256"]: + raise ValueError( + f"input {region} digest {actual} does not match declared {handle['sha256']}" + ) + return data + + +def _model_regions(request: dict) -> List[dict]: + """Echo the host's region table back verbatim. + + Anything else here is the adapter disagreeing with the host about what it + was given, which the host rejects rather than reconciles. + """ + return [ + { + "region": h["region"], + "size": h["size"], + "sha256": h["sha256"], + "virtual_address": h.get("virtual_address"), + "executable": h["executable"], + } + for h in request["inputs"] + ] + + +def _region_va(request: dict, region: str) -> int: + return int(_handle(request, region).get("virtual_address") or 0) + + +def _write_result( + path: Path, + status: str, + model: Optional[str] = None, + error: Optional[dict] = None, + resolved_backend: Optional[str] = None, + fallback_reason: Optional[str] = None, + diagnostics: Optional[List[dict]] = None, +) -> None: + payload = { + "protocol_major": PROTOCOL_MAJOR, + "model_major": MODEL_VERSION, + "status": status, + "model": model, + "error": error, + "resolved_backend": resolved_backend, + "fallback_reason": fallback_reason, + "diagnostics": diagnostics or [], + } + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +# -------------------------------------------------------------------------- +# model v4 assembly +# -------------------------------------------------------------------------- + + +def _diagnostic(code: str, message: str, subject: Optional[str] = None, + severity: str = "warning") -> dict: + return {"code": code, "severity": severity, "subject": subject, "message": message} + + +def _name(text: str, provenance: str) -> dict: + # `confidence` stays null everywhere in this file. A number here would look + # calibrated, and none of these backends has anything to calibrate against. + return {"text": text, "provenance": provenance, "confidence": None} + + +def _build_model( + request: dict, + capabilities: Dict[str, str], + libraries: List[dict], + classes: List[dict], + functions: List[dict], + object_pool: dict, + diagnostics: List[dict], +) -> dict: + """Assemble a v4 model and add the diagnostic every unavailable domain owes. + + Validation rejects an unavailable domain with nothing said about it, which + is deliberate: silence is indistinguishable from a producer that forgot to + look, and this is the one place that can tell the difference. + """ + explained = {d.get("subject") for d in diagnostics} + for domain in DOMAINS: + if capabilities[domain] == UNAVAILABLE and domain not in explained: + diagnostics.append( + _diagnostic( + "domain_not_recovered", + f"this backend recovered no {domain.replace('_', ' ')}", + subject=domain, + ) + ) + return { + "model_version": MODEL_VERSION, + "producer": request["producer"], + "input": { + "identity": request["identity"], + "regions": _model_regions(request), + }, + "compatibility": request["compatibility"], + "capabilities": capabilities, + "libraries": sorted(libraries, key=lambda v: v["id"]), + "classes": sorted(classes, key=lambda v: v["id"]), + "functions": sorted(functions, key=lambda v: v["id"]), + "object_pool": object_pool, + "diagnostics": diagnostics, + "extensions": {}, + } + + +def _ordinal_pool(entries: List[dict]) -> dict: + """A pool whose indexes are positions in a list, not hardware slots. + + `geometry` is absent and the index space is separately reported unavailable, + so a `ldr xN, [x27, #disp]` cannot be resolved through these. + """ + return {"index_space": "ordinal", "geometry": None, "entries": entries} + + +def _empty_pool() -> dict: + return _ordinal_pool([]) + + +# -------------------------------------------------------------------------- +# shared extraction +# -------------------------------------------------------------------------- def _extract_strings(data: bytes, min_len: int = 5, max_items: int = 20000) -> List[str]: @@ -27,6 +281,8 @@ def _extract_strings(data: bytes, min_len: int = 5, max_items: int = 20000) -> L s = m.group(0).decode("utf-8", errors="ignore") if len(s) > 220: continue + if not _usable_value(s): + continue out.append(s) if len(out) >= max_items: break @@ -41,15 +297,71 @@ def _extract_strings(data: bytes, min_len: int = 5, max_items: int = 20000) -> L return uniq -def _detect_snapshot_hash(vm_data: bytes, iso_data: bytes, fallback: str) -> str: - probe = (vm_data + iso_data)[:65536] - m = re.search(rb"([0-9a-f]{32})product\\s+no-code_comments", probe) - if m: - return m.group(1).decode("ascii", errors="ignore") - m2 = re.search(rb"\b([0-9a-f]{32})\b", probe) - if m2: - return m2.group(1).decode("ascii", errors="ignore") - return fallback +def _library_uris(strings: List[str]) -> List[str]: + """Library URIs that appear as strings in the image. + + There is no fallback. A snapshot whose data image contains no `package:` URI + yields no libraries, because the alternative is emitting one library named + after an app that may not exist. + """ + out: List[str] = [] + seen: Set[str] = set() + for s in strings: + if s.startswith("package:") and ".dart" in s and len(s) < 200 and s not in seen: + seen.add(s) + out.append(s) + return out[:512] + + +def _selector_from_string(s: str) -> Optional[str]: + t = s.strip() + if not t: + return None + lower = t.lower() + if ".dart" in lower or "/" in t or "\\" in t or "://" in t: + return None + if " " in t: + return None + if "@" in t: + t = t.split("@", 1)[0] + if ":" in t: + t = t.split(":", 1)[1] + t = t.strip() + if not t: + return None + cleaned = "".join(ch for ch in t if ch.isalnum() or ch in "._$") + if not cleaned: + return None + if len(cleaned) > 96: + return None + if not (cleaned[0].isalpha() or cleaned[0] in "_$"): + return None + return cleaned + + +def _normalize_library_uri(raw: str) -> Optional[str]: + t = raw.strip() + if not t: + return None + if t.startswith("package:") or t.startswith("dart:") or t.startswith("file:"): + return t + return None + + +def _sanitize_class_name(raw: str) -> Optional[str]: + t = raw.strip() + if not t: + return None + t = re.sub(r"<.*?>", "", t) + t = "".join(ch for ch in t if ch.isalnum() or ch in "_$") + if not t or _is_placeholder(t): + return None + return t + + +# -------------------------------------------------------------------------- +# internal backend: string carving plus prologue scanning +# -------------------------------------------------------------------------- def _decode_bl_target(pc: int, word: int) -> Optional[int]: @@ -57,7 +369,7 @@ def _decode_bl_target(pc: int, word: int) -> Optional[int]: return None imm26 = word & 0x03FFFFFF if imm26 & (1 << 25): - imm26 -= (1 << 26) + imm26 -= 1 << 26 return pc + (imm26 << 2) @@ -69,7 +381,13 @@ def _is_frame_prologue(word: int) -> bool: return is_store_pair and rt == 29 and rt2 == 30 and rn == 31 -def _recover_functions(instr: bytes, base_va: int) -> List[dict]: +def _recover_code_ranges(instr: bytes, base_va: int) -> List[dict]: + """Code ranges from frame prologues and call targets. + + Every range here is a guess about where a function begins, so each one is + `heuristic` and none of them carries a name: there is no name evidence in a + prologue, and `sub_1234` was never one. + """ starts: Set[int] = set() if base_va: starts.add(base_va) @@ -85,7 +403,6 @@ def _recover_functions(instr: bytes, base_va: int) -> List[dict]: tgt = _decode_bl_target(pc, word) if tgt is not None and base_va <= tgt < hi and (tgt - base_va) % 4 == 0: call_target_counts[tgt] = call_target_counts.get(tgt, 0) + 1 - if _is_frame_prologue(word): prologues.add(base_va + off) @@ -94,247 +411,100 @@ def _recover_functions(instr: bytes, base_va: int) -> List[dict]: if tgt in prologues or count >= 2: starts.add(tgt) - sorted_starts = sorted(starts) - funcs = [] + sorted_starts = sorted(s for s in starts if base_va <= s < hi) + out: List[dict] = [] for i, start in enumerate(sorted_starts): nxt = sorted_starts[i + 1] if i + 1 < len(sorted_starts) else hi - size = max(4, min(nxt - start, 0x8000)) if nxt > start else 128 - funcs.append( + size = min(nxt - start, 0x8000) + if size <= 0: + continue + out.append( { - "id": i, - "name": f"sub_{start:x}", - "owner_class": "Global", - "entry_va": int(start), - "size": int(size), + "id": len(out), + "name": None, + "owner": None, + "code": {"start_va": int(start), "size": int(size)}, "code_section_va": int(base_va), - "name_kind": "placeholder", + "provenance": HEURISTIC, } ) - return funcs - - -def _collect_libraries(strings: List[str]) -> List[str]: - out: List[str] = [] - seen: Set[str] = set() - for s in strings: - if s.startswith("package:") and ".dart" in s and len(s) < 200 and s not in seen: - seen.add(s) - out.append(s) - if not out: - out = ["package:app/main.dart"] - return out[:512] - - -def _selector_from_string(s: str) -> Optional[str]: - t = s.strip() - if not t: - return None - lower = t.lower() - if ".dart" in lower or "/" in t or "\\" in t or "://" in t: - return None - if " " in t: - return None - if "@" in t: - t = t.split("@", 1)[0] - if ":" in t: - t = t.split(":", 1)[1] - t = t.strip() - if not t: - return None - cleaned = "".join(ch for ch in t if ch.isalnum() or ch in "._$") - if not cleaned: - return None - if len(cleaned) > 96: - return None - if not (cleaned[0].isalpha() or cleaned[0] in "_$"): - return None - return cleaned - - -def _entrypoint_selector_from_name(name: str) -> Optional[str]: - selector = _selector_from_string(name or "") - if not selector: - return None - lower = selector.strip().lower() - if ( - lower == "main" - or lower.endswith(".main") - or lower.endswith("::main") - or lower.endswith("_main") - or lower == "runapp" - or lower.endswith(".runapp") - ): - return selector - return None + return out -def _bootflow_candidates_from_name( - name: str, owner_class: str, library_uri: str -) -> List[Tuple[str, str, str]]: - selector = _selector_from_string(name or "") - if not selector: - return [] - lower = selector.strip().lower() - owner_lower = (owner_class or "").strip().lower() - library_lower = (library_uri or "").strip().lower() - out: List[Tuple[str, str, str]] = [] - - if ( - lower == "main" - or lower.endswith(".main") - or lower.endswith("::main") - or lower.endswith("_main") - ): - out.append(("EntryPointCandidate", selector, f"entrypoint:{selector}")) - out.append(("BootMainCandidate", selector, f"bootflow:main:{selector}")) - - if lower == "runapp" or lower.endswith(".runapp"): - out.append(("EntryPointCandidate", selector, f"entrypoint:{selector}")) - out.append(("BootRunAppCandidate", selector, f"bootflow:runapp:{selector}")) - - if lower in { - "didpushrouteinformation", - "didpushroute", - "didpoproute", - "setnewroutepath", - "parserouteinformation", - "ongenerateroute", - "onunknownroute", - "onnewintent", - "handleintent", - }: - out.append(("DeepLinkHandlerCandidate", selector, f"bootflow:deeplink:{selector}")) - - looks_activity_owner = "activity" in owner_lower or "flutterjni" in owner_lower - looks_activity_lib = ( - "activity" in library_lower - or "android" in library_lower - or library_lower.startswith("package:flutter/src/embedding") - ) - if lower in {"onnewintent", "handleintent"}: - out.append(("ActivityHandlerCandidate", selector, f"bootflow:activity:{selector}")) - elif lower in {"oncreate", "onstart", "onresume", "onpause", "onstop"} and ( - looks_activity_owner or looks_activity_lib - ): - out.append(("ActivityHandlerCandidate", selector, f"bootflow:activity:{selector}")) - - bootstrap_like_owner = ( - "binding" in owner_lower - or "engine" in owner_lower - or "jni" in owner_lower - or "bootstrap" in owner_lower - or owner_lower == "global" - or owner_lower.endswith("binding") - ) - bootstrap_like_library = ( - library_lower.endswith("/main.dart") - or library_lower.startswith("package:flutter/") - or "/bootstrap" in library_lower - or "/engine" in library_lower - ) - if lower in { - "ensureinitialized", - "nativeensureinitialized", - "startinitialization", - "ensureinitializationcomplete", - } and ( - lower != "ensureinitialized" - or bootstrap_like_owner - or bootstrap_like_library - ): - out.append(("BootstrapInitCandidate", selector, f"bootflow:init:{selector}")) - - deduped: List[Tuple[str, str, str]] = [] - seen: Set[Tuple[str, str]] = set() - for decoded_kind, sel, value in out: - key = (decoded_kind, sel.lower()) - if key in seen: - continue - seen.add(key) - deduped.append((decoded_kind, sel, value)) - return deduped - - -def _pool_entries(strings: List[str]) -> List[dict]: - entries = [] - for i, s in enumerate(strings): - decoded_kind = "String" - selector = _selector_from_string(s) - library_uri = None - if s.startswith("package:") and ".dart" in s: - decoded_kind = "LibraryUri" - library_uri = s - elif selector is not None: - decoded_kind = "SelectorString" - - entries.append( - { - "index": i, - "kind": "String", - "value": s, - "decoded_kind": decoded_kind, - "selector": selector, - "target_va": None, - "owner_class": None, - "library_uri": library_uri, - "confidence": 0.4, - "source": "internal", - } - ) - return entries - - -def _append_synthetic_pool_entries(base: List[dict], synthetic: List[dict]) -> List[dict]: - out = list(base) - next_index = len(out) - seen: Set[Tuple[Optional[str], Optional[str], Optional[int], Optional[str], Optional[str]]] = set() - for e in out: - seen.add( - ( - e.get("decoded_kind"), - e.get("selector"), - e.get("target_va"), - e.get("owner_class"), - e.get("library_uri"), - ) - ) +def _build_internal_model(request: dict) -> dict: + vm_data = _read_region(request, VM_DATA) + iso_data = _read_region(request, ISOLATE_DATA) + iso_instr = _read_region(request, ISOLATE_INSTRUCTIONS) + iso_va = _region_va(request, ISOLATE_INSTRUCTIONS) - for e in synthetic: - key = ( - e.get("decoded_kind"), - e.get("selector"), - e.get("target_va"), - e.get("owner_class"), - e.get("library_uri"), + strings = _extract_strings(vm_data + iso_data) + uris = _library_uris(strings) + functions = _recover_code_ranges(iso_instr, iso_va) + entries = [ + { + "index": i, + "kind": "string", + "value": s, + "target_va": None, + "provenance": HEURISTIC, + "confidence": None, + } + for i, s in enumerate(strings) + ] + + capabilities = { + "libraries": PARTIAL if uris else UNAVAILABLE, + "classes": UNAVAILABLE, + "class_relationships": UNAVAILABLE, + "functions": PARTIAL if functions else UNAVAILABLE, + "function_names": UNAVAILABLE, + "object_pool": PARTIAL if entries else UNAVAILABLE, + "pool_index_space": UNAVAILABLE, + } + diagnostics = [ + _diagnostic( + "domain_not_recovered", + "this backend does not deserialize the snapshot, so no class table is reachable", + subject="classes", + ), + _diagnostic( + "domain_not_recovered", + "no function names are recoverable from instruction bytes alone", + subject="function_names", + ), + _diagnostic( + "domain_not_recovered", + "entries are carved strings in carve order, so no ObjectPool index space was established", + subject="pool_index_space", + ), + ] + if functions: + diagnostics.insert( + 0, + _diagnostic( + "domain_heuristic_only", + "code ranges come from frame-prologue and call-target scanning, not from a snapshot parser", + subject="functions", + ), ) - if key in seen: - continue - seen.add(key) - entry = dict(e) - entry["index"] = next_index - next_index += 1 - out.append(entry) - return out - -def _normalize_library_uri(raw: str) -> Optional[str]: - t = raw.strip() - if not t: - return None - if t.startswith("package:") or t.startswith("dart:") or t.startswith("file:"): - return t - return None + return _build_model( + request, + capabilities, + libraries=[ + {"id": i, "uri": uri, "display_name": None, "provenance": HEURISTIC} + for i, uri in enumerate(uris) + ], + classes=[], + functions=functions, + object_pool=_ordinal_pool(entries), + diagnostics=diagnostics, + ) -def _sanitize_owner_class(raw: str) -> Optional[str]: - t = raw.strip() - if not t: - return None - t = re.sub(r"<.*?>", "", t) - t = "".join(ch for ch in t if ch.isalnum() or ch in "_$") - if not t: - return None - return t +# -------------------------------------------------------------------------- +# blutter backend +# -------------------------------------------------------------------------- def _extract_blutter_function_name(head_line: str) -> Optional[str]: @@ -357,9 +527,7 @@ def _extract_blutter_function_name(head_line: str) -> Optional[str]: text = text[len(prefix) :].strip() changed = True - if text.startswith("set "): - text = text[4:].strip() - elif text.startswith("get "): + if text.startswith("set ") or text.startswith("get "): text = text[4:].strip() left = text.split("(", 1)[0].strip() @@ -378,145 +546,107 @@ def _extract_blutter_function_name(head_line: str) -> Optional[str]: return None token = re.sub(r"<.*?>", "", token) token = token.rstrip("{") - return token or None + if not _usable_value(token): + return None + return token + +def _parse_blutter_class_decl(line: str) -> Optional[Tuple[Optional[str], Optional[str]]]: + """`(class, super)` for a class declaration, or `None` for a non-declaration. -def _parse_blutter_class_decl(line: str) -> Optional[Tuple[str, str]]: + `class :: {` is blutter's header for a library's top-level members. It is not + a class, so it returns `(None, None)`: the functions under it have no owner + rather than an owner called `Global`. + """ t = line.strip() if t == "class :: {": - return ("Global", "Object") + return (None, None) m = re.match( r"^(?:abstract class|class|enum)\s+([^\s<{]+)(?:<[^>]*>)?(?:\s+extends\s+([^\s<{]+))?", t, ) if not m: return None - cls = _sanitize_owner_class(m.group(1) or "") + cls = _sanitize_class_name(m.group(1) or "") if not cls: return None - sup = _sanitize_owner_class(m.group(2) or "Object") or "Object" - return (cls, sup) - + return (cls, _sanitize_class_name(m.group(2) or "")) -def _parse_blutter_function_ref(text: str) -> Optional[Tuple[str, str, str]]: - m = re.search(r"\[([^\]]+)\]\s+([^:]+)::([^\s(]+)", text) - if not m: - return None - lib = _normalize_library_uri(m.group(1) or "") - owner = _sanitize_owner_class(m.group(2) or "") - sel = _selector_from_string(m.group(3) or "") - if not lib or not owner or not sel: - return None - return (lib, owner, sel) +def _parse_blutter_pp(pool_path: Path) -> List[str]: + """Pool slot text in the order blutter printed it. -def _parse_blutter_pp(pool_path: Path) -> List[dict]: + Blutter prints `[pp+0xNN]`, so a hardware index is arguably derivable from + the documented ARM64 AOT layout. It is not derived here: this producer has + no way to confirm those displacements are PP-relative for the snapshot in + hand, and a wrong index space silently mis-resolves every pool reference. + The entries stay ordinal and the index space stays unavailable. + """ if not pool_path.exists(): return [] - - entries: List[dict] = [] - line_re = re.compile(r"^\[pp\+0x([0-9a-fA-F]+)\]\s+(.*)$") - unlink_re = re.compile(r"UnlinkedCall:\s*0x([0-9a-fA-F]+)\s*-\s*(.*)$") - + line_re = re.compile(r"^\[pp\+0x[0-9a-fA-F]+\]\s+(.*)$") + out: List[str] = [] for raw_line in pool_path.read_text(encoding="utf-8", errors="ignore").splitlines(): m = line_re.match(raw_line.strip()) if not m: continue - value = (m.group(2) or "").strip() - decoded_kind = "BlutterPoolEntry" - selector = None - target_va = None - owner_class = None - library_uri = None - - m_unlink = unlink_re.search(value) - if m_unlink: - decoded_kind = "BlutterUnlinkedCall" - try: - target_va = int(m_unlink.group(1), 16) - except Exception: - target_va = None - ref = _parse_blutter_function_ref(m_unlink.group(2) or "") - if ref is not None: - library_uri, owner_class, selector = ref - else: - ref = _parse_blutter_function_ref(value) - if ref is not None: - decoded_kind = "BlutterFunctionRef" - library_uri, owner_class, selector = ref - - entries.append( - { - "index": len(entries), - "kind": "String", - "value": value, - "decoded_kind": decoded_kind, - "selector": selector, - "target_va": target_va, - "owner_class": owner_class, - "library_uri": library_uri, - "confidence": 0.8 if decoded_kind in ("BlutterUnlinkedCall", "BlutterFunctionRef") else 0.6, - "source": "blutter", - } - ) - return entries + value = (m.group(1) or "").strip() + if _usable_value(value): + out.append(value) + return out -def _parse_blutter_asm(asm_dir: Path) -> Tuple[List[dict], List[dict], List[dict], List[dict]]: +def _parse_blutter_asm(asm_dir: Path) -> Tuple[List[dict], List[dict], List[dict]]: if not asm_dir.exists() or not asm_dir.is_dir(): - raise RuntimeError(f"blutter asm directory not found: {asm_dir}") + raise BackendFailed(f"blutter asm directory not found: {asm_dir}") header_re = re.compile(r"^//\s*lib:\s*.*?,\s*url:\s*(\S+)\s*$") addr_re = re.compile(r"//\s*\*\*\s*addr:\s*(0x[0-9a-fA-F]+),\s*size:\s*(0x[0-9a-fA-F]+)") - libraries: Dict[str, dict] = {} - classes: Dict[Tuple[str, str], dict] = {} + library_ids: Dict[str, int] = {} + class_ids: Dict[Tuple[Optional[int], str], int] = {} + class_supers: Dict[int, str] = {} + class_names: Dict[str, int] = {} functions: List[dict] = [] seen_entry: Set[int] = set() - entrypoint_candidates: List[dict] = [] - seen_bootflow_target: Set[Tuple[int, str, str]] = set() - - def ensure_library(uri: str) -> str: - normalized = _normalize_library_uri(uri) or uri - if normalized not in libraries: - libraries[normalized] = { - "id": len(libraries), - "uri": normalized, - "name_display": normalized, - } - return normalized - - def ensure_class(uri: str, cls: str, super_name: str = "Object") -> str: - owner = _sanitize_owner_class(cls) or "Global" - key = (uri, owner) - if key not in classes: - classes[key] = { - "id": len(classes), - "name": owner, - "super": _sanitize_owner_class(super_name) or "Object", - "lib": uri, - } - return owner + + def ensure_library(uri: str) -> Optional[int]: + normalized = _normalize_library_uri(uri) + if normalized is None: + return None + if normalized not in library_ids: + library_ids[normalized] = len(library_ids) + return library_ids[normalized] + + def ensure_class(library: Optional[int], name: str, super_name: Optional[str]) -> int: + key = (library, name) + if key not in class_ids: + class_ids[key] = len(class_ids) + class_names.setdefault(name, class_ids[key]) + if super_name: + class_supers[class_ids[key]] = super_name + return class_ids[key] for dart_file in sorted(asm_dir.rglob("*.dart")): - current_lib = ensure_library("package:app/main.dart") - current_class = ensure_class(current_lib, "Global", "Object") + current_lib: Optional[int] = None + current_class: Optional[int] = None pending_name: Optional[str] = None - pending_owner: str = current_class - pending_lib: str = current_lib + pending_owner: Optional[int] = None for line in dart_file.read_text(encoding="utf-8", errors="ignore").splitlines(): m_header = header_re.match(line.strip()) if m_header: current_lib = ensure_library(m_header.group(1).strip()) - current_class = ensure_class(current_lib, "Global", "Object") + current_class = None pending_name = None continue decl = _parse_blutter_class_decl(line) if decl is not None: cls_name, super_name = decl - current_class = ensure_class(current_lib, cls_name, super_name) + current_class = ( + ensure_class(current_lib, cls_name, super_name) if cls_name else None + ) pending_name = None continue @@ -526,7 +656,6 @@ def ensure_class(uri: str, cls: str, super_name: str = "Object") -> str: if head_name: pending_name = head_name pending_owner = current_class - pending_lib = current_lib m_addr = addr_re.search(line) if not m_addr: @@ -534,56 +663,47 @@ def ensure_class(uri: str, cls: str, super_name: str = "Object") -> str: try: entry = int(m_addr.group(1), 16) size = int(m_addr.group(2), 16) - except Exception: + except ValueError: continue if entry in seen_entry: pending_name = None continue seen_entry.add(entry) - owner = ensure_class(pending_lib, pending_owner, "Object") - name = pending_name or f"sub_{entry:x}" functions.append( { "id": len(functions), - "name": name, - "owner_class": owner, - "entry_va": entry, - "size": max(size, 4), + # Scraped out of blutter's rendered source, so a guess about + # the text, never `exact`. + "name": _name(pending_name, HEURISTIC) if pending_name else None, + "owner": pending_owner if pending_name else current_class, + "code": {"start_va": entry, "size": max(size, 1)}, "code_section_va": 0, - "name_kind": "placeholder" if name.startswith("sub_") else "heuristic", + "provenance": DERIVED, } ) - for decoded_kind, selector, value in _bootflow_candidates_from_name( - name, owner, pending_lib - ): - key = (entry, decoded_kind, selector.lower()) - if key in seen_bootflow_target: - continue - seen_bootflow_target.add(key) - entrypoint_candidates.append( - { - "index": 0, - "kind": "String", - "value": value, - "decoded_kind": decoded_kind, - "selector": selector, - "target_va": int(entry), - "owner_class": owner, - "library_uri": pending_lib, - "confidence": 0.85, - "source": "synthetic", - } - ) pending_name = None - - libs = sorted(libraries.values(), key=lambda v: int(v["id"])) - clss = sorted(classes.values(), key=lambda v: int(v["id"])) - if not libs: - libs = [{"id": 0, "uri": "package:app/main.dart", "name_display": "package:app/main.dart"}] - if not clss: - clss = [{"id": 0, "name": "Global", "super": "Object", "lib": libs[0]["uri"]}] - return libs, clss, functions, entrypoint_candidates + pending_owner = None + + libraries = [ + {"id": lid, "uri": uri, "display_name": None, "provenance": DERIVED} + for uri, lid in library_ids.items() + ] + classes = [] + for (library, name), cid in class_ids.items(): + super_name = class_supers.get(cid) + super_id = class_names.get(super_name) if super_name else None + classes.append( + { + "id": cid, + "name": name, + "library": library, + # Only an edge whose target is a class we actually recovered. + "super_class": super_id if super_id is not None and super_id != cid else None, + "provenance": DERIVED, + } + ) + return libraries, classes, functions def _runner_mode(cmd: List[str]) -> str: @@ -621,7 +741,7 @@ def _resolve_blutter_runner() -> Optional[List[str]]: def _run_blutter_dump(input_path: Optional[str], libapp_path: Optional[str]) -> Path: runner = _resolve_blutter_runner() if not runner: - raise RuntimeError( + raise BackendUnavailable( "blutter runner not found. set FLUTTERDEC_BLUTTER_CMD or FLUTTERDEC_BLUTTER_PY, or install blutter.py" ) @@ -630,16 +750,15 @@ def _run_blutter_dump(input_path: Optional[str], libapp_path: Optional[str]) -> if mode == "blutter_py": if not input_path: - raise RuntimeError("blutter.py backend needs --input-path") - indir = input_path - cmd = runner + [indir, str(out_dir), "--no-analysis"] + raise BackendFailed("blutter.py backend needs --input-path") + cmd = runner + [input_path, str(out_dir), "--no-analysis"] elif mode == "blutter_bin": if not libapp_path: - raise RuntimeError("blutter binary backend needs --libapp-path") + raise BackendFailed("blutter binary backend needs --libapp-path") cmd = runner + ["-i", libapp_path, "-o", str(out_dir)] else: if not input_path: - raise RuntimeError("custom blutter backend needs --input-path") + raise BackendFailed("custom blutter backend needs --input-path") cmd = runner + [input_path, str(out_dir)] lock_dir = Path.home() / ".cache" / "flutterdec" @@ -656,7 +775,7 @@ def _run_blutter_dump(input_path: Optional[str], libapp_path: Optional[str]) -> ) fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN) if proc.returncode != 0: - raise RuntimeError( + raise BackendFailed( "blutter failed with status {}\nstdout:\n{}\nstderr:\n{}".format( proc.returncode, proc.stdout, proc.stderr ) @@ -664,38 +783,99 @@ def _run_blutter_dump(input_path: Optional[str], libapp_path: Optional[str]) -> return out_dir -def _build_blutter_model( - vm_data: bytes, - iso_data: bytes, - default_snapshot_hash: str, - default_version: str, - input_path: Optional[str], - libapp_path: Optional[str], -) -> dict: +def _build_blutter_model(request: dict, input_path: Optional[str], + libapp_path: Optional[str]) -> dict: blutter_out = _run_blutter_dump(input_path, libapp_path) - asm_dir = blutter_out / "asm" - libs, classes, funcs, entrypoint_candidates = _parse_blutter_asm(asm_dir) - if not funcs: - raise RuntimeError("blutter output did not contain recoverable functions") - - pool_entries = _parse_blutter_pp(blutter_out / "pp.txt") - if not pool_entries: - pool_entries = _pool_entries(_extract_strings(vm_data + iso_data)) - if entrypoint_candidates: - pool_entries = _append_synthetic_pool_entries(pool_entries, entrypoint_candidates) - - snapshot_hash = _detect_snapshot_hash(vm_data, iso_data, default_snapshot_hash) - return { - "schema_version": 3, - "adapter_kind": "blutter_bridge_model_v1", - "dart_version": default_version, - "snapshot_hash": snapshot_hash, - "arch": "arm64", - "libraries": libs, - "classes": classes, - "functions": funcs, - "object_pool": pool_entries, + libraries, classes, functions = _parse_blutter_asm(blutter_out / "asm") + if not functions: + raise BackendFailed("blutter output did not contain recoverable code ranges") + + # Blutter reports addresses in the isolate instruction image, which is the + # region the host declared; the model has to name the region base it means. + iso_va = _region_va(request, ISOLATE_INSTRUCTIONS) + iso_size = _handle(request, ISOLATE_INSTRUCTIONS)["size"] + kept: List[dict] = [] + dropped = 0 + for f in functions: + start = f["code"]["start_va"] + if not (iso_va <= start < iso_va + iso_size): + dropped += 1 + continue + f["code"]["size"] = min(f["code"]["size"], iso_va + iso_size - start) + f["code_section_va"] = iso_va + f["id"] = len(kept) + kept.append(f) + if not kept: + raise BackendFailed( + "no blutter code range fell inside the isolate instruction region the host declared" + ) + + pool_values = _parse_blutter_pp(blutter_out / "pp.txt") + entries = [ + { + "index": i, + "kind": "string", + "value": value, + "target_va": None, + "provenance": HEURISTIC, + "confidence": None, + } + for i, value in enumerate(pool_values) + ] + + named = sum(1 for f in kept if f["name"] is not None) + has_supers = any(c["super_class"] is not None for c in classes) + diagnostics = [ + _diagnostic( + "domain_heuristic_only", + "function names are scraped from blutter's rendered source, not read from the snapshot", + subject="function_names", + ) + ] + if dropped: + diagnostics.append( + _diagnostic( + "record_discarded", + f"{dropped} blutter code ranges fell outside the declared isolate instruction region", + subject="functions", + ) + ) + if entries: + diagnostics.append( + _diagnostic( + "domain_not_recovered", + "blutter prints pool slots in dump order; no ObjectPool index space was established", + subject="pool_index_space", + ) + ) + + capabilities = { + "libraries": PARTIAL if libraries else UNAVAILABLE, + "classes": PARTIAL if classes else UNAVAILABLE, + "class_relationships": PARTIAL if (classes and has_supers) else UNAVAILABLE, + "functions": PARTIAL, + "function_names": PARTIAL if named else UNAVAILABLE, + "object_pool": PARTIAL if entries else UNAVAILABLE, + "pool_index_space": UNAVAILABLE, } + if capabilities["function_names"] == UNAVAILABLE: + diagnostics = [d for d in diagnostics if d.get("subject") != "function_names"] + if not classes: + classes = [] + return _build_model( + request, + capabilities, + libraries=libraries, + classes=classes, + functions=kept, + object_pool=_ordinal_pool(entries), + diagnostics=diagnostics, + ) + + +# -------------------------------------------------------------------------- +# r2flutter backend +# -------------------------------------------------------------------------- def _resolve_r2flutter_runner() -> Optional[List[str]]: @@ -722,10 +902,10 @@ def _r2flutter_timeout() -> int: def _r2flutter_json(runner: List[str], target: str, flag: str): """Run one r2flutter action and parse its JSON. - r2flutter emits one action per invocation and writes radare2 loader warnings to - stderr, so stdout is parsed on its own. Six invocations happen per model build and - each one loads the whole binary, so a wedged radare2 would otherwise hang the - adapter, and the core waiting on it, indefinitely. + r2flutter emits one action per invocation and writes radare2 loader warnings + to stderr, so stdout is parsed on its own. Several invocations happen per + model build and each one loads the whole binary, so a wedged radare2 would + otherwise hang the adapter, and the core waiting on it, indefinitely. """ try: proc = subprocess.run( @@ -737,337 +917,387 @@ def _r2flutter_json(runner: List[str], target: str, flag: str): timeout=_r2flutter_timeout(), ) except OSError as exc: - raise RuntimeError(f"could not launch r2flutter ({' '.join(runner)}): {exc}") from exc + raise BackendUnavailable(f"could not launch r2flutter ({' '.join(runner)}): {exc}") from exc except subprocess.TimeoutExpired as exc: - raise RuntimeError(f"r2flutter {flag} timed out after {exc.timeout}s") from exc + raise BackendFailed(f"r2flutter {flag} timed out after {exc.timeout}s") from exc if proc.returncode != 0: - raise RuntimeError( + raise BackendFailed( f"r2flutter {flag} failed ({proc.returncode}): {proc.stderr.strip()[:400]}" ) try: return json.loads(proc.stdout) except json.JSONDecodeError as exc: - raise RuntimeError(f"r2flutter {flag} did not emit JSON: {exc}") from exc + raise BackendFailed(f"r2flutter {flag} did not emit JSON: {exc}") from exc _R2F_POOL_ENTRY_RE = re.compile(r"\bentry=(\d+)\b") _R2F_IT_METHOD_RE = re.compile(r"^method\.(?:(?P.+)\.)?(?P[^.]+)$") -def _r2flutter_functions(instruction_table: dict, isolate_instr_va: int) -> List[dict]: - """Map the AOT instruction table onto ProgramModel functions. +def _r2flutter_functions(instruction_table: dict, iso_va: int, iso_size: int, + class_ids: Dict[str, int]) -> Tuple[List[dict], int]: + """Map the AOT instruction table onto v4 functions. - Entry addresses and names come straight out of the snapshot, so every name is - `exact`. Sizes are not serialized; the gap to the next entry is the usual - approximation and is what the disassembler needs. + Entry addresses and names come out of the snapshot, so a name that is there + is `exact`. Sizes are not serialized: the gap to the next entry is the usual + approximation, which is why the record's own provenance is `derived` even + when its name is not. """ entries = sorted( (e for e in instruction_table.get("entries", []) if e.get("address")), key=lambda e: e["address"], ) out: List[dict] = [] + dropped = 0 for i, e in enumerate(entries): start = int(e["address"]) - nxt = int(entries[i + 1]["address"]) if i + 1 < len(entries) else start + 0x40 - raw_name = (e.get("name") or "").strip() or f"sub_{start:x}" - owner = "Global" - m = _R2F_IT_METHOD_RE.match(raw_name) - if m and m.group("owner"): - owner = m.group("owner") + if not (iso_va <= start < iso_va + iso_size): + dropped += 1 + continue + nxt = int(entries[i + 1]["address"]) if i + 1 < len(entries) else iso_va + iso_size + size = min(max(nxt - start, 4), 0x8000, iso_va + iso_size - start) + raw_name = (e.get("name") or "").strip() + owner = None + name = None + if _usable_value(raw_name): + m = _R2F_IT_METHOD_RE.match(raw_name) + if m and m.group("owner"): + owner = class_ids.get(m.group("owner")) + name = _name(raw_name, EXACT) out.append( { - "id": i, - "name": raw_name, - "owner_class": owner, - "entry_va": start, - "size": max(4, min(nxt - start, 0x8000)), - "code_section_va": int(isolate_instr_va or 0), - "name_kind": "exact", + "id": len(out), + "name": name, + "owner": owner, + "code": {"start_va": start, "size": int(size)}, + "code_section_va": iso_va, + "provenance": DERIVED, } ) - return out + return out, dropped + + +def _r2flutter_super_name(value) -> Optional[str]: + """Superclass name from r2flutter metadata, or None when it is unresolved. + + r2flutter emits `super` as an object that may carry `ref`, `type_ref` and + `name`. Only `name` is a recovered name: r2flutter fills it in itself when + the reference resolves, so a bare `ref`/`type_ref` means its own lookup + failed. v4 reports that as no superclass rather than inventing `Object`. + """ + if isinstance(value, str): + return _sanitize_class_name(value) + if isinstance(value, dict): + name = value.get("name") + if isinstance(name, str): + return _sanitize_class_name(name) + return None + + +def _r2flutter_classes(classes: List[dict]) -> Tuple[List[dict], Dict[str, int]]: + """Project r2flutter classes onto v4 classes. + + r2flutter does not attribute classes to libraries, so `library` stays null + rather than pointing at a URI recovered from somewhere else entirely. + """ + named: List[str] = [] + seen: Set[str] = set() + for c in classes: + name = _sanitize_class_name(c.get("name") or "") + if not name or name in seen: + continue + seen.add(name) + named.append(name) + ids = {name: i for i, name in enumerate(named)} + out = [] + for name in named: + raw_super = next( + (c.get("super") for c in classes if _sanitize_class_name(c.get("name") or "") == name), + None, + ) + super_name = _r2flutter_super_name(raw_super) + super_id = ids.get(super_name) if super_name else None + out.append( + { + "id": ids[name], + "name": name, + "library": None, + "super_class": super_id if super_id is not None and super_id != ids[name] else None, + "provenance": EXACT, + } + ) + return out, ids def _r2flutter_pool(strings: List[dict]) -> List[dict]: - """Build ObjectPool entries keyed by the real entry index. + """ObjectPool entries keyed by the real entry index. r2flutter reports, per string, the pool slots that reference it as `pool= index= entry= pp_off=`. `entry` is the authoritative index a `ldr xN, [x27, #pp_off]` resolves to, which is exactly the key the decompiler joins on. Nothing here is positional or guessed. """ - out: List[dict] = [] + by_index: Dict[int, dict] = {} for s in strings: value = s.get("value") - if not isinstance(value, str) or not value: + if not isinstance(value, str) or not _usable_value(value): continue - selector = _selector_from_string(value) - library_uri = _normalize_library_uri(value) - if library_uri is not None: - decoded_kind = "LibraryUri" - elif selector is not None: - decoded_kind = "SelectorString" - else: - decoded_kind = "String" + kind = "selector" if _selector_from_string(value) else "string" for ref in s.get("refs", []): if ref.get("kind") != "object_pool.entry": continue m = _R2F_POOL_ENTRY_RE.search(ref.get("name") or "") if not m: continue - out.append( + index = int(m.group(1)) + # One slot holds one object. A second claim on the same index is a + # contradiction, and the host rejects duplicates outright. + by_index.setdefault( + index, { - "index": int(m.group(1)), - "kind": "TwoByteString" if s.get("two_byte") else "OneByteString", + "index": index, + "kind": kind, "value": value, - "decoded_kind": decoded_kind, - "selector": selector, "target_va": None, - "owner_class": None, - "library_uri": library_uri, - "confidence": 1.0, - "source": "vm", - } + "provenance": EXACT, + "confidence": None, + }, ) - out.sort(key=lambda e: e["index"]) - return out - - -def _r2flutter_super_name(value) -> str: - """Normalize r2flutter superclass metadata to ProgramModel's string field.""" - if isinstance(value, str): - name = value.strip() - if name: - return name - if isinstance(value, dict): - name = value.get("name") - if isinstance(name, str): - name = name.strip() - if name: - return name - # ProgramModel v3 requires a non-null superclass string. "Object" is only - # a placeholder for unresolved r2flutter metadata, not recovered data. - return "Object" - - -def _r2flutter_classes(classes: List[dict]) -> List[dict]: - """Project r2flutter classes onto ProgramModel classes. - - r2flutter does not attribute classes to libraries, so library URIs are recovered - from pool strings instead and classes stay library-less rather than being given - an invented owner. - """ - out: List[dict] = [] - for i, c in enumerate(classes): - name = (c.get("name") or "").strip() - if not name: - continue - out.append( - { - "id": i, - "name": name, - "super": _r2flutter_super_name(c.get("super")), - "lib": "", - } - ) - return out + return [by_index[i] for i in sorted(by_index)] -def _build_r2flutter_model(default_snapshot_hash: str, default_version: str, - input_path: Optional[str], libapp_path: Optional[str], - isolate_instr_va: int) -> dict: +def _build_r2flutter_model(request: dict, input_path: Optional[str], + libapp_path: Optional[str]) -> dict: runner = _resolve_r2flutter_runner() if runner is None: - raise RuntimeError( + raise BackendUnavailable( "r2flutter not found; set FLUTTERDEC_R2FLUTTER_CMD or FLUTTERDEC_R2FLUTTER_BIN, " "or put r2flutter on PATH" ) target = libapp_path or input_path if not target: - raise RuntimeError("r2flutter backend needs --libapp-path or --input-path") + raise BackendFailed("r2flutter backend needs --libapp-path or --input-path") - header = _r2flutter_json(runner, target, "-jH") + iso_va = _region_va(request, ISOLATE_INSTRUCTIONS) + iso_size = _handle(request, ISOLATE_INSTRUCTIONS)["size"] + + classes, class_ids = _r2flutter_classes(_r2flutter_json(runner, target, "-jc")) instruction_table = _r2flutter_json(runner, target, "-ji") - functions = _r2flutter_functions(instruction_table, isolate_instr_va) + functions, dropped = _r2flutter_functions(instruction_table, iso_va, iso_size, class_ids) if not functions: - raise RuntimeError("r2flutter recovered no instruction-table entries") + raise BackendFailed("r2flutter recovered no instruction-table entries in the declared region") - # `-jxz` is the reliable pool-referenced string set with its slot back-references; - # that is what the pool index space needs. + # `-jxz` is the reliable pool-referenced string set with its slot + # back-references; that is what the pool index space needs. pool_strings = _r2flutter_json(runner, target, "-jxz") - object_pool = _r2flutter_pool(pool_strings) - classes = _r2flutter_classes(_r2flutter_json(runner, target, "-jc")) + pool_entries = _r2flutter_pool(pool_strings) - # Library URIs mostly live in the data image rather than the pool, so the wider - # carved set is the only place to find them. They drive `--function-scope` and - # package prioritisation, not naming, so the looser extraction is acceptable here. + # Library URIs mostly live in the data image rather than the pool, so the + # wider carved set is the only place to find them. They drive + # `--function-scope` and package prioritisation, not naming, which is why a + # carved URI is `heuristic` while an instruction-table name is not. try: all_strings = _r2flutter_json(runner, target, "-jzz") - except RuntimeError: + except BackendFailed: all_strings = pool_strings - libs = _collect_libraries( + uris = _library_uris( [s.get("value", "") for s in all_strings if isinstance(s.get("value"), str)] ) - libraries = [{"id": i, "uri": lib, "name_display": lib} for i, lib in enumerate(libs)] - if not classes: - classes = [{"id": 0, "name": "Global", "super": "Object", "lib": libs[0]}] + libraries = [ + {"id": i, "uri": uri, "display_name": None, "provenance": HEURISTIC} + for i, uri in enumerate(uris) + ] # Only claim an authoritative pool index space when the ObjectPool image was - # actually reconstructed. r2flutter reports `error` for snapshots whose pool fill - # payload it cannot decode, and a guessed geometry there would silently + # actually reconstructed. r2flutter reports an error for snapshots whose pool + # fill payload it cannot decode, and a guessed geometry there would silently # mis-resolve every pool reference. - pool_geometry = None + geometry = None try: pp = _r2flutter_json(runner, target, "-jp") if isinstance(pp, dict) and "entries_offset" in pp and "word_size" in pp: - pool_geometry = { + geometry = { "entries_offset": int(pp["entries_offset"]), "word_size": int(pp["word_size"]), } - except RuntimeError: - pool_geometry = None - if pool_geometry is None: - object_pool = [] - - model = { - "schema_version": 3, - "adapter_kind": "r2flutter_snapshot_v1", - "dart_version": header.get("dart_version") or default_version, - "snapshot_hash": header.get("hash") or default_snapshot_hash, - "arch": "arm64", - "libraries": libraries, - "classes": classes, - "functions": functions, - "object_pool": object_pool, - } - # The schema types `pool_geometry` as an object, so omit the key rather than - # emitting null: absence is how an adapter declines to claim a real index space. - if pool_geometry is not None: - model["pool_geometry"] = pool_geometry - return model + except BackendFailed: + geometry = None + + diagnostics: List[dict] = [] + if dropped: + diagnostics.append( + _diagnostic( + "record_discarded", + f"{dropped} instruction-table entries fell outside the declared isolate instruction region", + subject="functions", + ) + ) + if geometry is None: + pool_entries = [] + diagnostics.append( + _diagnostic( + "domain_not_recovered", + "r2flutter could not reconstruct the ObjectPool image, so no entry is addressable", + subject="object_pool", + ) + ) + object_pool = _empty_pool() + elif not pool_entries: + # Geometry without a single entry describes an index space nothing + # occupies; claiming `hardware` there would be a claim about no data. + object_pool = _empty_pool() + diagnostics.append( + _diagnostic( + "domain_not_recovered", + "r2flutter reconstructed the pool image but no slot referenced a usable string", + subject="object_pool", + ) + ) + else: + object_pool = { + "index_space": "hardware", + "geometry": geometry, + "entries": pool_entries, + } + if uris: + diagnostics.append( + _diagnostic( + "domain_heuristic_only", + "library URIs are carved from the data image, not read from a library table", + subject="libraries", + ) + ) + unnamed = sum(1 for f in functions if f["name"] is None) + has_supers = any(c["super_class"] is not None for c in classes) + capabilities = { + "libraries": PARTIAL if libraries else UNAVAILABLE, + "classes": PARTIAL if classes else UNAVAILABLE, + "class_relationships": PARTIAL if (classes and has_supers) else UNAVAILABLE, + # Sizes are the gap to the next entry, so the domain is never complete. + "functions": PARTIAL, + "function_names": UNAVAILABLE + if unnamed == len(functions) + else PARTIAL, + "object_pool": PARTIAL if pool_entries else UNAVAILABLE, + "pool_index_space": COMPLETE if geometry is not None and pool_entries else UNAVAILABLE, + } + return _build_model( + request, + capabilities, + libraries=libraries, + classes=classes, + functions=functions, + object_pool=object_pool, + diagnostics=diagnostics, + ) -def _normalize_backend(raw: str) -> str: - t = (raw or "auto").strip().lower() - if t in ("auto", "internal", "blutter", "r2flutter"): - return t - return "auto" +# -------------------------------------------------------------------------- +# entrypoint +# -------------------------------------------------------------------------- -def _drop_nulls(value): - """Strip keys whose value is null, recursively. +BACKEND_ORDER = ("r2flutter", "blutter", "internal") - Optional model fields are typed concretely in schemas/adapter.schema.json, so an - explicit null fails validation where an absent key passes. The Rust side treats - both as `None`, so omitting is free and makes the schema mean something. - """ - if isinstance(value, dict): - return {k: _drop_nulls(v) for k, v in value.items() if v is not None} - if isinstance(value, list): - return [_drop_nulls(v) for v in value] - return value +BUILDERS = { + "r2flutter": _build_r2flutter_model, + "blutter": _build_blutter_model, + "internal": lambda request, _input_path, _libapp_path: _build_internal_model(request), +} -def _write_model(path: str, payload: dict) -> None: - with open(path, "w", encoding="utf-8") as f: - json.dump(_drop_nulls(payload), f, indent=2) +def _run_backend(name: str, request: dict, input_path: Optional[str], + libapp_path: Optional[str]) -> dict: + return BUILDERS[name](request, input_path, libapp_path) -def entrypoint(default_snapshot_hash: str = "unknown", default_version: str = "unknown") -> int: +def entrypoint() -> int: p = argparse.ArgumentParser() - p.add_argument("--vm-data", required=True) - p.add_argument("--isolate-data", required=True) - p.add_argument("--vm-instr", required=True) - p.add_argument("--isolate-instr", required=True) - p.add_argument("--vm-instr-va", type=int, default=0) - p.add_argument("--isolate-instr-va", type=int, default=0) + p.add_argument("--request", required=True) + p.add_argument("--result", required=True) p.add_argument("--input-path") p.add_argument("--libapp-path") - p.add_argument("--out", required=True) args = p.parse_args() - vm_data = _read_bytes(args.vm_data) - iso_data = _read_bytes(args.isolate_data) - iso_instr = _read_bytes(args.isolate_instr) + result_path = Path(args.result) + try: + request = _load_request(Path(args.request)) + except ValueError as exc: + _write_result( + result_path, + "unsupported", + error={"code": "unsupported_protocol", "message": str(exc)}, + ) + return 1 - backend = _normalize_backend(os.getenv("FLUTTERDEC_ADAPTER_BACKEND", "auto")) + requested = request["requested_backend"] + output = request["output"] - # `auto` prefers r2flutter: it deserializes the snapshot, so it is the only - # backend that yields exact names plus a real ObjectPool index space. Each - # backend falls through to the next when its tooling is absent. - if backend in ("auto", "r2flutter"): - try: - payload = _build_r2flutter_model( - default_snapshot_hash, - default_version, - args.input_path, - args.libapp_path, - args.isolate_instr_va, - ) - _write_model(args.out, payload) - return 0 - except Exception as exc: - if backend == "r2flutter": - print(f"[adapter] r2flutter backend required but failed: {exc}", file=sys.stderr) - return 1 - print(f"[adapter] r2flutter backend unavailable: {exc}", file=sys.stderr) - - if backend in ("auto", "blutter"): + if requested == "auto": + order = list(BACKEND_ORDER) + elif requested in BUILDERS: + order = [requested] + else: + _write_result( + result_path, + "unsupported", + error={ + "code": "unsupported_protocol", + "message": f"unknown requested backend {requested!r}", + }, + ) + return 1 + + fallback_reason: Optional[str] = None + notes: List[dict] = [] + last_error: Optional[Tuple[str, str]] = None + + for name in order: try: - payload = _build_blutter_model( - vm_data, - iso_data, - default_snapshot_hash, - default_version, - args.input_path, - args.libapp_path, + model = _run_backend(name, request, args.input_path, args.libapp_path) + except BackendUnavailable as exc: + last_error = ("unsupported_snapshot", f"{name}: {exc}") + if requested == "auto" and fallback_reason is None: + fallback_reason = "backend_unavailable" + notes.append( + _diagnostic("domain_unsupported", f"{name} backend unavailable: {exc}", + subject=name, severity="info") ) - _write_model(args.out, payload) - return 0 - except Exception as exc: - if backend == "blutter": - raise - print( - f"[adapter] blutter backend unavailable, fallback to internal: {exc}", - file=sys.stderr, + continue + except (BackendFailed, FileNotFoundError, ValueError, OSError) as exc: + last_error = ("parse_failed", f"{name}: {exc}") + if requested == "auto" and fallback_reason is None: + fallback_reason = "backend_failed" + notes.append( + _diagnostic("domain_not_recovered", f"{name} backend failed: {exc}", + subject=name, severity="warning") ) + continue - strings = _extract_strings(vm_data + iso_data) - snapshot_hash = _detect_snapshot_hash(vm_data, iso_data, default_snapshot_hash) - - libs = _collect_libraries(strings) - funcs = _recover_functions(iso_instr, args.isolate_instr_va) - if not funcs: - funcs = [ - { - "id": 0, - "name": "entry", - "owner_class": "Global", - "entry_va": int(args.isolate_instr_va or 0x1000), - "size": 128, - "code_section_va": int(args.isolate_instr_va or 0x1000), - "name_kind": "heuristic", - } - ] - - payload = { - "schema_version": 3, - "adapter_kind": "dynamic_snapshot_string_model_v1", - "dart_version": default_version, - "snapshot_hash": snapshot_hash, - "arch": "arm64", - "libraries": [{"id": i, "uri": lib, "name_display": lib} for i, lib in enumerate(libs)], - "classes": [{"id": 0, "name": "Global", "super": "Object", "lib": libs[0]}], - "functions": funcs, - # Carved strings, indexed by carve order. This is NOT the ObjectPool index - # space, which is why `pool_geometry` is omitted entirely: the core then - # declines to resolve `pool[N]` rather than attaching an unrelated string. - "object_pool": _pool_entries(strings), - } - - _write_model(args.out, payload) + try: + Path(output).write_text(json.dumps(model), encoding="utf-8") + except OSError as exc: + _write_result( + result_path, + "failed", + error={"code": "output_write_failed", "message": str(exc)}, + ) + return 1 + + _write_result( + result_path, + "ok", + model=output, + resolved_backend=name, + fallback_reason=fallback_reason, + diagnostics=notes + model["diagnostics"], + ) + return 0 - return 0 + code, message = last_error or ("internal", "no backend ran") + _write_result(result_path, "failed", error={"code": code, "message": message}, + diagnostics=notes) + return 1 if __name__ == "__main__": diff --git a/context.md b/context.md index 591bd479..cfe6dbc1 100644 --- a/context.md +++ b/context.md @@ -92,25 +92,39 @@ Current module layout: ## Data contracts -The decompiler expects a normalized model from the adapter layer. That model includes: - -- functions and entry addresses -- classes and library metadata when available -- object pool entries -- `pool_geometry`, when the adapter recovered the real `ObjectPool` layout -- architecture and snapshot metadata +The decompiler expects a normalized model from the adapter layer: ProgramModel v4, the +only accepted contract. That model includes: + +- `model_version`, always 4; a document carrying `schema_version` is a legacy v2/v3 + model and is rejected rather than migrated +- `functions[]` with entry addresses, sizes, and optional names +- `libraries[]` and `classes[]`, addressed by typed `LibraryId`/`ClassId` rather than by + name strings, with a class's library optional +- `object_pool` with its declared `index_space`, optional `geometry`, and entries +- `producer`, `input` (the host's snapshot identity plus the region table with digests), + and `compatibility` (the record and profile digests that authorized the run) +- `capabilities`, saying per domain whether recovery was `complete`, `partial`, or + `unavailable` +- `diagnostics[]`, and `extensions`, the one object allowed undeclared keys + +Every recovered fact carries `provenance` (`exact`, `derived`, or `heuristic`), and +anything the producer did not recover is absent rather than filled with a stand-in. The +host does not re-derive identity, producer, or compatibility from the model: those are +host facts the model is checked against. This keeps the rest of the system independent from any single parser implementation. ### The pool index space is part of the contract -`object_pool[].index` means one thing: the entry index a `ldr xN, [x27, #disp]` -resolves to. An adapter claims that meaning by emitting `pool_geometry` -(`entries_offset`, `word_size`), and core converts displacements with -`index = (disp - entries_offset) / word_size`. +`object_pool.entries[].index` means one thing when the adapter says so: the entry index a +`ldr xN, [x27, #disp]` resolves to. An adapter claims that meaning by declaring +`index_space: hardware` and emitting `geometry` (`entries_offset`, `word_size`), and core +converts displacements with `index = (disp - entries_offset) / word_size`. -An adapter that cannot recover the real pool must omit `pool_geometry`, and core then -refuses to resolve pool references at all. This is deliberate: the failure mode of +An adapter that cannot recover the real pool declares `index_space: ordinal` and omits +`geometry`; core then refuses to resolve pool references at all and says so in +`report.json` under `pool_metadata.hints_suppressed_reason`. This is deliberate: the +failure mode of joining two unrelated index spaces is not a missing value, it is a *plausible wrong* value: a real string from the binary, attached to a slot that never referenced it, rendered in pseudocode with no marker distinguishing it from a correct one. For a @@ -188,7 +202,7 @@ Current scope: example: `ldr x1, [x27, #0xef8]` was rendered as `"_workoutWorkoutDeserialize"` when slot 477 actually holds a `type_arguments` object, and `pp+0x23a90` resolved two slots late to `...WebChromeClient.onShowFileChooser` instead of `...onProgressChanged` -- pool value/semantic hints are now gated on `pool_geometry`, so an adapter without a real +- pool value/semantic hints are now gated on `object_pool.index_space` being `hardware`, so an adapter without a real pool (the internal one) produces no pool literals instead of plausible wrong ones; `report.json.pool_metadata` reports `index_space_authoritative`, the geometry, and `hints_suppressed_reason` @@ -222,23 +236,23 @@ Current scope: - declaration typing now also treats pool-mapped literal assignments (`"value" /* pool[...] */`) as concrete `String` locals instead of leaving them as `dynamic` - declaration typing now also infers `bool` from condition context (`if (x)`, `x && y`, `x == true`) so argument/local declarations keep less `dynamic` noise in control-flow-heavy functions - repeated pool-mapped selector literals now hoist into local `String` aliases (for example `poolStr42`) so repeated callsites stay compact and readable -- adapter object-pool metadata fields (`decoded_kind`, `selector`, `target_va`, `owner_class`, `library_uri`) are now consumed by decompile for deterministic owner-qualified selector rewrites -- adapter model contract now accepts schema versions `2` and `3`; v3 adds optional per-function `name_kind` and optional object-pool provenance fields (`confidence`, `source`) while preserving v2 compatibility defaults +- adapter object-pool entry fields (`kind`, `value`, `target_va`, `provenance`) are consumed by decompile for deterministic owner-qualified selector rewrites; owner and library are not pool fields, they are resolved through the function `target_va` points at +- adapter model contract accepts ProgramModel v4 and nothing else: `model_version` must be 4, a document carrying `schema_version` is rejected as a legacy v2/v3 model, and there is no migration shim. Every recovered fact carries `provenance` (`exact`/`derived`/`heuristic`), unrecovered names/owners/libraries are absent rather than fabricated, and per-domain `capabilities` say what the producer could and could not do - adapter execution now supports backend selection (`auto`, `internal`, `blutter`, `r2flutter`) so deterministic parser backends can be introduced without changing decompiler core contracts - default adapter backend mode is `auto`: it tries r2flutter, then the Blutter bridge when configured (`FLUTTERDEC_BLUTTER_CMD` or `FLUTTERDEC_BLUTTER_PY`), and falls back to internal parsing for resilience - r2flutter backend (`--adapter-backend r2-flutter`, `FLUTTERDEC_R2FLUTTER_BIN`/`FLUTTERDEC_R2FLUTTER_CMD`) shells out to the MIT tool [radareorg/r2flutter](https://github.com/radareorg/r2flutter) and maps `-ji` (AOT instruction table), `-jc` (classes), `-jxz` (pool-referenced strings with their slot indices), `-jzz` (library URIs), and `-jp` (pool geometry) onto `ProgramModel`; on a Dart 3.9.2 sample it returns 37258 exactly-named functions and 8986 classes where the internal adapter returns 7458 `sub_*` placeholders and 1 synthetic class - Blutter bridge parsing currently normalizes `asm/*.dart` and `pp.txt` output into `ProgramModel` (`libraries`, `classes`, `functions`, and best-effort `object_pool` target metadata), synthesizes deterministic `EntryPointCandidate` pool entries for `main`/`runApp`-like functions when present, and serializes blutter invocations with a cache lock to avoid concurrent runner races -- owner-only metadata (selector + owner_class without library URI) can still rewrite indirect selector calls to deterministic owner-qualified call paths -- if pool entries miss selector/owner/library metadata, core now backfills semantic hints from function ownership metadata keyed by `target_va` +- owner-only metadata (a selector plus a resolved owning class whose library is unknown) can still rewrite indirect selector calls to deterministic owner-qualified call paths +- pool semantic hints are built from the function a pool entry's `target_va` points at, joined through typed `ClassId`/`LibraryId` edges; host-side `ProgramHints` fill gaps the model left and never override a model fact - when metadata includes `target_va` and that address resolves to a non-generic symbol, indirect calls can be rewritten to the resolved symbol path (with `target_va` traceability in comments) - model-backed canonical naming now deterministically tags Dart stdlib (`dart:*`), Flutter framework (`package:flutter/*`), and package-owned calls (`package:*`) when adapter metadata includes class/library ownership - pool target symbol synthesis now also emits deterministic `package___` names for `package:*` library targets, improving generic direct-call replacement in app/dependency code paths - symbol merge precedence now upgrades heuristic canonical names (`dart_*`, `flutter_*`, `package_*`) to stronger external symbols when both map to the same VA, reducing synthetic call names when symbol maps/ELFs are provided - symbol merge now uses an explicit quality lattice (`placeholder` < `heuristic` < `external` < `exact`) and reports final name-quality mix plus merge replacement diagnostics under `name_resolution` in `report.json` -- adapter schema reporting now includes `function_name_kind_breakdown` (`exact`, `external`, `heuristic`, `placeholder`, `unknown`, `unspecified`) so model naming confidence can be tracked across versions/backends +- `report.json` `model.function_name_provenance` counts recovered function names by provenance (`exact`, `derived`, `heuristic`) plus `unnamed`, so model naming confidence is trackable across versions/backends without a producer-supplied quality string - decompile reports now also include `adapter_selection` tracing (requested backend, resolved backend, adapter executable and manifest mapping, snapshot hash agreement) plus best-effort `engine_fingerprint_context` from nearby or APK-bundled `libflutter.so` - decompile `report.json` now includes a dedicated `compatibility` section with schema support status, manifest-entry presence, snapshot hash alignment, and warning diagnostics -- `flutterdec info` now surfaces lightweight compatibility signals too (`adapter_kind`, manifest-entry presence, snapshot-hash match, warnings) so researchers can triage adapter health without full decompile +- `flutterdec info` now surfaces lightweight compatibility signals too (`requested_backend`/`resolved_backend`/`backend_fallback_reason`, `producer_id`, `producer_trust`, `compatibility_record_sha256`, `snapshot_identity_is_exact`, `model_capabilities`, warnings) so researchers can triage adapter health without full decompile - decompile/diff now support `--require-snapshot-hash-match` for strict adapter-vs-loader hash enforcement; `diff_report.json` now also reports per-side snapshot hash match booleans - CLI now includes `flutterdec diff --old ... --new ...` to compare two builds at the recovered-function descriptor level (added/removed/common counts plus top changed signatures), with the same scope/package filters used by decompile; diff output now also normalizes unstable `file://.../.dart_tool/flutter_build/...` URIs and reports package-level churn summaries (`added_packages_top`, `removed_packages_top`) - generic symbol detection now also covers common tool-generated placeholders (`FUN_`, `nullsub_*`, `loc_*`, `off_*`) so deterministic semantic/external names can replace them @@ -282,7 +296,7 @@ Current scope: - capped selection now deterministically seeds one function per discovered bootflow category (`main`, `runapp`, `deeplink`, `activity`, `bootstrap`) before normal diversity fill, so low `--max-functions` runs preserve key entry/deeplink coverage - blutter adapter ingestion now synthesizes deterministic bootflow pool metadata from recovered function names (`BootMainCandidate`, `BootRunAppCandidate`, `DeepLinkHandlerCandidate`, `ActivityHandlerCandidate`, `BootstrapInitCandidate`) so main/runApp/deeplink/activity/init targets carry explicit `target_va` hints even when broader symbol data is sparse; activity and bootstrap candidates are now gated by owner/library context to reduce false positives from generic app methods - disassembly prioritization now dampens framework/stdlib bootflow boosts for deeplink/activity/bootstrap candidate kinds so app-owned handlers dominate capped reverse-engineering output -- decompile reports include a `bootflow_discovery` section in `report.json` with categorized deterministic targets (`main`, `runapp`, `deeplink`, `activity`, `bootstrap`) and metadata (`decoded_kind`, `selector`, `target_va`, owner and library context); overlapping discoveries for the same category/target/selector are deduplicated +- decompile reports include a `bootflow_discovery` section in `report.json` with categorized deterministic targets (`main`, `runapp`, `deeplink`, `activity`, `bootstrap`) and metadata (`kind`, `source`, `provenance`, `selector`, `target_va`, owner and library context); overlapping discoveries for the same category/target/selector are deduplicated - decompile now inspects `AndroidManifest.xml` directly from APK inputs and exposes `android_manifest` diagnostics in `report.json` (`parse_mode`, per-signal confidence, `main_launcher`, `view_browsable`, activity names, deeplink entries, parse errors, and synthetic manifest-hint counts); parsing is binary-AXML first with deterministic string-pool decoding and heuristic fallback, and manifest-derived candidate hints are injected into model metadata as `Manifest*Candidate` entries to reinforce deterministic entrypoint/deeplink/activity prioritization when adapter symbols are sparse - APK-oriented stages now share a loader-level `ApkSession` that opens the ZIP once per `info` or `decompile` run, indexes entry names, and caches entry bytes on demand; loader snapshot extraction, manifest inspection, APK startup scanning, and engine fingerprint lookup now reuse that session instead of reopening and rescanning the APK independently - `info` and `decompile` now also inspect APK `classes*.dex` entries for Android startup evidence and expose `android_startup` diagnostics (presence/confidence, scanned dex files, parse errors, Flutter embedding callsites, JNI/bootstrap stages, and recovered `DartEntrypoint` callsites when present); this is implemented in core as a report-focused APK bytecode pass and is controllable through the engine toggle `apk_startup_analysis` diff --git a/crates/flutterdec-adapter/Cargo.toml b/crates/flutterdec-adapter/Cargo.toml index 68a2c14d..8632d738 100644 --- a/crates/flutterdec-adapter/Cargo.toml +++ b/crates/flutterdec-adapter/Cargo.toml @@ -9,3 +9,5 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true tempfile.workspace = true +sha2.workspace = true +flutterdec-loader = { path = "../flutterdec-loader" } diff --git a/crates/flutterdec-adapter/src/lib.rs b/crates/flutterdec-adapter/src/lib.rs index ca5878ed..d91cf4b1 100644 --- a/crates/flutterdec-adapter/src/lib.rs +++ b/crates/flutterdec-adapter/src/lib.rs @@ -1,109 +1,33 @@ +//! Host side of the adapter boundary. +//! +//! One adapter run is one process: the host writes the snapshot regions and an +//! [`protocol::AdapterRequest`] into a scratch directory, runs the adapter +//! there, and reads back an [`protocol::AdapterResult`] plus a +//! [`model::ProgramModel`]. Nothing about the run is decided by the adapter: the +//! identity, the producer record, the compatibility binding, and the region +//! table are host facts that the model is checked against before it is returned. +//! +//! There is no v2/v3 path. [`model::ProgramModel::from_json`] rejects those +//! documents by version, so an old adapter fails loudly instead of being +//! silently reinterpreted. + +pub mod model; +pub mod primitives; +pub mod protocol; +pub mod validate; + use anyhow::{anyhow, bail, Context, Result}; +use flutterdec_loader::identity::IdentityRejection; +use model::{CompatibilityBinding, InputRegion, InputRegionName, Producer, ProgramModel}; +use primitives::{RelativePath, Sha256Digest}; +use protocol::{AdapterRequest, AdapterResult, AdapterStatus, BackendId, RequestedBackend}; use serde::{Deserialize, Serialize}; use std::fs; -use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::tempdir; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LibraryInfo { - pub id: u64, - pub uri: String, - pub name_display: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClassInfo { - pub id: u64, - pub name: String, - #[serde(rename = "super")] - pub super_name: String, - #[serde(rename = "lib")] - pub library_uri: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FunctionInfo { - pub id: u64, - pub name: String, - pub owner_class: String, - pub entry_va: u64, - pub size: u64, - pub code_section_va: u64, - #[serde(default)] - pub name_kind: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ObjectPoolEntry { - pub index: u64, - pub kind: String, - pub value: String, - #[serde(default)] - pub decoded_kind: Option, - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub target_va: Option, - #[serde(default)] - pub owner_class: Option, - #[serde(default)] - pub library_uri: Option, - #[serde(default)] - pub confidence: Option, - #[serde(default)] - pub source: Option, -} - -/// Hardware layout of the Dart `ObjectPool` object that `x27`/PP points at. -/// -/// Presence of this record is the adapter's assertion that `ObjectPoolEntry::index` -/// values live in the *hardware* index space, i.e. that a `ldr xN, [x27, #disp]` -/// resolves to `(disp - entries_offset) / word_size`. Adapters that only carve -/// strings out of the snapshot must leave it unset; without it the core refuses to -/// map pool references onto values instead of guessing. -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub struct PoolGeometry { - /// Byte offset of entry 0 from the PP base (0x10 on ARM64 AOT). - pub entries_offset: u64, - /// Stride between entries in bytes (8 on ARM64 AOT, even with compressed pointers). - pub word_size: u64, -} - -impl PoolGeometry { - /// Convert a PP-relative byte displacement into a pool entry index. - /// - /// Returns `None` for displacements below the first entry or not on a stride - /// boundary; those are pool-object header accesses, not entry loads. - pub fn index_for_displacement(&self, displacement: u64) -> Option { - if self.word_size == 0 { - return None; - } - let rel = displacement.checked_sub(self.entries_offset)?; - if !rel.is_multiple_of(self.word_size) { - return None; - } - Some(rel / self.word_size) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProgramModel { - pub schema_version: u32, - pub adapter_kind: String, - pub dart_version: String, - pub snapshot_hash: String, - pub arch: String, - pub libraries: Vec, - pub classes: Vec, - pub functions: Vec, - pub object_pool: Vec, - /// Set only by adapters that recover the real `ObjectPool`; see [`PoolGeometry`]. - #[serde(default)] - pub pool_geometry: Option, -} +use validate::HostSelectedContext; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AdapterManifest { @@ -117,17 +41,45 @@ pub struct AdapterManifestEntry { pub adapter: String, } +/// One snapshot region, as the host read it. +#[derive(Debug, Clone, Copy)] +pub struct AdapterRegionInput<'a> { + pub region: InputRegionName, + pub bytes: &'a [u8], + /// Load address. Required for executable regions, forbidden for data ones; + /// [`AdapterRequest::validate`] rejects the other combinations. + pub virtual_address: Option, +} + +/// Everything the host hands one adapter invocation. +/// +/// The host-selected facts are here rather than derived from adapter output on +/// the way back, because a fact the adapter supplies cannot check the adapter. #[derive(Debug, Clone)] pub struct AdapterInput<'a> { + /// Header-derived identity of the snapshot. Authoritative. + pub identity: &'a flutterdec_loader::identity::SnapshotIdentity, + /// Who the host believes is about to run, including the digest of the + /// artifact it is about to execute. + pub producer: Producer, + /// The compatibility decision that authorized this run. + pub compatibility: CompatibilityBinding, + pub regions: Vec>, + /// The original artifact, for backends that re-read it themselves. pub input_path: Option<&'a Path>, pub libapp_path: Option<&'a Path>, - pub vm_data: &'a [u8], - pub isolate_data: &'a [u8], - pub vm_instr: &'a [u8], - pub isolate_instr: &'a [u8], - pub vm_instr_va: u64, - pub isolate_instr_va: u64, - pub backend: Option<&'a str>, + pub requested_backend: RequestedBackend, +} + +/// What one adapter invocation produced, with the facts about the run that the +/// core needs and must not re-derive from the model. +#[derive(Debug, Clone)] +pub struct AdapterRun { + pub model: ProgramModel, + /// The backend that actually ran, as the protocol reported it. + pub resolved_backend: BackendId, + pub fallback_reason: Option, + pub diagnostics: Vec, } fn manifest_path(repo_root: &Path) -> PathBuf { @@ -200,10 +152,7 @@ pub fn install_adapter(repo_root: &Path, dart_hash: &str) -> Result { fs::create_dir_all(&out_dir)?; let out = out_dir.join(name); - let script = format!( - "#!/usr/bin/env python3\nfrom pathlib import Path\nimport sys\nroot = Path(__file__).resolve().parents[1]\nsys.path.insert(0, str(root / 'python'))\nimport adapter_template\nif __name__ == '__main__':\n raise SystemExit(adapter_template.entrypoint(default_snapshot_hash={:?}, default_version='unknown'))\n", - dart_hash - ); + let script = "#!/usr/bin/env python3\nfrom pathlib import Path\nimport sys\nroot = Path(__file__).resolve().parents[1]\nsys.path.insert(0, str(root / 'python'))\nimport adapter_template\nif __name__ == '__main__':\n raise SystemExit(adapter_template.entrypoint())\n"; fs::write(&out, script).with_context(|| format!("write adapter script: {}", out.display()))?; let mut perms = fs::metadata(&out)?.permissions(); @@ -237,80 +186,187 @@ pub fn resolve_adapter_exec(repo_root: &Path, dart_hash: &str) -> Result Result<()> { - if model.schema_version != 2 && model.schema_version != 3 { - bail!( - "unsupported adapter schema version {}", - model.schema_version - ); - } - if model.arch != "arm64" { - bail!("adapter returned unsupported arch {}", model.arch); - } - if model.functions.is_empty() { - bail!("adapter returned no functions"); +fn region_file_name(region: InputRegionName) -> &'static str { + match region { + InputRegionName::VmData => "vm_data.bin", + InputRegionName::IsolateData => "isolate_data.bin", + InputRegionName::VmInstructions => "vm_instructions.bin", + InputRegionName::IsolateInstructions => "isolate_instructions.bin", } - Ok(()) } -pub fn run_adapter(exec_path: &Path, input: &AdapterInput<'_>) -> Result { - let tmp = tempdir().context("create tempdir for adapter")?; +const OUTPUT_MODEL_PATH: &str = "model.json"; +const REQUEST_PATH: &str = "request.json"; +const RESULT_PATH: &str = "result.json"; - let vm_data = tmp.path().join("vm_data.bin"); - let iso_data = tmp.path().join("iso_data.bin"); - let vm_instr = tmp.path().join("vm_instr.bin"); - let iso_instr = tmp.path().join("iso_instr.bin"); - let out_json = tmp.path().join("model.json"); +/// Wrap an identity rejection so it survives as a typed cause. +/// +/// `anyhow::Error::new` keeps the `IdentityRejection` downcastable, so a caller +/// can act on *which* check refused the snapshot rather than parse a message. +pub fn identity_rejected(rejection: IdentityRejection) -> anyhow::Error { + anyhow::Error::new(rejection).context("snapshot identity may not authorize an adapter") +} - fs::File::create(&vm_data)?.write_all(input.vm_data)?; - fs::File::create(&iso_data)?.write_all(input.isolate_data)?; - fs::File::create(&vm_instr)?.write_all(input.vm_instr)?; - fs::File::create(&iso_instr)?.write_all(input.isolate_instr)?; +/// Run one adapter and return a model that has already been checked against the +/// host's own view of the snapshot. +/// +/// The order matters: the request is validated before the process is spawned, +/// and the model is validated before it is handed back, so neither a malformed +/// question nor a mismatched answer reaches the core. +pub fn run_adapter(exec_path: &Path, input: &AdapterInput<'_>) -> Result { + // The gate, restated at the boundary itself. Callers gate earlier so that a + // rejected identity never reaches a manifest or the filesystem, but this is + // the last place a process can be spawned, and a public entry point that + // trusts its caller to have checked is a public entry point that will one + // day be called by a caller that did not. + input + .identity + .exact_selection_key() + .map_err(identity_rejected)?; + + let tmp = tempdir().context("create scratch directory for adapter")?; + let work = tmp.path(); + + let mut handles = Vec::with_capacity(input.regions.len()); + let mut host_regions: Vec = Vec::with_capacity(input.regions.len()); + for region in &input.regions { + let name = region_file_name(region.region); + fs::write(work.join(name), region.bytes) + .with_context(|| format!("write adapter input region {}", region.region))?; + let digest = Sha256Digest::of(region.bytes); + let size = region.bytes.len() as u64; + handles.push(protocol::InputHandle { + region: region.region, + path: RelativePath::parse(name).map_err(|err| anyhow!(err))?, + size, + sha256: digest.clone(), + virtual_address: region.virtual_address, + executable: region.region.is_executable(), + }); + host_regions.push(InputRegion { + region: region.region, + size, + sha256: digest, + virtual_address: region.virtual_address, + executable: region.region.is_executable(), + }); + } + handles.sort_by_key(|h| h.region); + host_regions.sort_by_key(|r| r.region); + + let request = AdapterRequest { + protocol_major: protocol::PROTOCOL_MAJOR, + model_major: model::MODEL_VERSION, + compatibility: input.compatibility.clone(), + producer: input.producer.clone(), + identity: input.identity.clone(), + requested_backend: input.requested_backend, + inputs: handles, + output: RelativePath::parse(OUTPUT_MODEL_PATH).map_err(|err| anyhow!(err))?, + }; + // Fail before spawn, not after: a request the host itself would reject is + // not a request an adapter should get a chance to answer. + request + .validate() + .map_err(|err| anyhow!("adapter request is invalid: {}", err))?; + fs::write(work.join(REQUEST_PATH), request.to_json()).context("write adapter request")?; let mut cmd = Command::new(exec_path); - cmd.arg("--vm-data") - .arg(&vm_data) - .arg("--isolate-data") - .arg(&iso_data) - .arg("--vm-instr") - .arg(&vm_instr) - .arg("--isolate-instr") - .arg(&iso_instr) - .arg("--vm-instr-va") - .arg(input.vm_instr_va.to_string()) - .arg("--isolate-instr-va") - .arg(input.isolate_instr_va.to_string()) - .arg("--out") - .arg(&out_json); + cmd.current_dir(work) + .arg("--request") + .arg(REQUEST_PATH) + .arg("--result") + .arg(RESULT_PATH); if let Some(path) = input.input_path { - cmd.arg("--input-path").arg(path); + cmd.arg("--input-path").arg(absolute(path)); } if let Some(path) = input.libapp_path { - cmd.arg("--libapp-path").arg(path); - } - if let Some(backend) = input.backend { - cmd.env("FLUTTERDEC_ADAPTER_BACKEND", backend); + cmd.arg("--libapp-path").arg(absolute(path)); } let output = cmd .output() .with_context(|| format!("launch adapter: {}", exec_path.display()))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); + let result_path = work.join(RESULT_PATH); + if !output.status.success() && !result_path.exists() { return Err(anyhow!( - "adapter failed with status {}\nstdout:\n{}\nstderr:\n{}", + "adapter failed with status {} and wrote no result document\nstdout:\n{}\nstderr:\n{}", output.status, - stdout, - stderr + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + + let result_bytes = fs::read(&result_path) + .with_context(|| format!("read adapter result: {}", result_path.display()))?; + let result = AdapterResult::from_json(&result_bytes) + .map_err(|err| anyhow!("adapter result is not protocol v1: {}", err))?; + result + .validate_against(&request) + .map_err(|err| anyhow!("adapter result does not answer the request: {}", err))?; + + if result.status != AdapterStatus::Ok { + let error = result + .error + .as_ref() + .expect("a non-ok result carries an error"); + return Err(anyhow!( + "adapter reported {:?} ({:?}): {}\nstderr:\n{}", + result.status, + error.code, + error.message, + String::from_utf8_lossy(&output.stderr) )); } - let bytes = fs::read(&out_json) - .with_context(|| format!("read adapter output: {}", out_json.display()))?; - let model = serde_json::from_slice::(&bytes).context("parse adapter output")?; - validate_model(&model)?; - Ok(model) + let model_rel = result.model.as_ref().expect("an ok result carries a model"); + if model_rel.as_str() != request.output.as_str() { + bail!( + "adapter wrote its model to {:?} instead of the requested {:?}", + model_rel.as_str(), + request.output.as_str() + ); + } + let model_bytes = fs::read(work.join(model_rel.as_str())) + .with_context(|| format!("read adapter model: {}", model_rel.as_str()))?; + let model = ProgramModel::from_json(&model_bytes) + .map_err(|err| anyhow!("adapter model rejected: {}", err))?; + + let host = HostSelectedContext { + identity: input.identity.clone(), + producer: input.producer.clone(), + compatibility: input.compatibility.clone(), + regions: host_regions, + }; + validate::validate(&model, &host) + .map_err(|err| anyhow!("adapter model failed semantic validation: {}", err))?; + + Ok(AdapterRun { + model, + resolved_backend: result + .resolved_backend + .expect("an ok result names its backend"), + fallback_reason: result.fallback_reason, + diagnostics: result.diagnostics, + }) +} + +/// An absolute path for a caller-supplied artifact. +/// +/// The adapter runs with its working directory set to the scratch dir, so a +/// relative path handed straight through would resolve somewhere the caller did +/// not mean. Canonicalizing fails only for a path that does not exist yet, and +/// joining the current directory is still absolute. +fn absolute(path: &Path) -> PathBuf { + if let Ok(canonical) = path.canonicalize() { + return canonical; + } + if path.is_absolute() { + return path.to_path_buf(); + } + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) } #[cfg(test)] @@ -326,7 +382,7 @@ mod tests { fs::create_dir_all(repo.join("adapters/installed")).expect("mkdir"); fs::write( repo.join("adapters/python/adapter_template.py"), - "def entrypoint(default_snapshot_hash='x', default_version='unknown'): return 0\n", + "def entrypoint(): return 0\n", ) .expect("write template"); @@ -337,227 +393,4 @@ mod tests { assert_eq!(manifest.entries.len(), 1); assert_eq!(manifest.entries[0].snapshot_hash, "abcd1234"); } - - #[test] - fn run_adapter_blutter_backend_synthesizes_entrypoint_candidate() { - let td = tempdir().expect("tempdir"); - let root = td.path(); - let python_dir = root.join("python"); - fs::create_dir_all(&python_dir).expect("mkdir python"); - - let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .expect("canonicalize repo root"); - let template_src = repo_root.join("adapters/python/adapter_template.py"); - fs::copy(&template_src, python_dir.join("adapter_template.py")) - .expect("copy adapter template"); - - let fake_blutter = root.join("fake_blutter"); - fs::write( - &fake_blutter, - r#"#!/usr/bin/env python3 -from pathlib import Path -import sys - -if len(sys.argv) < 3: - raise SystemExit("usage: fake_blutter ") -out_dir = Path(sys.argv[2]) -asm_dir = out_dir / "asm" -asm_dir.mkdir(parents=True, exist_ok=True) -(asm_dir / "main.dart").write_text( - "// lib: 0, url: package:app/main.dart\n" - " dynamic main() {\n" - "// ** addr: 0x1000, size: 0x10\n" - "}\n", - encoding="utf-8", -) -(asm_dir / "router.dart").write_text( - "// lib: 1, url: package:app/router.dart\n" - "class RouterHost {\n" - " dynamic onNewIntent() {\n" - "// ** addr: 0x1010, size: 0x10\n" - " }\n" - "}\n", - encoding="utf-8", -) -(asm_dir / "subject.dart").write_text( - "// lib: 2, url: package:app/state/subject.dart\n" - "class Subject {\n" - " dynamic onResume() {\n" - "// ** addr: 0x1020, size: 0x10\n" - " }\n" - "}\n", - encoding="utf-8", -) -(out_dir / "pp.txt").write_text("", encoding="utf-8") -"#, - ) - .expect("write fake blutter"); - let mut fake_perms = fs::metadata(&fake_blutter).expect("metadata").permissions(); - fake_perms.set_mode(0o755); - fs::set_permissions(&fake_blutter, fake_perms).expect("chmod fake blutter"); - - let exec = root.join("adapter_exec.py"); - fs::write( - &exec, - "#!/usr/bin/env python3\nfrom pathlib import Path\nimport os\nimport sys\nroot = Path(__file__).resolve().parent\nos.environ['FLUTTERDEC_BLUTTER_CMD'] = str(root / 'fake_blutter')\nsys.path.insert(0, str(root / 'python'))\nimport adapter_template\nif __name__ == '__main__':\n raise SystemExit(adapter_template.entrypoint(default_snapshot_hash='testhash', default_version='unknown'))\n", - ) - .expect("write exec"); - let mut perms = fs::metadata(&exec).expect("metadata").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&exec, perms).expect("chmod exec"); - - let input_dir = root.join("input"); - fs::create_dir_all(&input_dir).expect("mkdir input"); - let input_file = input_dir.join("app.apk"); - fs::write(&input_file, b"dummy").expect("write dummy input"); - - let vm_data = vec![0u8; 64]; - let iso_data = vec![0u8; 64]; - let vm_instr = vec![0u8; 16]; - let iso_instr = vec![0u8; 16]; - let input = AdapterInput { - input_path: Some(&input_file), - libapp_path: None, - vm_data: &vm_data, - isolate_data: &iso_data, - vm_instr: &vm_instr, - isolate_instr: &iso_instr, - vm_instr_va: 0, - isolate_instr_va: 0, - backend: Some("blutter"), - }; - - let model = run_adapter(&exec, &input).expect("run adapter"); - assert!(model.functions.iter().any(|f| f.entry_va == 0x1000)); - let entrypoint = model - .object_pool - .iter() - .find(|e| e.decoded_kind.as_deref() == Some("EntryPointCandidate")) - .expect("entrypoint candidate"); - assert_eq!(entrypoint.selector.as_deref(), Some("main")); - assert_eq!(entrypoint.target_va, Some(0x1000)); - let deeplink = model - .object_pool - .iter() - .find(|e| e.decoded_kind.as_deref() == Some("DeepLinkHandlerCandidate")) - .expect("deeplink candidate"); - assert_eq!(deeplink.selector.as_deref(), Some("onNewIntent")); - assert_eq!(deeplink.target_va, Some(0x1010)); - let activity_targets = model - .object_pool - .iter() - .filter(|e| e.decoded_kind.as_deref() == Some("ActivityHandlerCandidate")) - .map(|e| e.target_va.unwrap_or_default()) - .collect::>(); - assert!( - activity_targets.contains(&0x1010), - "expected onNewIntent to be tagged as activity handler" - ); - assert!( - !activity_targets.contains(&0x1020), - "generic onResume in non-activity owner should not be tagged as activity handler" - ); - } - - #[test] - fn run_adapter_r2flutter_backend_normalizes_structured_superclass() { - let td = tempdir().expect("tempdir"); - let root = td.path(); - let python_dir = root.join("python"); - fs::create_dir_all(&python_dir).expect("mkdir python"); - - let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .expect("canonicalize repo root"); - let template_src = repo_root.join("adapters/python/adapter_template.py"); - fs::copy(&template_src, python_dir.join("adapter_template.py")) - .expect("copy adapter template"); - - let fake_r2flutter = root.join("fake_r2flutter"); - fs::write( - &fake_r2flutter, - r#"#!/usr/bin/env python3 -import json -import sys - -if "-jH" in sys.argv: - value = { - "dart_version": "3.6.2", - "hash": "testhash", - } -elif "-ji" in sys.argv: - value = { - "entries": [ - { - "index": 0, - "address": 4096, - "name": "method.Child.test", - } - ] - } -elif "-jxz" in sys.argv: - value = [] -elif "-jc" in sys.argv: - value = [ - { - "name": "Child", - "super": {"type_ref": 35836}, - }, - { - "name": "Widget", - "super": {"ref": 12, "name": "StatefulWidget"}, - } - ] -else: - raise SystemExit(1) - -print(json.dumps(value)) -"#, - ) - .expect("write fake r2flutter"); - let mut fake_perms = fs::metadata(&fake_r2flutter) - .expect("metadata") - .permissions(); - fake_perms.set_mode(0o755); - fs::set_permissions(&fake_r2flutter, fake_perms).expect("chmod fake r2flutter"); - - let exec = root.join("adapter_exec.py"); - fs::write( - &exec, - "#!/usr/bin/env python3\nfrom pathlib import Path\nimport os\nimport sys\nroot = Path(__file__).resolve().parent\nos.environ['FLUTTERDEC_R2FLUTTER_CMD'] = str(root / 'fake_r2flutter')\nsys.path.insert(0, str(root / 'python'))\nimport adapter_template\nif __name__ == '__main__':\n raise SystemExit(adapter_template.entrypoint(default_snapshot_hash='testhash', default_version='unknown'))\n", - ) - .expect("write exec"); - let mut perms = fs::metadata(&exec).expect("metadata").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&exec, perms).expect("chmod exec"); - - let input_file = root.join("libapp.so"); - fs::write(&input_file, b"dummy").expect("write dummy input"); - - let vm_data = vec![0u8; 64]; - let iso_data = vec![0u8; 64]; - let vm_instr = vec![0u8; 16]; - let iso_instr = vec![0u8; 16]; - let input = AdapterInput { - input_path: Some(&input_file), - libapp_path: None, - vm_data: &vm_data, - isolate_data: &iso_data, - vm_instr: &vm_instr, - isolate_instr: &iso_instr, - vm_instr_va: 0, - isolate_instr_va: 0, - backend: Some("r2flutter"), - }; - - let model = run_adapter(&exec, &input).expect("run adapter"); - assert_eq!(model.classes.len(), 2); - assert_eq!(model.classes[0].name, "Child"); - assert_eq!(model.classes[0].super_name, "Object"); - assert_eq!(model.classes[1].name, "Widget"); - assert_eq!(model.classes[1].super_name, "StatefulWidget"); - } } diff --git a/crates/flutterdec-adapter/src/model.rs b/crates/flutterdec-adapter/src/model.rs new file mode 100644 index 00000000..87e0a8a1 --- /dev/null +++ b/crates/flutterdec-adapter/src/model.rs @@ -0,0 +1,983 @@ +//! ProgramModel v4: the only model contract the host accepts. +//! +//! v2 and v3 were shaped so that "recovered nothing" and "recovered everything" +//! serialized the same way. Every name was a required `String`, every reference +//! was a display string matched by equality, and there was no place to say how +//! well anything was known, so an adapter that could not read a snapshot still +//! had to emit *something* and the core could not tell what it was looking at. +//! +//! v4 fixes that structurally rather than by convention: +//! +//! * Unknown is representable. Names are `Option`, so an unnamed function is a +//! function with no name rather than a function named `main`. +//! * Every recovered fact carries [`Provenance`], and per-domain +//! [`Capabilities`] say whether the domain is complete, partial, or +//! unavailable. A model that claims a complete domain and fills it with +//! guesses contradicts itself and is rejected. +//! * References are typed ids, so they can be checked, rather than strings that +//! silently fail to match. +//! * The model records who produced it, what it observed, and which +//! compatibility record authorized it, so the host can verify that the adapter +//! answered the question it was asked. +//! +//! Structural well-formedness is what this module owns. Semantic invariants that +//! need the host's own view of the world live in [`crate::validate`]. + +use crate::primitives::Sha256Digest; +use flutterdec_loader::identity::SnapshotIdentity; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::fmt; + +/// The only accepted model version. v2 and v3 are rejected, not migrated. +pub const MODEL_VERSION: u32 = 4; + +/// How well a single recovered fact is known. +/// +/// There is deliberately no `Unavailable` variant: a fact that is unavailable is +/// an absent record or a `None`, not a record carrying an "unknown" provenance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Provenance { + /// Read from the snapshot by a parser that understands its layout. + Exact, + /// Computed from exact facts by a rule that cannot be wrong if they are not. + Derived, + /// A guess from pattern evidence. May be wrong, and callers must treat it so. + Heuristic, +} + +impl Provenance { + pub fn as_str(self) -> &'static str { + match self { + Self::Exact => "exact", + Self::Derived => "derived", + Self::Heuristic => "heuristic", + } + } + + /// Whether a fact with this provenance is allowed to carry a confidence + /// score. Only guesses have confidence; a number attached to an exact fact + /// is decoration that makes the model look calibrated when it is not. + pub fn admits_confidence(self) -> bool { + matches!(self, Self::Heuristic) + } +} + +/// How much of one domain the producer recovered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityLevel { + /// Everything the snapshot contains for this domain is present and exact. + Complete, + /// Some of it is present. Absence proves nothing. + Partial, + /// None of it was recovered. The domain must be empty and say why. + Unavailable, +} + +impl CapabilityLevel { + pub fn as_str(self) -> &'static str { + match self { + Self::Complete => "complete", + Self::Partial => "partial", + Self::Unavailable => "unavailable", + } + } +} + +/// The domains a model reports capability for, named so validation can iterate +/// them rather than repeating the same check per field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Domain { + Libraries, + Classes, + ClassRelationships, + Functions, + FunctionNames, + ObjectPool, + PoolIndexSpace, +} + +impl Domain { + pub const ALL: [Domain; 7] = [ + Domain::Libraries, + Domain::Classes, + Domain::ClassRelationships, + Domain::Functions, + Domain::FunctionNames, + Domain::ObjectPool, + Domain::PoolIndexSpace, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Libraries => "libraries", + Self::Classes => "classes", + Self::ClassRelationships => "class_relationships", + Self::Functions => "functions", + Self::FunctionNames => "function_names", + Self::ObjectPool => "object_pool", + Self::PoolIndexSpace => "pool_index_space", + } + } +} + +impl fmt::Display for Domain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Capabilities { + pub libraries: CapabilityLevel, + pub classes: CapabilityLevel, + /// Superclass edges specifically: a producer can recover class names from + /// strings without recovering the hierarchy between them. + pub class_relationships: CapabilityLevel, + pub functions: CapabilityLevel, + /// Names specifically: code ranges are frequently recoverable when the names + /// attached to them are not. + pub function_names: CapabilityLevel, + pub object_pool: CapabilityLevel, + /// Whether pool indexes mean hardware displacements. Separate from + /// `object_pool` because listing entries and knowing where they live are + /// different achievements. + pub pool_index_space: CapabilityLevel, +} + +impl Capabilities { + pub fn level(&self, domain: Domain) -> CapabilityLevel { + match domain { + Domain::Libraries => self.libraries, + Domain::Classes => self.classes, + Domain::ClassRelationships => self.class_relationships, + Domain::Functions => self.functions, + Domain::FunctionNames => self.function_names, + Domain::ObjectPool => self.object_pool, + Domain::PoolIndexSpace => self.pool_index_space, + } + } + + /// Nothing was recovered. The honest capability set for a snapshot no parser + /// understands. + pub fn all_unavailable() -> Self { + Self { + libraries: CapabilityLevel::Unavailable, + classes: CapabilityLevel::Unavailable, + class_relationships: CapabilityLevel::Unavailable, + functions: CapabilityLevel::Unavailable, + function_names: CapabilityLevel::Unavailable, + object_pool: CapabilityLevel::Unavailable, + pool_index_space: CapabilityLevel::Unavailable, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticSeverity { + Info, + Warning, + Error, +} + +/// Closed set of reasons a model can give for what it did not do. +/// +/// Closed because a free-text reason cannot be checked, and an unavailable +/// domain with an unparseable explanation is indistinguishable from one with no +/// explanation at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticCode { + /// The domain was not attempted, because the parser has no support for it. + DomainUnsupported, + /// The domain was attempted and nothing was recovered. + DomainNotRecovered, + /// The domain was attempted and only part of it was recovered. + DomainPartiallyRecovered, + /// Records in the domain are pattern guesses, not parser output. + DomainHeuristicOnly, + /// A region of the input could not be decoded. + RegionNotDecoded, + /// A record was dropped because it did not survive the producer's own checks. + RecordDiscarded, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Diagnostic { + pub code: DiagnosticCode, + pub severity: DiagnosticSeverity, + /// The domain or region the diagnostic is about, when it is about one. + pub subject: Option, + pub message: String, +} + +impl Diagnostic { + /// The diagnostic an unavailable domain is required to carry. + pub fn unavailable(domain: Domain, message: impl Into) -> Self { + Self { + code: DiagnosticCode::DomainNotRecovered, + severity: DiagnosticSeverity::Warning, + subject: Some(domain.to_string()), + message: message.into(), + } + } +} + +/// How far the host trusts the thing that produced the model. +/// +/// Trust is host-assigned. An adapter that writes `registered` into its own +/// output has not become registered; validation compares this against what the +/// host selected and rejects the claim. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProducerTrust { + /// Reached through a compatibility record in the registry. + Registered, + /// Locally installed and digest-verified, with no registry record. + Local, + /// Neither. Output is evidence, not authority. + Untrusted, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Producer { + pub id: String, + pub version: String, + /// Digest of the artifact that ran, not of the sources it was built from. + pub artifact_sha256: Sha256Digest, + pub trust: ProducerTrust, +} + +/// The named regions an Android ARM64 AOT snapshot is carved into. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InputRegionName { + VmData, + IsolateData, + VmInstructions, + IsolateInstructions, +} + +impl InputRegionName { + pub const ALL: [InputRegionName; 4] = [ + InputRegionName::VmData, + InputRegionName::IsolateData, + InputRegionName::VmInstructions, + InputRegionName::IsolateInstructions, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::VmData => "vm_data", + Self::IsolateData => "isolate_data", + Self::VmInstructions => "vm_instructions", + Self::IsolateInstructions => "isolate_instructions", + } + } + + /// Whether this region holds code. Only executable regions can contain a + /// code range or be the target of a call. + pub fn is_executable(self) -> bool { + matches!(self, Self::VmInstructions | Self::IsolateInstructions) + } +} + +impl fmt::Display for InputRegionName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// One region as the host read it, digest included. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InputRegion { + pub region: InputRegionName, + pub size: u64, + pub sha256: Sha256Digest, + /// Load address. Present exactly for executable regions; a data region has + /// no address space that a code range could live in. + pub virtual_address: Option, + pub executable: bool, +} + +impl InputRegion { + /// One past the last byte, or `None` if the region overflows the address + /// space and is therefore not a region. + pub fn end_va(&self) -> Option { + self.virtual_address?.checked_add(self.size) + } + + /// Whether `[start, start + size)` lies wholly inside this region. + pub fn contains_range(&self, start: u64, size: u64) -> bool { + let (Some(base), Some(region_end)) = (self.virtual_address, self.end_va()) else { + return false; + }; + let Some(end) = start.checked_add(size) else { + return false; + }; + start >= base && end <= region_end + } + + pub fn contains_address(&self, address: u64) -> bool { + self.contains_range(address, 1) + } +} + +/// What the producer says it was given, so the host can check it against what it +/// actually handed over. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ObservedInput { + pub identity: SnapshotIdentity, + pub regions: Vec, +} + +impl ObservedInput { + pub fn region(&self, name: InputRegionName) -> Option<&InputRegion> { + self.regions.iter().find(|r| r.region == name) + } + + pub fn executable_regions(&self) -> impl Iterator { + self.regions.iter().filter(|r| r.executable) + } +} + +/// The registry decision that authorized this run, echoed back for checking. +/// +/// None of these are things an adapter may choose. They are recorded in the +/// model so that a model can be tied to the decision that produced it, and so a +/// model produced under a different decision is detectable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CompatibilityBinding { + /// Digest of the compatibility record the host selected. + pub record_sha256: Sha256Digest, + /// Which parser family the record pointed at. + pub parser_family_id: String, + pub profile_id: String, + pub profile_sha256: Sha256Digest, +} + +macro_rules! id_newtype { + ($name:ident, $doc:literal) => { + #[doc = $doc] + #[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, + )] + #[serde(transparent)] + pub struct $name(pub u32); + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} + +id_newtype!( + LibraryId, + "Model-local library id. Distinct type from the other ids so a class cannot reference a function." +); +id_newtype!(ClassId, "Model-local class id."); +id_newtype!(FunctionId, "Model-local function id."); + +/// A recovered name, with how it was recovered. +/// +/// Wrapping the string is what makes `Option` mean "no name was +/// recovered" instead of forcing a placeholder into a required field. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Name { + pub text: String, + pub provenance: Provenance, + /// Only meaningful, and only permitted, for heuristic names. + pub confidence: Option, +} + +impl Name { + pub fn exact(text: impl Into) -> Self { + Self { + text: text.into(), + provenance: Provenance::Exact, + confidence: None, + } + } +} + +impl Function { + /// The recovered name, if there is one. `None` is the honest answer for a + /// code range nobody could put a name to. + pub fn name_text(&self) -> Option<&str> { + Some(self.name.as_ref()?.text.as_str()) + } +} + +/// A half-open `[start_va, start_va + size)` span of code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodeRange { + pub start_va: u64, + pub size: u64, +} + +impl CodeRange { + /// `None` when the range runs off the end of the address space, which makes + /// it not a range rather than a range ending at `u64::MAX`. + pub fn end_va(&self) -> Option { + self.start_va.checked_add(self.size) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Library { + pub id: LibraryId, + /// The library's URI, e.g. `dart:core`. Not a display string. + pub uri: String, + pub display_name: Option, + pub provenance: Provenance, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Class { + pub id: ClassId, + pub name: String, + /// `None` means the owning library was not recovered. Producers that read + /// class names out of the snapshot without an attribution table land here, + /// and forcing them to name a library is how a class ends up filed under an + /// invented `package:app/main.dart`. + pub library: Option, + /// `None` means no superclass edge was recovered, which is not the same as + /// having no superclass. + pub super_class: Option, + pub provenance: Provenance, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Function { + pub id: FunctionId, + /// `None` when no name was recovered. A function still has a code range. + pub name: Option, + /// `None` when the owning class was not recovered, or the function has none. + pub owner: Option, + pub code: CodeRange, + /// Start address of the executable region the code lives in. Redundant with + /// the region table by design: a mismatch means the producer and the host + /// disagree about the address space. + pub code_section_va: u64, + /// Provenance of the code range, independently of the name's. + pub provenance: Provenance, +} + +/// What a pool entry's `index` counts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PoolIndexSpace { + /// Real `ObjectPool` entry indexes: `ldr xN, [x27, #disp]` resolves through + /// the geometry. Requires geometry to be present. + Hardware, + /// Positions in the producer's own list. Carries no address meaning, so a + /// pool reference in disassembly cannot be resolved through it. + Ordinal, +} + +/// Layout of the `ObjectPool` object `x27`/PP points at. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PoolGeometry { + /// Byte offset of entry 0 from the PP base (0x10 on ARM64 AOT). + pub entries_offset: u64, + /// Stride between entries in bytes (8 on ARM64 AOT, even with compressed + /// pointers). + pub word_size: u64, +} + +impl PoolGeometry { + /// Convert a PP-relative byte displacement into a pool entry index. + /// + /// `None` for displacements below the first entry or off a stride boundary; + /// those are pool-object header accesses, not entry loads. + pub fn index_for_displacement(&self, displacement: u64) -> Option { + if self.word_size == 0 { + return None; + } + let rel = displacement.checked_sub(self.entries_offset)?; + if rel % self.word_size != 0 { + return None; + } + Some(rel / self.word_size) + } + + /// The displacement entry `index` sits at, or `None` if it does not fit in + /// the address space. + pub fn displacement_for_index(&self, index: u64) -> Option { + index + .checked_mul(self.word_size)? + .checked_add(self.entries_offset) + } +} + +/// What a pool entry holds. Closed, because an open string here is how "we did +/// not decode this" became a decoded kind in v3. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PoolEntryKind { + /// A Dart `String` object. + String, + /// A tagged Smi or other immediate. + Immediate, + /// A reference to code, with `target_va` set. + Code, + /// A field or offset reference. + Field, + /// A class reference. + Class, + /// A selector/name used for dynamic dispatch. + Selector, + /// A slot the producer read but did not decode. Not a guess. + Undecoded, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PoolEntry { + pub index: u64, + pub kind: PoolEntryKind, + /// Decoded value, when there is one. `None` for `Undecoded`. + pub value: Option, + /// Set only for entries that reference code, and only to an address inside + /// an executable region. + pub target_va: Option, + pub provenance: Provenance, + /// Only permitted for heuristic entries. + pub confidence: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ObjectPool { + pub index_space: PoolIndexSpace, + /// Present exactly when `index_space` is `Hardware`. + pub geometry: Option, + pub entries: Vec, +} + +impl ObjectPool { + /// The empty pool a producer that recovered nothing must emit. + pub fn unavailable() -> Self { + Self { + index_space: PoolIndexSpace::Ordinal, + geometry: None, + entries: Vec::new(), + } + } +} + +/// The one place a model may carry fields this contract does not define. +/// +/// Everything else is `deny_unknown_fields`. Confining extension to a named +/// object is what makes "unknown key" a rejectable condition instead of a +/// silently ignored one, while still leaving room for a producer to attach data +/// the host is free to ignore. +pub type Extensions = BTreeMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProgramModel { + /// Must be [`MODEL_VERSION`]. Named `model_version` rather than + /// `schema_version` so a v2/v3 document cannot deserialize by accident. + pub model_version: u32, + pub producer: Producer, + pub input: ObservedInput, + pub compatibility: CompatibilityBinding, + pub capabilities: Capabilities, + pub libraries: Vec, + pub classes: Vec, + pub functions: Vec, + pub object_pool: ObjectPool, + pub diagnostics: Vec, + #[serde(default)] + pub extensions: Extensions, +} + +/// Why a document is not a v4 model. +#[derive(Debug)] +pub enum ModelParseError { + /// Not JSON, or not a JSON object. + NotAnObject(serde_json::Error), + /// A `schema_version` field: this is a v2 or v3 document. + LegacyModel(u64), + /// A `model_version` this build does not accept. + UnsupportedVersion(u64), + /// No version field at all. + MissingVersion, + /// Right version, wrong shape. + Malformed(serde_json::Error), +} + +impl fmt::Display for ModelParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotAnObject(err) => write!(f, "adapter output is not a JSON object: {}", err), + Self::LegacyModel(version) => write!( + f, + "adapter output is a legacy schema_version {} model; ProgramModel v{} is the only accepted contract and there is no compatibility shim", + version, MODEL_VERSION + ), + Self::UnsupportedVersion(version) => write!( + f, + "unsupported model_version {}; expected {}", + version, MODEL_VERSION + ), + Self::MissingVersion => write!( + f, + "adapter output has no model_version field; expected {}", + MODEL_VERSION + ), + Self::Malformed(err) => write!(f, "adapter output is not a valid v4 model: {}", err), + } + } +} + +impl std::error::Error for ModelParseError {} + +impl ProgramModel { + /// Parse fresh JSON as a v4 model. + /// + /// The version is read before the document is deserialized so that a v2/v3 + /// document is rejected as the wrong contract rather than as a pile of + /// missing-field errors, which is the difference between an operator + /// knowing to regenerate it and an operator guessing. + pub fn from_json(bytes: &[u8]) -> Result { + let raw: Value = serde_json::from_slice(bytes).map_err(ModelParseError::NotAnObject)?; + if let Some(legacy) = raw.get("schema_version").and_then(Value::as_u64) { + return Err(ModelParseError::LegacyModel(legacy)); + } + match raw.get("model_version").and_then(Value::as_u64) { + Some(version) if version == u64::from(MODEL_VERSION) => {} + Some(version) => return Err(ModelParseError::UnsupportedVersion(version)), + None => return Err(ModelParseError::MissingVersion), + } + serde_json::from_value(raw).map_err(ModelParseError::Malformed) + } + + /// Canonical bytes. Struct field order and `BTreeMap` extensions make this + /// a function of the value alone, so equal models serialize equal. + pub fn to_canonical_json(&self) -> Vec { + serde_json::to_vec(self).expect("ProgramModel is always serializable") + } + + pub fn library(&self, id: LibraryId) -> Option<&Library> { + self.libraries.iter().find(|l| l.id == id) + } + + pub fn class(&self, id: ClassId) -> Option<&Class> { + self.classes.iter().find(|c| c.id == id) + } + + pub fn function(&self, id: FunctionId) -> Option<&Function> { + self.functions.iter().find(|f| f.id == id) + } + + /// The URI of the library a class belongs to, when both are known. + pub fn class_library_uri(&self, id: ClassId) -> Option<&str> { + let class = self.class(id)?; + Some(self.library(class.library?)?.uri.as_str()) + } + + /// The owning class's name, or `None` when no owner was recovered. + /// + /// `None` rather than a stand-in: every consumer that used to read v3's + /// required `owner_class` string got `"Global"` for both "top level" and + /// "we did not find out", and could not tell the two apart. + pub fn owner_name(&self, function: &Function) -> Option<&str> { + Some(self.class(function.owner?)?.name.as_str()) + } + + /// The URI of the library the function's owning class belongs to. + pub fn owner_library_uri(&self, function: &Function) -> Option<&str> { + self.class_library_uri(function.owner?) + } +} + +fn level_enum() -> Value { + json!({ "type": "string", "enum": ["complete", "partial", "unavailable"] }) +} + +fn provenance_enum() -> Value { + json!({ "type": "string", "enum": ["exact", "derived", "heuristic"] }) +} + +fn digest_schema() -> Value { + json!({ "type": "string", "pattern": "^[0-9a-f]{64}$" }) +} + +fn u64_schema() -> Value { + json!({ "type": "integer", "minimum": 0, "maximum": 18446744073709551615u64 }) +} + +fn nullable_u64_schema() -> Value { + json!({ + "type": ["integer", "null"], + "minimum": 0, + "maximum": 18446744073709551615u64 + }) +} + +fn confidence_schema() -> Value { + json!({ "type": ["number", "null"], "minimum": 0.0, "maximum": 1.0 }) +} + +fn object(properties: Value, required: Vec<&str>) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": required, + "properties": properties, + }) +} + +fn identity_schema() -> Value { + object( + json!({ + "hash": { "type": ["string", "null"], "pattern": "^[0-9a-f]{32}$" }, + "hash_source": { "type": "string", "enum": ["header", "scan", "unavailable"] }, + "kind": { + "type": ["string", "null"], + "enum": ["full", "full_core", "full_jit", "full_aot", "unrecognized", null] + }, + "target_arch": { + "oneOf": [ + { "type": "string", "enum": ["arm64"] }, + object(json!({ "unsupported": { "type": "string" } }), vec!["unsupported"]) + ] + }, + "features": object( + json!({ + "raw": { "type": ["string", "null"] }, + "normalized": { "type": "array", "items": { "type": "string" } } + }), + vec!["raw", "normalized"], + ), + "pointer_compression": { + "type": "string", + "enum": ["compressed", "uncompressed", "unavailable", "conflicting"] + } + }), + vec![ + "hash", + "hash_source", + "kind", + "target_arch", + "features", + "pointer_compression", + ], + ) +} + +/// The closed JSON Schema for [`ProgramModel`]. +/// +/// Hand-built rather than derived, because the alternative is a schema-generator +/// dependency for one document. `schema_matches_rust_types` in this module's +/// tests walks a maximal model against this schema in both directions, so a +/// field added to a struct without a matching property here fails the build +/// rather than shipping a schema that quietly describes an older model. +pub fn schema() -> Value { + let library = object( + json!({ + "id": { "type": "integer", "minimum": 0, "maximum": 4294967295u32 }, + "uri": { "type": "string", "minLength": 1 }, + "display_name": { "type": ["string", "null"] }, + "provenance": provenance_enum(), + }), + vec!["id", "uri", "display_name", "provenance"], + ); + let class = object( + json!({ + "id": { "type": "integer", "minimum": 0, "maximum": 4294967295u32 }, + "name": { "type": "string", "minLength": 1 }, + "library": { "type": ["integer", "null"], "minimum": 0, "maximum": 4294967295u32 }, + "super_class": { "type": ["integer", "null"], "minimum": 0, "maximum": 4294967295u32 }, + "provenance": provenance_enum(), + }), + vec!["id", "name", "library", "super_class", "provenance"], + ); + let name = object( + json!({ + "text": { "type": "string", "minLength": 1 }, + "provenance": provenance_enum(), + "confidence": confidence_schema(), + }), + vec!["text", "provenance", "confidence"], + ); + let code_range = object( + json!({ "start_va": u64_schema(), "size": u64_schema() }), + vec!["start_va", "size"], + ); + let function = object( + json!({ + "id": { "type": "integer", "minimum": 0, "maximum": 4294967295u32 }, + "name": { "oneOf": [name, { "type": "null" }] }, + "owner": { "type": ["integer", "null"], "minimum": 0, "maximum": 4294967295u32 }, + "code": code_range, + "code_section_va": u64_schema(), + "provenance": provenance_enum(), + }), + vec![ + "id", + "name", + "owner", + "code", + "code_section_va", + "provenance", + ], + ); + let geometry = object( + json!({ "entries_offset": u64_schema(), "word_size": u64_schema() }), + vec!["entries_offset", "word_size"], + ); + let pool_entry = object( + json!({ + "index": u64_schema(), + "kind": { + "type": "string", + "enum": ["string", "immediate", "code", "field", "class", "selector", "undecoded"] + }, + "value": { "type": ["string", "null"] }, + "target_va": nullable_u64_schema(), + "provenance": provenance_enum(), + "confidence": confidence_schema(), + }), + vec![ + "index", + "kind", + "value", + "target_va", + "provenance", + "confidence", + ], + ); + let object_pool = object( + json!({ + "index_space": { "type": "string", "enum": ["hardware", "ordinal"] }, + "geometry": { "oneOf": [geometry, { "type": "null" }] }, + "entries": { "type": "array", "items": pool_entry }, + }), + vec!["index_space", "geometry", "entries"], + ); + let input_region = object( + json!({ + "region": { + "type": "string", + "enum": ["vm_data", "isolate_data", "vm_instructions", "isolate_instructions"] + }, + "size": u64_schema(), + "sha256": digest_schema(), + "virtual_address": nullable_u64_schema(), + "executable": { "type": "boolean" }, + }), + vec!["region", "size", "sha256", "virtual_address", "executable"], + ); + let diagnostic = object( + json!({ + "code": { + "type": "string", + "enum": [ + "domain_unsupported", + "domain_not_recovered", + "domain_partially_recovered", + "domain_heuristic_only", + "region_not_decoded", + "record_discarded" + ] + }, + "severity": { "type": "string", "enum": ["info", "warning", "error"] }, + "subject": { "type": ["string", "null"] }, + "message": { "type": "string" }, + }), + vec!["code", "severity", "subject", "message"], + ); + + let mut root = object( + json!({ + "model_version": { "type": "integer", "const": MODEL_VERSION }, + "producer": object( + json!({ + "id": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "artifact_sha256": digest_schema(), + "trust": { "type": "string", "enum": ["registered", "local", "untrusted"] }, + }), + vec!["id", "version", "artifact_sha256", "trust"], + ), + "input": object( + json!({ + "identity": identity_schema(), + "regions": { "type": "array", "items": input_region }, + }), + vec!["identity", "regions"], + ), + "compatibility": object( + json!({ + "record_sha256": digest_schema(), + "parser_family_id": { "type": "string", "minLength": 1 }, + "profile_id": { "type": "string", "minLength": 1 }, + "profile_sha256": digest_schema(), + }), + vec!["record_sha256", "parser_family_id", "profile_id", "profile_sha256"], + ), + "capabilities": object( + json!({ + "libraries": level_enum(), + "classes": level_enum(), + "class_relationships": level_enum(), + "functions": level_enum(), + "function_names": level_enum(), + "object_pool": level_enum(), + "pool_index_space": level_enum(), + }), + Domain::ALL.iter().map(|d| d.as_str()).collect(), + ), + "libraries": { "type": "array", "items": library }, + "classes": { "type": "array", "items": class }, + "functions": { "type": "array", "items": function }, + "object_pool": object_pool, + "diagnostics": { "type": "array", "items": diagnostic }, + "extensions": { + "description": "The only object in this schema that accepts undeclared keys. Hosts may ignore its contents; nothing in it carries authority.", + "type": "object", + "additionalProperties": true + }, + }), + vec![ + "model_version", + "producer", + "input", + "compatibility", + "capabilities", + "libraries", + "classes", + "functions", + "object_pool", + "diagnostics", + ], + ); + let map = root.as_object_mut().expect("schema root is an object"); + map.insert( + "$schema".to_string(), + json!("https://json-schema.org/draft/2020-12/schema"), + ); + map.insert("title".to_string(), json!("flutterdec ProgramModel v4")); + root +} diff --git a/crates/flutterdec-adapter/src/primitives.rs b/crates/flutterdec-adapter/src/primitives.rs new file mode 100644 index 00000000..fb4a2322 --- /dev/null +++ b/crates/flutterdec-adapter/src/primitives.rs @@ -0,0 +1,226 @@ +//! Newtypes the adapter boundary refuses to accept as bare strings. +//! +//! Both of these exist because the values they wrap arrive from outside the +//! host: a digest that is really a filename, or a path that is really `../..`, +//! is not a validation problem to be caught later but a value that must never +//! be constructed. Validation therefore lives in `Deserialize`, so parsing +//! untrusted JSON either yields a usable value or fails. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; + +/// A lowercase hex SHA-256. +/// +/// The snapshot hash is a compatibility fingerprint and is deliberately *not* +/// this type; these are content digests over bytes the host itself read. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(transparent)] +pub struct Sha256Digest(String); + +impl Sha256Digest { + pub fn of(bytes: &[u8]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(bytes); + Self(format!("{:x}", hasher.finalize())) + } + + pub fn parse(text: &str) -> Result { + if text.len() != 64 { + return Err(PrimitiveError::DigestLength(text.len())); + } + if !text + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(PrimitiveError::DigestAlphabet(text.to_string())); + } + Ok(Self(text.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Sha256Digest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Sha256Digest { + fn deserialize>(d: D) -> Result { + let raw = String::deserialize(d)?; + Self::parse(&raw).map_err(serde::de::Error::custom) + } +} + +/// A path handle that is relative, contained, and free of traversal. +/// +/// The adapter receives one of these per input and one for its output. Nothing +/// downstream re-checks them, so the containment guarantee has to hold from the +/// moment the value exists: no absolute path, no `..`, no `.`, no empty or +/// repeated separator, no backslash, no drive prefix, no NUL. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(transparent)] +pub struct RelativePath(String); + +impl RelativePath { + pub fn parse(text: &str) -> Result { + if text.is_empty() { + return Err(PrimitiveError::PathEmpty); + } + // A backslash is a separator on some hosts and an ordinary character on + // others, so a path containing one cannot mean the same thing to the + // host and the adapter. Refuse rather than pick an interpretation. + if text.contains('\\') { + return Err(PrimitiveError::PathBackslash(text.to_string())); + } + if text.contains('\0') { + return Err(PrimitiveError::PathNul(text.to_string())); + } + if text.starts_with('/') { + return Err(PrimitiveError::PathAbsolute(text.to_string())); + } + // `C:` and friends are absolute even without a leading separator. + if text.len() >= 2 && text.as_bytes()[1] == b':' { + return Err(PrimitiveError::PathAbsolute(text.to_string())); + } + for component in text.split('/') { + if component.is_empty() { + return Err(PrimitiveError::PathEmptyComponent(text.to_string())); + } + if component == "." || component == ".." { + return Err(PrimitiveError::PathTraversal(text.to_string())); + } + } + Ok(Self(text.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for RelativePath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for RelativePath { + fn deserialize>(d: D) -> Result { + let raw = String::deserialize(d)?; + Self::parse(&raw).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrimitiveError { + DigestLength(usize), + DigestAlphabet(String), + PathEmpty, + PathAbsolute(String), + PathTraversal(String), + PathEmptyComponent(String), + PathBackslash(String), + PathNul(String), +} + +impl fmt::Display for PrimitiveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DigestLength(len) => { + write!(f, "sha-256 digest must be 64 hex characters, got {}", len) + } + Self::DigestAlphabet(text) => { + write!(f, "sha-256 digest must be lowercase hex, got {:?}", text) + } + Self::PathEmpty => f.write_str("path handle must not be empty"), + Self::PathAbsolute(text) => write!(f, "path handle {:?} is absolute", text), + Self::PathTraversal(text) => { + write!(f, "path handle {:?} contains a traversal component", text) + } + Self::PathEmptyComponent(text) => { + write!(f, "path handle {:?} has an empty component", text) + } + Self::PathBackslash(text) => write!(f, "path handle {:?} contains a backslash", text), + Self::PathNul(text) => write!(f, "path handle {:?} contains a NUL", text), + } + } +} + +impl std::error::Error for PrimitiveError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digests_are_lowercase_hex_of_the_right_length() { + let digest = Sha256Digest::of(b""); + assert_eq!( + digest.as_str(), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!(Sha256Digest::parse(digest.as_str()).unwrap(), digest); + } + + #[test] + fn a_digest_that_is_not_a_digest_is_rejected() { + assert_eq!( + Sha256Digest::parse("deadbeef"), + Err(PrimitiveError::DigestLength(8)) + ); + // Uppercase is a different string, and accepting it would make two + // spellings of one digest compare unequal. + let upper = "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"; + assert!(matches!( + Sha256Digest::parse(upper), + Err(PrimitiveError::DigestAlphabet(_)) + )); + assert!(matches!( + Sha256Digest::parse(&"z".repeat(64)), + Err(PrimitiveError::DigestAlphabet(_)) + )); + } + + #[test] + fn path_handles_stay_relative_and_contained() { + assert_eq!( + RelativePath::parse("in/vm_data.bin").unwrap().as_str(), + "in/vm_data.bin" + ); + for (text, expected) in [ + ("", PrimitiveError::PathEmpty), + ( + "/etc/passwd", + PrimitiveError::PathAbsolute("/etc/passwd".into()), + ), + ("C:/x", PrimitiveError::PathAbsolute("C:/x".into())), + ( + "../escape", + PrimitiveError::PathTraversal("../escape".into()), + ), + ("a/../b", PrimitiveError::PathTraversal("a/../b".into())), + ("./a", PrimitiveError::PathTraversal("./a".into())), + ("a//b", PrimitiveError::PathEmptyComponent("a//b".into())), + ("a/", PrimitiveError::PathEmptyComponent("a/".into())), + ("a\\b", PrimitiveError::PathBackslash("a\\b".into())), + ("a\0b", PrimitiveError::PathNul("a\0b".into())), + ] { + assert_eq!(RelativePath::parse(text), Err(expected), "input {:?}", text); + } + } + + /// The guarantee has to survive deserialization, which is the only way these + /// values actually enter the process. + #[test] + fn fresh_json_cannot_smuggle_a_traversal_or_a_short_digest() { + assert!(serde_json::from_str::("\"../../etc/passwd\"").is_err()); + assert!(serde_json::from_str::("\"nope\"").is_err()); + let ok: RelativePath = serde_json::from_str("\"out/model.json\"").unwrap(); + assert_eq!(ok.as_str(), "out/model.json"); + } +} diff --git a/crates/flutterdec-adapter/src/protocol.rs b/crates/flutterdec-adapter/src/protocol.rs new file mode 100644 index 00000000..fae27dea --- /dev/null +++ b/crates/flutterdec-adapter/src/protocol.rs @@ -0,0 +1,546 @@ +//! Adapter protocol v1: one request in, one result out. +//! +//! An adapter run is a single process invocation. There is no session, no +//! JSON-RPC lifecycle, no streaming, and no persistent worker, because none of +//! those are needed to answer one question about one snapshot and all of them +//! add state a hostile or broken adapter could sit inside. +//! +//! Snapshot bytes are never in these documents. Each region is an +//! [`InputHandle`]: a contained relative path, its size, and its digest. That is +//! what keeps a request a few hundred bytes for a hundred-megabyte snapshot, and +//! it is why the request type has nowhere to put base64. +//! +//! Both documents carry the protocol and model majors they were written for. +//! Version negotiation is a rejection, not a translation. + +use crate::model::{CompatibilityBinding, Diagnostic, InputRegionName, Producer, MODEL_VERSION}; +use crate::primitives::{RelativePath, Sha256Digest}; +use flutterdec_loader::identity::SnapshotIdentity; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::fmt; + +/// The only accepted protocol major. +pub const PROTOCOL_MAJOR: u32 = 1; + +/// A checked-in producer backend. +/// +/// A closed enum rather than a string, because the previous design read the +/// resolved backend out of a substring of an adapter-authored free-text field, +/// which meant an adapter could name itself `r2flutter_...` and be treated as +/// one. Membership here is the only way to be a backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendId { + /// String carving plus prologue scanning. No exact names, no real pool. + Internal, + Blutter, + /// `r2flutter`: deserializes the snapshot, so it is the only backend that + /// can supply exact names and a hardware pool index space. + /// + /// Spelled out rather than left to `rename_all`, which would derive + /// `r2_flutter` and put a second spelling of one backend on the wire: the + /// request's `requested_backend` serializes through [`Self::as_str`], so a + /// producer echoing the token it was given back as `resolved_backend` would + /// be rejected by its own request's vocabulary. + #[serde(rename = "r2flutter")] + R2Flutter, +} + +impl BackendId { + pub fn as_str(self) -> &'static str { + match self { + Self::Internal => "internal", + Self::Blutter => "blutter", + Self::R2Flutter => "r2flutter", + } + } +} + +impl fmt::Display for BackendId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// What the host asked for, which is distinct from what ran. +/// +/// Serializes as one flat string, `auto` or a backend name, rather than as a +/// tagged variant: the wire form is what a producer parses, and one token is +/// easier to get right than a nested object. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestedBackend { + /// The producer may pick, and may fall back. + Auto, + /// The producer must use this one or fail. No silent substitution. + Fixed(BackendId), +} + +impl RequestedBackend { + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Fixed(backend) => backend.as_str(), + } + } + + pub fn parse(text: &str) -> Option { + match text { + "auto" => Some(Self::Auto), + "internal" => Some(Self::Fixed(BackendId::Internal)), + "blutter" => Some(Self::Fixed(BackendId::Blutter)), + "r2flutter" => Some(Self::Fixed(BackendId::R2Flutter)), + _ => None, + } + } + + pub fn fixed(self) -> Option { + match self { + Self::Auto => None, + Self::Fixed(backend) => Some(backend), + } + } +} + +impl Serialize for RequestedBackend { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for RequestedBackend { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + Self::parse(&text).ok_or_else(|| { + serde::de::Error::custom(format!("unknown requested backend {:?}", text)) + }) + } +} + +impl fmt::Display for RequestedBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Why the backend that ran is not the one `auto` would have preferred. +/// +/// Closed for the same reason [`DiagnosticCode`] is: a free-text reason cannot +/// be checked, and "fell back for some reason" is not a fact a host can act on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FallbackReason { + /// The preferred backend's tooling is not installed. + BackendUnavailable, + /// The preferred backend ran and failed on this snapshot. + BackendFailed, +} + +impl FallbackReason { + pub fn as_str(self) -> &'static str { + match self { + Self::BackendUnavailable => "backend_unavailable", + Self::BackendFailed => "backend_failed", + } + } +} + +impl fmt::Display for FallbackReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// One input region, as a handle rather than as content. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InputHandle { + pub region: InputRegionName, + /// Relative to the adapter's working directory, and contained by + /// construction. The adapter may read it and must not write it. + pub path: RelativePath, + pub size: u64, + /// Digest of the bytes at `path`, so the adapter can confirm it read what + /// the host meant to send. + pub sha256: Sha256Digest, + /// Load address, present exactly for executable regions. + pub virtual_address: Option, + pub executable: bool, +} + +/// Everything an adapter is given. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterRequest { + pub protocol_major: u32, + pub model_major: u32, + /// The compatibility record the host selected, digest included. Echoed into + /// the model so a model can be tied back to the decision that produced it, + /// and so a model produced under a different decision is detectable. + pub compatibility: CompatibilityBinding, + /// Who the host believes is running. The adapter reports it back verbatim; + /// it does not get to describe itself, which is what stops a producer from + /// promoting its own trust level. + pub producer: Producer, + /// The host's identity for the snapshot. Not a suggestion: the adapter must + /// report it back unchanged. + pub identity: SnapshotIdentity, + /// Which backend the host wants. `Fixed` forbids substitution. + pub requested_backend: RequestedBackend, + pub inputs: Vec, + /// Where the adapter writes its `ProgramModel`. + pub output: RelativePath, +} + +/// The outcome of one adapter invocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterStatus { + /// A model was written to the requested output path. + Ok, + /// The adapter understood the request and cannot serve it. Distinct from + /// `Failed`: retrying or fixing the input will not help. + Unsupported, + /// The adapter tried and could not finish. + Failed, +} + +/// Stable failure codes. +/// +/// Stable because operators and tests match on them; the message is for humans +/// and may change, the code may not. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterErrorCode { + /// The request's protocol major is not one this adapter implements. + UnsupportedProtocol, + /// The requested model major is not one this adapter can emit. + UnsupportedModelVersion, + /// The adapter has no parser for this snapshot identity. + UnsupportedSnapshot, + /// A declared input handle was not readable. + InputMissing, + /// An input's bytes did not match its declared digest or size. + InputDigestMismatch, + /// The snapshot's own header disagrees with the identity in the request. + IdentityMismatch, + /// The parser ran and failed on the snapshot's contents. + ParseFailed, + /// The model could not be written to the output handle. + OutputWriteFailed, + /// Anything else. Carries no contract beyond "this run produced nothing". + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterError { + pub code: AdapterErrorCode, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdapterResult { + pub protocol_major: u32, + pub model_major: u32, + pub status: AdapterStatus, + /// Present exactly when `status` is `Ok`. A path, never a model: the model + /// is a separate artifact so it can be large without bounding this document. + pub model: Option, + /// Present exactly when `status` is not `Ok`. + pub error: Option, + /// Which backend actually produced the model. Present exactly when `status` + /// is `Ok`, and the only place the host reads it from. + pub resolved_backend: Option, + /// Present only when the host asked for `auto` and the preferred backend + /// did not run. + pub fallback_reason: Option, + pub diagnostics: Vec, +} + +/// Why a protocol document is not usable. +#[derive(Debug, Clone, PartialEq)] +pub enum ProtocolError { + Malformed(String), + UnsupportedProtocolMajor(u32), + UnsupportedModelMajor(u32), + DuplicateRegion(InputRegionName), + MissingRegion(InputRegionName), + /// A region declaring an executability that contradicts what it is. + RegionExecutabilityMismatch(InputRegionName), + /// An executable region without a load address, or a data region with one. + RegionAddressMismatch(InputRegionName), + /// A region whose load address plus size leaves the address space. + RegionOverflows(InputRegionName), + EmptyRegion(InputRegionName), + /// Two handles pointing at the same path, or an output aliasing an input. + AliasedPath(String), + /// `status` and the `model`/`error` fields disagree. + StatusPayloadMismatch, + /// `resolved_backend` is present without success, or absent with it. + ResolvedBackendPayloadMismatch, + /// The host pinned a backend and a different one answered. + BackendSubstituted { + requested: BackendId, + resolved: BackendId, + }, + /// A fallback reason on a result whose backend was pinned, so there was + /// nothing to fall back from. + FallbackWithoutAuto, +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Malformed(detail) => write!(f, "malformed protocol document: {}", detail), + Self::UnsupportedProtocolMajor(major) => write!( + f, + "unsupported protocol major {}; this host implements {}", + major, PROTOCOL_MAJOR + ), + Self::UnsupportedModelMajor(major) => write!( + f, + "unsupported model major {}; this host implements {}", + major, MODEL_VERSION + ), + Self::DuplicateRegion(region) => write!(f, "input region {} declared twice", region), + Self::MissingRegion(region) => write!(f, "request omits input region {}", region), + Self::RegionExecutabilityMismatch(region) => write!( + f, + "input {} declares an executability that contradicts what it is", + region + ), + Self::RegionAddressMismatch(region) => write!( + f, + "input {} must have a virtual address exactly when it is executable", + region + ), + Self::RegionOverflows(region) => { + write!(f, "input {} runs past the end of the address space", region) + } + Self::EmptyRegion(region) => write!(f, "input {} declares a zero size", region), + Self::AliasedPath(path) => write!( + f, + "path handle {:?} is used more than once; inputs and the output must be distinct", + path + ), + Self::StatusPayloadMismatch => f.write_str( + "an ok result must carry a model and no error, and a failed result must carry an error and no model", + ), + Self::ResolvedBackendPayloadMismatch => f.write_str( + "an ok result must name the backend that produced the model, and a result that produced nothing must not name one", + ), + Self::BackendSubstituted { + requested, + resolved, + } => write!( + f, + "host pinned backend {} but {} answered; a pinned backend may fail, never be substituted", + requested, resolved + ), + Self::FallbackWithoutAuto => f.write_str( + "a fallback reason is only meaningful when the host asked for auto", + ), + } + } +} + +impl std::error::Error for ProtocolError {} + +/// Read the two majors before deserializing, so a document written for another +/// version is rejected as such rather than as a shape error. +fn check_majors(raw: &Value) -> Result<(), ProtocolError> { + let protocol = raw + .get("protocol_major") + .and_then(Value::as_u64) + .ok_or_else(|| ProtocolError::Malformed("missing protocol_major".to_string()))?; + if protocol != u64::from(PROTOCOL_MAJOR) { + return Err(ProtocolError::UnsupportedProtocolMajor(protocol as u32)); + } + let model = raw + .get("model_major") + .and_then(Value::as_u64) + .ok_or_else(|| ProtocolError::Malformed("missing model_major".to_string()))?; + if model != u64::from(MODEL_VERSION) { + return Err(ProtocolError::UnsupportedModelMajor(model as u32)); + } + Ok(()) +} + +impl AdapterRequest { + /// Parse and fully check fresh request JSON. + pub fn from_json(bytes: &[u8]) -> Result { + let raw: Value = serde_json::from_slice(bytes) + .map_err(|err| ProtocolError::Malformed(err.to_string()))?; + check_majors(&raw)?; + let request: Self = + serde_json::from_value(raw).map_err(|err| ProtocolError::Malformed(err.to_string()))?; + request.validate()?; + Ok(request) + } + + pub fn to_json(&self) -> Vec { + serde_json::to_vec(self).expect("AdapterRequest is always serializable") + } + + /// Every structural rule a request must satisfy before an adapter runs. + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.protocol_major != PROTOCOL_MAJOR { + return Err(ProtocolError::UnsupportedProtocolMajor(self.protocol_major)); + } + if self.model_major != MODEL_VERSION { + return Err(ProtocolError::UnsupportedModelMajor(self.model_major)); + } + + let mut seen = BTreeSet::new(); + for input in &self.inputs { + if !seen.insert(input.region) { + return Err(ProtocolError::DuplicateRegion(input.region)); + } + if input.executable != input.region.is_executable() { + return Err(ProtocolError::RegionExecutabilityMismatch(input.region)); + } + if input.executable != input.virtual_address.is_some() { + return Err(ProtocolError::RegionAddressMismatch(input.region)); + } + if input.size == 0 { + return Err(ProtocolError::EmptyRegion(input.region)); + } + if let Some(base) = input.virtual_address { + if base.checked_add(input.size).is_none() { + return Err(ProtocolError::RegionOverflows(input.region)); + } + } + } + for region in InputRegionName::ALL { + if !seen.contains(®ion) { + return Err(ProtocolError::MissingRegion(region)); + } + } + + // Distinct paths, and an output that cannot overwrite an input. + let mut paths = BTreeSet::new(); + for input in &self.inputs { + if !paths.insert(input.path.as_str()) { + return Err(ProtocolError::AliasedPath(input.path.to_string())); + } + } + if paths.contains(self.output.as_str()) { + return Err(ProtocolError::AliasedPath(self.output.to_string())); + } + Ok(()) + } + + pub fn input(&self, region: InputRegionName) -> Option<&InputHandle> { + self.inputs.iter().find(|i| i.region == region) + } +} + +impl AdapterResult { + pub fn from_json(bytes: &[u8]) -> Result { + let raw: Value = serde_json::from_slice(bytes) + .map_err(|err| ProtocolError::Malformed(err.to_string()))?; + check_majors(&raw)?; + let result: Self = + serde_json::from_value(raw).map_err(|err| ProtocolError::Malformed(err.to_string()))?; + result.validate()?; + Ok(result) + } + + pub fn to_json(&self) -> Vec { + serde_json::to_vec(self).expect("AdapterResult is always serializable") + } + + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.protocol_major != PROTOCOL_MAJOR { + return Err(ProtocolError::UnsupportedProtocolMajor(self.protocol_major)); + } + if self.model_major != MODEL_VERSION { + return Err(ProtocolError::UnsupportedModelMajor(self.model_major)); + } + // Success without a model, or failure without a reason, are both results + // the host cannot act on. + let consistent = match self.status { + AdapterStatus::Ok => self.model.is_some() && self.error.is_none(), + AdapterStatus::Unsupported | AdapterStatus::Failed => { + self.model.is_none() && self.error.is_some() + } + }; + if !consistent { + return Err(ProtocolError::StatusPayloadMismatch); + } + // A model nobody will admit to producing is a model with no provenance, + // and a backend named by a run that produced nothing is a claim about + // work that did not happen. + if self.resolved_backend.is_some() != matches!(self.status, AdapterStatus::Ok) { + return Err(ProtocolError::ResolvedBackendPayloadMismatch); + } + Ok(()) + } + + /// The checks that need the request the result answers. + /// + /// Separate from [`AdapterResult::validate`] because a result read off disk + /// can be checked for self-consistency on its own, but "did this answer the + /// question that was asked" is only decidable with the question in hand. + pub fn validate_against(&self, request: &AdapterRequest) -> Result<(), ProtocolError> { + self.validate()?; + if let (Some(requested), Some(resolved)) = + (request.requested_backend.fixed(), self.resolved_backend) + { + if requested != resolved { + return Err(ProtocolError::BackendSubstituted { + requested, + resolved, + }); + } + } + if self.fallback_reason.is_some() && request.requested_backend.fixed().is_some() { + return Err(ProtocolError::FallbackWithoutAuto); + } + Ok(()) + } + + pub fn ok( + model: RelativePath, + resolved_backend: BackendId, + fallback_reason: Option, + diagnostics: Vec, + ) -> Self { + Self { + protocol_major: PROTOCOL_MAJOR, + model_major: MODEL_VERSION, + status: AdapterStatus::Ok, + model: Some(model), + error: None, + resolved_backend: Some(resolved_backend), + fallback_reason, + diagnostics, + } + } + + pub fn failed(code: AdapterErrorCode, message: impl Into) -> Self { + Self { + protocol_major: PROTOCOL_MAJOR, + model_major: MODEL_VERSION, + status: AdapterStatus::Failed, + model: None, + error: Some(AdapterError { + code, + message: message.into(), + }), + resolved_backend: None, + fallback_reason: None, + diagnostics: Vec::new(), + } + } + + pub fn unsupported(code: AdapterErrorCode, message: impl Into) -> Self { + Self { + status: AdapterStatus::Unsupported, + ..Self::failed(code, message) + } + } +} diff --git a/crates/flutterdec-adapter/src/validate.rs b/crates/flutterdec-adapter/src/validate.rs new file mode 100644 index 00000000..699e4701 --- /dev/null +++ b/crates/flutterdec-adapter/src/validate.rs @@ -0,0 +1,950 @@ +//! Semantic validation of a [`ProgramModel`] against what the host actually did. +//! +//! Parsing proves a model is well-shaped. It does not prove the model is about +//! the snapshot the host loaded, that its references resolve, that its addresses +//! exist, or that its capability claims match its contents. Those are the +//! failures that produce confident wrong output rather than an error, so they +//! are checked here, once, before anything reaches core analysis. +//! +//! Two rules shape the checks: +//! +//! * **The host decides, the adapter reports.** Identity, producer, and +//! compatibility are compared against [`HostSelectedContext`]. An adapter +//! cannot promote itself, change which snapshot it was given, or claim a +//! different compatibility record. +//! * **Every size and address is checked arithmetic.** A range that overflows +//! `u64` is not a range that ends at `u64::MAX`; it is a rejection. + +use crate::model::{ + CapabilityLevel, ClassId, CompatibilityBinding, Domain, InputRegion, InputRegionName, + PoolEntryKind, PoolIndexSpace, Producer, ProgramModel, Provenance, MODEL_VERSION, +}; +use flutterdec_loader::identity::SnapshotIdentity; +use std::collections::BTreeSet; +use std::fmt; + +/// What the host selected and observed, which the model is checked against. +/// +/// Built by the host from the loaded snapshot and the registry decision. None of +/// it comes from adapter output. +#[derive(Debug, Clone, PartialEq)] +pub struct HostSelectedContext { + pub identity: SnapshotIdentity, + pub producer: Producer, + pub compatibility: CompatibilityBinding, + pub regions: Vec, +} + +/// Strings that are an admission that nothing was recovered. +/// +/// A model must leave an unknown name absent. Writing one of these into a +/// required field is how v3 turned "no name" into a name, and every consumer +/// downstream then treated it as one. +const PLACEHOLDER_NAMES: &[&str] = &[ + "", + "-", + "?", + "??", + "???", + "n/a", + "na", + "none", + "null", + "nil", + "todo", + "tbd", + "unknown", + "", + "unnamed", + "anonymous", + "placeholder", + "undefined", +]; + +fn is_placeholder(text: &str) -> bool { + let normalized = text.trim().to_ascii_lowercase(); + PLACEHOLDER_NAMES.contains(&normalized.as_str()) +} + +/// The first invariant a model breaks. +/// +/// One error rather than a list: every variant here means the model cannot be +/// used, so collecting more of them would only delay the same refusal. +#[derive(Debug, Clone, PartialEq)] +pub enum ValidationError { + UnsupportedModelVersion(u32), + + /// A host-selected fact the model reported differently. + HostFactMismatch { + field: &'static str, + }, + /// The model's region table does not match the regions the host provided. + RegionMismatch { + region: InputRegionName, + field: &'static str, + }, + MissingRegion(InputRegionName), + UnexpectedRegion(InputRegionName), + DuplicateRegion(InputRegionName), + /// A region's executability contradicts what that region is. + RegionExecutabilityMismatch(InputRegionName), + /// An executable region without a load address, or a data region with one. + RegionAddressMismatch(InputRegionName), + /// A region that runs off the end of the address space. + RegionOverflows(InputRegionName), + + EmptyField { + field: &'static str, + }, + /// A name that is an admission of ignorance rather than a name. + PlaceholderName { + field: &'static str, + value: String, + }, + + /// A confidence score on a fact that is not a guess. + ConfidenceWithoutHeuristicProvenance { + field: &'static str, + provenance: Provenance, + }, + /// A confidence outside `[0, 1]`, or not a number. + ConfidenceOutOfRange { + field: &'static str, + value: f64, + }, + + DuplicateLibraryId(u32), + DuplicateClassId(u32), + DuplicateFunctionId(u32), + DuplicatePoolIndex(u64), + + /// Records not in canonical ascending order. + NoncanonicalOrder { + collection: &'static str, + }, + + MissingLibraryReference { + class: u32, + library: u32, + }, + MissingSuperClassReference { + class: u32, + super_class: u32, + }, + MissingOwnerReference { + function: u32, + owner: u32, + }, + /// A superclass chain that returns to a class it already visited. + SuperClassCycle { + class: u32, + }, + + /// A capability level the model's own contents contradict. + CapabilityContradiction { + domain: Domain, + detail: &'static str, + }, + /// An unavailable domain with no diagnostic saying so. + UnavailableDomainWithoutDiagnostic(Domain), + + /// A code range of zero length, which is not a range. + EmptyCodeRange { + function: u32, + }, + /// A range or address whose arithmetic overflows `u64`. + AddressOverflow { + context: &'static str, + id: u64, + }, + /// A code range that is not inside any declared executable region. + CodeRangeOutsideExecutableRegions { + function: u32, + start_va: u64, + size: u64, + }, + /// A `code_section_va` that is not the base of an executable region, or not + /// the base of the region the range actually lies in. + CodeSectionMismatch { + function: u32, + code_section_va: u64, + }, + /// A pool target address outside every executable region. + PoolTargetOutsideExecutableRegions { + index: u64, + target_va: u64, + }, + /// A pool entry whose kind and payload disagree. + PoolEntryShape { + index: u64, + detail: &'static str, + }, + /// Geometry that cannot describe an `ObjectPool`. + PoolGeometry { + detail: &'static str, + }, + /// A hardware index that does not map back into the address space. + PoolIndexOutOfBounds { + index: u64, + }, +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedModelVersion(v) => write!( + f, + "model_version {} is not the accepted contract version {}", + v, MODEL_VERSION + ), + Self::HostFactMismatch { field } => write!( + f, + "model reports a different {} than the host selected; adapter output cannot change host-selected facts", + field + ), + Self::RegionMismatch { region, field } => write!( + f, + "region {} reports a different {} than the host observed", + region, field + ), + Self::MissingRegion(r) => write!(f, "model omits input region {}", r), + Self::UnexpectedRegion(r) => write!(f, "model declares region {} the host did not provide", r), + Self::DuplicateRegion(r) => write!(f, "input region {} appears more than once", r), + Self::RegionExecutabilityMismatch(r) => write!( + f, + "region {} declares an executability that contradicts what it is", + r + ), + Self::RegionAddressMismatch(r) => write!( + f, + "region {} must have a virtual address exactly when it is executable", + r + ), + Self::RegionOverflows(r) => { + write!(f, "region {} runs past the end of the address space", r) + } + Self::EmptyField { field } => write!(f, "{} must not be empty", field), + Self::PlaceholderName { field, value } => write!( + f, + "{} is the placeholder {:?}; an unrecovered name must be absent, not filled in", + field, value + ), + Self::ConfidenceWithoutHeuristicProvenance { field, provenance } => write!( + f, + "{} carries a confidence score but its provenance is {}; only heuristic facts may be scored", + field, + provenance.as_str() + ), + Self::ConfidenceOutOfRange { field, value } => { + write!(f, "{} confidence {} is outside [0, 1]", field, value) + } + Self::DuplicateLibraryId(id) => write!(f, "duplicate library id {}", id), + Self::DuplicateClassId(id) => write!(f, "duplicate class id {}", id), + Self::DuplicateFunctionId(id) => write!(f, "duplicate function id {}", id), + Self::DuplicatePoolIndex(i) => write!(f, "duplicate object pool index {}", i), + Self::NoncanonicalOrder { collection } => write!( + f, + "{} are not in canonical ascending order", + collection + ), + Self::MissingLibraryReference { class, library } => write!( + f, + "class {} references library {}, which the model does not define", + class, library + ), + Self::MissingSuperClassReference { class, super_class } => write!( + f, + "class {} references superclass {}, which the model does not define", + class, super_class + ), + Self::MissingOwnerReference { function, owner } => write!( + f, + "function {} references owner class {}, which the model does not define", + function, owner + ), + Self::SuperClassCycle { class } => { + write!(f, "class {} is in a superclass cycle", class) + } + Self::CapabilityContradiction { domain, detail } => write!( + f, + "capability for {} contradicts the model's contents: {}", + domain, detail + ), + Self::UnavailableDomainWithoutDiagnostic(domain) => write!( + f, + "{} is unavailable but no diagnostic explains why", + domain + ), + Self::EmptyCodeRange { function } => { + write!(f, "function {} has a zero-length code range", function) + } + Self::AddressOverflow { context, id } => { + write!(f, "{} {} overflows the address space", context, id) + } + Self::CodeRangeOutsideExecutableRegions { + function, + start_va, + size, + } => write!( + f, + "function {} code range [{:#x}, +{:#x}) is not inside any executable input region", + function, start_va, size + ), + Self::CodeSectionMismatch { + function, + code_section_va, + } => write!( + f, + "function {} declares code_section_va {:#x}, which is not the base of the executable region containing its code", + function, code_section_va + ), + Self::PoolTargetOutsideExecutableRegions { index, target_va } => write!( + f, + "object pool entry {} targets {:#x}, which is outside every executable input region", + index, target_va + ), + Self::PoolEntryShape { index, detail } => { + write!(f, "object pool entry {}: {}", index, detail) + } + Self::PoolGeometry { detail } => write!(f, "object pool geometry: {}", detail), + Self::PoolIndexOutOfBounds { index } => write!( + f, + "object pool index {} does not map to a displacement in the address space", + index + ), + } + } +} + +impl std::error::Error for ValidationError {} + +type Check = Result<(), ValidationError>; + +/// Reject a model that cannot be trusted to describe the host's snapshot. +/// +/// Checks run cheapest-and-most-fundamental first, so the reported error is the +/// most explanatory one rather than a downstream symptom of it. +pub fn validate(model: &ProgramModel, host: &HostSelectedContext) -> Check { + if model.model_version != MODEL_VERSION { + return Err(ValidationError::UnsupportedModelVersion( + model.model_version, + )); + } + check_regions(model)?; + check_host_facts(model, host)?; + check_strings(model)?; + check_confidence(model)?; + check_identity_and_order(model)?; + check_references(model)?; + check_capabilities(model)?; + check_geometry(model)?; + check_addresses(model)?; + Ok(()) +} + +fn check_regions(model: &ProgramModel) -> Check { + let mut seen = BTreeSet::new(); + for region in &model.input.regions { + if !seen.insert(region.region) { + return Err(ValidationError::DuplicateRegion(region.region)); + } + if region.executable != region.region.is_executable() { + return Err(ValidationError::RegionExecutabilityMismatch(region.region)); + } + if region.executable != region.virtual_address.is_some() { + return Err(ValidationError::RegionAddressMismatch(region.region)); + } + if region.executable && region.end_va().is_none() { + return Err(ValidationError::RegionOverflows(region.region)); + } + } + // Canonical order for the region table too, so two models describing the + // same input serialize identically. + if model + .input + .regions + .windows(2) + .any(|w| w[0].region >= w[1].region) + { + return Err(ValidationError::NoncanonicalOrder { + collection: "input regions", + }); + } + Ok(()) +} + +fn check_host_facts(model: &ProgramModel, host: &HostSelectedContext) -> Check { + let observed = &model.input.identity; + let expected = &host.identity; + let identity_fields: [(&'static str, bool); 7] = [ + ("snapshot hash", observed.hash == expected.hash), + ("hash source", observed.hash_source == expected.hash_source), + ("snapshot kind", observed.kind == expected.kind), + ( + "target architecture", + observed.target_arch == expected.target_arch, + ), + ( + "normalized features", + observed.features.normalized == expected.features.normalized, + ), + // The raw header string is host-parsed too, so an adapter that echoes a + // different one has changed the input it was given even when the + // normalized list still agrees. + ( + "raw features", + observed.features.raw == expected.features.raw, + ), + ( + "pointer compression", + observed.pointer_compression == expected.pointer_compression, + ), + ]; + for (field, matches) in identity_fields { + if !matches { + return Err(ValidationError::HostFactMismatch { field }); + } + } + + let producer_fields: [(&'static str, bool); 4] = [ + ("producer id", model.producer.id == host.producer.id), + ( + "producer version", + model.producer.version == host.producer.version, + ), + ( + "producer artifact digest", + model.producer.artifact_sha256 == host.producer.artifact_sha256, + ), + ( + "producer trust", + model.producer.trust == host.producer.trust, + ), + ]; + for (field, matches) in producer_fields { + if !matches { + return Err(ValidationError::HostFactMismatch { field }); + } + } + + let compatibility_fields: [(&'static str, bool); 4] = [ + ( + "compatibility record digest", + model.compatibility.record_sha256 == host.compatibility.record_sha256, + ), + ( + "parser family", + model.compatibility.parser_family_id == host.compatibility.parser_family_id, + ), + ( + "profile id", + model.compatibility.profile_id == host.compatibility.profile_id, + ), + ( + "profile digest", + model.compatibility.profile_sha256 == host.compatibility.profile_sha256, + ), + ]; + for (field, matches) in compatibility_fields { + if !matches { + return Err(ValidationError::HostFactMismatch { field }); + } + } + + for expected_region in &host.regions { + let Some(observed_region) = model.input.region(expected_region.region) else { + return Err(ValidationError::MissingRegion(expected_region.region)); + }; + let region = expected_region.region; + if observed_region.size != expected_region.size { + return Err(ValidationError::RegionMismatch { + region, + field: "size", + }); + } + if observed_region.sha256 != expected_region.sha256 { + return Err(ValidationError::RegionMismatch { + region, + field: "sha-256 digest", + }); + } + if observed_region.virtual_address != expected_region.virtual_address { + return Err(ValidationError::RegionMismatch { + region, + field: "virtual address", + }); + } + } + for observed_region in &model.input.regions { + if !host + .regions + .iter() + .any(|r| r.region == observed_region.region) + { + return Err(ValidationError::UnexpectedRegion(observed_region.region)); + } + } + Ok(()) +} + +fn non_empty(value: &str, field: &'static str) -> Check { + if value.trim().is_empty() { + return Err(ValidationError::EmptyField { field }); + } + Ok(()) +} + +fn not_placeholder(value: &str, field: &'static str) -> Check { + non_empty(value, field)?; + if is_placeholder(value) { + return Err(ValidationError::PlaceholderName { + field, + value: value.to_string(), + }); + } + Ok(()) +} + +fn check_strings(model: &ProgramModel) -> Check { + non_empty(&model.producer.id, "producer id")?; + non_empty(&model.producer.version, "producer version")?; + non_empty(&model.compatibility.parser_family_id, "parser family id")?; + non_empty(&model.compatibility.profile_id, "profile id")?; + for library in &model.libraries { + not_placeholder(&library.uri, "library uri")?; + if let Some(display) = &library.display_name { + not_placeholder(display, "library display name")?; + } + } + for class in &model.classes { + not_placeholder(&class.name, "class name")?; + } + for function in &model.functions { + if let Some(name) = &function.name { + not_placeholder(&name.text, "function name")?; + } + } + for entry in &model.object_pool.entries { + if let Some(value) = &entry.value { + not_placeholder(value, "object pool entry value")?; + } + } + Ok(()) +} + +fn check_one_confidence( + confidence: Option, + provenance: Provenance, + field: &'static str, +) -> Check { + let Some(value) = confidence else { + return Ok(()); + }; + if !provenance.admits_confidence() { + return Err(ValidationError::ConfidenceWithoutHeuristicProvenance { field, provenance }); + } + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + return Err(ValidationError::ConfidenceOutOfRange { field, value }); + } + Ok(()) +} + +fn check_confidence(model: &ProgramModel) -> Check { + for function in &model.functions { + if let Some(name) = &function.name { + check_one_confidence(name.confidence, name.provenance, "function name")?; + } + } + for entry in &model.object_pool.entries { + check_one_confidence(entry.confidence, entry.provenance, "object pool entry")?; + } + Ok(()) +} + +fn check_identity_and_order(model: &ProgramModel) -> Check { + let mut library_ids = BTreeSet::new(); + for library in &model.libraries { + if !library_ids.insert(library.id) { + return Err(ValidationError::DuplicateLibraryId(library.id.0)); + } + } + let mut class_ids = BTreeSet::new(); + for class in &model.classes { + if !class_ids.insert(class.id) { + return Err(ValidationError::DuplicateClassId(class.id.0)); + } + } + let mut function_ids = BTreeSet::new(); + for function in &model.functions { + if !function_ids.insert(function.id) { + return Err(ValidationError::DuplicateFunctionId(function.id.0)); + } + } + let mut indexes = BTreeSet::new(); + for entry in &model.object_pool.entries { + if !indexes.insert(entry.index) { + return Err(ValidationError::DuplicatePoolIndex(entry.index)); + } + } + + // Duplicates are reported above, so anything still out of order here is an + // ordering problem and gets its own error. + if model.libraries.windows(2).any(|w| w[0].id > w[1].id) { + return Err(ValidationError::NoncanonicalOrder { + collection: "libraries", + }); + } + if model.classes.windows(2).any(|w| w[0].id > w[1].id) { + return Err(ValidationError::NoncanonicalOrder { + collection: "classes", + }); + } + if model.functions.windows(2).any(|w| w[0].id > w[1].id) { + return Err(ValidationError::NoncanonicalOrder { + collection: "functions", + }); + } + if model + .object_pool + .entries + .windows(2) + .any(|w| w[0].index > w[1].index) + { + return Err(ValidationError::NoncanonicalOrder { + collection: "object pool entries", + }); + } + Ok(()) +} + +fn check_references(model: &ProgramModel) -> Check { + for class in &model.classes { + if let Some(library) = class.library { + if model.library(library).is_none() { + return Err(ValidationError::MissingLibraryReference { + class: class.id.0, + library: library.0, + }); + } + } + if let Some(super_class) = class.super_class { + if model.class(super_class).is_none() { + return Err(ValidationError::MissingSuperClassReference { + class: class.id.0, + super_class: super_class.0, + }); + } + } + } + for function in &model.functions { + if let Some(owner) = function.owner { + if model.class(owner).is_none() { + return Err(ValidationError::MissingOwnerReference { + function: function.id.0, + owner: owner.0, + }); + } + } + } + // A class that reaches itself through superclass edges is not a hierarchy. + // Every reference already resolves, so the walk terminates. + for class in &model.classes { + let mut visited: BTreeSet = BTreeSet::new(); + visited.insert(class.id); + let mut cursor = class.super_class; + while let Some(next) = cursor { + if !visited.insert(next) { + return Err(ValidationError::SuperClassCycle { class: class.id.0 }); + } + cursor = model.class(next).and_then(|c| c.super_class); + } + } + Ok(()) +} + +fn contradiction(domain: Domain, detail: &'static str) -> ValidationError { + ValidationError::CapabilityContradiction { domain, detail } +} + +fn check_capabilities(model: &ProgramModel) -> Check { + use CapabilityLevel::{Complete, Unavailable}; + let caps = &model.capabilities; + let pool = &model.object_pool; + + if caps.libraries == Unavailable && !model.libraries.is_empty() { + return Err(contradiction(Domain::Libraries, "libraries are present")); + } + if caps.libraries == Complete + && model + .libraries + .iter() + .any(|l| l.provenance == Provenance::Heuristic) + { + return Err(contradiction( + Domain::Libraries, + "a complete domain contains heuristic records", + )); + } + + if caps.classes == Unavailable && !model.classes.is_empty() { + return Err(contradiction(Domain::Classes, "classes are present")); + } + if caps.classes == Complete + && model + .classes + .iter() + .any(|c| c.provenance == Provenance::Heuristic) + { + return Err(contradiction( + Domain::Classes, + "a complete domain contains heuristic records", + )); + } + // Edges cannot exist without the nodes they connect. + if caps.classes == Unavailable && caps.class_relationships != Unavailable { + return Err(contradiction( + Domain::ClassRelationships, + "relationships are claimed while classes are unavailable", + )); + } + if caps.class_relationships == Unavailable + && model.classes.iter().any(|c| c.super_class.is_some()) + { + return Err(contradiction( + Domain::ClassRelationships, + "superclass edges are present", + )); + } + + if caps.functions == Unavailable && !model.functions.is_empty() { + return Err(contradiction(Domain::Functions, "functions are present")); + } + if caps.functions == Complete + && model + .functions + .iter() + .any(|f| f.provenance == Provenance::Heuristic) + { + return Err(contradiction( + Domain::Functions, + "a complete domain contains heuristic records", + )); + } + if caps.functions == Unavailable && caps.function_names != Unavailable { + return Err(contradiction( + Domain::FunctionNames, + "names are claimed while functions are unavailable", + )); + } + if caps.function_names == Unavailable && model.functions.iter().any(|f| f.name.is_some()) { + return Err(contradiction(Domain::FunctionNames, "names are present")); + } + if caps.function_names == Complete { + if model.functions.iter().any(|f| f.name.is_none()) { + return Err(contradiction( + Domain::FunctionNames, + "a complete domain leaves functions unnamed", + )); + } + if model + .functions + .iter() + .filter_map(|f| f.name.as_ref()) + .any(|n| n.provenance == Provenance::Heuristic) + { + return Err(contradiction( + Domain::FunctionNames, + "a complete domain contains heuristic names", + )); + } + } + + if caps.object_pool == Unavailable && !pool.entries.is_empty() { + return Err(contradiction( + Domain::ObjectPool, + "pool entries are present", + )); + } + if caps.object_pool == Complete { + if pool + .entries + .iter() + .any(|e| e.provenance == Provenance::Heuristic) + { + return Err(contradiction( + Domain::ObjectPool, + "a complete domain contains heuristic entries", + )); + } + if pool + .entries + .iter() + .any(|e| e.kind == PoolEntryKind::Undecoded) + { + return Err(contradiction( + Domain::ObjectPool, + "a complete domain contains undecoded entries", + )); + } + } + if caps.object_pool == Unavailable && caps.pool_index_space != Unavailable { + return Err(contradiction( + Domain::PoolIndexSpace, + "an index space is claimed while the pool is unavailable", + )); + } + // The index space claim and the geometry have to agree: a hardware index is + // meaningless without the layout that resolves it, and geometry alongside + // ordinal indexes invites the core to resolve positions as displacements. + match pool.index_space { + PoolIndexSpace::Hardware => { + if pool.geometry.is_none() { + return Err(contradiction( + Domain::PoolIndexSpace, + "hardware indexes are claimed without pool geometry", + )); + } + if caps.pool_index_space == Unavailable { + return Err(contradiction( + Domain::PoolIndexSpace, + "hardware indexes are claimed while the index space is unavailable", + )); + } + } + PoolIndexSpace::Ordinal => { + if pool.geometry.is_some() { + return Err(contradiction( + Domain::PoolIndexSpace, + "geometry is present but indexes are only ordinal", + )); + } + if caps.pool_index_space != Unavailable { + return Err(contradiction( + Domain::PoolIndexSpace, + "ordinal indexes carry no address meaning, so the index space is unavailable", + )); + } + } + } + + // An unavailable domain has to say why. Silence is indistinguishable from a + // producer that forgot to look. + for domain in Domain::ALL { + if caps.level(domain) != Unavailable { + continue; + } + let explained = model + .diagnostics + .iter() + .any(|d| d.subject.as_deref() == Some(domain.as_str())); + if !explained { + return Err(ValidationError::UnavailableDomainWithoutDiagnostic(domain)); + } + } + Ok(()) +} + +fn check_geometry(model: &ProgramModel) -> Check { + let Some(geometry) = model.object_pool.geometry else { + return Ok(()); + }; + // A stride has to be a power of two and no wider than a machine word; a + // stride of zero would make every displacement resolve to entry zero. + if geometry.word_size == 0 || geometry.word_size > 8 || !geometry.word_size.is_power_of_two() { + return Err(ValidationError::PoolGeometry { + detail: "word size must be a power of two no larger than 8", + }); + } + if geometry.entries_offset % geometry.word_size != 0 { + return Err(ValidationError::PoolGeometry { + detail: "entries offset must be a multiple of the word size", + }); + } + for entry in &model.object_pool.entries { + if geometry.displacement_for_index(entry.index).is_none() { + return Err(ValidationError::PoolIndexOutOfBounds { index: entry.index }); + } + } + Ok(()) +} + +fn check_addresses(model: &ProgramModel) -> Check { + let executable: Vec<&InputRegion> = model.input.executable_regions().collect(); + + for function in &model.functions { + if function.code.size == 0 { + return Err(ValidationError::EmptyCodeRange { + function: function.id.0, + }); + } + if function.code.end_va().is_none() { + return Err(ValidationError::AddressOverflow { + context: "code range of function", + id: u64::from(function.id.0), + }); + } + let contained = + |r: &&InputRegion| r.contains_range(function.code.start_va, function.code.size); + if !executable.iter().any(contained) { + return Err(ValidationError::CodeRangeOutsideExecutableRegions { + function: function.id.0, + start_va: function.code.start_va, + size: function.code.size, + }); + } + // The declared section base has to name the region the code is actually + // in. Keying off the declared base rather than off whichever region + // happens to match first keeps this answer independent of region order. + let declared_holds_the_code = executable + .iter() + .filter(|r| r.virtual_address == Some(function.code_section_va)) + .any(contained); + if !declared_holds_the_code { + return Err(ValidationError::CodeSectionMismatch { + function: function.id.0, + code_section_va: function.code_section_va, + }); + } + } + + for entry in &model.object_pool.entries { + match (entry.kind, entry.target_va, &entry.value) { + (PoolEntryKind::Code, None, _) => { + return Err(ValidationError::PoolEntryShape { + index: entry.index, + detail: "a code entry must carry the address it references", + }) + } + (PoolEntryKind::Undecoded, _, Some(_)) => { + return Err(ValidationError::PoolEntryShape { + index: entry.index, + detail: "an undecoded entry must not carry a decoded value", + }) + } + (kind, Some(_), _) + if !matches!(kind, PoolEntryKind::Code | PoolEntryKind::Selector) => + { + return Err(ValidationError::PoolEntryShape { + index: entry.index, + detail: "only code and selector entries may reference an address", + }) + } + (kind, _, None) if kind != PoolEntryKind::Undecoded => { + return Err(ValidationError::PoolEntryShape { + index: entry.index, + detail: "a decoded entry must carry a value", + }) + } + _ => {} + } + if let Some(target) = entry.target_va { + if !executable.iter().any(|r| r.contains_address(target)) { + return Err(ValidationError::PoolTargetOutsideExecutableRegions { + index: entry.index, + target_va: target, + }); + } + } + } + Ok(()) +} diff --git a/crates/flutterdec-adapter/tests/model_v4.rs b/crates/flutterdec-adapter/tests/model_v4.rs new file mode 100644 index 00000000..abac8279 --- /dev/null +++ b/crates/flutterdec-adapter/tests/model_v4.rs @@ -0,0 +1,1192 @@ +//! ProgramModel v4 contract tests. +//! +//! Every case here serializes a fixture, hands the *bytes* to +//! `ProgramModel::from_json`, and validates the parsed result. Testing the +//! in-memory struct would prove nothing about the boundary: the failures worth +//! catching are the ones a hostile or broken producer can write into JSON. + +mod support; + +use flutterdec_adapter::model::{ + schema, CapabilityLevel, ClassId, Diagnostic, Domain, ModelParseError, ProgramModel, + MODEL_VERSION, +}; +use flutterdec_adapter::validate::{validate, ValidationError}; +use serde_json::{json, Value}; +use support::{host, maximal_model, unavailable_model, ISO_INSTR_VA, VM_INSTR_VA}; + +/// Serialize a model, apply a mutation to the JSON, and hand the bytes back. +/// +/// The mutation happens on the document rather than the struct so a case can +/// express things the Rust types cannot hold, which is exactly where the +/// interesting rejections live. +/// One named edit to a serialized model. The tables below are the negative +/// fixtures: each entry is a plausible document that must be rejected. +type Mutation = Box; + +fn mutated(mut value: Value, mutate: impl FnOnce(&mut Value)) -> Vec { + mutate(&mut value); + serde_json::to_vec(&value).expect("mutated fixture serializes") +} + +fn model_json(model: &ProgramModel) -> Value { + serde_json::from_slice(&model.to_canonical_json()).expect("canonical json is json") +} + +/// Parse fresh bytes and validate against the host context, returning the first +/// invariant broken. +fn parse_and_validate(bytes: &[u8]) -> Result { + let model = ProgramModel::from_json(bytes).map_err(|err| err.to_string())?; + validate(&model, &host()).map_err(|err| err.to_string())?; + Ok(model) +} + +fn expect_validation_error(bytes: &[u8]) -> ValidationError { + let model = ProgramModel::from_json(bytes).expect("fixture still parses as v4"); + validate(&model, &host()).expect_err("fixture is expected to fail validation") +} + +// --------------------------------------------------------------------------- +// Positive path +// --------------------------------------------------------------------------- + +/// The maximal fixture survives a full round trip through JSON and validation, +/// and comes back byte-identical. If this fails, every negative case below is +/// measuring the wrong thing. +#[test] +fn a_valid_model_round_trips_through_fresh_json() { + let original = maximal_model(); + let bytes = original.to_canonical_json(); + let parsed = parse_and_validate(&bytes).expect("maximal fixture is valid"); + assert_eq!(parsed, original); + assert_eq!(parsed.to_canonical_json(), bytes); +} + +/// Canonical output is a function of the value alone, so repeated serialization +/// of the same model, and of a model parsed from that output, produce the same +/// bytes. Determinism is what makes model artifacts diffable across runs. +#[test] +fn canonical_serialization_is_byte_stable() { + let model = maximal_model(); + let first = model.to_canonical_json(); + for _ in 0..8 { + assert_eq!(model.to_canonical_json(), first); + } + let reparsed = ProgramModel::from_json(&first).expect("round trip parses"); + assert_eq!(reparsed.to_canonical_json(), first); + // Extensions are a map, so their key order has to come from the container + // rather than from insertion order. + let mut shuffled = maximal_model(); + shuffled.extensions.insert("aaa".to_string(), json!(1)); + shuffled.extensions.insert("zzz".to_string(), json!(2)); + let a = shuffled.to_canonical_json(); + let mut other = maximal_model(); + other.extensions.insert("zzz".to_string(), json!(2)); + other.extensions.insert("aaa".to_string(), json!(1)); + assert_eq!(a, other.to_canonical_json()); +} + +/// A producer that recovered nothing has a shape to say so: empty domains, +/// unavailable capabilities, and a diagnostic per domain. This is the model that +/// replaces inventing `package:app/main.dart` and a function called `main`. +#[test] +fn a_model_that_recovered_nothing_is_valid_and_says_so() { + let model = unavailable_model(); + let parsed = + parse_and_validate(&model.to_canonical_json()).expect("unavailable model is valid"); + + assert!(parsed.libraries.is_empty()); + assert!(parsed.classes.is_empty()); + assert!(parsed.functions.is_empty()); + assert!(parsed.object_pool.entries.is_empty()); + assert!(parsed.object_pool.geometry.is_none()); + for domain in Domain::ALL { + assert_eq!( + parsed.capabilities.level(domain), + CapabilityLevel::Unavailable, + "{domain} should be unavailable" + ); + assert!( + parsed + .diagnostics + .iter() + .any(|d| d.subject.as_deref() == Some(domain.as_str())), + "{domain} should carry a diagnostic" + ); + } +} + +/// A function can have a code range and no name. The point of the whole model +/// change: `None` is representable, so nothing has to be invented to fill a +/// required string. +#[test] +fn an_unnamed_function_keeps_its_code_range() { + let parsed = parse_and_validate(&maximal_model().to_canonical_json()).expect("valid"); + let unnamed = parsed + .functions + .iter() + .find(|f| f.name.is_none()) + .expect("fixture has an unnamed function"); + assert_eq!(unnamed.code.start_va, VM_INSTR_VA); + assert_eq!(unnamed.code.size, 0x20); +} + +/// A range that ends exactly on a region boundary is inside the region. The +/// off-by-one here decides whether the last function in a section is accepted. +#[test] +fn a_range_ending_exactly_at_the_region_boundary_is_valid() { + let mut model = maximal_model(); + let last = support::ISO_INSTR_SIZE; + model.functions[0].code.start_va = ISO_INSTR_VA + last - 8; + model.functions[0].code.size = 8; + parse_and_validate(&model.to_canonical_json()).expect("boundary-exact range is valid"); +} + +// --------------------------------------------------------------------------- +// Version rejection +// --------------------------------------------------------------------------- + +/// v2 and v3 documents are rejected as the wrong contract, not repaired. The +/// error has to name the legacy version, because "missing field `producer`" is +/// not something an operator can act on. +#[test] +fn legacy_v2_and_v3_models_are_rejected_without_a_shim() { + for version in [2u64, 3] { + let legacy = json!({ + "schema_version": version, + "adapter_kind": "blutter", + "dart_version": "3.5.0", + "snapshot_hash": support::HASH, + "arch": "arm64", + "libraries": [{ "id": 0, "uri": "package:app/main.dart", "name_display": "main" }], + "classes": [{ "id": 0, "name": "Global", "super": "Object", "lib": "package:app/main.dart" }], + "functions": [{ + "id": 0, "name": "main", "owner_class": "Global", + "entry_va": 8192, "size": 64, "code_section_va": 8192 + }], + "object_pool": [] + }); + let bytes = serde_json::to_vec(&legacy).expect("legacy fixture serializes"); + match ProgramModel::from_json(&bytes) { + Err(ModelParseError::LegacyModel(found)) => assert_eq!(found, version), + other => panic!("expected a legacy rejection for v{version}, got {other:?}"), + } + assert!(ProgramModel::from_json(&bytes) + .unwrap_err() + .to_string() + .contains("no compatibility shim")); + } +} + +/// A future version is rejected too. Accepting an unknown model would mean +/// guessing at fields this build does not know about. +#[test] +fn an_unknown_model_version_is_rejected() { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["model_version"] = json!(5); + }); + assert!(matches!( + ProgramModel::from_json(&bytes), + Err(ModelParseError::UnsupportedVersion(5)) + )); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v.as_object_mut().unwrap().remove("model_version"); + }); + assert!(matches!( + ProgramModel::from_json(&bytes), + Err(ModelParseError::MissingVersion) + )); +} + +// --------------------------------------------------------------------------- +// Closedness +// --------------------------------------------------------------------------- + +/// Every object except `extensions` rejects keys this contract does not define. +/// An ignored unknown key is how a producer ships a field the host silently +/// drops and both sides believe it took effect. +#[test] +fn undeclared_fields_are_rejected_everywhere_except_extensions() { + let cases: Vec<(&str, Mutation)> = vec![ + ( + "root", + Box::new(|v: &mut Value| v["surprise"] = json!(true)), + ), + ( + "producer", + Box::new(|v: &mut Value| v["producer"]["surprise"] = json!(true)), + ), + ( + "capabilities", + Box::new(|v: &mut Value| v["capabilities"]["surprise"] = json!("complete")), + ), + ( + "library", + Box::new(|v: &mut Value| v["libraries"][0]["surprise"] = json!(1)), + ), + ( + "function", + Box::new(|v: &mut Value| v["functions"][0]["surprise"] = json!(1)), + ), + ( + "function name", + Box::new(|v: &mut Value| v["functions"][0]["name"]["surprise"] = json!(1)), + ), + ( + "pool entry", + Box::new(|v: &mut Value| v["object_pool"]["entries"][0]["surprise"] = json!(1)), + ), + ( + "pool geometry", + Box::new(|v: &mut Value| v["object_pool"]["geometry"]["surprise"] = json!(1)), + ), + ( + "input region", + Box::new(|v: &mut Value| v["input"]["regions"][0]["surprise"] = json!(1)), + ), + ( + "identity", + Box::new(|v: &mut Value| v["input"]["identity"]["surprise"] = json!(1)), + ), + ( + "diagnostic", + Box::new(|v: &mut Value| v["diagnostics"][0]["surprise"] = json!(1)), + ), + ]; + for (label, mutate) in cases { + let bytes = mutated(model_json(&maximal_model()), mutate); + assert!( + ProgramModel::from_json(&bytes).is_err(), + "{label} accepted an undeclared field" + ); + } + + // The one controlled exception. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["extensions"]["anything"] = json!({ "nested": [1, 2, 3] }); + }); + let parsed = parse_and_validate(&bytes).expect("extensions accept undeclared keys"); + assert!(parsed.extensions.contains_key("anything")); +} + +// --------------------------------------------------------------------------- +// Host-selected facts: VAL-MODEL-003 +// --------------------------------------------------------------------------- + +/// The adapter reports host-selected facts back; it does not choose them. Each +/// case changes exactly one field and must be caught, because a model that +/// describes a different snapshot, producer, or compatibility decision is not a +/// model of this run. +#[test] +fn a_model_cannot_change_a_host_selected_fact() { + let cases: Vec<(&str, Mutation)> = vec![ + ( + "snapshot hash", + Box::new(|v: &mut Value| { + v["input"]["identity"]["hash"] = json!("00000000000000000000000000000000") + }), + ), + ( + "hash source", + Box::new(|v: &mut Value| v["input"]["identity"]["hash_source"] = json!("scan")), + ), + ( + "snapshot kind", + Box::new(|v: &mut Value| v["input"]["identity"]["kind"] = json!("full_jit")), + ), + ( + "target architecture", + Box::new(|v: &mut Value| { + v["input"]["identity"]["target_arch"] = json!({ "unsupported": "x64" }) + }), + ), + ( + "normalized features", + Box::new(|v: &mut Value| { + v["input"]["identity"]["features"]["normalized"] = json!(["arm64", "product"]) + }), + ), + ( + "pointer compression", + Box::new(|v: &mut Value| { + v["input"]["identity"]["pointer_compression"] = json!("uncompressed") + }), + ), + ( + "producer id", + Box::new(|v: &mut Value| v["producer"]["id"] = json!("someone-else")), + ), + ( + "producer version", + Box::new(|v: &mut Value| v["producer"]["version"] = json!("9.9.9")), + ), + ( + "producer artifact digest", + Box::new(|v: &mut Value| { + v["producer"]["artifact_sha256"] = json!(support::digest("other").to_string()) + }), + ), + // The self-promotion case: an untrusted adapter writing "registered". + ( + "producer trust", + Box::new(|v: &mut Value| v["producer"]["trust"] = json!("untrusted")), + ), + ( + "compatibility record digest", + Box::new(|v: &mut Value| { + v["compatibility"]["record_sha256"] = json!(support::digest("other").to_string()) + }), + ), + ( + "parser family", + Box::new(|v: &mut Value| { + v["compatibility"]["parser_family_id"] = json!("other-family") + }), + ), + ( + "profile id", + Box::new(|v: &mut Value| v["compatibility"]["profile_id"] = json!("other-profile")), + ), + ( + "profile digest", + Box::new(|v: &mut Value| { + v["compatibility"]["profile_sha256"] = json!(support::digest("other").to_string()) + }), + ), + ]; + + for (field, mutate) in cases { + let bytes = mutated(model_json(&maximal_model()), mutate); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::HostFactMismatch { field }, + "mutating {field} was not caught" + ); + } +} + +/// The region table has to describe the bytes the host actually handed over. +#[test] +fn a_model_cannot_misreport_the_input_regions() { + use flutterdec_adapter::model::InputRegionName; + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["input"]["regions"][0]["size"] = json!(999) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::RegionMismatch { + region: InputRegionName::VmData, + field: "size" + } + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["input"]["regions"][0]["sha256"] = json!(support::digest("tampered").to_string()) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::RegionMismatch { + region: InputRegionName::VmData, + field: "sha-256 digest" + } + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["input"]["regions"][2]["virtual_address"] = json!(0x9000) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::RegionMismatch { + region: InputRegionName::VmInstructions, + field: "virtual address" + } + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["input"]["regions"].as_array_mut().unwrap().remove(0); + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::MissingRegion(InputRegionName::VmData) + ); + + // A data region claiming to be executable would let a code range be placed + // in a section that holds no code. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["input"]["regions"][0]["executable"] = json!(true) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::RegionExecutabilityMismatch(InputRegionName::VmData) + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["input"]["regions"][2]["virtual_address"] = json!(Value::Null) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::RegionAddressMismatch(InputRegionName::VmInstructions) + ); + + // The region table is ordered, so two models of the same input are the same + // bytes. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["input"]["regions"].as_array_mut().unwrap().swap(0, 1); + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::NoncanonicalOrder { + collection: "input regions" + } + ); +} + +// --------------------------------------------------------------------------- +// Honest capabilities: VAL-MODEL-002 +// --------------------------------------------------------------------------- + +/// A capability claim that the model's own contents contradict is the failure +/// mode that makes capabilities worth having at all. +#[test] +fn capability_claims_must_match_the_models_contents() { + let cases: Vec<(&str, Domain, Mutation)> = vec![ + ( + "unavailable libraries with libraries present", + Domain::Libraries, + Box::new(|v: &mut Value| v["capabilities"]["libraries"] = json!("unavailable")), + ), + ( + "unavailable classes with classes present", + Domain::Classes, + Box::new(|v: &mut Value| v["capabilities"]["classes"] = json!("unavailable")), + ), + ( + "unavailable functions with functions present", + Domain::Functions, + Box::new(|v: &mut Value| v["capabilities"]["functions"] = json!("unavailable")), + ), + ( + "unavailable names with a named function", + Domain::FunctionNames, + Box::new(|v: &mut Value| v["capabilities"]["function_names"] = json!("unavailable")), + ), + ( + "unavailable pool with entries present", + Domain::ObjectPool, + Box::new(|v: &mut Value| v["capabilities"]["object_pool"] = json!("unavailable")), + ), + ( + "unavailable relationships with a superclass edge", + Domain::ClassRelationships, + Box::new(|v: &mut Value| { + v["capabilities"]["class_relationships"] = json!("unavailable") + }), + ), + // The headline case: a heuristic guess reported inside a complete + // domain, which is how a guess becomes indistinguishable from a fact. + ( + "complete functions containing a heuristic range", + Domain::Functions, + Box::new(|v: &mut Value| v["capabilities"]["functions"] = json!("complete")), + ), + ( + "complete pool containing a heuristic entry", + Domain::ObjectPool, + Box::new(|v: &mut Value| v["capabilities"]["object_pool"] = json!("complete")), + ), + ( + "complete names leaving a function unnamed", + Domain::FunctionNames, + Box::new(|v: &mut Value| v["capabilities"]["function_names"] = json!("complete")), + ), + ( + "complete libraries containing a heuristic library", + Domain::Libraries, + Box::new(|v: &mut Value| { + v["capabilities"]["libraries"] = json!("complete"); + v["libraries"][0]["provenance"] = json!("heuristic"); + }), + ), + ( + "complete classes containing a heuristic class", + Domain::Classes, + Box::new(|v: &mut Value| { + v["capabilities"]["classes"] = json!("complete"); + v["classes"][0]["provenance"] = json!("heuristic"); + }), + ), + ]; + + for (label, domain, mutate) in cases { + let bytes = mutated(model_json(&maximal_model()), mutate); + match expect_validation_error(&bytes) { + ValidationError::CapabilityContradiction { domain: got, .. } => { + assert_eq!(got, domain, "{label} reported the wrong domain") + } + other => panic!("{label} produced {other:?}"), + } + } +} + +/// Domains cannot exist without the domains they depend on. Names without +/// functions and relationships without classes are both claims about records +/// that are not there. +#[test] +fn a_domain_cannot_outlive_the_domain_it_depends_on() { + let mut model = unavailable_model(); + model.capabilities.function_names = CapabilityLevel::Partial; + match expect_validation_error(&model.to_canonical_json()) { + ValidationError::CapabilityContradiction { domain, .. } => { + assert_eq!(domain, Domain::FunctionNames) + } + other => panic!("expected a function-names contradiction, got {other:?}"), + } + + let mut model = unavailable_model(); + model.capabilities.class_relationships = CapabilityLevel::Partial; + match expect_validation_error(&model.to_canonical_json()) { + ValidationError::CapabilityContradiction { domain, .. } => { + assert_eq!(domain, Domain::ClassRelationships) + } + other => panic!("expected a relationships contradiction, got {other:?}"), + } +} + +/// An unavailable domain must say why. Without this, "we did not look" and "we +/// looked and there was nothing" are the same document. +#[test] +fn an_unavailable_domain_without_a_diagnostic_is_rejected() { + let mut model = unavailable_model(); + model + .diagnostics + .retain(|d| d.subject.as_deref() != Some("object_pool")); + assert_eq!( + expect_validation_error(&model.to_canonical_json()), + ValidationError::UnavailableDomainWithoutDiagnostic(Domain::ObjectPool) + ); +} + +/// Confidence is only meaningful on a guess. A score attached to an exact fact +/// is the calibrated-looking decoration this contract exists to keep out. +#[test] +fn confidence_requires_heuristic_provenance_and_a_real_range() { + use flutterdec_adapter::model::Provenance; + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["functions"][0]["name"]["confidence"] = json!(0.87) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::ConfidenceWithoutHeuristicProvenance { + field: "function name", + provenance: Provenance::Exact + } + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"][0]["confidence"] = json!(0.99) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::ConfidenceWithoutHeuristicProvenance { + field: "object pool entry", + provenance: Provenance::Exact + } + ); + + // Index 3 is the heuristic selector entry, so the provenance check passes + // and the range check is what fires. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"][3]["confidence"] = json!(1.5) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::ConfidenceOutOfRange { + field: "object pool entry", + value: 1.5 + } + ); +} + +/// A placeholder is not a name. Accepting one puts `unknown` into report output +/// as though a parser had recovered it. +#[test] +fn placeholder_strings_cannot_stand_in_for_unrecovered_names() { + let cases: Vec<(&str, &str, Mutation)> = vec![ + ( + "function name", + "", + Box::new(|v: &mut Value| v["functions"][0]["name"]["text"] = json!("")), + ), + ( + "class name", + "unnamed", + Box::new(|v: &mut Value| v["classes"][0]["name"] = json!("unnamed")), + ), + ( + "library uri", + "unknown", + Box::new(|v: &mut Value| v["libraries"][0]["uri"] = json!("unknown")), + ), + ( + "object pool entry value", + "TODO", + Box::new(|v: &mut Value| v["object_pool"]["entries"][0]["value"] = json!("TODO")), + ), + ( + "library display name", + "N/A", + Box::new(|v: &mut Value| v["libraries"][0]["display_name"] = json!("N/A")), + ), + ]; + for (field, value, mutate) in cases { + let bytes = mutated(model_json(&maximal_model()), mutate); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::PlaceholderName { + field, + value: value.to_string() + } + ); + } + + // Whitespace is not a name either. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["classes"][0]["name"] = json!(" ") + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::EmptyField { + field: "class name" + } + ); +} + +/// `main` is a real Dart function name. The placeholder rule rejects admissions +/// of ignorance, not ordinary identifiers that happen to look suspicious. +#[test] +fn ordinary_names_that_resemble_defaults_are_still_accepted() { + for name in ["main", "Global", "build", "Object"] { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["functions"][0]["name"]["text"] = json!(name) + }); + parse_and_validate(&bytes).unwrap_or_else(|err| panic!("{name} rejected: {err}")); + } +} + +// --------------------------------------------------------------------------- +// References, identity, ordering: VAL-VALIDATE-001 +// --------------------------------------------------------------------------- + +#[test] +fn duplicate_ids_and_indexes_are_rejected() { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["libraries"][1]["id"] = json!(1) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::DuplicateLibraryId(1) + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["classes"][1]["id"] = json!(1) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::DuplicateClassId(1) + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["functions"][1]["id"] = json!(1) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::DuplicateFunctionId(1) + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"][1]["index"] = json!(0) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::DuplicatePoolIndex(0) + ); +} + +#[test] +fn dangling_references_are_rejected() { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["classes"][0]["library"] = json!(99) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::MissingLibraryReference { + class: 1, + library: 99 + } + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["classes"][1]["super_class"] = json!(99) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::MissingSuperClassReference { + class: 2, + super_class: 99 + } + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["functions"][0]["owner"] = json!(99) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::MissingOwnerReference { + function: 1, + owner: 99 + } + ); +} + +/// A class that is its own ancestor is not a hierarchy, and a consumer walking +/// it would not terminate. +#[test] +fn superclass_cycles_are_rejected() { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["classes"][0]["super_class"] = json!(1) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::SuperClassCycle { class: 1 } + ); + + // A two-step cycle, which a self-reference check alone would miss. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["classes"][0]["super_class"] = json!(2); + v["classes"][1]["super_class"] = json!(1); + }); + assert!(matches!( + expect_validation_error(&bytes), + ValidationError::SuperClassCycle { .. } + )); +} + +#[test] +fn noncanonical_ordering_is_rejected() { + for (collection, pointer) in [ + ("libraries", "libraries"), + ("classes", "classes"), + ("functions", "functions"), + ] { + let bytes = mutated(model_json(&maximal_model()), |v| { + v[pointer].as_array_mut().unwrap().swap(0, 1); + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::NoncanonicalOrder { collection } + ); + } + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"] + .as_array_mut() + .unwrap() + .swap(0, 1); + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::NoncanonicalOrder { + collection: "object pool entries" + } + ); +} + +// --------------------------------------------------------------------------- +// Addresses, ranges, geometry: VAL-VALIDATE-002 +// --------------------------------------------------------------------------- + +/// Overflow is the case a naive `start + size` gets wrong, so it is checked with +/// the extreme values rather than a comfortable margin. +#[test] +fn overflowing_and_empty_ranges_are_rejected() { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["functions"][0]["code"]["size"] = json!(0) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::EmptyCodeRange { function: 1 } + ); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["functions"][0]["code"]["start_va"] = json!(u64::MAX); + v["functions"][0]["code"]["size"] = json!(1); + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::AddressOverflow { + context: "code range of function", + id: 1 + } + ); + + // Maximum size from a valid base: the sum overflows even though both fields + // are individually representable. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["functions"][0]["code"]["size"] = json!(u64::MAX) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::AddressOverflow { + context: "code range of function", + id: 1 + } + ); +} + +/// Code has to live in a region that holds code. A range in a data region, or +/// past the end of an executable one, cannot be disassembled. +#[test] +fn code_ranges_must_be_contained_by_an_executable_region() { + // One byte past the end of the isolate instructions. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["functions"][0]["code"]["start_va"] = json!(ISO_INSTR_VA + support::ISO_INSTR_SIZE - 4); + v["functions"][0]["code"]["size"] = json!(8); + }); + assert!(matches!( + expect_validation_error(&bytes), + ValidationError::CodeRangeOutsideExecutableRegions { function: 1, .. } + )); + + // An address in no region at all. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["functions"][0]["code"]["start_va"] = json!(0xdead_0000u64) + }); + assert!(matches!( + expect_validation_error(&bytes), + ValidationError::CodeRangeOutsideExecutableRegions { function: 1, .. } + )); + + // A section base that is not the base of the region the code is in: the + // producer and the host disagree about the address space. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["functions"][0]["code_section_va"] = json!(VM_INSTR_VA) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::CodeSectionMismatch { + function: 1, + code_section_va: VM_INSTR_VA + } + ); +} + +#[test] +fn pool_targets_must_land_inside_an_executable_region() { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"][1]["target_va"] = json!(0xdead_0000u64) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::PoolTargetOutsideExecutableRegions { + index: 3, + target_va: 0xdead_0000 + } + ); + + // One past the end of the region is outside it. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"][1]["target_va"] = json!(ISO_INSTR_VA + support::ISO_INSTR_SIZE) + }); + assert!(matches!( + expect_validation_error(&bytes), + ValidationError::PoolTargetOutsideExecutableRegions { .. } + )); +} + +#[test] +fn pool_entry_shapes_must_agree_with_their_kind() { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"][1]["target_va"] = json!(Value::Null) + }); + assert!(matches!( + expect_validation_error(&bytes), + ValidationError::PoolEntryShape { index: 3, .. } + )); + + // An undecoded slot that carries a decoded value is claiming both. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"][2]["value"] = json!("something") + }); + assert!(matches!( + expect_validation_error(&bytes), + ValidationError::PoolEntryShape { index: 5, .. } + )); + + // A string entry pointing at code. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"][0]["target_va"] = json!(ISO_INSTR_VA) + }); + assert!(matches!( + expect_validation_error(&bytes), + ValidationError::PoolEntryShape { index: 0, .. } + )); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"][0]["value"] = json!(Value::Null) + }); + assert!(matches!( + expect_validation_error(&bytes), + ValidationError::PoolEntryShape { index: 0, .. } + )); +} + +/// Geometry decides whether a `ldr xN, [x27, #disp]` resolves to a value or to +/// nothing. Bad geometry silently maps every displacement onto the wrong entry, +/// so the arithmetic constraints are checked rather than assumed. +#[test] +fn pool_geometry_must_be_able_to_describe_an_object_pool() { + for word_size in [0u64, 3, 12, 16] { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["geometry"]["word_size"] = json!(word_size) + }); + assert!( + matches!( + expect_validation_error(&bytes), + ValidationError::PoolGeometry { .. } + ), + "word size {word_size} accepted" + ); + } + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["geometry"]["entries_offset"] = json!(0x11) + }); + assert!(matches!( + expect_validation_error(&bytes), + ValidationError::PoolGeometry { .. } + )); + + // An index whose displacement leaves the address space is not addressable, + // however plausible the index looks on its own. + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["entries"][3]["index"] = json!(u64::MAX) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::PoolIndexOutOfBounds { index: u64::MAX } + ); +} + +/// Hardware indexes without geometry, and geometry without hardware indexes, are +/// both ways of letting a consumer resolve a position as though it were an +/// address. +#[test] +fn the_pool_index_space_and_geometry_must_agree() { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["geometry"] = json!(Value::Null) + }); + match expect_validation_error(&bytes) { + ValidationError::CapabilityContradiction { domain, .. } => { + assert_eq!(domain, Domain::PoolIndexSpace) + } + other => panic!("expected an index-space contradiction, got {other:?}"), + } + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["object_pool"]["index_space"] = json!("ordinal") + }); + match expect_validation_error(&bytes) { + ValidationError::CapabilityContradiction { domain, .. } => { + assert_eq!(domain, Domain::PoolIndexSpace) + } + other => panic!("expected an index-space contradiction, got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// Schema: VAL-MODEL-001 +// --------------------------------------------------------------------------- + +const SCHEMA_PATH: &str = "../../schemas/program-model-v4.schema.json"; + +/// The committed schema is the generated schema. +/// +/// Set `UPDATE_SCHEMA=1` to rewrite the file after an intentional model change; +/// the drift check below is what stops that from being a way to paper over an +/// unintentional one. +#[test] +fn the_committed_schema_matches_the_generated_one() { + let generated = format!( + "{}\n", + serde_json::to_string_pretty(&schema()).expect("schema serializes") + ); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(SCHEMA_PATH); + if std::env::var_os("UPDATE_SCHEMA").is_some() { + std::fs::write(&path, &generated).expect("write schema"); + return; + } + let committed = std::fs::read_to_string(&path).expect("committed schema exists"); + assert_eq!( + committed, generated, + "schemas/program-model-v4.schema.json is stale; regenerate with UPDATE_SCHEMA=1" + ); +} + +/// Which schema branch describes this instance. +fn branch_for<'a>(schema: &'a Value, instance: &Value) -> &'a Value { + let Some(branches) = schema.get("oneOf").and_then(Value::as_array) else { + return schema; + }; + branches + .iter() + .find(|branch| type_matches(branch, instance)) + .unwrap_or_else(|| panic!("no schema branch accepts {instance}")) +} + +fn type_matches(schema: &Value, instance: &Value) -> bool { + let accepts = |name: &str| match name { + "object" => instance.is_object(), + "array" => instance.is_array(), + "string" => instance.is_string(), + "integer" => instance.is_i64() || instance.is_u64(), + "number" => instance.is_number(), + "boolean" => instance.is_boolean(), + "null" => instance.is_null(), + _ => false, + }; + match schema.get("type") { + Some(Value::String(name)) => accepts(name), + Some(Value::Array(names)) => names.iter().filter_map(Value::as_str).any(accepts), + _ => true, + } +} + +/// Walk a serialized model against the schema, in both directions. +/// +/// This is the drift check that matters: comparing the committed file to +/// `schema()` only proves the file is current, not that either describes the +/// Rust types. A field added to a struct shows up as an instance key with no +/// schema property; a field removed shows up as a schema property with no +/// instance key. Both fail here. +fn assert_agrees(instance: &Value, schema: &Value, path: &str) { + let schema = branch_for(schema, instance); + assert!( + type_matches(schema, instance), + "{path}: schema type {:?} does not accept {instance}", + schema.get("type") + ); + if let (Some(allowed), Some(text)) = ( + schema.get("enum").and_then(Value::as_array), + instance.as_str(), + ) { + assert!( + allowed.iter().any(|v| v.as_str() == Some(text)), + "{path}: {text:?} is not in the schema's enum" + ); + } + match instance { + Value::Object(fields) => { + if schema.get("additionalProperties") == Some(&Value::Bool(true)) { + return; + } + let properties = schema + .get("properties") + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("{path}: object schema has no properties")); + for key in fields.keys() { + assert!( + properties.contains_key(key), + "{path}: the Rust type serializes {key:?}, which the schema does not declare" + ); + } + for key in properties.keys() { + assert!( + fields.contains_key(key), + "{path}: the schema declares {key:?}, which the Rust type does not serialize" + ); + } + for (key, value) in fields { + assert_agrees(value, &properties[key], &format!("{path}.{key}")); + } + } + Value::Array(items) => { + let item_schema = schema + .get("items") + .unwrap_or_else(|| panic!("{path}: array schema has no items")); + for (index, item) in items.iter().enumerate() { + assert_agrees(item, item_schema, &format!("{path}[{index}]")); + } + } + _ => {} + } +} + +/// The generated schema describes the Rust types, not an older version of them. +#[test] +fn the_schema_matches_the_rust_types() { + let schema = schema(); + for model in [maximal_model(), unavailable_model()] { + let instance = model_json(&model); + assert_agrees(&instance, &schema, "$"); + } +} + +/// The schema is closed everywhere except the one object that is documented as +/// open, which is what makes an undeclared field a rejectable condition. +#[test] +fn every_schema_object_except_extensions_is_closed() { + fn walk(node: &Value, path: &str, open: &mut Vec) { + if let Some(map) = node.as_object() { + if map.get("type") == Some(&Value::String("object".to_string())) + && map.get("additionalProperties") != Some(&Value::Bool(false)) + { + open.push(path.to_string()); + } + for (key, value) in map { + walk(value, &format!("{path}.{key}"), open); + } + } else if let Some(items) = node.as_array() { + for (index, item) in items.iter().enumerate() { + walk(item, &format!("{path}[{index}]"), open); + } + } + } + let mut open = Vec::new(); + walk(&schema(), "$", &mut open); + assert_eq!( + open, + vec!["$.properties.extensions".to_string()], + "exactly one object may be open" + ); +} + +#[test] +fn the_schema_pins_the_model_version() { + assert_eq!( + schema()["properties"]["model_version"]["const"], + json!(MODEL_VERSION) + ); +} + +/// A producer can recover class names without recovering which library they +/// came from. That has to be expressible, because the alternative is what v3 +/// did: file every such class under an invented library URI. +#[test] +fn a_class_with_no_recovered_library_is_valid() { + let mut model = support::maximal_model(); + model.libraries.clear(); + model.capabilities.libraries = CapabilityLevel::Unavailable; + model.diagnostics.push(Diagnostic::unavailable( + Domain::Libraries, + "no library attribution table in this snapshot", + )); + for class in &mut model.classes { + class.library = None; + } + let parsed = parse_and_validate(&model.to_canonical_json()).expect("valid without libraries"); + assert!(parsed.classes.iter().all(|c| c.library.is_none())); + assert_eq!(parsed.class_library_uri(ClassId(2)), None); +} diff --git a/crates/flutterdec-adapter/tests/producer_v4.rs b/crates/flutterdec-adapter/tests/producer_v4.rs new file mode 100644 index 00000000..9f5e4581 --- /dev/null +++ b/crates/flutterdec-adapter/tests/producer_v4.rs @@ -0,0 +1,872 @@ +//! End-to-end tests for the checked-in Python producer. +//! +//! Every case here runs the real `adapters/python/adapter_template.py` as a +//! subprocess through [`run_adapter`], so what is under test is the artifact +//! that ships, not a Rust re-implementation of it. The model that comes back has +//! crossed a process boundary as JSON and been through the same parse and +//! semantic validation the core uses. +//! +//! The question these ask is not "did it produce a model" but "did it produce a +//! model that admits what it does not know". A producer that invents +//! `package:app/main.dart`, a `Global` class, a function called `main`, or a +//! calibrated-looking confidence passes a schema check and lies to every +//! consumer downstream. + +mod support; + +use flutterdec_adapter::model::{ + CapabilityLevel, ClassId, Domain, PoolIndexSpace, Producer, ProducerTrust, ProgramModel, + Provenance, +}; +use flutterdec_adapter::model::{CompatibilityBinding, InputRegionName}; +use flutterdec_adapter::primitives::Sha256Digest; +use flutterdec_adapter::protocol::{BackendId, RequestedBackend}; +use flutterdec_adapter::{ + install_adapter, run_adapter, AdapterInput, AdapterRegionInput, AdapterRun, +}; +use flutterdec_loader::identity::SnapshotIdentity; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +const VM_INSTR_VA: u64 = 0x1000; +const ISO_INSTR_VA: u64 = 0x2000; + +/// `stp x29, x30, [sp, #-16]!` then `ret`: the frame prologue the internal +/// backend scans for, followed by something to end on. +const PROLOGUE: [u8; 4] = 0xA9BF_7BFDu32.to_le_bytes(); +const RET: [u8; 4] = 0xD65F_03C0u32.to_le_bytes(); + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize repo root") +} + +/// A scratch repo with the real producer installed under it. +struct Installed { + _dir: TempDir, + exec: PathBuf, +} + +fn install(hash: &str) -> Installed { + install_named(hash, None) +} + +/// Install under an arbitrary adapter file name. +/// +/// Used to prove that a deliberately misleading filename changes nothing: the +/// resolved backend comes from the protocol result, not from the path. +fn install_named(hash: &str, file_name: Option<&str>) -> Installed { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path(); + fs::create_dir_all(root.join("adapters/python")).expect("mkdir python"); + fs::create_dir_all(root.join("adapters/installed")).expect("mkdir installed"); + fs::copy( + repo_root().join("adapters/python/adapter_template.py"), + root.join("adapters/python/adapter_template.py"), + ) + .expect("copy producer"); + + if let Some(name) = file_name { + let manifest = serde_json::json!({ + "entries": [{ "snapshot_hash": hash, "version": "unknown", "adapter": name }] + }); + fs::write( + root.join("adapters/manifest.json"), + serde_json::to_vec_pretty(&manifest).expect("manifest json"), + ) + .expect("write manifest"); + } + + let exec = install_adapter(root, hash).expect("install adapter"); + Installed { _dir: dir, exec } +} + +/// Install an adapter that cannot discover any external backend. +/// +/// The producer resolves r2flutter and blutter from `FLUTTERDEC_*` variables and +/// then from `PATH`, and the installed wrapper inherits this test runner's +/// environment. A developer machine with either tool installed would otherwise +/// resolve a real backend and invalidate the assertion. The neutralization is +/// done inside the adapter's own process, as the blutter bridge test does, so +/// concurrent tests are unaffected. +fn install_without_external_backends(hash: &str) -> Installed { + let installed = install(hash); + let adapters = installed + .exec + .parent() + .and_then(Path::parent) + .expect("the installed adapter lives under /adapters/installed"); + fs::create_dir_all(adapters.join("no-tools")).expect("mkdir no-tools"); + fs::write( + &installed.exec, + r#"#!/usr/bin/env python3 +from pathlib import Path +import os +import sys +root = Path(__file__).resolve().parents[1] +for name in ( + "FLUTTERDEC_R2FLUTTER_CMD", + "FLUTTERDEC_R2FLUTTER_BIN", + "FLUTTERDEC_BLUTTER_CMD", + "FLUTTERDEC_BLUTTER_PY", +): + os.environ.pop(name, None) +os.environ["PATH"] = str(root / "no-tools") +sys.path.insert(0, str(root / "python")) +import adapter_template +if __name__ == "__main__": + raise SystemExit(adapter_template.entrypoint()) +"#, + ) + .expect("write adapter exec"); + set_executable(&installed.exec); + installed +} + +/// The host's own producer record. +/// +/// `Local` is not a judgement call here: `run_adapter` refuses any identity that +/// did not clear the exact-selection gate, so every run that happens at all is +/// one a locally installed adapter was authorized for. +fn producer(exec: &Path) -> Producer { + Producer { + id: "flutterdec-local-python".to_string(), + version: "unknown".to_string(), + artifact_sha256: Sha256Digest::of(&fs::read(exec).expect("read adapter artifact")), + trust: ProducerTrust::Local, + } +} + +fn compatibility() -> CompatibilityBinding { + CompatibilityBinding { + record_sha256: Sha256Digest::of(b"producer test record"), + parser_family_id: "flutterdec-local-python".to_string(), + profile_id: "unresolved".to_string(), + profile_sha256: Sha256Digest::of(b"producer test profile"), + } +} + +struct Snapshot { + vm_data: Vec, + isolate_data: Vec, + vm_instr: Vec, + isolate_instr: Vec, +} + +/// Bytes with no printable runs and no ARM64 prologues: nothing to recover. +fn empty_snapshot() -> Snapshot { + Snapshot { + vm_data: vec![0u8; 64], + isolate_data: vec![0u8; 64], + vm_instr: RET.to_vec(), + isolate_instr: RET.repeat(4), + } +} + +fn set_executable(path: &Path) { + let mut perms = fs::metadata(path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("chmod"); +} + +fn regions(snapshot: &Snapshot) -> Vec> { + vec![ + AdapterRegionInput { + region: InputRegionName::VmData, + bytes: &snapshot.vm_data, + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::IsolateData, + bytes: &snapshot.isolate_data, + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::VmInstructions, + bytes: &snapshot.vm_instr, + virtual_address: Some(VM_INSTR_VA), + }, + AdapterRegionInput { + region: InputRegionName::IsolateInstructions, + bytes: &snapshot.isolate_instr, + virtual_address: Some(ISO_INSTR_VA), + }, + ] +} + +fn run( + installed: &Installed, + identity: &SnapshotIdentity, + snapshot: &Snapshot, + backend: RequestedBackend, +) -> Result { + run_adapter( + &installed.exec, + &AdapterInput { + identity, + producer: producer(&installed.exec), + compatibility: compatibility(), + regions: regions(snapshot), + input_path: None, + libapp_path: None, + requested_backend: backend, + }, + ) + .map_err(|err| format!("{err:#}")) +} + +/// Strings that would be fabrications if they appeared anywhere in the model. +const FABRICATIONS: &[&str] = &[ + "package:app/main.dart", + "\"Global\"", + "sub_", + "fn_0x", + "EntryPointCandidate", + "BootMainCandidate", + "dynamic_snapshot_string_model_v1", +]; + +fn assert_no_fabrications(model: &ProgramModel) { + let json = String::from_utf8(model.to_canonical_json()).expect("model is utf-8"); + for needle in FABRICATIONS { + assert!( + !json.contains(needle), + "model contains the fabricated token {needle}: {json}" + ); + } + // A confidence anywhere is a calibrated-looking number none of these + // backends has anything to calibrate against. + assert!( + !json.contains("\"confidence\":0.") && !json.contains("\"confidence\":1"), + "model carries a confidence score: {json}" + ); + for function in &model.functions { + if let Some(name) = &function.name { + assert!( + !name.text.starts_with("sub_"), + "function {} carries an address-derived name", + function.id + ); + } + } + for class in &model.classes { + assert_ne!(class.name, "Global", "a `Global` class was invented"); + } +} + +/// Every unavailable domain has to say why, or "nothing was there" and "we did +/// not look" are the same answer. +fn assert_unavailable_domains_are_explained(model: &ProgramModel) { + for domain in Domain::ALL { + if model.capabilities.level(domain) != CapabilityLevel::Unavailable { + continue; + } + assert!( + model + .diagnostics + .iter() + .any(|d| d.subject.as_deref() == Some(domain.as_str())), + "domain {domain} is unavailable with no diagnostic: {:?}", + model.diagnostics + ); + } +} + +#[test] +fn a_snapshot_with_nothing_in_it_yields_unavailable_domains_and_no_invented_records() { + let installed = install("deadbeefdeadbeefdeadbeefdeadbeef"); + let identity = support::identity(); + let run = run( + &installed, + &identity, + &empty_snapshot(), + RequestedBackend::Fixed(BackendId::Internal), + ) + .expect("internal backend runs on an empty snapshot"); + + assert_eq!(run.resolved_backend, BackendId::Internal); + assert_eq!(run.fallback_reason, None); + + let model = &run.model; + assert_no_fabrications(model); + assert_unavailable_domains_are_explained(model); + + // No library URI in the data image means no libraries, not one library named + // after an app that may not exist. + assert!(model.libraries.is_empty()); + assert_eq!(model.capabilities.libraries, CapabilityLevel::Unavailable); + + // This backend does not deserialize the snapshot, so it has no class table. + assert!(model.classes.is_empty()); + assert_eq!(model.capabilities.classes, CapabilityLevel::Unavailable); + assert_eq!( + model.capabilities.class_relationships, + CapabilityLevel::Unavailable + ); + + // Names are never recoverable from instruction bytes alone. + assert_eq!( + model.capabilities.function_names, + CapabilityLevel::Unavailable + ); + assert!(model.functions.iter().all(|f| f.name.is_none())); + + // Carved strings are not an ObjectPool. + assert_eq!(model.object_pool.index_space, PoolIndexSpace::Ordinal); + assert!(model.object_pool.geometry.is_none()); + assert_eq!( + model.capabilities.pool_index_space, + CapabilityLevel::Unavailable + ); +} + +#[test] +fn heuristic_code_ranges_are_labelled_heuristic_and_stay_unnamed() { + let installed = install("deadbeefdeadbeefdeadbeefdeadbeef"); + let identity = support::identity(); + let mut snapshot = empty_snapshot(); + // Three prologues in the isolate instruction image, so the scanner has + // something to find and the domain comes back partial rather than empty. + snapshot.isolate_instr = [ + PROLOGUE.as_slice(), + RET.as_slice(), + PROLOGUE.as_slice(), + RET.as_slice(), + PROLOGUE.as_slice(), + RET.as_slice(), + ] + .concat(); + + let run = run( + &installed, + &identity, + &snapshot, + RequestedBackend::Fixed(BackendId::Internal), + ) + .expect("internal backend runs"); + let model = &run.model; + assert_no_fabrications(model); + assert_unavailable_domains_are_explained(model); + + assert!( + !model.functions.is_empty(), + "prologue scanning should recover code ranges" + ); + assert_eq!(model.capabilities.functions, CapabilityLevel::Partial); + for function in &model.functions { + assert_eq!( + function.provenance, + Provenance::Heuristic, + "a prologue guess is not an exact fact" + ); + assert!(function.name.is_none()); + assert!(function.owner.is_none()); + assert_eq!(function.code_section_va, ISO_INSTR_VA); + assert!(function.code.size > 0); + } + assert!( + model + .diagnostics + .iter() + .any(|d| d.subject.as_deref() == Some("functions")), + "a heuristic-only domain has to say so: {:?}", + model.diagnostics + ); +} + +#[test] +fn carved_strings_become_ordinal_pool_entries_never_hardware_ones() { + let installed = install("deadbeefdeadbeefdeadbeefdeadbeef"); + let identity = support::identity(); + let mut snapshot = empty_snapshot(); + snapshot.isolate_data = b"package:sample/widgets/home.dart\0onPressed\0Scaffold\0".to_vec(); + + let run = run( + &installed, + &identity, + &snapshot, + RequestedBackend::Fixed(BackendId::Internal), + ) + .expect("internal backend runs"); + let model = &run.model; + assert_no_fabrications(model); + assert_unavailable_domains_are_explained(model); + + // The carved URI becomes a library, but a heuristic one: it is a string that + // looks like a URI, not an entry read out of a library table. + assert_eq!( + model + .libraries + .iter() + .map(|l| l.uri.as_str()) + .collect::>(), + vec!["package:sample/widgets/home.dart"] + ); + assert!(model + .libraries + .iter() + .all(|l| l.provenance == Provenance::Heuristic)); + assert_eq!(model.capabilities.libraries, CapabilityLevel::Partial); + + assert!(!model.object_pool.entries.is_empty()); + assert_eq!(model.object_pool.index_space, PoolIndexSpace::Ordinal); + assert_eq!( + model.capabilities.pool_index_space, + CapabilityLevel::Unavailable + ); + for entry in &model.object_pool.entries { + assert_eq!(entry.provenance, Provenance::Heuristic); + assert_eq!(entry.confidence, None); + assert_eq!(entry.target_va, None); + } + // Ascending, unique indexes: the host rejects anything else. + let indexes = model + .object_pool + .entries + .iter() + .map(|e| e.index) + .collect::>(); + let mut sorted = indexes.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(indexes, sorted); +} + +#[test] +fn a_pinned_backend_that_cannot_run_fails_instead_of_falling_back() { + let installed = install_without_external_backends("deadbeefdeadbeefdeadbeefdeadbeef"); + let identity = support::identity(); + // The adapter cannot resolve r2flutter at all, so the backend cannot run. + // Pinned means it must fail rather than quietly answer as internal. + let err = run( + &installed, + &identity, + &empty_snapshot(), + RequestedBackend::Fixed(BackendId::R2Flutter), + ) + .expect_err("a pinned r2flutter run must not fall back to internal"); + assert!( + err.contains("r2flutter"), + "the failure should name the backend that could not run: {err}" + ); +} + +#[test] +fn auto_falls_back_to_internal_and_says_why() { + let installed = install_without_external_backends("deadbeefdeadbeefdeadbeefdeadbeef"); + let identity = support::identity(); + let run = run( + &installed, + &identity, + &empty_snapshot(), + RequestedBackend::Auto, + ) + .expect("auto reaches the internal backend"); + + assert_eq!(run.resolved_backend, BackendId::Internal); + assert!( + run.fallback_reason.is_some(), + "auto that did not get its first choice has to say why" + ); +} + +/// The resolved backend is a typed field on the protocol result. A filename that +/// says otherwise, however loudly, changes nothing. +#[test] +fn a_misleading_adapter_filename_cannot_change_the_resolved_backend() { + let identity = support::identity(); + for name in [ + "r2flutter_serwalker_adapter", + "blutter_bridge_model_v1", + "internal_but_actually_r2flutter", + "snapshot_serwalker", + ] { + let installed = install_named("deadbeefdeadbeefdeadbeefdeadbeef", Some(name)); + let run = run( + &installed, + &identity, + &empty_snapshot(), + RequestedBackend::Fixed(BackendId::Internal), + ) + .expect("internal backend runs"); + assert_eq!( + run.resolved_backend, + BackendId::Internal, + "adapter file named {name} changed the resolved backend" + ); + assert_eq!( + run.model.producer.trust, + ProducerTrust::Local, + "trust is host-assigned; a filename cannot raise it" + ); + } +} + +/// A producer that answers with a v2 or v3 document is rejected as the wrong +/// contract, not reinterpreted. This is the check that makes "no shim" real +/// across a process boundary rather than only in a unit test. +#[test] +fn a_producer_that_emits_a_legacy_model_is_rejected() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path(); + fs::create_dir_all(root.join("adapters/installed")).expect("mkdir"); + let exec = root.join("adapters/installed/legacy_adapter"); + fs::write( + &exec, + r#"#!/usr/bin/env python3 +import argparse, json, pathlib + +p = argparse.ArgumentParser() +p.add_argument("--request", required=True) +p.add_argument("--result", required=True) +p.add_argument("--input-path") +p.add_argument("--libapp-path") +args = p.parse_args() + +request = json.loads(pathlib.Path(args.request).read_text()) +pathlib.Path(request["output"]).write_text(json.dumps({ + "schema_version": 3, + "adapter_kind": "dynamic_snapshot_string_model_v1", + "dart_version": "unknown", + "snapshot_hash": "deadbeef", + "arch": "arm64", + "libraries": [{"id": 0, "uri": "package:app/main.dart", "name_display": "package:app/main.dart"}], + "classes": [{"id": 0, "name": "Global", "super": "Object", "lib": "package:app/main.dart"}], + "functions": [{"id": 0, "name": "main", "owner_class": "Global", "entry_va": 8192, + "size": 16, "code_section_va": 8192, "name_kind": "placeholder"}], + "object_pool": [] +})) +pathlib.Path(args.result).write_text(json.dumps({ + "protocol_major": 1, + "model_major": 4, + "status": "ok", + "model": request["output"], + "error": None, + "resolved_backend": "internal", + "fallback_reason": None, + "diagnostics": [] +})) +"#, + ) + .expect("write legacy adapter"); + set_executable(&exec); + + let identity = support::identity(); + let installed = Installed { + _dir: dir, + exec: exec.clone(), + }; + let err = run( + &installed, + &identity, + &empty_snapshot(), + RequestedBackend::Fixed(BackendId::Internal), + ) + .expect_err("a v3 document is not a v4 model"); + assert!( + err.contains("legacy schema_version 3"), + "the rejection should name the contract mismatch: {err}" + ); + assert!( + err.contains("no compatibility shim"), + "the rejection should say there is no migration path: {err}" + ); +} + +/// The Blutter bridge, against a stub that emits blutter's real output shape. +/// +/// v3's version of this test asserted that the bridge *synthesized* +/// `EntryPointCandidate` pool entries for `main`-like names. That is exactly the +/// fabrication this contract removes: the bridge now reports what blutter's dump +/// says and nothing more, and boot-flow classification is the host's job. +#[test] +fn the_blutter_bridge_reports_what_the_dump_says_and_invents_nothing() { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path().to_path_buf(); + fs::create_dir_all(root.join("adapters/python")).expect("mkdir python"); + fs::create_dir_all(root.join("adapters/installed")).expect("mkdir installed"); + fs::copy( + repo_root().join("adapters/python/adapter_template.py"), + root.join("adapters/python/adapter_template.py"), + ) + .expect("copy producer"); + + let fake = root.join("fake_blutter"); + fs::write( + &fake, + r#"#!/usr/bin/env python3 +from pathlib import Path +import sys + +out_dir = Path(sys.argv[2]) +asm = out_dir / "asm" +asm.mkdir(parents=True, exist_ok=True) +(asm / "main.dart").write_text( + "// lib: 0, url: package:sample/main.dart\n" + "class :: {\n" + " dynamic main() {\n" + "// ** addr: 0x2000, size: 0x10\n" + " }\n" + "}\n", + encoding="utf-8", +) +(asm / "router.dart").write_text( + "// lib: 1, url: package:sample/router.dart\n" + "class RouterHost extends Object {\n" + " dynamic onNewIntent() {\n" + "// ** addr: 0x2010, size: 0x10\n" + " }\n" + "}\n", + encoding="utf-8", +) +(out_dir / "pp.txt").write_text("[pp+0x18] \"a pool string\"\n", encoding="utf-8") +"#, + ) + .expect("write fake blutter"); + set_executable(&fake); + + // The runner is pointed at through the adapter's own environment rather than + // this process's, so concurrent tests cannot see it. + let exec = root.join("adapters/installed/blutter_adapter"); + fs::write( + &exec, + format!( + "#!/usr/bin/env python3\nfrom pathlib import Path\nimport os\nimport sys\nroot = Path(__file__).resolve().parents[1]\nos.environ['FLUTTERDEC_BLUTTER_CMD'] = {:?}\nsys.path.insert(0, str(root / 'python'))\nimport adapter_template\nif __name__ == '__main__':\n raise SystemExit(adapter_template.entrypoint())\n", + fake.display().to_string() + ), + ) + .expect("write adapter exec"); + set_executable(&exec); + + let installed = Installed { + _dir: dir, + exec: exec.clone(), + }; + let identity = support::identity(); + let mut snapshot = empty_snapshot(); + snapshot.isolate_instr = RET.repeat(16); + let input = root.join("app.apk"); + fs::write(&input, b"dummy").expect("write dummy input"); + + let run = run_adapter( + &installed.exec, + &AdapterInput { + identity: &identity, + producer: producer(&installed.exec), + compatibility: compatibility(), + regions: regions(&snapshot), + input_path: Some(&input), + libapp_path: None, + requested_backend: RequestedBackend::Fixed(BackendId::Blutter), + }, + ) + .map_err(|err| format!("{err:#}")) + .expect("blutter bridge runs"); + + assert_eq!(run.resolved_backend, BackendId::Blutter); + let model = &run.model; + assert_no_fabrications(model); + assert_unavailable_domains_are_explained(model); + + // `class :: {` is blutter's header for a library's top-level members. It is + // not a class, so `main` has no owner rather than an owner called `Global`. + let main = model + .functions + .iter() + .find(|f| f.code.start_va == 0x2000) + .expect("main's code range"); + assert_eq!(main.name_text(), Some("main")); + assert_eq!(main.owner, None); + assert_eq!( + main.name.as_ref().expect("name").provenance, + Provenance::Heuristic, + "a name scraped out of rendered source is a guess about the text" + ); + + let on_new_intent = model + .functions + .iter() + .find(|f| f.code.start_va == 0x2010) + .expect("onNewIntent's code range"); + assert_eq!(model.owner_name(on_new_intent), Some("RouterHost")); + assert_eq!( + model.owner_library_uri(on_new_intent), + Some("package:sample/router.dart") + ); + + // No boot-flow candidate was written into the pool, and the pool that is + // there is ordinal: blutter's `pp+` displacements are not confirmed to be + // PP-relative for this snapshot, so no index space is claimed. + assert_eq!(model.object_pool.index_space, PoolIndexSpace::Ordinal); + assert_eq!( + model.capabilities.pool_index_space, + CapabilityLevel::Unavailable + ); + assert!(model + .object_pool + .entries + .iter() + .all(|e| e.target_va.is_none())); +} + +/// Install the real producer with a fake `r2flutter` wired in, plus a dummy +/// target file for it to be pointed at. +/// +/// `classes_json` is what the fake answers for `-jc`. Everything else answers +/// the least the backend needs to build a model: one instruction-table entry +/// inside the declared isolate region, no strings, and a `-jp` failure so no +/// pool geometry is claimed. +fn install_with_fake_r2flutter(hash: &str, classes_json: &str) -> (Installed, PathBuf) { + let installed = install(hash); + let adapters = installed + .exec + .parent() + .and_then(Path::parent) + .expect("the installed adapter lives under /adapters/installed") + .to_path_buf(); + + let fake = adapters.join("fake_r2flutter"); + fs::write( + &fake, + format!( + r#"#!/usr/bin/env python3 +import json +import sys + +if "-jc" in sys.argv: + print(json.dumps({classes_json})) +elif "-ji" in sys.argv: + print(json.dumps({{"entries": [{{"index": 0, "address": {iso}, "name": "method.Widget.build"}}]}})) +elif "-jxz" in sys.argv or "-jzz" in sys.argv: + print(json.dumps([])) +else: + raise SystemExit(1) +"#, + classes_json = classes_json, + iso = ISO_INSTR_VA, + ), + ) + .expect("write fake r2flutter"); + set_executable(&fake); + + // `FLUTTERDEC_R2FLUTTER_CMD` outranks `_BIN` and `PATH` in the producer's + // resolver, so a real r2flutter on the developer's machine cannot win. + fs::write( + &installed.exec, + r#"#!/usr/bin/env python3 +from pathlib import Path +import os +import sys +root = Path(__file__).resolve().parents[1] +os.environ["FLUTTERDEC_R2FLUTTER_CMD"] = str(root / "fake_r2flutter") +sys.path.insert(0, str(root / "python")) +import adapter_template +if __name__ == "__main__": + raise SystemExit(adapter_template.entrypoint()) +"#, + ) + .expect("write adapter exec"); + set_executable(&installed.exec); + + let target = adapters.join("libapp.so"); + fs::write(&target, b"dummy").expect("write dummy target"); + (installed, target) +} + +fn run_r2flutter( + installed: &Installed, + identity: &SnapshotIdentity, + snapshot: &Snapshot, + target: &Path, +) -> Result { + run_adapter( + &installed.exec, + &AdapterInput { + identity, + producer: producer(&installed.exec), + compatibility: compatibility(), + regions: regions(snapshot), + input_path: None, + libapp_path: Some(target), + requested_backend: RequestedBackend::Fixed(BackendId::R2Flutter), + }, + ) + .map_err(|err| format!("{err:#}")) +} + +#[test] +fn a_structured_superclass_links_when_r2flutter_resolved_it_and_stays_null_when_it_did_not() { + // r2flutter emits `super` as an object. It fills in `name` itself when the + // reference resolves, so `Child` here is a superclass it could not name and + // `Widget` is one it could. + let (installed, target) = install_with_fake_r2flutter( + "deadbeefdeadbeefdeadbeefdeadbeef", + r#"[ + {"name": "Child", "super": {"type_ref": 35836}}, + {"name": "Widget", "super": {"ref": 12, "name": "StatefulWidget"}}, + {"name": "StatefulWidget"} + ]"#, + ); + let identity = support::identity(); + let run = run_r2flutter(&installed, &identity, &empty_snapshot(), &target) + .expect("the r2flutter backend runs against the fake"); + + assert_eq!(run.resolved_backend, BackendId::R2Flutter); + let model = &run.model; + assert_no_fabrications(model); + assert_unavailable_domains_are_explained(model); + + let by_name = |name: &str| { + model + .classes + .iter() + .find(|c| c.name == name) + .unwrap_or_else(|| panic!("class {name} is missing from the model")) + }; + + // An unresolved reference is no edge, not an edge to `Object`. Inventing + // one would assert a hierarchy the snapshot never stated. + assert_eq!(by_name("Child").super_class, None); + assert_eq!( + by_name("Widget").super_class, + Some(ClassId(by_name("StatefulWidget").id.0)) + ); + assert_eq!( + model.capabilities.class_relationships, + CapabilityLevel::Partial + ); +} + +#[test] +fn class_relationships_stay_unavailable_when_no_superclass_resolves() { + // Both shapes r2flutter emits for a superclass it could not name. Neither + // is an edge, so the domain has nothing partial about it. + let (installed, target) = install_with_fake_r2flutter( + "deadbeefdeadbeefdeadbeefdeadbeef", + r#"[ + {"name": "Child", "super": {"type_ref": 35836}}, + {"name": "Widget", "super": {"ref": 987}} + ]"#, + ); + let identity = support::identity(); + let run = run_r2flutter(&installed, &identity, &empty_snapshot(), &target) + .expect("the r2flutter backend runs against the fake"); + + let model = &run.model; + assert_no_fabrications(model); + assert_unavailable_domains_are_explained(model); + + assert_eq!(model.classes.len(), 2); + assert!(model.classes.iter().all(|c| c.super_class.is_none())); + // The class table itself was read, so classes are partial while the + // relationships between them are not there at all. + assert_eq!(model.capabilities.classes, CapabilityLevel::Partial); + assert_eq!( + model.capabilities.class_relationships, + CapabilityLevel::Unavailable + ); +} diff --git a/crates/flutterdec-adapter/tests/protocol_v1.rs b/crates/flutterdec-adapter/tests/protocol_v1.rs new file mode 100644 index 00000000..525d6788 --- /dev/null +++ b/crates/flutterdec-adapter/tests/protocol_v1.rs @@ -0,0 +1,556 @@ +//! Adapter protocol v1 contract tests. +//! +//! Same rule as the model suite: everything goes through serialized bytes, so +//! what is under test is what an adapter can actually put on disk. + +mod support; + +use flutterdec_adapter::model::{InputRegionName, MODEL_VERSION}; +use flutterdec_adapter::primitives::{RelativePath, Sha256Digest}; +use flutterdec_adapter::protocol::{ + AdapterError, AdapterErrorCode, AdapterRequest, AdapterResult, AdapterStatus, BackendId, + FallbackReason, InputHandle, ProtocolError, RequestedBackend, PROTOCOL_MAJOR, +}; +use serde_json::{json, Value}; +use support::{ISO_INSTR_VA, VM_INSTR_VA}; + +fn path(text: &str) -> RelativePath { + RelativePath::parse(text).expect("fixture path is valid") +} + +fn handle(region: InputRegionName, file: &str, size: u64, va: Option) -> InputHandle { + InputHandle { + region, + path: path(file), + size, + sha256: Sha256Digest::of(file.as_bytes()), + virtual_address: va, + executable: region.is_executable(), + } +} + +fn request() -> AdapterRequest { + AdapterRequest { + protocol_major: PROTOCOL_MAJOR, + model_major: MODEL_VERSION, + compatibility: support::compatibility(), + producer: support::producer(), + identity: support::identity(), + requested_backend: RequestedBackend::Auto, + inputs: vec![ + handle(InputRegionName::VmData, "in/vm_data.bin", 64, None), + handle(InputRegionName::IsolateData, "in/iso_data.bin", 128, None), + handle( + InputRegionName::VmInstructions, + "in/vm_instr.bin", + support::VM_INSTR_SIZE, + Some(VM_INSTR_VA), + ), + handle( + InputRegionName::IsolateInstructions, + "in/iso_instr.bin", + support::ISO_INSTR_SIZE, + Some(ISO_INSTR_VA), + ), + ], + output: path("out/model.json"), + } +} + +fn request_json() -> Value { + serde_json::from_slice(&request().to_json()).expect("request serializes to json") +} + +fn mutated(mut value: Value, mutate: impl FnOnce(&mut Value)) -> Vec { + mutate(&mut value); + serde_json::to_vec(&value).expect("mutated fixture serializes") +} + +#[test] +fn a_request_round_trips_through_fresh_json() { + let original = request(); + let parsed = AdapterRequest::from_json(&original.to_json()).expect("request is valid"); + assert_eq!(parsed, original); + assert_eq!(parsed.to_json(), original.to_json()); + assert_eq!( + parsed + .input(InputRegionName::IsolateInstructions) + .map(|i| i.virtual_address), + Some(Some(ISO_INSTR_VA)) + ); +} + +#[test] +fn a_result_round_trips_in_both_its_shapes() { + let ok = AdapterResult::ok( + path("out/model.json"), + BackendId::R2Flutter, + None, + Vec::new(), + ); + assert_eq!( + AdapterResult::from_json(&ok.to_json()).expect("ok result"), + ok + ); + + let failed = AdapterResult::failed(AdapterErrorCode::ParseFailed, "isolate data truncated"); + let parsed = AdapterResult::from_json(&failed.to_json()).expect("failed result"); + assert_eq!(parsed, failed); + assert_eq!(parsed.status, AdapterStatus::Failed); + assert_eq!( + parsed.error.as_ref().map(|e| e.code), + Some(AdapterErrorCode::ParseFailed) + ); + + // `Unsupported` is a distinct outcome from `Failed`: retrying will not help. + let unsupported = + AdapterResult::unsupported(AdapterErrorCode::UnsupportedSnapshot, "no parser family"); + let parsed = AdapterResult::from_json(&unsupported.to_json()).expect("unsupported result"); + assert_eq!(parsed.status, AdapterStatus::Unsupported); +} + +/// One backend has one name on the wire. +/// +/// The request's `requested_backend` and the result's `resolved_backend` are +/// different Rust types over the same vocabulary, so a producer answers with the +/// token it was handed. When they disagreed, `requested_backend: "r2flutter"` +/// went out and every `resolved_backend: "r2flutter"` came back rejected, which +/// broke the r2flutter backend for real runs while every Rust-to-Rust round trip +/// still passed. Asserting on the JSON token is what catches that. +#[test] +fn a_backend_has_the_same_wire_token_on_both_documents() { + for (backend, token) in [ + (BackendId::Internal, "internal"), + (BackendId::Blutter, "blutter"), + (BackendId::R2Flutter, "r2flutter"), + ] { + assert_eq!( + serde_json::to_value(backend).expect("backend serializes"), + json!(token), + "{backend} does not serialize as its own name" + ); + assert_eq!(backend.as_str(), token); + + // The request writes the token, the result has to accept it back. + let bytes = mutated(request_json(), |v| v["requested_backend"] = json!(token)); + let parsed = AdapterRequest::from_json(&bytes).expect("request accepts the token"); + assert_eq!(parsed.requested_backend, RequestedBackend::Fixed(backend)); + + let result: Value = serde_json::from_slice( + &AdapterResult::ok(path("out/model.json"), backend, None, Vec::new()).to_json(), + ) + .expect("result parses as json"); + assert_eq!(result["resolved_backend"], json!(token)); + let bytes = mutated(result, |v| v["resolved_backend"] = json!(token)); + assert_eq!( + AdapterResult::from_json(&bytes) + .expect("result accepts the token") + .resolved_backend, + Some(backend) + ); + } +} + +/// Version negotiation is a rejection, not a translation. A document written for +/// another protocol or model major has to stop here rather than be interpreted +/// under this build's field meanings. +#[test] +fn unsupported_majors_are_rejected_on_both_documents() { + let bytes = mutated(request_json(), |v| v["protocol_major"] = json!(2)); + assert_eq!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::UnsupportedProtocolMajor(2)) + ); + + let bytes = mutated(request_json(), |v| v["model_major"] = json!(3)); + assert_eq!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::UnsupportedModelMajor(3)) + ); + + let result = AdapterResult::ok( + path("out/model.json"), + BackendId::R2Flutter, + None, + Vec::new(), + ); + let mut raw: Value = serde_json::from_slice(&result.to_json()).expect("json"); + raw["protocol_major"] = json!(99); + let bytes = serde_json::to_vec(&raw).expect("serialize"); + assert_eq!( + AdapterResult::from_json(&bytes), + Err(ProtocolError::UnsupportedProtocolMajor(99)) + ); +} + +/// The old adapter interface was a pile of CLI flags: `--vm-data`, `--out`, and +/// a bare JSON blob with no version, no digests, and no identity. Feeding that +/// shape in has to fail, or "the adapter ran" would keep meaning "some process +/// wrote some JSON". +#[test] +fn the_legacy_cli_shape_cannot_satisfy_the_protocol() { + let legacy = json!({ + "vm_data": "/tmp/vm_data.bin", + "isolate_data": "/tmp/iso_data.bin", + "vm_instr": "/tmp/vm_instr.bin", + "isolate_instr": "/tmp/iso_instr.bin", + "vm_instr_va": 4096, + "isolate_instr_va": 8192, + "out": "/tmp/model.json", + "backend": "blutter" + }); + let bytes = serde_json::to_vec(&legacy).expect("legacy fixture serializes"); + let err = AdapterRequest::from_json(&bytes).expect_err("legacy shape is not a v1 request"); + assert!( + matches!(err, ProtocolError::Malformed(ref detail) if detail.contains("protocol_major")), + "expected a missing-version rejection, got {err}" + ); + + // Even with the versions bolted on, the legacy fields are undeclared and the + // required ones are absent. + let mut with_versions = legacy; + with_versions["protocol_major"] = json!(PROTOCOL_MAJOR); + with_versions["model_major"] = json!(MODEL_VERSION); + let bytes = serde_json::to_vec(&with_versions).expect("serialize"); + assert!(matches!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::Malformed(_)) + )); +} + +/// Snapshot bytes stay on disk. The request is a handle list, so its size is a +/// function of the number of regions and not of how big they are. +#[test] +fn a_request_stays_small_regardless_of_snapshot_size() { + let mut huge = request(); + for input in &mut huge.inputs { + input.size = 512 * 1024 * 1024; + } + let bytes = huge.to_json(); + assert!( + bytes.len() < 2048, + "a request for a half-gigabyte snapshot serialized to {} bytes", + bytes.len() + ); + + // There is nowhere to put content: no field of any input accepts bytes, and + // an attempt to add one is an undeclared field. + let bytes = mutated(request_json(), |v| { + v["inputs"][0]["contents_base64"] = json!("AAAA") + }); + assert!(matches!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::Malformed(_)) + )); +} + +/// Handles are contained by construction, so a traversal or absolute path +/// cannot survive deserialization into a value the host would later join onto a +/// working directory. +#[test] +fn path_handles_cannot_escape_the_working_directory() { + for escape in [ + "../../etc/passwd", + "/etc/passwd", + "in/../../out", + "", + "in\\vm.bin", + ] { + let bytes = mutated(request_json(), |v| v["inputs"][0]["path"] = json!(escape)); + assert!( + matches!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::Malformed(_)) + ), + "path {escape:?} was accepted" + ); + let bytes = mutated(request_json(), |v| v["output"] = json!(escape)); + assert!( + matches!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::Malformed(_)) + ), + "output {escape:?} was accepted" + ); + } +} + +#[test] +fn a_digest_that_is_not_a_digest_is_rejected() { + for bad in ["", "deadbeef", "not-a-digest", &"F".repeat(64)] { + let bytes = mutated(request_json(), |v| v["inputs"][0]["sha256"] = json!(bad)); + assert!( + matches!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::Malformed(_)) + ), + "digest {bad:?} was accepted" + ); + } +} + +/// The four regions are the contract. A missing one means the adapter would read +/// less than the host loaded; a duplicate means two handles claim the same +/// region and only one can be right. +#[test] +fn the_region_set_must_be_exactly_the_four_regions() { + let bytes = mutated(request_json(), |v| { + v["inputs"].as_array_mut().unwrap().remove(0); + }); + assert_eq!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::MissingRegion(InputRegionName::VmData)) + ); + + let bytes = mutated(request_json(), |v| { + v["inputs"][1]["region"] = json!("vm_data"); + v["inputs"][1]["path"] = json!("in/other.bin"); + }); + assert_eq!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::DuplicateRegion(InputRegionName::VmData)) + ); +} + +#[test] +fn region_geometry_must_be_self_consistent() { + let bytes = mutated(request_json(), |v| { + v["inputs"][0]["executable"] = json!(true) + }); + assert_eq!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::RegionExecutabilityMismatch( + InputRegionName::VmData + )) + ); + + let bytes = mutated(request_json(), |v| { + v["inputs"][2]["virtual_address"] = json!(Value::Null) + }); + assert_eq!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::RegionAddressMismatch( + InputRegionName::VmInstructions + )) + ); + + let bytes = mutated(request_json(), |v| v["inputs"][0]["size"] = json!(0)); + assert_eq!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::EmptyRegion(InputRegionName::VmData)) + ); + + // Checked arithmetic: a load address plus a size that leaves the address + // space describes no memory. + let bytes = mutated(request_json(), |v| { + v["inputs"][2]["virtual_address"] = json!(u64::MAX); + v["inputs"][2]["size"] = json!(2); + }); + assert_eq!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::RegionOverflows( + InputRegionName::VmInstructions + )) + ); +} + +/// An output that aliases an input would let the adapter destroy the bytes it +/// was asked to read. +#[test] +fn handles_must_be_distinct() { + let bytes = mutated(request_json(), |v| v["output"] = json!("in/vm_data.bin")); + assert_eq!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::AliasedPath("in/vm_data.bin".to_string())) + ); + + let bytes = mutated(request_json(), |v| { + v["inputs"][1]["path"] = json!("in/vm_data.bin") + }); + assert_eq!( + AdapterRequest::from_json(&bytes), + Err(ProtocolError::AliasedPath("in/vm_data.bin".to_string())) + ); +} + +/// A result has to be actionable: success names an artifact, failure names a +/// reason. Either without the other leaves the host with nothing to do. +#[test] +fn a_results_status_and_payload_must_agree() { + let mut ok = AdapterResult::ok( + path("out/model.json"), + BackendId::R2Flutter, + None, + Vec::new(), + ); + ok.model = None; + assert_eq!( + AdapterResult::from_json(&ok.to_json()), + Err(ProtocolError::StatusPayloadMismatch) + ); + + let mut ok = AdapterResult::ok( + path("out/model.json"), + BackendId::R2Flutter, + None, + Vec::new(), + ); + ok.error = Some(AdapterError { + code: AdapterErrorCode::Internal, + message: "but it worked".to_string(), + }); + assert_eq!( + AdapterResult::from_json(&ok.to_json()), + Err(ProtocolError::StatusPayloadMismatch) + ); + + let mut failed = AdapterResult::failed(AdapterErrorCode::Internal, "boom"); + failed.error = None; + assert_eq!( + AdapterResult::from_json(&failed.to_json()), + Err(ProtocolError::StatusPayloadMismatch) + ); + + let mut failed = AdapterResult::failed(AdapterErrorCode::Internal, "boom"); + failed.model = Some(path("out/model.json")); + assert_eq!( + AdapterResult::from_json(&failed.to_json()), + Err(ProtocolError::StatusPayloadMismatch) + ); +} + +/// Error codes are the stable part of a failure. They serialize as the snake +/// case names operators and tests match on, and a code this build does not know +/// is a rejection rather than a silent `Internal`. +#[test] +fn error_codes_are_stable_names() { + let failed = AdapterResult::failed(AdapterErrorCode::InputDigestMismatch, "digest"); + let raw: Value = serde_json::from_slice(&failed.to_json()).expect("json"); + assert_eq!(raw["error"]["code"], json!("input_digest_mismatch")); + assert_eq!(raw["status"], json!("failed")); + + let bytes = mutated(raw, |v| v["error"]["code"] = json!("something_new")); + assert!(matches!( + AdapterResult::from_json(&bytes), + Err(ProtocolError::Malformed(_)) + )); +} + +/// The request carries the host's identity verbatim, including the gate result, +/// so an adapter cannot be handed a snapshot the host has not cleared. +#[test] +fn the_request_carries_the_host_identity_unchanged() { + let parsed = AdapterRequest::from_json(&request().to_json()).expect("valid"); + assert_eq!(parsed.identity, support::identity()); + assert!(parsed.identity.exact_selection_key().is_ok()); +} + +/// Which backend ran is a typed field on the result, not something to be read +/// out of a name. Success has to name one, and a run that produced nothing must +/// not: "some backend produced this" and "no backend produced anything, but +/// here is a backend name" are both unusable answers. +#[test] +fn a_result_must_name_the_backend_that_produced_the_model() { + let mut ok = AdapterResult::ok(path("out/model.json"), BackendId::Blutter, None, Vec::new()); + ok.resolved_backend = None; + assert_eq!( + AdapterResult::from_json(&ok.to_json()), + Err(ProtocolError::ResolvedBackendPayloadMismatch) + ); + + let mut failed = AdapterResult::failed(AdapterErrorCode::ParseFailed, "boom"); + failed.resolved_backend = Some(BackendId::Internal); + assert_eq!( + AdapterResult::from_json(&failed.to_json()), + Err(ProtocolError::ResolvedBackendPayloadMismatch) + ); +} + +/// A pinned backend may fail. It may not be quietly swapped for another one, +/// which is what `auto`'s fall-through would otherwise do to an operator who +/// asked for a specific parser. +#[test] +fn a_pinned_backend_cannot_be_substituted() { + let mut pinned = request(); + pinned.requested_backend = RequestedBackend::Fixed(BackendId::R2Flutter); + + let substituted = AdapterResult::ok( + path("out/model.json"), + BackendId::Internal, + None, + Vec::new(), + ); + assert_eq!( + substituted.validate_against(&pinned), + Err(ProtocolError::BackendSubstituted { + requested: BackendId::R2Flutter, + resolved: BackendId::Internal, + }) + ); + + let honored = AdapterResult::ok( + path("out/model.json"), + BackendId::R2Flutter, + None, + Vec::new(), + ); + assert_eq!(honored.validate_against(&pinned), Ok(())); + + // Failing is always allowed; that is the whole point of pinning. + let refused = AdapterResult::failed(AdapterErrorCode::UnsupportedSnapshot, "no r2flutter"); + assert_eq!(refused.validate_against(&pinned), Ok(())); +} + +/// A fallback reason on a pinned request describes something that cannot have +/// happened, so it is a rejection rather than a curiosity. +#[test] +fn a_fallback_reason_only_makes_sense_under_auto() { + let mut pinned = request(); + pinned.requested_backend = RequestedBackend::Fixed(BackendId::Blutter); + let result = AdapterResult::ok( + path("out/model.json"), + BackendId::Blutter, + Some(FallbackReason::BackendUnavailable), + Vec::new(), + ); + assert_eq!( + result.validate_against(&pinned), + Err(ProtocolError::FallbackWithoutAuto) + ); + + let auto = request(); + assert_eq!(auto.requested_backend, RequestedBackend::Auto); + assert_eq!(result.validate_against(&auto), Ok(())); +} + +/// The backend vocabulary is closed. A producer that calls itself `serwalker`, +/// `blutter_bridge_model_v1`, or anything else outside the enum cannot land in +/// the field at all, which is what stops a name from becoming an authority. +#[test] +fn backend_names_outside_the_closed_set_are_rejected() { + let ok = AdapterResult::ok( + path("out/model.json"), + BackendId::Internal, + None, + Vec::new(), + ); + let raw: Value = serde_json::from_slice(&ok.to_json()).expect("json"); + assert_eq!(raw["resolved_backend"], json!("internal")); + + for imposter in [ + "serwalker", + "r2flutter_snapshot_v1", + "blutter_bridge_model_v1", + "dynamic_snapshot_string_model_v1", + "internal_but_actually_r2flutter", + ] { + let bytes = mutated(raw.clone(), |v| v["resolved_backend"] = json!(imposter)); + assert!( + matches!( + AdapterResult::from_json(&bytes), + Err(ProtocolError::Malformed(_)) + ), + "{imposter} was accepted as a backend id" + ); + } +} diff --git a/crates/flutterdec-adapter/tests/support/mod.rs b/crates/flutterdec-adapter/tests/support/mod.rs new file mode 100644 index 00000000..51eadc02 --- /dev/null +++ b/crates/flutterdec-adapter/tests/support/mod.rs @@ -0,0 +1,253 @@ +//! Fixture builders shared by the v4 model and protocol test suites. +//! +//! Everything here goes through the crate's public API only, and every test +//! that consumes a fixture serializes it and parses the bytes back, so the +//! suites exercise fresh JSON rather than in-memory structs that never crossed +//! the boundary. +//! +//! Each test binary links its own copy of this module and uses part of it, so +//! the unused-item warnings that would follow are an artifact of that layout. +#![allow(dead_code)] + +use flutterdec_adapter::model::{ + Capabilities, CapabilityLevel, Class, ClassId, CodeRange, CompatibilityBinding, Diagnostic, + DiagnosticCode, DiagnosticSeverity, Function, FunctionId, InputRegion, InputRegionName, + Library, LibraryId, Name, ObjectPool, ObservedInput, PoolEntry, PoolEntryKind, PoolGeometry, + PoolIndexSpace, Producer, ProducerTrust, ProgramModel, Provenance, MODEL_VERSION, +}; +use flutterdec_adapter::primitives::Sha256Digest; +use flutterdec_adapter::validate::HostSelectedContext; +use flutterdec_loader::identity::{SnapshotIdentity, SnapshotKind, TargetArch}; + +pub const HASH: &str = "80a49c7111088100a233b2ae788e1f48"; +pub const FEATURES: &str = "product no-code_comments arm64 android compressed-pointers"; + +pub const VM_INSTR_VA: u64 = 0x1000; +pub const VM_INSTR_SIZE: u64 = 0x100; +pub const ISO_INSTR_VA: u64 = 0x2000; +pub const ISO_INSTR_SIZE: u64 = 0x200; + +pub fn identity() -> SnapshotIdentity { + SnapshotIdentity::from_header(TargetArch::Arm64, HASH, SnapshotKind::FullAot, FEATURES) +} + +pub fn digest(seed: &str) -> Sha256Digest { + Sha256Digest::of(seed.as_bytes()) +} + +pub fn regions() -> Vec { + vec![ + InputRegion { + region: InputRegionName::VmData, + size: 64, + sha256: digest("vm_data"), + virtual_address: None, + executable: false, + }, + InputRegion { + region: InputRegionName::IsolateData, + size: 128, + sha256: digest("isolate_data"), + virtual_address: None, + executable: false, + }, + InputRegion { + region: InputRegionName::VmInstructions, + size: VM_INSTR_SIZE, + sha256: digest("vm_instr"), + virtual_address: Some(VM_INSTR_VA), + executable: true, + }, + InputRegion { + region: InputRegionName::IsolateInstructions, + size: ISO_INSTR_SIZE, + sha256: digest("isolate_instr"), + virtual_address: Some(ISO_INSTR_VA), + executable: true, + }, + ] +} + +pub fn producer() -> Producer { + Producer { + id: "dartaot".to_string(), + version: "3.5.0".to_string(), + artifact_sha256: digest("artifact"), + trust: ProducerTrust::Registered, + } +} + +pub fn compatibility() -> CompatibilityBinding { + CompatibilityBinding { + record_sha256: digest("record"), + parser_family_id: "dartaot-arm64".to_string(), + profile_id: "dart-3.5-arm64".to_string(), + profile_sha256: digest("profile"), + } +} + +pub fn host() -> HostSelectedContext { + HostSelectedContext { + identity: identity(), + producer: producer(), + compatibility: compatibility(), + regions: regions(), + } +} + +/// A model where every optional field is populated and every enum that appears +/// in the schema's `properties` is reachable. +/// +/// The drift check walks this value against the committed schema in both +/// directions, so it has to leave nothing out. +pub fn maximal_model() -> ProgramModel { + ProgramModel { + model_version: MODEL_VERSION, + producer: producer(), + input: ObservedInput { + identity: identity(), + regions: regions(), + }, + compatibility: compatibility(), + capabilities: Capabilities { + libraries: CapabilityLevel::Complete, + classes: CapabilityLevel::Complete, + class_relationships: CapabilityLevel::Partial, + // A heuristic code range is present, so this cannot be complete. + functions: CapabilityLevel::Partial, + // One function has no name at all. + function_names: CapabilityLevel::Partial, + // The pool carries an undecoded slot. + object_pool: CapabilityLevel::Partial, + pool_index_space: CapabilityLevel::Complete, + }, + libraries: vec![ + Library { + id: LibraryId(1), + uri: "dart:core".to_string(), + display_name: Some("core".to_string()), + provenance: Provenance::Exact, + }, + Library { + id: LibraryId(2), + uri: "package:app/main.dart".to_string(), + display_name: None, + provenance: Provenance::Derived, + }, + ], + classes: vec![ + Class { + id: ClassId(1), + name: "Object".to_string(), + library: Some(LibraryId(1)), + super_class: None, + provenance: Provenance::Exact, + }, + Class { + id: ClassId(2), + name: "App".to_string(), + library: Some(LibraryId(2)), + super_class: Some(ClassId(1)), + provenance: Provenance::Exact, + }, + ], + functions: vec![ + Function { + id: FunctionId(1), + name: Some(Name::exact("build")), + owner: Some(ClassId(2)), + code: CodeRange { + start_va: ISO_INSTR_VA, + size: 0x40, + }, + code_section_va: ISO_INSTR_VA, + provenance: Provenance::Exact, + }, + // A code range with no name: exactly the case v3 could not express + // without inventing one. + Function { + id: FunctionId(2), + name: None, + owner: None, + code: CodeRange { + start_va: VM_INSTR_VA, + size: 0x20, + }, + code_section_va: VM_INSTR_VA, + provenance: Provenance::Heuristic, + }, + ], + object_pool: ObjectPool { + index_space: PoolIndexSpace::Hardware, + geometry: Some(PoolGeometry { + entries_offset: 0x10, + word_size: 8, + }), + entries: vec![ + PoolEntry { + index: 0, + kind: PoolEntryKind::String, + value: Some("build".to_string()), + target_va: None, + provenance: Provenance::Exact, + confidence: None, + }, + PoolEntry { + index: 3, + kind: PoolEntryKind::Code, + value: Some("App.build".to_string()), + target_va: Some(ISO_INSTR_VA), + provenance: Provenance::Exact, + confidence: None, + }, + PoolEntry { + index: 5, + kind: PoolEntryKind::Undecoded, + value: None, + target_va: None, + provenance: Provenance::Exact, + confidence: None, + }, + PoolEntry { + index: 7, + kind: PoolEntryKind::Selector, + value: Some("onTap".to_string()), + target_va: Some(ISO_INSTR_VA + 0x40), + provenance: Provenance::Heuristic, + confidence: Some(0.5), + }, + ], + }, + diagnostics: vec![Diagnostic { + code: DiagnosticCode::DomainPartiallyRecovered, + severity: DiagnosticSeverity::Warning, + subject: Some("object_pool".to_string()), + message: "one pool slot was read but not decoded".to_string(), + }], + extensions: [( + "vendor".to_string(), + serde_json::json!({ "build": "local" }), + )] + .into_iter() + .collect(), + } +} + +/// The model a producer must emit when it recovered nothing. +/// +/// This is the shape v3 had no way to write: no libraries, no classes, no +/// functions, no pool, and a stated reason for each. +pub fn unavailable_model() -> ProgramModel { + let mut model = maximal_model(); + model.capabilities = Capabilities::all_unavailable(); + model.libraries.clear(); + model.classes.clear(); + model.functions.clear(); + model.object_pool = ObjectPool::unavailable(); + model.diagnostics = flutterdec_adapter::model::Domain::ALL + .iter() + .map(|domain| Diagnostic::unavailable(*domain, "no parser for this snapshot identity")) + .collect(); + model.extensions.clear(); + model +} diff --git a/crates/flutterdec-cli/src/main.rs b/crates/flutterdec-cli/src/main.rs index fbb0907d..1f663df7 100644 --- a/crates/flutterdec-cli/src/main.rs +++ b/crates/flutterdec-cli/src/main.rs @@ -505,14 +505,41 @@ fn handle_info(repo_root: &Path, cmd: InfoCmd) -> Result<()> { println!("compressed pointers: {}", compressed); } println!("adapter installed: {}", out.adapter_installed); - if let Some(kind) = out.adapter_kind.as_deref() { - println!("adapter kind: {}", kind); + // Requested, resolved, and fallback are printed separately because they + // are separate facts. Collapsing them into one "adapter kind" line is + // what made a filename look like a decision. + if let Some(requested) = out.requested_backend.as_deref() { + println!("requested backend: {}", requested); + } + if let Some(resolved) = out.resolved_backend.as_deref() { + println!("resolved backend: {}", resolved); + } + if let Some(reason) = out.backend_fallback_reason.as_deref() { + println!("backend fallback reason: {}", reason); + } + if let Some(id) = out.producer_id.as_deref() { + println!("producer: {}", id); + } + if let Some(trust) = out.producer_trust.as_deref() { + println!("producer trust: {}", trust); + } + if let Some(digest) = out.compatibility_record_sha256.as_deref() { + println!("compatibility record: {}", digest); } if let Some(present) = out.manifest_entry_present { println!("manifest entry present: {}", present); } - if let Some(hash_match) = out.adapter_snapshot_hash_match { - println!("adapter snapshot hash match: {}", hash_match); + if let Some(exact) = out.snapshot_identity_is_exact { + println!("snapshot identity header-derived: {}", exact); + } + if let Some(rejection) = out.identity_rejection.as_deref() { + println!("adapter selection refused: {}", rejection); + } + if let Some(capabilities) = out.model_capabilities.as_ref() { + println!("model capabilities:"); + for (domain, level) in capabilities { + println!(" {}: {}", domain, level); + } } if let Some(warnings) = out.compatibility_warnings.as_ref() { if !warnings.is_empty() { diff --git a/crates/flutterdec-core/src/lib.rs b/crates/flutterdec-core/src/lib.rs index 31e9076d..ba7673bb 100644 --- a/crates/flutterdec-core/src/lib.rs +++ b/crates/flutterdec-core/src/lib.rs @@ -1,21 +1,29 @@ #![recursion_limit = "512"] use anyhow::{bail, Context, Result}; +use flutterdec_adapter::model::{ + Capabilities, CompatibilityBinding, Domain, InputRegionName, Producer, ProducerTrust, + ProgramModel, +}; +use flutterdec_adapter::primitives::Sha256Digest; +use flutterdec_adapter::protocol::{BackendId, FallbackReason, RequestedBackend}; use flutterdec_adapter::{ - list_adapters, resolve_adapter_exec, run_adapter, AdapterInput, ProgramModel, + list_adapters, resolve_adapter_exec, run_adapter, AdapterInput, AdapterRegionInput, }; use flutterdec_decompiler::{emit_program_with_runtime_stubs, PseudocodeArtifact}; use flutterdec_disasm_arm64::{ disassemble_program_with_priorities_and_package_hints, FunctionDisassembly, - FunctionPriorityBreakdown, + FunctionPriorityBreakdown, HintKind, HintOrigin, HintProvenance, ProgramHints, }; use flutterdec_ir::{build_program_ir, FunctionIr}; +use flutterdec_loader::dart_profile::ResolvedDartProfile; +use flutterdec_loader::identity::ExactSelectionKey; use flutterdec_loader::{ load_snapshot_bundle, load_snapshot_bundle_from_apk_session, ApkSession, SnapshotBundle, }; use serde::Serialize; use serde_json::json; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -224,9 +232,27 @@ pub struct InfoOutput { /// The snapshot's features string verbatim, when the header parsed. pub snapshot_features: Option, pub adapter_installed: bool, - pub adapter_kind: Option, + /// What the operator asked for. + pub requested_backend: Option, + /// What actually answered, as the protocol result reported it. + pub resolved_backend: Option, + /// Why the two differ, when the request was `auto`. + pub backend_fallback_reason: Option, + pub producer_id: Option, + pub producer_trust: Option, + pub compatibility_record_sha256: Option, pub manifest_entry_present: Option, - pub adapter_snapshot_hash_match: Option, + /// Whether the snapshot identity came out of a real header. Replaces the v3 + /// "does the adapter agree about the hash" check, which compared a host fact + /// against a string the adapter chose. + pub snapshot_identity_is_exact: Option, + /// Why this snapshot may not select an adapter at all, when it may not. + /// `Some` means no manifest was read, no executable was resolved, and no + /// adapter ran; the fields below that describe a run are absent for that + /// reason rather than because a run failed. + pub identity_rejection: Option, + /// Per-domain capability levels the model reported. + pub model_capabilities: Option>, pub compatibility_warnings: Option>, pub function_count: Option, pub class_count: Option, diff --git a/crates/flutterdec-core/src/pipeline/apk_startup.rs b/crates/flutterdec-core/src/pipeline/apk_startup.rs index 001d9153..fca93373 100644 --- a/crates/flutterdec-core/src/pipeline/apk_startup.rs +++ b/crates/flutterdec-core/src/pipeline/apk_startup.rs @@ -2076,25 +2076,6 @@ fn startup_has_entrypoint_signal(startup: &AndroidStartupEvidence) -> bool { }) } -fn build_startup_class_library_lookup( - classes: &[flutterdec_adapter::ClassInfo], -) -> std::collections::HashMap { - let mut out = std::collections::HashMap::new(); - for class in classes { - out.entry(class.name.clone()) - .or_insert_with(|| class.library_uri.clone()); - } - out -} - -fn startup_hint_confidence(explicit_signal: bool) -> f64 { - if explicit_signal { - 0.9 - } else { - 0.8 - } -} - fn first_startup_method_selector( startup: &AndroidStartupEvidence, predicate: impl Fn(&str) -> bool, @@ -2102,308 +2083,169 @@ fn first_startup_method_selector( startup .startup_methods .iter() - .map(|method| normalize_method_selector(&method.method_name)) - .find(|selector| predicate(selector)) + .map(|method| method.method_name.clone()) + .find(|selector| predicate(&selector.to_ascii_lowercase())) } -fn first_startup_owner_and_library(startup: &AndroidStartupEvidence) -> (&str, String) { +fn first_startup_owner_and_library(startup: &AndroidStartupEvidence) -> (String, String) { if let Some(entrypoint) = startup.dart_entrypoints.first() { return ( - entrypoint.class_name.as_str(), + entrypoint.class_name.clone(), format!("apk:{}", entrypoint.source_dex), ); } if let Some(method) = startup.startup_methods.first() { - return (method.class_name.as_str(), format!("apk:{}", method.source_dex)); + return ( + method.class_name.clone(), + format!("apk:{}", method.source_dex), + ); } if let Some(class) = startup.flutter_activity_classes.first() { - return (class.class_name.as_str(), format!("apk:{}", class.source_dex)); + return ( + class.class_name.clone(), + format!("apk:{}", class.source_dex), + ); } - ("AndroidStartup", "apk:classes.dex".to_string()) + ("AndroidStartup".to_string(), "apk:classes.dex".to_string()) } -fn enrich_model_with_apk_startup_bootflow_hints( - model: &flutterdec_adapter::ProgramModel, +/// Turn APK startup evidence into hints, without touching the model. +/// +/// Two kinds come out. Function-anchored hints name a code address, because a +/// recovered function name matched a selector the dex evidence supports. +/// Evidence-only hints name no address at all: the dex says Flutter is started +/// from somewhere, and the honest record of that is a hint with `target_va: +/// None`, not a synthetic pool entry pointing at nothing. +fn collect_apk_startup_bootflow_hints( + model: &flutterdec_adapter::model::ProgramModel, startup: &AndroidStartupEvidence, -) -> (flutterdec_adapter::ProgramModel, usize) { + hints: &mut ProgramHints, +) -> usize { if !startup.present { - return (model.clone(), 0); + return 0; } let observed_method_names = startup_method_names(startup); let has_entrypoint_signal = startup_has_entrypoint_signal(startup); let has_bootstrap_signal = startup_has_bootstrap_signal(startup); - let has_deeplink_signal = observed_method_names.iter().any(|name| is_deeplink_selector(name)); + let has_deeplink_signal = observed_method_names + .iter() + .any(|name| is_deeplink_selector(name)); let has_activity_signal = !startup.flutter_activity_classes.is_empty() || observed_method_names .iter() .any(|name| is_activity_handler_selector(name)); - if !has_entrypoint_signal && !has_bootstrap_signal && !has_deeplink_signal && !has_activity_signal { - return (model.clone(), 0); + let licensed = |kind: HintKind| match kind { + HintKind::EntryPoint | HintKind::BootMain | HintKind::BootRunApp => has_entrypoint_signal, + HintKind::DeepLinkHandler => has_deeplink_signal, + HintKind::ActivityHandler => has_activity_signal, + HintKind::BootstrapInit => has_bootstrap_signal, + }; + + if !has_entrypoint_signal + && !has_bootstrap_signal + && !has_deeplink_signal + && !has_activity_signal + { + return 0; } - let mut enriched = model.clone(); let mut inserted = 0usize; - let mut inserted_main = 0usize; - let mut inserted_runapp = 0usize; - let mut inserted_deeplink = 0usize; - let mut inserted_activity = 0usize; - let mut inserted_bootstrap = 0usize; - let class_library = build_startup_class_library_lookup(&enriched.classes); - let mut seen = collect_existing_bootflow_hint_keys(&enriched); - let functions = enriched.functions.clone(); - - for function in functions { - let selector = normalize_method_selector(&function.name); - if selector.is_empty() { - continue; - } - let owner = function.owner_class.trim(); - let owner_lower = owner.to_ascii_lowercase(); - let library_uri = class_library - .get(&function.owner_class) - .cloned() - .unwrap_or_default(); - let library_lower = library_uri.to_ascii_lowercase(); - - if has_entrypoint_signal - && is_main_like_selector(&selector) - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "StartupMainCandidate", - selector: &selector, - target_va: Some(function.entry_va), - owner_class: owner, - library_uri: &library_uri, - value: "bootflow:main:apk_startup", - confidence: Some(startup_hint_confidence(true)), - source: Some("apk_startup"), - }, - ) - { - inserted += 1; - inserted_main += 1; - } - - if has_entrypoint_signal - && is_runapp_selector(&selector) - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "StartupRunAppCandidate", - selector: &selector, - target_va: Some(function.entry_va), - owner_class: owner, - library_uri: &library_uri, - value: "bootflow:runapp:apk_startup", - confidence: Some(startup_hint_confidence(true)), - source: Some("apk_startup"), - }, - ) - { - inserted += 1; - inserted_runapp += 1; - } - - if has_deeplink_signal - && is_deeplink_selector(&selector) - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "StartupDeepLinkCandidate", - selector: &selector, - target_va: Some(function.entry_va), - owner_class: owner, - library_uri: &library_uri, - value: "bootflow:deeplink:apk_startup", - confidence: Some(startup_hint_confidence(false)), - source: Some("apk_startup"), - }, - ) - { - inserted += 1; - inserted_deeplink += 1; - } + let mut anchored: HashSet = HashSet::new(); - if has_activity_signal - && is_activity_handler_selector(&selector) - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "StartupActivityCandidate", - selector: &selector, - target_va: Some(function.entry_va), - owner_class: owner, - library_uri: &library_uri, - value: "bootflow:activity:apk_startup", - confidence: Some(startup_hint_confidence(false)), - source: Some("apk_startup"), - }, - ) - { - inserted += 1; - inserted_activity += 1; - } - - if has_bootstrap_signal - && is_bootstrap_selector(&selector) - && (owner_is_bootstrap_context(&owner_lower) - || library_is_bootstrap_context(&library_lower)) - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "StartupBootstrapCandidate", - selector: &selector, - target_va: Some(function.entry_va), - owner_class: owner, - library_uri: &library_uri, - value: "bootflow:init:apk_startup", - confidence: Some(startup_hint_confidence(false)), - source: Some("apk_startup"), - }, - ) - { - inserted += 1; - inserted_bootstrap += 1; + for function in &model.functions { + let Some(name) = function.name_text() else { + continue; + }; + let owner = model.owner_name(function); + let library_uri = model.owner_library_uri(function); + let candidate = HintCandidate { + origin: HintOrigin::ApkStartup, + provenance: HintProvenance::Derived, + selector: name, + target_va: Some(function.code.start_va), + owner_class: owner, + library_uri, + detail: "apk startup evidence matched a recovered function name", + }; + for kind in hint_kinds_for_selector(name, owner, library_uri) { + if !licensed(kind) { + continue; + } + if push_hint(hints, kind, &candidate) { + inserted += 1; + anchored.insert(kind); + } } } + // Evidence the model could not be anchored to still gets recorded, so a + // report can say "the dex starts Flutter and no recovered function matches" + // instead of silently saying nothing. let (startup_owner, startup_library_uri) = first_startup_owner_and_library(startup); - let startup_library_uri = startup_library_uri.as_str(); - - if has_entrypoint_signal - && inserted_main == 0 - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "StartupMainCandidate", - selector: "main", - target_va: None, - owner_class: startup_owner, - library_uri: startup_library_uri, - value: "bootflow:main:apk_startup", - confidence: Some(startup_hint_confidence(true)), - source: Some("apk_startup"), - }, - ) - { - inserted += 1; - } - - if has_entrypoint_signal - && inserted_runapp == 0 - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "StartupRunAppCandidate", - selector: "runApp", - target_va: None, - owner_class: startup_owner, - library_uri: startup_library_uri, - value: "bootflow:runapp:apk_startup", - confidence: Some(startup_hint_confidence(true)), - source: Some("apk_startup"), - }, - ) - { - inserted += 1; - } - - if has_deeplink_signal && inserted_deeplink == 0 { - let selector = first_startup_method_selector(startup, is_deeplink_selector) - .unwrap_or_else(|| "onNewIntent".to_string()); - if push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "StartupDeepLinkCandidate", - selector: &selector, - target_va: None, - owner_class: startup_owner, - library_uri: startup_library_uri, - value: "bootflow:deeplink:apk_startup", - confidence: Some(startup_hint_confidence(false)), - source: Some("apk_startup"), - }, - ) { - inserted += 1; + let unanchored = |kind: HintKind, selector: &str, hints: &mut ProgramHints| { + if !licensed(kind) || anchored.contains(&kind) { + return 0; } - } - - if has_activity_signal && inserted_activity == 0 { - let selector = first_startup_method_selector(startup, is_activity_handler_selector) - .unwrap_or_else(|| "onCreate".to_string()); - if push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "StartupActivityCandidate", - selector: &selector, - target_va: None, - owner_class: startup_owner, - library_uri: startup_library_uri, - value: "bootflow:activity:apk_startup", - confidence: Some(startup_hint_confidence(false)), - source: Some("apk_startup"), - }, - ) { - inserted += 1; - } - } + let candidate = HintCandidate { + origin: HintOrigin::ApkStartup, + provenance: HintProvenance::Derived, + selector, + target_va: None, + owner_class: Some(startup_owner.as_str()), + library_uri: Some(startup_library_uri.as_str()), + detail: "apk startup evidence with no matching recovered function", + }; + usize::from(push_hint(hints, kind, &candidate)) + }; - if has_bootstrap_signal && inserted_bootstrap == 0 { - let selector = startup - .jni_bootstrap - .first() - .map(|item| item.target_method.clone()) - .or_else(|| { - startup - .startup_methods - .iter() - .find(|method| method.category.contains("initialization") || method.category.contains("jni")) - .map(|method| method.target_method.clone()) - }) - .unwrap_or_else(|| "attachToNative".to_string()); - if push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "StartupBootstrapCandidate", - selector: &selector, - target_va: None, - owner_class: startup_owner, - library_uri: startup_library_uri, - value: "bootflow:init:apk_startup", - confidence: Some(startup_hint_confidence(false)), - source: Some("apk_startup"), - }, - ) { - inserted += 1; - } - } + inserted += unanchored(HintKind::BootMain, "main", hints); + inserted += unanchored(HintKind::BootRunApp, "runApp", hints); + let deeplink_selector = first_startup_method_selector(startup, is_deeplink_selector) + .unwrap_or_else(|| "onNewIntent".to_string()); + inserted += unanchored(HintKind::DeepLinkHandler, &deeplink_selector, hints); + let activity_selector = first_startup_method_selector(startup, is_activity_handler_selector) + .unwrap_or_else(|| "onCreate".to_string()); + inserted += unanchored(HintKind::ActivityHandler, &activity_selector, hints); + let bootstrap_selector = startup + .jni_bootstrap + .first() + .map(|item| item.target_method.clone()) + .or_else(|| { + startup + .startup_methods + .iter() + .find(|method| { + method.category.contains("initialization") || method.category.contains("jni") + }) + .map(|method| method.target_method.clone()) + }) + .unwrap_or_else(|| "attachToNative".to_string()); + inserted += unanchored(HintKind::BootstrapInit, &bootstrap_selector, hints); - (enriched, inserted) + inserted } #[cfg(test)] mod apk_startup_tests { use super::{ - analyze_android_startup, classify_startup_method, enrich_model_with_apk_startup_bootflow_hints, + analyze_android_startup, classify_startup_method, collect_apk_startup_bootflow_hints, finalize_android_startup_evidence, has_super_class, is_classes_dex_entry, AndroidStartupEvidence, ScannedAppMethodInvoke, ScannedDartEntrypoint, ScannedMethodDef, ScannedMethodKey, ScannedStartupClass, ScannedStartupMethodRef, StartupManifestContext, StartupScanResult, DART_ENTRYPOINT_DESC, DART_EXECUTOR_DESC, FLUTTER_ACTIVITY_DESC, FLUTTER_ENGINE_DESC, FLUTTER_JNI_DESC, FLUTTER_LOADER_DESC, }; + use flutterdec_adapter::model::{ + Capabilities, CapabilityLevel, Class, ClassId, CodeRange, CompatibilityBinding, Function, + FunctionId, InputRegion, InputRegionName, Library, LibraryId, Name, ObjectPool, + ObservedInput, PoolIndexSpace, Producer, ProducerTrust, ProgramModel, Provenance, + MODEL_VERSION, + }; + use flutterdec_adapter::primitives::Sha256Digest; + use flutterdec_disasm_arm64::{HintKind, HintOrigin, HintProvenance, ProgramHints}; + use flutterdec_loader::identity::{SnapshotIdentity, SnapshotKind, TargetArch}; use std::collections::HashMap; use std::fs::File; use std::io::Write; @@ -2626,71 +2468,120 @@ mod apk_startup_tests { #[test] fn enriches_model_with_apk_startup_synthetic_bootflow_hints() { - let model = flutterdec_adapter::ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: vec![flutterdec_adapter::LibraryInfo { - id: 1, + let digest = Sha256Digest::of(b"apk startup fixture"); + let model = ProgramModel { + model_version: MODEL_VERSION, + producer: Producer { + id: "fixture".to_string(), + version: "0".to_string(), + artifact_sha256: digest.clone(), + trust: ProducerTrust::Untrusted, + }, + input: ObservedInput { + identity: SnapshotIdentity::from_header( + TargetArch::Arm64, + "80a49c7111088100a233b2ae788e1f48", + SnapshotKind::FullAot, + "product arm64 compressed-pointers", + ), + regions: vec![InputRegion { + region: InputRegionName::IsolateInstructions, + size: 0x1000, + sha256: digest.clone(), + virtual_address: Some(0x1000), + executable: true, + }], + }, + compatibility: CompatibilityBinding { + record_sha256: digest.clone(), + parser_family_id: "fixture".to_string(), + profile_id: "fixture".to_string(), + profile_sha256: digest, + }, + capabilities: Capabilities { + libraries: CapabilityLevel::Partial, + classes: CapabilityLevel::Partial, + class_relationships: CapabilityLevel::Unavailable, + functions: CapabilityLevel::Partial, + function_names: CapabilityLevel::Partial, + object_pool: CapabilityLevel::Unavailable, + pool_index_space: CapabilityLevel::Unavailable, + }, + libraries: vec![Library { + id: LibraryId(1), uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), + display_name: None, + provenance: Provenance::Exact, }], classes: vec![ - flutterdec_adapter::ClassInfo { - id: 1, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), + Class { + id: ClassId(1), + name: "AppRoot".to_string(), + library: Some(LibraryId(1)), + super_class: None, + provenance: Provenance::Exact, }, - flutterdec_adapter::ClassInfo { - id: 2, - name: "WidgetHost".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), + Class { + id: ClassId(2), + name: "MainActivityHost".to_string(), + library: Some(LibraryId(1)), + super_class: None, + provenance: Provenance::Exact, }, ], functions: vec![ - flutterdec_adapter::FunctionInfo { - id: 1, - name: "main".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 4, + Function { + id: FunctionId(1), + name: Some(Name::exact("main")), + owner: Some(ClassId(1)), + code: CodeRange { + start_va: 0x1000, + size: 4, + }, code_section_va: 0x1000, - name_kind: None, + provenance: Provenance::Exact, }, - flutterdec_adapter::FunctionInfo { - id: 2, - name: "runApp".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1004, - size: 4, + Function { + id: FunctionId(2), + name: Some(Name::exact("runApp")), + owner: Some(ClassId(1)), + code: CodeRange { + start_va: 0x1004, + size: 4, + }, code_section_va: 0x1000, - name_kind: None, + provenance: Provenance::Exact, }, - flutterdec_adapter::FunctionInfo { - id: 3, - name: "onNewIntent".to_string(), - owner_class: "WidgetHost".to_string(), - entry_va: 0x1008, - size: 4, + Function { + id: FunctionId(3), + name: Some(Name::exact("onNewIntent")), + owner: Some(ClassId(2)), + code: CodeRange { + start_va: 0x1008, + size: 4, + }, code_section_va: 0x1000, - name_kind: None, + provenance: Provenance::Exact, }, - flutterdec_adapter::FunctionInfo { - id: 4, - name: "ensureInitialized".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x100c, - size: 4, + Function { + id: FunctionId(4), + name: Some(Name::exact("ensureInitialized")), + owner: Some(ClassId(1)), + code: CodeRange { + start_va: 0x100c, + size: 4, + }, code_section_va: 0x1000, - name_kind: None, + provenance: Provenance::Exact, }, ], - pool_geometry: None, - object_pool: Vec::new(), + object_pool: ObjectPool { + index_space: PoolIndexSpace::Ordinal, + geometry: None, + entries: Vec::new(), + }, + diagnostics: Vec::new(), + extensions: Default::default(), }; let startup = AndroidStartupEvidence { present: true, @@ -2816,22 +2707,32 @@ mod apk_startup_tests { }, }; - let (enriched, inserted) = enrich_model_with_apk_startup_bootflow_hints(&model, &startup); - assert!(inserted >= 5); - - let kinds = enriched - .object_pool - .iter() - .filter_map(|entry| entry.decoded_kind.as_deref()) - .collect::>(); - assert!(kinds.contains(&"StartupMainCandidate")); - assert!(kinds.contains(&"StartupRunAppCandidate")); - assert!(kinds.contains(&"StartupDeepLinkCandidate")); - assert!(kinds.contains(&"StartupActivityCandidate")); - assert!(kinds.contains(&"StartupBootstrapCandidate")); - assert!(enriched - .object_pool + let before = model.clone(); + let mut hints = ProgramHints::new(); + let inserted = collect_apk_startup_bootflow_hints(&model, &startup, &mut hints); + assert!(inserted >= 5, "expected at least five hints, got {inserted}"); + + // The whole point of the hint record: the model is untouched, so the + // authoritative collections and the pool index space are exactly what + // the adapter authored. + assert_eq!(model, before); + assert!(model.object_pool.entries.is_empty()); + assert_eq!(model.object_pool.index_space, PoolIndexSpace::Ordinal); + + let kinds = hints.iter().map(|h| h.kind).collect::>(); + for expected in [ + HintKind::BootMain, + HintKind::BootRunApp, + HintKind::DeepLinkHandler, + HintKind::ActivityHandler, + HintKind::BootstrapInit, + ] { + assert!(kinds.contains(&expected), "missing hint kind {expected:?}"); + } + assert!(hints.iter().all(|h| h.origin == HintOrigin::ApkStartup)); + // Never `Exact`: dex evidence supports a guess about Dart code, not a fact. + assert!(hints .iter() - .all(|entry| entry.source.as_deref() == Some("apk_startup"))); + .all(|h| h.provenance == HintProvenance::Derived)); } } diff --git a/crates/flutterdec-core/src/pipeline/bootflow_hints.rs b/crates/flutterdec-core/src/pipeline/bootflow_hints.rs index 3e4e7547..45f96f3d 100644 --- a/crates/flutterdec-core/src/pipeline/bootflow_hints.rs +++ b/crates/flutterdec-core/src/pipeline/bootflow_hints.rs @@ -1,13 +1,12 @@ -struct SyntheticHintInput<'a> { - decoded_kind: &'a str, - selector: &'a str, - target_va: Option, - owner_class: &'a str, - library_uri: &'a str, - value: &'a str, - confidence: Option, - source: Option<&'a str>, -} +// Selector classification shared by every enrichment pass. +// +// These predicates say what a selector *looks* like. They do not say what it +// is, and nothing here writes into the adapter's model: the callers turn a +// match into a [`Hint`], which lives in its own record space with its own +// provenance. The previous version of this file appended synthetic +// `ObjectPoolEntry` records at `index = object_pool.len()`, which is how +// derived guesses ended up sharing an index space with hardware pool slots. +use flutterdec_disasm_arm64::Hint; fn normalize_method_selector(name: &str) -> String { let tail = name @@ -71,8 +70,7 @@ fn is_bootstrap_selector(selector: &str) -> bool { } fn owner_is_bootstrap_context(owner_lower: &str) -> bool { - owner_lower == "global" - || owner_lower.contains("binding") + owner_lower.contains("binding") || owner_lower.contains("bootstrap") || owner_lower.contains("engine") || owner_lower.contains("jni") @@ -85,75 +83,115 @@ fn library_is_bootstrap_context(library_lower: &str) -> bool { || library_lower.contains("/engine") } -fn synthetic_hint_key( - decoded_kind: &str, - selector: &str, - target_va: Option, - owner_class: &str, - library_uri: &str, - value: &str, -) -> String { - let target = target_va - .map(|va| format!("0x{va:x}")) - .unwrap_or_else(|| "none".to_string()); - format!( - "{}|{}|{}|{}|{}|{}", - decoded_kind.to_ascii_lowercase(), - selector.to_ascii_lowercase(), - target, - owner_class.to_ascii_lowercase(), - library_uri.to_ascii_lowercase(), - value.to_ascii_lowercase() - ) +/// Everything one enrichment pass knows about a candidate before deciding +/// whether it is worth a hint. +pub(crate) struct HintCandidate<'a> { + pub(crate) origin: HintOrigin, + pub(crate) provenance: HintProvenance, + pub(crate) selector: &'a str, + pub(crate) target_va: Option, + pub(crate) owner_class: Option<&'a str>, + pub(crate) library_uri: Option<&'a str>, + pub(crate) detail: &'a str, +} + +pub(crate) fn push_hint(hints: &mut ProgramHints, kind: HintKind, c: &HintCandidate<'_>) -> bool { + hints.push(Hint { + kind, + origin: c.origin, + provenance: c.provenance, + selector: c.selector.to_string(), + target_va: c.target_va, + owner_class: c.owner_class.map(str::to_string), + library_uri: c.library_uri.map(str::to_string), + detail: c.detail.to_string(), + }) } -fn collect_existing_bootflow_hint_keys( - model: &flutterdec_adapter::ProgramModel, -) -> std::collections::HashSet { - model.object_pool - .iter() - .filter_map(|entry| { - Some(synthetic_hint_key( - entry.decoded_kind.as_deref()?, - entry.selector.as_deref()?, - entry.target_va, - entry.owner_class.as_deref().unwrap_or(""), - entry.library_uri.as_deref().unwrap_or(""), - &entry.value, - )) - }) - .collect() +/// The hint kinds a selector's shape supports, in the context it was found in. +/// +/// Returns every kind that applies rather than the first: `onNewIntent` on an +/// activity is both a deep-link handler and an activity callback, and collapsing +/// that to one loses a seed category. +pub(crate) fn hint_kinds_for_selector( + raw_selector: &str, + owner_class: Option<&str>, + library_uri: Option<&str>, +) -> Vec { + let selector = normalize_method_selector(raw_selector); + if selector.is_empty() { + return Vec::new(); + } + let owner_lower = owner_class.unwrap_or("").to_ascii_lowercase(); + let library_lower = library_uri.unwrap_or("").to_ascii_lowercase(); + + let mut out = Vec::new(); + if is_main_like_selector(&selector) { + out.push(HintKind::EntryPoint); + out.push(HintKind::BootMain); + } + if is_runapp_selector(&selector) { + out.push(HintKind::EntryPoint); + out.push(HintKind::BootRunApp); + } + if is_deeplink_selector(&selector) { + out.push(HintKind::DeepLinkHandler); + } + if is_activity_handler_selector(&selector) { + // A lifecycle name only means an activity callback in an activity-shaped + // context. `onResume` on a plain Dart object is not one. + let activity_context = matches!(selector.as_str(), "onnewintent" | "handleintent") + || owner_lower.contains("activity") + || owner_lower.contains("flutterjni") + || library_lower.contains("activity") + || library_lower.contains("android") + || library_lower.starts_with("package:flutter/src/embedding"); + if activity_context { + out.push(HintKind::ActivityHandler); + } + } + if is_bootstrap_selector(&selector) { + let bootstrap_context = selector != "ensureinitialized" + || owner_is_bootstrap_context(&owner_lower) + || library_is_bootstrap_context(&library_lower); + if bootstrap_context { + out.push(HintKind::BootstrapInit); + } + } + out.dedup(); + out } -fn push_synthetic_hint( - model: &mut flutterdec_adapter::ProgramModel, - seen: &mut std::collections::HashSet, - hint: &SyntheticHintInput<'_>, -) -> bool { - let key = synthetic_hint_key( - hint.decoded_kind, - hint.selector, - hint.target_va, - hint.owner_class, - hint.library_uri, - hint.value, - ); - if seen.contains(&key) { - return false; +/// Hints derived from the names the adapter already recovered. +/// +/// This is the replacement for the producer-side "bootflow candidate" pool +/// entries: the same pattern matching, run by the host, over model records, into +/// records that are explicitly heuristic and explicitly not pool entries. +pub(crate) fn collect_model_name_hints( + model: &flutterdec_adapter::model::ProgramModel, + hints: &mut ProgramHints, +) -> usize { + let mut added = 0; + for function in &model.functions { + let Some(name) = function.name_text() else { + continue; + }; + let owner = model.owner_name(function); + let library = model.owner_library_uri(function); + for kind in hint_kinds_for_selector(name, owner, library) { + let candidate = HintCandidate { + origin: HintOrigin::ModelNamePattern, + provenance: HintProvenance::Heuristic, + selector: name, + target_va: Some(function.code.start_va), + owner_class: owner, + library_uri: library, + detail: "selector shape of a recovered function name", + }; + if push_hint(hints, kind, &candidate) { + added += 1; + } + } } - seen.insert(key); - let next_index = model.object_pool.len() as u64; - model.object_pool.push(flutterdec_adapter::ObjectPoolEntry { - index: next_index, - kind: "String".to_string(), - value: hint.value.to_string(), - decoded_kind: Some(hint.decoded_kind.to_string()), - selector: Some(hint.selector.to_string()), - target_va: hint.target_va, - owner_class: Some(hint.owner_class.to_string()), - library_uri: Some(hint.library_uri.to_string()), - confidence: hint.confidence, - source: hint.source.map(str::to_string), - }); - true + added } diff --git a/crates/flutterdec-core/src/pipeline/model.rs b/crates/flutterdec-core/src/pipeline/model.rs index 28fa33a5..9a439ee8 100644 --- a/crates/flutterdec-core/src/pipeline/model.rs +++ b/crates/flutterdec-core/src/pipeline/model.rs @@ -1,40 +1,211 @@ +/// The one parser family PR1 ships. A registry that could name others is PR2. +const PARSER_FAMILY_ID: &str = "flutterdec-local-python"; + +/// The profile id used when no Dart profile resolves for the snapshot hash. +/// +/// Not a placeholder standing in for a real profile: it names the state, and the +/// digest below is the digest of that state, so two runs without a profile agree +/// and a run with one never collides with them. +const UNRESOLVED_PROFILE_ID: &str = "unresolved"; + #[derive(Debug, Clone)] struct LoadedModel { model: ProgramModel, + /// The backend the protocol result named. Never inferred from a filename or + /// from a substring of adapter output. + resolved_backend: BackendId, + fallback_reason: Option, adapter_exec: PathBuf, + producer: Producer, + compatibility: CompatibilityBinding, manifest_entry_version: Option, manifest_entry_adapter: Option, } +/// A stable digest for a layout profile, or for the absence of one. +/// +/// `DartProfile` is deserialized from vendored data and is not `Serialize`, so +/// the digest is taken over a canonical rendering of the fields that decide +/// layout. Adding a field to the profile without adding it here would make two +/// different profiles digest the same, which is why the rendering lists them +/// explicitly rather than reflecting. +fn profile_binding(profile: Option<&ResolvedDartProfile>) -> (String, Sha256Digest) { + let Some(resolved) = profile else { + return ( + UNRESOLVED_PROFILE_ID.to_string(), + Sha256Digest::of(b"flutterdec:profile:unresolved"), + ); + }; + let p = &resolved.profile; + let mut cids = p.cids.iter().collect::>(); + cids.sort(); + // `dart_version` is deliberately absent. Layout buckets are sparse and + // resolved by floor, so several exact Dart versions share one + // `profile_version`. Including it would give those snapshots the same + // `profile_id` and different `profile_sha256` values, and the id is what + // this digest is supposed to pin. + let canonical = format!( + "profile_version={};tag_style={};compressed_word_size={};header_fields={};max_alignment={};heap_object_tag={};cids={:?}", + resolved.profile_version, + p.tag_style.as_str(), + p.compressed_word_size, + p.header_fields, + p.max_alignment, + p.heap_object_tag, + cids, + ); + ( + resolved.profile_version.clone(), + Sha256Digest::of(canonical.as_bytes()), + ) +} + +/// The compatibility decision, materialized locally. +/// +/// PR1 has no registry, so there is no record to look up; what there is, is a +/// decision the host made, and this digests it so the model can be tied back to +/// it. The digest covers the exact selection key, which only exists because the +/// identity already cleared the gate, so a model produced under a different +/// decision has a different binding and a rejected identity has none at all. +fn compatibility_binding(bundle: &SnapshotBundle, key: &ExactSelectionKey) -> CompatibilityBinding { + let (profile_id, profile_sha256) = profile_binding(bundle.dart_profile.as_ref()); + let decision = format!( + "exact;hash={};arch={};features={}", + key.hash, + key.target_arch.as_str(), + key.features.join(",") + ); + let record = format!( + "family={};profile={};decision={}", + PARSER_FAMILY_ID, profile_id, decision + ); + CompatibilityBinding { + record_sha256: Sha256Digest::of(record.as_bytes()), + parser_family_id: PARSER_FAMILY_ID.to_string(), + profile_id, + profile_sha256, + } +} + +/// Who the host is about to run, digest included. +/// +/// The digest is of the artifact on disk, taken immediately before the spawn, so +/// the model's producer record names the bytes that actually executed rather +/// than whatever the manifest says is installed. +fn producer_for(exec_path: &Path, version: Option<&str>) -> Result { + let bytes = fs::read(exec_path) + .with_context(|| format!("read adapter artifact: {}", exec_path.display()))?; + Ok(Producer { + id: PARSER_FAMILY_ID.to_string(), + version: version.unwrap_or("unknown").to_string(), + artifact_sha256: Sha256Digest::of(&bytes), + // `Local`, unconditionally, because `load_model` refuses any identity + // that cannot authorize an exact parser before it gets here. PR1 has no + // registry, so `Registered` is not yet reachable, and `Untrusted` is not + // a state a run can be in: a rejected identity stops, it is not + // relabeled and executed anyway. + trust: ProducerTrust::Local, + }) +} + +fn requested_backend(backend: AdapterBackend) -> RequestedBackend { + match backend { + AdapterBackend::Auto => RequestedBackend::Auto, + AdapterBackend::Internal => RequestedBackend::Fixed(BackendId::Internal), + AdapterBackend::Blutter => RequestedBackend::Fixed(BackendId::Blutter), + AdapterBackend::R2Flutter => RequestedBackend::Fixed(BackendId::R2Flutter), + } +} + +/// The core-facing name for a backend the protocol resolved. +fn backend_from_id(id: BackendId) -> AdapterBackend { + match id { + BackendId::Internal => AdapterBackend::Internal, + BackendId::Blutter => AdapterBackend::Blutter, + BackendId::R2Flutter => AdapterBackend::R2Flutter, + } +} + +/// The pre-lookup identity gate, in the one place every adapter path goes +/// through. +/// +/// This is deliberately not a warning or a trust label. A snapshot whose header +/// did not parse, or that is not FullAOT, or whose target this build cannot +/// handle, has no exact parser to select, so there is nothing for a manifest +/// lookup, a path resolution, or a process spawn to be right about. +fn require_exact_selection(bundle: &SnapshotBundle) -> Result { + bundle + .identity + .exact_selection_key() + .map_err(flutterdec_adapter::identity_rejected) +} + fn load_model( repo_root: &Path, bundle: &SnapshotBundle, backend: AdapterBackend, ) -> Result { + // Before the manifest is read, before a path is resolved, before anything is + // spawned. + let selection = require_exact_selection(bundle)?; let manifest = flutterdec_adapter::load_manifest(repo_root)?; let manifest_entry = manifest .entries .iter() .find(|entry| entry.snapshot_hash == bundle.snapshot_hash); let adapter_exec = resolve_adapter_exec(repo_root, &bundle.snapshot_hash)?; - let model = run_adapter( + let producer = producer_for( + &adapter_exec, + manifest_entry.map(|entry| entry.version.as_str()), + )?; + let compatibility = compatibility_binding(bundle, &selection); + + let run = run_adapter( &adapter_exec, &AdapterInput { + identity: &bundle.identity, + producer: producer.clone(), + compatibility: compatibility.clone(), + regions: vec![ + AdapterRegionInput { + region: InputRegionName::VmData, + bytes: &bundle.vm_data, + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::IsolateData, + bytes: &bundle.isolate_data, + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::VmInstructions, + bytes: &bundle.vm_instr, + virtual_address: Some(bundle.vm_instr_va), + }, + AdapterRegionInput { + region: InputRegionName::IsolateInstructions, + bytes: &bundle.isolate_instr, + virtual_address: Some(bundle.isolate_instr_va), + }, + ], input_path: Some(&bundle.input_path), libapp_path: Some(&bundle.libapp_path), - vm_data: &bundle.vm_data, - isolate_data: &bundle.isolate_data, - vm_instr: &bundle.vm_instr, - isolate_instr: &bundle.isolate_instr, - vm_instr_va: bundle.vm_instr_va, - isolate_instr_va: bundle.isolate_instr_va, - backend: Some(backend.as_str()), + requested_backend: requested_backend(backend), }, )?; + Ok(LoadedModel { - model, + model: run.model, + resolved_backend: run.resolved_backend, + fallback_reason: run.fallback_reason, adapter_exec, + producer, + compatibility, manifest_entry_version: manifest_entry.map(|entry| entry.version.clone()), manifest_entry_adapter: manifest_entry.map(|entry| entry.adapter.clone()), }) } + +#[cfg(test)] +#[path = "model_tests.rs"] +mod model_gate_tests; diff --git a/crates/flutterdec-core/src/pipeline/model_tests.rs b/crates/flutterdec-core/src/pipeline/model_tests.rs new file mode 100644 index 00000000..08c7b105 --- /dev/null +++ b/crates/flutterdec-core/src/pipeline/model_tests.rs @@ -0,0 +1,291 @@ +//! The identity gate, proven by what the pipeline does *not* do. +//! +//! Asserting that `load_model` returns an error would not distinguish a snapshot +//! refused before anything happened from one that was looked up, spawned, and +//! then failed. So each rejection case is run against a scratch repo that is +//! rigged to fail loudly at every step the gate is supposed to precede: +//! +//! * `adapters/manifest.json` is not valid JSON, so any manifest read reports a +//! parse failure instead of an identity rejection; +//! * an executable is installed under the expected name, so path resolution +//! would succeed and the failure would come from somewhere else; +//! * that executable is a spy that creates a marker file on its first line, so +//! a spawn leaves evidence even though the run itself cannot produce a model. +//! +//! A FullAOT control runs against the same rigging with a valid manifest and +//! proves the marker *does* appear, so the rejection cases are showing a gate +//! rather than a scratch repo that could never work. + +use super::*; +use flutterdec_loader::identity::{ + HashSource, IdentityRejection, SnapshotIdentity, SnapshotKind, TargetArch, +}; +use std::path::PathBuf; +use tempfile::TempDir; + +const HASH: &str = "80a49c7111088100a233b2ae788e1f48"; +const FEATURES: &str = "product no-code_comments arm64 android compressed-pointers"; + +/// A scratch repo whose adapter would run if it were ever reached. +struct SpyRepo { + _dir: TempDir, + root: PathBuf, + marker: PathBuf, +} + +impl SpyRepo { + /// `manifest` is written verbatim, so a caller can hand it bytes that are + /// not JSON at all. + fn new(manifest: &str) -> Self { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path().to_path_buf(); + let marker = root.join("adapter_ran.marker"); + fs::create_dir_all(root.join("adapters/installed")).expect("mkdir installed"); + fs::write(root.join("adapters/manifest.json"), manifest).expect("write manifest"); + + let exec = root.join(format!("adapters/installed/dart_adapter_{}", HASH)); + fs::write( + &exec, + format!( + "#!/bin/sh\ntouch '{}'\nexit 1\n", + marker.display() + ), + ) + .expect("write spy adapter"); + let mut perms = fs::metadata(&exec).expect("metadata").permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + fs::set_permissions(&exec, perms).expect("chmod spy adapter"); + + Self { + _dir: dir, + root, + marker, + } + } + + fn spawned(&self) -> bool { + self.marker.exists() + } +} + +fn poisoned_manifest_repo() -> SpyRepo { + SpyRepo::new("{ this is not json") +} + +fn valid_manifest_repo() -> SpyRepo { + SpyRepo::new(&format!( + "{{\"entries\":[{{\"snapshot_hash\":\"{}\",\"version\":\"1.0\",\"adapter\":\"dart_adapter_{}\"}}]}}", + HASH, HASH + )) +} + +/// A bundle carrying `identity`, with plausible regions so that nothing except +/// the identity can decide the outcome. +fn bundle(identity: SnapshotIdentity) -> SnapshotBundle { + SnapshotBundle { + input_path: PathBuf::from("/nonexistent/app.apk"), + libapp_path: PathBuf::from("/nonexistent/libapp.so"), + arch: identity.target_arch.as_str().to_string(), + snapshot_hash: identity.hash.clone().unwrap_or_default(), + vm_data: vec![0u8; 64], + isolate_data: vec![0u8; 64], + vm_instr: 0xD65F_03C0u32.to_le_bytes().to_vec(), + isolate_instr: 0xD65F_03C0u32.to_le_bytes().repeat(4), + vm_instr_va: 0x1000, + isolate_instr_va: 0x2000, + dart_profile: None, + snapshot_features: identity.features.raw.clone(), + compressed_pointers: Some(true), + identity, + } +} + +fn full_aot() -> SnapshotIdentity { + SnapshotIdentity::from_header(TargetArch::Arm64, HASH, SnapshotKind::FullAot, FEATURES) +} + +fn full_jit() -> SnapshotIdentity { + SnapshotIdentity::from_header(TargetArch::Arm64, HASH, SnapshotKind::FullJit, FEATURES) +} + +/// A hash recovered by scanning bytes, with no header behind it. +fn scanned() -> SnapshotIdentity { + SnapshotIdentity::without_header(TargetArch::Arm64, Some(HASH.to_string())) +} + +fn unsupported_target() -> SnapshotIdentity { + SnapshotIdentity::from_header( + TargetArch::Unsupported("x64".to_string()), + HASH, + SnapshotKind::FullAot, + "product x64 android compressed-pointers", + ) +} + +fn rejection(err: &anyhow::Error) -> IdentityRejection { + err.chain() + .find_map(|cause| cause.downcast_ref::()) + .cloned() + .unwrap_or_else(|| panic!("expected a typed identity rejection, got: {err:#}")) +} + +/// Every non-exact identity, through the shared entry every adapter path uses. +fn assert_stops_before_lookup(identity: SnapshotIdentity, expected: IdentityRejection) { + let repo = poisoned_manifest_repo(); + let bundle = bundle(identity); + + let err = load_model(&repo.root, &bundle, AdapterBackend::Auto) + .expect_err("a rejected identity cannot load a model"); + + assert_eq!(rejection(&err), expected, "wrong rejection: {err:#}"); + let rendered = format!("{err:#}"); + assert!( + !rendered.contains("adapter manifest"), + "the manifest was read before the gate: {rendered}" + ); + assert!( + !rendered.contains("adapter not installed"), + "the executable was resolved before the gate: {rendered}" + ); + assert!( + !repo.spawned(), + "the adapter was executed for a rejected identity" + ); +} + +#[test] +fn a_full_jit_snapshot_stops_before_manifest_lookup_or_execution() { + assert_stops_before_lookup( + full_jit(), + IdentityRejection::NotFullAot(Some(SnapshotKind::FullJit)), + ); +} + +#[test] +fn a_scanned_hash_stops_before_manifest_lookup_or_execution() { + assert_stops_before_lookup( + scanned(), + IdentityRejection::HashNotHeaderDerived(HashSource::Scan), + ); +} + +#[test] +fn a_snapshot_with_no_recoverable_hash_stops_before_manifest_lookup_or_execution() { + assert_stops_before_lookup( + SnapshotIdentity::without_header(TargetArch::Arm64, None), + IdentityRejection::HashNotHeaderDerived(HashSource::Unavailable), + ); +} + +#[test] +fn an_unsupported_target_stops_before_manifest_lookup_or_execution() { + assert_stops_before_lookup( + unsupported_target(), + IdentityRejection::UnsupportedTarget("x64".to_string()), + ); +} + +/// The control. Same rigging, valid manifest, exact identity: selection has to +/// reach the executable and spawn it, or the tests above would pass against a +/// repo that simply never works. +#[test] +fn a_full_aot_snapshot_reaches_selection_and_execution() { + let repo = valid_manifest_repo(); + let bundle = bundle(full_aot()); + + let err = load_model(&repo.root, &bundle, AdapterBackend::Auto) + .expect_err("the spy adapter cannot produce a model"); + + assert!( + repo.spawned(), + "an exact identity did not reach adapter execution: {err:#}" + ); + assert!( + err.chain() + .all(|cause| cause.downcast_ref::().is_none()), + "an exact identity was refused by the gate: {err:#}" + ); +} + +/// A rejected identity has no way to become a run with a lesser trust label. +/// The gate is the only exit, so `Untrusted` never reaches a producer record. +#[test] +fn a_rejected_identity_is_not_downgraded_to_an_untrusted_run() { + let repo = valid_manifest_repo(); + let bundle = bundle(full_jit()); + + let err = load_model(&repo.root, &bundle, AdapterBackend::Auto) + .expect_err("a FullJIT snapshot cannot load a model"); + + assert_eq!( + rejection(&err), + IdentityRejection::NotFullAot(Some(SnapshotKind::FullJit)) + ); + assert!( + !repo.spawned(), + "a rejected identity ran an adapter as an untrusted producer" + ); +} + +/// The library boundary states the same rule for itself: a caller that skipped +/// the core pipeline still cannot spawn an adapter for a rejected identity. +#[test] +fn run_adapter_refuses_a_rejected_identity_before_spawn() { + let repo = valid_manifest_repo(); + let bundle = bundle(full_jit()); + let exec = repo.root.join(format!("adapters/installed/dart_adapter_{}", HASH)); + + let err = run_adapter( + &exec, + &AdapterInput { + identity: &bundle.identity, + producer: Producer { + id: "flutterdec-local-python".to_string(), + version: "unknown".to_string(), + artifact_sha256: Sha256Digest::of(b"spy"), + trust: ProducerTrust::Untrusted, + }, + compatibility: CompatibilityBinding { + record_sha256: Sha256Digest::of(b"gate test record"), + parser_family_id: "flutterdec-local-python".to_string(), + profile_id: "unresolved".to_string(), + profile_sha256: Sha256Digest::of(b"gate test profile"), + }, + regions: vec![ + AdapterRegionInput { + region: InputRegionName::VmData, + bytes: &bundle.vm_data, + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::IsolateData, + bytes: &bundle.isolate_data, + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::VmInstructions, + bytes: &bundle.vm_instr, + virtual_address: Some(bundle.vm_instr_va), + }, + AdapterRegionInput { + region: InputRegionName::IsolateInstructions, + bytes: &bundle.isolate_instr, + virtual_address: Some(bundle.isolate_instr_va), + }, + ], + input_path: None, + libapp_path: None, + requested_backend: RequestedBackend::Auto, + }, + ) + .expect_err("run_adapter cannot run a rejected identity"); + + assert_eq!( + rejection(&err), + IdentityRejection::NotFullAot(Some(SnapshotKind::FullJit)) + ); + assert!( + !repo.spawned(), + "run_adapter spawned an adapter for a rejected identity" + ); +} diff --git a/crates/flutterdec-core/src/pipeline/runners.rs b/crates/flutterdec-core/src/pipeline/runners.rs index f9888540..3388b128 100644 --- a/crates/flutterdec-core/src/pipeline/runners.rs +++ b/crates/flutterdec-core/src/pipeline/runners.rs @@ -23,21 +23,20 @@ struct SharedStubNamingSummary { #[path = "runners/manifest.rs"] mod runners_manifest; use runners_manifest::{ - enrich_model_with_manifest_bootflow_hints, inspect_android_manifest, + collect_manifest_bootflow_hints, inspect_android_manifest, inspect_android_manifest_from_apk_session, AndroidManifestSignals, }; #[path = "runners/symbols.rs"] mod runners_symbols; use runners_symbols::{ - build_class_library_lookup, build_pool_semantic_hints, build_pool_target_symbols, + build_pool_semantic_hints, build_pool_target_symbols, build_pool_value_hints, canonical_standard_model_name, collect_pool_metadata_stats, - collect_symbol_quality_counts, infer_symbol_name_quality, merge_symbol_name, - symbol_name_quality_from_name_kind, SymbolMergeStats, SymbolNameQuality, + collect_symbol_quality_counts, merge_symbol_name, + symbol_name_quality_from_provenance, SymbolMergeStats, SymbolNameQuality, SymbolQualityCounts, }; #[cfg(test)] use runners_symbols::{is_generic_symbol_name, normalize_external_symbol_name}; -use std::collections::HashSet; use tempfile::NamedTempFile; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -75,39 +74,38 @@ impl FunctionScopeStats { } } +/// How the model's function names were recovered, counted. +/// +/// `unnamed` replaces v3's `placeholder`: a function with no name is now a +/// distinct, countable state instead of one carrying `sub_1234` and a +/// `name_kind` claiming that was a placeholder. #[derive(Debug, Default, Clone, Copy)] -struct FunctionNameKindStats { +struct FunctionNameProvenanceStats { exact: usize, - external: usize, + derived: usize, heuristic: usize, - placeholder: usize, - unknown: usize, - unspecified: usize, + unnamed: usize, } -impl FunctionNameKindStats { - fn tagged(self) -> usize { - self.exact + self.external + self.heuristic + self.placeholder + self.unknown +impl FunctionNameProvenanceStats { + fn named(self) -> usize { + self.exact + self.derived + self.heuristic } } -fn collect_function_name_kind_stats(functions: &[flutterdec_adapter::FunctionInfo]) -> FunctionNameKindStats { - let mut stats = FunctionNameKindStats::default(); +fn collect_function_name_provenance_stats( + functions: &[flutterdec_adapter::model::Function], +) -> FunctionNameProvenanceStats { + let mut stats = FunctionNameProvenanceStats::default(); for f in functions { - let Some(raw) = f.name_kind.as_deref().map(str::trim) else { - stats.unspecified += 1; + let Some(name) = f.name.as_ref() else { + stats.unnamed += 1; continue; }; - if raw.is_empty() { - stats.unspecified += 1; - continue; - } - match symbol_name_quality_from_name_kind(Some(raw)) { - Some(SymbolNameQuality::Exact) => stats.exact += 1, - Some(SymbolNameQuality::External) => stats.external += 1, - Some(SymbolNameQuality::Heuristic) => stats.heuristic += 1, - Some(SymbolNameQuality::Placeholder) => stats.placeholder += 1, - None => stats.unknown += 1, + match name.provenance { + flutterdec_adapter::model::Provenance::Exact => stats.exact += 1, + flutterdec_adapter::model::Provenance::Derived => stats.derived += 1, + flutterdec_adapter::model::Provenance::Heuristic => stats.heuristic += 1, } } stats @@ -140,20 +138,6 @@ struct EngineSymbolIngestion { error: Option, } -fn resolved_backend_from_adapter_kind(adapter_kind: &str) -> Option { - let lowered = adapter_kind.trim().to_ascii_lowercase(); - if lowered.contains("r2flutter") { - return Some(AdapterBackend::R2Flutter); - } - if lowered.contains("blutter") { - return Some(AdapterBackend::Blutter); - } - if lowered.contains("snapshot") || lowered.contains("internal") || lowered.contains("dynamic") { - return Some(AdapterBackend::Internal); - } - None -} - fn backend_label(value: Option) -> &'static str { match value { Some(backend) => backend.as_str(), @@ -167,7 +151,6 @@ fn format_quality_gate_failure_message( report_path: &Path, input_path: &Path, resolved_backend: Option, - loaded_adapter_kind: &str, symbol_quality_counts: &SymbolQualityCounts, ) -> String { let mut out = String::new(); @@ -194,10 +177,10 @@ fn format_quality_gate_failure_message( notes.push("input is not an APK, so manifest/startup evidence is unavailable".to_string()); } if resolved_backend == Some(AdapterBackend::Internal) { - notes.push("resolved backend is internal".to_string()); - } - if loaded_adapter_kind == "dynamic_snapshot_string_model_v1" { - notes.push("adapter kind is dynamic_snapshot_string_model_v1".to_string()); + notes.push( + "resolved backend is internal: no exact names and no ObjectPool index space" + .to_string(), + ); } if symbol_quality_counts.placeholder > 0 && symbol_quality_counts.exact == 0 @@ -458,10 +441,10 @@ fn classify_library_uri(uri: &str) -> ScopedFunctionKind { } fn function_kind_from_model( - f: &flutterdec_adapter::FunctionInfo, - class_to_library: &HashMap, + model: &ProgramModel, + f: &flutterdec_adapter::model::Function, ) -> ScopedFunctionKind { - let Some(uri) = class_to_library.get(&f.owner_class) else { + let Some(uri) = model.owner_library_uri(f) else { return ScopedFunctionKind::Unknown; }; classify_library_uri(uri) @@ -584,16 +567,9 @@ fn build_startup_manifest_context(signals: &AndroidManifestSignals) -> StartupMa } fn collect_app_package_counts(model: &ProgramModel) -> Vec<(String, usize)> { - let mut class_to_library = HashMap::new(); - for c in &model.classes { - class_to_library - .entry(c.name.clone()) - .or_insert_with(|| c.library_uri.clone()); - } - let mut counts: HashMap = HashMap::new(); for f in &model.functions { - let Some(uri) = class_to_library.get(&f.owner_class) else { + let Some(uri) = model.owner_library_uri(f) else { continue; }; if classify_library_uri(uri) != ScopedFunctionKind::App { @@ -656,7 +632,7 @@ fn collect_selected_priority_package_counts( ) -> Vec<(String, usize)> { let mut counts: HashMap = HashMap::new(); for item in selected { - let key = priority_package_from_library_uri(&item.library_uri); + let key = priority_package_from_library_uri(item.library_uri.as_deref().unwrap_or("")); *counts.entry(key).or_insert(0) += 1; } let mut out = counts.into_iter().collect::>(); @@ -679,7 +655,7 @@ fn collect_selected_priority_scope_mix( ) -> SelectedPriorityScopeMix { let mut mix = SelectedPriorityScopeMix::default(); for item in selected { - match classify_library_uri(&item.library_uri) { + match classify_library_uri(item.library_uri.as_deref().unwrap_or("")) { ScopedFunctionKind::App => mix.app += 1, ScopedFunctionKind::Framework => mix.framework += 1, ScopedFunctionKind::Stdlib => mix.stdlib += 1, @@ -701,10 +677,13 @@ fn collect_selected_preferred_package_stats( ) -> SelectedPreferredPackageStats { let mut out = SelectedPreferredPackageStats::default(); for item in selected { - if classify_library_uri(&item.library_uri) != ScopedFunctionKind::App { + let Some(uri) = item.library_uri.as_deref() else { + continue; + }; + if classify_library_uri(uri) != ScopedFunctionKind::App { continue; } - let Some(pkg) = package_name_from_library_uri(&item.library_uri) else { + let Some(pkg) = package_name_from_library_uri(uri) else { continue; }; if preferred_packages.contains(&pkg) { @@ -760,13 +739,14 @@ struct SelectedBootflowStats { #[derive(Debug, Clone)] struct SelectedBootflowHit { category: String, - decoded_kind: String, + hint_kind: String, source: String, + provenance: String, selector: String, target_va: u64, - function_name: String, - owner_class: String, - library_uri: String, + function_name: Option, + owner_class: Option, + library_uri: Option, total_score: i32, } @@ -804,8 +784,9 @@ fn collect_selected_bootflow_category_hits( } hits.push(SelectedBootflowHit { category: category.to_string(), - decoded_kind: entry.decoded_kind.clone(), + hint_kind: entry.kind.clone(), source: entry.source.clone(), + provenance: entry.provenance.clone(), selector: entry.selector.clone(), target_va, function_name: selected.function_name.clone(), @@ -942,21 +923,15 @@ fn apply_function_scope_filter( scope: FunctionScope, app_packages: &[String], ) -> (ProgramModel, FunctionScopeStats) { - let mut class_to_library = HashMap::new(); - for c in &model.classes { - class_to_library - .entry(c.name.clone()) - .or_insert_with(|| c.library_uri.clone()); - } let package_filters = normalize_package_filters(app_packages); let mut stats = FunctionScopeStats::from_total(model.functions.len()); let mut filtered_functions = Vec::new(); for f in &model.functions { - let kind = function_kind_from_model(f, &class_to_library); - let package_name = class_to_library - .get(&f.owner_class) - .and_then(|uri| package_name_from_library_uri(uri)); + let kind = function_kind_from_model(model, f); + let package_name = model + .owner_library_uri(f) + .and_then(package_name_from_library_uri); match kind { ScopedFunctionKind::App => stats.app += 1, ScopedFunctionKind::Framework => stats.framework += 1, @@ -989,7 +964,12 @@ pub fn run_info( ) -> Result { let apk_session = open_apk_session_if_input_is_apk(input_path)?; let bundle = load_snapshot_bundle_with_optional_apk_session(input_path, apk_session.as_ref())?; - let adapter_installed = resolve_adapter_exec(repo_root, &bundle.snapshot_hash).is_ok(); + // `info` reports rather than fails, but it still may not look an adapter up + // for a snapshot that could never authorize one: the filesystem probe is + // downstream of the gate, not a way around it. + let identity_rejection = bundle.identity.exact_selection_key().err(); + let adapter_installed = identity_rejection.is_none() + && resolve_adapter_exec(repo_root, &bundle.snapshot_hash).is_ok(); let manifest_inspection = if let Some(apk) = apk_session.as_ref() { inspect_android_manifest_from_apk_session(apk) } else { @@ -1023,9 +1003,16 @@ pub fn run_info( compressed_pointers: bundle.compressed_pointers, snapshot_features: bundle.snapshot_features.clone(), adapter_installed, - adapter_kind: None, + requested_backend: Some(adapter_backend.as_str().to_string()), + resolved_backend: None, + backend_fallback_reason: None, + producer_id: None, + producer_trust: None, + compatibility_record_sha256: None, manifest_entry_present: None, - adapter_snapshot_hash_match: None, + snapshot_identity_is_exact: Some(bundle.identity.is_exact()), + identity_rejection: identity_rejection.as_ref().map(ToString::to_string), + model_capabilities: None, compatibility_warnings: None, function_count: None, class_count: None, @@ -1042,24 +1029,36 @@ pub fn run_info( if let Ok(loaded) = load_model(repo_root, &bundle, adapter_backend) { let manifest_entry_present = loaded.manifest_entry_adapter.is_some(); let model = loaded.model; - let snapshot_hash_match = bundle.snapshot_hash == model.snapshot_hash; - let resolved_backend = resolved_backend_from_adapter_kind(&model.adapter_kind); + // The model was validated against the host identity before it got + // here, so it describes this snapshot by construction. What is worth + // reporting is whether that identity was header-derived at all. + let identity_is_exact = bundle.identity.is_exact(); + let resolved_backend = backend_from_id(loaded.resolved_backend); let backend_mismatch = match adapter_backend { AdapterBackend::Auto => false, - _ => resolved_backend.is_some_and(|value| value != adapter_backend), + _ => resolved_backend != adapter_backend, }; let warnings = collect_compatibility_warnings( manifest_entry_present, - snapshot_hash_match, + identity_is_exact, backend_mismatch, ); - out.adapter_kind = Some(model.adapter_kind.clone()); + out.requested_backend = Some(adapter_backend.as_str().to_string()); + out.resolved_backend = Some(resolved_backend.as_str().to_string()); + out.backend_fallback_reason = loaded + .fallback_reason + .map(|reason| reason.as_str().to_string()); + out.producer_id = Some(loaded.producer.id.clone()); + out.producer_trust = Some(producer_trust_label(loaded.producer.trust).to_string()); + out.compatibility_record_sha256 = + Some(loaded.compatibility.record_sha256.to_string()); out.manifest_entry_present = Some(manifest_entry_present); - out.adapter_snapshot_hash_match = Some(snapshot_hash_match); + out.snapshot_identity_is_exact = Some(identity_is_exact); out.compatibility_warnings = Some(warnings); + out.model_capabilities = Some(capability_map(&model.capabilities)); out.function_count = Some(model.functions.len()); out.class_count = Some(model.classes.len()); - out.object_pool_count = Some(model.object_pool.len()); + out.object_pool_count = Some(model.object_pool.entries.len()); let app_package_counts = collect_app_package_counts(&model); out.app_package_count_total = Some(app_package_counts.len()); out.app_package_counts_top = Some( @@ -1075,35 +1074,20 @@ pub fn run_info( Ok(out) } -fn enforce_snapshot_hash_match( - require_match: bool, - context_label: &str, - bundle_snapshot_hash: &str, - adapter_snapshot_hash: &str, -) -> Result { - let matches = bundle_snapshot_hash == adapter_snapshot_hash; - if require_match && !matches { - bail!( - "{} snapshot hash mismatch: bundle={} adapter={}", - context_label, - bundle_snapshot_hash, - adapter_snapshot_hash - ); - } - Ok(matches) -} - fn collect_compatibility_warnings( manifest_entry_present: bool, - snapshot_hash_match: bool, + identity_is_exact: bool, backend_mismatch: bool, ) -> Vec { let mut warnings = Vec::new(); if !manifest_entry_present { warnings.push("adapter manifest entry missing for this snapshot hash".to_string()); } - if !snapshot_hash_match { - warnings.push("adapter snapshot hash differs from loader snapshot hash".to_string()); + if !identity_is_exact { + warnings.push( + "snapshot identity is not header-derived, so no exact parser could be authorized" + .to_string(), + ); } if backend_mismatch { warnings.push("resolved adapter backend differs from requested backend".to_string()); @@ -1111,6 +1095,27 @@ fn collect_compatibility_warnings( warnings } +fn producer_trust_label(trust: ProducerTrust) -> &'static str { + match trust { + ProducerTrust::Registered => "registered", + ProducerTrust::Local => "local", + ProducerTrust::Untrusted => "untrusted", + } +} + +/// The model's per-domain capability levels, for reports. +fn capability_map(caps: &Capabilities) -> BTreeMap { + Domain::ALL + .iter() + .map(|domain| { + ( + domain.as_str().to_string(), + caps.level(*domain).as_str().to_string(), + ) + }) + .collect() +} + #[derive(Debug, Clone, Copy, Default)] struct TargetSelectionStats { enabled: bool, @@ -1118,11 +1123,15 @@ struct TargetSelectionStats { matched_count: usize, } -fn function_matches_target(func: &flutterdec_adapter::FunctionInfo, target: FunctionTarget) -> bool { +fn function_matches_target( + func: &flutterdec_adapter::model::Function, + target: FunctionTarget, +) -> bool { + let id = u64::from(func.id.0); match target { - FunctionTarget::FunctionId(id) => func.id == id, - FunctionTarget::EntryVa(entry_va) => func.entry_va == entry_va, - FunctionTarget::Any(value) => func.id == value || func.entry_va == value, + FunctionTarget::FunctionId(want) => id == want, + FunctionTarget::EntryVa(entry_va) => func.code.start_va == entry_va, + FunctionTarget::Any(value) => id == value || func.code.start_va == value, } } @@ -1167,7 +1176,14 @@ fn apply_target_function_filter( let preview = selected_functions .iter() .take(8) - .map(|func| format!("id={} va=0x{:x} {}", func.id, func.entry_va, func.name)) + .map(|func| { + format!( + "id={} va=0x{:x} {}", + func.id, + func.code.start_va, + func.name_text().unwrap_or("") + ) + }) .collect::>(); bail!( "target {} is ambiguous and matched {} functions: {}. use id: or va:0x", @@ -1204,20 +1220,29 @@ pub fn run_decompile( let adapter_exec_path = loaded_model.adapter_exec.display().to_string(); let manifest_entry_version = loaded_model.manifest_entry_version.clone(); let manifest_entry_adapter = loaded_model.manifest_entry_adapter.clone(); - let loaded_adapter_snapshot_hash = loaded_model.model.snapshot_hash.clone(); - let loaded_adapter_kind = loaded_model.model.adapter_kind.clone(); let requested_backend = opt.adapter_backend; - let resolved_backend = resolved_backend_from_adapter_kind(&loaded_adapter_kind); + // Four distinct typed facts, none of them read out of a name: what the host + // asked for, what answered, why it differed, and who produced the model. + let resolved_backend = backend_from_id(loaded_model.resolved_backend); + let backend_fallback_reason = loaded_model.fallback_reason; + let producer = loaded_model.producer.clone(); + let compatibility = loaded_model.compatibility.clone(); let backend_mismatch = match requested_backend { AdapterBackend::Auto => false, - _ => resolved_backend.is_some_and(|value| value != requested_backend), + _ => resolved_backend != requested_backend, }; - let snapshot_hash_match = enforce_snapshot_hash_match( - opt.require_snapshot_hash_match, - "decompile input", - &bundle.snapshot_hash, - &loaded_adapter_snapshot_hash, - )?; + let snapshot_identity_is_exact = bundle.identity.is_exact(); + if opt.require_snapshot_hash_match && !snapshot_identity_is_exact { + bail!( + "--require-snapshot-hash-match: decompile input identity is not header-derived: {}", + bundle + .identity + .exact_selection_key() + .err() + .map(|rejection| rejection.to_string()) + .unwrap_or_default() + ); + } let engine_context = try_collect_engine_fingerprint_with_apk_session(input_path, apk_session.as_ref(), &bundle.arch); let mut engine_symbol_ingestion = @@ -1242,15 +1267,21 @@ pub fn run_decompile( } else { AndroidStartupEvidence::default() }; - let (model, manifest_synthetic_hints) = if manifest_inspection.present { - enrich_model_with_manifest_bootflow_hints(&loaded_model.model, &manifest_inspection.signals) + // Enrichment produces hints. The model is not rewritten, so the + // authoritative library/class/function/pool records and the pool index space + // are exactly what the adapter authored, before and after this point. + let model = loaded_model.model; + let mut hints = ProgramHints::new(); + let model_name_hints = collect_model_name_hints(&model, &mut hints); + let manifest_hint_count = if manifest_inspection.present { + collect_manifest_bootflow_hints(&model, &manifest_inspection.signals, &mut hints) } else { - (loaded_model.model, 0) + 0 }; - let (model, startup_synthetic_hints) = if opt.engine_options.apk_startup_analysis { - enrich_model_with_apk_startup_bootflow_hints(&model, &startup_evidence) + let startup_hint_count = if opt.engine_options.apk_startup_analysis { + collect_apk_startup_bootflow_hints(&model, &startup_evidence, &mut hints) } else { - (model, 0) + 0 }; let app_package_counts = collect_app_package_counts(&model); let app_package_counts_top = app_package_counts @@ -1274,8 +1305,17 @@ pub fn run_decompile( (scoped_model.clone(), TargetSelectionStats::default()) }; - if selected_model.arch != "arm64" { - bail!("model arch {} unsupported in v1", selected_model.arch); + // Target architecture is a header fact the loader owns, and the model was + // already checked against it. v3 read `arch` off the adapter's own output, + // which meant an adapter could declare itself arm64. + if !matches!( + bundle.identity.target_arch, + flutterdec_loader::identity::TargetArch::Arm64 + ) { + bail!( + "target architecture {} unsupported in v1", + bundle.identity.target_arch + ); } if selected_model.functions.is_empty() { let app_package_note = if normalized_app_packages.is_empty() { @@ -1304,6 +1344,7 @@ pub fn run_decompile( let (disasm, selected_priorities) = disassemble_program_with_priorities_and_package_hints( &selected_model, + &hints, &bundle.isolate_instr, bundle.isolate_instr_va, if target_selection_stats.enabled { @@ -1333,46 +1374,23 @@ pub fn run_decompile( let mut symbol_quality: HashMap = HashMap::new(); let mut symbol_merge_stats = SymbolMergeStats::default(); let mut standard_model_symbol_count = 0usize; - let class_to_library = if opt.engine_options.canonical_model_symbols - || opt.engine_options.pool_semantic_hints - { - build_class_library_lookup(&selected_model) - } else { - HashMap::new() - }; // `pool[N]` in the disassembly is a real ObjectPool entry index only when the - // adapter recovered the pool layout. Without geometry the adapter's own indices - // are in some private space (string ordinals, for instance), so joining the two - // would attach arbitrary values to unrelated slots. Refuse rather than invent. - let pool_index_space_authoritative = model.pool_geometry.is_some(); - let pool_value_hints = if pool_index_space_authoritative - && (opt.engine_options.pool_value_hints || opt.engine_options.pool_semantic_hints) - { - build_pool_value_hints(&model) - } else { - HashMap::new() - }; + // model claims a hardware index space. An ordinal pool's indexes are + // positions in the producer's own list, so joining the two would attach + // arbitrary values to unrelated slots. The pool-reading helpers enforce this + // themselves; the flag is kept for the report. let pool_metadata = collect_pool_metadata_stats(&model); - let function_name_kind_stats = collect_function_name_kind_stats(&model.functions); - let pool_confidence_count = model - .object_pool - .iter() - .filter(|e| e.confidence.is_some()) - .count(); - let pool_source_count = model - .object_pool - .iter() - .filter(|e| { - e.source - .as_deref() - .map(str::trim) - .is_some_and(|v| !v.is_empty()) - }) - .count(); - let pool_semantic_hints = if pool_index_space_authoritative - && opt.engine_options.pool_semantic_hints - { - build_pool_semantic_hints(&model, &class_to_library) + let pool_index_space_authoritative = pool_metadata.addressable; + let pool_value_hints = + if opt.engine_options.pool_value_hints || opt.engine_options.pool_semantic_hints { + build_pool_value_hints(&model) + } else { + HashMap::new() + }; + let function_name_provenance_stats = + collect_function_name_provenance_stats(&model.functions); + let pool_semantic_hints = if opt.engine_options.pool_semantic_hints { + build_pool_semantic_hints(&model, &hints) } else { HashMap::new() }; @@ -1385,41 +1403,43 @@ pub fn run_decompile( }; for f in &selected_model.functions { - let resolved = if opt.engine_options.canonical_model_symbols { - let resolved = canonical_standard_model_name(f, &class_to_library) - .unwrap_or_else(|| f.name.clone()); - if resolved != f.name { - standard_model_symbol_count += 1; - } - resolved - } else { - f.name.clone() + // A function with no recovered name contributes no symbol. It gets an + // address-derived label at emit time instead, so nothing downstream can + // mistake `fn_0x1000` for something the snapshot said. + let Some(model_name) = f.name_text() else { + symbol_quality.insert(f.code.start_va, SymbolNameQuality::Placeholder); + continue; }; - let resolved_quality = if resolved != f.name { - SymbolNameQuality::Heuristic - } else { - symbol_name_quality_from_name_kind(f.name_kind.as_deref()).unwrap_or_else(|| { - if infer_symbol_name_quality(&resolved) == SymbolNameQuality::Placeholder { - SymbolNameQuality::Placeholder - } else { - SymbolNameQuality::Heuristic + let model_quality = f + .name + .as_ref() + .map(|name| symbol_name_quality_from_provenance(name.provenance)) + .unwrap_or(SymbolNameQuality::Placeholder); + let (resolved, resolved_quality) = if opt.engine_options.canonical_model_symbols { + match canonical_standard_model_name(&selected_model, f) { + Some(canonical) if canonical != model_name => { + standard_model_symbol_count += 1; + (canonical, SymbolNameQuality::Heuristic) } - }) + _ => (model_name.to_string(), model_quality), + } + } else { + (model_name.to_string(), model_quality) }; - symbol_names.insert(f.entry_va, resolved); - symbol_quality.insert(f.entry_va, resolved_quality); + symbol_names.insert(f.code.start_va, resolved); + symbol_quality.insert(f.code.start_va, resolved_quality); } for f in &disasm { - symbol_names + let Some(name) = f.function_name.clone() else { + symbol_quality + .entry(f.entry_va) + .or_insert(SymbolNameQuality::Placeholder); + continue; + }; + symbol_names.entry(f.entry_va).or_insert(name); + symbol_quality .entry(f.entry_va) - .or_insert_with(|| f.function_name.clone()); - symbol_quality.entry(f.entry_va).or_insert_with(|| { - if infer_symbol_name_quality(&f.function_name) == SymbolNameQuality::Placeholder { - SymbolNameQuality::Placeholder - } else { - SymbolNameQuality::Heuristic - } - }); + .or_insert(SymbolNameQuality::Heuristic); } for (va, name) in &pool_target_symbols { merge_symbol_name( @@ -1556,7 +1576,7 @@ pub fn run_decompile( let filename = format!( "{:05}_{}.s", f.function_id, - normalize_file_name(&f.function_name) + normalize_file_name(&f.display_name()) ); fs::write(asm_dir.join(filename), terminated(&lines.join("\n")))?; } @@ -1703,7 +1723,7 @@ pub fn run_decompile( }) }) .collect::>(); - let bootflow_discovery = collect_bootflow_discovery(&model); + let bootflow_discovery = collect_bootflow_discovery(&hints); let (selected_bootflow_stats, selected_bootflow_hits) = collect_selected_bootflow_hits(&prioritization_selected, &bootflow_discovery); let selected_bootflow_hits_top = selected_bootflow_hits @@ -1712,7 +1732,8 @@ pub fn run_decompile( .map(|hit| { json!({ "category": hit.category, - "decoded_kind": hit.decoded_kind, + "hint_kind": hit.hint_kind, + "provenance": hit.provenance, "source": hit.source, "selector": hit.selector, "target_va": hit.target_va, @@ -1760,13 +1781,14 @@ pub fn run_decompile( .iter() .map(|entry| { json!({ - "decoded_kind": entry.decoded_kind, + "kind": entry.kind, "source": entry.source, + "provenance": entry.provenance, "selector": entry.selector, "target_va": entry.target_va, "owner_class": entry.owner_class, "library_uri": entry.library_uri, - "value": entry.value + "detail": entry.detail }) }) .collect::>(); @@ -1775,13 +1797,14 @@ pub fn run_decompile( .iter() .map(|entry| { json!({ - "decoded_kind": entry.decoded_kind, + "kind": entry.kind, + "provenance": entry.provenance, "source": entry.source, "selector": entry.selector, "target_va": entry.target_va, "owner_class": entry.owner_class, "library_uri": entry.library_uri, - "value": entry.value + "detail": entry.detail }) }) .collect::>(); @@ -1790,13 +1813,14 @@ pub fn run_decompile( .iter() .map(|entry| { json!({ - "decoded_kind": entry.decoded_kind, + "kind": entry.kind, + "provenance": entry.provenance, "source": entry.source, "selector": entry.selector, "target_va": entry.target_va, "owner_class": entry.owner_class, "library_uri": entry.library_uri, - "value": entry.value + "detail": entry.detail }) }) .collect::>(); @@ -1805,13 +1829,14 @@ pub fn run_decompile( .iter() .map(|entry| { json!({ - "decoded_kind": entry.decoded_kind, + "kind": entry.kind, + "provenance": entry.provenance, "source": entry.source, "selector": entry.selector, "target_va": entry.target_va, "owner_class": entry.owner_class, "library_uri": entry.library_uri, - "value": entry.value + "detail": entry.detail }) }) .collect::>(); @@ -1820,13 +1845,14 @@ pub fn run_decompile( .iter() .map(|entry| { json!({ - "decoded_kind": entry.decoded_kind, + "kind": entry.kind, + "provenance": entry.provenance, "source": entry.source, "selector": entry.selector, "target_va": entry.target_va, "owner_class": entry.owner_class, "library_uri": entry.library_uri, - "value": entry.value + "detail": entry.detail }) }) .collect::>(); @@ -1836,8 +1862,11 @@ pub fn run_decompile( fs::write(&quality_path, serde_json::to_vec_pretty(&report)?)?; let bundle_snapshot_hash = bundle.snapshot_hash.clone(); let manifest_entry_present = manifest_entry_adapter.is_some(); - let compatibility_warnings = - collect_compatibility_warnings(manifest_entry_present, snapshot_hash_match, backend_mismatch); + let compatibility_warnings = collect_compatibility_warnings( + manifest_entry_present, + snapshot_identity_is_exact, + backend_mismatch, + ); let compatibility_status = if compatibility_warnings.is_empty() { "ok" } else { @@ -1861,36 +1890,42 @@ pub fn run_decompile( "profile": opt.analysis_profile.as_str(), "engine": &opt.engine_options }, - "adapter_kind": model.adapter_kind, + // Four separate typed facts. `resolved_backend` comes from the protocol + // result, never from a filename or a substring of adapter output. "adapter_selection": { "requested_backend": requested_backend.as_str(), - "resolved_backend": backend_label(resolved_backend), - "resolved_from_adapter_kind": loaded_adapter_kind, + "resolved_backend": backend_label(Some(resolved_backend)), + "fallback_reason": backend_fallback_reason.map(|reason| reason.as_str()), "backend_mismatch": backend_mismatch, "require_snapshot_hash_match": opt.require_snapshot_hash_match, "adapter_exec_path": adapter_exec_path, "manifest_entry_adapter": manifest_entry_adapter, "manifest_entry_version": manifest_entry_version, - "snapshot_hash": { - "bundle": bundle_snapshot_hash, - "adapter_model": loaded_adapter_snapshot_hash, - "match": snapshot_hash_match + "snapshot_identity": { + "hash": bundle_snapshot_hash, + "header_derived": snapshot_identity_is_exact } }, - "adapter_schema": { - "schema_version": model.schema_version, - "compatibility_mode": if model.schema_version == 2 { "v2_compat" } else { "native_v3" }, - "function_name_kind_count": function_name_kind_stats.tagged(), - "function_name_kind_breakdown": { - "exact": function_name_kind_stats.exact, - "external": function_name_kind_stats.external, - "heuristic": function_name_kind_stats.heuristic, - "placeholder": function_name_kind_stats.placeholder, - "unknown": function_name_kind_stats.unknown, - "unspecified": function_name_kind_stats.unspecified + "producer": { + "id": producer.id, + "version": producer.version, + "artifact_sha256": producer.artifact_sha256.to_string(), + "trust": producer_trust_label(producer.trust) + }, + "model": { + "model_version": model.model_version, + "name_pattern_hints": model_name_hints, + "capabilities": capability_map(&model.capabilities), + "diagnostics": model.diagnostics.len(), + "function_name_provenance": { + "named": function_name_provenance_stats.named(), + "exact": function_name_provenance_stats.exact, + "derived": function_name_provenance_stats.derived, + "heuristic": function_name_provenance_stats.heuristic, + "unnamed": function_name_provenance_stats.unnamed }, - "pool_confidence_count": pool_confidence_count, - "pool_source_count": pool_source_count + "pool_index_space_addressable": pool_metadata.addressable, + "pool_heuristic_entries": pool_metadata.heuristic }, "engine_fingerprint_context": { "detected": engine_context.detected, @@ -1908,18 +1943,32 @@ pub fn run_decompile( "error": engine_context.error }, "compatibility": { + // The binding lives in this object rather than a second + // `"compatibility"` key: a duplicate key is not an error in a JSON + // object literal, the later value simply wins, and these four + // fields were being dropped from the report. + "record_sha256": compatibility.record_sha256.to_string(), + "parser_family_id": compatibility.parser_family_id, + "profile_id": compatibility.profile_id, + "profile_sha256": compatibility.profile_sha256.to_string(), "status": compatibility_status, - "schema": { - "version": model.schema_version, - "supported_versions": [2, 3], - "supported": model.schema_version == 2 || model.schema_version == 3 + "model": { + "version": model.model_version, + "supported_versions": [flutterdec_adapter::model::MODEL_VERSION], + "supported": model.model_version == flutterdec_adapter::model::MODEL_VERSION }, - "snapshot_hash_match": snapshot_hash_match, + "snapshot_identity_is_exact": snapshot_identity_is_exact, "snapshot_hash_match_required": opt.require_snapshot_hash_match, "manifest_entry_present": manifest_entry_present, "warnings": compatibility_warnings }, - "dart_version": model.dart_version, + // The Dart version is a host fact resolved from the snapshot hash, not + // something the adapter reports: a semantic version is an alias of the + // hash, never a selector. + "dart_version": bundle + .dart_profile + .as_ref() + .map(|p| p.dart_version.clone()), "function_scope": { "selected": opt.function_scope.as_str(), "total_before_filter": function_scope_stats.total_before_filter, @@ -1959,7 +2008,7 @@ pub fn run_decompile( "deeplink_activities": manifest_inspection.signals.deeplink_activities, "deeplink_entry_count": manifest_inspection.signals.deeplink_entries.len(), "deeplink_entries": manifest_inspection.signals.deeplink_entries, - "synthetic_bootflow_hints": manifest_synthetic_hints + "bootflow_hints": manifest_hint_count }, "android_startup": { "enabled": opt.engine_options.apk_startup_analysis, @@ -1985,14 +2034,14 @@ pub fn run_decompile( "path_count": startup_evidence.bootstrap_chain.paths.len(), "paths": startup_evidence.bootstrap_chain.paths }, - "synthetic_bootflow_hints": startup_synthetic_hints + "bootflow_hints": startup_hint_count }, "counts": { "libraries": model.libraries.len(), "classes": model.classes.len(), "functions": selected_model.functions.len(), "functions_total": model.functions.len(), - "object_pool": model.object_pool.len(), + "object_pool": model.object_pool.entries.len(), "disassembled_functions": disasm.len() }, "quality": report, @@ -2079,10 +2128,10 @@ pub fn run_decompile( "total_entries": pool_metadata.total_entries, "with_target_va": pool_metadata.with_target_va, "with_selector": pool_metadata.with_selector, - "with_owner_class": pool_metadata.with_owner_class, - "with_library_uri": pool_metadata.with_library_uri, + "with_value": pool_metadata.with_value, + "heuristic_entries": pool_metadata.heuristic, "index_space_authoritative": pool_index_space_authoritative, - "geometry": model.pool_geometry.map(|g| serde_json::json!({ + "geometry": model.object_pool.geometry.map(|g| serde_json::json!({ "entries_offset": g.entries_offset, "word_size": g.word_size })), @@ -2090,8 +2139,8 @@ pub fn run_decompile( serde_json::Value::Null } else { serde_json::Value::String( - "adapter reported no pool_geometry; pool entry indices are not in the \ - hardware index space, so pool value/semantic hints were not applied" + "the model declares an ordinal pool index space, so pool indexes carry \ + no address meaning and pool value/semantic hints were not applied" .to_string(), ) } @@ -2142,7 +2191,7 @@ pub fn run_decompile( "noreturn_pruned_functions": noreturn_prune.functions, "noreturn_pruned_blocks": noreturn_prune.blocks_cut, "noreturn_pruned_instructions": noreturn_prune.instructions_cut, - "model": model.adapter_kind.clone(), + "resolved_backend": resolved_backend.as_str(), "snapshot_dart_version": bundle.dart_profile.as_ref().map(|p| p.dart_version.clone()), "compressed_pointers": bundle.compressed_pointers }, @@ -2210,8 +2259,7 @@ pub fn run_decompile( &quality_path, &report_path, input_path, - resolved_backend, - &loaded_adapter_kind, + Some(resolved_backend), &symbol_quality_counts, ) ); diff --git a/crates/flutterdec-core/src/pipeline/runners/manifest.rs b/crates/flutterdec-core/src/pipeline/runners/manifest.rs index bd062c9f..3e885824 100644 --- a/crates/flutterdec-core/src/pipeline/runners/manifest.rs +++ b/crates/flutterdec-core/src/pipeline/runners/manifest.rs @@ -1,10 +1,6 @@ -use super::{ - collect_existing_bootflow_hint_keys, is_activity_handler_selector, is_bootstrap_selector, - is_deeplink_selector, is_main_like_selector, is_runapp_selector, - library_is_bootstrap_context, normalize_method_selector, owner_is_bootstrap_context, - push_synthetic_hint, SyntheticHintInput, -}; -use flutterdec_adapter::ProgramModel; +use super::{hint_kinds_for_selector, push_hint, HintCandidate}; +use flutterdec_adapter::model::ProgramModel; +use flutterdec_disasm_arm64::{HintKind, HintOrigin, HintProvenance, ProgramHints}; use flutterdec_loader::ApkSession; use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::Path; @@ -996,20 +992,18 @@ fn inspect_manifest_bytes(bytes: &[u8]) -> AndroidManifestInspection { } } -pub(super) fn enrich_model_with_manifest_bootflow_hints( +/// Turn manifest evidence into hints about functions the adapter recovered. +/// +/// The model is read, never written. A manifest says which activity Android +/// launches; it does not say which Dart code range that is, so everything this +/// produces is a hint with `AndroidManifest` origin and derived provenance, and +/// none of it can create a class, a function, or a pool entry. +pub(super) fn collect_manifest_bootflow_hints( model: &ProgramModel, signals: &AndroidManifestSignals, -) -> (ProgramModel, usize) { - let mut enriched = model.clone(); + hints: &mut ProgramHints, +) -> usize { let mut inserted = 0usize; - let mut class_library = HashMap::new(); - for class in &enriched.classes { - class_library - .entry(class.name.clone()) - .or_insert_with(|| class.library_uri.clone()); - } - - let mut seen = collect_existing_bootflow_hint_keys(&enriched); let activity_set = signals .activities @@ -1018,121 +1012,42 @@ pub(super) fn enrich_model_with_manifest_bootflow_hints( .collect::>(); let has_deeplink_signal = signals.has_view_browsable || !signals.deeplink_entries.is_empty(); - let functions = enriched.functions.clone(); - for function in functions { - let selector = normalize_method_selector(&function.name); - if selector.is_empty() { + for function in &model.functions { + let Some(name) = function.name_text() else { continue; - } - let owner = function.owner_class.trim(); - let owner_lower = owner.to_ascii_lowercase(); - let library_uri = class_library - .get(&function.owner_class) - .cloned() - .unwrap_or_default(); - let library_lower = library_uri.to_ascii_lowercase(); - - if signals.has_main_launcher - && is_main_like_selector(&selector) - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "ManifestMainCandidate", - selector: &selector, - target_va: Some(function.entry_va), - owner_class: owner, - library_uri: &library_uri, - value: "manifest:main-launcher", - confidence: Some(0.95), - source: Some("manifest"), - }, - ) - { - inserted += 1; - } - if signals.has_main_launcher - && is_runapp_selector(&selector) - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "ManifestRunAppCandidate", - selector: &selector, - target_va: Some(function.entry_va), - owner_class: owner, - library_uri: &library_uri, - value: "manifest:runapp", - confidence: Some(0.95), - source: Some("manifest"), - }, - ) - { - inserted += 1; - } - if has_deeplink_signal - && is_deeplink_selector(&selector) - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "ManifestDeepLinkCandidate", - selector: &selector, - target_va: Some(function.entry_va), - owner_class: owner, - library_uri: &library_uri, - value: "manifest:deeplink", - confidence: Some(0.9), - source: Some("manifest"), - }, - ) - { + }; + let owner = model.owner_name(function); + let library_uri = model.owner_library_uri(function); + let candidate = HintCandidate { + origin: HintOrigin::AndroidManifest, + provenance: HintProvenance::Derived, + selector: name, + target_va: Some(function.code.start_va), + owner_class: owner, + library_uri, + detail: "manifest evidence matched a recovered function name", + }; + + for kind in hint_kinds_for_selector(name, owner, library_uri) { + // Each manifest signal only licenses the kinds it is evidence for. + let licensed = match kind { + HintKind::EntryPoint | HintKind::BootMain | HintKind::BootRunApp => { + signals.has_main_launcher + } + HintKind::DeepLinkHandler => has_deeplink_signal, + HintKind::ActivityHandler => { + has_deeplink_signal + && class_matches_manifest_activity(owner.unwrap_or(""), &activity_set) + } + HintKind::BootstrapInit => signals.has_main_launcher, + }; + if licensed && push_hint(hints, kind, &candidate) { inserted += 1; - } - if has_deeplink_signal - && class_matches_manifest_activity(owner, &activity_set) - && is_activity_handler_selector(&selector) - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "ManifestActivityCandidate", - selector: &selector, - target_va: Some(function.entry_va), - owner_class: owner, - library_uri: &library_uri, - value: "manifest:activity", - confidence: Some(0.9), - source: Some("manifest"), - }, - ) - { - inserted += 1; - } - if signals.has_main_launcher - && is_bootstrap_selector(&selector) - && (owner_is_bootstrap_context(&owner_lower) - || library_is_bootstrap_context(&library_lower)) - && push_synthetic_hint( - &mut enriched, - &mut seen, - &SyntheticHintInput { - decoded_kind: "ManifestBootstrapCandidate", - selector: &selector, - target_va: Some(function.entry_va), - owner_class: owner, - library_uri: &library_uri, - value: "manifest:bootstrap", - confidence: Some(0.9), - source: Some("manifest"), - }, - ) - { - inserted += 1; + } } } - (enriched, inserted) + inserted } #[cfg(test)] diff --git a/crates/flutterdec-core/src/pipeline/runners/reporting.rs b/crates/flutterdec-core/src/pipeline/runners/reporting.rs index 935bf9b7..b4005a7b 100644 --- a/crates/flutterdec-core/src/pipeline/runners/reporting.rs +++ b/crates/flutterdec-core/src/pipeline/runners/reporting.rs @@ -1,6 +1,6 @@ use flutterdec_decompiler::PseudocodeArtifact; use std::collections::{HashMap, HashSet}; -use flutterdec_adapter::ProgramModel; +use flutterdec_disasm_arm64::{HintKind, ProgramHints}; #[derive(Debug, Default, Clone, Copy)] pub(super) struct SemanticIntentSummary { @@ -36,13 +36,17 @@ pub(super) struct CallFallbackSummary { #[derive(Debug, Clone)] pub(super) struct BootflowDiscoveryEntry { - pub(super) decoded_kind: String, + /// The typed hint kind, not a free-text label a producer could pick. + pub(super) kind: String, + /// Which host artifact the hint came from. pub(super) source: String, + /// How well it is known. Always `derived` or `heuristic`, never exact. + pub(super) provenance: String, pub(super) selector: String, pub(super) target_va: Option, - pub(super) owner_class: String, - pub(super) library_uri: String, - pub(super) value: String, + pub(super) owner_class: Option, + pub(super) library_uri: Option, + pub(super) detail: String, } #[derive(Debug, Default, Clone)] @@ -175,91 +179,6 @@ pub(super) fn collect_call_fallback_summary(pseudo: &[PseudocodeArtifact]) -> Ca out } -fn is_main_like_selector(selector_lower: &str) -> bool { - selector_lower == "main" - || selector_lower.ends_with(".main") - || selector_lower.ends_with("::main") - || selector_lower.ends_with("_main") -} - -fn is_runapp_selector(selector_lower: &str) -> bool { - selector_lower == "runapp" || selector_lower.ends_with(".runapp") -} - -fn is_deeplink_selector(selector_lower: &str) -> bool { - matches!( - selector_lower, - "didpushrouteinformation" - | "didpushroute" - | "didpoproute" - | "setnewroutepath" - | "parserouteinformation" - | "ongenerateroute" - | "onunknownroute" - | "onnewintent" - | "handleintent" - ) -} - -fn is_activity_selector(selector_lower: &str) -> bool { - matches!( - selector_lower, - "onnewintent" | "handleintent" | "oncreate" | "onstart" | "onresume" | "onpause" | "onstop" - ) -} - -fn is_bootstrap_selector(selector_lower: &str) -> bool { - matches!( - selector_lower, - "ensureinitialized" - | "nativeensureinitialized" - | "startinitialization" - | "ensureinitializationcomplete" - ) -} - -struct BootflowEntrySeed<'a> { - decoded_kind: &'a str, - source: &'a str, - selector: &'a str, - target_va: Option, - owner_class: &'a str, - library_uri: &'a str, - value: &'a str, -} - -fn push_bootflow_entry( - out: &mut Vec, - seen: &mut HashSet, - category: &str, - seed: &BootflowEntrySeed<'_>, -) { - let key = format!( - "{}|{}|{}|{}|{}|{}", - category, - seed.target_va - .map(|va| format!("0x{va:x}")) - .unwrap_or_else(|| "none".to_string()), - seed.selector.to_ascii_lowercase(), - seed.source.to_ascii_lowercase(), - seed.owner_class.to_ascii_lowercase(), - seed.library_uri.to_ascii_lowercase() - ); - if seen.contains(&key) { - return; - } - seen.insert(key); - out.push(BootflowDiscoveryEntry { - decoded_kind: seed.decoded_kind.to_string(), - source: seed.source.to_string(), - selector: seed.selector.to_string(), - target_va: seed.target_va, - owner_class: seed.owner_class.to_string(), - library_uri: seed.library_uri.to_string(), - value: seed.value.to_string(), - }); -} - fn normalize_bootflow_entries(entries: &mut Vec) { entries.sort_by(|a, b| { a.target_va @@ -267,133 +186,50 @@ fn normalize_bootflow_entries(entries: &mut Vec) { .cmp(&b.target_va.unwrap_or(0)) .then_with(|| a.selector.cmp(&b.selector)) .then_with(|| a.source.cmp(&b.source)) - .then_with(|| a.decoded_kind.cmp(&b.decoded_kind)) + .then_with(|| a.kind.cmp(&b.kind)) }); entries.truncate(20); } -fn bootflow_entry_source( - entry: &flutterdec_adapter::ObjectPoolEntry, - decoded_kind_lower: &str, - value_lower: &str, -) -> &'static str { - match entry.source.as_deref().map(str::trim).unwrap_or("") { - "manifest" => "manifest", - "apk_startup" => "apk_startup", - "synthetic" | "internal" | "blutter" => "adapter", - explicit if !explicit.is_empty() => "adapter", - _ if decoded_kind_lower.starts_with("manifest") || value_lower.starts_with("manifest:") => { - "manifest" - } - _ if decoded_kind_lower.starts_with("startup") || value_lower.ends_with(":apk_startup") => { - "apk_startup" - } - _ => "adapter", - } -} - -pub(super) fn collect_bootflow_discovery(model: &ProgramModel) -> BootflowDiscoverySummary { +/// Report the boot-flow hints the host derived, grouped by category. +/// +/// Reads [`ProgramHints`] and nothing else. v3 read this back out of the model's +/// object pool, which meant the report could only describe what enrichment had +/// already written into the adapter's own records. +pub(super) fn collect_bootflow_discovery(hints: &ProgramHints) -> BootflowDiscoverySummary { let mut out = BootflowDiscoverySummary::default(); - let mut seen = HashSet::new(); - - for entry in &model.object_pool { - let decoded_kind = entry - .decoded_kind - .as_deref() - .map(str::trim) - .unwrap_or(""); - let decoded_kind_lower = decoded_kind.to_ascii_lowercase(); - let selector = entry.selector.as_deref().map(str::trim).unwrap_or(""); - let selector_lower = selector.to_ascii_lowercase(); - let value = entry.value.trim(); - let value_lower = value.to_ascii_lowercase(); - let owner_class = entry.owner_class.as_deref().map(str::trim).unwrap_or(""); - let library_uri = entry.library_uri.as_deref().map(str::trim).unwrap_or(""); - let source = bootflow_entry_source(entry, &decoded_kind_lower, &value_lower); - let seed = BootflowEntrySeed { - decoded_kind, - source, - selector, - target_va: entry.target_va, - owner_class, - library_uri, - value, + // Two hint kinds can share a category: `EntryPoint` and `BootMain` both mean + // "main". Reporting the same address twice under one category would inflate + // the discovery counts a reader uses to judge coverage. + let mut seen: HashSet<(String, Option, String, String)> = HashSet::new(); + for hint in hints.iter() { + let entry = BootflowDiscoveryEntry { + kind: hint.kind.as_str().to_string(), + source: hint.origin.as_str().to_string(), + provenance: hint.provenance.as_str().to_string(), + selector: hint.selector.clone(), + target_va: hint.target_va, + owner_class: hint.owner_class.clone(), + library_uri: hint.library_uri.clone(), + detail: hint.detail.clone(), }; - - if decoded_kind_lower == "bootmaincandidate" - || decoded_kind_lower == "startupmaincandidate" - || decoded_kind_lower == "manifestmaincandidate" - || value_lower.starts_with("bootflow:main:") - || value_lower.starts_with("manifest:main") - || (decoded_kind_lower == "entrypointcandidate" && is_main_like_selector(&selector_lower)) - { - push_bootflow_entry( - &mut out.main, - &mut seen, - "main", - &seed, - ); - } - - if decoded_kind_lower == "bootrunappcandidate" - || decoded_kind_lower == "startuprunappcandidate" - || decoded_kind_lower == "manifestrunappcandidate" - || value_lower.starts_with("bootflow:runapp:") - || value_lower.starts_with("manifest:runapp") - || (decoded_kind_lower == "entrypointcandidate" && is_runapp_selector(&selector_lower)) - { - push_bootflow_entry( - &mut out.runapp, - &mut seen, - "runapp", - &seed, - ); - } - - if decoded_kind_lower == "deeplinkhandlercandidate" - || decoded_kind_lower == "startupdeeplinkcandidate" - || decoded_kind_lower == "manifestdeeplinkcandidate" - || value_lower.starts_with("bootflow:deeplink:") - || value_lower.starts_with("manifest:deeplink") - || is_deeplink_selector(&selector_lower) - { - push_bootflow_entry( - &mut out.deeplink, - &mut seen, - "deeplink", - &seed, - ); - } - - if decoded_kind_lower == "activityhandlercandidate" - || decoded_kind_lower == "startupactivitycandidate" - || decoded_kind_lower == "manifestactivitycandidate" - || value_lower.starts_with("bootflow:activity:") - || value_lower.starts_with("manifest:activity") - || is_activity_selector(&selector_lower) - { - push_bootflow_entry( - &mut out.activity, - &mut seen, - "activity", - &seed, - ); - } - - if decoded_kind_lower == "bootstrapinitcandidate" - || decoded_kind_lower == "startupbootstrapcandidate" - || decoded_kind_lower == "manifestbootstrapcandidate" - || value_lower.starts_with("bootflow:init:") - || value_lower.starts_with("manifest:bootstrap") - || is_bootstrap_selector(&selector_lower) - { - push_bootflow_entry( - &mut out.bootstrap, - &mut seen, - "bootstrap", - &seed, - ); + let (category, bucket) = match hint.kind { + HintKind::EntryPoint | HintKind::BootMain => ("main", &mut out.main), + HintKind::BootRunApp => ("runapp", &mut out.runapp), + HintKind::DeepLinkHandler => ("deeplink", &mut out.deeplink), + HintKind::ActivityHandler => ("activity", &mut out.activity), + HintKind::BootstrapInit => ("bootstrap", &mut out.bootstrap), + }; + let key = ( + category.to_string(), + hint.target_va, + hint.selector.to_ascii_lowercase(), + entry.source.clone(), + ); + if !seen.insert(key) { + continue; } + bucket.push(entry); } normalize_bootflow_entries(&mut out.main); diff --git a/crates/flutterdec-core/src/pipeline/runners/split.rs b/crates/flutterdec-core/src/pipeline/runners/split.rs index 6c5d6082..487684b8 100644 --- a/crates/flutterdec-core/src/pipeline/runners/split.rs +++ b/crates/flutterdec-core/src/pipeline/runners/split.rs @@ -243,8 +243,10 @@ fn pieces( let va = first.va; out.push(FunctionDisassembly { function_id: *next_id, - function_name: format!("sub_{va:x}"), - owner_class: String::new(), + // A split tail is a code range nobody named. It stays unnamed; + // `sub_` here was the only reason the artifact looked named. + function_name: None, + owner_class: None, entry_va: va, size: byte_size(&tail), instructions: tail, @@ -282,8 +284,8 @@ mod tests { fn two_functions() -> FunctionDisassembly { FunctionDisassembly { function_id: 7, - function_name: "declaredName".to_string(), - owner_class: "SomeClass".to_string(), + function_name: Some("declaredName".to_string()), + owner_class: Some("SomeClass".to_string()), entry_va: 0x1000, size: 24, instructions: vec![ @@ -306,17 +308,21 @@ mod tests { assert_eq!(out[0].entry_va, 0x1000); assert_eq!(out[0].instructions.len(), 3); - assert_eq!(out[0].function_name, "declaredName", "the first piece keeps it"); + assert_eq!( + out[0].function_name.as_deref(), + Some("declaredName"), + "the first piece keeps it" + ); assert_eq!(out[0].size, 12, "size comes from the piece, not the record"); assert_eq!(out[1].entry_va, 0x100c); assert_eq!(out[1].instructions.len(), 3); assert_eq!( - out[1].function_name, "sub_100c", + out[1].function_name, None, "a piece the model never declared must not take the declared name" ); assert!( - out[1].owner_class.is_empty(), + out[1].owner_class.is_none(), "nor the declared owner class, which would be a wrong class for it" ); assert_ne!(out[0].function_id, out[1].function_id, "ids must stay unique"); @@ -341,8 +347,8 @@ mod tests { fn a_candidate_the_preceding_piece_reaches_is_refused() { let record = FunctionDisassembly { function_id: 7, - function_name: "declaredName".to_string(), - owner_class: String::new(), + function_name: Some("declaredName".to_string()), + owner_class: None, entry_va: 0x1000, size: 28, instructions: vec![ diff --git a/crates/flutterdec-core/src/pipeline/runners/stubs.rs b/crates/flutterdec-core/src/pipeline/runners/stubs.rs index 9a353922..230ab656 100644 --- a/crates/flutterdec-core/src/pipeline/runners/stubs.rs +++ b/crates/flutterdec-core/src/pipeline/runners/stubs.rs @@ -658,8 +658,8 @@ mod tests { instructions.push(ins(entry_va + 44, "ldr", &format!("x16, [x26, #{slot:#x}]"))); FunctionDisassembly { function_id: entry_va, - function_name: format!("sub_{entry_va:x}"), - owner_class: String::new(), + function_name: None, + owner_class: None, entry_va, size: 48, instructions, @@ -670,8 +670,8 @@ mod tests { fn ordinary(entry_va: u64) -> FunctionDisassembly { FunctionDisassembly { function_id: entry_va, - function_name: format!("sub_{entry_va:x}"), - owner_class: String::new(), + function_name: None, + owner_class: None, entry_va, size: 8, instructions: vec![ @@ -806,8 +806,8 @@ mod window_tests { )); FunctionDisassembly { function_id: entry_va, - function_name: format!("sub_{entry_va:x}"), - owner_class: String::new(), + function_name: None, + owner_class: None, entry_va, size: (depth as u64 + 1) * 4, instructions, @@ -848,8 +848,8 @@ mod trampoline_tests { fn body(entry_va: u64, instrs: Vec) -> FunctionDisassembly { FunctionDisassembly { function_id: entry_va, - function_name: format!("sub_{entry_va:x}"), - owner_class: String::new(), + function_name: None, + owner_class: None, entry_va, size: (instrs.len() as u64) * 4, instructions: instrs, @@ -1118,8 +1118,8 @@ mod allocation_tests { let hi = (tag >> 16) & 0xffff; FunctionDisassembly { function_id: entry_va, - function_name: format!("sub_{entry_va:x}"), - owner_class: String::new(), + function_name: None, + owner_class: None, entry_va, size: 12, instructions: vec![ diff --git a/crates/flutterdec-core/src/pipeline/runners/symbols.rs b/crates/flutterdec-core/src/pipeline/runners/symbols.rs index 932c8c3e..7a719aae 100644 --- a/crates/flutterdec-core/src/pipeline/runners/symbols.rs +++ b/crates/flutterdec-core/src/pipeline/runners/symbols.rs @@ -1,14 +1,20 @@ -use flutterdec_adapter::{FunctionInfo, ProgramModel}; +use flutterdec_adapter::model::{ + Function, PoolEntryKind, PoolIndexSpace, ProgramModel, Provenance, +}; use flutterdec_decompiler::PoolSemanticHint; +use flutterdec_disasm_arm64::ProgramHints; use std::collections::HashMap; #[derive(Debug, Default, Clone, Copy)] pub(super) struct PoolMetadataStats { pub(super) total_entries: usize, + /// Whether the indexes mean hardware slots. `false` makes every count below + /// a description of the producer's list, not of the snapshot's pool. + pub(super) addressable: bool, pub(super) with_target_va: usize, pub(super) with_selector: usize, - pub(super) with_owner_class: usize, - pub(super) with_library_uri: usize, + pub(super) with_value: usize, + pub(super) heuristic: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -61,14 +67,16 @@ impl SymbolQualityCounts { } } -pub(super) fn symbol_name_quality_from_name_kind(raw: Option<&str>) -> Option { - let token = raw?.trim().to_ascii_lowercase(); - match token.as_str() { - "placeholder" => Some(SymbolNameQuality::Placeholder), - "heuristic" => Some(SymbolNameQuality::Heuristic), - "external" => Some(SymbolNameQuality::External), - "exact" => Some(SymbolNameQuality::Exact), - _ => None, +/// Map a v4 name's provenance onto the symbol-quality lattice. +/// +/// v3 carried a free-text `name_kind` that a producer could set to `"exact"` +/// for a guess. Provenance is a closed enum the model's own validation checks, +/// so the quality a name gets is now a consequence of how it was recovered. +pub(super) fn symbol_name_quality_from_provenance(provenance: Provenance) -> SymbolNameQuality { + match provenance { + Provenance::Exact => SymbolNameQuality::Exact, + Provenance::Derived => SymbolNameQuality::External, + Provenance::Heuristic => SymbolNameQuality::Heuristic, } } @@ -158,135 +166,116 @@ fn is_heuristic_canonical_symbol_name(name: &str) -> bool { name.starts_with("dart_") || name.starts_with("flutter_") || name.starts_with("package_") } -pub(super) fn build_class_library_lookup(model: &ProgramModel) -> HashMap { - let mut out = HashMap::new(); - for c in &model.classes { - out.entry(c.name.clone()).or_insert_with(|| c.library_uri.clone()); - } - out +/// Whether pool indexes can be joined against disassembly at all. +/// +/// An ordinal pool's index is a position in the producer's own list, so joining +/// `pool[N]` from a `ldr` against it attaches an unrelated string. The whole +/// hint map is skipped rather than filtered, because the join key itself is the +/// thing that is meaningless. +fn pool_is_addressable(model: &ProgramModel) -> bool { + model.object_pool.index_space == PoolIndexSpace::Hardware } pub(super) fn build_pool_value_hints(model: &ProgramModel) -> HashMap { let mut out = HashMap::new(); - for e in &model.object_pool { - let kind = e.kind.to_ascii_lowercase(); - let decoded_kind = e - .decoded_kind - .as_deref() - .unwrap_or("") - .to_ascii_lowercase(); - let string_like = kind.contains("string") - || kind.contains("onebyte") - || kind.contains("twobyte") - || decoded_kind.contains("string") - || decoded_kind.contains("selector"); - if !string_like { + if !pool_is_addressable(model) { + return out; + } + for e in &model.object_pool.entries { + if !matches!( + e.kind, + PoolEntryKind::String | PoolEntryKind::Selector | PoolEntryKind::Field + ) { continue; } - - let selector = e.selector.as_deref().unwrap_or("").trim(); - if !selector.is_empty() && selector.len() <= 128 { - out.insert(e.index, selector.to_string()); + let Some(value) = e.value.as_deref().map(str::trim) else { continue; - } - - let trimmed = e.value.trim(); - if trimmed.is_empty() || trimmed.len() > 256 { + }; + if value.is_empty() || value.len() > 256 { continue; } - out.insert(e.index, trimmed.to_string()); + out.insert(e.index, value.to_string()); } out } pub(super) fn collect_pool_metadata_stats(model: &ProgramModel) -> PoolMetadataStats { let mut out = PoolMetadataStats { - total_entries: model.object_pool.len(), + total_entries: model.object_pool.entries.len(), + addressable: pool_is_addressable(model), ..PoolMetadataStats::default() }; - for e in &model.object_pool { + for e in &model.object_pool.entries { if e.target_va.is_some() { out.with_target_va += 1; } - if e - .selector - .as_deref() - .map(str::trim) - .is_some_and(|v| !v.is_empty()) - { + if e.kind == PoolEntryKind::Selector { out.with_selector += 1; } - if e - .owner_class - .as_deref() - .map(str::trim) - .is_some_and(|v| !v.is_empty()) - { - out.with_owner_class += 1; + if e.value.is_some() { + out.with_value += 1; } - if e - .library_uri - .as_deref() - .map(str::trim) - .is_some_and(|v| !v.is_empty()) - { - out.with_library_uri += 1; + if e.provenance == Provenance::Heuristic { + out.heuristic += 1; } } out } +/// Semantic metadata per pool index, for the decompiler's call-intent pass. +/// +/// Two sources feed it, in priority order, and neither can write into the +/// other: the function the entry points at, which is adapter-authored and +/// therefore authoritative, and the host's own hints, which fill in a selector +/// or owner the model never had. A hint never overrides a model fact. pub(super) fn build_pool_semantic_hints( model: &ProgramModel, - class_to_library: &HashMap, + hints: &ProgramHints, ) -> HashMap { let mut out = HashMap::new(); - let function_meta = build_function_metadata_lookup(model, class_to_library); - for e in &model.object_pool { + if !pool_is_addressable(model) { + return out; + } + let function_meta = build_function_metadata_lookup(model); + for e in &model.object_pool.entries { let fallback = e.target_va.and_then(|va| function_meta.get(&va)).cloned(); + let hint = e + .target_va + .and_then(|va| hints.iter().find(|h| h.target_va == Some(va))); let selector = e - .selector + .value .as_deref() .map(str::trim) + .filter(|_| e.kind == PoolEntryKind::Selector) .filter(|v| !v.is_empty() && v.len() <= 128) .map(str::to_string) .or_else(|| { fallback .as_ref() - .map(|(name, _, _)| name.as_str()) + .and_then(|(name, _, _)| name.clone()) .filter(|name| !is_generic_symbol_name(name)) - .map(str::to_string) - }); - let owner_class = e - .owner_class - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty() && v.len() <= 128) - .map(str::to_string) - .or_else(|| { - fallback - .as_ref() - .map(|(_, owner, _)| owner.as_str()) - .filter(|v| !v.is_empty()) - .map(str::to_string) - }); - let library_uri = e - .library_uri - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty() && v.len() <= 256) - .map(str::to_string) + }) .or_else(|| { - fallback - .as_ref() - .map(|(_, _, lib)| lib.as_str()) - .filter(|v| !v.is_empty()) - .map(str::to_string) + hint.map(|h| h.selector.clone()) + .filter(|v| !v.is_empty() && v.len() <= 128) }); + let owner_class = fallback + .as_ref() + .and_then(|(_, owner, _)| owner.clone()) + .or_else(|| hint.and_then(|h| h.owner_class.clone())) + .filter(|v| !v.is_empty() && v.len() <= 128); + let library_uri = fallback + .as_ref() + .and_then(|(_, _, lib)| lib.clone()) + .or_else(|| hint.and_then(|h| h.library_uri.clone())) + .filter(|v| !v.is_empty() && v.len() <= 256); let target_va = e.target_va; - if selector.is_none() && owner_class.is_none() && library_uri.is_none() && target_va.is_none() + if selector.is_none() + && owner_class.is_none() + && library_uri.is_none() + && target_va.is_none() { continue; } @@ -368,22 +357,25 @@ pub(super) fn build_pool_target_symbols( out } -fn build_function_metadata_lookup( - model: &ProgramModel, - class_to_library: &HashMap, -) -> HashMap { +/// Per-entry-address `(name, owner, library)`, each independently optional. +/// +/// v3 required all three and skipped a function whose owner was empty, which +/// dropped every top-level function. Here a function with a name and no owner +/// still contributes its name. +type FunctionMetadata = (Option, Option, Option); + +fn build_function_metadata_lookup(model: &ProgramModel) -> HashMap { let mut out = HashMap::new(); for f in &model.functions { - let owner = f.owner_class.trim(); - if owner.is_empty() { + let meta = ( + f.name_text().map(str::to_string), + model.owner_name(f).map(str::to_string), + model.owner_library_uri(f).map(str::to_string), + ); + if meta.0.is_none() && meta.1.is_none() && meta.2.is_none() { continue; } - let lib = class_to_library - .get(&f.owner_class) - .cloned() - .unwrap_or_default(); - out.entry(f.entry_va) - .or_insert_with(|| (f.name.clone(), f.owner_class.clone(), lib)); + out.entry(f.code.start_va).or_insert(meta); } out } @@ -398,24 +390,22 @@ fn semantic_token_eq(lhs: &str, rhs: &str) -> bool { normalize(lhs) == normalize(rhs) } -pub(super) fn canonical_standard_model_name( - f: &FunctionInfo, - class_to_library: &HashMap, -) -> Option { - if is_generic_symbol_name(&f.name) { +pub(super) fn canonical_standard_model_name(model: &ProgramModel, f: &Function) -> Option { + let name = f.name_text()?; + if is_generic_symbol_name(name) { return None; } - let method = sanitize_symbol_token_stream(&f.name); + let method = sanitize_symbol_token_stream(name); if method.is_empty() || is_generic_symbol_name(&method) { return None; } - let lib_uri = class_to_library.get(&f.owner_class)?; + let lib_uri = model.owner_library_uri(f)?; if let Some(dart_lib) = dart_library_segment(lib_uri) { return Some(format!("dart_{}_{}", dart_lib, method)); } if let Some(flutter_seg) = flutter_library_segment(lib_uri) { - let class_name = sanitize_symbol_token_stream(&f.owner_class); + let class_name = sanitize_symbol_token_stream(model.owner_name(f)?); if class_name.is_empty() { return None; } diff --git a/crates/flutterdec-core/src/pipeline/runners/tests.rs b/crates/flutterdec-core/src/pipeline/runners/tests.rs index f6f7afe7..29cc3034 100644 --- a/crates/flutterdec-core/src/pipeline/runners/tests.rs +++ b/crates/flutterdec-core/src/pipeline/runners/tests.rs @@ -1,7 +1,189 @@ use super::*; - use flutterdec_disasm_arm64::{AsmInstruction, FunctionDisassembly, FunctionPriorityComponent}; + use flutterdec_adapter::model::{ + Capabilities, CapabilityLevel, Class, ClassId, CodeRange, CompatibilityBinding, Function, + Diagnostic, Domain, FunctionId, InputRegion, InputRegionName, Library, LibraryId, Name, + ObjectPool, ObservedInput, PoolEntry, PoolEntryKind, PoolGeometry, PoolIndexSpace, + Producer, ProducerTrust, Provenance, MODEL_VERSION, + }; + use flutterdec_adapter::primitives::Sha256Digest; + use flutterdec_adapter::validate::{validate, HostSelectedContext}; + use flutterdec_disasm_arm64::{ + AsmInstruction, FunctionDisassembly, FunctionPriorityComponent, Hint, HintKind, HintOrigin, + HintProvenance, ProgramHints, + }; + use flutterdec_loader::identity::{SnapshotIdentity, SnapshotKind, TargetArch}; use tempfile::tempdir; + const ARM64_POOL_GEOMETRY: PoolGeometry = PoolGeometry { + entries_offset: 0x10, + word_size: 8, + }; + + /// Fixture builders for v4 models. + /// + /// Nothing here can produce a named function without being told the name, or + /// a class without a library id that resolves, which is the point: the cases + /// these tests cover include "the producer recovered nothing". + fn lib(id: u32, uri: &str) -> Library { + Library { + id: LibraryId(id), + uri: uri.to_string(), + display_name: None, + provenance: Provenance::Exact, + } + } + + fn cls(id: u32, name: &str, library: Option) -> Class { + Class { + id: ClassId(id), + name: name.to_string(), + library: library.map(LibraryId), + super_class: None, + provenance: Provenance::Exact, + } + } + + fn fun( + id: u32, + name: Option, + owner: Option, + start_va: u64, + size: u64, + ) -> Function { + Function { + id: FunctionId(id), + name, + owner: owner.map(ClassId), + code: CodeRange { start_va, size }, + code_section_va: start_va, + provenance: Provenance::Exact, + } + } + + fn named(text: &str) -> Option { + Some(Name::exact(text)) + } + + fn pool_string(index: u64, value: &str) -> PoolEntry { + PoolEntry { + index, + kind: PoolEntryKind::String, + value: Some(value.to_string()), + target_va: None, + provenance: Provenance::Exact, + confidence: None, + } + } + + fn pool_selector(index: u64, selector: &str, target_va: u64) -> PoolEntry { + PoolEntry { + index, + kind: PoolEntryKind::Selector, + value: Some(selector.to_string()), + target_va: Some(target_va), + provenance: Provenance::Exact, + confidence: None, + } + } + + fn ordinal_pool(entries: Vec) -> ObjectPool { + ObjectPool { + index_space: PoolIndexSpace::Ordinal, + geometry: None, + entries, + } + } + + fn hardware_pool(entries: Vec) -> ObjectPool { + ObjectPool { + index_space: PoolIndexSpace::Hardware, + geometry: Some(ARM64_POOL_GEOMETRY), + entries, + } + } + + fn hint( + kind: HintKind, + origin: HintOrigin, + selector: &str, + target_va: Option, + owner_class: Option<&str>, + library_uri: Option<&str>, + ) -> Hint { + Hint { + kind, + origin, + provenance: HintProvenance::Derived, + selector: selector.to_string(), + target_va, + owner_class: owner_class.map(str::to_string), + library_uri: library_uri.map(str::to_string), + detail: String::new(), + } + } + + fn program_hints(entries: Vec) -> ProgramHints { + let mut hints = ProgramHints::new(); + for entry in entries { + hints.push(entry); + } + hints + } + + fn test_model( + libraries: Vec, + classes: Vec, + functions: Vec, + object_pool: ObjectPool, + ) -> ProgramModel { + let digest = Sha256Digest::of(b"core fixture"); + ProgramModel { + model_version: MODEL_VERSION, + producer: Producer { + id: "core-fixture".to_string(), + version: "0".to_string(), + artifact_sha256: digest.clone(), + trust: ProducerTrust::Untrusted, + }, + input: ObservedInput { + identity: SnapshotIdentity::from_header( + TargetArch::Arm64, + "80a49c7111088100a233b2ae788e1f48", + SnapshotKind::FullAot, + "product arm64 compressed-pointers", + ), + regions: vec![InputRegion { + region: InputRegionName::IsolateInstructions, + size: u64::MAX / 2, + sha256: digest.clone(), + virtual_address: Some(0), + executable: true, + }], + }, + compatibility: CompatibilityBinding { + record_sha256: digest.clone(), + parser_family_id: "fixture".to_string(), + profile_id: "fixture".to_string(), + profile_sha256: digest, + }, + capabilities: Capabilities { + libraries: CapabilityLevel::Partial, + classes: CapabilityLevel::Partial, + class_relationships: CapabilityLevel::Unavailable, + functions: CapabilityLevel::Partial, + function_names: CapabilityLevel::Partial, + object_pool: CapabilityLevel::Partial, + pool_index_space: CapabilityLevel::Unavailable, + }, + libraries, + classes, + functions, + object_pool, + diagnostics: Vec::new(), + extensions: Default::default(), + } + } + #[test] fn formats_asm_instruction_without_opcode_word() { let ins = AsmInstruction { @@ -70,7 +252,6 @@ std::path::Path::new("./out/report.json"), std::path::Path::new("libapp.so"), Some(AdapterBackend::Internal), - "dynamic_snapshot_string_model_v1", &symbol_quality_counts, ); @@ -126,8 +307,8 @@ fn collects_ghidra_pool_comments_from_disassembly() { let disasm = vec![FunctionDisassembly { function_id: 1, - function_name: "main".to_string(), - owner_class: "Global".to_string(), + function_name: Some("main".to_string()), + owner_class: None, entry_va: 0x1000, size: 8, instructions: vec![ @@ -292,187 +473,92 @@ } #[test] - fn resolves_symbol_quality_from_name_kind_values() { + fn symbol_quality_follows_the_models_name_provenance() { assert_eq!( - symbol_name_quality_from_name_kind(Some("exact")), - Some(SymbolNameQuality::Exact) + symbol_name_quality_from_provenance(Provenance::Exact), + SymbolNameQuality::Exact ); assert_eq!( - symbol_name_quality_from_name_kind(Some("external")), - Some(SymbolNameQuality::External) + symbol_name_quality_from_provenance(Provenance::Derived), + SymbolNameQuality::External ); assert_eq!( - symbol_name_quality_from_name_kind(Some("heuristic")), - Some(SymbolNameQuality::Heuristic) + symbol_name_quality_from_provenance(Provenance::Heuristic), + SymbolNameQuality::Heuristic ); - assert_eq!( - symbol_name_quality_from_name_kind(Some("placeholder")), - Some(SymbolNameQuality::Placeholder) - ); - assert_eq!(symbol_name_quality_from_name_kind(Some("unknown")), None); - assert_eq!(symbol_name_quality_from_name_kind(None), None); } + /// A function with no name is counted as unnamed, not as a placeholder-named + /// one. v3 could not express the difference, because every function had to + /// carry a name string. #[test] - fn collects_function_name_kind_breakdown() { + fn counts_function_name_provenance_including_unnamed() { let functions = vec![ - flutterdec_adapter::FunctionInfo { - id: 1, - name: "main".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 16, - code_section_va: 0x1000, - name_kind: Some("exact".to_string()), - }, - flutterdec_adapter::FunctionInfo { - id: 2, - name: "native_libc_printf".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1010, - size: 16, - code_section_va: 0x1000, - name_kind: Some("external".to_string()), - }, - flutterdec_adapter::FunctionInfo { - id: 3, - name: "flutter_widgets_State_build".to_string(), - owner_class: "State".to_string(), - entry_va: 0x1020, - size: 16, - code_section_va: 0x1000, - name_kind: Some("heuristic".to_string()), - }, - flutterdec_adapter::FunctionInfo { - id: 4, - name: "sub_1030".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1030, - size: 16, - code_section_va: 0x1000, - name_kind: Some("placeholder".to_string()), - }, - flutterdec_adapter::FunctionInfo { - id: 5, - name: "fun_custom".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1040, - size: 16, - code_section_va: 0x1000, - name_kind: Some("mystery".to_string()), - }, - flutterdec_adapter::FunctionInfo { - id: 6, - name: "sub_1050".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1050, - size: 16, - code_section_va: 0x1000, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 7, - name: "sub_1060".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1060, - size: 16, - code_section_va: 0x1000, - name_kind: Some(" ".to_string()), - }, + fun(1, named("main"), None, 0x1000, 16), + fun( + 2, + Some(Name { + text: "native_libc_printf".to_string(), + provenance: Provenance::Derived, + confidence: None, + }), + None, + 0x1010, + 16, + ), + fun( + 3, + Some(Name { + text: "build".to_string(), + provenance: Provenance::Heuristic, + confidence: None, + }), + None, + 0x1020, + 16, + ), + fun(4, None, None, 0x1030, 16), + fun(5, None, None, 0x1040, 16), ]; - let stats = collect_function_name_kind_stats(&functions); + let stats = collect_function_name_provenance_stats(&functions); assert_eq!(stats.exact, 1); - assert_eq!(stats.external, 1); + assert_eq!(stats.derived, 1); assert_eq!(stats.heuristic, 1); - assert_eq!(stats.placeholder, 1); - assert_eq!(stats.unknown, 1); - assert_eq!(stats.unspecified, 2); - assert_eq!(stats.tagged(), 5); + assert_eq!(stats.unnamed, 2); + assert_eq!(stats.named(), 3); } #[test] fn collects_function_descriptors_with_library_context() { - let model = ProgramModel { - schema_version: 3, - adapter_kind: "python".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: vec![flutterdec_adapter::LibraryInfo { - id: 0, - uri: "package:spotube/main.dart".to_string(), - name_display: "package:spotube/main.dart".to_string(), - }], - classes: vec![flutterdec_adapter::ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:spotube/main.dart".to_string(), - }], - functions: vec![ - flutterdec_adapter::FunctionInfo { - id: 0, - name: "main".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 16, - code_section_va: 0x1000, - name_kind: Some("heuristic".to_string()), - }, - flutterdec_adapter::FunctionInfo { - id: 1, - name: "sub_2000".to_string(), - owner_class: "UnknownOwner".to_string(), - entry_va: 0x2000, - size: 16, - code_section_va: 0x1000, - name_kind: Some("placeholder".to_string()), - }, + let model = test_model( + vec![lib(0, "package:spotube/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, named("main"), Some(0), 0x1000, 16), + // No library, no owner, no name: three separate unknowns, and + // the descriptor says so with three empty segments rather than + // inventing `UnknownOwner::sub_2000`. + fun(1, None, None, 0x2000, 16), ], - pool_geometry: None, - object_pool: Vec::new(), - }; + ordinal_pool(Vec::new()), + ); let descriptors = collect_function_descriptors(&model); - assert!(descriptors.contains("package:spotube/main.dart::Global::main")); - assert!(descriptors.contains("UnknownOwner::sub_2000")); + assert!(descriptors.contains("package:spotube/main.dart::AppRoot::main")); + assert!(descriptors.contains("::::")); } #[test] fn canonicalizes_flutter_build_file_uri_in_descriptors() { - let model = ProgramModel { - schema_version: 3, - adapter_kind: "python".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: vec![flutterdec_adapter::LibraryInfo { - id: 0, - uri: - "file:///tmp/build/app/.dart_tool/flutter_build/dart_plugin_registrant.dart" - .to_string(), - name_display: "generated".to_string(), - }], - classes: vec![flutterdec_adapter::ClassInfo { - id: 0, - name: "_PluginRegistrant".to_string(), - super_name: "Object".to_string(), - library_uri: - "file:///tmp/build/app/.dart_tool/flutter_build/dart_plugin_registrant.dart" - .to_string(), - }], - functions: vec![flutterdec_adapter::FunctionInfo { - id: 0, - name: "register".to_string(), - owner_class: "_PluginRegistrant".to_string(), - entry_va: 0x3000, - size: 16, - code_section_va: 0x3000, - name_kind: Some("heuristic".to_string()), - }], - pool_geometry: None, - object_pool: Vec::new(), - }; + let model = test_model( + vec![lib( + 0, + "file:///tmp/build/app/.dart_tool/flutter_build/dart_plugin_registrant.dart", + )], + vec![cls(0, "_PluginRegistrant", Some(0))], + vec![fun(0, named("register"), Some(0), 0x3000, 16)], + ordinal_pool(Vec::new()), + ); let descriptors = collect_function_descriptors(&model); assert!(descriptors.contains( @@ -498,16 +584,6 @@ assert!(counts.iter().any(|p| p.package == "unknown" && p.functions == 1)); } - #[test] - fn snapshot_hash_match_enforcement_behaves_as_expected() { - let ok = enforce_snapshot_hash_match(false, "test", "aaa", "bbb").expect("not strict"); - assert!(!ok); - let ok = enforce_snapshot_hash_match(true, "test", "aaa", "aaa").expect("strict match"); - assert!(ok); - let err = enforce_snapshot_hash_match(true, "test", "aaa", "bbb").expect_err("mismatch"); - assert!(err.to_string().contains("snapshot hash mismatch")); - } - #[test] fn collects_compatibility_warnings_from_flags() { let warnings = collect_compatibility_warnings(false, false, true); @@ -517,7 +593,7 @@ .any(|w| w.contains("manifest entry missing"))); assert!(warnings .iter() - .any(|w| w.contains("snapshot hash differs"))); + .any(|w| w.contains("not header-derived"))); assert!(warnings .iter() .any(|w| w.contains("backend differs"))); @@ -526,22 +602,50 @@ assert!(warnings.is_empty()); } + /// The resolved backend is whatever the protocol result named, mapped + /// through a closed enum. No string is consulted, which is what makes a + /// producer calling itself `serwalker` or `r2flutter_snapshot_v1` inert. #[test] - fn resolves_backend_from_adapter_kind_values() { + fn resolved_backend_comes_from_the_typed_protocol_result() { + assert_eq!( + backend_from_id(BackendId::Blutter), + AdapterBackend::Blutter + ); assert_eq!( - resolved_backend_from_adapter_kind("blutter_bridge_model_v1"), - Some(AdapterBackend::Blutter) + backend_from_id(BackendId::R2Flutter), + AdapterBackend::R2Flutter ); assert_eq!( - resolved_backend_from_adapter_kind("dynamic_snapshot_string_model_v1"), - Some(AdapterBackend::Internal) + backend_from_id(BackendId::Internal), + AdapterBackend::Internal ); - assert_eq!(resolved_backend_from_adapter_kind("custom_model_v2"), None); assert_eq!(backend_label(Some(AdapterBackend::Internal)), "internal"); assert_eq!(backend_label(Some(AdapterBackend::Blutter)), "blutter"); assert_eq!(backend_label(None), "unknown"); } + /// A pinned backend maps to a `Fixed` request, which the protocol refuses to + /// let a producer substitute. `auto` is the only request that may fall back. + #[test] + fn requested_backend_pins_everything_except_auto() { + assert_eq!( + requested_backend(AdapterBackend::Auto), + RequestedBackend::Auto + ); + assert_eq!( + requested_backend(AdapterBackend::Blutter), + RequestedBackend::Fixed(BackendId::Blutter) + ); + assert_eq!( + requested_backend(AdapterBackend::R2Flutter), + RequestedBackend::Fixed(BackendId::R2Flutter) + ); + assert_eq!( + requested_backend(AdapterBackend::Internal), + RequestedBackend::Fixed(BackendId::Internal) + ); + } + #[test] fn engine_fingerprint_context_reports_missing_engine_binary() { let td = tempdir().expect("tempdir"); @@ -685,45 +789,45 @@ let selected = vec![ FunctionPriorityBreakdown { function_id: 1, - function_name: "main".to_string(), - owner_class: "Global".to_string(), - library_uri: "package:spotube/main.dart".to_string(), + function_name: Some("main".to_string()), + owner_class: None, + library_uri: Some("package:spotube/main.dart".to_string()), entry_va: 0x1000, total_score: 100, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 2, - function_name: "init".to_string(), - owner_class: "Global".to_string(), - library_uri: "package:spotube/services/init.dart".to_string(), + function_name: Some("init".to_string()), + owner_class: None, + library_uri: Some("package:spotube/services/init.dart".to_string()), entry_va: 0x1010, total_score: 90, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 3, - function_name: "watch".to_string(), - owner_class: "Provider".to_string(), - library_uri: "package:provider/src/provider.dart".to_string(), + function_name: Some("watch".to_string()), + owner_class: Some("Provider".to_string()), + library_uri: Some("package:provider/src/provider.dart".to_string()), entry_va: 0x1020, total_score: 80, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 4, - function_name: "toString".to_string(), - owner_class: "Object".to_string(), - library_uri: "dart:core".to_string(), + function_name: Some("toString".to_string()), + owner_class: Some("Object".to_string()), + library_uri: Some("dart:core".to_string()), entry_va: 0x1030, total_score: 70, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 5, - function_name: "sub_1040".to_string(), - owner_class: "Unknown".to_string(), - library_uri: "".to_string(), + function_name: Some("sub_1040".to_string()), + owner_class: Some("Unknown".to_string()), + library_uri: None, entry_va: 0x1040, total_score: 60, components: Vec::new(), @@ -746,36 +850,36 @@ let selected = vec![ FunctionPriorityBreakdown { function_id: 1, - function_name: "main".to_string(), - owner_class: "Global".to_string(), - library_uri: "package:spotube/main.dart".to_string(), + function_name: Some("main".to_string()), + owner_class: None, + library_uri: Some("package:spotube/main.dart".to_string()), entry_va: 0x1000, total_score: 100, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 2, - function_name: "setState".to_string(), - owner_class: "State".to_string(), - library_uri: "package:flutter/src/widgets/framework.dart".to_string(), + function_name: Some("setState".to_string()), + owner_class: Some("State".to_string()), + library_uri: Some("package:flutter/src/widgets/framework.dart".to_string()), entry_va: 0x1010, total_score: 90, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 3, - function_name: "toString".to_string(), - owner_class: "Object".to_string(), - library_uri: "dart:core".to_string(), + function_name: Some("toString".to_string()), + owner_class: Some("Object".to_string()), + library_uri: Some("dart:core".to_string()), entry_va: 0x1020, total_score: 80, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 4, - function_name: "sub_1030".to_string(), - owner_class: "Unknown".to_string(), - library_uri: "".to_string(), + function_name: Some("sub_1030".to_string()), + owner_class: Some("Unknown".to_string()), + library_uri: None, entry_va: 0x1030, total_score: 70, components: Vec::new(), @@ -793,36 +897,36 @@ let selected = vec![ FunctionPriorityBreakdown { function_id: 1, - function_name: "main".to_string(), - owner_class: "Global".to_string(), - library_uri: "package:app/main.dart".to_string(), + function_name: Some("main".to_string()), + owner_class: None, + library_uri: Some("package:app/main.dart".to_string()), entry_va: 0x1000, total_score: 100, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 2, - function_name: "init".to_string(), - owner_class: "Global".to_string(), - library_uri: "package:spotube/main.dart".to_string(), + function_name: Some("init".to_string()), + owner_class: None, + library_uri: Some("package:spotube/main.dart".to_string()), entry_va: 0x1010, total_score: 90, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 3, - function_name: "watch".to_string(), - owner_class: "Provider".to_string(), - library_uri: "package:provider/src/provider.dart".to_string(), + function_name: Some("watch".to_string()), + owner_class: Some("Provider".to_string()), + library_uri: Some("package:provider/src/provider.dart".to_string()), entry_va: 0x1020, total_score: 80, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 4, - function_name: "toString".to_string(), - owner_class: "Object".to_string(), - library_uri: "dart:core".to_string(), + function_name: Some("toString".to_string()), + owner_class: Some("Object".to_string()), + library_uri: Some("dart:core".to_string()), entry_va: 0x1030, total_score: 70, components: Vec::new(), @@ -839,9 +943,9 @@ let selected = vec![ FunctionPriorityBreakdown { function_id: 1, - function_name: "main".to_string(), - owner_class: "Global".to_string(), - library_uri: "package:app/main.dart".to_string(), + function_name: Some("main".to_string()), + owner_class: None, + library_uri: Some("package:app/main.dart".to_string()), entry_va: 0x1000, total_score: 100, components: vec![ @@ -857,9 +961,9 @@ }, FunctionPriorityBreakdown { function_id: 2, - function_name: "sub_1010".to_string(), - owner_class: "Provider".to_string(), - library_uri: "package:provider/src/provider.dart".to_string(), + function_name: Some("sub_1010".to_string()), + owner_class: Some("Provider".to_string()), + library_uri: Some("package:provider/src/provider.dart".to_string()), entry_va: 0x1010, total_score: 90, components: vec![ @@ -892,18 +996,18 @@ let selected = vec![ FunctionPriorityBreakdown { function_id: 1, - function_name: "main".to_string(), - owner_class: "Global".to_string(), - library_uri: "package:app/main.dart".to_string(), + function_name: Some("main".to_string()), + owner_class: None, + library_uri: Some("package:app/main.dart".to_string()), entry_va: 0x1000, total_score: 100, components: Vec::new(), }, FunctionPriorityBreakdown { function_id: 2, - function_name: "runApp".to_string(), - owner_class: "Global".to_string(), - library_uri: "package:app/main.dart".to_string(), + function_name: Some("runApp".to_string()), + owner_class: None, + library_uri: Some("package:app/main.dart".to_string()), entry_va: 0x1010, total_score: 90, components: Vec::new(), @@ -912,51 +1016,56 @@ let bootflow = BootflowDiscoverySummary { main: vec![ BootflowDiscoveryEntry { - decoded_kind: "pool".to_string(), + kind: "pool".to_string(), + provenance: "derived".to_string(), source: "adapter".to_string(), selector: "main".to_string(), target_va: Some(0x1000), - owner_class: "Global".to_string(), - library_uri: "package:app/main.dart".to_string(), - value: "bootflow:main:main".to_string(), + owner_class: None, + library_uri: Some("package:app/main.dart".to_string()), + detail: "bootflow:main:main".to_string(), }, BootflowDiscoveryEntry { - decoded_kind: "pool".to_string(), + kind: "pool".to_string(), + provenance: "derived".to_string(), source: "adapter".to_string(), selector: "main".to_string(), target_va: Some(0x2000), - owner_class: "Global".to_string(), - library_uri: "package:app/main.dart".to_string(), - value: "bootflow:main:main".to_string(), + owner_class: None, + library_uri: Some("package:app/main.dart".to_string()), + detail: "bootflow:main:main".to_string(), }, ], runapp: vec![BootflowDiscoveryEntry { - decoded_kind: "pool".to_string(), + kind: "pool".to_string(), + provenance: "derived".to_string(), source: "adapter".to_string(), selector: "runApp".to_string(), target_va: Some(0x1010), - owner_class: "Global".to_string(), - library_uri: "package:app/main.dart".to_string(), - value: "bootflow:runapp:runApp".to_string(), + owner_class: None, + library_uri: Some("package:app/main.dart".to_string()), + detail: "bootflow:runapp:runApp".to_string(), }], deeplink: vec![BootflowDiscoveryEntry { - decoded_kind: "pool".to_string(), + kind: "pool".to_string(), + provenance: "derived".to_string(), source: "adapter".to_string(), selector: "onNewIntent".to_string(), target_va: Some(0x3000), - owner_class: "MainActivity".to_string(), - library_uri: "package:app/main.dart".to_string(), - value: "bootflow:deeplink:onNewIntent".to_string(), + owner_class: Some("MainActivity".to_string()), + library_uri: Some("package:app/main.dart".to_string()), + detail: "bootflow:deeplink:onNewIntent".to_string(), }], activity: Vec::new(), bootstrap: vec![BootflowDiscoveryEntry { - decoded_kind: "pool".to_string(), + kind: "pool".to_string(), + provenance: "derived".to_string(), source: "adapter".to_string(), selector: "ensureInitialized".to_string(), target_va: Some(0x1000), - owner_class: "WidgetsFlutterBinding".to_string(), - library_uri: "package:flutter/src/widgets/binding.dart".to_string(), - value: "bootflow:init:ensureInitialized".to_string(), + owner_class: Some("WidgetsFlutterBinding".to_string()), + library_uri: Some("package:flutter/src/widgets/binding.dart".to_string()), + detail: "bootflow:init:ensureInitialized".to_string(), }], }; @@ -974,94 +1083,45 @@ assert!((selected_bootflow_coverage_ratio(stats.any) - 0.5).abs() < f64::EPSILON); assert!(hits.iter().any(|hit| { - hit.category == "main" && hit.target_va == 0x1000 && hit.function_name == "main" + hit.category == "main" && hit.target_va == 0x1000 && hit.function_name.as_deref() == Some("main") })); assert!(hits.iter().any(|hit| { hit.category == "runapp" && hit.target_va == 0x1010 - && hit.function_name == "runApp" + && hit.function_name.as_deref() == Some("runApp") })); assert!(hits.iter().any(|hit| { hit.category == "bootstrap" && hit.target_va == 0x1000 - && hit.function_name == "main" + && hit.function_name.as_deref() == Some("main") })); } #[test] fn applies_app_unknown_scope_filter() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: vec![ - flutterdec_adapter::ClassInfo { - id: 1, - name: "State".to_string(), - super_name: "Object".to_string(), - library_uri: "package:flutter/src/widgets/framework.dart".to_string(), - }, - flutterdec_adapter::ClassInfo { - id: 2, - name: "_StringBase".to_string(), - super_name: "Object".to_string(), - library_uri: "dart:core".to_string(), - }, - flutterdec_adapter::ClassInfo { - id: 3, - name: "ConnectService".to_string(), - super_name: "Object".to_string(), - library_uri: "package:spotube/models/connect/load.dart".to_string(), - }, + let model = test_model( + vec![ + lib(0, "package:flutter/src/widgets/framework.dart"), + lib(1, "dart:core"), + lib(2, "package:spotube/models/connect/load.dart"), ], - functions: vec![ - flutterdec_adapter::FunctionInfo { - id: 10, - name: "setState".to_string(), - owner_class: "State".to_string(), - entry_va: 0x1000, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 11, - name: "toString".to_string(), - owner_class: "_StringBase".to_string(), - entry_va: 0x1100, - size: 4, - code_section_va: 0x1100, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 12, - name: "executeCommandAsync".to_string(), - owner_class: "ConnectService".to_string(), - entry_va: 0x1200, - size: 4, - code_section_va: 0x1200, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 13, - name: "sub_1300".to_string(), - owner_class: "UnknownOwner".to_string(), - entry_va: 0x1300, - size: 4, - code_section_va: 0x1300, - name_kind: None, - }, + vec![ + cls(1, "State", Some(0)), + cls(2, "_StringBase", Some(1)), + cls(3, "ConnectService", Some(2)), ], - pool_geometry: None, - object_pool: Vec::new(), - }; + vec![ + fun(10, named("setState"), Some(1), 0x1000, 4), + fun(11, named("toString"), Some(2), 0x1100, 4), + fun(12, named("executeCommandAsync"), Some(3), 0x1200, 4), + fun(13, None, None, 0x1300, 4), + ], + ordinal_pool(Vec::new()), + ); let (scoped, stats) = apply_function_scope_filter(&model, FunctionScope::AppUnknown, &[]); let ids = scoped.functions.iter().map(|f| f.id).collect::>(); - assert_eq!(ids, vec![12, 13]); + assert_eq!(ids, vec![FunctionId(12), FunctionId(13)]); assert_eq!(stats.total_before_filter, 4); assert_eq!(stats.total_after_filter, 2); assert_eq!(stats.excluded, 2); @@ -1073,46 +1133,19 @@ #[test] fn applies_app_scope_filter() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: vec![flutterdec_adapter::ClassInfo { - id: 3, - name: "ConnectService".to_string(), - super_name: "Object".to_string(), - library_uri: "package:spotube/models/connect/load.dart".to_string(), - }], - functions: vec![ - flutterdec_adapter::FunctionInfo { - id: 12, - name: "executeCommandAsync".to_string(), - owner_class: "ConnectService".to_string(), - entry_va: 0x1200, - size: 4, - code_section_va: 0x1200, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 13, - name: "sub_1300".to_string(), - owner_class: "UnknownOwner".to_string(), - entry_va: 0x1300, - size: 4, - code_section_va: 0x1300, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:spotube/models/connect/load.dart")], + vec![cls(3, "ConnectService", Some(0))], + vec![ + fun(12, named("executeCommandAsync"), Some(3), 0x1200, 4), + fun(13, None, None, 0x1300, 4), ], - pool_geometry: None, - object_pool: Vec::new(), - }; + ordinal_pool(Vec::new()), + ); let (scoped, stats) = apply_function_scope_filter(&model, FunctionScope::App, &[]); let ids = scoped.functions.iter().map(|f| f.id).collect::>(); - assert_eq!(ids, vec![12]); + assert_eq!(ids, vec![FunctionId(12)]); assert_eq!(stats.total_before_filter, 2); assert_eq!(stats.total_after_filter, 1); assert_eq!(stats.excluded, 1); @@ -1120,80 +1153,31 @@ #[test] fn applies_app_package_filter_to_scoped_functions() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: vec![ - flutterdec_adapter::ClassInfo { - id: 3, - name: "ConnectService".to_string(), - super_name: "Object".to_string(), - library_uri: "package:spotube/models/connect/load.dart".to_string(), - }, - flutterdec_adapter::ClassInfo { - id: 4, - name: "ProviderCore".to_string(), - super_name: "Object".to_string(), - library_uri: "package:provider/src/provider.dart".to_string(), - }, - flutterdec_adapter::ClassInfo { - id: 5, - name: "State".to_string(), - super_name: "Object".to_string(), - library_uri: "package:flutter/src/widgets/framework.dart".to_string(), - }, + let model = test_model( + vec![ + lib(0, "package:spotube/models/connect/load.dart"), + lib(1, "package:provider/src/provider.dart"), + lib(2, "package:flutter/src/widgets/framework.dart"), ], - functions: vec![ - flutterdec_adapter::FunctionInfo { - id: 12, - name: "executeCommandAsync".to_string(), - owner_class: "ConnectService".to_string(), - entry_va: 0x1200, - size: 4, - code_section_va: 0x1200, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 13, - name: "watch".to_string(), - owner_class: "ProviderCore".to_string(), - entry_va: 0x1300, - size: 4, - code_section_va: 0x1300, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 14, - name: "setState".to_string(), - owner_class: "State".to_string(), - entry_va: 0x1400, - size: 4, - code_section_va: 0x1400, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 15, - name: "sub_1500".to_string(), - owner_class: "UnknownOwner".to_string(), - entry_va: 0x1500, - size: 4, - code_section_va: 0x1500, - name_kind: None, - }, + vec![ + cls(3, "ConnectService", Some(0)), + cls(4, "ProviderCore", Some(1)), + cls(5, "State", Some(2)), ], - pool_geometry: None, - object_pool: Vec::new(), - }; + vec![ + fun(12, named("executeCommandAsync"), Some(3), 0x1200, 4), + fun(13, named("watch"), Some(4), 0x1300, 4), + fun(14, named("setState"), Some(5), 0x1400, 4), + fun(15, None, None, 0x1500, 4), + ], + ordinal_pool(Vec::new()), + ); let app_packages = vec!["spotube".to_string()]; let (scoped, stats) = apply_function_scope_filter(&model, FunctionScope::AppUnknown, &app_packages); let ids = scoped.functions.iter().map(|f| f.id).collect::>(); - assert_eq!(ids, vec![12]); + assert_eq!(ids, vec![FunctionId(12)]); assert_eq!(stats.total_before_filter, 4); assert_eq!(stats.total_after_filter, 1); assert_eq!(stats.excluded, 3); @@ -1202,74 +1186,25 @@ #[test] fn collects_app_package_function_counts() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: vec![ - flutterdec_adapter::ClassInfo { - id: 1, - name: "AppA".to_string(), - super_name: "Object".to_string(), - library_uri: "package:spotube/a.dart".to_string(), - }, - flutterdec_adapter::ClassInfo { - id: 2, - name: "AppB".to_string(), - super_name: "Object".to_string(), - library_uri: "package:provider/b.dart".to_string(), - }, - flutterdec_adapter::ClassInfo { - id: 3, - name: "State".to_string(), - super_name: "Object".to_string(), - library_uri: "package:flutter/src/widgets/framework.dart".to_string(), - }, + let model = test_model( + vec![ + lib(0, "package:spotube/a.dart"), + lib(1, "package:provider/b.dart"), + lib(2, "package:flutter/src/widgets/framework.dart"), ], - functions: vec![ - flutterdec_adapter::FunctionInfo { - id: 10, - name: "f10".to_string(), - owner_class: "AppA".to_string(), - entry_va: 0x1000, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 11, - name: "f11".to_string(), - owner_class: "AppA".to_string(), - entry_va: 0x1100, - size: 4, - code_section_va: 0x1100, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 12, - name: "f12".to_string(), - owner_class: "AppB".to_string(), - entry_va: 0x1200, - size: 4, - code_section_va: 0x1200, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 13, - name: "setState".to_string(), - owner_class: "State".to_string(), - entry_va: 0x1300, - size: 4, - code_section_va: 0x1300, - name_kind: None, - }, + vec![ + cls(1, "AppA", Some(0)), + cls(2, "AppB", Some(1)), + cls(3, "State", Some(2)), ], - pool_geometry: None, - object_pool: Vec::new(), - }; + vec![ + fun(10, named("f10"), Some(1), 0x1000, 4), + fun(11, named("f11"), Some(1), 0x1100, 4), + fun(12, named("f12"), Some(2), 0x1200, 4), + fun(13, named("setState"), Some(3), 0x1300, 4), + ], + ordinal_pool(Vec::new()), + ); let counts = collect_app_package_counts(&model); assert_eq!( @@ -1300,87 +1235,50 @@ #[test] fn canonicalizes_standard_model_function_names() { - let mut class_lib = HashMap::new(); - class_lib.insert("_StringBase".to_string(), "dart:core".to_string()); - class_lib.insert( - "_BoolPatch".to_string(), - "dart:core-patch/bool_patch.dart".to_string(), - ); - class_lib.insert( - "State".to_string(), - "package:flutter/src/widgets/framework.dart".to_string(), - ); - class_lib.insert( - "RenderObject".to_string(), - "package:flutter/src/rendering/object.dart".to_string(), + let model = test_model( + vec![ + lib(0, "dart:core"), + lib(1, "dart:core-patch/bool_patch.dart"), + lib(2, "package:flutter/src/widgets/framework.dart"), + lib(3, "package:flutter/src/rendering/object.dart"), + ], + vec![ + cls(0, "_StringBase", Some(0)), + cls(1, "_BoolPatch", Some(1)), + cls(2, "State", Some(2)), + cls(3, "RenderObject", Some(3)), + ], + vec![ + fun(1, named("toString"), Some(0), 0x1000, 4), + fun(5, named("fromEnvironment"), Some(1), 0x1800, 4), + fun(2, named("setState"), Some(2), 0x2000, 4), + fun(3, named("layout"), Some(3), 0x3000, 4), + // No name at all: there is nothing to canonicalize, which is the + // case v3 expressed as the fabricated name `sub_1234`. + fun(4, None, Some(2), 0x4000, 4), + ], + ordinal_pool(Vec::new()), ); - - let dart_fn = flutterdec_adapter::FunctionInfo { - id: 1, - name: "toString".to_string(), - owner_class: "_StringBase".to_string(), - entry_va: 0x1000, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }; - assert_eq!( - canonical_standard_model_name(&dart_fn, &class_lib).as_deref(), - Some("dart_core_toString") - ); - - let dart_patch_fn = flutterdec_adapter::FunctionInfo { - id: 5, - name: "fromEnvironment".to_string(), - owner_class: "_BoolPatch".to_string(), - entry_va: 0x1800, - size: 4, - code_section_va: 0x1800, - name_kind: None, + let canonical = |id: u32| { + let f = model + .functions + .iter() + .find(|f| f.id == FunctionId(id)) + .expect("fixture function"); + canonical_standard_model_name(&model, f) }; + + assert_eq!(canonical(1).as_deref(), Some("dart_core_toString")); assert_eq!( - canonical_standard_model_name(&dart_patch_fn, &class_lib).as_deref(), + canonical(5).as_deref(), Some("dart_core_patch_bool_patch_fromEnvironment") ); - - let flutter_fn = flutterdec_adapter::FunctionInfo { - id: 2, - name: "setState".to_string(), - owner_class: "State".to_string(), - entry_va: 0x2000, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }; + assert_eq!(canonical(2).as_deref(), Some("flutter_widgets_State_setState")); assert_eq!( - canonical_standard_model_name(&flutter_fn, &class_lib).as_deref(), - Some("flutter_widgets_State_setState") - ); - - let render_fn = flutterdec_adapter::FunctionInfo { - id: 3, - name: "layout".to_string(), - owner_class: "RenderObject".to_string(), - entry_va: 0x3000, - size: 4, - code_section_va: 0x3000, - name_kind: None, - }; - assert_eq!( - canonical_standard_model_name(&render_fn, &class_lib).as_deref(), + canonical(3).as_deref(), Some("flutter_rendering_RenderObject_layout") ); - - let generic_fn = flutterdec_adapter::FunctionInfo { - id: 4, - name: "sub_1234".to_string(), - owner_class: "State".to_string(), - entry_va: 0x4000, - size: 4, - code_section_va: 0x4000, - name_kind: None, - }; - assert!(canonical_standard_model_name(&generic_fn, &class_lib).is_none()); + assert!(canonical(4).is_none()); } #[test] @@ -1546,133 +1444,28 @@ #[test] fn discovers_bootflow_candidates_from_pool_metadata() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: Vec::new(), - functions: Vec::new(), - pool_geometry: None, - object_pool: vec![ - flutterdec_adapter::ObjectPoolEntry { - index: 1, - kind: "String".to_string(), - value: "bootflow:main:main".to_string(), - decoded_kind: Some("BootMainCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x1000), - owner_class: Some("Global".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: None, - }, - flutterdec_adapter::ObjectPoolEntry { - index: 2, - kind: "String".to_string(), - value: "bootflow:deeplink:onNewIntent".to_string(), - decoded_kind: Some("DeepLinkHandlerCandidate".to_string()), - selector: Some("onNewIntent".to_string()), - target_va: Some(0x1010), - owner_class: Some("RouterHost".to_string()), - library_uri: Some("package:app/router.dart".to_string()), - confidence: None, - source: None, - }, - flutterdec_adapter::ObjectPoolEntry { - index: 3, - kind: "String".to_string(), - value: "bootflow:activity:onResume".to_string(), - decoded_kind: Some("ActivityHandlerCandidate".to_string()), - selector: Some("onResume".to_string()), - target_va: Some(0x1020), - owner_class: Some("MainActivityHost".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: None, - }, - flutterdec_adapter::ObjectPoolEntry { - index: 4, - kind: "String".to_string(), - value: "bootflow:init:ensureInitialized".to_string(), - decoded_kind: Some("BootstrapInitCandidate".to_string()), - selector: Some("ensureInitialized".to_string()), - target_va: Some(0x1030), - owner_class: Some("Global".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: None, - }, - ], - }; + let hints = program_hints(vec![hint(HintKind::BootMain, HintOrigin::ModelNamePattern, "main", Some(0x1000), Some("AppRoot"), Some("package:app/main.dart")), hint(HintKind::DeepLinkHandler, HintOrigin::ModelNamePattern, "onNewIntent", Some(0x1010), Some("RouterHost"), Some("package:app/router.dart")), hint(HintKind::ActivityHandler, HintOrigin::ModelNamePattern, "onResume", Some(0x1020), Some("MainActivityHost"), Some("package:app/main.dart")), hint(HintKind::BootstrapInit, HintOrigin::ModelNamePattern, "ensureInitialized", Some(0x1030), Some("AppRoot"), Some("package:app/main.dart"))]); - let summary = collect_bootflow_discovery(&model); + let summary = collect_bootflow_discovery(&hints); assert_eq!(summary.main.len(), 1); assert_eq!(summary.runapp.len(), 0); assert_eq!(summary.deeplink.len(), 1); - assert_eq!(summary.activity.len(), 2); + assert_eq!(summary.activity.len(), 1); assert_eq!(summary.bootstrap.len(), 1); assert_eq!(summary.main[0].target_va, Some(0x1000)); - assert_eq!(summary.main[0].source, "adapter"); + assert_eq!(summary.main[0].source, "model_name_pattern"); + // Provenance rides along on every reported entry: nothing here is exact. + assert_eq!(summary.main[0].provenance, "derived"); assert_eq!(summary.deeplink[0].selector, "onNewIntent"); - assert!( - summary - .activity - .iter() - .any(|entry| entry.selector == "onResume") - ); - assert!( - summary - .activity - .iter() - .any(|entry| entry.selector == "onNewIntent") - ); + assert_eq!(summary.activity[0].selector, "onResume"); assert_eq!(summary.bootstrap[0].selector, "ensureInitialized"); } #[test] fn dedupes_bootflow_entries_with_same_target_and_selector() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: Vec::new(), - functions: Vec::new(), - pool_geometry: None, - object_pool: vec![ - flutterdec_adapter::ObjectPoolEntry { - index: 1, - kind: "String".to_string(), - value: "entrypoint:main".to_string(), - decoded_kind: Some("EntryPointCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x2000), - owner_class: Some("Global".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: None, - }, - flutterdec_adapter::ObjectPoolEntry { - index: 2, - kind: "String".to_string(), - value: "bootflow:main:main".to_string(), - decoded_kind: Some("BootMainCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x2000), - owner_class: Some("Global".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: None, - }, - ], - }; + let hints = program_hints(vec![hint(HintKind::EntryPoint, HintOrigin::ModelNamePattern, "main", Some(0x2000), Some("AppRoot"), Some("package:app/main.dart")), hint(HintKind::BootMain, HintOrigin::ModelNamePattern, "main", Some(0x2000), Some("AppRoot"), Some("package:app/main.dart"))]); - let summary = collect_bootflow_discovery(&model); + let summary = collect_bootflow_discovery(&hints); assert_eq!(summary.main.len(), 1); assert_eq!(summary.main[0].target_va, Some(0x2000)); assert_eq!(summary.main[0].selector, "main"); @@ -1680,133 +1473,309 @@ #[test] fn keeps_bootflow_entries_with_same_target_and_selector_when_source_differs() { + let hints = program_hints(vec![hint(HintKind::BootMain, HintOrigin::AndroidManifest, "main", Some(0x2000), Some("AppRoot"), Some("package:app/main.dart")), hint(HintKind::BootMain, HintOrigin::ApkStartup, "main", Some(0x2000), Some("AppRoot"), Some("package:app/main.dart"))]); + + let summary = collect_bootflow_discovery(&hints); + assert_eq!(summary.main.len(), 2); + assert!(summary + .main + .iter() + .any(|entry| entry.source == "android_manifest")); + assert!(summary.main.iter().any(|entry| entry.source == "apk_startup")); + } + + /// A model that both parses and passes semantic validation, so enrichment + /// can be checked against the real invariant rather than against a fixture + /// that was never valid to begin with. + fn validatable_model( + libraries: Vec, + classes: Vec, + functions: Vec, + object_pool: ObjectPool, + capabilities: Capabilities, + diagnostics: Vec, + ) -> (ProgramModel, HostSelectedContext) { + let digest = Sha256Digest::of(b"enrichment fixture"); + let identity = SnapshotIdentity::from_header( + TargetArch::Arm64, + "80a49c7111088100a233b2ae788e1f48", + SnapshotKind::FullAot, + "product arm64 compressed-pointers", + ); + let producer = Producer { + id: "enrichment-fixture".to_string(), + version: "0".to_string(), + artifact_sha256: digest.clone(), + trust: ProducerTrust::Untrusted, + }; + let compatibility = CompatibilityBinding { + record_sha256: digest.clone(), + parser_family_id: "fixture".to_string(), + profile_id: "fixture".to_string(), + profile_sha256: digest.clone(), + }; + // Region order is the enum's declaration order, which is the canonical + // order validation requires. + let regions = vec![ + InputRegion { + region: InputRegionName::VmData, + size: 64, + sha256: digest.clone(), + virtual_address: None, + executable: false, + }, + InputRegion { + region: InputRegionName::IsolateData, + size: 64, + sha256: digest.clone(), + virtual_address: None, + executable: false, + }, + InputRegion { + region: InputRegionName::VmInstructions, + size: 0x100, + sha256: digest.clone(), + virtual_address: Some(0x1000), + executable: true, + }, + InputRegion { + region: InputRegionName::IsolateInstructions, + size: 0x100, + sha256: digest, + virtual_address: Some(0x2000), + executable: true, + }, + ]; let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: Vec::new(), - functions: Vec::new(), - pool_geometry: None, - object_pool: vec![ - flutterdec_adapter::ObjectPoolEntry { - index: 1, - kind: "String".to_string(), - value: "manifest:main-launcher".to_string(), - decoded_kind: Some("ManifestMainCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x2000), - owner_class: Some("Global".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: Some("manifest".to_string()), - }, - flutterdec_adapter::ObjectPoolEntry { - index: 2, - kind: "String".to_string(), - value: "bootflow:main:apk_startup".to_string(), - decoded_kind: Some("StartupMainCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x2000), - owner_class: Some("Global".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: Some("apk_startup".to_string()), + model_version: MODEL_VERSION, + producer: producer.clone(), + input: ObservedInput { + identity: identity.clone(), + regions: regions.clone(), + }, + compatibility: compatibility.clone(), + capabilities, + libraries, + classes, + functions, + object_pool, + diagnostics, + extensions: Default::default(), + }; + let host = HostSelectedContext { + identity, + producer, + compatibility, + regions, + }; + (model, host) + } + + fn manifest_signals() -> AndroidManifestSignals { + AndroidManifestSignals { + package_name: Some("com.example.app".to_string()), + application_name: Some("com.example.app.App".to_string()), + has_main_launcher: true, + has_view_browsable: true, + activities: vec!["com.example.app.MainActivity".to_string()], + launcher_activities: vec!["com.example.app.MainActivity".to_string()], + deeplink_activities: vec!["com.example.app.MainActivity".to_string()], + deeplink_entries: vec!["myapp://open".to_string()], + } + } + + fn startup_evidence() -> AndroidStartupEvidence { + AndroidStartupEvidence { + present: true, + confidence: "high".to_string(), + dex_files: vec!["classes.dex".to_string()], + dart_entrypoints: vec![DartEntrypointEvidence { + source_dex: "classes.dex".to_string(), + class_descriptor: "Lcom/example/MainActivity;".to_string(), + class_name: "com.example.MainActivity".to_string(), + method_name: "configureFlutterEngine".to_string(), + target_method: "executeDartEntrypoint".to_string(), + function_name: Some("main".to_string()), + library_uri: Some("package:app/main.dart".to_string()), + initial_route: None, + app_bundle_path: Some("flutter_assets".to_string()), + confidence: "high".to_string(), + }], + ..AndroidStartupEvidence::default() + } + } + + /// Run every enrichment pass and prove the model came out the other side + /// byte-identical and still valid. + /// + /// This is the invariant that matters: no synthetic pool entry, no invented + /// class or function, no index collision, no capability contradiction, and + /// no authority upgrade. Checking it by re-validating rather than by + /// eyeballing fields means a future pass that writes into the model fails + /// here even if nobody thought to assert on the field it touched. + fn assert_enrichment_preserves(model: &ProgramModel, host: &HostSelectedContext) { + validate(model, host).expect("fixture must be valid before enrichment"); + let before = model.to_canonical_json(); + + let mut hints = ProgramHints::new(); + collect_model_name_hints(model, &mut hints); + collect_manifest_bootflow_hints(model, &manifest_signals(), &mut hints); + collect_apk_startup_bootflow_hints(model, &startup_evidence(), &mut hints); + + assert_eq!( + before, + model.to_canonical_json(), + "enrichment mutated the model" + ); + validate(model, host).expect("model must still be valid after enrichment"); + + // Whatever the passes produced, none of it can claim to be exact. + assert!(hints + .iter() + .all(|h| h.provenance != HintProvenance::Derived + || h.origin != HintOrigin::ModelNamePattern)); + for hint in hints.iter() { + if let Some(va) = hint.target_va { + assert!( + model.functions.iter().any(|f| f.code.start_va == va), + "a hint points at {va:#x}, which is not a recovered code range" + ); + } + } + } + + #[test] + fn enrichment_preserves_an_authoritative_model() { + let (model, host) = validatable_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "MainActivity", Some(0))], + vec![ + fun(0, named("main"), Some(0), 0x2000, 0x10), + Function { + id: FunctionId(1), + name: named("onNewIntent"), + owner: Some(ClassId(0)), + code: CodeRange { + start_va: 0x2010, + size: 0x10, + }, + // The section base is the region's, not the function's. + code_section_va: 0x2000, + provenance: Provenance::Exact, }, ], - }; + ObjectPool { + index_space: PoolIndexSpace::Hardware, + geometry: Some(ARM64_POOL_GEOMETRY), + entries: vec![pool_selector(3, "build", 0x2000)], + }, + Capabilities { + libraries: CapabilityLevel::Complete, + classes: CapabilityLevel::Complete, + class_relationships: CapabilityLevel::Unavailable, + functions: CapabilityLevel::Complete, + function_names: CapabilityLevel::Complete, + object_pool: CapabilityLevel::Complete, + pool_index_space: CapabilityLevel::Complete, + }, + vec![Diagnostic::unavailable( + Domain::ClassRelationships, + "no superclass edges in this snapshot", + )], + ); + assert_enrichment_preserves(&model, &host); + } - let summary = collect_bootflow_discovery(&model); - assert_eq!(summary.main.len(), 2); - assert!(summary.main.iter().any(|entry| entry.source == "manifest")); - assert!(summary.main.iter().any(|entry| entry.source == "apk_startup")); + #[test] + fn enrichment_preserves_a_model_that_recovered_nothing() { + let (model, host) = validatable_model( + Vec::new(), + Vec::new(), + Vec::new(), + ObjectPool::unavailable(), + Capabilities::all_unavailable(), + Domain::ALL + .iter() + .map(|domain| Diagnostic::unavailable(*domain, "no parser for this identity")) + .collect(), + ); + assert_enrichment_preserves(&model, &host); } #[test] - fn enriches_model_with_manifest_synthetic_bootflow_hints() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: vec![flutterdec_adapter::LibraryInfo { - id: 1, + fn enrichment_preserves_a_heuristic_only_model() { + let (model, host) = validatable_model( + vec![Library { + id: LibraryId(0), uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), + display_name: None, + provenance: Provenance::Heuristic, }], - classes: vec![ - flutterdec_adapter::ClassInfo { - id: 1, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }, - flutterdec_adapter::ClassInfo { - id: 2, - name: "MainActivity".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }, - flutterdec_adapter::ClassInfo { - id: 3, - name: "SettingsMapper".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/model/settings.dart".to_string(), + Vec::new(), + vec![Function { + id: FunctionId(0), + name: None, + owner: None, + code: CodeRange { + start_va: 0x2000, + size: 0x10, }, + code_section_va: 0x2000, + provenance: Provenance::Heuristic, + }], + ObjectPool { + index_space: PoolIndexSpace::Ordinal, + geometry: None, + entries: vec![PoolEntry { + index: 0, + kind: PoolEntryKind::String, + value: Some("a carved string".to_string()), + target_va: None, + provenance: Provenance::Heuristic, + confidence: None, + }], + }, + Capabilities { + libraries: CapabilityLevel::Partial, + classes: CapabilityLevel::Unavailable, + class_relationships: CapabilityLevel::Unavailable, + functions: CapabilityLevel::Partial, + function_names: CapabilityLevel::Unavailable, + object_pool: CapabilityLevel::Partial, + pool_index_space: CapabilityLevel::Unavailable, + }, + vec![ + Diagnostic::unavailable(Domain::Classes, "no class table"), + Diagnostic::unavailable(Domain::ClassRelationships, "no class table"), + Diagnostic::unavailable(Domain::FunctionNames, "no names in instruction bytes"), + Diagnostic::unavailable(Domain::PoolIndexSpace, "carve order is not an index space"), ], - functions: vec![ - flutterdec_adapter::FunctionInfo { - id: 1, - name: "main".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 2, - name: "runApp".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1004, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 3, - name: "onNewIntent".to_string(), - owner_class: "MainActivity".to_string(), - entry_va: 0x1008, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 4, - name: "onResume".to_string(), - owner_class: "MainActivity".to_string(), - entry_va: 0x100c, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, - flutterdec_adapter::FunctionInfo { - id: 5, - name: "ensureInitialized".to_string(), - owner_class: "SettingsMapper".to_string(), - entry_va: 0x1010, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, + ); + assert_enrichment_preserves(&model, &host); + } + + /// Manifest enrichment produces hints and leaves the model alone. In v3 this + /// pushed synthetic `ObjectPoolEntry` records at `index = object_pool.len()`, + /// which both invented pool slots and grew the pool index space. + #[test] + fn manifest_enrichment_produces_hints_and_leaves_the_model_untouched() { + let model = test_model( + vec![lib(1, "package:app/main.dart")], + vec![ + cls(1, "AppRoot", Some(1)), + cls(2, "MainActivity", Some(1)), + cls(3, "SettingsMapper", None), ], - pool_geometry: None, - object_pool: Vec::new(), - }; + vec![ + fun(1, named("main"), Some(1), 0x1000, 4), + fun(2, named("runApp"), Some(1), 0x1004, 4), + fun(3, named("onNewIntent"), Some(2), 0x1008, 4), + fun(4, named("onResume"), Some(2), 0x100c, 4), + fun(5, named("ensureInitialized"), Some(3), 0x1010, 4), + ], + ordinal_pool(vec![pool_string(0, "an adapter-authored value")]), + ); + let before = model.clone(); let signals = AndroidManifestSignals { package_name: Some("com.example.app".to_string()), application_name: Some("com.example.app.App".to_string()), @@ -1818,23 +1787,29 @@ deeplink_entries: vec!["myapp://open".to_string()], }; - let (enriched, inserted) = enrich_model_with_manifest_bootflow_hints(&model, &signals); - assert!(inserted >= 4); - - let kinds = enriched - .object_pool + let mut hints = ProgramHints::new(); + let inserted = collect_manifest_bootflow_hints(&model, &signals, &mut hints); + assert!(inserted >= 4, "expected at least four hints, got {inserted}"); + + // No new pool entry, no new class, no new function, no index collision. + assert_eq!(model, before); + assert_eq!(model.object_pool.entries.len(), 1); + assert_eq!(model.object_pool.entries[0].index, 0); + + let kinds = hints.iter().map(|h| h.kind).collect::>(); + assert!(kinds.contains(&HintKind::BootMain)); + assert!(kinds.contains(&HintKind::BootRunApp)); + assert!(kinds.contains(&HintKind::DeepLinkHandler)); + assert!(kinds.contains(&HintKind::ActivityHandler)); + // `SettingsMapper.ensureInitialized` is not in a bootstrap context, so + // the selector shape alone does not license the hint. + assert!(!kinds.contains(&HintKind::BootstrapInit)); + assert!(hints .iter() - .filter_map(|e| e.decoded_kind.as_deref()) - .collect::>(); - assert!(kinds.contains(&"ManifestMainCandidate")); - assert!(kinds.contains(&"ManifestRunAppCandidate")); - assert!(kinds.contains(&"ManifestDeepLinkCandidate")); - assert!(kinds.contains(&"ManifestActivityCandidate")); - assert!(!kinds.contains(&"ManifestBootstrapCandidate")); - assert!(enriched - .object_pool + .all(|h| h.origin == HintOrigin::AndroidManifest)); + assert!(hints .iter() - .all(|entry| entry.source.as_deref() == Some("manifest"))); + .all(|h| h.provenance == HintProvenance::Derived)); } #[test] @@ -1870,50 +1845,24 @@ assert!(!cfg.apk_startup_analysis); } + /// Pool semantic metadata now comes from the function an entry points at, + /// plus host hints. v3 read `owner_class`/`library_uri` off the pool entry + /// itself, which let a producer attach any class to any slot. #[test] - fn builds_pool_semantic_hints_from_adapter_metadata() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: Vec::new(), - functions: Vec::new(), - pool_geometry: None, - object_pool: vec![ - flutterdec_adapter::ObjectPoolEntry { - index: 7, - kind: "String".to_string(), - value: "didChangeMetrics".to_string(), - decoded_kind: Some("selector".to_string()), - selector: Some("didChangeMetrics".to_string()), - target_va: Some(0x1234), - owner_class: Some("WidgetsBindingObserver".to_string()), - library_uri: Some("package:flutter/src/widgets/binding.dart".to_string()), - confidence: None, - source: None, - }, - flutterdec_adapter::ObjectPoolEntry { - index: 8, - kind: "Smi".to_string(), - value: "42".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }, - ], - }; + fn builds_pool_semantic_hints_from_the_function_an_entry_points_at() { + let model = test_model( + vec![lib(0, "package:flutter/src/widgets/binding.dart")], + vec![cls(0, "WidgetsBindingObserver", Some(0))], + vec![fun(0, named("didChangeMetrics"), Some(0), 0x1234, 4)], + hardware_pool(vec![ + pool_selector(7, "didChangeMetrics", 0x1234), + pool_string(8, "42"), + ]), + ); + let hints = program_hints(vec![]); - let class_to_library = build_class_library_lookup(&model); - let hints = build_pool_semantic_hints(&model, &class_to_library); - assert_eq!(hints.len(), 1); - let h = hints.get(&7).expect("missing semantic hint entry"); + let semantic = build_pool_semantic_hints(&model, &hints); + let h = semantic.get(&7).expect("missing semantic hint entry"); assert_eq!(h.selector.as_deref(), Some("didChangeMetrics")); assert_eq!(h.owner_class.as_deref(), Some("WidgetsBindingObserver")); assert_eq!( @@ -1923,110 +1872,74 @@ assert_eq!(h.target_va, Some(0x1234)); } + /// An ordinal pool index is a position in the producer's list. Joining + /// disassembly against it would attach unrelated strings, so the join is + /// refused outright rather than filtered. + #[test] + fn ordinal_pool_indexes_produce_no_hints_at_all() { + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![fun(0, named("didChangeMetrics"), Some(0), 0x1234, 4)], + ordinal_pool(vec![pool_selector(7, "didChangeMetrics", 0x1234)]), + ); + let hints = program_hints(vec![]); + + assert!(build_pool_semantic_hints(&model, &hints).is_empty()); + assert!(build_pool_value_hints(&model).is_empty()); + assert!(!collect_pool_metadata_stats(&model).addressable); + } + #[test] fn collects_pool_metadata_coverage_stats() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: Vec::new(), - functions: Vec::new(), - pool_geometry: None, - object_pool: vec![ - flutterdec_adapter::ObjectPoolEntry { - index: 1, - kind: "String".to_string(), - value: "a".to_string(), - decoded_kind: None, - selector: Some("setState".to_string()), - target_va: Some(0x1000), - owner_class: Some("State".to_string()), - library_uri: Some("package:flutter/src/widgets/framework.dart".to_string()), - confidence: None, - source: None, - }, - flutterdec_adapter::ObjectPoolEntry { - index: 2, - kind: "Smi".to_string(), - value: "42".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }, - ], - }; + let model = test_model( + vec![], + vec![], + vec![], + hardware_pool(vec![ + pool_selector(1, "setState", 0x1000), + pool_string(2, "42"), + ]), + ); let stats = collect_pool_metadata_stats(&model); assert_eq!(stats.total_entries, 2); + assert!(stats.addressable); assert_eq!(stats.with_target_va, 1); assert_eq!(stats.with_selector, 1); - assert_eq!(stats.with_owner_class, 1); - assert_eq!(stats.with_library_uri, 1); + assert_eq!(stats.with_value, 2); + assert_eq!(stats.heuristic, 0); } #[test] fn builds_pool_target_symbols_from_metadata() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: Vec::new(), - functions: Vec::new(), - pool_geometry: None, - object_pool: vec![ - flutterdec_adapter::ObjectPoolEntry { - index: 7, - kind: "String".to_string(), - value: "didChangeMetrics".to_string(), - decoded_kind: Some("selector".to_string()), - selector: Some("didChangeMetrics".to_string()), - target_va: Some(0x1234), - owner_class: Some("WidgetsBindingObserver".to_string()), - library_uri: Some("package:flutter/src/widgets/binding.dart".to_string()), - confidence: None, - source: None, - }, - flutterdec_adapter::ObjectPoolEntry { - index: 8, - kind: "String".to_string(), - value: "Int64List".to_string(), - decoded_kind: Some("selector".to_string()), - selector: Some("Int64List".to_string()), - target_va: Some(0x2234), - owner_class: Some("Int64List".to_string()), - library_uri: Some("dart:typed_data".to_string()), - confidence: None, - source: None, - }, - flutterdec_adapter::ObjectPoolEntry { - index: 9, - kind: "String".to_string(), - value: "executeCommandAsync".to_string(), - decoded_kind: Some("selector".to_string()), - selector: Some("executeCommandAsync".to_string()), - target_va: Some(0x3234), - owner_class: Some("ConnectService".to_string()), - library_uri: Some("package:spotube/models/connect/load.dart".to_string()), - confidence: None, - source: None, - }, + let model = test_model( + vec![ + lib(0, "package:flutter/src/widgets/binding.dart"), + lib(1, "dart:typed_data"), + lib(2, "package:spotube/services/connect.dart"), ], - }; + vec![ + cls(0, "WidgetsBindingObserver", Some(0)), + cls(1, "Int64List", Some(1)), + cls(2, "ConnectService", Some(2)), + ], + vec![ + fun(0, named("didChangeMetrics"), Some(0), 0x1234, 4), + fun(1, named("Int64List"), Some(1), 0x2234, 4), + fun(2, named("executeCommandAsync"), Some(2), 0x3234, 4), + ], + hardware_pool(vec![ + pool_selector(7, "didChangeMetrics", 0x1234), + pool_selector(8, "Int64List", 0x2234), + pool_selector(9, "executeCommandAsync", 0x3234), + ]), + ); + let hints = program_hints(vec![]); - let class_to_library = build_class_library_lookup(&model); - let hints = build_pool_semantic_hints(&model, &class_to_library); + let semantic = build_pool_semantic_hints(&model, &hints); let values = build_pool_value_hints(&model); - let map = build_pool_target_symbols(&hints, &values); + let map = build_pool_target_symbols(&semantic, &values); assert_eq!( map.get(&0x1234).map(String::as_str), Some("flutter_widgets_WidgetsBindingObserver_didChangeMetrics") @@ -2041,49 +1954,39 @@ ); } + /// A host hint fills in a selector the model never had, and only that: it + /// cannot displace an owner or library the model did recover. #[test] - fn enriches_pool_semantic_hints_from_function_metadata() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "python".to_string(), - dart_version: "3.0.0".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: vec![flutterdec_adapter::ClassInfo { - id: 1, - name: "State".to_string(), - super_name: "Object".to_string(), - library_uri: "package:flutter/src/widgets/framework.dart".to_string(), - }], - functions: vec![flutterdec_adapter::FunctionInfo { - id: 11, - name: "setState".to_string(), - owner_class: "State".to_string(), - entry_va: 0x4000, - size: 4, - code_section_va: 0x4000, - name_kind: None, - }], - pool_geometry: None, - object_pool: vec![flutterdec_adapter::ObjectPoolEntry { - index: 21, - kind: "Closure".to_string(), - value: "opaque".to_string(), - decoded_kind: None, - selector: None, - target_va: Some(0x4000), - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; - - let class_to_library = build_class_library_lookup(&model); - let hints = build_pool_semantic_hints(&model, &class_to_library); - let h = hints.get(&21).expect("missing enriched semantic hint"); - assert_eq!(h.selector.as_deref(), Some("setState")); + fn host_hints_fill_gaps_without_overriding_model_facts() { + let model = test_model( + vec![lib(0, "package:flutter/src/widgets/framework.dart")], + vec![cls(1, "State", Some(0))], + vec![fun(11, named("setState"), Some(1), 0x4000, 4)], + hardware_pool(vec![pool_string(21, "opaque")]), + ); + let hints = program_hints(vec![hint( + HintKind::ActivityHandler, + HintOrigin::AndroidManifest, + "onNewIntent", + Some(0x4000), + Some("ManifestActivity"), + Some("apk:classes.dex"), + )]); + + // Nothing points at 0x4000 from the pool, so the entry has no target and + // no fallback: the hint has nothing to attach to and does not invent one. + assert!(build_pool_semantic_hints(&model, &hints).is_empty()); + + let anchored = test_model( + vec![lib(0, "package:flutter/src/widgets/framework.dart")], + vec![cls(1, "State", Some(0))], + vec![fun(11, named("setState"), Some(1), 0x4000, 4)], + hardware_pool(vec![pool_selector(21, "opaque", 0x4000)]), + ); + let semantic = build_pool_semantic_hints(&anchored, &hints); + let h = semantic.get(&21).expect("missing enriched semantic hint"); + assert_eq!(h.selector.as_deref(), Some("opaque")); + // Owner and library come from the model's own function, not the hint. assert_eq!(h.owner_class.as_deref(), Some("State")); assert_eq!( h.library_uri.as_deref(), @@ -2094,31 +1997,7 @@ #[test] fn target_filter_matches_function_id_without_scope_override() { - let full_model = ProgramModel { - schema_version: 3, - adapter_kind: "python".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: vec![flutterdec_adapter::ClassInfo { - id: 1, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![flutterdec_adapter::FunctionInfo { - id: 42, - name: "main".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 4, - code_section_va: 0x1000, - name_kind: Some("heuristic".to_string()), - }], - pool_geometry: None, - object_pool: Vec::new(), - }; + let full_model = test_model(vec![], vec![cls(1, "AppRoot", None)], vec![fun(42, Some(Name { text: "main".to_string(), provenance: Provenance::Heuristic, confidence: None }), Some(1), 0x1000, 4)], ordinal_pool(vec![])); let scoped_model = full_model.clone(); let (selected, stats) = apply_target_function_filter( &full_model, @@ -2131,55 +2010,20 @@ assert!(!stats.scope_overridden); assert_eq!(stats.matched_count, 1); assert_eq!(selected.functions.len(), 1); - assert_eq!(selected.functions[0].id, 42); + assert_eq!(selected.functions[0].id, FunctionId(42)); } #[test] fn target_filter_can_override_scope_for_entry_va() { - let full_model = ProgramModel { - schema_version: 3, - adapter_kind: "python".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: vec![ - flutterdec_adapter::ClassInfo { - id: 1, - name: "AppClass".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }, - flutterdec_adapter::ClassInfo { - id: 2, - name: "CoreClass".to_string(), - super_name: "Object".to_string(), - library_uri: "dart:core".to_string(), - }, - ], - functions: vec![ - flutterdec_adapter::FunctionInfo { - id: 1, - name: "main".to_string(), - owner_class: "AppClass".to_string(), - entry_va: 0x1000, - size: 4, - code_section_va: 0x1000, - name_kind: Some("heuristic".to_string()), - }, - flutterdec_adapter::FunctionInfo { - id: 2, - name: "coreFn".to_string(), - owner_class: "CoreClass".to_string(), - entry_va: 0x2000, - size: 4, - code_section_va: 0x2000, - name_kind: Some("heuristic".to_string()), - }, + let full_model = test_model( + vec![lib(0, "package:app/main.dart"), lib(1, "dart:core")], + vec![cls(1, "AppClass", Some(0)), cls(2, "CoreClass", Some(1))], + vec![ + fun(1, named("main"), Some(1), 0x1000, 4), + fun(2, named("coreFn"), Some(2), 0x2000, 4), ], - pool_geometry: None, - object_pool: Vec::new(), - }; + ordinal_pool(Vec::new()), + ); let (scoped_model, _) = apply_function_scope_filter(&full_model, FunctionScope::App, &[]); assert_eq!(scoped_model.functions.len(), 1); @@ -2191,47 +2035,12 @@ assert!(stats.scope_overridden); assert_eq!(stats.matched_count, 1); assert_eq!(selected.functions.len(), 1); - assert_eq!(selected.functions[0].entry_va, 0x2000); + assert_eq!(selected.functions[0].code.start_va, 0x2000); } #[test] fn target_filter_rejects_ambiguous_any_selector() { - let full_model = ProgramModel { - schema_version: 3, - adapter_kind: "python".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "deadbeef".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: vec![flutterdec_adapter::ClassInfo { - id: 1, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - flutterdec_adapter::FunctionInfo { - id: 42, - name: "fnA".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 4, - code_section_va: 0x1000, - name_kind: Some("heuristic".to_string()), - }, - flutterdec_adapter::FunctionInfo { - id: 7, - name: "fnB".to_string(), - owner_class: "Global".to_string(), - entry_va: 42, - size: 4, - code_section_va: 42, - name_kind: Some("heuristic".to_string()), - }, - ], - pool_geometry: None, - object_pool: Vec::new(), - }; + let full_model = test_model(vec![], vec![cls(1, "AppRoot", None)], vec![fun(42, Some(Name { text: "fnA".to_string(), provenance: Provenance::Heuristic, confidence: None }), Some(1), 0x1000, 4), fun(7, Some(Name { text: "fnB".to_string(), provenance: Provenance::Heuristic, confidence: None }), Some(1), 42, 4)], ordinal_pool(vec![])); let scoped_model = full_model.clone(); let err = apply_target_function_filter(&full_model, &scoped_model, FunctionTarget::Any(42)) diff --git a/crates/flutterdec-core/src/pipeline/runners_diff.rs b/crates/flutterdec-core/src/pipeline/runners_diff.rs index 977a75f1..03b3d467 100644 --- a/crates/flutterdec-core/src/pipeline/runners_diff.rs +++ b/crates/flutterdec-core/src/pipeline/runners_diff.rs @@ -1,18 +1,32 @@ use std::collections::BTreeSet; -fn function_descriptor( - func: &flutterdec_adapter::FunctionInfo, - class_to_library: &HashMap, -) -> String { - let raw_library_uri = class_to_library - .get(&func.owner_class) - .map(String::as_str) - .unwrap_or(""); - let library_uri = canonicalize_library_uri_for_diff(raw_library_uri); - if library_uri.is_empty() { - return format!("{}::{}", func.owner_class, func.name); - } - format!("{}::{}::{}", library_uri, func.owner_class, func.name) +/// A stable identity for one function across two snapshots. +/// +/// Unrecovered parts are the empty segment rather than a stand-in, so a +/// function that gained a name between builds reads as one added and one +/// removed descriptor instead of silently matching a different `sub_` label. +fn function_descriptor(model: &ProgramModel, func: &flutterdec_adapter::model::Function) -> String { + let library_uri = + canonicalize_library_uri_for_diff(model.owner_library_uri(func).unwrap_or("")); + let owner = model.owner_name(func).unwrap_or(""); + let name = func.name_text().unwrap_or(""); + // Always three segments. Dropping the empty library segment would leave + // `owner::name`, and `descriptor_library_uri` reads everything before the + // first `::` as the library URI, so the owner class would be bucketed as a + // package instead of as unknown. + format!("{}::{}::{}", library_uri, owner, name) +} + +/// The Dart version for the report, from the loader's profile table. +/// +/// The adapter no longer supplies one: a semantic version is an alias of the +/// snapshot hash, and the host is the side that holds the hash-to-version table. +fn dart_version_label(bundle: &SnapshotBundle) -> String { + bundle + .dart_profile + .as_ref() + .map(|p| p.dart_version.clone()) + .unwrap_or_else(|| "unavailable".to_string()) } fn canonicalize_library_uri_for_diff(uri: &str) -> String { @@ -28,11 +42,10 @@ fn canonicalize_library_uri_for_diff(uri: &str) -> String { } fn collect_function_descriptors(model: &ProgramModel) -> BTreeSet { - let class_to_library = build_class_library_lookup(model); model .functions .iter() - .map(|func| function_descriptor(func, &class_to_library)) + .map(|func| function_descriptor(model, func)) .collect::>() } @@ -89,18 +102,18 @@ pub fn run_diff( let old_loaded = load_model(repo_root, &old_bundle, opt.adapter_backend)?; let new_loaded = load_model(repo_root, &new_bundle, opt.adapter_backend)?; - let old_snapshot_hash_match = enforce_snapshot_hash_match( - opt.require_snapshot_hash_match, - "old input", - &old_bundle.snapshot_hash, - &old_loaded.model.snapshot_hash, - )?; - let new_snapshot_hash_match = enforce_snapshot_hash_match( - opt.require_snapshot_hash_match, - "new input", - &new_bundle.snapshot_hash, - &new_loaded.model.snapshot_hash, - )?; + // The model echoes the host identity and validation already rejected any + // model that changed it, so a mismatch here is impossible by construction + // rather than something to re-check against an adapter-authored string. + let old_snapshot_hash_match = old_bundle.identity.is_exact(); + let new_snapshot_hash_match = new_bundle.identity.is_exact(); + if opt.require_snapshot_hash_match && !(old_snapshot_hash_match && new_snapshot_hash_match) { + bail!( + "--require-snapshot-hash-match: snapshot identity is not header-derived (old={}, new={})", + old_snapshot_hash_match, + new_snapshot_hash_match + ); + } let old_model = old_loaded.model; let new_model = new_loaded.model; @@ -128,13 +141,13 @@ pub fn run_diff( let report = DiffReport { old_input_path: old_bundle.input_path.display().to_string(), new_input_path: new_bundle.input_path.display().to_string(), - old_snapshot_hash: old_bundle.snapshot_hash, - new_snapshot_hash: new_bundle.snapshot_hash, + old_snapshot_hash: old_bundle.snapshot_hash.clone(), + new_snapshot_hash: new_bundle.snapshot_hash.clone(), old_snapshot_hash_match, new_snapshot_hash_match, require_snapshot_hash_match: opt.require_snapshot_hash_match, - old_dart_version: old_model.dart_version, - new_dart_version: new_model.dart_version, + old_dart_version: dart_version_label(&old_bundle), + new_dart_version: dart_version_label(&new_bundle), function_scope: opt.function_scope.as_str().to_string(), app_packages: opt.app_packages.clone(), old_function_count: old_descriptors.len(), diff --git a/crates/flutterdec-core/src/pipeline/symbol_map.rs b/crates/flutterdec-core/src/pipeline/symbol_map.rs index f48abcce..c225c1c9 100644 --- a/crates/flutterdec-core/src/pipeline/symbol_map.rs +++ b/crates/flutterdec-core/src/pipeline/symbol_map.rs @@ -5,7 +5,6 @@ use goblin::elf::section_header::SHF_EXECINSTR; use goblin::elf::sym::{STT_FUNC, STT_NOTYPE}; use goblin::elf::Elf; use serde::Deserialize; -use std::collections::BTreeMap; include!("symbol_map/types.rs"); include!("symbol_map/run.rs"); diff --git a/crates/flutterdec-disasm-arm64/Cargo.toml b/crates/flutterdec-disasm-arm64/Cargo.toml index 23534c8f..209d8671 100644 --- a/crates/flutterdec-disasm-arm64/Cargo.toml +++ b/crates/flutterdec-disasm-arm64/Cargo.toml @@ -9,3 +9,6 @@ flutterdec-adapter = { path = "../flutterdec-adapter" } serde.workspace = true capstone.workspace = true regex.workspace = true + +[dev-dependencies] +flutterdec-loader = { path = "../flutterdec-loader" } diff --git a/crates/flutterdec-disasm-arm64/src/hints.rs b/crates/flutterdec-disasm-arm64/src/hints.rs new file mode 100644 index 00000000..f106dd7b --- /dev/null +++ b/crates/flutterdec-disasm-arm64/src/hints.rs @@ -0,0 +1,243 @@ +//! Host-derived analysis hints, kept out of the adapter's authoritative domains. +//! +//! Core enrichment learns things the adapter never saw: the launcher activity in +//! an `AndroidManifest.xml`, the Dart entrypoints an APK's startup evidence +//! names, the selectors that look like boot-flow handlers. All of it is useful +//! for deciding what to disassemble first, and none of it is a fact about the +//! snapshot's `ObjectPool`. +//! +//! The previous design wrote these into `ProgramModel::object_pool` with +//! `index = object_pool.len()`, which put derived guesses in the same index +//! space as hardware pool slots and grew that space on every enrichment pass. +//! A hint lives here instead: it has its own record type, it always names where +//! it came from and how strongly, and it cannot collide with, overwrite, or be +//! mistaken for anything the adapter authored. + +use std::collections::BTreeSet; + +/// Which host artifact a hint was read out of. +/// +/// Kept distinct from [`HintProvenance`] because where evidence came from and +/// how strong it is are different questions: a manifest is an exact document +/// that still only supports a guess about Dart code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum HintOrigin { + /// `AndroidManifest.xml` in the input APK. + AndroidManifest, + /// The APK's Android startup evidence (dex entrypoints, activity classes). + ApkStartup, + /// A pattern match over names the adapter already recovered. + ModelNamePattern, +} + +impl HintOrigin { + pub fn as_str(self) -> &'static str { + match self { + Self::AndroidManifest => "android_manifest", + Self::ApkStartup => "apk_startup", + Self::ModelNamePattern => "model_name_pattern", + } + } +} + +/// How well a hint is known. +/// +/// There is no `Exact`. A hint is by construction something the host inferred +/// rather than read out of the snapshot, so promoting one to exact would be the +/// authority upgrade this module exists to prevent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum HintProvenance { + /// Read verbatim from a host artifact that states it. + Derived, + /// A guess from pattern evidence. + Heuristic, +} + +impl HintProvenance { + pub fn as_str(self) -> &'static str { + match self { + Self::Derived => "derived", + Self::Heuristic => "heuristic", + } + } +} + +/// What a hint claims about a selector or address. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum HintKind { + /// Program entry, by any route. + EntryPoint, + /// Dart `main`. + BootMain, + /// `runApp`. + BootRunApp, + /// Engine or binding initialization. + BootstrapInit, + /// Intent, route, or deep-link handling. + DeepLinkHandler, + /// An Android activity lifecycle callback. + ActivityHandler, +} + +impl HintKind { + pub const ALL: [HintKind; 6] = [ + HintKind::EntryPoint, + HintKind::BootMain, + HintKind::BootRunApp, + HintKind::BootstrapInit, + HintKind::DeepLinkHandler, + HintKind::ActivityHandler, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::EntryPoint => "entry_point", + Self::BootMain => "boot_main", + Self::BootRunApp => "boot_run_app", + Self::BootstrapInit => "bootstrap_init", + Self::DeepLinkHandler => "deep_link_handler", + Self::ActivityHandler => "activity_handler", + } + } + + /// The bootflow category name this hint contributes, for seed selection. + pub fn category(self) -> &'static str { + match self { + Self::EntryPoint | Self::BootMain => "main", + Self::BootRunApp => "runapp", + Self::BootstrapInit => "init", + Self::DeepLinkHandler => "deeplink", + Self::ActivityHandler => "activity", + } + } +} + +/// One host-derived claim about a selector, and optionally about an address. +#[derive(Debug, Clone, PartialEq)] +pub struct Hint { + pub kind: HintKind, + pub origin: HintOrigin, + pub provenance: HintProvenance, + /// The selector or class-like token the hint is about. + pub selector: String, + /// The code address the hint points at, when one is known. A hint with no + /// address still steers naming; it just cannot steer seed selection. + pub target_va: Option, + /// The owning class as the model reports it, when the hint came from a + /// model record. Never invented. + pub owner_class: Option, + pub library_uri: Option, + /// Free text for the report. Nothing keys on it. + pub detail: String, +} + +impl Hint { + fn key(&self) -> (HintKind, HintOrigin, String, Option) { + ( + self.kind, + self.origin, + self.selector.to_ascii_lowercase(), + self.target_va, + ) + } +} + +/// The hints one analysis run accumulated. +/// +/// Insertion is deduplicating, so running an enrichment pass twice is a no-op +/// rather than a doubling. Order is insertion order, which keeps reports stable +/// for a given input. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ProgramHints { + entries: Vec, + seen: BTreeSet<(HintKind, HintOrigin, String, Option)>, +} + +impl ProgramHints { + pub fn new() -> Self { + Self::default() + } + + /// Add a hint. Returns whether it was new. + pub fn push(&mut self, hint: Hint) -> bool { + if !self.seen.insert(hint.key()) { + return false; + } + self.entries.push(hint); + true + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn of_kind(&self, kind: HintKind) -> impl Iterator { + self.entries.iter().filter(move |h| h.kind == kind) + } + + /// The bootflow categories claimed for one address. + pub fn categories_for_va(&self, va: u64) -> BTreeSet<&'static str> { + self.entries + .iter() + .filter(|h| h.target_va == Some(va)) + .map(|h| h.kind.category()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hint(kind: HintKind, selector: &str, va: Option) -> Hint { + Hint { + kind, + origin: HintOrigin::AndroidManifest, + provenance: HintProvenance::Derived, + selector: selector.to_string(), + target_va: va, + owner_class: None, + library_uri: None, + detail: String::new(), + } + } + + #[test] + fn pushing_the_same_hint_twice_adds_one_record() { + let mut hints = ProgramHints::new(); + assert!(hints.push(hint(HintKind::EntryPoint, "main", Some(0x1000)))); + assert!(!hints.push(hint(HintKind::EntryPoint, "MAIN", Some(0x1000)))); + assert_eq!(hints.len(), 1); + } + + #[test] + fn categories_are_collected_per_address() { + let mut hints = ProgramHints::new(); + hints.push(hint(HintKind::BootMain, "main", Some(0x1000))); + hints.push(hint(HintKind::ActivityHandler, "onNewIntent", Some(0x1000))); + hints.push(hint(HintKind::BootRunApp, "runApp", Some(0x2000))); + assert_eq!( + hints + .categories_for_va(0x1000) + .into_iter() + .collect::>(), + vec!["activity", "main"] + ); + assert_eq!( + hints + .categories_for_va(0x2000) + .into_iter() + .collect::>(), + vec!["runapp"] + ); + assert!(hints.categories_for_va(0x3000).is_empty()); + } +} diff --git a/crates/flutterdec-disasm-arm64/src/lib.rs b/crates/flutterdec-disasm-arm64/src/lib.rs index c570dc42..5e5b9066 100644 --- a/crates/flutterdec-disasm-arm64/src/lib.rs +++ b/crates/flutterdec-disasm-arm64/src/lib.rs @@ -1,11 +1,16 @@ use capstone::arch::arm64::ArchMode; use capstone::prelude::*; -use flutterdec_adapter::{FunctionInfo, PoolGeometry, ProgramModel}; +use flutterdec_adapter::model::{ + Function, PoolEntryKind, PoolGeometry, PoolIndexSpace, ProgramModel, +}; use regex::Regex; use serde::Serialize; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::LazyLock; +pub mod hints; +pub use hints::{Hint, HintKind, HintOrigin, HintProvenance, ProgramHints}; + #[derive(Debug, Clone, Serialize)] pub struct AsmInstruction { pub va: u64, @@ -18,13 +23,34 @@ pub struct AsmInstruction { #[derive(Debug, Clone, Serialize)] pub struct FunctionDisassembly { pub function_id: u64, - pub function_name: String, - pub owner_class: String, + /// `None` when the model recovered no name. Downstream renders a label from + /// the entry address instead of the model handing one over, so an + /// address-derived string can never be mistaken for a recovered name. + pub function_name: Option, + /// `None` for a top-level function and for one whose owner was not + /// recovered. The model distinguishes those; this record does not need to. + pub owner_class: Option, pub entry_va: u64, pub size: u64, pub instructions: Vec, } +impl FunctionDisassembly { + /// A label to print. Derived from the entry address when there is no name, + /// which is a fact about where the code is, not a claim about what it is. + pub fn display_name(&self) -> String { + match &self.function_name { + Some(name) => name.clone(), + None => format!("fn_0x{:x}", self.entry_va), + } + } + + /// The owner to print, or the empty string when there is none. + pub fn owner_label(&self) -> &str { + self.owner_class.as_deref().unwrap_or("") + } +} + #[derive(Debug, Clone, Serialize)] pub struct FunctionPriorityComponent { pub name: String, @@ -34,9 +60,9 @@ pub struct FunctionPriorityComponent { #[derive(Debug, Clone, Serialize)] pub struct FunctionPriorityBreakdown { pub function_id: u64, - pub function_name: String, - pub owner_class: String, - pub library_uri: String, + pub function_name: Option, + pub owner_class: Option, + pub library_uri: Option, pub entry_va: u64, pub total_score: i32, pub components: Vec, @@ -212,21 +238,22 @@ fn annotation_for(mnemonic: &str, op_str: &str, pool: &mut PoolRefResolver) -> S } fn decode_function( - func: &FunctionInfo, + model: &ProgramModel, + func: &Function, iso_instr: &[u8], iso_base_va: u64, cs: Option<&Capstone>, pool_geometry: Option, ) -> Option { - if func.entry_va < iso_base_va { + if func.code.start_va < iso_base_va { return None; } - let rel = (func.entry_va - iso_base_va) as usize; + let rel = (func.code.start_va - iso_base_va) as usize; if rel >= iso_instr.len() { return None; } - let requested = usize::try_from(func.size).unwrap_or(0); + let requested = usize::try_from(func.code.size).unwrap_or(0); let size = requested.min(iso_instr.len() - rel); if size < 4 { return None; @@ -237,7 +264,7 @@ fn decode_function( let mut pool = PoolRefResolver::new(pool_geometry); if let Some(cs) = cs { - if let Ok(insns) = cs.disasm_all(code, func.entry_va) { + if let Ok(insns) = cs.disasm_all(code, func.code.start_va) { for ins in insns.iter() { let bytes = ins.bytes(); let word = if bytes.len() >= 4 { @@ -269,7 +296,7 @@ fn decode_function( iso_instr[rel + off + 2], iso_instr[rel + off + 3], ]); - let pc = func.entry_va + off as u64; + let pc = func.code.start_va + off as u64; instructions.push(AsmInstruction { va: pc, word, @@ -282,22 +309,22 @@ fn decode_function( } Some(FunctionDisassembly { - function_id: func.id, - function_name: func.name.clone(), - owner_class: func.owner_class.clone(), - entry_va: func.entry_va, + function_id: u64::from(func.id.0), + function_name: func.name_text().map(str::to_string), + owner_class: model.owner_name(func).map(str::to_string), + entry_va: func.code.start_va, size: size as u64, instructions, }) } -fn build_owner_library_lookup(model: &ProgramModel) -> HashMap { - let mut out = HashMap::new(); - for class in &model.classes { - out.entry(class.name.clone()) - .or_insert_with(|| class.library_uri.clone()); - } - out +/// The library URI a function's owning class belongs to, when both are known. +/// +/// v3 keyed this on the owner *name* through a string map, so two classes with +/// the same name in different libraries collided and whichever was seen first +/// won. Typed ids remove the question. +fn function_library_uri<'a>(model: &'a ProgramModel, func: &Function) -> Option<&'a str> { + model.owner_library_uri(func) } fn decode_bl_target(pc: u64, word: u32) -> Option { @@ -388,102 +415,67 @@ fn deep_link_signal_score(text: &str) -> i32 { deep_link_signal_score_lower(&text.to_ascii_lowercase()) } -fn entrypoint_signal_score(entry: &flutterdec_adapter::ObjectPoolEntry) -> i32 { - let mut score = 0i32; - let decoded_kind_lower = entry - .decoded_kind - .as_deref() - .map(str::trim) - .unwrap_or("") - .to_ascii_lowercase(); - let library_lower = entry - .library_uri - .as_deref() - .map(str::trim) - .unwrap_or("") - .to_ascii_lowercase(); - - score += match decoded_kind_lower.as_str() { - "entrypointcandidate" => 5000, - "bootmaincandidate" => 6200, - "bootrunappcandidate" => 3800, - "deeplinkhandlercandidate" => 2600, - "activityhandlercandidate" => 2400, - "bootstrapinitcandidate" => 1800, - "manifestmaincandidate" => 5400, - "manifestrunappcandidate" => 3200, - "manifestdeeplinkcandidate" => 2100, - "manifestactivitycandidate" => 1900, - "manifestbootstrapcandidate" => 1400, - _ => 0, +/// How strongly one host-derived hint argues that its address is worth +/// disassembling first. +/// +/// Scored from the hint's typed kind and origin. v3 read the same signal out of +/// a `decoded_kind` string and a `value` prefix on a synthetic pool entry, which +/// meant any producer that wrote `"BootMainCandidate"` into a pool slot could +/// set analysis priority. A hint cannot be produced by an adapter at all. +fn hint_signal_score(hint: &Hint) -> i32 { + let mut score = match (hint.kind, hint.origin) { + (HintKind::BootMain, HintOrigin::AndroidManifest) => 5400, + (HintKind::BootMain, _) => 6200, + (HintKind::EntryPoint, _) => 5000, + (HintKind::BootRunApp, HintOrigin::AndroidManifest) => 3200, + (HintKind::BootRunApp, _) => 3800, + (HintKind::DeepLinkHandler, HintOrigin::AndroidManifest) => 2100, + (HintKind::DeepLinkHandler, _) => 2600, + (HintKind::ActivityHandler, HintOrigin::AndroidManifest) => 1900, + (HintKind::ActivityHandler, _) => 2400, + (HintKind::BootstrapInit, HintOrigin::AndroidManifest) => 1400, + (HintKind::BootstrapInit, _) => 1800, }; - let value_lower = entry.value.trim().to_ascii_lowercase(); - if value_lower.starts_with("entrypoint:") { - score += 1800; - } else if value_lower.starts_with("bootflow:main:") { - score += 2200; - } else if value_lower.starts_with("bootflow:runapp:") { - score += 1200; - } else if value_lower.starts_with("bootflow:deeplink:") { - score += 900; - } else if value_lower.starts_with("bootflow:activity:") { - score += 800; - } else if value_lower.starts_with("bootflow:init:") { - score += 700; - } else if value_lower.starts_with("manifest:main") { - score += 1800; - } else if value_lower.starts_with("manifest:runapp") { - score += 1000; - } else if value_lower.starts_with("manifest:deeplink") { - score += 700; - } else if value_lower.starts_with("manifest:activity") { - score += 650; - } else if value_lower.starts_with("manifest:bootstrap") { - score += 520; - } - - if let Some(selector) = entry.selector.as_deref() { - let selector_lower = selector.trim().to_ascii_lowercase(); - if is_main_like_name(&selector_lower) { - score += 3200; - } else if selector_lower == "runapp" || selector_lower.ends_with(".runapp") { - score += 500; - } + let selector_lower = hint.selector.trim().to_ascii_lowercase(); + if is_main_like_name(&selector_lower) { + score += 3200; + } else if selector_is_runapp_like(&selector_lower) { + score += 500; } - // Framework/stdlib metadata is useful for graph seeding, but app-owned - // handlers should dominate capped reverse-engineering output. - let framework_weighted_kind = matches!( - decoded_kind_lower.as_str(), - "deeplinkhandlercandidate" - | "activityhandlercandidate" - | "bootstrapinitcandidate" - | "manifestdeeplinkcandidate" - | "manifestactivitycandidate" - | "manifestbootstrapcandidate" + // Framework and stdlib handlers are useful for graph seeding, but app-owned + // ones should dominate capped reverse-engineering output. + let framework_weighted = matches!( + hint.kind, + HintKind::DeepLinkHandler | HintKind::ActivityHandler | HintKind::BootstrapInit ); - if framework_weighted_kind && library_lower.starts_with("package:flutter/") { + let library_lower = hint + .library_uri + .as_deref() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + if framework_weighted && library_lower.starts_with("package:flutter/") { score /= 4; - } else if framework_weighted_kind && library_lower.starts_with("dart:") { + } else if framework_weighted && library_lower.starts_with("dart:") { score /= 5; } - score } -fn selector_signal_score(entry: &flutterdec_adapter::ObjectPoolEntry) -> i32 { - let selector = entry - .selector - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(|v| v.to_ascii_lowercase()); - let Some(selector) = selector else { +/// How strongly a pool entry's own value argues for its target address. +/// +/// Only entries the adapter authored, only their real decoded value, and only +/// when the pool actually addresses code. Unlike a hint, a pool entry carries no +/// library, so the framework/stdlib de-weighting that applies to hints has +/// nothing to key on here. +fn pool_selector_signal_score(value: &str) -> i32 { + let selector = value.trim().to_ascii_lowercase(); + if selector.is_empty() { return 0; - }; - - let mut score = match selector.as_str() { + } + let score = match selector.as_str() { "runapp" => 1600, "createstate" => 1500, "build" => 1200, @@ -503,21 +495,6 @@ fn selector_signal_score(entry: &flutterdec_adapter::ObjectPoolEntry) -> i32 { "main" => 1800, _ => 0, }; - if score == 0 { - return 0; - } - - let lib = entry - .library_uri - .as_deref() - .map(str::trim) - .unwrap_or("") - .to_ascii_lowercase(); - if lib.starts_with("package:flutter/") { - score /= 4; - } else if lib.starts_with("dart:") { - score /= 5; - } score } @@ -560,133 +537,98 @@ fn selector_is_bootstrap_like(selector_lower: &str) -> bool { ) } -fn infer_bootflow_categories(entry: &flutterdec_adapter::ObjectPoolEntry) -> HashSet<&'static str> { - let mut categories = HashSet::new(); - let decoded_kind_lower = entry - .decoded_kind - .as_deref() - .map(str::trim) - .unwrap_or("") - .to_ascii_lowercase(); - let value_lower = entry.value.trim().to_ascii_lowercase(); - let selector_lower = entry - .selector - .as_deref() - .map(str::trim) - .unwrap_or("") - .to_ascii_lowercase(); - - if matches!( - decoded_kind_lower.as_str(), - "entrypointcandidate" | "bootmaincandidate" | "manifestmaincandidate" - ) || value_lower.starts_with("entrypoint:") - || value_lower.starts_with("bootflow:main:") - || value_lower.starts_with("manifest:main") - || is_main_like_name(&selector_lower) - { - categories.insert("main"); - } - - if matches!( - decoded_kind_lower.as_str(), - "bootrunappcandidate" | "manifestrunappcandidate" - ) || value_lower.starts_with("bootflow:runapp:") - || value_lower.starts_with("manifest:runapp") - || selector_is_runapp_like(&selector_lower) - { - categories.insert("runapp"); - } - - if matches!( - decoded_kind_lower.as_str(), - "deeplinkhandlercandidate" | "manifestdeeplinkcandidate" - ) || value_lower.starts_with("bootflow:deeplink:") - || value_lower.starts_with("manifest:deeplink") - || selector_is_deeplink_like(&selector_lower) - { - categories.insert("deeplink"); - } - - if matches!( - decoded_kind_lower.as_str(), - "activityhandlercandidate" | "manifestactivitycandidate" - ) || value_lower.starts_with("bootflow:activity:") - || value_lower.starts_with("manifest:activity") - || selector_is_activity_like(&selector_lower) - { - categories.insert("activity"); - } - - if matches!( - decoded_kind_lower.as_str(), - "bootstrapinitcandidate" | "manifestbootstrapcandidate" - ) || value_lower.starts_with("bootflow:init:") - || value_lower.starts_with("manifest:bootstrap") - || selector_is_bootstrap_like(&selector_lower) - { - categories.insert("bootstrap"); - } - - categories -} - +/// Which bootflow categories are claimed for each code address. +/// +/// Two independent sources, kept apart: host-derived hints, which name a +/// category directly, and pool entries the adapter authored, whose decoded +/// selector implies one. Neither can write into the other. fn build_target_va_bootflow_categories( model: &ProgramModel, + hints: &ProgramHints, ) -> HashMap> { - let mut out = HashMap::new(); - for entry in &model.object_pool { - let Some(target_va) = entry.target_va else { + let mut out: HashMap> = HashMap::new(); + for hint in hints.iter() { + let Some(target_va) = hint.target_va else { continue; }; - let inferred = infer_bootflow_categories(entry); - if inferred.is_empty() { - continue; - } out.entry(target_va) - .or_insert_with(HashSet::new) - .extend(inferred); + .or_default() + .insert(hint.kind.category()); + } + for (target_va, selector) in pool_code_selectors(model) { + let lower = selector.trim().to_ascii_lowercase(); + let categories = out.entry(target_va).or_default(); + if is_main_like_name(&lower) { + categories.insert("main"); + } + if selector_is_runapp_like(&lower) { + categories.insert("runapp"); + } + if selector_is_deeplink_like(&lower) { + categories.insert("deeplink"); + } + if selector_is_activity_like(&lower) { + categories.insert("activity"); + } + if selector_is_bootstrap_like(&lower) { + categories.insert("bootstrap"); + } } + out.retain(|_, categories| !categories.is_empty()); out } -fn build_target_va_priority_hints(model: &ProgramModel) -> HashMap { - let mut out = HashMap::new(); - for entry in &model.object_pool { - let Some(target_va) = entry.target_va else { +/// Pool entries that both name something and point at code. +/// +/// Restricted to a pool whose index space is hardware: an ordinal pool is a +/// producer's own list, and a `target_va` in one is still an address the +/// producer recovered, so it is kept, but nothing here reads the index. +fn pool_code_selectors(model: &ProgramModel) -> Vec<(u64, &str)> { + model + .object_pool + .entries + .iter() + .filter(|e| matches!(e.kind, PoolEntryKind::Code | PoolEntryKind::Selector)) + .filter_map(|e| Some((e.target_va?, e.value.as_deref()?))) + .collect() +} + +fn build_target_va_priority_hints(model: &ProgramModel, hints: &ProgramHints) -> HashMap { + let mut out: HashMap = HashMap::new(); + let mut bump = |va: u64, score: i32| { + if score <= 0 { + return; + } + let clamped = score.min(6000); + let slot = out.entry(va).or_insert(0); + if clamped > *slot { + *slot = clamped; + } + }; + + for hint in hints.iter() { + let Some(target_va) = hint.target_va else { continue; }; - let mut score = 0i32; - score += entrypoint_signal_score(entry); - score += selector_signal_score(entry); - score += deep_link_signal_score(&entry.value); - score += entry - .decoded_kind - .as_deref() - .map(deep_link_signal_score) - .unwrap_or(0); - score += entry - .selector - .as_deref() - .map(deep_link_signal_score) - .unwrap_or(0); - score += entry + let mut score = hint_signal_score(hint); + score += deep_link_signal_score(&hint.selector); + score += hint .owner_class .as_deref() .map(deep_link_signal_score) .unwrap_or(0); - score += entry + score += hint .library_uri .as_deref() .map(deep_link_signal_score) .unwrap_or(0); - if score <= 0 { - continue; - } - let clamped = score.min(6000); - let slot = out.entry(target_va).or_insert(0); - if clamped > *slot { - *slot = clamped; - } + bump(target_va, score); + } + + for (target_va, value) in pool_code_selectors(model) { + let mut score = pool_selector_signal_score(value); + score += deep_link_signal_score(value); + bump(target_va, score); } out } @@ -765,12 +707,11 @@ fn is_bootstrap_like_name(name_lower: &str) -> bool { fn build_app_package_boosts( model: &ProgramModel, - owner_library: &HashMap, preferred_packages: &HashSet, ) -> HashMap { let mut counts: HashMap = HashMap::new(); for f in &model.functions { - let Some(uri) = owner_library.get(&f.owner_class) else { + let Some(uri) = function_library_uri(model, f) else { continue; }; let Some(pkg) = package_name_from_library_uri(uri) else { @@ -824,19 +765,19 @@ fn build_app_package_boosts( } fn collect_direct_call_targets( - func: &FunctionInfo, + func: &Function, iso_instr: &[u8], iso_base_va: u64, known_entries: &HashSet, ) -> Vec { - if func.entry_va < iso_base_va { + if func.code.start_va < iso_base_va { return Vec::new(); } - let rel = (func.entry_va - iso_base_va) as usize; + let rel = (func.code.start_va - iso_base_va) as usize; if rel >= iso_instr.len() { return Vec::new(); } - let requested = usize::try_from(func.size).unwrap_or(0); + let requested = usize::try_from(func.code.size).unwrap_or(0); let max_scan = requested.min(iso_instr.len() - rel).min(0x400); if max_scan < 4 { return Vec::new(); @@ -852,7 +793,7 @@ fn collect_direct_call_targets( iso_instr[rel + off + 2], iso_instr[rel + off + 3], ]); - let pc = func.entry_va + off as u64; + let pc = func.code.start_va + off as u64; if let Some(target) = decode_bl_target(pc, word) { if known_entries.contains(&target) && seen.insert(target) { out.push(target); @@ -864,11 +805,11 @@ fn collect_direct_call_targets( } fn build_call_adjacency( - candidates: &[&FunctionInfo], + candidates: &[&Function], iso_instr: &[u8], iso_base_va: u64, ) -> HashMap> { - let known_entries: HashSet = candidates.iter().map(|f| f.entry_va).collect(); + let known_entries: HashSet = candidates.iter().map(|f| f.code.start_va).collect(); if known_entries.is_empty() { return HashMap::new(); } @@ -877,26 +818,28 @@ fn build_call_adjacency( for f in candidates { let callees = collect_direct_call_targets(f, iso_instr, iso_base_va, &known_entries); if !callees.is_empty() { - adjacency.insert(f.entry_va, callees); + adjacency.insert(f.code.start_va, callees); } } adjacency } fn build_entrypoint_frontier_scores( - candidates: &[&FunctionInfo], + candidates: &[&Function], target_va_hints: &HashMap, adjacency: &HashMap>, ) -> HashMap { let mut seeds = HashSet::new(); for f in candidates { - let name_lower = f.name.to_ascii_lowercase(); + // An unnamed function seeds only on an address hint. There is no name to + // pattern-match, and inventing one is what put `main` on arbitrary code. + let name_lower = f.name_text().unwrap_or("").to_ascii_lowercase(); if is_main_like_name(&name_lower) || name_lower.contains("runapp") || name_lower.contains("ensureinitialized") - || target_va_hints.get(&f.entry_va).copied().unwrap_or(0) >= 1200 + || target_va_hints.get(&f.code.start_va).copied().unwrap_or(0) >= 1200 { - seeds.insert(f.entry_va); + seeds.insert(f.code.start_va); } } if seeds.is_empty() { @@ -969,8 +912,8 @@ struct FunctionScoreStats { } fn function_priority( - func: &FunctionInfo, - owner_library: &HashMap, + model: &ProgramModel, + func: &Function, target_va_hints: &HashMap, app_package_boosts: &HashMap, preferred_packages: &HashSet, @@ -979,8 +922,11 @@ fn function_priority( ) -> (i32, Vec<(String, i32)>) { let mut score = 0i32; let mut components = Vec::new(); - let name_lower = func.name.to_ascii_lowercase(); - let owner_lower = func.owner_class.to_ascii_lowercase(); + // An unrecovered name scores as an empty string rather than as a + // stand-in: `sub_1000` used to earn a generic-name penalty *and* look like + // a name, and neither was true. + let name_lower = func.name_text().unwrap_or("").to_ascii_lowercase(); + let owner_lower = model.owner_name(func).unwrap_or("").to_ascii_lowercase(); let mut library_kind = PriorityLibraryKind::Unknown; if has_no_isolate_marker(&name_lower) || has_no_isolate_marker(&owner_lower) { @@ -988,7 +934,7 @@ fn function_priority( push_component(&mut components, "no_isolate_marker_penalty", -650); } - if looks_generic_name(&name_lower) { + if name_lower.is_empty() || looks_generic_name(&name_lower) { score -= 40; push_component(&mut components, "generic_name_penalty", -40); } else { @@ -1013,15 +959,15 @@ fn function_priority( score -= 90; push_component(&mut components, "private_name_penalty", -90); } - if func.size <= 16 && looks_generic_name(&name_lower) { + if func.code.size <= 16 && (name_lower.is_empty() || looks_generic_name(&name_lower)) { score -= 80; push_component(&mut components, "tiny_generic_penalty", -80); } - if func.size <= 8 && (name_lower.starts_with("closure_") || name_lower.starts_with('_')) { + if func.code.size <= 8 && (name_lower.starts_with("closure_") || name_lower.starts_with('_')) { score -= 220; push_component(&mut components, "tiny_wrapper_penalty", -220); } - let size_bonus = function_size_bonus(func.size); + let size_bonus = function_size_bonus(func.code.size); score += size_bonus; push_component(&mut components, "function_size_bonus", size_bonus); if is_main_like_name(&name_lower) { @@ -1047,7 +993,7 @@ fn function_priority( score += owner_deeplink; push_component(&mut components, "owner_deeplink_signal", owner_deeplink); - if let Some(uri) = owner_library.get(&func.owner_class) { + if let Some(uri) = function_library_uri(model, func) { let uri_lower = uri.to_ascii_lowercase(); library_kind = priority_library_kind(&uri_lower); if uri_lower.ends_with("/main.dart") || uri_lower.ends_with("main.dart") { @@ -1118,11 +1064,11 @@ fn function_priority( PriorityLibraryKind::Unknown => {} } } - if let Some(extra) = target_va_hints.get(&func.entry_va) { + if let Some(extra) = target_va_hints.get(&func.code.start_va) { score += *extra; push_component(&mut components, "pool_target_va_hint", *extra); } - if let Some(extra) = entrypoint_frontier_scores.get(&func.entry_va) { + if let Some(extra) = entrypoint_frontier_scores.get(&func.code.start_va) { if name_lower.starts_with("closure_") { let boosted = *extra / 8; score += boosted; @@ -1149,7 +1095,7 @@ fn function_priority( } if stats.call_out_degree > 0 { let mut call_bonus = (stats.call_out_degree.min(6) as i32) * 60; - if func.size <= 16 { + if func.code.size <= 16 { call_bonus /= 2; } score += call_bonus; @@ -1165,20 +1111,42 @@ fn function_priority( struct RankedCandidate<'a> { index: usize, - func: &'a FunctionInfo, - library_uri: String, + func: &'a Function, + owner_class: Option, + library_uri: Option, score: i32, out_degree: usize, components: Vec<(String, i32)>, } +impl RankedCandidate<'_> { + fn entry_va(&self) -> u64 { + self.func.code.start_va + } + + fn name_key(&self) -> String { + self.func.name_text().unwrap_or("").to_ascii_lowercase() + } + + fn owner_name_key(&self) -> String { + format!( + "{}::{}", + self.owner_class + .as_deref() + .unwrap_or("") + .to_ascii_lowercase(), + self.name_key() + ) + } +} + fn to_breakdown(candidate: &RankedCandidate<'_>) -> FunctionPriorityBreakdown { FunctionPriorityBreakdown { - function_id: candidate.func.id, - function_name: candidate.func.name.clone(), - owner_class: candidate.func.owner_class.clone(), + function_id: u64::from(candidate.func.id.0), + function_name: candidate.func.name_text().map(str::to_string), + owner_class: candidate.owner_class.clone(), library_uri: candidate.library_uri.clone(), - entry_va: candidate.func.entry_va, + entry_va: candidate.entry_va(), total_score: candidate.score, components: candidate .components @@ -1193,36 +1161,40 @@ fn to_breakdown(candidate: &RankedCandidate<'_>) -> FunctionPriorityBreakdown { fn rank_candidates<'a>( model: &'a ProgramModel, + hints: &ProgramHints, iso_instr: &[u8], iso_base_va: u64, focus_prefix: Option<&str>, max_functions: Option, preferred_packages: &[String], ) -> Vec> { - let owner_library = build_owner_library_lookup(model); - let target_va_hints = build_target_va_priority_hints(model); + let target_va_hints = build_target_va_priority_hints(model, hints); let preferred_package_set = preferred_packages .iter() .filter_map(|v| normalize_package_filter(v)) .collect::>(); - let app_package_boosts = - build_app_package_boosts(model, &owner_library, &preferred_package_set); + let app_package_boosts = build_app_package_boosts(model, &preferred_package_set); let candidates = model .functions .iter() .enumerate() .filter(|(_, f)| { - if let Some(prefix) = focus_prefix { - f.name.starts_with(prefix) || f.owner_class.starts_with(prefix) - } else { - true - } + let Some(prefix) = focus_prefix else { + return true; + }; + // A focus prefix filters on recovered names only. An unnamed + // function matches nothing rather than matching a synthesized label. + f.name_text().is_some_and(|n| n.starts_with(prefix)) + || model.owner_name(f).is_some_and(|o| o.starts_with(prefix)) }) .collect::>(); let mut name_occurrences: HashMap = HashMap::new(); for (_, func) in &candidates { + let Some(name) = func.name_text() else { + continue; + }; *name_occurrences - .entry(func.name.to_ascii_lowercase()) + .entry(name.to_ascii_lowercase()) .or_insert(0) += 1; } let (frontier_scores, call_out_degree) = if max_functions.is_some() { @@ -1243,38 +1215,42 @@ fn rank_candidates<'a>( let mut ranked = candidates .into_iter() .map(|(index, func)| { - let library_uri = owner_library - .get(&func.owner_class) - .cloned() - .unwrap_or_default(); + let owner_class = model.owner_name(func).map(str::to_string); + let library_uri = function_library_uri(model, func).map(str::to_string); + let out_degree = call_out_degree + .get(&func.code.start_va) + .copied() + .unwrap_or(0); if max_functions.is_some() { let (score, components) = function_priority( + model, func, - &owner_library, &target_va_hints, &app_package_boosts, &preferred_package_set, &frontier_scores, FunctionScoreStats { - call_out_degree: call_out_degree.get(&func.entry_va).copied().unwrap_or(0), - name_occurrences: name_occurrences - .get(&func.name.to_ascii_lowercase()) - .copied() + call_out_degree: out_degree, + name_occurrences: func + .name_text() + .and_then(|n| name_occurrences.get(&n.to_ascii_lowercase()).copied()) .unwrap_or(1), }, ); RankedCandidate { index, func, + owner_class, library_uri, score, - out_degree: call_out_degree.get(&func.entry_va).copied().unwrap_or(0), + out_degree, components, } } else { RankedCandidate { index, func, + owner_class, library_uri, score: 0, out_degree: 0, @@ -1288,7 +1264,7 @@ fn rank_candidates<'a>( ranked.sort_by(|a, b| { b.score .cmp(&a.score) - .then(b.func.size.cmp(&a.func.size)) + .then(b.func.code.size.cmp(&a.func.code.size)) .then(b.out_degree.cmp(&a.out_degree)) .then(a.index.cmp(&b.index)) }); @@ -1305,17 +1281,17 @@ fn collect_bootflow_seed_entry_vas( for category in BOOTFLOW_SEED_CATEGORY_ORDER { let Some(candidate) = ranked.iter().find(|candidate| { - if selected.contains(&candidate.func.entry_va) { + if selected.contains(&candidate.entry_va()) { return false; } target_va_bootflow_categories - .get(&candidate.func.entry_va) + .get(&candidate.entry_va()) .is_some_and(|categories| categories.contains(category)) }) else { continue; }; - selected.insert(candidate.func.entry_va); - out.push(candidate.func.entry_va); + selected.insert(candidate.entry_va()); + out.push(candidate.entry_va()); } out @@ -1323,6 +1299,7 @@ fn collect_bootflow_seed_entry_vas( pub fn rank_program_functions( model: &ProgramModel, + hints: &ProgramHints, iso_instr: &[u8], iso_base_va: u64, focus_prefix: Option<&str>, @@ -1330,6 +1307,7 @@ pub fn rank_program_functions( ) -> Vec { rank_candidates( model, + hints, iso_instr, iso_base_va, focus_prefix, @@ -1343,6 +1321,7 @@ pub fn rank_program_functions( pub fn disassemble_program( model: &ProgramModel, + hints: &ProgramHints, iso_instr: &[u8], iso_base_va: u64, focus_prefix: Option<&str>, @@ -1350,6 +1329,7 @@ pub fn disassemble_program( ) -> Vec { disassemble_program_with_priorities_and_package_hints( model, + hints, iso_instr, iso_base_va, focus_prefix, @@ -1360,8 +1340,26 @@ pub fn disassemble_program( .0 } +/// The pool geometry a pool reference may be resolved through. +/// +/// `None` unless the producer claimed a hardware index space *and* supplied the +/// layout. An ordinal pool resolves to nothing, which is why disassembly prints +/// `poolOff[...]` rather than attaching whichever string happened to sit at that +/// position in the producer's list. +fn resolvable_pool_geometry(model: &ProgramModel) -> Option { + match model.object_pool.index_space { + PoolIndexSpace::Hardware => model.object_pool.geometry, + PoolIndexSpace::Ordinal => None, + } +} + +// Eight parameters, all of them independent inputs a caller genuinely chooses: +// bundling them into a struct would move the same list one level down without +// making any call site clearer. +#[allow(clippy::too_many_arguments)] pub fn disassemble_program_with_priorities_and_package_hints( model: &ProgramModel, + hints: &ProgramHints, iso_instr: &[u8], iso_base_va: u64, focus_prefix: Option<&str>, @@ -1372,9 +1370,10 @@ pub fn disassemble_program_with_priorities_and_package_hints( let mut out = Vec::new(); let mut priorities = Vec::new(); let cs = build_capstone(); - let pool_geometry = model.pool_geometry; + let pool_geometry = resolvable_pool_geometry(model); let ranked = rank_candidates( model, + hints, iso_instr, iso_base_va, focus_prefix, @@ -1387,7 +1386,7 @@ pub fn disassemble_program_with_priorities_and_package_hints( const DIVERSITY_FIRST_PASS_MAX_PER_OWNER_NAME: usize = 1; let bootflow_seed_entry_vas = if seed_bootflow_categories { - let target_va_bootflow_categories = build_target_va_bootflow_categories(model); + let target_va_bootflow_categories = build_target_va_bootflow_categories(model, hints); collect_bootflow_seed_entry_vas(&ranked, &target_va_bootflow_categories) } else { Vec::new() @@ -1404,11 +1403,12 @@ pub fn disassemble_program_with_priorities_and_package_hints( } let Some(candidate) = ranked .iter() - .find(|candidate| candidate.func.entry_va == seed_entry_va) + .find(|candidate| candidate.entry_va() == seed_entry_va) else { continue; }; if let Some(d) = decode_function( + model, candidate.func, iso_instr, iso_base_va, @@ -1418,15 +1418,11 @@ pub fn disassemble_program_with_priorities_and_package_hints( out.push(d); priorities.push(to_breakdown(candidate)); selected_entry_vas.insert(seed_entry_va); - let name_key = candidate.func.name.to_ascii_lowercase(); - let owner_name_key = format!( - "{}::{}", - candidate.func.owner_class.to_ascii_lowercase(), - name_key - ); - *selected_name_counts.entry(name_key).or_insert(0) += 1; + *selected_name_counts + .entry(candidate.name_key()) + .or_insert(0) += 1; *selected_owner_name_counts - .entry(owner_name_key) + .entry(candidate.owner_name_key()) .or_insert(0) += 1; } } @@ -1435,27 +1431,28 @@ pub fn disassemble_program_with_priorities_and_package_hints( if out.len() >= max { break; } - if selected_entry_vas.contains(&candidate.func.entry_va) { + if selected_entry_vas.contains(&candidate.entry_va()) { continue; } - let name_key = candidate.func.name.to_ascii_lowercase(); - let owner_name_key = format!( - "{}::{}", - candidate.func.owner_class.to_ascii_lowercase(), - name_key - ); + let name_key = candidate.name_key(); + let owner_name_key = candidate.owner_name_key(); + // Unnamed functions have no name to be diverse about, so the + // per-name caps do not apply to them. + let capped = !name_key.is_empty(); let name_seen = selected_name_counts.get(&name_key).copied().unwrap_or(0); let owner_name_seen = selected_owner_name_counts .get(&owner_name_key) .copied() .unwrap_or(0); - if name_seen >= DIVERSITY_FIRST_PASS_MAX_PER_NAME - || owner_name_seen >= DIVERSITY_FIRST_PASS_MAX_PER_OWNER_NAME + if capped + && (name_seen >= DIVERSITY_FIRST_PASS_MAX_PER_NAME + || owner_name_seen >= DIVERSITY_FIRST_PASS_MAX_PER_OWNER_NAME) { deferred.push(candidate); continue; } if let Some(d) = decode_function( + model, candidate.func, iso_instr, iso_base_va, @@ -1464,7 +1461,7 @@ pub fn disassemble_program_with_priorities_and_package_hints( ) { out.push(d); priorities.push(to_breakdown(&candidate)); - selected_entry_vas.insert(candidate.func.entry_va); + selected_entry_vas.insert(candidate.entry_va()); *selected_name_counts.entry(name_key).or_insert(0) += 1; *selected_owner_name_counts .entry(owner_name_key) @@ -1476,10 +1473,11 @@ pub fn disassemble_program_with_priorities_and_package_hints( if out.len() >= max { break; } - if selected_entry_vas.contains(&candidate.func.entry_va) { + if selected_entry_vas.contains(&candidate.entry_va()) { continue; } if let Some(d) = decode_function( + model, candidate.func, iso_instr, iso_base_va, @@ -1488,12 +1486,13 @@ pub fn disassemble_program_with_priorities_and_package_hints( ) { out.push(d); priorities.push(to_breakdown(&candidate)); - selected_entry_vas.insert(candidate.func.entry_va); + selected_entry_vas.insert(candidate.entry_va()); } } } else { for candidate in ranked { if let Some(d) = decode_function( + model, candidate.func, iso_instr, iso_base_va, @@ -1511,6 +1510,7 @@ pub fn disassemble_program_with_priorities_and_package_hints( pub fn disassemble_program_with_priorities( model: &ProgramModel, + hints: &ProgramHints, iso_instr: &[u8], iso_base_va: u64, focus_prefix: Option<&str>, @@ -1518,6 +1518,7 @@ pub fn disassemble_program_with_priorities( ) -> (Vec, Vec) { disassemble_program_with_priorities_and_package_hints( model, + hints, iso_instr, iso_base_va, focus_prefix, @@ -1530,7 +1531,198 @@ pub fn disassemble_program_with_priorities( #[cfg(test)] mod tests { use super::*; - use flutterdec_adapter::{ClassInfo, LibraryInfo, ObjectPoolEntry}; + use flutterdec_adapter::model::{ + Capabilities, CapabilityLevel, Class, ClassId, CodeRange, CompatibilityBinding, Function, + FunctionId, InputRegion, InputRegionName, Library, LibraryId, Name, ObjectPool, + ObservedInput, PoolEntry, PoolEntryKind, PoolIndexSpace, Producer, ProducerTrust, + Provenance, MODEL_VERSION, + }; + use flutterdec_adapter::primitives::Sha256Digest; + use flutterdec_loader::identity::{SnapshotIdentity, SnapshotKind, TargetArch}; + + /// Fixture builders for v4 models. + /// + /// The disassembler never validates a model, so these skip the host-selected + /// bookkeeping that [`flutterdec_adapter::validate`] checks and populate only + /// what ranking and decoding read. What they cannot do is invent a name or an + /// owner: an unnamed function is `fun(id, None, ...)`, which is the case these + /// tests exist to cover. + fn lib(id: u32, uri: &str) -> Library { + Library { + id: LibraryId(id), + uri: uri.to_string(), + display_name: None, + provenance: Provenance::Exact, + } + } + + fn cls(id: u32, name: &str, library: Option) -> Class { + Class { + id: ClassId(id), + name: name.to_string(), + library: library.map(LibraryId), + super_class: None, + provenance: Provenance::Exact, + } + } + + fn fun(id: u32, name: Option<&str>, owner: Option, start_va: u64, size: u64) -> Function { + Function { + id: FunctionId(id), + name: name.map(Name::exact), + owner: owner.map(ClassId), + code: CodeRange { start_va, size }, + code_section_va: start_va, + provenance: Provenance::Exact, + } + } + + fn pool_string(index: u64, value: &str) -> PoolEntry { + PoolEntry { + index, + kind: PoolEntryKind::String, + value: Some(value.to_string()), + target_va: None, + provenance: Provenance::Exact, + confidence: None, + } + } + + fn pool_selector(index: u64, selector: &str, target_va: u64) -> PoolEntry { + PoolEntry { + index, + kind: PoolEntryKind::Selector, + value: Some(selector.to_string()), + target_va: Some(target_va), + provenance: Provenance::Exact, + confidence: None, + } + } + + fn ordinal_pool(entries: Vec) -> ObjectPool { + ObjectPool { + index_space: PoolIndexSpace::Ordinal, + geometry: None, + entries, + } + } + + fn hardware_pool(entries: Vec) -> ObjectPool { + ObjectPool { + index_space: PoolIndexSpace::Hardware, + geometry: Some(ARM64_POOL_GEOMETRY), + entries, + } + } + + fn hint( + kind: HintKind, + origin: HintOrigin, + selector: &str, + target_va: Option, + owner_class: Option<&str>, + library_uri: Option<&str>, + ) -> Hint { + Hint { + kind, + origin, + provenance: HintProvenance::Heuristic, + selector: selector.to_string(), + target_va, + owner_class: owner_class.map(str::to_string), + library_uri: library_uri.map(str::to_string), + detail: String::new(), + } + } + + fn program_hints(entries: Vec) -> ProgramHints { + let mut hints = ProgramHints::new(); + for entry in entries { + hints.push(entry); + } + hints + } + + /// Score one function the way `rank_candidates` would, resolving its owner + /// and library through the model instead of through a name-keyed side table. + fn score( + model: &ProgramModel, + id: u32, + frontier: &HashMap, + preferred: &HashSet, + ) -> (i32, Vec<(String, i32)>) { + let func = model + .functions + .iter() + .find(|f| f.id == FunctionId(id)) + .expect("fixture function"); + function_priority( + model, + func, + &HashMap::new(), + &HashMap::new(), + preferred, + frontier, + FunctionScoreStats { + call_out_degree: 0, + name_occurrences: 1, + }, + ) + } + + fn test_model( + libraries: Vec, + classes: Vec, + functions: Vec, + object_pool: ObjectPool, + ) -> ProgramModel { + let digest = Sha256Digest::of(b"disasm fixture"); + ProgramModel { + model_version: MODEL_VERSION, + producer: Producer { + id: "disasm-fixture".to_string(), + version: "0".to_string(), + artifact_sha256: digest.clone(), + trust: ProducerTrust::Untrusted, + }, + input: ObservedInput { + identity: SnapshotIdentity::from_header( + TargetArch::Arm64, + "80a49c7111088100a233b2ae788e1f48", + SnapshotKind::FullAot, + "product arm64 compressed-pointers", + ), + regions: vec![InputRegion { + region: InputRegionName::IsolateInstructions, + size: u64::MAX / 2, + sha256: digest.clone(), + virtual_address: Some(0), + executable: true, + }], + }, + compatibility: CompatibilityBinding { + record_sha256: digest.clone(), + parser_family_id: "fixture".to_string(), + profile_id: "fixture".to_string(), + profile_sha256: digest, + }, + capabilities: Capabilities { + libraries: CapabilityLevel::Partial, + classes: CapabilityLevel::Partial, + class_relationships: CapabilityLevel::Unavailable, + functions: CapabilityLevel::Partial, + function_names: CapabilityLevel::Partial, + object_pool: CapabilityLevel::Partial, + pool_index_space: CapabilityLevel::Unavailable, + }, + libraries, + classes, + functions, + object_pool, + diagnostics: Vec::new(), + extensions: Default::default(), + } + } /// Word encodings lifted from a real Dart 3.9.2 `libapp.so`; ground-truth pool /// indices were cross-checked against an independent ObjectPool decoder. @@ -1545,35 +1737,16 @@ mod tests { } fn pool_probe_model(pool_geometry: Option) -> ProgramModel { - ProgramModel { - schema_version: 3, - adapter_kind: "test".to_string(), - dart_version: "3.9.2".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![FunctionInfo { - id: 0, - name: "poolProbe".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 16, - code_section_va: 0x1000, - name_kind: None, - }], - object_pool: Vec::new(), - pool_geometry, - } + let pool = match pool_geometry { + Some(_) => hardware_pool(Vec::new()), + None => ordinal_pool(Vec::new()), + }; + test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![fun(0, Some("poolProbe"), Some(0), 0x1000, 16)], + pool, + ) } fn annotations_for_words( @@ -1581,9 +1754,9 @@ mod tests { geometry: Option, ) -> Vec<(String, String)> { let mut model = pool_probe_model(geometry); - model.functions[0].size = (words.len() * 4) as u64; + model.functions[0].code.size = (words.len() * 4) as u64; let bytes: Vec = words.iter().flat_map(|w| w.to_le_bytes()).collect(); - let d = disassemble_program(&model, &bytes, 0x1000, None, None); + let d = disassemble_program(&model, &ProgramHints::new(), &bytes, 0x1000, None, None); d.first() .map(|f| { f.instructions @@ -1728,748 +1901,296 @@ mod tests { #[test] fn disassembles_simple_function() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![FunctionInfo { - id: 0, - name: "entry".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 8, - code_section_va: 0x1000, - name_kind: None, - }], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![fun(0, Some("entry"), Some(0), 0x1000, 8)], + ordinal_pool(vec![pool_string(0, "x")]), + ); + let hints = program_hints(vec![]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x1000, None, None); + let d = disassemble_program(&model, &hints, &bytes, 0x1000, None, None); assert_eq!(d.len(), 1); assert_eq!(d[0].instructions[0].mnemonic, "ret"); } #[test] fn prioritizes_main_like_name_when_max_functions_is_limited() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![ - LibraryInfo { - id: 0, - uri: "package:flutter/src/widgets/binding.dart".to_string(), - name_display: "package:flutter/src/widgets/binding.dart".to_string(), - }, - LibraryInfo { - id: 1, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }, + let model = test_model( + vec![ + lib(0, "package:flutter/src/widgets/binding.dart"), + lib(1, "package:app/main.dart"), ], - classes: vec![ - ClassInfo { - id: 0, - name: "WidgetsBinding".to_string(), - super_name: "Object".to_string(), - library_uri: "package:flutter/src/widgets/binding.dart".to_string(), - }, - ClassInfo { - id: 1, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }, + vec![ + cls(0, "WidgetsBinding", Some(0)), + cls(1, "AppRoot", Some(1)), ], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_1000".to_string(), - owner_class: "WidgetsBinding".to_string(), - entry_va: 0x1000, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "main".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1004, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, + vec![ + fun(0, None, Some(0), 0x1000, 4), + fun(1, Some("main"), Some(1), 0x1004, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![pool_string(0, "x")]), + ); + let hints = program_hints(vec![]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x1000, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x1000, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "main"); + assert_eq!(d[0].function_name.as_deref(), Some("main")); } #[test] fn prioritizes_app_main_library_for_generic_names_when_limited() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![ - LibraryInfo { - id: 0, - uri: "package:flutter/src/widgets/heroes.dart".to_string(), - name_display: "package:flutter/src/widgets/heroes.dart".to_string(), - }, - LibraryInfo { - id: 1, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }, + let model = test_model( + vec![ + lib(0, "package:flutter/src/widgets/heroes.dart"), + lib(1, "package:app/main.dart"), ], - classes: vec![ - ClassInfo { - id: 0, - name: "RenderErrorBox".to_string(), - super_name: "Object".to_string(), - library_uri: "package:flutter/src/widgets/heroes.dart".to_string(), - }, - ClassInfo { - id: 1, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }, + vec![ + cls(0, "RenderErrorBox", Some(0)), + cls(1, "AppRoot", Some(1)), ], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_a000".to_string(), - owner_class: "RenderErrorBox".to_string(), - entry_va: 0x2000, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_b000".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x2004, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, + vec![ + fun(0, None, Some(0), 0x2000, 4), + fun(1, None, Some(1), 0x2004, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![pool_string(0, "x")]), + ); + let hints = program_hints(vec![]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x2000, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x2000, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "sub_b000"); + assert_eq!(d[0].entry_va, 0x2004); + assert_eq!(d[0].function_name, None); } #[test] fn prioritizes_deeplink_and_activity_handler_names_when_limited() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/navigation.dart".to_string(), - name_display: "package:app/navigation.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "RouterHost".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/navigation.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_3000".to_string(), - owner_class: "RouterHost".to_string(), - entry_va: 0x3000, - size: 4, - code_section_va: 0x3000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "handleIncomingIntent".to_string(), - owner_class: "RouterHost".to_string(), - entry_va: 0x3004, - size: 4, - code_section_va: 0x3000, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/navigation.dart")], + vec![cls(0, "RouterHost", Some(0))], + vec![ + fun(0, None, Some(0), 0x3000, 4), + fun(1, Some("handleIncomingIntent"), Some(0), 0x3004, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![pool_string(0, "x")]), + ); + let hints = program_hints(vec![]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x3000, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x3000, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "handleIncomingIntent"); + assert_eq!(d[0].function_name.as_deref(), Some("handleIncomingIntent")); } #[test] fn prioritizes_pool_target_va_with_deeplink_selector_when_limited() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_4000".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x4000, - size: 4, - code_section_va: 0x4000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_4004".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x4004, - size: 4, - code_section_va: 0x4000, - name_kind: None, - }, - ], - pool_geometry: None, - object_pool: vec![ - ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "android.intent.action.VIEW".to_string(), - decoded_kind: Some("selector".to_string()), - selector: Some("onNewIntent".to_string()), - target_va: Some(0x4004), - owner_class: Some("MainActivity".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: None, - }, - ObjectPoolEntry { - index: 1, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, None, Some(0), 0x4000, 4), + fun(1, None, Some(0), 0x4004, 4), ], - }; + ordinal_pool(vec![ + pool_selector(0, "onNewIntent", 0x4004), + pool_string(1, "x"), + ]), + ); + let hints = program_hints(vec![]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x4000, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x4000, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "sub_4004"); + assert_eq!(d[0].entry_va, 0x4004); + assert_eq!(d[0].function_name, None); } #[test] fn prioritizes_entrypoint_candidate_target_va_when_names_are_generic() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_5000".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x5000, - size: 4, - code_section_va: 0x5000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_5004".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x5004, - size: 4, - code_section_va: 0x5000, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, None, Some(0), 0x5000, 4), + fun(1, None, Some(0), 0x5004, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "entrypoint:main".to_string(), - decoded_kind: Some("EntryPointCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x5004), - owner_class: Some("Global".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![]), + ); + let hints = program_hints(vec![hint( + HintKind::EntryPoint, + HintOrigin::ModelNamePattern, + "main", + Some(0x5004), + Some("AppRoot"), + Some("package:app/main.dart"), + )]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x5000, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x5000, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "sub_5004"); + assert_eq!(d[0].entry_va, 0x5004); + assert_eq!(d[0].function_name, None); } #[test] fn prioritizes_boot_main_candidate_target_va_when_names_are_generic() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_50a0".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x50a0, - size: 4, - code_section_va: 0x50a0, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_50a4".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x50a4, - size: 4, - code_section_va: 0x50a0, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, None, Some(0), 0x50a0, 4), + fun(1, None, Some(0), 0x50a4, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "bootflow:main:main".to_string(), - decoded_kind: Some("BootMainCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x50a4), - owner_class: Some("Global".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![]), + ); + let hints = program_hints(vec![hint( + HintKind::BootMain, + HintOrigin::ModelNamePattern, + "main", + Some(0x50a4), + Some("AppRoot"), + Some("package:app/main.dart"), + )]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x50a0, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x50a0, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "sub_50a4"); + assert_eq!(d[0].entry_va, 0x50a4); + assert_eq!(d[0].function_name, None); } #[test] fn prioritizes_manifest_main_candidate_target_va_when_names_are_generic() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_50aa".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x50aa, - size: 4, - code_section_va: 0x50aa, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_50ae".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x50ae, - size: 4, - code_section_va: 0x50aa, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, None, Some(0), 0x50aa, 4), + fun(1, None, Some(0), 0x50ae, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "manifest:main-launcher".to_string(), - decoded_kind: Some("ManifestMainCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x50ae), - owner_class: Some("Global".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![]), + ); + let hints = program_hints(vec![hint( + HintKind::BootMain, + HintOrigin::AndroidManifest, + "main", + Some(0x50ae), + Some("AppRoot"), + Some("package:app/main.dart"), + )]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x50aa, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x50aa, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "sub_50ae"); + assert_eq!(d[0].entry_va, 0x50ae); + assert_eq!(d[0].function_name, None); } #[test] fn prioritizes_deeplink_candidate_target_va_when_names_are_generic() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/router.dart".to_string(), - name_display: "package:app/router.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "RouterHost".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/router.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_50b0".to_string(), - owner_class: "RouterHost".to_string(), - entry_va: 0x50b0, - size: 4, - code_section_va: 0x50b0, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_50b4".to_string(), - owner_class: "RouterHost".to_string(), - entry_va: 0x50b4, - size: 4, - code_section_va: 0x50b0, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/router.dart")], + vec![cls(0, "RouterHost", Some(0))], + vec![ + fun(0, None, Some(0), 0x50b0, 4), + fun(1, None, Some(0), 0x50b4, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "bootflow:deeplink:onNewIntent".to_string(), - decoded_kind: Some("DeepLinkHandlerCandidate".to_string()), - selector: Some("onNewIntent".to_string()), - target_va: Some(0x50b4), - owner_class: Some("RouterHost".to_string()), - library_uri: Some("package:app/router.dart".to_string()), - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![]), + ); + let hints = program_hints(vec![hint( + HintKind::DeepLinkHandler, + HintOrigin::ModelNamePattern, + "onNewIntent", + Some(0x50b4), + Some("RouterHost"), + Some("package:app/router.dart"), + )]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x50b0, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x50b0, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "sub_50b4"); + assert_eq!(d[0].entry_va, 0x50b4); + assert_eq!(d[0].function_name, None); } #[test] fn prefers_app_deeplink_candidate_over_framework_deeplink_candidate() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![ - LibraryInfo { - id: 0, - uri: "package:app/router.dart".to_string(), - name_display: "package:app/router.dart".to_string(), - }, - LibraryInfo { - id: 1, - uri: "package:flutter/src/widgets/app.dart".to_string(), - name_display: "package:flutter/src/widgets/app.dart".to_string(), - }, - ], - classes: vec![ - ClassInfo { - id: 0, - name: "AppRouterHost".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/router.dart".to_string(), - }, - ClassInfo { - id: 1, - name: "WidgetsBindingObserver".to_string(), - super_name: "Object".to_string(), - library_uri: "package:flutter/src/widgets/app.dart".to_string(), - }, + let model = test_model( + vec![ + lib(0, "package:app/router.dart"), + lib(1, "package:flutter/src/widgets/app.dart"), ], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_50c0".to_string(), - owner_class: "AppRouterHost".to_string(), - entry_va: 0x50c0, - size: 4, - code_section_va: 0x50c0, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_50c4".to_string(), - owner_class: "WidgetsBindingObserver".to_string(), - entry_va: 0x50c4, - size: 4, - code_section_va: 0x50c0, - name_kind: None, - }, + vec![ + cls(0, "AppRouterHost", Some(0)), + cls(1, "WidgetsBindingObserver", Some(1)), ], - pool_geometry: None, - object_pool: vec![ - ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "bootflow:deeplink:onNewIntent".to_string(), - decoded_kind: Some("DeepLinkHandlerCandidate".to_string()), - selector: Some("onNewIntent".to_string()), - target_va: Some(0x50c0), - owner_class: Some("AppRouterHost".to_string()), - library_uri: Some("package:app/router.dart".to_string()), - confidence: None, - source: None, - }, - ObjectPoolEntry { - index: 1, - kind: "String".to_string(), - value: "bootflow:deeplink:didPushRouteInformation".to_string(), - decoded_kind: Some("DeepLinkHandlerCandidate".to_string()), - selector: Some("didPushRouteInformation".to_string()), - target_va: Some(0x50c4), - owner_class: Some("WidgetsBindingObserver".to_string()), - library_uri: Some("package:flutter/src/widgets/app.dart".to_string()), - confidence: None, - source: None, - }, + vec![ + fun(0, None, Some(0), 0x50c0, 4), + fun(1, None, Some(1), 0x50c4, 4), ], - }; + ordinal_pool(vec![]), + ); + let hints = program_hints(vec![ + hint( + HintKind::DeepLinkHandler, + HintOrigin::ModelNamePattern, + "onNewIntent", + Some(0x50c0), + Some("AppRouterHost"), + Some("package:app/router.dart"), + ), + hint( + HintKind::DeepLinkHandler, + HintOrigin::ModelNamePattern, + "didPushRouteInformation", + Some(0x50c4), + Some("WidgetsBindingObserver"), + Some("package:flutter/src/widgets/app.dart"), + ), + ]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x50c0, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x50c0, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "sub_50c0"); + assert_eq!(d[0].entry_va, 0x50c0); + assert_eq!(d[0].function_name, None); } #[test] fn seeds_bootflow_categories_in_capped_selection() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/router.dart".to_string(), - name_display: "package:app/router.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "RouterHost".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/router.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_5200".to_string(), - owner_class: "RouterHost".to_string(), - entry_va: 0x5200, - size: 4, - code_section_va: 0x5200, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_5204".to_string(), - owner_class: "RouterHost".to_string(), - entry_va: 0x5204, - size: 4, - code_section_va: 0x5200, - name_kind: None, - }, - FunctionInfo { - id: 2, - name: "sub_5208".to_string(), - owner_class: "RouterHost".to_string(), - entry_va: 0x5208, - size: 4, - code_section_va: 0x5200, - name_kind: None, - }, - ], - pool_geometry: None, - object_pool: vec![ - ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "bootflow:main:main".to_string(), - decoded_kind: Some("BootMainCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x5200), - owner_class: Some("RouterHost".to_string()), - library_uri: Some("package:app/router.dart".to_string()), - confidence: None, - source: None, - }, - ObjectPoolEntry { - index: 1, - kind: "String".to_string(), - value: "bootflow:main:main".to_string(), - decoded_kind: Some("BootMainCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x5204), - owner_class: Some("RouterHost".to_string()), - library_uri: Some("package:app/router.dart".to_string()), - confidence: None, - source: None, - }, - ObjectPoolEntry { - index: 2, - kind: "String".to_string(), - value: "bootflow:deeplink:onNewIntent".to_string(), - decoded_kind: Some("DeepLinkHandlerCandidate".to_string()), - selector: Some("onNewIntent".to_string()), - target_va: Some(0x5208), - owner_class: Some("RouterHost".to_string()), - library_uri: Some("package:app/router.dart".to_string()), - confidence: None, - source: None, - }, + let model = test_model( + vec![lib(0, "package:app/router.dart")], + vec![cls(0, "RouterHost", Some(0))], + vec![ + fun(0, None, Some(0), 0x5200, 4), + fun(1, None, Some(0), 0x5204, 4), + fun(2, None, Some(0), 0x5208, 4), ], - }; + ordinal_pool(vec![]), + ); + let hints = program_hints(vec![ + hint( + HintKind::BootMain, + HintOrigin::ModelNamePattern, + "main", + Some(0x5200), + Some("RouterHost"), + Some("package:app/router.dart"), + ), + hint( + HintKind::BootMain, + HintOrigin::ModelNamePattern, + "main", + Some(0x5204), + Some("RouterHost"), + Some("package:app/router.dart"), + ), + hint( + HintKind::DeepLinkHandler, + HintOrigin::ModelNamePattern, + "onNewIntent", + Some(0x5208), + Some("RouterHost"), + Some("package:app/router.dart"), + ), + ]); let bytes = vec![ 0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6, ]; - let d = disassemble_program(&model, &bytes, 0x5200, None, Some(2)); + let d = disassemble_program(&model, &hints, &bytes, 0x5200, None, Some(2)); assert_eq!(d.len(), 2); let selected = d.iter().map(|f| f.entry_va).collect::>(); assert!( @@ -2484,97 +2205,48 @@ mod tests { #[test] fn can_disable_bootflow_category_seeding() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/router.dart".to_string(), - name_display: "package:app/router.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "RouterHost".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/router.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_5300".to_string(), - owner_class: "RouterHost".to_string(), - entry_va: 0x5300, - size: 4, - code_section_va: 0x5300, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_5304".to_string(), - owner_class: "RouterHost".to_string(), - entry_va: 0x5304, - size: 4, - code_section_va: 0x5300, - name_kind: None, - }, - FunctionInfo { - id: 2, - name: "sub_5308".to_string(), - owner_class: "RouterHost".to_string(), - entry_va: 0x5308, - size: 4, - code_section_va: 0x5300, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/router.dart")], + vec![cls(0, "RouterHost", Some(0))], + vec![ + fun(0, None, Some(0), 0x5300, 4), + fun(1, None, Some(0), 0x5304, 4), + fun(2, None, Some(0), 0x5308, 4), ], - pool_geometry: None, - object_pool: vec![ - ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "bootflow:main:main".to_string(), - decoded_kind: Some("BootMainCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x5300), - owner_class: Some("RouterHost".to_string()), - library_uri: Some("package:app/router.dart".to_string()), - confidence: None, - source: None, - }, - ObjectPoolEntry { - index: 1, - kind: "String".to_string(), - value: "bootflow:main:main".to_string(), - decoded_kind: Some("BootMainCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x5304), - owner_class: Some("RouterHost".to_string()), - library_uri: Some("package:app/router.dart".to_string()), - confidence: None, - source: None, - }, - ObjectPoolEntry { - index: 2, - kind: "String".to_string(), - value: "bootflow:deeplink:onNewIntent".to_string(), - decoded_kind: Some("DeepLinkHandlerCandidate".to_string()), - selector: Some("onNewIntent".to_string()), - target_va: Some(0x5308), - owner_class: Some("RouterHost".to_string()), - library_uri: Some("package:app/router.dart".to_string()), - confidence: None, - source: None, - }, - ], - }; + ordinal_pool(vec![]), + ); + let hints = program_hints(vec![ + hint( + HintKind::BootMain, + HintOrigin::ModelNamePattern, + "main", + Some(0x5300), + Some("RouterHost"), + Some("package:app/router.dart"), + ), + hint( + HintKind::BootMain, + HintOrigin::ModelNamePattern, + "main", + Some(0x5304), + Some("RouterHost"), + Some("package:app/router.dart"), + ), + hint( + HintKind::DeepLinkHandler, + HintOrigin::ModelNamePattern, + "onNewIntent", + Some(0x5308), + Some("RouterHost"), + Some("package:app/router.dart"), + ), + ]); let bytes = vec![ 0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6, ]; let (d, _) = disassemble_program_with_priorities_and_package_hints( &model, + &hints, &bytes, 0x5300, None, @@ -2594,321 +2266,83 @@ mod tests { #[test] fn prioritizes_lifecycle_selector_target_va_when_names_are_generic() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:spotube/main.dart".to_string(), - name_display: "package:spotube/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:spotube/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_5100".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x5100, - size: 4, - code_section_va: 0x5100, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_5104".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x5104, - size: 4, - code_section_va: 0x5100, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:spotube/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, None, Some(0), 0x5100, 4), + fun(1, None, Some(0), 0x5104, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "createState".to_string(), - decoded_kind: Some("BlutterUnlinkedCall".to_string()), - selector: Some("createState".to_string()), - target_va: Some(0x5104), - owner_class: Some("MyApp".to_string()), - library_uri: Some("package:spotube/main.dart".to_string()), - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![pool_selector(0, "createState", 0x5104)]), + ); + let hints = program_hints(vec![]); let bytes = vec![0xc0, 0x03, 0x5f, 0xd6, 0xc0, 0x03, 0x5f, 0xd6]; - let d = disassemble_program(&model, &bytes, 0x5100, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x5100, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "sub_5104"); + assert_eq!(d[0].entry_va, 0x5104); + assert_eq!(d[0].function_name, None); } #[test] fn prioritizes_top_app_package_when_names_are_generic() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![ - LibraryInfo { - id: 0, - uri: "package:other_pkg/core.dart".to_string(), - name_display: "package:other_pkg/core.dart".to_string(), - }, - LibraryInfo { - id: 1, - uri: "package:app_pkg/feature.dart".to_string(), - name_display: "package:app_pkg/feature.dart".to_string(), - }, - ], - classes: vec![ - ClassInfo { - id: 0, - name: "OtherCls".to_string(), - super_name: "Object".to_string(), - library_uri: "package:other_pkg/core.dart".to_string(), - }, - ClassInfo { - id: 1, - name: "AppCls".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app_pkg/feature.dart".to_string(), - }, + let model = test_model( + vec![ + lib(0, "package:other_pkg/core.dart"), + lib(1, "package:app_pkg/feature.dart"), ], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_2000".to_string(), - owner_class: "OtherCls".to_string(), - entry_va: 0x2000, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_2004".to_string(), - owner_class: "AppCls".to_string(), - entry_va: 0x2004, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, - FunctionInfo { - id: 2, - name: "sub_2008".to_string(), - owner_class: "AppCls".to_string(), - entry_va: 0x2008, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, - FunctionInfo { - id: 3, - name: "sub_200c".to_string(), - owner_class: "AppCls".to_string(), - entry_va: 0x200c, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, - FunctionInfo { - id: 4, - name: "sub_2010".to_string(), - owner_class: "AppCls".to_string(), - entry_va: 0x2010, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, - FunctionInfo { - id: 5, - name: "sub_2014".to_string(), - owner_class: "AppCls".to_string(), - entry_va: 0x2014, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, - FunctionInfo { - id: 6, - name: "sub_2018".to_string(), - owner_class: "AppCls".to_string(), - entry_va: 0x2018, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, - FunctionInfo { - id: 7, - name: "sub_201c".to_string(), - owner_class: "AppCls".to_string(), - entry_va: 0x201c, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, - FunctionInfo { - id: 8, - name: "sub_2020".to_string(), - owner_class: "AppCls".to_string(), - entry_va: 0x2020, - size: 4, - code_section_va: 0x2000, - name_kind: None, - }, + vec![cls(0, "OtherCls", Some(0)), cls(1, "AppCls", Some(1))], + vec![ + fun(0, None, Some(0), 0x2000, 4), + fun(1, None, Some(1), 0x2004, 4), + fun(2, None, Some(1), 0x2008, 4), + fun(3, None, Some(1), 0x200c, 4), + fun(4, None, Some(1), 0x2010, 4), + fun(5, None, Some(1), 0x2014, 4), + fun(6, None, Some(1), 0x2018, 4), + fun(7, None, Some(1), 0x201c, 4), + fun(8, None, Some(1), 0x2020, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![pool_string(0, "x")]), + ); + let hints = program_hints(vec![]); let bytes = [0xc0u8, 0x03, 0x5f, 0xd6].repeat(9); - let d = disassemble_program(&model, &bytes, 0x2000, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x2000, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].owner_class, "AppCls"); + assert_eq!(d[0].owner_class.as_deref(), Some("AppCls")); } #[test] fn prioritizes_larger_function_when_names_are_generic_and_scores_tie() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_1000".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 8, - code_section_va: 0x1000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_1010".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1010, - size: 0x100, - code_section_va: 0x1000, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, None, Some(0), 0x1000, 8), + fun(1, None, Some(0), 0x1010, 0x100), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![pool_string(0, "x")]), + ); + let hints = program_hints(vec![]); let bytes = [0xc0u8, 0x03, 0x5f, 0xd6].repeat(68); - let d = disassemble_program(&model, &bytes, 0x1000, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x1000, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "sub_1010"); + assert_eq!(d[0].entry_va, 0x1010); + assert_eq!(d[0].function_name, None); } #[test] fn prioritizes_hub_function_by_call_out_degree_when_names_are_generic() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_1000".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 12, - code_section_va: 0x1000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_1010".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1010, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, - FunctionInfo { - id: 2, - name: "sub_1020".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1020, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, None, Some(0), 0x1000, 12), + fun(1, None, Some(0), 0x1010, 4), + fun(2, None, Some(0), 0x1020, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![pool_string(0, "x")]), + ); + let hints = program_hints(vec![]); let bytes = vec![ 0x04, 0x00, 0x00, 0x94, // bl #0x1010 0x07, 0x00, 0x00, 0x94, // bl #0x1020 @@ -2920,140 +2354,48 @@ mod tests { 0xc0, 0x03, 0x5f, 0xd6, // filler 0xc0, 0x03, 0x5f, 0xd6, // 0x1020 ret ]; - let d = disassemble_program(&model, &bytes, 0x1000, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x1000, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "sub_1000"); + assert_eq!(d[0].entry_va, 0x1000); + assert_eq!(d[0].function_name, None); } #[test] fn penalizes_repeated_named_functions_for_capped_selection() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "processUpdate".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 32, - code_section_va: 0x1000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "processUpdate".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1020, - size: 32, - code_section_va: 0x1000, - name_kind: None, - }, - FunctionInfo { - id: 2, - name: "processUpdate".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1040, - size: 32, - code_section_va: 0x1000, - name_kind: None, - }, - FunctionInfo { - id: 3, - name: "startCLI".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1060, - size: 32, - code_section_va: 0x1000, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, Some("processUpdate"), Some(0), 0x1000, 32), + fun(1, Some("processUpdate"), Some(0), 0x1020, 32), + fun(2, Some("processUpdate"), Some(0), 0x1040, 32), + fun(3, Some("startCLI"), Some(0), 0x1060, 32), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![pool_string(0, "x")]), + ); + let hints = program_hints(vec![]); let bytes = [0xc0u8, 0x03, 0x5f, 0xd6].repeat(40); - let d = disassemble_program(&model, &bytes, 0x1000, None, Some(1)); + let d = disassemble_program(&model, &hints, &bytes, 0x1000, None, Some(1)); assert_eq!(d.len(), 1); - assert_eq!(d[0].function_name, "startCLI"); + assert_eq!(d[0].function_name.as_deref(), Some("startCLI")); } #[test] fn penalizes_no_isolate_markers_in_name_or_owner() { - let clean = FunctionInfo { - id: 0, - name: "sub_6000".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x6000, - size: 32, - code_section_va: 0x6000, - name_kind: None, - }; - let noisy = FunctionInfo { - id: 1, - name: "sub_6010".to_string(), - owner_class: "Global no isolate".to_string(), - entry_va: 0x6010, - size: 32, - code_section_va: 0x6000, - name_kind: None, - }; - - let mut owner_library = HashMap::new(); - owner_library.insert("Global".to_string(), "package:app/main.dart".to_string()); - owner_library.insert( - "Global no isolate".to_string(), - "package:app/main.dart".to_string(), - ); - - let (clean_score, clean_components) = function_priority( - &clean, - &owner_library, - &HashMap::new(), - &HashMap::new(), - &HashSet::new(), - &HashMap::new(), - FunctionScoreStats { - call_out_degree: 0, - name_occurrences: 1, - }, - ); - let (noisy_score, noisy_components) = function_priority( - &noisy, - &owner_library, - &HashMap::new(), - &HashMap::new(), - &HashSet::new(), - &HashMap::new(), - FunctionScoreStats { - call_out_degree: 0, - name_occurrences: 1, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![ + cls(0, "AppRoot", Some(0)), + cls(1, "AppRoot no isolate", Some(0)), + ], + vec![ + fun(0, None, Some(0), 0x6000, 32), + fun(1, None, Some(1), 0x6010, 32), + ], + ordinal_pool(Vec::new()), ); + let (clean_score, clean_components) = score(&model, 0, &HashMap::new(), &HashSet::new()); + let (noisy_score, noisy_components) = score(&model, 1, &HashMap::new(), &HashSet::new()); assert!( noisy_components @@ -3075,59 +2417,24 @@ mod tests { #[test] fn penalizes_dart_isolate_library_more_than_generic_stdlib() { - let isolate_func = FunctionInfo { - id: 0, - name: "sub_6100".to_string(), - owner_class: "IsolateWorker".to_string(), - entry_va: 0x6100, - size: 32, - code_section_va: 0x6100, - name_kind: None, - }; - let core_func = FunctionInfo { - id: 1, - name: "sub_6110".to_string(), - owner_class: "CoreWorker".to_string(), - entry_va: 0x6110, - size: 32, - code_section_va: 0x6100, - name_kind: None, - }; - - let mut owner_library = HashMap::new(); - owner_library.insert( - "IsolateWorker".to_string(), - "dart:isolate-patch/isolate_patch.dart".to_string(), - ); - owner_library.insert( - "CoreWorker".to_string(), - "dart:core-patch/core_patch.dart".to_string(), - ); - - let (isolate_score, isolate_components) = function_priority( - &isolate_func, - &owner_library, - &HashMap::new(), - &HashMap::new(), - &HashSet::new(), - &HashMap::new(), - FunctionScoreStats { - call_out_degree: 0, - name_occurrences: 1, - }, - ); - let (core_score, core_components) = function_priority( - &core_func, - &owner_library, - &HashMap::new(), - &HashMap::new(), - &HashSet::new(), - &HashMap::new(), - FunctionScoreStats { - call_out_degree: 0, - name_occurrences: 1, - }, + let model = test_model( + vec![ + lib(0, "dart:isolate-patch/isolate_patch.dart"), + lib(1, "dart:core-patch/core_patch.dart"), + ], + vec![ + cls(0, "IsolateWorker", Some(0)), + cls(1, "CoreWorker", Some(1)), + ], + vec![ + fun(0, None, Some(0), 0x6100, 32), + fun(1, None, Some(1), 0x6110, 32), + ], + ordinal_pool(Vec::new()), ); + let (isolate_score, isolate_components) = + score(&model, 0, &HashMap::new(), &HashSet::new()); + let (core_score, core_components) = score(&model, 1, &HashMap::new(), &HashSet::new()); assert!( isolate_components @@ -3149,59 +2456,25 @@ mod tests { #[test] fn preferred_package_bonus_beats_non_preferred_package_for_generic_names() { - let preferred_func = FunctionInfo { - id: 0, - name: "sub_7100".to_string(), - owner_class: "SpotubeCore".to_string(), - entry_va: 0x7100, - size: 64, - code_section_va: 0x7000, - name_kind: None, - }; - let dep_func = FunctionInfo { - id: 1, - name: "sub_7200".to_string(), - owner_class: "ProviderCore".to_string(), - entry_va: 0x7200, - size: 64, - code_section_va: 0x7000, - name_kind: None, - }; - let mut owner_library = HashMap::new(); - owner_library.insert( - "SpotubeCore".to_string(), - "package:spotube/main.dart".to_string(), - ); - owner_library.insert( - "ProviderCore".to_string(), - "package:provider/src/provider.dart".to_string(), + let model = test_model( + vec![ + lib(0, "package:spotube/main.dart"), + lib(1, "package:provider/src/provider.dart"), + ], + vec![ + cls(0, "SpotubeCore", Some(0)), + cls(1, "ProviderCore", Some(1)), + ], + vec![ + fun(0, None, Some(0), 0x7100, 64), + fun(1, None, Some(1), 0x7200, 64), + ], + ordinal_pool(Vec::new()), ); let preferred = HashSet::from(["spotube".to_string()]); + let (preferred_score, preferred_components) = score(&model, 0, &HashMap::new(), &preferred); + let (dep_score, dep_components) = score(&model, 1, &HashMap::new(), &preferred); - let (preferred_score, preferred_components) = function_priority( - &preferred_func, - &owner_library, - &HashMap::new(), - &HashMap::new(), - &preferred, - &HashMap::new(), - FunctionScoreStats { - call_out_degree: 0, - name_occurrences: 1, - }, - ); - let (dep_score, dep_components) = function_priority( - &dep_func, - &owner_library, - &HashMap::new(), - &HashMap::new(), - &preferred, - &HashMap::new(), - FunctionScoreStats { - call_out_degree: 0, - name_occurrences: 1, - }, - ); assert!( preferred_components .iter() @@ -3222,58 +2495,24 @@ mod tests { #[test] fn app_bootstrap_context_outranks_framework_bootstrap_context() { - let app_func = FunctionInfo { - id: 0, - name: "ensureInitialized".to_string(), - owner_class: "AppBootstrap".to_string(), - entry_va: 0x7300, - size: 64, - code_section_va: 0x7300, - name_kind: None, - }; - let framework_func = FunctionInfo { - id: 1, - name: "ensureInitialized".to_string(), - owner_class: "WidgetsFlutterBinding".to_string(), - entry_va: 0x7310, - size: 64, - code_section_va: 0x7300, - name_kind: None, - }; - let mut owner_library = HashMap::new(); - owner_library.insert( - "AppBootstrap".to_string(), - "package:app/main.dart".to_string(), - ); - owner_library.insert( - "WidgetsFlutterBinding".to_string(), - "package:flutter/src/widgets/binding.dart".to_string(), - ); - - let (app_score, app_components) = function_priority( - &app_func, - &owner_library, - &HashMap::new(), - &HashMap::new(), - &HashSet::new(), - &HashMap::new(), - FunctionScoreStats { - call_out_degree: 0, - name_occurrences: 1, - }, - ); - let (framework_score, framework_components) = function_priority( - &framework_func, - &owner_library, - &HashMap::new(), - &HashMap::new(), - &HashSet::new(), - &HashMap::new(), - FunctionScoreStats { - call_out_degree: 0, - name_occurrences: 1, - }, + let model = test_model( + vec![ + lib(0, "package:app/main.dart"), + lib(1, "package:flutter/src/widgets/binding.dart"), + ], + vec![ + cls(0, "AppBootstrap", Some(0)), + cls(1, "WidgetsFlutterBinding", Some(1)), + ], + vec![ + fun(0, Some("ensureInitialized"), Some(0), 0x7300, 64), + fun(1, Some("ensureInitialized"), Some(1), 0x7310, 64), + ], + ordinal_pool(Vec::new()), ); + let (app_score, app_components) = score(&model, 0, &HashMap::new(), &HashSet::new()); + let (framework_score, framework_components) = + score(&model, 1, &HashMap::new(), &HashSet::new()); assert!(app_components .iter() @@ -3289,56 +2528,21 @@ mod tests { #[test] fn app_frontier_context_outranks_framework_frontier_context() { - let app_func = FunctionInfo { - id: 0, - name: "sub_7400".to_string(), - owner_class: "AppRoot".to_string(), - entry_va: 0x7400, - size: 64, - code_section_va: 0x7400, - name_kind: None, - }; - let framework_func = FunctionInfo { - id: 1, - name: "sub_7410".to_string(), - owner_class: "FrameworkRoot".to_string(), - entry_va: 0x7410, - size: 64, - code_section_va: 0x7400, - name_kind: None, - }; - let mut owner_library = HashMap::new(); - owner_library.insert("AppRoot".to_string(), "package:app/main.dart".to_string()); - owner_library.insert( - "FrameworkRoot".to_string(), - "package:flutter/src/widgets/app.dart".to_string(), - ); - let frontier_scores = HashMap::from([(0x7400, 900), (0x7410, 900)]); - - let (app_score, app_components) = function_priority( - &app_func, - &owner_library, - &HashMap::new(), - &HashMap::new(), - &HashSet::new(), - &frontier_scores, - FunctionScoreStats { - call_out_degree: 0, - name_occurrences: 1, - }, - ); - let (framework_score, framework_components) = function_priority( - &framework_func, - &owner_library, - &HashMap::new(), - &HashMap::new(), - &HashSet::new(), - &frontier_scores, - FunctionScoreStats { - call_out_degree: 0, - name_occurrences: 1, - }, + let model = test_model( + vec![ + lib(0, "package:app/main.dart"), + lib(1, "package:flutter/src/widgets/app.dart"), + ], + vec![cls(0, "AppRoot", Some(0)), cls(1, "FrameworkRoot", Some(1))], + vec![ + fun(0, None, Some(0), 0x7400, 64), + fun(1, None, Some(1), 0x7410, 64), + ], + ordinal_pool(Vec::new()), ); + let frontier = HashMap::from([(0x7400, 900), (0x7410, 900)]); + let (app_score, app_components) = score(&model, 0, &frontier, &HashSet::new()); + let (framework_score, framework_components) = score(&model, 1, &frontier, &HashSet::new()); assert!(app_components .iter() @@ -3355,53 +2559,35 @@ mod tests { #[test] fn preferred_package_boost_overrides_count_only_package_ranking() { let mut functions = Vec::new(); - for i in 0..20u64 { - functions.push(FunctionInfo { - id: i, - name: format!("dep_{i}"), - owner_class: "ProviderCore".to_string(), - entry_va: 0x8000 + (i * 4), - size: 32, - code_section_va: 0x8000, - name_kind: None, - }); + for i in 0..20u32 { + functions.push(fun( + i, + Some(&format!("dep_{i}")), + Some(1), + 0x8000 + u64::from(i) * 4, + 32, + )); } - for i in 0..3u64 { - functions.push(FunctionInfo { - id: 100 + i, - name: format!("app_{i}"), - owner_class: "AppCore".to_string(), - entry_va: 0x9000 + (i * 4), - size: 32, - code_section_va: 0x8000, - name_kind: None, - }); + for i in 0..3u32 { + functions.push(fun( + 100 + i, + Some(&format!("app_{i}")), + Some(0), + 0x9000 + u64::from(i) * 4, + 32, + )); } - - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: Vec::new(), - classes: Vec::new(), + let model = test_model( + vec![ + lib(0, "package:spotube/main.dart"), + lib(1, "package:provider/src/provider.dart"), + ], + vec![cls(0, "AppCore", Some(0)), cls(1, "ProviderCore", Some(1))], functions, - pool_geometry: None, - object_pool: Vec::new(), - }; - let owner_library = HashMap::from([ - ( - "ProviderCore".to_string(), - "package:provider/src/provider.dart".to_string(), - ), - ( - "AppCore".to_string(), - "package:spotube/main.dart".to_string(), - ), - ]); + ordinal_pool(Vec::new()), + ); let preferred = HashSet::from(["spotube".to_string()]); - let boosts = build_app_package_boosts(&model, &owner_library, &preferred); + let boosts = build_app_package_boosts(&model, &preferred); let provider = boosts.get("provider").copied().unwrap_or(0); let spotube = boosts.get("spotube").copied().unwrap_or(0); assert!( @@ -3412,203 +2598,73 @@ mod tests { #[test] fn prioritizes_entrypoint_frontier_callee_when_names_are_generic() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "sub_1000".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1000, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "sub_1004".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1004, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, - FunctionInfo { - id: 2, - name: "sub_1008".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x1008, - size: 4, - code_section_va: 0x1000, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, None, Some(0), 0x1000, 4), + fun(1, None, Some(0), 0x1004, 4), + fun(2, None, Some(0), 0x1008, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "entrypoint:main".to_string(), - decoded_kind: Some("EntryPointCandidate".to_string()), - selector: Some("main".to_string()), - target_va: Some(0x1000), - owner_class: Some("Global".to_string()), - library_uri: Some("package:app/main.dart".to_string()), - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![]), + ); + let hints = program_hints(vec![hint( + HintKind::EntryPoint, + HintOrigin::ModelNamePattern, + "main", + Some(0x1000), + Some("AppRoot"), + Some("package:app/main.dart"), + )]); let bytes = vec![ 0x02, 0x00, 0x00, 0x94, // bl #0x1008 0xc0, 0x03, 0x5f, 0xd6, // ret 0xc0, 0x03, 0x5f, 0xd6, // ret ]; - let d = disassemble_program(&model, &bytes, 0x1000, None, Some(2)); + let d = disassemble_program(&model, &hints, &bytes, 0x1000, None, Some(2)); assert_eq!(d.len(), 2); - assert_eq!(d[0].function_name, "sub_1000"); - assert_eq!(d[1].function_name, "sub_1008"); + assert_eq!(d[0].entry_va, 0x1000); + assert_eq!(d[0].function_name, None); + assert_eq!(d[1].entry_va, 0x1008); + assert_eq!(d[1].function_name, None); } #[test] fn capped_selection_prefers_diversity_before_duplicate_owner_name() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "main".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x7000, - size: 4, - code_section_va: 0x7000, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "main".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x7004, - size: 4, - code_section_va: 0x7000, - name_kind: None, - }, - FunctionInfo { - id: 2, - name: "startCLI".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x7008, - size: 4, - code_section_va: 0x7000, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, Some("main"), Some(0), 0x7000, 4), + fun(1, Some("main"), Some(0), 0x7004, 4), + fun(2, Some("startCLI"), Some(0), 0x7008, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![pool_string(0, "x")]), + ); + let hints = program_hints(vec![]); let bytes = [0xc0u8, 0x03, 0x5f, 0xd6].repeat(3); - let d = disassemble_program(&model, &bytes, 0x7000, None, Some(2)); + let d = disassemble_program(&model, &hints, &bytes, 0x7000, None, Some(2)); assert_eq!(d.len(), 2); - assert_eq!(d[0].function_name, "main"); - assert_eq!(d[1].function_name, "startCLI"); + assert_eq!(d[0].function_name.as_deref(), Some("main")); + assert_eq!(d[1].function_name.as_deref(), Some("startCLI")); } #[test] fn capped_selection_backfills_deferred_duplicates_when_needed() { - let model = ProgramModel { - schema_version: 2, - adapter_kind: "test".to_string(), - dart_version: "unknown".to_string(), - snapshot_hash: "h".to_string(), - arch: "arm64".to_string(), - libraries: vec![LibraryInfo { - id: 0, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - classes: vec![ClassInfo { - id: 0, - name: "Global".to_string(), - super_name: "Object".to_string(), - library_uri: "package:app/main.dart".to_string(), - }], - functions: vec![ - FunctionInfo { - id: 0, - name: "main".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x7100, - size: 4, - code_section_va: 0x7100, - name_kind: None, - }, - FunctionInfo { - id: 1, - name: "main".to_string(), - owner_class: "Global".to_string(), - entry_va: 0x7104, - size: 4, - code_section_va: 0x7100, - name_kind: None, - }, + let model = test_model( + vec![lib(0, "package:app/main.dart")], + vec![cls(0, "AppRoot", Some(0))], + vec![ + fun(0, Some("main"), Some(0), 0x7100, 4), + fun(1, Some("main"), Some(0), 0x7104, 4), ], - pool_geometry: None, - object_pool: vec![ObjectPoolEntry { - index: 0, - kind: "String".to_string(), - value: "x".to_string(), - decoded_kind: None, - selector: None, - target_va: None, - owner_class: None, - library_uri: None, - confidence: None, - source: None, - }], - }; + ordinal_pool(vec![pool_string(0, "x")]), + ); + let hints = program_hints(vec![]); let bytes = [0xc0u8, 0x03, 0x5f, 0xd6].repeat(2); - let d = disassemble_program(&model, &bytes, 0x7100, None, Some(2)); + let d = disassemble_program(&model, &hints, &bytes, 0x7100, None, Some(2)); assert_eq!(d.len(), 2); - assert_eq!(d[0].function_name, "main"); - assert_eq!(d[1].function_name, "main"); + assert_eq!(d[0].function_name.as_deref(), Some("main")); + assert_eq!(d[1].function_name.as_deref(), Some("main")); } } diff --git a/crates/flutterdec-ir/src/lib.rs b/crates/flutterdec-ir/src/lib.rs index fb5a4e1b..6107aea0 100644 --- a/crates/flutterdec-ir/src/lib.rs +++ b/crates/flutterdec-ir/src/lib.rs @@ -368,7 +368,7 @@ pub fn build_function_ir(d: &FunctionDisassembly) -> FunctionIr { FunctionIr { function_id: d.function_id, - name: d.function_name.clone(), + name: d.display_name(), entry_va: d.entry_va, blocks, } @@ -400,8 +400,8 @@ mod tests { fn elides_the_dart_stack_overflow_check_and_its_slow_path() { let d = FunctionDisassembly { function_id: 7, - function_name: "guarded".to_string(), - owner_class: "Global".to_string(), + function_name: Some("guarded".to_string()), + owner_class: None, entry_va: 0x1000, size: 24, instructions: vec![ @@ -455,8 +455,8 @@ mod tests { for offset in ["0x38", "0x48", "0x60"] { let d = FunctionDisassembly { function_id: 9, - function_name: "guarded".to_string(), - owner_class: "Global".to_string(), + function_name: Some("guarded".to_string()), + owner_class: None, entry_va: 0x1000, size: 20, instructions: vec![ @@ -494,8 +494,8 @@ mod tests { fn does_not_treat_an_unrelated_stack_compare_as_the_guard() { let d = FunctionDisassembly { function_id: 10, - function_name: "notGuarded".to_string(), - owner_class: "Global".to_string(), + function_name: Some("notGuarded".to_string()), + owner_class: None, entry_va: 0x1000, size: 12, instructions: vec![ @@ -522,8 +522,8 @@ mod tests { fn code_after_a_return_starts_a_new_block() { let d = FunctionDisassembly { function_id: 8, - function_name: "two_exits".to_string(), - owner_class: "Global".to_string(), + function_name: Some("two_exits".to_string()), + owner_class: None, entry_va: 0x2000, size: 12, instructions: vec![ @@ -542,8 +542,8 @@ mod tests { fn builds_cfg_with_branch_and_fallthrough() { let d = FunctionDisassembly { function_id: 1, - function_name: "f".to_string(), - owner_class: "Global".to_string(), + function_name: Some("f".to_string()), + owner_class: None, entry_va: 0x1000, size: 16, instructions: vec![ @@ -580,8 +580,8 @@ mod tests { fn parses_tbnz_target_from_last_operand_token() { let d = FunctionDisassembly { function_id: 2, - function_name: "g".to_string(), - owner_class: "Global".to_string(), + function_name: Some("g".to_string()), + owner_class: None, entry_va: 0x2000, size: 16, instructions: vec![ diff --git a/crates/flutterdec-loader/src/identity.rs b/crates/flutterdec-loader/src/identity.rs new file mode 100644 index 00000000..bc71e4f8 --- /dev/null +++ b/crates/flutterdec-loader/src/identity.rs @@ -0,0 +1,343 @@ +//! Typed, header-derived snapshot identity. +//! +//! Everything a compatibility decision is allowed to rest on comes from the +//! snapshot header itself: the 32-character version hash, the snapshot kind, and +//! the features string the VM wrote next to them. Filenames, semantic Dart +//! versions, adapter output, and byte scans are not identity; they are at best +//! evidence *about* a snapshot, and this module keeps that distinction in the +//! type system so no caller can lose it. +//! +//! `runtime/vm/snapshot.h` fixes the header layout and `WriteVersionAndFeatures` +//! (`runtime/vm/app_snapshot.cc`) fixes what follows it. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// `Snapshot::Kind` from `runtime/vm/snapshot.h`, in declaration order. +/// +/// Only `FullAot` can reach exact adapter selection. The other kinds are real +/// Dart snapshots with different serializer output, so accepting one under an +/// AOT parser would produce confident nonsense rather than an error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SnapshotKind { + Full, + FullCore, + FullJit, + FullAot, + /// The header parsed but its kind field is not one the VM writes. + Unrecognized, +} + +impl SnapshotKind { + /// Decode the header's `int64` kind field. + pub fn from_header_value(value: i64) -> Self { + match value { + 0 => Self::Full, + 1 => Self::FullCore, + 2 => Self::FullJit, + 3 => Self::FullAot, + _ => Self::Unrecognized, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Full => "full", + Self::FullCore => "full_core", + Self::FullJit => "full_jit", + Self::FullAot => "full_aot", + Self::Unrecognized => "unrecognized", + } + } +} + +impl fmt::Display for SnapshotKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Where the hash came from, which decides what it may authorize. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HashSource { + /// Read at its fixed offset in a validated snapshot header. Exact. + Header, + /// Recovered by scanning bytes for something hash-shaped. Heuristic: a + /// 32-hex run in a data section is not proof of a snapshot version. + Scan, + /// Neither worked. Nothing may be assumed. + Unavailable, +} + +/// The architecture the snapshot's code was generated *for*. +/// +/// Distinct from the host architecture, and distinct from the ELF machine of the +/// container: those coincide today only because the sole supported input is an +/// Android ARM64 `libapp.so`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetArch { + Arm64, + /// A target this build does not support, kept verbatim for diagnostics. + Unsupported(String), +} + +impl TargetArch { + pub fn as_str(&self) -> &str { + match self { + Self::Arm64 => "arm64", + Self::Unsupported(other) => other, + } + } +} + +impl fmt::Display for TargetArch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Pointer width mode, read from the features string rather than inferred. +/// +/// `Dart::FeaturesString` (`runtime/vm/dart.cc`) appends exactly one of +/// `compressed-pointers` or `no-compressed-pointers`. Both present is not a +/// snapshot the VM writes, so it is a conflict rather than a value to pick from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PointerCompression { + Compressed, + Uncompressed, + /// The features string said nothing about it. + Unavailable, + /// The features string said both. + Conflicting, +} + +/// The snapshot's features string, raw and normalized. +/// +/// Normalization is what a registry key can compare: whitespace-split, ASCII +/// lowercased, sorted, deduplicated. The raw string is retained because it is +/// the actual evidence and normalization is lossy about ordering. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FeatureEvidence { + /// Absent when no header parsed. + pub raw: Option, + pub normalized: Vec, +} + +impl FeatureEvidence { + pub fn unavailable() -> Self { + Self { + raw: None, + normalized: Vec::new(), + } + } + + pub fn parse(raw: &str) -> Self { + let mut normalized: Vec = raw + .split_whitespace() + .map(|token| token.to_ascii_lowercase()) + .collect(); + normalized.sort(); + normalized.dedup(); + Self { + raw: Some(raw.to_string()), + normalized, + } + } + + pub fn has(&self, token: &str) -> bool { + self.normalized.iter().any(|t| t == token) + } + + /// The architecture token the features string declares, if it declares one. + /// + /// These are the values `Dart::FeaturesString` can append; anything else in + /// the string is a build flag, not an architecture. + pub fn declared_target(&self) -> Option<&str> { + const ARCH_TOKENS: [&str; 6] = ["ia32", "x64", "arm", "arm64", "riscv32", "riscv64"]; + self.normalized + .iter() + .map(String::as_str) + .find(|token| ARCH_TOKENS.contains(token)) + } + + pub fn pointer_compression(&self) -> PointerCompression { + let compressed = self.has("compressed-pointers"); + let uncompressed = self.has("no-compressed-pointers"); + match (compressed, uncompressed) { + (true, true) => PointerCompression::Conflicting, + (true, false) => PointerCompression::Compressed, + (false, true) => PointerCompression::Uncompressed, + (false, false) => PointerCompression::Unavailable, + } + } +} + +/// What is actually known about a loaded snapshot, and how well it is known. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotIdentity { + /// Normalized lowercase 32-character hash. `None` when unrecoverable. + pub hash: Option, + pub hash_source: HashSource, + /// `None` when no header parsed, so the kind is genuinely unknown rather + /// than assumed to be AOT. + pub kind: Option, + /// Read from the ELF container's machine field. + pub target_arch: TargetArch, + pub features: FeatureEvidence, + pub pointer_compression: PointerCompression, +} + +/// Why an identity may not authorize exact adapter selection. +/// +/// Each variant names the check that stopped it, so a caller can report the +/// rejection point rather than a generic failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IdentityRejection { + /// No header parsed, or the hash was only scanned out of the bytes. + HashNotHeaderDerived(HashSource), + /// FullAOT is a pre-lookup hard gate, not a registry key component. + NotFullAot(Option), + UnsupportedTarget(String), + /// The features string names a different architecture than the container. + TargetArchConflict { + declared: String, + container: String, + }, + PointerCompressionUnavailable(PointerCompression), +} + +impl fmt::Display for IdentityRejection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HashNotHeaderDerived(source) => write!( + f, + "snapshot hash is not header-derived (source: {:?}); a scanned or missing hash cannot select an exact parser", + source + ), + Self::NotFullAot(kind) => write!( + f, + "snapshot kind is {}; only full_aot reaches adapter compatibility selection", + kind.map(SnapshotKind::as_str).unwrap_or("unknown") + ), + Self::UnsupportedTarget(arch) => { + write!(f, "unsupported target architecture {}", arch) + } + Self::TargetArchConflict { + declared, + container, + } => write!( + f, + "features string declares target {} but the container is {}", + declared, container + ), + Self::PointerCompressionUnavailable(state) => write!( + f, + "pointer compression evidence is {:?}; the word size of a reference field cannot be assumed", + state + ), + } + } +} + +impl std::error::Error for IdentityRejection {} + +/// The tuple exact compatibility selection is allowed to key on. +/// +/// FullAOT is absent by design: it is a gate that must already have passed, so +/// putting it in the key would multiply the registry with rows that can never +/// match. Semantic Dart versions are absent for the same reason plus a stronger +/// one, they are aliases of a hash and never selectors. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExactSelectionKey { + pub hash: String, + pub target_arch: TargetArch, + pub features: Vec, +} + +impl SnapshotIdentity { + /// The identity of a container whose snapshot header did not parse. + pub fn without_header(target_arch: TargetArch, scanned_hash: Option) -> Self { + let (hash, hash_source) = match scanned_hash { + Some(hash) => (Some(hash), HashSource::Scan), + None => (None, HashSource::Unavailable), + }; + Self { + hash, + hash_source, + kind: None, + target_arch, + features: FeatureEvidence::unavailable(), + pointer_compression: PointerCompression::Unavailable, + } + } + + /// The identity of a container whose snapshot header parsed. + pub fn from_header( + target_arch: TargetArch, + hash: &str, + kind: SnapshotKind, + features_raw: &str, + ) -> Self { + let features = FeatureEvidence::parse(features_raw); + let pointer_compression = features.pointer_compression(); + Self { + hash: Some(hash.to_ascii_lowercase()), + hash_source: HashSource::Header, + kind: Some(kind), + target_arch, + features, + pointer_compression, + } + } + + /// Whether this identity was read out of a real header. + pub fn is_exact(&self) -> bool { + self.hash_source == HashSource::Header + } + + /// The pre-lookup gate. `Ok` means, and only means, that exact compatibility + /// selection may be attempted; it is not a claim that a record exists. + pub fn exact_selection_key(&self) -> Result { + if self.hash_source != HashSource::Header { + return Err(IdentityRejection::HashNotHeaderDerived(self.hash_source)); + } + let hash = self + .hash + .clone() + .ok_or(IdentityRejection::HashNotHeaderDerived( + HashSource::Unavailable, + ))?; + if self.kind != Some(SnapshotKind::FullAot) { + return Err(IdentityRejection::NotFullAot(self.kind)); + } + let TargetArch::Arm64 = self.target_arch else { + return Err(IdentityRejection::UnsupportedTarget( + self.target_arch.as_str().to_string(), + )); + }; + if let Some(declared) = self.features.declared_target() { + if declared != self.target_arch.as_str() { + return Err(IdentityRejection::TargetArchConflict { + declared: declared.to_string(), + container: self.target_arch.as_str().to_string(), + }); + } + } + match self.pointer_compression { + PointerCompression::Compressed | PointerCompression::Uncompressed => {} + other => return Err(IdentityRejection::PointerCompressionUnavailable(other)), + } + Ok(ExactSelectionKey { + hash, + target_arch: self.target_arch.clone(), + features: self.features.normalized.clone(), + }) + } +} diff --git a/crates/flutterdec-loader/src/lib.rs b/crates/flutterdec-loader/src/lib.rs index 86055952..3c7ffd55 100644 --- a/crates/flutterdec-loader/src/lib.rs +++ b/crates/flutterdec-loader/src/lib.rs @@ -9,8 +9,10 @@ use std::path::{Path, PathBuf}; use zip::ZipArchive; pub mod dart_profile; +pub mod identity; use dart_profile::ResolvedDartProfile; +use identity::{SnapshotIdentity, SnapshotKind, TargetArch}; #[derive(Debug, Clone)] pub struct SnapshotBundle { @@ -42,6 +44,12 @@ pub struct SnapshotBundle { /// value of `kSmiBits`, and therefore which offset table applies. `None` /// means the header did not parse and nothing may be assumed. pub compressed_pointers: Option, + /// The authoritative typed identity. `arch`, `snapshot_hash`, + /// `snapshot_features`, and `compressed_pointers` above are flattened views + /// of this value, populated from it so the two cannot disagree. + /// Compatibility decisions must read this field: it is the only one that + /// carries how well each fact is known. + pub identity: SnapshotIdentity, } #[derive(Debug, Clone)] @@ -213,7 +221,7 @@ fn read_symbol_span( /// `WriteVersionAndFeatures` (`runtime/vm/app_snapshot.cc`) then writes /// `Version::SnapshotString()`, which is the 32-character snapshot hash and /// carries no separator, followed by the NUL-terminated features string. -fn parse_snapshot_header(bytes: &[u8]) -> Option<(String, String)> { +fn parse_snapshot_header(bytes: &[u8]) -> Option<(String, SnapshotKind, String)> { const MAGIC: [u8; 4] = [0xf5, 0xf5, 0xdc, 0xdc]; const HEADER_SIZE: usize = 20; const HASH_LEN: usize = 32; @@ -227,6 +235,12 @@ fn parse_snapshot_header(bytes: &[u8]) -> Option<(String, String)> { if length <= 0 || length > remaining { return None; } + // The kind field decides which serializer wrote the payload. Reading it here + // rather than assuming AOT is what lets the caller refuse a JIT or core + // snapshot instead of parsing it under the wrong contract. + let kind = SnapshotKind::from_header_value(i64::from_le_bytes( + bytes.get(start + 12..start + 20)?.try_into().ok()?, + )); let hash_at = start.checked_add(HEADER_SIZE)?; let features_at = hash_at.checked_add(HASH_LEN)?; let hash = bytes.get(hash_at..features_at)?; @@ -246,6 +260,7 @@ fn parse_snapshot_header(bytes: &[u8]) -> Option<(String, String)> { } Some(( String::from_utf8_lossy(hash).to_ascii_lowercase(), + kind, String::from_utf8_lossy(features).to_string(), )) } @@ -256,16 +271,18 @@ fn parse_snapshot_header(bytes: &[u8]) -> Option<(String, String)> { /// has to be tested first: `compressed-pointers` is a substring of /// `no-compressed-pointers`. fn compressed_pointers_from_features(features: &str) -> Option { - if features.contains("no-compressed-pointers") { - return Some(false); - } - if features.contains("compressed-pointers") { - return Some(true); + match identity::FeatureEvidence::parse(features).pointer_compression() { + identity::PointerCompression::Compressed => Some(true), + identity::PointerCompression::Uncompressed => Some(false), + // Silence and contradiction are both "nothing may be assumed"; the typed + // identity keeps them apart for the caller that needs the difference. + identity::PointerCompression::Unavailable | identity::PointerCompression::Conflicting => { + None + } } - None } -fn detect_snapshot_hash(vm_data: &[u8], isolate_data: &[u8]) -> String { +fn detect_snapshot_hash(vm_data: &[u8], isolate_data: &[u8]) -> Option { let mut probe = Vec::new(); probe.extend_from_slice(&vm_data[..vm_data.len().min(65536)]); probe.extend_from_slice(&isolate_data[..isolate_data.len().min(65536)]); @@ -273,18 +290,18 @@ fn detect_snapshot_hash(vm_data: &[u8], isolate_data: &[u8]) -> String { let pattern = Regex::new(r"([0-9a-f]{32})product\s+no-code_comments").expect("valid regex"); if let Some(caps) = pattern.captures(&probe) { if let Some(m) = caps.get(1) { - return String::from_utf8_lossy(m.as_bytes()).to_string(); + return Some(String::from_utf8_lossy(m.as_bytes()).to_string()); } } let fallback = Regex::new(r"\b([0-9a-f]{32})\b").expect("valid regex"); if let Some(caps) = fallback.captures(&probe) { if let Some(m) = caps.get(1) { - return String::from_utf8_lossy(m.as_bytes()).to_string(); + return Some(String::from_utf8_lossy(m.as_bytes()).to_string()); } } - "unknown".to_string() + None } fn from_elf(path: &Path, libapp_display: PathBuf, bytes: Vec) -> Result { @@ -321,11 +338,20 @@ fn from_elf(path: &Path, libapp_display: PathBuf, bytes: Vec) -> Result hash.clone(), - None => detect_snapshot_hash(&vm_data_bytes, &isolate_data_bytes), + let identity = match &header { + Some((hash, kind, features)) => { + SnapshotIdentity::from_header(TargetArch::Arm64, hash, *kind, features) + } + None => SnapshotIdentity::without_header( + TargetArch::Arm64, + detect_snapshot_hash(&vm_data_bytes, &isolate_data_bytes), + ), }; - let snapshot_features = header.map(|(_, features)| features); + let hash = identity + .hash + .clone() + .unwrap_or_else(|| "unknown".to_string()); + let snapshot_features = identity.features.raw.clone(); let compressed_pointers = snapshot_features .as_deref() .and_then(compressed_pointers_from_features); @@ -345,6 +371,7 @@ fn from_elf(path: &Path, libapp_display: PathBuf, bytes: Vec) -> Result Result { #[cfg(test)] mod tests { + use super::identity::{ + HashSource, IdentityRejection, PointerCompression, SnapshotIdentity, SnapshotKind, + TargetArch, + }; use super::{ compressed_pointers_from_features, list_apk_entries, load_snapshot_bundle_from_apk_session, parse_snapshot_header, read_apk_entry, ApkSession, @@ -459,6 +490,10 @@ mod tests { } fn snapshot_blob(hash: &str, features: &str) -> Vec { + snapshot_blob_of_kind(hash, features, 3) + } + + fn snapshot_blob_of_kind(hash: &str, features: &str, kind: i64) -> Vec { // Eight bytes of padding before the header, so the parser has to find the // magic rather than assume offset zero. let lead = 8usize; @@ -468,7 +503,7 @@ mod tests { // `length` has to describe a payload that fits inside the span, which is // what makes the check able to reject a stray magic. out.extend_from_slice(&(payload_len as i64).to_le_bytes()); - out.extend_from_slice(&3i64.to_le_bytes()); + out.extend_from_slice(&kind.to_le_bytes()); out.extend_from_slice(hash.as_bytes()); out.extend_from_slice(features.as_bytes()); out.push(0); @@ -499,8 +534,9 @@ mod tests { "80a49c7111088100a233b2ae788e1f48", "product no-code_comments arm64 android compressed-pointers", ); - let (hash, features) = parse_snapshot_header(&blob).expect("header parses"); + let (hash, kind, features) = parse_snapshot_header(&blob).expect("header parses"); assert_eq!(hash, "80a49c7111088100a233b2ae788e1f48"); + assert_eq!(kind, SnapshotKind::FullAot); assert!(features.ends_with("compressed-pointers")); assert_eq!(compressed_pointers_from_features(&features), Some(true)); } @@ -536,4 +572,398 @@ mod tests { truncated.extend_from_slice(&[b'x'; 2048]); assert!(parse_snapshot_header(&truncated).is_none()); } + + const AOT_FEATURES: &str = "product no-code_comments arm64 android compressed-pointers"; + const AOT_HASH: &str = "80a49c7111088100a233b2ae788e1f48"; + + /// Build the identity the loader would build for these header bytes, going + /// through the real header parser rather than constructing it by hand. + fn identity_from_blob(blob: &[u8]) -> SnapshotIdentity { + match parse_snapshot_header(blob) { + Some((hash, kind, features)) => { + SnapshotIdentity::from_header(TargetArch::Arm64, &hash, kind, &features) + } + None => SnapshotIdentity::without_header(TargetArch::Arm64, None), + } + } + + /// The whole point of the type: a valid FullAOT header yields exact, + /// header-sourced facts and a selection key that carries no kind and no + /// semantic version. + #[test] + fn a_full_aot_header_yields_an_exact_identity_and_a_selection_key() { + let identity = identity_from_blob(&snapshot_blob(AOT_HASH, AOT_FEATURES)); + + assert_eq!(identity.hash.as_deref(), Some(AOT_HASH)); + assert_eq!(identity.hash_source, HashSource::Header); + assert_eq!(identity.kind, Some(SnapshotKind::FullAot)); + assert_eq!(identity.target_arch, TargetArch::Arm64); + assert_eq!(identity.pointer_compression, PointerCompression::Compressed); + assert!(identity.is_exact()); + // Normalized evidence is sorted and deduplicated so it can be compared. + assert_eq!( + identity.features.normalized, + vec![ + "android".to_string(), + "arm64".to_string(), + "compressed-pointers".to_string(), + "no-code_comments".to_string(), + "product".to_string(), + ] + ); + + let key = identity.exact_selection_key().expect("gate passes"); + assert_eq!(key.hash, AOT_HASH); + assert_eq!(key.target_arch, TargetArch::Arm64); + assert_eq!(key.features, identity.features.normalized); + } + + /// A hash written in uppercase is the same hash. Normalizing at the boundary + /// is what keeps a registry lookup from missing on case alone. + #[test] + fn header_hashes_are_normalized_to_lowercase() { + let blob = snapshot_blob("80A49C7111088100A233B2AE788E1F48", AOT_FEATURES); + let identity = identity_from_blob(&blob); + assert_eq!(identity.hash.as_deref(), Some(AOT_HASH)); + } + + /// FullAOT is a gate, so every other kind has to stop before selection even + /// though its header is otherwise perfectly valid. + #[test] + fn non_full_aot_kinds_are_rejected_at_the_gate() { + for (value, expected) in [ + (0i64, SnapshotKind::Full), + (1, SnapshotKind::FullCore), + (2, SnapshotKind::FullJit), + (9, SnapshotKind::Unrecognized), + ] { + let blob = snapshot_blob_of_kind(AOT_HASH, AOT_FEATURES, value); + let identity = identity_from_blob(&blob); + assert_eq!(identity.kind, Some(expected), "kind {} decodes", value); + // The hash is still exact; it is the kind that withholds authority. + assert_eq!(identity.hash_source, HashSource::Header); + assert_eq!( + identity.exact_selection_key(), + Err(IdentityRejection::NotFullAot(Some(expected))), + ); + } + } + + /// A malformed hash means the header did not parse at all, so the identity + /// falls back rather than carrying a half-read hash. + #[test] + fn a_malformed_hash_leaves_no_header_identity() { + let blob = snapshot_blob("80a49c7111088100a233b2ae788e1zzz", AOT_FEATURES); + assert!(parse_snapshot_header(&blob).is_none()); + let identity = identity_from_blob(&blob); + assert_eq!(identity.hash, None); + assert_eq!(identity.hash_source, HashSource::Unavailable); + assert_eq!(identity.kind, None); + assert_eq!( + identity.exact_selection_key(), + Err(IdentityRejection::HashNotHeaderDerived( + HashSource::Unavailable + )), + ); + } + + /// Both pointer spellings in one features string is not a snapshot the VM + /// writes. Picking one would decide the word size of every reference field + /// on a coin flip, so the conflict has to survive into the gate. + #[test] + fn conflicting_pointer_features_are_a_conflict_not_a_choice() { + let blob = snapshot_blob( + AOT_HASH, + "product arm64 compressed-pointers no-compressed-pointers", + ); + let identity = identity_from_blob(&blob); + assert_eq!( + identity.pointer_compression, + PointerCompression::Conflicting + ); + assert_eq!( + identity.exact_selection_key(), + Err(IdentityRejection::PointerCompressionUnavailable( + PointerCompression::Conflicting + )), + ); + } + + /// Silence about pointer compression is also not a value. + #[test] + fn absent_pointer_features_stop_the_gate() { + let blob = snapshot_blob(AOT_HASH, "product arm64 android"); + let identity = identity_from_blob(&blob); + assert_eq!( + identity.pointer_compression, + PointerCompression::Unavailable + ); + assert_eq!( + identity.exact_selection_key(), + Err(IdentityRejection::PointerCompressionUnavailable( + PointerCompression::Unavailable + )), + ); + } + + /// A scanned hash is a 32-hex run that happened to be in a data section. It + /// is evidence, not identity, and must not reach an exact parser. + #[test] + fn a_scan_only_hash_cannot_authorize_exact_selection() { + let identity = + SnapshotIdentity::without_header(TargetArch::Arm64, Some(AOT_HASH.to_string())); + assert_eq!(identity.hash.as_deref(), Some(AOT_HASH)); + assert_eq!(identity.hash_source, HashSource::Scan); + assert!(!identity.is_exact()); + assert_eq!( + identity.exact_selection_key(), + Err(IdentityRejection::HashNotHeaderDerived(HashSource::Scan)), + ); + } + + /// The features string and the container have to agree about the target, or + /// one of them is describing a different binary. + #[test] + fn a_features_target_conflicting_with_the_container_is_rejected() { + let blob = snapshot_blob(AOT_HASH, "product x64 compressed-pointers"); + let identity = identity_from_blob(&blob); + assert_eq!( + identity.exact_selection_key(), + Err(IdentityRejection::TargetArchConflict { + declared: "x64".to_string(), + container: "arm64".to_string(), + }), + ); + } + + /// Host architecture is not target architecture; an unsupported target stops + /// before any lookup regardless of what the host happens to be. + #[test] + fn an_unsupported_target_stops_before_lookup() { + let mut identity = identity_from_blob(&snapshot_blob(AOT_HASH, AOT_FEATURES)); + identity.target_arch = TargetArch::Unsupported("riscv64".to_string()); + assert_eq!( + identity.exact_selection_key(), + Err(IdentityRejection::UnsupportedTarget("riscv64".to_string())), + ); + } + + /// Assemble a minimal ARM64 shared object carrying the four snapshot + /// symbols, so `load_snapshot_bundle` can be exercised on bytes rather than + /// on the header parser alone. + /// + /// The single `PT_LOAD` maps at address zero with file offset zero, which + /// makes a symbol's virtual address equal its file offset and keeps the + /// fixture readable. Only the pieces `goblin` needs to find a symbol table + /// are present: a program header, `.symtab`, `.strtab`, and `.shstrtab`. + fn synthetic_libapp( + vm_data: &[u8], + isolate_data: &[u8], + vm_instr: &[u8], + isolate_instr: &[u8], + ) -> Vec { + const EHDR: usize = 64; + const PHDR: usize = 56; + const SHDR: usize = 64; + const SYM: usize = 24; + + let mut out = vec![0u8; 128]; + let place = |out: &mut Vec, bytes: &[u8]| -> (u64, u64) { + let at = out.len() as u64; + out.extend_from_slice(bytes); + (at, bytes.len() as u64) + }; + let spans = [ + place(&mut out, vm_data), + place(&mut out, isolate_data), + place(&mut out, vm_instr), + place(&mut out, isolate_instr), + ]; + + let names = [ + "_kDartVmSnapshotData", + "_kDartIsolateSnapshotData", + "_kDartVmSnapshotInstructions", + "_kDartIsolateSnapshotInstructions", + ]; + let mut strtab = vec![0u8]; + let mut name_offsets = Vec::new(); + for name in names { + name_offsets.push(strtab.len() as u32); + strtab.extend_from_slice(name.as_bytes()); + strtab.push(0); + } + + // Index 0 is the reserved null symbol. + let mut symtab = vec![0u8; SYM]; + for (index, (value, size)) in spans.iter().enumerate() { + symtab.extend_from_slice(&name_offsets[index].to_le_bytes()); + symtab.push(0x11); // STB_GLOBAL | STT_OBJECT + symtab.push(0); + symtab.extend_from_slice(&1u16.to_le_bytes()); // any defined section + symtab.extend_from_slice(&value.to_le_bytes()); + symtab.extend_from_slice(&size.to_le_bytes()); + } + + let mut shstrtab = vec![0u8]; + let section_name = |shstrtab: &mut Vec, name: &str| -> u32 { + let at = shstrtab.len() as u32; + shstrtab.extend_from_slice(name.as_bytes()); + shstrtab.push(0); + at + }; + let symtab_name = section_name(&mut shstrtab, ".symtab"); + let strtab_name = section_name(&mut shstrtab, ".strtab"); + let shstrtab_name = section_name(&mut shstrtab, ".shstrtab"); + + let symtab_off = out.len() as u64; + out.extend_from_slice(&symtab); + let strtab_off = out.len() as u64; + out.extend_from_slice(&strtab); + let shstrtab_off = out.len() as u64; + out.extend_from_slice(&shstrtab); + let shoff = out.len() as u64; + + let mut section = + |name: u32, kind: u32, offset: u64, size: u64, link: u32, entsize: u64| { + let mut hdr = Vec::with_capacity(SHDR); + hdr.extend_from_slice(&name.to_le_bytes()); + hdr.extend_from_slice(&kind.to_le_bytes()); + hdr.extend_from_slice(&0u64.to_le_bytes()); // flags + hdr.extend_from_slice(&0u64.to_le_bytes()); // addr + hdr.extend_from_slice(&offset.to_le_bytes()); + hdr.extend_from_slice(&size.to_le_bytes()); + hdr.extend_from_slice(&link.to_le_bytes()); + hdr.extend_from_slice(&0u32.to_le_bytes()); // info + hdr.extend_from_slice(&1u64.to_le_bytes()); // addralign + hdr.extend_from_slice(&entsize.to_le_bytes()); + out.extend_from_slice(&hdr); + }; + section(0, 0, 0, 0, 0, 0); // SHT_NULL + section( + symtab_name, + 2, + symtab_off, + symtab.len() as u64, + 2, + SYM as u64, + ); // SHT_SYMTAB + section(strtab_name, 3, strtab_off, strtab.len() as u64, 0, 0); // SHT_STRTAB + section(shstrtab_name, 3, shstrtab_off, shstrtab.len() as u64, 0, 0); + + let total = out.len() as u64; + + let mut header = Vec::with_capacity(EHDR); + header.extend_from_slice(&[0x7f, b'E', b'L', b'F', 2, 1, 1, 0]); + header.extend_from_slice(&[0u8; 8]); + header.extend_from_slice(&3u16.to_le_bytes()); // ET_DYN + header.extend_from_slice(&183u16.to_le_bytes()); // EM_AARCH64 + header.extend_from_slice(&1u32.to_le_bytes()); // version + header.extend_from_slice(&0u64.to_le_bytes()); // entry + header.extend_from_slice(&(EHDR as u64).to_le_bytes()); // phoff + header.extend_from_slice(&shoff.to_le_bytes()); + header.extend_from_slice(&0u32.to_le_bytes()); // flags + header.extend_from_slice(&(EHDR as u16).to_le_bytes()); + header.extend_from_slice(&(PHDR as u16).to_le_bytes()); + header.extend_from_slice(&1u16.to_le_bytes()); // phnum + header.extend_from_slice(&(SHDR as u16).to_le_bytes()); + header.extend_from_slice(&4u16.to_le_bytes()); // shnum + header.extend_from_slice(&3u16.to_le_bytes()); // shstrndx + out[..EHDR].copy_from_slice(&header); + + // One PT_LOAD covering the file at address zero. + let mut phdr = Vec::with_capacity(PHDR); + phdr.extend_from_slice(&1u32.to_le_bytes()); // PT_LOAD + phdr.extend_from_slice(&5u32.to_le_bytes()); // R+X + phdr.extend_from_slice(&0u64.to_le_bytes()); // offset + phdr.extend_from_slice(&0u64.to_le_bytes()); // vaddr + phdr.extend_from_slice(&0u64.to_le_bytes()); // paddr + phdr.extend_from_slice(&total.to_le_bytes()); // filesz + phdr.extend_from_slice(&total.to_le_bytes()); // memsz + phdr.extend_from_slice(&0x1000u64.to_le_bytes()); // align + out[EHDR..EHDR + PHDR].copy_from_slice(&phdr); + + out + } + + fn load_synthetic(vm_data: &[u8]) -> super::SnapshotBundle { + let bytes = synthetic_libapp(vm_data, &[0u8; 32], &[0x1fu8; 16], &[0x2fu8; 16]); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("libapp.so"); + std::fs::write(&path, &bytes).expect("write libapp"); + super::load_snapshot_bundle(&path).expect("load synthetic libapp") + } + + /// The identity a real load produces, not just the one the header parser + /// returns. This is the wiring that decides what every downstream + /// compatibility check sees. + #[test] + fn loading_a_real_container_produces_the_header_identity() { + let bundle = load_synthetic(&snapshot_blob(AOT_HASH, AOT_FEATURES)); + + assert_eq!(bundle.identity.hash.as_deref(), Some(AOT_HASH)); + assert_eq!(bundle.identity.hash_source, HashSource::Header); + assert_eq!(bundle.identity.kind, Some(SnapshotKind::FullAot)); + assert_eq!(bundle.identity.target_arch, TargetArch::Arm64); + assert_eq!( + bundle.identity.pointer_compression, + PointerCompression::Compressed + ); + assert!(bundle.identity.exact_selection_key().is_ok()); + + // The flattened fields are views of the identity, so they cannot drift. + assert_eq!(bundle.snapshot_hash, AOT_HASH); + assert_eq!(bundle.arch, "arm64"); + assert_eq!(bundle.snapshot_features.as_deref(), Some(AOT_FEATURES)); + assert_eq!(bundle.compressed_pointers, Some(true)); + } + + /// A JIT snapshot loads fine and is refused at the gate rather than at the + /// parser, which is what keeps the refusal explainable. + #[test] + fn loading_a_jit_container_stops_at_the_gate_not_the_loader() { + let bundle = load_synthetic(&snapshot_blob_of_kind(AOT_HASH, AOT_FEATURES, 2)); + assert_eq!(bundle.identity.kind, Some(SnapshotKind::FullJit)); + assert_eq!( + bundle.identity.exact_selection_key(), + Err(IdentityRejection::NotFullAot(Some(SnapshotKind::FullJit))) + ); + } + + /// With no parseable header, the loader falls back to a byte scan. The hash + /// it finds is recorded as scanned, so it reaches the gate and is refused + /// instead of quietly selecting a parser. + #[test] + fn a_container_without_a_header_falls_back_to_a_scan_that_cannot_select() { + let mut data = vec![0u8; 16]; + data.extend_from_slice(AOT_HASH.as_bytes()); + data.extend_from_slice(b"product no-code_comments arm64\0"); + let bundle = load_synthetic(&data); + + assert_eq!(bundle.identity.hash.as_deref(), Some(AOT_HASH)); + assert_eq!(bundle.identity.hash_source, HashSource::Scan); + assert_eq!(bundle.identity.kind, None); + assert_eq!(bundle.snapshot_features, None); + assert_eq!(bundle.compressed_pointers, None); + assert_eq!( + bundle.identity.exact_selection_key(), + Err(IdentityRejection::HashNotHeaderDerived(HashSource::Scan)) + ); + } + + /// Nothing hash-shaped anywhere: the hash is unavailable rather than a + /// string that later compares against a registry key. + #[test] + fn a_container_with_no_hash_at_all_reports_it_as_unavailable() { + let bundle = load_synthetic(&[0x5au8; 256]); + assert_eq!(bundle.identity.hash, None); + assert_eq!(bundle.identity.hash_source, HashSource::Unavailable); + assert_eq!(bundle.snapshot_hash, "unknown"); + assert_eq!( + bundle.identity.exact_selection_key(), + Err(IdentityRejection::HashNotHeaderDerived( + HashSource::Unavailable + )) + ); + } } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 22f04f2c..a1e06b66 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -30,9 +30,12 @@ If adapter metadata is available, JSON output also includes app-package hints: - `app_package_count_total` - `app_package_counts_top` -- `adapter_kind` +- `requested_backend`, `resolved_backend`, `backend_fallback_reason` +- `producer_id`, `producer_trust`, `compatibility_record_sha256` - `manifest_entry_present` -- `adapter_snapshot_hash_match` +- `snapshot_identity_is_exact` +- `identity_rejection` +- `model_capabilities` - `compatibility_warnings` ## `flutterdec decompile` diff --git a/docs/development.md b/docs/development.md index 1e2160f3..d435d5ba 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,9 +52,23 @@ nix run .#ci-check - `crates/flutterdec-ir`: IR + CFG - `crates/flutterdec-decompiler`: pseudo-Dart emission and readability passes - `adapters/`: adapter metadata and Python adapter implementation -- `schemas/`: adapter schema +- `schemas/`: `program-model-v4.schema.json`, the JSON Schema for the adapter's ProgramModel v4 output - `scripts/`: regression tooling +## Adapter Model Contract Work + +`schemas/program-model-v4.schema.json` is committed and checked against the schema the +code generates, so an intentional change to `flutterdec_adapter::model` fails the test +suite until the file is regenerated: + +```bash +UPDATE_SCHEMA=1 cargo test -p flutterdec-adapter --test model_v4 the_committed_schema +``` + +The check is two-part: the committed file must equal `model::schema()`, and a maximal +model must validate against the schema in both directions, so a field added to the model +without a schema entry is caught as well as a schema entry with no field behind it. + ## Readability/Decompilation Work When modifying decompiler behavior: diff --git a/docs/how-it-works.md b/docs/how-it-works.md index f4aab5b9..2e342d87 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -76,7 +76,9 @@ sequenceDiagram 1. `info` - fast metadata path - loader always runs -- adapter runs only if installed for the detected hash +- the snapshot identity is gated first: a non-FullAOT, scanned-hash, or unsupported-target + snapshot reports why it was refused and no manifest, path, or adapter is touched +- adapter runs only if the identity cleared that gate and one is installed for the hash - APK inputs also run Android startup evidence extraction from `classes*.dex` and surface summary counts in JSON output - no disassembly, no IR, no pseudocode writing @@ -118,9 +120,9 @@ sequenceDiagram - VM-internal constructor selectors such as `_Closure` and `_TypeParameter` are rewritten to runtime constructor paths (`dart_vm.*.new`) - if selector evidence exists but no known standard mapping applies, indirect callsites use readable selector fallback forms: `dispatch.(...)` for general selectors and `.new(...)` for constructor-like selectors (annotated with `heuristic: constructor-like selector`) - selector evidence for indirect calls is inferred from both call arguments and indirect target expressions -- selector resolution also uses adapter pool metadata (`selector`, `owner_class`, `library_uri`) to build deterministic owner-qualified semantic paths -- owner-only metadata (`selector` + `owner_class` without `library_uri`) can still deterministically rewrite to owner-qualified call paths (`owner:Class.method`) -- missing selector/owner/library pool metadata can be backfilled from function ownership metadata (`target_va` -> function/class/library) before semantic resolution +- selector resolution also uses pool metadata to build deterministic owner-qualified semantic paths; in ProgramModel v4 a pool entry carries only `kind`, `value`, and `target_va`, so owner and library come from the function that `target_va` points at, resolved through typed `ClassId`/`LibraryId` edges +- owner-only metadata (a selector plus a resolved owning class whose library is unknown) can still deterministically rewrite to owner-qualified call paths (`owner:Class.method`) +- where the model resolves no owner at all, host-side `ProgramHints` can supply a selector or owner; a hint never overrides a model fact - if pool metadata carries `target_va` and symbol resolution for that VA is non-generic, indirect callsites can rewrite through that symbol path with `target_va` traceability comments - selector extraction ignores file/URI/path-like strings to reduce false-positive standard-call rewrites - unresolved `dispatchTarget` callsites prefer semantic library invoke names when URI evidence exists (for example `flutter.widgets.invoke(...)` or `spotube.models.connect.load.invoke(...)`), otherwise use callable target form `(...)` when the target expression is known, and only then use `dispatch.invoke(...)` fallback to reduce raw `dynamicCall(...)` noise @@ -166,6 +168,7 @@ This is the effective high-level control flow in `run_decompile`: ```text bundle = load_snapshot_bundle(input) +key = bundle.identity.exact_selection_key() // hard stop: no key, no lookup, no spawn model = run_adapter(resolve_adapter_exec(bundle.hash), bundle) scoped_model = apply_scope_filter(model, function_scope, app_package_filters) selected_model = apply_target_filter(scoped_model, model, target?) // optional --target id/va @@ -215,7 +218,7 @@ Important detail: - `crates/flutterdec-ir`: LLIR construction and CFG recovery - `crates/flutterdec-decompiler`: structured pseudo Dart emission and readability passes - `adapters/python/adapter_template.py`: default adapter implementation -- `schemas/adapter.schema.json`: adapter JSON contract schema +- `schemas/program-model-v4.schema.json`: generated JSON Schema for ProgramModel v4 ## Data models @@ -246,47 +249,98 @@ SnapshotBundle { } ``` -### ProgramModel +### ProgramModel v4 + +Produced by the adapter. v4 is the only accepted contract: `ProgramModel::from_json` +rejects a document carrying `schema_version` as a legacy v2/v3 model, and there is no +migration path. Fields: + +- `model_version` (always 4) +- `producer` (id, version, artifact SHA-256, host-assigned trust) +- `input` (the host's snapshot identity plus the region table with digests) +- `compatibility` (record digest, parser family, profile id and digest) +- `capabilities` (per-domain `complete` / `partial` / `unavailable`) +- `libraries[]`, `classes[]`, `functions[]` +- `object_pool` (index space, optional geometry, entries) +- `diagnostics[]` +- `extensions` (the only object that accepts undeclared keys) + +Four properties are what separate it from v3: + +- **Unknown is representable.** A function name is `Option`, a class's library is + `Option`, a superclass edge is `Option`. A function whose name was not recovered has + no name, rather than being called `sub_1234`. +- **Every fact carries provenance.** `exact`, `derived`, or `heuristic`, and only a + heuristic fact may carry a `confidence`. Per-domain capabilities say how much of a + domain was recovered, and validation rejects a model whose capability claims + contradict its contents. +- **The host decides, the adapter reports.** Identity, producer, and compatibility are + compared against what the host selected. An adapter cannot promote its own trust, + change which snapshot it was given, or claim a different compatibility record. +- **Identity is a gate, not a label.** A snapshot that cannot produce an exact selection + key never reaches manifest loading, executable resolution, or a process spawn; it is + refused with the typed rejection rather than run under a lower trust level. Every + producer record that exists therefore says `local`. + +Minimal example, from a producer that recovered code ranges and nothing else: -Produced by adapter. Main fields: +```json +{ + "model_version": 4, + "producer": {"id": "flutterdec-local-python", "version": "unknown", "artifact_sha256": "9f2c...", "trust": "local"}, + "input": { + "identity": {"hash": "63f9...", "hash_source": "header", "kind": "full_aot", "target_arch": "arm64", + "features": {"raw": "product arm64 compressed-pointers", "normalized": ["arm64", "compressed-pointers", "product"]}, + "pointer_compression": "compressed"}, + "regions": [{"region": "isolate_instructions", "size": 8192, "sha256": "1a2b...", "virtual_address": 6635520, "executable": true}] + }, + "compatibility": {"record_sha256": "44de...", "parser_family_id": "flutterdec-local-python", "profile_id": "3.9.0", "profile_sha256": "7b10..."}, + "capabilities": {"libraries": "partial", "classes": "unavailable", "class_relationships": "unavailable", + "functions": "partial", "function_names": "unavailable", "object_pool": "partial", "pool_index_space": "unavailable"}, + "libraries": [{"id": 0, "uri": "package:app/main.dart", "display_name": null, "provenance": "heuristic"}], + "classes": [], + "functions": [{"id": 0, "name": null, "owner": null, "code": {"start_va": 6640668, "size": 320}, + "code_section_va": 6635520, "provenance": "heuristic"}], + "object_pool": {"index_space": "ordinal", "geometry": null, + "entries": [{"index": 0, "kind": "string", "value": "package:app/main.dart", + "target_va": null, "provenance": "heuristic", "confidence": null}]}, + "diagnostics": [{"code": "domain_not_recovered", "severity": "warning", "subject": "function_names", + "message": "no function names are recoverable from instruction bytes alone"}], + "extensions": {} +} +``` -- `schema_version` -- `adapter_kind` -- `dart_version` -- `snapshot_hash` -- `arch` -- `libraries[]` -- `classes[]` -- `functions[]` -- `object_pool[]` -- `pool_geometry` (optional; see "Pool index space" below) +### Adapter protocol v1 -Schema compatibility: +One adapter run is one process invocation. The host writes the four snapshot regions +into a scratch directory along with a request document, runs the adapter there, and +reads back a result document plus the model. -- v2 and v3 are accepted by core -- v3 adds richer semantic metadata while preserving v2 compatibility defaults +The request carries the protocol and model majors, the host's identity, the producer +and compatibility records to echo, the requested backend, one relative +`InputHandle` per region (path, size, SHA-256, load address), and the output path. +Snapshot bytes are never embedded: a request for a half-gigabyte snapshot is a few +hundred bytes. There is no session, no JSON-RPC lifecycle, and no persistent worker. -Minimal example: +The result carries the same two majors, a structured status (`ok` / `unsupported` / +`failed`), the model path on success, a stable error code on failure, the backend that +actually ran, an optional fallback reason, and diagnostics. -```json -{ - "schema_version": 3, - "adapter_kind": "dynamic_snapshot_string_model_v1", - "dart_version": "unknown", - "snapshot_hash": "63f9...abcd", - "arch": "arm64", - "libraries": [{"id": 0, "uri": "package:app/main.dart", "name_display": "package:app/main.dart"}], - "classes": [{"id": 0, "name": "Global", "super": "Object", "lib": "package:app/main.dart"}], - "functions": [{"id": 0, "name": "sub_656c1c", "owner_class": "Global", "entry_va": 6640668, "size": 320, "code_section_va": 6635520, "name_kind": "placeholder"}], - "object_pool": [{"index": 0, "kind": "String", "value": "package:app/main.dart", "decoded_kind": "LibraryUri", "library_uri": "package:app/main.dart", "confidence": 0.4, "source": "internal"}] -} -``` +Backends are a closed vocabulary of three tokens - `internal`, `blutter`, `r2flutter` - +and the request's `requested_backend` and the result's `resolved_backend` spell them the +same way, so a producer can answer with the token it was handed. `requested_backend` is +additionally allowed to be `auto`, which is the only case in which a producer may pick a +backend and the only case in which `fallback_reason` may be set: a pinned backend fails +rather than substituting. ### FunctionDisassembly Produced by disassembler. Per function: -- function metadata (`id`, `name`, `entry_va`, `size`) +- function metadata (`id`, `entry_va`, `size`, and optional `name`/`owner_class`). + `name` is `None` when the model recovered none; the printed label is then derived from + the entry address (`fn_0x`), so an address-derived string can never be read back as + a recovered name - decoded instruction list (`AsmInstruction[]`) - per instruction annotation (`call`, `branch`, `return`, `pool[]`, `poolOff[]`, empty) @@ -376,36 +430,51 @@ The current Python template adapter: - recovers function starts with simple ARM64 heuristics - builds object pool from extracted strings - can run in `auto|internal|blutter|r2flutter` backend mode (`auto` tries r2flutter, then Blutter, then internal) -- in Blutter mode, parses `asm/*.dart` and `pp.txt` and synthesizes `EntryPointCandidate` pool metadata for `main`/`runApp`-like functions +- in Blutter mode, parses `asm/*.dart` and `pp.txt`; a declaration it cannot parse yields a function with no name, not a synthesized one - in r2flutter mode, shells out to `r2flutter -jH/-ji/-jc/-jxz/-jzz/-jp` and maps the AOT instruction table, class table, and ObjectPool-referenced strings onto the model -- emits schema version 3 JSON - -### Pool index space - -`ObjectPoolEntry.index` must be a real `ObjectPool` entry index, i.e. the value a -`ldr xN, [x27, #disp]` resolves to. An adapter asserts this by emitting -`pool_geometry` (`entries_offset`, `word_size`); core then converts displacements with -`index = (disp - entries_offset) / word_size`. +- emits ProgramModel v4 and an adapter protocol v1 result -Adapters that cannot recover the pool layout must omit `pool_geometry`. The internal -adapter is one: its `object_pool` is carved strings numbered by carve order, which has -nothing to do with the hardware index space. Core detects the absence and skips pool -value/semantic hints entirely rather than joining two unrelated index spaces, which -would attach real-looking strings to the wrong slots. `report.json.pool_metadata` -records `index_space_authoritative`, the geometry, and `hints_suppressed_reason`. +No backend invents a library, class, function name, or pool index it did not recover. +When a domain comes back empty it is reported `unavailable` with a diagnostic saying +why, which is what lets a reader tell "this snapshot has no such thing" apart from +"this backend cannot see it". -Validation in Rust enforces: +### Pool index space -- schema version is 2 or 3 -- arch equals `arm64` -- non-empty function list +`object_pool.index_space` says what an entry's `index` counts. `hardware` means a real +`ObjectPool` entry index, the value a `ldr xN, [x27, #disp]` resolves to; it requires +`geometry` (`entries_offset`, `word_size`), and core converts displacements with +`index = (disp - entries_offset) / word_size`. `ordinal` means a position in the +producer's own list, which carries no address meaning at all. + +The internal backend is ordinal: its entries are carved strings numbered by carve +order, which has nothing to do with the hardware index space. Core reads the declared +index space and skips pool value and semantic hints entirely rather than joining two +unrelated index spaces, which would attach real-looking strings to the wrong slots. +Validation makes the two consistent by construction: `hardware` without geometry is +rejected, and `ordinal` with geometry is rejected. `report.json.pool_metadata` records +`index_space_authoritative`, the geometry, and `hints_suppressed_reason`. + +Validation in Rust enforces, before any model reaches core analysis: + +- `model_version` is 4; a `schema_version` field is a legacy model and is rejected +- identity, producer, compatibility, and the region table match what the host selected +- no duplicate ids or pool indexes, no dangling references, no superclass cycles +- canonical ascending order for every collection, so equal models serialize equal +- no placeholder strings standing in for unrecovered names +- confidence only on heuristic facts, and only within `[0, 1]` +- capability claims that match the model's contents, and a diagnostic for every + unavailable domain +- checked arithmetic on every address: no overflowing or empty code range, every range + inside a declared executable region, every pool target inside one too Execution model: -- core creates temporary files for snapshot blobs -- adapter is invoked as a child process with explicit file paths -- adapter writes JSON to output path -- Rust side parses and validates that JSON +- core writes the four snapshot regions and a protocol v1 request into a scratch dir +- the adapter runs there as a child process, given only relative paths +- the adapter writes the model to the requested output path and a result document +- Rust parses the result, checks it answers the request, then parses and validates + the model against the host's own view before returning it Why process-based adapters: @@ -418,7 +487,7 @@ Key functions: - `run_adapter` - `resolve_adapter_exec` - `install_adapter` -- `validate_model` +- `validate::validate` ## 3) Disassembler @@ -439,8 +508,8 @@ Important behavior: majority of pool traffic in real binaries - page bases are tracked per function and dropped on any write to the register or on control flow, so a stale base can never invent a slot - - without `pool_geometry` the annotation is `poolOff[]` instead, - which is honest about what is known and never matches a value hint + - with an ordinal index space the annotation is `poolOff[]` + instead, which is honest about what is known and never matches a value hint Filtering behavior: @@ -671,7 +740,7 @@ File naming convention: - input metadata - counts for libraries, classes, functions, pool entries -- `adapter_schema.function_name_kind_breakdown` (exact/external/heuristic/placeholder/unknown/unspecified) +- `model.function_name_provenance` (exact/derived/heuristic/unnamed) - `adapter_selection` trace (requested backend, resolved backend, adapter exec, manifest mapping, snapshot hash match, and strict hash-match enforcement flag) - `compatibility` summary (adapter schema support, manifest-entry presence, snapshot hash alignment, and warning list) - embedded `quality` object diff --git a/docs/research-pseudocode-quality.md b/docs/research-pseudocode-quality.md index 17672df0..1688b195 100644 --- a/docs/research-pseudocode-quality.md +++ b/docs/research-pseudocode-quality.md @@ -2308,7 +2308,8 @@ carried. Here the schema is the tighter constraint: | pool entries | base required, semantic optional | 19,872 carved strings, `source=internal`, confidence 0.4; target VA **0**, owner class **0** | 19,156; target VA **0**, owner **0** | | pool geometry | optional; required to claim hardware indices | **absent** | **absent** | -`ClassInfo` in `schemas/adapter.schema.json:36-48` defines exactly `id`, `name`, `super`, +`ClassInfo` in the then-current `schemas/adapter.schema.json` (removed with the v4 +cutover) defined exactly `id`, `name`, `super`, `lib`, and the Rust deserializer mirrors that surface with no class-field, field-offset or method-table member. An exhaustive recursive raw-JSON audit of both captured models found only `classes[].{id,lib,name,super}`: no differently-spelled or nested field list, so this @@ -2384,7 +2385,8 @@ not hardware-space. PR #85, "resolve object pool references in the real index space, add r2flutter backend", is **already in the evaluated baseline**: its commit `36b4bc5` passes `git merge-base --is-ancestor 36b4bc5 2619ec7`. The named branch is an 11-commit -continuation beyond it. The implementation emits `pool_geometry` only when r2flutter +continuation beyond it. The implementation emits pool geometry (`object_pool.geometry` in +ProgramModel v4, `pool_geometry` at the time of this measurement) only when r2flutter reconstructs it and otherwise empties the semantic hints rather than guessing, which is why geometry is null here: **the mechanism is landed but unfed.** With radare2 and r2flutter absent from this host, the addressable prize is 0 of 70,635 and 0 of 84,205 `poolOff` diff --git a/docs/user-guide.md b/docs/user-guide.md index 5d1b9f64..c92946ba 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -171,9 +171,12 @@ For APK inputs, `info` also reports Android startup summary fields: If adapter metadata is available, `info` also reports: - `app_package_counts_top` -- `adapter_kind` +- `requested_backend`, `resolved_backend`, `backend_fallback_reason` +- `producer_id`, `producer_trust`, `compatibility_record_sha256` - `manifest_entry_present` -- `adapter_snapshot_hash_match` +- `snapshot_identity_is_exact` +- `identity_rejection` (why no adapter could be selected, when none could) +- `model_capabilities` (per-domain `complete` / `partial` / `unavailable`) - `compatibility_warnings` 2. Install the adapter for the detected Dart hash: @@ -302,18 +305,25 @@ Adapter backend options: - `--adapter-backend internal`: force internal adapter only - `--adapter-backend blutter`: require Blutter bridge backend with no fallback - `--adapter-backend r2-flutter`: require the r2flutter backend with no fallback -- `--require-snapshot-hash-match`: fail if adapter snapshot hash does not match loader snapshot hash +- `--require-snapshot-hash-match`: fail unless the snapshot identity came from a real header + +Requested and resolved backend are separate facts. `auto` may fall back, and the +result says which backend answered and why it differed; a named backend either runs or +fails, never silently substituted. Both appear in `info` output and in +`report.json.adapter_selection`. Backend choice decides how much is actually recovered. The internal adapter carves -strings and scans prologues: every function comes out as `sub_` and its -`object_pool` is a list of carved strings, not real pool slots. `r2flutter` and -`blutter` parse the snapshot, so they return exact Dart names and a real `ObjectPool`. - -Only a backend that reports `pool_geometry` lets `flutterdec` turn a `pool[N]` -reference in the disassembly into a value. Without it, pool references are left -unresolved on purpose, and `report.json.pool_metadata.hints_suppressed_reason` -explains why. Check `pool_metadata.index_space_authoritative` if pseudocode has fewer -string literals than you expected. +strings and scans prologues: it recovers code ranges with no names at all, and its +`object_pool` is a list of carved strings in an ordinal index space, not real pool +slots. Its `model_capabilities` say so: `function_names: unavailable`, +`pool_index_space: unavailable`. `r2flutter` and `blutter` parse the snapshot, so they +return real Dart names, and `r2flutter` can return a hardware `ObjectPool`. + +Only a hardware pool index space lets `flutterdec` turn a `pool[N]` reference in the +disassembly into a value. With an ordinal pool, references are left unresolved on +purpose, and `report.json.pool_metadata.hints_suppressed_reason` explains why. Check +`pool_metadata.index_space_authoritative` if pseudocode has fewer string literals than +you expected. r2flutter backend environment variables: diff --git a/schemas/adapter.schema.json b/schemas/adapter.schema.json deleted file mode 100644 index 2787a7d4..00000000 --- a/schemas/adapter.schema.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "flutterdec adapter output schema", - "type": "object", - "additionalProperties": true, - "required": [ - "schema_version", - "adapter_kind", - "dart_version", - "snapshot_hash", - "arch", - "libraries", - "classes", - "functions", - "object_pool" - ], - "properties": { - "schema_version": { "type": "integer", "enum": [2, 3] }, - "adapter_kind": { "type": "string" }, - "dart_version": { "type": "string" }, - "snapshot_hash": { "type": "string" }, - "arch": { "type": "string" }, - "libraries": { - "type": "array", - "items": { - "type": "object", - "required": ["id", "uri", "name_display"], - "additionalProperties": true, - "properties": { - "id": { "type": "integer", "minimum": 0 }, - "uri": { "type": "string" }, - "name_display": { "type": "string" } - } - } - }, - "classes": { - "type": "array", - "items": { - "type": "object", - "required": ["id", "name", "super", "lib"], - "additionalProperties": true, - "properties": { - "id": { "type": "integer", "minimum": 0 }, - "name": { "type": "string" }, - "super": { "type": "string" }, - "lib": { "type": "string" } - } - } - }, - "functions": { - "type": "array", - "items": { - "type": "object", - "required": ["id", "name", "owner_class", "entry_va", "size", "code_section_va"], - "additionalProperties": true, - "properties": { - "id": { "type": "integer", "minimum": 0 }, - "name": { "type": "string" }, - "owner_class": { "type": "string" }, - "entry_va": { "type": "integer", "minimum": 0 }, - "size": { "type": "integer", "minimum": 0 }, - "code_section_va": { "type": "integer", "minimum": 0 }, - "name_kind": { - "type": "string", - "enum": ["exact", "external", "heuristic", "placeholder"] - } - } - } - }, - "object_pool": { - "type": "array", - "items": { - "type": "object", - "required": ["index", "kind", "value"], - "additionalProperties": true, - "properties": { - "index": { "type": "integer", "minimum": 0 }, - "kind": { "type": "string" }, - "value": { "type": "string" }, - "decoded_kind": { "type": "string" }, - "selector": { "type": "string" }, - "target_va": { "type": "integer", "minimum": 0 }, - "owner_class": { "type": "string" }, - "library_uri": { "type": "string" }, - "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0 }, - "source": { - "type": "string", - "enum": ["vm", "blutter", "r2flutter", "internal", "synthetic", "unknown"] - } - } - } - }, - "pool_geometry": { - "description": "ObjectPool layout. Emit this only when object_pool[].index values are real ObjectPool entry indices, i.e. when a `ldr xN, [x27, #disp]` resolves to (disp - entries_offset) / word_size. Adapters that carve strings out of the snapshot must omit it; the core then declines to map pool references to values instead of guessing.", - "type": "object", - "required": ["entries_offset", "word_size"], - "additionalProperties": true, - "properties": { - "entries_offset": { "type": "integer", "minimum": 0 }, - "word_size": { "type": "integer", "minimum": 1 } - } - } - } -} diff --git a/schemas/program-model-v4.schema.json b/schemas/program-model-v4.schema.json new file mode 100644 index 00000000..fadf5b01 --- /dev/null +++ b/schemas/program-model-v4.schema.json @@ -0,0 +1,651 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "capabilities": { + "additionalProperties": false, + "properties": { + "class_relationships": { + "enum": [ + "complete", + "partial", + "unavailable" + ], + "type": "string" + }, + "classes": { + "enum": [ + "complete", + "partial", + "unavailable" + ], + "type": "string" + }, + "function_names": { + "enum": [ + "complete", + "partial", + "unavailable" + ], + "type": "string" + }, + "functions": { + "enum": [ + "complete", + "partial", + "unavailable" + ], + "type": "string" + }, + "libraries": { + "enum": [ + "complete", + "partial", + "unavailable" + ], + "type": "string" + }, + "object_pool": { + "enum": [ + "complete", + "partial", + "unavailable" + ], + "type": "string" + }, + "pool_index_space": { + "enum": [ + "complete", + "partial", + "unavailable" + ], + "type": "string" + } + }, + "required": [ + "libraries", + "classes", + "class_relationships", + "functions", + "function_names", + "object_pool", + "pool_index_space" + ], + "type": "object" + }, + "classes": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "library": { + "maximum": 4294967295, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "name": { + "minLength": 1, + "type": "string" + }, + "provenance": { + "enum": [ + "exact", + "derived", + "heuristic" + ], + "type": "string" + }, + "super_class": { + "maximum": 4294967295, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "id", + "name", + "library", + "super_class", + "provenance" + ], + "type": "object" + }, + "type": "array" + }, + "compatibility": { + "additionalProperties": false, + "properties": { + "parser_family_id": { + "minLength": 1, + "type": "string" + }, + "profile_id": { + "minLength": 1, + "type": "string" + }, + "profile_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "record_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "record_sha256", + "parser_family_id", + "profile_id", + "profile_sha256" + ], + "type": "object" + }, + "diagnostics": { + "items": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "domain_unsupported", + "domain_not_recovered", + "domain_partially_recovered", + "domain_heuristic_only", + "region_not_decoded", + "record_discarded" + ], + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "enum": [ + "info", + "warning", + "error" + ], + "type": "string" + }, + "subject": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "code", + "severity", + "subject", + "message" + ], + "type": "object" + }, + "type": "array" + }, + "extensions": { + "additionalProperties": true, + "description": "The only object in this schema that accepts undeclared keys. Hosts may ignore its contents; nothing in it carries authority.", + "type": "object" + }, + "functions": { + "items": { + "additionalProperties": false, + "properties": { + "code": { + "additionalProperties": false, + "properties": { + "size": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "start_va": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "start_va", + "size" + ], + "type": "object" + }, + "code_section_va": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "id": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "name": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "confidence": { + "maximum": 1.0, + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "provenance": { + "enum": [ + "exact", + "derived", + "heuristic" + ], + "type": "string" + }, + "text": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "text", + "provenance", + "confidence" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "owner": { + "maximum": 4294967295, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "provenance": { + "enum": [ + "exact", + "derived", + "heuristic" + ], + "type": "string" + } + }, + "required": [ + "id", + "name", + "owner", + "code", + "code_section_va", + "provenance" + ], + "type": "object" + }, + "type": "array" + }, + "input": { + "additionalProperties": false, + "properties": { + "identity": { + "additionalProperties": false, + "properties": { + "features": { + "additionalProperties": false, + "properties": { + "normalized": { + "items": { + "type": "string" + }, + "type": "array" + }, + "raw": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "raw", + "normalized" + ], + "type": "object" + }, + "hash": { + "pattern": "^[0-9a-f]{32}$", + "type": [ + "string", + "null" + ] + }, + "hash_source": { + "enum": [ + "header", + "scan", + "unavailable" + ], + "type": "string" + }, + "kind": { + "enum": [ + "full", + "full_core", + "full_jit", + "full_aot", + "unrecognized", + null + ], + "type": [ + "string", + "null" + ] + }, + "pointer_compression": { + "enum": [ + "compressed", + "uncompressed", + "unavailable", + "conflicting" + ], + "type": "string" + }, + "target_arch": { + "oneOf": [ + { + "enum": [ + "arm64" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "unsupported": { + "type": "string" + } + }, + "required": [ + "unsupported" + ], + "type": "object" + } + ] + } + }, + "required": [ + "hash", + "hash_source", + "kind", + "target_arch", + "features", + "pointer_compression" + ], + "type": "object" + }, + "regions": { + "items": { + "additionalProperties": false, + "properties": { + "executable": { + "type": "boolean" + }, + "region": { + "enum": [ + "vm_data", + "isolate_data", + "vm_instructions", + "isolate_instructions" + ], + "type": "string" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "size": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "virtual_address": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "region", + "size", + "sha256", + "virtual_address", + "executable" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "identity", + "regions" + ], + "type": "object" + }, + "libraries": { + "items": { + "additionalProperties": false, + "properties": { + "display_name": { + "type": [ + "string", + "null" + ] + }, + "id": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "provenance": { + "enum": [ + "exact", + "derived", + "heuristic" + ], + "type": "string" + }, + "uri": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "uri", + "display_name", + "provenance" + ], + "type": "object" + }, + "type": "array" + }, + "model_version": { + "const": 4, + "type": "integer" + }, + "object_pool": { + "additionalProperties": false, + "properties": { + "entries": { + "items": { + "additionalProperties": false, + "properties": { + "confidence": { + "maximum": 1.0, + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "index": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "kind": { + "enum": [ + "string", + "immediate", + "code", + "field", + "class", + "selector", + "undecoded" + ], + "type": "string" + }, + "provenance": { + "enum": [ + "exact", + "derived", + "heuristic" + ], + "type": "string" + }, + "target_va": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "index", + "kind", + "value", + "target_va", + "provenance", + "confidence" + ], + "type": "object" + }, + "type": "array" + }, + "geometry": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "entries_offset": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "word_size": { + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "entries_offset", + "word_size" + ], + "type": "object" + }, + { + "type": "null" + } + ] + }, + "index_space": { + "enum": [ + "hardware", + "ordinal" + ], + "type": "string" + } + }, + "required": [ + "index_space", + "geometry", + "entries" + ], + "type": "object" + }, + "producer": { + "additionalProperties": false, + "properties": { + "artifact_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "id": { + "minLength": 1, + "type": "string" + }, + "trust": { + "enum": [ + "registered", + "local", + "untrusted" + ], + "type": "string" + }, + "version": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "version", + "artifact_sha256", + "trust" + ], + "type": "object" + } + }, + "required": [ + "model_version", + "producer", + "input", + "compatibility", + "capabilities", + "libraries", + "classes", + "functions", + "object_pool", + "diagnostics" + ], + "title": "flutterdec ProgramModel v4", + "type": "object" +}