You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

865 lines
34 KiB
Rust

//! Upload validation: turns raw uploaded bytes into a manifest of
//! `(path, bytes)` files ready to be written to R2, while defending against
//! malicious archives.
//!
//! See `docs/plans/2026-07-12-pages-design.md` §2.2. The zip-bomb defense in
//! particular is deliberately paranoid: entry sizes declared in a zip's
//! central directory are **never trusted**. Every byte is counted as it
//! streams out of the decompressor, and extraction aborts the instant a
//! per-file or total limit is crossed — so a small compressed file that
//! claims (or would produce) a huge uncompressed size is rejected long
//! before that size is ever reached, let alone materialized in memory.
use std::collections::HashSet;
use std::io::{Cursor, Read};
/// Configurable limits enforced by [`build_manifest`].
///
/// Fields are public so callers (tests, and eventually `routes::api`) can
/// construct scaled-down variants for testing or future configurability;
/// [`Default`] provides the production values from design §2.2.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UploadLimits {
/// Maximum size of the raw uploaded bytes (compressed, for zip; as-is,
/// for html) in bytes. Checked against the buffered body before any
/// parsing happens.
pub max_compressed: usize,
/// Maximum total *uncompressed* size across all files in the archive,
/// in bytes. Enforced while streaming decompressed bytes, not from the
/// zip header.
pub max_uncompressed_total: u64,
/// Maximum *uncompressed* size of any single file, in bytes. Enforced
/// while streaming decompressed bytes, not from the zip header.
pub max_file: u64,
/// Maximum number of entries (including directories) a zip archive may
/// contain.
pub max_entries: usize,
/// Maximum length, in bytes, of a single entry path.
pub max_path_len: usize,
/// Maximum number of `/`-separated path segments (nesting depth,
/// including the filename) a single entry path may contain.
pub max_depth: usize,
}
impl Default for UploadLimits {
/// Production limits from design §2.2: 10 MiB max compressed upload,
/// 25 MiB max total uncompressed, 5 MiB max per file, 300 max entries,
/// 180-byte max path length, depth 10.
fn default() -> Self {
const MIB: u64 = 1024 * 1024;
Self {
max_compressed: (10 * MIB) as usize,
max_uncompressed_total: 25 * MIB,
max_file: 5 * MIB,
max_entries: 300,
max_path_len: 180,
max_depth: 10,
}
}
}
/// The kind of upload being validated — determines how `bytes` is
/// interpreted by [`build_manifest`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UploadKind {
/// A single raw HTML file, stored as the page's `index.html`.
Html,
/// A zip archive of static assets, extracted per design §2.2.
Zip,
}
/// One file destined for R2 storage: `path` is the R2 object key suffix
/// (relative to `<slug>/`), `bytes` is its full content.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestEntry {
/// Path relative to the page root, e.g. `"index.html"` or
/// `"assets/app.js"`. Always `/`-separated, never starts with `/`,
/// never contains a `..` segment (enforced by [`build_manifest`]).
pub path: String,
/// The file's full (decompressed, for zip) content.
pub bytes: Vec<u8>,
}
/// Errors returned by [`build_manifest`].
///
/// Each variant carries a precise, user-facing `Display` message suitable
/// for a `400 Bad Request` API response body.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UploadError {
/// The raw uploaded bytes exceeded `max_compressed`.
#[error("upload exceeds the maximum allowed size of {max} bytes")]
CompressedTooLarge {
/// The configured limit that was exceeded.
max: usize,
},
/// A zip entry name attempted to escape the page root.
#[error("archive entry has an unsafe path: '{path}'")]
PathTraversal {
/// The offending raw entry name.
path: String,
},
/// A zip entry name was longer than `max_path_len`.
#[error("archive entry path '{path}' exceeds the maximum length of {max} characters")]
PathTooLong {
/// The offending raw entry name.
path: String,
/// The configured limit that was exceeded.
max: usize,
},
/// A zip entry name had more `/`-separated segments than `max_depth`.
#[error("archive entry path '{path}' exceeds the maximum nesting depth of {max}")]
PathTooDeep {
/// The offending raw entry name.
path: String,
/// The configured limit that was exceeded.
max: usize,
},
/// The archive contained more entries than `max_entries`.
#[error("archive contains too many entries (maximum {max})")]
TooManyEntries {
/// The configured limit that was exceeded.
max: usize,
},
/// A single file's decompressed size exceeded `max_file`.
#[error("archive entry '{path}' exceeds the maximum per-file size of {max} bytes")]
FileTooLarge {
/// The offending entry name.
path: String,
/// The configured limit that was exceeded.
max: u64,
},
/// The archive's total decompressed size exceeded `max_uncompressed_total`.
#[error("archive's total uncompressed size exceeds the maximum allowed of {max} bytes")]
TotalTooLarge {
/// The configured limit that was exceeded.
max: u64,
},
/// The archive did not contain a root-level `index.html`.
#[error("archive is missing a required root-level index.html")]
MissingIndexHtml,
/// The archive contained the same normalized path twice.
///
/// With the `zip` crate version this crate currently depends on,
/// parsed archives cannot actually expose two entries sharing a
/// literal name (see the `seen` check in `build_zip_manifest` for
/// details), so this variant is presently unreachable in practice —
/// it is kept as defense-in-depth against that internal representation
/// changing in a future crate upgrade.
#[error("archive contains a duplicate entry: '{path}'")]
DuplicateEntry {
/// The duplicated path.
path: String,
},
/// The bytes could not be parsed as a zip archive at all.
#[error("upload could not be read as a valid zip archive")]
InvalidZip,
/// The archive (or html upload) contained no files.
#[error("upload contains no files")]
Empty,
}
/// Builds the file manifest for an upload, enforcing every limit in
/// `limits` and the structural rules from design §2.2.
///
/// - [`UploadKind::Html`]: `bytes` is stored verbatim as a single
/// `"index.html"` entry (still checked against `max_compressed` and
/// `max_file`/`max_uncompressed_total`).
/// - [`UploadKind::Zip`]: `bytes` is parsed as a zip archive. Directories
/// are skipped. Every file entry's name is checked for path traversal
/// (`..` segments, leading `/`, backslashes, drive letters, empty
/// segments, NUL bytes) and length/depth limits, duplicates are
/// rejected, the entry count is capped, and decompressed bytes are
/// counted as they stream out — extraction aborts the moment a per-file
/// or running total limit is crossed, never trusting the zip header's
/// declared size. The archive must contain a root-level `index.html` and
/// at least one file.
///
/// # Examples
///
/// ```
/// use pages::core::upload::{build_manifest, UploadKind, UploadLimits};
///
/// let limits = UploadLimits::default();
/// let manifest = build_manifest(
/// UploadKind::Html,
/// b"<html>hello</html>".to_vec(),
/// &limits,
/// )
/// .unwrap();
/// assert_eq!(manifest.len(), 1);
/// assert_eq!(manifest[0].path, "index.html");
/// ```
pub fn build_manifest(
kind: UploadKind,
bytes: Vec<u8>,
limits: &UploadLimits,
) -> Result<Vec<ManifestEntry>, UploadError> {
if bytes.len() > limits.max_compressed {
return Err(UploadError::CompressedTooLarge {
max: limits.max_compressed,
});
}
match kind {
UploadKind::Html => build_html_manifest(bytes, limits),
UploadKind::Zip => build_zip_manifest(bytes, limits),
}
}
/// Builds the single-entry manifest for an [`UploadKind::Html`] upload.
fn build_html_manifest(
bytes: Vec<u8>,
limits: &UploadLimits,
) -> Result<Vec<ManifestEntry>, UploadError> {
if bytes.is_empty() {
return Err(UploadError::Empty);
}
let len = bytes.len() as u64;
if len > limits.max_file {
return Err(UploadError::FileTooLarge {
path: "index.html".to_string(),
max: limits.max_file,
});
}
if len > limits.max_uncompressed_total {
return Err(UploadError::TotalTooLarge {
max: limits.max_uncompressed_total,
});
}
Ok(vec![ManifestEntry {
path: "index.html".to_string(),
bytes,
}])
}
/// Builds the manifest for an [`UploadKind::Zip`] upload.
fn build_zip_manifest(
bytes: Vec<u8>,
limits: &UploadLimits,
) -> Result<Vec<ManifestEntry>, UploadError> {
let mut archive =
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|_| UploadError::InvalidZip)?;
if archive.is_empty() {
return Err(UploadError::Empty);
}
if archive.len() > limits.max_entries {
return Err(UploadError::TooManyEntries {
max: limits.max_entries,
});
}
let mut manifest = Vec::new();
// Defense-in-depth against duplicate entry names (design §2.2). Note:
// `zip::ZipArchive` stores parsed entries in a name-keyed map
// internally, so `archive.by_index` can never actually yield two
// entries with the same literal name against the crate version this
// module depends on — see
// `zip_duplicate_named_entries_are_deduplicated_by_the_zip_crate_itself`
// in this module's tests for the full explanation. This check stays
// as a cheap guard in case that changes.
let mut seen: HashSet<String> = HashSet::new();
let mut total_used: u64 = 0;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|_| UploadError::InvalidZip)?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
check_entry_path(&name, limits)?;
if !seen.insert(name.clone()) {
return Err(UploadError::DuplicateEntry { path: name });
}
let data = read_entry_limited(&mut entry, &name, limits, &mut total_used)?;
manifest.push(ManifestEntry {
path: name,
bytes: data,
});
}
if manifest.is_empty() {
return Err(UploadError::Empty);
}
if !manifest.iter().any(|e| e.path == "index.html") {
return Err(UploadError::MissingIndexHtml);
}
Ok(manifest)
}
/// Validates a raw zip entry name against the path-traversal and
/// length/depth rules from design §2.2.
///
/// Rejects: empty names, names over `max_path_len`, names containing a
/// backslash or NUL byte, names starting with `/` (absolute), names
/// starting with a drive letter (`C:...`), and any `.` or `..` or empty
/// path segment. Also enforces `max_depth` on the segment count.
fn check_entry_path(name: &str, limits: &UploadLimits) -> Result<(), UploadError> {
if name.is_empty() {
return Err(UploadError::PathTraversal {
path: name.to_string(),
});
}
if name.len() > limits.max_path_len {
return Err(UploadError::PathTooLong {
path: name.to_string(),
max: limits.max_path_len,
});
}
if name.contains('\\') || name.contains('\0') {
return Err(UploadError::PathTraversal {
path: name.to_string(),
});
}
if name.starts_with('/') {
return Err(UploadError::PathTraversal {
path: name.to_string(),
});
}
// Windows drive letter, e.g. "C:foo" or "c:/foo".
let bytes = name.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
return Err(UploadError::PathTraversal {
path: name.to_string(),
});
}
let segments: Vec<&str> = name.split('/').collect();
if segments.len() > limits.max_depth {
return Err(UploadError::PathTooDeep {
path: name.to_string(),
max: limits.max_depth,
});
}
for seg in &segments {
if seg.is_empty() || *seg == "." || *seg == ".." {
return Err(UploadError::PathTraversal {
path: name.to_string(),
});
}
}
Ok(())
}
/// Chunk size used by [`read_entry_limited`]'s streaming read loop.
const READ_CHUNK: usize = 64 * 1024;
/// Reads a zip entry's decompressed content, enforcing `limits.max_file`
/// and `limits.max_uncompressed_total` **during** the read rather than
/// after — the read loop aborts as soon as either limit is crossed, so a
/// high-compression-ratio "zip bomb" entry is rejected after at most
/// `READ_CHUNK` extra bytes are decompressed, never after the full
/// (potentially huge) claimed uncompressed size.
fn read_entry_limited<R: Read>(
reader: &mut R,
path: &str,
limits: &UploadLimits,
total_used: &mut u64,
) -> Result<Vec<u8>, UploadError> {
let mut buf = Vec::new();
let mut chunk = [0u8; READ_CHUNK];
loop {
let n = reader
.read(&mut chunk)
.map_err(|_| UploadError::InvalidZip)?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.len() as u64 > limits.max_file {
return Err(UploadError::FileTooLarge {
path: path.to_string(),
max: limits.max_file,
});
}
*total_used += n as u64;
if *total_used > limits.max_uncompressed_total {
return Err(UploadError::TotalTooLarge {
max: limits.max_uncompressed_total,
});
}
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
/// Builds a zip archive in memory from `(name, content)` pairs, using
/// deflate compression (the only method compiled in, matching
/// production's `Cargo.toml`).
fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = Cursor::new(Vec::new());
{
let mut writer = ZipWriter::new(&mut buf);
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (name, content) in entries {
writer.start_file(*name, options).unwrap();
writer.write_all(content).unwrap();
}
writer.finish().unwrap();
}
buf.into_inner()
}
/// Builds a zip archive containing a highly-compressible single file
/// (all zero bytes) of `uncompressed_len` bytes — a small "zip bomb"
/// fixture used to test the streaming size guard.
fn make_bomb_zip(name: &str, uncompressed_len: usize) -> Vec<u8> {
make_zip(&[(name, &vec![0u8; uncompressed_len])])
}
fn limits() -> UploadLimits {
UploadLimits::default()
}
/// Minimal, hand-rolled CRC-32 (the standard zip/PNG polynomial) — used
/// only by [`make_zip_raw`], which builds a zip archive by hand rather
/// than through `ZipWriter`.
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
/// Hand-builds a raw (stored/uncompressed) zip archive from arbitrary
/// `(name, content)` entries, assembling the zip byte format directly
/// instead of going through `ZipWriter`.
///
/// Needed because `zip::write::ZipWriter` enforces filename uniqueness
/// itself (`start_file` returns `InvalidArchive("Duplicate filename")`
/// for a repeated name), so it cannot be used to build a fixture with
/// two entries sharing a name — see
/// `zip_duplicate_named_entries_are_deduplicated_by_the_zip_crate_itself`
/// below for why that fixture is still useful.
fn make_zip_raw(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, content) in entries {
let name_bytes = name.as_bytes();
let offset = out.len() as u32;
let crc = crc32(content);
let len = content.len() as u32;
// Local file header (stored, no data descriptor).
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(&0u16.to_le_bytes()); // compression: stored
out.extend_from_slice(&0u16.to_le_bytes()); // mod time
out.extend_from_slice(&0u16.to_le_bytes()); // mod date
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&len.to_le_bytes()); // compressed size
out.extend_from_slice(&len.to_le_bytes()); // uncompressed size
out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // extra field len
out.extend_from_slice(name_bytes);
out.extend_from_slice(content);
// Matching central directory header.
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
central.extend_from_slice(&20u16.to_le_bytes()); // version made by
central.extend_from_slice(&20u16.to_le_bytes()); // version needed
central.extend_from_slice(&0u16.to_le_bytes()); // flags
central.extend_from_slice(&0u16.to_le_bytes()); // compression: stored
central.extend_from_slice(&0u16.to_le_bytes()); // mod time
central.extend_from_slice(&0u16.to_le_bytes()); // mod date
central.extend_from_slice(&crc.to_le_bytes());
central.extend_from_slice(&len.to_le_bytes());
central.extend_from_slice(&len.to_le_bytes());
central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
central.extend_from_slice(&0u16.to_le_bytes()); // extra field len
central.extend_from_slice(&0u16.to_le_bytes()); // comment len
central.extend_from_slice(&0u16.to_le_bytes()); // disk number start
central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
central.extend_from_slice(&offset.to_le_bytes());
central.extend_from_slice(name_bytes);
}
let cd_offset = out.len() as u32;
let cd_size = central.len() as u32;
out.extend_from_slice(&central);
// End of central directory record.
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // this disk
out.extend_from_slice(&0u16.to_le_bytes()); // disk with cd start
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_offset.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // comment len
out
}
// ── html happy path ─────────────────────────────────────────────────
#[test]
fn html_happy_path() {
let manifest =
build_manifest(UploadKind::Html, b"<html></html>".to_vec(), &limits()).unwrap();
assert_eq!(manifest.len(), 1);
assert_eq!(manifest[0].path, "index.html");
assert_eq!(manifest[0].bytes, b"<html></html>");
}
#[test]
fn html_empty_is_rejected() {
let err = build_manifest(UploadKind::Html, Vec::new(), &limits()).unwrap_err();
assert_eq!(err, UploadError::Empty);
}
#[test]
fn html_over_per_file_limit_rejected() {
let small_limits = UploadLimits {
max_file: 10,
..limits()
};
let err = build_manifest(UploadKind::Html, vec![b'a'; 11], &small_limits).unwrap_err();
assert!(matches!(err, UploadError::FileTooLarge { .. }));
}
#[test]
fn oversize_compressed_input_rejected_for_html() {
let small_limits = UploadLimits {
max_compressed: 10,
..limits()
};
let err = build_manifest(UploadKind::Html, vec![b'a'; 11], &small_limits).unwrap_err();
assert_eq!(err, UploadError::CompressedTooLarge { max: 10 });
}
// ── zip happy path ───────────────────────────────────────────────────
#[test]
fn zip_happy_path_multi_file_with_subdirs() {
let zip = make_zip(&[
("index.html", b"<html></html>"),
("assets/app.js", b"console.log(1)"),
("assets/img/logo.png", b"\x89PNG"),
]);
let manifest = build_manifest(UploadKind::Zip, zip, &limits()).unwrap();
let mut paths: Vec<&str> = manifest.iter().map(|e| e.path.as_str()).collect();
paths.sort();
assert_eq!(
paths,
["assets/app.js", "assets/img/logo.png", "index.html"]
);
}
#[test]
fn zip_directory_entries_are_skipped() {
// ZipWriter::add_directory creates an explicit directory entry.
let mut buf = Cursor::new(Vec::new());
{
let mut writer = ZipWriter::new(&mut buf);
let options = SimpleFileOptions::default();
writer.add_directory("assets/", options).unwrap();
writer.start_file("index.html", options).unwrap();
writer.write_all(b"hi").unwrap();
writer.start_file("assets/app.js", options).unwrap();
writer.write_all(b"x").unwrap();
writer.finish().unwrap();
}
let manifest = build_manifest(UploadKind::Zip, buf.into_inner(), &limits()).unwrap();
assert_eq!(manifest.len(), 2);
assert!(manifest.iter().all(|e| !e.path.ends_with('/')));
}
// ── path traversal ──────────────────────────────────────────────────
#[test]
fn zip_rejects_dotdot_traversal() {
let zip = make_zip(&[("index.html", b"hi"), ("../evil.txt", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_dotdot_mid_path_traversal() {
let zip = make_zip(&[("index.html", b"hi"), ("assets/../../evil.txt", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_absolute_path() {
let zip = make_zip(&[("index.html", b"hi"), ("/etc/passwd", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_backslash_separator() {
let zip = make_zip(&[("index.html", b"hi"), ("assets\\evil.js", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_drive_letter() {
let zip = make_zip(&[("index.html", b"hi"), ("C:evil.txt", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_empty_path_segment() {
let zip = make_zip(&[("index.html", b"hi"), ("assets//evil.js", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_nul_byte() {
let zip = make_zip(&[("index.html", b"hi"), ("evil\0.js", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_path_too_long() {
let long_name = format!("{}.txt", "a".repeat(200));
let zip = make_zip(&[("index.html", b"hi"), (&long_name, b"x")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTooLong { .. }));
}
#[test]
fn zip_accepts_path_at_exact_max_length() {
// A path of exactly max_path_len (180) bytes must be accepted —
// guards against a future off-by-one tightening (`>=` vs `>`).
let max = limits().max_path_len;
let name = format!("{}.txt", "a".repeat(max - 4));
assert_eq!(name.len(), max);
let zip = make_zip(&[("index.html", b"hi"), (&name, b"x")]);
let manifest = build_manifest(UploadKind::Zip, zip, &limits()).unwrap();
assert!(manifest.iter().any(|e| e.path == name));
}
#[test]
fn zip_rejects_path_too_deep() {
let deep_name = format!("{}x.txt", "a/".repeat(11));
let zip = make_zip(&[("index.html", b"hi"), (&deep_name, b"x")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTooDeep { .. }));
}
#[test]
fn zip_accepts_path_at_exact_max_depth() {
// A path with exactly max_depth (10) `/`-separated segments
// (including the filename) must be accepted — guards against a
// future off-by-one tightening (`>=` vs `>`).
let max = limits().max_depth;
let name = format!("{}x.txt", "a/".repeat(max - 1));
assert_eq!(name.split('/').count(), max);
let zip = make_zip(&[("index.html", b"hi"), (&name, b"x")]);
let manifest = build_manifest(UploadKind::Zip, zip, &limits()).unwrap();
assert!(manifest.iter().any(|e| e.path == name));
}
// ── duplicates / structural ─────────────────────────────────────────
#[test]
fn zip_duplicate_named_entries_are_deduplicated_by_the_zip_crate_itself() {
// `zip` 2.x parses central-directory entries into an
// `IndexMap<Box<str>, ZipFileData>` keyed by name (see
// `zip::read::Shared` in the vendored crate source), so it is
// structurally impossible for `ZipArchive::by_index` to expose two
// entries with the exact same literal name — a repeated name just
// overwrites the earlier entry's metadata (last write wins),
// collapsing the archive to one entry per unique name.
//
// `build_manifest`'s own `seen`/`DuplicateEntry` check (see
// `build_zip_manifest` and `UploadError::DuplicateEntry`) is
// consequently dead code against this crate version: it can never
// observe two entries sharing a name through a real `ZipArchive`.
// It is kept anyway as free defense-in-depth in case a future
// `zip` upgrade changes that internal representation. This test
// pins down the crate's current dedup/last-write-wins behavior so
// such a change doesn't silently reopen the gap without a failing
// test to flag it — if this test starts failing, `DuplicateEntry`
// has become reachable and deserves its own direct test.
let zip = make_zip_raw(&[
("index.html", b"hi"),
("dup.txt", b"one"),
("dup.txt", b"two"),
]);
let archive = zip::ZipArchive::new(Cursor::new(zip.clone())).unwrap();
assert_eq!(
archive.len(),
2,
"expected the two dup.txt entries to collapse into one"
);
let manifest = build_manifest(UploadKind::Zip, zip, &limits()).unwrap();
assert_eq!(manifest.len(), 2);
let dup = manifest.iter().find(|e| e.path == "dup.txt").unwrap();
assert_eq!(
dup.bytes, b"two",
"last write should win for a deduplicated name"
);
}
#[test]
fn zip_rejects_missing_index_html() {
let zip = make_zip(&[("assets/app.js", b"x")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert_eq!(err, UploadError::MissingIndexHtml);
}
#[test]
fn zip_rejects_nested_index_html_only() {
// A nested index.html doesn't satisfy the root-level requirement.
let zip = make_zip(&[("sub/index.html", b"hi")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert_eq!(err, UploadError::MissingIndexHtml);
}
#[test]
fn zip_rejects_empty_archive() {
let zip = make_zip(&[]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert_eq!(err, UploadError::Empty);
}
#[test]
fn zip_rejects_archive_of_only_directories() {
let mut buf = Cursor::new(Vec::new());
{
let mut writer = ZipWriter::new(&mut buf);
writer
.add_directory("empty/", SimpleFileOptions::default())
.unwrap();
writer.finish().unwrap();
}
let err = build_manifest(UploadKind::Zip, buf.into_inner(), &limits()).unwrap_err();
assert_eq!(err, UploadError::Empty);
}
#[test]
fn invalid_zip_bytes_rejected() {
let err =
build_manifest(UploadKind::Zip, b"not a zip file".to_vec(), &limits()).unwrap_err();
assert_eq!(err, UploadError::InvalidZip);
}
// ── limits ───────────────────────────────────────────────────────────
#[test]
fn zip_rejects_too_many_entries() {
let small_limits = UploadLimits {
max_entries: 3,
..limits()
};
let zip = make_zip(&[
("index.html", b"hi"),
("a.txt", b"1"),
("b.txt", b"2"),
("c.txt", b"3"),
]);
let err = build_manifest(UploadKind::Zip, zip, &small_limits).unwrap_err();
assert_eq!(err, UploadError::TooManyEntries { max: 3 });
}
#[test]
fn zip_rejects_oversize_compressed_input() {
let zip = make_zip(&[("index.html", b"hi")]);
let small_limits = UploadLimits {
max_compressed: 4,
..limits()
};
let err = build_manifest(UploadKind::Zip, zip, &small_limits).unwrap_err();
assert_eq!(err, UploadError::CompressedTooLarge { max: 4 });
}
#[test]
fn zip_rejects_file_over_per_file_limit() {
let zip = make_zip(&[("index.html", b"hi"), ("big.bin", &[1u8; 100])]);
let small_limits = UploadLimits {
max_file: 50,
..limits()
};
let err = build_manifest(UploadKind::Zip, zip, &small_limits).unwrap_err();
assert!(matches!(err, UploadError::FileTooLarge { .. }));
}
#[test]
fn zip_rejects_total_over_limit_across_multiple_files() {
let zip = make_zip(&[
("index.html", &[1u8; 40]),
("a.bin", &[1u8; 40]),
("b.bin", &[1u8; 40]),
]);
let small_limits = UploadLimits {
max_file: 1000,
max_uncompressed_total: 100,
..limits()
};
let err = build_manifest(UploadKind::Zip, zip, &small_limits).unwrap_err();
assert!(matches!(err, UploadError::TotalTooLarge { .. }));
}
/// The headline zip-bomb test: a single entry whose *declared*
/// uncompressed size (10 MiB of zeros) is well within the default
/// 25 MiB total / 5 MiB per-file limits on paper, but a much smaller
/// per-file limit here proves the streaming guard aborts extraction
/// before the whole entry is decompressed — deflate compresses runs of
/// zeros to a tiny fraction of their size, so if we trusted the zip
/// header instead of counting streamed bytes, this would sail through.
#[test]
fn zip_bomb_rejected_by_streaming_guard_before_full_materialization() {
let bomb = make_bomb_zip("index.html", 10 * 1024 * 1024);
// Compressed size should be tiny relative to the claimed uncompressed size.
assert!(
bomb.len() < 100 * 1024,
"fixture not actually a high compression ratio: {} bytes",
bomb.len()
);
let tight_limits = UploadLimits {
max_file: 1024, // far smaller than the 10 MiB decompressed content
..limits()
};
let err = build_manifest(UploadKind::Zip, bomb, &tight_limits).unwrap_err();
assert!(matches!(err, UploadError::FileTooLarge { .. }));
}
#[test]
fn zip_bomb_rejected_against_default_limits() {
// 100 MiB of zeros compresses to a few KiB with deflate, but
// decompressing it fully would blow the default 25 MiB total /
// 5 MiB per-file limits — the streaming guard must catch this.
let bomb = make_bomb_zip("index.html", 100 * 1024 * 1024);
assert!(bomb.len() < 1024 * 1024);
let err = build_manifest(UploadKind::Zip, bomb, &limits()).unwrap_err();
assert!(matches!(
err,
UploadError::FileTooLarge { .. } | UploadError::TotalTooLarge { .. }
));
}
}