Skip to main content

ssh_cli/
scp.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! File transfer via SCP over SSH (one-shot).
3//!
4//! Wrapper around [`SshClient`] `upload` and `download` methods.
5//! Regular files only (no `-r` / no SFTP subsystem).
6
7use 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/// Runtime overrides for the `scp` subcommand (parity with exec).
16#[derive(Debug, Default, Clone)]
17pub struct ScpOptions {
18    /// SSH password (already resolved from flag or stdin).
19    pub password: Option<String>,
20    /// Private key path.
21    pub key: Option<String>,
22    /// Key passphrase (already resolved).
23    pub key_passphrase: Option<String>,
24    /// Total connect+transfer timeout in ms.
25    pub timeout: Option<u64>,
26    /// Replace divergent host key (global `--replace-host-key`).
27    pub replace_host_key: bool,
28    /// Emit success JSON (local flag or global format).
29    pub json: bool,
30}
31
32/// Runs the SCP subcommand (upload/download).
33pub 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            // GAP-SSH-SCP-001 / SCP-019: validate file local antes do connect.
50            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                &registro,
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                &registro,
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
125/// Testable SCP upload that accepts the client as a parameter.
126pub 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
153/// Testable SCP download that accepts the client as a parameter.
154pub 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        // Connection to 127.0.0.1:1 must fail (no long hang; 100ms timeout).
399        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}