1use super::core::SshClient;
16use crate::security::Password;
17use crate::ssh::known_hosts::StrictHostKeyChecking;
18use crate::ssh::tokio_client::{Client, SshConnectionConfig, SshConnectionConfigResolver};
19use anyhow::{Context, Result};
20use std::path::Path;
21use std::sync::Arc;
22use std::time::Duration;
23
24const FILE_UPLOAD_TIMEOUT_SECS: u64 = 300;
29
30const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 300;
35
36const DIR_UPLOAD_TIMEOUT_SECS: u64 = 600;
42
43const DIR_DOWNLOAD_TIMEOUT_SECS: u64 = 600;
49
50const SSH_CONNECT_TIMEOUT_SECS: u64 = 30;
55
56impl SshClient {
57 #[allow(clippy::too_many_arguments)]
59 pub async fn upload_file(
60 &mut self,
61 local_path: &Path,
62 remote_path: &str,
63 key_path: Option<&Path>,
64 strict_mode: Option<StrictHostKeyChecking>,
65 use_agent: bool,
66 use_password: bool,
67 connect_timeout_seconds: Option<u64>,
68 ) -> Result<()> {
69 let client = self
70 .connect_for_file_transfer(
71 key_path,
72 strict_mode,
73 use_agent,
74 use_password,
75 "file copy",
76 connect_timeout_seconds,
77 None,
78 )
79 .await?;
80
81 tracing::debug!("Connected and authenticated successfully");
82
83 if !local_path.exists() {
85 anyhow::bail!("Local file does not exist: {local_path:?}");
86 }
87
88 let metadata = std::fs::metadata(local_path)
89 .with_context(|| format!("Failed to get metadata for {local_path:?}"))?;
90
91 let file_size = metadata.len();
92
93 tracing::debug!(
94 "Uploading file {:?} ({} bytes) to {}:{} using SFTP",
95 local_path,
96 file_size,
97 self.host,
98 remote_path
99 );
100
101 let upload_timeout = Duration::from_secs(FILE_UPLOAD_TIMEOUT_SECS);
103 let transfer: Result<()> = async {
104 tokio::time::timeout(
105 upload_timeout,
106 client.upload_file(local_path, remote_path.to_string()),
107 )
108 .await
109 .with_context(|| {
110 format!(
111 "File upload timeout: Transfer of {:?} to {}:{} did not complete within 5 minutes",
112 local_path, self.host, remote_path
113 )
114 })?
115 .with_context(|| {
116 format!(
117 "Failed to upload file {:?} to {}:{}",
118 local_path, self.host, remote_path
119 )
120 })?;
121 Ok(())
122 }
123 .await;
124 client.flush_hostkey_updates().await;
125 transfer?;
126
127 tracing::debug!("File upload completed successfully");
128
129 Ok(())
130 }
131
132 #[allow(clippy::too_many_arguments)]
134 pub async fn download_file(
135 &mut self,
136 remote_path: &str,
137 local_path: &Path,
138 key_path: Option<&Path>,
139 strict_mode: Option<StrictHostKeyChecking>,
140 use_agent: bool,
141 use_password: bool,
142 connect_timeout_seconds: Option<u64>,
143 ) -> Result<()> {
144 let client = self
145 .connect_for_file_transfer(
146 key_path,
147 strict_mode,
148 use_agent,
149 use_password,
150 "file download",
151 connect_timeout_seconds,
152 None,
153 )
154 .await?;
155
156 tracing::debug!("Connected and authenticated successfully");
157
158 if let Some(parent) = local_path.parent() {
160 tokio::fs::create_dir_all(parent)
161 .await
162 .with_context(|| format!("Failed to create parent directory for {local_path:?}"))?;
163 }
164
165 tracing::debug!(
166 "Downloading file from {}:{} to {:?} using SFTP",
167 self.host,
168 remote_path,
169 local_path
170 );
171
172 let download_timeout = Duration::from_secs(FILE_DOWNLOAD_TIMEOUT_SECS);
174 let transfer: Result<()> = async {
175 tokio::time::timeout(
176 download_timeout,
177 client.download_file(remote_path.to_string(), local_path),
178 )
179 .await
180 .with_context(|| {
181 format!(
182 "File download timeout: Transfer from {}:{} to {:?} did not complete within 5 minutes",
183 self.host, remote_path, local_path
184 )
185 })?
186 .with_context(|| {
187 format!(
188 "Failed to download file from {}:{} to {:?}",
189 self.host, remote_path, local_path
190 )
191 })?;
192 Ok(())
193 }
194 .await;
195 client.flush_hostkey_updates().await;
196 transfer?;
197
198 tracing::debug!("File download completed successfully");
199
200 Ok(())
201 }
202
203 #[allow(clippy::too_many_arguments)]
205 pub async fn upload_dir(
206 &mut self,
207 local_dir_path: &Path,
208 remote_dir_path: &str,
209 key_path: Option<&Path>,
210 strict_mode: Option<StrictHostKeyChecking>,
211 use_agent: bool,
212 use_password: bool,
213 connect_timeout_seconds: Option<u64>,
214 ) -> Result<()> {
215 let client = self
216 .connect_for_file_transfer(
217 key_path,
218 strict_mode,
219 use_agent,
220 use_password,
221 "directory upload",
222 connect_timeout_seconds,
223 None,
224 )
225 .await?;
226
227 tracing::debug!("Connected and authenticated successfully");
228
229 if !local_dir_path.exists() {
231 anyhow::bail!("Local directory does not exist: {local_dir_path:?}");
232 }
233
234 if !local_dir_path.is_dir() {
235 anyhow::bail!("Local path is not a directory: {local_dir_path:?}");
236 }
237
238 tracing::debug!(
239 "Uploading directory {:?} to {}:{} using SFTP",
240 local_dir_path,
241 self.host,
242 remote_dir_path
243 );
244
245 let upload_timeout = Duration::from_secs(DIR_UPLOAD_TIMEOUT_SECS);
247 let transfer: Result<()> = async {
248 tokio::time::timeout(
249 upload_timeout,
250 client.upload_dir(local_dir_path, remote_dir_path.to_string()),
251 )
252 .await
253 .with_context(|| {
254 format!(
255 "Directory upload timeout: Transfer of {:?} to {}:{} did not complete within 10 minutes",
256 local_dir_path, self.host, remote_dir_path
257 )
258 })?
259 .with_context(|| {
260 format!(
261 "Failed to upload directory {:?} to {}:{}",
262 local_dir_path, self.host, remote_dir_path
263 )
264 })?;
265 Ok(())
266 }
267 .await;
268 client.flush_hostkey_updates().await;
269 transfer?;
270
271 tracing::debug!("Directory upload completed successfully");
272
273 Ok(())
274 }
275
276 #[allow(clippy::too_many_arguments)]
278 pub async fn download_dir(
279 &mut self,
280 remote_dir_path: &str,
281 local_dir_path: &Path,
282 key_path: Option<&Path>,
283 strict_mode: Option<StrictHostKeyChecking>,
284 use_agent: bool,
285 use_password: bool,
286 connect_timeout_seconds: Option<u64>,
287 ) -> Result<()> {
288 let client = self
289 .connect_for_file_transfer(
290 key_path,
291 strict_mode,
292 use_agent,
293 use_password,
294 "directory download",
295 connect_timeout_seconds,
296 None,
297 )
298 .await?;
299
300 tracing::debug!("Connected and authenticated successfully");
301
302 if let Some(parent) = local_dir_path.parent() {
304 tokio::fs::create_dir_all(parent).await.with_context(|| {
305 format!("Failed to create parent directory for {local_dir_path:?}")
306 })?;
307 }
308
309 tracing::debug!(
310 "Downloading directory from {}:{} to {:?} using SFTP",
311 self.host,
312 remote_dir_path,
313 local_dir_path
314 );
315
316 let download_timeout = Duration::from_secs(DIR_DOWNLOAD_TIMEOUT_SECS);
318 let transfer: Result<()> = async {
319 tokio::time::timeout(
320 download_timeout,
321 client.download_dir(remote_dir_path.to_string(), local_dir_path),
322 )
323 .await
324 .with_context(|| {
325 format!(
326 "Directory download timeout: Transfer from {}:{} to {:?} did not complete within 10 minutes",
327 self.host, remote_dir_path, local_dir_path
328 )
329 })?
330 .with_context(|| {
331 format!(
332 "Failed to download directory from {}:{} to {:?}",
333 self.host, remote_dir_path, local_dir_path
334 )
335 })?;
336 Ok(())
337 }
338 .await;
339 client.flush_hostkey_updates().await;
340 transfer?;
341
342 tracing::debug!("Directory download completed successfully");
343
344 Ok(())
345 }
346
347 #[allow(clippy::too_many_arguments)]
349 pub async fn upload_file_with_jump_hosts(
350 &mut self,
351 local_path: &Path,
352 remote_path: &str,
353 key_path: Option<&Path>,
354 strict_mode: Option<StrictHostKeyChecking>,
355 use_agent: bool,
356 use_password: bool,
357 jump_hosts_spec: Option<&str>,
358 connect_timeout_seconds: Option<u64>,
359 pre_collected_password: Option<Arc<Password>>,
360 ssh_connection_config: &SshConnectionConfig,
361 ssh_connection_config_resolver: Option<&SshConnectionConfigResolver>,
362 ) -> Result<()> {
363 tracing::debug!(
364 "Uploading file to {}:{} (jump hosts: {:?})",
365 self.host,
366 self.port,
367 jump_hosts_spec
368 );
369
370 let client = self
371 .connect_for_transfer_with_jump_hosts(
372 key_path,
373 strict_mode,
374 use_agent,
375 use_password,
376 jump_hosts_spec,
377 connect_timeout_seconds,
378 pre_collected_password,
379 ssh_connection_config,
380 ssh_connection_config_resolver,
381 )
382 .await?;
383
384 tracing::debug!("Connected and authenticated successfully");
385
386 if !local_path.exists() {
388 anyhow::bail!("Local file does not exist: {local_path:?}");
389 }
390
391 let metadata = std::fs::metadata(local_path)
392 .with_context(|| format!("Failed to get metadata for {local_path:?}"))?;
393
394 let file_size = metadata.len();
395
396 tracing::debug!(
397 "Uploading file {:?} ({} bytes) to {}:{} using SFTP",
398 local_path,
399 file_size,
400 self.host,
401 remote_path
402 );
403
404 let upload_timeout = Duration::from_secs(FILE_UPLOAD_TIMEOUT_SECS);
406 let transfer: Result<()> = async {
407 tokio::time::timeout(
408 upload_timeout,
409 client.upload_file(local_path, remote_path.to_string()),
410 )
411 .await
412 .with_context(|| {
413 format!(
414 "File upload timeout: Transfer of {:?} to {}:{} did not complete within 5 minutes",
415 local_path, self.host, remote_path
416 )
417 })?
418 .with_context(|| {
419 format!(
420 "Failed to upload file {:?} to {}:{}",
421 local_path, self.host, remote_path
422 )
423 })?;
424 Ok(())
425 }
426 .await;
427 client.flush_hostkey_updates().await;
428 transfer?;
429
430 tracing::debug!("File upload completed successfully");
431
432 Ok(())
433 }
434
435 #[allow(clippy::too_many_arguments)]
437 pub async fn download_file_with_jump_hosts(
438 &mut self,
439 remote_path: &str,
440 local_path: &Path,
441 key_path: Option<&Path>,
442 strict_mode: Option<StrictHostKeyChecking>,
443 use_agent: bool,
444 use_password: bool,
445 jump_hosts_spec: Option<&str>,
446 connect_timeout_seconds: Option<u64>,
447 pre_collected_password: Option<Arc<Password>>,
448 ssh_connection_config: &SshConnectionConfig,
449 ssh_connection_config_resolver: Option<&SshConnectionConfigResolver>,
450 ) -> Result<()> {
451 tracing::debug!(
452 "Downloading file from {}:{} (jump hosts: {:?})",
453 self.host,
454 self.port,
455 jump_hosts_spec
456 );
457
458 let client = self
459 .connect_for_transfer_with_jump_hosts(
460 key_path,
461 strict_mode,
462 use_agent,
463 use_password,
464 jump_hosts_spec,
465 connect_timeout_seconds,
466 pre_collected_password,
467 ssh_connection_config,
468 ssh_connection_config_resolver,
469 )
470 .await?;
471
472 tracing::debug!("Connected and authenticated successfully");
473
474 if let Some(parent) = local_path.parent() {
476 tokio::fs::create_dir_all(parent)
477 .await
478 .with_context(|| format!("Failed to create parent directory for {local_path:?}"))?;
479 }
480
481 tracing::debug!(
482 "Downloading file from {}:{} to {:?} using SFTP",
483 self.host,
484 remote_path,
485 local_path
486 );
487
488 let download_timeout = Duration::from_secs(FILE_DOWNLOAD_TIMEOUT_SECS);
490 let transfer: Result<()> = async {
491 tokio::time::timeout(
492 download_timeout,
493 client.download_file(remote_path.to_string(), local_path),
494 )
495 .await
496 .with_context(|| {
497 format!(
498 "File download timeout: Transfer from {}:{} to {:?} did not complete within 5 minutes",
499 self.host, remote_path, local_path
500 )
501 })?
502 .with_context(|| {
503 format!(
504 "Failed to download file from {}:{} to {:?}",
505 self.host, remote_path, local_path
506 )
507 })?;
508 Ok(())
509 }
510 .await;
511 client.flush_hostkey_updates().await;
512 transfer?;
513
514 tracing::debug!("File download completed successfully");
515
516 Ok(())
517 }
518
519 #[allow(clippy::too_many_arguments)]
521 pub async fn upload_dir_with_jump_hosts(
522 &mut self,
523 local_dir_path: &Path,
524 remote_dir_path: &str,
525 key_path: Option<&Path>,
526 strict_mode: Option<StrictHostKeyChecking>,
527 use_agent: bool,
528 use_password: bool,
529 jump_hosts_spec: Option<&str>,
530 connect_timeout_seconds: Option<u64>,
531 pre_collected_password: Option<Arc<Password>>,
532 ssh_connection_config: &SshConnectionConfig,
533 ssh_connection_config_resolver: Option<&SshConnectionConfigResolver>,
534 ) -> Result<()> {
535 tracing::debug!(
536 "Uploading directory to {}:{} (jump hosts: {:?})",
537 self.host,
538 self.port,
539 jump_hosts_spec
540 );
541
542 let client = self
543 .connect_for_transfer_with_jump_hosts(
544 key_path,
545 strict_mode,
546 use_agent,
547 use_password,
548 jump_hosts_spec,
549 connect_timeout_seconds,
550 pre_collected_password,
551 ssh_connection_config,
552 ssh_connection_config_resolver,
553 )
554 .await?;
555
556 tracing::debug!("Connected and authenticated successfully");
557
558 if !local_dir_path.exists() {
560 anyhow::bail!("Local directory does not exist: {local_dir_path:?}");
561 }
562
563 if !local_dir_path.is_dir() {
564 anyhow::bail!("Local path is not a directory: {local_dir_path:?}");
565 }
566
567 tracing::debug!(
568 "Uploading directory {:?} to {}:{} using SFTP",
569 local_dir_path,
570 self.host,
571 remote_dir_path
572 );
573
574 let upload_timeout = Duration::from_secs(DIR_UPLOAD_TIMEOUT_SECS);
576 let transfer: Result<()> = async {
577 tokio::time::timeout(
578 upload_timeout,
579 client.upload_dir(local_dir_path, remote_dir_path.to_string()),
580 )
581 .await
582 .with_context(|| {
583 format!(
584 "Directory upload timeout: Transfer of {:?} to {}:{} did not complete within 10 minutes",
585 local_dir_path, self.host, remote_dir_path
586 )
587 })?
588 .with_context(|| {
589 format!(
590 "Failed to upload directory {:?} to {}:{}",
591 local_dir_path, self.host, remote_dir_path
592 )
593 })?;
594 Ok(())
595 }
596 .await;
597 client.flush_hostkey_updates().await;
598 transfer?;
599
600 tracing::debug!("Directory upload completed successfully");
601
602 Ok(())
603 }
604
605 #[allow(clippy::too_many_arguments)]
607 pub async fn download_dir_with_jump_hosts(
608 &mut self,
609 remote_dir_path: &str,
610 local_dir_path: &Path,
611 key_path: Option<&Path>,
612 strict_mode: Option<StrictHostKeyChecking>,
613 use_agent: bool,
614 use_password: bool,
615 jump_hosts_spec: Option<&str>,
616 connect_timeout_seconds: Option<u64>,
617 pre_collected_password: Option<Arc<Password>>,
618 ssh_connection_config: &SshConnectionConfig,
619 ssh_connection_config_resolver: Option<&SshConnectionConfigResolver>,
620 ) -> Result<()> {
621 tracing::debug!(
622 "Downloading directory from {}:{} (jump hosts: {:?})",
623 self.host,
624 self.port,
625 jump_hosts_spec
626 );
627
628 let client = self
629 .connect_for_transfer_with_jump_hosts(
630 key_path,
631 strict_mode,
632 use_agent,
633 use_password,
634 jump_hosts_spec,
635 connect_timeout_seconds,
636 pre_collected_password,
637 ssh_connection_config,
638 ssh_connection_config_resolver,
639 )
640 .await?;
641
642 tracing::debug!("Connected and authenticated successfully");
643
644 if let Some(parent) = local_dir_path.parent() {
646 tokio::fs::create_dir_all(parent).await.with_context(|| {
647 format!("Failed to create parent directory for {local_dir_path:?}")
648 })?;
649 }
650
651 tracing::debug!(
652 "Downloading directory from {}:{} to {:?} using SFTP",
653 self.host,
654 remote_dir_path,
655 local_dir_path
656 );
657
658 let download_timeout = Duration::from_secs(DIR_DOWNLOAD_TIMEOUT_SECS);
660 let transfer: Result<()> = async {
661 tokio::time::timeout(
662 download_timeout,
663 client.download_dir(remote_dir_path.to_string(), local_dir_path),
664 )
665 .await
666 .with_context(|| {
667 format!(
668 "Directory download timeout: Transfer from {}:{} to {:?} did not complete within 10 minutes",
669 self.host, remote_dir_path, local_dir_path
670 )
671 })?
672 .with_context(|| {
673 format!(
674 "Failed to download directory from {}:{} to {:?}",
675 self.host, remote_dir_path, local_dir_path
676 )
677 })?;
678 Ok(())
679 }
680 .await;
681 client.flush_hostkey_updates().await;
682 transfer?;
683
684 tracing::debug!("Directory download completed successfully");
685
686 Ok(())
687 }
688
689 #[allow(clippy::too_many_arguments)]
691 async fn connect_for_file_transfer(
692 &self,
693 key_path: Option<&Path>,
694 strict_mode: Option<StrictHostKeyChecking>,
695 use_agent: bool,
696 use_password: bool,
697 operation_desc: &str,
698 connect_timeout_seconds: Option<u64>,
699 pre_collected_password: Option<Arc<Password>>,
700 ) -> Result<Client> {
701 let addr = (self.host.as_str(), self.port);
702 tracing::debug!(
703 "Connecting to {}:{} for {}",
704 self.host,
705 self.port,
706 operation_desc
707 );
708
709 let auth_method = self
712 .determine_auth_method(
713 key_path,
714 use_agent,
715 use_password,
716 #[cfg(target_os = "macos")]
717 false,
718 pre_collected_password,
719 None,
720 )
721 .await?;
722
723 let check_method = if let Some(mode) = strict_mode {
725 crate::ssh::known_hosts::get_check_method(mode)
726 } else {
727 crate::ssh::known_hosts::get_check_method(StrictHostKeyChecking::AcceptNew)
728 };
729
730 let timeout_secs = connect_timeout_seconds.unwrap_or(SSH_CONNECT_TIMEOUT_SECS);
732 let connect_timeout = Duration::from_secs(timeout_secs);
733 match tokio::time::timeout(
734 connect_timeout,
735 Client::connect(addr, &self.username, auth_method, check_method),
736 )
737 .await
738 {
739 Ok(Ok(client)) => Ok(client),
740 Ok(Err(e)) => {
741 let context = format!("SSH connection to {}:{}", self.host, self.port);
742 let detailed = format_ssh_error(&context, &e);
743 Err(anyhow::Error::new(e).context(detailed))
744 }
745 Err(_) => Err(anyhow::Error::new(
746 crate::ssh::tokio_client::Error::ConnectionTimeout {
747 host: self.host.clone(),
748 port: self.port,
749 seconds: timeout_secs,
750 stage: "connection setup or authentication",
751 },
752 )),
753 }
754 }
755
756 #[allow(clippy::too_many_arguments)]
758 async fn connect_for_transfer_with_jump_hosts(
759 &self,
760 key_path: Option<&Path>,
761 strict_mode: Option<StrictHostKeyChecking>,
762 use_agent: bool,
763 use_password: bool,
764 jump_hosts_spec: Option<&str>,
765 connect_timeout_seconds: Option<u64>,
766 pre_collected_password: Option<Arc<Password>>,
767 ssh_connection_config: &SshConnectionConfig,
768 ssh_connection_config_resolver: Option<&SshConnectionConfigResolver>,
769 ) -> Result<Client> {
770 let auth_method = self
773 .determine_auth_method(
774 key_path,
775 use_agent,
776 use_password,
777 #[cfg(target_os = "macos")]
778 false,
779 pre_collected_password.clone(),
780 Some(ssh_connection_config),
781 )
782 .await?;
783
784 let strict_mode = strict_mode.unwrap_or(StrictHostKeyChecking::AcceptNew);
785
786 self.establish_connection(
791 &auth_method,
792 strict_mode,
793 jump_hosts_spec,
794 key_path,
795 use_agent,
796 use_password,
797 connect_timeout_seconds,
798 Some(ssh_connection_config),
799 ssh_connection_config_resolver,
800 pre_collected_password,
801 crate::ssh::SessionPurpose::Bulk,
802 )
803 .await
804 }
805}
806
807fn format_ssh_error(context: &str, e: &crate::ssh::tokio_client::Error) -> String {
809 match e {
810 crate::ssh::tokio_client::Error::KeyAuthFailed => {
811 format!("{context} failed: Authentication rejected with provided SSH key")
812 }
813 crate::ssh::tokio_client::Error::KeyInvalid(_) => {
817 format!("{context} failed: Invalid SSH key")
818 }
819 crate::ssh::tokio_client::Error::ServerCheckFailed => {
820 format!(
821 "{context} failed: Host key verification failed, the server's host key is not trusted"
822 )
823 }
824 crate::ssh::tokio_client::Error::HostKeyChanged { host, port, .. } => {
825 let entry =
831 crate::ssh::tokio_client::host_verification::known_hosts_entry_name(host, *port);
832 format!(
833 "{context} failed: Possible man-in-the-middle attack, remove the old known_hosts entry with 'ssh-keygen -R \"{entry}\"' only if the key change is expected"
834 )
835 }
836 crate::ssh::tokio_client::Error::HostKeyRevoked { .. } => {
840 format!(
841 "{context} failed: The offered host key is explicitly revoked in known_hosts, refusing to connect"
842 )
843 }
844 crate::ssh::tokio_client::Error::PasswordWrong => {
845 format!("{context} failed: Password authentication rejected")
846 }
847 crate::ssh::tokio_client::Error::AgentConnectionFailed => {
848 format!("{context} failed: Cannot connect to SSH agent, ensure SSH_AUTH_SOCK is set")
849 }
850 crate::ssh::tokio_client::Error::AgentNoIdentities => {
851 format!("{context} failed: SSH agent has no keys, use 'ssh-add' to add your key")
852 }
853 crate::ssh::tokio_client::Error::AgentAuthenticationFailed => {
854 format!("{context} failed: SSH agent authentication rejected")
855 }
856 _ => format!("{context} failed"),
857 }
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863
864 #[test]
865 fn test_connect_for_file_transfer_error_ordering_puts_friendly_message_first() {
866 let e = crate::ssh::tokio_client::Error::PasswordWrong;
870 let context = "SSH connection to host:22".to_string();
871 let detailed = format_ssh_error(&context, &e);
872 let err = anyhow::Error::new(e).context(detailed.clone());
873
874 let rendered = format!("{err:#}");
875 assert!(
876 rendered.starts_with(&detailed),
877 "expected friendly message first, got: {rendered}"
878 );
879 assert!(
880 rendered[detailed.len()..].contains("Permission denied (password)."),
881 "expected the cause to appear after the friendly message, got: {rendered}"
882 );
883 }
884
885 #[test]
886 fn test_format_ssh_error_does_not_duplicate_inner_key_error() {
887 let key_err = russh::keys::Error::KeyIsCorrupt;
890 let inner_text = key_err.to_string();
891 let e = crate::ssh::tokio_client::Error::KeyInvalid(key_err);
892 let context = "SSH connection to host:22".to_string();
893
894 let detailed = format_ssh_error(&context, &e);
895 assert!(
896 !detailed.contains(&inner_text),
897 "context must not echo the inner key error, got: {detailed}"
898 );
899
900 let rendered = format!("{:#}", anyhow::Error::new(e).context(detailed));
901 assert_eq!(
902 rendered.matches(inner_text.as_str()).count(),
903 1,
904 "inner key error should appear exactly once, got: {rendered}"
905 );
906 }
907
908 #[test]
909 fn test_format_ssh_error_messages_have_no_trailing_period() {
910 let context = "SSH connection to host:22".to_string();
913 for e in [
914 crate::ssh::tokio_client::Error::KeyAuthFailed,
915 crate::ssh::tokio_client::Error::ServerCheckFailed,
916 crate::ssh::tokio_client::Error::PasswordWrong,
917 crate::ssh::tokio_client::Error::AgentConnectionFailed,
918 crate::ssh::tokio_client::Error::AgentNoIdentities,
919 crate::ssh::tokio_client::Error::AgentAuthenticationFailed,
920 crate::ssh::tokio_client::Error::HostKeyChanged {
921 host: "node1.example.com".to_string(),
922 port: 22,
923 line: 3,
924 },
925 crate::ssh::tokio_client::Error::HostKeyChanged {
926 host: "node1.example.com".to_string(),
927 port: 2222,
928 line: 3,
929 },
930 crate::ssh::tokio_client::Error::HostKeyRevoked {
931 host: "node1.example.com".to_string(),
932 port: 22,
933 line: 3,
934 },
935 ] {
936 let detailed = format_ssh_error(&context, &e);
937 assert!(
938 !detailed.ends_with('.'),
939 "context message must not end with a period, got: {detailed}"
940 );
941 }
942 }
943
944 #[test]
945 fn test_format_ssh_error_host_key_changed_adds_guidance_without_echo() {
946 let e = crate::ssh::tokio_client::Error::HostKeyChanged {
950 host: "node1.example.com".to_string(),
951 port: 22,
952 line: 7,
953 };
954 let cause_text = e.to_string();
955 let context = "SFTP upload to host:22".to_string();
956
957 let detailed = format_ssh_error(&context, &e);
958 assert!(
959 detailed.contains("ssh-keygen -R \"node1.example.com\""),
960 "guidance must include the removal command, got: {detailed}"
961 );
962 assert!(
963 !detailed.contains("has changed and no longer matches"),
964 "context must not restate the cause, got: {detailed}"
965 );
966
967 let rendered = format!("{:#}", anyhow::Error::new(e).context(detailed));
968 assert_eq!(
969 rendered.matches(cause_text.as_str()).count(),
970 1,
971 "cause text should appear exactly once, got: {rendered}"
972 );
973 }
974
975 #[test]
976 fn test_format_ssh_error_host_key_changed_names_port_qualified_entry() {
977 let e = crate::ssh::tokio_client::Error::HostKeyChanged {
982 host: "node1.example.com".to_string(),
983 port: 2222,
984 line: 7,
985 };
986 let context = "SFTP upload to host:2222".to_string();
987
988 let detailed = format_ssh_error(&context, &e);
989 assert!(
990 detailed.contains("ssh-keygen -R \"[node1.example.com]:2222\""),
991 "guidance must name the port-qualified entry, got: {detailed}"
992 );
993 assert!(
994 !detailed.contains("has changed and no longer matches"),
995 "context must not restate the cause, got: {detailed}"
996 );
997 }
998
999 #[test]
1000 fn test_format_ssh_error_host_key_revoked_adds_guidance_without_echo() {
1001 let e = crate::ssh::tokio_client::Error::HostKeyRevoked {
1005 host: "node1.example.com".to_string(),
1006 port: 22,
1007 line: 7,
1008 };
1009 let cause_text = e.to_string();
1010 let context = "SFTP upload to host:22".to_string();
1011
1012 let detailed = format_ssh_error(&context, &e);
1013 assert!(
1014 detailed.contains("explicitly revoked"),
1015 "guidance must warn about revocation, got: {detailed}"
1016 );
1017 assert!(
1018 !detailed.contains("is explicitly revoked by the known_hosts entry at line"),
1019 "context must not restate the cause, got: {detailed}"
1020 );
1021
1022 let rendered = format!("{:#}", anyhow::Error::new(e).context(detailed));
1023 assert_eq!(
1024 rendered.matches(cause_text.as_str()).count(),
1025 1,
1026 "cause text should appear exactly once, got: {rendered}"
1027 );
1028 }
1029
1030 #[test]
1031 fn test_format_ssh_error_catch_all_does_not_duplicate_cause() {
1032 let e = crate::ssh::tokio_client::Error::CommandDidntExit;
1036 let context = "SSH connection to host:22".to_string();
1037 let detailed = format_ssh_error(&context, &e);
1038 assert_eq!(detailed, "SSH connection to host:22 failed");
1039
1040 let cause_text = e.to_string();
1041 let err = anyhow::Error::new(e).context(detailed);
1042 let rendered = format!("{err:#}");
1043 assert_eq!(
1044 rendered.matches(cause_text.as_str()).count(),
1045 1,
1046 "cause text should appear exactly once, got: {rendered}"
1047 );
1048 }
1049}