mini-static 0.29.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
//! Benchmarks the HTTP request path end to end — `Server::handle_request`, the same
//! entry the accept loop and the integration tests use.
//!
//! `path_resolution.rs` measures only the resolver. Everything a real response also pays
//! for — opening the file, stat, ETag generation, MIME lookup, sidecar probing, and
//! streaming the body — was unmeasured, which is precisely the budget any future caching
//! work would claim to improve. These four cases establish that baseline:
//!
//! - `small_file_200` — the common case, a 1 KiB file served whole.
//! - `large_file_200` — 1 MiB, exercising `FileBody`'s chunked streaming.
//! - `not_modified_304` — a revalidating client: headers only, no body, but still a
//!   full resolve + stat.
//! - `sidecar_hit_200` — a precompressed `.br` sidecar, which costs extra `open`/stat
//!   syscalls on top of the original file's.
//!
//! Bodies are drained, not dropped: a `Response` is cheap to build and expensive to
//! read, so measuring construction alone would report a number no client experiences.
//!
//! # Baseline
//!
//! Recorded at 0.28.0 on an Apple-silicon laptop, APFS, current-thread runtime — useful
//! as a shape, not as an absolute:
//!
//! | case | median |
//! |---|---|
//! | `not_modified_304` | 37.9 µs |
//! | `small_file_200` | 56.2 µs |
//! | `sidecar_hit_200` | 69.7 µs |
//! | `large_file_200` | 229.6 µs |
//!
//! What the shape says: the 304 — which serves no body at all — still costs 37.9 µs, so
//! roughly two thirds of a small file's 56.2 µs is spent before a single byte is read.
//! That fixed cost is resolution and stat (canonicalize runs on the blocking pool), and
//! it is what a metadata or resolved-path cache would target. The sidecar probe's extra
//! `open`+stat is visible as the 13.5 µs between `small_file_200` and `sidecar_hit_200`.
//! Streaming itself is not the bottleneck: 1 MiB costs ~173 µs over the 304 floor, about
//! 6 GB/s, which is memory bandwidth rather than a chunking inefficiency.

use std::fs;

use bytes::Bytes;
use criterion::{criterion_group, criterion_main, Criterion};
use http_body_util::BodyExt;
use hyper::{HeaderMap, Method};
use mini_static::{ResponseBody, Server};
use tempfile::TempDir;
use tokio::runtime::Builder;

const SMALL_FILE_BYTES: usize = 1024;
const LARGE_FILE_BYTES: usize = 1024 * 1024;

fn build_fixture_root() -> TempDir {
    let dir = tempfile::tempdir().expect("create fixture tempdir");
    let root = dir.path();

    fs::write(root.join("small.txt"), vec![b'a'; SMALL_FILE_BYTES]).expect("write small fixture");
    fs::write(root.join("large.bin"), vec![b'b'; LARGE_FILE_BYTES]).expect("write large fixture");

    // A sidecar whose bytes differ from the original, so serving the wrong one would
    // show up as a different response size rather than passing silently.
    fs::write(root.join("asset.js"), vec![b'c'; SMALL_FILE_BYTES]).expect("write asset fixture");
    fs::write(root.join("asset.js.br"), vec![b'd'; 256]).expect("write sidecar fixture");

    dir
}

fn headers_with(name: &str, value: &str) -> HeaderMap {
    let mut headers = HeaderMap::new();
    headers.insert(
        hyper::header::HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
        value.parse().expect("valid header value"),
    );
    headers
}

/// Drive one request to completion, body included, and hand back the collected bytes so
/// the optimizer cannot discard the work.
async fn drain(server: &Server, method: &Method, path: &str, headers: &HeaderMap) -> Bytes {
    let response: hyper::Response<ResponseBody> =
        server.handle_request(method, path, headers).await;
    response
        .into_body()
        .collect()
        .await
        .expect("body collection is infallible")
        .to_bytes()
}

fn bench_handle_request(c: &mut Criterion) {
    // A current-thread runtime: the crate's dev-dependencies enable `rt` but not
    // `rt-multi-thread`, and a single-threaded driver measures the request path itself
    // rather than the scheduler's work-stealing.
    let runtime = Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("build tokio runtime");
    let fixture = build_fixture_root();
    let server = Server::new(fixture.path()).expect("canonicalize fixture root");
    let no_headers = HeaderMap::new();

    // Captured from a real response so the 304 case revalidates against the validator
    // the server actually issues, rather than a hand-written guess at its format.
    let etag = runtime.block_on(async {
        server
            .handle_request(&Method::GET, "/small.txt", &no_headers)
            .await
            .headers()
            .get("ETag")
            .expect("a 200 carries an ETag")
            .to_str()
            .expect("ETag is ASCII")
            .to_string()
    });
    let revalidate = headers_with("if-none-match", &etag);
    let accept_brotli = headers_with("accept-encoding", "br");

    let mut group = c.benchmark_group("handle_request");

    group.bench_function("small_file_200", |b| {
        b.iter(|| runtime.block_on(drain(&server, &Method::GET, "/small.txt", &no_headers)))
    });

    group.bench_function("large_file_200", |b| {
        b.iter(|| runtime.block_on(drain(&server, &Method::GET, "/large.bin", &no_headers)))
    });

    group.bench_function("not_modified_304", |b| {
        b.iter(|| runtime.block_on(drain(&server, &Method::GET, "/small.txt", &revalidate)))
    });

    group.bench_function("sidecar_hit_200", |b| {
        b.iter(|| runtime.block_on(drain(&server, &Method::GET, "/asset.js", &accept_brotli)))
    });

    group.finish();
}

criterion_group!(benches, bench_handle_request);
criterion_main!(benches);