Skip to main content

grit_lib/
transport.rs

1//! Embedder-facing transport abstraction for the Git wire protocols.
2//!
3//! This module defines a small, embedder-shaped surface over the bidirectional
4//! pkt-line channel that every Git transport (git://, ssh, http) exposes:
5//!
6//! * [`Transport`] — a factory that, given a URL, a [`Service`] and
7//!   [`ConnectOptions`], performs the protocol handshake and returns a live
8//!   [`Connection`].
9//! * [`Connection`] — the duplex pkt-line stream plus the ref/capability
10//!   advertisement captured on connect. The negotiation engine in
11//!   [`crate::fetch`] drives `want`/`have`/`done` over the connection's reader
12//!   and writer; it never assumes a subprocess or global config.
13//!
14//! Phase 1 ships [`GitDaemonTransport`] (the native `git://` daemon protocol),
15//! lifted from the CLI's `git_daemon_url` connector. `ssh` and `http(s)`
16//! transports are later phases and implement the same traits.
17//!
18//! The advertisement parser is hash-algorithm aware: it reads the leading hex
19//! run of each ref line, so SHA-256 (64-hex) advertisements parse the same way
20//! SHA-1 (40-hex) ones do.
21
22use std::ffi::OsString;
23use std::io::{Read, Write};
24use std::net::{TcpStream, ToSocketAddrs};
25use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
26use std::time::Duration;
27
28use crate::error::{Error, Result};
29use crate::objects::ObjectId;
30use crate::pkt_line;
31
32pub mod http;
33
34/// The Git service a [`Connection`] speaks.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum Service {
37    /// `git-upload-pack` — the server side of a fetch/clone.
38    UploadPack,
39    /// `git-receive-pack` — the server side of a push.
40    ReceivePack,
41}
42
43impl Service {
44    /// The wire service name (`git-upload-pack` / `git-receive-pack`).
45    #[must_use]
46    pub fn wire_name(self) -> &'static str {
47        match self {
48            Service::UploadPack => "git-upload-pack",
49            Service::ReceivePack => "git-receive-pack",
50        }
51    }
52}
53
54/// Options controlling the transport handshake.
55///
56/// The default requests protocol version 0 (the classic advertisement) with no
57/// server options.
58#[derive(Clone, Debug, Default)]
59pub struct ConnectOptions {
60    /// Requested protocol version (`0`, `1`, or `2`). The server may downgrade.
61    pub protocol_version: u8,
62    /// `server-option`s to send (protocol v2 `command` arguments / daemon
63    /// extra parameters). Ignored by servers that do not support them.
64    pub server_options: Vec<String>,
65}
66
67/// A live, bidirectional pkt-line connection to a Git service, with the
68/// ref/capability advertisement captured during the handshake.
69///
70/// The fetch/push engines read [`Connection::reader`] and write
71/// [`Connection::writer`]; the advertisement accessors expose what the server
72/// announced on connect so the caller can resolve `want`s and pick capabilities
73/// without re-reading the stream.
74pub trait Connection {
75    /// The readable half of the pkt-line stream (server -> client).
76    fn reader(&mut self) -> &mut dyn Read;
77
78    /// The writable half of the pkt-line stream (client -> server).
79    fn writer(&mut self) -> &mut dyn Write;
80
81    /// The refs the server advertised on connect (excluding `HEAD`, the
82    /// `capabilities^{}` carrier, and peeled `^{}` lines). Empty for a protocol
83    /// v2 connection, whose refs are obtained later via `ls-refs`.
84    fn advertised_refs(&self) -> &[(String, ObjectId)];
85
86    /// The capability tokens advertised by the server (from the first ref line
87    /// in v0/v1, or the v2 capability block).
88    fn capabilities(&self) -> &[String];
89
90    /// The target of the server's `HEAD` symref (e.g. `refs/heads/main`), if it
91    /// advertised one.
92    fn head_symref(&self) -> Option<&str>;
93
94    /// The negotiated protocol version (`0`, `1`, or `2`).
95    fn protocol_version(&self) -> u8;
96
97    /// Half-close the write side of the stream, signalling end-of-input to the
98    /// server (the wire equivalent of the CLI's `drop(stdin)`).
99    ///
100    /// Protocol v2 servers run a persistent `serve_loop`: after streaming the
101    /// pack for one `command=fetch` they block reading the next command. A
102    /// streaming transport (ssh subprocess, daemon socket) must therefore close
103    /// its write half once the fetch is complete, or the server never exits and
104    /// teardown (`child.wait()` / socket close) blocks. The default is a no-op
105    /// (v0/v1 connections, where the server closes after the single response).
106    fn finish_send(&mut self) {}
107}
108
109/// A factory that connects to a remote and performs the protocol handshake.
110///
111/// Implementations legitimately perform socket / subprocess / HTTP I/O; the
112/// trait itself makes no such assumption, so embedders can supply their own.
113pub trait Transport {
114    /// Connect to `url` for `service`, performing the handshake described by
115    /// `opts`, and return a live [`Connection`] positioned just past the
116    /// advertisement.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if the URL is malformed for this transport, if the
121    /// connection cannot be established, or if the advertisement is malformed.
122    fn connect(
123        &self,
124        url: &str,
125        service: Service,
126        opts: &ConnectOptions,
127    ) -> Result<Box<dyn Connection>>;
128}
129
130/// The captured ref/capability advertisement for a v0/v1 connection.
131#[derive(Clone, Debug, Default)]
132pub struct Advertisement {
133    /// Advertised refs (name -> oid), excluding `HEAD` and peeled/`capabilities` carriers.
134    pub refs: Vec<(String, ObjectId)>,
135    /// Server capability tokens (split on whitespace from the first ref line).
136    pub capabilities: Vec<String>,
137    /// `HEAD` symref target, if advertised via `symref=HEAD:<target>`.
138    pub head_symref: Option<String>,
139    /// Negotiated protocol version.
140    pub protocol_version: u8,
141}
142
143/// Read and parse a v0/v1 (or v2 preamble) ref advertisement from `reader`,
144/// stopping at the first flush packet.
145///
146/// This is the lib-side, hash-width-aware port of the CLI's `read_advertisement`
147/// (`grit/src/fetch_transport.rs`). It records the capability list from the
148/// first ref line, the `HEAD` symref, and the negotiated protocol version, and
149/// skips the `version N`, `capabilities^{}`, and peeled `^{}` carrier lines.
150///
151/// # Errors
152///
153/// Returns an error on I/O failure or if the server sends an `ERR` packet.
154pub fn read_advertisement(reader: &mut dyn Read) -> Result<Advertisement> {
155    let mut adv = Advertisement {
156        protocol_version: 0,
157        ..Default::default()
158    };
159    let mut reader = reader;
160    let mut first_ref = true;
161    // Set once we see a `version 2` line: every subsequent pkt-line up to the
162    // flush is a v2 capability (`agent=…`, `ls-refs=…`, `fetch=…`,
163    // `object-format=…`, `server-option`, …), not a ref. The caller obtains the
164    // refs later via an `ls-refs` command.
165    let mut v2 = false;
166    loop {
167        match pkt_line::read_packet(&mut reader)? {
168            None => break,
169            Some(pkt_line::Packet::Flush) | Some(pkt_line::Packet::Delim) => break,
170            Some(pkt_line::Packet::ResponseEnd) => break,
171            Some(pkt_line::Packet::Data(line)) => {
172                let line = line.trim_end_matches('\n');
173                if let Some(ver) = line.strip_prefix("version ") {
174                    if let Ok(n) = ver.trim().parse::<u8>() {
175                        adv.protocol_version = n;
176                        if n >= 2 {
177                            v2 = true;
178                        }
179                        continue;
180                    }
181                }
182                if v2 {
183                    // v2 capability block: collect every line verbatim and leave
184                    // `advertised_refs` empty. `ERR` is still fatal.
185                    if let Some(msg) = line.strip_prefix("ERR ") {
186                        return Err(Error::Message(format!("remote error: {}", msg.trim_end())));
187                    }
188                    adv.capabilities.push(line.to_string());
189                    continue;
190                }
191                if let Some(msg) = line.strip_prefix("ERR ") {
192                    return Err(Error::Message(format!("remote error: {}", msg.trim_end())));
193                }
194                let Some((oid, refname, caps)) = parse_ref_advertisement_line(line) else {
195                    continue;
196                };
197                if first_ref {
198                    first_ref = false;
199                    adv.capabilities = caps
200                        .split_whitespace()
201                        .map(std::string::ToString::to_string)
202                        .collect();
203                }
204                if refname == "HEAD" {
205                    for cap in caps.split_whitespace() {
206                        if let Some(target) = cap.strip_prefix("symref=HEAD:") {
207                            adv.head_symref = Some(target.to_string());
208                        }
209                    }
210                }
211                // The `0{hex} capabilities^{}` no-refs carrier and peeled `^{}` lines
212                // are not fetchable refs.
213                if refname == "capabilities^{}" || refname.ends_with("^{}") {
214                    continue;
215                }
216                if refname == "HEAD" {
217                    continue;
218                }
219                adv.refs.push((refname, oid));
220            }
221        }
222    }
223    Ok(adv)
224}
225
226/// Parse one ref-advertisement line: `<oid-hex> <refname>[\0<caps>]`.
227///
228/// Hash-width aware: the OID is the leading hex run (40 chars for SHA-1, 64 for
229/// SHA-256), so SHA-256 advertisements parse correctly. Returns `None` for
230/// non-ref lines (e.g. `shallow <oid>`).
231fn parse_ref_advertisement_line(line: &str) -> Option<(ObjectId, String, &str)> {
232    let line = line.trim_end_matches('\n');
233    // The OID is the maximal leading run of hex digits.
234    let hex_len = line
235        .as_bytes()
236        .iter()
237        .take_while(|b| b.is_ascii_hexdigit())
238        .count();
239    if hex_len != 40 && hex_len != 64 {
240        return None;
241    }
242    let hex = &line[..hex_len];
243    let oid = ObjectId::from_hex(hex).ok()?;
244    let mut rest = line[hex_len..].trim_start();
245    // `git-daemon` uses a single space after the OID; `upload-pack` often uses a tab.
246    rest = rest.trim_start_matches([' ', '\t']);
247    let (refname, caps) = if let Some(i) = rest.find('\0') {
248        (rest[..i].trim(), &rest[i + 1..])
249    } else {
250        (rest.trim(), "")
251    };
252    if refname.is_empty() {
253        return None;
254    }
255    Some((oid, refname.to_string(), caps))
256}
257
258/// Parsed `git://host[:port]/path` (path includes the leading `/`).
259#[derive(Clone, Debug)]
260pub struct GitDaemonUrl {
261    /// Host name or IP literal.
262    pub host: String,
263    /// TCP port (defaults to 9418).
264    pub port: u16,
265    /// Repository path on the daemon (with leading `/`).
266    pub path: String,
267}
268
269/// Parse a `git://host[:port]/path` URL for the native daemon transport.
270///
271/// Lifted from the CLI's `git_daemon_url::parse_git_url`. Supports bracketed
272/// IPv6 literals and defaults the port to 9418.
273///
274/// # Errors
275///
276/// Returns an error if the URL is not `git://`, has an empty host, or is missing
277/// a repository path.
278pub fn parse_git_url(url: &str) -> Result<GitDaemonUrl> {
279    let rest = url
280        .strip_prefix("git://")
281        .ok_or_else(|| Error::Message(format!("not a git:// URL: {url}")))?;
282    let (authority, path_part) = rest
283        .find('/')
284        .map(|i| (&rest[..i], &rest[i..]))
285        .unwrap_or((rest, "/"));
286    if path_part.is_empty() || path_part == "/" {
287        return Err(Error::Message(
288            "git:// URL missing repository path".to_owned(),
289        ));
290    }
291    let path = path_part.to_string();
292    let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
293        let end = stripped
294            .find(']')
295            .ok_or_else(|| Error::Message(format!("invalid git:// authority: {authority}")))?;
296        let host = stripped[..end].to_string();
297        let after = &stripped[end + 1..];
298        let port = if let Some(p) = after.strip_prefix(':') {
299            p.parse::<u16>()
300                .map_err(|_| Error::Message(format!("invalid port in git:// URL: {url}")))?
301        } else {
302            9418
303        };
304        (host, port)
305    } else if let Some((h, p)) = authority.rsplit_once(':') {
306        let h = h.trim_end_matches(':');
307        if p.is_empty() {
308            (h.to_string(), 9418)
309        } else if p.chars().all(|c| c.is_ascii_digit()) {
310            (
311                h.to_string(),
312                p.parse::<u16>()
313                    .map_err(|_| Error::Message(format!("invalid port in git:// URL: {url}")))?,
314            )
315        } else {
316            (authority.to_string(), 9418)
317        }
318    } else {
319        (authority.to_string(), 9418)
320    };
321    if host.is_empty() {
322        return Err(Error::Message("git:// URL has empty host".to_owned()));
323    }
324    Ok(GitDaemonUrl { host, port, path })
325}
326
327/// A live connection to a Git daemon over a duplex TCP socket.
328///
329/// Holds the read and write halves of the socket (duplicated file descriptors of
330/// the same connection) plus the advertisement read on connect.
331pub struct GitDaemonConnection {
332    reader: TcpStream,
333    writer: TcpStream,
334    adv: Advertisement,
335}
336
337impl Connection for GitDaemonConnection {
338    fn reader(&mut self) -> &mut dyn Read {
339        &mut self.reader
340    }
341
342    fn writer(&mut self) -> &mut dyn Write {
343        &mut self.writer
344    }
345
346    fn advertised_refs(&self) -> &[(String, ObjectId)] {
347        &self.adv.refs
348    }
349
350    fn capabilities(&self) -> &[String] {
351        &self.adv.capabilities
352    }
353
354    fn head_symref(&self) -> Option<&str> {
355        self.adv.head_symref.as_deref()
356    }
357
358    fn protocol_version(&self) -> u8 {
359        self.adv.protocol_version
360    }
361
362    fn finish_send(&mut self) {
363        // Signal EOF to the daemon's upload-pack so a v2 `serve_loop` exits after
364        // the fetch instead of blocking for another command. Best-effort.
365        let _ = self.writer.shutdown(std::net::Shutdown::Write);
366    }
367}
368
369/// The native `git://` daemon transport.
370///
371/// Connects over TCP, writes the daemon request line (`git-upload-pack
372/// <path>\0host=<host>\0[version=N\0]`), and reads the ref advertisement,
373/// exposing the socket as a [`Connection`]. Lifted from the CLI's
374/// `git_daemon_url::connect_git_daemon_upload_pack`.
375#[derive(Clone, Debug, Default)]
376pub struct GitDaemonTransport {
377    /// Connect timeout. `None` blocks per the OS default.
378    pub connect_timeout: Option<Duration>,
379    /// Read/write timeout for the established socket.
380    pub io_timeout: Option<Duration>,
381}
382
383impl GitDaemonTransport {
384    /// A transport with the CLI's default timeouts (30s connect, 600s I/O).
385    #[must_use]
386    pub fn new() -> Self {
387        Self {
388            connect_timeout: Some(Duration::from_secs(30)),
389            io_timeout: Some(Duration::from_secs(600)),
390        }
391    }
392
393    fn write_request(
394        &self,
395        stream_w: &mut TcpStream,
396        url: &GitDaemonUrl,
397        service: Service,
398        opts: &ConnectOptions,
399    ) -> Result<()> {
400        let virtual_host = format!("{}:{}", url.host, url.port);
401        let mut inner: Vec<u8> = Vec::new();
402        inner.extend_from_slice(service.wire_name().as_bytes());
403        inner.push(b' ');
404        inner.extend_from_slice(url.path.as_bytes());
405        inner.push(0);
406        inner.extend_from_slice(b"host=");
407        inner.extend_from_slice(virtual_host.as_bytes());
408        inner.push(0);
409        if opts.protocol_version > 0 {
410            // The daemon's extra-parameters block is introduced by an extra NUL.
411            inner.push(0);
412            inner.extend_from_slice(format!("version={}\0", opts.protocol_version).as_bytes());
413        }
414        pkt_line::write_packet_raw(stream_w, &inner)?;
415        stream_w.flush()?;
416        Ok(())
417    }
418}
419
420impl Transport for GitDaemonTransport {
421    fn connect(
422        &self,
423        url: &str,
424        service: Service,
425        opts: &ConnectOptions,
426    ) -> Result<Box<dyn Connection>> {
427        crate::net_trace::net_trace!(
428            "git:// connect {url} (service={}, request protocol v{})",
429            service.wire_name(),
430            opts.protocol_version
431        );
432        let parsed = parse_git_url(url)?;
433        let addr = format!("{}:{}", parsed.host, parsed.port)
434            .to_socket_addrs()
435            .map_err(|e| {
436                Error::Message(format!(
437                    "could not resolve git://{}:{}: {e}",
438                    parsed.host, parsed.port
439                ))
440            })?
441            .next()
442            .ok_or_else(|| {
443                Error::Message(format!(
444                    "no addresses for git://{}:{}",
445                    parsed.host, parsed.port
446                ))
447            })?;
448
449        let stream = match self.connect_timeout {
450            Some(t) => TcpStream::connect_timeout(&addr, t),
451            None => TcpStream::connect(addr),
452        }
453        .map_err(|e| {
454            Error::Message(format!(
455                "could not connect to git://{}:{}: {e}",
456                parsed.host, parsed.port
457            ))
458        })?;
459        if let Some(t) = self.io_timeout {
460            let _ = stream.set_read_timeout(Some(t));
461            let _ = stream.set_write_timeout(Some(t));
462        }
463
464        let mut writer = stream
465            .try_clone()
466            .map_err(|e| Error::Message(format!("dup git:// socket: {e}")))?;
467        self.write_request(&mut writer, &parsed, service, opts)?;
468
469        let mut reader = stream;
470        let adv = read_advertisement(&mut reader)?;
471        crate::net_trace::net_trace!(
472            "git:// connected: protocol v{}, {} ref(s) advertised",
473            adv.protocol_version,
474            adv.refs.len()
475        );
476
477        Ok(Box::new(GitDaemonConnection {
478            reader,
479            writer,
480            adv,
481        }))
482    }
483}
484
485// ===========================================================================
486// ssh transport
487// ===========================================================================
488//
489// Lifted from the CLI's `ssh_transport` (`grit/src/ssh_transport.rs`): the
490// scp-style / `ssh://` / `git+ssh://` URL parser (matching the behavior of Git's
491// `connect.c` `parse_connect_url`/`host_end`/`get_host_and_port`) and the
492// `GIT_SSH_COMMAND` / `GIT_SSH` subprocess spawn. The remote command is the
493// usual `git-upload-pack '<path>'`, shell-quoted exactly like Git's
494// `sq_quote_buf`.
495//
496// The CLI's plink/putty variant detection and `ssh -G` probe are intentionally
497// *not* ported here: this is the embedder-facing core, and OpenSSH `-p <port>`
498// covers the common case. The ssh program/command is pluggable via
499// [`SshTransport::ssh_command`] so embedders never depend on process globals.
500//
501// Spawning a subprocess for ssh is correct (ssh is not git); the no-process
502// rule is about the *public API shape* (no argv/stdout/global-config
503// assumptions), which the [`Transport`]/[`Connection`] traits honor.
504
505/// A parsed SSH remote (scp-style `host:path`, `ssh://`, or `git+ssh://`).
506///
507/// `ssh_host` is the `user@host` token passed to the ssh program (brackets
508/// already stripped for IPv6 literals); `path` is the repository path sent to
509/// the remote `git-upload-pack`.
510#[derive(Clone, Debug, PartialEq, Eq)]
511pub struct SshUrl {
512    /// Host (and optional `user@`) as passed to ssh, with IPv6 brackets removed.
513    pub ssh_host: String,
514    /// Repository path on the remote (passed to `git-upload-pack`).
515    pub path: String,
516    /// Whether the URL was scp-style (`host:path`) rather than `ssh://`.
517    pub scp_style: bool,
518    /// Numeric port (`ssh://host:port/...` or `[host:port]:path`), if any.
519    pub port: Option<String>,
520}
521
522/// True when `url` is an SSH transport address (`ssh://`, `git+ssh://`, or
523/// scp-style `host:path`) rather than a plain local path.
524///
525/// Mirrors Git's `url_is_local_not_ssh` (`connect.c`): a string is local unless
526/// it is `host:path` with no `/` before the first `:`.
527#[must_use]
528pub fn is_ssh_url(url: &str) -> bool {
529    let u = url.trim();
530    if u.starts_with("ext::") {
531        return false;
532    }
533    if u.starts_with("ssh://") || u.starts_with("git+ssh://") {
534        return true;
535    }
536    if u.contains("://") {
537        return false;
538    }
539    !url_is_local_not_ssh(u)
540}
541
542/// Git `url_is_local_not_ssh` (`connect.c`): local unless `host:path` with no
543/// `/` before the `:`.
544fn url_is_local_not_ssh(url: &str) -> bool {
545    let colon = url.find(':');
546    let slash = url.find('/');
547    match colon {
548        None => true,
549        Some(ci) => slash.is_some_and(|si| si < ci),
550    }
551}
552
553/// Parse and validate `url` as Git would for SSH (scp-style, `ssh://`, or
554/// `git+ssh://`).
555///
556/// Lifted verbatim from the CLI's `ssh_transport::parse_ssh_url`, a faithful
557/// port of Git's `connect.c` URL parsing (bracketed IPv6, `user@host:port`, the
558/// `~`-home path tweak, percent-decoding of `ssh://` paths).
559///
560/// # Errors
561///
562/// Returns an error if the URL has an empty host or path, the host starts with
563/// `-`, or a percent-escape is malformed.
564pub fn parse_ssh_url(url: &str) -> Result<SshUrl> {
565    let u = url.trim();
566    if let Some(rest) = u.strip_prefix("git+ssh://") {
567        return parse_ssh_url_form(rest);
568    }
569    if let Some(rest) = u.strip_prefix("ssh://") {
570        return parse_ssh_url_form(rest);
571    }
572    parse_scp_style(u)
573}
574
575fn parse_ssh_url_form(rest: &str) -> Result<SshUrl> {
576    let after_slashes = rest.strip_prefix("//").unwrap_or(rest);
577    let (authority, path_with_sep) = split_ssh_authority_and_path(after_slashes);
578    let (user_host, port) = parse_authority_host_port(authority)?;
579    if user_host.starts_with('-') {
580        return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
581    }
582    // Git: for PROTO_SSH, if `path[1] == '~'`, advance past the leading separator
583    // so `ssh://host/~repo` yields `~repo` (server-side home-dir expansion).
584    let path_after_tilde = if path_with_sep.as_bytes().get(1) == Some(&b'~') {
585        &path_with_sep[1..]
586    } else {
587        path_with_sep.as_str()
588    };
589    let path = normalize_ssh_url_path(path_after_tilde)?;
590    Ok(SshUrl {
591        ssh_host: user_host,
592        path,
593        scp_style: false,
594        port,
595    })
596}
597
598/// Split `host/path` into `(authority, path_including_leading_slash)`.
599fn split_ssh_authority_and_path(s: &str) -> (&str, String) {
600    let mut depth = 0usize;
601    for (i, ch) in s.char_indices() {
602        match ch {
603            '[' => depth += 1,
604            ']' => depth = depth.saturating_sub(1),
605            '/' if depth == 0 => return (&s[..i], s[i..].to_string()),
606            _ => {}
607        }
608    }
609    (s, String::new())
610}
611
612/// Result of Git's `host_end()` (`connect.c`) with `removebrackets`.
613struct HostEnd {
614    host: String,
615    rest: String,
616    bracketed: bool,
617}
618
619/// Faithful port of Git's `host_end()` (`connect.c`) with `removebrackets = 1`.
620fn host_end_remove_brackets(authority: &str) -> HostEnd {
621    let start_off = match authority.find("@[") {
622        Some(at) => at + 1,
623        None => 0,
624    };
625    let prefix = &authority[..start_off];
626    let start = &authority[start_off..];
627    if let Some(rest) = start.strip_prefix('[') {
628        if let Some(close) = rest.find(']') {
629            let inner = &rest[..close];
630            let after = &rest[close + 1..];
631            return HostEnd {
632                host: format!("{prefix}{inner}"),
633                rest: after.to_string(),
634                bracketed: true,
635            };
636        }
637    }
638    HostEnd {
639        host: authority.to_string(),
640        rest: authority.to_string(),
641        bracketed: false,
642    }
643}
644
645/// Faithful port of Git's `get_host_and_port()` (`connect.c`).
646fn get_host_and_port(he: HostEnd) -> (String, Option<String>) {
647    let HostEnd {
648        host,
649        rest,
650        bracketed,
651    } = he;
652    let Some(ci) = rest.find(':') else {
653        return (host, None);
654    };
655    let tail = &rest[ci + 1..];
656    let is_port = !tail.is_empty()
657        && tail.chars().all(|c| c.is_ascii_digit())
658        && tail.parse::<u32>().is_ok_and(|n| n < 65536);
659    if is_port {
660        let trimmed_host = if bracketed {
661            host
662        } else {
663            host[..ci].to_string()
664        };
665        return (trimmed_host, Some(tail.to_string()));
666    }
667    if tail.is_empty() {
668        let trimmed_host = if bracketed {
669            host
670        } else {
671            host[..ci].to_string()
672        };
673        return (trimmed_host, None);
674    }
675    (host, None)
676}
677
678/// Faithful port of Git's `get_port()` (`connect.c`) fallback.
679fn get_port(host: String) -> (String, Option<String>) {
680    let Some(ci) = host.find(':') else {
681        return (host, None);
682    };
683    let tail = &host[ci + 1..];
684    if !tail.is_empty()
685        && tail.chars().all(|c| c.is_ascii_digit())
686        && tail.parse::<u32>().is_ok_and(|n| n < 65536)
687    {
688        let h = host[..ci].to_string();
689        let p = tail.to_string();
690        return (h, Some(p));
691    }
692    (host, None)
693}
694
695/// Split `authority` into `user@host` (or `host`) and optional port.
696fn parse_authority_host_port(authority: &str) -> Result<(String, Option<String>)> {
697    let auth = authority.trim();
698    if auth.is_empty() {
699        return Err(Error::Message("ssh: empty host".to_owned()));
700    }
701    let (ssh_host, port) = get_host_and_port(host_end_remove_brackets(auth));
702    let (ssh_host, port) = match port {
703        Some(p) => (ssh_host, Some(p)),
704        None => get_port(ssh_host),
705    };
706    if ssh_host.is_empty() {
707        return Err(Error::Message("ssh: empty host".to_owned()));
708    }
709    if ssh_host.starts_with('-') {
710        return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
711    }
712    Ok((ssh_host, port))
713}
714
715fn parse_scp_style(u: &str) -> Result<SshUrl> {
716    let he = host_end_remove_brackets(u);
717    let sep_search_start = if he.bracketed {
718        u.find(']')
719            .map(|i| i + 1)
720            .ok_or_else(|| Error::Message("ssh: malformed host".to_owned()))?
721    } else {
722        0
723    };
724    let rel_colon = u[sep_search_start..]
725        .find(':')
726        .ok_or_else(|| Error::Message("ssh: no ':' in scp-style url".to_owned()))?;
727    let colon_pos = sep_search_start + rel_colon;
728    let host = &u[..colon_pos];
729    let mut path = &u[colon_pos + 1..];
730
731    if host.is_empty() || path.is_empty() {
732        return Err(Error::Message("ssh: empty host or path".to_owned()));
733    }
734    if host.starts_with('-') {
735        return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
736    }
737    if path.as_bytes().get(1) == Some(&b'~') {
738        path = &path[1..];
739    }
740    if path.starts_with('-') {
741        return Err(Error::Message("ssh: path starts with '-'".to_owned()));
742    }
743    let (ssh_host, port) = parse_authority_host_port(host)?;
744    Ok(SshUrl {
745        ssh_host,
746        path: path.to_owned(),
747        scp_style: true,
748        port,
749    })
750}
751
752fn normalize_ssh_url_path(path_part: &str) -> Result<String> {
753    if path_part.is_empty() {
754        return Ok(String::new());
755    }
756    let decoded = percent_decode_path(path_part)?;
757    if decoded.starts_with('-') {
758        return Err(Error::Message("ssh: path starts with '-'".to_owned()));
759    }
760    Ok(decoded)
761}
762
763fn percent_decode_path(path: &str) -> Result<String> {
764    let mut out = String::with_capacity(path.len());
765    let mut chars = path.chars().peekable();
766    while let Some(c) = chars.next() {
767        if c == '%' {
768            let h1 = chars
769                .next()
770                .ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
771            let h2 = chars
772                .next()
773                .ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
774            let byte = u8::from_str_radix(&format!("{h1}{h2}"), 16)
775                .map_err(|_| Error::Message("ssh: bad % escape".to_owned()))?;
776            out.push(byte as char);
777        } else {
778            out.push(c);
779        }
780    }
781    Ok(out)
782}
783
784/// Shell-quote `s` with single quotes like Git's `sq_quote_buf` (`git/quote.c`).
785fn sq_quote_shell_arg(s: &str) -> String {
786    let mut out = String::with_capacity(s.len() + 2);
787    out.push('\'');
788    for ch in s.chars() {
789        match ch {
790            '\'' => out.push_str("'\\''"),
791            '!' => out.push_str("'\\!'"),
792            _ => out.push(ch),
793        }
794    }
795    out.push('\'');
796    out
797}
798
799/// The remote command run on the far side of the ssh connection,
800/// `git-upload-pack '<path>'` (or `<service> '<path>'`).
801fn remote_service_cmd(service: Service, quoted_path: &str) -> String {
802    format!("{} {quoted_path}", service.wire_name())
803}
804
805/// How the [`SshTransport`] invokes ssh.
806///
807/// `Auto` reproduces Git's precedence: `$GIT_SSH_COMMAND` (a shell command
808/// line, run via `sh -c`), else `$GIT_SSH` (a program, no shell), else the
809/// `ssh` program. Embedders that do not want to depend on process-global env
810/// can pin a [`SshCommand::Program`] or [`SshCommand::ShellCommand`] explicitly.
811#[derive(Clone, Debug, Default)]
812pub enum SshCommand {
813    /// Resolve from the environment: `GIT_SSH_COMMAND`, then `GIT_SSH`, then
814    /// the `ssh` program. This is the default and matches Git.
815    #[default]
816    Auto,
817    /// A bare program invoked directly (no shell), like Git's `$GIT_SSH`. The
818    /// argv is `[program, <-p port>, host, remote_cmd]`.
819    Program(OsString),
820    /// A shell command line run via `sh -c`, like Git's `$GIT_SSH_COMMAND`. The
821    /// command is appended with `<-p port> host remote_cmd`.
822    ShellCommand(OsString),
823}
824
825impl SshCommand {
826    /// Resolve `Auto` against the current environment to a concrete variant.
827    fn resolve(&self) -> SshCommand {
828        match self {
829            SshCommand::Auto => {
830                if let Some(c) = std::env::var_os("GIT_SSH_COMMAND").filter(|v| !v.is_empty()) {
831                    SshCommand::ShellCommand(c)
832                } else if let Some(p) = std::env::var_os("GIT_SSH").filter(|v| !v.is_empty()) {
833                    SshCommand::Program(p)
834                } else {
835                    SshCommand::Program(OsString::from("ssh"))
836                }
837            }
838            other => other.clone(),
839        }
840    }
841}
842
843/// A live connection to a remote Git service over an ssh subprocess.
844///
845/// The child's stdin/stdout are the pkt-line stream; the advertisement is read
846/// on connect. Dropping the connection closes the pipes (signalling EOF to the
847/// remote) and reaps the child.
848pub struct SshConnection {
849    child: Child,
850    // `Option` so [`Connection::finish_send`] can drop stdin (sending EOF to the
851    // remote `git-upload-pack`) without consuming the connection.
852    writer: Option<ChildStdin>,
853    reader: ChildStdout,
854    adv: Advertisement,
855}
856
857impl Connection for SshConnection {
858    fn reader(&mut self) -> &mut dyn Read {
859        &mut self.reader
860    }
861
862    fn writer(&mut self) -> &mut dyn Write {
863        // The trait returns `&mut dyn Write`, so there is no error channel: the
864        // writer is present until `finish_send` takes it; writing afterward is a
865        // caller bug.
866        #[allow(clippy::expect_used)]
867        self.writer
868            .as_mut()
869            .expect("ssh connection writer used after finish_send")
870    }
871
872    fn advertised_refs(&self) -> &[(String, ObjectId)] {
873        &self.adv.refs
874    }
875
876    fn capabilities(&self) -> &[String] {
877        &self.adv.capabilities
878    }
879
880    fn head_symref(&self) -> Option<&str> {
881        self.adv.head_symref.as_deref()
882    }
883
884    fn protocol_version(&self) -> u8 {
885        self.adv.protocol_version
886    }
887
888    fn finish_send(&mut self) {
889        // Dropping the child's stdin closes the pipe, signalling EOF so the
890        // remote `git-upload-pack` v2 `serve_loop` exits instead of blocking for
891        // another command (which would hang the `child.wait()` in `Drop`).
892        self.writer = None;
893    }
894}
895
896impl Drop for SshConnection {
897    fn drop(&mut self) {
898        // Close the write half (child stdin) *before* waiting: a remote blocked
899        // reading its input — e.g. a `git-receive-pack` still waiting for the
900        // command list after a client-side-only push decision (non-ff reject,
901        // up-to-date) sent nothing — only exits once it sees EOF. Dropping the
902        // `ChildStdin` here signals that EOF; otherwise `child.wait()` would
903        // deadlock against a process that never terminates. (Fields drop after
904        // `drop()` returns, i.e. after the wait, so we must close it explicitly.)
905        self.writer = None;
906        // Best-effort reap so we don't leak zombies if the caller drops mid-stream.
907        let _ = self.child.wait();
908    }
909}
910
911/// The `ssh` transport: spawn `ssh [opts] <host> git-upload-pack '<path>'` and
912/// expose the child's stdio as a [`Connection`].
913///
914/// URL parsing and the `GIT_SSH_COMMAND`/`GIT_SSH`/`ssh` spawn are lifted from
915/// the CLI's `ssh_transport`. The ssh program is pluggable via [`Self::ssh_command`]
916/// so embedders can inject their own ssh (or a recording shim) without touching
917/// process globals; the default ([`SshCommand::Auto`]) reproduces Git's
918/// precedence.
919#[derive(Clone, Debug, Default)]
920pub struct SshTransport {
921    /// How to invoke ssh. Defaults to [`SshCommand::Auto`] (env, then `ssh`).
922    pub ssh_command: SshCommand,
923}
924
925impl SshTransport {
926    /// A transport that resolves ssh from the environment (`GIT_SSH_COMMAND` /
927    /// `GIT_SSH`), falling back to the `ssh` program — Git's default behavior.
928    #[must_use]
929    pub fn new() -> Self {
930        Self::default()
931    }
932
933    /// A transport pinned to a specific ssh *program* (no shell), like
934    /// `$GIT_SSH`.
935    #[must_use]
936    pub fn with_program(program: impl Into<OsString>) -> Self {
937        Self {
938            ssh_command: SshCommand::Program(program.into()),
939        }
940    }
941
942    /// A transport pinned to a specific ssh *shell command line* (run via
943    /// `sh -c`), like `$GIT_SSH_COMMAND`.
944    #[must_use]
945    pub fn with_shell_command(command: impl Into<OsString>) -> Self {
946        Self {
947            ssh_command: SshCommand::ShellCommand(command.into()),
948        }
949    }
950
951    /// Build and spawn the ssh child for `spec`/`service`, returning the live
952    /// child with piped stdin/stdout.
953    fn spawn(&self, spec: &SshUrl, service: Service, opts: &ConnectOptions) -> Result<Child> {
954        let quoted_path = sq_quote_shell_arg(&spec.path);
955        let remote_cmd = remote_service_cmd(service, &quoted_path);
956        let port = spec.port.as_deref();
957
958        let mut command = match self.ssh_command.resolve() {
959            SshCommand::ShellCommand(cmd) => {
960                // Reproduce Git's `GIT_SSH_COMMAND`: run the command line through
961                // a shell, appending the (shell-quoted) host and remote command.
962                let cmd = cmd.to_string_lossy();
963                let port_opt = match port {
964                    Some(p) => format!(" -p {}", shell_words::quote(p)),
965                    None => String::new(),
966                };
967                let script = format!(
968                    "{cmd}{port_opt} {} {}",
969                    shell_words::quote(&spec.ssh_host),
970                    shell_words::quote(&remote_cmd),
971                );
972                let mut c = Command::new("sh");
973                c.arg("-c").arg(script);
974                c
975            }
976            SshCommand::Program(prog) => {
977                // Reproduce Git's `$GIT_SSH` / default `ssh`: direct argv, no shell.
978                let mut c = Command::new(&prog);
979                if let Some(p) = port {
980                    c.arg("-p").arg(p);
981                }
982                c.arg(&spec.ssh_host).arg(&remote_cmd);
983                c
984            }
985            // `resolve()` never returns `Auto`.
986            SshCommand::Auto => unreachable!("SshCommand::resolve never yields Auto"),
987        };
988
989        // Request the wire protocol version the same way Git does: export
990        // `GIT_PROTOCOL=version=N` into the ssh process environment. OpenSSH
991        // forwards it (Git ships a `SendEnv GIT_PROTOCOL` default) and the remote
992        // `git-upload-pack` reads it to switch to v2; servers that don't see it
993        // fall back to the v0 advertisement, which `read_advertisement` still
994        // parses. Only set it for v1/v2 so a plain v0 request is unchanged.
995        if opts.protocol_version > 0 {
996            command.env("GIT_PROTOCOL", format!("version={}", opts.protocol_version));
997        }
998
999        command
1000            .stdin(Stdio::piped())
1001            .stdout(Stdio::piped())
1002            .stderr(Stdio::inherit())
1003            .spawn()
1004            .map_err(|e| Error::Message(format!("failed to spawn ssh for {}: {e}", spec.ssh_host)))
1005    }
1006}
1007
1008impl Transport for SshTransport {
1009    fn connect(
1010        &self,
1011        url: &str,
1012        service: Service,
1013        opts: &ConnectOptions,
1014    ) -> Result<Box<dyn Connection>> {
1015        crate::net_trace::net_trace!(
1016            "ssh connect {url} (service={}, request protocol v{})",
1017            service.wire_name(),
1018            opts.protocol_version
1019        );
1020        let spec = parse_ssh_url(url)?;
1021        let mut child = self.spawn(&spec, service, opts)?;
1022
1023        let writer = child
1024            .stdin
1025            .take()
1026            .ok_or_else(|| Error::Message("ssh child has no stdin".to_owned()))?;
1027        let mut reader = child
1028            .stdout
1029            .take()
1030            .ok_or_else(|| Error::Message("ssh child has no stdout".to_owned()))?;
1031
1032        let adv = read_advertisement(&mut reader)?;
1033        crate::net_trace::net_trace!(
1034            "ssh connected: protocol v{}, {} ref(s) advertised",
1035            adv.protocol_version,
1036            adv.refs.len()
1037        );
1038
1039        Ok(Box::new(SshConnection {
1040            child,
1041            writer: Some(writer),
1042            reader,
1043            adv,
1044        }))
1045    }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use super::*;
1051
1052    #[test]
1053    fn parse_git_url_defaults_and_ports() {
1054        let u = parse_git_url("git://example.com/repo.git").unwrap();
1055        assert_eq!(u.host, "example.com");
1056        assert_eq!(u.port, 9418);
1057        assert_eq!(u.path, "/repo.git");
1058
1059        let u = parse_git_url("git://example.com:9999/a/b").unwrap();
1060        assert_eq!(u.port, 9999);
1061        assert_eq!(u.path, "/a/b");
1062
1063        let u = parse_git_url("git://[::1]:1234/x").unwrap();
1064        assert_eq!(u.host, "::1");
1065        assert_eq!(u.port, 1234);
1066        assert_eq!(u.path, "/x");
1067
1068        assert!(parse_git_url("https://x/y").is_err());
1069        assert!(parse_git_url("git://host").is_err());
1070    }
1071
1072    #[test]
1073    fn parse_advertisement_line_sha1_and_sha256() {
1074        let sha1 = "1234567890123456789012345678901234567890 refs/heads/main\0caps here";
1075        let (oid, name, caps) = parse_ref_advertisement_line(sha1).unwrap();
1076        assert_eq!(oid.to_hex(), "1234567890123456789012345678901234567890");
1077        assert_eq!(name, "refs/heads/main");
1078        assert_eq!(caps, "caps here");
1079
1080        let hex64 = "0".repeat(64);
1081        let line = format!("{hex64} refs/heads/x");
1082        let (oid, name, caps) = parse_ref_advertisement_line(&line).unwrap();
1083        assert_eq!(oid.to_hex().len(), 64);
1084        assert_eq!(name, "refs/heads/x");
1085        assert_eq!(caps, "");
1086
1087        assert!(parse_ref_advertisement_line("shallow abc").is_none());
1088    }
1089
1090    #[test]
1091    fn read_advertisement_captures_refs_caps_and_symref() {
1092        let mut buf: Vec<u8> = Vec::new();
1093        let main = "1111111111111111111111111111111111111111";
1094        let head = format!("{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2",);
1095        pkt_line::write_line_to_vec(&mut buf, &head).unwrap();
1096        let r = format!("{main} refs/heads/main");
1097        pkt_line::write_line_to_vec(&mut buf, &r).unwrap();
1098        let tag = "2222222222222222222222222222222222222222";
1099        let t = format!("{tag} refs/tags/v1");
1100        pkt_line::write_line_to_vec(&mut buf, &t).unwrap();
1101        let peeled = format!("{main} refs/tags/v1^{{}}");
1102        pkt_line::write_line_to_vec(&mut buf, &peeled).unwrap();
1103        buf.extend_from_slice(b"0000");
1104
1105        let mut cur = std::io::Cursor::new(buf);
1106        let adv = read_advertisement(&mut cur).unwrap();
1107        assert_eq!(adv.head_symref.as_deref(), Some("refs/heads/main"));
1108        assert!(adv.capabilities.iter().any(|c| c == "multi_ack"));
1109        // HEAD, capabilities and peeled lines excluded; main + v1 recorded.
1110        let names: Vec<&str> = adv.refs.iter().map(|(n, _)| n.as_str()).collect();
1111        assert_eq!(names, vec!["refs/heads/main", "refs/tags/v1"]);
1112    }
1113
1114    #[test]
1115    fn read_advertisement_v2_captures_caps_and_no_refs() {
1116        // A v2 advertisement: `version 2`, capability lines, flush — and no refs.
1117        let mut buf: Vec<u8> = Vec::new();
1118        pkt_line::write_line_to_vec(&mut buf, "version 2").unwrap();
1119        pkt_line::write_line_to_vec(&mut buf, "agent=git/2.43.0").unwrap();
1120        pkt_line::write_line_to_vec(&mut buf, "ls-refs=unborn").unwrap();
1121        pkt_line::write_line_to_vec(&mut buf, "fetch=shallow wait-for-done filter").unwrap();
1122        pkt_line::write_line_to_vec(&mut buf, "object-format=sha1").unwrap();
1123        buf.extend_from_slice(b"0000");
1124
1125        let mut cur = std::io::Cursor::new(buf);
1126        let adv = read_advertisement(&mut cur).unwrap();
1127        assert_eq!(adv.protocol_version, 2);
1128        assert!(adv.refs.is_empty(), "v2 advertisement carries no refs");
1129        assert!(adv.capabilities.iter().any(|c| c == "agent=git/2.43.0"));
1130        assert!(adv
1131            .capabilities
1132            .iter()
1133            .any(|c| c == "fetch=shallow wait-for-done filter"));
1134        assert!(adv.capabilities.iter().any(|c| c == "object-format=sha1"));
1135        assert!(adv.head_symref.is_none());
1136    }
1137
1138    #[test]
1139    fn is_ssh_url_classification() {
1140        assert!(is_ssh_url("ssh://host/repo.git"));
1141        assert!(is_ssh_url("git+ssh://host/repo.git"));
1142        assert!(is_ssh_url("user@host:repo.git"));
1143        assert!(is_ssh_url("host:path/to/repo"));
1144        // Plain local paths and other schemes are not ssh.
1145        assert!(!is_ssh_url("/abs/local/repo"));
1146        assert!(!is_ssh_url("./relative"));
1147        assert!(!is_ssh_url("git://host/repo.git"));
1148        assert!(!is_ssh_url("https://host/repo.git"));
1149        assert!(!is_ssh_url("ext::sh -c foo"));
1150        // `host:path` with a `/` before the `:` is a local path, not ssh.
1151        assert!(!is_ssh_url("./a:b"));
1152    }
1153
1154    #[test]
1155    fn parse_scp_style_url() {
1156        let u = parse_ssh_url("git@example.com:my/repo.git").unwrap();
1157        assert_eq!(u.ssh_host, "git@example.com");
1158        assert_eq!(u.path, "my/repo.git");
1159        assert!(u.scp_style);
1160        assert_eq!(u.port, None);
1161    }
1162
1163    #[test]
1164    fn parse_ssh_scheme_url_with_port() {
1165        let u = parse_ssh_url("ssh://git@example.com:2222/srv/repo.git").unwrap();
1166        assert_eq!(u.ssh_host, "git@example.com");
1167        assert_eq!(u.path, "/srv/repo.git");
1168        assert!(!u.scp_style);
1169        assert_eq!(u.port.as_deref(), Some("2222"));
1170    }
1171
1172    #[test]
1173    fn parse_ssh_url_ipv6_and_tilde() {
1174        let u = parse_ssh_url("ssh://git@[::1]:2222/~/repo.git").unwrap();
1175        assert_eq!(u.ssh_host, "git@::1");
1176        assert_eq!(u.port.as_deref(), Some("2222"));
1177        // The `~` home-dir form drops the leading separator.
1178        assert_eq!(u.path, "~/repo.git");
1179
1180        // scp-style bracketed host with embedded port.
1181        let u = parse_ssh_url("[git@host:2200]:repo.git").unwrap();
1182        assert_eq!(u.ssh_host, "git@host");
1183        assert_eq!(u.port.as_deref(), Some("2200"));
1184        assert_eq!(u.path, "repo.git");
1185    }
1186
1187    #[test]
1188    fn parse_ssh_url_rejects_bad_inputs() {
1189        assert!(parse_ssh_url("ssh://-badhost/repo").is_err());
1190        assert!(parse_ssh_url("host:-dashpath").is_err());
1191        assert!(parse_ssh_url("host:").is_err());
1192    }
1193
1194    #[test]
1195    fn remote_command_is_shell_quoted() {
1196        let cmd = remote_service_cmd(Service::UploadPack, &sq_quote_shell_arg("/srv/repo.git"));
1197        assert_eq!(cmd, "git-upload-pack '/srv/repo.git'");
1198        // A single quote in the path is escaped Git-style.
1199        let q = sq_quote_shell_arg("a'b");
1200        assert_eq!(q, "'a'\\''b'");
1201        // receive-pack uses the matching service name.
1202        let cmd = remote_service_cmd(Service::ReceivePack, &sq_quote_shell_arg("p"));
1203        assert_eq!(cmd, "git-receive-pack 'p'");
1204    }
1205}