Skip to content

g1t/crates/runner/src/actions/zip.rs

660 lines26,074 bytesCodeBlameRaw
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
11use std::fs::File;
12use std::io::{self, BufReader, BufWriter, Read, Seek, SeekFrom, Write};
13use std::path::{Component, Path, PathBuf};
14use std::time::SystemTime;
15
16use flate2::Compression;
17use flate2::read::DeflateDecoder;
18use flate2::write::DeflateEncoder;
19
20const LOCAL: u32 = 0x0403_4b50;
21const CENTRAL: u32 = 0x0201_4b50;
22const END: u32 = 0x0605_4b50;
23const END64: u32 = 0x0606_4b50;
24const LOCATOR64: u32 = 0x0706_4b50;
25/// Names are UTF-8 (general purpose flag bit 11).
26const UTF8: u16 = 1 << 11;
27/// Made by unix (3), to version 4.5 of the specification.
28const 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.
31const LOCAL64_FROM: u64 = 0xF000_0000;
32const MAX32: u64 = 0xFFFF_FFFF;
33
34/// What packing wrote.
35#[derive(Debug)]
36pub(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)]
44pub(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.
58struct Counted<'a, W: Write> {
59 inner: &'a mut W,
60 count: u64,
61}
62
63impl<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).
76pub(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(&central_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)]
141fn mode(meta: &std::fs::Metadata) -> u32 {
142 use std::os::unix::fs::PermissionsExt;
143 meta.permissions().mode()
144}
145
146#[cfg(not(unix))]
147fn 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.
152fn 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
171fn 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.
176pub(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).
197pub(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.
210pub(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.
243pub(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
275fn put16(out: &mut Vec<u8>, v: u16) {
276 out.extend_from_slice(&v.to_le_bytes());
277}
278fn put32(out: &mut Vec<u8>, v: u32) {
279 out.extend_from_slice(&v.to_le_bytes());
280}
281fn put64(out: &mut Vec<u8>, v: u64) {
282 out.extend_from_slice(&v.to_le_bytes());
283}
284
285fn get16(b: &[u8], at: usize) -> u16 {
286 u16::from_le_bytes([b[at], b[at + 1]])
287}
288fn get32(b: &[u8], at: usize) -> u32 {
289 u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
290}
291fn get64(b: &[u8], at: usize) -> u64 {
292 u64::from_le_bytes(b[at..at + 8].try_into().unwrap())
293}
294
295fn 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`.
303pub(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.
330pub(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.
390pub(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.
413struct Checked<W: Write> {
414 inner: W,
415 crc: crc32fast::Hasher,
416}
417
418impl<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`.
432pub(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)]
499mod 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}