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

//! The walk: open every archive level, account every unpacked byte, run the
//! member checks.
//!
//! Members are STREAMED. Only the first [`magic::HEAD`] bytes of a member are
//! held to classify it; a member that is itself an archive is read into memory
//! (bounded by `max_nested_bytes`) so it can be opened; everything else is
//! drained through the byte budget and dropped. Every decompressing reader is a
//! [`Capped`], so a bomb is refused after at most the cap plus one buffer.

use std::cell::Cell;
use std::io::{self, Cursor, Read, Write};
use std::rc::Rc;

use crate::magic::{self, Kind, HEAD};
use crate::{
    malware, ExecPolicy, FindingKind, Findings, Format, Inspector, Limits, Rule, Severity,
};

/// Which limit a [`Capped`] reader tripped.
#[derive(Debug, Clone, Copy)]
enum Hit {
    Ratio { compressed: u64, cap: u64 },
    Entry,
    Total,
}

#[derive(Default)]
struct Budget {
    expanded: Cell<u64>,
    hit: Cell<Option<Hit>>,
}

/// A decompressing reader under the ratio, entry and total limits.
struct Capped<R> {
    inner: R,
    read: u64,
    compressed: u64,
    ratio_cap: u64,
    entry_cap: u64,
    total_cap: u64,
    budget: Rc<Budget>,
}

impl<R: Read> Capped<R> {
    fn new(inner: R, compressed: u64, limits: &Limits, budget: Rc<Budget>) -> Self {
        Self {
            inner,
            read: 0,
            compressed,
            ratio_cap: limits
                .ratio_floor_bytes
                .max(compressed.saturating_mul(limits.max_ratio)),
            entry_cap: limits.max_entry_bytes,
            total_cap: limits.max_total_expanded,
            budget,
        }
    }
}

impl<R: Read> Read for Capped<R> {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        let n = self.inner.read(buf)?;
        self.read += n as u64;
        let total = self.budget.expanded.get() + n as u64;
        self.budget.expanded.set(total);
        let hit = if self.read > self.ratio_cap && self.ratio_cap < self.entry_cap {
            Some(Hit::Ratio {
                compressed: self.compressed,
                cap: self.ratio_cap,
            })
        } else if self.read > self.entry_cap {
            Some(Hit::Entry)
        } else if total > self.total_cap {
            Some(Hit::Total)
        } else {
            None
        };
        if let Some(h) = hit {
            self.budget.hit.set(Some(h));
            return Err(io::Error::other("holger-inspect: expansion limit"));
        }
        Ok(n)
    }
}

/// The xz decode's sink: the decoded bytes in memory, under the same ratio, entry and
/// total limits as a [`Capped`] reader, and under `max_nested_bytes` (xz is decoded
/// whole: lzma-rs has no streaming reader).
struct CappedSink {
    out: Vec<u8>,
    compressed: u64,
    ratio_cap: u64,
    entry_cap: u64,
    total_cap: u64,
    nested_cap: u64,
    nested_hit: bool,
    budget: Rc<Budget>,
}

impl CappedSink {
    fn new(compressed: u64, limits: &Limits, budget: Rc<Budget>) -> Self {
        Self {
            out: Vec::new(),
            compressed,
            ratio_cap: limits
                .ratio_floor_bytes
                .max(compressed.saturating_mul(limits.max_ratio)),
            entry_cap: limits.max_entry_bytes,
            total_cap: limits.max_total_expanded,
            nested_cap: limits.max_nested_bytes,
            nested_hit: false,
            budget,
        }
    }
}

impl Write for CappedSink {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        let read = self.out.len() as u64 + buf.len() as u64;
        let total = self.budget.expanded.get() + buf.len() as u64;
        self.budget.expanded.set(total);
        let hit = if read > self.ratio_cap && self.ratio_cap < self.entry_cap {
            Some(Hit::Ratio {
                compressed: self.compressed,
                cap: self.ratio_cap,
            })
        } else if read > self.entry_cap {
            Some(Hit::Entry)
        } else if total > self.total_cap {
            Some(Hit::Total)
        } else {
            None
        };
        if let Some(h) = hit {
            self.budget.hit.set(Some(h));
            return Err(io::Error::other("holger-inspect: expansion limit"));
        }
        if read > self.nested_cap {
            self.nested_hit = true;
            return Err(io::Error::other("holger-inspect: max_nested_bytes"));
        }
        self.out.extend_from_slice(buf);
        Ok(buf.len())
    }

    fn flush(&mut self) -> io::Result<()> {
        Ok(())
    }
}

