Skip to main content

objectstore_types/
range.rs

1//! Types for HTTP range requests.
2
3use std::fmt;
4use std::str::FromStr;
5
6use http::header::HeaderValue;
7use thiserror::Error;
8
9/// Specifier for a single byte range.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ByteRange {
12    /// Bounded range with start and end, inclusive
13    Bounded(u64, u64),
14    /// From offset X onwards
15    From(u64),
16    /// Last X bytes
17    Last(u64),
18}
19
20impl ByteRange {
21    /// Formats this range for a `Range` request header.
22    pub fn to_header_value(&self) -> HeaderValue {
23        let s = match self {
24            ByteRange::Bounded(a, b) => format!("bytes={a}-{b}"),
25            ByteRange::From(n) => format!("bytes={n}-"),
26            ByteRange::Last(n) => format!("bytes=-{n}"),
27        };
28        HeaderValue::from_str(&s).expect("always a valid header value")
29    }
30
31    /// Resolves this range against a known total size, returning `None` if
32    /// unsatisfiable (the object is empty, or the start offset is past the end).
33    pub fn resolve(self, total: u64) -> Option<ContentRange> {
34        if total == 0 {
35            return None;
36        }
37
38        let (start, end) = match self {
39            ByteRange::Bounded(start, end) => {
40                if start >= total {
41                    return None;
42                }
43                (start, end.min(total - 1)) // clamp
44            }
45            ByteRange::From(start) => {
46                if start >= total {
47                    return None;
48                }
49                (start, total - 1)
50            }
51            ByteRange::Last(negative_start) => {
52                let start = total.saturating_sub(negative_start);
53                (start, total - 1)
54            }
55        };
56
57        Some(ContentRange { start, end, total })
58    }
59}
60
61/// Errors that can occur when parsing a `Range` header.
62#[derive(Debug, Clone, PartialEq, Eq, Error)]
63pub enum RangeError {
64    /// The value could not be parsed as a valid byte range.
65    #[error("invalid byte range")]
66    Invalid,
67    /// The value contained multiple range specifiers separated by commas.
68    #[error("expected single byte range, found multipart range")]
69    MultiRange,
70    /// The range unit is invalid
71    #[error("invalid range unit: {0}, expected: bytes")]
72    InvalidUnit(String),
73}
74
75/// Parses a `Range` request header value into a [`ByteRange`].
76///
77/// Only `bytes=` ranges with a single specifier are accepted.
78/// Multiple ranges and non-`bytes` units are rejected.
79impl FromStr for ByteRange {
80    type Err = RangeError;
81
82    fn from_str(value: &str) -> Result<Self, Self::Err> {
83        let lower = value.to_ascii_lowercase();
84        let Some(spec) = lower.strip_prefix("bytes=") else {
85            let unit = lower.split_once('=').map_or(&*lower, |(u, _)| u);
86            return Err(RangeError::InvalidUnit(unit.to_owned()));
87        };
88        if spec.contains(',') {
89            return Err(RangeError::MultiRange);
90        }
91
92        let (start, end) = spec.split_once('-').ok_or(RangeError::Invalid)?;
93        if end.is_empty() {
94            let start: u64 = start.parse().map_err(|_| RangeError::Invalid)?;
95            Ok(ByteRange::From(start))
96        } else if start.is_empty() {
97            let last: u64 = end.parse().map_err(|_| RangeError::Invalid)?;
98            if last == 0 {
99                return Err(RangeError::Invalid);
100            }
101            Ok(ByteRange::Last(last))
102        } else {
103            let start: u64 = start.parse().map_err(|_| RangeError::Invalid)?;
104            let end: u64 = end.parse().map_err(|_| RangeError::Invalid)?;
105            if start > end {
106                return Err(RangeError::Invalid);
107            }
108            Ok(ByteRange::Bounded(start, end))
109        }
110    }
111}
112
113/// Describes which bytes of the full object are present in the response body.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct ContentRange {
116    /// Byte offset of the first byte in the body (inclusive).
117    pub start: u64,
118    /// Byte offset of the last byte in the body (inclusive).
119    pub end: u64,
120    /// Total size of the complete object in bytes.
121    pub total: u64,
122}
123
124/// Parses a `Content-Range` response header value into a [`ContentRange`].
125impl FromStr for ContentRange {
126    type Err = RangeError;
127
128    fn from_str(s: &str) -> Result<Self, Self::Err> {
129        let parse = || {
130            let rest = s.strip_prefix("bytes ")?;
131            let (range_part, total_str) = rest.split_once('/')?;
132            let total: u64 = total_str.parse().ok()?;
133            let (start_str, end_str) = range_part.split_once('-')?;
134            let start: u64 = start_str.parse().ok()?;
135            let end: u64 = end_str.parse().ok()?;
136            if start > end || end >= total {
137                return None;
138            }
139            Some(Self { start, end, total })
140        };
141        parse().ok_or(RangeError::Invalid)
142    }
143}
144
145#[expect(
146    clippy::len_without_is_empty,
147    reason = "A valid ContentRange is never empty"
148)]
149impl ContentRange {
150    /// Returns the number of bytes in this range.
151    pub fn len(&self) -> u64 {
152        self.end - self.start + 1
153    }
154
155    /// Formats this range for a `Content-Range` response header.
156    ///
157    /// The returned value is always valid ASCII and can be inserted directly
158    /// into an HTTP header map.
159    pub fn to_header_value(&self) -> HeaderValue {
160        HeaderValue::from_str(&self.to_string()).expect("always a valid header value")
161    }
162
163    /// Formats the length of this range for a `Content-Length` response header.
164    pub fn len_to_header_value(&self) -> HeaderValue {
165        HeaderValue::from_str(&self.len().to_string()).expect("always a valid header value")
166    }
167
168    /// Parses the total from an unsatisfiable `Content-Range` response header value.
169    ///
170    /// An unsatisfiable `Content-Range` header value is of the form `bytes */1234`, where `1234`
171    /// represents the total size of the object.
172    /// This is communicated back to the client, so that it can make requests that make sense for
173    /// that total.
174    pub fn parse_unsatisfiable_total(header: &str) -> Option<u64> {
175        let rest = header.strip_prefix("bytes */")?;
176        rest.parse().ok()
177    }
178
179    /// Formats `total` as the total size of the object in an unsatisfiable `Content-Range` response header.
180    pub fn unsatisfiable_total_to_header_value(total: u64) -> HeaderValue {
181        HeaderValue::from_str(format!("bytes */{total}").as_str())
182            .expect("always a valid header value")
183    }
184}
185
186impl fmt::Display for ContentRange {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        write!(f, "bytes {}-{}/{}", self.start, self.end, self.total)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn parse_valid_ranges() {
198        assert_eq!(
199            "bytes=0-499".parse::<ByteRange>(),
200            Ok(ByteRange::Bounded(0, 499))
201        );
202        assert_eq!("bytes=500-".parse::<ByteRange>(), Ok(ByteRange::From(500)));
203        assert_eq!("bytes=-100".parse::<ByteRange>(), Ok(ByteRange::Last(100)));
204        // Case insensitive
205        assert_eq!(
206            "Bytes=0-499".parse::<ByteRange>(),
207            Ok(ByteRange::Bounded(0, 499))
208        );
209        assert_eq!("BYTES=100-".parse::<ByteRange>(), Ok(ByteRange::From(100)));
210    }
211
212    #[test]
213    fn parse_invalid_ranges() {
214        assert_eq!(
215            "bytes=0-10, 20-30".parse::<ByteRange>(),
216            Err(RangeError::MultiRange)
217        );
218        assert_eq!(
219            "items=0-10".parse::<ByteRange>(),
220            Err(RangeError::InvalidUnit("items".into()))
221        );
222        assert_eq!(
223            "bytes=500-100".parse::<ByteRange>(),
224            Err(RangeError::Invalid)
225        );
226        assert_eq!("bytes=-0".parse::<ByteRange>(), Err(RangeError::Invalid));
227    }
228
229    #[test]
230    fn resolve_satisfiable() {
231        let cr = |start, end, total| Some(ContentRange { start, end, total });
232        assert_eq!(ByteRange::Bounded(0, 499).resolve(1000), cr(0, 499, 1000));
233        assert_eq!(ByteRange::Bounded(0, 9999).resolve(500), cr(0, 499, 500));
234        assert_eq!(ByteRange::From(500).resolve(1000), cr(500, 999, 1000));
235        assert_eq!(ByteRange::Last(100).resolve(1000), cr(900, 999, 1000));
236        assert_eq!(ByteRange::Last(2000).resolve(1000), cr(0, 999, 1000));
237    }
238
239    #[test]
240    fn resolve_unsatisfiable() {
241        assert_eq!(ByteRange::Bounded(1000, 2000).resolve(500), None);
242        assert_eq!(ByteRange::From(500).resolve(500), None);
243        assert_eq!(ByteRange::Bounded(0, 0).resolve(0), None);
244    }
245
246    #[test]
247    fn content_range_len() {
248        let full = ContentRange {
249            start: 0,
250            end: 999,
251            total: 1000,
252        };
253        assert_eq!(full.len(), 1000);
254
255        let partial = ContentRange {
256            start: 0,
257            end: 499,
258            total: 1000,
259        };
260        assert_eq!(partial.len(), 500);
261    }
262
263    #[test]
264    fn parse_unsatisfiable_total() {
265        assert_eq!(
266            ContentRange::parse_unsatisfiable_total("bytes */1234"),
267            Some(1234)
268        );
269        assert_eq!(
270            ContentRange::parse_unsatisfiable_total("bytes 0-499/1234"),
271            None
272        );
273        assert_eq!(ContentRange::parse_unsatisfiable_total("invalid"), None);
274    }
275
276    #[test]
277    fn header_value_roundtrips() {
278        assert_eq!(ByteRange::Bounded(0, 499).to_header_value(), "bytes=0-499");
279        assert_eq!(ByteRange::From(500).to_header_value(), "bytes=500-");
280        assert_eq!(ByteRange::Last(100).to_header_value(), "bytes=-100");
281
282        let cr = ContentRange {
283            start: 0,
284            end: 499,
285            total: 1234,
286        };
287        assert_eq!(cr.to_header_value(), "bytes 0-499/1234");
288        assert_eq!("bytes 0-499/1234".parse::<ContentRange>(), Ok(cr));
289        assert!("bytes */1234".parse::<ContentRange>().is_err());
290        assert!("invalid".parse::<ContentRange>().is_err());
291        // Inverted bounds
292        assert!("bytes 499-0/1234".parse::<ContentRange>().is_err());
293        // End beyond total
294        assert!("bytes 0-1234/1234".parse::<ContentRange>().is_err());
295        assert_eq!(
296            ContentRange::unsatisfiable_total_to_header_value(1234),
297            "bytes */1234"
298        );
299    }
300}