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

nordisk/holger

server/repository/zip-entry/src/lib.rs

//! **The bounded ZIP member reader holger did not have.**
//!
//! Five backends already open a tarball to synthesize their index —
//! `znippy-deb` (`control.tar` out of an `ar`), `znippy-helm`
//! (`Chart.yaml` out of a `.tgz`), `znippy-gem`, `znippy-npm`,
//! `znippy-python`. Two could not, because their package format is a **zip**
//! and nothing in the tree could open one: `znippy-nuget` (`.nuspec` inside a
//! `.nupkg`) and `znippy-composer` (`composer.json` inside a dist zip). Both
//! therefore shipped as passthroughs that serve half a protocol. This crate is
//! the missing half, written ONCE for both.
//!
//! ## What it is not
//!
//! It is not a zip *library*. There is no writer (`ldeflate::zip_write` is
//! holger's writer), no directory extraction, no permissions, no symlinks. The
//! whole surface is "give me the bytes of ONE member of this archive, or tell
//! me you refuse" — because that is the only thing an index synthesiser ever
//! needs, and every capability beyond it is attack surface on **untrusted
//! airgap input**.
//!
//! ## The three traps, and where each is closed
//!
//! A zip carries the two traps a tar carries, plus a third that is specific to
//! having a directory at the END of the file:
//!
//! 1. **Decompression bomb.** A few KiB of DEFLATE inflates toward 1032× its
//!    input. Closed by [`read_member_capped`]: the inflate is *streamed*
//!    through [`std::io::Read::take`], so the process allocates at most the
//!    cap plus one byte before the member is refused. Never a
//!    `Vec::with_capacity(declared)`.
//! 2. **Path traversal.** `../../etc/passwd`, `/etc/passwd`, `C:\…`. Closed by
//!    [`is_safe_member_path`], applied to every name the central directory
//!    yields — in [`members`] itself, so a caller cannot forget.
//! 3. **A central directory that lies.** This is the one a tar reader has no
//!    analogue for. The 46-byte central header declares a compressed size, an
//!    uncompressed size and a local-header offset, and *all three are
//!    attacker-controlled*: a 40-byte file can claim a 16 EiB member. So this
//!    reader treats every declared size as a HINT and never as an allocation:
//!    the DEFLATE stream is read until it self-terminates or the cap trips, a
//!    STORED member is clamped to the bytes that actually exist, and the CRC-32
//!    the header declares is verified against what was actually produced. A
//!    lying header therefore ends as an `Err`, not as a 16 EiB allocation and
//!    not as silently wrong metadata.
//!
//! ## Zip64
//!
//! Handled, because a `.nupkg` with more than 65 535 members is legal and a
//! reader that mis-parses it would read a wrong offset rather than refuse. The
//! Zip64 end-of-central-directory record and the `0x0001` extra field are both
//! honoured.
//!
//! ## Cost
//!
//! [`members`] is one backwards scan of at most 64 KiB + 22 B for the EOCD
//! signature, then a forward walk of the central directory: O(entries), one
//! `String` per entry, no member bytes touched. [`read_member_capped`] then
//! touches exactly one member. Callers that need a member from every package in
//! a repository memoize the RESULT (see `znippy-nuget`'s registration cache and
//! `znippy-composer`'s p2 cache) — this crate deliberately holds no cache of
//! its own, because it does not know what the caller keyed on.

use std::io::Read;

use anyhow::{anyhow, bail, Result};

/// Hard ceiling on what ONE member may inflate to, for callers that do not
/// name their own.
///
/// The members this crate exists to read — a `.nuspec`, a `composer.json` —
/// are kilobytes. 32 MiB is four orders of magnitude of headroom while still
/// bounding a crafted member to something a server can absorb, and it is the
/// same number `znippy-helm` already uses for a chart's `Chart.yaml`
/// (`MAX_CHART_DECOMPRESSED_BYTES`) so the two ecosystems do not disagree
/// about what "too big for a metadata file" means.
pub const MAX_MEMBER_DECOMPRESSED_BYTES: usize = 32 * 1024 * 1024;

/// Hard ceiling on how many members one archive may declare.
///
/// The central directory is walked before any member is read, so an archive
/// declaring four billion entries would otherwise cost four billion `String`
/// allocations *before* the first refusal. A real `.nupkg` holds tens of
/// members; a fat one holds thousands.
pub const MAX_MEMBERS: usize = 200_000;

/// The largest end-of-central-directory comment the spec allows, plus the
/// 22-byte record itself: the whole window the EOCD signature can hide in.
const EOCD_SCAN_WINDOW: usize = 0xFFFF + 22;

const SIG_EOCD: u32 = 0x0605_4B50;
const SIG_ZIP64_EOCD_LOCATOR: u32 = 0x0706_4B50;
const SIG_ZIP64_EOCD: u32 = 0x0606_4B50;
const SIG_CENTRAL: u32 = 0x0201_4B50;
const SIG_LOCAL: u32 = 0x0403_4B50;

const METHOD_STORE: u16 = 0;
const METHOD_DEFLATE: u16 = 8;

/// One member as the central directory describes it.
///
/// Every numeric field here is **declared by the archive**, i.e. by untrusted
/// input. `uncompressed_size` in particular is the number a naive reader would
/// pass to `Vec::with_capacity`; this crate never does, and neither should a
/// caller. It is carried because it is useful for *reporting* (and for cheaply
/// skipping a member that is obviously too big) — never for sizing a buffer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ZipMember {
    /// The member path, already passed through [`is_safe_member_path`].
    pub name: String,
    /// DECLARED compressed size. A hint.
    pub compressed_size: u64,
    /// DECLARED uncompressed size. A hint — see the struct note.
    pub uncompressed_size: u64,
    /// DECLARED CRC-32 of the uncompressed bytes. This one is *checked*:
    /// [`read_member_capped`] verifies it and refuses a mismatch.
    pub crc32: u32,
    /// Compression method: 0 stored, 8 deflate. Anything else is refused at
    /// read time, not at listing time, so one exotic member does not hide the
    /// rest of the archive.
    pub method: u16,
    /// DECLARED offset of the local file header. Bounds-checked at read time.
    pub local_header_offset: u64,
}

