Skip to main content

Crate mini_static

Crate mini_static 

Source
Expand description

§mini-static

A secure static file server: streaming responses, live reload, precompressed sidecars, CSS/JS minification and bundling, directory-index redirects. No templating, no framework — files in, HTTP responses out.

Status: published, actively developed. See DEV_PLAN.md for the roadmap.

[dependencies]
mini-static = "0.14"

§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 ...

Multiple roots (for CSS bundling): When CSS @import bundling is enabled via with_css_bundling(), the server recognizes a “served root” (the HTTP boundary) and optional “bundle roots” (import-only directories). The served root determines what’s exposed over HTTP; bundle roots are purely for @import resolution — their files are never directly fetchable. This enables build pipelines where source partials live separately from final output.

§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 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.

§CSS @import bundling with external source roots

  • Opt-in. Server::with_css_bundling() enables it (disabled by default, requires with_minify() first) — bundling adds filesystem traversal and dependency resolution costs, appropriate only when the build process benefits from inlined imports.
  • Recursively inlines @import statements. When bundling is enabled, every .css response becomes a bundle entry point: @import statements are resolved, inlined, and recursively bundled before minification. A .css file with no imports bundles to itself — identical output to plain with_minify() alone.
  • Bounded by configured roots. Import resolution is bounded by the served root and any roots registered via Server::with_bundle_root(path). This enables build pipelines where source partials live in a separate directory tree from the final served output: CSS shared libraries, design-token partials, etc. can be @imported into entry files without being copied into or exposed from the served root.
  • Bundle roots are never directly HTTP-servable. Files under a bundle root can be referenced via @import but are rejected if fetched directly via GET — only the served root is exposed over HTTP. The bundle root is purely an import resolution boundary.
  • Traversal protection preserved. Any @import that would resolve outside all configured roots (served root + bundle roots) is rejected — @import "../../etc/passwd" still fails with an import error, whether attempting to escape the served root or all bundle roots.
  • Dependencies tracked for cache invalidation. The bundler collects the full transitive set of imported files with their mtimes. When live-reload is enabled, edits to any dependency trigger cache invalidation and a live-reload broadcast, so changes to shared partials immediately propagate to every entry file that imports them.
  • Import depth and count limits. Bundling fails gracefully if @import chains exceed MAX_IMPORT_DEPTH (32) or if a bundle pulls in more than MAX_IMPORTED_FILES (512) distinct files — preventing unbounded recursion and pathological wide-but-shallow import graphs.
  • Parse errors degrade gracefully. A CSS file that fails to parse is served unminified rather than failing the request; import resolution errors (missing file, traversal attempt) also fall back to serving the raw unbundled file.

§Features

  • errmini_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.

Structs§

Broadcaster
Broadcasts file change events to multiple subscribers.
ChangeEvent
A change event broadcast when a watched file is added, modified, or removed.
Server
A static file server for serving files securely from a root directory.
ServerHandle
A handle to a server started by one of the Server::run* methods.

Enums§

ChangeType
The type of change detected in a watched file.
CssBundlerError
Errors that can occur during CSS bundling.
MinifyError
Why minify could not produce output for the given bytes.
ResponseBody
The response body type used by mini-static.
StaticError
Errors that can occur during static file serving.

Constants§

LIVE_RELOAD_PATH
The request path Server serves the live-reload SSE stream on when crate::Server::with_live_reload is enabled.

Functions§

bundle_directory_css
Bundle all CSS files from a source directory into a single output file.
minify
Minify bytes according to change_type’s file kind.
reload_event_frame
Encode a reload event as an SSE (Server-Sent Events) frame.
resolve
Resolve a request path under a root directory, canonicalizing the root first.
resolve_with_canonical_root
Resolve a request path under a pre-canonicalized root.
start_watching
Start watching a directory for file changes.