use std::collections::HashSet;
use std::ffi::OsString;
use anyhow::Result;
use clap::{ArgAction::SetTrue, Args as _, FromArgMatches as _, Parser};
use crate::config::Source as ConfigSource;
use crate::util::{dirwalk, path};
use crate::{CopyJobSpec, FileSpec, config::Manager, util::AddressFamily};
const META_JOBSPEC: &str = "command-line (user@host)";
#[derive(Clone, Copy, Debug, Default, PartialEq, clap::ValueEnum)]
#[value(rename_all = "kebab-case")]
pub(crate) enum MainMode {
#[default]
Client,
Server,
ShowConfig,
HelpBuffers,
ShowConfigFiles,
ListFeatures,
}
#[derive(Debug, Parser, Clone, Default)]
#[command(
author,
// we set short/long version strings explicitly, see custom_parse()
about,
long_about,
override_usage = "qcp [OPTIONS] <SOURCE>... <DESTINATION>",
before_help = r"e.g. qcp some/file my-server:some-directory/
qcp -r dir1 dir2 my-server:
Exactly one side (source(s) or destination) must be remote.
When copying multiple sources, the destination is a directory, which will be created if necessary.
Long options may be abbreviated where unambiguous.
qcp will read your ssh config file to resolve any host name aliases you may have defined. The idea is, if you can ssh directly to a given host, you should be able to qcp to it by the same name. However, some particularly complicated ssh config files may be too much for qcp to understand. (In particular, Match directives are not currently supported.) In that case, you can use --ssh-config to provide an alternative configuration (or set it in your qcp configuration file).
",
infer_long_args(true),
help_template(
"\
{name} version {version}
{about-with-newline}
{usage-heading} {usage}
{before-help}
{all-args}{after-help}
"),
styles=super::styles::CLAP_STYLES)]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct CliArgs {
#[arg(hide = true, long("__mode"), num_args = 1, require_equals=true, default_value_ifs=[
// syntax: (field within this struct, value, argument to apply if field==value)
("server", "true", "server"),
("show_config", "true", "show-config"),
("help_buffers", "true", "help-buffers"),
("config_files", "true", "show-config-files"),
("list_features", "true", "list-features"),
], default_value="client")]
pub(crate) mode_: MainMode,
#[arg(long, hide = true, exclusive(true))]
pub server: bool,
#[arg(long, help_heading("Debug"), display_order(100))]
pub show_config: bool,
#[arg(long, help_heading("Debug"), exclusive(true), display_order(100))]
pub config_files: bool,
#[arg(long, help_heading("Tuning"), display_order(100))]
pub help_buffers: bool,
#[arg(long, help_heading("Debug"), exclusive(true), display_order(100))]
pub list_features: bool,
#[command(flatten)]
pub client_params: crate::client::Parameters,
#[arg(long, value_name("FILE"), display_order(0))]
pub log_file: Option<String>,
#[arg(
short = '4',
help_heading("Connection"),
group("ip address"),
action(SetTrue),
display_order(0)
)]
pub ipv4_alias__: bool,
#[arg(
short = '6',
help_heading("Connection"),
group("ip address"),
action(SetTrue),
display_order(0)
)]
pub ipv6_alias__: bool,
#[command(flatten)]
pub config: crate::config::Configuration_Optional,
#[arg(value_name = "SOURCE|DESTINATION", num_args = 0..)]
pub paths: Vec<FileSpec>,
}
impl CliArgs {
pub(crate) fn custom_parse<I, T>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let cli = clap::Command::new(clap::crate_name!());
let cli = CliArgs::augment_args(cli).version(crate::version::short());
let mut args = CliArgs::from_arg_matches(&cli.try_get_matches_from(args)?)?;
if args.ipv4_alias__ {
args.config.address_family = Some(AddressFamily::Inet);
} else if args.ipv6_alias__ {
args.config.address_family = Some(AddressFamily::Inet6);
}
Ok(args)
}
fn apply_jobspec_to(&self, mgr: &mut Manager) {
mgr.merge_provider(self.remote_user_as_config());
}
fn sources_and_destination(&self) -> anyhow::Result<(Vec<FileSpec>, FileSpec)> {
anyhow::ensure!(self.paths.len() >= 2, "source and destination are required");
let mut paths = self.paths.clone();
let destination = paths.pop().expect("destination must be present");
Ok((paths, destination))
}
pub(crate) fn jobspecs(&self) -> Result<(bool, Vec<CopyJobSpec>), anyhow::Error> {
let (sources, destination) = self.sources_and_destination()?;
let destination_is_remote = destination.user_at_host.is_some();
let mut remote_hosts = HashSet::new();
let mut remote_user: Option<&str> = None;
let mut success = true;
for spec in sources.iter().chain(std::iter::once(&destination)) {
if let Some(host) = spec.hostname() {
let _ = remote_hosts.insert(host);
}
if let Some(user) = spec.remote_user() {
if let Some(existing) = remote_user {
anyhow::ensure!(existing == user, "Only one remote user is supported");
} else {
remote_user = Some(user);
}
}
}
anyhow::ensure!(remote_hosts.len() <= 1, "Only one remote host is supported");
let remote_sources: Vec<_> = sources
.iter()
.filter(|s| s.user_at_host.is_some())
.collect();
let join_fn = if destination_is_remote {
anyhow::ensure!(
remote_sources.is_empty() && sources.iter().all(|src| src.user_at_host.is_none()),
"Only one remote side is supported"
);
path::join_remote
} else {
anyhow::ensure!(
!remote_sources.is_empty(),
"One file argument must be remote"
);
anyhow::ensure!(
sources.iter().all(|src| src.user_at_host.is_some()),
"Only one remote side is supported"
);
path::join_local
};
let multiple_sources = sources.len() > 1;
let mut jobs = Vec::with_capacity(sources.len());
if self.client_params.recurse && destination_is_remote {
for source in sources {
success &= dirwalk::recurse_local_source(
&source,
&destination,
self.client_params.preserve,
&mut jobs,
)?;
}
} else {
for source in sources {
let dest_filename =
if multiple_sources && (!self.client_params.recurse || destination_is_remote) {
let leaf = path::basename_of(&source.filename)?;
join_fn(&destination.filename, &leaf)
} else {
destination.filename.clone()
};
jobs.push(CopyJobSpec::try_new(
source,
FileSpec {
user_at_host: destination.user_at_host.clone(),
filename: dest_filename,
},
self.client_params.preserve,
false,
)?);
}
}
Ok((success, jobs))
}
pub(crate) fn remote_host_lossy(&self) -> anyhow::Result<Option<&str>> {
if self.paths.is_empty() {
return Ok(None);
}
let mut host: Option<&str> = None;
let mut remote_in_sources = false;
let mut remote_in_destination = false;
for (idx, spec) in self.paths.iter().enumerate() {
if let Some(h) = spec.hostname() {
if let Some(existing) = host {
anyhow::ensure!(existing == h, "Only one remote host is supported");
} else {
host = Some(h);
}
if idx == self.paths.len() - 1 {
remote_in_destination = true;
} else {
remote_in_sources = true;
}
}
}
anyhow::ensure!(
!(remote_in_sources && remote_in_destination),
"Only one remote side is supported"
);
Ok(host)
}
pub(crate) fn remote_user_as_config(&self) -> ConfigSource {
let mut cfg = ConfigSource::new(META_JOBSPEC);
let mut remote_user: Option<&str> = None;
for spec in &self.paths {
let user = spec.remote_user();
if let Some(u) = user {
if let Some(existing) = remote_user {
if existing != u {
remote_user = None;
break;
}
} else {
remote_user = Some(u);
}
}
}
if let Some(u) = remote_user {
cfg.add("remote_user", u.into());
}
cfg
}
}
impl TryFrom<&CliArgs> for Manager {
type Error = anyhow::Error;
fn try_from(value: &CliArgs) -> Result<Self, Self::Error> {
let host = value.remote_host_lossy()?;
let mut mgr = Manager::standard(host);
mgr.merge_provider(&value.config);
value.apply_jobspec_to(&mut mgr);
Ok(mgr)
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod test {
use std::str::FromStr;
use pretty_assertions::assert_eq;
use rusty_fork::rusty_fork_test;
use crate::{
FileSpec,
config::{Configuration_Optional, Manager, Source},
util::AddressFamily,
};
use super::CliArgs;
fn get_cli_args(src: bool, dst: bool) -> CliArgs {
let src_spec = if src {
FileSpec::from_str("myuser@myhost:myfile").unwrap()
} else {
FileSpec::from_str("myfile").unwrap()
};
let dst_spec = if dst {
FileSpec::from_str("myuser@myhost:myfile").unwrap()
} else {
FileSpec::from_str("myfile").unwrap()
};
CliArgs {
paths: vec![src_spec, dst_spec],
..Default::default()
}
}
fn config_user(user: &str) -> Source {
let mut prov = Source::new("test");
prov.add("remote_user", user.into());
prov
}
#[test]
fn src_has_user() {
let args = get_cli_args(true, false);
let mut mgr = Manager::without_default(None);
args.apply_jobspec_to(&mut mgr);
let cfg = mgr.get::<Configuration_Optional>().unwrap();
assert_eq!(cfg.remote_user, Some("myuser".to_owned()));
}
#[test]
fn dest_has_user() {
let args = get_cli_args(false, true);
let mut mgr = Manager::without_default(None);
args.apply_jobspec_to(&mut mgr);
let cfg = mgr.get::<Configuration_Optional>().unwrap();
assert_eq!(cfg.remote_user, Some("myuser".to_owned()));
}
#[test]
fn neither_has_user() {
let args = get_cli_args(false, false);
let mut mgr = Manager::without_default(None);
args.apply_jobspec_to(&mut mgr);
let cfg = mgr.get::<Configuration_Optional>().unwrap();
assert_eq!(cfg.remote_user, None);
}
#[test]
fn config_has_user() {
let args = get_cli_args(false, false);
let mut mgr = Manager::without_default(None);
mgr.merge_provider(config_user("user1"));
args.apply_jobspec_to(&mut mgr);
let cfg = mgr.get::<Configuration_Optional>().unwrap();
assert_eq!(cfg.remote_user, Some("user1".into()));
}
#[test]
fn there_can_be_only_one_remote() {
let args = get_cli_args(true, true);
let mgr = Manager::try_from(&args);
assert!(mgr.is_err());
}
#[test]
fn priority() {
let args = get_cli_args(true, false);
let mut mgr = Manager::without_default(None);
mgr.merge_provider(config_user("user2"));
args.apply_jobspec_to(&mut mgr);
let cfg = mgr.get::<Configuration_Optional>().unwrap();
assert_eq!(cfg.remote_user, Some("myuser".to_owned()));
}
#[test]
fn custom_parse_aliases() {
let table = [("-4", AddressFamily::Inet), ("-6", AddressFamily::Inet6)];
for (alias, family) in table {
let args = ["qcp", alias, "myuser@myhost:myfile", "."];
let result = CliArgs::custom_parse(args).unwrap();
assert_eq!(
result.paths[0],
FileSpec::from_str("myuser@myhost:myfile").unwrap()
);
assert_eq!(result.config.address_family.unwrap(), family);
let mgr = Manager::try_from(&result).unwrap();
assert_eq!(
mgr.get::<Configuration_Optional>()
.unwrap()
.address_family
.unwrap(),
family
);
}
}
#[test]
fn conflicting_options() {
let args = ["qcp", "--server", "--show-config"];
let result = CliArgs::custom_parse(args);
assert!(result.is_err());
println!("{result:?}");
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
eprintln!("{err}");
assert!(
err.to_string()
.contains("the argument '--server' cannot be used with one or more of the other")
);
}
rusty_fork_test! {
#[test]
fn parse_color() {
let args = ["qcp", "--color", "always"];
let result = CliArgs::custom_parse(args);
assert!(result.is_ok());
}
}
#[test]
fn cli_option_capitalisation() {
let args = &[
"qcp",
"--time-format",
"uTc-mIcro",
"--address-family",
"iNEt6",
"--color",
"NONE",
"--tls-auth-type",
"X509",
"--congestion",
"bBr",
"--show-config",
];
let res = CliArgs::custom_parse(args).inspect_err(|e| eprintln!("{e}"));
assert!(res.is_ok());
}
#[test]
fn cli_accepts_eng_quantities() {
let args = &[
"qcp",
"--tx",
"100M",
"--rx",
"42k",
"--udp-buffer",
"3M5",
"--show-config",
"host:file",
"file",
];
let res = CliArgs::custom_parse(args).inspect_err(|e| eprintln!("{e}"));
assert!(res.is_ok());
}
#[test]
fn cli_repeatable_arguments() {
let args = &[
"qcp",
"-S",
"-p",
"-S",
"222",
"--ssh-config",
"myfile",
"--ssh-config",
"myfile2",
];
let res = CliArgs::custom_parse(args).inspect_err(|e| eprintln!("{e}"));
assert!(res.is_ok());
}
#[test]
fn recurse_jobspecs() {
let args = &["qcp", "-r", "no-such-dir/", "desthost:otherdir"];
let res = CliArgs::custom_parse(args)
.inspect_err(|e| eprintln!("{e}"))
.unwrap();
let _ = res
.jobspecs()
.expect_err("nonexistent directory should have failed to recurse");
}
}