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