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            // Inclusive → exclusive end. Guard against overflow on the
55            // largest u64 value instead of panicking (debug) / wrapping.
56            parse_u64(end_s)?
57                .checked_add(1)
58                .ok_or(RangeParseError::Malformed)?
59        };
60        if end <= start {
61            return Err(RangeParseError::Malformed);
62        }
63        return Ok(Some(ParsedRange::Bytes(RangeSpec { start, end })));
64    }
65    Err(RangeParseError::Malformed)
66}
67
68fn parse_u64(s: &str) -> Result<u64, RangeParseError> {
69    if s.is_empty() {
70        return Err(RangeParseError::Malformed);
71    }
72    u64::from_str(s).map_err(|_| RangeParseError::Malformed)
73}
74
75/// `bytes start-end/total` (end inclusive) for a `Content-Range` header.
76pub fn content_range_value(spec: &RangeSpec, total: u64) -> String {
77    format!("bytes {}-{}/{}", spec.start, spec.end - 1, total)
78}
79
80/// `bytes */total` for a `416 Range Not Satisfiable` response.
81pub fn content_range_none_value(total: u64) -> String {
82    format!("bytes */{total}")
83}
84
85/// Check `If-None-Match` against the current ETag (RFC 9110 §13.1.2).
86///
87/// Supports `*` and comma-separated lists; `W/` weak prefixes compare
88/// equal. Returns `true` when the request should be answered `304`.
89pub fn etag_matches_if_none_match(if_none_match: &str, etag: &str) -> bool {
90    let strong_etag = etag.strip_prefix("W/").unwrap_or(etag);
91    if_none_match.split(',').any(|tag| {
92        let tag = tag.trim().strip_prefix("W/").unwrap_or(tag.trim());
93        tag == "*" || tag == strong_etag
94    })
95}
96
97/// Check `If-Range` (strong ETag form only, RFC 9110 §13.1.5).
98///
99/// Returns `true` when the range should be honored.
100pub fn if_range_matches(if_range: &str, etag: &str) -> bool {
101    let candidate = if_range.trim();
102    if let Some(tag) = candidate.strip_prefix('"') {
103        // Strong comparison only — a `W/` If-Range never matches.
104        return tag.trim_end_matches('"') == etag.trim_matches('"');
105    }
106    // HTTP-date form: not supported here — treat as no match.
107    false
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn parses_closed_range() {
116        let ParsedRange::Bytes(r) = parse_range_header("bytes=0-499").unwrap().unwrap() else {
117            panic!("expected bytes range");
118        };
119        assert_eq!((r.start, r.end), (0, 500));
120    }
121
122    #[test]
123    fn parses_open_ended_range() {
124        let ParsedRange::Bytes(r) = parse_range_header("bytes=500-").unwrap().unwrap() else {
125            panic!("expected bytes range");
126        };
127        assert_eq!((r.start, r.end), (500, u64::MAX));
128    }
129
130    #[test]
131    fn parses_suffix_range() {
132        let ParsedRange::Suffix(n) = parse_range_header("bytes=-500").unwrap().unwrap() else {
133            panic!("expected suffix range");
134        };
135        assert_eq!(n, 500);
136    }
137
138    #[test]
139    fn ignores_other_units_and_multi_ranges() {
140        assert_eq!(parse_range_header("items=0-1").unwrap(), None);
141        assert_eq!(parse_range_header("bytes=0-1,3-4").unwrap(), None);
142        assert_eq!(parse_range_header("").unwrap(), None);
143    }
144
145    #[test]
146    fn rejects_malformed() {
147        assert_eq!(parse_range_header("bytes=abc"), Err(RangeParseError::Malformed));
148        assert_eq!(parse_range_header("bytes=5-2"), Err(RangeParseError::Malformed));
149        assert_eq!(parse_range_header("bytes=-"), Err(RangeParseError::Malformed));
150    }
151
152    #[test]
153    fn rejects_overflowing_end_instead_of_panicking() {
154        // u64::MAX + 1 must not overflow/panic; it is treated as malformed.
155        assert_eq!(
156            parse_range_header("bytes=0-18446744073709551615"),
157            Err(RangeParseError::Malformed)
158        );
159    }
160
161    #[test]
162    fn if_none_match_matching() {
163        assert!(etag_matches_if_none_match("\"abc\"", "\"abc\""));
164        assert!(etag_matches_if_none_match("W/\"abc\"", "\"abc\""));
165        assert!(etag_matches_if_none_match("*", "\"abc\""));
166        assert!(etag_matches_if_none_match("\"x\", \"abc\"", "\"abc\""));
167        assert!(!etag_matches_if_none_match("\"xyz\"", "\"abc\""));
168    }
169
170    #[test]
171    fn if_range_matching() {
172        assert!(if_range_matches("\"abc\"", "\"abc\""));
173        assert!(!if_range_matches("W/\"abc\"", "\"abc\""));
174        assert!(!if_range_matches("\"xyz\"", "\"abc\""));
175        assert!(!if_range_matches("Wed, 01 Jan 2025 00:00:00 GMT", "\"abc\""));
176    }
177}