Skip to main content

asc_engine/
lib.rs

1//! The asc-rs engine: stateless, zero-preprocessing queries against an APK.
2//!
3//! * [`Engine::get_class_dex`] inflates the DEX entries in parallel (smallest
4//!   first) and stops as soon as one defines the requested class.
5//! * [`Engine::find_refs`] runs a reference search over every DEX entry in
6//!   parallel and streams results back as each DEX completes.
7//! * [`Engine::get_class`] rebuilds a minimal DEX containing only the target
8//!   class and renders it as smali.
9//! * [`Engine::list_classes`] lists the classes of every DEX entry in order.
10//! * [`Engine::manifest_xml`] decodes the binary `AndroidManifest.xml`.
11//!
12//! Optionally the engine keeps inflated DEX entries in an on-disk cache
13//! (shared across processes) and/or an in-memory LRU together with the locator
14//! tables built for them, so repeated queries skip inflation and table builds.
15//! The disk cache also keeps the locator tables (L2, mapped by later
16//! processes instead of rebuilt) and `find_refs` results (L3); see
17//! [`asc_cache`] for the tiers.
18//! Disk writes happen on a background thread so a query never waits on them;
19//! [`Engine::flush_cache_writes`] waits for them to land and [`Engine::close_cache_writes`]
20//! (run on drop) additionally stops queueing new ones.
21
22mod budget;
23mod callgraph;
24mod classes;
25mod compact;
26mod compose;
27pub mod condition;
28pub mod config;
29mod defs;
30mod hierarchy;
31pub mod hints;
32mod manifest;
33pub mod names;
34mod prebuild;
35mod resident;
36mod resources;
37mod rules;
38mod skip;
39mod sweep;
40pub mod text;
41mod tiers;
42
43use asc_apk::{Apk, Entry, Limits};
44use asc_cache::{ApkKey, DexData, DiskCache, MemoryCache, Tier};
45use asc_dex::Dex;
46use asc_dex::container::{dex041_logical_offsets, logical_dex_buffers, logical_dex_iter};
47pub use asc_findrefs::CompiledQuery;
48use asc_findrefs::{FindRefs, Hit, Query, RefKind, SearchState};
49use asc_rebuild::Rebuilt;
50use std::borrow::Cow;
51use std::collections::{BTreeMap, HashSet};
52use std::path::{Path, PathBuf};
53use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
54use std::sync::{Arc, Condvar, Mutex, OnceLock, Weak, mpsc};
55use std::time::{Duration, Instant, SystemTime};
56
57pub use asc_apk::Limits as InflateLimits;
58pub use asc_apk::archive_of;
59pub use asc_arsc;
60pub use asc_cache;
61pub use asc_findrefs::{ClassQuery, ClassRelation, FieldAccess, MemberQuery};
62pub use asc_smali::Options as SmaliOptions;
63pub use budget::{Device, MemoryPolicy, available_memory, total_memory};
64pub use callgraph::{CallQuery, CallRoots, CallRow, CallTree, Direction};
65pub use classes::{ClassListing, DexClasses, EntryClasses, dex_classes};
66pub use compact::TextRows;
67pub use compose::{DEFAULT_FAN_OUT, MatchingMethods};
68pub use hierarchy::{HierarchyQuery, HierarchyRow, HierarchyTree, Lineage};
69pub use manifest::{Application, Component, DataSpec, IntentFilter, ManifestSummary, Permission, protection_level_name};
70pub use prebuild::PrebuildReport;
71pub use resources::{ResourceField, ResourceRow, parse_id};
72pub use rules::compile_rules;
73pub use sweep::{Claim, DexDedup};
74pub use tiers::{Busy, auto_threads, auto_threads_for};
75
76#[derive(Debug, thiserror::Error)]
77pub enum EngineError {
78    #[error(transparent)]
79    Apk(#[from] asc_apk::ApkError),
80    #[error(transparent)]
81    Dex(#[from] asc_dex::DexError),
82    #[error(transparent)]
83    Find(#[from] asc_findrefs::FindError),
84    #[error(transparent)]
85    Rebuild(#[from] asc_rebuild::RebuildError),
86    #[error(transparent)]
87    Smali(#[from] asc_smali::SmaliError),
88    #[error(transparent)]
89    Axml(#[from] asc_axml::AxmlError),
90    #[error(transparent)]
91    Arsc(#[from] asc_arsc::ArscError),
92    /// `hint` (empty, or starting with `:` / `;`) says why and what to try.
93    #[error("class {class} not found in APK{hint}")]
94    ClassNotFound { class: String, hint: String },
95    #[error("{0}")]
96    Query(String),
97    #[error("{dex}: {reason}")]
98    BadDex { dex: String, reason: String },
99    #[error("worker panicked while processing {0}")]
100    WorkerPanic(String),
101}
102
103pub type Result<T> = std::result::Result<T, EngineError>;
104
105/// Shared inflated bytes of a DEX (possibly one logical DEX of a container).
106pub type DexBytes = Arc<DexData>;
107
108/// A DEX (possibly one logical DEX of a 041 container) that defines a class.
109#[derive(Clone)]
110pub struct ClassHit {
111    pub dex_name: String,
112    pub dex: DexBytes,
113    /// In a program, the DEX files of other archives that define the class
114    /// too (the hit is the first archive's, in program order).
115    pub also_defined_in: Vec<String>,
116}
117
118/// Result of a full `getclass`.
119#[derive(Clone)]
120pub struct ClassSource {
121    pub dex_name: String,
122    pub source: String,
123    /// The minimal single-class DEX, or `None` when the class could not be
124    /// rebuilt (see `warning`) and `source` was disassembled from the
125    /// original DEX instead.
126    pub rebuilt: Option<Rebuilt>,
127    /// Why the rebuild was skipped, when it was.
128    pub warning: Option<String>,
129    /// Microseconds spent locating and inflating the DEX.
130    pub scan_us: u128,
131    /// See [`ClassHit::also_defined_in`].
132    pub also_defined_in: Vec<String>,
133}
134
135/// One aggregated reference result: a method and what it references.
136///
137/// For a class relation query (`Extends` / `Implements`) the row is a class
138/// instead: `method_idx` is its type index, `method` and `signature` its
139/// descriptor, `matched` the target descriptor and `relation` is set.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct RefResult {
142    pub dex_name: String,
143    pub method_idx: u32,
144    /// `Lcls;->name`
145    pub method: String,
146    /// `Lcls;->name(params)ret`
147    pub signature: String,
148    /// The matched constants (strings, descriptors, members).
149    pub matched: Vec<Matched>,
150    /// Set for the rows of a class relation query.
151    pub relation: Option<ClassRelation>,
152    /// The referencing instructions, in offset order (none for class relations).
153    pub sites: Vec<RefSite>,
154    /// Smali around the sites, when asked for ([`RefContext`]).
155    pub context: Option<String>,
156}
157
158/// One instruction behind a [`RefResult`].
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct RefSite {
161    /// Absolute byte offset of the instruction in the DEX.
162    pub offset: u32,
163    /// Index into the row's `matched` of the constant it references.
164    pub matched: usize,
165    /// Whether this site reads or writes a field. `None` for other queries.
166    pub field_access: Option<FieldAccess>,
167    /// Code units from the start of the method's code (filled with a context).
168    pub pc: Option<u32>,
169    /// Source line from the debug info (filled with a context, when known).
170    pub line: Option<u32>,
171}
172
173/// How much code to return with each `findrefs` row.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
175pub enum RefContext {
176    /// The row alone (the Python output).
177    #[default]
178    None,
179    /// The referencing instructions with this many smali lines around each.
180    Lines(usize),
181    /// The whole referencing method.
182    Method,
183}
184
185/// Options of [`Engine::find_refs_opts`].
186#[derive(Debug, Clone, Copy)]
187pub struct FindOptions {
188    /// One row per method (with every matched constant) instead of one per instruction.
189    pub aggregate: bool,
190    pub context: RefContext,
191}
192
193impl Default for FindOptions {
194    fn default() -> Self {
195        FindOptions { aggregate: true, context: RefContext::None }
196    }
197}
198
199/// A constant a [`RefResult`] matched.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum Matched {
202    /// The value of a string constant.
203    String(String),
204    /// A type descriptor, `Lcls;->name(params)ret` or `Lcls;->name:type`.
205    Symbol(String),
206    /// A field symbol and how its instruction uses it.
207    Field { symbol: String, access: FieldAccess },
208}
209
210impl Matched {
211    /// The string value or the symbol, unquoted.
212    pub fn as_str(&self) -> &str {
213        match self {
214            Matched::String(s) | Matched::Symbol(s) | Matched::Field { symbol: s, .. } => s,
215        }
216    }
217
218    /// The field access represented by this match.
219    pub fn field_access(&self) -> Option<FieldAccess> {
220        match self {
221            Matched::Field { access, .. } => Some(*access),
222            _ => None,
223        }
224    }
225}
226
227/// Strings are quoted and escaped (one line, readable as JSON); symbols are
228/// written as they are, since descriptors never hold `, ` or a newline.
229impl std::fmt::Display for Matched {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        match self {
232            Matched::String(s) => f.write_str(&text::quoted(s)),
233            Matched::Symbol(s) => f.write_str(s),
234            Matched::Field { symbol, access } => write!(f, "{symbol} [{}]", access.as_str()),
235        }
236    }
237}
238
239impl RefResult {
240    /// `dex | Lcls;->m(params)ret | "a", Lx;->f:I`, or `dex | Lsub; | extends
241    /// Lbase;` for a class relation: three `|` columns, one line.
242    pub fn line(&self) -> String {
243        let matched = self.matched.iter().map(Matched::to_string).collect::<Vec<_>>().join(", ");
244        match self.relation {
245            Some(rel) => format!("{} | {} | {} {matched}", self.dex_name, self.signature, rel.verb()),
246            None => format!("{} | {} | {matched}", self.dex_name, self.signature),
247        }
248    }
249
250    /// The row followed by its context lines (just the row without one).
251    pub fn text(&self) -> String {
252        let mut out = self.line();
253        if let Some(c) = &self.context {
254            out.push('\n');
255            out.push_str(c.trim_end_matches('\n'));
256        }
257        out
258    }
259}
260
261/// Results for one DEX, delivered as soon as its search completes.
262#[derive(Debug, Clone)]
263pub struct RefBatch {
264    pub entry_name: String,
265    pub results: Vec<RefResult>,
266    /// The rows as text instead, for [`Engine::find_refs_text`] (`results`
267    /// is then empty).
268    pub text: Option<TextRows>,
269    pub inflate_us: u128,
270    pub search_us: u128,
271    /// Where the bytes came from: `inflate`, `memory`, `disk` or `stored`.
272    pub source: &'static str,
273    pub error: Option<String>,
274}
275
276impl RefBatch {
277    /// The number of rows, in `results` or `text`.
278    pub fn row_count(&self) -> usize {
279        self.results.len() + self.text.as_ref().map_or(0, TextRows::len)
280    }
281
282    /// Record a failure of one logical DEX of this entry. A container holds
283    /// several, so failures accumulate instead of overwriting each other.
284    /// Consumers prefix `entry_name`; only a container's logical DEX (whose
285    /// name differs) adds its own.
286    fn note_error(&mut self, name: &str, e: &str) {
287        let msg = if name == self.entry_name { e.to_string() } else { format!("{name}: {e}") };
288        self.error = Some(match self.error.take() {
289            Some(prev) => format!("{prev}; {msg}"),
290            None => msg,
291        });
292    }
293}
294
295/// Summary of a whole `find_refs` run.
296#[derive(Debug, Clone, Default)]
297pub struct RefSummary {
298    pub entries: usize,
299    pub total_results: usize,
300    pub total_us: u128,
301    /// What the search left out on purpose, e.g. a supertype method a
302    /// dispatch set did not follow; one line each, for `#` lines or hints.
303    pub notes: Vec<String>,
304}
305
306/// Cache configuration for an [`Engine`].
307pub struct CacheConfig {
308    /// In-memory LRU capacity for inflated entries (0 disables it).
309    pub memory_bytes: usize,
310    /// On-disk cache shared across processes.
311    pub disk: Option<DiskCache>,
312    /// Treat `memory_bytes` as a starting point and keep resizing the LRU to
313    /// this process's share of one budget for every resident host the user
314    /// runs ([`MemoryPolicy::share`]), instead of a fixed capacity.
315    pub memory_auto: Option<MemoryPolicy>,
316    /// Heap queued disk writes may hold before new ones are deferred
317    /// ([`WriteJob::defer`]); a memory cache's budget lowers it further.
318    pub write_queue_bytes: usize,
319    /// Disk writer threads (`None`: [`CacheConfig::auto_writers`]).
320    pub writers: Option<usize>,
321}
322
323impl CacheConfig {
324    /// Default of [`CacheConfig::write_queue_bytes`]. A cold multi-APK sweep
325    /// inflates far faster than the writers persist, and each queued job
326    /// keeps its DEX bytes or tables alive until written; without a bound the
327    /// heap grows with the backlog (tens of GB over a phone's APKs).
328    pub const WRITE_QUEUE: usize = 512 << 20;
329    /// Most automatic writer threads: writing is I/O, but a deferred L1
330    /// write inflates its entry again first.
331    pub const MAX_AUTO_WRITERS: usize = 4;
332
333    /// Disk cache in the default directory, no in-memory cache (CLI default).
334    pub fn disk_default() -> CacheConfig {
335        CacheConfig { disk: DiskCache::open_default(), ..CacheConfig::default() }
336    }
337
338    /// Disk cache plus an in-memory LRU of a fixed capacity.
339    pub fn resident(memory_bytes: usize) -> CacheConfig {
340        CacheConfig { memory_bytes, disk: DiskCache::open_default(), ..CacheConfig::default() }
341    }
342
343    /// One writer per 8 CPUs, 1..=[`CacheConfig::MAX_AUTO_WRITERS`].
344    pub fn auto_writers(cpus: usize) -> usize {
345        (cpus / 8).clamp(1, CacheConfig::MAX_AUTO_WRITERS)
346    }
347}
348
349impl Default for CacheConfig {
350    fn default() -> CacheConfig {
351        CacheConfig { memory_bytes: 0, disk: None, memory_auto: None, write_queue_bytes: CacheConfig::WRITE_QUEUE, writers: None }
352    }
353}
354
355/// A cache several [`Engine`]s share, so a process serving many APKs keeps
356/// one memory budget (the LRU is keyed by APK) and one disk-writer thread.
357/// Engines attached with [`Engine::with_shared_cache`] leave queued disk
358/// writes alone when dropped; dropping the `SharedCache` flushes them and
359/// stops queueing (as dropping a private engine does).
360pub struct SharedCache(Option<Arc<EngineCache>>);
361
362impl SharedCache {
363    /// Build the cache [`Engine::with_cache`] would; warnings (an unusable
364    /// disk cache directory) go to `logger`.
365    pub fn new(config: CacheConfig, logger: impl Fn(&str) + Send + Sync + 'static) -> SharedCache {
366        let mut holder = Engine::new("").with_logger(logger).with_cache(config);
367        SharedCache(holder.cache.take())
368    }
369
370    /// Bytes currently held by the shared in-memory cache.
371    pub fn memory_bytes(&self) -> usize {
372        self.0.as_ref().and_then(|c| c.memory.as_ref()).map(|m| m.lock().unwrap().used_bytes()).unwrap_or(0)
373    }
374
375    /// Budget of the shared in-memory cache (0 when it is disabled).
376    pub fn memory_capacity(&self) -> usize {
377        self.0.as_ref().and_then(|c| c.memory.as_ref()).map(|m| m.lock().unwrap().capacity_bytes()).unwrap_or(0)
378    }
379
380    /// Bytes counted against the memory budget besides the cache's entries
381    /// ([`SharedCache::set_reserved`]).
382    pub fn memory_reserved(&self) -> usize {
383        self.0.as_ref().and_then(|c| c.memory.as_ref()).map(|m| m.lock().unwrap().reserved_bytes()).unwrap_or(0)
384    }
385
386    /// Count `bytes` the host holds outside the cache (the class graphs and
387    /// resource tables of its engines, [`Engine::resident_bytes`]) against
388    /// the memory budget: the cache evicts entries to make room for them.
389    pub fn set_reserved(&self, bytes: usize) {
390        if let Some(m) = self.0.as_ref().and_then(|c| c.memory.as_ref()) {
391            m.lock().unwrap().set_reserved(bytes);
392        }
393    }
394
395    /// Block until every queued disk-cache write has landed.
396    pub fn flush_writes(&self) {
397        if let Some(c) = &self.0 {
398            c.flush(false);
399        }
400    }
401}
402
403impl Drop for SharedCache {
404    fn drop(&mut self) {
405        if let Some(c) = &self.0 {
406            c.flush(true);
407        }
408    }
409}
410
411/// One logical DEX of a cached entry with its lazily built locator tables.
412/// The tables are shared with the entry that replaces this one once its
413/// bytes are remapped from the disk cache (same bytes, so still valid).
414pub struct LogicalDex {
415    pub name: String,
416    pub data: DexBytes,
417    pub state: Arc<SearchState>,
418    persisted: Arc<tiers::Persisted>,
419}
420
421/// The memory cache charges file-backed bytes (mapped from the disk cache
422/// or the APK) at this fraction of their size, heap bytes in full.
423///
424/// File pages are not what runs a machine out of memory: the kernel drops
425/// clean ones under pressure (they are re-read from the file) and every
426/// process mapping the same L1 / L2 file or APK shares one copy, so charging
427/// them in full made each of N processes evict as if it held its own copy,
428/// and summed to N times the real footprint in the process registry
429/// (`MemAvailable`, which the budget starts from, already counts reclaimable
430/// page cache). They still cost something (address space, page cache churn,
431/// the re-reads), so they are not free either: the LRU still bounds them, at
432/// up to this many times the budget.
433pub const MAPPED_CHARGE_DIVISOR: usize = 8;
434
435/// A cached inflated entry.
436pub struct CachedEntry {
437    pub name: String,
438    pub data: DexBytes,
439    logical: OnceLock<Vec<LogicalDex>>,
440}
441
442impl CachedEntry {
443    fn new(name: &str, data: DexBytes) -> CachedEntry {
444        CachedEntry { name: name.to_string(), data, logical: OnceLock::new() }
445    }
446
447    /// Logical DEX files of this entry (one, unless it is a 041 container).
448    pub fn logical(&self) -> &[LogicalDex] {
449        self.logical.get_or_init(|| {
450            let bufs = logical_dex_buffers(&self.name, &self.data);
451            bufs.into_iter()
452                .map(|(name, buf)| {
453                    let data = match buf {
454                        Cow::Borrowed(_) => Arc::clone(&self.data),
455                        Cow::Owned(v) => Arc::new(DexData::Owned(v)),
456                    };
457                    LogicalDex { name, data, state: Arc::new(SearchState::new()), persisted: Arc::default() }
458                })
459                .collect()
460        })
461    }
462
463    /// Bytes this entry keeps resident once searched: the inflated entry plus,
464    /// for a 041 container, one full-container copy per further logical DEX
465    /// (what `logical()` materialises). Computed from the headers alone so
466    /// accounting does not itself trigger those copies.
467    pub fn resident_bytes(&self) -> usize {
468        self.data.len() * dex041_logical_offsets(&self.data).len()
469    }
470
471    /// Bytes of the locator tables built (on the heap) or mapped (from L2)
472    /// so far for this entry's logical DEX files (0 before the first search).
473    pub fn state_bytes(&self) -> usize {
474        self.logical.get().map_or(0, |l| l.iter().map(|d| d.state.heap_bytes() + d.state.mapped_bytes()).sum())
475    }
476
477    /// What the in-memory cache charges for this entry: heap bytes in full
478    /// (the inflated entry when not mapped, a container's logical copies,
479    /// tables built on the heap) and file-backed bytes (the entry mapped from
480    /// L1 or the APK, tables mapped from L2) at 1/[`MAPPED_CHARGE_DIVISOR`].
481    pub fn charged_bytes(&self) -> usize {
482        let copies = dex041_logical_offsets(&self.data).len();
483        let len = self.data.len();
484        let own =
485            if self.is_mapped() { (len / MAPPED_CHARGE_DIVISOR) * copies.min(1) + len * copies.saturating_sub(1) } else { len * copies };
486        let tables = self
487            .logical
488            .get()
489            .map_or(0, |l| l.iter().map(|d| d.state.heap_bytes() + d.state.mapped_bytes() / MAPPED_CHARGE_DIVISOR).sum::<usize>());
490        own + tables
491    }
492
493    /// The same entry over `data` (identical bytes from another source, e.g.
494    /// the disk cache mapping), keeping the locator tables already built.
495    /// A container's normalised logical copies are owned and are kept as is.
496    fn with_data(&self, data: DexBytes) -> CachedEntry {
497        let logical = OnceLock::new();
498        if let Some(old) = self.logical.get() {
499            let moved = old
500                .iter()
501                .map(|l| LogicalDex {
502                    name: l.name.clone(),
503                    data: if Arc::ptr_eq(&l.data, &self.data) { Arc::clone(&data) } else { Arc::clone(&l.data) },
504                    state: Arc::clone(&l.state),
505                    persisted: Arc::clone(&l.persisted),
506                })
507                .collect();
508            let _ = logical.set(moved);
509        }
510        CachedEntry { name: self.name.clone(), data, logical }
511    }
512
513    /// The same entry with the locator tables of logical DEX `idx` replaced
514    /// by `state` (the same tables viewed from L2).
515    fn with_state(&self, idx: usize, state: Arc<SearchState>) -> CachedEntry {
516        let logical = OnceLock::new();
517        if let Some(old) = self.logical.get() {
518            let moved = old
519                .iter()
520                .enumerate()
521                .map(|(i, l)| LogicalDex {
522                    name: l.name.clone(),
523                    data: Arc::clone(&l.data),
524                    state: if i == idx { Arc::clone(&state) } else { Arc::clone(&l.state) },
525                    persisted: Arc::clone(&l.persisted),
526                })
527                .collect();
528            let _ = logical.set(moved);
529        }
530        CachedEntry { name: self.name.clone(), data: Arc::clone(&self.data), logical }
531    }
532
533    /// Whether the entry's bytes are mapped from the disk cache.
534    pub fn is_mapped(&self) -> bool {
535        matches!(*self.data, DexData::Mapped(_))
536    }
537}
538
539/// A queued disk write for the APK of `key` at `apk`.
540struct WriteJob {
541    key: ApkKey,
542    apk: Arc<Path>,
543    kind: Job,
544}
545
546/// How often a call waiting on its workers checks for cancellation.
547const CANCEL_POLL: Duration = Duration::from_millis(20);
548
549/// The flag of a call nobody cancels.
550pub(crate) static NOT_CANCELLED: AtomicBool = AtomicBool::new(false);
551
552/// What a cancelled call returns.
553pub(crate) fn cancelled() -> EngineError {
554    asc_apk::ApkError::Cancelled.into()
555}
556
557impl WriteJob {
558    /// Heap the job keeps alive until written. L3 payloads are small and
559    /// not counted.
560    fn held_bytes(&self) -> usize {
561        match &self.kind {
562            Job::Dex { data, .. } => data.len(),
563            Job::Derived(d) => d.held_bytes(),
564            Job::Graph { graph, .. } => graph.heap_bytes(),
565            Job::Rendered { .. } | Job::Reinflate { .. } => 0,
566        }
567    }
568
569    /// The job without the bytes it holds, for a full queue. An L1 entry is
570    /// inflated again from the APK when its turn comes, so a cold sweep still
571    /// leaves L1 complete; L2 tables and the class graph cannot be rebuilt
572    /// by a writer and are released (`None`) for a later query to queue again.
573    fn defer(self) -> Option<WriteJob> {
574        let WriteJob { key, apk, kind } = self;
575        let kind = match kind {
576            Job::Dex { entry, limits, remap, .. } => Job::Reinflate { entry, limits, remap },
577            Job::Derived(d) => {
578                d.abandon();
579                return None;
580            }
581            Job::Graph { .. } => return None,
582            kind @ (Job::Rendered { .. } | Job::Reinflate { .. }) => kind,
583        };
584        Some(WriteJob { key, apk, kind })
585    }
586
587    /// Dropped unwritten: an L1 entry is simply written by a later query;
588    /// L2 tables must be released for one to queue them again.
589    fn abandon(self) {
590        if let Job::Derived(d) = self.kind {
591            d.abandon();
592        }
593    }
594}
595
596enum Job {
597    /// An inflated entry for L1: the bytes are shared with the query that
598    /// produced them, so queueing costs nothing.
599    Dex {
600        entry: Entry,
601        /// The APK's read limits, should the write be deferred.
602        limits: Limits,
603        data: DexBytes,
604        /// The in-memory cache entry holding `data`, if any: once the file is
605        /// in place its heap bytes are swapped for a mapping of that file.
606        remap: Option<Weak<CachedEntry>>,
607    },
608    /// An L1 write deferred by a full queue: the writer inflates `entry`
609    /// again (if the APK is unchanged and the file still missing).
610    Reinflate { entry: Entry, limits: Limits, remap: Option<Weak<CachedEntry>> },
611    /// Locator tables for L2.
612    Derived(tiers::DerivedJob),
613    /// A query result for L3.
614    Rendered { query: String, payload: Vec<u8> },
615    /// The class graph for L2, serialised by the writer; `cost` is what
616    /// building it took.
617    Graph { graph: Arc<hierarchy::ClassGraph>, cost: Duration },
618}
619
620struct Writer {
621    /// Each job with its [`WriteJob::held_bytes`] when queued.
622    tx: mpsc::Sender<(WriteJob, usize)>,
623    handles: Vec<std::thread::JoinHandle<()>>,
624}
625
626type SharedMemory = Arc<Mutex<MemoryCache<CachedEntry>>>;
627
628struct EngineCache {
629    memory: Option<SharedMemory>,
630    /// Publishes (and, when automatic, adjusts) the memory cache's size.
631    budget: Option<tiers::MemoryBudget>,
632    disk: Option<Arc<DiskCache>>,
633    writer: Mutex<Option<Writer>>,
634    /// Set by `flush`: later writes are dropped rather than handed to a new
635    /// writer thread the exiting process would kill mid-write.
636    closed: AtomicBool,
637    /// Entries currently being inflated, so concurrent queries share one
638    /// inflate instead of racing (and replacing each other's locator tables).
639    inflight: Mutex<HashSet<String>>,
640    inflight_done: Condvar,
641    /// L2 writes queued and not yet written: their tables stay on the heap
642    /// until written and mapped, so a prebuild waits while this is high.
643    derived_pending: Arc<AtomicUsize>,
644    /// Heap held by queued writes, bounded by [`EngineCache::queue_cap`].
645    pending_bytes: Arc<AtomicUsize>,
646    /// [`CacheConfig::write_queue_bytes`].
647    queue_cap: usize,
648    /// [`CacheConfig::writers`].
649    writers: Option<usize>,
650}
651
652impl EngineCache {
653    /// Heap the queued writes may hold: [`CacheConfig::write_queue_bytes`], or the
654    /// memory cache's budget when smaller. The budget is this process's share
655    /// of the user's (`--memory-mb`, or split over the registered processes):
656    /// sixteen servers sweeping at once would otherwise each queue 512 MiB
657    /// whatever their caches may hold.
658    fn queue_cap(&self) -> usize {
659        let budget = self.memory.as_ref().map_or(usize::MAX, |m| m.lock().unwrap().capacity_bytes());
660        self.queue_cap.min(budget)
661    }
662
663    /// Queue a disk write; falls back to a synchronous write only when no
664    /// writer thread can be started. A write that would take the queued heap
665    /// past [`EngineCache::queue_cap`] is queued without its bytes instead
666    /// ([`WriteJob::defer`]): the query never waits for the disk, the heap
667    /// stays bounded, and L1 still ends up complete.
668    fn put_async(&self, job: WriteJob) {
669        let Some(disk) = &self.disk else { return };
670        // Before the writer lock: never hold it while waiting for the memory cache's.
671        let cap = self.queue_cap();
672        let mut writer = self.writer.lock().unwrap();
673        if self.closed.load(Ordering::Relaxed) {
674            return;
675        }
676        let pending = self.pending_bytes.load(Ordering::Relaxed);
677        let job = if pending > 0 && pending + job.held_bytes() > cap {
678            match job.defer() {
679                Some(job) => job,
680                None => return,
681            }
682        } else {
683            job
684        };
685        let held = job.held_bytes();
686        if writer.is_none() {
687            match self.spawn_writers(disk) {
688                Some(w) => *writer = Some(w),
689                None => {
690                    run_job(disk, self.memory.as_ref(), job, &mut WriterState::default());
691                    disk.evict_lazily();
692                    return;
693                }
694            }
695        }
696        if let Some(w) = writer.as_ref() {
697            let derived = matches!(job.kind, Job::Derived(_));
698            if derived {
699                self.derived_pending.fetch_add(1, Ordering::Relaxed);
700            }
701            self.pending_bytes.fetch_add(held, Ordering::Relaxed);
702            if let Err(mpsc::SendError((job, _))) = w.tx.send((job, held)) {
703                if derived {
704                    self.derived_pending.fetch_sub(1, Ordering::Relaxed);
705                }
706                self.pending_bytes.fetch_sub(held, Ordering::Relaxed);
707                job.abandon();
708                *writer = None;
709            }
710        }
711    }
712
713    /// Wait for every queued disk write to land. With `close`, writes queued
714    /// afterwards are dropped (the next query re-inflates and re-queues them).
715    fn flush(&self, close: bool) {
716        let taken = {
717            let mut writer = self.writer.lock().unwrap();
718            // Under the lock, so a concurrent `put_async` either queued its job
719            // before the take or sees the flag.
720            if close {
721                self.closed.store(true, Ordering::Relaxed);
722            }
723            writer.take()
724        };
725        if let Some(Writer { tx, handles }) = taken {
726            drop(tx);
727            for handle in handles {
728                let _ = handle.join();
729            }
730        }
731    }
732
733    /// Start the writer threads, sharing one queue; `None` when not even one
734    /// can be started.
735    fn spawn_writers(&self, disk: &Arc<DiskCache>) -> Option<Writer> {
736        let (tx, rx) = mpsc::channel::<(WriteJob, usize)>();
737        let rx = Arc::new(Mutex::new(rx));
738        let count = self.writers.unwrap_or_else(|| CacheConfig::auto_writers(tiers::cpus())).max(1);
739        let handles: Vec<_> = (0..count)
740            .map_while(|_| {
741                let (rx, disk, memory) = (Arc::clone(&rx), Arc::clone(disk), self.memory.clone());
742                let (derived_pending, pending_bytes) = (Arc::clone(&self.derived_pending), Arc::clone(&self.pending_bytes));
743                std::thread::Builder::new()
744                    .name("asc-cache-writer".into())
745                    .spawn(move || {
746                        // Writes (and re-inflating deferred entries) must not take CPU from queries.
747                        prebuild::lower_priority();
748                        let mut state = WriterState::default();
749                        loop {
750                            // Only the receive holds the lock, so the writers run their jobs in parallel.
751                            let Ok((job, held)) = rx.lock().expect("writer queue mutex poisoned").recv() else { break };
752                            let derived = matches!(job.kind, Job::Derived(_));
753                            run_job(&disk, memory.as_ref(), job, &mut state);
754                            pending_bytes.fetch_sub(held, Ordering::Relaxed);
755                            if derived {
756                                derived_pending.fetch_sub(1, Ordering::Relaxed);
757                            }
758                            // Throttled by bytes written, so a long burst still keeps
759                            // the directory within budget without a walk per entry.
760                            disk.evict_lazily();
761                        }
762                    })
763                    .ok()
764            })
765            .collect();
766        (!handles.is_empty()).then_some(Writer { tx, handles })
767    }
768
769    /// Account for bytes just charged to the memory cache.
770    fn charged(&self) {
771        if let (Some(budget), Some(memory)) = (&self.budget, &self.memory) {
772            budget.maybe_rebalance(memory);
773        }
774    }
775
776    fn begin_inflate(&self, id: String) -> InflightGuard<'_> {
777        let mut set = self.inflight.lock().unwrap();
778        while set.contains(&id) {
779            set = self.inflight_done.wait(set).unwrap();
780        }
781        set.insert(id.clone());
782        InflightGuard { cache: self, id }
783    }
784}
785
786/// What one writer thread keeps between jobs.
787#[derive(Default)]
788struct WriterState {
789    /// APKs whose location the cache already recorded.
790    noted: HashSet<ApkKey>,
791    /// The archive deferred L1 writes last inflated from: a sweep defers
792    /// runs of entries of the same APK.
793    opened: Option<(Arc<Path>, ApkKey, Apk)>,
794}
795
796impl WriterState {
797    /// `path` opened with `limits`, if it is still the archive `key` names.
798    fn apk(&mut self, path: &Arc<Path>, key: &ApkKey, limits: Limits) -> Option<&Apk> {
799        if !matches!(&self.opened, Some((p, k, _)) if p == path && k == key) {
800            self.opened = None;
801            let apk = Apk::open(path).ok()?.with_limits(limits);
802            // Rewritten since the job was queued: its entries are not the ones to store.
803            if ApkKey::for_apk(&apk).ok()? != *key {
804                return None;
805            }
806            self.opened = Some((Arc::clone(path), key.clone(), apk));
807        }
808        self.opened.as_ref().map(|(_, _, apk)| apk)
809    }
810}
811
812/// Run a queued write on a writer thread. The first write for an APK also
813/// records where it lives.
814fn run_job(disk: &DiskCache, memory: Option<&SharedMemory>, job: WriteJob, state: &mut WriterState) {
815    let WriteJob { key, apk, kind } = job;
816    if state.noted.insert(key.clone()) {
817        let _ = disk.note_source(&key, &apk);
818    }
819    match kind {
820        Job::Dex { entry, data, remap, .. } => {
821            if disk.put(&key, &entry.name, &data).is_ok() {
822                remap_entry(disk, memory, key, entry.name, data, remap);
823            }
824        }
825        Job::Reinflate { entry, limits, remap } => {
826            // Written meanwhile, by a later query of the entry or another process.
827            if disk.get(&key, &entry.name, entry.uncomp_size).is_some() {
828                return;
829            }
830            let Some(archive) = state.apk(&apk, &key, limits) else { return };
831            let Ok(Cow::Owned(bytes)) = archive.read_entry(&entry) else { return };
832            let data: DexBytes = Arc::new(DexData::Owned(bytes));
833            if disk.put(&key, &entry.name, &data).is_ok() {
834                remap_entry(disk, memory, key, entry.name, data, remap);
835            }
836        }
837        Job::Derived(d) => tiers::write_derived(disk, memory, &key, d),
838        Job::Rendered { query, payload } => {
839            let _ = disk.put_rendered(&key, &query, &payload);
840        }
841        Job::Graph { graph, cost } => {
842            let _ = disk.put_derived(&key, hierarchy::GRAPH_NAME, hierarchy::GRAPH_KIND, hierarchy::GRAPH_FORMAT, cost, graph.bytes());
843        }
844    }
845}
846
847/// Swap a freshly written entry's heap bytes for a mapping of its cache file:
848/// the pages become file-backed (reclaimable by the OS and shared with other
849/// processes) and the heap copy is freed once the queries still holding the
850/// old entry drop it. The mapping is compared byte for byte first, so a file
851/// changed underneath (another writer, a cleared cache) is never served.
852fn remap_entry(
853    disk: &DiskCache,
854    memory: Option<&SharedMemory>,
855    key: ApkKey,
856    name: String,
857    data: DexBytes,
858    remap: Option<Weak<CachedEntry>>,
859) {
860    // The job's own handle on the bytes goes first, so the heap copy is freed
861    // as soon as the superseded entry is.
862    drop(data);
863    let (Some(memory), Some(entry)) = (memory, remap.and_then(|w| w.upgrade())) else { return };
864    if entry.is_mapped() {
865        return;
866    }
867    let Some(mapped) = disk.get(&key, &name, entry.data.len() as u64) else { return };
868    if *mapped != **entry.data {
869        return;
870    }
871    let new = Arc::new(entry.with_data(Arc::new(mapped)));
872    // Now file-backed: charged less.
873    let size = new.charged_bytes();
874    let mut memory = memory.lock().unwrap();
875    if memory.replace(&key, &name, &entry, Arc::clone(&new)) {
876        memory.resize(&key, &name, &new, size);
877    }
878}
879
880/// Make freed DEX-sized blocks go straight back to the OS. glibc serves
881/// blocks above its mmap threshold with `mmap`, but raises that threshold to
882/// the size of every such block freed (up to 32 MiB), after which inflated
883/// DEX copies come from the heap arenas and stay resident after an eviction
884/// or a remap, fragmented between longer-lived locator tables. Fixing the
885/// threshold disables that adjustment. Process-wide; done once, by the first
886/// engine that keeps entries in memory.
887fn tune_allocator() {
888    #[cfg(all(target_os = "linux", target_env = "gnu"))]
889    {
890        static ONCE: std::sync::Once = std::sync::Once::new();
891        ONCE.call_once(|| {
892            unsafe extern "C" {
893                fn mallopt(param: std::os::raw::c_int, value: std::os::raw::c_int) -> std::os::raw::c_int;
894            }
895            const M_MMAP_THRESHOLD: std::os::raw::c_int = -3;
896            // SAFETY: mallopt only adjusts glibc allocator parameters.
897            unsafe {
898                mallopt(M_MMAP_THRESHOLD, 1 << 20);
899            }
900        });
901    }
902}
903
904struct InflightGuard<'c> {
905    cache: &'c EngineCache,
906    id: String,
907}
908
909impl Drop for InflightGuard<'_> {
910    fn drop(&mut self) {
911        self.cache.inflight.lock().unwrap().remove(&self.id);
912        self.cache.inflight_done.notify_all();
913    }
914}
915
916/// Cache activity counters.
917#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
918pub struct CacheStats {
919    pub memory_hits: u64,
920    pub disk_hits: u64,
921    /// Derived indexes mapped from L2: the locator tables of a logical DEX,
922    /// and the class graph.
923    pub derived_hits: u64,
924    pub inflates: u64,
925    /// Deflate entries a search left out: a streamed prefix showed it could
926    /// not match them, so they were never inflated whole.
927    pub skipped: u64,
928}
929
930type Logger = Arc<dyn Fn(&str) + Send + Sync>;
931
932/// Where an entry's bytes came from.
933#[derive(Debug, Clone, Copy, PartialEq, Eq)]
934enum Source {
935    Memory,
936    Disk,
937    Stored,
938    Inflate,
939    /// Not loaded: a prefix showed the search cannot match it.
940    Skipped,
941}
942
943impl Source {
944    fn as_str(self) -> &'static str {
945        match self {
946            Source::Memory => "memory",
947            Source::Disk => "disk",
948            Source::Stored => "stored",
949            Source::Inflate => "inflate",
950            Source::Skipped => "skipped",
951        }
952    }
953}
954
955enum Loaded<'a> {
956    Borrowed(Cow<'a, [u8]>),
957    Cached(Arc<CachedEntry>),
958}
959
960impl<'a> Loaded<'a> {
961    fn bytes(&self) -> &[u8] {
962        match self {
963            Loaded::Borrowed(c) => c,
964            Loaded::Cached(e) => &e.data,
965        }
966    }
967
968    /// Logical DEX buffers, moving the inflated bytes instead of copying them
969    /// when the entry is a plain (non-container) DEX. A container's logical
970    /// DEXes are copied one at a time, as the caller reaches them.
971    fn into_logical(self, entry_name: &str) -> Box<dyn Iterator<Item = (String, DexBytes)> + 'a> {
972        match self {
973            Loaded::Cached(e) => {
974                let logical: Vec<_> = e.logical().iter().map(|l| (l.name.clone(), Arc::clone(&l.data))).collect();
975                Box::new(logical.into_iter())
976            }
977            Loaded::Borrowed(c) => {
978                let offsets = dex041_logical_offsets(&c);
979                if offsets.len() <= 1 {
980                    return Box::new(std::iter::once((entry_name.to_string(), Arc::new(DexData::Owned(c.into_owned())))));
981                }
982                let name = entry_name.to_string();
983                Box::new(offsets.into_iter().enumerate().map(move |(i, off)| {
984                    let data = asc_dex::container::normalize_dex041_logical(&c, off).into_owned();
985                    (format!("{name}!classes{}.dex", i + 1), Arc::new(DexData::Owned(data)))
986                }))
987            }
988        }
989    }
990}
991
992/// Engine over one APK.
993pub struct Engine {
994    apk_path: PathBuf,
995    threads: Threads,
996    debug: bool,
997    logger: Logger,
998    cache: Option<Arc<EngineCache>>,
999    /// `cache` belongs to a [`SharedCache`], which closes it instead of us.
1000    shared_cache: bool,
1001    stats: Arc<Stats>,
1002    /// `(size, mtime, key)` of the APK the key was last computed for.
1003    key_cache: Mutex<Option<(u64, Option<SystemTime>, ApkKey)>>,
1004    /// The APK last opened, handed out again while its files are unchanged
1005    /// (a program of many jars costs a mapping and a directory parse each).
1006    opened: Mutex<Option<Apk>>,
1007    limits: Limits,
1008    /// Parsed `resources.arsc` for the APK version it was read from.
1009    resources: Mutex<Option<resources::CachedTable>>,
1010    /// Background table builds ([`Engine::prebuild`]) and the searches they
1011    /// give way to.
1012    prebuild: prebuild::Prebuild,
1013    /// The class hierarchy, for the APK version it was built from.
1014    class_graph: Mutex<Option<hierarchy::CachedGraph>>,
1015}
1016
1017/// An APK opened for a search whose DEX entries are scheduled by the caller,
1018/// e.g. across many APKs at once ([`Engine::search_target`]).
1019pub struct SearchTarget {
1020    apk: Apk,
1021    key: Option<ApkKey>,
1022    /// Root DEX entries, smallest compressed first.
1023    pub entries: Vec<Entry>,
1024}
1025
1026#[derive(Default)]
1027struct Stats {
1028    memory_hits: AtomicUsize,
1029    disk_hits: AtomicUsize,
1030    derived_hits: AtomicUsize,
1031    inflates: AtomicUsize,
1032    skipped: AtomicUsize,
1033}
1034
1035/// Worker threads of a search.
1036#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1037pub enum Threads {
1038    /// [`auto_threads`], read again by every search.
1039    #[default]
1040    Auto,
1041    Fixed(usize),
1042}
1043
1044/// How often a worker [`Threads::wait_turn`] holds back looks again.
1045const TURN_POLL: Duration = Duration::from_millis(10);
1046
1047impl Threads {
1048    /// The thread count for a search starting now (at least 1).
1049    pub fn count(self) -> usize {
1050        match self {
1051            Threads::Auto => auto_threads(),
1052            Threads::Fixed(n) => n.max(1),
1053        }
1054    }
1055
1056    /// The most workers [`Threads::wait_turn`] ever lets run at once.
1057    pub fn most(self) -> usize {
1058        match self {
1059            Threads::Auto => tiers::cpus(),
1060            Threads::Fixed(n) => n.max(1),
1061        }
1062    }
1063
1064    /// For a long search that starts [`Threads::most`] workers: hold worker
1065    /// `k` back (between units of work) while [`auto_threads`] allows fewer
1066    /// than `k + 1`, or until `stop()`. The count shrinks as other processes
1067    /// start scans and grows back as they finish, which a count read once at
1068    /// the start cannot follow: every process of a burst would see the others
1069    /// idle and take every CPU.
1070    pub fn wait_turn(self, k: usize, stop: impl Fn() -> bool) {
1071        if self == Threads::Auto {
1072            while k >= auto_threads() && !stop() {
1073                std::thread::sleep(TURN_POLL);
1074            }
1075        }
1076    }
1077}
1078
1079impl From<usize> for Threads {
1080    fn from(n: usize) -> Threads {
1081        Threads::Fixed(n)
1082    }
1083}
1084
1085/// `None` (no `--threads`) is [`Threads::Auto`].
1086impl From<Option<usize>> for Threads {
1087    fn from(n: Option<usize>) -> Threads {
1088        n.map_or(Threads::Auto, Threads::Fixed)
1089    }
1090}
1091
1092impl Engine {
1093    /// An engine over `apk_path`: an APK, a jar, a bare DEX file, or a
1094    /// program of several read as one (a directory, or paths joined with
1095    /// `,`; see [`Apk::open`]).
1096    pub fn new(apk_path: impl AsRef<Path>) -> Engine {
1097        Engine {
1098            apk_path: apk_path.as_ref().to_path_buf(),
1099            threads: Threads::Auto,
1100            debug: false,
1101            logger: Arc::new(|m| println!("{m}")),
1102            cache: None,
1103            shared_cache: false,
1104            stats: Arc::new(Stats::default()),
1105            key_cache: Mutex::new(None),
1106            opened: Mutex::new(None),
1107            limits: Limits::default(),
1108            resources: Mutex::new(None),
1109            prebuild: prebuild::Prebuild::default(),
1110            class_graph: Mutex::new(None),
1111        }
1112    }
1113
1114    /// Refuse entries that inflate past `limits` (zip bombs).
1115    pub fn with_limits(mut self, limits: Limits) -> Engine {
1116        self.limits = limits;
1117        self
1118    }
1119
1120    pub fn with_threads(mut self, threads: impl Into<Threads>) -> Engine {
1121        self.threads = threads.into();
1122        self
1123    }
1124
1125    pub fn with_debug(mut self, debug: bool) -> Engine {
1126        self.debug = debug;
1127        self
1128    }
1129
1130    /// Route debug output somewhere other than stdout.
1131    pub fn with_logger(mut self, logger: impl Fn(&str) + Send + Sync + 'static) -> Engine {
1132        self.logger = Arc::new(logger);
1133        self
1134    }
1135
1136    /// Enable caching of inflated entries. A disk cache whose directory holds
1137    /// foreign files is dropped with a warning through the logger (set it
1138    /// first), instead of every write failing silently in the background.
1139    pub fn with_cache(mut self, mut config: CacheConfig) -> Engine {
1140        if let Some(Err(e)) = config.disk.as_ref().map(DiskCache::check_usable) {
1141            (self.logger)(&format!("Warning: {e}; disk cache disabled"));
1142            config.disk = None;
1143        }
1144        if config.memory_bytes > 0 {
1145            tune_allocator();
1146        }
1147        if config.memory_bytes == 0 && config.disk.is_none() {
1148            self.cache = None;
1149        } else {
1150            let cache = EngineCache {
1151                memory: (config.memory_bytes > 0).then(|| Arc::new(Mutex::new(MemoryCache::new(config.memory_bytes)))),
1152                budget: (config.memory_bytes > 0).then(|| tiers::MemoryBudget::new(config.memory_auto)),
1153                disk: config.disk.map(Arc::new),
1154                writer: Mutex::new(None),
1155                closed: AtomicBool::new(false),
1156                inflight: Mutex::new(HashSet::new()),
1157                inflight_done: Condvar::new(),
1158                derived_pending: Arc::new(AtomicUsize::new(0)),
1159                pending_bytes: Arc::new(AtomicUsize::new(0)),
1160                queue_cap: config.write_queue_bytes,
1161                writers: config.writers,
1162            };
1163            // Registers this process and takes its share right away.
1164            cache.charged();
1165            self.cache = Some(Arc::new(cache));
1166        }
1167        self
1168    }
1169
1170    /// Use `cache` (shared with other engines) instead of a private one.
1171    pub fn with_shared_cache(mut self, cache: &SharedCache) -> Engine {
1172        self.cache = cache.0.clone();
1173        self.shared_cache = true;
1174        self
1175    }
1176
1177    pub fn apk_path(&self) -> &Path {
1178        &self.apk_path
1179    }
1180
1181    /// The thread count for a search starting now.
1182    pub fn threads(&self) -> usize {
1183        self.threads.count()
1184    }
1185
1186    pub fn cache_stats(&self) -> CacheStats {
1187        CacheStats {
1188            memory_hits: self.stats.memory_hits.load(Ordering::Relaxed) as u64,
1189            disk_hits: self.stats.disk_hits.load(Ordering::Relaxed) as u64,
1190            derived_hits: self.stats.derived_hits.load(Ordering::Relaxed) as u64,
1191            inflates: self.stats.inflates.load(Ordering::Relaxed) as u64,
1192            skipped: self.stats.skipped.load(Ordering::Relaxed) as u64,
1193        }
1194    }
1195
1196    /// Heap this engine holds outside the memory cache, roughly: the class
1197    /// graph and the parsed resource table it keeps for later calls. A host
1198    /// keeping many engines counts it against its memory budget
1199    /// ([`SharedCache::set_reserved`]).
1200    pub fn resident_bytes(&self) -> usize {
1201        let graph = self.class_graph.lock().unwrap().as_ref().map_or(0, |(_, g)| g.heap_bytes());
1202        let table = self.resources.lock().unwrap().as_ref().map_or(0, resources::CachedTable::heap_bytes);
1203        graph + table
1204    }
1205
1206    /// Bytes currently charged to the in-memory cache: entry bytes plus the
1207    /// locator tables built for them.
1208    pub fn memory_cache_bytes(&self) -> usize {
1209        self.cache.as_ref().and_then(|c| c.memory.as_ref()).map(|m| m.lock().unwrap().used_bytes()).unwrap_or(0)
1210    }
1211
1212    /// Drop this APK's entries from the in-memory cache (shared or not), and
1213    /// its parsed resource table, and return the DEX bytes released; queries
1214    /// still holding them keep them alive.
1215    pub fn release_memory_cache(&self) -> usize {
1216        self.release_resources();
1217        self.class_graph.lock().unwrap().take();
1218        let Some(memory) = self.cache.as_ref().and_then(|c| c.memory.as_ref()) else { return 0 };
1219        let key = self.key_cache.lock().unwrap().as_ref().map(|(_, _, key)| key.clone());
1220        let Some(key) = key.or_else(|| self.open().ok().and_then(|apk| self.apk_key(&apk))) else { return 0 };
1221        memory.lock().unwrap().remove_apk(&key)
1222    }
1223
1224    /// Entry bytes of the in-memory cache that are mapped from the disk cache
1225    /// (file-backed pages the OS can reclaim) rather than held on the heap.
1226    pub fn memory_cache_mapped_bytes(&self) -> usize {
1227        let Some(m) = self.cache.as_ref().and_then(|c| c.memory.as_ref()) else { return 0 };
1228        m.lock().unwrap().values().filter(|e| e.is_mapped()).map(|e| e.data.len()).sum()
1229    }
1230
1231    /// Bytes of the in-memory cache's locator tables that are viewed in L2
1232    /// files (file-backed pages shared with other processes) rather than
1233    /// held on the heap.
1234    pub fn memory_cache_mapped_table_bytes(&self) -> usize {
1235        let Some(m) = self.cache.as_ref().and_then(|c| c.memory.as_ref()) else { return 0 };
1236        let m = m.lock().unwrap();
1237        m.values().filter_map(|e| e.logical.get()).flatten().map(|l| l.state.mapped_bytes()).sum()
1238    }
1239
1240    /// Block until every queued disk-cache write has been renamed into place.
1241    pub fn flush_cache_writes(&self) {
1242        if let Some(c) = &self.cache {
1243            c.flush(false);
1244        }
1245    }
1246
1247    /// [`Engine::flush_cache_writes`] for process exit: also stops queueing,
1248    /// so a worker still holding the engine cannot start a writer thread that
1249    /// exiting would kill mid-write. Run on drop.
1250    pub fn close_cache_writes(&self) {
1251        if let Some(c) = &self.cache {
1252            c.flush(true);
1253        }
1254    }
1255
1256    fn log(&self, msg: impl AsRef<str>) {
1257        if self.debug {
1258            (self.logger)(msg.as_ref());
1259        }
1260    }
1261
1262    /// Open the APK, or reuse the one opened last while [`Apk::version_of`]
1263    /// finds the same files, sizes and times.
1264    pub fn open(&self) -> Result<Apk> {
1265        let mut opened = self.opened.lock().unwrap();
1266        // Nothing to compare with on the first open: opening reads the
1267        // version anyway, so do not walk a program's directories twice.
1268        if let Some(apk) = opened.as_ref()
1269            && *apk.version() == Apk::version_of(&self.apk_path)?
1270        {
1271            return Ok(apk.clone().with_limits(self.limits));
1272        }
1273        let apk = Apk::open(&self.apk_path)?.with_limits(self.limits);
1274        *opened = Some(apk.clone());
1275        Ok(apk)
1276    }
1277
1278    /// `apk_path` (what [`Engine::new`] takes) with relative paths resolved
1279    /// against `base`, each path of a program on its own.
1280    pub fn resolve_path(apk_path: &Path, base: &Path) -> PathBuf {
1281        Apk::resolve(apk_path, base)
1282    }
1283
1284    /// Whether `apk_path` opens: a readable file, or a program with code.
1285    pub fn can_open(apk_path: &Path) -> bool {
1286        Apk::open(apk_path).is_ok()
1287    }
1288
1289    /// One line per archive of a program left out of its code (no root DEX,
1290    /// e.g. a jar deodexed into `oat/`, or not a zip); none for one file.
1291    pub fn program_notes(&self) -> Vec<String> {
1292        if self.apk_path.is_file() {
1293            return Vec::new();
1294        }
1295        self.open().map(|apk| apk.notes().to_vec()).unwrap_or_default()
1296    }
1297
1298    /// Open the APK for [`Engine::search_target`].
1299    pub fn search_target(&self) -> Result<SearchTarget> {
1300        let apk = self.open()?;
1301        let entries = apk.dex_entries()?;
1302        let key = self.apk_key(&apk);
1303        Ok(SearchTarget { apk, key, entries })
1304    }
1305
1306    /// Search one entry of `target` (what [`Engine::find_refs_with`] runs per
1307    /// entry), through this engine's caches.
1308    pub fn search_target_entry(
1309        &self,
1310        target: &SearchTarget,
1311        entry: &Entry,
1312        query: &CompiledQuery,
1313        aggregate: bool,
1314        cancel: &AtomicBool,
1315    ) -> RefBatch {
1316        search_entry(
1317            &target.apk,
1318            target.key.as_ref(),
1319            entry,
1320            query,
1321            FindOptions { aggregate, context: RefContext::None },
1322            Shape::Results,
1323            None,
1324            cancel,
1325            self.cache.as_deref(),
1326            &self.stats,
1327        )
1328    }
1329
1330    /// Root-level DEX entries, smallest compressed first.
1331    pub fn dex_entries(&self) -> Result<Vec<Entry>> {
1332        Ok(self.open()?.dex_entries()?)
1333    }
1334
1335    /// Cache key for `apk` when caching is enabled. Hashing the central
1336    /// directory is done once per APK version; later calls only `stat`.
1337    pub fn apk_key(&self, apk: &Apk) -> Option<ApkKey> {
1338        self.cache.as_ref()?;
1339        let ident = apk.stamp().ok()?;
1340        let mut cached = self.key_cache.lock().unwrap();
1341        if let Some((len, mtime, key)) = cached.as_ref()
1342            && (*len, *mtime) == ident
1343        {
1344            return Some(key.clone());
1345        }
1346        let key = ApkKey::for_apk(apk).ok()?;
1347        *cached = Some((ident.0, ident.1, key.clone()));
1348        Some(key)
1349    }
1350
1351    /// Inflated bytes of `entry`, through the caches when enabled. Stored
1352    /// entries are copied only when an in-memory cache wants to keep them.
1353    pub fn entry_bytes(&self, apk: &Apk, key: Option<&ApkKey>, entry: &Entry) -> Result<DexBytes> {
1354        Ok(match load_entry(apk, key, entry, self.cache.as_deref(), &self.stats, None)?.0 {
1355            Loaded::Borrowed(c) => Arc::new(DexData::Owned(c.into_owned())),
1356            Loaded::Cached(e) => Arc::clone(&e.data),
1357        })
1358    }
1359
1360    /// Find the DEX that defines `descriptor` (`Lcom/poc/Main;`).
1361    ///
1362    /// DEX entries are inflated in parallel, smallest first. Entries that are
1363    /// not cached are first streamed only as far as their id tables and string
1364    /// data to answer the probe cheaply; only a hit is inflated fully.
1365    ///
1366    /// A class several DEX files define resolves as a class loader would:
1367    /// the first archive of a program, then `classes.dex`, `classes2.dex`,
1368    /// ... ([`load_ranks`]). Once a DEX defines it, DEX files after it are no
1369    /// longer read; workers still inflating one at that moment finish on
1370    /// their own in the background. A program reads every archive, to name
1371    /// the others that define the class too ([`ClassHit::also_defined_in`]).
1372    pub fn get_class_dex(&self, descriptor: &str) -> Result<Option<ClassHit>> {
1373        self.get_class_dex_cancellable(descriptor, &NOT_CANCELLED)
1374    }
1375
1376    /// [`Engine::get_class_dex`] that stops, with `ApkError::Cancelled`, once
1377    /// `cancel` is raised.
1378    pub fn get_class_dex_cancellable(&self, descriptor: &str, cancel: &AtomicBool) -> Result<Option<ClassHit>> {
1379        if cancel.load(Ordering::Relaxed) {
1380            return Err(cancelled());
1381        }
1382        let t0 = Instant::now();
1383        let apk = Arc::new(self.open()?);
1384        let entries = Arc::new(apk.dex_entries()?);
1385        let key = Arc::new(self.apk_key(&apk));
1386        self.log(format!("[APK] scan setup={} us entries={}", t0.elapsed().as_micros(), entries.len()));
1387        if entries.is_empty() {
1388            return Ok(None);
1389        }
1390        // DEX descriptors are MUTF-8: a supplementary character is a surrogate pair,
1391        // not the 4-byte UTF-8 sequence `as_bytes` would compare against.
1392        let target: Arc<Vec<u8>> = Arc::new(asc_dex::mutf8::encode(descriptor));
1393        let next = Arc::new(AtomicUsize::new(0));
1394        let stop = Arc::new(AtomicBool::new(false));
1395        // Rank of the best DEX defining the class so far: later ones need not be read.
1396        let best = Arc::new(AtomicUsize::new(usize::MAX));
1397        let ranks: Arc<Vec<usize>> = Arc::new(load_ranks(&entries));
1398        let exhaustive = apk.archive_count() > 1;
1399        let workers = self.threads().min(entries.len()).max(1);
1400        let (tx, rx) = mpsc::channel::<(usize, std::result::Result<Option<ClassHit>, EngineError>)>();
1401        let debug = self.debug;
1402        for w in 0..workers {
1403            let (apk, entries, key, target, next, stop, tx, logger, cache, stats, best, ranks) = (
1404                Arc::clone(&apk),
1405                Arc::clone(&entries),
1406                Arc::clone(&key),
1407                Arc::clone(&target),
1408                Arc::clone(&next),
1409                Arc::clone(&stop),
1410                tx.clone(),
1411                Arc::clone(&self.logger),
1412                self.cache.clone(),
1413                Arc::clone(&self.stats),
1414                Arc::clone(&best),
1415                Arc::clone(&ranks),
1416            );
1417            std::thread::spawn(move || {
1418                while !stop.load(Ordering::Relaxed) {
1419                    let i = next.fetch_add(1, Ordering::Relaxed);
1420                    let Some(entry) = entries.get(i) else { break };
1421                    let rank = ranks[i];
1422                    // A DEX the class loader would read after the best hit cannot win.
1423                    if !exhaustive && rank > best.load(Ordering::Relaxed) {
1424                        let _ = tx.send((i, Ok(None)));
1425                        continue;
1426                    }
1427                    let t0 = Instant::now();
1428                    // Cached bytes answer directly; otherwise probe a prefix so
1429                    // most entries never get fully inflated.
1430                    let cached = cache.as_deref().and_then(|c| lookup_cached(c, key.as_ref().as_ref(), entry, &stats));
1431                    let (loaded, source) = match cached {
1432                        Some((e, source)) => (Loaded::Cached(e), source),
1433                        None => {
1434                            match probe_entry(&apk, entry, &target) {
1435                                Ok(Some(false)) => {
1436                                    if debug {
1437                                        logger(&format!(
1438                                            "[APK] [T{w:04x}] '{}' prefix-probe={} us hit=false",
1439                                            entry.name,
1440                                            t0.elapsed().as_micros()
1441                                        ));
1442                                    }
1443                                    let _ = tx.send((i, Ok(None)));
1444                                    continue;
1445                                }
1446                                Ok(Some(true)) | Ok(None) => {}
1447                                Err(e) => {
1448                                    let _ = tx.send((i, Err(e)));
1449                                    continue;
1450                                }
1451                            }
1452                            match load_entry(&apk, key.as_ref().as_ref(), entry, cache.as_deref(), &stats, Some(&stop)) {
1453                                Ok(l) => l,
1454                                Err(EngineError::Apk(asc_apk::ApkError::Cancelled)) => break,
1455                                Err(e) => {
1456                                    let _ = tx.send((i, Err(e)));
1457                                    continue;
1458                                }
1459                            }
1460                        }
1461                    };
1462                    let t1 = Instant::now();
1463                    let mut found = None;
1464                    // Exactly one message per entry: the caller counts them to
1465                    // know when every entry has answered.
1466                    let mut entry_err: Option<EngineError> = None;
1467                    for (name, data) in loaded.into_logical(&entry.name) {
1468                        if stop.load(Ordering::Relaxed) {
1469                            return;
1470                        }
1471                        match asc_dex::quick::dex_defines_class(&data, &target) {
1472                            Ok(true) => {
1473                                found = Some(ClassHit { dex_name: name, dex: data, also_defined_in: Vec::new() });
1474                                break;
1475                            }
1476                            Ok(false) => {}
1477                            Err(e) => {
1478                                entry_err.get_or_insert(e.into());
1479                            }
1480                        }
1481                    }
1482                    let is_hit = found.is_some();
1483                    if debug {
1484                        logger(&format!(
1485                            "[APK] [T{w:04x}] '{}' {}={} us lookup={} us hit={}",
1486                            entry.name,
1487                            source.as_str(),
1488                            (t1 - t0).as_micros(),
1489                            t1.elapsed().as_micros(),
1490                            is_hit
1491                        ));
1492                    }
1493                    if let Some(hit) = found {
1494                        best.fetch_min(rank, Ordering::Relaxed);
1495                        let _ = tx.send((i, Ok(Some(hit))));
1496                        continue;
1497                    }
1498                    let _ = tx.send((i, entry_err.map_or(Ok(None), Err)));
1499                }
1500            });
1501        }
1502        drop(tx);
1503        let mut hits: Vec<(usize, ClassHit)> = Vec::new();
1504        let mut last_err = None;
1505        let mut answered = vec![false; entries.len()];
1506        let mut seen = 0usize;
1507        loop {
1508            let (i, msg) = match rx.recv_timeout(CANCEL_POLL) {
1509                Ok(msg) => msg,
1510                Err(mpsc::RecvTimeoutError::Timeout) if cancel.load(Ordering::Relaxed) => {
1511                    stop.store(true, Ordering::Relaxed);
1512                    return Err(cancelled());
1513                }
1514                Err(mpsc::RecvTimeoutError::Timeout) => continue,
1515                Err(mpsc::RecvTimeoutError::Disconnected) => break,
1516            };
1517            seen += 1;
1518            answered[i] = true;
1519            match msg {
1520                Ok(Some(h)) => hits.push((ranks[i], h)),
1521                Ok(None) => {}
1522                Err(e) => last_err = Some(e),
1523            }
1524            if seen == entries.len() {
1525                break;
1526            }
1527            // The best hit wins once every DEX the class loader reads before it has answered.
1528            let best = hits.iter().map(|(rank, _)| *rank).min();
1529            if let Some(best) = best.filter(|_| !exhaustive)
1530                && ranks.iter().zip(&answered).all(|(&rank, &done)| done || rank >= best)
1531            {
1532                break;
1533            }
1534        }
1535        stop.store(true, Ordering::Relaxed);
1536        self.log(format!("[APK] getclass total={} us", t0.elapsed().as_micros()));
1537        hits.sort_by_key(|(rank, _)| *rank);
1538        let mut hits = hits.into_iter().map(|(_, h)| h);
1539        match (hits.next(), last_err) {
1540            (Some(mut h), _) => {
1541                let archive = archive_of(&h.dex_name).map(str::to_string);
1542                h.also_defined_in = hits.map(|o| o.dex_name).filter(|n| archive_of(n).map(str::to_string) != archive).collect();
1543                Ok(Some(h))
1544            }
1545            (None, Some(e)) => Err(e),
1546            (None, None) => Ok(None),
1547        }
1548    }
1549
1550    /// Locate, rebuild and disassemble a class.
1551    pub fn get_class(&self, descriptor: &str) -> Result<ClassSource> {
1552        self.get_class_with(descriptor, SmaliOptions::default())
1553    }
1554
1555    pub fn get_class_with(&self, descriptor: &str, opts: SmaliOptions) -> Result<ClassSource> {
1556        self.get_class_method(descriptor, opts, None)
1557    }
1558
1559    /// [`Engine::get_class_with`] that renders only the method whose
1560    /// `name(params)ret` is `method` (no fields) when one is given. A malformed
1561    /// signature is rejected before the APK is read; a class without such a
1562    /// method is an error. The rebuilt DEX still holds the whole class.
1563    pub fn get_class_method(&self, descriptor: &str, opts: SmaliOptions, method: Option<&str>) -> Result<ClassSource> {
1564        self.get_class_method_cancellable(descriptor, opts, method, &NOT_CANCELLED)
1565    }
1566
1567    /// [`Engine::get_class_method`] that stops looking for the class, with
1568    /// `ApkError::Cancelled`, once `cancel` is raised.
1569    pub fn get_class_method_cancellable(
1570        &self,
1571        descriptor: &str,
1572        opts: SmaliOptions,
1573        method: Option<&str>,
1574        cancel: &AtomicBool,
1575    ) -> Result<ClassSource> {
1576        if let Some(m) = method {
1577            asc_smali::normalize_method_signature(m)?;
1578        }
1579        let t0 = Instant::now();
1580        let hit = self
1581            .get_class_dex_cancellable(descriptor, cancel)?
1582            .ok_or_else(|| EngineError::ClassNotFound { class: descriptor.to_string(), hint: self.class_not_found_hint(descriptor) })?;
1583        let scan_us = t0.elapsed().as_micros();
1584        let (rebuilt, source, warning) = match decompile_dex_class_method(&hit.dex, descriptor, opts, method) {
1585            Ok((r, s)) => (Some(r), s, None),
1586            // The class is there but cannot be rebuilt faithfully (undefined
1587            // opcode, invoke-custom, too many members for 16-bit indexes...):
1588            // still show it, disassembled from the original DEX, and say why
1589            // there is no DEX to dump.
1590            Err(EngineError::Rebuild(e)) if e.is_input_limitation() => {
1591                let dex = Dex::with_name(&hit.dex, &hit.dex_name)?;
1592                let source = asc_smali::disassemble_class_method(&dex, descriptor, opts, method)?;
1593                let warning = format!("class not rebuilt ({e}); disassembled from the original DEX instead");
1594                self.log(format!("[DEBUG] {warning}"));
1595                (None, source, Some(warning))
1596            }
1597            Err(e) => return Err(e),
1598        };
1599        if self.debug
1600            && let Some(r) = &rebuilt
1601        {
1602            let t = r.timings;
1603            self.log(format!(
1604                    "[DEBUG] Hollower Time: {} us\n[DEBUG] Collect Time: {} us\n[DEBUG] Remapper Time: {} us\n[DEBUG] Builder Time: {} us\n[DEBUG] Total Extraction Time: {} us",
1605                    t.hollow_us,
1606                    t.collect_us,
1607                    t.map_us,
1608                    t.build_us,
1609                    t.total_us()
1610                ));
1611        }
1612        Ok(ClassSource { dex_name: hit.dex_name, source, rebuilt, warning, scan_us, also_defined_in: hit.also_defined_in })
1613    }
1614
1615    /// Search every DEX entry for references. `sink` receives one batch per
1616    /// entry in completion order. Returns a summary once all entries finish.
1617    pub fn find_refs(&self, query: &Query, mut sink: impl FnMut(RefBatch)) -> Result<RefSummary> {
1618        self.find_refs_with(query, true, &AtomicBool::new(false), &mut sink)
1619    }
1620
1621    /// `find_refs` with control over aggregation and cancellation. An invalid
1622    /// pattern is reported once, up front, instead of once per DEX.
1623    pub fn find_refs_with(
1624        &self,
1625        query: &Query,
1626        aggregate: bool,
1627        cancel: &AtomicBool,
1628        sink: &mut dyn FnMut(RefBatch),
1629    ) -> Result<RefSummary> {
1630        self.find_refs_opts(query, FindOptions { aggregate, context: RefContext::None }, cancel, sink)
1631    }
1632
1633    /// `find_refs` with every option: aggregation, context, cancellation.
1634    pub fn find_refs_opts(
1635        &self,
1636        query: &Query,
1637        opts: FindOptions,
1638        cancel: &AtomicBool,
1639        sink: &mut dyn FnMut(RefBatch),
1640    ) -> Result<RefSummary> {
1641        self.find_refs_shaped(query, opts, Shape::Results, cancel, sink)
1642    }
1643
1644    /// The rows of [`Engine::find_refs`] (one per method, no context) as
1645    /// text: each batch holds them in [`RefBatch::text`], exactly the
1646    /// [`RefResult::line`]s `find_refs` would give, with no structured rows.
1647    /// A broad query renders and replays much faster this way.
1648    pub fn find_refs_text(&self, query: &Query, cancel: &AtomicBool, sink: &mut dyn FnMut(RefBatch)) -> Result<RefSummary> {
1649        self.find_refs_shaped(query, FindOptions::default(), Shape::Text, cancel, sink)
1650    }
1651
1652    fn find_refs_shaped(
1653        &self,
1654        query: &Query,
1655        opts: FindOptions,
1656        shape: Shape,
1657        cancel: &AtomicBool,
1658        sink: &mut dyn FnMut(RefBatch),
1659    ) -> Result<RefSummary> {
1660        let t0 = Instant::now();
1661        compose::check_patterns(query)?;
1662        if let Some(engine) = self.transient(compose::needs_program(query))? {
1663            return engine.find_refs_shaped(query, opts, shape, cancel, sink);
1664        }
1665        let apk = self.open()?;
1666        let mut entries = apk.dex_entries()?;
1667        // Every entry has to be searched, so run the largest first: the biggest
1668        // inflate is the critical path and must not wait behind the small ones.
1669        entries.reverse();
1670        let key = self.apk_key(&apk);
1671        let mut summary = RefSummary { entries: entries.len(), ..Default::default() };
1672        if entries.is_empty() {
1673            return Ok(summary);
1674        }
1675        // L3: the rows of an earlier run of this query on this APK.
1676        let disk = self.cache.as_ref().and_then(|c| c.disk.as_ref()).filter(|d| d.uses(Tier::Rendered));
1677        let rendered = disk.zip(key.as_ref()).map(|(d, k)| (d, k, tiers::findrefs_key(query, opts, shape)));
1678        if let Some(recorded) = rendered.as_ref().and_then(|(d, k, q)| d.get_rendered_view(k, q)).and_then(tiers::decode_batches) {
1679            for batch in recorded.batches {
1680                summary.total_results += batch.row_count();
1681                sink(batch);
1682            }
1683            summary.notes = recorded.notes;
1684            summary.total_us = t0.elapsed().as_micros();
1685            self.log(format!("[APK] for_each_findrefs total={} us count={} source=result", summary.total_us, entries.len()));
1686            return Ok(summary);
1687        }
1688        // `method:{RULE}`: the methods RULE matches first, then the calls of
1689        // them. L3 keys on the query as given, so a replay skips this too.
1690        let resolved = self.resolve(std::slice::from_ref(query), cancel)?;
1691        summary.notes = resolved.notes.into_iter().next().unwrap_or_default();
1692        let _query = self.prebuild.query();
1693        let compiled = CompiledQuery::new(resolved.queries.as_ref().map_or(query, |r| &r[0]))?;
1694        let mut recorder = rendered.as_ref().map(|(d, _, _)| tiers::ResultRecorder::new(tiers::result_limit(d, shape)));
1695        let next = AtomicUsize::new(0);
1696        let (tx, rx) = mpsc::channel::<RefBatch>();
1697        let workers = self.threads().min(entries.len()).max(1);
1698        let cache = self.cache.as_deref();
1699        let stats = &self.stats;
1700        let probe = skip::Probe::new(vec![&compiled]);
1701        let (compiled, probe) = (&compiled, &probe);
1702        std::thread::scope(|s| {
1703            for _ in 0..workers {
1704                let tx = tx.clone();
1705                let (apk, entries, next, key) = (&apk, &entries, &next, key.as_ref());
1706                s.spawn(move || {
1707                    loop {
1708                        if cancel.load(Ordering::Relaxed) {
1709                            break;
1710                        }
1711                        let i = next.fetch_add(1, Ordering::Relaxed);
1712                        let Some(entry) = entries.get(i) else { break };
1713                        let batch = search_entry(apk, key, entry, compiled, opts, shape, Some(probe), cancel, cache, stats);
1714                        if tx.send(batch).is_err() {
1715                            break;
1716                        }
1717                    }
1718                });
1719            }
1720            drop(tx);
1721            for batch in rx {
1722                if self.debug {
1723                    self.log(format!(
1724                        "[APK] '{}' {}={} us process={} us results={}{}",
1725                        batch.entry_name,
1726                        batch.source,
1727                        batch.inflate_us,
1728                        batch.search_us,
1729                        batch.row_count(),
1730                        batch.error.as_ref().map(|e| format!(" error={e}")).unwrap_or_default()
1731                    ));
1732                }
1733                summary.total_results += batch.row_count();
1734                if let Some(r) = recorder.as_mut() {
1735                    r.record(&batch);
1736                }
1737                sink(batch);
1738            }
1739        });
1740        if let (Some((_, k, q)), Some(payload)) = (rendered, recorder.and_then(|r| r.finish(&summary.notes))) {
1741            // A cancelled run may be missing entries.
1742            if !cancel.load(Ordering::Relaxed) {
1743                let job = WriteJob { key: k.clone(), apk: Arc::from(apk.path()), kind: Job::Rendered { query: q, payload } };
1744                self.cache.as_ref().expect("rendered implies a cache").put_async(job);
1745            }
1746        }
1747        summary.total_us = t0.elapsed().as_micros();
1748        self.log(format!("[APK] for_each_findrefs total={} us count={} workers={}", summary.total_us, entries.len(), workers));
1749        Ok(summary)
1750    }
1751
1752    /// Fill the context of rows an earlier [`Engine::find_refs`] returned
1753    /// (typically one page of them): only their DEX files are loaded, from
1754    /// the cache when it holds them. Returns the entries that failed to load.
1755    pub fn fill_context(&self, rows: &mut [RefResult], ctx: RefContext) -> Result<Vec<String>> {
1756        if ctx == RefContext::None || rows.is_empty() {
1757            return Ok(Vec::new());
1758        }
1759        let _query = self.prebuild.query();
1760        let wanted: HashSet<String> = rows.iter().map(|r| entry_of(&r.dex_name).to_string()).collect();
1761        let (views, errors) = self.load_views(Some(&wanted), &NOT_CANCELLED)?;
1762        let mut errors = errors;
1763        for v in &views {
1764            let r = Dex::with_name(&v.data, &v.name).map_err(EngineError::from).and_then(|dex| {
1765                attach_context(&FindRefs::with_state(&dex, &v.state), rows.iter_mut().filter(|r| r.dex_name == v.name), ctx)
1766            });
1767            if let Err(e) = r {
1768                errors.push(format!("{}: {e}", v.name));
1769            }
1770        }
1771        self.recharge_views(&views);
1772        Ok(errors)
1773    }
1774
1775    /// Load DEX entries (every one, or those named in `only`) in parallel,
1776    /// as logical DEX files in entry order with their locator tables (the
1777    /// cached ones when the memory cache holds the entry). Entries that fail
1778    /// to load are reported as `name: error` strings.
1779    /// Stops with `ApkError::Cancelled` once `cancel` is raised.
1780    fn load_views(&self, only: Option<&HashSet<String>>, cancel: &AtomicBool) -> Result<(Vec<DexView>, Vec<String>)> {
1781        let apk = self.open()?;
1782        let mut entries = apk.dex_entries()?;
1783        if let Some(only) = only {
1784            entries.retain(|e| only.contains(&e.name));
1785        }
1786        let key = self.apk_key(&apk);
1787        let next = AtomicUsize::new(0);
1788        let workers = self.threads().min(entries.len()).max(1);
1789        let cache = self.cache.as_deref();
1790        let l2 = cache.and_then(|c| c.disk.as_deref()).filter(|d| d.uses(Tier::Derived)).zip(key.as_ref());
1791        let mut slots: Vec<Option<std::result::Result<Vec<DexView>, String>>> = (0..entries.len()).map(|_| None).collect();
1792        let done = Mutex::new(&mut slots);
1793        std::thread::scope(|s| {
1794            for _ in 0..workers {
1795                s.spawn(|| {
1796                    loop {
1797                        if cancel.load(Ordering::Relaxed) {
1798                            break;
1799                        }
1800                        let i = next.fetch_add(1, Ordering::Relaxed);
1801                        let Some(entry) = entries.get(i) else { break };
1802                        let r = load_entry(&apk, key.as_ref(), entry, cache, &self.stats, Some(cancel))
1803                            .map(|(loaded, _)| match loaded {
1804                                Loaded::Cached(e) => e
1805                                    .logical()
1806                                    .iter()
1807                                    .enumerate()
1808                                    .map(|(idx, l)| {
1809                                        if let (Some((disk, k)), Ok(dex)) = (l2, Dex::with_name(&l.data, &l.name))
1810                                            && tiers::load_derived(disk, k, l, &dex)
1811                                        {
1812                                            self.stats.derived_hits.fetch_add(1, Ordering::Relaxed);
1813                                        }
1814                                        DexView {
1815                                            name: l.name.clone(),
1816                                            data: Arc::clone(&l.data),
1817                                            state: Arc::clone(&l.state),
1818                                            entry: Some(Arc::clone(&e)),
1819                                            idx,
1820                                            mask: l.state.table_mask(),
1821                                            loaded: Instant::now(),
1822                                        }
1823                                    })
1824                                    .collect(),
1825                                borrowed => borrowed
1826                                    .into_logical(&entry.name)
1827                                    .map(|(name, data)| DexView {
1828                                        name,
1829                                        data,
1830                                        state: Arc::new(SearchState::new()),
1831                                        entry: None,
1832                                        idx: 0,
1833                                        mask: 0,
1834                                        loaded: Instant::now(),
1835                                    })
1836                                    .collect(),
1837                            })
1838                            .map_err(|e| format!("{}: {e}", entry.name));
1839                        done.lock().unwrap()[i] = Some(r);
1840                    }
1841                });
1842            }
1843        });
1844        if cancel.load(Ordering::Relaxed) {
1845            return Err(cancelled());
1846        }
1847        let mut views = Vec::new();
1848        let mut errors = Vec::new();
1849        for slot in slots.into_iter().flatten() {
1850            match slot {
1851                Ok(mut v) => views.append(&mut v),
1852                Err(e) => errors.push(e),
1853            }
1854        }
1855        Ok((views, errors))
1856    }
1857
1858    /// Charge the memory cache for the tables a pass over `views` built,
1859    /// and queue the new tables for L2.
1860    fn recharge_views(&self, views: &[DexView]) {
1861        let Some(cache) = self.cache.as_deref() else { return };
1862        let Some(key) = self.key_cache.lock().unwrap().as_ref().map(|k| k.2.clone()) else { return };
1863        let disk = cache.disk.as_deref().filter(|d| d.uses(Tier::Derived));
1864        let mut seen = HashSet::new();
1865        for v in views {
1866            let Some(e) = &v.entry else { continue };
1867            if let Some(job) = disk.and_then(|d| tiers::derived_job(d, e, v.idx, v.mask, v.loaded.elapsed())) {
1868                cache.put_async(WriteJob { key: key.clone(), apk: Arc::from(self.apk_path.as_path()), kind: Job::Derived(job) });
1869            }
1870            if seen.insert(Arc::as_ptr(e)) {
1871                recharge(Some(cache), Some(&key), e);
1872            }
1873        }
1874    }
1875
1876    /// Decode `AndroidManifest.xml`.
1877    pub fn manifest_xml(&self) -> Result<String> {
1878        let apk = self.open()?;
1879        let data = apk.read("AndroidManifest.xml")?;
1880        Ok(asc_axml::decode(&data)?)
1881    }
1882}
1883
1884impl Drop for Engine {
1885    fn drop(&mut self) {
1886        self.prebuild.cancel();
1887        // A shared cache outlives this engine; its `SharedCache` owner closes it.
1888        if !self.shared_cache {
1889            self.close_cache_writes();
1890        }
1891    }
1892}
1893
1894/// One logical DEX loaded for a pass over the whole APK.
1895struct DexView {
1896    name: String,
1897    data: DexBytes,
1898    state: Arc<SearchState>,
1899    /// The memory cache entry holding it, recharged after the pass.
1900    entry: Option<Arc<CachedEntry>>,
1901    /// Its index among the entry's logical DEX files.
1902    idx: usize,
1903    /// Its tables (`SearchState::table_mask`) when loaded, and when that was:
1904    /// tables built during the pass are queued for L2 with the pass's time.
1905    mask: u32,
1906    loaded: Instant,
1907}
1908
1909/// The APK entry a (logical) DEX name belongs to: `classes2.dex!classes1.dex`
1910/// is the first logical DEX of the `classes2.dex` container.
1911fn entry_of(dex_name: &str) -> &str {
1912    dex_name.split('!').next().unwrap_or(dex_name)
1913}
1914
1915/// Memory or disk cache lookup (no inflation).
1916/// Each entry's place in the order a class loader reads DEX files: archive
1917/// by archive in program order, then `classes.dex`, `classes2.dex`, ... by
1918/// number, other names last.
1919fn load_ranks(entries: &[Entry]) -> Vec<usize> {
1920    let key = |e: &Entry| {
1921        let inner = e.name.rsplit('/').next().unwrap_or(&e.name);
1922        let number = match inner.strip_prefix("classes").and_then(|rest| rest.strip_suffix(".dex")) {
1923            Some("") => 1,
1924            Some(n) if !n.starts_with('0') => n.parse().unwrap_or(u64::MAX),
1925            _ => u64::MAX,
1926        };
1927        (e.archive, number, inner.to_string())
1928    };
1929    let mut order: Vec<usize> = (0..entries.len()).collect();
1930    order.sort_by_cached_key(|&i| key(&entries[i]));
1931    let mut ranks = vec![0; entries.len()];
1932    for (rank, i) in order.into_iter().enumerate() {
1933        ranks[i] = rank;
1934    }
1935    ranks
1936}
1937
1938fn lookup_cached(cache: &EngineCache, key: Option<&ApkKey>, entry: &Entry, stats: &Stats) -> Option<(Arc<CachedEntry>, Source)> {
1939    let key = key?;
1940    if let Some(mem) = &cache.memory
1941        && let Some(e) = mem.lock().unwrap().get(key, &entry.name)
1942    {
1943        stats.memory_hits.fetch_add(1, Ordering::Relaxed);
1944        return Some((e, Source::Memory));
1945    }
1946    if entry.method == asc_apk::METHOD_DEFLATE
1947        && let Some(disk) = &cache.disk
1948        && let Some(data) = disk.get(key, &entry.name, entry.uncomp_size)
1949    {
1950        stats.disk_hits.fetch_add(1, Ordering::Relaxed);
1951        let e = Arc::new(CachedEntry::new(&entry.name, Arc::new(data)));
1952        if let Some(mem) = &cache.memory {
1953            // The declared size may be 0 (undeclared) and omits a
1954            // container's logical copies; charge what will be resident.
1955            let size = e.charged_bytes();
1956            mem.lock().unwrap().insert(key, &entry.name, Arc::clone(&e), size);
1957            cache.charged();
1958        }
1959        return Some((e, Source::Disk));
1960    }
1961    None
1962}
1963
1964/// Bytes of `entry`: cache → (stored: borrow) → inflate (+ fill caches).
1965fn load_entry<'a>(
1966    apk: &'a Apk,
1967    key: Option<&ApkKey>,
1968    entry: &Entry,
1969    cache: Option<&EngineCache>,
1970    stats: &Stats,
1971    cancel: Option<&AtomicBool>,
1972) -> Result<(Loaded<'a>, Source)> {
1973    if let (Some(c), Some(k)) = (cache, key) {
1974        if let Some((e, source)) = lookup_cached(c, Some(k), entry, stats) {
1975            return Ok((Loaded::Cached(e), source));
1976        }
1977        // One inflate per entry at a time; whoever waited re-checks the cache.
1978        let _guard = c.begin_inflate(format!("{}/{}", k.as_str(), entry.name));
1979        if let Some((e, source)) = lookup_cached(c, Some(k), entry, stats) {
1980            return Ok((Loaded::Cached(e), source));
1981        }
1982        let bytes = apk.read_entry_cancellable(entry, cancel)?;
1983        let (data, keep_in_memory, source) = match bytes {
1984            // Stored entries are already free to read; keep them only for the
1985            // locator tables that ride along with a cached entry (in memory,
1986            // or through L2). Mapped from the APK file rather than copied, so
1987            // they stay file pages the OS can share and reclaim.
1988            Cow::Borrowed(b) => {
1989                if c.memory.is_none() && !c.disk.as_ref().is_some_and(|d| d.uses(Tier::Derived)) {
1990                    return Ok((Loaded::Borrowed(Cow::Borrowed(b)), Source::Stored));
1991                }
1992                let offset = b.as_ptr() as usize - apk.archive_data(entry).as_ptr() as usize;
1993                let data =
1994                    DexData::map_region(apk.archive_path(entry), offset as u64, b.len()).unwrap_or_else(|_| DexData::Owned(b.to_vec()));
1995                (data, c.memory.is_some(), Source::Stored)
1996            }
1997            Cow::Owned(v) => {
1998                stats.inflates.fetch_add(1, Ordering::Relaxed);
1999                (DexData::Owned(v), c.memory.is_some(), Source::Inflate)
2000            }
2001        };
2002        let e = Arc::new(CachedEntry::new(&entry.name, Arc::new(data)));
2003        let mut remap = None;
2004        if keep_in_memory && let Some(mem) = &c.memory {
2005            // Account for the container copies too, so the LRU cap holds.
2006            let size = e.charged_bytes();
2007            mem.lock().unwrap().insert(k, &entry.name, Arc::clone(&e), size);
2008            c.charged();
2009            remap = Some(Arc::downgrade(&e));
2010        }
2011        // Small entries are cheaper to inflate again than to write.
2012        if source == Source::Inflate && c.disk.as_ref().is_some_and(|d| d.admits_dex(e.data.len() as u64)) {
2013            let job = WriteJob {
2014                key: k.clone(),
2015                apk: Arc::from(apk.path()),
2016                kind: Job::Dex { entry: entry.clone(), limits: apk.limits(), data: Arc::clone(&e.data), remap },
2017            };
2018            c.put_async(job);
2019        }
2020        return Ok((Loaded::Cached(e), source));
2021    }
2022    let bytes = apk.read_entry_cancellable(entry, cancel)?;
2023    let source = if matches!(bytes, Cow::Owned(_)) {
2024        stats.inflates.fetch_add(1, Ordering::Relaxed);
2025        Source::Inflate
2026    } else {
2027        Source::Stored
2028    };
2029    Ok((Loaded::Borrowed(bytes), source))
2030}
2031
2032/// Decide from a streamed prefix whether `entry` defines the class.
2033/// `Ok(Some(x))` is a definitive answer; `Ok(None)` means "inflate it fully".
2034fn probe_entry(apk: &Apk, entry: &Entry, target: &[u8]) -> Result<Option<bool>> {
2035    /// Entries below this are cheaper to inflate whole.
2036    const MIN_GAIN: u64 = 256 * 1024;
2037    /// Room after the last string offset for the string's own bytes.
2038    const SLACK: usize = 64 * 1024;
2039    /// Only worth a streaming (slower) decoder below this share of the entry.
2040    const WORTH_BELOW: f64 = 0.70;
2041    if entry.method != asc_apk::METHOD_DEFLATE || entry.uncomp_size <= MIN_GAIN {
2042        return Ok(None);
2043    }
2044    let size = entry.uncomp_size as usize;
2045    let prefix = apk.inflate_until(entry, |out| {
2046        if out.len() >= 8 && &out[..8] == asc_dex::container::DEX041_MAGIC {
2047            return asc_apk::PrefixStep::Abort;
2048        }
2049        match asc_dex::quick::probe_needed_len(out) {
2050            Some(needed) if (needed as f64) < size as f64 * WORTH_BELOW => asc_apk::PrefixStep::Continue(needed + SLACK),
2051            Some(_) => asc_apk::PrefixStep::Abort,
2052            None => asc_apk::PrefixStep::Continue(0),
2053        }
2054    })?;
2055    let bytes = match prefix {
2056        asc_apk::Prefix::Complete(data) | asc_apk::Prefix::Partial(data) => data,
2057        asc_apk::Prefix::Aborted(_) => return Ok(None),
2058    };
2059    // A truncated string read means the answer is not provable from the prefix.
2060    Ok(asc_dex::quick::dex_defines_class(&bytes, target).ok())
2061}
2062
2063#[allow(clippy::too_many_arguments)]
2064fn search_entry(
2065    apk: &Apk,
2066    key: Option<&ApkKey>,
2067    entry: &Entry,
2068    query: &CompiledQuery,
2069    opts: FindOptions,
2070    shape: Shape,
2071    probe: Option<&skip::Probe<'_>>,
2072    cancel: &AtomicBool,
2073    cache: Option<&EngineCache>,
2074    stats: &Stats,
2075) -> RefBatch {
2076    let run = |fr: &FindRefs<'_, '_, '_>| {
2077        Ok(vec![match shape {
2078            Shape::Results => Rows::Results(rows_compiled(fr, query, opts)?),
2079            Shape::Text => Rows::Text(compact::text_rows(fr, query)?),
2080        }])
2081    };
2082    search_entry_with(apk, key, entry, 1, &run, probe, cancel, cache, stats).pop().expect("one batch per query")
2083}
2084
2085/// Rows of `n` queries over one logical DEX, one per query.
2086pub(crate) type RunRows<'q> = dyn Fn(&FindRefs<'_, '_, '_>) -> Result<Vec<Rows>> + Sync + 'q;
2087
2088/// The rows of one query over one logical DEX, in the form asked for.
2089pub(crate) enum Rows {
2090    Results(Vec<RefResult>),
2091    Text(TextRows),
2092}
2093
2094/// The form [`Engine::find_refs_opts`] (`Results`) and
2095/// [`Engine::find_refs_text`] (`Text`) return rows in.
2096#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2097pub(crate) enum Shape {
2098    Results,
2099    Text,
2100}
2101
2102/// Load one entry (through the caches) and run `run` over each of its
2103/// logical DEX files: one batch per query, all sharing the load. With a
2104/// `probe`, an entry no cache keeps may first be shown not to match from a
2105/// prefix, and then is not loaded at all.
2106#[allow(clippy::too_many_arguments)]
2107pub(crate) fn search_entry_with(
2108    apk: &Apk,
2109    key: Option<&ApkKey>,
2110    entry: &Entry,
2111    n: usize,
2112    run: &RunRows<'_>,
2113    probe: Option<&skip::Probe<'_>>,
2114    cancel: &AtomicBool,
2115    cache: Option<&EngineCache>,
2116    stats: &Stats,
2117) -> Vec<RefBatch> {
2118    let t0 = Instant::now();
2119    let empty = RefBatch {
2120        entry_name: entry.name.clone(),
2121        results: Vec::new(),
2122        text: None,
2123        inflate_us: 0,
2124        search_us: 0,
2125        source: "inflate",
2126        error: None,
2127    };
2128    let mut batches = vec![empty; n];
2129    if probe.is_some_and(|p| p.skips(apk, entry, cache, stats)) {
2130        let inflate_us = t0.elapsed().as_micros();
2131        for b in &mut batches {
2132            (b.source, b.inflate_us) = (Source::Skipped.as_str(), inflate_us);
2133        }
2134        return batches;
2135    }
2136    let (loaded, source) = match load_entry(apk, key, entry, cache, stats, Some(cancel)) {
2137        Ok(l) => l,
2138        Err(e) => {
2139            let msg = e.to_string();
2140            for b in &mut batches {
2141                b.error = Some(msg.clone());
2142            }
2143            return batches;
2144        }
2145    };
2146    let t1 = Instant::now();
2147    let mut add = |name: &str, r: Result<Vec<Rows>>| match r {
2148        Ok(rows) => {
2149            for (b, rows) in batches.iter_mut().zip(rows) {
2150                match rows {
2151                    Rows::Results(mut rows) => b.results.append(&mut rows),
2152                    Rows::Text(t) => match &mut b.text {
2153                        Some(text) => text.append(&t),
2154                        None => b.text = Some(t),
2155                    },
2156                }
2157            }
2158        }
2159        Err(e) => {
2160            let msg = e.to_string();
2161            for b in batches.iter_mut() {
2162                b.note_error(name, &msg);
2163            }
2164        }
2165    };
2166    match &loaded {
2167        Loaded::Cached(e) => {
2168            let l2 = cache.and_then(|c| c.disk.as_deref().filter(|d| d.uses(Tier::Derived)).map(|d| (c, d))).zip(key);
2169            for (i, l) in e.logical().iter().enumerate() {
2170                let r = Dex::with_name(&l.data, &l.name).map_err(EngineError::from).and_then(|dex| {
2171                    if let Some(((_, disk), k)) = l2
2172                        && tiers::load_derived(disk, k, l, &dex)
2173                    {
2174                        stats.derived_hits.fetch_add(1, Ordering::Relaxed);
2175                    }
2176                    let (mask, t) = (l.state.table_mask(), Instant::now());
2177                    let rows = run(&FindRefs::with_state(&dex, &l.state));
2178                    if let Some(((c, disk), k)) = l2
2179                        && let Some(job) = tiers::derived_job(disk, e, i, mask, t.elapsed())
2180                    {
2181                        c.put_async(WriteJob { key: k.clone(), apk: Arc::from(apk.path()), kind: Job::Derived(job) });
2182                    }
2183                    rows
2184                });
2185                add(&l.name, r);
2186            }
2187            recharge(cache, key, e);
2188        }
2189        Loaded::Borrowed(_) => {
2190            for (name, buf) in logical_dex_iter(&entry.name, loaded.bytes()) {
2191                let r = Dex::with_name(&buf, &name).map_err(EngineError::from).and_then(|dex| run(&FindRefs::new(&dex)));
2192                add(&name, r);
2193            }
2194        }
2195    }
2196    let (inflate_us, search_us) = ((t1 - t0).as_micros(), t1.elapsed().as_micros());
2197    for b in &mut batches {
2198        (b.source, b.inflate_us, b.search_us) = (source.as_str(), inflate_us, search_us);
2199    }
2200    batches
2201}
2202
2203/// Charge the in-memory cache for the locator tables a search just built for
2204/// `e`, evicting other entries if that exceeds the capacity.
2205fn recharge(cache: Option<&EngineCache>, key: Option<&ApkKey>, e: &Arc<CachedEntry>) {
2206    let (Some(mem), Some(key)) = (cache.and_then(|c| c.memory.as_ref()), key) else { return };
2207    let size = e.charged_bytes();
2208    {
2209        let mut mem = mem.lock().unwrap();
2210        if !mem.charged_bytes(key, &e.name).is_some_and(|old| old != size) {
2211            return;
2212        }
2213        mem.resize(key, &e.name, e, size);
2214    }
2215    if let Some(c) = cache {
2216        c.charged();
2217    }
2218}
2219
2220/// The constant a hit matched, with the note of a call-site constraint
2221/// (`Lc;->m(I)V [arg0=1]`) when it has one.
2222fn hit_matched(dex: &Dex<'_>, kind: RefKind, idx: u32, note: Option<&str>, access: Option<FieldAccess>) -> Result<Matched> {
2223    let m = matched_name(dex, kind, idx)?;
2224    if let (RefKind::Field, Some(access)) = (kind, access) {
2225        return Ok(Matched::Field { symbol: m.as_str().to_string(), access });
2226    }
2227    Ok(match note {
2228        Some(note) => Matched::Symbol(format!("{} {note}", m.as_str())),
2229        None => m,
2230    })
2231}
2232
2233fn matched_name(dex: &Dex<'_>, kind: RefKind, idx: u32) -> Result<Matched> {
2234    Ok(match kind {
2235        RefKind::String => Matched::String(dex.string(idx)?),
2236        RefKind::Type => Matched::Symbol(dex.type_descriptor(idx)?),
2237        RefKind::Method => Matched::Symbol(asc_smali::method_signature(dex, idx)?),
2238        RefKind::Field => Matched::Symbol(asc_smali::field_signature(dex, idx)?),
2239    })
2240}
2241
2242impl RefResult {
2243    /// The row of method `m` of `dex`, with no context yet.
2244    fn method_row(dex: &Dex<'_>, m: u32, matched: Vec<Matched>, sites: Vec<RefSite>) -> Result<RefResult> {
2245        Ok(RefResult {
2246            dex_name: dex.name().to_string(),
2247            method_idx: m,
2248            method: dex.method_display(m)?,
2249            signature: asc_smali::method_signature(dex, m)?,
2250            matched,
2251            relation: None,
2252            sites,
2253            context: None,
2254        })
2255    }
2256}
2257
2258/// Run a reference search over one DEX buffer and return structured results.
2259pub fn find_refs_in_dex(dex_name: &str, buf: &[u8], query: &Query, aggregate: bool) -> Result<Vec<RefResult>> {
2260    find_refs_in_dex_opts(dex_name, buf, query, FindOptions { aggregate, context: RefContext::None })
2261}
2262
2263/// [`find_refs_in_dex`] with a context.
2264pub fn find_refs_in_dex_opts(dex_name: &str, buf: &[u8], query: &Query, opts: FindOptions) -> Result<Vec<RefResult>> {
2265    find_refs_in_dex_compiled(dex_name, buf, &CompiledQuery::new(query)?, opts)
2266}
2267
2268fn find_refs_in_dex_compiled(dex_name: &str, buf: &[u8], query: &CompiledQuery, opts: FindOptions) -> Result<Vec<RefResult>> {
2269    let dex = Dex::with_name(buf, dex_name)?;
2270    rows_compiled(&FindRefs::new(&dex), query, opts)
2271}
2272
2273/// Result rows of `query` over the DEX `fr` searches: class relation rows
2274/// straight from the class definitions, otherwise the code scan's hits.
2275fn rows_compiled(fr: &FindRefs<'_, '_, '_>, query: &CompiledQuery, opts: FindOptions) -> Result<Vec<RefResult>> {
2276    let dex = fr.dex();
2277    if let Some(c) = query.compound() {
2278        return rules::compound_rows(fr, c, opts);
2279    }
2280    if let Some(d) = query.defined() {
2281        let mut rows = defined_rows(dex, d)?;
2282        if opts.context != RefContext::None {
2283            attach_context(fr, &mut rows, opts.context)?;
2284        }
2285        return Ok(rows);
2286    }
2287    let Some(classes) = fr.class_relation(query)? else {
2288        let hits = fr.find_ref_compiled(query)?;
2289        let mut rows = results_from_hits(dex, query.kind(), &hits, opts.aggregate)?;
2290        if opts.context != RefContext::None {
2291            attach_context(fr, &mut rows, opts.context)?;
2292        }
2293        return Ok(rows);
2294    };
2295    let target = match query.query() {
2296        Query::Extends(t) | Query::Implements(t) => t.as_str(),
2297        _ => "",
2298    };
2299    let dex_name = dex.name().to_string();
2300    classes
2301        .into_iter()
2302        .map(|c| {
2303            let class = dex.type_descriptor(c)?;
2304            Ok(RefResult {
2305                dex_name: dex_name.clone(),
2306                method_idx: c,
2307                method: class.clone(),
2308                signature: class,
2309                matched: vec![Matched::Symbol(target.to_string())],
2310                relation: query.relation(),
2311                sites: Vec::new(),
2312                context: None,
2313            })
2314        })
2315        .collect()
2316}
2317
2318/// Rows of a method-definition predicate: one per matching method, with no
2319/// call site (the `matched` column names the predicate).
2320pub(crate) fn defined_rows(dex: &Dex<'_>, d: &asc_findrefs::Defined) -> Result<Vec<RefResult>> {
2321    let label = d.label();
2322    defs::defined_methods(dex, d)?
2323        .into_iter()
2324        .map(|m| RefResult::method_row(dex, m, vec![Matched::Symbol(label.clone())], Vec::new()))
2325        .collect()
2326}
2327
2328/// Turn raw hits into display results (grouped per method when `aggregate`).
2329pub fn results_from_hits(dex: &Dex<'_>, kind: RefKind, hits: &[Hit], aggregate: bool) -> Result<Vec<RefResult>> {
2330    if !aggregate {
2331        let mut out = Vec::new();
2332        for hit in hits {
2333            let Some(methods) = &hit.methods else { continue };
2334            let matched = hit_matched(dex, kind, hit.matched_idx, hit.note.as_deref(), hit.field_access)?;
2335            for &m in methods.ids() {
2336                let site = RefSite { offset: hit.offset, matched: 0, field_access: hit.field_access, pc: None, line: None };
2337                out.push(RefResult::method_row(dex, m, vec![matched.clone()], vec![site])?);
2338            }
2339        }
2340        return Ok(out);
2341    }
2342    // method -> (matched (index, note, access)s, (offset, matched key) per hit)
2343    type Key<'h> = (u32, Option<&'h str>, Option<FieldAccess>);
2344    type Group<'h> = (std::collections::BTreeSet<Key<'h>>, Vec<(u32, Key<'h>)>);
2345    let mut grouped: BTreeMap<u32, Group<'_>> = BTreeMap::new();
2346    for hit in hits {
2347        let Some(methods) = &hit.methods else { continue };
2348        let key = (hit.matched_idx, hit.note.as_deref(), hit.field_access);
2349        for &m in methods.ids() {
2350            let g = grouped.entry(m).or_default();
2351            g.0.insert(key);
2352            g.1.push((hit.offset, key));
2353        }
2354    }
2355    let mut out = Vec::with_capacity(grouped.len());
2356    for (m, (idxs, offsets)) in grouped {
2357        let idxs: Vec<Key<'_>> = idxs.into_iter().collect();
2358        let matched = idxs.iter().map(|&(i, note, access)| hit_matched(dex, kind, i, note, access)).collect::<Result<Vec<_>>>()?;
2359        let sites = offsets
2360            .into_iter()
2361            .map(|(offset, idx)| RefSite {
2362                offset,
2363                matched: idxs.binary_search(&idx).unwrap_or(0),
2364                field_access: idx.2,
2365                pc: None,
2366                line: None,
2367            })
2368            .collect();
2369        out.push(RefResult::method_row(dex, m, matched, sites)?);
2370    }
2371    Ok(out)
2372}
2373
2374/// Fill the sites' `pc` / `line` and the `context` of rows found in `fr`'s DEX.
2375/// A method that cannot be rendered gets a `# context unavailable` comment.
2376fn attach_context<'r>(fr: &FindRefs<'_, '_, '_>, rows: impl IntoIterator<Item = &'r mut RefResult>, ctx: RefContext) -> Result<()> {
2377    let dex = fr.dex();
2378    let bounds = fr.insn_locator()?.method_bounds();
2379    for row in rows {
2380        let start = bounds.get(row.method_idx as usize).copied().unwrap_or(u32::MAX);
2381        let listing = match asc_smali::disassemble_method(dex, row.method_idx, SmaliOptions::default()) {
2382            Ok(l) => l,
2383            Err(e) => {
2384                row.context = Some(format!("  # context unavailable: {e}\n"));
2385                continue;
2386            }
2387        };
2388        for site in &mut row.sites {
2389            if let Some(pc) = site.offset.checked_sub(start).filter(|_| start != u32::MAX).map(|b| b / 2) {
2390                site.pc = Some(pc);
2391                site.line = listing.source_line(pc);
2392            }
2393        }
2394        row.context = Some(render_context(&listing, &row.sites, ctx));
2395    }
2396    Ok(())
2397}
2398
2399/// Most smali lines one row's context shows; beyond it the rest is summarised.
2400const CONTEXT_MAX_LINES: usize = 150;
2401/// A whole method longer than [`CONTEXT_MAX_LINES`] shows this many lines
2402/// around each reference instead.
2403const METHOD_FALLBACK_LINES: usize = 10;
2404
2405/// Context block of one row: an `@` line listing the sites, then the smali
2406/// (`>` marks the referencing instructions; windows are separated by `--`).
2407fn render_context(listing: &asc_smali::MethodListing, sites: &[RefSite], ctx: RefContext) -> String {
2408    let mut out = String::from("  @");
2409    for (i, s) in sites.iter().enumerate() {
2410        out.push_str(if i == 0 { " " } else { ", " });
2411        match (s.pc, s.line) {
2412            (Some(pc), Some(line)) => out.push_str(&format!("pc 0x{pc:x} line {line}")),
2413            (Some(pc), None) => out.push_str(&format!("pc 0x{pc:x}")),
2414            _ => out.push_str(&format!("offset 0x{:x}", s.offset)),
2415        }
2416        if let Some(access) = s.field_access {
2417            out.push_str(&format!(" {}", access.as_str()));
2418        }
2419    }
2420    out.push('\n');
2421    let lines: Vec<&str> = listing.text.lines().collect();
2422    let mut hits: Vec<usize> = sites.iter().filter_map(|s| s.pc).filter_map(|pc| listing.line_of(pc)).map(|l| l as usize).collect();
2423    hits.sort_unstable();
2424    hits.dedup();
2425    let around = |n: usize| {
2426        let mut w: Vec<(usize, usize)> = Vec::new();
2427        for &h in &hits {
2428            let (a, b) = (h.saturating_sub(n), (h + n + 1).min(lines.len()));
2429            match w.last_mut() {
2430                Some(last) if a <= last.1 => last.1 = last.1.max(b),
2431                _ => w.push((a, b)),
2432            }
2433        }
2434        w
2435    };
2436    let windows = match ctx {
2437        RefContext::None => return out,
2438        RefContext::Method if lines.len() <= CONTEXT_MAX_LINES => vec![(0, lines.len())],
2439        RefContext::Method => {
2440            out.push_str(&format!("  # method has {} lines; showing {METHOD_FALLBACK_LINES} around each reference\n", lines.len()));
2441            around(METHOD_FALLBACK_LINES)
2442        }
2443        RefContext::Lines(n) => around(n),
2444    };
2445    let mut shown = 0;
2446    for (i, &(a, b)) in windows.iter().enumerate() {
2447        if shown + (b - a) > CONTEXT_MAX_LINES {
2448            let rest: usize = windows[i..].iter().map(|w| w.1 - w.0).sum::<usize>();
2449            out.push_str(&format!("  # cut at {CONTEXT_MAX_LINES} lines ({rest} more); read the method with getclass\n"));
2450            break;
2451        }
2452        if i > 0 {
2453            out.push_str("  --\n");
2454        }
2455        for (j, line) in lines[a..b].iter().enumerate() {
2456            out.push_str(if hits.binary_search(&(a + j)).is_ok() { "  > " } else { "    " });
2457            out.push_str(line);
2458            out.push('\n');
2459        }
2460        shown += b - a;
2461    }
2462    out
2463}
2464
2465/// Python `AscHandler.findrefs` equivalent: formatted lines for one DEX.
2466pub fn find_refs_lines(dex_name: &str, buf: &[u8], query: &Query, aggregate: bool) -> Result<Vec<String>> {
2467    Ok(find_refs_in_dex(dex_name, buf, query, aggregate)?.iter().map(RefResult::line).collect())
2468}
2469
2470/// Rebuild `descriptor` out of `buf` and disassemble it.
2471pub fn decompile_dex_class(buf: &[u8], descriptor: &str, opts: SmaliOptions) -> Result<(Rebuilt, String)> {
2472    decompile_dex_class_method(buf, descriptor, opts, None)
2473}
2474
2475/// [`decompile_dex_class`] rendering only `method` (`name(params)ret`) when given.
2476pub fn decompile_dex_class_method(buf: &[u8], descriptor: &str, opts: SmaliOptions, method: Option<&str>) -> Result<(Rebuilt, String)> {
2477    let dex = Dex::parse(buf)?;
2478    let rebuilt = asc_rebuild::rebuild_class(&dex, descriptor)?;
2479    let new_dex = Dex::parse(&rebuilt.bytes)?;
2480    let source = asc_smali::disassemble_class_method(&new_dex, descriptor, opts, method)?;
2481    Ok((rebuilt, source))
2482}
2483
2484#[cfg(test)]
2485mod tests {
2486    use super::*;
2487
2488    /// An APK of `n` deflated copies of the fixture DEX, an L1-only cache
2489    /// (admitting entries of any size), and the entry names.
2490    fn multi_dex_apk(dir: &Path, n: usize) -> (PathBuf, DiskCache, Vec<String>) {
2491        let dex = asc_fixture::make_dex();
2492        let names: Vec<String> = (1..=n).map(|i| if i == 1 { "classes.dex".into() } else { format!("classes{i}.dex") }).collect();
2493        let entries: Vec<(&str, &[u8])> = names.iter().map(|name| (name.as_str(), dex.as_slice())).collect();
2494        let apk = dir.join("multi.apk");
2495        std::fs::write(&apk, asc_fixture::make_apk(&entries, true)).unwrap();
2496        (apk, l1_cache(&dir.join("cache")), names)
2497    }
2498
2499    fn l1_cache(dir: &Path) -> DiskCache {
2500        DiskCache::open(dir).with_capacity(1 << 30).with_min_dex_bytes(0).with_tiers(&[Tier::Dex])
2501    }
2502
2503    #[test]
2504    fn a_full_write_queue_defers_l1_writes_instead_of_dropping_them() {
2505        let dir = tempfile::tempdir().unwrap();
2506        let (apk, disk, names) = multi_dex_apk(dir.path(), 8);
2507        let mut engine = Engine::new(&apk).with_threads(4).with_cache(CacheConfig { disk: Some(disk), ..CacheConfig::default() });
2508        // Room for one entry's bytes: every other write is queued without them.
2509        Arc::get_mut(engine.cache.as_mut().unwrap()).unwrap().queue_cap = 1;
2510        engine.find_refs(&Query::String("token".into()), |batch| assert!(batch.error.is_none())).unwrap();
2511        engine.flush_cache_writes();
2512        let cache = engine.cache.as_deref().unwrap();
2513        assert_eq!(cache.pending_bytes.load(Ordering::Relaxed), 0);
2514        let key = engine.apk_key(&Apk::open(&apk).unwrap()).unwrap();
2515        let len = asc_fixture::make_dex().len() as u64;
2516        for name in &names {
2517            assert!(cache.disk.as_ref().unwrap().get(&key, name, len).is_some(), "{name} missing from L1");
2518        }
2519    }
2520
2521    #[test]
2522    fn the_write_queue_stays_within_the_memory_budget() {
2523        let dir = tempfile::tempdir().unwrap();
2524        let disk = || Some(DiskCache::open(dir.path().join("cache")));
2525        let cap = |engine: &Engine| engine.cache.as_deref().unwrap().queue_cap();
2526        assert_eq!(cap(&Engine::new("x.apk").with_cache(CacheConfig { disk: disk(), ..CacheConfig::default() })), CacheConfig::WRITE_QUEUE);
2527        let small = Engine::new("x.apk").with_cache(CacheConfig { memory_bytes: 64 << 20, disk: disk(), ..CacheConfig::default() });
2528        assert_eq!(cap(&small), 64 << 20);
2529        let large = Engine::new("x.apk").with_cache(CacheConfig { memory_bytes: 4 << 30, disk: disk(), ..CacheConfig::default() });
2530        assert_eq!(cap(&large), CacheConfig::WRITE_QUEUE);
2531        let set = Engine::new("x.apk").with_cache(CacheConfig {
2532            memory_bytes: 4 << 30,
2533            disk: disk(),
2534            write_queue_bytes: 1 << 20,
2535            ..CacheConfig::default()
2536        });
2537        assert_eq!(cap(&set), 1 << 20);
2538        assert_eq!((CacheConfig::auto_writers(4), CacheConfig::auto_writers(16), CacheConfig::auto_writers(64)), (1, 2, 4));
2539    }
2540
2541    #[test]
2542    fn a_deferred_write_inflates_its_entry_again_unless_the_apk_changed() {
2543        let dir = tempfile::tempdir().unwrap();
2544        let (apk_path, disk, names) = multi_dex_apk(dir.path(), 2);
2545        let apk = Apk::open(&apk_path).unwrap();
2546        let key = ApkKey::for_apk(&apk).unwrap();
2547        let entries = apk.dex_entries().unwrap();
2548        let limits = apk.limits();
2549        let job = |entry: &Entry| WriteJob {
2550            key: key.clone(),
2551            apk: Arc::from(apk_path.as_path()),
2552            kind: Job::Dex { entry: entry.clone(), limits, data: Arc::new(DexData::Owned(vec![0; 64])), remap: None },
2553        };
2554        // Deferring lets go of the bytes; the writer inflates the entry itself.
2555        let deferred = job(&entries[0]).defer().unwrap();
2556        assert!(matches!(deferred.kind, Job::Reinflate { .. }));
2557        assert_eq!(deferred.held_bytes(), 0);
2558        run_job(&disk, None, deferred, &mut WriterState::default());
2559        let written = disk.get(&key, &entries[0].name, entries[0].uncomp_size).expect("inflated again and written");
2560        assert_eq!(&*written, &*apk.read_entry(&entries[0]).unwrap());
2561        // Replaced since the job was queued: nothing is written for the old key.
2562        drop(apk);
2563        let replacement = dir.path().join("replacement.apk");
2564        std::fs::write(&replacement, asc_fixture::make_apk(&[(names[0].as_str(), &asc_fixture::make_dex()[..])], false)).unwrap();
2565        std::fs::rename(&replacement, &apk_path).unwrap();
2566        let other = l1_cache(&dir.path().join("other"));
2567        run_job(&other, None, job(&entries[1]).defer().unwrap(), &mut WriterState::default());
2568        assert!(other.get(&key, &entries[1].name, entries[1].uncomp_size).is_none());
2569    }
2570}