diff --git a/Cargo.lock b/Cargo.lock index e2d1f3b..435d1e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + [[package]] name = "heck" version = "0.5.0" @@ -138,6 +147,7 @@ dependencies = [ "anyhow", "serde", "serde_json", + "time", ] [[package]] @@ -148,6 +158,7 @@ dependencies = [ "hound-api", "serde", "serde_json", + "time", ] [[package]] @@ -174,12 +185,24 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -258,6 +281,36 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index b159c76..d72dbe8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" clap = { version = "4", features = ["derive"] } colored = "2" +time = { version = "0.3", features = ["serde", "std", "formatting"] } [profile.release] lto = true diff --git a/crates/hound-api/Cargo.toml b/crates/hound-api/Cargo.toml index 192b2ba..60c293d 100644 --- a/crates/hound-api/Cargo.toml +++ b/crates/hound-api/Cargo.toml @@ -10,3 +10,4 @@ repository.workspace = true serde.workspace = true serde_json.workspace = true anyhow.workspace = true +time.workspace = true diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index b4512f4..7ac9976 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -68,11 +68,29 @@ impl Response { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Status { pub daemon_version: String, - pub clamav_present: bool, + /// Which engine the daemon is running (see `houndd::engine::ENGINE`). + #[serde(default)] + pub engine: String, + /// Whether the active engine is present/usable on this machine — + /// the tray's "engine online/offline" signal. + pub engine_present: bool, /// e.g. "daily.cld (Aug 20 2025)" — empty if no DB. pub db_summary: String, /// Human-readable OS string for the GUI banner. pub os: String, + /// Newest signature-DB file (e.g. "daily-20260820.cld") + its mtime + /// as an ISO-8601 timestamp. Clients use this to derive "signatures + /// stale → amber" instead of parsing `db_summary` prose. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub db: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DbFile { + /// Basename of the newest .cld/.ndb signature file. + pub file: String, + /// Last-modified time of that file, e.g. "2026-08-20T01:24:22Z". + pub updated_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -89,6 +107,19 @@ pub struct ScanResult { pub found: Vec, } +/// Result of the `update` method (a freshclam run). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateResult { + /// True when freshclam exited 0 (DB actually refreshed or already current). + pub ok: bool, + /// The freshclam run we attempted: "sudo freshclam" or "freshclam". + pub command: String, + /// Combined stdout+stderr tail (last ~2k chars) for display. + pub output: String, + /// Post-update status — callers use this to refresh their UI/tray. + pub status: Status, +} + impl ScanResult { pub fn is_clean(&self) -> bool { self.infected == 0 @@ -160,4 +191,9 @@ impl Client { let v = self.call(2, "scan", Some(params))?; Ok(serde_json::from_value(v)?) } + + pub fn update(&self) -> anyhow::Result { + let v = self.call(3, "update", None)?; + Ok(serde_json::from_value(v)?) + } } diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index 348e510..9fcb495 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -14,7 +14,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use colored::Colorize; -use hound_api::{Client, ScanResult}; +use hound_api::{Client, ScanResult, UpdateResult}; #[derive(Parser)] #[command( @@ -46,6 +46,12 @@ enum Cmd { #[arg(long)] json: bool, }, + /// Refresh ClamAV signature databases (freshclam) + Update { + /// Emit machine-readable JSON instead of human text + #[arg(long)] + json: bool, + }, } fn main() { @@ -79,15 +85,31 @@ fn run(client: &Client, cmd: &Cmd) -> Result { match cmd { Cmd::Status => { let st = client.status()?; - if st.clamav_present { - println!("{} {}", "Hound engine:", st.daemon_version.green().bold()); + if st.engine_present { + println!( + "{} {} [engine: {}]", + "Hound engine:", + st.daemon_version.green().bold(), + st.engine + ); println!(" OS: {}", st.os); - println!(" ClamAV: {}", "present".green()); + println!(" Engine: {}", "present".green()); println!(" Signatures:{}", st.db_summary); + if let Some(db) = &st.db { + println!(" DB file: {} (last modified {})", db.file, db.updated_at); + } } else { - println!("{} {}", "Hound engine:", st.daemon_version.green().bold()); + println!( + "{} {} [engine: {}]", + "Hound engine:", + st.daemon_version.green().bold(), + st.engine + ); println!(" OS: {}", st.os); - println!(" ClamAV: {}", "NOT FOUND — sudo apt install clamav".red()); + println!( + " Engine: {}", + "NOT FOUND — sudo apt install clamav".red() + ); } Ok(0) } @@ -104,6 +126,59 @@ fn run(client: &Client, cmd: &Cmd) -> Result { print_human(&r, path); Ok(if r.is_clean() { 0 } else { 1 }) } + Cmd::Update { json } => { + let u: UpdateResult = client.update()?; + if *json { + println!("{}", serde_json::to_string_pretty(&u)?); + return Ok(if u.ok { 0 } else { 1 }); + } + print_update_human(&u); + Ok(if u.ok { 0 } else { 1 }) + } + } +} + +fn print_update_human(u: &UpdateResult) { + if u.ok { + println!( + "{} signature DB refreshed via `{}`", + "✔".green().bold(), + u.command + ); + } else { + println!( + "{} update failed via `{}`", + "✘".red().bold(), + u.command + ); + } + // Show the tail of the run (errors, "already current", etc.). + for line in u.output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if line.starts_with("ERROR") || line.starts_with("WARNING") { + println!(" {}", line.yellow()); + } else { + println!(" {line}"); + } + } + if let Some(db) = &u.status.db { + println!( + " {}{}", + "DB now: ".dimmed(), + format!("{} (updated {})", db.file, db.updated_at) + ); + } + if !u.ok { + println!( + " {}", + "hint: plain-user freshclam needs write access to /var/lib/clamav \ + and /var/log/clamav — run the update from the GUI (polkit) or as \ + a user in the clamav group." + .dimmed() + ); } } diff --git a/crates/houndd/Cargo.toml b/crates/houndd/Cargo.toml index 3cab35d..04ecfdf 100644 --- a/crates/houndd/Cargo.toml +++ b/crates/houndd/Cargo.toml @@ -15,3 +15,4 @@ hound-api = { path = "../hound-api" } anyhow.workspace = true serde.workspace = true serde_json.workspace = true +time.workspace = true diff --git a/crates/houndd/src/engine.rs b/crates/houndd/src/engine.rs new file mode 100644 index 0000000..cbcdef6 --- /dev/null +++ b/crates/houndd/src/engine.rs @@ -0,0 +1,327 @@ +//! 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; + +/// What an engine implementation must answer. +pub trait ScanEngine { + /// 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); + + /// 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)>; +} + +/// 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, + 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()) +} + +/// Which engine the daemon serves. Flip this when the native engine +/// lands — the whole point of the trait is that this is the only change +/// the daemon needs. +pub const ENGINE: ClamAvEngine = ClamAvEngine; + +#[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); + } +} diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index feb4ab5..f05d0fa 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -1,22 +1,30 @@ //! `houndd` — the Hound engine. //! -//! A tiny single-purpose daemon that exposes ClamAV over a Unix-socket, -//! line-delimited JSON-RPC 2.0 API. Both the CLI and the GUI are thin -//! clients of this socket, which is what lets future suite tools -//! (firewall, updater, …) share the same engine without forking it. +//! A tiny single-purpose daemon that exposes a scanning engine over a +//! Unix-socket, line-delimited JSON-RPC 2.0 API. Both the CLI and the +//! GUI are thin clients of this socket, which is what lets future suite +//! tools (firewall, updater, …) share the same engine without forking +//! it. +//! +//! The engine itself is pluggable behind [`engine::ScanEngine`] — today +//! that's [`engine::ClamAvEngine`], tomorrow a native Rust engine. The +//! wire API doesn't know the difference; it only reports `Status.engine`. //! //! Current methods: -//! - `status` → engine health, ClamAV presence, signature-DB summary -//! - `scan` → recursive ClamAV scan of a path, returns per-file findings +//! - `status` → engine health, signature-DB freshness +//! - `scan` → recursive scan of a path, per-file findings +//! - `update` → refresh the signature store + +mod engine; use anyhow::{bail, Context, Result}; +use engine::{ScanEngine, ENGINE}; use hound_api::Response; use serde_json::Value; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; -use std::process::Command; const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -32,7 +40,8 @@ fn main() -> Result<()> { let listener = UnixListener::bind(&sock_path) .with_context(|| format!("binding {sock}"))?; eprintln!( - "houndd {DAEMON_VERSION} listening on {sock} (Ctrl-C to stop)" + "houndd {DAEMON_VERSION} listening on {sock} [engine: {}] (Ctrl-C to stop)", + ENGINE.name() ); for stream in listener.incoming() { @@ -86,10 +95,7 @@ fn handle_conn(stream: UnixStream) -> Result<()> { Ok(()) } -fn writer_flush( - reader: &mut BufReader, - bytes: &str, -) -> Result<()> { +fn writer_flush(reader: &mut BufReader, bytes: &str) -> Result<()> { // The BufReader consumed the stream; get the stream back out to write. let stream = reader.get_mut(); stream.write_all(bytes.as_bytes())?; @@ -100,6 +106,7 @@ fn writer_flush( fn dispatch(req: &hound_api::Request) -> Result { match req.method.as_str() { "status" => Ok(serde_json::to_value(status())?), + "update" => Ok(serde_json::to_value(update()?)?), "scan" => { let path = req .params @@ -119,157 +126,82 @@ fn dispatch(req: &hound_api::Request) -> Result { } } -// ── status ────────────────────────────────────────────────────────────────── +// ── RPC handlers (engine-agnostic) ────────────────────────────────────────── fn status() -> hound_api::Status { - let (present, db_summary) = clamav_probe(); + let (present, db_summary, db) = ENGINE.probe(); let os = std::fs::read_to_string("/etc/os-release") .ok() .and_then(|c| { c.lines() .find(|l| l.starts_with("PRETTY_NAME=")) - .map(|l| l.trim_start_matches("PRETTY_NAME=").trim_matches('"').to_string()) + .map(|l| { + l.trim_start_matches("PRETTY_NAME=") + .trim_matches('"') + .to_string() + }) }) .unwrap_or_else(|| "unknown".into()); hound_api::Status { daemon_version: DAEMON_VERSION.to_string(), - clamav_present: present, + engine: ENGINE.name().to_string(), + engine_present: present, db_summary, os, + db, } } -/// `clamscan --version` for presence; `--stat`-style summary for the DB. -fn clamav_probe() -> (bool, String) { - 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()); - } - // freshclam's DB files live in /var/lib/clamav; report newest mtime. - let db_dir = "/var/lib/clamav"; - let newest = fs::read_dir(db_dir) - .into_iter() - .flatten() - .flatten() - .filter(|e| { - e.path() - .extension() - .is_some_and(|ext| ext == "cld" || ext == "ndb") - }) - .filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok())) - .max(); - let summary = match newest { - Some(t) => { - let days = std::time::SystemTime::now() - .duration_since(t) - .map(|d| d.as_secs() / 86_400) - .unwrap_or(0); - format!("signatures updated {days}d ago (clamav {version})") - } - None => format!("no signature DB found (run: sudo freshclam) [clamav {version}]"), - }; - (true, summary) -} +/// Refresh the signature store via the engine, then re-probe so the +/// client can refresh its UI/tray from a single round-trip. +fn update() -> Result { + use hound_api::UpdateResult; -// ── scan ──────────────────────────────────────────────────────────────────── + let (ok, command, combined) = ENGINE.update()?; -/// Run `clamscan -r` over a path and parse its deterministic stdout tail: -/// -/// ```text -/// /path/eicar.com: EICAR-Test-File FOUND -/// -/// ---------------------- -/// Scan summary time: ... -/// Known viruses: ... -/// Scanned files: 3 -/// Infected files: 1 -/// ``` -/// -/// We deliberately parse `Infected files` + per-line `FOUND` markers -/// instead of `--json` because the text format is stable across ClamAV -/// 0.103 → 1.x while `--json` fields have churned. -fn scan(path: &str, recursive: bool) -> Result { - use hound_api::{Found, ScanResult}; - - 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?)")?; - - // Every file ClamAV looks at emits exactly one stdout line: - // /abs/path: OK - // /abs/path: VirusName FOUND - // /abs/path: INCOMPLETE - // We treat those lines as the single source of truth for both the - // scanned count and the findings — no second `find` pass that could - // disagree (perms, size caps, symlinks). - let stdout = String::from_utf8_lossy(&out.stdout); - let mut found = Vec::new(); - // --allmatch reports *every* signature a file matches (EICAR trips 3), - // so the same path can appear on multiple lines. Track unique files for - // the scanned total and report each infected file once. - let mut seen_files: std::collections::HashSet = std::collections::HashSet::new(); - let mut reported: std::collections::HashSet = std::collections::HashSet::new(); - for line in stdout.lines() { - // A per-file result line starts with the path then ": ". - let Some(idx) = line.find(": ") else { continue }; - let file = line[..idx].trim(); - // Skip the "------" separators and any non-file noise. - if !file.starts_with('/') || file.is_empty() { - continue; - } - let key = file.to_string(); - seen_files.insert(key.clone()); - let body = &line[idx + 2..]; - if let Some(end) = body.rfind(" FOUND") { - let virus = body[..end].to_string(); - if reported.insert(key.clone()) { - found.push(Found { path: key, virus }); - } - } - } - let scanned = seen_files.len() as u64; - - let status_code = out.status.code().unwrap_or(-1); - if !out.status.success() && status_code != 1 { - // 0 = no infections, 1 = infections found, >1 = real error - bail!( - "clamscan exited {status_code}: {}", - String::from_utf8_lossy(&out.stderr).trim() - ); - } - - let infected = found.len() as u64; - let clean = scanned.saturating_sub(infected); - - Ok(ScanResult { - scanned, - clean, - infected, - found, + Ok(UpdateResult { + ok, + command, + output: cap_tail(&combined, 2048), + status: status(), }) } +/// Keep the tail of `s` within `max` bytes, back-stepping to a char +/// boundary and prefixing an ellipsis marker. +fn cap_tail(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let cut = s.len() - max; + let cut = s + .char_indices() + .take_while(|(i, _)| *i < cut) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(cut); + format!("…\n{}", &s[cut..]) +} + +fn scan(path: &str, recursive: bool) -> Result { + ENGINE.scan(path, recursive) +} + #[cfg(test)] mod tests { use super::*; #[test] fn client_type_is_constructible() { - let _c: hound_api::Client = hound_api::Client::new("/tmp/does-not-matter.sock".into()); + let _c: hound_api::Client = + hound_api::Client::new("/tmp/does-not-matter.sock".into()); + } + + #[test] + fn cap_tail_keeps_char_boundaries() { + let s = "é".repeat(3000); + let t = cap_tail(&s, 100); + assert!(t.len() <= 100 + 4); // "…\n" prefix is 4 bytes + assert!(t.starts_with('…')); } }