use crate::core::{
FileHasher, FileInput, FileMetadata, ScanContext, ScanError, ScanOutcome, ScanResult, Scanner,
ThreatInfo, ThreatSeverity,
};
use async_trait::async_trait;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct ClamAvConfig {
pub socket_path: Option<PathBuf>,
pub tcp_address: Option<String>,
pub connection_timeout: Duration,
pub scan_timeout: Duration,
pub max_file_size: u64,
pub chunk_size: usize,
}
impl Default for ClamAvConfig {
fn default() -> Self {
Self {
socket_path: Some(PathBuf::from("/var/run/clamav/clamd.sock")),
tcp_address: None,
connection_timeout: Duration::from_secs(10),
scan_timeout: Duration::from_secs(300),
max_file_size: 100 * 1024 * 1024, chunk_size: 64 * 1024, }
}
}
impl ClamAvConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_socket(mut self, path: impl Into<PathBuf>) -> Self {
self.socket_path = Some(path.into());
self.tcp_address = None;
self
}
pub fn with_tcp(mut self, address: impl Into<String>) -> Self {
self.tcp_address = Some(address.into());
self.socket_path = None;
self
}
pub fn with_connection_timeout(mut self, timeout: Duration) -> Self {
self.connection_timeout = timeout;
self
}
pub fn with_scan_timeout(mut self, timeout: Duration) -> Self {
self.scan_timeout = timeout;
self
}
pub fn with_max_file_size(mut self, size: u64) -> Self {
self.max_file_size = size;
self
}
pub fn with_chunk_size(mut self, size: usize) -> Self {
self.chunk_size = size;
self
}
}
#[derive(Debug)]
pub struct ClamAvScanner {
config: ClamAvConfig,
hasher: FileHasher,
}
impl ClamAvScanner {
pub fn new(config: ClamAvConfig) -> Result<Self, ScanError> {
if config.socket_path.is_none() && config.tcp_address.is_none() {
return Err(ScanError::configuration(
"Either socket_path or tcp_address must be specified",
));
}
Ok(Self {
config,
hasher: FileHasher::new(),
})
}
pub fn with_defaults() -> Result<Self, ScanError> {
Self::new(ClamAvConfig::default())
}
fn parse_response(&self, response: &str) -> ScanOutcome {
let response = response.trim();
if response.ends_with("OK") {
ScanOutcome::Clean
} else if response.contains("FOUND") {
let threat_name = response
.split(':')
.nth(1)
.and_then(|s| s.strip_suffix("FOUND"))
.map(|s| s.trim())
.unwrap_or("Unknown")
.to_string();
ScanOutcome::Infected {
threats: vec![ThreatInfo::new(threat_name, ThreatSeverity::High, "clamav")],
}
} else if response.contains("ERROR") {
ScanOutcome::Error { recoverable: true }
} else {
ScanOutcome::Suspicious {
reason: format!("Unexpected response: {}", response),
confidence: 0.5,
}
}
}
async fn read_file_data(&self, input: &FileInput) -> Result<Vec<u8>, ScanError> {
match input {
FileInput::Path(path) => {
#[cfg(feature = "tokio-runtime")]
{
tokio::time::timeout(std::time::Duration::from_secs(30), tokio::fs::read(path))
.await
.map_err(|_| {
ScanError::internal(format!(
"File read timeout exceeded for: {}",
path.display()
))
})?
.map_err(ScanError::Io)
}
#[cfg(not(feature = "tokio-runtime"))]
{
std::fs::read(path).map_err(ScanError::Io)
}
}
FileInput::Bytes { data, .. } => Ok(data.clone()),
FileInput::Stream { .. } => Err(ScanError::internal(
"ClamAV scanner does not yet support streaming input",
)),
}
}
#[cfg(feature = "tokio-runtime")]
async fn scan_data(&self, data: &[u8]) -> Result<String, ScanError> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = if let Some(ref socket_path) = self.config.socket_path {
#[cfg(unix)]
{
tokio::net::UnixStream::connect(socket_path)
.await
.map_err(|e| ScanError::connection_failed("clamav", e.to_string()))?
}
#[cfg(not(unix))]
{
return Err(ScanError::configuration(
"Unix sockets not supported on this platform",
));
}
} else if let Some(ref _tcp_addr) = self.config.tcp_address {
return Err(ScanError::internal("TCP connection not yet implemented"));
} else {
return Err(ScanError::configuration("No connection method configured"));
};
let result = async {
#[cfg(unix)]
{
stream
.write_all(b"zINSTREAM\0")
.await
.map_err(|e| ScanError::connection_failed("clamav", e.to_string()))?;
for chunk in data.chunks(self.config.chunk_size) {
let len = chunk.len() as u32;
stream
.write_all(&len.to_be_bytes())
.await
.map_err(|e| ScanError::connection_failed("clamav", e.to_string()))?;
stream
.write_all(chunk)
.await
.map_err(|e| ScanError::connection_failed("clamav", e.to_string()))?;
}
stream
.write_all(&0u32.to_be_bytes())
.await
.map_err(|e| ScanError::connection_failed("clamav", e.to_string()))?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.await
.map_err(|e| ScanError::connection_failed("clamav", e.to_string()))?;
Ok::<String, ScanError>(response)
}
#[cfg(not(unix))]
{
Err(ScanError::configuration(
"Unix sockets not supported on this platform",
))
}
}
.await;
let _ = stream.shutdown().await;
result
}
#[cfg(not(feature = "tokio-runtime"))]
async fn scan_data(&self, _data: &[u8]) -> Result<String, ScanError> {
Err(ScanError::internal(
"ClamAV scanner requires tokio-runtime feature",
))
}
}
#[async_trait]
impl Scanner for ClamAvScanner {
fn name(&self) -> &str {
"clamav"
}
async fn scan(&self, input: &FileInput) -> Result<ScanResult, ScanError> {
let start = std::time::Instant::now();
let data = self.read_file_data(input).await?;
if data.len() as u64 > self.config.max_file_size {
return Err(ScanError::FileTooLarge {
size: data.len() as u64,
max: self.config.max_file_size,
});
}
let hash = self.hasher.hash_bytes(&data);
let response = self.scan_data(&data).await?;
let outcome = self.parse_response(&response);
let duration = start.elapsed();
let metadata = FileMetadata::new(data.len() as u64, hash)
.with_filename(input.filename().unwrap_or("unknown").to_string());
Ok(ScanResult::new(
outcome,
metadata,
"clamav",
duration,
ScanContext::new(),
))
}
async fn health_check(&self) -> Result<(), ScanError> {
#[cfg(all(feature = "tokio-runtime", unix))]
{
use tokio::io::{AsyncReadExt, AsyncWriteExt};
if let Some(ref socket_path) = self.config.socket_path {
let mut stream = tokio::net::UnixStream::connect(socket_path)
.await
.map_err(|e| ScanError::connection_failed("clamav", e.to_string()))?;
stream
.write_all(b"zPING\0")
.await
.map_err(|e| ScanError::connection_failed("clamav", e.to_string()))?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.await
.map_err(|e| ScanError::connection_failed("clamav", e.to_string()))?;
if response.trim() == "PONG" {
return Ok(());
} else {
return Err(ScanError::engine_unavailable(
"clamav",
format!("Unexpected response: {}", response),
));
}
}
}
Err(ScanError::engine_unavailable(
"clamav",
"Health check not available",
))
}
fn max_file_size(&self) -> Option<u64> {
Some(self.config.max_file_size)
}
async fn signature_version(&self) -> Option<String> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_response_clean() {
let config = ClamAvConfig::new();
let scanner = ClamAvScanner {
config,
hasher: FileHasher::new(),
};
let outcome = scanner.parse_response("stream: OK");
assert!(outcome.is_clean());
}
#[test]
fn test_parse_response_infected() {
let config = ClamAvConfig::new();
let scanner = ClamAvScanner {
config,
hasher: FileHasher::new(),
};
let outcome = scanner.parse_response("stream: Eicar-Test-Signature FOUND");
assert!(outcome.is_infected());
}
#[test]
fn test_config_builder() {
let config = ClamAvConfig::new()
.with_socket("/custom/path.sock")
.with_scan_timeout(Duration::from_secs(60));
assert_eq!(config.socket_path, Some(PathBuf::from("/custom/path.sock")));
assert_eq!(config.scan_timeout, Duration::from_secs(60));
}
}