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 format_http_date(timestamp: std::time::SystemTime) -> String {
305 use std::time::UNIX_EPOCH;
306 let secs = timestamp
307 .duration_since(UNIX_EPOCH)
308 .map(|d| d.as_secs())
309 .unwrap_or(0);
310
311 let (year, month, day, hour, minute, second, weekday) = secs_to_date_time(secs);
314
315 let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
316 let months = [
317 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
318 ];
319
320 format!(
321 "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
322 weekdays[weekday as usize],
323 day,
324 months[(month - 1) as usize],
325 year,
326 hour,
327 minute,
328 second,
329 )
330}
331
332fn secs_to_date_time(secs: u64) -> (u64, u64, u64, u64, u64, u64, u64) {
337 let secs_in_day = 86400u64;
338 let mut days = secs / secs_in_day;
339 let remainder = secs % secs_in_day;
340
341 let hour = remainder / 3600;
342 let minute = (remainder % 3600) / 60;
343 let second = remainder % 60;
344
345 let weekday = (days + 4) % 7;
347
348 days += 719468; let era = days / 146097;
351 let doe = days - era * 146097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400;
355 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 };
360
361 (year, m, d, hour, minute, second, weekday)
362}
363
364pub fn serve_file(path: &Path, headers: &axum::http::HeaderMap) -> axum::response::Response {
388 use axum::body::Body;
389 use axum::http::{header, StatusCode};
390 use axum::response::IntoResponse;
391
392 if !path.is_file() {
394 return (StatusCode::NOT_FOUND, "File not found").into_response();
395 }
396
397 let metadata = match std::fs::metadata(path) {
399 Ok(m) => m,
400 Err(_) => {
401 return (
402 StatusCode::INTERNAL_SERVER_ERROR,
403 "Failed to read file metadata",
404 )
405 .into_response()
406 }
407 };
408 let file_size = metadata.len();
409 let modified = metadata.modified().ok();
410
411 if let Some(modified_time) = modified {
413 let last_modified = format_http_date(modified_time);
414 if let Some(if_modified_since) = headers.get(header::IF_MODIFIED_SINCE) {
415 if let Ok(ims_str) = if_modified_since.to_str() {
416 if ims_str.trim() == last_modified {
417 return (
418 StatusCode::NOT_MODIFIED,
419 [(header::LAST_MODIFIED, last_modified.as_str())],
420 Body::empty(),
421 )
422 .into_response();
423 }
424 }
425 }
426 }
427
428 let mime = mime_type_for_path(path);
430 let content_type = mime
431 .clone()
432 .unwrap_or_else(|| "application/octet-stream".to_string());
433
434 let content = match std::fs::read(path) {
436 Ok(c) => c,
437 Err(_) => {
438 return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response()
439 }
440 };
441
442 if let Some(range_header) = headers.get(header::RANGE) {
444 if let Ok(range_str) = range_header.to_str() {
445 match parse_range_header(range_str, file_size) {
446 Ok(range) => {
447 let content_length = range.end - range.start + 1;
448 let bytes = content
449 .get(range.start as usize..=(range.end as usize))
450 .unwrap_or(&[]);
451 let content_range =
452 format!("bytes {}-{}/{}", range.start, range.end, file_size);
453 let content_length_str = content_length.to_string();
454
455 let mut response = (
456 StatusCode::PARTIAL_CONTENT,
457 [
458 (header::CONTENT_TYPE, content_type.as_str()),
459 (header::CONTENT_LENGTH, content_length_str.as_str()),
460 (header::CONTENT_RANGE, content_range.as_str()),
461 (header::ACCEPT_RANGES, "bytes"),
462 ],
463 Body::from(bytes.to_vec()),
464 )
465 .into_response();
466
467 if let Some(modified_time) = modified {
468 let last_modified = format_http_date(modified_time);
469 if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
470 response.headers_mut().insert(header::LAST_MODIFIED, val);
471 }
472 }
473 return response;
474 }
475 Err(RangeError::Unsatisfiable) => {
476 let content_range = format!("bytes */{}", file_size);
477 return (
478 StatusCode::RANGE_NOT_SATISFIABLE,
479 [(header::CONTENT_RANGE, content_range.as_str())],
480 Body::empty(),
481 )
482 .into_response();
483 }
484 Err(_) => {
485 }
487 }
488 }
489 }
490
491 let content_length_str = file_size.to_string();
493 let mut response = (
494 StatusCode::OK,
495 [
496 (header::CONTENT_TYPE, content_type.as_str()),
497 (header::CONTENT_LENGTH, content_length_str.as_str()),
498 (header::ACCEPT_RANGES, "bytes"),
499 ],
500 Body::from(content),
501 )
502 .into_response();
503
504 if let Some(modified_time) = modified {
506 let last_modified = format_http_date(modified_time);
507 if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
508 response.headers_mut().insert(header::LAST_MODIFIED, val);
509 }
510 }
511
512 if mime.is_none() {
514 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
515 let disposition = format!("attachment; filename=\"{}\"", filename);
516 if let Ok(val) = axum::http::HeaderValue::from_str(&disposition) {
517 response
518 .headers_mut()
519 .insert(header::CONTENT_DISPOSITION, val);
520 }
521 }
522 }
523
524 response
525}
526
527pub fn static_handler(
540 root: &Path,
541 uri_path: &str,
542 headers: &axum::http::HeaderMap,
543) -> axum::response::Response {
544 use axum::http::StatusCode;
545 use axum::response::IntoResponse;
546
547 let path_only = uri_path.split('?').next().unwrap_or(uri_path);
549
550 let decoded = percent_decode(path_only);
552
553 let relative = decoded.trim_start_matches('/');
557 let file_path: PathBuf = root.join(relative);
558
559 if !is_path_safe(&file_path, root) {
561 return (StatusCode::NOT_FOUND, "Not found").into_response();
562 }
563
564 serve_file(&file_path, headers)
566}
567
568fn percent_decode(input: &str) -> String {
572 let bytes = input.as_bytes();
573 let mut result = Vec::with_capacity(bytes.len());
574
575 let mut i = 0;
576 while i < bytes.len() {
577 if bytes[i] == b'%' && i + 2 < bytes.len() {
578 if let (Some(h), Some(l)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
579 result.push(h * 16 + l);
580 i += 3;
581 continue;
582 }
583 }
584 result.push(bytes[i]);
587 i += 1;
588 }
589
590 String::from_utf8_lossy(&result).into_owned()
591}
592
593fn hex_digit(b: u8) -> Option<u8> {
595 match b {
596 b'0'..=b'9' => Some(b - b'0'),
597 b'a'..=b'f' => Some(b - b'a' + 10),
598 b'A'..=b'F' => Some(b - b'A' + 10),
599 _ => None,
600 }
601}
602
603#[derive(Debug, Clone, Default)]
615pub struct CacheControlConfig {
616 pub max_age: Option<u64>,
618 pub visibility: Option<CacheVisibility>,
620 pub no_cache: bool,
622 pub no_store: bool,
624 pub must_revalidate: bool,
626 pub immutable: bool,
628}
629
630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632pub enum CacheVisibility {
633 Public,
635 Private,
637}
638
639impl CacheControlConfig {
640 pub fn new() -> Self {
642 Self::default()
643 }
644
645 pub fn with_max_age(mut self, seconds: u64) -> Self {
647 self.max_age = Some(seconds);
648 self
649 }
650
651 pub fn with_public(mut self) -> Self {
653 self.visibility = Some(CacheVisibility::Public);
654 self
655 }
656
657 pub fn with_private(mut self) -> Self {
659 self.visibility = Some(CacheVisibility::Private);
660 self
661 }
662
663 pub fn with_no_cache(mut self) -> Self {
665 self.no_cache = true;
666 self
667 }
668
669 pub fn with_no_store(mut self) -> Self {
671 self.no_store = true;
672 self
673 }
674
675 pub fn with_must_revalidate(mut self) -> Self {
677 self.must_revalidate = true;
678 self
679 }
680
681 pub fn with_immutable(mut self) -> Self {
683 self.immutable = true;
684 self
685 }
686
687 pub fn to_header_value(&self) -> Option<String> {
691 let mut directives = Vec::new();
692
693 if self.no_store {
694 directives.push("no-store".to_string());
695 }
696 if self.no_cache {
697 directives.push("no-cache".to_string());
698 }
699 if let Some(v) = self.visibility {
700 match v {
701 CacheVisibility::Public => directives.push("public".to_string()),
702 CacheVisibility::Private => directives.push("private".to_string()),
703 }
704 }
705 if let Some(max_age) = self.max_age {
706 directives.push(format!("max-age={}", max_age));
707 }
708 if self.must_revalidate {
709 directives.push("must-revalidate".to_string());
710 }
711 if self.immutable {
712 directives.push("immutable".to_string());
713 }
714
715 if directives.is_empty() {
716 None
717 } else {
718 Some(directives.join(", "))
719 }
720 }
721}
722
723pub fn compute_etag(metadata: &std::fs::Metadata) -> Option<String> {
735 let modified = metadata.modified().ok()?;
736 let secs = modified
737 .duration_since(std::time::UNIX_EPOCH)
738 .map(|d| d.as_secs())
739 .unwrap_or(0);
740 let size = metadata.len();
741 Some(format!("W/\"{}-{}\"", secs, size))
742}
743
744pub fn fingerprint_file(path: &Path) -> std::io::Result<String> {
756 let content = std::fs::read(path)?;
757 Ok(fingerprint_bytes(&content))
758}
759
760pub fn fingerprint_bytes(content: &[u8]) -> String {
764 use md5::{Digest, Md5};
765 let mut hasher = Md5::new();
766 hasher.update(content);
767 let result = hasher.finalize();
768 let mut hex = String::with_capacity(32);
770 for byte in result.iter() {
771 hex.push_str(&format!("{:02x}", byte));
772 }
773 hex
774}
775
776pub fn extract_version_hash(path: &str) -> Option<(String, String)> {
801 let last_dot = path.rfind('.')?;
803 let ext = &path[last_dot + 1..];
804 if ext.is_empty() {
805 return None;
806 }
807
808 let stem_with_hash = &path[..last_dot];
810 let second_last_dot = stem_with_hash.rfind('.')?;
811
812 let stem = &stem_with_hash[..second_last_dot];
813 let hash = &stem_with_hash[second_last_dot + 1..];
814
815 if hash.len() < 8 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
817 return None;
818 }
819
820 Some((format!("{}.{}", stem, ext), hash.to_string()))
821}
822
823pub fn serve_file_with_cache(
842 path: &Path,
843 headers: &axum::http::HeaderMap,
844 cache_config: Option<&CacheControlConfig>,
845) -> axum::response::Response {
846 use axum::body::Body;
847 use axum::http::{header, StatusCode};
848 use axum::response::IntoResponse;
849
850 if !path.is_file() {
852 return (StatusCode::NOT_FOUND, "File not found").into_response();
853 }
854
855 let metadata = match std::fs::metadata(path) {
857 Ok(m) => m,
858 Err(_) => {
859 return (
860 StatusCode::INTERNAL_SERVER_ERROR,
861 "Failed to read file metadata",
862 )
863 .into_response();
864 }
865 };
866 let file_size = metadata.len();
867 let modified = metadata.modified().ok();
868
869 let etag = compute_etag(&metadata);
871
872 let mut if_none_match_present = false;
875 if let Some(ref etag_value) = etag {
876 if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
877 if_none_match_present = true;
878 if let Ok(inm_str) = if_none_match.to_str() {
879 if inm_str.trim() == "*" || inm_str.trim() == etag_value.as_str() {
881 let mut response = (
882 StatusCode::NOT_MODIFIED,
883 [(header::ETAG, etag_value.as_str())],
884 Body::empty(),
885 )
886 .into_response();
887 if let Some(modified_time) = modified {
889 let last_modified = format_http_date(modified_time);
890 if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
891 response.headers_mut().insert(header::LAST_MODIFIED, val);
892 }
893 }
894 if let Some(cc) = cache_config {
895 if let Some(cc_value) = cc.to_header_value() {
896 if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
897 response.headers_mut().insert(header::CACHE_CONTROL, val);
898 }
899 }
900 }
901 return response;
902 }
903 }
904 }
905 }
906
907 if !if_none_match_present {
910 if let Some(modified_time) = modified {
911 let last_modified = format_http_date(modified_time);
912 if let Some(if_modified_since) = headers.get(header::IF_MODIFIED_SINCE) {
913 if let Ok(ims_str) = if_modified_since.to_str() {
914 if ims_str.trim() == last_modified {
915 let mut response = (
916 StatusCode::NOT_MODIFIED,
917 [(header::LAST_MODIFIED, last_modified.as_str())],
918 Body::empty(),
919 )
920 .into_response();
921 if let Some(ref etag_value) = etag {
922 if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
923 response.headers_mut().insert(header::ETAG, val);
924 }
925 }
926 if let Some(cc) = cache_config {
927 if let Some(cc_value) = cc.to_header_value() {
928 if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
929 response.headers_mut().insert(header::CACHE_CONTROL, val);
930 }
931 }
932 }
933 return response;
934 }
935 }
936 }
937 }
938 }
939
940 let mime = mime_type_for_path(path);
942 let content_type = mime
943 .clone()
944 .unwrap_or_else(|| "application/octet-stream".to_string());
945
946 let content = match std::fs::read(path) {
948 Ok(c) => c,
949 Err(_) => {
950 return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to read file").into_response()
951 }
952 };
953
954 if let Some(range_header) = headers.get(header::RANGE) {
956 if let Ok(range_str) = range_header.to_str() {
957 match parse_range_header(range_str, file_size) {
958 Ok(range) => {
959 let content_length = range.end - range.start + 1;
960 let bytes = content
961 .get(range.start as usize..=(range.end as usize))
962 .unwrap_or(&[]);
963 let content_range =
964 format!("bytes {}-{}/{}", range.start, range.end, file_size);
965 let content_length_str = content_length.to_string();
966
967 let mut response = (
968 StatusCode::PARTIAL_CONTENT,
969 [
970 (header::CONTENT_TYPE, content_type.as_str()),
971 (header::CONTENT_LENGTH, content_length_str.as_str()),
972 (header::CONTENT_RANGE, content_range.as_str()),
973 (header::ACCEPT_RANGES, "bytes"),
974 ],
975 Body::from(bytes.to_vec()),
976 )
977 .into_response();
978
979 if let Some(modified_time) = modified {
980 let last_modified = format_http_date(modified_time);
981 if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
982 response.headers_mut().insert(header::LAST_MODIFIED, val);
983 }
984 }
985 if let Some(ref etag_value) = etag {
986 if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
987 response.headers_mut().insert(header::ETAG, val);
988 }
989 }
990 if let Some(cc) = cache_config {
991 if let Some(cc_value) = cc.to_header_value() {
992 if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
993 response.headers_mut().insert(header::CACHE_CONTROL, val);
994 }
995 }
996 }
997 return response;
998 }
999 Err(RangeError::Unsatisfiable) => {
1000 let content_range = format!("bytes */{}", file_size);
1001 return (
1002 StatusCode::RANGE_NOT_SATISFIABLE,
1003 [(header::CONTENT_RANGE, content_range.as_str())],
1004 Body::empty(),
1005 )
1006 .into_response();
1007 }
1008 Err(_) => {
1009 }
1011 }
1012 }
1013 }
1014
1015 let content_length_str = file_size.to_string();
1017 let mut response = (
1018 StatusCode::OK,
1019 [
1020 (header::CONTENT_TYPE, content_type.as_str()),
1021 (header::CONTENT_LENGTH, content_length_str.as_str()),
1022 (header::ACCEPT_RANGES, "bytes"),
1023 ],
1024 Body::from(content),
1025 )
1026 .into_response();
1027
1028 if let Some(modified_time) = modified {
1030 let last_modified = format_http_date(modified_time);
1031 if let Ok(val) = axum::http::HeaderValue::from_str(&last_modified) {
1032 response.headers_mut().insert(header::LAST_MODIFIED, val);
1033 }
1034 }
1035
1036 if let Some(ref etag_value) = etag {
1038 if let Ok(val) = axum::http::HeaderValue::from_str(etag_value) {
1039 response.headers_mut().insert(header::ETAG, val);
1040 }
1041 }
1042
1043 if let Some(cc) = cache_config {
1045 if let Some(cc_value) = cc.to_header_value() {
1046 if let Ok(val) = axum::http::HeaderValue::from_str(&cc_value) {
1047 response.headers_mut().insert(header::CACHE_CONTROL, val);
1048 }
1049 }
1050 }
1051
1052 if mime.is_none() {
1054 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
1055 let disposition = format!("attachment; filename=\"{}\"", filename);
1056 if let Ok(val) = axum::http::HeaderValue::from_str(&disposition) {
1057 response
1058 .headers_mut()
1059 .insert(header::CONTENT_DISPOSITION, val);
1060 }
1061 }
1062 }
1063
1064 response
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069 use super::*;
1070 use axum::body::Body;
1071 use axum::http::{Method, Request, StatusCode};
1072 use http_body_util::BodyExt;
1073 use std::fs;
1074 use std::path::PathBuf;
1075 use tempfile::TempDir;
1076 use tower::ServiceExt;
1077
1078 fn create_test_dir() -> TempDir {
1080 let dir = tempfile::tempdir().expect("failed to create temp dir");
1081 let root = dir.path();
1082
1083 fs::write(root.join("index.html"), "<html>index</html>").unwrap();
1085 fs::write(root.join("style.css"), "body { color: red; }").unwrap();
1087 fs::create_dir_all(root.join("js")).unwrap();
1089 fs::write(root.join("js").join("app.js"), "console.log('hello');").unwrap();
1090 dir
1091 }
1092
1093 async fn send_get(router: Router, uri: &str) -> (StatusCode, Vec<u8>) {
1094 let req = Request::builder()
1095 .method(Method::GET)
1096 .uri(uri)
1097 .body(Body::empty())
1098 .unwrap();
1099 let resp = router.oneshot(req).await.unwrap();
1100 let status = resp.status();
1101 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1102 (status, bytes.to_vec())
1103 }
1104
1105 async fn send_get_with_headers(
1106 router: Router,
1107 uri: &str,
1108 ) -> (StatusCode, axum::http::HeaderMap, Vec<u8>) {
1109 let req = Request::builder()
1110 .method(Method::GET)
1111 .uri(uri)
1112 .body(Body::empty())
1113 .unwrap();
1114 let resp = router.oneshot(req).await.unwrap();
1115 let status = resp.status();
1116 let headers = resp.headers().clone();
1117 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1118 (status, headers, bytes.to_vec())
1119 }
1120
1121 #[tokio::test]
1126 async fn test_static_router_serves_existing_file() {
1127 let dir = create_test_dir();
1128 let router = static_router("/s", dir.path());
1129
1130 let (status, body) = send_get(router, "/s/style.css").await;
1131 assert_eq!(status, StatusCode::OK);
1132 assert_eq!(&body[..], b"body { color: red; }");
1133 }
1134
1135 #[tokio::test]
1136 async fn test_static_router_serves_file_in_subdir() {
1137 let dir = create_test_dir();
1138 let router = static_router("/s", dir.path());
1139
1140 let (status, body) = send_get(router, "/s/js/app.js").await;
1141 assert_eq!(status, StatusCode::OK);
1142 assert_eq!(&body[..], b"console.log('hello');");
1143 }
1144
1145 #[tokio::test]
1146 async fn test_static_router_returns_404_for_missing_file() {
1147 let dir = create_test_dir();
1148 let router = static_router("/s", dir.path());
1149
1150 let (status, _) = send_get(router, "/s/nonexistent.txt").await;
1151 assert_eq!(status, StatusCode::NOT_FOUND);
1152 }
1153
1154 #[tokio::test]
1159 async fn test_static_router_with_index_serves_index_on_dir() {
1160 let dir = create_test_dir();
1161 let router = static_router_with_index("/s", dir.path());
1162
1163 let (status, body) = send_get(router, "/s/").await;
1165 assert_eq!(status, StatusCode::OK);
1166 assert_eq!(&body[..], b"<html>index</html>");
1167 }
1168
1169 #[tokio::test]
1170 async fn test_static_router_with_index_serves_other_files() {
1171 let dir = create_test_dir();
1172 let router = static_router_with_index("/s", dir.path());
1173
1174 let (status, body) = send_get(router, "/s/style.css").await;
1175 assert_eq!(status, StatusCode::OK);
1176 assert_eq!(&body[..], b"body { color: red; }");
1177 }
1178
1179 #[tokio::test]
1184 async fn test_static_router_spa_fallback_to_index() {
1185 let dir = create_test_dir();
1186 let router = static_router_spa(dir.path());
1187
1188 let (status, body) = send_get(router, "/some/spa/route").await;
1190 assert_eq!(status, StatusCode::OK);
1191 assert_eq!(&body[..], b"<html>index</html>");
1192 }
1193
1194 #[tokio::test]
1195 async fn test_static_router_spa_serves_existing_file() {
1196 let dir = create_test_dir();
1197 let router = static_router_spa(dir.path());
1198
1199 let (status, body) = send_get(router, "/style.css").await;
1201 assert_eq!(status, StatusCode::OK);
1202 assert_eq!(&body[..], b"body { color: red; }");
1203 }
1204
1205 #[tokio::test]
1210 async fn test_static_file_serves_single_file() {
1211 let dir = create_test_dir();
1212 let file_path: PathBuf = dir.path().join("style.css");
1213 let router: Router = Router::new().route_service("/style.css", static_file(file_path));
1214
1215 let (status, body) = send_get(router, "/style.css").await;
1216 assert_eq!(status, StatusCode::OK);
1217 assert_eq!(&body[..], b"body { color: red; }");
1218 }
1219
1220 #[tokio::test]
1221 async fn test_static_file_unknown_path_404() {
1222 let dir = create_test_dir();
1223 let file_path: PathBuf = dir.path().join("style.css");
1224 let router: Router = Router::new().route_service("/style.css", static_file(file_path));
1225
1226 let (status, _) = send_get(router, "/nonexistent.css").await;
1227 assert_eq!(status, StatusCode::NOT_FOUND);
1228 }
1229
1230 #[tokio::test]
1235 async fn test_path_traversal_blocked() {
1236 let dir = create_test_dir();
1237 let parent = dir.path().parent().unwrap();
1239 let sensitive = parent.join("sensitive.txt");
1240 fs::write(&sensitive, "secret").unwrap();
1241
1242 let router = static_router("/s", dir.path());
1243
1244 let (status, _) = send_get(router, "/s/../sensitive.txt").await;
1246 assert!(
1248 status == StatusCode::NOT_FOUND || status == StatusCode::BAD_REQUEST,
1249 "expected 404 or 400, got {status}"
1250 );
1251
1252 let _ = fs::remove_file(&sensitive);
1254 }
1255
1256 #[tokio::test]
1261 async fn test_static_router_sets_content_type_css() {
1262 let dir = create_test_dir();
1263 let router = static_router("/s", dir.path());
1264
1265 let (_, headers, _) = send_get_with_headers(router, "/s/style.css").await;
1266 let ct = headers.get("content-type").unwrap().to_str().unwrap();
1267 assert!(ct.contains("css"), "expected css, got {ct}");
1268 }
1269
1270 #[tokio::test]
1271 async fn test_static_router_sets_content_type_js() {
1272 let dir = create_test_dir();
1273 let router = static_router("/s", dir.path());
1274
1275 let (_, headers, _) = send_get_with_headers(router, "/s/js/app.js").await;
1276 let ct = headers.get("content-type").unwrap().to_str().unwrap();
1277 assert!(
1278 ct.contains("javascript") || ct.contains("js"),
1279 "expected js, got {ct}"
1280 );
1281 }
1282
1283 #[tokio::test]
1284 async fn test_static_router_spa_sets_content_type_html() {
1285 let dir = create_test_dir();
1286 let router = static_router_spa(dir.path());
1287
1288 let (_, headers, _) = send_get_with_headers(router, "/unknown/route").await;
1289 let ct = headers.get("content-type").unwrap().to_str().unwrap();
1290 assert!(ct.contains("html"), "expected html, got {ct}");
1291 }
1292
1293 #[tokio::test]
1298 async fn test_static_router_handles_head_request() {
1299 let dir = create_test_dir();
1300 let router = static_router("/s", dir.path());
1301
1302 let req = Request::builder()
1303 .method(Method::HEAD)
1304 .uri("/s/style.css")
1305 .body(Body::empty())
1306 .unwrap();
1307 let resp = router.oneshot(req).await.unwrap();
1308 assert_eq!(resp.status(), StatusCode::OK);
1309 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1311 assert!(bytes.is_empty() || bytes.len() < 100);
1312 }
1313
1314 #[tokio::test]
1319 async fn test_static_dir_with_nest_service() {
1320 let dir = create_test_dir();
1321 let router: Router = Router::new().nest_service("/s", static_dir(dir.path()));
1322
1323 let (status, body) = send_get(router, "/s/style.css").await;
1324 assert_eq!(status, StatusCode::OK);
1325 assert_eq!(&body[..], b"body { color: red; }");
1326 }
1327
1328 #[tokio::test]
1329 async fn test_static_dir_with_index_with_nest_service() {
1330 let dir = create_test_dir();
1331 let router: Router = Router::new().nest_service("/s", static_dir_with_index(dir.path()));
1332
1333 let (status, body) = send_get(router, "/s/").await;
1334 assert_eq!(status, StatusCode::OK);
1335 assert_eq!(&body[..], b"<html>index</html>");
1336 }
1337
1338 #[tokio::test]
1339 async fn test_static_dir_spa_with_fallback_service() {
1340 let dir = create_test_dir();
1341 let router: Router = Router::new().fallback_service(static_dir_spa(dir.path()));
1342
1343 let (status, body) = send_get(router, "/unknown/route").await;
1344 assert_eq!(status, StatusCode::OK);
1345 assert_eq!(&body[..], b"<html>index</html>");
1346 }
1347
1348 #[tokio::test]
1353 async fn test_static_router_merge_with_api_routes() {
1354 let dir = create_test_dir();
1355
1356 let api_router: Router = Router::new().route(
1357 "/api/hello",
1358 axum::routing::get(|| async { "hello from api" }),
1359 );
1360 let static_router = static_router("/static", dir.path());
1361
1362 let app: Router = api_router.merge(static_router);
1363
1364 let (status, body) = send_get(app.clone(), "/api/hello").await;
1366 assert_eq!(status, StatusCode::OK);
1367 assert_eq!(&body[..], b"hello from api");
1368
1369 let (status, body) = send_get(app, "/static/style.css").await;
1371 assert_eq!(status, StatusCode::OK);
1372 assert_eq!(&body[..], b"body { color: red; }");
1373 }
1374
1375 #[test]
1380 fn test_mime_type_for_extension_html() {
1381 assert_eq!(mime_type_for_extension("html"), Some("text/html"));
1382 assert_eq!(mime_type_for_extension("HTML"), Some("text/html"));
1383 assert_eq!(mime_type_for_extension("Htm"), Some("text/html"));
1384 }
1385
1386 #[test]
1387 fn test_mime_type_for_extension_css() {
1388 assert_eq!(mime_type_for_extension("css"), Some("text/css"));
1389 }
1390
1391 #[test]
1392 fn test_mime_type_for_extension_js() {
1393 assert_eq!(
1394 mime_type_for_extension("js"),
1395 Some("application/javascript")
1396 );
1397 assert_eq!(
1398 mime_type_for_extension("mjs"),
1399 Some("application/javascript")
1400 );
1401 }
1402
1403 #[test]
1404 fn test_mime_type_for_extension_json() {
1405 assert_eq!(mime_type_for_extension("json"), Some("application/json"));
1406 }
1407
1408 #[test]
1409 fn test_mime_type_for_extension_images() {
1410 assert_eq!(mime_type_for_extension("png"), Some("image/png"));
1411 assert_eq!(mime_type_for_extension("jpg"), Some("image/jpeg"));
1412 assert_eq!(mime_type_for_extension("jpeg"), Some("image/jpeg"));
1413 assert_eq!(mime_type_for_extension("gif"), Some("image/gif"));
1414 assert_eq!(mime_type_for_extension("svg"), Some("image/svg+xml"));
1415 assert_eq!(mime_type_for_extension("ico"), Some("image/x-icon"));
1416 assert_eq!(mime_type_for_extension("webp"), Some("image/webp"));
1417 }
1418
1419 #[test]
1420 fn test_mime_type_for_extension_fonts() {
1421 assert_eq!(mime_type_for_extension("woff"), Some("font/woff"));
1422 assert_eq!(mime_type_for_extension("woff2"), Some("font/woff2"));
1423 assert_eq!(mime_type_for_extension("ttf"), Some("font/ttf"));
1424 }
1425
1426 #[test]
1427 fn test_mime_type_for_extension_unknown() {
1428 assert_eq!(mime_type_for_extension("xyz123"), None);
1429 assert_eq!(mime_type_for_extension(""), None);
1430 }
1431
1432 #[test]
1433 fn test_mime_type_for_path() {
1434 assert_eq!(
1435 mime_type_for_path(Path::new("style.css")),
1436 Some("text/css".to_string())
1437 );
1438 assert_eq!(
1439 mime_type_for_path(Path::new("/var/www/index.html")),
1440 Some("text/html".to_string())
1441 );
1442 let result = mime_type_for_path(Path::new("file.unknownext123"));
1444 let _ = result;
1446 }
1447
1448 #[test]
1453 fn test_parse_range_start_end() {
1454 let range = parse_range_header("bytes=0-499", 1000).unwrap();
1456 assert_eq!(range, RangeSpec { start: 0, end: 499 });
1457 }
1458
1459 #[test]
1460 fn test_parse_range_start_open() {
1461 let range = parse_range_header("bytes=500-", 1000).unwrap();
1463 assert_eq!(
1464 range,
1465 RangeSpec {
1466 start: 500,
1467 end: 999
1468 }
1469 );
1470 }
1471
1472 #[test]
1473 fn test_parse_range_suffix() {
1474 let range = parse_range_header("bytes=-500", 1000).unwrap();
1476 assert_eq!(
1477 range,
1478 RangeSpec {
1479 start: 500,
1480 end: 999
1481 }
1482 );
1483 }
1484
1485 #[test]
1486 fn test_parse_range_suffix_larger_than_file() {
1487 let range = parse_range_header("bytes=-2000", 1000).unwrap();
1489 assert_eq!(range, RangeSpec { start: 0, end: 999 });
1490 }
1491
1492 #[test]
1493 fn test_parse_range_end_exceeds_file_size() {
1494 let range = parse_range_header("bytes=900-2000", 1000).unwrap();
1496 assert_eq!(
1497 range,
1498 RangeSpec {
1499 start: 900,
1500 end: 999
1501 }
1502 );
1503 }
1504
1505 #[test]
1506 fn test_parse_range_start_equals_file_size() {
1507 let result = parse_range_header("bytes=1000-", 1000);
1509 assert_eq!(result, Err(RangeError::Unsatisfiable));
1510 }
1511
1512 #[test]
1513 fn test_parse_range_start_greater_than_end() {
1514 let result = parse_range_header("bytes=500-100", 1000);
1516 assert_eq!(result, Err(RangeError::InvalidRange));
1517 }
1518
1519 #[test]
1520 fn test_parse_range_invalid_format_no_bytes_prefix() {
1521 let result = parse_range_header("0-499", 1000);
1522 assert_eq!(result, Err(RangeError::InvalidFormat));
1523 }
1524
1525 #[test]
1526 fn test_parse_range_invalid_format_no_dash() {
1527 let result = parse_range_header("bytes=500", 1000);
1528 assert_eq!(result, Err(RangeError::InvalidFormat));
1529 }
1530
1531 #[test]
1532 fn test_parse_range_empty_range() {
1533 let result = parse_range_header("bytes=-", 1000);
1535 assert_eq!(result, Err(RangeError::InvalidRange));
1536 }
1537
1538 #[test]
1539 fn test_parse_range_non_numeric() {
1540 let result = parse_range_header("bytes=abc-500", 1000);
1541 assert_eq!(result, Err(RangeError::InvalidRange));
1542 }
1543
1544 #[test]
1545 fn test_parse_range_with_whitespace() {
1546 let range = parse_range_header(" bytes=0-499 ", 1000).unwrap();
1548 assert_eq!(range, RangeSpec { start: 0, end: 499 });
1549 }
1550
1551 #[test]
1556 fn test_is_path_safe_valid() {
1557 let dir = create_test_dir();
1558 let root = dir.path();
1559 let file = root.join("style.css");
1560 assert!(is_path_safe(&file, root));
1561 }
1562
1563 #[test]
1564 fn test_is_path_safe_subdir() {
1565 let dir = create_test_dir();
1566 let root = dir.path();
1567 let file = root.join("js").join("app.js");
1568 assert!(is_path_safe(&file, root));
1569 }
1570
1571 #[test]
1572 fn test_is_path_safe_traversal_blocked() {
1573 let dir = create_test_dir();
1574 let root = dir.path();
1575 let parent = root.parent().unwrap();
1577 let sensitive = parent.join("sensitive.txt");
1578 fs::write(&sensitive, "secret").unwrap();
1579
1580 let file = root.join("..").join("sensitive.txt");
1582 assert!(!is_path_safe(&file, root));
1583
1584 let _ = fs::remove_file(&sensitive);
1585 }
1586
1587 #[test]
1588 fn test_is_path_safe_nonexistent() {
1589 let dir = create_test_dir();
1590 let root = dir.path();
1591 let file = root.join("nonexistent.txt");
1592 assert!(!is_path_safe(&file, root));
1594 }
1595
1596 #[test]
1601 fn test_percent_decode_plain() {
1602 assert_eq!(percent_decode("/style.css"), "/style.css");
1603 }
1604
1605 #[test]
1606 fn test_percent_decode_encoded() {
1607 assert_eq!(percent_decode("/my%20file.css"), "/my file.css");
1609 }
1610
1611 #[test]
1612 fn test_percent_decode_unicode() {
1613 assert_eq!(percent_decode("/%E4%B8%AD.html"), "/中.html");
1615 }
1616
1617 #[test]
1618 fn test_percent_decode_no_plus_conversion() {
1619 assert_eq!(percent_decode("/my+file.css"), "/my+file.css");
1621 }
1622
1623 #[test]
1624 fn test_percent_decode_incomplete() {
1625 assert_eq!(percent_decode("/file%2.css"), "/file%2.css");
1627 }
1628
1629 #[tokio::test]
1634 async fn test_serve_file_basic() {
1635 let dir = create_test_dir();
1636 let file_path = dir.path().join("style.css");
1637 let headers = axum::http::HeaderMap::new();
1638
1639 let resp = serve_file(&file_path, &headers);
1640 assert_eq!(resp.status(), StatusCode::OK);
1641
1642 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1643 assert_eq!(&bytes[..], b"body { color: red; }");
1644 }
1645
1646 #[tokio::test]
1647 async fn test_serve_file_not_found() {
1648 let dir = create_test_dir();
1649 let file_path = dir.path().join("nonexistent.txt");
1650 let headers = axum::http::HeaderMap::new();
1651
1652 let resp = serve_file(&file_path, &headers);
1653 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1654 }
1655
1656 #[tokio::test]
1657 async fn test_serve_file_sets_content_type() {
1658 let dir = create_test_dir();
1659 let file_path = dir.path().join("style.css");
1660 let headers = axum::http::HeaderMap::new();
1661
1662 let resp = serve_file(&file_path, &headers);
1663 let ct = resp
1664 .headers()
1665 .get("content-type")
1666 .unwrap()
1667 .to_str()
1668 .unwrap();
1669 assert!(ct.contains("css"), "expected css, got {ct}");
1670 }
1671
1672 #[tokio::test]
1673 async fn test_serve_file_sets_last_modified() {
1674 let dir = create_test_dir();
1675 let file_path = dir.path().join("style.css");
1676 let headers = axum::http::HeaderMap::new();
1677
1678 let resp = serve_file(&file_path, &headers);
1679 let lm = resp.headers().get("last-modified");
1680 assert!(lm.is_some(), "Last-Modified header should be set");
1681 let lm_str = lm.unwrap().to_str().unwrap();
1682 assert!(lm_str.ends_with("GMT"), "Last-Modified should end with GMT");
1683 }
1684
1685 #[tokio::test]
1686 async fn test_serve_file_sets_accept_ranges() {
1687 let dir = create_test_dir();
1688 let file_path = dir.path().join("style.css");
1689 let headers = axum::http::HeaderMap::new();
1690
1691 let resp = serve_file(&file_path, &headers);
1692 let ar = resp
1693 .headers()
1694 .get("accept-ranges")
1695 .unwrap()
1696 .to_str()
1697 .unwrap();
1698 assert_eq!(ar, "bytes");
1699 }
1700
1701 #[tokio::test]
1702 async fn test_serve_file_304_if_modified_since_match() {
1703 let dir = create_test_dir();
1704 let file_path = dir.path().join("style.css");
1705
1706 let headers1 = axum::http::HeaderMap::new();
1708 let resp1 = serve_file(&file_path, &headers1);
1709 let last_modified = resp1
1710 .headers()
1711 .get("last-modified")
1712 .unwrap()
1713 .to_str()
1714 .unwrap()
1715 .to_string();
1716
1717 let mut headers2 = axum::http::HeaderMap::new();
1719 headers2.insert(
1720 axum::http::header::IF_MODIFIED_SINCE,
1721 axum::http::HeaderValue::from_str(&last_modified).unwrap(),
1722 );
1723 let resp2 = serve_file(&file_path, &headers2);
1724 assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
1725
1726 let bytes = resp2.into_body().collect().await.unwrap().to_bytes();
1727 assert!(bytes.is_empty(), "304 response should have empty body");
1728 }
1729
1730 #[tokio::test]
1731 async fn test_serve_file_304_if_modified_since_mismatch() {
1732 let dir = create_test_dir();
1733 let file_path = dir.path().join("style.css");
1734
1735 let mut headers = axum::http::HeaderMap::new();
1736 headers.insert(
1737 axum::http::header::IF_MODIFIED_SINCE,
1738 axum::http::HeaderValue::from_static("Mon, 01 Jan 2000 00:00:00 GMT"),
1739 );
1740 let resp = serve_file(&file_path, &headers);
1741 assert_eq!(resp.status(), StatusCode::OK);
1742 }
1743
1744 #[tokio::test]
1749 async fn test_serve_file_range_partial_content() {
1750 let dir = create_test_dir();
1751 let file_path = dir.path().join("data.bin");
1753 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let mut headers = axum::http::HeaderMap::new();
1756 headers.insert(
1757 axum::http::header::RANGE,
1758 axum::http::HeaderValue::from_static("bytes=5-9"),
1759 );
1760
1761 let resp = serve_file(&file_path, &headers);
1762 assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
1763
1764 let cr = resp
1765 .headers()
1766 .get("content-range")
1767 .unwrap()
1768 .to_str()
1769 .unwrap();
1770 assert_eq!(cr, "bytes 5-9/20");
1771
1772 let cl = resp
1773 .headers()
1774 .get("content-length")
1775 .unwrap()
1776 .to_str()
1777 .unwrap();
1778 assert_eq!(cl, "5");
1779
1780 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1781 assert_eq!(&bytes[..], b"56789");
1782 }
1783
1784 #[tokio::test]
1785 async fn test_serve_file_range_open_end() {
1786 let dir = create_test_dir();
1787 let file_path = dir.path().join("data.bin");
1788 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let mut headers = axum::http::HeaderMap::new();
1791 headers.insert(
1792 axum::http::header::RANGE,
1793 axum::http::HeaderValue::from_static("bytes=10-"),
1794 );
1795
1796 let resp = serve_file(&file_path, &headers);
1797 assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
1798
1799 let cr = resp
1800 .headers()
1801 .get("content-range")
1802 .unwrap()
1803 .to_str()
1804 .unwrap();
1805 assert_eq!(cr, "bytes 10-19/20");
1806
1807 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1808 assert_eq!(&bytes[..], b"ABCDEFGHIJ");
1809 }
1810
1811 #[tokio::test]
1812 async fn test_serve_file_range_suffix() {
1813 let dir = create_test_dir();
1814 let file_path = dir.path().join("data.bin");
1815 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let mut headers = axum::http::HeaderMap::new();
1818 headers.insert(
1819 axum::http::header::RANGE,
1820 axum::http::HeaderValue::from_static("bytes=-5"),
1821 );
1822
1823 let resp = serve_file(&file_path, &headers);
1824 assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
1825
1826 let cr = resp
1827 .headers()
1828 .get("content-range")
1829 .unwrap()
1830 .to_str()
1831 .unwrap();
1832 assert_eq!(cr, "bytes 15-19/20");
1833
1834 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1835 assert_eq!(&bytes[..], b"FGHIJ");
1836 }
1837
1838 #[tokio::test]
1839 async fn test_serve_file_range_unsatisfiable() {
1840 let dir = create_test_dir();
1841 let file_path = dir.path().join("data.bin");
1842 fs::write(&file_path, b"0123456789").unwrap(); let mut headers = axum::http::HeaderMap::new();
1845 headers.insert(
1846 axum::http::header::RANGE,
1847 axum::http::HeaderValue::from_static("bytes=100-200"),
1848 );
1849
1850 let resp = serve_file(&file_path, &headers);
1851 assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1852
1853 let cr = resp
1854 .headers()
1855 .get("content-range")
1856 .unwrap()
1857 .to_str()
1858 .unwrap();
1859 assert_eq!(cr, "bytes */10");
1860 }
1861
1862 #[tokio::test]
1863 async fn test_serve_file_range_invalid_fallback_to_full() {
1864 let dir = create_test_dir();
1865 let file_path = dir.path().join("data.bin");
1866 fs::write(&file_path, b"0123456789").unwrap(); let mut headers = axum::http::HeaderMap::new();
1869 headers.insert(
1871 axum::http::header::RANGE,
1872 axum::http::HeaderValue::from_static("0-499"),
1873 );
1874
1875 let resp = serve_file(&file_path, &headers);
1876 assert_eq!(resp.status(), StatusCode::OK);
1878
1879 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1880 assert_eq!(&bytes[..], b"0123456789");
1881 }
1882
1883 #[tokio::test]
1888 async fn test_static_handler_serves_file() {
1889 let dir = create_test_dir();
1890 let headers = axum::http::HeaderMap::new();
1891
1892 let resp = static_handler(dir.path(), "/style.css", &headers);
1893 assert_eq!(resp.status(), StatusCode::OK);
1894
1895 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1896 assert_eq!(&bytes[..], b"body { color: red; }");
1897 }
1898
1899 #[tokio::test]
1900 async fn test_static_handler_serves_subdir_file() {
1901 let dir = create_test_dir();
1902 let headers = axum::http::HeaderMap::new();
1903
1904 let resp = static_handler(dir.path(), "/js/app.js", &headers);
1905 assert_eq!(resp.status(), StatusCode::OK);
1906
1907 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1908 assert_eq!(&bytes[..], b"console.log('hello');");
1909 }
1910
1911 #[tokio::test]
1912 async fn test_static_handler_404_for_missing() {
1913 let dir = create_test_dir();
1914 let headers = axum::http::HeaderMap::new();
1915
1916 let resp = static_handler(dir.path(), "/nonexistent.txt", &headers);
1917 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1918 }
1919
1920 #[tokio::test]
1921 async fn test_static_handler_blocks_traversal() {
1922 let dir = create_test_dir();
1923 let root = dir.path();
1924 let parent = root.parent().unwrap();
1925 let sensitive = parent.join("secret.txt");
1926 fs::write(&sensitive, "secret").unwrap();
1927
1928 let headers = axum::http::HeaderMap::new();
1929 let resp = static_handler(root, "/../secret.txt", &headers);
1930 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1931
1932 let _ = fs::remove_file(&sensitive);
1933 }
1934
1935 #[tokio::test]
1936 async fn test_static_handler_with_query_string() {
1937 let dir = create_test_dir();
1938 let headers = axum::http::HeaderMap::new();
1939
1940 let resp = static_handler(dir.path(), "/style.css?v=123", &headers);
1942 assert_eq!(resp.status(), StatusCode::OK);
1943
1944 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1945 assert_eq!(&bytes[..], b"body { color: red; }");
1946 }
1947
1948 #[tokio::test]
1949 async fn test_static_handler_url_encoded_path() {
1950 let dir = create_test_dir();
1951 fs::write(dir.path().join("my file.css"), "encoded content").unwrap();
1953
1954 let headers = axum::http::HeaderMap::new();
1955 let resp = static_handler(dir.path(), "/my%20file.css", &headers);
1957 assert_eq!(resp.status(), StatusCode::OK);
1958
1959 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1960 assert_eq!(&bytes[..], b"encoded content");
1961 }
1962
1963 #[test]
1968 fn test_format_http_date_epoch() {
1969 let time = std::time::UNIX_EPOCH;
1971 let date_str = format_http_date(time);
1972 assert!(
1973 date_str.contains("Thu"),
1974 "expected Thursday, got {date_str}"
1975 );
1976 assert!(date_str.contains("01"), "expected day 01, got {date_str}");
1977 assert!(date_str.contains("Jan"), "expected January, got {date_str}");
1978 assert!(
1979 date_str.contains("1970"),
1980 "expected year 1970, got {date_str}"
1981 );
1982 assert!(
1983 date_str.ends_with("GMT"),
1984 "expected GMT suffix, got {date_str}"
1985 );
1986 }
1987
1988 #[test]
1989 fn test_format_http_date_known_timestamp() {
1990 let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1768569045);
1992 let date_str = format_http_date(time);
1993 assert!(
1994 date_str.contains("2026"),
1995 "expected year 2026, got {date_str}"
1996 );
1997 assert!(
1998 date_str.ends_with("GMT"),
1999 "expected GMT suffix, got {date_str}"
2000 );
2001 }
2002
2003 #[tokio::test]
2008 async fn test_serve_file_unknown_mime_sets_content_disposition() {
2009 let dir = create_test_dir();
2010 let file_path = dir.path().join("data.xyz123");
2012 fs::write(&file_path, "unknown content").unwrap();
2013
2014 let headers = axum::http::HeaderMap::new();
2015 let resp = serve_file(&file_path, &headers);
2016 assert_eq!(resp.status(), StatusCode::OK);
2017
2018 let cd = resp.headers().get("content-disposition");
2020 assert!(
2021 cd.is_some(),
2022 "Content-Disposition should be set for unknown MIME"
2023 );
2024 let cd_str = cd.unwrap().to_str().unwrap();
2025 assert!(
2026 cd_str.contains("attachment"),
2027 "expected attachment, got {cd_str}"
2028 );
2029 assert!(
2030 cd_str.contains("data.xyz123"),
2031 "expected filename, got {cd_str}"
2032 );
2033 }
2034
2035 #[tokio::test]
2036 async fn test_serve_file_known_mime_no_content_disposition() {
2037 let dir = create_test_dir();
2038 let file_path = dir.path().join("style.css");
2039 let headers = axum::http::HeaderMap::new();
2040
2041 let resp = serve_file(&file_path, &headers);
2042 assert_eq!(resp.status(), StatusCode::OK);
2043
2044 let cd = resp.headers().get("content-disposition");
2046 assert!(
2047 cd.is_none(),
2048 "Content-Disposition should not be set for known MIME"
2049 );
2050 }
2051
2052 #[test]
2057 fn test_cache_control_default_empty() {
2058 let config = CacheControlConfig::new();
2060 assert_eq!(config.to_header_value(), None);
2061 }
2062
2063 #[test]
2064 fn test_cache_control_max_age_only() {
2065 let config = CacheControlConfig::new().with_max_age(3600);
2067 assert_eq!(config.to_header_value().as_deref(), Some("max-age=3600"));
2068 }
2069
2070 #[test]
2071 fn test_cache_control_public_max_age() {
2072 let config = CacheControlConfig::new().with_public().with_max_age(3600);
2074 assert_eq!(
2075 config.to_header_value().as_deref(),
2076 Some("public, max-age=3600")
2077 );
2078 }
2079
2080 #[test]
2081 fn test_cache_control_private_max_age() {
2082 let config = CacheControlConfig::new().with_private().with_max_age(600);
2083 assert_eq!(
2084 config.to_header_value().as_deref(),
2085 Some("private, max-age=600")
2086 );
2087 }
2088
2089 #[test]
2090 fn test_cache_control_no_cache() {
2091 let config = CacheControlConfig::new().with_no_cache();
2093 assert_eq!(config.to_header_value().as_deref(), Some("no-cache"));
2094 }
2095
2096 #[test]
2097 fn test_cache_control_no_store() {
2098 let config = CacheControlConfig::new().with_no_store();
2099 assert_eq!(config.to_header_value().as_deref(), Some("no-store"));
2100 }
2101
2102 #[test]
2103 fn test_cache_control_no_store_no_cache_order() {
2104 let config = CacheControlConfig::new().with_no_cache().with_no_store();
2106 assert_eq!(
2107 config.to_header_value().as_deref(),
2108 Some("no-store, no-cache")
2109 );
2110 }
2111
2112 #[test]
2113 fn test_cache_control_must_revalidate() {
2114 let config = CacheControlConfig::new()
2115 .with_no_cache()
2116 .with_must_revalidate();
2117 assert_eq!(
2118 config.to_header_value().as_deref(),
2119 Some("no-cache, must-revalidate")
2120 );
2121 }
2122
2123 #[test]
2124 fn test_cache_control_immutable_long_max_age() {
2125 let config = CacheControlConfig::new()
2127 .with_public()
2128 .with_max_age(31536000)
2129 .with_immutable();
2130 assert_eq!(
2131 config.to_header_value().as_deref(),
2132 Some("public, max-age=31536000, immutable")
2133 );
2134 }
2135
2136 #[test]
2137 fn test_cache_control_full_directive_order() {
2138 let config = CacheControlConfig::new()
2140 .with_no_store()
2141 .with_no_cache()
2142 .with_public()
2143 .with_max_age(60)
2144 .with_must_revalidate()
2145 .with_immutable();
2146 assert_eq!(
2147 config.to_header_value().as_deref(),
2148 Some("no-store, no-cache, public, max-age=60, must-revalidate, immutable")
2149 );
2150 }
2151
2152 #[test]
2157 fn test_compute_etag_format() {
2158 let dir = create_test_dir();
2160 let file_path = dir.path().join("style.css");
2161 let metadata = std::fs::metadata(&file_path).unwrap();
2162
2163 let etag = compute_etag(&metadata).expect("ETag should be computed");
2164 assert!(
2165 etag.starts_with("W/\"") && etag.ends_with('"'),
2166 "ETag should be weak format W/\"...\", got: {etag}"
2167 );
2168 let inner = &etag[3..etag.len() - 1];
2170 let parts: Vec<&str> = inner.splitn(2, '-').collect();
2171 assert_eq!(parts.len(), 2, "ETag inner should be <mtime>-<size>");
2172 assert!(
2173 parts[0].chars().all(|c| c.is_ascii_digit()),
2174 "mtime should be numeric"
2175 );
2176 assert!(
2177 parts[1].chars().all(|c| c.is_ascii_digit()),
2178 "size should be numeric"
2179 );
2180 }
2181
2182 #[test]
2183 fn test_compute_etag_size_in_header() {
2184 let dir = create_test_dir();
2186 let file_path = dir.path().join("data.bin");
2187 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let metadata = std::fs::metadata(&file_path).unwrap();
2189
2190 let etag = compute_etag(&metadata).unwrap();
2191 assert!(
2192 etag.contains("-20\""),
2193 "ETag should contain file size 20, got: {etag}"
2194 );
2195 }
2196
2197 #[test]
2198 fn test_compute_etag_different_sizes_differ() {
2199 let dir = create_test_dir();
2200 let small_path = dir.path().join("small.bin");
2201 let large_path = dir.path().join("large.bin");
2202 fs::write(&small_path, b"short").unwrap();
2203 fs::write(&large_path, b"this is a much longer file content").unwrap();
2204
2205 let small_etag = compute_etag(&std::fs::metadata(&small_path).unwrap()).unwrap();
2206 let large_etag = compute_etag(&std::fs::metadata(&large_path).unwrap()).unwrap();
2207 assert_ne!(
2208 small_etag, large_etag,
2209 "Different file sizes should produce different ETags"
2210 );
2211 }
2212
2213 #[test]
2219 fn test_fingerprint_bytes_empty() {
2220 let hash = fingerprint_bytes(b"");
2222 assert_eq!(hash, "d41d8cd98f00b204e9800998ecf8427e");
2223 assert_eq!(hash.len(), 32, "MD5 hash should be 32 hex chars");
2224 }
2225
2226 #[test]
2227 fn test_fingerprint_bytes_hello() {
2228 let hash = fingerprint_bytes(b"hello");
2230 assert_eq!(hash, "5d41402abc4b2a76b9719d911017c592");
2231 }
2232
2233 #[test]
2234 fn test_fingerprint_bytes_known_php_value() {
2235 let hash = fingerprint_bytes(b"The quick brown fox jumps over the lazy dog");
2239 assert_eq!(hash, "9e107d9d372bb6826bd81d3542a419d6");
2240 }
2241
2242 #[test]
2243 fn test_fingerprint_bytes_lowercase_hex() {
2244 let hash = fingerprint_bytes(b"test");
2246 assert!(
2247 hash.chars()
2248 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
2249 "MD5 hash should be lowercase hex: {hash}"
2250 );
2251 }
2252
2253 #[test]
2254 fn test_fingerprint_file_reads_content() {
2255 let dir = create_test_dir();
2256 let file_path = dir.path().join("content.txt");
2257 fs::write(&file_path, b"hello").unwrap();
2258
2259 let file_hash = fingerprint_file(&file_path).unwrap();
2260 let bytes_hash = fingerprint_bytes(b"hello");
2261 assert_eq!(file_hash, bytes_hash);
2262 assert_eq!(file_hash, "5d41402abc4b2a76b9719d911017c592");
2263 }
2264
2265 #[test]
2266 fn test_fingerprint_file_missing_returns_err() {
2267 let dir = create_test_dir();
2268 let missing = dir.path().join("nonexistent.txt");
2269 let result = fingerprint_file(&missing);
2270 assert!(result.is_err(), "Missing file should return Err");
2271 }
2272
2273 #[test]
2279 fn test_extract_version_hash_valid() {
2280 let result = extract_version_hash("style.abc123def456.css");
2282 assert_eq!(
2283 result,
2284 Some(("style.css".to_string(), "abc123def456".to_string()))
2285 );
2286 }
2287
2288 #[test]
2289 fn test_extract_version_hash_path_with_dir() {
2290 let result = extract_version_hash("js/app.abc123def456.js");
2292 assert_eq!(
2293 result,
2294 Some(("js/app.js".to_string(), "abc123def456".to_string()))
2295 );
2296 }
2297
2298 #[test]
2299 fn test_extract_version_hash_multi_dot_stem() {
2300 let result = extract_version_hash("foo.bar.abc123def456.css");
2302 assert_eq!(
2303 result,
2304 Some(("foo.bar.css".to_string(), "abc123def456".to_string()))
2305 );
2306 }
2307
2308 #[test]
2309 fn test_extract_version_hash_min_8_chars() {
2310 let result = extract_version_hash("style.abc12345.css");
2312 assert_eq!(
2313 result,
2314 Some(("style.css".to_string(), "abc12345".to_string()))
2315 );
2316 }
2317
2318 #[test]
2319 fn test_extract_version_hash_no_hash() {
2320 let result = extract_version_hash("style.css");
2322 assert_eq!(result, None);
2323 }
2324
2325 #[test]
2326 fn test_extract_version_hash_short_hash() {
2327 let result = extract_version_hash("style.abc123.css");
2329 assert_eq!(result, None);
2330 }
2331
2332 #[test]
2333 fn test_extract_version_hash_non_hex() {
2334 let result = extract_version_hash("style.xyzghijk.css");
2336 assert_eq!(result, None);
2337 }
2338
2339 #[test]
2340 fn test_extract_version_hash_uppercase_hex() {
2341 let result = extract_version_hash("style.ABCDEF12.css");
2343 assert_eq!(
2344 result,
2345 Some(("style.css".to_string(), "ABCDEF12".to_string()))
2346 );
2347 }
2348
2349 #[test]
2350 fn test_extract_version_hash_no_extension() {
2351 let result = extract_version_hash("noextension");
2353 assert_eq!(result, None);
2354 }
2355
2356 #[test]
2357 fn test_extract_version_hash_empty_extension() {
2358 let result = extract_version_hash("style.abc123def456.");
2360 assert_eq!(result, None);
2361 }
2362
2363 #[tokio::test]
2368 async fn test_serve_file_with_cache_200_no_config() {
2369 let dir = create_test_dir();
2371 let file_path = dir.path().join("style.css");
2372 let headers = axum::http::HeaderMap::new();
2373
2374 let resp = serve_file_with_cache(&file_path, &headers, None);
2375 assert_eq!(resp.status(), StatusCode::OK);
2376
2377 let cc = resp.headers().get("cache-control");
2378 assert!(
2379 cc.is_none(),
2380 "Cache-Control should not be set without config"
2381 );
2382
2383 let etag = resp.headers().get("etag");
2385 assert!(etag.is_some(), "ETag should be set");
2386 }
2387
2388 #[tokio::test]
2389 async fn test_serve_file_with_cache_200_with_cache_control() {
2390 let dir = create_test_dir();
2391 let file_path = dir.path().join("style.css");
2392 let headers = axum::http::HeaderMap::new();
2393 let config = CacheControlConfig::new().with_public().with_max_age(3600);
2394
2395 let resp = serve_file_with_cache(&file_path, &headers, Some(&config));
2396 assert_eq!(resp.status(), StatusCode::OK);
2397
2398 let cc = resp
2399 .headers()
2400 .get("cache-control")
2401 .unwrap()
2402 .to_str()
2403 .unwrap();
2404 assert_eq!(cc, "public, max-age=3600");
2405 }
2406
2407 #[tokio::test]
2408 async fn test_serve_file_with_cache_etag_header_set() {
2409 let dir = create_test_dir();
2411 let file_path = dir.path().join("style.css");
2412 let headers = axum::http::HeaderMap::new();
2413
2414 let resp = serve_file_with_cache(&file_path, &headers, None);
2415 assert_eq!(resp.status(), StatusCode::OK);
2416
2417 let etag = resp.headers().get("etag").unwrap().to_str().unwrap();
2418 assert!(
2419 etag.starts_with("W/\""),
2420 "ETag should be weak format, got: {etag}"
2421 );
2422 }
2423
2424 #[tokio::test]
2425 async fn test_serve_file_with_cache_304_if_none_match_match() {
2426 let dir = create_test_dir();
2428 let file_path = dir.path().join("style.css");
2429
2430 let headers1 = axum::http::HeaderMap::new();
2432 let resp1 = serve_file_with_cache(&file_path, &headers1, None);
2433 let etag = resp1
2434 .headers()
2435 .get("etag")
2436 .unwrap()
2437 .to_str()
2438 .unwrap()
2439 .to_string();
2440
2441 let mut headers2 = axum::http::HeaderMap::new();
2443 headers2.insert(
2444 axum::http::header::IF_NONE_MATCH,
2445 axum::http::HeaderValue::from_str(&etag).unwrap(),
2446 );
2447 let resp2 = serve_file_with_cache(&file_path, &headers2, None);
2448 assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
2449
2450 let resp2_etag = resp2.headers().get("etag").unwrap().to_str().unwrap();
2452 assert_eq!(resp2_etag, etag);
2453
2454 assert!(
2456 resp2.headers().get("last-modified").is_some(),
2457 "304 should include Last-Modified"
2458 );
2459
2460 let bytes = resp2.into_body().collect().await.unwrap().to_bytes();
2462 assert!(bytes.is_empty(), "304 body should be empty");
2463 }
2464
2465 #[tokio::test]
2466 async fn test_serve_file_with_cache_304_if_none_match_star() {
2467 let dir = create_test_dir();
2469 let file_path = dir.path().join("style.css");
2470
2471 let mut headers = axum::http::HeaderMap::new();
2472 headers.insert(
2473 axum::http::header::IF_NONE_MATCH,
2474 axum::http::HeaderValue::from_static("*"),
2475 );
2476 let resp = serve_file_with_cache(&file_path, &headers, None);
2477 assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
2478 }
2479
2480 #[tokio::test]
2481 async fn test_serve_file_with_cache_200_if_none_match_mismatch() {
2482 let dir = create_test_dir();
2484 let file_path = dir.path().join("style.css");
2485
2486 let mut headers = axum::http::HeaderMap::new();
2487 headers.insert(
2488 axum::http::header::IF_NONE_MATCH,
2489 axum::http::HeaderValue::from_static("W/\"0-0\""),
2490 );
2491 let resp = serve_file_with_cache(&file_path, &headers, None);
2492 assert_eq!(resp.status(), StatusCode::OK);
2493 }
2494
2495 #[tokio::test]
2496 async fn test_serve_file_with_cache_304_includes_cache_control() {
2497 let dir = create_test_dir();
2499 let file_path = dir.path().join("style.css");
2500
2501 let headers1 = axum::http::HeaderMap::new();
2503 let config = CacheControlConfig::new().with_public().with_max_age(3600);
2504 let resp1 = serve_file_with_cache(&file_path, &headers1, Some(&config));
2505 let etag = resp1
2506 .headers()
2507 .get("etag")
2508 .unwrap()
2509 .to_str()
2510 .unwrap()
2511 .to_string();
2512
2513 let mut headers2 = axum::http::HeaderMap::new();
2515 headers2.insert(
2516 axum::http::header::IF_NONE_MATCH,
2517 axum::http::HeaderValue::from_str(&etag).unwrap(),
2518 );
2519 let resp2 = serve_file_with_cache(&file_path, &headers2, Some(&config));
2520 assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
2521
2522 let cc = resp2
2523 .headers()
2524 .get("cache-control")
2525 .expect("304 should include Cache-Control")
2526 .to_str()
2527 .unwrap();
2528 assert_eq!(cc, "public, max-age=3600");
2529 }
2530
2531 #[tokio::test]
2532 async fn test_serve_file_with_cache_304_if_modified_since_match() {
2533 let dir = create_test_dir();
2535 let file_path = dir.path().join("style.css");
2536
2537 let headers1 = axum::http::HeaderMap::new();
2539 let resp1 = serve_file_with_cache(&file_path, &headers1, None);
2540 let last_modified = resp1
2541 .headers()
2542 .get("last-modified")
2543 .unwrap()
2544 .to_str()
2545 .unwrap()
2546 .to_string();
2547
2548 let mut headers2 = axum::http::HeaderMap::new();
2550 headers2.insert(
2551 axum::http::header::IF_MODIFIED_SINCE,
2552 axum::http::HeaderValue::from_str(&last_modified).unwrap(),
2553 );
2554 let resp2 = serve_file_with_cache(&file_path, &headers2, None);
2555 assert_eq!(resp2.status(), StatusCode::NOT_MODIFIED);
2556 }
2557
2558 #[tokio::test]
2559 async fn test_serve_file_with_cache_if_none_match_takes_priority() {
2560 let dir = create_test_dir();
2563 let file_path = dir.path().join("style.css");
2564
2565 let headers1 = axum::http::HeaderMap::new();
2567 let resp1 = serve_file_with_cache(&file_path, &headers1, None);
2568 let last_modified = resp1
2569 .headers()
2570 .get("last-modified")
2571 .unwrap()
2572 .to_str()
2573 .unwrap()
2574 .to_string();
2575
2576 let mut headers2 = axum::http::HeaderMap::new();
2579 headers2.insert(
2580 axum::http::header::IF_NONE_MATCH,
2581 axum::http::HeaderValue::from_static("W/\"0-0\""),
2582 );
2583 headers2.insert(
2584 axum::http::header::IF_MODIFIED_SINCE,
2585 axum::http::HeaderValue::from_str(&last_modified).unwrap(),
2586 );
2587 let resp2 = serve_file_with_cache(&file_path, &headers2, None);
2588 assert_eq!(
2589 resp2.status(),
2590 StatusCode::OK,
2591 "If-None-Match should take priority over If-Modified-Since"
2592 );
2593 }
2594
2595 #[tokio::test]
2596 async fn test_serve_file_with_cache_404() {
2597 let dir = create_test_dir();
2598 let file_path = dir.path().join("nonexistent.txt");
2599 let headers = axum::http::HeaderMap::new();
2600
2601 let resp = serve_file_with_cache(&file_path, &headers, None);
2602 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
2603 }
2604
2605 #[tokio::test]
2606 async fn test_serve_file_with_cache_range_206_includes_etag() {
2607 let dir = create_test_dir();
2609 let file_path = dir.path().join("data.bin");
2610 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let mut headers = axum::http::HeaderMap::new();
2613 headers.insert(
2614 axum::http::header::RANGE,
2615 axum::http::HeaderValue::from_static("bytes=5-9"),
2616 );
2617
2618 let config = CacheControlConfig::new().with_max_age(3600);
2619 let resp = serve_file_with_cache(&file_path, &headers, Some(&config));
2620 assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
2621
2622 let cr = resp
2623 .headers()
2624 .get("content-range")
2625 .unwrap()
2626 .to_str()
2627 .unwrap();
2628 assert_eq!(cr, "bytes 5-9/20");
2629
2630 assert!(
2632 resp.headers().get("etag").is_some(),
2633 "206 should include ETag"
2634 );
2635
2636 let cc = resp
2638 .headers()
2639 .get("cache-control")
2640 .unwrap()
2641 .to_str()
2642 .unwrap();
2643 assert_eq!(cc, "max-age=3600");
2644
2645 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2646 assert_eq!(&bytes[..], b"56789");
2647 }
2648
2649 #[tokio::test]
2650 async fn test_serve_file_with_cache_range_416() {
2651 let dir = create_test_dir();
2653 let file_path = dir.path().join("data.bin");
2654 fs::write(&file_path, b"0123456789").unwrap(); let mut headers = axum::http::HeaderMap::new();
2657 headers.insert(
2658 axum::http::header::RANGE,
2659 axum::http::HeaderValue::from_static("bytes=100-200"),
2660 );
2661
2662 let resp = serve_file_with_cache(&file_path, &headers, None);
2663 assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
2664
2665 let cr = resp
2666 .headers()
2667 .get("content-range")
2668 .unwrap()
2669 .to_str()
2670 .unwrap();
2671 assert_eq!(cr, "bytes */10");
2672 }
2673
2674 #[tokio::test]
2675 async fn test_serve_file_with_cache_unknown_mime_content_disposition() {
2676 let dir = create_test_dir();
2678 let file_path = dir.path().join("unknown.xyzunknown");
2679 fs::write(&file_path, b"unknown content").unwrap();
2680
2681 let headers = axum::http::HeaderMap::new();
2682 let resp = serve_file_with_cache(&file_path, &headers, None);
2683 assert_eq!(resp.status(), StatusCode::OK);
2684
2685 let cd = resp
2686 .headers()
2687 .get("content-disposition")
2688 .expect("Content-Disposition should be set for unknown MIME");
2689 let cd_str = cd.to_str().unwrap();
2690 assert!(
2691 cd_str.contains("unknown.xyzunknown"),
2692 "Content-Disposition should contain filename, got: {cd_str}"
2693 );
2694 }
2695
2696 #[test]
2701 fn test_r5_php_no_etag_but_rust_extends_with_etag() {
2702 let dir = create_test_dir();
2708 let file_path = dir.path().join("style.css");
2709 let metadata = std::fs::metadata(&file_path).unwrap();
2710
2711 let etag = compute_etag(&metadata);
2712 assert!(etag.is_some(), "Rust should generate ETag (PHP doesn't)");
2713
2714 let etag_str = etag.unwrap();
2716 assert!(
2717 etag_str.starts_with("W/\"") && etag_str.ends_with('"'),
2718 "ETag should be nginx weak format"
2719 );
2720 }
2721
2722 #[test]
2723 fn test_r5_php_md5_alignment() {
2724 let rust_hash = fingerprint_bytes(b"hello");
2732 let php_hash = "5d41402abc4b2a76b9719d911017c592";
2733 assert_eq!(rust_hash, php_hash, "Rust MD5 should match PHP md5()");
2734 }
2735
2736 #[test]
2737 fn test_r5_nginx_etag_format_alignment() {
2738 let dir = create_test_dir();
2745 let file_path = dir.path().join("data.bin");
2746 fs::write(&file_path, b"0123456789ABCDEFGHIJ").unwrap(); let metadata = std::fs::metadata(&file_path).unwrap();
2748
2749 let mtime = metadata
2750 .modified()
2751 .unwrap()
2752 .duration_since(std::time::UNIX_EPOCH)
2753 .unwrap()
2754 .as_secs();
2755 let size = metadata.len();
2756
2757 let expected_etag = format!("W/\"{}-{}\"", mtime, size);
2758 let actual_etag = compute_etag(&metadata).unwrap();
2759 assert_eq!(
2760 actual_etag, expected_etag,
2761 "Rust ETag should match nginx format exactly"
2762 );
2763 }
2764}