Update dependabot.yml
[ouch.git] / src / archive / zip.rs
blob39b640e336059767ccc5053d0309695569079d40
1 //! Contains Zip-specific building and unpacking functions
3 #[cfg(unix)]
4 use std::os::unix::fs::PermissionsExt;
5 use std::{
6     env,
7     io::{self, prelude::*},
8     path::{Path, PathBuf},
9     sync::mpsc,
10     thread,
13 use filetime_creation::{set_file_mtime, FileTime};
14 use fs_err as fs;
15 use same_file::Handle;
16 use time::OffsetDateTime;
17 use zip::{self, read::ZipFile, DateTime, ZipArchive};
19 use crate::{
20     error::FinalError,
21     list::FileInArchive,
22     utils::{
23         self, cd_into_same_dir_as, get_invalid_utf8_paths,
24         logger::{info, info_accessible, warning},
25         pretty_format_list_of_paths, strip_cur_dir, Bytes, EscapedPathDisplay, FileVisibilityPolicy,
26     },
29 /// Unpacks the archive given by `archive` into the folder given by `output_folder`.
30 /// Assumes that output_folder is empty
31 pub fn unpack_archive<R>(
32     mut archive: ZipArchive<R>,
33     output_folder: &Path,
34     password: Option<&[u8]>,
35     quiet: bool,
36 ) -> crate::Result<usize>
37 where
38     R: Read + Seek,
40     assert!(output_folder.read_dir().expect("dir exists").count() == 0);
42     let mut unpacked_files = 0;
44     for idx in 0..archive.len() {
45         let mut file = match password {
46             Some(password) => archive
47                 .by_index_decrypt(idx, password)?
48                 .map_err(|_| zip::result::ZipError::UnsupportedArchive("Password required to decrypt file"))?,
49             None => archive.by_index(idx)?,
50         };
51         let file_path = match file.enclosed_name() {
52             Some(path) => path.to_owned(),
53             None => continue,
54         };
56         let file_path = output_folder.join(file_path);
58         display_zip_comment_if_exists(&file);
60         match file.name().ends_with('/') {
61             _is_dir @ true => {
62                 // This is printed for every file in the archive and has little
63                 // importance for most users, but would generate lots of
64                 // spoken text for users using screen readers, braille displays
65                 // and so on
66                 if !quiet {
67                     info(format!("File {} extracted to \"{}\"", idx, file_path.display()));
68                 }
69                 fs::create_dir_all(&file_path)?;
70             }
71             _is_file @ false => {
72                 if let Some(path) = file_path.parent() {
73                     if !path.exists() {
74                         fs::create_dir_all(path)?;
75                     }
76                 }
77                 let file_path = strip_cur_dir(file_path.as_path());
79                 // same reason is in _is_dir: long, often not needed text
80                 if !quiet {
81                     info(format!(
82                         "{:?} extracted. ({})",
83                         file_path.display(),
84                         Bytes::new(file.size())
85                     ));
86                 }
88                 let mut output_file = fs::File::create(file_path)?;
89                 io::copy(&mut file, &mut output_file)?;
91                 set_last_modified_time(&file, file_path)?;
92             }
93         }
95         #[cfg(unix)]
96         unix_set_permissions(&file_path, &file)?;
98         unpacked_files += 1;
99     }
101     Ok(unpacked_files)
104 /// List contents of `archive`, returning a vector of archive entries
105 pub fn list_archive<R>(
106     mut archive: ZipArchive<R>,
107     password: Option<&[u8]>,
108 ) -> crate::Result<impl Iterator<Item = crate::Result<FileInArchive>>>
109 where
110     R: Read + Seek + Send + 'static,
112     struct Files(mpsc::Receiver<crate::Result<FileInArchive>>);
113     impl Iterator for Files {
114         type Item = crate::Result<FileInArchive>;
116         fn next(&mut self) -> Option<Self::Item> {
117             self.0.recv().ok()
118         }
119     }
121     let password = password.map(|p| p.to_owned());
123     let (tx, rx) = mpsc::channel();
124     thread::spawn(move || {
125         for idx in 0..archive.len() {
126             let file_in_archive = (|| {
127                 let zip_result = match password.clone() {
128                     Some(password) => archive
129                         .by_index_decrypt(idx, &password)?
130                         .map_err(|_| zip::result::ZipError::UnsupportedArchive("Password required to decrypt file")),
131                     None => archive.by_index(idx),
132                 };
134                 let file = match zip_result {
135                     Ok(f) => f,
136                     Err(e) => return Err(e.into()),
137                 };
139                 let path = file.enclosed_name().unwrap_or(&*file.mangled_name()).to_owned();
140                 let is_dir = file.is_dir();
142                 Ok(FileInArchive { path, is_dir })
143             })();
144             tx.send(file_in_archive).unwrap();
145         }
146     });
148     Ok(Files(rx))
151 /// Compresses the archives given by `input_filenames` into the file given previously to `writer`.
152 pub fn build_archive_from_paths<W>(
153     input_filenames: &[PathBuf],
154     output_path: &Path,
155     writer: W,
156     file_visibility_policy: FileVisibilityPolicy,
157     quiet: bool,
158 ) -> crate::Result<W>
159 where
160     W: Write + Seek,
162     let mut writer = zip::ZipWriter::new(writer);
163     // always use ZIP64 to allow compression of files larger than 4GB
164     // the format is widely supported and the extra 20B is negligible in most cases
165     let options = zip::write::FileOptions::default().large_file(true);
166     let output_handle = Handle::from_path(output_path);
168     #[cfg(not(unix))]
169     let executable = options.unix_permissions(0o755);
171     // Vec of any filename that failed the UTF-8 check
172     let invalid_unicode_filenames = get_invalid_utf8_paths(input_filenames);
174     if !invalid_unicode_filenames.is_empty() {
175         let error = FinalError::with_title("Cannot build zip archive")
176             .detail("Zip archives require files to have valid UTF-8 paths")
177             .detail(format!(
178                 "Files with invalid paths: {}",
179                 pretty_format_list_of_paths(&invalid_unicode_filenames)
180             ));
182         return Err(error.into());
183     }
185     for filename in input_filenames {
186         let previous_location = cd_into_same_dir_as(filename)?;
188         // Unwrap safety:
189         //   paths should be canonicalized by now, and the root directory rejected.
190         let filename = filename.file_name().unwrap();
192         for entry in file_visibility_policy.build_walker(filename) {
193             let entry = entry?;
194             let path = entry.path();
196             // If the output_path is the same as the input file, warn the user and skip the input (in order to avoid compression recursion)
197             if let Ok(handle) = &output_handle {
198                 if matches!(Handle::from_path(path), Ok(x) if &x == handle) {
199                     warning(format!(
200                         "The output file and the input file are the same: `{}`, skipping...",
201                         output_path.display()
202                     ));
203                 }
204             }
206             // This is printed for every file in `input_filenames` and has
207             // little importance for most users, but would generate lots of
208             // spoken text for users using screen readers, braille displays
209             // and so on
210             if !quiet {
211                 info(format!("Compressing '{}'.", EscapedPathDisplay::new(path)));
212             }
214             let metadata = match path.metadata() {
215                 Ok(metadata) => metadata,
216                 Err(e) => {
217                     if e.kind() == std::io::ErrorKind::NotFound && utils::is_symlink(path) {
218                         // This path is for a broken symlink
219                         // We just ignore it
220                         continue;
221                     }
222                     return Err(e.into());
223                 }
224             };
226             #[cfg(unix)]
227             let options = options.unix_permissions(metadata.permissions().mode());
229             let entry_name = path.to_str().ok_or_else(|| {
230                 FinalError::with_title("Zip requires that all directories names are valid UTF-8")
231                     .detail(format!("File at '{path:?}' has a non-UTF-8 name"))
232             })?;
234             if metadata.is_dir() {
235                 writer.add_directory(entry_name, options)?;
236             } else {
237                 #[cfg(not(unix))]
238                 let options = if is_executable::is_executable(path) {
239                     executable
240                 } else {
241                     options
242                 };
244                 let mut file = fs::File::open(path)?;
246                 // Updated last modified time
247                 let last_modified_time = options.last_modified_time(get_last_modified_time(&file));
249                 writer.start_file(entry_name, last_modified_time)?;
250                 io::copy(&mut file, &mut writer)?;
251             }
252         }
254         env::set_current_dir(previous_location)?;
255     }
257     let bytes = writer.finish()?;
258     Ok(bytes)
261 fn display_zip_comment_if_exists(file: &ZipFile) {
262     let comment = file.comment();
263     if !comment.is_empty() {
264         // Zip file comments seem to be pretty rare, but if they are used,
265         // they may contain important information, so better show them
266         //
267         // "The .ZIP file format allows for a comment containing up to 65,535 (216−1) bytes
268         // of data to occur at the end of the file after the central directory."
269         //
270         // If there happen to be cases of very long and unnecessary comments in
271         // the future, maybe asking the user if he wants to display the comment
272         // (informing him of its size) would be sensible for both normal and
273         // accessibility mode..
274         info_accessible(format!("Found comment in {}: {}", file.name(), comment));
275     }
278 fn get_last_modified_time(file: &fs::File) -> DateTime {
279     file.metadata()
280         .and_then(|metadata| metadata.modified())
281         .ok()
282         .and_then(|time| DateTime::try_from(OffsetDateTime::from(time)).ok())
283         .unwrap_or_default()
286 fn set_last_modified_time(zip_file: &ZipFile, path: &Path) -> crate::Result<()> {
287     let modification_time = zip_file.last_modified().to_time();
289     let Ok(time_in_seconds) = modification_time else {
290         return Ok(());
291     };
293     // Zip does not support nanoseconds, so we can assume zero here
294     let modification_time = FileTime::from_unix_time(time_in_seconds.unix_timestamp(), 0);
296     set_file_mtime(path, modification_time)?;
298     Ok(())
301 #[cfg(unix)]
302 fn unix_set_permissions(file_path: &Path, file: &ZipFile) -> crate::Result<()> {
303     use std::fs::Permissions;
305     if let Some(mode) = file.unix_mode() {
306         fs::set_permissions(file_path, Permissions::from_mode(mode))?;
307     }
309     Ok(())