whiskers.git / web / site / src / range.rs
range.rsannotatedrange.rssource202 lines · 7.5 KB · raw
1//! Byte ranges (RFC 9110 section 14): which part of a file a `Range` header asks for.
2//!
3//! Link-preview players (Discord, Twitter) and iOS fetch a video in pieces, so the file that
4//! carries it must answer `Range`. This is the whole decision, as a pure function of the header
5//! and the file's length; the Worker only slices and sets headers from the answer.
6//!
7//! Only a single `bytes` range is honoured. A header that is absent, in another unit, malformed,
8//! reversed (`5-2`) or asks for several ranges is ignored and the whole file is sent, which
9//! RFC 9110 permits ("a server MAY ignore the Range header field") and is always correct.
10
11/// What to send for a request, given the file's length.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Selection {
14    /// The whole file, `200`.
15    Whole,
16    /// Bytes `start..=end` of the file, `206`. Always `start <= end < len`.
17    Partial { start: u64, end: u64 },
18    /// A valid range that no byte of the file satisfies, `416` with `Content-Range: bytes */len`.
19    Unsatisfiable,
20}
21
22/// A number made only of digits. One too large for a `u64` saturates, so it behaves as "past
23/// the end of any file" rather than as a syntax error.
24fn number(text: &str) -> Option<u64> {
25    if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
26        return None;
27    }
28    Some(text.bytes().fold(0u64, |n, b| n.saturating_mul(10).saturating_add(u64::from(b - b'0'))))
29}
30
31/// The part of a file of `len` bytes that a `Range` header (`None` if the request had none)
32/// selects.
33pub fn select(header: Option<&str>, len: u64) -> Selection {
34    let Some(header) = header else { return Selection::Whole };
35    let Some((unit, specs)) = header.split_once('=') else { return Selection::Whole };
36    if !unit.trim().eq_ignore_ascii_case("bytes") {
37        return Selection::Whole;
38    }
39    // Several ranges, or none: not served as a multipart answer, so the whole file.
40    if specs.contains(',') {
41        return Selection::Whole;
42    }
43    let Some((first, last)) = specs.trim().split_once('-') else { return Selection::Whole };
44
45    match (number(first), number(last)) {
46        // `-N`: the last N bytes. `-0` asks for nothing, which no file satisfies.
47        (None, Some(0)) if first.is_empty() => Selection::Unsatisfiable,
48        (None, Some(n)) if first.is_empty() => {
49            if len == 0 {
50                Selection::Unsatisfiable
51            } else {
52                Selection::Partial { start: len.saturating_sub(n), end: len - 1 }
53            }
54        }
55        // `N-`: from N to the end.
56        (Some(start), None) if last.is_empty() => from(start, u64::MAX, len),
57        // `N-M`: reversed is a syntax error (ignored); an end past the file is clamped.
58        (Some(start), Some(end)) => {
59            if end < start {
60                Selection::Whole
61            } else {
62                from(start, end, len)
63            }
64        }
65        _ => Selection::Whole,
66    }
67}
68
69/// `start..=end` clamped to a file of `len` bytes; unsatisfiable when `start` is past the end.
70fn from(start: u64, end: u64, len: u64) -> Selection {
71    if start >= len {
72        Selection::Unsatisfiable
73    } else {
74        Selection::Partial { start, end: end.min(len - 1) }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    const LEN: u64 = 1000;
83
84    fn part(start: u64, end: u64) -> Selection {
85        Selection::Partial { start, end }
86    }
87
88    fn pick(header: &str) -> Selection {
89        select(Some(header), LEN)
90    }
91
92    #[test]
93    fn no_header_is_the_whole_file() {
94        assert_eq!(select(None, LEN), Selection::Whole);
95    }
96
97    #[test]
98    fn a_closed_range() {
99        assert_eq!(pick("bytes=0-99"), part(0, 99));
100        assert_eq!(pick("bytes=500-500"), part(500, 500));
101        assert_eq!(pick("bytes=0-0"), part(0, 0));
102        assert_eq!(pick("bytes=900-999"), part(900, 999));
103    }
104
105    #[test]
106    fn an_open_ended_range_runs_to_the_end() {
107        assert_eq!(pick("bytes=0-"), part(0, 999));
108        assert_eq!(pick("bytes=100-"), part(100, 999));
109        assert_eq!(pick("bytes=999-"), part(999, 999));
110    }
111
112    #[test]
113    fn a_suffix_range_is_the_last_bytes() {
114        assert_eq!(pick("bytes=-1"), part(999, 999));
115        assert_eq!(pick("bytes=-100"), part(900, 999));
116        assert_eq!(pick("bytes=-1000"), part(0, 999));
117    }
118
119    #[test]
120    fn a_suffix_longer_than_the_file_is_the_whole_file_as_a_range() {
121        assert_eq!(pick("bytes=-1001"), part(0, 999));
122        assert_eq!(pick("bytes=-99999999999999999999999999"), part(0, 999));
123    }
124
125    #[test]
126    fn an_end_past_the_file_is_clamped() {
127        assert_eq!(pick("bytes=900-5000"), part(900, 999));
128        assert_eq!(pick("bytes=0-1000"), part(0, 999));
129        assert_eq!(pick("bytes=0-99999999999999999999999999"), part(0, 999));
130    }
131
132    #[test]
133    fn a_start_at_or_past_the_end_cannot_be_satisfied() {
134        assert_eq!(pick("bytes=1000-"), Selection::Unsatisfiable);
135        assert_eq!(pick("bytes=1000-1010"), Selection::Unsatisfiable);
136        assert_eq!(pick("bytes=5000-"), Selection::Unsatisfiable);
137        assert_eq!(pick("bytes=99999999999999999999999999-"), Selection::Unsatisfiable);
138        assert_eq!(pick("bytes=99999999999999999999999999-99999999999999999999999999"), Selection::Unsatisfiable);
139    }
140
141    #[test]
142    fn a_suffix_of_nothing_cannot_be_satisfied() {
143        assert_eq!(pick("bytes=-0"), Selection::Unsatisfiable);
144    }
145
146    #[test]
147    fn an_empty_file_satisfies_no_range() {
148        for header in ["bytes=0-", "bytes=0-0", "bytes=-1", "bytes=0-10", "bytes=5-"] {
149            assert_eq!(select(Some(header), 0), Selection::Unsatisfiable, "{header}");
150        }
151        assert_eq!(select(None, 0), Selection::Whole);
152    }
153
154    #[test]
155    fn a_reversed_range_is_ignored() {
156        assert_eq!(pick("bytes=5-2"), Selection::Whole);
157        assert_eq!(pick("bytes=999-0"), Selection::Whole);
158    }
159
160    #[test]
161    fn several_ranges_are_ignored() {
162        assert_eq!(pick("bytes=0-10,20-30"), Selection::Whole);
163        assert_eq!(pick("bytes=0-10, 20-30"), Selection::Whole);
164        assert_eq!(pick("bytes=0-10,5000-6000"), Selection::Whole);
165        assert_eq!(pick("bytes=0-0,"), Selection::Whole);
166    }
167
168    #[test]
169    fn anything_else_is_ignored() {
170        for header in [
171            "", "bytes", "bytes=", "bytes=-", "bytes= - ", "bytes=abc", "bytes=1-2-3", "bytes=a-b", "bytes=1-b", "bytes=a-2",
172            "bytes=+1-2", "bytes=1.5-2", "bytes=0x10-20", "bytes=--5", "bytes=1--5", "bytes=-5-", "=0-10", "0-10", "items=0-10",
173            "bytes 0-10", "bytes=\u{661}-5",
174        ] {
175            assert_eq!(pick(header), Selection::Whole, "{header:?}");
176        }
177    }
178
179    #[test]
180    fn the_unit_is_case_blind_and_space_around_it_is_allowed() {
181        assert_eq!(pick("Bytes=0-9"), part(0, 9));
182        assert_eq!(pick("BYTES=0-9"), part(0, 9));
183        assert_eq!(pick(" bytes =0-9"), part(0, 9));
184        assert_eq!(pick("bytes= 0-9 "), part(0, 9));
185    }
186
187    #[test]
188    fn a_partial_is_always_inside_the_file() {
189        let numbers = ["0", "1", "2", "9", "10", "11", "99", "100", "101", "99999999999999999999999"];
190        for len in [0, 1, 2, 10, 100] {
191            for a in numbers {
192                for b in numbers {
193                    for header in [format!("bytes={a}-{b}"), format!("bytes={a}-"), format!("bytes=-{b}")] {
194                        if let Selection::Partial { start, end } = select(Some(&header), len) {
195                            assert!(start <= end && end < len, "{header} on {len}: {start}-{end}");
196                        }
197                    }
198                }
199            }
200        }
201    }
202}