Skip to main content

asc_cache/
lib.rs

1//! Caches for inflated DEX entries and what is derived from them.
2//!
3//! Inflating the largest DEX of an APK is the critical path of every query, and
4//! it produces the same bytes every time. Two caches remove that cost for
5//! repeated queries without introducing any preprocessing step: they are
6//! filled lazily by the first query that needs an entry.
7//!
8//! * [`DiskCache`] stores data under a cache directory keyed by the APK's
9//!   size, modification time and central directory ([`ApkKey`]), in three
10//!   tiers ([`Tier`]) that share one budget:
11//!   - **L1** inflated DEX entries ([`DiskCache::put`] / [`DiskCache::get`]),
12//!     cheap to rebuild and large: evicted first, largest-and-oldest first.
13//!     Entries below [`DiskCache::min_dex_bytes`] are not worth writing
14//!     (re-inflating them is cheaper than the write).
15//!   - **L2** derived indexes ([`DiskCache::put_derived`] /
16//!     [`DiskCache::get_derived`]): locator tables and other per-DEX or
17//!     per-APK structures, stored so they can be mapped and viewed in place.
18//!     Every process mapping one shares its pages instead of building a
19//!     private copy. Kept by rebuild cost per byte; evicted last.
20//!   - **L3** rendered results ([`DiskCache::put_rendered`] /
21//!     [`DiskCache::get_rendered`]): small outputs keyed by the query and the
22//!     tool version, plain LRU under a small budget of their own.
23//!
24//!   Later processes memory-map L1 and L2 files, so the operating system's
25//!   page cache keeps them hot. Writes are plain `put`s; eviction is a
26//!   separate [`DiskCache::evict`] step so a host can batch it (writing a
27//!   30 MiB entry and walking the whole cache directory after every one of
28//!   them is what a query must never wait on). The budget follows the free
29//!   space of the file system by default ([`AutoDisk`]).
30//!
31//!   Writes are never synced: everything here can be rebuilt, and a sync
32//!   per file costs more than the query that wrote it (macOS flushes the
33//!   drive's own cache on every one). A temporary file renamed into place
34//!   keeps readers from seeing half a file while the system runs; what a
35//!   crash can leave is a file whose pages never reached the disk. Every
36//!   file carries a CRC-32 of its contents, checked when a file last
37//!   written before the current boot (or at an unknown or future time) is
38//!   first read: a damaged one is deleted and read as a miss. L3 files are small and
39//!   always checked.
40//! * [`MemoryCache`] is a byte-bounded LRU of inflated entries for hosts that
41//!   stay alive between queries (MCP servers, the daemon).
42//! * [`ProcessRegistry`] lets resident hosts of one user see each other, so
43//!   their memory budgets add up to one machine-wide budget, and
44//!   [`auto_threads`] splits the CPUs between the busy ones.
45
46mod lock;
47mod procs;
48mod threads;
49
50pub use lock::LockExt;
51pub use procs::{ProcessRegistry, ProcessUsage};
52pub use threads::{Threads, auto_threads, auto_threads_for, cpus};
53
54use std::collections::{HashMap, HashSet};
55use std::fs;
56use std::io::{Read, Seek, SeekFrom, Write};
57use std::ops::Deref;
58use std::path::{Path, PathBuf};
59use std::sync::{Arc, OnceLock};
60use std::time::{Duration, SystemTime};
61
62/// A memory budget charges file-backed bytes (mapped from the disk cache,
63/// the APK or a HarmonyOS package) at this fraction of their size, heap bytes in full.
64///
65/// File pages are not what runs a machine out of memory: the kernel drops
66/// clean ones under pressure (they are re-read from the file) and every
67/// process mapping the same L1 / L2 file or APK shares one copy, so charging
68/// them in full made each of N processes evict as if it held its own copy,
69/// and summed to N times the real footprint in the process registry
70/// (`MemAvailable`, which the budget starts from, already counts reclaimable
71/// page cache). They still cost something (address space, page cache churn,
72/// the re-reads), so they are not free either: the LRU still bounds them, at
73/// up to this many times the budget.
74pub const MAPPED_CHARGE_DIVISOR: usize = 8;
75
76/// Identity of an APK for cache purposes.
77///
78/// A program of several archives has a key of its own (rendered results,
79/// L3, and [`MemoryCache`] go by it), and each archive its own key too: L1
80/// and L2 files of an entry live under its archive's key, by its name inside
81/// the archive ([`ApkKey::locate`]). Rewriting one jar of a program then
82/// costs that jar's entries only, and a jar read alone shares them with the
83/// same jar read as part of a program.
84#[derive(Debug, Clone)]
85pub struct ApkKey {
86    id: String,
87    /// Each archive of a program; empty for a lone archive.
88    archives: Arc<[ArchiveKey]>,
89}
90
91/// One archive of a program's [`ApkKey`].
92#[derive(Debug)]
93struct ArchiveKey {
94    /// What the program's names of its entries start with, before a `/`.
95    label: String,
96    path: PathBuf,
97    key: ApkKey,
98}
99
100impl PartialEq for ApkKey {
101    fn eq(&self, other: &ApkKey) -> bool {
102        self.id == other.id
103    }
104}
105
106impl Eq for ApkKey {}
107
108impl std::hash::Hash for ApkKey {
109    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
110        self.id.hash(state);
111    }
112}
113
114/// Characters a key stem (and a cached entry file name) may contain.
115fn is_key_char(c: char) -> bool {
116    c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'
117}
118/// Longest stem kept from the APK file name.
119const STEM_MAX: usize = 40;
120/// Width of the hex hash that ends every key.
121const HASH_HEX_LEN: usize = 16;
122
123impl ApkKey {
124    /// Derive the key from the file size, mtime and a hash of the central
125    /// directory (so a rewritten archive with the same size is not confused
126    /// with the old one); for a program of several archives, from those of
127    /// each and its path, in order.
128    ///
129    /// # Errors
130    ///
131    /// The errors of [`asc_apk::Apk::directories`], and
132    /// [`asc_apk::ApkError::Io`] when an archive's metadata cannot be read.
133    pub fn for_apk(apk: &asc_apk::Apk) -> asc_apk::Result<ApkKey> {
134        let ids = apk.directories()?;
135        let mut key = 0u64;
136        let mut archives = Vec::with_capacity(if ids.len() > 1 { ids.len() } else { 0 });
137        for (i, id) in ids.iter().enumerate() {
138            let meta = fs::metadata(id.path).map_err(|source| asc_apk::ApkError::Io { path: id.path.to_path_buf(), source })?;
139            let mtime = meta.modified().ok().and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()).map(|d| d.as_nanos()).unwrap_or(0);
140            let mut h = fnv64(id.directory);
141            h ^= meta.len().wrapping_mul(0x9E37_79B9_7F4A_7C15);
142            h ^= (mtime as u64).rotate_left(17);
143            if ids.len() > 1 {
144                // The key the archive has when read alone.
145                let stem = id.path.file_stem().map(|s| s.to_string_lossy()).unwrap_or_default();
146                let key = ApkKey::lone(&stem, h);
147                archives.push(ArchiveKey { label: id.label.to_string(), path: id.path.to_path_buf(), key });
148            }
149            // A lone archive keeps the key it always had.
150            key = if i == 0 { h } else { key.rotate_left(11) ^ h ^ fnv64(id.path.as_os_str().as_encoded_bytes()).rotate_left(29) };
151        }
152        Ok(ApkKey { archives: archives.into(), ..ApkKey::lone(&apk.stem(), key) })
153    }
154
155    /// The key of a file known by its content rather than its place: one
156    /// ABC file of a HarmonyOS program, whose decompiled records depend on
157    /// its bytes alone. `label` names it (`entry.hap/ets/modules.abc`): its
158    /// last component's stem starts the key, the first 16 hex digits of
159    /// `sha256` end it. The same bytes get the same key under any path, in
160    /// any program; there are no archives to [`locate`](ApkKey::locate) in.
161    ///
162    /// The key only spreads files over directories: whoever stores under
163    /// it keeps the whole digest in the query, as two contents may share
164    /// these 64 bits.
165    pub fn for_content(label: &str, sha256: &[u8; 32]) -> ApkKey {
166        let name = label.rsplit(['/', '\\']).next().unwrap_or(label);
167        let stem = Path::new(name).file_stem().map_or_else(|| name.into(), |stem| stem.to_string_lossy());
168        let mut prefix = [0u8; 8];
169        prefix.copy_from_slice(&sha256[..8]);
170        ApkKey::lone(&stem, u64::from_be_bytes(prefix))
171    }
172
173    /// The key of one archive: `stem` (sanitised) and its hash.
174    fn lone(stem: &str, hash: u64) -> ApkKey {
175        let stem: String = stem.chars().filter(|c| is_key_char(*c)).take(STEM_MAX).collect();
176        ApkKey::from_id(format!("{stem}-{hash:0width$x}", width = HASH_HEX_LEN))
177    }
178
179    fn from_id(id: String) -> ApkKey {
180        ApkKey { id, archives: Arc::new([]) }
181    }
182
183    /// The key as text: `<sanitised stem>-<16 hex digits>`, also the name of
184    /// the APK's cache directories.
185    pub fn as_str(&self) -> &str {
186        &self.id
187    }
188
189    /// The key and name the L1 / L2 files of the entry (or logical DEX) a
190    /// program calls `name` are stored under: its archive's, by its name
191    /// inside that archive. Other names (and a lone archive's) stay as
192    /// they are.
193    pub fn locate<'n>(&self, name: &'n str) -> (&ApkKey, &'n str) {
194        let found = asc_apk::archive_of(name).and_then(|label| {
195            let a = self.archives.iter().find(|a| a.label == label)?;
196            Some((&a.key, &name[label.len() + 1..]))
197        });
198        found.unwrap_or((self, name))
199    }
200}
201
202/// FNV-1a over 8-byte lanes: the central directory of a large APK runs to a
203/// few megabytes and is hashed once per process.
204fn fnv64(bytes: &[u8]) -> u64 {
205    const PRIME: u64 = 0x0100_0000_01b3;
206    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
207    let (chunks, rest) = bytes.as_chunks::<8>();
208    for c in chunks {
209        h ^= u64::from_le_bytes(*c);
210        h = h.wrapping_mul(PRIME);
211    }
212    for &b in rest {
213        h ^= b as u64;
214        h = h.wrapping_mul(PRIME);
215    }
216    h
217}
218
219/// Inflated bytes of one entry, either owned or mapped from the disk cache.
220#[non_exhaustive]
221pub enum DexData {
222    /// Inflated into memory.
223    Owned(Vec<u8>),
224    /// Mapped read-only from a cache file or an uncompressed APK entry.
225    Mapped(memmap2::Mmap),
226}
227
228impl Deref for DexData {
229    type Target = [u8];
230    fn deref(&self) -> &[u8] {
231        match self {
232            DexData::Owned(v) => v,
233            DexData::Mapped(m) => m,
234        }
235    }
236}
237
238impl DexData {
239    /// `len` bytes at `offset` of the file at `path`, mapped read-only (any
240    /// offset: the mapping starts at the page holding it). For entries an
241    /// APK stores uncompressed, so a host keeping them gets file pages the
242    /// OS can share and reclaim instead of a heap copy. The file must not
243    /// change while mapped, the same contract as mapping the APK itself.
244    ///
245    /// # Errors
246    ///
247    /// The I/O error opening or mapping the file, or
248    /// [`std::io::ErrorKind::UnexpectedEof`] when the region runs past its end.
249    pub fn map_region(path: &Path, offset: u64, len: usize) -> std::io::Result<DexData> {
250        let file = fs::File::open(path)?;
251        if file.metadata()?.len() < offset.saturating_add(len as u64) {
252            return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "region past the end of the file"));
253        }
254        // SAFETY: read-only mapping of a range checked to lie inside the file;
255        // see the contract above.
256        let map = unsafe { memmap2::MmapOptions::new().offset(offset).len(len).map(&file) }?;
257        Ok(DexData::Mapped(map))
258    }
259}
260
261impl AsRef<[u8]> for DexData {
262    fn as_ref(&self) -> &[u8] {
263        self
264    }
265}
266
267/// Why a disk cache operation failed.
268#[derive(Debug, thiserror::Error)]
269#[non_exhaustive]
270pub enum CacheError {
271    /// A file system operation failed.
272    #[error("cache io error at {path}: {source}")]
273    Io {
274        /// The file or directory operated on.
275        path: PathBuf,
276        /// The underlying error.
277        source: std::io::Error,
278    },
279    /// The directory exists but holds no cache marker, so it may hold
280    /// someone else's files.
281    #[error("{path} is not a asc cache directory (no {tag} marker); refusing to touch it")]
282    NotACacheDir {
283        /// The cache directory.
284        path: PathBuf,
285        /// The marker file name, [`CACHEDIR_TAG`].
286        tag: &'static str,
287    },
288    /// Writing would eat into the free space the automatic budget reserves
289    /// for the file system ([`auto_disk_reserve`]).
290    #[error("{path}: too little free disk space left to cache {bytes} bytes")]
291    LowDiskSpace {
292        /// The cache directory.
293        path: PathBuf,
294        /// Size of the write that was refused.
295        bytes: u64,
296    },
297}
298
299fn io_err(path: &Path) -> impl Fn(std::io::Error) -> CacheError + '_ {
300    move |source| CacheError::Io { path: path.to_path_buf(), source }
301}
302
303/// On-disk cache of inflated entries, derived indexes and rendered results.
304pub struct DiskCache {
305    dir: PathBuf,
306    cap: DiskCapacity,
307    min_dex_bytes: u64,
308    min_derived_cost: Duration,
309    /// Tiers written and read; the others behave as permanently empty.
310    tiers: [bool; 3],
311    /// Bytes this handle wrote since its last eviction walk.
312    written: std::sync::atomic::AtomicU64,
313    /// When the system booted ([`boot_time`]): files written before it are
314    /// checked on read.
315    boot: Option<SystemTime>,
316}
317
318/// The tiers of a [`DiskCache`].
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
320#[non_exhaustive]
321pub enum Tier {
322    /// L1: inflated DEX entries, `<dir>/<apk key>/<entry>`.
323    Dex,
324    /// L2: derived indexes, `<dir>/derived/<apk key>/<name>~<kind>.v<format>.<version>`.
325    Derived,
326    /// L3: rendered results, `<dir>/rendered/<apk key>/<hash of the query>`.
327    Rendered,
328}
329
330impl Tier {
331    /// Every tier, L1 first.
332    pub const ALL: [Tier; 3] = [Tier::Dex, Tier::Derived, Tier::Rendered];
333
334    /// Name in a tier list (`dex,derived,rendered`).
335    pub fn name(self) -> &'static str {
336        match self {
337            Tier::Dex => "dex",
338            Tier::Derived => "derived",
339            Tier::Rendered => "rendered",
340        }
341    }
342
343    /// Name for reports, with its level (`L1 dex`).
344    pub fn label(self) -> &'static str {
345        match self {
346            Tier::Dex => "L1 dex",
347            Tier::Derived => "L2 derived",
348            Tier::Rendered => "L3 rendered",
349        }
350    }
351}
352
353/// Subdirectory of the L2 tier.
354const DERIVED_DIR: &str = "derived";
355/// Subdirectory of the L3 tier.
356const RENDERED_DIR: &str = "rendered";
357/// Where each APK key's source path is recorded, to spot dead entries.
358const SOURCES_DIR: &str = "sources";
359/// Where the MCP server writes replies too long for the client (its own
360/// budget and pruning; this cache only recognizes and clears it).
361pub const SPILL_DIR: &str = "out";
362/// Subdirectory holding the working directories of decompiler backends
363/// (`decompilers/<name>/`); the cache neither evicts nor clears it.
364pub const DECOMPILERS_DIR: &str = "decompilers";
365
366/// Absolute cap of L3, which also never takes more than
367/// 1/[`RENDERED_BUDGET_DIVISOR`] of the total budget.
368pub const RENDERED_MAX: u64 = 128 << 20;
369/// L3 never takes more than 1/`RENDERED_BUDGET_DIVISOR` of the total budget.
370pub const RENDERED_BUDGET_DIVISOR: u64 = 8;
371/// L2 never takes more than 1/[`DERIVED_BUDGET_DIVISOR`] of the total budget.
372pub const DERIVED_BUDGET_DIVISOR: u64 = 2;
373
374/// Inflated DEX entries smaller than this are not written to L1 by default:
375/// re-inflating them costs less than writing (and later evicting) them.
376pub const DEFAULT_MIN_DEX_BYTES: u64 = 4 << 20;
377/// Derived indexes that took less than this to build are not written to L2
378/// by default: mapping and checking a file would save next to nothing.
379pub const DEFAULT_MIN_DERIVED_COST: Duration = Duration::from_millis(2);
380
381/// Header of an L2 file; the payload follows at [`DERIVED_HEADER`], so it is
382/// as aligned as the mapping (page-aligned) up to that.
383const DERIVED_MAGIC: &[u8; 8] = b"ASCL2\0\0\0";
384const DERIVED_HEADER: usize = 64;
385/// Layout version of the L2 header, part of every L2 file name.
386const DERIVED_FORMAT: u32 = 2;
387const RENDERED_MAGIC: &[u8; 8] = b"ASCL3\0\0\0";
388/// Layout version of L3 files, part of every L3 key.
389const RENDERED_FORMAT: u32 = 2;
390/// End of an L1 file, after the inflated bytes: this magic, then the
391/// CRC-32 of those bytes. Readers map only the bytes before it.
392const L1_MAGIC: &[u8; 4] = b"ASC1";
393const L1_TRAILER: u64 = 8;
394
395/// A derived index mapped from L2: the payload [`DiskCache::put_derived`]
396/// stored, 64-byte aligned.
397#[non_exhaustive]
398pub struct Derived {
399    map: memmap2::Mmap,
400    /// Rebuild cost recorded by the writer.
401    pub build_cost: Duration,
402}
403
404/// A validated L3 result viewed directly in its cache file.
405#[derive(Debug)]
406pub struct Rendered {
407    map: memmap2::Mmap,
408    body: usize,
409}
410
411impl AsRef<[u8]> for Rendered {
412    fn as_ref(&self) -> &[u8] {
413        &self.map[self.body..]
414    }
415}
416
417impl Deref for Derived {
418    type Target = [u8];
419    fn deref(&self) -> &[u8] {
420        &self.map[DERIVED_HEADER..]
421    }
422}
423
424impl AsRef<[u8]> for Derived {
425    fn as_ref(&self) -> &[u8] {
426        self
427    }
428}
429
430/// Per-tier usage reported by [`DiskCache::tier_stats`].
431#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
432#[non_exhaustive]
433pub struct TierStats {
434    /// APK keys with data in the tier.
435    pub apks: usize,
436    /// Files in the tier.
437    pub files: usize,
438    /// Bytes those files take.
439    pub bytes: u64,
440}
441
442/// One evictable unit: an L1 APK directory, or one L2 / L3 file.
443struct Item {
444    tier: Tier,
445    key: String,
446    path: PathBuf,
447    mtime: SystemTime,
448    files: usize,
449    bytes: u64,
450    /// Rebuild cost (L2 only).
451    cost_us: u64,
452}
453
454/// How a [`DiskCache`] is bounded.
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum DiskCapacity {
457    /// Follow the free space of the cache's file system.
458    Auto(AutoDisk),
459    /// A fixed number of bytes.
460    Fixed(u64),
461}
462
463impl Default for DiskCapacity {
464    fn default() -> DiskCapacity {
465        DiskCapacity::Auto(AutoDisk::default())
466    }
467}
468
469/// The automatic disk budget where the free space cannot be read.
470pub const AUTO_DISK_UNKNOWN: u64 = 4 << 30;
471/// Smallest free space the automatic budget leaves on the file system; the
472/// reserve is the larger of this and [`AUTO_DISK_RESERVE_PERCENT`] of it.
473pub const AUTO_DISK_RESERVE_MIN: u64 = 1 << 30;
474/// Share of the file system's size, in percent, the automatic budget leaves
475/// free (at least [`AUTO_DISK_RESERVE_MIN`]).
476pub const AUTO_DISK_RESERVE_PERCENT: u64 = 5;
477
478/// Free space the automatic budget never writes into on a file system of
479/// `total` bytes.
480pub fn auto_disk_reserve(total: u64) -> u64 {
481    AUTO_DISK_RESERVE_MIN.max(total / 100 * AUTO_DISK_RESERVE_PERCENT)
482}
483
484/// The automatic disk budget: a share of what the cache could occupy (its
485/// own bytes plus the free space), at most a ceiling, and never more than
486/// leaves [`auto_disk_reserve`] free.
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488#[non_exhaustive]
489pub struct AutoDisk {
490    /// Share of (free space + the cache's own bytes), in percent.
491    pub percent: u8,
492    /// Upper bound, in bytes.
493    pub ceiling: u64,
494}
495
496impl AutoDisk {
497    /// Default share of the room.
498    pub const PERCENT: u8 = 20;
499    /// Default ceiling: large enough for the inflated DEX of a phone's worth
500    /// of APKs (416 system and store APKs: 8.6 GB).
501    pub const CEILING: u64 = 32 << 30;
502
503    /// `percent` of the room, at most `ceiling` bytes.
504    pub fn new(percent: u8, ceiling: u64) -> AutoDisk {
505        AutoDisk { percent, ceiling }
506    }
507
508    /// The budget for a cache holding `used` bytes on a file system with
509    /// `(free, total)` bytes (`None`: unknown, e.g. on Windows, giving
510    /// [`AUTO_DISK_UNKNOWN`]). When the disk is already below the reserve,
511    /// the last bound falls under `used`, so eviction gives the shortfall
512    /// back. (No separate floor: with a reserve of at least 1 GiB, the
513    /// reserve is always the tighter bound on a disk too small for 20 % to
514    /// reach 256 MiB.)
515    pub fn bytes_for(&self, space: Option<(u64, u64)>, used: u64) -> u64 {
516        let Some((free, total)) = space else { return AUTO_DISK_UNKNOWN.min(self.ceiling) };
517        let room = used.saturating_add(free);
518        let budget = (room / 100 * u64::from(self.percent)).min(self.ceiling);
519        budget.min(room.saturating_sub(auto_disk_reserve(total)))
520    }
521}
522
523impl Default for AutoDisk {
524    fn default() -> AutoDisk {
525        AutoDisk { percent: AutoDisk::PERCENT, ceiling: AutoDisk::CEILING }
526    }
527}
528
529/// `(free, total)` bytes of the file system holding `path` (or its nearest
530/// existing ancestor). `None` where it cannot be determined.
531pub fn disk_space(path: &Path) -> Option<(u64, u64)> {
532    let mut p = path;
533    loop {
534        if p.exists() {
535            return fs_space(p);
536        }
537        p = p.parent()?;
538    }
539}
540
541#[cfg(unix)]
542fn fs_space(path: &Path) -> Option<(u64, u64)> {
543    use std::os::unix::ffi::OsStrExt;
544    let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
545    let mut st = std::mem::MaybeUninit::<libc::statvfs>::uninit();
546    // SAFETY: `c` is a NUL-terminated path and `st` a properly sized out buffer.
547    if unsafe { libc::statvfs(c.as_ptr(), st.as_mut_ptr()) } != 0 {
548        return None;
549    }
550    // SAFETY: statvfs succeeded, so it filled `st`.
551    let st = unsafe { st.assume_init() };
552    // The field types differ between platforms.
553    #[allow(clippy::useless_conversion, clippy::unnecessary_cast)]
554    let (frsize, bavail, blocks) = (st.f_frsize as u64, st.f_bavail as u64, st.f_blocks as u64);
555    Some((bavail.saturating_mul(frsize), blocks.saturating_mul(frsize)))
556}
557
558#[cfg(not(unix))]
559fn fs_space(_path: &Path) -> Option<(u64, u64)> {
560    None
561}
562
563/// What [`DiskCache::budget`] reports.
564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
565#[non_exhaustive]
566pub struct DiskBudget {
567    /// How the cache is bounded.
568    pub capacity: DiskCapacity,
569    /// Bytes the cache may hold right now.
570    pub cap_bytes: u64,
571    /// Bytes it holds.
572    pub used: u64,
573    /// `(free, total)` of its file system, when known.
574    pub space: Option<(u64, u64)>,
575}
576
577/// Temporary files older than this are leftovers of a killed writer.
578const STALE_TMP: Duration = Duration::from_secs(10 * 60);
579
580/// Marker file written into the cache root (see <https://bford.info/cachedir/>).
581/// A directory is only used as a cache (written to, evicted, cleared) when it
582/// carries this marker, is empty or missing, or holds nothing but the cache's
583/// own APK directories (one written before the marker existed). Pointing
584/// `--cache-dir` at `$HOME` or a project tree therefore neither reclaims its
585/// subdirectories as "least recently used APKs" nor plants a tag that makes
586/// backup tools skip it.
587pub const CACHEDIR_TAG: &str = "CACHEDIR.TAG";
588const CACHEDIR_TAG_CONTENT: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\
589# This file is a cache directory tag created by asc.\n\
590# For information about cache directory tags, see: https://bford.info/cachedir/\n";
591
592/// Whether `name` has the shape [`ApkKey::for_apk`] produces
593/// (`<stem>-<hash>`, stem of at most [`STEM_MAX`] [`is_key_char`]s, hash of
594/// [`HASH_HEX_LEN`] lowercase hex digits). Only such subdirectories are
595/// counted, evicted or cleared.
596fn is_cache_dir_name(name: &str) -> bool {
597    let Some((stem, hash)) = name.rsplit_once('-') else { return false };
598    hash.len() == HASH_HEX_LEN
599        && hash.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
600        && stem.len() <= STEM_MAX
601        && stem.chars().all(is_key_char)
602}
603
604impl DiskCache {
605    /// The default cache directory: `$XDG_CACHE_HOME/asc`, else on Windows
606    /// `%LOCALAPPDATA%\\asc\\cache`, else `~/.cache/asc`.
607    pub fn default_dir() -> Option<PathBuf> {
608        let var = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(PathBuf::from);
609        if let Some(d) = var("XDG_CACHE_HOME") {
610            return Some(d.join("asc"));
611        }
612        if cfg!(windows)
613            && let Some(d) = var("LOCALAPPDATA")
614        {
615            return Some(d.join("asc").join("cache"));
616        }
617        var("HOME").map(|h| h.join(".cache").join("asc"))
618    }
619
620    /// A cache in `dir` with the default policy: automatic capacity, every
621    /// tier, [`DEFAULT_MIN_DEX_BYTES`]. Hosts apply the user's configuration
622    /// with the `with_*` methods.
623    pub fn open(dir: impl Into<PathBuf>) -> DiskCache {
624        DiskCache {
625            dir: dir.into(),
626            cap: DiskCapacity::default(),
627            min_dex_bytes: DEFAULT_MIN_DEX_BYTES,
628            min_derived_cost: DEFAULT_MIN_DERIVED_COST,
629            tiers: [true; 3],
630            written: Default::default(),
631            boot: boot_time(),
632        }
633    }
634
635    /// Use only `tiers`: reads of the others miss and writes to them are
636    /// skipped. Entries already stored in them still count and are evicted.
637    pub fn with_tiers(mut self, tiers: &[Tier]) -> DiskCache {
638        self.tiers = Tier::ALL.map(|t| tiers.contains(&t));
639        self
640    }
641
642    /// Whether `tier` is read and written ([`DiskCache::with_tiers`]).
643    pub fn uses(&self, tier: Tier) -> bool {
644        self.tiers[tier as usize]
645    }
646
647    /// Write inflated entries of at least `bytes` to L1 (0: all of them).
648    pub fn with_min_dex_bytes(mut self, bytes: u64) -> DiskCache {
649        self.min_dex_bytes = bytes;
650        self
651    }
652
653    /// Smallest inflated entry worth writing to L1 ([`DEFAULT_MIN_DEX_BYTES`]
654    /// unless configured otherwise). [`DiskCache::put`] itself
655    /// writes whatever it is given; hosts ask [`DiskCache::admits_dex`] first.
656    pub fn min_dex_bytes(&self) -> u64 {
657        self.min_dex_bytes
658    }
659
660    /// Whether an inflated entry of `len` bytes is worth an L1 file
661    /// ([`DiskCache::min_dex_bytes`]).
662    pub fn admits_dex(&self, len: u64) -> bool {
663        len >= self.min_dex_bytes
664    }
665
666    /// Write derived indexes that took at least `cost` to build to L2
667    /// (default [`DEFAULT_MIN_DERIVED_COST`]; zero: all of them).
668    pub fn with_min_derived_cost(mut self, cost: Duration) -> DiskCache {
669        self.min_derived_cost = cost;
670        self
671    }
672
673    /// Whether an index that took `cost` to build is worth an L2 file.
674    /// Like [`DiskCache::admits_dex`], a policy for hosts to apply.
675    pub fn admits_derived(&self, cost: Duration) -> bool {
676        cost >= self.min_derived_cost
677    }
678
679    /// Open the default directory, if one can be determined.
680    pub fn open_default() -> Option<DiskCache> {
681        Self::default_dir().map(Self::open)
682    }
683
684    /// A fixed capacity of `cap_bytes`.
685    pub fn with_capacity(self, cap_bytes: u64) -> DiskCache {
686        self.with_capacity_mode(DiskCapacity::Fixed(cap_bytes))
687    }
688
689    /// Bound the cache by `cap`.
690    pub fn with_capacity_mode(mut self, cap: DiskCapacity) -> DiskCache {
691        self.cap = cap;
692        self
693    }
694
695    /// How the cache is bounded.
696    pub fn capacity_mode(&self) -> DiskCapacity {
697        self.cap
698    }
699
700    /// The cache directory.
701    pub fn dir(&self) -> &Path {
702        &self.dir
703    }
704
705    /// Bytes the cache may hold when it holds `used`: the fixed capacity, or
706    /// the automatic budget for the file system's current free space.
707    pub fn capacity_for(&self, used: u64) -> u64 {
708        match self.cap {
709            DiskCapacity::Fixed(b) => b,
710            DiskCapacity::Auto(auto) => auto.bytes_for(disk_space(&self.dir), used),
711        }
712    }
713
714    /// Capacity, usage and free space right now (walks the cache directory).
715    pub fn budget(&self) -> DiskBudget {
716        let used = self.stats().2;
717        let space = disk_space(&self.dir);
718        let cap_bytes = match self.cap {
719            DiskCapacity::Fixed(b) => b,
720            DiskCapacity::Auto(auto) => auto.bytes_for(space, used),
721        };
722        DiskBudget { capacity: self.cap, cap_bytes, used, space }
723    }
724
725    /// Whether a cache file must be checked before use ([`predates_boot`]).
726    fn predates_boot(&self, meta: &fs::Metadata) -> bool {
727        predates_boot(meta.modified().ok(), self.boot, SystemTime::now())
728    }
729
730    /// Delete a damaged file; always a miss.
731    fn discard<T>(&self, path: &Path) -> Option<T> {
732        // A concurrent writer may have just renamed a sound file over it:
733        // deleting that one only costs a rebuild.
734        let _ = fs::remove_file(path);
735        None
736    }
737
738    fn entry_path(&self, key: &ApkKey, entry_name: &str) -> PathBuf {
739        let (key, entry_name) = key.locate(entry_name);
740        self.dir.join(key.as_str()).join(safe_name(entry_name))
741    }
742
743    fn derived_path(&self, key: &ApkKey, name: &str, kind: &str, version: u32) -> PathBuf {
744        let (key, name) = key.locate(name);
745        let file = format!("{}~{}.v{DERIVED_FORMAT}.{version}", safe_name(name), safe_name(kind));
746        self.dir.join(DERIVED_DIR).join(key.as_str()).join(file)
747    }
748
749    fn rendered_path(&self, key: &ApkKey, query: &str) -> PathBuf {
750        self.dir.join(RENDERED_DIR).join(key.as_str()).join(format!("{:016x}", fnv64(query.as_bytes())))
751    }
752
753    /// Map a cached entry if present and of the expected size (0: any).
754    /// A file from before the current boot is checked against its CRC-32
755    /// first; a damaged one is deleted.
756    ///
757    /// # Panics
758    ///
759    /// Never: the CRC is read from a trailer of fixed size.
760    pub fn get(&self, key: &ApkKey, entry_name: &str, expected_len: u64) -> Option<DexData> {
761        if !self.uses(Tier::Dex) {
762            return None;
763        }
764        let path = self.entry_path(key, entry_name);
765        let file = fs::File::open(&path).ok()?;
766        let meta = file.metadata().ok()?;
767        let len = meta.len().checked_sub(L1_TRAILER).filter(|&l| l > 0)?;
768        if expected_len != 0 && len != expected_len {
769            return None;
770        }
771        let mut trailer = [0u8; L1_TRAILER as usize];
772        (&file).seek(SeekFrom::Start(len)).ok()?;
773        (&file).read_exact(&mut trailer).ok()?;
774        // Not `populate`d: pre-faulting every entry of an APK costs more than
775        // faulting in the pages a query reads (getclass reads one class).
776        let map = map_prefix(&file, len)?;
777        if &trailer[..4] != L1_MAGIC {
778            return self.discard(&path);
779        }
780        if self.predates_boot(&meta) {
781            if crc32fast::hash(&map) != u32::from_le_bytes(trailer[4..].try_into().expect("4 bytes")) {
782                // Unmapped first: Windows refuses to remove a mapped file.
783                drop(map);
784                return self.discard(&path);
785            }
786            let _ = touch_file(&file, &path);
787        }
788        // Touch the APK directory so LRU eviction sees recent use.
789        let _ = touch_dir(path.parent()?);
790        Some(DexData::Mapped(map))
791    }
792
793    /// Store an inflated entry (atomic rename; concurrent writers are
794    /// harmless). Does not evict: call [`DiskCache::evict`] after a batch of
795    /// puts.
796    ///
797    /// # Errors
798    ///
799    /// [`CacheError::NotACacheDir`] when the directory is not this cache's,
800    /// [`CacheError::LowDiskSpace`] when the write would eat into the free
801    /// space reserve, [`CacheError::Io`] when writing fails.
802    pub fn put(&self, key: &ApkKey, entry_name: &str, bytes: &[u8]) -> Result<(), CacheError> {
803        if !self.uses(Tier::Dex) {
804            return Ok(());
805        }
806        let mut trailer = [0u8; L1_TRAILER as usize];
807        trailer[..4].copy_from_slice(L1_MAGIC);
808        trailer[4..].copy_from_slice(&crc32fast::hash(bytes).to_le_bytes());
809        self.write_atomic(&self.entry_path(key, entry_name), &[bytes, &trailer])
810    }
811
812    /// Map the L2 index `name` (e.g. a logical DEX name) of `kind` at the
813    /// producer's format `version`, as [`DiskCache::put_derived`] stored it.
814    /// A file of another version, a damaged header or a truncated payload
815    /// is a miss.
816    ///
817    /// # Panics
818    ///
819    /// Never: the header fields are read from a slice of the full header size.
820    pub fn get_derived(&self, key: &ApkKey, name: &str, kind: &str, version: u32) -> Option<Derived> {
821        if !self.uses(Tier::Derived) {
822            return None;
823        }
824        let path = self.derived_path(key, name, kind, version);
825        let file = fs::File::open(&path).ok()?;
826        let meta = file.metadata().ok()?;
827        let map = map_file(&file)?;
828        let header = map.get(..DERIVED_HEADER)?;
829        let u32_at = |o: usize| u32::from_le_bytes(header[o..o + 4].try_into().expect("4 bytes"));
830        let u64_at = |o: usize| u64::from_le_bytes(header[o..o + 8].try_into().expect("8 bytes"));
831        if &header[..8] != DERIVED_MAGIC
832            || u32_at(8) != DERIVED_FORMAT
833            || u32_at(12) != version
834            || u64_at(24) != (map.len() - DERIVED_HEADER) as u64
835        {
836            return None;
837        }
838        if self.predates_boot(&meta) && crc32fast::hash(&map[DERIVED_HEADER..]) != u32_at(32) {
839            drop(map);
840            return self.discard(&path);
841        }
842        let _ = touch_file(&file, &path);
843        Some(Derived { build_cost: Duration::from_micros(u64_at(16)), map })
844    }
845
846    /// Store an L2 index (see [`DiskCache::get_derived`]). `build_cost` is
847    /// what rebuilding it takes: eviction keeps the indexes that save the
848    /// most time per byte.
849    ///
850    /// # Errors
851    ///
852    /// As [`DiskCache::put`].
853    pub fn put_derived(
854        &self,
855        key: &ApkKey,
856        name: &str,
857        kind: &str,
858        version: u32,
859        build_cost: Duration,
860        payload: &[u8],
861    ) -> Result<(), CacheError> {
862        if !self.uses(Tier::Derived) {
863            return Ok(());
864        }
865        let mut header = [0u8; DERIVED_HEADER];
866        header[..8].copy_from_slice(DERIVED_MAGIC);
867        header[8..12].copy_from_slice(&DERIVED_FORMAT.to_le_bytes());
868        header[12..16].copy_from_slice(&version.to_le_bytes());
869        header[16..24].copy_from_slice(&(build_cost.as_micros() as u64).to_le_bytes());
870        header[24..32].copy_from_slice(&(payload.len() as u64).to_le_bytes());
871        header[32..36].copy_from_slice(&crc32fast::hash(payload).to_le_bytes());
872        self.write_atomic(&self.derived_path(key, name, kind, version), &[&header, payload])
873    }
874
875    /// The L3 result stored under `query` (the query, its options and the
876    /// producer's version: build it with [`rendered_key`]).
877    pub fn get_rendered(&self, key: &ApkKey, query: &str) -> Option<Vec<u8>> {
878        self.get_rendered_view(key, query).map(|view| view.as_ref().to_vec())
879    }
880
881    /// [`DiskCache::get_rendered`] without copying the payload out of its
882    /// cache file. The immutable file is mapped only after its full key and
883    /// CRC have been validated.
884    pub fn get_rendered_view(&self, key: &ApkKey, query: &str) -> Option<Rendered> {
885        if !self.uses(Tier::Rendered) {
886            return None;
887        }
888        let path = self.rendered_path(key, query);
889        let file = fs::File::open(&path).ok()?;
890        let data = map_file(&file)?;
891        let key_len = u32::from_le_bytes(data.get(8..12)?.try_into().ok()?) as usize;
892        let crc = u32::from_le_bytes(data.get(12..16)?.try_into().ok()?);
893        let body = 16usize.checked_add(key_len)?;
894        if data.get(..8)? != RENDERED_MAGIC || crc32fast::hash(&data[16..]) != crc {
895            drop(data);
896            return self.discard(&path);
897        }
898        // The full key is stored and compared, so a hash collision is a miss.
899        if data.get(16..body)? != query.as_bytes() {
900            return None;
901        }
902        let _ = touch_file(&file, &path);
903        Some(Rendered { map: data, body })
904    }
905
906    /// Store an L3 result (see [`DiskCache::get_rendered`]).
907    ///
908    /// # Errors
909    ///
910    /// As [`DiskCache::put`].
911    pub fn put_rendered(&self, key: &ApkKey, query: &str, payload: &[u8]) -> Result<(), CacheError> {
912        if !self.uses(Tier::Rendered) {
913            return Ok(());
914        }
915        let mut crc = crc32fast::Hasher::new();
916        crc.update(query.as_bytes());
917        crc.update(payload);
918        let mut header = Vec::with_capacity(16 + query.len());
919        header.extend_from_slice(RENDERED_MAGIC);
920        header.extend_from_slice(&(query.len() as u32).to_le_bytes());
921        header.extend_from_slice(&crc.finalize().to_le_bytes());
922        header.extend_from_slice(query.as_bytes());
923        self.write_atomic(&self.rendered_path(key, query), &[&header, payload])
924    }
925
926    /// Record where the APK of `key` lives, so eviction can tell the entries
927    /// of an APK deleted or replaced since (evicted first) from live ones.
928    /// Cheap to repeat; hosts call it once per APK. A program's archives are
929    /// recorded one by one, each under its own key.
930    ///
931    /// # Errors
932    ///
933    /// As [`DiskCache::put`].
934    pub fn note_source(&self, key: &ApkKey, apk_path: &Path) -> Result<(), CacheError> {
935        if !key.archives.is_empty() {
936            return key.archives.iter().try_for_each(|a| self.note_source(&a.key, &a.path));
937        }
938        let path = self.dir.join(SOURCES_DIR).join(key.as_str());
939        let Some(stamp) = source_stamp(apk_path) else { return Ok(()) };
940        if fs::read_to_string(&path).is_ok_and(|s| s == stamp) {
941            return Ok(());
942        }
943        self.write_atomic(&path, &[stamp.as_bytes()])
944    }
945
946    /// Write `parts` to `path` (atomic rename; concurrent writers are
947    /// harmless), refusing foreign directories and the free-space reserve.
948    /// Not synced: a file a crash damages fails its checksum on read (see
949    /// the module documentation).
950    fn write_atomic(&self, path: &Path, parts: &[&[u8]]) -> Result<(), CacheError> {
951        // Checked before anything is created: the tag must never land in a
952        // directory that holds someone else's files.
953        if !self.is_cache_dir() {
954            return Err(self.not_a_cache_dir());
955        }
956        let len: u64 = parts.iter().map(|p| p.len() as u64).sum();
957        // One statvfs per write: with the automatic budget, never write into
958        // the reserve the file system needs (another program may have filled
959        // the disk since the last eviction).
960        if matches!(self.cap, DiskCapacity::Auto(_))
961            && let Some((free, total)) = disk_space(&self.dir)
962            && free.saturating_sub(len) < auto_disk_reserve(total)
963        {
964            return Err(CacheError::LowDiskSpace { path: self.dir.clone(), bytes: len });
965        }
966        let parent = path.parent().expect("cache path has a parent");
967        fs::create_dir_all(parent).map_err(io_err(parent))?;
968        // Best effort: an unwritable root (shared cache, full disk) must not
969        // stop entries from being written into their own directories.
970        let _ = self.ensure_tag();
971        let tmp = parent.join(format!(".{}.{}.tmp", path.file_name().unwrap_or_default().to_string_lossy(), std::process::id()));
972        {
973            let mut f = fs::File::create(&tmp).map_err(io_err(&tmp))?;
974            for p in parts {
975                f.write_all(p).map_err(io_err(&tmp))?;
976            }
977        }
978        fs::rename(&tmp, path).map_err(io_err(path))?;
979        self.written.fetch_add(len, std::sync::atomic::Ordering::Relaxed);
980        Ok(())
981    }
982
983    /// `(APKs, files, bytes)` currently stored, over every tier (an APK
984    /// with data in several tiers counts once).
985    pub fn stats(&self) -> (usize, usize, u64) {
986        let items = self.items();
987        let keys: HashSet<&str> = items.iter().map(|i| i.key.as_str()).collect();
988        (keys.len(), items.iter().map(|i| i.files).sum(), items.iter().map(|i| i.bytes).sum())
989    }
990
991    /// Usage of each tier, in [`Tier::ALL`] order.
992    pub fn tier_stats(&self) -> [TierStats; 3] {
993        let items = self.items();
994        Tier::ALL.map(|tier| {
995            let of_tier = || items.iter().filter(move |i| i.tier == tier);
996            let keys: HashSet<&str> = of_tier().map(|i| i.key.as_str()).collect();
997            TierStats { apks: keys.len(), files: of_tier().map(|i| i.files).sum(), bytes: of_tier().map(|i| i.bytes).sum() }
998        })
999    }
1000
1001    /// Delete every cached entry of every tier (and nothing else). Returns
1002    /// the number of bytes freed. Refuses to run on a directory that is not
1003    /// a cache directory (see [`CACHEDIR_TAG`]).
1004    ///
1005    /// # Errors
1006    ///
1007    /// [`CacheError::NotACacheDir`] for a foreign directory, and
1008    /// [`CacheError::Io`] when a file or directory cannot be removed.
1009    pub fn clear(&self) -> Result<u64, CacheError> {
1010        if !self.dir.exists() {
1011            return Ok(0);
1012        }
1013        if !self.is_cache_dir() {
1014            return Err(self.not_a_cache_dir());
1015        }
1016        let mut bytes = 0;
1017        for item in self.items() {
1018            remove_item(&item).map_err(io_err(&item.path))?;
1019            bytes += item.bytes;
1020        }
1021        for sub in [DERIVED_DIR, RENDERED_DIR, SOURCES_DIR, SPILL_DIR] {
1022            let path = self.dir.join(sub);
1023            if path.is_dir() {
1024                fs::remove_dir_all(&path).map_err(io_err(&path))?;
1025            }
1026        }
1027        Ok(bytes)
1028    }
1029
1030    /// `Ok` when the directory may be used as this cache (see
1031    /// [`CACHEDIR_TAG`]), [`CacheError::NotACacheDir`] otherwise. Hosts call
1032    /// this up front: [`DiskCache::put`] refuses such a directory too, but its
1033    /// errors are usually swallowed by a background writer.
1034    ///
1035    /// # Errors
1036    ///
1037    /// [`CacheError::NotACacheDir`] when the directory exists without the
1038    /// marker.
1039    pub fn check_usable(&self) -> Result<(), CacheError> {
1040        if self.is_cache_dir() { Ok(()) } else { Err(self.not_a_cache_dir()) }
1041    }
1042
1043    fn not_a_cache_dir(&self) -> CacheError {
1044        CacheError::NotACacheDir { path: self.dir.clone(), tag: CACHEDIR_TAG }
1045    }
1046
1047    /// Write the [`CACHEDIR_TAG`] marker if it is missing.
1048    fn ensure_tag(&self) -> Result<(), CacheError> {
1049        if self.has_tag() {
1050            return Ok(());
1051        }
1052        let tag = self.dir.join(CACHEDIR_TAG);
1053        // `create_new` so two first writers cannot race; whoever loses is done.
1054        match fs::OpenOptions::new().write(true).create_new(true).open(&tag) {
1055            // A truncated tag would still count as one; remove it so the next
1056            // put retries.
1057            Ok(mut f) => f.write_all(CACHEDIR_TAG_CONTENT.as_bytes()).map_err(|e| {
1058                drop(f);
1059                let _ = fs::remove_file(&tag);
1060                io_err(&tag)(e)
1061            }),
1062            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
1063            Err(e) => Err(io_err(&tag)(e)),
1064        }
1065    }
1066
1067    fn has_tag(&self) -> bool {
1068        self.dir.join(CACHEDIR_TAG).is_file()
1069    }
1070
1071    /// Whether the directory may be treated as this cache's own: it carries
1072    /// the tag, does not exist yet, or contains nothing but APK directories
1073    /// of the cache's naming shape (plus Finder's `.DS_Store`), which is what
1074    /// a cache written before the tag was introduced looks like. Any other
1075    /// file, dot-files included, means the directory is someone else's.
1076    fn is_cache_dir(&self) -> bool {
1077        if self.has_tag() {
1078            return true;
1079        }
1080        match fs::read_dir(&self.dir) {
1081            Ok(rd) => rd.flatten().all(|d| {
1082                let name = d.file_name();
1083                let name = name.to_string_lossy();
1084                match d.file_type() {
1085                    Ok(t) if t.is_dir() => {
1086                        is_cache_dir_name(&name) || TIER_DIRS.contains(&&*name) || name == SPILL_DIR || name == DECOMPILERS_DIR
1087                    }
1088                    Ok(t) if t.is_file() => name == ".DS_Store" || name == EVICT_MARKER,
1089                    _ => false,
1090                }
1091            }),
1092            Err(e) => e.kind() == std::io::ErrorKind::NotFound,
1093        }
1094    }
1095
1096    /// `(mtime, path, file count, bytes)` per APK directory (subdirectories
1097    /// whose name matches [`is_cache_dir_name`]; anything else in the
1098    /// directory is ignored); also removes temporary files left behind by a
1099    /// killed writer.
1100    fn apk_dirs(&self) -> Vec<(SystemTime, PathBuf, usize, u64)> {
1101        apk_dirs_in(&self.dir)
1102    }
1103
1104    /// Every evictable unit of every tier.
1105    fn items(&self) -> Vec<Item> {
1106        let mut out: Vec<Item> = self
1107            .apk_dirs()
1108            .into_iter()
1109            .map(|(mtime, path, files, bytes)| Item {
1110                tier: Tier::Dex,
1111                key: path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
1112                path,
1113                mtime,
1114                files,
1115                bytes,
1116                cost_us: 0,
1117            })
1118            .collect();
1119        for (tier, sub) in [(Tier::Derived, DERIVED_DIR), (Tier::Rendered, RENDERED_DIR)] {
1120            for (_, dir, _, _) in apk_dirs_in(&self.dir.join(sub)) {
1121                let key = dir.file_name().unwrap_or_default().to_string_lossy().into_owned();
1122                let Ok(rd) = fs::read_dir(&dir) else { continue };
1123                for f in rd.flatten() {
1124                    let Ok(m) = f.metadata() else { continue };
1125                    if !m.is_file() || f.file_name().to_string_lossy().ends_with(".tmp") {
1126                        continue;
1127                    }
1128                    let cost_us = if tier == Tier::Derived { derived_cost_us(&f.path()) } else { 0 };
1129                    let mtime = m.modified().unwrap_or(SystemTime::UNIX_EPOCH);
1130                    out.push(Item { tier, key: key.clone(), path: f.path(), mtime, files: 1, bytes: m.len(), cost_us });
1131                }
1132            }
1133        }
1134        out
1135    }
1136
1137    /// APK keys whose recorded source ([`DiskCache::note_source`]) is gone
1138    /// or has changed since.
1139    fn dead_keys(&self) -> HashSet<String> {
1140        let mut out = HashSet::new();
1141        let Ok(rd) = fs::read_dir(self.dir.join(SOURCES_DIR)) else { return out };
1142        for f in rd.flatten() {
1143            let Ok(stamp) = fs::read_to_string(f.path()) else { continue };
1144            let Some(path) = stamp.lines().nth(2) else { continue };
1145            if source_stamp(Path::new(path)).as_deref() != Some(stamp.as_str()) {
1146                out.insert(f.file_name().to_string_lossy().into_owned());
1147            }
1148        }
1149        out
1150    }
1151}
1152
1153/// `(mtime, path, file count, bytes)` per APK directory directly under
1154/// `root` (subdirectories whose name matches [`is_cache_dir_name`]; anything
1155/// else is ignored); also removes temporary files left behind by a killed
1156/// writer.
1157fn apk_dirs_in(root: &Path) -> Vec<(SystemTime, PathBuf, usize, u64)> {
1158    let mut out = Vec::new();
1159    let Ok(rd) = fs::read_dir(root) else { return out };
1160    let now = SystemTime::now();
1161    for d in rd.flatten() {
1162        let path = d.path();
1163        if !is_cache_dir_name(&d.file_name().to_string_lossy()) || !path.is_dir() {
1164            continue;
1165        }
1166        let mtime = d.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
1167        let (mut files, mut size) = (0usize, 0u64);
1168        if let Ok(fd) = fs::read_dir(&path) {
1169            for f in fd.flatten() {
1170                let Ok(m) = f.metadata() else { continue };
1171                let name = f.file_name();
1172                let is_tmp = name.to_string_lossy().ends_with(".tmp");
1173                if is_tmp && m.modified().ok().and_then(|t| now.duration_since(t).ok()).is_some_and(|age| age > STALE_TMP) {
1174                    let _ = fs::remove_file(f.path());
1175                    continue;
1176                }
1177                files += 1;
1178                size += m.len();
1179            }
1180        }
1181        out.push((mtime, path, files, size));
1182    }
1183    out
1184}
1185
1186impl DiskCache {
1187    /// Evict until under capacity ([`DiskCache::capacity_for`],
1188    /// re-evaluated against the current free space). Does nothing in a
1189    /// directory that is not a cache directory (see [`CACHEDIR_TAG`]).
1190    ///
1191    /// L3 is first trimmed to its own budget (the smaller of
1192    /// [`RENDERED_MAX`] and 1/[`RENDERED_BUDGET_DIVISOR`] of the total) and
1193    /// L2 to 1/[`DERIVED_BUDGET_DIVISOR`]. Over the total, entries of APKs
1194    /// that are gone ([`DiskCache::note_source`]) go first, then L1 (the
1195    /// largest, least recently used APK directories first), then L3 (least
1196    /// recently used first), then L2 (least rebuild time saved per byte
1197    /// first, discounted by age).
1198    pub fn evict(&self) {
1199        if !self.is_cache_dir() {
1200            return;
1201        }
1202        self.written.store(0, std::sync::atomic::Ordering::Relaxed);
1203        let _ = fs::File::create(self.dir.join(EVICT_MARKER));
1204        let items = self.items();
1205        let mut total: u64 = items.iter().map(|i| i.bytes).sum();
1206        let cap = self.capacity_for(total);
1207        let caps = [cap, cap / DERIVED_BUDGET_DIVISOR, (cap / RENDERED_BUDGET_DIVISOR).min(RENDERED_MAX)];
1208        let mut tier_used = [0u64; 3];
1209        for i in &items {
1210            tier_used[i.tier as usize] += i.bytes;
1211        }
1212        if total > cap || tier_used[1] > caps[1] || tier_used[2] > caps[2] {
1213            let order = eviction_order(&items, &self.dead_keys());
1214            let mut gone = vec![false; items.len()];
1215            let mut remove = |idx: usize, total: &mut u64, tier_used: &mut [u64; 3]| {
1216                if !gone[idx] && remove_item(&items[idx]).is_ok() {
1217                    gone[idx] = true;
1218                    *total = total.saturating_sub(items[idx].bytes);
1219                    let t = items[idx].tier as usize;
1220                    tier_used[t] = tier_used[t].saturating_sub(items[idx].bytes);
1221                }
1222            };
1223            // The per-tier budgets of L3 and L2, each in its own order.
1224            for tier in [Tier::Rendered, Tier::Derived] {
1225                for &idx in order.iter().filter(|&&i| items[i].tier == tier) {
1226                    if tier_used[tier as usize] <= caps[tier as usize] {
1227                        break;
1228                    }
1229                    remove(idx, &mut total, &mut tier_used);
1230                }
1231            }
1232            for &idx in &order {
1233                if total <= cap {
1234                    break;
1235                }
1236                remove(idx, &mut total, &mut tier_used);
1237            }
1238        }
1239        // Drop the source records of keys with nothing left.
1240        if let Ok(rd) = fs::read_dir(self.dir.join(SOURCES_DIR)) {
1241            let live: HashSet<String> = self.items().into_iter().map(|i| i.key).collect();
1242            for f in rd.flatten() {
1243                if !live.contains(&*f.file_name().to_string_lossy()) {
1244                    let _ = fs::remove_file(f.path());
1245                }
1246            }
1247        }
1248    }
1249}
1250
1251impl DiskCache {
1252    /// [`DiskCache::evict`], skipped when another walk happened less than
1253    /// 5 s ago and this handle has written less than 1/16 of the budget
1254    /// since its own last walk. For hosts that evict after every burst of
1255    /// writes: a process per query would otherwise walk the whole cache
1256    /// thousands of times over a batch.
1257    pub fn evict_lazily(&self) {
1258        if !self.recently_evicted() {
1259            self.evict();
1260        }
1261    }
1262
1263    /// Whether a walk can be skipped: some process walked the cache less
1264    /// than [`EVICT_INTERVAL`] ago and this handle has written less than
1265    /// 1/[`EVICT_WRITE_DIVISOR`] of the budget since its own last walk. A
1266    /// walk reads every file's metadata (and every L2 header), which a
1267    /// process per query would otherwise repeat thousands of times over a
1268    /// batch; the overshoot is bounded, and every automatic-budget write
1269    /// still checks the free-space reserve.
1270    fn recently_evicted(&self) -> bool {
1271        let written = self.written.load(std::sync::atomic::Ordering::Relaxed);
1272        let fresh = fs::metadata(self.dir.join(EVICT_MARKER))
1273            .and_then(|m| m.modified())
1274            .ok()
1275            .and_then(|t| SystemTime::now().duration_since(t).ok())
1276            .is_some_and(|age| age < EVICT_INTERVAL);
1277        fresh && written < self.capacity_for(0) / EVICT_WRITE_DIVISOR
1278    }
1279}
1280
1281/// Touched by every eviction walk; see [`DiskCache::evict`].
1282const EVICT_MARKER: &str = ".last-evict";
1283const EVICT_INTERVAL: Duration = Duration::from_secs(5);
1284const EVICT_WRITE_DIVISOR: u64 = 16;
1285
1286/// Indexes of `items` in eviction order: entries of dead APKs first
1287/// (largest first), then L1 by `age × size` (descending), then L3 by age
1288/// (descending), then L2 by `rebuild µs / byte / (1 + age in hours)`
1289/// (ascending). Ages count from the newest item (plus one second), so clock
1290/// skew between writers cannot invert the order; ties go oldest first.
1291fn eviction_order(items: &[Item], dead: &HashSet<String>) -> Vec<usize> {
1292    let newest = items.iter().map(|i| i.mtime).max().unwrap_or(SystemTime::UNIX_EPOCH);
1293    let age_secs = |i: &Item| newest.duration_since(i.mtime).unwrap_or_default().as_secs_f64() + 1.0;
1294    let score = |i: &Item| -> (u8, f64) {
1295        if dead.contains(&i.key) {
1296            return (0, -(i.bytes as f64));
1297        }
1298        match i.tier {
1299            Tier::Dex => (1, -(age_secs(i) * i.bytes as f64)),
1300            Tier::Rendered => (2, -age_secs(i)),
1301            Tier::Derived => (3, i.cost_us as f64 / i.bytes.max(1) as f64 / (1.0 + age_secs(i) / 3600.0)),
1302        }
1303    };
1304    let mut order: Vec<usize> = (0..items.len()).collect();
1305    order.sort_by(|&a, &b| {
1306        let (sa, sb) = (score(&items[a]), score(&items[b]));
1307        sa.0.cmp(&sb.0).then(sa.1.total_cmp(&sb.1)).then(items[a].mtime.cmp(&items[b].mtime))
1308    });
1309    order
1310}
1311
1312fn remove_item(item: &Item) -> std::io::Result<()> {
1313    match item.tier {
1314        Tier::Dex => fs::remove_dir_all(&item.path),
1315        _ => fs::remove_file(&item.path),
1316    }
1317}
1318
1319/// Subdirectories of the cache root that belong to it besides APK directories.
1320const TIER_DIRS: [&str; 3] = [DERIVED_DIR, RENDERED_DIR, SOURCES_DIR];
1321
1322/// `name` with every character a cache file name may not hold replaced.
1323fn safe_name(name: &str) -> String {
1324    name.chars().map(|c| if is_key_char(c) { c } else { '_' }).collect()
1325}
1326
1327/// The key an L3 result is stored under: the producer (e.g. `findrefs`)
1328/// and its version, the L3 layout version and the query, so a tool upgrade
1329/// never serves output of the old one.
1330pub fn rendered_key(producer: &str, version: &str, query: &str) -> String {
1331    format!("{producer}\0{version}\0{RENDERED_FORMAT}\0{query}")
1332}
1333
1334/// Read-only mapping of a cache file.
1335fn map_file(file: &fs::File) -> Option<memmap2::Mmap> {
1336    // SAFETY: read-only mapping of a file renamed into place atomically; a
1337    // concurrent overwrite replaces the directory entry, not the mapped inode.
1338    unsafe { memmap2::MmapOptions::new().map(file) }.ok()
1339}
1340
1341/// The first `len` bytes of `file`, mapped as [`map_file`] does.
1342fn map_prefix(file: &fs::File, len: u64) -> Option<memmap2::Mmap> {
1343    let len = usize::try_from(len).ok()?;
1344    // SAFETY: as in `map_file`; the caller checked `len` against the file.
1345    unsafe { memmap2::MmapOptions::new().len(len).map(file) }.ok()
1346}
1347
1348/// Whether a file last modified at `mtime` may predate the current boot,
1349/// and so may hold pages a crash kept from reaching the disk. Unknown
1350/// times count as old, and so does a time in the future (a clock moved
1351/// back since): checking a sound file costs one read, trusting a damaged
1352/// one a wrong answer.
1353fn predates_boot(mtime: Option<SystemTime>, boot: Option<SystemTime>, now: SystemTime) -> bool {
1354    match (mtime, boot) {
1355        (Some(m), Some(b)) => m < b || m > now,
1356        _ => true,
1357    }
1358}
1359
1360/// When the system booted, read once per process.
1361fn boot_time() -> Option<SystemTime> {
1362    static BOOT: OnceLock<Option<SystemTime>> = OnceLock::new();
1363    *BOOT.get_or_init(read_boot_time)
1364}
1365
1366#[cfg(any(target_os = "linux", target_os = "android"))]
1367fn read_boot_time() -> Option<SystemTime> {
1368    let stat = fs::read_to_string("/proc/stat").ok()?;
1369    let secs = stat.lines().find_map(|l| l.strip_prefix("btime "))?.trim().parse().ok()?;
1370    Some(SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
1371}
1372
1373#[cfg(target_vendor = "apple")]
1374fn read_boot_time() -> Option<SystemTime> {
1375    let mut tv = libc::timeval { tv_sec: 0, tv_usec: 0 };
1376    let mut len = std::mem::size_of::<libc::timeval>();
1377    // SAFETY: `kern.boottime` is a `struct timeval`; the kernel writes at most
1378    // `len` bytes into `tv` and sets `len` to what it wrote.
1379    let rc = unsafe { libc::sysctlbyname(c"kern.boottime".as_ptr(), (&raw mut tv).cast(), &mut len, std::ptr::null_mut(), 0) };
1380    if rc != 0 || len != std::mem::size_of::<libc::timeval>() {
1381        return None;
1382    }
1383    Some(
1384        SystemTime::UNIX_EPOCH
1385            + Duration::from_secs(u64::try_from(tv.tv_sec).ok()?)
1386            + Duration::from_micros(u64::try_from(tv.tv_usec).ok()?),
1387    )
1388}
1389
1390#[cfg(windows)]
1391fn read_boot_time() -> Option<SystemTime> {
1392    // SAFETY: GetTickCount64 takes no arguments and only reads the uptime.
1393    let uptime = Duration::from_millis(unsafe { windows_sys::Win32::System::SystemInformation::GetTickCount64() });
1394    SystemTime::now().checked_sub(uptime)
1395}
1396
1397#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple", windows)))]
1398fn read_boot_time() -> Option<SystemTime> {
1399    None
1400}
1401
1402/// Rebuild cost recorded in an L2 file's header (0 when unreadable).
1403fn derived_cost_us(path: &Path) -> u64 {
1404    use std::io::Read;
1405    let mut header = [0u8; 24];
1406    match fs::File::open(path).and_then(|mut f| f.read_exact(&mut header)) {
1407        Ok(()) if &header[..8] == DERIVED_MAGIC => u64::from_le_bytes(header[16..24].try_into().expect("8 bytes")),
1408        _ => 0,
1409    }
1410}
1411
1412/// `size\nmtime\npath` of an APK, as [`DiskCache::note_source`] records it.
1413fn source_stamp(apk: &Path) -> Option<String> {
1414    let abs = fs::canonicalize(apk).ok()?;
1415    let meta = fs::metadata(&abs).ok()?;
1416    let mtime = meta.modified().ok()?.duration_since(SystemTime::UNIX_EPOCH).ok()?.as_nanos();
1417    let path = abs.to_str()?;
1418    (!path.contains('\n')).then(|| format!("{}\n{mtime}\n{path}", meta.len()))
1419}
1420
1421/// `path` (a file or a directory) opened so its times can be set: Unix
1422/// sets them through any handle the owner holds, Windows only through one
1423/// with FILE_WRITE_ATTRIBUTES access (and opens a directory only with
1424/// FILE_FLAG_BACKUP_SEMANTICS).
1425fn open_for_times(path: &Path) -> std::io::Result<fs::File> {
1426    #[cfg(windows)]
1427    {
1428        use std::os::windows::fs::OpenOptionsExt;
1429        const FILE_WRITE_ATTRIBUTES: u32 = 0x0100;
1430        const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1431        fs::OpenOptions::new().access_mode(FILE_WRITE_ATTRIBUTES).custom_flags(FILE_FLAG_BACKUP_SEMANTICS).open(path)
1432    }
1433    #[cfg(not(windows))]
1434    {
1435        fs::File::open(path)
1436    }
1437}
1438
1439/// Set the modification time of `file` (opened read-only at `path`) to now.
1440fn touch_file(file: &fs::File, path: &Path) -> std::io::Result<()> {
1441    if cfg!(windows) { open_for_times(path)?.set_modified(SystemTime::now()) } else { file.set_modified(SystemTime::now()) }
1442}
1443
1444fn touch_dir(dir: &Path) -> std::io::Result<()> {
1445    let now = SystemTime::now();
1446    if open_for_times(dir).and_then(|f| f.set_modified(now)).is_ok() {
1447        return Ok(());
1448    }
1449    // Portable fallback: update the directory mtime by creating and removing a marker.
1450    let marker = dir.join(".touch");
1451    fs::File::create(&marker)?;
1452    fs::remove_file(&marker)
1453}
1454
1455struct Slot<T> {
1456    value: Arc<T>,
1457    size: usize,
1458    /// Logical clock of the last access; the smallest is the LRU victim.
1459    tick: u64,
1460}
1461
1462/// Byte-bounded LRU of inflated entries plus any per-entry state the host
1463/// wants to keep alongside (`T`).
1464pub struct MemoryCache<T> {
1465    cap_bytes: usize,
1466    used: usize,
1467    /// Bytes the host holds elsewhere against the same capacity.
1468    reserved: usize,
1469    clock: u64,
1470    map: HashMap<ApkKey, HashMap<String, Slot<T>>>,
1471}
1472
1473impl<T> MemoryCache<T> {
1474    /// An empty cache holding at most `cap_bytes` bytes of entries.
1475    pub fn new(cap_bytes: usize) -> Self {
1476        MemoryCache { cap_bytes, used: 0, reserved: 0, clock: 0, map: HashMap::new() }
1477    }
1478
1479    fn tick(&mut self) -> u64 {
1480        self.clock += 1;
1481        self.clock
1482    }
1483
1484    /// The entry cached under `entry_name`, marked most recently used.
1485    pub fn get(&mut self, key: &ApkKey, entry_name: &str) -> Option<Arc<T>> {
1486        let tick = self.tick();
1487        let slot = self.map.get_mut(key)?.get_mut(entry_name)?;
1488        slot.tick = tick;
1489        Some(Arc::clone(&slot.value))
1490    }
1491
1492    /// Cache `value` under `entry_name`, charged at `size` bytes, replacing
1493    /// any previous entry and evicting least recently used ones past the
1494    /// capacity.
1495    pub fn insert(&mut self, key: &ApkKey, entry_name: &str, value: Arc<T>, size: usize) {
1496        let tick = self.tick();
1497        let per_apk = self.map.entry(key.clone()).or_default();
1498        if let Some(old) = per_apk.insert(entry_name.to_string(), Slot { value, size, tick }) {
1499            self.used -= old.size;
1500        }
1501        self.used += size;
1502        self.evict_over_cap();
1503    }
1504
1505    /// Re-charge the entry cached under `entry_name` at `size_of` its current
1506    /// value, e.g. once per-entry state built after insertion has grown it,
1507    /// and evict least recently used entries if that exceeds the capacity.
1508    /// The value is measured under the cache's lock, whatever replaced the
1509    /// one a caller last saw, so the charge always matches what the slot
1510    /// holds. Returns whether the charge changed.
1511    pub fn recharge(&mut self, key: &ApkKey, entry_name: &str, size_of: impl FnOnce(&T) -> usize) -> bool {
1512        let Some(slot) = self.map.get_mut(key).and_then(|m| m.get_mut(entry_name)) else { return false };
1513        let size = size_of(&slot.value);
1514        let old = std::mem::replace(&mut slot.size, size);
1515        if old == size {
1516            return false;
1517        }
1518        self.used = self.used - old + size;
1519        self.evict_over_cap();
1520        true
1521    }
1522
1523    /// Swap the entry cached under `entry_name` for `new` when it is still
1524    /// `current`, keeping its size and recency. Returns whether it swapped.
1525    pub fn replace(&mut self, key: &ApkKey, entry_name: &str, current: &Arc<T>, new: Arc<T>) -> bool {
1526        match self.map.get_mut(key).and_then(|m| m.get_mut(entry_name)) {
1527            Some(slot) if Arc::ptr_eq(&slot.value, current) => {
1528                slot.value = new;
1529                true
1530            }
1531            _ => false,
1532        }
1533    }
1534
1535    /// Size the entry cached under `entry_name` is charged at.
1536    pub fn charged_bytes(&self, key: &ApkKey, entry_name: &str) -> Option<usize> {
1537        self.map.get(key)?.get(entry_name).map(|s| s.size)
1538    }
1539
1540    /// Evict least recently used entries until under capacity (always
1541    /// keeping the last one: a single entry larger than the cap stays usable).
1542    fn evict_over_cap(&mut self) {
1543        while self.used + self.reserved > self.cap_bytes && self.len() > 1 {
1544            let Some((k, name)) = self.lru() else { break };
1545            if let Some(per_apk) = self.map.get_mut(&k) {
1546                if let Some(old) = per_apk.remove(&name) {
1547                    self.used -= old.size;
1548                }
1549                if per_apk.is_empty() {
1550                    self.map.remove(&k);
1551                }
1552            }
1553        }
1554    }
1555
1556    fn lru(&self) -> Option<(ApkKey, String)> {
1557        self.map
1558            .iter()
1559            .flat_map(|(k, per)| per.iter().map(move |(n, s)| (s.tick, k, n)))
1560            .min_by_key(|(t, _, _)| *t)
1561            .map(|(_, k, n)| (k.clone(), n.clone()))
1562    }
1563
1564    /// Bytes charged for the cached entries (the reserve not included).
1565    pub fn used_bytes(&self) -> usize {
1566        self.used
1567    }
1568
1569    /// Bytes the entries and the reserve may take together.
1570    pub fn capacity_bytes(&self) -> usize {
1571        self.cap_bytes
1572    }
1573
1574    /// Bytes counted against the capacity besides the entries
1575    /// ([`MemoryCache::set_reserved`]).
1576    pub fn reserved_bytes(&self) -> usize {
1577        self.reserved
1578    }
1579
1580    /// Count `bytes` the host holds elsewhere against the capacity, evicting
1581    /// least recently used entries to make room.
1582    pub fn set_reserved(&mut self, bytes: usize) {
1583        self.reserved = bytes;
1584        self.evict_over_cap();
1585    }
1586
1587    /// Change the capacity, evicting least recently used entries if the
1588    /// cache now holds more.
1589    pub fn set_capacity(&mut self, cap_bytes: usize) {
1590        self.cap_bytes = cap_bytes;
1591        self.evict_over_cap();
1592    }
1593
1594    /// The entry cached under `entry_name`, without touching its recency.
1595    pub fn peek(&self, key: &ApkKey, entry_name: &str) -> Option<Arc<T>> {
1596        self.map.get(key)?.get(entry_name).map(|s| Arc::clone(&s.value))
1597    }
1598
1599    /// Every cached value, in no particular order (recency is not touched).
1600    pub fn values(&self) -> impl Iterator<Item = &Arc<T>> {
1601        self.map.values().flat_map(|per| per.values().map(|s| &s.value))
1602    }
1603
1604    /// Number of cached entries, over every APK.
1605    pub fn len(&self) -> usize {
1606        self.map.values().map(HashMap::len).sum()
1607    }
1608
1609    /// Whether no entry is cached.
1610    pub fn is_empty(&self) -> bool {
1611        self.len() == 0
1612    }
1613
1614    /// Drop every entry cached for `key` and return the bytes released.
1615    pub fn remove_apk(&mut self, key: &ApkKey) -> usize {
1616        let freed: usize = self.map.remove(key).map(|per| per.values().map(|s| s.size).sum()).unwrap_or(0);
1617        self.used -= freed;
1618        freed
1619    }
1620
1621    /// Drop every entry (the reserve is kept).
1622    pub fn clear(&mut self) {
1623        self.map.clear();
1624        self.used = 0;
1625    }
1626}
1627
1628#[cfg(test)]
1629mod tests {
1630    use super::*;
1631
1632    #[test]
1633    fn content_keys_are_stable_and_distinct() {
1634        let mut sha = [0u8; 32];
1635        sha[0] = 0x3a;
1636        sha[7] = 0x7f;
1637        let key = ApkKey::for_content("entry.hap/ets/modules.abc", &sha);
1638        assert_eq!(key.as_str(), "modules-3a0000000000007f");
1639        assert!(is_cache_dir_name(key.as_str()), "evicted and cleared like an APK's directory");
1640        assert_eq!(ApkKey::for_content("other/dir/modules.abc", &sha), key, "the place does not count");
1641        let mut other = sha;
1642        other[1] = 1;
1643        assert_ne!(ApkKey::for_content("entry.hap/ets/modules.abc", &other), key);
1644        assert_eq!(ApkKey::for_content("a b/w*idgets.abc", &sha).as_str(), "widgets-3a0000000000007f");
1645        assert_eq!(key.locate("ets/modules.abc"), (&key, "ets/modules.abc"));
1646    }
1647
1648    #[test]
1649    fn regions_of_a_file_map_at_any_offset() {
1650        let dir = tempfile::tempdir().unwrap();
1651        let path = dir.path().join("app.apk");
1652        let bytes: Vec<u8> = (0..20_000u32).map(|i| (i % 251) as u8).collect();
1653        fs::write(&path, &bytes).unwrap();
1654        for (off, len) in [(0, 10), (4097, 3000), (12_345, 7655), (19_999, 1)] {
1655            let d = DexData::map_region(&path, off as u64, len).unwrap();
1656            assert!(matches!(d, DexData::Mapped(_)));
1657            assert_eq!(&*d, &bytes[off..off + len]);
1658        }
1659        assert!(DexData::map_region(&path, 19_999, 2).is_err());
1660        assert!(DexData::map_region(&dir.path().join("missing"), 0, 1).is_err());
1661    }
1662
1663    #[test]
1664    fn memory_remove_apk_releases_only_that_apk() {
1665        let (a, b) = (ApkKey::from_id("a-0123456789abcdef".into()), ApkKey::from_id("b-0123456789abcdef".into()));
1666        let mut mem = MemoryCache::new(1 << 20);
1667        mem.insert(&a, "classes.dex", Arc::new(1), 100);
1668        mem.insert(&a, "classes2.dex", Arc::new(2), 50);
1669        mem.insert(&b, "classes.dex", Arc::new(3), 7);
1670        assert_eq!(mem.remove_apk(&a), 150);
1671        assert_eq!(mem.remove_apk(&a), 0);
1672        assert_eq!((mem.used_bytes(), mem.len()), (7, 1));
1673        assert!(mem.get(&b, "classes.dex").is_some());
1674    }
1675
1676    #[test]
1677    fn disk_roundtrip_and_clear() {
1678        let dir = tempfile::tempdir().unwrap();
1679        let cache = DiskCache::open(dir.path()).with_capacity(1 << 20);
1680        let key = ApkKey::from_id("app-0123456789abcdef".into());
1681        assert!(cache.get(&key, "classes.dex", 3).is_none());
1682        cache.put(&key, "classes.dex", b"abc").unwrap();
1683        assert_eq!(&*cache.get(&key, "classes.dex", 3).unwrap(), b"abc");
1684        assert!(cache.get(&key, "classes.dex", 4).is_none());
1685        let (dirs, files, bytes) = cache.stats();
1686        assert_eq!((dirs, files, bytes), (1, 1, 3 + L1_TRAILER));
1687        assert_eq!(cache.clear().unwrap(), 3 + L1_TRAILER);
1688        assert!(cache.get(&key, "classes.dex", 3).is_none());
1689    }
1690
1691    const GIB: u64 = 1 << 30;
1692    const MIB: u64 = 1 << 20;
1693
1694    #[test]
1695    fn auto_disk_budget_follows_free_space() {
1696        // Unknown file system (Windows): the old fixed cap.
1697        assert_eq!(AutoDisk::default().bytes_for(None, 0), AUTO_DISK_UNKNOWN);
1698        // Large disk: capped at 32 GiB.
1699        assert_eq!(AutoDisk::default().bytes_for(Some((500 * GIB, 1000 * GIB)), 0), AutoDisk::CEILING);
1700        // A lower share and ceiling.
1701        let tight = AutoDisk { percent: 10, ceiling: 5 * GIB };
1702        assert_eq!(tight.bytes_for(Some((100 * GIB, 500 * GIB)), 0), 5 * GIB);
1703        assert_eq!(tight.bytes_for(Some((20 * GIB, 50 * GIB)), 0), 20 * GIB / 100 * 10);
1704        assert_eq!(tight.bytes_for(None, 0), 4 * GIB);
1705        // 100 GiB free: 20 GiB, past the old 4 GiB cap.
1706        assert_eq!(AutoDisk::default().bytes_for(Some((100 * GIB, 500 * GIB)), 0), 100 * GIB / 100 * 20);
1707        // Small disk: 20 % of free + own bytes.
1708        assert_eq!(AutoDisk::default().bytes_for(Some((10 * GIB, 20 * GIB)), 0), 10 * GIB / 100 * 20);
1709        assert_eq!(AutoDisk::default().bytes_for(Some((9 * GIB, 20 * GIB)), GIB), 10 * GIB / 100 * 20);
1710        // Never eats into the reserve (1 GiB here).
1711        assert_eq!(AutoDisk::default().bytes_for(Some((GIB + 100 * MIB, 16 * GIB)), 0), 100 * MIB);
1712        assert_eq!(AutoDisk::default().bytes_for(Some((GIB, 16 * GIB)), 0), 0);
1713    }
1714
1715    #[test]
1716    fn auto_disk_budget_shrinks_below_usage_on_a_nearly_full_disk() {
1717        // 100 GiB disk: 5 GiB reserve. 6 GiB cached, 0.5 GiB free: 20 % of
1718        // the 6.5 GiB the cache could hold, well below what it holds now.
1719        assert_eq!(AutoDisk::default().bytes_for(Some((GIB / 2, 100 * GIB)), 6 * GIB), (6 * GIB + GIB / 2) / 100 * 20);
1720        // 5.5 GiB cached, 0.5 GiB free: the reserve binds (1 GiB < 20 % of
1721        // 6 GiB), giving back the 4.5 GiB shortfall.
1722        assert_eq!(AutoDisk::default().bytes_for(Some((GIB / 2, 100 * GIB)), 5 * GIB + GIB / 2), GIB);
1723        // Nothing left to give: evict everything.
1724        assert_eq!(AutoDisk::default().bytes_for(Some((GIB / 2, 100 * GIB)), 3 * GIB), 0);
1725        assert_eq!(auto_disk_reserve(100 * GIB), 5 * GIB);
1726        assert_eq!(auto_disk_reserve(4 * GIB), GIB);
1727    }
1728
1729    #[test]
1730    fn explicit_disk_capacity_wins() {
1731        let dir = tempfile::tempdir().unwrap();
1732        let cache = DiskCache::open(dir.path()).with_capacity_mode(DiskCapacity::default()).with_capacity(7);
1733        assert_eq!(cache.capacity_mode(), DiskCapacity::Fixed(7));
1734        assert_eq!(cache.capacity_for(1 << 40), 7);
1735        let b = cache.budget();
1736        assert_eq!((b.capacity, b.cap_bytes, b.used), (DiskCapacity::Fixed(7), 7, 0));
1737        // The file system of a real directory is known on Unix.
1738        assert_eq!(b.space.is_some(), cfg!(unix));
1739        let auto = DiskCache::open(dir.path()).with_capacity_mode(DiskCapacity::default());
1740        // Compare against the space the budget itself measured: other tests write to the
1741        // same file system concurrently, so a second `disk_space` call may differ.
1742        let ab = auto.budget();
1743        assert_eq!(ab.cap_bytes, AutoDisk::default().bytes_for(ab.space, ab.used));
1744        // A directory that does not exist yet is measured on its parent.
1745        assert_eq!(disk_space(&dir.path().join("a/b")).is_some(), cfg!(unix));
1746    }
1747
1748    #[test]
1749    fn eviction_follows_a_lowered_capacity() {
1750        let dir = tempfile::tempdir().unwrap();
1751        let keys: Vec<ApkKey> = (0..3).map(|i| ApkKey::from_id(format!("app-000000000000000{i}"))).collect();
1752        let cache = DiskCache::open(dir.path()).with_capacity(100);
1753        for (i, k) in keys.iter().enumerate() {
1754            cache.put(k, "classes.dex", &[0; 10]).unwrap();
1755            let t = SystemTime::now() + Duration::from_secs(10 * i as u64);
1756            open_for_times(&dir.path().join(k.as_str())).unwrap().set_modified(t).unwrap();
1757        }
1758        cache.evict();
1759        assert_eq!(cache.stats(), (3, 3, 3 * (10 + L1_TRAILER)));
1760        // Same directory, smaller budget (as when the free space drops): the
1761        // next eviction pass trims to it, oldest first.
1762        let cache = cache.with_capacity(25);
1763        cache.evict();
1764        assert_eq!(cache.stats(), (1, 1, 10 + L1_TRAILER));
1765        assert!(cache.get(&keys[2], "classes.dex", 10).is_some());
1766    }
1767
1768    #[test]
1769    fn cache_dir_names_are_recognised() {
1770        assert!(is_cache_dir_name("app-0123456789abcdef"));
1771        assert!(is_cache_dir_name("-0123456789abcdef")); // empty stem
1772        assert!(is_cache_dir_name("my.app_v2-0123456789abcdef"));
1773        assert!(!is_cache_dir_name("Documents"));
1774        assert!(!is_cache_dir_name("app-0123456789ABCDEF")); // upper-case hex
1775        assert!(!is_cache_dir_name("app-0123456789abcde")); // 15 digits
1776        assert!(!is_cache_dir_name("app 1-0123456789abcdef")); // space
1777        assert!(!is_cache_dir_name(&format!("{}-0123456789abcdef", "a".repeat(41))));
1778    }
1779
1780    #[test]
1781    fn unrelated_directories_survive_evict_and_clear() {
1782        let dir = tempfile::tempdir().unwrap();
1783        let cache = DiskCache::open(dir.path()).with_capacity(4);
1784        let key = ApkKey::from_id("app-0123456789abcdef".into());
1785        // The first write tags the (empty) directory ...
1786        cache.put(&key, "classes.dex", &[0; 8]).unwrap();
1787        assert!(dir.path().join(CACHEDIR_TAG).is_file());
1788        // ... and whatever lands next to the cache afterwards is left alone.
1789        let thesis = dir.path().join("my-thesis");
1790        fs::create_dir(&thesis).unwrap();
1791        fs::write(thesis.join("chapter1.txt"), vec![0u8; 100]).unwrap();
1792        fs::write(dir.path().join("notes.txt"), b"keep").unwrap();
1793        // Only the cache directory is counted, even though the thesis is bigger.
1794        assert_eq!(cache.stats(), (1, 1, 8 + L1_TRAILER));
1795        cache.evict();
1796        assert!(thesis.join("chapter1.txt").is_file());
1797        assert!(cache.get(&key, "classes.dex", 8).is_none());
1798        cache.put(&key, "classes.dex", &[0; 8]).unwrap();
1799        assert_eq!(cache.clear().unwrap(), 8 + L1_TRAILER);
1800        assert!(thesis.join("chapter1.txt").is_file());
1801        assert!(dir.path().join("notes.txt").is_file());
1802        assert!(dir.path().join(CACHEDIR_TAG).is_file());
1803        assert!(dir.path().is_dir());
1804        assert_eq!(cache.stats(), (0, 0, 0));
1805    }
1806
1807    #[test]
1808    fn foreign_directories_are_refused_and_never_tagged() {
1809        let dir = tempfile::tempdir().unwrap();
1810        let cache = DiskCache::open(dir.path()).with_capacity(1);
1811        // A cache-shaped subdirectory next to unrelated content: not ours.
1812        let lookalike = dir.path().join("app-0123456789abcdef");
1813        fs::create_dir(&lookalike).unwrap();
1814        fs::write(lookalike.join("classes.dex"), [0; 16]).unwrap();
1815        fs::write(dir.path().join("notes.txt"), b"keep").unwrap();
1816        cache.evict();
1817        assert!(lookalike.join("classes.dex").is_file());
1818        assert!(matches!(cache.clear(), Err(CacheError::NotACacheDir { .. })));
1819        assert!(lookalike.join("classes.dex").is_file());
1820        // Writing refuses too, so no tag is planted that would make backup
1821        // tools skip the user's directory.
1822        let key = ApkKey::from_id("other-fedcba9876543210".into());
1823        assert!(matches!(cache.put(&key, "classes.dex", &[0; 4]), Err(CacheError::NotACacheDir { .. })));
1824        assert!(!dir.path().join(CACHEDIR_TAG).exists());
1825        assert!(!dir.path().join("other-fedcba9876543210").exists());
1826        // An empty or missing directory is fine.
1827        let empty = tempfile::tempdir().unwrap();
1828        assert_eq!(DiskCache::open(empty.path()).clear().unwrap(), 0);
1829        assert_eq!(DiskCache::open(empty.path().join("missing")).clear().unwrap(), 0);
1830    }
1831
1832    #[test]
1833    fn a_directory_holding_only_spilled_replies_is_still_a_cache() {
1834        // The MCP server may spill a reply before the first DEX is cached.
1835        let dir = tempfile::tempdir().unwrap();
1836        fs::create_dir(dir.path().join(SPILL_DIR)).unwrap();
1837        fs::write(dir.path().join(SPILL_DIR).join("ab.txt"), b"reply").unwrap();
1838        let cache = DiskCache::open(dir.path());
1839        assert!(cache.check_usable().is_ok());
1840        cache.put(&ApkKey::from_id("app-0123456789abcdef".into()), "classes.dex", &[0; 4]).unwrap();
1841        cache.clear().unwrap();
1842        assert!(!dir.path().join(SPILL_DIR).exists(), "clear removes spilled replies too");
1843    }
1844
1845    #[test]
1846    fn decompiler_working_directories_keep_a_cache_usable() {
1847        // A backend may start in `decompilers/<name>/` before anything is cached.
1848        let dir = tempfile::tempdir().unwrap();
1849        fs::create_dir_all(dir.path().join(DECOMPILERS_DIR).join("jeb")).unwrap();
1850        let cache = DiskCache::open(dir.path());
1851        assert!(cache.check_usable().is_ok());
1852        cache.put(&ApkKey::from_id("app-0123456789abcdef".into()), "classes.dex", &[0; 4]).unwrap();
1853        cache.clear().unwrap();
1854        assert!(dir.path().join(DECOMPILERS_DIR).join("jeb").exists(), "a backend's working directory is not the cache's to clear");
1855    }
1856
1857    #[test]
1858    fn legacy_cache_directories_are_adopted() {
1859        // Written by a build that predates the tag: only APK directories.
1860        let dir = tempfile::tempdir().unwrap();
1861        let cache = DiskCache::open(dir.path()).with_capacity(1);
1862        let legacy = dir.path().join("app-0123456789abcdef");
1863        fs::create_dir(&legacy).unwrap();
1864        fs::write(legacy.join("classes.dex"), [0; 16]).unwrap();
1865        assert_eq!(cache.stats(), (1, 1, 16));
1866        cache.evict();
1867        assert!(!legacy.exists(), "over capacity: evicted");
1868        fs::create_dir(&legacy).unwrap();
1869        fs::write(legacy.join("classes.dex"), [0; 16]).unwrap();
1870        assert_eq!(cache.clear().unwrap(), 16);
1871        // A put adopts it for good.
1872        let key = ApkKey::from_id("app-0123456789abcdef".into());
1873        cache.put(&key, "classes.dex", &[0; 4]).unwrap();
1874        assert!(dir.path().join(CACHEDIR_TAG).is_file());
1875    }
1876
1877    #[test]
1878    fn a_tag_that_is_not_a_file_is_not_a_tag() {
1879        let dir = tempfile::tempdir().unwrap();
1880        let cache = DiskCache::open(dir.path()).with_capacity(1);
1881        fs::create_dir(dir.path().join(CACHEDIR_TAG)).unwrap();
1882        fs::write(dir.path().join("notes.txt"), b"keep").unwrap();
1883        let key = ApkKey::from_id("app-0123456789abcdef".into());
1884        assert!(matches!(cache.put(&key, "classes.dex", &[0; 4]), Err(CacheError::NotACacheDir { .. })));
1885        assert!(matches!(cache.clear(), Err(CacheError::NotACacheDir { .. })));
1886    }
1887
1888    #[test]
1889    fn dot_files_make_a_directory_foreign() {
1890        // A checkout or config directory can hold nothing but dot-files.
1891        let dir = tempfile::tempdir().unwrap();
1892        let cache = DiskCache::open(dir.path());
1893        fs::write(dir.path().join(".gitignore"), b"*\n").unwrap();
1894        assert!(matches!(cache.check_usable(), Err(CacheError::NotACacheDir { .. })));
1895        let key = ApkKey::from_id("app-0123456789abcdef".into());
1896        assert!(matches!(cache.put(&key, "classes.dex", &[0; 4]), Err(CacheError::NotACacheDir { .. })));
1897        assert!(!dir.path().join(CACHEDIR_TAG).exists());
1898        // Finder metadata next to a legacy cache does not.
1899        let legacy = tempfile::tempdir().unwrap();
1900        fs::create_dir(legacy.path().join("app-0123456789abcdef")).unwrap();
1901        fs::write(legacy.path().join(".DS_Store"), b"").unwrap();
1902        assert!(DiskCache::open(legacy.path()).check_usable().is_ok());
1903    }
1904
1905    #[test]
1906    fn memory_lru_evicts_by_bytes() {
1907        let mut m: MemoryCache<Vec<u8>> = MemoryCache::new(10);
1908        let k = ApkKey::from_id("k".into());
1909        m.insert(&k, "a", Arc::new(vec![0; 4]), 4);
1910        m.insert(&k, "b", Arc::new(vec![0; 4]), 4);
1911        assert!(m.get(&k, "a").is_some()); // a is now most recent
1912        m.insert(&k, "c", Arc::new(vec![0; 4]), 4); // evicts b
1913        assert!(m.get(&k, "b").is_none());
1914        assert!(m.get(&k, "a").is_some());
1915        assert_eq!(m.used_bytes(), 8);
1916        assert_eq!(m.len(), 2);
1917        // Re-inserting an entry replaces its accounting.
1918        m.insert(&k, "a", Arc::new(vec![0; 2]), 2);
1919        assert_eq!(m.used_bytes(), 6);
1920        m.clear();
1921        assert!(m.is_empty());
1922    }
1923
1924    #[test]
1925    fn memory_lru_recharge_measures_the_current_value_and_evicts() {
1926        let mut m: MemoryCache<Vec<u8>> = MemoryCache::new(10);
1927        let k = ApkKey::from_id("k".into());
1928        m.insert(&k, "a", Arc::new(vec![0; 4]), 4);
1929        m.insert(&k, "b", Arc::new(vec![0; 7]), 4);
1930        // Growing b past the cap evicts a (least recently used).
1931        assert!(m.recharge(&k, "b", Vec::len));
1932        assert_eq!(m.used_bytes(), 7);
1933        assert!(m.get(&k, "a").is_none());
1934        assert_eq!(m.charged_bytes(&k, "b"), Some(7));
1935        assert!(!m.recharge(&k, "b", Vec::len));
1936        // Whatever replaced the entry is what gets measured.
1937        m.insert(&k, "b", Arc::new(vec![1; 9]), 4);
1938        assert!(m.recharge(&k, "b", Vec::len));
1939        assert_eq!(m.charged_bytes(&k, "b"), Some(9));
1940        assert!(!m.recharge(&k, "missing", Vec::len));
1941        // The last entry is kept even when it alone exceeds the cap.
1942        assert!(m.recharge(&k, "b", |_| 50));
1943        assert_eq!((m.len(), m.used_bytes()), (1, 50));
1944    }
1945
1946    #[test]
1947    fn memory_lru_capacity_can_shrink_and_peek_keeps_recency() {
1948        let mut m: MemoryCache<u8> = MemoryCache::new(10);
1949        let k = ApkKey::from_id("k".into());
1950        m.insert(&k, "a", Arc::new(1), 4);
1951        m.insert(&k, "b", Arc::new(2), 4);
1952        assert_eq!(*m.peek(&k, "a").unwrap(), 1); // a stays least recently used
1953        m.set_capacity(5);
1954        assert_eq!((m.capacity_bytes(), m.used_bytes()), (5, 4));
1955        assert!(m.peek(&k, "a").is_none());
1956        assert!(m.peek(&k, "b").is_some());
1957    }
1958
1959    #[test]
1960    fn memory_lru_reserved_bytes_make_room_under_the_cap() {
1961        let mut m: MemoryCache<u8> = MemoryCache::new(10);
1962        let k = ApkKey::from_id("k".into());
1963        m.insert(&k, "a", Arc::new(1), 4);
1964        m.insert(&k, "b", Arc::new(2), 4);
1965        m.set_reserved(3);
1966        assert_eq!((m.used_bytes(), m.reserved_bytes()), (4, 3), "a evicted to fit 3 reserved bytes");
1967        assert!(m.peek(&k, "a").is_none());
1968        m.insert(&k, "c", Arc::new(3), 3);
1969        assert_eq!(m.used_bytes(), 7);
1970        m.set_reserved(0);
1971        m.insert(&k, "d", Arc::new(4), 3);
1972        assert_eq!(m.used_bytes(), 10, "nothing reserved: the whole cap holds entries");
1973    }
1974
1975    #[test]
1976    fn memory_lru_replace_keeps_size_and_recency() {
1977        let mut m: MemoryCache<Vec<u8>> = MemoryCache::new(10);
1978        let k = ApkKey::from_id("k".into());
1979        let a = Arc::new(vec![0; 4]);
1980        m.insert(&k, "a", Arc::clone(&a), 4);
1981        m.insert(&k, "b", Arc::new(vec![0; 4]), 4);
1982        assert!(m.get(&k, "a").is_some()); // a most recent
1983        let a2 = Arc::new(vec![9; 4]);
1984        assert!(m.replace(&k, "a", &a, Arc::clone(&a2)));
1985        assert!(!m.replace(&k, "a", &a, Arc::new(vec![])), "stale handle");
1986        assert!(!m.replace(&k, "missing", &a, Arc::new(vec![])));
1987        assert_eq!(m.used_bytes(), 8);
1988        m.insert(&k, "c", Arc::new(vec![0; 4]), 4); // evicts b, not the replaced a
1989        assert!(Arc::ptr_eq(&m.get(&k, "a").unwrap(), &a2));
1990        assert!(m.get(&k, "b").is_none());
1991    }
1992
1993    #[test]
1994    fn fnv_lanes_match_scalar() {
1995        fn scalar(bytes: &[u8]) -> u64 {
1996            let mut h: u64 = 0xcbf2_9ce4_8422_2325;
1997            for &b in bytes {
1998                h ^= b as u64;
1999                h = h.wrapping_mul(0x0100_0000_01b3);
2000            }
2001            h
2002        }
2003        // The lane variant is a different function on purpose; it only has to
2004        // be stable and spread inputs. Check it distinguishes nearby inputs.
2005        let a = fnv64(b"PK\x01\x02classes.dex");
2006        let b = fnv64(b"PK\x01\x02classes2.dex");
2007        assert_ne!(a, b);
2008        assert_ne!(fnv64(b""), fnv64(b"\0"));
2009        assert_eq!(fnv64(b"abc"), scalar(b"abc")); // short inputs take the scalar path
2010    }
2011
2012    fn set_mtime(path: &Path, secs_from_now: i64) {
2013        let t = if secs_from_now >= 0 {
2014            SystemTime::now() + Duration::from_secs(secs_from_now as u64)
2015        } else {
2016            SystemTime::now() - Duration::from_secs((-secs_from_now) as u64)
2017        };
2018        open_for_times(path).unwrap().set_modified(t).unwrap();
2019    }
2020
2021    #[test]
2022    fn files_are_checked_when_they_predate_the_boot() {
2023        let now = SystemTime::now();
2024        let boot = Some(now - Duration::from_secs(60));
2025        assert!(predates_boot(Some(now - Duration::from_secs(61)), boot, now));
2026        assert!(!predates_boot(Some(now - Duration::from_secs(59)), boot, now));
2027        // A clock moved back, an unknown boot or mtime: checked.
2028        assert!(predates_boot(Some(now + Duration::from_secs(5)), boot, now));
2029        assert!(predates_boot(Some(now), None, now));
2030        assert!(predates_boot(None, boot, now));
2031    }
2032
2033    /// A cache whose system booted a minute ago.
2034    fn booted_cache(dir: &Path) -> DiskCache {
2035        let mut cache = DiskCache::open(dir).with_capacity(1 << 20);
2036        cache.boot = Some(SystemTime::now() - Duration::from_secs(60));
2037        cache
2038    }
2039
2040    /// Overwrite `damage` (offset, bytes) and cut the file to `cut`, then
2041    /// make it look written before the boot of [`booted_cache`].
2042    fn age(path: &Path, damage: Option<(u64, &[u8])>, cut: Option<u64>) {
2043        let f = fs::OpenOptions::new().write(true).open(path).unwrap();
2044        if let Some((at, bytes)) = damage {
2045            (&f).seek(SeekFrom::Start(at)).unwrap();
2046            (&f).write_all(bytes).unwrap();
2047        }
2048        if let Some(len) = cut {
2049            f.set_len(len).unwrap();
2050        }
2051        f.set_modified(SystemTime::now() - Duration::from_secs(120)).unwrap();
2052    }
2053
2054    #[test]
2055    fn l1_files_from_an_earlier_boot_are_checked() {
2056        let dir = tempfile::tempdir().unwrap();
2057        let cache = booted_cache(dir.path());
2058        let key = ApkKey::from_id("app-0123456789abcdef".into());
2059        let data: Vec<u8> = (0..4096u32).map(|i| i as u8).collect();
2060        let path = cache.entry_path(&key, "classes.dex");
2061        let n = data.len() as u64;
2062
2063        // Sound: served, and marked checked for this boot.
2064        cache.put(&key, "classes.dex", &data).unwrap();
2065        age(&path, None, None);
2066        assert_eq!(&*cache.get(&key, "classes.dex", n).unwrap(), &data[..]);
2067        assert!(!cache.predates_boot(&fs::metadata(&path).unwrap()));
2068
2069        // Zeroed pages, a flipped byte: deleted and missed.
2070        let flipped = [!data[7]];
2071        for (at, bytes) in [(1024, &[0u8; 1024][..]), (7, &flipped[..])] {
2072            cache.put(&key, "classes.dex", &data).unwrap();
2073            age(&path, Some((at, bytes)), None);
2074            assert!(cache.get(&key, "classes.dex", n).is_none(), "damage at {at}");
2075            assert!(!path.exists(), "damage at {at}");
2076        }
2077        // Cut short (looked up without a declared size, so the length alone
2078        // cannot tell): the trailer is gone.
2079        for cut in [n, n - 100] {
2080            cache.put(&key, "classes.dex", &data).unwrap();
2081            age(&path, None, Some(cut));
2082            assert!(cache.get(&key, "classes.dex", 0).is_none(), "cut to {cut}");
2083            assert!(!path.exists(), "cut to {cut}");
2084        }
2085
2086        // Written during this boot: trusted without a check.
2087        cache.put(&key, "classes.dex", &data).unwrap();
2088        let f = fs::OpenOptions::new().write(true).open(&path).unwrap();
2089        (&f).write_all(&[!data[0]]).unwrap();
2090        f.set_modified(SystemTime::now()).unwrap();
2091        assert_eq!(cache.get(&key, "classes.dex", n).unwrap()[0], !data[0]);
2092    }
2093
2094    #[test]
2095    fn l2_and_l3_files_carry_checksums() {
2096        let dir = tempfile::tempdir().unwrap();
2097        let cache = booted_cache(dir.path());
2098        let key = ApkKey::from_id("app-0123456789abcdef".into());
2099        let path = cache.derived_path(&key, "classes.dex", "locators", 1);
2100        let flip = DERIVED_HEADER as u64 + 3;
2101        cache.put_derived(&key, "classes.dex", "locators", 1, Duration::from_millis(7), b"tables").unwrap();
2102        age(&path, None, None);
2103        assert_eq!(&*cache.get_derived(&key, "classes.dex", "locators", 1).unwrap(), b"tables");
2104        age(&path, Some((flip, b"X")), None);
2105        assert!(cache.get_derived(&key, "classes.dex", "locators", 1).is_none());
2106        assert!(!path.exists());
2107
2108        // L3 is checked on every read.
2109        cache.put_rendered(&key, "q", b"rows").unwrap();
2110        assert_eq!(cache.get_rendered(&key, "q").unwrap(), b"rows");
2111        let path = cache.rendered_path(&key, "q");
2112        let len = fs::metadata(&path).unwrap().len();
2113        let f = fs::OpenOptions::new().write(true).open(&path).unwrap();
2114        (&f).seek(SeekFrom::Start(len - 1)).unwrap();
2115        (&f).write_all(b"X").unwrap();
2116        assert!(cache.get_rendered(&key, "q").is_none());
2117        assert!(!path.exists());
2118    }
2119
2120    #[test]
2121    fn derived_roundtrip_is_versioned_and_aligned() {
2122        let dir = tempfile::tempdir().unwrap();
2123        let cache = DiskCache::open(dir.path()).with_capacity(1 << 20);
2124        let key = ApkKey::from_id("app-0123456789abcdef".into());
2125        assert!(cache.get_derived(&key, "classes.dex", "locators", 1).is_none());
2126        cache.put_derived(&key, "classes.dex", "locators", 1, Duration::from_millis(7), b"tables").unwrap();
2127        let d = cache.get_derived(&key, "classes.dex", "locators", 1).unwrap();
2128        assert_eq!(&*d, b"tables");
2129        assert_eq!(d.build_cost, Duration::from_millis(7));
2130        assert_eq!(d.as_ptr() as usize % 64, 0, "payload as aligned as the mapping");
2131        drop(d);
2132        // Another producer version, kind or name is another file.
2133        assert!(cache.get_derived(&key, "classes.dex", "locators", 2).is_none());
2134        assert!(cache.get_derived(&key, "classes.dex", "callgraph", 1).is_none());
2135        assert!(cache.get_derived(&key, "classes2.dex", "locators", 1).is_none());
2136        // A truncated file is a miss.
2137        let path = cache.derived_path(&key, "classes.dex", "locators", 1);
2138        let full = fs::read(&path).unwrap();
2139        fs::write(&path, &full[..full.len() - 1]).unwrap();
2140        assert!(cache.get_derived(&key, "classes.dex", "locators", 1).is_none());
2141        fs::write(&path, &full[..10]).unwrap();
2142        assert!(cache.get_derived(&key, "classes.dex", "locators", 1).is_none());
2143        let [l1, l2, l3] = cache.tier_stats();
2144        assert_eq!((l1.files, l2.files, l3.files), (0, 1, 0));
2145    }
2146
2147    #[test]
2148    fn rendered_results_are_keyed_by_the_full_query() {
2149        let dir = tempfile::tempdir().unwrap();
2150        let cache = DiskCache::open(dir.path()).with_capacity(1 << 20);
2151        let key = ApkKey::from_id("app-0123456789abcdef".into());
2152        let q = rendered_key("findrefs", "1.0", "string\0token");
2153        assert!(cache.get_rendered(&key, &q).is_none());
2154        cache.put_rendered(&key, &q, b"rows").unwrap();
2155        assert_eq!(cache.get_rendered(&key, &q).unwrap(), b"rows");
2156        assert!(cache.get_rendered(&key, &rendered_key("findrefs", "1.1", "string\0token")).is_none(), "tool upgrade");
2157        // A file found under another query's hash (a collision) is a miss.
2158        let other = rendered_key("findrefs", "1.0", "string\0other");
2159        fs::rename(cache.rendered_path(&key, &q), cache.rendered_path(&key, &other)).unwrap();
2160        assert!(cache.get_rendered(&key, &other).is_none());
2161    }
2162
2163    #[test]
2164    fn stats_and_clear_cover_every_tier() {
2165        let dir = tempfile::tempdir().unwrap();
2166        let cache = DiskCache::open(dir.path()).with_capacity(1 << 20);
2167        let (a, b) = (ApkKey::from_id("a-0123456789abcdef".into()), ApkKey::from_id("b-0123456789abcdef".into()));
2168        cache.put(&a, "classes.dex", &[0; 10]).unwrap();
2169        cache.put_derived(&a, "classes.dex", "locators", 1, Duration::ZERO, &[0; 6]).unwrap();
2170        cache.put_rendered(&b, "q", &[0; 3]).unwrap();
2171        let (apks, files, bytes) = cache.stats();
2172        assert_eq!((apks, files), (2, 3));
2173        assert_eq!(bytes, (10 + L1_TRAILER) + (64 + 6) + (16 + 1 + 3));
2174        let [l1, l2, l3] = cache.tier_stats();
2175        assert_eq!((l1.apks, l2.apks, l3.apks), (1, 1, 1));
2176        // A cache with tier directories but no tag (lost) is still ours.
2177        fs::remove_file(dir.path().join(CACHEDIR_TAG)).unwrap();
2178        assert!(cache.check_usable().is_ok());
2179        assert_eq!(cache.clear().unwrap(), bytes);
2180        assert_eq!(cache.stats(), (0, 0, 0));
2181        assert!(!dir.path().join("derived").exists() && !dir.path().join("rendered").exists());
2182    }
2183
2184    #[test]
2185    fn disabled_tiers_miss_and_skip_writes() {
2186        let dir = tempfile::tempdir().unwrap();
2187        let cache = DiskCache::open(dir.path()).with_capacity(1 << 20).with_tiers(&[Tier::Derived]);
2188        let key = ApkKey::from_id("app-0123456789abcdef".into());
2189        cache.put(&key, "classes.dex", b"abc").unwrap();
2190        cache.put_rendered(&key, "q", b"rows").unwrap();
2191        cache.put_derived(&key, "classes.dex", "k", 1, Duration::ZERO, b"t").unwrap();
2192        assert_eq!(cache.tier_stats().map(|t| t.files), [0, 1, 0]);
2193        let all = DiskCache::open(dir.path()).with_capacity(1 << 20).with_tiers(&Tier::ALL);
2194        all.put(&key, "classes.dex", b"abc").unwrap();
2195        assert!(cache.get(&key, "classes.dex", 3).is_none());
2196        assert!(cache.uses(Tier::Derived) && !cache.uses(Tier::Dex));
2197    }
2198
2199    #[test]
2200    fn lazy_eviction_skips_a_walk_right_after_another() {
2201        let dir = tempfile::tempdir().unwrap();
2202        let cache = DiskCache::open(dir.path()).with_capacity(1 << 30);
2203        let key = ApkKey::from_id("app-0123456789abcdef".into());
2204        cache.put(&key, "classes.dex", &[0; 100]).unwrap();
2205        cache.evict();
2206        assert!(dir.path().join(EVICT_MARKER).is_file());
2207        assert!(cache.check_usable().is_ok(), "the marker is the cache's own");
2208        // Over a lowered budget: a lazy pass right after a walk leaves it ...
2209        let cache = cache.with_capacity(50);
2210        cache.evict_lazily();
2211        assert_eq!(cache.stats().1, 1);
2212        // ... until enough was written since (1/16 of the budget), or the walk is old.
2213        cache.put(&key, "classes2.dex", &[0; 100]).unwrap();
2214        cache.evict_lazily();
2215        assert_eq!(cache.stats().1, 0);
2216    }
2217
2218    #[test]
2219    fn small_dex_entries_are_not_admitted_by_default() {
2220        let dir = tempfile::tempdir().unwrap();
2221        let cache = DiskCache::open(dir.path());
2222        assert_eq!(cache.min_dex_bytes(), DEFAULT_MIN_DEX_BYTES);
2223        let cache = cache.with_min_dex_bytes(100);
2224        assert!(!cache.admits_dex(99));
2225        assert!(cache.admits_dex(100));
2226        assert!(cache.with_min_dex_bytes(0).admits_dex(0));
2227    }
2228
2229    #[test]
2230    fn eviction_trims_tiers_then_goes_l1_l3_l2() {
2231        let dir = tempfile::tempdir().unwrap();
2232        // Budget 1000: L2 may hold 500, L3 125.
2233        let cache = DiskCache::open(dir.path()).with_capacity(1000);
2234        let k = |i: u32| ApkKey::from_id(format!("app-{i:016x}"));
2235        // L3: four 64-byte results (+ header), the oldest beyond L3's own budget.
2236        for i in 0..4 {
2237            let q = format!("q{i}");
2238            cache.put_rendered(&k(0), &q, &[0; 64]).unwrap();
2239            set_mtime(&cache.rendered_path(&k(0), &q), -100 + i as i64);
2240        }
2241        cache.evict();
2242        let [_, _, l3] = cache.tier_stats();
2243        assert_eq!(l3.files, 1, "trimmed to its own budget, newest kept");
2244        assert!(cache.get_rendered(&k(0), "q3").is_some());
2245        // L2: a cheap and an expensive index per byte; L1: two APKs.
2246        cache.put_derived(&k(1), "classes.dex", "cheap", 1, Duration::from_micros(10), &[0; 136]).unwrap();
2247        cache.put_derived(&k(1), "classes.dex", "dear", 1, Duration::from_secs(1), &[0; 136]).unwrap();
2248        cache.put(&k(2), "classes.dex", &[0; 300]).unwrap();
2249        cache.put(&k(3), "classes.dex", &[0; 300]).unwrap();
2250        set_mtime(&dir.path().join(k(2).as_str()), -50);
2251        // 78 + 400 + 600 = 1078 > 1000: one L1 directory goes, the older.
2252        cache.evict();
2253        assert!(cache.get(&k(2), "classes.dex", 300).is_none());
2254        assert!(cache.get(&k(3), "classes.dex", 300).is_some());
2255        assert_eq!(cache.tier_stats().map(|t| t.files), [1, 2, 1]);
2256        // Budget 450 (L2 225, L3 56): L3 and the cheap L2 index exceed their
2257        // own budgets, then L1 goes to fit the total.
2258        let cache = cache.with_capacity(450);
2259        cache.evict();
2260        assert_eq!(cache.tier_stats().map(|t| t.files), [0, 1, 0]);
2261        assert!(cache.get_derived(&k(1), "classes.dex", "dear", 1).is_some());
2262    }
2263
2264    #[test]
2265    fn l1_eviction_weighs_size_with_age() {
2266        let dir = tempfile::tempdir().unwrap();
2267        let cache = DiskCache::open(dir.path()).with_capacity(600);
2268        let (big, small) = (ApkKey::from_id("big-0000000000000001".into()), ApkKey::from_id("small-0000000000000002".into()));
2269        // The big entry is newer but costs far more space per second of age.
2270        cache.put(&small, "classes.dex", &[0; 100]).unwrap();
2271        cache.put(&big, "classes.dex", &[0; 550]).unwrap();
2272        set_mtime(&dir.path().join(small.as_str()), -3);
2273        cache.evict();
2274        assert!(cache.get(&small, "classes.dex", 100).is_some());
2275        assert!(cache.get(&big, "classes.dex", 550).is_none());
2276    }
2277
2278    #[test]
2279    fn entries_of_deleted_apks_go_first() {
2280        let dir = tempfile::tempdir().unwrap();
2281        let apks = tempfile::tempdir().unwrap();
2282        let cache = DiskCache::open(dir.path()).with_capacity(1000);
2283        let (live, dead) = (ApkKey::from_id("live-0000000000000001".into()), ApkKey::from_id("dead-0000000000000002".into()));
2284        let (live_apk, dead_apk) = (apks.path().join("live.apk"), apks.path().join("dead.apk"));
2285        fs::write(&live_apk, b"PK").unwrap();
2286        fs::write(&dead_apk, b"PK").unwrap();
2287        cache.note_source(&live, &live_apk).unwrap();
2288        cache.note_source(&dead, &dead_apk).unwrap();
2289        cache.put(&live, "classes.dex", &[0; 400]).unwrap();
2290        // The dead APK's entry is the newest one, and its L2 index the dearest.
2291        cache.put_derived(&dead, "classes.dex", "locators", 1, Duration::from_secs(9), &[0; 100]).unwrap();
2292        cache.put(&dead, "classes.dex", &[0; 400]).unwrap();
2293        set_mtime(&dir.path().join(live.as_str()), -100);
2294        fs::remove_file(&dead_apk).unwrap();
2295        // 400 + 164 + 400 = 964: within budget, nothing goes.
2296        cache.evict();
2297        assert_eq!(cache.stats().1, 3);
2298        let cache = cache.with_capacity(500);
2299        cache.evict();
2300        assert!(cache.get(&live, "classes.dex", 400).is_some());
2301        assert!(cache.get(&dead, "classes.dex", 400).is_none());
2302        assert!(cache.get_derived(&dead, "classes.dex", "locators", 1).is_none());
2303        assert!(!dir.path().join("sources").join(dead.as_str()).exists(), "source record dropped with its entries");
2304        assert!(dir.path().join("sources").join(live.as_str()).exists());
2305    }
2306}