mini-static 0.29.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use std::fs;
use std::io::Write;
use std::sync::{Arc, Mutex};
use std::time::Duration;

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

/// A log sink the test can read back. `with_request_logging_to` takes ownership of the
/// writer, so sharing the buffer through an `Arc` is the only way to inspect what was
/// written — which is precisely why the builder takes a writer instead of hardcoding
/// stderr: libtest cannot capture `eprintln!` from a spawned server task.
#[derive(Clone, Default)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);

impl SharedBuffer {
    fn contents(&self) -> String {
        String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
    }
}

impl Write for SharedBuffer {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        self.0.lock().unwrap().extend_from_slice(buf);
        Ok(buf.len())
    }

    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

fn root_with_file() -> TempDir {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("test.txt"), b"hello world").unwrap();
    root
}

async fn get(port: u16, path: &str) {
    let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
        .await
        .unwrap();
    stream
        .write_all(
            format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
                .as_bytes(),
        )
        .await
        .unwrap();
    let mut response = Vec::new();
    stream.read_to_end(&mut response).await.unwrap();
}

#[tokio::test]
async fn a_served_request_is_logged_with_its_method_path_status_and_size() {
    let root = root_with_file();
    let log = SharedBuffer::default();
    let server = Server::new(root.path())
        .unwrap()
        .with_request_logging_to(Box::new(log.clone()));
    let (port, _handle) = server.run_ephemeral().await.unwrap();
    tokio::time::sleep(Duration::from_millis(10)).await;

    get(port, "/test.txt").await;
    tokio::time::sleep(Duration::from_millis(50)).await;

    let line = log.contents();
    assert!(line.starts_with("GET /test.txt 200 11 "), "got: {line:?}");
    assert!(
        line.trim_end().ends_with("ms"),
        "a duration should close the line, got: {line:?}"
    );
}

#[tokio::test]
async fn a_miss_is_logged_with_its_404() {
    let root = root_with_file();
    let log = SharedBuffer::default();
    let server = Server::new(root.path())
        .unwrap()
        .with_request_logging_to(Box::new(log.clone()));
    let (port, _handle) = server.run_ephemeral().await.unwrap();
    tokio::time::sleep(Duration::from_millis(10)).await;

    get(port, "/nope.txt").await;
    tokio::time::sleep(Duration::from_millis(50)).await;

    assert!(
        log.contents().contains("GET /nope.txt 404"),
        "got: {:?}",
        log.contents()
    );
}

/// The path is logged exactly as received. A traversal attempt is the log line an
/// operator most needs to see verbatim, and normalizing it would hide what was sent.
#[tokio::test]
async fn the_logged_path_is_the_raw_request_path() {
    let root = root_with_file();
    let log = SharedBuffer::default();
    let server = Server::new(root.path())
        .unwrap()
        .with_request_logging_to(Box::new(log.clone()));
    let (port, _handle) = server.run_ephemeral().await.unwrap();
    tokio::time::sleep(Duration::from_millis(10)).await;

    get(port, "/%2E%2E/etc/passwd").await;
    tokio::time::sleep(Duration::from_millis(50)).await;

    assert!(
        log.contents().contains("/%2E%2E/etc/passwd"),
        "the raw, undecoded path should appear, got: {:?}",
        log.contents()
    );
}

/// Logging is opt-in: a library writing to a process's output uninvited is a surprise.
#[tokio::test]
async fn nothing_is_logged_without_the_builder() {
    let root = root_with_file();
    let log = SharedBuffer::default();
    // Deliberately not wired to the server — this proves the sink stays empty because
    // nothing logged, not because the sink was never reachable.
    let server = Server::new(root.path()).unwrap();
    let (port, _handle) = server.run_ephemeral().await.unwrap();
    tokio::time::sleep(Duration::from_millis(10)).await;

    get(port, "/test.txt").await;
    tokio::time::sleep(Duration::from_millis(50)).await;

    assert_eq!(log.contents(), "");
}

/// Several requests must produce several intact lines, not interleaved fragments.
#[tokio::test]
async fn concurrent_requests_produce_whole_lines() {
    let root = root_with_file();
    let log = SharedBuffer::default();
    let server = Server::new(root.path())
        .unwrap()
        .with_request_logging_to(Box::new(log.clone()));
    let (port, _handle) = server.run_ephemeral().await.unwrap();
    tokio::time::sleep(Duration::from_millis(10)).await;

    let requests: Vec<_> = (0..8)
        .map(|_| tokio::spawn(async move { get(port, "/test.txt").await }))
        .collect();
    for request in requests {
        request.await.unwrap();
    }
    tokio::time::sleep(Duration::from_millis(100)).await;

    let contents = log.contents();
    let lines: Vec<&str> = contents.lines().collect();
    assert_eq!(lines.len(), 8, "expected 8 lines, got: {contents:?}");
    for line in lines {
        assert!(
            line.starts_with("GET /test.txt 200 11 "),
            "line was fragmented: {line:?}"
        );
    }
}