Skip to main content

teamcraft/
httprange.rs

1//! HTTP range requests for file downloads (RFC 9110, section 14): a client
2//! that lost the connection half-way through a large file asks for the rest
3//! with `Range: bytes=<offset>-` and `If-Range: "<sha256>"` instead of
4//! starting over.
5//!
6//! Only what resuming needs is supported: one range per request (a list of
7//! ranges is answered with the whole file, which RFC 9110 allows), suffix
8//! ranges (`bytes=-500`) and `If-Range` with the strong ETag of the content
9//! (`"<sha256>"`, the same for every version with that content). A date in
10//! `If-Range` never matches, so the client gets the whole file.
11
12/// What to send for a download request.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum RangeDecision {
15    /// No usable range (none asked for, several, malformed, or `If-Range`
16    /// did not match): `200` with the whole file.
17    Full,
18    /// `206 Partial Content` with the bytes `start..=end`.
19    Partial { start: u64, end: u64 },
20    /// `416 Range Not Satisfiable`: the range starts at or after the end.
21    Unsatisfiable,
22}
23
24/// The strong ETag of a file's content.
25pub fn etag_of(sha256: &str) -> String {
26    format!("\"{sha256}\"")
27}
28
29/// Decides how to answer a request with the headers `range` and `if_range`
30/// for a file of `size` bytes whose ETag is `etag`.
31pub fn decide(range: Option<&str>, if_range: Option<&str>, etag: &str, size: u64) -> RangeDecision {
32    let Some(range) = range else {
33        return RangeDecision::Full;
34    };
35    // If-Range: the range only applies to exactly this representation
36    if let Some(validator) = if_range {
37        if validator.trim() != etag {
38            return RangeDecision::Full;
39        }
40    }
41    let Some(spec) = range.trim().strip_prefix("bytes=") else {
42        return RangeDecision::Full;
43    };
44    if spec.contains(',') {
45        return RangeDecision::Full;
46    }
47    let Some((first, last)) = spec.trim().split_once('-') else {
48        return RangeDecision::Full;
49    };
50    let (first, last) = (first.trim(), last.trim());
51    if first.is_empty() {
52        // suffix range: the last N bytes
53        let Ok(n) = last.parse::<u64>() else {
54            return RangeDecision::Full;
55        };
56        if n == 0 || size == 0 {
57            return RangeDecision::Unsatisfiable;
58        }
59        let start = size.saturating_sub(n);
60        return RangeDecision::Partial {
61            start,
62            end: size - 1,
63        };
64    }
65    let Ok(start) = first.parse::<u64>() else {
66        return RangeDecision::Full;
67    };
68    let end = if last.is_empty() {
69        size.saturating_sub(1)
70    } else {
71        match last.parse::<u64>() {
72            Ok(e) if e >= start => e.min(size.saturating_sub(1)),
73            _ => return RangeDecision::Full,
74        }
75    };
76    if start >= size {
77        return RangeDecision::Unsatisfiable;
78    }
79    RangeDecision::Partial { start, end }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    const TAG: &str = "\"abc\"";
87
88    #[test]
89    fn without_a_range_the_whole_file() {
90        assert_eq!(decide(None, None, TAG, 100), RangeDecision::Full);
91        assert_eq!(decide(None, Some(TAG), TAG, 100), RangeDecision::Full);
92    }
93
94    #[test]
95    fn open_and_closed_ranges() {
96        assert_eq!(
97            decide(Some("bytes=40-"), None, TAG, 100),
98            RangeDecision::Partial { start: 40, end: 99 }
99        );
100        assert_eq!(
101            decide(Some("bytes=0-9"), None, TAG, 100),
102            RangeDecision::Partial { start: 0, end: 9 }
103        );
104        // an end beyond the file is cut to the last byte
105        assert_eq!(
106            decide(Some("bytes=90-500"), None, TAG, 100),
107            RangeDecision::Partial { start: 90, end: 99 }
108        );
109        assert_eq!(
110            decide(Some("bytes=-10"), None, TAG, 100),
111            RangeDecision::Partial { start: 90, end: 99 }
112        );
113        assert_eq!(
114            decide(Some("bytes=-500"), None, TAG, 100),
115            RangeDecision::Partial { start: 0, end: 99 }
116        );
117    }
118
119    #[test]
120    fn past_the_end_is_unsatisfiable() {
121        assert_eq!(
122            decide(Some("bytes=100-"), None, TAG, 100),
123            RangeDecision::Unsatisfiable
124        );
125        assert_eq!(
126            decide(Some("bytes=0-"), None, TAG, 0),
127            RangeDecision::Unsatisfiable
128        );
129        assert_eq!(
130            decide(Some("bytes=-0"), None, TAG, 100),
131            RangeDecision::Unsatisfiable
132        );
133    }
134
135    #[test]
136    fn if_range_must_name_this_content() {
137        assert_eq!(
138            decide(Some("bytes=40-"), Some(TAG), TAG, 100),
139            RangeDecision::Partial { start: 40, end: 99 }
140        );
141        assert_eq!(
142            decide(Some("bytes=40-"), Some("\"other\""), TAG, 100),
143            RangeDecision::Full,
144            "the file changed: start over"
145        );
146        assert_eq!(
147            decide(
148                Some("bytes=40-"),
149                Some("Wed, 21 Oct 2015 07:28:00 GMT"),
150                TAG,
151                100
152            ),
153            RangeDecision::Full,
154            "dates are not supported"
155        );
156    }
157
158    #[test]
159    fn unsupported_or_malformed_ranges_get_the_whole_file() {
160        for range in [
161            "bytes=0-9,20-29",
162            "items=0-9",
163            "bytes=x-",
164            "bytes=9-3",
165            "bytes=",
166            "bytes=-x",
167        ] {
168            assert_eq!(
169                decide(Some(range), None, TAG, 100),
170                RangeDecision::Full,
171                "{range}"
172            );
173        }
174        assert_eq!(etag_of("ab12"), "\"ab12\"");
175    }
176}