cosmic_files/
archive.rs

1use crate::mime_icon::mime_for_path;
2use crate::operation::{Controller, OpReader, OperationError, OperationErrorType};
3use cosmic::iced::futures;
4use jiff::Zoned;
5use jiff::civil::DateTime;
6use jiff::tz::TimeZone;
7use std::collections::HashSet;
8use std::fs;
9use std::io::{self, Read, Write};
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12use zip::result::ZipError;
13
14pub const SUPPORTED_ARCHIVE_TYPES: &[&str] = &[
15    "application/gzip",
16    "application/x-compressed-tar",
17    "application/x-tar",
18    "application/zip",
19    #[cfg(feature = "bzip2")]
20    "application/x-bzip",
21    #[cfg(feature = "bzip2")]
22    "application/x-bzip-compressed-tar",
23    #[cfg(feature = "bzip2")]
24    "application/x-bzip2",
25    #[cfg(feature = "bzip2")]
26    "application/x-bzip2-compressed-tar",
27    #[cfg(feature = "lzma-rust2")]
28    "application/x-xz",
29    #[cfg(feature = "lzma-rust2")]
30    "application/x-xz-compressed-tar",
31];
32
33pub const SUPPORTED_EXTENSIONS: &[&str] = &[
34    ".tar.bz2",
35    ".tar.gz",
36    ".tar.lzma",
37    ".tar.xz",
38    ".tgz",
39    ".tar",
40    ".zip",
41];
42
43pub fn extract(
44    path: &Path,
45    new_dir: &Path,
46    password: &Option<String>,
47    controller: &Controller,
48) -> Result<(Vec<PathBuf>, HashSet<PathBuf>), OperationError> {
49    let mime = mime_for_path(path, None, false);
50    let password = password.as_deref();
51    match mime.essence_str() {
52        "application/gzip" | "application/x-compressed-tar" => {
53            OpReader::new(path, controller.clone())
54                .map(io::BufReader::new)
55                .map(flate2::read::GzDecoder::new)
56                .map(tar::Archive::new)
57                .and_then(|mut archive| archive.unpack(new_dir))
58                .map_err(|e| OperationError::from_err(e, controller))
59                .map(|_| Default::default())
60        }
61        "application/x-tar" => OpReader::new(path, controller.clone())
62            .map(io::BufReader::new)
63            .map(tar::Archive::new)
64            .and_then(|mut archive| archive.unpack(new_dir))
65            .map_err(|e| OperationError::from_err(e, controller))
66            .map(|_| Default::default()),
67        "application/zip" => fs::File::open(path)
68            .map(io::BufReader::new)
69            .map(zip::ZipArchive::new)
70            .map_err(|e| OperationError::from_err(e, controller))?
71            .and_then(move |mut archive| {
72                zip_extract(&mut archive, new_dir, password, controller.clone())
73            })
74            .map_err(|e| match e {
75                ZipError::UnsupportedArchive(ZipError::PASSWORD_REQUIRED)
76                | ZipError::InvalidPassword => {
77                    OperationError::from_kind(OperationErrorType::PasswordRequired, controller)
78                }
79                _ => OperationError::from_err(e, controller),
80            }),
81        #[cfg(feature = "bzip2")]
82        "application/x-bzip"
83        | "application/x-bzip-compressed-tar"
84        | "application/x-bzip2"
85        | "application/x-bzip2-compressed-tar" => OpReader::new(path, controller.clone())
86            .map(io::BufReader::new)
87            .map(bzip2::read::BzDecoder::new)
88            .map(tar::Archive::new)
89            .and_then(|mut archive| archive.unpack(new_dir))
90            .map_err(|e| OperationError::from_err(e, controller))
91            .map(|_| Default::default()),
92        #[cfg(feature = "lzma-rust2")]
93        "application/x-xz" | "application/x-xz-compressed-tar" => {
94            OpReader::new(path, controller.clone())
95                .map(io::BufReader::new)
96                .map(|reader| lzma_rust2::XzReader::new(reader, true))
97                .map(tar::Archive::new)
98                .and_then(|mut archive| archive.unpack(new_dir))
99                .map_err(|e| OperationError::from_err(e, controller))
100                .map(|_| Default::default())
101        }
102        _ => Err(OperationError::from_err(
103            format!("unsupported mime type {mime:?}"),
104            controller,
105        )),
106    }
107}
108
109// From https://docs.rs/zip/latest/zip/read/struct.ZipArchive.html#method.extract, with cancellation and progress added
110fn zip_extract<R: io::Read + io::Seek, P: AsRef<Path>>(
111    archive: &mut zip::ZipArchive<R>,
112    directory: P,
113    password: Option<&str>,
114    controller: Controller,
115) -> zip::result::ZipResult<(Vec<PathBuf>, HashSet<PathBuf>)> {
116    use std::ffi::OsString;
117    use std::fs;
118    use zip::result::ZipError;
119
120    fn make_writable_dir_all<T: AsRef<Path>>(
121        outpath: T,
122        target_dirs: &mut HashSet<PathBuf>,
123    ) -> Result<(), ZipError> {
124        let path = outpath.as_ref();
125        if !path.exists() {
126            fs::create_dir_all(path)?;
127        }
128        if !target_dirs.contains(path) {
129            target_dirs.insert(path.to_path_buf());
130        }
131
132        #[cfg(unix)]
133        {
134            // Dirs must be writable until all normal files are extracted
135            use std::os::unix::fs::PermissionsExt;
136            fs::set_permissions(
137                path,
138                fs::Permissions::from_mode(0o700 | fs::metadata(path)?.permissions().mode()),
139            )?;
140        }
141        Ok(())
142    }
143
144    let mut buffer = vec![0; 4 * 1024 * 1024];
145    let total_files = archive.len();
146    let mut written_files = Vec::with_capacity(total_files);
147    let mut target_dirs = HashSet::new();
148    #[cfg(unix)]
149    let mut files_by_unix_mode = Vec::with_capacity(total_files);
150    let mut files_by_last_modified = Vec::with_capacity(total_files);
151
152    for i in 0..total_files {
153        futures::executor::block_on(async {
154            controller
155                .check()
156                .await
157                .map_err(|s| io::Error::other(OperationError::from_state(s, &controller)))
158        })?;
159
160        controller.set_progress(i as f32 / total_files as f32);
161
162        let mut file = match password {
163            None => archive.by_index(i),
164            Some(pwd) => archive.by_index_decrypt(i, pwd.as_bytes()),
165        }?;
166
167        let filepath = file
168            .enclosed_name()
169            .ok_or(ZipError::InvalidArchive("Invalid file path".into()))?;
170
171        let outpath = directory.as_ref().join(filepath);
172
173        if let Some(last_modified) = file.last_modified() {
174            files_by_last_modified.push((outpath.clone(), last_modified));
175        }
176
177        if file.is_dir() {
178            make_writable_dir_all(&outpath, &mut target_dirs)?;
179
180            #[cfg(unix)]
181            if let Some(mode) = file.unix_mode() {
182                files_by_unix_mode.push((outpath, mode));
183            }
184            continue;
185        }
186
187        if let Some(parent) = outpath.parent() {
188            make_writable_dir_all(parent, &mut target_dirs)?;
189        }
190
191        if file.is_symlink() && (cfg!(unix) || cfg!(windows)) {
192            let mut target = Vec::with_capacity(file.size() as usize);
193            file.read_to_end(&mut target)?;
194            // File no longer needed, drop to allow reading target on windows
195            drop(file);
196
197            #[cfg(unix)]
198            {
199                use std::os::unix::ffi::OsStringExt;
200                let target = OsString::from_vec(target);
201                std::os::unix::fs::symlink(&target, outpath.as_path())?;
202            }
203
204            #[cfg(windows)]
205            {
206                let Ok(target) = String::from_utf8(target) else {
207                    return Err(ZipError::InvalidArchive(
208                        "Invalid UTF-8 as symlink target".into(),
209                    ));
210                };
211                let target_is_dir_from_archive = match password {
212                    None => archive.by_name(&target),
213                    Some(pwd) => archive.by_name_decrypt(&target, pwd.as_bytes()),
214                }
215                .is_ok_and(|x| x.is_dir());
216                let target_path = directory.as_ref().join(OsString::from(target.to_string()));
217                let target_is_dir = if target_is_dir_from_archive {
218                    true
219                } else if let Ok(meta) = std::fs::metadata(&target_path) {
220                    meta.is_dir()
221                } else {
222                    false
223                };
224                if target_is_dir {
225                    std::os::windows::fs::symlink_dir(target_path, outpath.as_path())?;
226                } else {
227                    std::os::windows::fs::symlink_file(target_path, outpath.as_path())?;
228                }
229            }
230
231            written_files.push(outpath);
232            continue;
233        }
234
235        let total = file.size();
236        let mut outfile = fs::File::create(&outpath)?;
237        let mut current = 0;
238        loop {
239            futures::executor::block_on(async {
240                controller
241                    .check()
242                    .await
243                    .map_err(|s| io::Error::other(OperationError::from_state(s, &controller)))
244            })?;
245
246            let count = file.read(&mut buffer)?;
247            if count == 0 {
248                break;
249            }
250            outfile.write_all(&buffer[..count])?;
251            current += count as u64;
252
253            if current < total {
254                let file_progress = current as f32 / total as f32;
255                let total_progress = (i as f32 + file_progress) / total_files as f32;
256                controller.set_progress(total_progress);
257            }
258        }
259
260        // Check for real permissions, which we'll set in a second pass
261        #[cfg(unix)]
262        if let Some(mode) = file.unix_mode() {
263            files_by_unix_mode.push((outpath.clone(), mode));
264        }
265
266        written_files.push(outpath);
267    }
268    #[cfg(unix)]
269    {
270        use std::cmp::Reverse;
271        use std::os::unix::fs::PermissionsExt;
272
273        if files_by_unix_mode.len() > 1 {
274            // Ensure we update children's permissions before making a parent unwritable
275            files_by_unix_mode.sort_by_key(|(path, _)| Reverse(path.components().count()));
276        }
277        for (path, mode) in files_by_unix_mode {
278            fs::set_permissions(&path, fs::Permissions::from_mode(mode))?;
279        }
280    }
281
282    for (path, last_modified) in files_by_last_modified {
283        if let Some(modified) = zip_date_time_to_system_time(last_modified) {
284            let file_time = filetime::FileTime::from_system_time(modified);
285            filetime::set_file_mtime(&path, file_time)?;
286        }
287    }
288
289    Ok((written_files, target_dirs))
290}
291
292fn zip_date_time_to_system_time(date_time: zip::DateTime) -> Option<SystemTime> {
293    let dt = DateTime::new(
294        date_time.year() as i16,
295        date_time.month() as i8,
296        date_time.day() as i8,
297        date_time.hour() as i8,
298        date_time.minute() as i8,
299        date_time.second() as i8,
300        0,
301    )
302    .ok()?;
303    TimeZone::system()
304        .to_ambiguous_zoned(dt)
305        .later()
306        .ok()
307        .map(SystemTime::from)
308}
309
310pub fn system_time_to_zip_date_time(system_time: SystemTime) -> Option<zip::DateTime> {
311    let date_time = Zoned::try_from(system_time).ok()?;
312
313    zip::DateTime::from_date_and_time(
314        date_time.year() as u16,
315        date_time.month() as u8,
316        date_time.day() as u8,
317        date_time.hour() as u8,
318        date_time.minute() as u8,
319        date_time.second() as u8,
320    )
321    .ok()
322}