1use 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
12const MAX_SUGGESTIONS: usize = 5;
14const MAX_MEMBERS: usize = 8;
16const FRAMEWORK: &[&str] = &["Landroid/", "Ljava/", "Ljavax/", "Lkotlin/", "Ldalvik/", "Lorg/json/", "Lorg/xml/", "Lorg/w3c/", "Lsun/"];
18
19pub fn is_framework_class(descriptor: &str) -> bool {
21 FRAMEWORK.iter().any(|p| descriptor.starts_with(p))
22}
23
24fn simple_name(descriptor: &str) -> String {
27 let inner = names::class_inner(descriptor);
28 inner.rsplit(['/', '$']).next().unwrap_or(inner).to_ascii_lowercase()
29}
30
31fn 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
44fn 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 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
71fn 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
80fn 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
95fn 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
121fn 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
130fn 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 pub(crate) fn class_not_found_hint(&self, descriptor: &str) -> String {
152 let classes = self.list_classes(None).unwrap_or_default();
153 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 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 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 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 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 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 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 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
352pub 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
396pub 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}