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
This commit is contained in:
John McAfee 2026-08-20 16:59:14 -05:00
parent 9dfe685db4
commit 2cf41ce3bd
12 changed files with 1059 additions and 0 deletions

18
.editorconfig Normal file
View file

@ -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

3
.env.example Normal file
View file

@ -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

21
.gitignore vendored Normal file
View file

@ -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

365
Cargo.lock generated Normal file
View file

@ -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"

21
Cargo.toml Normal file
View file

@ -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

View file

@ -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

163
crates/hound-api/src/lib.rs Normal file
View file

@ -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<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response {
pub jsonrpc: String,
pub id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ErrorObject>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorObject {
pub code: i64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl Response {
/// Unwrap `result`, failing with the remote error message if present.
pub fn into_result(self) -> anyhow::Result<Value> {
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<Found>,
}
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<Value>,
) -> anyhow::Result<Value> {
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<Status> {
let v = self.call(1, "status", None)?;
Ok(serde_json::from_value(v)?)
}
pub fn scan(&self, path: &str, recursive: bool) -> anyhow::Result<ScanResult> {
let params = serde_json::json!({ "path": path, "recursive": recursive });
let v = self.call(2, "scan", Some(params))?;
Ok(serde_json::from_value(v)?)
}
}

18
crates/hound/Cargo.toml Normal file
View file

@ -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

144
crates/hound/src/main.rs Normal file
View file

@ -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<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,
},
}
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.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());
}
}

17
crates/houndd/Cargo.toml Normal file
View file

@ -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

275
crates/houndd/src/main.rs Normal file
View file

@ -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<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())?;
stream.flush()?;
Ok(())
}
fn dispatch(req: &hound_api::Request) -> Result<Value> {
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<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,
})
}
#[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());
}
}

2
rust-toolchain.toml Normal file
View file

@ -0,0 +1,2 @@
[toolchain]
channel = "stable"