mini-static 0.38.5

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
//! Which files under a root may be read into the content cache.
//!
//! Enumeration only: nothing here opens a file, reads bytes, populates a map or serves
//! anything — see `PLAN-cache.md` for the commits that do. This is the security half, alone
//! and testable, because it is the half a throughput benchmark cannot check.

use std::collections::{HashMap, HashSet};
use std::fs::{FileType, Metadata};
use std::io::Read as _;
use std::path::{Path, PathBuf};

use bytes::Bytes;

/// Ceiling on entries the walk will enumerate.
///
/// The walk cannot loop — it never follows a symlink, so it descends a finite real tree — but
/// "finite" is not a bound (A2). A root with a million files should stop, loudly, rather than
/// spend startup discovering that. Chosen well above any plausible static site and far below
/// anything that would make construction feel hung.
const MAX_CACHE_ENTRIES: usize = 65_536;

/// Whether a directory entry found by the population walk may be read into the cache.
///
/// Only a **real regular file**. A directory is descended rather than cached, and everything
/// else is refused: symlinks, FIFOs, sockets, block and character devices.
///
/// # Why refusing symlinks removes a whole hazard
///
/// The obvious rule would be "cache any file whose opened descriptor resolves inside the
/// root", reusing the containment check the request path already makes. That is the check
/// `resolve::open_verified` performs, and it is the reason there is no check-to-open window
/// when serving.
///
/// Refusing symlinks outright is stronger and needs none of it. If the walk never follows a
/// symlink — not for directories, not for files — then **every path it reaches is inside the
/// root by construction**, because it got there by descending real directories from a root
/// that was already canonicalised. There is nothing to verify, so there is no second
/// implementation of containment to keep in step with the first. That mattered enough to
/// design around: two independently derived answers to "where does this path point" is
/// exactly what let `/admin%2Fconfig` reach a nested file in 0.31.
///
/// The cost is stated rather than hidden: a symlinked file inside the root is not cached. It
/// still serves — the request falls through to the disk path, which verifies it on the
/// opened descriptor as it always has — it is simply served at disk speed.
///
/// # Why a FIFO is not merely "unusual"
///
/// `File::open` on a FIFO blocks until a writer appears. On the request path that costs one
/// request. During an eager walk at construction it would hang **startup**, so refusing it
/// here is what keeps population bounded (A2).
///
/// `FileType` comes from `DirEntry::file_type`, which does not traverse symlinks — so
/// `is_file()` is already false for a symlink pointing at a regular file. That is the
/// property this rule depends on, and it is why the check is one call rather than a `match`.
pub(crate) fn is_cacheable(file_type: &FileType) -> bool {
    file_type.is_file()
}

// Unix-gated: every refusal under test — symlink, FIFO, socket — is a unix file type, and
// the fixtures need `std::os::unix` and `mkfifo` to create them. `is_file()` excludes the
// Windows equivalents (reparse points, named pipes) by the same rule, but not provably from
// here.
#[cfg(all(test, unix))]
#[path = "../tests/unit/cache.rs"]
mod tests;

/// Every file under `root` that may be cached, sorted.
///
/// Descends real directories only. Because [`is_cacheable`] refuses symlinks and this walk
/// refuses to descend anything that is not a real directory, **every path returned is inside
/// `root` by construction** — which is why neither this function nor its callers need a
/// containment check. See [`is_cacheable`] for why that matters more than it looks.
///
/// An unreadable directory is skipped rather than fatal: a permissions problem on one
/// subdirectory should cost that subtree its caching, not the server its startup. Those paths
/// fall through to the disk path, which has always verified them per request.
///
/// Stops at [`MAX_CACHE_ENTRIES`], returning what it found. A truncated enumeration is a
/// smaller cache, not a broken one — every path not returned simply falls through to disk.
pub(crate) fn cacheable_entries(root: &Path) -> Vec<PathBuf> {
    // That this is the named constant and not something unbounded is not test-provable at any
    // sane fixture size — see the note at the end of `tests/unit/cache.rs`. Enforcement of
    // whatever ceiling is passed *is* proven.
    cacheable_entries_bounded(root, MAX_CACHE_ENTRIES)
}

/// [`cacheable_entries`] with the ceiling supplied.
///
/// Split out so the bound is testable. Asserting it against the real ceiling would need
/// 65,537 fixture files, and a test that instead creates sixty-four and checks
/// `len() <= 65_536` passes whether the ceiling is enforced or not — which is what the first
/// version of that test did, until the mutation run said so.
fn cacheable_entries_bounded(root: &Path, ceiling: usize) -> Vec<PathBuf> {
    let mut found = Vec::new();
    let mut directories = vec![root.to_path_buf()];

    while let Some(directory) = directories.pop() {
        let Ok(entries) = std::fs::read_dir(&directory) else {
            continue;
        };
        for entry in entries.flatten() {
            if found.len() >= ceiling {
                // Sorted here too: the early return would otherwise hand back `read_dir`
                // order. Note what this subset *is* — the first `ceiling` entries
                // encountered, then sorted, not the sorted first `ceiling`. Getting the
                // latter would mean collecting every path before truncating, which is the
                // unbounded work this ceiling exists to prevent. The byte budget in commit 2
                // truncates the sorted list and so is deterministic; this ceiling is a
                // safety bound for a pathological root, where determinism is not the point.
                found.sort();
                return found;
            }
            let Ok(file_type) = entry.file_type() else {
                continue;
            };
            // A symlinked directory is not descended. That is the whole containment argument:
            // the walk only ever moves through real directories beneath an already
            // canonicalised root, so it cannot arrive outside it.
            if file_type.is_dir() {
                directories.push(entry.path());
                continue;
            }
            if is_cacheable(&file_type) {
                found.push(entry.path());
            }
        }
    }
    // Sorted so that truncation at the ceiling is deterministic. `read_dir` order is
    // filesystem- and creation-order dependent, so without this two servers on identical
    // roots would cache different subsets once a budget is exhausted, and a hit rate would
    // depend on the order files happened to be written. The grill accepted that as a
    // tradeoff; sorting removes it for the cost of one comparison sort at startup.
    found.sort();
    found
}

