Skip to main content

mini_static/
server.rs

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