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