1use std::path::{Path, PathBuf};
52use std::time::SystemTime;
53
54use crate::response::{Response, ResponseBody, StatusCode, mime_type_for_extension};
55
56#[derive(Debug, Clone)]
58#[allow(clippy::struct_excessive_bools)]
59pub struct StaticFilesConfig {
60 pub directory: PathBuf,
62 pub prefix: String,
64 pub index_files: Vec<String>,
66 pub show_hidden: bool,
68 pub follow_symlinks: bool,
70 pub enable_etag: bool,
72 pub enable_last_modified: bool,
74 pub directory_listing: bool,
76 pub not_found_page: Option<String>,
78 pub extra_headers: Vec<(String, String)>,
80}
81
82impl Default for StaticFilesConfig {
83 fn default() -> Self {
84 Self {
85 directory: PathBuf::from("."),
86 prefix: String::new(),
87 index_files: vec!["index.html".to_string()],
88 show_hidden: false,
89 follow_symlinks: false,
90 enable_etag: true,
91 enable_last_modified: true,
92 directory_listing: false,
93 not_found_page: None,
94 extra_headers: Vec::new(),
95 }
96 }
97}
98
99impl StaticFilesConfig {
100 #[must_use]
102 pub fn new(directory: impl Into<PathBuf>) -> Self {
103 Self {
104 directory: directory.into(),
105 ..Default::default()
106 }
107 }
108
109 #[must_use]
111 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
112 self.prefix = prefix.into();
113 self
114 }
115
116 #[must_use]
118 pub fn index_files(mut self, files: Vec<String>) -> Self {
119 self.index_files = files;
120 self
121 }
122
123 #[must_use]
125 pub fn show_hidden(mut self, show: bool) -> Self {
126 self.show_hidden = show;
127 self
128 }
129
130 #[must_use]
132 pub fn follow_symlinks(mut self, follow: bool) -> Self {
133 self.follow_symlinks = follow;
134 self
135 }
136
137 #[must_use]
139 pub fn enable_etag(mut self, enable: bool) -> Self {
140 self.enable_etag = enable;
141 self
142 }
143
144 #[must_use]
146 pub fn enable_last_modified(mut self, enable: bool) -> Self {
147 self.enable_last_modified = enable;
148 self
149 }
150
151 #[must_use]
153 pub fn directory_listing(mut self, enable: bool) -> Self {
154 self.directory_listing = enable;
155 self
156 }
157
158 #[must_use]
160 pub fn not_found_page(mut self, page: impl Into<String>) -> Self {
161 self.not_found_page = Some(page.into());
162 self
163 }
164
165 #[must_use]
167 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
168 self.extra_headers.push((name.into(), value.into()));
169 self
170 }
171}
172
173#[derive(Debug, Clone)]
187pub struct StaticFiles {
188 config: StaticFilesConfig,
189}
190
191impl StaticFiles {
192 #[must_use]
194 pub fn new(directory: impl Into<PathBuf>) -> Self {
195 Self {
196 config: StaticFilesConfig::new(directory),
197 }
198 }
199
200 #[must_use]
202 pub fn with_config(config: StaticFilesConfig) -> Self {
203 Self { config }
204 }
205
206 #[must_use]
208 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
209 self.config.prefix = prefix.into();
210 self
211 }
212
213 #[must_use]
215 pub fn index_file(mut self, file: impl Into<String>) -> Self {
216 self.config.index_files = vec![file.into()];
217 self
218 }
219
220 #[must_use]
222 pub fn enable_directory_listing(mut self) -> Self {
223 self.config.directory_listing = true;
224 self
225 }
226
227 #[must_use]
229 pub fn follow_symlinks(mut self) -> Self {
230 self.config.follow_symlinks = true;
231 self
232 }
233
234 pub fn serve(&self, request_path: &str) -> Response {
244 let path_without_prefix = self.strip_prefix(request_path);
246
247 if !is_safe_path(path_without_prefix) {
249 return Response::with_status(StatusCode::FORBIDDEN)
250 .header("content-type", b"text/plain".to_vec())
251 .body(ResponseBody::Bytes(b"Forbidden: Invalid path".to_vec()));
252 }
253
254 let file_path = self
256 .config
257 .directory
258 .join(path_without_prefix.trim_start_matches('/'));
259
260 let Some(canonical_path) = self.resolve_path(&file_path) else {
262 return self.not_found_response();
263 };
264
265 let Ok(canonical_dir) = self.config.directory.canonicalize() else {
267 return self.not_found_response();
268 };
269
270 if !canonical_path.starts_with(&canonical_dir) {
271 return Response::with_status(StatusCode::FORBIDDEN)
272 .header("content-type", b"text/plain".to_vec())
273 .body(ResponseBody::Bytes(
274 b"Forbidden: Path traversal detected".to_vec(),
275 ));
276 }
277
278 if !self.config.show_hidden && has_hidden_component(&canonical_path) {
280 return self.not_found_response();
281 }
282
283 if canonical_path.is_dir() {
285 return self.serve_directory(&canonical_path, request_path);
286 }
287
288 self.serve_file(&canonical_path)
290 }
291
292 fn strip_prefix<'a>(&self, path: &'a str) -> &'a str {
294 if self.config.prefix.is_empty() {
295 return path;
296 }
297
298 path.strip_prefix(&self.config.prefix).unwrap_or(path)
299 }
300
301 fn resolve_path(&self, path: &Path) -> Option<PathBuf> {
303 if self.config.follow_symlinks {
304 path.canonicalize().ok()
306 } else {
307 if !path.exists() {
309 return None;
310 }
311
312 let canonical_dir = self.config.directory.canonicalize().ok()?;
314 let relative_path = path.strip_prefix(&self.config.directory).ok()?;
315
316 let mut current = canonical_dir.clone();
317 for component in relative_path.components() {
318 current.push(component);
319
320 let metadata = std::fs::symlink_metadata(¤t).ok()?;
322 if metadata.file_type().is_symlink() {
323 return None; }
325 }
326
327 Some(current)
329 }
330 }
331
332 fn serve_directory(&self, dir_path: &Path, request_path: &str) -> Response {
334 for index_file in &self.config.index_files {
336 if !is_safe_path(index_file) {
338 continue;
339 }
340 let index_path = dir_path.join(index_file);
341 if let Ok(canonical) = index_path.canonicalize() {
343 if !canonical.starts_with(dir_path) {
344 continue;
345 }
346 if canonical.is_file() {
347 return self.serve_file(&canonical);
348 }
349 }
350 }
351
352 if self.config.directory_listing {
354 return self.generate_directory_listing(dir_path, request_path);
355 }
356
357 self.not_found_response()
359 }
360
361 fn serve_file(&self, file_path: &Path) -> Response {
363 if !self.config.show_hidden && has_hidden_component(file_path) {
365 return self.not_found_response();
366 }
367
368 let Ok(contents) = std::fs::read(file_path) else {
370 return self.not_found_response();
371 };
372
373 let metadata = std::fs::metadata(file_path).ok();
375
376 let content_type = file_path
378 .extension()
379 .and_then(|ext| ext.to_str())
380 .map(mime_type_for_extension)
381 .unwrap_or("application/octet-stream");
382
383 let mut response = Response::ok()
384 .header("content-type", content_type.as_bytes().to_vec())
385 .header("accept-ranges", b"bytes".to_vec());
386
387 if self.config.enable_etag {
389 let etag = generate_etag(&contents);
390 response = response.header("etag", etag.into_bytes());
391 }
392
393 if self.config.enable_last_modified {
395 if let Some(ref meta) = metadata {
396 if let Ok(modified) = meta.modified() {
397 let http_date = format_http_date(modified);
398 response = response.header("last-modified", http_date.into_bytes());
399 }
400 }
401 }
402
403 for (name, value) in &self.config.extra_headers {
405 response = response.header(name.clone(), value.clone().into_bytes());
406 }
407
408 response.body(ResponseBody::Bytes(contents))
409 }
410
411 fn generate_directory_listing(&self, dir_path: &Path, request_path: &str) -> Response {
413 let mut entries = Vec::new();
414
415 if request_path != "/" && request_path != self.config.prefix {
417 entries.push(DirectoryEntry {
418 name: "..".to_string(),
419 is_dir: true,
420 size: 0,
421 modified: None,
422 });
423 }
424
425 if let Ok(read_dir) = std::fs::read_dir(dir_path) {
427 for entry in read_dir.flatten() {
428 let name = entry.file_name().to_string_lossy().to_string();
429
430 if !self.config.show_hidden && name.starts_with('.') {
432 continue;
433 }
434
435 if let Ok(metadata) = entry.metadata() {
436 entries.push(DirectoryEntry {
437 name,
438 is_dir: metadata.is_dir(),
439 size: metadata.len(),
440 modified: metadata.modified().ok(),
441 });
442 }
443 }
444 }
445
446 entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
448 (true, false) => std::cmp::Ordering::Less,
449 (false, true) => std::cmp::Ordering::Greater,
450 _ => a.name.cmp(&b.name),
451 });
452
453 let html = generate_listing_html(request_path, &entries);
455
456 Response::ok()
457 .header("content-type", b"text/html; charset=utf-8".to_vec())
458 .body(ResponseBody::Bytes(html.into_bytes()))
459 }
460
461 fn not_found_response(&self) -> Response {
463 if let Some(ref not_found_path) = self.config.not_found_page {
464 if is_safe_path(not_found_path) {
466 let path = self.config.directory.join(not_found_path);
467 if let Ok(canonical) = path.canonicalize() {
469 if let Ok(base_canonical) = self.config.directory.canonicalize() {
470 if canonical.starts_with(&base_canonical) {
471 if let Ok(contents) = std::fs::read(&canonical) {
472 let content_type = canonical
473 .extension()
474 .and_then(|ext| ext.to_str())
475 .map(mime_type_for_extension)
476 .unwrap_or("text/html; charset=utf-8");
477
478 return Response::with_status(StatusCode::NOT_FOUND)
479 .header("content-type", content_type.as_bytes().to_vec())
480 .body(ResponseBody::Bytes(contents));
481 }
482 }
483 }
484 }
485 }
486 }
487
488 Response::with_status(StatusCode::NOT_FOUND)
489 .header("content-type", b"text/plain".to_vec())
490 .body(ResponseBody::Bytes(b"Not Found".to_vec()))
491 }
492}
493
494fn has_hidden_component(path: &Path) -> bool {
496 path.components().any(|c| {
497 c.as_os_str()
498 .to_str()
499 .is_some_and(|s| s.starts_with('.') && s != "." && s != "..")
500 })
501}
502
503fn is_safe_path(path: &str) -> bool {
505 if path.contains('\0') {
507 return false;
508 }
509
510 let decoded = percent_decode(path);
512
513 if decoded.contains('\0') {
515 return false;
516 }
517
518 for component in decoded.split('/') {
520 if component == ".." {
521 return false;
522 }
523 }
524
525 true
526}
527
528fn percent_decode(s: &str) -> String {
533 let bytes = s.as_bytes();
534 let mut decoded_bytes = Vec::with_capacity(bytes.len());
535 let mut i = 0;
536
537 while i < bytes.len() {
538 if bytes[i] == b'%' && i + 2 < bytes.len() {
539 let hi = hex_val(bytes[i + 1]);
540 let lo = hex_val(bytes[i + 2]);
541 if let (Some(h), Some(l)) = (hi, lo) {
542 decoded_bytes.push((h << 4) | l);
543 i += 3;
544 continue;
545 }
546 }
547 decoded_bytes.push(bytes[i]);
548 i += 1;
549 }
550
551 String::from_utf8_lossy(&decoded_bytes).into_owned()
552}
553
554fn hex_val(b: u8) -> Option<u8> {
555 match b {
556 b'0'..=b'9' => Some(b - b'0'),
557 b'a'..=b'f' => Some(b - b'a' + 10),
558 b'A'..=b'F' => Some(b - b'A' + 10),
559 _ => None,
560 }
561}
562
563fn generate_etag(contents: &[u8]) -> String {
565 const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
566 const FNV_PRIME: u64 = 0x100000001b3;
567
568 let mut hash = FNV_OFFSET_BASIS;
569 for &byte in contents {
570 hash ^= u64::from(byte);
571 hash = hash.wrapping_mul(FNV_PRIME);
572 }
573
574 format!("\"{:016x}\"", hash)
575}
576
577fn format_http_date(time: SystemTime) -> String {
579 match time.duration_since(std::time::UNIX_EPOCH) {
580 Ok(duration) => {
581 let secs = duration.as_secs();
582 let days = secs / 86400;
583 let remaining_secs = secs % 86400;
584 let hours = remaining_secs / 3600;
585 let minutes = (remaining_secs % 3600) / 60;
586 let seconds = remaining_secs % 60;
587
588 let day_of_week = ((days + 4) % 7) as usize;
589 let day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
590
591 let (year, month, day) = days_to_date(days);
592 let month_names = [
593 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
594 ];
595
596 format!(
597 "{}, {:02} {} {} {:02}:{:02}:{:02} GMT",
598 day_names[day_of_week],
599 day,
600 month_names[(month - 1) as usize],
601 year,
602 hours,
603 minutes,
604 seconds
605 )
606 }
607 Err(_) => "Thu, 01 Jan 1970 00:00:00 GMT".to_string(),
608 }
609}
610
611fn days_to_date(days: u64) -> (u64, u64, u64) {
613 let mut remaining_days = days;
614 let mut year = 1970u64;
615
616 loop {
617 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
618 if remaining_days < days_in_year {
619 break;
620 }
621 remaining_days -= days_in_year;
622 year += 1;
623 }
624
625 let leap = is_leap_year(year);
626 let month_days: [u64; 12] = if leap {
627 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
628 } else {
629 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
630 };
631
632 let mut month = 1u64;
633 for &days_in_month in &month_days {
634 if remaining_days < days_in_month {
635 break;
636 }
637 remaining_days -= days_in_month;
638 month += 1;
639 }
640
641 (year, month, remaining_days + 1)
642}
643
644fn is_leap_year(year: u64) -> bool {
646 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
647}
648
649struct DirectoryEntry {
651 name: String,
652 is_dir: bool,
653 size: u64,
654 modified: Option<SystemTime>,
655}
656
657fn generate_listing_html(path: &str, entries: &[DirectoryEntry]) -> String {
659 let mut html = String::new();
660
661 html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
662 html.push_str("<meta charset=\"utf-8\">\n");
663 html.push_str(&format!("<title>Index of {}</title>\n", escape_html(path)));
664 html.push_str("<style>\n");
665 html.push_str("body { font-family: monospace; margin: 20px; }\n");
666 html.push_str("h1 { border-bottom: 1px solid #ccc; padding-bottom: 10px; }\n");
667 html.push_str("table { border-collapse: collapse; width: 100%; }\n");
668 html.push_str("th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; }\n");
669 html.push_str("th { background: #f5f5f5; }\n");
670 html.push_str("a { text-decoration: none; color: #0066cc; }\n");
671 html.push_str("a:hover { text-decoration: underline; }\n");
672 html.push_str(".dir { font-weight: bold; }\n");
673 html.push_str(".size { text-align: right; }\n");
674 html.push_str("</style>\n");
675 html.push_str("</head>\n<body>\n");
676
677 html.push_str(&format!("<h1>Index of {}</h1>\n", escape_html(path)));
678 html.push_str("<table>\n");
679 html.push_str("<tr><th>Name</th><th>Size</th><th>Modified</th></tr>\n");
680
681 for entry in entries {
682 let href = if entry.name == ".." {
683 parent_path(path)
684 } else if entry.is_dir {
685 format!("{}/{}/", path.trim_end_matches('/'), &entry.name)
686 } else {
687 format!("{}/{}", path.trim_end_matches('/'), &entry.name)
688 };
689
690 let class = if entry.is_dir { " class=\"dir\"" } else { "" };
691 let display_name = if entry.is_dir {
692 format!("{}/", &entry.name)
693 } else {
694 entry.name.clone()
695 };
696
697 let size_str = if entry.is_dir {
698 "-".to_string()
699 } else {
700 format_size(entry.size)
701 };
702
703 let modified_str = entry
704 .modified
705 .map(|t| format_http_date(t))
706 .unwrap_or_else(|| "-".to_string());
707
708 html.push_str(&format!(
709 "<tr><td{}><a href=\"{}\">{}</a></td><td class=\"size\">{}</td><td>{}</td></tr>\n",
710 class,
711 escape_html(&href),
712 escape_html(&display_name),
713 size_str,
714 modified_str
715 ));
716 }
717
718 html.push_str("</table>\n");
719 html.push_str("<hr>\n<p>fastapi_rust static file server</p>\n");
720 html.push_str("</body>\n</html>");
721
722 html
723}
724
725fn parent_path(path: &str) -> String {
727 let trimmed = path.trim_end_matches('/');
728 match trimmed.rfind('/') {
729 Some(pos) if pos > 0 => format!("{}/", &trimmed[..pos]),
730 _ => "/".to_string(),
731 }
732}
733
734fn escape_html(s: &str) -> String {
736 s.replace('&', "&")
737 .replace('<', "<")
738 .replace('>', ">")
739 .replace('"', """)
740}
741
742#[allow(clippy::cast_precision_loss)]
744fn format_size(size: u64) -> String {
745 const KB: u64 = 1024;
746 const MB: u64 = 1024 * KB;
747 const GB: u64 = 1024 * MB;
748
749 if size >= GB {
750 format!("{:.1}G", size as f64 / GB as f64)
751 } else if size >= MB {
752 format!("{:.1}M", size as f64 / MB as f64)
753 } else if size >= KB {
754 format!("{:.1}K", size as f64 / KB as f64)
755 } else {
756 format!("{}", size)
757 }
758}
759
760#[cfg(test)]
765mod tests {
766 use super::*;
767
768 #[test]
769 fn safe_path_normal() {
770 assert!(is_safe_path("/static/css/style.css"));
771 assert!(is_safe_path("/images/logo.png"));
772 assert!(is_safe_path("/"));
773 assert!(is_safe_path(""));
774 }
775
776 #[test]
777 fn safe_path_traversal_blocked() {
778 assert!(!is_safe_path("/../etc/passwd"));
779 assert!(!is_safe_path("/static/../../../etc/passwd"));
780 assert!(!is_safe_path(".."));
781 assert!(!is_safe_path("/.."));
782 }
783
784 #[test]
785 fn safe_path_encoded_traversal_blocked() {
786 assert!(!is_safe_path("/%2e%2e/etc/passwd"));
787 assert!(!is_safe_path("/static/%2e%2e/%2e%2e/etc/passwd"));
788 }
789
790 #[test]
791 fn safe_path_allows_double_dots_in_filename() {
792 assert!(is_safe_path("/files/test..data.txt"));
794 assert!(is_safe_path("/files/archive..tar.gz"));
795 assert!(is_safe_path("/files/version..1.2.txt"));
796 }
797
798 #[test]
799 fn safe_path_null_byte_blocked() {
800 assert!(!is_safe_path("/static/file\0.txt"));
801 }
802
803 #[test]
804 fn percent_decode_works() {
805 assert_eq!(percent_decode("%2e%2e"), "..");
806 assert_eq!(percent_decode("%2F"), "/");
807 assert_eq!(percent_decode("hello%20world"), "hello world");
808 assert_eq!(percent_decode("normal"), "normal");
809 }
810
811 #[test]
812 fn etag_generation() {
813 let contents = b"Hello, World!";
814 let etag = generate_etag(contents);
815 assert!(etag.starts_with('"'));
816 assert!(etag.ends_with('"'));
817 assert_eq!(etag.len(), 18); }
819
820 #[test]
821 fn etag_deterministic() {
822 let contents = b"test data";
823 let etag1 = generate_etag(contents);
824 let etag2 = generate_etag(contents);
825 assert_eq!(etag1, etag2);
826 }
827
828 #[test]
829 fn etag_different_for_different_content() {
830 let etag1 = generate_etag(b"content 1");
831 let etag2 = generate_etag(b"content 2");
832 assert_ne!(etag1, etag2);
833 }
834
835 #[test]
836 fn format_size_bytes() {
837 assert_eq!(format_size(0), "0");
838 assert_eq!(format_size(100), "100");
839 assert_eq!(format_size(1023), "1023");
840 }
841
842 #[test]
843 fn format_size_kb() {
844 assert_eq!(format_size(1024), "1.0K");
845 assert_eq!(format_size(2048), "2.0K");
846 assert_eq!(format_size(1536), "1.5K");
847 }
848
849 #[test]
850 fn format_size_mb() {
851 assert_eq!(format_size(1024 * 1024), "1.0M");
852 assert_eq!(format_size(5 * 1024 * 1024), "5.0M");
853 }
854
855 #[test]
856 fn format_size_gb() {
857 assert_eq!(format_size(1024 * 1024 * 1024), "1.0G");
858 assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2.0G");
859 }
860
861 #[test]
862 fn escape_html_special_chars() {
863 assert_eq!(escape_html("<script>"), "<script>");
864 assert_eq!(escape_html("a & b"), "a & b");
865 assert_eq!(escape_html("\"quoted\""), ""quoted"");
866 }
867
868 #[test]
869 fn parent_path_normal() {
870 assert_eq!(parent_path("/static/css/"), "/static/");
871 assert_eq!(parent_path("/static/"), "/");
872 assert_eq!(parent_path("/"), "/");
873 }
874
875 #[test]
876 fn config_builder() {
877 let config = StaticFilesConfig::new("./public")
878 .prefix("/static")
879 .show_hidden(false)
880 .directory_listing(true);
881
882 assert_eq!(config.directory, PathBuf::from("./public"));
883 assert_eq!(config.prefix, "/static");
884 assert!(!config.show_hidden);
885 assert!(config.directory_listing);
886 }
887
888 #[test]
889 fn static_files_builder() {
890 let handler = StaticFiles::new("./assets")
891 .prefix("/assets")
892 .index_file("index.htm")
893 .enable_directory_listing();
894
895 assert_eq!(handler.config.prefix, "/assets");
896 assert_eq!(handler.config.index_files, vec!["index.htm"]);
897 assert!(handler.config.directory_listing);
898 }
899
900 #[test]
901 fn leap_year_detection() {
902 assert!(!is_leap_year(1900)); assert!(is_leap_year(2000)); assert!(is_leap_year(2024)); assert!(!is_leap_year(2023)); }
907
908 #[test]
909 fn http_date_format() {
910 let date = format_http_date(std::time::UNIX_EPOCH);
911 assert_eq!(date, "Thu, 01 Jan 1970 00:00:00 GMT");
912 }
913}