Skip to main content

aster_forge_utils/
http_range.rs

1//! Transport-neutral parsing for a single HTTP byte range.
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
12impl HttpByteRange {
13    /// Creates a resolved byte range and validates it against the representation length.
14    pub fn new(start: u64, end: u64, total_size: u64) -> Result<Self, HttpRangeError> {
15        if total_size == 0 {
16            return Err(HttpRangeError::EmptyRepresentation);
17        }
18        if start > end || end >= total_size {
19            return Err(HttpRangeError::Unsatisfiable);
20        }
21        Ok(Self {
22            start,
23            end,
24            length: end - start + 1,
25            total_size,
26        })
27    }
28
29    #[must_use]
30    pub const fn start(self) -> u64 {
31        self.start
32    }
33
34    #[must_use]
35    pub const fn end(self) -> u64 {
36        self.end
37    }
38
39    #[must_use]
40    pub const fn length(self) -> u64 {
41        self.length
42    }
43
44    #[must_use]
45    pub const fn total_size(self) -> u64 {
46        self.total_size
47    }
48
49    /// Renders the value required by a successful `Content-Range` response header.
50    #[must_use]
51    pub fn content_range_header(self) -> String {
52        format!("bytes {}-{}/{}", self.start, self.end, self.total_size)
53    }
54}
55
56/// Stable failure categories for a single byte-range request.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
58pub enum HttpRangeError {
59    #[error("range header must use the bytes unit")]
60    UnsupportedUnit,
61    #[error("multiple range requests are not supported")]
62    MultipleRangesUnsupported,
63    #[error("range header is malformed")]
64    Malformed,
65    #[error("range bound must be a valid unsigned integer")]
66    InvalidNumber,
67    #[error("range cannot be requested for an empty representation")]
68    EmptyRepresentation,
69    #[error("range is not satisfiable for the current representation")]
70    Unsatisfiable,
71}
72
73/// Parses and resolves one RFC byte-range specifier against a representation length.
74///
75/// Multiple ranges are reported separately so callers can choose whether to reject them or
76/// implement multipart responses. End bounds beyond the representation are clamped as required
77/// by HTTP range semantics.
78pub fn parse_single_byte_range(
79    raw: &str,
80    total_size: u64,
81) -> Result<HttpByteRange, HttpRangeError> {
82    let raw = raw.trim_start();
83    let (unit, range) = raw.split_once('=').ok_or(HttpRangeError::UnsupportedUnit)?;
84    if !unit.eq_ignore_ascii_case("bytes") {
85        return Err(HttpRangeError::UnsupportedUnit);
86    }
87    if range.contains(',') {
88        return Err(HttpRangeError::MultipleRangesUnsupported);
89    }
90
91    let (start_raw, end_raw) = range.split_once('-').ok_or(HttpRangeError::Malformed)?;
92    if start_raw.is_empty() && end_raw.is_empty() {
93        return Err(HttpRangeError::Malformed);
94    }
95    if total_size == 0 {
96        return Err(HttpRangeError::EmptyRepresentation);
97    }
98
99    if start_raw.is_empty() {
100        let suffix_length = parse_bound(end_raw)?;
101        if suffix_length == 0 {
102            return Err(HttpRangeError::Unsatisfiable);
103        }
104        let length = suffix_length.min(total_size);
105        return HttpByteRange::new(total_size - length, total_size - 1, total_size);
106    }
107
108    let start = parse_bound(start_raw)?;
109    if start >= total_size {
110        return Err(HttpRangeError::Unsatisfiable);
111    }
112    let end = if end_raw.is_empty() {
113        total_size - 1
114    } else {
115        parse_bound(end_raw)?
116    };
117    if end < start {
118        return Err(HttpRangeError::Unsatisfiable);
119    }
120    HttpByteRange::new(start, end.min(total_size - 1), total_size)
121}
122
123fn parse_bound(value: &str) -> Result<u64, HttpRangeError> {
124    value
125        .parse::<u64>()
126        .map_err(|_| HttpRangeError::InvalidNumber)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::{HttpByteRange, HttpRangeError, parse_single_byte_range};
132
133    #[test]
134    fn resolves_bounded_open_and_suffix_ranges() {
135        assert_eq!(
136            parse_single_byte_range("bytes=5-9", 20),
137            HttpByteRange::new(5, 9, 20)
138        );
139        assert_eq!(
140            parse_single_byte_range("bytes=7-", 20),
141            HttpByteRange::new(7, 19, 20)
142        );
143        assert_eq!(
144            parse_single_byte_range("bytes=-6", 20),
145            HttpByteRange::new(14, 19, 20)
146        );
147        assert_eq!(
148            parse_single_byte_range("bytes=-50", 20),
149            HttpByteRange::new(0, 19, 20)
150        );
151        assert_eq!(
152            parse_single_byte_range("  BYTES=0-1", 20),
153            HttpByteRange::new(0, 1, 20)
154        );
155    }
156
157    #[test]
158    fn clamps_end_beyond_the_representation() {
159        assert_eq!(
160            parse_single_byte_range("bytes=17-99", 20),
161            HttpByteRange::new(17, 19, 20)
162        );
163    }
164
165    #[test]
166    fn preserves_u64_boundaries_without_overflow() {
167        let total_size = u64::MAX;
168        let range = parse_single_byte_range("bytes=0-18446744073709551615", total_size)
169            .expect("maximum end should clamp safely");
170        assert_eq!(range.start(), 0);
171        assert_eq!(range.end(), u64::MAX - 1);
172        assert_eq!(range.length(), u64::MAX);
173        assert_eq!(range.total_size(), total_size);
174    }
175
176    #[test]
177    fn renders_content_range_and_exposes_bounds() {
178        let range = HttpByteRange::new(2, 6, 10).expect("valid range");
179        assert_eq!(range.start(), 2);
180        assert_eq!(range.end(), 6);
181        assert_eq!(range.length(), 5);
182        assert_eq!(range.total_size(), 10);
183        assert_eq!(range.content_range_header(), "bytes 2-6/10");
184    }
185
186    #[test]
187    fn constructor_rejects_empty_inverted_and_out_of_bounds_ranges() {
188        assert_eq!(
189            HttpByteRange::new(0, 0, 0),
190            Err(HttpRangeError::EmptyRepresentation)
191        );
192        assert_eq!(
193            HttpByteRange::new(5, 4, 10),
194            Err(HttpRangeError::Unsatisfiable)
195        );
196        assert_eq!(
197            HttpByteRange::new(5, 10, 10),
198            Err(HttpRangeError::Unsatisfiable)
199        );
200    }
201
202    #[test]
203    fn classifies_every_rejected_range_shape() {
204        let cases = [
205            ("items=0-1", HttpRangeError::UnsupportedUnit),
206            ("bytes=0-1,3-4", HttpRangeError::MultipleRangesUnsupported),
207            ("bytes=-", HttpRangeError::Malformed),
208            ("bytes=abc-", HttpRangeError::InvalidNumber),
209            ("bytes=-0", HttpRangeError::Unsatisfiable),
210            ("bytes=9-5", HttpRangeError::Unsatisfiable),
211            ("bytes=20-", HttpRangeError::Unsatisfiable),
212        ];
213        for (raw, expected) in cases {
214            assert_eq!(parse_single_byte_range(raw, 20), Err(expected), "{raw}");
215        }
216        assert_eq!(
217            parse_single_byte_range("bytes=0-0", 0),
218            Err(HttpRangeError::EmptyRepresentation)
219        );
220    }
221}