mini-static 0.12.6

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use mini_static::Server;
use std::fs;
use std::time::Duration;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;

/// Read from `conn` until `needle` appears in the accumulated bytes, or `bound` elapses.
/// Bounded so a test hangs for at most `bound` instead of forever if the expected data
/// never arrives (A2: every wait has a stated upper bound).
async fn read_until(conn: &mut TcpStream, needle: &str, bound: Duration) -> String {
    let mut buf = Vec::new();
    let mut chunk = [0u8; 4096];

    timeout(bound, async {
        loop {
            let n = conn.read(&mut chunk).await.expect("read failed");
            assert!(n > 0, "connection closed before {needle:?} appeared");
            buf.extend_from_slice(&chunk[..n]);
            if String::from_utf8_lossy(&buf).contains(needle) {
                break;
            }
        }
    })
    .await
    .unwrap_or_else(|_| panic!("timed out waiting for {needle:?}, got so far: {}", String::from_utf8_lossy(&buf)));

    String::from_utf8_lossy(&buf).into_owned()
}

#[tokio::test]
async fn live_reload_disabled_by_default_serves_plain_html_and_404s_reload_endpoint() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), b"<html><body>hi</body></html>").unwrap();

    let server = Server::new(root.path()).unwrap();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let mut conn = TcpStream::connect(&addr).await.unwrap();
    conn.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
        .await
        .unwrap();
    let mut response_data = Vec::new();
    conn.read_to_end(&mut response_data).await.unwrap();
    let response = String::from_utf8_lossy(&response_data);

    assert!(response.contains("HTTP/1.1 200"));
    assert!(
        !response.contains("EventSource"),
        "no live-reload script should be injected when with_live_reload() was never called"
    );

    let mut conn = TcpStream::connect(&addr).await.unwrap();
    let request = format!(
        "GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
        mini_static::LIVE_RELOAD_PATH
    );
    conn.write_all(request.as_bytes()).await.unwrap();
    let mut response_data = Vec::new();
    conn.read_to_end(&mut response_data).await.unwrap();
    let response = String::from_utf8_lossy(&response_data);
    assert!(
        response.contains("HTTP/1.1 404"),
        "reload endpoint should not be routed when live-reload is disabled, got: {response}"
    );

    handle.shutdown().await;
}

#[tokio::test]
async fn live_reload_enabled_injects_script_into_html_with_correct_content_length() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), b"<html><body>hi</body></html>").unwrap();

    let server = Server::new(root.path()).unwrap().with_live_reload();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let mut conn = TcpStream::connect(&addr).await.unwrap();
    conn.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
        .await
        .unwrap();
    let mut response_data = Vec::new();
    conn.read_to_end(&mut response_data).await.unwrap();
    let response = String::from_utf8_lossy(&response_data);

    assert!(response.contains("HTTP/1.1 200"));
    assert!(response.contains(mini_static::LIVE_RELOAD_PATH));
    assert!(
        response.find("EventSource").unwrap() < response.find("</body>").unwrap(),
        "script must be injected before the closing body tag, got: {response}"
    );

    let content_length: usize = response
        .lines()
        .find(|l| l.to_lowercase().starts_with("content-length:"))
        .and_then(|l| l.split(':').nth(1))
        .and_then(|v| v.trim().parse().ok())
        .expect("Content-Length header present");
    let body = response.split("\r\n\r\n").nth(1).unwrap();
    assert_eq!(
        content_length,
        body.len(),
        "Content-Length must reflect the injected body, not the on-disk file size"
    );

    handle.shutdown().await;
}

