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::sync::Arc;
7use std::time::{Duration, SystemTime};
8
9use bytes::Bytes;
10use hyper::{Method, Response, StatusCode, Request};
11use hyper::service::service_fn;
12use http_body_util::Full;
13use hyper::body::Incoming;
14use hyper_util::rt::TokioExecutor;
15use hyper_util::rt::TokioIo;
16use hyper_util::server::conn::auto::Builder as AutoBuilder;
17use tokio::fs::File;
18use tokio::net::{TcpListener, TcpStream};
19use tokio::sync::{OwnedSemaphorePermit, Semaphore};
20use tokio::time::timeout;
21
22use crate::error::StaticError;
23use crate::handler::{FileBody, ResponseBody};
24use crate::resolve;
25
26const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
27const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
28
29/// A source of accepted TCP connections. Abstracted so the accept-error backoff below
30/// can be exercised against a listener that fails on demand, without needing to provoke
31/// real OS-level accept errors (e.g. EMFILE) in tests.
32trait TcpAccept {
33    async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
34}
35
36impl TcpAccept for TcpListener {
37    async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
38        TcpListener::accept(self).await
39    }
40}
41
42/// Exponential backoff for retrying `accept()` after an error, so a sustained failure
43/// (e.g. the process is out of file descriptors) degrades into periodic retries instead
44/// of a CPU-bound busy spin or, worse, silently ending the accept loop for good. Resets
45/// to the initial delay as soon as an accept succeeds.
46struct Backoff {
47    delay: Duration,
48}
49
50impl Backoff {
51    fn new() -> Self {
52        Backoff { delay: ACCEPT_BACKOFF_INITIAL }
53    }
54
55    fn next_delay(&mut self) -> Duration {
56        let delay = self.delay;
57        self.delay = (self.delay * 2).min(ACCEPT_BACKOFF_MAX);
58        delay
59    }
60
61    fn reset(&mut self) {
62        self.delay = ACCEPT_BACKOFF_INITIAL;
63    }
64}
65
66/// Accept a connection and reserve it a connection-limit permit, retrying transient
67/// `accept()` errors with `Backoff` instead of ending the accept loop on the first one.
68/// Returns `None` only if the semaphore itself has been closed (never happens in normal
69/// operation, since nothing ever calls `close()` on it — handled so a caller can still
70/// fail safely rather than panic).
71async fn accept_and_permit<L: TcpAccept>(
72    listener: &L,
73    backoff: &mut Backoff,
74    semaphore: &Arc<Semaphore>,
75) -> Option<(TcpStream, OwnedSemaphorePermit)> {
76    loop {
77        let stream = match listener.accept().await {
78            Ok((stream, _)) => {
79                backoff.reset();
80                stream
81            }
82            Err(_) => {
83                tokio::time::sleep(backoff.next_delay()).await;
84                continue;
85            }
86        };
87        return match semaphore.clone().acquire_owned().await {
88            Ok(permit) => Some((stream, permit)),
89            Err(_) => None,
90        };
91    }
92}
93
94/// A static file server for serving files securely from a root directory.
95///
96/// `Server` canonicalizes the root directory once at creation time and uses the
97/// canonical form for all subsequent requests, avoiding repeated filesystem calls.
98///
99/// # Security
100///
101/// The server protects against:
102/// - Path traversal attacks (e.g., `../../etc/passwd`)
103/// - Accessing files outside the root via symlinks
104/// - Disclosing filesystem structure (traversal and missing files both return 404)
105///
106/// # Cloning
107///
108/// `Server` is cheap to clone (a `PathBuf` and a `usize`). Multiple clones can be used
109/// concurrently in async tasks without synchronization overhead.
110///
111/// # Example
112///
113/// ```no_run
114/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
115/// use mini_static::Server;
116/// use std::path::Path;
117/// use std::time::Duration;
118///
119/// let server = Server::new(Path::new("./public"))?;
120/// let (port, _handle) = server.run(Duration::from_secs(30)).await?;
121/// println!("Server running on port {}", port);
122/// # Ok(())
123/// # }
124/// ```
125#[derive(Clone)]
126pub struct Server {
127    root_canon: PathBuf,
128    max_connections: usize,
129}
130
131impl Server {
132    /// Create a new server with the given root directory.
133    ///
134    /// Canonicalizes the root once at startup. All subsequent requests use the
135    /// canonical root without re-canonicalizing it, making this suitable for long-lived servers.
136    ///
137    /// # Arguments
138    ///
139    /// * `root` - The root directory to serve files from.
140    ///
141    /// # Errors
142    ///
143    /// Returns `Err(StaticError::Io)` if the root cannot be canonicalized (e.g., doesn't exist,
144    /// no read permissions).
145    pub fn new(root: &Path) -> Result<Self, StaticError> {
146        let root_canon = root.canonicalize().map_err(StaticError::Io)?;
147        Ok(Server { root_canon, max_connections: DEFAULT_MAX_CONNECTIONS })
148    }
149
150    /// Set the maximum number of connections served concurrently (default 1024).
151    ///
152    /// Once this many connections are in flight, `run()`'s accept loop stops accepting
153    /// new ones — without pausing the accept loop, a client that opens a connection and
154    /// sends nothing (see the header-read timeout docs on `run()`) could otherwise be
155    /// used, in enough parallel copies, to exhaust the process's file descriptors or
156    /// memory with no bound at all.
157    pub fn with_max_connections(mut self, max: usize) -> Self {
158        self.max_connections = max;
159        self
160    }
161
162    /// Resolve a request path under the server's root.
163    ///
164    /// This is a lower-level API for resolving paths without generating HTTP responses.
165    /// For most use cases, prefer `handle_request_with_method()` or the `run()` methods.
166    ///
167    /// # Arguments
168    ///
169    /// * `request_path` - The HTTP request path (e.g., `/path/to/file.html`).
170    ///
171    /// # Returns
172    ///
173    /// - `Ok(PathBuf)` if the path resolves to a file within root.
174    /// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
175    pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
176        resolve::resolve_with_canonical_root(&self.root_canon, request_path)
177    }
178
179    /// Handle an HTTP GET request for a resource path.
180    ///
181    /// Convenience method equivalent to `handle_request_with_method(&Method::GET, request_path)`.
182    ///
183    /// # Arguments
184    ///
185    /// * `request_path` - The HTTP request path (e.g., `/index.html`).
186    pub fn handle_request(&self, request_path: &str) -> Response<ResponseBody> {
187        self.handle_request_with_method(&Method::GET, request_path)
188    }
189
190    /// Handle an HTTP request with an explicit method.
191    ///
192    /// Only GET and HEAD methods are allowed. Other methods return 405 Method Not Allowed
193    /// with an Allow header listing the permitted methods.
194    ///
195    /// # Arguments
196    ///
197    /// * `method` - The HTTP method (GET and HEAD are allowed; others return 405).
198    /// * `request_path` - The HTTP request path (e.g., `/index.html`).
199    pub fn handle_request_with_method(
200        &self,
201        method: &Method,
202        request_path: &str,
203    ) -> Response<ResponseBody> {
204        self.handle_request_with_headers(method, request_path, None, None)
205    }
206
207    /// Run the server on a specific address with a configurable header-read timeout.
208    ///
209    /// Spawns the server in a background Tokio task and returns immediately with the
210    /// assigned port number and a [`ServerHandle`]. Call `handle.shutdown().await` to
211    /// stop accepting new connections and wait for in-flight connections to finish.
212    ///
213    /// # Header-Read Timeout
214    ///
215    /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
216    /// This prevents slowloris attacks and resource exhaustion from incomplete requests.
217    ///
218    /// # Arguments
219    ///
220    /// * `addr` - Socket address to bind to (e.g., `127.0.0.1:0` for loopback ephemeral,
221    ///   or `0.0.0.0:8080` to bind all interfaces on a fixed port).
222    /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
223    ///
224    /// # Returns
225    ///
226    /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
227    /// - `Err(StaticError::Io)` if binding to the socket fails.
228    pub async fn run_on(&self, addr: SocketAddr, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
229        let listener = TcpListener::bind(addr)
230            .await
231            .map_err(StaticError::Io)?;
232        let port = listener
233            .local_addr()
234            .map_err(StaticError::Io)?
235            .port();
236
237        let server = self.clone();
238        let semaphore = Arc::new(Semaphore::new(server.max_connections));
239        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
240
241        let accept_task = tokio::spawn(async move {
242            let mut backoff = Backoff::new();
243            let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
244            let mut shutdown_pin = std::pin::pin!(shutdown_rx);
245            let mut shutting_down = false;
246
247            loop {
248                if !shutting_down {
249                    // The accept-and-permit step and the shutdown signal race in a single
250                    // `select!` so shutdown can preempt a pending accept or a permit wait
251                    // cleanly, at any point — not just between loop iterations.
252                    tokio::select! {
253                        accepted = accept_and_permit(&listener, &mut backoff, &semaphore) => {
254                            match accepted {
255                                Some((stream, permit)) => {
256                                    let server = server.clone();
257                                    join_set.spawn(async move {
258                                        let _permit = permit;
259                                        serve_connection(stream, server, header_timeout).await;
260                                    });
261                                }
262                                None => shutting_down = true,
263                            }
264                        }
265                        _ = shutdown_pin.as_mut() => {
266                            shutting_down = true;
267                        }
268                    }
269                    continue;
270                }
271
272                // Stop accepting; drain already-spawned connections before returning.
273                match join_set.join_next().await {
274                    Some(_) => continue,
275                    None => break,
276                }
277            }
278        });
279
280        Ok((port, ServerHandle { shutdown_tx: Some(shutdown_tx), accept_task }))
281    }
282
283    /// Run the server on loopback (127.0.0.1) with a configurable header-read timeout.
284    ///
285    /// Binds to an ephemeral port and spawns the server in a background Tokio task.
286    /// Returns immediately with the assigned port number and a [`ServerHandle`]. Dropping
287    /// the handle without calling `shutdown()` leaves the server running in the
288    /// background for the life of the process — the same behavior `run()` always had.
289    /// Call `handle.shutdown().await` to stop accepting new connections and wait for
290    /// in-flight connections to finish.
291    ///
292    /// # Header-Read Timeout
293    ///
294    /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
295    /// This prevents slowloris attacks and resource exhaustion from incomplete requests.
296    ///
297    /// # Arguments
298    ///
299    /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
300    ///
301    /// # Returns
302    ///
303    /// - `Ok((u16, ServerHandle))` with the ephemeral port number assigned by the OS and
304    ///   a handle for graceful shutdown.
305    /// - `Err(StaticError::Io)` if binding to the socket fails.
306    ///
307    /// # Example
308    ///
309    /// ```no_run
310    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
311    /// use mini_static::Server;
312    /// use std::path::Path;
313    /// use std::time::Duration;
314    ///
315    /// let server = Server::new(Path::new("./public"))?;
316    /// let (port, handle) = server.run(Duration::from_secs(30)).await?;
317    /// println!("Server running on http://127.0.0.1:{}", port);
318    /// // ... later, to stop it gracefully:
319    /// handle.shutdown().await;
320    /// # Ok(())
321    /// # }
322    /// ```
323    pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
324        let addr: SocketAddr = ([127, 0, 0, 1], 0).into();
325        self.run_on(addr, header_timeout).await
326    }
327
328    /// Run the server on all interfaces (0.0.0.0) with a configurable header-read timeout.
329    ///
330    /// Binds to a specified port on all network interfaces. Useful for containerized
331    /// deployments, reverse-proxy setups, or services that need to accept connections
332    /// from anywhere. Spawns the server in a background Tokio task and returns immediately
333    /// with the assigned port and a [`ServerHandle`].
334    ///
335    /// # Header-Read Timeout
336    ///
337    /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
338    /// This prevents slowloris attacks and resource exhaustion from incomplete requests.
339    ///
340    /// # Arguments
341    ///
342    /// * `port` - Port number to bind to (0 for ephemeral port assignment).
343    /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
344    ///
345    /// # Returns
346    ///
347    /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
348    /// - `Err(StaticError::Io)` if binding to the socket fails.
349    ///
350    /// # Example
351    ///
352    /// ```no_run
353    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
354    /// use mini_static::Server;
355    /// use std::path::Path;
356    /// use std::time::Duration;
357    ///
358    /// let server = Server::new(Path::new("./public"))?;
359    /// let (_port, handle) = server.run_all(8080, Duration::from_secs(30)).await?;
360    /// println!("Server listening on 0.0.0.0:8080");
361    /// handle.shutdown().await;
362    /// # Ok(())
363    /// # }
364    /// ```
365    pub async fn run_all(&self, port: u16, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
366        let addr: SocketAddr = ([0, 0, 0, 0], port).into();
367        self.run_on(addr, header_timeout).await
368    }
369
370    /// Run the server on loopback (127.0.0.1) with a default header-read timeout.
371    ///
372    /// Convenience wrapper around `run()` that uses a default 30-second header-read timeout.
373    /// Returns immediately with the ephemeral port number and a [`ServerHandle`]; the server
374    /// continues in a background Tokio task until the handle's `shutdown()` is awaited or
375    /// the Tokio runtime shuts down.
376    ///
377    /// This is the recommended method for tests and lightweight services that don't require
378    /// custom timeout configuration.
379    ///
380    /// # Returns
381    ///
382    /// - `Ok((u16, ServerHandle))` with the ephemeral port number assigned by the OS and
383    ///   a handle for graceful shutdown.
384    /// - `Err(StaticError::Io)` if binding to the socket fails.
385    ///
386    /// # Example
387    ///
388    /// ```no_run
389    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
390    /// use mini_static::Server;
391    /// use std::path::Path;
392    ///
393    /// let server = Server::new(Path::new("./public"))?;
394    /// let (port, handle) = server.run_ephemeral().await?;
395    /// println!("Server ready on http://127.0.0.1:{}", port);
396    /// handle.shutdown().await;
397    /// # Ok(())
398    /// # }
399    /// ```
400    pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
401        self.run(Duration::from_secs(30)).await
402    }
403
404    /// Handle an HTTP request asynchronously, streaming file bodies to the client.
405    ///
406    /// This is the method to call when embedding `mini-static` inside another async
407    /// server's request-handling path (e.g. as a catch-all fallback route). Unlike
408    /// [`Server::handle_request`] and its synchronous siblings, this method never blocks
409    /// the calling task: path resolution runs on Tokio's blocking-thread pool via
410    /// `spawn_blocking`, and the file is read via async I/O.
411    ///
412    /// File responses are backed by `FileBody`, which reads and hands off one 64 KB
413    /// chunk to hyper at a time as `poll_frame` is driven — memory use stays bounded to
414    /// one chunk per in-flight response regardless of file size, and no chunk is copied
415    /// or zero-filled beyond what the read syscall itself writes.
416    ///
417    /// Conditional requests (If-None-Match, If-Modified-Since) are honored: if the
418    /// request includes a validator that matches the file's ETag, returns 304 Not Modified.
419    pub async fn handle_request_async(
420        &self,
421        method: &Method,
422        request_path: &str,
423        if_none_match: Option<&str>,
424        if_modified_since: Option<&str>,
425    ) -> Response<ResponseBody> {
426        // Gate on HTTP method
427        if method != Method::GET && method != Method::HEAD {
428            return finish(Response::builder()
429                .status(StatusCode::METHOD_NOT_ALLOWED)
430                .header("Allow", "GET, HEAD")
431                .header("X-Content-Type-Options", "nosniff")
432                .body(into_response_body(Full::new(Bytes::from("method not allowed\n")))));
433        }
434
435        // `resolve()` does blocking filesystem syscalls (`canonicalize()`, up to two per
436        // request). Running those directly in this `async fn` would block whichever
437        // Tokio worker thread happens to be driving it, stalling every other task
438        // scheduled on that thread for the duration of the syscalls. `spawn_blocking`
439        // moves the work onto Tokio's dedicated blocking thread pool instead.
440        let server = self.clone();
441        let owned_request_path = request_path.to_string();
442        let resolved = tokio::task::spawn_blocking(move || server.resolve(&owned_request_path)).await;
443        let resolved = match resolved {
444            Ok(r) => r,
445            Err(_) => return internal_error_response(),
446        };
447
448        match resolved {
449            Ok(path) => {
450                let decoded_request_path = resolve::decode_request_path(request_path);
451                if path.file_name().is_some_and(|name| name == "index.html")
452                    && !decoded_request_path.ends_with('/')
453                    && !decoded_request_path.ends_with("index.html")
454                {
455                    let location = format!("{}/", request_path.trim_end_matches('/'));
456                    // `location` is built from the (attacker-controlled) request path;
457                    // `finish()` degrades to 400 instead of panicking if it ever contains
458                    // bytes invalid in a header value.
459                    return finish(Response::builder()
460                        .status(StatusCode::MOVED_PERMANENTLY)
461                        .header("Location", location)
462                        .header("X-Content-Type-Options", "nosniff")
463                        .body(into_response_body(Full::new(Bytes::from("moved\n")))));
464                }
465
466                // Use async file operations for streaming
467                let file = match File::open(&path).await {
468                    Ok(f) => f,
469                    Err(_) => return internal_error_response(),
470                };
471
472                let metadata = match file.metadata().await {
473                    Ok(m) => m,
474                    Err(_) => return internal_error_response(),
475                };
476
477                let file_size = metadata.len();
478                let etag = generate_etag(&metadata);
479
480                // Check If-None-Match (ETag) for 304 Not Modified
481                if let Some(if_none_match) = if_none_match {
482                    if is_etag_match(if_none_match, &etag) {
483                        return finish(Response::builder()
484                            .status(StatusCode::NOT_MODIFIED)
485                            .header("ETag", etag)
486                            .body(into_response_body(Full::new(Bytes::new()))));
487                    }
488                }
489
490                // Check If-Modified-Since (mtime) for 304 Not Modified
491                if let Some(if_modified_since) = if_modified_since {
492                    if is_not_modified_since(if_modified_since, &metadata) {
493                        return finish(Response::builder()
494                            .status(StatusCode::NOT_MODIFIED)
495                            .header("ETag", etag)
496                            .body(into_response_body(Full::new(Bytes::new()))));
497                    }
498                }
499
500                // HEAD must not return a body (RFC 9110); skip opening the read stream
501                // entirely since we'd just discard every chunk.
502                let body: ResponseBody = if *method == Method::HEAD {
503                    into_response_body(Full::new(Bytes::new()))
504                } else {
505                    ResponseBody::Streamed(FileBody::new(file))
506                };
507
508                let content_type = mime_type_for_path(&path);
509                finish(Response::builder()
510                    .status(StatusCode::OK)
511                    .header("X-Content-Type-Options", "nosniff")
512                    .header("Content-Type", content_type)
513                    .header("Content-Length", file_size.to_string())
514                    .header("ETag", etag)
515                    .body(body))
516            }
517            Err(e) => {
518                let message = e.user_message();
519                let body = format!("{}\n", message);
520
521                finish(Response::builder()
522                    .status(StatusCode::NOT_FOUND)
523                    .header("X-Content-Type-Options", "nosniff")
524                    .body(into_response_body(Full::new(Bytes::from(body)))))
525            }
526        }
527    }
528
529    /// Handle an HTTP request with method and optional Range/If-Range headers (synchronous API).
530    ///
531    /// This is the synchronous version of request handling used internally by the
532    /// async server loop. For most use cases, prefer using `run()` or `run_ephemeral()`
533    /// which handle the full async lifecycle.
534    ///
535    /// Only GET and HEAD methods are allowed; other methods return 405 Method Not Allowed.
536    /// All errors (missing files, traversal attempts, I/O failures) are returned as 404
537    /// to avoid leaking filesystem structure information.
538    ///
539    /// # Range Request Handling
540    ///
541    /// mini-static does not yet serve `206 Partial Content` — every request,
542    /// ranged or not, gets the full body with `200`. This is RFC 9110-correct behavior
543    /// (as opposed to incorrectly answering `416`), but partial-content serving is
544    /// deferred to a later phase.
545    ///
546    /// # Arguments
547    ///
548    /// * `method` - The HTTP method (GET and HEAD only).
549    /// * `request_path` - The HTTP request path (e.g., `/index.html`).
550    /// * `_range_header` - Optional Range header (currently unused).
551    /// * `_if_range_header` - Optional If-Range header (currently unused).
552    pub fn handle_request_with_headers(
553        &self,
554        method: &Method,
555        request_path: &str,
556        _range_header: Option<&str>,
557        _if_range_header: Option<&str>,
558    ) -> Response<ResponseBody> {
559        // Gate on HTTP method
560        if method != Method::GET && method != Method::HEAD {
561            return finish(Response::builder()
562                .status(StatusCode::METHOD_NOT_ALLOWED)
563                .header("Allow", "GET, HEAD")
564                .header("X-Content-Type-Options", "nosniff")
565                .body(into_response_body(Full::new(Bytes::from("method not allowed\n")))));
566        }
567
568        // Method is allowed; resolve the path
569        match self.resolve(request_path) {
570            Ok(path) => {
571                // Check if resolved path is index.html but request_path doesn't end with /
572                // If so, redirect to path/ to establish correct base for relative links.
573                // Compare against the *decoded* request path so a percent-encoded explicit
574                // request for index.html (e.g. `/docs/index.htm%6c`) is recognized as such
575                // instead of producing a redirect to a still-encoded, broken Location.
576                let decoded_request_path = resolve::decode_request_path(request_path);
577                if path.file_name().is_some_and(|name| name == "index.html")
578                    && !decoded_request_path.ends_with('/')
579                    && !decoded_request_path.ends_with("index.html")
580                {
581                    let location = format!("{}/", request_path.trim_end_matches('/'));
582
583                    // Location is built from the (attacker-controlled) request path;
584                    // `finish()` degrades to 400 instead of panicking if it ever contains
585                    // bytes invalid in a header value.
586                    return finish(Response::builder()
587                        .status(StatusCode::MOVED_PERMANENTLY)
588                        .header("Location", location)
589                        .header("X-Content-Type-Options", "nosniff")
590                        .body(into_response_body(Full::new(Bytes::from("moved\n")))));
591                }
592
593                let file = match fs::File::open(&path) {
594                    Ok(f) => f,
595                    Err(_) => return internal_error_response(),
596                };
597                let metadata = match file.metadata() {
598                    Ok(m) => m,
599                    Err(_) => return internal_error_response(),
600                };
601                let file_size = metadata.len();
602                let etag = generate_etag(&metadata);
603
604                // HEAD must not return a body (RFC 9110); avoid reading file content we'd
605                // just discard.
606                let body_bytes = if *method == Method::HEAD {
607                    Bytes::new()
608                } else {
609                    let mut buf = Vec::with_capacity(file_size as usize);
610                    let mut file = file;
611                    if file.read_to_end(&mut buf).is_err() {
612                        return internal_error_response();
613                    }
614                    Bytes::from(buf)
615                };
616
617                finish(Response::builder()
618                    .status(StatusCode::OK)
619                    .header("X-Content-Type-Options", "nosniff")
620                    .header("Content-Length", file_size.to_string())
621                    .header("ETag", etag)
622                    .body(into_response_body(Full::new(body_bytes))))
623            }
624            Err(e) => {
625                let message = e.user_message();
626                let body = format!("{}\n", message);
627
628                finish(Response::builder()
629                    .status(StatusCode::NOT_FOUND)
630                    .header("X-Content-Type-Options", "nosniff")
631                    .body(into_response_body(Full::new(Bytes::from(body)))))
632            }
633        }
634    }
635}
636
637/// Wires an accepted connection up to the hyper HTTP/1 service and drives it to
638/// completion, bounded by `header_timeout`. Shared by every accept loop so the
639/// framing/timeout setup is defined exactly once.
640async fn serve_connection(stream: TcpStream, server: Server, header_timeout: Duration) {
641    let io = TokioIo::new(stream);
642    let svc = service_fn(move |req: Request<Incoming>| {
643        let server = server.clone();
644        async move {
645            let method = req.method().clone();
646            let path = req.uri().path().to_string();
647            let if_none_match = req.headers().get("if-none-match").and_then(|v| v.to_str().ok());
648            let if_modified_since = req.headers().get("if-modified-since").and_then(|v| v.to_str().ok());
649            let resp = server.handle_request_async(&method, &path, if_none_match, if_modified_since).await;
650            Ok::<_, Infallible>(resp)
651        }
652    });
653    let _ = timeout(
654        header_timeout,
655        AutoBuilder::new(TokioExecutor::new()).serve_connection(io, svc),
656    ).await;
657}
658
659/// A handle to a server started by `Server::run()` or `Server::run_ephemeral()`.
660///
661/// Dropping this handle without calling `shutdown()` leaves the server running in the
662/// background for the life of the process — the same behavior `run()` always had before
663/// this handle existed. Call `shutdown()` to stop accepting new connections and wait for
664/// already-accepted connections to finish before returning.
665pub struct ServerHandle {
666    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
667    accept_task: tokio::task::JoinHandle<()>,
668}
669
670impl ServerHandle {
671    /// Stop accepting new connections and wait for in-flight connections to finish.
672    pub async fn shutdown(mut self) {
673        if let Some(tx) = self.shutdown_tx.take() {
674            let _ = tx.send(());
675        }
676        let _ = self.accept_task.await;
677    }
678}
679
680fn into_response_body(body: Full<Bytes>) -> ResponseBody {
681    ResponseBody::Buffered(body)
682}
683
684/// Finishes building a response, degrading to a generic 400 instead of panicking if any
685/// header value turns out to be invalid for use as an HTTP header value.
686///
687/// Every header value that reaches `Response::builder()` in this module is either a
688/// static string or formatted from internal, already-validated data (a byte count, an
689/// mtime, a fixed method list) — none of it can actually fail today. But `.unwrap()`
690/// on that assumption is exactly the kind of thing that turns "can't happen" into a
691/// production panic the day someone adds a header built from new input without
692/// re-deriving that guarantee. Routing every response through this one fallible path
693/// means that mistake fails safe instead of panicking.
694fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
695    built.unwrap_or_else(|_| bad_request_response())
696}
697
698/// Default maximum concurrent connections, overridable via `Server::with_max_connections`.
699const DEFAULT_MAX_CONNECTIONS: usize = 1024;
700
701// `internal_error_response()` and `bad_request_response()` are the fallback responses
702// `finish()` itself degrades to — every header and body here is a fixed string with no
703// external input, so `.body(...)` cannot fail. They can't be routed through `finish()`
704// without it degrading to itself on failure.
705fn internal_error_response() -> Response<ResponseBody> {
706    Response::builder()
707        .status(StatusCode::INTERNAL_SERVER_ERROR)
708        .header("X-Content-Type-Options", "nosniff")
709        .body(into_response_body(Full::new(Bytes::from(
710            "internal server error\n",
711        ))))
712        .unwrap()
713}
714
715fn bad_request_response() -> Response<ResponseBody> {
716    Response::builder()
717        .status(StatusCode::BAD_REQUEST)
718        .header("X-Content-Type-Options", "nosniff")
719        .body(into_response_body(Full::new(Bytes::from("bad request\n"))))
720        .unwrap()
721}
722
723/// Generate an ETag for a file based on modification time and size.
724///
725/// Format: `"<size>-<mtime_secs>"`
726fn generate_etag(metadata: &fs::Metadata) -> String {
727    let size = metadata.len();
728    let mtime = metadata
729        .modified()
730        .ok()
731        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
732        .map(|d| d.as_secs())
733        .unwrap_or(0);
734    format!("\"{}-{}\"", size, mtime)
735}
736
737/// Determine MIME type from file path extension.
738fn mime_type_for_path(path: &Path) -> &'static str {
739    path.extension()
740        .and_then(|ext| ext.to_str())
741        .and_then(|ext| match ext.to_lowercase().as_str() {
742            "html" | "htm" => Some("text/html; charset=utf-8"),
743            "css" => Some("text/css; charset=utf-8"),
744            "js" => Some("application/javascript; charset=utf-8"),
745            "json" => Some("application/json; charset=utf-8"),
746            "svg" => Some("image/svg+xml"),
747            "png" => Some("image/png"),
748            "jpg" | "jpeg" => Some("image/jpeg"),
749            "gif" => Some("image/gif"),
750            "webp" => Some("image/webp"),
751            "ico" => Some("image/x-icon"),
752            "woff" => Some("font/woff"),
753            "woff2" => Some("font/woff2"),
754            "ttf" => Some("font/ttf"),
755            "md" | "markdown" => Some("text/markdown; charset=utf-8"),
756            "txt" => Some("text/plain; charset=utf-8"),
757            "xml" => Some("application/xml"),
758            "pdf" => Some("application/pdf"),
759            "zip" => Some("application/zip"),
760            _ => None,
761        })
762        .unwrap_or("application/octet-stream")
763}
764
765/// Check if the If-None-Match header matches the current ETag.
766/// Handles both exact match and wildcard (*) comparison per RFC 9110.
767fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
768    if if_none_match == "*" {
769        return true;
770    }
771    if_none_match.split(',').any(|tag| tag.trim() == etag)
772}
773
774/// Check if If-Modified-Since indicates the file hasn't been modified.
775/// Returns true if the file's mtime is before/equal to the If-Modified-Since timestamp.
776fn is_not_modified_since(if_modified_since: &str, metadata: &fs::Metadata) -> bool {
777    let file_mtime = metadata
778        .modified()
779        .ok()
780        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
781        .map(|d| d.as_secs())
782        .unwrap_or(0);
783
784    // Parse the If-Modified-Since header as an HTTP-date (RFC 9110 Section 5.6.7).
785    // For simplicity, try to parse as a simple Unix timestamp first, then fall back to
786    // a basic string comparison. A production implementation would use a proper
787    // RFC 2822 / RFC 9110 date parser, but for testing we can be lenient.
788    if let Ok(client_time) = if_modified_since.parse::<u64>() {
789        return file_mtime <= client_time;
790    }
791
792    // Fallback: if parsing fails, be conservative and don't return 304.
793    false
794}
795
796#[cfg(test)]
797mod file_body_tests {
798    use super::*;
799    use crate::handler::FILE_CHUNK_SIZE;
800    use http_body_util::BodyExt;
801
802    // Disproves the prior implementation, which read every chunk into a `Vec` and
803    // only wrapped the whole result in a single `Full` frame at the end — that
804    // implementation would fail this test with `frame_count == 1` and
805    // `max_frame_len == file size`, regardless of `FILE_CHUNK_SIZE`.
806    #[tokio::test]
807    async fn file_body_yields_multiple_bounded_chunks_not_one_buffered_frame() {
808        let dir = tempfile::TempDir::new().unwrap();
809        let path = dir.path().join("big.bin");
810        let content = vec![7u8; FILE_CHUNK_SIZE * 3 + 12_345];
811        fs::write(&path, &content).unwrap();
812
813        let file = File::open(&path).await.unwrap();
814        let mut body = FileBody::new(file);
815
816        let mut frame_count = 0usize;
817        let mut max_frame_len = 0usize;
818        let mut reassembled = Vec::new();
819
820        while let Some(frame) = body.frame().await {
821            let frame = frame.unwrap();
822            let data = frame.into_data().unwrap();
823            frame_count += 1;
824            max_frame_len = max_frame_len.max(data.len());
825            reassembled.extend_from_slice(&data);
826        }
827
828        assert!(
829            frame_count > 1,
830            "expected the file to be delivered as multiple frames, got {frame_count}"
831        );
832        assert!(
833            max_frame_len <= FILE_CHUNK_SIZE,
834            "no single frame should exceed the chunk size ({FILE_CHUNK_SIZE}), got {max_frame_len}"
835        );
836        assert_eq!(reassembled, content, "reassembled chunks must match original file content exactly");
837    }
838}
839
840#[cfg(test)]
841mod accept_tests {
842    use super::*;
843    use std::sync::atomic::{AtomicUsize, Ordering};
844    use std::sync::Mutex;
845
846    #[test]
847    fn backoff_doubles_up_to_max() {
848        let mut backoff = Backoff::new();
849        let mut last = backoff.next_delay();
850        assert_eq!(last, ACCEPT_BACKOFF_INITIAL);
851
852        // Double repeatedly; it must stop growing once it hits the cap rather than
853        // continuing to double forever (a fixed upper bound, not an unbounded retry).
854        for _ in 0..20 {
855            last = backoff.next_delay();
856        }
857        assert_eq!(last, ACCEPT_BACKOFF_MAX);
858    }
859
860    #[test]
861    fn backoff_reset_returns_to_initial_delay() {
862        let mut backoff = Backoff::new();
863        backoff.next_delay();
864        backoff.next_delay();
865        backoff.reset();
866        assert_eq!(backoff.next_delay(), ACCEPT_BACKOFF_INITIAL);
867    }
868
869    /// Fails `accept()` a fixed number of times, recording the (paused, virtual)
870    /// instant of each attempt, before delegating to a real listener so the caller can
871    /// eventually succeed.
872    struct FlakyListener {
873        inner: TcpListener,
874        remaining_failures: AtomicUsize,
875        attempts: Mutex<Vec<tokio::time::Instant>>,
876    }
877
878    impl TcpAccept for FlakyListener {
879        async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
880            self.attempts.lock().unwrap().push(tokio::time::Instant::now());
881            if self.remaining_failures.fetch_sub(1, Ordering::SeqCst) > 0 {
882                Err(std::io::Error::other("simulated accept error"))
883            } else {
884                TcpAccept::accept(&self.inner).await
885            }
886        }
887    }
888
889    // Disproves the prior implementation, which broke out of the accept loop entirely
890    // on the first `accept()` error — permanently ending the server. This test would
891    // also fail against a naive `continue`-only fix (no backoff): the recorded gaps
892    // between attempts would collapse to ~0 (a busy spin) instead of the expected
893    // exponentially growing delays.
894    #[tokio::test(start_paused = true)]
895    async fn accept_loop_backs_off_between_repeated_errors_instead_of_busy_spinning() {
896        let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
897        let addr = inner.local_addr().unwrap();
898
899        let flaky = FlakyListener {
900            inner,
901            remaining_failures: AtomicUsize::new(5),
902            attempts: Mutex::new(Vec::new()),
903        };
904
905        tokio::spawn(async move {
906            let _ = TcpStream::connect(addr).await;
907        });
908
909        let semaphore = Arc::new(Semaphore::new(1));
910        let mut backoff = Backoff::new();
911        let result = accept_and_permit(&flaky, &mut backoff, &semaphore).await;
912        assert!(result.is_some(), "accept should eventually succeed once the flaky listener stops failing");
913
914        let recorded = flaky.attempts.lock().unwrap();
915        assert_eq!(recorded.len(), 6, "5 failures then 1 success");
916
917        let expected_gaps = [
918            ACCEPT_BACKOFF_INITIAL,
919            ACCEPT_BACKOFF_INITIAL * 2,
920            ACCEPT_BACKOFF_INITIAL * 4,
921            ACCEPT_BACKOFF_INITIAL * 8,
922            ACCEPT_BACKOFF_INITIAL * 16,
923        ];
924        for (i, expected) in expected_gaps.iter().enumerate() {
925            let gap = recorded[i + 1] - recorded[i];
926            assert_eq!(
927                gap, *expected,
928                "gap between attempt {i} and {} should reflect the backoff delay, not a busy spin",
929                i + 1
930            );
931        }
932    }
933}
934
935#[cfg(test)]
936mod finish_tests {
937    use super::*;
938
939    // Disproves a bare `.unwrap()` on the same builder: CR/LF is not a legal header
940    // value byte (it would enable header/response splitting), so this construction is
941    // guaranteed to make `.body(...)` return `Err`. Every real call site in this module
942    // only ever builds header values from static strings or internally-formatted
943    // numbers, so this test can't happen through normal use — it exists to prove
944    // `finish()`'s fallback path actually works, not to exercise a reachable case.
945    #[test]
946    fn finish_degrades_to_400_on_invalid_header_value_instead_of_panicking() {
947        let built = Response::builder()
948            .status(StatusCode::OK)
949            .header("X-Test", "invalid\r\nvalue")
950            .body(into_response_body(Full::new(Bytes::new())));
951        assert!(built.is_err(), "CR/LF in a header value should be rejected by the builder");
952
953        let response = finish(built);
954        assert_eq!(
955            response.status(),
956            StatusCode::BAD_REQUEST,
957            "finish() should degrade to 400 rather than panicking on an invalid header value"
958        );
959    }
960}