impl ZipMember {
    /// Whether this crate can decode the member at all.
    pub fn is_readable(&self) -> bool {
        self.method == METHOD_STORE || self.method == METHOD_DEFLATE
    }
}

/// Whether a zip member path may be trusted as a *name*.
///
/// The house rule the tar readers apply (`is_safe_member_path`: no `..`, no
/// absolute path), restated for zip's spelling. Refused:
///
///   * an empty name, or one holding a NUL;
///   * an absolute path (`/etc/passwd`) — including the backslash spelling a
///     Windows-produced zip may carry;
///   * any `..` path component, in either separator;
///   * a Windows drive prefix (`C:\`, `c:/`).
///
/// This crate never writes a member to disk, so traversal cannot escape a
/// directory here. It refuses anyway, for two reasons: a name is *reported* —
/// it reaches a p2 document and a registration page, where a consumer may well
/// join it onto a path — and a member that lies about its name is evidence
/// about the archive, not a member worth reading.
pub fn is_safe_member_path(name: &str) -> bool {
    if name.is_empty() || name.contains('\0') {
        return false;
    }
    if name.starts_with('/') || name.starts_with('\\') {
        return false;
    }
    // `C:` / `c:/` / `C:\…`
    let b = name.as_bytes();
    if b.len() >= 2 && b[1] == b':' && b[0].is_ascii_alphabetic() {
        return false;
    }
    for comp in name.split(['/', '\\']) {
        if comp == ".." {
            return false;
        }
    }
    true
}

fn u16_at(buf: &[u8], off: usize) -> Result<u16> {
    let end = off
        .checked_add(2)
        .ok_or_else(|| anyhow!("zip: offset overflow"))?;
    let s = buf
        .get(off..end)
        .ok_or_else(|| anyhow!("zip: truncated at {off}"))?;
    Ok(u16::from_le_bytes([s[0], s[1]]))
}

