mini-static 0.31.3

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
# PLAN.md — Security + performance hardening toward best-in-show static serving

> Supersedes `DEV_PLAN.md` (deleted). That file was a phase log of work
> already shipped through 0.18.0 — the record of what landed lives in git
> history and the README's feature sections, not in a roadmap file that had
> to be reread to find the three lines still true. What carried over: the
> ≥80% line-coverage target and the real-fixture testing stance, now in
> *Standing Gates* below. What did not, and why:
>
> - **Phase 0.12's minified-body cache and `Server::with_minify()`** (never
>   built) contradict the architecture the crate actually settled on:
>   CSS/JS transformation is build-time only, delegated to external tools,
>   because a subprocess call has unbounded latency and does not belong in
>   request handling. A request-time minify cache is the design that was
>   rejected, not work still owed. Its deferred LRU-eviction note dies with
>   it.
> - **TODOs 38–39** (extract and property-test `contains_header_terminator`)
>   are superseded by commit 2, which deletes the hand-rolled header reader
>   those TODOs existed to harden. Testing code slated for deletion is
>   negative work.
> - **The "feature commits bump minor, fixes bump patch" convention** is
>   superseded by the workspace git convention: patch by default, minor only
>   for a milestone or a behavior change. Where the two disagree, the
>   workspace convention wins.

## Standing Gates

Every commit in this plan, not just the last one:

- `cargo test` and `cargo test --all-features` pass.
- `cargo clippy -- -D warnings` and `cargo clippy --all-features -- -D warnings`
  pass.
- `cargo llvm-cov --all-features --fail-under-lines 80` holds. `.gitlab-ci.yml`
  encodes all of the above, but its `workflow.rules` are `when: never` (no
  CI credits on this plan) — so these are **local** gates, run per commit, or
  they are not run at all.
- Connection-lifecycle and traversal behavior is tested against real socket
  and filesystem fixtures (`tempfile`, real binds), never pure-function unit
  tests alone. Several gaps in this plan — first-request-only header bounds
  among them — are exactly the class a pure unit test cannot see.
- The version is bumped in the same commit as the change it belongs to, per
  the workspace git convention.

## Where We Are

`mini-static` 0.21.2 serves GET/HEAD over plain TCP. The traversal boundary
(NUL check → percent-decode → `..`-segment pre-check → `canonicalize` +
`starts_with(root)`, `src/resolve.rs:68-97`) is property-tested; a rejected
traversal and a miss are byte-identical 404s; symlinks must land inside the
root. Range requests, `If-None-Match`/304, precompressed sidecars, immutable
cache predicates, and a bounded-chunk streaming body all work and are tested.

The gaps, precisely:

- **HTTP/2 is reachable but undocumented, untested, and unbounded.**
  `hyper_util::server::conn::auto::Builder` (`src/server.rs:1425`) is built
  with the `server-auto` feature, which transitively enables `hyper/http2`. A
  prior-knowledge h2c client gets HTTP/2 today, with no stream/concurrency
  configuration, while every code comment and the README describe an HTTP/1
  server. The hand-rolled header pre-read does not block the h2 preface.
- **Connection limits guard only the first request.** The header-read timeout
  and the 64 KB header cap live in `read_header_prefix`
  (`src/server.rs:1409-1413`, `:1291`), which runs once per connection.
  Subsequent keep-alive requests have no header timeout, no idle timeout, and
  hyper's default header limits.
- **Dotfiles are served.** Nothing filters leading-dot names: a `.env` or
  `.git/config` under the root is served like any file.
- **304 responses omit `X-Content-Type-Options`.** The 304 path builds its own
  response builder (`src/server.rs:1150-1158`) and skips the `nosniff` that
  `response()` adds everywhere else.
- **The ETag contradicts its own rationale.** `generate_etag` is
  `"{len}-{mtime_secs}"` (`src/server.rs:1591-1599`); README justifies
  omitting `If-Modified-Since` on the ground that the ETag distinguishes two
  same-second writes — at whole-second precision and unchanged length it
  cannot, so a same-second same-length rewrite yields a wrong 304.
