mini-static 0.32.2

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
# PLAN-layering.md — One server, two expressions

Cross-repo: commits are tagged **[serve]** or **[static]** or **[unified]** for the
repository they land in. Version bumps follow each repo's own convention.

## Where We Are

Two independent HTTP servers in one ecosystem.

`mini-serve` 0.13.8 owns an accept loop with a permit acquired before `accept()`, a
connection ceiling, a per-message header-read timeout, a bounded shutdown drain, an RFC
9112 `Host` check, path and query length limits, and a single response exit point
(`route_with` → `route_inner` → `finalize`) that applies nosniff, CORS and HEAD
body-stripping to every branch. Twenty-five guarantees, each verified by removing it from
the source and confirming exactly one test fails (`verify-guarantees.sh`). It costs 5.04
µs/req on a static route against raw hyper's 4.59 — 0.45 µs of framework.

`mini-static` 0.32.1 owns a *second* accept loop, semaphore, header timeout and shutdown,
none of which are written down as guarantees and none of which are mutation-verified. It
has no `THREAT_MODEL.md`. It also owns a genuinely fast file-serving engine: at 0.31.0,
56,700 req/s at 98% CPU, 57,900 req/CPU-s, p50 0.87 ms — ahead of a same-run
single-worker nginx on throughput (52,500) and level on per-CPU efficiency (57,900),
without a content cache. **Those numbers have not been re-measured since 0.31.0**; 0.32.0
and 0.32.1 changed the resolution path.

`mini-unified` 0.2.0 bridges the two in 209 lines whose entire purpose is to discard
`mini-static`'s server half and keep its handler half.

The two crates independently derived three things:

- **Path decoding.** `mini-serve` splits then decodes (RFC 3986 §3.3); `mini-static`
  decoded then split until 0.32.0. `%2F` was a literal to one and a separator to the
  other. Composed, `/admin%2Fconfig` bypassed a 403 guard and served the file — proven,
  then fixed in 0.32.0. Nothing structurally prevents the next divergence.
- **Method dispatch and 405**, with its own `Allow: GET, HEAD` (`server.rs:877`).
- **HEAD body-stripping** (`server.rs:1070`, `server.rs:1105`), where `mini-serve` strips
  uniformly *after* the handler returns and has a verified guarantee that a HEAD response
  reports the `Content-Length` its GET would (RFC 9110 §9.3.2).

Dependency weight: `mini-static` 33 crates, `mini-serve` 40 (7 of them the serde stack,
for JSON `mini-static` never emits), both together 43. Adding `mini-serve` to a
`mini-static` build costs 0.3s on a clean release build, 0.1s on debug, and 0.02s
incremental — measured, and the reason no feature flag appears in this plan.

## Where We Need To Be

One accept loop, one router, one path model, in one process.

- `mini-static` exposes its file-serving engine as a handler and has **no accept loop,
  no method dispatch and no HEAD handling of its own**. It depends on `mini-serve`
  unconditionally and re-exports it.
- `mini-serve` gains a `with_fallback` seam *inside* `route_inner`, receiving the request
  and the segments the router already decoded. It gains no dependency on `mini-static`;
  an API-only deployment pulls no file-serving code.
- `serve_dir(root, addr)` is the standalone path, implemented as a `mini-serve` app with
  zero routes and a static fallback. The bare case is the layered case.
- `mini-unified` is deleted.
- Serving files, API routes, TLS (transport seam) and WebSockets (upgrade seam) compose
  in one process without a bridge crate.

## Acceptance Gates

Every gate is a pass/fail with a number or a named test. The migration is not done until
all four pass. Gates 1 and 3 are captured as baselines *before* any behaviour moves,
because neither currently exists.

