# mini-static
A secure static file server: range requests, live reload, directory-index redirects. No
templating, no framework — files in, HTTP responses out.
> Status: **published**, actively developed. See `DEV_PLAN.md` for the roadmap.
```toml
[dependencies]
mini-static = { version = "0.12", 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.
- Conditional-request support: `If-None-Match` (ETag-based) and `If-Modified-Since`
(mtime-based) headers are honored, returning `304 Not Modified` when the file hasn't
changed — clients that revalidate get a fast, bodyless response instead of re-
downloading the same content.
## 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.
## Cache control and precompression
- **Cache-control default.** Every 200/304 file response carries `Cache-Control:
no-cache` — clients always revalidate against the ETag rather than caching blindly or
getting no guidance at all.
- **Immutable assets.** `Server::with_immutable_assets(predicate)` takes a
`Fn(&Path) -> bool`; paths the predicate matches get `Cache-Control: public,
max-age=31536000, immutable` instead of the default. Correct only for fingerprinted
filenames (`main.a1b2c3.js`) where a content change always produces a new name —
caching a mutable filename indefinitely would serve stale content to every client that
already has it cached.
- **Precompressed sidecars.** If a client's `Accept-Encoding` names `br` or `gzip` (`br`
preferred when both are accepted and both sidecars exist) and a sibling `<path>.br` /
`<path>.gz` exists next to the resolved file, its bytes are served instead with a
matching `Content-Encoding`. Every file response carries `Vary: Accept-Encoding` so
intermediate caches never serve the wrong variant to a differently-capable client, and
the ETag reflects whichever variant was actually served — no compression dependency,
a real bandwidth win for static sites that ship prebuilt `.gz`/`.br` files. The sidecar
path is derived by appending an extension to the already-resolved, canonicalized path
— never by re-resolving a modified request path — so it can't become a second
traversal surface.
## CSS/JS minification
- **Opt-in.** `Server::with_minify()` enables it (disabled by default) — an embedder
serving pre-bundled assets shouldn't pay a cache cost or a transform it didn't ask for.
- **Minified once per source mtime.** `.css` files go through `css-minify`, `.js`/`.mjs`
through `minify-js`; the result is cached in memory keyed by path and the mtime it was
derived from, bounded to `DEFAULT_MINIFY_CACHE_CAPACITY` (256) entries with simple
over-capacity eviction (not true LRU — deferred until a real embedder hits the cap; see
`DEV_PLAN.md`). A request for an unchanged file is served straight from the cache; a
changed mtime is treated as a miss and re-minified.
- **Invalidated immediately when live-reload is also enabled.** The cache subscribes to
the same file-change broadcaster that drives live-reload's SSE stream — no second file
watcher — and evicts a changed path's entry as soon as the event arrives, rather than
waiting for that file's next request to notice via the mtime check above.
- **`*.min.css`/`*.min.js` bypass.** Already-minified files are served as-is; running a
minifier on already-minified input is wasted work at best and a correctness risk at
worst.
- **Precompressed sidecars win.** If a `.gz`/`.br` sidecar matches the request, its bytes
are served and minification is skipped — a sidecar already represents whatever a build
step decided the final bytes should be.
- **Malformed source degrades gracefully.** A file that fails to minify (rare — a
hand-edited file, a minifier bug) is served unminified rather than failing the request.
## Features
- `err` — `mini_err::Error` integration for internal error responses.
- `log` — request logging, including logged (not exposed) traversal attempts.
## 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.