acton_htmx/storage/
scanning.rs

1//! Virus scanning integration for uploaded files
2//!
3//! This module provides a trait-based abstraction for virus scanning with
4//! support for multiple backends like ClamAV.
5//!
6//! # Security Warning
7//!
8//! Virus scanning is an important defense-in-depth measure, but should not be
9//! your only line of defense. Always combine virus scanning with:
10//! - MIME type validation (magic number checking)
11//! - File size limits
12//! - Sandboxing/isolation of uploaded files
13//! - Principle of least privilege
14//!
15//! # Examples
16//!
17//! ```rust,no_run
18//! use acton_htmx::storage::{UploadedFile, scanning::{VirusScanner, NoOpScanner}};
19//!
20//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
21//! let file = UploadedFile::new("document.pdf", "application/pdf", vec![/* ... */]);
22//!
23//! // Use a no-op scanner (for development/testing)
24//! let scanner = NoOpScanner::new();
25//! let result = scanner.scan(&file).await?;
26//!
27//! match result {
28//!     ScanResult::Clean => println!("File is safe"),
29//!     ScanResult::Infected { threat } => println!("File infected with: {}", threat),
30//!     ScanResult::Error { message } => println!("Scan error: {}", message),
31//! }
32//! # Ok(())
33//! # }
34//! ```
35
36use super::types::{StorageError, StorageResult, UploadedFile};
37use async_trait::async_trait;
38use chrono::Utc;
39use serde::{Deserialize, Serialize};
40use std::fmt;
41use tracing::{error, warn};
42
43/// Metadata stored with quarantined files
44///
45/// This struct contains information about a quarantined file, including
46/// when it was quarantined, what threat was detected, and the original
47/// filename.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49struct QuarantineMetadata {
50    /// ISO 8601 timestamp when file was quarantined
51    quarantined_at: String,
52
53    /// Detected threat name
54    threat_name: String,
55
56    /// Original filename
57    original_filename: String,
58
59    /// Original MIME type
60    original_mime_type: String,
61
62    /// File size in bytes
63    file_size: usize,
64}
65
66/// Result of a virus scan
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum ScanResult {
69    /// File is clean (no threats detected)
70    Clean,
71
72    /// File is infected
73    Infected {
74        /// Name/description of detected threat
75        threat: String,
76    },
77
78    /// Scanning encountered an error
79    Error {
80        /// Error message
81        message: String,
82    },
83}
84
85impl fmt::Display for ScanResult {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::Clean => write!(f, "Clean"),
89            Self::Infected { threat } => write!(f, "Infected: {threat}"),
90            Self::Error { message } => write!(f, "Scan error: {message}"),
91        }
92    }
93}
94
95/// Trait for virus scanning implementations
96///
97/// This trait allows for multiple virus scanning backends (ClamAV, Windows Defender,
98/// cloud scanning services, etc.) with a consistent API.
99#[cfg_attr(test, mockall::automock)]
100#[async_trait]
101pub trait VirusScanner: Send + Sync {
102    /// Scans a file for viruses and malware
103    ///
104    /// # Errors
105    ///
106    /// Returns error if scanning fails (e.g., scanner unavailable)
107    ///
108    /// # Examples
109    ///
110    /// ```rust,no_run
111    /// use acton_htmx::storage::{UploadedFile, scanning::{VirusScanner, NoOpScanner}};
112    ///
113    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
114    /// let file = UploadedFile::new("test.pdf", "application/pdf", vec![]);
115    /// let scanner = NoOpScanner::new();
116    /// let result = scanner.scan(&file).await?;
117    /// # Ok(())
118    /// # }
119    /// ```
120    async fn scan(&self, file: &UploadedFile) -> StorageResult<ScanResult>;
121
122    /// Returns the name of the scanner implementation
123    ///
124    /// # Examples
125    ///
126    /// ```rust
127    /// use acton_htmx::storage::scanning::{VirusScanner, NoOpScanner};
128    ///
129    /// let scanner = NoOpScanner::new();
130    /// assert_eq!(scanner.name(), "NoOp Scanner");
131    /// ```
132    fn name(&self) -> &'static str;
133
134    /// Checks if the scanner is available and functional
135    ///
136    /// # Examples
137    ///
138    /// ```rust
139    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
140    /// use acton_htmx::storage::scanning::{VirusScanner, NoOpScanner};
141    ///
142    /// let scanner = NoOpScanner::new();
143    /// assert!(scanner.is_available().await);
144    /// # Ok(())
145    /// # }
146    /// ```
147    async fn is_available(&self) -> bool;
148}
149
150/// No-op scanner that always returns Clean
151///
152/// This scanner is useful for:
153/// - Development and testing environments
154/// - Deployments where virus scanning is handled externally
155/// - Minimal overhead when scanning is not required
156///
157/// # Examples
158///
159/// ```rust
160/// use acton_htmx::storage::scanning::{VirusScanner, NoOpScanner};
161///
162/// let scanner = NoOpScanner::new();
163/// assert!(scanner.is_development_only());
164/// ```
165#[derive(Debug, Clone, Default)]
166pub struct NoOpScanner;
167
168impl NoOpScanner {
169    /// Creates a new no-op scanner
170    ///
171    /// # Examples
172    ///
173    /// ```rust
174    /// use acton_htmx::storage::scanning::NoOpScanner;
175    ///
176    /// let scanner = NoOpScanner::new();
177    /// ```
178    #[must_use]
179    pub const fn new() -> Self {
180        Self
181    }
182
183    /// Returns true (this is for development only)
184    ///
185    /// # Examples
186    ///
187    /// ```rust
188    /// use acton_htmx::storage::scanning::NoOpScanner;
189    ///
190    /// let scanner = NoOpScanner::new();
191    /// assert!(scanner.is_development_only());
192    /// ```
193    #[must_use]
194    pub const fn is_development_only(&self) -> bool {
195        true
196    }
197}
198
199#[async_trait]
200impl VirusScanner for NoOpScanner {
201    async fn scan(&self, _file: &UploadedFile) -> StorageResult<ScanResult> {
202        // Always return Clean in development mode
203        Ok(ScanResult::Clean)
204    }
205
206    fn name(&self) -> &'static str {
207        "NoOp Scanner"
208    }
209
210    async fn is_available(&self) -> bool {
211        true
212    }
213}
214
215/// ClamAV connection type
216///
217/// Specifies how to connect to the ClamAV daemon (clamd).
218#[cfg(feature = "clamav")]
219#[derive(Debug, Clone)]
220pub enum ClamAvConnection {
221    /// TCP connection
222    ///
223    /// # Examples
224    ///
225    /// ```rust
226    /// # #[cfg(feature = "clamav")]
227    /// # {
228    /// use acton_htmx::storage::scanning::{ClamAvScanner, ClamAvConnection};
229    ///
230    /// let scanner = ClamAvScanner::new(ClamAvConnection::Tcp {
231    ///     host: "localhost".to_string(),
232    ///     port: 3310,
233    /// });
234    /// # }
235    /// ```
236    Tcp {
237        /// Hostname or IP address
238        host: String,
239        /// Port number
240        port: u16,
241    },
242
243    /// Unix domain socket connection
244    ///
245    /// Only available on Unix platforms (Linux, macOS, etc.)
246    ///
247    /// # Examples
248    ///
249    /// ```rust
250    /// # #[cfg(all(feature = "clamav", unix))]
251    /// # {
252    /// use acton_htmx::storage::scanning::{ClamAvScanner, ClamAvConnection};
253    /// use std::path::PathBuf;
254    ///
255    /// let scanner = ClamAvScanner::new(ClamAvConnection::Socket {
256    ///     path: PathBuf::from("/var/run/clamav/clamd.sock"),
257    /// });
258    /// # }
259    /// ```
260    #[cfg(unix)]
261    Socket {
262        /// Path to Unix domain socket
263        path: std::path::PathBuf,
264    },
265}
266
267/// ClamAV virus scanner
268///
269/// Integrates with ClamAV daemon (clamd) for virus scanning. Supports both
270/// TCP and Unix socket connections.
271///
272/// # Feature Flag
273///
274/// This scanner requires the `clamav` feature to be enabled:
275///
276/// ```toml
277/// [dependencies]
278/// acton-htmx = { version = "1.0", features = ["clamav"] }
279/// ```
280///
281/// # Examples
282///
283/// ```rust,no_run
284/// # #[cfg(feature = "clamav")]
285/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
286/// use acton_htmx::storage::{UploadedFile, scanning::{VirusScanner, ClamAvScanner, ClamAvConnection}};
287///
288/// let scanner = ClamAvScanner::new(ClamAvConnection::Tcp {
289///     host: "localhost".to_string(),
290///     port: 3310,
291/// });
292///
293/// // Check if ClamAV is available
294/// if !scanner.is_available().await {
295///     eprintln!("ClamAV daemon is not available");
296///     return Ok(());
297/// }
298///
299/// // Scan a file
300/// let file = UploadedFile::new("document.pdf", "application/pdf", vec![/* ... */]);
301/// let result = scanner.scan(&file).await?;
302/// # Ok(())
303/// # }
304/// ```
305#[cfg(feature = "clamav")]
306#[derive(Debug, Clone)]
307pub struct ClamAvScanner {
308    connection: ClamAvConnection,
309}
310
311#[cfg(feature = "clamav")]
312impl ClamAvScanner {
313    /// Creates a new ClamAV scanner
314    ///
315    /// # Arguments
316    ///
317    /// * `connection` - How to connect to the ClamAV daemon
318    ///
319    /// # Examples
320    ///
321    /// ```rust
322    /// # #[cfg(feature = "clamav")]
323    /// # {
324    /// use acton_htmx::storage::scanning::{ClamAvScanner, ClamAvConnection};
325    ///
326    /// // TCP connection
327    /// let scanner = ClamAvScanner::new(ClamAvConnection::Tcp {
328    ///     host: "localhost".to_string(),
329    ///     port: 3310,
330    /// });
331    ///
332    /// // Unix socket connection
333    /// let scanner = ClamAvScanner::new(ClamAvConnection::Socket {
334    ///     path: "/var/run/clamav/clamd.sock".into(),
335    /// });
336    /// # }
337    /// ```
338    #[must_use]
339    pub const fn new(connection: ClamAvConnection) -> Self {
340        Self { connection }
341    }
342
343    /// Creates a new ClamAV scanner with default TCP settings
344    ///
345    /// Connects to `localhost:3310` (the default ClamAV port).
346    ///
347    /// # Examples
348    ///
349    /// ```rust
350    /// # #[cfg(feature = "clamav")]
351    /// # {
352    /// use acton_htmx::storage::scanning::ClamAvScanner;
353    ///
354    /// let scanner = ClamAvScanner::default_tcp();
355    /// # }
356    /// ```
357    #[must_use]
358    pub fn default_tcp() -> Self {
359        Self::new(ClamAvConnection::Tcp {
360            host: "localhost".to_string(),
361            port: 3310,
362        })
363    }
364
365    /// Creates a new ClamAV scanner with default Unix socket settings
366    ///
367    /// Connects to `/var/run/clamav/clamd.sock` (common default path).
368    ///
369    /// Only available on Unix platforms.
370    ///
371    /// # Examples
372    ///
373    /// ```rust
374    /// # #[cfg(all(feature = "clamav", unix))]
375    /// # {
376    /// use acton_htmx::storage::scanning::ClamAvScanner;
377    ///
378    /// let scanner = ClamAvScanner::default_socket();
379    /// # }
380    /// ```
381    #[must_use]
382    #[cfg(unix)]
383    pub fn default_socket() -> Self {
384        Self::new(ClamAvConnection::Socket {
385            path: "/var/run/clamav/clamd.sock".into(),
386        })
387    }
388}
389
390#[cfg(feature = "clamav")]
391#[async_trait]
392impl VirusScanner for ClamAvScanner {
393    async fn scan(&self, file: &UploadedFile) -> StorageResult<ScanResult> {
394        use clamav_client::tokio::{scan_buffer, Tcp};
395        #[cfg(unix)]
396        use clamav_client::tokio::Socket;
397
398        // Get file data (data is a field, not a method)
399        let data = &file.data;
400
401        // Scan based on connection type
402        let response = match &self.connection {
403            ClamAvConnection::Tcp { host, port } => {
404                let host_address = format!("{}:{}", host, port);
405                let clamd = Tcp {
406                    host_address: &host_address,
407                };
408                scan_buffer(data, clamd, None)
409                    .await
410                    .map_err(|e| StorageError::Other(format!("ClamAV scan failed: {}", e)))?
411            }
412            #[cfg(unix)]
413            ClamAvConnection::Socket { path } => {
414                let path_str = path
415                    .to_str()
416                    .ok_or_else(|| StorageError::Other("Invalid socket path".to_string()))?;
417                let clamd = Socket {
418                    socket_path: path_str,
419                };
420                scan_buffer(data, clamd, None)
421                    .await
422                    .map_err(|e| StorageError::Other(format!("ClamAV scan failed: {}", e)))?
423            }
424            #[cfg(not(unix))]
425            ClamAvConnection::Socket { .. } => {
426                return Err(StorageError::Other(
427                    "Unix socket connections not supported on this platform".to_string(),
428                ))
429            }
430        };
431
432        // Parse response
433        match clamav_client::clean(&response) {
434            Ok(true) => Ok(ScanResult::Clean),
435            Ok(false) => {
436                // Extract threat name from response
437                let threat = String::from_utf8_lossy(&response).trim().to_string();
438                Ok(ScanResult::Infected { threat })
439            }
440            Err(e) => Ok(ScanResult::Error {
441                message: format!("Failed to parse scan result: {}", e),
442            }),
443        }
444    }
445
446    fn name(&self) -> &'static str {
447        "ClamAV Scanner"
448    }
449
450    async fn is_available(&self) -> bool {
451        use clamav_client::tokio::{ping, Tcp};
452        use clamav_client::PONG;
453        #[cfg(unix)]
454        use clamav_client::tokio::Socket;
455
456        match &self.connection {
457            ClamAvConnection::Tcp { host, port } => {
458                let host_address = format!("{}:{}", host, port);
459                let clamd = Tcp {
460                    host_address: &host_address,
461                };
462                matches!(ping(clamd).await, Ok(response) if response == *PONG)
463            }
464            #[cfg(unix)]
465            ClamAvConnection::Socket { path } => {
466                let Some(path_str) = path.to_str() else {
467                    return false;
468                };
469                let clamd = Socket {
470                    socket_path: path_str,
471                };
472                matches!(ping(clamd).await, Ok(response) if response == *PONG)
473            }
474            #[cfg(not(unix))]
475            ClamAvConnection::Socket { .. } => false,
476        }
477    }
478}
479
480/// ClamAV scanner placeholder (when feature is disabled)
481///
482/// This is a compile-time placeholder that exists when the `clamav` feature
483/// is not enabled. It always returns an error indicating that ClamAV support
484/// is not compiled in.
485///
486/// # Examples
487///
488/// ```rust
489/// # #[cfg(not(feature = "clamav"))]
490/// # {
491/// use acton_htmx::storage::scanning::ClamAvScanner;
492///
493/// let scanner = ClamAvScanner::new();
494/// # }
495/// ```
496#[cfg(not(feature = "clamav"))]
497#[derive(Debug, Clone, Default)]
498pub struct ClamAvScanner;
499
500#[cfg(not(feature = "clamav"))]
501impl ClamAvScanner {
502    /// Creates a new ClamAV scanner placeholder
503    ///
504    /// # Examples
505    ///
506    /// ```rust
507    /// # #[cfg(not(feature = "clamav"))]
508    /// # {
509    /// use acton_htmx::storage::scanning::ClamAvScanner;
510    ///
511    /// let scanner = ClamAvScanner::new();
512    /// # }
513    /// ```
514    #[must_use]
515    pub const fn new() -> Self {
516        Self
517    }
518}
519
520#[cfg(not(feature = "clamav"))]
521#[async_trait]
522impl VirusScanner for ClamAvScanner {
523    async fn scan(&self, _file: &UploadedFile) -> StorageResult<ScanResult> {
524        Err(StorageError::Other(
525            "ClamAV support not enabled. Recompile with 'clamav' feature.".to_string(),
526        ))
527    }
528
529    fn name(&self) -> &'static str {
530        "ClamAV Scanner (disabled)"
531    }
532
533    async fn is_available(&self) -> bool {
534        false
535    }
536}
537
538/// Scanner that quarantines infected files
539///
540/// This wrapper scanner wraps another scanner and automatically quarantines
541/// files that are detected as infected.
542///
543/// When an infected file is detected, it is moved to the quarantine directory
544/// with a unique filename (UUID-based) and accompanied by a metadata JSON file
545/// containing information about the threat, original filename, and timestamp.
546///
547/// # Examples
548///
549/// ```rust
550/// use acton_htmx::storage::scanning::{QuarantineScanner, NoOpScanner};
551/// use std::path::PathBuf;
552///
553/// let base_scanner = NoOpScanner::new();
554/// let scanner = QuarantineScanner::new(
555///     base_scanner,
556///     PathBuf::from("/var/quarantine"),
557/// );
558/// ```
559#[derive(Debug)]
560pub struct QuarantineScanner<S: VirusScanner> {
561    /// Underlying scanner
562    inner: S,
563
564    /// Path to quarantine directory
565    quarantine_path: std::path::PathBuf,
566}
567
568impl<S: VirusScanner> QuarantineScanner<S> {
569    /// Creates a new quarantine scanner
570    ///
571    /// # Arguments
572    ///
573    /// * `scanner` - The underlying virus scanner
574    /// * `quarantine_path` - Directory where infected files will be moved
575    ///
576    /// # Examples
577    ///
578    /// ```rust
579    /// use acton_htmx::storage::scanning::{QuarantineScanner, NoOpScanner};
580    /// use std::path::PathBuf;
581    ///
582    /// let scanner = QuarantineScanner::new(
583    ///     NoOpScanner::new(),
584    ///     PathBuf::from("/var/quarantine"),
585    /// );
586    /// ```
587    #[must_use]
588    pub const fn new(scanner: S, quarantine_path: std::path::PathBuf) -> Self {
589        Self {
590            inner: scanner,
591            quarantine_path,
592        }
593    }
594}
595
596#[async_trait]
597impl<S: VirusScanner> VirusScanner for QuarantineScanner<S> {
598    async fn scan(&self, file: &UploadedFile) -> StorageResult<ScanResult> {
599        let result = self.inner.scan(file).await?;
600
601        if let ScanResult::Infected { ref threat } = result {
602            // Quarantine the infected file
603            if let Err(e) = self.quarantine_file(file, threat).await {
604                error!(
605                    "Failed to quarantine infected file '{}': {}",
606                    file.filename, e
607                );
608                // Log error but don't fail the scan - we still return Infected result
609                warn!(
610                    "File '{}' detected as infected with '{}' but quarantine failed",
611                    file.filename, threat
612                );
613            }
614        }
615
616        Ok(result)
617    }
618
619    fn name(&self) -> &'static str {
620        "Quarantine Scanner"
621    }
622
623    async fn is_available(&self) -> bool {
624        self.inner.is_available().await
625    }
626}
627
628impl<S: VirusScanner> QuarantineScanner<S> {
629    /// Quarantines an infected file
630    ///
631    /// Creates the quarantine directory if needed, generates a unique filename,
632    /// writes the file with metadata, and logs the event.
633    ///
634    /// # Errors
635    ///
636    /// Returns error if directory creation, file writing, or metadata serialization fails.
637    async fn quarantine_file(&self, file: &UploadedFile, threat: &str) -> StorageResult<()> {
638        // 1. Create quarantine directory if it doesn't exist
639        tokio::fs::create_dir_all(&self.quarantine_path)
640            .await
641            .map_err(|e| {
642                StorageError::Other(format!("Failed to create quarantine directory: {e}"))
643            })?;
644
645        // 2. Generate unique filename (UUID + timestamp)
646        let unique_id = uuid::Uuid::new_v4();
647        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
648        let quarantine_filename = format!("{timestamp}_{unique_id}");
649        let quarantine_file_path = self.quarantine_path.join(&quarantine_filename);
650        let metadata_path = self.quarantine_path.join(format!("{quarantine_filename}.json"));
651
652        // 3. Create metadata
653        let metadata = QuarantineMetadata {
654            quarantined_at: Utc::now().to_rfc3339(),
655            threat_name: threat.to_string(),
656            original_filename: file.filename.clone(),
657            original_mime_type: file.content_type.clone(),
658            file_size: file.data.len(),
659        };
660
661        // 4. Write file to quarantine
662        tokio::fs::write(&quarantine_file_path, &file.data)
663            .await
664            .map_err(|e| StorageError::Other(format!("Failed to write quarantined file: {e}")))?;
665
666        // 5. Write metadata JSON
667        let metadata_json = serde_json::to_string_pretty(&metadata)
668            .map_err(|e| {
669                StorageError::Other(format!("Failed to serialize quarantine metadata: {e}"))
670            })?;
671
672        tokio::fs::write(&metadata_path, metadata_json)
673            .await
674            .map_err(|e| {
675                StorageError::Other(format!("Failed to write quarantine metadata: {e}"))
676            })?;
677
678        // 6. Log quarantine event
679        warn!(
680            "File '{}' quarantined as '{}' - Threat: {}",
681            file.filename, quarantine_filename, threat
682        );
683
684        Ok(())
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[tokio::test]
693    async fn test_noop_scanner_always_clean() {
694        let file = UploadedFile::new("test.txt", "text/plain", b"harmless data".to_vec());
695        let scanner = NoOpScanner::new();
696
697        let result = scanner.scan(&file).await.unwrap();
698        assert_eq!(result, ScanResult::Clean);
699    }
700
701    #[tokio::test]
702    async fn test_noop_scanner_available() {
703        let scanner = NoOpScanner::new();
704        assert!(scanner.is_available().await);
705    }
706
707    #[tokio::test]
708    async fn test_noop_scanner_name() {
709        let scanner = NoOpScanner::new();
710        assert_eq!(scanner.name(), "NoOp Scanner");
711    }
712
713    #[cfg(feature = "clamav")]
714    #[tokio::test]
715    async fn test_clamav_scanner_tcp_not_available() {
716        let scanner = ClamAvScanner::new(ClamAvConnection::Tcp {
717            host: "nonexistent.invalid".to_string(),
718            port: 9999,
719        });
720        assert!(!scanner.is_available().await);
721    }
722
723    #[cfg(all(feature = "clamav", unix))]
724    #[tokio::test]
725    async fn test_clamav_scanner_socket_not_available() {
726        let scanner = ClamAvScanner::new(ClamAvConnection::Socket {
727            path: "/nonexistent/path.sock".into(),
728        });
729        assert!(!scanner.is_available().await);
730    }
731
732    #[cfg(feature = "clamav")]
733    #[tokio::test]
734    async fn test_clamav_scanner_default_tcp() {
735        let scanner = ClamAvScanner::default_tcp();
736        assert_eq!(scanner.name(), "ClamAV Scanner");
737    }
738
739    #[cfg(all(feature = "clamav", unix))]
740    #[tokio::test]
741    async fn test_clamav_scanner_default_socket() {
742        let scanner = ClamAvScanner::default_socket();
743        assert_eq!(scanner.name(), "ClamAV Scanner");
744    }
745
746    #[cfg(feature = "clamav")]
747    #[tokio::test]
748    async fn test_clamav_scanner_scan_connection_refused() {
749        let file = UploadedFile::new("test.txt", "text/plain", b"test data".to_vec());
750        let scanner = ClamAvScanner::new(ClamAvConnection::Tcp {
751            host: "localhost".to_string(),
752            port: 9999, // Non-existent port
753        });
754
755        let result = scanner.scan(&file).await;
756        // Should fail with connection error
757        assert!(result.is_err());
758        if let Err(StorageError::Other(msg)) = result {
759            assert!(msg.contains("ClamAV scan failed"));
760        }
761    }
762
763    #[cfg(not(feature = "clamav"))]
764    #[tokio::test]
765    async fn test_clamav_scanner_disabled() {
766        let file = UploadedFile::new("test.txt", "text/plain", b"test data".to_vec());
767        let scanner = ClamAvScanner::new();
768
769        let result = scanner.scan(&file).await;
770        assert!(result.is_err());
771        if let Err(StorageError::Other(msg)) = result {
772            assert!(msg.contains("not enabled"));
773        }
774    }
775
776    #[cfg(not(feature = "clamav"))]
777    #[tokio::test]
778    async fn test_clamav_scanner_disabled_not_available() {
779        let scanner = ClamAvScanner::new();
780        assert!(!scanner.is_available().await);
781        assert_eq!(scanner.name(), "ClamAV Scanner (disabled)");
782    }
783
784    #[test]
785    fn test_scan_result_display() {
786        assert_eq!(ScanResult::Clean.to_string(), "Clean");
787        assert_eq!(
788            ScanResult::Infected {
789                threat: "EICAR".to_string()
790            }
791            .to_string(),
792            "Infected: EICAR"
793        );
794        assert_eq!(
795            ScanResult::Error {
796                message: "Scanner offline".to_string()
797            }
798            .to_string(),
799            "Scan error: Scanner offline"
800        );
801    }
802
803    #[tokio::test]
804    async fn test_quarantine_scanner_wraps_inner() {
805        let file = UploadedFile::new("test.txt", "text/plain", b"test".to_vec());
806        let scanner = QuarantineScanner::new(
807            NoOpScanner::new(),
808            std::path::PathBuf::from("/tmp/quarantine"),
809        );
810
811        let result = scanner.scan(&file).await.unwrap();
812        assert_eq!(result, ScanResult::Clean);
813    }
814
815    // Mock scanner that always returns Infected for testing quarantine
816    #[derive(Debug, Clone)]
817    struct MockInfectedScanner {
818        threat: String,
819    }
820
821    impl MockInfectedScanner {
822        fn new(threat: impl Into<String>) -> Self {
823            Self {
824                threat: threat.into(),
825            }
826        }
827    }
828
829    #[async_trait]
830    impl VirusScanner for MockInfectedScanner {
831        async fn scan(&self, _file: &UploadedFile) -> StorageResult<ScanResult> {
832            Ok(ScanResult::Infected {
833                threat: self.threat.clone(),
834            })
835        }
836
837        fn name(&self) -> &'static str {
838            "Mock Infected Scanner"
839        }
840
841        async fn is_available(&self) -> bool {
842            true
843        }
844    }
845
846    #[tokio::test]
847    async fn test_quarantine_scanner_quarantines_infected_files() {
848        // Create a temporary quarantine directory
849        let temp_dir = tempfile::tempdir().unwrap();
850        let quarantine_path = temp_dir.path().to_path_buf();
851
852        let file = UploadedFile::new(
853            "malware.exe",
854            "application/octet-stream",
855            b"EICAR test file".to_vec(),
856        );
857
858        let scanner = QuarantineScanner::new(
859            MockInfectedScanner::new("EICAR.Test.Signature"),
860            quarantine_path.clone(),
861        );
862
863        let result = scanner.scan(&file).await.unwrap();
864
865        // Verify scan result is still Infected
866        assert!(matches!(result, ScanResult::Infected { .. }));
867
868        // Verify quarantine directory was created
869        assert!(quarantine_path.exists());
870
871        // Verify files were created in quarantine (should have at least 2 files: data + metadata)
872        let entries: Vec<_> = std::fs::read_dir(&quarantine_path)
873            .unwrap()
874            .collect::<Result<Vec<_>, _>>()
875            .unwrap();
876        assert_eq!(entries.len(), 2, "Should have quarantine file and metadata");
877
878        // Find the metadata file
879        let metadata_file = entries
880            .iter()
881            .find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("json"))
882            .expect("Should have metadata JSON file");
883
884        // Read and verify metadata
885        let metadata_json = std::fs::read_to_string(metadata_file.path()).unwrap();
886        let metadata: QuarantineMetadata = serde_json::from_str(&metadata_json).unwrap();
887
888        assert_eq!(metadata.threat_name, "EICAR.Test.Signature");
889        assert_eq!(metadata.original_filename, "malware.exe");
890        assert_eq!(metadata.original_mime_type, "application/octet-stream");
891        assert_eq!(metadata.file_size, b"EICAR test file".len());
892
893        // Verify file data was written
894        let data_file = entries
895            .iter()
896            .find(|e| e.path().extension().is_none())
897            .expect("Should have quarantine data file");
898        let quarantined_data = std::fs::read(data_file.path()).unwrap();
899        assert_eq!(quarantined_data, b"EICAR test file");
900    }
901
902    #[tokio::test]
903    async fn test_quarantine_scanner_clean_files_not_quarantined() {
904        let temp_dir = tempfile::tempdir().unwrap();
905        let quarantine_path = temp_dir.path().to_path_buf();
906
907        let file = UploadedFile::new("clean.txt", "text/plain", b"clean data".to_vec());
908
909        let scanner = QuarantineScanner::new(NoOpScanner::new(), quarantine_path.clone());
910
911        let result = scanner.scan(&file).await.unwrap();
912
913        // Verify scan result is Clean
914        assert_eq!(result, ScanResult::Clean);
915
916        // Verify no files were created in quarantine
917        let entries: Vec<_> = std::fs::read_dir(&quarantine_path)
918            .unwrap()
919            .collect::<Result<Vec<_>, _>>()
920            .unwrap();
921        assert_eq!(entries.len(), 0, "Clean files should not be quarantined");
922    }
923
924    #[tokio::test]
925    async fn test_quarantine_scanner_creates_directory() {
926        let temp_dir = tempfile::tempdir().unwrap();
927        let quarantine_path = temp_dir.path().join("nested").join("quarantine");
928
929        // Verify directory doesn't exist yet
930        assert!(!quarantine_path.exists());
931
932        let file = UploadedFile::new("malware.bin", "application/octet-stream", b"bad".to_vec());
933
934        let scanner = QuarantineScanner::new(
935            MockInfectedScanner::new("Test.Virus"),
936            quarantine_path.clone(),
937        );
938
939        scanner.scan(&file).await.unwrap();
940
941        // Verify directory was created
942        assert!(quarantine_path.exists());
943        assert!(quarantine_path.is_dir());
944    }
945
946    #[tokio::test]
947    async fn test_quarantine_scanner_unique_filenames() {
948        let temp_dir = tempfile::tempdir().unwrap();
949        let quarantine_path = temp_dir.path().to_path_buf();
950
951        let scanner = QuarantineScanner::new(
952            MockInfectedScanner::new("Test.Virus"),
953            quarantine_path.clone(),
954        );
955
956        // Quarantine two files with the same name
957        let file1 = UploadedFile::new("malware.exe", "application/octet-stream", b"bad1".to_vec());
958        let file2 = UploadedFile::new("malware.exe", "application/octet-stream", b"bad2".to_vec());
959
960        scanner.scan(&file1).await.unwrap();
961        scanner.scan(&file2).await.unwrap();
962
963        // Verify we have 4 files (2 data + 2 metadata)
964        let entries: Vec<_> = std::fs::read_dir(&quarantine_path)
965            .unwrap()
966            .collect::<Result<Vec<_>, _>>()
967            .unwrap();
968        assert_eq!(entries.len(), 4, "Should have 4 files (2 files + 2 metadata)");
969
970        // Verify all files have different names
971        let mut filenames: Vec<_> = entries
972            .iter()
973            .map(|e| e.file_name().to_string_lossy().to_string())
974            .collect();
975        filenames.sort();
976        filenames.dedup();
977        assert_eq!(filenames.len(), 4, "All quarantined files should have unique names");
978    }
979
980    #[tokio::test]
981    async fn test_quarantine_scanner_name() {
982        let scanner = QuarantineScanner::new(
983            NoOpScanner::new(),
984            std::path::PathBuf::from("/tmp/quarantine"),
985        );
986        assert_eq!(scanner.name(), "Quarantine Scanner");
987    }
988
989    #[tokio::test]
990    async fn test_quarantine_scanner_availability() {
991        let scanner = QuarantineScanner::new(
992            NoOpScanner::new(),
993            std::path::PathBuf::from("/tmp/quarantine"),
994        );
995        assert!(scanner.is_available().await);
996
997        // Test with unavailable inner scanner
998        let unavailable_scanner = QuarantineScanner::new(
999            MockInfectedScanner::new("test"),
1000            std::path::PathBuf::from("/tmp/quarantine"),
1001        );
1002        assert!(unavailable_scanner.is_available().await);
1003    }
1004}