| # | Gate | Bar |
|---|---|---|
| G1 | `mini-static`'s guarantees | Enumerated in `THREAT_MODEL.md`, every one mutation-verified. This is the checklist the migration must preserve. |
| G2 | `mini-serve`'s guarantees | All 25 mutations still caught, **with the response-shape tests extended to treat a fallback-served response as another shape** (nosniff, CORS, 5xx sanitisation, panic reporting, HEAD length). |
| G3 | `mini-static` throughput |**52,500 req/s** and ≥ **57,900 req/CPU-s** in the `oha` c=50 single-worker harness — i.e. still at or ahead of nginx. Re-baselined on 0.32.1 first. |
| G4 | `mini-serve` throughput | No regression in `bench/bench.sh` CPU-µs/req from adding the fallback seam. |

Criterion benches (`handle_request`, `path_resolution`) are necessary but **not
sufficient** for G3: they measure only the half that survives the migration and would
report no regression while end-to-end throughput fell.

## Commits

### 1. [static] Write down what mini-static already guarantees
- Does: adds `THREAT_MODEL.md` and `verify-guarantees.sh` covering today's behaviour —
  traversal containment, fd-based `starts_with` check, encoded-separator refusal, hidden
  files, the 404 oracle collapse, symlink escape, connection ceiling, header timeout,
  shutdown.
- Verifies: `./verify-guarantees.sh` reports every mutation caught; a citation test proves
  every named test exists. **This is G1.**
- Touches: `THREAT_MODEL.md`, `verify-guarantees.sh`, `tests/threat_model_citations.rs`.
- Reverts cleanly: yes — documentation and a script, no source change.
- Note: expect vacuous tests. The encoded-separator work found four in one afternoon.
- **Tripwire (P4): if more than five vacuous tests turn up, stop and split them into
  their own commit.** "Fix whatever the mutations find" is an unbounded second reason to
  exist hiding inside a documentation commit.

### 2. [static] Build the throughput harness, then baseline 0.32.1
- Does: adds `bench/throughput.sh` and the nginx config it compares against, then runs it.
  **The harness does not currently exist**`benches/` holds criterion micro-benchmarks
  only, and the 56,700-vs-nginx figures came from an ad-hoc run that cannot be reproduced
  or re-pointed at a composed server. G3 is unmeasurable until this exists.
- Verifies: three runs reproduce within noise, and reproduce 0.31.0's published numbers
  when checked out at that tag. **This is G3's baseline.**
- Touches: `bench/throughput.sh`, `bench/nginx.conf`, `bench/README.md`, this file.
- Reverts cleanly: yes.
- **Records the machine.** The 0.31.0 numbers were taken on the development machine; the
  T480 is the perf-verification box. A comparison across machines is not a comparison.
  The harness prints host, core count and nginx version into its output.
- Note: if 0.32.1 no longer clears 52,500 / 57,900, **stop** — that is a regression from
  the security fix, and this plan does not proceed until it is resolved.

### 3. [code] Teach the gate to check the non-default feature build
- Does: when a crate declares features, `gate.sh` runs its lint, tests and doc build a
  second time under `--no-default-features`.
- Verifies: reintroducing a `--no-default-features`-only compile error is caught — proven
  by making the check fail before trusting it, per the rule this gate already carries.
- Touches: `.claude/skills/code/gate.sh`, `SKILL.md`, and the copy in `~/.claude`.
- Reverts cleanly: yes.
- **Why it precedes commit 4.** The gate lints `--all-features` only. The moment commit 4
  adds a feature, it creates a build configuration nothing checks, which is how the
  unlinted state rots. This is the same failure the `--all-targets` widening fixed for
  tests.

### 4. [serve] Feature-gate serde behind a default-on `json` feature
- Does: puts `json()`, `json_body()`, the `query_params`/`path_params` deserializers and
  the 5xx JSON error body behind `json`, default on. The 5xx body becomes a plain string
  when the feature is off.
- Verifies: `verify-guarantees.sh` catches all 25 mutations under
  `--no-default-features`; crate count drops 40 → 34; `gate.sh` green in both feature
  states (commit 3); `mini-socket` and `mini-tls` still build against it.
