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/lib.rs

//! **Holger's file and archive inspector, and its malware hook.**
//!
//! Every artifact the intake chain ingests passes [`Inspector::inspect`]. The
//! inspector unpacks every container the INGEST walk meets (zip, tar, gzip, bzip2,
//! xz, zstd, ar and so a .deb, an rpm's cpio payload, a .conda, and any of them
//! nested) and runs the checks on what the archive contains, not on its name. An
//! archive it cannot open (7z) BLOCKS as [`FindingKind::NotUnpacked`] (lane HOL-NCSC):
//! members nobody inspected never pass. xz is decoded in memory (pure-Rust lzma-rs),
//! so it is held to `max_nested_bytes` as well:
//!
//! | Check | Finding | Default |
//! |---|---|---|
//! | Zip bomb: one stream expands past `max_ratio`× its compressed size | [`FindingKind::ZipBomb`] | 100×, once a stream is past 16 MiB |
//! | Total expansion of everything unpacked | [`FindingKind::ExpansionLimit`] | 4 GiB |
//! | One member's size | [`FindingKind::EntryTooLarge`] | 1 GiB |
//! | Member count, all levels | [`FindingKind::TooManyEntries`] | 100 000 |
//! | Nested archives | [`FindingKind::NestingTooDeep`] | 3 levels below the artifact |
//! | A nested archive held in memory to open it | [`FindingKind::NestedTooLarge`] | 256 MiB |
//! | `..` components, NUL bytes | [`FindingKind::PathTraversal`] | always |
//! | Absolute paths (`/x`, `\x`, `C:`) | [`FindingKind::AbsolutePath`] | always |
//! | Symlinks and hard links that leave the archive root | [`FindingKind::SymlinkEscape`] | always |
//! | Magic bytes against the declared format and the extension | [`FindingKind::MimeMismatch`] | always |
//! | ELF / PE / Mach-O binaries, `#!` scripts | [`FindingKind::Executable`], [`FindingKind::Script`] | per format, see [`ExecPolicy::default_for`] |
//!
//! The limits are [`Limits`], every one configurable from the RON config, each
//! with the default above. The numbers follow update-bundle's archive limits
//! (`MAX_EXPANSION_RATIO = 100`, `MAX_MEMBERS = 100_000`); the floor is lower
//! than the bundle's 64 MiB because a package, unlike a bundle, has no reason
//! to carry tens of megabytes of near-empty data.
//!
//! The malware hook is [`malware`]: a [`malware::MalwareScanner`] trait with a
//! ClamAV adapter (clamd socket or `clamscan`) and a YARA adapter. Both are
//! optional. An engine that is not installed reports "not configured", and a
//! scanner marked `required` that is not configured blocks.
//!
//! The crate is a leaf: it knows nothing of the server, the intake states or
//! the evidence store. It returns [`Findings`], and the caller turns their
//! [`Verdict`] into evidence.

pub mod binmeta;
pub mod image;
pub mod magic;
pub mod malware;
pub mod rpmdb;
mod walk;
pub use walk::Visit;
pub mod yara_lite;

use std::collections::BTreeMap;
use std::sync::Arc;

use serde::{Deserialize, Serialize};

pub use magic::Kind;

// ── the declared format ─────────────────────────────────────────────────────