- **`Accept-Encoding` is matched by substring** (`src/server.rs:1545-1547`):
  `gzip;q=0` still selects the gzip sidecar; no q-values, no `identity`, no
  `*`.
- **HTML injection buffers without bound.** With live-reload or SPA mode (SPA
  is a production feature), every `text/html` response is read fully into
  memory per request (`src/server.rs:1166-1180`) — a large HTML file is an
  amplification vector and a latency cliff.
- **Zero observability.** Connection errors are discarded
  (`src/server.rs:1425-1427`), accept errors are swallowed into backoff,
  there are no access logs. README documents `err` and `log` features;
  `Cargo.toml` defines no features at all.
- **Benchmarks cover only path resolution** (`benches/path_resolution.rs`) —
  nothing measures `handle_request`, body streaming, or the per-request
  syscall budget (1–2 canonicalize + open + metadata + up to 2 sidecar
  probes + a `Server` clone + a `String` alloc).
- **README/code divergences**: the filename-decoding section claims byte-level
  `OsStr::from_bytes` decoding; the code decodes to UTF-8 with a raw-string
  fallback (`src/resolve.rs:105-110`). The features section lists `err`/`log`
  which do not exist.

## Where We Need To Be

- A client speaking anything other than HTTP/1.1 (including an h2c preface)
  receives an HTTP/1.1-level rejection, and `h2` is absent from the
  dependency tree. The README states the HTTP/1.1-only surface.
- Every request's header phase — first or hundredth on a connection — is
  bounded by the same configured timeout and byte cap, enforced by hyper's
  `http1::Builder` (`header_read_timeout`, `max_buf_size`), and the
  hand-rolled `read_header_prefix`/`PrefixedIo` machinery is deleted. An idle
  keep-alive connection closes after that same timeout.
- A request whose decoded path contains a leading-dot segment answers the
  standard 404 — byte-identical to a miss — except under `.well-known/`,
  which serves normally (ACME, `security.txt`). `Server::with_hidden_files()`
  restores the old behavior.
- Every response, 304 included, carries `X-Content-Type-Options: nosniff`.
- The ETag incorporates sub-second mtime precision: two writes in the same
  second with equal length produce different ETags.
- `Accept-Encoding: gzip;q=0` never selects the gzip sidecar; q-values and
  token boundaries are honored per RFC 9110.
- An HTML response larger than a stated constant streams unmodified instead
  of being buffered for script injection, and the skip is logged.
- An embedder can attach fixed extra response headers (HSTS, CSP, …) at
  configuration time, validated eagerly, present on every response.
- Opt-in request logging emits one line per request (method, path, status,
  bytes, duration) and connection-level errors are logged rather than
  discarded. The README's feature list matches Cargo.toml.
- A criterion benchmark exercises `handle_request` end-to-end (small file,
  streamed large file, 304, sidecar hit) so future caching work argues from
  numbers, not vibes.

## Commits

### 1. Pin the surface to HTTP/1.1
- Does: Replace `conn::auto::Builder` with `hyper::server::conn::http1::Builder`
  and drop the `server-auto` feature from `hyper-util`, removing `h2` from
  the tree; document the HTTP/1.1-only surface in README.
