1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
49struct QuarantineMetadata {
50 quarantined_at: String,
52
53 threat_name: String,
55
56 original_filename: String,
58
59 original_mime_type: String,
61
62 file_size: usize,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum ScanResult {
69 Clean,
71
72 Infected {
74 threat: String,
76 },
77
78 Error {
80 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#[cfg_attr(test, mockall::automock)]
100#[async_trait]
101pub trait VirusScanner: Send + Sync {
102 async fn scan(&self, file: &UploadedFile) -> StorageResult<ScanResult>;
121
122 fn name(&self) -> &'static str;
133
134 async fn is_available(&self) -> bool;
148}
149
150#[derive(Debug, Clone, Default)]
166pub struct NoOpScanner;
167
168impl NoOpScanner {
169 #[must_use]
179 pub const fn new() -> Self {
180 Self
181 }
182
183 #[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 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#[cfg(feature = "clamav")]
219#[derive(Debug, Clone)]
220pub enum ClamAvConnection {
221 Tcp {
237 host: String,
239 port: u16,
241 },
242
243 #[cfg(unix)]
261 Socket {
262 path: std::path::PathBuf,
264 },
265}
266
267#[cfg(feature = "clamav")]
306#[derive(Debug, Clone)]
307pub struct ClamAvScanner {
308 connection: ClamAvConnection,
309}
310
311#[cfg(feature = "clamav")]
312impl ClamAvScanner {
313 #[must_use]
339 pub const fn new(connection: ClamAvConnection) -> Self {
340 Self { connection }
341 }
342
343 #[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 #[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 let data = &file.data;
400
401 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 match clamav_client::clean(&response) {
434 Ok(true) => Ok(ScanResult::Clean),
435 Ok(false) => {
436 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#[cfg(not(feature = "clamav"))]
497#[derive(Debug, Clone, Default)]
498pub struct ClamAvScanner;
499
500#[cfg(not(feature = "clamav"))]
501impl ClamAvScanner {
502 #[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#[derive(Debug)]
560pub struct QuarantineScanner<S: VirusScanner> {
561 inner: S,
563
564 quarantine_path: std::path::PathBuf,
566}
567
568impl<S: VirusScanner> QuarantineScanner<S> {
569 #[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 if let Err(e) = self.quarantine_file(file, threat).await {
604 error!(
605 "Failed to quarantine infected file '{}': {}",
606 file.filename, e
607 );
608 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 async fn quarantine_file(&self, file: &UploadedFile, threat: &str) -> StorageResult<()> {
638 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 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 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 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 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 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, });
754
755 let result = scanner.scan(&file).await;
756 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 #[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 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 assert!(matches!(result, ScanResult::Infected { .. }));
867
868 assert!(quarantine_path.exists());
870
871 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 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 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 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 assert_eq!(result, ScanResult::Clean);
915
916 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 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 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 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 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 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 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}