/// The name of the one file inside a compressed `base`: `x.tgz` holds `x.tar`, `x.gz`
/// holds `x`; `pairs` are `(suffix, what replaces it)`, lower case.
fn inner_name(path: &str, pairs: &[(&str, &str)]) -> String {
    let base = path.rsplit('!').next().unwrap_or(path);
    let lower = base.to_ascii_lowercase();
    for (suffix, with) in pairs {
        if lower.ends_with(suffix) {
            return format!("{}{with}", &base[..base.len() - suffix.len()]);
        }
    }
    String::new()
}

const GZ_NAMES: &[(&str, &str)] = &[(".tgz", ".tar"), (".gz", "")];
const BZ2_NAMES: &[(&str, &str)] = &[(".tbz2", ".tar"), (".tbz", ".tar"), (".bz2", "")];
const ZST_NAMES: &[(&str, &str)] = &[(".tzst", ".tar"), (".zst", ""), (".zstd", "")];
const XZ_NAMES: &[(&str, &str)] = &[(".txz", ".tar"), (".xz", "")];

/// Pure: where an rpm's payload starts: after the 96-byte lead, the signature header
/// (padded to 8 bytes) and the main header. `None` when the headers do not read.
fn rpm_payload_offset(b: &[u8]) -> Option<usize> {
    let header_len = |at: usize| -> Option<usize> {
        if b.get(at..at + 4)? != [0x8e, 0xad, 0xe8, 0x01] {
            return None;
        }
        let nindex = u32::from_be_bytes(b.get(at + 8..at + 12)?.try_into().ok()?) as usize;
        let hsize = u32::from_be_bytes(b.get(at + 12..at + 16)?.try_into().ok()?) as usize;
        let len = 16usize
            .checked_add(nindex.checked_mul(16)?)?
            .checked_add(hsize)?;
        (at.checked_add(len)? <= b.len()).then_some(len)
    };
    let sig: usize = 96;
    let main = sig.checked_add(header_len(sig)?)?.checked_add(7)? & !7;
    let payload = main.checked_add(header_len(main)?)?;
    (payload <= b.len()).then_some(payload)
}

/// What a visitor is handed for every member it may read: the member's own name,
/// its path through the archives (`artifact!dir/file`), and its bytes.
pub type Visit<'v> = &'v mut dyn FnMut(&str, &str, &[u8]);

struct Walker<'a, 'v> {
    /// Every non-archive member (at most `max_scan_member_bytes`), when set.
    visit: Option<Visit<'v>>,
    /// Members too large to hand to the visitor.
    skipped: u64,
    limits: &'a Limits,
    policy: &'a ExecPolicy,
    format: Format,
    scanners: &'a [std::sync::Arc<dyn malware::MalwareScanner>],
    member_scan: bool,
    budget: Rc<Budget>,
    out: Findings,
    /// A limit that ends the walk (total expansion, member count) was hit.
    stopped: bool,
}

pub(crate) fn walk(insp: &Inspector, bytes: &[u8], format: Format, name: &str) -> Findings {
    walk_visiting(insp, bytes, format, name, None).0
}

/// The walk, handing every member that is not itself an archive to `visit`
/// (the artifact itself too, when it is no archive). Returns the findings and
/// how many members were too large to hand over.
pub(crate) fn walk_visiting(
    insp: &Inspector,
    bytes: &[u8],
    format: Format,
    name: &str,
    visit: Option<Visit<'_>>,
) -> (Findings, u64) {
    let detected = magic::detect(bytes);
    let mut w = Walker {
        visit,
        skipped: 0,
        limits: insp.limits(),
        policy: insp.policy(format),
        format,
        scanners: insp.scanners(),
        member_scan: insp.scanners().iter().any(|s| s.reads_members()),
        budget: Rc::new(Budget::default()),
        out: Findings::new(format, detected),
        stopped: false,
    };
    let label = if name.is_empty() {
        format!("<{}>", format.name())
    } else {
        name.to_string()
    };
    let expected = format
        .expected_kinds()
        .or_else(|| magic::kinds_for_name(name));
    if let Some(exp) = expected {
        if !exp.contains(&detected) {
            w.out.push(
                FindingKind::MimeMismatch,
                Severity::Block,
                &label,
                format!(
                    "declared {} ({}) but the bytes are {}",
                    format.name(),
                    names(exp),
                    detected.name()
                ),
            );
        }
    }
    if !detected.is_unpacked() {
        if let Some(v) = w.visit.as_mut() {
            if bytes.len() as u64 <= w.limits.max_scan_member_bytes {
                v(&label, &label, bytes);
            } else {
                w.skipped += 1;
            }
        }
    }
    w.open(bytes, &label, 0);
    w.out.expanded_bytes = w.budget.expanded.get();
    (w.out, w.skipped)
}

fn names(kinds: &[Kind]) -> String {
    kinds
        .iter()
        .map(|k| k.name())
        .collect::<Vec<_>>()
        .join(" or ")
}

