mini-static 0.21.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};

use crate::tool;

/// External tools mini-static knows how to invoke for CSS bundling/minification.
///
/// `#[non_exhaustive]` so a future preset (e.g. a second CSS tool) is an additive
/// variant, not a semver-breaking change for downstream `match` expressions.
///
/// # Installation
///
/// mini-static does not install or manage these binaries — only looks them up on
/// `PATH` at server startup and fails loudly if missing (see [`crate::Server::with_css_tool`]).
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CssTool {
    /// <https://lightningcss.dev>, invoked via its separately-installed `lightningcss`
    /// CLI (npm package `lightningcss-cli`) — a different artifact from the Rust
    /// `lightningcss` crate this refactor removes.
    ///
    /// # Trust boundary
    ///
    /// Bundling delegates `@import` resolution entirely to this CLI process, which
    /// resolves imports relative to the file being processed with no root boundary
    /// mini-static can inject. This is an accepted trade-off: CSS source folders are
    /// developer-authored build inputs, not request-time attacker input (unlike the
    /// HTTP path resolver in `resolve.rs`, which stays fully guarded). An `@import`
    /// escaping the intended source tree is a build misconfiguration to catch in
    /// review, not a runtime exploit surface. [`tool::TOOL_TIMEOUT`] is the bound that
    /// replaces the old in-process import-depth/file-count ceilings.
    LightningCss,
    /// Copies the entry file to the output file unchanged. Test-only: lets
    /// orchestration (discovery, concatenation, error handling, `SourcePipeline`
    /// wiring) be exercised against a real subprocess without depending on
    /// `lightningcss` being installed in CI.
    #[cfg(test)]
    TestEcho,
    /// Always fails with `ToolError::NotFound`. Test-only, for exercising the
    /// startup PATH-probe and hard-fail-on-missing-binary paths.
    #[cfg(test)]
    TestMissing,
}

impl CssTool {
    pub(crate) fn binary_name(&self) -> &'static str {
        match self {
            CssTool::LightningCss => "lightningcss",
            #[cfg(test)]
            CssTool::TestEcho => "cp",
            #[cfg(test)]
            CssTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
        }
    }

    pub(crate) fn install_hint(&self) -> &'static str {
        match self {
            CssTool::LightningCss => {
                "install via `npm install -g lightningcss-cli` (or add it as a project \
                 devDependency and put its bin/ on PATH)"
            }
            #[cfg(test)]
            CssTool::TestEcho | CssTool::TestMissing => "test-only tool, not installable",
        }
    }

    fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
        match self {
            CssTool::LightningCss => {
                let mut args = Vec::new();
                if bundle {
                    args.push(OsString::from("--bundle"));
                }
                if minify {
                    args.push(OsString::from("--minify"));
                }
                args.push(OsString::from("-o"));
                args.push(output.into());
                args.push(entry.into());
                args
            }
            #[cfg(test)]
            CssTool::TestEcho => vec![entry.into(), output.into()],
            #[cfg(test)]
            CssTool::TestMissing => vec![],
        }
    }
}

/// Configuration for [`crate::Server::with_css_tool`]: independent `bundle`/`minify`
/// toggles, all four combinations valid.
#[derive(Debug, Clone)]
pub struct CssOptions {
    bundle: bool,
    minify: bool,
    bundle_output_name: String,
}

impl CssOptions {
    /// Neither bundle nor minify — CSS is copied through unchanged.
    pub fn new() -> Self {
        CssOptions {
            bundle: false,
            minify: false,
            bundle_output_name: "styles.css".to_string(),
        }
    }

    /// Bundle every `.css` under the source folders into a single output file,
    /// resolving `@import` via the configured [`CssTool`].
    pub fn bundle(mut self, bundle: bool) -> Self {
        self.bundle = bundle;
        self
    }

    /// Minify CSS via the configured [`CssTool`].
    pub fn minify(mut self, minify: bool) -> Self {
        self.minify = minify;
        self
    }

    /// Output file name for bundle mode, under the server's output dir (default
    /// `styles.css`). Ignored when `bundle` is `false`.
    pub fn bundle_output_name(mut self, name: impl Into<String>) -> Self {
        self.bundle_output_name = name.into();
        self
    }

    pub(crate) fn is_bundle(&self) -> bool {
        self.bundle
    }

    pub(crate) fn is_minify(&self) -> bool {
        self.minify
    }

    pub(crate) fn output_file_name(&self) -> &str {
        &self.bundle_output_name
    }
}

impl Default for CssOptions {
    fn default() -> Self {
        Self::new()
    }
}

/// Errors from CSS tool orchestration: discovery, subprocess execution, and output
/// writing.
#[derive(Debug)]
pub(crate) enum CssError {
    ReadSource { path: PathBuf, reason: String },
    WriteOutput { path: PathBuf, reason: String },
    NoFilesFound(PathBuf),
    Tool(tool::ToolError),
}

impl std::fmt::Display for CssError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CssError::ReadSource { path, reason } => {
                write!(f, "failed to read {}: {reason}", path.display())
            }
            CssError::WriteOutput { path, reason } => {
                write!(f, "failed to write {}: {reason}", path.display())
            }
            CssError::NoFilesFound(path) => write!(f, "no CSS files found in {}", path.display()),
            CssError::Tool(e) => write!(f, "{e}"),
        }
    }
}

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

impl From<tool::ToolError> for CssError {
    fn from(e: tool::ToolError) -> Self {
        CssError::Tool(e)
    }
}