/// The format an artifact says it is, from its name (and its repository).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Format {
    /// A Python wheel (`.whl`, a zip).
    Wheel,
    /// A Python source distribution (`.tar.gz` or `.zip` in a pip repository).
    Sdist,
    /// A Java archive (`.jar`, `.war`, `.ear`, `.aar`).
    Jar,
    /// A Rust `.crate` (a tar.gz).
    Crate,
    /// An npm tarball (`.tgz`).
    Npm,
    /// A NuGet package (`.nupkg`, a zip).
    Nuget,
    /// A Ruby gem (`.gem`, a tar).
    Gem,
    /// A Helm chart (`.tgz` in a helm repository).
    Helm,
    /// A Debian package (`.deb`, an ar).
    Deb,
    /// An RPM package.
    Rpm,
    /// A conda package (`.conda` zip or `.tar.bz2`).
    Conda,
    /// An OCI image layout (`.oci.tar`, a tar of `index.json` and
    /// `blobs/sha256/…`): an image a pull-through brought in whole. Its layers
    /// are a root file system, so a symlink inside a layer resolves in the
    /// container's own root (`etc/mtab -> /proc/mounts`), never on this host.
    OciImage,
    /// Anything else: only the extension promises anything.
    Generic,
}

impl Format {
    /// Every format, for the per-format policy table.
    pub const ALL: [Format; 13] = [
        Format::Wheel,
        Format::Sdist,
        Format::Jar,
        Format::Crate,
        Format::Npm,
        Format::Nuget,
        Format::Gem,
        Format::Helm,
        Format::Deb,
        Format::Rpm,
        Format::Conda,
        Format::OciImage,
        Format::Generic,
    ];

    pub fn name(&self) -> &'static str {
        match self {
            Format::Wheel => "wheel",
            Format::Sdist => "sdist",
            Format::Jar => "jar",
            Format::Crate => "crate",
            Format::Npm => "npm",
            Format::Nuget => "nuget",
            Format::Gem => "gem",
            Format::Helm => "helm",
            Format::Deb => "deb",
            Format::Rpm => "rpm",
            Format::Conda => "conda",
            Format::OciImage => "oci_image",
            Format::Generic => "generic",
        }
    }

    /// The format a file name and its repository's ecosystem declare.
    /// `ecosystem` is holger's lowercase format name (`pip`, `npm`, `helm`,
    /// `maven3`, `raw`, …); an empty string means "unknown".
    pub fn declared(name: &str, ecosystem: &str) -> Format {
        let n = name.to_ascii_lowercase();
        let eco = ecosystem.to_ascii_lowercase();
        let ends = |s: &str| n.ends_with(s);
        if ends(".oci.tar") {
            Format::OciImage
        } else if ends(".whl") {
            Format::Wheel
        } else if ends(".jar") || ends(".war") || ends(".ear") || ends(".aar") {
            Format::Jar
        } else if ends(".crate") {
            Format::Crate
        } else if ends(".nupkg") {
            Format::Nuget
        } else if ends(".gem") {
            Format::Gem
        } else if ends(".deb") {
            Format::Deb
        } else if ends(".rpm") {
            Format::Rpm
        } else if ends(".conda") || (eco == "conda" && ends(".tar.bz2")) {
            Format::Conda
        } else if ends(".tgz") && eco == "helm" {
            Format::Helm
        } else if ends(".tgz") && eco == "npm" {
            Format::Npm
        } else if eco == "pip" && (ends(".tar.gz") || ends(".zip")) {
            Format::Sdist
        } else {
            Format::Generic
        }
    }

    /// The container kinds this format must be, or `None` when the format
    /// promises nothing beyond its extension.
    pub fn expected_kinds(&self) -> Option<&'static [Kind]> {
        Some(match self {
            Format::Wheel | Format::Jar | Format::Nuget => &[Kind::Zip],
            Format::Sdist => &[Kind::Gzip, Kind::Zip],
            Format::Crate | Format::Npm | Format::Helm => &[Kind::Gzip],
            Format::Gem => &[Kind::Tar],
            Format::Deb => &[Kind::Ar],
            Format::Rpm => &[Kind::Rpm],
            Format::Conda => &[Kind::Zip, Kind::Bzip2],
            Format::OciImage => &[Kind::Tar],
            Format::Generic => return None,
        })
    }
}

// ── the limits ──────────────────────────────────────────────────────────────

