//! `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, #[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) -> Result { 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 { 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()); } }