mini-static 0.7.0

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;
}