1use std::ffi::OsString;
23use std::io::{Read, Write};
24use std::net::{TcpStream, ToSocketAddrs};
25use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
26use std::time::Duration;
27
28use crate::error::{Error, Result};
29use crate::objects::ObjectId;
30use crate::pkt_line;
31
32pub mod http;
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum Service {
37 UploadPack,
39 ReceivePack,
41}
42
43impl Service {
44 #[must_use]
46 pub fn wire_name(self) -> &'static str {
47 match self {
48 Service::UploadPack => "git-upload-pack",
49 Service::ReceivePack => "git-receive-pack",
50 }
51 }
52}
53
54#[derive(Clone, Debug, Default)]
59pub struct ConnectOptions {
60 pub protocol_version: u8,
62 pub server_options: Vec<String>,
65}
66
67pub trait Connection {
75 fn reader(&mut self) -> &mut dyn Read;
77
78 fn writer(&mut self) -> &mut dyn Write;
80
81 fn advertised_refs(&self) -> &[(String, ObjectId)];
85
86 fn capabilities(&self) -> &[String];
89
90 fn head_symref(&self) -> Option<&str>;
93
94 fn protocol_version(&self) -> u8;
96
97 fn finish_send(&mut self) {}
107}
108
109pub trait Transport {
114 fn connect(
123 &self,
124 url: &str,
125 service: Service,
126 opts: &ConnectOptions,
127 ) -> Result<Box<dyn Connection>>;
128}
129
130#[derive(Clone, Debug, Default)]
132pub struct Advertisement {
133 pub refs: Vec<(String, ObjectId)>,
135 pub capabilities: Vec<String>,
137 pub head_symref: Option<String>,
139 pub protocol_version: u8,
141}
142
143pub fn read_advertisement(reader: &mut dyn Read) -> Result<Advertisement> {
155 let mut adv = Advertisement {
156 protocol_version: 0,
157 ..Default::default()
158 };
159 let mut reader = reader;
160 let mut first_ref = true;
161 let mut v2 = false;
166 loop {
167 match pkt_line::read_packet(&mut reader)? {
168 None => break,
169 Some(pkt_line::Packet::Flush) | Some(pkt_line::Packet::Delim) => break,
170 Some(pkt_line::Packet::ResponseEnd) => break,
171 Some(pkt_line::Packet::Data(line)) => {
172 let line = line.trim_end_matches('\n');
173 if let Some(ver) = line.strip_prefix("version ") {
174 if let Ok(n) = ver.trim().parse::<u8>() {
175 adv.protocol_version = n;
176 if n >= 2 {
177 v2 = true;
178 }
179 continue;
180 }
181 }
182 if v2 {
183 if let Some(msg) = line.strip_prefix("ERR ") {
186 return Err(Error::Message(format!("remote error: {}", msg.trim_end())));
187 }
188 adv.capabilities.push(line.to_string());
189 continue;
190 }
191 if let Some(msg) = line.strip_prefix("ERR ") {
192 return Err(Error::Message(format!("remote error: {}", msg.trim_end())));
193 }
194 let Some((oid, refname, caps)) = parse_ref_advertisement_line(line) else {
195 continue;
196 };
197 if first_ref {
198 first_ref = false;
199 adv.capabilities = caps
200 .split_whitespace()
201 .map(std::string::ToString::to_string)
202 .collect();
203 }
204 if refname == "HEAD" {
205 for cap in caps.split_whitespace() {
206 if let Some(target) = cap.strip_prefix("symref=HEAD:") {
207 adv.head_symref = Some(target.to_string());
208 }
209 }
210 }
211 if refname == "capabilities^{}" || refname.ends_with("^{}") {
214 continue;
215 }
216 if refname == "HEAD" {
217 continue;
218 }
219 adv.refs.push((refname, oid));
220 }
221 }
222 }
223 Ok(adv)
224}
225
226fn parse_ref_advertisement_line(line: &str) -> Option<(ObjectId, String, &str)> {
232 let line = line.trim_end_matches('\n');
233 let hex_len = line
235 .as_bytes()
236 .iter()
237 .take_while(|b| b.is_ascii_hexdigit())
238 .count();
239 if hex_len != 40 && hex_len != 64 {
240 return None;
241 }
242 let hex = &line[..hex_len];
243 let oid = ObjectId::from_hex(hex).ok()?;
244 let mut rest = line[hex_len..].trim_start();
245 rest = rest.trim_start_matches([' ', '\t']);
247 let (refname, caps) = if let Some(i) = rest.find('\0') {
248 (rest[..i].trim(), &rest[i + 1..])
249 } else {
250 (rest.trim(), "")
251 };
252 if refname.is_empty() {
253 return None;
254 }
255 Some((oid, refname.to_string(), caps))
256}
257
258#[derive(Clone, Debug)]
260pub struct GitDaemonUrl {
261 pub host: String,
263 pub port: u16,
265 pub path: String,
267}
268
269pub fn parse_git_url(url: &str) -> Result<GitDaemonUrl> {
279 let rest = url
280 .strip_prefix("git://")
281 .ok_or_else(|| Error::Message(format!("not a git:// URL: {url}")))?;
282 let (authority, path_part) = rest
283 .find('/')
284 .map(|i| (&rest[..i], &rest[i..]))
285 .unwrap_or((rest, "/"));
286 if path_part.is_empty() || path_part == "/" {
287 return Err(Error::Message(
288 "git:// URL missing repository path".to_owned(),
289 ));
290 }
291 let path = path_part.to_string();
292 let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
293 let end = stripped
294 .find(']')
295 .ok_or_else(|| Error::Message(format!("invalid git:// authority: {authority}")))?;
296 let host = stripped[..end].to_string();
297 let after = &stripped[end + 1..];
298 let port = if let Some(p) = after.strip_prefix(':') {
299 p.parse::<u16>()
300 .map_err(|_| Error::Message(format!("invalid port in git:// URL: {url}")))?
301 } else {
302 9418
303 };
304 (host, port)
305 } else if let Some((h, p)) = authority.rsplit_once(':') {
306 let h = h.trim_end_matches(':');
307 if p.is_empty() {
308 (h.to_string(), 9418)
309 } else if p.chars().all(|c| c.is_ascii_digit()) {
310 (
311 h.to_string(),
312 p.parse::<u16>()
313 .map_err(|_| Error::Message(format!("invalid port in git:// URL: {url}")))?,
314 )
315 } else {
316 (authority.to_string(), 9418)
317 }
318 } else {
319 (authority.to_string(), 9418)
320 };
321 if host.is_empty() {
322 return Err(Error::Message("git:// URL has empty host".to_owned()));
323 }
324 Ok(GitDaemonUrl { host, port, path })
325}
326
327pub struct GitDaemonConnection {
332 reader: TcpStream,
333 writer: TcpStream,
334 adv: Advertisement,
335}
336
337impl Connection for GitDaemonConnection {
338 fn reader(&mut self) -> &mut dyn Read {
339 &mut self.reader
340 }
341
342 fn writer(&mut self) -> &mut dyn Write {
343 &mut self.writer
344 }
345
346 fn advertised_refs(&self) -> &[(String, ObjectId)] {
347 &self.adv.refs
348 }
349
350 fn capabilities(&self) -> &[String] {
351 &self.adv.capabilities
352 }
353
354 fn head_symref(&self) -> Option<&str> {
355 self.adv.head_symref.as_deref()
356 }
357
358 fn protocol_version(&self) -> u8 {
359 self.adv.protocol_version
360 }
361
362 fn finish_send(&mut self) {
363 let _ = self.writer.shutdown(std::net::Shutdown::Write);
366 }
367}
368
369#[derive(Clone, Debug, Default)]
376pub struct GitDaemonTransport {
377 pub connect_timeout: Option<Duration>,
379 pub io_timeout: Option<Duration>,
381}
382
383impl GitDaemonTransport {
384 #[must_use]
386 pub fn new() -> Self {
387 Self {
388 connect_timeout: Some(Duration::from_secs(30)),
389 io_timeout: Some(Duration::from_secs(600)),
390 }
391 }
392
393 fn write_request(
394 &self,
395 stream_w: &mut TcpStream,
396 url: &GitDaemonUrl,
397 service: Service,
398 opts: &ConnectOptions,
399 ) -> Result<()> {
400 let virtual_host = format!("{}:{}", url.host, url.port);
401 let mut inner: Vec<u8> = Vec::new();
402 inner.extend_from_slice(service.wire_name().as_bytes());
403 inner.push(b' ');
404 inner.extend_from_slice(url.path.as_bytes());
405 inner.push(0);
406 inner.extend_from_slice(b"host=");
407 inner.extend_from_slice(virtual_host.as_bytes());
408 inner.push(0);
409 if opts.protocol_version > 0 {
410 inner.push(0);
412 inner.extend_from_slice(format!("version={}\0", opts.protocol_version).as_bytes());
413 }
414 pkt_line::write_packet_raw(stream_w, &inner)?;
415 stream_w.flush()?;
416 Ok(())
417 }
418}
419
420impl Transport for GitDaemonTransport {
421 fn connect(
422 &self,
423 url: &str,
424 service: Service,
425 opts: &ConnectOptions,
426 ) -> Result<Box<dyn Connection>> {
427 crate::net_trace::net_trace!(
428 "git:// connect {url} (service={}, request protocol v{})",
429 service.wire_name(),
430 opts.protocol_version
431 );
432 let parsed = parse_git_url(url)?;
433 let addr = format!("{}:{}", parsed.host, parsed.port)
434 .to_socket_addrs()
435 .map_err(|e| {
436 Error::Message(format!(
437 "could not resolve git://{}:{}: {e}",
438 parsed.host, parsed.port
439 ))
440 })?
441 .next()
442 .ok_or_else(|| {
443 Error::Message(format!(
444 "no addresses for git://{}:{}",
445 parsed.host, parsed.port
446 ))
447 })?;
448
449 let stream = match self.connect_timeout {
450 Some(t) => TcpStream::connect_timeout(&addr, t),
451 None => TcpStream::connect(addr),
452 }
453 .map_err(|e| {
454 Error::Message(format!(
455 "could not connect to git://{}:{}: {e}",
456 parsed.host, parsed.port
457 ))
458 })?;
459 if let Some(t) = self.io_timeout {
460 let _ = stream.set_read_timeout(Some(t));
461 let _ = stream.set_write_timeout(Some(t));
462 }
463
464 let mut writer = stream
465 .try_clone()
466 .map_err(|e| Error::Message(format!("dup git:// socket: {e}")))?;
467 self.write_request(&mut writer, &parsed, service, opts)?;
468
469 let mut reader = stream;
470 let adv = read_advertisement(&mut reader)?;
471 crate::net_trace::net_trace!(
472 "git:// connected: protocol v{}, {} ref(s) advertised",
473 adv.protocol_version,
474 adv.refs.len()
475 );
476
477 Ok(Box::new(GitDaemonConnection {
478 reader,
479 writer,
480 adv,
481 }))
482 }
483}
484
485#[derive(Clone, Debug, PartialEq, Eq)]
511pub struct SshUrl {
512 pub ssh_host: String,
514 pub path: String,
516 pub scp_style: bool,
518 pub port: Option<String>,
520}
521
522#[must_use]
528pub fn is_ssh_url(url: &str) -> bool {
529 let u = url.trim();
530 if u.starts_with("ext::") {
531 return false;
532 }
533 if u.starts_with("ssh://") || u.starts_with("git+ssh://") {
534 return true;
535 }
536 if u.contains("://") {
537 return false;
538 }
539 !url_is_local_not_ssh(u)
540}
541
542fn url_is_local_not_ssh(url: &str) -> bool {
545 let colon = url.find(':');
546 let slash = url.find('/');
547 match colon {
548 None => true,
549 Some(ci) => slash.is_some_and(|si| si < ci),
550 }
551}
552
553pub fn parse_ssh_url(url: &str) -> Result<SshUrl> {
565 let u = url.trim();
566 if let Some(rest) = u.strip_prefix("git+ssh://") {
567 return parse_ssh_url_form(rest);
568 }
569 if let Some(rest) = u.strip_prefix("ssh://") {
570 return parse_ssh_url_form(rest);
571 }
572 parse_scp_style(u)
573}
574
575fn parse_ssh_url_form(rest: &str) -> Result<SshUrl> {
576 let after_slashes = rest.strip_prefix("//").unwrap_or(rest);
577 let (authority, path_with_sep) = split_ssh_authority_and_path(after_slashes);
578 let (user_host, port) = parse_authority_host_port(authority)?;
579 if user_host.starts_with('-') {
580 return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
581 }
582 let path_after_tilde = if path_with_sep.as_bytes().get(1) == Some(&b'~') {
585 &path_with_sep[1..]
586 } else {
587 path_with_sep.as_str()
588 };
589 let path = normalize_ssh_url_path(path_after_tilde)?;
590 Ok(SshUrl {
591 ssh_host: user_host,
592 path,
593 scp_style: false,
594 port,
595 })
596}
597
598fn split_ssh_authority_and_path(s: &str) -> (&str, String) {
600 let mut depth = 0usize;
601 for (i, ch) in s.char_indices() {
602 match ch {
603 '[' => depth += 1,
604 ']' => depth = depth.saturating_sub(1),
605 '/' if depth == 0 => return (&s[..i], s[i..].to_string()),
606 _ => {}
607 }
608 }
609 (s, String::new())
610}
611
612struct HostEnd {
614 host: String,
615 rest: String,
616 bracketed: bool,
617}
618
619fn host_end_remove_brackets(authority: &str) -> HostEnd {
621 let start_off = match authority.find("@[") {
622 Some(at) => at + 1,
623 None => 0,
624 };
625 let prefix = &authority[..start_off];
626 let start = &authority[start_off..];
627 if let Some(rest) = start.strip_prefix('[') {
628 if let Some(close) = rest.find(']') {
629 let inner = &rest[..close];
630 let after = &rest[close + 1..];
631 return HostEnd {
632 host: format!("{prefix}{inner}"),
633 rest: after.to_string(),
634 bracketed: true,
635 };
636 }
637 }
638 HostEnd {
639 host: authority.to_string(),
640 rest: authority.to_string(),
641 bracketed: false,
642 }
643}
644
645fn get_host_and_port(he: HostEnd) -> (String, Option<String>) {
647 let HostEnd {
648 host,
649 rest,
650 bracketed,
651 } = he;
652 let Some(ci) = rest.find(':') else {
653 return (host, None);
654 };
655 let tail = &rest[ci + 1..];
656 let is_port = !tail.is_empty()
657 && tail.chars().all(|c| c.is_ascii_digit())
658 && tail.parse::<u32>().is_ok_and(|n| n < 65536);
659 if is_port {
660 let trimmed_host = if bracketed {
661 host
662 } else {
663 host[..ci].to_string()
664 };
665 return (trimmed_host, Some(tail.to_string()));
666 }
667 if tail.is_empty() {
668 let trimmed_host = if bracketed {
669 host
670 } else {
671 host[..ci].to_string()
672 };
673 return (trimmed_host, None);
674 }
675 (host, None)
676}
677
678fn get_port(host: String) -> (String, Option<String>) {
680 let Some(ci) = host.find(':') else {
681 return (host, None);
682 };
683 let tail = &host[ci + 1..];
684 if !tail.is_empty()
685 && tail.chars().all(|c| c.is_ascii_digit())
686 && tail.parse::<u32>().is_ok_and(|n| n < 65536)
687 {
688 let h = host[..ci].to_string();
689 let p = tail.to_string();
690 return (h, Some(p));
691 }
692 (host, None)
693}
694
695fn parse_authority_host_port(authority: &str) -> Result<(String, Option<String>)> {
697 let auth = authority.trim();
698 if auth.is_empty() {
699 return Err(Error::Message("ssh: empty host".to_owned()));
700 }
701 let (ssh_host, port) = get_host_and_port(host_end_remove_brackets(auth));
702 let (ssh_host, port) = match port {
703 Some(p) => (ssh_host, Some(p)),
704 None => get_port(ssh_host),
705 };
706 if ssh_host.is_empty() {
707 return Err(Error::Message("ssh: empty host".to_owned()));
708 }
709 if ssh_host.starts_with('-') {
710 return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
711 }
712 Ok((ssh_host, port))
713}
714
715fn parse_scp_style(u: &str) -> Result<SshUrl> {
716 let he = host_end_remove_brackets(u);
717 let sep_search_start = if he.bracketed {
718 u.find(']')
719 .map(|i| i + 1)
720 .ok_or_else(|| Error::Message("ssh: malformed host".to_owned()))?
721 } else {
722 0
723 };
724 let rel_colon = u[sep_search_start..]
725 .find(':')
726 .ok_or_else(|| Error::Message("ssh: no ':' in scp-style url".to_owned()))?;
727 let colon_pos = sep_search_start + rel_colon;
728 let host = &u[..colon_pos];
729 let mut path = &u[colon_pos + 1..];
730
731 if host.is_empty() || path.is_empty() {
732 return Err(Error::Message("ssh: empty host or path".to_owned()));
733 }
734 if host.starts_with('-') {
735 return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
736 }
737 if path.as_bytes().get(1) == Some(&b'~') {
738 path = &path[1..];
739 }
740 if path.starts_with('-') {
741 return Err(Error::Message("ssh: path starts with '-'".to_owned()));
742 }
743 let (ssh_host, port) = parse_authority_host_port(host)?;
744 Ok(SshUrl {
745 ssh_host,
746 path: path.to_owned(),
747 scp_style: true,
748 port,
749 })
750}
751
752fn normalize_ssh_url_path(path_part: &str) -> Result<String> {
753 if path_part.is_empty() {
754 return Ok(String::new());
755 }
756 let decoded = percent_decode_path(path_part)?;
757 if decoded.starts_with('-') {
758 return Err(Error::Message("ssh: path starts with '-'".to_owned()));
759 }
760 Ok(decoded)
761}
762
763fn percent_decode_path(path: &str) -> Result<String> {
764 let mut out = String::with_capacity(path.len());
765 let mut chars = path.chars().peekable();
766 while let Some(c) = chars.next() {
767 if c == '%' {
768 let h1 = chars
769 .next()
770 .ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
771 let h2 = chars
772 .next()
773 .ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
774 let byte = u8::from_str_radix(&format!("{h1}{h2}"), 16)
775 .map_err(|_| Error::Message("ssh: bad % escape".to_owned()))?;
776 out.push(byte as char);
777 } else {
778 out.push(c);
779 }
780 }
781 Ok(out)
782}
783
784fn sq_quote_shell_arg(s: &str) -> String {
786 let mut out = String::with_capacity(s.len() + 2);
787 out.push('\'');
788 for ch in s.chars() {
789 match ch {
790 '\'' => out.push_str("'\\''"),
791 '!' => out.push_str("'\\!'"),
792 _ => out.push(ch),
793 }
794 }
795 out.push('\'');
796 out
797}
798
799fn remote_service_cmd(service: Service, quoted_path: &str) -> String {
802 format!("{} {quoted_path}", service.wire_name())
803}
804
805#[derive(Clone, Debug, Default)]
812pub enum SshCommand {
813 #[default]
816 Auto,
817 Program(OsString),
820 ShellCommand(OsString),
823}
824
825impl SshCommand {
826 fn resolve(&self) -> SshCommand {
828 match self {
829 SshCommand::Auto => {
830 if let Some(c) = std::env::var_os("GIT_SSH_COMMAND").filter(|v| !v.is_empty()) {
831 SshCommand::ShellCommand(c)
832 } else if let Some(p) = std::env::var_os("GIT_SSH").filter(|v| !v.is_empty()) {
833 SshCommand::Program(p)
834 } else {
835 SshCommand::Program(OsString::from("ssh"))
836 }
837 }
838 other => other.clone(),
839 }
840 }
841}
842
843pub struct SshConnection {
849 child: Child,
850 writer: Option<ChildStdin>,
853 reader: ChildStdout,
854 adv: Advertisement,
855}
856
857impl Connection for SshConnection {
858 fn reader(&mut self) -> &mut dyn Read {
859 &mut self.reader
860 }
861
862 fn writer(&mut self) -> &mut dyn Write {
863 #[allow(clippy::expect_used)]
867 self.writer
868 .as_mut()
869 .expect("ssh connection writer used after finish_send")
870 }
871
872 fn advertised_refs(&self) -> &[(String, ObjectId)] {
873 &self.adv.refs
874 }
875
876 fn capabilities(&self) -> &[String] {
877 &self.adv.capabilities
878 }
879
880 fn head_symref(&self) -> Option<&str> {
881 self.adv.head_symref.as_deref()
882 }
883
884 fn protocol_version(&self) -> u8 {
885 self.adv.protocol_version
886 }
887
888 fn finish_send(&mut self) {
889 self.writer = None;
893 }
894}
895
896impl Drop for SshConnection {
897 fn drop(&mut self) {
898 self.writer = None;
906 let _ = self.child.wait();
908 }
909}
910
911#[derive(Clone, Debug, Default)]
920pub struct SshTransport {
921 pub ssh_command: SshCommand,
923}
924
925impl SshTransport {
926 #[must_use]
929 pub fn new() -> Self {
930 Self::default()
931 }
932
933 #[must_use]
936 pub fn with_program(program: impl Into<OsString>) -> Self {
937 Self {
938 ssh_command: SshCommand::Program(program.into()),
939 }
940 }
941
942 #[must_use]
945 pub fn with_shell_command(command: impl Into<OsString>) -> Self {
946 Self {
947 ssh_command: SshCommand::ShellCommand(command.into()),
948 }
949 }
950
951 fn spawn(&self, spec: &SshUrl, service: Service, opts: &ConnectOptions) -> Result<Child> {
954 let quoted_path = sq_quote_shell_arg(&spec.path);
955 let remote_cmd = remote_service_cmd(service, "ed_path);
956 let port = spec.port.as_deref();
957
958 let mut command = match self.ssh_command.resolve() {
959 SshCommand::ShellCommand(cmd) => {
960 let cmd = cmd.to_string_lossy();
963 let port_opt = match port {
964 Some(p) => format!(" -p {}", shell_words::quote(p)),
965 None => String::new(),
966 };
967 let script = format!(
968 "{cmd}{port_opt} {} {}",
969 shell_words::quote(&spec.ssh_host),
970 shell_words::quote(&remote_cmd),
971 );
972 let mut c = Command::new("sh");
973 c.arg("-c").arg(script);
974 c
975 }
976 SshCommand::Program(prog) => {
977 let mut c = Command::new(&prog);
979 if let Some(p) = port {
980 c.arg("-p").arg(p);
981 }
982 c.arg(&spec.ssh_host).arg(&remote_cmd);
983 c
984 }
985 SshCommand::Auto => unreachable!("SshCommand::resolve never yields Auto"),
987 };
988
989 if opts.protocol_version > 0 {
996 command.env("GIT_PROTOCOL", format!("version={}", opts.protocol_version));
997 }
998
999 command
1000 .stdin(Stdio::piped())
1001 .stdout(Stdio::piped())
1002 .stderr(Stdio::inherit())
1003 .spawn()
1004 .map_err(|e| Error::Message(format!("failed to spawn ssh for {}: {e}", spec.ssh_host)))
1005 }
1006}
1007
1008impl Transport for SshTransport {
1009 fn connect(
1010 &self,
1011 url: &str,
1012 service: Service,
1013 opts: &ConnectOptions,
1014 ) -> Result<Box<dyn Connection>> {
1015 crate::net_trace::net_trace!(
1016 "ssh connect {url} (service={}, request protocol v{})",
1017 service.wire_name(),
1018 opts.protocol_version
1019 );
1020 let spec = parse_ssh_url(url)?;
1021 let mut child = self.spawn(&spec, service, opts)?;
1022
1023 let writer = child
1024 .stdin
1025 .take()
1026 .ok_or_else(|| Error::Message("ssh child has no stdin".to_owned()))?;
1027 let mut reader = child
1028 .stdout
1029 .take()
1030 .ok_or_else(|| Error::Message("ssh child has no stdout".to_owned()))?;
1031
1032 let adv = read_advertisement(&mut reader)?;
1033 crate::net_trace::net_trace!(
1034 "ssh connected: protocol v{}, {} ref(s) advertised",
1035 adv.protocol_version,
1036 adv.refs.len()
1037 );
1038
1039 Ok(Box::new(SshConnection {
1040 child,
1041 writer: Some(writer),
1042 reader,
1043 adv,
1044 }))
1045 }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::*;
1051
1052 #[test]
1053 fn parse_git_url_defaults_and_ports() {
1054 let u = parse_git_url("git://example.com/repo.git").unwrap();
1055 assert_eq!(u.host, "example.com");
1056 assert_eq!(u.port, 9418);
1057 assert_eq!(u.path, "/repo.git");
1058
1059 let u = parse_git_url("git://example.com:9999/a/b").unwrap();
1060 assert_eq!(u.port, 9999);
1061 assert_eq!(u.path, "/a/b");
1062
1063 let u = parse_git_url("git://[::1]:1234/x").unwrap();
1064 assert_eq!(u.host, "::1");
1065 assert_eq!(u.port, 1234);
1066 assert_eq!(u.path, "/x");
1067
1068 assert!(parse_git_url("https://x/y").is_err());
1069 assert!(parse_git_url("git://host").is_err());
1070 }
1071
1072 #[test]
1073 fn parse_advertisement_line_sha1_and_sha256() {
1074 let sha1 = "1234567890123456789012345678901234567890 refs/heads/main\0caps here";
1075 let (oid, name, caps) = parse_ref_advertisement_line(sha1).unwrap();
1076 assert_eq!(oid.to_hex(), "1234567890123456789012345678901234567890");
1077 assert_eq!(name, "refs/heads/main");
1078 assert_eq!(caps, "caps here");
1079
1080 let hex64 = "0".repeat(64);
1081 let line = format!("{hex64} refs/heads/x");
1082 let (oid, name, caps) = parse_ref_advertisement_line(&line).unwrap();
1083 assert_eq!(oid.to_hex().len(), 64);
1084 assert_eq!(name, "refs/heads/x");
1085 assert_eq!(caps, "");
1086
1087 assert!(parse_ref_advertisement_line("shallow abc").is_none());
1088 }
1089
1090 #[test]
1091 fn read_advertisement_captures_refs_caps_and_symref() {
1092 let mut buf: Vec<u8> = Vec::new();
1093 let main = "1111111111111111111111111111111111111111";
1094 let head = format!("{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2",);
1095 pkt_line::write_line_to_vec(&mut buf, &head).unwrap();
1096 let r = format!("{main} refs/heads/main");
1097 pkt_line::write_line_to_vec(&mut buf, &r).unwrap();
1098 let tag = "2222222222222222222222222222222222222222";
1099 let t = format!("{tag} refs/tags/v1");
1100 pkt_line::write_line_to_vec(&mut buf, &t).unwrap();
1101 let peeled = format!("{main} refs/tags/v1^{{}}");
1102 pkt_line::write_line_to_vec(&mut buf, &peeled).unwrap();
1103 buf.extend_from_slice(b"0000");
1104
1105 let mut cur = std::io::Cursor::new(buf);
1106 let adv = read_advertisement(&mut cur).unwrap();
1107 assert_eq!(adv.head_symref.as_deref(), Some("refs/heads/main"));
1108 assert!(adv.capabilities.iter().any(|c| c == "multi_ack"));
1109 let names: Vec<&str> = adv.refs.iter().map(|(n, _)| n.as_str()).collect();
1111 assert_eq!(names, vec!["refs/heads/main", "refs/tags/v1"]);
1112 }
1113
1114 #[test]
1115 fn read_advertisement_v2_captures_caps_and_no_refs() {
1116 let mut buf: Vec<u8> = Vec::new();
1118 pkt_line::write_line_to_vec(&mut buf, "version 2").unwrap();
1119 pkt_line::write_line_to_vec(&mut buf, "agent=git/2.43.0").unwrap();
1120 pkt_line::write_line_to_vec(&mut buf, "ls-refs=unborn").unwrap();
1121 pkt_line::write_line_to_vec(&mut buf, "fetch=shallow wait-for-done filter").unwrap();
1122 pkt_line::write_line_to_vec(&mut buf, "object-format=sha1").unwrap();
1123 buf.extend_from_slice(b"0000");
1124
1125 let mut cur = std::io::Cursor::new(buf);
1126 let adv = read_advertisement(&mut cur).unwrap();
1127 assert_eq!(adv.protocol_version, 2);
1128 assert!(adv.refs.is_empty(), "v2 advertisement carries no refs");
1129 assert!(adv.capabilities.iter().any(|c| c == "agent=git/2.43.0"));
1130 assert!(adv
1131 .capabilities
1132 .iter()
1133 .any(|c| c == "fetch=shallow wait-for-done filter"));
1134 assert!(adv.capabilities.iter().any(|c| c == "object-format=sha1"));
1135 assert!(adv.head_symref.is_none());
1136 }
1137
1138 #[test]
1139 fn is_ssh_url_classification() {
1140 assert!(is_ssh_url("ssh://host/repo.git"));
1141 assert!(is_ssh_url("git+ssh://host/repo.git"));
1142 assert!(is_ssh_url("user@host:repo.git"));
1143 assert!(is_ssh_url("host:path/to/repo"));
1144 assert!(!is_ssh_url("/abs/local/repo"));
1146 assert!(!is_ssh_url("./relative"));
1147 assert!(!is_ssh_url("git://host/repo.git"));
1148 assert!(!is_ssh_url("https://host/repo.git"));
1149 assert!(!is_ssh_url("ext::sh -c foo"));
1150 assert!(!is_ssh_url("./a:b"));
1152 }
1153
1154 #[test]
1155 fn parse_scp_style_url() {
1156 let u = parse_ssh_url("git@example.com:my/repo.git").unwrap();
1157 assert_eq!(u.ssh_host, "git@example.com");
1158 assert_eq!(u.path, "my/repo.git");
1159 assert!(u.scp_style);
1160 assert_eq!(u.port, None);
1161 }
1162
1163 #[test]
1164 fn parse_ssh_scheme_url_with_port() {
1165 let u = parse_ssh_url("ssh://git@example.com:2222/srv/repo.git").unwrap();
1166 assert_eq!(u.ssh_host, "git@example.com");
1167 assert_eq!(u.path, "/srv/repo.git");
1168 assert!(!u.scp_style);
1169 assert_eq!(u.port.as_deref(), Some("2222"));
1170 }
1171
1172 #[test]
1173 fn parse_ssh_url_ipv6_and_tilde() {
1174 let u = parse_ssh_url("ssh://git@[::1]:2222/~/repo.git").unwrap();
1175 assert_eq!(u.ssh_host, "git@::1");
1176 assert_eq!(u.port.as_deref(), Some("2222"));
1177 assert_eq!(u.path, "~/repo.git");
1179
1180 let u = parse_ssh_url("[git@host:2200]:repo.git").unwrap();
1182 assert_eq!(u.ssh_host, "git@host");
1183 assert_eq!(u.port.as_deref(), Some("2200"));
1184 assert_eq!(u.path, "repo.git");
1185 }
1186
1187 #[test]
1188 fn parse_ssh_url_rejects_bad_inputs() {
1189 assert!(parse_ssh_url("ssh://-badhost/repo").is_err());
1190 assert!(parse_ssh_url("host:-dashpath").is_err());
1191 assert!(parse_ssh_url("host:").is_err());
1192 }
1193
1194 #[test]
1195 fn remote_command_is_shell_quoted() {
1196 let cmd = remote_service_cmd(Service::UploadPack, &sq_quote_shell_arg("/srv/repo.git"));
1197 assert_eq!(cmd, "git-upload-pack '/srv/repo.git'");
1198 let q = sq_quote_shell_arg("a'b");
1200 assert_eq!(q, "'a'\\''b'");
1201 let cmd = remote_service_cmd(Service::ReceivePack, &sq_quote_shell_arg("p"));
1203 assert_eq!(cmd, "git-receive-pack 'p'");
1204 }
1205}