//! The engine seam. //! //! Everything ClamAV-specific (version probe, signature freshness, the //! `clamscan` subprocess + output parsing, `freshclam`) lives behind //! [`ScanEngine`]. The daemon's `status` / `scan` / `update` RPCs are //! defined against the trait, so a native Rust engine later — ours or a //! rewritten ClamAV — plugs in by implementing four methods and flipping //! the const at the bottom. Nothing on the wire, in the CLI, or in the //! GUI needs to change. //! //! The wire API is deliberately engine-agnostic: `Status.engine` //! identifies the implementation, and DB freshness travels as a file //! name + timestamp (any signature store has those two facts). use anyhow::{Context, Result}; use hound_api::{DbFile, ScanResult}; use std::fs; use std::process::Command; use std::sync::OnceLock; /// What an engine implementation must answer. /// /// `Send + Sync` because the daemon holds exactly one engine for its /// whole life and hands it to every connection thread and to the /// real-time monitor. pub trait ScanEngine: Send + Sync { /// Stable id for the wire (`Status.engine`): "clamav" today, e.g. /// "hound-native" when the Rust engine ships. fn name(&self) -> &'static str; /// Presence + signature-DB freshness. `present` is the tray's /// "engine online/offline" signal. fn probe(&self) -> (bool, String, Option); /// How many detection rules are actually compiled and live. /// /// The self-check needs this to tell "scanned and found nothing" apart /// from "scanned with no rules loaded", which look identical from the /// outside and mean opposite things. fn rule_count(&self) -> usize { 0 } /// Scan `path` (canonicalize first) and return per-file findings. fn scan(&self, path: &str, recursive: bool) -> Result; /// Refresh the signature store. Returns (success, command label, /// combined stdout+stderr tail) for the last attempt made. fn update(&self) -> Result<(bool, String, String)>; /// Scan bytes already in hand, returning a detection name. /// /// This exists for the execution gate, which is handed an open /// descriptor and must never re-open the path: an `open()` on a /// watched mount queues a permission event behind the one being /// answered and deadlocks the daemon against itself. Engines that /// can only scan paths return `None` and are simply not usable /// behind the gate. fn scan_bytes(&self, _bytes: &[u8]) -> Option { None } /// Whether a detection may move the file, rather than only report it. /// /// Defaults to `false`. An engine that does not model this — or a /// detection name it does not recognise — must not destroy anything. fn may_quarantine(&self, _detection: &str) -> bool { false } /// Answer from memory alone, without reading the file. /// /// `Some(verdict)` means we have judged this exact file version /// before; `None` means it must be scanned. The execution gate uses /// this to answer repeat executions without waking a worker, which is /// most of the traffic on a machine that is actually doing something. fn cached_verdict(&self, _md: &std::fs::Metadata) -> Option> { None } } /// The ClamAV-backed engine: `clamscan` + `freshclam` over their /// well-behaved text interfaces. pub struct ClamAvEngine; impl ScanEngine for ClamAvEngine { fn name(&self) -> &'static str { "clamav" } fn probe(&self) -> (bool, String, Option) { let version = Command::new("clamscan") .arg("--version") .output() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_default(); if version.is_empty() { return (false, String::new(), None); } // freshclam's DB files live in /var/lib/clamav; report the newest. let mut newest: Option<(String, std::time::SystemTime)> = None; for entry in fs::read_dir("/var/lib/clamav") .into_iter() .flatten() .flatten() { let path = entry.path(); if !path .extension() .is_some_and(|ext| ext == "cld" || ext == "ndb") { continue; } let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) else { continue; }; let name = path .file_name() .map(|f| f.to_string_lossy().into_owned()) .unwrap_or_default(); let replace = match &newest { None => true, Some((_, cur)) => mtime > *cur, }; if replace { newest = Some((name, mtime)); } } match newest { Some((file, t)) => { let days = std::time::SystemTime::now() .duration_since(t) .map(|d| d.as_secs() / 86_400) .unwrap_or(0); ( true, format!("signatures updated {days}d ago ({file}) [clamav {version}]"), Some(DbFile { file, updated_at: to_rfc3339(t), }), ) } None => ( true, format!("no signature DB found (run: sudo freshclam) [clamav {version}]"), None, ), } } fn scan(&self, path: &str, recursive: bool) -> Result { let path = fs::canonicalize(path).with_context(|| format!("no such path: {path}"))?; let mut cmd = Command::new("clamscan"); cmd.arg("--no-summary") .arg("--stdout") .arg("--max-filesize=100M") .arg("--max-scansize=250M"); if recursive { cmd.arg("-r"); } cmd.arg("--allmatch").arg(path); let out = cmd .output() .context("running clamscan (is ClamAV installed?)")?; Ok(parse_clamscan( &out.stdout, out.status.code().unwrap_or(-1), )?) } fn update(&self) -> Result<(bool, String, String)> { let attempts: [(&str, &[&str]); 2] = [ ("sudo freshclam", &["sudo", "freshclam", "--no-dns"]), ("freshclam", &["freshclam", "--no-dns"]), ]; let (ok, label, out) = if std::env::var_os("HOUNDD_NO_SUDO").is_some() { // Daemon already running as root: skip sudo (it would prompt). let (label, argv) = attempts[1]; let (label, out) = run_cmd(label, argv)?; (out.status.success(), label, out) } else { let (label, argv) = attempts[0]; let (label, out) = run_cmd(label, argv)?; if out.status.success() { (true, label, out) } else { // Plain user without group perms: report the honest reason. let (label2, argv2) = attempts[1]; let (label2, out2) = run_cmd(label2, argv2)?; (out2.status.success(), label2, out2) } }; let mut combined = String::from_utf8_lossy(&out.stdout).to_string(); if !out.stderr.is_empty() { combined.push_str(&String::from_utf8_lossy(&out.stderr)); } Ok((ok, label.to_string(), combined)) } } fn run_cmd(label: &str, argv: &[&str]) -> Result<(String, std::process::Output)> { let out = Command::new(argv[0]) .args(&argv[1..]) .output() .with_context(|| format!("running {label} failed (installed?)"))?; Ok((label.to_string(), out)) } /// Parse `clamscan --stdout` output into findings. /// /// Every file ClamAV looks at emits exactly one line: /// /// ```text /// /abs/path: OK /// /abs/path: VirusName FOUND /// /abs/path: INCOMPLETE /// ``` /// /// We parse those lines (not `--json`) because the text format is stable /// across ClamAV 0.103 → 1.x while `--json` fields have churned. /// `--allmatch` reports *every* signature a file matches (EICAR trips 3), /// so each path is counted once for the scanned total and reported once /// as a finding. pub fn parse_clamscan(stdout: &[u8], exit_code: i32) -> Result { use hound_api::Found; use std::collections::HashSet; let text = String::from_utf8_lossy(stdout); let mut found: Vec = Vec::new(); let mut seen_files: HashSet = HashSet::new(); let mut reported: HashSet = HashSet::new(); for line in text.lines() { // A per-file result line starts with the path then ": ". let Some(idx) = line.find(": ") else { continue }; let file = line[..idx].trim(); if !file.starts_with('/') || file.is_empty() { continue; } seen_files.insert(file.to_string()); let body = &line[idx + 2..]; if let Some(end) = body.rfind(" FOUND") { let key = file.to_string(); if reported.insert(key.clone()) { found.push(Found { path: key, virus: body[..end].to_string(), }); } } } // 0 = no infections, 1 = infections found, >1 = real error. if !(exit_code == 0 || exit_code == 1) { anyhow::bail!("clamscan exited {exit_code}"); } let scanned = seen_files.len() as u64; let infected = found.len() as u64; let clean = scanned.saturating_sub(infected); Ok(ScanResult { scanned, clean, infected, // clamscan does not tell us what it skipped for size. skipped: 0, found, }) } /// Format a `SystemTime` as RFC3339 UTC for the wire. pub fn to_rfc3339(t: std::time::SystemTime) -> String { use time::format_description::well_known::Rfc3339; let Ok(d) = t.duration_since(std::time::UNIX_EPOCH) else { return "unknown".into(); }; let Ok(dt) = time::OffsetDateTime::from_unix_timestamp(d.as_secs() as i64) else { return "unknown".into(); }; dt.format(&Rfc3339).unwrap_or_else(|_| "unknown".into()) } /// The active engine, chosen once at daemon startup. /// /// Default is [`HoundEngine`](crate::native::HoundEngine) — yara-x in /// process. `HOUNDD_ENGINE` overrides it: /// /// * `clamav` — the legacy `clamscan` subprocess path. Kept so the two /// can be compared directly, and because it still owns the Windows /// malware corpus that our own rules deliberately do not cover. /// * `fake` — the synthetic engine the E2E test drives, so a full /// daemon lifecycle can run without ClamAV or a real rule pack. /// /// The whole point of the trait is that this is the only place the /// daemon decides *which* engine it serves. pub fn engine() -> &'static dyn ScanEngine { // Selection is per call — one getenv — so the choice stays live and // two tests in one process cannot contaminate each other. Only the // expensive engine is memoised, below. match std::env::var("HOUNDD_ENGINE").as_deref() { Ok("fake") => { static FAKE: FakeEngine = FakeEngine; &FAKE } Ok("clamav") => { static CLAMAV: ClamAvEngine = ClamAvEngine; &CLAMAV } _ => native_engine(), } } /// The native engine, built exactly once. Compiling the ruleset is the /// one genuinely expensive thing the daemon does at startup, so it must /// never happen twice. fn native_engine() -> &'static dyn ScanEngine { static ENGINE: OnceLock> = OnceLock::new(); ENGINE .get_or_init(|| match crate::native::HoundEngine::new() { Ok(e) => Box::new(e) as Box, Err(e) => { // Losing detection entirely is worse than falling back to // the slow path, so say so loudly and carry on. eprintln!("engine: rules failed to compile ({e}) — falling back to clamav"); Box::new(ClamAvEngine) } }) .as_ref() } /// Test engine: reports itself present, scans anything whose name /// contains "EICAR" or ".eicar" as infected, and updates cleanly. /// Lets the E2E test exercise the full wire without ClamAV installed. pub struct FakeEngine; impl ScanEngine for FakeEngine { fn name(&self) -> &'static str { "fake" } fn probe(&self) -> (bool, String, Option) { ( true, "signatures: synthetic [fake]".to_string(), Some(DbFile { file: "fake.cld".to_string(), updated_at: to_rfc3339(std::time::SystemTime::now()), }), ) } fn scan(&self, path: &str, _recursive: bool) -> Result { use hound_api::Found; let path = fs::canonicalize(path).with_context(|| format!("no such path: {path}"))?; let infected = path .to_string_lossy() .to_ascii_lowercase() .contains("eicar"); let found = if infected { vec![Found { path: path.to_string_lossy().to_string(), virus: "Fake-Eicar".to_string(), }] } else { Vec::new() }; let scanned: u64 = 1; let infected_u: u64 = if infected { 1 } else { 0 }; Ok(ScanResult { scanned, clean: scanned - infected_u, infected: infected_u, skipped: 0, found, }) } fn update(&self) -> Result<(bool, String, String)> { Ok(( true, "fake update".to_string(), "OK: fake DB refreshed\n".to_string(), )) } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_clean_scan() { let out = br"/tmp/a.txt: OK /tmp/b.txt: OK "; let r = parse_clamscan(out, 0).unwrap(); assert_eq!(r.scanned, 2); assert_eq!(r.clean, 2); assert_eq!(r.infected, 0); assert!(r.found.is_empty()); assert!(r.is_clean()); } #[test] fn parses_infected_scan_dedup_allmatch() { // --allmatch: one EICAR file trips several signatures, one line each. let out = br"/tmp/eicar.com: Eicar-Test-Signature FOUND\n\ /tmp/eicar.com: Test.Virus TWO FOUND\n\ /tmp/clean.txt: OK\n"; let r = parse_clamscan(out, 1).unwrap(); assert_eq!(r.scanned, 2, "two unique files, not three lines"); assert_eq!(r.infected, 1, "EICAR must count as one threat"); assert_eq!(r.clean, 1); assert_eq!(r.found.len(), 1); assert_eq!(r.found[0].path, "/tmp/eicar.com"); assert_eq!(r.found[0].virus, "Eicar-Test-Signature"); } #[test] fn incomplete_lines_counted_not_infected() { let out = br"/tmp/big.bin: INCOMPLETE /tmp/x: OK "; let r = parse_clamscan(out, 0).unwrap(); assert_eq!(r.scanned, 2); assert_eq!(r.infected, 0); } #[test] fn nonzero_exit_is_error() { let r = parse_clamscan(b"", 7); assert!(r.is_err()); assert!(r.unwrap_err().to_string().contains("7")); } #[test] fn e2e_eicar_via_real_clamscan() { // Skips itself when ClamAV isn't installed (CI boxes). let which = Command::new("clamscan").arg("--version").output(); if which.as_ref().is_err() { return; } let tmp = std::env::temp_dir().join(format!("houndd-engine-test-{}", std::process::id())); let _ = fs::create_dir_all(&tmp); std::fs::write( tmp.join("eicar.bin"), "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*", ) .unwrap(); let engine = ClamAvEngine; let r = engine.scan(tmp.to_str().unwrap(), true).unwrap(); assert_eq!(r.scanned, 1); assert_eq!(r.infected, 1); assert_eq!(r.found[0].virus, "Eicar-Test-Signature"); let _ = fs::remove_dir_all(&tmp); } #[test] fn probe_reports_present() { let (present, _summary, _db) = ClamAvEngine.probe(); // CI boxes without ClamAV: presence is whatever the OS says. let via_cmd = Command::new("clamscan").arg("--version").output(); assert_eq!( present, via_cmd.map(|o| o.status.success()).unwrap_or(false) ); } #[test] fn trait_is_object_safe() { fn take(_e: &dyn ScanEngine) {} take(engine()); } }