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
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(¢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 { .. }
|
|
));
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|