mini-static 0.29.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
mod common;

use std::fs;
use std::io::Write;
use std::sync::{Arc, Mutex};

use mini_static::Server;
use tempfile::TempDir;

/// Must match `MAX_INJECTABLE_HTML_BYTES` in `src/server.rs`. Asserted against the
/// server's real behavior at both sides of the boundary below, so a change to the
/// constant that isn't mirrored here fails rather than silently weakening the tests.
const CAP: usize = 8 * 1024 * 1024;

#[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(())
    }
}

/// Writes an HTML file of exactly `size` bytes, padded inside a comment so the padding
/// cannot be mistaken for content and `</body>` stays where an injector would look.
fn write_html_of_size(root: &TempDir, name: &str, size: usize) {
    let prefix = b"<html><body>Hello<!--";
    let suffix = b"--></body></html>";
    let padding = size - prefix.len() - suffix.len();

    let mut contents = Vec::with_capacity(size);
    contents.extend_from_slice(prefix);
    contents.resize(prefix.len() + padding, b'x');
    contents.extend_from_slice(suffix);
    assert_eq!(contents.len(), size);

    fs::write(root.path().join(name), contents).unwrap();
}

#[tokio::test]
async fn an_html_page_at_the_cap_is_still_injected() {
    let root = TempDir::new().unwrap();
    write_html_of_size(&root, "big.html", CAP);
    let server = Server::new(root.path()).unwrap().with_spa_mode();

    let response = common::get(&server, "/big.html").await;
    let body = common::body_bytes(response).await;

    assert!(
        String::from_utf8_lossy(&body).contains("mini-static:navigate"),
        "a page exactly at the cap must still be injected"
    );
}

/// One byte over the cap: the page must be served whole and unmodified, streamed rather
/// than buffered, and the skip must be visible to an operator wondering why spa-mode
/// stopped working on one page.
#[tokio::test]
async fn an_html_page_over_the_cap_is_served_unmodified_and_logged() {
    let root = TempDir::new().unwrap();
    write_html_of_size(&root, "huge.html", CAP + 1);
    let log = SharedBuffer::default();
    let server = Server::new(root.path())
        .unwrap()
        .with_spa_mode()
        .with_request_logging_to(Box::new(log.clone()));

    let response = common::get(&server, "/huge.html").await;
    assert_eq!(response.status().as_u16(), 200);

    let body = common::body_bytes(response).await;
    assert_eq!(
        body.len(),
        CAP + 1,
        "the whole file must still be served, byte for byte"
    );
    assert!(
        !String::from_utf8_lossy(&body).contains("mini-static:navigate"),
        "an over-cap page must not be injected"
    );

    let logged = log.contents();
    assert!(
        logged.contains("html injection skipped for /huge.html"),
        "the skip must be logged, got: {logged:?}"
    );
}

/// The cap applies to live-reload injection for the same reason it applies to spa-mode:
/// both read the whole file into memory.
///
/// This one drives a real server over a socket rather than calling `handle_request`
/// directly, because `with_live_reload()` only sets a flag — the broadcaster that
/// actually enables injection is created inside `run()`. Called directly, the server
/// would decline to inject for want of a broadcaster and the test would pass without
/// exercising the cap at all.
#[tokio::test]
async fn the_cap_applies_to_live_reload_injection_too() {
    let root = TempDir::new().unwrap();
    write_html_of_size(&root, "huge.html", CAP + 1);
    write_html_of_size(&root, "small.html", 1024);
    let server = Server::new(root.path()).unwrap().with_live_reload();
    let (port, handle) = server.run_ephemeral().await.unwrap();
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;

    // Control: an under-cap page on this same server does get the reload script, so the
    // negative assertion below cannot pass merely because injection is off.
    let small = fetch(port, "/small.html").await;
    assert!(
        small.contains("EventSource"),
        "live-reload injection should be active on this server"
    );

    let huge = fetch(port, "/huge.html").await;
    assert!(
        !huge.contains("EventSource"),
        "an over-cap page must not get the reload script"
    );

    handle.shutdown().await;
}

/// Fetch `path` over a real connection and return the whole response as a string.
async fn fetch(port: u16, path: &str) -> String {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};

    let mut stream = tokio::net::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();
    String::from_utf8_lossy(&response).into_owned()
}

/// An ordinary page is unaffected — the guard against a cap that accidentally disables
/// injection everywhere.
#[tokio::test]
async fn an_ordinary_page_is_still_injected() {
    let root = TempDir::new().unwrap();
    fs::write(
        root.path().join("small.html"),
        b"<html><body>Hi</body></html>",
    )
    .unwrap();
    let server = Server::new(root.path()).unwrap().with_spa_mode();

    let body = common::body_bytes(common::get(&server, "/small.html").await).await;

    assert!(String::from_utf8_lossy(&body).contains("mini-static:navigate"));
}

/// Nothing is logged for a page that injects normally — the skip line should mean
/// something when it appears.
#[tokio::test]
async fn no_skip_is_logged_for_an_injected_page() {
    let root = TempDir::new().unwrap();
    fs::write(
        root.path().join("small.html"),
        b"<html><body>Hi</body></html>",
    )
    .unwrap();
    let log = SharedBuffer::default();
    let server = Server::new(root.path())
        .unwrap()
        .with_spa_mode()
        .with_request_logging_to(Box::new(log.clone()));

    common::get(&server, "/small.html").await;

    assert!(
        !log.contents().contains("html injection skipped"),
        "got: {:?}",
        log.contents()
    );
}