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>
209 lines
6.3 KiB
Rust
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));
|
|
}
|
|
}
|