/// One file held in memory.
///
/// Stores the `Metadata` rather than a derived ETag on purpose: the serving path builds its
/// ETag with `generate_etag(&metadata)`, so a cached response calls **the same function on the
/// same input** and cannot drift from an uncached one. Deriving it here would be a second
/// implementation of a value that must match exactly.
pub(crate) struct CachedFile {
    pub(crate) bytes: Bytes,
    pub(crate) metadata: Metadata,
    /// Whether a `<path>.br` or `<path>.gz` sibling was enumerated beside this file.
    ///
    /// Recorded here because the walk already knows: the sibling either is or is not in the
    /// enumerated set, so this costs a set lookup rather than the two `open()` attempts the
    /// request path makes. Commit 5 uses it; commit 4 uses it to decline to serve such files
    /// from memory at all.
    pub(crate) has_precompressed_sibling: bool,
}

/// Every cached file, keyed by its path relative to the served root.
///
/// The key is a `PathBuf` — byte-exact, not a `String`. A lossy conversion would map two
/// different filenames onto one key, and a request could then be served another file's bytes.
pub(crate) struct ContentCache {
    entries: HashMap<PathBuf, CachedFile>,
    bytes_held: usize,
    truncated: bool,
}

impl ContentCache {
    pub(crate) fn get(&self, relative: &Path) -> Option<&CachedFile> {
        self.entries.get(relative)
    }

    pub(crate) fn len(&self) -> usize {
        self.entries.len()
    }

    pub(crate) fn bytes_held(&self) -> usize {
        self.bytes_held
    }

    pub(crate) fn truncated(&self) -> bool {
        self.truncated
    }

    /// How many cached files have a `.br` or `.gz` sibling.
    ///
    /// Logged at construction because it tells an operator something they cannot otherwise
    /// see: whether this root ships precompressed assets at all. A root reporting zero is one
    /// where [`crate::Server::without_precompressed`] costs nothing and saves two `open()`
    /// calls per request.
    pub(crate) fn with_siblings(&self) -> usize {
        self.entries
            .values()
            .filter(|entry| entry.has_precompressed_sibling)
            .count()
    }
}

/// Read every cacheable file under `root` into memory, up to `max_bytes`.
///
/// Truncates rather than failing. Enumeration is sorted, so the files cached are a
/// deterministic prefix: two servers on identical roots hold the same set. Stopping at the
/// first file that would exceed the budget — rather than skipping it and continuing with
/// smaller ones — keeps that prefix property, which packing would destroy.
///
/// An unreadable file is skipped, not fatal. It falls through to the disk path, which has
/// always verified and served it; a permissions problem on one asset should not cost a
/// deployment its startup.
///
/// # Why there is no re-verification between the walk and the read
///
/// A file enumerated as a regular file could in principle be replaced by a symlink before it
/// is read, and `fs::read` would follow it — caching content from outside the root under an
/// in-root key. That race is not closed here, and deliberately so: **it requires write access
/// to the served root, and a writer who has that can simply write the file directly.** The
/// walk's symlink refusal exists for the operator who accidentally leaves a link pointing out
/// of the root, which is a configuration mistake rather than an attack, and it holds. Adding
/// `O_NOFOLLOW` would buy nothing an attacker with write access has not already got, at the
/// cost of a platform dependency.
pub(crate) fn populate(root: &Path, max_bytes: usize) -> ContentCache {
    let paths = cacheable_entries(root);
    // Sibling presence comes from the enumerated set rather than from `open()` attempts: the
    // walk has already been past every one of these paths.
    let enumerated: HashSet<&Path> = paths.iter().map(PathBuf::as_path).collect();

    let mut entries = HashMap::new();
    let mut bytes_held: usize = 0;
    let mut truncated = false;

    for path in &paths {
        let Ok(relative) = path.strip_prefix(root) else {
            continue;
        };
        let Ok(mut file) = std::fs::File::open(path) else {
            continue;
        };
        let Ok(metadata) = file.metadata() else {
            continue;
        };
        let size = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
        if bytes_held.saturating_add(size) > max_bytes {
            truncated = true;
            break;
        }

        let mut bytes = Vec::with_capacity(size);
        if file.read_to_end(&mut bytes).is_err() {
            continue;
        }
        bytes_held += bytes.len();

        let has_precompressed_sibling = SIDECAR_EXTENSIONS.iter().any(|extension| {
            let mut sibling = path.as_os_str().to_os_string();
            sibling.push(extension);
            enumerated.contains(PathBuf::from(sibling).as_path())
        });

        entries.insert(
            relative.to_path_buf(),
            CachedFile {
                bytes: Bytes::from(bytes),
                metadata,
                has_precompressed_sibling,
            },
        );
    }

    ContentCache { entries, bytes_held, truncated }
}

/// The sibling suffixes the request path probes for, in the same order.
///
/// Duplicated from `server::SIDECAR_ENCODINGS` only in the extensions, not the negotiation:
/// this asks "does a sibling exist", never "which one should be served".
const SIDECAR_EXTENSIONS: [&str; 2] = [".br", ".gz"];