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
14type HashMap<K, V> = rustc_hash::FxHashMap<K, V>;
18
19#[derive(Clone, Debug)]
23pub struct CacheConfig {
24 pub enabled: bool,
32 pub max_total_bytes: usize,
34 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
48pub(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
117fn is_safe_path(base: &Path, candidate: &Path) -> bool {
119 candidate.starts_with(base)
120}
121
122#[derive(Clone, Debug)]
125struct StaticAsset {
126 content: Bytes,
128 content_gz: Option<Bytes>,
130 content_br: Option<Bytes>,
132 etag: String,
137 etag_header: hyper::header::HeaderValue,
141 headers: hyper::HeaderMap,
142}
143
144#[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 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 #[must_use]
228 pub const fn cache(mut self, config: CacheConfig) -> Self {
229 self.cache_config = config;
230 self
231 }
232
233 #[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 pub async fn preload(mut self) -> std::io::Result<Self> {
250 if !self.cache_config.enabled {
251 return Ok(self);
252 }
253 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 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 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 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 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 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 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 let _ = headers.insert(
395 X_CONTENT_TYPE_OPTIONS,
396 hyper::header::HeaderValue::from_static("nosniff"),
397 );
398 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 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#[inline]
436fn make_etag(content: &[u8]) -> String {
437 let len = content.len();
438 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 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 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 #[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 if req_clean.contains("..") || req_clean.contains('\0') {
482 return Err(StatusCode::FORBIDDEN);
483 }
484
485 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 if let Some(cache) = &self.memory_cache
498 && let Some(asset) = cache.get(resolved)
499 {
500 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 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 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 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 #[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 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 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 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 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 let large_path = dir.path().join("large.txt");
811 let large_content = vec![0u8; 6 * 1024 * 1024]; fs::write(&large_path, large_content).unwrap();
813 let sd = ServeDir::new(dir.path()).preload().await.unwrap();
814 assert!(sd.memory_cache.as_ref().unwrap().get("large.txt").is_none());
816
817 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 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 assert_eq!(
832 sd_dyn.handle_request("style%x.css").await.unwrap_err(),
833 StatusCode::BAD_REQUEST
834 );
835
836 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 assert_eq!(
845 sd_dyn.handle_request("nonexistent.txt").await.unwrap_err(),
846 StatusCode::NOT_FOUND
847 );
848
849 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 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}