aster_forge_utils/
http_range.rs

1//! Transport-neutral parsing and normalization for HTTP byte ranges.
2
3/// A resolved inclusive byte range for one representation.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct HttpByteRange {
6    start: u64,
7    end: u64,
8    length: u64,
9    total_size: u64,
10}
11
12/// A bounded, normalized byte-range set for one representation.
13#[derive(Debug, PartialEq, Eq)]
14pub struct HttpByteRangeSet {
15    requested_count: usize,
16    ranges: Vec<HttpByteRange>,
17}
18
19impl HttpByteRangeSet {
20    /// Returns the number of non-empty range specs supplied by the sender.
21    #[must_use]
22    pub const fn requested_count(&self) -> usize {
23        self.requested_count
24    }
25
26    /// Returns the satisfiable ranges in request order.
27    #[must_use]
28    pub fn ranges(&self) -> &[HttpByteRange] {
29        &self.ranges
30    }
31
32    /// Consumes the set and returns its satisfiable ranges.
33    #[must_use]
34    pub fn into_ranges(self) -> Vec<HttpByteRange> {
35        self.ranges
36    }
37}
38
39impl HttpByteRange {
40    /// Creates a resolved byte range and validates it against the representation length.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`HttpRangeError::EmptyRepresentation`] for a zero-sized representation and
45    /// [`HttpRangeError::Unsatisfiable`] when the bounds are reversed or exceed the representation.
46    pub fn new(start: u64, end: u64, total_size: u64) -> Result<Self, HttpRangeError> {
47        if total_size == 0 {
48            return Err(HttpRangeError::EmptyRepresentation);
49        }
50        if start > end || end >= total_size {
51            return Err(HttpRangeError::Unsatisfiable);
52        }
53        Ok(Self {
54            start,
55            end,
56            length: end - start + 1,
57            total_size,
58        })
59    }
60
61    #[must_use]
62    pub const fn start(self) -> u64 {
63        self.start
64    }
65
66    #[must_use]
67    pub const fn end(self) -> u64 {
68        self.end
69    }
70
71    #[must_use]
72    pub const fn length(self) -> u64 {
73        self.length
74    }
75
76    #[must_use]
77    pub const fn total_size(self) -> u64 {
78        self.total_size
79    }
80
81    /// Renders the value required by a successful `Content-Range` response header.
82    #[must_use]
83    pub fn content_range_header(self) -> String {
84        format!("bytes {}-{}/{}", self.start, self.end, self.total_size)
85    }
86}
87
88/// Stable failure categories for byte-range requests.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
90pub enum HttpRangeError {
91    #[error("range header must use the bytes unit")]
92    UnsupportedUnit,
93    #[error("multiple range requests are not supported")]
94    MultipleRangesUnsupported,
95    #[error("range header exceeds the configured byte length")]
96    HeaderTooLong,
97    #[error("range request exceeds the configured number of range specs")]
98    TooManyRanges,
99    #[error("range header is malformed")]
100    Malformed,
101    #[error("range bound must be a valid unsigned integer")]
102    InvalidNumber,
103    #[error("range cannot be requested for an empty representation")]
104    EmptyRepresentation,
105    #[error("range is not satisfiable for the current representation")]
106    Unsatisfiable,
107}
108
109/// Parses and resolves one RFC byte-range specifier against a representation length.
110///
111/// Multiple ranges are reported separately so callers can choose whether to reject them or
112/// implement multipart responses. End bounds beyond the representation are clamped as required
113/// by HTTP range semantics.
114///
115/// # Errors
116///
117/// Returns an error when the unit, syntax, or numeric bounds are invalid; when more than one range
118/// is requested; or when no requested bytes are satisfiable for the representation.
119pub fn parse_single_byte_range(
120    raw: &str,
121    total_size: u64,
122) -> Result<HttpByteRange, HttpRangeError> {
123    let set = parse_byte_ranges(raw, total_size, raw.len(), 1).map_err(|error| match error {
124        HttpRangeError::TooManyRanges => HttpRangeError::MultipleRangesUnsupported,
125        other => other,
126    })?;
127    set.into_ranges()
128        .into_iter()
129        .next()
130        .ok_or(HttpRangeError::Unsatisfiable)
131}
132
133/// Parses an RFC 9110 `bytes` range-set with allocation, work, and raw-input bounds.
134///
135/// `maximum_raw_bytes` is checked before inspecting or allocating for the range-set. Empty list
136/// members are tolerated as required by the HTTP `#rule` recipient grammar. Every non-empty spec
137/// must be syntactically valid, while individually unsatisfiable specs are removed as long as at
138/// least one requested range remains satisfiable. End bounds beyond the current representation are
139/// clamped and suffix ranges larger than the representation select it all.
140///
141/// # Errors
142///
143/// Returns an error when the raw header exceeds `maximum_raw_bytes`, the range-set exceeds
144/// `maximum_specs`, the unit or syntax is invalid, a bound is not an unsigned integer, or no range
145/// is satisfiable for the current representation.
146pub fn parse_byte_ranges(
147    raw: &str,
148    total_size: u64,
149    maximum_raw_bytes: usize,
150    maximum_specs: usize,
151) -> Result<HttpByteRangeSet, HttpRangeError> {
152    if raw.len() > maximum_raw_bytes {
153        return Err(HttpRangeError::HeaderTooLong);
154    }
155    let raw = raw.trim_start();
156    let (unit, range_set) = raw.split_once('=').ok_or(HttpRangeError::UnsupportedUnit)?;
157    if !unit.eq_ignore_ascii_case("bytes") {
158        return Err(HttpRangeError::UnsupportedUnit);
159    }
160
161    let requested_count = range_set
162        .split(',')
163        .filter(|spec| !spec.trim().is_empty())
164        .count();
165    if requested_count == 0 {
166        return Err(HttpRangeError::Malformed);
167    }
168    if requested_count > maximum_specs {
169        return Err(HttpRangeError::TooManyRanges);
170    }
171
172    let mut ranges = Vec::with_capacity(requested_count);
173    for spec in range_set
174        .split(',')
175        .map(str::trim)
176        .filter(|spec| !spec.is_empty())
177    {
178        if let Some(range) = parse_byte_range_spec(spec, total_size)? {
179            ranges.push(range);
180        }
181    }
182    if ranges.is_empty() {
183        return Err(if total_size == 0 {
184            HttpRangeError::EmptyRepresentation
185        } else {
186            HttpRangeError::Unsatisfiable
187        });
188    }
189
190    Ok(HttpByteRangeSet {
191        requested_count,
192        ranges,
193    })
194}
195
196fn parse_byte_range_spec(
197    spec: &str,
198    total_size: u64,
199) -> Result<Option<HttpByteRange>, HttpRangeError> {
200    let (start_raw, end_raw) = spec.split_once('-').ok_or(HttpRangeError::Malformed)?;
201    if start_raw.is_empty() && end_raw.is_empty() {
202        return Err(HttpRangeError::Malformed);
203    }
204
205    if start_raw.is_empty() {
206        let suffix_length = parse_bound(end_raw)?;
207        if suffix_length == 0 || total_size == 0 {
208            return Ok(None);
209        }
210        let length = suffix_length.min(total_size);
211        return HttpByteRange::new(total_size - length, total_size - 1, total_size).map(Some);
212    }
213
214    let start = parse_bound(start_raw)?;
215    let end = if end_raw.is_empty() {
216        None
217    } else {
218        Some(parse_bound(end_raw)?)
219    };
220    if end.is_some_and(|end| end < start) {
221        return Err(HttpRangeError::Malformed);
222    }
223    if total_size == 0 || start >= total_size {
224        return Ok(None);
225    }
226    let end = end.unwrap_or(total_size - 1).min(total_size - 1);
227    HttpByteRange::new(start, end, total_size).map(Some)
228}
229
230fn parse_bound(value: &str) -> Result<u64, HttpRangeError> {
231    value
232        .parse::<u64>()
233        .map_err(|_| HttpRangeError::InvalidNumber)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::{HttpByteRange, HttpRangeError, parse_byte_ranges, parse_single_byte_range};
239
240    #[test]
241    fn resolves_bounded_open_and_suffix_ranges() {
242        assert_eq!(
243            parse_single_byte_range("bytes=5-9", 20),
244            HttpByteRange::new(5, 9, 20)
245        );
246        assert_eq!(
247            parse_single_byte_range("bytes=7-", 20),
248            HttpByteRange::new(7, 19, 20)
249        );
250        assert_eq!(
251            parse_single_byte_range("bytes=-6", 20),
252            HttpByteRange::new(14, 19, 20)
253        );
254        assert_eq!(
255            parse_single_byte_range("bytes=-50", 20),
256            HttpByteRange::new(0, 19, 20)
257        );
258        assert_eq!(
259            parse_single_byte_range("  BYTES=0-1", 20),
260            HttpByteRange::new(0, 1, 20)
261        );
262    }
263
264    #[test]
265    fn clamps_end_beyond_the_representation() {
266        assert_eq!(
267            parse_single_byte_range("bytes=17-99", 20),
268            HttpByteRange::new(17, 19, 20)
269        );
270    }
271
272    #[test]
273    fn preserves_u64_boundaries_without_overflow() {
274        let total_size = u64::MAX;
275        let range = parse_single_byte_range("bytes=0-18446744073709551615", total_size)
276            .expect("maximum end should clamp safely");
277        assert_eq!(range.start(), 0);
278        assert_eq!(range.end(), u64::MAX - 1);
279        assert_eq!(range.length(), u64::MAX);
280        assert_eq!(range.total_size(), total_size);
281    }
282
283    #[test]
284    fn multi_range_parser_preserves_order_and_removes_only_unsatisfiable_specs() {
285        let set = parse_byte_ranges("bytes=10-12, 50-, -5, 0-4", 20, 32, 4)
286            .expect("mixed range-set should keep satisfiable specs");
287        assert_eq!(set.requested_count(), 4);
288        assert_eq!(
289            set.ranges(),
290            [
291                HttpByteRange::new(10, 12, 20).expect("range"),
292                HttpByteRange::new(15, 19, 20).expect("range"),
293                HttpByteRange::new(0, 4, 20).expect("range"),
294            ]
295        );
296    }
297
298    #[test]
299    fn multi_range_parser_tolerates_empty_list_members_and_clamps_suffixes() {
300        let set = parse_byte_ranges("bytes=, 0-99, , -100,", 20, 24, 2)
301            .expect("empty list members are recipient-tolerated");
302        assert_eq!(set.requested_count(), 2);
303        assert_eq!(
304            set.into_ranges(),
305            vec![
306                HttpByteRange::new(0, 19, 20).expect("range"),
307                HttpByteRange::new(0, 19, 20).expect("range"),
308            ]
309        );
310    }
311
312    #[test]
313    fn multi_range_parser_clamps_mixed_ranges_at_u64_max() {
314        let set = parse_byte_ranges(
315            "bytes=-2,18446744073709551613-18446744073709551615",
316            u64::MAX,
317            58,
318            2,
319        )
320        .expect("maximum-sized representation ranges should clamp without overflow");
321        assert_eq!(set.requested_count(), 2);
322        assert_eq!(
323            set.ranges(),
324            [
325                HttpByteRange::new(u64::MAX - 2, u64::MAX - 1, u64::MAX).expect("suffix range"),
326                HttpByteRange::new(u64::MAX - 2, u64::MAX - 1, u64::MAX).expect("closed range"),
327            ]
328        );
329    }
330
331    #[test]
332    fn multi_range_parser_enforces_spec_limit_before_normalization() {
333        assert_eq!(
334            parse_byte_ranges("bytes=0-1,100-200", 20, 19, 1),
335            Err(HttpRangeError::TooManyRanges)
336        );
337        assert_eq!(
338            parse_byte_ranges("bytes=100-200,300-400", 20, 25, 2),
339            Err(HttpRangeError::Unsatisfiable)
340        );
341        assert_eq!(
342            parse_byte_ranges("bytes=-0,20-", 20, 13, 2),
343            Err(HttpRangeError::Unsatisfiable)
344        );
345    }
346
347    #[test]
348    fn multi_range_parser_rejects_invalid_members_and_empty_representations() {
349        for (raw, expected) in [
350            ("bytes=", HttpRangeError::Malformed),
351            ("bytes=, ,", HttpRangeError::Malformed),
352            ("bytes=0-1,broken", HttpRangeError::Malformed),
353            ("bytes=9-5,0-1", HttpRangeError::Malformed),
354            ("bytes=0-1,2-x", HttpRangeError::InvalidNumber),
355            ("items=0-1", HttpRangeError::UnsupportedUnit),
356        ] {
357            assert_eq!(
358                parse_byte_ranges(raw, 20, raw.len(), 8),
359                Err(expected),
360                "{raw}"
361            );
362        }
363        assert_eq!(
364            parse_byte_ranges("bytes=-1,0-", 0, 12, 2),
365            Err(HttpRangeError::EmptyRepresentation)
366        );
367    }
368
369    #[test]
370    fn multi_range_parser_separates_raw_byte_and_spec_limits() {
371        let exact = "bytes=0-1";
372        assert!(parse_byte_ranges(exact, 20, exact.len(), 1).is_ok());
373        assert_eq!(
374            parse_byte_ranges(exact, 20, exact.len() - 1, 1),
375            Err(HttpRangeError::HeaderTooLong)
376        );
377
378        let comma_padded = "bytes=,,,,,,,,0-1,,,,,,,,";
379        assert_eq!(
380            parse_byte_ranges(comma_padded, 20, 16, 1),
381            Err(HttpRangeError::HeaderTooLong)
382        );
383        let too_many_specs = "bytes=0-1,2-3";
384        assert_eq!(
385            parse_byte_ranges(too_many_specs, 20, too_many_specs.len(), 1),
386            Err(HttpRangeError::TooManyRanges)
387        );
388    }
389
390    #[test]
391    fn renders_content_range_and_exposes_bounds() {
392        let range = HttpByteRange::new(2, 6, 10).expect("valid range");
393        assert_eq!(range.start(), 2);
394        assert_eq!(range.end(), 6);
395        assert_eq!(range.length(), 5);
396        assert_eq!(range.total_size(), 10);
397        assert_eq!(range.content_range_header(), "bytes 2-6/10");
398    }
399
400    #[test]
401    fn constructor_rejects_empty_inverted_and_out_of_bounds_ranges() {
402        assert_eq!(
403            HttpByteRange::new(0, 0, 0),
404            Err(HttpRangeError::EmptyRepresentation)
405        );
406        assert_eq!(
407            HttpByteRange::new(5, 4, 10),
408            Err(HttpRangeError::Unsatisfiable)
409        );
410        assert_eq!(
411            HttpByteRange::new(5, 10, 10),
412            Err(HttpRangeError::Unsatisfiable)
413        );
414    }
415
416    #[test]
417    fn classifies_every_rejected_range_shape() {
418        let cases = [
419            ("items=0-1", HttpRangeError::UnsupportedUnit),
420            ("bytes=0-1,3-4", HttpRangeError::MultipleRangesUnsupported),
421            ("bytes=-", HttpRangeError::Malformed),
422            ("bytes=abc-", HttpRangeError::InvalidNumber),
423            ("bytes=-0", HttpRangeError::Unsatisfiable),
424            ("bytes=9-5", HttpRangeError::Malformed),
425            ("bytes=20-", HttpRangeError::Unsatisfiable),
426        ];
427        for (raw, expected) in cases {
428            assert_eq!(parse_single_byte_range(raw, 20), Err(expected), "{raw}");
429        }
430        assert_eq!(
431            parse_single_byte_range("bytes=0-0", 0),
432            Err(HttpRangeError::EmptyRepresentation)
433        );
434    }
435}