mini-static 0.5.2

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use mini_static::resolve;
use std::fs;
use tempfile::TempDir;

fn canon_eq(a: &std::path::Path, b: &std::path::Path) {
    let a_canon = a.canonicalize().unwrap();
    let b_canon = b.canonicalize().unwrap();
    assert_eq!(a_canon, b_canon);
}

#[test]
fn resolve_legitimate_filename_with_double_dot_substring() {
    let root = TempDir::new().unwrap();
    let test_file = root.path().join("jquery..min.js");
    fs::write(&test_file, b"content").unwrap();

    let result = resolve(root.path(), "/jquery..min.js");
    assert!(result.is_ok(), "should allow legitimate filename with .. substring");
    canon_eq(&result.unwrap(), &test_file);
}

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

    let result = resolve(root.path(), "/../../etc/passwd");
    assert!(result.is_err(), "should reject traversal attempt");
    assert!(matches!(result, Err(mini_static::StaticError::Traversal(_))));
}

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

    let result = resolve(root.path(), "/../etc/passwd");
    assert!(result.is_err(), "should reject single .. segment");
}

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

    let result = resolve(root.path(), "/foo/../../../etc/passwd");
    assert!(result.is_err(), "should reject .. segment in middle of path");
}

#[test]
fn resolve_regular_file() {
    let root = TempDir::new().unwrap();
    let test_file = root.path().join("test.txt");
    fs::write(&test_file, b"hello").unwrap();

    let result = resolve(root.path(), "/test.txt");
    assert!(result.is_ok());
    canon_eq(&result.unwrap(), &test_file);
}

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

    let result = resolve(root.path(), "/nonexistent.txt");
    assert!(result.is_err(), "should reject missing file");
    assert!(matches!(result, Err(mini_static::StaticError::NotFound(_))));
}

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

    let result = resolve(root.path(), "/docs");
    assert!(result.is_ok());
    canon_eq(&result.unwrap(), &index);
}

#[test]
fn resolve_directory_without_index_html() {
    let root = TempDir::new().unwrap();
    fs::create_dir(root.path().join("empty")).unwrap();

    let result = resolve(root.path(), "/empty");
    assert!(result.is_err(), "should reject directory without index.html");
    assert!(matches!(result, Err(mini_static::StaticError::NotFound(_))));
}

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

    let result = resolve(root.path(), "/file\x00.txt");
    assert!(result.is_err(), "should reject null bytes");
    assert!(matches!(result, Err(mini_static::StaticError::Traversal(_))));
}

#[test]
fn resolve_percent_encoded_path() {
    let root = TempDir::new().unwrap();
    let test_file = root.path().join("hello world.txt");
    fs::write(&test_file, b"content").unwrap();

    let result = resolve(root.path(), "/hello%20world.txt");
    assert!(result.is_ok());
    canon_eq(&result.unwrap(), &test_file);
}

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

    let result = resolve(root.path(), "test.txt");
    assert!(result.is_ok(), "should work with or without leading slash");
    canon_eq(&result.unwrap(), &test_file);
}

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

    let result = resolve(root.path(), "/");
    assert!(result.is_err(), "root itself has no index.html");
}

#[test]
fn resolve_rejects_symlink_escaping_root() {
    let outside = TempDir::new().unwrap();
    let secret = outside.path().join("secret.txt");
    fs::write(&secret, b"secret").unwrap();

    let root = TempDir::new().unwrap();
    let link = root.path().join("escape.txt");
    std::os::unix::fs::symlink(&secret, &link).unwrap();

    let result = resolve(root.path(), "/escape.txt");
    assert!(
        result.is_err(),
        "a symlink resolving outside root should be rejected"
    );
    assert!(matches!(result, Err(mini_static::StaticError::Traversal(_))));
}

#[test]
fn resolve_rejects_symlinked_index_html_escaping_root() {
    // The directory-index path (`canon.join("index.html")`) is a separate code path from
    // the main file resolution — verify its boundary check independently.
    let outside = TempDir::new().unwrap();
    let secret = outside.path().join("secret.txt");
    fs::write(&secret, b"secret").unwrap();

    let root = TempDir::new().unwrap();
    fs::create_dir(root.path().join("docs")).unwrap();
    let index_link = root.path().join("docs/index.html");
    std::os::unix::fs::symlink(&secret, &index_link).unwrap();

    let result = resolve(root.path(), "/docs/");
    assert!(
        result.is_err(),
        "a symlinked index.html resolving outside root should be rejected"
    );
    assert!(matches!(result, Err(mini_static::StaticError::Traversal(_))));
}

#[test]
fn resolve_allows_symlink_within_root() {
    let root = TempDir::new().unwrap();
    let real_file = root.path().join("real.txt");
    fs::write(&real_file, b"content").unwrap();
    let link = root.path().join("link.txt");
    std::os::unix::fs::symlink(&real_file, &link).unwrap();

    let result = resolve(root.path(), "/link.txt");
    assert!(result.is_ok(), "a symlink resolving within root should be allowed");
    canon_eq(&result.unwrap(), &real_file);
}