#![forbid(unsafe_code)]
use crate::cli::ScpAction;
use crate::errors::SshCliError;
use crate::i18n::{self, Message};
use crate::output;
use crate::ssh::client::{SshClient, SshClientTrait};
use crate::vps;
use std::path::PathBuf;
mod batch;
mod multi_host;
use batch::{run_scp_multi_file_download, run_scp_multi_file_upload};
use multi_host::{
run_scp_all_download, run_scp_all_upload, run_scp_multi_host_multi_file_download,
run_scp_multi_host_multi_file_upload,
};
#[derive(Debug, Default, Clone)]
pub struct ScpOptions {
pub password: Option<secrecy::SecretString>,
pub key: Option<String>,
pub key_passphrase: Option<secrecy::SecretString>,
pub timeout: Option<crate::domain::TimeoutMs>,
pub replace_host_key: bool,
pub json: bool,
pub use_agent: bool,
pub agent_socket: Option<String>,
}
#[derive(Debug, Clone)]
pub struct HostScpResult {
pub name: String,
pub ok: bool,
pub bytes: Option<u64>,
pub duration_ms: Option<u64>,
pub local: Option<String>,
pub error: Option<String>,
}
pub async fn run_scp(
action: ScpAction,
config_override: Option<PathBuf>,
opts: ScpOptions,
) -> anyhow::Result<()> {
if crate::signals::should_stop() {
return Err(anyhow::anyhow!(i18n::t(Message::OperationCancelled)));
}
match action {
ScpAction::Upload {
all,
hosts,
target,
..
} => {
let plan = crate::cli::parse_scp_target(all, hosts, target)
.map_err(SshCliError::InvalidArgument)?;
match plan {
crate::cli::ScpPathPlan::MultiFile {
vps,
sources,
dest_dir,
} => {
return run_scp_multi_file_upload(
&vps,
sources,
&dest_dir,
config_override,
opts,
)
.await;
}
crate::cli::ScpPathPlan::MultiHostMultiFile {
selection,
sources,
dest_dir,
} => {
return run_scp_multi_host_multi_file_upload(
&selection,
sources,
&dest_dir,
config_override,
opts,
)
.await;
}
crate::cli::ScpPathPlan::Single {
selection,
path_a: local,
path_b: remote,
} => {
if local.is_dir() {
return Err(SshCliError::InvalidArgument(i18n::t(
Message::ScpUploadFileOnly,
))
.into());
}
if !local.is_file() {
return Err(
SshCliError::FileNotFound(local.display().to_string()).into()
);
}
if selection.is_batch() {
return run_scp_all_upload(
&selection,
&local,
&remote,
config_override,
opts,
)
.await;
}
let vps::HostSelection::Single(vps_name) = selection else {
return Err(SshCliError::InvalidArgument(
"internal: expected single-host selection for non-batch SCP".into(),
)
.into());
};
let vps_key = vps_name.as_str();
let mut record = vps::find_by_name(config_override.as_deref(), vps_key)?
.ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
apply_scp_options(&mut record, &opts);
let path = crate::vps::resolve_config_path(config_override.as_deref())?;
let cfg = crate::vps::build_connection_config(
&record,
Some(&path),
opts.replace_host_key,
);
let client: Box<dyn SshClientTrait> =
<SshClient as SshClientTrait>::connect(cfg).await?;
run_scp_upload_with_client(vps_key, &local, &remote, client, opts.json)
.await?;
}
}
}
ScpAction::Download {
all,
hosts,
target,
..
} => {
let plan = crate::cli::parse_scp_target(all, hosts, target)
.map_err(SshCliError::InvalidArgument)?;
match plan {
crate::cli::ScpPathPlan::MultiFile {
vps,
sources: remotes,
dest_dir: local_dir,
} => {
return run_scp_multi_file_download(
&vps,
remotes,
&local_dir,
config_override,
opts,
)
.await;
}
crate::cli::ScpPathPlan::MultiHostMultiFile {
selection,
sources: remotes,
dest_dir: local_dir,
} => {
return run_scp_multi_host_multi_file_download(
&selection,
remotes,
&local_dir,
config_override,
opts,
)
.await;
}
crate::cli::ScpPathPlan::Single {
selection,
path_a: remote,
path_b: local,
} => {
if selection.is_batch() {
return run_scp_all_download(
&selection,
&remote,
&local,
config_override,
opts,
)
.await;
}
if local.is_dir() {
return Err(SshCliError::InvalidArgument(i18n::t(
Message::ScpDownloadLocalNotDirectory,
))
.into());
}
let vps::HostSelection::Single(vps_name) = selection else {
return Err(SshCliError::InvalidArgument(
"internal: expected single-host selection for non-batch SCP".into(),
)
.into());
};
let vps_key = vps_name.as_str();
let mut record = vps::find_by_name(config_override.as_deref(), vps_key)?
.ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
apply_scp_options(&mut record, &opts);
let path = crate::vps::resolve_config_path(config_override.as_deref())?;
let cfg = crate::vps::build_connection_config(
&record,
Some(&path),
opts.replace_host_key,
);
let client: Box<dyn SshClientTrait> =
<SshClient as SshClientTrait>::connect(cfg).await?;
run_scp_download_with_client(
vps_key, &remote, &local, client, opts.json,
)
.await?;
}
}
}
}
Ok(())
}
pub(crate) fn apply_scp_options(record: &mut crate::vps::model::VpsRecord, opts: &ScpOptions) {
if let Some(ref pwd) = opts.password {
record.password = pwd.clone();
}
if let Some(ref k) = opts.key {
if let Ok(kp) = crate::domain::KeyPath::try_new(k.as_str()) {
record.key_path = Some(kp);
}
}
if let Some(ref kp) = opts.key_passphrase {
record.key_passphrase = Some(kp.clone());
}
if let Some(t) = opts.timeout {
record.timeout_ms = t;
}
if opts.use_agent {
record.use_agent = true;
}
if let Some(ref sock) = opts.agent_socket {
record.agent_socket = Some(sock.clone());
record.use_agent = true;
}
}
pub async fn run_scp_upload_with_client(
vps_name: &str,
local: &std::path::Path,
remote: &std::path::Path,
client: Box<dyn SshClientTrait>,
json: bool,
) -> anyhow::Result<()> {
let result = client.upload(local, remote).await;
let _ = client.disconnect().await;
let result = result?;
if json {
output::print_transfer_json(
"upload",
vps_name,
&local.display().to_string(),
&remote.display().to_string(),
result.bytes_transferred,
result.duration_ms,
)?;
} else {
output::print_success(&i18n::t(Message::ScpUploadCompleted {
bytes: result.bytes_transferred,
ms: result.duration_ms,
}));
}
Ok(())
}
pub async fn run_scp_download_with_client(
vps_name: &str,
remote: &std::path::Path,
local: &std::path::Path,
client: Box<dyn SshClientTrait>,
json: bool,
) -> anyhow::Result<()> {
let result = client.download(remote, local).await;
let _ = client.disconnect().await;
let result = result?;
if json {
output::print_transfer_json(
"download",
vps_name,
&local.display().to_string(),
&remote.display().to_string(),
result.bytes_transferred,
result.duration_ms,
)?;
} else {
output::print_success(&i18n::t(Message::ScpDownloadCompleted {
bytes: result.bytes_transferred,
ms: result.duration_ms,
}));
}
Ok(())
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;