mini-static 0.12.5

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

A secure static file server: range requests, live reload, directory-index redirects. No
templating, no framework — files in, HTTP responses out.

> Status: **published**, actively developed. See `DEV_PLAN.md` for the roadmap.

```toml
[dependencies]
mini-static = { version = "0.12", features = ["err", "log"] }
```

## Design

`Server::run(root, addr)` canonicalizes `root` once at startup and serves everything
below it. Every resolved path is checked against the canonicalized root before any file
I/O happens — the canonicalization is the security boundary, not a pattern match on `..`.

## Connection lifecycle (fixed from the prior iteration)

- **Header-read timeout.** Every accepted connection now has a bounded time (default
  30s) to send its request headers before the connection is dropped. The prior
  implementation had no timeout at all: a client that opened a socket and sent nothing
  held a connection-semaphore permit forever — 1024 idle sockets (the default
  `max_connections`), trivially cheap for an attacker, permanently stopped the server
  from accepting anyone else.
- **Ephemeral binds are loopback-only** (`127.0.0.1:0`), matching `mini-serve`'s fix and
  for the same reason: a test helper should never expose a real file server to the LAN.
- **A transient `accept()` error no longer ends the server.** A sustained failure (e.g.
  the process is out of file descriptors) now degrades into periodic retries with
  exponential backoff instead of a single error silently ending the accept loop for
  good, or a naive retry busy-spinning at 100% CPU. Mirrors `mini-serve`'s `Backoff`.
- **`run()`/`run_ephemeral()` return a `ServerHandle` alongside the port.** The prior
  implementation had no way to stop a running server short of exiting the process — every
  test that started one leaked its background accept loop for the rest of the test
  binary's life, and an embedder had no way to stop serving at all. Calling
  `handle.shutdown().await` stops accepting new connections and waits for already-accepted
  connections to finish before returning; dropping the handle without calling it preserves
  the old fire-and-forget behavior.

## Path traversal responses (fixed)

A blocked traversal attempt and a genuinely missing file both answer `404 not found`.
The prior implementation answered traversal attempts with a distinct `403` and a
distinctive message — telling a prober exactly when they'd found the guard, and
inviting iteration to map the filesystem by response code. The distinction is still
logged server-side (via the `log` feature); it is simply not observable over the wire.
Every file response also carries `X-Content-Type-Options: nosniff` — the server serves
user-supplied directories, and content-sniffing a mislabeled file is a real vector for
stored XSS.

The traversal *pre-check* matches path **segments** equal to `..`, not any substring
containing `..` — the prior substring check rejected legitimate filenames like
`jquery..min.js`. `canonicalize()` + `starts_with(root)` remains the actual security
boundary; the segment check is a cheap early rejection, not the guarantee.

## HTTP correctness (fixed)

- **Method handling.** Only `GET`/`HEAD` serve files; everything else gets `405` with
  `Allow: GET, HEAD`. The prior implementation served files for any method, including
  streaming the full body for HEAD requests before hyper silently dropped it on the wire
  — real disk I/O for a response nobody could see.
- **`Content-Length` on streamed responses.** The file's length is known before
  streaming begins (`metadata.len()`) and is now sent on every full-file `200`, not only
  on range responses — without it, clients fall back to chunked encoding and lose
  progress bars and cacheability by size.
- **Directory index redirects.** Requesting `/dir` when `/dir/index.html` exists issues
  a `301` to `/dir/` first, so relative links inside the served page resolve against the
  right base — the prior implementation served the index directly at `/dir`, silently
  breaking every relative link on the page.
- **Multi-range requests** get a full `200` (ignoring the `Range` header), the RFC-
  correct behavior for a server that doesn't implement multipart range responses,
  instead of an unhelpful `416`. **`If-Range`** is honored: a mismatched validator serves
  the full current file rather than mixing stale range bytes with new content.

## Performance (fixed)

- The server root is canonicalized once at startup; per-request resolution takes the
  already-canonical root as a documented precondition instead of re-canonicalizing (two
  syscalls plus an allocation) on every single request.
