mini-static 0.6.3

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

use mini_static::Server;
use hyper::Method;
use std::fs;
use std::time::Duration;
use tempfile::TempDir;

#[tokio::test]
async fn multi_range_request_returns_200_with_full_body() {
    let root = TempDir::new().unwrap();
    let content = b"0123456789abcdefghij";
    fs::write(root.path().join("file.txt"), content).unwrap();

    let server = Server::new(root.path()).unwrap();
    let response = server.handle_request_with_headers(
        &Method::GET,
        "/file.txt",
        Some("bytes=0-5,10-15"),
        None,
    );

    assert_eq!(response.status().as_u16(), 200);

    let body = common::body_bytes(response).await;
    assert_eq!(&body[..], content, "multi-range should return the full body, not a partial slice");
}

#[test]
fn multi_range_request_includes_etag() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("file.txt"), b"content").unwrap();

    let server = Server::new(root.path()).unwrap();
    let response = server.handle_request_with_headers(
        &Method::GET,
        "/file.txt",
        Some("bytes=0-5,10-15"),
        None,
    );

    assert!(response.headers().get("ETag").is_some());
}

#[test]
fn single_range_without_if_range_returns_200() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("file.txt"), b"0123456789").unwrap();

    let server = Server::new(root.path()).unwrap();
    let response = server.handle_request_with_headers(
        &Method::GET,
        "/file.txt",
        Some("bytes=0-5"),
        None,
    );

    assert_eq!(response.status().as_u16(), 200);
}

#[test]
fn single_range_with_matching_if_range_returns_200() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("file.txt"), b"content").unwrap();

    let server = Server::new(root.path()).unwrap();

    // First request to get the ETag
    let first_response = server.handle_request_with_headers(
        &Method::GET,
        "/file.txt",
        None,
        None,
    );

    let etag = first_response
        .headers()
        .get("ETag")
        .and_then(|v| v.to_str().ok())
        .unwrap()
        .to_string();

    // Second request with matching If-Range
    let second_response = server.handle_request_with_headers(
        &Method::GET,
        "/file.txt",
        Some("bytes=0-5"),
        Some(&etag),
    );

    assert_eq!(second_response.status().as_u16(), 200);
}

#[test]
fn single_range_with_stale_if_range_returns_200() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("file.txt"), b"original content").unwrap();

    let server = Server::new(root.path()).unwrap();

    // Get initial ETag
    let first_response = server.handle_request_with_headers(
        &Method::GET,
        "/file.txt",
        None,
        None,
    );

    let initial_etag = first_response
        .headers()
        .get("ETag")
        .and_then(|v| v.to_str().ok())
        .unwrap()
        .to_string();

    // Modify the file to make the ETag stale
    std::thread::sleep(Duration::from_millis(100));
    fs::write(root.path().join("file.txt"), b"modified content now").unwrap();

    let server = Server::new(root.path()).unwrap();

    // Request with stale If-Range should return 200 with full body
    let response = server.handle_request_with_headers(
        &Method::GET,
        "/file.txt",
        Some("bytes=0-5"),
        Some(&initial_etag),
    );

    assert_eq!(response.status().as_u16(), 200);
}

#[test]
fn etag_header_present_on_all_200_responses() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("file.txt"), b"content").unwrap();

    let server = Server::new(root.path()).unwrap();

    let response1 = server.handle_request_with_method(&Method::GET, "/file.txt");
    let response2 = server.handle_request_with_headers(
        &Method::GET,
        "/file.txt",
        Some("bytes=0-5"),
        None,
    );

    assert!(response1.headers().get("ETag").is_some());
    assert!(response2.headers().get("ETag").is_some());
}

#[test]
fn etag_changes_when_file_modified() {
    let root = TempDir::new().unwrap();
    // Use different sizes to ensure ETag changes
    fs::write(root.path().join("file.txt"), b"original content here").unwrap();

    let server = Server::new(root.path()).unwrap();
    let response1 = server.handle_request_with_method(&Method::GET, "/file.txt");
    let etag1 = response1
        .headers()
        .get("ETag")
        .and_then(|v| v.to_str().ok())
        .unwrap()
        .to_string();

    // Modify file with different size
    std::thread::sleep(Duration::from_millis(100));
    fs::write(root.path().join("file.txt"), b"x").unwrap();

    let server = Server::new(root.path()).unwrap();
    let response2 = server.handle_request_with_method(&Method::GET, "/file.txt");
    let etag2 = response2
        .headers()
        .get("ETag")
        .and_then(|v| v.to_str().ok())
        .unwrap()
        .to_string();

    assert_ne!(etag1, etag2, "ETag should change when file is modified");
}

#[test]
fn multi_range_with_if_range_returns_200() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("file.txt"), b"0123456789").unwrap();

    let server = Server::new(root.path()).unwrap();

    // Get ETag first
    let initial = server.handle_request_with_method(&Method::GET, "/file.txt");
    let etag = initial
        .headers()
        .get("ETag")
        .and_then(|v| v.to_str().ok())
        .unwrap();

    // Multi-range with matching If-Range should still return 200
    let response = server.handle_request_with_headers(
        &Method::GET,
        "/file.txt",
        Some("bytes=0-5,10-15"),
        Some(etag),
    );

    assert_eq!(response.status().as_u16(), 200);
}

#[tokio::test]
async fn head_method_with_multi_range_returns_200_empty_body() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("file.txt"), b"content").unwrap();

    let server = Server::new(root.path()).unwrap();
    let response = server.handle_request_with_headers(
        &Method::HEAD,
        "/file.txt",
        Some("bytes=0-5,10-15"),
        None,
    );

    assert_eq!(response.status().as_u16(), 200);

    let body = common::body_bytes(response).await;
    assert_eq!(body.len(), 0, "HEAD response must not have a body");
}

#[test]
fn etag_format_is_valid() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("file.txt"), b"content").unwrap();

    let server = Server::new(root.path()).unwrap();
    let response = server.handle_request_with_method(&Method::GET, "/file.txt");

    let etag = response
        .headers()
        .get("ETag")
        .and_then(|v| v.to_str().ok())
        .unwrap();

    // ETag should be quoted and contain size and mtime
    assert!(etag.starts_with('"'));
    assert!(etag.ends_with('"'));
    assert!(etag.contains('-'));
}

#[test]
fn no_etag_on_404_response() {
    let root = TempDir::new().unwrap();

    let server = Server::new(root.path()).unwrap();
    let response = server.handle_request_with_method(&Method::GET, "/missing.txt");

    assert_eq!(response.status().as_u16(), 404);
    assert!(response.headers().get("ETag").is_none());
}

#[test]
fn no_etag_on_405_response() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("file.txt"), b"content").unwrap();

    let server = Server::new(root.path()).unwrap();
    let response = server.handle_request_with_method(&Method::DELETE, "/file.txt");

    assert_eq!(response.status().as_u16(), 405);
    assert!(response.headers().get("ETag").is_none());
}