1use 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
13pub fn is_ssh_url(url: &str) -> bool {
15 url.starts_with("ssh://") || (url.contains('@') && url.contains(':') && !url.contains("://"))
16}
17
18#[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
27pub fn parse_ssh_url(url: &str) -> Result<SshUrl, String> {
33 if let Some(rest) = url.strip_prefix("ssh://") {
34 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 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
91pub struct SshPipe {
93 child: Child,
94 reader: BufReader<std::process::ChildStdout>,
95 writer: BufWriter<std::process::ChildStdin>,
96}
97
98impl SshPipe {
99 pub fn open(parsed: &SshUrl) -> Result<Self, String> {
101 let mut cmd = Command::new("ssh");
102
103 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 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 pub fn open_local(repo_path: &std::path::Path) -> Result<Self, String> {
141 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 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 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 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 pub fn close(&mut self) {
234 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
247fn 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
262pub 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
293pub 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
304pub 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
314pub 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
327pub 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
349pub 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
385pub fn upload_objects_ssh(
387 pipe: &mut SshPipe,
388 local_store: &ObjectStore,
389 hashes: &[ObjectHash],
390) -> Result<usize, String> {
391 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
419fn 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
479fn shell_escape(s: &str) -> String {
481 format!("'{}'", s.replace('\'', "'\\''"))
483}