Skip to main content

asc_engine/
hints.rs

1//! What to tell an agent when a lookup comes up empty: that a class belongs
2//! to the framework, the classes or members it probably meant, why a search
3//! found nothing. Only computed on those paths, so it may scan the APK.
4
5use crate::{Engine, Result, names, skip};
6use asc_dex::Dex;
7use asc_findrefs::{MemberQuery, MemberSignature, Query};
8use std::collections::HashSet;
9use std::sync::Mutex;
10use std::sync::atomic::{AtomicUsize, Ordering};
11
12/// Suggestions per hint.
13const MAX_SUGGESTIONS: usize = 5;
14/// Members listed in one hint.
15const MAX_MEMBERS: usize = 8;
16/// Packages an APK uses but does not define (the framework and the JDK).
17const FRAMEWORK: &[&str] = &["Landroid/", "Ljava/", "Ljavax/", "Lkotlin/", "Ldalvik/", "Lorg/json/", "Lorg/xml/", "Lorg/w3c/", "Lsun/"];
18
19/// Whether `descriptor` is in a package the Android runtime provides.
20pub fn is_framework_class(descriptor: &str) -> bool {
21    FRAMEWORK.iter().any(|p| descriptor.starts_with(p))
22}
23
24/// `Lcom/a/B$Inner;` → `inner`: what a class is called without its package
25/// or outer classes, lowercased.
26fn simple_name(descriptor: &str) -> String {
27    let inner = names::class_inner(descriptor);
28    inner.rsplit(['/', '$']).next().unwrap_or(inner).to_ascii_lowercase()
29}
30
31/// The classes of `all` closest to `target`: same simple name first, then by
32/// edit distance; at most [`MAX_SUGGESTIONS`].
33fn similar_classes<'a>(target: &str, all: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
34    let want = simple_name(target);
35    let max = (target.len() / 8).max(2);
36    let mut scored: Vec<(usize, &str)> = all
37        .filter_map(|d| if simple_name(d) == want { Some((0, d)) } else { names::edit_distance(target, d, max).map(|k| (k, d)) })
38        .collect();
39    scored.sort_unstable();
40    scored.dedup();
41    scored.into_iter().take(MAX_SUGGESTIONS).map(|(_, d)| d).collect()
42}
43
44/// `name(params)ret` (methods) or `name:T` (fields) of every member of
45/// `class` that `dex` references or defines, in id order.
46fn class_members(dex: &Dex<'_>, class: &str, methods: bool) -> Result<Vec<String>> {
47    let Some(class_idx) = dex.find_type_idx(&asc_dex::mutf8::encode(class))? else { return Ok(Vec::new()) };
48    let count = if methods { dex.method_count() } else { dex.field_count() };
49    let class_of = |i: u32| -> Result<u32> { Ok(if methods { dex.method_id(i)?.class_idx } else { dex.field_id(i)?.class_idx } as u32) };
50    // Both id tables are sorted by class first.
51    let (mut lo, mut hi) = (0, count);
52    while lo < hi {
53        let mid = lo + (hi - lo) / 2;
54        if class_of(mid)? < class_idx {
55            lo = mid + 1;
56        } else {
57            hi = mid;
58        }
59    }
60    let mut out = Vec::new();
61    for i in lo..count {
62        if class_of(i)? != class_idx {
63            break;
64        }
65        let signature = if methods { asc_smali::method_signature(dex, i)? } else { asc_smali::field_signature(dex, i)? };
66        out.push(signature.split_once("->").map_or(signature.clone(), |(_, m)| m.to_string()));
67    }
68    Ok(out)
69}
70
71/// `a, b, c` for at most [`MAX_MEMBERS`] items, then how many were left out.
72fn list_some<S: AsRef<str>>(items: &[S]) -> String {
73    let mut out = items.iter().take(MAX_MEMBERS).map(AsRef::as_ref).collect::<Vec<_>>().join(", ");
74    if items.len() > MAX_MEMBERS {
75        out.push_str(&format!(" … ({} in all)", items.len()));
76    }
77    out
78}
79
80/// Whether the member `m` as [`class_members`] writes it (`name(params)ret`
81/// or `name:T`) has `signature`.
82fn has_signature(m: &str, signature: &MemberSignature) -> bool {
83    match signature {
84        MemberSignature::Method { params, ret } => {
85            let Some((have, have_ret)) = m.split_once('(').and_then(|(_, r)| r.split_once(')')) else { return false };
86            let Some(have) = names::dalvik_types(have) else { return false };
87            have.len() == params.len()
88                && have.iter().zip(params).all(|(h, p)| p.matches(h))
89                && ret.as_ref().is_none_or(|r| r.matches(have_ret))
90        }
91        MemberSignature::Field(t) => m.split_once(':').is_some_and(|(_, have)| t.matches(have)),
92    }
93}
94
95/// The members of `members` worth showing for `name`: the same name (any
96/// case), then names within a small edit distance, else all of them.
97fn closest_members(members: &[String], name: Option<&str>) -> Vec<String> {
98    let member_name = |m: &str| m.split(['(', ':']).next().unwrap_or(m).to_string();
99    let mut picked: Vec<String> = match name {
100        Some(n) => {
101            let want = n.to_ascii_lowercase();
102            let mut scored: Vec<(usize, &String)> = members
103                .iter()
104                .filter_map(|m| {
105                    let have = member_name(m).to_ascii_lowercase();
106                    if have == want || have.contains(&want) { Some((0, m)) } else { names::edit_distance(&want, &have, 3).map(|k| (k, m)) }
107                })
108                .collect();
109            scored.sort();
110            scored.into_iter().map(|(_, m)| m.clone()).collect()
111        }
112        None => Vec::new(),
113    };
114    if picked.is_empty() {
115        picked = members.to_vec();
116    }
117    picked.dedup();
118    picked
119}
120
121/// `res/xml-v21/file.xml` → `xml/file`: the resource a file path names.
122fn resource_name_of(path: &str) -> Option<String> {
123    let rest = path.strip_prefix("res/")?;
124    let (dir, file) = rest.split_once('/')?;
125    let type_name = dir.split('-').next()?;
126    let stem = file.split('.').next()?;
127    (!stem.is_empty()).then(|| format!("{type_name}/{stem}"))
128}
129
130/// Where a shrunk APK keeps the files `res/<dir>/` would hold: the
131/// directories holding entries of `path`'s extension, largest first.
132fn renamed_resources_hint(entries: &[String], path: &str) -> String {
133    let ext = path.rsplit_once('.').map_or("", |(_, e)| e);
134    let mut dirs: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
135    for e in entries.iter().filter(|e| !ext.is_empty() && e.ends_with(&format!(".{ext}")) && e.as_str() != "AndroidManifest.xml") {
136        *dirs.entry(e.rsplit_once('/').map_or("", |(d, _)| d)).or_default() += 1;
137    }
138    let mut dirs: Vec<(&str, usize)> = dirs.into_iter().collect();
139    dirs.sort_by_key(|d| std::cmp::Reverse(d.1));
140    let listed: Vec<String> = dirs.iter().take(3).map(|(d, n)| format!("{d}/ ({n})")).collect();
141    format!(
142        "resource files may be renamed by a shrinker; .{ext} files here live in {}: the manifest (manifest_summary) names the ones it uses",
143        if listed.is_empty() { "no directory".to_string() } else { listed.join(", ") }
144    )
145}
146
147impl Engine {
148    /// Why `descriptor` is not in the APK and what to try, appended to
149    /// "class … not found in APK": framework classes are only used, other
150    /// names get the closest classes the APK defines.
151    pub(crate) fn class_not_found_hint(&self, descriptor: &str) -> String {
152        let classes = self.list_classes(None).unwrap_or_default();
153        // Apps bundle and obfuscators reuse some of these packages (Douyin
154        // defines classes under `kotlin/jvm/internal/`): framework only when
155        // the APK defines nothing in the class's package.
156        let package = descriptor.rfind('/').map_or("L", |i| &descriptor[..=i]);
157        if is_framework_class(descriptor) && !classes.iter().any(|c| c.descriptor.starts_with(package)) {
158            return format!(
159                ": it is an Android / Java framework class, which the APK uses but does not define. \
160                 Find its uses with findrefs type {descriptor} (or method / field with class {descriptor}), \
161                 subclasses with findrefs extends {descriptor}"
162            );
163        }
164        let similar = similar_classes(descriptor, classes.iter().map(|c| c.descriptor.as_str()));
165        if similar.is_empty() { String::new() } else { format!("; did you mean {}", similar.join(", ")) }
166    }
167
168    /// For "entry … not found": entries with the same file name or a close
169    /// path, and where the resource of a `res/<type>/<name>.<ext>` path is
170    /// really stored (shrinkers rename resource files, e.g. `res/7dm.xml`).
171    pub(crate) fn entry_not_found_hint(&self, path: &str) -> String {
172        let mut hints = Vec::new();
173        if let Some(stored) = self.resource_file(path) {
174            hints.push(format!("resource {} is stored at {stored}", resource_name_of(path).unwrap_or_default()));
175        }
176        let entries: Vec<String> =
177            self.open().and_then(|a| Ok(a.entries()?)).map(|e| e.into_iter().map(|e| e.name).collect()).unwrap_or_default();
178        let file = path.rsplit('/').next().unwrap_or(path);
179        let mut similar: Vec<(usize, &str)> = entries
180            .iter()
181            .filter_map(|e| {
182                if e.rsplit('/').next() == Some(file) {
183                    Some((0, e.as_str()))
184                } else {
185                    names::edit_distance(path, e, 3).map(|k| (k, e.as_str()))
186                }
187            })
188            .collect();
189        similar.sort_unstable();
190        if !similar.is_empty() {
191            hints.push(format!("did you mean {}", similar.iter().take(MAX_SUGGESTIONS).map(|(_, e)| *e).collect::<Vec<_>>().join(", ")));
192        }
193        if hints.is_empty() && path.starts_with("res/") {
194            hints.push(renamed_resources_hint(&entries, path));
195        }
196        hints.iter().map(|h| format!("; {h}")).collect()
197    }
198
199    /// The entry the resource `res/<type>/<name>.<ext>` names is stored at,
200    /// when resources.arsc maps it elsewhere.
201    fn resource_file(&self, path: &str) -> Option<String> {
202        let name = resource_name_of(path)?;
203        let table = self.resources().ok()??;
204        let id = table.lookup(&name)?;
205        table
206            .values(id)
207            .into_iter()
208            .map(|v| table.format_entry(id, &v.value))
209            .map(|v| v.trim_matches('"').to_string())
210            .find(|v| v != path && v.starts_with("res/"))
211    }
212
213    /// For "no resource …": resources of a close name, the same type first.
214    pub(crate) fn resource_not_found_hint(&self, table: &asc_arsc::ResTable, query: &str) -> String {
215        let want = query.trim_start_matches('@');
216        let want_type = want.split_once('/').map(|(t, _)| t);
217        let mut scored: Vec<(usize, String)> = table
218            .ids()
219            .into_iter()
220            .filter_map(|id| {
221                let name = table.ref_name(id);
222                let same_type = want_type.is_some_and(|t| name.split_once('/').is_some_and(|(nt, _)| nt == t));
223                let k = names::edit_distance(&want.to_ascii_lowercase(), &name.to_ascii_lowercase(), 3)?;
224                Some((k + usize::from(!same_type), name))
225            })
226            .collect();
227        scored.sort();
228        scored.dedup();
229        match scored.len() {
230            0 => "; resource names may be obfuscated: the manifest (manifest_summary) names the files it uses, and search finds names and values".into(),
231            _ => format!("; did you mean {}", scored.into_iter().take(MAX_SUGGESTIONS).map(|(_, n)| n).collect::<Vec<_>>().join(", ")),
232        }
233    }
234
235    /// The members of `class` near `name` that the APK knows, for "no method
236    /// …" errors: `"; Lc; has a(I)V, b()V"`, or the class hint when no DEX
237    /// references `class` at all.
238    pub(crate) fn member_not_found_hint(&self, class: &str, name: Option<&str>, methods: bool) -> String {
239        let members = self.class_member_list(class, methods);
240        if members.is_empty() {
241            return format!("; no DEX references {class}{}", self.class_not_found_hint(class));
242        }
243        format!("; {class} has {}", list_some(&closest_members(&members, name)))
244    }
245
246    /// Every member of `class` any DEX references or defines, sorted.
247    ///
248    /// An entry no cache keeps (a one-shot cold query, whose search likely
249    /// left it out after a prefix probe) is read from a streamed prefix when
250    /// its id tables lie in one, rather than inflated whole again for a hint.
251    fn class_member_list(&self, class: &str, methods: bool) -> Vec<String> {
252        let Ok(apk) = self.open() else { return Vec::new() };
253        let Ok(entries) = apk.dex_entries() else { return Vec::new() };
254        let cache = self.cache.as_deref();
255        let members = Mutex::new(Vec::new());
256        let whole = Mutex::new(HashSet::new());
257        let next = AtomicUsize::new(0);
258        std::thread::scope(|s| {
259            for _ in 0..self.threads().min(entries.len()) {
260                s.spawn(|| {
261                    while let Some(entry) = entries.get(next.fetch_add(1, Ordering::Relaxed)) {
262                        let from_prefix = skip::worth_a_prefix(entry, cache)
263                            .then(|| skip::id_prefix(&apk, entry))
264                            .flatten()
265                            .and_then(|bytes| class_members(&Dex::with_name(&bytes, &entry.name).ok()?, class, methods).ok());
266                        match from_prefix {
267                            Some(m) => members.lock().expect("hint members mutex poisoned").extend(m),
268                            None => {
269                                whole.lock().expect("hint entries mutex poisoned").insert(entry.name.clone());
270                            }
271                        }
272                    }
273                });
274            }
275        });
276        let mut members = members.into_inner().expect("hint members mutex poisoned");
277        let whole = whole.into_inner().expect("hint entries mutex poisoned");
278        if !whole.is_empty()
279            && let Ok((views, _)) = self.load_views(Some(&whole), &crate::NOT_CANCELLED)
280        {
281            for v in &views {
282                if let Ok(dex) = Dex::with_name(&v.data, &v.name) {
283                    members.extend(class_members(&dex, class, methods).unwrap_or_default());
284                }
285            }
286        }
287        members.sort();
288        members.dedup();
289        members
290    }
291
292    /// One line each on why a search over `entries` DEX entries found
293    /// nothing and what to change: case-sensitive patterns, a class no DEX
294    /// references, members that do exist, direct relations only.
295    pub fn no_results_hints(&self, query: &Query, entries: usize) -> Vec<String> {
296        let mut out = vec![format!("searched {entries} DEX entr{}", if entries == 1 { "y" } else { "ies" })];
297        match query {
298            Query::Method(m) => {
299                out.extend(self.member_query_hint(m, true, None).or_else(|| query_hint(query)));
300            }
301            Query::Field(m) => {
302                out.extend(self.member_query_hint(m, false, None).or_else(|| query_hint(query)));
303            }
304            Query::FieldAccess { field, access } => {
305                out.extend(self.member_query_hint(field, false, Some(*access)).or_else(|| query_hint(query)));
306            }
307            _ => out.extend(query_hint(query)),
308        }
309        out
310    }
311
312    /// For a member query with an exact class: whether the class or the
313    /// member is absent, or the member exists but no code references it.
314    fn member_query_hint(&self, m: &MemberQuery, methods: bool, field_access: Option<asc_findrefs::FieldAccess>) -> Option<String> {
315        let class = &m.class.as_ref().filter(|c| c.precise)?.pattern;
316        let kind = if methods { "method" } else { "field" };
317        let members = self.class_member_list(class, methods);
318        if members.is_empty() {
319            return Some(format!("no DEX references {class}{}", self.class_not_found_hint(class)));
320        }
321        let Some(name) = &m.name else {
322            return Some(format!("{class} is known, but no code references its {kind}s"));
323        };
324        let member_name = |s: &str| s.split(['(', ':']).next().unwrap_or(s).to_string();
325        let defined: Vec<&String> = members.iter().filter(|s| member_name(s) == *name).collect();
326        let access = if methods {
327            "calls it".to_string()
328        } else {
329            match field_access {
330                Some(a) => format!("{}s it", a.as_str()),
331                None => "reads or writes it".to_string(),
332            }
333        };
334        Some(match &m.signature {
335            _ if defined.is_empty() => {
336                format!("{class} has no {kind} {name:?}; it has {}", list_some(&closest_members(&members, Some(name))))
337            }
338            Some(sig) if !defined.iter().any(|d| has_signature(d, sig)) => match sig {
339                MemberSignature::Method { .. } => {
340                    format!("{class}->{name} has no overload {sig}; it has {}: drop the signature for every overload", list_some(&defined))
341                }
342                MemberSignature::Field(_) => format!("{class}->{name} is {}, not {sig}: drop the type to match it", list_some(&defined)),
343            },
344            _ => format!(
345                "{class}->{name} exists ({}), but no code {access}: findrefs lists references, not definitions",
346                list_some(&defined)
347            ),
348        })
349    }
350}
351
352/// What a zero-result `query` itself suggests, without looking at an APK
353/// (so also for searches over many APKs).
354pub fn query_hint(query: &Query) -> Option<String> {
355    Some(match query {
356        Query::String(p) if p.chars().any(char::is_alphabetic) && !p.starts_with("(?i") => {
357            "string patterns are case-sensitive regexes: prefix (?i) to ignore case".into()
358        }
359        Query::StringLiteral(_) => "literal matches are case-sensitive; a string regex with (?i) ignores case".into(),
360        Query::Method(MemberQuery { signature: Some(sig), .. })
361        | Query::Field(MemberQuery { signature: Some(sig), .. })
362        | Query::FieldAccess { field: MemberQuery { signature: Some(sig), .. }, .. } => {
363            format!("only members of signature {sig} match: drop it for every overload")
364        }
365        Query::Method(MemberQuery { exact_name: true, .. })
366        | Query::Field(MemberQuery { exact_name: true, .. })
367        | Query::FieldAccess { field: MemberQuery { exact_name: true, .. }, .. } => {
368            "findrefs lists references, not definitions; the name is matched exactly (without = or a signature it is a pattern)".into()
369        }
370        Query::Extends(_) | Query::Implements(_) => {
371            "only direct subclasses / implementers are listed: hierarchy walks the whole tree".into()
372        }
373        Query::All { .. } => "every `all` condition must match inside the same method".into(),
374        Query::Nested(_) => {
375            "method:={RULE} lists calls naming exactly the methods whose code matches RULE: run RULE alone to see those methods".into()
376        }
377        Query::Virtual { .. } => {
378            "lists the calls of the methods (or of their supertypes / inheriting subclasses) whose code matches: run RULE alone to see them, a method only reached through reflection, a callback or a framework class outside the program has no call".into()
379        }
380        Query::Call { .. } => {
381            "only the calls whose values the [...] constraints accept are listed: drop them, or use [argN=*] to see the values".into()
382        }
383        Query::MethodSet(_) => {
384            "method:=@FILE matches the class the call site names: without = a call through a supertype or inheriting subclass counts too".into()
385        }
386        Query::Defined(_) => {
387            "this lists method definitions (no call site); override:/in: need the class to be defined in the program".into()
388        }
389        Query::FieldAccess { access, .. } => {
390            format!("only field {}s are listed: drop the access filter to include both reads and writes", access.as_str())
391        }
392        Query::String(_) | Query::Type(_) | Query::Method(_) | Query::Field(_) => return None,
393    })
394}
395
396/// The first hint line of a search over many APKs that found nothing.
397pub fn searched_hint(apks: usize, entries: usize) -> String {
398    format!("searched {apks} APK{} ({entries} DEX entr{})", if apks == 1 { "" } else { "s" }, if entries == 1 { "y" } else { "ies" })
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn similar_classes_rank_simple_names_then_edits() {
407        let all = ["Lcom/a/WebViewHelper;", "Lcom/a/B$Inner;", "Lcom/b/Main;", "Lcom/a/Mian;", "Lx/y/Z;"];
408        assert_eq!(similar_classes("Lcom/a/B/Inner;", all.into_iter()), ["Lcom/a/B$Inner;"]);
409        assert_eq!(similar_classes("Lcom/a/Main;", all.into_iter()), ["Lcom/b/Main;", "Lcom/a/Mian;"]);
410        assert!(similar_classes("Lorg/nothing/Like;", all.into_iter()).is_empty());
411    }
412
413    #[test]
414    fn closest_members_prefer_the_name() {
415        let members: Vec<String> = ["a()V", "loadUrl(Ljava/lang/String;)V", "loadData()V", "zz()V"].map(String::from).into();
416        assert_eq!(closest_members(&members, Some("loadurl")), ["loadUrl(Ljava/lang/String;)V"]);
417        assert_eq!(closest_members(&members, Some("loadDta"))[0], "loadData()V");
418        assert_eq!(closest_members(&members, Some("nothing")), members);
419        let many: Vec<String> = (0..20).map(|i| format!("f{i}:I")).collect();
420        assert_eq!(list_some(&many), "f0:I, f1:I, f2:I, f3:I, f4:I, f5:I, f6:I, f7:I … (20 in all)");
421        assert_eq!(list_some(&members[..2]), "a()V, loadUrl(Ljava/lang/String;)V");
422        let sig = names::parse_signature("(String)", true).unwrap();
423        assert!(has_signature("loadUrl(Ljava/lang/String;)V", &sig) && !has_signature("a()V", &sig));
424        assert!(has_signature("f:I", &names::parse_signature(":int", false).unwrap()));
425        assert!(is_framework_class("Landroid/app/Activity;") && !is_framework_class("Landroidx/core/A;"));
426        assert_eq!(resource_name_of("res/xml-v21/file_paths.xml").as_deref(), Some("xml/file_paths"));
427        assert_eq!(resource_name_of("AndroidManifest.xml"), None);
428    }
429}