# 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.
## 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.
- **Conditional-request support (`If-None-Match` / `If-Modified-Since` → `304`).** Every
response already computes and sends an `ETag`, but nothing yet reads it back: a client
that dutifully revalidates gets a full `200` every time instead of a bodyless `304`.
The prior implementation (`small-static`) had this (`is_not_modified()`); it isn't a
regression against this crate's own dev plan, since conditional GET was never one of
its numbered phases, so it's deferred deliberately rather than an oversight. See
`DEV_PLAN.md`'s post-0.5.0 phases for the planned shape.
## 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.