- 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).
219 lines
6 KiB
Rust
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());
|
|
}
|
|
}
|