# mini-static
A secure static file server: HTTP/1.1, streaming responses, traversal-safe path
resolution, hidden files denied by default, live reload, precompressed sidecars,
directory-index redirects. Read-only — it serves files and never writes them. No
templating, no framework, no build step — files in, HTTP responses out.
> Status: **published**, actively developed. See `PLAN.md` for the roadmap.
---
## ⚠ Breaking in 0.29.0 — the build pipeline moved to `mini-build`
CSS/JS bundling, minification, and asset mirroring are no longer part of this crate. They
live in [`mini-build`](https://crates.io/crates/mini-build), which produces the directory
this server serves. The two compose through the filesystem and neither depends on the
other.
**Removed:** `with_source_folder`, `with_asset_folder`, `with_output_dir`,
`with_bundle_root`, `with_css_tool`, `with_js_tool`, `with_prune_output`,
`Server::build()`, and the `CssOptions`, `CssTool`, `JsOptions`, `JsTool` types.
`StaticError::PipelineSetup` and `StaticError::Build` are gone with them.
**Before** — one object doing both jobs:
```rust,ignore
let server = Server::new(Path::new("./public"))?
.with_source_folder(Path::new("./src/styles"))?
.with_css_tool(CssTool::LightningCss, CssOptions::new().bundle(true).minify(true))
.with_asset_folder(Path::new("./src/assets"))?;
server.run(timeout).await?; // built on startup
```
**After** — build, then serve:
```rust,ignore
mini_build::Builder::new(Path::new("./public"))?
.source_folder(Path::new("./src/styles"))?
.css_tool(
mini_build::CssTool::LightningCss,
mini_build::CssOptions::new().bundle(true).minify(true),
)
.asset_folder(Path::new("./src/assets"))?
.build()?;
let server = Server::new(Path::new("./public"))?;
server.run(timeout).await?;
```
For development, `mini_build::Builder::watch` rebuilds on change while this server's
`with_live_reload` watches the directory it serves and reloads the browser. Because the
server cannot observe a file before it is written, "reload only after the output exists"
now holds by construction rather than by careful sequencing.
**What this buys.** `mini-static` no longer writes to disk at all — its file access is
read-only, which is checkable rather than merely intended — and it no longer shells out to
anything or enables `tokio`'s `process` feature. Build performance also became measurable
once it had its own benchmarks: batching tool invocations cut a fifty-file CSS build from
1155 ms to 195 ms, a win that was invisible inside a crate whose benchmarks measure
microsecond request latency.
---
```toml
[dependencies]
mini-static = "0.29"
```
## Design
`Server::new(root)` canonicalizes `root` once at startup and serves everything below it.
As of 0.31.0 containment is proven **on the opened file descriptor**: the file is opened
first, the kernel is asked what was actually opened (`fcntl(F_GETPATH)` on macOS,
`/proc/self/fd` on Linux), and that real path must lie under the root. There is no gap
between the check and the bytes served — the former check-then-open window (a documented,
accepted TOCTOU) no longer exists, and the fd route is ~38% cheaper than the
`canonicalize` walk it replaced. Platforms without an fd-path facility fall back to
canonicalize-then-open. The boundary is the kernel's answer, never a pattern match on
`..`.
## Protocol surface
**HTTP/1.1 only, deliberately.** Through 0.21.x the connection was served by
`hyper_util`'s auto builder, whose `server-auto` feature transitively enables
`hyper/http2` — so a prior-knowledge h2c client negotiated HTTP/2 against a server
that documented, tested, and tuned only HTTP/1: no stream-concurrency limit, no
frame-size bound, and a header pre-read whose `\r\n\r\n` scan the HTTP/2 preface
satisfies without being an HTTP/1 request at all. 0.22.0 pins the surface to
`hyper::server::conn::http1` and drops the `h2` dependency entirely. Browsers reach
HTTP/2 over TLS only, which this crate does not terminate — put a reverse proxy in
front for h2/h3.
## Connection lifecycle (fixed from the prior iteration)
- **Header-read timeout, per request.** Every request has a bounded time (default 30s)
to send its headers before the connection is dropped, and a 64 KiB ceiling on the
header block. The original 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. The fix for that enforced both bounds
in a hand-rolled pre-read that ran *once per connection*, which left the same hole one
step further in: a client could complete one cheap request and then stall mid-header
forever on the same keep-alive connection, bounded by nothing. 0.23.0 delegates both
bounds to hyper (`header_read_timeout`, `max_buf_size`), which applies them to every
request on a connection, and deletes ~100 lines of socket plumbing. Response bodies
are deliberately unbounded by this timeout — the live-reload SSE stream stays open
until a watched file changes.
- **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 two responses are now
byte-identical, so the distinction is not observable over the wire at all.
Every response 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. That includes `304`s as of 0.24.1; before then the revalidation path built
its own response and was the one status that could arrive without the header, which is
backwards — a revalidating client is precisely the one holding the cached copy.
## Hidden files
**Dot-prefixed paths are denied by default** (0.24.0). A request for `/.env` or
`/.git/config` answers `404`, byte-identical to a miss — an existing dotfile and a
missing one are indistinguishable, for the same reason a traversal and a miss are. The
traversal guard cannot help here: those files are legitimately *inside* the root, so
before this any visitor who guessed the name got them, and a served root is routinely a
build output directory or a repository working copy.
`/.well-known/` is served regardless — it is where the web puts resources meant to be
fetched (ACME challenges for certificate issuance, `security.txt`), and denying it would
break certificate renewal. The exception covers the first segment only:
`/.well-known/.hidden` is still denied. A segment of exactly `.` is a same-directory
reference, not a hidden name, so `/./index.html` still serves.
The check runs on the **decoded request path**, so `%2E` cannot smuggle a dot past it,
and never on the served root's own filesystem path — a root that itself lives under a
dot-directory (`~/.config/site/public`) keeps working. `Server::with_hidden_files()`
restores the old behavior for roots whose dotfiles are genuinely content.
The traversal *pre-check* matches path **segments** equal to `..`, not any substring
containing `..` — the prior substring check rejected legitimate filenames like
`jquery..min.js`. The fd containment check (`real_path_of` + `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. This is deliberately not configurable.
`/dir/` is the canonical URL of a directory index, and a browser resolves a page's
relative paths against the last `/` in the address: the same `img.png` means
`/dir/img.png` at `/dir/` and `/img.png` at `/dir`. Serving both would give one page
two addresses, one of which quietly resolves its own assets to the wrong place.
Slashless URLs are a coherent choice under a *different* mechanism — serving `dir.html`
at `/dir`, the way Vercel and Netlify hide extensions — where the resource is a file
and no directory base exists to get wrong. 0.20.0 briefly offered a
`TrailingSlash::Serve` option that mixed the two; it was removed in 0.21.0.
- **Custom 404 page.** `Server::with_not_found_page(Path::new("404.html"))` serves that
file as the body of every miss, keeping the `404` status (a `200` would be a soft 404 —
indexed by search engines, invisible to monitoring) and adding `Cache-Control: no-store`.
Opt-in: a `404.html` sitting in the root does nothing on its own. The path is validated
when configured, so a missing page fails at startup rather than on the first broken
link, and it is read per response so an edit lands without a restart. Nothing about the
failed request reaches the page — a miss and a rejected traversal return identical
bytes, preserving the `not found` collapse in `StaticError::user_message`.
- **Range requests.** Single-range requests (e.g., `bytes=0-99`) get a `206 Partial Content`
response with the requested byte range. Multi-range requests (e.g., `bytes=0-99,200-299`)
are treated as invalid and return a full `200` with the whole body (RFC 9110-legal,
matches common server behavior). Out-of-bounds ranges return `416 Range Not Satisfiable`.
`If-Range` validation: exact strong ETag match only; stale `If-Range` causes a full `200`
response. Precompressed sidecars are skipped for range requests (the original file is served).
Every response includes `Accept-Ranges: bytes` to advertise support.
## 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.
- **Filesystem work runs inline as of 0.30.0 — reversing this section's previous
claim.** Path resolution, `open`, and `stat` used to be dispatched to Tokio's blocking
thread pool so a slow filesystem lookup could not stall other tasks on the same worker.
Measured under load, that sheltering cost about three times the syscalls it protected:
a one-worker server burned ~380% CPU at 17,800 req/s, of which roughly three cores were
pool dispatch. Inlining the metadata work (and buffering bodies of one chunk or less)
reaches ~45,000 req/s at ~97% CPU — 10x the per-CPU efficiency. 0.31.0's fd-based
containment then removed the `canonicalize` walk entirely: **~56,700 req/s at 98% CPU,
ahead of a same-run single-worker nginx on throughput (52,500 req/s) and level with it
per CPU-second (57,900 vs 57,900)**, on identical files. nginx retains `sendfile` for
large bodies; this crate retains a lower floor for hot small files if a content cache
is ever justified. The premise the old
design served, a filesystem where `canonicalize` blocks for milliseconds, is a network
mount — not the local disk this crate targets. Serving from such a mount is still
possible: use a multi-threaded runtime, which confines a stall to one worker. Bodies
larger than one chunk (64 KiB) still stream through async I/O with bounded memory.
- Conditional-request support: the `If-None-Match` header is honored against the
response's ETag, 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. `If-Modified-Since` is deliberately not implemented: an ETag distinguishes
representations that a whole-second mtime cannot (two writes inside the same second),
so it is the validator to serve. Through 0.24.1 that reasoning did not survive contact
with the implementation — the ETag was `"<size>-<mtime_secs>"`, itself whole-second, so
rewriting a file within a second of its last write without changing its length
reproduced the previous ETag and every revalidating client was told `304 Not Modified`
while holding stale bytes. 0.25.0 includes sub-second precision
(`"<size>-<secs>.<nanos>"`), making the claim true rather than aspirational. On a
filesystem with only second-granular timestamps the nanos component is `0` and the
behavior is what it was — no worse, and no confidence beyond what the filesystem gives.
## Filename decoding
Request paths are percent-decoded and interpreted as **UTF-8**, so non-ASCII filenames
(`é.png`, requested as `/%C3%A9.png`) are servable. If the decoded bytes are not valid
UTF-8, the original still-encoded string is used instead — it will not match a file, so
the request 404s. A filename that is not valid UTF-8 is therefore not servable; macOS
filesystems require UTF-8 names anyway, so this is reachable only on Linux and only for
deliberately-created names.
The segment check and the fd containment guard remain the authoritative boundary
regardless of how the name was decoded, and the hidden-segment check above runs on the
decoded path so percent-encoding cannot smuggle a dot past it.
> Through 0.28.0 this section claimed decoding produced raw bytes reassembled via
> `OsStr::from_bytes` on Unix. It never did — `decode_request_path` has always gone
> through `decode_utf8` with a fallback to the raw string. Implementing byte-level
> decoding is a small, self-contained change if a consumer ever needs to serve
> non-UTF-8 names; documenting what the code does came first.
## 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 *at equal weight* and both sidecars exist; a higher
`q` wins over that default, so `br;q=0.5, gzip` serves gzip) 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. Negotiation parses `Accept-Encoding` as whole tokens with
`q`-weights (RFC 9110): `gzip;q=0` refuses gzip rather than selecting it, and `brotli`
does not match `br` — both were live bugs in the substring match used through 0.25.0.
The `*` wildcard deliberately selects nothing: missing a compression opportunity costs
bandwidth, while guessing at a wildcard risks sending an encoding the client never
asked for.
## SPA-mode navigation
Opt-in client-side navigation for multi-page sites, unlike live-reload meant to be
usable in production, not just local development: `Server::with_spa_mode()` (swap
target `document.body`) or `Server::with_spa_root(selector)` (swap target the element
matched by CSS `selector`, for sites with persistent chrome — nav, header, footer —
outside the part that changes per page). Both are off by default; enabling either
injects one small `<script>` into every served `text/html` response, the same
splice-before-`</body>` mechanism `with_live_reload()` already uses (the two compose —
enabling both injects both scripts).
- **Same-origin link clicks are intercepted and turned into a fetch + DOM swap**
instead of a full navigation: the target URL is fetched, and — only on a successful
`text/html` response — the configured root's `innerHTML` is replaced with the
fetched document's corresponding content, `document.title` is updated, and the new
URL is pushed via `history.pushState`. A non-OK response, a non-`text/html`
response, or a fetch error all fall back to a real `location.href` navigation —
spa-mode degrades to normal navigation, it never renders a broken page.
- **Excluded from interception:** links with a `target` other than empty/`_self`, a
`download` attribute, `rel="external"`, a `data-no-spa` attribute, or a same-page
hash-only href — those always get a normal navigation. Add `data-no-spa` to any link
you want to opt out explicitly, e.g. a link to a large non-HTML file: spa-mode fetches
the target once via JS before a content-type mismatch falls back to a real
navigation, which then re-requests it — worth avoiding for anything large.
- **Animated via the View Transitions API** (`document.startViewTransition()`) when the
browser supports it, and a plain synchronous swap otherwise, so nothing breaks on a
browser without support. `Server::with_spa_transition(SpaTransition)` picks how:
- `SpaTransition::Fade` (default) — the browser's built-in cross-fade, no CSS injected.
Customize it yourself via `::view-transition-old(root)`/`::view-transition-new(root)`.
- `SpaTransition::Slide(SlideOptions)` — the outgoing page slides out one side while
the incoming page slides in from the other, the same direction for every navigation
including the browser Back button. mini-static injects the `<style>` tag this needs
(keyframes, plus a `mix-blend-mode: normal` override — without it, the browser's
default cross-fade blend mode washes the two pages into each other where they
overlap mid-slide, instead of a clean push) — no site CSS required. `SlideOptions`
(builder-style, defaults reproduce the original slide) configures:
- `duration_ms(u32)` — the animation's `animation-duration`, default `300`.
- `direction(SlideDirection)` — `Forward` (default; exits left, enters from the
right) or `Reverse` (exits right, enters from the left).
- `easing(impl Into<String>)` — the animation's `animation-timing-function`,
default `"ease"`.
Back/forward and forward-click navigations are not distinguished — both animate the
same way. A per-direction (Back slides the opposite way from Forward) transition was
tried and dropped: it needed a `history.state` position counter to tell the two
browser buttons apart (both fire the same `popstate` event) and proved fiddly and
unreliable in practice for the payoff. `SpaTransition::Slide`'s `direction` is a fixed
choice, not one that alternates by navigation direction.
- **`window.dispatchEvent(new CustomEvent("mini-static:navigate", {detail:{url}}))`
fires after every client-side navigation** (not the initial page load). Listen for
it to re-run any per-page initialization:
```js
window.addEventListener("mini-static:navigate", (e) => {
});
```
This exists because content swapped in via `innerHTML` never executes `<script>`
tags it contains — a page whose behavior depends on an inline `<script>` running on
every visit needs that logic wired to this event (in addition to normal
`DOMContentLoaded`/inline execution on the first load), not just the inline tag.
- **Back/forward (`popstate`) is handled** by re-fetching and swapping to the new
`location.href`, without pushing a new history entry.
- **No JS test harness for this crate** covers the actual click/transition/popstate
behavior — it's verified manually via `cargo run --example mini-static`. Everything
else (script injection, escaping, builder wiring, composition with live-reload) is
covered by `cargo test`.
## Configurable response headers
`Server::with_response_header(name, value)` sends a fixed header on **every** response —
200, 304, 404, 405, 301, and the error paths alike. A policy header present on some
statuses and missing from others is worse than none, since the error paths are the ones
an attacker is probing:
```rust,no_run
use mini_static::Server;
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let _server = Server::new(Path::new("./public"))?
.with_response_header("Strict-Transport-Security", "max-age=63072000")?
.with_response_header("Content-Security-Policy", "default-src 'self'")?;
Ok(())
}
```
Name and value are validated when the server is built, not on the first request. Headers
the server computes per response — `Content-Length`, `Content-Type`, `Content-Encoding`,
`Content-Range`, `ETag`, `Cache-Control`, `Vary`, `Accept-Ranges`, `Allow`, `Location`,
`Connection`, `Transfer-Encoding`, `X-Content-Type-Options` — are **refused** with
`StaticError::Config` rather than accepted and silently overridden: a fixed
`Content-Length` or `ETag` is a correctness bug, not a policy choice. Use
`with_immutable_assets` for cache policy.
## HTML injection size cap
Live-reload and spa-mode both splice a `<script>` into served HTML, which is the one
code path that reads a whole file into memory instead of streaming it in bounded chunks
— and it does so *per request*, so a large HTML page turns every concurrent request for
it into another full copy in memory. Since spa-mode is a production feature, 0.26.1 caps
injection at **8 MiB**: a page over that is streamed unmodified, and the skip is logged
(`html injection skipped for /path: … exceeds …`) so a silently un-enhanced page is
diagnosable rather than mysterious. The cap is far above any hand-written page or
generator output, so it should never fire on content this feature was built for.
## Logging
**Opt-in, off by default.** `Server::with_request_logging()` writes one line per request
to stderr; `with_request_logging_to(writer)` sends them anywhere else:
```text
GET /index.html 200 512 0.421ms
```
— method, path exactly as received, status, response body bytes (`-` when the length
isn't known, as on a live-reload SSE stream), and handling time. Connection-level
failures log `connection error: …` and accept failures log `accept error: …; retrying
in …`; both were discarded entirely before 0.26.0, so a server refusing every request
looked exactly like one nobody was talking to.
The path is logged **undecoded**, on purpose: it is attacker-controlled input, and a
traversal attempt is the line an operator most needs to see verbatim rather than
normalized. Writes are serialized so concurrent responses can't interleave mid-line, and
a failing log sink is ignored rather than allowed to fail a request that was served
correctly.
`Cargo.toml` declares **no cargo features at all** — `default = []`. Earlier revisions of
this README advertised `err` and `log` features, which never existed.
## 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.
- **No TLS.** This is an embeddable crate, not a standalone server. Certificate
loading, renewal, ALPN, and cipher policy are a large surface with their own release
cadence, and adding rustls would several-fold the dependency tree of a crate that
currently has seven dependencies. It is also why HTTP/2 and HTTP/3 are absent —
browsers reach both over TLS only.
**Terminate TLS upstream** (reverse proxy, ingress, `stunnel`). Note the honest
limitation if you cannot: `run`/`run_on`/`run_ephemeral` bind their own
`TcpListener` internally, so there is no listener to wrap. Serving TLS today means
driving `Server::handle_request` from your own accept loop — which works, but gives
up everything the built-in loop provides: the connection-count ceiling, accept-error
backoff, per-request header timeout and 64 KiB header cap, request logging, and
`ServerHandle`'s graceful drain. That is a worse-secured path than the default one,
which is the wrong shape for this crate; letting a caller supply accepted streams
while keeping all of the above is planned (`PLAN-acceptor.md`) and does not require
TLS to enter this crate.
- No per-IP rate limiting. The connection ceiling
(`Server::with_max_connections`, default 1024) plus the per-request header timeout and
64 KiB header cap are the whole DoS posture; anything finer-grained belongs upstream.