Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/inspect/src/malware.rs

//! **The malware hook: optional, pluggable scanners.**
//!
//! A [`MalwareScanner`] scans bytes and says [`ScanOutcome::Clean`],
//! [`ScanOutcome::Detected`], [`ScanOutcome::NotConfigured`] or
//! [`ScanOutcome::Error`]. Two adapters ship in the open core:
//!
//! * [`ClamAv`] — a clamd socket (unix path or `host:port`, the `INSTREAM`
//!   protocol) or the `clamscan` binary. ClamAV unpacks archives itself, so it
//!   is handed the artifact once.
//! * [`Yara`] — rule files run by the built-in matcher ([`crate::yara_lite`])
//!   when they fit its subset, else by the `yara` binary. YARA does not unpack,
//!   so the inspector hands it every member as well as the artifact.
//!
//! An engine that is not installed reports "not configured". How that weighs
//! is the scanner's `required` flag: a required scanner that is not configured,
//! or that fails, BLOCKS; an optional one leaves an info or warn finding.
//! Commercial engines are not here (Holger pro).

use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;

use serde::{Deserialize, Serialize};

use crate::{FindingKind, Findings, Severity};

/// What one scan concluded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScanOutcome {
    Clean,
    /// The signatures or rules that matched.
    Detected(Vec<String>),
    /// The engine is not installed, or has nothing to run.
    NotConfigured(String),
    /// The engine is there but the scan failed.
    Error(String),
}

/// A pluggable malware scanner.
pub trait MalwareScanner: Send + Sync {
    /// The name in findings and evidence: `clamav`, `yara`.
    fn name(&self) -> &str;
    /// A required scanner that is not configured, or fails, blocks.
    fn required(&self) -> bool;
    /// Scan one artifact or member. `name` is its file name, for messages.
    fn scan(&self, bytes: &[u8], name: &str) -> ScanOutcome;
    /// Does it want every member of an archive too, not only the artifact?
    fn reads_members(&self) -> bool {
        false
    }
}

/// The artifact-level scan: every scanner, once.
/// Run the artifact-level scan of `scanners` over `bytes` into `out`. The
/// server calls it with ClamAV alone: clamd reads the bytes in its own sandboxed
/// process, so nothing is unpacked here.
pub fn scan_artifact(
    scanners: &[std::sync::Arc<dyn MalwareScanner>],
    bytes: &[u8],
    name: &str,
    out: &mut Findings,
) {
    let label = if name.is_empty() {
        format!("<{}>", out.format.name())
    } else {
        name.to_string()
    };
    for s in scanners {
        out.scanners.push(s.name().to_string());
        match s.scan(bytes, name) {
            ScanOutcome::Clean => {}
            ScanOutcome::Detected(sigs) => out.push(
                FindingKind::Malware,
                Severity::Block,
                &label,
                format!("{}: {}", s.name(), sigs.join(", ")),
            ),
            ScanOutcome::NotConfigured(why) => {
                let sev = if s.required() {
                    Severity::Block
                } else {
                    Severity::Info
                };
                let req = if s.required() {
                    "required scanner"
                } else {
                    "optional scanner"
                };
                out.push(
                    FindingKind::ScannerNotConfigured,
                    sev,
                    &label,
                    format!("{req} {} not configured: {why}", s.name()),
                );
            }
            ScanOutcome::Error(e) => {
                let sev = if s.required() {
                    Severity::Block
                } else {
                    Severity::Warn
                };
                out.push(
                    FindingKind::ScannerError,
                    sev,
                    &label,
                    format!("{}: {e}", s.name()),
                );
            }
        }
    }
}

