//! 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 (design §2.2, raised to accommodate large wasm /// binaries): 24 MiB max compressed upload, 48 MiB max total uncompressed, /// 32 MiB max per file, 300 max entries, 180-byte max path length, /// depth 10. Worst-case memory is bounded by `max_compressed` (the /// buffered body) plus `max_uncompressed_total` (the decompressed /// manifest), which stays well under the Worker's ~128 MiB isolate /// ceiling; `max_compressed` must stay in lockstep with /// [`MAX_UPLOAD_BYTES`](crate::routes::api::MAX_UPLOAD_BYTES). fn default() -> Self { const MIB: u64 = 1024 * 1024; Self { max_compressed: (24 * MIB) as usize, max_uncompressed_total: 48 * MIB, max_file: 32 * MIB, max_entries: 300, max_path_len: 180, max_depth: 10, } } } /// The compression wrapper around a tar archive, for the /// [`UploadKind::Tar`] upload formats. /// /// Each variant names a **pure-Rust** decompressor (no `*-sys` C bindings, /// no threads) so it compiles and runs in the single-threaded Worker: gzip /// via `flate2`'s `miniz_oxide` backend, zstd via `ruzstd`, xz via /// `lzma-rs`. bzip2 is deliberately unsupported. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Compression { /// gzip-compressed tar (`.tar.gz` / `.tgz`), magic `1f 8b`. Gzip, /// zstd-compressed tar (`.tar.zst`), magic `28 b5 2f fd`. Zstd, /// xz-compressed tar (`.tar.xz`), magic `fd 37 7a 58 5a 00`. Xz, } impl Compression { /// A short, stable label used in decompression-failure error messages /// (e.g. `"gzip"`), so a `400` body names the format that failed. fn label(self) -> &'static str { match self { Compression::Gzip => "gzip", Compression::Zstd => "zstd", Compression::Xz => "xz", } } } /// 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, /// A compressed tar archive of static assets. The inner tar is parsed /// with the exact same structural and size rules as [`UploadKind::Zip`] /// (see [`build_manifest`]); only the outer decompression differs. Tar(Compression), } /// 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 decompressed bytes could not be parsed as a tar archive at all. #[error("upload could not be read as a valid tar archive")] InvalidTar, /// The outer compression wrapper (gzip/zstd/xz) failed to decompress — /// either the stream was corrupt, or (for xz) it produced more output /// than the total-size guard allows and was aborted mid-decompress. #[error("upload could not be decompressed as a {format} archive")] DecompressionFailed { /// The compression format that failed (`"gzip"`, `"zstd"`, `"xz"`). format: &'static str, }, /// 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. /// - [`UploadKind::Tar`]: `bytes` is decompressed (gzip/zstd/xz) and the /// inner tar parsed with the **exact same** structural and size rules as /// the zip path — see [`build_tar_manifest`]. The streaming size guard is /// preserved end-to-end: gzip and zstd decode lazily as each tar entry is /// read, and xz (which decodes one-shot) is bounded by a [`LimitedWriter`] /// so an xz bomb aborts mid-decompress rather than fully materializing. /// /// # 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), UploadKind::Tar(compression) => build_tar_manifest(bytes, compression, limits), } } /// Sniffs the [`UploadKind`] of a buffered upload body from its leading /// **magic bytes**, falling back to [`UploadKind::Html`] when no archive /// signature matches. /// /// This deliberately ignores the client-declared `Content-Type` for the /// archive formats — it matches this module's "never trust the archive" /// posture, and prevents a mislabelled body (a zip sent as `text/html`, or /// vice versa) from being fed to the wrong parser. `content_type_hint` is /// retained only as documentation of the caller's declared type; because raw /// HTML has no magic signature, the no-magic case must always fall through to /// `Html` regardless of the hint (an upload is never rejected merely for an /// absent or unexpected `Content-Type`). /// /// Recognised signatures: /// - `50 4b` (`PK`) → [`UploadKind::Zip`] /// - `1f 8b` → [`UploadKind::Tar`]`(`[`Compression::Gzip`]`)` /// - `28 b5 2f fd` → [`UploadKind::Tar`]`(`[`Compression::Zstd`]`)` /// - `fd 37 7a 58 5a 00` → [`UploadKind::Tar`]`(`[`Compression::Xz`]`)` /// - anything else → [`UploadKind::Html`] /// /// # Examples /// /// ``` /// use pages::core::upload::{detect_kind, Compression, UploadKind}; /// /// assert_eq!(detect_kind(b"PK\x03\x04", ""), UploadKind::Zip); /// assert_eq!(detect_kind(b"\x1f\x8b\x08", ""), UploadKind::Tar(Compression::Gzip)); /// assert_eq!(detect_kind(b"", "text/html"), UploadKind::Html); /// ``` pub fn detect_kind(bytes: &[u8], content_type_hint: &str) -> UploadKind { // The hint plays no part in archive detection (magic bytes are // authoritative); it is accepted so callers can pass it through and so a // future tiebreaker for the no-magic case has a home without a signature // change. Reference it to keep that intent explicit. let _ = content_type_hint; if bytes.starts_with(b"PK") { UploadKind::Zip } else if bytes.starts_with(&[0x1f, 0x8b]) { UploadKind::Tar(Compression::Gzip) } else if bytes.starts_with(&[0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]) { UploadKind::Tar(Compression::Xz) } else if bytes.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]) { UploadKind::Tar(Compression::Zstd) } else { // Raw HTML (and anything else without an archive signature). Never a // rejection here — the manifest builder enforces emptiness/size. UploadKind::Html } } /// 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; } // `check_entry_path` returns the normalized path (`.` segments // stripped) — that normalized form is what we store and dedup on. let name = check_entry_path(entry.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, &UploadError::InvalidZip, )?; 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) } /// Builds the manifest for an [`UploadKind::Tar`] upload: decompresses the /// outer wrapper, then parses the inner tar with the same rules as /// [`build_zip_manifest`]. /// /// The decompressor choice is the *only* thing that varies by /// [`Compression`]: /// /// - **gzip / zstd** are streaming `Read` decoders. We wrap the raw bytes in /// the decoder and hand that straight to the tar reader, so decompression /// is lazy: each tar entry's bytes are only produced as /// [`read_entry_limited`] pulls them, and the read aborts the instant a /// per-file or running-total limit is crossed — a compression bomb is /// caught after at most one extra chunk, never fully materialized. /// - **xz** (via `lzma-rs`) is one-shot: it can only decompress the *whole* /// stream at once. To keep the same bomb guarantee we decompress into a /// [`LimitedWriter`], which returns an error the moment cumulative output /// crosses the materialization cap ([`xz_materialize_cap`]) — so an xz /// bomb aborts mid-decompress. The bounded buffer is then parsed as tar, /// whose per-entry reads still flow through [`read_entry_limited`]. fn build_tar_manifest( bytes: Vec, compression: Compression, limits: &UploadLimits, ) -> Result, UploadError> { match compression { Compression::Gzip => { let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes)); read_tar_entries(decoder, limits) } Compression::Zstd => { // `StreamingDecoder::new` reads and validates the frame header up // front; a body that isn't a zstd frame fails here. let decoder = ruzstd::decoding::StreamingDecoder::new(Cursor::new(bytes)).map_err(|_| { UploadError::DecompressionFailed { format: Compression::Zstd.label(), } })?; read_tar_entries(decoder, limits) } Compression::Xz => { // One-shot decode into a size-bounded sink so an xz bomb can't // fully materialize before the tar-level guards ever run. let mut input = Cursor::new(bytes); let mut writer = LimitedWriter::new(xz_materialize_cap(limits)); lzma_rs::xz_decompress(&mut input, &mut writer).map_err(|_| { UploadError::DecompressionFailed { format: Compression::Xz.label(), } })?; read_tar_entries(Cursor::new(writer.into_inner()), limits) } } } /// Iterates the entries of a (already-decompressed) tar stream, applying the /// identical structural and size rules as [`build_zip_manifest`]: directory /// and non-regular-file entries are skipped, every file entry's name is /// path-checked ([`check_entry_path`]), duplicates are rejected, the total /// entry count is capped, decompressed bytes are counted as they stream out /// ([`read_entry_limited`]), and the result must contain a root-level /// `index.html` and at least one file. /// /// `reader` is generic so the caller can pass either a live streaming /// decoder (gzip/zstd — decompression happens lazily as entries are read) or /// a `Cursor` over an already-bounded buffer (xz). fn read_tar_entries( reader: R, limits: &UploadLimits, ) -> Result, UploadError> { let mut archive = tar::Archive::new(reader); let entries = archive.entries().map_err(|_| UploadError::InvalidTar)?; let mut manifest = Vec::new(); // Mirror `build_zip_manifest`'s duplicate guard. Unlike the zip crate, // `tar` performs no name-based deduplication, so with a hand-crafted // archive this check is genuinely reachable. let mut seen: HashSet = HashSet::new(); let mut total_used: u64 = 0; // The zip path caps `archive.len()` (all entries, directories included) // up front; tar has no up-front count, so we cap it as we go, counting // every entry (skipped ones too) and bailing the moment the cap is // crossed — an archive with a huge entry count is rejected after reading // only `max_entries + 1` headers. let mut entry_count: usize = 0; for entry in entries { let mut entry = entry.map_err(|_| UploadError::InvalidTar)?; entry_count += 1; if entry_count > limits.max_entries { return Err(UploadError::TooManyEntries { max: limits.max_entries, }); } // Skip anything that isn't a regular file: directories, symlinks, // hardlinks, devices, and tar's PAX/GNU metadata pseudo-entries. Only // regular files become R2 objects (matching the zip path's // `is_dir()` skip, but stricter — tar has more entry types). if !entry.header().entry_type().is_file() { continue; } // Use the raw path *bytes* and require valid UTF-8, rather than // `entry.path()` (which lossily maps invalid sequences and can apply // OS-specific separator rewriting) — `check_entry_path` must see the // exact stored name to make its traversal decision. let name_bytes = entry.path_bytes(); let raw_name = match std::str::from_utf8(&name_bytes) { Ok(s) => s, Err(_) => { return Err(UploadError::PathTraversal { path: String::from_utf8_lossy(&name_bytes).into_owned(), }) } }; // `check_entry_path` returns the normalized path (`.` segments // stripped) — GNU tar's `./`-prefixed names normalize to their plain // form, which is what we store and dedup on. let name = check_entry_path(raw_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, &UploadError::InvalidTar, )?; 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) } /// The maximum number of decompressed bytes the one-shot xz path will /// materialize before aborting: `max_uncompressed_total` plus a bounded /// allowance for tar framing (a 512-byte header and up to ~512 bytes of /// content padding per entry, plus the two-block zero trailer). /// /// Sizing the allowance off `max_entries` guarantees that an honest archive /// whose *file contents* fit within `max_uncompressed_total` never trips this /// materialization cap on framing overhead alone — its precise per-file and /// total limits are still enforced afterwards by [`read_entry_limited`]. A /// genuine xz bomb, whose output dwarfs this bound, is aborted here before it /// can fill memory. fn xz_materialize_cap(limits: &UploadLimits) -> u64 { let framing = (limits.max_entries as u64) .saturating_add(2) .saturating_mul(1024); limits.max_uncompressed_total.saturating_add(framing) } /// A [`std::io::Write`] sink that buffers bytes in memory but refuses to grow /// past `limit`, returning an [`std::io::Error`] on the write that would /// cross it. /// /// Used only by the one-shot xz path in [`build_tar_manifest`]: because /// `lzma_rs::xz_decompress` decodes the whole stream in a single call, this /// is the mechanism that stops an xz decompression bomb from fully /// materializing — the error it returns aborts `xz_decompress` mid-stream, /// exactly as [`read_entry_limited`]'s early return does for the streaming /// gzip/zstd formats. struct LimitedWriter { /// The bytes accumulated so far (never exceeds `limit`). buf: Vec, /// The hard cap on total buffered bytes. limit: u64, /// Running count of bytes accepted so far. written: u64, } impl LimitedWriter { /// Creates a sink that will accept at most `limit` total bytes. fn new(limit: u64) -> Self { Self { buf: Vec::new(), limit, written: 0, } } /// Consumes the writer, returning the buffered bytes. fn into_inner(self) -> Vec { self.buf } } impl std::io::Write for LimitedWriter { fn write(&mut self, data: &[u8]) -> std::io::Result { self.written = self.written.saturating_add(data.len() as u64); if self.written > self.limit { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "decompressed output exceeded the allowed size", )); } self.buf.extend_from_slice(data); Ok(data.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } /// Validates a raw archive entry name against the path-traversal and /// length/depth rules from design §2.2, returning the **normalized** path to /// store as the R2 object key suffix. /// /// A `.` (current-directory) path segment is a no-op, so it is *normalized /// away* rather than rejected: `"./index.html"` becomes `"index.html"` and /// `"assets/./app.js"` becomes `"assets/app.js"`. This matters because GNU /// `tar` conventionally prefixes every entry with `./` when archiving a /// directory (`tar czf out.tgz .` yields `./index.html`, `./bin_bg.wasm`, /// …) — without this, such tarballs would be rejected outright and their /// `./index.html` would never satisfy the root-`index.html` requirement. /// Stripping `.` is safe: unlike `..`, it never escapes the page root. /// /// Rejects: empty names, names over `max_path_len` (checked on the raw name), /// names containing a backslash or NUL byte, names starting with `/` /// (absolute), names starting with a drive letter (`C:...`), any `..` or /// empty (`//`) path segment, and a name that normalizes to nothing (e.g. /// `"."` or `"./"`). Enforces `max_depth` on the *normalized* segment count. fn check_entry_path(name: &str, limits: &UploadLimits) -> Result { 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(), }); } // Normalize: drop `.` segments (harmless current-directory no-ops); reject // `..` (traversal) and empty (`//`) segments. let mut normalized: Vec<&str> = Vec::new(); for seg in name.split('/') { if seg == "." { continue; } if seg.is_empty() || seg == ".." { return Err(UploadError::PathTraversal { path: name.to_string(), }); } normalized.push(seg); } // A name consisting only of `.`/`./` segments carries no real path. if normalized.is_empty() { return Err(UploadError::PathTraversal { path: name.to_string(), }); } if normalized.len() > limits.max_depth { return Err(UploadError::PathTooDeep { path: name.to_string(), max: limits.max_depth, }); } Ok(normalized.join("/")) } /// Chunk size used by [`read_entry_limited`]'s streaming read loop. const READ_CHUNK: usize = 64 * 1024; /// Reads an archive 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/tar bomb" entry is rejected after at most /// `READ_CHUNK` extra bytes are decompressed, never after the full /// (potentially huge) claimed uncompressed size. /// /// `read_error` is the [`UploadError`] returned if the underlying reader /// itself fails mid-stream — [`UploadError::InvalidZip`] for the zip path, /// [`UploadError::InvalidTar`] for the tar path (a corrupt gzip/zstd stream /// surfaces as a read error here and folds into the tar error, which is /// close enough for a `400` body). fn read_entry_limited( reader: &mut R, path: &str, limits: &UploadLimits, total_used: &mut u64, read_error: &UploadError, ) -> Result, UploadError> { let mut buf = Vec::new(); let mut chunk = [0u8; READ_CHUNK]; loop { let n = reader.read(&mut chunk).map_err(|_| read_error.clone())?; 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 { .. } )); } // ── compressed-tar fixtures ───────────────────────────────────────── // // Like `make_zip_raw`, the inner tar is assembled by hand (rather than // through `tar::Builder`) so fixtures can carry adversarial names the // builder would sanitize or reject — `../evil.txt`, absolute paths, // drive letters. The raw ustar bytes are then compressed with each // pure-Rust codec's *encoder* (`flate2`, `ruzstd`, `lzma-rs`), so no // binary fixtures and no C dev-dependency are needed. /// Writes `val` into a tar header numeric field as zero-padded octal /// followed by a trailing NUL (the ustar convention): the field holds /// `buf.len() - 1` octal digits plus the terminator. fn tar_octal(buf: &mut [u8], val: u64) { let digits = buf.len() - 1; let s = format!("{val:0digits$o}"); buf[..digits].copy_from_slice(s.as_bytes()); buf[digits] = 0; } /// Hand-builds an uncompressed ustar archive from `(name, content)` /// pairs. A trailing `/` in a name makes a directory entry (typeflag /// `5`, no body); everything else is a regular file (typeflag `0`). /// Each entry is a 512-byte header (with a correct checksum, which the /// `tar` reader validates) followed by the content padded to the next /// 512-byte boundary; the stream ends with two all-zero blocks. fn make_tar_raw(entries: &[(&str, &[u8])]) -> Vec { let mut out = Vec::new(); for (name, content) in entries { let mut header = [0u8; 512]; let name_bytes = name.as_bytes(); let n = name_bytes.len().min(100); header[0..n].copy_from_slice(&name_bytes[..n]); tar_octal(&mut header[100..108], 0o644); // mode tar_octal(&mut header[108..116], 0); // uid tar_octal(&mut header[116..124], 0); // gid tar_octal(&mut header[124..136], content.len() as u64); // size tar_octal(&mut header[136..148], 0); // mtime let is_dir = name.ends_with('/'); header[156] = if is_dir { b'5' } else { b'0' }; // typeflag header[257..263].copy_from_slice(b"ustar\0"); // magic header[263..265].copy_from_slice(b"00"); // version // Checksum: the field is treated as 8 spaces while the unsigned // byte sum of the whole header is computed, then written back as // 6 octal digits + NUL + space. for b in &mut header[148..156] { *b = b' '; } let sum: u32 = header.iter().map(|&b| b as u32).sum(); let cks = format!("{sum:06o}"); header[148..154].copy_from_slice(cks.as_bytes()); header[154] = 0; header[155] = b' '; out.extend_from_slice(&header); if !is_dir { out.extend_from_slice(content); let pad = (512 - (content.len() % 512)) % 512; out.extend(std::iter::repeat_n(0u8, pad)); } } out.extend(std::iter::repeat_n(0u8, 1024)); // two-block trailer out } /// gzip-compresses `data` with `flate2`'s writer. fn gzip(data: &[u8]) -> Vec { let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); enc.write_all(data).unwrap(); enc.finish().unwrap() } /// zstd-compresses `data` with `ruzstd`'s encoder (the `Fastest` level; /// higher levels are unimplemented in 0.8). fn zstd(data: &[u8]) -> Vec { ruzstd::encoding::compress_to_vec( Cursor::new(data), ruzstd::encoding::CompressionLevel::Fastest, ) } /// xz-compresses `data` with `lzma-rs`'s one-shot `xz_compress`. fn xz(data: &[u8]) -> Vec { let mut out = Vec::new(); lzma_rs::xz_compress(&mut Cursor::new(data), &mut out).unwrap(); out } /// Compresses raw tar bytes with the codec for `comp`. fn compress_for(comp: Compression, data: &[u8]) -> Vec { match comp { Compression::Gzip => gzip(data), Compression::Zstd => zstd(data), Compression::Xz => xz(data), } } /// Builds a compressed-tarball upload fixture: a hand-rolled ustar /// archive of `entries`, compressed with `comp`'s codec. fn make_tar(comp: Compression, entries: &[(&str, &[u8])]) -> Vec { compress_for(comp, &make_tar_raw(entries)) } // Shared test bodies, run once per format by the `#[test]` wrappers // below — this mirrors the zip coverage for gzip, zstd, and xz without // triplicating every assertion. /// Happy path: a multi-file archive with subdirectories and a root /// `index.html` yields exactly those files. fn check_tar_happy_path(comp: Compression) { let tar = make_tar( comp, &[ ("index.html", b""), ("assets/app.js", b"console.log(1)"), ("assets/img/logo.png", b"\x89PNG"), ], ); let manifest = build_manifest(UploadKind::Tar(comp), tar, &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"] ); } /// Directory entries are skipped, not stored as objects. fn check_tar_directory_entries_skipped(comp: Compression) { let tar = make_tar( comp, &[ ("assets/", b""), ("index.html", b"hi"), ("assets/app.js", b"x"), ], ); let manifest = build_manifest(UploadKind::Tar(comp), tar, &limits()).unwrap(); assert_eq!(manifest.len(), 2); assert!(manifest.iter().all(|e| !e.path.ends_with('/'))); } /// A `..` segment is rejected as path traversal. fn check_tar_rejects_traversal(comp: Compression) { let tar = make_tar(comp, &[("index.html", b"hi"), ("../evil.txt", b"pwn")]); let err = build_manifest(UploadKind::Tar(comp), tar, &limits()).unwrap_err(); assert!(matches!(err, UploadError::PathTraversal { .. })); } /// Missing root-level `index.html` is rejected. fn check_tar_rejects_missing_index(comp: Compression) { let tar = make_tar(comp, &[("assets/app.js", b"x")]); let err = build_manifest(UploadKind::Tar(comp), tar, &limits()).unwrap_err(); assert_eq!(err, UploadError::MissingIndexHtml); } /// A single file over the per-file limit is rejected by the streaming /// guard. fn check_tar_rejects_file_over_limit(comp: Compression) { let tar = make_tar(comp, &[("index.html", b"hi"), ("big.bin", &[1u8; 100])]); let small = UploadLimits { max_file: 50, ..limits() }; let err = build_manifest(UploadKind::Tar(comp), tar, &small).unwrap_err(); assert!(matches!(err, UploadError::FileTooLarge { .. })); } /// The total across files exceeding the total limit is rejected by the /// streaming guard (never by the xz materialization cap, which has /// framing headroom above `max_uncompressed_total`). fn check_tar_rejects_total_over_limit(comp: Compression) { let tar = make_tar( comp, &[ ("index.html", &[1u8; 40]), ("a.bin", &[1u8; 40]), ("b.bin", &[1u8; 40]), ], ); let small = UploadLimits { max_file: 1000, max_uncompressed_total: 100, ..limits() }; let err = build_manifest(UploadKind::Tar(comp), tar, &small).unwrap_err(); assert!(matches!(err, UploadError::TotalTooLarge { .. })); } /// GNU-tar-style `./`-prefixed entries (as produced by `tar czf out.tgz /// .`) are accepted and stored under their normalized, `./`-stripped /// paths — including a `.wasm` file, which is fully supported. The /// `./index.html` entry must satisfy the root-level `index.html` /// requirement, and an interior `assets/./app.js` normalizes too. fn check_tar_dot_slash_prefix_normalized(comp: Compression) { let tar = make_tar( comp, &[ ("./", b""), ("./index.html", b""), ("./bin_bg.wasm", b"\0asm"), ("assets/./app.js", b"x"), ], ); let manifest = build_manifest(UploadKind::Tar(comp), tar, &limits()).unwrap(); let mut paths: Vec<&str> = manifest.iter().map(|e| e.path.as_str()).collect(); paths.sort(); assert_eq!(paths, ["assets/app.js", "bin_bg.wasm", "index.html"]); } // ── gzip ───────────────────────────────────────────────────────────── #[test] fn targz_happy_path_multi_file_with_subdirs() { check_tar_happy_path(Compression::Gzip); } #[test] fn targz_directory_entries_are_skipped() { check_tar_directory_entries_skipped(Compression::Gzip); } #[test] fn targz_rejects_traversal() { check_tar_rejects_traversal(Compression::Gzip); } #[test] fn targz_rejects_missing_index_html() { check_tar_rejects_missing_index(Compression::Gzip); } #[test] fn targz_rejects_file_over_per_file_limit() { check_tar_rejects_file_over_limit(Compression::Gzip); } #[test] fn targz_rejects_total_over_limit() { check_tar_rejects_total_over_limit(Compression::Gzip); } #[test] fn targz_dot_slash_prefix_normalized() { check_tar_dot_slash_prefix_normalized(Compression::Gzip); } /// A gzip "bomb": a single highly-compressible entry whose declared /// (and real) uncompressed size dwarfs a tight per-file limit. Because /// gzip decodes lazily as the tar entry is read, the streaming guard /// aborts after one extra chunk — the full content is never /// materialized. #[test] fn targz_bomb_rejected_by_streaming_guard() { let tar = make_tar( Compression::Gzip, &[("index.html", &vec![0u8; 4 * 1024 * 1024])], ); assert!( tar.len() < 100 * 1024, "fixture not a high compression ratio: {} bytes", tar.len() ); let tight = UploadLimits { max_file: 1024, ..limits() }; let err = build_manifest(UploadKind::Tar(Compression::Gzip), tar, &tight).unwrap_err(); assert!(matches!(err, UploadError::FileTooLarge { .. })); } // ── zstd ───────────────────────────────────────────────────────────── #[test] fn tarzst_happy_path_multi_file_with_subdirs() { check_tar_happy_path(Compression::Zstd); } #[test] fn tarzst_directory_entries_are_skipped() { check_tar_directory_entries_skipped(Compression::Zstd); } #[test] fn tarzst_rejects_traversal() { check_tar_rejects_traversal(Compression::Zstd); } #[test] fn tarzst_rejects_missing_index_html() { check_tar_rejects_missing_index(Compression::Zstd); } #[test] fn tarzst_rejects_file_over_per_file_limit() { check_tar_rejects_file_over_limit(Compression::Zstd); } #[test] fn tarzst_rejects_total_over_limit() { check_tar_rejects_total_over_limit(Compression::Zstd); } #[test] fn tarzst_dot_slash_prefix_normalized() { check_tar_dot_slash_prefix_normalized(Compression::Zstd); } /// A zstd "bomb": as with gzip, `ruzstd`'s `StreamingDecoder` decodes /// lazily, so the streaming guard aborts the read long before the whole /// entry is decompressed. #[test] fn tarzst_bomb_rejected_by_streaming_guard() { let tar = make_tar( Compression::Zstd, &[("index.html", &vec![0u8; 4 * 1024 * 1024])], ); let tight = UploadLimits { max_file: 1024, ..limits() }; let err = build_manifest(UploadKind::Tar(Compression::Zstd), tar, &tight).unwrap_err(); assert!(matches!(err, UploadError::FileTooLarge { .. })); } // ── xz ─────────────────────────────────────────────────────────────── #[test] fn tarxz_happy_path_multi_file_with_subdirs() { check_tar_happy_path(Compression::Xz); } #[test] fn tarxz_directory_entries_are_skipped() { check_tar_directory_entries_skipped(Compression::Xz); } #[test] fn tarxz_rejects_traversal() { check_tar_rejects_traversal(Compression::Xz); } #[test] fn tarxz_rejects_missing_index_html() { check_tar_rejects_missing_index(Compression::Xz); } #[test] fn tarxz_rejects_file_over_per_file_limit() { check_tar_rejects_file_over_limit(Compression::Xz); } #[test] fn tarxz_rejects_total_over_limit() { check_tar_rejects_total_over_limit(Compression::Xz); } #[test] fn tarxz_dot_slash_prefix_normalized() { check_tar_dot_slash_prefix_normalized(Compression::Xz); } /// The xz "bomb" test, exercising the one-shot path's [`LimitedWriter`] /// specifically: xz can only decode the whole stream at once, so the /// guarantee is upheld by the writer aborting decompression once its /// materialization cap is crossed. With tight limits the cap is a few /// KiB, far below the multi-MiB decompressed output, so /// `xz_decompress` fails and the upload is rejected as /// `DecompressionFailed` — the full output is never buffered. #[test] fn tarxz_bomb_rejected_by_limited_writer() { let tar = make_tar( Compression::Xz, &[("index.html", &vec![0u8; 4 * 1024 * 1024])], ); let tight = UploadLimits { max_uncompressed_total: 4096, max_file: 4096, max_entries: 2, ..limits() }; // The materialization cap here is small (4 KiB + a bounded framing // allowance), well under the 4 MiB of decompressed output. assert!( xz_materialize_cap(&tight) < 4 * 1024 * 1024, "cap should be far below the bomb's output" ); let err = build_manifest(UploadKind::Tar(Compression::Xz), tar, &tight).unwrap_err(); assert_eq!(err, UploadError::DecompressionFailed { format: "xz" }); } // ── structural / decompression errors ─────────────────────────────── #[test] fn tar_rejects_empty_archive() { // A bare two-block trailer decompresses fine but contains no files. let tar = make_tar(Compression::Gzip, &[]); let err = build_manifest(UploadKind::Tar(Compression::Gzip), tar, &limits()).unwrap_err(); assert_eq!(err, UploadError::Empty); } #[test] fn tar_rejects_too_many_entries() { let small = UploadLimits { max_entries: 3, ..limits() }; let tar = make_tar( Compression::Gzip, &[ ("index.html", b"hi"), ("a.txt", b"1"), ("b.txt", b"2"), ("c.txt", b"3"), ], ); let err = build_manifest(UploadKind::Tar(Compression::Gzip), tar, &small).unwrap_err(); assert_eq!(err, UploadError::TooManyEntries { max: 3 }); } #[test] fn tar_rejects_duplicate_entries() { // Unlike zip, tar performs no name deduplication, so the // `DuplicateEntry` guard is genuinely reachable here. let tar = make_tar( Compression::Gzip, &[ ("index.html", b"hi"), ("dup.txt", b"one"), ("dup.txt", b"two"), ], ); let err = build_manifest(UploadKind::Tar(Compression::Gzip), tar, &limits()).unwrap_err(); assert!(matches!(err, UploadError::DuplicateEntry { .. })); } #[test] fn tar_rejects_path_too_long() { // ustar names cap at 100 bytes, so scale the limit down rather than // using an over-long name that wouldn't fit the header field. let small = UploadLimits { max_path_len: 20, ..limits() }; let long = format!("{}.txt", "a".repeat(30)); let tar = make_tar(Compression::Gzip, &[("index.html", b"hi"), (&long, b"x")]); let err = build_manifest(UploadKind::Tar(Compression::Gzip), tar, &small).unwrap_err(); assert!(matches!(err, UploadError::PathTooLong { .. })); } #[test] fn tar_rejects_absolute_path() { let tar = make_tar( Compression::Zstd, &[("index.html", b"hi"), ("/etc/passwd", b"pwn")], ); let err = build_manifest(UploadKind::Tar(Compression::Zstd), tar, &limits()).unwrap_err(); assert!(matches!(err, UploadError::PathTraversal { .. })); } #[test] fn tar_gzip_corrupt_stream_rejected() { // Valid gzip magic but a truncated/garbage body — the decoder errors // mid-read, which folds into `InvalidTar`. let mut bytes = gzip(&make_tar_raw(&[("index.html", b"hi")])); // Corrupt the compressed payload past the gzip header. for b in bytes.iter_mut().skip(12) { *b ^= 0xff; } let err = build_manifest(UploadKind::Tar(Compression::Gzip), bytes, &limits()).unwrap_err(); assert!(matches!( err, UploadError::InvalidTar | UploadError::DecompressionFailed { .. } )); } #[test] fn tar_zstd_invalid_frame_rejected() { // zstd magic but not a valid frame. Depending on where the garbage // trips the decoder, this surfaces either from // `StreamingDecoder::new` (→ `DecompressionFailed`) or mid-read while // the tar reader pulls bytes (→ `InvalidTar`); both are correct 400s. let bytes = vec![0x28, 0xb5, 0x2f, 0xfd, 0x00, 0x11, 0x22, 0x33]; let err = build_manifest(UploadKind::Tar(Compression::Zstd), bytes, &limits()).unwrap_err(); assert!(matches!( err, UploadError::DecompressionFailed { format: "zstd" } | UploadError::InvalidTar )); } // ── detect_kind (magic-byte sniffing) ─────────────────────────────── #[test] fn detect_kind_recognizes_zip_magic() { assert_eq!(detect_kind(b"PK\x03\x04rest", "text/html"), UploadKind::Zip); } #[test] fn detect_kind_recognizes_gzip_magic() { assert_eq!( detect_kind(&[0x1f, 0x8b, 0x08, 0x00], ""), UploadKind::Tar(Compression::Gzip) ); } #[test] fn detect_kind_recognizes_xz_magic() { assert_eq!( detect_kind(&[0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00, 0x00], ""), UploadKind::Tar(Compression::Xz) ); } #[test] fn detect_kind_recognizes_zstd_magic() { assert_eq!( detect_kind(&[0x28, 0xb5, 0x2f, 0xfd, 0x00], ""), UploadKind::Tar(Compression::Zstd) ); } #[test] fn detect_kind_falls_back_to_html_without_magic() { // Raw HTML has no signature: fall back to Html regardless of the // Content-Type hint (including an absent/empty one). assert_eq!( detect_kind(b"", "text/html"), UploadKind::Html ); assert_eq!(detect_kind(b"", ""), UploadKind::Html); assert_eq!(detect_kind(b"", "application/zip"), UploadKind::Html); } /// The magic-byte sniffer picks the kind that actually round-trips /// through `build_manifest` for a real fixture of each format. #[test] fn detect_kind_agrees_with_build_manifest_per_format() { for comp in [Compression::Gzip, Compression::Zstd, Compression::Xz] { let tar = make_tar(comp, &[("index.html", b"hi")]); assert_eq!(detect_kind(&tar, ""), UploadKind::Tar(comp)); build_manifest(detect_kind(&tar, ""), tar, &limits()).unwrap(); } let zip = make_zip(&[("index.html", b"hi")]); assert_eq!(detect_kind(&zip, ""), UploadKind::Zip); } // ── path normalization (check_entry_path) ─────────────────────────── #[test] fn check_entry_path_strips_leading_dot_slash() { assert_eq!( check_entry_path("./bin_bg.wasm", &limits()).unwrap(), "bin_bg.wasm" ); assert_eq!( check_entry_path("./index.html", &limits()).unwrap(), "index.html" ); } #[test] fn check_entry_path_strips_interior_dot_segments() { assert_eq!( check_entry_path("assets/./app.js", &limits()).unwrap(), "assets/app.js" ); assert_eq!( check_entry_path("./a/./b/./c.txt", &limits()).unwrap(), "a/b/c.txt" ); } #[test] fn check_entry_path_leaves_plain_paths_unchanged() { assert_eq!( check_entry_path("assets/app.js", &limits()).unwrap(), "assets/app.js" ); } #[test] fn check_entry_path_still_rejects_dotdot() { // `..` must never be normalized away — it escapes the page root. assert!(matches!( check_entry_path("../evil", &limits()), Err(UploadError::PathTraversal { .. }) )); assert!(matches!( check_entry_path("a/../b", &limits()), Err(UploadError::PathTraversal { .. }) )); } #[test] fn check_entry_path_rejects_names_that_normalize_to_nothing() { // "." / "./" carry no real path once the `.` segments are stripped. for name in [".", "./", "././"] { assert!( matches!( check_entry_path(name, &limits()), Err(UploadError::PathTraversal { .. }) ), "expected {name:?} to be rejected" ); } } #[test] fn check_entry_path_still_rejects_empty_segments() { assert!(matches!( check_entry_path("assets//evil.js", &limits()), Err(UploadError::PathTraversal { .. }) )); } #[test] fn check_entry_path_depth_measured_after_normalization() { // Leading `./` no-ops must not count against max_depth: a path whose // real depth is within the limit is accepted despite extra `.` // segments padding the raw split. let max = limits().max_depth; let real = format!("{}x.txt", "a/".repeat(max - 1)); // exactly max segments let padded = format!("./{real}"); assert_eq!(check_entry_path(&padded, &limits()).unwrap(), real); } }