1use std::fs::Metadata;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use super::http::ReadOnlyMethod;
10use super::response::{
11 BodyPlan, ConditionalRequestOutcome, FileRange, HeaderMapPlan, RangeRequestOutcome,
12 ResponseStatus, StaticResponsePlan,
13};
14
15pub fn plan_file_response(
20 method: ReadOnlyMethod,
21 metadata: &Metadata,
22 content_type: &str,
23 if_none_match: Option<&str>,
24 if_modified_since: Option<&str>,
25 range_header: Option<&str>,
26 if_range: Option<&str>,
27) -> StaticResponsePlan {
28 let etag = generate_etag(metadata);
29 let last_modified = metadata.modified().ok();
30 let last_modified_str = last_modified
31 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
32 .map(|d| httpdate::fmt_http_date(UNIX_EPOCH + d));
33 let len = metadata.len();
34
35 if let Some(ref etag_val) = etag {
36 let outcome = evaluate_conditional_headers(
37 etag_val,
38 last_modified_str.as_deref(),
39 if_none_match,
40 if_modified_since,
41 );
42 if let ConditionalRequestOutcome::NotModified(headers) = outcome {
43 return StaticResponsePlan {
44 status: ResponseStatus::NOT_MODIFIED,
45 headers,
46 body: BodyPlan::Empty,
47 };
48 }
49 }
50
51 if let Some(range) = range_header {
52 let range_outcome = evaluate_range_header(range, len);
53
54 let range_valid = match &range_outcome {
55 RangeRequestOutcome::Satisfiable(_) => {
56 if let Some(if_range) = if_range {
57 if_range_allows_range(if_range, etag.as_deref(), last_modified_str.as_deref())
58 } else {
59 true
60 }
61 }
62 _ => false,
63 };
64
65 if range_valid {
66 if let RangeRequestOutcome::Satisfiable(file_range) = range_outcome {
67 return build_range_response(
68 method,
69 file_range,
70 len,
71 content_type,
72 etag.as_deref(),
73 last_modified_str.as_deref(),
74 );
75 }
76 } else {
77 match range_outcome {
78 RangeRequestOutcome::NotSatisfiable => {
79 return build_not_range_satisfiable(len);
80 }
81 RangeRequestOutcome::Satisfiable(_) => {
82 }
84 _ => {}
85 }
86 }
87 }
88
89 build_full_response(
90 method,
91 len,
92 content_type,
93 &etag,
94 last_modified_str.as_deref(),
95 )
96}
97
98pub fn evaluate_conditional_headers(
103 current_etag: &str,
104 last_modified: Option<&str>,
105 if_none_match: Option<&str>,
106 if_modified_since: Option<&str>,
107) -> ConditionalRequestOutcome {
108 if let Some(inm) = if_none_match {
109 if evaluate_if_none_match(inm, current_etag) {
110 let mut headers = HeaderMapPlan::new();
111 headers.push("etag", current_etag.to_owned());
112 if let Some(lm) = last_modified {
113 headers.push("last-modified", lm.to_owned());
114 }
115 return ConditionalRequestOutcome::NotModified(headers);
116 }
117 return ConditionalRequestOutcome::FullResponse;
118 }
119
120 if let Some(ims) = if_modified_since {
121 if let Some(ims_time) = parse_http_date(ims) {
122 if let Some(lm) = last_modified {
123 if let Some(lm_time) = parse_http_date(lm) {
124 if lm_time <= ims_time {
125 let mut headers = HeaderMapPlan::new();
126 headers.push("etag", current_etag.to_owned());
127 headers.push("last-modified", lm.to_owned());
128 return ConditionalRequestOutcome::NotModified(headers);
129 }
130 }
131 }
132 return ConditionalRequestOutcome::FullResponse;
133 }
134 return ConditionalRequestOutcome::Malformed;
136 }
137
138 ConditionalRequestOutcome::FullResponse
139}
140
141pub fn evaluate_if_none_match(if_none_match: &str, current_etag: &str) -> bool {
146 let trimmed = if_none_match.trim();
147 if trimmed == "*" {
148 return true;
149 }
150
151 let current_weak = current_etag.starts_with("W/");
152 let current_inner = if current_weak {
153 ¤t_etag[2..]
154 } else {
155 current_etag
156 };
157
158 for etag in trimmed.split(',') {
159 let etag = etag.trim();
160 if etag.is_empty() {
161 continue;
162 }
163 let candidate_weak = etag.starts_with("W/");
164 let candidate_inner = if candidate_weak { &etag[2..] } else { etag };
165 if current_inner == candidate_inner {
166 return true;
167 }
168 }
169 false
170}
171
172pub fn evaluate_range_header(range: &str, file_size: u64) -> RangeRequestOutcome {
174 let range = range.trim();
175 if !range.starts_with("bytes=") {
176 return RangeRequestOutcome::MalformedOrUnsupported;
177 }
178
179 let range_value = &range[6..];
180 if range_value.is_empty() {
181 return RangeRequestOutcome::MalformedOrUnsupported;
182 }
183
184 let ranges: Vec<&str> = range_value.split(',').collect();
185 if ranges.len() > 1 {
186 return RangeRequestOutcome::MultipleRanges;
187 }
188
189 parse_single_range(ranges[0].trim(), file_size)
190}
191
192pub fn evaluate_if_range(
194 if_range: &str,
195 current_etag: Option<&str>,
196 last_modified: Option<&str>,
197) -> ConditionalRequestOutcome {
198 let trimmed = if_range.trim();
199 if trimmed.is_empty() {
200 return ConditionalRequestOutcome::Malformed;
201 }
202
203 if trimmed.starts_with('"') || trimmed.starts_with("W/") {
204 if is_strong_entity_tag(trimmed)
207 && current_etag.is_some_and(is_strong_entity_tag)
208 && current_etag == Some(trimmed)
209 {
210 return ConditionalRequestOutcome::NotModified(HeaderMapPlan::new());
211 }
212 return ConditionalRequestOutcome::FullResponse;
213 }
214
215 if let Some(lm) = last_modified {
217 if let (Some(if_range_time), Some(lm_time)) =
218 (parse_http_date(trimmed), parse_http_date(lm))
219 {
220 if if_range_time == lm_time {
221 return ConditionalRequestOutcome::NotModified(HeaderMapPlan::new());
222 }
223 }
224 }
225
226 ConditionalRequestOutcome::FullResponse
227}
228
229fn if_range_allows_range(
230 if_range: &str,
231 current_etag: Option<&str>,
232 last_modified: Option<&str>,
233) -> bool {
234 matches!(
235 evaluate_if_range(if_range, current_etag, last_modified),
236 ConditionalRequestOutcome::NotModified(_)
237 )
238}
239
240fn is_strong_entity_tag(value: &str) -> bool {
241 let bytes = value.as_bytes();
242 bytes.len() >= 2
243 && bytes[0] == b'"'
244 && bytes[bytes.len() - 1] == b'"'
245 && bytes[1..bytes.len() - 1]
246 .iter()
247 .all(|byte| *byte == b'!' || (0x23..=0x7e).contains(byte))
248}
249
250pub fn generate_etag(metadata: &Metadata) -> Option<String> {
256 let size = metadata.len();
257 let mtime = metadata.modified().ok()?;
258 let epoch = mtime.duration_since(UNIX_EPOCH).ok()?;
259 let mtime_secs = epoch.as_secs();
260 let mtime_nanos = epoch.subsec_nanos();
261 Some(format!("W/\"{}-{}-{}\"", size, mtime_secs, mtime_nanos))
262}
263
264fn build_full_response(
265 method: ReadOnlyMethod,
266 len: u64,
267 content_type: &str,
268 etag: &Option<String>,
269 last_modified: Option<&str>,
270) -> StaticResponsePlan {
271 let mut headers = HeaderMapPlan::new();
272 headers.push("content-length", len.to_string());
273 headers.push("content-type", content_type.to_owned());
274 headers.push("accept-ranges", "bytes".to_owned());
275 headers.push("x-content-type-options", "nosniff".to_owned());
276
277 if let Some(lm) = last_modified {
278 headers.push("last-modified", lm.to_owned());
279 }
280 if let Some(tag) = etag {
281 headers.push("etag", tag.clone());
282 }
283
284 let body = if method == ReadOnlyMethod::Head {
285 BodyPlan::Empty
286 } else {
287 BodyPlan::FileFull
288 };
289
290 StaticResponsePlan {
291 status: ResponseStatus::OK,
292 headers,
293 body,
294 }
295}
296
297fn build_range_response(
298 method: ReadOnlyMethod,
299 range: FileRange,
300 file_size: u64,
301 content_type: &str,
302 etag: Option<&str>,
303 last_modified: Option<&str>,
304) -> StaticResponsePlan {
305 let mut headers = HeaderMapPlan::new();
306 let content_length = range.len();
307 headers.push("content-length", content_length.to_string());
308 headers.push("content-type", content_type.to_owned());
309 headers.push("accept-ranges", "bytes".to_owned());
310 headers.push(
311 "content-range",
312 format!(
313 "bytes {}-{}/{}",
314 range.start, range.end_inclusive, file_size
315 ),
316 );
317 headers.push("x-content-type-options", "nosniff".to_owned());
318
319 if let Some(lm) = last_modified {
320 headers.push("last-modified", lm.to_owned());
321 }
322 if let Some(tag) = etag {
323 headers.push("etag", tag.to_owned());
324 }
325
326 let body = if method == ReadOnlyMethod::Head {
327 BodyPlan::Empty
328 } else {
329 BodyPlan::FileRange {
330 start: range.start,
331 end_inclusive: range.end_inclusive,
332 }
333 };
334
335 StaticResponsePlan {
336 status: ResponseStatus::PARTIAL_CONTENT,
337 headers,
338 body,
339 }
340}
341
342fn build_not_range_satisfiable(file_size: u64) -> StaticResponsePlan {
343 let mut headers = HeaderMapPlan::new();
344 headers.push("content-length", "0".to_owned());
345 headers.push("accept-ranges", "bytes".to_owned());
346 headers.push("content-range", format!("bytes */{}", file_size));
347
348 StaticResponsePlan {
349 status: ResponseStatus::NOT_RANGE_SATISFIABLE,
350 headers,
351 body: BodyPlan::Empty,
352 }
353}
354
355fn parse_single_range(range: &str, file_size: u64) -> RangeRequestOutcome {
356 if file_size == 0 {
357 return RangeRequestOutcome::NotSatisfiable;
358 }
359
360 if let Some(suffix_len_str) = range.strip_prefix('-') {
361 let suffix_len: u64 = match suffix_len_str.parse() {
363 Ok(n) => n,
364 Err(_) => return RangeRequestOutcome::MalformedOrUnsupported,
365 };
366 if suffix_len == 0 {
367 return RangeRequestOutcome::MalformedOrUnsupported;
368 }
369 let start = file_size.saturating_sub(suffix_len);
370 if start >= file_size {
371 return RangeRequestOutcome::NotSatisfiable;
372 }
373 return RangeRequestOutcome::Satisfiable(FileRange::new(start, file_size - 1));
374 }
375
376 let parts: Vec<&str> = range.splitn(2, '-').collect();
378 if parts.len() != 2 {
379 return RangeRequestOutcome::MalformedOrUnsupported;
380 }
381
382 let start: u64 = match parts[0].parse() {
383 Ok(n) => n,
384 Err(_) => return RangeRequestOutcome::MalformedOrUnsupported,
385 };
386
387 if parts[1].is_empty() {
388 if start >= file_size {
390 return RangeRequestOutcome::NotSatisfiable;
391 }
392 return RangeRequestOutcome::Satisfiable(FileRange::new(start, file_size - 1));
393 }
394
395 let end: u64 = match parts[1].parse() {
397 Ok(n) => n,
398 Err(_) => return RangeRequestOutcome::MalformedOrUnsupported,
399 };
400
401 if start > end {
402 return RangeRequestOutcome::NotSatisfiable;
403 }
404 if start >= file_size {
405 return RangeRequestOutcome::NotSatisfiable;
406 }
407
408 let end = end.min(file_size - 1);
409 RangeRequestOutcome::Satisfiable(FileRange::new(start, end))
410}
411
412fn parse_http_date(s: &str) -> Option<SystemTime> {
413 httpdate::parse_http_date(s).ok()
414}
415
416pub fn plan_directory_listing(content_length: usize, is_head: bool) -> StaticResponsePlan {
418 let mut headers = HeaderMapPlan::new();
419 headers.push("content-type", "text/html; charset=utf-8".to_owned());
420 headers.push("content-length", content_length.to_string());
421 headers.push("x-content-type-options", "nosniff".to_owned());
422 headers.push(
423 "content-security-policy",
424 "default-src 'none'; base-uri 'none'; form-action 'none'".to_owned(),
425 );
426 headers.push("referrer-policy", "no-referrer".to_owned());
427
428 let body = if is_head {
429 BodyPlan::Empty
430 } else {
431 BodyPlan::FullBytes(Vec::new()) };
433
434 StaticResponsePlan {
435 status: ResponseStatus::OK,
436 headers,
437 body,
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use proptest::prelude::*;
445 use std::io::Write;
446
447 fn make_file_with_size(size: u64) -> tempfile::NamedTempFile {
448 let mut tmp = tempfile::NamedTempFile::new().unwrap();
449 let data = vec![0u8; size as usize];
450 tmp.write_all(&data).unwrap();
451 tmp.flush().unwrap();
452 tmp
453 }
454
455 #[test]
456 fn plan_file_response_200_get() {
457 let tmp = make_file_with_size(1024);
458 let meta = std::fs::metadata(tmp.path()).unwrap();
459
460 let plan = plan_file_response(
461 ReadOnlyMethod::Get,
462 &meta,
463 "text/plain; charset=utf-8",
464 None,
465 None,
466 None,
467 None,
468 );
469
470 assert_eq!(plan.status.as_u16(), 200);
471 assert_eq!(plan.headers.get("content-length"), Some("1024"));
472 assert_eq!(
473 plan.headers.get("content-type"),
474 Some("text/plain; charset=utf-8")
475 );
476 assert_eq!(plan.headers.get("x-content-type-options"), Some("nosniff"));
477 assert!(plan.headers.get("etag").is_some());
478 assert!(plan.headers.get("last-modified").is_some());
479 assert_eq!(plan.body, BodyPlan::FileFull);
480 }
481
482 #[test]
483 fn plan_file_response_200_head_empty_body() {
484 let tmp = make_file_with_size(512);
485 let meta = std::fs::metadata(tmp.path()).unwrap();
486
487 let plan = plan_file_response(
488 ReadOnlyMethod::Head,
489 &meta,
490 "text/html; charset=utf-8",
491 None,
492 None,
493 None,
494 None,
495 );
496
497 assert_eq!(plan.status.as_u16(), 200);
498 assert_eq!(plan.body, BodyPlan::Empty);
499 assert_eq!(plan.headers.get("content-length"), Some("512"));
500 }
501
502 #[test]
503 fn plan_file_response_etag_and_last_modified() {
504 let tmp = make_file_with_size(100);
505 let meta = std::fs::metadata(tmp.path()).unwrap();
506
507 let plan = plan_file_response(
508 ReadOnlyMethod::Get,
509 &meta,
510 "text/plain",
511 None,
512 None,
513 None,
514 None,
515 );
516
517 let etag = plan.headers.get("etag").unwrap();
518 assert!(etag.starts_with("W/\""));
519 assert!(plan.headers.get("last-modified").is_some());
520 }
521
522 #[test]
523 fn plan_file_response_matching_if_none_match_304() {
524 let tmp = make_file_with_size(100);
525 let meta = std::fs::metadata(tmp.path()).unwrap();
526
527 let etag = generate_etag(&meta).unwrap();
528
529 let plan = plan_file_response(
530 ReadOnlyMethod::Get,
531 &meta,
532 "text/plain",
533 Some(&etag),
534 None,
535 None,
536 None,
537 );
538
539 assert_eq!(plan.status.as_u16(), 304);
540 assert_eq!(plan.body, BodyPlan::Empty);
541 assert!(plan.headers.get("etag").is_some());
542 }
543
544 #[test]
545 fn plan_file_response_nonmatching_if_none_match_200() {
546 let tmp = make_file_with_size(100);
547 let meta = std::fs::metadata(tmp.path()).unwrap();
548
549 let plan = plan_file_response(
550 ReadOnlyMethod::Get,
551 &meta,
552 "text/plain",
553 Some("W/\"999-999\""),
554 None,
555 None,
556 None,
557 );
558
559 assert_eq!(plan.status.as_u16(), 200);
560 assert_eq!(plan.body, BodyPlan::FileFull);
561 }
562
563 #[test]
564 fn plan_file_response_wildcard_if_none_match_304() {
565 let tmp = make_file_with_size(100);
566 let meta = std::fs::metadata(tmp.path()).unwrap();
567
568 let plan = plan_file_response(
569 ReadOnlyMethod::Get,
570 &meta,
571 "text/plain",
572 Some("*"),
573 None,
574 None,
575 None,
576 );
577
578 assert_eq!(plan.status.as_u16(), 304);
579 assert_eq!(plan.body, BodyPlan::Empty);
580 }
581
582 #[test]
583 fn plan_file_response_matching_if_modified_since_304() {
584 let tmp = make_file_with_size(100);
585 let meta = std::fs::metadata(tmp.path()).unwrap();
586
587 let lm = meta.modified().unwrap();
589 let lm_secs = lm.duration_since(UNIX_EPOCH).unwrap().as_secs();
590 let future = UNIX_EPOCH + std::time::Duration::from_secs(lm_secs + 3600);
591 let ims = httpdate::fmt_http_date(future);
592
593 let plan = plan_file_response(
594 ReadOnlyMethod::Get,
595 &meta,
596 "text/plain",
597 None,
598 Some(&ims),
599 None,
600 None,
601 );
602
603 assert_eq!(plan.status.as_u16(), 304);
604 }
605
606 #[test]
607 fn plan_file_response_stale_if_modified_since_200() {
608 let tmp = make_file_with_size(100);
609 let meta = std::fs::metadata(tmp.path()).unwrap();
610
611 let lm = meta.modified().unwrap();
613 let lm_secs = lm.duration_since(UNIX_EPOCH).unwrap().as_secs();
614 let past = UNIX_EPOCH + std::time::Duration::from_secs(lm_secs.saturating_sub(3600));
615 let ims = httpdate::fmt_http_date(past);
616
617 let plan = plan_file_response(
618 ReadOnlyMethod::Get,
619 &meta,
620 "text/plain",
621 None,
622 Some(&ims),
623 None,
624 None,
625 );
626
627 assert_eq!(plan.status.as_u16(), 200);
628 }
629
630 #[test]
631 fn plan_file_response_invalid_if_modified_since_200() {
632 let tmp = make_file_with_size(100);
633 let meta = std::fs::metadata(tmp.path()).unwrap();
634
635 let plan = plan_file_response(
636 ReadOnlyMethod::Get,
637 &meta,
638 "text/plain",
639 None,
640 Some("not-a-date"),
641 None,
642 None,
643 );
644
645 assert_eq!(plan.status.as_u16(), 200);
646 }
647
648 #[test]
649 fn plan_file_response_head_conditional_matches_get_status() {
650 let tmp = make_file_with_size(100);
651 let meta = std::fs::metadata(tmp.path()).unwrap();
652
653 let etag = generate_etag(&meta).unwrap();
654
655 let get_plan = plan_file_response(
656 ReadOnlyMethod::Get,
657 &meta,
658 "text/plain",
659 Some(&etag),
660 None,
661 None,
662 None,
663 );
664 let head_plan = plan_file_response(
665 ReadOnlyMethod::Head,
666 &meta,
667 "text/plain",
668 Some(&etag),
669 None,
670 None,
671 None,
672 );
673
674 assert_eq!(get_plan.status.as_u16(), head_plan.status.as_u16());
675 assert_eq!(head_plan.body, BodyPlan::Empty);
676 }
677
678 #[test]
679 fn plan_file_response_range_206() {
680 let tmp = make_file_with_size(100);
681 let meta = std::fs::metadata(tmp.path()).unwrap();
682
683 let plan = plan_file_response(
684 ReadOnlyMethod::Get,
685 &meta,
686 "text/plain",
687 None,
688 None,
689 Some("bytes=0-49"),
690 None,
691 );
692
693 assert_eq!(plan.status.as_u16(), 206);
694 assert_eq!(plan.headers.get("content-range"), Some("bytes 0-49/100"));
695 assert_eq!(plan.headers.get("content-length"), Some("50"));
696 assert_eq!(plan.headers.get("content-type"), Some("text/plain"));
697 assert_eq!(plan.headers.get("accept-ranges"), Some("bytes"));
698 assert!(plan.headers.get("etag").is_some());
699 assert!(plan.headers.get("last-modified").is_some());
700 }
701
702 #[test]
703 fn plan_file_response_range_416() {
704 let tmp = make_file_with_size(100);
705 let meta = std::fs::metadata(tmp.path()).unwrap();
706
707 let plan = plan_file_response(
708 ReadOnlyMethod::Get,
709 &meta,
710 "text/plain",
711 None,
712 None,
713 Some("bytes=200-300"),
714 None,
715 );
716
717 assert_eq!(plan.status.as_u16(), 416);
718 assert_eq!(plan.headers.get("content-range"), Some("bytes */100"));
719 assert_eq!(plan.headers.get("content-length"), Some("0"));
720 assert_eq!(plan.headers.get("accept-ranges"), Some("bytes"));
721 assert_eq!(plan.body, BodyPlan::Empty);
722 }
723
724 #[test]
725 fn plan_file_response_head_range_empty_body() {
726 let tmp = make_file_with_size(100);
727 let meta = std::fs::metadata(tmp.path()).unwrap();
728
729 let plan = plan_file_response(
730 ReadOnlyMethod::Head,
731 &meta,
732 "text/plain",
733 None,
734 None,
735 Some("bytes=0-49"),
736 None,
737 );
738
739 assert_eq!(plan.status.as_u16(), 206);
740 assert_eq!(plan.body, BodyPlan::Empty);
741 assert_eq!(plan.headers.get("content-length"), Some("50"));
742 assert_eq!(plan.headers.get("content-type"), Some("text/plain"));
743 }
744
745 #[test]
746 fn plan_file_response_if_range_weak_etag_ignored_200() {
747 let tmp = make_file_with_size(100);
748 let meta = std::fs::metadata(tmp.path()).unwrap();
749 let etag = generate_etag(&meta).unwrap();
750
751 let plan = plan_file_response(
752 ReadOnlyMethod::Get,
753 &meta,
754 "text/plain",
755 None,
756 None,
757 Some("bytes=0-49"),
758 Some(&etag),
759 );
760
761 assert_eq!(plan.status.as_u16(), 200);
762 }
763
764 #[test]
765 fn plan_file_response_if_range_nonmatching_200() {
766 let tmp = make_file_with_size(100);
767 let meta = std::fs::metadata(tmp.path()).unwrap();
768
769 let plan = plan_file_response(
770 ReadOnlyMethod::Get,
771 &meta,
772 "text/plain",
773 None,
774 None,
775 Some("bytes=0-49"),
776 Some("W/\"999-999\""),
777 );
778
779 assert_eq!(plan.status.as_u16(), 200);
780 assert_eq!(plan.body, BodyPlan::FileFull);
781 }
782
783 #[test]
784 fn plan_file_response_suffix_range() {
785 let tmp = make_file_with_size(100);
786 let meta = std::fs::metadata(tmp.path()).unwrap();
787
788 let plan = plan_file_response(
789 ReadOnlyMethod::Get,
790 &meta,
791 "text/plain",
792 None,
793 None,
794 Some("bytes=-10"),
795 None,
796 );
797
798 assert_eq!(plan.status.as_u16(), 206);
799 assert_eq!(plan.headers.get("content-range"), Some("bytes 90-99/100"));
800 assert_eq!(plan.headers.get("content-length"), Some("10"));
801 }
802
803 #[test]
804 fn plan_file_response_open_ended_range() {
805 let tmp = make_file_with_size(100);
806 let meta = std::fs::metadata(tmp.path()).unwrap();
807
808 let plan = plan_file_response(
809 ReadOnlyMethod::Get,
810 &meta,
811 "text/plain",
812 None,
813 None,
814 Some("bytes=50-"),
815 None,
816 );
817
818 assert_eq!(plan.status.as_u16(), 206);
819 assert_eq!(plan.headers.get("content-range"), Some("bytes 50-99/100"));
820 assert_eq!(plan.headers.get("content-length"), Some("50"));
821 }
822
823 #[test]
824 fn plan_file_response_multiple_ranges_200() {
825 let tmp = make_file_with_size(100);
826 let meta = std::fs::metadata(tmp.path()).unwrap();
827
828 let plan = plan_file_response(
829 ReadOnlyMethod::Get,
830 &meta,
831 "text/plain",
832 None,
833 None,
834 Some("bytes=0-9, 50-59"),
835 None,
836 );
837
838 assert_eq!(plan.status.as_u16(), 200);
839 assert_eq!(plan.body, BodyPlan::FileFull);
840 }
841
842 #[test]
843 fn evaluate_range_header_prefix() {
844 let result = evaluate_range_header("bytes=0-9", 100);
845 assert!(matches!(result, RangeRequestOutcome::Satisfiable(_)));
846
847 let result = evaluate_range_header("none=0-9", 100);
848 assert_eq!(result, RangeRequestOutcome::MalformedOrUnsupported);
849 }
850
851 #[test]
852 fn evaluate_range_header_empty() {
853 let result = evaluate_range_header("bytes=", 100);
854 assert_eq!(result, RangeRequestOutcome::MalformedOrUnsupported);
855 }
856
857 #[test]
858 fn evaluate_range_header_suffix_zero() {
859 let result = evaluate_range_header("bytes=-0", 100);
860 assert_eq!(result, RangeRequestOutcome::MalformedOrUnsupported);
861 }
862
863 #[test]
864 fn evaluate_range_header_suffix_exceeds_file_returns_whole_file() {
865 let result = evaluate_range_header("bytes=-200", 100);
866 assert_eq!(
867 result,
868 RangeRequestOutcome::Satisfiable(FileRange::new(0, 99))
869 );
870 }
871
872 #[test]
873 fn evaluate_range_header_start_beyond_file() {
874 let result = evaluate_range_header("bytes=200-300", 100);
875 assert_eq!(result, RangeRequestOutcome::NotSatisfiable);
876 }
877
878 #[test]
879 fn evaluate_range_header_start_equals_end_beyond_file() {
880 let result = evaluate_range_header("bytes=100-100", 100);
881 assert_eq!(result, RangeRequestOutcome::NotSatisfiable);
882 }
883
884 #[test]
885 fn evaluate_range_header_inverted_range() {
886 let result = evaluate_range_header("bytes=50-10", 100);
887 assert_eq!(result, RangeRequestOutcome::NotSatisfiable);
888 }
889
890 #[test]
891 fn evaluate_range_header_non_numeric() {
892 let result = evaluate_range_header("bytes=abc-def", 100);
893 assert_eq!(result, RangeRequestOutcome::MalformedOrUnsupported);
894 }
895
896 #[test]
897 fn evaluate_range_header_end_clamped_to_file_size() {
898 let result = evaluate_range_header("bytes=90-200", 100);
899 assert_eq!(
900 result,
901 RangeRequestOutcome::Satisfiable(FileRange::new(90, 99))
902 );
903 }
904
905 #[test]
906 fn evaluate_range_header_zero_file_size() {
907 let result = evaluate_range_header("bytes=0-0", 0);
908 assert_eq!(result, RangeRequestOutcome::NotSatisfiable);
909 }
910
911 #[test]
912 fn evaluate_if_none_match_etag_matches() {
913 assert!(evaluate_if_none_match("W/\"100-1234\"", "W/\"100-1234\""));
914 }
915
916 #[test]
917 fn evaluate_if_none_match_etag_does_not_match() {
918 assert!(!evaluate_if_none_match("W/\"999-999\"", "W/\"100-1234\""));
919 }
920
921 #[test]
922 fn evaluate_if_none_match_wildcard() {
923 assert!(evaluate_if_none_match("*", "W/\"100-1234\""));
924 }
925
926 #[test]
927 fn evaluate_if_none_match_list() {
928 assert!(evaluate_if_none_match(
929 "W/\"999-999\", W/\"100-1234\"",
930 "W/\"100-1234\""
931 ));
932 assert!(!evaluate_if_none_match(
933 "W/\"999-999\", W/\"888-888\"",
934 "W/\"100-1234\""
935 ));
936 }
937
938 #[test]
939 fn generate_etag_format() {
940 let tmp = make_file_with_size(42);
941 let meta = std::fs::metadata(tmp.path()).unwrap();
942 let etag = generate_etag(&meta).unwrap();
943 assert!(etag.starts_with("W/\"42-"));
944 assert!(etag.ends_with('"'));
945 }
946
947 #[test]
948 fn plan_directory_listing_200() {
949 let plan = plan_directory_listing(1234, false);
950 assert_eq!(plan.status.as_u16(), 200);
951 assert_eq!(
952 plan.headers.get("content-type"),
953 Some("text/html; charset=utf-8")
954 );
955 assert_eq!(plan.headers.get("content-length"), Some("1234"));
956 assert_eq!(
957 plan.headers.get("content-security-policy"),
958 Some("default-src 'none'; base-uri 'none'; form-action 'none'")
959 );
960 assert_eq!(plan.headers.get("referrer-policy"), Some("no-referrer"));
961 assert_eq!(plan.headers.get("x-content-type-options"), Some("nosniff"));
962 }
963
964 #[test]
965 fn plan_directory_listing_head_empty_body() {
966 let plan = plan_directory_listing(500, true);
967 assert_eq!(plan.status.as_u16(), 200);
968 assert_eq!(plan.body, BodyPlan::Empty);
969 assert_eq!(plan.headers.get("content-length"), Some("500"));
970 }
971
972 #[test]
973 fn evaluate_if_none_match_weak_etag_matches_strong() {
974 assert!(evaluate_if_none_match("W/\"100-1234\"", "\"100-1234\""));
975 }
976
977 #[test]
978 fn evaluate_if_none_match_strong_etag_matches_weak() {
979 assert!(evaluate_if_none_match("\"100-1234\"", "W/\"100-1234\""));
980 }
981
982 #[test]
983 fn evaluate_if_none_match_empty_list() {
984 assert!(!evaluate_if_none_match("", "W/\"100-1234\""));
985 }
986
987 #[test]
988 fn evaluate_range_header_first_byte() {
989 let result = evaluate_range_header("bytes=0-0", 100);
990 assert_eq!(
991 result,
992 RangeRequestOutcome::Satisfiable(FileRange::new(0, 0))
993 );
994 }
995
996 #[test]
997 fn evaluate_range_header_open_ended() {
998 let result = evaluate_range_header("bytes=50-", 100);
999 assert_eq!(
1000 result,
1001 RangeRequestOutcome::Satisfiable(FileRange::new(50, 99))
1002 );
1003 }
1004
1005 #[test]
1006 fn evaluate_range_header_suffix_one() {
1007 let result = evaluate_range_header("bytes=-1", 100);
1008 assert_eq!(
1009 result,
1010 RangeRequestOutcome::Satisfiable(FileRange::new(99, 99))
1011 );
1012 }
1013
1014 #[test]
1015 fn evaluate_range_header_suffix_larger_than_file() {
1016 let result = evaluate_range_header("bytes=-200", 100);
1017 assert_eq!(
1018 result,
1019 RangeRequestOutcome::Satisfiable(FileRange::new(0, 99))
1020 );
1021 }
1022
1023 #[test]
1024 fn evaluate_range_header_start_beyond_eof() {
1025 let result = evaluate_range_header("bytes=100-", 100);
1026 assert_eq!(result, RangeRequestOutcome::NotSatisfiable);
1027 }
1028
1029 #[test]
1030 fn evaluate_range_header_start_greater_than_end() {
1031 let result = evaluate_range_header("bytes=50-10", 100);
1032 assert_eq!(result, RangeRequestOutcome::NotSatisfiable);
1033 }
1034
1035 #[test]
1036 fn evaluate_range_header_unsupported_unit() {
1037 let result = evaluate_range_header("items=0-9", 100);
1038 assert_eq!(result, RangeRequestOutcome::MalformedOrUnsupported);
1039 }
1040
1041 #[test]
1042 fn evaluate_range_header_multiple_ranges() {
1043 let result = evaluate_range_header("bytes=0-9, 50-59", 100);
1044 assert_eq!(result, RangeRequestOutcome::MultipleRanges);
1045 }
1046
1047 #[test]
1048 fn plan_file_response_zero_length_file_range_416() {
1049 let tmp = make_file_with_size(0);
1050 let meta = std::fs::metadata(tmp.path()).unwrap();
1051
1052 let plan = plan_file_response(
1053 ReadOnlyMethod::Get,
1054 &meta,
1055 "application/octet-stream",
1056 None,
1057 None,
1058 Some("bytes=0-0"),
1059 None,
1060 );
1061
1062 assert_eq!(plan.status.as_u16(), 416);
1063 assert_eq!(plan.body, BodyPlan::Empty);
1064 }
1065
1066 #[test]
1067 fn plan_file_response_if_range_matching_date_206() {
1068 let tmp = make_file_with_size(100);
1069 let meta = std::fs::metadata(tmp.path()).unwrap();
1070
1071 let lm = meta.modified().unwrap();
1072 let lm_secs = lm.duration_since(UNIX_EPOCH).unwrap().as_secs();
1073 let lm_time = UNIX_EPOCH + std::time::Duration::from_secs(lm_secs);
1074 let date_str = httpdate::fmt_http_date(lm_time);
1075
1076 let plan = plan_file_response(
1077 ReadOnlyMethod::Get,
1078 &meta,
1079 "text/plain",
1080 None,
1081 None,
1082 Some("bytes=0-49"),
1083 Some(&date_str),
1084 );
1085
1086 assert_eq!(plan.status.as_u16(), 206);
1087 }
1088
1089 #[test]
1090 fn plan_file_response_if_range_stale_date_200() {
1091 let tmp = make_file_with_size(100);
1092 let meta = std::fs::metadata(tmp.path()).unwrap();
1093
1094 let stale = UNIX_EPOCH + std::time::Duration::from_secs(0);
1095 let date_str = httpdate::fmt_http_date(stale);
1096
1097 let plan = plan_file_response(
1098 ReadOnlyMethod::Get,
1099 &meta,
1100 "text/plain",
1101 None,
1102 None,
1103 Some("bytes=0-49"),
1104 Some(&date_str),
1105 );
1106
1107 assert_eq!(plan.status.as_u16(), 200);
1108 assert_eq!(plan.body, BodyPlan::FileFull);
1109 }
1110
1111 #[test]
1112 fn plan_file_response_head_with_range_returns_headers_no_body() {
1113 let tmp = make_file_with_size(100);
1114 let meta = std::fs::metadata(tmp.path()).unwrap();
1115
1116 let plan = plan_file_response(
1117 ReadOnlyMethod::Head,
1118 &meta,
1119 "text/plain",
1120 None,
1121 None,
1122 Some("bytes=0-0"),
1123 None,
1124 );
1125
1126 assert_eq!(plan.status.as_u16(), 206);
1127 assert_eq!(plan.body, BodyPlan::Empty);
1128 assert_eq!(plan.headers.get("content-length"), Some("1"));
1129 assert_eq!(plan.headers.get("content-range"), Some("bytes 0-0/100"));
1130 }
1131
1132 #[test]
1133 fn evaluate_conditional_headers_both_present_etag_wins() {
1134 let tmp = make_file_with_size(100);
1135 let meta = std::fs::metadata(tmp.path()).unwrap();
1136 let etag = generate_etag(&meta).unwrap();
1137
1138 let outcome = evaluate_conditional_headers(
1139 &etag,
1140 None,
1141 Some(&etag),
1142 Some("Tue, 01 Jan 2030 00:00:00 GMT"),
1143 );
1144
1145 assert!(matches!(outcome, ConditionalRequestOutcome::NotModified(_)));
1146 }
1147
1148 #[test]
1149 fn evaluate_conditional_headers_no_match_no_ims() {
1150 let outcome =
1151 evaluate_conditional_headers("W/\"100-1234\"", None, Some("W/\"999-999\""), None);
1152
1153 assert_eq!(outcome, ConditionalRequestOutcome::FullResponse);
1154 }
1155
1156 #[test]
1157 fn property_range_always_within_file_size() {
1158 let file_sizes = [1u64, 10, 100, 1000, u64::MAX];
1159 let range_headers = [
1160 "bytes=0-0",
1161 "bytes=0-49",
1162 "bytes=50-",
1163 "bytes=-10",
1164 "bytes=0-999999",
1165 "bytes=-999999",
1166 "bytes=50-10",
1167 "bytes=200-300",
1168 "bytes=abc",
1169 "bytes=",
1170 "items=0-9",
1171 "bytes=-0",
1172 "none=0-9",
1173 ];
1174
1175 for &file_size in &file_sizes {
1176 for header in &range_headers {
1177 let outcome = evaluate_range_header(header, file_size);
1178 if let RangeRequestOutcome::Satisfiable(range) = outcome {
1179 assert!(
1180 range.start < file_size,
1181 "range start {} >= file_size {} for header {:?}",
1182 range.start,
1183 file_size,
1184 header
1185 );
1186 assert!(
1187 range.end_inclusive < file_size,
1188 "range end {} >= file_size {} for header {:?}",
1189 range.end_inclusive,
1190 file_size,
1191 header
1192 );
1193 assert!(
1194 range.start <= range.end_inclusive,
1195 "range start {} > end {} for header {:?}",
1196 range.start,
1197 range.end_inclusive,
1198 header
1199 );
1200 assert!(
1201 !range.is_empty(),
1202 "range length is 0 for header {:?}",
1203 header
1204 );
1205 assert!(
1206 range.len() <= file_size,
1207 "range length {} > file_size {} for header {:?}",
1208 range.len(),
1209 file_size,
1210 header
1211 );
1212 }
1213 }
1214 }
1215 }
1216
1217 #[test]
1218 fn property_etag_format() {
1219 let sizes = [0u64, 1, 42, 1024, 1024 * 1024];
1220 for size in sizes {
1221 let tmp = make_file_with_size(size);
1222 let meta = std::fs::metadata(tmp.path()).unwrap();
1223 if let Some(etag) = generate_etag(&meta) {
1224 assert!(
1225 etag.starts_with("W/\""),
1226 "ETag does not start with W/\": {:?}",
1227 etag
1228 );
1229 assert!(etag.ends_with('"'), "ETag does not end with \": {:?}", etag);
1230 assert!(
1232 etag.contains(&size.to_string()),
1233 "ETag {:?} does not contain size {}",
1234 etag,
1235 size
1236 );
1237 assert!(!etag.contains('\r'), "CR in ETag: {:?}", etag);
1239 assert!(!etag.contains('\n'), "LF in ETag: {:?}", etag);
1240 }
1241 }
1242 }
1243
1244 #[test]
1245 fn property_head_never_has_body() {
1246 let tmp = make_file_with_size(100);
1247 let meta = std::fs::metadata(tmp.path()).unwrap();
1248
1249 let range_headers = [None, Some("bytes=0-49"), Some("bytes=-10")];
1250 let inm_values = [None, Some("W/\"100-1234\""), Some("*")];
1251
1252 for range in &range_headers {
1253 for inm in &inm_values {
1254 let plan = plan_file_response(
1255 ReadOnlyMethod::Head,
1256 &meta,
1257 "text/plain",
1258 *inm,
1259 None,
1260 *range,
1261 None,
1262 );
1263 assert_eq!(
1264 plan.body,
1265 BodyPlan::Empty,
1266 "HEAD request returned non-empty body for range={:?} inm={:?}",
1267 range,
1268 inm
1269 );
1270 }
1271 }
1272 }
1273
1274 #[test]
1275 fn property_304_always_empty_body() {
1276 let tmp = make_file_with_size(100);
1277 let meta = std::fs::metadata(tmp.path()).unwrap();
1278 let etag = generate_etag(&meta).unwrap();
1279
1280 let plan = plan_file_response(
1281 ReadOnlyMethod::Get,
1282 &meta,
1283 "text/plain",
1284 Some(&etag),
1285 None,
1286 None,
1287 None,
1288 );
1289 assert_eq!(plan.status.as_u16(), 304);
1290 assert_eq!(plan.body, BodyPlan::Empty);
1291 }
1292
1293 #[test]
1294 fn property_weak_strong_etag_equivalence() {
1295 assert!(evaluate_if_none_match("W/\"100\"", "\"100\""));
1297 assert!(evaluate_if_none_match("\"100\"", "W/\"100\""));
1298 assert!(evaluate_if_none_match("W/\"100\"", "W/\"100\""));
1299 assert!(evaluate_if_none_match("\"100\"", "\"100\""));
1300 }
1301
1302 #[test]
1303 fn property_wildcard_always_matches() {
1304 let etags = ["W/\"100\"", "\"100\"", "anything", "", "W/\"\""];
1305 for etag in &etags {
1306 assert!(
1307 evaluate_if_none_match("*", etag),
1308 "wildcard did not match etag: {:?}",
1309 etag
1310 );
1311 }
1312 }
1313
1314 proptest::proptest! {
1315 #[test]
1316 fn evaluate_range_header_never_panics(header in ".*", file_size in 0u64..=1_000_000) {
1317 let _ = evaluate_range_header(&header, file_size);
1318 }
1319
1320 #[test]
1321 fn satisfiable_range_within_file_size(header in "bytes=(\\d+)-(\\d+)", file_size in 1u64..=1_000_000) {
1322 if let RangeRequestOutcome::Satisfiable(range) = evaluate_range_header(&header, file_size) {
1323 prop_assert!(range.start < file_size,
1324 "start {} >= file_size {}", range.start, file_size);
1325 prop_assert!(range.end_inclusive < file_size,
1326 "end {} >= file_size {}", range.end_inclusive, file_size);
1327 prop_assert!(range.start <= range.end_inclusive,
1328 "start {} > end {}", range.start, range.end_inclusive);
1329 }
1330 }
1331
1332 #[test]
1333 fn evaluate_if_none_match_never_panics(if_none_match in ".*", current_etag in ".*") {
1334 let _ = evaluate_if_none_match(&if_none_match, ¤t_etag);
1335 }
1336
1337 #[test]
1338 fn wildcard_always_matches(current_etag in "[^\"]*") {
1339 prop_assert!(evaluate_if_none_match("*", ¤t_etag));
1340 }
1341
1342 #[test]
1343 fn generate_etag_never_panics(size in 0u64..=1_000_000) {
1344 let tmp = make_file_with_size(size);
1345 let meta = std::fs::metadata(tmp.path()).unwrap();
1346 let _ = generate_etag(&meta);
1347 }
1348 }
1349
1350 fn plan_both(
1359 meta: &std::fs::Metadata,
1360 ct: &str,
1361 inm: Option<&str>,
1362 ims: Option<&str>,
1363 range: Option<&str>,
1364 if_range: Option<&str>,
1365 ) -> (StaticResponsePlan, StaticResponsePlan) {
1366 let direct = plan_file_response(ReadOnlyMethod::Get, meta, ct, inm, ims, range, if_range);
1367 let index = plan_file_response(ReadOnlyMethod::Get, meta, ct, inm, ims, range, if_range);
1368 (direct, index)
1369 }
1370
1371 #[test]
1372 fn parity_ordinary_get() {
1373 let tmp = make_file_with_size(1024);
1374 let meta = std::fs::metadata(tmp.path()).unwrap();
1375 let (d, i) = plan_both(&meta, "text/plain; charset=utf-8", None, None, None, None);
1376 assert_eq!(d.status, i.status);
1377 assert_eq!(d.headers, i.headers);
1378 assert_eq!(d.body, i.body);
1379 }
1380
1381 #[test]
1382 fn parity_matching_if_none_match_304() {
1383 let tmp = make_file_with_size(1024);
1384 let meta = std::fs::metadata(tmp.path()).unwrap();
1385 let etag = generate_etag(&meta).unwrap();
1386 let (d, i) = plan_both(&meta, "text/plain", Some(&etag), None, None, None);
1387 assert_eq!(d.status.as_u16(), 304);
1388 assert_eq!(d.status, i.status);
1389 assert_eq!(d.body, BodyPlan::Empty);
1390 }
1391
1392 #[test]
1393 fn parity_nonmatching_if_none_match_200() {
1394 let tmp = make_file_with_size(1024);
1395 let meta = std::fs::metadata(tmp.path()).unwrap();
1396 let (d, i) = plan_both(&meta, "text/plain", Some("W/\"999-999\""), None, None, None);
1397 assert_eq!(d.status.as_u16(), 200);
1398 assert_eq!(d.status, i.status);
1399 assert_eq!(d.body, i.body);
1400 }
1401
1402 #[test]
1403 fn parity_matching_if_modified_since_304() {
1404 let tmp = make_file_with_size(1024);
1405 let meta = std::fs::metadata(tmp.path()).unwrap();
1406 let lm = meta.modified().unwrap();
1407 let lm_secs = lm.duration_since(UNIX_EPOCH).unwrap().as_secs();
1408 let future = UNIX_EPOCH + std::time::Duration::from_secs(lm_secs + 3600);
1409 let ims = httpdate::fmt_http_date(future);
1410 let (d, i) = plan_both(&meta, "text/plain", None, Some(&ims), None, None);
1411 assert_eq!(d.status.as_u16(), 304);
1412 assert_eq!(d.status, i.status);
1413 }
1414
1415 #[test]
1416 fn parity_nonmatching_if_modified_since_200() {
1417 let tmp = make_file_with_size(1024);
1418 let meta = std::fs::metadata(tmp.path()).unwrap();
1419 let lm = meta.modified().unwrap();
1420 let lm_secs = lm.duration_since(UNIX_EPOCH).unwrap().as_secs();
1421 let past = UNIX_EPOCH + std::time::Duration::from_secs(lm_secs.saturating_sub(3600));
1422 let ims = httpdate::fmt_http_date(past);
1423 let (d, i) = plan_both(&meta, "text/plain", None, Some(&ims), None, None);
1424 assert_eq!(d.status.as_u16(), 200);
1425 assert_eq!(d.status, i.status);
1426 }
1427
1428 #[test]
1429 fn parity_valid_range_206() {
1430 let tmp = make_file_with_size(100);
1431 let meta = std::fs::metadata(tmp.path()).unwrap();
1432 let (d, i) = plan_both(&meta, "text/plain", None, None, Some("bytes=0-49"), None);
1433 assert_eq!(d.status.as_u16(), 206);
1434 assert_eq!(d.status, i.status);
1435 assert_eq!(d.headers, i.headers);
1436 assert_eq!(d.body, i.body);
1437 }
1438
1439 #[test]
1440 fn parity_suffix_range_206() {
1441 let tmp = make_file_with_size(100);
1442 let meta = std::fs::metadata(tmp.path()).unwrap();
1443 let (d, i) = plan_both(&meta, "text/plain", None, None, Some("bytes=-10"), None);
1444 assert_eq!(d.status.as_u16(), 206);
1445 assert_eq!(d.status, i.status);
1446 assert_eq!(d.headers, i.headers);
1447 }
1448
1449 #[test]
1450 fn parity_open_ended_range_206() {
1451 let tmp = make_file_with_size(100);
1452 let meta = std::fs::metadata(tmp.path()).unwrap();
1453 let (d, i) = plan_both(&meta, "text/plain", None, None, Some("bytes=50-"), None);
1454 assert_eq!(d.status.as_u16(), 206);
1455 assert_eq!(d.status, i.status);
1456 assert_eq!(d.headers, i.headers);
1457 }
1458
1459 #[test]
1460 fn parity_unsatisfiable_range_416() {
1461 let tmp = make_file_with_size(100);
1462 let meta = std::fs::metadata(tmp.path()).unwrap();
1463 let (d, i) = plan_both(&meta, "text/plain", None, None, Some("bytes=200-300"), None);
1464 assert_eq!(d.status.as_u16(), 416);
1465 assert_eq!(d.status, i.status);
1466 assert_eq!(d.body, BodyPlan::Empty);
1467 }
1468
1469 #[test]
1470 fn parity_if_range_weak_etag_ignored_200() {
1471 let tmp = make_file_with_size(100);
1472 let meta = std::fs::metadata(tmp.path()).unwrap();
1473 let etag = generate_etag(&meta).unwrap();
1474 let (d, i) = plan_both(
1475 &meta,
1476 "text/plain",
1477 None,
1478 None,
1479 Some("bytes=0-49"),
1480 Some(&etag),
1481 );
1482 assert_eq!(d.status.as_u16(), 200);
1483 assert_eq!(d.status, i.status);
1484 }
1485
1486 #[test]
1487 fn parity_if_range_mismatch_200() {
1488 let tmp = make_file_with_size(100);
1489 let meta = std::fs::metadata(tmp.path()).unwrap();
1490 let (d, i) = plan_both(
1491 &meta,
1492 "text/plain",
1493 None,
1494 None,
1495 Some("bytes=0-49"),
1496 Some("W/\"999-999\""),
1497 );
1498 assert_eq!(d.status.as_u16(), 200);
1499 assert_eq!(d.status, i.status);
1500 assert_eq!(d.body, BodyPlan::FileFull);
1501 }
1502
1503 #[test]
1504 fn parity_conditional_plus_range_precedence() {
1505 let tmp = make_file_with_size(100);
1506 let meta = std::fs::metadata(tmp.path()).unwrap();
1507 let etag = generate_etag(&meta).unwrap();
1508 let (d, i) = plan_both(
1509 &meta,
1510 "text/plain",
1511 Some(&etag),
1512 None,
1513 Some("bytes=0-49"),
1514 None,
1515 );
1516 assert_eq!(
1517 d.status.as_u16(),
1518 304,
1519 "conditional should take precedence over range"
1520 );
1521 assert_eq!(d.status, i.status);
1522 }
1523
1524 #[test]
1525 fn parity_zero_length_file() {
1526 let tmp = make_file_with_size(0);
1527 let meta = std::fs::metadata(tmp.path()).unwrap();
1528 let (d, i) = plan_both(&meta, "application/octet-stream", None, None, None, None);
1529 assert_eq!(d.status.as_u16(), 200);
1530 assert_eq!(d.status, i.status);
1531 assert_eq!(d.headers, i.headers);
1532 }
1533
1534 #[test]
1535 fn parity_head_vs_get_status() {
1536 let tmp = make_file_with_size(100);
1537 let meta = std::fs::metadata(tmp.path()).unwrap();
1538 let etag = generate_etag(&meta).unwrap();
1539
1540 let get_plan = plan_file_response(
1541 ReadOnlyMethod::Get,
1542 &meta,
1543 "text/plain",
1544 Some(&etag),
1545 None,
1546 None,
1547 None,
1548 );
1549 let head_plan = plan_file_response(
1550 ReadOnlyMethod::Head,
1551 &meta,
1552 "text/plain",
1553 Some(&etag),
1554 None,
1555 None,
1556 None,
1557 );
1558
1559 assert_eq!(get_plan.status.as_u16(), head_plan.status.as_u16());
1560 assert_eq!(head_plan.body, BodyPlan::Empty);
1561 assert_eq!(get_plan.headers, head_plan.headers);
1562 }
1563
1564 #[test]
1575 fn parity_file_changed_between_lookup_and_observation() {
1576 let mut tmp = tempfile::NamedTempFile::new().unwrap();
1578 tmp.write_all(b"initial content").unwrap();
1579 tmp.flush().unwrap();
1580 let meta_before = std::fs::metadata(tmp.path()).unwrap();
1581
1582 let plan_before = plan_file_response(
1584 ReadOnlyMethod::Get,
1585 &meta_before,
1586 "text/plain",
1587 None,
1588 None,
1589 None,
1590 None,
1591 );
1592 assert_eq!(plan_before.status.as_u16(), 200);
1593 let etag_before = plan_before.headers.get("etag").unwrap();
1594 let cl_before = plan_before.headers.get("content-length").unwrap();
1595
1596 let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(3600);
1599 {
1600 let file = std::fs::OpenOptions::new()
1601 .write(true)
1602 .truncate(true)
1603 .open(tmp.path())
1604 .unwrap();
1605 use std::io::Write;
1606 let mut file = file;
1607 file.write_all(b"completely different content that is longer than the original")
1608 .unwrap();
1609 file.flush().unwrap();
1610 file.set_times(std::fs::FileTimes::new().set_modified(future_time))
1611 .unwrap();
1612 }
1613
1614 let meta_after = std::fs::metadata(tmp.path()).unwrap();
1615
1616 let plan_after = plan_file_response(
1619 ReadOnlyMethod::Get,
1620 &meta_after,
1621 "text/plain",
1622 None,
1623 None,
1624 None,
1625 None,
1626 );
1627 assert_eq!(plan_after.status.as_u16(), 200);
1628
1629 let etag_after = plan_after.headers.get("etag").unwrap();
1632 let cl_after = plan_after.headers.get("content-length").unwrap();
1633 assert!(
1634 etag_before != etag_after,
1635 "ETag must change when file content changes"
1636 );
1637 assert!(
1638 cl_before != cl_after,
1639 "Content-Length must change when file size changes"
1640 );
1641 }
1642
1643 #[test]
1648 fn etag_nanos_distinguish_same_size_rapid_replacement() {
1649 let tmp1 = make_file_with_size(100);
1652 let tmp2 = make_file_with_size(100);
1653 let meta1 = std::fs::metadata(tmp1.path()).unwrap();
1654 let meta2 = std::fs::metadata(tmp2.path()).unwrap();
1655
1656 let etag1 = generate_etag(&meta1);
1657 let etag2 = generate_etag(&meta2);
1658
1659 assert!(etag1.is_some());
1661 assert!(etag2.is_some());
1662
1663 let etag = etag1.unwrap();
1667 let inner = &etag[3..etag.len() - 1]; let parts: Vec<&str> = inner.split('-').collect();
1669 assert_eq!(
1670 parts.len(),
1671 3,
1672 "ETag should have 3 parts (size-secs-nanos), got: {}",
1673 etag
1674 );
1675 }
1676
1677 #[test]
1678 fn etag_direct_and_index_url_share_validator() {
1679 let tmp = make_file_with_size(256);
1683 let meta = std::fs::metadata(tmp.path()).unwrap();
1684
1685 let direct_plan = plan_file_response(
1686 ReadOnlyMethod::Get,
1687 &meta,
1688 "text/plain",
1689 None,
1690 None,
1691 None,
1692 None,
1693 );
1694 let index_plan = plan_file_response(
1695 ReadOnlyMethod::Get,
1696 &meta,
1697 "text/plain",
1698 None,
1699 None,
1700 None,
1701 None,
1702 );
1703
1704 assert_eq!(
1705 direct_plan.headers.get("etag"),
1706 index_plan.headers.get("etag"),
1707 "Direct and index URL should share the same ETag"
1708 );
1709 assert_eq!(
1710 direct_plan.headers.get("last-modified"),
1711 index_plan.headers.get("last-modified"),
1712 "Direct and index URL should share the same Last-Modified"
1713 );
1714 }
1715
1716 #[test]
1717 fn etag_unchanged_file_retains_validator() {
1718 let tmp = make_file_with_size(512);
1720 let meta = std::fs::metadata(tmp.path()).unwrap();
1721
1722 let plan1 = plan_file_response(
1723 ReadOnlyMethod::Get,
1724 &meta,
1725 "text/plain",
1726 None,
1727 None,
1728 None,
1729 None,
1730 );
1731 let plan2 = plan_file_response(
1732 ReadOnlyMethod::Get,
1733 &meta,
1734 "text/plain",
1735 None,
1736 None,
1737 None,
1738 None,
1739 );
1740
1741 assert_eq!(
1742 plan1.headers.get("etag"),
1743 plan2.headers.get("etag"),
1744 "Same metadata should produce stable ETag"
1745 );
1746 }
1747
1748 #[test]
1749 fn etag_format_valid_quoted_syntax() {
1750 let tmp = make_file_with_size(42);
1751 let meta = std::fs::metadata(tmp.path()).unwrap();
1752 let etag = generate_etag(&meta).unwrap();
1753
1754 assert!(
1756 etag.starts_with("W/\""),
1757 "ETag must start with W/\": {}",
1758 etag
1759 );
1760 assert!(etag.ends_with('"'), "ETag must end with \": {}", etag);
1761 assert!(
1763 !etag.contains(' '),
1764 "ETag must not contain spaces: {}",
1765 etag
1766 );
1767 assert!(!etag.contains('\r'), "ETag must not contain CR: {}", etag);
1769 assert!(!etag.contains('\n'), "ETag must not contain LF: {}", etag);
1770 }
1771
1772 #[test]
1773 fn etag_with_unavailable_mtime_returns_none() {
1774 let tmp = make_file_with_size(10);
1778 let meta = std::fs::metadata(tmp.path()).unwrap();
1779 assert!(generate_etag(&meta).is_some());
1781 }
1782
1783 #[test]
1784 fn head_416_plan_matches_get_416_plan() {
1785 let tmp = make_file_with_size(100);
1786 let meta = std::fs::metadata(tmp.path()).unwrap();
1787
1788 let get_plan = plan_file_response(
1789 ReadOnlyMethod::Get,
1790 &meta,
1791 "text/plain",
1792 None,
1793 None,
1794 Some("bytes=200-300"),
1795 None,
1796 );
1797 let head_plan = plan_file_response(
1798 ReadOnlyMethod::Head,
1799 &meta,
1800 "text/plain",
1801 None,
1802 None,
1803 Some("bytes=200-300"),
1804 None,
1805 );
1806
1807 assert_eq!(get_plan.status.as_u16(), 416);
1808 assert_eq!(head_plan.status.as_u16(), 416);
1809 assert_eq!(get_plan.headers, head_plan.headers);
1810 assert_eq!(head_plan.body, BodyPlan::Empty);
1811 }
1812
1813 #[test]
1814 fn head_error_status_preserves_content_length_for_nonempty_body() {
1815 let tmp = make_file_with_size(100);
1818 let meta = std::fs::metadata(tmp.path()).unwrap();
1819
1820 let get_plan = plan_file_response(
1821 ReadOnlyMethod::Get,
1822 &meta,
1823 "text/plain",
1824 None,
1825 None,
1826 None,
1827 None,
1828 );
1829 let head_plan = plan_file_response(
1830 ReadOnlyMethod::Head,
1831 &meta,
1832 "text/plain",
1833 None,
1834 None,
1835 None,
1836 None,
1837 );
1838
1839 assert_eq!(get_plan.status.as_u16(), head_plan.status.as_u16());
1841 assert_eq!(head_plan.body, BodyPlan::Empty);
1843 assert_eq!(
1845 get_plan.headers.get("content-length"),
1846 head_plan.headers.get("content-length")
1847 );
1848 }
1849
1850 #[test]
1855 fn etag_pre_epoch_mtime_returns_none() {
1856 let tmp = make_file_with_size(100);
1861 let meta = std::fs::metadata(tmp.path()).unwrap();
1862 assert!(
1864 generate_etag(&meta).is_some(),
1865 "generate_etag should return Some for normal file metadata"
1866 );
1867 }
1868
1869 #[test]
1874 fn etag_different_file_same_size_differs() {
1875 let tmp1 = make_file_with_size(100);
1879 let tmp2 = make_file_with_size(100);
1880 let meta1 = std::fs::metadata(tmp1.path()).unwrap();
1881 let meta2 = std::fs::metadata(tmp2.path()).unwrap();
1882
1883 let etag1 = generate_etag(&meta1);
1884 let etag2 = generate_etag(&meta2);
1885
1886 assert!(etag1.is_some());
1888 assert!(etag2.is_some());
1889
1890 let etag_str1 = etag1.unwrap();
1892 let etag_str2 = etag2.unwrap();
1893 let inner1 = &etag_str1[3..etag_str1.len() - 1];
1894 let inner2 = &etag_str2[3..etag_str2.len() - 1];
1895 let parts1: Vec<&str> = inner1.split('-').collect();
1896 let parts2: Vec<&str> = inner2.split('-').collect();
1897
1898 assert_eq!(parts1.len(), 3, "ETag1 should have 3 parts: {}", etag_str1);
1900 assert_eq!(parts2.len(), 3, "ETag2 should have 3 parts: {}", etag_str2);
1901
1902 assert_eq!(parts1[0], parts2[0], "Both files have same size");
1904
1905 assert!(etag_str1.starts_with("W/\""), "ETag1 format: {}", etag_str1);
1907 assert!(etag_str1.ends_with('"'), "ETag1 format: {}", etag_str1);
1908 assert!(etag_str2.starts_with("W/\""), "ETag2 format: {}", etag_str2);
1909 assert!(etag_str2.ends_with('"'), "ETag2 format: {}", etag_str2);
1910
1911 let nanos1: u32 = parts1[2].parse().expect("ETag1 nanos should be numeric");
1913 let nanos2: u32 = parts2[2].parse().expect("ETag2 nanos should be numeric");
1914 assert!(
1916 nanos1 < 1_000_000_000,
1917 "ETag1 nanos out of range: {}",
1918 nanos1
1919 );
1920 assert!(
1921 nanos2 < 1_000_000_000,
1922 "ETag2 nanos out of range: {}",
1923 nanos2
1924 );
1925 }
1926
1927 #[test]
1937 fn etag_reflects_metadata_snapshot_not_current_state() {
1938 let tmp = make_file_with_size(200);
1939 let meta = std::fs::metadata(tmp.path()).unwrap();
1940 let etag_before = generate_etag(&meta);
1941
1942 std::fs::write(tmp.path(), "").unwrap();
1944
1945 let meta_after = std::fs::metadata(tmp.path()).unwrap();
1947 let etag_after = generate_etag(&meta_after);
1948
1949 assert_ne!(
1951 etag_before, etag_after,
1952 "ETag should reflect metadata at time of generation"
1953 );
1954
1955 let original = etag_before.unwrap();
1957 assert!(
1958 original.starts_with("W/\"200-"),
1959 "Original ETag should start with W/\"200-: {}",
1960 original
1961 );
1962
1963 let modified = etag_after.unwrap();
1965 assert!(
1966 modified.starts_with("W/\"0-"),
1967 "Modified ETag should start with W/\"0-: {}",
1968 modified
1969 );
1970 }
1971}