mini-static 0.14.7

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;

use bytes::Bytes;
use lightningcss::bundler::{Bundler, ResolveResult, SourceProvider};

use crate::resolve::canonicalize_within_roots;
use crate::error::StaticError;

/// Maximum import depth to prevent unbounded recursion in `@import` chains.
/// If a `.css` file's imports form a chain (A imports B imports C...) deeper than
/// this, bundling fails rather than recursing indefinitely.
pub(crate) const MAX_IMPORT_DEPTH: usize = 32;

/// Maximum total distinct files that can be pulled into a single bundle.
/// This guards against a wide-but-shallow import graph (e.g., one file importing
/// 1000+ siblings), which would consume memory and I/O without obvious bounds.
pub(crate) const MAX_IMPORTED_FILES: usize = 512;

#[derive(Debug)]
pub(crate) enum BundleError {
    Traversal(PathBuf),
    Cycle(PathBuf),
    DepthExceeded { path: PathBuf, depth: usize },
    TooManyFiles { limit: usize },
    MissingImport { path: PathBuf, io_error: String },
    Css(String),
    JoinError(String),
}

impl std::fmt::Display for BundleError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            BundleError::Traversal(p) => write!(f, "import traversal attempt: {}", p.display()),
            BundleError::Cycle(p) => write!(f, "import cycle detected: {}", p.display()),
            BundleError::DepthExceeded { path, depth } => {
                write!(f, "import depth exceeded at {}: depth {}", path.display(), depth)
            }
            BundleError::TooManyFiles { limit } => write!(f, "too many imported files (limit {})", limit),
            BundleError::MissingImport { path, io_error } => {
                write!(f, "missing import {}: {}", path.display(), io_error)
            }
            BundleError::Css(msg) => write!(f, "CSS bundling failed: {msg}"),
            BundleError::JoinError(msg) => write!(f, "bundling task failed: {msg}"),
        }
    }
}

impl std::error::Error for BundleError {}

/// A path-resolving `SourceProvider` for lightningcss's `Bundler` that enforces
/// multi-root boundaries (no escaping via `@import` specifiers) and prevents
/// unbounded recursion.
///
/// This struct is the security boundary for import resolution. It must reject any
/// `@import` that would escape the allowed roots, matching the protection of the HTTP
/// request-path resolver (resolve.rs).
struct RootBoundedProvider {
    allowed_roots: Vec<PathBuf>,
    /// Maps resolved file paths to their depth in the import chain.
    /// Used to detect exceeding MAX_IMPORT_DEPTH.
    depth_map: Mutex<HashMap<PathBuf, usize>>,
    /// Count of distinct files resolved so far (including the entry file).
    file_count: Mutex<usize>,
    /// Strings read from disk, owned and returned by reference scoped to this provider.
    /// Mutable only for caching read results; the contents themselves are immutable.
    read_cache: Mutex<HashMap<PathBuf, String>>,
}

impl RootBoundedProvider {
    fn new(allowed_roots: Vec<PathBuf>) -> Self {
        Self {
            allowed_roots,
            depth_map: Mutex::new(HashMap::new()),
            file_count: Mutex::new(0),
            read_cache: Mutex::new(HashMap::new()),
        }
    }
}

impl SourceProvider for RootBoundedProvider {
    type Error = BundleError;

    fn read<'a>(&'a self, file: &Path) -> Result<&'a str, Self::Error> {
        let mut cache = self.read_cache.lock().unwrap();
        if let Some(content) = cache.get(file) {
            let ptr = content.as_str() as *const str;
            return Ok(unsafe { &*ptr });
        }

        let content = std::fs::read_to_string(file).map_err(|e| BundleError::MissingImport {
            path: file.to_path_buf(),
            io_error: e.to_string(),
        })?;

        let ptr = content.as_str() as *const str;
        cache.insert(file.to_path_buf(), content);
        Ok(unsafe { &*ptr })
    }

    fn resolve(
        &self,
        specifier: &str,
        originating_file: &Path,
    ) -> Result<ResolveResult, Self::Error> {
        if specifier.starts_with("http://") || specifier.starts_with("https://") || specifier.starts_with("//") {
            return Ok(ResolveResult::External(specifier.to_string()));
        }

        let originating_dir = originating_file.parent().unwrap_or(Path::new("."));
        let joined = originating_dir.join(specifier);

        let canon = canonicalize_within_roots(&self.allowed_roots, &joined).map_err(|e| {
            match e {
                StaticError::Traversal(s) => BundleError::Traversal(PathBuf::from(s)),
                StaticError::NotFound(s) => BundleError::MissingImport {
                    path: PathBuf::from(s),
                    io_error: "file not found".to_string(),
                },
                StaticError::Io(e) => BundleError::MissingImport {
                    path: joined,
                    io_error: e.to_string(),
                },
            }
        })?;

        debug_assert!(
            self.allowed_roots.iter().any(|r| canon.starts_with(r)),
            "canonicalize_within_roots should guarantee this"
        );

        let mut depth_map = self.depth_map.lock().unwrap();
        let originating_depth = depth_map
            .get(originating_file)
            .copied()
            .unwrap_or(0);

        if originating_depth >= MAX_IMPORT_DEPTH {
            return Err(BundleError::DepthExceeded {
                path: canon.clone(),
                depth: originating_depth + 1,
            });
        }

        if let Some(&existing_depth) = depth_map.get(&canon) {
            if existing_depth <= originating_depth {
                return Err(BundleError::Cycle(canon));
            }
        } else {
            let mut count = self.file_count.lock().unwrap();
            if *count >= MAX_IMPORTED_FILES {
                return Err(BundleError::TooManyFiles {
                    limit: MAX_IMPORTED_FILES,
                });
            }
            *count += 1;
        }

        depth_map.insert(canon.clone(), originating_depth + 1);

        Ok(ResolveResult::File(canon))
    }
}

