Every serious bug found in desktop testing had one shape: Hound reporting success it had not achieved. A build script that said "built" without building. A window showing "Protected" while its front-end had failed to load. A rootkit check calling 988 processes hidden when it was the one that had been blinded. A settings write refused while the switch stayed where the user put it. A visible error is something a person can act on; a false green is not. `hound selfcheck` asks the question directly — what can this installation not currently do? — and the daemon prints anything wrong at startup rather than waiting for it to be inferred from behaviour. The states are ok, degraded, and blind; the last is the one that matters, because blind means a detector is running and cannot see. It exits non-zero when blind, so it can be wired into monitoring. It earned its place within a minute of existing, by reporting the vault as /root/.local/share/hound/quarantine. The daemon runs as root, root has a home directory, and the XDG rules therefore sent the system vault into root's dotfiles — while the installer created and hardened /var/lib/hound/vault, which sat empty, and the desktop app read the user's own vault. Three vaults, none agreeing, which is exactly why the Quarantine tab reported "vault is empty" beside two quarantined files. Root now uses the system vault; an unprivileged daemon keeps its own, since a developer running houndd by hand must not need /var/lib. Also: `hound update` restarts the desktop app itself after installing. The app can notice its own package being replaced, but only from the version that learned how — updating from an older one leaves the stale process showing the old front-end, which is indistinguishable from an update that did nothing. The updater matches processes on the executable rather than a command line anyone could imitate, and relaunches each as its own owner with the session environment it was already using: DISPLAY, Wayland socket and bus address are taken from the running process, because guessing them breaks on Wayland or a second seat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
480 lines
16 KiB
Rust
480 lines
16 KiB
Rust
//! 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;
|
|
use std::sync::OnceLock;
|
|
|
|
/// What an engine implementation must answer.
|
|
///
|
|
/// `Send + Sync` because the daemon holds exactly one engine for its
|
|
/// whole life and hands it to every connection thread and to the
|
|
/// real-time monitor.
|
|
pub trait ScanEngine: Send + Sync {
|
|
/// 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>);
|
|
|
|
/// How many detection rules are actually compiled and live.
|
|
///
|
|
/// The self-check needs this to tell "scanned and found nothing" apart
|
|
/// from "scanned with no rules loaded", which look identical from the
|
|
/// outside and mean opposite things.
|
|
fn rule_count(&self) -> usize {
|
|
0
|
|
}
|
|
|
|
/// 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)>;
|
|
|
|
/// Scan bytes already in hand, returning a detection name.
|
|
///
|
|
/// This exists for the execution gate, which is handed an open
|
|
/// descriptor and must never re-open the path: an `open()` on a
|
|
/// watched mount queues a permission event behind the one being
|
|
/// answered and deadlocks the daemon against itself. Engines that
|
|
/// can only scan paths return `None` and are simply not usable
|
|
/// behind the gate.
|
|
fn scan_bytes(&self, _bytes: &[u8]) -> Option<String> {
|
|
None
|
|
}
|
|
|
|
/// Whether a detection may move the file, rather than only report it.
|
|
///
|
|
/// Defaults to `false`. An engine that does not model this — or a
|
|
/// detection name it does not recognise — must not destroy anything.
|
|
fn may_quarantine(&self, _detection: &str) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Answer from memory alone, without reading the file.
|
|
///
|
|
/// `Some(verdict)` means we have judged this exact file version
|
|
/// before; `None` means it must be scanned. The execution gate uses
|
|
/// this to answer repeat executions without waking a worker, which is
|
|
/// most of the traffic on a machine that is actually doing something.
|
|
fn cached_verdict(&self, _md: &std::fs::Metadata) -> Option<Option<String>> {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
// clamscan does not tell us what it skipped for size.
|
|
skipped: 0,
|
|
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())
|
|
}
|
|
|
|
/// The active engine, chosen once at daemon startup.
|
|
///
|
|
/// Default is [`HoundEngine`](crate::native::HoundEngine) — yara-x in
|
|
/// process. `HOUNDD_ENGINE` overrides it:
|
|
///
|
|
/// * `clamav` — the legacy `clamscan` subprocess path. Kept so the two
|
|
/// can be compared directly, and because it still owns the Windows
|
|
/// malware corpus that our own rules deliberately do not cover.
|
|
/// * `fake` — the synthetic engine the E2E test drives, so a full
|
|
/// daemon lifecycle can run without ClamAV or a real rule pack.
|
|
///
|
|
/// The whole point of the trait is that this is the only place the
|
|
/// daemon decides *which* engine it serves.
|
|
pub fn engine() -> &'static dyn ScanEngine {
|
|
// Selection is per call — one getenv — so the choice stays live and
|
|
// two tests in one process cannot contaminate each other. Only the
|
|
// expensive engine is memoised, below.
|
|
match std::env::var("HOUNDD_ENGINE").as_deref() {
|
|
Ok("fake") => {
|
|
static FAKE: FakeEngine = FakeEngine;
|
|
&FAKE
|
|
}
|
|
Ok("clamav") => {
|
|
static CLAMAV: ClamAvEngine = ClamAvEngine;
|
|
&CLAMAV
|
|
}
|
|
_ => native_engine(),
|
|
}
|
|
}
|
|
|
|
/// The native engine, built exactly once. Compiling the ruleset is the
|
|
/// one genuinely expensive thing the daemon does at startup, so it must
|
|
/// never happen twice.
|
|
fn native_engine() -> &'static dyn ScanEngine {
|
|
static ENGINE: OnceLock<Box<dyn ScanEngine>> = OnceLock::new();
|
|
ENGINE
|
|
.get_or_init(|| match crate::native::HoundEngine::new() {
|
|
Ok(e) => Box::new(e) as Box<dyn ScanEngine>,
|
|
Err(e) => {
|
|
// Losing detection entirely is worse than falling back to
|
|
// the slow path, so say so loudly and carry on.
|
|
eprintln!("engine: rules failed to compile ({e}) — falling back to clamav");
|
|
Box::new(ClamAvEngine)
|
|
}
|
|
})
|
|
.as_ref()
|
|
}
|
|
|
|
/// Test engine: reports itself present, scans anything whose name
|
|
/// contains "EICAR" or ".eicar" as infected, and updates cleanly.
|
|
/// Lets the E2E test exercise the full wire without ClamAV installed.
|
|
pub struct FakeEngine;
|
|
|
|
impl ScanEngine for FakeEngine {
|
|
fn name(&self) -> &'static str {
|
|
"fake"
|
|
}
|
|
|
|
fn probe(&self) -> (bool, String, Option<DbFile>) {
|
|
(
|
|
true,
|
|
"signatures: synthetic [fake]".to_string(),
|
|
Some(DbFile {
|
|
file: "fake.cld".to_string(),
|
|
updated_at: to_rfc3339(std::time::SystemTime::now()),
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn scan(&self, path: &str, _recursive: bool) -> Result<ScanResult> {
|
|
use hound_api::Found;
|
|
let path = fs::canonicalize(path).with_context(|| format!("no such path: {path}"))?;
|
|
let infected = path
|
|
.to_string_lossy()
|
|
.to_ascii_lowercase()
|
|
.contains("eicar");
|
|
let found = if infected {
|
|
vec![Found {
|
|
path: path.to_string_lossy().to_string(),
|
|
virus: "Fake-Eicar".to_string(),
|
|
}]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let scanned: u64 = 1;
|
|
let infected_u: u64 = if infected { 1 } else { 0 };
|
|
Ok(ScanResult {
|
|
scanned,
|
|
clean: scanned - infected_u,
|
|
infected: infected_u,
|
|
skipped: 0,
|
|
found,
|
|
})
|
|
}
|
|
|
|
fn update(&self) -> Result<(bool, String, String)> {
|
|
Ok((
|
|
true,
|
|
"fake update".to_string(),
|
|
"OK: fake DB refreshed\n".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|