diff --git a/ares-pull/README.md b/ares-pull/README.md index 57e11c0..7882b43 100644 --- a/ares-pull/README.md +++ b/ares-pull/README.md @@ -17,13 +17,42 @@ Arguments: Options: -d, --device Specify DEVICE to use [env: ARES_DEVICE=] - -i, --ignore Continue on errors instead of stopping at the first failure + -i, --ignore Hide the detailed copy messages + -k, --keep-going Continue on errors instead of stopping at the first failure -h, --help Print help ``` +## Where files land + +The layout comes from what SOURCE is on the device and what DESTINATION already +is on your computer, the same way `@webosose/ares-cli` decides it. A trailing +`/` changes nothing. + +| SOURCE on the device | DESTINATION on the host | Result | +|----------------------|-------------------------|-----------------------| +| a directory | anything but a file | `DESTINATION/` | +| a directory | a file | error | +| a file | a directory | `DESTINATION/` | +| a file | missing, or a file | `DESTINATION` | + +Missing parent directories are made along the way. + +A directory keeps its own name, so `ares-pull /var/log ./out` puts it at +`./out/log`. + ## Examples ```sh ares-pull -d tv /var/log/messages ares-pull -d tv /media/developer/apps ./backup ``` + +## Differences from @webosose/ares-cli + +- `-k, --keep-going` skips a file that fails and goes on. The original always + stops at the first failure. The exit code is still non-zero. +- Pulling a file to a path whose parent does not exist works. The original + fails with `ENOENT`. +- Symlinks are followed, as in the original. A broken symlink is skipped with a + message, and nesting past 64 levels stops with an error instead of looping. +- The copy runs over SFTP, so the device needs no `find` binary. diff --git a/ares-pull/src/main.rs b/ares-pull/src/main.rs index bd89603..1161dd1 100644 --- a/ares-pull/src/main.rs +++ b/ares-pull/src/main.rs @@ -7,7 +7,7 @@ use ares_connection_lib::session::NewSession; use ares_device_lib::DeviceManager; use ares_device_lib::cli::unwrap_or_exit; use clap::Parser; -use libssh_rs::{FileType, OpenFlags, Sftp}; +use libssh_rs::{Error as SshError, FileType, OpenFlags, Sftp}; #[derive(Parser, Debug)] #[command(about)] @@ -20,12 +20,14 @@ struct Cli { help = "Specify DEVICE to use" )] device: Option, + #[arg(short, long, help = "Hide the detailed copy messages")] + ignore: bool, #[arg( short, long, help = "Continue on errors instead of stopping at the first failure" )] - ignore: bool, + keep_going: bool, #[arg( value_name = "SOURCE", help = "Path on the DEVICE, where files exist", @@ -40,6 +42,10 @@ struct Cli { destination: String, } +/// Directory nesting we refuse to go past. Symlinks are followed, so a link +/// that points at a parent would otherwise never end. +const MAX_DEPTH: usize = 64; + fn main() { let cli = Cli::parse(); let manager = DeviceManager::default(); @@ -51,102 +57,249 @@ fn main() { let session = unwrap_or_exit(device.new_session(), &format!("connect to {}", device.name)); let sftp = unwrap_or_exit(session.sftp(), "start SFTP"); - let target = resolve_target(&cli.source, &cli.destination); - if let Err(e) = pull(&sftp, &cli.source, &target, cli.ignore) { + let mut pull = Pull { + sftp: &sftp, + quiet: cli.ignore, + keep_going: cli.keep_going, + failed: false, + }; + if let Err(e) = pull.run(&cli.source, &cli.destination) { eprintln!("Failed to pull: {e}"); exit(1); } + if pull.failed { + exit(1); + } } -/// Maps the remote `source` onto a local destination path. Mirrors ares-push: -/// a trailing "/" on the destination keeps the source's last path component, -/// otherwise the source maps directly onto the destination. -fn resolve_target(source: &str, destination: &str) -> PathBuf { - let source = Path::new(source); - let dest_base = Path::new(destination); - let source_prefix = if destination.ends_with('/') { - source.parent().unwrap_or(source) - } else { - source - }; - match source.strip_prefix(source_prefix) { - Ok(relative) if !relative.as_os_str().is_empty() => dest_base.join(relative), - _ => dest_base.to_path_buf(), - } +struct Pull<'a> { + sftp: &'a Sftp, + quiet: bool, + keep_going: bool, + /// Set when --keep-going swallowed a failure, so the exit code still says so. + failed: bool, } -/// Recursively pulls `remote` into the local path `local`. -fn pull(sftp: &Sftp, remote: &str, local: &Path, ignore: bool) -> Result<(), Error> { - let metadata = sftp.symlink_metadata(remote).map_err(to_io)?; - match metadata.file_type() { - Some(FileType::Symlink) => { - eprintln!("Skipping symlink {remote}"); - Ok(()) +impl Pull<'_> { + fn run(&mut self, source: &str, destination: &str) -> Result<(), Error> { + let source_is_dir = is_dir(self.sftp, source) + .map_err(|e| Error::new(e.kind(), format!("SOURCE {source}: {e}")))?; + let target = resolve_target( + source, + destination, + source_is_dir, + Path::new(destination).is_dir(), + ); + if source_is_dir && target.exists() && !target.is_dir() { + return Err(Error::new( + ErrorKind::AlreadyExists, + format!("{} is not a directory", target.display()), + )); } - Some(FileType::Directory) => pull_dir(sftp, remote, local, ignore), - _ => pull_file(sftp, remote, local), + self.copy(source, &target, 0) } -} -fn pull_dir(sftp: &Sftp, remote: &str, local: &Path, ignore: bool) -> Result<(), Error> { - create_dir_all(local)?; - println!("{remote} => {}", local.display()); - for entry in sftp.read_dir(remote).map_err(to_io)? { - let Some(name) = entry.name() else { continue }; - if name == "." || name == ".." { - continue; + fn copy(&mut self, remote: &str, local: &Path, depth: usize) -> Result<(), Error> { + let file_type = match self.sftp.metadata(remote).map(|m| m.file_type()) { + Ok(file_type) => file_type, + Err(e) => { + // ares-cli walks with `find -follow`, which lists a broken + // symlink as neither a file nor a directory and skips it. + if matches!( + self.sftp.symlink_metadata(remote).map(|m| m.file_type()), + Ok(Some(FileType::Symlink)) + ) { + eprintln!("Skipping {remote}: it is a broken symlink"); + return Ok(()); + } + return Err(sftp_error(remote, &e)); + } + }; + if file_type == Some(FileType::Directory) { + if depth >= MAX_DEPTH { + return Err(Error::new( + ErrorKind::InvalidData, + format!("{remote} is nested too deep, which usually means a symlink loop"), + )); + } + self.copy_dir(remote, local, depth) + } else { + self.copy_file(remote, local) } - let child_remote = format!("{}/{name}", remote.trim_end_matches('/')); - let child_local = local.join(name); - if let Err(e) = pull(sftp, &child_remote, &child_local, ignore) { - if ignore { - eprintln!("Skipping {child_remote}: {e}"); - } else { - return Err(e); + } + + fn copy_dir(&mut self, remote: &str, local: &Path, depth: usize) -> Result<(), Error> { + let sftp = self.sftp; + create_dir_all(local)?; + self.report(remote, local); + for entry in sftp.read_dir(remote).map_err(|e| sftp_error(remote, &e))? { + let Some(name) = entry.name() else { continue }; + if name == "." || name == ".." { + continue; + } + let child_remote = format!("{}/{name}", remote.trim_end_matches('/')); + let child_local = local.join(name); + if let Err(e) = self.copy(&child_remote, &child_local, depth + 1) { + self.item_failed(&child_remote, e)?; } } + Ok(()) + } + + fn copy_file(&mut self, remote: &str, local: &Path) -> Result<(), Error> { + if let Some(parent) = local.parent() { + create_dir_all(parent)?; + } + self.report(remote, local); + let mut remote_file = self + .sftp + .open(remote, OpenFlags::READ_ONLY, 0) + .map_err(|e| sftp_error(remote, &e))?; + let mut local_file = File::create(local)?; + std::io::copy(&mut remote_file, &mut local_file)?; + Ok(()) + } + + fn report(&self, remote: &str, local: &Path) { + if !self.quiet { + println!("{remote} => {}", local.display()); + } + } + + /// Handle a failure on one item. Returns the error to stop the whole copy, + /// or Ok to go on when --keep-going is set. + fn item_failed(&mut self, what: &str, e: Error) -> Result<(), Error> { + if !self.keep_going { + return Err(e); + } + eprintln!("Skipping {what}: {e}"); + self.failed = true; + Ok(()) } - Ok(()) } -fn pull_file(sftp: &Sftp, remote: &str, local: &Path) -> Result<(), Error> { - if let Some(parent) = local.parent() { - create_dir_all(parent)?; +/// Where SOURCE lands on the host. +/// +/// This follows ares-cli: a directory always keeps its own name under +/// DESTINATION, and a file keeps its name only when DESTINATION already is a +/// directory. A trailing "/" changes nothing. +fn resolve_target( + source: &str, + destination: &str, + source_is_dir: bool, + dest_is_dir: bool, +) -> PathBuf { + let dest = Path::new(destination); + if !source_is_dir && !dest_is_dir { + return dest.to_path_buf(); + } + match remote_name(source) { + Some(name) => dest.join(name), + None => dest.to_path_buf(), + } +} + +/// Last component of a device path, which always uses "/". Returns None for a +/// path with no name of its own ("/", "." and ".."), where the copy goes +/// straight into DESTINATION. +fn remote_name(path: &str) -> Option<&str> { + let name = path.trim_end_matches('/').rsplit('/').next()?; + if name.is_empty() || name == "." || name == ".." { + return None; + } + Some(name) +} + +/// True when `path` is a directory on the device. ares-cli tests with `[ -f ]` +/// and `[ -d ]`, which both follow symlinks, so follow them here too. +fn is_dir(sftp: &Sftp, path: &str) -> Result { + let metadata = sftp.metadata(path).map_err(|e| sftp_error(path, &e))?; + Ok(metadata.file_type() == Some(FileType::Directory)) +} + +/// Recover the numeric SFTP status code from a libssh error. `SftpError`'s code +/// field is private, so parse it out of the Display text ("Sftp error code N"). +/// Returns `None` for non-SFTP errors. +fn sftp_status(e: &SshError) -> Option { + if !matches!(e, SshError::Sftp(_)) { + return None; + } + e.to_string().rsplit(' ').next()?.parse().ok() +} + +/// Human-readable reason for an SFTP status code (subset of `SSH_FX_*` codes). +fn sftp_reason(code: u32) -> &'static str { + match code { + 2 => "no such file or directory", + 3 => "permission denied", + 4 => "failure", + 8 => "operation not supported", + _ => "SFTP error", } - println!("{remote} => {}", local.display()); - let mut remote_file = sftp.open(remote, OpenFlags::READ_ONLY, 0).map_err(to_io)?; - let mut local_file = File::create(local)?; - std::io::copy(&mut remote_file, &mut local_file)?; - Ok(()) } -fn to_io(error: libssh_rs::Error) -> Error { - Error::new(ErrorKind::Other, error.to_string()) +fn sftp_error(path: &str, e: &SshError) -> Error { + match sftp_status(e) { + Some(2) => Error::new( + ErrorKind::NotFound, + format!("{path} does not exist on the device"), + ), + Some(3) => Error::new( + ErrorKind::PermissionDenied, + format!("{path}: permission denied"), + ), + Some(code) => Error::other(format!("{path}: {} (SFTP code {code})", sftp_reason(code))), + None => Error::other(format!("{path}: {e}")), + } } #[cfg(test)] mod tests { - use super::resolve_target; + use super::{remote_name, resolve_target}; - fn target(source: &str, destination: &str) -> String { - resolve_target(source, destination) + fn target(source: &str, destination: &str, source_is_dir: bool, dest_is_dir: bool) -> String { + resolve_target(source, destination, source_is_dir, dest_is_dir) .to_string_lossy() .replace('\\', "/") } #[test] - fn file_maps_onto_destination() { - assert_eq!(target("/remote/f.txt", "out.txt"), "out.txt"); + fn a_file_takes_the_destination_name() { + assert_eq!(target("/remote/f.txt", "out.txt", false, false), "out.txt"); + } + + #[test] + fn a_file_keeps_its_name_under_a_directory() { + assert_eq!(target("/remote/f.txt", "out", false, true), "out/f.txt"); + } + + #[test] + fn a_directory_always_keeps_its_own_name() { + // The point of the ares-cli rule: "dir" lands as out/dir, whether or not + // "out" already exists. + assert_eq!(target("/remote/dir", "out", true, true), "out/dir"); + assert_eq!(target("/remote/dir", "out", true, false), "out/dir"); + } + + #[test] + fn a_trailing_slash_changes_nothing() { + assert_eq!(target("/remote/dir/", "out", true, true), "out/dir"); + assert_eq!(target("/remote/dir", "out/", true, true), "out/dir"); + assert_eq!(target("/remote/f.txt", "out/", false, true), "out/f.txt"); } #[test] - fn trailing_slash_keeps_last_component() { - assert_eq!(target("/remote/f.txt", "dir/"), "dir/f.txt"); - assert_eq!(target("/remote/dir", "out/"), "out/dir"); + fn a_source_with_no_name_copies_its_contents() { + assert_eq!(remote_name("/"), None); + assert_eq!(remote_name("."), None); + assert_eq!(remote_name(".."), None); + assert_eq!(target("/", "out", true, true), "out"); } #[test] - fn directory_contents_map_into_destination() { - assert_eq!(target("/remote/dir", "out"), "out"); + fn names_come_from_the_last_component() { + assert_eq!(remote_name("/var/log/messages"), Some("messages")); + assert_eq!(remote_name("/var/log/"), Some("log")); + assert_eq!(remote_name("messages"), Some("messages")); } } diff --git a/ares-push/README.md b/ares-push/README.md index 0ead6da..2e81c7d 100644 --- a/ares-push/README.md +++ b/ares-push/README.md @@ -17,16 +17,43 @@ Arguments: Options: -d, --device Specify DEVICE to use [env: ARES_DEVICE=] - -i, --ignore Continue on errors instead of stopping at the first failure + -i, --ignore Hide the detailed copy messages + -k, --keep-going Continue on errors instead of stopping at the first failure -h, --help Print help ``` -Directories are copied with their contents. Without `--ignore`, the first -failure stops the copy and the exit code is non-zero. +## Where files land + +The layout comes from what DESTINATION already is on the device, the same way +`@webosose/ares-cli` decides it. A trailing `/` changes nothing. + +| SOURCE | DESTINATION on the device | Result | +|-------------|---------------------------|---------------------------| +| `build` | missing, or a directory | `DESTINATION/build` | +| `build` | a file | error | +| `a.txt` | a directory | `DESTINATION/a.txt` | +| `a.txt` | missing, or a file | `DESTINATION` | +| `a.txt b.txt` | anything but a file | `DESTINATION/a.txt`, `DESTINATION/b.txt` | + +Missing parent directories are made along the way, like `mkdir -p`. + +A directory keeps its own name, so `ares-push build /media/developer/apps` puts +it at `/media/developer/apps/build`. To copy the contents instead of the +directory, run the command from inside it and push `.`, the way `cp -r . DEST` +works. ## Examples ```sh ares-push -d tv ./build /media/developer/apps/usr/palm/applications -ares-push -d tv --ignore ./a.txt ./b.txt /tmp +ares-push -d tv --keep-going ./a.txt ./b.txt /tmp ``` + +## Differences from @webosose/ares-cli + +- `-k, --keep-going` skips a file that fails and goes on. The original always + stops at the first failure. The exit code is still non-zero. +- A symlink to a file is copied as its content, as in the original. A symlink to + a directory is skipped with a message instead of failing the copy. +- A missing parent of DESTINATION is made over SFTP, so the device needs no + `mkdir` binary. diff --git a/ares-push/src/main.rs b/ares-push/src/main.rs index f05b98e..4dc20cd 100644 --- a/ares-push/src/main.rs +++ b/ares-push/src/main.rs @@ -1,13 +1,16 @@ +use std::collections::HashSet; +use std::ffi::OsString; use std::fs::File; -use std::path::{Path, PathBuf}; +use std::io::{Error, ErrorKind}; +use std::path::{Component, Path, PathBuf}; use std::process::exit; use ares_connection_lib::session::NewSession; use ares_device_lib::DeviceManager; use ares_device_lib::cli::unwrap_or_exit; use clap::Parser; -use libssh_rs::{Error as SshError, OpenFlags}; -use path_slash::PathBufExt; +use libssh_rs::{Error as SshError, FileType, OpenFlags, Sftp}; +use path_slash::PathExt; use walkdir::WalkDir; #[derive(Parser, Debug)] @@ -21,12 +24,14 @@ struct Cli { help = "Specify DEVICE to use" )] device: Option, + #[arg(short, long, help = "Hide the detailed copy messages")] + ignore: bool, #[arg( short, long, help = "Continue on errors instead of stopping at the first failure" )] - ignore: bool, + keep_going: bool, #[arg( value_name = "SOURCE", help = "Path in the host machine, where files exist.", @@ -41,25 +46,13 @@ struct Cli { destination: String, } -/// Recover the numeric SFTP status code from a libssh error. `SftpError`'s code -/// field is private, so parse it out of the Display text ("Sftp error code N"). -/// Returns `None` for non-SFTP errors. -fn sftp_status(e: &SshError) -> Option { - if !matches!(e, SshError::Sftp(_)) { - return None; - } - e.to_string().rsplit(' ').next()?.parse().ok() -} - -/// Human-readable reason for an SFTP status code (subset of SSH_FX_* codes). -fn sftp_reason(code: u32) -> &'static str { - match code { - 2 => "no such file or directory", - 3 => "permission denied", - 4 => "failure", - 8 => "operation not supported", - _ => "SFTP error", - } +/// What DESTINATION already is on the device. ares-cli reads the layout from +/// this, not from a trailing "/". +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DestKind { + Dir, + File, + Missing, } fn main() { @@ -78,85 +71,364 @@ fn main() { exit(1); } }; - for source in cli.source { - let walker = WalkDir::new(&source).contents_first(false); - let dest_base = Path::new(&cli.destination); - let mut source_prefix: &Path = &source; - if cli.destination.ends_with("/") { - if let Some(parent) = source_prefix.parent() { - source_prefix = parent; + + let dest_kind = dest_kind(&sftp, &cli.destination); + let single = cli.source.len() == 1; + if dest_kind == DestKind::File && !single { + eprintln!( + "Failed to push: {} is a file, so it can hold only one SOURCE", + cli.destination + ); + exit(1); + } + + let mut push = Push { + sftp: &sftp, + quiet: cli.ignore, + keep_going: cli.keep_going, + made_dirs: HashSet::new(), + failed: false, + }; + for source in &cli.source { + if let Err(e) = push.source(source, &cli.destination, dest_kind, single) { + eprintln!("Failed to push {}: {e}", source.display()); + if !push.keep_going { + exit(1); } + push.failed = true; } - for entry in walker { - match entry { - Ok(entry) => { - let file_type = entry.file_type(); - let dest_path = - dest_base.join(entry.path().strip_prefix(source_prefix).unwrap()); - let dest_display = dest_path.to_slash_lossy(); - if file_type.is_dir() { - println!("{} => {}", entry.path().to_string_lossy(), dest_display); - // A directory that already exists reports an error we can - // safely ignore; a genuine failure surfaces when we try to - // write a file into it below. - sftp.create_dir(dest_display.as_ref(), 0o755).unwrap_or(()); - } else if file_type.is_file() { - println!("{} => {}", entry.path().to_string_lossy(), dest_display); - let mut file = match sftp.open( - dest_display.as_ref(), - OpenFlags::WRITE_ONLY | OpenFlags::CREATE | OpenFlags::TRUNCATE, - 0o644, - ) { - Ok(file) => file, - Err(e) => { - match sftp_status(&e) { - Some(code) => { - eprintln!( - "Failed to write {dest_display}: {} (SFTP code {code})", - sftp_reason(code) - ); - if code == 3 { - eprintln!( - " The destination may not be writable on this \ - device; try a different path." - ); - } - } - None => eprintln!("Failed to write {dest_display}: {e}"), - } - if !cli.ignore { - exit(1); - } - continue; - } - }; - let mut loc_file = match File::open(entry.path()) { - Ok(loc_file) => loc_file, - Err(e) => { - eprintln!("Failed to read {}: {e}", entry.path().to_string_lossy()); - if !cli.ignore { - exit(1); - } - continue; - } - }; - if let Err(e) = std::io::copy(&mut loc_file, &mut file) { - eprintln!("Failed to write {dest_display}: {e}"); - if !cli.ignore { - exit(1); - } - } - } else if file_type.is_symlink() { - eprintln!("Skipping symlink {}", entry.path().to_string_lossy()); - } - } + } + if push.failed { + exit(1); + } +} + +struct Push<'a> { + sftp: &'a Sftp, + quiet: bool, + keep_going: bool, + /// Device paths we already made, so a run does not stat the same directory + /// once per file. + made_dirs: HashSet, + /// Set when --keep-going swallowed a failure, so the exit code still says so. + failed: bool, +} + +impl Push<'_> { + /// Copy one SOURCE. `dest` is DESTINATION as typed, `kind` what it already + /// is on the device, and `single` whether it is the only SOURCE. + fn source( + &mut self, + source: &Path, + dest: &str, + kind: DestKind, + single: bool, + ) -> Result<(), Error> { + // Follow a symlinked SOURCE, the same way walkdir follows the root. + let source_is_dir = std::fs::metadata(source)?.is_dir(); + let root = resolve_dest(dest, kind, source, source_is_dir, single)?; + + for entry in WalkDir::new(source) { + let entry = match entry { + Ok(entry) => entry, Err(e) => { - eprintln!("Failed to push file: {e:?}"); - if !cli.ignore { - exit(1); + self.item_failed(&source.to_string_lossy(), Error::from(e))?; + continue; + } + }; + let Ok(relative) = entry.path().strip_prefix(source) else { + continue; + }; + let target = if relative.as_os_str().is_empty() { + root.clone() + } else { + root.join(relative) + }; + let target = target.to_slash_lossy().to_string(); + + let file_type = entry.file_type(); + let result = if file_type.is_dir() { + self.report(entry.path(), &target); + self.mkdir_p(&target) + } else if file_type.is_symlink() { + // ares-cli reads through a symlink to a file and copies the + // content. A symlink to a directory makes it fail, so skip that. + match std::fs::metadata(entry.path()) { + Ok(metadata) if metadata.is_dir() => { + eprintln!( + "Skipping {}: it is a symlink to a directory", + entry.path().display() + ); + Ok(()) } + Ok(_) => self.put_file(entry.path(), &target), + Err(e) => Err(e), } + } else { + self.put_file(entry.path(), &target) + }; + if let Err(e) = result { + self.item_failed(&entry.path().to_string_lossy(), e)?; } } + Ok(()) + } + + fn put_file(&mut self, local: &Path, target: &str) -> Result<(), Error> { + if let Some(parent) = parent_of(target) { + self.mkdir_p(parent)?; + } + self.report(local, target); + let mut remote = self + .sftp + .open( + target, + OpenFlags::WRITE_ONLY | OpenFlags::CREATE | OpenFlags::TRUNCATE, + 0o644, + ) + .map_err(|e| sftp_error(target, &e))?; + let mut source = File::open(local)?; + std::io::copy(&mut source, &mut remote)?; + Ok(()) + } + + /// Make `path` and every missing parent, the way `mkdir -p` does. + fn mkdir_p(&mut self, path: &str) -> Result<(), Error> { + if path.is_empty() || path == "/" || path == "." || self.made_dirs.contains(path) { + return Ok(()); + } + let sftp = self.sftp; + if !is_dir(sftp, path) { + if let Some(parent) = parent_of(path) { + self.mkdir_p(parent)?; + } + // Another writer may win the race, so a failure only counts when the + // directory still is not there. + if let Err(e) = sftp.create_dir(path, 0o755) + && !is_dir(sftp, path) + { + return Err(sftp_error(path, &e)); + } + } + self.made_dirs.insert(path.to_string()); + Ok(()) + } + + fn report(&self, local: &Path, target: &str) { + if !self.quiet { + println!("{} => {target}", local.display()); + } + } + + /// Handle a failure on one item. Returns the error to stop the whole copy, + /// or Ok to go on when --keep-going is set. + fn item_failed(&mut self, what: &str, e: Error) -> Result<(), Error> { + if !self.keep_going { + return Err(e); + } + eprintln!("Skipping {what}: {e}"); + self.failed = true; + Ok(()) + } +} + +/// Where SOURCE itself lands on the device. +/// +/// This follows ares-cli: a directory always keeps its own name under +/// DESTINATION, and a lone file keeps its name only when DESTINATION already is +/// a directory. A trailing "/" changes nothing. +fn resolve_dest( + dest: &str, + kind: DestKind, + source: &Path, + source_is_dir: bool, + single: bool, +) -> Result { + let dest_path = Path::new(dest); + if source_is_dir { + if kind == DestKind::File { + return Err(Error::new( + ErrorKind::AlreadyExists, + format!("{dest} is a file, and SOURCE is a directory"), + )); + } + } else if single && kind != DestKind::Dir { + // One file onto a free path, or onto a file to overwrite. + return Ok(dest_path.to_path_buf()); + } + Ok(match source_name(source)? { + Some(name) => dest_path.join(name), + None => dest_path.to_path_buf(), + }) +} + +/// Name that SOURCE takes on the device. +/// +/// `Path::file_name` gives None for ".", ".." and "/". "." copies the contents, +/// the way `cp -r . dest` does, so it has no name of its own. The rest resolve +/// to a real directory name. +fn source_name(path: &Path) -> Result, Error> { + if let Some(name) = path.file_name() { + return Ok(Some(name.to_os_string())); + } + if path.components().all(|c| c == Component::CurDir) { + return Ok(None); + } + let resolved = path.canonicalize()?; + match resolved.file_name() { + Some(name) => Ok(Some(name.to_os_string())), + None => Err(Error::new( + ErrorKind::InvalidInput, + format!("{} has no name to copy", path.display()), + )), + } +} + +fn dest_kind(sftp: &Sftp, path: &str) -> DestKind { + match sftp.metadata(path).map(|m| m.file_type()) { + Ok(Some(FileType::Directory)) => DestKind::Dir, + Ok(_) => DestKind::File, + Err(_) => DestKind::Missing, + } +} + +fn is_dir(sftp: &Sftp, path: &str) -> bool { + matches!( + sftp.metadata(path).map(|m| m.file_type()), + Ok(Some(FileType::Directory)) + ) +} + +/// Parent of a device path, which always uses "/". Returns None when the path +/// has no parent to make. +fn parent_of(path: &str) -> Option<&str> { + let (head, _) = path.trim_end_matches('/').rsplit_once('/')?; + Some(if head.is_empty() { "/" } else { head }) +} + +/// Recover the numeric SFTP status code from a libssh error. `SftpError`'s code +/// field is private, so parse it out of the Display text ("Sftp error code N"). +/// Returns `None` for non-SFTP errors. +fn sftp_status(e: &SshError) -> Option { + if !matches!(e, SshError::Sftp(_)) { + return None; + } + e.to_string().rsplit(' ').next()?.parse().ok() +} + +/// Human-readable reason for an SFTP status code (subset of `SSH_FX_*` codes). +fn sftp_reason(code: u32) -> &'static str { + match code { + 2 => "no such file or directory", + 3 => "permission denied", + 4 => "failure", + 8 => "operation not supported", + _ => "SFTP error", + } +} + +fn sftp_error(path: &str, e: &SshError) -> Error { + match sftp_status(e) { + Some(3) => Error::new( + ErrorKind::PermissionDenied, + format!( + "{path}: permission denied. The destination may not be writable on this device, \ + so try a different path." + ), + ), + Some(code) => Error::other(format!("{path}: {} (SFTP code {code})", sftp_reason(code))), + None => Error::other(format!("{path}: {e}")), + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{DestKind, parent_of, resolve_dest}; + + fn dest(source: &str, destination: &str, kind: DestKind, is_dir: bool, single: bool) -> String { + resolve_dest(destination, kind, Path::new(source), is_dir, single) + .expect("resolve_dest") + .to_string_lossy() + .replace('\\', "/") + } + + #[test] + fn a_lone_file_takes_the_destination_name() { + for kind in [DestKind::Missing, DestKind::File] { + assert_eq!(dest("a.txt", "/tmp/b.txt", kind, false, true), "/tmp/b.txt"); + } + } + + #[test] + fn a_lone_file_keeps_its_name_under_a_directory() { + assert_eq!( + dest("a.txt", "/tmp", DestKind::Dir, false, true), + "/tmp/a.txt" + ); + } + + #[test] + fn many_files_go_into_the_destination() { + for kind in [DestKind::Dir, DestKind::Missing] { + assert_eq!(dest("a.txt", "/tmp", kind, false, false), "/tmp/a.txt"); + } + } + + #[test] + fn a_directory_always_keeps_its_own_name() { + // The point of the ares-cli rule: "build" lands as /tmp/out/build, even + // when /tmp/out already is a directory. + for kind in [DestKind::Dir, DestKind::Missing] { + assert_eq!( + dest("build", "/tmp/out", kind, true, true), + "/tmp/out/build" + ); + } + } + + #[test] + fn a_trailing_slash_changes_nothing() { + assert_eq!( + dest("build", "/tmp/out/", DestKind::Dir, true, true), + "/tmp/out/build" + ); + assert_eq!( + dest("build/", "/tmp/out", DestKind::Dir, true, true), + "/tmp/out/build" + ); + assert_eq!( + dest("a.txt", "/tmp/", DestKind::Dir, false, true), + "/tmp/a.txt" + ); + } + + #[test] + fn a_dot_source_copies_its_contents() { + assert_eq!(dest(".", "/tmp/out", DestKind::Dir, true, true), "/tmp/out"); + assert_eq!( + dest("./", "/tmp/out", DestKind::Missing, true, true), + "/tmp/out" + ); + } + + #[test] + fn a_directory_onto_a_file_is_an_error() { + assert!( + resolve_dest("/tmp/a.txt", DestKind::File, Path::new("build"), true, true).is_err() + ); + } + + #[test] + fn parents_stop_at_the_root() { + assert_eq!(parent_of("/media/developer/apps"), Some("/media/developer")); + assert_eq!( + parent_of("/media/developer/apps/"), + Some("/media/developer") + ); + assert_eq!(parent_of("/media"), Some("/")); + assert_eq!(parent_of("/"), None); + assert_eq!(parent_of("app.ipk"), None); } }