# mini-static
A secure static file server: streaming responses, live reload, precompressed sidecars,
external-tool CSS/JS bundling and minification, 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 = "0.17"
```
## Design
`Server::new(root)` 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 `..`.
**Source folders and an output dir:** optional *source folders* (`with_source_folder`)
hold the inputs to the build pipelines — CSS that feeds a single bundle, JS that is
minified per-file. A single *output dir* (`with_output_dir`, defaulting to the served
root) receives the processed outputs. The two are disjoint by construction: a source
folder that overlaps the output dir is rejected, and the output dir is never a watcher
trigger — pipelines react to source folders only, so a pipeline's own output can never
re-trigger its own rebuild.
## Source-folder build pipelines
The server can build its own static output from `with_source_folder(path)` trees. When
any source folder or a CSS/JS tool is configured, a startup build runs `full_build`;
with `with_live_reload()` the source folders are watched and each change re-runs the
appropriate step. Everything lives under the one output dir (`with_output_dir`, default
the served root):
- **CSS and JS are each independently opt-in**, via `with_css_tool(tool, options)` /
`with_js_tool(tool, options)`. With neither configured, source folders are watched
(for live-reload) but nothing is transformed or copied to the output dir.
- **`bundle`/`minify` are independent toggles per language** (`CssOptions`/`JsOptions`),
all four combinations valid: passthrough copy, per-file minify (no `@import`/module
resolution), bundle only (unminified, useful for debugging), or bundle + minify.
- **A source change re-runs only the affected step** — a bundle-mode edit rebuilds the
whole bundle, a per-file-mode edit rebuilds just that file — and then the reload
broadcast is emitted *after* the output is written, so the browser reloads content
that already exists.
- **Prune is opt-in and build-time only.** `with_prune_output()` deletes the CSS bundle
at startup when no CSS sources remain; it never runs during live-reload.
- **Asset folders (`with_asset_folder(path)`) are a third, simpler source kind.**
Every file under one — any extension, `index.html`, images, whatever — is mirrored
byte-identical into the output dir, preserving its path relative to the asset
folder. No CSS/JS tool involved, just a flat copy on startup and on every
live-reload change. Use this for hand-authored static files that should live
outside the served/output dir as source, the same separation the CSS/JS pipelines
already have. Rejected if it overlaps the output dir or another registered
source/asset folder, for the same feedback-loop reason `with_source_folder` is.
- **`Server::build()` runs every configured pipeline once and returns, no HTTP
server involved.** For deploy tooling that wants the output dir populated ahead
of time — e.g. a one-shot `cargo run --bin build_static` step before baking a
Docker image — mirroring a one-shot content builder's `build()` (such as
`mini_docs::Builder::build()`) rather than needing to start-and-kill a live
server just to get one build out of it.
## 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 two responses are now
byte-identical, so the distinction is not observable over the wire at all.
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.
- **Range requests** get a full `200` with the whole body — `Range` and `If-Range` are
ignored outright. This is the RFC 9110-correct answer for a server that doesn't
implement `206 Partial Content` (as opposed to an unhelpful `416`), and it is honest
about the fact that partial-content serving isn't built: nothing in the response
claims range support, so no client negotiates for one.
## 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: 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 already
distinguishes representations that a whole-second mtime cannot (two writes inside the
same second), so it is the validator to serve.
## 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 bundling and minification (external tools)
mini-static does not bundle or minify CSS/JS itself — it delegates to an external CLI
tool you configure, and does not install or manage that tool. This keeps the core crate
free of any particular bundler's dependency weight and release cadence, at the cost of
an extra install step for the embedder.
- **Opt-in, per language, via a named preset.** `Server::with_css_tool(CssTool::LightningCss,
options)` and `Server::with_js_tool(JsTool::Esbuild, options)` — `#[non_exhaustive]`
enums so more presets can be added later without breaking existing `match`es. Neither
is configured by default.
- **`bundle`/`minify` are independent `CssOptions`/`JsOptions` toggles.** All four
combinations are valid per language: passthrough copy, per-file minify (mirrored 1:1
into the output dir, no `@import`/module resolution), bundle-only (unminified,
concatenated/entry-resolved), or bundle + minify.
- **CSS bundling discovers every `.css` under the source folders** and concatenates each
file's (optionally `@import`-resolved, optionally minified) output in sorted path
order — the same shape as before, just delegated per-file to the external tool.
- **JS bundling requires an explicit entry point** (`JsOptions::bundle_entry(path,
output_name)`, validated to lie under a registered source folder at configuration
time) — unlike CSS, a JS module graph has no well-defined "concatenate everything"
meaning. Without a bundle entry, JS runs in per-file mode.
- **Installation is the embedder's responsibility.** `lightningcss` (npm package
`lightningcss-cli`) and/or `esbuild` (npm package `esbuild`) must be on `PATH`.
`Server::run_on` checks this at startup — before the listener binds — and returns
`Err(StaticError::PipelineSetup)` with an install hint if a configured tool's binary
is missing, rather than silently serving unprocessed files.
- **Bounded and fails loudly.** Every tool invocation has a 30-second timeout; a
timeout, non-zero exit, or missing-binary error is reported with the tool's own
stderr where available (see `ToolError`).
- **Per-file mode degrades to a raw copy on tool failure** (a malformed source file, or
the tool crashing on it) — logged loudly, not silently, so the server keeps serving
and the browser stays in sync rather than 404ing.
- **A failed bundle rebuild leaves the previous good bundle in place** — never a
partial or corrupt file — and is logged; the next successful rebuild replaces it.
- **`@import`/module resolution is delegated entirely to the external tool** — mini-static
no longer enforces an import-root boundary or depth/file-count ceiling itself (the
30-second timeout is the bound in its place). This is an accepted trade-off: CSS/JS
source folders are developer-authored build inputs, not request-time attacker input,
unlike the HTTP path resolver above (which remains fully guarded). `with_bundle_root`
still exists, but now purely as an extra watch target for triggering CSS rebuilds —
not an `@import` traversal boundary.
- **`*.min.css`/`*.min.js` bypass** per-file mode's minify step — already-minified files
are mirrored as-is.
- **No request-time transformation.** All processing is build-time only, via the source
pipeline above — a subprocess call has unbounded latency and doesn't belong inside
HTTP request handling. An embedder that wants CSS/JS served transformed must run the
server with the appropriate source folder(s) and tool(s) configured.
## 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.