/// Every expansion limit. Each field has a safe default and can be set alone
/// in the RON config: `limits: (max_depth: 2)` keeps the other defaults.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Limits {
    /// A stream may expand to at most this many times its compressed size…
    pub max_ratio: u64,
    /// …once it is past this many bytes. Below it the ratio is not enforced,
    /// since a small, highly compressible file legitimately has a huge ratio.
    pub ratio_floor_bytes: u64,
    /// Bytes unpacked in total, every level together.
    pub max_total_expanded: u64,
    /// Bytes in one member.
    pub max_entry_bytes: u64,
    /// Members in total, every level together.
    pub max_entries: u64,
    /// How many archives may nest below the artifact itself. A gzip layer
    /// around a tar is one level, not two.
    pub max_depth: u32,
    /// A nested archive is read into memory to open it; at most this many bytes.
    pub max_nested_bytes: u64,
    /// A member is handed to the scanners that read members (YARA) when it is at
    /// most this big.
    pub max_scan_member_bytes: u64,
}

impl Limits {
    pub const DEFAULT_MAX_RATIO: u64 = 100;
    pub const DEFAULT_RATIO_FLOOR_BYTES: u64 = 16 << 20;
    pub const DEFAULT_MAX_TOTAL_EXPANDED: u64 = 4 << 30;
    pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 1 << 30;
    pub const DEFAULT_MAX_ENTRIES: u64 = 100_000;
    pub const DEFAULT_MAX_DEPTH: u32 = 3;
    pub const DEFAULT_MAX_NESTED_BYTES: u64 = 256 << 20;
    pub const DEFAULT_MAX_SCAN_MEMBER_BYTES: u64 = 16 << 20;
}

impl Default for Limits {
    fn default() -> Self {
        Self {
            max_ratio: Self::DEFAULT_MAX_RATIO,
            ratio_floor_bytes: Self::DEFAULT_RATIO_FLOOR_BYTES,
            max_total_expanded: Self::DEFAULT_MAX_TOTAL_EXPANDED,
            max_entry_bytes: Self::DEFAULT_MAX_ENTRY_BYTES,
            max_entries: Self::DEFAULT_MAX_ENTRIES,
            max_depth: Self::DEFAULT_MAX_DEPTH,
            max_nested_bytes: Self::DEFAULT_MAX_NESTED_BYTES,
            max_scan_member_bytes: Self::DEFAULT_MAX_SCAN_MEMBER_BYTES,
        }
    }
}

// ── the per-format policy ───────────────────────────────────────────────────

/// What one rule does when it matches.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Rule {
    Allow,
    Warn,
    Block,
}

/// Where a format allows native binaries and scripts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecPolicy {
    /// An ELF / PE / Mach-O executable (anything native that is not a library).
    pub executables: Rule,
    /// A native shared library with a library's name (`.so`, `.pyd`, `.dll`, …).
    pub libraries: Rule,
    /// A `#!` script outside `script_dirs`.
    pub scripts: Rule,
    /// Directories where scripts are always allowed. One `*` per path segment
    /// matches any text in that segment: `*.data/scripts`.
    #[serde(default)]
    pub script_dirs: Vec<String>,
    /// **A Windows script launcher** (lane HOL-310-CHECK): a PE executable named `*.exe` of at
    /// most [`WINDOWS_LAUNCHER_MAX_BYTES`]. Python's packaging tools carry these small stubs
    /// (setuptools' `cli-64.exe`/`gui-64.exe`, distlib's `t64.exe`/`w64.exe` vendored in pip) and
    /// copy one next to each console script on a Windows install; nothing runs them on a build
    /// host or a Linux client. `None`: such a file follows `executables`, as every other native
    /// executable does. An operator's own policy that leaves this out gets `None`, the strict rule.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub windows_launchers: Option<Rule>,
}

/// The words every Windows-launcher finding carries (SEC-REVIEW 2026-11-01 W1): the server's
/// auto-approve looks for them, since such a WARN excuses what `executables` would block.
pub const WINDOWS_LAUNCHER_FINDING: &str = "Windows script launcher";