fn u32_at(buf: &[u8], off: usize) -> Result<u32> {
    let end = off
        .checked_add(4)
        .ok_or_else(|| anyhow!("zip: offset overflow"))?;
    let s = buf
        .get(off..end)
        .ok_or_else(|| anyhow!("zip: truncated at {off}"))?;
    Ok(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}

fn u64_at(buf: &[u8], off: usize) -> Result<u64> {
    let end = off
        .checked_add(8)
        .ok_or_else(|| anyhow!("zip: offset overflow"))?;
    let s = buf
        .get(off..end)
        .ok_or_else(|| anyhow!("zip: truncated at {off}"))?;
    let mut a = [0u8; 8];
    a.copy_from_slice(s);
    Ok(u64::from_le_bytes(a))
}

/// The most bytes a central directory may span when it is read out of a [`ZipSource`]
/// (lane HOL-RSS-3). It is read whole, so it is bounded like a member is: a `.nupkg`'s
/// directory is a few KiB, and [`MAX_MEMBERS`] entries with ordinary names fit well inside.
pub const MAX_CENTRAL_DIR_BYTES: u64 = 64 * 1024 * 1024;

/// **A zip read where its bytes lie, never held whole** (lane HOL-RSS-3).
///
/// The Composer and NuGet package lists need one small member of every package: the
/// `composer.json`, the `.nuspec`. Read out of a slice, that meant holding every package
/// whole, one per core, to read a few KiB of each. Read out of a source, it costs the
/// end-of-central-directory window, the central directory and the one member: the
/// `_at` functions below ask for exactly those ranges and nothing else.
///
/// A byte slice is a source too, and the slice functions ([`members`],
/// [`read_member_capped`], [`find_member_capped`]) are the `_at` functions over one, so
/// both paths are the same code and every trap test holds for both.
pub trait ZipSource {
    /// The archive's length in bytes.
    fn len(&self) -> u64;

    /// Whether the archive holds no bytes at all.
    fn is_empty(&self) -> bool {
        self.len() == 0
    }

    /// Exactly `buf.len()` bytes from `offset`, or an error (never a short read).
    fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()>;
}

impl ZipSource for &[u8] {
    fn len(&self) -> u64 {
        <[u8]>::len(self) as u64
    }

    fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()> {
        let start = usize::try_from(offset)
            .map_err(|_| anyhow!("zip: offset {offset} beyond this address space"))?;
        let end = start
            .checked_add(buf.len())
            .ok_or_else(|| anyhow!("zip: offset overflow"))?;
        let s = self
            .get(start..end)
            .ok_or_else(|| anyhow!("zip: truncated at {start}"))?;
        buf.copy_from_slice(s);
        Ok(())
    }
}

/// A [`ZipSource`] over a length and a positioned read: what a backend hands in for a
/// package it reads out of its archive a range at a time.
pub struct ReadAt<F> {
    len: u64,
    read: F,
}

impl<F: FnMut(u64, &mut [u8]) -> Result<()>> ReadAt<F> {
    /// `len` bytes, read by `read(offset, buf)`, which fills `buf` exactly.
    pub fn new(len: u64, read: F) -> Self {
        Self { len, read }
    }
}

impl<F: FnMut(u64, &mut [u8]) -> Result<()>> ZipSource for ReadAt<F> {
    fn len(&self) -> u64 {
        self.len
    }

    fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()> {
        let end = offset
            .checked_add(buf.len() as u64)
            .ok_or_else(|| anyhow!("zip: offset overflow"))?;
        if end > self.len {
            bail!("zip: truncated at {offset}");
        }
        (self.read)(offset, buf)
    }
}

/// `len` bytes of `src` from `offset`.
fn read_vec(src: &mut dyn ZipSource, offset: u64, len: usize) -> Result<Vec<u8>> {
    let mut v = vec![0u8; len];
    src.read_at(offset, &mut v)?;
    Ok(v)
}

/// Where the central directory starts, where it must end (the record after it), and
/// how many entries it declares.
struct CentralDir {
    offset: u64,
    end: u64,
    entries: usize,
}

/// Locate the end-of-central-directory record and, through it, the central
/// directory — following the Zip64 records when the classic fields are
/// saturated. Reads the tail window and, for Zip64, the two Zip64 records: nothing else.
fn locate_central_dir(src: &mut dyn ZipSource, max_members: usize) -> Result<CentralDir> {
    let len = src.len();
    if len < 22 {
        bail!("zip: shorter than an end-of-central-directory record ({len} bytes)");
    }
    // Backwards scan, bounded: the EOCD is 22 bytes plus a comment of at most
    // 65 535, so anything earlier than that cannot be it.
    let window = (EOCD_SCAN_WINDOW as u64).min(len);
    let base = len - window;
    let tail = read_vec(src, base, window as usize)?;
    let mut eocd = None;
    let mut i = tail.len() - 22;
    loop {
        if u32_at(&tail, i)? == SIG_EOCD {
            // The comment length must account for exactly the tail, or this is
            // a signature that happens to appear inside compressed data.
            let comment_len = u16_at(&tail, i + 20)? as usize;
            if i + 22 + comment_len == tail.len() {
                eocd = Some(i);
                break;
            }
        }
        if i == 0 {
            break;
        }
        i -= 1;
    }
    let eocd = eocd.ok_or_else(|| anyhow!("zip: no end-of-central-directory record found"))?;
    let eocd_at = base + eocd as u64;

    let mut entries = u16_at(&tail, eocd + 10)? as u64;
    let mut cd_offset = u32_at(&tail, eocd + 16)? as u64;
    // The directory lies before the record that points at it.
    let mut cd_end = eocd_at;

    // Zip64: either field saturated means the truth is in the Zip64 records.
    if entries == 0xFFFF || cd_offset == 0xFFFF_FFFF {
        if eocd_at < 20 {
            bail!("zip: Zip64 fields saturated but no room for a Zip64 locator");
        }
        let loc = read_vec(src, eocd_at - 20, 20)?;
        if u32_at(&loc, 0)? != SIG_ZIP64_EOCD_LOCATOR {
            bail!("zip: Zip64 fields saturated but the Zip64 locator is missing");
        }
        let z64 = u64_at(&loc, 8)?;
        let record = if z64.checked_add(56).is_some_and(|e| e <= eocd_at - 20) {
            read_vec(src, z64, 56)?
        } else {
            Vec::new()
        };
        if record.is_empty() || u32_at(&record, 0)? != SIG_ZIP64_EOCD {
            bail!("zip: Zip64 end-of-central-directory record not at the declared offset");
        }
        entries = u64_at(&record, 32)?;
        cd_offset = u64_at(&record, 48)?;
        cd_end = z64;
    }

    let entries = usize::try_from(entries)
        .map_err(|_| anyhow!("zip: entry count beyond this address space"))?;
    if entries > max_members {
        bail!(
            "zip: {} members declared, over the {} ceiling",
            entries,
            max_members
        );
    }
    if cd_offset > cd_end {
        bail!("zip: central directory declared at {cd_offset}, past its end at {cd_end} ({len} byte archive)");
    }
    Ok(CentralDir {
        offset: cd_offset,
        end: cd_end,
        entries,
    })
}

/// Read the Zip64 `0x0001` extra field, replacing whichever of the three
/// values the classic header saturated.
///
/// The spec is positional and conditional: the 8/8/8/4-byte values appear ONLY
/// for the fields that were `0xFFFFFFFF`, in this order, so the offsets cannot
/// be hardcoded.
fn apply_zip64_extra(
    extra: &[u8],
    uncompressed: &mut u64,
    compressed: &mut u64,
    local_offset: &mut u64,
) -> Result<()> {
    let mut p = 0usize;
    while p + 4 <= extra.len() {
        let id = u16_at(extra, p)?;
        let len = u16_at(extra, p + 2)? as usize;
        let body_start = p + 4;
        let body_end = body_start
            .checked_add(len)
            .ok_or_else(|| anyhow!("zip: extra field overflow"))?;
        if body_end > extra.len() {
            // A truncated extra field is not fatal: the classic values stand.
            break;
        }
        if id == 0x0001 {
            let body = &extra[body_start..body_end];
            let mut q = 0usize;
            if *uncompressed == 0xFFFF_FFFF && q + 8 <= body.len() {
                *uncompressed = u64_at(body, q)?;
                q += 8;
            }
            if *compressed == 0xFFFF_FFFF && q + 8 <= body.len() {
                *compressed = u64_at(body, q)?;
                q += 8;
            }
            if *local_offset == 0xFFFF_FFFF && q + 8 <= body.len() {
                *local_offset = u64_at(body, q)?;
            }
            return Ok(());
        }
        p = body_end;
    }
    Ok(())
}

/// Every member the central directory declares, in central-directory order.
///
/// Members whose name fails [`is_safe_member_path`] are **dropped**, not
/// returned and not fatal: one hostile name in a `.nupkg` must not hide the
/// `.nuspec` beside it, exactly as `znippy-helm` skips one bomb chart rather
/// than failing the whole index build. No member bytes are read.
pub fn members(zip: &[u8]) -> Result<Vec<ZipMember>> {
    members_up_to(zip, MAX_MEMBERS)
}

/// [`members`] under the caller's own member ceiling instead of [`MAX_MEMBERS`] (a package's
/// zip): an archive that is a database dump, such as OSV's `npm/all.zip` (230 994 records on
/// 2026-10-31, lane HOL-FIELD-TEST), names a ceiling of its own size.
pub fn members_up_to(zip: &[u8], max_members: usize) -> Result<Vec<ZipMember>> {
    let mut src = zip;
    let cd = locate_central_dir(&mut src, max_members)?;
    // The slice is already in memory: the directory is parsed where it lies.
    parse_central_dir(&zip[cd.offset as usize..cd.end as usize], cd.entries)
}

/// [`members_up_to`] out of a [`ZipSource`]: the tail window and the central directory
/// (at most [`MAX_CENTRAL_DIR_BYTES`]) are read, no member byte is.
pub fn members_at(src: &mut dyn ZipSource, max_members: usize) -> Result<Vec<ZipMember>> {
    let cd = locate_central_dir(src, max_members)?;
    let n = cd.end - cd.offset;
    if n > MAX_CENTRAL_DIR_BYTES {
        bail!(
            "zip: the central directory spans {n} bytes, over the {MAX_CENTRAL_DIR_BYTES} byte ceiling"
        );
    }
    let dir = read_vec(src, cd.offset, n as usize)?;
    parse_central_dir(&dir, cd.entries)
}

/// The `entries` headers of a central directory, `dir` being its bytes.
fn parse_central_dir(dir: &[u8], entries: usize) -> Result<Vec<ZipMember>> {
    let mut out = Vec::with_capacity(entries.min(1024));
    let mut p = 0usize;
    for _ in 0..entries {
        if u32_at(dir, p)? != SIG_CENTRAL {
            bail!("zip: central directory header expected at {p}");
        }
        let method = u16_at(dir, p + 10)?;
        let crc32 = u32_at(dir, p + 16)?;
        let mut compressed = u32_at(dir, p + 20)? as u64;
        let mut uncompressed = u32_at(dir, p + 24)? as u64;
        let name_len = u16_at(dir, p + 28)? as usize;
        let extra_len = u16_at(dir, p + 30)? as usize;
        let comment_len = u16_at(dir, p + 32)? as usize;
        let mut local_offset = u32_at(dir, p + 42)? as u64;

        let name_start = p + 46;
        let name_end = name_start
            .checked_add(name_len)
            .ok_or_else(|| anyhow!("zip: name overflow"))?;
        let name_bytes = dir
            .get(name_start..name_end)
            .ok_or_else(|| anyhow!("zip: truncated member name"))?;
        let extra_end = name_end
            .checked_add(extra_len)
            .ok_or_else(|| anyhow!("zip: extra overflow"))?;
        let extra = dir
            .get(name_end..extra_end)
            .ok_or_else(|| anyhow!("zip: truncated extra field"))?;
        apply_zip64_extra(extra, &mut uncompressed, &mut compressed, &mut local_offset)?;

        // A member name is not required to be UTF-8 by the spec. One that is
        // not cannot be a `.nuspec` or a `composer.json` path, so it is skipped
        // rather than lossily renamed into something that might collide.
        if let Ok(name) = std::str::from_utf8(name_bytes) {
            if is_safe_member_path(name) {
                out.push(ZipMember {
                    name: name.to_string(),
                    compressed_size: compressed,
                    uncompressed_size: uncompressed,
                    crc32,
                    method,
                    local_header_offset: local_offset,
                });
            }
        }

        p = extra_end
            .checked_add(comment_len)
            .ok_or_else(|| anyhow!("zip: central directory overflow"))?;
    }
    Ok(out)
}

/// The bytes of one member, bounded by [`MAX_MEMBER_DECOMPRESSED_BYTES`].
pub fn read_member(zip: &[u8], member: &ZipMember) -> Result<Vec<u8>> {
    read_member_capped(zip, member, MAX_MEMBER_DECOMPRESSED_BYTES)
}

/// The bytes of one member with an explicit ceiling — the seam the bomb test
/// drives with a small cap instead of allocating a real 32 MiB.
///
/// Refuses, rather than allocating, when the member inflates past `max_out`.
/// Verifies the declared CRC-32 and refuses a mismatch, which is what turns a
/// lying central directory into an error instead of into wrong metadata.
pub fn read_member_capped(zip: &[u8], member: &ZipMember, max_out: usize) -> Result<Vec<u8>> {
    let mut src = zip;
    read_member_at(&mut src, member, max_out)
}

/// The DEFLATE input of a member: `src` from `at` to its end, read a buffer at a time.
struct SourceReader<'a> {
    src: &'a mut dyn ZipSource,
    at: u64,
    end: u64,
}