impl Walker<'_, '_> {
    fn block(&mut self, kind: FindingKind, path: &str, detail: impl Into<String>) {
        self.out.push(kind, Severity::Block, path, detail);
    }

    fn rule(&mut self, rule: Rule, kind: FindingKind, path: &str, detail: String) {
        match rule {
            Rule::Allow => {}
            Rule::Warn => self.out.push(kind, Severity::Warn, path, detail),
            Rule::Block => self.out.push(kind, Severity::Block, path, detail),
        }
    }

    /// Open a container held in memory. `depth` is its own level (the artifact is 0).
    fn open(&mut self, bytes: &[u8], path: &str, depth: u32) {
        let kind = magic::detect(bytes);
        if kind.is_unpacked() {
            self.out.max_depth_seen = self.out.max_depth_seen.max(depth);
        }
        match kind {
            Kind::Zip => self.zip(bytes, path, depth),
            Kind::Tar => self.tar(Cursor::new(bytes), path, depth),
            Kind::Gzip => self.gzip(bytes, path, depth),
            Kind::Bzip2 => self.compressed(
                bzip2::read::MultiBzDecoder::new(bytes),
                bytes.len() as u64,
                path,
                depth,
                BZ2_NAMES,
            ),
            Kind::Zstd => match zstd::stream::read::Decoder::new(bytes) {
                Ok(d) => self.compressed(d, bytes.len() as u64, path, depth, ZST_NAMES),
                Err(e) => self.read_failed(path, depth, &e),
            },
            Kind::Xz => {
                if let Some(plain) = self.unxz(bytes, path, depth) {
                    self.compressed(
                        Cursor::new(plain),
                        bytes.len() as u64,
                        path,
                        depth,
                        XZ_NAMES,
                    );
                }
            }
            Kind::Ar => self.ar(bytes, path, depth),
            Kind::Rpm => self.rpm(bytes, path, depth),
            k if k.is_archive() => self.not_unpacked(k, path),
            _ => {}
        }
    }

    /// HOL-NCSC (NCSC P2): an archive the walk cannot open is BLOCKED, never passed.
    fn not_unpacked(&mut self, k: Kind, path: &str) {
        self.block(
            FindingKind::NotUnpacked,
            path,
            format!(
                "{} cannot be unpacked here, so its members cannot be inspected; it is blocked",
                k.name()
            ),
        );
    }

    /// The finding for a read that failed: a tripped limit, or a corrupt archive.
    fn read_failed(&mut self, path: &str, depth: u32, e: &dyn std::fmt::Display) {
        match self.budget.hit.take() {
            Some(Hit::Ratio { compressed, cap }) => self.block(
                FindingKind::ZipBomb,
                path,
                format!(
                    "expands past {cap} bytes from {compressed} compressed bytes (more than {}x)",
                    self.limits.max_ratio
                ),
            ),
            Some(Hit::Entry) => self.block(
                FindingKind::EntryTooLarge,
                path,
                format!(
                    "expands past max_entry_bytes ({})",
                    self.limits.max_entry_bytes
                ),
            ),
            Some(Hit::Total) => {
                self.block(
                    FindingKind::ExpansionLimit,
                    path,
                    format!(
                        "the artifact expands past max_total_expanded ({})",
                        self.limits.max_total_expanded
                    ),
                );
                self.stopped = true;
            }
            None => {
                // A corrupt top-level file that only LOOKS like an archive, in a
                // format that promised none, cannot hide members: warn.
                let sev = if depth == 0 && self.format == Format::Generic {
                    Severity::Warn
                } else {
                    Severity::Block
                };
                self.out.push(
                    FindingKind::CorruptArchive,
                    sev,
                    path,
                    format!("cannot be read: {e}"),
                );
            }
        }
    }

    fn count_entry(&mut self, path: &str) -> bool {
        self.out.entries += 1;
        if self.out.entries > self.limits.max_entries {
            self.block(
                FindingKind::TooManyEntries,
                path,
                format!(
                    "more than max_entries ({}) members",
                    self.limits.max_entries
                ),
            );
            self.stopped = true;
            return false;
        }
        true
    }

    fn check_name(&mut self, name: &str, mpath: &str) {
        let b = name.as_bytes();
        if name.contains('\0') {
            self.block(
                FindingKind::PathTraversal,
                mpath,
                "NUL byte in the member name",
            );
        }
        if name.starts_with('/')
            || name.starts_with('\\')
            || (b.len() >= 2 && b[1] == b':' && b[0].is_ascii_alphabetic())
        {
            self.block(
                FindingKind::AbsolutePath,
                mpath,
                format!("absolute member path {name:?}"),
            );
        }
        if name.split(['/', '\\']).any(|s| s == "..") {
            self.block(
                FindingKind::PathTraversal,
                mpath,
                format!("member path {name:?} climbs out with '..'"),
            );
        }
    }