- Verifies: New test sends an HTTP/2 prior-knowledge preface over a raw
  socket and asserts no h2 SETTINGS frame comes back — the connection closes
  or answers an HTTP/1.x error (hyper's parser rejects the `PRI *
  HTTP/2.0` request line; whether it writes a 400 before closing is hyper's
  choice, not this crate's contract); `cargo tree -i h2` exits non-zero;
  full suite passes.
- Touches: `src/server.rs` (~10 LOC), `Cargo.toml`, `Cargo.lock`, README,
  one new test (~30 LOC).
- Reverts cleanly: yes.

### 2. Bound every request's header phase; delete the hand-rolled pre-read
- Does: Configure `http1::Builder::header_read_timeout(header_timeout)` and
  `max_buf_size(MAX_HEADER_BYTES)`; delete `read_header_prefix`, `PrefixedIo`,
  `HeaderReadError`, and `tests/unit/server/header_prefix.rs`. hyper 1.x enforces
  `header_read_timeout` only when a timer is installed — the builder must
  also call `.timer(hyper_util::rt::TokioTimer::new())` (hyper-util `rt`
  helpers), or the timeout is configured but never fires; the
  first-request-stall test is the tripwire proving the timer is wired.
- Verifies: Existing first-request timeout and oversized-header tests still
  pass against hyper's enforcement; new test sends one complete request,
  then stalls mid-header on the same connection and asserts it closes within
  the timeout; existing SSE-survives-timeout test still passes.
- Touches: `src/server.rs` (−~120 LOC, +~10), delete one unit test file.
  Depends on commit 1's builder.
- Reverts cleanly: yes, as a pair with commit 1 if both revert; alone it
  restores the pre-read against the http1 builder without conflict.

### 3. Deny dotfiles by default, `.well-known` excepted
- Does: In `resolve`, reject any decoded *request-path* segment starting
  with `.` with the same `NotFound` collapse as a miss; add
  `Server::with_hidden_files()` to opt out. Scope, precisely: the check runs
  on the decoded request path only, never on the served root's own
  filesystem path (a root under `~/.config/site` keeps working); segments
  equal to `.` are exempt (`/./file` is a same-dir reference, not a hidden
  name; `..` is already rejected upstream); the sole hidden-name exception
  is a *first* segment equal to `.well-known` — deeper dot segments,
  including ones under `.well-known/`, still deny.
- Verifies: `/.env` and `/.git/config` (files present on disk) answer 404
  byte-identical to a miss; `/.well-known/security.txt` serves;
  `/.well-known/.hidden` answers 404; `/./real.html` still serves; with
  `with_hidden_files()` the `.env` serves; property test extended: no
  resolved path came from a request containing a hidden segment unless
  opted in.
- Touches: `src/resolve.rs` (~25 LOC), `src/server.rs` (builder, ~15 LOC),
  `tests/resolve.rs` + `tests/resolve_property.rs` (~60 LOC), README.
- Reverts cleanly: yes.

### 4. `nosniff` on 304
- Does: Route the 304 response through `response()` so it inherits
  `X-Content-Type-Options` like every other status.
- Verifies: Test triggers a 304 via `If-None-Match` and asserts the header.
- Touches: `src/server.rs` (~5 LOC), one test (~15 LOC).
- Reverts cleanly: yes.

### 5. Sub-second ETag precision
- Does: Extend `generate_etag` to `"{len}-{secs}.{nanos}"` using
  `Duration::subsec_nanos`, making the README's same-second claim true.
- Verifies: Unit test constructs two metadata mtimes inside one second and
  asserts distinct ETags; existing conditional/If-Range tests pass (they
  round-trip served ETags, never hardcode the format).
- Touches: `src/server.rs` (~5 LOC), one unit test (~15 LOC).
- Reverts cleanly: yes.

### 6. RFC 9110 `Accept-Encoding` negotiation
- Does: Replace substring `contains` with token-level parsing: split on
  commas, match whole codings case-insensitively, honor `;q=` (q=0 excludes),
  keep the existing br-over-gzip preference at equal q. `*` never selects a
  sidecar — deliberately conservative: treating the wildcard as
  match-nothing can only miss a bandwidth optimization, never serve an
  encoding the client refused.
- Verifies: Unit tests: `gzip;q=0` serves identity bytes; `br;q=0.5, gzip`
  picks gzip; `brotli` (not a real coding) matches nothing; existing sidecar
  integration tests pass.
- Touches: `src/server.rs` (~40 LOC incl. tests in
  `tests/unit/server/precompressed_sidecar.rs`).
- Reverts cleanly: yes.

### 7. Opt-in request/connection logging
- Does: Add `Server::with_request_logging_to(writer: Box<dyn Write + Send>)`
  emitting one line per request (method, decoded path, status, body bytes,
  duration) and logging connection-serve and accept errors instead of
  discarding them, plus a `with_request_logging()` convenience that targets
  stderr; fix the README features section (drop the nonexistent `err`/`log`
  feature claims in favor of this builder). The writer-injection form exists
  because libtest cannot capture `eprintln!` from server tasks — a sink the
  test hands in is the only honestly verifiable design.
- Verifies: Test passes an in-memory buffer as the writer, makes a request,
  and asserts the line's fields; default (no builder) writes nothing.
- Touches: `src/server.rs` (~70 LOC), README, one test (~40 LOC).
- Reverts cleanly: yes.

### 8. Bound HTML-injection buffering
- Does: Make the existing HTML-injection gate size-aware: injectable =
  (live-reload or SPA) ∧ `text/html``metadata.len() <=
  MAX_INJECTABLE_HTML_BYTES` (8 MiB const). Everything already keyed off
  that gate — sidecar skip, range skip, full-read — follows automatically,
  so an over-cap HTML file takes the ordinary streamed path (sidecars
  eligible again) with no second decision point. The skip logs through
  commit 7's sink when logging is enabled.
- Verifies: Test serves an over-cap HTML fixture with SPA mode on and asserts
  a streamed, un-injected, correct-length body; an under-cap fixture still
  gets the script; with logging enabled, the skip line appears in the test's
  buffer.
- Touches: `src/server.rs` (~20 LOC), `tests/spa_mode.rs` (~40 LOC), README.
- Reverts cleanly: yes (commit 7 stays useful without it).

### 9. Configurable extra response headers
- Does: Add `Server::with_response_header(name, value)` (repeatable),
  validated via `HeaderName`/`HeaderValue` parsing at configuration time
  (`Err(StaticError)` on invalid), applied inside `response()` so every
  status inherits them. Names the server computes per response are rejected
  at configuration time with the same error path: `Content-Length`,
  `Content-Type`, `Content-Encoding`, `Content-Range`, `ETag`,
  `Cache-Control`, `Vary`, `Accept-Ranges`, `Allow`, `Location`,
  `Connection`, `Transfer-Encoding`, `X-Content-Type-Options` — a fixed
  header silently fighting a computed one is a config mistake surfaced at
  startup, not a per-request surprise.
- Verifies: Configure `Strict-Transport-Security`; assert presence on 200,
  304, 404, and 405; invalid header name errors at configuration, not at
  request time; configuring `Content-Length` errors at configuration.
- Touches: `src/server.rs` (~45 LOC), one test (~40 LOC), README.
- Reverts cleanly: yes.

### 10. HTTP-path benchmarks
- Does: Add `benches/handle_request.rs` (criterion) driving `handle_request`
  directly — it is `pub` (`src/server.rs:1047`), the same entry
  `tests/common` uses, so an external bench target can call it: small-file
  200, 1 MiB
  streamed 200, 304, sidecar hit — establishing the baseline any future
  metadata/sidecar-cache proposal must beat.
- Verifies: `cargo bench` runs all four groups; numbers recorded in the
  commit message.
- Touches: `benches/handle_request.rs` (~90 LOC), `Cargo.toml` bench entry.
- Reverts cleanly: yes.

### 11. Docs truth pass
- Does: Fix the README filename-decoding section to describe the actual
  UTF-8-with-fallback behavior of `decode_request_path`; sweep README claims
  against commits 1–10's landed behavior.
- Verifies: `grep OsStr::from_bytes README.md` is empty; README review
  against the shipped builders.
- Touches: README only.
- Reverts cleanly: yes.

## Open Questions

- **TLS stays a non-goal.** Best-in-show *standalone* servers (Caddy) own
  TLS; mini-static is an embeddable crate that a reverse proxy or the
  embedder's own rustls acceptor fronts. Adding an optional rustls feature
  is a separate plan if a real consumer asks — flagging, not planning it.
- **HTTP/2 and HTTP/3** are deliberately dropped/absent (commit 1). For a
  crate without TLS they are near-unreachable by browsers anyway (h2 in
  browsers is TLS-only). Revisit only alongside a TLS decision.
- **Hot-path caching** (metadata/sidecar-existence/open-file) was deferred
  until commit 10's benchmarks showed whether the syscall budget matters.
  It does, measurably: a `304` that serves no body still costs 37.9 µs
  against a small file's 56.2 µs, so roughly two thirds of a small-file
  response is spent resolving and stat-ing before any byte is read, and the
  sidecar probe adds a further 13.5 µs. Streaming is *not* the bottleneck
  (1 MiB ≈ 6 GB/s). A resolved-path/metadata cache is therefore the one
  optimization with evidence behind it — but it is a correctness-sensitive
  change (invalidation, symlink revalidation, TOCTOU) and belongs to its own
  plan with its own grill, not an appendix to this one. Numbers and method
  live in `benches/handle_request.rs`.
- **Per-IP rate limiting / max-requests-per-connection**: out of scope for
  this plan; the connection semaphore plus per-request header bounds are the
  crate's DoS posture. A real deployment story may warrant it later.
- **Non-UTF-8 filename serving** (the README's original byte-level-decode
  claim): commit 11 documents reality instead of implementing the claim.
  Implementing `OsStr::from_bytes` decoding is a small follow-up if a
  consumer actually serves non-UTF-8 names.

## Grill Verdict
Round: 1
Status: PASS

Findings resolved:
- [Commit 1, G1] Verification asserted "an HTTP/1.1 error response" to an
  h2c preface — hyper only guarantees the connection won't speak h2, not
  that it writes a 400 before closing → assertion rewritten to "no SETTINGS
  frame; closes or HTTP/1.x error".
- [Commit 2, G3] hyper 1.x `header_read_timeout` is inert without an
  installed timer → `.timer(TokioTimer::new())` written into the commit,
  with the stall test named as the tripwire proving it's wired.
- [Commit 3, G3] Hidden-segment check scope was ambiguous between request
  path and filesystem path — a served root under a dot-directory
  (`~/.config/site`) would have broken → scoped explicitly to decoded
  request-path segments only.
- [Commit 3, G1] Blanket "starts with `.`" would 404 harmless `/./file`
  same-dir references → segments equal to `.` exempted.
- [Commit 3, G1] `.well-known` exception depth unstated → first-segment
  only; deeper dot segments (including under `.well-known/`) still deny;
  verification cases added.
- [Commit 6, G3] `Accept-Encoding: *` handling unstated → wildcard never
  selects a sidecar, with the conservative rationale written in.
- [Commits 7/8, G3+P3] Old commit 7 logged via an interim `eprintln!` that
  old commit 8 would migrate — cross-commit coupling → reordered: logging
  lands first, the injection cap logs through its sink.
- [Commit 7, G1] "Capture stderr" isn't achievable under libtest for
  `eprintln!` from server tasks — the verification was untestable as
  written → design changed to writer injection
  (`with_request_logging_to(Box<dyn Write + Send>)`, stderr convenience on
  top), test asserts against an in-memory buffer.
- [Commit 9, G1] `with_response_header` could silently fight computed
  headers (`Content-Length`, `ETag`, `Vary`, …) → fixed denylist rejected at
  configuration time, verification case added.
- [Commit 10, G3] Bench assumed `handle_request` is callable from an
  external bench target → verified `pub` at `src/server.rs:1047`, cited in
  the commit.
- [Commit 5, G3] "Existing tests never hardcode the ETag format" — verified
  against `tests/`: the only literal is a deliberately-stale `If-Range`
  validator, format-independent. Clean, claim stands.

Findings accepted as tradeoffs:
- [Commit 6, G1] Wildcard `*` and q-ordered preference beyond br-over-gzip
  are not fully implemented — the parser is correct-but-conservative.
  Reason: for a two-sidecar negotiation, full RFC 9110 proactive-negotiation
  scoring buys nothing but code; the conservative reading can under-serve
  compression, never mis-serve it. Logged in the commit body.
- [Commit 8, G1] An over-cap HTML page silently loses SPA/live-reload
  injection (logged only when logging is enabled). Reason: the alternative
  — streaming injection — is a parser in the response path; an 8 MiB HTML
  document losing a dev-reload script is the right side of that trade.