1use crate::cli::ScpAction;
8use crate::erros::SshCliError;
9use crate::i18n::{self, Message};
10use crate::output;
11use crate::ssh::client::{SshClient, SshClientTrait};
12use crate::vps;
13use std::path::PathBuf;
14
15#[derive(Debug, Default, Clone)]
17pub struct ScpOptions {
18 pub password: Option<String>,
20 pub key: Option<String>,
22 pub key_passphrase: Option<String>,
24 pub timeout: Option<u64>,
26 pub replace_host_key: bool,
28 pub json: bool,
30}
31
32pub async fn run_scp(
34 action: ScpAction,
35 config_override: Option<PathBuf>,
36 opts: ScpOptions,
37) -> anyhow::Result<()> {
38 if crate::signals::is_cancelled() {
39 return Err(anyhow::anyhow!(i18n::t(Message::OperationCancelled)));
40 }
41
42 match action {
43 ScpAction::Upload {
44 vps_name,
45 local,
46 remote,
47 ..
48 } => {
49 if local.is_dir() {
51 return Err(SshCliError::InvalidArgument(i18n::t(
52 Message::ScpUploadFileOnly,
53 ))
54 .into());
55 }
56 if !local.is_file() {
57 return Err(SshCliError::FileNotFound(local.display().to_string()).into());
58 }
59
60 let mut registro = vps::find_by_name(config_override.clone(), &vps_name)?
61 .ok_or_else(|| SshCliError::VpsNotFound(vps_name.clone()))?;
62
63 apply_scp_options(&mut registro, &opts);
64
65 let path = crate::vps::resolve_config_path(config_override.clone())?;
66 let cfg = crate::vps::build_connection_config(
67 ®istro,
68 Some(&path),
69 opts.replace_host_key,
70 );
71
72 let client: Box<dyn SshClientTrait> =
73 <SshClient as SshClientTrait>::connect(cfg).await?;
74 run_scp_upload_with_client(&vps_name, &local, &remote, client, opts.json).await?;
75 }
76 ScpAction::Download {
77 vps_name,
78 remote,
79 local,
80 ..
81 } => {
82 if local.is_dir() {
83 return Err(SshCliError::InvalidArgument(i18n::t(
84 Message::ScpDownloadLocalNotDirectory,
85 ))
86 .into());
87 }
88
89 let mut registro = vps::find_by_name(config_override.clone(), &vps_name)?
90 .ok_or_else(|| SshCliError::VpsNotFound(vps_name.clone()))?;
91
92 apply_scp_options(&mut registro, &opts);
93
94 let path = crate::vps::resolve_config_path(config_override.clone())?;
95 let cfg = crate::vps::build_connection_config(
96 ®istro,
97 Some(&path),
98 opts.replace_host_key,
99 );
100
101 let client: Box<dyn SshClientTrait> =
102 <SshClient as SshClientTrait>::connect(cfg).await?;
103 run_scp_download_with_client(&vps_name, &remote, &local, client, opts.json)
104 .await?;
105 }
106 }
107 Ok(())
108}
109
110fn apply_scp_options(registro: &mut crate::vps::model::VpsRecord, opts: &ScpOptions) {
111 if let Some(ref pwd) = opts.password {
112 registro.password = secrecy::SecretString::from(pwd.clone());
113 }
114 if let Some(ref k) = opts.key {
115 registro.key_path = Some(k.clone());
116 }
117 if let Some(ref kp) = opts.key_passphrase {
118 registro.key_passphrase = Some(secrecy::SecretString::from(kp.clone()));
119 }
120 if let Some(t) = opts.timeout {
121 registro.timeout_ms = t;
122 }
123}
124
125pub async fn run_scp_upload_with_client(
127 vps_name: &str,
128 local: &std::path::Path,
129 remote: &std::path::Path,
130 mut client: Box<dyn SshClientTrait>,
131 json: bool,
132) -> anyhow::Result<()> {
133 let result = client.upload(local, remote).await?;
134 client.disconnect().await?;
135 if json {
136 output::print_transfer_json(
137 "upload",
138 vps_name,
139 &local.display().to_string(),
140 &remote.display().to_string(),
141 result.bytes_transferred,
142 result.duration_ms,
143 );
144 } else {
145 output::print_success(&i18n::t(Message::ScpUploadCompleted {
146 bytes: result.bytes_transferred,
147 ms: result.duration_ms,
148 }));
149 }
150 Ok(())
151}
152
153pub async fn run_scp_download_with_client(
155 vps_name: &str,
156 remote: &std::path::Path,
157 local: &std::path::Path,
158 mut client: Box<dyn SshClientTrait>,
159 json: bool,
160) -> anyhow::Result<()> {
161 let result = client.download(remote, local).await?;
162 client.disconnect().await?;
163 if json {
164 output::print_transfer_json(
165 "download",
166 vps_name,
167 &local.display().to_string(),
168 &remote.display().to_string(),
169 result.bytes_transferred,
170 result.duration_ms,
171 );
172 } else {
173 output::print_success(&i18n::t(Message::ScpDownloadCompleted {
174 bytes: result.bytes_transferred,
175 ms: result.duration_ms,
176 }));
177 }
178 Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::erros::SshCliError;
185 use crate::ssh::client::{
186 TunnelChannel, ConnectionConfig, ExecutionOutput, TransferResult,
187 };
188 use crate::vps::model::{VpsRecord, CURRENT_SCHEMA_VERSION};
189 use crate::vps::{self, ConfigFile};
190 use async_trait::async_trait;
191 use secrecy::SecretString;
192 use serial_test::serial;
193 use std::collections::BTreeMap;
194 use std::path::Path;
195 use tempfile::TempDir;
196
197 struct FakeScpClient {
198 upload_ok: bool,
199 download_ok: bool,
200 bytes_upload: u64,
201 bytes_download: u64,
202 }
203
204 #[async_trait]
205 impl SshClientTrait for FakeScpClient {
206 async fn connect(_cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError> {
207 Err(SshCliError::ConnectionFailed(
208 "not implemented in test".to_string(),
209 ))
210 }
211
212 async fn run_command(
213 &mut self,
214 _cmd: &str,
215 _max_chars: usize,
216 _stdin_data: Option<Vec<u8>>,
217 ) -> Result<ExecutionOutput, SshCliError> {
218 Err(SshCliError::ChannelFailed(
219 "not implemented in test".to_string(),
220 ))
221 }
222
223 async fn upload(
224 &mut self,
225 _local: &Path,
226 _remote: &Path,
227 ) -> Result<TransferResult, SshCliError> {
228 if self.upload_ok {
229 Ok(TransferResult {
230 bytes_transferred: self.bytes_upload,
231 duration_ms: 10,
232 })
233 } else {
234 Err(SshCliError::ChannelFailed("upload failed".to_string()))
235 }
236 }
237
238 async fn download(
239 &mut self,
240 _remote: &Path,
241 _local: &Path,
242 ) -> Result<TransferResult, SshCliError> {
243 if self.download_ok {
244 Ok(TransferResult {
245 bytes_transferred: self.bytes_download,
246 duration_ms: 20,
247 })
248 } else {
249 Err(SshCliError::ChannelFailed("download failed".to_string()))
250 }
251 }
252
253 async fn open_tunnel_channel(
254 &self,
255 _host_remoto: &str,
256 _porta_remota: u16,
257 _endereco_origem: &str,
258 _porta_origem: u16,
259 ) -> Result<Box<dyn TunnelChannel>, SshCliError> {
260 Err(SshCliError::ChannelFailed(
261 "not implemented in test".to_string(),
262 ))
263 }
264
265 async fn disconnect(&self) -> Result<(), SshCliError> {
266 Ok(())
267 }
268 }
269
270 fn registro_teste(name: &str) -> VpsRecord {
271 VpsRecord::new(
272 name.to_string(),
273 "127.0.0.1".to_string(),
274 1,
275 "root".to_string(),
276 SecretString::from("senha-teste".to_string()),
277 None,
278 None,
279 Some(100),
280 Some(1000),
281 Some(1000),
282 None,
283 None,
284 false,
285 )
286 }
287
288 fn save_config_with_vps(tmp: &TempDir, name: &str) {
289 let mut hosts = BTreeMap::new();
290 hosts.insert(name.to_string(), registro_teste(name));
291 let file = ConfigFile {
292 schema_version: CURRENT_SCHEMA_VERSION,
293 hosts,
294 };
295 let path = tmp.path().join("config.toml");
296 vps::save(&path, &file).expect("save test config");
297 }
298
299 #[tokio::test]
300 async fn scp_upload_with_client_returns_ok() {
301 let client = Box::new(FakeScpClient {
302 upload_ok: true,
303 download_ok: true,
304 bytes_upload: 128,
305 bytes_download: 0,
306 });
307 let local = Path::new("/tmp/local.txt");
308 let remote = Path::new("/tmp/remote.txt");
309 let result = run_scp_upload_with_client("v1", local, remote, client, false).await;
310 assert!(result.is_ok());
311 }
312
313 #[tokio::test]
314 async fn scp_download_with_client_returns_ok() {
315 let client = Box::new(FakeScpClient {
316 upload_ok: true,
317 download_ok: true,
318 bytes_upload: 0,
319 bytes_download: 256,
320 });
321 let result = run_scp_download_with_client(
322 "v1",
323 Path::new("/tmp/remote.txt"),
324 Path::new("/tmp/local.txt"),
325 client,
326 false,
327 )
328 .await;
329 assert!(result.is_ok());
330 }
331
332 #[tokio::test]
333 async fn scp_upload_with_client_returns_error() {
334 let client = Box::new(FakeScpClient {
335 upload_ok: false,
336 download_ok: true,
337 bytes_upload: 0,
338 bytes_download: 0,
339 });
340 let result = run_scp_upload_with_client(
341 "v1",
342 Path::new("/tmp/local.txt"),
343 Path::new("/tmp/remote.txt"),
344 client,
345 false,
346 )
347 .await;
348 assert!(result.is_err());
349 }
350
351 #[tokio::test]
352 async fn scp_download_with_client_returns_error() {
353 let client = Box::new(FakeScpClient {
354 upload_ok: true,
355 download_ok: false,
356 bytes_upload: 0,
357 bytes_download: 0,
358 });
359 let result = run_scp_download_with_client(
360 "v1",
361 Path::new("/tmp/remote.txt"),
362 Path::new("/tmp/local.txt"),
363 client,
364 false,
365 )
366 .await;
367 assert!(result.is_err());
368 }
369
370 #[tokio::test]
371 #[serial]
372 async fn scp_upload_tries_connect_when_vps_exists() {
373 let tmp = TempDir::new().unwrap();
374 save_config_with_vps(&tmp, "vps-upload");
375 let local = tmp.path().join("local.bin");
376 std::fs::write(&local, b"abc").unwrap();
377 let action = ScpAction::Upload {
378 vps_name: "vps-upload".to_string(),
379 local,
380 remote: PathBuf::from("/tmp/x"),
381 password: None,
382 password_stdin: false,
383 key: None,
384 key_passphrase: None,
385 key_passphrase_stdin: false,
386 timeout: Some(100),
387 json: false,
388 };
389 let r = run_scp(
390 action,
391 Some(tmp.path().to_path_buf()),
392 ScpOptions {
393 timeout: Some(100),
394 ..Default::default()
395 },
396 )
397 .await;
398 assert!(r.is_err());
400 }
401
402 #[tokio::test]
403 #[serial]
404 async fn scp_download_tries_connect_when_vps_exists() {
405 let tmp = TempDir::new().unwrap();
406 save_config_with_vps(&tmp, "vps-download");
407 let action = ScpAction::Download {
408 vps_name: "vps-download".to_string(),
409 remote: PathBuf::from("/tmp/x"),
410 local: tmp.path().join("out.bin"),
411 password: None,
412 password_stdin: false,
413 key: None,
414 key_passphrase: None,
415 key_passphrase_stdin: false,
416 timeout: Some(100),
417 json: false,
418 };
419 let r = run_scp(
420 action,
421 Some(tmp.path().to_path_buf()),
422 ScpOptions {
423 timeout: Some(100),
424 ..Default::default()
425 },
426 )
427 .await;
428 assert!(r.is_err());
429 }
430
431 #[tokio::test]
432 #[serial]
433 async fn scp_upload_rejects_directory() {
434 let tmp = TempDir::new().unwrap();
435 save_config_with_vps(&tmp, "vps-dir");
436 let action = ScpAction::Upload {
437 vps_name: "vps-dir".to_string(),
438 local: tmp.path().to_path_buf(),
439 remote: PathBuf::from("/tmp/x"),
440 password: None,
441 password_stdin: false,
442 key: None,
443 key_passphrase: None,
444 key_passphrase_stdin: false,
445 timeout: None,
446 json: false,
447 };
448 let r = run_scp(action, Some(tmp.path().to_path_buf()), ScpOptions::default()).await;
449 assert!(r.is_err());
450 let msg = format!("{:?}", r.err().unwrap());
451 assert!(
452 msg.contains("regular files")
453 || msg.contains("arquivos regulares")
454 || msg.contains("InvalidArgument"),
455 "msg={msg}"
456 );
457 }
458}