Skip to main content

ssh_cli/sftp/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SFTP: SFTP CLI surface (one-shot; stream transfers; no full-file heap).
3#![forbid(unsafe_code)]
4//! SFTP subsystem operations over SSH (upload/download/ls/mkdir/rm/stat/rename).
5//!
6//! Complements SCP (regular-file wire). SFTP adds directory trees and FS ops.
7//! Multi-host fan-out uses [`crate::concurrency::map_bounded`]. Multi-file on one
8//! host reuses **one** SFTP session (G-SFTP-19).
9
10use crate::cli::SftpAction;
11use crate::constants::SFTP_FALLBACK_BASENAME;
12use crate::errors::SshCliError;
13use crate::i18n::{self, Message};
14use crate::output;
15use crate::ssh::client::{SshClient, TransferResult};
16use crate::ssh::sftp_path::{ensure_local_under, validate_entry_name};
17use crate::ssh::sftp_session;
18use crate::ssh::sftp_types::{SftpListEntry, SftpStat};
19use crate::vps;
20use std::path::{Path, PathBuf};
21use std::time::Instant;
22
23pub(crate) mod batch;
24
25/// Runtime overrides for the `sftp` subcommand (parity with scp + agent G-SFTP-18).
26#[derive(Debug, Default, Clone)]
27pub struct SftpOptions {
28    /// SSH password (resolved).
29    pub password: Option<secrecy::SecretString>,
30    /// Private key path.
31    pub key: Option<String>,
32    /// Key passphrase (resolved).
33    pub key_passphrase: Option<secrecy::SecretString>,
34    /// Total connect+op timeout ms.
35    pub timeout: Option<crate::domain::TimeoutMs>,
36    /// Replace divergent host key.
37    pub replace_host_key: bool,
38    /// Emit JSON success envelopes.
39    pub json: bool,
40    /// Use ssh-agent (CLI/XDG only).
41    pub use_agent: bool,
42    /// Agent socket / named pipe path.
43    pub agent_socket: Option<String>,
44    /// Recursive tree transfer.
45    pub recursive: bool,
46}
47
48/// Applies CLI overrides onto a VPS record (incl. agent — G-SFTP-18).
49pub(crate) fn apply_sftp_options(record: &mut crate::vps::model::VpsRecord, opts: &SftpOptions) {
50    if let Some(ref pwd) = opts.password {
51        record.password = pwd.clone();
52    }
53    if let Some(ref k) = opts.key {
54        if let Ok(kp) = crate::domain::KeyPath::try_new(k.as_str()) {
55            record.key_path = Some(kp);
56        }
57    }
58    if let Some(ref kp) = opts.key_passphrase {
59        record.key_passphrase = Some(kp.clone());
60    }
61    if let Some(t) = opts.timeout {
62        record.timeout_ms = t;
63    }
64    if opts.use_agent {
65        record.use_agent = true;
66    }
67    if let Some(ref sock) = opts.agent_socket {
68        record.agent_socket = Some(sock.clone());
69        record.use_agent = true;
70    }
71}
72
73async fn connect_client(
74    vps_key: &str,
75    config_override: Option<&std::path::Path>,
76    opts: &SftpOptions,
77) -> anyhow::Result<SshClient> {
78    let mut record = vps::find_by_name(config_override, vps_key)?
79        .ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
80    apply_sftp_options(&mut record, opts);
81    let path = vps::resolve_config_path(config_override)?;
82    let cfg = vps::build_connection_config(&record, Some(&path), opts.replace_host_key);
83    let client = SshClient::connect(cfg).await?;
84    Ok(client)
85}
86
87fn remote_str(p: &Path) -> String {
88    p.to_string_lossy().into_owned()
89}
90
91/// Runs the `sftp` subcommand.
92pub async fn run_sftp(
93    action: SftpAction,
94    config_override: Option<PathBuf>,
95    opts: SftpOptions,
96) -> anyhow::Result<()> {
97    if crate::signals::should_stop() {
98        return Err(anyhow::anyhow!(i18n::t(Message::OperationCancelled)));
99    }
100
101    match action {
102        SftpAction::Upload {
103            all,
104            hosts,
105            target,
106            recursive,
107            ..
108        } => {
109            let mut opts = opts;
110            opts.recursive = recursive;
111            let plan = crate::cli::parse_scp_target(all, hosts, target)
112                .map_err(SshCliError::InvalidArgument)?;
113            match plan {
114                crate::cli::ScpPathPlan::Single {
115                    selection,
116                    path_a: local,
117                    path_b: remote,
118                } => {
119                    if selection.is_batch() {
120                        return batch::run_sftp_all_upload(
121                            &selection,
122                            &local,
123                            &remote_str(&remote),
124                            config_override,
125                            opts,
126                        )
127                        .await;
128                    }
129                    let vps::HostSelection::Single(vps_name) = selection else {
130                        return Err(SshCliError::InvalidArgument(
131                            "internal: expected single-host sftp upload".into(),
132                        )
133                        .into());
134                    };
135                    let client =
136                        connect_client(vps_name.as_str(), config_override.as_deref(), &opts)
137                            .await?;
138                    let remote = remote_str(&remote);
139                    let result = if opts.recursive {
140                        client.sftp_upload_tree(&local, &remote).await
141                    } else {
142                        client.sftp_upload(&local, &remote).await
143                    };
144                    let _ = client.disconnect().await;
145                    emit_transfer(
146                        "upload",
147                        vps_name.as_str(),
148                        &local.display().to_string(),
149                        &remote,
150                        result?,
151                        opts.json,
152                        opts.recursive,
153                    )?;
154                }
155                crate::cli::ScpPathPlan::MultiFile {
156                    vps,
157                    sources,
158                    dest_dir,
159                } => {
160                    if opts.recursive {
161                        return Err(SshCliError::InvalidArgument(
162                            "sftp multi-file upload does not combine with --recursive".into(),
163                        )
164                        .into());
165                    }
166                    let client =
167                        connect_client(vps.as_str(), config_override.as_deref(), &opts).await?;
168                    let dest = remote_str(&dest_dir);
169                    let local_label = sources
170                        .first()
171                        .map(|p| p.display().to_string())
172                        .unwrap_or_default();
173                    let start = Instant::now();
174                    let timeout_ms = client.timeout_ms();
175                    let result = sftp_session::under_timeout(timeout_ms, async {
176                        let sftp = client.open_sftp().await?;
177                        let mut bytes = 0_u64;
178                        let mut err: Option<SshCliError> = None;
179                        for src in &sources {
180                            let name = src
181                                .file_name()
182                                .map(|n| n.to_string_lossy().into_owned())
183                                .unwrap_or_else(|| SFTP_FALLBACK_BASENAME.to_owned());
184                            validate_entry_name(&name)?;
185                            let remote = crate::ssh::sftp_path::join_remote(&dest, &name);
186                            match sftp_session::upload_file(&sftp, src, &remote).await {
187                                Ok(r) => bytes = bytes.saturating_add(r.bytes_transferred),
188                                Err(e) => {
189                                    err = Some(e);
190                                    break;
191                                }
192                            }
193                        }
194                        sftp_session::close_sftp(&sftp).await;
195                        if let Some(e) = err {
196                            return Err(e);
197                        }
198                        Ok(bytes)
199                    })
200                    .await;
201                    let _ = client.disconnect().await;
202                    let bytes = result?;
203                    emit_transfer(
204                        "upload",
205                        vps.as_str(),
206                        &local_label,
207                        &dest,
208                        TransferResult {
209                            bytes_transferred: bytes,
210                            duration_ms: u64::try_from(start.elapsed().as_millis())
211                                .unwrap_or(u64::MAX),
212                        },
213                        opts.json,
214                        false,
215                    )?;
216                }
217                crate::cli::ScpPathPlan::MultiHostMultiFile {
218                    selection,
219                    sources,
220                    dest_dir,
221                } => {
222                    return batch::run_sftp_multi_host_multi_file_upload(
223                        &selection,
224                        sources,
225                        &remote_str(&dest_dir),
226                        config_override,
227                        opts,
228                    )
229                    .await;
230                }
231            }
232        }
233        SftpAction::Download {
234            all,
235            hosts,
236            target,
237            recursive,
238            ..
239        } => {
240            let mut opts = opts;
241            opts.recursive = recursive;
242            let plan = crate::cli::parse_scp_target(all, hosts, target)
243                .map_err(SshCliError::InvalidArgument)?;
244            match plan {
245                crate::cli::ScpPathPlan::Single {
246                    selection,
247                    path_a: remote,
248                    path_b: local,
249                } => {
250                    if selection.is_batch() {
251                        return batch::run_sftp_all_download(
252                            &selection,
253                            &remote_str(&remote),
254                            &local,
255                            config_override,
256                            opts,
257                        )
258                        .await;
259                    }
260                    let vps::HostSelection::Single(vps_name) = selection else {
261                        return Err(SshCliError::InvalidArgument(
262                            "internal: expected single-host sftp download".into(),
263                        )
264                        .into());
265                    };
266                    let client =
267                        connect_client(vps_name.as_str(), config_override.as_deref(), &opts)
268                            .await?;
269                    let remote = remote_str(&remote);
270                    let result = if opts.recursive {
271                        client.sftp_download_tree(&remote, &local).await
272                    } else {
273                        client.sftp_download(&remote, &local).await
274                    };
275                    let _ = client.disconnect().await;
276                    emit_transfer(
277                        "download",
278                        vps_name.as_str(),
279                        &local.display().to_string(),
280                        &remote,
281                        result?,
282                        opts.json,
283                        opts.recursive,
284                    )?;
285                }
286                crate::cli::ScpPathPlan::MultiFile {
287                    vps,
288                    sources: remotes,
289                    dest_dir: local_dir,
290                } => {
291                    if opts.recursive {
292                        return Err(SshCliError::InvalidArgument(
293                            "sftp multi-file download does not combine with --recursive".into(),
294                        )
295                        .into());
296                    }
297                    let client =
298                        connect_client(vps.as_str(), config_override.as_deref(), &opts).await?;
299                    let local_label = local_dir.display().to_string();
300                    let remote_label = remotes
301                        .first()
302                        .map(|p| p.display().to_string())
303                        .unwrap_or_default();
304                    let start = Instant::now();
305                    let timeout_ms = client.timeout_ms();
306                    let local_root = local_dir.clone();
307                    let result = sftp_session::under_timeout(timeout_ms, async {
308                        tokio::fs::create_dir_all(&local_dir)
309                            .await
310                            .map_err(SshCliError::Io)?;
311                        let sftp = client.open_sftp().await?;
312                        let mut bytes = 0_u64;
313                        let mut err: Option<SshCliError> = None;
314                        for remote_p in &remotes {
315                            let remote = remote_str(remote_p);
316                            let name = remote_p
317                                .file_name()
318                                .map(|n| n.to_string_lossy().into_owned())
319                                .unwrap_or_else(|| SFTP_FALLBACK_BASENAME.to_owned());
320                            if let Err(e) = validate_entry_name(&name) {
321                                err = Some(e);
322                                break;
323                            }
324                            let local = local_dir.join(&name);
325                            if let Err(e) = ensure_local_under(&local_root, &local) {
326                                err = Some(e);
327                                break;
328                            }
329                            match sftp_session::download_file(&sftp, &remote, &local).await {
330                                Ok(r) => bytes = bytes.saturating_add(r.bytes_transferred),
331                                Err(e) => {
332                                    err = Some(e);
333                                    break;
334                                }
335                            }
336                        }
337                        sftp_session::close_sftp(&sftp).await;
338                        if let Some(e) = err {
339                            return Err(e);
340                        }
341                        Ok(bytes)
342                    })
343                    .await;
344                    let _ = client.disconnect().await;
345                    let bytes = result?;
346                    emit_transfer(
347                        "download",
348                        vps.as_str(),
349                        &local_label,
350                        &remote_label,
351                        TransferResult {
352                            bytes_transferred: bytes,
353                            duration_ms: u64::try_from(start.elapsed().as_millis())
354                                .unwrap_or(u64::MAX),
355                        },
356                        opts.json,
357                        false,
358                    )?;
359                }
360                crate::cli::ScpPathPlan::MultiHostMultiFile {
361                    selection,
362                    sources: remotes,
363                    dest_dir: local_dir,
364                } => {
365                    return batch::run_sftp_multi_host_multi_file_download(
366                        &selection,
367                        remotes,
368                        &local_dir,
369                        config_override,
370                        opts,
371                    )
372                    .await;
373                }
374            }
375        }
376        SftpAction::Ls {
377            vps_name,
378            remote,
379            json: json_local,
380            ..
381        } => {
382            let json = opts.json || json_local;
383            let client =
384                connect_client(&vps_name, config_override.as_deref(), &opts).await?;
385            let timeout_ms = client.timeout_ms();
386            let entries = sftp_session::under_timeout(timeout_ms, async {
387                let sftp = client.open_sftp().await?;
388                let entries = sftp_session::list_dir(&sftp, &remote).await;
389                sftp_session::close_sftp(&sftp).await;
390                entries
391            })
392            .await;
393            let _ = client.disconnect().await;
394            emit_list(&vps_name, &remote, &entries?, json)?;
395        }
396        SftpAction::Mkdir {
397            vps_name,
398            remote,
399            json: json_local,
400            ..
401        } => {
402            let json = opts.json || json_local;
403            let start = Instant::now();
404            let client =
405                connect_client(&vps_name, config_override.as_deref(), &opts).await?;
406            let timeout_ms = client.timeout_ms();
407            let result = sftp_session::under_timeout(timeout_ms, async {
408                let sftp = client.open_sftp().await?;
409                let result = sftp_session::mkdir(&sftp, &remote).await;
410                sftp_session::close_sftp(&sftp).await;
411                result
412            })
413            .await;
414            let _ = client.disconnect().await;
415            result?;
416            emit_fs_op(
417                "mkdir",
418                &vps_name,
419                &remote,
420                None,
421                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
422                json,
423            )?;
424        }
425        SftpAction::Rmdir {
426            vps_name,
427            remote,
428            json: json_local,
429            ..
430        } => {
431            let json = opts.json || json_local;
432            let start = Instant::now();
433            let client =
434                connect_client(&vps_name, config_override.as_deref(), &opts).await?;
435            let timeout_ms = client.timeout_ms();
436            let result = sftp_session::under_timeout(timeout_ms, async {
437                let sftp = client.open_sftp().await?;
438                let result = sftp_session::rmdir(&sftp, &remote).await;
439                sftp_session::close_sftp(&sftp).await;
440                result
441            })
442            .await;
443            let _ = client.disconnect().await;
444            result?;
445            emit_fs_op(
446                "rmdir",
447                &vps_name,
448                &remote,
449                None,
450                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
451                json,
452            )?;
453        }
454        SftpAction::Rm {
455            vps_name,
456            remote,
457            json: json_local,
458            ..
459        } => {
460            let json = opts.json || json_local;
461            let start = Instant::now();
462            let client =
463                connect_client(&vps_name, config_override.as_deref(), &opts).await?;
464            let timeout_ms = client.timeout_ms();
465            let result = sftp_session::under_timeout(timeout_ms, async {
466                let sftp = client.open_sftp().await?;
467                let result = sftp_session::rm(&sftp, &remote).await;
468                sftp_session::close_sftp(&sftp).await;
469                result
470            })
471            .await;
472            let _ = client.disconnect().await;
473            result?;
474            emit_fs_op(
475                "rm",
476                &vps_name,
477                &remote,
478                None,
479                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
480                json,
481            )?;
482        }
483        SftpAction::Stat {
484            vps_name,
485            remote,
486            json: json_local,
487            ..
488        } => {
489            let json = opts.json || json_local;
490            let client =
491                connect_client(&vps_name, config_override.as_deref(), &opts).await?;
492            let timeout_ms = client.timeout_ms();
493            let st = sftp_session::under_timeout(timeout_ms, async {
494                let sftp = client.open_sftp().await?;
495                let st = sftp_session::stat(&sftp, &remote).await;
496                sftp_session::close_sftp(&sftp).await;
497                st
498            })
499            .await;
500            let _ = client.disconnect().await;
501            emit_stat(&vps_name, &st?, json)?;
502        }
503        SftpAction::Rename {
504            vps_name,
505            from,
506            to,
507            json: json_local,
508            ..
509        } => {
510            let json = opts.json || json_local;
511            let start = Instant::now();
512            let client =
513                connect_client(&vps_name, config_override.as_deref(), &opts).await?;
514            let timeout_ms = client.timeout_ms();
515            let result = sftp_session::under_timeout(timeout_ms, async {
516                let sftp = client.open_sftp().await?;
517                let result = sftp_session::rename(&sftp, &from, &to).await;
518                sftp_session::close_sftp(&sftp).await;
519                result
520            })
521            .await;
522            let _ = client.disconnect().await;
523            result?;
524            emit_fs_op(
525                "rename",
526                &vps_name,
527                &from,
528                Some(to.as_str()),
529                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
530                json,
531            )?;
532        }
533    }
534    Ok(())
535}
536
537fn emit_transfer(
538    direction: &str,
539    vps: &str,
540    local: &str,
541    remote: &str,
542    result: TransferResult,
543    json: bool,
544    recursive: bool,
545) -> anyhow::Result<()> {
546    if json {
547        output::print_sftp_transfer_json(
548            direction,
549            vps,
550            local,
551            remote,
552            result.bytes_transferred,
553            result.duration_ms,
554            recursive,
555        )?;
556    } else {
557        let msg = if direction == "upload" {
558            Message::SftpUploadCompleted {
559                bytes: result.bytes_transferred,
560                ms: result.duration_ms,
561            }
562        } else {
563            Message::SftpDownloadCompleted {
564                bytes: result.bytes_transferred,
565                ms: result.duration_ms,
566            }
567        };
568        output::print_success(&i18n::t(msg));
569    }
570    Ok(())
571}
572
573fn emit_list(vps: &str, path: &str, entries: &[SftpListEntry], json: bool) -> anyhow::Result<()> {
574    if json {
575        output::print_sftp_list_json(vps, path, entries)?;
576    } else {
577        for e in entries {
578            println!(
579                "{}\t{}\t{}",
580                e.kind,
581                e.size.map(|s| s.to_string()).unwrap_or_else(|| "-".into()),
582                e.path
583            );
584        }
585    }
586    Ok(())
587}
588
589fn emit_stat(vps: &str, st: &SftpStat, json: bool) -> anyhow::Result<()> {
590    if json {
591        output::print_sftp_stat_json(vps, st)?;
592    } else {
593        println!(
594            "path={} kind={} size={} mode={:?} mtime={:?}",
595            st.path,
596            st.kind,
597            st.size.map(|s| s.to_string()).unwrap_or_else(|| "-".into()),
598            st.mode,
599            st.mtime
600        );
601    }
602    Ok(())
603}
604
605fn emit_fs_op(
606    op: &str,
607    vps: &str,
608    path: &str,
609    to: Option<&str>,
610    duration_ms: u64,
611    json: bool,
612) -> anyhow::Result<()> {
613    if json {
614        output::print_sftp_fs_op_json(op, vps, path, to, duration_ms)?;
615    } else {
616        match to {
617            Some(t) => output::print_success(&format!("sftp {op} ok: {path} -> {t} ({duration_ms}ms)")),
618            None => output::print_success(&format!("sftp {op} ok: {path} ({duration_ms}ms)")),
619        }
620    }
621    Ok(())
622}
623
624