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    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        // Check if local file exists
72        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        // Use the built-in upload_file method with timeout (SFTP-based)
90        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    /// Download a single file from the remote host
115    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        // Create parent directory if it doesn't exist
137        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        // Use the built-in download_file method with timeout (SFTP-based)
151        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    /// Upload a directory to the remote host
176    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        // Check if local directory exists
198        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        // Use the built-in upload_dir method with timeout
214        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    /// Download a directory from the remote host
239    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        // Create parent directory if it doesn't exist
261        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        // Use the built-in download_dir method with timeout
275        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    /// Upload file with jump host support
300    #[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        // Check if local file exists
331        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        // Use the built-in upload_file method with timeout (SFTP-based)
349        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    /// Download file with jump host support
374    #[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        // Create parent directory if it doesn't exist
405        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        // Use the built-in download_file method with timeout (SFTP-based)
419        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    /// Upload directory with jump host support
444    #[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        // Check if local directory exists
475        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        // Use the built-in upload_dir method with timeout
491        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    /// Download directory with jump host support
516    #[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        // Create parent directory if it doesn't exist
547        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        // Use the built-in download_dir method with timeout
561        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    /// Helper function to connect for file transfer operations (without jump hosts)
586    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        // Determine authentication method based on parameters
603        // Note: use_keychain is set to false for file transfers to avoid prompts
604        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        // Set up host key checking
615        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        // Connect and authenticate with timeout
622        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    /// Helper function to connect for file transfer with jump hosts
642    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        // Determine authentication method
651        // Note: use_keychain is set to false for file transfers to avoid prompts
652        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        // Create client connection - either direct or through jump hosts
665        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
677/// Format detailed SSH error messages
678fn 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}