From c24020d4675dcd7a7410f037f2a1af087b80a6da Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Mon, 31 Aug 2026 12:10:30 -0400 Subject: [PATCH 01/18] feat(loader): derive a typed snapshot identity from the header Compatibility decisions were resting on a bare `snapshot_hash: String` that could equally be a header read, a byte scan, or the literal "unknown", and on a snapshot kind that was never read at all. Nothing downstream could tell the three apart, so a 32-hex run in a data section could authorize the same exact parser as a real header. `SnapshotIdentity` makes the difference a type. It carries the hash with its `HashSource`, the `Snapshot::Kind` field the header parser now decodes, the container's target architecture, normalized feature evidence, and pointer compression as a four-state value that keeps "said nothing" and "said both" apart instead of collapsing them. `exact_selection_key` is the pre-lookup gate: header-derived hash, FullAOT, supported target, no features/container target conflict, and known pointer compression. FullAOT is absent from the key it returns because it is a gate that has already passed, not a registry-key component; semantic Dart versions are absent because they are aliases of a hash and never selectors. `SnapshotBundle` keeps its existing flattened fields, now populated from the identity so the two cannot disagree. --- crates/flutterdec-loader/src/identity.rs | 340 +++++++++++++++++++++++ crates/flutterdec-loader/src/lib.rs | 245 ++++++++++++++-- 2 files changed, 568 insertions(+), 17 deletions(-) create mode 100644 crates/flutterdec-loader/src/identity.rs diff --git a/crates/flutterdec-loader/src/identity.rs b/crates/flutterdec-loader/src/identity.rs new file mode 100644 index 00000000..02c8d4e2 --- /dev/null +++ b/crates/flutterdec-loader/src/identity.rs @@ -0,0 +1,340 @@ +//! 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)] +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)] +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)] +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..421c2194 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,179 @@ 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())), + ); + } } From fb8e299fb8cdfabf6e3c4aafaa9f5d49fbdbfd60 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Mon, 31 Aug 2026 12:24:07 -0400 Subject: [PATCH 02/18] feat(adapter): add ProgramModel v4, protocol v1, and semantic validation The v2/v3 model could not express not knowing something. Every name was a required string, every reference was a display string compared by equality, and there was no field anywhere for how well a fact was known. A producer that read nothing still had to emit a library, a class, and a function, so the core could not distinguish recovered structure from filler. ProgramModel v4 makes the difference structural. Names are `Option`, so an unnamed function is a code range with no name. Every record carries a `Provenance`, and per-domain `Capabilities` say complete, partial, or unavailable; a domain claiming completeness while holding heuristic records contradicts itself. References are typed ids that can be resolved and checked. The model records the producer, the observed input with per-region digests, and the compatibility record that authorized the run, so a model can be tied back to the decision that produced it. Protocol v1 is one request in, one result out. Regions are path handles with sizes and digests, never embedded bytes, so a request for a half-gigabyte snapshot is under two kilobytes. `RelativePath` and `Sha256Digest` validate in `Deserialize`, so a traversal or a bogus digest cannot exist as a value. `validate` is the gate before core analysis. It compares host-selected facts against what the model reports, resolves every reference, rejects duplicate ids and noncanonical ordering, requires an unavailable domain to say why, permits confidence only on heuristic facts, and checks every address and size with checked arithmetic against the declared executable regions. v2 and v3 JSON is rejected by version before deserialization, so an operator gets "there is no compatibility shim" rather than a list of missing fields. The committed schema is closed except one `extensions` object. Two checks guard it: the file must equal the generated schema, and a maximal model is walked against the schema in both directions, so a field added to a struct without a matching property fails the build. --- Cargo.lock | 67 + Cargo.toml | 1 + crates/flutterdec-adapter/Cargo.toml | 2 + crates/flutterdec-adapter/src/lib.rs | 5 + crates/flutterdec-adapter/src/model.rs | 947 +++++++++++++ crates/flutterdec-adapter/src/primitives.rs | 226 ++++ crates/flutterdec-adapter/src/protocol.rs | 343 +++++ crates/flutterdec-adapter/src/validate.rs | 937 +++++++++++++ crates/flutterdec-adapter/tests/model_v4.rs | 1171 +++++++++++++++++ .../flutterdec-adapter/tests/protocol_v1.rs | 382 ++++++ .../flutterdec-adapter/tests/support/mod.rs | 253 ++++ crates/flutterdec-loader/src/identity.rs | 3 + schemas/program-model-v4.schema.json | 648 +++++++++ 13 files changed, 4985 insertions(+) create mode 100644 crates/flutterdec-adapter/src/model.rs create mode 100644 crates/flutterdec-adapter/src/primitives.rs create mode 100644 crates/flutterdec-adapter/src/protocol.rs create mode 100644 crates/flutterdec-adapter/src/validate.rs create mode 100644 crates/flutterdec-adapter/tests/model_v4.rs create mode 100644 crates/flutterdec-adapter/tests/protocol_v1.rs create mode 100644 crates/flutterdec-adapter/tests/support/mod.rs create mode 100644 schemas/program-model-v4.schema.json diff --git a/Cargo.lock b/Cargo.lock index 7b416310..c35bfb05 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", ] @@ -370,6 +410,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 +830,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 +928,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/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..f8f5e4df 100644 --- a/crates/flutterdec-adapter/src/lib.rs +++ b/crates/flutterdec-adapter/src/lib.rs @@ -1,3 +1,8 @@ +pub mod model; +pub mod primitives; +pub mod protocol; +pub mod validate; + use anyhow::{anyhow, bail, Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; diff --git a/crates/flutterdec-adapter/src/model.rs b/crates/flutterdec-adapter/src/model.rs new file mode 100644 index 00000000..decb6639 --- /dev/null +++ b/crates/flutterdec-adapter/src/model.rs @@ -0,0 +1,947 @@ +//! 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, + } + } +} + +/// 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, + pub library: LibraryId, + /// `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) + } +} + +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", "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..1f89f4cc --- /dev/null +++ b/crates/flutterdec-adapter/src/protocol.rs @@ -0,0 +1,343 @@ +//! 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::{Diagnostic, InputRegionName, 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; + +/// 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, + /// Digest of the compatibility record that selected this adapter. Echoed + /// into the model so the run can be tied back to the decision. + pub compatibility_record_sha256: Sha256Digest, + /// The host's identity for the snapshot. Not a suggestion: the adapter must + /// report it back unchanged. + pub identity: SnapshotIdentity, + 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, + 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, +} + +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", + ), + } + } +} + +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); + } + Ok(()) + } + + pub fn ok(model: RelativePath, diagnostics: Vec) -> Self { + Self { + protocol_major: PROTOCOL_MAJOR, + model_major: MODEL_VERSION, + status: AdapterStatus::Ok, + model: Some(model), + error: None, + 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(), + }), + 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..dccd1b90 --- /dev/null +++ b/crates/flutterdec-adapter/src/validate.rs @@ -0,0 +1,937 @@ +//! 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); 6] = [ + ("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, + ), + ( + "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 model.library(class.library).is_none() { + return Err(ValidationError::MissingLibraryReference { + class: class.id.0, + library: class.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 Some(region) = executable + .iter() + .find(|r| r.contains_range(function.code.start_va, function.code.size)) + else { + return Err(ValidationError::CodeRangeOutsideExecutableRegions { + function: function.id.0, + start_va: function.code.start_va, + size: function.code.size, + }); + }; + // The declared section base has to be the base of the region the code is + // actually in, or the producer and the host disagree about the layout. + if region.virtual_address != Some(function.code_section_va) { + 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..05b7e039 --- /dev/null +++ b/crates/flutterdec-adapter/tests/model_v4.rs @@ -0,0 +1,1171 @@ +//! 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, 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) + ); +} diff --git a/crates/flutterdec-adapter/tests/protocol_v1.rs b/crates/flutterdec-adapter/tests/protocol_v1.rs new file mode 100644 index 00000000..fe089b51 --- /dev/null +++ b/crates/flutterdec-adapter/tests/protocol_v1.rs @@ -0,0 +1,382 @@ +//! 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, InputHandle, + ProtocolError, 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_record_sha256: Sha256Digest::of(b"record"), + identity: support::identity(), + 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"), 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); +} + +/// 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"), 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"), 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"), 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()); +} diff --git a/crates/flutterdec-adapter/tests/support/mod.rs b/crates/flutterdec-adapter/tests/support/mod.rs new file mode 100644 index 00000000..8b006a73 --- /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: LibraryId(1), + super_class: None, + provenance: Provenance::Exact, + }, + Class { + id: ClassId(2), + name: "App".to_string(), + library: 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-loader/src/identity.rs b/crates/flutterdec-loader/src/identity.rs index 02c8d4e2..bc71e4f8 100644 --- a/crates/flutterdec-loader/src/identity.rs +++ b/crates/flutterdec-loader/src/identity.rs @@ -121,6 +121,7 @@ pub enum PointerCompression { /// 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, @@ -178,6 +179,7 @@ impl FeatureEvidence { /// 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, @@ -252,6 +254,7 @@ impl std::error::Error for IdentityRejection {} /// 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, diff --git a/schemas/program-model-v4.schema.json b/schemas/program-model-v4.schema.json new file mode 100644 index 00000000..ba9a1ef2 --- /dev/null +++ b/schemas/program-model-v4.schema.json @@ -0,0 +1,648 @@ +{ + "$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" + }, + "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" +} From 4e4e446071142de2b40c1ee4f0c5d28097fa7ede Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Mon, 31 Aug 2026 12:28:49 -0400 Subject: [PATCH 03/18] test(loader): exercise snapshot identity through a real container load The identity tests went through the header parser directly, so the step that actually builds a `SnapshotIdentity` during a load, including the scan fallback and the flattened bundle fields, had no coverage. These four cases assemble a minimal ARM64 shared object carrying the snapshot symbols and run `load_snapshot_bundle` over the bytes: a FullAOT header that passes the gate, a FullJIT header that loads and is refused at the gate rather than at the parser, a container with no header whose scanned hash cannot select, and one with no hash at all. --- crates/flutterdec-adapter/src/lib.rs | 6 + crates/flutterdec-adapter/src/validate.rs | 20 +- crates/flutterdec-loader/src/lib.rs | 219 ++++++++++++++++++++++ 3 files changed, 237 insertions(+), 8 deletions(-) diff --git a/crates/flutterdec-adapter/src/lib.rs b/crates/flutterdec-adapter/src/lib.rs index f8f5e4df..dff06bbe 100644 --- a/crates/flutterdec-adapter/src/lib.rs +++ b/crates/flutterdec-adapter/src/lib.rs @@ -94,6 +94,12 @@ impl PoolGeometry { } } +/// Legacy adapter output, superseded by [`model::ProgramModel`]. +/// +/// Kept only because the core, disassembler, and CLI still read it. It is not a +/// v4 compatibility shim and must not become one: nothing converts between the +/// two, and `model::ProgramModel::from_json` rejects any document this type can +/// parse. Migrating the remaining consumers removes this type outright. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProgramModel { pub schema_version: u32, diff --git a/crates/flutterdec-adapter/src/validate.rs b/crates/flutterdec-adapter/src/validate.rs index dccd1b90..0bcbd20c 100644 --- a/crates/flutterdec-adapter/src/validate.rs +++ b/crates/flutterdec-adapter/src/validate.rs @@ -874,19 +874,23 @@ fn check_addresses(model: &ProgramModel) -> Check { id: u64::from(function.id.0), }); } - let Some(region) = executable - .iter() - .find(|r| r.contains_range(function.code.start_va, function.code.size)) - else { + 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 be the base of the region the code is - // actually in, or the producer and the host disagree about the layout. - if region.virtual_address != Some(function.code_section_va) { + } + // 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, diff --git a/crates/flutterdec-loader/src/lib.rs b/crates/flutterdec-loader/src/lib.rs index 421c2194..f6924db5 100644 --- a/crates/flutterdec-loader/src/lib.rs +++ b/crates/flutterdec-loader/src/lib.rs @@ -747,4 +747,223 @@ mod tests { 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 mut 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 mut 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 + )) + ); + } } From f7f9ba50b25f6c0b78571fa738f9f4d52e953b98 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Mon, 31 Aug 2026 13:26:18 -0400 Subject: [PATCH 04/18] feat(adapter)!: cut every producer and consumer over to ProgramModel v4 v2 and v3 were shaped so that "recovered nothing" and "recovered everything" serialized the same way, and the core had no way to tell them apart. Every name was a required String, so a producer that could not read a snapshot still had to emit one: `sub_1234` for a function, `Global` for its owner, `package:app/main.dart` for its library, `entry` when even the prologue scan came up empty. Those strings then flowed through naming, prioritisation, scope filtering, and pseudocode as if they were facts. This removes the v2/v3 types outright rather than adapting them. `ProgramModel:: from_json` already rejected a `schema_version` document; now nothing in the tree can produce one, and there is no path that reads one. What changed at the boundary: * The Python producer speaks adapter protocol v1: one request in, one result out, region handles with digests rather than a pile of CLI flags. It reads the four regions, verifies each against its declared size and SHA-256, and echoes the host's identity, producer, and compatibility records back so validation can check that the model answers the question that was asked. * No backend invents anything any more. The internal backend emits code ranges with no names and `function_names: unavailable`; carved strings become an ordinal pool with `pool_index_space: unavailable`; a data image with no `package:` URI yields no libraries. Blutter's `class :: {` header is a library's top-level members, so functions under it have no owner rather than an owner called `Global`. r2flutter's classes keep `library: null` because r2flutter has no attribution table. Every empty domain carries a diagnostic saying why. * `Class.library` became optional. A producer that recovers class names without an attribution table has to be able to say so; requiring a library id is how a class ended up filed under an invented URI. What changed in the host: * Requested backend, resolved backend, fallback reason, and model producer are four distinct typed facts. `resolved_backend_from_adapter_kind` used to read the resolved backend out of a substring of adapter-authored free text, so an adapter naming itself `r2flutter_...` became one. The protocol result names it from a closed enum instead, a pinned backend can fail but never be substituted, and a fallback reason is only meaningful under `auto`. * Core enrichment no longer writes into the model. APK startup, manifest, and name-pattern passes used to append synthetic `ObjectPoolEntry` records at `index = object_pool.len()`, which put derived guesses in the same index space as hardware pool slots and grew that space on every pass. They now produce `ProgramHints`: separate records that always name their origin and provenance, can never be exact, and cannot collide with anything the adapter authored. * `FunctionDisassembly` carries `Option` for name and owner. Downstream renders `fn_0x` from the entry address at emit time, which is a fact about where the code is rather than a claim about what it is. * The snapshot hash is no longer cross-checked against adapter output, because validation already rejects a model that changed it. What is worth reporting, and what `--require-snapshot-hash-match` now gates on, is whether the identity came out of a real header at all. `schemas/adapter.schema.json` is deleted; `schemas/program-model-v4.schema.json` is the only model schema and is drift-checked against the Rust types. `tests/producer_v4.rs` runs the real `adapter_template.py` as a subprocess through `run_adapter` for the empty, heuristic-only, carved-string, blutter, fallback, and misleading-filename cases, and asserts the absence of every fabricated token. --- Cargo.lock | 1 + adapters/python/adapter_template.py | 1519 +++++---- crates/flutterdec-adapter/src/lib.rs | 607 ++-- crates/flutterdec-adapter/src/model.rs | 40 +- crates/flutterdec-adapter/src/protocol.rs | 206 +- crates/flutterdec-adapter/src/validate.rs | 12 +- crates/flutterdec-adapter/tests/model_v4.rs | 23 +- .../flutterdec-adapter/tests/producer_v4.rs | 666 ++++ .../flutterdec-adapter/tests/protocol_v1.rs | 146 +- .../flutterdec-adapter/tests/support/mod.rs | 4 +- crates/flutterdec-cli/src/main.rs | 32 +- crates/flutterdec-core/src/lib.rs | 30 +- .../src/pipeline/apk_startup.rs | 579 ++-- .../src/pipeline/bootflow_hints.rs | 194 +- crates/flutterdec-core/src/pipeline/model.rs | 163 +- .../flutterdec-core/src/pipeline/runners.rs | 539 +-- .../src/pipeline/runners/manifest.rs | 171 +- .../src/pipeline/runners/reporting.rs | 256 +- .../src/pipeline/runners/split.rs | 24 +- .../src/pipeline/runners/stubs.rs | 20 +- .../src/pipeline/runners/symbols.rs | 216 +- .../src/pipeline/runners/tests.rs | 1716 ++++------ .../src/pipeline/runners_diff.rs | 70 +- crates/flutterdec-disasm-arm64/Cargo.toml | 3 + crates/flutterdec-disasm-arm64/src/hints.rs | 243 ++ crates/flutterdec-disasm-arm64/src/lib.rs | 2894 ++++++----------- crates/flutterdec-ir/src/lib.rs | 26 +- crates/flutterdec-loader/src/lib.rs | 4 +- schemas/adapter.schema.json | 104 - schemas/program-model-v4.schema.json | 5 +- 30 files changed, 5138 insertions(+), 5375 deletions(-) create mode 100644 crates/flutterdec-adapter/tests/producer_v4.rs create mode 100644 crates/flutterdec-disasm-arm64/src/hints.rs delete mode 100644 schemas/adapter.schema.json diff --git a/Cargo.lock b/Cargo.lock index c35bfb05..c8a7180b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,6 +379,7 @@ version = "0.1.0-alpha.4" dependencies = [ "capstone", "flutterdec-adapter", + "flutterdec-loader", "regex", "serde", ] diff --git a/adapters/python/adapter_template.py b/adapters/python/adapter_template.py index c14a2659..f9830285 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[str, str]]: +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. + + `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,370 @@ 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_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 = _sanitize_class_name(raw_super or "") + 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 + return [by_index[i] for i in sorted(by_index)] -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 - - -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") + + iso_va = _region_va(request, ISOLATE_INSTRUCTIONS) + iso_size = _handle(request, ISOLATE_INSTRUCTIONS)["size"] - header = _r2flutter_json(runner, target, "-jH") + 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/crates/flutterdec-adapter/src/lib.rs b/crates/flutterdec-adapter/src/lib.rs index dff06bbe..2901eff2 100644 --- a/crates/flutterdec-adapter/src/lib.rs +++ b/crates/flutterdec-adapter/src/lib.rs @@ -1,120 +1,34 @@ +//! 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 model::{ + CompatibilityBinding, InputRegion, InputRegionName, Producer, ProducerTrust, 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) - } -} - -/// Legacy adapter output, superseded by [`model::ProgramModel`]. -/// -/// Kept only because the core, disassembler, and CLI still read it. It is not a -/// v4 compatibility shim and must not become one: nothing converts between the -/// two, and `model::ProgramModel::from_json` rejects any document this type can -/// parse. Migrating the remaining consumers removes this type outright. -#[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 { @@ -128,17 +42,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 { @@ -211,10 +153,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(); @@ -248,80 +187,184 @@ 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"); +/// How far the host may trust a producer it is about to run. +/// +/// PR1 has no registry, so nothing can be [`ProducerTrust::Registered`] yet. +/// What is decidable today is whether the identity could have authorized an +/// exact parser at all: a locally installed adapter behind a header-derived +/// FullAOT identity is `Local`, and everything else is `Untrusted` evidence. +pub fn local_producer_trust( + identity: &flutterdec_loader::identity::SnapshotIdentity, +) -> ProducerTrust { + match identity.exact_selection_key() { + Ok(_) => ProducerTrust::Local, + Err(_) => ProducerTrust::Untrusted, } - Ok(()) } -pub fn run_adapter(exec_path: &Path, input: &AdapterInput<'_>) -> Result { - let tmp = tempdir().context("create tempdir for adapter")?; +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", + } +} - 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"); +const OUTPUT_MODEL_PATH: &str = "model.json"; +const REQUEST_PATH: &str = "request.json"; +const RESULT_PATH: &str = "result.json"; - 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 { + 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 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 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 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)] @@ -337,7 +380,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"); @@ -349,226 +392,4 @@ mod tests { 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 index decb6639..87e0a8a1 100644 --- a/crates/flutterdec-adapter/src/model.rs +++ b/crates/flutterdec-adapter/src/model.rs @@ -413,6 +413,14 @@ impl Name { } } +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)] @@ -444,7 +452,11 @@ pub struct Library { pub struct Class { pub id: ClassId, pub name: String, - pub library: LibraryId, + /// `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, @@ -673,6 +685,30 @@ impl ProgramModel { 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 { @@ -771,7 +807,7 @@ pub fn schema() -> Value { json!({ "id": { "type": "integer", "minimum": 0, "maximum": 4294967295u32 }, "name": { "type": "string", "minLength": 1 }, - "library": { "type": "integer", "minimum": 0, "maximum": 4294967295u32 }, + "library": { "type": ["integer", "null"], "minimum": 0, "maximum": 4294967295u32 }, "super_class": { "type": ["integer", "null"], "minimum": 0, "maximum": 4294967295u32 }, "provenance": provenance_enum(), }), diff --git a/crates/flutterdec-adapter/src/protocol.rs b/crates/flutterdec-adapter/src/protocol.rs index 1f89f4cc..c653f3c3 100644 --- a/crates/flutterdec-adapter/src/protocol.rs +++ b/crates/flutterdec-adapter/src/protocol.rs @@ -13,7 +13,7 @@ //! Both documents carry the protocol and model majors they were written for. //! Version negotiation is a rejection, not a translation. -use crate::model::{Diagnostic, InputRegionName, MODEL_VERSION}; +use crate::model::{CompatibilityBinding, Diagnostic, InputRegionName, Producer, MODEL_VERSION}; use crate::primitives::{RelativePath, Sha256Digest}; use flutterdec_loader::identity::SnapshotIdentity; use serde::{Deserialize, Serialize}; @@ -24,6 +24,127 @@ 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. + 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)] @@ -47,12 +168,19 @@ pub struct InputHandle { pub struct AdapterRequest { pub protocol_major: u32, pub model_major: u32, - /// Digest of the compatibility record that selected this adapter. Echoed - /// into the model so the run can be tied back to the decision. - pub compatibility_record_sha256: Sha256Digest, + /// 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, @@ -116,6 +244,12 @@ pub struct AdapterResult { 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, } @@ -138,6 +272,16 @@ pub enum ProtocolError { 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 { @@ -178,6 +322,20 @@ impl fmt::Display for ProtocolError { 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", + ), } } } @@ -306,16 +464,52 @@ impl AdapterResult { 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, diagnostics: Vec) -> Self { + 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, } } @@ -330,6 +524,8 @@ impl AdapterResult { code, message: message.into(), }), + resolved_backend: None, + fallback_reason: None, diagnostics: Vec::new(), } } diff --git a/crates/flutterdec-adapter/src/validate.rs b/crates/flutterdec-adapter/src/validate.rs index 0bcbd20c..66b165e0 100644 --- a/crates/flutterdec-adapter/src/validate.rs +++ b/crates/flutterdec-adapter/src/validate.rs @@ -612,11 +612,13 @@ fn check_identity_and_order(model: &ProgramModel) -> Check { fn check_references(model: &ProgramModel) -> Check { for class in &model.classes { - if model.library(class.library).is_none() { - return Err(ValidationError::MissingLibraryReference { - class: class.id.0, - library: class.library.0, - }); + 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() { diff --git a/crates/flutterdec-adapter/tests/model_v4.rs b/crates/flutterdec-adapter/tests/model_v4.rs index 05b7e039..abac8279 100644 --- a/crates/flutterdec-adapter/tests/model_v4.rs +++ b/crates/flutterdec-adapter/tests/model_v4.rs @@ -8,7 +8,8 @@ mod support; use flutterdec_adapter::model::{ - schema, CapabilityLevel, Domain, ModelParseError, ProgramModel, MODEL_VERSION, + schema, CapabilityLevel, ClassId, Diagnostic, Domain, ModelParseError, ProgramModel, + MODEL_VERSION, }; use flutterdec_adapter::validate::{validate, ValidationError}; use serde_json::{json, Value}; @@ -1169,3 +1170,23 @@ fn the_schema_pins_the_model_version() { 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..387de43e --- /dev/null +++ b/crates/flutterdec-adapter/tests/producer_v4.rs @@ -0,0 +1,666 @@ +//! 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, 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 } +} + +fn producer(identity: &SnapshotIdentity, 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: flutterdec_adapter::local_producer_trust(identity), + } +} + +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(identity, &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("deadbeefdeadbeefdeadbeefdeadbeef"); + let identity = support::identity(); + // r2flutter is not on PATH in this environment, and the input path the + // backend needs is absent, so it cannot run. Pinned means it must fail. + 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("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(&identity, &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())); +} diff --git a/crates/flutterdec-adapter/tests/protocol_v1.rs b/crates/flutterdec-adapter/tests/protocol_v1.rs index fe089b51..43f6fdf9 100644 --- a/crates/flutterdec-adapter/tests/protocol_v1.rs +++ b/crates/flutterdec-adapter/tests/protocol_v1.rs @@ -8,8 +8,8 @@ mod support; use flutterdec_adapter::model::{InputRegionName, MODEL_VERSION}; use flutterdec_adapter::primitives::{RelativePath, Sha256Digest}; use flutterdec_adapter::protocol::{ - AdapterError, AdapterErrorCode, AdapterRequest, AdapterResult, AdapterStatus, InputHandle, - ProtocolError, PROTOCOL_MAJOR, + 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}; @@ -33,8 +33,10 @@ fn request() -> AdapterRequest { AdapterRequest { protocol_major: PROTOCOL_MAJOR, model_major: MODEL_VERSION, - compatibility_record_sha256: Sha256Digest::of(b"record"), + 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), @@ -80,7 +82,12 @@ fn a_request_round_trips_through_fresh_json() { #[test] fn a_result_round_trips_in_both_its_shapes() { - let ok = AdapterResult::ok(path("out/model.json"), Vec::new()); + 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 @@ -119,7 +126,12 @@ fn unsupported_majors_are_rejected_on_both_documents() { Err(ProtocolError::UnsupportedModelMajor(3)) ); - let result = AdapterResult::ok(path("out/model.json"), Vec::new()); + 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"); @@ -323,14 +335,24 @@ fn handles_must_be_distinct() { /// 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"), Vec::new()); + 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"), Vec::new()); + 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(), @@ -380,3 +402,113 @@ fn the_request_carries_the_host_identity_unchanged() { 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 index 8b006a73..51eadc02 100644 --- a/crates/flutterdec-adapter/tests/support/mod.rs +++ b/crates/flutterdec-adapter/tests/support/mod.rs @@ -139,14 +139,14 @@ pub fn maximal_model() -> ProgramModel { Class { id: ClassId(1), name: "Object".to_string(), - library: LibraryId(1), + library: Some(LibraryId(1)), super_class: None, provenance: Provenance::Exact, }, Class { id: ClassId(2), name: "App".to_string(), - library: LibraryId(2), + library: Some(LibraryId(2)), super_class: Some(ClassId(1)), provenance: Provenance::Exact, }, diff --git a/crates/flutterdec-cli/src/main.rs b/crates/flutterdec-cli/src/main.rs index fbb0907d..612115cc 100644 --- a/crates/flutterdec-cli/src/main.rs +++ b/crates/flutterdec-cli/src/main.rs @@ -505,14 +505,38 @@ 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(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..a5f40e4f 100644 --- a/crates/flutterdec-core/src/lib.rs +++ b/crates/flutterdec-core/src/lib.rs @@ -1,21 +1,28 @@ #![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::{ 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::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -224,9 +231,22 @@ 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, + /// 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..0db2458e 100644 --- a/crates/flutterdec-core/src/pipeline/model.rs +++ b/crates/flutterdec-core/src/pipeline/model.rs @@ -1,11 +1,125 @@ +/// 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(); + let canonical = format!( + "dart_version={};profile_version={};tag_style={};compressed_word_size={};header_fields={};max_alignment={};heap_object_tag={};cids={:?}", + resolved.dart_version, + 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 when the identity cleared the +/// FullAOT gate, and the rejection reason when it did not, so a model produced +/// under a different decision has a different binding. +fn compatibility_binding(bundle: &SnapshotBundle) -> CompatibilityBinding { + let (profile_id, profile_sha256) = profile_binding(bundle.dart_profile.as_ref()); + let decision = match bundle.identity.exact_selection_key() { + Ok(key) => format!( + "exact;hash={};arch={};features={}", + key.hash, + key.target_arch.as_str(), + key.features.join(",") + ), + Err(rejection) => format!("inexact;{}", rejection), + }; + 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>, bundle: &SnapshotBundle) -> 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), + trust: flutterdec_adapter::local_producer_trust(&bundle.identity), + }) +} + +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, + } +} + fn load_model( repo_root: &Path, bundle: &SnapshotBundle, @@ -17,23 +131,54 @@ fn load_model( .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()), + bundle, + )?; + let compatibility = compatibility_binding(bundle); + + 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()), }) diff --git a/crates/flutterdec-core/src/pipeline/runners.rs b/crates/flutterdec-core/src/pipeline/runners.rs index f9888540..8185bb3c 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, @@ -1023,9 +998,15 @@ 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()), + model_capabilities: None, compatibility_warnings: None, function_count: None, class_count: None, @@ -1042,24 +1023,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 +1068,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 +1089,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) -> Vec<(String, String)> { + 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 +1117,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 +1170,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 +1214,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 +1261,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 +1299,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 +1338,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 +1368,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 +1397,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 +1570,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 +1717,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 +1726,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 +1775,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 +1791,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 +1807,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 +1823,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 +1839,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 +1856,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 +1884,51 @@ 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) + }, + "compatibility": { + "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() + }, + "model": { + "model_version": model.model_version, + "name_pattern_hints": model_name_hints, + "capabilities": capability_map(&model.capabilities) + .into_iter() + .map(|(domain, level)| (domain, serde_json::Value::String(level))) + .collect::>(), + "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, @@ -1909,17 +1947,23 @@ pub fn run_decompile( }, "compatibility": { "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 +2003,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 +2029,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 +2123,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 +2134,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 +2186,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 +2254,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..034e7923 100644 --- a/crates/flutterdec-core/src/pipeline/runners/tests.rs +++ b/crates/flutterdec-core/src/pipeline/runners/tests.rs @@ -1,7 +1,188 @@ use super::*; - use flutterdec_disasm_arm64::{AsmInstruction, FunctionDisassembly, FunctionPriorityComponent}; + use flutterdec_adapter::model::{ + Capabilities, CapabilityLevel, Class, ClassId, CodeRange, CompatibilityBinding, Function, + FunctionId, InputRegion, InputRegionName, Library, LibraryId, Name, ObjectPool, + ObservedInput, PoolEntry, PoolEntryKind, PoolGeometry, PoolIndexSpace, Producer, + ProducerTrust, Provenance, MODEL_VERSION, + }; + use flutterdec_adapter::primitives::Sha256Digest; + 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 +251,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 +306,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 +472,92 @@ } #[test] - fn resolves_symbol_quality_from_name_kind_values() { - assert_eq!( - symbol_name_quality_from_name_kind(Some("exact")), - Some(SymbolNameQuality::Exact) - ); + fn symbol_quality_follows_the_models_name_provenance() { assert_eq!( - symbol_name_quality_from_name_kind(Some("external")), - Some(SymbolNameQuality::External) + symbol_name_quality_from_provenance(Provenance::Exact), + SymbolNameQuality::Exact ); assert_eq!( - symbol_name_quality_from_name_kind(Some("heuristic")), - Some(SymbolNameQuality::Heuristic) + symbol_name_quality_from_provenance(Provenance::Derived), + SymbolNameQuality::External ); assert_eq!( - symbol_name_quality_from_name_kind(Some("placeholder")), - Some(SymbolNameQuality::Placeholder) + symbol_name_quality_from_provenance(Provenance::Heuristic), + SymbolNameQuality::Heuristic ); - 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 +583,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 +592,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 +601,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!( - resolved_backend_from_adapter_kind("blutter_bridge_model_v1"), - Some(AdapterBackend::Blutter) + backend_from_id(BackendId::Blutter), + AdapterBackend::Blutter ); assert_eq!( - resolved_backend_from_adapter_kind("dynamic_snapshot_string_model_v1"), - Some(AdapterBackend::Internal) + backend_from_id(BackendId::R2Flutter), + AdapterBackend::R2Flutter + ); + assert_eq!( + 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 +788,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 +849,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 +896,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 +942,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 +960,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 +995,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 +1015,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 +1082,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 +1132,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 +1152,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 +1185,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 +1234,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 +1443,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 +1472,39 @@ #[test] fn keeps_bootflow_entries_with_same_target_and_selector_when_source_differs() { - 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()), - }, - ], - }; + 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(&model); + let summary = collect_bootflow_discovery(&hints); assert_eq!(summary.main.len(), 2); - assert!(summary.main.iter().any(|entry| entry.source == "manifest")); + assert!(summary + .main + .iter() + .any(|entry| entry.source == "android_manifest")); assert!(summary.main.iter().any(|entry| entry.source == "apk_startup")); } + /// 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 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, - uri: "package:app/main.dart".to_string(), - name_display: "package:app/main.dart".to_string(), - }], - 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(), - }, + 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), ], - 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, - }, + 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), ], - pool_geometry: None, - object_pool: Vec::new(), - }; + 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 +1516,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 +1574,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 +1601,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 +1683,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 +1726,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 +1739,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 +1764,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..8ece62a8 100644 --- a/crates/flutterdec-core/src/pipeline/runners_diff.rs +++ b/crates/flutterdec-core/src/pipeline/runners_diff.rs @@ -1,18 +1,31 @@ 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); +/// 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(""); if library_uri.is_empty() { - return format!("{}::{}", func.owner_class, func.name); + return format!("{}::{}", owner, name); } - format!("{}::{}::{}", library_uri, func.owner_class, func.name) + 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 +41,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 +101,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 +140,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-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..cbdd4122 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,101 +415,64 @@ 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. +fn pool_selector_signal_score(value: &str, library_lower: &str) -> i32 { + let selector = value.trim().to_ascii_lowercase(); + if selector.is_empty() { return 0; - }; - + } let mut score = match selector.as_str() { "runapp" => 1600, "createstate" => 1500, @@ -506,16 +496,9 @@ fn selector_signal_score(entry: &flutterdec_adapter::ObjectPoolEntry) -> i32 { 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/") { + if library_lower.starts_with("package:flutter/") { score /= 4; - } else if lib.starts_with("dart:") { + } else if library_lower.starts_with("dart:") { score /= 5; } score @@ -560,133 +543,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 +713,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 +771,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 +799,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 +811,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 +824,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 +918,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 +928,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 +940,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 +965,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 +999,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 +1070,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 +1101,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 +1117,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 +1167,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 +1221,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 +1270,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 +1287,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 +1305,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 +1313,7 @@ pub fn rank_program_functions( ) -> Vec { rank_candidates( model, + hints, iso_instr, iso_base_va, focus_prefix, @@ -1343,6 +1327,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 +1335,7 @@ pub fn disassemble_program( ) -> Vec { disassemble_program_with_priorities_and_package_hints( model, + hints, iso_instr, iso_base_va, focus_prefix, @@ -1360,8 +1346,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 +1376,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 +1392,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 +1409,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 +1424,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 +1437,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 +1467,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 +1479,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 +1492,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 +1516,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 +1524,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 +1537,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 +1743,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 +1760,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 +1907,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(), - }, + let model = test_model( + vec![ + lib(0, "package:app/router.dart"), + lib(1, "package:flutter/src/widgets/app.dart"), ], - 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(), - }, + vec![ + cls(0, "AppRouterHost", Some(0)), + cls(1, "WidgetsBindingObserver", Some(1)), ], - 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![ + fun(0, None, Some(0), 0x50c0, 4), + fun(1, None, Some(1), 0x50c4, 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(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, - }, - ], - }; + 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 +2211,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 +2272,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(), - }, + let model = test_model( + vec![ + lib(0, "package:other_pkg/core.dart"), + lib(1, "package:app_pkg/feature.dart"), ], - 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(), - }, + 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), ], - 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, - }, - ], - 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 +2360,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 +2423,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 +2462,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 +2501,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 +2534,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 +2565,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 +2604,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/lib.rs b/crates/flutterdec-loader/src/lib.rs index f6924db5..3c7ffd55 100644 --- a/crates/flutterdec-loader/src/lib.rs +++ b/crates/flutterdec-loader/src/lib.rs @@ -768,7 +768,7 @@ mod tests { const SYM: usize = 24; let mut out = vec![0u8; 128]; - let mut place = |out: &mut Vec, bytes: &[u8]| -> (u64, u64) { + 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) @@ -806,7 +806,7 @@ mod tests { } let mut shstrtab = vec![0u8]; - let mut section_name = |shstrtab: &mut Vec, name: &str| -> u32 { + 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); 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 index ba9a1ef2..fadf5b01 100644 --- a/schemas/program-model-v4.schema.json +++ b/schemas/program-model-v4.schema.json @@ -85,7 +85,10 @@ "library": { "maximum": 4294967295, "minimum": 0, - "type": "integer" + "type": [ + "integer", + "null" + ] }, "name": { "minLength": 1, From 6d0aafe9f1848ef497f3e7faa90f93f6dc309258 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Mon, 31 Aug 2026 13:26:28 -0400 Subject: [PATCH 05/18] docs(model): describe the v4 contract, protocol v1, and typed backend facts The adapter sections still documented `schema_version` 2/3, `adapter_kind`, and a `pool_geometry` flag, and pointed at `schemas/adapter.schema.json`, which no longer exists. They now describe what a reader actually gets: the v4 field set, what makes unknown representable, the one-shot request/result shape, and the index-space rules that decide whether `pool[N]` resolves. The backend table said the internal adapter produces `sub_` placeholders. It produces no names at all, and the model says so through its capability levels, so the table and the surrounding text say that instead. `info` output gained requested/resolved/fallback backend, producer identity and trust, the compatibility record digest, and per-domain capabilities; the three places that enumerate those fields are updated together. --- README.md | 29 +++-- docs/cli-reference.md | 6 +- docs/how-it-works.md | 168 ++++++++++++++++++---------- docs/research-pseudocode-quality.md | 3 +- docs/user-guide.md | 33 ++++-- 5 files changed, 156 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index f6f4d2b9..016bc89d 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,10 @@ 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` +- `model_capabilities` - `compatibility_warnings` 2. Install the adapter for the detected Dart hash: @@ -359,19 +361,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 | - -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`. +| `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 | + +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: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 22f04f2c..a1ae5786 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -30,9 +30,11 @@ 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` +- `model_capabilities` - `compatibility_warnings` ## `flutterdec decompile` diff --git a/docs/how-it-works.md b/docs/how-it-works.md index f4aab5b9..0c7c4568 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -215,7 +215,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,42 +246,79 @@ SnapshotBundle { } ``` -### ProgramModel - -Produced by adapter. Main fields: - -- `schema_version` -- `adapter_kind` -- `dart_version` -- `snapshot_hash` -- `arch` -- `libraries[]` -- `classes[]` -- `functions[]` -- `object_pool[]` -- `pool_geometry` (optional; see "Pool index space" below) - -Schema compatibility: - -- v2 and v3 are accepted by core -- v3 adds richer semantic metadata while preserving v2 compatibility defaults - -Minimal example: +### 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) + +Three 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. + +Minimal example, from a producer that recovered code ranges and nothing else: ```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"}] + "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": {} } ``` +### Adapter protocol v1 + +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. + +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. + +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. + ### FunctionDisassembly Produced by disassembler. Per function: @@ -376,36 +413,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 +470,7 @@ Key functions: - `run_adapter` - `resolve_adapter_exec` - `install_adapter` -- `validate_model` +- `validate::validate` ## 3) Disassembler @@ -439,8 +491,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 +723,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..06eb71a4 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 diff --git a/docs/user-guide.md b/docs/user-guide.md index 5d1b9f64..075a7346 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -171,9 +171,11 @@ 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` +- `model_capabilities` (per-domain `complete` / `partial` / `unavailable`) - `compatibility_warnings` 2. Install the adapter for the detected Dart hash: @@ -302,18 +304,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: From ff150048236320fdac8da2941c827850cadad7ef Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Mon, 31 Aug 2026 13:28:17 -0400 Subject: [PATCH 06/18] refactor(core): report model capabilities as a map and drop a dead score input `InfoOutput.model_capabilities` serialized as an array of two-element arrays, which is a worse shape than the object it describes for anything reading the JSON. A `BTreeMap` gives the object and keeps the domain order stable. `pool_selector_signal_score` took a library URI that every call site passed as `""`: a v4 pool entry carries no library, so the framework and stdlib de-weighting it applied had nothing to key on. The parameter is gone and the doc comment says why the weighting only applies to hints. --- crates/flutterdec-core/src/lib.rs | 4 ++-- crates/flutterdec-core/src/pipeline/runners.rs | 7 ++----- .../flutterdec-core/src/pipeline/symbol_map.rs | 1 - crates/flutterdec-disasm-arm64/src/lib.rs | 18 ++++++------------ 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/crates/flutterdec-core/src/lib.rs b/crates/flutterdec-core/src/lib.rs index a5f40e4f..15a1d7c2 100644 --- a/crates/flutterdec-core/src/lib.rs +++ b/crates/flutterdec-core/src/lib.rs @@ -22,7 +22,7 @@ use flutterdec_loader::{ }; use serde::Serialize; use serde_json::json; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -246,7 +246,7 @@ pub struct InfoOutput { /// against a string the adapter chose. pub snapshot_identity_is_exact: Option, /// Per-domain capability levels the model reported. - pub model_capabilities: Option>, + pub model_capabilities: Option>, pub compatibility_warnings: Option>, pub function_count: Option, pub class_count: Option, diff --git a/crates/flutterdec-core/src/pipeline/runners.rs b/crates/flutterdec-core/src/pipeline/runners.rs index 8185bb3c..80c5589a 100644 --- a/crates/flutterdec-core/src/pipeline/runners.rs +++ b/crates/flutterdec-core/src/pipeline/runners.rs @@ -1098,7 +1098,7 @@ fn producer_trust_label(trust: ProducerTrust) -> &'static str { } /// The model's per-domain capability levels, for reports. -fn capability_map(caps: &Capabilities) -> Vec<(String, String)> { +fn capability_map(caps: &Capabilities) -> BTreeMap { Domain::ALL .iter() .map(|domain| { @@ -1915,10 +1915,7 @@ pub fn run_decompile( "model": { "model_version": model.model_version, "name_pattern_hints": model_name_hints, - "capabilities": capability_map(&model.capabilities) - .into_iter() - .map(|(domain, level)| (domain, serde_json::Value::String(level))) - .collect::>(), + "capabilities": capability_map(&model.capabilities), "diagnostics": model.diagnostics.len(), "function_name_provenance": { "named": function_name_provenance_stats.named(), 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/src/lib.rs b/crates/flutterdec-disasm-arm64/src/lib.rs index cbdd4122..5e5b9066 100644 --- a/crates/flutterdec-disasm-arm64/src/lib.rs +++ b/crates/flutterdec-disasm-arm64/src/lib.rs @@ -467,13 +467,15 @@ fn hint_signal_score(hint: &Hint) -> i32 { /// 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. -fn pool_selector_signal_score(value: &str, library_lower: &str) -> i32 { +/// 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, @@ -493,14 +495,6 @@ fn pool_selector_signal_score(value: &str, library_lower: &str) -> i32 { "main" => 1800, _ => 0, }; - if score == 0 { - return 0; - } - if library_lower.starts_with("package:flutter/") { - score /= 4; - } else if library_lower.starts_with("dart:") { - score /= 5; - } score } @@ -632,7 +626,7 @@ fn build_target_va_priority_hints(model: &ProgramModel, hints: &ProgramHints) -> } for (target_va, value) in pool_code_selectors(model) { - let mut score = pool_selector_signal_score(value, ""); + let mut score = pool_selector_signal_score(value); score += deep_link_signal_score(value); bump(target_va, score); } From 8a8278ec5e2fe8a945f6fe2ce775ccf1fe3d3d99 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Mon, 31 Aug 2026 13:30:07 -0400 Subject: [PATCH 07/18] test(core): re-validate the model after every enrichment pass The manifest and APK-startup tests assert that enrichment leaves the model equal to what it was, which catches a pass that writes into a field the test thought to check. It does not catch a pass that writes into one nobody thought of. These run all three passes over a model that is valid to begin with and then re-run `validate` on it, across the three shapes enrichment has to survive: an authoritative model with a hardware pool, one that recovered nothing, and a heuristic-only one. Any synthetic pool entry, invented class or function, index collision, or capability contradiction fails the second validation regardless of which field it landed in. Each hint that names an address is also checked to name a code range the model actually recovered, so a pass cannot point analysis at an address the adapter never reported. --- .../src/pipeline/runners/tests.rs | 277 +++++++++++++++++- 1 file changed, 274 insertions(+), 3 deletions(-) diff --git a/crates/flutterdec-core/src/pipeline/runners/tests.rs b/crates/flutterdec-core/src/pipeline/runners/tests.rs index 034e7923..89fc0a4c 100644 --- a/crates/flutterdec-core/src/pipeline/runners/tests.rs +++ b/crates/flutterdec-core/src/pipeline/runners/tests.rs @@ -1,11 +1,12 @@ use super::*; use flutterdec_adapter::model::{ Capabilities, CapabilityLevel, Class, ClassId, CodeRange, CompatibilityBinding, Function, - FunctionId, InputRegion, InputRegionName, Library, LibraryId, Name, ObjectPool, - ObservedInput, PoolEntry, PoolEntryKind, PoolGeometry, PoolIndexSpace, Producer, - ProducerTrust, Provenance, MODEL_VERSION, + 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, @@ -1483,6 +1484,276 @@ 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 { + 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); + } + + #[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 enrichment_preserves_a_heuristic_only_model() { + let (model, host) = validatable_model( + vec![Library { + id: LibraryId(0), + uri: "package:app/main.dart".to_string(), + display_name: None, + provenance: Provenance::Heuristic, + }], + 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"), + ], + ); + 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. From 1ba735a1d4e947a2a301c3f3234ea36bcb4d2dbf Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Mon, 31 Aug 2026 13:46:22 -0400 Subject: [PATCH 08/18] fix(adapter): give every backend one wire token `BackendId` derived its serde names from `rename_all = "snake_case"`, so `R2Flutter` serialized as `r2_flutter`. `BackendId::as_str`, `RequestedBackend`'s hand-written `Serialize`, `RequestedBackend::parse`, the CLI's `--adapter-backend r2-flutter` alias, and the shipped `adapter_template.py` all spell it `r2flutter`. That put two spellings of one backend on the same wire pair. The host wrote `requested_backend: "r2flutter"` into the request, and the adapter answering with the token it was handed was rejected: adapter result is not protocol v1: malformed protocol document: unknown variant `r2flutter`, expected one of `internal`, `blutter`, `r2_flutter` So every run in which the r2flutter backend actually succeeded failed at the result document - the one backend that can supply exact names and a hardware pool index space. Every existing test round-tripped `BackendId` through Rust structs, where both sides agreed, so none of them saw it. Pin the variant's name to `r2flutter` and assert on the JSON token in both documents rather than on the Rust round trip. --- crates/flutterdec-adapter/src/protocol.rs | 7 ++++ .../flutterdec-adapter/tests/protocol_v1.rs | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/crates/flutterdec-adapter/src/protocol.rs b/crates/flutterdec-adapter/src/protocol.rs index c653f3c3..fae27dea 100644 --- a/crates/flutterdec-adapter/src/protocol.rs +++ b/crates/flutterdec-adapter/src/protocol.rs @@ -38,6 +38,13 @@ pub enum BackendId { 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, } diff --git a/crates/flutterdec-adapter/tests/protocol_v1.rs b/crates/flutterdec-adapter/tests/protocol_v1.rs index 43f6fdf9..525d6788 100644 --- a/crates/flutterdec-adapter/tests/protocol_v1.rs +++ b/crates/flutterdec-adapter/tests/protocol_v1.rs @@ -109,6 +109,48 @@ fn a_result_round_trips_in_both_its_shapes() { 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. From 698fb8e2a9d7de715af70c7c2890b9db61c6bde5 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Mon, 31 Aug 2026 13:46:33 -0400 Subject: [PATCH 09/18] docs(model): retire the v2/v3 model vocabulary The v4 cutover left docs asserting field names that no longer exist. Corrected against the code and against a real CLI run: - `context.md` "Data contracts" described the pre-v4 model. Rewritten around `model_version`, typed `LibraryId`/`ClassId`, provenance, capabilities, and the host facts the model is checked against. - pool owner attribution: a v4 `PoolEntry` carries `kind`, `value`, `target_va`, `provenance`. `decoded_kind`, `owner_class`, and `library_uri` were never v4 pool fields; owner and library come from the function `target_va` points at. - `object_pool.index_space`, not a top-level `pool_geometry`, is what gates pool hints. - `function_name_kind_breakdown` is now `model.function_name_provenance`. - `info` reports the v4 compatibility signals, not `adapter_kind`. - `bootflow_discovery` sources are `android_manifest` / `apk_startup` / `model_name_pattern`, and entries carry `kind` and `provenance`. - `FunctionDisassembly::function_name` is optional; the printed label falls back to `fn_0x`. - documented the closed backend vocabulary and the `UPDATE_SCHEMA=1` regeneration command for the committed schema. --- README.md | 2 +- context.md | 56 ++++++++++++++++++----------- docs/development.md | 16 ++++++++- docs/how-it-works.md | 18 +++++++--- docs/research-pseudocode-quality.md | 3 +- 5 files changed, 67 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 016bc89d..d284f367 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,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/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/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 0c7c4568..605d88b5 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -118,9 +118,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 @@ -319,11 +319,21 @@ 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. +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) diff --git a/docs/research-pseudocode-quality.md b/docs/research-pseudocode-quality.md index 06eb71a4..1688b195 100644 --- a/docs/research-pseudocode-quality.md +++ b/docs/research-pseudocode-quality.md @@ -2385,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` From 3badf3c0bdc3bfce9fdd62f89c7f71d2d5a430bf Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Mon, 31 Aug 2026 14:35:18 -0400 Subject: [PATCH 10/18] feat(core)!: gate adapter selection on a header-derived FullAOT identity Every production path that can select or run an adapter now requires `SnapshotIdentity::exact_selection_key()` first. A snapshot whose header did not parse, whose kind is not FullAOT, whose target this build cannot handle, or whose features contradict the container stops with the typed `IdentityRejection` before the manifest is read, before an executable is resolved, and before anything is spawned. The old shape let a rejected identity through: `local_producer_trust` turned it into `ProducerTrust::Untrusted` and the run continued, so a JIT or core snapshot was parsed under an AOT contract and the only trace was a label on the producer record. That function is gone. Trust is `Local` because the gate is the only way to reach a run at all, and `compatibility_binding` no longer has an "inexact" branch to digest. `run_adapter` restates the gate at the library boundary, so a caller that skips the core pipeline still cannot spawn an adapter for a rejected identity. `info` reports the rejection in a new `identity_rejection` field instead of probing the filesystem for an adapter that could never be authorized. --- README.md | 1 + crates/flutterdec-adapter/src/lib.rs | 38 +-- .../flutterdec-adapter/tests/producer_v4.rs | 13 +- crates/flutterdec-cli/src/main.rs | 3 + crates/flutterdec-core/src/lib.rs | 6 + crates/flutterdec-core/src/pipeline/model.rs | 56 +++- .../src/pipeline/model_tests.rs | 291 ++++++++++++++++++ .../flutterdec-core/src/pipeline/runners.rs | 8 +- docs/cli-reference.md | 1 + docs/how-it-works.md | 9 +- 10 files changed, 385 insertions(+), 41 deletions(-) create mode 100644 crates/flutterdec-core/src/pipeline/model_tests.rs diff --git a/README.md b/README.md index d284f367..41c9d4e9 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ If adapter metadata is available, `info` also reports package and compatibility - `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` diff --git a/crates/flutterdec-adapter/src/lib.rs b/crates/flutterdec-adapter/src/lib.rs index 2901eff2..8d0807db 100644 --- a/crates/flutterdec-adapter/src/lib.rs +++ b/crates/flutterdec-adapter/src/lib.rs @@ -17,9 +17,8 @@ pub mod protocol; pub mod validate; use anyhow::{anyhow, bail, Context, Result}; -use model::{ - CompatibilityBinding, InputRegion, InputRegionName, Producer, ProducerTrust, ProgramModel, -}; +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}; @@ -187,21 +186,6 @@ pub fn resolve_adapter_exec(repo_root: &Path, dart_hash: &str) -> Result ProducerTrust { - match identity.exact_selection_key() { - Ok(_) => ProducerTrust::Local, - Err(_) => ProducerTrust::Untrusted, - } -} - fn region_file_name(region: InputRegionName) -> &'static str { match region { InputRegionName::VmData => "vm_data.bin", @@ -215,6 +199,14 @@ const OUTPUT_MODEL_PATH: &str = "model.json"; const REQUEST_PATH: &str = "request.json"; const RESULT_PATH: &str = "result.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") +} + /// Run one adapter and return a model that has already been checked against the /// host's own view of the snapshot. /// @@ -222,6 +214,16 @@ const RESULT_PATH: &str = "result.json"; /// 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(); diff --git a/crates/flutterdec-adapter/tests/producer_v4.rs b/crates/flutterdec-adapter/tests/producer_v4.rs index 387de43e..79374ac7 100644 --- a/crates/flutterdec-adapter/tests/producer_v4.rs +++ b/crates/flutterdec-adapter/tests/producer_v4.rs @@ -84,12 +84,17 @@ fn install_named(hash: &str, file_name: Option<&str>) -> Installed { Installed { _dir: dir, exec } } -fn producer(identity: &SnapshotIdentity, exec: &Path) -> Producer { +/// 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: flutterdec_adapter::local_producer_trust(identity), + trust: ProducerTrust::Local, } } @@ -160,7 +165,7 @@ fn run( &installed.exec, &AdapterInput { identity, - producer: producer(identity, &installed.exec), + producer: producer(&installed.exec), compatibility: compatibility(), regions: regions(snapshot), input_path: None, @@ -608,7 +613,7 @@ asm.mkdir(parents=True, exist_ok=True) &installed.exec, &AdapterInput { identity: &identity, - producer: producer(&identity, &installed.exec), + producer: producer(&installed.exec), compatibility: compatibility(), regions: regions(&snapshot), input_path: Some(&input), diff --git a/crates/flutterdec-cli/src/main.rs b/crates/flutterdec-cli/src/main.rs index 612115cc..1f663df7 100644 --- a/crates/flutterdec-cli/src/main.rs +++ b/crates/flutterdec-cli/src/main.rs @@ -532,6 +532,9 @@ fn handle_info(repo_root: &Path, cmd: InfoCmd) -> Result<()> { 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 { diff --git a/crates/flutterdec-core/src/lib.rs b/crates/flutterdec-core/src/lib.rs index 15a1d7c2..ba7673bb 100644 --- a/crates/flutterdec-core/src/lib.rs +++ b/crates/flutterdec-core/src/lib.rs @@ -17,6 +17,7 @@ use flutterdec_disasm_arm64::{ }; 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, }; @@ -245,6 +246,11 @@ pub struct InfoOutput { /// "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>, diff --git a/crates/flutterdec-core/src/pipeline/model.rs b/crates/flutterdec-core/src/pipeline/model.rs index 0db2458e..43432955 100644 --- a/crates/flutterdec-core/src/pipeline/model.rs +++ b/crates/flutterdec-core/src/pipeline/model.rs @@ -60,20 +60,17 @@ fn profile_binding(profile: Option<&ResolvedDartProfile>) -> (String, Sha256Dige /// /// 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 when the identity cleared the -/// FullAOT gate, and the rejection reason when it did not, so a model produced -/// under a different decision has a different binding. -fn compatibility_binding(bundle: &SnapshotBundle) -> CompatibilityBinding { +/// 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 = match bundle.identity.exact_selection_key() { - Ok(key) => format!( - "exact;hash={};arch={};features={}", - key.hash, - key.target_arch.as_str(), - key.features.join(",") - ), - Err(rejection) => format!("inexact;{}", rejection), - }; + 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 @@ -91,14 +88,19 @@ fn compatibility_binding(bundle: &SnapshotBundle) -> CompatibilityBinding { /// 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>, bundle: &SnapshotBundle) -> Result { +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), - trust: flutterdec_adapter::local_producer_trust(&bundle.identity), + // `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, }) } @@ -120,11 +122,28 @@ fn backend_from_id(id: BackendId) -> AdapterBackend { } } +/// 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 @@ -134,9 +153,8 @@ fn load_model( let producer = producer_for( &adapter_exec, manifest_entry.map(|entry| entry.version.as_str()), - bundle, )?; - let compatibility = compatibility_binding(bundle); + let compatibility = compatibility_binding(bundle, &selection); let run = run_adapter( &adapter_exec, @@ -183,3 +201,7 @@ fn load_model( 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 80c5589a..209982a6 100644 --- a/crates/flutterdec-core/src/pipeline/runners.rs +++ b/crates/flutterdec-core/src/pipeline/runners.rs @@ -964,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 { @@ -1006,6 +1011,7 @@ pub fn run_info( compatibility_record_sha256: None, manifest_entry_present: 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, diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a1ae5786..a1e06b66 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -34,6 +34,7 @@ If adapter metadata is available, JSON output also includes app-package hints: - `producer_id`, `producer_trust`, `compatibility_record_sha256` - `manifest_entry_present` - `snapshot_identity_is_exact` +- `identity_rejection` - `model_capabilities` - `compatibility_warnings` diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 605d88b5..72746e02 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 @@ -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 @@ -274,6 +277,10 @@ Three properties are what separate it from v3: - **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: From 771bf2d6c21657e4de805729cfb6951982af89f3 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Tue, 1 Sep 2026 09:51:49 -0400 Subject: [PATCH 11/18] fix(core): keep three segments in a function descriptor An unrecovered library dropped its segment and left `owner::name`, but `descriptor_library_uri` reads everything before the first `::` as the library URI. The owner class was therefore bucketed as a package, so a function whose library did not parse landed under a package derived from its class name in `diff_report.json` instead of under unknown. The empty segment is kept, which is what the test's own comment already described. --- crates/flutterdec-core/src/pipeline/runners/tests.rs | 2 +- crates/flutterdec-core/src/pipeline/runners_diff.rs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/flutterdec-core/src/pipeline/runners/tests.rs b/crates/flutterdec-core/src/pipeline/runners/tests.rs index 89fc0a4c..29cc3034 100644 --- a/crates/flutterdec-core/src/pipeline/runners/tests.rs +++ b/crates/flutterdec-core/src/pipeline/runners/tests.rs @@ -545,7 +545,7 @@ let descriptors = collect_function_descriptors(&model); assert!(descriptors.contains("package:spotube/main.dart::AppRoot::main")); - assert!(descriptors.contains("::")); + assert!(descriptors.contains("::::")); } #[test] diff --git a/crates/flutterdec-core/src/pipeline/runners_diff.rs b/crates/flutterdec-core/src/pipeline/runners_diff.rs index 8ece62a8..03b3d467 100644 --- a/crates/flutterdec-core/src/pipeline/runners_diff.rs +++ b/crates/flutterdec-core/src/pipeline/runners_diff.rs @@ -10,9 +10,10 @@ fn function_descriptor(model: &ProgramModel, func: &flutterdec_adapter::model::F 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(""); - if library_uri.is_empty() { - return format!("{}::{}", owner, name); - } + // 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) } From 646f66c07077b7207eb07ea762f2fc274ea95565 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Tue, 1 Sep 2026 09:51:49 -0400 Subject: [PATCH 12/18] fix(adapter): pin the raw feature string to the host's `check_host_facts` compared only the normalized feature list, so an adapter could echo a different raw header string and still validate. The raw string is host-parsed too, and the module's rule is that identity is compared against what the host selected, so it is no longer an unstated exception. --- crates/flutterdec-adapter/src/validate.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/flutterdec-adapter/src/validate.rs b/crates/flutterdec-adapter/src/validate.rs index 66b165e0..699e4701 100644 --- a/crates/flutterdec-adapter/src/validate.rs +++ b/crates/flutterdec-adapter/src/validate.rs @@ -376,7 +376,7 @@ fn check_regions(model: &ProgramModel) -> Check { fn check_host_facts(model: &ProgramModel, host: &HostSelectedContext) -> Check { let observed = &model.input.identity; let expected = &host.identity; - let identity_fields: [(&'static str, bool); 6] = [ + 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), @@ -388,6 +388,13 @@ fn check_host_facts(model: &ProgramModel, host: &HostSelectedContext) -> Check { "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, From 1fa58dcf06bace30b32fb75f3baf9334dea1dc98 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Tue, 1 Sep 2026 09:51:50 -0400 Subject: [PATCH 13/18] test(adapter): stop two backend tests reading the ambient environment The installed wrapper inherits the test runner's environment, so a machine with r2flutter or blutter installed resolves a real backend and the assertions stop testing what they claim. Both tests now install an adapter that clears the `FLUTTERDEC_*` runner variables and empties `PATH` inside its own process, the technique the blutter bridge test already used. --- .../flutterdec-adapter/tests/producer_v4.rs | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/flutterdec-adapter/tests/producer_v4.rs b/crates/flutterdec-adapter/tests/producer_v4.rs index 79374ac7..c56ae90a 100644 --- a/crates/flutterdec-adapter/tests/producer_v4.rs +++ b/crates/flutterdec-adapter/tests/producer_v4.rs @@ -84,6 +84,48 @@ fn install_named(hash: &str, file_name: Option<&str>) -> Installed { 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 @@ -395,10 +437,10 @@ fn carved_strings_become_ordinal_pool_entries_never_hardware_ones() { #[test] fn a_pinned_backend_that_cannot_run_fails_instead_of_falling_back() { - let installed = install("deadbeefdeadbeefdeadbeefdeadbeef"); + let installed = install_without_external_backends("deadbeefdeadbeefdeadbeefdeadbeef"); let identity = support::identity(); - // r2flutter is not on PATH in this environment, and the input path the - // backend needs is absent, so it cannot run. Pinned means it must fail. + // 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, @@ -414,7 +456,7 @@ fn a_pinned_backend_that_cannot_run_fails_instead_of_falling_back() { #[test] fn auto_falls_back_to_internal_and_says_why() { - let installed = install("deadbeefdeadbeefdeadbeefdeadbeef"); + let installed = install_without_external_backends("deadbeefdeadbeefdeadbeefdeadbeef"); let identity = support::identity(); let run = run( &installed, From 616f0e42e2f3c2494000bbfcf531f2a6250af06f Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Tue, 1 Sep 2026 09:51:50 -0400 Subject: [PATCH 14/18] docs(how-it-works): count the v4 properties correctly Four bullets follow, not three. --- docs/how-it-works.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 72746e02..2e342d87 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -265,7 +265,7 @@ migration path. Fields: - `diagnostics[]` - `extensions` (the only object that accepts undeclared keys) -Three properties are what separate it from v3: +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 From 872e5cfcdd5846016b52bdec825217272bc80480 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Tue, 1 Sep 2026 09:59:51 -0400 Subject: [PATCH 15/18] fix(core): stop the compatibility binding falling out of the report The decompile summary declared `"compatibility"` twice. A duplicate key in a JSON object literal is not an error, the later value simply wins, so `record_sha256`, `parser_family_id`, `profile_id` and `profile_sha256` never reached `report.json`. The binding now lives in the object that survives. --- crates/flutterdec-core/src/pipeline/runners.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/flutterdec-core/src/pipeline/runners.rs b/crates/flutterdec-core/src/pipeline/runners.rs index 209982a6..3388b128 100644 --- a/crates/flutterdec-core/src/pipeline/runners.rs +++ b/crates/flutterdec-core/src/pipeline/runners.rs @@ -1912,12 +1912,6 @@ pub fn run_decompile( "artifact_sha256": producer.artifact_sha256.to_string(), "trust": producer_trust_label(producer.trust) }, - "compatibility": { - "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() - }, "model": { "model_version": model.model_version, "name_pattern_hints": model_name_hints, @@ -1949,6 +1943,14 @@ 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, "model": { "version": model.model_version, From ca67229fa11ad95708643d357ef231f73772d2f6 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Tue, 1 Sep 2026 09:59:51 -0400 Subject: [PATCH 16/18] fix(core): digest the profile by the fields that decide layout `profile_sha256` rendered `dart_version`, which is not a layout field. Buckets are resolved by floor, so several exact Dart versions share one `profile_version`: those snapshots reported the same `profile_id` with different digests, and the digest is what pins the id. --- crates/flutterdec-core/src/pipeline/model.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/flutterdec-core/src/pipeline/model.rs b/crates/flutterdec-core/src/pipeline/model.rs index 43432955..9a439ee8 100644 --- a/crates/flutterdec-core/src/pipeline/model.rs +++ b/crates/flutterdec-core/src/pipeline/model.rs @@ -39,9 +39,13 @@ fn profile_binding(profile: Option<&ResolvedDartProfile>) -> (String, Sha256Dige 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!( - "dart_version={};profile_version={};tag_style={};compressed_word_size={};header_fields={};max_alignment={};heap_object_tag={};cids={:?}", - resolved.dart_version, + "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, From 4c0cfdb3b0d82ee0beda452026653668281ba6d9 Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Tue, 1 Sep 2026 09:59:51 -0400 Subject: [PATCH 17/18] docs(user-guide): list identity_rejection among the info fields `info` prints it as "adapter selection refused" and the README documents it, so a reader of this list had no explanation for that line. --- docs/user-guide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user-guide.md b/docs/user-guide.md index 075a7346..c92946ba 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -175,6 +175,7 @@ If adapter metadata is available, `info` also reports: - `producer_id`, `producer_trust`, `compatibility_record_sha256` - `manifest_entry_present` - `snapshot_identity_is_exact` +- `identity_rejection` (why no adapter could be selected, when none could) - `model_capabilities` (per-domain `complete` / `partial` / `unavailable`) - `compatibility_warnings` From 6506916fc95692214194a698a80c569a7c4df83e Mon Sep 17 00:00:00 2001 From: Camilo Vera Date: Thu, 3 Sep 2026 13:18:50 -0400 Subject: [PATCH 18/18] fix(adapter): normalize structured r2flutter superclass metadata for v4 r2flutter emits class `super` as a JSON object, so the v4 projection's `_sanitize_class_name(raw_super or "")` raised `AttributeError: 'dict' object has no attribute 'strip'` on any snapshot carrying a `{"type_ref": N}` superclass. #99 fixed the same root cause in the v3 producer, which this branch replaces, so the fix does not survive the rebase and is reimplemented here. v4 does not need the `Object` placeholder v3's schema forced: `super_class` is an optional id, so an unresolved reference is reported as no edge. r2flutter fills in `name` itself when a reference resolves, which makes a bare `ref`/`type_ref` a lookup its own resolver already failed. Covered by a fake-r2flutter producer test asserting both shapes, and by an assertion that `class_relationships` stays unavailable when no superclass resolves rather than claiming partial off a class table alone. --- adapters/python/adapter_template.py | 19 ++- crates/flutterdec-adapter/src/lib.rs | 1 - .../flutterdec-adapter/tests/producer_v4.rs | 161 +++++++++++++++++- 3 files changed, 178 insertions(+), 3 deletions(-) diff --git a/adapters/python/adapter_template.py b/adapters/python/adapter_template.py index f9830285..fb63b1ef 100644 --- a/adapters/python/adapter_template.py +++ b/adapters/python/adapter_template.py @@ -977,6 +977,23 @@ def _r2flutter_functions(instruction_table: dict, iso_va: int, iso_size: int, 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. @@ -998,7 +1015,7 @@ def _r2flutter_classes(classes: List[dict]) -> Tuple[List[dict], Dict[str, int]] (c.get("super") for c in classes if _sanitize_class_name(c.get("name") or "") == name), None, ) - super_name = _sanitize_class_name(raw_super or "") + super_name = _r2flutter_super_name(raw_super) super_id = ids.get(super_name) if super_name else None out.append( { diff --git a/crates/flutterdec-adapter/src/lib.rs b/crates/flutterdec-adapter/src/lib.rs index 8d0807db..d91cf4b1 100644 --- a/crates/flutterdec-adapter/src/lib.rs +++ b/crates/flutterdec-adapter/src/lib.rs @@ -393,5 +393,4 @@ mod tests { assert_eq!(manifest.entries.len(), 1); assert_eq!(manifest.entries[0].snapshot_hash, "abcd1234"); } - } diff --git a/crates/flutterdec-adapter/tests/producer_v4.rs b/crates/flutterdec-adapter/tests/producer_v4.rs index c56ae90a..9f5e4581 100644 --- a/crates/flutterdec-adapter/tests/producer_v4.rs +++ b/crates/flutterdec-adapter/tests/producer_v4.rs @@ -15,7 +15,8 @@ mod support; use flutterdec_adapter::model::{ - CapabilityLevel, Domain, PoolIndexSpace, Producer, ProducerTrust, ProgramModel, Provenance, + CapabilityLevel, ClassId, Domain, PoolIndexSpace, Producer, ProducerTrust, ProgramModel, + Provenance, }; use flutterdec_adapter::model::{CompatibilityBinding, InputRegionName}; use flutterdec_adapter::primitives::Sha256Digest; @@ -711,3 +712,161 @@ asm.mkdir(parents=True, exist_ok=True) .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 + ); +}