mini-static 0.29.0

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;

/// The HTTP/2 connection preface a prior-knowledge (h2c) client opens with, per
/// RFC 9113 §3.4. Note it ends in `\r\n\r\n` — the same terminator an HTTP/1 header
/// block ends in, which is why a byte-scanning pre-read cannot tell the two apart.
const H2_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";

/// The first byte of an HTTP/2 frame header is the top byte of a 24-bit length; a
/// server that accepted the preface answers with a SETTINGS frame (type `0x04` at
/// offset 3). An HTTP/1.1 server answers with ASCII, or with nothing at all.
const H2_FRAME_TYPE_SETTINGS: u8 = 0x04;

/// Bounds the read below. A server that *accepts* the preface answers with SETTINGS and
/// then holds the connection open for more frames, so `read_to_end` would never see EOF
/// — the regression this test exists to catch would hang the suite instead of failing
/// it. Reaching this deadline is itself a failure: it means the connection stayed open.
const READ_DEADLINE: Duration = Duration::from_secs(5);

/// Returns the `TempDir` and `ServerHandle` alongside the port: dropping either one
/// deletes the served root or stops the listener, so both must outlive the request.
async fn serve_fixture() -> (u16, TempDir, mini_static::ServerHandle) {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), b"<html>hello</html>").unwrap();
    let server = Server::new(root.path()).unwrap();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    tokio::time::sleep(Duration::from_millis(10)).await;
    (port, root, handle)
}

/// A prior-knowledge HTTP/2 client must not get an HTTP/2 session. hyper's HTTP/1
/// parser rejects the `PRI * HTTP/2.0` request line; whether it writes a 400 before
/// closing is hyper's choice, so the contract asserted here is the negative one — no
/// SETTINGS frame, therefore no h2 session.
#[tokio::test]
async fn an_http2_preface_never_negotiates_an_http2_session() {
    let (port, _root, _handle) = serve_fixture().await;
    let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
        .await
        .unwrap();

    stream.write_all(H2_PREFACE).await.unwrap();

    let mut response = Vec::new();
    let read = tokio::time::timeout(READ_DEADLINE, stream.read_to_end(&mut response)).await;
    assert!(
        read.is_ok(),
        "connection stayed open past {READ_DEADLINE:?} after the h2 preface — the server \
         accepted it and is waiting for frames; bytes so far: {response:?}"
    );
    read.unwrap().unwrap();

    assert!(
        response.get(3) != Some(&H2_FRAME_TYPE_SETTINGS),
        "server answered the h2 preface with a SETTINGS frame: {response:?}"
    );
    assert!(
        response.is_empty() || response.starts_with(b"HTTP/1."),
        "expected an HTTP/1.x reply or a closed connection, got: {}",
        String::from_utf8_lossy(&response)
    );
}

/// The negative assertion above is only meaningful if the same socket setup does
/// serve a normal HTTP/1.1 request — otherwise it would pass against a dead port.
#[tokio::test]
async fn an_http1_request_on_the_same_listener_still_serves() {
    let (port, _root, _handle) = serve_fixture().await;
    let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
        .await
        .unwrap();

    stream
        .write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
        .await
        .unwrap();

    let mut response = Vec::new();
    tokio::time::timeout(READ_DEADLINE, stream.read_to_end(&mut response))
        .await
        .expect("server did not close the connection despite `Connection: close`")
        .unwrap();
    let response = String::from_utf8_lossy(&response);

    assert!(
        response.starts_with("HTTP/1.1 200"),
        "expected a served HTTP/1.1 response, got: {response}"
    );
}