impl Read for SourceReader<'_> {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        let n = (self.end - self.at).min(buf.len() as u64) as usize;
        if n == 0 {
            return Ok(0);
        }
        self.src
            .read_at(self.at, &mut buf[..n])
            .map_err(std::io::Error::other)?;
        self.at += n as u64;
        Ok(n)
    }
}

/// [`read_member_capped`] out of a [`ZipSource`]: the member's local header, then its
/// data a buffer at a time, inflated under the cap. Nothing before or after it is read.
pub fn read_member_at(
    src: &mut dyn ZipSource,
    member: &ZipMember,
    max_out: usize,
) -> Result<Vec<u8>> {
    let len = src.len();
    let lh = member.local_header_offset;
    if lh.checked_add(30).is_none_or(|e| e > len) {
        bail!("zip: no local file header for {:?} at {lh}", member.name);
    }
    let head = read_vec(src, lh, 30)?;
    if u32_at(&head, 0)? != SIG_LOCAL {
        bail!("zip: no local file header for {:?} at {lh}", member.name);
    }
    // The LOCAL header's own name/extra lengths, not the central one's: the two
    // are allowed to differ, and following the central directory's extra length
    // here would land the read inside the member's data.
    let name_len = u16_at(&head, 26)? as u64;
    let extra_len = u16_at(&head, 28)? as u64;
    let data_start = lh + 30 + name_len + extra_len;
    if data_start > len {
        bail!(
            "zip: member {:?} starts past the end of the archive",
            member.name
        );
    }
    // Deliberately NOT `data_start .. data_start + compressed_size`: that
    // length is declared by the archive. The DEFLATE stream self-terminates,
    // and a STORED member is clamped below.
    let remaining = len - data_start;

    let out = match member.method {
        METHOD_STORE => {
            let declared = usize::try_from(member.uncompressed_size)
                .map_err(|_| anyhow!("zip: stored member larger than this address space"))?;
            if declared > max_out {
                bail!(
                    "zip: member {:?} declares {declared} stored bytes, over the {max_out} cap",
                    member.name
                );
            }
            if declared as u64 > remaining {
                bail!(
                    "zip: member {:?} declares {declared} stored bytes but only {remaining} remain",
                    member.name
                );
            }
            read_vec(src, data_start, declared)?
        }
        METHOD_DEFLATE => {
            let input = std::io::BufReader::with_capacity(
                64 * 1024,
                SourceReader {
                    src,
                    at: data_start,
                    end: len,
                },
            );
            let mut dec = flate2::bufread::DeflateDecoder::new(input).take(max_out as u64 + 1);
            // No `with_capacity(declared)`: the declared size is the lie this
            // whole function is built around. Grow from a modest floor instead.
            let mut out = Vec::with_capacity(8 * 1024);
            dec.read_to_end(&mut out)
                .map_err(|e| anyhow!("zip: inflate of {:?} failed: {e}", member.name))?;
            if out.len() > max_out {
                bail!(
                    "zip: member {:?} inflates past the {max_out} byte cap — refused unread",
                    member.name
                );
            }
            out
        }
        other => bail!(
            "zip: member {:?} uses unsupported compression method {other}",
            member.name
        ),
    };

    // The declared CRC is the ONE central-directory number worth trusting,
    // because it is checkable. A member whose bytes do not match it is either
    // corrupt or forged, and either way its `.nuspec` must not reach an index.
    // (crc == 0 with an empty member is the legitimate zero.)
    let mut crc = flate2::Crc::new();
    crc.update(&out);
    if crc.sum() != member.crc32 {
        bail!(
            "zip: member {:?} CRC mismatch — declared {:08x}, read {:08x}",
            member.name,
            member.crc32,
            crc.sum()
        );
    }
    Ok(out)
}

