mini_static/server.rs
1use std::fs;
2use std::io::Write;
3use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, Instant, SystemTime};
7
8use bytes::Bytes;
9use http_body_util::Full;
10use hyper::header::{self, HeaderName, HeaderValue};
11use hyper::http::response::Builder;
12use hyper::{HeaderMap, Method, Request, Response, StatusCode};
13use tokio::fs::File;
14use tokio::net::TcpListener;
15use tokio::time::timeout;
16
17use crate::error::StaticError;
18use crate::handler::{FileBody, ResponseBody};
19use crate::reload::{self, SseBody};
20use crate::resolve;
21use crate::resolve::HiddenFiles;
22use crate::spa::{self, SpaTransition};
23use crate::watcher::{start_watching, Broadcaster};
24
25
26/// Default maximum concurrent connections, overridable via `Server::with_max_connections`.
27const DEFAULT_MAX_CONNECTIONS: usize = 1024;
28
29/// A predicate deciding whether a resolved file path should get an immutable cache
30/// policy; see [`Server::with_immutable_assets`].
31type ImmutablePredicate = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
32
33/// A static file server for serving files securely from a root directory.
34///
35/// `Server` canonicalizes the root directory once at creation time and uses the
36/// canonical form for all subsequent requests, avoiding repeated filesystem calls.
37///
38/// # Security
39///
40/// The server protects against:
41/// - Path traversal attacks (e.g., `../../etc/passwd`)
42/// - Accessing files outside the root via symlinks
43/// - Disclosing filesystem structure (traversal and missing files both return 404)
44///
45/// # Cloning
46///
47/// `Server` is cheap to clone: a `PathBuf`, a couple of primitives, and an `Arc`'d
48/// predicate closure. Multiple clones can be used concurrently in async tasks without
49/// synchronization overhead.
50///
51/// # Example
52///
53/// ```no_run
54/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
55/// use mini_static::Server;
56/// use std::path::Path;
57/// use std::time::Duration;
58///
59/// let server = Server::new(Path::new("./public"))?;
60/// let (port, _handle) = server.run(Duration::from_secs(30)).await?;
61/// println!("Server running on port {}", port);
62/// # Ok(())
63/// # }
64/// ```
65/// Headers this server derives from the response it is building, and therefore refuses
66/// as fixed values via [`Server::with_response_header`]. A fixed value would be either
67/// silently overridden or silently duplicated depending on the response — and a wrong
68/// `Content-Length` or `ETag` is a correctness bug, not a policy choice.
69const SERVER_COMPUTED_HEADERS: [HeaderName; 13] = [
70 header::CONTENT_LENGTH,
71 header::CONTENT_TYPE,
72 header::CONTENT_ENCODING,
73 header::CONTENT_RANGE,
74 header::ETAG,
75 header::CACHE_CONTROL,
76 header::VARY,
77 header::ACCEPT_RANGES,
78 header::ALLOW,
79 header::LOCATION,
80 header::CONNECTION,
81 header::TRANSFER_ENCODING,
82 header::X_CONTENT_TYPE_OPTIONS,
83];
84
85/// Where request and connection log lines go.
86///
87/// A `Server` is cloned per request, so the sink is shared rather than duplicated. The
88/// mutex serializes writes from concurrent connections — without it, two responses
89/// finishing at once would interleave mid-line and produce log entries belonging to
90/// neither request.
91type RequestLog = Arc<Mutex<Box<dyn Write + Send>>>;
92
93#[derive(Clone)]
94pub struct Server {
95 root_canon: PathBuf,
96 max_connections: usize,
97 live_reload: bool,
98 broadcaster: Option<Broadcaster>,
99 spa_mode: bool,
100 spa_root: Option<String>,
101 spa_transition: SpaTransition,
102 not_found_page: Option<PathBuf>,
103 hidden_files: HiddenFiles,
104 /// Whether to look for `.br`/`.gz` siblings. On by default; see
105 /// [`Server::without_precompressed`] for what it costs and why the default stands.
106 precompressed: bool,
107 /// Files read into memory at construction, if [`Server::with_content_cache`] was called.
108 ///
109 /// `Arc` because `Server` is cloned per connection today and the map is read-only after
110 /// construction — there is no lock, no eviction and no invalidation, which is the whole
111 /// reason an eager cache is simpler than a general one.
112 content_cache: Option<Arc<crate::cache::ContentCache>>,
113 request_log: Option<RequestLog>,
114 extra_headers: Arc<Vec<(HeaderName, HeaderValue)>>,
115 immutable_predicate: Option<ImmutablePredicate>,
116}
117
118impl Server {
119 /// Create a new server with the given root directory.
120 ///
121 /// Canonicalizes the root once at startup. All subsequent requests use the
122 /// canonical root without re-canonicalizing it, making this suitable for long-lived servers.
123 ///
124 /// # Errors
125 ///
126 /// Returns `Err(StaticError::Io)` if the root cannot be canonicalized (e.g., doesn't exist,
127 /// no read permissions).
128 pub fn new(root: &Path) -> Result<Self, StaticError> {
129 let root_canon = root.canonicalize().map_err(StaticError::Io)?;
130 Ok(Server {
131 root_canon,
132 max_connections: DEFAULT_MAX_CONNECTIONS,
133 live_reload: false,
134 broadcaster: None,
135 spa_mode: false,
136 spa_root: None,
137 spa_transition: SpaTransition::default(),
138 not_found_page: None,
139 hidden_files: HiddenFiles::Deny,
140 precompressed: true,
141 content_cache: None,
142 request_log: None,
143 extra_headers: Arc::new(Vec::new()),
144 immutable_predicate: None,
145 })
146 }
147
148 /// Set the maximum number of connections served concurrently (default 1024).
149 ///
150 /// Once this many connections are in flight, `run()`'s accept loop stops accepting
151 /// new ones — without pausing the accept loop, a client that opens a connection and
152 /// sends nothing (see the header-read timeout docs on [`Server::run_on`]) could
153 /// otherwise be used, in enough parallel copies, to exhaust the process's file
154 /// descriptors or memory with no bound at all.
155 pub fn with_max_connections(mut self, max: usize) -> Self {
156 self.max_connections = max;
157 self
158 }
159
160 /// Enable live-reload for this server (disabled by default).
161 ///
162 /// Once enabled, the `run*` methods start a background watcher (mtime polling,
163 /// bounded 500ms interval — see [`crate::start_watching`]) the first time the server
164 /// actually starts accepting connections. It watches the served root; when a build
165 /// pipeline is configured it watches that pipeline's source folders instead, because
166 /// the pipeline broadcasts its own outputs once they are written. Then it will:
167 ///
168 /// - serve a live-reload SSE stream at [`crate::LIVE_RELOAD_PATH`], broadcasting a
169 /// change event (with [`crate::ChangeType`]) whenever a served file is added,
170 /// modified, or removed;
171 /// - inject a small `<script>` into every served `text/html` response that connects
172 /// to that stream and reloads the page (or hot-swaps stylesheet `<link>`s, for CSS
173 /// changes) — no manual client wiring required.
174 ///
175 /// This is meant for local development, not production: leave it disabled (the
176 /// default) for any server serving real traffic. A typical call site gates it behind
177 /// `#[cfg(debug_assertions)]` so a release build never pays for the watcher or the
178 /// injected script.
179 ///
180 /// # Example
181 ///
182 /// ```no_run
183 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
184 /// use mini_static::Server;
185 /// use std::path::Path;
186 ///
187 /// let server = Server::new(Path::new("./public"))?;
188 /// #[cfg(debug_assertions)]
189 /// let server = server.with_live_reload();
190 /// # Ok(())
191 /// # }
192 /// ```
193 pub fn with_live_reload(mut self) -> Self {
194 self.live_reload = true;
195 self
196 }
197
198 /// Enable spa-mode navigation for this server, swapping `document.body` on
199 /// each navigation (disabled by default).
200 ///
201 /// Once enabled, every served `text/html` response gets a small `<script>`
202 /// injected (see [`Server::with_spa_root`] for what it does) that treats
203 /// `document.body` as the swap target. Calling this after
204 /// [`Server::with_spa_root`] does not clear a previously configured root
205 /// selector — the two methods set independent fields, so
206 /// `.with_spa_root(sel).with_spa_mode()` and
207 /// `.with_spa_mode().with_spa_root(sel)` both end up with spa-mode on and
208 /// root `sel`. Use this one alone when there's no persistent chrome to
209 /// preserve across navigations.
210 ///
211 /// # Example
212 ///
213 /// ```no_run
214 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
215 /// use mini_static::Server;
216 /// use std::path::Path;
217 ///
218 /// let server = Server::new(Path::new("./public"))?.with_spa_mode();
219 /// # Ok(())
220 /// # }
221 /// ```
222 pub fn with_spa_mode(mut self) -> Self {
223 self.spa_mode = true;
224 self
225 }
226
227 /// Enable spa-mode navigation for this server, swapping only the element
228 /// matched by the CSS `selector` on each navigation (disabled by default;
229 /// also enables spa-mode the same as [`Server::with_spa_mode`]).
230 ///
231 /// Once enabled, every served `text/html` response gets a small `<script>`
232 /// injected that intercepts left-clicks on same-origin `<a href>`
233 /// elements (skipping links with a non-`_self` `target`, a `download`
234 /// attribute, `rel="external"`, a `data-no-spa` attribute, or a same-page
235 /// hash-only href) and, instead of a normal navigation:
236 ///
237 /// - fetches the target URL;
238 /// - on a non-OK or non-`text/html` response (or a fetch error), falls
239 /// back to a real `location.href` navigation — spa-mode never renders a
240 /// broken page;
241 /// - otherwise replaces the matched element's `innerHTML` with the
242 /// corresponding content from the fetched document, updates the page
243 /// title, and pushes the new URL via `history.pushState`, animating the
244 /// swap with `document.startViewTransition()` where supported;
245 /// - dispatches a `mini-static:navigate` `CustomEvent` on `window` after
246 /// every client-side navigation, so page scripts can re-run any
247 /// per-page initialization that would otherwise only execute once
248 /// (content swapped in via `innerHTML` never executes its own
249 /// `<script>` tags);
250 /// - handles browser back/forward by re-fetching and swapping to the new
251 /// `location.href`.
252 ///
253 /// `selector` is matched against both the current page and the fetched
254 /// page; a link click where the selector matches neither falls back to a
255 /// real navigation, same as a fetch failure. Choose a `selector` that
256 /// wraps only the content that varies between pages, leaving persistent
257 /// chrome (nav/header/footer) outside it so it survives navigation
258 /// untouched.
259 ///
260 /// This is meant to be usable in production, not just local development
261 /// (unlike [`Server::with_live_reload`]): a click on a link mini-static
262 /// doesn't intercept, or on a browser without JS or View Transitions
263 /// support, still works as a normal navigation.
264 ///
265 /// # Example
266 ///
267 /// ```no_run
268 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
269 /// use mini_static::Server;
270 /// use std::path::Path;
271 ///
272 /// let server = Server::new(Path::new("./public"))?.with_spa_root("#app");
273 /// # Ok(())
274 /// # }
275 /// ```
276 pub fn with_spa_root(mut self, selector: &str) -> Self {
277 self.spa_mode = true;
278 self.spa_root = Some(selector.to_string());
279 self
280 }
281
282 /// Set how spa-mode animates the swap between pages (also enables
283 /// spa-mode the same as [`Server::with_spa_mode`]; default
284 /// [`SpaTransition::Fade`] when spa-mode is enabled without calling this).
285 ///
286 /// [`SpaTransition::Slide`] injects its own `<style>` tag alongside the
287 /// spa-mode `<script>` — no site CSS is required. See [`SpaTransition`]
288 /// and [`crate::SlideOptions`] for what each variant does and how to
289 /// configure the slide's duration, direction, and easing.
290 ///
291 /// # Example
292 ///
293 /// ```no_run
294 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
295 /// use mini_static::{Server, SlideOptions, SpaTransition};
296 /// use std::path::Path;
297 ///
298 /// let server = Server::new(Path::new("./public"))?
299 /// .with_spa_root("#app")
300 /// .with_spa_transition(SpaTransition::Slide(
301 /// SlideOptions::default().duration_ms(500),
302 /// ));
303 /// # Ok(())
304 /// # }
305 /// ```
306 pub fn with_spa_transition(mut self, transition: SpaTransition) -> Self {
307 self.spa_mode = true;
308 self.spa_transition = transition;
309 self
310 }
311
312 /// Serves `path` as the body of every `404`, instead of the default plain-text
313 /// `not found`.
314 ///
315 /// `path` is resolved relative to the served root and must exist when this is
316 /// called: a missing 404 page is a deployment mistake, and finding out on the first
317 /// broken link — the one moment the page exists to handle — is too late. It is read
318 /// from disk per response rather than cached, so editing it during a live-reload
319 /// session takes effect without a restart.
320 ///
321 /// The response keeps its `404` status. Serving a custom page with `200` is a soft
322 /// 404: search engines index it, and monitoring stops seeing the failures. It also
323 /// carries `Cache-Control: no-store`, so a client never holds this page as though it
324 /// were the resource that was actually requested.
325 ///
326 /// Nothing about the failed request reaches the page — no path, no reason. A
327 /// traversal attempt and an ordinary miss are deliberately indistinguishable
328 /// (`StaticError::user_message`), and templating the requested path into the
329 /// response would undo that and hand back a reflected-content vector besides.
330 ///
331 /// # Errors
332 ///
333 /// Returns `Err(StaticError::Io)` if `path` cannot be canonicalized (typically:
334 /// it does not exist), or `Err(StaticError::Traversal)` if it lies outside the
335 /// served root.
336 ///
337 /// # Example
338 ///
339 /// ```no_run
340 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
341 /// use mini_static::Server;
342 /// use std::path::Path;
343 ///
344 /// let server = Server::new(Path::new("./public"))?
345 /// .with_not_found_page(Path::new("404.html"))?;
346 /// # Ok(())
347 /// # }
348 /// ```
349 pub fn with_not_found_page(mut self, path: &Path) -> Result<Self, StaticError> {
350 let joined = self.root_canon.join(path);
351 let canon = joined.canonicalize().map_err(StaticError::Io)?;
352
353 if !canon.starts_with(&self.root_canon) {
354 return Err(StaticError::Traversal(format!(
355 "404 page {} lies outside the served root {}",
356 canon.display(),
357 self.root_canon.display()
358 )));
359 }
360
361 self.not_found_page = Some(canon);
362 Ok(self)
363 }
364
365 /// Serve dot-prefixed paths (`.env`, `.git/config`) instead of answering them as a
366 /// miss.
367 ///
368 /// Hidden files are denied by default. A served root is routinely a build output
369 /// directory, a repository working copy, or a folder someone dropped a `.env` into,
370 /// and the traversal guard cannot help: those files are legitimately *inside* the
371 /// root, so anyone who guesses the name gets them. The default trades a rarely-wanted
372 /// capability for not leaking credentials by accident.
373 ///
374 /// `/.well-known/` is served either way — it is where the web puts resources that
375 /// are meant to be fetched (ACME challenges for certificate issuance,
376 /// `security.txt`), and denying it would break certificate renewal. The exception is
377 /// the first segment only: `/.well-known/.hidden` is still denied.
378 ///
379 /// Call this when the served root is a curated directory whose dotfiles are content
380 /// — a static site that publishes a `.htaccess` for a downstream server, say.
381 ///
382 /// # Example
383 ///
384 /// ```no_run
385 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
386 /// use mini_static::Server;
387 /// use std::path::Path;
388 ///
389 /// let server = Server::new(Path::new("./public"))?.with_hidden_files();
390 /// # Ok(())
391 /// # }
392 /// ```
393 pub fn with_hidden_files(mut self) -> Self {
394 self.hidden_files = HiddenFiles::Serve;
395 self
396 }
397
398 /// A cached sidecar for `relative`, honouring the client's stated encoding preference.
399 ///
400 /// The variants are already in the map: a `.br` file is a regular file and was enumerated
401 /// under its own name, so this is a second lookup rather than extra storage. A cached root
402 /// therefore serves precompressed assets with **no `open()` at all**, where the disk path
403 /// spends up to two.
404 ///
405 /// Negotiation comes from `preferred_encodings`, the same function the disk probe uses, so
406 /// the two cannot disagree about which encoding a client wanted.
407 fn cached_sidecar(
408 &self,
409 relative: &Path,
410 accept_encoding: Option<&str>,
411 ) -> Option<(&crate::cache::CachedFile, &'static str)> {
412 let cache = self.content_cache.as_ref()?;
413 for (encoding, ext) in preferred_encodings(accept_encoding) {
414 let mut sibling = relative.as_os_str().to_os_string();
415 sibling.push(ext);
416 if let Some(entry) = cache.get(Path::new(&sibling)) {
417 return Some((entry, encoding));
418 }
419 }
420 None
421 }
422
423 /// The cached entry for a request's segments, if one is held and usable.
424 ///
425 /// Retries with `index.html` appended, because the disk path resolves a directory to its
426 /// index and a cache keyed on files would otherwise miss `/` — the most common request any
427 /// site receives. The returned path is relative to the root; the caller joins it, so the
428 /// trailing-slash redirect downstream sees exactly the path it would have seen from disk.
429 ///
430 /// Does **not** decline an entry that has a precompressed sibling, though an earlier draft
431 /// did. The sidecar probe runs after this and replaces the body when the client accepts an
432 /// encoding, so declining changed nothing a client could see — a mutation removing the
433 /// decline left every test green, which is how the code was found to be inert. Dropping it
434 /// is also faster: a client that sends no `Accept-Encoding` now gets such a file from
435 /// memory instead of from disk.
436 fn cached_entry<S: AsRef<str>>(
437 &self,
438 segments: Option<&[S]>,
439 request_path: &str,
440 ) -> Option<(PathBuf, &crate::cache::CachedFile)> {
441 let cache = self.content_cache.as_ref()?;
442
443 // `handle_request` supplies no segments, so decode them here. The disk path would do
444 // the same work; nothing is duplicated by doing it before the lookup instead of after.
445 let decoded = match segments {
446 Some(_) => None,
447 None => Some(resolve::decode_segments(request_path).ok()?),
448 };
449
450 // The same refusals the disk path makes, from the same function. Skipping them served
451 // `/.env` from memory while the disk path refused it; a cache must not be a way around
452 // a policy.
453 let key: PathBuf = match (segments, &decoded) {
454 (Some(given), _) => resolve::servable_segments(given, request_path, self.hidden_files)
455 .ok()?
456 .iter()
457 .map(|segment| segment.as_ref())
458 .collect(),
459 (None, Some(own)) => {
460 resolve::servable_segments(own, request_path, self.hidden_files).ok()?;
461 own.iter().map(|segment| segment.as_ref()).collect()
462 }
463 (None, None) => return None,
464 };
465
466 let direct = self.content_cache.as_ref().and_then(|c| c.get(&key)).map(|entry| (key.clone(), entry));
467 let found = match direct {
468 Some(found) => found,
469 None => {
470 let index = key.join(resolve::INDEX_FILE_NAME);
471 let entry = cache.get(&index)?;
472 (index, entry)
473 }
474 };
475 Some(found)
476 }
477
478 /// The conflict between a content cache and live-reload, decided in one place.
479 ///
480 /// Live-reload exists because files under the root change while the server runs; the cache
481 /// exists because they do not. Holding both is not a preference to resolve at serve time —
482 /// it is a contradiction, and serving stale content while a watcher announces changes is
483 /// the worst of the available outcomes.
484 ///
485 /// Consulted from all three entry points rather than checked at each: `with_content_cache`
486 /// catches the conflict when the cache is added second, `run_on` catches it when
487 /// live-reload is, and `into_fallback` catches it for a composed deployment that never
488 /// calls `run_on` at all. One condition, three callers — the alternative is three copies of
489 /// a rule that must agree.
490 fn cache_conflict(&self) -> Option<StaticError> {
491 (self.live_reload && self.content_cache.is_some()).then(|| {
492 StaticError::Config(
493 "a content cache and live-reload cannot both be enabled: live-reload watches \
494 the served root for changes, and the cache is never invalidated, so every \
495 change it reported would be a change the server did not serve. Drop \
496 with_content_cache for development, or with_live_reload for production."
497 .to_string(),
498 )
499 })
500 }
501
502 /// Read the served root into memory now, and answer from memory thereafter.
503 ///
504 /// **This reads the filesystem when called**, walking the root and holding up to
505 /// `max_bytes` of file contents. That is unusual for a builder and is the point: the cost
506 /// is paid once, at construction, so no request pays it.
507 ///
508 /// # The promise you are making
509 ///
510 /// The cache is never invalidated. **A file changed under the root after this call is
511 /// served in its old form until the process restarts.** That suits the deployment this
512 /// crate targets — a baked image, built then served — and does not suit a root that is
513 /// written while running, which is why a server configured with both this and
514 /// [`Server::with_live_reload`] refuses to start rather than serving stale content.
515 ///
516 /// # What is cached
517 ///
518 /// Real regular files only. Symlinks, FIFOs, sockets and devices are refused, and the walk
519 /// does not follow a symlinked directory — so every cached path is inside the root by
520 /// construction, with no containment check of its own. Anything not cached, including
521 /// everything past `max_bytes`, is served from disk exactly as before.
522 ///
523 /// Exceeding `max_bytes` truncates rather than failing: enumeration is sorted, so the
524 /// cached set is a deterministic prefix, and the shortfall is logged.
525 /// # Errors
526 ///
527 /// Returns `Err` if [`Server::with_live_reload`] was already called: live-reload watches
528 /// the served root for changes and this cache is never invalidated, so the two contradict
529 /// each other. Fallible in the builder rather than only at start-up
530 /// because catching a contradiction at the call site that created it beats catching it
531 /// later; `with_live_reload` cannot do the same, since it returns `Self`.
532 pub fn with_content_cache(mut self, max_bytes: usize) -> Result<Self, StaticError> {
533 let cache = crate::cache::populate(&self.root_canon, max_bytes);
534 self.log(format_args!(
535 "content cache: {} files, {} bytes, {} with precompressed siblings{}",
536 cache.len(),
537 cache.bytes_held(),
538 cache.with_siblings(),
539 if cache.truncated() {
540 format!(" (truncated at the {max_bytes}-byte ceiling; the rest serves from disk)")
541 } else {
542 String::new()
543 }
544 ));
545 self.content_cache = Some(Arc::new(cache));
546 match self.cache_conflict() {
547 Some(conflict) => Err(conflict),
548 None => Ok(self),
549 }
550 }
551
552 /// Stop looking for precompressed `.br`/`.gz` siblings.
553 ///
554 /// Serving a sidecar costs **two `open()` calls per request that finds none**, because
555 /// browsers send `Accept-Encoding` on every request: one attempt for `<path>.br`, one
556 /// for `<path>.gz`. Measured on a 484-byte file that is **12.7% of throughput**
557 /// (58,638 → 66,077 req/s), which makes it the largest single cost this crate pays for
558 /// a feature many deployments never use — nothing in this ecosystem generates sidecars,
559 /// so a root without them pays the whole 12.7% for a lookup that cannot succeed.
560 ///
561 /// Left **on by default** deliberately. Inferring the answer from a directory scan would
562 /// be behaviour a reader has to know to look for, and defaulting it off would silently
563 /// stop serving precompressed assets for anyone who does ship them — a failure visible
564 /// only as a bandwidth bill. So it is a decision made at the call site.
565 ///
566 /// Call this when the served root contains no `.br` or `.gz` siblings. If one appears
567 /// later it will not be served, which is the whole of what this trades away.
568 pub fn without_precompressed(mut self) -> Self {
569 self.precompressed = false;
570 self
571 }
572
573 /// Log one line per request to stderr, plus connection-level errors.
574 ///
575 /// Off by default: a library that writes to a process's stderr uninvited is a
576 /// surprise, and an embedder with its own logging wants the lines somewhere else.
577 /// See [`Server::with_request_logging_to`] to choose the destination.
578 ///
579 /// # Example
580 ///
581 /// ```no_run
582 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
583 /// use mini_static::Server;
584 /// use std::path::Path;
585 ///
586 /// let server = Server::new(Path::new("./public"))?.with_request_logging();
587 /// # Ok(())
588 /// # }
589 /// ```
590 /// Send `name: value` on every response.
591 ///
592 /// Call repeatedly to add several. Intended for the policy headers a static site
593 /// wants applied uniformly — `Strict-Transport-Security`, `Content-Security-Policy`,
594 /// `Referrer-Policy` — which this crate has no business choosing on an embedder's
595 /// behalf but every business making expressible.
596 ///
597 /// Both name and value are validated here, at configuration time, so a malformed
598 /// header fails when the server is built rather than on a request months later.
599 ///
600 /// # Errors
601 ///
602 /// - `StaticError::Config` if `name` or `value` is not a valid HTTP header.
603 /// - `StaticError::Config` if `name` is one this server computes per response
604 /// (`Content-Length`, `Content-Type`, `Content-Encoding`, `Content-Range`, `ETag`,
605 /// `Cache-Control`, `Vary`, `Accept-Ranges`, `Allow`, `Location`, `Connection`,
606 /// `Transfer-Encoding`, `X-Content-Type-Options`). A fixed value would either be
607 /// silently overridden or silently duplicated depending on the response — a
608 /// configuration mistake worth surfacing at startup rather than a behavior worth
609 /// supporting. Use [`Server::with_immutable_assets`] for cache policy.
610 ///
611 /// # Example
612 ///
613 /// ```no_run
614 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
615 /// use mini_static::Server;
616 /// use std::path::Path;
617 ///
618 /// let server = Server::new(Path::new("./public"))?
619 /// .with_response_header("Strict-Transport-Security", "max-age=63072000")?
620 /// .with_response_header("Referrer-Policy", "strict-origin-when-cross-origin")?;
621 /// # Ok(())
622 /// # }
623 /// ```
624 pub fn with_response_header(mut self, name: &str, value: &str) -> Result<Self, StaticError> {
625 let name = HeaderName::from_bytes(name.as_bytes())
626 .map_err(|_| StaticError::Config(format!("invalid header name: {name}")))?;
627 let value = HeaderValue::from_str(value).map_err(|_| {
628 StaticError::Config(format!("invalid value for header {name}: {value}"))
629 })?;
630
631 if SERVER_COMPUTED_HEADERS.contains(&name) {
632 return Err(StaticError::Config(format!(
633 "{name} is computed per response and cannot be set as a fixed header"
634 )));
635 }
636
637 Arc::make_mut(&mut self.extra_headers).push((name, value));
638 Ok(self)
639 }
640
641 pub fn with_request_logging(self) -> Self {
642 self.with_request_logging_to(Box::new(std::io::stderr()))
643 }
644
645 /// Log one line per request to `writer`, plus connection-level errors.
646 ///
647 /// Each served request writes one line:
648 ///
649 /// ```text
650 /// GET /index.html 200 512 0.421ms
651 /// ```
652 ///
653 /// — method, requested path exactly as received, status, response body bytes (`-`
654 /// when the length isn't known, as on a live-reload SSE stream), and how long
655 /// handling took. Connection-level failures — a malformed request, a client
656 /// vanishing mid-response — write `connection error: <cause>`; before this they were
657 /// discarded entirely, so a server that was refusing every request looked exactly
658 /// like one nobody was talking to.
659 ///
660 /// The path is logged as received, *not* decoded: it is attacker-controlled input,
661 /// and a log reader deserves to see the bytes that actually arrived rather than a
662 /// normalized rendering of them.
663 ///
664 /// Writes are serialized across connections and write errors are ignored — a
665 /// failing log sink must not take down request serving.
666 ///
667 /// # Example
668 ///
669 /// ```no_run
670 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
671 /// use mini_static::Server;
672 /// use std::fs::File;
673 /// use std::path::Path;
674 ///
675 /// let log = File::create("access.log")?;
676 /// let server = Server::new(Path::new("./public"))?.with_request_logging_to(Box::new(log));
677 /// # Ok(())
678 /// # }
679 /// ```
680 pub fn with_request_logging_to(mut self, writer: Box<dyn Write + Send>) -> Self {
681 self.request_log = Some(Arc::new(Mutex::new(writer)));
682 self
683 }
684
685 /// Start a response carrying the baseline security header plus every header the
686 /// embedder configured via [`Server::with_response_header`].
687 ///
688 /// Every response this server builds for a request goes through here, so a
689 /// configured policy header cannot be missing from one status and present on
690 /// another. The sole exception is the `400` that `finish` falls back to when a
691 /// builder produced an invalid header — no `Server` is in scope there, and a
692 /// response that exists only because header construction already failed is the wrong
693 /// place to add more headers.
694 fn response(&self, status: StatusCode) -> Builder {
695 let mut builder = response(status);
696 for (name, value) in self.extra_headers.iter() {
697 builder = builder.header(name, value);
698 }
699 builder
700 }
701
702 /// Write `line` to the configured log sink, if there is one.
703 ///
704 /// A poisoned mutex (some earlier writer panicked mid-write) and a failed write are
705 /// both ignored: neither is a reason to fail a request that was otherwise served
706 /// correctly.
707 fn log(&self, line: std::fmt::Arguments<'_>) {
708 let Some(log) = &self.request_log else {
709 return;
710 };
711 if let Ok(mut sink) = log.lock() {
712 let _ = writeln!(sink, "{line}");
713 let _ = sink.flush();
714 }
715 }
716
717 /// Serve files matching `predicate` with a long-lived, immutable cache policy
718 /// instead of the default `Cache-Control: no-cache`.
719 ///
720 /// `predicate` is evaluated against each resolved file's path; a match sends
721 /// `Cache-Control: public, max-age=31536000, immutable` on that file's 200 and 304
722 /// responses. This is correct only for fingerprinted assets (e.g.
723 /// `main.a1b2c3.js`) where a content change always produces a new filename —
724 /// caching a mutable filename indefinitely would serve stale content to every
725 /// client that already has it cached.
726 ///
727 /// # Example
728 ///
729 /// ```no_run
730 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
731 /// use mini_static::Server;
732 /// use std::path::Path;
733 ///
734 /// let server = Server::new(Path::new("./public"))?
735 /// .with_immutable_assets(|path| {
736 /// path.file_name()
737 /// .and_then(|name| name.to_str())
738 /// .is_some_and(|name| name.contains(".fingerprint."))
739 /// });
740 /// # Ok(())
741 /// # }
742 /// ```
743 pub fn with_immutable_assets<F>(mut self, predicate: F) -> Self
744 where
745 F: Fn(&Path) -> bool + Send + Sync + 'static,
746 {
747 self.immutable_predicate = Some(Arc::new(predicate));
748 self
749 }
750
751 /// The `Cache-Control` header value for a resolved file path: the immutable policy
752 /// if `with_immutable_assets`'s predicate matches, `no-cache` otherwise.
753 fn cache_control_for(&self, path: &Path) -> &'static str {
754 match &self.immutable_predicate {
755 Some(predicate) if predicate(path) => "public, max-age=31536000, immutable",
756 _ => "no-cache",
757 }
758 }
759
760 /// The directories the live-reload watcher polls: the served root, and only that.
761 ///
762 /// Until 0.29.0 this also returned the build pipeline's source folders, and the
763 /// served root was excluded whenever a pipeline was configured — watching the output
764 /// dir would have fed each pipeline its own writes back into its own trigger. The
765 /// pipeline now lives in `mini-build`, in a separate process, so nothing this server
766 /// watches is written by this server and the exclusion has nothing left to prevent.
767 fn watch_targets(&self) -> Vec<PathBuf> {
768 vec![self.root_canon.clone()]
769 }
770
771 /// Resolve a request path under the server's root.
772 ///
773 /// This is a lower-level API for resolving paths without generating HTTP responses.
774 /// For most use cases, prefer [`Server::handle_request`] or the `run*` methods.
775 ///
776 /// # Returns
777 ///
778 /// - `Ok(PathBuf)` if the path resolves to a file within root.
779 /// - `Err(StaticError)` if the path is invalid, missing, or attempts traversal.
780 pub fn resolve(&self, request_path: &str) -> Result<PathBuf, StaticError> {
781 resolve::resolve_with_policy(&self.root_canon, request_path, self.hidden_files)
782 }
783
784 /// Builds the `404` response: the configured page when there is one and it can be
785 /// read, and `fallback` as plain text otherwise.
786 ///
787 /// `fallback` is the caller's already-sanitized message (see
788 /// `StaticError::user_message`) — never the requested path, so an ordinary miss and
789 /// a rejected traversal stay indistinguishable to whoever is probing.
790 ///
791 /// A page that vanished after `with_not_found_page` validated it degrades to that
792 /// text rather than to a `500`: the request was still a miss, and answering a
793 /// missing page with the wrong status would be a second bug wearing the first one's
794 /// clothes.
795 async fn not_found_response(&self, fallback: &'static str) -> Response<ResponseBody> {
796 let builder = self
797 .response(StatusCode::NOT_FOUND)
798 .header("Cache-Control", "no-store");
799
800 let Some(page) = &self.not_found_page else {
801 return text(builder, format!("{fallback}\n"));
802 };
803 let Ok(body) = tokio::fs::read(page).await else {
804 return text(builder, format!("{fallback}\n"));
805 };
806
807 text(
808 builder.header("Content-Type", "text/html; charset=utf-8"),
809 body,
810 )
811 }
812
813 /// This root as a `mini-serve` fallback handler.
814 ///
815 /// The composed deployment: register API routes, then hand everything they do not
816 /// match to the files.
817 ///
818 /// ```no_run
819 /// # fn example(files: mini_static::Server, api: mini_serve::Handler<()>) -> mini_serve::App<()> {
820 /// mini_serve::RouteBuilder::stateless()
821 /// .get("/api/users", api)
822 /// .with_fallback(files.into_fallback())
823 /// .seal()
824 /// # }
825 /// ```
826 ///
827 /// This is what `mini-unified` existed to provide. That crate had to wrap this one's
828 /// handler for `mini-serve` because this crate shipped a whole server, when the
829 /// composed case only ever wanted the handler out of it.
830 pub fn into_fallback<S: Send + Sync + 'static>(self) -> mini_serve::Handler<S> {
831 // The composed path never calls `run_on`, so this is where the cache/live-reload
832 // contradiction has to be caught for it. Returning a `Handler` leaves no way to report
833 // an error, so every request fails loudly instead: a `500` naming the misconfiguration
834 // is a bug found in the first minute of testing, where serving stale content while a
835 // watcher announces changes is a bug found in production, by a reader, weeks later.
836 if let Some(conflict) = self.cache_conflict() {
837 let message = conflict.to_string();
838 self.log(format_args!("refusing to serve: {message}"));
839 return mini_serve::handler(move |_req, _state| {
840 let message = message.clone();
841 async move { Err(mini_serve::ServeError::new(500, message)) }
842 });
843 }
844 let server = Arc::new(self);
845 mini_serve::handler(move |req, _state| {
846 let server = Arc::clone(&server);
847 async move {
848 // The router already split and decoded this path; taking its answer is
849 // the point. `unwrap_or_default` covers a caller who wired the handler
850 // up without the seam — an empty segment list resolves to the root's
851 // index, which is the same answer a bare `/` gets.
852 // Taken, not cloned. Cloning cost a `Vec<String>` and one allocation
853 // per segment on every request; nothing downstream reads the extension
854 // again, so moving it out is free.
855 let mut req = req;
856 let segments = req
857 .extensions_mut()
858 .remove::<mini_serve::PathSegments>()
859 .map(|s| s.0)
860 .unwrap_or_default();
861 // Logged here rather than in the connection layer, which this crate no
862 // longer owns. The format is unchanged — `mini-serve`'s own line omits
863 // the byte count, and changing either crate's format to unify them is
864 // a user-visible change worth making on its own, not inside a
865 // migration.
866 let started = Instant::now();
867 let method = req.method().clone();
868 let path = req.uri().path().to_string();
869
870 let resp = server.respond(&req, &segments).await;
871
872 let bytes = header_str(resp.headers(), "content-length").unwrap_or("-");
873 server.log(format_args!(
874 "{method} {path} {} {bytes} {:.3}ms",
875 resp.status().as_u16(),
876 started.elapsed().as_secs_f64() * 1000.0,
877 ));
878 Ok(bridge_body(resp))
879 }
880 })
881 }
882
883 /// Build the `mini-serve` app that serves this root, and nothing else.
884 ///
885 /// The whole crate as one fallback: with no routes registered, every request is a file
886 /// request. The same app with routes in front is the composed deployment, which is what
887 /// [`Server::into_fallback`] is for.
888 fn into_app(self, header_timeout: Duration) -> mini_serve::App<()> {
889 let max_connections = self.max_connections;
890 mini_serve::RouteBuilder::stateless()
891 .with_header_read_timeout(header_timeout)
892 .with_max_connections(max_connections)
893 // This crate's own 64 KiB ceiling, passed through rather than dropped. It
894 // predates mini-serve having one at all — the migration is what surfaced that.
895 .with_max_header_bytes(MAX_HEADER_BYTES)
896 .with_fallback(self.into_fallback())
897 .seal()
898 }
899
900 /// Run the server on a specific address with a configurable header-read timeout.
901 ///
902 /// Spawns the server in a background Tokio task and returns immediately with the
903 /// assigned port number and a [`ServerHandle`]. Call `handle.shutdown().await` to
904 /// stop accepting new connections and wait for in-flight connections to finish.
905 /// Dropping the handle instead leaves the server running for the life of the process.
906 ///
907 /// # Header-Read Timeout
908 ///
909 /// Connections that don't send complete HTTP headers within `header_timeout` are closed.
910 /// This prevents slowloris attacks and resource exhaustion from incomplete requests. The
911 /// timeout applies only to the header-read phase — once a complete header block has been
912 /// read, the connection is handed off with no further time bound, so long-lived response
913 /// bodies (e.g. the live-reload SSE stream from [`Server::with_live_reload`]) are not cut
914 /// off mid-stream.
915 ///
916 /// # Precompressed Sidecars
917 ///
918 /// If a request's `Accept-Encoding` allows `br` or `gzip` (preferring `br`) and a
919 /// sibling `<path>.br`/`<path>.gz` exists next to the resolved file, its bytes are
920 /// served instead with a matching `Content-Encoding`. Every file response carries
921 /// `Vary: Accept-Encoding` so intermediate caches don't serve the wrong variant to a
922 /// differently-capable client.
923 ///
924 /// # Arguments
925 ///
926 /// * `addr` - Socket address to bind to (e.g., `127.0.0.1:0` for loopback ephemeral,
927 /// or `0.0.0.0:8080` to bind all interfaces on a fixed port).
928 /// * `header_timeout` - Maximum time to wait for complete HTTP headers on each connection.
929 ///
930 /// # Returns
931 ///
932 /// - `Ok((u16, ServerHandle))` with the assigned port number and a handle for graceful shutdown.
933 /// - `Err(StaticError::Io)` if binding to the socket fails. This is the only error
934 /// this function returns.
935 pub async fn run_on(
936 &self,
937 addr: SocketAddr,
938 header_timeout: Duration,
939 ) -> Result<(u16, ServerHandle), StaticError> {
940 if let Some(conflict) = self.cache_conflict() {
941 return Err(conflict);
942 }
943 let listener = TcpListener::bind(addr).await.map_err(StaticError::Io)?;
944 let port = listener.local_addr().map_err(StaticError::Io)?.port();
945
946 let mut server = self.clone();
947 if server.live_reload {
948 // The served root is the only watch target now that nothing writes into it.
949 // While the build pipeline lived here the output dir was deliberately never
950 // watched, because watching it fed each pipeline its own writes back into its
951 // trigger; with the builder in a separate process that loop cannot happen.
952 let broadcaster = Broadcaster::new();
953 for dir in server.watch_targets() {
954 start_watching(Arc::new(dir), broadcaster.clone());
955 }
956 server.broadcaster = Some(broadcaster);
957 }
958
959 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
960 let app = server.into_app(header_timeout);
961 let accept_task = tokio::spawn(async move {
962 // `mini-serve` owns the accept loop, the connection ceiling, the header-read
963 // timeout and the bounded drain — all of them mutation-verified there. This
964 // crate used to carry a second implementation of each; keeping two was how the
965 // two came to disagree about what a path segment is.
966 let _ = app
967 .run(listener, async move {
968 let _ = shutdown_rx.await;
969 })
970 .await;
971 });
972
973 Ok((
974 port,
975 ServerHandle {
976 shutdown_tx: Some(shutdown_tx),
977 accept_task,
978 },
979 ))
980 }
981
982 /// Run the server on loopback (127.0.0.1), binding an ephemeral port.
983 ///
984 /// Thin wrapper around [`Server::run_on`] — see it for the header-read timeout and
985 /// sidecar semantics, and for what the returned [`ServerHandle`] does.
986 pub async fn run(&self, header_timeout: Duration) -> Result<(u16, ServerHandle), StaticError> {
987 self.run_on((EPHEMERAL_BIND_IP, 0).into(), header_timeout).await
988 }
989
990 /// Run the server on all interfaces (0.0.0.0) at `port` (0 for an ephemeral port).
991 ///
992 /// Useful for containerized deployments and reverse-proxy setups. Thin wrapper
993 /// around [`Server::run_on`] — see it for the header-read timeout and sidecar
994 /// semantics, and for what the returned [`ServerHandle`] does.
995 pub async fn run_all(
996 &self,
997 port: u16,
998 header_timeout: Duration,
999 ) -> Result<(u16, ServerHandle), StaticError> {
1000 self.run_on(([0, 0, 0, 0], port).into(), header_timeout)
1001 .await
1002 }
1003
1004 /// Run the server on loopback with the default 30-second header-read timeout.
1005 ///
1006 /// The recommended entry point for tests and lightweight services that don't need a
1007 /// custom timeout. Thin wrapper around [`Server::run`].
1008 ///
1009 /// # Example
1010 ///
1011 /// ```no_run
1012 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1013 /// use mini_static::Server;
1014 /// use std::path::Path;
1015 ///
1016 /// let server = Server::new(Path::new("./public"))?;
1017 /// let (port, handle) = server.run_ephemeral().await?;
1018 /// println!("Server ready on http://127.0.0.1:{}", port);
1019 /// handle.shutdown().await;
1020 /// # Ok(())
1021 /// # }
1022 /// ```
1023 pub async fn run_ephemeral(&self) -> Result<(u16, ServerHandle), StaticError> {
1024 self.run(DEFAULT_HEADER_TIMEOUT).await
1025 }
1026
1027 /// Produce the HTTP response for a request, streaming file bodies to the client.
1028 ///
1029 /// This is the crate's single request-handling path: the `run*` accept loop calls it,
1030 /// and so should any async server embedding `mini-static` as a fallback route (e.g.
1031 /// `mini-unified`).
1032 ///
1033 /// Filesystem metadata work (path resolution, `open`, `stat`) runs *inline* on the
1034 /// calling task, deliberately. Until 0.30.0 it was dispatched to Tokio's blocking
1035 /// pool so a slow filesystem could not stall co-scheduled tasks — measured under
1036 /// load, that dispatch cost roughly three times the syscalls it sheltered, and a
1037 /// one-worker server burned nearly four cores on pool handoff. On the local-disk
1038 /// deployments this crate targets these calls are single-digit microseconds; an
1039 /// embedder serving from a filesystem with unbounded latency (a network mount)
1040 /// should use a multi-threaded runtime, which bounds the blast radius of a stall
1041 /// to one worker.
1042 ///
1043 /// File responses are backed by `FileBody`, which hands hyper one 64 KB chunk at a
1044 /// time as `poll_frame` is driven: memory use stays bounded to one chunk per in-flight
1045 /// response regardless of file size.
1046 ///
1047 /// `headers` are the request's headers; `If-None-Match` (304 on a matching ETag) and
1048 /// `Accept-Encoding` (precompressed sidecar selection, see [`Server::run_on`]) are the
1049 /// ones read today. Only `GET` and `HEAD` are allowed; anything else gets a 405 with an
1050 /// `Allow` header. Missing files and traversal attempts both get an identical 404, so a
1051 /// response never discloses whether a path exists outside the root.
1052 pub async fn handle_request(
1053 &self,
1054 method: &Method,
1055 request_path: &str,
1056 headers: &HeaderMap,
1057 ) -> Response<ResponseBody> {
1058 self.serve(method, request_path, headers, None::<&[String]>).await
1059 }
1060
1061 /// Serve a request whose path a router has already split and decoded.
1062 ///
1063 /// The engine, without a server around it. Every type here belongs to `http`/`hyper`,
1064 /// so this drops into any stack that speaks them — it is not a `mini-serve` adapter.
1065 ///
1066 /// `segments` decide **which file is opened**; the request's raw path is used only to
1067 /// echo back into a `Location` redirect, and never to resolve anything. That division
1068 /// is the point: a redirect must preserve the client's own encoding (`/my%20docs` →
1069 /// `/my%20docs/`, since re-encoding is not a safe round-trip — `%41` would return as
1070 /// `A`), while resolution must use exactly the segments the router matched on. Two
1071 /// crates deriving path segments independently is what let `/admin%2Fconfig` reach a
1072 /// nested file while the router in front saw one segment and matched no route.
1073 ///
1074 /// Segments are still checked before they touch the filesystem. Where they came from
1075 /// is the caller's business; whether they can escape the root is this crate's.
1076 pub async fn respond<B>(
1077 &self,
1078 req: &Request<B>,
1079 segments: &[String],
1080 ) -> Response<ResponseBody> {
1081 self.serve(req.method(), req.uri().path(), req.headers(), Some(segments))
1082 .await
1083 }
1084
1085 /// One implementation behind both entry points. `segments` is `None` when this crate
1086 /// owns the path and `Some` when a router already decided it.
1087 async fn serve<S: AsRef<str>>(
1088 &self,
1089 method: &Method,
1090 request_path: &str,
1091 headers: &HeaderMap,
1092 segments: Option<&[S]>,
1093 ) -> Response<ResponseBody> {
1094 if method != Method::GET && method != Method::HEAD {
1095 return text(
1096 self.response(StatusCode::METHOD_NOT_ALLOWED)
1097 .header("Allow", "GET, HEAD"),
1098 "method not allowed\n",
1099 );
1100 }
1101
1102 // Live-reload SSE stream — only reachable when `with_live_reload()` was called
1103 // and the server was started via a `run*` method (those are the only paths that
1104 // populate `broadcaster`).
1105 if *method == Method::GET && request_path == reload::LIVE_RELOAD_PATH {
1106 if let Some(broadcaster) = &self.broadcaster {
1107 return finish(
1108 self.response(StatusCode::OK)
1109 .header("Content-Type", "text/event-stream")
1110 .header("Cache-Control", "no-cache")
1111 .header("Connection", "keep-alive")
1112 .body(ResponseBody::Sse(SseBody::new(broadcaster.subscribe()))),
1113 );
1114 }
1115 }
1116
1117 // Inline on purpose — see this function's doc comment for the measured case
1118 // against the old `spawn_blocking` dispatch. One call opens the file and proves
1119 // containment on the opened fd, so there is no separate open to fail later and
1120 // no window between the check and the handle that gets served.
1121 // The cache is consulted before the open, because avoiding the open — and the `fstat`
1122 // behind it — is the entire reason the cache exists. A miss falls through to exactly
1123 // the resolution that has always run, including its refusals.
1124 let cached = self.cached_entry(segments, request_path);
1125 let (source, metadata, path, cached_key) = match cached {
1126 Some((relative, entry)) => (
1127 BodySource::Memory(entry.bytes.clone()),
1128 entry.metadata.clone(),
1129 self.root_canon.join(&relative),
1130 Some(relative),
1131 ),
1132 None => {
1133 let opened = match segments {
1134 Some(segments) => resolve::open_segments(
1135 &self.root_canon,
1136 segments,
1137 request_path,
1138 self.hidden_files,
1139 ),
1140 None => {
1141 resolve::open_with_policy(&self.root_canon, request_path, self.hidden_files)
1142 }
1143 };
1144 let resolved = match opened {
1145 Err(e) => return self.not_found_response(e.user_message()).await,
1146 Ok(resolved) => resolved,
1147 };
1148 (
1149 BodySource::Descriptor(resolved.file),
1150 resolved.metadata,
1151 resolved.path,
1152 None,
1153 )
1154 }
1155 };
1156
1157 // A directory served via its `index.html` needs a trailing slash to establish the
1158 // correct base for the page's relative links. Compare against the *decoded*
1159 // request path so a percent-encoded explicit request for index.html (e.g.
1160 // `/docs/index.htm%6c`) is recognized as such instead of producing a redirect to a
1161 // still-encoded, broken Location.
1162 // Compared segment-wise through the same decoder resolution used, so a
1163 // percent-encoded explicit request for index.html (e.g. `/docs/index.htm%6c`) is
1164 // recognised as such instead of redirecting to a still-encoded, broken Location.
1165 // A trailing slash is read from the raw path: `%2F` is no longer a separator, so
1166 // a real trailing slash is the only thing that can produce one.
1167 let last_segment = resolve::decode_segments(request_path)
1168 .ok()
1169 .and_then(|segments| segments.last().cloned())
1170 .unwrap_or_default();
1171 if path.file_name().is_some_and(|name| name == resolve::INDEX_FILE_NAME)
1172 && !request_path.ends_with('/')
1173 && last_segment != resolve::INDEX_FILE_NAME
1174 {
1175 // `location` is built from the (attacker-controlled) request path; `finish()`
1176 // degrades to 400 instead of panicking if it ever contains bytes invalid in a
1177 // header value.
1178 let location = format!("{}/", request_path.trim_end_matches('/'));
1179 return text(
1180 self.response(StatusCode::MOVED_PERMANENTLY)
1181 .header("Location", location),
1182 "moved\n",
1183 );
1184 }
1185
1186 let content_type = mime_type_for_path(&path);
1187 // Live-reload and spa-mode HTML injection both need the original, uncompressed
1188 // bytes to splice their script into — never substitute a precompressed sidecar on
1189 // this path. `broadcaster` is only `Some` when live-reload is enabled (see
1190 // `Server::with_live_reload`); `spa_mode` is independent of it (see
1191 // `Server::with_spa_mode`/`with_spa_root`) — either alone is enough to trigger
1192 // injection.
1193 // Injection reads the whole file into memory, so it is also gated on size. Every
1194 // decision keyed off `html_injection` — the sidecar skip below, the range skip,
1195 // the full read itself — inherits the cap from this one boolean, so an over-cap
1196 // page takes the ordinary streamed path with no second decision point.
1197 let wants_injection =
1198 (self.broadcaster.is_some() || self.spa_mode) && content_type.starts_with("text/html");
1199 let html_injection = wants_injection && metadata.len() <= MAX_INJECTABLE_HTML_BYTES;
1200
1201 if wants_injection && !html_injection {
1202 self.log(format_args!(
1203 "html injection skipped for {request_path}: {} bytes exceeds the \
1204 {MAX_INJECTABLE_HTML_BYTES}-byte limit; serving unmodified",
1205 metadata.len(),
1206 ));
1207 }
1208
1209 let range_header = header_str(headers, "range");
1210 let if_range_header = header_str(headers, "if-range");
1211
1212 let accept_encoding = header_str(headers, "accept-encoding");
1213 // Skip precompressed sidecars when Range is requested (serve original file instead).
1214 let wants_sidecar = self.precompressed && !html_injection && range_header.is_none();
1215
1216 // A cached body looks for a cached variant, so a cached root spends no `open()` on
1217 // content negotiation at all — where the disk path spends up to two per request, on
1218 // files that usually do not exist. The disk probe is reached only when the body itself
1219 // came from disk.
1220 let cached_variant = match (wants_sidecar, &cached_key) {
1221 (true, Some(relative)) => self.cached_sidecar(relative, accept_encoding),
1222 _ => None,
1223 };
1224 let (source, metadata, content_encoding) = match cached_variant {
1225 Some((entry, encoding)) => (
1226 BodySource::Memory(entry.bytes.clone()),
1227 entry.metadata.clone(),
1228 Some(encoding),
1229 ),
1230 // Reached when the body came from disk, *and* when it came from memory but no
1231 // cached variant was found — budget truncation can hold `app.css` without holding
1232 // `app.css.br`, and a cached hit must still find that variant on disk or it would
1233 // serve an unencoded body where the disk path serves a compressed one. An earlier
1234 // draft guarded this with `cached_key.is_none()` and had exactly that divergence.
1235 None if wants_sidecar => {
1236 match select_precompressed_sidecar(&self.root_canon, &path, accept_encoding) {
1237 Some((sidecar_file, sidecar_metadata, encoding)) => (
1238 BodySource::Descriptor(sidecar_file),
1239 sidecar_metadata,
1240 Some(encoding),
1241 ),
1242 None => (source, metadata, None),
1243 }
1244 }
1245 None => (source, metadata, None),
1246 };
1247 // The handle stays synchronous until a body actually streams: every whole-file
1248 // read below (HTML injection, small bodies) is cheaper inline than as a
1249 // blocking-pool round trip, and only `FileBody` needs an async `File`.
1250
1251 // HTML injection is skipped for a served precompressed sidecar (already final
1252 // bytes from a build step) — see `html_injection`'s definition above.
1253 let etag = generate_etag(&metadata);
1254 let cache_control = self.cache_control_for(&path);
1255
1256 if header_str(headers, "if-none-match").is_some_and(|value| is_etag_match(value, &etag)) {
1257 return finish(
1258 self.response(StatusCode::NOT_MODIFIED)
1259 .header("Cache-Control", cache_control)
1260 .header("Vary", "Accept-Encoding")
1261 .header("ETag", etag)
1262 .header("Accept-Ranges", "bytes")
1263 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1264 );
1265 }
1266
1267 // Built before the HEAD check below because RFC 9110 requires a HEAD response's
1268 // headers — `Content-Length` included — to match what a GET would send, even though
1269 // the body itself is dropped. Built once, so the descriptor is moved into exactly one
1270 // branch and there is no state where the source is both in memory and on disk.
1271 let source = if html_injection {
1272 use std::io::Read as _;
1273 let mut html = match source {
1274 // A cached page is injected from memory rather than re-read: the bytes are the
1275 // same bytes, so the served result is identical and the open is still avoided.
1276 BodySource::Memory(bytes) => bytes.to_vec(),
1277 BodySource::Descriptor(mut file) => {
1278 let mut buffer = Vec::with_capacity(metadata.len() as usize);
1279 if file.read_to_end(&mut buffer).is_err() {
1280 return internal_error_response();
1281 }
1282 buffer
1283 }
1284 };
1285 if self.broadcaster.is_some() {
1286 reload::inject_reload_script(&mut html);
1287 }
1288 if self.spa_mode {
1289 spa::inject_spa_script(&mut html, self.spa_root.as_deref(), &self.spa_transition);
1290 }
1291 BodySource::Memory(Bytes::from(html))
1292 } else {
1293 source
1294 };
1295
1296 let file_size = match &source {
1297 BodySource::Memory(bytes) => bytes.len() as u64,
1298 BodySource::Descriptor(_) => metadata.len(),
1299 };
1300
1301 // Handle Range requests.
1302 let range_outcome = range_header.map(|h| parse_range_header(h, file_size));
1303 let range_check = if let Some(outcome) = &range_outcome {
1304 match outcome {
1305 RangeOutcome::Satisfiable(start, end) => {
1306 // If-Range validation: stale If-Range ignores Range, serves full 200.
1307 if let Some(if_range) = if_range_header {
1308 if !if_range_valid(if_range, &etag) {
1309 RangeCheck::IgnoreRange
1310 } else {
1311 RangeCheck::Satisfiable(*start, *end)
1312 }
1313 } else {
1314 RangeCheck::Satisfiable(*start, *end)
1315 }
1316 }
1317 RangeOutcome::MultiRangeIgnored => RangeCheck::IgnoreRange,
1318 RangeOutcome::Unsatisfiable => RangeCheck::Unsatisfiable,
1319 RangeOutcome::NoRange => RangeCheck::IgnoreRange,
1320 }
1321 } else {
1322 RangeCheck::IgnoreRange
1323 };
1324
1325 match &range_check {
1326 RangeCheck::Unsatisfiable => {
1327 return finish(
1328 Response::builder()
1329 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1330 .header("Content-Range", format!("bytes */{}", file_size))
1331 .header("Accept-Ranges", "bytes")
1332 .body(ResponseBody::Buffered(Full::new(Bytes::new()))),
1333 );
1334 }
1335 RangeCheck::Satisfiable(start, end) => {
1336 let range_len = end - start + 1;
1337
1338 // HEAD must not return a body (RFC 9110).
1339 let body = if *method == Method::HEAD {
1340 ResponseBody::Buffered(Full::new(Bytes::new()))
1341 } else {
1342 match source {
1343 BodySource::Memory(bytes) => ResponseBody::Buffered(Full::new(
1344 bytes.slice(*start as usize..(*end as usize + 1)),
1345 )),
1346 // The seek lives here rather than behind a guard above: only a
1347 // descriptor can be sought, and now only the descriptor arm reaches it.
1348 BodySource::Descriptor(mut file) => {
1349 if std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(*start))
1350 .is_err()
1351 {
1352 return internal_error_response();
1353 }
1354 ResponseBody::Streamed(FileBody::new_ranged(
1355 File::from_std(file),
1356 range_len,
1357 ))
1358 }
1359 }
1360 };
1361
1362 let mut builder = Response::builder()
1363 .status(StatusCode::PARTIAL_CONTENT)
1364 .header("Content-Type", content_type)
1365 .header("Content-Length", range_len.to_string())
1366 .header(
1367 "Content-Range",
1368 format!("bytes {}-{}/{}", start, end, file_size),
1369 )
1370 .header("Cache-Control", cache_control)
1371 .header("Vary", "Accept-Encoding")
1372 .header("ETag", etag)
1373 .header("Accept-Ranges", "bytes");
1374 if let Some(encoding) = content_encoding {
1375 builder = builder.header("Content-Encoding", encoding);
1376 }
1377 return finish(builder.body(body));
1378 }
1379 RangeCheck::IgnoreRange => {}
1380 }
1381
1382 // HEAD must not return a body (RFC 9110).
1383 let body = if *method == Method::HEAD {
1384 ResponseBody::Buffered(Full::new(Bytes::new()))
1385 } else {
1386 match source {
1387 BodySource::Memory(bytes) => ResponseBody::Buffered(Full::new(bytes)),
1388 BodySource::Descriptor(mut file) if metadata.len() <= INLINE_BODY_BYTES => {
1389 // From the handle opened above — never by re-opening the path — so
1390 // the bytes served are provably the file that was probed and
1391 // stat'd, sidecars included, with no reopen window in between.
1392 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1393 use std::io::Read as _;
1394 if file.read_to_end(&mut bytes).is_err() {
1395 return internal_error_response();
1396 }
1397 ResponseBody::Buffered(Full::new(Bytes::from(bytes)))
1398 }
1399 BodySource::Descriptor(file) => {
1400 ResponseBody::Streamed(FileBody::new(File::from_std(file)))
1401 }
1402 }
1403 };
1404
1405 let mut builder = self
1406 .response(StatusCode::OK)
1407 .header("Content-Type", content_type)
1408 .header("Content-Length", file_size.to_string())
1409 .header("Cache-Control", cache_control)
1410 .header("Vary", "Accept-Encoding")
1411 .header("ETag", etag)
1412 .header("Accept-Ranges", "bytes");
1413 if let Some(encoding) = content_encoding {
1414 builder = builder.header("Content-Encoding", encoding);
1415 }
1416 finish(builder.body(body))
1417 }
1418}
1419
1420/// Ceiling on how many bytes hyper buffers for a single request's header block before
1421/// rejecting it. Without this, a client that trickles bytes forever without ever sending
1422/// the terminating blank line could grow the buffer without limit — the header-read
1423/// timeout alone doesn't bound memory, only wall-clock time, and a sufficiently patient
1424/// sender could still send unbounded data before the deadline fires.
1425const MAX_HEADER_BYTES: usize = 64 * 1024;
1426
1427/// Ceiling on the size of an HTML file this server will buffer in memory to splice a
1428/// live-reload or spa-mode `<script>` into.
1429///
1430/// Injection is the one code path that reads a whole file into memory rather than
1431/// streaming it in bounded chunks, and it does so *per request* — so without a cap, a
1432/// single large HTML file turns every concurrent request for it into another full copy
1433/// in memory, and spa-mode is a production feature, not a development-only one. An
1434/// over-cap page is served unmodified (and streamed) instead: losing a client-side
1435/// navigation enhancement on an 8 MiB document is a far smaller failure than an
1436/// allocation proportional to file size times concurrency.
1437///
1438/// 8 MiB is comfortably above any hand-written HTML page and any realistic
1439/// static-site-generator output, so the cap should never fire on content this feature
1440/// was designed for.
1441const MAX_INJECTABLE_HTML_BYTES: u64 = 8 * 1024 * 1024;
1442
1443/// Bodies at or below this size are read synchronously and served from memory; larger
1444/// ones stream through `FileBody`. Equal to `FileBody`'s chunk size on purpose: at or
1445/// under one chunk the streaming path performed exactly one read anyway, so buffering
1446/// changes only *where* that read runs (inline, instead of a blocking-pool round trip
1447/// per chunk) — never how much memory a response can hold.
1448const INLINE_BODY_BYTES: u64 = 64 * 1024;
1449
1450/// The address [`Server::run`] and [`Server::run_ephemeral`] bind to.
1451///
1452/// Loopback, deliberately: a convenience entry point must not put a server on the LAN
1453/// because the caller did not think to say otherwise. Exposing the service is
1454/// [`Server::run_on`]'s job, where the address is written at the call site and visible in
1455/// review. Named rather than inlined so a test can assert the choice — the previous test
1456/// only checked that loopback *reached* the server, which is equally true of `0.0.0.0`.
1457const EPHEMERAL_BIND_IP: std::net::Ipv4Addr = std::net::Ipv4Addr::LOCALHOST;
1458
1459/// Bridge this crate's response body to `mini-serve`'s.
1460///
1461/// `ResponseBody` stays a concrete enum so the streaming paths keep their own types; this
1462/// is the single place it is type-erased. The error remap matters as much as the erasure:
1463/// a mid-stream disk failure must abort the connection rather than being dropped, which
1464/// would send a truncated body under a `200`.
1465fn bridge_body(response: Response<ResponseBody>) -> Response<mini_serve::ResponseBody> {
1466 let (parts, body) = response.into_parts();
1467 let erased = http_body_util::BodyExt::map_err(body, mini_serve::BodyError::new);
1468 Response::from_parts(parts, http_body_util::BodyExt::boxed(erased))
1469}
1470
1471/// Default header-read timeout used by [`Server::run_ephemeral`].
1472const DEFAULT_HEADER_TIMEOUT: Duration = Duration::from_secs(30);
1473
1474/// Default grace period `ServerHandle::shutdown()` waits for in-flight connections to
1475/// finish on their own before aborting whatever is left. A connection with no
1476/// self-imposed end — an open live-reload SSE stream, or any keep-alive connection whose
1477/// peer simply never closes it — would otherwise let `shutdown()` hang forever waiting
1478/// for it to finish naturally. Every wait in this crate has a stated upper bound;
1479/// shutdown is no exception.
1480const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
1481
1482/// A handle to a server started by one of the `Server::run*` methods.
1483///
1484/// Dropping this handle without calling `shutdown()` leaves the server running in the
1485/// background for the life of the process. Call `shutdown()` to stop accepting new
1486/// connections and wait for already-accepted connections to finish before returning.
1487pub struct ServerHandle {
1488 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
1489 accept_task: tokio::task::JoinHandle<()>,
1490}
1491
1492impl ServerHandle {
1493 /// Stop accepting new connections and wait up to `DEFAULT_SHUTDOWN_DRAIN_TIMEOUT`
1494 /// (5s) for in-flight connections to finish on their own. Equivalent to
1495 /// `shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)` — see that method for what
1496 /// happens to connections still open once the grace period elapses.
1497 pub async fn shutdown(self) {
1498 self.shutdown_with_timeout(DEFAULT_SHUTDOWN_DRAIN_TIMEOUT)
1499 .await;
1500 }
1501
1502 /// Stop accepting new connections and wait up to `drain_timeout` for in-flight
1503 /// connections to finish on their own.
1504 ///
1505 /// Connections still open once `drain_timeout` elapses are aborted rather than
1506 /// waited on further: dropping the accept task drops its `JoinSet`, which aborts
1507 /// every task still tracked in it (see `tokio::task::JoinSet`'s own drop behavior) —
1508 /// which in turn drops each connection's socket, closing it. This is what bounds
1509 /// shutdown when a connection has no natural end of its own (the live-reload SSE
1510 /// stream is the motivating case: it stays open until a watched file changes, which
1511 /// may never happen before the process needs to exit).
1512 pub async fn shutdown_with_timeout(mut self, drain_timeout: Duration) {
1513 if let Some(tx) = self.shutdown_tx.take() {
1514 let _ = tx.send(());
1515 }
1516 if timeout(drain_timeout, &mut self.accept_task).await.is_err() {
1517 self.accept_task.abort();
1518 }
1519 }
1520}
1521
1522/// Read a request header as a `&str`, or `None` if it's absent or not valid ASCII.
1523fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
1524 headers.get(name).and_then(|value| value.to_str().ok())
1525}
1526
1527/// Start a response carrying the baseline security header every response in this crate
1528/// sends — 304s included. A 304 otherwise repeats only the caching validators, which is
1529/// why it once built its own builder and was the single response able to arrive without
1530/// `nosniff`; a client that caches the header set alongside the representation would
1531/// then hold a copy missing it.
1532///
1533/// Prefer [`Server::response`], which also applies the embedder's configured headers.
1534/// This bare form exists for `bad_request_response`, which is reachable from `finish`
1535/// where no `Server` is in scope.
1536fn response(status: StatusCode) -> Builder {
1537 Response::builder()
1538 .status(status)
1539 .header("X-Content-Type-Options", "nosniff")
1540}
1541
1542/// Finish `builder` with a plain-text body. `&'static str` bodies borrow rather than
1543/// allocate; owned bodies are moved in.
1544fn text(builder: Builder, body: impl Into<Bytes>) -> Response<ResponseBody> {
1545 finish(builder.body(ResponseBody::Buffered(Full::new(body.into()))))
1546}
1547
1548/// Finishes building a response, degrading to a generic 400 instead of panicking if any
1549/// header value turns out to be invalid for use as an HTTP header value.
1550///
1551/// Every header value that reaches `Response::builder()` in this module is either a
1552/// static string or formatted from internal, already-validated data (a byte count, an
1553/// mtime, a fixed method list) — none of it can actually fail today. But `.unwrap()`
1554/// on that assumption is exactly the kind of thing that turns "can't happen" into a
1555/// production panic the day someone adds a header built from new input without
1556/// re-deriving that guarantee. Routing every response through this one fallible path
1557/// means that mistake fails safe instead of panicking.
1558fn finish(built: Result<Response<ResponseBody>, hyper::http::Error>) -> Response<ResponseBody> {
1559 built.unwrap_or_else(|_| bad_request_response())
1560}
1561
1562// `internal_error_response()` and `bad_request_response()` are the fallback responses
1563// `finish()` itself degrades to — every header and body here is a fixed string with no
1564// external input, so `.body(...)` cannot fail. They can't be routed through `finish()`
1565// without it degrading to itself on failure.
1566fn internal_error_response() -> Response<ResponseBody> {
1567 response(StatusCode::INTERNAL_SERVER_ERROR)
1568 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1569 b"internal server error\n",
1570 ))))
1571 .unwrap()
1572}
1573
1574fn bad_request_response() -> Response<ResponseBody> {
1575 response(StatusCode::BAD_REQUEST)
1576 .body(ResponseBody::Buffered(Full::new(Bytes::from_static(
1577 b"bad request\n",
1578 ))))
1579 .unwrap()
1580}
1581
1582/// `Content-Encoding` name and sidecar file extension for each supported precompressed
1583/// variant, in preference order — brotli wins when a client accepts both and both
1584/// sidecars exist.
1585const SIDECAR_ENCODINGS: [(&str, &str); 2] = [("br", ".br"), ("gzip", ".gz")];
1586
1587/// `q`-values are carried in thousandths — RFC 9110 allows at most three decimal places
1588/// — so weights compare exactly as integers instead of through float equality.
1589const QVALUE_SCALE: f32 = 1000.0;
1590
1591/// An `Accept-Encoding` entry with no explicit `q` parameter has weight 1.
1592const DEFAULT_QVALUE: u16 = 1000;
1593
1594/// The weight `accept_encoding` gives `encoding`, or `None` if it does not list it.
1595///
1596/// Entries are matched as whole tokens, case-insensitively, per RFC 9110 — not by
1597/// substring. The substring form this replaces got two things wrong that a client can
1598/// trigger: `Accept-Encoding: gzip;q=0` selected gzip, because the header *contains*
1599/// "gzip" while explicitly refusing it, and a token like `brotli` matched `br`.
1600///
1601/// `*` is deliberately not honored: treating the wildcard as matching nothing can only
1602/// cost a bandwidth optimization, while treating it as matching everything risks sending
1603/// an encoding the client did not ask for. The conservative reading is the safe one when
1604/// the payoff is choosing between two static files.
1605fn encoding_quality(accept_encoding: &str, encoding: &str) -> Option<u16> {
1606 accept_encoding.split(',').find_map(|entry| {
1607 let mut parts = entry.split(';');
1608 if !parts.next()?.trim().eq_ignore_ascii_case(encoding) {
1609 return None;
1610 }
1611
1612 let quality = parts
1613 .find_map(|parameter| {
1614 let (key, value) = parameter.split_once('=')?;
1615 key.trim().eq_ignore_ascii_case("q").then(|| value.trim())
1616 })
1617 .and_then(|value| value.parse::<f32>().ok())
1618 .map(|value| (value.clamp(0.0, 1.0) * QVALUE_SCALE).round() as u16)
1619 .unwrap_or(DEFAULT_QVALUE);
1620
1621 Some(quality)
1622 })
1623}
1624
1625/// Look for a precompressed sidecar (`<path>.br` / `<path>.gz`) matching the client's
1626/// `Accept-Encoding`, and return its open file, metadata, and encoding name if found.
1627///
1628/// `path` must already be the fully resolved, canonicalized path `resolve()` produced.
1629/// The sidecar path is built by appending an extension to it — never by re-resolving a
1630/// modified request path — so this lookup can't become a second traversal surface: any
1631/// path this function reads is provably a sibling of a path `resolve()` already cleared.
1632/// The encodings a client will accept, best first, as `(encoding, file extension)`.
1633///
1634/// Highest `q` first; ties keep `SIDECAR_ENCODINGS` order (brotli over gzip) because the sort
1635/// is stable. Without this, `br;q=0.5, gzip` would serve brotli purely because it is listed
1636/// first here, ignoring the preference the client stated.
1637///
1638/// Extracted so that finding a sidecar on disk and finding one in the content cache share one
1639/// negotiation. Two copies of "which encoding does this client want" is the shape that let a
1640/// router and a file server disagree about `%2F`; content negotiation is no safer a place for it.
1641fn preferred_encodings(accept_encoding: Option<&str>) -> Vec<(&'static str, &'static str)> {
1642 let mut candidates: Vec<(&'static str, &'static str, u16)> = SIDECAR_ENCODINGS
1643 .iter()
1644 .filter_map(|(encoding, ext)| {
1645 let quality = accept_encoding.and_then(|header| encoding_quality(header, encoding))?;
1646 (quality > 0).then_some((*encoding, *ext, quality))
1647 })
1648 .collect();
1649 candidates.sort_by_key(|(_, _, quality)| std::cmp::Reverse(*quality));
1650 candidates
1651 .into_iter()
1652 .map(|(encoding, ext, _)| (encoding, ext))
1653 .collect()
1654}
1655
1656fn select_precompressed_sidecar(
1657 root_canon: &Path,
1658 path: &Path,
1659 accept_encoding: Option<&str>,
1660) -> Option<(std::fs::File, fs::Metadata, &'static str)> {
1661 for (encoding, ext) in preferred_encodings(accept_encoding) {
1662 let mut sidecar = path.as_os_str().to_os_string();
1663 sidecar.push(ext);
1664 let sidecar_path = PathBuf::from(sidecar);
1665
1666 // Containment is proven on the sidecar's **own** descriptor, by
1667 // `resolve::open_sidecar_verified`, and not inferred from `path` having been
1668 // verified. Inferring it is what this function did from 0.9.0 until the fix: the
1669 // constructed path sits beside an already-verified file, so the sidecar was opened
1670 // with a bare `File::open` and served. A symlink at that name escaped the root —
1671 // `GET /styles.css.br` returned 404 while `GET /styles.css` with
1672 // `Accept-Encoding: br` served the link's target. A `debug_assert_eq!` on parent
1673 // equality stood here and could not have caught it: it compared constructed paths,
1674 // not what the descriptor pointed at, and was compiled out of release builds
1675 // anyway.
1676 //
1677 // It stays an *open* rather than a cheaper `stat`: handing back an already-open,
1678 // already-verified file is what keeps there being no gap between probing the
1679 // sidecar and serving it. Browsers send `Accept-Encoding` on every request, so this
1680 // probe is the common path — as `tokio::fs` opens, two misses per request kept the
1681 // blocking pool hot for files that do not exist.
1682 if let Some(resolved) = resolve::open_sidecar_verified(root_canon, &sidecar_path) {
1683 return Some((resolved.file, resolved.metadata, encoding));
1684 }
1685 }
1686 None
1687}
1688
1689/// Generate an ETag for a file based on modification time and size.
1690///
1691/// Format: `"<size>-<mtime_secs>.<mtime_nanos>"`.
1692///
1693/// The sub-second component is what makes this crate's choice to serve an ETag *instead*
1694/// of `Last-Modified`/`If-Modified-Since` sound. That choice rests on an ETag being able
1695/// to distinguish representations a whole-second timestamp cannot — two writes inside the
1696/// same second — which a whole-second ETag plainly cannot do either: rewriting a file
1697/// within a second of its last write, without changing its length, reproduced the
1698/// previous ETag exactly and every revalidating client was told `304 Not Modified` while
1699/// holding stale bytes. Build pipelines that rewrite generated assets are the realistic
1700/// way to hit that, and this crate ships one.
1701///
1702/// A filesystem whose timestamps are only second-granular gives `subsec_nanos() == 0`
1703/// and the same behavior as before — no worse, and no false confidence beyond what the
1704/// filesystem actually provides.
1705fn generate_etag(metadata: &fs::Metadata) -> String {
1706 let mtime = metadata
1707 .modified()
1708 .ok()
1709 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1710 .unwrap_or_default();
1711 format!(
1712 "\"{}-{}.{}\"",
1713 metadata.len(),
1714 mtime.as_secs(),
1715 mtime.subsec_nanos()
1716 )
1717}
1718
1719/// Where a response body's bytes come from.
1720///
1721/// Replaces a `(Option<Bytes>, File)` pair whose invariant — exactly one of them is the real
1722/// source — was carried by convention and by a `transformed.is_none()` guard on the seek. As an
1723/// enum the invariant is the type: there is no state where both or neither is present, and the
1724/// seek cannot be reached without the descriptor it seeks.
1725///
1726/// `Memory` covers an injected HTML page today and a cached file from commit 5 of
1727/// `PLAN-cache.md`; nothing downstream needs to know which.
1728enum BodySource {
1729 Memory(Bytes),
1730 Descriptor(std::fs::File),
1731}
1732
1733/// Determine MIME type from file path extension.
1734fn mime_type_for_path(path: &Path) -> &'static str {
1735 let ext = path
1736 .extension()
1737 .and_then(|ext| ext.to_str())
1738 .unwrap_or_default()
1739 .to_lowercase();
1740
1741 match ext.as_str() {
1742 "html" | "htm" => "text/html; charset=utf-8",
1743 "css" => "text/css; charset=utf-8",
1744 "js" => "application/javascript; charset=utf-8",
1745 "json" => "application/json; charset=utf-8",
1746 "svg" => "image/svg+xml",
1747 "png" => "image/png",
1748 "jpg" | "jpeg" => "image/jpeg",
1749 "gif" => "image/gif",
1750 "webp" => "image/webp",
1751 "ico" => "image/x-icon",
1752 "woff" => "font/woff",
1753 "woff2" => "font/woff2",
1754 "ttf" => "font/ttf",
1755 "md" | "markdown" => "text/markdown; charset=utf-8",
1756 "txt" => "text/plain; charset=utf-8",
1757 "xml" => "application/xml",
1758 "pdf" => "application/pdf",
1759 "zip" => "application/zip",
1760 _ => "application/octet-stream",
1761 }
1762}
1763
1764/// Check if the If-None-Match header matches the current ETag.
1765/// Handles both exact match and wildcard (*) comparison per RFC 9110.
1766fn is_etag_match(if_none_match: &str, etag: &str) -> bool {
1767 if if_none_match == "*" {
1768 return true;
1769 }
1770 if_none_match.split(',').any(|tag| tag.trim() == etag)
1771}
1772
1773#[derive(Debug)]
1774enum RangeOutcome {
1775 NoRange,
1776 Satisfiable(u64, u64),
1777 Unsatisfiable,
1778 MultiRangeIgnored,
1779}
1780
1781enum RangeCheck {
1782 IgnoreRange,
1783 Satisfiable(u64, u64),
1784 Unsatisfiable,
1785}
1786
1787fn parse_range_header(header: &str, file_size: u64) -> RangeOutcome {
1788 let header = header.trim();
1789 if !header.starts_with("bytes=") {
1790 return RangeOutcome::NoRange;
1791 }
1792
1793 let range_spec = &header[6..];
1794
1795 if range_spec.contains(',') {
1796 return RangeOutcome::MultiRangeIgnored;
1797 }
1798
1799 if let Some(suffix_pos) = range_spec.find('-') {
1800 if suffix_pos == 0 {
1801 let suffix_len_str = &range_spec[1..];
1802 if let Ok(suffix_len) = suffix_len_str.parse::<u64>() {
1803 if suffix_len == 0 {
1804 return RangeOutcome::Unsatisfiable;
1805 }
1806 if suffix_len >= file_size {
1807 return RangeOutcome::Satisfiable(0, file_size - 1);
1808 }
1809 return RangeOutcome::Satisfiable(file_size - suffix_len, file_size - 1);
1810 }
1811 return RangeOutcome::Unsatisfiable;
1812 }
1813
1814 let start_str = &range_spec[..suffix_pos];
1815 let end_str = &range_spec[suffix_pos + 1..];
1816
1817 if let Ok(start) = start_str.parse::<u64>() {
1818 if start >= file_size {
1819 return RangeOutcome::Unsatisfiable;
1820 }
1821
1822 if end_str.is_empty() {
1823 return RangeOutcome::Satisfiable(start, file_size - 1);
1824 }
1825
1826 if let Ok(end) = end_str.parse::<u64>() {
1827 if end < start {
1828 return RangeOutcome::Unsatisfiable;
1829 }
1830 let clamped_end = (end + 1).min(file_size) - 1;
1831 if start > clamped_end {
1832 return RangeOutcome::Unsatisfiable;
1833 }
1834 return RangeOutcome::Satisfiable(start, clamped_end);
1835 }
1836 }
1837 }
1838
1839 RangeOutcome::Unsatisfiable
1840}
1841
1842fn if_range_valid(if_range_header: &str, current_etag: &str) -> bool {
1843 if_range_header.trim() == current_etag
1844}
1845
1846#[cfg(test)]
1847mod bind_address_tests {
1848 use super::EPHEMERAL_BIND_IP;
1849
1850 /// `run`/`run_ephemeral` must never expose the server beyond loopback.
1851 #[test]
1852 fn the_ephemeral_bind_address_is_loopback() {
1853 assert!(
1854 EPHEMERAL_BIND_IP.is_loopback(),
1855 "run_ephemeral would expose the server on {EPHEMERAL_BIND_IP}"
1856 );
1857 }
1858}
1859
1860#[cfg(test)]
1861#[path = "../tests/unit/server/precompressed_sidecar.rs"]
1862mod precompressed_sidecar_tests;
1863
1864#[cfg(test)]
1865#[path = "../tests/unit/server/file_body.rs"]
1866mod file_body_tests;
1867
1868#[cfg(test)]
1869#[path = "../tests/unit/server/finish.rs"]
1870mod finish_tests;
1871
1872#[cfg(test)]
1873#[path = "../tests/unit/server/etag.rs"]
1874mod etag_tests;
1875
1876#[cfg(test)]
1877#[path = "../tests/unit/server/range_header.rs"]
1878mod range_header_tests;