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::{BufReader, Read, Write};
24use std::net::{TcpStream, ToSocketAddrs};
25use std::path::PathBuf;
26use std::process::{Child, ChildStdin, ChildStdout};
27use std::sync::Arc;
28
29use crate::command_runner::{
30    system_command_runner, CommandEnvironment, CommandRunner, CommandSpec, CommandStdin,
31    CommandStdio, ShellInvocation,
32};
33use std::time::Duration;
34
35use crate::error::{Error, RemoteUnreadableReason, Result};
36use crate::objects::ObjectId;
37use crate::pkt_line;
38
39pub mod http;
40pub(crate) mod rpc_channel;
41pub mod stateless_http;
42
43/// The Git service a [`Connection`] speaks.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum Service {
46    /// `git-upload-pack` — the server side of a fetch/clone.
47    UploadPack,
48    /// `git-receive-pack` — the server side of a push.
49    ReceivePack,
50}
51
52impl Service {
53    /// The wire service name (`git-upload-pack` / `git-receive-pack`).
54    #[must_use]
55    pub fn wire_name(self) -> &'static str {
56        match self {
57            Service::UploadPack => "git-upload-pack",
58            Service::ReceivePack => "git-receive-pack",
59        }
60    }
61}
62
63/// Options controlling the transport handshake.
64///
65/// The default requests protocol version 0 (the classic advertisement) with no
66/// server options.
67#[derive(Clone, Default)]
68pub struct ConnectOptions {
69    /// Requested protocol version (`0`, `1`, or `2`). The server may downgrade.
70    pub protocol_version: u8,
71    /// `server-option`s to send (protocol v2 `command` arguments / daemon
72    /// extra parameters). Ignored by servers that do not support them.
73    pub server_options: Vec<String>,
74    /// Diagnostic sink for [`crate::diagnostics::Trace::Network`] events.
75    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
76    /// When true with [`Self::diagnostics`], connect paths may emit network traces.
77    pub network_trace: bool,
78}
79
80/// A live, bidirectional pkt-line connection to a Git service, with the
81/// ref/capability advertisement captured during the handshake.
82///
83/// The fetch/push engines read [`Connection::reader`] and write
84/// [`Connection::writer`]; the advertisement accessors expose what the server
85/// announced on connect so the caller can resolve `want`s and pick capabilities
86/// without re-reading the stream.
87pub trait Connection {
88    /// The readable half of the pkt-line stream (server -> client).
89    fn reader(&mut self) -> &mut dyn Read;
90
91    /// The writable half of the pkt-line stream (client -> server).
92    fn writer(&mut self) -> &mut dyn Write;
93
94    /// The refs the server advertised on connect (excluding `HEAD`, the
95    /// `capabilities^{}` carrier, and peeled `^{}` lines). Empty for a protocol
96    /// v2 connection, whose refs are obtained later via `ls-refs`.
97    fn advertised_refs(&self) -> &[(String, ObjectId)];
98
99    /// The capability tokens advertised by the server (from the first ref line
100    /// in v0/v1, or the v2 capability block).
101    fn capabilities(&self) -> &[String];
102
103    /// The target of the server's `HEAD` symref (e.g. `refs/heads/main`), if it
104    /// advertised one.
105    fn head_symref(&self) -> Option<&str>;
106
107    /// The negotiated protocol version (`0`, `1`, or `2`).
108    fn protocol_version(&self) -> u8;
109
110    /// Peeled object ids for annotated tags from the v0/v1 ref advertisement
111    /// (`refs/tags/name` → commit oid), when captured on connect.
112    fn advertised_tag_peel(&self) -> Option<&std::collections::HashMap<String, ObjectId>> {
113        None
114    }
115
116    /// Half-close the write side of the stream, signalling end-of-input to the
117    /// server (the wire equivalent of the CLI's `drop(stdin)`).
118    ///
119    /// Protocol v2 servers run a persistent `serve_loop`: after streaming the
120    /// pack for one `command=fetch` they block reading the next command. A
121    /// streaming transport (ssh subprocess, daemon socket) must therefore close
122    /// its write half once the fetch is complete, or the server never exits and
123    /// teardown (`child.wait()` / socket close) blocks. The default is a no-op
124    /// (v0/v1 connections, where the server closes after the single response).
125    fn finish_send(&mut self) {}
126
127    /// End the session and report how the server side ended, for transports whose
128    /// server runs in this process.
129    ///
130    /// Call it after the exchange, successful or not: when the client hit an EOF or a
131    /// protocol error, the server's own error says why (for example a promised object
132    /// it does not have). The default, for transports whose server is elsewhere, returns
133    /// `Ok(())`.
134    ///
135    /// # Errors
136    ///
137    /// The error the server side stopped with.
138    fn finish(&mut self) -> crate::error::Result<()> {
139        Ok(())
140    }
141
142    /// Send whatever request is still buffered and wait until the server's
143    /// response can be read from [`Self::reader`], giving up once `cancel` fires.
144    ///
145    /// Smart HTTP sends the receive-pack request here and waits for the reply on
146    /// another thread, polling `cancel` meanwhile; a request that was not sent in
147    /// full is abandoned, so the server sees a truncated body. Once the whole
148    /// request was sent, the server may still apply it after a cancel. Streaming
149    /// transports have nothing to wait for, so the default returns at once.
150    ///
151    /// # Errors
152    ///
153    /// [`crate::error::Error::Cancelled`] when `cancel` fires first, or the
154    /// transport error that ended the request.
155    fn wait_for_response(
156        &mut self,
157        cancel: &dyn crate::progress::Cancel,
158    ) -> crate::error::Result<()> {
159        let _ = cancel;
160        Ok(())
161    }
162
163    /// Smart HTTP stateless RPC (`gitprotocol-http`): each POST carries a full
164    /// command body rather than appending to one duplex stream.
165    fn stateless_rpc(&self) -> bool {
166        false
167    }
168}
169
170/// Write the v0/v1 client-side flush pkt-line (`0000`) after reading the ref
171/// advertisement when there are no `want` lines or ref-update commands to send.
172///
173/// Git's upload-pack/receive-pack expect this tail on streaming transports;
174/// closing the write side without it makes the server log "hung up unexpectedly".
175/// No-op for stateless smart-HTTP RPC, where each POST carries a complete body.
176pub(crate) fn send_client_flush(conn: &mut dyn Connection) -> Result<()> {
177    if conn.stateless_rpc() {
178        return Ok(());
179    }
180    conn.writer().write_all(b"0000")?;
181    conn.writer().flush()?;
182    Ok(())
183}
184
185/// A factory that connects to a remote and performs the protocol handshake.
186///
187/// Implementations legitimately perform socket / subprocess / HTTP I/O; the
188/// trait itself makes no such assumption, so embedders can supply their own.
189pub trait Transport {
190    /// Connect to `url` for `service`, performing the handshake described by
191    /// `opts`, and return a live [`Connection`] positioned just past the
192    /// advertisement.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if the URL is malformed for this transport, if the
197    /// connection cannot be established, or if the advertisement is malformed.
198    fn connect(
199        &self,
200        url: &str,
201        service: Service,
202        opts: &ConnectOptions,
203    ) -> Result<Box<dyn Connection>>;
204}
205
206/// The captured ref/capability advertisement for a v0/v1 connection.
207#[derive(Clone, Debug, Default)]
208pub struct Advertisement {
209    /// Advertised refs (name -> oid), excluding `HEAD` and peeled/`capabilities` carriers.
210    pub refs: Vec<(String, ObjectId)>,
211    /// Peeled targets for annotated tags (`refs/tags/name` → object oid).
212    pub advertised_peel: std::collections::HashMap<String, ObjectId>,
213    /// Server capability tokens (split on whitespace from the first ref line).
214    pub capabilities: Vec<String>,
215    /// `HEAD` symref target, if advertised via `symref=HEAD:<target>`.
216    pub head_symref: Option<String>,
217    /// Negotiated protocol version.
218    pub protocol_version: u8,
219}
220
221/// True when the connect-time advertisement shows a completed Git protocol handshake.
222///
223/// An empty repository still advertises server capabilities (v0/v1 zero-oid carrier or a v2
224/// capability block). A completely empty [`Advertisement`] means the transport never spoke Git.
225#[must_use]
226pub(crate) fn remote_handshake_complete(adv: &Advertisement) -> bool {
227    if adv.protocol_version >= 2 {
228        !adv.capabilities.is_empty()
229    } else {
230        !adv.capabilities.is_empty() || !adv.refs.is_empty() || adv.head_symref.is_some()
231    }
232}
233
234#[must_use]
235pub(crate) fn connection_handshake_complete(conn: &dyn Connection) -> bool {
236    if conn.protocol_version() >= 2 {
237        !conn.capabilities().is_empty()
238    } else {
239        !conn.capabilities().is_empty()
240            || !conn.advertised_refs().is_empty()
241            || conn.head_symref().is_some()
242    }
243}
244
245fn verify_ssh_child_after_advertisement(
246    url: &str,
247    child: &mut Child,
248    adv: &Advertisement,
249) -> Result<()> {
250    if remote_handshake_complete(adv) {
251        return Ok(());
252    }
253    let no_handshake = || {
254        Error::remote_unreadable(
255            crate::transport_path::redact_url_for_display(url),
256            RemoteUnreadableReason::NoHandshake,
257        )
258    };
259    for _ in 0..50 {
260        match child.try_wait() {
261            Ok(Some(_)) => return Err(no_handshake()),
262            Ok(None) => std::thread::sleep(Duration::from_millis(10)),
263            Err(e) => {
264                return Err(Error::Message(format!("ssh wait failed: {e}")));
265            }
266        }
267    }
268    Err(no_handshake())
269}
270
271/// Read and parse a v0/v1 (or v2 preamble) ref advertisement from `reader`,
272/// stopping at the first flush packet.
273///
274/// This is the lib-side, hash-width-aware port of the CLI's `read_advertisement`
275/// (`grit/src/fetch_transport.rs`). It records the capability list from the
276/// first ref line, the `HEAD` symref, and the negotiated protocol version, and
277/// skips the `version N`, `capabilities^{}`, and peeled `^{}` carrier lines.
278///
279/// # Errors
280///
281/// Returns an error on I/O failure or if the server sends an `ERR` packet.
282pub fn read_advertisement(reader: &mut dyn Read) -> Result<Advertisement> {
283    let mut adv = Advertisement {
284        protocol_version: 0,
285        ..Default::default()
286    };
287    let mut reader = reader;
288    let mut first_ref = true;
289    // Set once we see a `version 2` line: every subsequent pkt-line up to the
290    // flush is a v2 capability (`agent=…`, `ls-refs=…`, `fetch=…`,
291    // `object-format=…`, `server-option`, …), not a ref. The caller obtains the
292    // refs later via an `ls-refs` command.
293    let mut v2 = false;
294    loop {
295        match pkt_line::read_packet(&mut reader)? {
296            None => break,
297            Some(pkt_line::Packet::Flush) | Some(pkt_line::Packet::Delim) => break,
298            Some(pkt_line::Packet::ResponseEnd) => break,
299            Some(pkt_line::Packet::Data(line)) => {
300                let line = line.trim_end_matches('\n');
301                if let Some(ver) = line.strip_prefix("version ") {
302                    if let Ok(n) = ver.trim().parse::<u8>() {
303                        adv.protocol_version = n;
304                        if n >= 2 {
305                            v2 = true;
306                        }
307                        continue;
308                    }
309                }
310                if v2 {
311                    // v2 capability block: collect every line verbatim and leave
312                    // `advertised_refs` empty. `ERR` is still fatal.
313                    if let Some(msg) = line.strip_prefix("ERR ") {
314                        return Err(Error::Message(format!("remote error: {}", msg.trim_end())));
315                    }
316                    adv.capabilities.push(line.to_string());
317                    continue;
318                }
319                if let Some(msg) = line.strip_prefix("ERR ") {
320                    return Err(Error::Message(format!("remote error: {}", msg.trim_end())));
321                }
322                let Some((oid, refname, caps)) = parse_ref_advertisement_line(line) else {
323                    continue;
324                };
325                if first_ref {
326                    first_ref = false;
327                    adv.capabilities = caps
328                        .split_whitespace()
329                        .map(std::string::ToString::to_string)
330                        .collect();
331                }
332                if refname == "HEAD" {
333                    for cap in caps.split_whitespace() {
334                        if let Some(target) = cap.strip_prefix("symref=HEAD:") {
335                            if crate::refs::is_valid_advertised_symref_target(target) {
336                                adv.head_symref = Some(target.to_string());
337                            }
338                        }
339                    }
340                }
341                // The `0{hex} capabilities^{}` no-refs carrier is not a fetchable ref.
342                if refname == "capabilities^{}" {
343                    continue;
344                }
345                if let Some(base) = refname.strip_suffix("^{}") {
346                    if crate::refs::is_valid_fetch_advertised_ref(base) {
347                        adv.advertised_peel.insert(base.to_string(), oid);
348                    }
349                    continue;
350                }
351                if refname == "HEAD" {
352                    continue;
353                }
354                if crate::refs::is_valid_fetch_advertised_ref(&refname) {
355                    adv.refs.push((refname, oid));
356                }
357            }
358        }
359    }
360    Ok(adv)
361}
362
363/// Parse one ref-advertisement line: `<oid-hex> <refname>[\0<caps>]`.
364///
365/// Hash-width aware: the OID is the leading hex run (40 chars for SHA-1, 64 for
366/// SHA-256), so SHA-256 advertisements parse correctly. Returns `None` for
367/// non-ref lines (e.g. `shallow <oid>`).
368fn parse_ref_advertisement_line(line: &str) -> Option<(ObjectId, String, &str)> {
369    let line = line.trim_end_matches('\n');
370    // The OID is the maximal leading run of hex digits.
371    let hex_len = line
372        .as_bytes()
373        .iter()
374        .take_while(|b| b.is_ascii_hexdigit())
375        .count();
376    if hex_len != 40 && hex_len != 64 {
377        return None;
378    }
379    let hex = &line[..hex_len];
380    let oid = ObjectId::from_hex(hex).ok()?;
381    let mut rest = line[hex_len..].trim_start();
382    // `git-daemon` uses a single space after the OID; `upload-pack` often uses a tab.
383    rest = rest.trim_start_matches([' ', '\t']);
384    let (refname, caps) = if let Some(i) = rest.find('\0') {
385        (rest[..i].trim(), &rest[i + 1..])
386    } else {
387        (rest.trim(), "")
388    };
389    if refname.is_empty() {
390        return None;
391    }
392    Some((oid, refname.to_string(), caps))
393}
394
395/// Parsed `git://host[:port]/path` (path includes the leading `/`).
396#[derive(Clone, Debug)]
397pub struct GitDaemonUrl {
398    /// Host name or IP literal.
399    pub host: String,
400    /// TCP port (defaults to 9418).
401    pub port: u16,
402    /// Repository path on the daemon (with leading `/`).
403    pub path: String,
404}
405
406/// Parse a `git://host[:port]/path` URL for the native daemon transport.
407///
408/// Lifted from the CLI's `git_daemon_url::parse_git_url`. Supports bracketed
409/// IPv6 literals and defaults the port to 9418.
410///
411/// # Errors
412///
413/// Returns an error if the URL is not `git://`, has an empty host, or is missing
414/// a repository path.
415pub fn parse_git_url(url: &str) -> Result<GitDaemonUrl> {
416    let rest = url
417        .strip_prefix("git://")
418        .ok_or_else(|| Error::Message(format!("not a git:// URL: {url}")))?;
419    let (authority, path_part) = rest
420        .find('/')
421        .map(|i| (&rest[..i], &rest[i..]))
422        .unwrap_or((rest, "/"));
423    if path_part.is_empty() || path_part == "/" {
424        return Err(Error::Message(
425            "git:// URL missing repository path".to_owned(),
426        ));
427    }
428    let path = path_part.to_string();
429    let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
430        let end = stripped
431            .find(']')
432            .ok_or_else(|| Error::Message(format!("invalid git:// authority: {authority}")))?;
433        let host = stripped[..end].to_string();
434        let after = &stripped[end + 1..];
435        let port = if let Some(p) = after.strip_prefix(':') {
436            p.parse::<u16>()
437                .map_err(|_| Error::Message(format!("invalid port in git:// URL: {url}")))?
438        } else {
439            9418
440        };
441        (host, port)
442    } else if let Some((h, p)) = authority.rsplit_once(':') {
443        let h = h.trim_end_matches(':');
444        if p.is_empty() {
445            (h.to_string(), 9418)
446        } else if p.chars().all(|c| c.is_ascii_digit()) {
447            (
448                h.to_string(),
449                p.parse::<u16>()
450                    .map_err(|_| Error::Message(format!("invalid port in git:// URL: {url}")))?,
451            )
452        } else {
453            (authority.to_string(), 9418)
454        }
455    } else {
456        (authority.to_string(), 9418)
457    };
458    if host.is_empty() {
459        return Err(Error::Message("git:// URL has empty host".to_owned()));
460    }
461    Ok(GitDaemonUrl { host, port, path })
462}
463
464/// A live connection to a Git daemon over a duplex TCP socket.
465///
466/// Holds the read and write halves of the socket (duplicated file descriptors of
467/// the same connection) plus the advertisement read on connect.
468pub struct GitDaemonConnection {
469    reader: BufReader<TcpStream>,
470    writer: TcpStream,
471    adv: Advertisement,
472}
473
474impl Connection for GitDaemonConnection {
475    fn reader(&mut self) -> &mut dyn Read {
476        &mut self.reader
477    }
478
479    fn writer(&mut self) -> &mut dyn Write {
480        &mut self.writer
481    }
482
483    fn advertised_refs(&self) -> &[(String, ObjectId)] {
484        &self.adv.refs
485    }
486
487    fn capabilities(&self) -> &[String] {
488        &self.adv.capabilities
489    }
490
491    fn head_symref(&self) -> Option<&str> {
492        self.adv.head_symref.as_deref()
493    }
494
495    fn protocol_version(&self) -> u8 {
496        self.adv.protocol_version
497    }
498
499    fn advertised_tag_peel(&self) -> Option<&std::collections::HashMap<String, ObjectId>> {
500        Some(&self.adv.advertised_peel)
501    }
502
503    fn finish_send(&mut self) {
504        // Signal EOF to the daemon's upload-pack so a v2 `serve_loop` exits after
505        // the fetch instead of blocking for another command. Best-effort.
506        let _ = self.writer.shutdown(std::net::Shutdown::Write);
507    }
508}
509
510/// The native `git://` daemon transport.
511///
512/// Connects over TCP, writes the daemon request line (`git-upload-pack
513/// <path>\0host=<host>\0[version=N\0]`), and reads the ref advertisement,
514/// exposing the socket as a [`Connection`]. Lifted from the CLI's
515/// `git_daemon_url::connect_git_daemon_upload_pack`.
516#[derive(Clone, Debug, Default)]
517pub struct GitDaemonTransport {
518    /// Connect timeout. `None` blocks per the OS default.
519    pub connect_timeout: Option<Duration>,
520    /// Read/write timeout for the established socket.
521    pub io_timeout: Option<Duration>,
522}
523
524impl GitDaemonTransport {
525    /// A transport with the CLI's default timeouts (30s connect, 600s I/O).
526    #[must_use]
527    pub fn new() -> Self {
528        Self {
529            connect_timeout: Some(Duration::from_secs(30)),
530            io_timeout: Some(Duration::from_secs(600)),
531        }
532    }
533
534    fn write_request(
535        &self,
536        stream_w: &mut TcpStream,
537        url: &GitDaemonUrl,
538        service: Service,
539        opts: &ConnectOptions,
540    ) -> Result<()> {
541        let virtual_host = format!("{}:{}", url.host, url.port);
542        let mut inner: Vec<u8> = Vec::new();
543        inner.extend_from_slice(service.wire_name().as_bytes());
544        inner.push(b' ');
545        inner.extend_from_slice(url.path.as_bytes());
546        inner.push(0);
547        inner.extend_from_slice(b"host=");
548        inner.extend_from_slice(virtual_host.as_bytes());
549        inner.push(0);
550        if opts.protocol_version > 0 {
551            // The daemon's extra-parameters block is introduced by an extra NUL.
552            inner.push(0);
553            inner.extend_from_slice(format!("version={}\0", opts.protocol_version).as_bytes());
554        }
555        pkt_line::write_packet_raw(stream_w, &inner)?;
556        stream_w.flush()?;
557        Ok(())
558    }
559}
560
561impl Transport for GitDaemonTransport {
562    fn connect(
563        &self,
564        url: &str,
565        service: Service,
566        opts: &ConnectOptions,
567    ) -> Result<Box<dyn Connection>> {
568        crate::net_trace::net_trace!(
569            opts.network_trace,
570            opts.diagnostics.as_ref(),
571            "git:// connect {url} (service={}, request protocol v{})",
572            service.wire_name(),
573            opts.protocol_version
574        );
575        let parsed = parse_git_url(url)?;
576        let addr = format!("{}:{}", parsed.host, parsed.port)
577            .to_socket_addrs()
578            .map_err(|e| {
579                Error::remote_unreadable(
580                    url,
581                    RemoteUnreadableReason::Transport {
582                        detail: e.to_string(),
583                    },
584                )
585            })?
586            .next()
587            .ok_or_else(|| {
588                Error::Message(format!(
589                    "no addresses for git://{}:{}",
590                    parsed.host, parsed.port
591                ))
592            })?;
593
594        let stream = match self.connect_timeout {
595            Some(t) => TcpStream::connect_timeout(&addr, t),
596            None => TcpStream::connect(addr),
597        }
598        .map_err(|e| {
599            Error::remote_unreadable(
600                url,
601                RemoteUnreadableReason::Transport {
602                    detail: e.to_string(),
603                },
604            )
605        })?;
606        if let Some(t) = self.io_timeout {
607            let _ = stream.set_read_timeout(Some(t));
608            let _ = stream.set_write_timeout(Some(t));
609        }
610
611        let mut writer = stream
612            .try_clone()
613            .map_err(|e| Error::Message(format!("dup git:// socket: {e}")))?;
614        let broken_connection = |e: Error| match e {
615            Error::Io(io) => Error::remote_unreadable(
616                url,
617                RemoteUnreadableReason::Transport {
618                    detail: io.to_string(),
619                },
620            ),
621            other => other,
622        };
623        self.write_request(&mut writer, &parsed, service, opts)
624            .map_err(broken_connection)?;
625
626        let mut reader = BufReader::new(stream);
627        let adv = read_advertisement(&mut reader).map_err(broken_connection)?;
628        if !remote_handshake_complete(&adv) {
629            return Err(Error::remote_unreadable(
630                url,
631                RemoteUnreadableReason::NoHandshake,
632            ));
633        }
634        crate::net_trace::net_trace!(
635            opts.network_trace,
636            opts.diagnostics.as_ref(),
637            "git:// connected: protocol v{}, {} ref(s) advertised",
638            adv.protocol_version,
639            adv.refs.len()
640        );
641
642        Ok(Box::new(GitDaemonConnection {
643            reader,
644            writer,
645            adv,
646        }))
647    }
648}
649
650// ===========================================================================
651// ssh transport
652// ===========================================================================
653//
654// Lifted from the CLI's `ssh_transport` (`grit/src/ssh_transport.rs`): the
655// scp-style / `ssh://` / `git+ssh://` URL parser (matching the behavior of Git's
656// `connect.c` `parse_connect_url`/`host_end`/`get_host_and_port`) and the
657// `GIT_SSH_COMMAND` / `GIT_SSH` subprocess spawn. The remote command is the
658// usual `git-upload-pack '<path>'`, shell-quoted exactly like Git's
659// `sq_quote_buf`.
660//
661// The CLI's plink/putty variant detection and `ssh -G` probe are intentionally
662// *not* ported here: this is the embedder-facing core, and OpenSSH `-p <port>`
663// covers the common case. The ssh program/command is pluggable via
664// [`SshTransport::ssh_command`] so embedders never depend on process globals.
665//
666// Spawning a subprocess for ssh is correct (ssh is not git); the no-process
667// rule is about the *public API shape* (no argv/stdout/global-config
668// assumptions), which the [`Transport`]/[`Connection`] traits honor.
669
670/// A parsed SSH remote (scp-style `host:path`, `ssh://`, or `git+ssh://`).
671///
672/// `ssh_host` is the `user@host` token passed to the ssh program (brackets
673/// already stripped for IPv6 literals); `path` is the repository path sent to
674/// the remote `git-upload-pack`.
675#[derive(Clone, Debug, PartialEq, Eq)]
676pub struct SshUrl {
677    /// Host (and optional `user@`) as passed to ssh, with IPv6 brackets removed.
678    pub ssh_host: String,
679    /// Repository path on the remote (passed to `git-upload-pack`).
680    pub path: String,
681    /// Whether the URL was scp-style (`host:path`) rather than `ssh://`.
682    pub scp_style: bool,
683    /// Numeric port (`ssh://host:port/...` or `[host:port]:path`), if any.
684    pub port: Option<String>,
685}
686
687/// True when `url` is an SSH transport address (`ssh://`, `git+ssh://`, or
688/// scp-style `host:path`) rather than a plain local path.
689///
690/// Mirrors Git's `url_is_local_not_ssh` (`connect.c`): a string is local unless
691/// it is `host:path` with no `/` before the first `:`.
692#[must_use]
693pub fn is_ssh_url(url: &str) -> bool {
694    let u = url.trim();
695    if u.starts_with("ext::") {
696        return false;
697    }
698    if u.starts_with("ssh://") || u.starts_with("git+ssh://") {
699        return true;
700    }
701    if u.contains("://") {
702        return false;
703    }
704    !crate::transport_path::url_is_local_not_ssh(u)
705}
706
707/// Parse and validate `url` as Git would for SSH (scp-style, `ssh://`, or
708/// `git+ssh://`).
709///
710/// Lifted verbatim from the CLI's `ssh_transport::parse_ssh_url`, a faithful
711/// port of Git's `connect.c` URL parsing (bracketed IPv6, `user@host:port`, the
712/// `~`-home path tweak, percent-decoding of `ssh://` paths).
713///
714/// # Errors
715///
716/// Returns an error if the URL has an empty host or path, the host starts with
717/// `-`, or a percent-escape is malformed.
718pub fn parse_ssh_url(url: &str) -> Result<SshUrl> {
719    let u = url.trim();
720    if let Some(rest) = u.strip_prefix("git+ssh://") {
721        return parse_ssh_url_form(rest);
722    }
723    if let Some(rest) = u.strip_prefix("ssh://") {
724        return parse_ssh_url_form(rest);
725    }
726    parse_scp_style(u)
727}
728
729fn parse_ssh_url_form(rest: &str) -> Result<SshUrl> {
730    let after_slashes = rest.strip_prefix("//").unwrap_or(rest);
731    let (authority, path_with_sep) = split_ssh_authority_and_path(after_slashes);
732    let (user_host, port) = parse_authority_host_port(authority)?;
733    if user_host.starts_with('-') {
734        return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
735    }
736    // Git: for PROTO_SSH, if `path[1] == '~'`, advance past the leading separator
737    // so `ssh://host/~repo` yields `~repo` (server-side home-dir expansion).
738    let path_after_tilde = if path_with_sep.as_bytes().get(1) == Some(&b'~') {
739        &path_with_sep[1..]
740    } else {
741        path_with_sep.as_str()
742    };
743    let path = normalize_ssh_url_path(path_after_tilde)?;
744    Ok(SshUrl {
745        ssh_host: user_host,
746        path,
747        scp_style: false,
748        port,
749    })
750}
751
752/// Split `host/path` into `(authority, path_including_leading_slash)`.
753fn split_ssh_authority_and_path(s: &str) -> (&str, String) {
754    let mut depth = 0usize;
755    for (i, ch) in s.char_indices() {
756        match ch {
757            '[' => depth += 1,
758            ']' => depth = depth.saturating_sub(1),
759            '/' if depth == 0 => return (&s[..i], s[i..].to_string()),
760            _ => {}
761        }
762    }
763    (s, String::new())
764}
765
766/// Result of Git's `host_end()` (`connect.c`) with `removebrackets`.
767struct HostEnd {
768    host: String,
769    rest: String,
770    bracketed: bool,
771}
772
773/// Faithful port of Git's `host_end()` (`connect.c`) with `removebrackets = 1`.
774fn host_end_remove_brackets(authority: &str) -> HostEnd {
775    let start_off = match authority.find("@[") {
776        Some(at) => at + 1,
777        None => 0,
778    };
779    let prefix = &authority[..start_off];
780    let start = &authority[start_off..];
781    if let Some(rest) = start.strip_prefix('[') {
782        if let Some(close) = rest.find(']') {
783            let inner = &rest[..close];
784            let after = &rest[close + 1..];
785            return HostEnd {
786                host: format!("{prefix}{inner}"),
787                rest: after.to_string(),
788                bracketed: true,
789            };
790        }
791    }
792    HostEnd {
793        host: authority.to_string(),
794        rest: authority.to_string(),
795        bracketed: false,
796    }
797}
798
799/// Faithful port of Git's `get_host_and_port()` (`connect.c`).
800fn get_host_and_port(he: HostEnd) -> (String, Option<String>) {
801    let HostEnd {
802        host,
803        rest,
804        bracketed,
805    } = he;
806    let Some(ci) = rest.find(':') else {
807        return (host, None);
808    };
809    let tail = &rest[ci + 1..];
810    let is_port = !tail.is_empty()
811        && tail.chars().all(|c| c.is_ascii_digit())
812        && tail.parse::<u32>().is_ok_and(|n| n < 65536);
813    if is_port {
814        let trimmed_host = if bracketed {
815            host
816        } else {
817            host[..ci].to_string()
818        };
819        return (trimmed_host, Some(tail.to_string()));
820    }
821    if tail.is_empty() {
822        let trimmed_host = if bracketed {
823            host
824        } else {
825            host[..ci].to_string()
826        };
827        return (trimmed_host, None);
828    }
829    (host, None)
830}
831
832/// Faithful port of Git's `get_port()` (`connect.c`) fallback.
833fn get_port(host: String) -> (String, Option<String>) {
834    let Some(ci) = host.find(':') else {
835        return (host, None);
836    };
837    let tail = &host[ci + 1..];
838    if !tail.is_empty()
839        && tail.chars().all(|c| c.is_ascii_digit())
840        && tail.parse::<u32>().is_ok_and(|n| n < 65536)
841    {
842        let h = host[..ci].to_string();
843        let p = tail.to_string();
844        return (h, Some(p));
845    }
846    (host, None)
847}
848
849/// Split `authority` into `user@host` (or `host`) and optional port.
850fn parse_authority_host_port(authority: &str) -> Result<(String, Option<String>)> {
851    let auth = authority.trim();
852    if auth.is_empty() {
853        return Err(Error::Message("ssh: empty host".to_owned()));
854    }
855    let (ssh_host, port) = get_host_and_port(host_end_remove_brackets(auth));
856    let (ssh_host, port) = match port {
857        Some(p) => (ssh_host, Some(p)),
858        None => get_port(ssh_host),
859    };
860    if ssh_host.is_empty() {
861        return Err(Error::Message("ssh: empty host".to_owned()));
862    }
863    if ssh_host.starts_with('-') {
864        return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
865    }
866    Ok((ssh_host, port))
867}
868
869fn parse_scp_style(u: &str) -> Result<SshUrl> {
870    let he = host_end_remove_brackets(u);
871    let sep_search_start = if he.bracketed {
872        u.find(']')
873            .map(|i| i + 1)
874            .ok_or_else(|| Error::Message("ssh: malformed host".to_owned()))?
875    } else {
876        0
877    };
878    let rel_colon = u[sep_search_start..]
879        .find(':')
880        .ok_or_else(|| Error::Message("ssh: no ':' in scp-style url".to_owned()))?;
881    let colon_pos = sep_search_start + rel_colon;
882    let host = &u[..colon_pos];
883    let mut path = &u[colon_pos + 1..];
884
885    if host.is_empty() || path.is_empty() {
886        return Err(Error::Message("ssh: empty host or path".to_owned()));
887    }
888    if host.starts_with('-') {
889        return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
890    }
891    if path.as_bytes().get(1) == Some(&b'~') {
892        path = &path[1..];
893    }
894    if path.starts_with('-') {
895        return Err(Error::Message("ssh: path starts with '-'".to_owned()));
896    }
897    let (ssh_host, port) = parse_authority_host_port(host)?;
898    Ok(SshUrl {
899        ssh_host,
900        path: path.to_owned(),
901        scp_style: true,
902        port,
903    })
904}
905
906fn normalize_ssh_url_path(path_part: &str) -> Result<String> {
907    if path_part.is_empty() {
908        return Ok(String::new());
909    }
910    let decoded = percent_decode_path(path_part)?;
911    if decoded.starts_with('-') {
912        return Err(Error::Message("ssh: path starts with '-'".to_owned()));
913    }
914    Ok(decoded)
915}
916
917fn percent_decode_path(path: &str) -> Result<String> {
918    let mut out = String::with_capacity(path.len());
919    let mut chars = path.chars().peekable();
920    while let Some(c) = chars.next() {
921        if c == '%' {
922            let h1 = chars
923                .next()
924                .ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
925            let h2 = chars
926                .next()
927                .ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
928            let byte = u8::from_str_radix(&format!("{h1}{h2}"), 16)
929                .map_err(|_| Error::Message("ssh: bad % escape".to_owned()))?;
930            out.push(byte as char);
931        } else {
932            out.push(c);
933        }
934    }
935    Ok(out)
936}
937
938/// Shell-quote `s` with single quotes like Git's `sq_quote_buf` (`git/quote.c`).
939pub(crate) fn sq_quote_shell_arg(s: &str) -> String {
940    let mut out = String::with_capacity(s.len() + 2);
941    out.push('\'');
942    for ch in s.chars() {
943        match ch {
944            '\'' => out.push_str("'\\''"),
945            '!' => out.push_str("'\\!'"),
946            _ => out.push(ch),
947        }
948    }
949    out.push('\'');
950    out
951}
952
953/// The remote command run on the far side of the ssh connection,
954/// `git-upload-pack '<path>'` (or `<service> '<path>'`).
955fn remote_service_cmd(service: Service, quoted_path: &str) -> String {
956    format!("{} {quoted_path}", service.wire_name())
957}
958
959/// How the [`SshTransport`] invokes ssh.
960///
961/// `Auto` reproduces Git's precedence: `$GIT_SSH_COMMAND` (a shell command
962/// line, run via `sh -c`), else `core.sshCommand` from config, else `$GIT_SSH`
963/// (a program, no shell), else the `ssh` program. Embedders that do not want to
964/// depend on process-global env can pin a [`SshCommand::Program`] or
965/// [`SshCommand::ShellCommand`] explicitly.
966#[derive(Clone, Debug, Default)]
967pub enum SshCommand {
968    /// Resolve from the environment and optional config (see
969    /// [`resolve_effective_ssh_command`]).
970    #[default]
971    Auto,
972    /// A bare program invoked directly (no shell), like Git's `$GIT_SSH`. The
973    /// argv is `[program, <-p port>, host, remote_cmd]`.
974    Program(OsString),
975    /// A shell command line run via `sh -c`, like Git's `$GIT_SSH_COMMAND`. The
976    /// command is appended with `<-p port> host remote_cmd`.
977    ShellCommand(OsString),
978}
979
980impl SshCommand {
981    /// Resolve `Auto` against `env` and optional repo config to a concrete variant.
982    fn resolve(
983        &self,
984        env: &crate::environment::Environment,
985        config: Option<&crate::config::ConfigSet>,
986    ) -> SshCommand {
987        match self {
988            SshCommand::Auto => resolve_effective_ssh_command(env, config),
989            other => other.clone(),
990        }
991    }
992}
993
994/// Git-compatible ssh invocation for wire transports.
995///
996/// Precedence: `GIT_SSH_COMMAND` → `core.sshCommand` → `GIT_SSH` → `ssh`.
997#[must_use]
998pub fn resolve_effective_ssh_command(
999    env: &crate::environment::Environment,
1000    config: Option<&crate::config::ConfigSet>,
1001) -> SshCommand {
1002    if let Some(c) = env.var_os("GIT_SSH_COMMAND").filter(|v| !v.is_empty()) {
1003        return SshCommand::ShellCommand(c);
1004    }
1005    if let Some(cfg) = config {
1006        if let Some(cmd) = cfg.get("core.sshCommand").filter(|s| !s.is_empty()) {
1007            return SshCommand::ShellCommand(OsString::from(cmd));
1008        }
1009    }
1010    if let Some(p) = env.var_os("GIT_SSH").filter(|v| !v.is_empty()) {
1011        return SshCommand::Program(p);
1012    }
1013    SshCommand::Program(OsString::from("ssh"))
1014}
1015
1016/// A live connection to a remote Git service over an ssh subprocess.
1017///
1018/// The child's stdin/stdout are the pkt-line stream; the advertisement is read
1019/// on connect. Dropping the connection closes the pipes (signalling EOF to the
1020/// remote) and reaps the child.
1021pub struct SshConnection {
1022    child: Child,
1023    // `Option` so [`Connection::finish_send`] can drop stdin (sending EOF to the
1024    // remote `git-upload-pack`) without consuming the connection.
1025    writer: Option<ChildStdin>,
1026    reader: BufReader<ChildStdout>,
1027    adv: Advertisement,
1028}
1029
1030impl Connection for SshConnection {
1031    fn reader(&mut self) -> &mut dyn Read {
1032        &mut self.reader
1033    }
1034
1035    fn writer(&mut self) -> &mut dyn Write {
1036        // The trait returns `&mut dyn Write`, so there is no error channel: the
1037        // writer is present until `finish_send` takes it; writing afterward is a
1038        // caller bug.
1039        #[allow(clippy::expect_used)]
1040        self.writer
1041            .as_mut()
1042            .expect("ssh connection writer used after finish_send")
1043    }
1044
1045    fn advertised_refs(&self) -> &[(String, ObjectId)] {
1046        &self.adv.refs
1047    }
1048
1049    fn capabilities(&self) -> &[String] {
1050        &self.adv.capabilities
1051    }
1052
1053    fn head_symref(&self) -> Option<&str> {
1054        self.adv.head_symref.as_deref()
1055    }
1056
1057    fn protocol_version(&self) -> u8 {
1058        self.adv.protocol_version
1059    }
1060
1061    fn advertised_tag_peel(&self) -> Option<&std::collections::HashMap<String, ObjectId>> {
1062        Some(&self.adv.advertised_peel)
1063    }
1064
1065    fn finish_send(&mut self) {
1066        // Dropping the child's stdin closes the pipe, signalling EOF so the
1067        // remote `git-upload-pack` v2 `serve_loop` exits instead of blocking for
1068        // another command (which would hang the `child.wait()` in `Drop`).
1069        self.writer = None;
1070    }
1071}
1072
1073/// Serve a fetch from a local repository over the wire protocol, in-process
1074/// (Git's `file://` transport, which runs `git upload-pack` on the path).
1075///
1076/// The fetch side talks pkt-lines to [`crate::serve::upload_pack`] running on its own
1077/// thread over a pair of pipes, so filtered and promisor fetches from local paths use
1078/// the same exchange as a remote server: filters, the `promisor-remote`
1079/// capability, `want`s of single objects. Only fetches are supported.
1080///
1081/// The served repository is opened with the client's environment as Git's local
1082/// transport passes it to `upload-pack` ([`crate::environment::Environment::for_local_transport`]):
1083/// global and system config apply (an `uploadpack.allowFilter` in `~/.gitconfig`, say),
1084/// while the client's `GIT_DIR`, object directory and `-c` overrides never leak into it.
1085///
1086/// `GIT_NO_LAZY_FETCH` in that environment sets the served side's
1087/// [`ServeOptions::lazy_fetch`](crate::serve::ServeOptions::lazy_fetch), as it does for
1088/// Git's upload-pack; when lazy fetch is on, `promisor_fetcher` (normally the client
1089/// repository's) is what the served repository fetches promised objects with. With it
1090/// off the served repository gets no fetcher and fetches nothing.
1091pub(crate) fn connect_local_upload_pack(
1092    remote_git_dir: &std::path::Path,
1093    service: Service,
1094    opts: &ConnectOptions,
1095    client_env: &crate::environment::Environment,
1096    promisor_fetcher: Option<std::sync::Arc<dyn crate::promisor_fetch::PromisorFetcher>>,
1097) -> Result<Box<dyn Connection>> {
1098    use crate::serve::{ProtocolVersion, ServeOptions};
1099    if service != Service::UploadPack {
1100        return Err(Error::Message(
1101            "local upload-pack connect supports fetch only".to_owned(),
1102        ));
1103    }
1104    let protocol = match opts.protocol_version {
1105        2 => ProtocolVersion::V2,
1106        1 => ProtocolVersion::V1,
1107        _ => ProtocolVersion::V0,
1108    };
1109    let (mut server_input, client_writer) = crate::mem_pipe::mem_pipe();
1110    let (client_reader, mut server_output) = crate::mem_pipe::mem_pipe();
1111    let dir = remote_git_dir.to_path_buf();
1112    let served_env = client_env.for_local_transport();
1113    let server = std::thread::Builder::new()
1114        .name("grit-local-upload-pack".to_owned())
1115        .spawn(
1116            move || -> std::result::Result<(), crate::serve::ServeError> {
1117                let lazy_fetch = ServeOptions::lazy_fetch_from_environment(&served_env);
1118                let repo = crate::repo::Repository::open(
1119                    &dir,
1120                    &crate::environment::RepositoryOptions::with_environment(served_env)
1121                        .for_serving(),
1122                )?;
1123                let config =
1124                    crate::config::ConfigSet::load(repo.environment(), Some(repo.git_dir()), true)
1125                        .unwrap_or_default();
1126                let mut serve = ServeOptions {
1127                    protocol,
1128                    agent: format!("grit/{}", env!("CARGO_PKG_VERSION")),
1129                    hidden_refs: crate::hide_refs::hide_ref_patterns_uploadpack(&config),
1130                    lazy_fetch,
1131                    ..ServeOptions::default()
1132                };
1133                serve.attach_promisor_fetcher(&repo, &config, promisor_fetcher);
1134                crate::serve::upload_pack(&repo, &mut server_input, &mut server_output, &mut serve)
1135                    .map(|_| ())
1136            },
1137        )?;
1138    let mut conn = LocalUploadPackConnection {
1139        writer: Some(client_writer),
1140        reader: Some(BufReader::new(client_reader)),
1141        adv: Advertisement::default(),
1142        server: Some(server),
1143    };
1144    let adv = conn
1145        .reader
1146        .as_mut()
1147        .map(|r| read_advertisement(r))
1148        .transpose();
1149    match adv {
1150        Ok(Some(adv)) if remote_handshake_complete(&adv) => {
1151            conn.adv = adv;
1152            Ok(Box::new(conn))
1153        }
1154        // The server stopped before advertising anything: report why.
1155        other => {
1156            let served = conn.finish_server();
1157            match (served, other) {
1158                (Err(err), _) => Err(err),
1159                (Ok(()), Err(err)) => Err(err),
1160                (Ok(()), Ok(_)) => Err(Error::RemoteUnreadable {
1161                    url: Some(remote_git_dir.display().to_string()),
1162                    reason: RemoteUnreadableReason::NoHandshake,
1163                }),
1164            }
1165        }
1166    }
1167}
1168
1169/// A [`Connection`] to [`crate::serve::upload_pack`] serving a local repository on
1170/// another thread; see [`connect_local_upload_pack`].
1171///
1172/// The two directions are in-memory pipes ([`crate::mem_pipe`]), not OS pipes, so a
1173/// server still writing after the client gave up gets an error, never `SIGPIPE`.
1174struct LocalUploadPackConnection {
1175    /// Client → server pipe. `None` once [`Connection::finish_send`] closed it.
1176    writer: Option<crate::mem_pipe::MemPipeWriter>,
1177    /// Server → client pipe. `None` only while being torn down.
1178    reader: Option<BufReader<crate::mem_pipe::MemPipeReader>>,
1179    adv: Advertisement,
1180    server: Option<std::thread::JoinHandle<std::result::Result<(), crate::serve::ServeError>>>,
1181}
1182
1183impl LocalUploadPackConnection {
1184    /// Close both pipes and wait for the serving thread, returning its error, if any.
1185    ///
1186    /// The client → server side closes first, so a server waiting for another command
1187    /// sees the end of input and stops. Closing the read side makes a server blocked
1188    /// writing a pack fail instead of waiting for a reader that is gone; that failure is
1189    /// caused by this teardown, so it is not reported.
1190    fn finish_server(&mut self) -> Result<()> {
1191        self.writer = None;
1192        self.reader = None;
1193        let Some(server) = self.server.take() else {
1194            return Ok(());
1195        };
1196        match server.join() {
1197            Ok(Ok(())) => Ok(()),
1198            Ok(Err(err)) if stopped_by_closed_pipe(&err) => Ok(()),
1199            Ok(Err(crate::serve::ServeError::Repository(err))) => Err(err),
1200            Ok(Err(err)) => Err(Error::LocalUploadPack(Box::new(err))),
1201            Err(_) => Err(Error::LocalUploadPackPanicked),
1202        }
1203    }
1204}
1205
1206/// Whether `err` is the server failing to write because the client closed its end.
1207fn stopped_by_closed_pipe(err: &crate::serve::ServeError) -> bool {
1208    use crate::serve::ServeError;
1209    match err {
1210        ServeError::Io(e) | ServeError::Repository(Error::Io(e)) => {
1211            e.kind() == std::io::ErrorKind::BrokenPipe
1212        }
1213        ServeError::PackInterrupted(inner) => stopped_by_closed_pipe(inner),
1214        _ => false,
1215    }
1216}
1217
1218impl Connection for LocalUploadPackConnection {
1219    fn reader(&mut self) -> &mut dyn Read {
1220        // The reader lives until drop; the trait has no error channel.
1221        #[allow(clippy::expect_used)]
1222        self.reader
1223            .as_mut()
1224            .expect("local upload-pack reader used after teardown")
1225    }
1226
1227    fn writer(&mut self) -> &mut dyn Write {
1228        // Present until `finish_send` takes it; writing afterward is a caller bug.
1229        #[allow(clippy::expect_used)]
1230        self.writer
1231            .as_mut()
1232            .expect("local upload-pack writer used after finish_send")
1233    }
1234
1235    fn advertised_refs(&self) -> &[(String, ObjectId)] {
1236        &self.adv.refs
1237    }
1238
1239    fn capabilities(&self) -> &[String] {
1240        &self.adv.capabilities
1241    }
1242
1243    fn head_symref(&self) -> Option<&str> {
1244        self.adv.head_symref.as_deref()
1245    }
1246
1247    fn protocol_version(&self) -> u8 {
1248        self.adv.protocol_version
1249    }
1250
1251    fn advertised_tag_peel(&self) -> Option<&std::collections::HashMap<String, ObjectId>> {
1252        Some(&self.adv.advertised_peel)
1253    }
1254
1255    fn finish_send(&mut self) {
1256        self.writer = None;
1257    }
1258
1259    fn finish(&mut self) -> Result<()> {
1260        self.finish_server()
1261    }
1262}
1263
1264impl Drop for LocalUploadPackConnection {
1265    fn drop(&mut self) {
1266        // Callers that want the server's error use `Connection::finish` first; here
1267        // only the thread is reaped.
1268        let _ = self.finish_server();
1269    }
1270}
1271
1272impl Drop for SshConnection {
1273    fn drop(&mut self) {
1274        // Close the write half (child stdin) *before* waiting: a remote blocked
1275        // reading its input — e.g. a `git-receive-pack` still waiting for the
1276        // command list after a client-side-only push decision (non-ff reject,
1277        // up-to-date) sent nothing — only exits once it sees EOF. Dropping the
1278        // `ChildStdin` here signals that EOF; otherwise `child.wait()` would
1279        // deadlock against a process that never terminates. (Fields drop after
1280        // `drop()` returns, i.e. after the wait, so we must close it explicitly.)
1281        self.writer = None;
1282        // Best-effort reap so we don't leak zombies if the caller drops mid-stream.
1283        let _ = self.child.wait();
1284    }
1285}
1286
1287/// The `ssh` transport: spawn `ssh [opts] <host> git-upload-pack '<path>'` and
1288/// expose the child's stdio as a [`Connection`].
1289///
1290/// URL parsing and the `GIT_SSH_COMMAND`/`GIT_SSH`/`ssh` spawn are lifted from
1291/// the CLI's `ssh_transport`. The ssh program is pluggable via [`Self::ssh_command`]
1292/// so embedders can inject their own ssh (or a recording shim) without touching
1293/// process globals; the default ([`SshCommand::Auto`]) reproduces Git's
1294/// precedence.
1295#[derive(Clone)]
1296pub struct SshTransport {
1297    /// How to invoke ssh. Defaults to [`SshCommand::Auto`] (env, then `ssh`).
1298    pub ssh_command: SshCommand,
1299    command_runner: Arc<dyn CommandRunner>,
1300    /// Environment used to resolve [`SshCommand::Auto`] (`GIT_SSH_COMMAND`, `GIT_SSH`).
1301    ssh_env: Arc<crate::environment::Environment>,
1302    /// Repository config consulted for `core.sshCommand` when [`Self::ssh_command`] is `Auto`.
1303    ssh_config: Option<Arc<crate::config::ConfigSet>>,
1304}
1305
1306impl std::fmt::Debug for SshTransport {
1307    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1308        f.debug_struct("SshTransport")
1309            .field("ssh_command", &self.ssh_command)
1310            .finish_non_exhaustive()
1311    }
1312}
1313
1314impl Default for SshTransport {
1315    fn default() -> Self {
1316        Self {
1317            ssh_command: SshCommand::Auto,
1318            command_runner: system_command_runner(),
1319            ssh_env: Arc::new(crate::environment::Environment::empty()),
1320            ssh_config: None,
1321        }
1322    }
1323}
1324
1325impl SshTransport {
1326    /// A transport that resolves ssh from the environment (`GIT_SSH_COMMAND` /
1327    /// `GIT_SSH`), falling back to the `ssh` program — Git's default behavior.
1328    #[must_use]
1329    pub fn new() -> Self {
1330        Self::default()
1331    }
1332
1333    /// Use a custom [`CommandRunner`] for ssh subprocesses (tests and embedders).
1334    #[must_use]
1335    pub fn with_command_runner(mut self, runner: Arc<dyn CommandRunner>) -> Self {
1336        self.command_runner = runner;
1337        self
1338    }
1339
1340    /// Environment consulted when [`Self::ssh_command`] is [`SshCommand::Auto`].
1341    #[must_use]
1342    pub fn with_ssh_environment(mut self, env: Arc<crate::environment::Environment>) -> Self {
1343        self.ssh_env = env;
1344        self
1345    }
1346
1347    /// Config consulted for `core.sshCommand` when [`Self::ssh_command`] is [`SshCommand::Auto`].
1348    #[must_use]
1349    pub fn with_ssh_config(mut self, config: Arc<crate::config::ConfigSet>) -> Self {
1350        self.ssh_config = Some(config);
1351        self
1352    }
1353
1354    /// A transport pinned to a specific ssh *program* (no shell), like
1355    /// `$GIT_SSH`.
1356    #[must_use]
1357    pub fn with_program(program: impl Into<OsString>) -> Self {
1358        Self {
1359            ssh_command: SshCommand::Program(program.into()),
1360            command_runner: system_command_runner(),
1361            ssh_env: Arc::new(crate::environment::Environment::empty()),
1362            ssh_config: None,
1363        }
1364    }
1365
1366    /// A transport pinned to a specific ssh *shell command line* (run via
1367    /// `sh -c`), like `$GIT_SSH_COMMAND`.
1368    #[must_use]
1369    pub fn with_shell_command(command: impl Into<OsString>) -> Self {
1370        Self {
1371            ssh_command: SshCommand::ShellCommand(command.into()),
1372            command_runner: system_command_runner(),
1373            ssh_env: Arc::new(crate::environment::Environment::empty()),
1374            ssh_config: None,
1375        }
1376    }
1377
1378    fn ssh_command_spec(
1379        &self,
1380        spec: &SshUrl,
1381        service: Service,
1382        opts: &ConnectOptions,
1383    ) -> CommandSpec {
1384        let quoted_path = sq_quote_shell_arg(&spec.path);
1385        let remote_cmd = remote_service_cmd(service, &quoted_path);
1386        let port = spec.port.as_deref();
1387
1388        let mut env = CommandEnvironment::inherit_process_only();
1389        if opts.protocol_version > 0 {
1390            env.set.push((
1391                OsString::from("GIT_PROTOCOL"),
1392                OsString::from(format!("version={}", opts.protocol_version)),
1393            ));
1394        }
1395
1396        match self
1397            .ssh_command
1398            .resolve(self.ssh_env.as_ref(), self.ssh_config.as_deref())
1399        {
1400            SshCommand::ShellCommand(cmd) => {
1401                let cmd = cmd.to_string_lossy();
1402                let port_opt = match port {
1403                    Some(p) => format!(" -p {}", shell_words::quote(p)),
1404                    None => String::new(),
1405                };
1406                let script = format!(
1407                    "{cmd}{port_opt} {} {}",
1408                    shell_words::quote(&spec.ssh_host),
1409                    shell_words::quote(&remote_cmd),
1410                );
1411                CommandSpec {
1412                    program: "sh".into(),
1413                    args: Vec::new(),
1414                    shell: Some(ShellInvocation::DashC {
1415                        script,
1416                        argv0: None,
1417                        args: Vec::new(),
1418                    }),
1419                    cwd: None,
1420                    env,
1421                    stdin: CommandStdin::Pipe(Vec::new()),
1422                    stdout: CommandStdio::Pipe,
1423                    stderr: CommandStdio::Inherit,
1424                }
1425            }
1426            SshCommand::Program(prog) => {
1427                let mut args = Vec::new();
1428                if let Some(p) = port {
1429                    args.push(OsString::from("-p"));
1430                    args.push(OsString::from(p));
1431                }
1432                args.push(OsString::from(spec.ssh_host.as_str()));
1433                args.push(OsString::from(remote_cmd.as_str()));
1434                CommandSpec {
1435                    program: PathBuf::from(prog),
1436                    args,
1437                    shell: None,
1438                    cwd: None,
1439                    env,
1440                    stdin: CommandStdin::Pipe(Vec::new()),
1441                    stdout: CommandStdio::Pipe,
1442                    stderr: CommandStdio::Inherit,
1443                }
1444            }
1445            SshCommand::Auto => unreachable!("SshCommand::resolve never yields Auto"),
1446        }
1447    }
1448}
1449
1450impl Transport for SshTransport {
1451    fn connect(
1452        &self,
1453        url: &str,
1454        service: Service,
1455        opts: &ConnectOptions,
1456    ) -> Result<Box<dyn Connection>> {
1457        crate::net_trace::net_trace!(
1458            opts.network_trace,
1459            opts.diagnostics.as_ref(),
1460            "ssh connect {url} (service={}, request protocol v{})",
1461            service.wire_name(),
1462            opts.protocol_version
1463        );
1464        let spec = parse_ssh_url(url)?;
1465        let cmd_spec = self.ssh_command_spec(&spec, service, opts);
1466        let running = self.command_runner.spawn(&cmd_spec).map_err(|e| {
1467            Error::Message(format!("failed to spawn ssh for {}: {e}", spec.ssh_host))
1468        })?;
1469        let (mut child, writer, reader, _stderr) = running
1470            .into_system_parts()
1471            .map_err(|_| Error::Message("ssh child is not backed by a live process".to_owned()))?;
1472        let writer = writer.ok_or_else(|| Error::Message("ssh child has no stdin".to_owned()))?;
1473        let mut reader = BufReader::new(
1474            reader.ok_or_else(|| Error::Message("ssh child has no stdout".to_owned()))?,
1475        );
1476
1477        let adv = read_advertisement(&mut reader)?;
1478        verify_ssh_child_after_advertisement(url, &mut child, &adv)?;
1479        crate::net_trace::net_trace!(
1480            opts.network_trace,
1481            opts.diagnostics.as_ref(),
1482            "ssh connected: protocol v{}, {} ref(s) advertised",
1483            adv.protocol_version,
1484            adv.refs.len()
1485        );
1486
1487        Ok(Box::new(SshConnection {
1488            child,
1489            writer: Some(writer),
1490            reader,
1491            adv,
1492        }))
1493    }
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498    use super::*;
1499    use std::process::Command;
1500
1501    #[test]
1502    fn parse_git_url_defaults_and_ports() {
1503        let u = parse_git_url("git://example.com/repo.git").unwrap();
1504        assert_eq!(u.host, "example.com");
1505        assert_eq!(u.port, 9418);
1506        assert_eq!(u.path, "/repo.git");
1507
1508        let u = parse_git_url("git://example.com:9999/a/b").unwrap();
1509        assert_eq!(u.port, 9999);
1510        assert_eq!(u.path, "/a/b");
1511
1512        let u = parse_git_url("git://[::1]:1234/x").unwrap();
1513        assert_eq!(u.host, "::1");
1514        assert_eq!(u.port, 1234);
1515        assert_eq!(u.path, "/x");
1516
1517        assert!(parse_git_url("https://x/y").is_err());
1518        assert!(parse_git_url("git://host").is_err());
1519    }
1520
1521    #[test]
1522    fn parse_advertisement_line_sha1_and_sha256() {
1523        let sha1 = "1234567890123456789012345678901234567890 refs/heads/main\0caps here";
1524        let (oid, name, caps) = parse_ref_advertisement_line(sha1).unwrap();
1525        assert_eq!(oid.to_hex(), "1234567890123456789012345678901234567890");
1526        assert_eq!(name, "refs/heads/main");
1527        assert_eq!(caps, "caps here");
1528
1529        let hex64 = "0".repeat(64);
1530        let line = format!("{hex64} refs/heads/x");
1531        let (oid, name, caps) = parse_ref_advertisement_line(&line).unwrap();
1532        assert_eq!(oid.to_hex().len(), 64);
1533        assert_eq!(name, "refs/heads/x");
1534        assert_eq!(caps, "");
1535
1536        assert!(parse_ref_advertisement_line("shallow abc").is_none());
1537    }
1538
1539    #[test]
1540    fn read_advertisement_captures_refs_caps_and_symref() {
1541        let mut buf: Vec<u8> = Vec::new();
1542        let main = "1111111111111111111111111111111111111111";
1543        let head = format!("{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2",);
1544        pkt_line::write_line_to_vec(&mut buf, &head).unwrap();
1545        let r = format!("{main} refs/heads/main");
1546        pkt_line::write_line_to_vec(&mut buf, &r).unwrap();
1547        let tag = "2222222222222222222222222222222222222222";
1548        let t = format!("{tag} refs/tags/v1");
1549        pkt_line::write_line_to_vec(&mut buf, &t).unwrap();
1550        let peeled = format!("{main} refs/tags/v1^{{}}");
1551        pkt_line::write_line_to_vec(&mut buf, &peeled).unwrap();
1552        buf.extend_from_slice(b"0000");
1553
1554        let mut cur = std::io::Cursor::new(buf);
1555        let adv = read_advertisement(&mut cur).unwrap();
1556        assert_eq!(adv.head_symref.as_deref(), Some("refs/heads/main"));
1557        assert!(adv.capabilities.iter().any(|c| c == "multi_ack"));
1558        // HEAD, capabilities and peeled lines excluded; main + v1 recorded.
1559        let names: Vec<&str> = adv.refs.iter().map(|(n, _)| n.as_str()).collect();
1560        assert_eq!(names, vec!["refs/heads/main", "refs/tags/v1"]);
1561        let commit = ObjectId::from_hex(main).unwrap();
1562        assert_eq!(
1563            adv.advertised_peel.get("refs/tags/v1"),
1564            Some(&commit),
1565            "peeled tag line must be recorded"
1566        );
1567    }
1568
1569    #[test]
1570    fn read_advertisement_v2_captures_caps_and_no_refs() {
1571        // A v2 advertisement: `version 2`, capability lines, flush — and no refs.
1572        let mut buf: Vec<u8> = Vec::new();
1573        pkt_line::write_line_to_vec(&mut buf, "version 2").unwrap();
1574        pkt_line::write_line_to_vec(&mut buf, "agent=git/2.43.0").unwrap();
1575        pkt_line::write_line_to_vec(&mut buf, "ls-refs=unborn").unwrap();
1576        pkt_line::write_line_to_vec(&mut buf, "fetch=shallow wait-for-done filter").unwrap();
1577        pkt_line::write_line_to_vec(&mut buf, "object-format=sha1").unwrap();
1578        buf.extend_from_slice(b"0000");
1579
1580        let mut cur = std::io::Cursor::new(buf);
1581        let adv = read_advertisement(&mut cur).unwrap();
1582        assert_eq!(adv.protocol_version, 2);
1583        assert!(adv.refs.is_empty(), "v2 advertisement carries no refs");
1584        assert!(adv.capabilities.iter().any(|c| c == "agent=git/2.43.0"));
1585        assert!(adv
1586            .capabilities
1587            .iter()
1588            .any(|c| c == "fetch=shallow wait-for-done filter"));
1589        assert!(adv.capabilities.iter().any(|c| c == "object-format=sha1"));
1590        assert!(adv.head_symref.is_none());
1591    }
1592
1593    #[cfg_attr(
1594        not(feature = "full-tests"),
1595        ignore = "starts system git or is heavy; run with --features full-tests"
1596    )]
1597    #[test]
1598    fn read_advertisement_system_git_empty_bare_upload_pack() {
1599        if Command::new("git").arg("--version").output().is_err() {
1600            eprintln!("SKIP: no system git");
1601            return;
1602        }
1603        let tmp = tempfile::tempdir().expect("tempdir");
1604        let bare = tmp.path().join("empty.git");
1605        let init = Command::new("git")
1606            .args(["init", "-q", "--bare", "-b", "main"])
1607            .arg(&bare)
1608            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1609            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1610            .status()
1611            .expect("git init");
1612        assert!(init.success(), "git init --bare failed");
1613
1614        let out = Command::new("git")
1615            .args([
1616                "-C",
1617                bare.to_str().expect("utf8 path"),
1618                "upload-pack",
1619                "--advertise-refs",
1620                ".",
1621            ])
1622            .output()
1623            .expect("git upload-pack --advertise-refs");
1624        assert!(
1625            out.status.success(),
1626            "upload-pack advertise failed: {}",
1627            String::from_utf8_lossy(&out.stderr)
1628        );
1629        assert!(
1630            !out.stdout.is_empty(),
1631            "empty bare repo must still advertise capabilities"
1632        );
1633
1634        let mut cur = std::io::Cursor::new(out.stdout);
1635        let adv = read_advertisement(&mut cur).expect("parse git upload-pack advertisement");
1636        assert!(
1637            remote_handshake_complete(&adv),
1638            "capabilities must be present on an empty repository"
1639        );
1640        assert!(
1641            !adv.capabilities.is_empty(),
1642            "expected capability tokens, got {:?}",
1643            adv.capabilities
1644        );
1645        assert!(
1646            adv.refs.is_empty(),
1647            "empty bare must not advertise fetchable refs, got {:?}",
1648            adv.refs
1649        );
1650    }
1651
1652    #[test]
1653    fn is_ssh_url_classification() {
1654        assert!(is_ssh_url("ssh://host/repo.git"));
1655        assert!(is_ssh_url("git+ssh://host/repo.git"));
1656        assert!(is_ssh_url("user@host:repo.git"));
1657        assert!(is_ssh_url("host:path/to/repo"));
1658        // Plain local paths and other schemes are not ssh.
1659        assert!(!is_ssh_url("/abs/local/repo"));
1660        assert!(!is_ssh_url("./relative"));
1661        assert!(!is_ssh_url("git://host/repo.git"));
1662        assert!(!is_ssh_url("https://host/repo.git"));
1663        assert!(!is_ssh_url("ext::sh -c foo"));
1664        // `host:path` with a `/` before the `:` is a local path, not ssh.
1665        assert!(!is_ssh_url("./a:b"));
1666        assert!(!is_ssh_url(r"C:\tmp\repo.git"));
1667        assert!(!is_ssh_url("C:/tmp/repo.git"));
1668        assert!(!is_ssh_url(r"\\?\C:\tmp\repo.git"));
1669    }
1670
1671    #[test]
1672    fn parse_scp_style_url() {
1673        let u = parse_ssh_url("git@example.com:my/repo.git").unwrap();
1674        assert_eq!(u.ssh_host, "git@example.com");
1675        assert_eq!(u.path, "my/repo.git");
1676        assert!(u.scp_style);
1677        assert_eq!(u.port, None);
1678    }
1679
1680    #[test]
1681    fn parse_ssh_scheme_url_with_port() {
1682        let u = parse_ssh_url("ssh://git@example.com:2222/srv/repo.git").unwrap();
1683        assert_eq!(u.ssh_host, "git@example.com");
1684        assert_eq!(u.path, "/srv/repo.git");
1685        assert!(!u.scp_style);
1686        assert_eq!(u.port.as_deref(), Some("2222"));
1687    }
1688
1689    #[test]
1690    fn resolve_effective_ssh_command_precedence() {
1691        use crate::config::ConfigSet;
1692        use crate::environment::Environment;
1693
1694        let mut env = Environment::from_vars(
1695            [
1696                ("GIT_SSH_COMMAND", "cmd-from-env"),
1697                ("GIT_SSH", "/bin/git-ssh"),
1698            ]
1699            .map(|(key, value)| (OsString::from(key), OsString::from(value))),
1700            std::path::PathBuf::from("."),
1701        );
1702        let mut cfg = ConfigSet::new();
1703        cfg.add_command_override("core.sshCommand", "cmd-from-config")
1704            .unwrap();
1705        assert!(matches!(
1706            resolve_effective_ssh_command(&env, Some(&cfg)),
1707            SshCommand::ShellCommand(ref c) if c == "cmd-from-env"
1708        ));
1709
1710        std::sync::Arc::make_mut(env.captured_vars.as_mut().unwrap()).remove("GIT_SSH_COMMAND");
1711        assert!(matches!(
1712            resolve_effective_ssh_command(&env, Some(&cfg)),
1713            SshCommand::ShellCommand(ref c) if c == "cmd-from-config"
1714        ));
1715
1716        let empty_cfg = ConfigSet::new();
1717        assert!(matches!(
1718            resolve_effective_ssh_command(&env, Some(&empty_cfg)),
1719            SshCommand::Program(ref p) if p == "/bin/git-ssh"
1720        ));
1721
1722        std::sync::Arc::make_mut(env.captured_vars.as_mut().unwrap()).remove("GIT_SSH");
1723        assert!(matches!(
1724            resolve_effective_ssh_command(&env, None),
1725            SshCommand::Program(ref p) if p == "ssh"
1726        ));
1727    }
1728
1729    #[test]
1730    fn parse_ssh_url_ipv6_and_tilde() {
1731        let u = parse_ssh_url("ssh://git@[::1]:2222/~/repo.git").unwrap();
1732        assert_eq!(u.ssh_host, "git@::1");
1733        assert_eq!(u.port.as_deref(), Some("2222"));
1734        // The `~` home-dir form drops the leading separator.
1735        assert_eq!(u.path, "~/repo.git");
1736
1737        // scp-style bracketed host with embedded port.
1738        let u = parse_ssh_url("[git@host:2200]:repo.git").unwrap();
1739        assert_eq!(u.ssh_host, "git@host");
1740        assert_eq!(u.port.as_deref(), Some("2200"));
1741        assert_eq!(u.path, "repo.git");
1742    }
1743
1744    #[test]
1745    fn parse_ssh_url_rejects_bad_inputs() {
1746        assert!(parse_ssh_url("ssh://-badhost/repo").is_err());
1747        assert!(parse_ssh_url("host:-dashpath").is_err());
1748        assert!(parse_ssh_url("host:").is_err());
1749    }
1750
1751    #[test]
1752    fn remote_handshake_complete_empty_vs_capabilities_carrier() {
1753        let empty = Advertisement::default();
1754        assert!(!remote_handshake_complete(&empty));
1755
1756        let mut empty_repo = Advertisement::default();
1757        empty_repo.capabilities = vec!["multi_ack".to_owned()];
1758        assert!(remote_handshake_complete(&empty_repo));
1759
1760        let mut v2 = Advertisement::default();
1761        v2.protocol_version = 2;
1762        v2.capabilities = vec!["ls-refs".to_owned()];
1763        assert!(remote_handshake_complete(&v2));
1764    }
1765
1766    #[cfg(unix)]
1767    #[test]
1768    fn ssh_connect_failed_subprocess_empty_advertisement_is_remote_unreadable() {
1769        use std::os::unix::fs::PermissionsExt;
1770
1771        let dir = tempfile::tempdir().expect("tempdir");
1772        let script = dir.path().join("fake-ssh-fail.sh");
1773        std::fs::write(&script, b"#!/bin/sh\nexit 255\n").expect("write script");
1774        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod");
1775        let transport = SshTransport::with_program(&script);
1776        let err = match transport.connect(
1777            "git@127.0.0.1:/nope",
1778            Service::UploadPack,
1779            &ConnectOptions::default(),
1780        ) {
1781            Ok(_) => panic!("failed ssh must not yield a connection"),
1782            Err(e) => e,
1783        };
1784        assert!(
1785            matches!(
1786                err,
1787                Error::RemoteUnreadable {
1788                    reason: RemoteUnreadableReason::NoHandshake,
1789                    ..
1790                }
1791            ),
1792            "expected NoHandshake, got {err}"
1793        );
1794    }
1795
1796    #[test]
1797    fn remote_command_is_shell_quoted() {
1798        let cmd = remote_service_cmd(Service::UploadPack, &sq_quote_shell_arg("/srv/repo.git"));
1799        assert_eq!(cmd, "git-upload-pack '/srv/repo.git'");
1800        // A single quote in the path is escaped Git-style.
1801        let q = sq_quote_shell_arg("a'b");
1802        assert_eq!(q, "'a'\\''b'");
1803        // receive-pack uses the matching service name.
1804        let cmd = remote_service_cmd(Service::ReceivePack, &sq_quote_shell_arg("p"));
1805        assert_eq!(cmd, "git-receive-pack 'p'");
1806    }
1807}