/// The member-level scan: the scanners that read members, on one member.
/// "Not configured" is reported once, by the artifact-level scan.
pub(crate) fn scan_member(
    scanners: &[std::sync::Arc<dyn MalwareScanner>],
    bytes: &[u8],
    name: &str,
    mpath: &str,
    out: &mut Findings,
) {
    for s in scanners.iter().filter(|s| s.reads_members()) {
        match s.scan(bytes, name) {
            ScanOutcome::Detected(sigs) => out.push(
                FindingKind::Malware,
                Severity::Block,
                mpath,
                format!("{}: {}", s.name(), sigs.join(", ")),
            ),
            ScanOutcome::Error(e) => {
                let sev = if s.required() {
                    Severity::Block
                } else {
                    Severity::Warn
                };
                out.push(
                    FindingKind::ScannerError,
                    sev,
                    mpath,
                    format!("{}: {e}", s.name()),
                );
            }
            ScanOutcome::Clean | ScanOutcome::NotConfigured(_) => {}
        }
    }
}

/// An executable named `cmd` on `PATH`.
pub fn which(cmd: &str) -> Option<PathBuf> {
    let path = std::env::var_os("PATH")?;
    std::env::split_paths(&path)
        .map(|d| d.join(cmd))
        .find(|p| is_executable(p))
}

fn is_executable(p: &Path) -> bool {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        p.metadata()
            .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
    }
    #[cfg(not(unix))]
    {
        p.is_file()
    }
}

/// Run `cmd` with `input` on stdin; (exit code, stdout, stderr).
fn run(mut cmd: Command, input: Option<&[u8]>) -> std::io::Result<(Option<i32>, String, String)> {
    cmd.stdin(if input.is_some() {
        Stdio::piped()
    } else {
        Stdio::null()
    })
    .stdout(Stdio::piped())
    .stderr(Stdio::piped());
    // A32-14, a process by design: the scanner process itself (see its constructor).
    let mut child = cmd.spawn()?;
    let writer = match (input, child.stdin.take()) {
        (Some(bytes), Some(mut stdin)) => {
            let bytes = bytes.to_vec();
            // A32-14, stays a task: the scanner's stdin writer, joined after the scan.
            Some(std::thread::spawn(move || {
                let _ = stdin.write_all(&bytes);
            }))
        }
        _ => None,
    };
    let out = child.wait_with_output()?;
    if let Some(w) = writer {
        let _ = w.join();
    }
    Ok((
        out.status.code(),
        String::from_utf8_lossy(&out.stdout).into_owned(),
        String::from_utf8_lossy(&out.stderr).into_owned(),
    ))
}

// ── ClamAV ──────────────────────────────────────────────────────────────────

/// The `clamav:` block.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ClamAvConfig {
    /// A clamd socket: a unix socket path, or `host:port`. Unset: the usual
    /// socket paths are tried, then `clamscan` on PATH. Holger's server in a real
    /// build takes only the SANDBOXED clamd's unix socket here (no `host:port`, no
    /// host clamd, no `clamscan`; `holger-sandbox-15`, lane HOL-NCSC).
    pub socket: Option<String>,
    /// The `clamscan` binary, used when no socket is set.
    pub clamscan: Option<String>,
    pub required: bool,
    /// Socket read and write timeout.
    pub timeout_secs: u64,
}

impl Default for ClamAvConfig {
    fn default() -> Self {
        Self {
            socket: None,
            clamscan: None,
            required: false,
            timeout_secs: 120,
        }
    }
}

/// Where clamd listens on Debian, Fedora and the upstream default.
pub const CLAMD_SOCKETS: [&str; 4] = [
    "/run/clamav/clamd.ctl",
    "/var/run/clamav/clamd.ctl",
    "/run/clamd.scan/clamd.sock",
    "/tmp/clamd.socket",
];

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClamEngine {
    #[cfg(unix)]
    Unix(PathBuf),
    Tcp(String),
    Clamscan(PathBuf),
}

/// The ClamAV adapter.
#[derive(Debug, Clone)]
pub struct ClamAv {
    cfg: ClamAvConfig,
}

impl ClamAv {
    pub fn from_config(cfg: &ClamAvConfig) -> Self {
        Self { cfg: cfg.clone() }
    }

