Skip to main content

shardline_protocol/
ranges.rs

1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4/// Inclusive byte range.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub struct ByteRange {
7    start: u64,
8    end_inclusive: u64,
9}
10
11impl ByteRange {
12    /// Creates an inclusive byte range.
13    ///
14    /// # Errors
15    ///
16    /// Returns [`RangeError::Inverted`] when `end_inclusive` is smaller than `start`.
17    pub const fn new(start: u64, end_inclusive: u64) -> Result<Self, RangeError> {
18        if end_inclusive < start {
19            return Err(RangeError::Inverted);
20        }
21
22        Ok(Self {
23            start,
24            end_inclusive,
25        })
26    }
27
28    /// Returns the first byte offset in the range.
29    #[must_use]
30    pub const fn start(&self) -> u64 {
31        self.start
32    }
33
34    /// Returns the inclusive final byte offset in the range.
35    #[must_use]
36    pub const fn end_inclusive(&self) -> u64 {
37        self.end_inclusive
38    }
39
40    /// Returns the number of bytes in the range.
41    #[must_use]
42    pub const fn len(&self) -> Option<u64> {
43        match self.end_inclusive.checked_sub(self.start) {
44            Some(offset) => offset.checked_add(1),
45            None => None,
46        }
47    }
48
49    /// Returns false because validated inclusive byte ranges always contain at least one byte.
50    #[must_use]
51    pub const fn is_empty(&self) -> bool {
52        false
53    }
54}
55
56/// End-exclusive chunk index range.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ChunkRange {
59    start: u32,
60    end_exclusive: u32,
61}
62
63impl ChunkRange {
64    /// Creates an end-exclusive chunk index range.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`RangeError::Empty`] when `end_exclusive` is equal to `start`.
69    /// Returns [`RangeError::Inverted`] when `end_exclusive` is smaller than `start`.
70    pub const fn new(start: u32, end_exclusive: u32) -> Result<Self, RangeError> {
71        if end_exclusive < start {
72            return Err(RangeError::Inverted);
73        }
74
75        if end_exclusive == start {
76            return Err(RangeError::Empty);
77        }
78
79        Ok(Self {
80            start,
81            end_exclusive,
82        })
83    }
84
85    /// Returns the first chunk index in the range.
86    #[must_use]
87    pub const fn start(self) -> u32 {
88        self.start
89    }
90
91    /// Returns the end-exclusive chunk index.
92    #[must_use]
93    pub const fn end_exclusive(self) -> u32 {
94        self.end_exclusive
95    }
96}
97
98/// Range construction failure.
99#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
100pub enum RangeError {
101    /// The range end was smaller than the range start.
102    #[error("range end must not be smaller than range start")]
103    Inverted,
104    /// The range contained no chunks.
105    #[error("chunk range must contain at least one chunk")]
106    Empty,
107}
108
109/// Reconstruction request range parse failure.
110#[derive(Debug, Clone, Error)]
111pub enum HttpRangeParseError {
112    /// The header did not start with the expected unit token.
113    #[error("range header must use bytes=<start>-<end> syntax")]
114    MissingBytesUnit,
115    /// The header contained unsupported or malformed syntax.
116    #[error("range header must use bytes=<start>-<end> syntax: {0}")]
117    InvalidSyntax(String),
118    /// The numeric range could not be parsed.
119    #[error("range header contained an invalid number: {0}")]
120    InvalidNumber(String),
121    /// The requested start exceeded the represented resource length.
122    #[error("requested range is not satisfiable")]
123    Unsatisfiable,
124}
125
126/// Parses a reconstruction `Range` header into an inclusive byte range.
127///
128/// The Xet reconstruction API uses `bytes=<start>-<end>` syntax with an inclusive end.
129/// When the requested end exceeds the resource length, the returned range is clamped to
130/// the last byte of the resource.
131///
132/// # Errors
133///
134/// Returns [`HttpRangeParseError::InvalidSyntax`] when the header uses unsupported
135/// syntax, [`HttpRangeParseError::InvalidNumber`] when parsing fails, and
136/// [`HttpRangeParseError::Unsatisfiable`] when the requested start exceeds the last byte
137/// of the resource.
138pub fn parse_http_byte_range(
139    value: &str,
140    resource_length: u64,
141) -> Result<ByteRange, HttpRangeParseError> {
142    let Some(raw_suffix) = value.strip_prefix("bytes=") else {
143        return Err(HttpRangeParseError::MissingBytesUnit);
144    };
145    if raw_suffix.is_empty() {
146        return Err(HttpRangeParseError::InvalidSyntax(
147            "empty range suffix".to_owned(),
148        ));
149    }
150    if raw_suffix.contains(',') {
151        return Err(HttpRangeParseError::InvalidSyntax(
152            "multi-range not supported".to_owned(),
153        ));
154    }
155
156    let mut parts = raw_suffix.splitn(2, '-');
157    let Some(raw_start) = parts.next() else {
158        return Err(HttpRangeParseError::InvalidSyntax(
159            "missing range start".to_owned(),
160        ));
161    };
162    let Some(raw_end) = parts.next() else {
163        return Err(HttpRangeParseError::InvalidSyntax(
164            "missing range end".to_owned(),
165        ));
166    };
167    if raw_start.is_empty() {
168        // Suffix range: bytes=-N (last N bytes)
169        let suffix_len = raw_end
170            .parse::<u64>()
171            .map_err(|e| HttpRangeParseError::InvalidNumber(e.to_string()))?;
172        if suffix_len == 0 {
173            return Err(HttpRangeParseError::InvalidSyntax(
174                "suffix length must be non-zero".to_owned(),
175            ));
176        }
177        let start = resource_length.saturating_sub(suffix_len);
178        let end = resource_length.saturating_sub(1);
179        return ByteRange::new(start, end)
180            .map_err(|err| HttpRangeParseError::InvalidSyntax(err.to_string()));
181    }
182
183    let start = raw_start
184        .parse::<u64>()
185        .map_err(|e| HttpRangeParseError::InvalidNumber(e.to_string()))?;
186    if start >= resource_length {
187        return Err(HttpRangeParseError::Unsatisfiable);
188    }
189
190    let last_byte = resource_length
191        .checked_sub(1)
192        .ok_or(HttpRangeParseError::Unsatisfiable)?;
193    let parsed_end = if raw_end.is_empty() {
194        last_byte
195    } else {
196        raw_end
197            .parse::<u64>()
198            .map_err(|e| HttpRangeParseError::InvalidNumber(e.to_string()))?
199    };
200    let end_inclusive = parsed_end.min(last_byte);
201
202    ByteRange::new(start, end_inclusive)
203        .map_err(|err| HttpRangeParseError::InvalidSyntax(err.to_string()))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::{ByteRange, ChunkRange, HttpRangeParseError, RangeError, parse_http_byte_range};
209
210    #[test]
211    fn byte_range_is_inclusive() {
212        let range = ByteRange::new(10, 20);
213
214        assert!(range.is_ok());
215        if let Ok(value) = range {
216            assert_eq!(value.start(), 10);
217            assert_eq!(value.end_inclusive(), 20);
218            assert_eq!(value.len(), Some(11));
219            assert!(!value.is_empty());
220        }
221    }
222
223    #[test]
224    fn byte_range_rejects_inverted_input() {
225        let range = ByteRange::new(20, 10);
226
227        assert_eq!(range, Err(RangeError::Inverted));
228    }
229
230    #[test]
231    fn byte_range_reports_unrepresentable_full_u64_length() {
232        let range = ByteRange::new(0, u64::MAX);
233
234        assert!(range.is_ok());
235        if let Ok(value) = range {
236            assert_eq!(value.len(), None);
237        }
238    }
239
240    #[test]
241    fn chunk_range_rejects_empty_ranges() {
242        let range = ChunkRange::new(4, 4);
243
244        assert_eq!(range, Err(RangeError::Empty));
245    }
246
247    #[test]
248    fn chunk_range_rejects_inverted_ranges() {
249        let range = ChunkRange::new(5, 4);
250
251        assert_eq!(range, Err(RangeError::Inverted));
252    }
253
254    #[test]
255    fn chunk_range_is_end_exclusive() {
256        let range = ChunkRange::new(4, 9);
257
258        assert!(range.is_ok());
259        if let Ok(value) = range {
260            assert_eq!(value.start(), 4);
261            assert_eq!(value.end_exclusive(), 9);
262        }
263    }
264
265    #[test]
266    fn http_byte_range_parses_inclusive_range() {
267        let parsed = parse_http_byte_range("bytes=10-20", 100);
268        let expected = ByteRange::new(10, 20);
269
270        assert!(expected.is_ok());
271        assert_eq!(parsed.unwrap(), expected.unwrap());
272    }
273
274    #[test]
275    fn http_byte_range_clamps_open_or_oversized_end_to_resource() {
276        let open_ended = parse_http_byte_range("bytes=10-", 25);
277        let oversized = parse_http_byte_range("bytes=10-999", 25);
278        let expected = ByteRange::new(10, 24);
279
280        assert!(expected.is_ok());
281        let expected = expected.unwrap();
282        assert_eq!(open_ended.unwrap(), expected);
283        assert_eq!(oversized.unwrap(), expected);
284    }
285
286    #[test]
287    fn http_byte_range_rejects_invalid_syntax() {
288        assert!(matches!(
289            parse_http_byte_range("items=0-1", 10),
290            Err(HttpRangeParseError::MissingBytesUnit)
291        ));
292        assert!(matches!(
293            parse_http_byte_range("bytes=1-2,4-5", 10),
294            Err(HttpRangeParseError::InvalidSyntax(_))
295        ));
296        assert!(matches!(
297            parse_http_byte_range("bytes=2-1", 10),
298            Err(HttpRangeParseError::InvalidSyntax(_))
299        ));
300        assert!(matches!(
301            parse_http_byte_range("bytes= 1-2", 10),
302            Err(HttpRangeParseError::InvalidNumber(_))
303        ));
304        assert!(matches!(
305            parse_http_byte_range("bytes=1 -2", 10),
306            Err(HttpRangeParseError::InvalidNumber(_))
307        ));
308    }
309
310    #[test]
311    fn http_byte_range_accepts_suffix_and_rejects_multi_range_forms() {
312        let suffix = parse_http_byte_range("bytes=-1", 10);
313        let expected = ByteRange::new(9, 9);
314        assert!(expected.is_ok());
315        assert_eq!(suffix.unwrap(), expected.unwrap());
316
317        let suffix_large = parse_http_byte_range("bytes=-100", 50);
318        let expected_large = ByteRange::new(0, 49);
319        assert!(expected_large.is_ok());
320        assert_eq!(suffix_large.unwrap(), expected_large.unwrap());
321
322        assert!(matches!(
323            parse_http_byte_range("bytes=0-0,1-1", 10),
324            Err(HttpRangeParseError::InvalidSyntax(_))
325        ));
326    }
327
328    #[test]
329    fn http_byte_range_rejects_unsatisfiable_start() {
330        assert!(matches!(
331            parse_http_byte_range("bytes=10-20", 10),
332            Err(HttpRangeParseError::Unsatisfiable)
333        ));
334        assert!(matches!(
335            parse_http_byte_range("bytes=0-0", 0),
336            Err(HttpRangeParseError::Unsatisfiable)
337        ));
338    }
339
340    #[test]
341    fn http_byte_range_rejects_empty_raw_suffix() {
342        assert!(matches!(
343            parse_http_byte_range("bytes=", 10),
344            Err(HttpRangeParseError::InvalidSyntax(_))
345        ));
346    }
347
348    #[test]
349    fn http_byte_range_rejects_empty_start_no_end() {
350        // "bytes=-" hits the suffix branch; empty suffix length fails parse as InvalidNumber
351        assert!(matches!(
352            parse_http_byte_range("bytes=-", 10),
353            Err(HttpRangeParseError::InvalidNumber(_))
354        ));
355    }
356
357    #[test]
358    fn http_byte_range_rejects_suffix_zero() {
359        assert!(matches!(
360            parse_http_byte_range("bytes=-0", 10),
361            Err(HttpRangeParseError::InvalidSyntax(_))
362        ));
363    }
364
365    #[test]
366    fn http_byte_range_suffix_larger_than_resource_clamps_to_start() {
367        let result = parse_http_byte_range("bytes=-999", 100);
368        let expected = ByteRange::new(0, 99);
369        assert!(expected.is_ok());
370        assert_eq!(result.unwrap(), expected.unwrap());
371    }
372
373    // --- error Display tests ---
374
375    #[test]
376    fn range_error_display_inverted() {
377        let msg = RangeError::Inverted.to_string();
378        assert!(!msg.is_empty());
379        assert!(
380            msg.contains("smaller"),
381            "expected 'smaller' in display, got: {msg}"
382        );
383    }
384
385    #[test]
386    fn range_error_display_empty() {
387        let msg = RangeError::Empty.to_string();
388        assert!(!msg.is_empty());
389        assert!(
390            msg.contains("at least one chunk"),
391            "expected 'at least one chunk' in display, got: {msg}"
392        );
393    }
394
395    #[test]
396    fn http_range_parse_error_display_all_variants() {
397        let cases: &[(HttpRangeParseError, &str)] = &[
398            (HttpRangeParseError::MissingBytesUnit, "syntax"),
399            (
400                HttpRangeParseError::InvalidSyntax("test".to_owned()),
401                "syntax",
402            ),
403            (
404                HttpRangeParseError::InvalidNumber("test".to_owned()),
405                "invalid number",
406            ),
407            (HttpRangeParseError::Unsatisfiable, "satisfiable"),
408        ];
409        for (error, substring) in cases {
410            let msg = error.to_string();
411            assert!(!msg.is_empty(), "empty display for {error:?}");
412            assert!(
413                msg.contains(substring),
414                "expected '{substring}' in '{msg}' from {error:?}"
415            );
416        }
417    }
418
419    // ── Additional ByteRange edge cases ──────────────────────────────────
420
421    #[test]
422    fn byte_range_single_byte() {
423        let range = ByteRange::new(5, 5).unwrap();
424        assert_eq!(range.start(), 5);
425        assert_eq!(range.end_inclusive(), 5);
426        assert_eq!(range.len(), Some(1));
427        assert!(!range.is_empty());
428    }
429
430    #[test]
431    fn byte_range_zero_length_range() {
432        // Start == end is valid — covers 1 byte
433        let range = ByteRange::new(0, 0).unwrap();
434        assert_eq!(range.len(), Some(1));
435    }
436
437    #[test]
438    fn byte_range_u64_max_start() {
439        let range = ByteRange::new(u64::MAX, u64::MAX);
440        assert!(range.is_ok());
441        let range = range.unwrap();
442        assert_eq!(range.len(), Some(1));
443    }
444
445    #[test]
446    fn byte_range_clone_copy_consistency() {
447        let range = ByteRange::new(10, 20).unwrap();
448        let cloned = range;
449        assert_eq!(range, cloned);
450        assert_eq!(range.start(), cloned.start());
451        assert_eq!(range.end_inclusive(), cloned.end_inclusive());
452    }
453
454    // ── Additional ChunkRange edge cases ─────────────────────────────────
455
456    #[test]
457    fn chunk_range_single_chunk() {
458        let range = ChunkRange::new(3, 4).unwrap();
459        assert_eq!(range.start(), 3);
460        assert_eq!(range.end_exclusive(), 4);
461    }
462
463    #[test]
464    fn chunk_range_u32_bounds() {
465        let range = ChunkRange::new(0, u32::MAX).unwrap();
466        assert_eq!(range.start(), 0);
467        assert_eq!(range.end_exclusive(), u32::MAX);
468    }
469
470    #[test]
471    fn chunk_range_clone_copy_consistency() {
472        let range = ChunkRange::new(1, 5).unwrap();
473        let cloned = range;
474        assert_eq!(range, cloned);
475        assert_eq!(range.start(), cloned.start());
476    }
477
478    // ── Additional parse_http_byte_range edge cases ──────────────────────
479
480    #[test]
481    fn http_byte_range_exact_resource_length_start_zero() {
482        // Start=0 with resource_length=1 -> byte 0
483        let result = parse_http_byte_range("bytes=0-0", 1).unwrap();
484        assert_eq!(result.start(), 0);
485        assert_eq!(result.end_inclusive(), 0);
486    }
487
488    #[test]
489    fn http_byte_range_rejects_start_equals_resource_length() {
490        // start == resource_length is unsatisfiable
491        let result = parse_http_byte_range("bytes=5-10", 5);
492        assert!(matches!(result, Err(HttpRangeParseError::Unsatisfiable)));
493    }
494
495    #[test]
496    fn http_byte_range_zero_resource_length() {
497        // resource_length=0 -> any range is unsatisfiable
498        let result = parse_http_byte_range("bytes=0-0", 0);
499        assert!(matches!(result, Err(HttpRangeParseError::Unsatisfiable)));
500    }
501
502    #[test]
503    fn http_byte_range_suffix_zero_resource_length() {
504        // resource_length=0, suffix "-0" -> no bytes available
505        let result = parse_http_byte_range("bytes=-0", 0);
506        assert!(matches!(result, Err(HttpRangeParseError::InvalidSyntax(_))));
507    }
508
509    #[test]
510    fn http_byte_range_huge_numbers() {
511        let result = parse_http_byte_range("bytes=99999999999999999999-100000000000000000000", 100);
512        assert!(matches!(result, Err(HttpRangeParseError::InvalidNumber(_))));
513    }
514
515    // ── RangeError derive tests ──────────────────────────────────────────
516
517    #[test]
518    fn range_error_clone_copy_partial_eq() {
519        let a = RangeError::Inverted;
520        let b = RangeError::Empty;
521        let a2 = a;
522        assert_eq!(a, a2);
523        assert_ne!(a, b);
524    }
525
526    #[test]
527    fn http_range_parse_error_debug_non_empty() {
528        let err = HttpRangeParseError::InvalidSyntax("test".to_owned());
529        let debug = format!("{err:?}");
530        assert!(!debug.is_empty());
531    }
532}