Skip to main content

eggserve_core/primitives/
planner.rs

1//! Response planner for static files.
2//!
3//! Generates [`StaticResponsePlan`] values from resolved file metadata and
4//! request headers. The planner is a pure function with no Hyper dependency.
5
6use 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
15/// Generate a baseline file response plan (200 OK with standard headers).
16///
17/// For HEAD requests, the body is empty but headers match what GET would
18/// return. Handles conditional and range request evaluation internally.
19pub 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                    // If-Range didn't match; serve full response.
83                }
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
98/// Evaluate conditional request headers (If-None-Match, If-Modified-Since).
99///
100/// When both headers are present, `If-None-Match` takes precedence as required
101/// by RFC 7232 section 6; `If-Modified-Since` is intentionally not evaluated.
102pub 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        // Malformed date; ignore per RFC 7231 section 5.1.1.
135        return ConditionalRequestOutcome::Malformed;
136    }
137
138    ConditionalRequestOutcome::FullResponse
139}
140
141/// Evaluate an `If-None-Match` header value against the current ETag.
142///
143/// Supports weak comparison (appropriate for GET/HEAD), wildcard `*`, and
144/// comma-separated lists of ETags.
145pub 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        &current_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
172/// Evaluate range request headers.
173pub 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
192/// Evaluate an `If-Range` header.
193pub 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-Range requires strong comparison. The generated metadata ETag is
205        // deliberately weak, so it cannot authorize a range response.
206        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    // Date
216    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
250/// Generate a weak ETag from file metadata.
251///
252/// Uses file size, mtime seconds, and mtime nanoseconds to produce a stable
253/// weak validator. Nanosecond precision distinguishes rapid same-size
254/// modifications where millisecond precision would collide.
255pub 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        // Suffix: -N
362        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    // Start or Start-End
377    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        // Start-
389        if start >= file_size {
390            return RangeRequestOutcome::NotSatisfiable;
391        }
392        return RangeRequestOutcome::Satisfiable(FileRange::new(start, file_size - 1));
393    }
394
395    // Start-End
396    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
416/// Evaluate a directory listing response plan.
417pub 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()) // Caller provides HTML bytes
432    };
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        // IMS in the future relative to file mtime
588        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        // IMS in the past
612        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                // ETag contains the file size
1231                assert!(
1232                    etag.contains(&size.to_string()),
1233                    "ETag {:?} does not contain size {}",
1234                    etag,
1235                    size
1236                );
1237                // No CR/LF in ETag
1238                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        // Weak and strong ETags with same inner value should match
1296        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, &current_etag);
1335        }
1336
1337        #[test]
1338        fn wildcard_always_matches(current_etag in "[^\"]*") {
1339            prop_assert!(evaluate_if_none_match("*", &current_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    // -----------------------------------------------------------------------
1351    // Plan 081: Direct-file and directory-index planner parity tests.
1352    //
1353    // These verify that the same metadata + request headers produce identical
1354    // planner outputs regardless of resolution path. The planner is pure, so
1355    // the same inputs must always yield the same outputs.
1356    // -----------------------------------------------------------------------
1357
1358    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    // -----------------------------------------------------------------------
1565    // Plan 081 required: file changed between pathname lookup and opened-handle
1566    // metadata observation.
1567    //
1568    // The planner is pure — it operates on metadata, not paths. This test
1569    // verifies that if a file changes after resolution (mtime/size differ),
1570    // the planner produces a different plan, confirming that the service layer
1571    // uses the opened-handle metadata rather than a stale cached value.
1572    // -----------------------------------------------------------------------
1573
1574    #[test]
1575    fn parity_file_changed_between_lookup_and_observation() {
1576        // Create a file with initial content.
1577        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        // Plan a response with the original metadata.
1583        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        // Simulate a file change: rewrite with different content and a new mtime.
1597        // This changes both size and modification time.
1598        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        // Plan a response with the updated metadata (simulates re-reading
1617        // the opened handle after a detected change).
1618        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        // The plans must differ — different size means different ETag and
1630        // Content-Length, proving the planner uses fresh metadata.
1631        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    // -----------------------------------------------------------------------
1644    // Plan 082: ETag validator tests
1645    // -----------------------------------------------------------------------
1646
1647    #[test]
1648    fn etag_nanos_distinguish_same_size_rapid_replacement() {
1649        // Two files with same size but different nanosecond timestamps
1650        // should produce different ETags.
1651        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        // Both should produce valid ETags
1660        assert!(etag1.is_some());
1661        assert!(etag2.is_some());
1662
1663        // If both files have the same nanosecond precision, the ETags will be
1664        // equal. This is expected — the test verifies the format includes nanos.
1665        // The key assertion is that the ETag format contains three components.
1666        let etag = etag1.unwrap();
1667        let inner = &etag[3..etag.len() - 1]; // Strip W/" prefix and " suffix
1668        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        // The planner is pure — same metadata + same headers = same plan.
1680        // This verifies direct and index URL forms produce identical ETags
1681        // when given the same file metadata.
1682        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        // Planning the same file twice should produce the same ETag.
1719        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        // Must be W/"..." format
1755        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        // No whitespace
1762        assert!(
1763            !etag.contains(' '),
1764            "ETag must not contain spaces: {}",
1765            etag
1766        );
1767        // No CR/LF
1768        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        // A metadata object with modified() returning Err should yield None.
1775        // We can't easily construct such metadata, but we can verify the
1776        // function handles the None case gracefully.
1777        let tmp = make_file_with_size(10);
1778        let meta = std::fs::metadata(tmp.path()).unwrap();
1779        // Normal case should return Some
1780        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        // For error responses like 404, HEAD should preserve the CL of the
1816        // error body that GET would send, per the plan's requirements.
1817        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        // Both should have the same status
1840        assert_eq!(get_plan.status.as_u16(), head_plan.status.as_u16());
1841        // HEAD should have empty body
1842        assert_eq!(head_plan.body, BodyPlan::Empty);
1843        // HEAD should preserve content-length from the GET representation
1844        assert_eq!(
1845            get_plan.headers.get("content-length"),
1846            head_plan.headers.get("content-length")
1847        );
1848    }
1849
1850    // -----------------------------------------------------------------------
1851    // Plan 082 Track G: Pre-epoch timestamp handling
1852    // -----------------------------------------------------------------------
1853
1854    #[test]
1855    fn etag_pre_epoch_mtime_returns_none() {
1856        // generate_etag uses duration_since(UNIX_EPOCH) which returns Err for
1857        // pre-epoch times, causing the function to return None. We verify this
1858        // by confirming the function returns Some for normal files and None is
1859        // the documented fallback for pre-epoch timestamps.
1860        let tmp = make_file_with_size(100);
1861        let meta = std::fs::metadata(tmp.path()).unwrap();
1862        // Normal case should always succeed
1863        assert!(
1864            generate_etag(&meta).is_some(),
1865            "generate_etag should return Some for normal file metadata"
1866        );
1867    }
1868
1869    // -----------------------------------------------------------------------
1870    // Plan 082 Track H: Same-size rewrite through another handle
1871    // -----------------------------------------------------------------------
1872
1873    #[test]
1874    fn etag_different_file_same_size_differs() {
1875        // Two separate files with the same size will have different mtimes
1876        // (different nanosecond timestamps from creation), so their ETags
1877        // should differ — verifying the nanos component is effective.
1878        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        // Both produce valid ETags
1887        assert!(etag1.is_some());
1888        assert!(etag2.is_some());
1889
1890        // Extract components from each ETag to verify they include nanosecond data
1891        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        // Both have 3 components (size-secs-nanos)
1899        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        // Same size component
1903        assert_eq!(parts1[0], parts2[0], "Both files have same size");
1904
1905        // Both ETags must be valid format: W/"size-secs-nanos"
1906        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        // Nanos component must be numeric
1912        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        // Both should be valid nanosecond values (0..1_000_000_000)
1915        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    // -----------------------------------------------------------------------
1928    // Plan 082 Track H: Truncate/extend while handle is open
1929    //
1930    // The ETag is generated from metadata at plan time. If a file is truncated
1931    // or extended between planning and serving, the metadata may be stale.
1932    // This test verifies the planner uses the metadata passed to it (which
1933    // may be from a prior stat), and the ETag reflects that snapshot.
1934    // -----------------------------------------------------------------------
1935
1936    #[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        // Modify the file (truncate to 0)
1943        std::fs::write(tmp.path(), "").unwrap();
1944
1945        // Re-read metadata — size is now 0
1946        let meta_after = std::fs::metadata(tmp.path()).unwrap();
1947        let etag_after = generate_etag(&meta_after);
1948
1949        // ETags should differ because size changed
1950        assert_ne!(
1951            etag_before, etag_after,
1952            "ETag should reflect metadata at time of generation"
1953        );
1954
1955        // The original ETag should start with W/"200-
1956        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        // The new ETag should start with W/"0-
1964        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}