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