    /// The engine this host has, or why there is none.
    pub fn engine(&self) -> Result<ClamEngine, String> {
        if let Some(s) = &self.cfg.socket {
            if !s.contains('/') && s.contains(':') {
                return Ok(ClamEngine::Tcp(s.clone()));
            }
            #[cfg(unix)]
            {
                return if Path::new(s).exists() {
                    Ok(ClamEngine::Unix(PathBuf::from(s)))
                } else {
                    Err(format!(
                        "clamd socket {s} does not exist (is clamd installed and running?)"
                    ))
                };
            }
            #[cfg(not(unix))]
            return Err(format!("unix socket {s} on a host without unix sockets"));
        }
        if let Some(c) = &self.cfg.clamscan {
            return if is_executable(Path::new(c)) {
                Ok(ClamEngine::Clamscan(PathBuf::from(c)))
            } else {
                Err(format!("clamscan {c} is not installed"))
            };
        }
        #[cfg(unix)]
        if let Some(p) = CLAMD_SOCKETS.iter().map(Path::new).find(|p| p.exists()) {
            return Ok(ClamEngine::Unix(p.to_path_buf()));
        }
        which("clamscan")
            .map(ClamEngine::Clamscan)
            .ok_or_else(|| "neither a clamd socket nor clamscan is installed".to_string())
    }

    fn instream<S: Read + Write>(mut s: S, bytes: &[u8]) -> ScanOutcome {
        let send = |s: &mut S| -> std::io::Result<()> {
            s.write_all(b"zINSTREAM\0")?;
            for chunk in bytes.chunks(64 * 1024) {
                s.write_all(&(chunk.len() as u32).to_be_bytes())?;
                s.write_all(chunk)?;
            }
            s.write_all(&0u32.to_be_bytes())?;
            s.flush()
        };
        if let Err(e) = send(&mut s) {
            return ScanOutcome::Error(format!("clamd INSTREAM: {e}"));
        }
        let mut reply = Vec::new();
        if let Err(e) = s.take(64 * 1024).read_to_end(&mut reply) {
            if reply.is_empty() {
                return ScanOutcome::Error(format!("clamd reply: {e}"));
            }
        }
        parse_clamd_reply(&String::from_utf8_lossy(&reply))
    }
}

/// `stream: OK`, `stream: Sig.Name FOUND`, or `… ERROR`.
pub fn parse_clamd_reply(raw: &str) -> ScanOutcome {
    let line = raw.trim_matches(|c: char| c == '\0' || c.is_whitespace());
    let body = line.split_once(": ").map(|(_, b)| b).unwrap_or(line);
    if body == "OK" {
        ScanOutcome::Clean
    } else if let Some(sig) = body.strip_suffix(" FOUND") {
        ScanOutcome::Detected(vec![sig.to_string()])
    } else {
        ScanOutcome::Error(format!("clamd answered {line:?}"))
    }
}

impl MalwareScanner for ClamAv {
    fn name(&self) -> &str {
        "clamav"
    }

    fn required(&self) -> bool {
        self.cfg.required
    }

    fn scan(&self, bytes: &[u8], _name: &str) -> ScanOutcome {
        let t = Some(Duration::from_secs(self.cfg.timeout_secs.max(1)));
        match self.engine() {
            Err(why) => ScanOutcome::NotConfigured(why),
            #[cfg(unix)]
            Ok(ClamEngine::Unix(p)) => match std::os::unix::net::UnixStream::connect(&p) {
                Ok(s) => {
                    let _ = s.set_read_timeout(t);
                    let _ = s.set_write_timeout(t);
                    Self::instream(s, bytes)
                }
                Err(e) => ScanOutcome::Error(format!("clamd {}: {e}", p.display())),
            },
            Ok(ClamEngine::Tcp(addr)) => match std::net::TcpStream::connect(&addr) {
                Ok(s) => {
                    let _ = s.set_read_timeout(t);
                    let _ = s.set_write_timeout(t);
                    Self::instream(s, bytes)
                }
                Err(e) => ScanOutcome::Error(format!("clamd {addr}: {e}")),
            },
            Ok(ClamEngine::Clamscan(bin)) => {
                // A32-14, a process by design: the malware scan runs ClamAV's clamscan when no clamd socket is fed; the scanner is the feature.
                let mut cmd = Command::new(&bin);
                cmd.args(["--no-summary", "--stdout", "-"]);
                match run(cmd, Some(bytes)) {
                    Ok((Some(0), _, _)) => ScanOutcome::Clean,
                    Ok((Some(1), out, _)) => ScanOutcome::Detected(
                        out.lines()
                            .filter_map(|l| l.strip_suffix(" FOUND"))
                            .map(|l| l.split_once(": ").map(|(_, s)| s).unwrap_or(l).to_string())
                            .collect(),
                    ),
                    Ok((code, _, err)) => {
                        ScanOutcome::Error(format!("clamscan exit {code:?}: {}", err.trim()))
                    }
                    Err(e) => ScanOutcome::Error(format!("clamscan: {e}")),
                }
            }
        }
    }
}

