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!(i18n::t(Message::OperationCancelled)));
88    }
89
90    match action {
91        ScpAction::Upload {
92            all, hosts, target, ..
93        } => {
94            let plan = crate::cli::parse_scp_target(all, hosts, target)
95                .map_err(SshCliError::InvalidArgument)?;
96            match plan {
97                crate::cli::ScpPathPlan::MultiFile {
98                    vps,
99                    sources,
100                    dest_dir,
101                } => {
102                    return run_scp_multi_file_upload(
103                        &vps,
104                        sources,
105                        &dest_dir,
106                        config_override,
107                        opts,
108                    )
109                    .await;
110                }
111                crate::cli::ScpPathPlan::MultiHostMultiFile {
112                    selection,
113                    sources,
114                    dest_dir,
115                } => {
116                    return run_scp_multi_host_multi_file_upload(
117                        &selection,
118                        sources,
119                        &dest_dir,
120                        config_override,
121                        opts,
122                    )
123                    .await;
124                }
125                crate::cli::ScpPathPlan::Single {
126                    selection,
127                    path_a: local,
128                    path_b: remote,
129                } => {
130                    // GAP-SSH-SCP-001 / SCP-019: validate file local antes do connect.
131                    if local.is_dir() {
132                        return Err(SshCliError::InvalidArgument(i18n::t(
133                            Message::ScpUploadFileOnly,
134                        ))
135                        .into());
136                    }
137                    if !local.is_file() {
138                        return Err(SshCliError::FileNotFound(local.display().to_string()).into());
139                    }
140
141                    if selection.is_batch() {
142                        return run_scp_all_upload(
143                            &selection,
144                            &local,
145                            &remote,
146                            config_override,
147                            opts,
148                        )
149                        .await;
150                    }
151                    let vps::HostSelection::Single(vps_name) = selection else {
152                        // G-SEC-08: fail closed instead of panic on invariant slip.
153                        return Err(SshCliError::InvalidArgument(
154                            "internal: expected single-host selection for non-batch SCP".into(),
155                        )
156                        .into());
157                    };
158                    let vps_key = vps_name.as_str();
159
160                    let mut record = vps::find_by_name(config_override.as_deref(), vps_key)?
161                        .ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
162
163                    apply_scp_options(&mut record, &opts);
164
165                    let path = crate::vps::resolve_config_path(config_override.as_deref())?;
166                    let cfg = crate::vps::build_connection_config(
167                        &record,
168                        Some(&path),
169                        opts.replace_host_key,
170                    );
171
172                    let client: Box<dyn SshClientTrait> =
173                        <SshClient as SshClientTrait>::connect(cfg).await?;
174                    run_scp_upload_with_client(vps_key, &local, &remote, client, opts.json).await?;
175                }
176            }
177        }
178        ScpAction::Download {
179            all, hosts, target, ..
180        } => {
181            let plan = crate::cli::parse_scp_target(all, hosts, target)
182                .map_err(SshCliError::InvalidArgument)?;
183            match plan {
184                crate::cli::ScpPathPlan::MultiFile {
185                    vps,
186                    sources: remotes,
187                    dest_dir: local_dir,
188                } => {
189                    return run_scp_multi_file_download(
190                        &vps,
191                        remotes,
192                        &local_dir,
193                        config_override,
194                        opts,
195                    )
196                    .await;
197                }
198                crate::cli::ScpPathPlan::MultiHostMultiFile {
199                    selection,
200                    sources: remotes,
201                    dest_dir: local_dir,
202                } => {
203                    return run_scp_multi_host_multi_file_download(
204                        &selection,
205                        remotes,
206                        &local_dir,
207                        config_override,
208                        opts,
209                    )
210                    .await;
211                }
212                crate::cli::ScpPathPlan::Single {
213                    selection,
214                    path_a: remote,
215                    path_b: local,
216                } => {
217                    if selection.is_batch() {
218                        return run_scp_all_download(
219                            &selection,
220                            &remote,
221                            &local,
222                            config_override,
223                            opts,
224                        )
225                        .await;
226                    }
227                    if local.is_dir() {
228                        return Err(SshCliError::InvalidArgument(i18n::t(
229                            Message::ScpDownloadLocalNotDirectory,
230                        ))
231                        .into());
232                    }
233                    let vps::HostSelection::Single(vps_name) = selection else {
234                        // G-SEC-08: fail closed instead of panic on invariant slip.
235                        return Err(SshCliError::InvalidArgument(
236                            "internal: expected single-host selection for non-batch SCP".into(),
237                        )
238                        .into());
239                    };
240                    let vps_key = vps_name.as_str();
241
242                    let mut record = vps::find_by_name(config_override.as_deref(), vps_key)?
243                        .ok_or_else(|| SshCliError::VpsNotFound(vps_key.to_owned()))?;
244
245                    apply_scp_options(&mut record, &opts);
246
247                    let path = crate::vps::resolve_config_path(config_override.as_deref())?;
248                    let cfg = crate::vps::build_connection_config(
249                        &record,
250                        Some(&path),
251                        opts.replace_host_key,
252                    );
253
254                    let client: Box<dyn SshClientTrait> =
255                        <SshClient as SshClientTrait>::connect(cfg).await?;
256                    run_scp_download_with_client(vps_key, &remote, &local, client, opts.json)
257                        .await?;
258                }
259            }
260        }
261    }
262    Ok(())
263}
264
265/// G-PAR-51: reject directories / missing files via `tokio::fs` (async path).
266pub(crate) fn apply_scp_options(record: &mut crate::vps::model::VpsRecord, opts: &ScpOptions) {
267    // G-MEM-SCP: borrow opts (often behind Arc) and clone secrets into the record.
268    // Prefer Arc fan-out over cloning ScpOptions per host.
269    if let Some(ref pwd) = opts.password {
270        record.password = pwd.clone();
271    }
272    if let Some(ref k) = opts.key {
273        if let Ok(kp) = crate::domain::KeyPath::try_new(k.as_str()) {
274            record.key_path = Some(kp);
275        }
276    }
277    if let Some(ref kp) = opts.key_passphrase {
278        record.key_passphrase = Some(kp.clone());
279    }
280    // G-TYPE-18: timeout already TimeoutMs at the options boundary.
281    if let Some(t) = opts.timeout {
282        record.timeout_ms = t;
283    }
284    // G-SFTP-17: agent parity with exec/sftp (CLI/XDG — not env store).
285    if opts.use_agent {
286        record.use_agent = true;
287    }
288    if let Some(ref sock) = opts.agent_socket {
289        record.agent_socket = Some(sock.clone());
290        record.use_agent = true;
291    }
292}
293
294/// Testable SCP upload that accepts the client as a parameter.
295pub async fn run_scp_upload_with_client(
296    vps_name: &str,
297    local: &std::path::Path,
298    remote: &std::path::Path,
299    client: Box<dyn SshClientTrait>,
300    json: bool,
301) -> anyhow::Result<()> {
302    let result = client.upload(local, remote).await;
303    let _ = client.disconnect().await;
304    let result = result?;
305    if json {
306        output::print_transfer_json(
307            "upload",
308            vps_name,
309            &local.display().to_string(),
310            &remote.display().to_string(),
311            &result,
312        )?;
313    } else {
314        output::print_success(&i18n::t(Message::ScpUploadCompleted {
315            bytes: result.bytes_transferred,
316            ms: result.duration_ms,
317        }));
318    }
319    Ok(())
320}
321
322/// Testable SCP download that accepts the client as a parameter.
323pub async fn run_scp_download_with_client(
324    vps_name: &str,
325    remote: &std::path::Path,
326    local: &std::path::Path,
327    client: Box<dyn SshClientTrait>,
328    json: bool,
329) -> anyhow::Result<()> {
330    let result = client.download(remote, local).await;
331    let _ = client.disconnect().await;
332    let result = result?;
333    if json {
334        output::print_transfer_json(
335            "download",
336            vps_name,
337            &local.display().to_string(),
338            &remote.display().to_string(),
339            &result,
340        )?;
341    } else {
342        output::print_success(&i18n::t(Message::ScpDownloadCompleted {
343            bytes: result.bytes_transferred,
344            ms: result.duration_ms,
345        }));
346    }
347    Ok(())
348}
349
350#[cfg(test)]
351#[path = "tests.rs"]
352mod tests;