| 1 | //! Sizes, chunk plans and the range headers of a chunked upload. |
| 2 | |
| 3 | pub const MIB: u64 = 1024 * 1024; |
| 4 | /// What a chunk is unless `--chunk-size` says otherwise. |
| 5 | #[cfg_attr(not(test), allow(dead_code))] |
| 6 | pub const DEFAULT_CHUNK: u64 = 90 * MIB; |
| 7 | /// The largest chunk: well under the 100 MB a request to g1t.sh may carry. |
| 8 | pub const MAX_CHUNK: u64 = 95 * MIB; |
| 9 | /// The smallest: every chunk but the last becomes a part in storage. |
| 10 | pub const MIN_CHUNK: u64 = 5 * MIB; |
| 11 | |
| 12 | /// Reads a size like `90MB`, `90MiB`, `90M`, `512K` or `94371840`. K, M and |
| 13 | /// G count in 1,024s, with or without a trailing `B` or `iB`. |
| 14 | pub fn parse_size(text: &str) -> Result<u64, String> { |
| 15 | let text = text.trim(); |
| 16 | let split = text |
| 17 | .find(|c: char| !c.is_ascii_digit() && c != '.') |
| 18 | .unwrap_or(text.len()); |
| 19 | let (number, unit) = text.split_at(split); |
| 20 | let number: f64 = number |
| 21 | .parse() |
| 22 | .map_err(|_| format!("`{text}` is not a size, like 90MB"))?; |
| 23 | let unit = unit.trim().to_ascii_lowercase(); |
| 24 | let unit = unit |
| 25 | .strip_suffix("ib") |
| 26 | .or_else(|| unit.strip_suffix('b')) |
| 27 | .unwrap_or(&unit); |
| 28 | let scale = match unit { |
| 29 | "" => 1, |
| 30 | "k" => 1024, |
| 31 | "m" => MIB, |
| 32 | "g" => 1024 * MIB, |
| 33 | _ => return Err(format!("`{text}` is not a size, like 90MB")), |
| 34 | }; |
| 35 | Ok((number * scale as f64) as u64) |
| 36 | } |
| 37 | |
| 38 | /// Checks a chunk size against the limits. |
| 39 | pub fn check_chunk(size: u64) -> Result<u64, String> { |
| 40 | if size > MAX_CHUNK { |
| 41 | return Err(format!( |
| 42 | "A chunk may be at most 95MiB ({MAX_CHUNK} bytes), to stay under the 100 MB a request may carry." |
| 43 | )); |
| 44 | } |
| 45 | if size < MIN_CHUNK { |
| 46 | return Err(format!( |
| 47 | "A chunk must be at least 5MiB ({MIN_CHUNK} bytes)." |
| 48 | )); |
| 49 | } |
| 50 | Ok(size) |
| 51 | } |
| 52 | |
| 53 | /// The chunks that send bytes `from..total`: `(start, length)` each. |
| 54 | pub fn plan(total: u64, chunk: u64, from: u64) -> Vec<(u64, u64)> { |
| 55 | let mut chunks = Vec::new(); |
| 56 | let mut start = from; |
| 57 | while start < total { |
| 58 | let length = chunk.min(total - start); |
| 59 | chunks.push((start, length)); |
| 60 | start += length; |
| 61 | } |
| 62 | chunks |
| 63 | } |
| 64 | |
| 65 | /// How many chunks a blob of `total` bytes takes. |
| 66 | pub fn count(total: u64, chunk: u64) -> u64 { |
| 67 | total.div_ceil(chunk).max(1) |
| 68 | } |
| 69 | |
| 70 | /// A PATCH's `Content-Range`: `<first byte>-<last byte>`. |
| 71 | pub fn content_range(start: u64, length: u64) -> String { |
| 72 | format!("{start}-{}", start + length.max(1) - 1) |
| 73 | } |
| 74 | |
| 75 | /// The offset an upload's `Range` header (`0-<last byte>`) says comes next. |
| 76 | /// `0-0` reads as nothing received yet: registries say it before the first |
| 77 | /// byte, and a one-byte upload that was in fact received is caught by the |
| 78 | /// registry refusing the resent byte. |
| 79 | pub fn next_offset(range: &str) -> Option<u64> { |
| 80 | let text = range.trim(); |
| 81 | let text = text |
| 82 | .strip_prefix("bytes=") |
| 83 | .or_else(|| text.strip_prefix("bytes ")) |
| 84 | .unwrap_or(text); |
| 85 | let (start, end) = text.split_once('-')?; |
| 86 | let (start, end): (u64, u64) = (start.trim().parse().ok()?, end.trim().parse().ok()?); |
| 87 | if start != 0 { |
| 88 | return None; |
| 89 | } |
| 90 | Some(if end == 0 { 0 } else { end + 1 }) |
| 91 | } |
| 92 | |
| 93 | /// A size for people: `612 B`, `3.5 MiB`. |
| 94 | pub fn human(bytes: u64) -> String { |
| 95 | const UNITS: [&str; 4] = ["KiB", "MiB", "GiB", "TiB"]; |
| 96 | if bytes < 1024 { |
| 97 | return format!("{bytes} B"); |
| 98 | } |
| 99 | let mut value = bytes as f64 / 1024.0; |
| 100 | let mut unit = 0; |
| 101 | while value >= 1024.0 && unit < UNITS.len() - 1 { |
| 102 | value /= 1024.0; |
| 103 | unit += 1; |
| 104 | } |
| 105 | format!("{value:.1} {}", UNITS[unit]) |
| 106 | } |
| 107 | |
| 108 | #[cfg(test)] |
| 109 | mod tests { |
| 110 | use super::*; |
| 111 | |
| 112 | #[test] |
| 113 | fn sizes_read_in_1024s_with_any_suffix() { |
| 114 | assert_eq!(parse_size("90MB"), Ok(90 * MIB)); |
| 115 | assert_eq!(parse_size("90MiB"), Ok(90 * MIB)); |
| 116 | assert_eq!(parse_size("90m"), Ok(90 * MIB)); |
| 117 | assert_eq!(parse_size("512K"), Ok(512 * 1024)); |
| 118 | assert_eq!(parse_size("94371840"), Ok(94_371_840)); |
| 119 | assert_eq!(parse_size("1.5G"), Ok(1536 * MIB)); |
| 120 | assert!(parse_size("ninety").is_err()); |
| 121 | assert!(parse_size("90XB").is_err()); |
| 122 | } |
| 123 | |
| 124 | #[test] |
| 125 | fn chunks_stay_between_5_and_95_mib() { |
| 126 | assert_eq!(check_chunk(95 * MIB), Ok(95 * MIB)); |
| 127 | assert!(check_chunk(95 * MIB + 1).is_err()); |
| 128 | assert!(check_chunk(100_000_000).is_err()); |
| 129 | assert!(check_chunk(MIN_CHUNK - 1).is_err()); |
| 130 | } |
| 131 | |
| 132 | #[test] |
| 133 | fn a_plan_covers_every_byte_once() { |
| 134 | let total = 150_000_000 + 4_096; |
| 135 | let chunks = plan(total, DEFAULT_CHUNK, 0); |
| 136 | assert_eq!( |
| 137 | chunks, |
| 138 | vec![(0, DEFAULT_CHUNK), (DEFAULT_CHUNK, total - DEFAULT_CHUNK)] |
| 139 | ); |
| 140 | assert_eq!(chunks.iter().map(|c| c.1).sum::<u64>(), total); |
| 141 | assert_eq!(count(total, DEFAULT_CHUNK), 2); |
| 142 | } |
| 143 | |
| 144 | #[test] |
| 145 | fn a_plan_on_an_exact_multiple_has_no_empty_tail() { |
| 146 | let chunks = plan(3 * MIB * 10, 10 * MIB, 0); |
| 147 | assert_eq!(chunks.len(), 3); |
| 148 | assert!(chunks.iter().all(|c| c.1 == 10 * MIB)); |
| 149 | } |
| 150 | |
| 151 | #[test] |
| 152 | fn a_plan_resumes_from_an_offset() { |
| 153 | let chunks = plan(25 * MIB, 10 * MIB, 10 * MIB); |
| 154 | assert_eq!(chunks, vec![(10 * MIB, 10 * MIB), (20 * MIB, 5 * MIB)]); |
| 155 | assert!(plan(25 * MIB, 10 * MIB, 25 * MIB).is_empty()); |
| 156 | } |
| 157 | |
| 158 | #[test] |
| 159 | fn small_and_empty_blobs_are_one_chunk_or_none() { |
| 160 | assert_eq!(plan(612, DEFAULT_CHUNK, 0), vec![(0, 612)]); |
| 161 | assert!(plan(0, DEFAULT_CHUNK, 0).is_empty()); |
| 162 | assert_eq!(count(0, DEFAULT_CHUNK), 1); |
| 163 | } |
| 164 | |
| 165 | #[test] |
| 166 | fn content_ranges_name_the_first_and_last_byte() { |
| 167 | assert_eq!(content_range(0, 1024), "0-1023"); |
| 168 | assert_eq!( |
| 169 | content_range(DEFAULT_CHUNK, 10), |
| 170 | format!("{}-{}", DEFAULT_CHUNK, DEFAULT_CHUNK + 9) |
| 171 | ); |
| 172 | } |
| 173 | |
| 174 | #[test] |
| 175 | fn upload_ranges_give_the_next_offset() { |
| 176 | assert_eq!(next_offset("0-0"), Some(0)); |
| 177 | assert_eq!(next_offset("0-94371839"), Some(94_371_840)); |
| 178 | assert_eq!(next_offset("bytes=0-1023"), Some(1024)); |
| 179 | assert_eq!(next_offset("5-10"), None); |
| 180 | assert_eq!(next_offset("junk"), None); |
| 181 | } |
| 182 | |
| 183 | #[test] |
| 184 | fn sizes_print_for_people() { |
| 185 | assert_eq!(human(612), "612 B"); |
| 186 | assert_eq!(human(150_000_000), "143.1 MiB"); |
| 187 | } |
| 188 | } |