Skip to main content

lit/network/
ssh.rs

1//! SSH transport for remote Lit repositories
2//!
3//! Communicates with a remote `lit serve --stdio` instance over SSH.
4//! The SSH client spawns the system `ssh` command and communicates
5//! via newline-delimited JSON over stdin/stdout pipes.
6
7use crate::core::{Object, ObjectHash};
8use crate::network::transport::RemoteRef;
9use crate::storage::ObjectStore;
10use std::io::{BufRead, BufReader, BufWriter, Write};
11use std::process::{Child, Command, Stdio};
12
13/// Check whether a URL uses the SSH transport
14pub fn is_ssh_url(url: &str) -> bool {
15    url.starts_with("ssh://") || (url.contains('@') && url.contains(':') && !url.contains("://"))
16}
17
18/// Parsed SSH URL components
19#[derive(Debug, Clone)]
20pub struct SshUrl {
21    pub user: Option<String>,
22    pub host: String,
23    pub port: Option<u16>,
24    pub path: String,
25}
26
27/// Parse an SSH URL into its components.
28///
29/// Supports two formats:
30/// - `ssh://[user@]host[:port]/path`
31/// - `user@host:path` (SCP-style)
32pub fn parse_ssh_url(url: &str) -> Result<SshUrl, String> {
33    if let Some(rest) = url.strip_prefix("ssh://") {
34        // ssh://[user@]host[:port]/path
35        let (userhost, path) = rest
36            .split_once('/')
37            .ok_or_else(|| format!("Invalid SSH URL (missing path): {}", url))?;
38
39        let (user, hostport) = if let Some((u, hp)) = userhost.split_once('@') {
40            (Some(u.to_string()), hp)
41        } else {
42            (None, userhost)
43        };
44
45        let (host, port) = if let Some((h, p)) = hostport.split_once(':') {
46            let port_num = p
47                .parse::<u16>()
48                .map_err(|_| format!("Invalid port in SSH URL: {}", p))?;
49            (h.to_string(), Some(port_num))
50        } else {
51            (hostport.to_string(), None)
52        };
53
54        if host.is_empty() {
55            return Err(format!("Empty host in SSH URL: {}", url));
56        }
57
58        Ok(SshUrl {
59            user,
60            host,
61            port,
62            path: format!("/{}", path),
63        })
64    } else if url.contains('@') && url.contains(':') && !url.contains("://") {
65        // user@host:path (SCP-style)
66        let (user_host, path) = url
67            .split_once(':')
68            .ok_or_else(|| format!("Invalid SCP-style SSH URL: {}", url))?;
69        let (user, host) = user_host
70            .split_once('@')
71            .ok_or_else(|| format!("Invalid SCP-style SSH URL: {}", url))?;
72
73        if host.is_empty() || path.is_empty() {
74            return Err(format!("Invalid SCP-style SSH URL: {}", url));
75        }
76
77        Ok(SshUrl {
78            user: Some(user.to_string()),
79            host: host.to_string(),
80            port: None,
81            path: path.to_string(),
82        })
83    } else {
84        Err(format!(
85            "Not an SSH URL: {}. Use ssh://[user@]host[:port]/path or user@host:path",
86            url
87        ))
88    }
89}
90
91/// An SSH pipe connection to a remote `lit serve --stdio` instance
92pub struct SshPipe {
93    child: Child,
94    reader: BufReader<std::process::ChildStdout>,
95    writer: BufWriter<std::process::ChildStdin>,
96}
97
98impl SshPipe {
99    /// Open an SSH pipe to a remote repository
100    pub fn open(parsed: &SshUrl) -> Result<Self, String> {
101        let mut cmd = Command::new("ssh");
102
103        // Disable interactive prompts for batch mode
104        cmd.arg("-o").arg("BatchMode=yes");
105
106        if let Some(port) = parsed.port {
107            cmd.arg("-p").arg(port.to_string());
108        }
109
110        let target = if let Some(ref user) = parsed.user {
111            format!("{}@{}", user, parsed.host)
112        } else {
113            parsed.host.clone()
114        };
115        cmd.arg(&target);
116
117        // Remote command: cd to repo path and run lit serve --stdio
118        let remote_cmd = format!("cd {} && lit serve --stdio", shell_escape(&parsed.path));
119        cmd.arg(remote_cmd);
120
121        cmd.stdin(Stdio::piped())
122            .stdout(Stdio::piped())
123            .stderr(Stdio::piped());
124
125        let mut child = cmd
126            .spawn()
127            .map_err(|e| format!("Failed to spawn ssh: {}", e))?;
128
129        let stdout = child.stdout.take().ok_or("Failed to capture ssh stdout")?;
130        let stdin = child.stdin.take().ok_or("Failed to capture ssh stdin")?;
131
132        Ok(SshPipe {
133            child,
134            reader: BufReader::new(stdout),
135            writer: BufWriter::new(stdin),
136        })
137    }
138
139    /// Open a pipe directly to a `lit serve --stdio` process (for testing without SSH)
140    pub fn open_local(repo_path: &std::path::Path) -> Result<Self, String> {
141        // Find the lit binary: look in the same directory as the current executable,
142        // which covers both `cargo run` and `cargo test` scenarios.
143        let current_exe = std::env::current_exe()
144            .map_err(|e| format!("Cannot find current executable: {}", e))?;
145        let exe_dir = current_exe
146            .parent()
147            .ok_or("Cannot determine executable directory")?;
148
149        // In test builds, the test binary is in target/debug/deps/ but
150        // the lit binary is in target/debug/
151        let lit_exe = if exe_dir.ends_with("deps") {
152            exe_dir
153                .parent()
154                .unwrap()
155                .join("lit")
156                .with_extension(std::env::consts::EXE_EXTENSION)
157        } else {
158            exe_dir
159                .join("lit")
160                .with_extension(std::env::consts::EXE_EXTENSION)
161        };
162
163        if !lit_exe.exists() {
164            return Err(format!(
165                "lit binary not found at {}. Run `cargo build` first.",
166                lit_exe.display()
167            ));
168        }
169
170        let mut child = Command::new(lit_exe)
171            .arg("serve")
172            .arg("--stdio")
173            .current_dir(repo_path)
174            .stdin(Stdio::piped())
175            .stdout(Stdio::piped())
176            .stderr(Stdio::piped())
177            .spawn()
178            .map_err(|e| format!("Failed to spawn lit serve --stdio: {}", e))?;
179
180        let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
181        let stdin = child.stdin.take().ok_or("Failed to capture stdin")?;
182
183        Ok(SshPipe {
184            child,
185            reader: BufReader::new(stdout),
186            writer: BufWriter::new(stdin),
187        })
188    }
189
190    /// Send a request and read the response
191    fn request(
192        &mut self,
193        method: &str,
194        path: &str,
195        body: &str,
196    ) -> Result<(u16, serde_json::Value), String> {
197        let req = serde_json::json!({
198            "method": method,
199            "path": path,
200            "body": body,
201        });
202        writeln!(self.writer, "{}", req)
203            .map_err(|e| format!("Failed to write to SSH pipe: {}", e))?;
204        self.writer
205            .flush()
206            .map_err(|e| format!("Failed to flush SSH pipe: {}", e))?;
207
208        let mut line = String::new();
209        self.reader
210            .read_line(&mut line)
211            .map_err(|e| format!("Failed to read from SSH pipe: {}", e))?;
212
213        if line.is_empty() {
214            return Err("SSH pipe closed unexpectedly".to_string());
215        }
216
217        let resp: serde_json::Value = serde_json::from_str(line.trim())
218            .map_err(|e| format!("Invalid JSON from SSH pipe: {}", e))?;
219
220        let status = resp.get("status").and_then(|v| v.as_u64()).unwrap_or(500) as u16;
221
222        // The body field is a JSON string that needs to be parsed
223        let body_str = resp.get("body").and_then(|v| v.as_str()).unwrap_or("{}");
224
225        let body_json: serde_json::Value =
226            serde_json::from_str(body_str).unwrap_or_else(|_| serde_json::json!({"raw": body_str}));
227
228        Ok((status, body_json))
229    }
230
231    /// Close the SSH pipe gracefully.
232    /// The `Drop` implementation will kill the process if not already exited.
233    pub fn close(&mut self) {
234        // Signal EOF to the remote process by closing our stdin handle,
235        // using a zero-byte write attempt followed by checking the child status.
236        let _ = self.child.try_wait();
237    }
238}
239
240impl Drop for SshPipe {
241    fn drop(&mut self) {
242        let _ = self.child.kill();
243        let _ = self.child.wait();
244    }
245}
246
247/// Check response status and extract error messages
248fn check_status(status: u16, body: &serde_json::Value) -> Result<(), String> {
249    if status >= 400 {
250        let msg = body
251            .get("error")
252            .and_then(|e| e.get("message"))
253            .and_then(|m| m.as_str())
254            .or_else(|| body.get("raw").and_then(|v| v.as_str()))
255            .unwrap_or("Unknown error");
256        Err(format!("SSH transport error ({}): {}", status, msg))
257    } else {
258        Ok(())
259    }
260}
261
262/// List refs from a remote server via SSH pipe
263pub fn list_refs_ssh(pipe: &mut SshPipe, kind: &str) -> Result<Vec<RemoteRef>, String> {
264    let path = format!("/api/v1/transport/refs?kind={}", kind);
265    let (status, body) = pipe.request("GET", &path, "")?;
266    check_status(status, &body)?;
267
268    let refs = body
269        .get("refs")
270        .and_then(|v| v.as_array())
271        .ok_or("Invalid refs response from SSH")?;
272
273    let mut result = Vec::new();
274    for r in refs {
275        let kind = r.get("kind").and_then(|v| v.as_str()).unwrap_or("heads");
276        let name = r
277            .get("name")
278            .and_then(|v| v.as_str())
279            .ok_or("Missing ref name")?;
280        let hash = r
281            .get("hash")
282            .and_then(|v| v.as_str())
283            .ok_or("Missing ref hash")?;
284        result.push(RemoteRef {
285            kind: kind.to_string(),
286            name: name.to_string(),
287            hash: hash.to_string(),
288        });
289    }
290    Ok(result)
291}
292
293/// Read a branch ref from a remote server via SSH pipe
294pub fn read_ref_ssh(pipe: &mut SshPipe, branch: &str) -> Result<String, String> {
295    let path = format!("/api/v1/transport/refs/heads/{}", branch);
296    let (status, body) = pipe.request("GET", &path, "")?;
297    check_status(status, &body)?;
298    body.get("hash")
299        .and_then(|v| v.as_str())
300        .map(|s| s.to_string())
301        .ok_or("Missing hash in SSH response".to_string())
302}
303
304/// Read HEAD from a remote server via SSH pipe
305pub fn read_head_ssh(pipe: &mut SshPipe) -> Result<String, String> {
306    let (status, body) = pipe.request("GET", "/api/v1/transport/head", "")?;
307    check_status(status, &body)?;
308    body.get("head")
309        .and_then(|v| v.as_str())
310        .map(|s| s.to_string())
311        .ok_or("Missing head in SSH response".to_string())
312}
313
314/// Update a branch ref on a remote server via SSH pipe
315pub fn update_ref_ssh(
316    pipe: &mut SshPipe,
317    branch: &str,
318    hash: &str,
319    force: bool,
320) -> Result<(), String> {
321    let path = format!("/api/v1/transport/refs/heads/{}", branch);
322    let body = serde_json::json!({"hash": hash, "force": force}).to_string();
323    let (status, resp) = pipe.request("PUT", &path, &body)?;
324    check_status(status, &resp)
325}
326
327/// Negotiate which objects are needed via SSH pipe
328pub fn negotiate_ssh(
329    pipe: &mut SshPipe,
330    wants: &[String],
331    haves: &[String],
332) -> Result<Vec<ObjectHash>, String> {
333    let body = serde_json::json!({"wants": wants, "haves": haves}).to_string();
334    let (status, resp) = pipe.request("POST", "/api/v1/transport/negotiate", &body)?;
335    check_status(status, &resp)?;
336
337    let needed = resp
338        .get("needed")
339        .and_then(|v| v.as_array())
340        .ok_or("Invalid negotiate response from SSH")?;
341
342    Ok(needed
343        .iter()
344        .filter_map(|v| v.as_str())
345        .map(|s| ObjectHash::from_hex(s.to_string()))
346        .collect())
347}
348
349/// Download objects from a remote server via SSH pipe
350pub fn download_objects_ssh(
351    pipe: &mut SshPipe,
352    local_store: &ObjectStore,
353    hashes: &[ObjectHash],
354) -> Result<usize, String> {
355    let mut count = 0;
356    for hash in hashes {
357        if local_store.exists(hash) {
358            continue;
359        }
360        let path = format!("/api/v1/transport/objects/{}", hash.as_str());
361        let (status, body) = pipe.request("GET", &path, "")?;
362        check_status(status, &body)?;
363
364        let b64_data = body
365            .get("data")
366            .and_then(|v| v.as_str())
367            .ok_or("Missing object data in SSH response")?;
368
369        let compressed = base64_decode(b64_data)?;
370
371        use std::io::Read as _;
372        let mut decoder = flate2::read::ZlibDecoder::new(&compressed[..]);
373        let mut raw = Vec::new();
374        decoder
375            .read_to_end(&mut raw)
376            .map_err(|e| format!("Decompress error: {}", e))?;
377
378        let obj = Object::from_bytes(&raw)?;
379        local_store.write(&obj)?;
380        count += 1;
381    }
382    Ok(count)
383}
384
385/// Upload objects from a local store to a remote server via SSH pipe
386pub fn upload_objects_ssh(
387    pipe: &mut SshPipe,
388    local_store: &ObjectStore,
389    hashes: &[ObjectHash],
390) -> Result<usize, String> {
391    // Upload in batches of 50
392    let mut total = 0;
393    for chunk in hashes.chunks(50) {
394        let mut objects_json = Vec::new();
395        for hash in chunk {
396            let obj = local_store.read(hash)?;
397            let data = obj.to_bytes();
398            use std::io::Write as _;
399            let mut encoder =
400                flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
401            encoder
402                .write_all(&data)
403                .map_err(|e| format!("Compress error: {}", e))?;
404            let compressed = encoder
405                .finish()
406                .map_err(|e| format!("Compress error: {}", e))?;
407            let b64 = base64_encode(&compressed);
408            objects_json.push(serde_json::json!({"hash": hash.as_str(), "data": b64}));
409        }
410
411        let body = serde_json::json!({"objects": objects_json}).to_string();
412        let (status, resp) = pipe.request("POST", "/api/v1/transport/objects", &body)?;
413        check_status(status, &resp)?;
414        total += resp.get("written").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
415    }
416    Ok(total)
417}
418
419// ── Base64 helpers ──
420
421fn base64_encode(data: &[u8]) -> String {
422    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
423    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
424    for chunk in data.chunks(3) {
425        let b0 = chunk[0] as u32;
426        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
427        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
428        let triple = (b0 << 16) | (b1 << 8) | b2;
429        out.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
430        out.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
431        if chunk.len() > 1 {
432            out.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
433        } else {
434            out.push('=');
435        }
436        if chunk.len() > 2 {
437            out.push(CHARS[(triple & 0x3F) as usize] as char);
438        } else {
439            out.push('=');
440        }
441    }
442    out
443}
444
445fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
446    fn val(c: u8) -> Result<u32, String> {
447        match c {
448            b'A'..=b'Z' => Ok((c - b'A') as u32),
449            b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
450            b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
451            b'+' => Ok(62),
452            b'/' => Ok(63),
453            b'=' => Ok(0),
454            _ => Err(format!("Invalid base64 character: {}", c as char)),
455        }
456    }
457    let bytes: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
458    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
459    for chunk in bytes.chunks(4) {
460        if chunk.len() < 4 {
461            return Err("Invalid base64 length".to_string());
462        }
463        let a = val(chunk[0])?;
464        let b = val(chunk[1])?;
465        let c = val(chunk[2])?;
466        let d = val(chunk[3])?;
467        let triple = (a << 18) | (b << 12) | (c << 6) | d;
468        out.push(((triple >> 16) & 0xFF) as u8);
469        if chunk[2] != b'=' {
470            out.push(((triple >> 8) & 0xFF) as u8);
471        }
472        if chunk[3] != b'=' {
473            out.push((triple & 0xFF) as u8);
474        }
475    }
476    Ok(out)
477}
478
479/// Escape a path for use in a shell command
480fn shell_escape(s: &str) -> String {
481    // Use single quotes, escaping any single quotes in the string
482    format!("'{}'", s.replace('\'', "'\\''"))
483}