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, AsyncWrite, ReadBuf};
21use tokio::net::{TcpListener, TcpStream};
22use tokio::sync::{OwnedSemaphorePermit, Semaphore};
23use tokio::time::timeout;
24
25use crate::error::StaticError;
26use crate::handler::{FileBody, ResponseBody};
27use crate::minify::{self, MinifyError};
28use crate::minify_cache::{MinifyCache, DEFAULT_MINIFY_CACHE_CAPACITY};
29use crate::reload::{self, ChangeType, SseBody};
30use crate::resolve;
31use crate::source::SourcePipeline;
32use crate::watcher::{start_watching, Broadcaster};
33
34const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
35const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
36
37/// Default maximum concurrent connections, overridable via `Server::with_max_connections`.
38const DEFAULT_MAX_CONNECTIONS: usize = 1024;
39
40/// A source of accepted TCP connections. Abstracted so the accept-error backoff below
41/// can be exercised against a listener that fails on demand, without needing to provoke
42/// real OS-level accept errors (e.g. EMFILE) in tests.
43trait TcpAccept {
44    async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
45}
46
47impl TcpAccept for TcpListener {
48    async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
49        TcpListener::accept(self).await
50    }
51}
52
53/// Accept a connection and reserve it a connection-limit permit.
54///
55/// `backoff` retries a failed `accept()` after an exponentially growing delay (reset on
56/// the next success, capped at `ACCEPT_BACKOFF_MAX`) instead of ending the accept loop,
57/// so a sustained failure — the process being out of file descriptors, say — degrades
58/// into periodic retries rather than a CPU-bound busy spin or a permanently dead server.
59///
60/// Returns `None` only if the semaphore itself has been closed (never happens in normal
61/// operation, since nothing ever calls `close()` on it — handled so a caller can still
62/// fail safely rather than panic).
63async fn accept_and_permit<L: TcpAccept>(
64    listener: &L,
65    backoff: &mut Duration,
66    semaphore: &Arc<Semaphore>,
67) -> Option<(TcpStream, OwnedSemaphorePermit)> {
68    loop {
69        let stream = match listener.accept().await {
70            Ok((stream, _)) => {
71                *backoff = ACCEPT_BACKOFF_INITIAL;
72                stream
73            }
74            Err(_) => {
75                tokio::time::sleep(*backoff).await;
76                *backoff = (*backoff * 2).min(ACCEPT_BACKOFF_MAX);
77                continue;
78            }
79        };
80        return semaphore
81            .clone()
82            .acquire_owned()
83            .await
84            .ok()
85            .map(|permit| (stream, permit));
86    }
87}
88
89/// A predicate deciding whether a resolved file path should get an immutable cache
90/// policy; see [`Server::with_immutable_assets`].
91type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
92
93/// A static file server for serving files securely from a root directory.
94///
95/// `Server` canonicalizes the root directory once at creation time and uses the
96/// canonical form for all subsequent requests, avoiding repeated filesystem calls.
97///
98/// # Security
99///
100/// The server protects against:
101/// - Path traversal attacks (e.g., `../../etc/passwd`)
102/// - Accessing files outside the root via symlinks
103/// - Disclosing filesystem structure (traversal and missing files both return 404)
104///
105/// # Cloning
106///
107/// `Server` is cheap to clone: a `PathBuf`, a couple of primitives, and an `Arc`'d
108/// predicate closure. Multiple clones can be used concurrently in async tasks without
109/// synchronization overhead.
110///
111/// # Example
112///
113/// ```no_run
114/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
115/// use mini_static::Server;
116/// use std::path::Path;
117/// use std::time::Duration;
118///
119/// let server = Server::new(Path::new("./public"))?;
120/// let (port, _handle) = server.run(Duration::from_secs(30)).await?;
121/// println!("Server running on port {}", port);
122/// # Ok(())
123/// # }
124/// ```
125#[derive(Clone)]
126pub struct Server {
127    root_canon: PathBuf,
128    bundle_roots: Vec<PathBuf>,
129    max_connections: usize,
130    live_reload: bool,
131    broadcaster: Option<Broadcaster>,
132    immutable_predicate: Option<ImmutablePredicate>,
133    minify_cache: Option<Arc<MinifyCache>>,
134    source_folders: Vec<PathBuf>,
135    output_dir: PathBuf,
136    css_bundle_output: Option<PathBuf>,
137    prune_output: bool,
138}
139
140/// True when two canonical paths are the same path or one contains the other.
141///
142/// Used by the source/output overlap check: `Path::starts_with` compares component-wise, so
143/// `/a/b` neither equals nor contains their sibling `/a/b/c` itself. This is the single
144/// place the overlap rule is defined, so both `with_source_folder` and `with_output_dir`
145/// cannot drift apart.
146fn paths_overlap(a: &Path, b: &Path) -> bool {
147    a.starts_with(b) || b.starts_with(a)
148}
149
150impl Server {
151    /// Create a new server with the given root directory.
152    ///
153    /// Canonicalizes the root once at startup. All subsequent requests use the
154    /// canonical root without re-canonicalizing it, making this suitable for long-lived servers.
155    ///
156    /// # Errors
157    ///
158    /// Returns `Err(StaticError::Io)` if the root cannot be canonicalized (e.g., doesn't exist,
159    /// no read permissions).
160    pub fn new(root: &Path) -> Result<Self, StaticError> {
161        let root_canon = root.canonicalize().map_err(StaticError::Io)?;
162        let output_dir = root_canon.clone();
163        Ok(Server {
164            root_canon,
165            bundle_roots: Vec::new(),
166            max_connections: DEFAULT_MAX_CONNECTIONS,
167            live_reload: false,
168            broadcaster: None,
169            immutable_predicate: None,
170            minify_cache: None,
171            source_folders: Vec::new(),
172            output_dir,
173            css_bundle_output: None,
174            prune_output: false,
175        })
176    }
177
178    /// Set the maximum number of connections served concurrently (default 1024).
179    ///
180    /// Once this many connections are in flight, `run()`'s accept loop stops accepting
181    /// new ones — without pausing the accept loop, a client that opens a connection and
182    /// sends nothing (see the header-read timeout docs on [`Server::run_on`]) could
183    /// otherwise be used, in enough parallel copies, to exhaust the process's file
184    /// descriptors or memory with no bound at all.
185    pub fn with_max_connections(mut self, max: usize) -> Self {
186        self.max_connections = max;
187        self
188    }
189
190    /// Enable live-reload for this server (disabled by default).
191    ///
192    /// Once enabled, the `run*` methods start a background watcher (mtime polling,
193    /// bounded 500ms interval — see [`crate::start_watching`]) over the server's root
194    /// the first time the server actually starts accepting connections, and:
195    ///
196    /// - serve a live-reload SSE stream at [`crate::LIVE_RELOAD_PATH`], broadcasting a
197    ///   change event (with [`crate::ChangeType`]) whenever a served file is added,
198    ///   modified, or removed;
199    /// - inject a small `<script>` into every served `text/html` response that connects
200    ///   to that stream and reloads the page (or hot-swaps stylesheet `<link>`s, for CSS
201    ///   changes) — no manual client wiring required.
202    ///
203    /// This is meant for local development, not production: leave it disabled (the
204    /// default) for any server serving real traffic. A typical call site gates it behind
205    /// `#[cfg(debug_assertions)]` so a release build never pays for the watcher or the
206    /// injected script.
207    ///
208    /// # Example
209    ///
210    /// ```no_run
211    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
212    /// use mini_static::Server;
213    /// use std::path::Path;
214    ///
215    /// let server = Server::new(Path::new("./public"))?;
216    /// #[cfg(debug_assertions)]
217    /// let server = server.with_live_reload();
218    /// # Ok(())
219    /// # }
220    /// ```
221    pub fn with_live_reload(mut self) -> Self {
222        self.live_reload = true;
223        self
224    }
225
226    /// Serve files matching `predicate` with a long-lived, immutable cache policy
227    /// instead of the default `Cache-Control: no-cache`.
228    ///
229    /// `predicate` is evaluated against each resolved file's path; a match sends
230    /// `Cache-Control: public, max-age=31536000, immutable` on that file's 200 and 304
231    /// responses. This is correct only for fingerprinted assets (e.g.
232    /// `main.a1b2c3.js`) where a content change always produces a new filename —
233    /// caching a mutable filename indefinitely would serve stale content to every
234    /// client that already has it cached.
235    ///
236    /// # Example
237    ///
238    /// ```no_run
239    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
240    /// use mini_static::Server;
241    /// use std::path::Path;
242    ///
243    /// let server = Server::new(Path::new("./public"))?
244    ///     .with_immutable_assets(|path| {
245    ///         path.file_name()
246    ///             .and_then(|name| name.to_str())
247    ///             .is_some_and(|name| name.contains(".fingerprint."))
248    ///     });
249    /// # Ok(())
250    /// # }
251    /// ```
252    pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
253    where
254        F: Fn(&Path) -> bool + Send + Sync + 'static,
255    {
256        self.immutable_predicate = Some(Arc::new(predicate));
257        self
258    }
259
260    /// The `Cache-Control` header value for a resolved file path: the immutable policy
261    /// if `with_immutable_assets`'s predicate matches, `no-cache` otherwise.
262    fn cache_control_for(&self, path: &Path) -> &'static str {
263        match &self.immutable_predicate {
264            Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
265            _ => "no-cache",
266        }
267    }
268
269    /// Enable in-memory CSS/JS minification for this server (disabled by default).
270    ///
271    /// A `.css`/`.js`/`.mjs` response is minified at most once per source mtime: a hit
272    /// serves cached bytes, a miss reads and minifies the file and caches the result
273    /// (see [`crate::minify`]). Files matching `*.min.css`/`*.min.js`
274    /// are served as-is — minifying already-minified input is wasted work at best and
275    /// a correctness risk at worst. If a precompressed sidecar (see
276    /// [`Server::run_on`]'s docs) matches the request, its bytes are served directly
277    /// and minification is skipped, since a sidecar already represents whatever a
278    /// build step decided the final bytes should be. A file that fails to minify (rare
279    /// malformed CSS/JS) is served unminified rather than failing the request.
280    ///
281    /// When `with_live_reload()` is also enabled, the cache drops an entry as soon as the
282    /// same file-change event that drives live-reload arrives, instead of only noticing the
283    /// change reactively on that file's next request.
284    ///
285    /// # Example
286    ///
287    /// ```no_run
288    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
289    /// use mini_static::Server;
290    /// use std::path::Path;
291    ///
292    /// let server = Server::new(Path::new("./public"))?.with_minify();
293    /// # Ok(())
294    /// # }
295    /// ```
296    pub fn with_minify(mut self) -> Self {
297        self.minify_cache = Some(Arc::new(MinifyCache::new(DEFAULT_MINIFY_CACHE_CAPACITY)));
298        self
299    }
300
301    /// Allow CSS `@import` resolution to reach files outside the served root.
302    ///
303    /// When CSS bundling is enabled, `@import` statements may resolve to files under `path`,
304    /// in addition to files under the served root. This is useful for build pipelines where
305    /// source partials live in a separate directory tree from the final served output.
306    ///
307    /// Files under `path` are never directly HTTP-servable: `Server::resolve` and the
308    /// request-handling path never consult bundle roots, only the bundler's `@import`
309    /// resolution does. This is purely an `@import` resolution boundary, not a second
310    /// served root.
311    ///
312    /// This method is fallible and canonicalizes the path once at call time, matching
313    /// `Server::new`'s canonicalize-once policy. Call it multiple times to register
314    /// more than one external source tree.
315    ///
316    /// # Errors
317    ///
318    /// Returns `Err(StaticError::Io)` if the path cannot be canonicalized.
319    pub fn with_bundle_root(mut self, path: &Path) -> Result<Self, StaticError> {
320        let canon = path.canonicalize().map_err(StaticError::Io)?;
321        self.bundle_roots.push(canon);
322        Ok(self)
323    }
324
325    /// Designate `dir` as a source folder whose changes drive the build pipelines.
326    ///
327    /// Watched when `with_live_reload()` is enabled; `.css` files under it feed the single
328    /// CSS bundle, `.js`/`.mjs` files are minified per-file into the output dir.
329    ///
330    /// Rejected if `dir` overlaps the output dir or an already-registered source folder: a
331    /// source folder that is also the output would feed every pipeline its own output — the
332    /// feedback loop this layering exists to prevent.
333    ///
334    /// # Errors
335    ///
336    /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
337    /// `Err(StaticError::Traversal)` if it overlaps the output dir or another source folder.
338    pub fn with_source_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
339        let canon = dir.canonicalize().map_err(StaticError::Io)?;
340
341        if paths_overlap(&canon, &self.output_dir) {
342            return Err(StaticError::Traversal(format!(
343                "source folder {} overlaps the output dir {}",
344                canon.display(),
345                self.output_dir.display()
346            )));
347        }
348        if self
349            .source_folders
350            .iter()
351            .any(|existing| paths_overlap(&canon, existing))
352        {
353            return Err(StaticError::Traversal(format!(
354                "source folder {} overlaps an already-registered source folder",
355                canon.display()
356            )));
357        }
358
359        self.source_folders.push(canon);
360        Ok(self)
361    }
362
363    /// Designate `dir` as the output directory processed outputs are written to.
364    ///
365    /// Defaults to the served root. The output dir is never a watcher trigger: pipelines
366    /// react to source folders only, so a pipeline's own output can never re-trigger it.
367    /// Call this before `with_css_bundle(_name)` so the bundle path reflects the override.
368    ///
369    /// # Errors
370    ///
371    /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
372    /// `Err(StaticError::Traversal)` if it overlaps a registered source folder.
373    pub fn with_output_dir(mut self, dir: &Path) -> Result<Self, StaticError> {
374        let canon = dir.canonicalize().map_err(StaticError::Io)?;
375
376        if self
377            .source_folders
378            .iter()
379            .any(|existing| paths_overlap(&canon, existing))
380        {
381            return Err(StaticError::Traversal(format!(
382                "output dir {} overlaps a registered source folder",
383                canon.display()
384            )));
385        }
386
387        self.output_dir = canon;
388        Ok(self)
389    }
390
391    /// Enable the single-file CSS bundle: every `.css` under the source folders is bundled
392    /// (with `@import` resolution within the source folders and any `with_bundle_root` dirs)
393    /// and minified into `<output>/styles.css`.
394    pub fn with_css_bundle(mut self) -> Self {
395        self.css_bundle_output = Some(self.output_dir.join("styles.css"));
396        self
397    }
398
399    /// Enable the single-file CSS bundle with a custom output file name under the output
400    /// dir (default is `styles.css`, see [`Server::with_css_bundle`]).
401    pub fn with_css_bundle_name(mut self, name: &str) -> Self {
402        self.css_bundle_output = Some(self.output_dir.join(name));
403        self
404    }
405
406    /// Remove stale CSS bundle output at build time — specifically, delete the bundle file
407    /// when no CSS sources remain, rather than serving an orphan. Applies only to the
408    /// one-shot startup build, never during live-reload.
409    pub fn with_prune_output(mut self) -> Self {
410        self.prune_output = true;
411        self
412    }
413
414    /// True when any build pipeline is configured (CSS bundle and/or JS minification from
415    /// source folders), i.e. the server should run a startup build.
416    fn has_pipeline(&self) -> bool {
417        self.css_bundle_output.is_some() || !self.source_folders.is_empty()
418    }
419
420    /// Every directory to watch for source changes: the source folders plus the CSS
421    /// `@import` roots, deduplicated so a directory registered as both is watched once.
422    fn watch_targets(&self) -> Vec<PathBuf> {
423        let mut targets = Vec::new();
424        for dir in self.source_folders.iter().chain(self.bundle_roots.iter()) {
425            if !targets.contains(dir) {
426                targets.push(dir.clone());
427            }
428        }
429        targets
430    }
431
432    /// Resolve a request path under the server's root.
433    ///
434    /// This is a lower-level API for resolving paths without generating HTTP responses.
435    /// For most use cases, prefer [`Server::handle_request`] or the `run*` methods.
436    ///
437    /// # Returns
438    ///
439    /// - `Ok(PathBuf)` if the path resolves to a file within root.
440    /// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
441    pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
442        resolve::resolve_with_canonical_root(&self.root_canon, request_path)
443    }
444
445    /// Run the server on a specific address with a configurable header-read timeout.
446    ///
447    /// Spawns the server in a background Tokio task and returns immediately with the
448    /// assigned port number and a [`ServerHandle`]. Call `handle.shutdown().await` to
449    /// stop accepting new connections and wait for in-flight connections to finish.
450    /// Dropping the handle instead leaves the server running for the life of the process.
451    ///
452    /// # Header-Read Timeout
453    ///
454    /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
455    /// This prevents slowloris attacks and resource exhaustion from incomplete requests. The
456    /// timeout applies only to the header-read phase — once a complete header block has been
457    /// read, the connection is handed off with no further time bound, so long-lived response
458    /// bodies (e.g. the live-reload SSE stream from [`Server::with_live_reload`]) are not cut
459    /// off mid-stream.
460    ///
461    /// # Precompressed Sidecars
462    ///
463    /// If a request's `Accept-Encoding` allows `br` or `gzip` (preferring `br`) and a
464    /// sibling `<path>.br`/`<path>.gz` exists next to the resolved file, its bytes are
465    /// served instead with a matching `Content-Encoding`. Every file response carries
466    /// `Vary: Accept-Encoding` so intermediate caches don't serve the wrong variant to a
467    /// differently-capable client.
468    ///
469    /// # Arguments
470    ///
471    /// * `addr` - Socket address to bind to (e.g., `127.0.0.1:0` for loopback ephemeral,
472    ///   or `0.0.0.0:8080` to bind all interfaces on a fixed port).
473    /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
474    ///
475    /// # Returns
476    ///
477    /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
478    /// - `Err(StaticError::Io)` if binding to the socket fails.
479    pub async fn run_on(
480        &self,
481        addr: SocketAddr,
482        header_timeout: Duration,
483    ) -> Result<(u16, ServerHandle), StaticError> {
484        let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
485        let port = listener.local_addr().map_err(StaticError::Io)?.port();
486
487        let mut server = self.clone();
488        if server.live_reload {
489            let broadcaster = Broadcaster::new();
490            if let Some(cache) = &server.minify_cache {
491                Arc::clone(cache).subscribe_to_invalidation(&broadcaster);
492            }
493
494            // The build pipelines react to SOURCE folders only; the output dir is never
495            // watched. Watching the output would feed each pipeline its own writes back
496            // into its trigger — the feedback loop this layering exists to prevent.
497            if server.has_pipeline() {
498                let pipeline = Arc::new(SourcePipeline::new(
499                    server.source_folders.clone(),
500                    server.bundle_roots.clone(),
501                    server.output_dir.clone(),
502                    server.css_bundle_output.clone(),
503                    server.prune_output,
504                    broadcaster.clone(),
505                ));
506                let mut rx = broadcaster.subscribe();
507                tokio::spawn(async move {
508                    // One-shot startup build (and optional prune) first, so the earliest
509                    // request already sees fresh output rather than yesterday's.
510                    if let Err(e) = pipeline.full_build().await {
511                        eprintln!("source pipeline build error: {e}");
512                    }
513                    while let Some(event) = rx.recv().await {
514                        if let Err(e) = pipeline
515                            .process_change(&event.path, &event.change_type)
516                            .await
517                        {
518                            eprintln!("source pipeline error: {e}");
519                        }
520                    }
521                });
522            }
523
524            for dir in server.watch_targets() {
525                start_watching(Arc::new(dir), broadcaster.clone());
526            }
527
528            server.broadcaster = Some(broadcaster);
529        } else if server.has_pipeline() {
530            // No live-reload: still run the one-shot build so a release boot reflects the
531            // current sources. The broadcaster is a throwaway — there is no browser to
532            // notify, so broadcasting into it is a no-op.
533            let pipeline = Arc::new(SourcePipeline::new(
534                server.source_folders.clone(),
535                server.bundle_roots.clone(),
536                server.output_dir.clone(),
537                server.css_bundle_output.clone(),
538                server.prune_output,
539                Broadcaster::new(),
540            ));
541            tokio::spawn(async move {
542                if let Err(e) = pipeline.full_build().await {
543                    eprintln!("source pipeline build error: {e}");
544                }
545            });
546        }
547        let semaphore = Arc::new(Semaphore::new(server.max_connections));
548        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
549
550        let accept_task = tokio::spawn(async move {
551            let mut backoff = ACCEPT_BACKOFF_INITIAL;
552            let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
553            let mut shutdown_pin = std::pin::pin!(shutdown_rx);
554            let mut shutting_down = false;
555
556            loop {
557                if !shutting_down {
558                    // The accept-and-permit step and the shutdown signal race in a single
559                    // `select!` so shutdown can preempt a pending accept or a permit wait
560                    // cleanly, at any point — not just between loop iterations.
561                    tokio::select! {
562                        accepted = accept_and_permit(&listener, &mut backoff, &semaphore) => {
563                            match accepted {
564                                Some((stream, permit)) => {
565                                    let server = server.clone();
566                                    join_set.spawn(async move {
567                                        let _permit = permit;
568                                        serve_connection(stream, server, header_timeout).await;
569                                    });
570                                }
571                                None => shutting_down = true,
572                            }
573                        }
574                        _ = shutdown_pin.as_mut() => {
575                            shutting_down = true;
576                        }
577                    }
578                    continue;
579                }
580
581                // Stop accepting; drain already-spawned connections before returning.
582                match join_set.join_next().await {
583                    Some(_) => continue,
584                    None => break,
585                }
586            }
587        });
588
589        Ok((
590            port,
591            ServerHandle {
592                shutdown_tx: Some(shutdown_tx),
593                accept_task,
594            },
595        ))
596    }
597
598    /// Run the server on loopback (127.0.0.1), binding an ephemeral port.
599    ///
600    /// Thin wrapper around [`Server::run_on`] — see it for the header-read timeout and
601    /// sidecar semantics, and for what the returned [`ServerHandle`] does.
602    pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
603        self.run_on(([127, 0, 0, 1], 0).into(), header_timeout)
604            .await
605    }
606
607    /// Run the server on all interfaces (0.0.0.0) at `port` (0 for an ephemeral port).
608    ///
609    /// Useful for containerized deployments and reverse-proxy setups. Thin wrapper
610    /// around [`Server::run_on`] — see it for the header-read timeout and sidecar
611    /// semantics, and for what the returned [`ServerHandle`] does.
612    pub async fn run_all(
613        &self,
614        port: u16,
615        header_timeout: Duration,
616    ) -> Result<(u16, ServerHandle), StaticError> {
617        self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
618            .await
619    }
620
621    /// Run the server on loopback with the default 30-second header-read timeout.
622    ///
623    /// The recommended entry point for tests and lightweight services that don't need a
624    /// custom timeout. Thin wrapper around [`Server::run`].
625    ///
626    /// # Example
627    ///
628    /// ```no_run
629    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
630    /// use mini_static::Server;
631    /// use std::path::Path;
632    ///
633    /// let server = Server::new(Path::new("./public"))?;
634    /// let (port, handle) = server.run_ephemeral().await?;
635    /// println!("Server ready on http://127.0.0.1:{}", port);
636    /// handle.shutdown().await;
637    /// # Ok(())
638    /// # }
639    /// ```
640    pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
641        self.run(DEFAULT_HEADER_TIMEOUT).await
642    }
643
644    /// Produce the HTTP response for a request, streaming file bodies to the client.
645    ///
646    /// This is the crate's single request-handling path: the `run*` accept loop calls it,
647    /// and so should any async server embedding `mini-static` as a fallback route (e.g.
648    /// `mini-unified`). It never blocks the calling task — path resolution runs on Tokio's
649    /// blocking-thread pool via `spawn_blocking`, and the file is read via async I/O.
650    ///
651    /// File responses are backed by `FileBody`, which hands hyper one 64 KB chunk at a
652    /// time as `poll_frame` is driven: memory use stays bounded to one chunk per in-flight
653    /// response regardless of file size.
654    ///
655    /// `headers` are the request's headers; `If-None-Match` (304 on a matching ETag) and
656    /// `Accept-Encoding` (precompressed sidecar selection, see [`Server::run_on`]) are the
657    /// ones read today. Only `GET` and `HEAD` are allowed; anything else gets a 405 with an
658    /// `Allow` header. Missing files and traversal attempts both get an identical 404, so a
659    /// response never discloses whether a path exists outside the root.
660    pub async fn handle_request(
661        &self,
662        method: &Method,
663        request_path: &str,
664        headers: &HeaderMap,
665    ) -> Response<ResponseBody> {
666        if method != Method::GET && method != Method::HEAD {
667            return text(
668                response(StatusCode::METHOD_NOT_ALLOWED).header("Allow", "GET, HEAD"),
669                "method not allowed\n",
670            );
671        }
672
673        // Live-reload SSE stream — only reachable when `with_live_reload()` was called
674        // and the server was started via a `run*` method (those are the only paths that
675        // populate `broadcaster`).
676        if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
677            if let Some(broadcaster) = &self.broadcaster {
678                return finish(
679                    response(StatusCode::OK)
680                        .header("Content-Type", "text/event-stream")
681                        .header("Cache-Control", "no-cache")
682                        .header("Connection", "keep-alive")
683                        .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
684                );
685            }
686        }
687
688        // `resolve()` does blocking filesystem syscalls (`canonicalize()`, up to two per
689        // request). Running those directly in this `async fn` would block whichever
690        // Tokio worker thread happens to be driving it, stalling every other task
691        // scheduled on that thread for the duration of the syscalls. `spawn_blocking`
692        // moves the work onto Tokio's dedicated blocking thread pool instead.
693        let server = self.clone();
694        let owned_request_path = request_path.to_string();
695        let resolved =
696            tokio::task::spawn_blocking(move || server.resolve(&owned_request_path)).await;
697        let path = match resolved {
698            Err(_) => return internal_error_response(),
699            Ok(Err(e)) => {
700                return text(
701                    response(StatusCode::NOT_FOUND),
702                    format!("{}\n", e.user_message()),
703                )
704            }
705            Ok(Ok(path)) => path,
706        };
707
708        // A directory served via its `index.html` needs a trailing slash to establish the
709        // correct base for the page's relative links. Compare against the *decoded*
710        // request path so a percent-encoded explicit request for index.html (e.g.
711        // `/docs/index.htm%6c`) is recognized as such instead of producing a redirect to a
712        // still-encoded, broken Location.
713        let decoded_request_path = resolve::decode_request_path(request_path);
714        if path.file_name().is_some_and(|name| name == "index.html")
715            && !decoded_request_path.ends_with('/')
716            && !decoded_request_path.ends_with("index.html")
717        {
718            // `location` is built from the (attacker-controlled) request path; `finish()`
719            // degrades to 400 instead of panicking if it ever contains bytes invalid in a
720            // header value.
721            let location = format!("{}/", request_path.trim_end_matches('/'));
722            return text(
723                response(StatusCode::MOVED_PERMANENTLY).header("Location", location),
724                "moved\n",
725            );
726        }
727
728        let Ok(file) = File::open(&path).await else {
729            return internal_error_response();
730        };
731        let Ok(metadata) = file.metadata().await else {
732            return internal_error_response();
733        };
734
735        let content_type = mime_type_for_path(&path);
736        // Live-reload HTML injection needs the original, uncompressed bytes to splice the
737        // reload script into — never substitute a precompressed sidecar on this path.
738        let html_injection = self.broadcaster.is_some() && content_type.starts_with("text/html");
739
740        let accept_encoding = header_str(headers, "accept-encoding");
741        let sidecar = if html_injection {
742            None
743        } else {
744            select_precompressed_sidecar(&path, accept_encoding).await
745        };
746        let (mut file, metadata, content_encoding) = match sidecar {
747            Some((sidecar_file, sidecar_metadata, encoding)) => {
748                (sidecar_file, sidecar_metadata, Some(encoding))
749            }
750            None => (file, metadata, None),
751        };
752
753        // Minification and bundling are skipped for a served precompressed sidecar (already final bytes
754        // from a build step) and for the live-reload HTML injection path (needs the
755        // original text to splice into).
756        let change_type = ChangeType::from_path(&path);
757        let should_minify = self.minify_cache.as_ref().is_some()
758            && content_encoding.is_none()
759            && !html_injection
760            && matches!(change_type, ChangeType::Css | ChangeType::Script)
761            && !minify::is_already_minified(&path);
762
763        let etag = generate_etag(&metadata, if should_minify { "-min" } else { "" });
764        let cache_control = self.cache_control_for(&path);
765
766        if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
767            return finish(
768                Response::builder()
769                    .status(StatusCode::NOT_MODIFIED)
770                    .header("Cache-Control", cache_control)
771                    .header("Vary", "Accept-Encoding")
772                    .header("ETag", etag)
773                    .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
774            );
775        }
776
777        // `Some` when the served representation differs from the file's raw bytes and had
778        // to be built in memory; `None` means stream the open file as-is. Computed before
779        // the HEAD check below because RFC 9110 requires a HEAD response's headers —
780        // `Content-Length` included — to match what a GET would send, even though the body
781        // itself is dropped.
782        let transformed: Option<Bytes> = if should_minify {
783            let cache = self.minify_cache.as_ref().unwrap();
784            let mtime = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
785            match minified_or_raw(cache, &path, mtime, change_type, &mut file).await {
786                Ok(bytes) => Some(bytes),
787                Err(_) => return internal_error_response(),
788            }
789        } else if html_injection {
790            let mut html = Vec::with_capacity(metadata.len() as usize);
791            if file.read_to_end(&mut html).await.is_err() {
792                return internal_error_response();
793            }
794            reload::inject_reload_script(&mut html);
795            Some(Bytes::from(html))
796        } else {
797            None
798        };
799
800        let file_size = transformed
801            .as_ref()
802            .map_or(metadata.len(), |bytes| bytes.len() as u64);
803
804        // HEAD must not return a body (RFC 9110).
805        let body = if *method == Method::HEAD {
806            ResponseBody::Buffered(Full::new(Bytes::new()))
807        } else {
808            match transformed {
809                Some(bytes) => ResponseBody::Buffered(Full::new(bytes)),
810                None => ResponseBody::Streamed(FileBody::new(file)),
811            }
812        };
813
814        let mut builder = response(StatusCode::OK)
815            .header("Content-Type", content_type)
816            .header("Content-Length", file_size.to_string())
817            .header("Cache-Control", cache_control)
818            .header("Vary", "Accept-Encoding")
819            .header("ETag", etag);
820        if let Some(encoding) = content_encoding {
821            builder = builder.header("Content-Encoding", encoding);
822        }
823        finish(builder.body(body))
824    }
825}
826
827/// Minified bytes for `path`, falling back to the file's raw bytes if the source is
828/// malformed — a rare but real possibility (a hand-edited file, a build tool's bug). A
829/// broken minify step shouldn't take down an otherwise-servable file.
830///
831/// # Errors
832///
833/// Returns `Err` only if the fallback read of `file` itself fails.
834async fn minified_or_raw(
835    cache: &MinifyCache,
836    path: &Path,
837    mtime: SystemTime,
838    change_type: ChangeType,
839    file: &mut File,
840) -> Result<Bytes, MinifyError> {
841    if let Ok(minified) = cache
842        .get_or_minify(path, mtime, change_type, minify::minify)
843        .await
844    {
845        return Ok(minified);
846    }
847    let mut raw = Vec::new();
848    file.read_to_end(&mut raw).await.map_err(MinifyError::Io)?;
849    Ok(Bytes::from(raw))
850}
851
852/// Ceiling on how many bytes `read_header_prefix` buffers before giving up. Without this,
853/// a client that trickles bytes forever without ever sending the terminating blank line
854/// could grow the buffer without limit — the header-read timeout alone doesn't bound
855/// memory, only wall-clock time, and a sufficiently patient sender could still send
856/// unbounded data before the deadline fires.
857const MAX_HEADER_BYTES: usize = 64 * 1024;
858
859/// Why `read_header_prefix` gave up before seeing a complete header block. Every variant
860/// is a legitimate reason to drop the connection — none is treated specially by the
861/// caller today, but the distinction is worth preserving for anyone debugging this later.
862#[derive(Debug)]
863enum HeaderReadError {
864    /// The client closed the connection (or shut down its write half) before sending a
865    /// complete header block.
866    ConnectionClosed,
867    /// More than `MAX_HEADER_BYTES` were buffered without seeing `\r\n\r\n`.
868    TooLarge,
869    /// The underlying socket read failed. Kept rather than discarded so a future `log`
870    /// feature has the real I/O error to report instead of an opaque unit variant.
871    #[allow(dead_code)]
872    Io(std::io::Error),
873}
874
875/// Reads from `stream` until a complete HTTP header block (`\r\n\r\n`) has been buffered,
876/// returning every byte read so far — which may include bytes past the header block
877/// (request body, or a second pipelined request) if the client sent them in the same
878/// read. Callers pair this with `tokio::time::timeout` to bound how long the header phase
879/// itself may take; this function has no timeout of its own, only the size ceiling in
880/// `MAX_HEADER_BYTES`.
881async fn read_header_prefix(stream: &mut TcpStream) -> Result<Vec<u8>, HeaderReadError> {
882    let mut buf = Vec::new();
883    let mut chunk = [0u8; 4096];
884
885    loop {
886        let n = stream.read(&mut chunk).await.map_err(HeaderReadError::Io)?;
887        if n == 0 {
888            return Err(HeaderReadError::ConnectionClosed);
889        }
890        buf.extend_from_slice(&chunk[..n]);
891
892        if buf.len() > MAX_HEADER_BYTES {
893            return Err(HeaderReadError::TooLarge);
894        }
895        // Only the tail can hold a terminator this read completed: the `n` new bytes plus
896        // the 3 before them. Rescanning the whole buffer every time would make the header
897        // read quadratic in the bytes received.
898        let scan_from = buf.len().saturating_sub(n + 3);
899        if buf[scan_from..].windows(4).any(|w| w == b"\r\n\r\n") {
900            return Ok(buf);
901        }
902    }
903}
904
905/// Wraps an accepted `TcpStream` whose header block has already been drained into
906/// `prefix` (by `read_header_prefix`, ahead of the connection being handed to hyper).
907/// Reads replay `prefix` first, then fall through to the live socket — so hyper sees
908/// exactly the byte stream it would have seen without the pre-read, just sourced from two
909/// buffers back-to-back instead of one continuous one. Writes pass straight through.
910struct PrefixedIo {
911    prefix: Bytes,
912    prefix_pos: usize,
913    inner: TcpStream,
914}
915
916impl PrefixedIo {
917    fn new(prefix: Vec<u8>, inner: TcpStream) -> Self {
918        PrefixedIo {
919            prefix: Bytes::from(prefix),
920            prefix_pos: 0,
921            inner,
922        }
923    }
924}
925
926impl AsyncRead for PrefixedIo {
927    fn poll_read(
928        self: Pin<&mut Self>,
929        cx: &mut Context<'_>,
930        buf: &mut ReadBuf<'_>,
931    ) -> Poll<std::io::Result<()>> {
932        let this = self.get_mut();
933        if this.prefix_pos < this.prefix.len() {
934            let remaining = &this.prefix[this.prefix_pos..];
935            let n = remaining.len().min(buf.remaining());
936            buf.put_slice(&remaining[..n]);
937            this.prefix_pos += n;
938            return Poll::Ready(Ok(()));
939        }
940        Pin::new(&mut this.inner).poll_read(cx, buf)
941    }
942}
943
944impl AsyncWrite for PrefixedIo {
945    fn poll_write(
946        self: Pin<&mut Self>,
947        cx: &mut Context<'_>,
948        buf: &[u8],
949    ) -> Poll<std::io::Result<usize>> {
950        Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
951    }
952
953    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
954        Pin::new(&mut self.get_mut().inner).poll_flush(cx)
955    }
956
957    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
958        Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
959    }
960}
961
962/// Wires an accepted connection up to the hyper HTTP/1 service.
963///
964/// `header_timeout` bounds only the header-read phase (`read_header_prefix`, run before
965/// hyper ever sees the connection). Once a complete header block has been read, the
966/// connection is handed to hyper with no further time bound — deliberately, since a
967/// response body may legitimately outlive `header_timeout` by design (the live-reload SSE
968/// stream is the motivating case: it stays open until a watched file changes, which may
969/// be minutes or hours after the request). Wrapping the whole connection lifetime in
970/// `header_timeout` — the prior implementation — silently truncated exactly that stream
971/// once `header_timeout` elapsed, aborting the response mid-write after headers had
972/// already been sent (the client observes this as a chunked-encoding error, not a clean
973/// close). The connection-count ceiling (`Server::with_max_connections`) is what bounds
974/// resource use from connections held open indefinitely, not this timeout.
975async fn serve_connection(mut stream: TcpStream, server: Server, header_timeout: Duration) {
976    let prefix = match timeout(header_timeout, read_header_prefix(&mut stream)).await {
977        Ok(Ok(prefix)) => prefix,
978        Ok(Err(_)) | Err(_) => return,
979    };
980
981    let io = TokioIo::new(PrefixedIo::new(prefix, stream));
982    let svc = service_fn(move |req: Request<Incoming>| {
983        let server = server.clone();
984        async move {
985            let resp = server
986                .handle_request(req.method(), req.uri().path(), req.headers())
987                .await;
988            Ok::<_, Infallible>(resp)
989        }
990    });
991    let _ = AutoBuilder::new(TokioExecutor::new())
992        .serve_connection(io, svc)
993        .await;
994}
995
996/// Default header-read timeout used by [`Server::run_ephemeral`].
997const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
998
999/// Default grace period `ServerHandle::shutdown()` waits for in-flight connections to
1000/// finish on their own before aborting whatever is left. A connection with no
1001/// self-imposed end — an open live-reload SSE stream, or any keep-alive connection whose
1002/// peer simply never closes it — would otherwise let `shutdown()` hang forever waiting
1003/// for it to finish naturally. Every wait in this crate has a stated upper bound;
1004/// shutdown is no exception.
1005const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1006
1007/// A handle to a server started by one of the `Server::run*` methods.
1008///
1009/// Dropping this handle without calling `shutdown()` leaves the server running in the
1010/// background for the life of the process. Call `shutdown()` to stop accepting new
1011/// connections and wait for already-accepted connections to finish before returning.
1012pub struct ServerHandle {
1013    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1014    accept_task: tokio::task::JoinHandle<()>,
1015}
1016
1017impl ServerHandle {
1018    /// Stop accepting new connections and wait up to `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT`
1019    /// (5s) for in-flight connections to finish on their own. Equivalent to
1020    /// `shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)` — see that method for what
1021    /// happens to connections still open once the grace period elapses.
1022    pub async fn shutdown(self) {
1023        self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1024            .await;
1025    }
1026
1027    /// Stop accepting new connections and wait up to `drain_timeout` for in-flight
1028    /// connections to finish on their own.
1029    ///
1030    /// Connections still open once `drain_timeout` elapses are aborted rather than
1031    /// waited on further: dropping the accept task drops its `JoinSet`, which aborts
1032    /// every task still tracked in it (see `tokio::task::JoinSet`'s own drop behavior) —
1033    /// which in turn drops each connection's socket, closing it. This is what bounds
1034    /// shutdown when a connection has no natural end of its own (the live-reload SSE
1035    /// stream is the motivating case: it stays open until a watched file changes, which
1036    /// may never happen before the process needs to exit).
1037    pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1038        if let Some(tx) = self.shutdown_tx.take() {
1039            let _ = tx.send(());
1040        }
1041        if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1042            self.accept_task.abort();
1043        }
1044    }
1045}
1046
1047/// Read a request header as a `&str`, or `None` if it's absent or not valid ASCII.
1048fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1049    headers.get(name).and_then(|value| value.to_str().ok())
1050}
1051
1052/// Start a response carrying the baseline security header every response in this crate
1053/// sends. The 304 path is the one exception and builds its own — a 304 repeats only the
1054/// caching validators, not the full header set.
1055fn response(status: StatusCode) -> Builder {
1056    Response::builder()
1057        .status(status)
1058        .header("X-Content-Type-Options", "nosniff")
1059}
1060
1061/// Finish `builder` with a plain-text body. `&'static str` bodies borrow rather than
1062/// allocate; `String` bodies (the 404 message) are moved in.
1063fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1064    finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1065}
1066
1067/// Finishes building a response, degrading to a generic 400 instead of panicking if any
1068/// header value turns out to be invalid for use as an HTTP header value.
1069///
1070/// Every header value that reaches `Response::builder()` in this module is either a
1071/// static string or formatted from internal, already-validated data (a byte count, an
1072/// mtime, a fixed method list) — none of it can actually fail today. But `.unwrap()`
1073/// on that assumption is exactly the kind of thing that turns "can't happen" into a
1074/// production panic the day someone adds a header built from new input without
1075/// re-deriving that guarantee. Routing every response through this one fallible path
1076/// means that mistake fails safe instead of panicking.
1077fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1078    built.unwrap_or_else(|_| bad_request_response())
1079}
1080
1081// `internal_error_response()` and `bad_request_response()` are the fallback responses
1082// `finish()` itself degrades to — every header and body here is a fixed string with no
1083// external input, so `.body(...)` cannot fail. They can't be routed through `finish()`
1084// without it degrading to itself on failure.
1085fn internal_error_response() -> Response<ResponseBody> {
1086    response(StatusCode::INTERNAL_SERVER_ERROR)
1087        .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1088            b"internal server error\n",
1089        ))))
1090        .unwrap()
1091}
1092
1093fn bad_request_response() -> Response<ResponseBody> {
1094    response(StatusCode::BAD_REQUEST)
1095        .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1096            b"bad request\n",
1097        ))))
1098        .unwrap()
1099}
1100
1101/// `Content-Encoding` name and sidecar file extension for each supported precompressed
1102/// variant, in preference order — brotli wins when a client accepts both and both
1103/// sidecars exist.
1104const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1105
1106/// Whether `accept_encoding` allows `encoding`.
1107///
1108/// Matches by substring rather than parsing `q`-value weights or the `identity`/`*`
1109/// directives — a lighter-weight negotiation than a general HTTP client would need,
1110/// sufficient for deciding between two static sidecar files.
1111fn accepts_encoding(accept_encoding: Option<&str>, encoding: &str) -> bool {
1112    accept_encoding.is_some_and(|header| header.contains(encoding))
1113}
1114
1115/// Look for a precompressed sidecar (`<path>.br` / `<path>.gz`) matching the client's
1116/// `Accept-Encoding`, and return its open file, metadata, and encoding name if found.
1117///
1118/// `path` must already be the fully resolved, canonicalized path `resolve()` produced.
1119/// The sidecar path is built by appending an extension to it — never by re-resolving a
1120/// modified request path — so this lookup can't become a second traversal surface: any
1121/// path this function reads is provably a sibling of a path `resolve()` already cleared.
1122async fn select_precompressed_sidecar(
1123    path: &Path,
1124    accept_encoding: Option<&str>,
1125) -> Option<(File, fs::Metadata, &'static str)> {
1126    for (encoding, ext) in SIDECAR_ENCODINGS {
1127        if !accepts_encoding(accept_encoding, encoding) {
1128            continue;
1129        }
1130        let mut sidecar = path.as_os_str().to_os_string();
1131        sidecar.push(ext);
1132        let sidecar_path = PathBuf::from(sidecar);
1133
1134        // Tripwire for the traversal boundary: a sidecar path built by appending a suffix
1135        // must stay in the same directory as `path` (which `resolve()` already proved is
1136        // inside root). `ext` is always one of the two static literals in
1137        // `SIDECAR_ENCODINGS`, never derived from request input, so this can only fire if
1138        // a future change starts deriving `sidecar` some other way.
1139        debug_assert_eq!(
1140            sidecar_path.parent(),
1141            path.parent(),
1142            "sidecar path must stay in the same directory as the already-resolved path"
1143        );
1144
1145        if let Ok(sidecar_file) = File::open(&sidecar_path).await {
1146            if let Ok(sidecar_metadata) = sidecar_file.metadata().await {
1147                return Some((sidecar_file, sidecar_metadata, encoding));
1148            }
1149        }
1150    }
1151    None
1152}
1153
1154/// Generate an ETag for a file based on modification time and size.
1155///
1156/// `variant_suffix` distinguishes a served representation that differs from the raw
1157/// source bytes without needing to read/transform the file just to compute a tag: pass
1158/// `"-min"` when the response will be minified, `""` otherwise. Without this, turning
1159/// `with_minify()` on for an already-served, already-cached file wouldn't change its
1160/// ETag at all (the source file's size and mtime are unchanged) — a client that cached
1161/// the unminified `200` would keep matching on `If-None-Match` and get `304`s forever,
1162/// never seeing the now-minified bytes until the source file's mtime actually changes.
1163///
1164/// Format: `"<size>-<mtime_secs><variant_suffix>"`
1165fn generate_etag(metadata: &fs::Metadata, variant_suffix: &str) -> String {
1166    let mtime = metadata
1167        .modified()
1168        .ok()
1169        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1170        .map(|d| d.as_secs())
1171        .unwrap_or(0);
1172    format!("\"{}-{}{}\"", metadata.len(), mtime, variant_suffix)
1173}
1174
1175/// Determine MIME type from file path extension.
1176fn mime_type_for_path(path: &Path) -> &'static str {
1177    let ext = path
1178        .extension()
1179        .and_then(|ext| ext.to_str())
1180        .unwrap_or_default()
1181        .to_lowercase();
1182
1183    match ext.as_str() {
1184        "html" | "htm" => "text/html; charset=utf-8",
1185        "css" => "text/css; charset=utf-8",
1186        "js" => "application/javascript; charset=utf-8",
1187        "json" => "application/json; charset=utf-8",
1188        "svg" => "image/svg+xml",
1189        "png" => "image/png",
1190        "jpg" | "jpeg" => "image/jpeg",
1191        "gif" => "image/gif",
1192        "webp" => "image/webp",
1193        "ico" => "image/x-icon",
1194        "woff" => "font/woff",
1195        "woff2" => "font/woff2",
1196        "ttf" => "font/ttf",
1197        "md" | "markdown" => "text/markdown; charset=utf-8",
1198        "txt" => "text/plain; charset=utf-8",
1199        "xml" => "application/xml",
1200        "pdf" => "application/pdf",
1201        "zip" => "application/zip",
1202        _ => "application/octet-stream",
1203    }
1204}
1205
1206/// Check if the If-None-Match header matches the current ETag.
1207/// Handles both exact match and wildcard (*) comparison per RFC 9110.
1208fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1209    if if_none_match == "*" {
1210        return true;
1211    }
1212    if_none_match.split(',').any(|tag| tag.trim() == etag)
1213}
1214
1215#[cfg(test)]
1216mod precompressed_sidecar_tests {
1217    use super::*;
1218
1219    // `select_precompressed_sidecar` only ever appends a static extension literal
1220    // (".br"/".gz") to the `path` it's given — it never re-joins against `root` or
1221    // re-parses a request-path string, so it structurally cannot become a second
1222    // traversal surface the way re-running `resolve()` on modified input could. This
1223    // test locks that in by construction: the sidecar it finds must live in exactly
1224    // the same directory as the resolved file, for every encoding preference branch.
1225    #[tokio::test]
1226    async fn sidecar_never_leaves_the_resolved_files_directory() {
1227        let root = tempfile::TempDir::new().unwrap();
1228        let sub = root.path().join("assets");
1229        fs::create_dir(&sub).unwrap();
1230        let resolved = sub.join("app.js");
1231        fs::write(&resolved, b"plain").unwrap();
1232        fs::write(sub.join("app.js.br"), b"brotli-bytes").unwrap();
1233        fs::write(sub.join("app.js.gz"), b"gzip-bytes").unwrap();
1234
1235        let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("br, gzip"))
1236            .await
1237            .expect("both sidecars present, br should be preferred");
1238        assert_eq!(
1239            encoding, "br",
1240            "br must be preferred over gzip when both are accepted"
1241        );
1242
1243        let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("gzip"))
1244            .await
1245            .expect("gzip sidecar present");
1246        assert_eq!(encoding, "gzip");
1247
1248        assert!(
1249            select_precompressed_sidecar(&resolved, None)
1250                .await
1251                .is_none(),
1252            "no Accept-Encoding header should never select a sidecar"
1253        );
1254    }
1255
1256    #[test]
1257    fn accepts_encoding_matches_only_listed_directives() {
1258        assert!(!accepts_encoding(None, "br"));
1259        assert!(!accepts_encoding(Some("identity"), "br"));
1260        assert!(!accepts_encoding(Some("identity"), "gzip"));
1261        assert!(accepts_encoding(Some("gzip, br"), "br"));
1262        assert!(accepts_encoding(Some("gzip"), "gzip"));
1263        assert!(!accepts_encoding(Some("gzip"), "br"));
1264    }
1265}
1266
1267#[cfg(test)]
1268mod file_body_tests {
1269    use super::*;
1270    use crate::handler::FILE_CHUNK_SIZE;
1271    use http_body_util::BodyExt;
1272
1273    // Disproves the prior implementation, which read every chunk into a `Vec` and
1274    // only wrapped the whole result in a single `Full` frame at the end — that
1275    // implementation would fail this test with `frame_count == 1` and
1276    // `max_frame_len == file size`, regardless of `FILE_CHUNK_SIZE`.
1277    #[tokio::test]
1278    async fn file_body_yields_multiple_bounded_chunks_not_one_buffered_frame() {
1279        let dir = tempfile::TempDir::new().unwrap();
1280        let path = dir.path().join("big.bin");
1281        let content = vec![7u8; FILE_CHUNK_SIZE * 3 + 12_345];
1282        fs::write(&path, &content).unwrap();
1283
1284        let file = File::open(&path).await.unwrap();
1285        let mut body = FileBody::new(file);
1286
1287        let mut frame_count = 0usize;
1288        let mut max_frame_len = 0usize;
1289        let mut reassembled = Vec::new();
1290
1291        while let Some(frame) = body.frame().await {
1292            let frame = frame.unwrap();
1293            let data = frame.into_data().unwrap();
1294            frame_count += 1;
1295            max_frame_len = max_frame_len.max(data.len());
1296            reassembled.extend_from_slice(&data);
1297        }
1298
1299        assert!(
1300            frame_count > 1,
1301            "expected the file to be delivered as multiple frames, got {frame_count}"
1302        );
1303        assert!(
1304            max_frame_len <= FILE_CHUNK_SIZE,
1305            "no single frame should exceed the chunk size ({FILE_CHUNK_SIZE}), got {max_frame_len}"
1306        );
1307        assert_eq!(
1308            reassembled, content,
1309            "reassembled chunks must match original file content exactly"
1310        );
1311    }
1312}
1313
1314#[cfg(test)]
1315mod accept_tests {
1316    use super::*;
1317    use std::sync::atomic::{AtomicUsize, Ordering};
1318    use std::sync::Mutex;
1319
1320    /// Fails `accept()` a fixed number of times, recording the (paused, virtual)
1321    /// instant of each attempt, before delegating to a real listener so the caller can
1322    /// eventually succeed.
1323    struct FlakyListener {
1324        inner: TcpListener,
1325        remaining_failures: AtomicUsize,
1326        attempts: Mutex<Vec<tokio::time::Instant>>,
1327    }
1328
1329    impl TcpAccept for FlakyListener {
1330        async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
1331            self.attempts
1332                .lock()
1333                .unwrap()
1334                .push(tokio::time::Instant::now());
1335            if self.remaining_failures.fetch_sub(1, Ordering::SeqCst) > 0 {
1336                Err(std::io::Error::other("simulated accept error"))
1337            } else {
1338                TcpAccept::accept(&self.inner).await
1339            }
1340        }
1341    }
1342
1343    // Disproves the prior implementation, which broke out of the accept loop entirely
1344    // on the first `accept()` error — permanently ending the server. This test would
1345    // also fail against a naive `continue`-only fix (no backoff): the recorded gaps
1346    // between attempts would collapse to ~0 (a busy spin) instead of the expected
1347    // exponentially growing delays.
1348    #[tokio::test(start_paused = true)]
1349    async fn accept_loop_backs_off_between_repeated_errors_instead_of_busy_spinning() {
1350        let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1351        let addr = inner.local_addr().unwrap();
1352
1353        let flaky = FlakyListener {
1354            inner,
1355            remaining_failures: AtomicUsize::new(5),
1356            attempts: Mutex::new(Vec::new()),
1357        };
1358
1359        tokio::spawn(async move {
1360            let _ = TcpStream::connect(addr).await;
1361        });
1362
1363        let semaphore = Arc::new(Semaphore::new(1));
1364        let mut backoff = ACCEPT_BACKOFF_INITIAL;
1365        let result = accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1366        assert!(
1367            result.is_some(),
1368            "accept should eventually succeed once the flaky listener stops failing"
1369        );
1370
1371        let recorded = flaky.attempts.lock().unwrap();
1372        assert_eq!(recorded.len(), 6, "5 failures then 1 success");
1373
1374        let expected_gaps = [
1375            ACCEPT_BACKOFF_INITIAL,
1376            ACCEPT_BACKOFF_INITIAL * 2,
1377            ACCEPT_BACKOFF_INITIAL * 4,
1378            ACCEPT_BACKOFF_INITIAL * 8,
1379            ACCEPT_BACKOFF_INITIAL * 16,
1380        ];
1381        for (i, expected) in expected_gaps.iter().enumerate() {
1382            let gap = recorded[i + 1] - recorded[i];
1383            assert_eq!(
1384                gap,
1385                *expected,
1386                "gap between attempt {i} and {} should reflect the backoff delay, not a busy spin",
1387                i + 1
1388            );
1389        }
1390
1391        // The delay must stop doubling at the cap rather than growing without bound.
1392        let mut capped = ACCEPT_BACKOFF_MAX;
1393        capped = (capped * 2).min(ACCEPT_BACKOFF_MAX);
1394        assert_eq!(capped, ACCEPT_BACKOFF_MAX);
1395    }
1396
1397    // A successful accept must clear the accumulated delay, so an isolated error later
1398    // on doesn't inherit a second-long wait from an unrelated earlier failure.
1399    #[tokio::test(start_paused = true)]
1400    async fn a_successful_accept_resets_the_backoff() {
1401        let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1402        let addr = inner.local_addr().unwrap();
1403        let flaky = FlakyListener {
1404            inner,
1405            remaining_failures: AtomicUsize::new(3),
1406            attempts: Mutex::new(Vec::new()),
1407        };
1408        tokio::spawn(async move {
1409            let _ = TcpStream::connect(addr).await;
1410        });
1411
1412        let semaphore = Arc::new(Semaphore::new(1));
1413        let mut backoff = ACCEPT_BACKOFF_INITIAL * 32;
1414        accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1415
1416        assert_eq!(
1417            backoff, ACCEPT_BACKOFF_INITIAL,
1418            "the delay must return to its initial value once an accept succeeds"
1419        );
1420    }
1421}
1422
1423#[cfg(test)]
1424mod finish_tests {
1425    use super::*;
1426
1427    // Disproves a bare `.unwrap()` on the same builder: CR/LF is not a legal header
1428    // value byte (it would enable header/response splitting), so this construction is
1429    // guaranteed to make `.body(...)` return `Err`. Every real call site in this module
1430    // only ever builds header values from static strings or internally-formatted
1431    // numbers, so this test can't happen through normal use — it exists to prove
1432    // `finish()`'s fallback path actually works, not to exercise a reachable case.
1433    #[test]
1434    fn finish_degrades_to_400_on_invalid_header_value_instead_of_panicking() {
1435        let built = Response::builder()
1436            .status(StatusCode::OK)
1437            .header("X-Test", "invalid\r\nvalue")
1438            .body(ResponseBody::Buffered(Full::new(Bytes::new())));
1439        assert!(
1440            built.is_err(),
1441            "CR/LF in a header value should be rejected by the builder"
1442        );
1443
1444        let response = finish(built);
1445        assert_eq!(
1446            response.status(),
1447            StatusCode::BAD_REQUEST,
1448            "finish() should degrade to 400 rather than panicking on an invalid header value"
1449        );
1450    }
1451}
1452
1453#[cfg(test)]
1454mod header_prefix_tests {
1455    use super::*;
1456    use tokio::io::AsyncWriteExt;
1457
1458    /// Binds an ephemeral listener, connects a client to it, and returns both ends —
1459    /// `(server_side, client_side)` — so a test can drive `read_header_prefix` against a
1460    /// real socket without a full `Server`/`serve_connection` in the loop.
1461    async fn connected_pair() -> (TcpStream, TcpStream) {
1462        let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1463        let addr = listener.local_addr().unwrap();
1464        let client = TcpStream::connect(addr).await.unwrap();
1465        let (server_side, _) = listener.accept().await.unwrap();
1466        (server_side, client)
1467    }
1468
1469    #[tokio::test]
1470    async fn reads_exactly_up_to_and_including_the_terminating_blank_line() {
1471        let (mut server_side, mut client) = connected_pair().await;
1472
1473        client
1474            .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
1475            .await
1476            .unwrap();
1477
1478        let prefix = read_header_prefix(&mut server_side)
1479            .await
1480            .unwrap_or_else(|_| {
1481                panic!("expected a complete header block to be read");
1482            });
1483
1484        assert_eq!(prefix, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
1485    }
1486
1487    // Disproves a version that only inspects the newest chunk for `\r\n\r\n`: writing the
1488    // blank line in a separate write (and thus, almost always, a separate read) after the
1489    // rest of the headers would make that version wait forever, since the terminator
1490    // never appears within a single chunk. Also pins the tail-only scan in
1491    // `read_header_prefix` — a terminator straddling two reads must still be seen.
1492    #[tokio::test]
1493    async fn assembles_a_header_block_split_across_multiple_writes() {
1494        let (mut server_side, mut client) = connected_pair().await;
1495
1496        client
1497            .write_all(b"GET /page HTTP/1.1\r\nHost: localhost\r")
1498            .await
1499            .unwrap();
1500        client.write_all(b"\n\r\n").await.unwrap();
1501
1502        let prefix = read_header_prefix(&mut server_side)
1503            .await
1504            .unwrap_or_else(|_| {
1505                panic!("expected a complete header block to be read across multiple writes");
1506            });
1507
1508        assert_eq!(prefix, b"GET /page HTTP/1.1\r\nHost: localhost\r\n\r\n");
1509    }
1510
1511    // Bytes past the header block (a pipelined second request, here) must be preserved
1512    // verbatim in the returned prefix — `PrefixedIo` depends on this to replay them to
1513    // hyper untouched.
1514    #[tokio::test]
1515    async fn preserves_bytes_sent_past_the_header_block() {
1516        let (mut server_side, mut client) = connected_pair().await;
1517
1518        let first = b"GET /a HTTP/1.1\r\nHost: localhost\r\n\r\n";
1519        let second = b"GET /b HTTP/1.1\r\nHost: localhost\r\n\r\n";
1520        let mut sent = Vec::new();
1521        sent.extend_from_slice(first);
1522        sent.extend_from_slice(second);
1523        client.write_all(&sent).await.unwrap();
1524
1525        let prefix = read_header_prefix(&mut server_side)
1526            .await
1527            .unwrap_or_else(|_| {
1528                panic!("expected a complete header block to be read");
1529            });
1530
1531        assert_eq!(
1532            &prefix, &sent,
1533            "pipelined bytes past the first header block must survive intact"
1534        );
1535    }
1536
1537    #[tokio::test]
1538    async fn errors_with_connection_closed_when_client_disconnects_before_headers_complete() {
1539        let (mut server_side, client) = connected_pair().await;
1540        drop(client);
1541
1542        match read_header_prefix(&mut server_side).await {
1543            Err(HeaderReadError::ConnectionClosed) => {}
1544            Err(_) => panic!("expected ConnectionClosed, got a different error variant"),
1545            Ok(_) => {
1546                panic!("expected an error, got a complete header block from a closed connection")
1547            }
1548        }
1549    }
1550
1551    // Disproves an unbounded buffer: without the `MAX_HEADER_BYTES` check, this would
1552    // hang consuming memory forever instead of erroring, since the client never sends the
1553    // terminating blank line.
1554    #[tokio::test]
1555    async fn errors_with_too_large_once_max_header_bytes_is_exceeded_without_a_terminator() {
1556        let (mut server_side, mut client) = connected_pair().await;
1557
1558        let garbage = vec![b'a'; MAX_HEADER_BYTES + 1];
1559        client.write_all(&garbage).await.unwrap();
1560
1561        match read_header_prefix(&mut server_side).await {
1562            Err(HeaderReadError::TooLarge) => {}
1563            Err(_) => panic!("expected TooLarge, got a different error variant"),
1564            Ok(_) => {
1565                panic!("expected an error, got a complete header block from unterminated garbage")
1566            }
1567        }
1568    }
1569
1570    #[tokio::test]
1571    async fn prefixed_io_replays_the_prefix_before_reading_from_the_live_socket() {
1572        let (server_side, mut client) = connected_pair().await;
1573        let mut io = PrefixedIo::new(b"buffered-prefix".to_vec(), server_side);
1574
1575        client.write_all(b"-live-bytes").await.unwrap();
1576
1577        let mut collected = Vec::new();
1578        let mut chunk = [0u8; 8];
1579        while collected.len() < b"buffered-prefix-live-bytes".len() {
1580            let n = io.read(&mut chunk).await.unwrap();
1581            assert!(n > 0, "read returned 0 before all expected bytes arrived");
1582            collected.extend_from_slice(&chunk[..n]);
1583        }
1584
1585        assert_eq!(collected, b"buffered-prefix-live-bytes");
1586    }
1587}
1588
1589#[cfg(test)]
1590mod css_bundle_tests {
1591    use super::*;
1592    use std::fs;
1593    use std::time::Duration;
1594    use tempfile::TempDir;
1595    use tokio::time::sleep;
1596
1597    #[tokio::test]
1598    async fn source_folder_overlapping_output_dir_is_rejected() {
1599        let root = TempDir::new().unwrap();
1600
1601        // The output dir defaults to the served root, so registering that root as a source
1602        // folder must be refused: watching the output would feed every pipeline its own
1603        // writes back into its trigger.
1604        let result = Server::new(root.path())
1605            .unwrap()
1606            .with_source_folder(root.path());
1607        assert!(
1608            result.is_err(),
1609            "a source folder equal to the output dir must be rejected"
1610        );
1611    }
1612
1613    #[tokio::test]
1614    async fn source_folder_inside_output_dir_is_rejected() {
1615        let root = TempDir::new().unwrap();
1616        let nested = root.path().join("nested");
1617        fs::create_dir(&nested).unwrap();
1618
1619        let result = Server::new(root.path())
1620            .unwrap()
1621            .with_source_folder(&nested);
1622        assert!(
1623            result.is_err(),
1624            "a source folder nested in the output dir must be rejected"
1625        );
1626    }
1627
1628    #[tokio::test]
1629    async fn output_dir_overlapping_source_folder_is_rejected() {
1630        let root = TempDir::new().unwrap();
1631        let source = TempDir::new().unwrap();
1632
1633        let server = Server::new(root.path())
1634            .unwrap()
1635            .with_source_folder(source.path())
1636            .unwrap();
1637
1638        let result = server.with_output_dir(source.path());
1639        assert!(
1640            result.is_err(),
1641            "an output dir equal to a source folder must be rejected"
1642        );
1643    }
1644
1645    #[tokio::test]
1646    async fn css_bundle_creates_output_on_startup_with_live_reload() {
1647        let src = TempDir::new().unwrap();
1648        let out = TempDir::new().unwrap();
1649
1650        fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
1651
1652        let server = Server::new(out.path())
1653            .unwrap()
1654            .with_live_reload()
1655            .with_source_folder(src.path())
1656            .unwrap()
1657            .with_css_bundle();
1658
1659        let (_port, handle) = server.run_ephemeral().await.unwrap();
1660
1661        sleep(Duration::from_millis(800)).await;
1662
1663        let bundle = out.path().join("styles.css");
1664        assert!(
1665            bundle.exists(),
1666            "bundle should be written to the default <output>/styles.css"
1667        );
1668        let content = fs::read_to_string(&bundle).unwrap();
1669        assert!(!content.is_empty(), "bundle should contain CSS");
1670
1671        handle.shutdown().await;
1672    }
1673
1674    #[tokio::test]
1675    async fn css_bundle_rebuilds_once_and_settles_when_source_css_changes() {
1676        let src = TempDir::new().unwrap();
1677        let out = TempDir::new().unwrap();
1678        let src_path = src.path();
1679        let bundle = out.path().join("styles.css");
1680
1681        fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
1682
1683        let server = Server::new(out.path())
1684            .unwrap()
1685            .with_live_reload()
1686            .with_source_folder(src_path)
1687            .unwrap()
1688            .with_css_bundle();
1689
1690        let (_port, handle) = server.run_ephemeral().await.unwrap();
1691
1692        // Let the startup build and the watcher's first poll pass (500ms) complete.
1693        sleep(Duration::from_millis(800)).await;
1694        assert!(bundle.exists());
1695
1696        fs::write(
1697            src_path.join("style.css"),
1698            "body { margin: 0; color: blue; }",
1699        )
1700        .unwrap();
1701
1702        // Wait long enough for the watcher poll + rebundle to land at least once.
1703        sleep(Duration::from_millis(1500)).await;
1704        let content_v2 = fs::read_to_string(&bundle).unwrap();
1705        assert!(
1706            content_v2.contains("color"),
1707            "rebundle should contain the new color rule"
1708        );
1709
1710        let mtime_after = fs::metadata(&bundle).unwrap().modified().unwrap();
1711        sleep(Duration::from_millis(1200)).await;
1712        let mtime_later = fs::metadata(&bundle).unwrap().modified().unwrap();
1713
1714        // The regression this guards: the output write must NOT re-trigger another rebuild
1715        // (the feedback loop would keep mutating the bundle's mtime here). A settled mtime
1716        // over a full poll interval proves a single rebuild, not a loop.
1717        assert_eq!(
1718            mtime_after, mtime_later,
1719            "bundle mtime must settle after one rebuild — an ongoing loop would keep changing it"
1720        );
1721
1722        handle.shutdown().await;
1723    }
1724
1725    #[tokio::test]
1726    async fn css_bundle_creates_output_on_startup_without_live_reload() {
1727        let src = TempDir::new().unwrap();
1728        let out = TempDir::new().unwrap();
1729
1730        fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
1731
1732        let server = Server::new(out.path())
1733            .unwrap()
1734            .with_source_folder(src.path())
1735            .unwrap()
1736            .with_css_bundle();
1737
1738        let (_port, handle) = server.run_ephemeral().await.unwrap();
1739
1740        sleep(Duration::from_millis(200)).await;
1741
1742        let bundle = out.path().join("styles.css");
1743        assert!(
1744            bundle.exists(),
1745            "bundle should be created even without live_reload"
1746        );
1747        let content = fs::read_to_string(&bundle).unwrap();
1748        assert!(!content.is_empty(), "bundle should contain CSS");
1749
1750        handle.shutdown().await;
1751    }
1752
1753    #[tokio::test]
1754    async fn css_bundle_concatenates_multiple_source_css_files() {
1755        let src = TempDir::new().unwrap();
1756        let out = TempDir::new().unwrap();
1757
1758        fs::write(src.path().join("reset.css"), "* { margin: 0; padding: 0; }").unwrap();
1759        fs::write(src.path().join("theme.css"), "body { background: white; }").unwrap();
1760
1761        let server = Server::new(out.path())
1762            .unwrap()
1763            .with_live_reload()
1764            .with_source_folder(src.path())
1765            .unwrap()
1766            .with_css_bundle();
1767
1768        let (_port, handle) = server.run_ephemeral().await.unwrap();
1769
1770        sleep(Duration::from_millis(800)).await;
1771
1772        let content = fs::read_to_string(out.path().join("styles.css")).unwrap();
1773        assert!(
1774            content.contains("margin"),
1775            "output should contain reset CSS"
1776        );
1777        assert!(
1778            content.contains("background"),
1779            "output should contain theme CSS"
1780        );
1781
1782        handle.shutdown().await;
1783    }
1784}