Skip to main content

horto_os_ui_shared/remote/
transfer.rs

1//! File transfer: `scp` first; prefer `rsync -e ssh` when both ends have it.
2
3use super::process::{CommandOutput, ProcessRunner, StdioMode};
4use super::ssh::SshSession;
5use crate::error::{HortoError, Result};
6use std::path::Path;
7
8fn require_ok(program: &str, out: &CommandOutput) -> Result<()> {
9    if out.success() {
10        return Ok(());
11    }
12    let detail = if out.stderr.trim().is_empty() {
13        out.stdout.trim().to_owned()
14    } else {
15        out.stderr.trim().to_owned()
16    };
17    Err(HortoError::command(
18        program,
19        format!("exit {}: {detail}", out.status),
20    ))
21}
22
23/// True when local `rsync` exists and the remote host has `rsync` on PATH.
24pub fn prefer_rsync(runner: &dyn ProcessRunner, session: &SshSession) -> bool {
25    let local = runner
26        .run("rsync", &["--version"], &[], StdioMode::Capture)
27        .is_ok_and(|o| o.success());
28    local && session.remote_has_rsync(runner)
29}
30
31/// Copy one or more local files to a remote directory using `scp`.
32///
33/// # Errors
34///
35/// Returns [`crate::HortoError`] when scp fails.
36pub fn scp_files(
37    runner: &dyn ProcessRunner,
38    session: &SshSession,
39    locals: &[&Path],
40    remote_dir: &str,
41) -> Result<()> {
42    session.exec(
43        runner,
44        &format!("mkdir -p {remote_dir}"),
45        StdioMode::Capture,
46    )?;
47    for local in locals {
48        let name = local
49            .file_name()
50            .and_then(|s| s.to_str())
51            .ok_or_else(|| HortoError::msg("bad local file name for scp"))?;
52        let remote_path = format!("{remote_dir}/{name}");
53        session.scp_to(runner, local, &remote_path)?;
54    }
55    Ok(())
56}
57
58/// Copy files with rsync when available, otherwise scp.
59///
60/// # Errors
61///
62/// Returns [`crate::HortoError`] when transfer fails.
63pub fn transfer_files(
64    runner: &dyn ProcessRunner,
65    session: &SshSession,
66    locals: &[&Path],
67    remote_dir: &str,
68) -> Result<()> {
69    if prefer_rsync(runner, session) {
70        session.exec(
71            runner,
72            &format!("mkdir -p {remote_dir}"),
73            StdioMode::Capture,
74        )?;
75        let dest = format!("{}:{remote_dir}/", session.host.raw);
76        let mut args: Vec<&str> = vec!["-e", "ssh", "-a"];
77        let path_strs: Vec<String> = locals
78            .iter()
79            .map(|p| {
80                p.to_str()
81                    .ok_or_else(|| HortoError::msg("non-utf8 path for rsync"))
82                    .map(str::to_owned)
83            })
84            .collect::<Result<Vec<_>>>()?;
85        for s in &path_strs {
86            args.push(s.as_str());
87        }
88        args.push(&dest);
89        let env_owned = session.env.as_pairs();
90        let env_refs: Vec<(&str, &str)> = env_owned
91            .iter()
92            .map(|(k, v)| (k.as_str(), v.as_str()))
93            .collect();
94        let out = runner.run("rsync", &args, &env_refs, StdioMode::Inherit)?;
95        require_ok("rsync", &out)?;
96        return Ok(());
97    }
98    scp_files(runner, session, locals, remote_dir)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::remote::host::parse_host_spec;
105    use crate::remote::process::ScriptedRunner;
106    use crate::remote::ssh::{SshEnv, SshSession};
107    use std::path::PathBuf;
108    use tempfile::TempDir;
109
110    fn session() -> SshSession {
111        SshSession {
112            host: parse_host_spec("box").unwrap(),
113            env: SshEnv::default(),
114            config_file: None,
115        }
116    }
117
118    #[test]
119    fn prefer_rsync_false_when_local_missing() {
120        let runner = ScriptedRunner::default();
121        runner.push("rsync", ScriptedRunner::fail(127, "not found"));
122        assert!(!prefer_rsync(&runner, &session()));
123    }
124
125    #[test]
126    fn prefer_rsync_true_when_both_ends_have_it() {
127        let runner = ScriptedRunner::default();
128        runner.push("rsync", ScriptedRunner::ok("rsync  version\n"));
129        runner.push("ssh", ScriptedRunner::ok(""));
130        assert!(prefer_rsync(&runner, &session()));
131    }
132
133    #[test]
134    fn scp_files_copies_each_local() {
135        let tmp = TempDir::new().unwrap();
136        let a = tmp.path().join("a.bin");
137        let b = tmp.path().join("b.bin");
138        std::fs::write(&a, b"a").unwrap();
139        std::fs::write(&b, b"b").unwrap();
140        let runner = ScriptedRunner::default();
141        runner.push("ssh", ScriptedRunner::ok(""));
142        runner.push("scp", ScriptedRunner::ok(""));
143        runner.push("scp", ScriptedRunner::ok(""));
144        scp_files(&runner, &session(), &[&a, &b], "/tmp/dest").unwrap();
145        let programs: Vec<_> = runner
146            .calls
147            .lock()
148            .unwrap()
149            .iter()
150            .map(|(p, _, _, _)| p.clone())
151            .collect();
152        assert_eq!(programs, vec!["ssh", "scp", "scp"]);
153    }
154
155    #[test]
156    fn transfer_files_falls_back_to_scp() {
157        let tmp = TempDir::new().unwrap();
158        let f = tmp.path().join("x");
159        std::fs::write(&f, b"x").unwrap();
160        let runner = ScriptedRunner::default();
161        runner.push("rsync", ScriptedRunner::fail(127, "no"));
162        runner.push("ssh", ScriptedRunner::ok(""));
163        runner.push("scp", ScriptedRunner::ok(""));
164        transfer_files(&runner, &session(), &[&f], "/tmp/d").unwrap();
165        assert!(runner
166            .calls
167            .lock()
168            .unwrap()
169            .iter()
170            .any(|(p, _, _, _)| p == "scp"));
171    }
172
173    #[test]
174    fn transfer_files_uses_rsync_when_available() {
175        let tmp = TempDir::new().unwrap();
176        let f = tmp.path().join("x");
177        std::fs::write(&f, b"x").unwrap();
178        let runner = ScriptedRunner::default();
179        // prefer_rsync
180        runner.push("rsync", ScriptedRunner::ok("ok"));
181        runner.push("ssh", ScriptedRunner::ok(""));
182        // mkdir for transfer
183        runner.push("ssh", ScriptedRunner::ok(""));
184        // rsync copy
185        runner.push("rsync", ScriptedRunner::ok(""));
186        transfer_files(&runner, &session(), &[&f], "/tmp/d").unwrap();
187        let rsync_calls = runner
188            .calls
189            .lock()
190            .unwrap()
191            .iter()
192            .filter(|(p, _, _, _)| p == "rsync")
193            .count();
194        assert!(rsync_calls >= 2);
195    }
196
197    #[test]
198    fn require_ok_uses_stdout_when_stderr_empty() {
199        let runner = ScriptedRunner::default();
200        runner.push("rsync", ScriptedRunner::fail(127, ""));
201        // force prefer false then scp mkdir fail with stdout-only detail via ssh
202        let runner2 = ScriptedRunner::default();
203        runner2.push("rsync", ScriptedRunner::fail(1, ""));
204        assert!(!prefer_rsync(&runner2, &session()));
205
206        let runner3 = ScriptedRunner::default();
207        runner3.push("rsync", ScriptedRunner::ok("v"));
208        runner3.push("ssh", ScriptedRunner::fail(1, ""));
209        // remote_has_rsync fails -> prefer false; no panic
210        assert!(!prefer_rsync(&runner3, &session()));
211        let _ = runner;
212        let _ = PathBuf::from("/tmp");
213    }
214
215    #[test]
216    fn rsync_failure_surfaces() {
217        let tmp = TempDir::new().unwrap();
218        let f = tmp.path().join("x");
219        std::fs::write(&f, b"x").unwrap();
220        let runner = ScriptedRunner::default();
221        runner.push("rsync", ScriptedRunner::ok("v"));
222        runner.push("ssh", ScriptedRunner::ok(""));
223        runner.push("ssh", ScriptedRunner::ok(""));
224        runner.push("rsync", ScriptedRunner::fail(1, "boom"));
225        let err = transfer_files(&runner, &session(), &[&f], "/tmp/d").unwrap_err();
226        assert!(err.to_string().contains("rsync"));
227    }
228}