mini-static 0.12.6

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;

use bytes::Bytes;

use crate::minify::MinifyError;
use crate::reload::ChangeType;
use crate::watcher::Broadcaster;

/// Default cap on the number of distinct paths [`MinifyCache`] holds minified bytes
/// for. An unbounded cache would grow without limit for a server with enough distinct
/// CSS/JS files — this is a stated ceiling (per architecture principle A2), not
/// "as much as fits."
pub(crate) const DEFAULT_MINIFY_CACHE_CAPACITY: usize = 256;

struct CacheEntry {
    mtime: SystemTime,
    bytes: Bytes,
}

/// An in-memory cache of minified file bytes, keyed by path and valid only for the
/// source mtime it was derived from.
///
/// A cache hit requires both the path to be present *and* its stored mtime to match
/// the file's current mtime — a stale entry (source changed since it was minified) is
/// treated as a miss and overwritten, not served. Bounded to `capacity` entries: once
/// full, inserting a new path evicts an arbitrary existing entry (not true LRU — see
/// `DEV_PLAN.md`, deferred until a real embedder hits the cap).
pub(crate) struct MinifyCache {
    entries: Mutex<HashMap<PathBuf, CacheEntry>>,
    capacity: usize,
}

impl MinifyCache {
    pub(crate) fn new(capacity: usize) -> Self {
        MinifyCache {
            entries: Mutex::new(HashMap::new()),
            capacity,
        }
    }

    /// Return minified bytes for `path`, using `minify_fn` to produce them on a cache
    /// miss or a stale entry (`mtime` doesn't match what's cached).
    ///
    /// `minify_fn` is a parameter (rather than always calling [`crate::minify::minify`]
    /// directly) so tests can wrap it with a call counter and assert the minifier ran
    /// only on genuine misses — the same "inject the thing you want to observe" shape
    /// as `accept_tests`' fake `TcpAccept` listener elsewhere in this crate.
    ///
    /// # Errors
    ///
    /// Returns `Err` if reading `path` fails, or if `minify_fn` rejects the source
    /// bytes as malformed.
    pub(crate) async fn get_or_minify<F>(
        &self,
        path: &Path,
        mtime: SystemTime,
        change_type: ChangeType,
        minify_fn: F,
    ) -> Result<Bytes, MinifyError>
    where
        F: FnOnce(&[u8], ChangeType) -> Result<Bytes, MinifyError>,
    {
        if let Some(bytes) = self.hit(path, mtime) {
            return Ok(bytes);
        }

        let source = tokio::fs::read(path).await.map_err(MinifyError::Io)?;
        let minified = minify_fn(&source, change_type)?;
        self.insert(path.to_path_buf(), mtime, minified.clone());
        Ok(minified)
    }

    /// Drop the cached entry for `path`, if any.
    pub(crate) fn invalidate(&self, path: &Path) {
        self.entries.lock().unwrap().remove(path);
    }

    /// Spawn a background task that invalidates cache entries as CSS/Script change
    /// events arrive from `broadcaster` — reusing the file watcher already started for
    /// live-reload rather than running a second one. Without this, a changed file's
    /// stale entry would only be noticed reactively, on the next request for it (the
    /// mtime check in [`Self::get_or_minify`] still catches it then — this just makes
    /// the eviction immediate instead of deferred to that next request).
    ///
    /// Runs until `broadcaster`'s sender side is dropped (server shutdown).
    pub(crate) fn subscribe_to_invalidation(self: Arc<Self>, broadcaster: &Broadcaster) {
        let mut events = broadcaster.subscribe();
        tokio::spawn(async move {
            while let Some(event) = events.recv().await {
                if matches!(event.change_type, ChangeType::Css | ChangeType::Script) {
                    self.invalidate(&event.path);
                }
            }
        });
    }

    fn hit(&self, path: &Path, mtime: SystemTime) -> Option<Bytes> {
        let entries = self.entries.lock().unwrap();
        let entry = entries.get(path)?;
        (entry.mtime == mtime).then(|| entry.bytes.clone())
    }

    fn insert(&self, path: PathBuf, mtime: SystemTime, bytes: Bytes) {
        let mut entries = self.entries.lock().unwrap();
        if entries.len() >= self.capacity && !entries.contains_key(&path) {
            // Not true LRU — see the type's doc comment. Evicting an arbitrary entry
            // still enforces the ceiling; it just doesn't optimize which entry goes.
            if let Some(victim) = entries.keys().next().cloned() {
                entries.remove(&victim);
            }
        }
        entries.insert(path, CacheEntry { mtime, bytes });
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicUsize, Ordering};

    fn counting_minify(counter: &AtomicUsize) -> impl Fn(&[u8], ChangeType) -> Result<Bytes, MinifyError> + '_ {
        move |bytes, change_type| {
            counter.fetch_add(1, Ordering::SeqCst);
            crate::minify::minify(bytes, change_type)
        }
    }

    #[tokio::test]
    async fn minifies_once_per_mtime_then_serves_from_cache() {
        let dir = tempfile::TempDir::new().unwrap();
        let path = dir.path().join("app.css");
        std::fs::write(&path, "body {  color: red;  }").unwrap();
        let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();

        let cache = MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY);
        let calls = AtomicUsize::new(0);

        let first = cache
            .get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
            .await
            .unwrap();
        let second = cache
            .get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
            .await
            .unwrap();

        assert_eq!(calls.load(Ordering::SeqCst), 1, "second request with the same mtime should hit the cache");
        assert_eq!(first, second);

        // Touch the file (new mtime) and request again: must re-minify.
        std::thread::sleep(std::time::Duration::from_millis(10));
        std::fs::write(&path, "body {  color: blue;  }").unwrap();
        let new_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
        assert_ne!(mtime, new_mtime, "test fixture must actually produce a new mtime");

        let third = cache
            .get_or_minify(&path, new_mtime, ChangeType::Css, counting_minify(&calls))
            .await
            .unwrap();

        assert_eq!(calls.load(Ordering::SeqCst), 2, "a changed mtime must be treated as a miss");
        assert_ne!(first, third, "content changed, so minified bytes must differ");
    }

    #[tokio::test]
    async fn invalidate_forces_a_reminify_even_with_an_unchanged_mtime() {
        let dir = tempfile::TempDir::new().unwrap();
        let path = dir.path().join("app.css");
        std::fs::write(&path, "body { color: red; }").unwrap();
        let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();

        let cache = MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY);
        let calls = AtomicUsize::new(0);

        cache
            .get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
            .await
            .unwrap();
        cache.invalidate(&path);
        cache
            .get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
            .await
            .unwrap();

        assert_eq!(calls.load(Ordering::SeqCst), 2, "invalidate() must force a re-minify on the next request");
    }

    #[tokio::test]
    async fn capacity_is_enforced() {
        let dir = tempfile::TempDir::new().unwrap();
        let cache = MinifyCache::new(2);
        let calls = AtomicUsize::new(0);

        for i in 0..5 {
            let path = dir.path().join(format!("f{i}.css"));
            std::fs::write(&path, format!("body {{ color: red{i}; }}")).unwrap();
            let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
            cache
                .get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
                .await
                .unwrap();
        }

        assert!(
            cache.entries.lock().unwrap().len() <= 2,
            "cache must never exceed its stated capacity"
        );
    }

    #[tokio::test]
    async fn broadcaster_change_event_invalidates_before_the_next_request() {
        let dir = tempfile::TempDir::new().unwrap();
        let path = dir.path().join("app.css");
        std::fs::write(&path, "body { color: red; }").unwrap();
        let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();

        let cache = Arc::new(MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY));
        let calls = AtomicUsize::new(0);

        // Populate the cache.
        cache
            .get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
            .await
            .unwrap();
        assert_eq!(calls.load(Ordering::SeqCst), 1);

        // Reuse the same broadcaster live-reload's watcher already runs — not a
        // second watcher — and subscribe the cache to it.
        let broadcaster = Broadcaster::new();
        Arc::clone(&cache).subscribe_to_invalidation(&broadcaster);

        broadcaster.broadcast(crate::watcher::ChangeEvent {
            path: path.clone(),
            change_type: ChangeType::Css,
        });

        // Give the spawned subscriber task a moment to process the event before the
        // "next request" arrives — matching the DEV_PLAN spec: the entry must be gone
        // *before* that next request, not just eventually consistent by the one after.
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;

        // Same mtime as before: if the entry were still cached, this would be a hit
        // and the minifier would NOT run again. A second call proves invalidation.
        cache
            .get_or_minify(&path, mtime, ChangeType::Css, counting_minify(&calls))
            .await
            .unwrap();
        assert_eq!(
            calls.load(Ordering::SeqCst),
            2,
            "the broadcaster's change event must have evicted the entry before this request"
        );
    }
}