//! 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 `/`), `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, } /// 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"hello".to_vec(), /// &limits, /// ) /// .unwrap(); /// assert_eq!(manifest.len(), 1); /// assert_eq!(manifest[0].path, "index.html"); /// ``` pub fn build_manifest( kind: UploadKind, bytes: Vec, limits: &UploadLimits, ) -> Result, 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, limits: &UploadLimits, ) -> Result, 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, limits: &UploadLimits, ) -> Result, 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 = 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( reader: &mut R, path: &str, limits: &UploadLimits, total_used: &mut u64, ) -> Result, 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 { 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 { 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 { 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(¢ral); // 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"".to_vec(), &limits()).unwrap(); assert_eq!(manifest.len(), 1); assert_eq!(manifest[0].path, "index.html"); assert_eq!(manifest[0].bytes, b""); } #[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""), ("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, 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 { .. } )); } }