diff --git a/Cargo.lock b/Cargo.lock index 4bf2540bdb158bbca43e357124991b1605b8152a..d2d1facf2b5bdcc0ebb96a68fe27735f90c5a3e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -646,6 +646,7 @@ dependencies = [ "lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "open 1.2.2 (registry+https://github.com/rust-lang/crates.io-index)", "openssl-probe 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", + "pathdiff 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "pbr 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "prettytable-rs 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", "qr2term 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1251,6 +1252,11 @@ dependencies = [ "winapi 0.3.7 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "pathdiff" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "pbr" version = "1.0.1" @@ -2433,6 +2439,7 @@ dependencies = [ "checksum owning_ref 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "49a4b8ea2179e6a2e27411d3bca09ca6dd630821cf6894c6c7c8467a8ee7ef13" "checksum parking_lot 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ab41b4aed082705d1056416ae4468b6ea99d52599ecf3169b00088d43113e337" "checksum parking_lot_core 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "94c8c7923936b28d546dfd14d4472eaf34c99b14e1c973a32b3e6d4eb04298c9" +"checksum pathdiff 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "a3bf70094d203e07844da868b634207e71bfab254fe713171fae9a6e751ccf31" "checksum pbr 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "deb73390ab68d81992bd994d145f697451bb0b54fd39738e72eef32458ad6907" "checksum percent-encoding 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "31010dd2e1ac33d5b46a5b413495239882813e0369f8ed8a5e266f173602f831" "checksum phf 0.7.24 (registry+https://github.com/rust-lang/crates.io-index)" = "b3da44b85f8e8dfaec21adae67f95d93244b2ecf6ad2a692320598dcc8e6dd18" diff --git a/Cargo.toml b/Cargo.toml index 2e973a3a1984c03d59946c17341f1b389304717b..db3d89cb11b9bfaf662090fd3a294f3a2222cbe7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,7 @@ fs2 = "0.4" lazy_static = "1.0" open = "1" openssl-probe = "0.1" +pathdiff = "0.1" pbr = "1" prettytable-rs = "0.8" qr2term = { version = "0.1", optional = true } diff --git a/src/action/upload.rs b/src/action/upload.rs index 6837b603d43e5d612bea62dc82f271f4832fa590..ecd34cdc6cae69cfbc26a008a98dc4ff65c13ace 100644 --- a/src/action/upload.rs +++ b/src/action/upload.rs @@ -1,6 +1,11 @@ +use std::env::current_dir; #[cfg(feature = "archive")] use std::io::Error as IoError; use std::path::Path; +#[cfg(feature = "archive")] +use std::path::PathBuf; +#[cfg(feature = "archive")] +use std::process::exit; use std::sync::{Arc, Mutex}; use clap::ArgMatches; @@ -10,6 +15,7 @@ use ffsend_api::action::upload::{Error as UploadError, Upload as ApiUpload}; use ffsend_api::action::version::Error as VersionError; use ffsend_api::config::{upload_size_max, UPLOAD_SIZE_MAX_RECOMMENDED}; use ffsend_api::pipe::ProgressReporter; +use pathdiff::diff_paths; use prettytable::{format::FormatBuilder, Cell, Row, Table}; #[cfg(feature = "qrcode")] use qr2term::print_qr; @@ -53,9 +59,183 @@ impl<'a> Upload<'a> { // Get API parameters #[allow(unused_mut)] - let mut path = Path::new(matcher_upload.file()).to_path_buf(); + let mut paths: Vec<_> = matcher_upload + .files() + .into_iter() + .map(|p| Path::new(p).to_path_buf()) + .collect(); + let mut path = Path::new(paths.first().unwrap()).to_path_buf(); let host = matcher_upload.host(); + // The file name to use + #[allow(unused_mut)] + let mut file_name = matcher_upload.name().map(|s| s.to_owned()); + + // All paths must exist + // TODO: ensure the file exists and is accessible + for path in &paths { + if !path.exists() { + quit_error_msg( + format!("the path '{}' does not exist", path.to_str().unwrap_or("?")), + ErrorHintsBuilder::default().build().unwrap(), + ); + } + } + + // A temporary archive file, only used when archiving + // The temporary file is stored here, to ensure it's lifetime exceeds the upload process + #[allow(unused_mut)] + #[cfg(feature = "archive")] + let mut tmp_archive: Option<NamedTempFile> = None; + + #[cfg(feature = "archive")] + { + // Determine whether to archive, we must archive for multiple files/directory + let mut archive = matcher_upload.archive(); + if !archive { + if paths.len() > 1 { + if prompt_yes( + "You've selected multiple files, only a single file may be uploaded.\n\ + Archive the files into a single file?", + Some(true), + &matcher_main, + ) { + archive = true; + } else { + exit(1); + } + } else if path.is_dir() { + if prompt_yes( + "You've selected a directory, only a single file may be uploaded.\n\ + Archive the directory into a single file?", + Some(true), + &matcher_main, + ) { + archive = true; + } else { + exit(1); + } + } + } + + // Archive the selected file or directory + if archive { + eprintln!("Archiving..."); + let archive_extention = ".tar"; + + // Create a new temporary file to write the archive to + tmp_archive = Some( + TempBuilder::new() + .prefix(&format!(".{}-archive-", crate_name!())) + .suffix(archive_extention) + .tempfile() + .map_err(ArchiveError::TempFile)?, + ); + if let Some(tmp_archive) = &tmp_archive { + // Get the path, and the actual file + let archive_path = tmp_archive.path().to_path_buf(); + let archive_file = tmp_archive + .as_file() + .try_clone() + .map_err(ArchiveError::CloneHandle)?; + + // Select the file name to use if not set + if file_name.is_none() { + // Require user to specify name if multiple files are given + if paths.len() > 1 { + quit_error_msg( + "you must specify a file name for the archive", + ErrorHintsBuilder::default() + .name(true) + .verbose(false) + .build() + .unwrap(), + ); + } + + // Derive name from given file + file_name = Some( + path.canonicalize() + .map_err(|err| ArchiveError::FileName(Some(err)))? + .file_name() + .ok_or(ArchiveError::FileName(None))? + .to_str() + .map(|s| s.to_owned()) + .ok_or(ArchiveError::FileName(None))?, + ); + } + + // Get the current working directory, including working directory as highest possible root, canonicalize it + let working_dir = + current_dir().expect("failed to get current working directory"); + let shared_dir = { + let mut paths = paths.clone(); + paths.push(working_dir.clone()); + match shared_dir(paths) { + Some(p) => p, + None => quit_error_msg( + "when archiving, all files must be within a same directory", + ErrorHintsBuilder::default().verbose(false).build().unwrap(), + ), + } + }; + + // Build an archiver, append each file + let mut archiver = Archiver::new(archive_file); + for path in &paths { + // Canonicalize the path + let mut path = Path::new(path).to_path_buf(); + if let Ok(p) = path.canonicalize() { + path = p; + } + + // Find relative name to share dir, used to derive name from + let name = diff_paths(&path, &shared_dir) + .expect("failed to determine relative path of file to archive"); + let name = name.to_str().expect("failed to get file path"); + + // Add file to archiver + archiver + .append_path(name, &path) + .map_err(ArchiveError::AddFile)?; + } + + // Finish the archival process, writes the archive file + archiver.finish().map_err(ArchiveError::Write)?; + + // Append archive extention to name, set to upload archived file + if let Some(ref mut file_name) = file_name { + file_name.push_str(archive_extention); + } + path = archive_path; + paths.clear(); + } + } + } + + // Quit with error when uploading multiple files or directory, if we cannot archive + #[cfg(not(feature = "archive"))] + { + if paths.len() > 1 { + quit_error_msg( + "uploading multiple files is not supported, ffsend must be compiled with 'archive' feature for this", + ErrorHintsBuilder::default() + .verbose(false) + .build() + .unwrap(), + ); + } + if path.is_dir() { + quit_error_msg( + "uploading a directory is not supported, ffsend must be compiled with 'archive' feature for this", + ErrorHintsBuilder::default() + .verbose(false) + .build() + .unwrap(), + ); + } + } + // Create a reqwest client capable for uploading files let client_config = create_config(&matcher_main); let client = client_config.clone().client(false); @@ -65,8 +245,6 @@ impl<'a> Upload<'a> { select_api_version(&client, host.clone(), &mut desired_version)?; let api_version = desired_version.version().unwrap(); - // TODO: ensure the file exists and is accessible - // We do not authenticate for now let auth = false; @@ -155,83 +333,6 @@ impl<'a> Upload<'a> { } }; - // The file name to use - #[allow(unused_mut)] - let mut file_name = matcher_upload.name().map(|s| s.to_owned()); - - // A temporary archive file, only used when archiving - // The temporary file is stored here, to ensure it's lifetime exceeds the upload process - #[allow(unused_mut)] - #[cfg(feature = "archive")] - let mut tmp_archive: Option<NamedTempFile> = None; - - #[cfg(feature = "archive")] - { - // Determine whether to archive, ask if a directory was selected - let mut archive = matcher_upload.archive(); - if !archive && path.is_dir() { - if prompt_yes( - "You've selected a directory, only a single file may be uploaded.\n\ - Archive the directory into a single file?", - Some(true), - &matcher_main, - ) { - archive = true; - } - } - - // Archive the selected file or directory - if archive { - eprintln!("Archiving..."); - let archive_extention = ".tar"; - - // Create a new temporary file to write the archive to - tmp_archive = Some( - TempBuilder::new() - .prefix(&format!(".{}-archive-", crate_name!())) - .suffix(archive_extention) - .tempfile() - .map_err(ArchiveError::TempFile)?, - ); - if let Some(tmp_archive) = &tmp_archive { - // Get the path, and the actual file - let archive_path = tmp_archive.path().to_path_buf(); - let archive_file = tmp_archive - .as_file() - .try_clone() - .map_err(ArchiveError::CloneHandle)?; - - // Select the file name to use if not set - if file_name.is_none() { - file_name = Some( - path.canonicalize() - .map_err(|err| ArchiveError::FileName(Some(err)))? - .file_name() - .ok_or(ArchiveError::FileName(None))? - .to_str() - .map(|s| s.to_owned()) - .ok_or(ArchiveError::FileName(None))?, - ); - } - - // Build an archiver and append the file - let mut archiver = Archiver::new(archive_file); - archiver - .append_path(file_name.as_ref().unwrap(), &path) - .map_err(ArchiveError::AddFile)?; - - // Finish the archival process, writes the archive file - archiver.finish().map_err(ArchiveError::Write)?; - - // Append archive extention to name, set to upload archived file - if let Some(ref mut file_name) = file_name { - file_name.push_str(archive_extention); - } - path = archive_path; - } - } - } - // Build the progress reporter let progress_reporter: Arc<Mutex<ProgressReporter>> = progress_bar; @@ -376,6 +477,74 @@ impl<'a> Upload<'a> { } } +/// Find the deepest directory all given paths share. +/// +/// This function canonicalizes the paths, make sure the paths exist. +/// +/// Returns `None` if paths are using a different root. +/// +/// # Examples +/// +/// If the following paths are given: +/// +/// - `/home/user/git/ffsend/src` +/// - `/home/user/git/ffsend/src/main.rs` +/// - `/home/user/git/ffsend/Cargo.toml` +/// +/// The following is returned: +/// +/// `/home/user/git/ffsend` +#[cfg(feature = "archive")] +fn shared_dir(paths: Vec<PathBuf>) -> Option<PathBuf> { + // Any path must be given + if paths.is_empty() { + return None; + } + + // Build vector + let c: Vec<Vec<PathBuf>> = paths + .into_iter() + .map(|p| p.canonicalize().expect("failed to canonicalize path")) + .map(|mut p| { + // Start with parent if current path is file + if p.is_file() { + p = match p.parent() { + Some(p) => p.to_path_buf(), + None => return vec![], + }; + } + + // Build list of path buffers for each path component + let mut items = vec![p]; + #[allow(mutable_borrow_reservation_conflict)] + while let Some(item) = items.last().unwrap().parent() { + items.push(item.to_path_buf()); + } + + // Reverse as we built it in the wrong order + items.reverse(); + items + }) + .collect(); + + // Find the index at which the paths are last shared at by walking through indices + let i = (0..) + .take_while(|i| { + // Get path for first item, stop if none + let base = &c[0].get(*i); + if base.is_none() { + return false; + }; + + // All other paths must equal at this index + c.iter().skip(1).all(|p| &p.get(*i) == base) + }) + .last(); + + // Find the shared path + i.map(|i| c[0][i].to_path_buf()) +} + #[derive(Debug, Fail)] pub enum Error { /// Selecting the API version to use failed. diff --git a/src/cmd/matcher/upload.rs b/src/cmd/matcher/upload.rs index 35ce5120ffe170d6c28e8ed8f8eba2238c37e017..b9a9d89a72b90b7d6395d3c6e4949c62a214c4d7 100644 --- a/src/cmd/matcher/upload.rs +++ b/src/cmd/matcher/upload.rs @@ -18,10 +18,11 @@ pub struct UploadMatcher<'a> { impl<'a: 'b, 'b> UploadMatcher<'a> { /// Get the selected file to upload. // TODO: maybe return a file or path instance here - pub fn file(&'a self) -> &'a str { + pub fn files(&'a self) -> Vec<&'a str> { self.matches - .value_of("FILE") + .values_of("FILE") .expect("no file specified to upload") + .collect() } /// The the name to use for the uploaded file. diff --git a/src/cmd/subcmd/upload.rs b/src/cmd/subcmd/upload.rs index 4c39d7ecb0a9e3ad9b6d567583d880d5adbbd5f1..b62ad2d82d2f4ed9ac24171c380464e8555482d3 100644 --- a/src/cmd/subcmd/upload.rs +++ b/src/cmd/subcmd/upload.rs @@ -16,9 +16,9 @@ impl CmdUpload { .visible_alias("up") .arg( Arg::with_name("FILE") - .help("The file to upload") + .help("The file(s) to upload") .required(true) - .multiple(false), + .multiple(true), ) .arg(ArgPassword::build().help("Protect the file with a password")) .arg(ArgGenPassphrase::build()) diff --git a/src/util.rs b/src/util.rs index d3ecb6f142ccbd4b8dfc7a07bb8e376a2b8a7593..67176be26c91be1ab5ba3203a4db2c9775bf9eb2 100644 --- a/src/util.rs +++ b/src/util.rs @@ -132,6 +132,9 @@ pub struct ErrorHints { /// A list of info messages to print along with the error. info: Vec<String>, + /// Show about the name option. + name: bool, + /// Show about the password option. password: bool, @@ -157,7 +160,8 @@ impl ErrorHints { pub fn any(&self) -> bool { // Determine the result #[allow(unused_mut)] - let mut result = self.password || self.owner || self.force || self.verbose || self.help; + let mut result = + self.name || self.password || self.owner || self.force || self.verbose || self.help; // Factor in the history hint when enabled #[cfg(feature = "history")] @@ -189,6 +193,12 @@ impl ErrorHints { highlight("--api <VERSION>") ); } + if self.name { + eprintln!( + "Use '{}' to specify a file name", + highlight("--name <NAME>") + ); + } if self.password { eprintln!( "Use '{}' to specify a password", @@ -230,6 +240,7 @@ impl Default for ErrorHints { ErrorHints { api: false, info: Vec::new(), + name: false, password: false, owner: false, #[cfg(feature = "history")]