mini-static 0.7.0

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: **planned**. Previously implemented and published as `small-static`; being
> rebuilt from this spec with a security and correctness pass folded in. See
> `DEV_PLAN.md`.

```toml
[dependencies]
mini-static = { version = "0.1", 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.

## Features

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

## Optional, not in the MVP

- **Cache-control policy hook.** Default is `no-cache` (always revalidate); a builder
  method (`with_cache_control(fn(&Path) -> &'static str)`) lets callers opt fingerprinted
  assets into `public, max-age=31536000, immutable` without changing the zero-config
  default. Lands after the MVP, as its own phase.
- **Precompressed sidecar support.** If `foo.js.gz`/`.br` exists and the client sends a
  matching `Accept-Encoding`, serve it with `Content-Encoding` + `Vary` instead of the
  uncompressed file — no compression dependency, real bandwidth win for static sites.

## 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.