Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 1 | //! Artifacts as ZIP files, as `actions/upload-artifact` makes them and |
| 2 | //! `actions/download-artifact` reads them: each file deflated (or stored, | |
| 3 | //! at level 0) with its CRC-32, UTF-8 names, unix modes and times. | |
| 4 | //! | |
| 5 | //! Each local header is written before its file and then filled in by | |
| 6 | //! seeking back, so there are no data descriptors and the ZIP unpacks as | |
| 7 | //! it streams. Sizes, offsets and counts too large for the classic fields | |
| 8 | //! go in ZIP64 extra fields and ZIP64 end records. Files are streamed in | |
| 9 | //! and out; none is held in memory whole. | |
| 10 | ||
| 11 | use std::fs::File; | |
| 12 | use std::io::{self, BufReader, BufWriter, Read, Seek, SeekFrom, Write}; | |
| 13 | use std::path::{Component, Path, PathBuf}; | |
| 14 | use std::time::SystemTime; | |
| 15 | ||
| 16 | use flate2::Compression; | |
| 17 | use flate2::read::DeflateDecoder; | |
| 18 | use flate2::write::DeflateEncoder; | |
| 19 | ||
| 20 | const LOCAL: u32 = 0x0403_4b50; | |
| 21 | const CENTRAL: u32 = 0x0201_4b50; | |
| 22 | const END: u32 = 0x0605_4b50; | |
| 23 | const END64: u32 = 0x0606_4b50; | |
| 24 | const LOCATOR64: u32 = 0x0706_4b50; | |
| 25 | /// Names are UTF-8 (general purpose flag bit 11). | |
| 26 | const UTF8: u16 = 1 << 11; | |
| 27 | /// Made by unix (3), to version 4.5 of the specification. | |
| 28 | const MADE_BY: u16 = (3 << 8) | 45; | |
| 29 | /// A file at least this big gets a ZIP64 local header, leaving room for | |
| 30 | /// deflate to come out a little larger than what went in. | |
| 31 | const LOCAL64_FROM: u64 = 0xF000_0000; | |
| 32 | const MAX32: u64 = 0xFFFF_FFFF; | |
| 33 | ||
| 34 | /// What packing wrote. | |
| 35 | #[derive(Debug)] | |
| 36 | pub(crate) struct Packed { | |
| 37 | pub(crate) files: usize, | |
| 38 | /// The ZIP file's size. | |
| 39 | pub(crate) size: u64, | |
| 40 | } | |
| 41 | ||
| 42 | /// One file in the central directory. | |
| 43 | #[derive(Debug, Clone, PartialEq)] | |
| 44 | pub(crate) struct Entry { | |
| 45 | pub(crate) name: String, | |
| 46 | pub(crate) method: u16, | |
| 47 | pub(crate) crc: u32, | |
| 48 | pub(crate) compressed: u64, | |
| 49 | pub(crate) size: u64, | |
| 50 | pub(crate) offset: u64, | |
| 51 | pub(crate) time: u16, | |
| 52 | pub(crate) date: u16, | |
| 53 | /// The unix mode, when the entry has one. | |
| 54 | pub(crate) mode: Option<u32>, | |
| 55 | } | |
| 56 | ||
| 57 | /// Counts what goes through to the file, for the compressed size. | |
| 58 | struct Counted<'a, W: Write> { | |
| 59 | inner: &'a mut W, | |
| 60 | count: u64, | |
| 61 | } | |
| 62 | ||
| 63 | impl<W: Write> Write for Counted<'_, W> { | |
| 64 | fn write(&mut self, buf: &[u8]) -> io::Result<usize> { | |
| 65 | let n = self.inner.write(buf)?; | |
| 66 | self.count += n as u64; | |
| 67 | Ok(n) | |
| 68 | } | |
| 69 | fn flush(&mut self) -> io::Result<()> { | |
| 70 | self.inner.flush() | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | /// Packs `files`, each a name in the ZIP and the file on disk, into the | |
| 75 | /// ZIP file `out`, deflated at `level` (0 to 9; 0 stores them as they are). | |
| 76 | pub(crate) fn write(out: &Path, files: &[(String, PathBuf)], level: u32) -> io::Result<Packed> { | |
| 77 | let mut zip = BufWriter::new(File::create(out)?); | |
| 78 | let mut entries = Vec::with_capacity(files.len()); | |
| 79 | let mut buffer = vec![0u8; 64 * 1024]; | |
| 80 | for (name, path) in files { | |
| 81 | let meta = std::fs::metadata(path)?; | |
| 82 | let (time, date) = dos_time(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH)); | |
| 83 | let method = if level == 0 { 0 } else { 8 }; | |
| 84 | let offset = zip.stream_position()?; | |
| 85 | let large = meta.len() >= LOCAL64_FROM; | |
| 86 | let mut entry = Entry { name: name.clone(), method, crc: 0, compressed: 0, size: 0, offset, time, date, mode: Some(mode(&meta)) }; | |
| 87 | zip.write_all(&local_header(&entry, large))?; | |
| 88 | let data_start = zip.stream_position()?; | |
| 89 | let mut crc = crc32fast::Hasher::new(); | |
| 90 | let mut size = 0u64; | |
| 91 | let mut input = File::open(path)?; | |
| 92 | let mut counted = Counted { inner: &mut zip, count: 0 }; | |
| 93 | if method == 0 { | |
| 94 | loop { | |
| 95 | let n = input.read(&mut buffer)?; | |
| 96 | if n == 0 { | |
| 97 | break; | |
| 98 | } | |
| 99 | crc.update(&buffer[..n]); | |
| 100 | size += n as u64; | |
| 101 | counted.write_all(&buffer[..n])?; | |
| 102 | } | |
| 103 | } else { | |
| 104 | let mut encoder = DeflateEncoder::new(&mut counted, Compression::new(level.min(9))); | |
| 105 | loop { | |
| 106 | let n = input.read(&mut buffer)?; | |
| 107 | if n == 0 { | |
| 108 | break; | |
| 109 | } | |
| 110 | crc.update(&buffer[..n]); | |
| 111 | size += n as u64; | |
| 112 | encoder.write_all(&buffer[..n])?; | |
| 113 | } | |
| 114 | encoder.finish()?; | |
| 115 | } | |
| 116 | entry.compressed = counted.count; | |
| 117 | entry.size = size; | |
| 118 | entry.crc = crc.finalize(); | |
| 119 | if !large && (entry.size > MAX32 || entry.compressed > MAX32) { | |
| 120 | return Err(io::Error::other(format!("{name} grew past 4 GB while it was packed"))); | |
| 121 | } | |
| 122 | let end = zip.stream_position()?; | |
| 123 | debug_assert_eq!(end, data_start + entry.compressed); | |
| 124 | zip.seek(SeekFrom::Start(offset))?; | |
| 125 | zip.write_all(&local_header(&entry, large))?; | |
| 126 | zip.seek(SeekFrom::Start(end))?; | |
| 127 | entries.push(entry); | |
| 128 | } | |
| 129 | let directory = zip.stream_position()?; | |
| 130 | for entry in &entries { | |
| 131 | zip.write_all(¢ral_header(entry))?; | |
| 132 | } | |
| 133 | let directory_size = zip.stream_position()? - directory; | |
| 134 | zip.write_all(&end_records(entries.len() as u64, directory_size, directory))?; | |
| 135 | let size = zip.stream_position()?; | |
| 136 | zip.into_inner().map_err(|e| e.into_error())?.sync_all()?; | |
| 137 | Ok(Packed { files: entries.len(), size }) | |
| 138 | } | |
| 139 | ||
| 140 | #[cfg(unix)] | |
| 141 | fn mode(meta: &std::fs::Metadata) -> u32 { | |
| 142 | use std::os::unix::fs::PermissionsExt; | |
| 143 | meta.permissions().mode() | |
| 144 | } | |
| 145 | ||
| 146 | #[cfg(not(unix))] | |
| 147 | fn mode(meta: &std::fs::Metadata) -> u32 { | |
| 148 | if meta.permissions().readonly() { 0o100_444 } else { 0o100_644 } | |
| 149 | } | |
| 150 | ||
| 151 | /// A time as MS-DOS keeps it, in UTC, from 1980 on. | |
| 152 | fn dos_time(time: SystemTime) -> (u16, u16) { | |
| 153 | let secs = time.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0).max(315_532_800); | |
| 154 | let days = (secs / 86_400) as i64; | |
| 155 | let rest = secs % 86_400; | |
| 156 | // Days since 1970 as a civil date (Howard Hinnant's algorithm). | |
| 157 | let z = days + 719_468; | |
| 158 | let era = z.div_euclid(146_097); | |
| 159 | let doe = z - era * 146_097; | |
| 160 | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; | |
| 161 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); | |
| 162 | let mp = (5 * doy + 2) / 153; | |
| 163 | let day = doy - (153 * mp + 2) / 5 + 1; | |
| 164 | let month = if mp < 10 { mp + 3 } else { mp - 9 }; | |
| 165 | let year = (yoe + era * 400 + i64::from(month <= 2)).min(2107); | |
| 166 | let time = ((rest / 3600) << 11) | (((rest % 3600) / 60) << 5) | ((rest % 60) / 2); | |
| 167 | let date = (((year - 1980) as u64) << 9) | ((month as u64) << 5) | day as u64; | |
| 168 | (time as u16, date as u16) | |
| 169 | } | |
| 170 | ||
| 171 | fn version_needed(zip64: bool) -> u16 { | |
| 172 | if zip64 { 45 } else { 20 } | |
| 173 | } | |
| 174 | ||
| 175 | /// A local file header; with `large`, its sizes in a ZIP64 extra field. | |
| 176 | pub(crate) fn local_header(entry: &Entry, large: bool) -> Vec<u8> { | |
| 177 | let mut out = Vec::with_capacity(30 + entry.name.len() + 20); | |
| 178 | put32(&mut out, LOCAL); | |
| 179 | put16(&mut out, version_needed(large)); | |
| 180 | put16(&mut out, UTF8); | |
| 181 | put16(&mut out, entry.method); | |
| 182 | put16(&mut out, entry.time); | |
| 183 | put16(&mut out, entry.date); | |
| 184 | put32(&mut out, entry.crc); | |
| 185 | let extra = if large { zip64_extra(Some(entry.size), Some(entry.compressed), None) } else { Vec::new() }; | |
| 186 | put32(&mut out, if large { MAX32 as u32 } else { entry.compressed as u32 }); | |
| 187 | put32(&mut out, if large { MAX32 as u32 } else { entry.size as u32 }); | |
| 188 | put16(&mut out, entry.name.len() as u16); | |
| 189 | put16(&mut out, extra.len() as u16); | |
| 190 | out.extend_from_slice(entry.name.as_bytes()); | |
| 191 | out.extend_from_slice(&extra); | |
| 192 | out | |
| 193 | } | |
| 194 | ||
| 195 | /// A ZIP64 extended information field: the values given, in the | |
| 196 | /// specification's order (size, compressed size, offset). | |
| 197 | pub(crate) fn zip64_extra(size: Option<u64>, compressed: Option<u64>, offset: Option<u64>) -> Vec<u8> { | |
| 198 | let values: Vec<u64> = [size, compressed, offset].into_iter().flatten().collect(); | |
| 199 | let mut out = Vec::with_capacity(4 + values.len() * 8); | |
| 200 | put16(&mut out, 0x0001); | |
| 201 | put16(&mut out, (values.len() * 8) as u16); | |
| 202 | for value in values { | |
| 203 | put64(&mut out, value); | |
| 204 | } | |
| 205 | out | |
| 206 | } | |
| 207 | ||
| 208 | /// An entry's header in the central directory, with a ZIP64 extra field | |
| 209 | /// for whichever of its sizes and offset do not fit 32 bits. | |
| 210 | pub(crate) fn central_header(entry: &Entry) -> Vec<u8> { | |
| 211 | let big = |v: u64| v >= MAX32; | |
| 212 | let extra = if big(entry.size) || big(entry.compressed) || big(entry.offset) { | |
| 213 | zip64_extra(big(entry.size).then_some(entry.size), big(entry.compressed).then_some(entry.compressed), big(entry.offset).then_some(entry.offset)) | |
| 214 | } else { | |
| 215 | Vec::new() | |
| 216 | }; | |
| 217 | let clip = |v: u64| if big(v) { MAX32 as u32 } else { v as u32 }; | |
| 218 | let mut out = Vec::with_capacity(46 + entry.name.len() + extra.len()); | |
| 219 | put32(&mut out, CENTRAL); | |
| 220 | put16(&mut out, MADE_BY); | |
| 221 | put16(&mut out, version_needed(!extra.is_empty())); | |
| 222 | put16(&mut out, UTF8); | |
| 223 | put16(&mut out, entry.method); | |
| 224 | put16(&mut out, entry.time); | |
| 225 | put16(&mut out, entry.date); | |
| 226 | put32(&mut out, entry.crc); | |
| 227 | put32(&mut out, clip(entry.compressed)); | |
| 228 | put32(&mut out, clip(entry.size)); | |
| 229 | put16(&mut out, entry.name.len() as u16); | |
| 230 | put16(&mut out, extra.len() as u16); | |
| 231 | put16(&mut out, 0); // comment | |
| 232 | put16(&mut out, 0); // disk | |
| 233 | put16(&mut out, 0); // internal attributes | |
| 234 | put32(&mut out, entry.mode.unwrap_or(0) << 16); | |
| 235 | put32(&mut out, clip(entry.offset)); | |
| 236 | out.extend_from_slice(entry.name.as_bytes()); | |
| 237 | out.extend_from_slice(&extra); | |
| 238 | out | |
| 239 | } | |
| 240 | ||
| 241 | /// The end of central directory record, after a ZIP64 end record and | |
| 242 | /// its locator when the count, size or offset needs them. | |
| 243 | pub(crate) fn end_records(count: u64, size: u64, offset: u64) -> Vec<u8> { | |
| 244 | let mut out = Vec::new(); | |
| 245 | let zip64 = count >= 0xFFFF || size >= MAX32 || offset >= MAX32; | |
| 246 | if zip64 { | |
| 247 | let at = offset + size; | |
| 248 | put32(&mut out, END64); | |
| 249 | put64(&mut out, 44); | |
| 250 | put16(&mut out, MADE_BY); | |
| 251 | put16(&mut out, 45); | |
| 252 | put32(&mut out, 0); | |
| 253 | put32(&mut out, 0); | |
| 254 | put64(&mut out, count); | |
| 255 | put64(&mut out, count); | |
| 256 | put64(&mut out, size); | |
| 257 | put64(&mut out, offset); | |
| 258 | put32(&mut out, LOCATOR64); | |
| 259 | put32(&mut out, 0); | |
| 260 | put64(&mut out, at); | |
| 261 | put32(&mut out, 1); | |
| 262 | } | |
| 263 | put32(&mut out, END); | |
| 264 | put16(&mut out, 0); | |
| 265 | put16(&mut out, 0); | |
| 266 | let count16 = if zip64 { 0xFFFF } else { count as u16 }; | |
| 267 | put16(&mut out, count16); | |
| 268 | put16(&mut out, count16); | |
| 269 | put32(&mut out, if zip64 { MAX32 as u32 } else { size as u32 }); | |
| 270 | put32(&mut out, if zip64 { MAX32 as u32 } else { offset as u32 }); | |
| 271 | put16(&mut out, 0); | |
| 272 | out | |
| 273 | } | |
| 274 | ||
| 275 | fn put16(out: &mut Vec<u8>, v: u16) { | |
| 276 | out.extend_from_slice(&v.to_le_bytes()); | |
| 277 | } | |
| 278 | fn put32(out: &mut Vec<u8>, v: u32) { | |
| 279 | out.extend_from_slice(&v.to_le_bytes()); | |
| 280 | } | |
| 281 | fn put64(out: &mut Vec<u8>, v: u64) { | |
| 282 | out.extend_from_slice(&v.to_le_bytes()); | |
| 283 | } | |
| 284 | ||
| 285 | fn get16(b: &[u8], at: usize) -> u16 { | |
| 286 | u16::from_le_bytes([b[at], b[at + 1]]) | |
| 287 | } | |
| 288 | fn get32(b: &[u8], at: usize) -> u32 { | |
| 289 | u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) | |
| 290 | } | |
| 291 | fn get64(b: &[u8], at: usize) -> u64 { | |
| 292 | u64::from_le_bytes(b[at..at + 8].try_into().unwrap()) | |
| 293 | } | |
| 294 | ||
| 295 | fn bad(message: impl Into<String>) -> io::Error { | |
| 296 | io::Error::new(io::ErrorKind::InvalidData, message.into()) | |
| 297 | } | |
| 298 | ||
| 299 | /// Where the central directory is, from the end of a ZIP file whose last | |
| 300 | /// bytes are `tail`, starting at `tail_at` in the file: its entry count, | |
| 301 | /// size and offset. When the classic record says to, the ZIP64 end record | |
| 302 | /// is read with `read_at`. | |
| 303 | pub(crate) fn find_directory(tail: &[u8], tail_at: u64, mut read_at: impl FnMut(u64, usize) -> io::Result<Vec<u8>>) -> io::Result<(u64, u64, u64)> { | |
| 304 | let end = (0..=tail.len().saturating_sub(22)).rev().find(|&i| get32(tail, i) == END).ok_or_else(|| bad("it is not a ZIP file"))?; | |
| 305 | let mut count = u64::from(get16(tail, end + 10)); | |
| 306 | let mut size = u64::from(get32(tail, end + 12)); | |
| 307 | let mut offset = u64::from(get32(tail, end + 16)); | |
| 308 | if count == 0xFFFF || size == MAX32 || offset == MAX32 { | |
| 309 | if end < 20 || get32(tail, end - 20) != LOCATOR64 { | |
| 310 | return Err(bad("its ZIP64 end record is missing")); | |
| 311 | } | |
| 312 | let at = get64(tail, end - 20 + 8); | |
| 313 | let record = if at >= tail_at && (at - tail_at) as usize + 56 <= tail.len() { | |
| 314 | tail[(at - tail_at) as usize..(at - tail_at) as usize + 56].to_vec() | |
| 315 | } else { | |
| 316 | read_at(at, 56)? | |
| 317 | }; | |
| 318 | if get32(&record, 0) != END64 { | |
| 319 | return Err(bad("its ZIP64 end record is damaged")); | |
| 320 | } | |
| 321 | count = get64(&record, 32); | |
| 322 | size = get64(&record, 40); | |
| 323 | offset = get64(&record, 48); | |
| 324 | } | |
| 325 | Ok((count, size, offset)) | |
| 326 | } | |
| 327 | ||
| 328 | /// The entries of a central directory, sizes and offsets from ZIP64 extra | |
| 329 | /// fields where the classic ones say to look there. | |
| 330 | pub(crate) fn parse_directory(directory: &[u8], count: u64) -> io::Result<Vec<Entry>> { | |
| 331 | let mut entries = Vec::new(); | |
| 332 | let mut at = 0; | |
| 333 | for _ in 0..count { | |
| 334 | if at + 46 > directory.len() || get32(directory, at) != CENTRAL { | |
| 335 | return Err(bad("its central directory is damaged")); | |
| 336 | } | |
| 337 | let made_by = get16(directory, at + 4); | |
| 338 | let flags = get16(directory, at + 8); | |
| 339 | if flags & 1 != 0 { | |
| 340 | return Err(bad("it is encrypted")); | |
| 341 | } | |
| 342 | let name_len = get16(directory, at + 28) as usize; | |
| 343 | let extra_len = get16(directory, at + 30) as usize; | |
| 344 | let comment_len = get16(directory, at + 32) as usize; | |
| 345 | let next = at + 46 + name_len + extra_len + comment_len; | |
| 346 | if next > directory.len() { | |
| 347 | return Err(bad("its central directory is damaged")); | |
| 348 | } | |
| 349 | let name_bytes = &directory[at + 46..at + 46 + name_len]; | |
| 350 | let name = String::from_utf8(name_bytes.to_vec()).unwrap_or_else(|_| name_bytes.iter().map(|&b| b as char).collect()); | |
| 351 | let mut entry = Entry { | |
| 352 | name, | |
| 353 | method: get16(directory, at + 10), | |
| 354 | time: get16(directory, at + 12), | |
| 355 | date: get16(directory, at + 14), | |
| 356 | crc: get32(directory, at + 16), | |
| 357 | compressed: u64::from(get32(directory, at + 20)), | |
| 358 | size: u64::from(get32(directory, at + 24)), | |
| 359 | offset: u64::from(get32(directory, at + 42)), | |
| 360 | mode: None, | |
| 361 | }; | |
| 362 | let attributes = get32(directory, at + 38) >> 16; | |
| 363 | if made_by >> 8 == 3 && attributes != 0 { | |
| 364 | entry.mode = Some(attributes); | |
| 365 | } | |
| 366 | let mut extra = &directory[at + 46 + name_len..at + 46 + name_len + extra_len]; | |
| 367 | while extra.len() >= 4 { | |
| 368 | let id = get16(extra, 0); | |
| 369 | let len = (get16(extra, 2) as usize).min(extra.len() - 4); | |
| 370 | if id == 0x0001 { | |
| 371 | let field = &extra[4..4 + len]; | |
| 372 | let mut i = 0; | |
| 373 | for value in [&mut entry.size, &mut entry.compressed, &mut entry.offset] { | |
| 374 | if *value == MAX32 && i + 8 <= field.len() { | |
| 375 | *value = get64(field, i); | |
| 376 | i += 8; | |
| 377 | } | |
| 378 | } | |
| 379 | } | |
| 380 | extra = &extra[4 + len..]; | |
| 381 | } | |
| 382 | entries.push(entry); | |
| 383 | at = next; | |
| 384 | } | |
| 385 | Ok(entries) | |
| 386 | } | |
| 387 | ||
| 388 | /// Where an entry may be unpacked under `into`: never an absolute path, | |
| 389 | /// a drive, or a `..` part. `None` refuses it. | |
| 390 | pub(crate) fn safe_path(into: &Path, name: &str) -> Option<PathBuf> { | |
| 391 | let name = name.replace('\\', "/"); | |
| 392 | if name.starts_with('/') || name.as_bytes().get(1) == Some(&b':') { | |
| 393 | return None; | |
| 394 | } | |
| 395 | let mut out = into.to_path_buf(); | |
| 396 | for part in name.split('/') { | |
| 397 | match part { | |
| 398 | "" | "." => {} | |
| 399 | ".." => return None, | |
| 400 | _ => { | |
| 401 | let path = Path::new(part); | |
| 402 | if path.components().any(|c| !matches!(c, Component::Normal(_))) { | |
| 403 | return None; | |
| 404 | } | |
| 405 | out.push(part); | |
| 406 | } | |
| 407 | } | |
| 408 | } | |
| 409 | Some(out) | |
| 410 | } | |
| 411 | ||
| 412 | /// Checks the CRC of what is written through it. | |
| 413 | struct Checked<W: Write> { | |
| 414 | inner: W, | |
| 415 | crc: crc32fast::Hasher, | |
| 416 | } | |
| 417 | ||
| 418 | impl<W: Write> Write for Checked<W> { | |
| 419 | fn write(&mut self, buf: &[u8]) -> io::Result<usize> { | |
| 420 | let n = self.inner.write(buf)?; | |
| 421 | self.crc.update(&buf[..n]); | |
| 422 | Ok(n) | |
| 423 | } | |
| 424 | fn flush(&mut self) -> io::Result<()> { | |
| 425 | self.inner.flush() | |
| 426 | } | |
| 427 | } | |
| 428 | ||
| 429 | /// Unpacks the ZIP file `zip` into the folder `into`; how many files it | |
| 430 | /// held. Refuses, before writing anything, a ZIP with an entry that would | |
| 431 | /// land outside `into`. | |
| 432 | pub(crate) fn extract(zip: &Path, into: &Path) -> io::Result<usize> { | |
| 433 | let mut file = File::open(zip)?; | |
| 434 | let len = file.metadata()?.len(); | |
| 435 | let tail_len = len.min(65_535 + 22 + 20 + 56); | |
| 436 | let tail_at = len - tail_len; | |
| 437 | let mut tail = vec![0u8; tail_len as usize]; | |
| 438 | file.seek(SeekFrom::Start(tail_at))?; | |
| 439 | file.read_exact(&mut tail)?; | |
| 440 | let (count, size, offset) = find_directory(&tail, tail_at, |at, n| { | |
| 441 | let mut buf = vec![0u8; n]; | |
| 442 | file.seek(SeekFrom::Start(at))?; | |
| 443 | file.read_exact(&mut buf)?; | |
| 444 | Ok(buf) | |
| 445 | })?; | |
| 446 | if offset.checked_add(size).is_none_or(|end| end > len) { | |
| 447 | return Err(bad("its central directory is past its end")); | |
| 448 | } | |
| 449 | let mut directory = vec![0u8; size as usize]; | |
| 450 | file.seek(SeekFrom::Start(offset))?; | |
| 451 | file.read_exact(&mut directory)?; | |
| 452 | let entries = parse_directory(&directory, count)?; | |
| 453 | let mut targets = Vec::with_capacity(entries.len()); | |
| 454 | for entry in &entries { | |
| 455 | let target = safe_path(into, &entry.name).ok_or_else(|| bad(format!("it has an entry outside its folder: {}", entry.name)))?; | |
| 456 | targets.push(target); | |
| 457 | } | |
| 458 | std::fs::create_dir_all(into)?; | |
| 459 | let mut files = 0; | |
| 460 | for (entry, target) in entries.iter().zip(targets) { | |
| 461 | if entry.name.ends_with('/') || entry.name.ends_with('\\') { | |
| 462 | std::fs::create_dir_all(&target)?; | |
| 463 | continue; | |
| 464 | } | |
| 465 | if let Some(parent) = target.parent() { | |
| 466 | std::fs::create_dir_all(parent)?; | |
| 467 | } | |
| 468 | let mut header = [0u8; 30]; | |
| 469 | file.seek(SeekFrom::Start(entry.offset))?; | |
| 470 | file.read_exact(&mut header)?; | |
| 471 | if get32(&header, 0) != LOCAL { | |
| 472 | return Err(bad(format!("{} has no local header", entry.name))); | |
| 473 | } | |
| 474 | let data = entry.offset + 30 + u64::from(get16(&header, 26)) + u64::from(get16(&header, 28)); | |
| 475 | file.seek(SeekFrom::Start(data))?; | |
| 476 | let mut raw = BufReader::new((&mut file).take(entry.compressed)); | |
| 477 | let _ = std::fs::remove_file(&target); | |
| 478 | let mut out = Checked { inner: BufWriter::new(File::create(&target)?), crc: crc32fast::Hasher::new() }; | |
| 479 | let written = match entry.method { | |
| 480 | 0 => io::copy(&mut raw, &mut out)?, | |
| 481 | 8 => io::copy(&mut DeflateDecoder::new(raw), &mut out)?, | |
| 482 | other => return Err(bad(format!("{} is compressed with method {other}, which g1t does not read", entry.name))), | |
| 483 | }; | |
| 484 | out.inner.flush()?; | |
| 485 | if written != entry.size || out.crc.finalize() != entry.crc { | |
| 486 | return Err(bad(format!("{} is damaged: its size or CRC does not check", entry.name))); | |
| 487 | } | |
| 488 | #[cfg(unix)] | |
| 489 | if let Some(mode) = entry.mode { | |
| 490 | use std::os::unix::fs::PermissionsExt; | |
| 491 | let _ = std::fs::set_permissions(&target, std::fs::Permissions::from_mode(mode & 0o7777)); | |
| 492 | } | |
| 493 | files += 1; | |
| 494 | } | |
| 495 | Ok(files) | |
| 496 | } | |
| 497 | ||
| 498 | #[cfg(test)] | |
| 499 | mod tests { | |
| 500 | use super::*; | |
| 501 | use std::process::Command; | |
| 502 | ||
| 503 | fn scratch(label: &str) -> PathBuf { | |
| 504 | let dir = std::env::temp_dir().join(format!("g1t-zip-{label}-{}-{}", std::process::id(), super::super::rand_id())); | |
| 505 | let _ = std::fs::remove_dir_all(&dir); | |
| 506 | std::fs::create_dir_all(&dir).unwrap(); | |
| 507 | dir | |
| 508 | } | |
| 509 | ||
| 510 | fn sample(dir: &Path) -> Vec<(String, PathBuf)> { | |
| 511 | let mut big = Vec::new(); | |
| 512 | for i in 0..100_000u32 { | |
| 513 | big.extend_from_slice(&(i.wrapping_mul(2_654_435_761)).to_le_bytes()[..1 + (i % 3) as usize]); | |
| 514 | } | |
| 515 | let files: Vec<(&str, Vec<u8>)> = vec![ | |
| 516 | ("a.txt", b"hello\n".to_vec()), | |
| 517 | ("nested/deeper/b.txt", b"bee".repeat(1000)), | |
| 518 | ("empty", Vec::new()), | |
| 519 | ("big.bin", big), | |
| 520 | ("ünïcødé/文件.txt", "unicode".as_bytes().to_vec()), | |
| 521 | ]; | |
| 522 | files | |
| 523 | .into_iter() | |
| 524 | .map(|(name, bytes)| { | |
| 525 | let path = dir.join(name); | |
| 526 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); | |
| 527 | std::fs::write(&path, bytes).unwrap(); | |
| 528 | (name.to_owned(), path) | |
| 529 | }) | |
| 530 | .collect() | |
| 531 | } | |
| 532 | ||
| 533 | fn round_trip(level: u32) { | |
| 534 | let dir = scratch(&format!("level{level}")); | |
| 535 | let source = dir.join("src"); | |
| 536 | let files = sample(&source); | |
| 537 | let zip = dir.join("a.zip"); | |
| 538 | let packed = write(&zip, &files, level).unwrap(); | |
| 539 | assert_eq!(packed.files, files.len()); | |
| 540 | assert_eq!(packed.size, std::fs::metadata(&zip).unwrap().len()); | |
| 541 | assert!(files.iter().any(|(_, p)| std::fs::metadata(p).unwrap().len() > 64 * 1024)); | |
| 542 | let out = dir.join("out"); | |
| 543 | assert_eq!(extract(&zip, &out).unwrap(), files.len()); | |
| 544 | for (name, path) in &files { | |
| 545 | assert_eq!(std::fs::read(out.join(name)).unwrap(), std::fs::read(path).unwrap(), "{name}"); | |
| 546 | } | |
| 547 | // Other tools read it too, where they are installed. | |
| 548 | if Command::new("unzip").arg("-v").output().is_ok_and(|o| o.status.success()) { | |
| 549 | let status = Command::new("unzip").arg("-tq").arg(&zip).status().unwrap(); | |
| 550 | assert!(status.success(), "unzip -t failed"); | |
| 551 | } | |
| 552 | let check = "import sys, zipfile; z = zipfile.ZipFile(sys.argv[1]); assert z.testzip() is None; print(len(z.namelist()))"; | |
| 553 | for python in ["python3", "python"] { | |
| 554 | if let Ok(output) = Command::new(python).args(["-c", check]).arg(&zip).output() | |
| 555 | && output.status.success() | |
| 556 | { | |
| 557 | assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), files.len().to_string()); | |
| 558 | break; | |
| 559 | } | |
| 560 | } | |
| 561 | let _ = std::fs::remove_dir_all(&dir); | |
| 562 | } | |
| 563 | ||
| 564 | #[test] | |
| 565 | fn deflated_zips_round_trip() { | |
| 566 | round_trip(9); | |
| 567 | round_trip(6); | |
| 568 | } | |
| 569 | ||
| 570 | #[test] | |
| 571 | fn stored_zips_round_trip() { | |
| 572 | round_trip(0); | |
| 573 | } | |
| 574 | ||
| 575 | #[test] | |
| 576 | fn a_damaged_crc_is_caught() { | |
| 577 | let dir = scratch("crc"); | |
| 578 | let files = sample(&dir.join("src")); | |
| 579 | let zip = dir.join("a.zip"); | |
| 580 | write(&zip, &files[..1], 0).unwrap(); | |
| 581 | let mut bytes = std::fs::read(&zip).unwrap(); | |
| 582 | // The stored content of a.txt starts after its 30-byte header and name. | |
| 583 | bytes[30 + "a.txt".len()] ^= 0xFF; | |
| 584 | std::fs::write(&zip, bytes).unwrap(); | |
| 585 | assert!(extract(&zip, &dir.join("out")).unwrap_err().to_string().contains("CRC")); | |
| 586 | let _ = std::fs::remove_dir_all(&dir); | |
| 587 | } | |
| 588 | ||
| 589 | #[test] | |
| 590 | fn zip64_records_carry_large_values() { | |
| 591 | let entry = Entry { | |
| 592 | name: "huge.bin".into(), | |
| 593 | method: 8, | |
| 594 | crc: 0xDEAD_BEEF, | |
| 595 | compressed: 5 << 30, | |
| 596 | size: 6 << 30, | |
| 597 | offset: 7 << 30, | |
| 598 | time: 1, | |
| 599 | date: 2, | |
| 600 | mode: Some(0o100_644), | |
| 601 | }; | |
| 602 | let header = central_header(&entry); | |
| 603 | assert_eq!(get32(&header, 20), u32::MAX); | |
| 604 | assert_eq!(get16(&header, 30), 4 + 24); | |
| 605 | assert_eq!(parse_directory(&header, 1).unwrap(), vec![entry.clone()]); | |
| 606 | // Only the offset too large: only it goes in the extra field. | |
| 607 | let small = Entry { compressed: 10, size: 20, ..entry.clone() }; | |
| 608 | let header = central_header(&small); | |
| 609 | assert_eq!(get16(&header, 30), 4 + 8); | |
| 610 | assert_eq!(parse_directory(&header, 1).unwrap(), vec![small]); | |
| 611 | let local = local_header(&entry, true); | |
| 612 | assert_eq!(get32(&local, 18), u32::MAX); | |
| 613 | assert_eq!(get64(&local, 30 + 8 + 4), 6 << 30); | |
| 614 | assert_eq!(get64(&local, 30 + 8 + 12), 5 << 30); | |
| 615 | ||
| 616 | // The end records, as if the directory sat past 4 GB with 70,000 entries. | |
| 617 | let (count, size, offset) = (70_000u64, 9 << 20, 5u64 << 30); | |
| 618 | let tail = end_records(count, size, offset); | |
| 619 | assert_eq!(tail.len(), 56 + 20 + 22); | |
| 620 | let found = find_directory(&tail, offset + size, |_, _| panic!("the record is in the tail")).unwrap(); | |
| 621 | assert_eq!(found, (count, size, offset)); | |
| 622 | let plain = end_records(3, 100, 2000); | |
| 623 | assert_eq!(plain.len(), 22); | |
| 624 | assert_eq!(find_directory(&plain, 2100, |_, _| unreachable!()).unwrap(), (3, 100, 2000)); | |
| 625 | } | |
| 626 | ||
| 627 | #[test] | |
| 628 | fn dos_times_count_from_1980() { | |
| 629 | // 2024-02-29 13:45:30 UTC. | |
| 630 | let (time, date) = dos_time(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_709_214_330)); | |
| 631 | assert_eq!(date, ((2024 - 1980) << 9) | (2 << 5) | 29); | |
| 632 | assert_eq!(time, (13 << 11) | (45 << 5) | 15); | |
| 633 | assert_eq!(dos_time(SystemTime::UNIX_EPOCH).1, (1 << 5) | 1); | |
| 634 | } | |
| 635 | ||
| 636 | #[test] | |
| 637 | fn entries_outside_the_folder_are_refused() { | |
| 638 | let into = Path::new("/w/out"); | |
| 639 | assert!(safe_path(into, "../x").is_none()); | |
| 640 | assert!(safe_path(into, "a/../../x").is_none()); | |
| 641 | assert!(safe_path(into, "/etc/passwd").is_none()); | |
| 642 | assert!(safe_path(into, "\\abs").is_none()); | |
| 643 | assert!(safe_path(into, "C:/x").is_none()); | |
| 644 | assert!(safe_path(into, "a\\..\\..\\x").is_none()); | |
| 645 | assert_eq!(safe_path(into, "./a/b.txt").unwrap(), into.join("a").join("b.txt")); | |
| 646 | ||
| 647 | // A whole ZIP holding such an entry writes nothing. | |
| 648 | let dir = scratch("evil"); | |
| 649 | let zip = dir.join("evil.zip"); | |
| 650 | let file = dir.join("x"); | |
| 651 | std::fs::write(&file, "x").unwrap(); | |
| 652 | for name in ["../x", "/abs/x"] { | |
| 653 | write(&zip, &[("ok.txt".into(), file.clone()), (name.into(), file.clone())], 6).unwrap(); | |
| 654 | let error = extract(&zip, &dir.join("out")).unwrap_err().to_string(); | |
| 655 | assert!(error.contains("outside"), "{error}"); | |
| 656 | assert!(!dir.join("out").join("ok.txt").exists()); | |
| 657 | } | |
| 658 | let _ = std::fs::remove_dir_all(&dir); | |
| 659 | } | |
| 660 | } |