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