diff --git a/src/archive.rs b/src/archive.rs new file mode 100644 index 0000000..6d55fea --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,268 @@ +use std::{ + collections::VecDeque, + fs, + io::{self, Read, Write}, + path::Path, +}; +use zip::result::ZipError; + +use crate::{ + mime_icon::mime_for_path, + operation::{Controller, OpReader, OperationError, OperationErrorType}, +}; + +pub const SUPPORTED_ARCHIVE_TYPES: &[&str] = &[ + "application/gzip", + "application/x-compressed-tar", + "application/x-tar", + "application/zip", + #[cfg(feature = "bzip2")] + "application/x-bzip", + #[cfg(feature = "bzip2")] + "application/x-bzip-compressed-tar", + #[cfg(feature = "bzip2")] + "application/x-bzip2", + #[cfg(feature = "bzip2")] + "application/x-bzip2-compressed-tar", + #[cfg(feature = "xz2")] + "application/x-xz", + #[cfg(feature = "xz2")] + "application/x-xz-compressed-tar", +]; + +pub const SUPPORTED_EXTENSIONS: &[&str] = &[ + ".tar.bz2", + ".tar.gz", + ".tar.lzma", + ".tar.xz", + ".tgz", + ".tar", + ".zip", +]; + +pub fn extract( + path: &Path, + new_dir: &Path, + password: &Option, + controller: &Controller, +) -> Result<(), OperationError> { + let mime = mime_for_path(path, None, false); + let controller = controller.clone(); + let password = password.clone(); + match mime.essence_str() { + "application/gzip" | "application/x-compressed-tar" => OpReader::new(path, controller) + .map(io::BufReader::new) + .map(flate2::read::GzDecoder::new) + .map(tar::Archive::new) + .and_then(|mut archive| archive.unpack(&new_dir)) + .map_err(OperationError::from_str)?, + "application/x-tar" => OpReader::new(path, controller) + .map(io::BufReader::new) + .map(tar::Archive::new) + .and_then(|mut archive| archive.unpack(&new_dir)) + .map_err(OperationError::from_str)?, + "application/zip" => fs::File::open(path) + .map(io::BufReader::new) + .map(zip::ZipArchive::new) + .map_err(OperationError::from_str)? + .and_then(move |mut archive| zip_extract(&mut archive, &new_dir, password, controller)) + .map_err(|e| match e { + ZipError::UnsupportedArchive(ZipError::PASSWORD_REQUIRED) + | ZipError::InvalidPassword => OperationError { + kind: OperationErrorType::PasswordRequired, + }, + _ => OperationError::from_str(e), + })?, + #[cfg(feature = "bzip2")] + "application/x-bzip" + | "application/x-bzip-compressed-tar" + | "application/x-bzip2" + | "application/x-bzip2-compressed-tar" => OpReader::new(path, controller) + .map(io::BufReader::new) + .map(bzip2::read::BzDecoder::new) + .map(tar::Archive::new) + .and_then(|mut archive| archive.unpack(&new_dir)) + .map_err(OperationError::from_str)?, + #[cfg(feature = "xz2")] + "application/x-xz" | "application/x-xz-compressed-tar" => OpReader::new(path, controller) + .map(io::BufReader::new) + .map(xz2::read::XzDecoder::new) + .map(tar::Archive::new) + .and_then(|mut archive| archive.unpack(&new_dir)) + .map_err(OperationError::from_str)?, + _ => Err(OperationError::from_str(format!( + "unsupported mime type {:?}", + mime + )))?, + } + Ok(()) +} + +// From https://docs.rs/zip/latest/zip/read/struct.ZipArchive.html#method.extract, with cancellation and progress added +fn zip_extract>( + archive: &mut zip::ZipArchive, + directory: P, + password: Option, + controller: Controller, +) -> zip::result::ZipResult<()> { + use std::{ffi::OsString, fs}; + use zip::result::ZipError; + + fn make_writable_dir_all>(outpath: T) -> Result<(), ZipError> { + fs::create_dir_all(outpath.as_ref())?; + #[cfg(unix)] + { + // Dirs must be writable until all normal files are extracted + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + outpath.as_ref(), + std::fs::Permissions::from_mode( + 0o700 | std::fs::metadata(outpath.as_ref())?.permissions().mode(), + ), + )?; + } + Ok(()) + } + + #[cfg(unix)] + let mut files_by_unix_mode = Vec::new(); + let mut buffer = vec![0; 4 * 1024 * 1024]; + let total_files = archive.len(); + let mut pending_directory_creates = VecDeque::new(); + + for i in 0..total_files { + futures::executor::block_on(async { + controller + .check() + .await + .map_err(|err| io::Error::new(io::ErrorKind::Other, err)) + })?; + + controller.set_progress((i as f32) / total_files as f32); + + let mut file = match &password { + None => archive.by_index(i), + Some(pwd) => archive.by_index_decrypt(i, pwd.as_bytes()), + } + .map_err(|e| e)?; + let filepath = file + .enclosed_name() + .ok_or(ZipError::InvalidArchive("Invalid file path".into()))?; + + let outpath = directory.as_ref().join(filepath); + + if file.is_dir() { + pending_directory_creates.push_back(outpath.clone()); + continue; + } + let symlink_target = if file.is_symlink() && (cfg!(unix) || cfg!(windows)) { + let mut target = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut target)?; + Some(target) + } else { + None + }; + drop(file); + if let Some(target) = symlink_target { + // create all pending dirs + while let Some(pending_dir) = pending_directory_creates.pop_front() { + make_writable_dir_all(pending_dir)?; + } + + if let Some(p) = outpath.parent() { + make_writable_dir_all(p)?; + } + + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let target = OsString::from_vec(target); + std::os::unix::fs::symlink(&target, outpath.as_path())?; + } + #[cfg(windows)] + { + let Ok(target) = String::from_utf8(target) else { + return Err(ZipError::InvalidArchive("Invalid UTF-8 as symlink target")); + }; + let target = target.into_boxed_str(); + let target_is_dir_from_archive = + archive.shared.files.contains_key(&target) && is_dir(&target); + let target_path = directory.as_ref().join(OsString::from(target.to_string())); + let target_is_dir = if target_is_dir_from_archive { + true + } else if let Ok(meta) = std::fs::metadata(&target_path) { + meta.is_dir() + } else { + false + }; + if target_is_dir { + std::os::windows::fs::symlink_dir(target_path, outpath.as_path())?; + } else { + std::os::windows::fs::symlink_file(target_path, outpath.as_path())?; + } + } + continue; + } + let mut file = match &password { + None => archive.by_index(i), + Some(pwd) => archive.by_index_decrypt(i, pwd.as_bytes()), + } + .map_err(|e| e)?; + + // create all pending dirs + while let Some(pending_dir) = pending_directory_creates.pop_front() { + make_writable_dir_all(pending_dir)?; + } + + if let Some(p) = outpath.parent() { + make_writable_dir_all(p)?; + } + + let total = file.size(); + let mut outfile = fs::File::create(&outpath)?; + let mut current = 0; + loop { + futures::executor::block_on(async { + controller + .check() + .await + .map_err(|err| io::Error::new(io::ErrorKind::Other, err)) + })?; + + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + outfile.write_all(&buffer[..count])?; + current += count as u64; + + if current < total { + let file_progress = current as f32 / total as f32; + let total_progress = (i as f32 + file_progress) / total_files as f32; + controller.set_progress(total_progress); + } + } + outfile.sync_all()?; + #[cfg(unix)] + { + // Check for real permissions, which we'll set in a second pass + if let Some(mode) = file.unix_mode() { + files_by_unix_mode.push((outpath.clone(), mode)); + } + } + } + #[cfg(unix)] + { + use std::cmp::Reverse; + use std::os::unix::fs::PermissionsExt; + + if files_by_unix_mode.len() > 1 { + // Ensure we update children's permissions before making a parent unwritable + files_by_unix_mode.sort_by_key(|(path, _)| Reverse(path.clone())); + } + for (path, mode) in files_by_unix_mode.into_iter() { + fs::set_permissions(&path, fs::Permissions::from_mode(mode))?; + } + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 8f285ca..253f627 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ use std::{env, fs, path::PathBuf, process}; use app::{App, Flags}; pub mod app; +mod archive; pub mod clipboard; use config::Config; pub mod config; diff --git a/src/menu.rs b/src/menu.rs index a03d3c1..145a976 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -207,27 +207,10 @@ pub fn context_menu<'a>( children.push(menu_item(fl!("copy"), Action::Copy).into()); children.push(divider::horizontal::light().into()); - let supported_archive_types = [ - "application/gzip", - "application/x-compressed-tar", - "application/x-tar", - "application/zip", - #[cfg(feature = "bzip2")] - "application/x-bzip", - #[cfg(feature = "bzip2")] - "application/x-bzip-compressed-tar", - #[cfg(feature = "bzip2")] - "application/x-bzip2", - #[cfg(feature = "bzip2")] - "application/x-bzip2-compressed-tar", - #[cfg(feature = "xz2")] - "application/x-xz", - #[cfg(feature = "xz2")] - "application/x-xz-compressed-tar", - ] - .iter() - .filter_map(|mime_type| mime_type.parse::().ok()) - .collect::>(); + let supported_archive_types = crate::archive::SUPPORTED_ARCHIVE_TYPES + .iter() + .filter_map(|mime_type| mime_type.parse::().ok()) + .collect::>(); selected_types.retain(|t| !supported_archive_types.contains(t)); if selected_types.is_empty() { children.push(menu_item(fl!("extract-here"), Action::ExtractHere).into()); diff --git a/src/operation/mod.rs b/src/operation/mod.rs index ce2ac6d..e9247a8 100644 --- a/src/operation/mod.rs +++ b/src/operation/mod.rs @@ -2,12 +2,10 @@ use crate::{ app::{ArchiveType, DialogPage, Message}, config::IconSizes, fl, - mime_icon::mime_for_path, spawn_detached::spawn_detached, tab, }; use cosmic::iced::futures::{channel::mpsc::Sender, SinkExt}; -use std::collections::VecDeque; use std::fmt::Formatter; use std::{ borrow::Cow, @@ -18,13 +16,12 @@ use std::{ }; use tokio::sync::{mpsc, Mutex as TokioMutex}; use walkdir::WalkDir; -use zip::result::ZipError; use zip::AesMode::Aes256; pub use self::controller::{Controller, ControllerState}; pub mod controller; -use self::reader::OpReader; +pub use self::reader::OpReader; pub mod reader; use self::recursive::{Context, Method}; @@ -69,17 +66,7 @@ async fn handle_replace( fn get_directory_name(file_name: &str) -> &str { // TODO: Chain with COMPOUND_EXTENSIONS once more formats are supported - const SUPPORTED_EXTENSIONS: &[&str] = &[ - ".tar.bz2", - ".tar.gz", - ".tar.lzma", - ".tar.xz", - ".tgz", - ".tar", - ".zip", - ]; - - for ext in SUPPORTED_EXTENSIONS { + for ext in crate::archive::SUPPORTED_EXTENSIONS { if let Some(stripped) = file_name.strip_suffix(ext) { return stripped; } @@ -87,175 +74,6 @@ fn get_directory_name(file_name: &str) -> &str { file_name } -// From https://docs.rs/zip/latest/zip/read/struct.ZipArchive.html#method.extract, with cancellation and progress added -fn zip_extract>( - archive: &mut zip::ZipArchive, - directory: P, - controller: Controller, - password: Option, -) -> zip::result::ZipResult<()> { - use std::{ffi::OsString, fs}; - use zip::result::ZipError; - - fn make_writable_dir_all>(outpath: T) -> Result<(), ZipError> { - fs::create_dir_all(outpath.as_ref())?; - #[cfg(unix)] - { - // Dirs must be writable until all normal files are extracted - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions( - outpath.as_ref(), - std::fs::Permissions::from_mode( - 0o700 | std::fs::metadata(outpath.as_ref())?.permissions().mode(), - ), - )?; - } - Ok(()) - } - - #[cfg(unix)] - let mut files_by_unix_mode = Vec::new(); - let mut buffer = vec![0; 4 * 1024 * 1024]; - let total_files = archive.len(); - let mut pending_directory_creates = VecDeque::new(); - - for i in 0..total_files { - futures::executor::block_on(async { - controller - .check() - .await - .map_err(|err| io::Error::new(io::ErrorKind::Other, err)) - })?; - - controller.set_progress((i as f32) / total_files as f32); - - let mut file = match &password { - None => archive.by_index(i), - Some(pwd) => archive.by_index_decrypt(i, pwd.as_bytes()), - } - .map_err(|e| e)?; - let filepath = file - .enclosed_name() - .ok_or(ZipError::InvalidArchive("Invalid file path".into()))?; - - let outpath = directory.as_ref().join(filepath); - - if file.is_dir() { - pending_directory_creates.push_back(outpath.clone()); - continue; - } - let symlink_target = if file.is_symlink() && (cfg!(unix) || cfg!(windows)) { - let mut target = Vec::with_capacity(file.size() as usize); - file.read_to_end(&mut target)?; - Some(target) - } else { - None - }; - drop(file); - if let Some(target) = symlink_target { - // create all pending dirs - while let Some(pending_dir) = pending_directory_creates.pop_front() { - make_writable_dir_all(pending_dir)?; - } - - if let Some(p) = outpath.parent() { - make_writable_dir_all(p)?; - } - - #[cfg(unix)] - { - use std::os::unix::ffi::OsStringExt; - let target = OsString::from_vec(target); - std::os::unix::fs::symlink(&target, outpath.as_path())?; - } - #[cfg(windows)] - { - let Ok(target) = String::from_utf8(target) else { - return Err(ZipError::InvalidArchive("Invalid UTF-8 as symlink target")); - }; - let target = target.into_boxed_str(); - let target_is_dir_from_archive = - archive.shared.files.contains_key(&target) && is_dir(&target); - let target_path = directory.as_ref().join(OsString::from(target.to_string())); - let target_is_dir = if target_is_dir_from_archive { - true - } else if let Ok(meta) = std::fs::metadata(&target_path) { - meta.is_dir() - } else { - false - }; - if target_is_dir { - std::os::windows::fs::symlink_dir(target_path, outpath.as_path())?; - } else { - std::os::windows::fs::symlink_file(target_path, outpath.as_path())?; - } - } - continue; - } - let mut file = match &password { - None => archive.by_index(i), - Some(pwd) => archive.by_index_decrypt(i, pwd.as_bytes()), - } - .map_err(|e| e)?; - - // create all pending dirs - while let Some(pending_dir) = pending_directory_creates.pop_front() { - make_writable_dir_all(pending_dir)?; - } - - if let Some(p) = outpath.parent() { - make_writable_dir_all(p)?; - } - - let total = file.size(); - let mut outfile = fs::File::create(&outpath)?; - let mut current = 0; - loop { - futures::executor::block_on(async { - controller - .check() - .await - .map_err(|err| io::Error::new(io::ErrorKind::Other, err)) - })?; - - let count = file.read(&mut buffer)?; - if count == 0 { - break; - } - outfile.write_all(&buffer[..count])?; - current += count as u64; - - if current < total { - let file_progress = current as f32 / total as f32; - let total_progress = (i as f32 + file_progress) / total_files as f32; - controller.set_progress(total_progress); - } - } - outfile.sync_all()?; - #[cfg(unix)] - { - // Check for real permissions, which we'll set in a second pass - if let Some(mode) = file.unix_mode() { - files_by_unix_mode.push((outpath.clone(), mode)); - } - } - } - #[cfg(unix)] - { - use std::cmp::Reverse; - use std::os::unix::fs::PermissionsExt; - - if files_by_unix_mode.len() > 1 { - // Ensure we update children's permissions before making a parent unwritable - files_by_unix_mode.sort_by_key(|(path, _)| Reverse(path.clone())); - } - for (path, mode) in files_by_unix_mode.into_iter() { - fs::set_permissions(&path, fs::Permissions::from_mode(mode))?; - } - } - Ok(()) -} - #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum ReplaceResult { Replace(bool), @@ -1026,65 +844,7 @@ impl Operation { op_sel.ignored.push(path.clone()); op_sel.selected.push(new_dir.clone()); - let controller = controller.clone(); - let mime = mime_for_path(path, None, false); - let password = password.clone(); - match mime.essence_str() { - "application/gzip" | "application/x-compressed-tar" => { - OpReader::new(path, controller) - .map(io::BufReader::new) - .map(flate2::read::GzDecoder::new) - .map(tar::Archive::new) - .and_then(|mut archive| archive.unpack(&new_dir)) - .map_err(OperationError::from_str)? - } - "application/x-tar" => OpReader::new(path, controller) - .map(io::BufReader::new) - .map(tar::Archive::new) - .and_then(|mut archive| archive.unpack(&new_dir)) - .map_err(OperationError::from_str)?, - "application/zip" => fs::File::open(path) - .map(io::BufReader::new) - .map(zip::ZipArchive::new) - .map_err(OperationError::from_str)? - .and_then(move |mut archive| { - zip_extract(&mut archive, &new_dir, controller, password) - }) - .map_err(|e| match e { - ZipError::UnsupportedArchive( - ZipError::PASSWORD_REQUIRED, - ) - | ZipError::InvalidPassword => OperationError { - kind: OperationErrorType::PasswordRequired, - }, - _ => OperationError::from_str(e), - })?, - #[cfg(feature = "bzip2")] - "application/x-bzip" - | "application/x-bzip-compressed-tar" - | "application/x-bzip2" - | "application/x-bzip2-compressed-tar" => { - OpReader::new(path, controller) - .map(io::BufReader::new) - .map(bzip2::read::BzDecoder::new) - .map(tar::Archive::new) - .and_then(|mut archive| archive.unpack(&new_dir)) - .map_err(OperationError::from_str)? - } - #[cfg(feature = "xz2")] - "application/x-xz" | "application/x-xz-compressed-tar" => { - OpReader::new(path, controller) - .map(io::BufReader::new) - .map(xz2::read::XzDecoder::new) - .map(tar::Archive::new) - .and_then(|mut archive| archive.unpack(&new_dir)) - .map_err(OperationError::from_str)? - } - _ => Err(OperationError::from_str(format!( - "unsupported mime type {:?}", - mime - )))?, - } + crate::archive::extract(path, &new_dir, &password, &controller)?; } }