- **Check first:** the `5xx-body-sanitized` guarantee test may assert a JSON body shape.
  If it does, gating JSON changes what that mutation proves, and the test must be
  rewritten to assert the *absence of the internal message* rather than the presence of
  a JSON envelope.
- Touches: `Cargo.toml`, `src/body.rs`, `src/response.rs`, `src/extract.rs`,
  `src/app.rs`, `src/lib.rs`. ~60 LOC.
- Reverts cleanly: yes.
- Rationale: not build time (measured at 0.3s), but dependency weight — 7 crates of JSON
  machinery in a static file server is what the ecosystem premise exists to refuse.

### 5. [serve] Add the fallback seam
- Does: `RouteBuilder::with_fallback(handler)`, invoked **inside `route_inner`** when no
  route matches, receiving the request and the already-decoded path segments.
- Verifies: a fallback-served response carries nosniff and CORS, has its 5xx body
  sanitised, survives a panicking fallback, and strips its HEAD body — asserted by
  extending the existing guarantee tests, then confirmed by re-running their mutations.
  **This is G2.** Plus G4: `bench.sh` shows no CPU-µs/req regression.
- Touches: `src/app.rs`, `src/router.rs` (expose segments), tests. ~120 LOC.
- Reverts cleanly: yes — additive; no existing caller changes.

**The fallback MUST pass through `apply_middlewares`.** Middleware is applied per route at
registration (`app.rs:1068`–`1096`); a fallback wired in anywhere else is *not wrapped*,
so an auth middleware guarding `/admin/` would not protect a file the fallback serves.
That is the identical failure to the encoded-slash bypass this whole plan exists to
prevent — reintroduced by the seam's plumbing rather than by a decoder. This gets its own
guarantee, its own test, and its own mutation id (`fallback-is-wrapped`): delete the
`apply_middlewares` call on the fallback and a middleware-blocks-fallback test must fail.

**Placement is the other half.** Wired *around* `route_with` instead of inside
`route_inner`, the fallback silently bypasses `finalize` — nosniff, CORS, HEAD stripping,
all of it. Both failures are invisible in a passing test suite; only the mutations catch
them.

### 6. [static] Expose the file-serving engine as a handler
- Does: adds a public handler entry point taking method, decoded segments and headers,
  returning a response. Structurally typed — `http`/`hyper` types only, no `mini-serve`
  types. The existing server stays and calls it.
- Verifies: the entire existing suite passes unchanged, routed through the new entry
  point. No behaviour change.
- Touches: `src/server.rs`, `src/lib.rs`. ~80 LOC.
- Reverts cleanly: yes.
- **Blocked on the `Location` open question.** If redirects need the raw path, the
  signature is not segments alone and this commit has a different shape. Resolve that
  question before starting, not during.

### 7. [static] Adopt mini-serve; delete the second accept loop
- Does: takes an unconditional dependency on `mini-serve`, re-exports it, replaces
  `run`/`run_on`/`run_all`/`run_ephemeral` with `serve_dir`, and deletes
  `accept_and_permit`, `serve_connection`, the semaphore and the shutdown handling.
