1use crate::erros::{SshCliError, SshCliResult};
9use secrecy::{ExposeSecret, SecretString};
10use std::path::PathBuf;
11use tokio::io::{AsyncRead, AsyncWrite};
12
13#[derive(Clone)]
18pub struct ConnectionConfig {
19 pub host: String,
21 pub port: u16,
23 pub username: String,
25 pub password: SecretString,
27 pub key_path: Option<String>,
29 pub key_passphrase: Option<SecretString>,
31 pub timeout_ms: u64,
33 pub known_hosts_path: Option<PathBuf>,
35 pub replace_host_key: bool,
37}
38
39impl std::fmt::Debug for ConnectionConfig {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("ConnectionConfig")
42 .field("host", &self.host)
43 .field("porta", &self.port)
44 .field("usuario", &self.username)
45 .field("senha", &"<redacted>")
46 .field("key_path", &self.key_path)
47 .field(
48 "key_passphrase",
49 &self.key_passphrase.as_ref().map(|_| "<redacted>"),
50 )
51 .field("timeout_ms", &self.timeout_ms)
52 .field("known_hosts_path", &self.known_hosts_path)
53 .field("replace_host_key", &self.replace_host_key)
54 .finish()
55 }
56}
57
58impl ConnectionConfig {
59 pub fn validate(&self) -> SshCliResult<()> {
61 if self.host.trim().is_empty() {
62 return Err(SshCliError::InvalidArgument(
63 "empty host in ConnectionConfig".to_string(),
64 ));
65 }
66 if self.port == 0 {
67 return Err(SshCliError::InvalidArgument(
68 "port 0 is invalid in ConnectionConfig".to_string(),
69 ));
70 }
71 if self.username.trim().is_empty() {
72 return Err(SshCliError::InvalidArgument(
73 "empty user in ConnectionConfig".to_string(),
74 ));
75 }
76 let has_password = !self.password.expose_secret().is_empty();
77 let tem_key = self.key_path.as_ref().is_some_and(|p| !p.trim().is_empty());
78 if !has_password && !tem_key {
79 return Err(SshCliError::InvalidArgument(
80 "auth requires password or key_path".to_string(),
81 ));
82 }
83 Ok(())
84 }
85}
86
87#[derive(Debug, Clone)]
89pub struct ExecutionOutput {
90 pub stdout: String,
92 pub stderr: String,
94 pub exit_code: Option<i32>,
96 pub truncated_stdout: bool,
98 pub truncated_stderr: bool,
100 pub duration_ms: u64,
102}
103
104#[derive(Debug, Clone)]
106pub struct TransferResult {
107 pub bytes_transferred: u64,
109 pub duration_ms: u64,
111}
112
113#[must_use]
118pub fn truncate_utf8(content: &str, max_chars: usize) -> (String, bool) {
119 let total = content.chars().count();
120 if total <= max_chars {
121 return (content.to_string(), false);
122 }
123 let truncated: String = content.chars().take(max_chars).collect();
124 (truncated, true)
125}
126
127use async_trait::async_trait;
132use std::path::Path;
133
134pub trait TunnelChannel: AsyncRead + AsyncWrite + Unpin + Send {}
136
137impl<T> TunnelChannel for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
138
139#[async_trait]
144pub trait SshClientTrait: Send + Sync + 'static {
145 async fn connect(cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError>
147 where
148 Self: Sized;
149
150 async fn run_command(
155 &mut self,
156 cmd: &str,
157 max_chars: usize,
158 stdin_data: Option<Vec<u8>>,
159 ) -> Result<ExecutionOutput, SshCliError>;
160
161 async fn upload(
163 &mut self,
164 local: &Path,
165 remote: &Path,
166 ) -> Result<TransferResult, SshCliError>;
167
168 async fn download(
170 &mut self,
171 remote: &Path,
172 local: &Path,
173 ) -> Result<TransferResult, SshCliError>;
174
175 async fn open_tunnel_channel(
177 &self,
178 remote_host: &str,
179 remote_port: u16,
180 endereco_origem: &str,
181 porta_origem: u16,
182 ) -> Result<Box<dyn TunnelChannel>, SshCliError>;
183
184 async fn disconnect(&self) -> Result<(), SshCliError>;
186}
187
188#[cfg(test)]
189pub mod mocks {
191 use super::*;
192 use mockall::mock;
193
194 mock! {
195 pub SshClient {}
196
197 #[async_trait]
198 impl crate::ssh::client::SshClientTrait for SshClient {
199 async fn connect(cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError>;
200 async fn run_command(&mut self, cmd: &str, max_chars: usize, stdin_data: Option<Vec<u8>>) -> Result<ExecutionOutput, SshCliError>;
201 async fn upload(&mut self, local: &Path, remote: &Path) -> Result<TransferResult, SshCliError>;
202 async fn download(&mut self, remote: &Path, local: &Path) -> Result<TransferResult, SshCliError>;
203 async fn open_tunnel_channel(
204 &self,
205 remote_host: &str,
206 remote_port: u16,
207 endereco_origem: &str,
208 porta_origem: u16,
209 ) -> Result<Box<dyn TunnelChannel>, SshCliError>;
210 async fn disconnect(&self) -> Result<(), SshCliError>;
211 }
212 }
213}
214
215#[cfg(feature = "ssh-real")]
220mod real {
221 use super::{
222 TunnelChannel, SshClientTrait, ConnectionConfig, ExecutionOutput, TransferResult,
223 };
224 use crate::erros::{SshCliError, SshCliResult};
225 use async_trait::async_trait;
226 use secrecy::ExposeSecret;
227 use std::path::Path;
228 use std::sync::Arc;
229 use std::time::{Duration, Instant};
230
231 pub struct ClientHandler {
233 host: String,
234 port: u16,
235 known_hosts_path: Option<std::path::PathBuf>,
236 replace_host_key: bool,
237 host_key_rejected: bool,
239 host_key_detail: Option<String>,
240 }
241
242 impl ClientHandler {
243 fn new(cfg: &ConnectionConfig) -> Self {
244 Self {
245 host: cfg.host.clone(),
246 port: cfg.port,
247 known_hosts_path: cfg.known_hosts_path.clone(),
248 replace_host_key: cfg.replace_host_key,
249 host_key_rejected: false,
250 host_key_detail: None,
251 }
252 }
253 }
254
255 impl russh::client::Handler for ClientHandler {
256 type Error = russh::Error;
257
258 async fn check_server_key(
259 &mut self,
260 server_key: &russh::keys::ssh_key::PublicKey,
261 ) -> Result<bool, Self::Error> {
262 let fingerprint = format!(
263 "{}",
264 server_key.fingerprint(russh::keys::HashAlg::Sha256)
265 );
266
267 let Some(path) = self.known_hosts_path.clone() else {
268 tracing::warn!("known_hosts missing: accepting host key (test mode)");
269 return Ok(true);
270 };
271
272 let mut kh = match crate::ssh::known_hosts::KnownHosts::load(path) {
273 Ok(k) => k,
274 Err(e) => {
275 tracing::error!(err = %e, "failed to load known_hosts");
276 self.host_key_rejected = true;
277 self.host_key_detail = Some(e.to_string());
278 return Ok(false);
279 }
280 };
281
282 match crate::ssh::known_hosts::verify_tofu(
283 &mut kh,
284 &self.host,
285 self.port,
286 &fingerprint,
287 self.replace_host_key,
288 ) {
289 Ok(true) => Ok(true),
290 Ok(false) => Ok(false),
291 Err(e) => {
292 self.host_key_rejected = true;
293 self.host_key_detail = Some(e.to_string());
294 tracing::error!(err = %e, "host key rejeitada");
295 Ok(false)
296 }
297 }
298 }
299 }
300
301 pub struct SshClient {
303 pub session: russh::client::Handle<ClientHandler>,
305 cfg: ConnectionConfig,
306 }
307
308 impl std::fmt::Debug for SshClient {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 f.debug_struct("SshClient")
311 .field("host", &self.cfg.host)
312 .field("port", &self.cfg.port)
313 .field("user", &self.cfg.username)
314 .field("timeout_ms", &self.cfg.timeout_ms)
315 .finish()
316 }
317 }
318
319 fn map_exit_status(exit_status: u32) -> i32 {
320 i32::try_from(exit_status).unwrap_or(-1)
321 }
322
323 fn process_exec_message(
324 msg: russh::ChannelMsg,
325 stdout_bytes: &mut Vec<u8>,
326 stderr_bytes: &mut Vec<u8>,
327 exit_code: &mut Option<i32>,
328 ) -> bool {
329 use russh::ChannelMsg;
330
331 match msg {
332 ChannelMsg::Data { data } => {
333 stdout_bytes.extend_from_slice(&data);
334 }
335 ChannelMsg::ExtendedData { data, ext } => {
336 if ext == 1 {
338 stderr_bytes.extend_from_slice(&data);
339 } else {
340 tracing::debug!(ext, "extended data ignored");
341 }
342 }
343 ChannelMsg::ExitStatus { exit_status } => {
344 *exit_code = Some(map_exit_status(exit_status));
348 }
350 ChannelMsg::ExitSignal {
351 signal_name,
352 core_dumped,
353 error_message,
354 ..
355 } => {
356 tracing::warn!(
357 ?signal_name,
358 core_dumped,
359 %error_message,
360 "remote process terminated by signal"
361 );
362 }
364 ChannelMsg::Eof => {
365 tracing::debug!("EOF on SSH channel");
366 }
367 ChannelMsg::Close => {
368 tracing::debug!("SSH channel closed by server");
369 return true;
370 }
371 _ => {}
372 }
373
374 false
375 }
376
377 const SCP_OK: u8 = 0;
379
380 fn basename_scp(file_name: &str) -> String {
382 file_name
383 .split(['/', '\\'])
384 .next_back()
385 .unwrap_or("file")
386 .replace(['\n', '\r', '\0'], "_")
387 }
388
389 #[cfg_attr(not(test), allow(dead_code))]
391 fn format_scp_upload_header(size: u64, file_name: &str) -> String {
392 format_scp_upload_header_with_mode(0o644, size, file_name)
393 }
394
395 fn format_scp_upload_header_with_mode(mode: u32, size: u64, file_name: &str) -> String {
397 let name = basename_scp(file_name);
398 let mode = mode & 0o7777;
399 format!("C{mode:04o} {size} {name}\n")
400 }
401
402 fn format_scp_t_line(mtime_secs: u64, atime_secs: u64) -> String {
404 format!("T{mtime_secs} 0 {atime_secs} 0\n")
405 }
406
407 fn parse_scp_t_line(line: &str) -> SshCliResult<(u64, u64)> {
409 let line = line.trim_end_matches(['\0', '\r', '\n']).trim();
410 if !line.starts_with('T') {
411 return Err(SshCliError::ChannelFailed(format!(
412 "unexpected SCP T line: {line}"
413 )));
414 }
415 let resto = &line[1..];
416 let partes: Vec<&str> = resto.split_whitespace().collect();
417 if partes.len() < 3 {
418 return Err(SshCliError::ChannelFailed(format!(
419 "malformed SCP T line: {line}"
420 )));
421 }
422 let mtime: u64 = partes[0].parse().map_err(|_| {
423 SshCliError::ChannelFailed(format!("invalid mtime in T line: {}", partes[0]))
424 })?;
425 let atime: u64 = partes[2].parse().map_err(|_| {
426 SshCliError::ChannelFailed(format!("invalid atime in T line: {}", partes[2]))
427 })?;
428 Ok((mtime, atime))
429 }
430
431 fn parse_scp_header(header: &str) -> SshCliResult<(u32, u64)> {
433 let header = header.trim_end_matches(['\0', '\r', '\n']).trim();
434
435 if !header.starts_with('C') {
436 return Err(SshCliError::ChannelFailed(format!(
437 "unexpected SCP header: {}",
438 header
439 )));
440 }
441
442 let partes: Vec<&str> = header.split_whitespace().collect();
443 if partes.len() < 3 {
444 return Err(SshCliError::ChannelFailed(format!(
445 "malformed SCP header: {}",
446 header
447 )));
448 }
449
450 let mode_token = partes[0];
452 if mode_token.len() < 2 {
453 return Err(SshCliError::ChannelFailed(format!(
454 "missing SCP mode in header: {header}"
455 )));
456 }
457 let mode_oct = &mode_token[1..];
458 let mode: u32 = u32::from_str_radix(mode_oct, 8)
459 .map_err(|_| SshCliError::ChannelFailed(format!("invalid SCP mode: {mode_oct}")))?;
460
461 let size = partes[1].parse().map_err(|_| {
462 SshCliError::ChannelFailed(format!("invalid size in header: {}", partes[1]))
463 })?;
464 Ok((mode & 0o7777, size))
465 }
466
467 fn scp_mode_from_metadata(meta: &std::fs::Metadata) -> u32 {
469 #[cfg(unix)]
470 {
471 use std::os::unix::fs::PermissionsExt;
472 meta.permissions().mode() & 0o7777
473 }
474 #[cfg(not(unix))]
475 {
476 let _ = meta;
477 0o644
478 }
479 }
480
481 fn system_time_secs(t: std::time::SystemTime) -> u64 {
483 t.duration_since(std::time::UNIX_EPOCH)
484 .map(|d| d.as_secs())
485 .unwrap_or(0)
486 }
487
488 const SCP_PARTIAL_SUFFIX: &str = ".ssh-cli.partial";
490
491 fn partial_download_path(local: &std::path::Path) -> std::path::PathBuf {
492 let mut p = local.as_os_str().to_os_string();
493 p.push(SCP_PARTIAL_SUFFIX);
494 std::path::PathBuf::from(p)
495 }
496
497 fn classify_scp_message(msg: &str) -> SshCliError {
503 let lower = msg.to_ascii_lowercase();
504 if lower.contains("no such file") || lower.contains("not found") {
505 SshCliError::FileNotFound(normalize_scp_missing_path(msg))
506 } else if msg.is_empty() {
507 SshCliError::ChannelFailed("SCP rejected the transfer".to_string())
508 } else if msg.starts_with("SCP:") || msg.starts_with("SCP ") {
509 SshCliError::ChannelFailed(msg.to_string())
510 } else {
511 SshCliError::ChannelFailed(format!("SCP: {msg}"))
512 }
513 }
514
515 fn normalize_scp_missing_path(msg: &str) -> String {
517 let mut s = msg.trim().to_string();
518 for prefix in ["SCP: ", "SCP:", "scp: ", "scp:"] {
519 if let Some(rest) = s.strip_prefix(prefix) {
520 s = rest.trim().to_string();
521 }
522 }
523 let lower = s.to_ascii_lowercase();
525 for needle in [": no such file or directory", ": not found"] {
526 if let Some(idx) = lower.find(needle) {
527 return s[..idx].trim().trim_matches('"').to_string();
528 }
529 }
530 s
531 }
532
533 fn interpret_scp_status(bytes: &[u8]) -> SshCliResult<()> {
535 if bytes.is_empty() {
536 return Err(SshCliError::ChannelFailed(
537 "empty SCP status (expected ACK 0x00)".to_string(),
538 ));
539 }
540 match bytes[0] {
541 SCP_OK => Ok(()),
542 1 | 2 => {
543 let msg = String::from_utf8_lossy(&bytes[1..]).trim().to_string();
544 if msg.is_empty() {
545 Err(SshCliError::ChannelFailed(format!(
546 "SCP rejected the transfer (status {})",
547 bytes[0]
548 )))
549 } else {
550 let full = format!("SCP: {msg}");
552 Err(classify_scp_message(&full))
553 }
554 }
555 other => Err(SshCliError::ChannelFailed(format!(
556 "unexpected SCP status: 0x{other:02x}"
557 ))),
558 }
559 }
560
561 fn remote_scp_command(mode: &str, remote: &std::path::Path) -> String {
567 let path = crate::ssh::packing::escape_shell_single_quotes(&remote.display().to_string());
568 let mode_p = if mode.contains('p') {
570 mode.to_string()
571 } else {
572 format!("{mode}p")
573 };
574 format!("scp {mode_p} {path}")
576 }
577
578 fn apply_local_mode(path: &std::path::Path, mode: u32) -> SshCliResult<()> {
580 #[cfg(unix)]
581 {
582 use std::os::unix::fs::PermissionsExt;
583 let perms = std::fs::Permissions::from_mode(mode & 0o7777);
584 std::fs::set_permissions(path, perms).map_err(SshCliError::Io)?;
585 }
586 #[cfg(not(unix))]
587 {
588 let _ = (path, mode);
589 }
590 Ok(())
591 }
592
593 async fn scp_read_data<S>(channel: &mut russh::Channel<S>) -> SshCliResult<Vec<u8>>
595 where
596 S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
597 {
598 use russh::ChannelMsg;
599 loop {
600 match channel.wait().await {
601 Some(ChannelMsg::Data { data }) => {
602 if data.is_empty() {
603 continue;
604 }
605 return Ok(data.to_vec());
606 }
607 Some(ChannelMsg::ExtendedData { data, .. }) => {
608 if data.is_empty() {
609 continue;
610 }
611 let msg = String::from_utf8_lossy(data.as_ref()).trim().to_string();
612 let full = format!("SCP stderr: {msg}");
614 return Err(classify_scp_message(&full));
615 }
616 Some(ChannelMsg::ExitStatus { exit_status }) if exit_status != 0 => {
617 return Err(SshCliError::ChannelFailed(format!(
618 "scp encerrou com exit {exit_status}"
619 )));
620 }
621 Some(ChannelMsg::Close) | None => {
622 return Err(SshCliError::ChannelFailed(
623 "SCP channel closed prematurely".to_string(),
624 ));
625 }
626 _ => continue,
627 }
628 }
629 }
630
631 async fn scp_wait_status<S>(channel: &mut russh::Channel<S>) -> SshCliResult<()>
633 where
634 S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
635 {
636 let data = scp_read_data(channel).await?;
637 interpret_scp_status(&data)
638 }
639
640 async fn scp_read_until_newline<S>(channel: &mut russh::Channel<S>) -> SshCliResult<Vec<u8>>
642 where
643 S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
644 {
645 let mut buf = Vec::new();
646 loop {
647 let chunk = scp_read_data(channel).await?;
648 if buf.is_empty() && matches!(chunk.first().copied(), Some(1 | 2)) {
649 return Ok(chunk);
650 }
651 buf.extend_from_slice(&chunk);
652 if buf.contains(&b'\n') {
653 return Ok(buf);
654 }
655 if buf.len() > 16_384 {
656 return Err(SshCliError::ChannelFailed(
657 "SCP header excessively long".to_string(),
658 ));
659 }
660 }
661 }
662
663 impl SshClient {
664 pub async fn connect(cfg: ConnectionConfig) -> SshCliResult<Self> {
674 cfg.validate()?;
675
676 let timeout = Duration::from_millis(cfg.timeout_ms);
677 let host = cfg.host.clone();
678 let port = cfg.port;
679 let username = cfg.username.clone();
680 let secure_password = cfg.password.clone();
681 let key_path = cfg.key_path.clone();
682 let key_passphrase = cfg.key_passphrase.clone();
683 let handler = ClientHandler::new(&cfg);
684
685 let client_config = Arc::new(russh::client::Config {
686 inactivity_timeout: Some(timeout),
687 ..Default::default()
688 });
689
690 tracing::info!(
691 host = %host,
692 port,
693 username = %username,
694 timeout_ms = cfg.timeout_ms,
695 has_key = key_path.is_some(),
696 "iniciando conexão SSH"
697 );
698
699 let connection_result = tokio::time::timeout(timeout, async move {
700 let mut session = russh::client::connect(
701 client_config,
702 (host.as_str(), port),
703 handler,
704 )
705 .await
706 .map_err(|e| SshCliError::ConnectionFailed(format!("TCP/handshake failed: {e}")))?;
707
708 let mut autenticado = false;
710
711 if let Some(ref kp) = key_path {
712 let pass = key_passphrase
713 .as_ref()
714 .map(|s| s.expose_secret().to_string());
715 let key = russh::keys::load_secret_key(kp, pass.as_deref()).map_err(|e| {
716 SshCliError::SshAuthentication(format!(
717 "failed to load key {kp}: {e}"
718 ))
719 })?;
720 let hash = session
721 .best_supported_rsa_hash()
722 .await
723 .map_err(|e| {
724 SshCliError::ConnectionFailed(format!("rsa hash: {e}"))
725 })?
726 .flatten();
727 let auth = session
728 .authenticate_publickey(
729 username.clone(),
730 russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), hash),
731 )
732 .await
733 .map_err(|e| {
734 SshCliError::ConnectionFailed(format!("publickey auth failed: {e}"))
735 })?;
736 autenticado = auth.success();
737 if !autenticado {
738 tracing::warn!(host = %host, "key auth rejected; trying password if present");
739 }
740 }
741
742 if !autenticado && !secure_password.expose_secret().is_empty() {
743 let auth = session
744 .authenticate_password(username.clone(), secure_password.expose_secret())
745 .await
746 .map_err(|e| {
747 SshCliError::ConnectionFailed(format!("password auth failed: {e}"))
748 })?;
749 autenticado = auth.success();
750 }
751
752 if !autenticado {
753 tracing::warn!(host = %host, username = %username, "SSH authentication rejected");
754 return Err(SshCliError::AuthenticationFailed);
755 }
756
757 Ok::<_, SshCliError>(session)
758 })
759 .await;
760
761 let session = match connection_result {
762 Ok(Ok(s)) => s,
763 Ok(Err(err)) => return Err(err),
764 Err(_) => return Err(SshCliError::SshTimeout(cfg.timeout_ms)),
765 };
766
767 tracing::info!("conexão SSH autenticada com sucesso");
768
769 Ok(Self { session, cfg })
770 }
771
772 pub async fn run_command(
781 &mut self,
782 command: &str,
783 max_chars: usize,
784 stdin_data: Option<Vec<u8>>,
785 ) -> SshCliResult<ExecutionOutput> {
786 self.run_command_internal(command, max_chars, true, stdin_data)
787 .await
788 }
789
790 async fn run_command_internal(
791 &mut self,
792 command: &str,
793 max_chars: usize,
794 abort_em_timeout: bool,
795 stdin_data: Option<Vec<u8>>,
796 ) -> SshCliResult<ExecutionOutput> {
797 let inicio = Instant::now();
798 let timeout = Duration::from_millis(self.cfg.timeout_ms);
799
800 let result = tokio::time::timeout(timeout, async {
801 let mut channel = self
802 .session
803 .channel_open_session()
804 .await
805 .map_err(|e| SshCliError::ChannelFailed(format!("abrir sessão: {e}")))?;
806
807 channel
808 .exec(true, command)
809 .await
810 .map_err(|e| SshCliError::ChannelFailed(format!("exec: {e}")))?;
811
812 if let Some(bytes) = stdin_data.as_ref() {
814 channel
815 .data(&bytes[..])
816 .await
817 .map_err(|e| SshCliError::ChannelFailed(format!("stdin channel: {e}")))?;
818 channel
819 .eof()
820 .await
821 .map_err(|e| SshCliError::ChannelFailed(format!("eof channel: {e}")))?;
822 }
823
824 let mut stdout_bytes: Vec<u8> = Vec::new();
825 let mut stderr_bytes: Vec<u8> = Vec::new();
826 let mut exit_code: Option<i32> = None;
827
828 while let Some(msg) = channel.wait().await {
829 if process_exec_message(
830 msg,
831 &mut stdout_bytes,
832 &mut stderr_bytes,
833 &mut exit_code,
834 ) {
835 break;
836 }
837 }
838
839 Ok::<_, SshCliError>((stdout_bytes, stderr_bytes, exit_code))
840 })
841 .await;
842
843 let (stdout_bytes, stderr_bytes, exit_code) = match result {
844 Ok(Ok(t)) => t,
845 Ok(Err(err)) => return Err(err),
846 Err(_) => {
847 if abort_em_timeout {
848 if let Some(pattern) = crate::ssh::packing::remote_abort_pattern(command) {
849 let abort_cmd = crate::ssh::packing::pack_abort_pkill(&pattern);
850 tracing::warn!(
851 pattern = %pattern,
852 "local timeout; attempting best-effort remote abort"
853 );
854 let _ = self.try_remote_abort(&abort_cmd).await;
855 }
856 }
857 return Err(SshCliError::SshTimeout(self.cfg.timeout_ms));
858 }
859 };
860
861 let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
862 let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
863
864 let (stdout_truncado, truncated_stdout) = super::truncate_utf8(&stdout_str, max_chars);
865 let (stderr_truncado, truncated_stderr) = super::truncate_utf8(&stderr_str, max_chars);
866
867 let duration_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
868
869 Ok(ExecutionOutput {
870 stdout: stdout_truncado,
871 stderr: stderr_truncado,
872 exit_code,
873 truncated_stdout,
874 truncated_stderr,
875 duration_ms,
876 })
877 }
878
879 pub async fn upload(
889 &mut self,
890 local: &std::path::Path,
891 remote: &std::path::Path,
892 ) -> SshCliResult<TransferResult> {
893 use russh::ChannelMsg;
894 use std::io::Read;
895 use std::time::Instant;
896
897 let local_str = local.display().to_string();
898
899 if local.is_dir() {
900 return Err(SshCliError::InvalidArgument(crate::i18n::t(
901 crate::i18n::Message::ScpUploadFileOnly,
902 )));
903 }
904
905 let metadados = std::fs::metadata(local).map_err(|e| {
906 if e.kind() == std::io::ErrorKind::NotFound {
907 SshCliError::FileNotFound(local_str.clone())
908 } else {
909 SshCliError::Io(e)
910 }
911 })?;
912
913 if !metadados.is_file() {
914 return Err(SshCliError::InvalidArgument(crate::i18n::t(
915 crate::i18n::Message::ScpUploadFileOnly,
916 )));
917 }
918
919 let size = metadados.len();
920 let mode = scp_mode_from_metadata(&metadados);
921 let mtime = metadados.modified().ok().map(system_time_secs).unwrap_or(0);
922 let atime = metadados
923 .accessed()
924 .ok()
925 .map(system_time_secs)
926 .unwrap_or(mtime);
927 let file_name = local.file_name().and_then(|n| n.to_str()).unwrap_or("file");
928
929 let inicio = Instant::now();
930 let timeout = Duration::from_millis(self.cfg.timeout_ms);
931
932 let result =
933 tokio::time::timeout(timeout, async {
934 if crate::signals::is_cancelled() {
935 return Err(SshCliError::InvalidArgument(crate::i18n::t(
936 crate::i18n::Message::OperationCancelled,
937 )));
938 }
939
940 let mut channel =
941 self.session.channel_open_session().await.map_err(|e| {
942 SshCliError::ChannelFailed(format!("abrir sessão SCP: {e}"))
943 })?;
944
945 let command = remote_scp_command("-t", remote);
946 channel
947 .exec(true, command.as_str())
948 .await
949 .map_err(|e| SshCliError::ChannelFailed(format!("exec SCP: {e}")))?;
950
951 scp_wait_status(&mut channel).await?;
953
954 let linha_t = format_scp_t_line(mtime, atime);
956 channel
957 .data(linha_t.as_bytes())
958 .await
959 .map_err(|e| SshCliError::ChannelFailed(format!("enviar linha T SCP: {e}")))?;
960 scp_wait_status(&mut channel).await?;
961
962 let header = format_scp_upload_header_with_mode(mode, size, file_name);
963 channel
964 .data(header.as_bytes())
965 .await
966 .map_err(|e| SshCliError::ChannelFailed(format!("enviar header SCP: {e}")))?;
967 scp_wait_status(&mut channel).await?;
968
969 let mut file = std::fs::File::open(local).map_err(SshCliError::Io)?;
971 let mut buf = vec![0u8; 32_768];
972 loop {
973 if crate::signals::is_cancelled() {
974 return Err(SshCliError::InvalidArgument(crate::i18n::t(
975 crate::i18n::Message::OperationCancelled,
976 )));
977 }
978 let n = file.read(&mut buf).map_err(SshCliError::Io)?;
979 if n == 0 {
980 break;
981 }
982 channel.data(&buf[..n]).await.map_err(|e| {
983 SshCliError::ChannelFailed(format!("enviar bloco SCP: {e}"))
984 })?;
985 }
986
987 channel
989 .data([SCP_OK].as_slice())
990 .await
991 .map_err(|e| SshCliError::ChannelFailed(format!("enviar EOF SCP: {e}")))?;
992 scp_wait_status(&mut channel).await?;
993
994 let _ = channel.eof().await;
995 while let Some(msg) = channel.wait().await {
996 if let ChannelMsg::Close = msg {
997 break;
998 }
999 }
1000
1001 Ok::<_, SshCliError>(())
1002 })
1003 .await;
1004
1005 result.map_err(|_| SshCliError::SshTimeout(self.cfg.timeout_ms))??;
1006
1007 let duration_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
1008
1009 Ok(TransferResult {
1010 bytes_transferred: size,
1011 duration_ms,
1012 })
1013 }
1014
1015 pub async fn download(
1024 &mut self,
1025 remote: &std::path::Path,
1026 local: &std::path::Path,
1027 ) -> SshCliResult<TransferResult> {
1028 use russh::ChannelMsg;
1029 use std::io::Write;
1030 use std::time::{Duration as StdDuration, Instant, UNIX_EPOCH};
1031
1032 if local.is_dir() {
1033 return Err(SshCliError::InvalidArgument(crate::i18n::t(
1034 crate::i18n::Message::ScpDownloadLocalNotDirectory,
1035 )));
1036 }
1037
1038 let inicio = Instant::now();
1039 let timeout = Duration::from_millis(self.cfg.timeout_ms);
1040 let partial = partial_download_path(local);
1041
1042 let result = tokio::time::timeout(timeout, async {
1043 if crate::signals::is_cancelled() {
1044 return Err(SshCliError::InvalidArgument(crate::i18n::t(
1045 crate::i18n::Message::OperationCancelled,
1046 )));
1047 }
1048
1049 let mut channel = self
1050 .session
1051 .channel_open_session()
1052 .await
1053 .map_err(|e| SshCliError::ChannelFailed(format!("abrir sessão SCP: {e}")))?;
1054
1055 let command = remote_scp_command("-f", remote);
1056 channel
1057 .exec(true, command.as_str())
1058 .await
1059 .map_err(|e| SshCliError::ChannelFailed(format!("exec SCP: {e}")))?;
1060
1061 channel
1063 .data([SCP_OK].as_slice())
1064 .await
1065 .map_err(|e| SshCliError::ChannelFailed(format!("enviar ack inicial: {e}")))?;
1066
1067 let mut times: Option<(u64, u64)> = None;
1068 let mut header_bytes = scp_read_until_newline(&mut channel).await?;
1069 if !header_bytes.is_empty() && matches!(header_bytes[0], 1 | 2) {
1071 interpret_scp_status(&header_bytes)?;
1072 }
1073 let mut header = String::from_utf8_lossy(&header_bytes).into_owned();
1074 if header.trim_start().starts_with('T') {
1076 times = Some(parse_scp_t_line(&header)?);
1077 channel
1078 .data([SCP_OK].as_slice())
1079 .await
1080 .map_err(|e| SshCliError::ChannelFailed(format!("enviar ack T: {e}")))?;
1081 header_bytes = scp_read_until_newline(&mut channel).await?;
1082 if !header_bytes.is_empty() && matches!(header_bytes[0], 1 | 2) {
1083 interpret_scp_status(&header_bytes)?;
1084 }
1085 header = String::from_utf8_lossy(&header_bytes).into_owned();
1086 }
1087 let (mode_remoto, size) = parse_scp_header(&header)?;
1088
1089 channel
1090 .data([SCP_OK].as_slice())
1091 .await
1092 .map_err(|e| SshCliError::ChannelFailed(format!("enviar ack header: {e}")))?;
1093
1094 if let Some(parent_dir) = local.parent() {
1095 if !parent_dir.as_os_str().is_empty() {
1096 std::fs::create_dir_all(parent_dir)?;
1097 }
1098 }
1099
1100 let mut file = std::fs::File::create(&partial).map_err(SshCliError::Io)?;
1102 let mut recebidos: u64 = 0;
1103 let mut pendente: Vec<u8> = Vec::new();
1104
1105 while recebidos < size {
1106 if crate::signals::is_cancelled() {
1107 return Err(SshCliError::InvalidArgument(crate::i18n::t(
1108 crate::i18n::Message::OperationCancelled,
1109 )));
1110 }
1111 if pendente.is_empty() {
1112 let chunk = scp_read_data(&mut channel).await?;
1113 pendente.extend_from_slice(&chunk);
1114 }
1115 let falta = (size - recebidos) as usize;
1116 let usar = falta.min(pendente.len());
1117 file
1118 .write_all(&pendente[..usar])
1119 .map_err(SshCliError::Io)?;
1120 recebidos += usar as u64;
1121 pendente.drain(..usar);
1122 }
1123
1124 if pendente.is_empty() {
1126 match scp_read_data(&mut channel).await {
1127 Ok(trail) => pendente.extend_from_slice(&trail),
1128 Err(_) if recebidos == size => {}
1129 Err(e) => return Err(e),
1130 }
1131 }
1132 if pendente.first() == Some(&SCP_OK) {
1133 pendente.remove(0);
1134 } else if !pendente.is_empty() {
1135 return Err(SshCliError::ChannelFailed(format!(
1136 "unexpected SCP terminator after payload (0x{:02x})",
1137 pendente[0]
1138 )));
1139 }
1140
1141 file.flush().map_err(SshCliError::Io)?;
1142 let _ = file.sync_data();
1143 drop(file);
1144
1145 channel
1146 .data([SCP_OK].as_slice())
1147 .await
1148 .map_err(|e| SshCliError::ChannelFailed(format!("enviar ack final: {e}")))?;
1149
1150 let _ = channel.eof().await;
1151 while let Some(msg) = channel.wait().await {
1152 if matches!(msg, ChannelMsg::Close) {
1153 break;
1154 }
1155 }
1156
1157 apply_local_mode(&partial, mode_remoto)?;
1160 if let Some((mtime, atime)) = times {
1161 let mtime_st = UNIX_EPOCH + StdDuration::from_secs(mtime);
1162 let atime_st = UNIX_EPOCH + StdDuration::from_secs(atime);
1163 let ft = std::fs::FileTimes::new()
1164 .set_modified(mtime_st)
1165 .set_accessed(atime_st);
1166 if let Ok(f) = std::fs::File::options().write(true).open(&partial) {
1167 let _ = f.set_times(ft);
1168 }
1169 }
1170
1171 std::fs::rename(&partial, local).map_err(SshCliError::Io)?;
1172 if let Some(parent_dir) = local.parent() {
1174 if !parent_dir.as_os_str().is_empty() {
1175 if let Ok(dir) = std::fs::File::open(parent_dir) {
1176 let _ = dir.sync_all();
1177 }
1178 }
1179 }
1180
1181 Ok::<_, SshCliError>(recebidos)
1182 })
1183 .await;
1184
1185 match result {
1186 Ok(Ok(recebidos)) => {
1187 let duration_ms =
1188 u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
1189 Ok(TransferResult {
1190 bytes_transferred: recebidos,
1191 duration_ms,
1192 })
1193 }
1194 Ok(Err(e)) => {
1195 let _ = std::fs::remove_file(&partial);
1196 Err(e)
1199 }
1200 Err(_) => {
1201 let _ = std::fs::remove_file(&partial);
1202 Err(SshCliError::SshTimeout(self.cfg.timeout_ms))
1203 }
1204 }
1205 }
1206
1207 async fn try_remote_abort(&self, abort_cmd: &str) -> SshCliResult<()> {
1209 let mut cfg_abort = self.cfg.clone();
1212 cfg_abort.timeout_ms = cfg_abort.timeout_ms.clamp(3_000, 10_000);
1213 let outro = match Self::connect(cfg_abort).await {
1214 Ok(c) => c,
1215 Err(e) => {
1216 tracing::debug!(err = %e, "remote abort could not reconnect");
1217 return Err(e);
1218 }
1219 };
1220 let timeout = Duration::from_millis(outro.cfg.timeout_ms);
1221 let _ = tokio::time::timeout(timeout, async {
1222 let mut channel = outro
1223 .session
1224 .channel_open_session()
1225 .await
1226 .map_err(|e| SshCliError::ChannelFailed(format!("abort channel: {e}")))?;
1227 channel
1228 .exec(true, abort_cmd)
1229 .await
1230 .map_err(|e| SshCliError::ChannelFailed(format!("abort exec: {e}")))?;
1231 while let Some(msg) = channel.wait().await {
1232 if matches!(msg, russh::ChannelMsg::Close) {
1233 break;
1234 }
1235 }
1236 Ok::<(), SshCliError>(())
1237 })
1238 .await;
1239 let _ = outro.disconnect().await;
1240 Ok(())
1241 }
1242
1243 pub async fn disconnect(&self) -> SshCliResult<()> {
1248 let result = self
1249 .session
1250 .disconnect(russh::Disconnect::ByApplication, "encerrando", "pt-BR")
1251 .await;
1252 match result {
1253 Ok(()) => {
1254 tracing::info!("sessão SSH encerrada");
1255 Ok(())
1256 }
1257 Err(e) => {
1258 tracing::warn!(err = %e, "failed to close SSH session");
1259 Err(SshCliError::ConnectionFailed(format!(
1260 "failed to disconnect: {e}"
1261 )))
1262 }
1263 }
1264 }
1265
1266 pub async fn open_tunnel_channel(
1268 &self,
1269 remote_host: &str,
1270 remote_port: u16,
1271 endereco_origem: &str,
1272 porta_origem: u16,
1273 ) -> SshCliResult<Box<dyn TunnelChannel>> {
1274 let channel = self
1275 .session
1276 .channel_open_direct_tcpip(
1277 remote_host.to_string(),
1278 u32::from(remote_port),
1279 endereco_origem.to_string(),
1280 u32::from(porta_origem),
1281 )
1282 .await
1283 .map_err(|e| {
1284 SshCliError::ChannelFailed(format!(
1285 "failed to open direct-tcpip channel to {}:{}: {}",
1286 remote_host, remote_port, e
1287 ))
1288 })?;
1289
1290 Ok(Box::new(channel.into_stream()))
1291 }
1292 }
1293
1294 #[async_trait]
1295 impl SshClientTrait for SshClient {
1296 async fn connect(cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError> {
1297 Self::connect(cfg).await.map(Box::new)
1298 }
1299
1300 async fn run_command(
1301 &mut self,
1302 cmd: &str,
1303 max_chars: usize,
1304 stdin_data: Option<Vec<u8>>,
1305 ) -> Result<ExecutionOutput, SshCliError> {
1306 Self::run_command(self, cmd, max_chars, stdin_data).await
1307 }
1308
1309 async fn upload(
1310 &mut self,
1311 local: &Path,
1312 remote: &Path,
1313 ) -> Result<TransferResult, SshCliError> {
1314 Self::upload(self, local, remote).await
1315 }
1316
1317 async fn download(
1318 &mut self,
1319 remote: &Path,
1320 local: &Path,
1321 ) -> Result<TransferResult, SshCliError> {
1322 Self::download(self, remote, local).await
1323 }
1324
1325 async fn open_tunnel_channel(
1326 &self,
1327 remote_host: &str,
1328 remote_port: u16,
1329 endereco_origem: &str,
1330 porta_origem: u16,
1331 ) -> Result<Box<dyn TunnelChannel>, SshCliError> {
1332 Self::open_tunnel_channel(
1333 self,
1334 remote_host,
1335 remote_port,
1336 endereco_origem,
1337 porta_origem,
1338 )
1339 .await
1340 }
1341
1342 async fn disconnect(&self) -> Result<(), SshCliError> {
1343 Self::disconnect(self).await
1344 }
1345 }
1346
1347 #[cfg(test)]
1348 mod real_tests {
1349 use super::{
1350 partial_download_path, classify_scp_message, remote_scp_command,
1351 format_scp_upload_header, format_scp_upload_header_with_mode, format_scp_t_line,
1352 interpret_scp_status, map_exit_status, parse_scp_header, parse_scp_t_line,
1353 process_exec_message, SCP_PARTIAL_SUFFIX,
1354 };
1355 use crate::erros::SshCliError;
1356
1357 #[test]
1358 fn map_exit_status_normal() {
1359 assert_eq!(map_exit_status(0), 0);
1360 assert_eq!(map_exit_status(255), 255);
1361 }
1362
1363 #[test]
1364 fn map_exit_status_overflow_returns_minus_one() {
1365 assert_eq!(map_exit_status(u32::MAX), -1);
1366 }
1367
1368 #[test]
1369 fn parse_scp_header_valid_returns_mode_and_size() {
1370 let (mode, size) =
1371 parse_scp_header("C0644 42 arquivo.txt\n").expect("valid header");
1372 assert_eq!(mode, 0o644);
1373 assert_eq!(size, 42);
1374 let (mode2, _) = parse_scp_header("C0600 1 x\n").expect("600");
1375 assert_eq!(mode2, 0o600);
1376 }
1377
1378 #[test]
1379 fn parse_scp_header_invalid_returns_error() {
1380 assert!(parse_scp_header("ERRO").is_err());
1381 assert!(parse_scp_header("C0644 sem_tamanho").is_err());
1382 assert!(parse_scp_header("C0644 abc arquivo").is_err());
1383 assert!(parse_scp_header("Czzzz 1 x\n").is_err());
1384 }
1385
1386 #[test]
1387 fn process_exec_message_handles_stdout_stderr_close() {
1388 let mut stdout = Vec::new();
1389 let mut stderr = Vec::new();
1390 let mut exit_code = None;
1391
1392 let deve_parar = process_exec_message(
1393 russh::ChannelMsg::Data {
1394 data: b"stdout".to_vec().into(),
1395 },
1396 &mut stdout,
1397 &mut stderr,
1398 &mut exit_code,
1399 );
1400 assert!(!deve_parar);
1401 assert_eq!(stdout, b"stdout");
1402
1403 let deve_parar = process_exec_message(
1404 russh::ChannelMsg::ExtendedData {
1405 data: b"stderr".to_vec().into(),
1406 ext: 1,
1407 },
1408 &mut stdout,
1409 &mut stderr,
1410 &mut exit_code,
1411 );
1412 assert!(!deve_parar);
1413 assert_eq!(stderr, b"stderr");
1414
1415 let _ = process_exec_message(
1416 russh::ChannelMsg::ExitStatus { exit_status: 17 },
1417 &mut stdout,
1418 &mut stderr,
1419 &mut exit_code,
1420 );
1421 assert_eq!(exit_code, Some(17));
1422
1423 let deve_parar = process_exec_message(
1424 russh::ChannelMsg::Close,
1425 &mut stdout,
1426 &mut stderr,
1427 &mut exit_code,
1428 );
1429 assert!(deve_parar);
1430 }
1431
1432 #[test]
1433 fn format_scp_upload_header_expected_format() {
1434 let header = format_scp_upload_header(123, "arquivo.txt");
1435 assert_eq!(header, "C0644 123 arquivo.txt\n");
1437 assert_eq!(header.as_bytes().last().copied(), Some(b'\n'));
1438 assert!(
1439 !header.as_bytes().windows(2).any(|w| w == *b"\\n"),
1440 "header must not contain literal backslash-n"
1441 );
1442 }
1443
1444 #[test]
1445 fn format_scp_upload_header_uses_basename() {
1446 let header = format_scp_upload_header(1, "/tmp/dir/nome.bin");
1447 assert_eq!(header, "C0644 1 nome.bin\n");
1448 }
1449
1450 #[test]
1451 fn interpret_scp_status_ok_and_error() {
1452 assert!(interpret_scp_status(&[0]).is_ok());
1453 assert!(interpret_scp_status(&[1, b'f', b'a', b'i', b'l']).is_err());
1454 assert!(interpret_scp_status(&[]).is_err());
1455 }
1456
1457 #[test]
1459 fn interpret_scp_status_no_such_file() {
1460 let mut payload = vec![1u8];
1461 payload.extend_from_slice(b"scp: /tmp/missing: No such file or directory\n");
1462 let err = interpret_scp_status(&payload).unwrap_err();
1463 assert!(
1464 matches!(err, SshCliError::FileNotFound(_)),
1465 "esperado FileNotFound, got {err:?}"
1466 );
1467 assert_eq!(err.exit_code(), 66);
1468 }
1469
1470 #[test]
1471 fn classificar_mensagem_scp_protocol_permanece_canal() {
1472 let err = classify_scp_message("SCP: protocol error");
1473 assert!(matches!(err, SshCliError::ChannelFailed(_)));
1474 assert_eq!(err.exit_code(), 74);
1475 let err2 = classify_scp_message("SCP stderr: Permission denied");
1476 assert!(matches!(err2, SshCliError::ChannelFailed(_)));
1477 assert_eq!(err2.exit_code(), 74);
1478 }
1479
1480 #[test]
1481 fn classificar_mensagem_scp_not_found_e_66() {
1482 let err = classify_scp_message("SCP stderr: scp: foo: not found");
1483 assert!(matches!(err, SshCliError::FileNotFound(_)));
1484 assert_eq!(err.exit_code(), 66);
1485 }
1486
1487 #[test]
1488 fn remote_scp_command_escapa_path_e_usa_p() {
1489 let cmd = remote_scp_command("-t", std::path::Path::new("/tmp/a b.txt"));
1490 assert_eq!(cmd, "scp -tp '/tmp/a b.txt'");
1491 let cmd_f = remote_scp_command("-f", std::path::Path::new("/var/log/a.log"));
1492 assert_eq!(cmd_f, "scp -fp '/var/log/a.log'");
1493 assert_eq!(
1495 remote_scp_command("-fp", std::path::Path::new("/x")),
1496 "scp -fp '/x'"
1497 );
1498 }
1499
1500 #[test]
1501 fn format_scp_t_line_format() {
1502 let t = format_scp_t_line(1_700_000_000, 1_700_000_001);
1503 assert_eq!(t, "T1700000000 0 1700000001 0\n");
1504 assert_eq!(t.as_bytes().last().copied(), Some(b'\n'));
1505 }
1506
1507 #[test]
1508 fn parse_scp_t_line_ok() {
1509 let (m, a) = parse_scp_t_line("T100 0 200 0\n").expect("T ok");
1510 assert_eq!((m, a), (100, 200));
1511 }
1512
1513 #[test]
1514 fn format_header_with_mode() {
1515 let h = format_scp_upload_header_with_mode(0o755, 10, "x.sh");
1516 assert_eq!(h, "C0755 10 x.sh\n");
1517 }
1518
1519 #[test]
1520 fn partial_download_path_suffix() {
1521 let p = partial_download_path(std::path::Path::new("/tmp/out.bin"));
1522 assert!(p.to_string_lossy().ends_with(SCP_PARTIAL_SUFFIX));
1523 assert!(p.to_string_lossy().contains("out.bin"));
1524 }
1525
1526 #[test]
1527 fn process_exec_message_ignores_extended_non_stderr() {
1528 let mut stdout = Vec::new();
1529 let mut stderr = Vec::new();
1530 let mut exit_code = None;
1531
1532 let deve_parar = process_exec_message(
1533 russh::ChannelMsg::ExtendedData {
1534 data: b"nao-e-stderr".to_vec().into(),
1535 ext: 2,
1536 },
1537 &mut stdout,
1538 &mut stderr,
1539 &mut exit_code,
1540 );
1541
1542 assert!(!deve_parar);
1543 assert!(stdout.is_empty());
1544 assert!(stderr.is_empty());
1545 assert!(exit_code.is_none());
1546 }
1547
1548 #[test]
1549 fn process_exec_message_handles_exit_signal_and_eof() {
1550 let mut stdout = Vec::new();
1551 let mut stderr = Vec::new();
1552 let mut exit_code = Some(7);
1553
1554 let deve_parar_signal = process_exec_message(
1555 russh::ChannelMsg::ExitSignal {
1556 signal_name: russh::Sig::TERM,
1557 core_dumped: false,
1558 error_message: "encerrado".to_string(),
1559 lang_tag: "pt-BR".to_string(),
1560 },
1561 &mut stdout,
1562 &mut stderr,
1563 &mut exit_code,
1564 );
1565
1566 let deve_parar_eof = process_exec_message(
1567 russh::ChannelMsg::Eof,
1568 &mut stdout,
1569 &mut stderr,
1570 &mut exit_code,
1571 );
1572
1573 assert!(!deve_parar_signal);
1574 assert!(!deve_parar_eof);
1575 assert_eq!(exit_code, Some(7));
1576 }
1577
1578 #[test]
1579 fn process_exec_message_ignores_unhandled_variants() {
1580 let mut stdout = Vec::new();
1581 let mut stderr = Vec::new();
1582 let mut exit_code = None;
1583
1584 let deve_parar = process_exec_message(
1585 russh::ChannelMsg::WindowAdjusted { new_size: 2048 },
1586 &mut stdout,
1587 &mut stderr,
1588 &mut exit_code,
1589 );
1590
1591 assert!(!deve_parar);
1592 assert!(stdout.is_empty());
1593 assert!(stderr.is_empty());
1594 assert!(exit_code.is_none());
1595 }
1596 }
1597}
1598
1599#[cfg(feature = "ssh-real")]
1600pub use real::{SshClient, ClientHandler};
1601
1602#[cfg(not(feature = "ssh-real"))]
1607mod stub {
1608 use super::{ConnectionConfig, ExecutionOutput, TransferResult};
1609 use crate::erros::SshCliError;
1610 use crate::ssh::client::SshClientTrait;
1611 use async_trait::async_trait;
1612 use std::path::Path;
1613
1614 #[derive(Debug)]
1617 pub struct SshClient;
1618
1619 #[async_trait]
1620 impl SshClientTrait for SshClient {
1621 async fn connect(_cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError> {
1622 Err(SshCliError::ConnectionFailed(
1623 "feature `ssh-real` está desabilitada; recompile com --features ssh-real".into(),
1624 ))
1625 }
1626
1627 async fn run_command(
1628 &mut self,
1629 _cmd: &str,
1630 _max_chars: usize,
1631 _stdin_data: Option<Vec<u8>>,
1632 ) -> Result<ExecutionOutput, SshCliError> {
1633 Err(SshCliError::ChannelFailed(
1634 "stub sem russh: feature `ssh-real` desabilitada".into(),
1635 ))
1636 }
1637
1638 async fn upload(
1639 &mut self,
1640 _local: &Path,
1641 _remote: &Path,
1642 ) -> Result<TransferResult, SshCliError> {
1643 Err(SshCliError::ChannelFailed(
1644 "stub sem russh: feature `ssh-real` desabilitada".into(),
1645 ))
1646 }
1647
1648 async fn download(
1649 &mut self,
1650 _remote: &Path,
1651 _local: &Path,
1652 ) -> Result<TransferResult, SshCliError> {
1653 Err(SshCliError::ChannelFailed(
1654 "stub sem russh: feature `ssh-real` desabilitada".into(),
1655 ))
1656 }
1657
1658 async fn open_tunnel_channel(
1659 &self,
1660 _host_remoto: &str,
1661 _porta_remota: u16,
1662 _endereco_origem: &str,
1663 _porta_origem: u16,
1664 ) -> Result<Box<dyn super::TunnelChannel>, SshCliError> {
1665 Err(SshCliError::ChannelFailed(
1666 "stub sem russh: feature `ssh-real` desabilitada".into(),
1667 ))
1668 }
1669
1670 async fn disconnect(&self) -> Result<(), SshCliError> {
1671 Ok(())
1672 }
1673 }
1674}
1675
1676#[cfg(not(feature = "ssh-real"))]
1677pub use stub::SshClient;
1678
1679#[cfg(test)]
1684mod tests {
1685 use super::*;
1686 use secrecy::SecretString;
1687
1688 fn valid_cfg() -> ConnectionConfig {
1689 ConnectionConfig {
1690 host: "127.0.0.1".to_string(),
1691 port: 22,
1692 username: "root".to_string(),
1693 password: SecretString::from("senha-exemplo".to_string()),
1694 key_path: None,
1695 key_passphrase: None,
1696 timeout_ms: 5000,
1697 known_hosts_path: None,
1698 replace_host_key: false,
1699 }
1700 }
1701
1702 #[test]
1703 fn validate_empty_host_returns_error() {
1704 let mut c = valid_cfg();
1705 c.host = String::new();
1706 let r = c.validate();
1707 assert!(r.is_err());
1708 let msg = r.unwrap_err().to_string();
1709 assert!(msg.contains("host"));
1710 }
1711
1712 #[test]
1713 fn validate_whitespace_host_returns_error() {
1714 let mut c = valid_cfg();
1715 c.host = " ".to_string();
1716 assert!(c.validate().is_err());
1717 }
1718
1719 #[test]
1720 fn validate_port_zero_returns_error() {
1721 let mut c = valid_cfg();
1722 c.port = 0;
1723 let r = c.validate();
1724 assert!(r.is_err());
1725 let msg = r.unwrap_err().to_string();
1726 assert!(msg.contains("port"));
1727 }
1728
1729 #[test]
1730 fn validate_empty_user_returns_error() {
1731 let mut c = valid_cfg();
1732 c.username = String::new();
1733 assert!(c.validate().is_err());
1734 }
1735
1736 #[test]
1737 fn validate_correct_config_returns_ok() {
1738 assert!(valid_cfg().validate().is_ok());
1739 }
1740
1741 #[test]
1742 fn debug_does_not_expose_password() {
1743 let c = valid_cfg();
1744 let dbg = format!("{c:?}");
1745 assert!(!dbg.contains("senha-exemplo"));
1746 assert!(dbg.contains("redacted"));
1747 }
1748
1749 #[test]
1750 fn truncate_utf8_no_truncate_if_fits() {
1751 let (s, t) = truncate_utf8("ola mundo", 100);
1752 assert_eq!(s, "ola mundo");
1753 assert!(!t);
1754 }
1755
1756 #[test]
1757 fn truncate_utf8_truncates_large_ascii() {
1758 let entrada: String = "a".repeat(200);
1759 let (s, t) = truncate_utf8(&entrada, 50);
1760 assert_eq!(s.chars().count(), 50);
1761 assert!(t);
1762 }
1763
1764 #[test]
1765 fn truncate_utf8_preserves_accented_graphemes() {
1766 let entrada: String = "á".repeat(30);
1768 let (s, t) = truncate_utf8(&entrada, 10);
1769 assert_eq!(s.chars().count(), 10);
1770 assert_eq!(s.len(), 20);
1772 assert!(t);
1773 assert!(s.chars().all(|c| c == 'á'));
1775 }
1776
1777 #[test]
1778 fn truncate_utf8_emojis_does_not_break() {
1779 let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
1780 let (s, t) = truncate_utf8(entrada, 5);
1781 assert_eq!(s.chars().count(), 5);
1782 assert!(t);
1783 }
1784
1785 #[test]
1786 fn truncate_utf8_zero_returns_empty() {
1787 let (s, t) = truncate_utf8("abc", 0);
1788 assert_eq!(s, "");
1789 assert!(t);
1790 }
1791
1792 #[test]
1793 fn execution_output_debug_does_not_crash() {
1794 let s = ExecutionOutput {
1795 stdout: "ok".into(),
1796 stderr: String::new(),
1797 exit_code: Some(0),
1798 truncated_stdout: false,
1799 truncated_stderr: false,
1800 duration_ms: 42,
1801 };
1802 let _ = format!("{s:?}");
1803 }
1804
1805 #[test]
1806 fn duration_ms_type_compatible() {
1807 let fake: u64 = 1234;
1809 assert_eq!(fake, 1234_u64);
1810 }
1811}