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