1use std::io::{BufRead, BufReader, Read, Write};
6use std::path::Path;
7use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
8
9use crate::error::Result;
10use crate::hash::Hash;
11use crate::transport::local::ObjectSet;
12
13pub struct SshConnection {
15 child: Child,
16 reader: BufReader<ChildStdout>,
17 writer: ChildStdin,
18}
19
20impl SshConnection {
21 pub fn connect(remote: &str, repo_path: &Path) -> Result<Self> {
23 let (host, user) = parse_remote(remote);
25
26 if !check_remote_zub(&host, user.as_deref())? {
28 deploy_zub_to_remote(&host, user.as_deref())?;
29 }
30
31 let mut child = spawn_remote(&host, user.as_deref(), repo_path)?;
32
33 let stdout = child.stdout.take().ok_or_else(|| crate::Error::Transport {
34 message: "stdout not available".to_string(),
35 })?;
36 let stdin = child.stdin.take().ok_or_else(|| crate::Error::Transport {
37 message: "stdin not available".to_string(),
38 })?;
39
40 Ok(Self {
41 child,
42 reader: BufReader::new(stdout),
43 writer: stdin,
44 })
45 }
46
47 pub fn list_refs(&mut self) -> Result<Vec<(String, Hash)>> {
49 self.send_command("list-refs")?;
50 let response = self.read_response()?;
51
52 let mut refs = Vec::new();
53 for line in response.lines() {
54 let parts: Vec<&str> = line.splitn(2, ' ').collect();
55 if parts.len() == 2 {
56 if let Ok(hash) = Hash::from_hex(parts[0]) {
57 refs.push((parts[1].to_string(), hash));
58 }
59 }
60 }
61
62 Ok(refs)
63 }
64
65 pub fn want_objects(&mut self, objects: &ObjectSet) -> Result<ObjectSet> {
67 let mut request = String::from("want-objects\n");
68
69 for hash in &objects.blobs {
70 request.push_str(&format!("blob {}\n", hash));
71 }
72 for hash in &objects.trees {
73 request.push_str(&format!("tree {}\n", hash));
74 }
75 for hash in &objects.commits {
76 request.push_str(&format!("commit {}\n", hash));
77 }
78 request.push_str("end\n");
79
80 self.send_raw(&request)?;
81 let response = self.read_response()?;
82
83 let mut needed = ObjectSet::new();
84 for line in response.lines() {
85 let parts: Vec<&str> = line.splitn(2, ' ').collect();
86 if parts.len() == 2 {
87 if let Ok(hash) = Hash::from_hex(parts[1]) {
88 match parts[0] {
89 "blob" => needed.blobs.push(hash),
90 "tree" => needed.trees.push(hash),
91 "commit" => needed.commits.push(hash),
92 _ => {}
93 }
94 }
95 }
96 }
97
98 Ok(needed)
99 }
100
101 pub fn send_object(&mut self, obj_type: &str, hash: &Hash, data: &[u8]) -> Result<()> {
103 let header = format!("object {} {} {}\n", obj_type, hash, data.len());
104 self.send_raw(&header)?;
105
106 self.writer.write_all(data).map_err(|e| crate::Error::Transport {
107 message: format!("failed to write object: {}", e),
108 })?;
109
110 self.expect_ok()
111 }
112
113 pub fn update_ref(&mut self, name: &str, hash: &Hash) -> Result<()> {
115 self.send_command(&format!("update-ref {} {}", name, hash))?;
116 self.expect_ok()
117 }
118
119 pub fn have_objects(&mut self, objects: &ObjectSet) -> Result<ObjectSet> {
121 let mut request = String::from("have-objects\n");
122
123 for hash in &objects.blobs {
124 request.push_str(&format!("blob {}\n", hash));
125 }
126 for hash in &objects.trees {
127 request.push_str(&format!("tree {}\n", hash));
128 }
129 for hash in &objects.commits {
130 request.push_str(&format!("commit {}\n", hash));
131 }
132 request.push_str("end\n");
133
134 self.send_raw(&request)?;
135 let response = self.read_response()?;
136
137 let mut missing = ObjectSet::new();
138 for line in response.lines() {
139 let parts: Vec<&str> = line.splitn(2, ' ').collect();
140 if parts.len() == 2 {
141 if let Ok(hash) = Hash::from_hex(parts[1]) {
142 match parts[0] {
143 "blob" => missing.blobs.push(hash),
144 "tree" => missing.trees.push(hash),
145 "commit" => missing.commits.push(hash),
146 _ => {}
147 }
148 }
149 }
150 }
151
152 Ok(missing)
153 }
154
155 pub fn receive_object(&mut self) -> Result<Option<(String, Hash, Vec<u8>, u32)>> {
158 let mut line = String::new();
159 self.reader
160 .read_line(&mut line)
161 .map_err(|e| crate::Error::Transport {
162 message: format!("failed to read: {}", e),
163 })?;
164
165 let line = line.trim();
166 if line == "end" {
167 return Ok(None);
168 }
169
170 let parts: Vec<&str> = line.splitn(5, ' ').collect();
172 if parts.len() < 4 || parts[0] != "object" {
173 return Err(crate::Error::Transport {
174 message: format!("unexpected response: {}", line),
175 });
176 }
177
178 let obj_type = parts[1].to_string();
179 let hash = Hash::from_hex(parts[2])?;
180 let size: usize = parts[3].parse().map_err(|_| crate::Error::Transport {
181 message: format!("invalid size: {}", parts[3]),
182 })?;
183 let mode: u32 = parts
185 .get(4)
186 .and_then(|s| s.parse().ok())
187 .unwrap_or(0o644);
188
189 let mut data = vec![0u8; size];
190 self.reader
191 .read_exact(&mut data)
192 .map_err(|e| crate::Error::Transport {
193 message: format!("failed to read object data: {}", e),
194 })?;
195
196 Ok(Some((obj_type, hash, data, mode)))
197 }
198
199 pub fn get_ref(&mut self, name: &str) -> Result<Option<Hash>> {
201 self.send_command(&format!("get-ref {}", name))?;
202 let response = self.read_response()?;
203
204 if response.trim().is_empty() || response.trim() == "not-found" {
205 return Ok(None);
206 }
207
208 Hash::from_hex(response.trim()).map(Some)
209 }
210
211 pub fn close(mut self) -> Result<()> {
213 let _ = self.send_command("quit");
214 let _ = self.child.wait();
215 Ok(())
216 }
217
218 fn send_command(&mut self, cmd: &str) -> Result<()> {
219 self.send_raw(&format!("{}\n", cmd))
220 }
221
222 fn send_raw(&mut self, data: &str) -> Result<()> {
223 self.writer
224 .write_all(data.as_bytes())
225 .map_err(|e| crate::Error::Transport {
226 message: format!("failed to write: {}", e),
227 })?;
228
229 self.writer.flush().map_err(|e| crate::Error::Transport {
230 message: format!("failed to flush: {}", e),
231 })
232 }
233
234 fn read_response(&mut self) -> Result<String> {
235 let mut response = String::new();
236
237 loop {
238 let mut line = String::new();
239 let n = self
240 .reader
241 .read_line(&mut line)
242 .map_err(|e| crate::Error::Transport {
243 message: format!("failed to read: {}", e),
244 })?;
245
246 if n == 0 {
247 break;
248 }
249
250 if line.trim() == "end" {
251 break;
252 }
253
254 if line.starts_with("error:") {
255 return Err(crate::Error::Transport {
256 message: line[6..].trim().to_string(),
257 });
258 }
259
260 response.push_str(&line);
261 }
262
263 Ok(response)
264 }
265
266 fn expect_ok(&mut self) -> Result<()> {
267 let response = self.read_response()?;
268 if response.trim() == "ok" {
269 Ok(())
270 } else {
271 Err(crate::Error::Transport {
272 message: format!("expected 'ok', got: {}", response),
273 })
274 }
275 }
276}
277
278impl Drop for SshConnection {
279 fn drop(&mut self) {
280 let _ = self.child.kill();
281 }
282}
283
284fn parse_remote(remote: &str) -> (String, Option<String>) {
285 if remote.contains('@') {
286 let parts: Vec<&str> = remote.splitn(2, '@').collect();
287 (parts[1].to_string(), Some(parts[0].to_string()))
288 } else {
289 (remote.to_string(), None)
290 }
291}
292
293const REMOTE_ZUB_PATH: &str = "${TMPDIR:-$HOME/.cache}/zub_auto_deployed";
295
296fn check_remote_zub(host: &str, user: Option<&str>) -> Result<bool> {
297 let mut cmd = Command::new("ssh");
298 if let Some(u) = user {
299 cmd.arg("-l").arg(u);
300 }
301 cmd.arg(host);
302 cmd.arg(format!(
304 "command -v zub >/dev/null 2>&1 || test -x {}",
305 REMOTE_ZUB_PATH
306 ));
307
308 let status = cmd.status().map_err(|e| crate::Error::Transport {
309 message: format!("failed to check remote zub: {}", e),
310 })?;
311
312 Ok(status.success())
313}
314
315fn deploy_zub_to_remote(host: &str, user: Option<&str>) -> Result<()> {
316 let local_exe = std::env::current_exe().map_err(|e| crate::Error::Transport {
321 message: format!("failed to get current executable path: {}", e),
322 })?;
323
324 let resolved_path = get_resolved_remote_path(host, user)?;
326
327 let remote_target = if let Some(u) = user {
328 format!("{}@{}:{}", u, host, resolved_path)
329 } else {
330 format!("{}:{}", host, resolved_path)
331 };
332
333 let mut mkdir_cmd = Command::new("ssh");
335 if let Some(u) = user {
336 mkdir_cmd.arg("-l").arg(u);
337 }
338 mkdir_cmd.arg(host);
339 mkdir_cmd.arg(format!("mkdir -p \"$(dirname {})\"", REMOTE_ZUB_PATH));
340
341 let status = mkdir_cmd.status().map_err(|e| crate::Error::Transport {
342 message: format!("failed to create remote directory: {}", e),
343 })?;
344
345 if !status.success() {
346 return Err(crate::Error::Transport {
347 message: "failed to create directory on remote".to_string(),
348 });
349 }
350
351 let status = Command::new("scp")
353 .arg(&local_exe)
354 .arg(&remote_target)
355 .status()
356 .map_err(|e| crate::Error::Transport {
357 message: format!("failed to copy zub to remote: {}", e),
358 })?;
359
360 if !status.success() {
361 return Err(crate::Error::Transport {
362 message: "failed to copy zub binary to remote".to_string(),
363 });
364 }
365
366 let mut chmod_cmd = Command::new("ssh");
368 if let Some(u) = user {
369 chmod_cmd.arg("-l").arg(u);
370 }
371 chmod_cmd.arg(host);
372 chmod_cmd.arg(format!("chmod +x {}", REMOTE_ZUB_PATH));
373
374 let status = chmod_cmd.status().map_err(|e| crate::Error::Transport {
375 message: format!("failed to chmod zub on remote: {}", e),
376 })?;
377
378 if !status.success() {
379 return Err(crate::Error::Transport {
380 message: "failed to make zub executable on remote".to_string(),
381 });
382 }
383
384 eprintln!("deployed zub to remote {}", resolved_path);
385 Ok(())
386}
387
388fn get_resolved_remote_path(host: &str, user: Option<&str>) -> Result<String> {
389 let mut cmd = Command::new("ssh");
390 if let Some(u) = user {
391 cmd.arg("-l").arg(u);
392 }
393 cmd.arg(host);
394 cmd.arg(format!("echo {}", REMOTE_ZUB_PATH));
395
396 let output = cmd.output().map_err(|e| crate::Error::Transport {
397 message: format!("failed to resolve remote path: {}", e),
398 })?;
399
400 if !output.status.success() {
401 return Err(crate::Error::Transport {
402 message: "failed to resolve remote path".to_string(),
403 });
404 }
405
406 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
407}
408
409fn spawn_remote(host: &str, user: Option<&str>, repo_path: &Path) -> Result<std::process::Child> {
410 let mut cmd = Command::new("ssh");
411
412 if let Some(u) = user {
413 cmd.arg("-l").arg(u);
414 }
415
416 cmd.arg(host);
417 cmd.arg(format!(
419 "$(command -v zub || echo {}) zub-remote {}",
420 REMOTE_ZUB_PATH,
421 repo_path.display()
422 ));
423
424 cmd.stdin(Stdio::piped());
425 cmd.stdout(Stdio::piped());
426 cmd.stderr(Stdio::inherit());
427
428 cmd.spawn().map_err(|e| crate::Error::Transport {
429 message: format!("failed to spawn ssh: {}", e),
430 })
431}
432
433