Skip to main content

mini_static/
server.rs

1use std::convert::Infallible;
2use std::fs;
3use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::sync::Arc;
7use std::task::{Context, Poll};
8use std::time::{Duration, SystemTime};
9
10use bytes::Bytes;
11use http_body_util::Full;
12use hyper::body::Incoming;
13use hyper::http::response::Builder;
14use hyper::service::service_fn;
15use hyper::{HeaderMap, Method, Request, Response, StatusCode};
16use hyper_util::rt::TokioExecutor;
17use hyper_util::rt::TokioIo;
18use hyper_util::server::conn::auto::Builder as AutoBuilder;
19use tokio::fs::File;
20use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, ReadBuf};
21use tokio::net::{TcpListener, TcpStream};
22use tokio::sync::{OwnedSemaphorePermit, Semaphore};
23use tokio::time::timeout;
24
25use crate::css::{CssOptions, CssTool};
26use crate::error::StaticError;
27use crate::handler::{FileBody, ResponseBody};
28use crate::js::{JsOptions, JsTool};
29use crate::reload::{self, SseBody};
30use crate::resolve;
31use crate::source::SourcePipeline;
32use crate::spa;
33use crate::tool;
34use crate::watcher::{start_watching, Broadcaster};
35
36const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
37const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
38
39/// Default maximum concurrent connections, overridable via `Server::with_max_connections`.
40const DEFAULT_MAX_CONNECTIONS: usize = 1024;
41
42/// A source of accepted TCP connections. Abstracted so the accept-error backoff below
43/// can be exercised against a listener that fails on demand, without needing to provoke
44/// real OS-level accept errors (e.g. EMFILE) in tests.
45trait TcpAccept {
46    async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
47}
48
49impl TcpAccept for TcpListener {
50    async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
51        TcpListener::accept(self).await
52    }
53}
54
55/// Accept a connection and reserve it a connection-limit permit.
56///
57/// `backoff` retries a failed `accept()` after an exponentially growing delay (reset on
58/// the next success, capped at `ACCEPT_BACKOFF_MAX`) instead of ending the accept loop,
59/// so a sustained failure — the process being out of file descriptors, say — degrades
60/// into periodic retries rather than a CPU-bound busy spin or a permanently dead server.
61///
62/// Returns `None` only if the semaphore itself has been closed (never happens in normal
63/// operation, since nothing ever calls `close()` on it — handled so a caller can still
64/// fail safely rather than panic).
65async fn accept_and_permit<L: TcpAccept>(
66    listener: &L,
67    backoff: &mut Duration,
68    semaphore: &Arc<Semaphore>,
69) -> Option<(TcpStream, OwnedSemaphorePermit)> {
70    loop {
71        let stream = match listener.accept().await {
72            Ok((stream, _)) => {
73                *backoff = ACCEPT_BACKOFF_INITIAL;
74                stream
75            }
76            Err(_) => {
77                tokio::time::sleep(*backoff).await;
78                *backoff = (*backoff * 2).min(ACCEPT_BACKOFF_MAX);
79                continue;
80            }
81        };
82        return semaphore
83            .clone()
84            .acquire_owned()
85            .await
86            .ok()
87            .map(|permit| (stream, permit));
88    }
89}
90
91/// A predicate deciding whether a resolved file path should get an immutable cache
92/// policy; see [`Server::with_immutable_assets`].
93type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
94
95/// A static file server for serving files securely from a root directory.
96///
97/// `Server` canonicalizes the root directory once at creation time and uses the
98/// canonical form for all subsequent requests, avoiding repeated filesystem calls.
99///
100/// # Security
101///
102/// The server protects against:
103/// - Path traversal attacks (e.g., `../../etc/passwd`)
104/// - Accessing files outside the root via symlinks
105/// - Disclosing filesystem structure (traversal and missing files both return 404)
106///
107/// # Cloning
108///
109/// `Server` is cheap to clone: a `PathBuf`, a couple of primitives, and an `Arc`'d
110/// predicate closure. Multiple clones can be used concurrently in async tasks without
111/// synchronization overhead.
112///
113/// # Example
114///
115/// ```no_run
116/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
117/// use mini_static::Server;
118/// use std::path::Path;
119/// use std::time::Duration;
120///
121/// let server = Server::new(Path::new("./public"))?;
122/// let (port, _handle) = server.run(Duration::from_secs(30)).await?;
123/// println!("Server running on port {}", port);
124/// # Ok(())
125/// # }
126/// ```
127#[derive(Clone)]
128pub struct Server {
129    root_canon: PathBuf,
130    bundle_roots: Vec<PathBuf>,
131    max_connections: usize,
132    live_reload: bool,
133    broadcaster: Option<Broadcaster>,
134    spa_mode: bool,
135    spa_root: Option<String>,
136    immutable_predicate: Option<ImmutablePredicate>,
137    source_folders: Vec<PathBuf>,
138    asset_folders: Vec<PathBuf>,
139    output_dir: PathBuf,
140    css_tool: Option<(CssTool, CssOptions)>,
141    js_tool: Option<(JsTool, JsOptions)>,
142    prune_output: bool,
143}
144
145/// True when two canonical paths are the same path or one contains the other.
146///
147/// Used by the source/output overlap check: `Path::starts_with` compares component-wise, so
148/// `/a/b` neither equals nor contains their sibling `/a/b/c` itself. This is the single
149/// place the overlap rule is defined, so both `with_source_folder` and `with_output_dir`
150/// cannot drift apart.
151fn paths_overlap(a: &Path, b: &Path) -> bool {
152    a.starts_with(b) || b.starts_with(a)
153}
154
155impl Server {
156    /// Create a new server with the given root directory.
157    ///
158    /// Canonicalizes the root once at startup. All subsequent requests use the
159    /// canonical root without re-canonicalizing it, making this suitable for long-lived servers.
160    ///
161    /// # Errors
162    ///
163    /// Returns `Err(StaticError::Io)` if the root cannot be canonicalized (e.g., doesn't exist,
164    /// no read permissions).
165    pub fn new(root: &Path) -> Result<Self, StaticError> {
166        let root_canon = root.canonicalize().map_err(StaticError::Io)?;
167        let output_dir = root_canon.clone();
168        Ok(Server {
169            root_canon,
170            bundle_roots: Vec::new(),
171            max_connections: DEFAULT_MAX_CONNECTIONS,
172            live_reload: false,
173            broadcaster: None,
174            spa_mode: false,
175            spa_root: None,
176            immutable_predicate: None,
177            source_folders: Vec::new(),
178            asset_folders: Vec::new(),
179            output_dir,
180            css_tool: None,
181            js_tool: None,
182            prune_output: false,
183        })
184    }
185
186    /// Set the maximum number of connections served concurrently (default 1024).
187    ///
188    /// Once this many connections are in flight, `run()`'s accept loop stops accepting
189    /// new ones — without pausing the accept loop, a client that opens a connection and
190    /// sends nothing (see the header-read timeout docs on [`Server::run_on`]) could
191    /// otherwise be used, in enough parallel copies, to exhaust the process's file
192    /// descriptors or memory with no bound at all.
193    pub fn with_max_connections(mut self, max: usize) -> Self {
194        self.max_connections = max;
195        self
196    }
197
198    /// Enable live-reload for this server (disabled by default).
199    ///
200    /// Once enabled, the `run*` methods start a background watcher (mtime polling,
201    /// bounded 500ms interval — see [`crate::start_watching`]) over the server's root
202    /// the first time the server actually starts accepting connections, and:
203    ///
204    /// - serve a live-reload SSE stream at [`crate::LIVE_RELOAD_PATH`], broadcasting a
205    ///   change event (with [`crate::ChangeType`]) whenever a served file is added,
206    ///   modified, or removed;
207    /// - inject a small `<script>` into every served `text/html` response that connects
208    ///   to that stream and reloads the page (or hot-swaps stylesheet `<link>`s, for CSS
209    ///   changes) — no manual client wiring required.
210    ///
211    /// This is meant for local development, not production: leave it disabled (the
212    /// default) for any server serving real traffic. A typical call site gates it behind
213    /// `#[cfg(debug_assertions)]` so a release build never pays for the watcher or the
214    /// injected script.
215    ///
216    /// # Example
217    ///
218    /// ```no_run
219    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
220    /// use mini_static::Server;
221    /// use std::path::Path;
222    ///
223    /// let server = Server::new(Path::new("./public"))?;
224    /// #[cfg(debug_assertions)]
225    /// let server = server.with_live_reload();
226    /// # Ok(())
227    /// # }
228    /// ```
229    pub fn with_live_reload(mut self) -> Self {
230        self.live_reload = true;
231        self
232    }
233
234    /// Enable spa-mode navigation for this server, swapping `document.body` on
235    /// each navigation (disabled by default).
236    ///
237    /// Once enabled, every served `text/html` response gets a small `<script>`
238    /// injected (see [`Server::with_spa_root`] for what it does) that treats
239    /// `document.body` as the swap target. Calling this after
240    /// [`Server::with_spa_root`] does not clear a previously configured root
241    /// selector — the two methods set independent fields, so
242    /// `.with_spa_root(sel).with_spa_mode()` and
243    /// `.with_spa_mode().with_spa_root(sel)` both end up with spa-mode on and
244    /// root `sel`. Use this one alone when there's no persistent chrome to
245    /// preserve across navigations.
246    ///
247    /// # Example
248    ///
249    /// ```no_run
250    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
251    /// use mini_static::Server;
252    /// use std::path::Path;
253    ///
254    /// let server = Server::new(Path::new("./public"))?.with_spa_mode();
255    /// # Ok(())
256    /// # }
257    /// ```
258    pub fn with_spa_mode(mut self) -> Self {
259        self.spa_mode = true;
260        self
261    }
262
263    /// Enable spa-mode navigation for this server, swapping only the element
264    /// matched by the CSS `selector` on each navigation (disabled by default;
265    /// also enables spa-mode the same as [`Server::with_spa_mode`]).
266    ///
267    /// Once enabled, every served `text/html` response gets a small `<script>`
268    /// injected that intercepts left-clicks on same-origin `<a href>`
269    /// elements (skipping links with a non-`_self` `target`, a `download`
270    /// attribute, `rel="external"`, a `data-no-spa` attribute, or a same-page
271    /// hash-only href) and, instead of a normal navigation:
272    ///
273    /// - fetches the target URL;
274    /// - on a non-OK or non-`text/html` response (or a fetch error), falls
275    ///   back to a real `location.href` navigation — spa-mode never renders a
276    ///   broken page;
277    /// - otherwise replaces the matched element's `innerHTML` with the
278    ///   corresponding content from the fetched document, updates the page
279    ///   title, and pushes the new URL via `history.pushState`, animating the
280    ///   swap with `document.startViewTransition()` where supported;
281    /// - dispatches a `mini-static:navigate` `CustomEvent` on `window` after
282    ///   every client-side navigation, so page scripts can re-run any
283    ///   per-page initialization that would otherwise only execute once
284    ///   (content swapped in via `innerHTML` never executes its own
285    ///   `<script>` tags);
286    /// - handles browser back/forward by re-fetching and swapping to the new
287    ///   `location.href`.
288    ///
289    /// `selector` is matched against both the current page and the fetched
290    /// page; a link click where the selector matches neither falls back to a
291    /// real navigation, same as a fetch failure. Choose a `selector` that
292    /// wraps only the content that varies between pages, leaving persistent
293    /// chrome (nav/header/footer) outside it so it survives navigation
294    /// untouched.
295    ///
296    /// This is meant to be usable in production, not just local development
297    /// (unlike [`Server::with_live_reload`]): a click on a link mini-static
298    /// doesn't intercept, or on a browser without JS or View Transitions
299    /// support, still works as a normal navigation.
300    ///
301    /// # Example
302    ///
303    /// ```no_run
304    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
305    /// use mini_static::Server;
306    /// use std::path::Path;
307    ///
308    /// let server = Server::new(Path::new("./public"))?.with_spa_root("#app");
309    /// # Ok(())
310    /// # }
311    /// ```
312    pub fn with_spa_root(mut self, selector: &str) -> Self {
313        self.spa_mode = true;
314        self.spa_root = Some(selector.to_string());
315        self
316    }
317
318    /// Serve files matching `predicate` with a long-lived, immutable cache policy
319    /// instead of the default `Cache-Control: no-cache`.
320    ///
321    /// `predicate` is evaluated against each resolved file's path; a match sends
322    /// `Cache-Control: public, max-age=31536000, immutable` on that file's 200 and 304
323    /// responses. This is correct only for fingerprinted assets (e.g.
324    /// `main.a1b2c3.js`) where a content change always produces a new filename —
325    /// caching a mutable filename indefinitely would serve stale content to every
326    /// client that already has it cached.
327    ///
328    /// # Example
329    ///
330    /// ```no_run
331    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
332    /// use mini_static::Server;
333    /// use std::path::Path;
334    ///
335    /// let server = Server::new(Path::new("./public"))?
336    ///     .with_immutable_assets(|path| {
337    ///         path.file_name()
338    ///             .and_then(|name| name.to_str())
339    ///             .is_some_and(|name| name.contains(".fingerprint."))
340    ///     });
341    /// # Ok(())
342    /// # }
343    /// ```
344    pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
345    where
346        F: Fn(&Path) -> bool + Send + Sync + 'static,
347    {
348        self.immutable_predicate = Some(Arc::new(predicate));
349        self
350    }
351
352    /// The `Cache-Control` header value for a resolved file path: the immutable policy
353    /// if `with_immutable_assets`'s predicate matches, `no-cache` otherwise.
354    fn cache_control_for(&self, path: &Path) -> &'static str {
355        match &self.immutable_predicate {
356            Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
357            _ => "no-cache",
358        }
359    }
360
361    /// Register `path` as an additional directory whose changes should trigger a CSS
362    /// bundle rebuild, alongside the registered source folders.
363    ///
364    /// Useful for build pipelines where CSS partials referenced via `@import` live in a
365    /// separate directory tree from the source folders proper: without registering that
366    /// tree here, editing a partial wouldn't be noticed by the watcher and the bundle
367    /// would go stale until something else touched it.
368    ///
369    /// Files under `path` are never directly HTTP-servable: `Server::resolve` and the
370    /// request-handling path never consult bundle roots. This is purely a watch target,
371    /// not a second served root, and — since `@import` resolution is delegated entirely
372    /// to the configured [`CssTool`] (see [`Server::with_css_tool`]) — not an `@import`
373    /// traversal boundary either; the external tool resolves its own imports with no
374    /// root mini-static can enforce.
375    ///
376    /// This method is fallible and canonicalizes the path once at call time, matching
377    /// `Server::new`'s canonicalize-once policy. Call it multiple times to register
378    /// more than one external source tree.
379    ///
380    /// # Errors
381    ///
382    /// Returns `Err(StaticError::Io)` if the path cannot be canonicalized.
383    pub fn with_bundle_root(mut self, path: &Path) -> Result<Self, StaticError> {
384        let canon = path.canonicalize().map_err(StaticError::Io)?;
385        self.bundle_roots.push(canon);
386        Ok(self)
387    }
388
389    /// Designate `dir` as a source folder whose changes drive the build pipelines.
390    ///
391    /// Watched when `with_live_reload()` is enabled; `.css` files under it feed the single
392    /// CSS bundle, `.js`/`.mjs` files are minified per-file into the output dir.
393    ///
394    /// Rejected if `dir` overlaps the output dir or an already-registered source folder: a
395    /// source folder that is also the output would feed every pipeline its own output — the
396    /// feedback loop this layering exists to prevent.
397    ///
398    /// # Errors
399    ///
400    /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
401    /// `Err(StaticError::Traversal)` if it overlaps the output dir or another source folder.
402    pub fn with_source_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
403        let canon = dir.canonicalize().map_err(StaticError::Io)?;
404
405        if paths_overlap(&canon, &self.output_dir) {
406            return Err(StaticError::Traversal(format!(
407                "source folder {} overlaps the output dir {}",
408                canon.display(),
409                self.output_dir.display()
410            )));
411        }
412        if self
413            .source_folders
414            .iter()
415            .chain(self.asset_folders.iter())
416            .any(|existing| paths_overlap(&canon, existing))
417        {
418            return Err(StaticError::Traversal(format!(
419                "source folder {} overlaps an already-registered source/asset folder",
420                canon.display()
421            )));
422        }
423
424        self.source_folders.push(canon);
425        Ok(self)
426    }
427
428    /// Designate `dir` as an asset source folder: every file under it (any extension)
429    /// is mirrored byte-identical into the output dir at server startup and on every
430    /// live-reload change — no CSS/JS transformation, just a flat copy preserving each
431    /// file's path relative to `dir`. Use this for hand-authored static files
432    /// (`index.html`, images) that should live outside the served/output dir as
433    /// source, the same source/output separation `with_source_folder`'s CSS/JS
434    /// pipelines already have.
435    ///
436    /// Rejected if `dir` overlaps the output dir or an already-registered
437    /// source/asset folder, for the same reason `with_source_folder` rejects it: a
438    /// folder that is also the output would feed the pipeline its own output.
439    ///
440    /// # Errors
441    ///
442    /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
443    /// `Err(StaticError::Traversal)` if it overlaps the output dir or another
444    /// registered source/asset folder.
445    pub fn with_asset_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
446        let canon = dir.canonicalize().map_err(StaticError::Io)?;
447
448        if paths_overlap(&canon, &self.output_dir) {
449            return Err(StaticError::Traversal(format!(
450                "asset folder {} overlaps the output dir {}",
451                canon.display(),
452                self.output_dir.display()
453            )));
454        }
455        if self
456            .source_folders
457            .iter()
458            .chain(self.asset_folders.iter())
459            .any(|existing| paths_overlap(&canon, existing))
460        {
461            return Err(StaticError::Traversal(format!(
462                "asset folder {} overlaps an already-registered source/asset folder",
463                canon.display()
464            )));
465        }
466
467        self.asset_folders.push(canon);
468        Ok(self)
469    }
470
471    /// Designate `dir` as the output directory processed outputs are written to.
472    ///
473    /// Defaults to the served root. The output dir is never a watcher trigger: pipelines
474    /// react to source folders only, so a pipeline's own output can never re-trigger it.
475    /// Call this before `with_css_tool` so a bundle output path reflects the override.
476    ///
477    /// # Errors
478    ///
479    /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
480    /// `Err(StaticError::Traversal)` if it overlaps a registered source or asset folder.
481    pub fn with_output_dir(mut self, dir: &Path) -> Result<Self, StaticError> {
482        let canon = dir.canonicalize().map_err(StaticError::Io)?;
483
484        if self
485            .source_folders
486            .iter()
487            .chain(self.asset_folders.iter())
488            .any(|existing| paths_overlap(&canon, existing))
489        {
490            return Err(StaticError::Traversal(format!(
491                "output dir {} overlaps a registered source/asset folder",
492                canon.display()
493            )));
494        }
495
496        self.output_dir = canon;
497        Ok(self)
498    }
499
500    /// Configure CSS bundling/minification via an external tool (disabled by default).
501    ///
502    /// `tool` is a preset naming the CLI mini-static invokes (see [`CssTool`]) —
503    /// mini-static does not install or manage the binary, only looks it up on `PATH`;
504    /// [`Server::run_on`] fails fast at startup if it's missing. `options` selects
505    /// `bundle`/`minify` independently (see [`CssOptions`]):
506    ///
507    /// - Neither: every `.css` under the source folders is copied through unchanged,
508    ///   mirrored into the output dir.
509    /// - `minify` only: each file is minified independently and mirrored (no `@import`
510    ///   following).
511    /// - `bundle` only: every `.css` under the source folders is discovered,
512    ///   `@import`-resolved, and concatenated into one output file, unminified.
513    /// - Both: the bundle above, minified.
514    ///
515    /// # Example
516    ///
517    /// ```no_run
518    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
519    /// use mini_static::{CssOptions, CssTool, Server};
520    /// use std::path::Path;
521    ///
522    /// let server = Server::new(Path::new("./public"))?
523    ///     .with_css_tool(CssTool::LightningCss, CssOptions::new().bundle(true).minify(true));
524    /// # Ok(())
525    /// # }
526    /// ```
527    pub fn with_css_tool(mut self, tool: CssTool, options: CssOptions) -> Self {
528        self.css_tool = Some((tool, options));
529        self
530    }
531
532    /// Configure JS bundling/minification via an external tool (disabled by default).
533    ///
534    /// `tool` is a preset naming the CLI mini-static invokes (see [`JsTool`]) —
535    /// mini-static does not install or manage the binary, only looks it up on `PATH`;
536    /// [`Server::run_on`] fails fast at startup if it's missing. Unlike CSS, JS bundling
537    /// requires an explicit entry point ([`JsOptions::bundle_entry`]) since a JS module
538    /// graph has no well-defined "concatenate everything" meaning; without it, `options`
539    /// runs in per-file mode (every `.js`/`.mjs` under the source folders processed and
540    /// mirrored independently).
541    ///
542    /// # Errors
543    ///
544    /// Returns `Err(StaticError::Io)` if `options` specifies a bundle entry that cannot
545    /// be canonicalized, or `Err(StaticError::Traversal)` if it doesn't lie under a
546    /// registered source folder — checked eagerly here so a bad entry path fails at
547    /// configuration time, not on the first rebuild.
548    ///
549    /// # Example
550    ///
551    /// ```no_run
552    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
553    /// use mini_static::{JsOptions, JsTool, Server};
554    /// use std::path::Path;
555    ///
556    /// let server = Server::new(Path::new("./public"))?
557    ///     .with_source_folder(Path::new("./js-src"))?
558    ///     .with_js_tool(
559    ///         JsTool::Esbuild,
560    ///         JsOptions::new()
561    ///             .bundle_entry(Path::new("./js-src/main.js"), "bundle.js")
562    ///             .minify(true),
563    ///     )?;
564    /// # Ok(())
565    /// # }
566    /// ```
567    pub fn with_js_tool(mut self, tool: JsTool, options: JsOptions) -> Result<Self, StaticError> {
568        if let Some(entry) = options.entry() {
569            let entry_canon = entry.canonicalize().map_err(StaticError::Io)?;
570            let under_source_folder = self
571                .source_folders
572                .iter()
573                .any(|folder| entry_canon.starts_with(folder));
574            if !under_source_folder {
575                return Err(StaticError::Traversal(format!(
576                    "js bundle entry {} is not under any registered source folder",
577                    entry_canon.display()
578                )));
579            }
580        }
581
582        self.js_tool = Some((tool, options));
583        Ok(self)
584    }
585
586    /// Remove stale CSS bundle output at build time — specifically, delete the bundle file
587    /// when no CSS sources remain, rather than serving an orphan. Applies only to the
588    /// one-shot startup build, never during live-reload.
589    pub fn with_prune_output(mut self) -> Self {
590        self.prune_output = true;
591        self
592    }
593
594    /// True when any build pipeline is configured (a CSS/JS tool and/or a source
595    /// folder), i.e. the server should run a startup build.
596    fn has_pipeline(&self) -> bool {
597        self.css_tool.is_some()
598            || self.js_tool.is_some()
599            || !self.source_folders.is_empty()
600            || !self.asset_folders.is_empty()
601    }
602
603    /// Every external tool binary this configuration actually needs at some point
604    /// (bundle and/or minify enabled — a pure passthrough config never spawns its
605    /// configured tool, so it has nothing to fail-fast on), paired with its
606    /// human-readable install hint for a fail-fast startup error.
607    fn required_tool_binaries(&self) -> Vec<(&'static str, &'static str)> {
608        let mut required = Vec::new();
609        if let Some((css_tool, options)) = &self.css_tool {
610            if options.is_bundle() || options.is_minify() {
611                required.push((css_tool.binary_name(), css_tool.install_hint()));
612            }
613        }
614        if let Some((js_tool, options)) = &self.js_tool {
615            if options.is_bundle() || options.is_minify() {
616                required.push((js_tool.binary_name(), js_tool.install_hint()));
617            }
618        }
619        required
620    }
621
622    /// Every directory to watch for source changes: the source folders, the CSS
623    /// `@import` roots, and the asset folders, deduplicated so a directory registered
624    /// under more than one role is watched once.
625    fn watch_targets(&self) -> Vec<PathBuf> {
626        let mut targets = Vec::new();
627        for dir in self
628            .source_folders
629            .iter()
630            .chain(self.bundle_roots.iter())
631            .chain(self.asset_folders.iter())
632        {
633            if !targets.contains(dir) {
634                targets.push(dir.clone());
635            }
636        }
637        targets
638    }
639
640    /// Run every configured build pipeline (CSS/JS tools, asset folders) once and
641    /// return, without starting the HTTP server. A one-shot equivalent of the
642    /// startup build `run*` does automatically — for deploy tooling that wants to
643    /// populate the output dir ahead of time (e.g. a `cargo run --bin build_static`
644    /// step before baking a Docker image), mirroring a one-shot content
645    /// builder's `build()` (e.g. `mini_docs::Builder::build()`).
646    ///
647    /// # Errors
648    ///
649    /// - `Err(StaticError::PipelineSetup)` if a configured tool's binary that's
650    ///   actually needed (bundle or minify enabled) is missing from `PATH` — checked
651    ///   before anything runs, same as [`Server::run_on`].
652    /// - `Err(StaticError::Build)` if a configured pipeline step fails (a tool
653    ///   invocation error, a filesystem error writing output, etc.).
654    ///
655    /// # Example
656    ///
657    /// ```no_run
658    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
659    /// use mini_static::Server;
660    /// use std::path::Path;
661    ///
662    /// let server = Server::new(Path::new("./public"))?;
663    /// server.build().await?;
664    /// # Ok(())
665    /// # }
666    /// ```
667    pub async fn build(&self) -> Result<(), StaticError> {
668        for (binary, install_hint) in self.required_tool_binaries() {
669            if !tool::locate_on_path(binary) {
670                return Err(StaticError::PipelineSetup(format!(
671                    "{binary} not found on PATH ({install_hint})"
672                )));
673            }
674        }
675
676        let pipeline = SourcePipeline::new(
677            self.source_folders.clone(),
678            self.bundle_roots.clone(),
679            self.asset_folders.clone(),
680            self.output_dir.clone(),
681            self.css_tool.clone(),
682            self.js_tool.clone(),
683            self.prune_output,
684            Broadcaster::new(),
685        );
686        pipeline
687            .full_build()
688            .await
689            .map_err(|e| StaticError::Build(e.to_string()))
690    }
691
692    /// Resolve a request path under the server's root.
693    ///
694    /// This is a lower-level API for resolving paths without generating HTTP responses.
695    /// For most use cases, prefer [`Server::handle_request`] or the `run*` methods.
696    ///
697    /// # Returns
698    ///
699    /// - `Ok(PathBuf)` if the path resolves to a file within root.
700    /// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
701    pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
702        resolve::resolve_with_canonical_root(&self.root_canon, request_path)
703    }
704
705    /// Run the server on a specific address with a configurable header-read timeout.
706    ///
707    /// Spawns the server in a background Tokio task and returns immediately with the
708    /// assigned port number and a [`ServerHandle`]. Call `handle.shutdown().await` to
709    /// stop accepting new connections and wait for in-flight connections to finish.
710    /// Dropping the handle instead leaves the server running for the life of the process.
711    ///
712    /// # Header-Read Timeout
713    ///
714    /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
715    /// This prevents slowloris attacks and resource exhaustion from incomplete requests. The
716    /// timeout applies only to the header-read phase — once a complete header block has been
717    /// read, the connection is handed off with no further time bound, so long-lived response
718    /// bodies (e.g. the live-reload SSE stream from [`Server::with_live_reload`]) are not cut
719    /// off mid-stream.
720    ///
721    /// # Precompressed Sidecars
722    ///
723    /// If a request's `Accept-Encoding` allows `br` or `gzip` (preferring `br`) and a
724    /// sibling `<path>.br`/`<path>.gz` exists next to the resolved file, its bytes are
725    /// served instead with a matching `Content-Encoding`. Every file response carries
726    /// `Vary: Accept-Encoding` so intermediate caches don't serve the wrong variant to a
727    /// differently-capable client.
728    ///
729    /// # Arguments
730    ///
731    /// * `addr` - Socket address to bind to (e.g., `127.0.0.1:0` for loopback ephemeral,
732    ///   or `0.0.0.0:8080` to bind all interfaces on a fixed port).
733    /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
734    ///
735    /// # Returns
736    ///
737    /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
738    /// - `Err(StaticError::Io)` if binding to the socket fails.
739    /// - `Err(StaticError::PipelineSetup)` if a configured [`CssTool`]/[`JsTool`]'s binary
740    ///   is not found on `PATH`. Checked before the listener binds: a deployment whose
741    ///   configured pipeline can never run should fail visibly at boot, not be discovered
742    ///   later as a missing/stale asset.
743    pub async fn run_on(
744        &self,
745        addr: SocketAddr,
746        header_timeout: Duration,
747    ) -> Result<(u16, ServerHandle), StaticError> {
748        for (binary, install_hint) in self.required_tool_binaries() {
749            if !tool::locate_on_path(binary) {
750                return Err(StaticError::PipelineSetup(format!(
751                    "{binary} not found on PATH ({install_hint})"
752                )));
753            }
754        }
755
756        let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
757        let port = listener.local_addr().map_err(StaticError::Io)?.port();
758
759        let mut server = self.clone();
760        if server.live_reload {
761            let broadcaster = Broadcaster::new();
762
763            // The build pipelines react to SOURCE folders only; the output dir is never
764            // watched. Watching the output would feed each pipeline its own writes back
765            // into its trigger — the feedback loop this layering exists to prevent.
766            if server.has_pipeline() {
767                let pipeline = Arc::new(SourcePipeline::new(
768                    server.source_folders.clone(),
769                    server.bundle_roots.clone(),
770                    server.asset_folders.clone(),
771                    server.output_dir.clone(),
772                    server.css_tool.clone(),
773                    server.js_tool.clone(),
774                    server.prune_output,
775                    broadcaster.clone(),
776                ));
777                let mut rx = broadcaster.subscribe();
778                tokio::spawn(async move {
779                    // One-shot startup build (and optional prune) first, so the earliest
780                    // request already sees fresh output rather than yesterday's.
781                    if let Err(e) = pipeline.full_build().await {
782                        eprintln!("source pipeline build error: {e}");
783                    }
784                    while let Some(event) = rx.recv().await {
785                        if let Err(e) = pipeline
786                            .process_change(&event.path, &event.change_type)
787                            .await
788                        {
789                            eprintln!("source pipeline error: {e}");
790                        }
791                    }
792                });
793            }
794
795            for dir in server.watch_targets() {
796                start_watching(Arc::new(dir), broadcaster.clone());
797            }
798
799            server.broadcaster = Some(broadcaster);
800        } else if server.has_pipeline() {
801            // No live-reload: still run the one-shot build so a release boot reflects the
802            // current sources. The broadcaster is a throwaway — there is no browser to
803            // notify, so broadcasting into it is a no-op.
804            let pipeline = Arc::new(SourcePipeline::new(
805                server.source_folders.clone(),
806                server.bundle_roots.clone(),
807                server.asset_folders.clone(),
808                server.output_dir.clone(),
809                server.css_tool.clone(),
810                server.js_tool.clone(),
811                server.prune_output,
812                Broadcaster::new(),
813            ));
814            tokio::spawn(async move {
815                if let Err(e) = pipeline.full_build().await {
816                    eprintln!("source pipeline build error: {e}");
817                }
818            });
819        }
820        let semaphore = Arc::new(Semaphore::new(server.max_connections));
821        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
822
823        let accept_task = tokio::spawn(async move {
824            let mut backoff = ACCEPT_BACKOFF_INITIAL;
825            let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
826            let mut shutdown_pin = std::pin::pin!(shutdown_rx);
827            let mut shutting_down = false;
828
829            loop {
830                if !shutting_down {
831                    // The accept-and-permit step and the shutdown signal race in a single
832                    // `select!` so shutdown can preempt a pending accept or a permit wait
833                    // cleanly, at any point — not just between loop iterations.
834                    tokio::select! {
835                        accepted = accept_and_permit(&listener, &mut backoff, &semaphore) => {
836                            match accepted {
837                                Some((stream, permit)) => {
838                                    let server = server.clone();
839                                    join_set.spawn(async move {
840                                        let _permit = permit;
841                                        serve_connection(stream, server, header_timeout).await;
842                                    });
843                                }
844                                None => shutting_down = true,
845                            }
846                        }
847                        _ = shutdown_pin.as_mut() => {
848                            shutting_down = true;
849                        }
850                    }
851                    continue;
852                }
853
854                // Stop accepting; drain already-spawned connections before returning.
855                match join_set.join_next().await {
856                    Some(_) => continue,
857                    None => break,
858                }
859            }
860        });
861
862        Ok((
863            port,
864            ServerHandle {
865                shutdown_tx: Some(shutdown_tx),
866                accept_task,
867            },
868        ))
869    }
870
871    /// Run the server on loopback (127.0.0.1), binding an ephemeral port.
872    ///
873    /// Thin wrapper around [`Server::run_on`] — see it for the header-read timeout and
874    /// sidecar semantics, and for what the returned [`ServerHandle`] does.
875    pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
876        self.run_on(([127, 0, 0, 1], 0).into(), header_timeout)
877            .await
878    }
879
880    /// Run the server on all interfaces (0.0.0.0) at `port` (0 for an ephemeral port).
881    ///
882    /// Useful for containerized deployments and reverse-proxy setups. Thin wrapper
883    /// around [`Server::run_on`] — see it for the header-read timeout and sidecar
884    /// semantics, and for what the returned [`ServerHandle`] does.
885    pub async fn run_all(
886        &self,
887        port: u16,
888        header_timeout: Duration,
889    ) -> Result<(u16, ServerHandle), StaticError> {
890        self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
891            .await
892    }
893
894    /// Run the server on loopback with the default 30-second header-read timeout.
895    ///
896    /// The recommended entry point for tests and lightweight services that don't need a
897    /// custom timeout. Thin wrapper around [`Server::run`].
898    ///
899    /// # Example
900    ///
901    /// ```no_run
902    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
903    /// use mini_static::Server;
904    /// use std::path::Path;
905    ///
906    /// let server = Server::new(Path::new("./public"))?;
907    /// let (port, handle) = server.run_ephemeral().await?;
908    /// println!("Server ready on http://127.0.0.1:{}", port);
909    /// handle.shutdown().await;
910    /// # Ok(())
911    /// # }
912    /// ```
913    pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
914        self.run(DEFAULT_HEADER_TIMEOUT).await
915    }
916
917    /// Produce the HTTP response for a request, streaming file bodies to the client.
918    ///
919    /// This is the crate's single request-handling path: the `run*` accept loop calls it,
920    /// and so should any async server embedding `mini-static` as a fallback route (e.g.
921    /// `mini-unified`). It never blocks the calling task — path resolution runs on Tokio's
922    /// blocking-thread pool via `spawn_blocking`, and the file is read via async I/O.
923    ///
924    /// File responses are backed by `FileBody`, which hands hyper one 64 KB chunk at a
925    /// time as `poll_frame` is driven: memory use stays bounded to one chunk per in-flight
926    /// response regardless of file size.
927    ///
928    /// `headers` are the request's headers; `If-None-Match` (304 on a matching ETag) and
929    /// `Accept-Encoding` (precompressed sidecar selection, see [`Server::run_on`]) are the
930    /// ones read today. Only `GET` and `HEAD` are allowed; anything else gets a 405 with an
931    /// `Allow` header. Missing files and traversal attempts both get an identical 404, so a
932    /// response never discloses whether a path exists outside the root.
933    pub async fn handle_request(
934        &self,
935        method: &Method,
936        request_path: &str,
937        headers: &HeaderMap,
938    ) -> Response<ResponseBody> {
939        if method != Method::GET && method != Method::HEAD {
940            return text(
941                response(StatusCode::METHOD_NOT_ALLOWED).header("Allow", "GET, HEAD"),
942                "method not allowed\n",
943            );
944        }
945
946        // Live-reload SSE stream — only reachable when `with_live_reload()` was called
947        // and the server was started via a `run*` method (those are the only paths that
948        // populate `broadcaster`).
949        if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
950            if let Some(broadcaster) = &self.broadcaster {
951                return finish(
952                    response(StatusCode::OK)
953                        .header("Content-Type", "text/event-stream")
954                        .header("Cache-Control", "no-cache")
955                        .header("Connection", "keep-alive")
956                        .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
957                );
958            }
959        }
960
961        // `resolve()` does blocking filesystem syscalls (`canonicalize()`, up to two per
962        // request). Running those directly in this `async fn` would block whichever
963        // Tokio worker thread happens to be driving it, stalling every other task
964        // scheduled on that thread for the duration of the syscalls. `spawn_blocking`
965        // moves the work onto Tokio's dedicated blocking thread pool instead.
966        let server = self.clone();
967        let owned_request_path = request_path.to_string();
968        let resolved =
969            tokio::task::spawn_blocking(move || server.resolve(&owned_request_path)).await;
970        let path = match resolved {
971            Err(_) => return internal_error_response(),
972            Ok(Err(e)) => {
973                return text(
974                    response(StatusCode::NOT_FOUND),
975                    format!("{}\n", e.user_message()),
976                )
977            }
978            Ok(Ok(path)) => path,
979        };
980
981        // A directory served via its `index.html` needs a trailing slash to establish the
982        // correct base for the page's relative links. Compare against the *decoded*
983        // request path so a percent-encoded explicit request for index.html (e.g.
984        // `/docs/index.htm%6c`) is recognized as such instead of producing a redirect to a
985        // still-encoded, broken Location.
986        let decoded_request_path = resolve::decode_request_path(request_path);
987        if path.file_name().is_some_and(|name| name == "index.html")
988            && !decoded_request_path.ends_with('/')
989            && !decoded_request_path.ends_with("index.html")
990        {
991            // `location` is built from the (attacker-controlled) request path; `finish()`
992            // degrades to 400 instead of panicking if it ever contains bytes invalid in a
993            // header value.
994            let location = format!("{}/", request_path.trim_end_matches('/'));
995            return text(
996                response(StatusCode::MOVED_PERMANENTLY).header("Location", location),
997                "moved\n",
998            );
999        }
1000
1001        let Ok(file) = File::open(&path).await else {
1002            return internal_error_response();
1003        };
1004        let Ok(metadata) = file.metadata().await else {
1005            return internal_error_response();
1006        };
1007
1008        let content_type = mime_type_for_path(&path);
1009        // Live-reload and spa-mode HTML injection both need the original, uncompressed
1010        // bytes to splice their script into — never substitute a precompressed sidecar on
1011        // this path. `broadcaster` is only `Some` when live-reload is enabled (see
1012        // `Server::with_live_reload`); `spa_mode` is independent of it (see
1013        // `Server::with_spa_mode`/`with_spa_root`) — either alone is enough to trigger
1014        // injection.
1015        let html_injection =
1016            (self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html");
1017
1018        let range_header = header_str(headers, "range");
1019        let if_range_header = header_str(headers, "if-range");
1020
1021        let accept_encoding = header_str(headers, "accept-encoding");
1022        // Skip precompressed sidecars when Range is requested (serve original file instead).
1023        let sidecar = if html_injection || range_header.is_some() {
1024            None
1025        } else {
1026            select_precompressed_sidecar(&path, accept_encoding).await
1027        };
1028        let (mut file, metadata, content_encoding) = match sidecar {
1029            Some((sidecar_file, sidecar_metadata, encoding)) => {
1030                (sidecar_file, sidecar_metadata, Some(encoding))
1031            }
1032            None => (file, metadata, None),
1033        };
1034
1035        // HTML injection is skipped for a served precompressed sidecar (already final
1036        // bytes from a build step) — see `html_injection`'s definition above.
1037        let etag = generate_etag(&metadata);
1038        let cache_control = self.cache_control_for(&path);
1039
1040        if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
1041            return finish(
1042                Response::builder()
1043                    .status(StatusCode::NOT_MODIFIED)
1044                    .header("Cache-Control", cache_control)
1045                    .header("Vary", "Accept-Encoding")
1046                    .header("ETag", etag)
1047                    .header("Accept-Ranges", "bytes")
1048                    .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1049            );
1050        }
1051
1052        // `Some` when the served representation differs from the file's raw bytes and had
1053        // to be built in memory; `None` means stream the open file as-is. Computed before
1054        // the HEAD check below because RFC 9110 requires a HEAD response's headers —
1055        // `Content-Length` included — to match what a GET would send, even though the body
1056        // itself is dropped.
1057        let transformed: Option<Bytes> = if html_injection {
1058            let mut html = Vec::with_capacity(metadata.len() as usize);
1059            if file.read_to_end(&mut html).await.is_err() {
1060                return internal_error_response();
1061            }
1062            if self.broadcaster.is_some() {
1063                reload::inject_reload_script(&mut html);
1064            }
1065            if self.spa_mode {
1066                spa::inject_spa_script(&mut html, self.spa_root.as_deref());
1067            }
1068            Some(Bytes::from(html))
1069        } else {
1070            None
1071        };
1072
1073        let file_size = transformed
1074            .as_ref()
1075            .map_or(metadata.len(), |bytes| bytes.len() as u64);
1076
1077        // Handle Range requests.
1078        let range_outcome = range_header.map(|h| parse_range_header(h, file_size));
1079        let range_check = if let Some(outcome) = &range_outcome {
1080            match outcome {
1081                RangeOutcome::Satisfiable(start, end) => {
1082                    // If-Range validation: stale If-Range ignores Range, serves full 200.
1083                    if let Some(if_range) = if_range_header {
1084                        if !if_range_valid(if_range, &etag) {
1085                            RangeCheck::IgnoreRange
1086                        } else {
1087                            RangeCheck::Satisfiable(*start, *end)
1088                        }
1089                    } else {
1090                        RangeCheck::Satisfiable(*start, *end)
1091                    }
1092                }
1093                RangeOutcome::MultiRangeIgnored => RangeCheck::IgnoreRange,
1094                RangeOutcome::Unsatisfiable => RangeCheck::Unsatisfiable,
1095                RangeOutcome::NoRange => RangeCheck::IgnoreRange,
1096            }
1097        } else {
1098            RangeCheck::IgnoreRange
1099        };
1100
1101        match &range_check {
1102            RangeCheck::Unsatisfiable => {
1103                return finish(
1104                    Response::builder()
1105                        .status(StatusCode::RANGE_NOT_SATISFIABLE)
1106                        .header("Content-Range", format!("bytes */{}", file_size))
1107                        .header("Accept-Ranges", "bytes")
1108                        .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1109                );
1110            }
1111            RangeCheck::Satisfiable(start, end) => {
1112                let range_len = end - start + 1;
1113
1114                // Seek to start position; if sidecar, we already skipped it above.
1115                if transformed.is_none() {
1116                    if file.seek(std::io::SeekFrom::Start(*start)).await.is_err() {
1117                        return internal_error_response();
1118                    }
1119                }
1120
1121                // HEAD must not return a body (RFC 9110).
1122                let body = if *method == Method::HEAD {
1123                    ResponseBody::Buffered(Full::new(Bytes::new()))
1124                } else {
1125                    match transformed {
1126                        Some(ref bytes) => ResponseBody::Buffered(Full::new(
1127                            bytes.slice(*start as usize..(*end as usize + 1)),
1128                        )),
1129                        None => ResponseBody::Streamed(FileBody::new_ranged(file, range_len)),
1130                    }
1131                };
1132
1133                let mut builder = Response::builder()
1134                    .status(StatusCode::PARTIAL_CONTENT)
1135                    .header("Content-Type", content_type)
1136                    .header("Content-Length", range_len.to_string())
1137                    .header(
1138                        "Content-Range",
1139                        format!("bytes {}-{}/{}", start, end, file_size),
1140                    )
1141                    .header("Cache-Control", cache_control)
1142                    .header("Vary", "Accept-Encoding")
1143                    .header("ETag", etag)
1144                    .header("Accept-Ranges", "bytes");
1145                if let Some(encoding) = content_encoding {
1146                    builder = builder.header("Content-Encoding", encoding);
1147                }
1148                return finish(builder.body(body));
1149            }
1150            RangeCheck::IgnoreRange => {}
1151        }
1152
1153        // HEAD must not return a body (RFC 9110).
1154        let body = if *method == Method::HEAD {
1155            ResponseBody::Buffered(Full::new(Bytes::new()))
1156        } else {
1157            match transformed {
1158                Some(bytes) => ResponseBody::Buffered(Full::new(bytes)),
1159                None => ResponseBody::Streamed(FileBody::new(file)),
1160            }
1161        };
1162
1163        let mut builder = response(StatusCode::OK)
1164            .header("Content-Type", content_type)
1165            .header("Content-Length", file_size.to_string())
1166            .header("Cache-Control", cache_control)
1167            .header("Vary", "Accept-Encoding")
1168            .header("ETag", etag)
1169            .header("Accept-Ranges", "bytes");
1170        if let Some(encoding) = content_encoding {
1171            builder = builder.header("Content-Encoding", encoding);
1172        }
1173        finish(builder.body(body))
1174    }
1175}
1176
1177/// Ceiling on how many bytes `read_header_prefix` buffers before giving up. Without this,
1178/// a client that trickles bytes forever without ever sending the terminating blank line
1179/// could grow the buffer without limit — the header-read timeout alone doesn't bound
1180/// memory, only wall-clock time, and a sufficiently patient sender could still send
1181/// unbounded data before the deadline fires.
1182const MAX_HEADER_BYTES: usize = 64 * 1024;
1183
1184/// Why `read_header_prefix` gave up before seeing a complete header block. Every variant
1185/// is a legitimate reason to drop the connection — none is treated specially by the
1186/// caller today, but the distinction is worth preserving for anyone debugging this later.
1187#[derive(Debug)]
1188enum HeaderReadError {
1189    /// The client closed the connection (or shut down its write half) before sending a
1190    /// complete header block.
1191    ConnectionClosed,
1192    /// More than `MAX_HEADER_BYTES` were buffered without seeing `\r\n\r\n`.
1193    TooLarge,
1194    /// The underlying socket read failed. Kept rather than discarded so a future `log`
1195    /// feature has the real I/O error to report instead of an opaque unit variant.
1196    #[allow(dead_code)]
1197    Io(std::io::Error),
1198}
1199
1200/// Reads from `stream` until a complete HTTP header block (`\r\n\r\n`) has been buffered,
1201/// returning every byte read so far — which may include bytes past the header block
1202/// (request body, or a second pipelined request) if the client sent them in the same
1203/// read. Callers pair this with `tokio::time::timeout` to bound how long the header phase
1204/// itself may take; this function has no timeout of its own, only the size ceiling in
1205/// `MAX_HEADER_BYTES`.
1206async fn read_header_prefix(stream: &mut TcpStream) -> Result<Vec<u8>, HeaderReadError> {
1207    let mut buf = Vec::new();
1208    let mut chunk = [0u8; 4096];
1209
1210    loop {
1211        let n = stream.read(&mut chunk).await.map_err(HeaderReadError::Io)?;
1212        if n == 0 {
1213            return Err(HeaderReadError::ConnectionClosed);
1214        }
1215        buf.extend_from_slice(&chunk[..n]);
1216
1217        if buf.len() > MAX_HEADER_BYTES {
1218            return Err(HeaderReadError::TooLarge);
1219        }
1220        // Only the tail can hold a terminator this read completed: the `n` new bytes plus
1221        // the 3 before them. Rescanning the whole buffer every time would make the header
1222        // read quadratic in the bytes received.
1223        let scan_from = buf.len().saturating_sub(n + 3);
1224        if buf[scan_from..].windows(4).any(|w| w == b"\r\n\r\n") {
1225            return Ok(buf);
1226        }
1227    }
1228}
1229
1230/// Wraps an accepted `TcpStream` whose header block has already been drained into
1231/// `prefix` (by `read_header_prefix`, ahead of the connection being handed to hyper).
1232/// Reads replay `prefix` first, then fall through to the live socket — so hyper sees
1233/// exactly the byte stream it would have seen without the pre-read, just sourced from two
1234/// buffers back-to-back instead of one continuous one. Writes pass straight through.
1235struct PrefixedIo {
1236    prefix: Bytes,
1237    prefix_pos: usize,
1238    inner: TcpStream,
1239}
1240
1241impl PrefixedIo {
1242    fn new(prefix: Vec<u8>, inner: TcpStream) -> Self {
1243        PrefixedIo {
1244            prefix: Bytes::from(prefix),
1245            prefix_pos: 0,
1246            inner,
1247        }
1248    }
1249}
1250
1251impl AsyncRead for PrefixedIo {
1252    fn poll_read(
1253        self: Pin<&mut Self>,
1254        cx: &mut Context<'_>,
1255        buf: &mut ReadBuf<'_>,
1256    ) -> Poll<std::io::Result<()>> {
1257        let this = self.get_mut();
1258        if this.prefix_pos < this.prefix.len() {
1259            let remaining = &this.prefix[this.prefix_pos..];
1260            let n = remaining.len().min(buf.remaining());
1261            buf.put_slice(&remaining[..n]);
1262            this.prefix_pos += n;
1263            return Poll::Ready(Ok(()));
1264        }
1265        Pin::new(&mut this.inner).poll_read(cx, buf)
1266    }
1267}
1268
1269impl AsyncWrite for PrefixedIo {
1270    fn poll_write(
1271        self: Pin<&mut Self>,
1272        cx: &mut Context<'_>,
1273        buf: &[u8],
1274    ) -> Poll<std::io::Result<usize>> {
1275        Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
1276    }
1277
1278    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1279        Pin::new(&mut self.get_mut().inner).poll_flush(cx)
1280    }
1281
1282    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1283        Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
1284    }
1285}
1286
1287/// Wires an accepted connection up to the hyper HTTP/1 service.
1288///
1289/// `header_timeout` bounds only the header-read phase (`read_header_prefix`, run before
1290/// hyper ever sees the connection). Once a complete header block has been read, the
1291/// connection is handed to hyper with no further time bound — deliberately, since a
1292/// response body may legitimately outlive `header_timeout` by design (the live-reload SSE
1293/// stream is the motivating case: it stays open until a watched file changes, which may
1294/// be minutes or hours after the request). Wrapping the whole connection lifetime in
1295/// `header_timeout` — the prior implementation — silently truncated exactly that stream
1296/// once `header_timeout` elapsed, aborting the response mid-write after headers had
1297/// already been sent (the client observes this as a chunked-encoding error, not a clean
1298/// close). The connection-count ceiling (`Server::with_max_connections`) is what bounds
1299/// resource use from connections held open indefinitely, not this timeout.
1300async fn serve_connection(mut stream: TcpStream, server: Server, header_timeout: Duration) {
1301    let prefix = match timeout(header_timeout, read_header_prefix(&mut stream)).await {
1302        Ok(Ok(prefix)) => prefix,
1303        Ok(Err(_)) | Err(_) => return,
1304    };
1305
1306    let io = TokioIo::new(PrefixedIo::new(prefix, stream));
1307    let svc = service_fn(move |req: Request<Incoming>| {
1308        let server = server.clone();
1309        async move {
1310            let resp = server
1311                .handle_request(req.method(), req.uri().path(), req.headers())
1312                .await;
1313            Ok::<_, Infallible>(resp)
1314        }
1315    });
1316    let _ = AutoBuilder::new(TokioExecutor::new())
1317        .serve_connection(io, svc)
1318        .await;
1319}
1320
1321/// Default header-read timeout used by [`Server::run_ephemeral`].
1322const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
1323
1324/// Default grace period `ServerHandle::shutdown()` waits for in-flight connections to
1325/// finish on their own before aborting whatever is left. A connection with no
1326/// self-imposed end — an open live-reload SSE stream, or any keep-alive connection whose
1327/// peer simply never closes it — would otherwise let `shutdown()` hang forever waiting
1328/// for it to finish naturally. Every wait in this crate has a stated upper bound;
1329/// shutdown is no exception.
1330const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1331
1332/// A handle to a server started by one of the `Server::run*` methods.
1333///
1334/// Dropping this handle without calling `shutdown()` leaves the server running in the
1335/// background for the life of the process. Call `shutdown()` to stop accepting new
1336/// connections and wait for already-accepted connections to finish before returning.
1337pub struct ServerHandle {
1338    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1339    accept_task: tokio::task::JoinHandle<()>,
1340}
1341
1342impl ServerHandle {
1343    /// Stop accepting new connections and wait up to `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT`
1344    /// (5s) for in-flight connections to finish on their own. Equivalent to
1345    /// `shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)` — see that method for what
1346    /// happens to connections still open once the grace period elapses.
1347    pub async fn shutdown(self) {
1348        self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1349            .await;
1350    }
1351
1352    /// Stop accepting new connections and wait up to `drain_timeout` for in-flight
1353    /// connections to finish on their own.
1354    ///
1355    /// Connections still open once `drain_timeout` elapses are aborted rather than
1356    /// waited on further: dropping the accept task drops its `JoinSet`, which aborts
1357    /// every task still tracked in it (see `tokio::task::JoinSet`'s own drop behavior) —
1358    /// which in turn drops each connection's socket, closing it. This is what bounds
1359    /// shutdown when a connection has no natural end of its own (the live-reload SSE
1360    /// stream is the motivating case: it stays open until a watched file changes, which
1361    /// may never happen before the process needs to exit).
1362    pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1363        if let Some(tx) = self.shutdown_tx.take() {
1364            let _ = tx.send(());
1365        }
1366        if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1367            self.accept_task.abort();
1368        }
1369    }
1370}
1371
1372/// Read a request header as a `&str`, or `None` if it's absent or not valid ASCII.
1373fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1374    headers.get(name).and_then(|value| value.to_str().ok())
1375}
1376
1377/// Start a response carrying the baseline security header every response in this crate
1378/// sends. The 304 path is the one exception and builds its own — a 304 repeats only the
1379/// caching validators, not the full header set.
1380fn response(status: StatusCode) -> Builder {
1381    Response::builder()
1382        .status(status)
1383        .header("X-Content-Type-Options", "nosniff")
1384}
1385
1386/// Finish `builder` with a plain-text body. `&'static str` bodies borrow rather than
1387/// allocate; `String` bodies (the 404 message) are moved in.
1388fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1389    finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1390}
1391
1392/// Finishes building a response, degrading to a generic 400 instead of panicking if any
1393/// header value turns out to be invalid for use as an HTTP header value.
1394///
1395/// Every header value that reaches `Response::builder()` in this module is either a
1396/// static string or formatted from internal, already-validated data (a byte count, an
1397/// mtime, a fixed method list) — none of it can actually fail today. But `.unwrap()`
1398/// on that assumption is exactly the kind of thing that turns "can't happen" into a
1399/// production panic the day someone adds a header built from new input without
1400/// re-deriving that guarantee. Routing every response through this one fallible path
1401/// means that mistake fails safe instead of panicking.
1402fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1403    built.unwrap_or_else(|_| bad_request_response())
1404}
1405
1406// `internal_error_response()` and `bad_request_response()` are the fallback responses
1407// `finish()` itself degrades to — every header and body here is a fixed string with no
1408// external input, so `.body(...)` cannot fail. They can't be routed through `finish()`
1409// without it degrading to itself on failure.
1410fn internal_error_response() -> Response<ResponseBody> {
1411    response(StatusCode::INTERNAL_SERVER_ERROR)
1412        .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1413            b"internal server error\n",
1414        ))))
1415        .unwrap()
1416}
1417
1418fn bad_request_response() -> Response<ResponseBody> {
1419    response(StatusCode::BAD_REQUEST)
1420        .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1421            b"bad request\n",
1422        ))))
1423        .unwrap()
1424}
1425
1426/// `Content-Encoding` name and sidecar file extension for each supported precompressed
1427/// variant, in preference order — brotli wins when a client accepts both and both
1428/// sidecars exist.
1429const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1430
1431/// Whether `accept_encoding` allows `encoding`.
1432///
1433/// Matches by substring rather than parsing `q`-value weights or the `identity`/`*`
1434/// directives — a lighter-weight negotiation than a general HTTP client would need,
1435/// sufficient for deciding between two static sidecar files.
1436fn accepts_encoding(accept_encoding: Option<&str>, encoding: &str) -> bool {
1437    accept_encoding.is_some_and(|header| header.contains(encoding))
1438}
1439
1440/// Look for a precompressed sidecar (`<path>.br` / `<path>.gz`) matching the client's
1441/// `Accept-Encoding`, and return its open file, metadata, and encoding name if found.
1442///
1443/// `path` must already be the fully resolved, canonicalized path `resolve()` produced.
1444/// The sidecar path is built by appending an extension to it — never by re-resolving a
1445/// modified request path — so this lookup can't become a second traversal surface: any
1446/// path this function reads is provably a sibling of a path `resolve()` already cleared.
1447async fn select_precompressed_sidecar(
1448    path: &Path,
1449    accept_encoding: Option<&str>,
1450) -> Option<(File, fs::Metadata, &'static str)> {
1451    for (encoding, ext) in SIDECAR_ENCODINGS {
1452        if !accepts_encoding(accept_encoding, encoding) {
1453            continue;
1454        }
1455        let mut sidecar = path.as_os_str().to_os_string();
1456        sidecar.push(ext);
1457        let sidecar_path = PathBuf::from(sidecar);
1458
1459        // Tripwire for the traversal boundary: a sidecar path built by appending a suffix
1460        // must stay in the same directory as `path` (which `resolve()` already proved is
1461        // inside root). `ext` is always one of the two static literals in
1462        // `SIDECAR_ENCODINGS`, never derived from request input, so this can only fire if
1463        // a future change starts deriving `sidecar` some other way.
1464        debug_assert_eq!(
1465            sidecar_path.parent(),
1466            path.parent(),
1467            "sidecar path must stay in the same directory as the already-resolved path"
1468        );
1469
1470        if let Ok(sidecar_file) = File::open(&sidecar_path).await {
1471            if let Ok(sidecar_metadata) = sidecar_file.metadata().await {
1472                return Some((sidecar_file, sidecar_metadata, encoding));
1473            }
1474        }
1475    }
1476    None
1477}
1478
1479/// Generate an ETag for a file based on modification time and size.
1480///
1481/// Format: `"<size>-<mtime_secs>"`
1482fn generate_etag(metadata: &fs::Metadata) -> String {
1483    let mtime = metadata
1484        .modified()
1485        .ok()
1486        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1487        .map(|d| d.as_secs())
1488        .unwrap_or(0);
1489    format!("\"{}-{}\"", metadata.len(), mtime)
1490}
1491
1492/// Determine MIME type from file path extension.
1493fn mime_type_for_path(path: &Path) -> &'static str {
1494    let ext = path
1495        .extension()
1496        .and_then(|ext| ext.to_str())
1497        .unwrap_or_default()
1498        .to_lowercase();
1499
1500    match ext.as_str() {
1501        "html" | "htm" => "text/html; charset=utf-8",
1502        "css" => "text/css; charset=utf-8",
1503        "js" => "application/javascript; charset=utf-8",
1504        "json" => "application/json; charset=utf-8",
1505        "svg" => "image/svg+xml",
1506        "png" => "image/png",
1507        "jpg" | "jpeg" => "image/jpeg",
1508        "gif" => "image/gif",
1509        "webp" => "image/webp",
1510        "ico" => "image/x-icon",
1511        "woff" => "font/woff",
1512        "woff2" => "font/woff2",
1513        "ttf" => "font/ttf",
1514        "md" | "markdown" => "text/markdown; charset=utf-8",
1515        "txt" => "text/plain; charset=utf-8",
1516        "xml" => "application/xml",
1517        "pdf" => "application/pdf",
1518        "zip" => "application/zip",
1519        _ => "application/octet-stream",
1520    }
1521}
1522
1523/// Check if the If-None-Match header matches the current ETag.
1524/// Handles both exact match and wildcard (*) comparison per RFC 9110.
1525fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1526    if if_none_match == "*" {
1527        return true;
1528    }
1529    if_none_match.split(',').any(|tag| tag.trim() == etag)
1530}
1531
1532#[derive(Debug)]
1533enum RangeOutcome {
1534    NoRange,
1535    Satisfiable(u64, u64),
1536    Unsatisfiable,
1537    MultiRangeIgnored,
1538}
1539
1540enum RangeCheck {
1541    IgnoreRange,
1542    Satisfiable(u64, u64),
1543    Unsatisfiable,
1544}
1545
1546fn parse_range_header(header: &str, file_size: u64) -> RangeOutcome {
1547    let header = header.trim();
1548    if !header.starts_with("bytes=") {
1549        return RangeOutcome::NoRange;
1550    }
1551
1552    let range_spec = &header[6..];
1553
1554    if range_spec.contains(',') {
1555        return RangeOutcome::MultiRangeIgnored;
1556    }
1557
1558    if let Some(suffix_pos) = range_spec.find('-') {
1559        if suffix_pos == 0 {
1560            let suffix_len_str = &range_spec[1..];
1561            if let Ok(suffix_len) = suffix_len_str.parse::<u64>() {
1562                if suffix_len == 0 {
1563                    return RangeOutcome::Unsatisfiable;
1564                }
1565                if suffix_len >= file_size {
1566                    return RangeOutcome::Satisfiable(0, file_size - 1);
1567                }
1568                return RangeOutcome::Satisfiable(file_size - suffix_len, file_size - 1);
1569            }
1570            return RangeOutcome::Unsatisfiable;
1571        }
1572
1573        let start_str = &range_spec[..suffix_pos];
1574        let end_str = &range_spec[suffix_pos + 1..];
1575
1576        if let Ok(start) = start_str.parse::<u64>() {
1577            if start >= file_size {
1578                return RangeOutcome::Unsatisfiable;
1579            }
1580
1581            if end_str.is_empty() {
1582                return RangeOutcome::Satisfiable(start, file_size - 1);
1583            }
1584
1585            if let Ok(end) = end_str.parse::<u64>() {
1586                if end < start {
1587                    return RangeOutcome::Unsatisfiable;
1588                }
1589                let clamped_end = (end + 1).min(file_size) - 1;
1590                if start > clamped_end {
1591                    return RangeOutcome::Unsatisfiable;
1592                }
1593                return RangeOutcome::Satisfiable(start, clamped_end);
1594            }
1595        }
1596    }
1597
1598    RangeOutcome::Unsatisfiable
1599}
1600
1601fn if_range_valid(if_range_header: &str, current_etag: &str) -> bool {
1602    if_range_header.trim() == current_etag
1603}
1604
1605#[cfg(test)]
1606mod precompressed_sidecar_tests {
1607    use super::*;
1608
1609    // `select_precompressed_sidecar` only ever appends a static extension literal
1610    // (".br"/".gz") to the `path` it's given — it never re-joins against `root` or
1611    // re-parses a request-path string, so it structurally cannot become a second
1612    // traversal surface the way re-running `resolve()` on modified input could. This
1613    // test locks that in by construction: the sidecar it finds must live in exactly
1614    // the same directory as the resolved file, for every encoding preference branch.
1615    #[tokio::test]
1616    async fn sidecar_never_leaves_the_resolved_files_directory() {
1617        let root = tempfile::TempDir::new().unwrap();
1618        let sub = root.path().join("assets");
1619        fs::create_dir(&sub).unwrap();
1620        let resolved = sub.join("app.js");
1621        fs::write(&resolved, b"plain").unwrap();
1622        fs::write(sub.join("app.js.br"), b"brotli-bytes").unwrap();
1623        fs::write(sub.join("app.js.gz"), b"gzip-bytes").unwrap();
1624
1625        let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("br, gzip"))
1626            .await
1627            .expect("both sidecars present, br should be preferred");
1628        assert_eq!(
1629            encoding, "br",
1630            "br must be preferred over gzip when both are accepted"
1631        );
1632
1633        let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("gzip"))
1634            .await
1635            .expect("gzip sidecar present");
1636        assert_eq!(encoding, "gzip");
1637
1638        assert!(
1639            select_precompressed_sidecar(&resolved, None)
1640                .await
1641                .is_none(),
1642            "no Accept-Encoding header should never select a sidecar"
1643        );
1644    }
1645
1646    #[test]
1647    fn accepts_encoding_matches_only_listed_directives() {
1648        assert!(!accepts_encoding(None, "br"));
1649        assert!(!accepts_encoding(Some("identity"), "br"));
1650        assert!(!accepts_encoding(Some("identity"), "gzip"));
1651        assert!(accepts_encoding(Some("gzip, br"), "br"));
1652        assert!(accepts_encoding(Some("gzip"), "gzip"));
1653        assert!(!accepts_encoding(Some("gzip"), "br"));
1654    }
1655}
1656
1657#[cfg(test)]
1658mod file_body_tests {
1659    use super::*;
1660    use crate::handler::FILE_CHUNK_SIZE;
1661    use http_body_util::BodyExt;
1662
1663    // Disproves the prior implementation, which read every chunk into a `Vec` and
1664    // only wrapped the whole result in a single `Full` frame at the end — that
1665    // implementation would fail this test with `frame_count == 1` and
1666    // `max_frame_len == file size`, regardless of `FILE_CHUNK_SIZE`.
1667    #[tokio::test]
1668    async fn file_body_yields_multiple_bounded_chunks_not_one_buffered_frame() {
1669        let dir = tempfile::TempDir::new().unwrap();
1670        let path = dir.path().join("big.bin");
1671        let content = vec![7u8; FILE_CHUNK_SIZE * 3 + 12_345];
1672        fs::write(&path, &content).unwrap();
1673
1674        let file = File::open(&path).await.unwrap();
1675        let mut body = FileBody::new(file);
1676
1677        let mut frame_count = 0usize;
1678        let mut max_frame_len = 0usize;
1679        let mut reassembled = Vec::new();
1680
1681        while let Some(frame) = body.frame().await {
1682            let frame = frame.unwrap();
1683            let data = frame.into_data().unwrap();
1684            frame_count += 1;
1685            max_frame_len = max_frame_len.max(data.len());
1686            reassembled.extend_from_slice(&data);
1687        }
1688
1689        assert!(
1690            frame_count > 1,
1691            "expected the file to be delivered as multiple frames, got {frame_count}"
1692        );
1693        assert!(
1694            max_frame_len <= FILE_CHUNK_SIZE,
1695            "no single frame should exceed the chunk size ({FILE_CHUNK_SIZE}), got {max_frame_len}"
1696        );
1697        assert_eq!(
1698            reassembled, content,
1699            "reassembled chunks must match original file content exactly"
1700        );
1701    }
1702}
1703
1704#[cfg(test)]
1705mod accept_tests {
1706    use super::*;
1707    use std::sync::atomic::{AtomicUsize, Ordering};
1708    use std::sync::Mutex;
1709
1710    /// Fails `accept()` a fixed number of times, recording the (paused, virtual)
1711    /// instant of each attempt, before delegating to a real listener so the caller can
1712    /// eventually succeed.
1713    struct FlakyListener {
1714        inner: TcpListener,
1715        remaining_failures: AtomicUsize,
1716        attempts: Mutex<Vec<tokio::time::Instant>>,
1717    }
1718
1719    impl TcpAccept for FlakyListener {
1720        async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
1721            self.attempts
1722                .lock()
1723                .unwrap()
1724                .push(tokio::time::Instant::now());
1725            if self.remaining_failures.fetch_sub(1, Ordering::SeqCst) > 0 {
1726                Err(std::io::Error::other("simulated accept error"))
1727            } else {
1728                TcpAccept::accept(&self.inner).await
1729            }
1730        }
1731    }
1732
1733    // Disproves the prior implementation, which broke out of the accept loop entirely
1734    // on the first `accept()` error — permanently ending the server. This test would
1735    // also fail against a naive `continue`-only fix (no backoff): the recorded gaps
1736    // between attempts would collapse to ~0 (a busy spin) instead of the expected
1737    // exponentially growing delays.
1738    #[tokio::test(start_paused = true)]
1739    async fn accept_loop_backs_off_between_repeated_errors_instead_of_busy_spinning() {
1740        let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1741        let addr = inner.local_addr().unwrap();
1742
1743        let flaky = FlakyListener {
1744            inner,
1745            remaining_failures: AtomicUsize::new(5),
1746            attempts: Mutex::new(Vec::new()),
1747        };
1748
1749        tokio::spawn(async move {
1750            let _ = TcpStream::connect(addr).await;
1751        });
1752
1753        let semaphore = Arc::new(Semaphore::new(1));
1754        let mut backoff = ACCEPT_BACKOFF_INITIAL;
1755        let result = accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1756        assert!(
1757            result.is_some(),
1758            "accept should eventually succeed once the flaky listener stops failing"
1759        );
1760
1761        let recorded = flaky.attempts.lock().unwrap();
1762        assert_eq!(recorded.len(), 6, "5 failures then 1 success");
1763
1764        let expected_gaps = [
1765            ACCEPT_BACKOFF_INITIAL,
1766            ACCEPT_BACKOFF_INITIAL * 2,
1767            ACCEPT_BACKOFF_INITIAL * 4,
1768            ACCEPT_BACKOFF_INITIAL * 8,
1769            ACCEPT_BACKOFF_INITIAL * 16,
1770        ];
1771        for (i, expected) in expected_gaps.iter().enumerate() {
1772            let gap = recorded[i + 1] - recorded[i];
1773            assert_eq!(
1774                gap,
1775                *expected,
1776                "gap between attempt {i} and {} should reflect the backoff delay, not a busy spin",
1777                i + 1
1778            );
1779        }
1780
1781        // The delay must stop doubling at the cap rather than growing without bound.
1782        let mut capped = ACCEPT_BACKOFF_MAX;
1783        capped = (capped * 2).min(ACCEPT_BACKOFF_MAX);
1784        assert_eq!(capped, ACCEPT_BACKOFF_MAX);
1785    }
1786
1787    // A successful accept must clear the accumulated delay, so an isolated error later
1788    // on doesn't inherit a second-long wait from an unrelated earlier failure.
1789    #[tokio::test(start_paused = true)]
1790    async fn a_successful_accept_resets_the_backoff() {
1791        let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1792        let addr = inner.local_addr().unwrap();
1793        let flaky = FlakyListener {
1794            inner,
1795            remaining_failures: AtomicUsize::new(3),
1796            attempts: Mutex::new(Vec::new()),
1797        };
1798        tokio::spawn(async move {
1799            let _ = TcpStream::connect(addr).await;
1800        });
1801
1802        let semaphore = Arc::new(Semaphore::new(1));
1803        let mut backoff = ACCEPT_BACKOFF_INITIAL * 32;
1804        accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1805
1806        assert_eq!(
1807            backoff, ACCEPT_BACKOFF_INITIAL,
1808            "the delay must return to its initial value once an accept succeeds"
1809        );
1810    }
1811}
1812
1813#[cfg(test)]
1814mod finish_tests {
1815    use super::*;
1816
1817    // Disproves a bare `.unwrap()` on the same builder: CR/LF is not a legal header
1818    // value byte (it would enable header/response splitting), so this construction is
1819    // guaranteed to make `.body(...)` return `Err`. Every real call site in this module
1820    // only ever builds header values from static strings or internally-formatted
1821    // numbers, so this test can't happen through normal use — it exists to prove
1822    // `finish()`'s fallback path actually works, not to exercise a reachable case.
1823    #[test]
1824    fn finish_degrades_to_400_on_invalid_header_value_instead_of_panicking() {
1825        let built = Response::builder()
1826            .status(StatusCode::OK)
1827            .header("X-Test", "invalid\r\nvalue")
1828            .body(ResponseBody::Buffered(Full::new(Bytes::new())));
1829        assert!(
1830            built.is_err(),
1831            "CR/LF in a header value should be rejected by the builder"
1832        );
1833
1834        let response = finish(built);
1835        assert_eq!(
1836            response.status(),
1837            StatusCode::BAD_REQUEST,
1838            "finish() should degrade to 400 rather than panicking on an invalid header value"
1839        );
1840    }
1841}
1842
1843#[cfg(test)]
1844mod header_prefix_tests {
1845    use super::*;
1846    use tokio::io::AsyncWriteExt;
1847
1848    /// Binds an ephemeral listener, connects a client to it, and returns both ends —
1849    /// `(server_side, client_side)` — so a test can drive `read_header_prefix` against a
1850    /// real socket without a full `Server`/`serve_connection` in the loop.
1851    async fn connected_pair() -> (TcpStream, TcpStream) {
1852        let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1853        let addr = listener.local_addr().unwrap();
1854        let client = TcpStream::connect(addr).await.unwrap();
1855        let (server_side, _) = listener.accept().await.unwrap();
1856        (server_side, client)
1857    }
1858
1859    #[tokio::test]
1860    async fn reads_exactly_up_to_and_including_the_terminating_blank_line() {
1861        let (mut server_side, mut client) = connected_pair().await;
1862
1863        client
1864            .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
1865            .await
1866            .unwrap();
1867
1868        let prefix = read_header_prefix(&mut server_side)
1869            .await
1870            .unwrap_or_else(|_| {
1871                panic!("expected a complete header block to be read");
1872            });
1873
1874        assert_eq!(prefix, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
1875    }
1876
1877    // Disproves a version that only inspects the newest chunk for `\r\n\r\n`: writing the
1878    // blank line in a separate write (and thus, almost always, a separate read) after the
1879    // rest of the headers would make that version wait forever, since the terminator
1880    // never appears within a single chunk. Also pins the tail-only scan in
1881    // `read_header_prefix` — a terminator straddling two reads must still be seen.
1882    #[tokio::test]
1883    async fn assembles_a_header_block_split_across_multiple_writes() {
1884        let (mut server_side, mut client) = connected_pair().await;
1885
1886        client
1887            .write_all(b"GET /page HTTP/1.1\r\nHost: localhost\r")
1888            .await
1889            .unwrap();
1890        client.write_all(b"\n\r\n").await.unwrap();
1891
1892        let prefix = read_header_prefix(&mut server_side)
1893            .await
1894            .unwrap_or_else(|_| {
1895                panic!("expected a complete header block to be read across multiple writes");
1896            });
1897
1898        assert_eq!(prefix, b"GET /page HTTP/1.1\r\nHost: localhost\r\n\r\n");
1899    }
1900
1901    // Bytes past the header block (a pipelined second request, here) must be preserved
1902    // verbatim in the returned prefix — `PrefixedIo` depends on this to replay them to
1903    // hyper untouched.
1904    #[tokio::test]
1905    async fn preserves_bytes_sent_past_the_header_block() {
1906        let (mut server_side, mut client) = connected_pair().await;
1907
1908        let first = b"GET /a HTTP/1.1\r\nHost: localhost\r\n\r\n";
1909        let second = b"GET /b HTTP/1.1\r\nHost: localhost\r\n\r\n";
1910        let mut sent = Vec::new();
1911        sent.extend_from_slice(first);
1912        sent.extend_from_slice(second);
1913        client.write_all(&sent).await.unwrap();
1914
1915        let prefix = read_header_prefix(&mut server_side)
1916            .await
1917            .unwrap_or_else(|_| {
1918                panic!("expected a complete header block to be read");
1919            });
1920
1921        assert_eq!(
1922            &prefix, &sent,
1923            "pipelined bytes past the first header block must survive intact"
1924        );
1925    }
1926
1927    #[tokio::test]
1928    async fn errors_with_connection_closed_when_client_disconnects_before_headers_complete() {
1929        let (mut server_side, client) = connected_pair().await;
1930        drop(client);
1931
1932        match read_header_prefix(&mut server_side).await {
1933            Err(HeaderReadError::ConnectionClosed) => {}
1934            Err(_) => panic!("expected ConnectionClosed, got a different error variant"),
1935            Ok(_) => {
1936                panic!("expected an error, got a complete header block from a closed connection")
1937            }
1938        }
1939    }
1940
1941    // Disproves an unbounded buffer: without the `MAX_HEADER_BYTES` check, this would
1942    // hang consuming memory forever instead of erroring, since the client never sends the
1943    // terminating blank line.
1944    #[tokio::test]
1945    async fn errors_with_too_large_once_max_header_bytes_is_exceeded_without_a_terminator() {
1946        let (mut server_side, mut client) = connected_pair().await;
1947
1948        let garbage = vec![b'a'; MAX_HEADER_BYTES + 1];
1949        client.write_all(&garbage).await.unwrap();
1950
1951        match read_header_prefix(&mut server_side).await {
1952            Err(HeaderReadError::TooLarge) => {}
1953            Err(_) => panic!("expected TooLarge, got a different error variant"),
1954            Ok(_) => {
1955                panic!("expected an error, got a complete header block from unterminated garbage")
1956            }
1957        }
1958    }
1959
1960    #[tokio::test]
1961    async fn prefixed_io_replays_the_prefix_before_reading_from_the_live_socket() {
1962        let (server_side, mut client) = connected_pair().await;
1963        let mut io = PrefixedIo::new(b"buffered-prefix".to_vec(), server_side);
1964
1965        client.write_all(b"-live-bytes").await.unwrap();
1966
1967        let mut collected = Vec::new();
1968        let mut chunk = [0u8; 8];
1969        while collected.len() < b"buffered-prefix-live-bytes".len() {
1970            let n = io.read(&mut chunk).await.unwrap();
1971            assert!(n > 0, "read returned 0 before all expected bytes arrived");
1972            collected.extend_from_slice(&chunk[..n]);
1973        }
1974
1975        assert_eq!(collected, b"buffered-prefix-live-bytes");
1976    }
1977}
1978
1979#[cfg(test)]
1980mod css_bundle_tests {
1981    use super::*;
1982    use std::fs;
1983    use std::time::Duration;
1984    use tempfile::TempDir;
1985    use tokio::time::sleep;
1986
1987    #[tokio::test]
1988    async fn source_folder_overlapping_output_dir_is_rejected() {
1989        let root = TempDir::new().unwrap();
1990
1991        // The output dir defaults to the served root, so registering that root as a source
1992        // folder must be refused: watching the output would feed every pipeline its own
1993        // writes back into its trigger.
1994        let result = Server::new(root.path())
1995            .unwrap()
1996            .with_source_folder(root.path());
1997        assert!(
1998            result.is_err(),
1999            "a source folder equal to the output dir must be rejected"
2000        );
2001    }
2002
2003    #[tokio::test]
2004    async fn source_folder_inside_output_dir_is_rejected() {
2005        let root = TempDir::new().unwrap();
2006        let nested = root.path().join("nested");
2007        fs::create_dir(&nested).unwrap();
2008
2009        let result = Server::new(root.path())
2010            .unwrap()
2011            .with_source_folder(&nested);
2012        assert!(
2013            result.is_err(),
2014            "a source folder nested in the output dir must be rejected"
2015        );
2016    }
2017
2018    #[tokio::test]
2019    async fn output_dir_overlapping_source_folder_is_rejected() {
2020        let root = TempDir::new().unwrap();
2021        let source = TempDir::new().unwrap();
2022
2023        let server = Server::new(root.path())
2024            .unwrap()
2025            .with_source_folder(source.path())
2026            .unwrap();
2027
2028        let result = server.with_output_dir(source.path());
2029        assert!(
2030            result.is_err(),
2031            "an output dir equal to a source folder must be rejected"
2032        );
2033    }
2034
2035    #[tokio::test]
2036    async fn css_bundle_creates_output_on_startup_with_live_reload() {
2037        let src = TempDir::new().unwrap();
2038        let out = TempDir::new().unwrap();
2039
2040        fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
2041
2042        let server = Server::new(out.path())
2043            .unwrap()
2044            .with_live_reload()
2045            .with_source_folder(src.path())
2046            .unwrap()
2047            .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2048
2049        let (_port, handle) = server.run_ephemeral().await.unwrap();
2050
2051        sleep(Duration::from_millis(800)).await;
2052
2053        let bundle = out.path().join("styles.css");
2054        assert!(
2055            bundle.exists(),
2056            "bundle should be written to the default <output>/styles.css"
2057        );
2058        let content = fs::read_to_string(&bundle).unwrap();
2059        assert!(!content.is_empty(), "bundle should contain CSS");
2060
2061        handle.shutdown().await;
2062    }
2063
2064    #[tokio::test]
2065    async fn css_bundle_rebuilds_once_and_settles_when_source_css_changes() {
2066        let src = TempDir::new().unwrap();
2067        let out = TempDir::new().unwrap();
2068        let src_path = src.path();
2069        let bundle = out.path().join("styles.css");
2070
2071        fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
2072
2073        let server = Server::new(out.path())
2074            .unwrap()
2075            .with_live_reload()
2076            .with_source_folder(src_path)
2077            .unwrap()
2078            .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2079
2080        let (_port, handle) = server.run_ephemeral().await.unwrap();
2081
2082        // Let the startup build and the watcher's first poll pass (500ms) complete.
2083        sleep(Duration::from_millis(800)).await;
2084        assert!(bundle.exists());
2085
2086        fs::write(
2087            src_path.join("style.css"),
2088            "body { margin: 0; color: blue; }",
2089        )
2090        .unwrap();
2091
2092        // Wait long enough for the watcher poll + rebundle to land at least once.
2093        sleep(Duration::from_millis(1500)).await;
2094        let content_v2 = fs::read_to_string(&bundle).unwrap();
2095        assert!(
2096            content_v2.contains("color"),
2097            "rebundle should contain the new color rule"
2098        );
2099
2100        let mtime_after = fs::metadata(&bundle).unwrap().modified().unwrap();
2101        sleep(Duration::from_millis(1200)).await;
2102        let mtime_later = fs::metadata(&bundle).unwrap().modified().unwrap();
2103
2104        // The regression this guards: the output write must NOT re-trigger another rebuild
2105        // (the feedback loop would keep mutating the bundle's mtime here). A settled mtime
2106        // over a full poll interval proves a single rebuild, not a loop.
2107        assert_eq!(
2108            mtime_after, mtime_later,
2109            "bundle mtime must settle after one rebuild — an ongoing loop would keep changing it"
2110        );
2111
2112        handle.shutdown().await;
2113    }
2114
2115    #[tokio::test]
2116    async fn css_bundle_creates_output_on_startup_without_live_reload() {
2117        let src = TempDir::new().unwrap();
2118        let out = TempDir::new().unwrap();
2119
2120        fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
2121
2122        let server = Server::new(out.path())
2123            .unwrap()
2124            .with_source_folder(src.path())
2125            .unwrap()
2126            .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2127
2128        let (_port, handle) = server.run_ephemeral().await.unwrap();
2129
2130        sleep(Duration::from_millis(200)).await;
2131
2132        let bundle = out.path().join("styles.css");
2133        assert!(
2134            bundle.exists(),
2135            "bundle should be created even without live_reload"
2136        );
2137        let content = fs::read_to_string(&bundle).unwrap();
2138        assert!(!content.is_empty(), "bundle should contain CSS");
2139
2140        handle.shutdown().await;
2141    }
2142
2143    #[tokio::test]
2144    async fn css_bundle_concatenates_multiple_source_css_files() {
2145        let src = TempDir::new().unwrap();
2146        let out = TempDir::new().unwrap();
2147
2148        fs::write(src.path().join("reset.css"), "* { margin: 0; padding: 0; }").unwrap();
2149        fs::write(src.path().join("theme.css"), "body { background: white; }").unwrap();
2150
2151        let server = Server::new(out.path())
2152            .unwrap()
2153            .with_live_reload()
2154            .with_source_folder(src.path())
2155            .unwrap()
2156            .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2157
2158        let (_port, handle) = server.run_ephemeral().await.unwrap();
2159
2160        sleep(Duration::from_millis(800)).await;
2161
2162        let content = fs::read_to_string(out.path().join("styles.css")).unwrap();
2163        assert!(
2164            content.contains("margin"),
2165            "output should contain reset CSS"
2166        );
2167        assert!(
2168            content.contains("background"),
2169            "output should contain theme CSS"
2170        );
2171
2172        handle.shutdown().await;
2173    }
2174}
2175
2176#[cfg(test)]
2177mod asset_folder_tests {
2178    use super::*;
2179    use std::fs;
2180    use std::time::Duration;
2181    use tempfile::TempDir;
2182    use tokio::time::sleep;
2183
2184    #[tokio::test]
2185    async fn asset_folder_overlapping_output_dir_is_rejected() {
2186        let root = TempDir::new().unwrap();
2187        let result = Server::new(root.path())
2188            .unwrap()
2189            .with_asset_folder(root.path());
2190        assert!(
2191            result.is_err(),
2192            "an asset folder equal to the output dir must be rejected"
2193        );
2194    }
2195
2196    #[tokio::test]
2197    async fn asset_folder_overlapping_an_existing_asset_folder_is_rejected() {
2198        let root = TempDir::new().unwrap();
2199        let assets = TempDir::new().unwrap();
2200
2201        let result = Server::new(root.path())
2202            .unwrap()
2203            .with_asset_folder(assets.path())
2204            .unwrap()
2205            .with_asset_folder(assets.path());
2206        assert!(
2207            result.is_err(),
2208            "registering the same asset folder twice must be rejected"
2209        );
2210    }
2211
2212    #[tokio::test]
2213    async fn asset_folder_overlapping_a_source_folder_is_rejected_both_ways() {
2214        let root = TempDir::new().unwrap();
2215        let shared = TempDir::new().unwrap();
2216
2217        let via_asset_then_source = Server::new(root.path())
2218            .unwrap()
2219            .with_asset_folder(shared.path())
2220            .unwrap()
2221            .with_source_folder(shared.path());
2222        assert!(
2223            via_asset_then_source.is_err(),
2224            "a source folder overlapping an already-registered asset folder must be rejected"
2225        );
2226
2227        let via_source_then_asset = Server::new(root.path())
2228            .unwrap()
2229            .with_source_folder(shared.path())
2230            .unwrap()
2231            .with_asset_folder(shared.path());
2232        assert!(
2233            via_source_then_asset.is_err(),
2234            "an asset folder overlapping an already-registered source folder must be rejected"
2235        );
2236    }
2237
2238    #[tokio::test]
2239    async fn asset_folder_files_are_served_after_startup_build() {
2240        let assets = TempDir::new().unwrap();
2241        let out = TempDir::new().unwrap();
2242        fs::write(assets.path().join("index.html"), "<html>hi</html>").unwrap();
2243        fs::create_dir(assets.path().join("images")).unwrap();
2244        fs::write(assets.path().join("images/logo.svg"), "<svg></svg>").unwrap();
2245
2246        let server = Server::new(out.path())
2247            .unwrap()
2248            .with_asset_folder(assets.path())
2249            .unwrap();
2250        let (port, handle) = server.run_ephemeral().await.unwrap();
2251
2252        sleep(Duration::from_millis(200)).await;
2253
2254        let index = fs::read_to_string(out.path().join("index.html")).unwrap();
2255        assert_eq!(index, "<html>hi</html>");
2256        let logo = fs::read_to_string(out.path().join("images/logo.svg")).unwrap();
2257        assert_eq!(logo, "<svg></svg>");
2258
2259        let mut conn = tokio::net::TcpStream::connect(("127.0.0.1", port))
2260            .await
2261            .unwrap();
2262        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2263        conn.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
2264            .await
2265            .unwrap();
2266        let mut response = Vec::new();
2267        conn.read_to_end(&mut response).await.unwrap();
2268        let response = String::from_utf8_lossy(&response);
2269        assert!(response.contains("HTTP/1.1 200"), "got: {response}");
2270        assert!(response.contains("<html>hi</html>"), "got: {response}");
2271
2272        handle.shutdown().await;
2273    }
2274
2275    #[tokio::test]
2276    async fn asset_folder_change_rebuilds_and_live_reloads() {
2277        let assets = TempDir::new().unwrap();
2278        let out = TempDir::new().unwrap();
2279        fs::write(assets.path().join("index.html"), "v1").unwrap();
2280
2281        let server = Server::new(out.path())
2282            .unwrap()
2283            .with_live_reload()
2284            .with_asset_folder(assets.path())
2285            .unwrap();
2286        let (_port, handle) = server.run_ephemeral().await.unwrap();
2287
2288        sleep(Duration::from_millis(800)).await;
2289        assert_eq!(
2290            fs::read_to_string(out.path().join("index.html")).unwrap(),
2291            "v1"
2292        );
2293
2294        fs::write(assets.path().join("index.html"), "v2").unwrap();
2295        sleep(Duration::from_millis(1500)).await;
2296
2297        assert_eq!(
2298            fs::read_to_string(out.path().join("index.html")).unwrap(),
2299            "v2",
2300            "editing the source asset must re-copy it into the output dir"
2301        );
2302
2303        handle.shutdown().await;
2304    }
2305}
2306
2307#[cfg(test)]
2308mod build_once_tests {
2309    use super::*;
2310    use std::fs;
2311    use tempfile::TempDir;
2312
2313    #[tokio::test]
2314    async fn build_populates_the_output_dir_without_starting_a_server() {
2315        let assets = TempDir::new().unwrap();
2316        let out = TempDir::new().unwrap();
2317        fs::write(assets.path().join("index.html"), "<html>hi</html>").unwrap();
2318
2319        let server = Server::new(out.path())
2320            .unwrap()
2321            .with_asset_folder(assets.path())
2322            .unwrap();
2323
2324        server.build().await.unwrap();
2325
2326        assert_eq!(
2327            fs::read_to_string(out.path().join("index.html")).unwrap(),
2328            "<html>hi</html>",
2329            "build() must populate the output dir synchronously, no server needed"
2330        );
2331    }
2332
2333    #[tokio::test]
2334    async fn build_with_no_pipeline_configured_is_a_harmless_no_op() {
2335        let out = TempDir::new().unwrap();
2336        let server = Server::new(out.path()).unwrap();
2337
2338        server
2339            .build()
2340            .await
2341            .expect("build() with nothing configured must succeed trivially");
2342    }
2343
2344    #[tokio::test]
2345    async fn build_fails_fast_when_a_required_tool_binary_is_missing() {
2346        let src = TempDir::new().unwrap();
2347        let out = TempDir::new().unwrap();
2348        fs::write(src.path().join("a.css"), "body{}").unwrap();
2349
2350        let server = Server::new(out.path())
2351            .unwrap()
2352            .with_source_folder(src.path())
2353            .unwrap()
2354            .with_css_tool(CssTool::TestMissing, CssOptions::new().minify(true));
2355
2356        let result = server.build().await;
2357
2358        assert!(
2359            matches!(result, Err(StaticError::PipelineSetup(_))),
2360            "expected PipelineSetup, got {result:?}"
2361        );
2362    }
2363}
2364
2365#[cfg(test)]
2366mod range_header_tests {
2367    use super::*;
2368
2369    #[test]
2370    fn no_range_header_returns_unsatisfiable() {
2371        match parse_range_header("bytes=", 1000) {
2372            RangeOutcome::Unsatisfiable => {}
2373            other => panic!("expected Unsatisfiable, got {other:?}"),
2374        }
2375    }
2376
2377    #[test]
2378    fn invalid_format_returns_unsatisfiable() {
2379        match parse_range_header("invalid", 1000) {
2380            RangeOutcome::NoRange => {}
2381            other => panic!("expected NoRange, got {other:?}"),
2382        }
2383    }
2384
2385    #[test]
2386    fn simple_range_returns_satisfiable() {
2387        match parse_range_header("bytes=0-99", 1000) {
2388            RangeOutcome::Satisfiable(start, end) => {
2389                assert_eq!(start, 0);
2390                assert_eq!(end, 99);
2391            }
2392            other => panic!("expected Satisfiable(0, 99), got {other:?}"),
2393        }
2394    }
2395
2396    #[test]
2397    fn open_ended_range_returns_satisfiable() {
2398        match parse_range_header("bytes=100-", 1000) {
2399            RangeOutcome::Satisfiable(start, end) => {
2400                assert_eq!(start, 100);
2401                assert_eq!(end, 999);
2402            }
2403            other => panic!("expected Satisfiable(100, 999), got {other:?}"),
2404        }
2405    }
2406
2407    #[test]
2408    fn suffix_range_returns_satisfiable() {
2409        match parse_range_header("bytes=-100", 1000) {
2410            RangeOutcome::Satisfiable(start, end) => {
2411                assert_eq!(start, 900);
2412                assert_eq!(end, 999);
2413            }
2414            other => panic!("expected Satisfiable(900, 999), got {other:?}"),
2415        }
2416    }
2417
2418    #[test]
2419    fn suffix_range_longer_than_file_returns_full_range() {
2420        match parse_range_header("bytes=-2000", 1000) {
2421            RangeOutcome::Satisfiable(start, end) => {
2422                assert_eq!(start, 0);
2423                assert_eq!(end, 999);
2424            }
2425            other => panic!("expected Satisfiable(0, 999), got {other:?}"),
2426        }
2427    }
2428
2429    #[test]
2430    fn end_overshooting_file_clamps_correctly() {
2431        match parse_range_header("bytes=0-2000", 1000) {
2432            RangeOutcome::Satisfiable(start, end) => {
2433                assert_eq!(start, 0);
2434                assert_eq!(end, 999);
2435            }
2436            other => panic!("expected Satisfiable(0, 999), got {other:?}"),
2437        }
2438    }
2439
2440    #[test]
2441    fn start_at_file_boundary_returns_unsatisfiable() {
2442        match parse_range_header("bytes=1000-", 1000) {
2443            RangeOutcome::Unsatisfiable => {}
2444            other => panic!("expected Unsatisfiable, got {other:?}"),
2445        }
2446    }
2447
2448    #[test]
2449    fn start_beyond_file_returns_unsatisfiable() {
2450        match parse_range_header("bytes=2000-3000", 1000) {
2451            RangeOutcome::Unsatisfiable => {}
2452            other => panic!("expected Unsatisfiable, got {other:?}"),
2453        }
2454    }
2455
2456    #[test]
2457    fn end_before_start_returns_unsatisfiable() {
2458        match parse_range_header("bytes=100-50", 1000) {
2459            RangeOutcome::Unsatisfiable => {}
2460            other => panic!("expected Unsatisfiable, got {other:?}"),
2461        }
2462    }
2463
2464    #[test]
2465    fn multi_range_returns_multi_range_ignored() {
2466        match parse_range_header("bytes=0-99,200-299", 1000) {
2467            RangeOutcome::MultiRangeIgnored => {}
2468            other => panic!("expected MultiRangeIgnored, got {other:?}"),
2469        }
2470    }
2471
2472    #[test]
2473    fn zero_suffix_length_returns_unsatisfiable() {
2474        match parse_range_header("bytes=-0", 1000) {
2475            RangeOutcome::Unsatisfiable => {}
2476            other => panic!("expected Unsatisfiable, got {other:?}"),
2477        }
2478    }
2479
2480    #[test]
2481    fn if_range_valid_with_matching_etag() {
2482        assert!(if_range_valid("\"abc123\"", "\"abc123\""));
2483    }
2484
2485    #[test]
2486    fn if_range_valid_with_mismatched_etag() {
2487        assert!(!if_range_valid("\"abc123\"", "\"def456\""));
2488    }
2489
2490    #[test]
2491    fn if_range_valid_with_whitespace() {
2492        assert!(if_range_valid("  \"abc123\"  ", "\"abc123\""));
2493    }
2494}