From 2cf41ce3bdeb38ac07df6aacbe27950a1edd383b Mon Sep 17 00:00:00 2001 From: johnmcafee Date: Thu, 20 Aug 2026 16:59:14 -0500 Subject: [PATCH] Rust engine + CLI over ClamAV Unix socket - hound-api: shared wire types (Status/ScanRequest/ScanResult/Found) + line-delimited JSON-RPC client used by both clients - houndd: daemon binding a Unix socket, dispatching status/scan to ClamAV, parsing per-file results, deduping --allmatch hits - hound: clap CLI (status / scan --json), colored human output, exit codes 0=clean 1=threats - workspace scaffolding: toolchain, gitignore, env example, editorconfig --- .editorconfig | 18 ++ .env.example | 3 + .gitignore | 21 +++ Cargo.lock | 365 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 21 +++ crates/hound-api/Cargo.toml | 12 ++ crates/hound-api/src/lib.rs | 163 ++++++++++++++++ crates/hound/Cargo.toml | 18 ++ crates/hound/src/main.rs | 144 ++++++++++++++ crates/houndd/Cargo.toml | 17 ++ crates/houndd/src/main.rs | 275 +++++++++++++++++++++++++++ rust-toolchain.toml | 2 + 12 files changed, 1059 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/hound-api/Cargo.toml create mode 100644 crates/hound-api/src/lib.rs create mode 100644 crates/hound/Cargo.toml create mode 100644 crates/hound/src/main.rs create mode 100644 crates/houndd/Cargo.toml create mode 100644 crates/houndd/src/main.rs create mode 100644 rust-toolchain.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c5cd46b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.rs] +indent_size = 4 + +[*.toml] +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..83b55d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Copy to .env and adjust. Never commit real values. +# Socket the daemon listens on / clients talk to (default: $XDG_RUNTIME_DIR/houndd.sock) +#HOUNDD_SOCK=/run/user/1000/houndd.sock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ebd7a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# ── Rust ───────────────────────────────────────────── +/target/ +# Cargo.lock is a binary workspace → keep it in git (do not ignore) + +# ── Node / GUI ─────────────────────────────────────── +gui/node_modules/ +gui/dist/ +gui/src-tauri/target/ + +# ── Env & secrets (repo-local credential file, chmod 600) ── +.env +.env.* +!.env.example +.git/.git-credentials + +# ── Editor / OS ────────────────────────────────────── +*.swp +.DS_Store +.idea/ +.vscode/* +!.vscode/extensions.json diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e2d1f3b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,365 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hound" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "colored", + "hound-api", + "serde_json", +] + +[[package]] +name = "hound-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", +] + +[[package]] +name = "houndd" +version = "0.1.0" +dependencies = [ + "anyhow", + "hound-api", + "serde", + "serde_json", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b159c76 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] +resolver = "2" +members = ["crates/*"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://git.joelovestech.com/Hound/Antivirus.git" + +[workspace.dependencies] +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +clap = { version = "4", features = ["derive"] } +colored = "2" + +[profile.release] +lto = true +strip = true +codegen-units = 1 diff --git a/crates/hound-api/Cargo.toml b/crates/hound-api/Cargo.toml new file mode 100644 index 0000000..192b2ba --- /dev/null +++ b/crates/hound-api/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "hound-api" +description = "Shared wire types + Unix-socket JSON-RPC client for Hound" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs new file mode 100644 index 0000000..b4512f4 --- /dev/null +++ b/crates/hound-api/src/lib.rs @@ -0,0 +1,163 @@ +//! Shared wire types and a minimal line-delimited JSON-RPC 2.0 client +//! used by both the CLI and the GUI to talk to the `houndd` daemon. +//! +//! The protocol is deliberately simple: each request/response is one +//! JSON object per line (UTF-8, `\n`-terminated). This makes it trivial +//! to hand-debug with `nc` and keeps the client dependency-free. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{BufRead, BufReader, Write}; +#[cfg(unix)] +use std::os::unix::net::UnixStream; +#[cfg(windows)] +use std::os::windows::net::UnixStream; + +/// The default socket path when `$HOUNDD_SOCK` is unset. +pub fn default_socket_path() -> String { + if let Ok(sock) = std::env::var("HOUNDD_SOCK") { + return sock; + } + let runtime = std::env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| "/run/user/1000".to_string()); + format!("{runtime}/houndd.sock") +} + +// ── Request / Response envelopes ──────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + pub jsonrpc: String, + pub id: u64, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub jsonrpc: String, + pub id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorObject { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl Response { + /// Unwrap `result`, failing with the remote error message if present. + pub fn into_result(self) -> anyhow::Result { + if let Some(err) = self.error { + anyhow::bail!("daemon error {}: {}", err.code, err.message); + } + self.result + .ok_or_else(|| anyhow::anyhow!("daemon returned neither result nor error")) + } +} + +// ── Domain types ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Status { + pub daemon_version: String, + pub clamav_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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Found { + pub path: String, + pub virus: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ScanResult { + pub scanned: u64, + pub clean: u64, + pub infected: u64, + pub found: Vec, +} + +impl ScanResult { + pub fn is_clean(&self) -> bool { + self.infected == 0 + } +} + +// ── Client ────────────────────────────────────────────────────────────────── + +/// A small blocking JSON-RPC client over a Unix socket. +/// +/// Each [`call`] opens a fresh connection, so it is safe to use from any +/// thread and trivially correct (no framing state to leak between calls). +#[derive(Debug, Clone)] +pub struct Client { + sock: String, +} + +impl Client { + pub fn new(sock: String) -> Self { + Self { sock } + } + + pub fn default_path() -> Self { + Self::new(default_socket_path()) + } + + /// Connect and issue a single request. Returns the decoded `result`. + pub fn call( + &self, + id: u64, + method: &str, + params: Option, + ) -> anyhow::Result { + let mut stream = UnixStream::connect(&self.sock) + .map_err(|e| anyhow::anyhow!("cannot reach houndd at {}: {e}", self.sock))?; + let req = Request { + jsonrpc: "2.0".into(), + id, + method: method.into(), + params, + }; + let mut line = serde_json::to_string(&req)?; + line.push('\n'); + stream + .write_all(line.as_bytes()) + .map_err(|e| anyhow::anyhow!("write failed: {e}"))?; + + let reader = BufReader::new(stream); + for raw in reader.lines() { + let line: String = raw?; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let resp: Response = serde_json::from_str(trimmed)?; + return resp.into_result(); + } + anyhow::bail!("daemon closed the connection before responding") + } + + /// Convenience wrappers for the known methods. + pub fn status(&self) -> anyhow::Result { + let v = self.call(1, "status", None)?; + Ok(serde_json::from_value(v)?) + } + + pub fn scan(&self, path: &str, recursive: bool) -> anyhow::Result { + let params = serde_json::json!({ "path": path, "recursive": recursive }); + let v = self.call(2, "scan", Some(params))?; + Ok(serde_json::from_value(v)?) + } +} diff --git a/crates/hound/Cargo.toml b/crates/hound/Cargo.toml new file mode 100644 index 0000000..eba5dd8 --- /dev/null +++ b/crates/hound/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "hound" +description = "Hound CLI client for the houndd daemon" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "hound" +path = "src/main.rs" + +[dependencies] +hound-api = { path = "../hound-api" } +anyhow.workspace = true +serde_json.workspace = true +clap.workspace = true +colored.workspace = true diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs new file mode 100644 index 0000000..348e510 --- /dev/null +++ b/crates/hound/src/main.rs @@ -0,0 +1,144 @@ +//! `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}; + +#[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, + }, +} + +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.clamav_present { + println!("{} {}", "Hound engine:", st.daemon_version.green().bold()); + println!(" OS: {}", st.os); + println!(" ClamAV: {}", "present".green()); + println!(" Signatures:{}", st.db_summary); + } else { + println!("{} {}", "Hound engine:", st.daemon_version.green().bold()); + println!(" OS: {}", st.os); + println!(" ClamAV: {}", "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 }) + } + } +} + +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()); + } +} diff --git a/crates/houndd/Cargo.toml b/crates/houndd/Cargo.toml new file mode 100644 index 0000000..3cab35d --- /dev/null +++ b/crates/houndd/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "houndd" +description = "Hound daemon: ClamAV-backed Unix-socket JSON-RPC engine" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "houndd" +path = "src/main.rs" + +[dependencies] +hound-api = { path = "../hound-api" } +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs new file mode 100644 index 0000000..feb4ab5 --- /dev/null +++ b/crates/houndd/src/main.rs @@ -0,0 +1,275 @@ +//! `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. +//! +//! Current methods: +//! - `status` → engine health, ClamAV presence, signature-DB summary +//! - `scan` → recursive ClamAV scan of a path, returns per-file findings + +use anyhow::{bail, Context, Result}; +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"); + +fn main() -> Result<()> { + let sock = hound_api::default_socket_path(); + let sock_path = PathBuf::from(&sock); + if let Some(parent) = sock_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating socket dir {}", parent.display()))?; + } + // Reconnect-friendly startup: drop a stale socket from a dead daemon. + let _ = fs::remove_file(&sock_path); + let listener = UnixListener::bind(&sock_path) + .with_context(|| format!("binding {sock}"))?; + eprintln!( + "houndd {DAEMON_VERSION} listening on {sock} (Ctrl-C to stop)" + ); + + for stream in listener.incoming() { + let stream = match stream { + Ok(s) => s, + Err(e) => { + eprintln!("accept error: {e}"); + continue; + } + }; + std::thread::spawn(move || { + if let Err(e) = handle_conn(stream) { + eprintln!("connection error: {e}"); + } + }); + } + Ok(()) +} + +/// Read one request line, dispatch, write one response line. +fn handle_conn(stream: UnixStream) -> Result<()> { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line)?; + + let req: hound_api::Request = serde_json::from_str(line.trim()) + .context("decoding request")?; + + let result = dispatch(&req); + let resp = match result { + Ok(value) => Response { + jsonrpc: "2.0".into(), + id: req.id, + result: Some(value), + error: None, + }, + Err(e) => Response { + jsonrpc: "2.0".into(), + id: req.id, + result: None, + error: Some(hound_api::ErrorObject { + code: -32000, + message: e.to_string(), + data: None, + }), + }, + }; + let mut out = serde_json::to_string(&resp)?; + out.push('\n'); + writer_flush(&mut reader, &out)?; + Ok(()) +} + +fn writer_flush( + reader: &mut BufReader, + 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())?; + stream.flush()?; + Ok(()) +} + +fn dispatch(req: &hound_api::Request) -> Result { + match req.method.as_str() { + "status" => Ok(serde_json::to_value(status())?), + "scan" => { + let path = req + .params + .as_ref() + .and_then(|p| p.get("path")) + .and_then(Value::as_str) + .context("scan requires params.path")?; + let recursive = req + .params + .as_ref() + .and_then(|p| p.get("recursive")) + .and_then(Value::as_bool) + .unwrap_or(true); + Ok(serde_json::to_value(scan(path, recursive)?)?) + } + other => bail!("unknown method {other:?}"), + } +} + +// ── status ────────────────────────────────────────────────────────────────── + +fn status() -> hound_api::Status { + let (present, db_summary) = clamav_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()) + }) + .unwrap_or_else(|| "unknown".into()); + hound_api::Status { + daemon_version: DAEMON_VERSION.to_string(), + clamav_present: present, + db_summary, + os, + } +} + +/// `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) +} + +// ── scan ──────────────────────────────────────────────────────────────────── + +/// 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 { + 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 = std::collections::HashSet::new(); + let mut reported: std::collections::HashSet = 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, + }) +} + +#[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()); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..292fe49 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable"