1use std::path::Path;
20use std::sync::Arc;
21
22use anyhow::{anyhow, bail, Context, Result};
23use tokio::io::{ReadHalf, WriteHalf};
24use tokio_util::io::SyncIoBridge;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SshLocation {
29 pub user: String,
30 pub host: String,
31 pub port: u16,
32 pub path: String,
34}
35
36pub fn parse_ssh_url(url: &str) -> Result<SshLocation> {
40 if let Some(rest) = url.strip_prefix("ssh://") {
41 let (authority, path) = rest
43 .split_once('/')
44 .ok_or_else(|| anyhow!("ssh url `{url}` has no path"))?;
45 let (user, hostport) = match authority.split_once('@') {
46 Some((u, hp)) => (u.to_string(), hp),
47 None => ("git".to_string(), authority),
48 };
49 let (host, port) = match hostport.split_once(':') {
50 Some((h, p)) => (
51 h.to_string(),
52 p.parse().with_context(|| format!("ssh url `{url}` bad port"))?,
53 ),
54 None => (hostport.to_string(), 22),
55 };
56 if host.is_empty() {
57 bail!("ssh url `{url}` has empty host");
58 }
59 return Ok(SshLocation {
60 user,
61 host,
62 port,
63 path: format!("/{path}"),
64 });
65 }
66 if url.contains("://") {
68 bail!("`{url}` is not an SSH url (expected host:path or ssh://…)");
69 }
70 let (userhost, path) = url
72 .split_once(':')
73 .ok_or_else(|| anyhow!("`{url}` is not an SSH url (expected host:path or ssh://…)"))?;
74 let (user, host) = match userhost.split_once('@') {
75 Some((u, h)) => (u.to_string(), h.to_string()),
76 None => ("git".to_string(), userhost.to_string()),
77 };
78 if host.is_empty() || path.is_empty() {
79 bail!("ssh url `{url}` has empty host or path");
80 }
81 Ok(SshLocation {
82 user,
83 host,
84 port: 22,
85 path: path.to_string(),
86 })
87}
88
89pub fn parse_ref_advertisement(mut buf: &[u8]) -> Result<Vec<(String, String)>> {
96 let mut refs = Vec::new();
97 loop {
98 if buf.len() < 4 {
99 break;
100 }
101 let len_hex = std::str::from_utf8(&buf[..4]).context("pkt-line length not utf8")?;
102 let len = usize::from_str_radix(len_hex, 16)
103 .with_context(|| format!("pkt-line length `{len_hex}` not hex"))?;
104 if len == 0 {
105 break;
107 }
108 if len < 4 || len > buf.len() {
109 bail!("pkt-line length {len} out of range (have {} bytes)", buf.len());
110 }
111 let payload = &buf[4..len];
112 buf = &buf[len..];
113
114 let line = payload.strip_suffix(b"\n").unwrap_or(payload);
116 let line = line.split(|&b| b == 0).next().unwrap_or(line);
117 let text = std::str::from_utf8(line).context("ref line not utf8")?;
118 if text.starts_with("# service=") {
119 continue;
120 }
121 if let Some((sha, name)) = text.split_once(' ') {
122 if sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
123 refs.push((sha.to_string(), name.to_string()));
124 }
125 }
126 }
127 Ok(refs)
128}
129
130struct Client;
133
134impl russh::client::Handler for Client {
135 type Error = russh::Error;
136
137 async fn check_server_key(
140 &mut self,
141 _server_public_key: &russh::keys::ssh_key::PublicKey,
142 ) -> Result<bool, Self::Error> {
143 Ok(true)
144 }
145}
146
147async fn connect_session(
152 loc: &SshLocation,
153 key_path: &Path,
154) -> Result<russh::client::Handle<Client>> {
155 use russh::keys::{PrivateKeyWithHashAlg, ssh_key::PrivateKey};
156
157 let key_pem = std::fs::read_to_string(key_path)
158 .with_context(|| format!("read ssh key {}", key_path.display()))?;
159 let key = PrivateKey::from_openssh(&key_pem)
160 .with_context(|| format!("parse OpenSSH key {}", key_path.display()))?;
161
162 let config = Arc::new(russh::client::Config::default());
163 let mut session = russh::client::connect(config, (loc.host.as_str(), loc.port), Client)
164 .await
165 .with_context(|| format!("ssh connect {}:{}", loc.host, loc.port))?;
166
167 let auth = session
168 .authenticate_publickey(&loc.user, PrivateKeyWithHashAlg::new(Arc::new(key), None))
169 .await
170 .context("ssh publickey auth")?;
171 if !auth.success() {
172 bail!("ssh publickey auth rejected for {}@{}", loc.user, loc.host);
173 }
174 Ok(session)
175}
176
177pub async fn ls_remote(loc: &SshLocation, key_path: &Path) -> Result<Vec<(String, String)>> {
180 let session = connect_session(loc, key_path).await?;
181 let mut channel = session
182 .channel_open_session()
183 .await
184 .context("ssh open session channel")?;
185 let cmd = format!("git-upload-pack '{}'", loc.path);
187 channel.exec(true, cmd.as_bytes()).await.context("ssh exec git-upload-pack")?;
188
189 channel
194 .data(&b"0000"[..])
195 .await
196 .context("ssh send flush-pkt")?;
197
198 let mut out: Vec<u8> = Vec::new();
199 while let Some(msg) = channel.wait().await {
200 match msg {
201 russh::ChannelMsg::Data { ref data } => out.extend_from_slice(data),
202 russh::ChannelMsg::Eof | russh::ChannelMsg::ExitStatus { .. } => break,
203 _ => {}
204 }
205 }
206 parse_ref_advertisement(&out)
207}
208
209pub fn ls_remote_blocking(url: &str, key_path: &Path) -> Result<Vec<(String, String)>> {
212 let loc = parse_ssh_url(url)?;
213 let rt = tokio::runtime::Builder::new_current_thread()
214 .enable_all()
215 .build()
216 .context("build runtime for ssh ls-remote")?;
217 rt.block_on(ls_remote(&loc, key_path))
218}
219
220pub struct UploadPack {
232 _rt: tokio::runtime::Runtime,
234 _session: russh::client::Handle<Client>,
236 pub reader: SyncIoBridge<ReadHalf<russh::ChannelStream<russh::client::Msg>>>,
238 pub writer: SyncIoBridge<WriteHalf<russh::ChannelStream<russh::client::Msg>>>,
240}
241
242pub fn connect_upload_pack(loc: &SshLocation, key_path: &Path) -> Result<UploadPack> {
245 let rt = tokio::runtime::Builder::new_multi_thread()
246 .enable_all()
247 .build()
248 .context("build runtime for ssh upload-pack")?;
249
250 let (session, read_half, write_half) = rt.block_on(async {
251 let session = connect_session(loc, key_path).await?;
252 let channel = session
253 .channel_open_session()
254 .await
255 .context("ssh open session channel")?;
256 channel.set_env(false, "GIT_PROTOCOL", "version=2").await.ok();
262 let cmd = format!("git-upload-pack '{}'", loc.path);
263 channel
264 .exec(true, cmd.as_bytes())
265 .await
266 .context("ssh exec git-upload-pack")?;
267 let (r, w) = tokio::io::split(channel.into_stream());
268 Ok::<_, anyhow::Error>((session, r, w))
269 })?;
270
271 let handle = rt.handle().clone();
272 let reader = SyncIoBridge::new_with_handle(read_half, handle.clone());
273 let writer = SyncIoBridge::new_with_handle(write_half, handle);
274 Ok(UploadPack { _rt: rt, _session: session, reader, writer })
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn parse_scp_like() {
283 let l = parse_ssh_url("git@github.com:octocat/Hello-World.git").unwrap();
284 assert_eq!(l.user, "git");
285 assert_eq!(l.host, "github.com");
286 assert_eq!(l.port, 22);
287 assert_eq!(l.path, "octocat/Hello-World.git");
288 }
289
290 #[test]
291 fn parse_ssh_scheme_with_port_and_user() {
292 let l = parse_ssh_url("ssh://deploy@git.example.com:2222/srv/repos/foo.git").unwrap();
293 assert_eq!(l.user, "deploy");
294 assert_eq!(l.host, "git.example.com");
295 assert_eq!(l.port, 2222);
296 assert_eq!(l.path, "/srv/repos/foo.git");
297 }
298
299 #[test]
300 fn reject_https() {
301 assert!(parse_ssh_url("https://github.com/octocat/Hello-World").is_err());
302 }
303
304 #[test]
305 fn parse_advertisement() {
306 let sha1 = "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d";
308 let sha2 = "1111111111111111111111111111111111111111";
309 let line1_body = format!("{sha1} HEAD\0multi_ack symref=HEAD:refs/heads/main\n");
310 let line2_body = format!("{sha2} refs/heads/main\n");
311 let mut buf = Vec::new();
312 for body in [line1_body, line2_body] {
313 let len = body.len() + 4;
314 buf.extend_from_slice(format!("{len:04x}").as_bytes());
315 buf.extend_from_slice(body.as_bytes());
316 }
317 buf.extend_from_slice(b"0000"); let refs = parse_ref_advertisement(&buf).unwrap();
320 assert_eq!(refs.len(), 2);
321 assert_eq!(refs[0], (sha1.to_string(), "HEAD".to_string()));
322 assert_eq!(refs[1], (sha2.to_string(), "refs/heads/main".to_string()));
323 }
324}