mini-static 0.29.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use super::*;

use std::error::Error as _;
use std::io::{Error as IoError, ErrorKind};

/// The one path in this file that is a security contract rather than a message
/// format: a traversal must be indistinguishable from a miss, and neither may
/// carry the path that produced it into a response body.
#[test]
fn user_message_collapses_traversal_into_the_miss_message() {
    let miss = StaticError::NotFound("/etc/passwd".into());
    let traversal = StaticError::Traversal("/../../etc/passwd".into());

    assert_eq!(miss.user_message(), "not found");
    assert_eq!(traversal.user_message(), miss.user_message());
}

#[test]
fn user_message_never_carries_the_offending_path() {
    let secret = "deploy-secrets/id_rsa";

    for error in [
        StaticError::NotFound(secret.into()),
        StaticError::Traversal(secret.into()),
        StaticError::Config(secret.into()),
    ] {
        assert!(
            !error.user_message().contains(secret),
            "user_message leaked its payload: {}",
            error.user_message()
        );
    }
}

/// Internal failures must not be reported to a client as a miss — a 404 for
/// what is really a broken pipeline or an unreadable file hides the outage.
#[test]
fn user_message_reports_internal_failures_as_internal() {
    let io = StaticError::Io(IoError::new(ErrorKind::PermissionDenied, "denied"));
    let config = StaticError::Config("ETag is computed per response".into());

    assert_eq!(io.user_message(), "internal server error");
    assert_eq!(config.user_message(), "internal server error");
}

/// `Display` is the operator-facing half of the pair `user_message` guards:
/// it is what reaches stderr and a returned `Result`, so it keeps the detail
/// the HTTP body deliberately drops.
#[test]
fn display_keeps_the_detail_user_message_withholds() {
    let path = "public/missing.html";

    assert_eq!(
        StaticError::NotFound(path.into()).to_string(),
        format!("not found: {path}")
    );
    assert_eq!(
        StaticError::Traversal(path.into()).to_string(),
        format!("path traversal denied: {path}")
    );
    assert_eq!(
        StaticError::Config("ETag is computed".into()).to_string(),
        "invalid configuration: ETag is computed"
    );
}

#[test]
fn display_of_an_io_error_includes_the_underlying_cause() {
    let error = StaticError::Io(IoError::new(ErrorKind::NotFound, "no such file"));

    assert_eq!(error.to_string(), "io error: no such file");
}

/// Only `Io` wraps another error, so only `Io` has a source to hand a caller
/// walking the chain.
#[test]
fn only_the_io_variant_exposes_a_source() {
    let io = StaticError::Io(IoError::other("inner"));
    assert_eq!(io.source().map(ToString::to_string), Some("inner".into()));

    for error in [
        StaticError::NotFound("p".into()),
        StaticError::Traversal("p".into()),
        StaticError::Config("m".into()),
    ] {
        assert!(error.source().is_none(), "unexpected source on {error}");
    }
}

#[test]
fn an_io_error_converts_into_the_io_variant_preserving_its_kind() {
    let error: StaticError = IoError::new(ErrorKind::PermissionDenied, "denied").into();

    let StaticError::Io(inner) = &error else {
        panic!("io::Error must convert into StaticError::Io, got: {error}");
    };
    assert_eq!(inner.kind(), ErrorKind::PermissionDenied);
}