1#![forbid(unsafe_code)]
4use crate::cli::ScpAction;
23use crate::errors::SshCliError;
24use crate::i18n::{self, Message};
25use crate::output;
26use crate::ssh::client::{SshClient, SshClientTrait};
27use crate::vps;
28use std::path::PathBuf;
29
30mod batch;
31mod multi_host;
32
33use batch::{run_scp_multi_file_download, run_scp_multi_file_upload};
34use multi_host::{
35 run_scp_all_download, run_scp_all_upload, run_scp_multi_host_multi_file_download,
36 run_scp_multi_host_multi_file_upload,
37};
38
39#[derive(Debug, Default, Clone)]
44pub struct ScpOptions {
45 pub password: Option<secrecy::SecretString>,
47 pub key: Option<String>,
49 pub key_passphrase: Option<secrecy::SecretString>,
51 pub timeout: Option<crate::domain::TimeoutMs>,
53 pub replace_host_key: bool,
55 pub json: bool,
57 pub use_agent: bool,
59 pub agent_socket: Option<String>,
61}
62
63#[derive(Debug, Clone)]
65pub struct HostScpResult {
66 pub name: String,
68 pub ok: bool,
70 pub bytes: Option<u64>,
72 pub duration_ms: Option<u64>,
74 pub local: Option<String>,
76 pub error: Option<String>,
78}
79
80pub async fn run_scp(
82 action: ScpAction,
83 config_override: Option<PathBuf>,
84 opts: ScpOptions,
85) -> anyhow::Result<()> {
86 if crate::signals::should_stop() {
87 return Err(anyhow::anyhow!(crate::constants::OPERATION_CANCELLED_MSG));
88 }
89
90 match action {
91 ScpAction::Upload {
92 all,
93 hosts,
94 src,
95 dest,
96 target,
97 ..
98 } => {
99 let plan = crate::cli::parse_scp_target(
100 all,
101 hosts,
102 crate::cli::TransferSlots::new(src, dest),
103 target,
104 )
105 .map_err(SshCliError::InvalidArgument)?;
106 match plan {
107 crate::cli::ScpPathPlan::MultiFile {
108 vps,
109 sources,
110 dest_dir,
111 } => {
112 return run_scp_multi_file_upload(
113 &vps,
114 sources,
115 &dest_dir,
116 config_override,
117 opts,
118 )
119 .await;
120 }
121 crate::cli::ScpPathPlan::MultiHostMultiFile {
122 selection,
123 sources,
124 dest_dir,
125 } => {
126 return run_scp_multi_host_multi_file_upload(
127 &selection,
128 sources,
129 &dest_dir,
130 config_override,
131 opts,
132 )
133 .await;
134 }
135 crate::cli::ScpPathPlan::Single {
136 selection,
137 path_a: local,
138 path_b: remote,
139 } => {
140 if local.is_dir() {
142 return Err(SshCliError::InvalidArgument(
143 crate::constants::SCP_UPLOAD_FILE_ONLY_MSG.to_string(),
144 )
145 .into());
146 }
147 if !local.is_file() {
148 return Err(SshCliError::FileNotFound(local.display().to_string()).into());
149 }
150
151 if selection.is_batch() {
152 return run_scp_all_upload(
153 &selection,
154 &local,
155 &remote,
156 config_override,
157 opts,
158 )
159 .await;
160 }
161 let vps::HostSelection::Single(vps_name) = selection else {
162 return Err(SshCliError::InvalidArgument(
164 "internal: expected single-host selection for non-batch SCP".into(),
165 )
166 .into());
167 };
168 let vps_key = vps_name.as_str();
169
170 let mut record = vps::find_by_name(config_override.as_deref(), vps_key)?
171 .ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
172 let target = crate::json_wire::ExecTarget::new(
176 vps_key,
177 crate::json_wire::TargetSource::Argv,
178 );
179 crate::json_wire::set_resolved_target(&target);
180
181 apply_scp_options(&mut record, &opts);
182
183 let path = crate::vps::resolve_config_path(config_override.as_deref())?;
184 let cfg = crate::vps::build_connection_config(
185 &record,
186 Some(&path),
187 opts.replace_host_key,
188 );
189
190 let client: Box<dyn SshClientTrait> =
191 <SshClient as SshClientTrait>::connect(cfg).await?;
192 run_scp_upload_with_client(&target, &local, &remote, client, opts.json).await?;
193 }
194 }
195 }
196 ScpAction::Download {
197 all,
198 hosts,
199 src,
200 dest,
201 target,
202 ..
203 } => {
204 let plan = crate::cli::parse_scp_target(
205 all,
206 hosts,
207 crate::cli::TransferSlots::new(src, dest),
208 target,
209 )
210 .map_err(SshCliError::InvalidArgument)?;
211 match plan {
212 crate::cli::ScpPathPlan::MultiFile {
213 vps,
214 sources: remotes,
215 dest_dir: local_dir,
216 } => {
217 return run_scp_multi_file_download(
218 &vps,
219 remotes,
220 &local_dir,
221 config_override,
222 opts,
223 )
224 .await;
225 }
226 crate::cli::ScpPathPlan::MultiHostMultiFile {
227 selection,
228 sources: remotes,
229 dest_dir: local_dir,
230 } => {
231 return run_scp_multi_host_multi_file_download(
232 &selection,
233 remotes,
234 &local_dir,
235 config_override,
236 opts,
237 )
238 .await;
239 }
240 crate::cli::ScpPathPlan::Single {
241 selection,
242 path_a: remote,
243 path_b: local,
244 } => {
245 if selection.is_batch() {
246 return run_scp_all_download(
247 &selection,
248 &remote,
249 &local,
250 config_override,
251 opts,
252 )
253 .await;
254 }
255 if local.is_dir() {
256 return Err(SshCliError::InvalidArgument(
257 crate::constants::SCP_DOWNLOAD_LOCAL_NOT_DIRECTORY_MSG.to_string(),
258 )
259 .into());
260 }
261 let vps::HostSelection::Single(vps_name) = selection else {
262 return Err(SshCliError::InvalidArgument(
264 "internal: expected single-host selection for non-batch SCP".into(),
265 )
266 .into());
267 };
268 let vps_key = vps_name.as_str();
269
270 let mut record = vps::find_by_name(config_override.as_deref(), vps_key)?
271 .ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
272 let target = crate::json_wire::ExecTarget::new(
276 vps_key,
277 crate::json_wire::TargetSource::Argv,
278 );
279 crate::json_wire::set_resolved_target(&target);
280
281 apply_scp_options(&mut record, &opts);
282
283 let path = crate::vps::resolve_config_path(config_override.as_deref())?;
284 let cfg = crate::vps::build_connection_config(
285 &record,
286 Some(&path),
287 opts.replace_host_key,
288 );
289
290 let client: Box<dyn SshClientTrait> =
291 <SshClient as SshClientTrait>::connect(cfg).await?;
292 run_scp_download_with_client(&target, &remote, &local, client, opts.json)
293 .await?;
294 }
295 }
296 }
297 }
298 Ok(())
299}
300
301pub(crate) fn apply_scp_options(record: &mut crate::vps::model::VpsRecord, opts: &ScpOptions) {
303 if let Some(ref pwd) = opts.password {
306 record.password = pwd.clone();
307 }
308 if let Some(ref k) = opts.key {
309 if let Ok(kp) = crate::domain::KeyPath::try_new(k.as_str()) {
310 record.key_path = Some(kp);
311 }
312 }
313 if let Some(ref kp) = opts.key_passphrase {
314 record.key_passphrase = Some(kp.clone());
315 }
316 if let Some(t) = opts.timeout {
318 record.timeout_ms = t;
319 }
320 if opts.use_agent {
322 record.use_agent = true;
323 }
324 if let Some(ref sock) = opts.agent_socket {
325 record.agent_socket = Some(sock.clone());
326 record.use_agent = true;
327 }
328}
329
330pub async fn run_scp_upload_with_client(
332 target: &crate::json_wire::ExecTarget,
333 local: &std::path::Path,
334 remote: &std::path::Path,
335 client: Box<dyn SshClientTrait>,
336 json: bool,
337) -> anyhow::Result<()> {
338 let result = client.upload(local, remote).await;
339 let _ = client.disconnect().await;
340 let result = result?;
341 if json {
342 output::print_transfer_json(
343 "upload",
344 target,
345 &local.display().to_string(),
346 &remote.display().to_string(),
347 &result,
348 )?;
349 } else {
350 output::print_success(&i18n::t(Message::ScpUploadCompleted {
351 bytes: result.bytes_transferred,
352 ms: result.duration_ms,
353 }));
354 }
355 Ok(())
356}
357
358pub async fn run_scp_download_with_client(
360 target: &crate::json_wire::ExecTarget,
361 remote: &std::path::Path,
362 local: &std::path::Path,
363 client: Box<dyn SshClientTrait>,
364 json: bool,
365) -> anyhow::Result<()> {
366 let result = client.download(remote, local).await;
367 let _ = client.disconnect().await;
368 let result = result?;
369 if json {
370 output::print_transfer_json(
371 "download",
372 target,
373 &local.display().to_string(),
374 &remote.display().to_string(),
375 &result,
376 )?;
377 } else {
378 output::print_success(&i18n::t(Message::ScpDownloadCompleted {
379 bytes: result.bytes_transferred,
380 ms: result.duration_ms,
381 }));
382 }
383 Ok(())
384}
385
386#[cfg(test)]
387#[path = "tests.rs"]
388mod tests;