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