Skip to main content

ssh_cli/
scp.rs

1//! Transferência de arquivos via SCP sobre SSH (one-shot).
2//!
3//! Wrapper que usa os métodos `upload` e `download` do [`ClienteSsh`].
4//! Somente arquivos regulares (sem `-r` / sem SFTP subsystem).
5
6use crate::cli::AcaoScp;
7use crate::erros::ErroSshCli;
8use crate::i18n::{self, Mensagem};
9use crate::output;
10use crate::ssh::cliente::{ClienteSsh, ClienteSshTrait};
11use crate::vps;
12use std::path::PathBuf;
13
14/// Overrides de runtime para o subcomando `scp` (paridade com exec).
15#[derive(Debug, Default, Clone)]
16pub struct OpcoesScp {
17    /// Senha SSH (já resolvida de flag ou stdin).
18    pub password: Option<String>,
19    /// Caminho da chave privada.
20    pub key: Option<String>,
21    /// Passphrase da chave (já resolvida).
22    pub key_passphrase: Option<String>,
23    /// Timeout total connect+transfer em ms.
24    pub timeout: Option<u64>,
25    /// Substitui host key divergente (global `--replace-host-key`).
26    pub replace_host_key: bool,
27    /// Emite JSON de sucesso (flag local ou formato global).
28    pub json: bool,
29}
30
31/// Executa o subcomando SCP (upload/download).
32pub async fn executar_scp(
33    acao: AcaoScp,
34    config_override: Option<PathBuf>,
35    opts: OpcoesScp,
36) -> anyhow::Result<()> {
37    if crate::signals::cancelado() {
38        return Err(anyhow::anyhow!(i18n::t(Mensagem::OperacaoCancelada)));
39    }
40
41    match acao {
42        AcaoScp::Upload {
43            vps_nome,
44            local,
45            remote,
46            ..
47        } => {
48            // GAP-SSH-SCP-001 / SCP-019: validar arquivo local antes do connect.
49            if local.is_dir() {
50                return Err(ErroSshCli::ArgumentoInvalido(i18n::t(
51                    Mensagem::ScpUploadSomenteArquivo,
52                ))
53                .into());
54            }
55            if !local.is_file() {
56                return Err(ErroSshCli::ArquivoNaoEncontrado(local.display().to_string()).into());
57            }
58
59            let mut registro = vps::buscar_por_nome(config_override.clone(), &vps_nome)?
60                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.clone()))?;
61
62            aplicar_opcoes_scp(&mut registro, &opts);
63
64            let caminho = crate::vps::resolver_caminho_config(config_override.clone())?;
65            let cfg = crate::vps::construir_configuracao(
66                &registro,
67                Some(&caminho),
68                opts.replace_host_key,
69            );
70
71            let cliente: Box<dyn ClienteSshTrait> =
72                <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
73            executar_scp_upload_with_client(&vps_nome, &local, &remote, cliente, opts.json).await?;
74        }
75        AcaoScp::Download {
76            vps_nome,
77            remote,
78            local,
79            ..
80        } => {
81            if local.is_dir() {
82                return Err(ErroSshCli::ArgumentoInvalido(i18n::t(
83                    Mensagem::ScpDownloadLocalNaoDiretorio,
84                ))
85                .into());
86            }
87
88            let mut registro = vps::buscar_por_nome(config_override.clone(), &vps_nome)?
89                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.clone()))?;
90
91            aplicar_opcoes_scp(&mut registro, &opts);
92
93            let caminho = crate::vps::resolver_caminho_config(config_override.clone())?;
94            let cfg = crate::vps::construir_configuracao(
95                &registro,
96                Some(&caminho),
97                opts.replace_host_key,
98            );
99
100            let cliente: Box<dyn ClienteSshTrait> =
101                <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
102            executar_scp_download_with_client(&vps_nome, &remote, &local, cliente, opts.json)
103                .await?;
104        }
105    }
106    Ok(())
107}
108
109fn aplicar_opcoes_scp(registro: &mut crate::vps::modelo::VpsRegistro, opts: &OpcoesScp) {
110    if let Some(ref pwd) = opts.password {
111        registro.senha = secrecy::SecretString::from(pwd.clone());
112    }
113    if let Some(ref k) = opts.key {
114        registro.key_path = Some(k.clone());
115    }
116    if let Some(ref kp) = opts.key_passphrase {
117        registro.key_passphrase = Some(secrecy::SecretString::from(kp.clone()));
118    }
119    if let Some(t) = opts.timeout {
120        registro.timeout_ms = t;
121    }
122}
123
124/// Versão testável de upload SCP que aceita o cliente como parâmetro.
125pub async fn executar_scp_upload_with_client(
126    vps_nome: &str,
127    local: &std::path::Path,
128    remote: &std::path::Path,
129    mut cliente: Box<dyn ClienteSshTrait>,
130    json: bool,
131) -> anyhow::Result<()> {
132    let resultado = cliente.upload(local, remote).await?;
133    cliente.desconectar().await?;
134    if json {
135        output::imprimir_transferencia_json(
136            "upload",
137            vps_nome,
138            &local.display().to_string(),
139            &remote.display().to_string(),
140            resultado.bytes_transferidos,
141            resultado.duracao_ms,
142        );
143    } else {
144        output::imprimir_sucesso(&i18n::t(Mensagem::ScpUploadConcluido {
145            bytes: resultado.bytes_transferidos,
146            ms: resultado.duracao_ms,
147        }));
148    }
149    Ok(())
150}
151
152/// Versão testável de download SCP que aceita o cliente como parâmetro.
153pub async fn executar_scp_download_with_client(
154    vps_nome: &str,
155    remote: &std::path::Path,
156    local: &std::path::Path,
157    mut cliente: Box<dyn ClienteSshTrait>,
158    json: bool,
159) -> anyhow::Result<()> {
160    let resultado = cliente.download(remote, local).await?;
161    cliente.desconectar().await?;
162    if json {
163        output::imprimir_transferencia_json(
164            "download",
165            vps_nome,
166            &local.display().to_string(),
167            &remote.display().to_string(),
168            resultado.bytes_transferidos,
169            resultado.duracao_ms,
170        );
171    } else {
172        output::imprimir_sucesso(&i18n::t(Mensagem::ScpDownloadConcluido {
173            bytes: resultado.bytes_transferidos,
174            ms: resultado.duracao_ms,
175        }));
176    }
177    Ok(())
178}
179
180#[cfg(test)]
181mod testes {
182    use super::*;
183    use crate::erros::ErroSshCli;
184    use crate::ssh::cliente::{
185        CanalTunel, ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado,
186    };
187    use crate::vps::modelo::{VpsRegistro, SCHEMA_VERSION_ATUAL};
188    use crate::vps::{self, ArquivoConfig};
189    use async_trait::async_trait;
190    use secrecy::SecretString;
191    use serial_test::serial;
192    use std::collections::BTreeMap;
193    use std::path::Path;
194    use tempfile::TempDir;
195
196    struct ClienteFakeScp {
197        upload_ok: bool,
198        download_ok: bool,
199        bytes_upload: u64,
200        bytes_download: u64,
201    }
202
203    #[async_trait]
204    impl ClienteSshTrait for ClienteFakeScp {
205        async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
206            Err(ErroSshCli::ConexaoFalhou(
207                "não implementado em teste".to_string(),
208            ))
209        }
210
211        async fn executar_comando(
212            &mut self,
213            _cmd: &str,
214            _max_chars: usize,
215            _stdin_data: Option<Vec<u8>>,
216        ) -> Result<SaidaExecucao, ErroSshCli> {
217            Err(ErroSshCli::CanalFalhou(
218                "não implementado em teste".to_string(),
219            ))
220        }
221
222        async fn upload(
223            &mut self,
224            _local: &Path,
225            _remote: &Path,
226        ) -> Result<TransferenciaResultado, ErroSshCli> {
227            if self.upload_ok {
228                Ok(TransferenciaResultado {
229                    bytes_transferidos: self.bytes_upload,
230                    duracao_ms: 10,
231                })
232            } else {
233                Err(ErroSshCli::CanalFalhou("upload falhou".to_string()))
234            }
235        }
236
237        async fn download(
238            &mut self,
239            _remote: &Path,
240            _local: &Path,
241        ) -> Result<TransferenciaResultado, ErroSshCli> {
242            if self.download_ok {
243                Ok(TransferenciaResultado {
244                    bytes_transferidos: self.bytes_download,
245                    duracao_ms: 20,
246                })
247            } else {
248                Err(ErroSshCli::CanalFalhou("download falhou".to_string()))
249            }
250        }
251
252        async fn abrir_canal_tunel(
253            &self,
254            _host_remoto: &str,
255            _porta_remota: u16,
256            _endereco_origem: &str,
257            _porta_origem: u16,
258        ) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
259            Err(ErroSshCli::CanalFalhou(
260                "não implementado em teste".to_string(),
261            ))
262        }
263
264        async fn desconectar(&self) -> Result<(), ErroSshCli> {
265            Ok(())
266        }
267    }
268
269    fn registro_teste(nome: &str) -> VpsRegistro {
270        VpsRegistro::novo(
271            nome.to_string(),
272            "127.0.0.1".to_string(),
273            1,
274            "root".to_string(),
275            SecretString::from("senha-teste".to_string()),
276            None,
277            None,
278            Some(100),
279            Some(1000),
280            Some(1000),
281            None,
282            None,
283            false,
284        )
285    }
286
287    fn salvar_config_com_vps(tmp: &TempDir, nome: &str) {
288        let mut hosts = BTreeMap::new();
289        hosts.insert(nome.to_string(), registro_teste(nome));
290        let arquivo = ArquivoConfig {
291            schema_version: SCHEMA_VERSION_ATUAL,
292            hosts,
293        };
294        let caminho = tmp.path().join("config.toml");
295        vps::salvar(&caminho, &arquivo).expect("salvar config teste");
296    }
297
298    #[tokio::test]
299    async fn executar_scp_upload_with_client_retorna_ok() {
300        let cliente = Box::new(ClienteFakeScp {
301            upload_ok: true,
302            download_ok: true,
303            bytes_upload: 128,
304            bytes_download: 0,
305        });
306        let local = Path::new("/tmp/local.txt");
307        let remote = Path::new("/tmp/remote.txt");
308        let resultado = executar_scp_upload_with_client("v1", local, remote, cliente, false).await;
309        assert!(resultado.is_ok());
310    }
311
312    #[tokio::test]
313    async fn executar_scp_download_with_client_retorna_ok() {
314        let cliente = Box::new(ClienteFakeScp {
315            upload_ok: true,
316            download_ok: true,
317            bytes_upload: 0,
318            bytes_download: 256,
319        });
320        let resultado = executar_scp_download_with_client(
321            "v1",
322            Path::new("/tmp/remote.txt"),
323            Path::new("/tmp/local.txt"),
324            cliente,
325            false,
326        )
327        .await;
328        assert!(resultado.is_ok());
329    }
330
331    #[tokio::test]
332    async fn executar_scp_upload_with_client_retorna_erro() {
333        let cliente = Box::new(ClienteFakeScp {
334            upload_ok: false,
335            download_ok: true,
336            bytes_upload: 0,
337            bytes_download: 0,
338        });
339        let resultado = executar_scp_upload_with_client(
340            "v1",
341            Path::new("/tmp/local.txt"),
342            Path::new("/tmp/remote.txt"),
343            cliente,
344            false,
345        )
346        .await;
347        assert!(resultado.is_err());
348    }
349
350    #[tokio::test]
351    async fn executar_scp_download_with_client_retorna_erro() {
352        let cliente = Box::new(ClienteFakeScp {
353            upload_ok: true,
354            download_ok: false,
355            bytes_upload: 0,
356            bytes_download: 0,
357        });
358        let resultado = executar_scp_download_with_client(
359            "v1",
360            Path::new("/tmp/remote.txt"),
361            Path::new("/tmp/local.txt"),
362            cliente,
363            false,
364        )
365        .await;
366        assert!(resultado.is_err());
367    }
368
369    #[tokio::test]
370    #[serial]
371    async fn executar_scp_upload_tenta_conectar_quando_vps_existe() {
372        let tmp = TempDir::new().unwrap();
373        salvar_config_com_vps(&tmp, "vps-upload");
374        let local = tmp.path().join("local.bin");
375        std::fs::write(&local, b"abc").unwrap();
376        let acao = AcaoScp::Upload {
377            vps_nome: "vps-upload".to_string(),
378            local,
379            remote: PathBuf::from("/tmp/x"),
380            password: None,
381            password_stdin: false,
382            key: None,
383            key_passphrase: None,
384            key_passphrase_stdin: false,
385            timeout: Some(100),
386            json: false,
387        };
388        let r = executar_scp(
389            acao,
390            Some(tmp.path().to_path_buf()),
391            OpcoesScp {
392                timeout: Some(100),
393                ..Default::default()
394            },
395        )
396        .await;
397        // Conexão a 127.0.0.1:1 deve falhar (sem hang longo por timeout 100ms).
398        assert!(r.is_err());
399    }
400
401    #[tokio::test]
402    #[serial]
403    async fn executar_scp_download_tenta_conectar_quando_vps_existe() {
404        let tmp = TempDir::new().unwrap();
405        salvar_config_com_vps(&tmp, "vps-download");
406        let acao = AcaoScp::Download {
407            vps_nome: "vps-download".to_string(),
408            remote: PathBuf::from("/tmp/x"),
409            local: tmp.path().join("out.bin"),
410            password: None,
411            password_stdin: false,
412            key: None,
413            key_passphrase: None,
414            key_passphrase_stdin: false,
415            timeout: Some(100),
416            json: false,
417        };
418        let r = executar_scp(
419            acao,
420            Some(tmp.path().to_path_buf()),
421            OpcoesScp {
422                timeout: Some(100),
423                ..Default::default()
424            },
425        )
426        .await;
427        assert!(r.is_err());
428    }
429
430    #[tokio::test]
431    #[serial]
432    async fn executar_scp_upload_rejeita_diretorio() {
433        let tmp = TempDir::new().unwrap();
434        salvar_config_com_vps(&tmp, "vps-dir");
435        let acao = AcaoScp::Upload {
436            vps_nome: "vps-dir".to_string(),
437            local: tmp.path().to_path_buf(),
438            remote: PathBuf::from("/tmp/x"),
439            password: None,
440            password_stdin: false,
441            key: None,
442            key_passphrase: None,
443            key_passphrase_stdin: false,
444            timeout: None,
445            json: false,
446        };
447        let r = executar_scp(acao, Some(tmp.path().to_path_buf()), OpcoesScp::default()).await;
448        assert!(r.is_err());
449        let msg = format!("{:?}", r.err().unwrap());
450        assert!(
451            msg.contains("regular files")
452                || msg.contains("arquivos regulares")
453                || msg.contains("ArgumentoInvalido"),
454            "msg={msg}"
455        );
456    }
457}