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.

1854 lines
72 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 (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 `<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 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"<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),
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"<html>", "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<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;
}
// `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<u8>,
compression: Compression,
limits: &UploadLimits,
) -> Result<Vec<ManifestEntry>, 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<R: Read>(
reader: R,
limits: &UploadLimits,
) -> Result<Vec<ManifestEntry>, 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<String> = 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<u8>,
/// 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<u8> {
self.buf
}
}
impl std::io::Write for LimitedWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
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<String, 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(),
});
}
// 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<R: Read>(
reader: &mut R,
path: &str,
limits: &UploadLimits,
total_used: &mut u64,
read_error: &UploadError,
) -> Result<Vec<u8>, 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<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 { .. }
));
}
// ── 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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
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"<html></html>"),
("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"<html></html>"),
("./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"<!doctype html>", "text/html"),
UploadKind::Html
);
assert_eq!(detect_kind(b"<html></html>", ""), 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);
}
}