#![forbid(unsafe_code)]
use super::{apply_scp_options, HostScpResult, ScpOptions};
use crate::errors::SshCliError;
use crate::output;
use crate::ssh::client::{SshClient, SshClientTrait};
use crate::vps;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::task::Poll;
pub(crate) fn join_remote_scp(remote_dir: &Path, base: &str) -> PathBuf {
let dir = remote_dir.to_string_lossy().into_owned();
if dir.is_empty() || dir == "." {
return PathBuf::from(base);
}
if dir.ends_with('/') {
PathBuf::from(format!("{dir}{base}"))
} else {
PathBuf::from(format!("{dir}/{base}"))
}
}
async fn join_window<T>(mut futures: Vec<Pin<Box<dyn Future<Output = T> + Send + '_>>>) -> Vec<T> {
let mut slots: Vec<Option<T>> = (0..futures.len()).map(|_| None).collect();
std::future::poll_fn(|cx| {
let mut all_ready = true;
for (slot, fut) in slots.iter_mut().zip(futures.iter_mut()) {
if slot.is_some() {
continue;
}
match fut.as_mut().poll(cx) {
Poll::Ready(value) => *slot = Some(value),
Poll::Pending => all_ready = false,
}
}
if all_ready {
Poll::Ready(())
} else {
Poll::Pending
}
})
.await;
slots.into_iter().flatten().collect()
}
pub(crate) async fn validate_local_upload_sources(sources: &[PathBuf]) -> anyhow::Result<()> {
for local in sources {
let meta = tokio::fs::metadata(local).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
SshCliError::FileNotFound(local.display().to_string())
} else {
SshCliError::Io(e)
}
})?;
if meta.is_dir() {
return Err(SshCliError::InvalidArgument(
crate::constants::SCP_UPLOAD_FILE_ONLY_MSG.to_string(),
)
.into());
}
if !meta.is_file() {
return Err(SshCliError::FileNotFound(local.display().to_string()).into());
}
}
Ok(())
}
pub(crate) async fn run_scp_multi_file_upload(
vps_name: &str,
sources: Vec<PathBuf>,
remote_dir: &Path,
config_override: Option<PathBuf>,
opts: ScpOptions,
) -> anyhow::Result<()> {
validate_local_upload_sources(&sources).await?;
let mut record = vps::find_by_name(config_override.as_deref(), vps_name)?
.ok_or_else(|| SshCliError::VpsNotFound(vps_name.to_string()))?;
apply_scp_options(&mut record, &opts);
let path = vps::resolve_config_path(config_override.as_deref())?;
let replace = opts.replace_host_key;
let json = opts.json;
let limit = crate::concurrency::effective_limit();
tracing::info!(
files = sources.len(),
vps = %vps_name,
session_reuse = true,
"multi-file scp upload (one session)"
);
let cfg = vps::build_connection_config(&record, Some(&path), replace);
let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
let host_results =
multi_file_upload_on_session(client.as_ref(), &sources, remote_dir, None).await;
let _ = client.disconnect().await;
finish_scp_results("upload", host_results, limit, json)
}
pub(crate) async fn multi_file_upload_on_session(
client: &dyn SshClientTrait,
sources: &[PathBuf],
remote_dir: &Path,
name_prefix: Option<&str>,
) -> Vec<HostScpResult> {
let window = crate::concurrency::scp_file_concurrency().max(1);
if window > 1 && sources.len() > 1 {
tracing::debug!(
window,
files = sources.len(),
"scp multi-file parallel window"
);
}
let mut host_results = Vec::with_capacity(sources.len());
let mut i = 0;
while i < sources.len() {
if crate::signals::should_stop() {
push_cancelled_upload_remainder(&mut host_results, &sources[i..], name_prefix);
break;
}
let end = (i + window).min(sources.len());
let slice = &sources[i..end];
if slice.is_empty() {
break;
}
if slice.len() == 1 {
host_results.push(upload_one(client, &slice[0], remote_dir, name_prefix).await);
} else {
let futures = slice
.iter()
.map(|local| {
Box::pin(upload_one(client, local, remote_dir, name_prefix))
as Pin<Box<dyn Future<Output = HostScpResult> + Send>>
})
.collect();
host_results.extend(join_window(futures).await);
}
i = end;
}
debug_assert_eq!(host_results.len(), sources.len());
host_results
}
fn push_cancelled_upload_remainder(
out: &mut Vec<HostScpResult>,
remaining: &[PathBuf],
name_prefix: Option<&str>,
) {
for local in remaining {
let label = match name_prefix {
Some(h) => format!("{h}:{}", local.display()),
None => local.display().to_string(),
};
out.push(cancelled_host_scp(label, Some(local.display().to_string())));
}
}
pub(crate) fn cancelled_host_scp(name: String, local: Option<String>) -> HostScpResult {
HostScpResult {
name,
ok: false,
bytes: None,
duration_ms: None,
local,
error: Some(crate::constants::OPERATION_CANCELLED_MSG.to_string()),
}
}
async fn upload_one(
client: &dyn SshClientTrait,
local: &Path,
remote_dir: &Path,
name_prefix: Option<&str>,
) -> HostScpResult {
let label = match name_prefix {
Some(h) => format!("{h}:{}", local.display()),
None => local.display().to_string(),
};
if crate::signals::should_stop() {
return cancelled_host_scp(label, Some(local.display().to_string()));
}
let base = local
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_owned());
let remote = join_remote_scp(remote_dir, &base);
match client.upload(local, &remote).await {
Ok(t) => HostScpResult {
name: label,
ok: true,
bytes: Some(t.bytes_transferred),
duration_ms: Some(t.duration_ms),
local: Some(local.display().to_string()),
error: None,
},
Err(e) => HostScpResult {
name: label,
ok: false,
bytes: None,
duration_ms: None,
local: Some(local.display().to_string()),
error: Some(e.to_string()),
},
}
}
pub(crate) async fn run_scp_multi_file_download(
vps_name: &str,
remotes: Vec<PathBuf>,
local_dir: &Path,
config_override: Option<PathBuf>,
opts: ScpOptions,
) -> anyhow::Result<()> {
let dest_meta = tokio::fs::metadata(local_dir).await;
match dest_meta {
Ok(m) if m.is_file() => {
return Err(SshCliError::InvalidArgument(
"multi-file download destination must be a directory (not an existing file)".into(),
)
.into());
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tokio::fs::create_dir_all(local_dir)
.await
.map_err(SshCliError::Io)?;
}
Err(e) => return Err(SshCliError::Io(e).into()),
}
let mut record = vps::find_by_name(config_override.as_deref(), vps_name)?
.ok_or_else(|| SshCliError::VpsNotFound(vps_name.to_string()))?;
apply_scp_options(&mut record, &opts);
let path = vps::resolve_config_path(config_override.as_deref())?;
let replace = opts.replace_host_key;
let json = opts.json;
let limit = crate::concurrency::effective_limit();
tracing::info!(
files = remotes.len(),
vps = %vps_name,
session_reuse = true,
"multi-file scp download (one session)"
);
let cfg = vps::build_connection_config(&record, Some(&path), replace);
let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
let host_results =
multi_file_download_on_session(client.as_ref(), &remotes, local_dir, None).await;
let _ = client.disconnect().await;
finish_scp_results("download", host_results, limit, json)
}
pub(crate) async fn multi_file_download_on_session(
client: &dyn SshClientTrait,
remotes: &[PathBuf],
local_dir: &Path,
name_prefix: Option<&str>,
) -> Vec<HostScpResult> {
let mut host_results = Vec::with_capacity(remotes.len());
for (idx, remote) in remotes.iter().enumerate() {
let label = match name_prefix {
Some(h) => format!("{h}:{}", remote.display()),
None => remote.display().to_string(),
};
if crate::signals::should_stop() {
host_results.push(cancelled_host_scp(label, None));
for remote in &remotes[idx + 1..] {
let lab = match name_prefix {
Some(h) => format!("{h}:{}", remote.display()),
None => remote.display().to_string(),
};
host_results.push(cancelled_host_scp(lab, None));
}
break;
}
let base = remote
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("file"));
let local = local_dir.join(base);
match client.download(remote, &local).await {
Ok(t) => host_results.push(HostScpResult {
name: label,
ok: true,
bytes: Some(t.bytes_transferred),
duration_ms: Some(t.duration_ms),
local: Some(local.display().to_string()),
error: None,
}),
Err(e) => host_results.push(HostScpResult {
name: label,
ok: false,
bytes: None,
duration_ms: None,
local: Some(local.display().to_string()),
error: Some(e.to_string()),
}),
}
}
debug_assert_eq!(host_results.len(), remotes.len());
host_results
}
const HOST_NOT_ATTEMPTED: &str = "not attempted (fan-out admission stopped)";
fn not_attempted_host_scp(name: String) -> HostScpResult {
HostScpResult {
name,
ok: false,
bytes: None,
duration_ms: None,
local: None,
error: Some(HOST_NOT_ATTEMPTED.to_owned()),
}
}
fn scp_op(direction: &str, upload: &'static str, download: &'static str) -> &'static str {
if direction == "upload" {
upload
} else {
download
}
}
pub(crate) fn finish_scp_results(
direction: &'static str,
host_results: Vec<HostScpResult>,
limit: usize,
json: bool,
) -> anyhow::Result<()> {
let failures = host_results.iter().filter(|h| !h.ok).count();
let total = host_results.len();
output::print_scp_batch(
direction,
&host_results,
limit,
json,
crate::json_wire::TargetSource::Argv,
)?;
crate::errors::finish_batch(
failures,
total,
scp_op(
direction,
"multi-file scp upload",
"multi-file scp download",
),
)?;
Ok(())
}
fn slot_by_host<R>(
results: Vec<crate::concurrency::IndexedResult<R>>,
names: &[String],
on_join_error: impl Fn(String, String) -> R,
missing: impl Fn(String) -> R,
) -> Vec<R> {
let mut slots: Vec<Option<R>> = (0..names.len()).map(|_| None).collect();
let mut surplus: Vec<R> = Vec::new();
for r in results {
let row = match r.outcome {
Ok(value) => value,
Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
Err(e) => on_join_error(
names
.get(r.index)
.cloned()
.unwrap_or_else(|| format!("task-{}", r.index)),
e.to_string(),
),
};
match slots.get_mut(r.index) {
Some(slot) => *slot = Some(row),
None => surplus.push(row),
}
}
let mut out = Vec::with_capacity(slots.len().saturating_add(surplus.len()));
for (i, slot) in slots.into_iter().enumerate() {
out.push(match slot {
Some(row) => row,
None => missing(names[i].clone()),
});
}
out.extend(surplus);
out
}
pub(crate) fn finish_scp_nested_batch(
direction: &'static str,
results: Vec<crate::concurrency::IndexedResult<Vec<HostScpResult>>>,
names: &[String],
limit: usize,
json: bool,
) -> anyhow::Result<()> {
let per_host = slot_by_host(
results,
names,
|name, err| {
vec![HostScpResult {
name,
ok: false,
bytes: None,
duration_ms: None,
local: None,
error: Some(err),
}]
},
|name| vec![not_attempted_host_scp(name)],
);
let host_results: Vec<HostScpResult> = per_host.into_iter().flatten().collect();
let failures = host_results.iter().filter(|h| !h.ok).count();
let total = host_results.len();
output::print_scp_batch(
direction,
&host_results,
limit,
json,
crate::json_wire::TargetSource::Selector,
)?;
crate::errors::finish_batch(
failures,
total,
scp_op(
direction,
"multi-host multi-file scp upload",
"multi-host multi-file scp download",
),
)?;
Ok(())
}
pub(crate) fn finish_scp_batch(
direction: &'static str,
results: Vec<crate::concurrency::IndexedResult<HostScpResult>>,
names: &[String],
limit: usize,
json: bool,
) -> anyhow::Result<()> {
let host_results = slot_by_host(
results,
names,
|name, err| HostScpResult {
name,
ok: false,
bytes: None,
duration_ms: None,
local: None,
error: Some(err),
},
not_attempted_host_scp,
);
let failures = host_results.iter().filter(|h| !h.ok).count();
let total = host_results.len();
output::print_scp_batch(
direction,
&host_results,
limit,
json,
crate::json_wire::TargetSource::Selector,
)?;
crate::errors::finish_batch(
failures,
total,
scp_op(
direction,
"multi-host scp upload",
"multi-host scp download",
),
)?;
Ok(())
}
#[cfg(test)]
mod remote_path_tests {
use super::*;
#[test]
fn remote_join_always_uses_forward_slash() {
let joined = join_remote_scp(Path::new("/srv/incoming"), "report.csv");
assert_eq!(joined.to_string_lossy(), "/srv/incoming/report.csv");
assert!(!joined.to_string_lossy().contains('\\'));
}
#[test]
fn remote_join_does_not_double_separator() {
assert_eq!(
join_remote_scp(Path::new("/srv/incoming/"), "a.txt").to_string_lossy(),
"/srv/incoming/a.txt"
);
}
#[test]
fn remote_join_relative_and_dot_dirs() {
assert_eq!(
join_remote_scp(Path::new("uploads"), "a.txt").to_string_lossy(),
"uploads/a.txt"
);
assert_eq!(
join_remote_scp(Path::new("."), "a.txt").to_string_lossy(),
"a.txt"
);
assert_eq!(
join_remote_scp(Path::new(""), "a.txt").to_string_lossy(),
"a.txt"
);
}
#[test]
fn remote_join_preserves_caller_path_text() {
assert_eq!(
join_remote_scp(Path::new("/tmp/dir with space"), "b c.txt").to_string_lossy(),
"/tmp/dir with space/b c.txt"
);
}
}