Skip to main content

nornir_git/
ssh.rs

1//! Pure-Rust git-over-SSH (via `russh`) — **no `ssh` subprocess**, honoring
2//! nornir's no-shellout rule (gix's own SSH transport execs `ssh`, which is why
3//! [`crate::gitio`] refuses SSH and defers here).
4//!
5//! Implemented:
6//! - **`ls-remote`** — connect, authenticate with the nornir ed25519 deploy key,
7//!   run `git-upload-pack '<path>'`, and parse the server's ref advertisement
8//!   (pkt-line) into `(sha, refname)` pairs. The cheap poll primitive.
9//! - **pack transfer (fetch/clone)** — [`connect_upload_pack`] execs
10//!   `git-upload-pack` and exposes the channel as blocking `Read`/`Write`
11//!   (bridged from russh's async channel via `tokio_util`'s `SyncIoBridge`).
12//!   [`crate::gitio`] hands that to gix's blocking git transport
13//!   (`ConnectMode::Process`), so gix drives the full object negotiation and
14//!   pack indexing — no `ssh`/`git` subprocess, no hand-rolled packfile code.
15//!
16//! Not yet: `git-receive-pack` (SSH **push**). The URL- and pkt-line parsers
17//! below are transport-agnostic and unit-tested.
18
19use 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/// A parsed SSH git location.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SshLocation {
29    pub user: String,
30    pub host: String,
31    pub port: u16,
32    /// Repository path as the remote `git-upload-pack` expects it.
33    pub path: String,
34}
35
36/// Parse `git@host:owner/repo(.git)` (scp-like) or
37/// `ssh://[user@]host[:port]/path` into an [`SshLocation`]. Defaults: user
38/// `git`, port `22`.
39pub fn parse_ssh_url(url: &str) -> Result<SshLocation> {
40    if let Some(rest) = url.strip_prefix("ssh://") {
41        // ssh://[user@]host[:port]/path
42        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    // Any other explicit scheme (http://, https://, git://) is not SSH.
67    if url.contains("://") {
68        bail!("`{url}` is not an SSH url (expected host:path or ssh://…)");
69    }
70    // scp-like: [user@]host:path
71    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
89/// Parse a git smart-protocol **ref advertisement** (the bytes a server emits
90/// right after `git-upload-pack`) into `(sha, refname)` pairs.
91///
92/// pkt-line framing: each line is 4 hex digits of length (counting the 4) then
93/// the payload; `0000` is a flush. The first ref line carries capabilities
94/// after a NUL. A leading `# service=…` line (smart-HTTP style) is skipped.
95pub 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            // flush-pkt — end of the advertisement (first section).
106            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        // Drop a trailing newline, then split off capabilities after a NUL.
115        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
130// ── russh client ────────────────────────────────────────────────────────────
131
132struct Client;
133
134impl russh::client::Handler for Client {
135    type Error = russh::Error;
136
137    // We trust the host key (deploy-time TOFU is out of scope here; the bearer
138    // of the deploy key already gates access). Returning Ok(true) accepts it.
139    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
147/// Connect to `loc` and authenticate with the OpenSSH private key at `key_path`.
148/// Shared by [`ls_remote`] and [`connect_upload_pack`]. The returned session
149/// handle owns the connection's I/O task — keep it alive while the channel is in
150/// use.
151async 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
177/// `ls-remote` over SSH: connect to `loc`, run `git-upload-pack`, and return the
178/// advertised refs (reading only the initial ref advertisement — no negotiation).
179pub 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    // Quoting per git's scp-style transport.
186    let cmd = format!("git-upload-pack '{}'", loc.path);
187    channel.exec(true, cmd.as_bytes()).await.context("ssh exec git-upload-pack")?;
188
189    // `git-upload-pack` sends the ref advertisement and then *waits for the
190    // client's wants* — it never EOFs on its own. Send a flush-pkt ("I want
191    // nothing") so it finishes and closes; otherwise the read loop below blocks
192    // forever.
193    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
209/// Blocking wrapper for the sync fetch/poll path: spins a small current-thread
210/// runtime and runs [`ls_remote`].
211pub 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
220/// An authenticated `git-upload-pack` channel exposed as blocking `Read`/`Write`.
221///
222/// russh is async; gix's blocking git transport wants `std::io::{Read, Write}`.
223/// We own a tokio runtime that drives the russh connection and bridge the
224/// channel's two halves with [`SyncIoBridge`]. The runtime + session handle are
225/// retained so the connection stays alive for the duration of the fetch; the
226/// `reader`/`writer` are handed (by `&mut`) to a `gix` `git::Connection`.
227///
228/// IMPORTANT: the blocking `reader`/`writer` calls must run on a thread that is
229/// **not** one of this runtime's workers (else `Handle::block_on` panics) — i.e.
230/// drive gix's fetch on the calling thread, as [`crate::gitio`] does.
231pub struct UploadPack {
232    /// Drives the russh connection; retained for liveness, dropped last.
233    _rt: tokio::runtime::Runtime,
234    /// Connection handle; retained so the channel stays open during the fetch.
235    _session: russh::client::Handle<Client>,
236    /// Blocking reader over the channel's stdout (the pack stream).
237    pub reader: SyncIoBridge<ReadHalf<russh::ChannelStream<russh::client::Msg>>>,
238    /// Blocking writer over the channel's stdin (want/have negotiation).
239    pub writer: SyncIoBridge<WriteHalf<russh::ChannelStream<russh::client::Msg>>>,
240}
241
242/// Open an authenticated `git-upload-pack` channel to `loc` and return it as
243/// blocking I/O for gix's transport. See [`UploadPack`].
244pub 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        // Negotiate git protocol v2 (gix's v1 impl can deadlock). The server only
257        // speaks v2 if this env is set before the command — GitHub honors it.
258        // Best-effort: a server that ignores it falls back to v0, which gix's V2
259        // client also handles. (ls-remote deliberately omits this and reads the
260        // v0 advertisement.)
261        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        // Two refs; first carries a NUL-separated capability list; then flush.
307        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"); // flush
318
319        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}