mini-static 0.5.2

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

use crate::error::StaticError;

/// 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> {
    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()));
        }
    }

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

    let canon = joined.canonicalize().map_err(|_| {
        StaticError::NotFound(request_path.to_string())
    })?;

    if !canon.starts_with(root_canon) {
        return Err(StaticError::Traversal(request_path.to_string()));
    }

    if canon.is_dir() {
        let index = canon.join("index.html");
        // Canonicalizing (not just `.exists()`) both confirms the file is present and
        // resolves any symlink so the boundary check below also covers a symlinked
        // index.html pointing outside root — `.exists()` alone would miss that.
        let index_canon = index.canonicalize().map_err(|_| {
            StaticError::NotFound(request_path.to_string())
        })?;
        if !index_canon.starts_with(root_canon) {
            return Err(StaticError::Traversal(request_path.to_string()));
        }
        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)
}