mini-static 0.17.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)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn sh_args(script: &str) -> Vec<OsString> {
        vec![OsString::from("-c"), OsString::from(script)]
    }

    #[tokio::test]
    async fn succeeds_when_process_writes_output_and_exits_zero() {
        let dir = TempDir::new().unwrap();
        let output = dir.path().join("out.txt");
        let script = format!("printf hi > {}", output.display());

        let result = execute(
            "sh-test",
            "n/a",
            "sh",
            &sh_args(&script),
            &output,
            TOOL_TIMEOUT,
        )
        .await;

        assert!(result.is_ok(), "{result:?}");
    }

    #[tokio::test]
    async fn maps_nonzero_exit_to_exit_non_zero_with_captured_stderr() {
        let dir = TempDir::new().unwrap();
        let output = dir.path().join("out.txt");

        let result = execute(
            "sh-test",
            "n/a",
            "sh",
            &sh_args("echo boom 1>&2; exit 3"),
            &output,
            TOOL_TIMEOUT,
        )
        .await;

        match result {
            Err(ToolError::ExitNonZero { code, stderr, .. }) => {
                assert_eq!(code, Some(3));
                assert!(stderr.contains("boom"), "stderr was: {stderr}");
            }
            other => panic!("expected ExitNonZero, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn maps_missing_binary_to_not_found() {
        let dir = TempDir::new().unwrap();
        let output = dir.path().join("out.txt");

        let result = execute(
            "missing-test",
            "install it from nowhere",
            "definitely-not-a-real-binary-9f3c2a",
            &[],
            &output,
            TOOL_TIMEOUT,
        )
        .await;

        assert!(
            matches!(result, Err(ToolError::NotFound { .. })),
            "{result:?}"
        );
    }

    #[tokio::test]
    async fn zero_exit_with_no_output_file_is_no_output() {
        let dir = TempDir::new().unwrap();
        let output = dir.path().join("out.txt");

        let result = execute(
            "sh-test",
            "n/a",
            "sh",
            &sh_args("true"),
            &output,
            TOOL_TIMEOUT,
        )
        .await;

        assert!(
            matches!(result, Err(ToolError::NoOutput { .. })),
            "{result:?}"
        );
    }

    #[tokio::test]
    async fn timed_out_process_is_reported() {
        let dir = TempDir::new().unwrap();
        let output = dir.path().join("out.txt");

        let result = execute(
            "sh-test",
            "n/a",
            "sh",
            &sh_args("sleep 5"),
            &output,
            Duration::from_millis(50),
        )
        .await;

        assert!(
            matches!(result, Err(ToolError::TimedOut { .. })),
            "{result:?}"
        );
    }

    #[test]
    fn locates_a_binary_known_to_exist_on_path() {
        assert!(
            locate_on_path("sh"),
            "sh should be on PATH in any test environment"
        );
    }

    #[test]
    fn does_not_locate_a_nonexistent_binary() {
        assert!(!locate_on_path("definitely-not-a-real-binary-9f3c2a"));
    }
}