    /// A symlink (relative to its own directory) or a hard link (relative to the
    /// archive root) must stay inside the archive.
    fn check_link(&mut self, name: &str, target: &str, hard: bool, mpath: &str) {
        let what = if hard { "hard link" } else { "symlink" };
        let tb = target.as_bytes();
        if target.starts_with('/') || target.starts_with('\\') || (tb.len() >= 2 && tb[1] == b':') {
            self.block(
                FindingKind::SymlinkEscape,
                mpath,
                format!("{what} to the absolute path {target:?}"),
            );
            return;
        }
        let mut stack: Vec<&str> = Vec::new();
        if !hard {
            let segs: Vec<&str> = name
                .split(['/', '\\'])
                .filter(|s| !s.is_empty() && *s != ".")
                .collect();
            stack.extend(segs.iter().take(segs.len().saturating_sub(1)));
        }
        for seg in target.split(['/', '\\']) {
            match seg {
                "" | "." => {}
                ".." => {
                    if stack.pop().is_none() {
                        self.block(
                            FindingKind::SymlinkEscape,
                            mpath,
                            format!("{what} to {target:?} leaves the archive root"),
                        );
                        return;
                    }
                }
                s => stack.push(s),
            }
        }
    }

    /// The checks on what a member IS: its name against its bytes, and the
    /// format's rule for binaries and scripts.
    ///
    /// `true`: the member may be a Windows script launcher, which only its size decides; the
    /// caller reads it ([`Self::launcher`]) and no finding is made here.
    fn check_kind(&mut self, head: &[u8], kind: Kind, name: &str, mpath: &str) -> bool {
        if let Some(exp) = magic::kinds_for_name(name) {
            if !exp.contains(&kind) {
                let disguised = kind.is_native() || kind == Kind::Script || kind.is_archive();
                let sev = if disguised {
                    Severity::Block
                } else {
                    Severity::Warn
                };
                self.out.push(
                    FindingKind::MimeMismatch,
                    sev,
                    mpath,
                    format!(
                        "named like {} but the bytes are {}",
                        names(exp),
                        kind.name()
                    ),
                );
            }
        }
        if kind.is_native() {
            let lib = magic::is_library(head, kind, name);
            let base = name.rsplit('/').next().unwrap_or(name);
            if !lib
                && kind == Kind::Pe
                && self.policy.windows_launchers.is_some()
                && crate::WINDOWS_LAUNCHER_NAMES.contains(&base.to_ascii_lowercase().as_str())
            {
                return true;
            }
            let (rule, what) = if lib {
                (self.policy.libraries, "native library")
            } else {
                (self.policy.executables, "native executable")
            };
            let detail = format!(
                "{what} ({}) in a {} — policy says {rule:?}",
                kind.name(),
                self.format.name()
            );
            self.rule(rule, FindingKind::Executable, mpath, detail);
        } else if kind == Kind::Script && !self.policy.in_script_dir(name) {
            let rule = self.policy.scripts;
            let detail = format!(
                "#! script in a {} outside its script directories — policy says {rule:?}",
                self.format.name()
            );
            self.rule(rule, FindingKind::Script, mpath, detail);
        }
        false
    }

    /// **A PE `*.exe` where the policy has a launcher rule** (lane HOL-310-CHECK): read whole up
    /// to [`crate::WINDOWS_LAUNCHER_MAX_BYTES`]. Within it, the `windows_launchers` rule, and the
    /// malware scanners read it as any member; past it, the `executables` rule (no launcher is
    /// that big).
    fn launcher(&mut self, head: Vec<u8>, r: &mut dyn Read, name: &str, mpath: &str, depth: u32) {
        let cap = crate::WINDOWS_LAUNCHER_MAX_BYTES;
        let Some(buf) = self.buffer(head, r, cap, mpath, depth) else {
            let rule = self.policy.executables;
            let detail = format!(
                "native executable (PE binary) in a {}, larger than a Windows script launcher ({cap} bytes) — policy says {rule:?}",
                self.format.name()
            );
            if self.member_scan || self.visit.is_some() {
                self.skipped += 1;
            }
            return self.rule(rule, FindingKind::Executable, mpath, detail);
        };
        let rule = self
            .policy
            .windows_launchers
            .unwrap_or(self.policy.executables);
        let detail = format!(
            "{} (PE .exe, {} bytes, at most {cap}) in a {}: a Windows install copies it next to a console script; nothing runs it on a build host or a Linux client; the malware scanners read it — policy says {rule:?}",
            crate::WINDOWS_LAUNCHER_FINDING,
            buf.len(),
            self.format.name()
        );
        self.rule(rule, FindingKind::Executable, mpath, detail);
        if self.member_scan {
            malware::scan_member(self.scanners, &buf, name, mpath, &mut self.out);
        }
        if let Some(v) = self.visit.as_mut() {
            v(name, mpath, &buf);
        }
    }

