mini-static 0.14.6

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

use hyper::Method;
use mini_static::Server;
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);
}