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