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