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