- Verifies: G1 (static's own guarantees still caught — connection ceiling and shutdown now
  satisfied by `mini-serve`'s verified versions, cited as inherited), G3 (throughput holds).
- Touches: `src/server.rs` (−~280 LOC), `Cargo.toml`, `THREAT_MODEL.md` (an
  "Inherited from `mini-serve`" table, as `mini-socket`'s has).
- Reverts cleanly: yes, but it is the breaking change — `run_on` and friends disappear.
  Minor bump at minimum; this is where castra's frontends break.
- **The rest of the builder surface needs a decision, not a discovery.**
  `with_max_connections` duplicates `mini-serve`'s ceiling; `with_request_logging` and
  `with_response_header` duplicate middleware. Each either forwards to `mini-serve`,
  or is deleted with the migration path documented. Enumerate them before starting —
  finding them one at a time during the commit is how it blows its estimate.
- **The live-reload watcher has no owner once the server is gone.** `start_watching` and
  the `Broadcaster` are spawned by the current `run_*` path. `serve_dir` must take over
  that lifecycle, and a fallback registered by hand must have a documented way to do the
  same, or live reload silently stops working in the layered case.
- **Do not publish until commit 10.** Commits 7 and 8 are only safe together, and the
  gates have not run yet.

### 8. [static] Delete the duplicated method dispatch and HEAD handling
- Does: removes the 405/`Allow` construction and both HEAD body-stripping sites.
  `mini-serve` owns method semantics.
- Verifies: `tests/methods.rs` still passes with the assertions now describing
  `mini-serve`'s 405; a HEAD request reports the `Content-Length` its GET would.
- Touches: `src/server.rs` (−~40 LOC), `tests/methods.rs`.
- Reverts cleanly: yes.
- Note: must follow 7. Deleting these while `mini-static` still runs its own server would
  leave it with no method dispatch at all.

### 9. [unified] Delete mini-unified
- Does: yanks `mini-unified` and replaces its README with a pointer to `with_fallback`.
- Verifies: nothing depends on it — confirmed by grep across the monorepo *and* castra.
  The `mini-unified` pattern is one castra intends to use; if anything there is mid-flight
  toward it, this commit waits until that consumer has moved to `with_fallback`. Yanking
  is reversible and existing lockfiles keep resolving, so the blast radius is new
  resolution only.
- Touches: the whole repo.
- Reverts cleanly: yes (a yank is reversible; the crate is not deleted from the registry).

### 10. [static] Run every gate and publish
- Does: no source change. Runs G1–G4, records results in the table below, publishes.
- Verifies: all four gates green, recorded with numbers, not adjectives.
- Reverts cleanly: n/a — publishing is the one irreversible step in this plan.
- **Publish order is `mini-serve`, then `mini-static`.** `mini-static` depends on it, so
  the reverse order cannot resolve. `mini-unified`'s yank goes last.
- **If a gate fails here**, the answer is not to lower the bar. Commits 7 and 8 revert
  together and cleanly; the crate returns to its own accept loop while the cause is
  found. That is the rollback, and it is why nothing publishes before this point.

## Open Questions

- **Does a CORS preflight for a fallback-served path get a 204 or a 404?** `mini-serve`
  guarantees that *a preflight for an unregistered path 404s rather than being masked by a
  204* (`cors_preflight_only_for_registered_routes`). A catch-all fallback makes "registered"
  ambiguous — every path is now servable. Either the guarantee's meaning changes or the
  fallback must not receive `OPTIONS`. **Decide before commit 5**; the guarantee's wording
  has to change in the same commit that changes its meaning, or the threat model starts
  lying.
- **Which methods reach the fallback?** If `with_fallback` catches every method, then
  `mini-serve`'s 405 machinery never fires for static paths and commit 7 removes a
  behaviour without replacing it. If the fallback is registered per-method, 405 works
  naturally but the API is clumsier. **Decide before commit 4** — it determines whether
  commit 7 is a deletion or a migration.
- **Does the fallback see segments before or after the router's own allocation?** The
  performance argument in this plan assumes the segments are computed once and handed
  over. If the seam ends up re-deriving them, the composed path does the work twice and G3
  is at risk.
- **`Location` headers are built from the raw request path.** With segments as the input,
  `mini-static` must re-encode to build a redirect. Percent-encoding round-trips are an
  easy place to introduce a second path model by accident — the exact bug this plan exists
  to make impossible.
- **castra's three frontend servers are pinned at `mini-static = "0.7.1"`**, twenty-five
  minor versions behind, and commit 6 breaks their entry point. They need an upgrade pass
  regardless; it is out of scope here but it is a real downstream consequence.
- **`mini-static`'s 0.32.1 numbers are unmeasured end-to-end.** Commit 2 may find the
  security fix cost more than the criterion benches showed, in which case this plan pauses
  at commit 2.
- **No `--no-default-features` handler-only mode.** Measured DX benefit was 0.02s in the
  dev loop, so the flag is not justified by build time. If someone wants the engine inside
  axum or raw hyper, add it then — with a user asking, rather than on a hypothesis.

## Recorded Results

| Gate | Baseline | Post-migration | Verdict |
|---|---|---|---|
| G1 static guarantees | _commit 1_ | | |
| G2 serve guarantees | 25/25 caught | | |
| G3 static throughput | _commit 2_ (0.31.0: 56,700 req/s, 57,900 req/CPU-s) | | |
| G4 serve CPU-µs/req | 5.04 µs (`/health`) | | |

## Grill Verdict
Round: 1
Status: PASS

Findings resolved:
- **[5, G1] The fallback would not be wrapped by middleware.** Middleware is applied per
  route at registration (`app.rs:1068``1096`). A fallback wired in anywhere else is
  unwrapped, so an auth guard on `/admin/` would not protect a file the fallback serves —
  the identical failure to the encoded-slash bypass this plan exists to prevent, arriving
  through the seam's plumbing instead of a decoder. Now an explicit requirement with its
  own guarantee, test and mutation id (`fallback-is-wrapped`).
- **[5, G1] The CORS preflight guarantee changes meaning.** `preflight-only-real-routes`
  says a preflight for an *unregistered* path 404s; a catch-all fallback makes every path
  registered. Promoted to Open Questions with a decision required before commit 5, because
  the guarantee's wording must change in the same commit as its meaning.
- **[2, G1] The throughput harness does not exist.** `benches/` holds criterion
  micro-benchmarks only; the 56,700-vs-nginx numbers came from an ad-hoc run. G3 was
  unmeasurable as written. Commit 2 now *builds* the harness, and records host, cores and
  nginx version — a cross-machine comparison is not a comparison.
- **[4, G1] The gate cannot see a `--no-default-features` build.** It lints
  `--all-features` only, so commit 4 would create a build configuration nothing checks.
  Split out as commit 3, ahead of the feature it exists to cover.
- **[4, G3] The 5xx sanitisation test may assert a JSON body shape**, in which case gating
  JSON changes what that mutation proves. Now checked first, and rewritten to assert the
  absence of the internal message rather than the presence of a JSON envelope.
- **[7, G1] The builder surface beyond `run_*` was undiscovered work.**
  `with_max_connections`, `with_request_logging` and `with_response_header` all duplicate
  `mini-serve` facilities. Enumerated as a decision to take before the commit starts.
- **[7, G1] The live-reload watcher has no owner** once `run_*` is gone. `serve_dir` takes
  the lifecycle, and the hand-registered case needs a documented equivalent, or live
  reload silently dies in the layered configuration.
- **[6, G3] The handler's signature depends on the unresolved `Location` question.**
  Marked blocked rather than discovered mid-commit.
- **[1, G2] "Fix whatever the mutations find" was unbounded** inside a documentation
  commit. Given a P4 tripwire at five.
- **[10, G1] Publish order was unstated**`mini-serve` must precede `mini-static`, and
  the rollback if a gate fails at the end is now written down rather than improvised.
- **[9, G1] Deleting `mini-unified` could strand castra**, which intends to use the
  pattern. Now gated on that consumer having moved.

Findings accepted as tradeoffs:
- **Ten commits across three repositories.** Larger than any plan in this stack so far.
  Inherent: the seam has to exist in `mini-serve` before `mini-static` can plug into it,
  and the gates have to exist before either moves. Recorded so it is entered deliberately.
- **Commits 7 and 8 revert as a pair, not individually.** Deleting method dispatch (8)
  while `mini-static` still ran its own server would leave it with none at all. P3 is
  satisfied for the pair rather than each commit; stated rather than pretended away.
- **castra's three frontends break at commit 7.** They are pinned at `mini-static
  = "0.7.1"`, twenty-five minor versions back, and need an upgrade pass regardless. Out
  of scope here, but it is a real downstream cost of this plan and not a free one.