/// The first member whose name satisfies `pred`, and its bytes — the shape both
/// backends actually want ("the `.nuspec`", "the `composer.json`").
///
/// `Ok(None)` when no member matches. A member that matches but cannot be read
/// (bomb, CRC mismatch, exotic method) is an `Err`, because silently returning
/// `None` there would make a forged package indistinguishable from one that
/// simply has no metadata.
pub fn find_member_capped(
    zip: &[u8],
    max_out: usize,
    pred: impl Fn(&str) -> bool,
) -> Result<Option<(String, Vec<u8>)>> {
    let members = members(zip)?;
    let Some(m) = members.into_iter().find(|m| pred(&m.name)) else {
        return Ok(None);
    };
    let bytes = read_member_capped(zip, &m, max_out)?;
    Ok(Some((m.name, bytes)))
}

/// [`find_member_capped`] at [`MAX_MEMBER_DECOMPRESSED_BYTES`].
pub fn find_member(zip: &[u8], pred: impl Fn(&str) -> bool) -> Result<Option<(String, Vec<u8>)>> {
    find_member_capped(zip, MAX_MEMBER_DECOMPRESSED_BYTES, pred)
}

/// [`find_member_capped`] out of a [`ZipSource`] (lane HOL-RSS-3): the tail window, the
/// central directory and the one member are read, the rest of the package is not.
pub fn find_member_at(
    src: &mut dyn ZipSource,
    max_out: usize,
    pred: impl Fn(&str) -> bool,
) -> Result<Option<(String, Vec<u8>)>> {
    let members = members_at(src, MAX_MEMBERS)?;
    let Some(m) = members.into_iter().find(|m| pred(&m.name)) else {
        return Ok(None);
    };
    let bytes = read_member_at(src, &m, max_out)?;
    Ok(Some((m.name, bytes)))
}

// ─────────────────────────────────────────────────────────────────────────────

/// A minimal zip WRITER, for tests and fixtures only.
///
/// `ldeflate::zip_write` is holger's production writer; this exists so the
/// tests in this crate and in its two consumers can build a `.nupkg` or a
/// Composer dist without depending on the writer's tuning — and, crucially, so
/// the bomb and traversal fixtures can declare things a well-behaved writer
/// would refuse to declare.
#[cfg(any(test, feature = "fixtures"))]
pub mod fixture {
    use std::io::Write;

