mini-static 0.20.0

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

use hyper::Method;
use mini_static::{Server, TrailingSlash};
use std::fs;
use tempfile::TempDir;

/// A root holding `<dir>/index.html`, for the common redirect setup.
fn root_with_index(dir: &str) -> TempDir {
    let root = TempDir::new().unwrap();
    fs::create_dir_all(root.path().join(dir)).unwrap();
    fs::write(
        root.path().join(dir).join("index.html"),
        b"<html>Index</html>",
    )
    .unwrap();
    root
}

fn location(response: &hyper::Response<mini_static::ResponseBody>) -> Option<&str> {
    response
        .headers()
        .get("Location")
        .and_then(|v| v.to_str().ok())
}

#[tokio::test]
async fn directory_without_a_trailing_slash_redirects_to_one() {
    let root = root_with_index("docs");
    let server = Server::new(root.path()).unwrap();

    let response = common::get(&server, "/docs").await;

    assert_eq!(response.status().as_u16(), 301);
    // Exactly `/docs/` — not `//docs/`, not `/docs//`.
    assert_eq!(location(&response), Some("/docs/"));
    assert_eq!(
        response
            .headers()
            .get("X-Content-Type-Options")
            .and_then(|v| v.to_str().ok()),
        Some("nosniff"),
        "the redirect must still carry the nosniff header"
    );
}

#[tokio::test]
async fn nested_directory_redirects_to_its_full_path_with_a_slash() {
    let root = root_with_index("api/v1/users");
    let server = Server::new(root.path()).unwrap();

    let response = common::get(&server, "/api/v1/users").await;

    assert_eq!(response.status().as_u16(), 301);
    assert_eq!(location(&response), Some("/api/v1/users/"));
}

#[tokio::test]
async fn head_gets_the_same_redirect_as_get() {
    let root = root_with_index("docs");
    let server = Server::new(root.path()).unwrap();

    let response = common::request(&server, &Method::HEAD, "/docs").await;

    assert_eq!(response.status().as_u16(), 301);
    assert_eq!(location(&response), Some("/docs/"));
}

// The `Location` must be built from the raw request path, not the decoded one, or a
// directory with a space in its name redirects to a URL the client cannot request.
#[tokio::test]
async fn redirect_location_preserves_percent_encoding() {
    let root = root_with_index("my docs");
    let server = Server::new(root.path()).unwrap();

    let response = common::get(&server, "/my%20docs").await;

    assert_eq!(response.status().as_u16(), 301);
    assert_eq!(location(&response), Some("/my%20docs/"));
}

#[tokio::test]
async fn a_path_that_already_addresses_the_index_is_served_without_a_redirect() {
    let root = root_with_index("docs");
    let server = Server::new(root.path()).unwrap();

    // Regression test on the third case: `/docs/index.htm%6c` decodes to
    // `/docs/index.html`. The redirect decision must compare against the *decoded* path,
    // not the raw encoded string, or this produces a broken `Location:
    // /docs/index.htm%6c/`.
    for path in ["/docs/", "/docs/index.html", "/docs/index.htm%6c"] {
        let response = common::get(&server, path).await;
        assert_eq!(
            response.status().as_u16(),
            200,
            "{path} should be served directly"
        );
        assert!(location(&response).is_none(), "{path} should not redirect");
    }
}

#[tokio::test]
async fn directory_without_an_index_html_returns_404() {
    let root = TempDir::new().unwrap();
    fs::create_dir(root.path().join("empty")).unwrap();
    let server = Server::new(root.path()).unwrap();

    let response = common::get(&server, "/empty").await;

    assert_eq!(response.status().as_u16(), 404);
}

// -- TrailingSlash::Serve -----------------------------------------------------------

#[tokio::test]
async fn serve_mode_answers_the_unslashed_directory_directly() {
    let root = root_with_index("docs");
    let server = Server::new(root.path())
        .unwrap()
        .with_trailing_slash(TrailingSlash::Serve);

    let response = common::get(&server, "/docs").await;

    assert_eq!(response.status().as_u16(), 200);
    assert_eq!(
        location(&response),
        None,
        "serve mode must not redirect at all"
    );
}

#[tokio::test]
async fn serve_mode_still_answers_the_slashed_directory() {
    let root = root_with_index("docs");
    let server = Server::new(root.path())
        .unwrap()
        .with_trailing_slash(TrailingSlash::Serve);

    let response = common::get(&server, "/docs/").await;

    assert_eq!(response.status().as_u16(), 200);
}

#[tokio::test]
async fn serve_mode_returns_the_same_bytes_at_both_urls() {
    let root = root_with_index("docs");
    let server = Server::new(root.path())
        .unwrap()
        .with_trailing_slash(TrailingSlash::Serve);

    let slashless = common::body_bytes(common::get(&server, "/docs").await).await;
    let slashed = common::body_bytes(common::get(&server, "/docs/").await).await;

    assert_eq!(slashless, slashed);
    assert_eq!(slashless.as_ref(), b"<html>Index</html>");
}

#[tokio::test]
async fn serve_mode_does_not_invent_pages_for_missing_directories() {
    let root = root_with_index("docs");
    let server = Server::new(root.path())
        .unwrap()
        .with_trailing_slash(TrailingSlash::Serve);

    let response = common::get(&server, "/nope").await;

    assert_eq!(
        response.status().as_u16(),
        404,
        "relaxing the trailing slash must not relax what exists"
    );
}

#[tokio::test]
async fn redirect_is_the_default_when_trailing_slash_is_not_configured() {
    let root = root_with_index("docs");
    let server = Server::new(root.path()).unwrap();

    let response = common::get(&server, "/docs").await;

    assert_eq!(
        response.status().as_u16(),
        301,
        "the safe behaviour must be the one you get without asking"
    );
}