Skip to main content

mini_static/
server.rs

1use std::fs;
2use std::io::Write;
3use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, Instant, SystemTime};
7
8use bytes::Bytes;
9use http_body_util::Full;
10use hyper::header::{self, HeaderName, HeaderValue};
11use hyper::http::response::Builder;
12use hyper::{HeaderMap, Method, Request, Response, StatusCode};
13use tokio::fs::File;
14use tokio::net::TcpListener;
15use tokio::time::timeout;
16
17use crate::error::StaticError;
18use crate::handler::{FileBody, ResponseBody};
19use crate::reload::{self, SseBody};
20use crate::resolve;
21use crate::resolve::HiddenFiles;
22use crate::spa::{self, SpaTransition};
23use crate::watcher::{start_watching, Broadcaster};
24
25
26/// Default maximum concurrent connections, overridable via `Server::with_max_connections`.
27const DEFAULT_MAX_CONNECTIONS: usize = 1024;
28
29/// A predicate deciding whether a resolved file path should get an immutable cache
30/// policy; see [`Server::with_immutable_assets`].
31type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
32
33/// A static file server for serving files securely from a root directory.
34///
35/// `Server` canonicalizes the root directory once at creation time and uses the
36/// canonical form for all subsequent requests, avoiding repeated filesystem calls.
37///
38/// # Security
39///
40/// The server protects against:
41/// - Path traversal attacks (e.g., `../../etc/passwd`)
42/// - Accessing files outside the root via symlinks
43/// - Disclosing filesystem structure (traversal and missing files both return 404)
44///
45/// # Cloning
46///
47/// `Server` is cheap to clone: a `PathBuf`, a couple of primitives, and an `Arc`'d
48/// predicate closure. Multiple clones can be used concurrently in async tasks without
49/// synchronization overhead.
50///
51/// # Example
52///
53/// ```no_run
54/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
55/// use mini_static::Server;
56/// use std::path::Path;
57/// use std::time::Duration;
58///
59/// let server = Server::new(Path::new("./public"))?;
60/// let (port, _handle) = server.run(Duration::from_secs(30)).await?;
61/// println!("Server running on port {}", port);
62/// # Ok(())
63/// # }
64/// ```
65/// Headers this server derives from the response it is building, and therefore refuses
66/// as fixed values via [`Server::with_response_header`]. A fixed value would be either
67/// silently overridden or silently duplicated depending on the response — and a wrong
68/// `Content-Length` or `ETag` is a correctness bug, not a policy choice.
69const SERVER_COMPUTED_HEADERS: [HeaderName; 13] = [
70    header::CONTENT_LENGTH,
71    header::CONTENT_TYPE,
72    header::CONTENT_ENCODING,
73    header::CONTENT_RANGE,
74    header::ETAG,
75    header::CACHE_CONTROL,
76    header::VARY,
77    header::ACCEPT_RANGES,
78    header::ALLOW,
79    header::LOCATION,
80    header::CONNECTION,
81    header::TRANSFER_ENCODING,
82    header::X_CONTENT_TYPE_OPTIONS,
83];
84
85/// Where request and connection log lines go.
86///
87/// A `Server` is cloned per request, so the sink is shared rather than duplicated. The
88/// mutex serializes writes from concurrent connections — without it, two responses
89/// finishing at once would interleave mid-line and produce log entries belonging to
90/// neither request.
91type RequestLog = Arc<Mutex<Box<dyn Write + Send>>>;
92
93#[derive(Clone)]
94pub struct Server {
95    root_canon: PathBuf,
96    max_connections: usize,
97    live_reload: bool,
98    broadcaster: Option<Broadcaster>,
99    spa_mode: bool,
100    spa_root: Option<String>,
101    spa_transition: SpaTransition,
102    not_found_page: Option<PathBuf>,
103    hidden_files: HiddenFiles,
104    request_log: Option<RequestLog>,
105    extra_headers: Arc<Vec<(HeaderName, HeaderValue)>>,
106    immutable_predicate: Option<ImmutablePredicate>,
107}
108
109impl Server {
110    /// Create a new server with the given root directory.
111    ///
112    /// Canonicalizes the root once at startup. All subsequent requests use the
113    /// canonical root without re-canonicalizing it, making this suitable for long-lived servers.
114    ///
115    /// # Errors
116    ///
117    /// Returns `Err(StaticError::Io)` if the root cannot be canonicalized (e.g., doesn't exist,
118    /// no read permissions).
119    pub fn new(root: &Path) -> Result<Self, StaticError> {
120        let root_canon = root.canonicalize().map_err(StaticError::Io)?;
121        Ok(Server {
122            root_canon,
123            max_connections: DEFAULT_MAX_CONNECTIONS,
124            live_reload: false,
125            broadcaster: None,
126            spa_mode: false,
127            spa_root: None,
128            spa_transition: SpaTransition::default(),
129            not_found_page: None,
130            hidden_files: HiddenFiles::Deny,
131            request_log: None,
132            extra_headers: Arc::new(Vec::new()),
133            immutable_predicate: None,
134        })
135    }
136
137    /// Set the maximum number of connections served concurrently (default 1024).
138    ///
139    /// Once this many connections are in flight, `run()`'s accept loop stops accepting
140    /// new ones — without pausing the accept loop, a client that opens a connection and
141    /// sends nothing (see the header-read timeout docs on [`Server::run_on`]) could
142    /// otherwise be used, in enough parallel copies, to exhaust the process's file
143    /// descriptors or memory with no bound at all.
144    pub fn with_max_connections(mut self, max: usize) -> Self {
145        self.max_connections = max;
146        self
147    }
148
149    /// Enable live-reload for this server (disabled by default).
150    ///
151    /// Once enabled, the `run*` methods start a background watcher (mtime polling,
152    /// bounded 500ms interval — see [`crate::start_watching`]) the first time the server
153    /// actually starts accepting connections. It watches the served root; when a build
154    /// pipeline is configured it watches that pipeline's source folders instead, because
155    /// the pipeline broadcasts its own outputs once they are written. Then it will:
156    ///
157    /// - serve a live-reload SSE stream at [`crate::LIVE_RELOAD_PATH`], broadcasting a
158    ///   change event (with [`crate::ChangeType`]) whenever a served file is added,
159    ///   modified, or removed;
160    /// - inject a small `<script>` into every served `text/html` response that connects
161    ///   to that stream and reloads the page (or hot-swaps stylesheet `<link>`s, for CSS
162    ///   changes) — no manual client wiring required.
163    ///
164    /// This is meant for local development, not production: leave it disabled (the
165    /// default) for any server serving real traffic. A typical call site gates it behind
166    /// `#[cfg(debug_assertions)]` so a release build never pays for the watcher or the
167    /// injected script.
168    ///
169    /// # Example
170    ///
171    /// ```no_run
172    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
173    /// use mini_static::Server;
174    /// use std::path::Path;
175    ///
176    /// let server = Server::new(Path::new("./public"))?;
177    /// #[cfg(debug_assertions)]
178    /// let server = server.with_live_reload();
179    /// # Ok(())
180    /// # }
181    /// ```
182    pub fn with_live_reload(mut self) -> Self {
183        self.live_reload = true;
184        self
185    }
186
187    /// Enable spa-mode navigation for this server, swapping `document.body` on
188    /// each navigation (disabled by default).
189    ///
190    /// Once enabled, every served `text/html` response gets a small `<script>`
191    /// injected (see [`Server::with_spa_root`] for what it does) that treats
192    /// `document.body` as the swap target. Calling this after
193    /// [`Server::with_spa_root`] does not clear a previously configured root
194    /// selector — the two methods set independent fields, so
195    /// `.with_spa_root(sel).with_spa_mode()` and
196    /// `.with_spa_mode().with_spa_root(sel)` both end up with spa-mode on and
197    /// root `sel`. Use this one alone when there's no persistent chrome to
198    /// preserve across navigations.
199    ///
200    /// # Example
201    ///
202    /// ```no_run
203    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
204    /// use mini_static::Server;
205    /// use std::path::Path;
206    ///
207    /// let server = Server::new(Path::new("./public"))?.with_spa_mode();
208    /// # Ok(())
209    /// # }
210    /// ```
211    pub fn with_spa_mode(mut self) -> Self {
212        self.spa_mode = true;
213        self
214    }
215
216    /// Enable spa-mode navigation for this server, swapping only the element
217    /// matched by the CSS `selector` on each navigation (disabled by default;
218    /// also enables spa-mode the same as [`Server::with_spa_mode`]).
219    ///
220    /// Once enabled, every served `text/html` response gets a small `<script>`
221    /// injected that intercepts left-clicks on same-origin `<a href>`
222    /// elements (skipping links with a non-`_self` `target`, a `download`
223    /// attribute, `rel="external"`, a `data-no-spa` attribute, or a same-page
224    /// hash-only href) and, instead of a normal navigation:
225    ///
226    /// - fetches the target URL;
227    /// - on a non-OK or non-`text/html` response (or a fetch error), falls
228    ///   back to a real `location.href` navigation — spa-mode never renders a
229    ///   broken page;
230    /// - otherwise replaces the matched element's `innerHTML` with the
231    ///   corresponding content from the fetched document, updates the page
232    ///   title, and pushes the new URL via `history.pushState`, animating the
233    ///   swap with `document.startViewTransition()` where supported;
234    /// - dispatches a `mini-static:navigate` `CustomEvent` on `window` after
235    ///   every client-side navigation, so page scripts can re-run any
236    ///   per-page initialization that would otherwise only execute once
237    ///   (content swapped in via `innerHTML` never executes its own
238    ///   `<script>` tags);
239    /// - handles browser back/forward by re-fetching and swapping to the new
240    ///   `location.href`.
241    ///
242    /// `selector` is matched against both the current page and the fetched
243    /// page; a link click where the selector matches neither falls back to a
244    /// real navigation, same as a fetch failure. Choose a `selector` that
245    /// wraps only the content that varies between pages, leaving persistent
246    /// chrome (nav/header/footer) outside it so it survives navigation
247    /// untouched.
248    ///
249    /// This is meant to be usable in production, not just local development
250    /// (unlike [`Server::with_live_reload`]): a click on a link mini-static
251    /// doesn't intercept, or on a browser without JS or View Transitions
252    /// support, still works as a normal navigation.
253    ///
254    /// # Example
255    ///
256    /// ```no_run
257    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
258    /// use mini_static::Server;
259    /// use std::path::Path;
260    ///
261    /// let server = Server::new(Path::new("./public"))?.with_spa_root("#app");
262    /// # Ok(())
263    /// # }
264    /// ```
265    pub fn with_spa_root(mut self, selector: &str) -> Self {
266        self.spa_mode = true;
267        self.spa_root = Some(selector.to_string());
268        self
269    }
270
271    /// Set how spa-mode animates the swap between pages (also enables
272    /// spa-mode the same as [`Server::with_spa_mode`]; default
273    /// [`SpaTransition::Fade`] when spa-mode is enabled without calling this).
274    ///
275    /// [`SpaTransition::Slide`] injects its own `<style>` tag alongside the
276    /// spa-mode `<script>` — no site CSS is required. See [`SpaTransition`]
277    /// and [`crate::SlideOptions`] for what each variant does and how to
278    /// configure the slide's duration, direction, and easing.
279    ///
280    /// # Example
281    ///
282    /// ```no_run
283    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
284    /// use mini_static::{Server, SlideOptions, SpaTransition};
285    /// use std::path::Path;
286    ///
287    /// let server = Server::new(Path::new("./public"))?
288    ///     .with_spa_root("#app")
289    ///     .with_spa_transition(SpaTransition::Slide(
290    ///         SlideOptions::default().duration_ms(500),
291    ///     ));
292    /// # Ok(())
293    /// # }
294    /// ```
295    pub fn with_spa_transition(mut self, transition: SpaTransition) -> Self {
296        self.spa_mode = true;
297        self.spa_transition = transition;
298        self
299    }
300
301    /// Serves `path` as the body of every `404`, instead of the default plain-text
302    /// `not found`.
303    ///
304    /// `path` is resolved relative to the served root and must exist when this is
305    /// called: a missing 404 page is a deployment mistake, and finding out on the first
306    /// broken link — the one moment the page exists to handle — is too late. It is read
307    /// from disk per response rather than cached, so editing it during a live-reload
308    /// session takes effect without a restart.
309    ///
310    /// The response keeps its `404` status. Serving a custom page with `200` is a soft
311    /// 404: search engines index it, and monitoring stops seeing the failures. It also
312    /// carries `Cache-Control: no-store`, so a client never holds this page as though it
313    /// were the resource that was actually requested.
314    ///
315    /// Nothing about the failed request reaches the page — no path, no reason. A
316    /// traversal attempt and an ordinary miss are deliberately indistinguishable
317    /// (`StaticError::user_message`), and templating the requested path into the
318    /// response would undo that and hand back a reflected-content vector besides.
319    ///
320    /// # Errors
321    ///
322    /// Returns `Err(StaticError::Io)` if `path` cannot be canonicalized (typically:
323    /// it does not exist), or `Err(StaticError::Traversal)` if it lies outside the
324    /// served root.
325    ///
326    /// # Example
327    ///
328    /// ```no_run
329    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
330    /// use mini_static::Server;
331    /// use std::path::Path;
332    ///
333    /// let server = Server::new(Path::new("./public"))?
334    ///     .with_not_found_page(Path::new("404.html"))?;
335    /// # Ok(())
336    /// # }
337    /// ```
338    pub fn with_not_found_page(mut self, path: &Path) -> Result<Self, StaticError> {
339        let joined = self.root_canon.join(path);
340        let canon = joined.canonicalize().map_err(StaticError::Io)?;
341
342        if !canon.starts_with(&self.root_canon) {
343            return Err(StaticError::Traversal(format!(
344                "404 page {} lies outside the served root {}",
345                canon.display(),
346                self.root_canon.display()
347            )));
348        }
349
350        self.not_found_page = Some(canon);
351        Ok(self)
352    }
353
354    /// Serve dot-prefixed paths (`.env`, `.git/config`) instead of answering them as a
355    /// miss.
356    ///
357    /// Hidden files are denied by default. A served root is routinely a build output
358    /// directory, a repository working copy, or a folder someone dropped a `.env` into,
359    /// and the traversal guard cannot help: those files are legitimately *inside* the
360    /// root, so anyone who guesses the name gets them. The default trades a rarely-wanted
361    /// capability for not leaking credentials by accident.
362    ///
363    /// `/.well-known/` is served either way — it is where the web puts resources that
364    /// are meant to be fetched (ACME challenges for certificate issuance,
365    /// `security.txt`), and denying it would break certificate renewal. The exception is
366    /// the first segment only: `/.well-known/.hidden` is still denied.
367    ///
368    /// Call this when the served root is a curated directory whose dotfiles are content
369    /// — a static site that publishes a `.htaccess` for a downstream server, say.
370    ///
371    /// # Example
372    ///
373    /// ```no_run
374    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
375    /// use mini_static::Server;
376    /// use std::path::Path;
377    ///
378    /// let server = Server::new(Path::new("./public"))?.with_hidden_files();
379    /// # Ok(())
380    /// # }
381    /// ```
382    pub fn with_hidden_files(mut self) -> Self {
383        self.hidden_files = HiddenFiles::Serve;
384        self
385    }
386
387    /// Log one line per request to stderr, plus connection-level errors.
388    ///
389    /// Off by default: a library that writes to a process's stderr uninvited is a
390    /// surprise, and an embedder with its own logging wants the lines somewhere else.
391    /// See [`Server::with_request_logging_to`] to choose the destination.
392    ///
393    /// # Example
394    ///
395    /// ```no_run
396    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
397    /// use mini_static::Server;
398    /// use std::path::Path;
399    ///
400    /// let server = Server::new(Path::new("./public"))?.with_request_logging();
401    /// # Ok(())
402    /// # }
403    /// ```
404    /// Send `name: value` on every response.
405    ///
406    /// Call repeatedly to add several. Intended for the policy headers a static site
407    /// wants applied uniformly — `Strict-Transport-Security`, `Content-Security-Policy`,
408    /// `Referrer-Policy` — which this crate has no business choosing on an embedder's
409    /// behalf but every business making expressible.
410    ///
411    /// Both name and value are validated here, at configuration time, so a malformed
412    /// header fails when the server is built rather than on a request months later.
413    ///
414    /// # Errors
415    ///
416    /// - `StaticError::Config` if `name` or `value` is not a valid HTTP header.
417    /// - `StaticError::Config` if `name` is one this server computes per response
418    ///   (`Content-Length`, `Content-Type`, `Content-Encoding`, `Content-Range`, `ETag`,
419    ///   `Cache-Control`, `Vary`, `Accept-Ranges`, `Allow`, `Location`, `Connection`,
420    ///   `Transfer-Encoding`, `X-Content-Type-Options`). A fixed value would either be
421    ///   silently overridden or silently duplicated depending on the response — a
422    ///   configuration mistake worth surfacing at startup rather than a behavior worth
423    ///   supporting. Use [`Server::with_immutable_assets`] for cache policy.
424    ///
425    /// # Example
426    ///
427    /// ```no_run
428    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
429    /// use mini_static::Server;
430    /// use std::path::Path;
431    ///
432    /// let server = Server::new(Path::new("./public"))?
433    ///     .with_response_header("Strict-Transport-Security", "max-age=63072000")?
434    ///     .with_response_header("Referrer-Policy", "strict-origin-when-cross-origin")?;
435    /// # Ok(())
436    /// # }
437    /// ```
438    pub fn with_response_header(mut self, name: &str, value: &str) -> Result<Self, StaticError> {
439        let name = HeaderName::from_bytes(name.as_bytes())
440            .map_err(|_| StaticError::Config(format!("invalid header name: {name}")))?;
441        let value = HeaderValue::from_str(value).map_err(|_| {
442            StaticError::Config(format!("invalid value for header {name}: {value}"))
443        })?;
444
445        if SERVER_COMPUTED_HEADERS.contains(&name) {
446            return Err(StaticError::Config(format!(
447                "{name} is computed per response and cannot be set as a fixed header"
448            )));
449        }
450
451        Arc::make_mut(&mut self.extra_headers).push((name, value));
452        Ok(self)
453    }
454
455    pub fn with_request_logging(self) -> Self {
456        self.with_request_logging_to(Box::new(std::io::stderr()))
457    }
458
459    /// Log one line per request to `writer`, plus connection-level errors.
460    ///
461    /// Each served request writes one line:
462    ///
463    /// ```text
464    /// GET /index.html 200 512 0.421ms
465    /// ```
466    ///
467    /// — method, requested path exactly as received, status, response body bytes (`-`
468    /// when the length isn't known, as on a live-reload SSE stream), and how long
469    /// handling took. Connection-level failures — a malformed request, a client
470    /// vanishing mid-response — write `connection error: <cause>`; before this they were
471    /// discarded entirely, so a server that was refusing every request looked exactly
472    /// like one nobody was talking to.
473    ///
474    /// The path is logged as received, *not* decoded: it is attacker-controlled input,
475    /// and a log reader deserves to see the bytes that actually arrived rather than a
476    /// normalized rendering of them.
477    ///
478    /// Writes are serialized across connections and write errors are ignored — a
479    /// failing log sink must not take down request serving.
480    ///
481    /// # Example
482    ///
483    /// ```no_run
484    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
485    /// use mini_static::Server;
486    /// use std::fs::File;
487    /// use std::path::Path;
488    ///
489    /// let log = File::create("access.log")?;
490    /// let server = Server::new(Path::new("./public"))?.with_request_logging_to(Box::new(log));
491    /// # Ok(())
492    /// # }
493    /// ```
494    pub fn with_request_logging_to(mut self, writer: Box<dyn Write + Send>) -> Self {
495        self.request_log = Some(Arc::new(Mutex::new(writer)));
496        self
497    }
498
499    /// Start a response carrying the baseline security header plus every header the
500    /// embedder configured via [`Server::with_response_header`].
501    ///
502    /// Every response this server builds for a request goes through here, so a
503    /// configured policy header cannot be missing from one status and present on
504    /// another. The sole exception is the `400` that `finish` falls back to when a
505    /// builder produced an invalid header — no `Server` is in scope there, and a
506    /// response that exists only because header construction already failed is the wrong
507    /// place to add more headers.
508    fn response(&self, status: StatusCode) -> Builder {
509        let mut builder = response(status);
510        for (name, value) in self.extra_headers.iter() {
511            builder = builder.header(name, value);
512        }
513        builder
514    }
515
516    /// Write `line` to the configured log sink, if there is one.
517    ///
518    /// A poisoned mutex (some earlier writer panicked mid-write) and a failed write are
519    /// both ignored: neither is a reason to fail a request that was otherwise served
520    /// correctly.
521    fn log(&self, line: std::fmt::Arguments<'_>) {
522        let Some(log) = &self.request_log else {
523            return;
524        };
525        if let Ok(mut sink) = log.lock() {
526            let _ = writeln!(sink, "{line}");
527            let _ = sink.flush();
528        }
529    }
530
531    /// Serve files matching `predicate` with a long-lived, immutable cache policy
532    /// instead of the default `Cache-Control: no-cache`.
533    ///
534    /// `predicate` is evaluated against each resolved file's path; a match sends
535    /// `Cache-Control: public, max-age=31536000, immutable` on that file's 200 and 304
536    /// responses. This is correct only for fingerprinted assets (e.g.
537    /// `main.a1b2c3.js`) where a content change always produces a new filename —
538    /// caching a mutable filename indefinitely would serve stale content to every
539    /// client that already has it cached.
540    ///
541    /// # Example
542    ///
543    /// ```no_run
544    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
545    /// use mini_static::Server;
546    /// use std::path::Path;
547    ///
548    /// let server = Server::new(Path::new("./public"))?
549    ///     .with_immutable_assets(|path| {
550    ///         path.file_name()
551    ///             .and_then(|name| name.to_str())
552    ///             .is_some_and(|name| name.contains(".fingerprint."))
553    ///     });
554    /// # Ok(())
555    /// # }
556    /// ```
557    pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
558    where
559        F: Fn(&Path) -> bool + Send + Sync + 'static,
560    {
561        self.immutable_predicate = Some(Arc::new(predicate));
562        self
563    }
564
565    /// The `Cache-Control` header value for a resolved file path: the immutable policy
566    /// if `with_immutable_assets`'s predicate matches, `no-cache` otherwise.
567    fn cache_control_for(&self, path: &Path) -> &'static str {
568        match &self.immutable_predicate {
569            Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
570            _ => "no-cache",
571        }
572    }
573
574    /// The directories the live-reload watcher polls: the served root, and only that.
575    ///
576    /// Until 0.29.0 this also returned the build pipeline's source folders, and the
577    /// served root was excluded whenever a pipeline was configured — watching the output
578    /// dir would have fed each pipeline its own writes back into its own trigger. The
579    /// pipeline now lives in `mini-build`, in a separate process, so nothing this server
580    /// watches is written by this server and the exclusion has nothing left to prevent.
581    fn watch_targets(&self) -> Vec<PathBuf> {
582        vec![self.root_canon.clone()]
583    }
584
585    /// Resolve a request path under the server's root.
586    ///
587    /// This is a lower-level API for resolving paths without generating HTTP responses.
588    /// For most use cases, prefer [`Server::handle_request`] or the `run*` methods.
589    ///
590    /// # Returns
591    ///
592    /// - `Ok(PathBuf)` if the path resolves to a file within root.
593    /// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
594    pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
595        resolve::resolve_with_policy(&self.root_canon, request_path, self.hidden_files)
596    }
597
598    /// Builds the `404` response: the configured page when there is one and it can be
599    /// read, and `fallback` as plain text otherwise.
600    ///
601    /// `fallback` is the caller's already-sanitized message (see
602    /// `StaticError::user_message`) — never the requested path, so an ordinary miss and
603    /// a rejected traversal stay indistinguishable to whoever is probing.
604    ///
605    /// A page that vanished after `with_not_found_page` validated it degrades to that
606    /// text rather than to a `500`: the request was still a miss, and answering a
607    /// missing page with the wrong status would be a second bug wearing the first one's
608    /// clothes.
609    async fn not_found_response(&self, fallback: &'static str) -> Response<ResponseBody> {
610        let builder = self
611            .response(StatusCode::NOT_FOUND)
612            .header("Cache-Control", "no-store");
613
614        let Some(page) = &self.not_found_page else {
615            return text(builder, format!("{fallback}\n"));
616        };
617        let Ok(body) = tokio::fs::read(page).await else {
618            return text(builder, format!("{fallback}\n"));
619        };
620
621        text(
622            builder.header("Content-Type", "text/html; charset=utf-8"),
623            body,
624        )
625    }
626
627    /// Build the `mini-serve` app that serves this root.
628    ///
629    /// The whole crate, expressed as one fallback handler: every request that matches no
630    /// route is a file request. There are no routes here, so that is every request — but
631    /// the same app with routes registered in front is exactly the composed
632    /// static-plus-API deployment, which is why this is a fallback rather than a
633    /// bespoke server.
634    /// This root as a `mini-serve` fallback handler.
635    ///
636    /// The composed deployment: register API routes, then hand everything they do not
637    /// match to the files.
638    ///
639    /// ```no_run
640    /// # fn example(files: mini_static::Server, api: mini_serve::Handler<()>) -> mini_serve::App<()> {
641    /// mini_serve::RouteBuilder::stateless()
642    ///     .get("/api/users", api)
643    ///     .with_fallback(files.into_fallback())
644    ///     .seal()
645    /// # }
646    /// ```
647    ///
648    /// This is what `mini-unified` existed to provide. That crate had to wrap this one's
649    /// handler for `mini-serve` because this crate shipped a whole server, when the
650    /// composed case only ever wanted the handler out of it.
651    pub fn into_fallback<S: Send + Sync + 'static>(self) -> mini_serve::Handler<S> {
652        let server = Arc::new(self);
653        mini_serve::handler(move |req, _state| {
654                let server = Arc::clone(&server);
655                async move {
656                    // The router already split and decoded this path; taking its answer is
657                    // the point. `unwrap_or_default` covers a caller who wired the handler
658                    // up without the seam — an empty segment list resolves to the root's
659                    // index, which is the same answer a bare `/` gets.
660                    // Taken, not cloned. Cloning cost a `Vec<String>` and one allocation
661                    // per segment on every request; nothing downstream reads the extension
662                    // again, so moving it out is free.
663                    let mut req = req;
664                    let segments = req
665                        .extensions_mut()
666                        .remove::<mini_serve::PathSegments>()
667                        .map(|s| s.0)
668                        .unwrap_or_default();
669                    // Logged here rather than in the connection layer, which this crate no
670                    // longer owns. The format is unchanged — `mini-serve`'s own line omits
671                    // the byte count, and changing either crate's format to unify them is
672                    // a user-visible change worth making on its own, not inside a
673                    // migration.
674                    let started = Instant::now();
675                    let method = req.method().clone();
676                    let path = req.uri().path().to_string();
677
678                    let resp = server.respond(&req, &segments).await;
679
680                    let bytes = header_str(resp.headers(), "content-length").unwrap_or("-");
681                    server.log(format_args!(
682                        "{method} {path} {} {bytes} {:.3}ms",
683                        resp.status().as_u16(),
684                        started.elapsed().as_secs_f64() * 1000.0,
685                    ));
686                    Ok(bridge_body(resp))
687                }
688        })
689    }
690
691    /// Build the `mini-serve` app that serves this root, and nothing else.
692    ///
693    /// The whole crate as one fallback: with no routes registered, every request is a file
694    /// request. The same app with routes in front is the composed deployment, which is what
695    /// [`Server::into_fallback`] is for.
696    fn into_app(self, header_timeout: Duration) -> mini_serve::App<()> {
697        let max_connections = self.max_connections;
698        mini_serve::RouteBuilder::stateless()
699            .with_header_read_timeout(header_timeout)
700            .with_max_connections(max_connections)
701            // This crate's own 64 KiB ceiling, passed through rather than dropped. It
702            // predates mini-serve having one at all — the migration is what surfaced that.
703            .with_max_header_bytes(MAX_HEADER_BYTES)
704            .with_fallback(self.into_fallback())
705            .seal()
706    }
707
708    /// Run the server on a specific address with a configurable header-read timeout.
709    ///
710    /// Spawns the server in a background Tokio task and returns immediately with the
711    /// assigned port number and a [`ServerHandle`]. Call `handle.shutdown().await` to
712    /// stop accepting new connections and wait for in-flight connections to finish.
713    /// Dropping the handle instead leaves the server running for the life of the process.
714    ///
715    /// # Header-Read Timeout
716    ///
717    /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
718    /// This prevents slowloris attacks and resource exhaustion from incomplete requests. The
719    /// timeout applies only to the header-read phase — once a complete header block has been
720    /// read, the connection is handed off with no further time bound, so long-lived response
721    /// bodies (e.g. the live-reload SSE stream from [`Server::with_live_reload`]) are not cut
722    /// off mid-stream.
723    ///
724    /// # Precompressed Sidecars
725    ///
726    /// If a request's `Accept-Encoding` allows `br` or `gzip` (preferring `br`) and a
727    /// sibling `<path>.br`/`<path>.gz` exists next to the resolved file, its bytes are
728    /// served instead with a matching `Content-Encoding`. Every file response carries
729    /// `Vary: Accept-Encoding` so intermediate caches don't serve the wrong variant to a
730    /// differently-capable client.
731    ///
732    /// # Arguments
733    ///
734    /// * `addr` - Socket address to bind to (e.g., `127.0.0.1:0` for loopback ephemeral,
735    ///   or `0.0.0.0:8080` to bind all interfaces on a fixed port).
736    /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
737    ///
738    /// # Returns
739    ///
740    /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
741    /// - `Err(StaticError::Io)` if binding to the socket fails. This is the only error
742    ///   this function returns.
743    pub async fn run_on(
744        &self,
745        addr: SocketAddr,
746        header_timeout: Duration,
747    ) -> Result<(u16, ServerHandle), StaticError> {
748        let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
749        let port = listener.local_addr().map_err(StaticError::Io)?.port();
750
751        let mut server = self.clone();
752        if server.live_reload {
753            // The served root is the only watch target now that nothing writes into it.
754            // While the build pipeline lived here the output dir was deliberately never
755            // watched, because watching it fed each pipeline its own writes back into its
756            // trigger; with the builder in a separate process that loop cannot happen.
757            let broadcaster = Broadcaster::new();
758            for dir in server.watch_targets() {
759                start_watching(Arc::new(dir), broadcaster.clone());
760            }
761            server.broadcaster = Some(broadcaster);
762        }
763
764        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
765        let app = server.into_app(header_timeout);
766        let accept_task = tokio::spawn(async move {
767            // `mini-serve` owns the accept loop, the connection ceiling, the header-read
768            // timeout and the bounded drain — all of them mutation-verified there. This
769            // crate used to carry a second implementation of each; keeping two was how the
770            // two came to disagree about what a path segment is.
771            let _ = app
772                .run(listener, async move {
773                    let _ = shutdown_rx.await;
774                })
775                .await;
776        });
777
778        Ok((
779            port,
780            ServerHandle {
781                shutdown_tx: Some(shutdown_tx),
782                accept_task,
783            },
784        ))
785    }
786
787    /// Run the server on loopback (127.0.0.1), binding an ephemeral port.
788    ///
789    /// Thin wrapper around [`Server::run_on`] — see it for the header-read timeout and
790    /// sidecar semantics, and for what the returned [`ServerHandle`] does.
791    pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
792        self.run_on((EPHEMERAL_BIND_IP, 0).into(), header_timeout).await
793    }
794
795    /// Run the server on all interfaces (0.0.0.0) at `port` (0 for an ephemeral port).
796    ///
797    /// Useful for containerized deployments and reverse-proxy setups. Thin wrapper
798    /// around [`Server::run_on`] — see it for the header-read timeout and sidecar
799    /// semantics, and for what the returned [`ServerHandle`] does.
800    pub async fn run_all(
801        &self,
802        port: u16,
803        header_timeout: Duration,
804    ) -> Result<(u16, ServerHandle), StaticError> {
805        self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
806            .await
807    }
808
809    /// Run the server on loopback with the default 30-second header-read timeout.
810    ///
811    /// The recommended entry point for tests and lightweight services that don't need a
812    /// custom timeout. Thin wrapper around [`Server::run`].
813    ///
814    /// # Example
815    ///
816    /// ```no_run
817    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
818    /// use mini_static::Server;
819    /// use std::path::Path;
820    ///
821    /// let server = Server::new(Path::new("./public"))?;
822    /// let (port, handle) = server.run_ephemeral().await?;
823    /// println!("Server ready on http://127.0.0.1:{}", port);
824    /// handle.shutdown().await;
825    /// # Ok(())
826    /// # }
827    /// ```
828    pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
829        self.run(DEFAULT_HEADER_TIMEOUT).await
830    }
831
832    /// Produce the HTTP response for a request, streaming file bodies to the client.
833    ///
834    /// This is the crate's single request-handling path: the `run*` accept loop calls it,
835    /// and so should any async server embedding `mini-static` as a fallback route (e.g.
836    /// `mini-unified`).
837    ///
838    /// Filesystem metadata work (path resolution, `open`, `stat`) runs *inline* on the
839    /// calling task, deliberately. Until 0.30.0 it was dispatched to Tokio's blocking
840    /// pool so a slow filesystem could not stall co-scheduled tasks — measured under
841    /// load, that dispatch cost roughly three times the syscalls it sheltered, and a
842    /// one-worker server burned nearly four cores on pool handoff. On the local-disk
843    /// deployments this crate targets these calls are single-digit microseconds; an
844    /// embedder serving from a filesystem with unbounded latency (a network mount)
845    /// should use a multi-threaded runtime, which bounds the blast radius of a stall
846    /// to one worker.
847    ///
848    /// File responses are backed by `FileBody`, which hands hyper one 64 KB chunk at a
849    /// time as `poll_frame` is driven: memory use stays bounded to one chunk per in-flight
850    /// response regardless of file size.
851    ///
852    /// `headers` are the request's headers; `If-None-Match` (304 on a matching ETag) and
853    /// `Accept-Encoding` (precompressed sidecar selection, see [`Server::run_on`]) are the
854    /// ones read today. Only `GET` and `HEAD` are allowed; anything else gets a 405 with an
855    /// `Allow` header. Missing files and traversal attempts both get an identical 404, so a
856    /// response never discloses whether a path exists outside the root.
857    pub async fn handle_request(
858        &self,
859        method: &Method,
860        request_path: &str,
861        headers: &HeaderMap,
862    ) -> Response<ResponseBody> {
863        self.serve(method, request_path, headers, None::<&[String]>).await
864    }
865
866    /// Serve a request whose path a router has already split and decoded.
867    ///
868    /// The engine, without a server around it. Every type here belongs to `http`/`hyper`,
869    /// so this drops into any stack that speaks them — it is not a `mini-serve` adapter.
870    ///
871    /// `segments` decide **which file is opened**; the request's raw path is used only to
872    /// echo back into a `Location` redirect, and never to resolve anything. That division
873    /// is the point: a redirect must preserve the client's own encoding (`/my%20docs` →
874    /// `/my%20docs/`, since re-encoding is not a safe round-trip — `%41` would return as
875    /// `A`), while resolution must use exactly the segments the router matched on. Two
876    /// crates deriving path segments independently is what let `/admin%2Fconfig` reach a
877    /// nested file while the router in front saw one segment and matched no route.
878    ///
879    /// Segments are still checked before they touch the filesystem. Where they came from
880    /// is the caller's business; whether they can escape the root is this crate's.
881    pub async fn respond<B>(
882        &self,
883        req: &Request<B>,
884        segments: &[String],
885    ) -> Response<ResponseBody> {
886        self.serve(req.method(), req.uri().path(), req.headers(), Some(segments))
887            .await
888    }
889
890    /// One implementation behind both entry points. `segments` is `None` when this crate
891    /// owns the path and `Some` when a router already decided it.
892    async fn serve<S: AsRef<str>>(
893        &self,
894        method: &Method,
895        request_path: &str,
896        headers: &HeaderMap,
897        segments: Option<&[S]>,
898    ) -> Response<ResponseBody> {
899        if method != Method::GET && method != Method::HEAD {
900            return text(
901                self.response(StatusCode::METHOD_NOT_ALLOWED)
902                    .header("Allow", "GET, HEAD"),
903                "method not allowed\n",
904            );
905        }
906
907        // Live-reload SSE stream — only reachable when `with_live_reload()` was called
908        // and the server was started via a `run*` method (those are the only paths that
909        // populate `broadcaster`).
910        if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
911            if let Some(broadcaster) = &self.broadcaster {
912                return finish(
913                    self.response(StatusCode::OK)
914                        .header("Content-Type", "text/event-stream")
915                        .header("Cache-Control", "no-cache")
916                        .header("Connection", "keep-alive")
917                        .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
918                );
919            }
920        }
921
922        // Inline on purpose — see this function's doc comment for the measured case
923        // against the old `spawn_blocking` dispatch. One call opens the file and proves
924        // containment on the opened fd, so there is no separate open to fail later and
925        // no window between the check and the handle that gets served.
926        let opened = match segments {
927            Some(segments) => {
928                resolve::open_segments(&self.root_canon, segments, request_path, self.hidden_files)
929            }
930            None => resolve::open_with_policy(&self.root_canon, request_path, self.hidden_files),
931        };
932        let resolved = match opened {
933            Err(e) => return self.not_found_response(e.user_message()).await,
934            Ok(resolved) => resolved,
935        };
936        let (std_file, metadata, path) = (resolved.file, resolved.metadata, resolved.path);
937
938        // A directory served via its `index.html` needs a trailing slash to establish the
939        // correct base for the page's relative links. Compare against the *decoded*
940        // request path so a percent-encoded explicit request for index.html (e.g.
941        // `/docs/index.htm%6c`) is recognized as such instead of producing a redirect to a
942        // still-encoded, broken Location.
943        // Compared segment-wise through the same decoder resolution used, so a
944        // percent-encoded explicit request for index.html (e.g. `/docs/index.htm%6c`) is
945        // recognised as such instead of redirecting to a still-encoded, broken Location.
946        // A trailing slash is read from the raw path: `%2F` is no longer a separator, so
947        // a real trailing slash is the only thing that can produce one.
948        let last_segment = resolve::decode_segments(request_path)
949            .ok()
950            .and_then(|segments| segments.last().cloned())
951            .unwrap_or_default();
952        if path.file_name().is_some_and(|name| name == "index.html")
953            && !request_path.ends_with('/')
954            && last_segment != "index.html"
955        {
956            // `location` is built from the (attacker-controlled) request path; `finish()`
957            // degrades to 400 instead of panicking if it ever contains bytes invalid in a
958            // header value.
959            let location = format!("{}/", request_path.trim_end_matches('/'));
960            return text(
961                self.response(StatusCode::MOVED_PERMANENTLY)
962                    .header("Location", location),
963                "moved\n",
964            );
965        }
966
967        let content_type = mime_type_for_path(&path);
968        // Live-reload and spa-mode HTML injection both need the original, uncompressed
969        // bytes to splice their script into — never substitute a precompressed sidecar on
970        // this path. `broadcaster` is only `Some` when live-reload is enabled (see
971        // `Server::with_live_reload`); `spa_mode` is independent of it (see
972        // `Server::with_spa_mode`/`with_spa_root`) — either alone is enough to trigger
973        // injection.
974        // Injection reads the whole file into memory, so it is also gated on size. Every
975        // decision keyed off `html_injection` — the sidecar skip below, the range skip,
976        // the full read itself — inherits the cap from this one boolean, so an over-cap
977        // page takes the ordinary streamed path with no second decision point.
978        let wants_injection =
979            (self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html");
980        let html_injection = wants_injection && metadata.len() <= MAX_INJECTABLE_HTML_BYTES;
981
982        if wants_injection && !html_injection {
983            self.log(format_args!(
984                "html injection skipped for {request_path}: {} bytes exceeds the \
985                 {MAX_INJECTABLE_HTML_BYTES}-byte limit; serving unmodified",
986                metadata.len(),
987            ));
988        }
989
990        let range_header = header_str(headers, "range");
991        let if_range_header = header_str(headers, "if-range");
992
993        let accept_encoding = header_str(headers, "accept-encoding");
994        // Skip precompressed sidecars when Range is requested (serve original file instead).
995        let sidecar = if html_injection || range_header.is_some() {
996            None
997        } else {
998            select_precompressed_sidecar(&path, accept_encoding)
999        };
1000        let (mut std_file, metadata, content_encoding) = match sidecar {
1001            Some((sidecar_file, sidecar_metadata, encoding)) => {
1002                (sidecar_file, sidecar_metadata, Some(encoding))
1003            }
1004            None => (std_file, metadata, None),
1005        };
1006        // The handle stays synchronous until a body actually streams: every whole-file
1007        // read below (HTML injection, small bodies) is cheaper inline than as a
1008        // blocking-pool round trip, and only `FileBody` needs an async `File`.
1009
1010        // HTML injection is skipped for a served precompressed sidecar (already final
1011        // bytes from a build step) — see `html_injection`'s definition above.
1012        let etag = generate_etag(&metadata);
1013        let cache_control = self.cache_control_for(&path);
1014
1015        if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
1016            return finish(
1017                self.response(StatusCode::NOT_MODIFIED)
1018                    .header("Cache-Control", cache_control)
1019                    .header("Vary", "Accept-Encoding")
1020                    .header("ETag", etag)
1021                    .header("Accept-Ranges", "bytes")
1022                    .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1023            );
1024        }
1025
1026        // `Some` when the served representation differs from the file's raw bytes and had
1027        // to be built in memory; `None` means stream the open file as-is. Computed before
1028        // the HEAD check below because RFC 9110 requires a HEAD response's headers —
1029        // `Content-Length` included — to match what a GET would send, even though the body
1030        // itself is dropped.
1031        let transformed: Option<Bytes> = if html_injection {
1032            let mut html = Vec::with_capacity(metadata.len() as usize);
1033            use std::io::Read as _;
1034            if std_file.read_to_end(&mut html).is_err() {
1035                return internal_error_response();
1036            }
1037            if self.broadcaster.is_some() {
1038                reload::inject_reload_script(&mut html);
1039            }
1040            if self.spa_mode {
1041                spa::inject_spa_script(&mut html, self.spa_root.as_deref(), &self.spa_transition);
1042            }
1043            Some(Bytes::from(html))
1044        } else {
1045            None
1046        };
1047
1048        let file_size = transformed
1049            .as_ref()
1050            .map_or(metadata.len(), |bytes| bytes.len() as u64);
1051
1052        // Handle Range requests.
1053        let range_outcome = range_header.map(|h| parse_range_header(h, file_size));
1054        let range_check = if let Some(outcome) = &range_outcome {
1055            match outcome {
1056                RangeOutcome::Satisfiable(start, end) => {
1057                    // If-Range validation: stale If-Range ignores Range, serves full 200.
1058                    if let Some(if_range) = if_range_header {
1059                        if !if_range_valid(if_range, &etag) {
1060                            RangeCheck::IgnoreRange
1061                        } else {
1062                            RangeCheck::Satisfiable(*start, *end)
1063                        }
1064                    } else {
1065                        RangeCheck::Satisfiable(*start, *end)
1066                    }
1067                }
1068                RangeOutcome::MultiRangeIgnored => RangeCheck::IgnoreRange,
1069                RangeOutcome::Unsatisfiable => RangeCheck::Unsatisfiable,
1070                RangeOutcome::NoRange => RangeCheck::IgnoreRange,
1071            }
1072        } else {
1073            RangeCheck::IgnoreRange
1074        };
1075
1076        match &range_check {
1077            RangeCheck::Unsatisfiable => {
1078                return finish(
1079                    Response::builder()
1080                        .status(StatusCode::RANGE_NOT_SATISFIABLE)
1081                        .header("Content-Range", format!("bytes */{}", file_size))
1082                        .header("Accept-Ranges", "bytes")
1083                        .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1084                );
1085            }
1086            RangeCheck::Satisfiable(start, end) => {
1087                let range_len = end - start + 1;
1088
1089                // Seek to start position; if sidecar, we already skipped it above.
1090                if transformed.is_none()
1091                    && std::io::Seek::seek(&mut std_file, std::io::SeekFrom::Start(*start)).is_err()
1092                {
1093                    return internal_error_response();
1094                }
1095
1096                // HEAD must not return a body (RFC 9110).
1097                let body = if *method == Method::HEAD {
1098                    ResponseBody::Buffered(Full::new(Bytes::new()))
1099                } else {
1100                    match transformed {
1101                        Some(ref bytes) => ResponseBody::Buffered(Full::new(
1102                            bytes.slice(*start as usize..(*end as usize + 1)),
1103                        )),
1104                        None => ResponseBody::Streamed(FileBody::new_ranged(
1105                            File::from_std(std_file),
1106                            range_len,
1107                        )),
1108                    }
1109                };
1110
1111                let mut builder = Response::builder()
1112                    .status(StatusCode::PARTIAL_CONTENT)
1113                    .header("Content-Type", content_type)
1114                    .header("Content-Length", range_len.to_string())
1115                    .header(
1116                        "Content-Range",
1117                        format!("bytes {}-{}/{}", start, end, file_size),
1118                    )
1119                    .header("Cache-Control", cache_control)
1120                    .header("Vary", "Accept-Encoding")
1121                    .header("ETag", etag)
1122                    .header("Accept-Ranges", "bytes");
1123                if let Some(encoding) = content_encoding {
1124                    builder = builder.header("Content-Encoding", encoding);
1125                }
1126                return finish(builder.body(body));
1127            }
1128            RangeCheck::IgnoreRange => {}
1129        }
1130
1131        // HEAD must not return a body (RFC 9110).
1132        let body = if *method == Method::HEAD {
1133            ResponseBody::Buffered(Full::new(Bytes::new()))
1134        } else {
1135            match transformed {
1136                Some(bytes) => ResponseBody::Buffered(Full::new(bytes)),
1137                None if metadata.len() <= INLINE_BODY_BYTES => {
1138                    // From the handle opened above — never by re-opening the path — so
1139                    // the bytes served are provably the file that was probed and
1140                    // stat'd, sidecars included, with no reopen window in between.
1141                    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1142                    use std::io::Read as _;
1143                    if std_file.read_to_end(&mut bytes).is_err() {
1144                        return internal_error_response();
1145                    }
1146                    ResponseBody::Buffered(Full::new(Bytes::from(bytes)))
1147                }
1148                None => ResponseBody::Streamed(FileBody::new(File::from_std(std_file))),
1149            }
1150        };
1151
1152        let mut builder = self
1153            .response(StatusCode::OK)
1154            .header("Content-Type", content_type)
1155            .header("Content-Length", file_size.to_string())
1156            .header("Cache-Control", cache_control)
1157            .header("Vary", "Accept-Encoding")
1158            .header("ETag", etag)
1159            .header("Accept-Ranges", "bytes");
1160        if let Some(encoding) = content_encoding {
1161            builder = builder.header("Content-Encoding", encoding);
1162        }
1163        finish(builder.body(body))
1164    }
1165}
1166
1167/// Ceiling on how many bytes hyper buffers for a single request's header block before
1168/// rejecting it. Without this, a client that trickles bytes forever without ever sending
1169/// the terminating blank line could grow the buffer without limit — the header-read
1170/// timeout alone doesn't bound memory, only wall-clock time, and a sufficiently patient
1171/// sender could still send unbounded data before the deadline fires.
1172const MAX_HEADER_BYTES: usize = 64 * 1024;
1173
1174/// Ceiling on the size of an HTML file this server will buffer in memory to splice a
1175/// live-reload or spa-mode `<script>` into.
1176///
1177/// Injection is the one code path that reads a whole file into memory rather than
1178/// streaming it in bounded chunks, and it does so *per request* — so without a cap, a
1179/// single large HTML file turns every concurrent request for it into another full copy
1180/// in memory, and spa-mode is a production feature, not a development-only one. An
1181/// over-cap page is served unmodified (and streamed) instead: losing a client-side
1182/// navigation enhancement on an 8 MiB document is a far smaller failure than an
1183/// allocation proportional to file size times concurrency.
1184///
1185/// 8 MiB is comfortably above any hand-written HTML page and any realistic
1186/// static-site-generator output, so the cap should never fire on content this feature
1187/// was designed for.
1188const MAX_INJECTABLE_HTML_BYTES: u64 = 8 * 1024 * 1024;
1189
1190/// Bodies at or below this size are read synchronously and served from memory; larger
1191/// ones stream through `FileBody`. Equal to `FileBody`'s chunk size on purpose: at or
1192/// under one chunk the streaming path performed exactly one read anyway, so buffering
1193/// changes only *where* that read runs (inline, instead of a blocking-pool round trip
1194/// per chunk) — never how much memory a response can hold.
1195const INLINE_BODY_BYTES: u64 = 64 * 1024;
1196
1197/// The address [`Server::run`] and [`Server::run_ephemeral`] bind to.
1198///
1199/// Loopback, deliberately: a convenience entry point must not put a server on the LAN
1200/// because the caller did not think to say otherwise. Exposing the service is
1201/// [`Server::run_on`]'s job, where the address is written at the call site and visible in
1202/// review. Named rather than inlined so a test can assert the choice — the previous test
1203/// only checked that loopback *reached* the server, which is equally true of `0.0.0.0`.
1204const EPHEMERAL_BIND_IP: std::net::Ipv4Addr = std::net::Ipv4Addr::LOCALHOST;
1205
1206/// Bridge this crate's response body to `mini-serve`'s.
1207///
1208/// `ResponseBody` stays a concrete enum so the streaming paths keep their own types; this
1209/// is the single place it is type-erased. The error remap matters as much as the erasure:
1210/// a mid-stream disk failure must abort the connection rather than being dropped, which
1211/// would send a truncated body under a `200`.
1212fn bridge_body(response: Response<ResponseBody>) -> Response<mini_serve::ResponseBody> {
1213    let (parts, body) = response.into_parts();
1214    let erased = http_body_util::BodyExt::map_err(body, mini_serve::BodyError::new);
1215    Response::from_parts(parts, http_body_util::BodyExt::boxed(erased))
1216}
1217
1218/// Default header-read timeout used by [`Server::run_ephemeral`].
1219const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
1220
1221/// Default grace period `ServerHandle::shutdown()` waits for in-flight connections to
1222/// finish on their own before aborting whatever is left. A connection with no
1223/// self-imposed end — an open live-reload SSE stream, or any keep-alive connection whose
1224/// peer simply never closes it — would otherwise let `shutdown()` hang forever waiting
1225/// for it to finish naturally. Every wait in this crate has a stated upper bound;
1226/// shutdown is no exception.
1227const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1228
1229/// A handle to a server started by one of the `Server::run*` methods.
1230///
1231/// Dropping this handle without calling `shutdown()` leaves the server running in the
1232/// background for the life of the process. Call `shutdown()` to stop accepting new
1233/// connections and wait for already-accepted connections to finish before returning.
1234pub struct ServerHandle {
1235    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1236    accept_task: tokio::task::JoinHandle<()>,
1237}
1238
1239impl ServerHandle {
1240    /// Stop accepting new connections and wait up to `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT`
1241    /// (5s) for in-flight connections to finish on their own. Equivalent to
1242    /// `shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)` — see that method for what
1243    /// happens to connections still open once the grace period elapses.
1244    pub async fn shutdown(self) {
1245        self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1246            .await;
1247    }
1248
1249    /// Stop accepting new connections and wait up to `drain_timeout` for in-flight
1250    /// connections to finish on their own.
1251    ///
1252    /// Connections still open once `drain_timeout` elapses are aborted rather than
1253    /// waited on further: dropping the accept task drops its `JoinSet`, which aborts
1254    /// every task still tracked in it (see `tokio::task::JoinSet`'s own drop behavior) —
1255    /// which in turn drops each connection's socket, closing it. This is what bounds
1256    /// shutdown when a connection has no natural end of its own (the live-reload SSE
1257    /// stream is the motivating case: it stays open until a watched file changes, which
1258    /// may never happen before the process needs to exit).
1259    pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1260        if let Some(tx) = self.shutdown_tx.take() {
1261            let _ = tx.send(());
1262        }
1263        if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1264            self.accept_task.abort();
1265        }
1266    }
1267}
1268
1269/// Read a request header as a `&str`, or `None` if it's absent or not valid ASCII.
1270fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1271    headers.get(name).and_then(|value| value.to_str().ok())
1272}
1273
1274/// Start a response carrying the baseline security header every response in this crate
1275/// sends — 304s included. A 304 otherwise repeats only the caching validators, which is
1276/// why it once built its own builder and was the single response able to arrive without
1277/// `nosniff`; a client that caches the header set alongside the representation would
1278/// then hold a copy missing it.
1279///
1280/// Prefer [`Server::response`], which also applies the embedder's configured headers.
1281/// This bare form exists for `bad_request_response`, which is reachable from `finish`
1282/// where no `Server` is in scope.
1283fn response(status: StatusCode) -> Builder {
1284    Response::builder()
1285        .status(status)
1286        .header("X-Content-Type-Options", "nosniff")
1287}
1288
1289/// Finish `builder` with a plain-text body. `&'static str` bodies borrow rather than
1290/// allocate; owned bodies are moved in.
1291fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1292    finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1293}
1294
1295/// Finishes building a response, degrading to a generic 400 instead of panicking if any
1296/// header value turns out to be invalid for use as an HTTP header value.
1297///
1298/// Every header value that reaches `Response::builder()` in this module is either a
1299/// static string or formatted from internal, already-validated data (a byte count, an
1300/// mtime, a fixed method list) — none of it can actually fail today. But `.unwrap()`
1301/// on that assumption is exactly the kind of thing that turns "can't happen" into a
1302/// production panic the day someone adds a header built from new input without
1303/// re-deriving that guarantee. Routing every response through this one fallible path
1304/// means that mistake fails safe instead of panicking.
1305fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1306    built.unwrap_or_else(|_| bad_request_response())
1307}
1308
1309// `internal_error_response()` and `bad_request_response()` are the fallback responses
1310// `finish()` itself degrades to — every header and body here is a fixed string with no
1311// external input, so `.body(...)` cannot fail. They can't be routed through `finish()`
1312// without it degrading to itself on failure.
1313fn internal_error_response() -> Response<ResponseBody> {
1314    response(StatusCode::INTERNAL_SERVER_ERROR)
1315        .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1316            b"internal server error\n",
1317        ))))
1318        .unwrap()
1319}
1320
1321fn bad_request_response() -> Response<ResponseBody> {
1322    response(StatusCode::BAD_REQUEST)
1323        .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1324            b"bad request\n",
1325        ))))
1326        .unwrap()
1327}
1328
1329/// `Content-Encoding` name and sidecar file extension for each supported precompressed
1330/// variant, in preference order — brotli wins when a client accepts both and both
1331/// sidecars exist.
1332const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1333
1334/// `q`-values are carried in thousandths — RFC 9110 allows at most three decimal places
1335/// — so weights compare exactly as integers instead of through float equality.
1336const QVALUE_SCALE: f32 = 1000.0;
1337
1338/// An `Accept-Encoding` entry with no explicit `q` parameter has weight 1.
1339const DEFAULT_QVALUE: u16 = 1000;
1340
1341/// The weight `accept_encoding` gives `encoding`, or `None` if it does not list it.
1342///
1343/// Entries are matched as whole tokens, case-insensitively, per RFC 9110 — not by
1344/// substring. The substring form this replaces got two things wrong that a client can
1345/// trigger: `Accept-Encoding: gzip;q=0` selected gzip, because the header *contains*
1346/// "gzip" while explicitly refusing it, and a token like `brotli` matched `br`.
1347///
1348/// `*` is deliberately not honored: treating the wildcard as matching nothing can only
1349/// cost a bandwidth optimization, while treating it as matching everything risks sending
1350/// an encoding the client did not ask for. The conservative reading is the safe one when
1351/// the payoff is choosing between two static files.
1352fn encoding_quality(accept_encoding: &str, encoding: &str) -> Option<u16> {
1353    accept_encoding.split(',').find_map(|entry| {
1354        let mut parts = entry.split(';');
1355        if !parts.next()?.trim().eq_ignore_ascii_case(encoding) {
1356            return None;
1357        }
1358
1359        let quality = parts
1360            .find_map(|parameter| {
1361                let (key, value) = parameter.split_once('=')?;
1362                key.trim().eq_ignore_ascii_case("q").then(|| value.trim())
1363            })
1364            .and_then(|value| value.parse::<f32>().ok())
1365            .map(|value| (value.clamp(0.0, 1.0) * QVALUE_SCALE).round() as u16)
1366            .unwrap_or(DEFAULT_QVALUE);
1367
1368        Some(quality)
1369    })
1370}
1371
1372/// Look for a precompressed sidecar (`<path>.br` / `<path>.gz`) matching the client's
1373/// `Accept-Encoding`, and return its open file, metadata, and encoding name if found.
1374///
1375/// `path` must already be the fully resolved, canonicalized path `resolve()` produced.
1376/// The sidecar path is built by appending an extension to it — never by re-resolving a
1377/// modified request path — so this lookup can't become a second traversal surface: any
1378/// path this function reads is provably a sibling of a path `resolve()` already cleared.
1379fn select_precompressed_sidecar(
1380    path: &Path,
1381    accept_encoding: Option<&str>,
1382) -> Option<(std::fs::File, fs::Metadata, &'static str)> {
1383    // Highest `q` first; ties keep `SIDECAR_ENCODINGS` order (brotli over gzip) because
1384    // the sort is stable. Without this, `br;q=0.5, gzip` would serve brotli purely
1385    // because it is listed first here, ignoring the preference the client stated.
1386    let mut candidates: Vec<(&'static str, &'static str, u16)> = SIDECAR_ENCODINGS
1387        .iter()
1388        .filter_map(|(encoding, ext)| {
1389            let quality = accept_encoding.and_then(|header| encoding_quality(header, encoding))?;
1390            (quality > 0).then_some((*encoding, *ext, quality))
1391        })
1392        .collect();
1393    candidates.sort_by_key(|(_, _, quality)| std::cmp::Reverse(*quality));
1394
1395    for (encoding, ext, _) in candidates {
1396        let mut sidecar = path.as_os_str().to_os_string();
1397        sidecar.push(ext);
1398        let sidecar_path = PathBuf::from(sidecar);
1399
1400        // Tripwire for the traversal boundary: a sidecar path built by appending a suffix
1401        // must stay in the same directory as `path` (which `resolve()` already proved is
1402        // inside root). `ext` is always one of the two static literals in
1403        // `SIDECAR_ENCODINGS`, never derived from request input, so this can only fire if
1404        // a future change starts deriving `sidecar` some other way.
1405        debug_assert_eq!(
1406            sidecar_path.parent(),
1407            path.parent(),
1408            "sidecar path must stay in the same directory as the already-resolved path"
1409        );
1410
1411        // Synchronous, and it stays an *open* rather than a cheaper `stat`: handing back
1412        // an already-open file is what keeps there being no gap between probing the
1413        // sidecar and serving it. Browsers send `Accept-Encoding` on every request, so
1414        // this probe is the common path — as `tokio::fs` opens, two misses per request
1415        // kept the blocking pool hot for files that do not exist.
1416        if let Ok(sidecar_std) = std::fs::File::open(&sidecar_path) {
1417            if let Ok(sidecar_metadata) = sidecar_std.metadata() {
1418                return Some((sidecar_std, sidecar_metadata, encoding));
1419            }
1420        }
1421    }
1422    None
1423}
1424
1425/// Generate an ETag for a file based on modification time and size.
1426///
1427/// Format: `"<size>-<mtime_secs>.<mtime_nanos>"`.
1428///
1429/// The sub-second component is what makes this crate's choice to serve an ETag *instead*
1430/// of `Last-Modified`/`If-Modified-Since` sound. That choice rests on an ETag being able
1431/// to distinguish representations a whole-second timestamp cannot — two writes inside the
1432/// same second — which a whole-second ETag plainly cannot do either: rewriting a file
1433/// within a second of its last write, without changing its length, reproduced the
1434/// previous ETag exactly and every revalidating client was told `304 Not Modified` while
1435/// holding stale bytes. Build pipelines that rewrite generated assets are the realistic
1436/// way to hit that, and this crate ships one.
1437///
1438/// A filesystem whose timestamps are only second-granular gives `subsec_nanos() == 0`
1439/// and the same behavior as before — no worse, and no false confidence beyond what the
1440/// filesystem actually provides.
1441fn generate_etag(metadata: &fs::Metadata) -> String {
1442    let mtime = metadata
1443        .modified()
1444        .ok()
1445        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1446        .unwrap_or_default();
1447    format!(
1448        "\"{}-{}.{}\"",
1449        metadata.len(),
1450        mtime.as_secs(),
1451        mtime.subsec_nanos()
1452    )
1453}
1454
1455/// Determine MIME type from file path extension.
1456fn mime_type_for_path(path: &Path) -> &'static str {
1457    let ext = path
1458        .extension()
1459        .and_then(|ext| ext.to_str())
1460        .unwrap_or_default()
1461        .to_lowercase();
1462
1463    match ext.as_str() {
1464        "html" | "htm" => "text/html; charset=utf-8",
1465        "css" => "text/css; charset=utf-8",
1466        "js" => "application/javascript; charset=utf-8",
1467        "json" => "application/json; charset=utf-8",
1468        "svg" => "image/svg+xml",
1469        "png" => "image/png",
1470        "jpg" | "jpeg" => "image/jpeg",
1471        "gif" => "image/gif",
1472        "webp" => "image/webp",
1473        "ico" => "image/x-icon",
1474        "woff" => "font/woff",
1475        "woff2" => "font/woff2",
1476        "ttf" => "font/ttf",
1477        "md" | "markdown" => "text/markdown; charset=utf-8",
1478        "txt" => "text/plain; charset=utf-8",
1479        "xml" => "application/xml",
1480        "pdf" => "application/pdf",
1481        "zip" => "application/zip",
1482        _ => "application/octet-stream",
1483    }
1484}
1485
1486/// Check if the If-None-Match header matches the current ETag.
1487/// Handles both exact match and wildcard (*) comparison per RFC 9110.
1488fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1489    if if_none_match == "*" {
1490        return true;
1491    }
1492    if_none_match.split(',').any(|tag| tag.trim() == etag)
1493}
1494
1495#[derive(Debug)]
1496enum RangeOutcome {
1497    NoRange,
1498    Satisfiable(u64, u64),
1499    Unsatisfiable,
1500    MultiRangeIgnored,
1501}
1502
1503enum RangeCheck {
1504    IgnoreRange,
1505    Satisfiable(u64, u64),
1506    Unsatisfiable,
1507}
1508
1509fn parse_range_header(header: &str, file_size: u64) -> RangeOutcome {
1510    let header = header.trim();
1511    if !header.starts_with("bytes=") {
1512        return RangeOutcome::NoRange;
1513    }
1514
1515    let range_spec = &header[6..];
1516
1517    if range_spec.contains(',') {
1518        return RangeOutcome::MultiRangeIgnored;
1519    }
1520
1521    if let Some(suffix_pos) = range_spec.find('-') {
1522        if suffix_pos == 0 {
1523            let suffix_len_str = &range_spec[1..];
1524            if let Ok(suffix_len) = suffix_len_str.parse::<u64>() {
1525                if suffix_len == 0 {
1526                    return RangeOutcome::Unsatisfiable;
1527                }
1528                if suffix_len >= file_size {
1529                    return RangeOutcome::Satisfiable(0, file_size - 1);
1530                }
1531                return RangeOutcome::Satisfiable(file_size - suffix_len, file_size - 1);
1532            }
1533            return RangeOutcome::Unsatisfiable;
1534        }
1535
1536        let start_str = &range_spec[..suffix_pos];
1537        let end_str = &range_spec[suffix_pos + 1..];
1538
1539        if let Ok(start) = start_str.parse::<u64>() {
1540            if start >= file_size {
1541                return RangeOutcome::Unsatisfiable;
1542            }
1543
1544            if end_str.is_empty() {
1545                return RangeOutcome::Satisfiable(start, file_size - 1);
1546            }
1547
1548            if let Ok(end) = end_str.parse::<u64>() {
1549                if end < start {
1550                    return RangeOutcome::Unsatisfiable;
1551                }
1552                let clamped_end = (end + 1).min(file_size) - 1;
1553                if start > clamped_end {
1554                    return RangeOutcome::Unsatisfiable;
1555                }
1556                return RangeOutcome::Satisfiable(start, clamped_end);
1557            }
1558        }
1559    }
1560
1561    RangeOutcome::Unsatisfiable
1562}
1563
1564fn if_range_valid(if_range_header: &str, current_etag: &str) -> bool {
1565    if_range_header.trim() == current_etag
1566}
1567
1568#[cfg(test)]
1569mod bind_address_tests {
1570    use super::EPHEMERAL_BIND_IP;
1571
1572    /// `run`/`run_ephemeral` must never expose the server beyond loopback.
1573    #[test]
1574    fn the_ephemeral_bind_address_is_loopback() {
1575        assert!(
1576            EPHEMERAL_BIND_IP.is_loopback(),
1577            "run_ephemeral would expose the server on {EPHEMERAL_BIND_IP}"
1578        );
1579    }
1580}
1581
1582#[cfg(test)]
1583#[path = "../tests/unit/server/precompressed_sidecar.rs"]
1584mod precompressed_sidecar_tests;
1585
1586#[cfg(test)]
1587#[path = "../tests/unit/server/file_body.rs"]
1588mod file_body_tests;
1589
1590#[cfg(test)]
1591#[path = "../tests/unit/server/finish.rs"]
1592mod finish_tests;
1593
1594#[cfg(test)]
1595#[path = "../tests/unit/server/etag.rs"]
1596mod etag_tests;
1597
1598#[cfg(test)]
1599#[path = "../tests/unit/server/range_header.rs"]
1600mod range_header_tests;