/// Bundle and minify a CSS file, resolving all `@import` chains within allowed roots.
///
/// Runs the blocking bundle+minify work in `spawn_blocking`, consistent with how
/// `resolve()` handles blocking filesystem operations.
///
/// Returns both the minified bytes and the full set of (dependency_path, mtime) pairs
/// touched during bundling — needed by the cache to detect staleness when any dependency
/// changes.
///
/// # Fallback behavior on error
///
/// Parse-level failures (`Css` error) and missing files (`MissingImport`) are
/// considered recoverable at the HTTP level — the caller falls back to serving the
/// raw, unbundled entry file. Traversal/cycle/depth/count violations (`Traversal`,
/// `Cycle`, `DepthExceeded`, `TooManyFiles`) are logged at error level but also fall
/// back to raw service rather than returning 500 — a misconfigured import graph should
/// not take down a live site, but it should be visible in logs.
pub(crate) async fn bundle_and_minify_css(
    allowed_roots: &[PathBuf],
    entry: &Path,
) -> Result<(Bytes, Vec<(PathBuf, SystemTime)>), BundleError> {
    let allowed_roots = allowed_roots.to_vec();
    let entry = entry.to_path_buf();

    tokio::task::spawn_blocking(move || {
        let provider = RootBoundedProvider::new(allowed_roots.clone());

        let mut bundler = Bundler::new(&provider, None, Default::default());
        let mut bundled = bundler.bundle(&entry).map_err(|e| {
            BundleError::Css(format!("{:?}", e))
        })?;

        bundled.minify(Default::default()).map_err(|e| {
            BundleError::Css(format!("{:?}", e))
        })?;

        let minified_bytes = bundled
            .to_css(Default::default())
            .map_err(|e| BundleError::Css(format!("{:?}", e)))?
            .code;

        let dependencies = collect_dependencies(&entry, &allowed_roots)?;

        Ok((Bytes::from(minified_bytes), dependencies))
    })
    .await
    .map_err(|e| BundleError::JoinError(e.to_string()))?
}

/// Collect all files touched during bundling, paired with their mtimes at collection time.
/// Used by the cache to detect staleness if any dependency changes.
fn collect_dependencies(entry: &Path, allowed_roots: &[PathBuf]) -> Result<Vec<(PathBuf, SystemTime)>, BundleError> {
    let mut deps = Vec::new();
    let mut to_walk = vec![entry.to_path_buf()];
    let mut visited = HashSet::new();

    while let Some(file) = to_walk.pop() {
        if visited.contains(&file) {
            continue;
        }
        visited.insert(file.clone());

        let mtime = std::fs::metadata(&file)
            .and_then(|m| m.modified())
            .map_err(|e| BundleError::MissingImport {
                path: file.clone(),
                io_error: e.to_string(),
            })?;

        deps.push((file.clone(), mtime));

        let content = std::fs::read_to_string(&file).map_err(|e| BundleError::MissingImport {
            path: file.clone(),
            io_error: e.to_string(),
        })?;

        for line in content.lines() {
            if let Some(import_spec) = extract_import_spec(line) {
                let file_dir = file.parent().unwrap_or(Path::new("."));
                let joined = file_dir.join(&import_spec);

                let canon = canonicalize_within_roots(allowed_roots, &joined).map_err(|e| {
                    match e {
                        StaticError::Traversal(s) => BundleError::Traversal(PathBuf::from(s)),
                        StaticError::NotFound(s) => BundleError::MissingImport {
                            path: PathBuf::from(s),
                            io_error: "not found".to_string(),
                        },
                        StaticError::Io(e) => BundleError::MissingImport {
                            path: joined,
                            io_error: e.to_string(),
                        },
                    }
                })?;

                if !visited.contains(&canon) {
                    to_walk.push(canon);
                }
            }
        }
    }

    Ok(deps)
}

