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 = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
384            let timeout_ms = client.timeout_ms();
385            let entries = sftp_session::under_timeout(timeout_ms, async {
386                let sftp = client.open_sftp().await?;
387                let entries = sftp_session::list_dir(&sftp, &remote).await;
388                sftp_session::close_sftp(&sftp).await;
389                entries
390            })
391            .await;
392            let _ = client.disconnect().await;
393            emit_list(&vps_name, &remote, &entries?, json)?;
394        }
395        SftpAction::Mkdir {
396            vps_name,
397            remote,
398            json: json_local,
399            ..
400        } => {
401            let json = opts.json || json_local;
402            let start = Instant::now();
403            let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
404            let timeout_ms = client.timeout_ms();
405            let result = sftp_session::under_timeout(timeout_ms, async {
406                let sftp = client.open_sftp().await?;
407                let result = sftp_session::mkdir(&sftp, &remote).await;
408                sftp_session::close_sftp(&sftp).await;
409                result
410            })
411            .await;
412            let _ = client.disconnect().await;
413            result?;
414            emit_fs_op(
415                "mkdir",
416                &vps_name,
417                &remote,
418                None,
419                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
420                json,
421            )?;
422        }
423        SftpAction::Rmdir {
424            vps_name,
425            remote,
426            json: json_local,
427            ..
428        } => {
429            let json = opts.json || json_local;
430            let start = Instant::now();
431            let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
432            let timeout_ms = client.timeout_ms();
433            let result = sftp_session::under_timeout(timeout_ms, async {
434                let sftp = client.open_sftp().await?;
435                let result = sftp_session::rmdir(&sftp, &remote).await;
436                sftp_session::close_sftp(&sftp).await;
437                result
438            })
439            .await;
440            let _ = client.disconnect().await;
441            result?;
442            emit_fs_op(
443                "rmdir",
444                &vps_name,
445                &remote,
446                None,
447                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
448                json,
449            )?;
450        }
451        SftpAction::Rm {
452            vps_name,
453            remote,
454            json: json_local,
455            ..
456        } => {
457            let json = opts.json || json_local;
458            let start = Instant::now();
459            let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
460            let timeout_ms = client.timeout_ms();
461            let result = sftp_session::under_timeout(timeout_ms, async {
462                let sftp = client.open_sftp().await?;
463                let result = sftp_session::rm(&sftp, &remote).await;
464                sftp_session::close_sftp(&sftp).await;
465                result
466            })
467            .await;
468            let _ = client.disconnect().await;
469            result?;
470            emit_fs_op(
471                "rm",
472                &vps_name,
473                &remote,
474                None,
475                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
476                json,
477            )?;
478        }
479        SftpAction::Stat {
480            vps_name,
481            remote,
482            json: json_local,
483            ..
484        } => {
485            let json = opts.json || json_local;
486            let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
487            let timeout_ms = client.timeout_ms();
488            let st = sftp_session::under_timeout(timeout_ms, async {
489                let sftp = client.open_sftp().await?;
490                let st = sftp_session::stat(&sftp, &remote).await;
491                sftp_session::close_sftp(&sftp).await;
492                st
493            })
494            .await;
495            let _ = client.disconnect().await;
496            emit_stat(&vps_name, &st?, json)?;
497        }
498        SftpAction::Rename {
499            vps_name,
500            from,
501            to,
502            json: json_local,
503            ..
504        } => {
505            let json = opts.json || json_local;
506            let start = Instant::now();
507            let client = connect_client(&vps_name, config_override.as_deref(), &opts).await?;
508            let timeout_ms = client.timeout_ms();
509            let result = sftp_session::under_timeout(timeout_ms, async {
510                let sftp = client.open_sftp().await?;
511                let result = sftp_session::rename(&sftp, &from, &to).await;
512                sftp_session::close_sftp(&sftp).await;
513                result
514            })
515            .await;
516            let _ = client.disconnect().await;
517            result?;
518            emit_fs_op(
519                "rename",
520                &vps_name,
521                &from,
522                Some(to.as_str()),
523                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
524                json,
525            )?;
526        }
527    }
528    Ok(())
529}
530
531fn emit_transfer(
532    direction: &str,
533    vps: &str,
534    local: &str,
535    remote: &str,
536    result: TransferResult,
537    json: bool,
538    recursive: bool,
539) -> anyhow::Result<()> {
540    if json {
541        output::print_sftp_transfer_json(
542            direction,
543            vps,
544            local,
545            remote,
546            result.bytes_transferred,
547            result.duration_ms,
548            recursive,
549        )?;
550    } else {
551        let msg = if direction == "upload" {
552            Message::SftpUploadCompleted {
553                bytes: result.bytes_transferred,
554                ms: result.duration_ms,
555            }
556        } else {
557            Message::SftpDownloadCompleted {
558                bytes: result.bytes_transferred,
559                ms: result.duration_ms,
560            }
561        };
562        output::print_success(&i18n::t(msg));
563    }
564    Ok(())
565}
566
567fn emit_list(vps: &str, path: &str, entries: &[SftpListEntry], json: bool) -> anyhow::Result<()> {
568    if json {
569        output::print_sftp_list_json(vps, path, entries)?;
570    } else {
571        for e in entries {
572            println!(
573                "{}\t{}\t{}",
574                e.kind,
575                e.size.map(|s| s.to_string()).unwrap_or_else(|| "-".into()),
576                e.path
577            );
578        }
579    }
580    Ok(())
581}
582
583fn emit_stat(vps: &str, st: &SftpStat, json: bool) -> anyhow::Result<()> {
584    if json {
585        output::print_sftp_stat_json(vps, st)?;
586    } else {
587        println!(
588            "path={} kind={} size={} mode={:?} mtime={:?}",
589            st.path,
590            st.kind,
591            st.size.map(|s| s.to_string()).unwrap_or_else(|| "-".into()),
592            st.mode,
593            st.mtime
594        );
595    }
596    Ok(())
597}
598
599fn emit_fs_op(
600    op: &str,
601    vps: &str,
602    path: &str,
603    to: Option<&str>,
604    duration_ms: u64,
605    json: bool,
606) -> anyhow::Result<()> {
607    if json {
608        output::print_sftp_fs_op_json(op, vps, path, to, duration_ms)?;
609    } else {
610        match to {
611            Some(t) => {
612                output::print_success(&format!("sftp {op} ok: {path} -> {t} ({duration_ms}ms)"))
613            }
614            None => output::print_success(&format!("sftp {op} ok: {path} ({duration_ms}ms)")),
615        }
616    }
617    Ok(())
618}