use std::ffi::OsString;
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, ChildStdout};
use std::sync::Arc;
use crate::command_runner::{
system_command_runner, CommandEnvironment, CommandRunner, CommandSpec, CommandStdin,
CommandStdio, ShellInvocation,
};
use std::time::Duration;
use crate::error::{Error, Result};
use crate::objects::ObjectId;
use crate::pkt_line;
pub mod http;
pub(crate) mod rpc_channel;
pub mod stateless_http;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Service {
UploadPack,
ReceivePack,
}
impl Service {
#[must_use]
pub fn wire_name(self) -> &'static str {
match self {
Service::UploadPack => "git-upload-pack",
Service::ReceivePack => "git-receive-pack",
}
}
}
#[derive(Clone, Default)]
pub struct ConnectOptions {
pub protocol_version: u8,
pub server_options: Vec<String>,
pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
pub network_trace: bool,
}
pub trait Connection {
fn reader(&mut self) -> &mut dyn Read;
fn writer(&mut self) -> &mut dyn Write;
fn advertised_refs(&self) -> &[(String, ObjectId)];
fn capabilities(&self) -> &[String];
fn head_symref(&self) -> Option<&str>;
fn protocol_version(&self) -> u8;
fn advertised_tag_peel(&self) -> Option<&std::collections::HashMap<String, ObjectId>> {
None
}
fn finish_send(&mut self) {}
fn stateless_rpc(&self) -> bool {
false
}
}
pub(crate) fn send_client_flush(conn: &mut dyn Connection) -> Result<()> {
if conn.stateless_rpc() {
return Ok(());
}
conn.writer().write_all(b"0000")?;
conn.writer().flush()?;
Ok(())
}
pub trait Transport {
fn connect(
&self,
url: &str,
service: Service,
opts: &ConnectOptions,
) -> Result<Box<dyn Connection>>;
}
#[derive(Clone, Debug, Default)]
pub struct Advertisement {
pub refs: Vec<(String, ObjectId)>,
pub advertised_peel: std::collections::HashMap<String, ObjectId>,
pub capabilities: Vec<String>,
pub head_symref: Option<String>,
pub protocol_version: u8,
}
#[must_use]
pub(crate) fn remote_handshake_complete(adv: &Advertisement) -> bool {
if adv.protocol_version >= 2 {
!adv.capabilities.is_empty()
} else {
!adv.capabilities.is_empty() || !adv.refs.is_empty() || adv.head_symref.is_some()
}
}
#[must_use]
pub(crate) fn connection_handshake_complete(conn: &dyn Connection) -> bool {
if conn.protocol_version() >= 2 {
!conn.capabilities().is_empty()
} else {
!conn.capabilities().is_empty()
|| !conn.advertised_refs().is_empty()
|| conn.head_symref().is_some()
}
}
fn verify_ssh_child_after_advertisement(child: &mut Child, adv: &Advertisement) -> Result<()> {
if remote_handshake_complete(adv) {
return Ok(());
}
for _ in 0..50 {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Err(Error::RemoteReadFailed),
Ok(Some(_)) => return Err(Error::RemoteReadFailed),
Ok(None) => std::thread::sleep(Duration::from_millis(10)),
Err(e) => {
return Err(Error::Message(format!("ssh wait failed: {e}")));
}
}
}
if !remote_handshake_complete(adv) {
return Err(Error::RemoteReadFailed);
}
Ok(())
}
pub fn read_advertisement(reader: &mut dyn Read) -> Result<Advertisement> {
let mut adv = Advertisement {
protocol_version: 0,
..Default::default()
};
let mut reader = reader;
let mut first_ref = true;
let mut v2 = false;
loop {
match pkt_line::read_packet(&mut reader)? {
None => break,
Some(pkt_line::Packet::Flush) | Some(pkt_line::Packet::Delim) => break,
Some(pkt_line::Packet::ResponseEnd) => break,
Some(pkt_line::Packet::Data(line)) => {
let line = line.trim_end_matches('\n');
if let Some(ver) = line.strip_prefix("version ") {
if let Ok(n) = ver.trim().parse::<u8>() {
adv.protocol_version = n;
if n >= 2 {
v2 = true;
}
continue;
}
}
if v2 {
if let Some(msg) = line.strip_prefix("ERR ") {
return Err(Error::Message(format!("remote error: {}", msg.trim_end())));
}
adv.capabilities.push(line.to_string());
continue;
}
if let Some(msg) = line.strip_prefix("ERR ") {
return Err(Error::Message(format!("remote error: {}", msg.trim_end())));
}
let Some((oid, refname, caps)) = parse_ref_advertisement_line(line) else {
continue;
};
if first_ref {
first_ref = false;
adv.capabilities = caps
.split_whitespace()
.map(std::string::ToString::to_string)
.collect();
}
if refname == "HEAD" {
for cap in caps.split_whitespace() {
if let Some(target) = cap.strip_prefix("symref=HEAD:") {
if crate::refs::is_valid_advertised_symref_target(target) {
adv.head_symref = Some(target.to_string());
}
}
}
}
if refname == "capabilities^{}" {
continue;
}
if let Some(base) = refname.strip_suffix("^{}") {
if crate::refs::is_valid_fetch_advertised_ref(base) {
adv.advertised_peel.insert(base.to_string(), oid);
}
continue;
}
if refname == "HEAD" {
continue;
}
if crate::refs::is_valid_fetch_advertised_ref(&refname) {
adv.refs.push((refname, oid));
}
}
}
}
Ok(adv)
}
fn parse_ref_advertisement_line(line: &str) -> Option<(ObjectId, String, &str)> {
let line = line.trim_end_matches('\n');
let hex_len = line
.as_bytes()
.iter()
.take_while(|b| b.is_ascii_hexdigit())
.count();
if hex_len != 40 && hex_len != 64 {
return None;
}
let hex = &line[..hex_len];
let oid = ObjectId::from_hex(hex).ok()?;
let mut rest = line[hex_len..].trim_start();
rest = rest.trim_start_matches([' ', '\t']);
let (refname, caps) = if let Some(i) = rest.find('\0') {
(rest[..i].trim(), &rest[i + 1..])
} else {
(rest.trim(), "")
};
if refname.is_empty() {
return None;
}
Some((oid, refname.to_string(), caps))
}
#[derive(Clone, Debug)]
pub struct GitDaemonUrl {
pub host: String,
pub port: u16,
pub path: String,
}
pub fn parse_git_url(url: &str) -> Result<GitDaemonUrl> {
let rest = url
.strip_prefix("git://")
.ok_or_else(|| Error::Message(format!("not a git:// URL: {url}")))?;
let (authority, path_part) = rest
.find('/')
.map(|i| (&rest[..i], &rest[i..]))
.unwrap_or((rest, "/"));
if path_part.is_empty() || path_part == "/" {
return Err(Error::Message(
"git:// URL missing repository path".to_owned(),
));
}
let path = path_part.to_string();
let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
let end = stripped
.find(']')
.ok_or_else(|| Error::Message(format!("invalid git:// authority: {authority}")))?;
let host = stripped[..end].to_string();
let after = &stripped[end + 1..];
let port = if let Some(p) = after.strip_prefix(':') {
p.parse::<u16>()
.map_err(|_| Error::Message(format!("invalid port in git:// URL: {url}")))?
} else {
9418
};
(host, port)
} else if let Some((h, p)) = authority.rsplit_once(':') {
let h = h.trim_end_matches(':');
if p.is_empty() {
(h.to_string(), 9418)
} else if p.chars().all(|c| c.is_ascii_digit()) {
(
h.to_string(),
p.parse::<u16>()
.map_err(|_| Error::Message(format!("invalid port in git:// URL: {url}")))?,
)
} else {
(authority.to_string(), 9418)
}
} else {
(authority.to_string(), 9418)
};
if host.is_empty() {
return Err(Error::Message("git:// URL has empty host".to_owned()));
}
Ok(GitDaemonUrl { host, port, path })
}
pub struct GitDaemonConnection {
reader: TcpStream,
writer: TcpStream,
adv: Advertisement,
}
impl Connection for GitDaemonConnection {
fn reader(&mut self) -> &mut dyn Read {
&mut self.reader
}
fn writer(&mut self) -> &mut dyn Write {
&mut self.writer
}
fn advertised_refs(&self) -> &[(String, ObjectId)] {
&self.adv.refs
}
fn capabilities(&self) -> &[String] {
&self.adv.capabilities
}
fn head_symref(&self) -> Option<&str> {
self.adv.head_symref.as_deref()
}
fn protocol_version(&self) -> u8 {
self.adv.protocol_version
}
fn advertised_tag_peel(&self) -> Option<&std::collections::HashMap<String, ObjectId>> {
Some(&self.adv.advertised_peel)
}
fn finish_send(&mut self) {
let _ = self.writer.shutdown(std::net::Shutdown::Write);
}
}
#[derive(Clone, Debug, Default)]
pub struct GitDaemonTransport {
pub connect_timeout: Option<Duration>,
pub io_timeout: Option<Duration>,
}
impl GitDaemonTransport {
#[must_use]
pub fn new() -> Self {
Self {
connect_timeout: Some(Duration::from_secs(30)),
io_timeout: Some(Duration::from_secs(600)),
}
}
fn write_request(
&self,
stream_w: &mut TcpStream,
url: &GitDaemonUrl,
service: Service,
opts: &ConnectOptions,
) -> Result<()> {
let virtual_host = format!("{}:{}", url.host, url.port);
let mut inner: Vec<u8> = Vec::new();
inner.extend_from_slice(service.wire_name().as_bytes());
inner.push(b' ');
inner.extend_from_slice(url.path.as_bytes());
inner.push(0);
inner.extend_from_slice(b"host=");
inner.extend_from_slice(virtual_host.as_bytes());
inner.push(0);
if opts.protocol_version > 0 {
inner.push(0);
inner.extend_from_slice(format!("version={}\0", opts.protocol_version).as_bytes());
}
pkt_line::write_packet_raw(stream_w, &inner)?;
stream_w.flush()?;
Ok(())
}
}
impl Transport for GitDaemonTransport {
fn connect(
&self,
url: &str,
service: Service,
opts: &ConnectOptions,
) -> Result<Box<dyn Connection>> {
crate::net_trace::net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"git:// connect {url} (service={}, request protocol v{})",
service.wire_name(),
opts.protocol_version
);
let parsed = parse_git_url(url)?;
let addr = format!("{}:{}", parsed.host, parsed.port)
.to_socket_addrs()
.map_err(|e| {
Error::Message(format!(
"could not resolve git://{}:{}: {e}",
parsed.host, parsed.port
))
})?
.next()
.ok_or_else(|| {
Error::Message(format!(
"no addresses for git://{}:{}",
parsed.host, parsed.port
))
})?;
let stream = match self.connect_timeout {
Some(t) => TcpStream::connect_timeout(&addr, t),
None => TcpStream::connect(addr),
}
.map_err(|e| {
Error::Message(format!(
"could not connect to git://{}:{}: {e}",
parsed.host, parsed.port
))
})?;
if let Some(t) = self.io_timeout {
let _ = stream.set_read_timeout(Some(t));
let _ = stream.set_write_timeout(Some(t));
}
let mut writer = stream
.try_clone()
.map_err(|e| Error::Message(format!("dup git:// socket: {e}")))?;
self.write_request(&mut writer, &parsed, service, opts)?;
let mut reader = stream;
let adv = read_advertisement(&mut reader)?;
crate::net_trace::net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"git:// connected: protocol v{}, {} ref(s) advertised",
adv.protocol_version,
adv.refs.len()
);
Ok(Box::new(GitDaemonConnection {
reader,
writer,
adv,
}))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SshUrl {
pub ssh_host: String,
pub path: String,
pub scp_style: bool,
pub port: Option<String>,
}
#[must_use]
pub fn is_ssh_url(url: &str) -> bool {
let u = url.trim();
if u.starts_with("ext::") {
return false;
}
if u.starts_with("ssh://") || u.starts_with("git+ssh://") {
return true;
}
if u.contains("://") {
return false;
}
!crate::transport_path::url_is_local_not_ssh(u)
}
pub fn parse_ssh_url(url: &str) -> Result<SshUrl> {
let u = url.trim();
if let Some(rest) = u.strip_prefix("git+ssh://") {
return parse_ssh_url_form(rest);
}
if let Some(rest) = u.strip_prefix("ssh://") {
return parse_ssh_url_form(rest);
}
parse_scp_style(u)
}
fn parse_ssh_url_form(rest: &str) -> Result<SshUrl> {
let after_slashes = rest.strip_prefix("//").unwrap_or(rest);
let (authority, path_with_sep) = split_ssh_authority_and_path(after_slashes);
let (user_host, port) = parse_authority_host_port(authority)?;
if user_host.starts_with('-') {
return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
}
let path_after_tilde = if path_with_sep.as_bytes().get(1) == Some(&b'~') {
&path_with_sep[1..]
} else {
path_with_sep.as_str()
};
let path = normalize_ssh_url_path(path_after_tilde)?;
Ok(SshUrl {
ssh_host: user_host,
path,
scp_style: false,
port,
})
}
fn split_ssh_authority_and_path(s: &str) -> (&str, String) {
let mut depth = 0usize;
for (i, ch) in s.char_indices() {
match ch {
'[' => depth += 1,
']' => depth = depth.saturating_sub(1),
'/' if depth == 0 => return (&s[..i], s[i..].to_string()),
_ => {}
}
}
(s, String::new())
}
struct HostEnd {
host: String,
rest: String,
bracketed: bool,
}
fn host_end_remove_brackets(authority: &str) -> HostEnd {
let start_off = match authority.find("@[") {
Some(at) => at + 1,
None => 0,
};
let prefix = &authority[..start_off];
let start = &authority[start_off..];
if let Some(rest) = start.strip_prefix('[') {
if let Some(close) = rest.find(']') {
let inner = &rest[..close];
let after = &rest[close + 1..];
return HostEnd {
host: format!("{prefix}{inner}"),
rest: after.to_string(),
bracketed: true,
};
}
}
HostEnd {
host: authority.to_string(),
rest: authority.to_string(),
bracketed: false,
}
}
fn get_host_and_port(he: HostEnd) -> (String, Option<String>) {
let HostEnd {
host,
rest,
bracketed,
} = he;
let Some(ci) = rest.find(':') else {
return (host, None);
};
let tail = &rest[ci + 1..];
let is_port = !tail.is_empty()
&& tail.chars().all(|c| c.is_ascii_digit())
&& tail.parse::<u32>().is_ok_and(|n| n < 65536);
if is_port {
let trimmed_host = if bracketed {
host
} else {
host[..ci].to_string()
};
return (trimmed_host, Some(tail.to_string()));
}
if tail.is_empty() {
let trimmed_host = if bracketed {
host
} else {
host[..ci].to_string()
};
return (trimmed_host, None);
}
(host, None)
}
fn get_port(host: String) -> (String, Option<String>) {
let Some(ci) = host.find(':') else {
return (host, None);
};
let tail = &host[ci + 1..];
if !tail.is_empty()
&& tail.chars().all(|c| c.is_ascii_digit())
&& tail.parse::<u32>().is_ok_and(|n| n < 65536)
{
let h = host[..ci].to_string();
let p = tail.to_string();
return (h, Some(p));
}
(host, None)
}
fn parse_authority_host_port(authority: &str) -> Result<(String, Option<String>)> {
let auth = authority.trim();
if auth.is_empty() {
return Err(Error::Message("ssh: empty host".to_owned()));
}
let (ssh_host, port) = get_host_and_port(host_end_remove_brackets(auth));
let (ssh_host, port) = match port {
Some(p) => (ssh_host, Some(p)),
None => get_port(ssh_host),
};
if ssh_host.is_empty() {
return Err(Error::Message("ssh: empty host".to_owned()));
}
if ssh_host.starts_with('-') {
return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
}
Ok((ssh_host, port))
}
fn parse_scp_style(u: &str) -> Result<SshUrl> {
let he = host_end_remove_brackets(u);
let sep_search_start = if he.bracketed {
u.find(']')
.map(|i| i + 1)
.ok_or_else(|| Error::Message("ssh: malformed host".to_owned()))?
} else {
0
};
let rel_colon = u[sep_search_start..]
.find(':')
.ok_or_else(|| Error::Message("ssh: no ':' in scp-style url".to_owned()))?;
let colon_pos = sep_search_start + rel_colon;
let host = &u[..colon_pos];
let mut path = &u[colon_pos + 1..];
if host.is_empty() || path.is_empty() {
return Err(Error::Message("ssh: empty host or path".to_owned()));
}
if host.starts_with('-') {
return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
}
if path.as_bytes().get(1) == Some(&b'~') {
path = &path[1..];
}
if path.starts_with('-') {
return Err(Error::Message("ssh: path starts with '-'".to_owned()));
}
let (ssh_host, port) = parse_authority_host_port(host)?;
Ok(SshUrl {
ssh_host,
path: path.to_owned(),
scp_style: true,
port,
})
}
fn normalize_ssh_url_path(path_part: &str) -> Result<String> {
if path_part.is_empty() {
return Ok(String::new());
}
let decoded = percent_decode_path(path_part)?;
if decoded.starts_with('-') {
return Err(Error::Message("ssh: path starts with '-'".to_owned()));
}
Ok(decoded)
}
fn percent_decode_path(path: &str) -> Result<String> {
let mut out = String::with_capacity(path.len());
let mut chars = path.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
let h1 = chars
.next()
.ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
let h2 = chars
.next()
.ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
let byte = u8::from_str_radix(&format!("{h1}{h2}"), 16)
.map_err(|_| Error::Message("ssh: bad % escape".to_owned()))?;
out.push(byte as char);
} else {
out.push(c);
}
}
Ok(out)
}
fn sq_quote_shell_arg(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for ch in s.chars() {
match ch {
'\'' => out.push_str("'\\''"),
'!' => out.push_str("'\\!'"),
_ => out.push(ch),
}
}
out.push('\'');
out
}
fn remote_service_cmd(service: Service, quoted_path: &str) -> String {
format!("{} {quoted_path}", service.wire_name())
}
#[derive(Clone, Debug, Default)]
pub enum SshCommand {
#[default]
Auto,
Program(OsString),
ShellCommand(OsString),
}
impl SshCommand {
fn resolve(
&self,
env: &crate::environment::Environment,
config: Option<&crate::config::ConfigSet>,
) -> SshCommand {
match self {
SshCommand::Auto => resolve_effective_ssh_command(env, config),
other => other.clone(),
}
}
}
#[must_use]
pub fn resolve_effective_ssh_command(
env: &crate::environment::Environment,
config: Option<&crate::config::ConfigSet>,
) -> SshCommand {
if let Some(c) = env.var_os("GIT_SSH_COMMAND").filter(|v| !v.is_empty()) {
return SshCommand::ShellCommand(c);
}
if let Some(cfg) = config {
if let Some(cmd) = cfg.get("core.sshCommand").filter(|s| !s.is_empty()) {
return SshCommand::ShellCommand(OsString::from(cmd));
}
}
if let Some(p) = env.var_os("GIT_SSH").filter(|v| !v.is_empty()) {
return SshCommand::Program(p);
}
SshCommand::Program(OsString::from("ssh"))
}
pub struct SshConnection {
child: Child,
writer: Option<ChildStdin>,
reader: ChildStdout,
adv: Advertisement,
}
impl Connection for SshConnection {
fn reader(&mut self) -> &mut dyn Read {
&mut self.reader
}
fn writer(&mut self) -> &mut dyn Write {
#[allow(clippy::expect_used)]
self.writer
.as_mut()
.expect("ssh connection writer used after finish_send")
}
fn advertised_refs(&self) -> &[(String, ObjectId)] {
&self.adv.refs
}
fn capabilities(&self) -> &[String] {
&self.adv.capabilities
}
fn head_symref(&self) -> Option<&str> {
self.adv.head_symref.as_deref()
}
fn protocol_version(&self) -> u8 {
self.adv.protocol_version
}
fn advertised_tag_peel(&self) -> Option<&std::collections::HashMap<String, ObjectId>> {
Some(&self.adv.advertised_peel)
}
fn finish_send(&mut self) {
self.writer = None;
}
}
pub(crate) fn connect_local_upload_pack(
remote_git_dir: &std::path::Path,
service: Service,
opts: &ConnectOptions,
) -> Result<Box<dyn Connection>> {
if service != Service::UploadPack {
return Err(Error::Message(
"local upload-pack connect supports fetch only".to_owned(),
));
}
let mut cmd = std::process::Command::new("git");
cmd.arg("upload-pack").arg(remote_git_dir);
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit());
if opts.protocol_version > 0 {
cmd.env("GIT_PROTOCOL", format!("version={}", opts.protocol_version));
}
let mut child = cmd.spawn().map_err(|e| {
Error::Message(format!(
"failed to spawn git upload-pack on {}: {e}",
remote_git_dir.display()
))
})?;
let writer = child
.stdin
.take()
.ok_or_else(|| Error::Message("git upload-pack missing stdin".to_owned()))?;
let mut reader = child
.stdout
.take()
.ok_or_else(|| Error::Message("git upload-pack missing stdout".to_owned()))?;
let adv = read_advertisement(&mut reader)?;
Ok(Box::new(SshConnection {
child,
writer: Some(writer),
reader,
adv,
}))
}
impl Drop for SshConnection {
fn drop(&mut self) {
self.writer = None;
let _ = self.child.wait();
}
}
#[derive(Clone)]
pub struct SshTransport {
pub ssh_command: SshCommand,
command_runner: Arc<dyn CommandRunner>,
ssh_env: Arc<crate::environment::Environment>,
ssh_config: Option<Arc<crate::config::ConfigSet>>,
}
impl std::fmt::Debug for SshTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SshTransport")
.field("ssh_command", &self.ssh_command)
.finish_non_exhaustive()
}
}
impl Default for SshTransport {
fn default() -> Self {
Self {
ssh_command: SshCommand::Auto,
command_runner: system_command_runner(),
ssh_env: Arc::new(crate::environment::Environment::empty()),
ssh_config: None,
}
}
}
impl SshTransport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_command_runner(mut self, runner: Arc<dyn CommandRunner>) -> Self {
self.command_runner = runner;
self
}
#[must_use]
pub fn with_ssh_environment(mut self, env: Arc<crate::environment::Environment>) -> Self {
self.ssh_env = env;
self
}
#[must_use]
pub fn with_ssh_config(mut self, config: Arc<crate::config::ConfigSet>) -> Self {
self.ssh_config = Some(config);
self
}
#[must_use]
pub fn with_program(program: impl Into<OsString>) -> Self {
Self {
ssh_command: SshCommand::Program(program.into()),
command_runner: system_command_runner(),
ssh_env: Arc::new(crate::environment::Environment::empty()),
ssh_config: None,
}
}
#[must_use]
pub fn with_shell_command(command: impl Into<OsString>) -> Self {
Self {
ssh_command: SshCommand::ShellCommand(command.into()),
command_runner: system_command_runner(),
ssh_env: Arc::new(crate::environment::Environment::empty()),
ssh_config: None,
}
}
fn ssh_command_spec(
&self,
spec: &SshUrl,
service: Service,
opts: &ConnectOptions,
) -> CommandSpec {
let quoted_path = sq_quote_shell_arg(&spec.path);
let remote_cmd = remote_service_cmd(service, "ed_path);
let port = spec.port.as_deref();
let mut env = CommandEnvironment::inherit_process_only();
if opts.protocol_version > 0 {
env.set.push((
OsString::from("GIT_PROTOCOL"),
OsString::from(format!("version={}", opts.protocol_version)),
));
}
match self
.ssh_command
.resolve(self.ssh_env.as_ref(), self.ssh_config.as_deref())
{
SshCommand::ShellCommand(cmd) => {
let cmd = cmd.to_string_lossy();
let port_opt = match port {
Some(p) => format!(" -p {}", shell_words::quote(p)),
None => String::new(),
};
let script = format!(
"{cmd}{port_opt} {} {}",
shell_words::quote(&spec.ssh_host),
shell_words::quote(&remote_cmd),
);
CommandSpec {
program: "sh".into(),
args: Vec::new(),
shell: Some(ShellInvocation::DashC {
script,
argv0: None,
args: Vec::new(),
}),
cwd: None,
env,
stdin: CommandStdin::Pipe(Vec::new()),
stdout: CommandStdio::Pipe,
stderr: CommandStdio::Inherit,
}
}
SshCommand::Program(prog) => {
let mut args = Vec::new();
if let Some(p) = port {
args.push(OsString::from("-p"));
args.push(OsString::from(p));
}
args.push(OsString::from(spec.ssh_host.as_str()));
args.push(OsString::from(remote_cmd.as_str()));
CommandSpec {
program: PathBuf::from(prog),
args,
shell: None,
cwd: None,
env,
stdin: CommandStdin::Pipe(Vec::new()),
stdout: CommandStdio::Pipe,
stderr: CommandStdio::Inherit,
}
}
SshCommand::Auto => unreachable!("SshCommand::resolve never yields Auto"),
}
}
}
impl Transport for SshTransport {
fn connect(
&self,
url: &str,
service: Service,
opts: &ConnectOptions,
) -> Result<Box<dyn Connection>> {
crate::net_trace::net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"ssh connect {url} (service={}, request protocol v{})",
service.wire_name(),
opts.protocol_version
);
let spec = parse_ssh_url(url)?;
let cmd_spec = self.ssh_command_spec(&spec, service, opts);
let running = self.command_runner.spawn(&cmd_spec).map_err(|e| {
Error::Message(format!("failed to spawn ssh for {}: {e}", spec.ssh_host))
})?;
let (mut child, writer, reader, _stderr) = running
.into_system_parts()
.map_err(|_| Error::Message("ssh child is not backed by a live process".to_owned()))?;
let writer = writer.ok_or_else(|| Error::Message("ssh child has no stdin".to_owned()))?;
let mut reader =
reader.ok_or_else(|| Error::Message("ssh child has no stdout".to_owned()))?;
let adv = read_advertisement(&mut reader)?;
verify_ssh_child_after_advertisement(&mut child, &adv)?;
crate::net_trace::net_trace!(
opts.network_trace,
opts.diagnostics.as_ref(),
"ssh connected: protocol v{}, {} ref(s) advertised",
adv.protocol_version,
adv.refs.len()
);
Ok(Box::new(SshConnection {
child,
writer: Some(writer),
reader,
adv,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_git_url_defaults_and_ports() {
let u = parse_git_url("git://example.com/repo.git").unwrap();
assert_eq!(u.host, "example.com");
assert_eq!(u.port, 9418);
assert_eq!(u.path, "/repo.git");
let u = parse_git_url("git://example.com:9999/a/b").unwrap();
assert_eq!(u.port, 9999);
assert_eq!(u.path, "/a/b");
let u = parse_git_url("git://[::1]:1234/x").unwrap();
assert_eq!(u.host, "::1");
assert_eq!(u.port, 1234);
assert_eq!(u.path, "/x");
assert!(parse_git_url("https://x/y").is_err());
assert!(parse_git_url("git://host").is_err());
}
#[test]
fn parse_advertisement_line_sha1_and_sha256() {
let sha1 = "1234567890123456789012345678901234567890 refs/heads/main\0caps here";
let (oid, name, caps) = parse_ref_advertisement_line(sha1).unwrap();
assert_eq!(oid.to_hex(), "1234567890123456789012345678901234567890");
assert_eq!(name, "refs/heads/main");
assert_eq!(caps, "caps here");
let hex64 = "0".repeat(64);
let line = format!("{hex64} refs/heads/x");
let (oid, name, caps) = parse_ref_advertisement_line(&line).unwrap();
assert_eq!(oid.to_hex().len(), 64);
assert_eq!(name, "refs/heads/x");
assert_eq!(caps, "");
assert!(parse_ref_advertisement_line("shallow abc").is_none());
}
#[test]
fn read_advertisement_captures_refs_caps_and_symref() {
let mut buf: Vec<u8> = Vec::new();
let main = "1111111111111111111111111111111111111111";
let head = format!("{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2",);
pkt_line::write_line_to_vec(&mut buf, &head).unwrap();
let r = format!("{main} refs/heads/main");
pkt_line::write_line_to_vec(&mut buf, &r).unwrap();
let tag = "2222222222222222222222222222222222222222";
let t = format!("{tag} refs/tags/v1");
pkt_line::write_line_to_vec(&mut buf, &t).unwrap();
let peeled = format!("{main} refs/tags/v1^{{}}");
pkt_line::write_line_to_vec(&mut buf, &peeled).unwrap();
buf.extend_from_slice(b"0000");
let mut cur = std::io::Cursor::new(buf);
let adv = read_advertisement(&mut cur).unwrap();
assert_eq!(adv.head_symref.as_deref(), Some("refs/heads/main"));
assert!(adv.capabilities.iter().any(|c| c == "multi_ack"));
let names: Vec<&str> = adv.refs.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, vec!["refs/heads/main", "refs/tags/v1"]);
let commit = ObjectId::from_hex(main).unwrap();
assert_eq!(
adv.advertised_peel.get("refs/tags/v1"),
Some(&commit),
"peeled tag line must be recorded"
);
}
#[test]
fn read_advertisement_v2_captures_caps_and_no_refs() {
let mut buf: Vec<u8> = Vec::new();
pkt_line::write_line_to_vec(&mut buf, "version 2").unwrap();
pkt_line::write_line_to_vec(&mut buf, "agent=git/2.43.0").unwrap();
pkt_line::write_line_to_vec(&mut buf, "ls-refs=unborn").unwrap();
pkt_line::write_line_to_vec(&mut buf, "fetch=shallow wait-for-done filter").unwrap();
pkt_line::write_line_to_vec(&mut buf, "object-format=sha1").unwrap();
buf.extend_from_slice(b"0000");
let mut cur = std::io::Cursor::new(buf);
let adv = read_advertisement(&mut cur).unwrap();
assert_eq!(adv.protocol_version, 2);
assert!(adv.refs.is_empty(), "v2 advertisement carries no refs");
assert!(adv.capabilities.iter().any(|c| c == "agent=git/2.43.0"));
assert!(adv
.capabilities
.iter()
.any(|c| c == "fetch=shallow wait-for-done filter"));
assert!(adv.capabilities.iter().any(|c| c == "object-format=sha1"));
assert!(adv.head_symref.is_none());
}
#[test]
fn is_ssh_url_classification() {
assert!(is_ssh_url("ssh://host/repo.git"));
assert!(is_ssh_url("git+ssh://host/repo.git"));
assert!(is_ssh_url("user@host:repo.git"));
assert!(is_ssh_url("host:path/to/repo"));
assert!(!is_ssh_url("/abs/local/repo"));
assert!(!is_ssh_url("./relative"));
assert!(!is_ssh_url("git://host/repo.git"));
assert!(!is_ssh_url("https://host/repo.git"));
assert!(!is_ssh_url("ext::sh -c foo"));
assert!(!is_ssh_url("./a:b"));
assert!(!is_ssh_url(r"C:\tmp\repo.git"));
assert!(!is_ssh_url("C:/tmp/repo.git"));
assert!(!is_ssh_url(r"\\?\C:\tmp\repo.git"));
}
#[test]
fn parse_scp_style_url() {
let u = parse_ssh_url("git@example.com:my/repo.git").unwrap();
assert_eq!(u.ssh_host, "git@example.com");
assert_eq!(u.path, "my/repo.git");
assert!(u.scp_style);
assert_eq!(u.port, None);
}
#[test]
fn parse_ssh_scheme_url_with_port() {
let u = parse_ssh_url("ssh://git@example.com:2222/srv/repo.git").unwrap();
assert_eq!(u.ssh_host, "git@example.com");
assert_eq!(u.path, "/srv/repo.git");
assert!(!u.scp_style);
assert_eq!(u.port.as_deref(), Some("2222"));
}
#[test]
fn resolve_effective_ssh_command_precedence() {
use crate::config::ConfigSet;
use crate::environment::Environment;
let mut env = Environment::empty();
env.captured_vars
.insert("GIT_SSH_COMMAND".to_owned(), OsString::from("cmd-from-env"));
env.captured_vars
.insert("GIT_SSH".to_owned(), OsString::from("/bin/git-ssh"));
let mut cfg = ConfigSet::new();
cfg.add_command_override("core.sshCommand", "cmd-from-config")
.unwrap();
assert!(matches!(
resolve_effective_ssh_command(&env, Some(&cfg)),
SshCommand::ShellCommand(ref c) if c == "cmd-from-env"
));
env.captured_vars.remove("GIT_SSH_COMMAND");
assert!(matches!(
resolve_effective_ssh_command(&env, Some(&cfg)),
SshCommand::ShellCommand(ref c) if c == "cmd-from-config"
));
let empty_cfg = ConfigSet::new();
assert!(matches!(
resolve_effective_ssh_command(&env, Some(&empty_cfg)),
SshCommand::Program(ref p) if p == "/bin/git-ssh"
));
env.captured_vars.remove("GIT_SSH");
assert!(matches!(
resolve_effective_ssh_command(&env, None),
SshCommand::Program(ref p) if p == "ssh"
));
}
#[test]
fn parse_ssh_url_ipv6_and_tilde() {
let u = parse_ssh_url("ssh://git@[::1]:2222/~/repo.git").unwrap();
assert_eq!(u.ssh_host, "git@::1");
assert_eq!(u.port.as_deref(), Some("2222"));
assert_eq!(u.path, "~/repo.git");
let u = parse_ssh_url("[git@host:2200]:repo.git").unwrap();
assert_eq!(u.ssh_host, "git@host");
assert_eq!(u.port.as_deref(), Some("2200"));
assert_eq!(u.path, "repo.git");
}
#[test]
fn parse_ssh_url_rejects_bad_inputs() {
assert!(parse_ssh_url("ssh://-badhost/repo").is_err());
assert!(parse_ssh_url("host:-dashpath").is_err());
assert!(parse_ssh_url("host:").is_err());
}
#[test]
fn remote_handshake_complete_empty_vs_capabilities_carrier() {
let empty = Advertisement::default();
assert!(!remote_handshake_complete(&empty));
let mut empty_repo = Advertisement::default();
empty_repo.capabilities = vec!["multi_ack".to_owned()];
assert!(remote_handshake_complete(&empty_repo));
let mut v2 = Advertisement::default();
v2.protocol_version = 2;
v2.capabilities = vec!["ls-refs".to_owned()];
assert!(remote_handshake_complete(&v2));
}
#[cfg(unix)]
#[test]
fn ssh_connect_failed_subprocess_empty_advertisement_is_remote_read_failed() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let script = dir.path().join("fake-ssh-fail.sh");
std::fs::write(&script, b"#!/bin/sh\nexit 255\n").expect("write script");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let transport = SshTransport::with_program(&script);
let err = match transport.connect(
"git@127.0.0.1:/nope",
Service::UploadPack,
&ConnectOptions::default(),
) {
Ok(_) => panic!("failed ssh must not yield a connection"),
Err(e) => e,
};
assert!(
matches!(err, Error::RemoteReadFailed),
"expected RemoteReadFailed, got {err}"
);
}
#[test]
fn remote_command_is_shell_quoted() {
let cmd = remote_service_cmd(Service::UploadPack, &sq_quote_shell_arg("/srv/repo.git"));
assert_eq!(cmd, "git-upload-pack '/srv/repo.git'");
let q = sq_quote_shell_arg("a'b");
assert_eq!(q, "'a'\\''b'");
let cmd = remote_service_cmd(Service::ReceivePack, &sq_quote_shell_arg("p"));
assert_eq!(cmd, "git-receive-pack 'p'");
}
}