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 let parsed = parse_git_url(url)?;
434 let addr = format!("{}:{}", parsed.host, parsed.port)
435 .to_socket_addrs()
436 .map_err(|e| {
437 Error::Message(format!(
438 "could not resolve git://{}:{}: {e}",
439 parsed.host, parsed.port
440 ))
441 })?
442 .next()
443 .ok_or_else(|| {
444 Error::Message(format!(
445 "no addresses for git://{}:{}",
446 parsed.host, parsed.port
447 ))
448 })?;
449
450 let stream = match self.connect_timeout {
451 Some(t) => TcpStream::connect_timeout(&addr, t),
452 None => TcpStream::connect(addr),
453 }
454 .map_err(|e| {
455 Error::Message(format!(
456 "could not connect to git://{}:{}: {e}",
457 parsed.host, parsed.port
458 ))
459 })?;
460 if let Some(t) = self.io_timeout {
461 let _ = stream.set_read_timeout(Some(t));
462 let _ = stream.set_write_timeout(Some(t));
463 }
464
465 let mut writer = stream
466 .try_clone()
467 .map_err(|e| Error::Message(format!("dup git:// socket: {e}")))?;
468 self.write_request(&mut writer, &parsed, service, opts)?;
469
470 let mut reader = stream;
471 let adv = read_advertisement(&mut reader)?;
472
473 Ok(Box::new(GitDaemonConnection {
474 reader,
475 writer,
476 adv,
477 }))
478 }
479}
480
481#[derive(Clone, Debug, PartialEq, Eq)]
507pub struct SshUrl {
508 pub ssh_host: String,
510 pub path: String,
512 pub scp_style: bool,
514 pub port: Option<String>,
516}
517
518#[must_use]
524pub fn is_ssh_url(url: &str) -> bool {
525 let u = url.trim();
526 if u.starts_with("ext::") {
527 return false;
528 }
529 if u.starts_with("ssh://") || u.starts_with("git+ssh://") {
530 return true;
531 }
532 if u.contains("://") {
533 return false;
534 }
535 !url_is_local_not_ssh(u)
536}
537
538fn url_is_local_not_ssh(url: &str) -> bool {
541 let colon = url.find(':');
542 let slash = url.find('/');
543 match colon {
544 None => true,
545 Some(ci) => slash.is_some_and(|si| si < ci),
546 }
547}
548
549pub fn parse_ssh_url(url: &str) -> Result<SshUrl> {
561 let u = url.trim();
562 if let Some(rest) = u.strip_prefix("git+ssh://") {
563 return parse_ssh_url_form(rest);
564 }
565 if let Some(rest) = u.strip_prefix("ssh://") {
566 return parse_ssh_url_form(rest);
567 }
568 parse_scp_style(u)
569}
570
571fn parse_ssh_url_form(rest: &str) -> Result<SshUrl> {
572 let after_slashes = rest.strip_prefix("//").unwrap_or(rest);
573 let (authority, path_with_sep) = split_ssh_authority_and_path(after_slashes);
574 let (user_host, port) = parse_authority_host_port(authority)?;
575 if user_host.starts_with('-') {
576 return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
577 }
578 let path_after_tilde = if path_with_sep.as_bytes().get(1) == Some(&b'~') {
581 &path_with_sep[1..]
582 } else {
583 path_with_sep.as_str()
584 };
585 let path = normalize_ssh_url_path(path_after_tilde)?;
586 Ok(SshUrl {
587 ssh_host: user_host,
588 path,
589 scp_style: false,
590 port,
591 })
592}
593
594fn split_ssh_authority_and_path(s: &str) -> (&str, String) {
596 let mut depth = 0usize;
597 for (i, ch) in s.char_indices() {
598 match ch {
599 '[' => depth += 1,
600 ']' => depth = depth.saturating_sub(1),
601 '/' if depth == 0 => return (&s[..i], s[i..].to_string()),
602 _ => {}
603 }
604 }
605 (s, String::new())
606}
607
608struct HostEnd {
610 host: String,
611 rest: String,
612 bracketed: bool,
613}
614
615fn host_end_remove_brackets(authority: &str) -> HostEnd {
617 let start_off = match authority.find("@[") {
618 Some(at) => at + 1,
619 None => 0,
620 };
621 let prefix = &authority[..start_off];
622 let start = &authority[start_off..];
623 if let Some(rest) = start.strip_prefix('[') {
624 if let Some(close) = rest.find(']') {
625 let inner = &rest[..close];
626 let after = &rest[close + 1..];
627 return HostEnd {
628 host: format!("{prefix}{inner}"),
629 rest: after.to_string(),
630 bracketed: true,
631 };
632 }
633 }
634 HostEnd {
635 host: authority.to_string(),
636 rest: authority.to_string(),
637 bracketed: false,
638 }
639}
640
641fn get_host_and_port(he: HostEnd) -> (String, Option<String>) {
643 let HostEnd {
644 host,
645 rest,
646 bracketed,
647 } = he;
648 let Some(ci) = rest.find(':') else {
649 return (host, None);
650 };
651 let tail = &rest[ci + 1..];
652 let is_port = !tail.is_empty()
653 && tail.chars().all(|c| c.is_ascii_digit())
654 && tail.parse::<u32>().is_ok_and(|n| n < 65536);
655 if is_port {
656 let trimmed_host = if bracketed {
657 host
658 } else {
659 host[..ci].to_string()
660 };
661 return (trimmed_host, Some(tail.to_string()));
662 }
663 if tail.is_empty() {
664 let trimmed_host = if bracketed {
665 host
666 } else {
667 host[..ci].to_string()
668 };
669 return (trimmed_host, None);
670 }
671 (host, None)
672}
673
674fn get_port(host: String) -> (String, Option<String>) {
676 let Some(ci) = host.find(':') else {
677 return (host, None);
678 };
679 let tail = &host[ci + 1..];
680 if !tail.is_empty()
681 && tail.chars().all(|c| c.is_ascii_digit())
682 && tail.parse::<u32>().is_ok_and(|n| n < 65536)
683 {
684 let h = host[..ci].to_string();
685 let p = tail.to_string();
686 return (h, Some(p));
687 }
688 (host, None)
689}
690
691fn parse_authority_host_port(authority: &str) -> Result<(String, Option<String>)> {
693 let auth = authority.trim();
694 if auth.is_empty() {
695 return Err(Error::Message("ssh: empty host".to_owned()));
696 }
697 let (ssh_host, port) = get_host_and_port(host_end_remove_brackets(auth));
698 let (ssh_host, port) = match port {
699 Some(p) => (ssh_host, Some(p)),
700 None => get_port(ssh_host),
701 };
702 if ssh_host.is_empty() {
703 return Err(Error::Message("ssh: empty host".to_owned()));
704 }
705 if ssh_host.starts_with('-') {
706 return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
707 }
708 Ok((ssh_host, port))
709}
710
711fn parse_scp_style(u: &str) -> Result<SshUrl> {
712 let he = host_end_remove_brackets(u);
713 let sep_search_start = if he.bracketed {
714 u.find(']')
715 .map(|i| i + 1)
716 .ok_or_else(|| Error::Message("ssh: malformed host".to_owned()))?
717 } else {
718 0
719 };
720 let rel_colon = u[sep_search_start..]
721 .find(':')
722 .ok_or_else(|| Error::Message("ssh: no ':' in scp-style url".to_owned()))?;
723 let colon_pos = sep_search_start + rel_colon;
724 let host = &u[..colon_pos];
725 let mut path = &u[colon_pos + 1..];
726
727 if host.is_empty() || path.is_empty() {
728 return Err(Error::Message("ssh: empty host or path".to_owned()));
729 }
730 if host.starts_with('-') {
731 return Err(Error::Message("ssh: hostname starts with '-'".to_owned()));
732 }
733 if path.as_bytes().get(1) == Some(&b'~') {
734 path = &path[1..];
735 }
736 if path.starts_with('-') {
737 return Err(Error::Message("ssh: path starts with '-'".to_owned()));
738 }
739 let (ssh_host, port) = parse_authority_host_port(host)?;
740 Ok(SshUrl {
741 ssh_host,
742 path: path.to_owned(),
743 scp_style: true,
744 port,
745 })
746}
747
748fn normalize_ssh_url_path(path_part: &str) -> Result<String> {
749 if path_part.is_empty() {
750 return Ok(String::new());
751 }
752 let decoded = percent_decode_path(path_part)?;
753 if decoded.starts_with('-') {
754 return Err(Error::Message("ssh: path starts with '-'".to_owned()));
755 }
756 Ok(decoded)
757}
758
759fn percent_decode_path(path: &str) -> Result<String> {
760 let mut out = String::with_capacity(path.len());
761 let mut chars = path.chars().peekable();
762 while let Some(c) = chars.next() {
763 if c == '%' {
764 let h1 = chars
765 .next()
766 .ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
767 let h2 = chars
768 .next()
769 .ok_or_else(|| Error::Message("ssh: bad % escape".to_owned()))?;
770 let byte = u8::from_str_radix(&format!("{h1}{h2}"), 16)
771 .map_err(|_| Error::Message("ssh: bad % escape".to_owned()))?;
772 out.push(byte as char);
773 } else {
774 out.push(c);
775 }
776 }
777 Ok(out)
778}
779
780fn sq_quote_shell_arg(s: &str) -> String {
782 let mut out = String::with_capacity(s.len() + 2);
783 out.push('\'');
784 for ch in s.chars() {
785 match ch {
786 '\'' => out.push_str("'\\''"),
787 '!' => out.push_str("'\\!'"),
788 _ => out.push(ch),
789 }
790 }
791 out.push('\'');
792 out
793}
794
795fn remote_service_cmd(service: Service, quoted_path: &str) -> String {
798 format!("{} {quoted_path}", service.wire_name())
799}
800
801#[derive(Clone, Debug, Default)]
808pub enum SshCommand {
809 #[default]
812 Auto,
813 Program(OsString),
816 ShellCommand(OsString),
819}
820
821impl SshCommand {
822 fn resolve(&self) -> SshCommand {
824 match self {
825 SshCommand::Auto => {
826 if let Some(c) =
827 std::env::var_os("GIT_SSH_COMMAND").filter(|v| !v.is_empty())
828 {
829 SshCommand::ShellCommand(c)
830 } else if let Some(p) = std::env::var_os("GIT_SSH").filter(|v| !v.is_empty()) {
831 SshCommand::Program(p)
832 } else {
833 SshCommand::Program(OsString::from("ssh"))
834 }
835 }
836 other => other.clone(),
837 }
838 }
839}
840
841pub struct SshConnection {
847 child: Child,
848 writer: Option<ChildStdin>,
851 reader: ChildStdout,
852 adv: Advertisement,
853}
854
855impl Connection for SshConnection {
856 fn reader(&mut self) -> &mut dyn Read {
857 &mut self.reader
858 }
859
860 fn writer(&mut self) -> &mut dyn Write {
861 self.writer
862 .as_mut()
863 .expect("ssh connection writer used after finish_send")
864 }
865
866 fn advertised_refs(&self) -> &[(String, ObjectId)] {
867 &self.adv.refs
868 }
869
870 fn capabilities(&self) -> &[String] {
871 &self.adv.capabilities
872 }
873
874 fn head_symref(&self) -> Option<&str> {
875 self.adv.head_symref.as_deref()
876 }
877
878 fn protocol_version(&self) -> u8 {
879 self.adv.protocol_version
880 }
881
882 fn finish_send(&mut self) {
883 self.writer = None;
887 }
888}
889
890impl Drop for SshConnection {
891 fn drop(&mut self) {
892 self.writer = None;
900 let _ = self.child.wait();
902 }
903}
904
905#[derive(Clone, Debug, Default)]
914pub struct SshTransport {
915 pub ssh_command: SshCommand,
917}
918
919impl SshTransport {
920 #[must_use]
923 pub fn new() -> Self {
924 Self::default()
925 }
926
927 #[must_use]
930 pub fn with_program(program: impl Into<OsString>) -> Self {
931 Self {
932 ssh_command: SshCommand::Program(program.into()),
933 }
934 }
935
936 #[must_use]
939 pub fn with_shell_command(command: impl Into<OsString>) -> Self {
940 Self {
941 ssh_command: SshCommand::ShellCommand(command.into()),
942 }
943 }
944
945 fn spawn(&self, spec: &SshUrl, service: Service, opts: &ConnectOptions) -> Result<Child> {
948 let quoted_path = sq_quote_shell_arg(&spec.path);
949 let remote_cmd = remote_service_cmd(service, "ed_path);
950 let port = spec.port.as_deref();
951
952 let mut command = match self.ssh_command.resolve() {
953 SshCommand::ShellCommand(cmd) => {
954 let cmd = cmd.to_string_lossy();
957 let port_opt = match port {
958 Some(p) => format!(" -p {}", shell_words::quote(p)),
959 None => String::new(),
960 };
961 let script = format!(
962 "{cmd}{port_opt} {} {}",
963 shell_words::quote(&spec.ssh_host),
964 shell_words::quote(&remote_cmd),
965 );
966 let mut c = Command::new("sh");
967 c.arg("-c").arg(script);
968 c
969 }
970 SshCommand::Program(prog) => {
971 let mut c = Command::new(&prog);
973 if let Some(p) = port {
974 c.arg("-p").arg(p);
975 }
976 c.arg(&spec.ssh_host).arg(&remote_cmd);
977 c
978 }
979 SshCommand::Auto => unreachable!("SshCommand::resolve never yields Auto"),
981 };
982
983 if opts.protocol_version > 0 {
990 command.env("GIT_PROTOCOL", format!("version={}", opts.protocol_version));
991 }
992
993 command
994 .stdin(Stdio::piped())
995 .stdout(Stdio::piped())
996 .stderr(Stdio::inherit())
997 .spawn()
998 .map_err(|e| Error::Message(format!("failed to spawn ssh for {}: {e}", spec.ssh_host)))
999 }
1000}
1001
1002impl Transport for SshTransport {
1003 fn connect(
1004 &self,
1005 url: &str,
1006 service: Service,
1007 opts: &ConnectOptions,
1008 ) -> Result<Box<dyn Connection>> {
1009 let spec = parse_ssh_url(url)?;
1010 let mut child = self.spawn(&spec, service, opts)?;
1011
1012 let writer = child
1013 .stdin
1014 .take()
1015 .ok_or_else(|| Error::Message("ssh child has no stdin".to_owned()))?;
1016 let mut reader = child
1017 .stdout
1018 .take()
1019 .ok_or_else(|| Error::Message("ssh child has no stdout".to_owned()))?;
1020
1021 let adv = read_advertisement(&mut reader)?;
1022
1023 Ok(Box::new(SshConnection {
1024 child,
1025 writer: Some(writer),
1026 reader,
1027 adv,
1028 }))
1029 }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034 use super::*;
1035
1036 #[test]
1037 fn parse_git_url_defaults_and_ports() {
1038 let u = parse_git_url("git://example.com/repo.git").unwrap();
1039 assert_eq!(u.host, "example.com");
1040 assert_eq!(u.port, 9418);
1041 assert_eq!(u.path, "/repo.git");
1042
1043 let u = parse_git_url("git://example.com:9999/a/b").unwrap();
1044 assert_eq!(u.port, 9999);
1045 assert_eq!(u.path, "/a/b");
1046
1047 let u = parse_git_url("git://[::1]:1234/x").unwrap();
1048 assert_eq!(u.host, "::1");
1049 assert_eq!(u.port, 1234);
1050 assert_eq!(u.path, "/x");
1051
1052 assert!(parse_git_url("https://x/y").is_err());
1053 assert!(parse_git_url("git://host").is_err());
1054 }
1055
1056 #[test]
1057 fn parse_advertisement_line_sha1_and_sha256() {
1058 let sha1 = "1234567890123456789012345678901234567890 refs/heads/main\0caps here";
1059 let (oid, name, caps) = parse_ref_advertisement_line(sha1).unwrap();
1060 assert_eq!(oid.to_hex(), "1234567890123456789012345678901234567890");
1061 assert_eq!(name, "refs/heads/main");
1062 assert_eq!(caps, "caps here");
1063
1064 let hex64 = "0".repeat(64);
1065 let line = format!("{hex64} refs/heads/x");
1066 let (oid, name, caps) = parse_ref_advertisement_line(&line).unwrap();
1067 assert_eq!(oid.to_hex().len(), 64);
1068 assert_eq!(name, "refs/heads/x");
1069 assert_eq!(caps, "");
1070
1071 assert!(parse_ref_advertisement_line("shallow abc").is_none());
1072 }
1073
1074 #[test]
1075 fn read_advertisement_captures_refs_caps_and_symref() {
1076 let mut buf: Vec<u8> = Vec::new();
1077 let main = "1111111111111111111111111111111111111111";
1078 let head = format!(
1079 "{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2",
1080 );
1081 pkt_line::write_line_to_vec(&mut buf, &head).unwrap();
1082 let r = format!("{main} refs/heads/main");
1083 pkt_line::write_line_to_vec(&mut buf, &r).unwrap();
1084 let tag = "2222222222222222222222222222222222222222";
1085 let t = format!("{tag} refs/tags/v1");
1086 pkt_line::write_line_to_vec(&mut buf, &t).unwrap();
1087 let peeled = format!("{main} refs/tags/v1^{{}}");
1088 pkt_line::write_line_to_vec(&mut buf, &peeled).unwrap();
1089 buf.extend_from_slice(b"0000");
1090
1091 let mut cur = std::io::Cursor::new(buf);
1092 let adv = read_advertisement(&mut cur).unwrap();
1093 assert_eq!(adv.head_symref.as_deref(), Some("refs/heads/main"));
1094 assert!(adv.capabilities.iter().any(|c| c == "multi_ack"));
1095 let names: Vec<&str> = adv.refs.iter().map(|(n, _)| n.as_str()).collect();
1097 assert_eq!(names, vec!["refs/heads/main", "refs/tags/v1"]);
1098 }
1099
1100 #[test]
1101 fn read_advertisement_v2_captures_caps_and_no_refs() {
1102 let mut buf: Vec<u8> = Vec::new();
1104 pkt_line::write_line_to_vec(&mut buf, "version 2").unwrap();
1105 pkt_line::write_line_to_vec(&mut buf, "agent=git/2.43.0").unwrap();
1106 pkt_line::write_line_to_vec(&mut buf, "ls-refs=unborn").unwrap();
1107 pkt_line::write_line_to_vec(&mut buf, "fetch=shallow wait-for-done filter").unwrap();
1108 pkt_line::write_line_to_vec(&mut buf, "object-format=sha1").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.protocol_version, 2);
1114 assert!(adv.refs.is_empty(), "v2 advertisement carries no refs");
1115 assert!(adv.capabilities.iter().any(|c| c == "agent=git/2.43.0"));
1116 assert!(adv
1117 .capabilities
1118 .iter()
1119 .any(|c| c == "fetch=shallow wait-for-done filter"));
1120 assert!(adv.capabilities.iter().any(|c| c == "object-format=sha1"));
1121 assert!(adv.head_symref.is_none());
1122 }
1123
1124 #[test]
1125 fn is_ssh_url_classification() {
1126 assert!(is_ssh_url("ssh://host/repo.git"));
1127 assert!(is_ssh_url("git+ssh://host/repo.git"));
1128 assert!(is_ssh_url("user@host:repo.git"));
1129 assert!(is_ssh_url("host:path/to/repo"));
1130 assert!(!is_ssh_url("/abs/local/repo"));
1132 assert!(!is_ssh_url("./relative"));
1133 assert!(!is_ssh_url("git://host/repo.git"));
1134 assert!(!is_ssh_url("https://host/repo.git"));
1135 assert!(!is_ssh_url("ext::sh -c foo"));
1136 assert!(!is_ssh_url("./a:b"));
1138 }
1139
1140 #[test]
1141 fn parse_scp_style_url() {
1142 let u = parse_ssh_url("git@example.com:my/repo.git").unwrap();
1143 assert_eq!(u.ssh_host, "git@example.com");
1144 assert_eq!(u.path, "my/repo.git");
1145 assert!(u.scp_style);
1146 assert_eq!(u.port, None);
1147 }
1148
1149 #[test]
1150 fn parse_ssh_scheme_url_with_port() {
1151 let u = parse_ssh_url("ssh://git@example.com:2222/srv/repo.git").unwrap();
1152 assert_eq!(u.ssh_host, "git@example.com");
1153 assert_eq!(u.path, "/srv/repo.git");
1154 assert!(!u.scp_style);
1155 assert_eq!(u.port.as_deref(), Some("2222"));
1156 }
1157
1158 #[test]
1159 fn parse_ssh_url_ipv6_and_tilde() {
1160 let u = parse_ssh_url("ssh://git@[::1]:2222/~/repo.git").unwrap();
1161 assert_eq!(u.ssh_host, "git@::1");
1162 assert_eq!(u.port.as_deref(), Some("2222"));
1163 assert_eq!(u.path, "~/repo.git");
1165
1166 let u = parse_ssh_url("[git@host:2200]:repo.git").unwrap();
1168 assert_eq!(u.ssh_host, "git@host");
1169 assert_eq!(u.port.as_deref(), Some("2200"));
1170 assert_eq!(u.path, "repo.git");
1171 }
1172
1173 #[test]
1174 fn parse_ssh_url_rejects_bad_inputs() {
1175 assert!(parse_ssh_url("ssh://-badhost/repo").is_err());
1176 assert!(parse_ssh_url("host:-dashpath").is_err());
1177 assert!(parse_ssh_url("host:").is_err());
1178 }
1179
1180 #[test]
1181 fn remote_command_is_shell_quoted() {
1182 let cmd = remote_service_cmd(Service::UploadPack, &sq_quote_shell_arg("/srv/repo.git"));
1183 assert_eq!(cmd, "git-upload-pack '/srv/repo.git'");
1184 let q = sq_quote_shell_arg("a'b");
1186 assert_eq!(q, "'a'\\''b'");
1187 let cmd = remote_service_cmd(Service::ReceivePack, &sq_quote_shell_arg("p"));
1189 assert_eq!(cmd, "git-receive-pack 'p'");
1190 }
1191}