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::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
24// File upload timeout design:
25// - 5 minutes handles typical file sizes over slow networks
26// - Sufficient for multi-MB files on broadband connections
27// - Prevents hang on network failures or very large files
28const FILE_UPLOAD_TIMEOUT_SECS: u64 = 300;
29
30// File download timeout design:
31// - 5 minutes handles typical file sizes over slow networks
32// - Sufficient for multi-MB files on broadband connections
33// - Prevents hang on network failures or very large files
34const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 300;
35
36// Directory upload timeout design:
37// - 10 minutes handles directories with many files
38// - Accounts for SFTP overhead per file (connection setup, etc.)
39// - Longer than single file to accommodate batch operations
40// - Prevents indefinite hang on large directory trees
41const DIR_UPLOAD_TIMEOUT_SECS: u64 = 600;
42
43// Directory download timeout design:
44// - 10 minutes handles directories with many files
45// - Accounts for SFTP overhead per file (connection setup, etc.)
46// - Longer than single file to accommodate batch operations
47// - Prevents indefinite hang on large directory trees
48const DIR_DOWNLOAD_TIMEOUT_SECS: u64 = 600;
49
50// SSH connection timeout design:
51// - 30 seconds accommodates slow networks and SSH negotiation
52// - Industry standard for SSH client connections
53// - Balances user patience with reliability on poor networks
54const SSH_CONNECT_TIMEOUT_SECS: u64 = 30;
55
56impl SshClient {
57    /// Upload a single file to the remote host
58    #[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        // Check if local file exists
84        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        // Use the built-in upload_file method with timeout (SFTP-based)
102        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    /// Download a single file from the remote host
133    #[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        // Create parent directory if it doesn't exist
159        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        // Use the built-in download_file method with timeout (SFTP-based)
173        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    /// Upload a directory to the remote host
204    #[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        // Check if local directory exists
230        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        // Use the built-in upload_dir method with timeout
246        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    /// Download a directory from the remote host
277    #[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        // Create parent directory if it doesn't exist
303        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        // Use the built-in download_dir method with timeout
317        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    /// Upload file with jump host support
348    #[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        // Check if local file exists
387        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        // Use the built-in upload_file method with timeout (SFTP-based)
405        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    /// Download file with jump host support
436    #[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        // Create parent directory if it doesn't exist
475        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        // Use the built-in download_file method with timeout (SFTP-based)
489        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    /// Upload directory with jump host support
520    #[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        // Check if local directory exists
559        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        // Use the built-in upload_dir method with timeout
575        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    /// Download directory with jump host support
606    #[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        // Create parent directory if it doesn't exist
645        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        // Use the built-in download_dir method with timeout
659        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    /// Helper function to connect for file transfer operations (without jump hosts)
690    #[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        // Determine authentication method based on parameters
710        // Note: use_keychain is set to false for file transfers to avoid prompts
711        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        // Set up host key checking
724        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        // Connect and authenticate with timeout
731        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    /// Helper function to connect for file transfer with jump hosts
757    #[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        // Determine authentication method
771        // Note: use_keychain is set to false for file transfers to avoid prompts
772        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        // Create client connection - either direct or through jump hosts.
787        // Threading `pre_collected_password` here ensures jump-host auth
788        // (when `--password` is combined with `-J`) consumes the dispatcher's
789        // single up-front prompt instead of re-prompting per jump. See #200.
790        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
807/// Format detailed SSH error messages
808fn 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        // The inner key error is not interpolated here: the `KeyInvalid`
814        // variant's own `Display` already includes it, and this message is the
815        // outer context layer, so echoing it would print it twice.
816        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            // Name the entry as known_hosts records it, so the command also
826            // works for non-standard ports, and double quote it so zsh does not
827            // reject the unmatched `[...]` glob. No `-f` here: this path has no
828            // known_hosts path to hand, and production always uses the default
829            // file, which `ssh-keygen` picks itself.
830            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        // No `ssh-keygen -R` remediation here, unlike `HostKeyChanged`: the
837        // entry that matched is the `@revoked` marker line placed
838        // deliberately to blocklist this exact key, not a stale pin.
839        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        // Regression test for issue #238's readability defect: `detailed`
867        // must be the OUTER context so `{:#}` renders it first, followed by
868        // the underlying cause, instead of the reverse.
869        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        // `KeyInvalid`'s own `Display` already interpolates the underlying key
888        // error, so this outer context layer must not interpolate it again.
889        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        // `{:#}` joins layers with ": ", so a trailing period would render as
911        // the awkward sequence ".: " in the middle of a line.
912        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        // The changed-key context layer must point at the offending entry's
947        // removal command without restating the cause's own wording, which
948        // would render twice through anyhow's `{:#}` form (#239, #238).
949        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        // known_hosts records a non-standard port as `[host]:port`, so
978        // `ssh-keygen -R host` would remove nothing and leave the user stuck.
979        // The guidance must name the entry that actually exists, quoted so the
980        // unmatched `[...]` glob does not make zsh reject the command.
981        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        // Same invariant as HostKeyChanged: guidance without restating the
1002        // cause's own wording, which would render twice through anyhow's
1003        // `{:#}` form (#239, #238).
1004        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        // The catch-all arm used to interpolate `{e}` directly into the
1033        // detailed message; once nesting is corrected that would print the
1034        // cause's text twice. It must not repeat the variant's own text.
1035        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}