Skip to main content

tachyon_web/routing/
static_dir.rs

1use crate::http::response::{Body, IntoResponse};
2use bytes::Bytes;
3use hyper::{
4    Response, StatusCode,
5    header::{
6        CACHE_CONTROL, CONTENT_ENCODING, CONTENT_TYPE, ETAG, IF_NONE_MATCH, VARY,
7        X_CONTENT_TYPE_OPTIONS,
8    },
9};
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use tokio::fs;
13
14/// Hash map keyed by asset path, using `FxHash` instead of the default
15/// `SipHash`: the key set is fixed after `preload()`/`crawl_dir()`, so the
16/// DoS-resistance `SipHash` provides buys nothing here, only lookup latency.
17type HashMap<K, V> = rustc_hash::FxHashMap<K, V>;
18
19/// Configuration for in-memory file caching.
20///
21/// Defaults: enabled, 64 MiB total cap, 2 MiB per-file cap.
22#[derive(Clone, Debug)]
23pub struct CacheConfig {
24    /// Whether RAM caching is *permitted*. This only takes effect once
25    /// [`ServeDir::preload`] is actually called — `enabled: true` alone does not
26    /// populate the cache; it just means a subsequent `.preload().await` won't be a
27    /// no-op. [`Router::serve_static`](crate::routing::Router::serve_static), the
28    /// most common entry point, never calls `.preload()`, so caching stays off by
29    /// default there even though this field defaults to `true`. Set `false` to make
30    /// `.preload()` itself a no-op and always serve from disk.
31    pub enabled: bool,
32    /// Maximum total RAM used by the cache in bytes. Default: 64 MiB.
33    pub max_total_bytes: usize,
34    /// Maximum size of a single file that will be cached. Default: 2 MiB.
35    pub max_file_bytes: usize,
36}
37
38impl Default for CacheConfig {
39    fn default() -> Self {
40        Self {
41            enabled: true,
42            max_total_bytes: 64 * 1024 * 1024,
43            max_file_bytes: 2 * 1024 * 1024,
44        }
45    }
46}
47
48/// Maps a file extension to a MIME type, by extension only — this never inspects
49/// file content, so a `.svg` is always reported as `image/svg+xml` regardless of
50/// whether it contains a `<script>`. See the `ServeDir` docs' upload-safety
51/// warning before pointing a `ServeDir` at a directory that can contain files an
52/// untrusted user chose the bytes of.
53pub(crate) fn guess_mime_type(path: &Path) -> &'static str {
54    let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
55        return "application/octet-stream";
56    };
57
58    if ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm") {
59        "text/html; charset=utf-8"
60    } else if ext.eq_ignore_ascii_case("css") {
61        "text/css; charset=utf-8"
62    } else if ext.eq_ignore_ascii_case("js") || ext.eq_ignore_ascii_case("mjs") {
63        "application/javascript; charset=utf-8"
64    } else if ext.eq_ignore_ascii_case("json") {
65        "application/json"
66    } else if ext.eq_ignore_ascii_case("wasm") {
67        "application/wasm"
68    } else if ext.eq_ignore_ascii_case("webmanifest") {
69        "application/manifest+json"
70    } else if ext.eq_ignore_ascii_case("xml") {
71        "text/xml; charset=utf-8"
72    } else if ext.eq_ignore_ascii_case("txt") {
73        "text/plain; charset=utf-8"
74    } else if ext.eq_ignore_ascii_case("csv") {
75        "text/csv; charset=utf-8"
76    } else if ext.eq_ignore_ascii_case("png") {
77        "image/png"
78    } else if ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg") {
79        "image/jpeg"
80    } else if ext.eq_ignore_ascii_case("gif") {
81        "image/gif"
82    } else if ext.eq_ignore_ascii_case("svg") || ext.eq_ignore_ascii_case("svgz") {
83        "image/svg+xml"
84    } else if ext.eq_ignore_ascii_case("ico") {
85        "image/x-icon"
86    } else if ext.eq_ignore_ascii_case("webp") {
87        "image/webp"
88    } else if ext.eq_ignore_ascii_case("avif") {
89        "image/avif"
90    } else if ext.eq_ignore_ascii_case("bmp") {
91        "image/bmp"
92    } else if ext.eq_ignore_ascii_case("woff") {
93        "font/woff"
94    } else if ext.eq_ignore_ascii_case("woff2") {
95        "font/woff2"
96    } else if ext.eq_ignore_ascii_case("ttf") {
97        "font/ttf"
98    } else if ext.eq_ignore_ascii_case("otf") {
99        "font/otf"
100    } else if ext.eq_ignore_ascii_case("mp3") {
101        "audio/mpeg"
102    } else if ext.eq_ignore_ascii_case("mp4") || ext.eq_ignore_ascii_case("m4v") {
103        "video/mp4"
104    } else if ext.eq_ignore_ascii_case("webm") {
105        "video/webm"
106    } else if ext.eq_ignore_ascii_case("pdf") {
107        "application/pdf"
108    } else if ext.eq_ignore_ascii_case("zip") {
109        "application/zip"
110    } else if ext.eq_ignore_ascii_case("gz") {
111        "application/gzip"
112    } else {
113        "application/octet-stream"
114    }
115}
116
117/// Validates that `candidate` lives inside `base` (path-traversal guard).
118fn is_safe_path(base: &Path, candidate: &Path) -> bool {
119    candidate.starts_with(base)
120}
121
122// ─── StaticAsset ──────────────────────────────────────────────────────────────
123
124#[derive(Clone, Debug)]
125struct StaticAsset {
126    /// Raw (identity) content.
127    content: Bytes,
128    /// Pre-compressed gzip variant, if available alongside the original file.
129    content_gz: Option<Bytes>,
130    /// Pre-compressed brotli variant, if available alongside the original file.
131    content_br: Option<Bytes>,
132    /// `ETag` value: hex-encoded content length plus a cheap rolling-hash fingerprint
133    /// of the content's first 8 and last 4 bytes (see `make_etag`) — not a
134    /// cryptographic or full-content hash, so it's sized for change-detection, not
135    /// collision resistance.
136    etag: String,
137    /// Pre-validated `HeaderValue` form of `etag`, computed once at crawl time so the
138    /// request path only ever needs a cheap `Bytes`-backed clone instead of re-parsing
139    /// (and re-validating) `etag` on every cache hit via `HeaderValue::from_str`.
140    etag_header: hyper::header::HeaderValue,
141    headers: hyper::HeaderMap,
142}
143
144// ─── ServeDir ─────────────────────────────────────────────────────────────────
145
146/// High-performance static file server.
147///
148/// ## Nginx-like usage
149///
150/// ```rust,no_run
151/// use tachyon_web::{Router, ServeDir};
152///
153/// # async fn example() -> std::io::Result<()> {
154/// // Simple: serve ./public/ at /, index.html as default
155/// let router: Router = Router::new()
156///     .serve_static("./public");
157///
158/// // Advanced: with preloading
159/// let sd = ServeDir::new("./public")
160///     .index("index.html")
161///     .preload().await?;
162/// let router: Router = Router::new().serve_dir("/", sd);
163/// # let _ = router;
164/// # Ok(())
165/// # }
166/// ```
167///
168/// ## ⚠️ Never point this at a directory that accepts user uploads
169///
170/// `ServeDir` serves whatever bytes are on disk with the MIME type derived
171/// from the file extension — it has no way to know
172/// whether a file's *content* actually matches that extension. In particular,
173/// `.svg` is served as `image/svg+xml`, and SVG is allowed to contain
174/// `<script>`: if the served directory (or any subdirectory reachable through
175/// it) can ever contain a file an untrusted user chose the bytes of — an
176/// avatar/logo upload folder mixed into `./public/`, for example — that user
177/// can plant a self-executing script that runs in your origin the moment
178/// anyone views the "image" directly or via `<img>`/`<object>`. Every response
179/// from `ServeDir` sets `X-Content-Type-Options: nosniff`, which stops browsers
180/// from *guessing* a more dangerous type than what's declared, but it does
181/// **not** stop an SVG correctly served as `image/svg+xml` from running the
182/// script embedded inside it — nosniff and inline-SVG script execution are
183/// orthogonal protections.
184///
185/// If a served directory can ever contain user-supplied files:
186/// - Serve that subtree from a **separate route/directory** with
187///   `Content-Disposition: attachment` (forces download, never inline render),
188///   or
189/// - Sanitize/strip `<script>` (and other active content) from SVGs before
190///   they land in the served directory.
191#[derive(Clone, Debug)]
192pub struct ServeDir {
193    base_path: PathBuf,
194    memory_cache: Option<Arc<HashMap<String, StaticAsset>>>,
195    index_file: Option<String>,
196    cache_config: CacheConfig,
197}
198
199impl ServeDir {
200    /// Create a `ServeDir` that reads files from disk on every request.
201    ///
202    /// `path` is canonicalized once, here, so later traversal checks
203    /// (`is_safe_path`) compare against a symlink-resolved base. If `path`
204    /// doesn't exist yet at construction time, canonicalization is skipped and
205    /// `base_path` falls back to an uncanonicalized (but absolute) path —
206    /// [`preload`](Self::preload) retries canonicalization once the directory
207    /// exists. If you construct a `ServeDir` for a directory that doesn't exist
208    /// yet and never call `preload`, and any path component involved is later a
209    /// symlink, traversal checks may spuriously reject legitimate requests;
210    /// prefer creating the directory before calling `new`.
211    pub fn new(path: impl AsRef<Path>) -> Self {
212        let base = path.as_ref().to_path_buf();
213        let base = std::fs::canonicalize(&base)
214            .or_else(|_| std::env::current_dir().map(|cd| cd.join(&base)))
215            .unwrap_or(base);
216        Self {
217            base_path: base,
218            memory_cache: None,
219            index_file: None,
220            cache_config: CacheConfig::default(),
221        }
222    }
223
224    /// Configure in-memory caching behaviour.
225    ///
226    /// Use this to disable caching entirely (e.g. for development) or to tune RAM limits.
227    #[must_use]
228    pub const fn cache(mut self, config: CacheConfig) -> Self {
229        self.cache_config = config;
230        self
231    }
232
233    /// Serve `index_file` (e.g. `"index.html"`) when a directory or root is requested.
234    /// This is equivalent to Nginx's `index` directive.
235    #[must_use]
236    pub fn index(mut self, file: impl Into<String>) -> Self {
237        self.index_file = Some(file.into());
238        self
239    }
240
241    /// Preload the entire directory tree into memory.
242    ///
243    /// After this call, every request is served from `Arc<Bytes>` with **zero disk I/O**.
244    /// Set `cache_config.enabled = false` to skip preloading and serve directly from disk.
245    ///
246    /// # Errors
247    ///
248    /// Returns an `std::io::Error` if reading files or directories from disk fails.
249    pub async fn preload(mut self) -> std::io::Result<Self> {
250        if !self.cache_config.enabled {
251            return Ok(self);
252        }
253        // `base_path` may not have been canonicalized in `new()` if the directory
254        // didn't exist yet at construction time — retry now that `preload` is
255        // about to walk it (and thus requires it to exist), so subsequent
256        // request-time traversal checks compare against a symlink-resolved base.
257        if let Ok(canonical) = fs::canonicalize(&self.base_path).await {
258            self.base_path = canonical;
259        }
260        let mut cache = HashMap::default();
261        let mut current_total = 0usize;
262        Self::crawl_dir(
263            &self.base_path.clone(),
264            &self.base_path.clone(),
265            &mut cache,
266            &mut current_total,
267            self.cache_config.max_file_bytes,
268            self.cache_config.max_total_bytes,
269        )
270        .await?;
271        // Also store the index file under the empty string key for root lookups.
272        // `StaticAsset::content`/`content_gz`/`content_br` are `Bytes`, so this clone
273        // shares the same underlying buffer rather than duplicating it — the extra
274        // `HashMap` entry (key, headers, etag) is real but negligible overhead, not
275        // counted against `max_total_bytes` since it isn't proportional to file size.
276        if let Some(ref idx) = self.index_file
277            && let Some(asset) = cache.get(idx.as_str()).cloned()
278        {
279            let _ = cache.insert(String::new(), asset);
280        }
281        self.memory_cache = Some(Arc::new(cache));
282        Ok(self)
283    }
284
285    #[allow(clippy::too_many_lines)]
286    async fn crawl_dir(
287        base: &Path,
288        current: &Path,
289        cache: &mut HashMap<String, StaticAsset>,
290        current_total: &mut usize,
291        max_file_bytes: usize,
292        max_total_bytes: usize,
293    ) -> std::io::Result<()> {
294        if !current.exists() {
295            return Ok(());
296        }
297        let mut entries = fs::read_dir(current).await?;
298        while let Some(entry) = entries.next_entry().await? {
299            let path = entry.path();
300
301            // Guard against symlinks planted under the served directory that point
302            // outside `base` (e.g. `ln -s /etc app/static/etc`). Unlike the dynamic
303            // disk-serving path, preloaded assets are served straight from the
304            // in-memory cache with no per-request traversal check, so this has to be
305            // enforced once, here, at crawl time — otherwise a symlink escape would
306            // get cached under an innocuous-looking key and served on every request.
307            match fs::canonicalize(&path).await {
308                Ok(real) if real.starts_with(base) => {}
309                _ => {
310                    tracing::warn!(
311                        path = %path.display(),
312                        "Skipping cache entry that resolves outside the served directory"
313                    );
314                    continue;
315                }
316            }
317
318            if path.is_dir() {
319                Box::pin(Self::crawl_dir(
320                    base,
321                    &path,
322                    cache,
323                    current_total,
324                    max_file_bytes,
325                    max_total_bytes,
326                ))
327                .await?;
328                continue;
329            }
330
331            // Skip compressed sidecar files — but only when they actually *are* a sidecar
332            // of some other cached file (i.e. the uncompressed base file exists next to
333            // them). A standalone downloadable archive with no uncompressed sibling (e.g.
334            // `release.tar.gz`) has no base to be loaded as a variant of, so it must still
335            // be crawled and cached under its own key — otherwise it 404s in preloaded mode
336            // while serving fine from disk in dynamic mode.
337            let path_str = path.to_string_lossy();
338            if let Some(base_str) = path_str
339                .strip_suffix(".gz")
340                .or_else(|| path_str.strip_suffix(".br"))
341                && fs::metadata(base_str).await.is_ok()
342            {
343                continue;
344            }
345
346            let meta = fs::metadata(&path).await?;
347            if usize::try_from(meta.len()).unwrap_or(usize::MAX) > max_file_bytes {
348                tracing::debug!(
349                    "Skipping cache for large file: {} ({} bytes)",
350                    path.display(),
351                    meta.len()
352                );
353                continue;
354            }
355
356            // Check total RAM budget before reading. `current_total` is a running
357            // accumulator updated as each asset is inserted, avoiding an O(n) rescan
358            // of the whole cache (and thus O(n^2) behaviour) for every file crawled.
359            if *current_total >= max_total_bytes {
360                tracing::warn!("RAM cache budget exhausted; remaining files served from disk");
361                break;
362            }
363
364            let content = fs::read(&path).await?;
365            let relative = match path.strip_prefix(base) {
366                Ok(rel) => rel
367                    .to_string_lossy()
368                    .trim_start_matches('/')
369                    .replace('\\', "/"),
370                Err(_) => continue,
371            };
372
373            // Attempt to load pre-compressed sidecar files.
374            let gz_path = PathBuf::from(format!("{}.gz", path.display()));
375            let br_path = PathBuf::from(format!("{}.br", path.display()));
376            let content_gz = fs::read(&gz_path).await.ok().map(Bytes::from);
377            let content_br = fs::read(&br_path).await.ok().map(Bytes::from);
378
379            // Compute a fast ETag from content length + first 8 bytes.
380            let etag = make_etag(&content);
381            let etag_header = hyper::header::HeaderValue::from_str(&etag)
382                .unwrap_or_else(|_| hyper::header::HeaderValue::from_static("\"0\""));
383
384            let mime_type = guess_mime_type(&path);
385            let mut headers = hyper::HeaderMap::new();
386            let _ = headers.insert(
387                CONTENT_TYPE,
388                hyper::header::HeaderValue::from_static(mime_type),
389            );
390            // Prevent the browser from MIME-sniffing away from the declared
391            // Content-Type (e.g. treating a misconfigured upload as HTML). See the
392            // module-level docs for why this alone does not make it safe to serve
393            // user-uploaded files (notably `.svg`) from the same directory.
394            let _ = headers.insert(
395                X_CONTENT_TYPE_OPTIONS,
396                hyper::header::HeaderValue::from_static("nosniff"),
397            );
398            // Cache-Control: 1 hour for versioned assets; 5 min for HTML.
399            let cc = if mime_type.starts_with("text/html") {
400                "public, max-age=300"
401            } else {
402                "public, max-age=3600, immutable"
403            };
404            let _ = headers.insert(CACHE_CONTROL, hyper::header::HeaderValue::from_static(cc));
405            // Vary: Accept-Encoding whenever we have compressed variants.
406            if content_gz.is_some() || content_br.is_some() {
407                let _ = headers.insert(
408                    VARY,
409                    hyper::header::HeaderValue::from_static("Accept-Encoding"),
410                );
411            }
412
413            *current_total += content.len()
414                + content_gz.as_ref().map_or(0, Bytes::len)
415                + content_br.as_ref().map_or(0, Bytes::len);
416
417            let _ = cache.insert(
418                relative,
419                StaticAsset {
420                    content: Bytes::from(content),
421                    content_gz,
422                    content_br,
423                    etag,
424                    etag_header,
425                    headers,
426                },
427            );
428        }
429        Ok(())
430    }
431}
432
433/// Produce a lightweight `ETag` string from content: `"<len>-<first8hex>"`.
434/// No crypto, no allocation-heavy hashing — pure arithmetic on existing bytes.
435#[inline]
436fn make_etag(content: &[u8]) -> String {
437    let len = content.len();
438    // Grab up to 8 bytes and fold into a u64 for a quick fingerprint.
439    let mut sample: u64 = 0;
440    for &b in content.iter().take(8) {
441        sample = sample.wrapping_mul(31).wrapping_add(u64::from(b));
442    }
443    // Also mix in a few bytes from the tail for better collision resistance.
444    for &b in content.iter().rev().take(4) {
445        sample = sample.wrapping_mul(37).wrapping_add(u64::from(b));
446    }
447    format!("\"{len:x}-{sample:x}\"")
448}
449
450impl ServeDir {
451    /// Serve a request; convenience wrapper with no encoding negotiation or `ETag` checking.
452    ///
453    /// # Errors
454    ///
455    /// Returns a `StatusCode` (like `404 Not Found` or `403 Forbidden`) if serving fails.
456    pub async fn handle_request(&self, req_path: &str) -> Result<Response<Body>, StatusCode> {
457        self.handle_request_with_encoding(req_path, "", "").await
458    }
459
460    /// Full request handler with content-encoding negotiation and `ETag` 304 support.
461    ///
462    /// `accept_encoding` — value of the request's `Accept-Encoding` header (empty if absent).
463    /// `if_none_match` — value of `If-None-Match` for `ETag` 304 short-circuit.
464    ///
465    /// # Errors
466    ///
467    /// Returns a `StatusCode` if percent decoding fails, traversal is detected, or the file cannot be served.
468    #[allow(clippy::too_many_lines)]
469    pub async fn handle_request_with_encoding(
470        &self,
471        req_path: &str,
472        accept_encoding: &str,
473        if_none_match: &str,
474    ) -> Result<Response<Body>, StatusCode> {
475        let Some(decoded) = crate::routing::percent_decode(req_path) else {
476            return Err(StatusCode::BAD_REQUEST);
477        };
478        let req_clean = decoded.trim_start_matches('/');
479
480        // ── Security: reject obvious traversal before anything else ──────────
481        if req_clean.contains("..") || req_clean.contains('\0') {
482            return Err(StatusCode::FORBIDDEN);
483        }
484
485        // Resolve index file for empty path (root / directory requests)
486        let resolved = if req_clean.is_empty() {
487            self.index_file.as_deref().unwrap_or("")
488        } else {
489            req_clean
490        };
491
492        if resolved.is_empty() {
493            return Err(StatusCode::NOT_FOUND);
494        }
495
496        // ── 1. Preloaded memory cache (zero I/O) ─────────────────────────────
497        if let Some(cache) = &self.memory_cache
498            && let Some(asset) = cache.get(resolved)
499        {
500            // ETag 304 short-circuit — zero body, minimal CPU.
501            if !if_none_match.is_empty() && if_none_match == asset.etag {
502                return Ok(Response::builder()
503                    .status(StatusCode::NOT_MODIFIED)
504                    .body(Body::empty())
505                    .unwrap_or_else(|_| Response::new(Body::empty())));
506            }
507
508            // Content-negotiation: prefer br > gz > identity.
509            let (body_bytes, encoding) =
510                if !accept_encoding.is_empty() && accept_encoding.contains("br") {
511                    asset.content_br.as_ref().map_or_else(
512                        || (asset.content.clone(), None),
513                        |b| (b.clone(), Some("br")),
514                    )
515                } else if !accept_encoding.is_empty() && accept_encoding.contains("gzip") {
516                    asset.content_gz.as_ref().map_or_else(
517                        || (asset.content.clone(), None),
518                        |b| (b.clone(), Some("gzip")),
519                    )
520                } else {
521                    (asset.content.clone(), None)
522                };
523
524            let mut resp = Response::new(Body::full(body_bytes));
525            *resp.headers_mut() = asset.headers.clone();
526            let _ = resp
527                .headers_mut()
528                .insert(ETAG, asset.etag_header.clone());
529            if let Some(enc) = encoding {
530                let enc_val = hyper::header::HeaderValue::from_static(enc);
531                let _ = resp.headers_mut().insert(CONTENT_ENCODING, enc_val);
532            }
533            return Ok(resp);
534        }
535        // If preloaded but missing from cache (e.g. too large), fall through to disk!
536
537        // ── 2. Dynamic disk mode with path-traversal hardening ───────────────
538        let candidate = self.base_path.join(resolved);
539        let Ok(canonical) = fs::canonicalize(&candidate).await else {
540            return Err(StatusCode::NOT_FOUND);
541        };
542
543        if !is_safe_path(&self.base_path, &canonical) {
544            tracing::warn!(
545                path = %canonical.display(),
546                base = %self.base_path.display(),
547                "Rejected path traversal attempt"
548            );
549            return Err(StatusCode::FORBIDDEN);
550        }
551
552        let Ok(meta) = fs::metadata(&canonical).await else {
553            return Err(StatusCode::NOT_FOUND);
554        };
555
556        if !meta.is_file() {
557            return Err(StatusCode::NOT_FOUND);
558        }
559
560        match fs::read(&canonical).await {
561            Ok(content) => {
562                let mime_type = guess_mime_type(&canonical);
563
564                // ZERO-COPY from Vec<u8> to Bytes
565                let mut resp = Response::new(Body::full(Bytes::from(content)));
566                let _ = resp.headers_mut().insert(
567                    CONTENT_TYPE,
568                    hyper::header::HeaderValue::from_static(mime_type),
569                );
570                let _ = resp.headers_mut().insert(
571                    X_CONTENT_TYPE_OPTIONS,
572                    hyper::header::HeaderValue::from_static("nosniff"),
573                );
574
575                Ok(resp)
576            }
577            Err(e) => {
578                tracing::error!(path = %canonical.display(), error = %e, "Failed to read static file");
579                Err(StatusCode::INTERNAL_SERVER_ERROR)
580            }
581        }
582    }
583
584    /// Build a `MethodRouter` from this `ServeDir`.
585    ///
586    /// The path to serve is taken from the `{path}` or `{*path}` matchit capture.
587    /// This is used internally by `Router::serve_static` — you rarely need this directly.
588    #[must_use]
589    pub fn into_method_router<S>(self) -> crate::routing::MethodRouter<S>
590    where
591        S: Clone + Send + Sync + 'static,
592    {
593        let self_arc = std::sync::Arc::new(self);
594        crate::routing::get(move |req: hyper::Request<Bytes>| {
595            let this = self_arc.clone();
596
597            async move {
598                // By moving `req` into the async block FIRST, we can borrow `&str`
599                // directly from its extensions or URI, achieving zero allocations!
600                let path_ext = req
601                    .extensions()
602                    .get::<crate::routing::extract::PathParams>();
603
604                let file_path = path_ext
605                    .and_then(|p| {
606                        p.0.iter()
607                            .find(|(k, _)| k.as_ref() == "path" || k.as_ref() == "*path")
608                            .map(|(_, v)| v.as_str())
609                    })
610                    .unwrap_or_else(|| req.uri().path());
611
612                // Extract encoding negotiation headers before calling the handler.
613                let accept_enc = req
614                    .headers()
615                    .get(hyper::header::ACCEPT_ENCODING)
616                    .and_then(|v| v.to_str().ok())
617                    .unwrap_or("");
618                let if_none_match = req
619                    .headers()
620                    .get(IF_NONE_MATCH)
621                    .and_then(|v| v.to_str().ok())
622                    .unwrap_or("");
623
624                match this
625                    .handle_request_with_encoding(file_path, accept_enc, if_none_match)
626                    .await
627                {
628                    Ok(resp) => resp,
629                    Err(status) => status.into_response(),
630                }
631            }
632        })
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    #![allow(clippy::unwrap_used)]
639    use super::*;
640    use std::fs;
641
642    fn make_temp_dir() -> tempfile::TempDir {
643        let dir = tempfile::tempdir().expect("tempdir");
644        fs::write(
645            dir.path().join("index.html"),
646            b"<html><script>var x=1;</script></html>",
647        )
648        .unwrap();
649        fs::write(dir.path().join("style.css"), b"body{}").unwrap();
650        fs::write(dir.path().join("app.js"), b"console.log(1)").unwrap();
651        dir
652    }
653
654    #[tokio::test]
655    async fn test_serve_existing_file() {
656        let dir = make_temp_dir();
657        let sd = ServeDir::new(dir.path()).preload().await.unwrap();
658        let resp = sd.handle_request("style.css").await.unwrap();
659        assert_eq!(resp.status(), StatusCode::OK);
660        let ct = resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap();
661        assert!(ct.contains("text/css"), "ct: {ct}");
662    }
663
664    #[tokio::test]
665    async fn test_nosniff_header_preloaded() {
666        let dir = make_temp_dir();
667        let sd = ServeDir::new(dir.path()).preload().await.unwrap();
668        let resp = sd.handle_request("style.css").await.unwrap();
669        assert_eq!(
670            resp.headers().get(X_CONTENT_TYPE_OPTIONS).unwrap(),
671            "nosniff"
672        );
673    }
674
675    #[tokio::test]
676    async fn test_nosniff_header_dynamic() {
677        let dir = make_temp_dir();
678        let sd = ServeDir::new(dir.path());
679        let resp = sd.handle_request("style.css").await.unwrap();
680        assert_eq!(
681            resp.headers().get(X_CONTENT_TYPE_OPTIONS).unwrap(),
682            "nosniff"
683        );
684    }
685
686    #[test]
687    fn test_svg_mime_type() {
688        // Documented, deliberate behavior: `.svg` is reported by extension only,
689        // never by content — see the `ServeDir` docs' upload-safety warning.
690        assert_eq!(guess_mime_type(Path::new("logo.svg")), "image/svg+xml");
691    }
692
693    #[tokio::test]
694    async fn test_not_found() {
695        let dir = make_temp_dir();
696        let sd = ServeDir::new(dir.path()).preload().await.unwrap();
697        assert_eq!(
698            sd.handle_request("missing.txt").await.unwrap_err(),
699            StatusCode::NOT_FOUND
700        );
701    }
702
703    #[tokio::test]
704    async fn test_index_file_on_root_request() {
705        let dir = make_temp_dir();
706        let sd = ServeDir::new(dir.path())
707            .index("index.html")
708            .preload()
709            .await
710            .unwrap();
711        let resp = sd.handle_request("").await.unwrap();
712        assert_eq!(resp.status(), StatusCode::OK);
713        let ct = resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap();
714        assert!(ct.contains("text/html"), "ct: {ct}");
715    }
716
717    #[tokio::test]
718    async fn test_path_traversal_dotdot() {
719        let dir = make_temp_dir();
720        let sd = ServeDir::new(dir.path()).preload().await.unwrap();
721        let err = sd.handle_request("../../etc/passwd").await.unwrap_err();
722        assert_eq!(err, StatusCode::FORBIDDEN);
723    }
724
725    #[tokio::test]
726    async fn test_path_traversal_dynamic_mode() {
727        let dir = make_temp_dir();
728        let sd = ServeDir::new(dir.path());
729        let err = sd.handle_request("../../../etc/passwd").await.unwrap_err();
730        assert!(err == StatusCode::FORBIDDEN || err == StatusCode::NOT_FOUND);
731    }
732
733    #[tokio::test]
734    async fn test_null_byte_rejected() {
735        let dir = make_temp_dir();
736        let sd = ServeDir::new(dir.path()).preload().await.unwrap();
737        assert_eq!(
738            sd.handle_request("style\x00.css").await.unwrap_err(),
739            StatusCode::FORBIDDEN
740        );
741    }
742
743    #[test]
744    fn test_guess_mime_types() {
745        let cases = [
746            ("index.html", "text/html; charset=utf-8"),
747            ("style.css", "text/css; charset=utf-8"),
748            ("app.js", "application/javascript; charset=utf-8"),
749            ("data.json", "application/json"),
750            ("file.wasm", "application/wasm"),
751            ("manifest.webmanifest", "application/manifest+json"),
752            ("feed.xml", "text/xml; charset=utf-8"),
753            ("doc.txt", "text/plain; charset=utf-8"),
754            ("sheet.csv", "text/csv; charset=utf-8"),
755            ("img.png", "image/png"),
756            ("pic.jpg", "image/jpeg"),
757            ("anim.gif", "image/gif"),
758            ("vector.svg", "image/svg+xml"),
759            ("fav.ico", "image/x-icon"),
760            ("pic.webp", "image/webp"),
761            ("pic.avif", "image/avif"),
762            ("pic.bmp", "image/bmp"),
763            ("font.woff", "font/woff"),
764            ("font.woff2", "font/woff2"),
765            ("font.ttf", "font/ttf"),
766            ("font.otf", "font/otf"),
767            ("audio.mp3", "audio/mpeg"),
768            ("video.mp4", "video/mp4"),
769            ("video.webm", "video/webm"),
770            ("doc.pdf", "application/pdf"),
771            ("archive.zip", "application/zip"),
772            ("archive.gz", "application/gzip"),
773            ("no_ext", "application/octet-stream"),
774            ("file.unknown", "application/octet-stream"),
775        ];
776
777        for (filename, expected) in cases {
778            let path = Path::new(filename);
779            assert_eq!(guess_mime_type(path), expected, "failed on {filename}");
780        }
781    }
782
783    #[test]
784    fn test_is_safe_path() {
785        let base = Path::new("/var/www");
786        let safe = Path::new("/var/www/index.html");
787        let unsafe_path = Path::new("/var/etc/passwd");
788        assert!(is_safe_path(base, safe));
789        assert!(!is_safe_path(base, unsafe_path));
790    }
791
792    #[tokio::test]
793    async fn test_crawl_dir_edge_cases() {
794        let dir = tempfile::tempdir().unwrap();
795        // Crawl non-existent path
796        let mut cache = HashMap::default();
797        let mut current_total = 0usize;
798        let res = ServeDir::crawl_dir(
799            dir.path(),
800            &dir.path().join("missing"),
801            &mut cache,
802            &mut current_total,
803            2 * 1024 * 1024,
804            64 * 1024 * 1024,
805        )
806        .await;
807        assert!(res.is_ok());
808
809        // Crawl large file (> 5MB)
810        let large_path = dir.path().join("large.txt");
811        let large_content = vec![0u8; 6 * 1024 * 1024]; // 6MB
812        fs::write(&large_path, large_content).unwrap();
813        let sd = ServeDir::new(dir.path()).preload().await.unwrap();
814        // Large file should not be preloaded in cache
815        assert!(sd.memory_cache.as_ref().unwrap().get("large.txt").is_none());
816
817        // Dynamic serving of large file should succeed
818        let resp = sd.handle_request("large.txt").await.unwrap();
819        assert_eq!(resp.status(), StatusCode::OK);
820    }
821
822    #[tokio::test]
823    async fn test_handle_request_edge_cases() {
824        let dir = make_temp_dir();
825        // Dynamic mode
826        let sd_dyn = ServeDir::new(dir.path());
827        let resp = sd_dyn.handle_request("style.css").await.unwrap();
828        assert_eq!(resp.status(), StatusCode::OK);
829
830        // Invalid percent encoding
831        assert_eq!(
832            sd_dyn.handle_request("style%x.css").await.unwrap_err(),
833            StatusCode::BAD_REQUEST
834        );
835
836        // Empty path and empty index
837        let sd_no_index = ServeDir::new(dir.path());
838        assert_eq!(
839            sd_no_index.handle_request("").await.unwrap_err(),
840            StatusCode::NOT_FOUND
841        );
842
843        // Canonicalization failure
844        assert_eq!(
845            sd_dyn.handle_request("nonexistent.txt").await.unwrap_err(),
846            StatusCode::NOT_FOUND
847        );
848
849        // Path traversal targeting a file that actually exists on disk (so
850        // canonicalization succeeds) must still be rejected.
851        let sd_traversal = ServeDir::new(dir.path());
852        let res = sd_traversal
853            .handle_request("../../../../../../../../../etc/passwd")
854            .await;
855        assert!(res.is_err());
856    }
857
858    #[tokio::test]
859    async fn test_into_method_router_fallback() {
860        let dir = make_temp_dir();
861        let sd = ServeDir::new(dir.path()).preload().await.unwrap();
862        let router = sd.into_method_router::<()>();
863        // 1. Without extension, fallback to URI path
864        let req = hyper::Request::builder()
865            .method("GET")
866            .uri("/style.css")
867            .body(Body::empty())
868            .unwrap();
869        let h = router.handlers[super::super::IDX_GET].as_ref().unwrap();
870        let resp = h.call(req, Arc::new(())).await;
871        assert_eq!(resp.status(), StatusCode::OK);
872    }
873}