Skip to main content

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;