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 ¢ral {
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"
);
}
}