//! The verdict cache. //! //! Scanning the same unchanged bytes twice is pure waste, and on a //! developer machine it is nearly all of the work: a `cargo build` opens //! the same crate sources hundreds of times, and Phase 1's execution gate //! will ask for a verdict on every `execve` of every binary on the box. //! //! The key is `(dev, ino, mtime, size)`. If any of those four change the //! file is treated as new, so an edit, a truncate, a replace-by-rename or //! a move across filesystems all correctly miss the cache. Content is //! never hashed — hashing to avoid reading would mean reading. //! //! Eviction is FIFO with a hard capacity. An LRU would hold a slightly //! better working set, but FIFO costs one `VecDeque` push and cannot //! degrade pathologically, and the cost of a miss here is one scan. use std::collections::{HashMap, VecDeque}; use std::fs::Metadata; use std::os::unix::fs::MetadataExt; use std::sync::{Arc, Mutex}; /// Identity of a file *version*. Any field changing means rescan. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub struct FileKey { dev: u64, ino: u64, mtime: i64, mtime_nsec: i64, size: u64, } impl FileKey { pub fn from_metadata(md: &Metadata) -> Self { Self { dev: md.dev(), ino: md.ino(), mtime: md.mtime(), mtime_nsec: md.mtime_nsec(), size: md.size(), } } } /// What a previous scan concluded. `None` is clean. pub type Verdict = Option>; struct Inner { map: HashMap, order: VecDeque, hits: u64, misses: u64, } /// A bounded, thread-safe cache of scan verdicts. #[derive(Clone)] pub struct VerdictCache { inner: Arc>, capacity: usize, } impl VerdictCache { pub fn new(capacity: usize) -> Self { Self { inner: Arc::new(Mutex::new(Inner { map: HashMap::with_capacity(capacity.min(4096)), order: VecDeque::with_capacity(capacity.min(4096)), hits: 0, misses: 0, })), capacity: capacity.max(1), } } /// Look up a verdict. The outer `Option` is cache presence; the inner /// one is the verdict itself, so a cached-clean answer is /// `Some(None)` and is deliberately distinguishable from a miss. pub fn get(&self, key: &FileKey) -> Option { let mut inner = self.inner.lock().expect("verdict cache poisoned"); match inner.map.get(key) { Some(v) => { let v = v.clone(); inner.hits += 1; Some(v) } None => { inner.misses += 1; None } } } /// Record a verdict, evicting the oldest entry when full. pub fn put(&self, key: FileKey, verdict: Verdict) { let mut inner = self.inner.lock().expect("verdict cache poisoned"); if inner.map.insert(key, verdict).is_none() { inner.order.push_back(key); while inner.order.len() > self.capacity { if let Some(old) = inner.order.pop_front() { inner.map.remove(&old); } } } } /// Drop everything. Called whenever the ruleset changes — a verdict /// reached under the old rules says nothing about the new ones. pub fn clear(&self) { let mut inner = self.inner.lock().expect("verdict cache poisoned"); inner.map.clear(); inner.order.clear(); } /// `(entries, hits, misses)` for `hound status` and the benchmarks. pub fn stats(&self) -> (usize, u64, u64) { let inner = self.inner.lock().expect("verdict cache poisoned"); (inner.map.len(), inner.hits, inner.misses) } } #[cfg(test)] mod tests { use super::*; fn key(ino: u64, size: u64) -> FileKey { FileKey { dev: 1, ino, mtime: 100, mtime_nsec: 0, size, } } #[test] fn miss_then_hit() { let c = VerdictCache::new(8); assert!(c.get(&key(1, 10)).is_none(), "cold lookup must miss"); c.put(key(1, 10), None); assert_eq!(c.get(&key(1, 10)), Some(None), "cached clean is a hit"); } #[test] fn cached_infected_round_trips() { let c = VerdictCache::new(8); c.put(key(2, 20), Some("Linux.Coinminer.XMRig".into())); let got = c.get(&key(2, 20)).expect("should hit"); assert_eq!(got.as_deref(), Some("Linux.Coinminer.XMRig")); } #[test] fn any_field_change_misses() { let c = VerdictCache::new(8); c.put(key(3, 30), None); // Same inode, different size — the file was rewritten. assert!(c.get(&key(3, 31)).is_none()); // Same size, different inode — replaced by rename. assert!(c.get(&key(4, 30)).is_none()); } #[test] fn mtime_change_misses() { let c = VerdictCache::new(8); let mut k = key(5, 50); c.put(k, None); k.mtime_nsec = 1; assert!(c.get(&k).is_none(), "a nanosecond of edit is still an edit"); } #[test] fn evicts_fifo_at_capacity() { let c = VerdictCache::new(2); c.put(key(1, 1), None); c.put(key(2, 2), None); c.put(key(3, 3), None); assert!(c.get(&key(1, 1)).is_none(), "oldest should be evicted"); assert!(c.get(&key(3, 3)).is_some(), "newest should be resident"); let (entries, _, _) = c.stats(); assert_eq!(entries, 2, "capacity must be honoured"); } #[test] fn reinsert_does_not_grow_order_queue() { let c = VerdictCache::new(4); for _ in 0..50 { c.put(key(9, 9), None); } let (entries, _, _) = c.stats(); assert_eq!(entries, 1); } #[test] fn clear_empties() { let c = VerdictCache::new(4); c.put(key(1, 1), None); c.clear(); assert!(c.get(&key(1, 1)).is_none()); assert_eq!(c.stats().0, 0); } #[test] fn stats_count_hits_and_misses() { let c = VerdictCache::new(4); c.get(&key(1, 1)); // miss c.put(key(1, 1), None); c.get(&key(1, 1)); // hit let (_, hits, misses) = c.stats(); assert_eq!((hits, misses), (1, 1)); } }