1use std::path::Path;
28#[cfg(not(windows))]
29use std::path::PathBuf;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Verdict {
34 Clean,
35 Infected(String),
37 TooBig,
40 Unavailable(String),
42}
43
44pub fn scan(file: &Path) -> Verdict {
46 #[cfg(feature = "insecure-test-tls")]
48 if std::env::var_os("SIOUL_TEST_NO_ANTIVIRUS").is_some() {
49 return Verdict::Unavailable("no antivirus (test)".into());
50 }
51 #[cfg(windows)]
52 {
53 windows_scan(file)
54 }
55 #[cfg(target_os = "android")]
57 {
58 let _ = file;
59 Verdict::Unavailable("no antivirus on a phone".into())
60 }
61 #[cfg(not(any(windows, target_os = "android")))]
62 {
63 clamav(file)
64 }
65}
66
67#[cfg(not(windows))]
70fn program(name: &str) -> PathBuf {
71 ["/opt/homebrew/bin", "/usr/local/bin"].iter().map(|dir| Path::new(dir).join(name)).find(|p| p.is_file()).filter(|_| cfg!(target_os = "macos")).unwrap_or_else(|| PathBuf::from(name))
72}
73
74pub fn install_hint() -> String {
78 #[cfg(windows)]
79 {
80 crate::translator().text("antivirus-hint-windows", None)
81 }
82 #[cfg(target_os = "macos")]
83 {
84 "`brew install clamav`".to_string()
85 }
86 #[cfg(target_os = "android")]
88 {
89 crate::translator().text("antivirus-hint-phone", None)
90 }
91 #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))]
92 {
93 let release = std::fs::read_to_string("/etc/os-release").unwrap_or_default().to_ascii_lowercase();
94 let like = |name: &str| release.lines().any(|l| (l.starts_with("id=") || l.starts_with("id_like=")) && l.contains(name));
95 if like("fedora") || like("rhel") {
96 "`sudo dnf install clamav clamav-update && sudo freshclam`".to_string()
97 } else if like("debian") || like("ubuntu") {
98 "`sudo apt install clamav && sudo freshclam`".to_string()
99 } else if like("arch") {
100 "`sudo pacman -S clamav && sudo freshclam`".to_string()
101 } else if like("suse") {
102 "`sudo zypper install clamav && sudo freshclam`".to_string()
103 } else {
104 crate::translator().text("antivirus-hint-packages", None)
105 }
106 }
107}
108
109#[cfg(not(windows))]
111fn installed(name: &str) -> bool {
112 let found = program(name);
113 (found.is_absolute() && found.is_file()) || std::env::var_os("PATH").is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join(name).is_file()))
114}
115
116#[cfg(not(windows))]
118fn own_signatures() -> PathBuf {
119 sioul_core::config::data_dir().join("clamav")
120}
121
122#[cfg(not(windows))]
124fn system_signatures() -> bool {
125 ["/var/lib/clamav", "/usr/local/share/clamav", "/opt/homebrew/var/lib/clamav", "/usr/local/var/lib/clamav"]
126 .iter()
127 .any(|dir| std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok).any(|e| e.path().extension().is_some_and(|x| x == "cvd" || x == "cld")))
128}
129
130#[cfg(not(any(windows, target_os = "android")))]
131fn clamav(file: &Path) -> Verdict {
132 let own = own_signatures();
133 let daemon = daemon_config().is_some_and(|text| daemon_alerts(&text));
134 let mut problem = String::new();
135 for (command, options) in runs(own.is_dir().then_some(own.as_path()), daemon) {
136 match std::process::Command::new(program(command)).args(&options).arg(file).output() {
137 Ok(out) => match clamav_verdict(out.status.code(), &String::from_utf8_lossy(&out.stdout)) {
138 Some(verdict) => return verdict,
139 None => problem = String::from_utf8_lossy(&out.stderr).lines().find(|l| !l.contains("LibClamAV Warning")).unwrap_or("").to_string(),
140 },
141 Err(e) if problem.is_empty() => problem = e.to_string(),
142 Err(_) => {}
143 }
144 }
145 Verdict::Unavailable(problem)
146}
147
148#[cfg(not(any(windows, target_os = "android")))]
153fn runs(own: Option<&Path>, daemon_alerts: bool) -> Vec<(&'static str, Vec<String>)> {
154 let scanner = || vec!["--no-summary".to_string(), "--alert-exceeds-max=yes".to_string()];
155 let mut runs: Vec<(&'static str, Vec<String>)> = Vec::new();
156 if daemon_alerts {
157 runs.push(("clamdscan", vec!["--no-summary".into(), "--fdpass".into()]));
158 }
159 runs.push(("clamscan", scanner()));
160 if let Some(own) = own {
161 runs.push(("clamscan", [scanner(), vec![format!("--database={}", own.display())]].concat()));
162 }
163 runs
164}
165
166#[cfg(not(any(windows, target_os = "android")))]
171fn clamav_verdict(code: Option<i32>, stdout: &str) -> Option<Verdict> {
172 match code {
173 Some(0) => Some(Verdict::Clean),
174 Some(1) => {
175 let threat = stdout.lines().find_map(|l| l.trim_end().strip_suffix(" FOUND").and_then(|l| l.rsplit_once(": ")).map(|(_, t)| t.to_string())).unwrap_or_default();
176 Some(if threat.starts_with("Heuristics.Limits.Exceeded") { Verdict::TooBig } else { Verdict::Infected(threat) })
177 }
178 _ => None,
179 }
180}
181
182#[cfg(not(any(windows, target_os = "android")))]
185fn daemon_config() -> Option<String> {
186 ["/etc/clamd.d/scan.conf", "/etc/clamav/clamd.conf", "/etc/clamd.conf", "/usr/local/etc/clamd.conf", "/opt/homebrew/etc/clamav/clamd.conf", "/usr/local/etc/clamav/clamd.conf"].iter().find_map(|path| std::fs::read_to_string(path).ok())
187}
188
189#[cfg(not(any(windows, target_os = "android")))]
194fn daemon_alerts(config: &str) -> bool {
195 config.lines().map(str::trim).filter(|l| !l.starts_with('#')).filter_map(|l| l.split_once(char::is_whitespace)).any(|(key, value)| key == "AlertExceedsMax" && matches!(value.trim().to_ascii_lowercase().as_str(), "yes" | "true" | "1"))
196}
197
198#[cfg(not(windows))]
201pub fn refresh_signatures() -> Result<bool, String> {
202 if system_signatures() || !installed("freshclam") {
204 return Ok(false);
205 }
206 let own = own_signatures();
207 let fresh = std::fs::metadata(own.join("daily.cld"))
208 .or_else(|_| std::fs::metadata(own.join("daily.cvd")))
209 .and_then(|m| m.modified())
210 .ok()
211 .and_then(|t| t.elapsed().ok())
212 .is_some_and(|age| age.as_secs() < 24 * 3600);
213 if fresh {
214 return Ok(false);
215 }
216 std::fs::create_dir_all(&own).map_err(|e| e.to_string())?;
217 let settings = own.join("freshclam.conf");
219 std::fs::write(&settings, format!("DatabaseDirectory {}\nDatabaseMirror database.clamav.net\nNotifyClamd no\n", own.display())).map_err(|e| e.to_string())?;
220 let out = std::process::Command::new(program("freshclam")).arg(format!("--config-file={}", settings.display())).arg("--quiet").output().map_err(|e| e.to_string())?;
221 if out.status.success() { Ok(true) } else { Err(String::from_utf8_lossy(&out.stderr).lines().next().unwrap_or("freshclam").to_string()) }
222}
223
224#[cfg(windows)]
226pub fn refresh_signatures() -> Result<bool, String> {
227 Ok(false)
228}
229
230#[cfg(windows)]
232fn windows_scan(file: &Path) -> Verdict {
233 use windows::Win32::System::Antimalware::{AMSI_RESULT_DETECTED, AmsiCloseSession, AmsiInitialize, AmsiOpenSession, AmsiScanBuffer, AmsiUninitialize};
234 use windows::core::HSTRING;
235 let bytes = match std::fs::read(file) {
236 Ok(bytes) => bytes,
237 Err(e) => return Verdict::Unavailable(e.to_string()),
238 };
239 let Ok(length) = u32::try_from(bytes.len()) else { return Verdict::TooBig };
240 let name = HSTRING::from(file.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default());
241 unsafe {
244 let context = match AmsiInitialize(&HSTRING::from("Sioul")) {
245 Ok(context) => context,
246 Err(e) => return Verdict::Unavailable(e.message()),
247 };
248 let session = AmsiOpenSession(context).ok();
249 let result = AmsiScanBuffer(context, bytes.as_ptr().cast(), length, &name, session);
250 if let Some(session) = session {
251 AmsiCloseSession(context, session);
252 }
253 AmsiUninitialize(context);
254 match result {
255 Ok(result) if result.0 >= AMSI_RESULT_DETECTED.0 => Verdict::Infected("Microsoft Defender".into()),
256 Ok(_) => Verdict::Clean,
257 Err(e) => Verdict::Unavailable(e.message()),
258 }
259 }
260}
261
262#[cfg(all(test, not(any(windows, target_os = "android"))))]
263mod tests {
264 use super::*;
265
266 #[test]
269 fn too_big_is_not_clean() {
270 assert_eq!(clamav_verdict(Some(1), "/tmp/plain.txt: Heuristics.Limits.Exceeded.MaxFileSize FOUND\n"), Some(Verdict::TooBig));
272 assert_eq!(clamav_verdict(Some(1), "/tmp/a.zip: Heuristics.Limits.Exceeded.MaxScanSize FOUND\n"), Some(Verdict::TooBig));
273 assert_eq!(clamav_verdict(Some(1), "/tmp/eicar.com: Eicar-Test-Signature FOUND\n"), Some(Verdict::Infected("Eicar-Test-Signature".into())));
274 assert_eq!(clamav_verdict(Some(0), "/tmp/plain.txt: OK\n"), Some(Verdict::Clean));
275 assert_eq!(clamav_verdict(Some(2), ""), None);
276 let own = Path::new("/data/sioul/clamav");
278 for daemon in [false, true] {
279 let all = runs(Some(own), daemon);
280 assert!(all.iter().filter(|(c, _)| *c == "clamscan").all(|(_, o)| o.iter().any(|o| o == "--alert-exceeds-max=yes")), "{all:?}");
281 assert_eq!(all.iter().any(|(c, _)| *c == "clamdscan"), daemon);
282 }
283 assert_eq!(runs(None, false).len(), 1);
284 assert!(!daemon_alerts("# When AlertExceedsMax is set...\n#AlertExceedsMax yes\nMaxFileSize 400M\n"));
286 assert!(daemon_alerts("LocalSocket /run/clamd.scan/clamd.sock\nAlertExceedsMax yes\n"));
287 assert!(!daemon_alerts("AlertExceedsMax no\n"));
288 }
289}