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