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