//! Layer 5: ClamAV daemon scanning via Unix socket. //! //! Connects to `clamd` using the INSTREAM protocol: sends file data in //! length-prefixed chunks, reads the verdict. Optional, only runs if //! CLAMAV_SOCKET env var is set. use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::UnixStream; use crate::constants; use super::{ErrorPolicy, LayerResult, LayerVerdict}; /// External local-service layer. An `Error` here means `clamd` is down, /// unreachable, or out of memory, operational problems that must not block /// every upload across the platform. Fail open; PoM surfaces degraded health. pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailOpen; /// Verify the `clamd` socket is reachable and responsive. Used at startup /// to refuse to boot if scanning is configured but the daemon is dead, /// otherwise the FailOpen policy would silently pass every upload as Clean /// for the lifetime of the process. pub async fn ping(socket_path: &str) -> Result<(), String> { let timeout = Duration::from_secs(2); let work = async { let mut stream = UnixStream::connect(socket_path) .await .map_err(|e| format!("connect: {e}"))?; stream .write_all(b"zPING\0") .await .map_err(|e| format!("write PING: {e}"))?; let mut response = Vec::with_capacity(8); stream .take(16) .read_to_end(&mut response) .await .map_err(|e| format!("read PONG: {e}"))?; let reply = String::from_utf8_lossy(&response); let reply = reply.trim_end_matches('\0').trim(); if reply == "PONG" { Ok(()) } else { Err(format!("unexpected PING response: {reply:?}")) } }; tokio::time::timeout(timeout, work) .await .map_err(|_| "ClamAV PING timed out after 2s".to_string())? } /// Maximum chunk size for INSTREAM protocol (ClamAV default) const CHUNK_SIZE: usize = 8192; /// Scan file data using a ClamAV daemon via Unix socket. pub async fn scan_with_clamav(socket_path: &str, data: &[u8]) -> LayerResult { let timeout = Duration::from_secs(constants::SCAN_CLAMAV_TIMEOUT_SECS); match tokio::time::timeout(timeout, scan_instream(socket_path, data)).await { Ok(Ok(result)) => result, Ok(Err(e)) => LayerResult { layer: "clamav", verdict: LayerVerdict::Error, detail: Some(format!("ClamAV error: {e}")), }, Err(_) => LayerResult { layer: "clamav", verdict: LayerVerdict::Error, detail: Some("ClamAV scan timed out".to_string()), }, } } /// Streaming entry. Pumps `reader` into ClamAV INSTREAM frame-by-frame /// rather than buffering the whole object first. Frame shape matches the /// buffered path: 4-byte BE length + bytes, terminated with `0u32`. pub async fn scan_with_clamav_stream(socket_path: &str, reader: R) -> LayerResult where R: tokio::io::AsyncRead + Unpin, { let timeout = Duration::from_secs(constants::SCAN_CLAMAV_TIMEOUT_SECS); match tokio::time::timeout(timeout, scan_instream_streaming(socket_path, reader)).await { Ok(Ok(result)) => result, Ok(Err(e)) => LayerResult { layer: "clamav", verdict: LayerVerdict::Error, detail: Some(format!("ClamAV error: {e}")), }, Err(_) => LayerResult { layer: "clamav", verdict: LayerVerdict::Error, detail: Some("ClamAV scan timed out".to_string()), }, } } async fn scan_instream(socket_path: &str, data: &[u8]) -> Result { let mut stream = UnixStream::connect(socket_path) .await .map_err(|e| format!("Failed to connect to ClamAV socket: {e}"))?; // Send INSTREAM command stream .write_all(b"zINSTREAM\0") .await .map_err(|e| format!("Failed to send INSTREAM command: {e}"))?; // Send data in chunks: each chunk prefixed with 4-byte big-endian length. // If clamd drops the connection mid-stream (e.g. it hit StreamMaxLength), a // write fails with EPIPE. Route that to the same fail-closed incomplete-scan // hold the streaming path uses (`incomplete_after_clamd_dropped`) instead of // returning an Err, an Err here was mapped to a clamav "degraded" error and // then fail-OPEN, the opposite posture from the streaming twin (Run 15). for chunk in data.chunks(CHUNK_SIZE) { let len = (chunk.len() as u32).to_be_bytes(); if stream.write_all(&len).await.is_err() || stream.write_all(chunk).await.is_err() { return Ok(incomplete_after_clamd_dropped(&mut stream).await); } } // Send zero-length chunk to signal end of data if stream.write_all(&0u32.to_be_bytes()).await.is_err() { return Ok(incomplete_after_clamd_dropped(&mut stream).await); } // Read response (capped at 16 KB, verdicts are typically under 100 bytes) let mut response = Vec::with_capacity(256); stream .take(16_384) .read_to_end(&mut response) .await .map_err(|e| format!("Failed to read ClamAV response: {e}"))?; // A 16 KB response means we hit the take() cap, clamd's verdict was // longer than expected (typical verdicts are <100 bytes) and we don't // know how much was truncated. Treat as suspicious / fail-closed rather // than letting `parse_clamav_response` see a half-token and return Error // (which under FailOpen would silently Pass). EICAR's "FOUND" suffix // might be just beyond the cap; assume the worst. if response.len() >= 16_384 { return Ok(LayerResult { layer: "clamav", verdict: LayerVerdict::Fail, detail: Some( "ClamAV response truncated at 16 KB cap, refusing to interpret".to_string(), ), }); } let response_str = String::from_utf8_lossy(&response); let response_str = response_str.trim_end_matches('\0').trim(); Ok(clamav_layer_result(response_str)) } async fn scan_instream_streaming(socket_path: &str, mut reader: R) -> Result where R: tokio::io::AsyncRead + Unpin, { let mut stream = UnixStream::connect(socket_path) .await .map_err(|e| format!("Failed to connect to ClamAV socket: {e}"))?; stream .write_all(b"zINSTREAM\0") .await .map_err(|e| format!("Failed to send INSTREAM command: {e}"))?; // A write failure *after* the connection is established and the INSTREAM // command was accepted means clamd dropped us mid-stream. The common cause // is StreamMaxLength enforcement: clamd writes a size-limit ERROR and closes // the socket, so our next write gets EPIPE. That is a reachable-but- // incomplete scan (a coverage gap that must fail CLOSED), NOT the fail-open // "daemon unreachable" bucket, so on any such write error we read clamd's // pending verdict and classify it (a size-limit reply parses to // NotFullyScanned → clamav_incomplete → FailClosed) rather than propagating // a transport Err. This keeps the streaming path's size-limit handling // identical to the buffered path's (CHRONIC S1 intent). let mut buf = vec![0u8; CHUNK_SIZE]; loop { let n = reader .read(&mut buf) .await .map_err(|e| format!("Failed to read source: {e}"))?; if n == 0 { break; } let len = (n as u32).to_be_bytes(); if stream.write_all(&len).await.is_err() || stream.write_all(&buf[..n]).await.is_err() { return Ok(incomplete_after_clamd_dropped(&mut stream).await); } } if stream.write_all(&0u32.to_be_bytes()).await.is_err() { return Ok(incomplete_after_clamd_dropped(&mut stream).await); } let mut response = Vec::with_capacity(256); stream .take(16_384) .read_to_end(&mut response) .await .map_err(|e| format!("Failed to read ClamAV response: {e}"))?; // A 16 KB response means we hit the take() cap, clamd's verdict was // longer than expected (typical verdicts are <100 bytes) and we don't // know how much was truncated. Treat as suspicious / fail-closed rather // than letting `parse_clamav_response` see a half-token and return Error // (which under FailOpen would silently Pass). EICAR's "FOUND" suffix // might be just beyond the cap; assume the worst. if response.len() >= 16_384 { return Ok(LayerResult { layer: "clamav", verdict: LayerVerdict::Fail, detail: Some( "ClamAV response truncated at 16 KB cap, refusing to interpret".to_string(), ), }); } let response_str = String::from_utf8_lossy(&response); let response_str = response_str.trim_end_matches('\0').trim(); Ok(clamav_layer_result(response_str)) } /// Classified outcome of a clamd INSTREAM reply we *successfully received*. /// /// The distinction this enum forces is the whole point of CHRONIC S1's fix: /// transport failures (unreachable daemon / timeout) are handled in the scan /// entry points and are legitimately FailOpen, clamd being down must not block /// every upload. But by the time we are parsing a reply, **clamd is reachable**. /// An error reply at that point (size/scan-size/recursion limit, or anything we /// can't parse) means the file was *not cleanly scanned*, a coverage gap that /// must fail CLOSED, never be conflated into the FailOpen unreachable bucket. /// Because this is an exhaustive enum mapped at one place /// ([`clamav_layer_result`]), a future reply kind can't silently fall into the /// FailOpen path the way the old single `LayerVerdict::Error` return did. #[derive(Debug, PartialEq, Eq)] enum ClamavResponse { Clean, Infected(String), /// clamd responded but produced no clean verdict, a limit was hit or the /// reply was unparseable. The file was not fully scanned ⇒ fail closed. NotFullyScanned(String), } /// Parse a clamd INSTREAM response string into a [`ClamavResponse`]. /// Extracted for testability, the socket/IO layer just feeds the string in. /// /// ClamAV response format: /// - `"stream: OK"`, clean /// - `"stream: FOUND"`, infected /// - `"stream: ERROR"` (e.g. `"INSTREAM size limit exceeded"`), /// empty, or anything else, clamd is reachable but did not fully scan. fn parse_clamav_response(response: &str) -> ClamavResponse { if response == "stream: OK" { ClamavResponse::Clean } else if response.ends_with("FOUND") { // Extract virus name: "stream: Eicar-Signature FOUND" → "Eicar-Signature" let virus_name = response .strip_prefix("stream: ") .unwrap_or(response) .strip_suffix(" FOUND") .unwrap_or(response); ClamavResponse::Infected(virus_name.to_string()) } else { ClamavResponse::NotFullyScanned(response.to_string()) } } /// Map a *received* clamd reply to a [`LayerResult`]. A `NotFullyScanned` /// outcome is emitted under the dedicated `clamav_incomplete` layer, whose /// `error_policy_for` entry is **FailClosed**, so an un-scanned file is held for /// review. A genuinely unreachable daemon / timeout is reported separately in /// the scan entry points under the `clamav` layer (FailOpen). Routing the two /// error kinds to different layer identities is the same mechanism the /// `scan_panic` / `scan_size_limit` pseudo-layers already use to pick a policy. fn clamav_layer_result(response: &str) -> LayerResult { match parse_clamav_response(response) { ClamavResponse::Clean => LayerResult { layer: "clamav", verdict: LayerVerdict::Pass, detail: None, }, ClamavResponse::Infected(name) => LayerResult { layer: "clamav", verdict: LayerVerdict::Fail, detail: Some(format!("ClamAV detection: {name}")), }, ClamavResponse::NotFullyScanned(resp) => LayerResult { layer: "clamav_incomplete", verdict: LayerVerdict::Error, detail: Some(format!( "ClamAV did not fully scan (held for review): {resp}" )), }, } } /// clamd dropped the connection mid-INSTREAM (commonly StreamMaxLength). Read /// whatever verdict it managed to send before closing and classify it; a /// size-limit reply parses to `NotFullyScanned` → fail-closed /// `clamav_incomplete`. When nothing legible was sent, synthesize the same /// fail-closed outcome. This never returns the fail-open `clamav` transport /// bucket: clamd was reachable, the scan just didn't complete. async fn incomplete_after_clamd_dropped(stream: &mut UnixStream) -> LayerResult { let mut response = Vec::with_capacity(256); let _ = (&mut *stream).take(16_384).read_to_end(&mut response).await; let response_str = String::from_utf8_lossy(&response); let response_str = response_str.trim_end_matches('\0').trim(); if response_str.is_empty() { LayerResult { layer: "clamav_incomplete", verdict: LayerVerdict::Error, detail: Some( "ClamAV closed the connection mid-stream without a verdict \ (likely stream size limit); held for review" .to_string(), ), } } else { clamav_layer_result(response_str) } } #[cfg(test)] mod tests { use super::*; // Clean responses #[test] fn clean_response_passes() { assert_eq!(parse_clamav_response("stream: OK"), ClamavResponse::Clean); let result = clamav_layer_result("stream: OK"); assert_eq!(result.verdict, LayerVerdict::Pass); assert_eq!(result.layer, "clamav"); assert!(result.detail.is_none()); } // Malware detections #[test] fn eicar_detection_fails() { assert_eq!( parse_clamav_response("stream: Eicar-Signature FOUND"), ClamavResponse::Infected("Eicar-Signature".to_string()) ); let result = clamav_layer_result("stream: Eicar-Signature FOUND"); assert_eq!(result.verdict, LayerVerdict::Fail); assert_eq!(result.layer, "clamav"); assert!(result.detail.unwrap().contains("Eicar-Signature")); } #[test] fn complex_virus_name_extracted() { let result = clamav_layer_result("stream: Win.Test.EICAR_HDB-1 FOUND"); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Win.Test.EICAR_HDB-1")); } #[test] fn trojan_detection_fails() { let result = clamav_layer_result("stream: Win.Trojan.Agent-123456 FOUND"); assert_eq!(result.verdict, LayerVerdict::Fail); assert!(result.detail.unwrap().contains("Win.Trojan.Agent-123456")); } // Not-fully-scanned responses (clamd reachable, no clean verdict) // // CHRONIC S1: every one of these must FAIL CLOSED, not FailOpen. They are // emitted under the `clamav_incomplete` layer (FailClosed in // `error_policy_for`), distinct from the FailOpen `clamav` layer used for an // unreachable daemon. #[test] fn empty_response_is_not_fully_scanned() { assert_eq!( parse_clamav_response(""), ClamavResponse::NotFullyScanned(String::new()) ); assert_eq!(clamav_layer_result("").layer, "clamav_incomplete"); assert_eq!(clamav_layer_result("").verdict, LayerVerdict::Error); } #[test] fn garbage_response_is_not_fully_scanned() { let result = clamav_layer_result("this is not a valid response"); assert_eq!(result.layer, "clamav_incomplete"); assert_eq!(result.verdict, LayerVerdict::Error); } #[test] fn size_limit_error_fails_closed_not_open() { // The regression that defined CHRONIC S1: a payload sized past clamd's // StreamMaxLength yields this reply on a HEALTHY clamd. It must route to // the FailClosed `clamav_incomplete` layer, never the FailOpen `clamav` // layer (which would skip → Clean). let response = "stream: INSTREAM size limit exceeded ERROR"; assert!(matches!( parse_clamav_response(response), ClamavResponse::NotFullyScanned(_) )); let result = clamav_layer_result(response); assert_eq!(result.layer, "clamav_incomplete"); assert_eq!(result.verdict, LayerVerdict::Error); } }