mini-static 0.29.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
mod common;

use hyper::header::HeaderMap;
use hyper::Method;
use mini_static::Server;
use std::fs;
use tempfile::TempDir;

/// A traversal attempt and a genuinely missing file must be indistinguishable in the
/// response — same status and same body — or the difference itself discloses whether a
/// path exists outside the root.
#[tokio::test]
async fn traversal_and_missing_file_are_indistinguishable() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("exists.txt"), b"content").unwrap();
    let server = Server::new(root.path()).unwrap();

    let traversal = common::get(&server, "/../../../etc/passwd").await;
    let missing = common::get(&server, "/definitely-not-a-real-file-xyz.txt").await;

    assert_eq!(traversal.status().as_u16(), 404);
    assert_eq!(missing.status().as_u16(), 404);

    let traversal_body = common::body_bytes(traversal).await;
    let missing_body = common::body_bytes(missing).await;
    assert_eq!(
        traversal_body, missing_body,
        "the two bodies must be byte-identical, or the response leaks which case occurred"
    );
}

#[tokio::test]
async fn every_response_carries_nosniff_and_success_returns_the_real_file_content() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("test.txt"), b"content").unwrap();
    let server = Server::new(root.path()).unwrap();

    for path in ["/../etc/passwd", "/nonexistent.txt", "/test.txt"] {
        let response = common::get(&server, path).await;
        assert_eq!(
            response
                .headers()
                .get("X-Content-Type-Options")
                .map(|v| v.to_str().unwrap()),
            Some("nosniff"),
            "{path} should carry nosniff"
        );
    }

    // The success case must return the file's actual content, not a placeholder — this
    // is the check that would have caught `handle_request` answering every successful
    // resolve with a hardcoded "ok\n" body regardless of the file's real content.
    let success = common::get(&server, "/test.txt").await;
    assert_eq!(success.status().as_u16(), 200);
    let body = common::body_bytes(success).await;
    assert_eq!(
        &body[..],
        b"content",
        "success response body should be the file's real content"
    );
}

/// The end-to-end form of the ETag-precision bug: rewrite a file with different content
/// of the same length, fast enough to land in the same whole second, and revalidate with
/// the old validator. A whole-second ETag reproduced itself exactly here, so the client
/// was told `304 Not Modified` and kept serving stale bytes — the failure mode is a
/// build pipeline regenerating an asset, which this crate ships.
#[tokio::test]
async fn a_same_second_rewrite_of_equal_length_is_not_answered_304() {
    let root = TempDir::new().unwrap();
    let path = root.path().join("asset.js");
    fs::write(&path, b"console.log(1)").unwrap();
    let server = Server::new(root.path()).unwrap();

    let first = common::get(&server, "/asset.js").await;
    let stale_etag = first.headers().get("ETag").unwrap().clone();

    // Same byte length, different content, written immediately — same second.
    fs::write(&path, b"console.log(2)").unwrap();

    let mut headers = HeaderMap::new();
    headers.insert("if-none-match", stale_etag);
    let revalidated = server
        .handle_request(&Method::GET, "/asset.js", &headers)
        .await;

    assert_eq!(
        revalidated.status().as_u16(),
        200,
        "a changed file must not revalidate as 304"
    );
    assert_eq!(
        common::body_bytes(revalidated).await.as_ref(),
        b"console.log(2)",
        "the client must receive the new bytes, not the cached ones"
    );
}

/// The 304 built its own response for a while and was the single status that could
/// arrive without `nosniff`. A revalidating client is exactly the one that keeps the
/// representation around, so it is the worst response to hand back a weaker header set
/// than the `200` it is confirming.
#[tokio::test]
async fn a_304_carries_nosniff_like_every_other_response() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("test.txt"), b"content").unwrap();
    let server = Server::new(root.path()).unwrap();

    let first = common::get(&server, "/test.txt").await;
    let etag = first
        .headers()
        .get("ETag")
        .expect("a 200 must carry an ETag to revalidate against")
        .clone();

    let mut headers = HeaderMap::new();
    headers.insert("if-none-match", etag);
    let revalidated = server
        .handle_request(&Method::GET, "/test.txt", &headers)
        .await;

    assert_eq!(
        revalidated.status().as_u16(),
        304,
        "a matching ETag must revalidate, or this test is asserting nothing"
    );
    assert_eq!(
        revalidated
            .headers()
            .get("X-Content-Type-Options")
            .map(|v| v.to_str().unwrap()),
        Some("nosniff"),
    );
}