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, AsyncSeekExt, AsyncWrite, ReadBuf};
21use tokio::net::{TcpListener, TcpStream};
22use tokio::sync::{OwnedSemaphorePermit, Semaphore};
23use tokio::time::timeout;
24
25use crate::css::{CssOptions, CssTool};
26use crate::error::StaticError;
27use crate::handler::{FileBody, ResponseBody};
28use crate::js::{JsOptions, JsTool};
29use crate::reload::{self, SseBody};
30use crate::resolve;
31use crate::source::SourcePipeline;
32use crate::spa::{self, SpaTransition};
33use crate::tool;
34use crate::watcher::{start_watching, Broadcaster};
35
36const ACCEPT_BACKOFF_INITIAL: Duration = Duration::from_millis(10);
37const ACCEPT_BACKOFF_MAX: Duration = Duration::from_secs(1);
38
39/// Default maximum concurrent connections, overridable via `Server::with_max_connections`.
40const DEFAULT_MAX_CONNECTIONS: usize = 1024;
41
42/// A source of accepted TCP connections. Abstracted so the accept-error backoff below
43/// can be exercised against a listener that fails on demand, without needing to provoke
44/// real OS-level accept errors (e.g. EMFILE) in tests.
45trait TcpAccept {
46 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)>;
47}
48
49impl TcpAccept for TcpListener {
50 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
51 TcpListener::accept(self).await
52 }
53}
54
55/// Accept a connection and reserve it a connection-limit permit.
56///
57/// `backoff` retries a failed `accept()` after an exponentially growing delay (reset on
58/// the next success, capped at `ACCEPT_BACKOFF_MAX`) instead of ending the accept loop,
59/// so a sustained failure — the process being out of file descriptors, say — degrades
60/// into periodic retries rather than a CPU-bound busy spin or a permanently dead server.
61///
62/// Returns `None` only if the semaphore itself has been closed (never happens in normal
63/// operation, since nothing ever calls `close()` on it — handled so a caller can still
64/// fail safely rather than panic).
65async fn accept_and_permit<L: TcpAccept>(
66 listener: &L,
67 backoff: &mut Duration,
68 semaphore: &Arc<Semaphore>,
69) -> Option<(TcpStream, OwnedSemaphorePermit)> {
70 loop {
71 let stream = match listener.accept().await {
72 Ok((stream, _)) => {
73 *backoff = ACCEPT_BACKOFF_INITIAL;
74 stream
75 }
76 Err(_) => {
77 tokio::time::sleep(*backoff).await;
78 *backoff = (*backoff * 2).min(ACCEPT_BACKOFF_MAX);
79 continue;
80 }
81 };
82 return semaphore
83 .clone()
84 .acquire_owned()
85 .await
86 .ok()
87 .map(|permit| (stream, permit));
88 }
89}
90
91/// A predicate deciding whether a resolved file path should get an immutable cache
92/// policy; see [`Server::with_immutable_assets`].
93type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
94
95/// A static file server for serving files securely from a root directory.
96///
97/// `Server` canonicalizes the root directory once at creation time and uses the
98/// canonical form for all subsequent requests, avoiding repeated filesystem calls.
99///
100/// # Security
101///
102/// The server protects against:
103/// - Path traversal attacks (e.g., `../../etc/passwd`)
104/// - Accessing files outside the root via symlinks
105/// - Disclosing filesystem structure (traversal and missing files both return 404)
106///
107/// # Cloning
108///
109/// `Server` is cheap to clone: a `PathBuf`, a couple of primitives, and an `Arc`'d
110/// predicate closure. Multiple clones can be used concurrently in async tasks without
111/// synchronization overhead.
112///
113/// # Example
114///
115/// ```no_run
116/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
117/// use mini_static::Server;
118/// use std::path::Path;
119/// use std::time::Duration;
120///
121/// let server = Server::new(Path::new("./public"))?;
122/// let (port, _handle) = server.run(Duration::from_secs(30)).await?;
123/// println!("Server running on port {}", port);
124/// # Ok(())
125/// # }
126/// ```
127#[derive(Clone)]
128pub struct Server {
129 root_canon: PathBuf,
130 bundle_roots: Vec<PathBuf>,
131 max_connections: usize,
132 live_reload: bool,
133 broadcaster: Option<Broadcaster>,
134 spa_mode: bool,
135 spa_root: Option<String>,
136 spa_transition: SpaTransition,
137 immutable_predicate: Option<ImmutablePredicate>,
138 source_folders: Vec<PathBuf>,
139 asset_folders: Vec<PathBuf>,
140 output_dir: PathBuf,
141 css_tool: Option<(CssTool, CssOptions)>,
142 js_tool: Option<(JsTool, JsOptions)>,
143 prune_output: bool,
144}
145
146/// True when two canonical paths are the same path or one contains the other.
147///
148/// Used by the source/output overlap check: `Path::starts_with` compares component-wise, so
149/// `/a/b` neither equals nor contains their sibling `/a/b/c` itself. This is the single
150/// place the overlap rule is defined, so both `with_source_folder` and `with_output_dir`
151/// cannot drift apart.
152fn paths_overlap(a: &Path, b: &Path) -> bool {
153 a.starts_with(b) || b.starts_with(a)
154}
155
156impl Server {
157 /// Create a new server with the given root directory.
158 ///
159 /// Canonicalizes the root once at startup. All subsequent requests use the
160 /// canonical root without re-canonicalizing it, making this suitable for long-lived servers.
161 ///
162 /// # Errors
163 ///
164 /// Returns `Err(StaticError::Io)` if the root cannot be canonicalized (e.g., doesn't exist,
165 /// no read permissions).
166 pub fn new(root: &Path) -> Result<Self, StaticError> {
167 let root_canon = root.canonicalize().map_err(StaticError::Io)?;
168 let output_dir = root_canon.clone();
169 Ok(Server {
170 root_canon,
171 bundle_roots: Vec::new(),
172 max_connections: DEFAULT_MAX_CONNECTIONS,
173 live_reload: false,
174 broadcaster: None,
175 spa_mode: false,
176 spa_root: None,
177 spa_transition: SpaTransition::default(),
178 immutable_predicate: None,
179 source_folders: Vec::new(),
180 asset_folders: Vec::new(),
181 output_dir,
182 css_tool: None,
183 js_tool: None,
184 prune_output: false,
185 })
186 }
187
188 /// Set the maximum number of connections served concurrently (default 1024).
189 ///
190 /// Once this many connections are in flight, `run()`'s accept loop stops accepting
191 /// new ones — without pausing the accept loop, a client that opens a connection and
192 /// sends nothing (see the header-read timeout docs on [`Server::run_on`]) could
193 /// otherwise be used, in enough parallel copies, to exhaust the process's file
194 /// descriptors or memory with no bound at all.
195 pub fn with_max_connections(mut self, max: usize) -> Self {
196 self.max_connections = max;
197 self
198 }
199
200 /// Enable live-reload for this server (disabled by default).
201 ///
202 /// Once enabled, the `run*` methods start a background watcher (mtime polling,
203 /// bounded 500ms interval — see [`crate::start_watching`]) over the server's root
204 /// the first time the server actually starts accepting connections, and:
205 ///
206 /// - serve a live-reload SSE stream at [`crate::LIVE_RELOAD_PATH`], broadcasting a
207 /// change event (with [`crate::ChangeType`]) whenever a served file is added,
208 /// modified, or removed;
209 /// - inject a small `<script>` into every served `text/html` response that connects
210 /// to that stream and reloads the page (or hot-swaps stylesheet `<link>`s, for CSS
211 /// changes) — no manual client wiring required.
212 ///
213 /// This is meant for local development, not production: leave it disabled (the
214 /// default) for any server serving real traffic. A typical call site gates it behind
215 /// `#[cfg(debug_assertions)]` so a release build never pays for the watcher or the
216 /// injected script.
217 ///
218 /// # Example
219 ///
220 /// ```no_run
221 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
222 /// use mini_static::Server;
223 /// use std::path::Path;
224 ///
225 /// let server = Server::new(Path::new("./public"))?;
226 /// #[cfg(debug_assertions)]
227 /// let server = server.with_live_reload();
228 /// # Ok(())
229 /// # }
230 /// ```
231 pub fn with_live_reload(mut self) -> Self {
232 self.live_reload = true;
233 self
234 }
235
236 /// Enable spa-mode navigation for this server, swapping `document.body` on
237 /// each navigation (disabled by default).
238 ///
239 /// Once enabled, every served `text/html` response gets a small `<script>`
240 /// injected (see [`Server::with_spa_root`] for what it does) that treats
241 /// `document.body` as the swap target. Calling this after
242 /// [`Server::with_spa_root`] does not clear a previously configured root
243 /// selector — the two methods set independent fields, so
244 /// `.with_spa_root(sel).with_spa_mode()` and
245 /// `.with_spa_mode().with_spa_root(sel)` both end up with spa-mode on and
246 /// root `sel`. Use this one alone when there's no persistent chrome to
247 /// preserve across navigations.
248 ///
249 /// # Example
250 ///
251 /// ```no_run
252 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
253 /// use mini_static::Server;
254 /// use std::path::Path;
255 ///
256 /// let server = Server::new(Path::new("./public"))?.with_spa_mode();
257 /// # Ok(())
258 /// # }
259 /// ```
260 pub fn with_spa_mode(mut self) -> Self {
261 self.spa_mode = true;
262 self
263 }
264
265 /// Enable spa-mode navigation for this server, swapping only the element
266 /// matched by the CSS `selector` on each navigation (disabled by default;
267 /// also enables spa-mode the same as [`Server::with_spa_mode`]).
268 ///
269 /// Once enabled, every served `text/html` response gets a small `<script>`
270 /// injected that intercepts left-clicks on same-origin `<a href>`
271 /// elements (skipping links with a non-`_self` `target`, a `download`
272 /// attribute, `rel="external"`, a `data-no-spa` attribute, or a same-page
273 /// hash-only href) and, instead of a normal navigation:
274 ///
275 /// - fetches the target URL;
276 /// - on a non-OK or non-`text/html` response (or a fetch error), falls
277 /// back to a real `location.href` navigation — spa-mode never renders a
278 /// broken page;
279 /// - otherwise replaces the matched element's `innerHTML` with the
280 /// corresponding content from the fetched document, updates the page
281 /// title, and pushes the new URL via `history.pushState`, animating the
282 /// swap with `document.startViewTransition()` where supported;
283 /// - dispatches a `mini-static:navigate` `CustomEvent` on `window` after
284 /// every client-side navigation, so page scripts can re-run any
285 /// per-page initialization that would otherwise only execute once
286 /// (content swapped in via `innerHTML` never executes its own
287 /// `<script>` tags);
288 /// - handles browser back/forward by re-fetching and swapping to the new
289 /// `location.href`.
290 ///
291 /// `selector` is matched against both the current page and the fetched
292 /// page; a link click where the selector matches neither falls back to a
293 /// real navigation, same as a fetch failure. Choose a `selector` that
294 /// wraps only the content that varies between pages, leaving persistent
295 /// chrome (nav/header/footer) outside it so it survives navigation
296 /// untouched.
297 ///
298 /// This is meant to be usable in production, not just local development
299 /// (unlike [`Server::with_live_reload`]): a click on a link mini-static
300 /// doesn't intercept, or on a browser without JS or View Transitions
301 /// support, still works as a normal navigation.
302 ///
303 /// # Example
304 ///
305 /// ```no_run
306 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
307 /// use mini_static::Server;
308 /// use std::path::Path;
309 ///
310 /// let server = Server::new(Path::new("./public"))?.with_spa_root("#app");
311 /// # Ok(())
312 /// # }
313 /// ```
314 pub fn with_spa_root(mut self, selector: &str) -> Self {
315 self.spa_mode = true;
316 self.spa_root = Some(selector.to_string());
317 self
318 }
319
320 /// Set how spa-mode animates the swap between pages (also enables
321 /// spa-mode the same as [`Server::with_spa_mode`]; default
322 /// [`SpaTransition::Fade`] when spa-mode is enabled without calling this).
323 ///
324 /// [`SpaTransition::Slide`] injects its own `<style>` tag alongside the
325 /// spa-mode `<script>` — no site CSS is required. See [`SpaTransition`]
326 /// for what each variant does.
327 ///
328 /// # Example
329 ///
330 /// ```no_run
331 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
332 /// use mini_static::{Server, SpaTransition};
333 /// use std::path::Path;
334 ///
335 /// let server = Server::new(Path::new("./public"))?
336 /// .with_spa_root("#app")
337 /// .with_spa_transition(SpaTransition::Slide);
338 /// # Ok(())
339 /// # }
340 /// ```
341 pub fn with_spa_transition(mut self, transition: SpaTransition) -> Self {
342 self.spa_mode = true;
343 self.spa_transition = transition;
344 self
345 }
346
347 /// Serve files matching `predicate` with a long-lived, immutable cache policy
348 /// instead of the default `Cache-Control: no-cache`.
349 ///
350 /// `predicate` is evaluated against each resolved file's path; a match sends
351 /// `Cache-Control: public, max-age=31536000, immutable` on that file's 200 and 304
352 /// responses. This is correct only for fingerprinted assets (e.g.
353 /// `main.a1b2c3.js`) where a content change always produces a new filename —
354 /// caching a mutable filename indefinitely would serve stale content to every
355 /// client that already has it cached.
356 ///
357 /// # Example
358 ///
359 /// ```no_run
360 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
361 /// use mini_static::Server;
362 /// use std::path::Path;
363 ///
364 /// let server = Server::new(Path::new("./public"))?
365 /// .with_immutable_assets(|path| {
366 /// path.file_name()
367 /// .and_then(|name| name.to_str())
368 /// .is_some_and(|name| name.contains(".fingerprint."))
369 /// });
370 /// # Ok(())
371 /// # }
372 /// ```
373 pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
374 where
375 F: Fn(&Path) -> bool + Send + Sync + 'static,
376 {
377 self.immutable_predicate = Some(Arc::new(predicate));
378 self
379 }
380
381 /// The `Cache-Control` header value for a resolved file path: the immutable policy
382 /// if `with_immutable_assets`'s predicate matches, `no-cache` otherwise.
383 fn cache_control_for(&self, path: &Path) -> &'static str {
384 match &self.immutable_predicate {
385 Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
386 _ => "no-cache",
387 }
388 }
389
390 /// Register `path` as an additional directory whose changes should trigger a CSS
391 /// bundle rebuild, alongside the registered source folders.
392 ///
393 /// Useful for build pipelines where CSS partials referenced via `@import` live in a
394 /// separate directory tree from the source folders proper: without registering that
395 /// tree here, editing a partial wouldn't be noticed by the watcher and the bundle
396 /// would go stale until something else touched it.
397 ///
398 /// Files under `path` are never directly HTTP-servable: `Server::resolve` and the
399 /// request-handling path never consult bundle roots. This is purely a watch target,
400 /// not a second served root, and — since `@import` resolution is delegated entirely
401 /// to the configured [`CssTool`] (see [`Server::with_css_tool`]) — not an `@import`
402 /// traversal boundary either; the external tool resolves its own imports with no
403 /// root mini-static can enforce.
404 ///
405 /// This method is fallible and canonicalizes the path once at call time, matching
406 /// `Server::new`'s canonicalize-once policy. Call it multiple times to register
407 /// more than one external source tree.
408 ///
409 /// # Errors
410 ///
411 /// Returns `Err(StaticError::Io)` if the path cannot be canonicalized.
412 pub fn with_bundle_root(mut self, path: &Path) -> Result<Self, StaticError> {
413 let canon = path.canonicalize().map_err(StaticError::Io)?;
414 self.bundle_roots.push(canon);
415 Ok(self)
416 }
417
418 /// Designate `dir` as a source folder whose changes drive the build pipelines.
419 ///
420 /// Watched when `with_live_reload()` is enabled; `.css` files under it feed the single
421 /// CSS bundle, `.js`/`.mjs` files are minified per-file into the output dir.
422 ///
423 /// Rejected if `dir` overlaps the output dir or an already-registered source folder: a
424 /// source folder that is also the output would feed every pipeline its own output — the
425 /// feedback loop this layering exists to prevent.
426 ///
427 /// # Errors
428 ///
429 /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
430 /// `Err(StaticError::Traversal)` if it overlaps the output dir or another source folder.
431 pub fn with_source_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
432 let canon = dir.canonicalize().map_err(StaticError::Io)?;
433
434 if paths_overlap(&canon, &self.output_dir) {
435 return Err(StaticError::Traversal(format!(
436 "source folder {} overlaps the output dir {}",
437 canon.display(),
438 self.output_dir.display()
439 )));
440 }
441 if self
442 .source_folders
443 .iter()
444 .chain(self.asset_folders.iter())
445 .any(|existing| paths_overlap(&canon, existing))
446 {
447 return Err(StaticError::Traversal(format!(
448 "source folder {} overlaps an already-registered source/asset folder",
449 canon.display()
450 )));
451 }
452
453 self.source_folders.push(canon);
454 Ok(self)
455 }
456
457 /// Designate `dir` as an asset source folder: every file under it (any extension)
458 /// is mirrored byte-identical into the output dir at server startup and on every
459 /// live-reload change — no CSS/JS transformation, just a flat copy preserving each
460 /// file's path relative to `dir`. Use this for hand-authored static files
461 /// (`index.html`, images) that should live outside the served/output dir as
462 /// source, the same source/output separation `with_source_folder`'s CSS/JS
463 /// pipelines already have.
464 ///
465 /// Rejected if `dir` overlaps the output dir or an already-registered
466 /// source/asset folder, for the same reason `with_source_folder` rejects it: a
467 /// folder that is also the output would feed the pipeline its own output.
468 ///
469 /// # Errors
470 ///
471 /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
472 /// `Err(StaticError::Traversal)` if it overlaps the output dir or another
473 /// registered source/asset folder.
474 pub fn with_asset_folder(mut self, dir: &Path) -> Result<Self, StaticError> {
475 let canon = dir.canonicalize().map_err(StaticError::Io)?;
476
477 if paths_overlap(&canon, &self.output_dir) {
478 return Err(StaticError::Traversal(format!(
479 "asset folder {} overlaps the output dir {}",
480 canon.display(),
481 self.output_dir.display()
482 )));
483 }
484 if self
485 .source_folders
486 .iter()
487 .chain(self.asset_folders.iter())
488 .any(|existing| paths_overlap(&canon, existing))
489 {
490 return Err(StaticError::Traversal(format!(
491 "asset folder {} overlaps an already-registered source/asset folder",
492 canon.display()
493 )));
494 }
495
496 self.asset_folders.push(canon);
497 Ok(self)
498 }
499
500 /// Designate `dir` as the output directory processed outputs are written to.
501 ///
502 /// Defaults to the served root. The output dir is never a watcher trigger: pipelines
503 /// react to source folders only, so a pipeline's own output can never re-trigger it.
504 /// Call this before `with_css_tool` so a bundle output path reflects the override.
505 ///
506 /// # Errors
507 ///
508 /// Returns `Err(StaticError::Io)` if `dir` cannot be canonicalized, or
509 /// `Err(StaticError::Traversal)` if it overlaps a registered source or asset folder.
510 pub fn with_output_dir(mut self, dir: &Path) -> Result<Self, StaticError> {
511 let canon = dir.canonicalize().map_err(StaticError::Io)?;
512
513 if self
514 .source_folders
515 .iter()
516 .chain(self.asset_folders.iter())
517 .any(|existing| paths_overlap(&canon, existing))
518 {
519 return Err(StaticError::Traversal(format!(
520 "output dir {} overlaps a registered source/asset folder",
521 canon.display()
522 )));
523 }
524
525 self.output_dir = canon;
526 Ok(self)
527 }
528
529 /// Configure CSS bundling/minification via an external tool (disabled by default).
530 ///
531 /// `tool` is a preset naming the CLI mini-static invokes (see [`CssTool`]) —
532 /// mini-static does not install or manage the binary, only looks it up on `PATH`;
533 /// [`Server::run_on`] fails fast at startup if it's missing. `options` selects
534 /// `bundle`/`minify` independently (see [`CssOptions`]):
535 ///
536 /// - Neither: every `.css` under the source folders is copied through unchanged,
537 /// mirrored into the output dir.
538 /// - `minify` only: each file is minified independently and mirrored (no `@import`
539 /// following).
540 /// - `bundle` only: every `.css` under the source folders is discovered,
541 /// `@import`-resolved, and concatenated into one output file, unminified.
542 /// - Both: the bundle above, minified.
543 ///
544 /// # Example
545 ///
546 /// ```no_run
547 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
548 /// use mini_static::{CssOptions, CssTool, Server};
549 /// use std::path::Path;
550 ///
551 /// let server = Server::new(Path::new("./public"))?
552 /// .with_css_tool(CssTool::LightningCss, CssOptions::new().bundle(true).minify(true));
553 /// # Ok(())
554 /// # }
555 /// ```
556 pub fn with_css_tool(mut self, tool: CssTool, options: CssOptions) -> Self {
557 self.css_tool = Some((tool, options));
558 self
559 }
560
561 /// Configure JS bundling/minification via an external tool (disabled by default).
562 ///
563 /// `tool` is a preset naming the CLI mini-static invokes (see [`JsTool`]) —
564 /// mini-static does not install or manage the binary, only looks it up on `PATH`;
565 /// [`Server::run_on`] fails fast at startup if it's missing. Unlike CSS, JS bundling
566 /// requires an explicit entry point ([`JsOptions::bundle_entry`]) since a JS module
567 /// graph has no well-defined "concatenate everything" meaning; without it, `options`
568 /// runs in per-file mode (every `.js`/`.mjs` under the source folders processed and
569 /// mirrored independently).
570 ///
571 /// # Errors
572 ///
573 /// Returns `Err(StaticError::Io)` if `options` specifies a bundle entry that cannot
574 /// be canonicalized, or `Err(StaticError::Traversal)` if it doesn't lie under a
575 /// registered source folder — checked eagerly here so a bad entry path fails at
576 /// configuration time, not on the first rebuild.
577 ///
578 /// # Example
579 ///
580 /// ```no_run
581 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
582 /// use mini_static::{JsOptions, JsTool, Server};
583 /// use std::path::Path;
584 ///
585 /// let server = Server::new(Path::new("./public"))?
586 /// .with_source_folder(Path::new("./js-src"))?
587 /// .with_js_tool(
588 /// JsTool::Esbuild,
589 /// JsOptions::new()
590 /// .bundle_entry(Path::new("./js-src/main.js"), "bundle.js")
591 /// .minify(true),
592 /// )?;
593 /// # Ok(())
594 /// # }
595 /// ```
596 pub fn with_js_tool(mut self, tool: JsTool, options: JsOptions) -> Result<Self, StaticError> {
597 if let Some(entry) = options.entry() {
598 let entry_canon = entry.canonicalize().map_err(StaticError::Io)?;
599 let under_source_folder = self
600 .source_folders
601 .iter()
602 .any(|folder| entry_canon.starts_with(folder));
603 if !under_source_folder {
604 return Err(StaticError::Traversal(format!(
605 "js bundle entry {} is not under any registered source folder",
606 entry_canon.display()
607 )));
608 }
609 }
610
611 self.js_tool = Some((tool, options));
612 Ok(self)
613 }
614
615 /// Remove stale CSS bundle output at build time — specifically, delete the bundle file
616 /// when no CSS sources remain, rather than serving an orphan. Applies only to the
617 /// one-shot startup build, never during live-reload.
618 pub fn with_prune_output(mut self) -> Self {
619 self.prune_output = true;
620 self
621 }
622
623 /// True when any build pipeline is configured (a CSS/JS tool and/or a source
624 /// folder), i.e. the server should run a startup build.
625 fn has_pipeline(&self) -> bool {
626 self.css_tool.is_some()
627 || self.js_tool.is_some()
628 || !self.source_folders.is_empty()
629 || !self.asset_folders.is_empty()
630 }
631
632 /// Every external tool binary this configuration actually needs at some point
633 /// (bundle and/or minify enabled — a pure passthrough config never spawns its
634 /// configured tool, so it has nothing to fail-fast on), paired with its
635 /// human-readable install hint for a fail-fast startup error.
636 fn required_tool_binaries(&self) -> Vec<(&'static str, &'static str)> {
637 let mut required = Vec::new();
638 if let Some((css_tool, options)) = &self.css_tool {
639 if options.is_bundle() || options.is_minify() {
640 required.push((css_tool.binary_name(), css_tool.install_hint()));
641 }
642 }
643 if let Some((js_tool, options)) = &self.js_tool {
644 if options.is_bundle() || options.is_minify() {
645 required.push((js_tool.binary_name(), js_tool.install_hint()));
646 }
647 }
648 required
649 }
650
651 /// Every directory to watch for source changes: the source folders, the CSS
652 /// `@import` roots, and the asset folders, deduplicated so a directory registered
653 /// under more than one role is watched once.
654 fn watch_targets(&self) -> Vec<PathBuf> {
655 let mut targets = Vec::new();
656 for dir in self
657 .source_folders
658 .iter()
659 .chain(self.bundle_roots.iter())
660 .chain(self.asset_folders.iter())
661 {
662 if !targets.contains(dir) {
663 targets.push(dir.clone());
664 }
665 }
666 targets
667 }
668
669 /// Run every configured build pipeline (CSS/JS tools, asset folders) once and
670 /// return, without starting the HTTP server. A one-shot equivalent of the
671 /// startup build `run*` does automatically — for deploy tooling that wants to
672 /// populate the output dir ahead of time (e.g. a `cargo run --bin build_static`
673 /// step before baking a Docker image), mirroring a one-shot content
674 /// builder's `build()` (e.g. `mini_docs::Builder::build()`).
675 ///
676 /// # Errors
677 ///
678 /// - `Err(StaticError::PipelineSetup)` if a configured tool's binary that's
679 /// actually needed (bundle or minify enabled) is missing from `PATH` — checked
680 /// before anything runs, same as [`Server::run_on`].
681 /// - `Err(StaticError::Build)` if a configured pipeline step fails (a tool
682 /// invocation error, a filesystem error writing output, etc.).
683 ///
684 /// # Example
685 ///
686 /// ```no_run
687 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
688 /// use mini_static::Server;
689 /// use std::path::Path;
690 ///
691 /// let server = Server::new(Path::new("./public"))?;
692 /// server.build().await?;
693 /// # Ok(())
694 /// # }
695 /// ```
696 pub async fn build(&self) -> Result<(), StaticError> {
697 for (binary, install_hint) in self.required_tool_binaries() {
698 if !tool::locate_on_path(binary) {
699 return Err(StaticError::PipelineSetup(format!(
700 "{binary} not found on PATH ({install_hint})"
701 )));
702 }
703 }
704
705 let pipeline = SourcePipeline::new(
706 self.source_folders.clone(),
707 self.bundle_roots.clone(),
708 self.asset_folders.clone(),
709 self.output_dir.clone(),
710 self.css_tool.clone(),
711 self.js_tool.clone(),
712 self.prune_output,
713 Broadcaster::new(),
714 );
715 pipeline
716 .full_build()
717 .await
718 .map_err(|e| StaticError::Build(e.to_string()))
719 }
720
721 /// Resolve a request path under the server's root.
722 ///
723 /// This is a lower-level API for resolving paths without generating HTTP responses.
724 /// For most use cases, prefer [`Server::handle_request`] or the `run*` methods.
725 ///
726 /// # Returns
727 ///
728 /// - `Ok(PathBuf)` if the path resolves to a file within root.
729 /// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
730 pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
731 resolve::resolve_with_canonical_root(&self.root_canon, request_path)
732 }
733
734 /// Run the server on a specific address with a configurable header-read timeout.
735 ///
736 /// Spawns the server in a background Tokio task and returns immediately with the
737 /// assigned port number and a [`ServerHandle`]. Call `handle.shutdown().await` to
738 /// stop accepting new connections and wait for in-flight connections to finish.
739 /// Dropping the handle instead leaves the server running for the life of the process.
740 ///
741 /// # Header-Read Timeout
742 ///
743 /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
744 /// This prevents slowloris attacks and resource exhaustion from incomplete requests. The
745 /// timeout applies only to the header-read phase — once a complete header block has been
746 /// read, the connection is handed off with no further time bound, so long-lived response
747 /// bodies (e.g. the live-reload SSE stream from [`Server::with_live_reload`]) are not cut
748 /// off mid-stream.
749 ///
750 /// # Precompressed Sidecars
751 ///
752 /// If a request's `Accept-Encoding` allows `br` or `gzip` (preferring `br`) and a
753 /// sibling `<path>.br`/`<path>.gz` exists next to the resolved file, its bytes are
754 /// served instead with a matching `Content-Encoding`. Every file response carries
755 /// `Vary: Accept-Encoding` so intermediate caches don't serve the wrong variant to a
756 /// differently-capable client.
757 ///
758 /// # Arguments
759 ///
760 /// * `addr` - Socket address to bind to (e.g., `127.0.0.1:0` for loopback ephemeral,
761 /// or `0.0.0.0:8080` to bind all interfaces on a fixed port).
762 /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
763 ///
764 /// # Returns
765 ///
766 /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
767 /// - `Err(StaticError::Io)` if binding to the socket fails.
768 /// - `Err(StaticError::PipelineSetup)` if a configured [`CssTool`]/[`JsTool`]'s binary
769 /// is not found on `PATH`. Checked before the listener binds: a deployment whose
770 /// configured pipeline can never run should fail visibly at boot, not be discovered
771 /// later as a missing/stale asset.
772 pub async fn run_on(
773 &self,
774 addr: SocketAddr,
775 header_timeout: Duration,
776 ) -> Result<(u16, ServerHandle), StaticError> {
777 for (binary, install_hint) in self.required_tool_binaries() {
778 if !tool::locate_on_path(binary) {
779 return Err(StaticError::PipelineSetup(format!(
780 "{binary} not found on PATH ({install_hint})"
781 )));
782 }
783 }
784
785 let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
786 let port = listener.local_addr().map_err(StaticError::Io)?.port();
787
788 let mut server = self.clone();
789 if server.live_reload {
790 let broadcaster = Broadcaster::new();
791
792 // The build pipelines react to SOURCE folders only; the output dir is never
793 // watched. Watching the output would feed each pipeline its own writes back
794 // into its trigger — the feedback loop this layering exists to prevent.
795 if server.has_pipeline() {
796 let pipeline = Arc::new(SourcePipeline::new(
797 server.source_folders.clone(),
798 server.bundle_roots.clone(),
799 server.asset_folders.clone(),
800 server.output_dir.clone(),
801 server.css_tool.clone(),
802 server.js_tool.clone(),
803 server.prune_output,
804 broadcaster.clone(),
805 ));
806 let mut rx = broadcaster.subscribe();
807 tokio::spawn(async move {
808 // One-shot startup build (and optional prune) first, so the earliest
809 // request already sees fresh output rather than yesterday's.
810 if let Err(e) = pipeline.full_build().await {
811 eprintln!("source pipeline build error: {e}");
812 }
813 while let Some(event) = rx.recv().await {
814 if let Err(e) = pipeline
815 .process_change(&event.path, &event.change_type)
816 .await
817 {
818 eprintln!("source pipeline error: {e}");
819 }
820 }
821 });
822 }
823
824 for dir in server.watch_targets() {
825 start_watching(Arc::new(dir), broadcaster.clone());
826 }
827
828 server.broadcaster = Some(broadcaster);
829 } else if server.has_pipeline() {
830 // No live-reload: still run the one-shot build so a release boot reflects the
831 // current sources. The broadcaster is a throwaway — there is no browser to
832 // notify, so broadcasting into it is a no-op.
833 let pipeline = Arc::new(SourcePipeline::new(
834 server.source_folders.clone(),
835 server.bundle_roots.clone(),
836 server.asset_folders.clone(),
837 server.output_dir.clone(),
838 server.css_tool.clone(),
839 server.js_tool.clone(),
840 server.prune_output,
841 Broadcaster::new(),
842 ));
843 tokio::spawn(async move {
844 if let Err(e) = pipeline.full_build().await {
845 eprintln!("source pipeline build error: {e}");
846 }
847 });
848 }
849 let semaphore = Arc::new(Semaphore::new(server.max_connections));
850 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
851
852 let accept_task = tokio::spawn(async move {
853 let mut backoff = ACCEPT_BACKOFF_INITIAL;
854 let mut join_set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
855 let mut shutdown_pin = std::pin::pin!(shutdown_rx);
856 let mut shutting_down = false;
857
858 loop {
859 if !shutting_down {
860 // The accept-and-permit step and the shutdown signal race in a single
861 // `select!` so shutdown can preempt a pending accept or a permit wait
862 // cleanly, at any point — not just between loop iterations.
863 tokio::select! {
864 accepted = accept_and_permit(&listener, &mut backoff, &semaphore) => {
865 match accepted {
866 Some((stream, permit)) => {
867 let server = server.clone();
868 join_set.spawn(async move {
869 let _permit = permit;
870 serve_connection(stream, server, header_timeout).await;
871 });
872 }
873 None => shutting_down = true,
874 }
875 }
876 _ = shutdown_pin.as_mut() => {
877 shutting_down = true;
878 }
879 }
880 continue;
881 }
882
883 // Stop accepting; drain already-spawned connections before returning.
884 match join_set.join_next().await {
885 Some(_) => continue,
886 None => break,
887 }
888 }
889 });
890
891 Ok((
892 port,
893 ServerHandle {
894 shutdown_tx: Some(shutdown_tx),
895 accept_task,
896 },
897 ))
898 }
899
900 /// Run the server on loopback (127.0.0.1), binding an ephemeral port.
901 ///
902 /// Thin wrapper around [`Server::run_on`] — see it for the header-read timeout and
903 /// sidecar semantics, and for what the returned [`ServerHandle`] does.
904 pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
905 self.run_on(([127, 0, 0, 1], 0).into(), header_timeout)
906 .await
907 }
908
909 /// Run the server on all interfaces (0.0.0.0) at `port` (0 for an ephemeral port).
910 ///
911 /// Useful for containerized deployments and reverse-proxy setups. Thin wrapper
912 /// around [`Server::run_on`] — see it for the header-read timeout and sidecar
913 /// semantics, and for what the returned [`ServerHandle`] does.
914 pub async fn run_all(
915 &self,
916 port: u16,
917 header_timeout: Duration,
918 ) -> Result<(u16, ServerHandle), StaticError> {
919 self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
920 .await
921 }
922
923 /// Run the server on loopback with the default 30-second header-read timeout.
924 ///
925 /// The recommended entry point for tests and lightweight services that don't need a
926 /// custom timeout. Thin wrapper around [`Server::run`].
927 ///
928 /// # Example
929 ///
930 /// ```no_run
931 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
932 /// use mini_static::Server;
933 /// use std::path::Path;
934 ///
935 /// let server = Server::new(Path::new("./public"))?;
936 /// let (port, handle) = server.run_ephemeral().await?;
937 /// println!("Server ready on http://127.0.0.1:{}", port);
938 /// handle.shutdown().await;
939 /// # Ok(())
940 /// # }
941 /// ```
942 pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
943 self.run(DEFAULT_HEADER_TIMEOUT).await
944 }
945
946 /// Produce the HTTP response for a request, streaming file bodies to the client.
947 ///
948 /// This is the crate's single request-handling path: the `run*` accept loop calls it,
949 /// and so should any async server embedding `mini-static` as a fallback route (e.g.
950 /// `mini-unified`). It never blocks the calling task — path resolution runs on Tokio's
951 /// blocking-thread pool via `spawn_blocking`, and the file is read via async I/O.
952 ///
953 /// File responses are backed by `FileBody`, which hands hyper one 64 KB chunk at a
954 /// time as `poll_frame` is driven: memory use stays bounded to one chunk per in-flight
955 /// response regardless of file size.
956 ///
957 /// `headers` are the request's headers; `If-None-Match` (304 on a matching ETag) and
958 /// `Accept-Encoding` (precompressed sidecar selection, see [`Server::run_on`]) are the
959 /// ones read today. Only `GET` and `HEAD` are allowed; anything else gets a 405 with an
960 /// `Allow` header. Missing files and traversal attempts both get an identical 404, so a
961 /// response never discloses whether a path exists outside the root.
962 pub async fn handle_request(
963 &self,
964 method: &Method,
965 request_path: &str,
966 headers: &HeaderMap,
967 ) -> Response<ResponseBody> {
968 if method != Method::GET && method != Method::HEAD {
969 return text(
970 response(StatusCode::METHOD_NOT_ALLOWED).header("Allow", "GET, HEAD"),
971 "method not allowed\n",
972 );
973 }
974
975 // Live-reload SSE stream — only reachable when `with_live_reload()` was called
976 // and the server was started via a `run*` method (those are the only paths that
977 // populate `broadcaster`).
978 if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
979 if let Some(broadcaster) = &self.broadcaster {
980 return finish(
981 response(StatusCode::OK)
982 .header("Content-Type", "text/event-stream")
983 .header("Cache-Control", "no-cache")
984 .header("Connection", "keep-alive")
985 .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
986 );
987 }
988 }
989
990 // `resolve()` does blocking filesystem syscalls (`canonicalize()`, up to two per
991 // request). Running those directly in this `async fn` would block whichever
992 // Tokio worker thread happens to be driving it, stalling every other task
993 // scheduled on that thread for the duration of the syscalls. `spawn_blocking`
994 // moves the work onto Tokio's dedicated blocking thread pool instead.
995 let server = self.clone();
996 let owned_request_path = request_path.to_string();
997 let resolved =
998 tokio::task::spawn_blocking(move || server.resolve(&owned_request_path)).await;
999 let path = match resolved {
1000 Err(_) => return internal_error_response(),
1001 Ok(Err(e)) => {
1002 return text(
1003 response(StatusCode::NOT_FOUND),
1004 format!("{}\n", e.user_message()),
1005 )
1006 }
1007 Ok(Ok(path)) => path,
1008 };
1009
1010 // A directory served via its `index.html` needs a trailing slash to establish the
1011 // correct base for the page's relative links. Compare against the *decoded*
1012 // request path so a percent-encoded explicit request for index.html (e.g.
1013 // `/docs/index.htm%6c`) is recognized as such instead of producing a redirect to a
1014 // still-encoded, broken Location.
1015 let decoded_request_path = resolve::decode_request_path(request_path);
1016 if path.file_name().is_some_and(|name| name == "index.html")
1017 && !decoded_request_path.ends_with('/')
1018 && !decoded_request_path.ends_with("index.html")
1019 {
1020 // `location` is built from the (attacker-controlled) request path; `finish()`
1021 // degrades to 400 instead of panicking if it ever contains bytes invalid in a
1022 // header value.
1023 let location = format!("{}/", request_path.trim_end_matches('/'));
1024 return text(
1025 response(StatusCode::MOVED_PERMANENTLY).header("Location", location),
1026 "moved\n",
1027 );
1028 }
1029
1030 let Ok(file) = File::open(&path).await else {
1031 return internal_error_response();
1032 };
1033 let Ok(metadata) = file.metadata().await else {
1034 return internal_error_response();
1035 };
1036
1037 let content_type = mime_type_for_path(&path);
1038 // Live-reload and spa-mode HTML injection both need the original, uncompressed
1039 // bytes to splice their script into — never substitute a precompressed sidecar on
1040 // this path. `broadcaster` is only `Some` when live-reload is enabled (see
1041 // `Server::with_live_reload`); `spa_mode` is independent of it (see
1042 // `Server::with_spa_mode`/`with_spa_root`) — either alone is enough to trigger
1043 // injection.
1044 let html_injection =
1045 (self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html");
1046
1047 let range_header = header_str(headers, "range");
1048 let if_range_header = header_str(headers, "if-range");
1049
1050 let accept_encoding = header_str(headers, "accept-encoding");
1051 // Skip precompressed sidecars when Range is requested (serve original file instead).
1052 let sidecar = if html_injection || range_header.is_some() {
1053 None
1054 } else {
1055 select_precompressed_sidecar(&path, accept_encoding).await
1056 };
1057 let (mut file, metadata, content_encoding) = match sidecar {
1058 Some((sidecar_file, sidecar_metadata, encoding)) => {
1059 (sidecar_file, sidecar_metadata, Some(encoding))
1060 }
1061 None => (file, metadata, None),
1062 };
1063
1064 // HTML injection is skipped for a served precompressed sidecar (already final
1065 // bytes from a build step) — see `html_injection`'s definition above.
1066 let etag = generate_etag(&metadata);
1067 let cache_control = self.cache_control_for(&path);
1068
1069 if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
1070 return finish(
1071 Response::builder()
1072 .status(StatusCode::NOT_MODIFIED)
1073 .header("Cache-Control", cache_control)
1074 .header("Vary", "Accept-Encoding")
1075 .header("ETag", etag)
1076 .header("Accept-Ranges", "bytes")
1077 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1078 );
1079 }
1080
1081 // `Some` when the served representation differs from the file's raw bytes and had
1082 // to be built in memory; `None` means stream the open file as-is. Computed before
1083 // the HEAD check below because RFC 9110 requires a HEAD response's headers —
1084 // `Content-Length` included — to match what a GET would send, even though the body
1085 // itself is dropped.
1086 let transformed: Option<Bytes> = if html_injection {
1087 let mut html = Vec::with_capacity(metadata.len() as usize);
1088 if file.read_to_end(&mut html).await.is_err() {
1089 return internal_error_response();
1090 }
1091 if self.broadcaster.is_some() {
1092 reload::inject_reload_script(&mut html);
1093 }
1094 if self.spa_mode {
1095 spa::inject_spa_script(&mut html, self.spa_root.as_deref(), self.spa_transition);
1096 }
1097 Some(Bytes::from(html))
1098 } else {
1099 None
1100 };
1101
1102 let file_size = transformed
1103 .as_ref()
1104 .map_or(metadata.len(), |bytes| bytes.len() as u64);
1105
1106 // Handle Range requests.
1107 let range_outcome = range_header.map(|h| parse_range_header(h, file_size));
1108 let range_check = if let Some(outcome) = &range_outcome {
1109 match outcome {
1110 RangeOutcome::Satisfiable(start, end) => {
1111 // If-Range validation: stale If-Range ignores Range, serves full 200.
1112 if let Some(if_range) = if_range_header {
1113 if !if_range_valid(if_range, &etag) {
1114 RangeCheck::IgnoreRange
1115 } else {
1116 RangeCheck::Satisfiable(*start, *end)
1117 }
1118 } else {
1119 RangeCheck::Satisfiable(*start, *end)
1120 }
1121 }
1122 RangeOutcome::MultiRangeIgnored => RangeCheck::IgnoreRange,
1123 RangeOutcome::Unsatisfiable => RangeCheck::Unsatisfiable,
1124 RangeOutcome::NoRange => RangeCheck::IgnoreRange,
1125 }
1126 } else {
1127 RangeCheck::IgnoreRange
1128 };
1129
1130 match &range_check {
1131 RangeCheck::Unsatisfiable => {
1132 return finish(
1133 Response::builder()
1134 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1135 .header("Content-Range", format!("bytes */{}", file_size))
1136 .header("Accept-Ranges", "bytes")
1137 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1138 );
1139 }
1140 RangeCheck::Satisfiable(start, end) => {
1141 let range_len = end - start + 1;
1142
1143 // Seek to start position; if sidecar, we already skipped it above.
1144 if transformed.is_none() {
1145 if file.seek(std::io::SeekFrom::Start(*start)).await.is_err() {
1146 return internal_error_response();
1147 }
1148 }
1149
1150 // HEAD must not return a body (RFC 9110).
1151 let body = if *method == Method::HEAD {
1152 ResponseBody::Buffered(Full::new(Bytes::new()))
1153 } else {
1154 match transformed {
1155 Some(ref bytes) => ResponseBody::Buffered(Full::new(
1156 bytes.slice(*start as usize..(*end as usize + 1)),
1157 )),
1158 None => ResponseBody::Streamed(FileBody::new_ranged(file, range_len)),
1159 }
1160 };
1161
1162 let mut builder = Response::builder()
1163 .status(StatusCode::PARTIAL_CONTENT)
1164 .header("Content-Type", content_type)
1165 .header("Content-Length", range_len.to_string())
1166 .header(
1167 "Content-Range",
1168 format!("bytes {}-{}/{}", start, end, file_size),
1169 )
1170 .header("Cache-Control", cache_control)
1171 .header("Vary", "Accept-Encoding")
1172 .header("ETag", etag)
1173 .header("Accept-Ranges", "bytes");
1174 if let Some(encoding) = content_encoding {
1175 builder = builder.header("Content-Encoding", encoding);
1176 }
1177 return finish(builder.body(body));
1178 }
1179 RangeCheck::IgnoreRange => {}
1180 }
1181
1182 // HEAD must not return a body (RFC 9110).
1183 let body = if *method == Method::HEAD {
1184 ResponseBody::Buffered(Full::new(Bytes::new()))
1185 } else {
1186 match transformed {
1187 Some(bytes) => ResponseBody::Buffered(Full::new(bytes)),
1188 None => ResponseBody::Streamed(FileBody::new(file)),
1189 }
1190 };
1191
1192 let mut builder = response(StatusCode::OK)
1193 .header("Content-Type", content_type)
1194 .header("Content-Length", file_size.to_string())
1195 .header("Cache-Control", cache_control)
1196 .header("Vary", "Accept-Encoding")
1197 .header("ETag", etag)
1198 .header("Accept-Ranges", "bytes");
1199 if let Some(encoding) = content_encoding {
1200 builder = builder.header("Content-Encoding", encoding);
1201 }
1202 finish(builder.body(body))
1203 }
1204}
1205
1206/// Ceiling on how many bytes `read_header_prefix` buffers before giving up. Without this,
1207/// a client that trickles bytes forever without ever sending the terminating blank line
1208/// could grow the buffer without limit — the header-read timeout alone doesn't bound
1209/// memory, only wall-clock time, and a sufficiently patient sender could still send
1210/// unbounded data before the deadline fires.
1211const MAX_HEADER_BYTES: usize = 64 * 1024;
1212
1213/// Why `read_header_prefix` gave up before seeing a complete header block. Every variant
1214/// is a legitimate reason to drop the connection — none is treated specially by the
1215/// caller today, but the distinction is worth preserving for anyone debugging this later.
1216#[derive(Debug)]
1217enum HeaderReadError {
1218 /// The client closed the connection (or shut down its write half) before sending a
1219 /// complete header block.
1220 ConnectionClosed,
1221 /// More than `MAX_HEADER_BYTES` were buffered without seeing `\r\n\r\n`.
1222 TooLarge,
1223 /// The underlying socket read failed. Kept rather than discarded so a future `log`
1224 /// feature has the real I/O error to report instead of an opaque unit variant.
1225 #[allow(dead_code)]
1226 Io(std::io::Error),
1227}
1228
1229/// Reads from `stream` until a complete HTTP header block (`\r\n\r\n`) has been buffered,
1230/// returning every byte read so far — which may include bytes past the header block
1231/// (request body, or a second pipelined request) if the client sent them in the same
1232/// read. Callers pair this with `tokio::time::timeout` to bound how long the header phase
1233/// itself may take; this function has no timeout of its own, only the size ceiling in
1234/// `MAX_HEADER_BYTES`.
1235async fn read_header_prefix(stream: &mut TcpStream) -> Result<Vec<u8>, HeaderReadError> {
1236 let mut buf = Vec::new();
1237 let mut chunk = [0u8; 4096];
1238
1239 loop {
1240 let n = stream.read(&mut chunk).await.map_err(HeaderReadError::Io)?;
1241 if n == 0 {
1242 return Err(HeaderReadError::ConnectionClosed);
1243 }
1244 buf.extend_from_slice(&chunk[..n]);
1245
1246 if buf.len() > MAX_HEADER_BYTES {
1247 return Err(HeaderReadError::TooLarge);
1248 }
1249 // Only the tail can hold a terminator this read completed: the `n` new bytes plus
1250 // the 3 before them. Rescanning the whole buffer every time would make the header
1251 // read quadratic in the bytes received.
1252 let scan_from = buf.len().saturating_sub(n + 3);
1253 if buf[scan_from..].windows(4).any(|w| w == b"\r\n\r\n") {
1254 return Ok(buf);
1255 }
1256 }
1257}
1258
1259/// Wraps an accepted `TcpStream` whose header block has already been drained into
1260/// `prefix` (by `read_header_prefix`, ahead of the connection being handed to hyper).
1261/// Reads replay `prefix` first, then fall through to the live socket — so hyper sees
1262/// exactly the byte stream it would have seen without the pre-read, just sourced from two
1263/// buffers back-to-back instead of one continuous one. Writes pass straight through.
1264struct PrefixedIo {
1265 prefix: Bytes,
1266 prefix_pos: usize,
1267 inner: TcpStream,
1268}
1269
1270impl PrefixedIo {
1271 fn new(prefix: Vec<u8>, inner: TcpStream) -> Self {
1272 PrefixedIo {
1273 prefix: Bytes::from(prefix),
1274 prefix_pos: 0,
1275 inner,
1276 }
1277 }
1278}
1279
1280impl AsyncRead for PrefixedIo {
1281 fn poll_read(
1282 self: Pin<&mut Self>,
1283 cx: &mut Context<'_>,
1284 buf: &mut ReadBuf<'_>,
1285 ) -> Poll<std::io::Result<()>> {
1286 let this = self.get_mut();
1287 if this.prefix_pos < this.prefix.len() {
1288 let remaining = &this.prefix[this.prefix_pos..];
1289 let n = remaining.len().min(buf.remaining());
1290 buf.put_slice(&remaining[..n]);
1291 this.prefix_pos += n;
1292 return Poll::Ready(Ok(()));
1293 }
1294 Pin::new(&mut this.inner).poll_read(cx, buf)
1295 }
1296}
1297
1298impl AsyncWrite for PrefixedIo {
1299 fn poll_write(
1300 self: Pin<&mut Self>,
1301 cx: &mut Context<'_>,
1302 buf: &[u8],
1303 ) -> Poll<std::io::Result<usize>> {
1304 Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
1305 }
1306
1307 fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1308 Pin::new(&mut self.get_mut().inner).poll_flush(cx)
1309 }
1310
1311 fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
1312 Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
1313 }
1314}
1315
1316/// Wires an accepted connection up to the hyper HTTP/1 service.
1317///
1318/// `header_timeout` bounds only the header-read phase (`read_header_prefix`, run before
1319/// hyper ever sees the connection). Once a complete header block has been read, the
1320/// connection is handed to hyper with no further time bound — deliberately, since a
1321/// response body may legitimately outlive `header_timeout` by design (the live-reload SSE
1322/// stream is the motivating case: it stays open until a watched file changes, which may
1323/// be minutes or hours after the request). Wrapping the whole connection lifetime in
1324/// `header_timeout` — the prior implementation — silently truncated exactly that stream
1325/// once `header_timeout` elapsed, aborting the response mid-write after headers had
1326/// already been sent (the client observes this as a chunked-encoding error, not a clean
1327/// close). The connection-count ceiling (`Server::with_max_connections`) is what bounds
1328/// resource use from connections held open indefinitely, not this timeout.
1329async fn serve_connection(mut stream: TcpStream, server: Server, header_timeout: Duration) {
1330 let prefix = match timeout(header_timeout, read_header_prefix(&mut stream)).await {
1331 Ok(Ok(prefix)) => prefix,
1332 Ok(Err(_)) | Err(_) => return,
1333 };
1334
1335 let io = TokioIo::new(PrefixedIo::new(prefix, stream));
1336 let svc = service_fn(move |req: Request<Incoming>| {
1337 let server = server.clone();
1338 async move {
1339 let resp = server
1340 .handle_request(req.method(), req.uri().path(), req.headers())
1341 .await;
1342 Ok::<_, Infallible>(resp)
1343 }
1344 });
1345 let _ = AutoBuilder::new(TokioExecutor::new())
1346 .serve_connection(io, svc)
1347 .await;
1348}
1349
1350/// Default header-read timeout used by [`Server::run_ephemeral`].
1351const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
1352
1353/// Default grace period `ServerHandle::shutdown()` waits for in-flight connections to
1354/// finish on their own before aborting whatever is left. A connection with no
1355/// self-imposed end — an open live-reload SSE stream, or any keep-alive connection whose
1356/// peer simply never closes it — would otherwise let `shutdown()` hang forever waiting
1357/// for it to finish naturally. Every wait in this crate has a stated upper bound;
1358/// shutdown is no exception.
1359const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1360
1361/// A handle to a server started by one of the `Server::run*` methods.
1362///
1363/// Dropping this handle without calling `shutdown()` leaves the server running in the
1364/// background for the life of the process. Call `shutdown()` to stop accepting new
1365/// connections and wait for already-accepted connections to finish before returning.
1366pub struct ServerHandle {
1367 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1368 accept_task: tokio::task::JoinHandle<()>,
1369}
1370
1371impl ServerHandle {
1372 /// Stop accepting new connections and wait up to `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT`
1373 /// (5s) for in-flight connections to finish on their own. Equivalent to
1374 /// `shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)` — see that method for what
1375 /// happens to connections still open once the grace period elapses.
1376 pub async fn shutdown(self) {
1377 self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1378 .await;
1379 }
1380
1381 /// Stop accepting new connections and wait up to `drain_timeout` for in-flight
1382 /// connections to finish on their own.
1383 ///
1384 /// Connections still open once `drain_timeout` elapses are aborted rather than
1385 /// waited on further: dropping the accept task drops its `JoinSet`, which aborts
1386 /// every task still tracked in it (see `tokio::task::JoinSet`'s own drop behavior) —
1387 /// which in turn drops each connection's socket, closing it. This is what bounds
1388 /// shutdown when a connection has no natural end of its own (the live-reload SSE
1389 /// stream is the motivating case: it stays open until a watched file changes, which
1390 /// may never happen before the process needs to exit).
1391 pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1392 if let Some(tx) = self.shutdown_tx.take() {
1393 let _ = tx.send(());
1394 }
1395 if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1396 self.accept_task.abort();
1397 }
1398 }
1399}
1400
1401/// Read a request header as a `&str`, or `None` if it's absent or not valid ASCII.
1402fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1403 headers.get(name).and_then(|value| value.to_str().ok())
1404}
1405
1406/// Start a response carrying the baseline security header every response in this crate
1407/// sends. The 304 path is the one exception and builds its own — a 304 repeats only the
1408/// caching validators, not the full header set.
1409fn response(status: StatusCode) -> Builder {
1410 Response::builder()
1411 .status(status)
1412 .header("X-Content-Type-Options", "nosniff")
1413}
1414
1415/// Finish `builder` with a plain-text body. `&'static str` bodies borrow rather than
1416/// allocate; `String` bodies (the 404 message) are moved in.
1417fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1418 finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1419}
1420
1421/// Finishes building a response, degrading to a generic 400 instead of panicking if any
1422/// header value turns out to be invalid for use as an HTTP header value.
1423///
1424/// Every header value that reaches `Response::builder()` in this module is either a
1425/// static string or formatted from internal, already-validated data (a byte count, an
1426/// mtime, a fixed method list) — none of it can actually fail today. But `.unwrap()`
1427/// on that assumption is exactly the kind of thing that turns "can't happen" into a
1428/// production panic the day someone adds a header built from new input without
1429/// re-deriving that guarantee. Routing every response through this one fallible path
1430/// means that mistake fails safe instead of panicking.
1431fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1432 built.unwrap_or_else(|_| bad_request_response())
1433}
1434
1435// `internal_error_response()` and `bad_request_response()` are the fallback responses
1436// `finish()` itself degrades to — every header and body here is a fixed string with no
1437// external input, so `.body(...)` cannot fail. They can't be routed through `finish()`
1438// without it degrading to itself on failure.
1439fn internal_error_response() -> Response<ResponseBody> {
1440 response(StatusCode::INTERNAL_SERVER_ERROR)
1441 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1442 b"internal server error\n",
1443 ))))
1444 .unwrap()
1445}
1446
1447fn bad_request_response() -> Response<ResponseBody> {
1448 response(StatusCode::BAD_REQUEST)
1449 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1450 b"bad request\n",
1451 ))))
1452 .unwrap()
1453}
1454
1455/// `Content-Encoding` name and sidecar file extension for each supported precompressed
1456/// variant, in preference order — brotli wins when a client accepts both and both
1457/// sidecars exist.
1458const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1459
1460/// Whether `accept_encoding` allows `encoding`.
1461///
1462/// Matches by substring rather than parsing `q`-value weights or the `identity`/`*`
1463/// directives — a lighter-weight negotiation than a general HTTP client would need,
1464/// sufficient for deciding between two static sidecar files.
1465fn accepts_encoding(accept_encoding: Option<&str>, encoding: &str) -> bool {
1466 accept_encoding.is_some_and(|header| header.contains(encoding))
1467}
1468
1469/// Look for a precompressed sidecar (`<path>.br` / `<path>.gz`) matching the client's
1470/// `Accept-Encoding`, and return its open file, metadata, and encoding name if found.
1471///
1472/// `path` must already be the fully resolved, canonicalized path `resolve()` produced.
1473/// The sidecar path is built by appending an extension to it — never by re-resolving a
1474/// modified request path — so this lookup can't become a second traversal surface: any
1475/// path this function reads is provably a sibling of a path `resolve()` already cleared.
1476async fn select_precompressed_sidecar(
1477 path: &Path,
1478 accept_encoding: Option<&str>,
1479) -> Option<(File, fs::Metadata, &'static str)> {
1480 for (encoding, ext) in SIDECAR_ENCODINGS {
1481 if !accepts_encoding(accept_encoding, encoding) {
1482 continue;
1483 }
1484 let mut sidecar = path.as_os_str().to_os_string();
1485 sidecar.push(ext);
1486 let sidecar_path = PathBuf::from(sidecar);
1487
1488 // Tripwire for the traversal boundary: a sidecar path built by appending a suffix
1489 // must stay in the same directory as `path` (which `resolve()` already proved is
1490 // inside root). `ext` is always one of the two static literals in
1491 // `SIDECAR_ENCODINGS`, never derived from request input, so this can only fire if
1492 // a future change starts deriving `sidecar` some other way.
1493 debug_assert_eq!(
1494 sidecar_path.parent(),
1495 path.parent(),
1496 "sidecar path must stay in the same directory as the already-resolved path"
1497 );
1498
1499 if let Ok(sidecar_file) = File::open(&sidecar_path).await {
1500 if let Ok(sidecar_metadata) = sidecar_file.metadata().await {
1501 return Some((sidecar_file, sidecar_metadata, encoding));
1502 }
1503 }
1504 }
1505 None
1506}
1507
1508/// Generate an ETag for a file based on modification time and size.
1509///
1510/// Format: `"<size>-<mtime_secs>"`
1511fn generate_etag(metadata: &fs::Metadata) -> String {
1512 let mtime = metadata
1513 .modified()
1514 .ok()
1515 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1516 .map(|d| d.as_secs())
1517 .unwrap_or(0);
1518 format!("\"{}-{}\"", metadata.len(), mtime)
1519}
1520
1521/// Determine MIME type from file path extension.
1522fn mime_type_for_path(path: &Path) -> &'static str {
1523 let ext = path
1524 .extension()
1525 .and_then(|ext| ext.to_str())
1526 .unwrap_or_default()
1527 .to_lowercase();
1528
1529 match ext.as_str() {
1530 "html" | "htm" => "text/html; charset=utf-8",
1531 "css" => "text/css; charset=utf-8",
1532 "js" => "application/javascript; charset=utf-8",
1533 "json" => "application/json; charset=utf-8",
1534 "svg" => "image/svg+xml",
1535 "png" => "image/png",
1536 "jpg" | "jpeg" => "image/jpeg",
1537 "gif" => "image/gif",
1538 "webp" => "image/webp",
1539 "ico" => "image/x-icon",
1540 "woff" => "font/woff",
1541 "woff2" => "font/woff2",
1542 "ttf" => "font/ttf",
1543 "md" | "markdown" => "text/markdown; charset=utf-8",
1544 "txt" => "text/plain; charset=utf-8",
1545 "xml" => "application/xml",
1546 "pdf" => "application/pdf",
1547 "zip" => "application/zip",
1548 _ => "application/octet-stream",
1549 }
1550}
1551
1552/// Check if the If-None-Match header matches the current ETag.
1553/// Handles both exact match and wildcard (*) comparison per RFC 9110.
1554fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1555 if if_none_match == "*" {
1556 return true;
1557 }
1558 if_none_match.split(',').any(|tag| tag.trim() == etag)
1559}
1560
1561#[derive(Debug)]
1562enum RangeOutcome {
1563 NoRange,
1564 Satisfiable(u64, u64),
1565 Unsatisfiable,
1566 MultiRangeIgnored,
1567}
1568
1569enum RangeCheck {
1570 IgnoreRange,
1571 Satisfiable(u64, u64),
1572 Unsatisfiable,
1573}
1574
1575fn parse_range_header(header: &str, file_size: u64) -> RangeOutcome {
1576 let header = header.trim();
1577 if !header.starts_with("bytes=") {
1578 return RangeOutcome::NoRange;
1579 }
1580
1581 let range_spec = &header[6..];
1582
1583 if range_spec.contains(',') {
1584 return RangeOutcome::MultiRangeIgnored;
1585 }
1586
1587 if let Some(suffix_pos) = range_spec.find('-') {
1588 if suffix_pos == 0 {
1589 let suffix_len_str = &range_spec[1..];
1590 if let Ok(suffix_len) = suffix_len_str.parse::<u64>() {
1591 if suffix_len == 0 {
1592 return RangeOutcome::Unsatisfiable;
1593 }
1594 if suffix_len >= file_size {
1595 return RangeOutcome::Satisfiable(0, file_size - 1);
1596 }
1597 return RangeOutcome::Satisfiable(file_size - suffix_len, file_size - 1);
1598 }
1599 return RangeOutcome::Unsatisfiable;
1600 }
1601
1602 let start_str = &range_spec[..suffix_pos];
1603 let end_str = &range_spec[suffix_pos + 1..];
1604
1605 if let Ok(start) = start_str.parse::<u64>() {
1606 if start >= file_size {
1607 return RangeOutcome::Unsatisfiable;
1608 }
1609
1610 if end_str.is_empty() {
1611 return RangeOutcome::Satisfiable(start, file_size - 1);
1612 }
1613
1614 if let Ok(end) = end_str.parse::<u64>() {
1615 if end < start {
1616 return RangeOutcome::Unsatisfiable;
1617 }
1618 let clamped_end = (end + 1).min(file_size) - 1;
1619 if start > clamped_end {
1620 return RangeOutcome::Unsatisfiable;
1621 }
1622 return RangeOutcome::Satisfiable(start, clamped_end);
1623 }
1624 }
1625 }
1626
1627 RangeOutcome::Unsatisfiable
1628}
1629
1630fn if_range_valid(if_range_header: &str, current_etag: &str) -> bool {
1631 if_range_header.trim() == current_etag
1632}
1633
1634#[cfg(test)]
1635mod precompressed_sidecar_tests {
1636 use super::*;
1637
1638 // `select_precompressed_sidecar` only ever appends a static extension literal
1639 // (".br"/".gz") to the `path` it's given — it never re-joins against `root` or
1640 // re-parses a request-path string, so it structurally cannot become a second
1641 // traversal surface the way re-running `resolve()` on modified input could. This
1642 // test locks that in by construction: the sidecar it finds must live in exactly
1643 // the same directory as the resolved file, for every encoding preference branch.
1644 #[tokio::test]
1645 async fn sidecar_never_leaves_the_resolved_files_directory() {
1646 let root = tempfile::TempDir::new().unwrap();
1647 let sub = root.path().join("assets");
1648 fs::create_dir(&sub).unwrap();
1649 let resolved = sub.join("app.js");
1650 fs::write(&resolved, b"plain").unwrap();
1651 fs::write(sub.join("app.js.br"), b"brotli-bytes").unwrap();
1652 fs::write(sub.join("app.js.gz"), b"gzip-bytes").unwrap();
1653
1654 let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("br, gzip"))
1655 .await
1656 .expect("both sidecars present, br should be preferred");
1657 assert_eq!(
1658 encoding, "br",
1659 "br must be preferred over gzip when both are accepted"
1660 );
1661
1662 let (_, _, encoding) = select_precompressed_sidecar(&resolved, Some("gzip"))
1663 .await
1664 .expect("gzip sidecar present");
1665 assert_eq!(encoding, "gzip");
1666
1667 assert!(
1668 select_precompressed_sidecar(&resolved, None)
1669 .await
1670 .is_none(),
1671 "no Accept-Encoding header should never select a sidecar"
1672 );
1673 }
1674
1675 #[test]
1676 fn accepts_encoding_matches_only_listed_directives() {
1677 assert!(!accepts_encoding(None, "br"));
1678 assert!(!accepts_encoding(Some("identity"), "br"));
1679 assert!(!accepts_encoding(Some("identity"), "gzip"));
1680 assert!(accepts_encoding(Some("gzip, br"), "br"));
1681 assert!(accepts_encoding(Some("gzip"), "gzip"));
1682 assert!(!accepts_encoding(Some("gzip"), "br"));
1683 }
1684}
1685
1686#[cfg(test)]
1687mod file_body_tests {
1688 use super::*;
1689 use crate::handler::FILE_CHUNK_SIZE;
1690 use http_body_util::BodyExt;
1691
1692 // Disproves the prior implementation, which read every chunk into a `Vec` and
1693 // only wrapped the whole result in a single `Full` frame at the end — that
1694 // implementation would fail this test with `frame_count == 1` and
1695 // `max_frame_len == file size`, regardless of `FILE_CHUNK_SIZE`.
1696 #[tokio::test]
1697 async fn file_body_yields_multiple_bounded_chunks_not_one_buffered_frame() {
1698 let dir = tempfile::TempDir::new().unwrap();
1699 let path = dir.path().join("big.bin");
1700 let content = vec![7u8; FILE_CHUNK_SIZE * 3 + 12_345];
1701 fs::write(&path, &content).unwrap();
1702
1703 let file = File::open(&path).await.unwrap();
1704 let mut body = FileBody::new(file);
1705
1706 let mut frame_count = 0usize;
1707 let mut max_frame_len = 0usize;
1708 let mut reassembled = Vec::new();
1709
1710 while let Some(frame) = body.frame().await {
1711 let frame = frame.unwrap();
1712 let data = frame.into_data().unwrap();
1713 frame_count += 1;
1714 max_frame_len = max_frame_len.max(data.len());
1715 reassembled.extend_from_slice(&data);
1716 }
1717
1718 assert!(
1719 frame_count > 1,
1720 "expected the file to be delivered as multiple frames, got {frame_count}"
1721 );
1722 assert!(
1723 max_frame_len <= FILE_CHUNK_SIZE,
1724 "no single frame should exceed the chunk size ({FILE_CHUNK_SIZE}), got {max_frame_len}"
1725 );
1726 assert_eq!(
1727 reassembled, content,
1728 "reassembled chunks must match original file content exactly"
1729 );
1730 }
1731}
1732
1733#[cfg(test)]
1734mod accept_tests {
1735 use super::*;
1736 use std::sync::atomic::{AtomicUsize, Ordering};
1737 use std::sync::Mutex;
1738
1739 /// Fails `accept()` a fixed number of times, recording the (paused, virtual)
1740 /// instant of each attempt, before delegating to a real listener so the caller can
1741 /// eventually succeed.
1742 struct FlakyListener {
1743 inner: TcpListener,
1744 remaining_failures: AtomicUsize,
1745 attempts: Mutex<Vec<tokio::time::Instant>>,
1746 }
1747
1748 impl TcpAccept for FlakyListener {
1749 async fn accept(&self) -> std::io::Result<(TcpStream, SocketAddr)> {
1750 self.attempts
1751 .lock()
1752 .unwrap()
1753 .push(tokio::time::Instant::now());
1754 if self.remaining_failures.fetch_sub(1, Ordering::SeqCst) > 0 {
1755 Err(std::io::Error::other("simulated accept error"))
1756 } else {
1757 TcpAccept::accept(&self.inner).await
1758 }
1759 }
1760 }
1761
1762 // Disproves the prior implementation, which broke out of the accept loop entirely
1763 // on the first `accept()` error — permanently ending the server. This test would
1764 // also fail against a naive `continue`-only fix (no backoff): the recorded gaps
1765 // between attempts would collapse to ~0 (a busy spin) instead of the expected
1766 // exponentially growing delays.
1767 #[tokio::test(start_paused = true)]
1768 async fn accept_loop_backs_off_between_repeated_errors_instead_of_busy_spinning() {
1769 let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1770 let addr = inner.local_addr().unwrap();
1771
1772 let flaky = FlakyListener {
1773 inner,
1774 remaining_failures: AtomicUsize::new(5),
1775 attempts: Mutex::new(Vec::new()),
1776 };
1777
1778 tokio::spawn(async move {
1779 let _ = TcpStream::connect(addr).await;
1780 });
1781
1782 let semaphore = Arc::new(Semaphore::new(1));
1783 let mut backoff = ACCEPT_BACKOFF_INITIAL;
1784 let result = accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1785 assert!(
1786 result.is_some(),
1787 "accept should eventually succeed once the flaky listener stops failing"
1788 );
1789
1790 let recorded = flaky.attempts.lock().unwrap();
1791 assert_eq!(recorded.len(), 6, "5 failures then 1 success");
1792
1793 let expected_gaps = [
1794 ACCEPT_BACKOFF_INITIAL,
1795 ACCEPT_BACKOFF_INITIAL * 2,
1796 ACCEPT_BACKOFF_INITIAL * 4,
1797 ACCEPT_BACKOFF_INITIAL * 8,
1798 ACCEPT_BACKOFF_INITIAL * 16,
1799 ];
1800 for (i, expected) in expected_gaps.iter().enumerate() {
1801 let gap = recorded[i + 1] - recorded[i];
1802 assert_eq!(
1803 gap,
1804 *expected,
1805 "gap between attempt {i} and {} should reflect the backoff delay, not a busy spin",
1806 i + 1
1807 );
1808 }
1809
1810 // The delay must stop doubling at the cap rather than growing without bound.
1811 let mut capped = ACCEPT_BACKOFF_MAX;
1812 capped = (capped * 2).min(ACCEPT_BACKOFF_MAX);
1813 assert_eq!(capped, ACCEPT_BACKOFF_MAX);
1814 }
1815
1816 // A successful accept must clear the accumulated delay, so an isolated error later
1817 // on doesn't inherit a second-long wait from an unrelated earlier failure.
1818 #[tokio::test(start_paused = true)]
1819 async fn a_successful_accept_resets_the_backoff() {
1820 let inner = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1821 let addr = inner.local_addr().unwrap();
1822 let flaky = FlakyListener {
1823 inner,
1824 remaining_failures: AtomicUsize::new(3),
1825 attempts: Mutex::new(Vec::new()),
1826 };
1827 tokio::spawn(async move {
1828 let _ = TcpStream::connect(addr).await;
1829 });
1830
1831 let semaphore = Arc::new(Semaphore::new(1));
1832 let mut backoff = ACCEPT_BACKOFF_INITIAL * 32;
1833 accept_and_permit(&flaky, &mut backoff, &semaphore).await;
1834
1835 assert_eq!(
1836 backoff, ACCEPT_BACKOFF_INITIAL,
1837 "the delay must return to its initial value once an accept succeeds"
1838 );
1839 }
1840}
1841
1842#[cfg(test)]
1843mod finish_tests {
1844 use super::*;
1845
1846 // Disproves a bare `.unwrap()` on the same builder: CR/LF is not a legal header
1847 // value byte (it would enable header/response splitting), so this construction is
1848 // guaranteed to make `.body(...)` return `Err`. Every real call site in this module
1849 // only ever builds header values from static strings or internally-formatted
1850 // numbers, so this test can't happen through normal use — it exists to prove
1851 // `finish()`'s fallback path actually works, not to exercise a reachable case.
1852 #[test]
1853 fn finish_degrades_to_400_on_invalid_header_value_instead_of_panicking() {
1854 let built = Response::builder()
1855 .status(StatusCode::OK)
1856 .header("X-Test", "invalid\r\nvalue")
1857 .body(ResponseBody::Buffered(Full::new(Bytes::new())));
1858 assert!(
1859 built.is_err(),
1860 "CR/LF in a header value should be rejected by the builder"
1861 );
1862
1863 let response = finish(built);
1864 assert_eq!(
1865 response.status(),
1866 StatusCode::BAD_REQUEST,
1867 "finish() should degrade to 400 rather than panicking on an invalid header value"
1868 );
1869 }
1870}
1871
1872#[cfg(test)]
1873mod header_prefix_tests {
1874 use super::*;
1875 use tokio::io::AsyncWriteExt;
1876
1877 /// Binds an ephemeral listener, connects a client to it, and returns both ends —
1878 /// `(server_side, client_side)` — so a test can drive `read_header_prefix` against a
1879 /// real socket without a full `Server`/`serve_connection` in the loop.
1880 async fn connected_pair() -> (TcpStream, TcpStream) {
1881 let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1882 let addr = listener.local_addr().unwrap();
1883 let client = TcpStream::connect(addr).await.unwrap();
1884 let (server_side, _) = listener.accept().await.unwrap();
1885 (server_side, client)
1886 }
1887
1888 #[tokio::test]
1889 async fn reads_exactly_up_to_and_including_the_terminating_blank_line() {
1890 let (mut server_side, mut client) = connected_pair().await;
1891
1892 client
1893 .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
1894 .await
1895 .unwrap();
1896
1897 let prefix = read_header_prefix(&mut server_side)
1898 .await
1899 .unwrap_or_else(|_| {
1900 panic!("expected a complete header block to be read");
1901 });
1902
1903 assert_eq!(prefix, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
1904 }
1905
1906 // Disproves a version that only inspects the newest chunk for `\r\n\r\n`: writing the
1907 // blank line in a separate write (and thus, almost always, a separate read) after the
1908 // rest of the headers would make that version wait forever, since the terminator
1909 // never appears within a single chunk. Also pins the tail-only scan in
1910 // `read_header_prefix` — a terminator straddling two reads must still be seen.
1911 #[tokio::test]
1912 async fn assembles_a_header_block_split_across_multiple_writes() {
1913 let (mut server_side, mut client) = connected_pair().await;
1914
1915 client
1916 .write_all(b"GET /page HTTP/1.1\r\nHost: localhost\r")
1917 .await
1918 .unwrap();
1919 client.write_all(b"\n\r\n").await.unwrap();
1920
1921 let prefix = read_header_prefix(&mut server_side)
1922 .await
1923 .unwrap_or_else(|_| {
1924 panic!("expected a complete header block to be read across multiple writes");
1925 });
1926
1927 assert_eq!(prefix, b"GET /page HTTP/1.1\r\nHost: localhost\r\n\r\n");
1928 }
1929
1930 // Bytes past the header block (a pipelined second request, here) must be preserved
1931 // verbatim in the returned prefix — `PrefixedIo` depends on this to replay them to
1932 // hyper untouched.
1933 #[tokio::test]
1934 async fn preserves_bytes_sent_past_the_header_block() {
1935 let (mut server_side, mut client) = connected_pair().await;
1936
1937 let first = b"GET /a HTTP/1.1\r\nHost: localhost\r\n\r\n";
1938 let second = b"GET /b HTTP/1.1\r\nHost: localhost\r\n\r\n";
1939 let mut sent = Vec::new();
1940 sent.extend_from_slice(first);
1941 sent.extend_from_slice(second);
1942 client.write_all(&sent).await.unwrap();
1943
1944 let prefix = read_header_prefix(&mut server_side)
1945 .await
1946 .unwrap_or_else(|_| {
1947 panic!("expected a complete header block to be read");
1948 });
1949
1950 assert_eq!(
1951 &prefix, &sent,
1952 "pipelined bytes past the first header block must survive intact"
1953 );
1954 }
1955
1956 #[tokio::test]
1957 async fn errors_with_connection_closed_when_client_disconnects_before_headers_complete() {
1958 let (mut server_side, client) = connected_pair().await;
1959 drop(client);
1960
1961 match read_header_prefix(&mut server_side).await {
1962 Err(HeaderReadError::ConnectionClosed) => {}
1963 Err(_) => panic!("expected ConnectionClosed, got a different error variant"),
1964 Ok(_) => {
1965 panic!("expected an error, got a complete header block from a closed connection")
1966 }
1967 }
1968 }
1969
1970 // Disproves an unbounded buffer: without the `MAX_HEADER_BYTES` check, this would
1971 // hang consuming memory forever instead of erroring, since the client never sends the
1972 // terminating blank line.
1973 #[tokio::test]
1974 async fn errors_with_too_large_once_max_header_bytes_is_exceeded_without_a_terminator() {
1975 let (mut server_side, mut client) = connected_pair().await;
1976
1977 let garbage = vec![b'a'; MAX_HEADER_BYTES + 1];
1978 client.write_all(&garbage).await.unwrap();
1979
1980 match read_header_prefix(&mut server_side).await {
1981 Err(HeaderReadError::TooLarge) => {}
1982 Err(_) => panic!("expected TooLarge, got a different error variant"),
1983 Ok(_) => {
1984 panic!("expected an error, got a complete header block from unterminated garbage")
1985 }
1986 }
1987 }
1988
1989 #[tokio::test]
1990 async fn prefixed_io_replays_the_prefix_before_reading_from_the_live_socket() {
1991 let (server_side, mut client) = connected_pair().await;
1992 let mut io = PrefixedIo::new(b"buffered-prefix".to_vec(), server_side);
1993
1994 client.write_all(b"-live-bytes").await.unwrap();
1995
1996 let mut collected = Vec::new();
1997 let mut chunk = [0u8; 8];
1998 while collected.len() < b"buffered-prefix-live-bytes".len() {
1999 let n = io.read(&mut chunk).await.unwrap();
2000 assert!(n > 0, "read returned 0 before all expected bytes arrived");
2001 collected.extend_from_slice(&chunk[..n]);
2002 }
2003
2004 assert_eq!(collected, b"buffered-prefix-live-bytes");
2005 }
2006}
2007
2008#[cfg(test)]
2009mod css_bundle_tests {
2010 use super::*;
2011 use std::fs;
2012 use std::time::Duration;
2013 use tempfile::TempDir;
2014 use tokio::time::sleep;
2015
2016 #[tokio::test]
2017 async fn source_folder_overlapping_output_dir_is_rejected() {
2018 let root = TempDir::new().unwrap();
2019
2020 // The output dir defaults to the served root, so registering that root as a source
2021 // folder must be refused: watching the output would feed every pipeline its own
2022 // writes back into its trigger.
2023 let result = Server::new(root.path())
2024 .unwrap()
2025 .with_source_folder(root.path());
2026 assert!(
2027 result.is_err(),
2028 "a source folder equal to the output dir must be rejected"
2029 );
2030 }
2031
2032 #[tokio::test]
2033 async fn source_folder_inside_output_dir_is_rejected() {
2034 let root = TempDir::new().unwrap();
2035 let nested = root.path().join("nested");
2036 fs::create_dir(&nested).unwrap();
2037
2038 let result = Server::new(root.path())
2039 .unwrap()
2040 .with_source_folder(&nested);
2041 assert!(
2042 result.is_err(),
2043 "a source folder nested in the output dir must be rejected"
2044 );
2045 }
2046
2047 #[tokio::test]
2048 async fn output_dir_overlapping_source_folder_is_rejected() {
2049 let root = TempDir::new().unwrap();
2050 let source = TempDir::new().unwrap();
2051
2052 let server = Server::new(root.path())
2053 .unwrap()
2054 .with_source_folder(source.path())
2055 .unwrap();
2056
2057 let result = server.with_output_dir(source.path());
2058 assert!(
2059 result.is_err(),
2060 "an output dir equal to a source folder must be rejected"
2061 );
2062 }
2063
2064 #[tokio::test]
2065 async fn css_bundle_creates_output_on_startup_with_live_reload() {
2066 let src = TempDir::new().unwrap();
2067 let out = TempDir::new().unwrap();
2068
2069 fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
2070
2071 let server = Server::new(out.path())
2072 .unwrap()
2073 .with_live_reload()
2074 .with_source_folder(src.path())
2075 .unwrap()
2076 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2077
2078 let (_port, handle) = server.run_ephemeral().await.unwrap();
2079
2080 sleep(Duration::from_millis(800)).await;
2081
2082 let bundle = out.path().join("styles.css");
2083 assert!(
2084 bundle.exists(),
2085 "bundle should be written to the default <output>/styles.css"
2086 );
2087 let content = fs::read_to_string(&bundle).unwrap();
2088 assert!(!content.is_empty(), "bundle should contain CSS");
2089
2090 handle.shutdown().await;
2091 }
2092
2093 #[tokio::test]
2094 async fn css_bundle_rebuilds_once_and_settles_when_source_css_changes() {
2095 let src = TempDir::new().unwrap();
2096 let out = TempDir::new().unwrap();
2097 let src_path = src.path();
2098 let bundle = out.path().join("styles.css");
2099
2100 fs::write(src_path.join("style.css"), "body { margin: 0; }").unwrap();
2101
2102 let server = Server::new(out.path())
2103 .unwrap()
2104 .with_live_reload()
2105 .with_source_folder(src_path)
2106 .unwrap()
2107 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2108
2109 let (_port, handle) = server.run_ephemeral().await.unwrap();
2110
2111 // Let the startup build and the watcher's first poll pass (500ms) complete.
2112 sleep(Duration::from_millis(800)).await;
2113 assert!(bundle.exists());
2114
2115 fs::write(
2116 src_path.join("style.css"),
2117 "body { margin: 0; color: blue; }",
2118 )
2119 .unwrap();
2120
2121 // Wait long enough for the watcher poll + rebundle to land at least once.
2122 sleep(Duration::from_millis(1500)).await;
2123 let content_v2 = fs::read_to_string(&bundle).unwrap();
2124 assert!(
2125 content_v2.contains("color"),
2126 "rebundle should contain the new color rule"
2127 );
2128
2129 let mtime_after = fs::metadata(&bundle).unwrap().modified().unwrap();
2130 sleep(Duration::from_millis(1200)).await;
2131 let mtime_later = fs::metadata(&bundle).unwrap().modified().unwrap();
2132
2133 // The regression this guards: the output write must NOT re-trigger another rebuild
2134 // (the feedback loop would keep mutating the bundle's mtime here). A settled mtime
2135 // over a full poll interval proves a single rebuild, not a loop.
2136 assert_eq!(
2137 mtime_after, mtime_later,
2138 "bundle mtime must settle after one rebuild — an ongoing loop would keep changing it"
2139 );
2140
2141 handle.shutdown().await;
2142 }
2143
2144 #[tokio::test]
2145 async fn css_bundle_creates_output_on_startup_without_live_reload() {
2146 let src = TempDir::new().unwrap();
2147 let out = TempDir::new().unwrap();
2148
2149 fs::write(src.path().join("style.css"), "body { margin: 0; }").unwrap();
2150
2151 let server = Server::new(out.path())
2152 .unwrap()
2153 .with_source_folder(src.path())
2154 .unwrap()
2155 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2156
2157 let (_port, handle) = server.run_ephemeral().await.unwrap();
2158
2159 sleep(Duration::from_millis(200)).await;
2160
2161 let bundle = out.path().join("styles.css");
2162 assert!(
2163 bundle.exists(),
2164 "bundle should be created even without live_reload"
2165 );
2166 let content = fs::read_to_string(&bundle).unwrap();
2167 assert!(!content.is_empty(), "bundle should contain CSS");
2168
2169 handle.shutdown().await;
2170 }
2171
2172 #[tokio::test]
2173 async fn css_bundle_concatenates_multiple_source_css_files() {
2174 let src = TempDir::new().unwrap();
2175 let out = TempDir::new().unwrap();
2176
2177 fs::write(src.path().join("reset.css"), "* { margin: 0; padding: 0; }").unwrap();
2178 fs::write(src.path().join("theme.css"), "body { background: white; }").unwrap();
2179
2180 let server = Server::new(out.path())
2181 .unwrap()
2182 .with_live_reload()
2183 .with_source_folder(src.path())
2184 .unwrap()
2185 .with_css_tool(CssTool::TestEcho, CssOptions::new().bundle(true));
2186
2187 let (_port, handle) = server.run_ephemeral().await.unwrap();
2188
2189 sleep(Duration::from_millis(800)).await;
2190
2191 let content = fs::read_to_string(out.path().join("styles.css")).unwrap();
2192 assert!(
2193 content.contains("margin"),
2194 "output should contain reset CSS"
2195 );
2196 assert!(
2197 content.contains("background"),
2198 "output should contain theme CSS"
2199 );
2200
2201 handle.shutdown().await;
2202 }
2203}
2204
2205#[cfg(test)]
2206mod asset_folder_tests {
2207 use super::*;
2208 use std::fs;
2209 use std::time::Duration;
2210 use tempfile::TempDir;
2211 use tokio::time::sleep;
2212
2213 #[tokio::test]
2214 async fn asset_folder_overlapping_output_dir_is_rejected() {
2215 let root = TempDir::new().unwrap();
2216 let result = Server::new(root.path())
2217 .unwrap()
2218 .with_asset_folder(root.path());
2219 assert!(
2220 result.is_err(),
2221 "an asset folder equal to the output dir must be rejected"
2222 );
2223 }
2224
2225 #[tokio::test]
2226 async fn asset_folder_overlapping_an_existing_asset_folder_is_rejected() {
2227 let root = TempDir::new().unwrap();
2228 let assets = TempDir::new().unwrap();
2229
2230 let result = Server::new(root.path())
2231 .unwrap()
2232 .with_asset_folder(assets.path())
2233 .unwrap()
2234 .with_asset_folder(assets.path());
2235 assert!(
2236 result.is_err(),
2237 "registering the same asset folder twice must be rejected"
2238 );
2239 }
2240
2241 #[tokio::test]
2242 async fn asset_folder_overlapping_a_source_folder_is_rejected_both_ways() {
2243 let root = TempDir::new().unwrap();
2244 let shared = TempDir::new().unwrap();
2245
2246 let via_asset_then_source = Server::new(root.path())
2247 .unwrap()
2248 .with_asset_folder(shared.path())
2249 .unwrap()
2250 .with_source_folder(shared.path());
2251 assert!(
2252 via_asset_then_source.is_err(),
2253 "a source folder overlapping an already-registered asset folder must be rejected"
2254 );
2255
2256 let via_source_then_asset = Server::new(root.path())
2257 .unwrap()
2258 .with_source_folder(shared.path())
2259 .unwrap()
2260 .with_asset_folder(shared.path());
2261 assert!(
2262 via_source_then_asset.is_err(),
2263 "an asset folder overlapping an already-registered source folder must be rejected"
2264 );
2265 }
2266
2267 #[tokio::test]
2268 async fn asset_folder_files_are_served_after_startup_build() {
2269 let assets = TempDir::new().unwrap();
2270 let out = TempDir::new().unwrap();
2271 fs::write(assets.path().join("index.html"), "<html>hi</html>").unwrap();
2272 fs::create_dir(assets.path().join("images")).unwrap();
2273 fs::write(assets.path().join("images/logo.svg"), "<svg></svg>").unwrap();
2274
2275 let server = Server::new(out.path())
2276 .unwrap()
2277 .with_asset_folder(assets.path())
2278 .unwrap();
2279 let (port, handle) = server.run_ephemeral().await.unwrap();
2280
2281 sleep(Duration::from_millis(200)).await;
2282
2283 let index = fs::read_to_string(out.path().join("index.html")).unwrap();
2284 assert_eq!(index, "<html>hi</html>");
2285 let logo = fs::read_to_string(out.path().join("images/logo.svg")).unwrap();
2286 assert_eq!(logo, "<svg></svg>");
2287
2288 let mut conn = tokio::net::TcpStream::connect(("127.0.0.1", port))
2289 .await
2290 .unwrap();
2291 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2292 conn.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
2293 .await
2294 .unwrap();
2295 let mut response = Vec::new();
2296 conn.read_to_end(&mut response).await.unwrap();
2297 let response = String::from_utf8_lossy(&response);
2298 assert!(response.contains("HTTP/1.1 200"), "got: {response}");
2299 assert!(response.contains("<html>hi</html>"), "got: {response}");
2300
2301 handle.shutdown().await;
2302 }
2303
2304 #[tokio::test]
2305 async fn asset_folder_change_rebuilds_and_live_reloads() {
2306 let assets = TempDir::new().unwrap();
2307 let out = TempDir::new().unwrap();
2308 fs::write(assets.path().join("index.html"), "v1").unwrap();
2309
2310 let server = Server::new(out.path())
2311 .unwrap()
2312 .with_live_reload()
2313 .with_asset_folder(assets.path())
2314 .unwrap();
2315 let (_port, handle) = server.run_ephemeral().await.unwrap();
2316
2317 sleep(Duration::from_millis(800)).await;
2318 assert_eq!(
2319 fs::read_to_string(out.path().join("index.html")).unwrap(),
2320 "v1"
2321 );
2322
2323 fs::write(assets.path().join("index.html"), "v2").unwrap();
2324 sleep(Duration::from_millis(1500)).await;
2325
2326 assert_eq!(
2327 fs::read_to_string(out.path().join("index.html")).unwrap(),
2328 "v2",
2329 "editing the source asset must re-copy it into the output dir"
2330 );
2331
2332 handle.shutdown().await;
2333 }
2334}
2335
2336#[cfg(test)]
2337mod build_once_tests {
2338 use super::*;
2339 use std::fs;
2340 use tempfile::TempDir;
2341
2342 #[tokio::test]
2343 async fn build_populates_the_output_dir_without_starting_a_server() {
2344 let assets = TempDir::new().unwrap();
2345 let out = TempDir::new().unwrap();
2346 fs::write(assets.path().join("index.html"), "<html>hi</html>").unwrap();
2347
2348 let server = Server::new(out.path())
2349 .unwrap()
2350 .with_asset_folder(assets.path())
2351 .unwrap();
2352
2353 server.build().await.unwrap();
2354
2355 assert_eq!(
2356 fs::read_to_string(out.path().join("index.html")).unwrap(),
2357 "<html>hi</html>",
2358 "build() must populate the output dir synchronously, no server needed"
2359 );
2360 }
2361
2362 #[tokio::test]
2363 async fn build_with_no_pipeline_configured_is_a_harmless_no_op() {
2364 let out = TempDir::new().unwrap();
2365 let server = Server::new(out.path()).unwrap();
2366
2367 server
2368 .build()
2369 .await
2370 .expect("build() with nothing configured must succeed trivially");
2371 }
2372
2373 #[tokio::test]
2374 async fn build_fails_fast_when_a_required_tool_binary_is_missing() {
2375 let src = TempDir::new().unwrap();
2376 let out = TempDir::new().unwrap();
2377 fs::write(src.path().join("a.css"), "body{}").unwrap();
2378
2379 let server = Server::new(out.path())
2380 .unwrap()
2381 .with_source_folder(src.path())
2382 .unwrap()
2383 .with_css_tool(CssTool::TestMissing, CssOptions::new().minify(true));
2384
2385 let result = server.build().await;
2386
2387 assert!(
2388 matches!(result, Err(StaticError::PipelineSetup(_))),
2389 "expected PipelineSetup, got {result:?}"
2390 );
2391 }
2392}
2393
2394#[cfg(test)]
2395mod range_header_tests {
2396 use super::*;
2397
2398 #[test]
2399 fn no_range_header_returns_unsatisfiable() {
2400 match parse_range_header("bytes=", 1000) {
2401 RangeOutcome::Unsatisfiable => {}
2402 other => panic!("expected Unsatisfiable, got {other:?}"),
2403 }
2404 }
2405
2406 #[test]
2407 fn invalid_format_returns_unsatisfiable() {
2408 match parse_range_header("invalid", 1000) {
2409 RangeOutcome::NoRange => {}
2410 other => panic!("expected NoRange, got {other:?}"),
2411 }
2412 }
2413
2414 #[test]
2415 fn simple_range_returns_satisfiable() {
2416 match parse_range_header("bytes=0-99", 1000) {
2417 RangeOutcome::Satisfiable(start, end) => {
2418 assert_eq!(start, 0);
2419 assert_eq!(end, 99);
2420 }
2421 other => panic!("expected Satisfiable(0, 99), got {other:?}"),
2422 }
2423 }
2424
2425 #[test]
2426 fn open_ended_range_returns_satisfiable() {
2427 match parse_range_header("bytes=100-", 1000) {
2428 RangeOutcome::Satisfiable(start, end) => {
2429 assert_eq!(start, 100);
2430 assert_eq!(end, 999);
2431 }
2432 other => panic!("expected Satisfiable(100, 999), got {other:?}"),
2433 }
2434 }
2435
2436 #[test]
2437 fn suffix_range_returns_satisfiable() {
2438 match parse_range_header("bytes=-100", 1000) {
2439 RangeOutcome::Satisfiable(start, end) => {
2440 assert_eq!(start, 900);
2441 assert_eq!(end, 999);
2442 }
2443 other => panic!("expected Satisfiable(900, 999), got {other:?}"),
2444 }
2445 }
2446
2447 #[test]
2448 fn suffix_range_longer_than_file_returns_full_range() {
2449 match parse_range_header("bytes=-2000", 1000) {
2450 RangeOutcome::Satisfiable(start, end) => {
2451 assert_eq!(start, 0);
2452 assert_eq!(end, 999);
2453 }
2454 other => panic!("expected Satisfiable(0, 999), got {other:?}"),
2455 }
2456 }
2457
2458 #[test]
2459 fn end_overshooting_file_clamps_correctly() {
2460 match parse_range_header("bytes=0-2000", 1000) {
2461 RangeOutcome::Satisfiable(start, end) => {
2462 assert_eq!(start, 0);
2463 assert_eq!(end, 999);
2464 }
2465 other => panic!("expected Satisfiable(0, 999), got {other:?}"),
2466 }
2467 }
2468
2469 #[test]
2470 fn start_at_file_boundary_returns_unsatisfiable() {
2471 match parse_range_header("bytes=1000-", 1000) {
2472 RangeOutcome::Unsatisfiable => {}
2473 other => panic!("expected Unsatisfiable, got {other:?}"),
2474 }
2475 }
2476
2477 #[test]
2478 fn start_beyond_file_returns_unsatisfiable() {
2479 match parse_range_header("bytes=2000-3000", 1000) {
2480 RangeOutcome::Unsatisfiable => {}
2481 other => panic!("expected Unsatisfiable, got {other:?}"),
2482 }
2483 }
2484
2485 #[test]
2486 fn end_before_start_returns_unsatisfiable() {
2487 match parse_range_header("bytes=100-50", 1000) {
2488 RangeOutcome::Unsatisfiable => {}
2489 other => panic!("expected Unsatisfiable, got {other:?}"),
2490 }
2491 }
2492
2493 #[test]
2494 fn multi_range_returns_multi_range_ignored() {
2495 match parse_range_header("bytes=0-99,200-299", 1000) {
2496 RangeOutcome::MultiRangeIgnored => {}
2497 other => panic!("expected MultiRangeIgnored, got {other:?}"),
2498 }
2499 }
2500
2501 #[test]
2502 fn zero_suffix_length_returns_unsatisfiable() {
2503 match parse_range_header("bytes=-0", 1000) {
2504 RangeOutcome::Unsatisfiable => {}
2505 other => panic!("expected Unsatisfiable, got {other:?}"),
2506 }
2507 }
2508
2509 #[test]
2510 fn if_range_valid_with_matching_etag() {
2511 assert!(if_range_valid("\"abc123\"", "\"abc123\""));
2512 }
2513
2514 #[test]
2515 fn if_range_valid_with_mismatched_etag() {
2516 assert!(!if_range_valid("\"abc123\"", "\"def456\""));
2517 }
2518
2519 #[test]
2520 fn if_range_valid_with_whitespace() {
2521 assert!(if_range_valid(" \"abc123\" ", "\"abc123\""));
2522 }
2523}