/// The launcher names Python's packaging tools ship (SEC-REVIEW 2026-11-01 W1): setuptools'
/// `{cli,gui}{,-32,-64,-arm64}.exe` and distlib's `{t,w}{32,64,64-arm}.exe` (vendored in pip).
/// Only these are excused; any other `*.exe` follows `executables`.
pub const WINDOWS_LAUNCHER_NAMES: &[&str] = &[
    "cli.exe",
    "cli-32.exe",
    "cli-64.exe",
    "cli-arm64.exe",
    "gui.exe",
    "gui-32.exe",
    "gui-64.exe",
    "gui-arm64.exe",
    "t32.exe",
    "t64.exe",
    "t64-arm.exe",
    "w32.exe",
    "w64.exe",
    "w64-arm.exe",
];

/// The largest PE `.exe` [`ExecPolicy::windows_launchers`] covers. The launchers Python's
/// tools ship are 11 KiB (setuptools `cli-32.exe`) to 183 KiB (distlib `t64-arm.exe`, pip 26.2).
pub const WINDOWS_LAUNCHER_MAX_BYTES: u64 = 256 * 1024;

impl ExecPolicy {
    fn new(executables: Rule, libraries: Rule, scripts: Rule, dirs: &[&str]) -> Self {
        Self {
            executables,
            libraries,
            scripts,
            script_dirs: dirs.iter().map(|s| s.to_string()).collect(),
            windows_launchers: None,
        }
    }

    fn with_windows_launchers(mut self, rule: Rule) -> Self {
        self.windows_launchers = Some(rule);
        self
    }

    /// The default for each format.
    ///
    /// * wheel: executables block; extension modules allowed; scripts warn
    ///   outside `*.data/scripts` (many modules start with a `#!` line).
    /// * sdist: source only, so executables AND libraries block.
    /// * wheel and sdist: a Windows script launcher (a PE `*.exe` of at most 256 KiB) warns
    ///   (`windows_launchers`, lane HOL-310-CHECK): setuptools and pip carry them, and blocking
    ///   them blocked every sdist build and pip itself. The malware scanners still read them.
    /// * jar: executables block; JNI libraries allowed; scripts warn.
    /// * crate: executables block; libraries warn; scripts allowed (build scripts).
    /// * npm, gem: executables block; prebuilt addons allowed; scripts allowed.
    /// * nuget: executables warn (tool packages ship them); libraries allowed.
    /// * helm: a chart is text, so binaries block and scripts warn.
    /// * deb, rpm, conda, generic: binary packages; everything allowed.
    pub fn default_for(format: Format) -> Self {
        use Rule::*;
        match format {
            Format::Wheel => {
                Self::new(Block, Allow, Warn, &["*.data/scripts"]).with_windows_launchers(Warn)
            }
            Format::Sdist => Self::new(Block, Block, Allow, &[]).with_windows_launchers(Warn),
            Format::Jar => Self::new(Block, Allow, Warn, &[]),
            Format::Crate => Self::new(Block, Warn, Allow, &[]),
            Format::Npm | Format::Gem => Self::new(Block, Allow, Allow, &[]),
            Format::Nuget => Self::new(Warn, Allow, Allow, &[]),
            Format::Helm => Self::new(Block, Block, Warn, &[]),
            Format::Deb | Format::Rpm | Format::Conda | Format::OciImage | Format::Generic => {
                Self::new(Allow, Allow, Allow, &[])
            }
        }
    }

    /// Is `path` inside one of the script directories?
    pub fn in_script_dir(&self, path: &str) -> bool {
        let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
        self.script_dirs.iter().any(|dir| {
            let pat: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect();
            !pat.is_empty()
                && segs.len() > pat.len()
                && segs
                    .windows(pat.len())
                    .take(segs.len() - pat.len())
                    .any(|w| w.iter().zip(&pat).all(|(s, p)| glob_segment(p, s)))
        })
    }
}

