You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
865 lines
34 KiB
Rust
865 lines
34 KiB
Rust
//! Upload validation: turns raw uploaded bytes into a manifest of
|
|
//! `(path, bytes)` files ready to be written to R2, while defending against
|
|
//! malicious archives.
|
|
//!
|
|
//! See `docs/plans/2026-07-12-pages-design.md` §2.2. The zip-bomb defense in
|
|
//! particular is deliberately paranoid: entry sizes declared in a zip's
|
|
//! central directory are **never trusted**. Every byte is counted as it
|
|
//! streams out of the decompressor, and extraction aborts the instant a
|
|
//! per-file or total limit is crossed — so a small compressed file that
|
|
//! claims (or would produce) a huge uncompressed size is rejected long
|
|
//! before that size is ever reached, let alone materialized in memory.
|
|
|
|
use std::collections::HashSet;
|
|
use std::io::{Cursor, Read};
|
|
|
|
/// Configurable limits enforced by [`build_manifest`].
|
|
///
|
|
/// Fields are public so callers (tests, and eventually `routes::api`) can
|
|
/// construct scaled-down variants for testing or future configurability;
|
|
/// [`Default`] provides the production values from design §2.2.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct UploadLimits {
|
|
/// Maximum size of the raw uploaded bytes (compressed, for zip; as-is,
|
|
/// for html) in bytes. Checked against the buffered body before any
|
|
/// parsing happens.
|
|
pub max_compressed: usize,
|
|
/// Maximum total *uncompressed* size across all files in the archive,
|
|
/// in bytes. Enforced while streaming decompressed bytes, not from the
|
|
/// zip header.
|
|
pub max_uncompressed_total: u64,
|
|
/// Maximum *uncompressed* size of any single file, in bytes. Enforced
|
|
/// while streaming decompressed bytes, not from the zip header.
|
|
pub max_file: u64,
|
|
/// Maximum number of entries (including directories) a zip archive may
|
|
/// contain.
|
|
pub max_entries: usize,
|
|
/// Maximum length, in bytes, of a single entry path.
|
|
pub max_path_len: usize,
|
|
/// Maximum number of `/`-separated path segments (nesting depth,
|
|
/// including the filename) a single entry path may contain.
|
|
pub max_depth: usize,
|
|
}
|
|
|
|
impl Default for UploadLimits {
|
|
/// Production limits from design §2.2: 10 MiB max compressed upload,
|
|
/// 25 MiB max total uncompressed, 5 MiB max per file, 300 max entries,
|
|
/// 180-byte max path length, depth 10.
|
|
fn default() -> Self {
|
|
const MIB: u64 = 1024 * 1024;
|
|
Self {
|
|
max_compressed: (10 * MIB) as usize,
|
|
max_uncompressed_total: 25 * MIB,
|
|
max_file: 5 * MIB,
|
|
max_entries: 300,
|
|
max_path_len: 180,
|
|
max_depth: 10,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The kind of upload being validated — determines how `bytes` is
|
|
/// interpreted by [`build_manifest`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum UploadKind {
|
|
/// A single raw HTML file, stored as the page's `index.html`.
|
|
Html,
|
|
/// A zip archive of static assets, extracted per design §2.2.
|
|
Zip,
|
|
}
|
|
|
|
/// One file destined for R2 storage: `path` is the R2 object key suffix
|
|
/// (relative to `<slug>/`), `bytes` is its full content.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ManifestEntry {
|
|
/// Path relative to the page root, e.g. `"index.html"` or
|
|
/// `"assets/app.js"`. Always `/`-separated, never starts with `/`,
|
|
/// never contains a `..` segment (enforced by [`build_manifest`]).
|
|
pub path: String,
|
|
/// The file's full (decompressed, for zip) content.
|
|
pub bytes: Vec<u8>,
|
|
}
|
|
|
|
/// Errors returned by [`build_manifest`].
|
|
///
|
|
/// Each variant carries a precise, user-facing `Display` message suitable
|
|
/// for a `400 Bad Request` API response body.
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum UploadError {
|
|
/// The raw uploaded bytes exceeded `max_compressed`.
|
|
#[error("upload exceeds the maximum allowed size of {max} bytes")]
|
|
CompressedTooLarge {
|
|
/// The configured limit that was exceeded.
|
|
max: usize,
|
|
},
|
|
/// A zip entry name attempted to escape the page root.
|
|
#[error("archive entry has an unsafe path: '{path}'")]
|
|
PathTraversal {
|
|
/// The offending raw entry name.
|
|
path: String,
|
|
},
|
|
/// A zip entry name was longer than `max_path_len`.
|
|
#[error("archive entry path '{path}' exceeds the maximum length of {max} characters")]
|
|
PathTooLong {
|
|
/// The offending raw entry name.
|
|
path: String,
|
|
/// The configured limit that was exceeded.
|
|
max: usize,
|
|
},
|
|
/// A zip entry name had more `/`-separated segments than `max_depth`.
|
|
#[error("archive entry path '{path}' exceeds the maximum nesting depth of {max}")]
|
|
PathTooDeep {
|
|
/// The offending raw entry name.
|
|
path: String,
|
|
/// The configured limit that was exceeded.
|
|
max: usize,
|
|
},
|
|
/// The archive contained more entries than `max_entries`.
|
|
#[error("archive contains too many entries (maximum {max})")]
|
|
TooManyEntries {
|
|
/// The configured limit that was exceeded.
|
|
max: usize,
|
|
},
|
|
/// A single file's decompressed size exceeded `max_file`.
|
|
#[error("archive entry '{path}' exceeds the maximum per-file size of {max} bytes")]
|
|
FileTooLarge {
|
|
/// The offending entry name.
|
|
path: String,
|
|
/// The configured limit that was exceeded.
|
|
max: u64,
|
|
},
|
|
/// The archive's total decompressed size exceeded `max_uncompressed_total`.
|
|
#[error("archive's total uncompressed size exceeds the maximum allowed of {max} bytes")]
|
|
TotalTooLarge {
|
|
/// The configured limit that was exceeded.
|
|
max: u64,
|
|
},
|
|
/// The archive did not contain a root-level `index.html`.
|
|
#[error("archive is missing a required root-level index.html")]
|
|
MissingIndexHtml,
|
|
/// The archive contained the same normalized path twice.
|
|
///
|
|
/// With the `zip` crate version this crate currently depends on,
|
|
/// parsed archives cannot actually expose two entries sharing a
|
|
/// literal name (see the `seen` check in `build_zip_manifest` for
|
|
/// details), so this variant is presently unreachable in practice —
|
|
/// it is kept as defense-in-depth against that internal representation
|
|
/// changing in a future crate upgrade.
|
|
#[error("archive contains a duplicate entry: '{path}'")]
|
|
DuplicateEntry {
|
|
/// The duplicated path.
|
|
path: String,
|
|
},
|
|
/// The bytes could not be parsed as a zip archive at all.
|
|
#[error("upload could not be read as a valid zip archive")]
|
|
InvalidZip,
|
|
/// The archive (or html upload) contained no files.
|
|
#[error("upload contains no files")]
|
|
Empty,
|
|
}
|
|
|
|
/// Builds the file manifest for an upload, enforcing every limit in
|
|
/// `limits` and the structural rules from design §2.2.
|
|
///
|
|
/// - [`UploadKind::Html`]: `bytes` is stored verbatim as a single
|
|
/// `"index.html"` entry (still checked against `max_compressed` and
|
|
/// `max_file`/`max_uncompressed_total`).
|
|
/// - [`UploadKind::Zip`]: `bytes` is parsed as a zip archive. Directories
|
|
/// are skipped. Every file entry's name is checked for path traversal
|
|
/// (`..` segments, leading `/`, backslashes, drive letters, empty
|
|
/// segments, NUL bytes) and length/depth limits, duplicates are
|
|
/// rejected, the entry count is capped, and decompressed bytes are
|
|
/// counted as they stream out — extraction aborts the moment a per-file
|
|
/// or running total limit is crossed, never trusting the zip header's
|
|
/// declared size. The archive must contain a root-level `index.html` and
|
|
/// at least one file.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```
|
|
/// use pages::core::upload::{build_manifest, UploadKind, UploadLimits};
|
|
///
|
|
/// let limits = UploadLimits::default();
|
|
/// let manifest = build_manifest(
|
|
/// UploadKind::Html,
|
|
/// b"<html>hello</html>".to_vec(),
|
|
/// &limits,
|
|
/// )
|
|
/// .unwrap();
|
|
/// assert_eq!(manifest.len(), 1);
|
|
/// assert_eq!(manifest[0].path, "index.html");
|
|
/// ```
|
|
pub fn build_manifest(
|
|
kind: UploadKind,
|
|
bytes: Vec<u8>,
|
|
limits: &UploadLimits,
|
|
) -> Result<Vec<ManifestEntry>, UploadError> {
|
|
if bytes.len() > limits.max_compressed {
|
|
return Err(UploadError::CompressedTooLarge {
|
|
max: limits.max_compressed,
|
|
});
|
|
}
|
|
|
|
match kind {
|
|
UploadKind::Html => build_html_manifest(bytes, limits),
|
|
UploadKind::Zip => build_zip_manifest(bytes, limits),
|
|
}
|
|
}
|
|
|
|
/// Builds the single-entry manifest for an [`UploadKind::Html`] upload.
|
|
fn build_html_manifest(
|
|
bytes: Vec<u8>,
|
|
limits: &UploadLimits,
|
|
) -> Result<Vec<ManifestEntry>, UploadError> {
|
|
if bytes.is_empty() {
|
|
return Err(UploadError::Empty);
|
|
}
|
|
let len = bytes.len() as u64;
|
|
if len > limits.max_file {
|
|
return Err(UploadError::FileTooLarge {
|
|
path: "index.html".to_string(),
|
|
max: limits.max_file,
|
|
});
|
|
}
|
|
if len > limits.max_uncompressed_total {
|
|
return Err(UploadError::TotalTooLarge {
|
|
max: limits.max_uncompressed_total,
|
|
});
|
|
}
|
|
Ok(vec![ManifestEntry {
|
|
path: "index.html".to_string(),
|
|
bytes,
|
|
}])
|
|
}
|
|
|
|
/// Builds the manifest for an [`UploadKind::Zip`] upload.
|
|
fn build_zip_manifest(
|
|
bytes: Vec<u8>,
|
|
limits: &UploadLimits,
|
|
) -> Result<Vec<ManifestEntry>, UploadError> {
|
|
let mut archive =
|
|
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|_| UploadError::InvalidZip)?;
|
|
|
|
if archive.is_empty() {
|
|
return Err(UploadError::Empty);
|
|
}
|
|
if archive.len() > limits.max_entries {
|
|
return Err(UploadError::TooManyEntries {
|
|
max: limits.max_entries,
|
|
});
|
|
}
|
|
|
|
let mut manifest = Vec::new();
|
|
// Defense-in-depth against duplicate entry names (design §2.2). Note:
|
|
// `zip::ZipArchive` stores parsed entries in a name-keyed map
|
|
// internally, so `archive.by_index` can never actually yield two
|
|
// entries with the same literal name against the crate version this
|
|
// module depends on — see
|
|
// `zip_duplicate_named_entries_are_deduplicated_by_the_zip_crate_itself`
|
|
// in this module's tests for the full explanation. This check stays
|
|
// as a cheap guard in case that changes.
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
let mut total_used: u64 = 0;
|
|
|
|
for i in 0..archive.len() {
|
|
let mut entry = archive.by_index(i).map_err(|_| UploadError::InvalidZip)?;
|
|
|
|
if entry.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let name = entry.name().to_string();
|
|
check_entry_path(&name, limits)?;
|
|
|
|
if !seen.insert(name.clone()) {
|
|
return Err(UploadError::DuplicateEntry { path: name });
|
|
}
|
|
|
|
let data = read_entry_limited(&mut entry, &name, limits, &mut total_used)?;
|
|
manifest.push(ManifestEntry {
|
|
path: name,
|
|
bytes: data,
|
|
});
|
|
}
|
|
|
|
if manifest.is_empty() {
|
|
return Err(UploadError::Empty);
|
|
}
|
|
if !manifest.iter().any(|e| e.path == "index.html") {
|
|
return Err(UploadError::MissingIndexHtml);
|
|
}
|
|
|
|
Ok(manifest)
|
|
}
|
|
|
|
/// Validates a raw zip entry name against the path-traversal and
|
|
/// length/depth rules from design §2.2.
|
|
///
|
|
/// Rejects: empty names, names over `max_path_len`, names containing a
|
|
/// backslash or NUL byte, names starting with `/` (absolute), names
|
|
/// starting with a drive letter (`C:...`), and any `.` or `..` or empty
|
|
/// path segment. Also enforces `max_depth` on the segment count.
|
|
fn check_entry_path(name: &str, limits: &UploadLimits) -> Result<(), UploadError> {
|
|
if name.is_empty() {
|
|
return Err(UploadError::PathTraversal {
|
|
path: name.to_string(),
|
|
});
|
|
}
|
|
if name.len() > limits.max_path_len {
|
|
return Err(UploadError::PathTooLong {
|
|
path: name.to_string(),
|
|
max: limits.max_path_len,
|
|
});
|
|
}
|
|
if name.contains('\\') || name.contains('\0') {
|
|
return Err(UploadError::PathTraversal {
|
|
path: name.to_string(),
|
|
});
|
|
}
|
|
if name.starts_with('/') {
|
|
return Err(UploadError::PathTraversal {
|
|
path: name.to_string(),
|
|
});
|
|
}
|
|
// Windows drive letter, e.g. "C:foo" or "c:/foo".
|
|
let bytes = name.as_bytes();
|
|
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
|
|
return Err(UploadError::PathTraversal {
|
|
path: name.to_string(),
|
|
});
|
|
}
|
|
|
|
let segments: Vec<&str> = name.split('/').collect();
|
|
if segments.len() > limits.max_depth {
|
|
return Err(UploadError::PathTooDeep {
|
|
path: name.to_string(),
|
|
max: limits.max_depth,
|
|
});
|
|
}
|
|
for seg in &segments {
|
|
if seg.is_empty() || *seg == "." || *seg == ".." {
|
|
return Err(UploadError::PathTraversal {
|
|
path: name.to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Chunk size used by [`read_entry_limited`]'s streaming read loop.
|
|
const READ_CHUNK: usize = 64 * 1024;
|
|
|
|
/// Reads a zip entry's decompressed content, enforcing `limits.max_file`
|
|
/// and `limits.max_uncompressed_total` **during** the read rather than
|
|
/// after — the read loop aborts as soon as either limit is crossed, so a
|
|
/// high-compression-ratio "zip bomb" entry is rejected after at most
|
|
/// `READ_CHUNK` extra bytes are decompressed, never after the full
|
|
/// (potentially huge) claimed uncompressed size.
|
|
fn read_entry_limited<R: Read>(
|
|
reader: &mut R,
|
|
path: &str,
|
|
limits: &UploadLimits,
|
|
total_used: &mut u64,
|
|
) -> Result<Vec<u8>, UploadError> {
|
|
let mut buf = Vec::new();
|
|
let mut chunk = [0u8; READ_CHUNK];
|
|
loop {
|
|
let n = reader
|
|
.read(&mut chunk)
|
|
.map_err(|_| UploadError::InvalidZip)?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
buf.extend_from_slice(&chunk[..n]);
|
|
|
|
if buf.len() as u64 > limits.max_file {
|
|
return Err(UploadError::FileTooLarge {
|
|
path: path.to_string(),
|
|
max: limits.max_file,
|
|
});
|
|
}
|
|
|
|
*total_used += n as u64;
|
|
if *total_used > limits.max_uncompressed_total {
|
|
return Err(UploadError::TotalTooLarge {
|
|
max: limits.max_uncompressed_total,
|
|
});
|
|
}
|
|
}
|
|
Ok(buf)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Write;
|
|
use zip::write::SimpleFileOptions;
|
|
use zip::ZipWriter;
|
|
|
|
/// Builds a zip archive in memory from `(name, content)` pairs, using
|
|
/// deflate compression (the only method compiled in, matching
|
|
/// production's `Cargo.toml`).
|
|
fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
|
let mut buf = Cursor::new(Vec::new());
|
|
{
|
|
let mut writer = ZipWriter::new(&mut buf);
|
|
let options =
|
|
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
|
for (name, content) in entries {
|
|
writer.start_file(*name, options).unwrap();
|
|
writer.write_all(content).unwrap();
|
|
}
|
|
writer.finish().unwrap();
|
|
}
|
|
buf.into_inner()
|
|
}
|
|
|
|
/// Builds a zip archive containing a highly-compressible single file
|
|
/// (all zero bytes) of `uncompressed_len` bytes — a small "zip bomb"
|
|
/// fixture used to test the streaming size guard.
|
|
fn make_bomb_zip(name: &str, uncompressed_len: usize) -> Vec<u8> {
|
|
make_zip(&[(name, &vec![0u8; uncompressed_len])])
|
|
}
|
|
|
|
fn limits() -> UploadLimits {
|
|
UploadLimits::default()
|
|
}
|
|
|
|
/// Minimal, hand-rolled CRC-32 (the standard zip/PNG polynomial) — used
|
|
/// only by [`make_zip_raw`], which builds a zip archive by hand rather
|
|
/// than through `ZipWriter`.
|
|
fn crc32(data: &[u8]) -> u32 {
|
|
let mut crc: u32 = 0xFFFF_FFFF;
|
|
for &byte in data {
|
|
crc ^= byte as u32;
|
|
for _ in 0..8 {
|
|
let mask = (crc & 1).wrapping_neg();
|
|
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
|
|
}
|
|
}
|
|
!crc
|
|
}
|
|
|
|
/// Hand-builds a raw (stored/uncompressed) zip archive from arbitrary
|
|
/// `(name, content)` entries, assembling the zip byte format directly
|
|
/// instead of going through `ZipWriter`.
|
|
///
|
|
/// Needed because `zip::write::ZipWriter` enforces filename uniqueness
|
|
/// itself (`start_file` returns `InvalidArchive("Duplicate filename")`
|
|
/// for a repeated name), so it cannot be used to build a fixture with
|
|
/// two entries sharing a name — see
|
|
/// `zip_duplicate_named_entries_are_deduplicated_by_the_zip_crate_itself`
|
|
/// below for why that fixture is still useful.
|
|
fn make_zip_raw(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
|
let mut out = Vec::new();
|
|
let mut central = Vec::new();
|
|
|
|
for (name, content) in entries {
|
|
let name_bytes = name.as_bytes();
|
|
let offset = out.len() as u32;
|
|
let crc = crc32(content);
|
|
let len = content.len() as u32;
|
|
|
|
// Local file header (stored, no data descriptor).
|
|
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
|
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // flags
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // compression: stored
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // mod time
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // mod date
|
|
out.extend_from_slice(&crc.to_le_bytes());
|
|
out.extend_from_slice(&len.to_le_bytes()); // compressed size
|
|
out.extend_from_slice(&len.to_le_bytes()); // uncompressed size
|
|
out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // extra field len
|
|
out.extend_from_slice(name_bytes);
|
|
out.extend_from_slice(content);
|
|
|
|
// Matching central directory header.
|
|
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
|
central.extend_from_slice(&20u16.to_le_bytes()); // version made by
|
|
central.extend_from_slice(&20u16.to_le_bytes()); // version needed
|
|
central.extend_from_slice(&0u16.to_le_bytes()); // flags
|
|
central.extend_from_slice(&0u16.to_le_bytes()); // compression: stored
|
|
central.extend_from_slice(&0u16.to_le_bytes()); // mod time
|
|
central.extend_from_slice(&0u16.to_le_bytes()); // mod date
|
|
central.extend_from_slice(&crc.to_le_bytes());
|
|
central.extend_from_slice(&len.to_le_bytes());
|
|
central.extend_from_slice(&len.to_le_bytes());
|
|
central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
|
|
central.extend_from_slice(&0u16.to_le_bytes()); // extra field len
|
|
central.extend_from_slice(&0u16.to_le_bytes()); // comment len
|
|
central.extend_from_slice(&0u16.to_le_bytes()); // disk number start
|
|
central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
|
|
central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
|
|
central.extend_from_slice(&offset.to_le_bytes());
|
|
central.extend_from_slice(name_bytes);
|
|
}
|
|
|
|
let cd_offset = out.len() as u32;
|
|
let cd_size = central.len() as u32;
|
|
out.extend_from_slice(¢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"<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 { .. }
|
|
));
|
|
}
|
|
}
|