Supervisión de directorios en tiempo real y análisis antivirus con Rust y ClamAV
Crea un monitor en Rust que analice archivos completos con un demonio local de ClamAV. Informa si están limpios, infectados o si hubo un error del analizador, sin mover ni eliminar archivos. Solo una respuesta explícita que indique que el archivo está limpio permite considerarlo limpio.
¿Por qué usar supervisión en tiempo real y Rust?
Los eventos del sistema de archivos pueden llegar antes de que el proceso de escritura haya terminado. Una espera o un tamaño de archivo sin cambios no demuestran que la escritura haya finalizado. Por eso, este ejemplo utiliza un contrato con el productor: escribe un archivo con un nombre único terminado en .part, ciérralo y luego renómbralo de forma atómica a .ready en el mismo directorio. Nunca modifiques un archivo .ready. Mantén el directorio privado, con acceso exclusivo para la aplicación y su productor de confianza.
El monitor solo analiza archivos .ready del nivel superior. Es una herramienta de notificación, no un mecanismo de control de acceso: un resultado describe los bytes analizados y no autoriza una lectura posterior de una ruta mutable.
Requisitos previos
Usa Linux o macOS con Rust y Cargo. El programa se compiló con Rust 1.98.1 y se probó con ClamAV 1.5.4. Un demonio local clamd debe tener una base de datos de firmas actualizada y un socket Unix accesible para el proceso de Rust.
Configura el demonio de ClamAV (clamd)
En Debian o Ubuntu, instala el demonio y el actualizador de definiciones:
sudo apt-get update
sudo apt-get install clamav-daemon clamav-freshclam
En el archivo clamd.conf de la distribución, conserva los ajustes de la base de datos y del usuario del servicio, y configura un socket local. Este es un fragmento de configuración; asegúrate de que no quede habilitada ninguna directiva TCPSocket:
LocalSocket /var/run/clamav/clamd.ctl
LocalSocketMode 660
StreamMaxLength 10M
MaxFileSize 10M
MaxScanSize 20M
AlertExceedsMax yes
Concede al usuario del servicio de la aplicación acceso al grupo del socket y luego reinicia el servicio. Deja que el servicio FreshClam de la distribución mantenga actualizada la base de datos. No ejecutes un segundo actualizador manual sobre su base de datos bloqueada.
sudo systemctl enable --now clamav-freshclam
sudo systemctl restart clamav-daemon
La documentación del protocolo de ClamAV describe el socket y la estructura de las tramas de INSTREAM. Clamd no tiene autenticación TCP; este ejemplo utiliza únicamente un socket local con acceso controlado mediante permisos. Configura los límites para archivos contenedores y análisis según tu carga de trabajo; las detecciones por límites excedidos no deben tratarse como resultados limpios.
Configura el proyecto
cargo new realtime_virus_scanner --edition 2021 --vcs none
cd realtime_virus_scanner
Reemplaza el contenido de Cargo.toml por lo siguiente:
[package]
name = "realtime_virus_scanner"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "=4.5.50", features = ["derive"] }
notify = "=8.2.0"
tokio = { version = "=1.48.0", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
Conserva Cargo.lock en tu aplicación. El ejemplo implementa el breve intercambio INSTREAM directamente con Tokio, en lugar de depender de una biblioteca cliente adicional para ClamAV.
Interfaz de línea de comandos con clap
La CLI acepta --directory y --socket. Añade --once para analizar los archivos completos existentes y salir: cero significa que todos los archivos seleccionados estaban limpios; uno, que se produjo una infección o un error del analizador. El modo de supervisión repite los análisis hasta que pulses Ctrl-C e informa de cada resultado.
Supervisión de directorios en tiempo real con notify
Un observador recomendado activa el analizador tras un evento de creación, modificación o eliminación. Un canal con capacidad para un solo elemento agrupa los eventos, porque cada activación vuelve a analizar el directorio. Un intervalo de reconciliación de cinco segundos también permite encontrar archivos cuando se pierden eventos. Los eventos de lectura y acceso se ignoran para evitar que los análisis se activen a sí mismos.
Análisis antivirus asíncrono con Tokio
Cada análisis toma una instantánea de tamaño limitado, la transmite en bloques de 64 KiB y lee como máximo 4097 bytes de respuesta. Solo se acepta una respuesta de archivo limpio que sea exacta, única y esté terminada correctamente. Las respuestas vacías, truncadas, demasiado grandes, múltiples o no reconocidas se rechazan de forma segura. Una infección o una detección por límites del motor se considera un resultado no limpio. Un plazo de diez segundos abarca la lectura del archivo y la comunicación con clamd.
Integra todo: src/main.rs
Guarda este programa completo como src/main.rs:
use clap::Parser;
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::{error::Error, path::{Path, PathBuf}, time::Duration};
use tokio::{
fs::{self, File},
io::{AsyncReadExt, AsyncWriteExt},
net::UnixStream,
sync::mpsc,
time::{interval, timeout},
};
type AppResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
const MAX_BYTES: u64 = 10 * 1024 * 1024;
#[derive(Parser)]
struct Args {
#[arg(long)]
directory: PathBuf,
#[arg(long, default_value = "/var/run/clamav/clamd.ctl")]
socket: PathBuf,
#[arg(long)]
once: bool,
}
#[derive(Debug, PartialEq)]
enum Verdict {
Clean,
Infected,
ScannerError,
}
fn parse_reply(reply: &[u8]) -> Verdict {
if reply == b"stream: OK\0" {
return Verdict::Clean;
}
if let Some(name) = reply.strip_prefix(b"stream: ")
.and_then(|value| value.strip_suffix(b" FOUND\0"))
{
if !name.is_empty() && !name.iter().any(|byte| byte.is_ascii_control()) {
return Verdict::Infected;
}
}
Verdict::ScannerError
}
async fn scan(path: &Path, socket: &Path) -> AppResult<Verdict> {
// The private-directory producer contract forbids modifying published .ready files.
if !fs::symlink_metadata(path).await?.file_type().is_file() {
return Err("Input must be a regular, non-symlink file".into());
}
let file = File::open(path).await?;
if !file.metadata().await?.is_file() {
return Err("Input must be a regular file".into());
}
let mut bytes = Vec::new();
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).await?;
if bytes.len() as u64 > MAX_BYTES {
return Err("Input exceeds 10 MiB".into());
}
let mut stream = UnixStream::connect(socket).await?;
stream.write_all(b"zINSTREAM\0").await?;
for chunk in bytes.chunks(64 * 1024) {
stream.write_all(&(chunk.len() as u32).to_be_bytes()).await?;
stream.write_all(chunk).await?;
}
stream.write_all(&0u32.to_be_bytes()).await?;
let mut reply = Vec::new();
stream.take(4097).read_to_end(&mut reply).await?;
Ok(if reply.len() > 4096 {
Verdict::ScannerError
} else {
parse_reply(&reply)
})
}
async fn scan_directory(args: &Args) -> AppResult<bool> {
let mut entries = fs::read_dir(&args.directory).await?;
let mut all_clean = true;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("ready") {
continue;
}
let verdict = match timeout(Duration::from_secs(10), scan(&path, &args.socket)).await {
Ok(Ok(verdict)) => verdict,
_ => Verdict::ScannerError,
};
all_clean &= verdict == Verdict::Clean;
// Debug path formatting escapes newlines and control characters in filenames.
println!("{verdict:?} {:?}", path.file_name());
}
Ok(all_clean)
}
async fn monitor(args: &Args) -> AppResult<bool> {
if args.once {
return scan_directory(args).await;
}
let (tx, mut rx) = mpsc::channel(1);
let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
let changed = match event {
Ok(event) => matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
),
Err(_) => {
eprintln!("Watcher error; periodic reconciliation remains active.");
true
}
};
if changed {
// A full queue already guarantees a complete directory reconciliation.
let _ = tx.try_send(());
}
})?;
watcher.watch(&args.directory, RecursiveMode::NonRecursive)?;
let mut ticks = interval(Duration::from_secs(5));
loop {
tokio::select! {
_ = ticks.tick() => {}
event = rx.recv() => {
if event.is_none() {
return Err("Watcher channel closed".into());
}
}
}
scan_directory(args).await?;
}
}
async fn run() -> AppResult<bool> {
let args = Args::parse();
tokio::select! {
result = monitor(&args) => result,
result = tokio::signal::ctrl_c() => {
result?;
Err("Scanning interrupted".into())
}
}
}
#[tokio::main]
async fn main() {
match run().await {
Ok(true) => {}
Ok(false) => std::process::exit(1),
Err(_) => {
eprintln!("Scanner stopped; check the directory, socket, and daemon configuration.");
std::process::exit(1);
}
}
}
Compila e inicia el monitor con un directorio de entrada privado y vacío:
mkdir -m 700 inbox
cargo build
cargo run -- --directory inbox --socket /var/run/clamav/clamd.ctl
En otra terminal, desde el directorio del proyecto, publica un archivo completo con un nombre nuevo:
printf 'A completed example file.\n' > inbox/example.part
mv inbox/example.part inbox/example.ready
Un análisis limpio muestra Clean. Mantén los archivos .part fuera del conjunto de entrada del analizador; renómbralos solo después de que el proceso de escritura haya cerrado el archivo, y no reutilices ni alteres una ruta .ready publicada.
Gestión de errores y notificaciones
ScannerError significa que el archivo no se ha declarado limpio. Infected también abarca las alertas de límites excedidos configuradas en ClamAV. Ninguno de estos resultados debe desencadenar la publicación del archivo. El ejemplo deja deliberadamente todos los archivos en su lugar para que un fallo del análisis no pueda eliminar una subida.
Ctrl-C descarta el futuro del análisis activo, cierra su socket, libera el observador y termina con un código distinto de cero. En el modo de supervisión, un análisis fallido se vuelve a intentar durante una reconciliación posterior. Usa --once cuando el proceso que lo invoca necesite un código de salida de error para un lote que no esté limpio.
Consideraciones de rendimiento
Hay un solo análisis activo a la vez, una activación pendiente, una instantánea de tamaño limitado por archivo y una respuesta de tamaño limitado. La enumeración del directorio no acumula una cola de rutas. Volver a analizar todos los archivos completos sacrifica rendimiento para mantener un ejemplo pequeño y recuperable; un servicio de mayor tamaño debería registrar el trabajo de forma persistente y asociar cada veredicto con el objeto inmutable exacto que analizó.
Este monitor no es recursivo, y el intervalo de cinco segundos programa la reconciliación, sin garantizar un plazo de detección. Los lotes grandes y los análisis lentos tardan más. Mantén actualizadas las firmas y prueba los límites para archivos contenedores de tu demonio como parte de la operación del servicio.
Conclusión
La entrega del productor evita que las escrituras parciales se confundan con archivos completos. Notify proporciona activaciones rápidas, los análisis periódicos recuperan los eventos perdidos y la validación explícita del protocolo impide considerar limpios los resultados ambiguos del analizador.
Transloadit también utiliza ClamAV para filtrar archivos mediante su Robot 🤖 /file/virusscan.
