Antivirus/crates/houndd/src/cache.rs
Hound 6746182f18 houndd: replace the clamscan fork with yara-x in process
The old engine shelled out to clamscan for every scan, and clamscan
reloads a 169 MB signature database on every invocation. Measured on a
68-byte EICAR file: 6.5 seconds and ~1.5 GB RSS — paid once per file,
and realtime.rs called it once per inotify event.

Replaces it with HoundEngine: yara-x compiled once at daemon start,
held in memory, one scanner reused across a whole walk, plus a verdict
cache keyed on (dev, ino, mtime, size) so an unchanged file that has
been seen before never reaches the matcher.

Measured after, same machine, same EICAR file:

  single file      6.5 s  ->  4 ms
  400 files cold      --  ->  9 ms
  400 files warm      --  ->  5 ms

Also here:

- rules.rs: hot-swappable rule store. Built-in pack is embedded so a
  fresh install detects something before it has ever reached the
  network; on-disk packs load from $HOUNDD_RULES_DIR, /var/lib/hound
  or the XDG data dir. Reload swaps an Arc, so in-flight scans are
  never torn out from under.
- cache.rs: bounded FIFO verdict cache. Any of the four key fields
  changing means rescan, so edits, truncates and replace-by-rename all
  correctly miss.
- The goodware gate: every rule is scanned against all of /usr/bin,
  /bin and /usr/sbin in CI, and a single hit fails the build. It has
  already earned its keep — it caught a reverse-shell rule that matched
  /usr/bin/sudo, which is now removed rather than tuned. A rule that
  quarantines sudo is worse than no rule at all.
- ScanEngine is Send + Sync and selection stays per-call, so
  HOUNDD_ENGINE=clamav still reaches the legacy path for comparison.
- ScanResult.skipped reports files passed over for size instead of
  quietly counting them as clean.
- Settings gain theme (auto/light/dark), tray_icon_style (color/mono),
  close_to_tray and confirm_quit, normalised daemon-side because
  clients are not trusted to send a theme we can render.

57 tests pass, up from 29.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:05:09 -05:00

209 lines
6.3 KiB
Rust

//! 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<Arc<str>>;
struct Inner {
map: HashMap<FileKey, Verdict>,
order: VecDeque<FileKey>,
hits: u64,
misses: u64,
}
/// A bounded, thread-safe cache of scan verdicts.
#[derive(Clone)]
pub struct VerdictCache {
inner: Arc<Mutex<Inner>>,
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<Verdict> {
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));
}
}