mini-static 0.29.0

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

use crate::error::StaticError;

/// Check that a canonicalized path stays within the server root.
///
/// Calls `canonicalize()` on the joined path and verifies the result starts with
/// `root_canon`, catching symlink escapes and traversal attempts that sneak through
/// segment-based checks.
///
/// # Arguments
///
/// * `root_canon` - The server root in canonical form.
/// * `joined` - A path that may need canonicalizing (e.g., the result of `root.join(...)`).
///
/// # Returns
///
/// - `Ok(PathBuf)` if the canonicalized path stays within root.
/// - `Err(StaticError::NotFound)` if the path doesn't exist or can't be canonicalized.
/// - `Err(StaticError::Traversal)` if the canonicalized path escapes root.
pub(crate) fn canonicalize_within_root(
    root_canon: &Path,
    joined: &Path,
) -> Result<PathBuf, StaticError> {
    let canon = joined
        .canonicalize()
        .map_err(|_| StaticError::NotFound(joined.display().to_string()))?;

    if canon.starts_with(root_canon) {
        Ok(canon)
    } else {
        Err(StaticError::Traversal(joined.display().to_string()))
    }
}

/// Resolve a request path under a pre-canonicalized root.
///
/// This function assumes `root_canon` is already in canonical form — `root_canon` should be
/// the output of `root.canonicalize()` called once at server startup. Per-request resolution
/// only canonicalizes the joined path, not the root.
///
/// # Path Traversal Protection
///
/// Segment-based traversal check rejects only path segments exactly equal to `..`.
/// This allows filenames containing `..` as a substring (e.g., `jquery..min.js`) while
/// blocking traversal attempts like `../../etc/passwd`.
///
/// # Directory Handling
///
/// If the resolved path is a directory, automatically serves `index.html` from that directory
/// if it exists and doesn't escape the root.
///
/// # Symlinks
///
/// Symlinks are followed during canonicalization. After following symlinks, the final
/// canonical path must stay within the server root.
///
/// # Arguments
///
/// * `root_canon` - The server root in canonical form (should be output of `canonicalize()`).
/// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
///
/// # Returns
///
/// - `Ok(PathBuf)` if the path resolves to a file within root.
/// - `Err(StaticError::NotFound)` if the path doesn't exist.
/// - `Err(StaticError::Traversal)` if the path attempts to escape the root.
pub fn resolve_with_canonical_root(
    root_canon: &Path,
    request_path: &str,
) -> Result<PathBuf, StaticError> {
    resolve_with_policy(root_canon, request_path, HiddenFiles::Deny)
}

/// Whether dot-prefixed request-path segments may be served.
///
/// The default is [`HiddenFiles::Deny`]: a served root is frequently a build output
/// directory, a repository working copy, or a folder someone dropped a `.env` into, and
/// serving `.git/config` or `.env` to anyone who guesses the name is a credential leak
/// that no traversal check catches — the files are legitimately *inside* the root.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HiddenFiles {
    /// Dot-prefixed segments answer as a miss.
    Deny,
    /// Dot-prefixed segments resolve like any other name.
    Serve,
}

/// The one dot-prefixed prefix served under [`HiddenFiles::Deny`]: `/.well-known/` is
/// where the web puts things that are *meant* to be fetched — ACME challenges for
/// certificate issuance, `security.txt`, app-site association files. Denying it would
/// break certificate renewal on any site served by this crate.
const WELL_KNOWN: &str = ".well-known";

/// Whether any segment of `decoded` is a hidden name, honoring the `.well-known`
/// exception.
///
/// Scope is deliberately the *request path* only, never the served root's own
/// filesystem path — a root that itself lives under a dot-directory
/// (`~/.config/site/public`) must keep working, since the operator chose that location
/// and no request can address it.
///
/// A segment of exactly `.` is a same-directory reference (`/./index.html`), not a
/// hidden name, so it is exempt. `..` never reaches here — the traversal check rejects
/// it first.
fn has_hidden_segment(decoded: &str) -> bool {
    decoded
        .trim_start_matches('/')
        .split('/')
        .enumerate()
        .any(|(index, segment)| {
            let is_well_known_root = index == 0 && segment == WELL_KNOWN;
            segment.starts_with('.') && segment != "." && !is_well_known_root
        })
}

/// [`resolve_with_canonical_root`] with an explicit hidden-file policy.
pub(crate) fn resolve_with_policy(
    root_canon: &Path,
    request_path: &str,
    hidden: HiddenFiles,
) -> Result<PathBuf, StaticError> {
    if request_path.contains('\0') {
        return Err(StaticError::Traversal(request_path.to_string()));
    }

    let decoded = decode_request_path(request_path);

    // Segment-based traversal check: reject only path segments exactly equal to ".."
    for segment in decoded.split('/') {
        if segment == ".." {
            return Err(StaticError::Traversal(request_path.to_string()));
        }
    }

    // `NotFound`, not a distinct error: a hidden file that exists and one that doesn't
    // must be indistinguishable, or the response becomes an oracle for what the root
    // contains — the same reasoning that collapses traversal into the miss message.
    if hidden == HiddenFiles::Deny && has_hidden_segment(&decoded) {
        return Err(StaticError::NotFound(request_path.to_string()));
    }

    let stripped = decoded.trim_start_matches('/');
    let joined = root_canon.join(stripped);

    let canon = canonicalize_within_root(root_canon, &joined)?;

    if canon.is_dir() {
        let index = canon.join("index.html");
        let _index_canon = canonicalize_within_root(root_canon, &index)?;
        Ok(index)
    } else {
        Ok(canon)
    }
}

/// Percent-decode a request path to UTF-8, falling back to the raw string if decoding
/// produces invalid UTF-8.
///
/// This function decodes percent-encoded characters in the path (e.g., `%2F` → `/`),
/// allowing clients to request files with non-ASCII characters in their names.
/// If the decoded bytes are not valid UTF-8, the original path is returned unchanged.
pub(crate) fn decode_request_path(request_path: &str) -> String {
    percent_encoding::percent_decode_str(request_path)
        .decode_utf8()
        .map(|s| s.to_string())
        .unwrap_or_else(|_| request_path.to_string())
}

/// Resolve a request path under a root directory, canonicalizing the root first.
///
/// This is a convenience wrapper around `resolve_with_canonical_root()` that canonicalizes
/// the root on every call. For production use where the root is fixed at startup, prefer
/// `Server::new()` which canonicalizes the root once and reuses it for all requests.
///
/// # Arguments
///
/// * `root` - The server root directory (need not be pre-canonicalized).
/// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
///
/// # Returns
///
/// - `Ok(PathBuf)` if the path resolves to a file within root.
/// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
/// - `Err(StaticError::Io)` if canonicalizing the root fails.
pub fn resolve(root: &Path, request_path: &str) -> Result<PathBuf, StaticError> {
    let root_canon = root.canonicalize().map_err(StaticError::Io)?;
    resolve_with_canonical_root(&root_canon, request_path)
}