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!(i18n::t(Message::OperationCancelled)));
88 }
89
90 match action {
91 ScpAction::Upload {
92 all, hosts, target, ..
93 } => {
94 let plan = crate::cli::parse_scp_target(all, hosts, target)
95 .map_err(SshCliError::InvalidArgument)?;
96 match plan {
97 crate::cli::ScpPathPlan::MultiFile {
98 vps,
99 sources,
100 dest_dir,
101 } => {
102 return run_scp_multi_file_upload(
103 &vps,
104 sources,
105 &dest_dir,
106 config_override,
107 opts,
108 )
109 .await;
110 }
111 crate::cli::ScpPathPlan::MultiHostMultiFile {
112 selection,
113 sources,
114 dest_dir,
115 } => {
116 return run_scp_multi_host_multi_file_upload(
117 &selection,
118 sources,
119 &dest_dir,
120 config_override,
121 opts,
122 )
123 .await;
124 }
125 crate::cli::ScpPathPlan::Single {
126 selection,
127 path_a: local,
128 path_b: remote,
129 } => {
130 if local.is_dir() {
132 return Err(SshCliError::InvalidArgument(i18n::t(
133 Message::ScpUploadFileOnly,
134 ))
135 .into());
136 }
137 if !local.is_file() {
138 return Err(SshCliError::FileNotFound(local.display().to_string()).into());
139 }
140
141 if selection.is_batch() {
142 return run_scp_all_upload(
143 &selection,
144 &local,
145 &remote,
146 config_override,
147 opts,
148 )
149 .await;
150 }
151 let vps::HostSelection::Single(vps_name) = selection else {
152 return Err(SshCliError::InvalidArgument(
154 "internal: expected single-host selection for non-batch SCP".into(),
155 )
156 .into());
157 };
158 let vps_key = vps_name.as_str();
159
160 let mut record = vps::find_by_name(config_override.as_deref(), vps_key)?
161 .ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
162
163 apply_scp_options(&mut record, &opts);
164
165 let path = crate::vps::resolve_config_path(config_override.as_deref())?;
166 let cfg = crate::vps::build_connection_config(
167 &record,
168 Some(&path),
169 opts.replace_host_key,
170 );
171
172 let client: Box<dyn SshClientTrait> =
173 <SshClient as SshClientTrait>::connect(cfg).await?;
174 run_scp_upload_with_client(vps_key, &local, &remote, client, opts.json).await?;
175 }
176 }
177 }
178 ScpAction::Download {
179 all, hosts, target, ..
180 } => {
181 let plan = crate::cli::parse_scp_target(all, hosts, target)
182 .map_err(SshCliError::InvalidArgument)?;
183 match plan {
184 crate::cli::ScpPathPlan::MultiFile {
185 vps,
186 sources: remotes,
187 dest_dir: local_dir,
188 } => {
189 return run_scp_multi_file_download(
190 &vps,
191 remotes,
192 &local_dir,
193 config_override,
194 opts,
195 )
196 .await;
197 }
198 crate::cli::ScpPathPlan::MultiHostMultiFile {
199 selection,
200 sources: remotes,
201 dest_dir: local_dir,
202 } => {
203 return run_scp_multi_host_multi_file_download(
204 &selection,
205 remotes,
206 &local_dir,
207 config_override,
208 opts,
209 )
210 .await;
211 }
212 crate::cli::ScpPathPlan::Single {
213 selection,
214 path_a: remote,
215 path_b: local,
216 } => {
217 if selection.is_batch() {
218 return run_scp_all_download(
219 &selection,
220 &remote,
221 &local,
222 config_override,
223 opts,
224 )
225 .await;
226 }
227 if local.is_dir() {
228 return Err(SshCliError::InvalidArgument(i18n::t(
229 Message::ScpDownloadLocalNotDirectory,
230 ))
231 .into());
232 }
233 let vps::HostSelection::Single(vps_name) = selection else {
234 return Err(SshCliError::InvalidArgument(
236 "internal: expected single-host selection for non-batch SCP".into(),
237 )
238 .into());
239 };
240 let vps_key = vps_name.as_str();
241
242 let mut record = vps::find_by_name(config_override.as_deref(), vps_key)?
243 .ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
244
245 apply_scp_options(&mut record, &opts);
246
247 let path = crate::vps::resolve_config_path(config_override.as_deref())?;
248 let cfg = crate::vps::build_connection_config(
249 &record,
250 Some(&path),
251 opts.replace_host_key,
252 );
253
254 let client: Box<dyn SshClientTrait> =
255 <SshClient as SshClientTrait>::connect(cfg).await?;
256 run_scp_download_with_client(vps_key, &remote, &local, client, opts.json)
257 .await?;
258 }
259 }
260 }
261 }
262 Ok(())
263}
264
265pub(crate) fn apply_scp_options(record: &mut crate::vps::model::VpsRecord, opts: &ScpOptions) {
267 if let Some(ref pwd) = opts.password {
270 record.password = pwd.clone();
271 }
272 if let Some(ref k) = opts.key {
273 if let Ok(kp) = crate::domain::KeyPath::try_new(k.as_str()) {
274 record.key_path = Some(kp);
275 }
276 }
277 if let Some(ref kp) = opts.key_passphrase {
278 record.key_passphrase = Some(kp.clone());
279 }
280 if let Some(t) = opts.timeout {
282 record.timeout_ms = t;
283 }
284 if opts.use_agent {
286 record.use_agent = true;
287 }
288 if let Some(ref sock) = opts.agent_socket {
289 record.agent_socket = Some(sock.clone());
290 record.use_agent = true;
291 }
292}
293
294pub async fn run_scp_upload_with_client(
296 vps_name: &str,
297 local: &std::path::Path,
298 remote: &std::path::Path,
299 client: Box<dyn SshClientTrait>,
300 json: bool,
301) -> anyhow::Result<()> {
302 let result = client.upload(local, remote).await;
303 let _ = client.disconnect().await;
304 let result = result?;
305 if json {
306 output::print_transfer_json(
307 "upload",
308 vps_name,
309 &local.display().to_string(),
310 &remote.display().to_string(),
311 result.bytes_transferred,
312 result.duration_ms,
313 )?;
314 } else {
315 output::print_success(&i18n::t(Message::ScpUploadCompleted {
316 bytes: result.bytes_transferred,
317 ms: result.duration_ms,
318 }));
319 }
320 Ok(())
321}
322
323pub async fn run_scp_download_with_client(
325 vps_name: &str,
326 remote: &std::path::Path,
327 local: &std::path::Path,
328 client: Box<dyn SshClientTrait>,
329 json: bool,
330) -> anyhow::Result<()> {
331 let result = client.download(remote, local).await;
332 let _ = client.disconnect().await;
333 let result = result?;
334 if json {
335 output::print_transfer_json(
336 "download",
337 vps_name,
338 &local.display().to_string(),
339 &remote.display().to_string(),
340 result.bytes_transferred,
341 result.duration_ms,
342 )?;
343 } else {
344 output::print_success(&i18n::t(Message::ScpDownloadCompleted {
345 bytes: result.bytes_transferred,
346 ms: result.duration_ms,
347 }));
348 }
349 Ok(())
350}
351
352#[cfg(test)]
353#[path = "tests.rs"]
354mod tests;