1use std::collections::{HashMap, HashSet};
13use std::io::Read as _;
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
125fn assert_no_duplicate_names(path: &Path) -> Result<()> {
134 const SIGNATURE: [u8; 4] = [b'P', b'K', 1, 2];
135 const HEADER_LENGTH: usize = 46;
136 let bytes = std::fs::read(path)
137 .map_err(|error| Error::new(format!("cannot read archive {}: {error}", path.display())))?;
138 let mut seen: HashSet<&[u8]> = HashSet::new();
139 let mut cursor = 0usize;
140 while cursor + HEADER_LENGTH <= bytes.len() {
141 if bytes[cursor..cursor + 4] != SIGNATURE {
142 cursor += 1;
143 continue;
144 }
145 let name_length = u16::from_le_bytes([bytes[cursor + 28], bytes[cursor + 29]]) as usize;
146 let start = cursor + HEADER_LENGTH;
147 let Some(name) = bytes.get(start..start + name_length) else {
148 break;
149 };
150 if !seen.insert(name) {
151 let name = String::from_utf8_lossy(name);
152 fail!("Archive entry collides with another entry: {name}");
153 }
154 cursor = start + name_length;
155 }
156 Ok(())
157}
158
159fn open(path: &Path) -> Result<zip::ZipArchive<std::fs::File>> {
160 let file = std::fs::File::open(path)
161 .map_err(|error| Error::new(format!("cannot read archive {}: {error}", path.display())))?;
162 zip::ZipArchive::new(file)
163 .map_err(|error| Error::new(format!("cannot read archive {}: {error}", path.display())))
164}
165
166pub fn list_zip_entries(path: &Path) -> Result<Vec<ArchiveEntry>> {
173 assert_no_duplicate_names(path)?;
174 let mut archive = open(path)?;
175 let mut entries: Vec<ArchiveEntry> = Vec::with_capacity(archive.len());
176 for index in 0..archive.len() {
177 let (name, encrypted, mode, size) = {
180 let entry = archive
181 .by_index_raw(index)
182 .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
183 (
184 entry.name().to_string(),
185 entry.encrypted(),
186 entry.unix_mode(),
187 entry.size(),
188 )
189 };
190 let mut classified = classify(&name, encrypted, mode, size)?;
191 if classified.kind == EntryKind::Link {
192 let mut target = String::new();
193 archive
194 .by_index(index)
195 .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?
196 .take(MAX_LINK_TARGET_BYTES + 1)
197 .read_to_string(&mut target)
198 .map_err(|error| {
199 Error::new(format!("cannot read archive link {}: {error}", classified.path))
200 })?;
201 if target.len() as u64 > MAX_LINK_TARGET_BYTES {
202 fail!("Archive link target is too long: {}", classified.path);
203 }
204 classified.link_target = Some(target);
205 }
206 entries.push(classified);
207 }
208
209 assert_no_collisions(&entries)?;
210 let payload: Vec<PayloadEntry> = entries.iter().map(ArchiveEntry::as_payload_entry).collect();
211 if let Some(path) = find_unresolvable_link(&payload) {
212 fail!("Archive link does not resolve to a file inside the payload: {path}");
213 }
214 if let Some(path) = find_entry_through_link(&payload) {
215 fail!("Archive entry would be written through a link: {path}");
216 }
217 Ok(entries)
218}
219
220pub fn read_zip_entry(path: &Path, wanted: &str, maximum_bytes: u64) -> Result<Vec<u8>> {
226 let safe = safe_relative_path(wanted)?;
227 let mut archive = open(path)?;
228 for index in 0..archive.len() {
229 let (name, encrypted, mode, size) = {
230 let entry = archive
231 .by_index_raw(index)
232 .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
233 (
234 entry.name().to_string(),
235 entry.encrypted(),
236 entry.unix_mode(),
237 entry.size(),
238 )
239 };
240 let classified = classify(&name, encrypted, mode, size)?;
241 if classified.path != safe || classified.kind != EntryKind::File {
242 continue;
243 }
244 if classified.size > maximum_bytes {
245 fail!("ZIP entry is too large to read as metadata: {safe}");
246 }
247 let mut bytes = Vec::new();
248 archive
249 .by_index(index)
250 .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?
251 .take(maximum_bytes + 1)
252 .read_to_end(&mut bytes)
253 .map_err(|error| Error::new(format!("cannot read {safe}: {error}")))?;
254 if bytes.len() as u64 > maximum_bytes {
255 fail!("ZIP entry is too large to read as metadata: {safe}");
256 }
257 return Ok(bytes);
258 }
259 fail!("ZIP archive does not contain {safe}")
260}
261
262pub fn read_zip_entry_text(path: &Path, wanted: &str) -> Result<String> {
268 let bytes = read_zip_entry(path, wanted, MAX_METADATA_BYTES)?;
269 String::from_utf8(bytes).map_err(|_| Error::new(format!("Invalid UTF-8 in {wanted}.")))
270}
271
272pub fn extract_zip_archive(archive_path: &Path, destination: &Path) -> Result<()> {
278 let validated = list_zip_entries(archive_path)?;
282 let link_targets: HashMap<&str, &str> = validated
283 .iter()
284 .filter(|entry| entry.kind == EntryKind::Link)
285 .filter_map(|entry| Some((entry.path.as_str(), entry.link_target.as_deref()?)))
286 .collect();
287
288 std::fs::create_dir_all(destination)?;
289 let mut archive = open(archive_path)?;
290 for (index, entry) in validated.iter().enumerate() {
291 let output = join_relative(destination, &entry.path);
292 match entry.kind {
293 EntryKind::Directory => {
294 std::fs::create_dir_all(&output)?;
295 continue;
296 }
297 EntryKind::Link => {
298 if let Some(parent) = output.parent() {
299 std::fs::create_dir_all(parent)?;
300 }
301 let target = link_targets.get(entry.path.as_str()).copied().unwrap_or("");
304 create_symlink(target, &output)?;
305 continue;
306 }
307 EntryKind::File => {}
308 }
309 if let Some(parent) = output.parent() {
310 std::fs::create_dir_all(parent)?;
311 }
312 let mut file = new_file(&output, entry.mode)?;
315 let mut source = archive
316 .by_index(index)
317 .map_err(|error| Error::new(format!("cannot read archive entry: {error}")))?;
318 std::io::copy(&mut source, &mut file)
319 .map_err(|error| Error::new(format!("cannot write {}: {error}", output.display())))?;
320 }
321
322 validate_extracted_tree(destination, true)
324}
325
326#[cfg(unix)]
327fn new_file(path: &Path, mode: u32) -> Result<std::fs::File> {
328 use std::os::unix::fs::OpenOptionsExt as _;
329 std::fs::OpenOptions::new()
330 .write(true)
331 .create_new(true)
332 .mode(if mode == 0 { 0o644 } else { mode })
333 .open(path)
334 .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
335}
336
337#[cfg(not(unix))]
338fn new_file(path: &Path, _mode: u32) -> Result<std::fs::File> {
339 std::fs::OpenOptions::new()
341 .write(true)
342 .create_new(true)
343 .open(path)
344 .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
345}
346
347#[cfg(unix)]
348fn create_symlink(target: &str, path: &Path) -> Result<()> {
349 std::os::unix::fs::symlink(target, path)
350 .map_err(|error| Error::new(format!("cannot write link {}: {error}", path.display())))
351}
352
353#[cfg(not(unix))]
354fn create_symlink(target: &str, path: &Path) -> Result<()> {
355 std::os::windows::fs::symlink_file(target, path)
358 .map_err(|error| Error::new(format!("cannot write link {}: {error}", path.display())))
359}
360
361#[cfg(test)]
362mod tests {
363 use super::{assert_no_collisions, classify, ArchiveEntry};
364 use crate::contract::links::EntryKind;
365
366 fn entry(path: &str, kind: EntryKind) -> ArchiveEntry {
367 ArchiveEntry {
368 path: path.to_string(),
369 kind,
370 size: 0,
371 mode: 0o644,
372 link_target: None,
373 }
374 }
375
376 #[test]
382 fn an_encrypted_entry_is_refused_before_anything_else() {
383 let error = classify("box.json", true, Some(0o100_644), 10).unwrap_err();
384 assert!(error.message().contains("Encrypted ZIP entries"), "{error}");
385 }
386
387 #[test]
388 fn special_entries_are_refused_by_their_type_bits() {
389 for (name, mode) in [
390 ("fifo", 0o010_000),
391 ("device", 0o020_000),
392 ("block", 0o060_000),
393 ("socket", 0o140_000),
394 ] {
395 let error = classify(name, false, Some(mode | 0o644), 0).unwrap_err();
396 assert!(
397 error.message().contains("special entries"),
398 "{name} was accepted: {error}"
399 );
400 }
401 }
402
403 #[test]
404 fn regular_files_and_directories_are_classified_as_the_format_expects() {
405 let file = classify("box.json", false, Some(0o100_644), 12).unwrap();
406 assert_eq!(file.kind, EntryKind::File);
407 assert_eq!(file.mode, 0o644);
408
409 assert_eq!(
411 classify("venv/", false, None, 0).unwrap().kind,
412 EntryKind::Directory
413 );
414 assert_eq!(
415 classify("venv", false, Some(0o040_755), 0).unwrap().kind,
416 EntryKind::Directory
417 );
418
419 assert_eq!(
421 classify("plain.txt", false, None, 3).unwrap().kind,
422 EntryKind::File
423 );
424 }
425
426 #[test]
427 fn a_link_is_classified_but_its_target_is_not_yet_known() {
428 let link = classify("venv/bin/python", false, Some(0o120_777), 9).unwrap();
429 assert_eq!(link.kind, EntryKind::Link);
430 assert!(link.link_target.is_none());
431 }
432
433 #[test]
434 fn an_oversized_link_target_is_refused_before_it_is_read() {
435 let error = classify("venv/bin/python", false, Some(0o120_777), 4096).unwrap_err();
436 assert!(error.message().contains("link target is too long"), "{error}");
437 }
438
439 #[test]
440 fn an_entry_name_that_escapes_the_root_is_refused() {
441 for name in ["../escape", "/etc/passwd", "C:/windows", "venv/../../out"] {
442 let error = classify(name, false, Some(0o100_644), 1).unwrap_err();
443 assert!(
444 error.message().contains("Unsafe relative path"),
445 "{name} was accepted: {error}"
446 );
447 }
448 }
449
450 #[test]
451 fn colliding_entries_are_refused_in_every_shape() {
452 let duplicate = vec![entry("a.txt", EntryKind::File), entry("a.txt", EntryKind::File)];
454 assert!(assert_no_collisions(&duplicate).is_err());
455
456 let through_file = vec![entry("a", EntryKind::File), entry("a/b", EntryKind::File)];
458 assert!(assert_no_collisions(&through_file).is_err());
459
460 let after_children = vec![entry("a/b", EntryKind::File), entry("a", EntryKind::File)];
462 assert!(assert_no_collisions(&after_children).is_err());
463
464 let fine = vec![
466 entry("box.json", EntryKind::File),
467 entry("venv", EntryKind::Directory),
468 entry("venv/bin", EntryKind::Directory),
469 entry("venv/bin/python", EntryKind::File),
470 ];
471 assert!(assert_no_collisions(&fine).is_ok());
472 }
473}