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