1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum Service {
46 UploadPack,
48 ReceivePack,
50}
51
52impl Service {
53 #[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#[derive(Clone, Default)]
68pub struct ConnectOptions {
69 pub protocol_version: u8,
71 pub server_options: Vec<String>,
74 pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
76 pub network_trace: bool,
78}
79
80pub trait Connection {
88 fn reader(&mut self) -> &mut dyn Read;
90
91 fn writer(&mut self) -> &mut dyn Write;
93
94 fn advertised_refs(&self) -> &[(String, ObjectId)];
98
99 fn capabilities(&self) -> &[String];
102
103 fn head_symref(&self) -> Option<&str>;
106
107 fn protocol_version(&self) -> u8;
109
110 fn advertised_tag_peel(&self) -> Option<&std::collections::HashMap<String, ObjectId>> {
113 None
114 }
115
116 fn finish_send(&mut self) {}
126
127 fn finish(&mut self) -> crate::error::Result<()> {
139 Ok(())
140 }
141
142 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 fn stateless_rpc(&self) -> bool {
166 false
167 }
168}
169
170pub(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
185pub trait Transport {
190 fn connect(
199 &self,
200 url: &str,
201 service: Service,
202 opts: &ConnectOptions,
203 ) -> Result<Box<dyn Connection>>;
204}
205
206#[derive(Clone, Debug, Default)]
208pub struct Advertisement {
209 pub refs: Vec<(String, ObjectId)>,
211 pub advertised_peel: std::collections::HashMap<String, ObjectId>,
213 pub capabilities: Vec<String>,
215 pub head_symref: Option<String>,
217 pub protocol_version: u8,
219}
220
221#[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
271pub 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 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 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 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
363fn parse_ref_advertisement_line(line: &str) -> Option<(ObjectId, String, &str)> {
369 let line = line.trim_end_matches('\n');
370 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 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#[derive(Clone, Debug)]
397pub struct GitDaemonUrl {
398 pub host: String,
400 pub port: u16,
402 pub path: String,
404}
405
406pub 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
464pub 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 let _ = self.writer.shutdown(std::net::Shutdown::Write);
507 }
508}
509
510#[derive(Clone, Debug, Default)]
517pub struct GitDaemonTransport {
518 pub connect_timeout: Option<Duration>,
520 pub io_timeout: Option<Duration>,
522}
523
524impl GitDaemonTransport {
525 #[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 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#[derive(Clone, Debug, PartialEq, Eq)]
676pub struct SshUrl {
677 pub ssh_host: String,
679 pub path: String,
681 pub scp_style: bool,
683 pub port: Option<String>,
685}
686
687#[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
707pub 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 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
752fn 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
766struct HostEnd {
768 host: String,
769 rest: String,
770 bracketed: bool,
771}
772
773fn 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
799fn 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
832fn 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
849fn 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
938pub(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
953fn remote_service_cmd(service: Service, quoted_path: &str) -> String {
956 format!("{} {quoted_path}", service.wire_name())
957}
958
959#[derive(Clone, Debug, Default)]
967pub enum SshCommand {
968 #[default]
971 Auto,
972 Program(OsString),
975 ShellCommand(OsString),
978}
979
980impl SshCommand {
981 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#[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
1016pub struct SshConnection {
1022 child: Child,
1023 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 #[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 self.writer = None;
1070 }
1071}
1072
1073pub(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 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
1169struct LocalUploadPackConnection {
1175 writer: Option<crate::mem_pipe::MemPipeWriter>,
1177 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 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
1206fn 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 #[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 #[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 let _ = self.finish_server();
1269 }
1270}
1271
1272impl Drop for SshConnection {
1273 fn drop(&mut self) {
1274 self.writer = None;
1282 let _ = self.child.wait();
1284 }
1285}
1286
1287#[derive(Clone)]
1296pub struct SshTransport {
1297 pub ssh_command: SshCommand,
1299 command_runner: Arc<dyn CommandRunner>,
1300 ssh_env: Arc<crate::environment::Environment>,
1302 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 #[must_use]
1329 pub fn new() -> Self {
1330 Self::default()
1331 }
1332
1333 #[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 #[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 #[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 #[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 #[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, "ed_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 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 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 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 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 assert_eq!(u.path, "~/repo.git");
1736
1737 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 let q = sq_quote_shell_arg("a'b");
1802 assert_eq!(q, "'a'\\''b'");
1803 let cmd = remote_service_cmd(Service::ReceivePack, &sq_quote_shell_arg("p"));
1805 assert_eq!(cmd, "git-receive-pack 'p'");
1806 }
1807}