use crate::erros::{ErroSshCli, ResultadoSshCli};
use secrecy::SecretString;
use tokio::io::{AsyncRead, AsyncWrite};
#[derive(Clone)]
pub struct ConfiguracaoConexao {
pub host: String,
pub porta: u16,
pub usuario: String,
pub senha: SecretString,
pub timeout_ms: u64,
}
impl std::fmt::Debug for ConfiguracaoConexao {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConfiguracaoConexao")
.field("host", &self.host)
.field("porta", &self.porta)
.field("usuario", &self.usuario)
.field("senha", &"<redacted>")
.field("timeout_ms", &self.timeout_ms)
.finish()
}
}
impl ConfiguracaoConexao {
pub fn validar(&self) -> ResultadoSshCli<()> {
if self.host.trim().is_empty() {
return Err(ErroSshCli::ArgumentoInvalido(
"host vazio em ConfiguracaoConexao".to_string(),
));
}
if self.porta == 0 {
return Err(ErroSshCli::ArgumentoInvalido(
"porta 0 inválida em ConfiguracaoConexao".to_string(),
));
}
if self.usuario.trim().is_empty() {
return Err(ErroSshCli::ArgumentoInvalido(
"usuário vazio em ConfiguracaoConexao".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SaidaExecucao {
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
pub truncado_stdout: bool,
pub truncado_stderr: bool,
pub duracao_ms: u64,
}
#[derive(Debug, Clone)]
pub struct TransferenciaResultado {
pub bytes_transferidos: u64,
pub duracao_ms: u64,
}
#[must_use]
pub fn truncar_utf8(conteudo: &str, max_chars: usize) -> (String, bool) {
let total = conteudo.chars().count();
if total <= max_chars {
return (conteudo.to_string(), false);
}
let truncado: String = conteudo.chars().take(max_chars).collect();
(truncado, true)
}
use async_trait::async_trait;
use std::path::Path;
pub trait CanalTunel: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T> CanalTunel for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
#[async_trait]
pub trait ClienteSshTrait: Send + Sync + 'static {
async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli>
where
Self: Sized;
async fn executar_comando(
&mut self,
cmd: &str,
max_chars: usize,
) -> Result<SaidaExecucao, ErroSshCli>;
async fn upload(
&mut self,
local: &Path,
remote: &Path,
) -> Result<TransferenciaResultado, ErroSshCli>;
async fn download(
&mut self,
remote: &Path,
local: &Path,
) -> Result<TransferenciaResultado, ErroSshCli>;
async fn abrir_canal_tunel(
&self,
host_remoto: &str,
porta_remota: u16,
endereco_origem: &str,
porta_origem: u16,
) -> Result<Box<dyn CanalTunel>, ErroSshCli>;
async fn desconectar(&self) -> Result<(), ErroSshCli>;
}
#[cfg(test)]
pub mod mocks {
use super::*;
use mockall::mock;
mock! {
pub ClienteSsh {}
#[async_trait]
impl crate::ssh::cliente::ClienteSshTrait for ClienteSsh {
async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli>;
async fn executar_comando(&mut self, cmd: &str, max_chars: usize) -> Result<SaidaExecucao, ErroSshCli>;
async fn upload(&mut self, local: &Path, remote: &Path) -> Result<TransferenciaResultado, ErroSshCli>;
async fn download(&mut self, remote: &Path, local: &Path) -> Result<TransferenciaResultado, ErroSshCli>;
async fn abrir_canal_tunel(
&self,
host_remoto: &str,
porta_remota: u16,
endereco_origem: &str,
porta_origem: u16,
) -> Result<Box<dyn CanalTunel>, ErroSshCli>;
async fn desconectar(&self) -> Result<(), ErroSshCli>;
}
}
}
#[cfg(feature = "ssh-real")]
mod real {
use super::{
CanalTunel, ClienteSshTrait, ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado,
};
use crate::erros::{ErroSshCli, ResultadoSshCli};
use async_trait::async_trait;
use secrecy::ExposeSecret;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct ManipuladorCliente;
impl russh::client::Handler for ManipuladorCliente {
type Error = russh::Error;
async fn check_server_key(
&mut self,
_chave_servidor: &russh::keys::ssh_key::PublicKey,
) -> Result<bool, Self::Error> {
tracing::warn!("check_server_key aceita TODA chave (iteração 2: sem known_hosts)");
Ok(true)
}
}
pub struct ClienteSsh {
pub sessao: russh::client::Handle<ManipuladorCliente>,
cfg: ConfiguracaoConexao,
}
impl std::fmt::Debug for ClienteSsh {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClienteSsh")
.field("host", &self.cfg.host)
.field("porta", &self.cfg.porta)
.field("usuario", &self.cfg.usuario)
.field("timeout_ms", &self.cfg.timeout_ms)
.finish()
}
}
fn mapear_exit_status(exit_status: u32) -> i32 {
i32::try_from(exit_status).unwrap_or(-1)
}
fn processar_mensagem_exec(
msg: russh::ChannelMsg,
stdout_bytes: &mut Vec<u8>,
stderr_bytes: &mut Vec<u8>,
exit_code: &mut Option<i32>,
) -> bool {
use russh::ChannelMsg;
match msg {
ChannelMsg::Data { data } => {
stdout_bytes.extend_from_slice(&data);
}
ChannelMsg::ExtendedData { data, ext } => {
if ext == 1 {
stderr_bytes.extend_from_slice(&data);
} else {
tracing::debug!(ext, "dados estendidos ignorados");
}
}
ChannelMsg::ExitStatus { exit_status } => {
*exit_code = Some(mapear_exit_status(exit_status));
}
ChannelMsg::ExitSignal {
signal_name,
core_dumped,
error_message,
..
} => {
tracing::warn!(
?signal_name,
core_dumped,
%error_message,
"processo remoto terminou por sinal"
);
}
ChannelMsg::Eof => {
tracing::debug!("EOF no canal SSH");
}
ChannelMsg::Close => {
tracing::debug!("canal SSH fechado pelo servidor");
return true;
}
_ => {}
}
false
}
fn formatar_header_upload_scp(tamanho: u64, nome_arquivo: &str) -> String {
format!("C0644 {} {}\\n", tamanho, nome_arquivo)
}
fn parse_header_scp(header: &str) -> ResultadoSshCli<u64> {
let header = header.trim();
if !header.starts_with('C') {
return Err(ErroSshCli::CanalFalhou(format!(
"header SCP inesperado: {}",
header
)));
}
let partes: Vec<&str> = header.split_whitespace().collect();
if partes.len() < 3 {
return Err(ErroSshCli::CanalFalhou(format!(
"header SCP mal formatado: {}",
header
)));
}
partes[1].parse().map_err(|_| {
ErroSshCli::CanalFalhou(format!("tamanho inválido no header: {}", partes[1]))
})
}
impl ClienteSsh {
pub async fn conectar(cfg: ConfiguracaoConexao) -> ResultadoSshCli<Self> {
cfg.validar()?;
let timeout = Duration::from_millis(cfg.timeout_ms);
let host = cfg.host.clone();
let porta = cfg.porta;
let usuario = cfg.usuario.clone();
let senha_segura = cfg.senha.clone();
let config_cliente = Arc::new(russh::client::Config {
inactivity_timeout: Some(timeout),
..Default::default()
});
tracing::info!(
host = %host,
porta,
usuario = %usuario,
timeout_ms = cfg.timeout_ms,
"iniciando conexão SSH"
);
let resultado_conexao = tokio::time::timeout(timeout, async move {
let mut sessao = russh::client::connect(
config_cliente,
(host.as_str(), porta),
ManipuladorCliente,
)
.await
.map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha TCP/handshake: {e}")))?;
let auth = sessao
.authenticate_password(usuario.clone(), senha_segura.expose_secret())
.await
.map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha auth transport: {e}")))?;
if !auth.success() {
tracing::warn!(
host = %host,
usuario = %usuario,
"autenticação SSH rejeitada"
);
return Err(ErroSshCli::AutenticacaoFalhou);
}
Ok::<_, ErroSshCli>(sessao)
})
.await;
let sessao = match resultado_conexao {
Ok(Ok(s)) => s,
Ok(Err(erro)) => return Err(erro),
Err(_) => return Err(ErroSshCli::TimeoutSsh(cfg.timeout_ms)),
};
tracing::info!("conexão SSH autenticada com sucesso");
Ok(Self { sessao, cfg })
}
pub async fn executar_comando(
&mut self,
comando: &str,
max_chars: usize,
) -> ResultadoSshCli<SaidaExecucao> {
let inicio = Instant::now();
let timeout = Duration::from_millis(self.cfg.timeout_ms);
let resultado = tokio::time::timeout(timeout, async {
let mut canal = self
.sessao
.channel_open_session()
.await
.map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão: {e}")))?;
canal
.exec(true, comando)
.await
.map_err(|e| ErroSshCli::CanalFalhou(format!("exec: {e}")))?;
let mut stdout_bytes: Vec<u8> = Vec::new();
let mut stderr_bytes: Vec<u8> = Vec::new();
let mut exit_code: Option<i32> = None;
while let Some(msg) = canal.wait().await {
if processar_mensagem_exec(
msg,
&mut stdout_bytes,
&mut stderr_bytes,
&mut exit_code,
) {
break;
}
}
Ok::<_, ErroSshCli>((stdout_bytes, stderr_bytes, exit_code))
})
.await;
let (stdout_bytes, stderr_bytes, exit_code) = match resultado {
Ok(Ok(t)) => t,
Ok(Err(erro)) => return Err(erro),
Err(_) => return Err(ErroSshCli::TimeoutSsh(self.cfg.timeout_ms)),
};
let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
let (stdout_truncado, truncado_stdout) = super::truncar_utf8(&stdout_str, max_chars);
let (stderr_truncado, truncado_stderr) = super::truncar_utf8(&stderr_str, max_chars);
let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
Ok(SaidaExecucao {
stdout: stdout_truncado,
stderr: stderr_truncado,
exit_code,
truncado_stdout,
truncado_stderr,
duracao_ms,
})
}
pub async fn upload(
&mut self,
local: &std::path::Path,
remote: &std::path::Path,
) -> ResultadoSshCli<TransferenciaResultado> {
use russh::ChannelMsg;
use std::time::Instant;
let local_str = local.display().to_string();
let remote_str = remote.display().to_string();
let metadados = std::fs::metadata(local).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ErroSshCli::ArquivoNaoEncontrado(local_str.clone())
} else {
ErroSshCli::Io(e)
}
})?;
if !metadados.is_file() {
return Err(ErroSshCli::ArgumentoInvalido(
"upload só suporta arquivos regulares".to_string(),
));
}
let tamanho = metadados.len();
let nome_arquivo = local.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let inicio = Instant::now();
let timeout = Duration::from_millis(self.cfg.timeout_ms);
let resultado =
tokio::time::timeout(timeout, async {
let mut canal =
self.sessao.channel_open_session().await.map_err(|e| {
ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}"))
})?;
let comando = format!("scp -t -p {}", remote_str);
canal
.exec(true, comando.as_str())
.await
.map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
canal.wait().await.ok_or_else(|| {
ErroSshCli::CanalFalhou("canal fechou prematuramente".to_string())
})?;
let resposta = formatar_header_upload_scp(tamanho, nome_arquivo);
canal
.data(resposta.as_bytes())
.await
.map_err(|e| ErroSshCli::CanalFalhou(format!("enviar header SCP: {e}")))?;
canal.wait().await.ok_or_else(|| {
ErroSshCli::CanalFalhou("canal fechou durante header".to_string())
})?;
let conteudo = std::fs::read(local).map_err(ErroSshCli::Io)?;
let mut offset = 0;
let tamanho_bloco = 32768;
while offset < conteudo.len() {
let fim = std::cmp::min(offset + tamanho_bloco, conteudo.len());
let bloco = &conteudo[offset..fim];
canal.data(bloco).await.map_err(|e| {
ErroSshCli::CanalFalhou(format!("enviar bloco SCP: {e}"))
})?;
offset = fim;
}
canal
.data(&[] as &[u8])
.await
.map_err(|e| ErroSshCli::CanalFalhou(format!("enviar EOF SCP: {e}")))?;
canal.wait().await.ok_or_else(|| {
ErroSshCli::CanalFalhou("canal fechou durante transferência".to_string())
})?;
while let Some(msg) = canal.wait().await {
if let ChannelMsg::Close = msg {
break;
}
}
Ok::<_, ErroSshCli>(())
})
.await;
resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
Ok(TransferenciaResultado {
bytes_transferidos: tamanho,
duracao_ms,
})
}
pub async fn download(
&mut self,
remote: &std::path::Path,
local: &std::path::Path,
) -> ResultadoSshCli<TransferenciaResultado> {
use russh::ChannelMsg;
use std::io::Write;
use std::time::Instant;
let remote_str = remote.display().to_string();
let inicio = Instant::now();
let timeout = Duration::from_millis(self.cfg.timeout_ms);
let resultado = tokio::time::timeout(timeout, async {
let mut canal = self
.sessao
.channel_open_session()
.await
.map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}")))?;
let comando = format!("scp -f -p {}", remote_str);
canal
.exec(true, comando.as_str())
.await
.map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
canal
.data(&[] as &[u8])
.await
.map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack inicial: {e}")))?;
let mut msg = canal.wait().await.ok_or_else(|| {
ErroSshCli::CanalFalhou("canal fechou esperando header".to_string())
})?;
let ChannelMsg::Data { data } = msg else {
return Err(ErroSshCli::CanalFalhou(
"esperava dados do servidor".to_string(),
));
};
let header = String::from_utf8_lossy(&data);
let tamanho = parse_header_scp(&header)?;
canal
.data(&[] as &[u8])
.await
.map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack: {e}")))?;
if let Some(pai) = local.parent() {
std::fs::create_dir_all(pai)?;
}
let mut arquivo = std::fs::File::create(local).map_err(ErroSshCli::Io)?;
let mut recebidos: u64 = 0;
while recebidos < tamanho {
msg = canal.wait().await.ok_or_else(|| {
ErroSshCli::CanalFalhou("canal fechou durante download".to_string())
})?;
let ChannelMsg::Data { data } = msg else {
continue;
};
let bytes = data.as_ref();
if bytes.is_empty() {
continue;
}
arquivo.write_all(bytes).map_err(ErroSshCli::Io)?;
recebidos += bytes.len() as u64;
canal.data(&[] as &[u8]).await.map_err(|e| {
ErroSshCli::CanalFalhou(format!("enviar ack durante download: {e}"))
})?;
}
while let Some(msg) = canal.wait().await {
if let ChannelMsg::Close = msg {
break;
}
}
Ok::<_, ErroSshCli>(recebidos)
})
.await;
let recebidos =
resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
Ok(TransferenciaResultado {
bytes_transferidos: recebidos,
duracao_ms,
})
}
pub async fn desconectar(&self) -> ResultadoSshCli<()> {
let resultado = self
.sessao
.disconnect(russh::Disconnect::ByApplication, "encerrando", "pt-BR")
.await;
match resultado {
Ok(()) => {
tracing::info!("sessão SSH encerrada");
Ok(())
}
Err(e) => {
tracing::warn!(erro = %e, "falha ao encerrar sessão SSH");
Err(ErroSshCli::ConexaoFalhou(format!(
"falha ao desconectar: {e}"
)))
}
}
}
pub async fn abrir_canal_tunel(
&self,
host_remoto: &str,
porta_remota: u16,
endereco_origem: &str,
porta_origem: u16,
) -> ResultadoSshCli<Box<dyn CanalTunel>> {
let canal = self
.sessao
.channel_open_direct_tcpip(
host_remoto.to_string(),
u32::from(porta_remota),
endereco_origem.to_string(),
u32::from(porta_origem),
)
.await
.map_err(|e| {
ErroSshCli::CanalFalhou(format!(
"falha ao abrir canal direct-tcpip para {}:{}: {}",
host_remoto, porta_remota, e
))
})?;
Ok(Box::new(canal.into_stream()))
}
}
#[async_trait]
impl ClienteSshTrait for ClienteSsh {
async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
Self::conectar(cfg).await.map(Box::new)
}
async fn executar_comando(
&mut self,
cmd: &str,
max_chars: usize,
) -> Result<SaidaExecucao, ErroSshCli> {
Self::executar_comando(self, cmd, max_chars).await
}
async fn upload(
&mut self,
local: &Path,
remote: &Path,
) -> Result<TransferenciaResultado, ErroSshCli> {
Self::upload(self, local, remote).await
}
async fn download(
&mut self,
remote: &Path,
local: &Path,
) -> Result<TransferenciaResultado, ErroSshCli> {
Self::download(self, remote, local).await
}
async fn abrir_canal_tunel(
&self,
host_remoto: &str,
porta_remota: u16,
endereco_origem: &str,
porta_origem: u16,
) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
Self::abrir_canal_tunel(
self,
host_remoto,
porta_remota,
endereco_origem,
porta_origem,
)
.await
}
async fn desconectar(&self) -> Result<(), ErroSshCli> {
Self::desconectar(self).await
}
}
#[cfg(test)]
mod testes_real {
use super::{
formatar_header_upload_scp, mapear_exit_status, parse_header_scp,
processar_mensagem_exec,
};
#[test]
fn mapear_exit_status_normal() {
assert_eq!(mapear_exit_status(0), 0);
assert_eq!(mapear_exit_status(255), 255);
}
#[test]
fn mapear_exit_status_overflow_retorna_menos_um() {
assert_eq!(mapear_exit_status(u32::MAX), -1);
}
#[test]
fn parse_header_scp_valido_retorna_tamanho() {
let tamanho = parse_header_scp("C0644 42 arquivo.txt\n").expect("header válido");
assert_eq!(tamanho, 42);
}
#[test]
fn parse_header_scp_invalido_retorna_erro() {
assert!(parse_header_scp("ERRO").is_err());
assert!(parse_header_scp("C0644 sem_tamanho").is_err());
assert!(parse_header_scp("C0644 abc arquivo").is_err());
}
#[test]
fn processar_mensagem_exec_trata_stdout_stderr_e_close() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code = None;
let deve_parar = processar_mensagem_exec(
russh::ChannelMsg::Data {
data: b"stdout".to_vec().into(),
},
&mut stdout,
&mut stderr,
&mut exit_code,
);
assert!(!deve_parar);
assert_eq!(stdout, b"stdout");
let deve_parar = processar_mensagem_exec(
russh::ChannelMsg::ExtendedData {
data: b"stderr".to_vec().into(),
ext: 1,
},
&mut stdout,
&mut stderr,
&mut exit_code,
);
assert!(!deve_parar);
assert_eq!(stderr, b"stderr");
let _ = processar_mensagem_exec(
russh::ChannelMsg::ExitStatus { exit_status: 17 },
&mut stdout,
&mut stderr,
&mut exit_code,
);
assert_eq!(exit_code, Some(17));
let deve_parar = processar_mensagem_exec(
russh::ChannelMsg::Close,
&mut stdout,
&mut stderr,
&mut exit_code,
);
assert!(deve_parar);
}
#[test]
fn formatar_header_upload_scp_gera_formato_esperado() {
let header = formatar_header_upload_scp(123, "arquivo.txt");
assert_eq!(header, "C0644 123 arquivo.txt\\n");
}
#[test]
fn processar_mensagem_exec_ignora_extendido_com_codigo_diferente_de_stderr() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code = None;
let deve_parar = processar_mensagem_exec(
russh::ChannelMsg::ExtendedData {
data: b"nao-e-stderr".to_vec().into(),
ext: 2,
},
&mut stdout,
&mut stderr,
&mut exit_code,
);
assert!(!deve_parar);
assert!(stdout.is_empty());
assert!(stderr.is_empty());
assert!(exit_code.is_none());
}
#[test]
fn processar_mensagem_exec_trata_exit_signal_e_eof_sem_encerrar_loop() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code = Some(7);
let deve_parar_signal = processar_mensagem_exec(
russh::ChannelMsg::ExitSignal {
signal_name: russh::Sig::TERM,
core_dumped: false,
error_message: "encerrado".to_string(),
lang_tag: "pt-BR".to_string(),
},
&mut stdout,
&mut stderr,
&mut exit_code,
);
let deve_parar_eof = processar_mensagem_exec(
russh::ChannelMsg::Eof,
&mut stdout,
&mut stderr,
&mut exit_code,
);
assert!(!deve_parar_signal);
assert!(!deve_parar_eof);
assert_eq!(exit_code, Some(7));
}
#[test]
fn processar_mensagem_exec_ignora_variantes_sem_tratamento_especifico() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code = None;
let deve_parar = processar_mensagem_exec(
russh::ChannelMsg::WindowAdjusted { new_size: 2048 },
&mut stdout,
&mut stderr,
&mut exit_code,
);
assert!(!deve_parar);
assert!(stdout.is_empty());
assert!(stderr.is_empty());
assert!(exit_code.is_none());
}
#[test]
fn parse_header_scp_aceita_header_com_whitespace_extra() {
let tamanho = parse_header_scp(" C0644 128 arquivo.bin \r\n")
.expect("header com espaços extras é válido");
assert_eq!(tamanho, 128);
}
#[test]
fn parse_header_scp_numero_muito_grande_retorna_u64_ok() {
let tamanho = parse_header_scp("C0644 10737418240 grande.iso\n").expect("u64 válido");
assert_eq!(tamanho, 10_737_418_240);
}
#[test]
fn parse_header_scp_string_vazia_retorna_erro() {
let resultado = parse_header_scp("");
assert!(resultado.is_err());
}
#[test]
fn parse_header_scp_header_apenas_c_retorna_erro() {
let resultado = parse_header_scp("C");
assert!(resultado.is_err());
}
#[test]
fn formatar_header_upload_scp_com_nome_contendo_espaco() {
let header = formatar_header_upload_scp(64, "meu arquivo.txt");
assert!(header.starts_with("C0644 64 "));
assert!(header.contains("meu arquivo.txt"));
assert!(header.ends_with("\\n"));
}
#[test]
fn formatar_header_upload_scp_tamanho_zero_e_valido() {
let header = formatar_header_upload_scp(0, "vazio.txt");
assert_eq!(header, "C0644 0 vazio.txt\\n");
}
#[test]
fn mapear_exit_status_cobre_valores_intermediarios() {
assert_eq!(mapear_exit_status(1), 1);
assert_eq!(mapear_exit_status(42), 42);
assert_eq!(mapear_exit_status(127), 127);
assert_eq!(mapear_exit_status(128), 128);
assert_eq!(mapear_exit_status(i32::MAX as u32), i32::MAX);
}
#[test]
fn processar_mensagem_exec_acumula_stdout_em_multiplas_chamadas() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code = None;
for parte in [b"parte1".to_vec(), b"-".to_vec(), b"parte2".to_vec()] {
processar_mensagem_exec(
russh::ChannelMsg::Data { data: parte.into() },
&mut stdout,
&mut stderr,
&mut exit_code,
);
}
assert_eq!(stdout, b"parte1-parte2");
assert!(stderr.is_empty());
}
#[tokio::test]
async fn conectar_com_config_invalida_host_vazio_retorna_argumento_invalido() {
use super::super::ConfiguracaoConexao;
use super::ClienteSsh;
use crate::erros::ErroSshCli;
use secrecy::SecretString;
let cfg = ConfiguracaoConexao {
host: String::new(),
porta: 22,
usuario: "root".to_string(),
senha: SecretString::from("x".to_string()),
timeout_ms: 500,
};
match ClienteSsh::conectar(cfg).await {
Err(ErroSshCli::ArgumentoInvalido(_)) => {}
outro => panic!("esperava ArgumentoInvalido, veio {outro:?}"),
}
}
#[tokio::test]
async fn conectar_com_porta_inalcançavel_retorna_erro_conexao_ou_timeout() {
use super::super::ConfiguracaoConexao;
use super::ClienteSsh;
use crate::erros::ErroSshCli;
use secrecy::SecretString;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind efêmero");
let porta = listener.local_addr().expect("addr").port();
let cfg = ConfiguracaoConexao {
host: "127.0.0.1".to_string(),
porta,
usuario: "root".to_string(),
senha: SecretString::from("senha".to_string()),
timeout_ms: 200,
};
let resultado = ClienteSsh::conectar(cfg).await;
assert!(resultado.is_err(), "conexão deveria falhar");
match resultado.unwrap_err() {
ErroSshCli::TimeoutSsh(_) | ErroSshCli::ConexaoFalhou(_) => {
}
outro => panic!("esperava TimeoutSsh ou ConexaoFalhou, recebeu: {outro:?}"),
}
}
#[tokio::test]
async fn conectar_com_porta_fechada_falha_conexao_tcp() {
use super::super::ConfiguracaoConexao;
use super::ClienteSsh;
use secrecy::SecretString;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let porta = listener.local_addr().expect("addr").port();
drop(listener);
let cfg = ConfiguracaoConexao {
host: "127.0.0.1".to_string(),
porta,
usuario: "u".to_string(),
senha: SecretString::from("s".to_string()),
timeout_ms: 500,
};
let resultado = ClienteSsh::conectar(cfg).await;
assert!(resultado.is_err(), "conectar em porta fechada deve falhar");
}
#[tokio::test]
async fn manipulador_cliente_check_server_key_sempre_aceita() {
use russh::client::Handler;
use russh::keys::parse_public_key_base64;
let chave_base64 =
"AAAAC3NzaC1lZDI1NTE5AAAAIKHEChfyk+R2N4OgRtRhnYXJYfxZqkEyiqYW7v4zj4iV";
let pub_key = parse_public_key_base64(chave_base64).expect("chave base64 válida");
let mut handler = super::ManipuladorCliente;
let resultado = handler
.check_server_key(&pub_key)
.await
.expect("handler não falha");
assert!(
resultado,
"handler é permissivo por design (trust-on-first-use iteração 2)"
);
}
}
}
#[cfg(feature = "ssh-real")]
pub use real::{ClienteSsh, ManipuladorCliente};
#[cfg(not(feature = "ssh-real"))]
mod stub {
use super::{ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado};
use crate::erros::ErroSshCli;
use crate::ssh::cliente::ClienteSshTrait;
use async_trait::async_trait;
use std::path::Path;
#[derive(Debug)]
pub struct ClienteSsh;
#[async_trait]
impl ClienteSshTrait for ClienteSsh {
async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
Err(ErroSshCli::ConexaoFalhou(
"feature `ssh-real` está desabilitada; recompile com --features ssh-real".into(),
))
}
async fn executar_comando(
&mut self,
_cmd: &str,
_max_chars: usize,
) -> Result<SaidaExecucao, ErroSshCli> {
Err(ErroSshCli::CanalFalhou(
"stub sem russh: feature `ssh-real` desabilitada".into(),
))
}
async fn upload(
&mut self,
_local: &Path,
_remote: &Path,
) -> Result<TransferenciaResultado, ErroSshCli> {
Err(ErroSshCli::CanalFalhou(
"stub sem russh: feature `ssh-real` desabilitada".into(),
))
}
async fn download(
&mut self,
_remote: &Path,
_local: &Path,
) -> Result<TransferenciaResultado, ErroSshCli> {
Err(ErroSshCli::CanalFalhou(
"stub sem russh: feature `ssh-real` desabilitada".into(),
))
}
async fn abrir_canal_tunel(
&self,
_host_remoto: &str,
_porta_remota: u16,
_endereco_origem: &str,
_porta_origem: u16,
) -> Result<Box<dyn super::CanalTunel>, ErroSshCli> {
Err(ErroSshCli::CanalFalhou(
"stub sem russh: feature `ssh-real` desabilitada".into(),
))
}
async fn desconectar(&self) -> Result<(), ErroSshCli> {
Ok(())
}
}
}
#[cfg(not(feature = "ssh-real"))]
pub use stub::ClienteSsh;
#[cfg(test)]
mod testes {
use super::*;
use secrecy::SecretString;
fn cfg_valida() -> ConfiguracaoConexao {
ConfiguracaoConexao {
host: "127.0.0.1".to_string(),
porta: 22,
usuario: "root".to_string(),
senha: SecretString::from("senha-exemplo".to_string()),
timeout_ms: 5000,
}
}
#[test]
fn validar_host_vazio_retorna_erro() {
let mut c = cfg_valida();
c.host = String::new();
let r = c.validar();
assert!(r.is_err());
let msg = r.unwrap_err().to_string();
assert!(msg.contains("host"));
}
#[test]
fn validar_host_apenas_espacos_retorna_erro() {
let mut c = cfg_valida();
c.host = " ".to_string();
assert!(c.validar().is_err());
}
#[test]
fn validar_porta_zero_retorna_erro() {
let mut c = cfg_valida();
c.porta = 0;
let r = c.validar();
assert!(r.is_err());
let msg = r.unwrap_err().to_string();
assert!(msg.contains("porta"));
}
#[test]
fn validar_usuario_vazio_retorna_erro() {
let mut c = cfg_valida();
c.usuario = String::new();
assert!(c.validar().is_err());
}
#[test]
fn validar_configuracao_correta_retorna_ok() {
assert!(cfg_valida().validar().is_ok());
}
#[test]
fn debug_nao_expoe_senha() {
let c = cfg_valida();
let dbg = format!("{c:?}");
assert!(!dbg.contains("senha-exemplo"));
assert!(dbg.contains("redacted"));
}
#[test]
fn truncar_utf8_nao_trunca_se_cabe() {
let (s, t) = truncar_utf8("ola mundo", 100);
assert_eq!(s, "ola mundo");
assert!(!t);
}
#[test]
fn truncar_utf8_trunca_string_grande_ascii() {
let entrada: String = "a".repeat(200);
let (s, t) = truncar_utf8(&entrada, 50);
assert_eq!(s.chars().count(), 50);
assert!(t);
}
#[test]
fn truncar_utf8_preserva_grafemas_acentuados() {
let entrada: String = "á".repeat(30);
let (s, t) = truncar_utf8(&entrada, 10);
assert_eq!(s.chars().count(), 10);
assert_eq!(s.len(), 20);
assert!(t);
assert!(s.chars().all(|c| c == 'á'));
}
#[test]
fn truncar_utf8_com_emojis_nao_quebra() {
let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
let (s, t) = truncar_utf8(entrada, 5);
assert_eq!(s.chars().count(), 5);
assert!(t);
}
#[test]
fn truncar_utf8_zero_retorna_vazio() {
let (s, t) = truncar_utf8("abc", 0);
assert_eq!(s, "");
assert!(t);
}
#[test]
fn saida_execucao_debug_nao_crasha() {
let s = SaidaExecucao {
stdout: "ok".into(),
stderr: String::new(),
exit_code: Some(0),
truncado_stdout: false,
truncado_stderr: false,
duracao_ms: 42,
};
let _ = format!("{s:?}");
}
#[test]
fn duracao_ms_tipo_compativel() {
let fake: u64 = 1234;
assert_eq!(fake, 1234_u64);
}
#[test]
fn configuracao_conexao_clone_preserva_campos_visiveis() {
let original = cfg_valida();
let copia = original.clone();
assert_eq!(copia.host, original.host);
assert_eq!(copia.porta, original.porta);
assert_eq!(copia.usuario, original.usuario);
assert_eq!(copia.timeout_ms, original.timeout_ms);
}
#[test]
fn debug_contem_campos_principais() {
let c = cfg_valida();
let dbg = format!("{c:?}");
assert!(dbg.contains("127.0.0.1"));
assert!(dbg.contains("22"));
assert!(dbg.contains("root"));
assert!(dbg.contains("5000"));
}
#[test]
fn saida_execucao_clone_preserva_todos_campos() {
let original = SaidaExecucao {
stdout: "saida".to_string(),
stderr: "erro".to_string(),
exit_code: Some(7),
truncado_stdout: true,
truncado_stderr: false,
duracao_ms: 999,
};
let copia = original.clone();
assert_eq!(copia.stdout, "saida");
assert_eq!(copia.stderr, "erro");
assert_eq!(copia.exit_code, Some(7));
assert!(copia.truncado_stdout);
assert!(!copia.truncado_stderr);
assert_eq!(copia.duracao_ms, 999);
}
#[test]
fn saida_execucao_exit_code_none_sinaliza_termino_por_sinal() {
let s = SaidaExecucao {
stdout: String::new(),
stderr: String::new(),
exit_code: None,
truncado_stdout: false,
truncado_stderr: false,
duracao_ms: 0,
};
assert!(s.exit_code.is_none());
let _ = format!("{s:?}");
}
#[test]
fn transferencia_resultado_clone_e_debug() {
let original = TransferenciaResultado {
bytes_transferidos: 1_048_576,
duracao_ms: 2500,
};
let copia = original.clone();
assert_eq!(copia.bytes_transferidos, 1_048_576);
assert_eq!(copia.duracao_ms, 2500);
let dbg = format!("{copia:?}");
assert!(dbg.contains("1048576"));
assert!(dbg.contains("2500"));
}
#[test]
fn truncar_utf8_exato_max_chars_nao_marca_truncado() {
let entrada = "abcde";
let (s, t) = truncar_utf8(entrada, 5);
assert_eq!(s, "abcde");
assert!(!t);
}
#[test]
fn truncar_utf8_com_string_vazia_retorna_vazio_sem_truncar() {
let (s, t) = truncar_utf8("", 100);
assert_eq!(s, "");
assert!(!t);
}
#[test]
fn truncar_utf8_com_string_vazia_max_zero_nao_trunca() {
let (s, t) = truncar_utf8("", 0);
assert_eq!(s, "");
assert!(!t);
}
#[test]
fn truncar_utf8_preserva_codepoints_mistos_cjk_emoji() {
let entrada = "a中🔑b漢";
assert_eq!(entrada.chars().count(), 5);
let (s, t) = truncar_utf8(entrada, 3);
assert_eq!(s.chars().count(), 3);
let colhidos: String = entrada.chars().take(3).collect();
assert_eq!(s, colhidos);
assert!(t);
}
#[test]
fn truncar_utf8_max_muito_maior_que_string_nao_trunca() {
let (s, t) = truncar_utf8("oi", usize::MAX);
assert_eq!(s, "oi");
assert!(!t);
}
#[test]
fn truncar_utf8_invariante_chars_count_sempre_le_max() {
for max in [0usize, 1, 5, 10, 50, 100] {
for entrada in [
"",
"a",
"abcdef",
"á".repeat(20).as_str(),
"🚀",
"🚀🚀🚀🚀🚀",
"中文测试字符串",
] {
let (s, _) = truncar_utf8(entrada, max);
assert!(
s.chars().count() <= max.max(0),
"falha para max={max}, entrada={entrada:?}"
);
}
}
}
#[tokio::test]
async fn mock_executar_comando_retorna_saida_configurada() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
mock.expect_executar_comando().times(1).returning(|cmd, _| {
Ok(SaidaExecucao {
stdout: format!("eco: {cmd}"),
stderr: String::new(),
exit_code: Some(0),
truncado_stdout: false,
truncado_stderr: false,
duracao_ms: 10,
})
});
let saida = mock.executar_comando("echo oi", 100).await.expect("ok");
assert_eq!(saida.stdout, "eco: echo oi");
assert_eq!(saida.exit_code, Some(0));
assert_eq!(saida.duracao_ms, 10);
}
#[tokio::test]
async fn mock_executar_comando_propaga_erro_canal() {
use crate::erros::ErroSshCli;
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
mock.expect_executar_comando()
.returning(|_, _| Err(ErroSshCli::CanalFalhou("erro simulado".to_string())));
let erro = mock.executar_comando("ls", 100).await.expect_err("erro");
assert!(matches!(erro, ErroSshCli::CanalFalhou(_)));
}
#[tokio::test]
async fn mock_upload_retorna_transferencia_configurada() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
use std::path::PathBuf;
let mut mock = MockClienteSsh::new();
mock.expect_upload().times(1).returning(|_, _| {
Ok(TransferenciaResultado {
bytes_transferidos: 4096,
duracao_ms: 50,
})
});
let local = PathBuf::from("/tmp/arquivo_local");
let remote = PathBuf::from("/remote/arquivo");
let resultado = mock.upload(&local, &remote).await.expect("ok");
assert_eq!(resultado.bytes_transferidos, 4096);
assert_eq!(resultado.duracao_ms, 50);
}
#[tokio::test]
async fn mock_download_retorna_transferencia_configurada() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
use std::path::PathBuf;
let mut mock = MockClienteSsh::new();
mock.expect_download().times(1).returning(|_, _| {
Ok(TransferenciaResultado {
bytes_transferidos: 2048,
duracao_ms: 30,
})
});
let remote = PathBuf::from("/remote/x");
let local = PathBuf::from("/tmp/x");
let resultado = mock.download(&remote, &local).await.expect("ok");
assert_eq!(resultado.bytes_transferidos, 2048);
assert_eq!(resultado.duracao_ms, 30);
}
#[tokio::test]
async fn mock_download_propaga_erro_arquivo() {
use crate::erros::ErroSshCli;
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
use std::path::PathBuf;
let mut mock = MockClienteSsh::new();
mock.expect_download()
.returning(|_, _| Err(ErroSshCli::ArquivoNaoEncontrado("inexistente".to_string())));
let erro = mock
.download(&PathBuf::from("/r"), &PathBuf::from("/l"))
.await
.expect_err("erro");
assert!(matches!(erro, ErroSshCli::ArquivoNaoEncontrado(_)));
}
#[tokio::test]
async fn mock_desconectar_ok() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
mock.expect_desconectar().times(1).returning(|| Ok(()));
assert!(mock.desconectar().await.is_ok());
}
#[tokio::test]
async fn mock_desconectar_propaga_erro() {
use crate::erros::ErroSshCli;
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
mock.expect_desconectar()
.returning(|| Err(ErroSshCli::ConexaoFalhou("eof".to_string())));
let erro = mock.desconectar().await.expect_err("erro");
assert!(matches!(erro, ErroSshCli::ConexaoFalhou(_)));
}
#[tokio::test]
async fn mock_executar_comando_invocado_multiplas_vezes_respeita_times() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
mock.expect_executar_comando().times(3).returning(|_, _| {
Ok(SaidaExecucao {
stdout: "ok".to_string(),
stderr: String::new(),
exit_code: Some(0),
truncado_stdout: false,
truncado_stderr: false,
duracao_ms: 1,
})
});
for _ in 0..3 {
let r = mock.executar_comando("x", 10).await.expect("ok");
assert_eq!(r.stdout, "ok");
}
}
#[tokio::test]
async fn mock_executar_comando_com_with_matcher_filtra_argumentos() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
use mockall::predicate::*;
let mut mock = MockClienteSsh::new();
mock.expect_executar_comando()
.with(eq("ls -la"), eq(500usize))
.times(1)
.returning(|_, _| {
Ok(SaidaExecucao {
stdout: "listagem".to_string(),
stderr: String::new(),
exit_code: Some(0),
truncado_stdout: false,
truncado_stderr: false,
duracao_ms: 5,
})
});
let r = mock.executar_comando("ls -la", 500).await.expect("ok");
assert_eq!(r.stdout, "listagem");
}
#[tokio::test]
async fn mock_upload_com_predicate_caminho() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
use mockall::predicate::*;
use std::path::{Path, PathBuf};
let mut mock = MockClienteSsh::new();
mock.expect_upload()
.with(eq(Path::new("/tmp/a")), eq(Path::new("/remote/b")))
.returning(|_, _| {
Ok(TransferenciaResultado {
bytes_transferidos: 10,
duracao_ms: 1,
})
});
let r = mock
.upload(&PathBuf::from("/tmp/a"), &PathBuf::from("/remote/b"))
.await
.expect("ok");
assert_eq!(r.bytes_transferidos, 10);
}
#[tokio::test]
async fn mock_abrir_canal_tunel_propaga_erro_canal() {
use crate::erros::ErroSshCli;
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
mock.expect_abrir_canal_tunel()
.returning(|_, _, _, _| Err(ErroSshCli::CanalFalhou("sem tunnel".to_string())));
let resultado = mock
.abrir_canal_tunel("host.exemplo", 8080, "127.0.0.1", 12345)
.await;
match resultado {
Ok(_) => panic!("esperava erro, recebeu Ok"),
Err(ErroSshCli::CanalFalhou(_)) => {}
Err(outro) => panic!("variante de erro inesperada: {outro:?}"),
}
}
#[tokio::test]
async fn mock_fluxo_completo_conectar_exec_desconectar() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
mock.expect_executar_comando().returning(|_, _| {
Ok(SaidaExecucao {
stdout: "hostname-x".to_string(),
stderr: String::new(),
exit_code: Some(0),
truncado_stdout: false,
truncado_stderr: false,
duracao_ms: 7,
})
});
mock.expect_desconectar().returning(|| Ok(()));
let saida = mock.executar_comando("hostname", 200).await.expect("ok");
assert_eq!(saida.stdout, "hostname-x");
mock.desconectar().await.expect("desconecta");
}
#[tokio::test]
async fn mock_conectar_retorna_caixa_do_mock() {
use crate::ssh::cliente::mocks::MockClienteSsh;
let _guard = MockClienteSsh::conectar_context();
drop(_guard);
}
#[tokio::test]
async fn mock_executar_comando_com_return_once() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
let saida = SaidaExecucao {
stdout: "único".to_string(),
stderr: String::new(),
exit_code: Some(0),
truncado_stdout: false,
truncado_stderr: false,
duracao_ms: 3,
};
mock.expect_executar_comando()
.return_once(move |_, _| Ok(saida));
let r = mock.executar_comando("once", 100).await.expect("ok");
assert_eq!(r.stdout, "único");
}
#[tokio::test]
async fn mock_desconectar_com_returning_ok() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
mock.expect_desconectar().returning(|| Ok(()));
assert!(mock.desconectar().await.is_ok());
}
#[tokio::test]
async fn mock_upload_usado_zero_vezes_respeita_never() {
use crate::ssh::cliente::mocks::MockClienteSsh;
let mut mock = MockClienteSsh::new();
mock.expect_upload().never();
drop(mock);
}
#[tokio::test]
async fn mock_download_com_times_range() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
use std::path::PathBuf;
let mut mock = MockClienteSsh::new();
mock.expect_download().times(1..=2).returning(|_, _| {
Ok(TransferenciaResultado {
bytes_transferidos: 1,
duracao_ms: 1,
})
});
let r = mock
.download(&PathBuf::from("/r"), &PathBuf::from("/l"))
.await
.expect("ok");
assert_eq!(r.bytes_transferidos, 1);
}
#[tokio::test]
async fn mock_executar_comando_com_never_e_dropa() {
use crate::ssh::cliente::mocks::MockClienteSsh;
let mut mock = MockClienteSsh::new();
mock.expect_executar_comando().never();
drop(mock);
}
#[tokio::test]
async fn mock_desconectar_com_returning_sem_argumentos() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
mock.expect_desconectar().returning(|| Ok(()));
assert!(mock.desconectar().await.is_ok());
let _boxed: Box<dyn ClienteSshTrait> = Box::new(mock);
}
#[tokio::test]
async fn mock_upload_com_times_exato() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
use std::path::PathBuf;
let mut mock = MockClienteSsh::new();
mock.expect_upload().times(2).returning(|_, _| {
Ok(TransferenciaResultado {
bytes_transferidos: 512,
duracao_ms: 10,
})
});
for _ in 0..2 {
let r = mock
.upload(&PathBuf::from("/a"), &PathBuf::from("/b"))
.await
.expect("ok");
assert_eq!(r.bytes_transferidos, 512);
}
}
#[tokio::test]
async fn mock_abrir_canal_tunel_com_returning_captura_argumentos() {
use crate::erros::ErroSshCli;
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
let mut mock = MockClienteSsh::new();
mock.expect_abrir_canal_tunel()
.returning(|host, porta, origem, porta_origem| {
assert_eq!(host, "servidor.exemplo");
assert_eq!(porta, 443);
assert_eq!(origem, "127.0.0.1");
assert_eq!(porta_origem, 8443);
Err(ErroSshCli::CanalFalhou("fake".to_string()))
});
let resultado = mock
.abrir_canal_tunel("servidor.exemplo", 443, "127.0.0.1", 8443)
.await;
assert!(resultado.is_err());
}
#[tokio::test]
async fn mock_executar_comando_com_in_sequence() {
use crate::ssh::cliente::mocks::MockClienteSsh;
use crate::ssh::cliente::ClienteSshTrait;
use mockall::Sequence;
let mut mock = MockClienteSsh::new();
let mut seq = Sequence::new();
mock.expect_executar_comando()
.times(1)
.in_sequence(&mut seq)
.returning(|_, _| {
Ok(SaidaExecucao {
stdout: "primeiro".to_string(),
stderr: String::new(),
exit_code: Some(0),
truncado_stdout: false,
truncado_stderr: false,
duracao_ms: 1,
})
});
mock.expect_executar_comando()
.times(1)
.in_sequence(&mut seq)
.returning(|_, _| {
Ok(SaidaExecucao {
stdout: "segundo".to_string(),
stderr: String::new(),
exit_code: Some(0),
truncado_stdout: false,
truncado_stderr: false,
duracao_ms: 1,
})
});
let r1 = mock.executar_comando("a", 10).await.expect("ok");
assert_eq!(r1.stdout, "primeiro");
let r2 = mock.executar_comando("b", 10).await.expect("ok");
assert_eq!(r2.stdout, "segundo");
}
}