chore: tweak tests so they run faster
[ouch.git] / src / archive / zip.rs
blob9995e079ba3ca5647a04a13d92760b0aa67e7093
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         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 ) -> 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     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                         "Cannot compress `{}` into itself, 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 && path.is_symlink() {
218                         // This path is for a broken symlink, ignore it
219                         continue;
220                     }
221                     return Err(e.into());
222                 }
223             };
225             #[cfg(unix)]
226             let options = options.unix_permissions(metadata.permissions().mode());
228             let entry_name = path.to_str().ok_or_else(|| {
229                 FinalError::with_title("Zip requires that all directories names are valid UTF-8")
230                     .detail(format!("File at '{path:?}' has a non-UTF-8 name"))
231             })?;
233             if metadata.is_dir() {
234                 writer.add_directory(entry_name, options)?;
235             } else {
236                 #[cfg(not(unix))]
237                 let options = if is_executable::is_executable(path) {
238                     executable
239                 } else {
240                     options
241                 };
243                 let mut file = fs::File::open(path)?;
245                 // Updated last modified time
246                 let last_modified_time = options.last_modified_time(get_last_modified_time(&file));
248                 writer.start_file(entry_name, last_modified_time)?;
249                 io::copy(&mut file, &mut writer)?;
250             }
251         }
253         env::set_current_dir(previous_location)?;
254     }
256     let bytes = writer.finish()?;
257     Ok(bytes)
260 fn display_zip_comment_if_exists(file: &ZipFile) {
261     let comment = file.comment();
262     if !comment.is_empty() {
263         // Zip file comments seem to be pretty rare, but if they are used,
264         // they may contain important information, so better show them
265         //
266         // "The .ZIP file format allows for a comment containing up to 65,535 (216−1) bytes
267         // of data to occur at the end of the file after the central directory."
268         //
269         // If there happen to be cases of very long and unnecessary comments in
270         // the future, maybe asking the user if he wants to display the comment
271         // (informing him of its size) would be sensible for both normal and
272         // accessibility mode..
273         info_accessible(format!("Found comment in {}: {}", file.name(), comment));
274     }
277 fn get_last_modified_time(file: &fs::File) -> DateTime {
278     file.metadata()
279         .and_then(|metadata| metadata.modified())
280         .ok()
281         .and_then(|time| DateTime::try_from(OffsetDateTime::from(time)).ok())
282         .unwrap_or_default()
285 fn set_last_modified_time(zip_file: &ZipFile, path: &Path) -> crate::Result<()> {
286     let modification_time = zip_file.last_modified().to_time();
288     let Ok(time_in_seconds) = modification_time else {
289         return Ok(());
290     };
292     // Zip does not support nanoseconds, so we can assume zero here
293     let modification_time = FileTime::from_unix_time(time_in_seconds.unix_timestamp(), 0);
295     set_file_mtime(path, modification_time)?;
297     Ok(())
300 #[cfg(unix)]
301 fn unix_set_permissions(file_path: &Path, file: &ZipFile) -> crate::Result<()> {
302     use std::fs::Permissions;
304     if let Some(mode) = file.unix_mode() {
305         fs::set_permissions(file_path, Permissions::from_mode(mode))?;
306     }
308     Ok(())