Skip to main content

sioul_sync/
antivirus.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Checking a file before it opens: one question, three answers, one way per
5//! system (docs/client.md, "Antivirus").
6//!
7//! - **Windows**: the Antimalware Scan Interface (AMSI) hands the file to the
8//!   antivirus Windows already runs, Microsoft Defender by default. Nothing to install.
9//! - **Linux and macOS**: ClamAV, when the system has it (its packages, or
10//!   Homebrew): its daemon when it runs (fast) and says when a file passes its
11//!   limits, else its scanner with the system's signatures, else with Sioul's
12//!   own signatures, which Sioul keeps current with the system's `freshclam`.
13//!   Nothing of ClamAV ships with Sioul.
14//! - **A file larger than the antivirus scans** (ClamAV's limits on a file's
15//!   size, on what an archive holds, on its depth; AMSI's 4 GB) is "not
16//!   scanned: too big", never clean: ClamAV left alone finds such a file clean
17//!   without reading it, so its scanner is asked to say so
18//!   (`--alert-exceeds-max`), and its daemon is asked only when its own
19//!   configuration says so (`AlertExceedsMax yes`).
20//! - **Without an antivirus**, nothing is refused: the window says the file
21//!   will not be checked, asks before opening or saving it, and says how to
22//!   install one (`install_hint`).
23//! - **A phone** has no antivirus another app can call, and no ClamAV:
24//!   `scan` answers `Unavailable` there without trying, and Sioul's window
25//!   does not ask it (crates/sioul-app/src/attachments.rs).
26
27use std::path::Path;
28#[cfg(not(windows))]
29use std::path::PathBuf;
30
31/// What the antivirus said.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Verdict {
34    Clean,
35    /// A threat, by the name the antivirus gives it.
36    Infected(String),
37    /// Not scanned: larger than the antivirus scans (a file's size, what an
38    /// archive holds, how deep it nests). Nothing is opened without asking.
39    TooBig,
40    /// No antivirus answered: nothing is opened.
41    Unavailable(String),
42}
43
44/// Checks a file.
45pub fn scan(file: &Path) -> Verdict {
46    // A computer without an antivirus, in tests only (never in Sioul's builds).
47    #[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    // A phone: no antivirus another app can call, and no ClamAV to start.
56    #[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/// One of ClamAV's programs, as the system has it: on its path, else where
68/// Homebrew puts it (a macOS application does not get the shell's path).
69#[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
74/// How to install an antivirus here, in one line: the command for this
75/// system's packages, in backticks (the window offers it to copy), or what to
76/// turn on, in your language.
77pub 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    // A phone has none to install that Sioul could call: a computer with one, then.
87    #[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/// Whether one of ClamAV's programs is installed: on the path, or where Homebrew puts it.
110#[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/// Where Sioul keeps its own ClamAV signatures (not on Windows, which has its own antivirus).
117#[cfg(not(windows))]
118fn own_signatures() -> PathBuf {
119    sioul_core::config::data_dir().join("clamav")
120}
121
122/// Whether the system keeps ClamAV signatures Sioul's scanner can read.
123#[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/// The scanners to ask, in order, with their options: ClamAV's daemon when
149/// it says when a file passes its limits (`daemon_alerts`), else none of it;
150/// its scanner with the system's signatures, then with Sioul's own (`own`),
151/// each asked to say so (`--alert-exceeds-max`).
152#[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/// What ClamAV's exit code and output say: 0, nothing found; 1, a threat
167/// (`<file>: Eicar-Test-Signature FOUND`), or a limit passed, which it names
168/// `Heuristics.Limits.Exceeded…` when asked to say so: not scanned, too big;
169/// anything else, none: it could not scan.
170#[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/// The configuration ClamAV's daemon and `clamdscan` read, where each
183/// system's packages put it; none when there is none to read.
184#[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/// Whether ClamAV's daemon says when a file passes its limits: its
190/// configuration's `AlertExceedsMax yes`. Without it, the daemon finds such a
191/// file clean without reading it, and there is no option to ask it otherwise
192/// for one scan: the scanner is asked instead.
193#[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/// Keeps Sioul's own ClamAV signatures current, once a day, when the system
199/// has ClamAV's programs but no signatures of its own. Returns whether it ran.
200#[cfg(not(windows))]
201pub fn refresh_signatures() -> Result<bool, String> {
202    // Without ClamAV (Android has none), nothing to keep current.
203    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    // freshclam reads its settings from a file: the smallest one, written here.
218    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/// Windows keeps its antivirus current itself.
225#[cfg(windows)]
226pub fn refresh_signatures() -> Result<bool, String> {
227    Ok(false)
228}
229
230/// AMSI: the file's bytes, with its name, handed to Windows' antivirus.
231#[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    // SAFETY: AMSI's documented sequence: a context, a session, one scan of a
242    // buffer that outlives the call, then both closed, on this thread.
243    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    /// A file larger than ClamAV scans is "too big", never clean: its scanner
267    /// is always asked to say so, its daemon only when it is set to.
268    #[test]
269    fn too_big_is_not_clean() {
270        // As ClamAV 1.4 writes it, for a file over --max-filesize.
271        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        // Every scanner run asks for the alert; the daemon is asked only when its configuration alerts.
277        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        // Fedora's file as it comes: the line commented out.
285        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}