1use std::net::ToSocketAddrs;
5use std::path::Path;
6use std::sync::OnceLock;
7
8use anyhow::{bail, Context, Result};
9
10use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
11
12fn git_host_allowlist() -> &'static [String] {
16 static ALLOW: OnceLock<Vec<String>> = OnceLock::new();
17 ALLOW.get_or_init(|| {
18 std::env::var("SLOC_GIT_HOST_ALLOWLIST")
19 .unwrap_or_default()
20 .split(',')
21 .map(|s| s.trim().to_lowercase())
22 .filter(|s| !s.is_empty())
23 .collect()
24 })
25}
26
27fn require_host_allowlist() -> bool {
34 static REQ: OnceLock<bool> = OnceLock::new();
35 *REQ.get_or_init(|| {
36 std::env::var("SLOC_GIT_REQUIRE_ALLOWLIST")
37 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
38 })
39}
40
41fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
44 let mut cmd = std::process::Command::new("git");
45 cmd.env("GIT_TERMINAL_PROMPT", "0")
54 .env("GCM_INTERACTIVE", "never")
55 .env("GIT_ASKPASS", "")
56 .env("SSH_ASKPASS", "");
57 let out = cmd
58 .args(args)
59 .current_dir(repo)
60 .output()
61 .context("failed to spawn git process")?;
62 if !out.status.success() {
63 let stderr = String::from_utf8_lossy(&out.stderr);
64 bail!("git {}: {}", args.first().unwrap_or(&""), stderr.trim());
65 }
66 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
67}
68
69#[must_use]
78pub fn normalize_git_url(raw: &str) -> String {
79 let url = raw.trim();
80 if url.starts_with("git@") || url.starts_with("ssh://") {
81 return url.to_owned();
82 }
83 let scheme = if url.starts_with("https://") {
84 "https"
85 } else if url.starts_with("http://") {
86 "http"
87 } else {
88 return url.to_owned();
89 };
90 let authority_and_path = &url[scheme.len() + 3..];
91 let (host, path) = authority_and_path
92 .find('/')
93 .map_or((authority_and_path, "/"), |i| {
94 (&authority_and_path[..i], &authority_and_path[i..])
95 });
96 let path = path.trim_end_matches('/');
97
98 try_normalize_bitbucket_server(scheme, host, path)
99 .or_else(|| try_normalize_gitlab(scheme, host, path))
100 .or_else(|| try_normalize_github(scheme, host, path))
101 .or_else(|| try_normalize_bitbucket_cloud(scheme, host, path))
102 .unwrap_or_else(|| url.to_owned())
103}
104
105fn try_normalize_bitbucket_server(scheme: &str, host: &str, path: &str) -> Option<String> {
109 let path_lower = path.to_lowercase();
110 let proj_pos = path_lower.find("/projects/")?;
111 let after = &path[proj_pos + "/projects/".len()..];
112 let parts: Vec<&str> = after.splitn(4, '/').collect();
113 if parts.len() < 3 || !parts[1].eq_ignore_ascii_case("repos") {
114 return None;
115 }
116 let context = &path[..proj_pos];
117 let project = parts[0].to_lowercase();
118 let repo = parts[2].trim_end_matches(".git");
119 Some(format!(
120 "{scheme}://{host}{context}/scm/{project}/{repo}.git"
121 ))
122}
123
124fn try_normalize_gitlab(scheme: &str, host: &str, path: &str) -> Option<String> {
127 let idx = path.find("/-/")?;
128 let repo_path = path[..idx].trim_end_matches(".git");
129 Some(format!("{scheme}://{host}{repo_path}.git"))
130}
131
132fn try_normalize_github(scheme: &str, host: &str, path: &str) -> Option<String> {
135 if host != "github.com" && !host.ends_with(".github.com") {
136 return None;
137 }
138 let p = path.trim_start_matches('/');
139 let parts: Vec<&str> = p.splitn(4, '/').collect();
140 if parts.len() < 3
141 || !matches!(
142 parts[2],
143 "tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
144 )
145 {
146 return None;
147 }
148 let owner = parts[0];
149 let repo = parts[1].trim_end_matches(".git");
150 Some(format!("{scheme}://{host}/{owner}/{repo}.git"))
151}
152
153fn try_normalize_bitbucket_cloud(scheme: &str, host: &str, path: &str) -> Option<String> {
156 if host != "bitbucket.org" {
157 return None;
158 }
159 let p = path.trim_start_matches('/');
160 let parts: Vec<&str> = p.splitn(4, '/').collect();
161 if parts.len() < 3 || parts[2] != "src" {
162 return None;
163 }
164 let ws = parts[0];
165 let repo = parts[1].trim_end_matches(".git");
166 Some(format!("{scheme}://{host}/{ws}/{repo}.git"))
167}
168
169fn validate_clone_url(url: &str) -> Result<()> {
172 let lower = url.to_lowercase();
173 let allowed = ["https://", "git://", "ssh://", "git@"];
176 if !allowed.iter().any(|p| lower.starts_with(p)) {
177 bail!(
178 "git URL rejected: only https://, git://, ssh://, and git@ URLs are \
179 permitted (got {url:?})"
180 );
181 }
182 let Some(host) = host_of_git_url(url) else {
189 return Ok(());
190 };
191 check_host_allowed(&host)?;
192 check_resolved_ips(&host, url)?;
193 Ok(())
194}
195
196fn check_host_allowed(host: &str) -> Result<()> {
200 let allow = git_host_allowlist();
206 if allow.is_empty() {
207 if require_host_allowlist() {
208 bail!(
209 "git URL rejected: SLOC_GIT_REQUIRE_ALLOWLIST is set but \
210 SLOC_GIT_HOST_ALLOWLIST is empty (no hosts are permitted)"
211 );
212 }
213 } else if !allow.iter().any(|h| h == host) {
214 bail!("git URL rejected: host {host:?} is not in SLOC_GIT_HOST_ALLOWLIST");
215 }
216 if is_ssrf_blocked_host(host) {
217 bail!(
218 "git URL rejected: loopback, link-local, and cloud-metadata \
219 addresses are not permitted (host {host:?})"
220 );
221 }
222 Ok(())
223}
224
225fn check_resolved_ips(host: &str, url: &str) -> Result<()> {
231 let Some(port) = port_of_git_url(url) else {
232 return Ok(());
233 };
234 let Ok(addrs) = (host, port).to_socket_addrs() else {
235 return Ok(());
236 };
237 for addr in addrs {
238 if is_ssrf_blocked_ip(addr.ip()) {
239 bail!(
240 "git URL rejected: host {host:?} resolves to a blocked \
241 address {} (loopback/link-local/cloud-metadata)",
242 addr.ip()
243 );
244 }
245 }
246 Ok(())
247}
248
249fn host_of_git_url(url: &str) -> Option<String> {
252 let u = url.trim();
253 if let Some(rest) = u.strip_prefix("git@") {
255 let host = rest.split(':').next().unwrap_or(rest);
256 return Some(host.to_lowercase());
257 }
258 let after_scheme = u.split("://").nth(1)?;
260 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
261 let authority = authority.rsplit('@').next().unwrap_or(authority);
263 let host = authority.strip_prefix('[').map_or_else(
265 || authority.split(':').next().unwrap_or(authority).to_string(),
266 |stripped| stripped.split(']').next().unwrap_or(stripped).to_string(),
267 );
268 Some(host.to_lowercase())
269}
270
271fn port_of_git_url(url: &str) -> Option<u16> {
275 let u = url.trim();
276 if u.starts_with("git@") {
278 return Some(22);
279 }
280 let (scheme, after_scheme) = u.split_once("://")?;
281 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
282 let authority = authority.rsplit('@').next().unwrap_or(authority);
283 let explicit = authority.strip_prefix('[').map_or_else(
285 || {
287 authority
288 .rsplit_once(':')
289 .and_then(|(_, p)| p.parse::<u16>().ok())
290 },
291 |stripped| {
293 stripped
294 .split_once("]:")
295 .and_then(|(_, p)| p.parse::<u16>().ok())
296 },
297 );
298 explicit.or_else(|| match scheme.to_lowercase().as_str() {
299 "https" => Some(443),
300 "git" => Some(9418),
301 "ssh" => Some(22),
302 _ => None,
303 })
304}
305
306const BLOCKED_METADATA_HOSTNAMES: &[&str] = &[
308 "metadata.google.internal",
309 "metadata.internal",
310 "instance-data",
311];
312
313fn is_ssrf_blocked_host(host: &str) -> bool {
317 let h = host
318 .trim()
319 .trim_start_matches('[')
320 .trim_end_matches(']')
321 .to_lowercase();
322 if h == "localhost" || BLOCKED_METADATA_HOSTNAMES.contains(&h.as_str()) {
323 return true;
324 }
325 h.parse::<std::net::IpAddr>().is_ok_and(is_ssrf_blocked_ip)
326}
327
328fn is_ssrf_blocked_ip(ip: std::net::IpAddr) -> bool {
331 match ip {
332 std::net::IpAddr::V4(v4) => {
333 v4.is_loopback()
334 || v4.is_link_local()
335 || v4.is_unspecified()
336 || v4.is_broadcast()
337 || v4.is_multicast()
338 || v4.octets() == [100, 100, 100, 200] }
340 std::net::IpAddr::V6(v6) => {
341 v6.is_loopback()
342 || v6.is_unspecified()
343 || v6.is_multicast()
344 || (v6.segments()[0] & 0xffc0) == 0xfe80 }
346 }
347}
348
349pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
358 let normalized = normalize_git_url(url);
359 let url = normalized.as_str();
360 validate_clone_url(url)?;
361 if dest.join(".git").exists() {
364 run_git(
365 dest,
366 &[
367 "-c",
368 "http.followRedirects=false",
369 "fetch",
370 "--all",
371 "--tags",
372 "--prune",
373 ],
374 )?;
375 } else {
376 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
377 let dest_str = dest.to_str().unwrap_or(".");
378 let parent = dest.parent().unwrap_or(dest);
379 run_git(
380 parent,
381 &[
382 "-c",
383 "http.followRedirects=false",
384 "clone",
385 "--no-single-branch",
386 "--depth=50",
387 url,
388 dest_str,
389 ],
390 )?;
391 }
392 Ok(())
393}
394
395pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
400 run_git(repo, &["rev-parse", ref_name])
401}
402
403pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
410 let wt = worktree_path.to_str().unwrap_or(".");
411 run_git(repo, &["worktree", "add", "--detach", wt, ref_name])?;
412 Ok(())
413}
414
415pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
420 let wt = worktree_path.to_str().unwrap_or(".");
421 let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
422 Ok(())
423}
424
425pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
432 Ok(RepoRefs {
433 branches: list_branches(repo)?,
434 tags: list_tags(repo)?,
435 recent_commits: list_commits(repo, "HEAD", 40)?,
436 })
437}
438
439fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
440 let fmt = "%(symref)|%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
446 let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
450 let refs = out
451 .lines()
452 .filter(|l| !l.trim().is_empty())
453 .filter_map(|l| {
455 let (symref, rest) = l.split_once('|')?;
456 if symref.trim().is_empty() {
457 Some(rest)
458 } else {
459 None
460 }
461 })
462 .map(|l| parse_ref_line(l, GitRefKind::Branch))
463 .map(|mut r| {
464 if let Some(slash) = r.name.find('/') {
466 r.name = r.name[slash + 1..].to_owned();
467 }
468 r
469 })
470 .collect::<Vec<_>>();
471 Ok(refs)
472}
473
474fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
475 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
476 let out = run_git(
477 repo,
478 &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
479 )?;
480 Ok(out
481 .lines()
482 .filter(|l| !l.trim().is_empty())
483 .map(|l| parse_ref_line(l, GitRefKind::Tag))
484 .collect())
485}
486
487fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
488 let parts: Vec<&str> = line.splitn(4, '|').collect();
489 let name = parts.first().copied().unwrap_or("").to_owned();
490 let sha = parts.get(1).copied().unwrap_or("").to_owned();
491 let date = parts.get(2).copied().and_then(parse_git_date);
492 let message = parts.get(3).map(|s| (*s).to_owned());
493 GitRef {
494 kind,
495 name,
496 sha,
497 date,
498 message,
499 }
500}
501
502pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
509 let fmt = "%H|%h|%an|%aI|%s";
510 let n = format!("-{limit}");
511 let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
512 Ok(out
513 .lines()
514 .filter(|l| !l.trim().is_empty())
515 .map(parse_commit_line)
516 .collect())
517}
518
519fn parse_commit_line(line: &str) -> GitCommit {
520 let p: Vec<&str> = line.splitn(5, '|').collect();
521 let sha = p.first().copied().unwrap_or("").to_owned();
522 let short_sha = p.get(1).copied().unwrap_or("").to_owned();
523 let author = p.get(2).copied().unwrap_or("").to_owned();
524 let date = p
525 .get(3)
526 .copied()
527 .and_then(parse_git_date)
528 .unwrap_or_default();
529 let subject = p.get(4).copied().unwrap_or("").to_owned();
530 GitCommit {
531 sha,
532 short_sha,
533 author,
534 date,
535 subject,
536 }
537}
538
539fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
540 chrono::DateTime::parse_from_rfc3339(s)
541 .ok()
542 .map(|d| d.with_timezone(&chrono::Utc))
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use crate::GitRefKind;
549 use chrono::Timelike as _;
550
551 #[test]
554 fn is_ssrf_blocked_host_blocks_localhost_and_metadata() {
555 assert!(is_ssrf_blocked_host("localhost"));
556 assert!(is_ssrf_blocked_host("metadata.google.internal"));
557 assert!(is_ssrf_blocked_host("metadata.internal"));
558 assert!(is_ssrf_blocked_host("instance-data"));
559 assert!(is_ssrf_blocked_host(" LOCALHOST "));
561 assert!(is_ssrf_blocked_host("127.0.0.1"));
563 assert!(is_ssrf_blocked_host("[::1]"));
564 assert!(is_ssrf_blocked_host("169.254.169.254"));
565 }
566
567 #[test]
568 fn require_host_allowlist_defaults_false() {
569 assert!(!require_host_allowlist());
571 }
572
573 #[test]
574 fn check_host_allowed_denylist_mode_permits_public_blocks_sensitive() {
575 assert!(check_host_allowed("github.com").is_ok());
577 assert!(check_host_allowed("localhost").is_err());
578 }
579
580 #[test]
581 fn is_ssrf_blocked_host_allows_public_hosts() {
582 assert!(!is_ssrf_blocked_host("github.com"));
583 assert!(!is_ssrf_blocked_host("example.com"));
584 assert!(!is_ssrf_blocked_host("192.168.1.10"));
586 assert!(!is_ssrf_blocked_host("10.0.0.1"));
587 }
588
589 #[test]
592 fn normalize_github_tree_url() {
593 assert_eq!(
594 normalize_git_url("https://github.com/owner/repo/tree/main"),
595 "https://github.com/owner/repo.git"
596 );
597 }
598
599 #[test]
600 fn normalize_github_blob_url() {
601 assert_eq!(
602 normalize_git_url("https://github.com/owner/repo/blob/main/README.md"),
603 "https://github.com/owner/repo.git"
604 );
605 }
606
607 #[test]
608 fn normalize_github_commits_url() {
609 assert_eq!(
610 normalize_git_url("https://github.com/owner/repo/commits/main"),
611 "https://github.com/owner/repo.git"
612 );
613 }
614
615 #[test]
616 fn normalize_github_releases_url() {
617 assert_eq!(
618 normalize_git_url("https://github.com/owner/repo/releases"),
619 "https://github.com/owner/repo.git"
620 );
621 }
622
623 #[test]
624 fn normalize_github_tags_url() {
625 assert_eq!(
626 normalize_git_url("https://github.com/owner/repo/tags"),
627 "https://github.com/owner/repo.git"
628 );
629 }
630
631 #[test]
632 fn normalize_github_branches_url() {
633 assert_eq!(
634 normalize_git_url("https://github.com/owner/repo/branches"),
635 "https://github.com/owner/repo.git"
636 );
637 }
638
639 #[test]
640 fn normalize_github_plain_clone_url_unchanged() {
641 let url = "https://github.com/owner/repo.git";
642 assert_eq!(normalize_git_url(url), url);
643 }
644
645 #[test]
646 fn normalize_gitlab_tree_url() {
647 assert_eq!(
648 normalize_git_url("https://gitlab.com/group/subgroup/repo/-/tree/main"),
649 "https://gitlab.com/group/subgroup/repo.git"
650 );
651 }
652
653 #[test]
654 fn normalize_gitlab_blob_url() {
655 assert_eq!(
656 normalize_git_url("https://gitlab.com/org/repo/-/blob/main/src/lib.rs"),
657 "https://gitlab.com/org/repo.git"
658 );
659 }
660
661 #[test]
662 fn normalize_gitlab_self_hosted() {
663 assert_eq!(
664 normalize_git_url("https://gitlab.corp.com/team/project/-/tree/develop"),
665 "https://gitlab.corp.com/team/project.git"
666 );
667 }
668
669 #[test]
670 fn normalize_bitbucket_server_browse_url() {
671 assert_eq!(
672 normalize_git_url("https://bitbucket.corp.com/projects/MYPROJ/repos/myrepo/browse"),
673 "https://bitbucket.corp.com/scm/myproj/myrepo.git"
674 );
675 }
676
677 #[test]
678 fn normalize_bitbucket_server_with_context() {
679 assert_eq!(
680 normalize_git_url("https://host.com/ctx/projects/PROJ/repos/repo/browse"),
681 "https://host.com/ctx/scm/proj/repo.git"
682 );
683 }
684
685 #[test]
686 fn normalize_bitbucket_cloud_src_url() {
687 assert_eq!(
688 normalize_git_url("https://bitbucket.org/workspace/repo/src/main/README.md"),
689 "https://bitbucket.org/workspace/repo.git"
690 );
691 }
692
693 #[test]
694 fn normalize_ssh_url_unchanged() {
695 let url = "git@github.com:owner/repo.git";
696 assert_eq!(normalize_git_url(url), url);
697 }
698
699 #[test]
700 fn normalize_ssh_protocol_url_unchanged() {
701 let url = "ssh://git@github.com/owner/repo.git";
702 assert_eq!(normalize_git_url(url), url);
703 }
704
705 #[test]
706 fn normalize_trims_leading_trailing_whitespace() {
707 assert_eq!(
708 normalize_git_url(" https://github.com/owner/repo/tree/main "),
709 "https://github.com/owner/repo.git"
710 );
711 }
712
713 #[test]
714 fn normalize_http_url_without_match_returned_unchanged() {
715 let url = "http://internal.corp.com/repo.git";
716 assert_eq!(normalize_git_url(url), url);
717 }
718
719 #[test]
722 fn validate_https_url_ok() {
723 assert!(validate_clone_url("https://github.com/owner/repo.git").is_ok());
724 }
725
726 #[test]
727 fn validate_git_protocol_url_ok() {
728 assert!(validate_clone_url("git://github.com/owner/repo.git").is_ok());
729 }
730
731 #[test]
732 fn validate_ssh_protocol_url_ok() {
733 assert!(validate_clone_url("ssh://git@github.com/owner/repo.git").is_ok());
734 }
735
736 #[test]
737 fn validate_git_at_url_ok() {
738 assert!(validate_clone_url("git@github.com:owner/repo.git").is_ok());
739 }
740
741 #[test]
742 fn validate_http_plain_rejected() {
743 assert!(
744 validate_clone_url("http://github.com/owner/repo.git").is_err(),
745 "plain http:// must be rejected"
746 );
747 }
748
749 #[test]
750 fn validate_link_local_169_254_rejected() {
751 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
752 }
753
754 #[test]
755 fn validate_google_metadata_endpoint_rejected() {
756 assert!(
757 validate_clone_url("https://metadata.google.internal/computeMetadata/v1/").is_err()
758 );
759 }
760
761 #[test]
762 fn validate_alibaba_metadata_rejected() {
763 assert!(validate_clone_url("https://100.100.100.200/latest/meta-data/").is_err());
764 }
765
766 #[test]
767 fn validate_ipv6_fe80_link_local_rejected() {
768 assert!(validate_clone_url("https://[fe80::1]/repo").is_err());
769 }
770
771 #[test]
772 fn validate_file_protocol_rejected() {
773 assert!(validate_clone_url("file:///etc/passwd").is_err());
774 }
775
776 #[test]
777 fn validate_empty_string_rejected() {
778 assert!(validate_clone_url("").is_err());
779 }
780
781 #[test]
782 fn validate_rfc1918_10_allowed() {
783 assert!(validate_clone_url("https://10.0.0.1/repo.git").is_ok());
785 }
786
787 #[test]
788 fn validate_rfc1918_192_168_allowed() {
789 assert!(validate_clone_url("https://192.168.1.1/repo.git").is_ok());
790 }
791
792 #[test]
793 fn validate_rfc1918_172_16_allowed() {
794 assert!(validate_clone_url("https://172.16.0.1/repo.git").is_ok());
795 }
796
797 #[test]
798 fn validate_rfc1918_172_31_allowed() {
799 assert!(validate_clone_url("https://172.31.255.255/repo.git").is_ok());
800 }
801
802 #[test]
803 fn validate_ipv6_ula_fd_allowed() {
804 assert!(validate_clone_url("https://[fd12:3456:789a::1]/repo").is_ok());
806 }
807
808 #[test]
810 fn port_https_default() {
811 assert_eq!(port_of_git_url("https://github.com/o/r.git"), Some(443));
812 }
813
814 #[test]
815 fn port_explicit_overrides_default() {
816 assert_eq!(
817 port_of_git_url("https://gitlab.corp:8443/o/r.git"),
818 Some(8443)
819 );
820 }
821
822 #[test]
823 fn port_git_scheme_default() {
824 assert_eq!(port_of_git_url("git://example.com/r.git"), Some(9418));
825 }
826
827 #[test]
828 fn port_scp_like_is_ssh() {
829 assert_eq!(port_of_git_url("git@github.com:owner/repo.git"), Some(22));
830 }
831
832 #[test]
833 fn port_ipv6_with_explicit_port() {
834 assert_eq!(port_of_git_url("https://[fd00::1]:7000/r"), Some(7000));
835 }
836
837 #[test]
838 fn port_ipv6_default() {
839 assert_eq!(port_of_git_url("https://[fd00::1]/r"), Some(443));
840 }
841
842 #[test]
843 fn validate_metadata_ip_literal_still_rejected() {
844 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
846 }
847
848 #[test]
849 fn validate_loopback_127_rejected() {
850 assert!(validate_clone_url("https://127.0.0.1/repo.git").is_err());
851 }
852
853 #[test]
854 fn validate_localhost_rejected() {
855 assert!(validate_clone_url("https://localhost/repo.git").is_err());
856 }
857
858 #[test]
859 fn validate_unspecified_0_0_0_0_rejected() {
860 assert!(validate_clone_url("https://0.0.0.0/repo.git").is_err());
861 }
862
863 #[test]
866 fn host_of_git_url_https_with_port_and_creds() {
867 assert_eq!(
868 host_of_git_url("https://user:pw@gitlab.corp.com:8443/team/repo.git").as_deref(),
869 Some("gitlab.corp.com")
870 );
871 }
872
873 #[test]
874 fn host_of_git_url_scp_syntax() {
875 assert_eq!(
876 host_of_git_url("git@github.com:owner/repo.git").as_deref(),
877 Some("github.com")
878 );
879 }
880
881 #[test]
882 fn host_of_git_url_ipv6_literal() {
883 assert_eq!(
884 host_of_git_url("https://[fe80::1]:443/repo").as_deref(),
885 Some("fe80::1")
886 );
887 }
888
889 #[test]
890 fn validate_clone_url_path_with_version_number_not_blocked() {
891 assert!(validate_clone_url("https://github.com/acme/release-v10.2.git").is_ok());
893 assert!(validate_clone_url("https://github.com/foo/bar-127-baz.git").is_ok());
894 }
895
896 #[test]
899 fn bitbucket_server_uppercase_project_lowercased() {
900 let r = try_normalize_bitbucket_server(
901 "https",
902 "bb.corp.com",
903 "/projects/PROJ/repos/myrepo/browse",
904 );
905 assert_eq!(
906 r,
907 Some("https://bb.corp.com/scm/proj/myrepo.git".to_owned())
908 );
909 }
910
911 #[test]
912 fn bitbucket_server_without_projects_returns_none() {
913 assert!(
914 try_normalize_bitbucket_server("https", "bb.corp.com", "/scm/proj/repo.git").is_none()
915 );
916 }
917
918 #[test]
919 fn bitbucket_server_missing_repos_segment_returns_none() {
920 assert!(
921 try_normalize_bitbucket_server("https", "bb.corp.com", "/projects/PROJ/browse")
922 .is_none()
923 );
924 }
925
926 #[test]
929 fn gitlab_dash_tree_normalized() {
930 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo/-/tree/main");
931 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
932 }
933
934 #[test]
935 fn gitlab_no_dash_returns_none() {
936 assert!(try_normalize_gitlab("https", "gitlab.com", "/group/repo").is_none());
937 }
938
939 #[test]
940 fn gitlab_strips_existing_dot_git_before_readding() {
941 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo.git/-/tree/main");
942 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
943 }
944
945 #[test]
948 fn github_tree_normalized() {
949 let r = try_normalize_github("https", "github.com", "/owner/repo/tree/main");
950 assert_eq!(r, Some("https://github.com/owner/repo.git".to_owned()));
951 }
952
953 #[test]
954 fn github_non_github_host_returns_none() {
955 assert!(try_normalize_github("https", "gitlab.com", "/owner/repo/tree/main").is_none());
956 }
957
958 #[test]
959 fn github_plain_two_segment_path_returns_none() {
960 assert!(try_normalize_github("https", "github.com", "/owner/repo").is_none());
961 }
962
963 #[test]
964 fn github_unknown_third_segment_returns_none() {
965 assert!(try_normalize_github("https", "github.com", "/owner/repo/wiki").is_none());
966 }
967
968 #[test]
971 fn bitbucket_cloud_src_normalized() {
972 let r = try_normalize_bitbucket_cloud(
973 "https",
974 "bitbucket.org",
975 "/workspace/repo/src/main/README.md",
976 );
977 assert_eq!(
978 r,
979 Some("https://bitbucket.org/workspace/repo.git".to_owned())
980 );
981 }
982
983 #[test]
984 fn bitbucket_cloud_non_bitbucket_host_returns_none() {
985 assert!(
986 try_normalize_bitbucket_cloud("https", "github.com", "/ws/repo/src/main").is_none()
987 );
988 }
989
990 #[test]
991 fn bitbucket_cloud_without_src_segment_returns_none() {
992 assert!(try_normalize_bitbucket_cloud("https", "bitbucket.org", "/ws/repo").is_none());
993 }
994
995 #[test]
998 fn parse_ref_line_all_fields() {
999 let line = "main|abc1234|2024-01-15T10:00:00+00:00|Initial commit";
1000 let r = parse_ref_line(line, GitRefKind::Branch);
1001 assert_eq!(r.name, "main");
1002 assert_eq!(r.sha, "abc1234");
1003 assert!(r.date.is_some());
1004 assert_eq!(r.message.as_deref(), Some("Initial commit"));
1005 assert!(matches!(r.kind, GitRefKind::Branch));
1006 }
1007
1008 #[test]
1009 fn parse_ref_line_tag_kind() {
1010 let line = "v1.0.0|deadbeef|2024-01-01T00:00:00+00:00|Release v1.0.0";
1011 let r = parse_ref_line(line, GitRefKind::Tag);
1012 assert_eq!(r.name, "v1.0.0");
1013 assert!(matches!(r.kind, GitRefKind::Tag));
1014 }
1015
1016 #[test]
1017 fn parse_ref_line_name_only() {
1018 let r = parse_ref_line("main", GitRefKind::Branch);
1019 assert_eq!(r.name, "main");
1020 assert_eq!(r.sha, "");
1021 assert!(r.date.is_none());
1022 assert!(r.message.is_none());
1023 }
1024
1025 #[test]
1026 fn parse_ref_line_invalid_date_gives_none() {
1027 let r = parse_ref_line("main|abc|not-a-date|msg", GitRefKind::Branch);
1028 assert!(r.date.is_none());
1029 assert_eq!(r.message.as_deref(), Some("msg"));
1030 }
1031
1032 #[test]
1033 fn parse_ref_line_empty_string() {
1034 let r = parse_ref_line("", GitRefKind::Branch);
1035 assert_eq!(r.name, "");
1036 }
1037
1038 #[test]
1041 fn parse_commit_line_all_fields() {
1042 let line =
1043 "abc1234567890abcdef|abc1234|Alice Smith|2024-01-15T10:00:00+00:00|Fix critical bug";
1044 let c = parse_commit_line(line);
1045 assert_eq!(c.sha, "abc1234567890abcdef");
1046 assert_eq!(c.short_sha, "abc1234");
1047 assert_eq!(c.author, "Alice Smith");
1048 assert_eq!(c.subject, "Fix critical bug");
1049 }
1050
1051 #[test]
1052 fn parse_commit_line_empty() {
1053 let c = parse_commit_line("");
1054 assert_eq!(c.sha, "");
1055 assert_eq!(c.short_sha, "");
1056 assert_eq!(c.author, "");
1057 assert_eq!(c.subject, "");
1058 }
1059
1060 #[test]
1061 fn parse_commit_line_partial_fields() {
1062 let c = parse_commit_line("sha1|sha_short");
1063 assert_eq!(c.sha, "sha1");
1064 assert_eq!(c.short_sha, "sha_short");
1065 assert_eq!(c.author, "");
1066 }
1067
1068 #[test]
1069 fn parse_commit_line_subject_with_pipe() {
1070 let line = "sha|short|author|2024-01-01T00:00:00+00:00|subject with | pipe inside";
1072 let c = parse_commit_line(line);
1073 assert_eq!(c.subject, "subject with | pipe inside");
1074 }
1075
1076 #[test]
1079 fn parse_git_date_valid_rfc3339() {
1080 let dt = parse_git_date("2024-01-15T10:30:00+00:00");
1081 assert!(dt.is_some());
1082 }
1083
1084 #[test]
1085 fn parse_git_date_invalid_returns_none() {
1086 assert!(parse_git_date("not-a-date").is_none());
1087 assert!(parse_git_date("").is_none());
1088 }
1089
1090 #[test]
1091 fn parse_git_date_with_offset_converts_to_utc() {
1092 let dt = parse_git_date("2024-06-01T12:00:00+05:00").unwrap();
1093 assert_eq!(dt.time().hour(), 7);
1095 }
1096
1097 #[test]
1098 fn port_of_git_url_unknown_scheme_returns_none() {
1099 assert_eq!(port_of_git_url("https://host/repo"), Some(443));
1101 assert_eq!(port_of_git_url("ssh://host/repo"), Some(22));
1102 assert_eq!(port_of_git_url("git://host/repo"), Some(9418));
1103 assert_eq!(port_of_git_url("file://host/repo"), None);
1105 assert_eq!(port_of_git_url("ftp://host/repo"), None);
1106 }
1107}
1108
1109#[cfg(test)]
1116mod git_integration {
1117 use super::*;
1118 use std::path::Path;
1119 use tempfile::tempdir;
1120
1121 fn git(dir: &Path, args: &[&str]) {
1124 let status = std::process::Command::new("git")
1125 .args(args)
1126 .current_dir(dir)
1127 .env("GIT_AUTHOR_NAME", "Test")
1128 .env("GIT_AUTHOR_EMAIL", "test@example.com")
1129 .env("GIT_COMMITTER_NAME", "Test")
1130 .env("GIT_COMMITTER_EMAIL", "test@example.com")
1131 .status()
1132 .expect("git must be on PATH");
1133 assert!(status.success(), "git {args:?} failed");
1134 }
1135
1136 fn make_repo(dir: &Path) {
1138 git(dir, &["init", "-b", "main"]);
1139 std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
1140 git(dir, &["add", "hello.txt"]);
1141 git(dir, &["commit", "--no-gpg-sign", "-m", "initial"]);
1142 }
1143
1144 #[test]
1147 fn run_git_success_returns_stdout() {
1148 let dir = tempdir().unwrap();
1149 make_repo(dir.path());
1150 let sha = run_git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
1152 assert_eq!(sha.len(), 40, "full SHA must be 40 hex chars: {sha}");
1153 }
1154
1155 #[test]
1156 fn run_git_failure_returns_error() {
1157 let dir = tempdir().unwrap();
1158 make_repo(dir.path());
1159 let result = run_git(dir.path(), &["rev-parse", "nonexistent-ref-xyz"]);
1160 assert!(result.is_err(), "nonexistent ref must return an error");
1161 }
1162
1163 #[test]
1166 fn clone_or_fetch_clones_local_repo() {
1167 let src = tempdir().unwrap();
1168 make_repo(src.path());
1169
1170 let dest_root = tempdir().unwrap();
1171 let dest = dest_root.path().join("clone");
1172
1173 std::fs::create_dir_all(&dest).unwrap();
1182 let src_str = src.path().to_str().unwrap();
1183 let dest_str = dest.to_str().unwrap();
1184 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1185 assert!(dest.join(".git").exists(), "clone must create .git dir");
1186
1187 std::fs::write(src.path().join("second.txt"), "v2\n").unwrap();
1189 git(src.path(), &["add", "second.txt"]);
1190 git(src.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
1191
1192 run_git(&dest, &["fetch", "--all", "--tags", "--prune"]).unwrap();
1197 }
1198
1199 #[test]
1200 fn list_branches_excludes_origin_head_symref() {
1201 let src = tempdir().unwrap();
1205 let inner = src.path().join("inner");
1206 std::fs::create_dir_all(&inner).unwrap();
1207 make_repo(&inner);
1208 git(&inner, &["branch", "feature-x"]);
1209
1210 let dest_root = tempdir().unwrap();
1211 let dest = dest_root.path().join("clone");
1212 let src_str = inner.to_str().unwrap();
1213 let dest_str = dest.to_str().unwrap();
1214 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1215 let _ = run_git(&dest, &["remote", "set-head", "origin", "--auto"]);
1217
1218 let branches = list_branches(&dest).unwrap();
1219 let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
1220 assert!(
1221 !names.contains(&"origin"),
1222 "origin/HEAD symref must not appear as a branch: {names:?}"
1223 );
1224 assert!(
1225 names.contains(&"main"),
1226 "main branch must be listed: {names:?}"
1227 );
1228 assert!(
1229 names.contains(&"feature-x"),
1230 "real branches must still be listed: {names:?}"
1231 );
1232 }
1233
1234 #[test]
1235 fn clone_or_fetch_rejects_http_plain_url() {
1236 let dest = tempdir().unwrap();
1237 let result = clone_or_fetch("http://example.com/repo.git", dest.path());
1238 assert!(
1239 result.is_err(),
1240 "http:// must be rejected by validate_clone_url"
1241 );
1242 }
1243
1244 #[test]
1245 fn clone_or_fetch_rejects_link_local_url() {
1246 let dest = tempdir().unwrap();
1247 let result = clone_or_fetch("https://169.254.169.254/repo", dest.path());
1248 assert!(result.is_err());
1249 }
1250
1251 #[test]
1254 fn get_sha_returns_full_commit_hash() {
1255 let dir = tempdir().unwrap();
1256 make_repo(dir.path());
1257 let sha = get_sha(dir.path(), "HEAD").unwrap();
1258 assert_eq!(sha.len(), 40);
1259 assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
1260 }
1261
1262 #[test]
1263 fn get_sha_nonexistent_ref_errors() {
1264 let dir = tempdir().unwrap();
1265 make_repo(dir.path());
1266 assert!(get_sha(dir.path(), "refs/heads/nonexistent").is_err());
1267 }
1268
1269 #[test]
1272 fn list_commits_returns_at_least_one_commit() {
1273 let dir = tempdir().unwrap();
1274 make_repo(dir.path());
1275 let commits = list_commits(dir.path(), "HEAD", 10).unwrap();
1276 assert!(
1277 !commits.is_empty(),
1278 "must return at least the initial commit"
1279 );
1280 let c = &commits[0];
1281 assert_eq!(c.sha.len(), 40);
1282 assert!(!c.short_sha.is_empty());
1283 assert_eq!(c.author, "Test");
1284 assert_eq!(c.subject, "initial");
1285 }
1286
1287 #[test]
1288 fn list_commits_respects_limit() {
1289 let dir = tempdir().unwrap();
1290 make_repo(dir.path());
1291 std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
1293 git(dir.path(), &["add", "b.txt"]);
1294 git(dir.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
1295
1296 let one = list_commits(dir.path(), "HEAD", 1).unwrap();
1297 assert_eq!(one.len(), 1, "limit=1 must return exactly 1 commit");
1298
1299 let two = list_commits(dir.path(), "HEAD", 10).unwrap();
1300 assert_eq!(two.len(), 2, "limit=10 must return both commits");
1301 }
1302
1303 #[test]
1306 fn list_refs_returns_main_branch() {
1307 let src = tempdir().unwrap();
1308 make_repo(src.path());
1309
1310 let dest_root = tempdir().unwrap();
1312 let dest = dest_root.path().join("clone");
1313 let src_str = src.path().to_str().unwrap();
1314 let dest_str = dest.to_str().unwrap();
1315 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
1316
1317 let refs = list_refs(&dest).unwrap();
1318 let branch_names: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
1319 assert!(
1320 branch_names.contains(&"main"),
1321 "branches must include 'main', got: {branch_names:?}"
1322 );
1323 }
1324
1325 #[test]
1326 fn list_refs_returns_tag() {
1327 let src = tempdir().unwrap();
1328 make_repo(src.path());
1329 git(src.path(), &["tag", "v1.0.0"]);
1330
1331 let dest_root = tempdir().unwrap();
1332 let dest = dest_root.path().join("clone");
1333 let src_str = src.path().to_str().unwrap();
1334 run_git(src.path(), &["clone", src_str, dest.to_str().unwrap()]).unwrap();
1335 run_git(&dest, &["fetch", "--tags"]).unwrap();
1337
1338 let refs = list_refs(&dest).unwrap();
1339 let tag_names: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
1340 assert!(
1341 tag_names.contains(&"v1.0.0"),
1342 "tags must include 'v1.0.0', got: {tag_names:?}"
1343 );
1344 }
1345
1346 #[test]
1349 fn create_and_destroy_worktree() {
1350 let repo = tempdir().unwrap();
1351 make_repo(repo.path());
1352
1353 let sha = get_sha(repo.path(), "HEAD").unwrap();
1354
1355 let wt_root = tempdir().unwrap();
1356 let wt_path = wt_root.path().join("worktree");
1357
1358 create_worktree(repo.path(), &sha, &wt_path).unwrap();
1359 assert!(
1360 wt_path.exists(),
1361 "worktree directory must exist after creation"
1362 );
1363 assert!(
1364 wt_path.join("hello.txt").exists(),
1365 "worktree must contain committed files"
1366 );
1367
1368 destroy_worktree(repo.path(), &wt_path).unwrap();
1369 assert!(
1370 !wt_path.exists(),
1371 "worktree directory must be removed after destroy"
1372 );
1373 }
1374
1375 #[test]
1376 fn destroy_worktree_on_nonexistent_path_succeeds() {
1377 let repo = tempdir().unwrap();
1379 make_repo(repo.path());
1380 let nonexistent = repo.path().join("does_not_exist");
1381 assert!(destroy_worktree(repo.path(), &nonexistent).is_ok());
1382 }
1383}