mini-static 0.29.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
# 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. 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 `..`.

## 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`. `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. 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.
- 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 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 `canonicalize()` 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) => {
    // e.detail.url is the new page's URL; re-init widgets scoped to the swapped root.
  });
  ```
  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.