mini-static 0.38.7

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
//! A realistic eager-cache **hit** path, for measurement only.
//!
//! Not a proposal and not production code: it exists to replace an extrapolated number with
//! a measured one. `bench_memory` served a single fixed response with no lookup, which
//! flattered the idea badly — this does the work a real cached path would have to do:
//!
//!   * take the segments the router already decoded,
//!   * refuse a segment that must not reach a lookup (`..`, separators, NUL),
//!   * refuse a dot-prefixed segment, as the hidden-file policy does,
//!   * build a key and hash it,
//!   * look the entry up,
//!   * compare `If-None-Match` and answer `304` when it matches,
//!   * emit the same seven headers the real path emits.
//!
//! What it deliberately does *not* model: population (the walk, containment per file,
//! symlink refusal, regular-file checks), the fall-through to disk on a miss, encoding
//! variants, and range requests. Those are the costs that do not show up in a hit-rate
//! benchmark, and they are the reason this needs a plan rather than a patch.

use std::collections::HashMap;
use std::env;
use std::path::{Path, PathBuf};

use hyper::header::{
    HeaderValue, ACCEPT_RANGES, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, ETAG, IF_NONE_MATCH,
    VARY,
};
use hyper::{Response, StatusCode};
use mini_serve::{body, handler, PathSegments, RouteBuilder, ServeError};

struct Entry {
    bytes: hyper::body::Bytes,
    etag: HeaderValue,
    content_type: HeaderValue,
    len: usize,
}

/// Walk the root once and read every regular file. A real implementation would prove
/// containment per file and refuse anything that is not a regular file; this is a
/// measurement of the *hit* path, so the walk is deliberately naive and its cost is not
/// what is being measured.
fn populate(root: &Path) -> HashMap<String, Entry> {
    let mut map = HashMap::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else { continue };
        for entry in entries.flatten() {
            let path = entry.path();
            let Ok(meta) = entry.metadata() else { continue };
            if meta.is_dir() {
                stack.push(path);
                continue;
            }
            if !meta.is_file() {
                continue;
            }
            let Ok(bytes) = std::fs::read(&path) else { continue };
            let Ok(rel) = path.strip_prefix(root) else { continue };
            let key = rel.to_string_lossy().into_owned();
            let modified = meta
                .modified()
                .ok()
                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| d.as_secs_f64())
                .unwrap_or(0.0);
            let etag = HeaderValue::from_str(&format!("\"{}-{modified}\"", bytes.len()))
                .unwrap_or(HeaderValue::from_static("\"x\""));
            let content_type = HeaderValue::from_static("text/css; charset=utf-8");
            let len = bytes.len();
            map.insert(
                key,
                Entry { bytes: hyper::body::Bytes::from(bytes), etag, content_type, len },
            );
        }
    }
    map
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let port: u16 = env::var("PORT").unwrap_or_else(|_| "8099".into()).parse()?;
    let root = PathBuf::from(env::var("ROOT").unwrap_or_else(|_| "./bench/www".into()));
    let cache = std::sync::Arc::new(populate(&root));
    eprintln!("cached {} entries", cache.len());

    let app = RouteBuilder::stateless()
        .with_fallback(handler(move |req, _state| {
            let cache = std::sync::Arc::clone(&cache);
            async move {
                let segments = req
                    .extensions()
                    .get::<PathSegments>()
                    .map(|s| s.0.as_slice())
                    .unwrap_or(&[]);

                // The same refusals the real path makes, before anything is looked up.
                for segment in segments {
                    if segment.contains('/')
                        || segment.contains('\\')
                        || segment.contains('\0')
                        || segment == ".."
                    {
                        return Ok(Response::builder()
                            .status(StatusCode::NOT_FOUND)
                            .body(body("not found\n".into()))
                            .unwrap());
                    }
                    if segment.starts_with('.') && segment != "." {
                        return Ok(Response::builder()
                            .status(StatusCode::NOT_FOUND)
                            .body(body("not found\n".into()))
                            .unwrap());
                    }
                }

                let key = segments.join("/");
                let Some(entry) = cache.get(&key) else {
                    return Ok(Response::builder()
                        .status(StatusCode::NOT_FOUND)
                        .body(body("not found\n".into()))
                        .unwrap());
                };

                if req.headers().get(IF_NONE_MATCH).is_some_and(|v| v == entry.etag) {
                    let mut resp = Response::new(body(hyper::body::Bytes::new()));
                    *resp.status_mut() = StatusCode::NOT_MODIFIED;
                    resp.headers_mut().insert(ETAG, entry.etag.clone());
                    return Ok::<_, ServeError>(resp);
                }

                let mut resp = Response::new(body(entry.bytes.clone()));
                let headers = resp.headers_mut();
                headers.insert(CONTENT_TYPE, entry.content_type.clone());
                headers.insert(CONTENT_LENGTH, entry.len.into());
                headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
                headers.insert(VARY, HeaderValue::from_static("Accept-Encoding"));
                headers.insert(ETAG, entry.etag.clone());
                headers.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
                Ok(resp)
            }
        }))
        .seal();

    let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
    app.run(listener, mini_serve::shutdown_signal()?).await?;
    Ok(())
}