horto_os_ui_shared/remote/
transfer.rs1use 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
23pub 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
31pub 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
58pub 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 runner.push("rsync", ScriptedRunner::ok("ok"));
181 runner.push("ssh", ScriptedRunner::ok(""));
182 runner.push("ssh", ScriptedRunner::ok(""));
184 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 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 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}