Skip to main content

ssh_cli/scp/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! File transfer via SCP over SSH (one-shot).
5//!
6//! Wrapper around [`SshClient`] `upload` and `download` methods.
7//! Regular files only (no `-r` / no SFTP subsystem).
8//!
9//! # Workload classification
10//!
11//! **I/O-bound** (network + disk). Multi-host `--all` / `--hosts` uses
12//! [`crate::concurrency::map_bounded`] via [`crate::vps::resolve_host_jobs`]
13//! (one permit = one SSH **session**).
14//!
15//! **Multi-file (G-PAR-47):** one host, N files → **one** `connect`, serial
16//! transfers on that session (auth RTT once; `&mut` client cannot safely fan-out
17//! channels without redesign). Parallelism useful at **host** granularity.
18//!
19//! **Multi-host × multi-file (G-PAR-48):** outer `map_bounded` per host; inner
20//! multi-file session reuse. Batch JSON even when `--hosts` has one name (G-PAR-36).
21
22use crate::cli::ScpAction;
23use crate::errors::SshCliError;
24use crate::i18n::{self, Message};
25use crate::output;
26use crate::ssh::client::{SshClient, SshClientTrait};
27use crate::vps;
28use std::path::PathBuf;
29
30mod batch;
31mod multi_host;
32
33use batch::{run_scp_multi_file_download, run_scp_multi_file_upload};
34use multi_host::{
35    run_scp_all_download, run_scp_all_upload, run_scp_multi_host_multi_file_download,
36    run_scp_multi_host_multi_file_upload,
37};
38
39/// Runtime overrides for the `scp` subcommand (parity with exec).
40///
41/// G-SECDEV-02: secrets are [`secrecy::SecretString`] from the CLI boundary.
42/// G-TYPE-18: `timeout` is refined [`crate::domain::TimeoutMs`].
43#[derive(Debug, Default, Clone)]
44pub struct ScpOptions {
45    /// SSH password (already resolved from flag or stdin).
46    pub password: Option<secrecy::SecretString>,
47    /// Private key path.
48    pub key: Option<String>,
49    /// Key passphrase (already resolved).
50    pub key_passphrase: Option<secrecy::SecretString>,
51    /// Total connect+transfer timeout in ms (refined at CLI boundary).
52    pub timeout: Option<crate::domain::TimeoutMs>,
53    /// Replace divergent host key (global `--replace-host-key`).
54    pub replace_host_key: bool,
55    /// Emit success JSON (local flag or global format).
56    pub json: bool,
57    /// Use ssh-agent (G-SFTP-17 / G-SSH-04 parity). CLI/XDG only.
58    pub use_agent: bool,
59    /// Agent socket (Unix) or named pipe (Windows).
60    pub agent_socket: Option<String>,
61}
62
63/// Per-host SCP outcome for multi-host batch output.
64#[derive(Debug, Clone)]
65pub struct HostScpResult {
66    /// VPS name.
67    pub name: String,
68    /// Whether transfer succeeded.
69    pub ok: bool,
70    /// Bytes transferred when ok.
71    pub bytes: Option<u64>,
72    /// Duration ms when measured.
73    pub duration_ms: Option<u64>,
74    /// Effective local path (download may be host-suffixed).
75    pub local: Option<String>,
76    /// Error detail.
77    pub error: Option<String>,
78}
79
80/// Runs the SCP subcommand (upload/download), single host, multi-file, or multi-host.
81pub async fn run_scp(
82    action: ScpAction,
83    config_override: Option<PathBuf>,
84    opts: ScpOptions,
85) -> anyhow::Result<()> {
86    if crate::signals::should_stop() {
87        return Err(anyhow::anyhow!(crate::constants::OPERATION_CANCELLED_MSG));
88    }
89
90    match action {
91        ScpAction::Upload {
92            all,
93            hosts,
94            src,
95            dest,
96            target,
97            ..
98        } => {
99            let plan = crate::cli::parse_scp_target(
100                all,
101                hosts,
102                crate::cli::TransferSlots::new(src, dest),
103                target,
104            )
105            .map_err(SshCliError::InvalidArgument)?;
106            match plan {
107                crate::cli::ScpPathPlan::MultiFile {
108                    vps,
109                    sources,
110                    dest_dir,
111                } => {
112                    return run_scp_multi_file_upload(
113                        &vps,
114                        sources,
115                        &dest_dir,
116                        config_override,
117                        opts,
118                    )
119                    .await;
120                }
121                crate::cli::ScpPathPlan::MultiHostMultiFile {
122                    selection,
123                    sources,
124                    dest_dir,
125                } => {
126                    return run_scp_multi_host_multi_file_upload(
127                        &selection,
128                        sources,
129                        &dest_dir,
130                        config_override,
131                        opts,
132                    )
133                    .await;
134                }
135                crate::cli::ScpPathPlan::Single {
136                    selection,
137                    path_a: local,
138                    path_b: remote,
139                } => {
140                    // GAP-SSH-SCP-001 / SCP-019: validate file local antes do connect.
141                    if local.is_dir() {
142                        return Err(SshCliError::InvalidArgument(
143                            crate::constants::SCP_UPLOAD_FILE_ONLY_MSG.to_string(),
144                        )
145                        .into());
146                    }
147                    if !local.is_file() {
148                        return Err(SshCliError::FileNotFound(local.display().to_string()).into());
149                    }
150
151                    if selection.is_batch() {
152                        return run_scp_all_upload(
153                            &selection,
154                            &local,
155                            &remote,
156                            config_override,
157                            opts,
158                        )
159                        .await;
160                    }
161                    let vps::HostSelection::Single(vps_name) = selection else {
162                        // G-SEC-08: fail closed instead of panic on invariant slip.
163                        return Err(SshCliError::InvalidArgument(
164                            "internal: expected single-host selection for non-batch SCP".into(),
165                        )
166                        .into());
167                    };
168                    let vps_key = vps_name.as_str();
169
170                    let mut record = vps::find_by_name(config_override.as_deref(), vps_key)?
171                        .ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
172                    // Explicit Target Designation: publish only after the lookup
173                    // succeeded, so a `VpsNotFound` envelope never claims a host that
174                    // was never resolved. Same seam and same reason as `exec`.
175                    let target = crate::json_wire::ExecTarget::new(
176                        vps_key,
177                        crate::json_wire::TargetSource::Argv,
178                    );
179                    crate::json_wire::set_resolved_target(&target);
180
181                    apply_scp_options(&mut record, &opts);
182
183                    let path = crate::vps::resolve_config_path(config_override.as_deref())?;
184                    let cfg = crate::vps::build_connection_config(
185                        &record,
186                        Some(&path),
187                        opts.replace_host_key,
188                    );
189
190                    let client: Box<dyn SshClientTrait> =
191                        <SshClient as SshClientTrait>::connect(cfg).await?;
192                    run_scp_upload_with_client(&target, &local, &remote, client, opts.json).await?;
193                }
194            }
195        }
196        ScpAction::Download {
197            all,
198            hosts,
199            src,
200            dest,
201            target,
202            ..
203        } => {
204            let plan = crate::cli::parse_scp_target(
205                all,
206                hosts,
207                crate::cli::TransferSlots::new(src, dest),
208                target,
209            )
210            .map_err(SshCliError::InvalidArgument)?;
211            match plan {
212                crate::cli::ScpPathPlan::MultiFile {
213                    vps,
214                    sources: remotes,
215                    dest_dir: local_dir,
216                } => {
217                    return run_scp_multi_file_download(
218                        &vps,
219                        remotes,
220                        &local_dir,
221                        config_override,
222                        opts,
223                    )
224                    .await;
225                }
226                crate::cli::ScpPathPlan::MultiHostMultiFile {
227                    selection,
228                    sources: remotes,
229                    dest_dir: local_dir,
230                } => {
231                    return run_scp_multi_host_multi_file_download(
232                        &selection,
233                        remotes,
234                        &local_dir,
235                        config_override,
236                        opts,
237                    )
238                    .await;
239                }
240                crate::cli::ScpPathPlan::Single {
241                    selection,
242                    path_a: remote,
243                    path_b: local,
244                } => {
245                    if selection.is_batch() {
246                        return run_scp_all_download(
247                            &selection,
248                            &remote,
249                            &local,
250                            config_override,
251                            opts,
252                        )
253                        .await;
254                    }
255                    if local.is_dir() {
256                        return Err(SshCliError::InvalidArgument(
257                            crate::constants::SCP_DOWNLOAD_LOCAL_NOT_DIRECTORY_MSG.to_string(),
258                        )
259                        .into());
260                    }
261                    let vps::HostSelection::Single(vps_name) = selection else {
262                        // G-SEC-08: fail closed instead of panic on invariant slip.
263                        return Err(SshCliError::InvalidArgument(
264                            "internal: expected single-host selection for non-batch SCP".into(),
265                        )
266                        .into());
267                    };
268                    let vps_key = vps_name.as_str();
269
270                    let mut record = vps::find_by_name(config_override.as_deref(), vps_key)?
271                        .ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
272                    // Explicit Target Designation: publish only after the lookup
273                    // succeeded, so a `VpsNotFound` envelope never claims a host that
274                    // was never resolved. Same seam and same reason as `exec`.
275                    let target = crate::json_wire::ExecTarget::new(
276                        vps_key,
277                        crate::json_wire::TargetSource::Argv,
278                    );
279                    crate::json_wire::set_resolved_target(&target);
280
281                    apply_scp_options(&mut record, &opts);
282
283                    let path = crate::vps::resolve_config_path(config_override.as_deref())?;
284                    let cfg = crate::vps::build_connection_config(
285                        &record,
286                        Some(&path),
287                        opts.replace_host_key,
288                    );
289
290                    let client: Box<dyn SshClientTrait> =
291                        <SshClient as SshClientTrait>::connect(cfg).await?;
292                    run_scp_download_with_client(&target, &remote, &local, client, opts.json)
293                        .await?;
294                }
295            }
296        }
297    }
298    Ok(())
299}
300
301/// G-PAR-51: reject directories / missing files via `tokio::fs` (async path).
302pub(crate) fn apply_scp_options(record: &mut crate::vps::model::VpsRecord, opts: &ScpOptions) {
303    // G-MEM-SCP: borrow opts (often behind Arc) and clone secrets into the record.
304    // Prefer Arc fan-out over cloning ScpOptions per host.
305    if let Some(ref pwd) = opts.password {
306        record.password = pwd.clone();
307    }
308    if let Some(ref k) = opts.key {
309        if let Ok(kp) = crate::domain::KeyPath::try_new(k.as_str()) {
310            record.key_path = Some(kp);
311        }
312    }
313    if let Some(ref kp) = opts.key_passphrase {
314        record.key_passphrase = Some(kp.clone());
315    }
316    // G-TYPE-18: timeout already TimeoutMs at the options boundary.
317    if let Some(t) = opts.timeout {
318        record.timeout_ms = t;
319    }
320    // G-SFTP-17: agent parity with exec/sftp (CLI/XDG — not env store).
321    if opts.use_agent {
322        record.use_agent = true;
323    }
324    if let Some(ref sock) = opts.agent_socket {
325        record.agent_socket = Some(sock.clone());
326        record.use_agent = true;
327    }
328}
329
330/// Testable SCP upload that accepts the client as a parameter.
331pub async fn run_scp_upload_with_client(
332    target: &crate::json_wire::ExecTarget,
333    local: &std::path::Path,
334    remote: &std::path::Path,
335    client: Box<dyn SshClientTrait>,
336    json: bool,
337) -> anyhow::Result<()> {
338    let result = client.upload(local, remote).await;
339    let _ = client.disconnect().await;
340    let result = result?;
341    if json {
342        output::print_transfer_json(
343            "upload",
344            target,
345            &local.display().to_string(),
346            &remote.display().to_string(),
347            &result,
348        )?;
349    } else {
350        output::print_success(&i18n::t(Message::ScpUploadCompleted {
351            bytes: result.bytes_transferred,
352            ms: result.duration_ms,
353        }));
354    }
355    Ok(())
356}
357
358/// Testable SCP download that accepts the client as a parameter.
359pub async fn run_scp_download_with_client(
360    target: &crate::json_wire::ExecTarget,
361    remote: &std::path::Path,
362    local: &std::path::Path,
363    client: Box<dyn SshClientTrait>,
364    json: bool,
365) -> anyhow::Result<()> {
366    let result = client.download(remote, local).await;
367    let _ = client.disconnect().await;
368    let result = result?;
369    if json {
370        output::print_transfer_json(
371            "download",
372            target,
373            &local.display().to_string(),
374            &remote.display().to_string(),
375            &result,
376        )?;
377    } else {
378        output::print_success(&i18n::t(Message::ScpDownloadCompleted {
379            bytes: result.bytes_transferred,
380            ms: result.duration_ms,
381        }));
382    }
383    Ok(())
384}
385
386#[cfg(test)]
387#[path = "tests.rs"]
388mod tests;