    /// One member, streamed from `r`. `depth` is the member's own level.
    fn member(&mut self, r: &mut dyn Read, name: &str, mpath: &str, depth: u32) {
        let mut head = Vec::with_capacity(HEAD);
        if let Err(e) = (&mut *r).take(HEAD as u64).read_to_end(&mut head) {
            return self.read_failed(mpath, depth, &e);
        }
        self.member_with_head(head, r, name, mpath, depth);
    }

    fn member_with_head(
        &mut self,
        head: Vec<u8>,
        r: &mut dyn Read,
        name: &str,
        mpath: &str,
        depth: u32,
    ) {
        if head.is_empty() {
            return;
        }
        let kind = magic::detect(&head);
        if self.check_kind(&head, kind, name, mpath) {
            return self.launcher(head, r, name, mpath, depth);
        }
        if kind.is_archive() && !kind.is_unpacked() {
            self.not_unpacked(kind, mpath);
            return self.drain(r, mpath, depth);
        }
        if kind.is_unpacked() {
            if depth > self.limits.max_depth {
                self.block(
                    FindingKind::NestingTooDeep,
                    mpath,
                    format!(
                        "archive nested {depth} levels deep; max_depth is {}",
                        self.limits.max_depth
                    ),
                );
                return self.drain(r, mpath, depth);
            }
            let Some(buf) = self.buffer(head, r, self.limits.max_nested_bytes, mpath, depth) else {
                self.block(
                    FindingKind::NestedTooLarge,
                    mpath,
                    format!(
                        "nested archive larger than max_nested_bytes ({})",
                        self.limits.max_nested_bytes
                    ),
                );
                return;
            };
            self.open(&buf, mpath, depth);
        } else if self.member_scan || self.visit.is_some() {
            match self.buffer(head, r, self.limits.max_scan_member_bytes, mpath, depth) {
                Some(buf) => {
                    if self.member_scan {
                        malware::scan_member(self.scanners, &buf, name, mpath, &mut self.out);
                    }
                    if let Some(v) = self.visit.as_mut() {
                        v(name, mpath, &buf);
                    }
                }
                None => self.skipped += 1,
            }
        } else {
            self.drain(r, mpath, depth);
        }
    }

    /// The whole member in memory, or `None` (the rest drained) past `cap`.
    fn buffer(
        &mut self,
        mut head: Vec<u8>,
        r: &mut dyn Read,
        cap: u64,
        mpath: &str,
        depth: u32,
    ) -> Option<Vec<u8>> {
        let room = (cap + 1).saturating_sub(head.len() as u64);
        if let Err(e) = (&mut *r).take(room).read_to_end(&mut head) {
            self.read_failed(mpath, depth, &e);
            return None;
        }
        if head.len() as u64 > cap {
            self.drain(r, mpath, depth);
            return None;
        }
        Some(head)
    }

    fn drain(&mut self, r: &mut dyn Read, mpath: &str, depth: u32) {
        if let Err(e) = io::copy(r, &mut io::sink()) {
            self.read_failed(mpath, depth, &e);
        }
    }

    fn zip(&mut self, bytes: &[u8], path: &str, depth: u32) {
        let mut ar = match zip::ZipArchive::new(Cursor::new(bytes)) {
            Ok(a) => a,
            Err(e) => return self.read_failed(path, depth, &e),
        };
        for i in 0..ar.len() {
            if self.stopped {
                return;
            }
            let (name, is_dir, encrypted, compressed, mode) = match ar.by_index_raw(i) {
                Ok(f) => (
                    String::from_utf8_lossy(f.name_raw()).into_owned(),
                    f.is_dir(),
                    f.encrypted(),
                    f.compressed_size(),
                    f.unix_mode(),
                ),
                Err(e) => {
                    self.read_failed(&format!("{path}!#{i}"), depth + 1, &e);
                    continue;
                }
            };
            let mpath = format!("{path}!{name}");
            if !self.count_entry(&mpath) {
                return;
            }
            self.check_name(&name, &mpath);
            if is_dir {
                continue;
            }
            if encrypted {
                self.block(
                    FindingKind::Encrypted,
                    &mpath,
                    "encrypted member; its contents cannot be inspected",
                );
                continue;
            }
            let f = match ar.by_index(i) {
                Ok(f) => f,
                Err(e) => {
                    self.read_failed(&mpath, depth + 1, &e);
                    continue;
                }
            };
            let mut r = Capped::new(f, compressed, self.limits, Rc::clone(&self.budget));
            if mode.is_some_and(|m| m & 0o170000 == 0o120000) {
                let mut target = Vec::new();
                match (&mut r).take(4096).read_to_end(&mut target) {
                    Ok(_) => {
                        self.check_link(&name, &String::from_utf8_lossy(&target), false, &mpath)
                    }
                    Err(e) => self.read_failed(&mpath, depth + 1, &e),
                }
                continue;
            }
            self.member(&mut r, &name, &mpath, depth + 1);
        }
    }

