Skip to main content

objectstore_types/
range.rs

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