Skip to main content

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}