    /// Build a zip from `(name, contents)` pairs, DEFLATE-compressed.
    pub fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
        build(entries, false)
    }

    /// Build a zip whose central directory **lies** about the uncompressed
    /// size of every member, declaring 1 byte. Used to prove that no allocation
    /// or truncation follows the declared size.
    pub fn zip_with_lying_sizes(entries: &[(&str, &[u8])]) -> Vec<u8> {
        build(entries, true)
    }

    fn deflate(data: &[u8]) -> Vec<u8> {
        let mut e = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
        e.write_all(data).expect("in-memory write");
        e.finish().expect("in-memory finish")
    }

    fn build(entries: &[(&str, &[u8])], lie: bool) -> Vec<u8> {
        let mut out: Vec<u8> = Vec::new();
        let mut central: Vec<(u32, u32, u32, usize, String)> = Vec::new();
        for (name, data) in entries {
            let comp = deflate(data);
            let mut crc = flate2::Crc::new();
            crc.update(data);
            let crc = crc.sum();
            let offset = out.len();
            out.extend_from_slice(&0x0403_4B50u32.to_le_bytes());
            out.extend_from_slice(&20u16.to_le_bytes()); // version needed
            out.extend_from_slice(&0u16.to_le_bytes()); // flags
            out.extend_from_slice(&8u16.to_le_bytes()); // deflate
            out.extend_from_slice(&0u16.to_le_bytes()); // time
            out.extend_from_slice(&0u16.to_le_bytes()); // date
            out.extend_from_slice(&crc.to_le_bytes());
            out.extend_from_slice(&(comp.len() as u32).to_le_bytes());
            out.extend_from_slice(&(data.len() as u32).to_le_bytes());
            out.extend_from_slice(&(name.len() as u16).to_le_bytes());
            out.extend_from_slice(&0u16.to_le_bytes()); // extra len
            out.extend_from_slice(name.as_bytes());
            out.extend_from_slice(&comp);
            central.push((
                crc,
                comp.len() as u32,
                data.len() as u32,
                offset,
                (*name).to_string(),
            ));
        }
        let cd_start = out.len();
        for (crc, csize, usize_, offset, name) in &central {
            out.extend_from_slice(&0x0201_4B50u32.to_le_bytes());
            out.extend_from_slice(&20u16.to_le_bytes()); // version made by
            out.extend_from_slice(&20u16.to_le_bytes()); // version needed
            out.extend_from_slice(&0u16.to_le_bytes()); // flags
            out.extend_from_slice(&8u16.to_le_bytes()); // deflate
            out.extend_from_slice(&0u16.to_le_bytes()); // time
            out.extend_from_slice(&0u16.to_le_bytes()); // date
            out.extend_from_slice(&crc.to_le_bytes());
            out.extend_from_slice(&csize.to_le_bytes());
            out.extend_from_slice(&(if lie { 1u32 } else { *usize_ }).to_le_bytes());
            out.extend_from_slice(&(name.len() as u16).to_le_bytes());
            out.extend_from_slice(&0u16.to_le_bytes()); // extra
            out.extend_from_slice(&0u16.to_le_bytes()); // comment
            out.extend_from_slice(&0u16.to_le_bytes()); // disk
            out.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
            out.extend_from_slice(&0u32.to_le_bytes()); // external attrs
            out.extend_from_slice(&(*offset as u32).to_le_bytes());
            out.extend_from_slice(name.as_bytes());
        }
        let cd_len = out.len() - cd_start;
        // More members than the classic record counts: the Zip64 end record and its locator
        // carry the count (the OSV dumps, lane HOL-FIELD-TEST), the classic fields saturate.
        let count = if central.len() > 0xFFFE {
            let z64 = out.len() as u64;
            out.extend_from_slice(&0x0606_4B50u32.to_le_bytes());
            out.extend_from_slice(&44u64.to_le_bytes()); // size of the rest
            out.extend_from_slice(&45u16.to_le_bytes()); // version made by
            out.extend_from_slice(&45u16.to_le_bytes()); // version needed
            out.extend_from_slice(&0u32.to_le_bytes()); // disk
            out.extend_from_slice(&0u32.to_le_bytes()); // cd disk
            out.extend_from_slice(&(central.len() as u64).to_le_bytes());
            out.extend_from_slice(&(central.len() as u64).to_le_bytes());
            out.extend_from_slice(&(cd_len as u64).to_le_bytes());
            out.extend_from_slice(&(cd_start as u64).to_le_bytes());
            out.extend_from_slice(&0x0706_4B50u32.to_le_bytes());
            out.extend_from_slice(&0u32.to_le_bytes()); // disk of the Zip64 end record
            out.extend_from_slice(&z64.to_le_bytes());
            out.extend_from_slice(&1u32.to_le_bytes()); // disks
            0xFFFFu16
        } else {
            central.len() as u16
        };
        out.extend_from_slice(&0x0605_4B50u32.to_le_bytes());
        out.extend_from_slice(&0u16.to_le_bytes()); // disk
        out.extend_from_slice(&0u16.to_le_bytes()); // cd disk
        out.extend_from_slice(&count.to_le_bytes());
        out.extend_from_slice(&count.to_le_bytes());
        out.extend_from_slice(&(cd_len as u32).to_le_bytes());
        out.extend_from_slice(&(cd_start as u32).to_le_bytes());
        out.extend_from_slice(&0u16.to_le_bytes()); // comment len
        out
    }
}

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

    #[cfg(feature = "testmatrix")]
    use traits::test_support::fstatus;

    #[test]
    fn reads_a_member_back_out_of_a_zip() {
        let z = fixture::zip(&[
            ("lib/net8.0/Thing.dll", b"BINARY".as_slice()),
            (
                "Thing.nuspec",
                b"<package><metadata><id>Thing</id></metadata></package>".as_slice(),
            ),
        ]);
        let ms = members(&z).expect("central directory");
        assert_eq!(ms.len(), 2);
        assert_eq!(ms[1].name, "Thing.nuspec");
        assert!(ms.iter().all(|m| m.is_readable()));

        let (name, bytes) = find_member(&z, |n| n.ends_with(".nuspec"))
            .expect("read")
            .expect("present");
        assert_eq!(name, "Thing.nuspec");
        assert!(String::from_utf8_lossy(&bytes).contains("<id>Thing</id>"));

        let absent = find_member(&z, |n| n.ends_with(".nuspecX")).expect("read");
        assert!(absent.is_none(), "a missing member is None, not an error");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-zip-entry",
            "reads_a_named_member",
            name == "Thing.nuspec",
            "central directory walked, one member inflated and CRC-verified",
        );
    }

    /// ★ **Trap 1: the decompression bomb.**
    ///
    /// A member of a few KiB that inflates to megabytes must cost the CAP, not
    /// the member. RED without the `take()`: the read succeeds and the process
    /// holds the whole inflated member.
    #[test]
    fn a_decompression_bomb_is_refused_at_the_cap() {
        let bomb = vec![0u8; 8 * 1024 * 1024];
        let z = fixture::zip(&[
            ("bomb.bin", bomb.as_slice()),
            ("small.txt", b"ok".as_slice()),
        ]);
        assert!(
            z.len() < 64 * 1024,
            "the fixture must stay small on the wire: {} bytes",
            z.len()
        );

        let ms = members(&z).expect("central directory");
        let bomb_m = ms
            .iter()
            .find(|m| m.name == "bomb.bin")
            .expect("bomb listed");

        // A cap far below the member: refused, and nothing near 8 MiB allocated.
        let err = read_member_capped(&z, bomb_m, 64 * 1024).expect_err("must refuse");
        let msg = err.to_string();
        assert!(msg.contains("cap"), "{msg}");

        // The honest member beside it still reads — one bomb does not blind the
        // archive, the same way znippy-helm skips one bomb chart.
        let small = ms.iter().find(|m| m.name == "small.txt").expect("listed");
        assert_eq!(
            read_member_capped(&z, small, 64 * 1024).expect("honest member"),
            b"ok"
        );

        // And at a cap that fits, the very same member reads correctly — so the
        // refusal is the CAP and not a decode failure.
        let full = read_member_capped(&z, bomb_m, 16 * 1024 * 1024).expect("fits");
        assert_eq!(full.len(), 8 * 1024 * 1024);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-zip-entry",
            "decompression_bomb_capped",
            msg.contains("cap"),
            "streamed inflate refuses past the cap; the honest member beside it still reads",
        );
    }

    /// ★ **Trap 2: path traversal.**
    ///
    /// A member named `../../etc/passwd` never reaches a caller: it is dropped
    /// by [`members`] itself, so no consumer can forget the check. RED without
    /// `is_safe_member_path`: the hostile name is listed and its bytes offered.
    #[test]
    fn a_traversing_member_name_never_reaches_a_caller() {
        let z = fixture::zip(&[
            ("../../etc/passwd", b"root:x:0:0".as_slice()),
            ("/etc/shadow", b"secret".as_slice()),
            ("..\\..\\windows\\system32\\evil.dll", b"MZ".as_slice()),
            ("C:/Windows/evil.dll", b"MZ".as_slice()),
            ("Honest.nuspec", b"<package/>".as_slice()),
        ]);
        let ms = members(&z).expect("central directory");
        assert_eq!(
            ms.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
            vec!["Honest.nuspec"],
            "only the safe name survives"
        );
        // And the predicate itself, stated once for every spelling.
        for hostile in [
            "../x",
            "a/../../x",
            "/abs",
            "\\abs",
            "..\\x",
            "C:\\x",
            "c:/x",
            "",
            "with\0nul",
        ] {
            assert!(!is_safe_member_path(hostile), "must refuse {hostile:?}");
        }
        for ok in [
            "a.nuspec",
            "lib/net8.0/a.dll",
            "a/b/c.json",
            "..dotted.txt",
            "a..b/c",
        ] {
            assert!(is_safe_member_path(ok), "must accept {ok:?}");
        }

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-zip-entry",
            "traversal_member_dropped",
            ms.len() == 1,
            "four traversal spellings dropped by members(); the honest member survives",
        );
    }

    /// ★ **Trap 3, the one a tar does not have: a central directory that
    /// lies.**
    ///
    /// The declared uncompressed size is attacker-controlled. Here it says 1
    /// byte for a 5 000-byte member. A reader that believed it would return a
    /// truncated `.nuspec` — wrong metadata, silently. This reader ignores the
    /// number for DEFLATE and catches the lie with the CRC.
    #[test]
    fn a_lying_central_directory_does_not_truncate_or_allocate() {
        let body = vec![b'x'; 5000];
        let z = fixture::zip_with_lying_sizes(&[("Thing.nuspec", body.as_slice())]);
        let ms = members(&z).expect("central directory");
        assert_eq!(
            ms[0].uncompressed_size, 1,
            "the archive really does declare a lie"
        );

        let bytes = read_member(&z, &ms[0]).expect("declared size is a hint, not a bound");
        assert_eq!(
            bytes.len(),
            5000,
            "the member is read in full, not truncated to the lie"
        );

        // And a lie the other way — a huge declared size — buys no allocation,
        // because nothing is sized from it: the same read still costs the
        // member, and a STORED member would be clamped to what exists.
        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-zip-entry",
            "lying_central_directory",
            bytes.len() == 5000,
            "declared uncompressed size ignored for DEFLATE; CRC is the checked field",
        );
    }

    /// A forged member — right name, wrong bytes — is an error, not metadata.
    #[test]
    fn a_crc_mismatch_is_refused() {
        let z = fixture::zip(&[("Thing.nuspec", b"<package/>".as_slice())]);
        let mut ms = members(&z).expect("central directory");
        ms[0].crc32 ^= 0xFFFF_FFFF;
        let err = read_member(&z, &ms[0]).expect_err("forged CRC must refuse");
        assert!(err.to_string().contains("CRC mismatch"), "{err}");
    }

    #[test]
    fn a_non_zip_is_refused_rather_than_guessed_at() {
        assert!(members(b"not a zip at all").is_err());
        assert!(members(b"").is_err());
        assert!(members(&vec![0u8; 4096]).is_err());
    }

    /// An archive whose EOCD is followed by a comment still parses — the
    /// backwards scan is what finds it, and the comment-length check is what
    /// stops a signature inside compressed data being mistaken for the EOCD.
    #[test]
    fn an_archive_comment_does_not_hide_the_directory() {
        let mut z = fixture::zip(&[("a.txt", b"a".as_slice())]);
        let n = z.len();
        z[n - 2..].copy_from_slice(&5u16.to_le_bytes());
        z.extend_from_slice(b"hello");
        assert_eq!(members(&z).expect("comment tolerated").len(), 1);
    }

    // ── lane HOL-RSS-3: read out of a source, never the whole package ──────────

    /// A source over a slice that counts what it was asked for.
    struct Counted<'a> {
        zip: &'a [u8],
        read: u64,
    }

    impl ZipSource for Counted<'_> {
        fn len(&self) -> u64 {
            self.zip.len() as u64
        }

        fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()> {
            self.read += buf.len() as u64;
            let mut s = self.zip;
            s.read_at(offset, buf)
        }
    }

    fn counted(zip: &[u8]) -> Counted<'_> {
        Counted { zip, read: 0 }
    }

    /// Bytes that do not compress, so a zip of them is as large as they are.
    fn noise(len: usize) -> Vec<u8> {
        let mut x: u64 = 0x9E37_79B9_7F4A_7C15;
        let mut v = Vec::with_capacity(len + 8);
        while v.len() < len {
            x ^= x << 13;
            x ^= x >> 7;
            x ^= x << 17;
            v.extend_from_slice(&x.to_le_bytes());
        }
        v.truncate(len);
        v
    }

    /// ★ **The manifest of a package, read without the package.**
    ///
    /// The Composer and NuGet package lists read one small member of every package. Out
    /// of a source that costs the tail, the central directory and that member, wherever
    /// the member sits; the 8 MiB beside it is never asked for. RED before the `_at`
    /// functions: the package was read whole (a slice) to find its `.nuspec`.
    #[test]
    fn a_member_is_read_without_the_rest_of_the_package() {
        let big = noise(8 * 1024 * 1024);
        let nuspec = b"<package><metadata><id>Big</id></metadata></package>".as_slice();
        let first = fixture::zip(&[("Big.nuspec", nuspec), ("lib/net8.0/Big.dll", &big)]);
        let last = fixture::zip(&[("lib/net8.0/Big.dll", &big), ("Big.nuspec", nuspec)]);
        for (what, z) in [("first", &first), ("last", &last)] {
            assert!(z.len() > 8 * 1024 * 1024, "{what}: the package is big");
            let mut src = counted(z);
            let got = find_member_at(&mut src, MAX_MEMBER_DECOMPRESSED_BYTES, |n| {
                n.ends_with(".nuspec")
            })
            .expect("read")
            .expect("present");
            assert_eq!(
                Some(got.clone()),
                find_member(z, |n| n.ends_with(".nuspec")).expect("slice read"),
                "{what}: the same member as the slice read"
            );
            assert_eq!(got.1, nuspec, "{what}: the member's bytes");
            assert!(
                src.read < 256 * 1024,
                "{what}: {} bytes read of a {} byte package for a {} byte member",
                src.read,
                z.len(),
                nuspec.len()
            );
            let mut src = counted(z);
            assert!(
                find_member_at(&mut src, MAX_MEMBER_DECOMPRESSED_BYTES, |n| n
                    .ends_with(".x"))
                .expect("read")
                .is_none(),
                "{what}: a missing member is None"
            );
            assert!(
                src.read < 128 * 1024,
                "{what}: and costs the directory only"
            );
        }

        // A closure over the same bytes is the same source.
        let mut src = ReadAt::new(last.len() as u64, |off, buf: &mut [u8]| {
            let mut s = last.as_slice();
            s.read_at(off, buf)
        });
        let got = find_member_at(&mut src, MAX_MEMBER_DECOMPRESSED_BYTES, |n| {
            n.ends_with(".nuspec")
        })
        .expect("read")
        .expect("present");
        assert_eq!(got.1, nuspec);
        let mut short = [0u8; 4];
        assert!(
            src.read_at(last.len() as u64 - 2, &mut short).is_err(),
            "a read past the end is refused, never short"
        );
    }

    /// ★ Every trap the slice reader closes stays closed out of a source.
    #[test]
    fn the_traps_hold_out_of_a_source_too() {
        // The bomb: refused at the cap, having read about the cap's worth of input.
        let bomb = vec![0u8; 8 * 1024 * 1024];
        let z = fixture::zip(&[
            ("bomb.bin", bomb.as_slice()),
            ("small.txt", b"ok".as_slice()),
        ]);
        let mut src = counted(&z);
        let ms = members_at(&mut src, MAX_MEMBERS).expect("central directory");
        let bomb_m = ms.iter().find(|m| m.name == "bomb.bin").expect("listed");
        let err = read_member_at(&mut src, bomb_m, 64 * 1024).expect_err("must refuse");
        assert!(err.to_string().contains("cap"), "{err}");
        let small = ms.iter().find(|m| m.name == "small.txt").expect("listed");
        assert_eq!(read_member_at(&mut src, small, 64 * 1024).unwrap(), b"ok");
        let full = read_member_at(&mut src, bomb_m, 16 * 1024 * 1024).expect("fits");
        assert_eq!(full.len(), 8 * 1024 * 1024);

        // Traversal: dropped by the listing.
        let z = fixture::zip(&[
            ("../../etc/passwd", b"root".as_slice()),
            ("Honest.nuspec", b"<package/>".as_slice()),
        ]);
        let ms = members_at(&mut counted(&z), MAX_MEMBERS).expect("central directory");
        assert_eq!(
            ms.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
            vec!["Honest.nuspec"]
        );

        // A lying directory: read in full, not truncated to the lie.
        let body = vec![b'x'; 5000];
        let z = fixture::zip_with_lying_sizes(&[("Thing.nuspec", body.as_slice())]);
        let mut src = counted(&z);
        let ms = members_at(&mut src, MAX_MEMBERS).expect("central directory");
        assert_eq!(ms[0].uncompressed_size, 1);
        assert_eq!(
            read_member_at(&mut src, &ms[0], MAX_MEMBER_DECOMPRESSED_BYTES)
                .unwrap()
                .len(),
            5000
        );

        // A forged CRC: refused.
        let z = fixture::zip(&[("Thing.nuspec", b"<package/>".as_slice())]);
        let mut src = counted(&z);
        let mut ms = members_at(&mut src, MAX_MEMBERS).expect("central directory");
        ms[0].crc32 ^= 0xFFFF_FFFF;
        let err = read_member_at(&mut src, &ms[0], MAX_MEMBER_DECOMPRESSED_BYTES)
            .expect_err("forged CRC must refuse");
        assert!(err.to_string().contains("CRC mismatch"), "{err}");

        // Not a zip: refused.
        let zeros = [0u8; 4096];
        for junk in [
            b"not a zip at all".as_slice(),
            b"".as_slice(),
            zeros.as_slice(),
        ] {
            assert!(members_at(&mut counted(junk), MAX_MEMBERS).is_err());
        }

        // A comment after the end record: tolerated.
        let mut z = fixture::zip(&[("a.txt", b"a".as_slice())]);
        let n = z.len();
        z[n - 2..].copy_from_slice(&5u16.to_le_bytes());
        z.extend_from_slice(b"hello");
        assert_eq!(members_at(&mut counted(&z), MAX_MEMBERS).unwrap().len(), 1);

        // Zip64: more members than the classic record counts.
        let names: Vec<String> = (0..70_000).map(|i| format!("m{i}.txt")).collect();
        let entries: Vec<(&str, &[u8])> = names
            .iter()
            .map(|n| (n.as_str(), b"x".as_slice()))
            .collect();
        let z = fixture::zip(&entries);
        let ms = members_at(&mut counted(&z), MAX_MEMBERS).expect("Zip64 directory");
        assert_eq!(ms.len(), 70_000);
        assert_eq!(
            ms,
            members(&z).expect("slice"),
            "the same listing as the slice"
        );
        assert!(
            members_at(&mut counted(&z), 1_000).is_err(),
            "the caller's member ceiling holds"
        );
    }
}