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