- File responses stream to the client one chunk at a time via a `Body` impl backed by
  a reused `BytesMut`; each chunk is handed off via `split_to(n).freeze()` — no
  per-chunk zero-fill, no second copy of every byte read, and memory use stays bounded
  to one chunk per in-flight response regardless of file size.
- Path resolution's blocking `canonicalize()` syscalls run on Tokio's blocking thread
  pool via `spawn_blocking`, not directly on the async worker thread handling the
  request — a slow filesystem lookup for one request no longer stalls every other task
  scheduled on that same worker thread.
- Conditional-request support: `If-None-Match` (ETag-based) and `If-Modified-Since`
  (mtime-based) headers are honored, returning `304 Not Modified` when the file hasn't
  changed — clients that revalidate get a fast, bodyless response instead of re-
  downloading the same content.

## Filename decoding

Filenames are percent-decoded to raw bytes (not ASCII-only) and reassembled via
`OsStr::from_bytes` on Unix, so non-ASCII filenames (`é.png`) are servable — the segment
check and `canonicalize()` guard remain the authoritative boundary regardless of how the
name was decoded.

## Cache control and precompression

- **Cache-control default.** Every 200/304 file response carries `Cache-Control:
  no-cache` — clients always revalidate against the ETag rather than caching blindly or
  getting no guidance at all.
- **Immutable assets.** `Server::with_immutable_assets(predicate)` takes a
  `Fn(&Path) -> bool`; paths the predicate matches get `Cache-Control: public,
  max-age=31536000, immutable` instead of the default. Correct only for fingerprinted
  filenames (`main.a1b2c3.js`) where a content change always produces a new name —
  caching a mutable filename indefinitely would serve stale content to every client that
  already has it cached.
- **Precompressed sidecars.** If a client's `Accept-Encoding` names `br` or `gzip` (`br`
  preferred when both are accepted and both sidecars exist) and a sibling `<path>.br` /
  `<path>.gz` exists next to the resolved file, its bytes are served instead with a
  matching `Content-Encoding`. Every file response carries `Vary: Accept-Encoding` so
  intermediate caches never serve the wrong variant to a differently-capable client, and
  the ETag reflects whichever variant was actually served — no compression dependency,
  a real bandwidth win for static sites that ship prebuilt `.gz`/`.br` files. The sidecar
  path is derived by appending an extension to the already-resolved, canonicalized path
  — never by re-resolving a modified request path — so it can't become a second
  traversal surface.

## CSS/JS minification

- **Opt-in.** `Server::with_minify()` enables it (disabled by default) — an embedder
  serving pre-bundled assets shouldn't pay a cache cost or a transform it didn't ask for.
- **Minified once per source mtime.** `.css` files go through `css-minify`, `.js`/`.mjs`
  through `minify-js`; the result is cached in memory keyed by path and the mtime it was
  derived from, bounded to `DEFAULT_MINIFY_CACHE_CAPACITY` (256) entries with simple
  over-capacity eviction (not true LRU — deferred until a real embedder hits the cap; see
  `DEV_PLAN.md`). A request for an unchanged file is served straight from the cache; a
  changed mtime is treated as a miss and re-minified.
- **Invalidated immediately when live-reload is also enabled.** The cache subscribes to
  the same file-change broadcaster that drives live-reload's SSE stream — no second file
  watcher — and evicts a changed path's entry as soon as the event arrives, rather than
  waiting for that file's next request to notice via the mtime check above.
- **`*.min.css`/`*.min.js` bypass.** Already-minified files are served as-is; running a
  minifier on already-minified input is wasted work at best and a correctness risk at
  worst.
- **Precompressed sidecars win.** If a `.gz`/`.br` sidecar matches the request, its bytes
  are served and minification is skipped — a sidecar already represents whatever a build
  step decided the final bytes should be.
- **Malformed source degrades gracefully.** A file that fails to minify (rare — a
  hand-edited file, a minifier bug) is served unminified rather than failing the request.

## Features

- `err``mini_err::Error` integration for internal error responses.
- `log` — request logging, including logged (not exposed) traversal attempts.

## Non-goals

- No directory listing UI — an index page is either present as a real file or the
  request 404s.
- No on-the-fly transcoding or image resizing.
- No built-in compression of arbitrary responses — see *precompressed sidecar support*
  above as the intended growth path instead.