whiskers.git / web / site / src / range.rs
range.rsannotatedrange.rssource202 lines · 7.5 KB · raw

Byte ranges (RFC 9110 section 14): which part of a file a Range header asks for.

Link-preview players (Discord, Twitter) and iOS fetch a video in pieces, so the file that carries it must answer Range. This is the whole decision, as a pure function of the header and the file's length; the Worker only slices and sets headers from the answer.

Only a single bytes range is honoured. A header that is absent, in another unit, malformed, reversed (5-2) or asks for several ranges is ignored and the whole file is sent, which RFC 9110 permits ("a server MAY ignore the Range header field") and is always correct.

What to send for a request, given the file's length.

12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Selection {

The whole file, 200.

15    Whole,

Bytes start..=end of the file, 206. Always start <= end < len.

17    Partial { start: u64, end: u64 },

A valid range that no byte of the file satisfies, 416 with Content-Range: bytes */len.

19    Unsatisfiable,
20}

A number made only of digits. One too large for a u64 saturates, so it behaves as "past 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}

The part of a file of len bytes that a Range header (None if the request had none) 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}

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}
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}