// ── YARA ────────────────────────────────────────────────────────────────────

/// The `yara:` block.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct YaraConfig {
    /// A rule file, or a directory of `.yar` / `.yara` files.
    pub rules: Option<String>,
    /// The `yara` binary for rules beyond the built-in subset. Unset: PATH.
    pub binary: Option<String>,
    pub required: bool,
}

#[derive(Debug, Clone)]
enum YaraEngine {
    /// The rules fit the built-in matcher.
    Builtin(crate::yara_lite::Rules),
    /// The rules need the real engine: these rule files, this binary.
    Binary {
        bin: PathBuf,
        files: Vec<PathBuf>,
    },
    NotConfigured(String),
}

/// The YARA adapter.
#[derive(Debug, Clone)]
pub struct Yara {
    engine: YaraEngine,
    required: bool,
}

impl Yara {
    pub fn from_config(cfg: &YaraConfig) -> Self {
        let engine = match &cfg.rules {
            None => YaraEngine::NotConfigured("no rules are configured".into()),
            Some(path) => match Self::rule_files(Path::new(path)) {
                Err(why) => YaraEngine::NotConfigured(why),
                Ok(files) => Self::compile(&files, cfg.binary.as_deref()),
            },
        };
        Self {
            engine,
            required: cfg.required,
        }
    }

    /// Rules from source, for tests and embedders. No binary fallback.
    pub fn from_source(src: &str, required: bool) -> Self {
        let engine = match crate::yara_lite::Rules::parse(src) {
            Ok(r) => YaraEngine::Builtin(r),
            Err(e) => YaraEngine::NotConfigured(format!("rules need the yara engine: {e}")),
        };
        Self { engine, required }
    }

    /// Is this adapter able to scan, and with what?
    pub fn engine_name(&self) -> Result<&'static str, String> {
        match &self.engine {
            YaraEngine::Builtin(_) => Ok("built-in"),
            YaraEngine::Binary { .. } => Ok("yara binary"),
            YaraEngine::NotConfigured(why) => Err(why.clone()),
        }
    }

    fn rule_files(p: &Path) -> Result<Vec<PathBuf>, String> {
        if p.is_file() {
            return Ok(vec![p.to_path_buf()]);
        }
        let rd = std::fs::read_dir(p).map_err(|e| format!("rules {}: {e}", p.display()))?;
        let mut files: Vec<PathBuf> = rd
            .filter_map(|e| e.ok().map(|e| e.path()))
            .filter(|f| f.extension().is_some_and(|x| x == "yar" || x == "yara"))
            .collect();
        files.sort();
        if files.is_empty() {
            return Err(format!("no .yar or .yara files in {}", p.display()));
        }
        Ok(files)
    }

    fn compile(files: &[PathBuf], binary: Option<&str>) -> YaraEngine {
        let mut src = String::new();
        for f in files {
            match std::fs::read_to_string(f) {
                Ok(s) => {
                    src.push_str(&s);
                    src.push('\n');
                }
                Err(e) => return YaraEngine::NotConfigured(format!("rules {}: {e}", f.display())),
            }
        }
        match crate::yara_lite::Rules::parse(&src) {
            Ok(r) => YaraEngine::Builtin(r),
            Err(why) => {
                let bin = match binary {
                    Some(b) => Some(PathBuf::from(b)).filter(|p| is_executable(p)),
                    None => which("yara"),
                };
                match bin {
                    Some(bin) => YaraEngine::Binary { bin, files: files.to_vec() },
                    None => YaraEngine::NotConfigured(format!(
                        "the rules need the yara engine ({why}), and the yara binary is not installed"
                    )),
                }
            }
        }
    }
}

