mini-static 0.20.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use std::ffi::OsString;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;

use tokio::process::Command;

/// Default ceiling on how long a single external tool invocation may run before
/// mini-static gives up on it (A2): a hung or misbehaving external process must not
/// block a build indefinitely. Callers may inject a shorter timeout (tests do, to stay
/// fast); production call sites should pass this constant.
pub(crate) const TOOL_TIMEOUT: Duration = Duration::from_secs(30);

/// Why an external tool invocation ([`execute`]) failed.
///
/// `tool` names the CLI tool (`"lightningcss"`, `"esbuild"`) in every variant so a
/// caller juggling both CSS and JS pipelines can report which one broke without
/// threading the name through separately.
#[derive(Debug)]
pub(crate) enum ToolError {
    /// `program` was not found on `PATH`. `install_hint` is preset-specific guidance
    /// (e.g. an npm install command) surfaced verbatim in the error message.
    NotFound {
        tool: &'static str,
        install_hint: &'static str,
    },
    /// The process ran and exited with a non-zero status. `stderr` carries whatever
    /// diagnostic the tool itself printed.
    ExitNonZero {
        tool: &'static str,
        code: Option<i32>,
        stderr: String,
    },
    /// The process did not finish within the given timeout. The process is killed
    /// (`kill_on_drop`) rather than left to run in the background.
    TimedOut {
        tool: &'static str,
        timeout: Duration,
    },
    /// The process exited successfully but `output_path` is missing or empty — the
    /// tool silently produced nothing.
    NoOutput { tool: &'static str },
    /// A spawn/wait failure not attributable to a missing binary.
    Io(std::io::Error),
}

impl std::fmt::Display for ToolError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ToolError::NotFound { tool, install_hint } => {
                write!(f, "{tool} not found on PATH ({install_hint})")
            }
            ToolError::ExitNonZero { tool, code, stderr } => {
                write!(f, "{tool} exited with code {code:?}: {stderr}")
            }
            ToolError::TimedOut { tool, timeout } => {
                write!(f, "{tool} timed out after {timeout:?}")
            }
            ToolError::NoOutput { tool } => {
                write!(f, "{tool} exited successfully but produced no output")
            }
            ToolError::Io(e) => write!(f, "failed to run external tool: {e}"),
        }
    }
}

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

/// Run `program args...`, expecting it to write its result to `output_path`.
///
/// Bounded by `timeout` (A2): a hung process is killed and reported as `TimedOut`
/// rather than blocking the caller forever. Captures stderr so a non-zero exit carries
/// the tool's own diagnostic, not just a bare exit code (A5).
///
/// # Errors
///
/// - `NotFound` if `program` isn't on `PATH`.
/// - `ExitNonZero` if the process exits with a non-zero code.
/// - `TimedOut` if the process doesn't finish within `timeout`.
/// - `NoOutput` if the process exits 0 but `output_path` is missing or empty.
/// - `Io` for any other spawn/wait failure.
pub(crate) async fn execute(
    tool: &'static str,
    install_hint: &'static str,
    program: &str,
    args: &[OsString],
    output_path: &Path,
    timeout: Duration,
) -> Result<(), ToolError> {
    let spawned = Command::new(program)
        .args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .kill_on_drop(true)
        .spawn();

    let child = match spawned {
        Ok(child) => child,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Err(ToolError::NotFound { tool, install_hint });
        }
        Err(e) => return Err(ToolError::Io(e)),
    };

    let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
        Ok(Ok(output)) => output,
        Ok(Err(e)) => return Err(ToolError::Io(e)),
        Err(_) => return Err(ToolError::TimedOut { tool, timeout }),
    };

    if !output.status.success() {
        return Err(ToolError::ExitNonZero {
            tool,
            code: output.status.code(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        });
    }

    match tokio::fs::metadata(output_path).await {
        Ok(meta) if meta.len() > 0 => Ok(()),
        _ => Err(ToolError::NoOutput { tool }),
    }
}

/// True if `binary_name` resolves to an executable file somewhere on `PATH`.
///
/// A synchronous, no-subprocess scan (just directory listings) — cheap enough to run
/// at server startup as a fail-fast precondition check (A4) before any build is
/// attempted, so a missing tool is visible immediately rather than on the first
/// rebuild that needs it.
pub(crate) fn locate_on_path(binary_name: &str) -> bool {
    let Some(path_var) = std::env::var_os("PATH") else {
        return false;
    };

    std::env::split_paths(&path_var).any(|dir| is_executable_file(&dir.join(binary_name)))
}

#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
    use std::os::unix::fs::PermissionsExt;
    std::fs::metadata(path)
        .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}

#[cfg(not(unix))]
fn is_executable_file(path: &Path) -> bool {
    path.is_file()
}

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