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}