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!(
187 "remote error: {}",
188 msg.trim_end()
189 )));
190 }
191 adv.capabilities.push(line.to_string());
192 continue;
193 }
194 if let Some(msg) = line.strip_prefix("ERR ") {
195 return Err(Error::Message(format!(
196 "remote error: {}",
197 msg.trim_end()
198 )));
199 }
200 let Some((oid, refname, caps)) = parse_ref_advertisement_line(line) else {
201 continue;
202 };
203 if first_ref {
204 first_ref = false;
205 adv.capabilities = caps
206 .split_whitespace()
207 .map(std::string::ToString::to_string)
208 .collect();
209 }
210 if refname == "HEAD" {
211 for cap in caps.split_whitespace() {
212 if let Some(target) = cap.strip_prefix("symref=HEAD:") {
213 adv.head_symref = Some(target.to_string());
214 }
215 }
216 }
217 if refname == "capabilities^{}" || refname.ends_with("^{}") {
220 continue;
221 }
222 if refname == "HEAD" {
223 continue;
224 }
225 adv.refs.push((refname, oid));
226 }
227 }
228 }
229 Ok(adv)
230}
231
232fn parse_ref_advertisement_line(line: &str) -> Option<(ObjectId, String, &str)> {
238 let line = line.trim_end_matches('\n');
239 let hex_len = line
241 .as_bytes()
242 .iter()
243 .take_while(|b| b.is_ascii_hexdigit())
244 .count();
245 if hex_len != 40 && hex_len != 64 {
246 return None;
247 }
248 let hex = &line[..hex_len];
249 let oid = ObjectId::from_hex(hex).ok()?;
250 let mut rest = line[hex_len..].trim_start();
251 rest = rest.trim_start_matches([' ', '\t']);
253 let (refname, caps) = if let Some(i) = rest.find('\0') {
254 (rest[..i].trim(), &rest[i + 1..])
255 } else {
256 (rest.trim(), "")
257 };
258 if refname.is_empty() {
259 return None;
260 }
261 Some((oid, refname.to_string(), caps))
262}
263
264#[derive(Clone, Debug)]
266pub struct GitDaemonUrl {
267 pub host: String,
269 pub port: u16,
271 pub path: String,
273}
274
275pub fn parse_git_url(url: &str) -> Result<GitDaemonUrl> {
285 let rest = url
286 .strip_prefix("git://")
287 .ok_or_else(|| Error::Message(format!("not a git:// URL: {url}")))?;
288 let (authority, path_part) = rest
289 .find('/')
290 .map(|i| (&rest[..i], &rest[i..]))
291 .unwrap_or((rest, "/"));
292 if path_part.is_empty() || path_part == "/" {
293 return Err(Error::Message(
294 "git:// URL missing repository path".to_owned(),
295 ));
296 }
297 let path = path_part.to_string();
298 let (host, port) = if let Some(stripped) = authority.strip_prefix('[') {
299 let end = stripped
300 .find(']')
301 .ok_or_else(|| Error::Message(format!("invalid git:// authority: {authority}")))?;
302 let host = stripped[..end].to_string();
303 let after = &stripped[end + 1..];
304 let port = if let Some(p) = after.strip_prefix(':') {
305 p.parse::<u16>()
306 .map_err(|_| Error::Message(format!("invalid port in git:// URL: {url}")))?
307 } else {
308 9418
309 };
310 (host, port)
311 } else if let Some((h, p)) = authority.rsplit_once(':') {
312 let h = h.trim_end_matches(':');
313 if p.is_empty() {
314 (h.to_string(), 9418)
315 } else if p.chars().all(|c| c.is_ascii_digit()) {
316 (
317 h.to_string(),
318 p.parse::<u16>()
319 .map_err(|_| Error::Message(format!("invalid port in git:// URL: {url}")))?,
320 )
321 } else {
322 (authority.to_string(), 9418)
323 }
324 } else {
325 (authority.to_string(), 9418)
326 };
327 if host.is_empty() {
328 return Err(Error::Message("git:// URL has empty host".to_owned()));
329 }
330 Ok(GitDaemonUrl { host, port, path })
331}
332
333pub struct GitDaemonConnection {
338 reader: TcpStream,
339 writer: TcpStream,
340 adv: Advertisement,
341}
342
343impl Connection for GitDaemonConnection {
344 fn reader(&mut self) -> &mut dyn Read {
345 &mut self.reader
346 }
347
348 fn writer(&mut self) -> &mut dyn Write {
349 &mut self.writer
350 }
351
352 fn advertised_refs(&self) -> &[(String, ObjectId)] {
353 &self.adv.refs
354 }
355
356 fn capabilities(&self) -> &[String] {
357 &self.adv.capabilities
358 }
359
360 fn head_symref(&self) -> Option<&str> {
361 self.adv.head_symref.as_deref()
362 }
363
364 fn protocol_version(&self) -> u8 {
365 self.adv.protocol_version
366 }
367
368 fn finish_send(&mut self) {
369 let _ = self.writer.shutdown(std::net::Shutdown::Write);
372 }
373}
374
375#[derive(Clone, Debug, Default)]
382pub struct GitDaemonTransport {
383 pub connect_timeout: Option<Duration>,
385 pub io_timeout: Option<Duration>,
387}
388
389impl GitDaemonTransport {
390 #[must_use]
392 pub fn new() -> Self {
393 Self {
394 connect_timeout: Some(Duration::from_secs(30)),
395 io_timeout: Some(Duration::from_secs(600)),
396 }
397 }
398
399 fn write_request(
400 &self,
401 stream_w: &mut TcpStream,
402 url: &GitDaemonUrl,
403 service: Service,
404 opts: &ConnectOptions,
405 ) -> Result<()> {
406 let virtual_host = format!("{}:{}", url.host, url.port);
407 let mut inner: Vec<u8> = Vec::new();
408 inner.extend_from_slice(service.wire_name().as_bytes());
409 inner.push(b' ');
410 inner.extend_from_slice(url.path.as_bytes());
411 inner.push(0);
412 inner.extend_from_slice(b"host=");
413 inner.extend_from_slice(virtual_host.as_bytes());
414 inner.push(0);
415 if opts.protocol_version > 0 {
416 inner.push(0);
418 inner.extend_from_slice(format!("version={}\0", opts.protocol_version).as_bytes());
419 }
420 pkt_line::write_packet_raw(stream_w, &inner)?;
421 stream_w.flush()?;
422 Ok(())
423 }
424}
425
426impl Transport for GitDaemonTransport {
427 fn connect(
428 &self,
429 url: &str,
430 service: Service,
431 opts: &ConnectOptions,
432 ) -> Result<Box<dyn Connection>> {
433 crate::net_trace::net_trace!(
434 "git:// connect {url} (service={}, request protocol v{})",
435 service.wire_name(),
436 opts.protocol_version
437 );
438 let parsed = parse_git_url(url)?;
439 let addr = format!("{}:{}", parsed.host, parsed.port)
440 .to_socket_addrs()
441 .map_err(|e| {
442 Error::Message(format!(
443 "could not resolve git://{}:{}: {e}",
444 parsed.host, parsed.port
445 ))
446 })?
447 .next()
448 .ok_or_else(|| {
449 Error::Message(format!(
450 "no addresses for git://{}:{}",
451 parsed.host, parsed.port
452 ))
453 })?;
454
455 let stream = match self.connect_timeout {
456 Some(t) => TcpStream::connect_timeout(&addr, t),
457 None => TcpStream::connect(addr),
458 }
459 .map_err(|e| {
460 Error::Message(format!(
461 "could not connect to git://{}:{}: {e}",
462 parsed.host, parsed.port
463 ))
464 })?;
465 if let Some(t) = self.io_timeout {
466 let _ = stream.set_read_timeout(Some(t));
467 let _ = stream.set_write_timeout(Some(t));
468 }
469
470 let mut writer = stream
471 .try_clone()
472 .map_err(|e| Error::Message(format!("dup git:// socket: {e}")))?;
473 self.write_request(&mut writer, &parsed, service, opts)?;
474
475 let mut reader = stream;
476 let adv = read_advertisement(&mut reader)?;
477 crate::net_trace::net_trace!(
478 "git:// connected: protocol v{}, {} ref(s) advertised",
479 adv.protocol_version,
480 adv.refs.len()
481 );
482
483 Ok(Box::new(GitDaemonConnection {
484 reader,
485 writer,
486 adv,
487 }))
488 }
489}
490
491#[derive(Clone, Debug, PartialEq, Eq)]
517pub struct SshUrl {
518 pub ssh_host: String,
520 pub path: String,
522 pub scp_style: bool,
524 pub port: Option<String>,
526}
527
528#[must_use]
534pub fn is_ssh_url(url: &str) -> bool {
535 let u = url.trim();
536 if u.starts_with("ext::") {
537 return false;
538 }
539 if u.starts_with("ssh://") || u.starts_with("git+ssh://") {
540 return true;
541 }
542 if u.contains("://") {
543 return false;
544 }
545 !url_is_local_not_ssh(u)
546}
547
548fn url_is_local_not_ssh(url: &str) -> bool {
551 let colon = url.find(':');
552 let slash = url.find('/');
553 match colon {
554 None => true,
555 Some(ci) => slash.is_some_and(|si| si < ci),
556 }
557}
558
559pub fn parse_ssh_url(url: &str) -> Result<SshUrl> {
571 let u = url.trim();
572 if let Some(rest) = u.strip_prefix("git+ssh://") {
573 return parse_ssh_url_form(rest);
574 }
575 if let Some(rest) = u.strip_prefix("ssh://") {
576 return parse_ssh_url_form(rest);
577 }
578 parse_scp_style(u)
579}
580
581fn parse_ssh_url_form(rest: &str) -> Result<SshUrl> {
582 let after_slashes = rest.strip_prefix("//").unwrap_or(rest);
583 let (authority, path_with_sep) = split_ssh_authority_and_path(after_slashes);
584 let (user_host, port) = parse_authority_host_port(authority)?;
585 if user_host.starts_with('-') {
586 return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
587 }
588 let path_after_tilde = if path_with_sep.as_bytes().get(1) == Some(&b'~') {
591 &path_with_sep[1..]
592 } else {
593 path_with_sep.as_str()
594 };
595 let path = normalize_ssh_url_path(path_after_tilde)?;
596 Ok(SshUrl {
597 ssh_host: user_host,
598 path,
599 scp_style: false,
600 port,
601 })
602}
603
604fn split_ssh_authority_and_path(s: &str) -> (&str, String) {
606 let mut depth = 0usize;
607 for (i, ch) in s.char_indices() {
608 match ch {
609 '[' => depth += 1,
610 ']' => depth = depth.saturating_sub(1),
611 '/' if depth == 0 => return (&s[..i], s[i..].to_string()),
612 _ => {}
613 }
614 }
615 (s, String::new())
616}
617
618struct HostEnd {
620 host: String,
621 rest: String,
622 bracketed: bool,
623}
624
625fn host_end_remove_brackets(authority: &str) -> HostEnd {
627 let start_off = match authority.find("@[") {
628 Some(at) => at + 1,
629 None => 0,
630 };
631 let prefix = &authority[..start_off];
632 let start = &authority[start_off..];
633 if let Some(rest) = start.strip_prefix('[') {
634 if let Some(close) = rest.find(']') {
635 let inner = &rest[..close];
636 let after = &rest[close + 1..];
637 return HostEnd {
638 host: format!("{prefix}{inner}"),
639 rest: after.to_string(),
640 bracketed: true,
641 };
642 }
643 }
644 HostEnd {
645 host: authority.to_string(),
646 rest: authority.to_string(),
647 bracketed: false,
648 }
649}
650
651fn get_host_and_port(he: HostEnd) -> (String, Option<String>) {
653 let HostEnd {
654 host,
655 rest,
656 bracketed,
657 } = he;
658 let Some(ci) = rest.find(':') else {
659 return (host, None);
660 };
661 let tail = &rest[ci + 1..];
662 let is_port = !tail.is_empty()
663 && tail.chars().all(|c| c.is_ascii_digit())
664 && tail.parse::<u32>().is_ok_and(|n| n < 65536);
665 if is_port {
666 let trimmed_host = if bracketed {
667 host
668 } else {
669 host[..ci].to_string()
670 };
671 return (trimmed_host, Some(tail.to_string()));
672 }
673 if tail.is_empty() {
674 let trimmed_host = if bracketed {
675 host
676 } else {
677 host[..ci].to_string()
678 };
679 return (trimmed_host, None);
680 }
681 (host, None)
682}
683
684fn get_port(host: String) -> (String, Option<String>) {
686 let Some(ci) = host.find(':') else {
687 return (host, None);
688 };
689 let tail = &host[ci + 1..];
690 if !tail.is_empty()
691 && tail.chars().all(|c| c.is_ascii_digit())
692 && tail.parse::<u32>().is_ok_and(|n| n < 65536)
693 {
694 let h = host[..ci].to_string();
695 let p = tail.to_string();
696 return (h, Some(p));
697 }
698 (host, None)
699}
700
701fn parse_authority_host_port(authority: &str) -> Result<(String, Option<String>)> {
703 let auth = authority.trim();
704 if auth.is_empty() {
705 return Err(Error::Message("ssh: empty host".to_owned()));
706 }
707 let (ssh_host, port) = get_host_and_port(host_end_remove_brackets(auth));
708 let (ssh_host, port) = match port {
709 Some(p) => (ssh_host, Some(p)),
710 None => get_port(ssh_host),
711 };
712 if ssh_host.is_empty() {
713 return Err(Error::Message("ssh: empty host".to_owned()));
714 }
715 if ssh_host.starts_with('-') {
716 return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
717 }
718 Ok((ssh_host, port))
719}
720
721fn parse_scp_style(u: &str) -> Result<SshUrl> {
722 let he = host_end_remove_brackets(u);
723 let sep_search_start = if he.bracketed {
724 u.find(']')
725 .map(|i| i + 1)
726 .ok_or_else(|| Error::Message("ssh: malformed host".to_owned()))?
727 } else {
728 0
729 };
730 let rel_colon = u[sep_search_start..]
731 .find(':')
732 .ok_or_else(|| Error::Message("ssh: no ':' in scp-style url".to_owned()))?;
733 let colon_pos = sep_search_start + rel_colon;
734 let host = &u[..colon_pos];
735 let mut path = &u[colon_pos + 1..];
736
737 if host.is_empty() || path.is_empty() {
738 return Err(Error::Message("ssh: empty host or path".to_owned()));
739 }
740 if host.starts_with('-') {
741 return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
742 }
743 if path.as_bytes().get(1) == Some(&b'~') {
744 path = &path[1..];
745 }
746 if path.starts_with('-') {
747 return Err(Error::Message("ssh: path starts with '-'".to_owned()));
748 }
749 let (ssh_host, port) = parse_authority_host_port(host)?;
750 Ok(SshUrl {
751 ssh_host,
752 path: path.to_owned(),
753 scp_style: true,
754 port,
755 })
756}
757
758fn normalize_ssh_url_path(path_part: &str) -> Result<String> {
759 if path_part.is_empty() {
760 return Ok(String::new());
761 }
762 let decoded = percent_decode_path(path_part)?;
763 if decoded.starts_with('-') {
764 return Err(Error::Message("ssh: path starts with '-'".to_owned()));
765 }
766 Ok(decoded)
767}
768
769fn percent_decode_path(path: &str) -> Result<String> {
770 let mut out = String::with_capacity(path.len());
771 let mut chars = path.chars().peekable();
772 while let Some(c) = chars.next() {
773 if c == '%' {
774 let h1 = chars
775 .next()
776 .ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
777 let h2 = chars
778 .next()
779 .ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
780 let byte = u8::from_str_radix(&format!("{h1}{h2}"), 16)
781 .map_err(|_| Error::Message("ssh: bad % escape".to_owned()))?;
782 out.push(byte as char);
783 } else {
784 out.push(c);
785 }
786 }
787 Ok(out)
788}
789
790fn sq_quote_shell_arg(s: &str) -> String {
792 let mut out = String::with_capacity(s.len() + 2);
793 out.push('\'');
794 for ch in s.chars() {
795 match ch {
796 '\'' => out.push_str("'\\''"),
797 '!' => out.push_str("'\\!'"),
798 _ => out.push(ch),
799 }
800 }
801 out.push('\'');
802 out
803}
804
805fn remote_service_cmd(service: Service, quoted_path: &str) -> String {
808 format!("{} {quoted_path}", service.wire_name())
809}
810
811#[derive(Clone, Debug, Default)]
818pub enum SshCommand {
819 #[default]
822 Auto,
823 Program(OsString),
826 ShellCommand(OsString),
829}
830
831impl SshCommand {
832 fn resolve(&self) -> SshCommand {
834 match self {
835 SshCommand::Auto => {
836 if let Some(c) =
837 std::env::var_os("GIT_SSH_COMMAND").filter(|v| !v.is_empty())
838 {
839 SshCommand::ShellCommand(c)
840 } else if let Some(p) = std::env::var_os("GIT_SSH").filter(|v| !v.is_empty()) {
841 SshCommand::Program(p)
842 } else {
843 SshCommand::Program(OsString::from("ssh"))
844 }
845 }
846 other => other.clone(),
847 }
848 }
849}
850
851pub struct SshConnection {
857 child: Child,
858 writer: Option<ChildStdin>,
861 reader: ChildStdout,
862 adv: Advertisement,
863}
864
865impl Connection for SshConnection {
866 fn reader(&mut self) -> &mut dyn Read {
867 &mut self.reader
868 }
869
870 fn writer(&mut self) -> &mut dyn Write {
871 self.writer
872 .as_mut()
873 .expect("ssh connection writer used after finish_send")
874 }
875
876 fn advertised_refs(&self) -> &[(String, ObjectId)] {
877 &self.adv.refs
878 }
879
880 fn capabilities(&self) -> &[String] {
881 &self.adv.capabilities
882 }
883
884 fn head_symref(&self) -> Option<&str> {
885 self.adv.head_symref.as_deref()
886 }
887
888 fn protocol_version(&self) -> u8 {
889 self.adv.protocol_version
890 }
891
892 fn finish_send(&mut self) {
893 self.writer = None;
897 }
898}
899
900impl Drop for SshConnection {
901 fn drop(&mut self) {
902 self.writer = None;
910 let _ = self.child.wait();
912 }
913}
914
915#[derive(Clone, Debug, Default)]
924pub struct SshTransport {
925 pub ssh_command: SshCommand,
927}
928
929impl SshTransport {
930 #[must_use]
933 pub fn new() -> Self {
934 Self::default()
935 }
936
937 #[must_use]
940 pub fn with_program(program: impl Into<OsString>) -> Self {
941 Self {
942 ssh_command: SshCommand::Program(program.into()),
943 }
944 }
945
946 #[must_use]
949 pub fn with_shell_command(command: impl Into<OsString>) -> Self {
950 Self {
951 ssh_command: SshCommand::ShellCommand(command.into()),
952 }
953 }
954
955 fn spawn(&self, spec: &SshUrl, service: Service, opts: &ConnectOptions) -> Result<Child> {
958 let quoted_path = sq_quote_shell_arg(&spec.path);
959 let remote_cmd = remote_service_cmd(service, "ed_path);
960 let port = spec.port.as_deref();
961
962 let mut command = match self.ssh_command.resolve() {
963 SshCommand::ShellCommand(cmd) => {
964 let cmd = cmd.to_string_lossy();
967 let port_opt = match port {
968 Some(p) => format!(" -p {}", shell_words::quote(p)),
969 None => String::new(),
970 };
971 let script = format!(
972 "{cmd}{port_opt} {} {}",
973 shell_words::quote(&spec.ssh_host),
974 shell_words::quote(&remote_cmd),
975 );
976 let mut c = Command::new("sh");
977 c.arg("-c").arg(script);
978 c
979 }
980 SshCommand::Program(prog) => {
981 let mut c = Command::new(&prog);
983 if let Some(p) = port {
984 c.arg("-p").arg(p);
985 }
986 c.arg(&spec.ssh_host).arg(&remote_cmd);
987 c
988 }
989 SshCommand::Auto => unreachable!("SshCommand::resolve never yields Auto"),
991 };
992
993 if opts.protocol_version > 0 {
1000 command.env("GIT_PROTOCOL", format!("version={}", opts.protocol_version));
1001 }
1002
1003 command
1004 .stdin(Stdio::piped())
1005 .stdout(Stdio::piped())
1006 .stderr(Stdio::inherit())
1007 .spawn()
1008 .map_err(|e| Error::Message(format!("failed to spawn ssh for {}: {e}", spec.ssh_host)))
1009 }
1010}
1011
1012impl Transport for SshTransport {
1013 fn connect(
1014 &self,
1015 url: &str,
1016 service: Service,
1017 opts: &ConnectOptions,
1018 ) -> Result<Box<dyn Connection>> {
1019 crate::net_trace::net_trace!(
1020 "ssh connect {url} (service={}, request protocol v{})",
1021 service.wire_name(),
1022 opts.protocol_version
1023 );
1024 let spec = parse_ssh_url(url)?;
1025 let mut child = self.spawn(&spec, service, opts)?;
1026
1027 let writer = child
1028 .stdin
1029 .take()
1030 .ok_or_else(|| Error::Message("ssh child has no stdin".to_owned()))?;
1031 let mut reader = child
1032 .stdout
1033 .take()
1034 .ok_or_else(|| Error::Message("ssh child has no stdout".to_owned()))?;
1035
1036 let adv = read_advertisement(&mut reader)?;
1037 crate::net_trace::net_trace!(
1038 "ssh connected: protocol v{}, {} ref(s) advertised",
1039 adv.protocol_version,
1040 adv.refs.len()
1041 );
1042
1043 Ok(Box::new(SshConnection {
1044 child,
1045 writer: Some(writer),
1046 reader,
1047 adv,
1048 }))
1049 }
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055
1056 #[test]
1057 fn parse_git_url_defaults_and_ports() {
1058 let u = parse_git_url("git://example.com/repo.git").unwrap();
1059 assert_eq!(u.host, "example.com");
1060 assert_eq!(u.port, 9418);
1061 assert_eq!(u.path, "/repo.git");
1062
1063 let u = parse_git_url("git://example.com:9999/a/b").unwrap();
1064 assert_eq!(u.port, 9999);
1065 assert_eq!(u.path, "/a/b");
1066
1067 let u = parse_git_url("git://[::1]:1234/x").unwrap();
1068 assert_eq!(u.host, "::1");
1069 assert_eq!(u.port, 1234);
1070 assert_eq!(u.path, "/x");
1071
1072 assert!(parse_git_url("https://x/y").is_err());
1073 assert!(parse_git_url("git://host").is_err());
1074 }
1075
1076 #[test]
1077 fn parse_advertisement_line_sha1_and_sha256() {
1078 let sha1 = "1234567890123456789012345678901234567890 refs/heads/main\0caps here";
1079 let (oid, name, caps) = parse_ref_advertisement_line(sha1).unwrap();
1080 assert_eq!(oid.to_hex(), "1234567890123456789012345678901234567890");
1081 assert_eq!(name, "refs/heads/main");
1082 assert_eq!(caps, "caps here");
1083
1084 let hex64 = "0".repeat(64);
1085 let line = format!("{hex64} refs/heads/x");
1086 let (oid, name, caps) = parse_ref_advertisement_line(&line).unwrap();
1087 assert_eq!(oid.to_hex().len(), 64);
1088 assert_eq!(name, "refs/heads/x");
1089 assert_eq!(caps, "");
1090
1091 assert!(parse_ref_advertisement_line("shallow abc").is_none());
1092 }
1093
1094 #[test]
1095 fn read_advertisement_captures_refs_caps_and_symref() {
1096 let mut buf: Vec<u8> = Vec::new();
1097 let main = "1111111111111111111111111111111111111111";
1098 let head = format!(
1099 "{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2",
1100 );
1101 pkt_line::write_line_to_vec(&mut buf, &head).unwrap();
1102 let r = format!("{main} refs/heads/main");
1103 pkt_line::write_line_to_vec(&mut buf, &r).unwrap();
1104 let tag = "2222222222222222222222222222222222222222";
1105 let t = format!("{tag} refs/tags/v1");
1106 pkt_line::write_line_to_vec(&mut buf, &t).unwrap();
1107 let peeled = format!("{main} refs/tags/v1^{{}}");
1108 pkt_line::write_line_to_vec(&mut buf, &peeled).unwrap();
1109 buf.extend_from_slice(b"0000");
1110
1111 let mut cur = std::io::Cursor::new(buf);
1112 let adv = read_advertisement(&mut cur).unwrap();
1113 assert_eq!(adv.head_symref.as_deref(), Some("refs/heads/main"));
1114 assert!(adv.capabilities.iter().any(|c| c == "multi_ack"));
1115 let names: Vec<&str> = adv.refs.iter().map(|(n, _)| n.as_str()).collect();
1117 assert_eq!(names, vec!["refs/heads/main", "refs/tags/v1"]);
1118 }
1119
1120 #[test]
1121 fn read_advertisement_v2_captures_caps_and_no_refs() {
1122 let mut buf: Vec<u8> = Vec::new();
1124 pkt_line::write_line_to_vec(&mut buf, "version 2").unwrap();
1125 pkt_line::write_line_to_vec(&mut buf, "agent=git/2.43.0").unwrap();
1126 pkt_line::write_line_to_vec(&mut buf, "ls-refs=unborn").unwrap();
1127 pkt_line::write_line_to_vec(&mut buf, "fetch=shallow wait-for-done filter").unwrap();
1128 pkt_line::write_line_to_vec(&mut buf, "object-format=sha1").unwrap();
1129 buf.extend_from_slice(b"0000");
1130
1131 let mut cur = std::io::Cursor::new(buf);
1132 let adv = read_advertisement(&mut cur).unwrap();
1133 assert_eq!(adv.protocol_version, 2);
1134 assert!(adv.refs.is_empty(), "v2 advertisement carries no refs");
1135 assert!(adv.capabilities.iter().any(|c| c == "agent=git/2.43.0"));
1136 assert!(adv
1137 .capabilities
1138 .iter()
1139 .any(|c| c == "fetch=shallow wait-for-done filter"));
1140 assert!(adv.capabilities.iter().any(|c| c == "object-format=sha1"));
1141 assert!(adv.head_symref.is_none());
1142 }
1143
1144 #[test]
1145 fn is_ssh_url_classification() {
1146 assert!(is_ssh_url("ssh://host/repo.git"));
1147 assert!(is_ssh_url("git+ssh://host/repo.git"));
1148 assert!(is_ssh_url("user@host:repo.git"));
1149 assert!(is_ssh_url("host:path/to/repo"));
1150 assert!(!is_ssh_url("/abs/local/repo"));
1152 assert!(!is_ssh_url("./relative"));
1153 assert!(!is_ssh_url("git://host/repo.git"));
1154 assert!(!is_ssh_url("https://host/repo.git"));
1155 assert!(!is_ssh_url("ext::sh -c foo"));
1156 assert!(!is_ssh_url("./a:b"));
1158 }
1159
1160 #[test]
1161 fn parse_scp_style_url() {
1162 let u = parse_ssh_url("git@example.com:my/repo.git").unwrap();
1163 assert_eq!(u.ssh_host, "git@example.com");
1164 assert_eq!(u.path, "my/repo.git");
1165 assert!(u.scp_style);
1166 assert_eq!(u.port, None);
1167 }
1168
1169 #[test]
1170 fn parse_ssh_scheme_url_with_port() {
1171 let u = parse_ssh_url("ssh://git@example.com:2222/srv/repo.git").unwrap();
1172 assert_eq!(u.ssh_host, "git@example.com");
1173 assert_eq!(u.path, "/srv/repo.git");
1174 assert!(!u.scp_style);
1175 assert_eq!(u.port.as_deref(), Some("2222"));
1176 }
1177
1178 #[test]
1179 fn parse_ssh_url_ipv6_and_tilde() {
1180 let u = parse_ssh_url("ssh://git@[::1]:2222/~/repo.git").unwrap();
1181 assert_eq!(u.ssh_host, "git@::1");
1182 assert_eq!(u.port.as_deref(), Some("2222"));
1183 assert_eq!(u.path, "~/repo.git");
1185
1186 let u = parse_ssh_url("[git@host:2200]:repo.git").unwrap();
1188 assert_eq!(u.ssh_host, "git@host");
1189 assert_eq!(u.port.as_deref(), Some("2200"));
1190 assert_eq!(u.path, "repo.git");
1191 }
1192
1193 #[test]
1194 fn parse_ssh_url_rejects_bad_inputs() {
1195 assert!(parse_ssh_url("ssh://-badhost/repo").is_err());
1196 assert!(parse_ssh_url("host:-dashpath").is_err());
1197 assert!(parse_ssh_url("host:").is_err());
1198 }
1199
1200 #[test]
1201 fn remote_command_is_shell_quoted() {
1202 let cmd = remote_service_cmd(Service::UploadPack, &sq_quote_shell_arg("/srv/repo.git"));
1203 assert_eq!(cmd, "git-upload-pack '/srv/repo.git'");
1204 let q = sq_quote_shell_arg("a'b");
1206 assert_eq!(q, "'a'\\''b'");
1207 let cmd = remote_service_cmd(Service::ReceivePack, &sq_quote_shell_arg("p"));
1209 assert_eq!(cmd, "git-receive-pack 'p'");
1210 }
1211}