/// One path segment against a pattern with at most one `*`.
fn glob_segment(pat: &str, s: &str) -> bool {
    match pat.split_once('*') {
        None => pat == s,
        Some((pre, post)) => {
            s.len() >= pre.len() + post.len() && s.starts_with(pre) && s.ends_with(post)
        }
    }
}

// ── the configuration ───────────────────────────────────────────────────────

/// The `inspect:` block: the limits, any per-format policy overrides, and the
/// optional malware scanners.
///
/// ```ron
/// inspect: (
///     limits: (max_depth: 2, max_total_expanded: 1073741824),
///     policies: { wheel: (executables: block, libraries: block, scripts: warn) },
///     // The sandboxed clamd (Holger's server refuses any other in a real build: holger-sandbox-15).
///     clamav: Some((socket: Some("/data/holger/sandbox/clamd/clamd.sock"), required: true)),
///     yara: Some((rules: "/etc/holger/yara", required: false)),
/// )
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct InspectConfig {
    pub limits: Limits,
    /// Replaces [`ExecPolicy::default_for`] for the formats it names.
    pub policies: BTreeMap<Format, ExecPolicy>,
    pub clamav: Option<malware::ClamAvConfig>,
    pub yara: Option<malware::YaraConfig>,
}

// ── the findings ────────────────────────────────────────────────────────────

/// How bad one finding is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
    Info,
    Warn,
    Block,
}

/// What a finding is about.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingKind {
    ZipBomb,
    ExpansionLimit,
    EntryTooLarge,
    TooManyEntries,
    NestingTooDeep,
    NestedTooLarge,
    PathTraversal,
    AbsolutePath,
    SymlinkEscape,
    MimeMismatch,
    Executable,
    Script,
    CorruptArchive,
    Encrypted,
    NotUnpacked,
    Malware,
    ScannerNotConfigured,
    ScannerError,
}

impl FindingKind {
    pub fn name(&self) -> &'static str {
        match self {
            FindingKind::ZipBomb => "zip-bomb",
            FindingKind::ExpansionLimit => "expansion-limit",
            FindingKind::EntryTooLarge => "entry-too-large",
            FindingKind::TooManyEntries => "too-many-entries",
            FindingKind::NestingTooDeep => "nesting-too-deep",
            FindingKind::NestedTooLarge => "nested-too-large",
            FindingKind::PathTraversal => "path-traversal",
            FindingKind::AbsolutePath => "absolute-path",
            FindingKind::SymlinkEscape => "symlink-escape",
            FindingKind::MimeMismatch => "mime-mismatch",
            FindingKind::Executable => "executable",
            FindingKind::Script => "script",
            FindingKind::CorruptArchive => "corrupt-archive",
            FindingKind::Encrypted => "encrypted",
            FindingKind::NotUnpacked => "not-unpacked",
            FindingKind::Malware => "malware",
            FindingKind::ScannerNotConfigured => "scanner-not-configured",
            FindingKind::ScannerError => "scanner-error",
        }
    }
}

/// One finding: what, how bad, where (`artifact!member!inner`), and why.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
    pub kind: FindingKind,
    pub severity: Severity,
    pub path: String,
    pub detail: String,
}

impl std::fmt::Display for Finding {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{} {}: {}", self.kind.name(), self.path, self.detail)
    }
}

/// The conclusion, in the intake chain's three words.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
    Pass,
    Warn,
    Fail,
}

/// Everything one inspection found, and what it walked.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Findings {
    pub format: Format,
    /// What the artifact's own bytes are.
    pub detected: Kind,
    pub findings: Vec<Finding>,
    /// Members seen, every level together.
    pub entries: u64,
    /// Bytes unpacked, every level together.
    pub expanded_bytes: u64,
    /// The deepest archive level opened.
    pub max_depth_seen: u32,
    /// The scanners that ran, by name.
    pub scanners: Vec<String>,
}