impl MalwareScanner for Yara {
    fn name(&self) -> &str {
        "yara"
    }

    fn required(&self) -> bool {
        self.required
    }

    fn reads_members(&self) -> bool {
        true
    }

    fn scan(&self, bytes: &[u8], _name: &str) -> ScanOutcome {
        match &self.engine {
            YaraEngine::NotConfigured(why) => ScanOutcome::NotConfigured(why.clone()),
            YaraEngine::Builtin(rules) => {
                let hits = rules.scan(bytes);
                if hits.is_empty() {
                    ScanOutcome::Clean
                } else {
                    ScanOutcome::Detected(hits)
                }
            }
            YaraEngine::Binary { bin, files } => {
                let mut tmp = match tempfile::NamedTempFile::new() {
                    Ok(t) => t,
                    Err(e) => return ScanOutcome::Error(format!("temp file: {e}")),
                };
                if let Err(e) = tmp.write_all(bytes).and_then(|_| tmp.flush()) {
                    return ScanOutcome::Error(format!("temp file: {e}"));
                }
                // A32-14, a process by design: the YARA scan runs the yara program on the bytes; the scanner is the feature.
                let mut cmd = Command::new(bin);
                cmd.arg("-w").args(files).arg(tmp.path());
                match run(cmd, None) {
                    Ok((Some(0), out, _)) => {
                        let hits: Vec<String> = out
                            .lines()
                            .filter_map(|l| l.split_whitespace().next())
                            .map(str::to_string)
                            .collect();
                        if hits.is_empty() {
                            ScanOutcome::Clean
                        } else {
                            ScanOutcome::Detected(hits)
                        }
                    }
                    Ok((code, _, err)) => {
                        ScanOutcome::Error(format!("yara exit {code:?}: {}", err.trim()))
                    }
                    Err(e) => ScanOutcome::Error(format!("yara: {e}")),
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn clamd_replies_parse() {
        assert_eq!(parse_clamd_reply("stream: OK\0"), ScanOutcome::Clean);
        assert_eq!(
            parse_clamd_reply("stream: Eicar-Test-Signature FOUND\0"),
            ScanOutcome::Detected(vec!["Eicar-Test-Signature".into()])
        );
        assert!(matches!(
            parse_clamd_reply("INSTREAM size limit exceeded. ERROR\0"),
            ScanOutcome::Error(_)
        ));
    }

    #[test]
    fn a_clamd_reply_without_the_stream_prefix_still_parses() {
        assert_eq!(parse_clamd_reply("OK"), ScanOutcome::Clean);
        assert_eq!(
            parse_clamd_reply("  Win.Test FOUND \n"),
            ScanOutcome::Detected(vec!["Win.Test".into()])
        );
        assert_eq!(
            parse_clamd_reply(""),
            ScanOutcome::Error("clamd answered \"\"".into())
        );
    }

    /// A stand-in clamd on TCP: reads one INSTREAM, records the chunk sizes, answers `reply`.
    fn tcp_clamd(reply: &'static [u8]) -> (String, std::thread::JoinHandle<Vec<usize>>) {
        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = l.local_addr().unwrap().to_string();
        let h = std::thread::spawn(move || {
            let (mut s, _) = l.accept().unwrap();
            let mut cmd = [0u8; 10];
            s.read_exact(&mut cmd).unwrap();
            assert_eq!(&cmd, b"zINSTREAM\0");
            let mut sizes = Vec::new();
            loop {
                let mut len = [0u8; 4];
                s.read_exact(&mut len).unwrap();
                let n = u32::from_be_bytes(len) as usize;
                if n == 0 {
                    break;
                }
                let mut chunk = vec![0u8; n];
                s.read_exact(&mut chunk).unwrap();
                sizes.push(n);
            }
            s.write_all(reply).unwrap();
            sizes
        });
        (addr, h)
    }

    #[test]
    fn clamd_over_tcp_gets_the_bytes_in_64_kib_chunks() {
        let (addr, h) = tcp_clamd(b"stream: OK\0");
        let clam = ClamAv::from_config(&ClamAvConfig {
            socket: Some(addr.clone()),
            ..Default::default()
        });
        assert_eq!(clam.engine(), Ok(ClamEngine::Tcp(addr)));
        assert_eq!(
            clam.scan(&vec![7u8; 150 * 1024], "big.bin"),
            ScanOutcome::Clean
        );
        assert_eq!(h.join().unwrap(), vec![65536, 65536, 150 * 1024 - 131072]);

        let (addr, h) = tcp_clamd(b"stream: Win.Test.Sig FOUND\0");
        let clam = ClamAv::from_config(&ClamAvConfig {
            socket: Some(addr),
            ..Default::default()
        });
        assert_eq!(
            clam.scan(b"x", "x"),
            ScanOutcome::Detected(vec!["Win.Test.Sig".into()])
        );
        h.join().unwrap();
    }

    #[test]
    fn a_clamd_that_answers_nothing_or_is_not_there_is_an_error_not_clean() {
        let (addr, h) = tcp_clamd(b"");
        let clam = ClamAv::from_config(&ClamAvConfig {
            socket: Some(addr),
            ..Default::default()
        });
        assert!(
            matches!(clam.scan(b"x", "x"), ScanOutcome::Error(e) if e.contains("clamd answered"))
        );
        h.join().unwrap();

        let dead = {
            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
            l.local_addr().unwrap().to_string()
        };
        let clam = ClamAv::from_config(&ClamAvConfig {
            socket: Some(dead.clone()),
            ..Default::default()
        });
        assert!(
            matches!(clam.scan(b"x", "x"), ScanOutcome::Error(e) if e.starts_with(&format!("clamd {dead}")))
        );
    }

    #[cfg(unix)]
    fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
        use std::os::unix::fs::PermissionsExt;
        let p = dir.join(name);
        std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
        p
    }

    /// A freshly written script can be "busy" for a moment while another test forks.
    #[cfg(unix)]
    fn settled(f: impl Fn() -> ScanOutcome) -> ScanOutcome {
        for _ in 0..20 {
            match f() {
                ScanOutcome::Error(e) if e.contains("busy") => {
                    std::thread::sleep(Duration::from_millis(50))
                }
                o => return o,
            }
        }
        f()
    }

    #[cfg(unix)]
    #[test]
    fn clamscan_exit_codes_map_to_clean_detected_and_error() {
        let d = tempfile::tempdir().unwrap();
        let clam = |bin: &Path| {
            ClamAv::from_config(&ClamAvConfig {
                clamscan: Some(bin.display().to_string()),
                ..Default::default()
            })
        };
        let clean = script(d.path(), "clean", "cat >/dev/null; exit 0");
        let found = script(
            d.path(),
            "found",
            "cat >/dev/null; echo \"stdin: Win.Test.Sig FOUND\"; echo \"stdin: Other FOUND\"; exit 1",
        );
        let broken = script(d.path(), "broken", "cat >/dev/null; echo boom >&2; exit 2");
        assert_eq!(
            clam(&clean).engine(),
            Ok(ClamEngine::Clamscan(clean.clone()))
        );
        assert_eq!(
            settled(|| clam(&clean).scan(b"abc", "a")),
            ScanOutcome::Clean
        );
        assert_eq!(
            settled(|| clam(&found).scan(b"abc", "a")),
            ScanOutcome::Detected(vec!["Win.Test.Sig".into(), "Other".into()])
        );
        assert_eq!(
            settled(|| clam(&broken).scan(b"abc", "a")),
            ScanOutcome::Error("clamscan exit Some(2): boom".into())
        );

        // A clamscan that is not executable is not an engine.
        let plain = d.path().join("plain");
        std::fs::write(&plain, "not a program").unwrap();
        assert!(
            matches!(clam(&plain).scan(b"x", "x"), ScanOutcome::NotConfigured(w) if w.contains("is not installed"))
        );
    }

    #[cfg(unix)]
    #[test]
    fn rules_beyond_the_built_in_subset_run_on_the_yara_binary() {
        let d = tempfile::tempdir().unwrap();
        let rules = d.path().join("rules");
        std::fs::create_dir(&rules).unwrap();
        std::fs::write(
            rules.join("r.yar"),
            "rule R { strings: $a = /x+/ condition: $a }",
        )
        .unwrap();
        std::fs::write(rules.join("notes.txt"), "not a rule").unwrap();
        // The fake engine checks it is called as `yara -w <rule files> <target>`.
        let hit = script(
            d.path(),
            "yara-hit",
            "[ \"$1\" = -w ] || exit 9; case \"$2\" in *r.yar) ;; *) exit 8;; esac; echo \"R $3\"; exit 0",
        );
        let miss = script(d.path(), "yara-miss", "exit 0");
        let broken = script(d.path(), "yara-broken", "echo bad rules >&2; exit 1");
        let y = |bin: &Path| {
            Yara::from_config(&YaraConfig {
                rules: Some(rules.display().to_string()),
                binary: Some(bin.display().to_string()),
                required: true,
            })
        };
        assert_eq!(y(&hit).engine_name(), Ok("yara binary"));
        assert!(y(&hit).reads_members());
        assert_eq!(
            settled(|| y(&hit).scan(b"xxx", "a")),
            ScanOutcome::Detected(vec!["R".into()])
        );
        assert_eq!(settled(|| y(&miss).scan(b"xxx", "a")), ScanOutcome::Clean);
        assert_eq!(
            settled(|| y(&broken).scan(b"xxx", "a")),
            ScanOutcome::Error("yara exit Some(1): bad rules".into())
        );

        // The same rules with a binary that is not executable: not configured, and saying why.
        let plain = d.path().join("plain");
        std::fs::write(&plain, "x").unwrap();
        let e = y(&plain).engine_name().unwrap_err();
        assert!(e.contains("yara binary is not installed"), "{e}");
    }

    #[test]
    fn yara_rule_paths_that_hold_no_rules_are_not_configured() {
        let d = tempfile::tempdir().unwrap();
        let cfg = |p: &Path| YaraConfig {
            rules: Some(p.display().to_string()),
            ..Default::default()
        };
        let e = Yara::from_config(&cfg(d.path())).engine_name().unwrap_err();
        assert!(e.contains("no .yar or .yara files"), "{e}");
        let e = Yara::from_config(&cfg(&d.path().join("absent")))
            .engine_name()
            .unwrap_err();
        assert!(e.starts_with("rules "), "{e}");
        // One rule file named directly loads on the built-in matcher.
        let f = d.path().join("one.yara");
        std::fs::write(&f, "rule Hello { strings: $a = \"hello\" condition: $a }").unwrap();
        let y = Yara::from_config(&cfg(&f));
        assert_eq!(y.engine_name(), Ok("built-in"));
        assert_eq!(
            y.scan(b"say hello", "x"),
            ScanOutcome::Detected(vec!["Hello".into()])
        );
        // A rule file that is not UTF-8 is refused as rules, not half read.
        std::fs::remove_file(&f).unwrap();
        std::fs::write(d.path().join("bad.yar"), [0xff, 0xfe, 0x00]).unwrap();
        let e = Yara::from_config(&cfg(d.path())).engine_name().unwrap_err();
        assert!(e.starts_with("rules "), "{e}");
    }

    /// A scanner that answers what it is told.
    struct Fixed {
        outcome: ScanOutcome,
        required: bool,
        members: bool,
    }

    impl MalwareScanner for Fixed {
        fn name(&self) -> &str {
            "fixed"
        }
        fn required(&self) -> bool {
            self.required
        }
        fn scan(&self, _: &[u8], _: &str) -> ScanOutcome {
            self.outcome.clone()
        }
        fn reads_members(&self) -> bool {
            self.members
        }
    }

    fn fixed(
        outcome: ScanOutcome,
        required: bool,
        members: bool,
    ) -> std::sync::Arc<dyn MalwareScanner> {
        std::sync::Arc::new(Fixed {
            outcome,
            required,
            members,
        })
    }

    #[test]
    fn a_failing_scanner_warns_when_optional_and_blocks_when_required() {
        use crate::{Format, Kind};
        let run = |s: std::sync::Arc<dyn MalwareScanner>, name: &str| {
            let mut f = Findings::new(Format::Wheel, Kind::Zip);
            scan_artifact(&[s], b"bytes", name, &mut f);
            f
        };

        let f = run(
            fixed(ScanOutcome::Error("timeout".into()), false, false),
            "a.whl",
        );
        assert_eq!(
            (f.findings[0].kind, f.findings[0].severity),
            (FindingKind::ScannerError, Severity::Warn)
        );
        assert_eq!(f.findings[0].detail, "fixed: timeout");
        let f = run(
            fixed(ScanOutcome::Error("timeout".into()), true, false),
            "a.whl",
        );
        assert_eq!(f.findings[0].severity, Severity::Block);
        let f = run(
            fixed(ScanOutcome::NotConfigured("absent".into()), true, false),
            "",
        );
        assert_eq!(
            (f.findings[0].kind, f.findings[0].severity),
            (FindingKind::ScannerNotConfigured, Severity::Block)
        );
        assert_eq!(
            f.findings[0].path, "<wheel>",
            "an unnamed artifact is labelled by its format"
        );
        assert!(f.findings[0].detail.starts_with("required scanner fixed"));
        let f = run(
            fixed(
                ScanOutcome::Detected(vec!["A".into(), "B".into()]),
                false,
                false,
            ),
            "a.whl",
        );
        assert_eq!(
            (f.findings[0].kind, f.findings[0].severity),
            (FindingKind::Malware, Severity::Block)
        );
        assert_eq!(f.findings[0].detail, "fixed: A, B");
        let f = run(fixed(ScanOutcome::Clean, true, false), "a.whl");
        assert!(f.findings.is_empty());
        assert_eq!(f.scanners, vec!["fixed".to_string()]);
    }

    #[test]
    fn a_member_scan_reports_hits_and_errors_at_the_member_and_nothing_else() {
        use crate::{Format, Kind};
        let run = |s: std::sync::Arc<dyn MalwareScanner>| {
            let mut f = Findings::new(Format::Wheel, Kind::Zip);
            scan_member(&[s], b"bytes", "m.py", "a.whl!m.py", &mut f);
            f
        };
        let f = run(fixed(ScanOutcome::Detected(vec!["R".into()]), false, true));
        assert_eq!(f.findings[0].path, "a.whl!m.py");
        assert_eq!(f.findings[0].severity, Severity::Block);
        let f = run(fixed(ScanOutcome::Error("e".into()), false, true));
        assert_eq!(f.findings[0].severity, Severity::Warn);
        let f = run(fixed(ScanOutcome::Error("e".into()), true, true));
        assert_eq!(f.findings[0].severity, Severity::Block);
        // Not configured is said once, by the artifact scan; a clean member says nothing.
        assert!(
            run(fixed(ScanOutcome::NotConfigured("x".into()), true, true))
                .findings
                .is_empty()
        );
        assert!(run(fixed(ScanOutcome::Clean, true, true))
            .findings
            .is_empty());
        // A scanner that does not read members is not asked.
        assert!(
            run(fixed(ScanOutcome::Detected(vec!["R".into()]), true, false))
                .findings
                .is_empty()
        );
    }
}