zip_extract/
lib.rs

1//! # zip-extract
2//! zip-extract's primary goal is simple: Automate tedious zip extraction. Ever wanted to just unpack
3//! an archive somewhere? Well, here you go:
4//!
5//! ## Usage
6//! See `extract` for details.
7//!
8//! ```ignore
9//! let archive: Vec<u8> = download_my_archive()?;
10//! let target_dir = PathBuf::from("my_target_dir"); // Doesn't need to exist
11//!
12//! // The third parameter allows you to strip away toplevel directories.
13//! // If `archive` contained a single folder, that folder's contents would be extracted instead.
14//! zip_extract::extract(Cursor::new(archive), &target_dir, true)?;
15//! ```
16//!
17//! ## Features
18//! All features are passed through to `zip2`, refer to
19//! [the documentation](https://docs.rs/crate/zip/2/features) for defaults and a list of features.
20
21#![forbid(unsafe_code)]
22
23#[macro_use]
24extern crate log;
25
26#[cfg(unix)]
27use std::os::unix::fs::PermissionsExt;
28
29use std::io::{Read, Seek};
30use std::path::{Path, PathBuf, StripPrefixError};
31use std::{fs, io};
32use thiserror::Error;
33
34/// Re-export of zip's error type, for convenience.
35pub use zip::result::ZipError;
36
37/// zip-extract's error type
38#[derive(Error, Debug)]
39pub enum ZipExtractError {
40    #[error(transparent)]
41    Io(#[from] io::Error),
42    #[error(transparent)]
43    Zip(#[from] ZipError),
44    #[error("Failed to strip toplevel directory {} from {}: {error}", .toplevel.to_string_lossy(), .path.to_string_lossy())]
45    StripToplevel {
46        toplevel: PathBuf,
47        path: PathBuf,
48        error: StripPrefixError,
49    },
50}
51
52/// Extracts a zip archive into `target_dir`.
53///
54/// `target_dir` is created if it doesn't exist. Will error if `target_dir.parent()` doesn't exist.
55///
56/// If `strip_toplevel` is true, will strip away the topmost directory. `strip_toplevel` only applies
57/// if all files and directories within the archive are descendants of the toplevel directory.
58///
59/// If you want to read from a source that doesn't implement Seek, you can wrap it into a Cursor:
60/// ```
61/// use std::io::Cursor;
62/// use std::path::PathBuf;
63///
64/// let bytes: Vec<u8> = vec![];
65/// let target = PathBuf::from("/tmp/target-directory");
66/// zip_extract::extract(Cursor::new(bytes), &target, false);
67/// ```
68///
69/// If on unix, `extract` will preserve permissions while extracting.
70pub fn extract<S: Read + Seek>(
71    source: S,
72    target_dir: &Path,
73    strip_toplevel: bool,
74) -> Result<(), ZipExtractError> {
75    if !target_dir.exists() {
76        fs::create_dir(target_dir)?;
77    }
78
79    let mut archive = zip::ZipArchive::new(source)?;
80
81    let do_strip_toplevel = strip_toplevel && has_toplevel(&mut archive)?;
82
83    debug!("Extracting to {}", target_dir.to_string_lossy());
84    for i in 0..archive.len() {
85        let mut file = archive.by_index(i)?;
86        let mut relative_path = file.mangled_name();
87
88        if do_strip_toplevel {
89            let base = relative_path
90                .components()
91                .take(1)
92                .fold(PathBuf::new(), |mut p, c| {
93                    p.push(c);
94                    p
95                });
96            relative_path = relative_path
97                .strip_prefix(&base)
98                .map_err(|error| ZipExtractError::StripToplevel {
99                    toplevel: base,
100                    path: relative_path.clone(),
101                    error,
102                })?
103                .to_path_buf()
104        }
105
106        if relative_path.to_string_lossy().is_empty() {
107            // Top-level directory
108            continue;
109        }
110
111        let mut outpath = target_dir.to_path_buf();
112        outpath.push(relative_path);
113
114        trace!(
115            "Extracting {} to {}",
116            file.name(),
117            outpath.to_string_lossy()
118        );
119        if file.name().ends_with('/') {
120            fs::create_dir_all(&outpath)?;
121        } else {
122            if let Some(p) = outpath.parent() {
123                if !p.exists() {
124                    fs::create_dir_all(p)?;
125                }
126            }
127            let mut outfile = fs::File::create(&outpath)?;
128            io::copy(&mut file, &mut outfile)?;
129        }
130
131        #[cfg(unix)]
132        set_unix_mode(&file, &outpath)?;
133    }
134
135    debug!("Extracted {} files", archive.len());
136    Ok(())
137}
138
139fn has_toplevel<S: Read + Seek>(
140    archive: &mut zip::ZipArchive<S>,
141) -> Result<bool, zip::result::ZipError> {
142    let mut toplevel_dir: Option<PathBuf> = None;
143    if archive.len() < 2 {
144        return Ok(false);
145    }
146
147    for i in 0..archive.len() {
148        let file = archive.by_index(i)?.mangled_name();
149        if let Some(toplevel_dir) = &toplevel_dir {
150            if !file.starts_with(toplevel_dir) {
151                trace!("Found different toplevel directory");
152                return Ok(false);
153            }
154        } else {
155            // First iteration
156            let comp: PathBuf = file.components().take(1).collect();
157            trace!(
158                "Checking if path component {} is the only toplevel directory",
159                comp.to_string_lossy()
160            );
161            toplevel_dir = Some(comp);
162        }
163    }
164    trace!("Found no other toplevel directory");
165    Ok(true)
166}
167
168#[cfg(unix)]
169fn set_unix_mode<R: Read>(file: &zip::read::ZipFile<R>, outpath: &Path) -> io::Result<()> {
170    if let Some(m) = file.unix_mode() {
171        fs::set_permissions(outpath, PermissionsExt::from_mode(m))?
172    }
173    Ok(())
174}