Skip to main content

libfw_server/
http.rs

1//! HTTP-range and ETag helpers (RFC 7233 / RFC 9110).
2
3use std::str::FromStr;
4
5use libfw_core::RangeSpec;
6
7/// Errors from parsing a `Range` header.
8#[derive(Debug, PartialEq, Eq)]
9pub enum RangeParseError {
10    /// The header is malformed or uses an unsupported unit.
11    Malformed,
12}
13
14/// A parsed, still-unresolved range request.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ParsedRange {
17    /// An explicit byte range `[start, end)` (end may be unbounded).
18    Bytes(RangeSpec),
19    /// The final `n` bytes of the resource (`bytes=-n`).
20    Suffix(u64),
21}
22
23/// Parse a single-range `Range` header like `bytes=0-499`.
24///
25/// Returns:
26/// - `Ok(Some(range))` for a single, well-formed `bytes=` range,
27/// - `Ok(None)` when the header is absent, uses another unit, or requests
28///   multiple ranges (which RFC 7233 §3.1 permits servers to ignore),
29/// - `Err(Malformed)` for a malformed `bytes=` value.
30pub fn parse_range_header(header: &str) -> Result<Option<ParsedRange>, RangeParseError> {
31    let header = header.trim();
32    if header.is_empty() {
33        return Ok(None);
34    }
35    let Some((unit, spec)) = header.split_once('=') else {
36        return Ok(None); // "other-units" — ignore per RFC 7233 §3.1
37    };
38    if !unit.trim().eq_ignore_ascii_case("bytes") {
39        return Ok(None);
40    }
41    let spec = spec.trim();
42    if spec.contains(',') {
43        return Ok(None); // multi-range: may ignore
44    }
45    if let Some(suffix) = spec.strip_prefix('-') {
46        let n = parse_u64(suffix)?;
47        return Ok(Some(ParsedRange::Suffix(n)));
48    }
49    if let Some((start_s, end_s)) = spec.split_once('-') {
50        let start = parse_u64(start_s)?;
51        let end = if end_s.is_empty() {
52            u64::MAX
53        } else {
54            parse_u64(end_s)? + 1 // inclusive → exclusive
55        };
56        if end <= start {
57            return Err(RangeParseError::Malformed);
58        }
59        return Ok(Some(ParsedRange::Bytes(RangeSpec { start, end })));
60    }
61    Err(RangeParseError::Malformed)
62}
63
64fn parse_u64(s: &str) -> Result<u64, RangeParseError> {
65    if s.is_empty() {
66        return Err(RangeParseError::Malformed);
67    }
68    u64::from_str(s).map_err(|_| RangeParseError::Malformed)
69}
70
71/// `bytes start-end/total` (end inclusive) for a `Content-Range` header.
72pub fn content_range_value(spec: &RangeSpec, total: u64) -> String {
73    format!("bytes {}-{}/{}", spec.start, spec.end - 1, total)
74}
75
76/// `bytes */total` for a `416 Range Not Satisfiable` response.
77pub fn content_range_none_value(total: u64) -> String {
78    format!("bytes */{total}")
79}
80
81/// Check `If-None-Match` against the current ETag (RFC 9110 §13.1.2).
82///
83/// Supports `*` and comma-separated lists; `W/` weak prefixes compare
84/// equal. Returns `true` when the request should be answered `304`.
85pub fn etag_matches_if_none_match(if_none_match: &str, etag: &str) -> bool {
86    let strong_etag = etag.strip_prefix("W/").unwrap_or(etag);
87    if_none_match.split(',').any(|tag| {
88        let tag = tag.trim().strip_prefix("W/").unwrap_or(tag.trim());
89        tag == "*" || tag == strong_etag
90    })
91}
92
93/// Check `If-Range` (strong ETag form only, RFC 9110 §13.1.5).
94///
95/// Returns `true` when the range should be honored.
96pub fn if_range_matches(if_range: &str, etag: &str) -> bool {
97    let candidate = if_range.trim();
98    if let Some(tag) = candidate.strip_prefix('"') {
99        // Strong comparison only — a `W/` If-Range never matches.
100        return tag.trim_end_matches('"') == etag.trim_matches('"');
101    }
102    // HTTP-date form: not supported here — treat as no match.
103    false
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn parses_closed_range() {
112        let ParsedRange::Bytes(r) = parse_range_header("bytes=0-499").unwrap().unwrap() else {
113            panic!("expected bytes range");
114        };
115        assert_eq!((r.start, r.end), (0, 500));
116    }
117
118    #[test]
119    fn parses_open_ended_range() {
120        let ParsedRange::Bytes(r) = parse_range_header("bytes=500-").unwrap().unwrap() else {
121            panic!("expected bytes range");
122        };
123        assert_eq!((r.start, r.end), (500, u64::MAX));
124    }
125
126    #[test]
127    fn parses_suffix_range() {
128        let ParsedRange::Suffix(n) = parse_range_header("bytes=-500").unwrap().unwrap() else {
129            panic!("expected suffix range");
130        };
131        assert_eq!(n, 500);
132    }
133
134    #[test]
135    fn ignores_other_units_and_multi_ranges() {
136        assert_eq!(parse_range_header("items=0-1").unwrap(), None);
137        assert_eq!(parse_range_header("bytes=0-1,3-4").unwrap(), None);
138        assert_eq!(parse_range_header("").unwrap(), None);
139    }
140
141    #[test]
142    fn rejects_malformed() {
143        assert_eq!(parse_range_header("bytes=abc"), Err(RangeParseError::Malformed));
144        assert_eq!(parse_range_header("bytes=5-2"), Err(RangeParseError::Malformed));
145        assert_eq!(parse_range_header("bytes=-"), Err(RangeParseError::Malformed));
146    }
147
148    #[test]
149    fn if_none_match_matching() {
150        assert!(etag_matches_if_none_match("\"abc\"", "\"abc\""));
151        assert!(etag_matches_if_none_match("W/\"abc\"", "\"abc\""));
152        assert!(etag_matches_if_none_match("*", "\"abc\""));
153        assert!(etag_matches_if_none_match("\"x\", \"abc\"", "\"abc\""));
154        assert!(!etag_matches_if_none_match("\"xyz\"", "\"abc\""));
155    }
156
157    #[test]
158    fn if_range_matching() {
159        assert!(if_range_matches("\"abc\"", "\"abc\""));
160        assert!(!if_range_matches("W/\"abc\"", "\"abc\""));
161        assert!(!if_range_matches("\"xyz\"", "\"abc\""));
162        assert!(!if_range_matches("Wed, 01 Jan 2025 00:00:00 GMT", "\"abc\""));
163    }
164}