mini-static 0.33.2

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

What `mini-static` defends against, what it deliberately does not, and — for every row in
the first table — the test that fails when the defence is removed.

The second and third tables are the more useful ones. A list of defences with no stated
boundary is marketing; naming what this crate does *not* do is what makes the first list
credible.

## Trust boundary

`mini-static` trusts:

- **The contents of the served root.** Every file under it is publicly readable by
  anyone who can reach the server. Putting a secret there is a deployment decision this
  crate cannot second-guess — the hidden-file policy narrows the default, it does not
  make the root safe to fill with private data.
- **The operator's own configuration.** A 404 page, a response header or an
  immutable-asset predicate supplied at startup is taken at face value, subject to the
  validation rows below.
- **`hyper`**, for HTTP/1 framing — request lines, header parsing, chunked encoding, and
  the smuggling defences at that layer.
- **The filesystem's containment primitives** — that a file descriptor's real path does
  not change under it after `open` returns.

`mini-static` trusts nothing from the wire: not the request path, not its percent
encoding, not the method, not `Accept-Encoding`, not `Range`, not `If-None-Match`.

## Defended

Every row was verified by removing the guarantee from the source and confirming that
exactly that test fails. `./verify-guarantees.sh` performs those mutations and is the
evidence for this table — run it rather than believing it. Last full run: 15 of 15 caught.

| Guarantee | Test | Mutation id |
|---|---|---|
| A `..` path segment is refused, however it was spelled | `resolve_traversal_attempt_rejected` | `traversal-rejected` |
| Segments supplied by a router are checked before they reach the filesystem — where they came from is the caller's business, whether they can escape the root is this crate's | `a_segment_containing_a_separator_is_refused` | `segments-from-a-router-are-checked` |
| A guard on a path prefix cannot be bypassed by encoding the separator — proven end to end, API routes and files in one process | `an_encoded_separator_cannot_bypass_a_guard_on_the_prefix` | `segments-from-a-router-are-checked` |
| A percent-encoded separator is not a separator (RFC 3986 §3.3) — `/a%2Fb` is one segment, not two | `an_encoded_separator_does_not_reach_a_nested_file` | `encoded-separator-refused` |
| A decoded backslash is refused on every platform, not only where it separates | `an_encoded_backslash_is_refused` | `encoded-backslash-refused` |
| A NUL byte, raw or `%00`, is refused at the path rather than deep inside a syscall | `resolve_null_byte_rejected` | `nul-byte-rejected` |
| Containment is proven on the *opened* file descriptor, so a symlink cannot escape the root and there is no check-to-open window | `resolve_rejects_symlink_escaping_root` | `containment-verified-on-fd` |
| Dot-prefixed segments answer as a miss by default | `dotfiles_are_denied_by_default` | `hidden-files-denied` |
| `.well-known` is exempt at the root, and only at the root | `well_known_is_served_despite_its_leading_dot` | `well-known-exempt` |
| `X-Content-Type-Options: nosniff` is on every response shape, including 304, 404 and 405 | `every_response_carries_nosniff_and_success_returns_the_real_file_content` | `nosniff-on-every-response` |
| A per-response computed header cannot be pinned to a fixed value by configuration | `server_computed_headers_are_refused` | `computed-headers-refused` |
| An HTML page over the injection cap is served unmodified rather than buffered whole | `an_html_page_over_the_cap_is_served_unmodified_and_logged` | `injection-cap-enforced` |
| `run`/`run_ephemeral` bind loopback only — a convenience entry point never exposes a server to the LAN | `the_ephemeral_bind_address_is_loopback` | `ephemeral-bind-loopback` |
| Shutdown drains in-flight work, then stops waiting — an open SSE stream cannot hold the process open | `live_reload_shutdown_aborts_a_still_open_sse_connection_after_the_drain_timeout` | `shutdown-drain-bounded` |
| A configured 404 page outside the served root is refused at configuration time | `a_page_outside_the_root_is_rejected` | `404-page-must-be-inside-root` |
| An unsatisfiable `Range` is a 416, not a silent full-body 200 | `range_out_of_bounds_returns_416` | `range-unsatisfiable-is-416` |