/// Extract the import path from a CSS `@import` statement, if one is found.
/// Handles basic forms like `@import "path/to/file.css";` and `@import url("...");`.
fn extract_import_spec(line: &str) -> Option<String> {
    let trimmed = line.trim();

    if !trimmed.starts_with("@import") {
        return None;
    }

    let rest = trimmed.strip_prefix("@import")?.trim_start();

    if let Some(quoted) = rest.strip_prefix('"') {
        if let Some(end) = quoted.find('"') {
            return Some(quoted[..end].to_string());
        }
    }

    if let Some(quoted) = rest.strip_prefix('\'') {
        if let Some(end) = quoted.find('\'') {
            return Some(quoted[..end].to_string());
        }
    }

    if let Some(url_str) = rest.strip_prefix("url(") {
        for quote in &['"', '\''] {
            if let Some(quoted) = url_str.strip_prefix(*quote) {
                if let Some(end) = quoted.find(*quote) {
                    let spec = quoted[..end].to_string();
                    if !spec.starts_with("http") && !spec.starts_with("//") {
                        return Some(spec);
                    }
                }
            }
        }
    }

    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;
    use std::fs;

    #[tokio::test]
    async fn bundles_basic_file() {
        let tmpdir = TempDir::new().unwrap();
        let root = tmpdir.path();
        let root_canon = root.canonicalize().unwrap();

        fs::write(root.join("style.css"), "body { margin: 0; }").unwrap();

        let result = bundle_and_minify_css(&[root_canon.clone()], &root.join("style.css")).await;

        assert!(result.is_ok());
        let (bytes, deps) = result.unwrap();
        assert!(!bytes.is_empty());
        assert!(deps.len() >= 1);
    }

    #[tokio::test]
    async fn rejects_import_escaping_root() {
        let tmpdir = TempDir::new().unwrap();
        let root = tmpdir.path();
        let root_canon = root.canonicalize().unwrap();

        fs::write(root.join("evil.css"), r#"@import "../../etc/passwd";"#).unwrap();

        let result = bundle_and_minify_css(&[root_canon.clone()], &root.join("evil.css")).await;

        let is_guarded = matches!(result, Err(BundleError::Traversal(_)) | Err(BundleError::MissingImport { .. }) | Err(BundleError::Css(_)));
        assert!(is_guarded, "expected traversal/missing import/css error, got {result:?}");
    }

    #[tokio::test]
    async fn bundles_import_from_external_bundle_root_succeeds() {
        let parent_tmpdir = TempDir::new().unwrap();
        let parent = parent_tmpdir.path();

        let served_root = parent.join("served").canonicalize().unwrap_or_else(|_| {
            fs::create_dir_all(parent.join("served")).unwrap();
            parent.join("served").canonicalize().unwrap()
        });
        let bundle_root = parent.join("bundle").canonicalize().unwrap_or_else(|_| {
            fs::create_dir_all(parent.join("bundle")).unwrap();
            parent.join("bundle").canonicalize().unwrap()
        });

        fs::write(served_root.join("entry.css"), r#"@import "../bundle/shared.css";"#).unwrap();
        fs::write(bundle_root.join("shared.css"), ".shared { color: red; }").unwrap();

        let result = bundle_and_minify_css(&[served_root.clone(), bundle_root.clone()], &served_root.join("entry.css")).await;

        assert!(result.is_ok(), "expected bundling to succeed, got {result:?}");
        let (bytes, deps) = result.unwrap();
        assert!(!bytes.is_empty());
        let bytes_str = String::from_utf8_lossy(&bytes);
        assert!(bytes_str.contains("shared"), "expected shared CSS rule in output");
        assert!(deps.iter().any(|(p, _)| p == &bundle_root.join("shared.css")), "expected shared.css in dependencies");
    }

    #[tokio::test]
    async fn rejects_import_escaping_union_of_all_allowed_roots() {
        let served_tmpdir = TempDir::new().unwrap();
        let bundle_tmpdir = TempDir::new().unwrap();
        let served_root = served_tmpdir.path().canonicalize().unwrap();
        let bundle_root = bundle_tmpdir.path().canonicalize().unwrap();

        fs::write(served_root.join("entry.css"), r#"@import "../../etc/passwd";"#).unwrap();

        let result = bundle_and_minify_css(&[served_root.clone(), bundle_root.clone()], &served_root.join("entry.css")).await;

        let is_guarded = matches!(result, Err(BundleError::Traversal(_)) | Err(BundleError::MissingImport { .. }) | Err(BundleError::Css(_)));
        assert!(is_guarded, "expected traversal/missing import/css error, got {result:?}");
    }
}