Antivirus/crates/hound/src/main.rs
johnmcafee 566e0cbccb 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).
2026-08-20 17:54:50 -05:00

219 lines
6 KiB
Rust

//! `hound` — the CLI client for the Hound engine.
//!
//! Thin over the daemon's Unix socket:
//!
//! ```sh
//! hound status # engine health + signature DB age
//! hound scan ~/Downloads # recursive scan, human output
//! hound scan --no-recursive /tmp/eicar.com
//! hound scan --json ~/Downloads > report.json
//! ```
//!
//! Exit codes: 0 = clean, 1 = infected, 2 = usage/engine error.
use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::Colorize;
use hound_api::{Client, ScanResult, UpdateResult};
#[derive(Parser)]
#[command(
name = "hound",
about = "Hound Antivirus CLI — the fastest way to know if your box is clean",
version
)]
struct Cli {
/// Daemon socket (default: $HOUNDD_SOCK or $XDG_RUNTIME_DIR/houndd.sock)
#[arg(long, global = true)]
sock: Option<String>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Show engine status (daemon version, ClamAV, signature-DB age)
Status,
/// Scan a file or directory with ClamAV
Scan {
/// Path to scan (file or directory)
path: String,
/// Only scan the given directory's top level
#[arg(long)]
no_recursive: bool,
/// Emit machine-readable JSON instead of human text
#[arg(long)]
json: bool,
},
/// Refresh ClamAV signature databases (freshclam)
Update {
/// Emit machine-readable JSON instead of human text
#[arg(long)]
json: bool,
},
}
fn main() {
let cli = Cli::parse();
let client = match client(&cli.sock) {
Ok(c) => c,
Err(e) => {
eprintln!("{} {e}", "error:".red().bold());
std::process::exit(2);
}
};
let code = match run(&client, &cli.cmd) {
Ok(code) => code,
Err(e) => {
eprintln!("{} {e:#}", "error:".red().bold());
2
}
};
std::process::exit(code);
}
fn client(sock: &Option<String>) -> Result<Client> {
Ok(match sock {
Some(s) => Client::new(s.clone()),
None => Client::default_path(),
})
}
/// Returns the process exit code.
fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
match cmd {
Cmd::Status => {
let st = client.status()?;
if st.engine_present {
println!(
"{} {} [engine: {}]",
"Hound engine:",
st.daemon_version.green().bold(),
st.engine
);
println!(" OS: {}", st.os);
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!(
"{} {} [engine: {}]",
"Hound engine:",
st.daemon_version.green().bold(),
st.engine
);
println!(" OS: {}", st.os);
println!(
" Engine: {}",
"NOT FOUND — sudo apt install clamav".red()
);
}
Ok(0)
}
Cmd::Scan {
path,
no_recursive,
json,
} => {
let r: ScanResult = client.scan(path, !no_recursive)?;
if *json {
println!("{}", serde_json::to_string_pretty(&r)?);
return Ok(if r.is_clean() { 0 } else { 1 });
}
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()
);
}
}
fn print_human(r: &ScanResult, path: &str) {
if r.is_clean() {
println!(
"{} {scanned} file(s) scanned — no threats found.",
"".green().bold(),
scanned = r.scanned
);
} else {
println!(
"{} {infected} threat(s) found in {scanned} file(s) under {path}",
"".red().bold(),
infected = r.infected,
scanned = r.scanned
);
for f in &r.found {
println!(" {} {}", f.virus.yellow(), f.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_from_none_uses_default() {
let _ = client(&None).unwrap();
}
#[test]
fn anyhow_error_constructs() {
let res: Result<()> = Err(anyhow::anyhow!("x"));
assert!(res.is_err());
}
}