zub/transport/
ssh.rs

1//! SSH transport for remote repository operations
2//!
3//! uses the `zub-remote` helper on the remote side (similar to git-receive-pack)
4
5use 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
13/// SSH connection to a remote repository
14pub struct SshConnection {
15    child: Child,
16    reader: BufReader<ChildStdout>,
17    writer: ChildStdin,
18}
19
20impl SshConnection {
21    /// connect to a remote repository via SSH
22    pub fn connect(remote: &str, repo_path: &Path) -> Result<Self> {
23        // parse remote in format user@host or just host
24        let (host, user) = parse_remote(remote);
25
26        // first, check if zub exists on the remote
27        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    /// list refs on the remote
48    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    /// check which objects the remote needs
66    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    /// send an object to the remote
102    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    /// update a ref on the remote
114    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    /// request objects from remote (for pull)
120    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    /// receive an object from the remote
156    /// returns (type, hash, data, mode) where mode is file permissions for blobs
157    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        // parse "object TYPE HASH SIZE MODE"
171        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        // mode is optional for backwards compat, default to 0644
184        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    /// request ref value from remote
200    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    /// close the connection
212    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
293// deployed binary path: use $TMPDIR if set, otherwise ~/.cache
294const 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    // check both PATH and our deploy location
303    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    // TODO: this assumes the remote has the same architecture as the local machine.
317    // in the future, we could detect the remote arch and either:
318    // - download the correct binary from a release
319    // - refuse with a helpful error message
320    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    // get the resolved remote path
325    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    // ensure parent directory exists on remote
334    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    // copy the binary
352    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    // make it executable
367    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    // try zub in PATH first, fall back to deployed location
418    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// note: SSH transport tests require a remote server, so they're integration tests