    fn tar<R: Read>(&mut self, reader: R, path: &str, depth: u32) {
        let mut ar = tar::Archive::new(reader);
        let entries = match ar.entries() {
            Ok(e) => e,
            Err(e) => return self.read_failed(path, depth, &e),
        };
        for ent in entries {
            if self.stopped {
                return;
            }
            let mut ent = match ent {
                Ok(e) => e,
                Err(e) => return self.read_failed(path, depth, &e),
            };
            let name = String::from_utf8_lossy(&ent.path_bytes()).into_owned();
            let mpath = format!("{path}!{name}");
            if !self.count_entry(&mpath) {
                return;
            }
            self.check_name(&name, &mpath);
            let et = ent.header().entry_type();
            if et.is_symlink() || et.is_hard_link() {
                let target = ent
                    .link_name_bytes()
                    .map(|t| String::from_utf8_lossy(&t).into_owned())
                    .unwrap_or_default();
                if matches!(self.format, Format::OciImage | Format::Deb | Format::Rpm)
                    && depth > 0
                    && et.is_symlink()
                {
                    // A layer is a root file system: its symlinks resolve inside the
                    // container's root, so they are recorded, not refused.
                    self.out.push(FindingKind::SymlinkEscape, Severity::Info, &mpath, format!("symlink to {target:?} inside an image layer (resolved in the container's root)"));
                    continue;
                }
                self.check_link(&name, &target, et.is_hard_link(), &mpath);
                continue;
            }
            if !et.is_file() {
                continue;
            }
            if ent.size() > self.limits.max_entry_bytes {
                self.block(
                    FindingKind::EntryTooLarge,
                    &mpath,
                    format!(
                        "{} bytes; max_entry_bytes is {}",
                        ent.size(),
                        self.limits.max_entry_bytes
                    ),
                );
                continue;
            }
            self.member(&mut ent, &name, &mpath, depth + 1);
            if self.budget.hit.get().is_some() {
                // The stream under this tar broke; the next header cannot be read.
                return;
            }
        }
    }

    /// A gzip around a tar is transparent: a tar.gz is ONE level. The one file
    /// in any other `.gz` is a member one level down.
    fn gzip(&mut self, bytes: &[u8], path: &str, depth: u32) {
        self.compressed(
            flate2::read::MultiGzDecoder::new(bytes),
            bytes.len() as u64,
            path,
            depth,
            GZ_NAMES,
        );
    }

    /// A compressed stream (gzip, bzip2, zstd, xz decoded): around a tar it is
    /// transparent, a `.tar.gz` is ONE level; any other file in it is a member one
    /// level down, so a chain of compression layers cannot nest without bound.
    fn compressed<R: Read>(
        &mut self,
        dec: R,
        compressed: u64,
        path: &str,
        depth: u32,
        names: &[(&str, &str)],
    ) {
        let mut r = Capped::new(dec, compressed, self.limits, Rc::clone(&self.budget));
        let mut head = Vec::with_capacity(HEAD);
        if let Err(e) = (&mut r).take(HEAD as u64).read_to_end(&mut head) {
            return self.read_failed(path, depth, &e);
        }
        if magic::detect(&head) == Kind::Tar {
            return self.tar(Cursor::new(head).chain(r), path, depth);
        }
        let inner = inner_name(path, names);
        let mpath = format!("{path}!{inner}");
        self.member_with_head(head, &mut r, &inner, &mpath, depth + 1);
    }

    /// **xz, decoded whole in memory** (lzma-rs, pure Rust, has no streaming reader): under
    /// the expansion limits, and under `max_nested_bytes`. The decoded bytes are accounted
    /// here, once. `None`: refused (the finding is written).
    fn unxz(&mut self, bytes: &[u8], path: &str, depth: u32) -> Option<Vec<u8>> {
        let mut sink = CappedSink::new(bytes.len() as u64, self.limits, Rc::clone(&self.budget));
        let r = lzma_rs::xz_decompress(&mut io::BufReader::new(bytes), &mut sink);
        if sink.nested_hit {
            self.block(
                FindingKind::NestedTooLarge,
                path,
                format!(
                    "the xz stream decodes past max_nested_bytes ({}); xz is decoded in memory",
                    self.limits.max_nested_bytes
                ),
            );
            return None;
        }
        match r {
            Ok(()) => {
                // Read back from memory: the expansion is already counted.
                let plain = std::mem::take(&mut sink.out);
                self.budget.expanded.set(
                    self.budget
                        .expanded
                        .get()
                        .saturating_sub(plain.len() as u64),
                );
                Some(plain)
            }
            Err(e) => {
                self.read_failed(path, depth, &e);
                None
            }
        }
    }