impl Findings {
    fn new(format: Format, detected: Kind) -> Self {
        Self {
            format,
            detected,
            findings: Vec::new(),
            entries: 0,
            expanded_bytes: 0,
            max_depth_seen: 0,
            scanners: Vec::new(),
        }
    }

    pub(crate) fn push(
        &mut self,
        kind: FindingKind,
        severity: Severity,
        path: &str,
        detail: impl Into<String>,
    ) {
        self.findings.push(Finding {
            kind,
            severity,
            path: path.to_string(),
            detail: detail.into(),
        });
    }

    /// Fail on any blocking finding, Warn on any warning, else Pass.
    pub fn verdict(&self) -> Verdict {
        match self.findings.iter().map(|f| f.severity).max() {
            Some(Severity::Block) => Verdict::Fail,
            Some(Severity::Warn) => Verdict::Warn,
            _ => Verdict::Pass,
        }
    }

    /// The blocking findings.
    pub fn blocking(&self) -> impl Iterator<Item = &Finding> {
        self.findings
            .iter()
            .filter(|f| f.severity == Severity::Block)
    }

    /// Is there a finding of this kind at this severity or worse?
    pub fn has(&self, kind: FindingKind, at_least: Severity) -> bool {
        self.findings
            .iter()
            .any(|f| f.kind == kind && f.severity >= at_least)
    }

    /// One line for an audit record or an evidence reference.
    pub fn summary(&self) -> String {
        let count = |s| self.findings.iter().filter(|f| f.severity == s).count();
        format!(
            "inspect {}: {:?}, {} entries, {} bytes unpacked, depth {}, {} block / {} warn / {} info",
            self.format.name(),
            self.verdict(),
            self.entries,
            self.expanded_bytes,
            self.max_depth_seen,
            count(Severity::Block),
            count(Severity::Warn),
            count(Severity::Info),
        )
    }
}

// ── the inspector ───────────────────────────────────────────────────────────

/// The inspector of one server: limits, the per-format policies, and the
/// malware scanners. Built once from [`InspectConfig`] and shared.
#[derive(Clone)]
pub struct Inspector {
    limits: Limits,
    policies: BTreeMap<Format, ExecPolicy>,
    scanners: Vec<Arc<dyn malware::MalwareScanner>>,
}

impl Default for Inspector {
    fn default() -> Self {
        Self::new(Limits::default())
    }
}

impl std::fmt::Debug for Inspector {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Inspector")
            .field("limits", &self.limits)
            .field(
                "scanners",
                &self
                    .scanners
                    .iter()
                    .map(|s| s.name().to_string())
                    .collect::<Vec<_>>(),
            )
            .finish()
    }
}

impl Inspector {
    /// An inspector with these limits, the default policies and no scanners.
    pub fn new(limits: Limits) -> Self {
        Self {
            limits,
            policies: Format::ALL
                .into_iter()
                .map(|f| (f, ExecPolicy::default_for(f)))
                .collect(),
            scanners: Vec::new(),
        }
    }

    /// The inspector the config declares, scanners included.
    pub fn from_config(cfg: &InspectConfig) -> Self {
        let mut me = Self::new(cfg.limits.clone());
        for (f, p) in &cfg.policies {
            me.policies.insert(*f, p.clone());
        }
        if let Some(c) = &cfg.clamav {
            me = me.with_scanner(Arc::new(malware::ClamAv::from_config(c)));
        }
        if let Some(y) = &cfg.yara {
            me = me.with_scanner(Arc::new(malware::Yara::from_config(y)));
        }
        me
    }

    /// Replace one format's policy.
    pub fn with_policy(mut self, format: Format, policy: ExecPolicy) -> Self {
        self.policies.insert(format, policy);
        self
    }