/// Bundle every `.css` file discovered under `source_dirs` into a single file at
/// `output_path`, per `options`. Each discovered file is run through `css_tool`
/// (bundle/minify per `options`) into a scratch file, then concatenated in sorted
/// path order — same shape as the old in-process bundler, just delegated per-file.
///
/// # Errors
///
/// Returns `Err` without touching `output_path` if discovery, any tool invocation, or
/// the final write fails — a broken rebuild leaves the previous good output in place
/// rather than serving a partial or corrupt bundle.
pub(crate) async fn build_css_bundle(
    css_tool: CssTool,
    options: &CssOptions,
    source_dirs: &[PathBuf],
    output_path: &Path,
) -> Result<(), CssError> {
    let css_files = find_css_files(source_dirs)?;
    if css_files.is_empty() {
        let first = source_dirs
            .first()
            .cloned()
            .unwrap_or_else(|| PathBuf::from("."));
        return Err(CssError::NoFilesFound(first));
    }

    let mut combined = Vec::new();
    for (index, file) in css_files.iter().enumerate() {
        if !options.bundle && !options.minify {
            let bytes = fs::read(file).map_err(|e| CssError::ReadSource {
                path: file.clone(),
                reason: e.to_string(),
            })?;
            combined.extend_from_slice(&bytes);
            continue;
        }

        let scratch = scratch_output_path(output_path, index);
        run_tool(css_tool, options.bundle, options.minify, file, &scratch).await?;
        let bytes = fs::read(&scratch).map_err(|e| CssError::ReadSource {
            path: scratch.clone(),
            reason: e.to_string(),
        })?;
        let _ = fs::remove_file(&scratch);
        combined.extend_from_slice(&bytes);
    }

    write_output(output_path, &combined)
}

/// Process a single CSS file (bundle disabled) into its mirrored `output` path.
///
/// A malformed source degrades to a raw copy rather than failing the pipeline — the
/// server must still serve the file and keep the browser in sync. The degradation is
/// logged, not silent.
pub(crate) async fn build_css_file(
    css_tool: CssTool,
    options: &CssOptions,
    source: &Path,
    output: &Path,
) -> Result<(), CssError> {
    if !options.minify || is_already_minified(source) {
        return copy_file(source, output);
    }

    if let Err(e) = run_tool(css_tool, false, true, source, output).await {
        eprintln!(
            "css tool: minify failed for {}, serving raw bytes: {e}",
            source.display()
        );
        return copy_file(source, output);
    }
    Ok(())
}

async fn run_tool(
    css_tool: CssTool,
    bundle: bool,
    minify: bool,
    entry: &Path,
    output: &Path,
) -> Result<(), tool::ToolError> {
    if let Some(parent) = output.parent() {
        let _ = fs::create_dir_all(parent);
    }
    let args = css_tool.args(bundle, minify, entry, output);
    tool::execute(
        css_tool.binary_name(),
        css_tool.install_hint(),
        css_tool.binary_name(),
        &args,
        output,
        tool::TOOL_TIMEOUT,
    )
    .await
}

/// True if `path`'s filename indicates it's already minified (`*.min.css`). Such
/// files should be served as-is — running a minifier on already-minified input is
/// wasted work at best and a correctness risk at worst.
fn is_already_minified(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.ends_with(".min.css"))
}

fn scratch_output_path(output_path: &Path, index: usize) -> PathBuf {
    let file_name = output_path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("output");
    output_path.with_file_name(format!(".{file_name}.{index}.building"))
}

fn write_output(output_path: &Path, bytes: &[u8]) -> Result<(), CssError> {
    if let Some(parent) = output_path.parent() {
        fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
            path: output_path.to_path_buf(),
            reason: e.to_string(),
        })?;
    }
    fs::write(output_path, bytes).map_err(|e| CssError::WriteOutput {
        path: output_path.to_path_buf(),
        reason: e.to_string(),
    })
}

fn copy_file(source: &Path, output: &Path) -> Result<(), CssError> {
    if let Some(parent) = output.parent() {
        fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
            path: output.to_path_buf(),
            reason: e.to_string(),
        })?;
    }
    fs::copy(source, output).map_err(|e| CssError::WriteOutput {
        path: output.to_path_buf(),
        reason: e.to_string(),
    })?;
    Ok(())
}

/// Find every `.css` file under `source_dirs`, recursively, sorted by path.
fn find_css_files(source_dirs: &[PathBuf]) -> Result<Vec<PathBuf>, CssError> {
    let mut files = Vec::new();
    for dir in source_dirs {
        let found = walk_for_extension(dir, "css").map_err(|e| CssError::ReadSource {
            path: dir.clone(),
            reason: e.to_string(),
        })?;
        files.extend(found);
    }
    files.sort();
    Ok(files)
}

fn walk_for_extension(dir: &Path, ext: &str) -> std::io::Result<Vec<PathBuf>> {
    let mut files = Vec::new();
    let mut dirs = vec![dir.to_path_buf()];

    while let Some(current_dir) = dirs.pop() {
        for entry in fs::read_dir(&current_dir)? {
            let entry = entry?;
            let path = entry.path();
            let file_type = entry.file_type()?;

            if file_type.is_dir() {
                dirs.push(path);
            } else if file_type.is_file() && path.extension().and_then(|s| s.to_str()) == Some(ext)
            {
                files.push(path);
            }
        }
    }

    Ok(files)
}

#[cfg(test)]
#[path = "../tests/unit/css.rs"]
mod tests;