Beyond the example-based tests above, path resolution is covered by property tests over
generated paths (`tests/resolve_property.rs`): every resolved path lies under the root's
canonical form, traversal is always rejected, and hidden segments never resolve.

## Inherited from `mini-serve`

Not implemented here. This crate builds a `mini-serve` app with itself as the fallback
handler, so the connection lifecycle — accepting, bounding, timing out and draining — is
`mini-serve`'s, and is mutation-verified there rather than twice.

These rows moved out of the table above when that happened. Their tests stayed: they run
against a real socket through this crate's own `run_ephemeral`, so they prove the
inheritance is actually wired up, which a citation alone would not.

| Guarantee | Proven here by | Verified in `mini-serve` as |
|---|---|---|
| Concurrent connections are capped | `server_with_max_connections_bounds_concurrent_connections` | `connection-ceiling` |
| The header-read timeout re-arms per *message* | `a_stall_on_the_second_request_of_a_connection_still_times_out` | `header-timeout-per-message` |
| A header block over 64 KiB is refused on its size | `an_oversized_header_on_the_second_request_is_still_rejected` | `header-block-bounded` |
| Shutdown drains in-flight work, then stops waiting | `live_reload_shutdown_aborts_a_still_open_sse_connection_after_the_drain_timeout` | `shutdown-drain-bounded` |
| An HTTP/1.1 request with no `Host` is refused (RFC 9112 §3.2) || `host-required-on-http11` |
| The request path is split before it is decoded, so `%2F` is never a separator | `an_encoded_separator_does_not_reach_a_nested_file` | `fallback-gets-router-segments` |

**The header-block ceiling went the other way.** `mini-serve` had no such bound until this
migration: adopting it would have silently dropped this crate's 64 KiB limit, and the test
above is what caught it. The bound now lives in `mini-serve` and every one of its
consumers gets it.

## Enforced by construction

These are not in the table because there is nothing to mutate — they hold by type, by
manifest, or by the compiler, which is stronger than a test.

- **No `unsafe` in this crate's own code.** A statement about `src/` only; `tokio` and
  `hyper` contain `unsafe`, as any async runtime must.
- **HTTP/1 only.** The `hyper` dependency declares `features = ["http1", "server"]` and no
  h2, so an HTTP/2 preface cannot negotiate a session — there is no h2 implementation
  linked in to negotiate with. Pinned by `an_http2_preface_never_negotiates_an_http2_session`
  as a regression guard.
- **A 404 body cannot echo the requested path.** `StaticError::user_message()` returns
  `&'static str`, so there is no dynamic content available to leak. Both a miss and a
  traversal attempt return the same `"not found"`, which is also why the two are
  indistinguishable to a client probing for what the root contains.

## Not defended

- **Nothing under the root is private.** There is no authentication, no authorisation and
  no per-path access control. The hidden-file policy hides dotfiles from the default
  configuration; it is not an access-control mechanism and must not be used as one.
- **No rate limiting.** The connection ceiling bounds concurrency, not request rate. A
  client within the ceiling may request as fast as it likes.
- **No content cache.** Every request resolves and opens the file. This is a throughput
  decision, not a security one, but it means a hot path is not shielded from disk.
- **TLS is out of scope**, permanently. Terminate it in front, or compose `mini-serve`
  with `mini-tls`.
- **The `Range` implementation answers a single range only.** A multi-range request is
  answered with the full body rather than a multipart response — deliberate, since
  multipart range assembly is a parser this crate would rather not own.
- **Symlink containment depends on the platform.** On macOS, iOS and Linux the check runs
  against the opened descriptor's real path. Elsewhere it falls back to
  canonicalize-then-open, which re-admits a check-to-open window. Both variants are
  exercised by the property tests on whichever platform compiles them.