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:
parent
bbbabefdda
commit
566e0cbccb
8 changed files with 570 additions and 144 deletions
53
Cargo.lock
generated
53
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@ repository.workspace = true
|
|||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
time.workspace = true
|
||||
|
|
|
|||
|
|
@ -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)?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
327
crates/houndd/src/engine.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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('…'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue