mini_static/server.rs
1use std::convert::Infallible;
2use std::fs;
3use std::io::Write;
4use std::net::SocketAddr;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant, SystemTime};
8
9use bytes::Bytes;
10use http_body_util::Full;
11use hyper::body::Incoming;
12use hyper::http::response::Builder;
13use hyper::service::service_fn;
14use hyper::server::conn::http1;
15use hyper::header::{self, HeaderName, HeaderValue};
16use hyper::{HeaderMap, Method, Request, Response, StatusCode};
17use hyper_util::rt::{TokioIo, TokioTimer};
18use tokio::fs::File;
19use tokio::io::{AsyncReadExt, AsyncSeekExt};
20use tokio::net::{TcpListener, TcpStream};
21use tokio::sync::{OwnedSemaphorePermit, Semaphore};
22use tokio::time::timeout;
23
24use crate::css::{CssOptions, CssTool};
25use crate::error::StaticError;
26use crate::handler::{FileBody, ResponseBody};
27use crate::js::{JsOptions, JsTool};
28use crate::reload::{self, SseBody};
29use crate::resolve;
30use crate::resolve::HiddenFiles;
31use crate::source::SourcePipeline;
32use crate::spa::{self, SpaTransition};
33use crate::tool;
34use crate::watcher::{start_watching, Broadcaster};
35
36const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
37const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
38
39/// Default maximum concurrent connections, overridable via `Server::with_max_connections`.
40const DEFAULT_MAX_CONNECTIONS: usize = 1024;
41
42/// A source of accepted TCP connections. Abstracted so the accept-error backoff below
43/// can be exercised against a listener that fails on demand, without needing to provoke
44/// real OS-level accept errors (e.g. EMFILE) in tests.
45trait TcpAccept {
46 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
47}
48
49impl TcpAccept for TcpListener {
50 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
51 TcpListener::accept(self).await
52 }
53}
54
55/// Accept a connection and reserve it a connection-limit permit.
56///
57/// `backoff` retries a failed `accept()` after an exponentially growing delay (reset on
58/// the next success, capped at `ACCEPT_BACKOFF_MAX`) instead of ending the accept loop,
59/// so a sustained failure — the process being out of file descriptors, say — degrades
60/// into periodic retries rather than a CPU-bound busy spin or a permanently dead server.
61///
62/// Returns `None` only if the semaphore itself has been closed (never happens in normal
63/// operation, since nothing ever calls `close()` on it — handled so a caller can still
64/// fail safely rather than panic).
65async fn accept_and_permit<L: TcpAccept>(
66 listener: &L,
67 backoff: &mut Duration,
68 semaphore: &Arc<Semaphore>,
69 log: Option<&Server>,
70) -> Option<(TcpStream, OwnedSemaphorePermit)> {
71 loop {
72 let stream = match listener.accept().await {
73 Ok((stream, _)) => {
74 *backoff = ACCEPT_BACKOFF_INITIAL;
75 stream
76 }
77 Err(error) => {
78 // Logged rather than swallowed: sustained accept failure (out of file
79 // descriptors, most often) degrades into an ever-slower retry loop that
80 // is otherwise indistinguishable from an idle server.
81 if let Some(server) = log {
82 server.log(format_args!(
83 "accept error: {error}; retrying in {backoff:?}"
84 ));
85 }
86 tokio::time::sleep(*backoff).await;
87 *backoff = (*backoff * 2).min(ACCEPT_BACKOFF_MAX);
88 continue;
89 }
90 };
91 return semaphore
92 .clone()
93 .acquire_owned()
94 .await
95 .ok()
96 .map(|permit| (stream, permit));
97 }
98}
99
100/// A predicate deciding whether a resolved file path should get an immutable cache
101/// policy; see [`Server::with_immutable_assets`].
102type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
103
104/// A static file server for serving files securely from a root directory.
105///
106/// `Server` canonicalizes the root directory once at creation time and uses the
107/// canonical form for all subsequent requests, avoiding repeated filesystem calls.
108///
109/// # Security
110///
111/// The server protects against:
112/// - Path traversal attacks (e.g., `../../etc/passwd`)
113/// - Accessing files outside the root via symlinks
114/// - Disclosing filesystem structure (traversal and missing files both return 404)
115///
116/// # Cloning
117///
118/// `Server` is cheap to clone: a `PathBuf`, a couple of primitives, and an `Arc`'d
119/// predicate closure. Multiple clones can be used concurrently in async tasks without
120/// synchronization overhead.
121///
122/// # Example
123///
124/// ```no_run
125/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
126/// use mini_static::Server;
127/// use std::path::Path;
128/// use std::time::Duration;
129///
130/// let server = Server::new(Path::new("./public"))?;
131/// let (port, _handle) = server.run(Duration::from_secs(30)).await?;
132/// println!("Server running on port {}", port);
133/// # Ok(())
134/// # }
135/// ```
136/// Headers this server derives from the response it is building, and therefore refuses
137/// as fixed values via [`Server::with_response_header`]. A fixed value would be either
138/// silently overridden or silently duplicated depending on the response — and a wrong
139/// `Content-Length` or `ETag` is a correctness bug, not a policy choice.
140const SERVER_COMPUTED_HEADERS: [HeaderName; 13] = [
141 header::CONTENT_LENGTH,
142 header::CONTENT_TYPE,
143 header::CONTENT_ENCODING,
144 header::CONTENT_RANGE,
145 header::ETAG,
146 header::CACHE_CONTROL,
147 header::VARY,
148 header::ACCEPT_RANGES,
149 header::ALLOW,
150 header::LOCATION,
151 header::CONNECTION,
152 header::TRANSFER_ENCODING,
153 header::X_CONTENT_TYPE_OPTIONS,
154];
155
156/// Where request and connection log lines go.
157///
158/// A `Server` is cloned per request, so the sink is shared rather than duplicated. The
159/// mutex serializes writes from concurrent connections — without it, two responses
160/// finishing at once would interleave mid-line and produce log entries belonging to
161/// neither request.
162type RequestLog = Arc<Mutex<Box<dyn Write + Send>>>;
163
164#[derive(Clone)]
165pub struct Server {
166 root_canon: PathBuf,
167 bundle_roots: Vec<PathBuf>,
168 max_connections: usize,
169 live_reload: bool,
170 broadcaster: Option<Broadcaster>,
171 spa_mode: bool,
172 spa_root: Option<String>,
173 spa_transition: SpaTransition,
174 not_found_page: Option<PathBuf>,
175 hidden_files: HiddenFiles,
176 request_log: Option<RequestLog>,
177 extra_headers: Arc<Vec<(HeaderName, HeaderValue)>>,
178 immutable_predicate: Option<ImmutablePredicate>,
179 source_folders: Vec<PathBuf>,
180 asset_folders: Vec<PathBuf>,
181 output_dir: PathBuf,
182 css_tool: Option<(CssTool, CssOptions)>,
183 js_tool: Option<(JsTool, JsOptions)>,
184 prune_output: bool,
185}
186
187/// True when two canonical paths are the same path or one contains the other.
188///
189/// Used by the source/output overlap check: `Path::starts_with` compares component-wise, so
190/// `/a/b` neither equals nor contains their sibling `/a/b/c` itself. This is the single
191/// place the overlap rule is defined, so both `with_source_folder` and `with_output_dir`
192/// cannot drift apart.
193fn paths_overlap(a: &Path, b: &Path) -> bool {
194 a.starts_with(b) || b.starts_with(a)
195}
196
197impl Server {
198 /// Create a new server with the given root directory.
199 ///
200 /// Canonicalizes the root once at startup. All subsequent requests use the
201 /// canonical root without re-canonicalizing it, making this suitable for long-lived servers.
202 ///
203 /// # Errors
204 ///
205 /// Returns `Err(StaticError::Io)` if the root cannot be canonicalized (e.g., doesn't exist,
206 /// no read permissions).
207 pub fn new(root: &Path) -> Result<Self, StaticError> {
208 let root_canon = root.canonicalize().map_err(StaticError::Io)?;
209 let output_dir = root_canon.clone();
210 Ok(Server {
211 root_canon,
212 bundle_roots: Vec::new(),
213 max_connections: DEFAULT_MAX_CONNECTIONS,
214 live_reload: false,
215 broadcaster: None,
216 spa_mode: false,
217 spa_root: None,
218 spa_transition: SpaTransition::default(),
219 not_found_page: None,
220 hidden_files: HiddenFiles::Deny,
221 request_log: None,
222 extra_headers: Arc::new(Vec::new()),
223 immutable_predicate: None,
224 source_folders: Vec::new(),
225 asset_folders: Vec::new(),
226 output_dir,
227 css_tool: None,
228 js_tool: None,
229 prune_output: false,
230 })
231 }
232
233 /// Set the maximum number of connections served concurrently (default 1024).
234 ///
235 /// Once this many connections are in flight, `run()`'s accept loop stops accepting
236 /// new ones — without pausing the accept loop, a client that opens a connection and
237 /// sends nothing (see the header-read timeout docs on [`Server::run_on`]) could
238 /// otherwise be used, in enough parallel copies, to exhaust the process's file
239 /// descriptors or memory with no bound at all.
240 pub fn with_max_connections(mut self, max: usize) -> Self {
241 self.max_connections = max;
242 self
243 }
244
245 /// Enable live-reload for this server (disabled by default).
246 ///
247 /// Once enabled, the `run*` methods start a background watcher (mtime polling,
248 /// bounded 500ms interval — see [`crate::start_watching`]) the first time the server
249 /// actually starts accepting connections. It watches the served root; when a build
250 /// pipeline is configured it watches that pipeline's source folders instead, because
251 /// the pipeline broadcasts its own outputs once they are written. Then it will:
252 ///
253 /// - serve a live-reload SSE stream at [`crate::LIVE_RELOAD_PATH`], broadcasting a
254 /// change event (with [`crate::ChangeType`]) whenever a served file is added,
255 /// modified, or removed;
256 /// - inject a small `<script>` into every served `text/html` response that connects
257 /// to that stream and reloads the page (or hot-swaps stylesheet `<link>`s, for CSS
258 /// changes) — no manual client wiring required.
259 ///
260 /// This is meant for local development, not production: leave it disabled (the
261 /// default) for any server serving real traffic. A typical call site gates it behind
262 /// `#[cfg(debug_assertions)]` so a release build never pays for the watcher or the
263 /// injected script.
264 ///
265 /// # Example
266 ///
267 /// ```no_run
268 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
269 /// use mini_static::Server;
270 /// use std::path::Path;
271 ///
272 /// let server = Server::new(Path::new("./public"))?;
273 /// #[cfg(debug_assertions)]
274 /// let server = server.with_live_reload();
275 /// # Ok(())
276 /// # }
277 /// ```
278 pub fn with_live_reload(mut self) -> Self {
279 self.live_reload = true;
280 self
281 }
282
283 /// Enable spa-mode navigation for this server, swapping `document.body` on
284 /// each navigation (disabled by default).
285 ///
286 /// Once enabled, every served `text/html` response gets a small `<script>`
287 /// injected (see [`Server::with_spa_root`] for what it does) that treats
288 /// `document.body` as the swap target. Calling this after
289 /// [`Server::with_spa_root`] does not clear a previously configured root
290 /// selector — the two methods set independent fields, so
291 /// `.with_spa_root(sel).with_spa_mode()` and
292 /// `.with_spa_mode().with_spa_root(sel)` both end up with spa-mode on and
293 /// root `sel`. Use this one alone when there's no persistent chrome to
294 /// preserve across navigations.
295 ///
296 /// # Example
297 ///
298 /// ```no_run
299 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
300 /// use mini_static::Server;
301 /// use std::path::Path;
302 ///
303 /// let server = Server::new(Path::new("./public"))?.with_spa_mode();
304 /// # Ok(())
305 /// # }
306 /// ```
307 pub fn with_spa_mode(mut self) -> Self {
308 self.spa_mode = true;
309 self
310 }
311
312 /// Enable spa-mode navigation for this server, swapping only the element
313 /// matched by the CSS `selector` on each navigation (disabled by default;
314 /// also enables spa-mode the same as [`Server::with_spa_mode`]).
315 ///
316 /// Once enabled, every served `text/html` response gets a small `<script>`
317 /// injected that intercepts left-clicks on same-origin `<a href>`
318 /// elements (skipping links with a non-`_self` `target`, a `download`
319 /// attribute, `rel="external"`, a `data-no-spa` attribute, or a same-page
320 /// hash-only href) and, instead of a normal navigation:
321 ///
322 /// - fetches the target URL;
323 /// - on a non-OK or non-`text/html` response (or a fetch error), falls
324 /// back to a real `location.href` navigation — spa-mode never renders a
325 /// broken page;
326 /// - otherwise replaces the matched element's `innerHTML` with the
327 /// corresponding content from the fetched document, updates the page
328 /// title, and pushes the new URL via `history.pushState`, animating the
329 /// swap with `document.startViewTransition()` where supported;
330 /// - dispatches a `mini-static:navigate` `CustomEvent` on `window` after
331 /// every client-side navigation, so page scripts can re-run any
332 /// per-page initialization that would otherwise only execute once
333 /// (content swapped in via `innerHTML` never executes its own
334 /// `<script>` tags);
335 /// - handles browser back/forward by re-fetching and swapping to the new
336 /// `location.href`.
337 ///
338 /// `selector` is matched against both the current page and the fetched
339 /// page; a link click where the selector matches neither falls back to a
340 /// real navigation, same as a fetch failure. Choose a `selector` that
341 /// wraps only the content that varies between pages, leaving persistent
342 /// chrome (nav/header/footer) outside it so it survives navigation
343 /// untouched.
344 ///
345 /// This is meant to be usable in production, not just local development
346 /// (unlike [`Server::with_live_reload`]): a click on a link mini-static
347 /// doesn't intercept, or on a browser without JS or View Transitions
348 /// support, still works as a normal navigation.
349 ///
350 /// # Example
351 ///
352 /// ```no_run
353 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
354 /// use mini_static::Server;
355 /// use std::path::Path;
356 ///
357 /// let server = Server::new(Path::new("./public"))?.with_spa_root("#app");
358 /// # Ok(())
359 /// # }
360 /// ```
361 pub fn with_spa_root(mut self, selector: &str) -> Self {
362 self.spa_mode = true;
363 self.spa_root = Some(selector.to_string());
364 self
365 }
366
367 /// Set how spa-mode animates the swap between pages (also enables
368 /// spa-mode the same as [`Server::with_spa_mode`]; default
369 /// [`SpaTransition::Fade`] when spa-mode is enabled without calling this).
370 ///
371 /// [`SpaTransition::Slide`] injects its own `<style>` tag alongside the
372 /// spa-mode `<script>` — no site CSS is required. See [`SpaTransition`]
373 /// and [`SlideOptions`] for what each variant does and how to
374 /// configure the slide's duration, direction, and easing.
375 ///
376 /// # Example
377 ///
378 /// ```no_run
379 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
380 /// use mini_static::{Server, SlideOptions, SpaTransition};
381 /// use std::path::Path;
382 ///
383 /// let server = Server::new(Path::new("./public"))?
384 /// .with_spa_root("#app")
385 /// .with_spa_transition(SpaTransition::Slide(
386 /// SlideOptions::default().duration_ms(500),
387 /// ));
388 /// # Ok(())
389 /// # }
390 /// ```
391 pub fn with_spa_transition(mut self, transition: SpaTransition) -> Self {
392 self.spa_mode = true;
393 self.spa_transition = transition;
394 self
395 }
396
397 /// Serves `path` as the body of every `404`, instead of the default plain-text
398 /// `not found`.
399 ///
400 /// `path` is resolved relative to the served root and must exist when this is
401 /// called: a missing 404 page is a deployment mistake, and finding out on the first
402 /// broken link — the one moment the page exists to handle — is too late. It is read
403 /// from disk per response rather than cached, so editing it during a live-reload
404 /// session takes effect without a restart.
405 ///
406 /// The response keeps its `404` status. Serving a custom page with `200` is a soft
407 /// 404: search engines index it, and monitoring stops seeing the failures. It also
408 /// carries `Cache-Control: no-store`, so a client never holds this page as though it
409 /// were the resource that was actually requested.
410 ///
411 /// Nothing about the failed request reaches the page — no path, no reason. A
412 /// traversal attempt and an ordinary miss are deliberately indistinguishable
413 /// (`StaticError::user_message`), and templating the requested path into the
414 /// response would undo that and hand back a reflected-content vector besides.
415 ///
416 /// # Errors
417 ///
418 /// Returns `Err(StaticError::Io)` if `path` cannot be canonicalized (typically:
419 /// it does not exist), or `Err(StaticError::Traversal)` if it lies outside the
420 /// served root.
421 ///
422 /// # Example
423 ///
424 /// ```no_run
425 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
426 /// use mini_static::Server;
427 /// use std::path::Path;
428 ///
429 /// let server = Server::new(Path::new("./public"))?
430 /// .with_not_found_page(Path::new("404.html"))?;
431 /// # Ok(())
432 /// # }
433 /// ```
434 pub fn with_not_found_page(mut self, path: &Path) -> Result<Self, StaticError> {
435 let joined = self.root_canon.join(path);
436 let canon = joined.canonicalize().map_err(StaticError::Io)?;
437
438 if !canon.starts_with(&self.root_canon) {
439 return Err(StaticError::Traversal(format!(
440 "404 page {} lies outside the served root {}",
441 canon.display(),
442 self.root_canon.display()
443 )));
444 }
445
446 self.not_found_page = Some(canon);
447 Ok(self)
448 }
449
450 /// Serve dot-prefixed paths (`.env`, `.git/config`) instead of answering them as a
451 /// miss.
452 ///
453 /// Hidden files are denied by default. A served root is routinely a build output
454 /// directory, a repository working copy, or a folder someone dropped a `.env` into,
455 /// and the traversal guard cannot help: those files are legitimately *inside* the
456 /// root, so anyone who guesses the name gets them. The default trades a rarely-wanted
457 /// capability for not leaking credentials by accident.
458 ///
459 /// `/.well-known/` is served either way — it is where the web puts resources that
460 /// are meant to be fetched (ACME challenges for certificate issuance,
461 /// `security.txt`), and denying it would break certificate renewal. The exception is
462 /// the first segment only: `/.well-known/.hidden` is still denied.
463 ///
464 /// Call this when the served root is a curated directory whose dotfiles are content
465 /// — a static site that publishes a `.htaccess` for a downstream server, say.
466 ///
467 /// # Example
468 ///
469 /// ```no_run
470 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
471 /// use mini_static::Server;
472 /// use std::path::Path;
473 ///
474 /// let server = Server::new(Path::new("./public"))?.with_hidden_files();
475 /// # Ok(())
476 /// # }
477 /// ```
478 pub fn with_hidden_files(mut self) -> Self {
479 self.hidden_files = HiddenFiles::Serve;
480 self
481 }
482
483 /// Log one line per request to stderr, plus connection-level errors.
484 ///
485 /// Off by default: a library that writes to a process's stderr uninvited is a
486 /// surprise, and an embedder with its own logging wants the lines somewhere else.
487 /// See [`Server::with_request_logging_to`] to choose the destination.
488 ///
489 /// # Example
490 ///
491 /// ```no_run
492 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
493 /// use mini_static::Server;
494 /// use std::path::Path;
495 ///
496 /// let server = Server::new(Path::new("./public"))?.with_request_logging();
497 /// # Ok(())
498 /// # }
499 /// ```
500 /// Send `name: value` on every response.
501 ///
502 /// Call repeatedly to add several. Intended for the policy headers a static site
503 /// wants applied uniformly — `Strict-Transport-Security`, `Content-Security-Policy`,
504 /// `Referrer-Policy` — which this crate has no business choosing on an embedder's
505 /// behalf but every business making expressible.
506 ///
507 /// Both name and value are validated here, at configuration time, so a malformed
508 /// header fails when the server is built rather than on a request months later.
509 ///
510 /// # Errors
511 ///
512 /// - `StaticError::Config` if `name` or `value` is not a valid HTTP header.
513 /// - `StaticError::Config` if `name` is one this server computes per response
514 /// (`Content-Length`, `Content-Type`, `Content-Encoding`, `Content-Range`, `ETag`,
515 /// `Cache-Control`, `Vary`, `Accept-Ranges`, `Allow`, `Location`, `Connection`,
516 /// `Transfer-Encoding`, `X-Content-Type-Options`). A fixed value would either be
517 /// silently overridden or silently duplicated depending on the response — a
518 /// configuration mistake worth surfacing at startup rather than a behavior worth
519 /// supporting. Use [`Server::with_immutable_assets`] for cache policy.
520 ///
521 /// # Example
522 ///
523 /// ```no_run
524 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
525 /// use mini_static::Server;
526 /// use std::path::Path;
527 ///
528 /// let server = Server::new(Path::new("./public"))?
529 /// .with_response_header("Strict-Transport-Security", "max-age=63072000")?
530 /// .with_response_header("Referrer-Policy", "strict-origin-when-cross-origin")?;
531 /// # Ok(())
532 /// # }
533 /// ```
534 pub fn with_response_header(mut self, name: &str, value: &str) -> Result<Self, StaticError> {
535 let name = HeaderName::from_bytes(name.as_bytes())
536 .map_err(|_| StaticError::Config(format!("invalid header name: {name}")))?;
537 let value = HeaderValue::from_str(value)
538 .map_err(|_| StaticError::Config(format!("invalid value for header {name}: {value}")))?;
539
540 if SERVER_COMPUTED_HEADERS.contains(&name) {
541 return Err(StaticError::Config(format!(
542 "{name} is computed per response and cannot be set as a fixed header"
543 )));
544 }
545
546 Arc::make_mut(&mut self.extra_headers).push((name, value));
547 Ok(self)
548 }
549
550 pub fn with_request_logging(self) -> Self {
551 self.with_request_logging_to(Box::new(std::io::stderr()))
552 }
553
554 /// Log one line per request to `writer`, plus connection-level errors.
555 ///
556 /// Each served request writes one line:
557 ///
558 /// ```text
559 /// GET /index.html 200 512 0.421ms
560 /// ```
561 ///
562 /// — method, requested path exactly as received, status, response body bytes (`-`
563 /// when the length isn't known, as on a live-reload SSE stream), and how long
564 /// handling took. Connection-level failures — a malformed request, a client
565 /// vanishing mid-response — write `connection error: <cause>`; before this they were
566 /// discarded entirely, so a server that was refusing every request looked exactly
567 /// like one nobody was talking to.
568 ///
569 /// The path is logged as received, *not* decoded: it is attacker-controlled input,
570 /// and a log reader deserves to see the bytes that actually arrived rather than a
571 /// normalized rendering of them.
572 ///
573 /// Writes are serialized across connections and write errors are ignored — a
574 /// failing log sink must not take down request serving.
575 ///
576 /// # Example
577 ///
578 /// ```no_run
579 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
580 /// use mini_static::Server;
581 /// use std::fs::File;
582 /// use std::path::Path;
583 ///
584 /// let log = File::create("access.log")?;
585 /// let server = Server::new(Path::new("./public"))?.with_request_logging_to(Box::new(log));
586 /// # Ok(())
587 /// # }
588 /// ```
589 pub fn with_request_logging_to(mut self, writer: Box<dyn Write + Send>) -> Self {
590 self.request_log = Some(Arc::new(Mutex::new(writer)));
591 self
592 }
593
594 /// Start a response carrying the baseline security header plus every header the
595 /// embedder configured via [`Server::with_response_header`].
596 ///
597 /// Every response this server builds for a request goes through here, so a
598 /// configured policy header cannot be missing from one status and present on
599 /// another. The sole exception is the `400` that `finish` falls back to when a
600 /// builder produced an invalid header — no `Server` is in scope there, and a
601 /// response that exists only because header construction already failed is the wrong
602 /// place to add more headers.
603 fn response(&self, status: StatusCode) -> Builder {
604 let mut builder = response(status);
605 for (name, value) in self.extra_headers.iter() {
606 builder = builder.header(name, value);
607 }
608 builder
609 }
610
611 /// Write `line` to the configured log sink, if there is one.
612 ///
613 /// A poisoned mutex (some earlier writer panicked mid-write) and a failed write are
614 /// both ignored: neither is a reason to fail a request that was otherwise served
615 /// correctly.
616 fn log(&self, line: std::fmt::Arguments<'_>) {
617 let Some(log) = &self.request_log else {
618 return;
619 };
620 if let Ok(mut sink) = log.lock() {
621 let _ = writeln!(sink, "{line}");
622 let _ = sink.flush();
623 }
624 }
625
626 /// Serve files matching `predicate` with a long-lived, immutable cache policy
627 /// instead of the default `Cache-Control: no-cache`.
628 ///
629 /// `predicate` is evaluated against each resolved file's path; a match sends
630 /// `Cache-Control: public, max-age=31536000, immutable` on that file's 200 and 304
631 /// responses. This is correct only for fingerprinted assets (e.g.
632 /// `main.a1b2c3.js`) where a content change always produces a new filename —
633 /// caching a mutable filename indefinitely would serve stale content to every
634 /// client that already has it cached.
635 ///
636 /// # Example
637 ///
638 /// ```no_run
639 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
640 /// use mini_static::Server;
641 /// use std::path::Path;
642 ///
643 /// let server = Server::new(Path::new("./public"))?
644 /// .with_immutable_assets(|path| {
645 /// path.file_name()
646 /// .and_then(|name| name.to_str())
647 /// .is_some_and(|name| name.contains(".fingerprint."))
648 /// });
649 /// # Ok(())
650 /// # }
651 /// ```
652 pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
653 where
654 F: Fn(&Path) -> bool + Send + Sync + 'static,
655 {
656 self.immutable_predicate = Some(Arc::new(predicate));
657 self
658 }
659
660 /// The `Cache-Control` header value for a resolved file path: the immutable policy
661 /// if `with_immutable_assets`'s predicate matches, `no-cache` otherwise.
662 fn cache_control_for(&self, path: &Path) -> &'static str {
663 match &self.immutable_predicate {
664 Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
665 _ => "no-cache",
666 }
667 }
668
669 /// Register `path` as an additional directory whose changes should trigger a CSS
670 /// bundle rebuild, alongside the registered source folders.
671 ///
672 /// Useful for build pipelines where CSS partials referenced via `@import` live in a
673 /// separate directory tree from the source folders proper: without registering that
674 /// tree here, editing a partial wouldn't be noticed by the watcher and the bundle
675 /// would go stale until something else touched it.
676 ///
677 /// Files under `path` are never directly HTTP-servable: `Server::resolve` and the
678 /// request-handling path never consult bundle roots. This is purely a watch target,
679 /// not a second served root, and — since `@import` resolution is delegated entirely
680 /// to the configured [`CssTool`] (see [`Server::with_css_tool`]) — not an `@import`
681 /// traversal boundary either; the external tool resolves its own imports with no
682 /// root mini-static can enforce.
683 ///
684 /// This method is fallible and canonicalizes the path once at call time, matching
685 /// `Server::new`'s canonicalize-once policy. Call it multiple times to register
686 /// more than one external source tree.
687 ///
688 /// # Errors
689 ///
690 /// Returns `Err(StaticError::Io)` if the path cannot be canonicalized.
691 pub fn with_bundle_root(mut self, path: &Path) -> Result<Self, StaticError> {
692 let canon = path.canonicalize().map_err(StaticError::Io)?;
693 self.bundle_roots.push(canon);
694 Ok(self)
695 }
696
697 /// Designate `dir` as a source folder whose changes drive the build pipelines.
698 ///
699 /// Watched when `with_live_reload()` is enabled; `.css` files under it feed the single
700 /// CSS bundle, `.js`/`.mjs` files are minified per-file into the output dir.
701 ///
702 /// Rejected if `dir` overlaps the output dir or an already-registered source folder: a
703 /// source folder that is also the output would feed every pipeline its own output — the
704 /// feedback loop this layering exists to prevent.
705 ///
706 /// # Errors
707 ///
708 /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
709 /// `Err(StaticError::Traversal)` if it overlaps the output dir or another source folder.
710 pub fn with_source_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
711 let canon = dir.canonicalize().map_err(StaticError::Io)?;
712
713 if paths_overlap(&canon, &self.output_dir) {
714 return Err(StaticError::Traversal(format!(
715 "source folder {} overlaps the output dir {}",
716 canon.display(),
717 self.output_dir.display()
718 )));
719 }
720 if self
721 .source_folders
722 .iter()
723 .chain(self.asset_folders.iter())
724 .any(|existing| paths_overlap(&canon, existing))
725 {
726 return Err(StaticError::Traversal(format!(
727 "source folder {} overlaps an already-registered source/asset folder",
728 canon.display()
729 )));
730 }
731
732 self.source_folders.push(canon);
733 Ok(self)
734 }
735
736 /// Designate `dir` as an asset source folder: every file under it (any extension)
737 /// is mirrored byte-identical into the output dir at server startup and on every
738 /// live-reload change — no CSS/JS transformation, just a flat copy preserving each
739 /// file's path relative to `dir`. Use this for hand-authored static files
740 /// (`index.html`, images) that should live outside the served/output dir as
741 /// source, the same source/output separation `with_source_folder`'s CSS/JS
742 /// pipelines already have.
743 ///
744 /// Rejected if `dir` overlaps the output dir or an already-registered
745 /// source/asset folder, for the same reason `with_source_folder` rejects it: a
746 /// folder that is also the output would feed the pipeline its own output.
747 ///
748 /// # Errors
749 ///
750 /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
751 /// `Err(StaticError::Traversal)` if it overlaps the output dir or another
752 /// registered source/asset folder.
753 pub fn with_asset_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
754 let canon = dir.canonicalize().map_err(StaticError::Io)?;
755
756 if paths_overlap(&canon, &self.output_dir) {
757 return Err(StaticError::Traversal(format!(
758 "asset folder {} overlaps the output dir {}",
759 canon.display(),
760 self.output_dir.display()
761 )));
762 }
763 if self
764 .source_folders
765 .iter()
766 .chain(self.asset_folders.iter())
767 .any(|existing| paths_overlap(&canon, existing))
768 {
769 return Err(StaticError::Traversal(format!(
770 "asset folder {} overlaps an already-registered source/asset folder",
771 canon.display()
772 )));
773 }
774
775 self.asset_folders.push(canon);
776 Ok(self)
777 }
778
779 /// Designate `dir` as the output directory processed outputs are written to.
780 ///
781 /// Defaults to the served root. The output dir is never a watcher trigger: pipelines
782 /// react to source folders only, so a pipeline's own output can never re-trigger it.
783 /// Call this before `with_css_tool` so a bundle output path reflects the override.
784 ///
785 /// # Errors
786 ///
787 /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
788 /// `Err(StaticError::Traversal)` if it overlaps a registered source or asset folder.
789 pub fn with_output_dir(mut self, dir: &Path) -> Result<Self, StaticError> {
790 let canon = dir.canonicalize().map_err(StaticError::Io)?;
791
792 if self
793 .source_folders
794 .iter()
795 .chain(self.asset_folders.iter())
796 .any(|existing| paths_overlap(&canon, existing))
797 {
798 return Err(StaticError::Traversal(format!(
799 "output dir {} overlaps a registered source/asset folder",
800 canon.display()
801 )));
802 }
803
804 self.output_dir = canon;
805 Ok(self)
806 }
807
808 /// Configure CSS bundling/minification via an external tool (disabled by default).
809 ///
810 /// `tool` is a preset naming the CLI mini-static invokes (see [`CssTool`]) —
811 /// mini-static does not install or manage the binary, only looks it up on `PATH`;
812 /// [`Server::run_on`] fails fast at startup if it's missing. `options` selects
813 /// `bundle`/`minify` independently (see [`CssOptions`]):
814 ///
815 /// - Neither: every `.css` under the source folders is copied through unchanged,
816 /// mirrored into the output dir.
817 /// - `minify` only: each file is minified independently and mirrored (no `@import`
818 /// following).
819 /// - `bundle` only: every `.css` under the source folders is discovered,
820 /// `@import`-resolved, and concatenated into one output file, unminified.
821 /// - Both: the bundle above, minified.
822 ///
823 /// # Example
824 ///
825 /// ```no_run
826 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
827 /// use mini_static::{CssOptions, CssTool, Server};
828 /// use std::path::Path;
829 ///
830 /// let server = Server::new(Path::new("./public"))?
831 /// .with_css_tool(CssTool::LightningCss, CssOptions::new().bundle(true).minify(true));
832 /// # Ok(())
833 /// # }
834 /// ```
835 pub fn with_css_tool(mut self, tool: CssTool, options: CssOptions) -> Self {
836 self.css_tool = Some((tool, options));
837 self
838 }
839
840 /// Configure JS bundling/minification via an external tool (disabled by default).
841 ///
842 /// `tool` is a preset naming the CLI mini-static invokes (see [`JsTool`]) —
843 /// mini-static does not install or manage the binary, only looks it up on `PATH`;
844 /// [`Server::run_on`] fails fast at startup if it's missing. Unlike CSS, JS bundling
845 /// requires an explicit entry point ([`JsOptions::bundle_entry`]) since a JS module
846 /// graph has no well-defined "concatenate everything" meaning; without it, `options`
847 /// runs in per-file mode (every `.js`/`.mjs` under the source folders processed and
848 /// mirrored independently).
849 ///
850 /// # Errors
851 ///
852 /// Returns `Err(StaticError::Io)` if `options` specifies a bundle entry that cannot
853 /// be canonicalized, or `Err(StaticError::Traversal)` if it doesn't lie under a
854 /// registered source folder — checked eagerly here so a bad entry path fails at
855 /// configuration time, not on the first rebuild.
856 ///
857 /// # Example
858 ///
859 /// ```no_run
860 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
861 /// use mini_static::{JsOptions, JsTool, Server};
862 /// use std::path::Path;
863 ///
864 /// let server = Server::new(Path::new("./public"))?
865 /// .with_source_folder(Path::new("./js-src"))?
866 /// .with_js_tool(
867 /// JsTool::Esbuild,
868 /// JsOptions::new()
869 /// .bundle_entry(Path::new("./js-src/main.js"), "bundle.js")
870 /// .minify(true),
871 /// )?;
872 /// # Ok(())
873 /// # }
874 /// ```
875 pub fn with_js_tool(mut self, tool: JsTool, options: JsOptions) -> Result<Self, StaticError> {
876 if let Some(entry) = options.entry() {
877 let entry_canon = entry.canonicalize().map_err(StaticError::Io)?;
878 let under_source_folder = self
879 .source_folders
880 .iter()
881 .any(|folder| entry_canon.starts_with(folder));
882 if !under_source_folder {
883 return Err(StaticError::Traversal(format!(
884 "js bundle entry {} is not under any registered source folder",
885 entry_canon.display()
886 )));
887 }
888 }
889
890 self.js_tool = Some((tool, options));
891 Ok(self)
892 }
893
894 /// Remove stale CSS bundle output at build time — specifically, delete the bundle file
895 /// when no CSS sources remain, rather than serving an orphan. Applies only to the
896 /// one-shot startup build, never during live-reload.
897 pub fn with_prune_output(mut self) -> Self {
898 self.prune_output = true;
899 self
900 }
901
902 /// True when any build pipeline is configured (a CSS/JS tool and/or a source
903 /// folder), i.e. the server should run a startup build.
904 fn has_pipeline(&self) -> bool {
905 self.css_tool.is_some()
906 || self.js_tool.is_some()
907 || !self.source_folders.is_empty()
908 || !self.asset_folders.is_empty()
909 }
910
911 /// Every external tool binary this configuration actually needs at some point
912 /// (bundle and/or minify enabled — a pure passthrough config never spawns its
913 /// configured tool, so it has nothing to fail-fast on), paired with its
914 /// human-readable install hint for a fail-fast startup error.
915 fn required_tool_binaries(&self) -> Vec<(&'static str, &'static str)> {
916 let mut required = Vec::new();
917 if let Some((css_tool, options)) = &self.css_tool {
918 if options.is_bundle() || options.is_minify() {
919 required.push((css_tool.binary_name(), css_tool.install_hint()));
920 }
921 }
922 if let Some((js_tool, options)) = &self.js_tool {
923 if options.is_bundle() || options.is_minify() {
924 required.push((js_tool.binary_name(), js_tool.install_hint()));
925 }
926 }
927 required
928 }
929
930 /// Every directory to watch for source changes: the source folders, the CSS
931 /// `@import` roots, and the asset folders, deduplicated so a directory registered
932 /// under more than one role is watched once.
933 fn watch_targets(&self) -> Vec<PathBuf> {
934 // With no build pipeline configured, the served root *is* the content, so watch
935 // it directly — the behavior [`Server::with_live_reload`] documents and the
936 // behavior this crate had until 0.15.0, when the source/output split replaced a
937 // direct `start_watching(root_canon)` with the input-folder list below and
938 // silently left a plain static site watching nothing at all.
939 if !self.has_pipeline() {
940 return vec![self.root_canon.clone()];
941 }
942
943 // With a pipeline, watch its *inputs*. Outputs land in the output dir — which
944 // defaults to the served root — and are broadcast by the pipeline itself once the
945 // write has completed (`SourcePipeline`'s echo suppression, `source.rs`). Watching
946 // the output here instead would feed each pipeline its own writes back into its
947 // trigger, which is the loop that separation exists to prevent.
948 let mut targets = Vec::new();
949 for dir in self
950 .source_folders
951 .iter()
952 .chain(self.bundle_roots.iter())
953 .chain(self.asset_folders.iter())
954 {
955 if !targets.contains(dir) {
956 targets.push(dir.clone());
957 }
958 }
959 targets
960 }
961
962 /// Run every configured build pipeline (CSS/JS tools, asset folders) once and
963 /// return, without starting the HTTP server. A one-shot equivalent of the
964 /// startup build `run*` does automatically — for deploy tooling that wants to
965 /// populate the output dir ahead of time (e.g. a `cargo run --bin build_static`
966 /// step before baking a Docker image), mirroring a one-shot content
967 /// builder's `build()` (e.g. `mini_docs::Builder::build()`).
968 ///
969 /// # Errors
970 ///
971 /// - `Err(StaticError::PipelineSetup)` if a configured tool's binary that's
972 /// actually needed (bundle or minify enabled) is missing from `PATH` — checked
973 /// before anything runs, same as [`Server::run_on`].
974 /// - `Err(StaticError::Build)` if a configured pipeline step fails (a tool
975 /// invocation error, a filesystem error writing output, etc.).
976 ///
977 /// # Example
978 ///
979 /// ```no_run
980 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
981 /// use mini_static::Server;
982 /// use std::path::Path;
983 ///
984 /// let server = Server::new(Path::new("./public"))?;
985 /// server.build().await?;
986 /// # Ok(())
987 /// # }
988 /// ```
989 pub async fn build(&self) -> Result<(), StaticError> {
990 for (binary, install_hint) in self.required_tool_binaries() {
991 if !tool::locate_on_path(binary) {
992 return Err(StaticError::PipelineSetup(format!(
993 "{binary} not found on PATH ({install_hint})"
994 )));
995 }
996 }
997
998 let pipeline = SourcePipeline::new(
999 self.source_folders.clone(),
1000 self.bundle_roots.clone(),
1001 self.asset_folders.clone(),
1002 self.output_dir.clone(),
1003 self.css_tool.clone(),
1004 self.js_tool.clone(),
1005 self.prune_output,
1006 Broadcaster::new(),
1007 );
1008 pipeline
1009 .full_build()
1010 .await
1011 .map_err(|e| StaticError::Build(e.to_string()))
1012 }
1013
1014 /// Resolve a request path under the server's root.
1015 ///
1016 /// This is a lower-level API for resolving paths without generating HTTP responses.
1017 /// For most use cases, prefer [`Server::handle_request`] or the `run*` methods.
1018 ///
1019 /// # Returns
1020 ///
1021 /// - `Ok(PathBuf)` if the path resolves to a file within root.
1022 /// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
1023 pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
1024 resolve::resolve_with_policy(&self.root_canon, request_path, self.hidden_files)
1025 }
1026
1027 /// Builds the `404` response: the configured page when there is one and it can be
1028 /// read, and `fallback` as plain text otherwise.
1029 ///
1030 /// `fallback` is the caller's already-sanitized message (see
1031 /// `StaticError::user_message`) — never the requested path, so an ordinary miss and
1032 /// a rejected traversal stay indistinguishable to whoever is probing.
1033 ///
1034 /// A page that vanished after `with_not_found_page` validated it degrades to that
1035 /// text rather than to a `500`: the request was still a miss, and answering a
1036 /// missing page with the wrong status would be a second bug wearing the first one's
1037 /// clothes.
1038 async fn not_found_response(&self, fallback: &'static str) -> Response<ResponseBody> {
1039 let builder = self.response(StatusCode::NOT_FOUND).header("Cache-Control", "no-store");
1040
1041 let Some(page) = &self.not_found_page else {
1042 return text(builder, format!("{fallback}\n"));
1043 };
1044 let Ok(body) = tokio::fs::read(page).await else {
1045 return text(builder, format!("{fallback}\n"));
1046 };
1047
1048 text(
1049 builder.header("Content-Type", "text/html; charset=utf-8"),
1050 body,
1051 )
1052 }
1053
1054 /// Run the server on a specific address with a configurable header-read timeout.
1055 ///
1056 /// Spawns the server in a background Tokio task and returns immediately with the
1057 /// assigned port number and a [`ServerHandle`]. Call `handle.shutdown().await` to
1058 /// stop accepting new connections and wait for in-flight connections to finish.
1059 /// Dropping the handle instead leaves the server running for the life of the process.
1060 ///
1061 /// # Header-Read Timeout
1062 ///
1063 /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
1064 /// This prevents slowloris attacks and resource exhaustion from incomplete requests. The
1065 /// timeout applies only to the header-read phase — once a complete header block has been
1066 /// read, the connection is handed off with no further time bound, so long-lived response
1067 /// bodies (e.g. the live-reload SSE stream from [`Server::with_live_reload`]) are not cut
1068 /// off mid-stream.
1069 ///
1070 /// # Precompressed Sidecars
1071 ///
1072 /// If a request's `Accept-Encoding` allows `br` or `gzip` (preferring `br`) and a
1073 /// sibling `<path>.br`/`<path>.gz` exists next to the resolved file, its bytes are
1074 /// served instead with a matching `Content-Encoding`. Every file response carries
1075 /// `Vary: Accept-Encoding` so intermediate caches don't serve the wrong variant to a
1076 /// differently-capable client.
1077 ///
1078 /// # Arguments
1079 ///
1080 /// * `addr` - Socket address to bind to (e.g., `127.0.0.1:0` for loopback ephemeral,
1081 /// or `0.0.0.0:8080` to bind all interfaces on a fixed port).
1082 /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
1083 ///
1084 /// # Returns
1085 ///
1086 /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
1087 /// - `Err(StaticError::Io)` if binding to the socket fails.
1088 /// - `Err(StaticError::PipelineSetup)` if a configured [`CssTool`]/[`JsTool`]'s binary
1089 /// is not found on `PATH`. Checked before the listener binds: a deployment whose
1090 /// configured pipeline can never run should fail visibly at boot, not be discovered
1091 /// later as a missing/stale asset.
1092 pub async fn run_on(
1093 &self,
1094 addr: SocketAddr,
1095 header_timeout: Duration,
1096 ) -> Result<(u16, ServerHandle), StaticError> {
1097 for (binary, install_hint) in self.required_tool_binaries() {
1098 if !tool::locate_on_path(binary) {
1099 return Err(StaticError::PipelineSetup(format!(
1100 "{binary} not found on PATH ({install_hint})"
1101 )));
1102 }
1103 }
1104
1105 let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
1106 let port = listener.local_addr().map_err(StaticError::Io)?.port();
1107
1108 let mut server = self.clone();
1109 if server.live_reload {
1110 let broadcaster = Broadcaster::new();
1111
1112 // The build pipelines react to SOURCE folders only; the output dir is never
1113 // watched. Watching the output would feed each pipeline its own writes back
1114 // into its trigger — the feedback loop this layering exists to prevent.
1115 if server.has_pipeline() {
1116 let pipeline = Arc::new(SourcePipeline::new(
1117 server.source_folders.clone(),
1118 server.bundle_roots.clone(),
1119 server.asset_folders.clone(),
1120 server.output_dir.clone(),
1121 server.css_tool.clone(),
1122 server.js_tool.clone(),
1123 server.prune_output,
1124 broadcaster.clone(),
1125 ));
1126 let mut rx = broadcaster.subscribe();
1127 tokio::spawn(async move {
1128 // One-shot startup build (and optional prune) first, so the earliest
1129 // request already sees fresh output rather than yesterday's.
1130 if let Err(e) = pipeline.full_build().await {
1131 eprintln!("source pipeline build error: {e}");
1132 }
1133 while let Some(event) = rx.recv().await {
1134 if let Err(e) = pipeline
1135 .process_change(&event.path, &event.change_type)
1136 .await
1137 {
1138 eprintln!("source pipeline error: {e}");
1139 }
1140 }
1141 });
1142 }
1143
1144 for dir in server.watch_targets() {
1145 start_watching(Arc::new(dir), broadcaster.clone());
1146 }
1147
1148 server.broadcaster = Some(broadcaster);
1149 } else if server.has_pipeline() {
1150 // No live-reload: still run the one-shot build so a release boot reflects the
1151 // current sources. The broadcaster is a throwaway — there is no browser to
1152 // notify, so broadcasting into it is a no-op.
1153 let pipeline = Arc::new(SourcePipeline::new(
1154 server.source_folders.clone(),
1155 server.bundle_roots.clone(),
1156 server.asset_folders.clone(),
1157 server.output_dir.clone(),
1158 server.css_tool.clone(),
1159 server.js_tool.clone(),
1160 server.prune_output,
1161 Broadcaster::new(),
1162 ));
1163 tokio::spawn(async move {
1164 if let Err(e) = pipeline.full_build().await {
1165 eprintln!("source pipeline build error: {e}");
1166 }
1167 });
1168 }
1169 let semaphore = Arc::new(Semaphore::new(server.max_connections));
1170 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
1171
1172 let accept_task = tokio::spawn(async move {
1173 let mut backoff = ACCEPT_BACKOFF_INITIAL;
1174 let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
1175 let mut shutdown_pin = std::pin::pin!(shutdown_rx);
1176 let mut shutting_down = false;
1177
1178 loop {
1179 if !shutting_down {
1180 // The accept-and-permit step and the shutdown signal race in a single
1181 // `select!` so shutdown can preempt a pending accept or a permit wait
1182 // cleanly, at any point — not just between loop iterations.
1183 tokio::select! {
1184 accepted = accept_and_permit(&listener, &mut backoff, &semaphore, Some(&server)) => {
1185 match accepted {
1186 Some((stream, permit)) => {
1187 let server = server.clone();
1188 join_set.spawn(async move {
1189 let _permit = permit;
1190 serve_connection(stream, server, header_timeout).await;
1191 });
1192 }
1193 None => shutting_down = true,
1194 }
1195 }
1196 _ = shutdown_pin.as_mut() => {
1197 shutting_down = true;
1198 }
1199 }
1200 continue;
1201 }
1202
1203 // Stop accepting; drain already-spawned connections before returning.
1204 match join_set.join_next().await {
1205 Some(_) => continue,
1206 None => break,
1207 }
1208 }
1209 });
1210
1211 Ok((
1212 port,
1213 ServerHandle {
1214 shutdown_tx: Some(shutdown_tx),
1215 accept_task,
1216 },
1217 ))
1218 }
1219
1220 /// Run the server on loopback (127.0.0.1), binding an ephemeral port.
1221 ///
1222 /// Thin wrapper around [`Server::run_on`] — see it for the header-read timeout and
1223 /// sidecar semantics, and for what the returned [`ServerHandle`] does.
1224 pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
1225 self.run_on(([127, 0, 0, 1], 0).into(), header_timeout)
1226 .await
1227 }
1228
1229 /// Run the server on all interfaces (0.0.0.0) at `port` (0 for an ephemeral port).
1230 ///
1231 /// Useful for containerized deployments and reverse-proxy setups. Thin wrapper
1232 /// around [`Server::run_on`] — see it for the header-read timeout and sidecar
1233 /// semantics, and for what the returned [`ServerHandle`] does.
1234 pub async fn run_all(
1235 &self,
1236 port: u16,
1237 header_timeout: Duration,
1238 ) -> Result<(u16, ServerHandle), StaticError> {
1239 self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
1240 .await
1241 }
1242
1243 /// Run the server on loopback with the default 30-second header-read timeout.
1244 ///
1245 /// The recommended entry point for tests and lightweight services that don't need a
1246 /// custom timeout. Thin wrapper around [`Server::run`].
1247 ///
1248 /// # Example
1249 ///
1250 /// ```no_run
1251 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1252 /// use mini_static::Server;
1253 /// use std::path::Path;
1254 ///
1255 /// let server = Server::new(Path::new("./public"))?;
1256 /// let (port, handle) = server.run_ephemeral().await?;
1257 /// println!("Server ready on http://127.0.0.1:{}", port);
1258 /// handle.shutdown().await;
1259 /// # Ok(())
1260 /// # }
1261 /// ```
1262 pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
1263 self.run(DEFAULT_HEADER_TIMEOUT).await
1264 }
1265
1266 /// Produce the HTTP response for a request, streaming file bodies to the client.
1267 ///
1268 /// This is the crate's single request-handling path: the `run*` accept loop calls it,
1269 /// and so should any async server embedding `mini-static` as a fallback route (e.g.
1270 /// `mini-unified`). It never blocks the calling task — path resolution runs on Tokio's
1271 /// blocking-thread pool via `spawn_blocking`, and the file is read via async I/O.
1272 ///
1273 /// File responses are backed by `FileBody`, which hands hyper one 64 KB chunk at a
1274 /// time as `poll_frame` is driven: memory use stays bounded to one chunk per in-flight
1275 /// response regardless of file size.
1276 ///
1277 /// `headers` are the request's headers; `If-None-Match` (304 on a matching ETag) and
1278 /// `Accept-Encoding` (precompressed sidecar selection, see [`Server::run_on`]) are the
1279 /// ones read today. Only `GET` and `HEAD` are allowed; anything else gets a 405 with an
1280 /// `Allow` header. Missing files and traversal attempts both get an identical 404, so a
1281 /// response never discloses whether a path exists outside the root.
1282 pub async fn handle_request(
1283 &self,
1284 method: &Method,
1285 request_path: &str,
1286 headers: &HeaderMap,
1287 ) -> Response<ResponseBody> {
1288 if method != Method::GET && method != Method::HEAD {
1289 return text(
1290 self.response(StatusCode::METHOD_NOT_ALLOWED).header("Allow", "GET, HEAD"),
1291 "method not allowed\n",
1292 );
1293 }
1294
1295 // Live-reload SSE stream — only reachable when `with_live_reload()` was called
1296 // and the server was started via a `run*` method (those are the only paths that
1297 // populate `broadcaster`).
1298 if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
1299 if let Some(broadcaster) = &self.broadcaster {
1300 return finish(
1301 self.response(StatusCode::OK)
1302 .header("Content-Type", "text/event-stream")
1303 .header("Cache-Control", "no-cache")
1304 .header("Connection", "keep-alive")
1305 .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
1306 );
1307 }
1308 }
1309
1310 // `resolve()` does blocking filesystem syscalls (`canonicalize()`, up to two per
1311 // request). Running those directly in this `async fn` would block whichever
1312 // Tokio worker thread happens to be driving it, stalling every other task
1313 // scheduled on that thread for the duration of the syscalls. `spawn_blocking`
1314 // moves the work onto Tokio's dedicated blocking thread pool instead.
1315 let server = self.clone();
1316 let owned_request_path = request_path.to_string();
1317 let resolved =
1318 tokio::task::spawn_blocking(move || server.resolve(&owned_request_path)).await;
1319 let path = match resolved {
1320 Err(_) => return internal_error_response(),
1321 Ok(Err(e)) => return self.not_found_response(e.user_message()).await,
1322 Ok(Ok(path)) => path,
1323 };
1324
1325 // A directory served via its `index.html` needs a trailing slash to establish the
1326 // correct base for the page's relative links. Compare against the *decoded*
1327 // request path so a percent-encoded explicit request for index.html (e.g.
1328 // `/docs/index.htm%6c`) is recognized as such instead of producing a redirect to a
1329 // still-encoded, broken Location.
1330 let decoded_request_path = resolve::decode_request_path(request_path);
1331 if path.file_name().is_some_and(|name| name == "index.html")
1332 && !decoded_request_path.ends_with('/')
1333 && !decoded_request_path.ends_with("index.html")
1334 {
1335 // `location` is built from the (attacker-controlled) request path; `finish()`
1336 // degrades to 400 instead of panicking if it ever contains bytes invalid in a
1337 // header value.
1338 let location = format!("{}/", request_path.trim_end_matches('/'));
1339 return text(
1340 self.response(StatusCode::MOVED_PERMANENTLY).header("Location", location),
1341 "moved\n",
1342 );
1343 }
1344
1345 let Ok(file) = File::open(&path).await else {
1346 return internal_error_response();
1347 };
1348 let Ok(metadata) = file.metadata().await else {
1349 return internal_error_response();
1350 };
1351
1352 let content_type = mime_type_for_path(&path);
1353 // Live-reload and spa-mode HTML injection both need the original, uncompressed
1354 // bytes to splice their script into — never substitute a precompressed sidecar on
1355 // this path. `broadcaster` is only `Some` when live-reload is enabled (see
1356 // `Server::with_live_reload`); `spa_mode` is independent of it (see
1357 // `Server::with_spa_mode`/`with_spa_root`) — either alone is enough to trigger
1358 // injection.
1359 // Injection reads the whole file into memory, so it is also gated on size. Every
1360 // decision keyed off `html_injection` — the sidecar skip below, the range skip,
1361 // the full read itself — inherits the cap from this one boolean, so an over-cap
1362 // page takes the ordinary streamed path with no second decision point.
1363 let wants_injection =
1364 (self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html");
1365 let html_injection = wants_injection && metadata.len() <= MAX_INJECTABLE_HTML_BYTES;
1366
1367 if wants_injection && !html_injection {
1368 self.log(format_args!(
1369 "html injection skipped for {request_path}: {} bytes exceeds the \
1370 {MAX_INJECTABLE_HTML_BYTES}-byte limit; serving unmodified",
1371 metadata.len(),
1372 ));
1373 }
1374
1375 let range_header = header_str(headers, "range");
1376 let if_range_header = header_str(headers, "if-range");
1377
1378 let accept_encoding = header_str(headers, "accept-encoding");
1379 // Skip precompressed sidecars when Range is requested (serve original file instead).
1380 let sidecar = if html_injection || range_header.is_some() {
1381 None
1382 } else {
1383 select_precompressed_sidecar(&path, accept_encoding).await
1384 };
1385 let (mut file, metadata, content_encoding) = match sidecar {
1386 Some((sidecar_file, sidecar_metadata, encoding)) => {
1387 (sidecar_file, sidecar_metadata, Some(encoding))
1388 }
1389 None => (file, metadata, None),
1390 };
1391
1392 // HTML injection is skipped for a served precompressed sidecar (already final
1393 // bytes from a build step) — see `html_injection`'s definition above.
1394 let etag = generate_etag(&metadata);
1395 let cache_control = self.cache_control_for(&path);
1396
1397 if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
1398 return finish(
1399 self.response(StatusCode::NOT_MODIFIED)
1400 .header("Cache-Control", cache_control)
1401 .header("Vary", "Accept-Encoding")
1402 .header("ETag", etag)
1403 .header("Accept-Ranges", "bytes")
1404 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1405 );
1406 }
1407
1408 // `Some` when the served representation differs from the file's raw bytes and had
1409 // to be built in memory; `None` means stream the open file as-is. Computed before
1410 // the HEAD check below because RFC 9110 requires a HEAD response's headers —
1411 // `Content-Length` included — to match what a GET would send, even though the body
1412 // itself is dropped.
1413 let transformed: Option<Bytes> = if html_injection {
1414 let mut html = Vec::with_capacity(metadata.len() as usize);
1415 if file.read_to_end(&mut html).await.is_err() {
1416 return internal_error_response();
1417 }
1418 if self.broadcaster.is_some() {
1419 reload::inject_reload_script(&mut html);
1420 }
1421 if self.spa_mode {
1422 spa::inject_spa_script(&mut html, self.spa_root.as_deref(), &self.spa_transition);
1423 }
1424 Some(Bytes::from(html))
1425 } else {
1426 None
1427 };
1428
1429 let file_size = transformed
1430 .as_ref()
1431 .map_or(metadata.len(), |bytes| bytes.len() as u64);
1432
1433 // Handle Range requests.
1434 let range_outcome = range_header.map(|h| parse_range_header(h, file_size));
1435 let range_check = if let Some(outcome) = &range_outcome {
1436 match outcome {
1437 RangeOutcome::Satisfiable(start, end) => {
1438 // If-Range validation: stale If-Range ignores Range, serves full 200.
1439 if let Some(if_range) = if_range_header {
1440 if !if_range_valid(if_range, &etag) {
1441 RangeCheck::IgnoreRange
1442 } else {
1443 RangeCheck::Satisfiable(*start, *end)
1444 }
1445 } else {
1446 RangeCheck::Satisfiable(*start, *end)
1447 }
1448 }
1449 RangeOutcome::MultiRangeIgnored => RangeCheck::IgnoreRange,
1450 RangeOutcome::Unsatisfiable => RangeCheck::Unsatisfiable,
1451 RangeOutcome::NoRange => RangeCheck::IgnoreRange,
1452 }
1453 } else {
1454 RangeCheck::IgnoreRange
1455 };
1456
1457 match &range_check {
1458 RangeCheck::Unsatisfiable => {
1459 return finish(
1460 Response::builder()
1461 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1462 .header("Content-Range", format!("bytes */{}", file_size))
1463 .header("Accept-Ranges", "bytes")
1464 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1465 );
1466 }
1467 RangeCheck::Satisfiable(start, end) => {
1468 let range_len = end - start + 1;
1469
1470 // Seek to start position; if sidecar, we already skipped it above.
1471 if transformed.is_none()
1472 && file.seek(std::io::SeekFrom::Start(*start)).await.is_err()
1473 {
1474 return internal_error_response();
1475 }
1476
1477 // HEAD must not return a body (RFC 9110).
1478 let body = if *method == Method::HEAD {
1479 ResponseBody::Buffered(Full::new(Bytes::new()))
1480 } else {
1481 match transformed {
1482 Some(ref bytes) => ResponseBody::Buffered(Full::new(
1483 bytes.slice(*start as usize..(*end as usize + 1)),
1484 )),
1485 None => ResponseBody::Streamed(FileBody::new_ranged(file, range_len)),
1486 }
1487 };
1488
1489 let mut builder = Response::builder()
1490 .status(StatusCode::PARTIAL_CONTENT)
1491 .header("Content-Type", content_type)
1492 .header("Content-Length", range_len.to_string())
1493 .header(
1494 "Content-Range",
1495 format!("bytes {}-{}/{}", start, end, file_size),
1496 )
1497 .header("Cache-Control", cache_control)
1498 .header("Vary", "Accept-Encoding")
1499 .header("ETag", etag)
1500 .header("Accept-Ranges", "bytes");
1501 if let Some(encoding) = content_encoding {
1502 builder = builder.header("Content-Encoding", encoding);
1503 }
1504 return finish(builder.body(body));
1505 }
1506 RangeCheck::IgnoreRange => {}
1507 }
1508
1509 // HEAD must not return a body (RFC 9110).
1510 let body = if *method == Method::HEAD {
1511 ResponseBody::Buffered(Full::new(Bytes::new()))
1512 } else {
1513 match transformed {
1514 Some(bytes) => ResponseBody::Buffered(Full::new(bytes)),
1515 None => ResponseBody::Streamed(FileBody::new(file)),
1516 }
1517 };
1518
1519 let mut builder = self.response(StatusCode::OK)
1520 .header("Content-Type", content_type)
1521 .header("Content-Length", file_size.to_string())
1522 .header("Cache-Control", cache_control)
1523 .header("Vary", "Accept-Encoding")
1524 .header("ETag", etag)
1525 .header("Accept-Ranges", "bytes");
1526 if let Some(encoding) = content_encoding {
1527 builder = builder.header("Content-Encoding", encoding);
1528 }
1529 finish(builder.body(body))
1530 }
1531}
1532
1533/// Ceiling on how many bytes hyper buffers for a single request's header block before
1534/// rejecting it. Without this, a client that trickles bytes forever without ever sending
1535/// the terminating blank line could grow the buffer without limit — the header-read
1536/// timeout alone doesn't bound memory, only wall-clock time, and a sufficiently patient
1537/// sender could still send unbounded data before the deadline fires.
1538const MAX_HEADER_BYTES: usize = 64 * 1024;
1539
1540/// Ceiling on the size of an HTML file this server will buffer in memory to splice a
1541/// live-reload or spa-mode `<script>` into.
1542///
1543/// Injection is the one code path that reads a whole file into memory rather than
1544/// streaming it in bounded chunks, and it does so *per request* — so without a cap, a
1545/// single large HTML file turns every concurrent request for it into another full copy
1546/// in memory, and spa-mode is a production feature, not a development-only one. An
1547/// over-cap page is served unmodified (and streamed) instead: losing a client-side
1548/// navigation enhancement on an 8 MiB document is a far smaller failure than an
1549/// allocation proportional to file size times concurrency.
1550///
1551/// 8 MiB is comfortably above any hand-written HTML page and any realistic
1552/// static-site-generator output, so the cap should never fire on content this feature
1553/// was designed for.
1554const MAX_INJECTABLE_HTML_BYTES: u64 = 8 * 1024 * 1024;
1555
1556/// Wires an accepted connection up to the hyper HTTP/1.1 service.
1557///
1558/// HTTP/1.1 is the whole surface, deliberately. An earlier implementation used
1559/// `hyper_util`'s auto builder, whose `server-auto` feature transitively enables
1560/// `hyper/http2` — so a prior-knowledge h2c client negotiated HTTP/2 against a server
1561/// that documented, tested, and tuned only HTTP/1: no stream-concurrency limit, no
1562/// frame-size bound, and a header pre-read whose `\r\n\r\n` scan the HTTP/2 preface
1563/// satisfies without being an HTTP/1 request at all. Serving a protocol nobody
1564/// configured is worse than not serving it; browsers reach h2 over TLS only, which this
1565/// crate does not terminate.
1566///
1567/// `header_timeout` and `MAX_HEADER_BYTES` are enforced by hyper itself, per request.
1568/// They were previously enforced by a hand-rolled pre-read that ran once, before the
1569/// connection was handed to hyper — which meant the first request on a connection was
1570/// bounded and every subsequent keep-alive request on that same connection was not:
1571/// a client could complete one cheap request and then trickle headers forever, or send
1572/// an unbounded header block, with neither the timeout nor the size ceiling in play.
1573/// Delegating to hyper applies both bounds to every request, and deletes ~100 lines of
1574/// socket plumbing (`read_header_prefix`, `PrefixedIo`) whose only job was to hand the
1575/// already-read bytes back to hyper.
1576///
1577/// Only the *header* phase is bounded. A response body may legitimately outlive
1578/// `header_timeout` by design — the live-reload SSE stream stays open until a watched
1579/// file changes, possibly hours later — and `header_read_timeout` does not apply once a
1580/// request's headers are complete. The connection-count ceiling
1581/// (`Server::with_max_connections`) is what bounds resource use from connections held
1582/// open indefinitely.
1583///
1584/// `.timer(TokioTimer::new())` is load-bearing, not boilerplate: hyper resolves
1585/// `header_read_timeout` against an installed timer and panics with "timeout set, but no
1586/// timer set" if there isn't one (`hyper::common::time`). The failure is loud rather than
1587/// silent, but it happens per connection inside a spawned task — where a panic takes out
1588/// the connection, not the server — so it is the keep-alive tests, not a startup check,
1589/// that hold this wiring in place.
1590async fn serve_connection(stream: TcpStream, server: Server, header_timeout: Duration) {
1591 let io = TokioIo::new(stream);
1592 let log_server = server.clone();
1593 let svc = service_fn(move |req: Request<Incoming>| {
1594 let server = server.clone();
1595 async move {
1596 let started = Instant::now();
1597 let method = req.method().clone();
1598 let path = req.uri().path().to_string();
1599
1600 let resp = server
1601 .handle_request(req.method(), req.uri().path(), req.headers())
1602 .await;
1603
1604 let bytes = header_str(resp.headers(), "content-length").unwrap_or("-");
1605 server.log(format_args!(
1606 "{method} {path} {} {bytes} {:.3}ms",
1607 resp.status().as_u16(),
1608 started.elapsed().as_secs_f64() * 1000.0,
1609 ));
1610 Ok::<_, Infallible>(resp)
1611 }
1612 });
1613 if let Err(error) = http1::Builder::new()
1614 .timer(TokioTimer::new())
1615 .header_read_timeout(header_timeout)
1616 .max_buf_size(MAX_HEADER_BYTES)
1617 .serve_connection(io, svc)
1618 .await
1619 {
1620 log_server.log(format_args!("connection error: {error}"));
1621 }
1622}
1623
1624/// Default header-read timeout used by [`Server::run_ephemeral`].
1625const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
1626
1627/// Default grace period `ServerHandle::shutdown()` waits for in-flight connections to
1628/// finish on their own before aborting whatever is left. A connection with no
1629/// self-imposed end — an open live-reload SSE stream, or any keep-alive connection whose
1630/// peer simply never closes it — would otherwise let `shutdown()` hang forever waiting
1631/// for it to finish naturally. Every wait in this crate has a stated upper bound;
1632/// shutdown is no exception.
1633const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1634
1635/// A handle to a server started by one of the `Server::run*` methods.
1636///
1637/// Dropping this handle without calling `shutdown()` leaves the server running in the
1638/// background for the life of the process. Call `shutdown()` to stop accepting new
1639/// connections and wait for already-accepted connections to finish before returning.
1640pub struct ServerHandle {
1641 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1642 accept_task: tokio::task::JoinHandle<()>,
1643}
1644
1645impl ServerHandle {
1646 /// Stop accepting new connections and wait up to `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT`
1647 /// (5s) for in-flight connections to finish on their own. Equivalent to
1648 /// `shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)` — see that method for what
1649 /// happens to connections still open once the grace period elapses.
1650 pub async fn shutdown(self) {
1651 self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1652 .await;
1653 }
1654
1655 /// Stop accepting new connections and wait up to `drain_timeout` for in-flight
1656 /// connections to finish on their own.
1657 ///
1658 /// Connections still open once `drain_timeout` elapses are aborted rather than
1659 /// waited on further: dropping the accept task drops its `JoinSet`, which aborts
1660 /// every task still tracked in it (see `tokio::task::JoinSet`'s own drop behavior) —
1661 /// which in turn drops each connection's socket, closing it. This is what bounds
1662 /// shutdown when a connection has no natural end of its own (the live-reload SSE
1663 /// stream is the motivating case: it stays open until a watched file changes, which
1664 /// may never happen before the process needs to exit).
1665 pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1666 if let Some(tx) = self.shutdown_tx.take() {
1667 let _ = tx.send(());
1668 }
1669 if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1670 self.accept_task.abort();
1671 }
1672 }
1673}
1674
1675/// Read a request header as a `&str`, or `None` if it's absent or not valid ASCII.
1676fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1677 headers.get(name).and_then(|value| value.to_str().ok())
1678}
1679
1680/// Start a response carrying the baseline security header every response in this crate
1681/// sends — 304s included. A 304 otherwise repeats only the caching validators, which is
1682/// why it once built its own builder and was the single response able to arrive without
1683/// `nosniff`; a client that caches the header set alongside the representation would
1684/// then hold a copy missing it.
1685///
1686/// Prefer [`Server::response`], which also applies the embedder's configured headers.
1687/// This bare form exists for `bad_request_response`, which is reachable from `finish`
1688/// where no `Server` is in scope.
1689fn response(status: StatusCode) -> Builder {
1690 Response::builder()
1691 .status(status)
1692 .header("X-Content-Type-Options", "nosniff")
1693}
1694
1695/// Finish `builder` with a plain-text body. `&'static str` bodies borrow rather than
1696/// allocate; owned bodies are moved in.
1697fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1698 finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1699}
1700
1701/// Finishes building a response, degrading to a generic 400 instead of panicking if any
1702/// header value turns out to be invalid for use as an HTTP header value.
1703///
1704/// Every header value that reaches `Response::builder()` in this module is either a
1705/// static string or formatted from internal, already-validated data (a byte count, an
1706/// mtime, a fixed method list) — none of it can actually fail today. But `.unwrap()`
1707/// on that assumption is exactly the kind of thing that turns "can't happen" into a
1708/// production panic the day someone adds a header built from new input without
1709/// re-deriving that guarantee. Routing every response through this one fallible path
1710/// means that mistake fails safe instead of panicking.
1711fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1712 built.unwrap_or_else(|_| bad_request_response())
1713}
1714
1715// `internal_error_response()` and `bad_request_response()` are the fallback responses
1716// `finish()` itself degrades to — every header and body here is a fixed string with no
1717// external input, so `.body(...)` cannot fail. They can't be routed through `finish()`
1718// without it degrading to itself on failure.
1719fn internal_error_response() -> Response<ResponseBody> {
1720 response(StatusCode::INTERNAL_SERVER_ERROR)
1721 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1722 b"internal server error\n",
1723 ))))
1724 .unwrap()
1725}
1726
1727fn bad_request_response() -> Response<ResponseBody> {
1728 response(StatusCode::BAD_REQUEST)
1729 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1730 b"bad request\n",
1731 ))))
1732 .unwrap()
1733}
1734
1735/// `Content-Encoding` name and sidecar file extension for each supported precompressed
1736/// variant, in preference order — brotli wins when a client accepts both and both
1737/// sidecars exist.
1738const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1739
1740/// `q`-values are carried in thousandths — RFC 9110 allows at most three decimal places
1741/// — so weights compare exactly as integers instead of through float equality.
1742const QVALUE_SCALE: f32 = 1000.0;
1743
1744/// An `Accept-Encoding` entry with no explicit `q` parameter has weight 1.
1745const DEFAULT_QVALUE: u16 = 1000;
1746
1747/// The weight `accept_encoding` gives `encoding`, or `None` if it does not list it.
1748///
1749/// Entries are matched as whole tokens, case-insensitively, per RFC 9110 — not by
1750/// substring. The substring form this replaces got two things wrong that a client can
1751/// trigger: `Accept-Encoding: gzip;q=0` selected gzip, because the header *contains*
1752/// "gzip" while explicitly refusing it, and a token like `brotli` matched `br`.
1753///
1754/// `*` is deliberately not honored: treating the wildcard as matching nothing can only
1755/// cost a bandwidth optimization, while treating it as matching everything risks sending
1756/// an encoding the client did not ask for. The conservative reading is the safe one when
1757/// the payoff is choosing between two static files.
1758fn encoding_quality(accept_encoding: &str, encoding: &str) -> Option<u16> {
1759 accept_encoding.split(',').find_map(|entry| {
1760 let mut parts = entry.split(';');
1761 if !parts.next()?.trim().eq_ignore_ascii_case(encoding) {
1762 return None;
1763 }
1764
1765 let quality = parts
1766 .find_map(|parameter| {
1767 let (key, value) = parameter.split_once('=')?;
1768 key.trim().eq_ignore_ascii_case("q").then(|| value.trim())
1769 })
1770 .and_then(|value| value.parse::<f32>().ok())
1771 .map(|value| (value.clamp(0.0, 1.0) * QVALUE_SCALE).round() as u16)
1772 .unwrap_or(DEFAULT_QVALUE);
1773
1774 Some(quality)
1775 })
1776}
1777
1778
1779/// Look for a precompressed sidecar (`<path>.br` / `<path>.gz`) matching the client's
1780/// `Accept-Encoding`, and return its open file, metadata, and encoding name if found.
1781///
1782/// `path` must already be the fully resolved, canonicalized path `resolve()` produced.
1783/// The sidecar path is built by appending an extension to it — never by re-resolving a
1784/// modified request path — so this lookup can't become a second traversal surface: any
1785/// path this function reads is provably a sibling of a path `resolve()` already cleared.
1786async fn select_precompressed_sidecar(
1787 path: &Path,
1788 accept_encoding: Option<&str>,
1789) -> Option<(File, fs::Metadata, &'static str)> {
1790 // Highest `q` first; ties keep `SIDECAR_ENCODINGS` order (brotli over gzip) because
1791 // the sort is stable. Without this, `br;q=0.5, gzip` would serve brotli purely
1792 // because it is listed first here, ignoring the preference the client stated.
1793 let mut candidates: Vec<(&'static str, &'static str, u16)> = SIDECAR_ENCODINGS
1794 .iter()
1795 .filter_map(|(encoding, ext)| {
1796 let quality = accept_encoding.and_then(|header| encoding_quality(header, encoding))?;
1797 (quality > 0).then_some((*encoding, *ext, quality))
1798 })
1799 .collect();
1800 candidates.sort_by_key(|(_, _, quality)| std::cmp::Reverse(*quality));
1801
1802 for (encoding, ext, _) in candidates {
1803 let mut sidecar = path.as_os_str().to_os_string();
1804 sidecar.push(ext);
1805 let sidecar_path = PathBuf::from(sidecar);
1806
1807 // Tripwire for the traversal boundary: a sidecar path built by appending a suffix
1808 // must stay in the same directory as `path` (which `resolve()` already proved is
1809 // inside root). `ext` is always one of the two static literals in
1810 // `SIDECAR_ENCODINGS`, never derived from request input, so this can only fire if
1811 // a future change starts deriving `sidecar` some other way.
1812 debug_assert_eq!(
1813 sidecar_path.parent(),
1814 path.parent(),
1815 "sidecar path must stay in the same directory as the already-resolved path"
1816 );
1817
1818 if let Ok(sidecar_file) = File::open(&sidecar_path).await {
1819 if let Ok(sidecar_metadata) = sidecar_file.metadata().await {
1820 return Some((sidecar_file, sidecar_metadata, encoding));
1821 }
1822 }
1823 }
1824 None
1825}
1826
1827/// Generate an ETag for a file based on modification time and size.
1828///
1829/// Format: `"<size>-<mtime_secs>.<mtime_nanos>"`.
1830///
1831/// The sub-second component is what makes this crate's choice to serve an ETag *instead*
1832/// of `Last-Modified`/`If-Modified-Since` sound. That choice rests on an ETag being able
1833/// to distinguish representations a whole-second timestamp cannot — two writes inside the
1834/// same second — which a whole-second ETag plainly cannot do either: rewriting a file
1835/// within a second of its last write, without changing its length, reproduced the
1836/// previous ETag exactly and every revalidating client was told `304 Not Modified` while
1837/// holding stale bytes. Build pipelines that rewrite generated assets are the realistic
1838/// way to hit that, and this crate ships one.
1839///
1840/// A filesystem whose timestamps are only second-granular gives `subsec_nanos() == 0`
1841/// and the same behavior as before — no worse, and no false confidence beyond what the
1842/// filesystem actually provides.
1843fn generate_etag(metadata: &fs::Metadata) -> String {
1844 let mtime = metadata
1845 .modified()
1846 .ok()
1847 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1848 .unwrap_or_default();
1849 format!(
1850 "\"{}-{}.{}\"",
1851 metadata.len(),
1852 mtime.as_secs(),
1853 mtime.subsec_nanos()
1854 )
1855}
1856
1857/// Determine MIME type from file path extension.
1858fn mime_type_for_path(path: &Path) -> &'static str {
1859 let ext = path
1860 .extension()
1861 .and_then(|ext| ext.to_str())
1862 .unwrap_or_default()
1863 .to_lowercase();
1864
1865 match ext.as_str() {
1866 "html" | "htm" => "text/html; charset=utf-8",
1867 "css" => "text/css; charset=utf-8",
1868 "js" => "application/javascript; charset=utf-8",
1869 "json" => "application/json; charset=utf-8",
1870 "svg" => "image/svg+xml",
1871 "png" => "image/png",
1872 "jpg" | "jpeg" => "image/jpeg",
1873 "gif" => "image/gif",
1874 "webp" => "image/webp",
1875 "ico" => "image/x-icon",
1876 "woff" => "font/woff",
1877 "woff2" => "font/woff2",
1878 "ttf" => "font/ttf",
1879 "md" | "markdown" => "text/markdown; charset=utf-8",
1880 "txt" => "text/plain; charset=utf-8",
1881 "xml" => "application/xml",
1882 "pdf" => "application/pdf",
1883 "zip" => "application/zip",
1884 _ => "application/octet-stream",
1885 }
1886}
1887
1888/// Check if the If-None-Match header matches the current ETag.
1889/// Handles both exact match and wildcard (*) comparison per RFC 9110.
1890fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1891 if if_none_match == "*" {
1892 return true;
1893 }
1894 if_none_match.split(',').any(|tag| tag.trim() == etag)
1895}
1896
1897#[derive(Debug)]
1898enum RangeOutcome {
1899 NoRange,
1900 Satisfiable(u64, u64),
1901 Unsatisfiable,
1902 MultiRangeIgnored,
1903}
1904
1905enum RangeCheck {
1906 IgnoreRange,
1907 Satisfiable(u64, u64),
1908 Unsatisfiable,
1909}
1910
1911fn parse_range_header(header: &str, file_size: u64) -> RangeOutcome {
1912 let header = header.trim();
1913 if !header.starts_with("bytes=") {
1914 return RangeOutcome::NoRange;
1915 }
1916
1917 let range_spec = &header[6..];
1918
1919 if range_spec.contains(',') {
1920 return RangeOutcome::MultiRangeIgnored;
1921 }
1922
1923 if let Some(suffix_pos) = range_spec.find('-') {
1924 if suffix_pos == 0 {
1925 let suffix_len_str = &range_spec[1..];
1926 if let Ok(suffix_len) = suffix_len_str.parse::<u64>() {
1927 if suffix_len == 0 {
1928 return RangeOutcome::Unsatisfiable;
1929 }
1930 if suffix_len >= file_size {
1931 return RangeOutcome::Satisfiable(0, file_size - 1);
1932 }
1933 return RangeOutcome::Satisfiable(file_size - suffix_len, file_size - 1);
1934 }
1935 return RangeOutcome::Unsatisfiable;
1936 }
1937
1938 let start_str = &range_spec[..suffix_pos];
1939 let end_str = &range_spec[suffix_pos + 1..];
1940
1941 if let Ok(start) = start_str.parse::<u64>() {
1942 if start >= file_size {
1943 return RangeOutcome::Unsatisfiable;
1944 }
1945
1946 if end_str.is_empty() {
1947 return RangeOutcome::Satisfiable(start, file_size - 1);
1948 }
1949
1950 if let Ok(end) = end_str.parse::<u64>() {
1951 if end < start {
1952 return RangeOutcome::Unsatisfiable;
1953 }
1954 let clamped_end = (end + 1).min(file_size) - 1;
1955 if start > clamped_end {
1956 return RangeOutcome::Unsatisfiable;
1957 }
1958 return RangeOutcome::Satisfiable(start, clamped_end);
1959 }
1960 }
1961 }
1962
1963 RangeOutcome::Unsatisfiable
1964}
1965
1966fn if_range_valid(if_range_header: &str, current_etag: &str) -> bool {
1967 if_range_header.trim() == current_etag
1968}
1969
1970#[cfg(test)]
1971#[path = "../tests/unit/server/precompressed_sidecar.rs"]
1972mod precompressed_sidecar_tests;
1973
1974#[cfg(test)]
1975#[path = "../tests/unit/server/file_body.rs"]
1976mod file_body_tests;
1977
1978#[cfg(test)]
1979#[path = "../tests/unit/server/accept.rs"]
1980mod accept_tests;
1981
1982#[cfg(test)]
1983#[path = "../tests/unit/server/finish.rs"]
1984mod finish_tests;
1985
1986#[cfg(test)]
1987#[path = "../tests/unit/server/etag.rs"]
1988mod etag_tests;
1989
1990#[cfg(test)]
1991#[path = "../tests/unit/server/css_bundle.rs"]
1992mod css_bundle_tests;
1993
1994#[cfg(test)]
1995#[path = "../tests/unit/server/asset_folder.rs"]
1996mod asset_folder_tests;
1997
1998#[cfg(test)]
1999#[path = "../tests/unit/server/build_once.rs"]
2000mod build_once_tests;
2001
2002#[cfg(test)]
2003#[path = "../tests/unit/server/range_header.rs"]
2004mod range_header_tests;