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:
parent
9dfe685db4
commit
2cf41ce3bd
12 changed files with 1059 additions and 0 deletions
18
.editorconfig
Normal file
18
.editorconfig
Normal 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
3
.env.example
Normal 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
21
.gitignore
vendored
Normal 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
365
Cargo.lock
generated
Normal 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
21
Cargo.toml
Normal 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
|
||||
12
crates/hound-api/Cargo.toml
Normal file
12
crates/hound-api/Cargo.toml
Normal 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
163
crates/hound-api/src/lib.rs
Normal 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
18
crates/hound/Cargo.toml
Normal 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
144
crates/hound/src/main.rs
Normal 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
17
crates/houndd/Cargo.toml
Normal 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
275
crates/houndd/src/main.rs
Normal 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
2
rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[toolchain]
|
||||
channel = "stable"
|
||||
Loading…
Reference in a new issue