    /// Add a malware scanner.
    pub fn with_scanner(mut self, s: Arc<dyn malware::MalwareScanner>) -> Self {
        self.scanners.push(s);
        self
    }

    pub fn limits(&self) -> &Limits {
        &self.limits
    }

    pub fn policy(&self, format: Format) -> &ExecPolicy {
        self.policies
            .get(&format)
            .expect("every format has a policy")
    }

    pub fn scanners(&self) -> &[Arc<dyn malware::MalwareScanner>] {
        &self.scanners
    }

    /// Inspect one artifact. `name` is its file name; `format` what it declares.
    pub fn inspect(&self, bytes: &[u8], format: Format, name: &str) -> Findings {
        let mut out = walk::walk(self, bytes, format, name);
        malware::scan_artifact(&self.scanners, bytes, name, &mut out);
        out
    }

    /// **Walk one artifact and hand every member to `visit`**: the same walk as
    /// [`Inspector::inspect`] (the same limits; archives opened level by level,
    /// gzip, tar and zip, image layers included), with every member that is not
    /// itself an archive passed as `(name, path, bytes)`, the artifact itself
    /// too when it is no archive. The malware scanners do not run. Returns the
    /// walk's findings and how many members were larger than
    /// `max_scan_member_bytes` and so not handed over. The secrets scanner reads
    /// packages through it.
    pub fn visit(
        &self,
        bytes: &[u8],
        format: Format,
        name: &str,
        visit: Visit<'_>,
    ) -> (Findings, u64) {
        let me = Self {
            scanners: Vec::new(),
            ..self.clone()
        };
        walk::walk_visiting(&me, bytes, format, name, Some(visit))
    }
}

