1use axum::Router;
48use std::path::{Path, PathBuf};
49use tower_http::services::{ServeDir, ServeFile};
50
51pub fn static_dir(path: impl AsRef<Path>) -> ServeDir {
60 ServeDir::new(path)
61}
62
63pub fn static_dir_with_index(path: impl AsRef<Path>) -> ServeDir {
67 ServeDir::new(path).append_index_html_on_directories(true)
68}
69
70pub fn static_dir_spa(path: impl AsRef<Path>) -> ServeDir<ServeFile> {
74 let index = path.as_ref().join("index.html");
75 ServeDir::new(path).fallback(ServeFile::new(index))
76}
77
78pub fn static_router(prefix: &str, path: impl AsRef<Path>) -> Router {
82 Router::new().nest_service(prefix, ServeDir::new(path))
83}
84
85pub fn static_router_with_index(prefix: &str, path: impl AsRef<Path>) -> Router {
87 Router::new().nest_service(prefix, static_dir_with_index(path))
88}
89
90pub fn static_router_spa(path: impl AsRef<Path>) -> Router {
94 Router::new().fallback_service(static_dir_spa(path))
95}
96
97pub fn static_file(path: impl AsRef<Path>) -> ServeFile {
99 ServeFile::new(path)
100}
101
102const MIME_TYPES: &[(&str, &str)] = &[
111 ("html", "text/html"),
113 ("htm", "text/html"),
114 ("shtml", "text/html"),
115 ("css", "text/css"),
116 ("xml", "text/xml"),
117 ("txt", "text/plain"),
118 ("md", "text/markdown"),
119 ("csv", "text/csv"),
120 ("js", "application/javascript"),
122 ("mjs", "application/javascript"),
123 ("json", "application/json"),
124 ("png", "image/png"),
126 ("jpg", "image/jpeg"),
127 ("jpeg", "image/jpeg"),
128 ("gif", "image/gif"),
129 ("bmp", "image/bmp"),
130 ("ico", "image/x-icon"),
131 ("svg", "image/svg+xml"),
132 ("webp", "image/webp"),
133 ("avif", "image/avif"),
134 ("mp3", "audio/mpeg"),
136 ("wav", "audio/wav"),
137 ("ogg", "audio/ogg"),
138 ("mp4", "video/mp4"),
139 ("webm", "video/webm"),
140 ("m3u8", "application/vnd.apple.mpegurl"),
141 ("ts", "video/mp2t"),
142 ("woff", "font/woff"),
144 ("woff2", "font/woff2"),
145 ("ttf", "font/ttf"),
146 ("otf", "font/otf"),
147 ("eot", "application/vnd.ms-fontobject"),
148 ("pdf", "application/pdf"),
150 ("zip", "application/zip"),
151 ("gz", "application/gzip"),
152 ("tar", "application/x-tar"),
153 ("rar", "application/vnd.rar"),
154 ("7z", "application/x-7z-compressed"),
155 ("wasm", "application/wasm"),
157 ("swf", "application/x-shockwave-flash"),
159 ("doc", "application/msword"),
160 (
161 "docx",
162 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
163 ),
164 ("xls", "application/vnd.ms-excel"),
165 (
166 "xlsx",
167 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
168 ),
169 ("ppt", "application/vnd.ms-powerpoint"),
170 (
171 "pptx",
172 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
173 ),
174];
175
176pub fn mime_type_for_extension(ext: &str) -> Option<&'static str> {
180 let ext_lower = ext.to_lowercase();
181 MIME_TYPES
182 .iter()
183 .find(|(k, _)| *k == ext_lower)
184 .map(|(_, v)| *v)
185}
186
187pub fn mime_type_for_path(path: &Path) -> Option<String> {
192 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
194 if let Some(mime) = mime_type_for_extension(ext) {
195 return Some(mime.to_string());
196 }
197 }
198 mime_guess::from_path(path).first().map(|m| m.to_string())
200}
201
202#[derive(Debug, Clone, PartialEq)]
204pub struct RangeSpec {
205 pub start: u64,
207 pub end: u64,
209}
210
211#[derive(Debug, Clone, PartialEq)]
213pub enum RangeError {
214 InvalidFormat,
216 InvalidRange,
218 Unsatisfiable,
220}
221
222pub fn parse_range_header(range: &str, file_size: u64) -> Result<RangeSpec, RangeError> {
232 let range = range.trim();
234 let range_value = range
235 .strip_prefix("bytes=")
236 .ok_or(RangeError::InvalidFormat)?;
237
238 let (start_str, end_str) = range_value
240 .split_once('-')
241 .ok_or(RangeError::InvalidFormat)?;
242
243 let (start, end) = match (start_str.is_empty(), end_str.is_empty()) {
244 (true, false) => {
246 let suffix: u64 = end_str.parse().map_err(|_| RangeError::InvalidRange)?;
247 if suffix == 0 {
248 return Err(RangeError::InvalidRange);
249 }
250 let start = file_size.saturating_sub(suffix);
251 (start, file_size.saturating_sub(1))
252 }
253 (false, true) => {
255 let start: u64 = start_str.parse().map_err(|_| RangeError::InvalidRange)?;
256 if start >= file_size {
257 return Err(RangeError::Unsatisfiable);
258 }
259 (start, file_size.saturating_sub(1))
260 }
261 (false, false) => {
263 let start: u64 = start_str.parse().map_err(|_| RangeError::InvalidRange)?;
264 let end: u64 = end_str.parse().map_err(|_| RangeError::InvalidRange)?;
265 if start > end {
266 return Err(RangeError::InvalidRange);
267 }
268 if start >= file_size {
269 return Err(RangeError::Unsatisfiable);
270 }
271 let end = end.min(file_size.saturating_sub(1));
273 (start, end)
274 }
275 (true, true) => return Err(RangeError::InvalidRange),
277 };
278
279 Ok(RangeSpec { start, end })
280}
281
282pub fn is_path_safe(path: &Path, root: &Path) -> bool {
287 let canonical_root = match root.canonicalize() {
289 Ok(p) => p,
290 Err(_) => return false,
291 };
292 let canonical_path = match path.canonicalize() {
293 Ok(p) => p,
294 Err(_) => return false,
295 };
296 canonical_path.starts_with(&canonical_root)
298}
299
300fn has_traversal_component(path: &Path) -> bool {
305 use std::path::Component;
306 path.components().any(|c| matches!(c, Component::ParentDir))
307}
308
309fn format_http_date(timestamp: std::time::SystemTime) -> String {
314 use std::time::UNIX_EPOCH;
315 let secs = timestamp
316 .duration_since(UNIX_EPOCH)
317 .map(|d| d.as_secs())
318 .unwrap_or(0);
319
320 let (year, month, day, hour, minute, second, weekday) = secs_to_date_time(secs);
323
324 let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
325 let months = [
326 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
327 ];
328
329 format!(
330 "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
331 weekdays[weekday as usize],
332 day,
333 months[(month - 1) as usize],
334 year,
335 hour,
336 minute,
337 second,
338 )
339}
340
341fn secs_to_date_time(secs: u64) -> (u64, u64, u64, u64, u64, u64, u64) {
346 let secs_in_day = 86400u64;
347 let mut days = secs / secs_in_day;
348 let remainder = secs % secs_in_day;
349
350 let hour = remainder / 3600;
351 let minute = (remainder % 3600) / 60;
352 let second = remainder % 60;
353
354 let weekday = (days + 4) % 7;
356
357 days += 719468; let era = days / 146097;
360 let doe = days - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
364 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if m <= 2 { y + 1 } else { y };
369
370 (year, m, d, hour, minute, second, weekday)
371}
372
373pub async fn serve_file(path: &Path, headers: &axum::http::HeaderMap) -> axum::response::Response {
397 use axum::body::Body;
398 use axum::http::{header, StatusCode};
399 use axum::response::IntoResponse;
400
401 if has_traversal_component(path) {
404 return (StatusCode::NOT_FOUND, "Not found").into_response();
405 }
406
407 if !path.is_file() {
409 return (StatusCode::NOT_FOUND, "File not found").into_response();
410 }
411
412 let metadata = match tokio::fs::metadata(path).await {
414 Ok(m) => m,
415 Err(_) => {
416 return (
417 StatusCode::INTERNAL_SERVER_ERROR,
418 "Failed to read file metadata",
419 )
420 .into_response()
421 }
422 };
423 let file_size = metadata.len();
424 let modified = metadata.modified().ok();
425
426 if let Some(modified_time) = modified {
428 let last_modified = format_http_date(modified_time);
429 if let Some(if_modified_since) = headers.get(header::IF_MODIFIED_SINCE) {
430 if let Ok(ims_str) = if_modified_since.to_str() {
431 if ims_str.trim() == last_modified {
432 return (
433 StatusCode::NOT_MODIFIED,
434 [(header::LAST_MODIFIED, last_modified.as_str())],
435 Body::empty(),
436 )
437 .into_response();
438 }
439 }
440 }
441 }
442
443 let mime = mime_type_for_path(path);
445 let content_type = mime
446 .clone()
447 .unwrap_or_else(|| "application/octet-stream".to_string());
448
449 let content = match tokio::fs::read(path).await {
451 Ok(c) => c,
452 Err(_) => {
453 return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response()
454 }
455 };
456
457 if let Some(range_header) = headers.get(header::RANGE) {
459 if let Ok(range_str) = range_header.to_str() {
460 match parse_range_header(range_str, file_size) {
461 Ok(range) => {
462 let content_length = range.end - range.start + 1;
463 let bytes = content
464 .get(range.start as usize..=(range.end as usize))
465 .unwrap_or(&[]);
466 let content_range =
467 format!("bytes {}-{}/{}", range.start, range.end, file_size);
468 let content_length_str = content_length.to_string();
469
470 let mut response = (
471 StatusCode::PARTIAL_CONTENT,
472 [
473 (header::CONTENT_TYPE, content_type.as_str()),
474 (header::CONTENT_LENGTH, content_length_str.as_str()),
475 (header::CONTENT_RANGE, content_range.as_str()),
476 (header::ACCEPT_RANGES, "bytes"),
477 ],
478 Body::from(bytes.to_vec()),
479 )
480 .into_response();
481
482 if let Some(modified_time) = modified {
483 let last_modified = format_http_date(modified_time);
484 if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
485 response.headers_mut().insert(header::LAST_MODIFIED, val);
486 }
487 }
488 return response;
489 }
490 Err(RangeError::Unsatisfiable) => {
491 let content_range = format!("bytes */{}", file_size);
492 return (
493 StatusCode::RANGE_NOT_SATISFIABLE,
494 [(header::CONTENT_RANGE, content_range.as_str())],
495 Body::empty(),
496 )
497 .into_response();
498 }
499 Err(_) => {
500 }
502 }
503 }
504 }
505
506 let content_length_str = file_size.to_string();
508 let mut response = (
509 StatusCode::OK,
510 [
511 (header::CONTENT_TYPE, content_type.as_str()),
512 (header::CONTENT_LENGTH, content_length_str.as_str()),
513 (header::ACCEPT_RANGES, "bytes"),
514 ],
515 Body::from(content),
516 )
517 .into_response();
518
519 if let Some(modified_time) = modified {
521 let last_modified = format_http_date(modified_time);
522 if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
523 response.headers_mut().insert(header::LAST_MODIFIED, val);
524 }
525 }
526
527 if mime.is_none() {
529 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
530 let disposition = format!("attachment; filename=\"{}\"", filename);
531 if let Ok(val) = axum::http::HeaderValue::from_str(&disposition) {
532 response
533 .headers_mut()
534 .insert(header::CONTENT_DISPOSITION, val);
535 }
536 }
537 }
538
539 response
540}
541
542pub async fn static_handler(
555 root: &Path,
556 uri_path: &str,
557 headers: &axum::http::HeaderMap,
558) -> axum::response::Response {
559 use axum::http::StatusCode;
560 use axum::response::IntoResponse;
561
562 let path_only = uri_path.split('?').next().unwrap_or(uri_path);
564
565 let decoded = percent_decode(path_only);
567
568 let relative = decoded.trim_start_matches('/');
572 let file_path: PathBuf = root.join(relative);
573
574 if !is_path_safe(&file_path, root) {
576 return (StatusCode::NOT_FOUND, "Not found").into_response();
577 }
578
579 serve_file(&file_path, headers).await
581}
582
583fn percent_decode(input: &str) -> String {
587 let bytes = input.as_bytes();
588 let mut result = Vec::with_capacity(bytes.len());
589
590 let mut i = 0;
591 while i < bytes.len() {
592 if bytes[i] == b'%' && i + 2 < bytes.len() {
593 if let (Some(h), Some(l)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
594 result.push(h * 16 + l);
595 i += 3;
596 continue;
597 }
598 }
599 result.push(bytes[i]);
602 i += 1;
603 }
604
605 String::from_utf8_lossy(&result).into_owned()
606}
607
608fn hex_digit(b: u8) -> Option<u8> {
610 match b {
611 b'0'..=b'9' => Some(b - b'0'),
612 b'a'..=b'f' => Some(b - b'a' + 10),
613 b'A'..=b'F' => Some(b - b'A' + 10),
614 _ => None,
615 }
616}
617
618#[derive(Debug, Clone, Default)]
630pub struct CacheControlConfig {
631 pub max_age: Option<u64>,
633 pub visibility: Option<CacheVisibility>,
635 pub no_cache: bool,
637 pub no_store: bool,
639 pub must_revalidate: bool,
641 pub immutable: bool,
643}
644
645#[derive(Debug, Clone, Copy, PartialEq, Eq)]
647pub enum CacheVisibility {
648 Public,
650 Private,
652}
653
654impl CacheControlConfig {
655 pub fn new() -> Self {
657 Self::default()
658 }
659
660 pub fn with_max_age(mut self, seconds: u64) -> Self {
662 self.max_age = Some(seconds);
663 self
664 }
665
666 pub fn with_public(mut self) -> Self {
668 self.visibility = Some(CacheVisibility::Public);
669 self
670 }
671
672 pub fn with_private(mut self) -> Self {
674 self.visibility = Some(CacheVisibility::Private);
675 self
676 }
677
678 pub fn with_no_cache(mut self) -> Self {
680 self.no_cache = true;
681 self
682 }
683
684 pub fn with_no_store(mut self) -> Self {
686 self.no_store = true;
687 self
688 }
689
690 pub fn with_must_revalidate(mut self) -> Self {
692 self.must_revalidate = true;
693 self
694 }
695
696 pub fn with_immutable(mut self) -> Self {
698 self.immutable = true;
699 self
700 }
701
702 pub fn to_header_value(&self) -> Option<String> {
706 let mut directives = Vec::new();
707
708 if self.no_store {
709 directives.push("no-store".to_string());
710 }
711 if self.no_cache {
712 directives.push("no-cache".to_string());
713 }
714 if let Some(v) = self.visibility {
715 match v {
716 CacheVisibility::Public => directives.push("public".to_string()),
717 CacheVisibility::Private => directives.push("private".to_string()),
718 }
719 }
720 if let Some(max_age) = self.max_age {
721 directives.push(format!("max-age={}", max_age));
722 }
723 if self.must_revalidate {
724 directives.push("must-revalidate".to_string());
725 }
726 if self.immutable {
727 directives.push("immutable".to_string());
728 }
729
730 if directives.is_empty() {
731 None
732 } else {
733 Some(directives.join(", "))
734 }
735 }
736}
737
738pub fn compute_etag(metadata: &std::fs::Metadata) -> Option<String> {
750 let modified = metadata.modified().ok()?;
751 let secs = modified
752 .duration_since(std::time::UNIX_EPOCH)
753 .map(|d| d.as_secs())
754 .unwrap_or(0);
755 let size = metadata.len();
756 Some(format!("W/\"{}-{}\"", secs, size))
757}
758
759pub fn fingerprint_file(path: &Path) -> std::io::Result<String> {
771 let content = std::fs::read(path)?;
772 Ok(fingerprint_bytes(&content))
773}
774
775pub fn fingerprint_bytes(content: &[u8]) -> String {
779 use md5::{Digest, Md5};
780 let mut hasher = Md5::new();
781 hasher.update(content);
782 let result = hasher.finalize();
783 let mut hex = String::with_capacity(32);
785 for byte in result.iter() {
786 hex.push_str(&format!("{:02x}", byte));
787 }
788 hex
789}
790
791pub fn extract_version_hash(path: &str) -> Option<(String, String)> {
816 let last_dot = path.rfind('.')?;
818 let ext = &path[last_dot + 1..];
819 if ext.is_empty() {
820 return None;
821 }
822
823 let stem_with_hash = &path[..last_dot];
825 let second_last_dot = stem_with_hash.rfind('.')?;
826
827 let stem = &stem_with_hash[..second_last_dot];
828 let hash = &stem_with_hash[second_last_dot + 1..];
829
830 if hash.len() < 8 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
832 return None;
833 }
834
835 Some((format!("{}.{}", stem, ext), hash.to_string()))
836}
837
838pub async fn serve_file_with_cache(
857 path: &Path,
858 headers: &axum::http::HeaderMap,
859 cache_config: Option<&CacheControlConfig>,
860) -> axum::response::Response {
861 use axum::body::Body;
862 use axum::http::{header, StatusCode};
863 use axum::response::IntoResponse;
864
865 if has_traversal_component(path) {
867 return (StatusCode::NOT_FOUND, "Not found").into_response();
868 }
869
870 if !path.is_file() {
872 return (StatusCode::NOT_FOUND, "File not found").into_response();
873 }
874
875 let metadata = match tokio::fs::metadata(path).await {
877 Ok(m) => m,
878 Err(_) => {
879 return (
880 StatusCode::INTERNAL_SERVER_ERROR,
881 "Failed to read file metadata",
882 )
883 .into_response();
884 }
885 };
886 let file_size = metadata.len();
887 let modified = metadata.modified().ok();
888
889 let etag = compute_etag(&metadata);
891
892 let mut if_none_match_present = false;
895 if let Some(ref etag_value) = etag {
896 if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
897 if_none_match_present = true;
898 if let Ok(inm_str) = if_none_match.to_str() {
899 if inm_str.trim() == "*" || inm_str.trim() == etag_value.as_str() {
901 let mut response = (
902 StatusCode::NOT_MODIFIED,
903 [(header::ETAG, etag_value.as_str())],
904 Body::empty(),
905 )
906 .into_response();
907 if let Some(modified_time) = modified {
909 let last_modified = format_http_date(modified_time);
910 if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
911 response.headers_mut().insert(header::LAST_MODIFIED, val);
912 }
913 }
914 if let Some(cc) = cache_config {
915 if let Some(cc_value) = cc.to_header_value() {
916 if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
917 response.headers_mut().insert(header::CACHE_CONTROL, val);
918 }
919 }
920 }
921 return response;
922 }
923 }
924 }
925 }
926
927 if !if_none_match_present {
930 if let Some(modified_time) = modified {
931 let last_modified = format_http_date(modified_time);
932 if let Some(if_modified_since) = headers.get(header::IF_MODIFIED_SINCE) {
933 if let Ok(ims_str) = if_modified_since.to_str() {
934 if ims_str.trim() == last_modified {
935 let mut response = (
936 StatusCode::NOT_MODIFIED,
937 [(header::LAST_MODIFIED, last_modified.as_str())],
938 Body::empty(),
939 )
940 .into_response();
941 if let Some(ref etag_value) = etag {
942 if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
943 response.headers_mut().insert(header::ETAG, val);
944 }
945 }
946 if let Some(cc) = cache_config {
947 if let Some(cc_value) = cc.to_header_value() {
948 if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
949 response.headers_mut().insert(header::CACHE_CONTROL, val);
950 }
951 }
952 }
953 return response;
954 }
955 }
956 }
957 }
958 }
959
960 let mime = mime_type_for_path(path);
962 let content_type = mime
963 .clone()
964 .unwrap_or_else(|| "application/octet-stream".to_string());
965
966 let content = match tokio::fs::read(path).await {
968 Ok(c) => c,
969 Err(_) => {
970 return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response()
971 }
972 };
973
974 if let Some(range_header) = headers.get(header::RANGE) {
976 if let Ok(range_str) = range_header.to_str() {
977 match parse_range_header(range_str, file_size) {
978 Ok(range) => {
979 let content_length = range.end - range.start + 1;
980 let bytes = content
981 .get(range.start as usize..=(range.end as usize))
982 .unwrap_or(&[]);
983 let content_range =
984 format!("bytes {}-{}/{}", range.start, range.end, file_size);
985 let content_length_str = content_length.to_string();
986
987 let mut response = (
988 StatusCode::PARTIAL_CONTENT,
989 [
990 (header::CONTENT_TYPE, content_type.as_str()),
991 (header::CONTENT_LENGTH, content_length_str.as_str()),
992 (header::CONTENT_RANGE, content_range.as_str()),
993 (header::ACCEPT_RANGES, "bytes"),
994 ],
995 Body::from(bytes.to_vec()),
996 )
997 .into_response();
998
999 if let Some(modified_time) = modified {
1000 let last_modified = format_http_date(modified_time);
1001 if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
1002 response.headers_mut().insert(header::LAST_MODIFIED, val);
1003 }
1004 }
1005 if let Some(ref etag_value) = etag {
1006 if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
1007 response.headers_mut().insert(header::ETAG, val);
1008 }
1009 }
1010 if let Some(cc) = cache_config {
1011 if let Some(cc_value) = cc.to_header_value() {
1012 if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
1013 response.headers_mut().insert(header::CACHE_CONTROL, val);
1014 }
1015 }
1016 }
1017 return response;
1018 }
1019 Err(RangeError::Unsatisfiable) => {
1020 let content_range = format!("bytes */{}", file_size);
1021 return (
1022 StatusCode::RANGE_NOT_SATISFIABLE,
1023 [(header::CONTENT_RANGE, content_range.as_str())],
1024 Body::empty(),
1025 )
1026 .into_response();
1027 }
1028 Err(_) => {
1029 }
1031 }
1032 }
1033 }
1034
1035 let content_length_str = file_size.to_string();
1037 let mut response = (
1038 StatusCode::OK,
1039 [
1040 (header::CONTENT_TYPE, content_type.as_str()),
1041 (header::CONTENT_LENGTH, content_length_str.as_str()),
1042 (header::ACCEPT_RANGES, "bytes"),
1043 ],
1044 Body::from(content),
1045 )
1046 .into_response();
1047
1048 if let Some(modified_time) = modified {
1050 let last_modified = format_http_date(modified_time);
1051 if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
1052 response.headers_mut().insert(header::LAST_MODIFIED, val);
1053 }
1054 }
1055
1056 if let Some(ref etag_value) = etag {
1058 if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
1059 response.headers_mut().insert(header::ETAG, val);
1060 }
1061 }
1062
1063 if let Some(cc) = cache_config {
1065 if let Some(cc_value) = cc.to_header_value() {
1066 if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
1067 response.headers_mut().insert(header::CACHE_CONTROL, val);
1068 }
1069 }
1070 }
1071
1072 if mime.is_none() {
1074 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
1075 let disposition = format!("attachment; filename=\"{}\"", filename);
1076 if let Ok(val) = axum::http::HeaderValue::from_str(&disposition) {
1077 response
1078 .headers_mut()
1079 .insert(header::CONTENT_DISPOSITION, val);
1080 }
1081 }
1082 }
1083
1084 response
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089 use super::*;
1090 use axum::body::Body;
1091 use axum::http::{Method, Request, StatusCode};
1092 use http_body_util::BodyExt;
1093 use std::fs;
1094 use std::path::PathBuf;
1095 use tempfile::TempDir;
1096 use tower::ServiceExt;
1097
1098 fn create_test_dir() -> TempDir {
1100 let dir = tempfile::tempdir().expect("failed to create temp dir");
1101 let root = dir.path();
1102
1103 fs::write(root.join("index.html"), "<html>index</html>").unwrap();
1105 fs::write(root.join("style.css"), "body { color: red; }").unwrap();
1107 fs::create_dir_all(root.join("js")).unwrap();
1109 fs::write(root.join("js").join("app.js"), "console.log('hello');").unwrap();
1110 dir
1111 }
1112
1113 async fn send_get(router: Router, uri: &str) -> (StatusCode, Vec<u8>) {
1114 let req = Request::builder()
1115 .method(Method::GET)
1116 .uri(uri)
1117 .body(Body::empty())
1118 .unwrap();
1119 let resp = router.oneshot(req).await.unwrap();
1120 let status = resp.status();
1121 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1122 (status, bytes.to_vec())
1123 }
1124
1125 async fn send_get_with_headers(
1126 router: Router,
1127 uri: &str,
1128 ) -> (StatusCode, axum::http::HeaderMap, Vec<u8>) {
1129 let req = Request::builder()
1130 .method(Method::GET)
1131 .uri(uri)
1132 .body(Body::empty())
1133 .unwrap();
1134 let resp = router.oneshot(req).await.unwrap();
1135 let status = resp.status();
1136 let headers = resp.headers().clone();
1137 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1138 (status, headers, bytes.to_vec())
1139 }
1140
1141 #[tokio::test]
1146 async fn test_static_router_serves_existing_file() {
1147 let dir = create_test_dir();
1148 let router = static_router("/s", dir.path());
1149
1150 let (status, body) = send_get(router, "/s/style.css").await;
1151 assert_eq!(status, StatusCode::OK);
1152 assert_eq!(&body[..], b"body { color: red; }");
1153 }
1154
1155 #[tokio::test]
1156 async fn test_static_router_serves_file_in_subdir() {
1157 let dir = create_test_dir();
1158 let router = static_router("/s", dir.path());
1159
1160 let (status, body) = send_get(router, "/s/js/app.js").await;
1161 assert_eq!(status, StatusCode::OK);
1162 assert_eq!(&body[..], b"console.log('hello');");
1163 }
1164
1165 #[tokio::test]
1166 async fn test_static_router_returns_404_for_missing_file() {
1167 let dir = create_test_dir();
1168 let router = static_router("/s", dir.path());
1169
1170 let (status, _) = send_get(router, "/s/nonexistent.txt").await;
1171 assert_eq!(status, StatusCode::NOT_FOUND);
1172 }
1173
1174 #[tokio::test]
1179 async fn test_static_router_with_index_serves_index_on_dir() {
1180 let dir = create_test_dir();
1181 let router = static_router_with_index("/s", dir.path());
1182
1183 let (status, body) = send_get(router, "/s/").await;
1185 assert_eq!(status, StatusCode::OK);
1186 assert_eq!(&body[..], b"<html>index</html>");
1187 }
1188
1189 #[tokio::test]
1190 async fn test_static_router_with_index_serves_other_files() {
1191 let dir = create_test_dir();
1192 let router = static_router_with_index("/s", dir.path());
1193
1194 let (status, body) = send_get(router, "/s/style.css").await;
1195 assert_eq!(status, StatusCode::OK);
1196 assert_eq!(&body[..], b"body { color: red; }");
1197 }
1198
1199 #[tokio::test]
1204 async fn test_static_router_spa_fallback_to_index() {
1205 let dir = create_test_dir();
1206 let router = static_router_spa(dir.path());
1207
1208 let (status, body) = send_get(router, "/some/spa/route").await;
1210 assert_eq!(status, StatusCode::OK);
1211 assert_eq!(&body[..], b"<html>index</html>");
1212 }
1213
1214 #[tokio::test]
1215 async fn test_static_router_spa_serves_existing_file() {
1216 let dir = create_test_dir();
1217 let router = static_router_spa(dir.path());
1218
1219 let (status, body) = send_get(router, "/style.css").await;
1221 assert_eq!(status, StatusCode::OK);
1222 assert_eq!(&body[..], b"body { color: red; }");
1223 }
1224
1225 #[tokio::test]
1230 async fn test_static_file_serves_single_file() {
1231 let dir = create_test_dir();
1232 let file_path: PathBuf = dir.path().join("style.css");
1233 let router: Router = Router::new().route_service("/style.css", static_file(file_path));
1234
1235 let (status, body) = send_get(router, "/style.css").await;
1236 assert_eq!(status, StatusCode::OK);
1237 assert_eq!(&body[..], b"body { color: red; }");
1238 }
1239
1240 #[tokio::test]
1241 async fn test_static_file_unknown_path_404() {
1242 let dir = create_test_dir();
1243 let file_path: PathBuf = dir.path().join("style.css");
1244 let router: Router = Router::new().route_service("/style.css", static_file(file_path));
1245
1246 let (status, _) = send_get(router, "/nonexistent.css").await;
1247 assert_eq!(status, StatusCode::NOT_FOUND);
1248 }
1249
1250 #[tokio::test]
1255 async fn test_path_traversal_blocked() {
1256 let dir = create_test_dir();
1257 let parent = dir.path().parent().unwrap();
1259 let sensitive = parent.join("sensitive.txt");
1260 fs::write(&sensitive, "secret").unwrap();
1261
1262 let router = static_router("/s", dir.path());
1263
1264 let (status, _) = send_get(router, "/s/../sensitive.txt").await;
1266 assert!(
1268 status == StatusCode::NOT_FOUND || status == StatusCode::BAD_REQUEST,
1269 "expected 404 or 400, got {status}"
1270 );
1271
1272 let _ = fs::remove_file(&sensitive);
1274 }
1275
1276 #[tokio::test]
1281 async fn test_static_router_sets_content_type_css() {
1282 let dir = create_test_dir();
1283 let router = static_router("/s", dir.path());
1284
1285 let (_, headers, _) = send_get_with_headers(router, "/s/style.css").await;
1286 let ct = headers.get("content-type").unwrap().to_str().unwrap();
1287 assert!(ct.contains("css"), "expected css, got {ct}");
1288 }
1289
1290 #[tokio::test]
1291 async fn test_static_router_sets_content_type_js() {
1292 let dir = create_test_dir();
1293 let router = static_router("/s", dir.path());
1294
1295 let (_, headers, _) = send_get_with_headers(router, "/s/js/app.js").await;
1296 let ct = headers.get("content-type").unwrap().to_str().unwrap();
1297 assert!(
1298 ct.contains("javascript") || ct.contains("js"),
1299 "expected js, got {ct}"
1300 );
1301 }
1302
1303 #[tokio::test]
1304 async fn test_static_router_spa_sets_content_type_html() {
1305 let dir = create_test_dir();
1306 let router = static_router_spa(dir.path());
1307
1308 let (_, headers, _) = send_get_with_headers(router, "/unknown/route").await;
1309 let ct = headers.get("content-type").unwrap().to_str().unwrap();
1310 assert!(ct.contains("html"), "expected html, got {ct}");
1311 }
1312
1313 #[tokio::test]
1318 async fn test_static_router_handles_head_request() {
1319 let dir = create_test_dir();
1320 let router = static_router("/s", dir.path());
1321
1322 let req = Request::builder()
1323 .method(Method::HEAD)
1324 .uri("/s/style.css")
1325 .body(Body::empty())
1326 .unwrap();
1327 let resp = router.oneshot(req).await.unwrap();
1328 assert_eq!(resp.status(), StatusCode::OK);
1329 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1331 assert!(bytes.is_empty() || bytes.len() < 100);
1332 }
1333
1334 #[tokio::test]
1339 async fn test_static_dir_with_nest_service() {
1340 let dir = create_test_dir();
1341 let router: Router = Router::new().nest_service("/s", static_dir(dir.path()));
1342
1343 let (status, body) = send_get(router, "/s/style.css").await;
1344 assert_eq!(status, StatusCode::OK);
1345 assert_eq!(&body[..], b"body { color: red; }");
1346 }
1347
1348 #[tokio::test]
1349 async fn test_static_dir_with_index_with_nest_service() {
1350 let dir = create_test_dir();
1351 let router: Router = Router::new().nest_service("/s", static_dir_with_index(dir.path()));
1352
1353 let (status, body) = send_get(router, "/s/").await;
1354 assert_eq!(status, StatusCode::OK);
1355 assert_eq!(&body[..], b"<html>index</html>");
1356 }
1357
1358 #[tokio::test]
1359 async fn test_static_dir_spa_with_fallback_service() {
1360 let dir = create_test_dir();
1361 let router: Router = Router::new().fallback_service(static_dir_spa(dir.path()));
1362
1363 let (status, body) = send_get(router, "/unknown/route").await;
1364 assert_eq!(status, StatusCode::OK);
1365 assert_eq!(&body[..], b"<html>index</html>");
1366 }
1367
1368 #[tokio::test]
1373 async fn test_static_router_merge_with_api_routes() {
1374 let dir = create_test_dir();
1375
1376 let api_router: Router = Router::new().route(
1377 "/api/hello",
1378 axum::routing::get(|| async { "hello from api" }),
1379 );
1380 let static_router = static_router("/static", dir.path());
1381
1382 let app: Router = api_router.merge(static_router);
1383
1384 let (status, body) = send_get(app.clone(), "/api/hello").await;
1386 assert_eq!(status, StatusCode::OK);
1387 assert_eq!(&body[..], b"hello from api");
1388
1389 let (status, body) = send_get(app, "/static/style.css").await;
1391 assert_eq!(status, StatusCode::OK);
1392 assert_eq!(&body[..], b"body { color: red; }");
1393 }
1394
1395 #[test]
1400 fn test_mime_type_for_extension_html() {
1401 assert_eq!(mime_type_for_extension("html"), Some("text/html"));
1402 assert_eq!(mime_type_for_extension("HTML"), Some("text/html"));
1403 assert_eq!(mime_type_for_extension("Htm"), Some("text/html"));
1404 }
1405
1406 #[test]
1407 fn test_mime_type_for_extension_css() {
1408 assert_eq!(mime_type_for_extension("css"), Some("text/css"));
1409 }
1410
1411 #[test]
1412 fn test_mime_type_for_extension_js() {
1413 assert_eq!(
1414 mime_type_for_extension("js"),
1415 Some("application/javascript")
1416 );
1417 assert_eq!(
1418 mime_type_for_extension("mjs"),
1419 Some("application/javascript")
1420 );
1421 }
1422
1423 #[test]
1424 fn test_mime_type_for_extension_json() {
1425 assert_eq!(mime_type_for_extension("json"), Some("application/json"));
1426 }
1427
1428 #[test]
1429 fn test_mime_type_for_extension_images() {
1430 assert_eq!(mime_type_for_extension("png"), Some("image/png"));
1431 assert_eq!(mime_type_for_extension("jpg"), Some("image/jpeg"));
1432 assert_eq!(mime_type_for_extension("jpeg"), Some("image/jpeg"));
1433 assert_eq!(mime_type_for_extension("gif"), Some("image/gif"));
1434 assert_eq!(mime_type_for_extension("svg"), Some("image/svg+xml"));
1435 assert_eq!(mime_type_for_extension("ico"), Some("image/x-icon"));
1436 assert_eq!(mime_type_for_extension("webp"), Some("image/webp"));
1437 }
1438
1439 #[test]
1440 fn test_mime_type_for_extension_fonts() {
1441 assert_eq!(mime_type_for_extension("woff"), Some("font/woff"));
1442 assert_eq!(mime_type_for_extension("woff2"), Some("font/woff2"));
1443 assert_eq!(mime_type_for_extension("ttf"), Some("font/ttf"));
1444 }
1445
1446 #[test]
1447 fn test_mime_type_for_extension_unknown() {
1448 assert_eq!(mime_type_for_extension("xyz123"), None);
1449 assert_eq!(mime_type_for_extension(""), None);
1450 }
1451
1452 #[test]
1453 fn test_mime_type_for_path() {
1454 assert_eq!(
1455 mime_type_for_path(Path::new("style.css")),
1456 Some("text/css".to_string())
1457 );
1458 assert_eq!(
1459 mime_type_for_path(Path::new("/var/www/index.html")),
1460 Some("text/html".to_string())
1461 );
1462 let result = mime_type_for_path(Path::new("file.unknownext123"));
1464 let _ = result;
1466 }
1467
1468 #[test]
1473 fn test_parse_range_start_end() {
1474 let range = parse_range_header("bytes=0-499", 1000).unwrap();
1476 assert_eq!(range, RangeSpec { start: 0, end: 499 });
1477 }
1478
1479 #[test]
1480 fn test_parse_range_start_open() {
1481 let range = parse_range_header("bytes=500-", 1000).unwrap();
1483 assert_eq!(
1484 range,
1485 RangeSpec {
1486 start: 500,
1487 end: 999
1488 }
1489 );
1490 }
1491
1492 #[test]
1493 fn test_parse_range_suffix() {
1494 let range = parse_range_header("bytes=-500", 1000).unwrap();
1496 assert_eq!(
1497 range,
1498 RangeSpec {
1499 start: 500,
1500 end: 999
1501 }
1502 );
1503 }
1504
1505 #[test]
1506 fn test_parse_range_suffix_larger_than_file() {
1507 let range = parse_range_header("bytes=-2000", 1000).unwrap();
1509 assert_eq!(range, RangeSpec { start: 0, end: 999 });
1510 }
1511
1512 #[test]
1513 fn test_parse_range_end_exceeds_file_size() {
1514 let range = parse_range_header("bytes=900-2000", 1000).unwrap();
1516 assert_eq!(
1517 range,
1518 RangeSpec {
1519 start: 900,
1520 end: 999
1521 }
1522 );
1523 }
1524
1525 #[test]
1526 fn test_parse_range_start_equals_file_size() {
1527 let result = parse_range_header("bytes=1000-", 1000);
1529 assert_eq!(result, Err(RangeError::Unsatisfiable));
1530 }
1531
1532 #[test]
1533 fn test_parse_range_start_greater_than_end() {
1534 let result = parse_range_header("bytes=500-100", 1000);
1536 assert_eq!(result, Err(RangeError::InvalidRange));
1537 }
1538
1539 #[test]
1540 fn test_parse_range_invalid_format_no_bytes_prefix() {
1541 let result = parse_range_header("0-499", 1000);
1542 assert_eq!(result, Err(RangeError::InvalidFormat));
1543 }
1544
1545 #[test]
1546 fn test_parse_range_invalid_format_no_dash() {
1547 let result = parse_range_header("bytes=500", 1000);
1548 assert_eq!(result, Err(RangeError::InvalidFormat));
1549 }
1550
1551 #[test]
1552 fn test_parse_range_empty_range() {
1553 let result = parse_range_header("bytes=-", 1000);
1555 assert_eq!(result, Err(RangeError::InvalidRange));
1556 }
1557
1558 #[test]
1559 fn test_parse_range_non_numeric() {
1560 let result = parse_range_header("bytes=abc-500", 1000);
1561 assert_eq!(result, Err(RangeError::InvalidRange));
1562 }
1563
1564 #[test]
1565 fn test_parse_range_with_whitespace() {
1566 let range = parse_range_header(" bytes=0-499 ", 1000).unwrap();
1568 assert_eq!(range, RangeSpec { start: 0, end: 499 });
1569 }
1570
1571 #[test]
1576 fn test_is_path_safe_valid() {
1577 let dir = create_test_dir();
1578 let root = dir.path();
1579 let file = root.join("style.css");
1580 assert!(is_path_safe(&file, root));
1581 }
1582
1583 #[test]
1584 fn test_is_path_safe_subdir() {
1585 let dir = create_test_dir();
1586 let root = dir.path();
1587 let file = root.join("js").join("app.js");
1588 assert!(is_path_safe(&file, root));
1589 }
1590
1591 #[test]
1592 fn test_is_path_safe_traversal_blocked() {
1593 let dir = create_test_dir();
1594 let root = dir.path();
1595 let parent = root.parent().unwrap();
1597 let sensitive = parent.join("sensitive.txt");
1598 fs::write(&sensitive, "secret").unwrap();
1599
1600 let file = root.join("..").join("sensitive.txt");
1602 assert!(!is_path_safe(&file, root));
1603
1604 let _ = fs::remove_file(&sensitive);
1605 }
1606
1607 #[test]
1608 fn test_is_path_safe_nonexistent() {
1609 let dir = create_test_dir();
1610 let root = dir.path();
1611 let file = root.join("nonexistent.txt");
1612 assert!(!is_path_safe(&file, root));
1614 }
1615
1616 #[test]
1621 fn test_percent_decode_plain() {
1622 assert_eq!(percent_decode("/style.css"), "/style.css");
1623 }
1624
1625 #[test]
1626 fn test_percent_decode_encoded() {
1627 assert_eq!(percent_decode("/my%20file.css"), "/my file.css");
1629 }
1630
1631 #[test]
1632 fn test_percent_decode_unicode() {
1633 assert_eq!(percent_decode("/%E4%B8%AD.html"), "/中.html");
1635 }
1636
1637 #[test]
1638 fn test_percent_decode_no_plus_conversion() {
1639 assert_eq!(percent_decode("/my+file.css"), "/my+file.css");
1641 }
1642
1643 #[test]
1644 fn test_percent_decode_incomplete() {
1645 assert_eq!(percent_decode("/file%2.css"), "/file%2.css");
1647 }
1648
1649 #[tokio::test]
1654 async fn test_serve_file_basic() {
1655 let dir = create_test_dir();
1656 let file_path = dir.path().join("style.css");
1657 let headers = axum::http::HeaderMap::new();
1658
1659 let resp = serve_file(&file_path, &headers).await;
1660 assert_eq!(resp.status(), StatusCode::OK);
1661
1662 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1663 assert_eq!(&bytes[..], b"body { color: red; }");
1664 }
1665
1666 #[tokio::test]
1667 async fn test_serve_file_not_found() {
1668 let dir = create_test_dir();
1669 let file_path = dir.path().join("nonexistent.txt");
1670 let headers = axum::http::HeaderMap::new();
1671
1672 let resp = serve_file(&file_path, &headers).await;
1673 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1674 }
1675
1676 #[tokio::test]
1677 async fn test_serve_file_sets_content_type() {
1678 let dir = create_test_dir();
1679 let file_path = dir.path().join("style.css");
1680 let headers = axum::http::HeaderMap::new();
1681
1682 let resp = serve_file(&file_path, &headers).await;
1683 let ct = resp
1684 .headers()
1685 .get("content-type")
1686 .unwrap()
1687 .to_str()
1688 .unwrap();
1689 assert!(ct.contains("css"), "expected css, got {ct}");
1690 }
1691
1692 #[tokio::test]
1693 async fn test_serve_file_sets_last_modified() {
1694 let dir = create_test_dir();
1695 let file_path = dir.path().join("style.css");
1696 let headers = axum::http::HeaderMap::new();
1697
1698 let resp = serve_file(&file_path, &headers).await;
1699 let lm = resp.headers().get("last-modified");
1700 assert!(lm.is_some(), "Last-Modified header should be set");
1701 let lm_str = lm.unwrap().to_str().unwrap();
1702 assert!(lm_str.ends_with("GMT"), "Last-Modified should end with GMT");
1703 }
1704
1705 #[tokio::test]
1706 async fn test_serve_file_sets_accept_ranges() {
1707 let dir = create_test_dir();
1708 let file_path = dir.path().join("style.css");
1709 let headers = axum::http::HeaderMap::new();
1710
1711 let resp = serve_file(&file_path, &headers).await;
1712 let ar = resp
1713 .headers()
1714 .get("accept-ranges")
1715 .unwrap()
1716 .to_str()
1717 .unwrap();
1718 assert_eq!(ar, "bytes");
1719 }
1720
1721 #[tokio::test]
1722 async fn test_serve_file_304_if_modified_since_match() {
1723 let dir = create_test_dir();
1724 let file_path = dir.path().join("style.css");
1725
1726 let headers1 = axum::http::HeaderMap::new();
1728 let resp1 = serve_file(&file_path, &headers1).await;
1729 let last_modified = resp1
1730 .headers()
1731 .get("last-modified")
1732 .unwrap()
1733 .to_str()
1734 .unwrap()
1735 .to_string();
1736
1737 let mut headers2 = axum::http::HeaderMap::new();
1739 headers2.insert(
1740 axum::http::header::IF_MODIFIED_SINCE,
1741 axum::http::HeaderValue::from_str(&last_modified).unwrap(),
1742 );
1743 let resp2 = serve_file(&file_path, &headers2).await;
1744 assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
1745
1746 let bytes = resp2.into_body().collect().await.unwrap().to_bytes();
1747 assert!(bytes.is_empty(), "304 response should have empty body");
1748 }
1749
1750 #[tokio::test]
1751 async fn test_serve_file_304_if_modified_since_mismatch() {
1752 let dir = create_test_dir();
1753 let file_path = dir.path().join("style.css");
1754
1755 let mut headers = axum::http::HeaderMap::new();
1756 headers.insert(
1757 axum::http::header::IF_MODIFIED_SINCE,
1758 axum::http::HeaderValue::from_static("Mon, 01 Jan 2000 00:00:00 GMT"),
1759 );
1760 let resp = serve_file(&file_path, &headers).await;
1761 assert_eq!(resp.status(), StatusCode::OK);
1762 }
1763
1764 #[tokio::test]
1769 async fn test_serve_file_range_partial_content() {
1770 let dir = create_test_dir();
1771 let file_path = dir.path().join("data.bin");
1773 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let mut headers = axum::http::HeaderMap::new();
1776 headers.insert(
1777 axum::http::header::RANGE,
1778 axum::http::HeaderValue::from_static("bytes=5-9"),
1779 );
1780
1781 let resp = serve_file(&file_path, &headers).await;
1782 assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
1783
1784 let cr = resp
1785 .headers()
1786 .get("content-range")
1787 .unwrap()
1788 .to_str()
1789 .unwrap();
1790 assert_eq!(cr, "bytes 5-9/20");
1791
1792 let cl = resp
1793 .headers()
1794 .get("content-length")
1795 .unwrap()
1796 .to_str()
1797 .unwrap();
1798 assert_eq!(cl, "5");
1799
1800 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1801 assert_eq!(&bytes[..], b"56789");
1802 }
1803
1804 #[tokio::test]
1805 async fn test_serve_file_range_open_end() {
1806 let dir = create_test_dir();
1807 let file_path = dir.path().join("data.bin");
1808 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let mut headers = axum::http::HeaderMap::new();
1811 headers.insert(
1812 axum::http::header::RANGE,
1813 axum::http::HeaderValue::from_static("bytes=10-"),
1814 );
1815
1816 let resp = serve_file(&file_path, &headers).await;
1817 assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
1818
1819 let cr = resp
1820 .headers()
1821 .get("content-range")
1822 .unwrap()
1823 .to_str()
1824 .unwrap();
1825 assert_eq!(cr, "bytes 10-19/20");
1826
1827 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1828 assert_eq!(&bytes[..], b"ABCDEFGHIJ");
1829 }
1830
1831 #[tokio::test]
1832 async fn test_serve_file_range_suffix() {
1833 let dir = create_test_dir();
1834 let file_path = dir.path().join("data.bin");
1835 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let mut headers = axum::http::HeaderMap::new();
1838 headers.insert(
1839 axum::http::header::RANGE,
1840 axum::http::HeaderValue::from_static("bytes=-5"),
1841 );
1842
1843 let resp = serve_file(&file_path, &headers).await;
1844 assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
1845
1846 let cr = resp
1847 .headers()
1848 .get("content-range")
1849 .unwrap()
1850 .to_str()
1851 .unwrap();
1852 assert_eq!(cr, "bytes 15-19/20");
1853
1854 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1855 assert_eq!(&bytes[..], b"FGHIJ");
1856 }
1857
1858 #[tokio::test]
1859 async fn test_serve_file_range_unsatisfiable() {
1860 let dir = create_test_dir();
1861 let file_path = dir.path().join("data.bin");
1862 fs::write(&file_path, b"0123456789").unwrap(); let mut headers = axum::http::HeaderMap::new();
1865 headers.insert(
1866 axum::http::header::RANGE,
1867 axum::http::HeaderValue::from_static("bytes=100-200"),
1868 );
1869
1870 let resp = serve_file(&file_path, &headers).await;
1871 assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1872
1873 let cr = resp
1874 .headers()
1875 .get("content-range")
1876 .unwrap()
1877 .to_str()
1878 .unwrap();
1879 assert_eq!(cr, "bytes */10");
1880 }
1881
1882 #[tokio::test]
1883 async fn test_serve_file_range_invalid_fallback_to_full() {
1884 let dir = create_test_dir();
1885 let file_path = dir.path().join("data.bin");
1886 fs::write(&file_path, b"0123456789").unwrap(); let mut headers = axum::http::HeaderMap::new();
1889 headers.insert(
1891 axum::http::header::RANGE,
1892 axum::http::HeaderValue::from_static("0-499"),
1893 );
1894
1895 let resp = serve_file(&file_path, &headers).await;
1896 assert_eq!(resp.status(), StatusCode::OK);
1898
1899 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1900 assert_eq!(&bytes[..], b"0123456789");
1901 }
1902
1903 #[tokio::test]
1908 async fn test_static_handler_serves_file() {
1909 let dir = create_test_dir();
1910 let headers = axum::http::HeaderMap::new();
1911
1912 let resp = static_handler(dir.path(), "/style.css", &headers).await;
1913 assert_eq!(resp.status(), StatusCode::OK);
1914
1915 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1916 assert_eq!(&bytes[..], b"body { color: red; }");
1917 }
1918
1919 #[tokio::test]
1920 async fn test_static_handler_serves_subdir_file() {
1921 let dir = create_test_dir();
1922 let headers = axum::http::HeaderMap::new();
1923
1924 let resp = static_handler(dir.path(), "/js/app.js", &headers).await;
1925 assert_eq!(resp.status(), StatusCode::OK);
1926
1927 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1928 assert_eq!(&bytes[..], b"console.log('hello');");
1929 }
1930
1931 #[tokio::test]
1932 async fn test_static_handler_404_for_missing() {
1933 let dir = create_test_dir();
1934 let headers = axum::http::HeaderMap::new();
1935
1936 let resp = static_handler(dir.path(), "/nonexistent.txt", &headers).await;
1937 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1938 }
1939
1940 #[tokio::test]
1941 async fn test_static_handler_blocks_traversal() {
1942 let dir = create_test_dir();
1943 let root = dir.path();
1944 let parent = root.parent().unwrap();
1945 let sensitive = parent.join("secret.txt");
1946 fs::write(&sensitive, "secret").unwrap();
1947
1948 let headers = axum::http::HeaderMap::new();
1949 let resp = static_handler(root, "/../secret.txt", &headers).await;
1950 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1951
1952 let _ = fs::remove_file(&sensitive);
1953 }
1954
1955 #[tokio::test]
1960 async fn test_p1_path_01_serve_file_rejects_parent_dir_component() {
1961 let dir = create_test_dir();
1963 let file_path = dir.path().join("subdir/../style.css");
1965 let headers = axum::http::HeaderMap::new();
1968 let resp = serve_file(&file_path, &headers).await;
1969 assert_eq!(
1970 resp.status(),
1971 StatusCode::NOT_FOUND,
1972 "P1-PATH-01: serve_file 应拒绝包含 .. 组件的路径,即使解析后文件存在"
1973 );
1974 }
1975
1976 #[tokio::test]
1977 async fn test_p1_path_01_serve_file_with_cache_rejects_parent_dir_component() {
1978 let dir = create_test_dir();
1979 let file_path = dir.path().join("subdir/../style.css");
1980 let headers = axum::http::HeaderMap::new();
1981 let resp = serve_file_with_cache(&file_path, &headers, None).await;
1982 assert_eq!(
1983 resp.status(),
1984 StatusCode::NOT_FOUND,
1985 "P1-PATH-01: serve_file_with_cache 应拒绝包含 .. 组件的路径"
1986 );
1987 }
1988
1989 #[tokio::test]
1990 async fn test_p1_path_01_serve_file_allows_clean_path() {
1991 let dir = create_test_dir();
1993 let file_path = dir.path().join("style.css");
1994 let headers = axum::http::HeaderMap::new();
1995 let resp = serve_file(&file_path, &headers).await;
1996 assert_eq!(
1997 resp.status(),
1998 StatusCode::OK,
1999 "P1-PATH-01: 不含 .. 的正常路径应正常工作"
2000 );
2001 }
2002
2003 #[tokio::test]
2004 async fn test_p1_path_01_serve_file_rejects_deep_traversal() {
2005 let dir = create_test_dir();
2007 let file_path = dir.path().join("a/../../b/../../etc/passwd");
2008 let headers = axum::http::HeaderMap::new();
2009 let resp = serve_file(&file_path, &headers).await;
2010 assert_eq!(
2011 resp.status(),
2012 StatusCode::NOT_FOUND,
2013 "P1-PATH-01: 多层 .. 路径应被拒绝"
2014 );
2015 }
2016
2017 #[tokio::test]
2018 async fn test_static_handler_with_query_string() {
2019 let dir = create_test_dir();
2020 let headers = axum::http::HeaderMap::new();
2021
2022 let resp = static_handler(dir.path(), "/style.css?v=123", &headers).await;
2024 assert_eq!(resp.status(), StatusCode::OK);
2025
2026 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2027 assert_eq!(&bytes[..], b"body { color: red; }");
2028 }
2029
2030 #[tokio::test]
2031 async fn test_static_handler_url_encoded_path() {
2032 let dir = create_test_dir();
2033 fs::write(dir.path().join("my file.css"), "encoded content").unwrap();
2035
2036 let headers = axum::http::HeaderMap::new();
2037 let resp = static_handler(dir.path(), "/my%20file.css", &headers).await;
2039 assert_eq!(resp.status(), StatusCode::OK);
2040
2041 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2042 assert_eq!(&bytes[..], b"encoded content");
2043 }
2044
2045 #[test]
2050 fn test_format_http_date_epoch() {
2051 let time = std::time::UNIX_EPOCH;
2053 let date_str = format_http_date(time);
2054 assert!(
2055 date_str.contains("Thu"),
2056 "expected Thursday, got {date_str}"
2057 );
2058 assert!(date_str.contains("01"), "expected day 01, got {date_str}");
2059 assert!(date_str.contains("Jan"), "expected January, got {date_str}");
2060 assert!(
2061 date_str.contains("1970"),
2062 "expected year 1970, got {date_str}"
2063 );
2064 assert!(
2065 date_str.ends_with("GMT"),
2066 "expected GMT suffix, got {date_str}"
2067 );
2068 }
2069
2070 #[test]
2071 fn test_format_http_date_known_timestamp() {
2072 let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1768569045);
2074 let date_str = format_http_date(time);
2075 assert!(
2076 date_str.contains("2026"),
2077 "expected year 2026, got {date_str}"
2078 );
2079 assert!(
2080 date_str.ends_with("GMT"),
2081 "expected GMT suffix, got {date_str}"
2082 );
2083 }
2084
2085 #[tokio::test]
2090 async fn test_serve_file_unknown_mime_sets_content_disposition() {
2091 let dir = create_test_dir();
2092 let file_path = dir.path().join("data.xyz123");
2094 fs::write(&file_path, "unknown content").unwrap();
2095
2096 let headers = axum::http::HeaderMap::new();
2097 let resp = serve_file(&file_path, &headers).await;
2098 assert_eq!(resp.status(), StatusCode::OK);
2099
2100 let cd = resp.headers().get("content-disposition");
2102 assert!(
2103 cd.is_some(),
2104 "Content-Disposition should be set for unknown MIME"
2105 );
2106 let cd_str = cd.unwrap().to_str().unwrap();
2107 assert!(
2108 cd_str.contains("attachment"),
2109 "expected attachment, got {cd_str}"
2110 );
2111 assert!(
2112 cd_str.contains("data.xyz123"),
2113 "expected filename, got {cd_str}"
2114 );
2115 }
2116
2117 #[tokio::test]
2118 async fn test_serve_file_known_mime_no_content_disposition() {
2119 let dir = create_test_dir();
2120 let file_path = dir.path().join("style.css");
2121 let headers = axum::http::HeaderMap::new();
2122
2123 let resp = serve_file(&file_path, &headers).await;
2124 assert_eq!(resp.status(), StatusCode::OK);
2125
2126 let cd = resp.headers().get("content-disposition");
2128 assert!(
2129 cd.is_none(),
2130 "Content-Disposition should not be set for known MIME"
2131 );
2132 }
2133
2134 #[test]
2139 fn test_cache_control_default_empty() {
2140 let config = CacheControlConfig::new();
2142 assert_eq!(config.to_header_value(), None);
2143 }
2144
2145 #[test]
2146 fn test_cache_control_max_age_only() {
2147 let config = CacheControlConfig::new().with_max_age(3600);
2149 assert_eq!(config.to_header_value().as_deref(), Some("max-age=3600"));
2150 }
2151
2152 #[test]
2153 fn test_cache_control_public_max_age() {
2154 let config = CacheControlConfig::new().with_public().with_max_age(3600);
2156 assert_eq!(
2157 config.to_header_value().as_deref(),
2158 Some("public, max-age=3600")
2159 );
2160 }
2161
2162 #[test]
2163 fn test_cache_control_private_max_age() {
2164 let config = CacheControlConfig::new().with_private().with_max_age(600);
2165 assert_eq!(
2166 config.to_header_value().as_deref(),
2167 Some("private, max-age=600")
2168 );
2169 }
2170
2171 #[test]
2172 fn test_cache_control_no_cache() {
2173 let config = CacheControlConfig::new().with_no_cache();
2175 assert_eq!(config.to_header_value().as_deref(), Some("no-cache"));
2176 }
2177
2178 #[test]
2179 fn test_cache_control_no_store() {
2180 let config = CacheControlConfig::new().with_no_store();
2181 assert_eq!(config.to_header_value().as_deref(), Some("no-store"));
2182 }
2183
2184 #[test]
2185 fn test_cache_control_no_store_no_cache_order() {
2186 let config = CacheControlConfig::new().with_no_cache().with_no_store();
2188 assert_eq!(
2189 config.to_header_value().as_deref(),
2190 Some("no-store, no-cache")
2191 );
2192 }
2193
2194 #[test]
2195 fn test_cache_control_must_revalidate() {
2196 let config = CacheControlConfig::new()
2197 .with_no_cache()
2198 .with_must_revalidate();
2199 assert_eq!(
2200 config.to_header_value().as_deref(),
2201 Some("no-cache, must-revalidate")
2202 );
2203 }
2204
2205 #[test]
2206 fn test_cache_control_immutable_long_max_age() {
2207 let config = CacheControlConfig::new()
2209 .with_public()
2210 .with_max_age(31536000)
2211 .with_immutable();
2212 assert_eq!(
2213 config.to_header_value().as_deref(),
2214 Some("public, max-age=31536000, immutable")
2215 );
2216 }
2217
2218 #[test]
2219 fn test_cache_control_full_directive_order() {
2220 let config = CacheControlConfig::new()
2222 .with_no_store()
2223 .with_no_cache()
2224 .with_public()
2225 .with_max_age(60)
2226 .with_must_revalidate()
2227 .with_immutable();
2228 assert_eq!(
2229 config.to_header_value().as_deref(),
2230 Some("no-store, no-cache, public, max-age=60, must-revalidate, immutable")
2231 );
2232 }
2233
2234 #[test]
2239 fn test_compute_etag_format() {
2240 let dir = create_test_dir();
2242 let file_path = dir.path().join("style.css");
2243 let metadata = std::fs::metadata(&file_path).unwrap();
2244
2245 let etag = compute_etag(&metadata).expect("ETag should be computed");
2246 assert!(
2247 etag.starts_with("W/\"") && etag.ends_with('"'),
2248 "ETag should be weak format W/\"...\", got: {etag}"
2249 );
2250 let inner = &etag[3..etag.len() - 1];
2252 let parts: Vec<&str> = inner.splitn(2, '-').collect();
2253 assert_eq!(parts.len(), 2, "ETag inner should be <mtime>-<size>");
2254 assert!(
2255 parts[0].chars().all(|c| c.is_ascii_digit()),
2256 "mtime should be numeric"
2257 );
2258 assert!(
2259 parts[1].chars().all(|c| c.is_ascii_digit()),
2260 "size should be numeric"
2261 );
2262 }
2263
2264 #[test]
2265 fn test_compute_etag_size_in_header() {
2266 let dir = create_test_dir();
2268 let file_path = dir.path().join("data.bin");
2269 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let metadata = std::fs::metadata(&file_path).unwrap();
2271
2272 let etag = compute_etag(&metadata).unwrap();
2273 assert!(
2274 etag.contains("-20\""),
2275 "ETag should contain file size 20, got: {etag}"
2276 );
2277 }
2278
2279 #[test]
2280 fn test_compute_etag_different_sizes_differ() {
2281 let dir = create_test_dir();
2282 let small_path = dir.path().join("small.bin");
2283 let large_path = dir.path().join("large.bin");
2284 fs::write(&small_path, b"short").unwrap();
2285 fs::write(&large_path, b"this is a much longer file content").unwrap();
2286
2287 let small_etag = compute_etag(&std::fs::metadata(&small_path).unwrap()).unwrap();
2288 let large_etag = compute_etag(&std::fs::metadata(&large_path).unwrap()).unwrap();
2289 assert_ne!(
2290 small_etag, large_etag,
2291 "Different file sizes should produce different ETags"
2292 );
2293 }
2294
2295 #[test]
2301 fn test_fingerprint_bytes_empty() {
2302 let hash = fingerprint_bytes(b"");
2304 assert_eq!(hash, "d41d8cd98f00b204e9800998ecf8427e");
2305 assert_eq!(hash.len(), 32, "MD5 hash should be 32 hex chars");
2306 }
2307
2308 #[test]
2309 fn test_fingerprint_bytes_hello() {
2310 let hash = fingerprint_bytes(b"hello");
2312 assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
2313 }
2314
2315 #[test]
2316 fn test_fingerprint_bytes_known_php_value() {
2317 let hash = fingerprint_bytes(b"The quick brown fox jumps over the lazy dog");
2321 assert_eq!(hash, "9e107d9d372bb6826bd81d3542a419d6");
2322 }
2323
2324 #[test]
2325 fn test_fingerprint_bytes_lowercase_hex() {
2326 let hash = fingerprint_bytes(b"test");
2328 assert!(
2329 hash.chars()
2330 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
2331 "MD5 hash should be lowercase hex: {hash}"
2332 );
2333 }
2334
2335 #[test]
2336 fn test_fingerprint_file_reads_content() {
2337 let dir = create_test_dir();
2338 let file_path = dir.path().join("content.txt");
2339 fs::write(&file_path, b"hello").unwrap();
2340
2341 let file_hash = fingerprint_file(&file_path).unwrap();
2342 let bytes_hash = fingerprint_bytes(b"hello");
2343 assert_eq!(file_hash, bytes_hash);
2344 assert_eq!(file_hash, "5d41402abc4b2a76b9719d911017c592");
2345 }
2346
2347 #[test]
2348 fn test_fingerprint_file_missing_returns_err() {
2349 let dir = create_test_dir();
2350 let missing = dir.path().join("nonexistent.txt");
2351 let result = fingerprint_file(&missing);
2352 assert!(result.is_err(), "Missing file should return Err");
2353 }
2354
2355 #[test]
2361 fn test_extract_version_hash_valid() {
2362 let result = extract_version_hash("style.abc123def456.css");
2364 assert_eq!(
2365 result,
2366 Some(("style.css".to_string(), "abc123def456".to_string()))
2367 );
2368 }
2369
2370 #[test]
2371 fn test_extract_version_hash_path_with_dir() {
2372 let result = extract_version_hash("js/app.abc123def456.js");
2374 assert_eq!(
2375 result,
2376 Some(("js/app.js".to_string(), "abc123def456".to_string()))
2377 );
2378 }
2379
2380 #[test]
2381 fn test_extract_version_hash_multi_dot_stem() {
2382 let result = extract_version_hash("foo.bar.abc123def456.css");
2384 assert_eq!(
2385 result,
2386 Some(("foo.bar.css".to_string(), "abc123def456".to_string()))
2387 );
2388 }
2389
2390 #[test]
2391 fn test_extract_version_hash_min_8_chars() {
2392 let result = extract_version_hash("style.abc12345.css");
2394 assert_eq!(
2395 result,
2396 Some(("style.css".to_string(), "abc12345".to_string()))
2397 );
2398 }
2399
2400 #[test]
2401 fn test_extract_version_hash_no_hash() {
2402 let result = extract_version_hash("style.css");
2404 assert_eq!(result, None);
2405 }
2406
2407 #[test]
2408 fn test_extract_version_hash_short_hash() {
2409 let result = extract_version_hash("style.abc123.css");
2411 assert_eq!(result, None);
2412 }
2413
2414 #[test]
2415 fn test_extract_version_hash_non_hex() {
2416 let result = extract_version_hash("style.xyzghijk.css");
2418 assert_eq!(result, None);
2419 }
2420
2421 #[test]
2422 fn test_extract_version_hash_uppercase_hex() {
2423 let result = extract_version_hash("style.ABCDEF12.css");
2425 assert_eq!(
2426 result,
2427 Some(("style.css".to_string(), "ABCDEF12".to_string()))
2428 );
2429 }
2430
2431 #[test]
2432 fn test_extract_version_hash_no_extension() {
2433 let result = extract_version_hash("noextension");
2435 assert_eq!(result, None);
2436 }
2437
2438 #[test]
2439 fn test_extract_version_hash_empty_extension() {
2440 let result = extract_version_hash("style.abc123def456.");
2442 assert_eq!(result, None);
2443 }
2444
2445 #[tokio::test]
2450 async fn test_serve_file_with_cache_200_no_config() {
2451 let dir = create_test_dir();
2453 let file_path = dir.path().join("style.css");
2454 let headers = axum::http::HeaderMap::new();
2455
2456 let resp = serve_file_with_cache(&file_path, &headers, None).await;
2457 assert_eq!(resp.status(), StatusCode::OK);
2458
2459 let cc = resp.headers().get("cache-control");
2460 assert!(
2461 cc.is_none(),
2462 "Cache-Control should not be set without config"
2463 );
2464
2465 let etag = resp.headers().get("etag");
2467 assert!(etag.is_some(), "ETag should be set");
2468 }
2469
2470 #[tokio::test]
2471 async fn test_serve_file_with_cache_200_with_cache_control() {
2472 let dir = create_test_dir();
2473 let file_path = dir.path().join("style.css");
2474 let headers = axum::http::HeaderMap::new();
2475 let config = CacheControlConfig::new().with_public().with_max_age(3600);
2476
2477 let resp = serve_file_with_cache(&file_path, &headers, Some(&config)).await;
2478 assert_eq!(resp.status(), StatusCode::OK);
2479
2480 let cc = resp
2481 .headers()
2482 .get("cache-control")
2483 .unwrap()
2484 .to_str()
2485 .unwrap();
2486 assert_eq!(cc, "public, max-age=3600");
2487 }
2488
2489 #[tokio::test]
2490 async fn test_serve_file_with_cache_etag_header_set() {
2491 let dir = create_test_dir();
2493 let file_path = dir.path().join("style.css");
2494 let headers = axum::http::HeaderMap::new();
2495
2496 let resp = serve_file_with_cache(&file_path, &headers, None).await;
2497 assert_eq!(resp.status(), StatusCode::OK);
2498
2499 let etag = resp.headers().get("etag").unwrap().to_str().unwrap();
2500 assert!(
2501 etag.starts_with("W/\""),
2502 "ETag should be weak format, got: {etag}"
2503 );
2504 }
2505
2506 #[tokio::test]
2507 async fn test_serve_file_with_cache_304_if_none_match_match() {
2508 let dir = create_test_dir();
2510 let file_path = dir.path().join("style.css");
2511
2512 let headers1 = axum::http::HeaderMap::new();
2514 let resp1 = serve_file_with_cache(&file_path, &headers1, None).await;
2515 let etag = resp1
2516 .headers()
2517 .get("etag")
2518 .unwrap()
2519 .to_str()
2520 .unwrap()
2521 .to_string();
2522
2523 let mut headers2 = axum::http::HeaderMap::new();
2525 headers2.insert(
2526 axum::http::header::IF_NONE_MATCH,
2527 axum::http::HeaderValue::from_str(&etag).unwrap(),
2528 );
2529 let resp2 = serve_file_with_cache(&file_path, &headers2, None).await;
2530 assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
2531
2532 let resp2_etag = resp2.headers().get("etag").unwrap().to_str().unwrap();
2534 assert_eq!(resp2_etag, etag);
2535
2536 assert!(
2538 resp2.headers().get("last-modified").is_some(),
2539 "304 should include Last-Modified"
2540 );
2541
2542 let bytes = resp2.into_body().collect().await.unwrap().to_bytes();
2544 assert!(bytes.is_empty(), "304 body should be empty");
2545 }
2546
2547 #[tokio::test]
2548 async fn test_serve_file_with_cache_304_if_none_match_star() {
2549 let dir = create_test_dir();
2551 let file_path = dir.path().join("style.css");
2552
2553 let mut headers = axum::http::HeaderMap::new();
2554 headers.insert(
2555 axum::http::header::IF_NONE_MATCH,
2556 axum::http::HeaderValue::from_static("*"),
2557 );
2558 let resp = serve_file_with_cache(&file_path, &headers, None).await;
2559 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
2560 }
2561
2562 #[tokio::test]
2563 async fn test_serve_file_with_cache_200_if_none_match_mismatch() {
2564 let dir = create_test_dir();
2566 let file_path = dir.path().join("style.css");
2567
2568 let mut headers = axum::http::HeaderMap::new();
2569 headers.insert(
2570 axum::http::header::IF_NONE_MATCH,
2571 axum::http::HeaderValue::from_static("W/\"0-0\""),
2572 );
2573 let resp = serve_file_with_cache(&file_path, &headers, None).await;
2574 assert_eq!(resp.status(), StatusCode::OK);
2575 }
2576
2577 #[tokio::test]
2578 async fn test_serve_file_with_cache_304_includes_cache_control() {
2579 let dir = create_test_dir();
2581 let file_path = dir.path().join("style.css");
2582
2583 let headers1 = axum::http::HeaderMap::new();
2585 let config = CacheControlConfig::new().with_public().with_max_age(3600);
2586 let resp1 = serve_file_with_cache(&file_path, &headers1, Some(&config)).await;
2587 let etag = resp1
2588 .headers()
2589 .get("etag")
2590 .unwrap()
2591 .to_str()
2592 .unwrap()
2593 .to_string();
2594
2595 let mut headers2 = axum::http::HeaderMap::new();
2597 headers2.insert(
2598 axum::http::header::IF_NONE_MATCH,
2599 axum::http::HeaderValue::from_str(&etag).unwrap(),
2600 );
2601 let resp2 = serve_file_with_cache(&file_path, &headers2, Some(&config)).await;
2602 assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
2603
2604 let cc = resp2
2605 .headers()
2606 .get("cache-control")
2607 .expect("304 should include Cache-Control")
2608 .to_str()
2609 .unwrap();
2610 assert_eq!(cc, "public, max-age=3600");
2611 }
2612
2613 #[tokio::test]
2614 async fn test_serve_file_with_cache_304_if_modified_since_match() {
2615 let dir = create_test_dir();
2617 let file_path = dir.path().join("style.css");
2618
2619 let headers1 = axum::http::HeaderMap::new();
2621 let resp1 = serve_file_with_cache(&file_path, &headers1, None).await;
2622 let last_modified = resp1
2623 .headers()
2624 .get("last-modified")
2625 .unwrap()
2626 .to_str()
2627 .unwrap()
2628 .to_string();
2629
2630 let mut headers2 = axum::http::HeaderMap::new();
2632 headers2.insert(
2633 axum::http::header::IF_MODIFIED_SINCE,
2634 axum::http::HeaderValue::from_str(&last_modified).unwrap(),
2635 );
2636 let resp2 = serve_file_with_cache(&file_path, &headers2, None).await;
2637 assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
2638 }
2639
2640 #[tokio::test]
2641 async fn test_serve_file_with_cache_if_none_match_takes_priority() {
2642 let dir = create_test_dir();
2645 let file_path = dir.path().join("style.css");
2646
2647 let headers1 = axum::http::HeaderMap::new();
2649 let resp1 = serve_file_with_cache(&file_path, &headers1, None).await;
2650 let last_modified = resp1
2651 .headers()
2652 .get("last-modified")
2653 .unwrap()
2654 .to_str()
2655 .unwrap()
2656 .to_string();
2657
2658 let mut headers2 = axum::http::HeaderMap::new();
2661 headers2.insert(
2662 axum::http::header::IF_NONE_MATCH,
2663 axum::http::HeaderValue::from_static("W/\"0-0\""),
2664 );
2665 headers2.insert(
2666 axum::http::header::IF_MODIFIED_SINCE,
2667 axum::http::HeaderValue::from_str(&last_modified).unwrap(),
2668 );
2669 let resp2 = serve_file_with_cache(&file_path, &headers2, None).await;
2670 assert_eq!(
2671 resp2.status(),
2672 StatusCode::OK,
2673 "If-None-Match should take priority over If-Modified-Since"
2674 );
2675 }
2676
2677 #[tokio::test]
2678 async fn test_serve_file_with_cache_404() {
2679 let dir = create_test_dir();
2680 let file_path = dir.path().join("nonexistent.txt");
2681 let headers = axum::http::HeaderMap::new();
2682
2683 let resp = serve_file_with_cache(&file_path, &headers, None).await;
2684 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2685 }
2686
2687 #[tokio::test]
2688 async fn test_serve_file_with_cache_range_206_includes_etag() {
2689 let dir = create_test_dir();
2691 let file_path = dir.path().join("data.bin");
2692 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let mut headers = axum::http::HeaderMap::new();
2695 headers.insert(
2696 axum::http::header::RANGE,
2697 axum::http::HeaderValue::from_static("bytes=5-9"),
2698 );
2699
2700 let config = CacheControlConfig::new().with_max_age(3600);
2701 let resp = serve_file_with_cache(&file_path, &headers, Some(&config)).await;
2702 assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
2703
2704 let cr = resp
2705 .headers()
2706 .get("content-range")
2707 .unwrap()
2708 .to_str()
2709 .unwrap();
2710 assert_eq!(cr, "bytes 5-9/20");
2711
2712 assert!(
2714 resp.headers().get("etag").is_some(),
2715 "206 should include ETag"
2716 );
2717
2718 let cc = resp
2720 .headers()
2721 .get("cache-control")
2722 .unwrap()
2723 .to_str()
2724 .unwrap();
2725 assert_eq!(cc, "max-age=3600");
2726
2727 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2728 assert_eq!(&bytes[..], b"56789");
2729 }
2730
2731 #[tokio::test]
2732 async fn test_serve_file_with_cache_range_416() {
2733 let dir = create_test_dir();
2735 let file_path = dir.path().join("data.bin");
2736 fs::write(&file_path, b"0123456789").unwrap(); let mut headers = axum::http::HeaderMap::new();
2739 headers.insert(
2740 axum::http::header::RANGE,
2741 axum::http::HeaderValue::from_static("bytes=100-200"),
2742 );
2743
2744 let resp = serve_file_with_cache(&file_path, &headers, None).await;
2745 assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
2746
2747 let cr = resp
2748 .headers()
2749 .get("content-range")
2750 .unwrap()
2751 .to_str()
2752 .unwrap();
2753 assert_eq!(cr, "bytes */10");
2754 }
2755
2756 #[tokio::test]
2757 async fn test_serve_file_with_cache_unknown_mime_content_disposition() {
2758 let dir = create_test_dir();
2760 let file_path = dir.path().join("unknown.xyzunknown");
2761 fs::write(&file_path, b"unknown content").unwrap();
2762
2763 let headers = axum::http::HeaderMap::new();
2764 let resp = serve_file_with_cache(&file_path, &headers, None).await;
2765 assert_eq!(resp.status(), StatusCode::OK);
2766
2767 let cd = resp
2768 .headers()
2769 .get("content-disposition")
2770 .expect("Content-Disposition should be set for unknown MIME");
2771 let cd_str = cd.to_str().unwrap();
2772 assert!(
2773 cd_str.contains("unknown.xyzunknown"),
2774 "Content-Disposition should contain filename, got: {cd_str}"
2775 );
2776 }
2777
2778 #[test]
2783 fn test_r5_php_no_etag_but_rust_extends_with_etag() {
2784 let dir = create_test_dir();
2790 let file_path = dir.path().join("style.css");
2791 let metadata = std::fs::metadata(&file_path).unwrap();
2792
2793 let etag = compute_etag(&metadata);
2794 assert!(etag.is_some(), "Rust should generate ETag (PHP doesn't)");
2795
2796 let etag_str = etag.unwrap();
2798 assert!(
2799 etag_str.starts_with("W/\"") && etag_str.ends_with('"'),
2800 "ETag should be nginx weak format"
2801 );
2802 }
2803
2804 #[test]
2805 fn test_r5_php_md5_alignment() {
2806 let rust_hash = fingerprint_bytes(b"hello");
2814 let php_hash = "5d41402abc4b2a76b9719d911017c592";
2815 assert_eq!(rust_hash, php_hash, "Rust MD5 should match PHP md5()");
2816 }
2817
2818 #[test]
2819 fn test_r5_nginx_etag_format_alignment() {
2820 let dir = create_test_dir();
2827 let file_path = dir.path().join("data.bin");
2828 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let metadata = std::fs::metadata(&file_path).unwrap();
2830
2831 let mtime = metadata
2832 .modified()
2833 .unwrap()
2834 .duration_since(std::time::UNIX_EPOCH)
2835 .unwrap()
2836 .as_secs();
2837 let size = metadata.len();
2838
2839 let expected_etag = format!("W/\"{}-{}\"", mtime, size);
2840 let actual_etag = compute_etag(&metadata).unwrap();
2841 assert_eq!(
2842 actual_etag, expected_etag,
2843 "Rust ETag should match nginx format exactly"
2844 );
2845 }
2846}