mini-static 0.31.2

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use std::fs;
use std::time::Duration;

use mini_static::Server;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;

const HEADER_TIMEOUT: Duration = Duration::from_millis(100);
/// Comfortably past `HEADER_TIMEOUT` without making the suite slow.
const PAST_TIMEOUT: Duration = Duration::from_millis(400);

async fn serve() -> (u16, TempDir, mini_static::ServerHandle) {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("test.txt"), b"hello world").unwrap();
    let server = Server::new(root.path()).unwrap();
    let (port, handle) = server.run(HEADER_TIMEOUT).await.unwrap();
    tokio::time::sleep(Duration::from_millis(10)).await;
    (port, root, handle)
}

/// Reads one complete response off `stream`, stopping at the end of the body so the
/// connection stays open for the next request. `test.txt` is 11 bytes and the response
/// carries `Content-Length`, so a read that reaches the body's end has the whole thing.
async fn read_one_response(stream: &mut TcpStream) -> String {
    let mut buf = vec![0u8; 4096];
    let mut seen = Vec::new();
    loop {
        let n = stream.read(&mut buf).await.unwrap();
        assert_ne!(n, 0, "connection closed mid-response");
        seen.extend_from_slice(&buf[..n]);
        if seen.windows(4).any(|w| w == b"\r\n\r\n") && seen.ends_with(b"hello world") {
            return String::from_utf8_lossy(&seen).into_owned();
        }
    }
}

/// The regression this commit exists for. The header bounds used to be enforced by a
/// pre-read that ran once per *connection*, so a client that completed one cheap request
/// and then stalled mid-header on the same keep-alive connection was bounded by nothing:
/// it held a connection-semaphore permit indefinitely. Both requests must be bounded.
#[tokio::test]
async fn a_stall_on_the_second_request_of_a_connection_still_times_out() {
    let (port, _root, _handle) = serve().await;
    let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
        .await
        .unwrap();

    // First request: complete, and deliberately keep-alive.
    stream
        .write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\n\r\n")
        .await
        .unwrap();
    let first = read_one_response(&mut stream).await;
    assert!(
        first.starts_with("HTTP/1.1 200"),
        "first request should serve: {first}"
    );

    // Second request: start the headers, then stall without ever terminating the block.
    stream
        .write_all(b"GET /test.txt HTTP/1.1\r\n")
        .await
        .unwrap();
    tokio::time::sleep(PAST_TIMEOUT).await;

    let mut buf = vec![0u8; 1024];
    let n = stream.read(&mut buf).await.unwrap_or(0);
    let tail = String::from_utf8_lossy(&buf[..n]);
    assert!(
        n == 0 || tail.starts_with("HTTP/1.1 408") || tail.starts_with("HTTP/1.1 4"),
        "a stalled second request must be closed or refused, got: {tail}"
    );
}

/// The size ceiling has the same per-connection-vs-per-request problem: it must still
/// reject an oversized header block on a later request, not just the first.
#[tokio::test]
async fn an_oversized_header_on_the_second_request_is_still_rejected() {
    let (port, _root, _handle) = serve().await;
    let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
        .await
        .unwrap();

    stream
        .write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\n\r\n")
        .await
        .unwrap();
    let first = read_one_response(&mut stream).await;
    assert!(first.starts_with("HTTP/1.1 200"), "first request: {first}");

    // Well past MAX_HEADER_BYTES (64 KiB), sent as one never-terminated header line.
    stream
        .write_all(b"GET /test.txt HTTP/1.1\r\nX-Big: ")
        .await
        .unwrap();
    let filler = vec![b'a'; 8 * 1024];
    let mut wrote_all = true;
    for _ in 0..16 {
        if stream.write_all(&filler).await.is_err() {
            // The server closing on us mid-write is itself the rejection.
            wrote_all = false;
            break;
        }
    }

    let mut buf = vec![0u8; 1024];
    let n = stream.read(&mut buf).await.unwrap_or(0);
    let tail = String::from_utf8_lossy(&buf[..n]);
    assert!(
        !wrote_all || n == 0 || tail.starts_with("HTTP/1.1 4"),
        "an oversized header block on a keep-alive connection must be rejected, got: {tail}"
    );
}

/// A well-behaved client making several requests on one connection must not be
/// penalized by the per-request bounds — the guard against a fix that simply closes
/// keep-alive connections early.
#[tokio::test]
async fn sequential_requests_on_one_connection_all_serve() {
    let (port, _root, _handle) = serve().await;
    let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
        .await
        .unwrap();

    for i in 0..3 {
        stream
            .write_all(b"GET /test.txt HTTP/1.1\r\nHost: localhost\r\n\r\n")
            .await
            .unwrap();
        let response = read_one_response(&mut stream).await;
        assert!(
            response.starts_with("HTTP/1.1 200"),
            "request {i} on a reused connection should serve: {response}"
        );
    }
}