mini-static 0.38.7

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
//! A precompressed sidecar must prove containment on its own descriptor.
//!
//! From 0.9.0 until `PLAN-sidecar.md` commit 1, `select_precompressed_sidecar` opened its
//! file with a bare `File::open` and served it, inferring containment from the *original*
//! having been verified. A symlink at the sidecar's name escaped the root: `GET /styles.css`
//! with `Accept-Encoding: br` served the link's target, while `GET /styles.css.br` on the
//! same file correctly returned 404. Two doors to one file, one unguarded.
//!
//! These tests pin **both** sides of the boundary. The rule is "the descriptor resolves
//! inside the root", not "no symlinks" — `resolve` serves an in-root symlink with 200, and a
//! build system that links compressed assets from a shared directory under the root is a
//! legitimate setup. A fix that refused every symlinked sidecar would pass the escape test
//! and still be wrong.

mod common;

use common::{body_bytes, request_with_headers};
use hyper::Method;
use mini_static::Server;
use std::fs;
use tempfile::TempDir;

const ORIGINAL: &[u8] = b"body{color:red}\n";
const OUTSIDE: &[u8] = b"TOP SECRET OUTSIDE ROOT\n";

/// A root with `styles.css`, plus whatever `link_sidecar` points `styles.css.br` at.
///
/// Returns the outside directory too: dropping it would delete the file the escape test
/// needs to still exist while the request is served.
fn root_with_sidecar_link(target: SidecarLink) -> (TempDir, TempDir) {
    let outside = TempDir::new().unwrap();
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("styles.css"), ORIGINAL).unwrap();

    let secret = outside.path().join("secret.txt");
    fs::write(&secret, OUTSIDE).unwrap();
    let inside = root.path().join("shared-assets.br");
    fs::write(&inside, b"legitimate-brotli-bytes").unwrap();

    let link_target = match target {
        SidecarLink::Outside => secret,
        SidecarLink::Inside => inside,
    };
    std::os::unix::fs::symlink(link_target, root.path().join("styles.css.br")).unwrap();
    (root, outside)
}

enum SidecarLink {
    Outside,
    Inside,
}

/// The bug: a sidecar symlinked outside the root is declined, and the original is served.
///
/// Asserts on the *body* rather than the status, because the broken version answered 200
/// too — it simply answered with the wrong file's bytes. A status assertion would have
/// passed throughout.
#[tokio::test]
async fn a_sidecar_symlinked_outside_the_root_is_declined() {
    let (root, _outside) = root_with_sidecar_link(SidecarLink::Outside);
    let server = Server::new(root.path()).unwrap();

    let response = request_with_headers(
        &server,
        &Method::GET,
        "/styles.css",
        &[("accept-encoding", "br")],
    )
    .await;

    assert!(
        !response.headers().contains_key("content-encoding"),
        "an escaping sidecar must be declined, not served: got content-encoding {:?}",
        response.headers().get("content-encoding"),
    );
    let body = body_bytes(response).await;
    assert_eq!(
        body.as_ref(),
        ORIGINAL,
        "expected the original file's bytes; serving {} bytes means the root was escaped",
        body.len(),
    );
    assert_ne!(
        body.as_ref(),
        OUTSIDE,
        "the sidecar probe served a file from outside the served root"
    );
}

/// The other side of the boundary: an in-root symlinked sidecar is still served.
///
/// This is what stops the fix from becoming "refuse all symlinked sidecars", which would
/// pass the test above while breaking a build that links assets from a shared in-root
/// directory — and would contradict `resolve`, which serves in-root symlinks.
#[tokio::test]
async fn a_sidecar_symlinked_inside_the_root_is_still_served() {
    let (root, _outside) = root_with_sidecar_link(SidecarLink::Inside);
    let server = Server::new(root.path()).unwrap();

    let response = request_with_headers(
        &server,
        &Method::GET,
        "/styles.css",
        &[("accept-encoding", "br")],
    )
    .await;

    assert_eq!(
        response
            .headers()
            .get("content-encoding")
            .map(|value| value.to_str().unwrap()),
        Some("br"),
        "an in-root symlinked sidecar resolves inside the root and must be served"
    );
    let body = body_bytes(response).await;
    assert_eq!(body.as_ref(), b"legitimate-brotli-bytes");
}

/// A directory named like a sidecar is declined rather than descended.
///
/// `resolve::open_verified` retries a directory with `index.html` appended, which is right
/// for a request path and wrong for a sidecar: routing the probe through it unmodified would
/// serve `styles.css.br/index.html` as a brotli body. `open_sidecar_verified` requires
/// `is_file()` instead, which is the same condition that excludes a FIFO — where
/// `File::open` blocks until a writer appears and would hang the request rather than
/// answering it.
#[tokio::test]
async fn a_directory_named_like_a_sidecar_is_declined() {
    let root = TempDir::new().unwrap();
    fs::write(root.path().join("styles.css"), ORIGINAL).unwrap();
    fs::create_dir(root.path().join("styles.css.br")).unwrap();
    fs::write(
        root.path().join("styles.css.br").join("index.html"),
        b"not-a-sidecar",
    )
    .unwrap();

    let server = Server::new(root.path()).unwrap();
    let response = request_with_headers(
        &server,
        &Method::GET,
        "/styles.css",
        &[("accept-encoding", "br")],
    )
    .await;

    assert!(
        !response.headers().contains_key("content-encoding"),
        "a directory is not a sidecar and must be declined"
    );
    let body = body_bytes(response).await;
    assert_eq!(
        body.as_ref(),
        ORIGINAL,
        "expected the original; the index-retry inside open_verified must not apply here"
    );
}

/// The inconsistency that made the bug findable: the same file, requested directly, was
/// always refused. Pinning it means a future change cannot "fix" the escape by loosening
/// this side instead.
#[tokio::test]
async fn the_same_symlink_requested_directly_is_refused() {
    let (root, _outside) = root_with_sidecar_link(SidecarLink::Outside);
    let server = Server::new(root.path()).unwrap();

    let response = common::get(&server, "/styles.css.br").await;
    assert_eq!(
        response.status(),
        hyper::StatusCode::NOT_FOUND,
        "a symlink escaping the root is refused when requested directly"
    );
}