Freshclam wrapper: update RPC, DB age in status, engine-agnostic fields

- hound-api: DbFile (file + updated_at), Status.db, UpdateResult, Client::update()
- houndd: probe() extracts signature freshness from /var/lib/clamav mtimes;
  update() tries 'sudo freshclam', falls back to plain 'freshclam' and
  reports the honest reason on failure; output capped to 2KB tail
- hound CLI: 'hound update' with --json parity and human output
- rename Status.clamav_present -> engine_present + add Status.engine
  (prep for the engine seam; wire stays engine-agnostic)

Live-verified: status shows DB file + last-modified RFC3339; update
returns ok:false with the real log for a plain user (exit 1).
This commit is contained in:
John McAfee 2026-08-20 17:54:50 -05:00
parent bbbabefdda
commit 566e0cbccb
8 changed files with 570 additions and 144 deletions

53
Cargo.lock generated
View file

@ -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"

View file

@ -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

View file

@ -10,3 +10,4 @@ repository.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
time.workspace = true

View file

@ -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<DbFile>,
}
#[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<Found>,
}
/// 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<UpdateResult> {
let v = self.call(3, "update", None)?;
Ok(serde_json::from_value(v)?)
}
}

View file

@ -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<i32> {
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<i32> {
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()
);
}
}

View file

@ -15,3 +15,4 @@ hound-api = { path = "../hound-api" }
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
time.workspace = true

327
crates/houndd/src/engine.rs Normal file
View file

@ -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<DbFile>);
/// Scan `path` (canonicalize first) and return per-file findings.
fn scan(&self, path: &str, recursive: bool) -> Result<ScanResult>;
/// 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<DbFile>) {
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<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?)")?;
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<ScanResult> {
use hound_api::Found;
use std::collections::HashSet;
let text = String::from_utf8_lossy(stdout);
let mut found: Vec<Found> = Vec::new();
let mut seen_files: HashSet<String> = HashSet::new();
let mut reported: HashSet<String> = 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);
}
}

View file

@ -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<UnixStream>,
bytes: &str,
) -> Result<()> {
fn writer_flush(reader: &mut BufReader<UnixStream>, 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<Value> {
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<Value> {
}
}
// ── 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<hound_api::UpdateResult> {
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<hound_api::ScanResult> {
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<String> = std::collections::HashSet::new();
let mut reported: std::collections::HashSet<String> = 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<hound_api::ScanResult> {
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('…'));
}
}