1use std::collections::{HashMap, HashSet};
13use std::io::{Read as _, Seek as _, SeekFrom};
14use std::path::Path;
15
16use crate::contract::links::{find_entry_through_link, find_unresolvable_link, EntryKind, PayloadEntry};
17use crate::error::{fail, Error, Result};
18use crate::filesystem::validate_extracted_tree;
19use crate::path::{join_relative, safe_relative_path};
20
21const ZIP_FILE_TYPE_MASK: u32 = 0o170_000;
22const ZIP_REGULAR_FILE: u32 = 0o100_000;
23const ZIP_DIRECTORY: u32 = 0o040_000;
24const ZIP_SYMBOLIC_LINK: u32 = 0o120_000;
25
26const MAX_LINK_TARGET_BYTES: u64 = 1024;
31
32const MAX_METADATA_BYTES: u64 = 1024 * 1024;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ArchiveEntry {
38 pub path: String,
40 pub kind: EntryKind,
42 pub size: u64,
44 pub mode: u32,
46 pub link_target: Option<String>,
48}
49
50impl ArchiveEntry {
51 fn as_payload_entry(&self) -> PayloadEntry {
52 PayloadEntry {
53 path: self.path.clone(),
54 kind: self.kind,
55 link_target: self.link_target.clone(),
56 }
57 }
58}
59
60fn classify(name: &str, encrypted: bool, unix_mode: Option<u32>, size: u64) -> Result<ArchiveEntry> {
62 if encrypted {
63 fail!("Encrypted ZIP entries are not allowed: {name}");
64 }
65 let trimmed = name.strip_suffix('/').unwrap_or(name);
66 let path = safe_relative_path(trimmed)?;
67 let mode = unix_mode.unwrap_or(0);
68 let unix_type = mode & ZIP_FILE_TYPE_MASK;
69
70 if unix_type == ZIP_SYMBOLIC_LINK {
71 if size > MAX_LINK_TARGET_BYTES {
72 fail!("Archive link target is too long: {path}");
73 }
74 return Ok(ArchiveEntry {
77 path,
78 kind: EntryKind::Link,
79 size,
80 mode: 0o777,
81 link_target: None,
82 });
83 }
84
85 let is_directory = name.ends_with('/') || unix_type == ZIP_DIRECTORY;
86 if !is_directory && unix_type != 0 && unix_type != ZIP_REGULAR_FILE {
87 fail!("Archive special entries are not allowed: {path}");
88 }
89 Ok(ArchiveEntry {
90 path,
91 kind: if is_directory {
92 EntryKind::Directory
93 } else {
94 EntryKind::File
95 },
96 size,
97 mode: mode & 0o777,
98 link_target: None,
99 })
100}
101
102fn assert_no_collisions(entries: &[ArchiveEntry]) -> Result<()> {
104 let mut seen: HashMap<&str, EntryKind> = HashMap::new();
105 let mut parents_with_children: HashSet<&str> = HashSet::new();
106 for entry in entries {
107 if seen.contains_key(entry.path.as_str()) {
108 fail!("Archive entry collides with another entry: {}", entry.path);
109 }
110 for (index, _) in entry.path.match_indices('/') {
111 let parent = &entry.path[..index];
112 if seen.get(parent) == Some(&EntryKind::File) {
113 fail!("Archive entry collides with another entry: {}", entry.path);
114 }
115 parents_with_children.insert(parent);
116 }
117 if entry.kind == EntryKind::File && parents_with_children.contains(entry.path.as_str()) {
118 fail!("Archive entry collides with another entry: {}", entry.path);
119 }
120 seen.insert(entry.path.as_str(), entry.kind);
121 }
122 Ok(())
123}
124
125struct CentralDirectory {
127 offset: u64,
128 size: u64,
129 records: u64,
130}
131
132fn archive_read_error(path: &Path, error: impl std::fmt::Display) -> Error {
133 Error::new(format!("cannot read archive {}: {error}", path.display()))
134}
135
136fn u16_at(bytes: &[u8], offset: usize) -> u16 {
137 u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
138}
139
140fn u32_at(bytes: &[u8], offset: usize) -> u32 {
141 u32::from_le_bytes([
142 bytes[offset],
143 bytes[offset + 1],
144 bytes[offset + 2],
145 bytes[offset + 3],
146 ])
147}
148
149fn u64_at(bytes: &[u8], offset: usize) -> u64 {
150 u64::from_le_bytes([
151 bytes[offset],
152 bytes[offset + 1],
153 bytes[offset + 2],
154 bytes[offset + 3],
155 bytes[offset + 4],
156 bytes[offset + 5],
157 bytes[offset + 6],
158 bytes[offset + 7],
159 ])
160}
161
162fn read_exact_at(
163 file: &mut std::fs::File,
164 path: &Path,
165 offset: u64,
166 bytes: &mut [u8],
167) -> Result<()> {
168 file.seek(SeekFrom::Start(offset))
169 .and_then(|_| file.read_exact(bytes))
170 .map_err(|error| archive_read_error(path, error))
171}
172
173fn zip64_central_directory(
175 file: &mut std::fs::File,
176 path: &Path,
177 eocd_offset: u64,
178) -> Result<Option<CentralDirectory>> {
179 const LOCATOR_SIGNATURE: [u8; 4] = [b'P', b'K', 6, 7];
180 const EOCD_SIGNATURE: [u8; 4] = [b'P', b'K', 6, 6];
181 const LOCATOR_LENGTH: u64 = 20;
182 const EOCD_MINIMUM_LENGTH: u64 = 56;
183 const SEARCH_CHUNK: u64 = 64 * 1024;
184
185 let Some(locator_offset) = eocd_offset.checked_sub(LOCATOR_LENGTH) else {
186 return Ok(None);
187 };
188 let mut locator = [0u8; 20];
189 read_exact_at(file, path, locator_offset, &mut locator)?;
190 if locator[..4] != LOCATOR_SIGNATURE
191 || u32_at(&locator, 4) != 0
192 || u32_at(&locator, 16) != 1
193 {
194 return Ok(None);
195 }
196 let relative_eocd_offset = u64_at(&locator, 8);
197 if relative_eocd_offset >= locator_offset {
198 return Ok(None);
199 }
200
201 let mut search_end = locator_offset;
205 while search_end > relative_eocd_offset {
206 let search_start = relative_eocd_offset.max(search_end.saturating_sub(SEARCH_CHUNK));
207 let read_end = locator_offset.min(search_end.saturating_add(3));
208 let length = usize::try_from(read_end - search_start)
209 .map_err(|error| archive_read_error(path, error))?;
210 let mut window = vec![0u8; length];
211 read_exact_at(file, path, search_start, &mut window)?;
212
213 let owned_starts = usize::try_from(search_end - search_start)
214 .map_err(|error| archive_read_error(path, error))?;
215 for index in (0..owned_starts).rev() {
216 if window.get(index..index + 4) != Some(EOCD_SIGNATURE.as_slice()) {
217 continue;
218 }
219 let candidate = search_start + index as u64;
220 if candidate + EOCD_MINIMUM_LENGTH > locator_offset {
221 continue;
222 }
223 let mut header = [0u8; 56];
224 read_exact_at(file, path, candidate, &mut header)?;
225 let Some(record_end) = candidate
226 .checked_add(12)
227 .and_then(|start| start.checked_add(u64_at(&header, 4)))
228 else {
229 continue;
230 };
231 if record_end != locator_offset
232 || u64_at(&header, 4) < 44
233 || u32_at(&header, 16) != 0
234 || u32_at(&header, 20) != 0
235 || u64_at(&header, 24) != u64_at(&header, 32)
236 {
237 continue;
238 }
239
240 let Some(archive_offset) = candidate.checked_sub(relative_eocd_offset) else {
241 continue;
242 };
243 let directory_size = u64_at(&header, 40);
244 let Some(directory_offset) = archive_offset.checked_add(u64_at(&header, 48)) else {
245 continue;
246 };
247 if directory_offset.checked_add(directory_size) != Some(candidate) {
248 continue;
249 }
250 return Ok(Some(CentralDirectory {
251 offset: directory_offset,
252 size: directory_size,
253 records: u64_at(&header, 32),
254 }));
255 }
256 search_end = search_start;
257 }
258 Ok(None)
259}
260
261fn central_directory(file: &mut std::fs::File, path: &Path) -> Result<CentralDirectory> {
263 const EOCD_SIGNATURE: [u8; 4] = [b'P', b'K', 5, 6];
264 const EOCD_LENGTH: usize = 22;
265 const MAX_COMMENT_LENGTH: u64 = u16::MAX as u64;
266
267 let file_length = file
268 .metadata()
269 .map_err(|error| archive_read_error(path, error))?
270 .len();
271 let tail_length = file_length.min(EOCD_LENGTH as u64 + MAX_COMMENT_LENGTH);
272 let tail_offset = file_length - tail_length;
273 let tail_capacity =
274 usize::try_from(tail_length).map_err(|error| archive_read_error(path, error))?;
275 let mut tail = vec![0u8; tail_capacity];
276 read_exact_at(file, path, tail_offset, &mut tail)?;
277
278 if tail.len() >= EOCD_LENGTH {
279 for index in (0..=tail.len() - EOCD_LENGTH).rev() {
280 if tail[index..index + 4] != EOCD_SIGNATURE {
281 continue;
282 }
283 let comment_length = usize::from(u16_at(&tail, index + 20));
284 if index + EOCD_LENGTH + comment_length != tail.len() {
285 continue;
286 }
287 let eocd_offset = tail_offset + index as u64;
288 let may_be_zip64 = u16_at(&tail, index + 8) == u16::MAX
289 || u16_at(&tail, index + 10) == u16::MAX
290 || u32_at(&tail, index + 12) == u32::MAX
291 || u32_at(&tail, index + 16) == u32::MAX;
292 if may_be_zip64 {
293 if let Some(directory) = zip64_central_directory(file, path, eocd_offset)? {
294 return Ok(directory);
295 }
296 }
297
298 let records_on_disk = u16_at(&tail, index + 8);
299 let records = u16_at(&tail, index + 10);
300 if u16_at(&tail, index + 4) != 0
301 || u16_at(&tail, index + 6) != 0
302 || records_on_disk != records
303 {
304 continue;
305 }
306 let directory_size = u64::from(u32_at(&tail, index + 12));
307 let relative_offset = u64::from(u32_at(&tail, index + 16));
308 let Some(relative_end) = relative_offset.checked_add(directory_size) else {
309 continue;
310 };
311 let Some(archive_offset) = eocd_offset.checked_sub(relative_end) else {
312 continue;
313 };
314 return Ok(CentralDirectory {
315 offset: archive_offset + relative_offset,
316 size: directory_size,
317 records: u64::from(records),
318 });
319 }
320 }
321 Err(archive_read_error(path, "invalid ZIP central directory"))
322}
323
324fn assert_no_duplicate_names(path: &Path) -> Result<()> {
335 const SIGNATURE: [u8; 4] = [b'P', b'K', 1, 2];
336 const HEADER_LENGTH: usize = 46;
337
338 let mut file = std::fs::File::open(path)
339 .map_err(|error| archive_read_error(path, error))?;
340 let central = central_directory(&mut file, path)?;
341 if central.records > central.size / HEADER_LENGTH as u64 {
342 return Err(archive_read_error(path, "invalid ZIP central directory"));
343 }
344 file.seek(SeekFrom::Start(central.offset))
345 .map_err(|error| archive_read_error(path, error))?;
346 let mut directory = (&mut file).take(central.size);
347 let mut seen: HashSet<Vec<u8>> = HashSet::new();
348 for _ in 0..central.records {
349 let mut header = [0u8; HEADER_LENGTH];
350 directory
351 .read_exact(&mut header)
352 .map_err(|error| archive_read_error(path, error))?;
353 if header[..4] != SIGNATURE {
354 return Err(archive_read_error(path, "invalid ZIP central directory"));
355 }
356 let name_length = usize::from(u16_at(&header, 28));
357 let extra_length = u64::from(u16_at(&header, 30));
358 let comment_length = u64::from(u16_at(&header, 32));
359 let variable_length = name_length as u64 + extra_length + comment_length;
360 if variable_length > directory.limit() {
361 return Err(archive_read_error(path, "invalid ZIP central directory"));
362 }
363
364 let mut name = vec![0u8; name_length];
365 directory
366 .read_exact(&mut name)
367 .map_err(|error| archive_read_error(path, error))?;
368 if seen.contains(&name) {
369 let name = String::from_utf8_lossy(&name);
370 fail!("Archive entry collides with another entry: {name}");
371 }
372 seen.insert(name);
373
374 let skipped = std::io::copy(
375 &mut directory.by_ref().take(extra_length + comment_length),
376 &mut std::io::sink(),
377 )
378 .map_err(|error| archive_read_error(path, error))?;
379 if skipped != extra_length + comment_length {
380 return Err(archive_read_error(path, "invalid ZIP central directory"));
381 }
382 }
383 Ok(())
384}
385
386fn open(path: &Path) -> Result<zip::ZipArchive<std::fs::File>> {
387 let file = std::fs::File::open(path)
388 .map_err(|error| Error::new(format!("cannot read archive {}: {error}", path.display())))?;
389 zip::ZipArchive::new(file)
390 .map_err(|error| Error::new(format!("cannot read archive {}: {error}", path.display())))
391}
392
393pub fn list_zip_entries(path: &Path) -> Result<Vec<ArchiveEntry>> {
400 assert_no_duplicate_names(path)?;
401 let mut archive = open(path)?;
402 let mut entries: Vec<ArchiveEntry> = Vec::with_capacity(archive.len());
403 for index in 0..archive.len() {
404 let (name, encrypted, mode, size) = {
407 let entry = archive
408 .by_index_raw(index)
409 .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
410 (
411 entry.name().to_string(),
412 entry.encrypted(),
413 entry.unix_mode(),
414 entry.size(),
415 )
416 };
417 let mut classified = classify(&name, encrypted, mode, size)?;
418 if classified.kind == EntryKind::Link {
419 let mut target = String::new();
420 archive
421 .by_index(index)
422 .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?
423 .take(MAX_LINK_TARGET_BYTES + 1)
424 .read_to_string(&mut target)
425 .map_err(|error| {
426 Error::new(format!("cannot read archive link {}: {error}", classified.path))
427 })?;
428 if target.len() as u64 > MAX_LINK_TARGET_BYTES {
429 fail!("Archive link target is too long: {}", classified.path);
430 }
431 classified.link_target = Some(target);
432 }
433 entries.push(classified);
434 }
435
436 assert_no_collisions(&entries)?;
437 let payload: Vec<PayloadEntry> = entries.iter().map(ArchiveEntry::as_payload_entry).collect();
438 if let Some(path) = find_unresolvable_link(&payload) {
439 fail!("Archive link does not resolve to a file inside the payload: {path}");
440 }
441 if let Some(path) = find_entry_through_link(&payload) {
442 fail!("Archive entry would be written through a link: {path}");
443 }
444 Ok(entries)
445}
446
447pub fn read_zip_entry(path: &Path, wanted: &str, maximum_bytes: u64) -> Result<Vec<u8>> {
453 let safe = safe_relative_path(wanted)?;
454 let mut archive = open(path)?;
455 for index in 0..archive.len() {
456 let (name, encrypted, mode, size) = {
457 let entry = archive
458 .by_index_raw(index)
459 .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
460 (
461 entry.name().to_string(),
462 entry.encrypted(),
463 entry.unix_mode(),
464 entry.size(),
465 )
466 };
467 let classified = classify(&name, encrypted, mode, size)?;
468 if classified.path != safe || classified.kind != EntryKind::File {
469 continue;
470 }
471 if classified.size > maximum_bytes {
472 fail!("ZIP entry is too large to read as metadata: {safe}");
473 }
474 let mut bytes = Vec::new();
475 archive
476 .by_index(index)
477 .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?
478 .take(maximum_bytes + 1)
479 .read_to_end(&mut bytes)
480 .map_err(|error| Error::new(format!("cannot read {safe}: {error}")))?;
481 if bytes.len() as u64 > maximum_bytes {
482 fail!("ZIP entry is too large to read as metadata: {safe}");
483 }
484 return Ok(bytes);
485 }
486 fail!("ZIP archive does not contain {safe}")
487}
488
489pub fn read_zip_entry_text(path: &Path, wanted: &str) -> Result<String> {
495 let bytes = read_zip_entry(path, wanted, MAX_METADATA_BYTES)?;
496 String::from_utf8(bytes).map_err(|_| Error::new(format!("Invalid UTF-8 in {wanted}.")))
497}
498
499pub fn extract_zip_archive(archive_path: &Path, destination: &Path) -> Result<()> {
505 let validated = list_zip_entries(archive_path)?;
509 let link_targets: HashMap<&str, &str> = validated
510 .iter()
511 .filter(|entry| entry.kind == EntryKind::Link)
512 .filter_map(|entry| Some((entry.path.as_str(), entry.link_target.as_deref()?)))
513 .collect();
514
515 std::fs::create_dir_all(destination)?;
516 let mut archive = open(archive_path)?;
517 for (index, entry) in validated.iter().enumerate() {
518 let output = join_relative(destination, &entry.path);
519 match entry.kind {
520 EntryKind::Directory => {
521 std::fs::create_dir_all(&output)?;
522 continue;
523 }
524 EntryKind::Link => {
525 if let Some(parent) = output.parent() {
526 std::fs::create_dir_all(parent)?;
527 }
528 let target = link_targets.get(entry.path.as_str()).copied().unwrap_or("");
531 create_symlink(target, &output)?;
532 continue;
533 }
534 EntryKind::File => {}
535 }
536 if let Some(parent) = output.parent() {
537 std::fs::create_dir_all(parent)?;
538 }
539 let mut file = new_file(&output, entry.mode)?;
542 let mut source = archive
543 .by_index(index)
544 .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
545 std::io::copy(&mut source, &mut file)
546 .map_err(|error| Error::new(format!("cannot write {}: {error}", output.display())))?;
547 }
548
549 validate_extracted_tree(destination, true)
551}
552
553#[cfg(unix)]
554fn new_file(path: &Path, mode: u32) -> Result<std::fs::File> {
555 use std::os::unix::fs::OpenOptionsExt as _;
556 std::fs::OpenOptions::new()
557 .write(true)
558 .create_new(true)
559 .mode(if mode == 0 { 0o644 } else { mode })
560 .open(path)
561 .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
562}
563
564#[cfg(not(unix))]
565fn new_file(path: &Path, _mode: u32) -> Result<std::fs::File> {
566 std::fs::OpenOptions::new()
568 .write(true)
569 .create_new(true)
570 .open(path)
571 .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
572}
573
574#[cfg(unix)]
575fn create_symlink(target: &str, path: &Path) -> Result<()> {
576 std::os::unix::fs::symlink(target, path)
577 .map_err(|error| Error::new(format!("cannot write link {}: {error}", path.display())))
578}
579
580#[cfg(not(unix))]
581fn create_symlink(target: &str, path: &Path) -> Result<()> {
582 std::os::windows::fs::symlink_file(target, path)
585 .map_err(|error| Error::new(format!("cannot write link {}: {error}", path.display())))
586}
587
588#[cfg(test)]
589mod tests {
590 use super::{assert_no_collisions, classify, ArchiveEntry};
591 use crate::contract::links::EntryKind;
592
593 fn entry(path: &str, kind: EntryKind) -> ArchiveEntry {
594 ArchiveEntry {
595 path: path.to_string(),
596 kind,
597 size: 0,
598 mode: 0o644,
599 link_target: None,
600 }
601 }
602
603 #[test]
609 fn an_encrypted_entry_is_refused_before_anything_else() {
610 let error = classify("box.json", true, Some(0o100_644), 10).unwrap_err();
611 assert!(error.message().contains("Encrypted ZIP entries"), "{error}");
612 }
613
614 #[test]
615 fn special_entries_are_refused_by_their_type_bits() {
616 for (name, mode) in [
617 ("fifo", 0o010_000),
618 ("device", 0o020_000),
619 ("block", 0o060_000),
620 ("socket", 0o140_000),
621 ] {
622 let error = classify(name, false, Some(mode | 0o644), 0).unwrap_err();
623 assert!(
624 error.message().contains("special entries"),
625 "{name} was accepted: {error}"
626 );
627 }
628 }
629
630 #[test]
631 fn regular_files_and_directories_are_classified_as_the_format_expects() {
632 let file = classify("box.json", false, Some(0o100_644), 12).unwrap();
633 assert_eq!(file.kind, EntryKind::File);
634 assert_eq!(file.mode, 0o644);
635
636 assert_eq!(
638 classify("venv/", false, None, 0).unwrap().kind,
639 EntryKind::Directory
640 );
641 assert_eq!(
642 classify("venv", false, Some(0o040_755), 0).unwrap().kind,
643 EntryKind::Directory
644 );
645
646 assert_eq!(
648 classify("plain.txt", false, None, 3).unwrap().kind,
649 EntryKind::File
650 );
651 }
652
653 #[test]
654 fn a_link_is_classified_but_its_target_is_not_yet_known() {
655 let link = classify("venv/bin/python", false, Some(0o120_777), 9).unwrap();
656 assert_eq!(link.kind, EntryKind::Link);
657 assert!(link.link_target.is_none());
658 }
659
660 #[test]
661 fn an_oversized_link_target_is_refused_before_it_is_read() {
662 let error = classify("venv/bin/python", false, Some(0o120_777), 4096).unwrap_err();
663 assert!(error.message().contains("link target is too long"), "{error}");
664 }
665
666 #[test]
667 fn an_entry_name_that_escapes_the_root_is_refused() {
668 for name in ["../escape", "/etc/passwd", "C:/windows", "venv/../../out"] {
669 let error = classify(name, false, Some(0o100_644), 1).unwrap_err();
670 assert!(
671 error.message().contains("Unsafe relative path"),
672 "{name} was accepted: {error}"
673 );
674 }
675 }
676
677 #[test]
678 fn colliding_entries_are_refused_in_every_shape() {
679 let duplicate = vec![entry("a.txt", EntryKind::File), entry("a.txt", EntryKind::File)];
681 assert!(assert_no_collisions(&duplicate).is_err());
682
683 let through_file = vec![entry("a", EntryKind::File), entry("a/b", EntryKind::File)];
685 assert!(assert_no_collisions(&through_file).is_err());
686
687 let after_children = vec![entry("a/b", EntryKind::File), entry("a", EntryKind::File)];
689 assert!(assert_no_collisions(&after_children).is_err());
690
691 let fine = vec![
693 entry("box.json", EntryKind::File),
694 entry("venv", EntryKind::Directory),
695 entry("venv/bin", EntryKind::Directory),
696 entry("venv/bin/python", EntryKind::File),
697 ];
698 assert!(assert_no_collisions(&fine).is_ok());
699 }
700}