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