Skip to main content

bssh/ssh/client/
file_transfer.rs

1// Copyright 2025 Lablup Inc. and Jeongkyu Shin
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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
22// File upload timeout design:
23// - 5 minutes handles typical file sizes over slow networks
24// - Sufficient for multi-MB files on broadband connections
25// - Prevents hang on network failures or very large files
26const FILE_UPLOAD_TIMEOUT_SECS: u64 = 300;
27
28// File download timeout design:
29// - 5 minutes handles typical file sizes over slow networks
30// - Sufficient for multi-MB files on broadband connections
31// - Prevents hang on network failures or very large files
32const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 300;
33
34// Directory upload timeout design:
35// - 10 minutes handles directories with many files
36// - Accounts for SFTP overhead per file (connection setup, etc.)
37// - Longer than single file to accommodate batch operations
38// - Prevents indefinite hang on large directory trees
39const DIR_UPLOAD_TIMEOUT_SECS: u64 = 600;
40
41// Directory download timeout design:
42// - 10 minutes handles directories with many files
43// - Accounts for SFTP overhead per file (connection setup, etc.)
44// - Longer than single file to accommodate batch operations
45// - Prevents indefinite hang on large directory trees
46const DIR_DOWNLOAD_TIMEOUT_SECS: u64 = 600;
47
48// SSH connection timeout design:
49// - 30 seconds accommodates slow networks and SSH negotiation
50// - Industry standard for SSH client connections
51// - Balances user patience with reliability on poor networks
52const SSH_CONNECT_TIMEOUT_SECS: u64 = 30;
53
54impl SshClient {
55    /// Upload a single file to the remote host
56    #[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        // Check if local file exists
81        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        // Use the built-in upload_file method with timeout (SFTP-based)
99        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    /// Download a single file from the remote host
124    #[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        // Create parent directory if it doesn't exist
149        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        // Use the built-in download_file method with timeout (SFTP-based)
163        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    /// Upload a directory to the remote host
188    #[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        // Check if local directory exists
213        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        // Use the built-in upload_dir method with timeout
229        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    /// Download a directory from the remote host
254    #[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        // Create parent directory if it doesn't exist
279        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        // Use the built-in download_dir method with timeout
293        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    /// Upload file with jump host support
318    #[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        // Check if local file exists
351        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        // Use the built-in upload_file method with timeout (SFTP-based)
369        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    /// Download file with jump host support
394    #[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        // Create parent directory if it doesn't exist
427        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        // Use the built-in download_file method with timeout (SFTP-based)
441        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    /// Upload directory with jump host support
466    #[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        // Check if local directory exists
499        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        // Use the built-in upload_dir method with timeout
515        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    /// Download directory with jump host support
540    #[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        // Create parent directory if it doesn't exist
573        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        // Use the built-in download_dir method with timeout
587        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    /// Helper function to connect for file transfer operations (without jump hosts)
612    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        // Determine authentication method based on parameters
630        // Note: use_keychain is set to false for file transfers to avoid prompts
631        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        // Set up host key checking
642        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        // Connect and authenticate with timeout
649        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    /// Helper function to connect for file transfer with jump hosts
670    #[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        // Determine authentication method
681        // Note: use_keychain is set to false for file transfers to avoid prompts
682        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        // Create client connection - either direct or through jump hosts
695        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
709/// Format detailed SSH error messages
710fn 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}