#[tokio::test]
async fn live_reload_enabled_leaves_non_html_files_byte_identical() {
    let root = TempDir::new().unwrap();
    let css = b"body { color: red; }".to_vec();
    fs::write(root.path().join("style.css"), &css).unwrap();

    let server = Server::new(root.path()).unwrap().with_live_reload();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let mut conn = TcpStream::connect(&addr).await.unwrap();
    conn.write_all(b"GET /style.css HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
        .await
        .unwrap();
    let mut response_data = Vec::new();
    conn.read_to_end(&mut response_data).await.unwrap();
    let response = String::from_utf8_lossy(&response_data);

    assert!(response.contains("HTTP/1.1 200"));
    assert!(response.ends_with(&String::from_utf8(css).unwrap()));

    handle.shutdown().await;
}

#[tokio::test]
async fn live_reload_sse_endpoint_broadcasts_file_change_to_connected_client() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), b"<html></html>").unwrap();

    let server = Server::new(root.path()).unwrap().with_live_reload();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let mut conn = TcpStream::connect(&addr).await.unwrap();
    let request = format!("GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n", mini_static::LIVE_RELOAD_PATH);
    conn.write_all(request.as_bytes()).await.unwrap();

    let headers = read_until(&mut conn, "\r\n\r\n", Duration::from_secs(2)).await;
    assert!(headers.contains("HTTP/1.1 200"));
    assert!(headers.to_lowercase().contains("content-type: text/event-stream"));

    // Give the watcher's first poll pass (500ms, per `watcher::start_watching`) time to
    // complete before writing, so the write is seen as a change, not the initial baseline.
    tokio::time::sleep(Duration::from_millis(700)).await;
    fs::write(root.path().join("style.css"), b"body{}").unwrap();

    let frame = read_until(&mut conn, "\n\n", Duration::from_secs(2)).await;
    assert!(
        frame.contains("event: css"),
        "expected an SSE frame for the new .css file, got: {frame}"
    );

    handle.shutdown().await;
}

// Disproves the prior implementation, which wrapped the *entire* connection lifetime
// (not just the header-read phase) in `header_timeout`. Under that implementation, an
// SSE connection surviving past `header_timeout` was aborted mid-stream — after the `200
// OK` headers had already been flushed but before any further frame — which a real
// browser reports as `net::ERR_INCOMPLETE_CHUNKED_ENCODING`. This test uses a short
// `header_timeout` and waits well past it before triggering a change, so it would have
// failed (connection reset, no frame received) against that implementation.
#[tokio::test]
async fn live_reload_sse_connection_survives_past_header_timeout() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), b"<html></html>").unwrap();

    let header_timeout = Duration::from_millis(100);
    let server = Server::new(root.path()).unwrap().with_live_reload();
    let (port, handle) = server.run(header_timeout).await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let mut conn = TcpStream::connect(&addr).await.unwrap();
    let request = format!("GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n", mini_static::LIVE_RELOAD_PATH);
    conn.write_all(request.as_bytes()).await.unwrap();

    let headers = read_until(&mut conn, "\r\n\r\n", Duration::from_secs(2)).await;
    assert!(headers.contains("HTTP/1.1 200"));

    // Outlive header_timeout several times over, well past the watcher's bounded 500ms
    // poll interval, so any premature connection abort has ample opportunity to happen
    // first — and clears the poll interval by a wide enough margin to avoid flakiness.
    tokio::time::sleep(header_timeout * 8).await;
    fs::write(root.path().join("style.css"), b"body{}").unwrap();

    let frame = read_until(&mut conn, "\n\n", Duration::from_secs(2)).await;
    assert!(
        frame.contains("event: css"),
        "SSE connection should still be alive long after header_timeout elapsed, got: {frame}"
    );

    // Close the client side first so the server observes EOF and the connection's task
    // ends on its own — shutdown()'s drain-timeout/abort behavior for a connection that
    // *doesn't* close on its own is covered separately, below.
    drop(conn);
    tokio::time::timeout(Duration::from_secs(2), handle.shutdown())
        .await
        .expect("shutdown should complete promptly once the client has disconnected");
}

// Disproves an implementation where `ServerHandle::shutdown()` waits unconditionally for
// every in-flight connection to finish on its own: an open live-reload SSE connection has
// no natural end (it stays open until a file changes), so that implementation would hang
// forever here. `shutdown_with_timeout` with a short grace period must instead abort it.
#[tokio::test]
async fn live_reload_shutdown_aborts_a_still_open_sse_connection_after_the_drain_timeout() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("index.html"), b"<html></html>").unwrap();

    let server = Server::new(root.path()).unwrap().with_live_reload();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    let addr = format!("127.0.0.1:{port}");

    let mut conn = TcpStream::connect(&addr).await.unwrap();
    let request = format!("GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n", mini_static::LIVE_RELOAD_PATH);
    conn.write_all(request.as_bytes()).await.unwrap();
    let headers = read_until(&mut conn, "\r\n\r\n", Duration::from_secs(2)).await;
    assert!(headers.contains("HTTP/1.1 200"), "SSE connection should be established, got: {headers}");

    // Never triggers a file change and never closes `conn` — the SSE connection is left
    // genuinely open, with nothing that would end it on its own.
    tokio::time::timeout(Duration::from_secs(2), handle.shutdown_with_timeout(Duration::from_millis(200)))
        .await
        .expect("shutdown_with_timeout should abort the open connection at its drain timeout, not hang");
}