Freshclam wrapper: update RPC, DB age in status, engine-agnostic fields
- hound-api: DbFile (file + updated_at), Status.db, UpdateResult, Client::update() - houndd: probe() extracts signature freshness from /var/lib/clamav mtimes; update() tries 'sudo freshclam', falls back to plain 'freshclam' and reports the honest reason on failure; output capped to 2KB tail - hound CLI: 'hound update' with --json parity and human output - rename Status.clamav_present -> engine_present + add Status.engine (prep for the engine seam; wire stays engine-agnostic) Live-verified: status shows DB file + last-modified RFC3339; update returns ok:false with the real log for a plain user (exit 1).
This commit is contained in:
parent
bbbabefdda
commit
566e0cbccb
8 changed files with 570 additions and 144 deletions
53
Cargo.lock
generated
53
Cargo.lock
generated
|
|
@ -114,6 +114,15 @@ dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
|
|
@ -138,6 +147,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -148,6 +158,7 @@ dependencies = [
|
||||||
"hound-api",
|
"hound-api",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -174,12 +185,24 @@ version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell_polyfill"
|
name = "once_cell_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
|
|
@ -258,6 +281,36 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.55"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
colored = "2"
|
colored = "2"
|
||||||
|
time = { version = "0.3", features = ["serde", "std", "formatting"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,4 @@ repository.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
time.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,29 @@ impl Response {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct Status {
|
pub struct Status {
|
||||||
pub daemon_version: String,
|
pub daemon_version: String,
|
||||||
pub clamav_present: bool,
|
/// Which engine the daemon is running (see `houndd::engine::ENGINE`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub engine: String,
|
||||||
|
/// Whether the active engine is present/usable on this machine —
|
||||||
|
/// the tray's "engine online/offline" signal.
|
||||||
|
pub engine_present: bool,
|
||||||
/// e.g. "daily.cld (Aug 20 2025)" — empty if no DB.
|
/// e.g. "daily.cld (Aug 20 2025)" — empty if no DB.
|
||||||
pub db_summary: String,
|
pub db_summary: String,
|
||||||
/// Human-readable OS string for the GUI banner.
|
/// Human-readable OS string for the GUI banner.
|
||||||
pub os: String,
|
pub os: String,
|
||||||
|
/// Newest signature-DB file (e.g. "daily-20260820.cld") + its mtime
|
||||||
|
/// as an ISO-8601 timestamp. Clients use this to derive "signatures
|
||||||
|
/// stale → amber" instead of parsing `db_summary` prose.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub db: Option<DbFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DbFile {
|
||||||
|
/// Basename of the newest .cld/.ndb signature file.
|
||||||
|
pub file: String,
|
||||||
|
/// Last-modified time of that file, e.g. "2026-08-20T01:24:22Z".
|
||||||
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -89,6 +107,19 @@ pub struct ScanResult {
|
||||||
pub found: Vec<Found>,
|
pub found: Vec<Found>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of the `update` method (a freshclam run).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateResult {
|
||||||
|
/// True when freshclam exited 0 (DB actually refreshed or already current).
|
||||||
|
pub ok: bool,
|
||||||
|
/// The freshclam run we attempted: "sudo freshclam" or "freshclam".
|
||||||
|
pub command: String,
|
||||||
|
/// Combined stdout+stderr tail (last ~2k chars) for display.
|
||||||
|
pub output: String,
|
||||||
|
/// Post-update status — callers use this to refresh their UI/tray.
|
||||||
|
pub status: Status,
|
||||||
|
}
|
||||||
|
|
||||||
impl ScanResult {
|
impl ScanResult {
|
||||||
pub fn is_clean(&self) -> bool {
|
pub fn is_clean(&self) -> bool {
|
||||||
self.infected == 0
|
self.infected == 0
|
||||||
|
|
@ -160,4 +191,9 @@ impl Client {
|
||||||
let v = self.call(2, "scan", Some(params))?;
|
let v = self.call(2, "scan", Some(params))?;
|
||||||
Ok(serde_json::from_value(v)?)
|
Ok(serde_json::from_value(v)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update(&self) -> anyhow::Result<UpdateResult> {
|
||||||
|
let v = self.call(3, "update", None)?;
|
||||||
|
Ok(serde_json::from_value(v)?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use hound_api::{Client, ScanResult};
|
use hound_api::{Client, ScanResult, UpdateResult};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
|
|
@ -46,6 +46,12 @@ enum Cmd {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
|
/// Refresh ClamAV signature databases (freshclam)
|
||||||
|
Update {
|
||||||
|
/// Emit machine-readable JSON instead of human text
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|
@ -79,15 +85,31 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Cmd::Status => {
|
Cmd::Status => {
|
||||||
let st = client.status()?;
|
let st = client.status()?;
|
||||||
if st.clamav_present {
|
if st.engine_present {
|
||||||
println!("{} {}", "Hound engine:", st.daemon_version.green().bold());
|
println!(
|
||||||
|
"{} {} [engine: {}]",
|
||||||
|
"Hound engine:",
|
||||||
|
st.daemon_version.green().bold(),
|
||||||
|
st.engine
|
||||||
|
);
|
||||||
println!(" OS: {}", st.os);
|
println!(" OS: {}", st.os);
|
||||||
println!(" ClamAV: {}", "present".green());
|
println!(" Engine: {}", "present".green());
|
||||||
println!(" Signatures:{}", st.db_summary);
|
println!(" Signatures:{}", st.db_summary);
|
||||||
|
if let Some(db) = &st.db {
|
||||||
|
println!(" DB file: {} (last modified {})", db.file, db.updated_at);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("{} {}", "Hound engine:", st.daemon_version.green().bold());
|
println!(
|
||||||
|
"{} {} [engine: {}]",
|
||||||
|
"Hound engine:",
|
||||||
|
st.daemon_version.green().bold(),
|
||||||
|
st.engine
|
||||||
|
);
|
||||||
println!(" OS: {}", st.os);
|
println!(" OS: {}", st.os);
|
||||||
println!(" ClamAV: {}", "NOT FOUND — sudo apt install clamav".red());
|
println!(
|
||||||
|
" Engine: {}",
|
||||||
|
"NOT FOUND — sudo apt install clamav".red()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +126,59 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
|
||||||
print_human(&r, path);
|
print_human(&r, path);
|
||||||
Ok(if r.is_clean() { 0 } else { 1 })
|
Ok(if r.is_clean() { 0 } else { 1 })
|
||||||
}
|
}
|
||||||
|
Cmd::Update { json } => {
|
||||||
|
let u: UpdateResult = client.update()?;
|
||||||
|
if *json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&u)?);
|
||||||
|
return Ok(if u.ok { 0 } else { 1 });
|
||||||
|
}
|
||||||
|
print_update_human(&u);
|
||||||
|
Ok(if u.ok { 0 } else { 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_update_human(u: &UpdateResult) {
|
||||||
|
if u.ok {
|
||||||
|
println!(
|
||||||
|
"{} signature DB refreshed via `{}`",
|
||||||
|
"✔".green().bold(),
|
||||||
|
u.command
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"{} update failed via `{}`",
|
||||||
|
"✘".red().bold(),
|
||||||
|
u.command
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Show the tail of the run (errors, "already current", etc.).
|
||||||
|
for line in u.output.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if line.starts_with("ERROR") || line.starts_with("WARNING") {
|
||||||
|
println!(" {}", line.yellow());
|
||||||
|
} else {
|
||||||
|
println!(" {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(db) = &u.status.db {
|
||||||
|
println!(
|
||||||
|
" {}{}",
|
||||||
|
"DB now: ".dimmed(),
|
||||||
|
format!("{} (updated {})", db.file, db.updated_at)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !u.ok {
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
"hint: plain-user freshclam needs write access to /var/lib/clamav \
|
||||||
|
and /var/log/clamav — run the update from the GUI (polkit) or as \
|
||||||
|
a user in the clamav group."
|
||||||
|
.dimmed()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,4 @@ hound-api = { path = "../hound-api" }
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
time.workspace = true
|
||||||
|
|
|
||||||
327
crates/houndd/src/engine.rs
Normal file
327
crates/houndd/src/engine.rs
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
//! The engine seam.
|
||||||
|
//!
|
||||||
|
//! Everything ClamAV-specific (version probe, signature freshness, the
|
||||||
|
//! `clamscan` subprocess + output parsing, `freshclam`) lives behind
|
||||||
|
//! [`ScanEngine`]. The daemon's `status` / `scan` / `update` RPCs are
|
||||||
|
//! defined against the trait, so a native Rust engine later — ours or a
|
||||||
|
//! rewritten ClamAV — plugs in by implementing four methods and flipping
|
||||||
|
//! the const at the bottom. Nothing on the wire, in the CLI, or in the
|
||||||
|
//! GUI needs to change.
|
||||||
|
//!
|
||||||
|
//! The wire API is deliberately engine-agnostic: `Status.engine`
|
||||||
|
//! identifies the implementation, and DB freshness travels as a file
|
||||||
|
//! name + timestamp (any signature store has those two facts).
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use hound_api::{DbFile, ScanResult};
|
||||||
|
use std::fs;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// What an engine implementation must answer.
|
||||||
|
pub trait ScanEngine {
|
||||||
|
/// Stable id for the wire (`Status.engine`): "clamav" today, e.g.
|
||||||
|
/// "hound-native" when the Rust engine ships.
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Presence + signature-DB freshness. `present` is the tray's
|
||||||
|
/// "engine online/offline" signal.
|
||||||
|
fn probe(&self) -> (bool, String, Option<DbFile>);
|
||||||
|
|
||||||
|
/// Scan `path` (canonicalize first) and return per-file findings.
|
||||||
|
fn scan(&self, path: &str, recursive: bool) -> Result<ScanResult>;
|
||||||
|
|
||||||
|
/// Refresh the signature store. Returns (success, command label,
|
||||||
|
/// combined stdout+stderr tail) for the last attempt made.
|
||||||
|
fn update(&self) -> Result<(bool, String, String)>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ClamAV-backed engine: `clamscan` + `freshclam` over their
|
||||||
|
/// well-behaved text interfaces.
|
||||||
|
pub struct ClamAvEngine;
|
||||||
|
|
||||||
|
impl ScanEngine for ClamAvEngine {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"clamav"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe(&self) -> (bool, String, Option<DbFile>) {
|
||||||
|
let version = Command::new("clamscan")
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if version.is_empty() {
|
||||||
|
return (false, String::new(), None);
|
||||||
|
}
|
||||||
|
// freshclam's DB files live in /var/lib/clamav; report the newest.
|
||||||
|
let mut newest: Option<(String, std::time::SystemTime)> = None;
|
||||||
|
for entry in fs::read_dir("/var/lib/clamav").into_iter().flatten().flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext == "cld" || ext == "ndb")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|f| f.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let replace = match &newest {
|
||||||
|
None => true,
|
||||||
|
Some((_, cur)) => mtime > *cur,
|
||||||
|
};
|
||||||
|
if replace {
|
||||||
|
newest = Some((name, mtime));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match newest {
|
||||||
|
Some((file, t)) => {
|
||||||
|
let days = std::time::SystemTime::now()
|
||||||
|
.duration_since(t)
|
||||||
|
.map(|d| d.as_secs() / 86_400)
|
||||||
|
.unwrap_or(0);
|
||||||
|
(
|
||||||
|
true,
|
||||||
|
format!(
|
||||||
|
"signatures updated {days}d ago ({file}) [clamav {version}]"
|
||||||
|
),
|
||||||
|
Some(DbFile {
|
||||||
|
file,
|
||||||
|
updated_at: to_rfc3339(t),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => (
|
||||||
|
true,
|
||||||
|
format!("no signature DB found (run: sudo freshclam) [clamav {version}]"),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan(&self, path: &str, recursive: bool) -> Result<ScanResult> {
|
||||||
|
let path = fs::canonicalize(path).with_context(|| format!("no such path: {path}"))?;
|
||||||
|
let mut cmd = Command::new("clamscan");
|
||||||
|
cmd.arg("--no-summary")
|
||||||
|
.arg("--stdout")
|
||||||
|
.arg("--max-filesize=100M")
|
||||||
|
.arg("--max-scansize=250M");
|
||||||
|
if recursive {
|
||||||
|
cmd.arg("-r");
|
||||||
|
}
|
||||||
|
cmd.arg("--allmatch").arg(path);
|
||||||
|
|
||||||
|
let out = cmd
|
||||||
|
.output()
|
||||||
|
.context("running clamscan (is ClamAV installed?)")?;
|
||||||
|
|
||||||
|
Ok(parse_clamscan(&out.stdout, out.status.code().unwrap_or(-1))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&self) -> Result<(bool, String, String)> {
|
||||||
|
let attempts: [(&str, &[&str]); 2] = [
|
||||||
|
("sudo freshclam", &["sudo", "freshclam", "--no-dns"]),
|
||||||
|
("freshclam", &["freshclam", "--no-dns"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let (ok, label, out) = if std::env::var_os("HOUNDD_NO_SUDO").is_some() {
|
||||||
|
// Daemon already running as root: skip sudo (it would prompt).
|
||||||
|
let (label, argv) = attempts[1];
|
||||||
|
let (label, out) = run_cmd(label, argv)?;
|
||||||
|
(out.status.success(), label, out)
|
||||||
|
} else {
|
||||||
|
let (label, argv) = attempts[0];
|
||||||
|
let (label, out) = run_cmd(label, argv)?;
|
||||||
|
if out.status.success() {
|
||||||
|
(true, label, out)
|
||||||
|
} else {
|
||||||
|
// Plain user without group perms: report the honest reason.
|
||||||
|
let (label2, argv2) = attempts[1];
|
||||||
|
let (label2, out2) = run_cmd(label2, argv2)?;
|
||||||
|
(out2.status.success(), label2, out2)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut combined = String::from_utf8_lossy(&out.stdout).to_string();
|
||||||
|
if !out.stderr.is_empty() {
|
||||||
|
combined.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||||
|
}
|
||||||
|
Ok((ok, label.to_string(), combined))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_cmd(label: &str, argv: &[&str]) -> Result<(String, std::process::Output)> {
|
||||||
|
let out = Command::new(argv[0])
|
||||||
|
.args(&argv[1..])
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("running {label} failed (installed?)"))?;
|
||||||
|
Ok((label.to_string(), out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `clamscan --stdout` output into findings.
|
||||||
|
///
|
||||||
|
/// Every file ClamAV looks at emits exactly one line:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// /abs/path: OK
|
||||||
|
/// /abs/path: VirusName FOUND
|
||||||
|
/// /abs/path: INCOMPLETE
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// We parse those lines (not `--json`) because the text format is stable
|
||||||
|
/// across ClamAV 0.103 → 1.x while `--json` fields have churned.
|
||||||
|
/// `--allmatch` reports *every* signature a file matches (EICAR trips 3),
|
||||||
|
/// so each path is counted once for the scanned total and reported once
|
||||||
|
/// as a finding.
|
||||||
|
pub fn parse_clamscan(stdout: &[u8], exit_code: i32) -> Result<ScanResult> {
|
||||||
|
use hound_api::Found;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(stdout);
|
||||||
|
let mut found: Vec<Found> = Vec::new();
|
||||||
|
let mut seen_files: HashSet<String> = HashSet::new();
|
||||||
|
let mut reported: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
// A per-file result line starts with the path then ": ".
|
||||||
|
let Some(idx) = line.find(": ") else { continue };
|
||||||
|
let file = line[..idx].trim();
|
||||||
|
if !file.starts_with('/') || file.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen_files.insert(file.to_string());
|
||||||
|
let body = &line[idx + 2..];
|
||||||
|
if let Some(end) = body.rfind(" FOUND") {
|
||||||
|
let key = file.to_string();
|
||||||
|
if reported.insert(key.clone()) {
|
||||||
|
found.push(Found {
|
||||||
|
path: key,
|
||||||
|
virus: body[..end].to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 = no infections, 1 = infections found, >1 = real error.
|
||||||
|
if !((exit_code == 0 || exit_code == 1)) {
|
||||||
|
anyhow::bail!("clamscan exited {exit_code}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let scanned = seen_files.len() as u64;
|
||||||
|
let infected = found.len() as u64;
|
||||||
|
let clean = scanned.saturating_sub(infected);
|
||||||
|
|
||||||
|
Ok(ScanResult {
|
||||||
|
scanned,
|
||||||
|
clean,
|
||||||
|
infected,
|
||||||
|
found,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a `SystemTime` as RFC3339 UTC for the wire.
|
||||||
|
pub fn to_rfc3339(t: std::time::SystemTime) -> String {
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
let Ok(d) = t.duration_since(std::time::UNIX_EPOCH) else {
|
||||||
|
return "unknown".into();
|
||||||
|
};
|
||||||
|
let Ok(dt) = time::OffsetDateTime::from_unix_timestamp(d.as_secs() as i64) else {
|
||||||
|
return "unknown".into();
|
||||||
|
};
|
||||||
|
dt.format(&Rfc3339).unwrap_or_else(|_| "unknown".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which engine the daemon serves. Flip this when the native engine
|
||||||
|
/// lands — the whole point of the trait is that this is the only change
|
||||||
|
/// the daemon needs.
|
||||||
|
pub const ENGINE: ClamAvEngine = ClamAvEngine;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_clean_scan() {
|
||||||
|
let out = br"/tmp/a.txt: OK
|
||||||
|
/tmp/b.txt: OK
|
||||||
|
";
|
||||||
|
let r = parse_clamscan(out, 0).unwrap();
|
||||||
|
assert_eq!(r.scanned, 2);
|
||||||
|
assert_eq!(r.clean, 2);
|
||||||
|
assert_eq!(r.infected, 0);
|
||||||
|
assert!(r.found.is_empty());
|
||||||
|
assert!(r.is_clean());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_infected_scan_dedup_allmatch() {
|
||||||
|
// --allmatch: one EICAR file trips several signatures, one line each.
|
||||||
|
let out = br"/tmp/eicar.com: Eicar-Test-Signature FOUND\n\
|
||||||
|
/tmp/eicar.com: Test.Virus TWO FOUND\n\
|
||||||
|
/tmp/clean.txt: OK\n";
|
||||||
|
let r = parse_clamscan(out, 1).unwrap();
|
||||||
|
assert_eq!(r.scanned, 2, "two unique files, not three lines");
|
||||||
|
assert_eq!(r.infected, 1, "EICAR must count as one threat");
|
||||||
|
assert_eq!(r.clean, 1);
|
||||||
|
assert_eq!(r.found.len(), 1);
|
||||||
|
assert_eq!(r.found[0].path, "/tmp/eicar.com");
|
||||||
|
assert_eq!(r.found[0].virus, "Eicar-Test-Signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_lines_counted_not_infected() {
|
||||||
|
let out = br"/tmp/big.bin: INCOMPLETE
|
||||||
|
/tmp/x: OK
|
||||||
|
";
|
||||||
|
let r = parse_clamscan(out, 0).unwrap();
|
||||||
|
assert_eq!(r.scanned, 2);
|
||||||
|
assert_eq!(r.infected, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonzero_exit_is_error() {
|
||||||
|
let r = parse_clamscan(b"", 7);
|
||||||
|
assert!(r.is_err());
|
||||||
|
assert!(r.unwrap_err().to_string().contains("7"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn e2e_eicar_via_real_clamscan() {
|
||||||
|
// Skips itself when ClamAV isn't installed (CI boxes).
|
||||||
|
let which = Command::new("clamscan").arg("--version").output();
|
||||||
|
if which.as_ref().is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tmp = std::env::temp_dir().join(format!("houndd-engine-test-{}", std::process::id()));
|
||||||
|
let _ = fs::create_dir_all(&tmp);
|
||||||
|
std::fs::write(
|
||||||
|
tmp.join("eicar.bin"),
|
||||||
|
"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let engine = ClamAvEngine;
|
||||||
|
let r = engine.scan(tmp.to_str().unwrap(), true).unwrap();
|
||||||
|
assert_eq!(r.scanned, 1);
|
||||||
|
assert_eq!(r.infected, 1);
|
||||||
|
assert_eq!(r.found[0].virus, "Eicar-Test-Signature");
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_reports_present() {
|
||||||
|
let (present, _summary, _db) = ClamAvEngine.probe();
|
||||||
|
// CI boxes without ClamAV: presence is whatever the OS says.
|
||||||
|
let via_cmd = Command::new("clamscan").arg("--version").output();
|
||||||
|
assert_eq!(present, via_cmd.map(|o| o.status.success()).unwrap_or(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trait_is_object_safe() {
|
||||||
|
fn take(_e: &dyn ScanEngine) {}
|
||||||
|
take(&ENGINE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,30 @@
|
||||||
//! `houndd` — the Hound engine.
|
//! `houndd` — the Hound engine.
|
||||||
//!
|
//!
|
||||||
//! A tiny single-purpose daemon that exposes ClamAV over a Unix-socket,
|
//! A tiny single-purpose daemon that exposes a scanning engine over a
|
||||||
//! line-delimited JSON-RPC 2.0 API. Both the CLI and the GUI are thin
|
//! Unix-socket, line-delimited JSON-RPC 2.0 API. Both the CLI and the
|
||||||
//! clients of this socket, which is what lets future suite tools
|
//! GUI are thin clients of this socket, which is what lets future suite
|
||||||
//! (firewall, updater, …) share the same engine without forking it.
|
//! tools (firewall, updater, …) share the same engine without forking
|
||||||
|
//! it.
|
||||||
|
//!
|
||||||
|
//! The engine itself is pluggable behind [`engine::ScanEngine`] — today
|
||||||
|
//! that's [`engine::ClamAvEngine`], tomorrow a native Rust engine. The
|
||||||
|
//! wire API doesn't know the difference; it only reports `Status.engine`.
|
||||||
//!
|
//!
|
||||||
//! Current methods:
|
//! Current methods:
|
||||||
//! - `status` → engine health, ClamAV presence, signature-DB summary
|
//! - `status` → engine health, signature-DB freshness
|
||||||
//! - `scan` → recursive ClamAV scan of a path, returns per-file findings
|
//! - `scan` → recursive scan of a path, per-file findings
|
||||||
|
//! - `update` → refresh the signature store
|
||||||
|
|
||||||
|
mod engine;
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
use engine::{ScanEngine, ENGINE};
|
||||||
use hound_api::Response;
|
use hound_api::Response;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
|
@ -32,7 +40,8 @@ fn main() -> Result<()> {
|
||||||
let listener = UnixListener::bind(&sock_path)
|
let listener = UnixListener::bind(&sock_path)
|
||||||
.with_context(|| format!("binding {sock}"))?;
|
.with_context(|| format!("binding {sock}"))?;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"houndd {DAEMON_VERSION} listening on {sock} (Ctrl-C to stop)"
|
"houndd {DAEMON_VERSION} listening on {sock} [engine: {}] (Ctrl-C to stop)",
|
||||||
|
ENGINE.name()
|
||||||
);
|
);
|
||||||
|
|
||||||
for stream in listener.incoming() {
|
for stream in listener.incoming() {
|
||||||
|
|
@ -86,10 +95,7 @@ fn handle_conn(stream: UnixStream) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn writer_flush(
|
fn writer_flush(reader: &mut BufReader<UnixStream>, bytes: &str) -> Result<()> {
|
||||||
reader: &mut BufReader<UnixStream>,
|
|
||||||
bytes: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
// The BufReader consumed the stream; get the stream back out to write.
|
// The BufReader consumed the stream; get the stream back out to write.
|
||||||
let stream = reader.get_mut();
|
let stream = reader.get_mut();
|
||||||
stream.write_all(bytes.as_bytes())?;
|
stream.write_all(bytes.as_bytes())?;
|
||||||
|
|
@ -100,6 +106,7 @@ fn writer_flush(
|
||||||
fn dispatch(req: &hound_api::Request) -> Result<Value> {
|
fn dispatch(req: &hound_api::Request) -> Result<Value> {
|
||||||
match req.method.as_str() {
|
match req.method.as_str() {
|
||||||
"status" => Ok(serde_json::to_value(status())?),
|
"status" => Ok(serde_json::to_value(status())?),
|
||||||
|
"update" => Ok(serde_json::to_value(update()?)?),
|
||||||
"scan" => {
|
"scan" => {
|
||||||
let path = req
|
let path = req
|
||||||
.params
|
.params
|
||||||
|
|
@ -119,157 +126,82 @@ fn dispatch(req: &hound_api::Request) -> Result<Value> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── status ──────────────────────────────────────────────────────────────────
|
// ── RPC handlers (engine-agnostic) ──────────────────────────────────────────
|
||||||
|
|
||||||
fn status() -> hound_api::Status {
|
fn status() -> hound_api::Status {
|
||||||
let (present, db_summary) = clamav_probe();
|
let (present, db_summary, db) = ENGINE.probe();
|
||||||
let os = std::fs::read_to_string("/etc/os-release")
|
let os = std::fs::read_to_string("/etc/os-release")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|c| {
|
.and_then(|c| {
|
||||||
c.lines()
|
c.lines()
|
||||||
.find(|l| l.starts_with("PRETTY_NAME="))
|
.find(|l| l.starts_with("PRETTY_NAME="))
|
||||||
.map(|l| l.trim_start_matches("PRETTY_NAME=").trim_matches('"').to_string())
|
.map(|l| {
|
||||||
|
l.trim_start_matches("PRETTY_NAME=")
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "unknown".into());
|
.unwrap_or_else(|| "unknown".into());
|
||||||
hound_api::Status {
|
hound_api::Status {
|
||||||
daemon_version: DAEMON_VERSION.to_string(),
|
daemon_version: DAEMON_VERSION.to_string(),
|
||||||
clamav_present: present,
|
engine: ENGINE.name().to_string(),
|
||||||
|
engine_present: present,
|
||||||
db_summary,
|
db_summary,
|
||||||
os,
|
os,
|
||||||
|
db,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `clamscan --version` for presence; `--stat`-style summary for the DB.
|
/// Refresh the signature store via the engine, then re-probe so the
|
||||||
fn clamav_probe() -> (bool, String) {
|
/// client can refresh its UI/tray from a single round-trip.
|
||||||
let version = Command::new("clamscan")
|
fn update() -> Result<hound_api::UpdateResult> {
|
||||||
.arg("--version")
|
use hound_api::UpdateResult;
|
||||||
.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 ────────────────────────────────────────────────────────────────────
|
let (ok, command, combined) = ENGINE.update()?;
|
||||||
|
|
||||||
/// Run `clamscan -r` over a path and parse its deterministic stdout tail:
|
Ok(UpdateResult {
|
||||||
///
|
ok,
|
||||||
/// ```text
|
command,
|
||||||
/// /path/eicar.com: EICAR-Test-File FOUND
|
output: cap_tail(&combined, 2048),
|
||||||
///
|
status: status(),
|
||||||
/// ----------------------
|
|
||||||
/// 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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep the tail of `s` within `max` bytes, back-stepping to a char
|
||||||
|
/// boundary and prefixing an ellipsis marker.
|
||||||
|
fn cap_tail(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let cut = s.len() - max;
|
||||||
|
let cut = s
|
||||||
|
.char_indices()
|
||||||
|
.take_while(|(i, _)| *i < cut)
|
||||||
|
.last()
|
||||||
|
.map(|(i, c)| i + c.len_utf8())
|
||||||
|
.unwrap_or(cut);
|
||||||
|
format!("…\n{}", &s[cut..])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan(path: &str, recursive: bool) -> Result<hound_api::ScanResult> {
|
||||||
|
ENGINE.scan(path, recursive)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_type_is_constructible() {
|
fn client_type_is_constructible() {
|
||||||
let _c: hound_api::Client = hound_api::Client::new("/tmp/does-not-matter.sock".into());
|
let _c: hound_api::Client =
|
||||||
|
hound_api::Client::new("/tmp/does-not-matter.sock".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cap_tail_keeps_char_boundaries() {
|
||||||
|
let s = "é".repeat(3000);
|
||||||
|
let t = cap_tail(&s, 100);
|
||||||
|
assert!(t.len() <= 100 + 4); // "…\n" prefix is 4 bytes
|
||||||
|
assert!(t.starts_with('…'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue