mini-static 0.12.5

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
# mini-static — Development Plan

Coverage target: **≥ 80%** line coverage. Like `mini-serve`, the connection-lifecycle
and traversal-security tests need real filesystem and socket fixtures (`tempfile`), not
pure-function unit tests alone.

## Phase 0.1.0 — MVP: resolve, serve, traverse-safe

*Exit criteria: serve a file under a root; a traversal attempt and a missing file are
indistinguishable over the wire.*

1. **Commit: `resolve()` — segment-based traversal check + canonicalize-and-`starts_with`
   boundary.** Primary test: request `jquery..min.js` (a legitimate filename containing
   `..` as a substring but not as a segment), assert it resolves successfully — the
   regression test for the over-eager substring check. Second test: request `../../etc/passwd`-shaped
   input, assert it's rejected.
2. **Commit: traversal and missing-file both answer 404, `nosniff` header on all
   responses.** Primary test: request a traversal path and a genuinely missing file,
   assert both return status `404` with byte-identical bodies — proving there's no
   distinguishing oracle. Assert `X-Content-Type-Options: nosniff` is present on both.
3. **Commit: `Server::run` canonicalizes root once; `resolve` takes it as a precondition.**
   Primary test: instrument syscall count (or wrap the filesystem in a counting fake) for
   N requests, assert `canonicalize` is called once total, not once per request.

## Phase 0.2.0 — HTTP correctness

*Exit criteria: method handling, Content-Length, directory redirects, range edge cases
all match RFC 9110 behavior.*

4. **Commit: method gate — GET/HEAD proceed, everything else 405 + `Allow`.** Primary
   test: `DELETE` a real file, assert `405` with `Allow: GET, HEAD` and confirm (via a
   counting fake filesystem) zero disk reads occurred.
5. **Commit: `Content-Length` on full 200 responses.** Primary test: request a known-size
   file without a `Range` header, assert the response header's value equals the file's
   actual byte length exactly.
6. **Commit: directory-index 301 redirect.** Primary test: request `/dir` where
   `/dir/index.html` exists, assert a `301` with `Location: /dir/` — not the index
   content directly.
7. **Commit: multi-range → full 200; `If-Range` honored.** Primary test: send a
   multi-range `Range` header, assert `200` with the full body (not `416`); send a
   single range with a stale `If-Range` validator, assert the full current file returns.

## Phase 0.3.0 — Connection lifecycle

8. **Commit: header-read timeout.** Primary test: open a raw socket, send nothing,
   assert the connection closes within the configured timeout and a subsequent normal
   request on a new connection still succeeds.
9. **Commit: loopback-only ephemeral binds.** Primary test: assert `run_ephemeral()`'s
   bound address is exactly `127.0.0.1`.

## Phase 0.4.0 — Performance + non-ASCII filenames

10. **Commit: `BytesMut`-based streaming, `split_to(n).freeze()`.** Primary test:
    benchmark or instrument to confirm one copy per chunk instead of two, on a
    multi-megabyte fixture file.
11. **Commit: byte-level percent-decoding for filenames.** Primary test: serve a file
    named with a non-ASCII character (e.g. `é.png`), assert it resolves and serves
    correctly, with the traversal guard still active on the same request path.

## Phase 0.5.0 — Publish

12. **Commit: `err`/`log` feature docs + doc comments on every public item.**
13. **Commit: CI**`cargo test`, `cargo test --all-features`, `cargo clippy -- -D warnings`,
    `cargo llvm-cov --fail-under-lines 80`.
14. **Publish `0.5.0`** to crates.io (name confirmed available).

Cache-control policy hook and precompressed-sidecar support (both noted as optional in
the README) become their own post-0.5.0 minor-version phases if/when a consumer actually
needs them — not built speculatively ahead of that need.

## Phase 0.5.1 — Conditional requests (patch release)

15. **Commit: honor `If-None-Match` / `If-Modified-Since`, answer `304 Not Modified`
    with no body when the validator matches.** Primary test: request a file with a
    matching `If-None-Match` (the ETag from a prior response), assert `304` with an
    empty body; request the same file with a stale validator, assert the full `200`
    body returns. Tests verify ETag-based (comma-separated list, wildcard support) and
    mtime-based (as Unix seconds) conditional matching.
16. **Publish `0.5.1`** to crates.io.

## Phase 0.8.0 (published as 0.9.2) — Cache control + precompression (post-0.5.1)

Live-reload and the Docker example (shipped as `0.6.x`/`0.7.x` outside this plan) landed
before this phase; the work below was still open, and the version target picks up from
the actual last-published `0.7.1` rather than the originally-planned `0.6.0`, which is no
longer available to publish over. This repo bumps the version on every commit (feature
commits bump minor, fixes/tests/docs bump patch), not just at a phase's final publish
step, so the phase consumed `0.8.0` through `0.9.2` rather than landing on a single
number — the phase name is kept as a stable label; the "published as" version is the
actual result. Sequenced ahead of the minification phase because the cache-control
default pairs directly with the ETag/`If-None-Match` revalidation already built in Phase
0.5.1 — there's nothing to revalidate against without it.

*Exit criteria: every file response carries an explicit cache-control policy; a client
that sends `Accept-Encoding: gzip`/`br` and a matching `.gz`/`.br` sidecar exists on
disk gets the precompressed bytes instead of the server compressing on the fly.*

17. **Commit: default `Cache-Control: no-cache` on every 200/304 file response.**
    Forces revalidation via the ETag that's already generated, rather than either
    caching blindly or sending no cache guidance at all — the SSE stream already sets
    this same value for the same reason. Primary test: request a static file, assert
    `Cache-Control: no-cache` is present on the `200`; trigger a `304` via a matching
    `If-None-Match`, assert the header is present there too.
18. **Commit: `Server::with_immutable_assets(predicate)` builder — paths matching the
    predicate get `Cache-Control: public, max-age=31536000, immutable` instead of the
    default.** For fingerprinted filenames (`main.a1b2c3.js`) where a content change
    always means a new filename, indefinite caching is correct rather than wasteful.
    Primary test: configure a predicate matching `*.a1b2c3.js`-shaped names, request a
    matching and a non-matching file, assert only the matching one gets the immutable
    header and the other still gets the Commit 17 default.
19. **Commit: precompressed sidecar serving.** A request for `foo.js` with
    `Accept-Encoding: gzip` (or `br`) and a sibling `foo.js.gz` (or `.br`) present on
    disk serves the sidecar's bytes with `Content-Encoding` set accordingly and the
    original `Content-Type` preserved; every response — matched or not — carries
    `Vary: Accept-Encoding` so intermediate caches never serve the wrong variant to a
    differently-capable client. Primary test: create `foo.js` and `foo.js.gz`, request
    with `Accept-Encoding: gzip`, assert the `.gz` sidecar's bytes and
    `Content-Encoding: gzip` come back; request the same path with no `Accept-Encoding`,
    assert the plain `foo.js` bytes return unchanged.
20. **Commit: sidecar-aware ETag.** The ETag reflects the metadata of whichever variant
    was actually served (sidecar or original), not always the original's, so
    `If-None-Match` validates against real served bytes. Primary test: request `foo.js`
    twice with `Accept-Encoding: gzip`, capture the first response's ETag, send it back
    as `If-None-Match`, assert `304`; request the same path with no `Accept-Encoding`,
    assert its ETag differs from the gzip variant's.
21. **Commit: sidecar path never re-resolves attacker input.** The sidecar path is
    derived by appending `.gz`/`.br` to the already-canonicalized, traversal-checked
    path from Phase 0.1.0 — never by re-running `resolve()` on a modified request path
    — so sidecar lookup can't become a second traversal surface. Primary test:
    instrument a counting fake filesystem, assert exactly one `canonicalize()` call
    occurs for a request that ends up serving a precompressed sidecar (extending Phase
    0.1.0 Commit 3's single-canonicalization guarantee to sidecar lookups).
22. **Commit: CI + docs**`cargo test --all-features`, `cargo clippy -- -D warnings`,
    `cargo llvm-cov --fail-under-lines 80` covering the new code paths; README section
    documenting the cache-control default, the immutable-assets builder, and
    precompressed-sidecar negotiation.
23. **Publish** to crates.io (landed as `0.9.2` — see the phase note above).

## Phase 0.9.0 (published as 0.10.0–0.12.1) — CSS/JS minification

*Exit criteria: `.css` and `.js`/`.mjs` files under the served root reach the client
minified; every other file type is served byte-for-byte unchanged; a given file's
content is minified at most once per version, never once per request.*

24. **Commit: `minify(bytes, ChangeType) -> Result<Bytes, MinifyError>` — pure dispatch
    over `css-minify` (CSS) and `minify-js` (JS/MJS), passthrough for `Html`/`Other`.**
    Reuses the extension classification already in `reload::ChangeType` instead of a
    second copy of the same `match`. Primary test: minify a CSS fixture with redundant
    whitespace and a comment, assert the output is shorter and the comment is gone while
    the declared rules survive; feed deliberately malformed CSS/JS, assert `Err` — never
    a panic, never silently-passed-through-broken output.
25. **Commit: in-memory minified-body cache, keyed by path + source mtime, bounded to a
    stated max entry count.** On a miss, the file is read fully, minified, and the
    result cached alongside the mtime it was derived from; a hit compares the cached
    mtime against the file's current mtime and only serves from memory if they match.
    Primary test: request the same `.css` file twice, assert (via a counting fake) the
    minifier ran once, not twice; touch the file's mtime between requests, assert the
    third request re-minifies.
26. **Commit: cache invalidation wired to the existing `Broadcaster`, not a second file
    watcher.** `watcher::start_watching` already emits a `ChangeEvent{path, change_type}`
    for every CSS/Script change (the same event that drives live-reload's SSE stream);
    the minification cache subscribes to that broadcaster and evicts the entry for a
    changed path immediately, instead of waiting for the next request's mtime check to
    notice. Primary test: with live-reload enabled, modify a cached `.css` file, assert
    the cache entry for that path is gone before the next request arrives.
27. **Commit: bypass for already-minified sources.** Files matching `*.min.css` /
    `*.min.js` are served unminified — running a minifier on already-minified input is
    wasted work at best and a correctness risk at worst. Primary test: request a
    `foo.min.js` fixture, assert the served bytes equal the file's bytes exactly and a
    counting fake shows the minifier was never invoked.
28. **Commit: opt-in via `Server::with_minify()`, off by default.** Mirrors
    `with_live_reload()`'s builder shape. An embedder serving pre-bundled assets — the
    common case once a real bundler is in the pipeline — shouldn't pay a cache cost or a
    transform they didn't ask for. Primary test: without the builder call, request a
    `.css` fixture with known-redundant whitespace, assert the served bytes are
    byte-identical to the file on disk.
29. **Commit: CI + docs**`cargo test --all-features`, `cargo clippy -- -D warnings`,
    `cargo llvm-cov --fail-under-lines 80` covering the new module; README section
    documenting the cache bound, the invalidation path, and the `.min.*` bypass.
30. **Publish** to crates.io (landed as `0.12.1` — see the phase header above).

Cache eviction policy beyond the mtime/broadcaster invalidation (e.g. true LRU past the
entry cap) is deferred until a real embedder hits the cap — not built speculatively
ahead of that need.