1use super::core::SshClient;
16use crate::ssh::known_hosts::StrictHostKeyChecking;
17use crate::ssh::tokio_client::Client;
18use anyhow::{Context, Result};
19use std::path::Path;
20use std::time::Duration;
21
22const FILE_UPLOAD_TIMEOUT_SECS: u64 = 300;
27
28const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 300;
33
34const DIR_UPLOAD_TIMEOUT_SECS: u64 = 600;
40
41const DIR_DOWNLOAD_TIMEOUT_SECS: u64 = 600;
47
48const SSH_CONNECT_TIMEOUT_SECS: u64 = 30;
53
54impl SshClient {
55 #[allow(clippy::too_many_arguments)]
57 pub async fn upload_file(
58 &mut self,
59 local_path: &Path,
60 remote_path: &str,
61 key_path: Option<&Path>,
62 strict_mode: Option<StrictHostKeyChecking>,
63 use_agent: bool,
64 use_password: bool,
65 connect_timeout_seconds: Option<u64>,
66 ) -> Result<()> {
67 let client = self
68 .connect_for_file_transfer(
69 key_path,
70 strict_mode,
71 use_agent,
72 use_password,
73 "file copy",
74 connect_timeout_seconds,
75 )
76 .await?;
77
78 tracing::debug!("Connected and authenticated successfully");
79
80 if !local_path.exists() {
82 anyhow::bail!("Local file does not exist: {local_path:?}");
83 }
84
85 let metadata = std::fs::metadata(local_path)
86 .with_context(|| format!("Failed to get metadata for {local_path:?}"))?;
87
88 let file_size = metadata.len();
89
90 tracing::debug!(
91 "Uploading file {:?} ({} bytes) to {}:{} using SFTP",
92 local_path,
93 file_size,
94 self.host,
95 remote_path
96 );
97
98 let upload_timeout = Duration::from_secs(FILE_UPLOAD_TIMEOUT_SECS);
100 tokio::time::timeout(
101 upload_timeout,
102 client.upload_file(local_path, remote_path.to_string()),
103 )
104 .await
105 .with_context(|| {
106 format!(
107 "File upload timeout: Transfer of {:?} to {}:{} did not complete within 5 minutes",
108 local_path, self.host, remote_path
109 )
110 })?
111 .with_context(|| {
112 format!(
113 "Failed to upload file {:?} to {}:{}",
114 local_path, self.host, remote_path
115 )
116 })?;
117
118 tracing::debug!("File upload completed successfully");
119
120 Ok(())
121 }
122
123 #[allow(clippy::too_many_arguments)]
125 pub async fn download_file(
126 &mut self,
127 remote_path: &str,
128 local_path: &Path,
129 key_path: Option<&Path>,
130 strict_mode: Option<StrictHostKeyChecking>,
131 use_agent: bool,
132 use_password: bool,
133 connect_timeout_seconds: Option<u64>,
134 ) -> Result<()> {
135 let client = self
136 .connect_for_file_transfer(
137 key_path,
138 strict_mode,
139 use_agent,
140 use_password,
141 "file download",
142 connect_timeout_seconds,
143 )
144 .await?;
145
146 tracing::debug!("Connected and authenticated successfully");
147
148 if let Some(parent) = local_path.parent() {
150 tokio::fs::create_dir_all(parent)
151 .await
152 .with_context(|| format!("Failed to create parent directory for {local_path:?}"))?;
153 }
154
155 tracing::debug!(
156 "Downloading file from {}:{} to {:?} using SFTP",
157 self.host,
158 remote_path,
159 local_path
160 );
161
162 let download_timeout = Duration::from_secs(FILE_DOWNLOAD_TIMEOUT_SECS);
164 tokio::time::timeout(
165 download_timeout,
166 client.download_file(remote_path.to_string(), local_path),
167 )
168 .await
169 .with_context(|| {
170 format!(
171 "File download timeout: Transfer from {}:{} to {:?} did not complete within 5 minutes",
172 self.host, remote_path, local_path
173 )
174 })?
175 .with_context(|| {
176 format!(
177 "Failed to download file from {}:{} to {:?}",
178 self.host, remote_path, local_path
179 )
180 })?;
181
182 tracing::debug!("File download completed successfully");
183
184 Ok(())
185 }
186
187 #[allow(clippy::too_many_arguments)]
189 pub async fn upload_dir(
190 &mut self,
191 local_dir_path: &Path,
192 remote_dir_path: &str,
193 key_path: Option<&Path>,
194 strict_mode: Option<StrictHostKeyChecking>,
195 use_agent: bool,
196 use_password: bool,
197 connect_timeout_seconds: Option<u64>,
198 ) -> Result<()> {
199 let client = self
200 .connect_for_file_transfer(
201 key_path,
202 strict_mode,
203 use_agent,
204 use_password,
205 "directory upload",
206 connect_timeout_seconds,
207 )
208 .await?;
209
210 tracing::debug!("Connected and authenticated successfully");
211
212 if !local_dir_path.exists() {
214 anyhow::bail!("Local directory does not exist: {local_dir_path:?}");
215 }
216
217 if !local_dir_path.is_dir() {
218 anyhow::bail!("Local path is not a directory: {local_dir_path:?}");
219 }
220
221 tracing::debug!(
222 "Uploading directory {:?} to {}:{} using SFTP",
223 local_dir_path,
224 self.host,
225 remote_dir_path
226 );
227
228 let upload_timeout = Duration::from_secs(DIR_UPLOAD_TIMEOUT_SECS);
230 tokio::time::timeout(
231 upload_timeout,
232 client.upload_dir(local_dir_path, remote_dir_path.to_string()),
233 )
234 .await
235 .with_context(|| {
236 format!(
237 "Directory upload timeout: Transfer of {:?} to {}:{} did not complete within 10 minutes",
238 local_dir_path, self.host, remote_dir_path
239 )
240 })?
241 .with_context(|| {
242 format!(
243 "Failed to upload directory {:?} to {}:{}",
244 local_dir_path, self.host, remote_dir_path
245 )
246 })?;
247
248 tracing::debug!("Directory upload completed successfully");
249
250 Ok(())
251 }
252
253 #[allow(clippy::too_many_arguments)]
255 pub async fn download_dir(
256 &mut self,
257 remote_dir_path: &str,
258 local_dir_path: &Path,
259 key_path: Option<&Path>,
260 strict_mode: Option<StrictHostKeyChecking>,
261 use_agent: bool,
262 use_password: bool,
263 connect_timeout_seconds: Option<u64>,
264 ) -> Result<()> {
265 let client = self
266 .connect_for_file_transfer(
267 key_path,
268 strict_mode,
269 use_agent,
270 use_password,
271 "directory download",
272 connect_timeout_seconds,
273 )
274 .await?;
275
276 tracing::debug!("Connected and authenticated successfully");
277
278 if let Some(parent) = local_dir_path.parent() {
280 tokio::fs::create_dir_all(parent).await.with_context(|| {
281 format!("Failed to create parent directory for {local_dir_path:?}")
282 })?;
283 }
284
285 tracing::debug!(
286 "Downloading directory from {}:{} to {:?} using SFTP",
287 self.host,
288 remote_dir_path,
289 local_dir_path
290 );
291
292 let download_timeout = Duration::from_secs(DIR_DOWNLOAD_TIMEOUT_SECS);
294 tokio::time::timeout(
295 download_timeout,
296 client.download_dir(remote_dir_path.to_string(), local_dir_path),
297 )
298 .await
299 .with_context(|| {
300 format!(
301 "Directory download timeout: Transfer from {}:{} to {:?} did not complete within 10 minutes",
302 self.host, remote_dir_path, local_dir_path
303 )
304 })?
305 .with_context(|| {
306 format!(
307 "Failed to download directory from {}:{} to {:?}",
308 self.host, remote_dir_path, local_dir_path
309 )
310 })?;
311
312 tracing::debug!("Directory download completed successfully");
313
314 Ok(())
315 }
316
317 #[allow(clippy::too_many_arguments)]
319 pub async fn upload_file_with_jump_hosts(
320 &mut self,
321 local_path: &Path,
322 remote_path: &str,
323 key_path: Option<&Path>,
324 strict_mode: Option<StrictHostKeyChecking>,
325 use_agent: bool,
326 use_password: bool,
327 jump_hosts_spec: Option<&str>,
328 connect_timeout_seconds: Option<u64>,
329 ) -> Result<()> {
330 tracing::debug!(
331 "Uploading file to {}:{} (jump hosts: {:?})",
332 self.host,
333 self.port,
334 jump_hosts_spec
335 );
336
337 let client = self
338 .connect_for_transfer_with_jump_hosts(
339 key_path,
340 strict_mode,
341 use_agent,
342 use_password,
343 jump_hosts_spec,
344 connect_timeout_seconds,
345 )
346 .await?;
347
348 tracing::debug!("Connected and authenticated successfully");
349
350 if !local_path.exists() {
352 anyhow::bail!("Local file does not exist: {local_path:?}");
353 }
354
355 let metadata = std::fs::metadata(local_path)
356 .with_context(|| format!("Failed to get metadata for {local_path:?}"))?;
357
358 let file_size = metadata.len();
359
360 tracing::debug!(
361 "Uploading file {:?} ({} bytes) to {}:{} using SFTP",
362 local_path,
363 file_size,
364 self.host,
365 remote_path
366 );
367
368 let upload_timeout = Duration::from_secs(FILE_UPLOAD_TIMEOUT_SECS);
370 tokio::time::timeout(
371 upload_timeout,
372 client.upload_file(local_path, remote_path.to_string()),
373 )
374 .await
375 .with_context(|| {
376 format!(
377 "File upload timeout: Transfer of {:?} to {}:{} did not complete within 5 minutes",
378 local_path, self.host, remote_path
379 )
380 })?
381 .with_context(|| {
382 format!(
383 "Failed to upload file {:?} to {}:{}",
384 local_path, self.host, remote_path
385 )
386 })?;
387
388 tracing::debug!("File upload completed successfully");
389
390 Ok(())
391 }
392
393 #[allow(clippy::too_many_arguments)]
395 pub async fn download_file_with_jump_hosts(
396 &mut self,
397 remote_path: &str,
398 local_path: &Path,
399 key_path: Option<&Path>,
400 strict_mode: Option<StrictHostKeyChecking>,
401 use_agent: bool,
402 use_password: bool,
403 jump_hosts_spec: Option<&str>,
404 connect_timeout_seconds: Option<u64>,
405 ) -> Result<()> {
406 tracing::debug!(
407 "Downloading file from {}:{} (jump hosts: {:?})",
408 self.host,
409 self.port,
410 jump_hosts_spec
411 );
412
413 let client = self
414 .connect_for_transfer_with_jump_hosts(
415 key_path,
416 strict_mode,
417 use_agent,
418 use_password,
419 jump_hosts_spec,
420 connect_timeout_seconds,
421 )
422 .await?;
423
424 tracing::debug!("Connected and authenticated successfully");
425
426 if let Some(parent) = local_path.parent() {
428 tokio::fs::create_dir_all(parent)
429 .await
430 .with_context(|| format!("Failed to create parent directory for {local_path:?}"))?;
431 }
432
433 tracing::debug!(
434 "Downloading file from {}:{} to {:?} using SFTP",
435 self.host,
436 remote_path,
437 local_path
438 );
439
440 let download_timeout = Duration::from_secs(FILE_DOWNLOAD_TIMEOUT_SECS);
442 tokio::time::timeout(
443 download_timeout,
444 client.download_file(remote_path.to_string(), local_path),
445 )
446 .await
447 .with_context(|| {
448 format!(
449 "File download timeout: Transfer from {}:{} to {:?} did not complete within 5 minutes",
450 self.host, remote_path, local_path
451 )
452 })?
453 .with_context(|| {
454 format!(
455 "Failed to download file from {}:{} to {:?}",
456 self.host, remote_path, local_path
457 )
458 })?;
459
460 tracing::debug!("File download completed successfully");
461
462 Ok(())
463 }
464
465 #[allow(clippy::too_many_arguments)]
467 pub async fn upload_dir_with_jump_hosts(
468 &mut self,
469 local_dir_path: &Path,
470 remote_dir_path: &str,
471 key_path: Option<&Path>,
472 strict_mode: Option<StrictHostKeyChecking>,
473 use_agent: bool,
474 use_password: bool,
475 jump_hosts_spec: Option<&str>,
476 connect_timeout_seconds: Option<u64>,
477 ) -> Result<()> {
478 tracing::debug!(
479 "Uploading directory to {}:{} (jump hosts: {:?})",
480 self.host,
481 self.port,
482 jump_hosts_spec
483 );
484
485 let client = self
486 .connect_for_transfer_with_jump_hosts(
487 key_path,
488 strict_mode,
489 use_agent,
490 use_password,
491 jump_hosts_spec,
492 connect_timeout_seconds,
493 )
494 .await?;
495
496 tracing::debug!("Connected and authenticated successfully");
497
498 if !local_dir_path.exists() {
500 anyhow::bail!("Local directory does not exist: {local_dir_path:?}");
501 }
502
503 if !local_dir_path.is_dir() {
504 anyhow::bail!("Local path is not a directory: {local_dir_path:?}");
505 }
506
507 tracing::debug!(
508 "Uploading directory {:?} to {}:{} using SFTP",
509 local_dir_path,
510 self.host,
511 remote_dir_path
512 );
513
514 let upload_timeout = Duration::from_secs(DIR_UPLOAD_TIMEOUT_SECS);
516 tokio::time::timeout(
517 upload_timeout,
518 client.upload_dir(local_dir_path, remote_dir_path.to_string()),
519 )
520 .await
521 .with_context(|| {
522 format!(
523 "Directory upload timeout: Transfer of {:?} to {}:{} did not complete within 10 minutes",
524 local_dir_path, self.host, remote_dir_path
525 )
526 })?
527 .with_context(|| {
528 format!(
529 "Failed to upload directory {:?} to {}:{}",
530 local_dir_path, self.host, remote_dir_path
531 )
532 })?;
533
534 tracing::debug!("Directory upload completed successfully");
535
536 Ok(())
537 }
538
539 #[allow(clippy::too_many_arguments)]
541 pub async fn download_dir_with_jump_hosts(
542 &mut self,
543 remote_dir_path: &str,
544 local_dir_path: &Path,
545 key_path: Option<&Path>,
546 strict_mode: Option<StrictHostKeyChecking>,
547 use_agent: bool,
548 use_password: bool,
549 jump_hosts_spec: Option<&str>,
550 connect_timeout_seconds: Option<u64>,
551 ) -> Result<()> {
552 tracing::debug!(
553 "Downloading directory from {}:{} (jump hosts: {:?})",
554 self.host,
555 self.port,
556 jump_hosts_spec
557 );
558
559 let client = self
560 .connect_for_transfer_with_jump_hosts(
561 key_path,
562 strict_mode,
563 use_agent,
564 use_password,
565 jump_hosts_spec,
566 connect_timeout_seconds,
567 )
568 .await?;
569
570 tracing::debug!("Connected and authenticated successfully");
571
572 if let Some(parent) = local_dir_path.parent() {
574 tokio::fs::create_dir_all(parent).await.with_context(|| {
575 format!("Failed to create parent directory for {local_dir_path:?}")
576 })?;
577 }
578
579 tracing::debug!(
580 "Downloading directory from {}:{} to {:?} using SFTP",
581 self.host,
582 remote_dir_path,
583 local_dir_path
584 );
585
586 let download_timeout = Duration::from_secs(DIR_DOWNLOAD_TIMEOUT_SECS);
588 tokio::time::timeout(
589 download_timeout,
590 client.download_dir(remote_dir_path.to_string(), local_dir_path),
591 )
592 .await
593 .with_context(|| {
594 format!(
595 "Directory download timeout: Transfer from {}:{} to {:?} did not complete within 10 minutes",
596 self.host, remote_dir_path, local_dir_path
597 )
598 })?
599 .with_context(|| {
600 format!(
601 "Failed to download directory from {}:{} to {:?}",
602 self.host, remote_dir_path, local_dir_path
603 )
604 })?;
605
606 tracing::debug!("Directory download completed successfully");
607
608 Ok(())
609 }
610
611 async fn connect_for_file_transfer(
613 &self,
614 key_path: Option<&Path>,
615 strict_mode: Option<StrictHostKeyChecking>,
616 use_agent: bool,
617 use_password: bool,
618 operation_desc: &str,
619 connect_timeout_seconds: Option<u64>,
620 ) -> Result<Client> {
621 let addr = (self.host.as_str(), self.port);
622 tracing::debug!(
623 "Connecting to {}:{} for {}",
624 self.host,
625 self.port,
626 operation_desc
627 );
628
629 let auth_method = self
632 .determine_auth_method(
633 key_path,
634 use_agent,
635 use_password,
636 #[cfg(target_os = "macos")]
637 false,
638 )
639 .await?;
640
641 let check_method = if let Some(mode) = strict_mode {
643 crate::ssh::known_hosts::get_check_method(mode)
644 } else {
645 crate::ssh::known_hosts::get_check_method(StrictHostKeyChecking::AcceptNew)
646 };
647
648 let timeout_secs = connect_timeout_seconds.unwrap_or(SSH_CONNECT_TIMEOUT_SECS);
650 let connect_timeout = Duration::from_secs(timeout_secs);
651 match tokio::time::timeout(
652 connect_timeout,
653 Client::connect(addr, &self.username, auth_method, check_method),
654 )
655 .await
656 {
657 Ok(Ok(client)) => Ok(client),
658 Ok(Err(e)) => {
659 let context = format!("SSH connection to {}:{}", self.host, self.port);
660 let detailed = format_ssh_error(&context, &e);
661 Err(anyhow::anyhow!(detailed).context(e))
662 }
663 Err(_) => Err(anyhow::anyhow!(
664 "Connection timeout after {timeout_secs} seconds. Host may be unreachable or SSH service not running."
665 )),
666 }
667 }
668
669 #[allow(clippy::too_many_arguments)]
671 async fn connect_for_transfer_with_jump_hosts(
672 &self,
673 key_path: Option<&Path>,
674 strict_mode: Option<StrictHostKeyChecking>,
675 use_agent: bool,
676 use_password: bool,
677 jump_hosts_spec: Option<&str>,
678 connect_timeout_seconds: Option<u64>,
679 ) -> Result<Client> {
680 let auth_method = self
683 .determine_auth_method(
684 key_path,
685 use_agent,
686 use_password,
687 #[cfg(target_os = "macos")]
688 false,
689 )
690 .await?;
691
692 let strict_mode = strict_mode.unwrap_or(StrictHostKeyChecking::AcceptNew);
693
694 self.establish_connection(
696 &auth_method,
697 strict_mode,
698 jump_hosts_spec,
699 key_path,
700 use_agent,
701 use_password,
702 connect_timeout_seconds,
703 None,
704 )
705 .await
706 }
707}
708
709fn format_ssh_error(context: &str, e: &crate::ssh::tokio_client::Error) -> String {
711 match e {
712 crate::ssh::tokio_client::Error::KeyAuthFailed => {
713 format!("{context} failed: Authentication rejected with provided SSH key")
714 }
715 crate::ssh::tokio_client::Error::KeyInvalid(err) => {
716 format!("{context} failed: Invalid SSH key - {err}")
717 }
718 crate::ssh::tokio_client::Error::ServerCheckFailed => {
719 format!(
720 "{context} failed: Host key verification failed. The server's host key is not trusted."
721 )
722 }
723 crate::ssh::tokio_client::Error::PasswordWrong => {
724 format!("{context} failed: Password authentication rejected")
725 }
726 crate::ssh::tokio_client::Error::AgentConnectionFailed => {
727 format!("{context} failed: Cannot connect to SSH agent. Ensure SSH_AUTH_SOCK is set.")
728 }
729 crate::ssh::tokio_client::Error::AgentNoIdentities => {
730 format!("{context} failed: SSH agent has no keys. Use 'ssh-add' to add your key.")
731 }
732 crate::ssh::tokio_client::Error::AgentAuthenticationFailed => {
733 format!("{context} failed: SSH agent authentication rejected")
734 }
735 _ => format!("{context} failed: {e}"),
736 }
737}