    /// **An `ar` archive** (a .deb, a static library), held in memory: every member,
    /// GNU (`//` name table) and BSD (`#1/<len>`) long names, the symbol table skipped.
    fn ar(&mut self, b: &[u8], path: &str, depth: u32) {
        let mut at = 8;
        let mut long_names: &[u8] = &[];
        while at < b.len() {
            if self.stopped {
                return;
            }
            if b[at..].iter().all(|c| *c == b'\n') {
                return;
            }
            let Some(h) = b.get(at..at + 60) else {
                return self.read_failed(path, depth, &"a truncated ar member header");
            };
            let size = std::str::from_utf8(&h[48..58])
                .ok()
                .and_then(|s| s.trim().parse::<usize>().ok());
            let (Some(size), b"`\n") = (size, &h[58..60]) else {
                return self.read_failed(path, depth, &"an ar member header that does not read");
            };
            let start = at + 60;
            let Some(mut data) = start.checked_add(size).and_then(|end| b.get(start..end)) else {
                return self.read_failed(path, depth, &"an ar member that runs past the archive");
            };
            at = start + size + (size & 1);
            let raw = std::str::from_utf8(&h[..16]).unwrap_or("").trim_end();
            let name = match raw {
                "/" | "/SYM64/" | "__.SYMDEF" | "__.SYMDEF SORTED" => continue,
                "//" => {
                    long_names = data;
                    continue;
                }
                n if n.len() > 1
                    && n.starts_with('/')
                    && n[1..].bytes().all(|c| c.is_ascii_digit()) =>
                {
                    let off: usize = n[1..].parse().unwrap_or(usize::MAX);
                    let rest = long_names.get(off..).unwrap_or_default();
                    let end = rest.iter().position(|c| *c == b'\n').unwrap_or(rest.len());
                    String::from_utf8_lossy(&rest[..end])
                        .trim_end_matches('/')
                        .to_string()
                }
                n if n.starts_with("#1/") => {
                    let len: usize = n[3..].trim().parse().unwrap_or(usize::MAX);
                    let Some(nm) = data.get(..len) else {
                        return self.read_failed(
                            path,
                            depth,
                            &"a BSD ar long name that runs past its member",
                        );
                    };
                    let name = String::from_utf8_lossy(nm)
                        .trim_end_matches('\0')
                        .to_string();
                    data = &data[len..];
                    name
                }
                n => n.trim_end_matches('/').to_string(),
            };
            let mpath = format!("{path}!{name}");
            if !self.count_entry(&mpath) {
                return;
            }
            self.check_name(&name, &mpath);
            self.member(&mut Cursor::new(data), &name, &mpath, depth + 1);
        }
    }

    /// **An rpm**: its payload (a cpio `newc` stream, gzip, xz, zstd or bzip2, or plain)
    /// is opened and every member inspected; the payload's compression is transparent, as
    /// a tar's gzip is. A payload in no format the walk opens is blocked.
    fn rpm(&mut self, b: &[u8], path: &str, depth: u32) {
        let Some(off) = rpm_payload_offset(b) else {
            return self.block(
                FindingKind::CorruptArchive,
                path,
                "the rpm's lead or headers do not read, so its payload cannot be inspected",
            );
        };
        let payload = &b[off..];
        let compressed = payload.len() as u64;
        match magic::detect(payload) {
            Kind::Gzip => self.cpio(flate2::read::MultiGzDecoder::new(payload), compressed, path, depth),
            Kind::Bzip2 => self.cpio(bzip2::read::MultiBzDecoder::new(payload), compressed, path, depth),
            Kind::Zstd => match zstd::stream::read::Decoder::new(payload) {
                Ok(d) => self.cpio(d, compressed, path, depth),
                Err(e) => self.read_failed(path, depth, &e),
            },
            Kind::Xz => {
                if let Some(plain) = self.unxz(payload, path, depth) {
                    self.cpio(Cursor::new(plain), compressed, path, depth);
                }
            }
            _ if payload.starts_with(b"07070") => self.cpio(payload, compressed, path, depth),
            k => self.block(
                FindingKind::NotUnpacked,
                path,
                format!("the rpm's payload is {}, which cannot be unpacked here, so its members cannot be inspected; it is blocked", k.name()),
            ),
        }
    }

