zip/read/extract.rs
1//! Extraction
2
3use crate::result::{ZipResult, invalid};
4use crate::{ZipArchive, result::ZipError};
5use std::ffi::OsStr;
6use std::io::{self, Read, Seek};
7use std::path::Path;
8
9use indexmap::IndexMap;
10
11/// Upper bound on the uncompressed size of a symlink entry.
12///
13/// A symlink target is a filesystem path, so its length is bounded by the
14/// platform (`PATH_MAX` is 4096 on Linux) no matter what the archive says. The
15/// uncompressed size stored in the central directory is attacker-controlled,
16/// so entries above this bound are rejected rather than pre-allocated.
17pub(crate) const MAX_SYMLINK_TARGET_LEN: u64 = 4096;
18
19/// Symlink extract action
20#[derive(Debug, Default, Copy, Clone)]
21pub enum SymlinkExtractAction {
22 /// Omit symlinks.
23 NoExtract,
24 /// Convert symlinks to text file
25 ExtractAsFile,
26 /// Convert symlinks to text file as utf8
27 #[cfg_attr(not(any(unix, windows)), default)]
28 ExtractAsFileUtf8,
29 /// Extract symlinks if the recursive target is in the destination folder (default on Unix and Windows, but not possible on other platforms).
30 #[cfg(any(unix, windows))]
31 #[default]
32 ExtractRecursiveInFolder,
33 /// Extract symlinks if the _direct_ target is in the destination folder (i.e. it can point to a pre-existing symlink that in turn points outside the folder).
34 #[cfg(any(unix, windows))]
35 ExtractInFolder,
36 /// Extract symlinks with no restrictions.
37 #[cfg(any(unix, windows))]
38 ExtractNoRestrictions,
39}
40
41/// Extract options
42#[derive(Debug, Default)]
43#[non_exhaustive]
44pub struct ExtractOptions {
45 pub(crate) symlink_action: SymlinkExtractAction,
46}
47
48impl ExtractOptions {
49 /// Set the symlink action
50 #[must_use]
51 pub fn symlink_action(self, symlink_action: SymlinkExtractAction) -> Self {
52 Self {
53 symlink_action,
54 ..self
55 }
56 }
57}
58
59pub(crate) fn make_writable_dir_all<T: AsRef<Path>>(outpath: T) -> Result<(), ZipError> {
60 use std::fs;
61 fs::create_dir_all(outpath.as_ref())?;
62 #[cfg(unix)]
63 {
64 // Dirs must be writable until all normal files are extracted
65 use std::os::unix::fs::PermissionsExt;
66 std::fs::set_permissions(
67 outpath.as_ref(),
68 std::fs::Permissions::from_mode(
69 0o700 | std::fs::metadata(outpath.as_ref())?.permissions().mode(),
70 ),
71 )?;
72 }
73 Ok(())
74}
75
76#[cfg(unix)]
77pub(crate) fn make_symlink_impl<T>(
78 outpath: &Path,
79 target_str: &str,
80 _existing_files: &IndexMap<Box<[u8]>, T>,
81) -> ZipResult<()> {
82 std::os::unix::fs::symlink(Path::new(&target_str), outpath)?;
83 Ok(())
84}
85
86#[cfg(windows)]
87pub(crate) fn make_symlink_impl<T>(
88 outpath: &Path,
89 target_str: &str,
90 existing_files: &IndexMap<Box<[u8]>, T>,
91) -> ZipResult<()> {
92 use crate::format::functions::is_dir;
93 let target = Path::new(OsStr::new(&target_str));
94 let target_is_dir_from_archive =
95 is_dir(target_str.as_bytes()) && existing_files.contains_key(target_str.as_bytes());
96 let target_is_dir = if target_is_dir_from_archive {
97 true
98 } else if let Ok(meta) = std::fs::metadata(target) {
99 meta.is_dir()
100 } else {
101 false
102 };
103 if target_is_dir {
104 std::os::windows::fs::symlink_dir(target, outpath)?;
105 } else {
106 std::os::windows::fs::symlink_file(target, outpath)?;
107 }
108 Ok(())
109}
110
111#[cfg(any(windows, unix))]
112pub(crate) fn make_symlink<T>(
113 base: &Path, // canonicalized destination directory
114 outpath: &Path,
115 target: &[u8],
116 #[cfg_attr(not(any(windows, unix)), allow(unused))] existing_files: &IndexMap<Box<[u8]>, T>,
117 symlink_action: SymlinkExtractAction,
118) -> ZipResult<()> {
119 let Ok(target_str) = std::str::from_utf8(target) else {
120 return Err(invalid!("Invalid UTF-8 as symlink target"));
121 };
122
123 // Check where the symlink would resolve to before creating it. A relative target
124 // is resolved from the parent of the symlink itself, exactly as the OS would.
125 let target_path = Path::new(target_str);
126 let (start, rest) = if target_path.is_absolute() {
127 let Some(rest) = crate::path::strip_base_prefix(base, target_path) else {
128 return Err(invalid!(
129 "make_symlink: Symlink target escapes the destination directory"
130 ));
131 };
132 (base, rest)
133 } else {
134 // A Windows target such as `C:foo` (relative to the current directory of a
135 // drive) or `\foo` (relative to the root of the current drive) is neither
136 // absolute nor safely relative: the OS resolves it from somewhere other than
137 // the parent of the symlink, so there is nothing meaningful to check here.
138 if target_path.components().any(|component| {
139 matches!(
140 component,
141 std::path::Component::Prefix(_) | std::path::Component::RootDir
142 )
143 }) {
144 return Err(invalid!("Invalid symlink target path"));
145 }
146 let parent = outpath.parent().unwrap_or(base);
147 if !parent.starts_with(base) {
148 return Err(invalid!("Symlink is not inside the destination directory"));
149 }
150 (parent, target_path)
151 };
152
153 // ExtractNoRestrictions is allowed outside (no restrictions) - but it uses
154 // [`make_symlink_impl`] directly
155 // ExtractRecursiveInFolder is allowed outside (it will be checked that the symlink fall in the
156 // folder)
157 #[cfg(any(windows, unix))]
158 let allow_outside = matches!(
159 symlink_action,
160 SymlinkExtractAction::ExtractNoRestrictions
161 | SymlinkExtractAction::ExtractRecursiveInFolder
162 );
163 #[cfg(not(any(windows, unix)))]
164 let allow_outside = false;
165 if allow_outside {
166 // For ExtractRecursiveInFolder: the literal path may leave `base` (e.g.
167 // `../sibling`), but it must canonicalize (following real filesystem symlinks) back into `base`.
168 #[cfg(any(unix, windows))]
169 if matches!(
170 symlink_action,
171 SymlinkExtractAction::ExtractRecursiveInFolder
172 ) {
173 // Try to canonicalize the resolved path so that symlinks in the filesystem
174 // that point back inside `base` are followed.
175 let abs_to_canonicalize = if target_path.is_absolute() {
176 target_path
177 } else {
178 &start.join(target_path)
179 };
180 match std::fs::canonicalize(abs_to_canonicalize) {
181 Ok(canonical) => {
182 if !canonical.starts_with(base) {
183 return Err(invalid!("Path escapes the destination directory"));
184 }
185 }
186 Err(_) => {
187 crate::path::resolve_enclosed(
188 base,
189 start,
190 rest.components().map(|c| c.as_os_str().to_os_string()),
191 false,
192 )?;
193 }
194 }
195 }
196 } else {
197 crate::path::resolve_enclosed(
198 base,
199 start,
200 rest.components().map(|c| c.as_os_str().to_os_string()),
201 false, // only checking here, so nothing is created
202 )?;
203 }
204
205 make_symlink_impl(outpath, target_str, existing_files)
206}
207
208/// Wasm and others
209#[cfg(not(any(windows, unix)))]
210pub(crate) fn make_symlink<T>(
211 // No symlink is created on these targets, so there is nothing to contain.
212 _base: &Path,
213 outpath: &Path,
214 target: &[u8],
215 #[cfg_attr(not(any(windows, unix)), allow(unused))] existing_files: &IndexMap<Box<[u8]>, T>,
216 _symlink_action: SymlinkExtractAction,
217) -> ZipResult<()> {
218 make_symlink_as_file_utf8(outpath, target)?;
219 Ok(())
220}
221
222pub(crate) fn make_symlink_as_file_utf8(outpath: &Path, target: &[u8]) -> ZipResult<()> {
223 let Ok(_) = std::str::from_utf8(target) else {
224 return Err(invalid!("Invalid UTF-8 as symlink target"));
225 };
226 make_symlink_as_file(outpath, target)?;
227 Ok(())
228}
229
230pub(crate) fn make_symlink_as_file(outpath: &Path, target: &[u8]) -> std::io::Result<()> {
231 use std::fs::File;
232 use std::io::Write;
233 let output = File::create(outpath);
234 output?.write_all(target)?;
235 Ok(())
236}
237
238#[cfg(unix)]
239pub(crate) fn set_permissions_no_follow_symlink(
240 path: &Path,
241 mode: std::fs::Permissions,
242) -> std::io::Result<()> {
243 use std::fs;
244 if fs::symlink_metadata(path)?.file_type().is_symlink() {
245 #[cfg(any(
246 target_os = "macos",
247 target_os = "freebsd",
248 target_os = "openbsd",
249 target_os = "netbsd",
250 target_os = "dragonfly"
251 ))]
252 {
253 // These are the only platforms where permission bits on symlinks themselves are meaningful.
254
255 use nix::fcntl::AT_FDCWD;
256 use nix::sys::stat::{FchmodatFlags, Mode, fchmodat};
257 use std::os::unix::fs::PermissionsExt;
258
259 let mode = Mode::from_bits_truncate(mode.mode() as u16);
260
261 // Use fchmodat with the flag telling it NOT to follow the symlink
262 fchmodat(AT_FDCWD, path, mode, FchmodatFlags::NoFollowSymlink)?;
263 }
264 } else {
265 fs::set_permissions(path, mode)?;
266 }
267 Ok(())
268}
269
270/// Store all entries which specify a numeric "mode" which is familiar to POSIX operating systems.
271#[derive(Default, Debug)]
272struct UnixFileModes {
273 #[cfg(unix)]
274 map: std::collections::BTreeMap<std::path::PathBuf, u32>,
275 #[cfg(not(unix))]
276 read_only_set: std::collections::BTreeSet<std::path::PathBuf>,
277}
278
279impl UnixFileModes {
280 pub fn add_mode(&mut self, path: std::path::PathBuf, mode: u32) {
281 // We don't print a warning or consider it remotely out of the ordinary to receive two
282 // separate modes for the same path (e.g. entries named `a` and `./a`): just take the
283 // later one, as the later file's contents are what's on disk.
284 #[cfg(unix)]
285 self.map.insert(path, mode);
286 #[cfg(not(unix))]
287 if mode & 0o222 == 0 {
288 self.read_only_set.insert(path);
289 } else {
290 self.read_only_set.remove(&path);
291 }
292 }
293
294 #[cfg(unix)]
295 // Child nodes will be sorted later lexicographically, so reversing the order puts them first.
296 pub fn all_perms_with_children_first(
297 self,
298 ) -> impl IntoIterator<Item = (std::path::PathBuf, std::fs::Permissions)> {
299 use std::os::unix::fs::PermissionsExt;
300 self.map
301 .into_iter()
302 .rev()
303 .map(|(p, m)| (p, std::fs::Permissions::from_mode(m)))
304 }
305
306 #[cfg(not(unix))]
307 // Child nodes will be sorted later lexicographically, so reversing the order puts them first.
308 pub fn all_read_only_with_children_first(self) -> impl IntoIterator<Item = std::path::PathBuf> {
309 self.read_only_set.into_iter().rev()
310 }
311}
312
313impl<R: Read + Seek> ZipArchive<R> {
314 /// Extract a Zip archive into a directory, overwriting files if they
315 /// already exist. Paths are sanitized with [`ZipFile::enclosed_name`](crate::read::ZipFile::enclosed_name).
316 ///
317 /// Symbolic links are only created, and only followed, if their target resolves to a path
318 /// inside the destination directory. Paths are resolved one component at a time, and the
319 /// target of every symlink encountered is resolved in the same way, so a symlink cannot be
320 /// used as an intermediate component to reach outside the destination directory. Note that
321 /// this does not protect against another process concurrently modifying the destination
322 /// directory during extraction.
323 ///
324 /// Extraction is not atomic. If an error is encountered, some of the files
325 /// may be left on disk. However, on Unix targets, no newly-created directories with part but
326 /// not all of their contents extracted will be readable, writable or usable as process working
327 /// directories by any non-root user except you.
328 ///
329 /// On Unix and Windows, symbolic links are extracted correctly. On other platforms such as
330 /// WebAssembly, symbolic links aren't supported, so they're extracted as normal files
331 /// containing the target path in UTF-8.
332 pub fn extract<P: AsRef<Path>>(&mut self, directory: P) -> ZipResult<()> {
333 self.extract_internal(
334 directory,
335 None::<fn(&Path) -> bool>,
336 ExtractOptions::default(),
337 )
338 }
339 /// Same as extract but with options
340 pub fn extract_with_options<P: AsRef<Path>>(
341 &mut self,
342 directory: P,
343 extract_options: ExtractOptions,
344 ) -> ZipResult<()> {
345 self.extract_internal(directory, None::<fn(&Path) -> bool>, extract_options)
346 }
347
348 /// Extracts a Zip archive into a directory in the same fashion as
349 /// [`ZipArchive::extract`], but detects a "root" directory in the archive
350 /// (a single top-level directory that contains the rest of the archive's
351 /// entries) and extracts its contents directly.
352 ///
353 /// For a sensible default `filter`, you can use [`crate::read::root_dir_common_filter`].
354 /// For a custom `filter`, see [`RootDirFilter`].
355 ///
356 /// See [`ZipArchive::root_dir`] for more information on how the root
357 /// directory is detected and the meaning of the `filter` parameter.
358 ///
359 /// ## Example
360 ///
361 /// Imagine a Zip archive with the following structure:
362 ///
363 /// ```text
364 /// root/file1.txt
365 /// root/file2.txt
366 /// root/sub/file3.txt
367 /// root/sub/subsub/file4.txt
368 /// ```
369 ///
370 /// If the archive is extracted to `foo` using [`ZipArchive::extract`],
371 /// the resulting directory structure will be:
372 ///
373 /// ```text
374 /// foo/root/file1.txt
375 /// foo/root/file2.txt
376 /// foo/root/sub/file3.txt
377 /// foo/root/sub/subsub/file4.txt
378 /// ```
379 ///
380 /// If the archive is extracted to `foo` using
381 /// [`ZipArchive::extract_unwrapped_root_dir`], the resulting directory
382 /// structure will be:
383 ///
384 /// ```text
385 /// foo/file1.txt
386 /// foo/file2.txt
387 /// foo/sub/file3.txt
388 /// foo/sub/subsub/file4.txt
389 /// ```
390 ///
391 /// ## Example - No Root Directory
392 ///
393 /// Imagine a Zip archive with the following structure:
394 ///
395 /// ```text
396 /// root/file1.txt
397 /// root/file2.txt
398 /// root/sub/file3.txt
399 /// root/sub/subsub/file4.txt
400 /// other/file5.txt
401 /// ```
402 ///
403 /// Due to the presence of the `other` directory,
404 /// [`ZipArchive::extract_unwrapped_root_dir`] will extract this in the same
405 /// fashion as [`ZipArchive::extract`] as there is now no "root directory."
406 pub fn extract_unwrapped_root_dir<P: AsRef<Path>>(
407 &mut self,
408 directory: P,
409 root_dir_filter: impl RootDirFilter,
410 ) -> ZipResult<()> {
411 self.extract_internal(directory, Some(root_dir_filter), ExtractOptions::default())
412 }
413
414 fn extract_internal<P: AsRef<Path>>(
415 &mut self,
416 directory: P,
417 root_dir_filter: Option<impl RootDirFilter>,
418 extract_options: ExtractOptions,
419 ) -> ZipResult<()> {
420 use std::fs;
421
422 fs::create_dir_all(&directory)?;
423 let directory = directory.as_ref().canonicalize()?;
424
425 let root_dir = root_dir_filter
426 .and_then(|filter| {
427 self.root_dir(&filter)
428 .transpose()
429 .map(|root_dir| root_dir.map(|root_dir| (root_dir, filter)))
430 })
431 .transpose()?;
432
433 // If we have a root dir, simplify the path components to be more
434 // appropriate for passing to `safe_prepare_path`
435 let root_dir = root_dir
436 .as_ref()
437 .map(|(root_dir, filter)| {
438 crate::path::simplified_components(root_dir)
439 .ok_or_else(|| {
440 // Should be unreachable
441 debug_assert!(false, "Invalid root dir path");
442
443 invalid!("Invalid root dir path")
444 })
445 .map(|root_dir| (root_dir, filter))
446 })
447 .transpose()?;
448
449 let mut files_by_unix_mode = UnixFileModes::default();
450
451 for i in 0..self.len() {
452 let mut file = self.by_index(i)?;
453
454 let mut outpath = directory.clone();
455 /* TODO: the control flow of this method call and subsequent expectations about the
456 * values in this loop is extremely difficult to follow. It also appears to
457 * perform a nested loop upon extracting every single file entry? Why does it
458 * accept two arguments that point to the same directory path, one mutable? */
459 file.safe_prepare_path(directory.as_ref(), &mut outpath, root_dir.as_ref())?;
460
461 if file.is_symlink() {
462 // `file.size()` is the uncompressed size declared in the central
463 // directory, i.e. attacker-controlled. A symlink target is a
464 // path, so an entry claiming more than `MAX_SYMLINK_TARGET_LEN`
465 // bytes is malformed. Reject it instead of reserving -- and then
466 // reading -- the declared size, which would otherwise let a
467 // small archive exhaust memory or abort the process.
468 let declared_len = file.size();
469 if declared_len > MAX_SYMLINK_TARGET_LEN {
470 return Err(invalid!(
471 "symlink target declares {} bytes, more than the {} byte limit",
472 declared_len,
473 MAX_SYMLINK_TARGET_LEN
474 ));
475 }
476 let mut target = Vec::with_capacity(declared_len as usize);
477 file.read_to_end(&mut target)?;
478 drop(file);
479 match extract_options.symlink_action {
480 SymlinkExtractAction::NoExtract => {
481 // do nothing
482 }
483 SymlinkExtractAction::ExtractAsFile => {
484 make_symlink_as_file(&outpath, &target)?;
485 }
486 SymlinkExtractAction::ExtractAsFileUtf8 => {
487 make_symlink_as_file_utf8(&outpath, &target)?;
488 }
489 #[cfg(any(unix, windows))]
490 SymlinkExtractAction::ExtractRecursiveInFolder => {
491 make_symlink(
492 directory.as_ref(),
493 &outpath,
494 &target,
495 &self.shared.files,
496 extract_options.symlink_action,
497 )?;
498 }
499 #[cfg(any(unix, windows))]
500 SymlinkExtractAction::ExtractInFolder => {
501 make_symlink(
502 directory.as_ref(),
503 &outpath,
504 &target,
505 &self.shared.files,
506 extract_options.symlink_action,
507 )?;
508 }
509 #[cfg(any(unix, windows))]
510 SymlinkExtractAction::ExtractNoRestrictions => {
511 let Ok(target_str) = std::str::from_utf8(&target) else {
512 return Err(invalid!("Invalid UTF-8 as symlink target"));
513 };
514 make_symlink_impl(&outpath, target_str, &self.shared.files)?;
515 }
516 }
517 } else if file.is_dir() {
518 make_writable_dir_all(&outpath)?;
519 // Check for real permissions, which we'll set in a second pass.
520 if let Some(mode) = file.unix_mode() {
521 files_by_unix_mode.add_mode(outpath, mode);
522 }
523 } else {
524 let mut outfile = fs::File::create(&outpath)?;
525 io::copy(&mut file, &mut outfile)?;
526 // Set original timestamp.
527 #[cfg(feature = "chrono")]
528 if let Some(last_modified) = file.last_modified()
529 && let Some(t) = last_modified.datetime_to_systemtime()
530 {
531 outfile.set_modified(t)?;
532 }
533 #[cfg(not(unix))]
534 if file.is_read_only() {
535 let mut permissions = outfile.metadata()?.permissions();
536 permissions.set_readonly(true);
537 outfile.set_permissions(permissions)?;
538 }
539 #[cfg(unix)]
540 if let Some(mode) = file.unix_mode() {
541 use std::os::unix::fs::PermissionsExt;
542 outfile.set_permissions(fs::Permissions::from_mode(mode))?;
543 }
544 }
545 }
546
547 // Ensure we update children's permissions before making a parent unwritable.
548 #[cfg(unix)]
549 for (path, perms) in files_by_unix_mode.all_perms_with_children_first() {
550 set_permissions_no_follow_symlink(&path, perms)?;
551 }
552 #[cfg(not(unix))]
553 for path in files_by_unix_mode.all_read_only_with_children_first() {
554 let metadata = fs::symlink_metadata(&path)?;
555 if metadata.is_dir() {
556 let mut permissions = metadata.permissions();
557 permissions.set_readonly(true);
558 fs::set_permissions(path, permissions)?;
559 }
560 }
561
562 Ok(())
563 }
564}
565
566/// A filter that determines whether an entry should be ignored when searching
567/// for the root directory of a Zip archive.
568///
569/// Returns `true` if the entry should be considered, and `false` if it should
570/// be ignored.
571///
572/// See [`root_dir_common_filter`] for a sensible default filter.
573pub trait RootDirFilter: Fn(&Path) -> bool {}
574impl<F: Fn(&Path) -> bool> RootDirFilter for F {}
575
576/// Common filters when finding the root directory of a Zip archive.
577///
578/// This filter is a sensible default for most use cases and filters out common
579/// system files that are usually irrelevant to the contents of the archive.
580///
581/// Currently, the filter ignores:
582/// - `/__MACOSX/`
583/// - `/.DS_Store`
584/// - `/Thumbs.db`
585///
586/// **This function is not guaranteed to be stable and may change in future versions.**
587///
588/// # Example
589///
590/// ```rust
591/// # use std::path::Path;
592/// assert!(zip::read::root_dir_common_filter(Path::new("foo.txt")));
593/// assert!(!zip::read::root_dir_common_filter(Path::new(".DS_Store")));
594/// assert!(!zip::read::root_dir_common_filter(Path::new("Thumbs.db")));
595/// assert!(!zip::read::root_dir_common_filter(Path::new("__MACOSX")));
596/// assert!(!zip::read::root_dir_common_filter(Path::new("__MACOSX/foo.txt")));
597/// ```
598#[must_use]
599pub fn root_dir_common_filter(path: &Path) -> bool {
600 const COMMON_FILTER_ROOT_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
601
602 if path.starts_with("__MACOSX") {
603 return false;
604 }
605
606 if path.components().count() == 1
607 && path.file_name().is_some_and(|file_name| {
608 COMMON_FILTER_ROOT_FILES
609 .iter()
610 .map(OsStr::new)
611 .any(|cmp| cmp == file_name)
612 })
613 {
614 return false;
615 }
616
617 true
618}