/// Inspect with the default limits and policies and no scanners.
pub fn inspect(bytes: &[u8], format: Format) -> Findings {
    Inspector::default().inspect(bytes, format, "")
}

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

    #[test]
    fn the_declared_format_comes_from_the_name_and_the_ecosystem() {
        assert_eq!(
            Format::declared("demo-1.0-py3-none-any.whl", "raw"),
            Format::Wheel
        );
        assert_eq!(Format::declared("demo-1.0.tar.gz", "pip"), Format::Sdist);
        assert_eq!(Format::declared("demo-1.0.tar.gz", "raw"), Format::Generic);
        assert_eq!(Format::declared("chart-1.0.tgz", "helm"), Format::Helm);
        assert_eq!(Format::declared("serde-1.0.crate", ""), Format::Crate);
    }

    #[test]
    fn script_dirs_match_one_segment_wildcard() {
        let p = ExecPolicy::default_for(Format::Wheel);
        assert!(p.in_script_dir("demo-1.0.data/scripts/run"));
        assert!(!p.in_script_dir("demo/scripts/run"));
        assert!(
            !p.in_script_dir("demo-1.0.data/scripts"),
            "the directory itself is not a member in it"
        );
    }

    #[test]
    fn every_limit_reads_alone_from_ron() {
        let l: Limits = ron::from_str("(max_depth: 1)").unwrap();
        assert_eq!(l.max_depth, 1);
        assert_eq!(l.max_ratio, Limits::DEFAULT_MAX_RATIO);
        let c: InspectConfig = ron::from_str(
            "(policies: { wheel: (executables: block, libraries: block, scripts: warn) })",
        )
        .unwrap();
        assert_eq!(c.policies[&Format::Wheel].libraries, Rule::Block);
        assert!(
            ron::from_str::<Limits>("(max_dept: 1)").is_err(),
            "a misspelt limit is refused"
        );
    }

    #[test]
    fn a_file_name_and_its_ecosystem_declare_the_format() {
        let cases = [
            ("img.OCI.tar", "", Format::OciImage),
            ("demo-1.0-py3-none-any.whl", "raw", Format::Wheel),
            ("app.jar", "maven3", Format::Jar),
            ("app.WAR", "", Format::Jar),
            ("app.ear", "", Format::Jar),
            ("lib.aar", "", Format::Jar),
            ("serde-1.0.crate", "rust", Format::Crate),
            ("pkg.1.0.nupkg", "nuget", Format::Nuget),
            ("rack-3.0.gem", "gem", Format::Gem),
            ("tool_1.0_amd64.deb", "deb", Format::Deb),
            ("tool-1.0.x86_64.rpm", "rpm", Format::Rpm),
            ("numpy-2.0.conda", "", Format::Conda),
            ("numpy-1.0.tar.bz2", "CONDA", Format::Conda),
            ("numpy-1.0.tar.bz2", "raw", Format::Generic),
            ("chart-1.0.tgz", "helm", Format::Helm),
            ("left-pad-1.0.tgz", "npm", Format::Npm),
            ("left-pad-1.0.tgz", "raw", Format::Generic),
            ("demo-1.0.tar.gz", "pip", Format::Sdist),
            ("demo-1.0.zip", "pip", Format::Sdist),
            ("demo-1.0.tar.gz", "raw", Format::Generic),
            ("README", "", Format::Generic),
        ];
        for (name, eco, want) in cases {
            assert_eq!(Format::declared(name, eco), want, "{name} in {eco:?}");
        }
        // Every format has a name, a policy and, but for the generic and image ones, a container.
        let insp = Inspector::default();
        for f in Format::ALL {
            assert!(!f.name().is_empty());
            let _ = insp.policy(f);
        }
        assert!(Format::Generic.expected_kinds().is_none());
    }

    #[test]
    fn the_config_builds_its_scanners_and_policies() {
        let cfg: InspectConfig = ron::from_str(
            r#"(
                policies: { wheel: (executables: warn, libraries: allow, scripts: allow) },
                clamav: Some((socket: Some("/nonexistent/holger/clamd.ctl"), required: true)),
                yara: Some((rules: None)),
            )"#,
        )
        .unwrap();
        let insp = Inspector::from_config(&cfg);
        let names: Vec<&str> = insp.scanners().iter().map(|s| s.name()).collect();
        assert_eq!(names, vec!["clamav", "yara"]);
        assert_eq!(insp.policy(Format::Wheel).executables, Rule::Warn);
        assert_eq!(
            insp.policy(Format::Jar),
            &ExecPolicy::default_for(Format::Jar)
        );
        assert_eq!(insp.limits(), &Limits::default());
        let dbg = format!("{insp:?}");
        assert!(dbg.contains("clamav") && dbg.contains("yara"), "{dbg}");

        // The required clamd is absent: every artifact blocks, and the summary says so.
        let f = insp.inspect(b"plain bytes", Format::Generic, "x.bin");
        assert_eq!(f.verdict(), Verdict::Fail);
        assert_eq!(f.blocking().count(), 1);
        assert!(f.has(FindingKind::ScannerNotConfigured, Severity::Block));
        assert!(f.has(FindingKind::ScannerNotConfigured, Severity::Info));
        assert!(!f.has(FindingKind::Malware, Severity::Info));
        let s = f.summary();
        assert!(
            s.starts_with("inspect generic: Fail") && s.contains("1 block / 0 warn / 1 info"),
            "{s}"
        );
        assert_eq!(
            FindingKind::ScannerNotConfigured.name(),
            "scanner-not-configured"
        );

        // with_policy replaces one format's policy and no other.
        let strict = ExecPolicy {
            executables: Rule::Block,
            libraries: Rule::Block,
            scripts: Rule::Block,
            script_dirs: vec![],
            windows_launchers: None,
        };
        let insp = Inspector::default().with_policy(Format::Npm, strict.clone());
        assert_eq!(insp.policy(Format::Npm), &strict);
        assert_eq!(
            insp.policy(Format::Wheel),
            &ExecPolicy::default_for(Format::Wheel)
        );
    }
}