    /// A cpio `newc` stream (`070701`/`070702`), streamed: every regular file is a member,
    /// every symlink checked, the rest skipped.
    fn cpio<R: Read>(&mut self, dec: R, compressed: u64, path: &str, depth: u32) {
        let mut r = Capped::new(dec, compressed, self.limits, Rc::clone(&self.budget));
        let mut read = 0u64;
        loop {
            if self.stopped {
                return;
            }
            let mut h = [0u8; 110];
            if let Err(e) = r.read_exact(&mut h) {
                return self.read_failed(path, depth, &e);
            }
            read += 110;
            if &h[..6] != b"070701" && &h[..6] != b"070702" {
                return self.read_failed(path, depth, &"a cpio member header that is not newc");
            }
            let field = |i: usize| {
                std::str::from_utf8(&h[6 + i * 8..14 + i * 8])
                    .ok()
                    .and_then(|s| u64::from_str_radix(s, 16).ok())
            };
            let (Some(mode), Some(size), Some(namesize)) = (field(1), field(6), field(11)) else {
                return self.read_failed(path, depth, &"a cpio member header that does not read");
            };
            if namesize == 0 || namesize > 4096 {
                return self.read_failed(path, depth, &"a cpio member name of no or absurd length");
            }
            let mut raw = vec![0u8; namesize as usize];
            if let Err(e) = r.read_exact(&mut raw) {
                return self.read_failed(path, depth, &e);
            }
            read += namesize;
            if skip(&mut r, (4 - read % 4) % 4, &mut read).is_err() {
                return self.read_failed(path, depth, &"a truncated cpio member name");
            }
            let name = String::from_utf8_lossy(raw.strip_suffix(&[0]).unwrap_or(&raw)).into_owned();
            if name == "TRAILER!!!" {
                return;
            }
            let mpath = format!("{path}!{name}");
            if !self.count_entry(&mpath) {
                return;
            }
            self.check_name(&name, &mpath);
            let ftype = mode & 0o170000;
            if ftype == 0o120000 {
                let mut target = Vec::new();
                if let Err(e) = (&mut r).take(size.min(4096)).read_to_end(&mut target) {
                    return self.read_failed(&mpath, depth + 1, &e);
                }
                read += target.len() as u64;
                if size > 4096 {
                    self.block(
                        FindingKind::CorruptArchive,
                        &mpath,
                        "a symlink target longer than 4096 bytes",
                    );
                    if skip(&mut r, size - 4096, &mut read).is_err() {
                        return self.read_failed(path, depth, &"a truncated cpio member");
                    }
                } else if matches!(self.format, Format::OciImage | Format::Deb | Format::Rpm)
                    && depth == 0
                {
                    // An rpm's payload is a root file system: its symlinks resolve in the
                    // installed system's root, so an absolute one is recorded, not refused.
                    let t = String::from_utf8_lossy(&target).into_owned();
                    self.out.push(FindingKind::SymlinkEscape, Severity::Info, &mpath, format!("symlink to {t:?} in a package payload (resolved in the installed root)"));
                } else {
                    self.check_link(&name, &String::from_utf8_lossy(&target), false, &mpath);
                }
            } else if ftype == 0o100000 {
                if size > self.limits.max_entry_bytes {
                    self.block(
                        FindingKind::EntryTooLarge,
                        &mpath,
                        format!(
                            "{size} bytes; max_entry_bytes is {}",
                            self.limits.max_entry_bytes
                        ),
                    );
                    if skip(&mut r, size, &mut read).is_err() {
                        return self.read_failed(path, depth, &"a truncated cpio member");
                    }
                } else {
                    let mut body = (&mut r).take(size);
                    self.member(&mut body, &name, &mpath, depth + 1);
                    if self.budget.hit.get().is_some() {
                        return;
                    }
                    // What the member left (a stopped read) is drained to the next header.
                    if io::copy(&mut body, &mut io::sink()).is_err() {
                        return;
                    }
                    read += size;
                }
            } else if skip(&mut r, size, &mut read).is_err() {
                return self.read_failed(path, depth, &"a truncated cpio member");
            }
            if skip(&mut r, (4 - read % 4) % 4, &mut read).is_err() {
                return self.read_failed(path, depth, &"a truncated cpio member");
            }
        }
    }
}

/// Read and drop `n` bytes of `r`, counting them into `read`.
fn skip(r: &mut dyn Read, n: u64, read: &mut u64) -> io::Result<()> {
    let got = io::copy(&mut r.take(n), &mut io::sink())?;
    *read += got;
    if got < n {
        return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "truncated"));
    }
    Ok(())
}