mini-static 0.38.7

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
//! RFC 3986 §3.3: `/` separates path segments; a percent-encoded `%2F` is an ordinary
//! character *inside* a segment and does not separate anything.
//!
//! Through 0.31.x this crate decoded the whole path and split afterwards, so `%2F` was
//! promoted into a separator and `/admin%2Fconfig` reached `admin/config` on disk.
//! Containment held — the file was still under the root — so nothing here looked wrong in
//! isolation. It went wrong the moment anything routed in front of this server: a router
//! reading the same request per the RFC sees one segment, matches no route, and hands off
//! to the static fallback, which then serves a file the unmatched route was guarding. The
//! guard is never consulted, because as far as the router is concerned the guarded route
//! was never requested.
//!
//! The whole suite passed throughout. These tests exist because agreeing with yourself is
//! not evidence.

mod common;

use std::fs;

use mini_static::Server;
use tempfile::TempDir;

/// A root whose nested file is reachable honestly at `/admin/config`.
fn root_with_nested_file() -> TempDir {
    let root = TempDir::new().unwrap();
    fs::create_dir(root.path().join("admin")).unwrap();
    fs::write(root.path().join("admin/config"), b"SECRET").unwrap();
    fs::write(root.path().join("plain.txt"), b"ordinary").unwrap();
    root
}

/// The honest path must keep working, or the test below proves only that the server is
/// broken.
#[tokio::test]
async fn the_unencoded_path_still_serves_the_nested_file() {
    let root = root_with_nested_file();
    let server = Server::new(root.path()).unwrap();

    let response = common::get(&server, "/admin/config").await;
    assert_eq!(response.status().as_u16(), 200);
    assert_eq!(&common::body_bytes(response).await[..], b"SECRET");
}

/// The defect itself, in both hex cases — a decoder that lowercases or uppercases before
/// comparing would catch only one.
#[tokio::test]
async fn an_encoded_separator_does_not_reach_a_nested_file() {
    let root = root_with_nested_file();
    let server = Server::new(root.path()).unwrap();

    for path in ["/admin%2Fconfig", "/admin%2fconfig", "/%61dmin%2Fconfig"] {
        let response = common::get(&server, path).await;
        assert_eq!(
            response.status().as_u16(),
            404,
            "{path} reached a nested file through an encoded separator"
        );
    }
}

/// A decoded backslash is refused on every platform, not only where it separates.
///
/// The root really does contain a file named `admin\config` — on Unix that is one
/// perfectly legal filename. Without the refusal this request serves it, so the assertion
/// distinguishes "refused" from "no such file", which an empty root could not.
#[tokio::test]
async fn an_encoded_backslash_is_refused() {
    let root = root_with_nested_file();
    fs::write(root.path().join(r"admin\config"), b"BACKSLASH-NAMED").unwrap();
    let server = Server::new(root.path()).unwrap();

    let response = common::get(&server, "/admin%5Cconfig").await;
    assert_eq!(
        response.status().as_u16(),
        404,
        "a decoded backslash resolved to a file"
    );
}

/// `%2E%2E` must be caught by the same check that catches a literal `..`, or the
/// traversal defence is only as good as the client's willingness to spell it out.
///
/// Hidden files are *served* here, and that is load-bearing rather than incidental. Under
/// the default policy `..` is refused by the dotfile check — it starts with a dot — so a
/// test written against a default server passes with the traversal guard deleted. It
/// measured the wrong defence, and the mutation run is what said so.
#[tokio::test]
async fn an_encoded_dot_dot_is_still_refused() {
    let root = root_with_nested_file();
    let server = Server::new(root.path()).unwrap().with_hidden_files();

    for path in ["/admin/../plain.txt", "/admin/%2E%2E/plain.txt", "/%2e%2e/plain.txt"] {
        let response = common::get(&server, path).await;
        assert_eq!(response.status().as_u16(), 404, "{path} was not refused");
    }
}

/// The other direction, and the reason this fix is not simply "reject percent-encoding".
/// Encoding exists so that a filename can contain a space or a non-ASCII character, and
/// refusing those would trade one bug for a more visible one.
#[tokio::test]
async fn ordinary_percent_encoding_still_resolves() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("a file.txt"), b"spaced").unwrap();
    fs::write(root.path().join("café.txt"), b"accented").unwrap();
    fs::create_dir(root.path().join("docs")).unwrap();
    fs::write(root.path().join("docs/index.html"), b"<html>docs</html>").unwrap();
    let server = Server::new(root.path()).unwrap();

    for (path, expected) in [
        ("/a%20file.txt", &b"spaced"[..]),
        ("/caf%C3%A9.txt", &b"accented"[..]),
        // The case the directory-redirect logic cares about: an explicit, partly encoded
        // request for index.html is served rather than redirected.
        ("/docs/index.htm%6C", &b"<html>docs</html>"[..]),
    ] {
        let response = common::get(&server, path).await;
        assert_eq!(response.status().as_u16(), 200, "{path} should resolve");
        assert_eq!(&common::body_bytes(response).await[..], expected, "{path}");
    }
}