Expand description
§mini-static
A secure static file server: range requests, live reload, directory-index redirects. No templating, no framework — files in, HTTP responses out.
Status: planned. Previously implemented and published as
small-static; being rebuilt from this spec with a security and correctness pass folded in. SeeDEV_PLAN.md.
[dependencies]
mini-static = { version = "0.1", 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), matchingmini-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. Mirrorsmini-serve’sBackoff. run()/run_ephemeral()return aServerHandlealongside 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. Callinghandle.shutdown().awaitstops 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/HEADserve files; everything else gets405withAllow: 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-Lengthon streamed responses. The file’s length is known before streaming begins (metadata.len()) and is now sent on every full-file200, 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
/dirwhen/dir/index.htmlexists issues a301to/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 theRangeheader), the RFC- correct behavior for a server that doesn’t implement multipart range responses, instead of an unhelpful416.If-Rangeis 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
Bodyimpl backed by a reusedBytesMut; each chunk is handed off viasplit_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 viaspawn_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) andIf-Modified-Since(mtime-based) headers are honored, returning304 Not Modifiedwhen 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.
§Features
err—mini_err::Errorintegration for internal error responses.log— request logging, including logged (not exposed) traversal attempts.
§Optional, not in the MVP
- Cache-control policy hook. Default is
no-cache(always revalidate); a builder method (with_cache_control(fn(&Path) -> &'static str)) lets callers opt fingerprinted assets intopublic, max-age=31536000, immutablewithout changing the zero-config default. Lands after the MVP, as its own phase. - Precompressed sidecar support. If
foo.js.gz/.brexists and the client sends a matchingAccept-Encoding, serve it withContent-Encoding+Varyinstead of the uncompressed file — no compression dependency, real bandwidth win for static sites.
§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§
- Server
- A static file server for serving files securely from a root directory.
- Server
Handle - A handle to a server started by
Server::run()orServer::run_ephemeral().
Enums§
- Static
Error - Errors that can occur during static file serving.
Functions§
- 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.
Type Aliases§
- Response
Body - The response body type used by mini-static.