1use std::io::Read as _;
5use std::path::{Path, PathBuf};
6use std::process::Stdio;
7use std::sync::OnceLock;
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result, bail};
11
12use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
13
14fn git_host_allowlist() -> &'static [String] {
18 static ALLOW: OnceLock<Vec<String>> = OnceLock::new();
19 ALLOW.get_or_init(|| {
20 std::env::var("SLOC_GIT_HOST_ALLOWLIST")
21 .unwrap_or_default()
22 .split(',')
23 .map(|s| s.trim().to_lowercase())
24 .filter(|s| !s.is_empty())
25 .collect()
26 })
27}
28
29#[must_use]
33pub fn host_allowlist_configured() -> bool {
34 !git_host_allowlist().is_empty()
35}
36
37fn require_host_allowlist() -> bool {
44 static REQ: OnceLock<bool> = OnceLock::new();
45 *REQ.get_or_init(|| {
46 std::env::var("SLOC_GIT_REQUIRE_ALLOWLIST")
47 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
48 })
49}
50
51fn ssl_no_verify() -> bool {
57 static NO_VERIFY: OnceLock<bool> = OnceLock::new();
58 *NO_VERIFY.get_or_init(|| std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some())
59}
60
61fn git_timeout() -> Duration {
65 static TIMEOUT: OnceLock<Duration> = OnceLock::new();
66 *TIMEOUT.get_or_init(|| {
67 let secs = std::env::var("SLOC_GIT_TIMEOUT")
68 .ok()
69 .and_then(|v| v.parse::<u64>().ok())
70 .filter(|&s| s > 0)
71 .unwrap_or(300);
72 Duration::from_secs(secs)
73 })
74}
75
76enum GitCredential {
84 Https { user: String, token: String },
86 Ssh { key_path: String },
88}
89
90fn hostkey(host: &str) -> String {
98 host.chars()
99 .map(|c| {
100 if c.is_ascii_alphanumeric() {
101 c.to_ascii_uppercase()
102 } else {
103 '_'
104 }
105 })
106 .collect()
107}
108
109fn resolve_credential(host: &str, port: Option<u16>) -> Option<GitCredential> {
120 let mut keys: Vec<String> = Vec::with_capacity(2);
125 if let Some(pt) = port {
126 keys.push(hostkey(&format!("{host}:{pt}")));
127 }
128 keys.push(hostkey(host));
129 for key in &keys {
130 if let Ok(v) = std::env::var(format!("SLOC_GIT_CRED_{key}"))
131 && let Some((user, token)) = v.split_once(':')
132 && !token.is_empty()
133 {
134 return Some(GitCredential::Https {
135 user: user.to_owned(),
136 token: token.to_owned(),
137 });
138 }
139 if let Ok(p) = std::env::var(format!("SLOC_GIT_SSHKEY_{key}"))
140 && !p.trim().is_empty()
141 {
142 return Some(GitCredential::Ssh { key_path: p });
143 }
144 }
145 cred_from_file(host)
146}
147
148fn cred_from_file(host: &str) -> Option<GitCredential> {
155 let path = std::env::var("SLOC_GIT_CRED_FILE").ok()?;
156 let path = path.trim();
157 if path.is_empty() {
158 return None;
159 }
160 warn_if_world_readable(path);
161 let content = std::fs::read_to_string(path).ok()?;
162 let host_lower = host.to_lowercase();
163 for line in content.lines() {
164 let line = line.trim();
165 if line.is_empty() || line.starts_with('#') {
166 continue;
167 }
168 let Some((k, v)) = line.split_once('=') else {
169 continue;
170 };
171 if k.trim().trim_matches('"').to_lowercase() != host_lower {
172 continue;
173 }
174 let v = v.trim().trim_matches('"');
175 if let Some((user, token)) = v.split_once(':')
176 && !token.is_empty()
177 {
178 return Some(GitCredential::Https {
179 user: user.to_owned(),
180 token: token.to_owned(),
181 });
182 }
183 }
184 None
185}
186
187#[cfg(unix)]
189fn warn_if_world_readable(path: &str) {
190 use std::os::unix::fs::PermissionsExt as _;
191 if let Ok(meta) = std::fs::metadata(path)
192 && meta.permissions().mode() & 0o077 != 0
193 {
194 eprintln!(
195 "warning: SLOC_GIT_CRED_FILE {path:?} is group/world-readable; \
196 restrict it with chmod 600"
197 );
198 }
199}
200
201#[cfg(not(unix))]
202fn warn_if_world_readable(_path: &str) {}
203
204#[derive(Default)]
213struct CredInjection {
214 config: Vec<String>,
215 env: Vec<(String, String)>,
216}
217
218fn cred_injection(host: &str, port: Option<u16>) -> CredInjection {
219 match resolve_credential(host, port) {
220 Some(GitCredential::Https { user, token }) => CredInjection {
221 config: vec![
222 "credential.helper=".to_owned(),
223 "credential.helper=!f() { test \"$1\" = get && echo \"username=$GIT_U\" && \
224 echo \"password=$GIT_P\"; }; f"
225 .to_owned(),
226 ],
227 env: vec![("GIT_U".to_owned(), user), ("GIT_P".to_owned(), token)],
228 },
229 Some(GitCredential::Ssh { key_path }) => {
230 let mut ssh = format!("ssh -i \"{key_path}\" -o IdentitiesOnly=yes -o BatchMode=yes");
231 if ssh_accept_new() {
235 ssh.push_str(" -o StrictHostKeyChecking=accept-new");
236 }
237 CredInjection {
238 config: Vec::new(),
239 env: vec![("GIT_SSH_COMMAND".to_owned(), ssh)],
240 }
241 }
242 None => CredInjection::default(),
243 }
244}
245
246fn ssh_accept_new() -> bool {
251 std::env::var("SLOC_GIT_SSH_ACCEPT_NEW")
252 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
253}
254
255fn allow_local() -> bool {
261 std::env::var("SLOC_GIT_ALLOW_LOCAL").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
262}
263
264fn local_root() -> Option<PathBuf> {
268 std::env::var("SLOC_GIT_LOCAL_ROOT")
269 .ok()
270 .map(PathBuf::from)
271 .filter(|p| !p.as_os_str().is_empty())
272}
273
274fn network_git_config() -> Vec<String> {
289 let mut cfg = vec![
290 "http.followRedirects=false".to_owned(),
291 "http.lowSpeedLimit=1000".to_owned(),
292 "http.lowSpeedTime=30".to_owned(),
293 ];
294 if cfg!(windows) {
295 cfg.push("http.sslBackend=schannel".to_owned());
296 }
297 if ssl_no_verify() {
298 cfg.push("http.sslVerify=false".to_owned());
299 }
300 cfg
301}
302
303fn with_config<'a>(cfg: &'a [String], tail: &[&'a str]) -> Vec<&'a str> {
305 let mut v = Vec::with_capacity(cfg.len() * 2 + tail.len());
306 for c in cfg {
307 v.push("-c");
308 v.push(c.as_str());
309 }
310 v.extend_from_slice(tail);
311 v
312}
313
314fn persist_repo_config(dest: &Path, cfg: &[String]) {
321 let mut helper_reset = false;
322 for kv in cfg {
323 if let Some((key, value)) = kv.split_once('=') {
324 if key == "credential.helper" {
325 if !helper_reset {
332 let _ = run_git(dest, &["config", "--unset-all", "credential.helper"]);
333 helper_reset = true;
334 }
335 let _ = run_git(dest, &["config", "--add", "credential.helper", value]);
336 } else {
337 let _ = run_git(dest, &["config", key, value]);
338 }
339 }
340 }
341}
342
343fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
346 run_git_env(repo, args, &[])
347}
348
349fn run_git_env(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> Result<String> {
354 let mut cmd = std::process::Command::new("git");
355 cmd.env("GIT_TERMINAL_PROMPT", "0")
364 .env("GCM_INTERACTIVE", "never")
365 .env("GIT_ASKPASS", "")
366 .env("SSH_ASKPASS", "");
367 for (k, v) in extra_env {
370 cmd.env(k, v);
371 }
372 cmd.args(args)
373 .current_dir(repo)
374 .stdin(Stdio::null())
375 .stdout(Stdio::piped())
376 .stderr(Stdio::piped());
377 let mut child = cmd.spawn().context("failed to spawn git process")?;
378
379 let mut out_pipe = child.stdout.take();
383 let mut err_pipe = child.stderr.take();
384 let out_handle = std::thread::spawn(move || {
385 let mut buf = Vec::new();
386 if let Some(p) = out_pipe.as_mut() {
387 let _ = p.read_to_end(&mut buf);
388 }
389 buf
390 });
391 let err_handle = std::thread::spawn(move || {
392 let mut buf = Vec::new();
393 if let Some(p) = err_pipe.as_mut() {
394 let _ = p.read_to_end(&mut buf);
395 }
396 buf
397 });
398
399 let timeout = git_timeout();
401 let start = Instant::now();
402 let status = loop {
403 if let Some(status) = child.try_wait().context("failed to poll git process")? {
404 break status;
405 }
406 if start.elapsed() >= timeout {
407 let _ = child.kill();
408 let _ = child.wait();
409 bail!(
410 "git {} timed out after {}s — the remote did not respond in time. \
411 On a corporate network this usually means a proxy or VPN is slow or \
412 blocking the connection. Raise the ceiling with SLOC_GIT_TIMEOUT=<seconds>, \
413 or check your proxy/VPN configuration.",
414 args.first().copied().unwrap_or(""),
415 timeout.as_secs()
416 );
417 }
418 std::thread::sleep(Duration::from_millis(100));
419 };
420
421 let stdout = out_handle.join().unwrap_or_default();
422 let stderr = err_handle.join().unwrap_or_default();
423 if !status.success() {
424 let stderr = String::from_utf8_lossy(&stderr);
425 bail!(
426 "git {}: {}",
427 args.first().copied().unwrap_or(""),
428 stderr.trim()
429 );
430 }
431 Ok(String::from_utf8_lossy(&stdout).trim().to_owned())
432}
433
434#[must_use]
443pub fn normalize_git_url(raw: &str) -> String {
444 let url = raw.trim();
445 if url.starts_with("git@") || url.starts_with("ssh://") {
446 return url.to_owned();
447 }
448 let scheme = if url.starts_with("https://") {
449 "https"
450 } else if url.starts_with("http://") {
451 "http"
452 } else {
453 return url.to_owned();
454 };
455 let authority_and_path = &url[scheme.len() + 3..];
456 let (host, path) = authority_and_path
457 .find('/')
458 .map_or((authority_and_path, "/"), |i| {
459 (&authority_and_path[..i], &authority_and_path[i..])
460 });
461 let path = path.trim_end_matches('/');
462
463 try_normalize_bitbucket_server(scheme, host, path)
464 .or_else(|| try_normalize_gitlab(scheme, host, path))
465 .or_else(|| try_normalize_github(scheme, host, path))
466 .or_else(|| try_normalize_bitbucket_cloud(scheme, host, path))
467 .unwrap_or_else(|| url.to_owned())
468}
469
470fn try_normalize_bitbucket_server(scheme: &str, host: &str, path: &str) -> Option<String> {
474 let path_lower = path.to_lowercase();
475 let proj_pos = path_lower.find("/projects/")?;
476 let after = &path[proj_pos + "/projects/".len()..];
477 let parts: Vec<&str> = after.splitn(4, '/').collect();
478 if parts.len() < 3 || !parts[1].eq_ignore_ascii_case("repos") {
479 return None;
480 }
481 let context = &path[..proj_pos];
482 let project = parts[0].to_lowercase();
483 let repo = parts[2].trim_end_matches(".git");
484 Some(format!(
485 "{scheme}://{host}{context}/scm/{project}/{repo}.git"
486 ))
487}
488
489fn try_normalize_gitlab(scheme: &str, host: &str, path: &str) -> Option<String> {
492 let idx = path.find("/-/")?;
493 let repo_path = path[..idx].trim_end_matches(".git");
494 Some(format!("{scheme}://{host}{repo_path}.git"))
495}
496
497fn try_normalize_github(scheme: &str, host: &str, path: &str) -> Option<String> {
500 if host != "github.com" && !host.ends_with(".github.com") {
501 return None;
502 }
503 let p = path.trim_start_matches('/');
504 let parts: Vec<&str> = p.splitn(4, '/').collect();
505 if parts.len() < 3
506 || !matches!(
507 parts[2],
508 "tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
509 )
510 {
511 return None;
512 }
513 let owner = parts[0];
514 let repo = parts[1].trim_end_matches(".git");
515 Some(format!("{scheme}://{host}/{owner}/{repo}.git"))
516}
517
518fn try_normalize_bitbucket_cloud(scheme: &str, host: &str, path: &str) -> Option<String> {
521 if host != "bitbucket.org" {
522 return None;
523 }
524 let p = path.trim_start_matches('/');
525 let parts: Vec<&str> = p.splitn(4, '/').collect();
526 if parts.len() < 3 || parts[2] != "src" {
527 return None;
528 }
529 let ws = parts[0];
530 let repo = parts[1].trim_end_matches(".git");
531 Some(format!("{scheme}://{host}/{ws}/{repo}.git"))
532}
533
534fn validate_clone_url(url: &str) -> Result<()> {
537 let lower = url.to_lowercase();
538 let allowed = ["https://", "git://", "ssh://", "git@"];
541 if !allowed.iter().any(|p| lower.starts_with(p)) {
542 bail!(
543 "git URL rejected: only https://, git://, ssh://, and git@ URLs are \
544 permitted (got {url:?})"
545 );
546 }
547 let Some(host) = host_of_git_url(url) else {
554 return Ok(());
555 };
556 check_host_allowed(&host)?;
557 check_resolved_ips(&host, url)?;
558 Ok(())
559}
560
561fn check_host_allowed(host: &str) -> Result<()> {
565 let allow = git_host_allowlist();
571 if allow.is_empty() {
572 if require_host_allowlist() {
573 bail!(
574 "git URL rejected: SLOC_GIT_REQUIRE_ALLOWLIST is set but \
575 SLOC_GIT_HOST_ALLOWLIST is empty (no hosts are permitted)"
576 );
577 }
578 } else if !allow.iter().any(|h| h == host) {
579 bail!("git URL rejected: host {host:?} is not in SLOC_GIT_HOST_ALLOWLIST");
580 }
581 if is_ssrf_blocked_host(host) {
582 bail!(
583 "git URL rejected: loopback, link-local, and cloud-metadata \
584 addresses are not permitted (host {host:?})"
585 );
586 }
587 Ok(())
588}
589
590fn check_resolved_ips(host: &str, url: &str) -> Result<()> {
596 let Some(port) = port_of_git_url(url) else {
597 return Ok(());
598 };
599 let Ok(addrs) = resolve_host_port(host, port) else {
600 return Ok(());
601 };
602 for addr in addrs {
603 if is_ssrf_blocked_ip(addr.ip()) {
604 bail!(
605 "git URL rejected: host {host:?} resolves to a blocked \
606 address {} (loopback/link-local/cloud-metadata)",
607 addr.ip()
608 );
609 }
610 }
611 Ok(())
612}
613
614#[cfg(not(test))]
621fn resolve_host_port(
622 host: &str,
623 port: u16,
624) -> std::io::Result<std::vec::IntoIter<std::net::SocketAddr>> {
625 use std::net::ToSocketAddrs as _;
626 (host, port).to_socket_addrs()
627}
628
629#[cfg(test)]
630fn resolve_host_port(
631 host: &str,
632 port: u16,
633) -> std::io::Result<std::vec::IntoIter<std::net::SocketAddr>> {
634 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
635 let ip = host
639 .parse::<IpAddr>()
640 .unwrap_or(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)));
641 Ok(vec![SocketAddr::new(ip, port)].into_iter())
642}
643
644fn host_of_git_url(url: &str) -> Option<String> {
647 let u = url.trim();
648 if let Some(rest) = u.strip_prefix("git@") {
650 let host = rest.split(':').next().unwrap_or(rest);
651 return Some(host.to_lowercase());
652 }
653 let after_scheme = u.split("://").nth(1)?;
655 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
656 let authority = authority.rsplit('@').next().unwrap_or(authority);
658 let host = authority.strip_prefix('[').map_or_else(
660 || authority.split(':').next().unwrap_or(authority).to_string(),
661 |stripped| stripped.split(']').next().unwrap_or(stripped).to_string(),
662 );
663 Some(host.to_lowercase())
664}
665
666fn port_of_git_url(url: &str) -> Option<u16> {
670 let u = url.trim();
671 if u.starts_with("git@") {
673 return Some(22);
674 }
675 let (scheme, after_scheme) = u.split_once("://")?;
676 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
677 let authority = authority.rsplit('@').next().unwrap_or(authority);
678 let explicit = authority.strip_prefix('[').map_or_else(
680 || {
682 authority
683 .rsplit_once(':')
684 .and_then(|(_, p)| p.parse::<u16>().ok())
685 },
686 |stripped| {
688 stripped
689 .split_once("]:")
690 .and_then(|(_, p)| p.parse::<u16>().ok())
691 },
692 );
693 explicit.or_else(|| match scheme.to_lowercase().as_str() {
694 "https" => Some(443),
695 "git" => Some(9418),
696 "ssh" => Some(22),
697 _ => None,
698 })
699}
700
701fn explicit_port_of_git_url(url: &str) -> Option<u16> {
707 let u = url.trim();
708 if u.starts_with("git@") {
709 return None;
710 }
711 let (_scheme, after_scheme) = u.split_once("://")?;
712 let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
713 let authority = authority.rsplit('@').next().unwrap_or(authority);
714 authority.strip_prefix('[').map_or_else(
715 || {
716 authority
717 .rsplit_once(':')
718 .and_then(|(_, p)| p.parse::<u16>().ok())
719 },
720 |stripped| {
721 stripped
722 .split_once("]:")
723 .and_then(|(_, p)| p.parse::<u16>().ok())
724 },
725 )
726}
727
728const BLOCKED_METADATA_HOSTNAMES: &[&str] = &[
730 "metadata.google.internal",
731 "metadata.internal",
732 "instance-data",
733];
734
735fn is_ssrf_blocked_host(host: &str) -> bool {
739 let h = host
740 .trim()
741 .trim_start_matches('[')
742 .trim_end_matches(']')
743 .to_lowercase();
744 if h == "localhost" || BLOCKED_METADATA_HOSTNAMES.contains(&h.as_str()) {
745 return true;
746 }
747 h.parse::<std::net::IpAddr>().is_ok_and(is_ssrf_blocked_ip)
748}
749
750fn is_ssrf_blocked_ip(ip: std::net::IpAddr) -> bool {
753 match ip {
754 std::net::IpAddr::V4(v4) => {
755 v4.is_loopback()
756 || v4.is_link_local()
757 || v4.is_unspecified()
758 || v4.is_broadcast()
759 || v4.is_multicast()
760 || v4.octets() == [100, 100, 100, 200] }
762 std::net::IpAddr::V6(v6) => {
763 v6.is_loopback()
764 || v6.is_unspecified()
765 || v6.is_multicast()
766 || (v6.segments()[0] & 0xffc0) == 0xfe80 }
768 }
769}
770
771enum GitSource {
774 Remote,
776 FileUrl,
778 LocalPath,
781 Bundle,
783}
784
785fn classify_source(url: &str) -> GitSource {
787 let u = url.trim();
788 let lower = u.to_lowercase();
789 if lower.starts_with("https://")
790 || lower.starts_with("http://")
791 || lower.starts_with("git://")
792 || lower.starts_with("ssh://")
793 || u.starts_with("git@")
794 {
795 GitSource::Remote
796 } else if lower.starts_with("file://") {
797 GitSource::FileUrl
798 } else if lower.ends_with(".bundle") {
799 GitSource::Bundle
800 } else {
801 GitSource::LocalPath
802 }
803}
804
805pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
817 let normalized = normalize_git_url(url);
818 let url = normalized.as_str();
819 match classify_source(url) {
820 GitSource::Remote => clone_or_fetch_remote(url, dest),
821 source => clone_or_fetch_local(url, dest, &source),
822 }
823}
824
825fn clone_or_fetch_remote(url: &str, dest: &Path) -> Result<()> {
828 validate_clone_url(url)?;
829 let mut cfg = network_git_config();
833 let inj = host_of_git_url(url)
835 .map(|h| cred_injection(&h, explicit_port_of_git_url(url)))
836 .unwrap_or_default();
837 cfg.extend(inj.config.iter().cloned());
838 let env: Vec<(&str, &str)> = inj
839 .env
840 .iter()
841 .map(|(k, v)| (k.as_str(), v.as_str()))
842 .collect();
843
844 if dest.join(".git").exists() {
845 let args = with_config(&cfg, &["fetch", "--all", "--tags", "--prune"]);
846 run_git_env(dest, &args, &env)?;
847 return Ok(());
848 }
849
850 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
851 let dest_str = dest.to_str().unwrap_or(".");
852 let parent = dest.parent().unwrap_or(dest);
853
854 let fast = with_config(
860 &cfg,
861 &[
862 "clone",
863 "--filter=blob:none",
864 "--no-checkout",
865 "--no-single-branch",
866 url,
867 dest_str,
868 ],
869 );
870 if let Err(e) = run_git_env(parent, &fast, &env) {
871 let msg = e.to_string().to_lowercase();
877 if !(msg.contains("filter") || msg.contains("partial")) {
878 return Err(e);
879 }
880 let _ = std::fs::remove_dir_all(dest);
881 std::fs::create_dir_all(dest).context("failed to re-create clone directory")?;
882 let full = with_config(
883 &cfg,
884 &[
885 "clone",
886 "--no-checkout",
887 "--no-single-branch",
888 url,
889 dest_str,
890 ],
891 );
892 run_git_env(parent, &full, &env)?;
893 }
894 persist_repo_config(dest, &cfg);
895 Ok(())
896}
897
898fn clone_or_fetch_local(url: &str, dest: &Path, source: &GitSource) -> Result<()> {
902 let src = validate_local_source(url, source)?;
903 let cfg = network_git_config();
904 if dest.join(".git").exists() {
905 let args = with_config(&cfg, &["fetch", "--all", "--tags", "--prune"]);
906 run_git(dest, &args)?;
907 return Ok(());
908 }
909 std::fs::create_dir_all(dest).context("failed to create clone directory")?;
910 let dest_str = dest.to_str().unwrap_or(".");
911 let parent = dest.parent().unwrap_or(dest);
912
913 let tail: Vec<&str> = match source {
914 GitSource::Bundle => vec!["clone", "--no-checkout", &src, dest_str],
919 GitSource::FileUrl => vec![
921 "clone",
922 "--filter=blob:none",
923 "--no-checkout",
924 "--no-single-branch",
925 &src,
926 dest_str,
927 ],
928 GitSource::LocalPath => vec![
932 "clone",
933 "--no-local",
934 "--filter=blob:none",
935 "--no-checkout",
936 "--no-single-branch",
937 &src,
938 dest_str,
939 ],
940 GitSource::Remote => unreachable!("remote sources are handled by clone_or_fetch_remote"),
941 };
942 let args = with_config(&cfg, &tail);
943 run_git(parent, &args)?;
944 persist_repo_config(dest, &cfg);
945 Ok(())
946}
947
948pub fn publish_dir(
962 repo_url: &str,
963 branch: &str,
964 subdir: &str,
965 src_dir: &Path,
966 message: &str,
967 work_dir: &Path,
968) -> Result<()> {
969 let normalized = normalize_git_url(repo_url);
970 let url = normalized.as_str();
971
972 let (cfg, env_owned): (Vec<String>, Vec<(String, String)>) = match classify_source(url) {
974 GitSource::Remote => {
975 validate_clone_url(url)?;
976 let mut cfg = network_git_config();
977 let inj = host_of_git_url(url)
978 .map(|h| cred_injection(&h, explicit_port_of_git_url(url)))
979 .unwrap_or_default();
980 cfg.extend(inj.config.iter().cloned());
981 (cfg, inj.env)
982 }
983 source => {
984 validate_local_source(url, &source)?;
985 (network_git_config(), Vec::new())
986 }
987 };
988 let env: Vec<(&str, &str)> = env_owned
989 .iter()
990 .map(|(k, v)| (k.as_str(), v.as_str()))
991 .collect();
992
993 std::fs::create_dir_all(work_dir).context("failed to create publish work dir")?;
994 let dest_str = work_dir
995 .to_str()
996 .context("publish work dir path is not valid UTF-8")?;
997 let parent = work_dir.parent().unwrap_or(work_dir);
998
999 let clone_args = with_config(&cfg, &["clone", url, dest_str]);
1001 run_git_env(parent, &clone_args, &env)?;
1002 persist_repo_config(work_dir, &cfg);
1003
1004 let origin_ref = format!("origin/{branch}");
1007 if run_git(work_dir, &["rev-parse", "--verify", "--quiet", &origin_ref]).is_ok() {
1008 run_git(work_dir, &["checkout", "-B", branch, &origin_ref])?;
1009 } else {
1010 run_git(work_dir, &["checkout", "-B", branch])?;
1011 }
1012
1013 let target = if subdir.is_empty() {
1015 work_dir.to_path_buf()
1016 } else {
1017 work_dir.join(subdir)
1018 };
1019 if target != work_dir && target.exists() {
1020 std::fs::remove_dir_all(&target).ok();
1021 }
1022 copy_dir_all(src_dir, &target)?;
1023
1024 run_git(work_dir, &["add", "-A"])?;
1025 if run_git(work_dir, &["status", "--porcelain"])?
1026 .trim()
1027 .is_empty()
1028 {
1029 return Ok(()); }
1031 run_git(
1032 work_dir,
1033 &[
1034 "-c",
1035 "user.email=oxide-sloc@localhost",
1036 "-c",
1037 "user.name=oxide-sloc",
1038 "commit",
1039 "-m",
1040 message,
1041 ],
1042 )?;
1043 let refspec = format!("HEAD:refs/heads/{branch}");
1044 let push_args = with_config(&cfg, &["push", "origin", &refspec]);
1045 run_git_env(work_dir, &push_args, &env)?;
1046 Ok(())
1047}
1048
1049fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> {
1053 std::fs::create_dir_all(dest)?;
1054 for entry in std::fs::read_dir(src)?.flatten() {
1055 let ft = entry.file_type()?;
1056 if ft.is_symlink() {
1057 continue;
1058 }
1059 let to = dest.join(entry.file_name());
1060 if ft.is_dir() {
1061 copy_dir_all(&entry.path(), &to)?;
1062 } else if ft.is_file() {
1063 std::fs::copy(entry.path(), &to)?;
1064 }
1065 }
1066 Ok(())
1067}
1068
1069fn validate_local_source(url: &str, source: &GitSource) -> Result<String> {
1074 if !allow_local() {
1075 bail!(
1076 "local/offline git source rejected: set SLOC_GIT_ALLOW_LOCAL=1 to enable bundle / \
1077 file:// / local-path imports (got {url:?})"
1078 );
1079 }
1080 let Some(root) = local_root() else {
1081 bail!(
1082 "SLOC_GIT_ALLOW_LOCAL is set but SLOC_GIT_LOCAL_ROOT is not — refusing local import \
1083 (fail-closed). Point SLOC_GIT_LOCAL_ROOT at the directory holding your bundles/mirrors."
1084 );
1085 };
1086
1087 let raw = url.trim();
1088 let path = match source {
1089 GitSource::FileUrl => file_url_to_path(raw)?,
1090 _ => raw.to_owned(),
1091 };
1092 if path.starts_with("\\\\") || path.starts_with("//") {
1095 bail!("UNC path rejected: SMB shares are network sources, not local ({url:?})");
1096 }
1097
1098 let canon = std::fs::canonicalize(&path)
1099 .with_context(|| format!("local git source not found or unreadable: {path:?}"))?;
1100 let root_canon = std::fs::canonicalize(&root)
1101 .with_context(|| format!("SLOC_GIT_LOCAL_ROOT not found: {}", root.display()))?;
1102 if !canon.starts_with(&root_canon) {
1103 bail!(
1104 "local git source {} is outside SLOC_GIT_LOCAL_ROOT {}",
1105 canon.display(),
1106 root_canon.display()
1107 );
1108 }
1109 Ok(deverbatim(&canon))
1111}
1112
1113fn file_url_to_path(url: &str) -> Result<String> {
1117 let rest = &url.trim()[7..]; if !rest.starts_with('/') {
1119 bail!(
1120 "file:// URL with a host authority is not permitted (use file:///local/path): {url:?}"
1121 );
1122 }
1123 let after = &rest[1..];
1126 if after.len() >= 2 && after.as_bytes()[1] == b':' {
1127 Ok(after.to_owned())
1128 } else {
1129 Ok(rest.to_owned())
1130 }
1131}
1132
1133fn deverbatim(p: &Path) -> String {
1136 let s = p.to_string_lossy();
1137 s.strip_prefix(r"\\?\").unwrap_or(&s).to_owned()
1138}
1139
1140pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
1145 run_git(repo, &["rev-parse", ref_name])
1146}
1147
1148#[must_use]
1158pub fn is_local_repo_path(s: &str) -> bool {
1159 let t = s.trim();
1160 if t.is_empty() {
1161 return false;
1162 }
1163 let lower = t.to_lowercase();
1164 if lower.starts_with("https://")
1165 || lower.starts_with("http://")
1166 || lower.starts_with("git://")
1167 || lower.starts_with("ssh://")
1168 || lower.starts_with("file://")
1169 || t.starts_with("git@")
1170 {
1171 return false;
1172 }
1173 let p = Path::new(t);
1174 if !p.is_dir() {
1175 return false;
1176 }
1177 p.join(".git").exists() || (p.join("HEAD").is_file() && p.join("objects").is_dir())
1180}
1181
1182pub fn open_local_repo(path: &Path) -> Result<PathBuf> {
1188 if let Ok(top) = run_git(path, &["rev-parse", "--show-toplevel"])
1191 && !top.trim().is_empty()
1192 {
1193 return Ok(PathBuf::from(top.trim()));
1194 }
1195 run_git(path, &["rev-parse", "--git-dir"]).context("not a git repository")?;
1197 Ok(std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()))
1198}
1199
1200pub fn resolve_committish(repo: &Path, ref_name: &str) -> Result<String> {
1213 let candidates = [
1214 ref_name.to_owned(),
1215 format!("origin/{ref_name}"),
1216 format!("refs/remotes/origin/{ref_name}"),
1217 ];
1218 for cand in &candidates {
1219 let spec = format!("{cand}^{{commit}}");
1220 if let Ok(sha) = run_git(repo, &["rev-parse", "--verify", "-q", &spec])
1221 && !sha.is_empty()
1222 {
1223 return Ok(sha);
1224 }
1225 }
1226 bail!(
1227 "ref {ref_name:?} not found in repository (tried it directly, as origin/{ref_name}, \
1228 and as refs/remotes/origin/{ref_name})"
1229 );
1230}
1231
1232pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
1241 let wt = worktree_path.to_str().unwrap_or(".");
1242 let committish = resolve_committish(repo, ref_name)?;
1243 let env = cred_env_for_repo(repo);
1248 let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
1249 run_git_env(
1250 repo,
1251 &["worktree", "add", "--detach", wt, &committish],
1252 &env_refs,
1253 )?;
1254 Ok(())
1255}
1256
1257fn cred_env_for_repo(repo: &Path) -> Vec<(String, String)> {
1261 let Ok(url) = run_git(repo, &["config", "--get", "remote.origin.url"]) else {
1262 return Vec::new();
1263 };
1264 let Some(host) = host_of_git_url(&url) else {
1265 return Vec::new();
1266 };
1267 cred_injection(&host, explicit_port_of_git_url(&url)).env
1268}
1269
1270pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
1275 let wt = worktree_path.to_str().unwrap_or(".");
1276 let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
1277 Ok(())
1278}
1279
1280pub fn populate_submodules(worktree: &Path) -> Result<Vec<String>> {
1301 if !worktree.join(".gitmodules").is_file() {
1302 return Ok(Vec::new());
1303 }
1304 let mut safe_paths: Vec<String> = Vec::new();
1305 let mut skipped: Vec<String> = Vec::new();
1306 for name in submodule_names(worktree) {
1307 let url = gitmodules_value(worktree, &name, "url");
1308 let path = gitmodules_value(worktree, &name, "path");
1309 if url.trim().is_empty() || path.trim().is_empty() {
1310 continue;
1311 }
1312 if submodule_url_is_safe(url.trim()) {
1313 safe_paths.push(path.trim().to_owned());
1314 } else {
1315 skipped.push(name);
1316 }
1317 }
1318 if safe_paths.is_empty() {
1319 return Ok(skipped);
1320 }
1321 let cfg = network_git_config();
1325 let mut tail: Vec<&str> = vec!["submodule", "update", "--init", "--recursive", "--"];
1326 tail.extend(safe_paths.iter().map(String::as_str));
1327 let args = with_config(&cfg, &tail);
1328 let _ = run_git(worktree, &args); Ok(skipped)
1330}
1331
1332fn submodule_names(worktree: &Path) -> Vec<String> {
1334 let out = run_git(
1335 worktree,
1336 &[
1337 "config",
1338 "-f",
1339 ".gitmodules",
1340 "--name-only",
1341 "--get-regexp",
1342 r"^submodule\..*\.path$",
1343 ],
1344 )
1345 .unwrap_or_default();
1346 out.lines()
1347 .filter_map(|l| {
1348 l.trim()
1349 .strip_prefix("submodule.")
1350 .and_then(|s| s.strip_suffix(".path"))
1351 .map(str::to_owned)
1352 })
1353 .collect()
1354}
1355
1356fn gitmodules_value(worktree: &Path, name: &str, key: &str) -> String {
1358 run_git(
1359 worktree,
1360 &[
1361 "config",
1362 "-f",
1363 ".gitmodules",
1364 "--get",
1365 &format!("submodule.{name}.{key}"),
1366 ],
1367 )
1368 .unwrap_or_default()
1369}
1370
1371fn submodule_url_is_safe(url: &str) -> bool {
1375 let u = url.trim();
1376 if u.is_empty() {
1377 return false;
1378 }
1379 if u.starts_with('.') {
1380 return true;
1381 }
1382 let norm = normalize_git_url(u);
1383 match classify_source(&norm) {
1384 GitSource::Remote => validate_clone_url(&norm).is_ok(),
1385 _ => false,
1386 }
1387}
1388
1389pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
1396 Ok(RepoRefs {
1397 branches: list_branches(repo)?,
1398 tags: list_tags(repo)?,
1399 recent_commits: list_commits(repo, "HEAD", 40)?,
1400 })
1401}
1402
1403pub fn list_refs_local(repo: &Path) -> Result<RepoRefs> {
1411 Ok(RepoRefs {
1412 branches: list_branches_local(repo)?,
1413 tags: list_tags(repo)?,
1414 recent_commits: list_commits(repo, "HEAD", 40)?,
1415 })
1416}
1417
1418fn list_branches_local(repo: &Path) -> Result<Vec<GitRef>> {
1420 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
1421 let out = run_git(repo, &["branch", &format!("--format={fmt}")])?;
1422 Ok(out
1423 .lines()
1424 .filter(|l| !l.trim().is_empty())
1425 .map(|l| parse_ref_line(l, GitRefKind::Branch))
1426 .collect())
1427}
1428
1429fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
1430 let fmt = "%(symref)|%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
1436 let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
1440 let refs = out
1441 .lines()
1442 .filter(|l| !l.trim().is_empty())
1443 .filter_map(|l| {
1445 let (symref, rest) = l.split_once('|')?;
1446 if symref.trim().is_empty() {
1447 Some(rest)
1448 } else {
1449 None
1450 }
1451 })
1452 .map(|l| parse_ref_line(l, GitRefKind::Branch))
1453 .map(|mut r| {
1454 if let Some(slash) = r.name.find('/') {
1456 r.name = r.name[slash + 1..].to_owned();
1457 }
1458 r
1459 })
1460 .collect::<Vec<_>>();
1461 Ok(refs)
1462}
1463
1464fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
1465 let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
1466 let out = run_git(
1467 repo,
1468 &["tag", "--sort=-creatordate", &format!("--format={fmt}")],
1469 )?;
1470 Ok(out
1471 .lines()
1472 .filter(|l| !l.trim().is_empty())
1473 .map(|l| parse_ref_line(l, GitRefKind::Tag))
1474 .collect())
1475}
1476
1477fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
1478 let parts: Vec<&str> = line.splitn(4, '|').collect();
1479 let name = parts.first().copied().unwrap_or("").to_owned();
1480 let sha = parts.get(1).copied().unwrap_or("").to_owned();
1481 let date = parts.get(2).copied().and_then(parse_git_date);
1482 let message = parts.get(3).map(|s| (*s).to_owned());
1483 GitRef {
1484 kind,
1485 name,
1486 sha,
1487 date,
1488 message,
1489 }
1490}
1491
1492pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
1499 let fmt = "%H|%h|%an|%aI|%s";
1500 let n = format!("-{limit}");
1501 let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
1502 Ok(out
1503 .lines()
1504 .filter(|l| !l.trim().is_empty())
1505 .map(parse_commit_line)
1506 .collect())
1507}
1508
1509fn parse_commit_line(line: &str) -> GitCommit {
1510 let p: Vec<&str> = line.splitn(5, '|').collect();
1511 let sha = p.first().copied().unwrap_or("").to_owned();
1512 let short_sha = p.get(1).copied().unwrap_or("").to_owned();
1513 let author = p.get(2).copied().unwrap_or("").to_owned();
1514 let date = p
1515 .get(3)
1516 .copied()
1517 .and_then(parse_git_date)
1518 .unwrap_or_default();
1519 let subject = p.get(4).copied().unwrap_or("").to_owned();
1520 GitCommit {
1521 sha,
1522 short_sha,
1523 author,
1524 date,
1525 subject,
1526 }
1527}
1528
1529fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
1530 chrono::DateTime::parse_from_rfc3339(s)
1531 .ok()
1532 .map(|d| d.with_timezone(&chrono::Utc))
1533}
1534
1535#[cfg(test)]
1539static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1540
1541#[cfg(test)]
1544fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1545 ENV_LOCK
1546 .lock()
1547 .unwrap_or_else(std::sync::PoisonError::into_inner)
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552 use super::*;
1553 use crate::GitRefKind;
1554 use chrono::Timelike as _;
1555
1556 #[test]
1559 fn is_ssrf_blocked_host_blocks_localhost_and_metadata() {
1560 assert!(is_ssrf_blocked_host("localhost"));
1561 assert!(is_ssrf_blocked_host("metadata.google.internal"));
1562 assert!(is_ssrf_blocked_host("metadata.internal"));
1563 assert!(is_ssrf_blocked_host("instance-data"));
1564 assert!(is_ssrf_blocked_host(" LOCALHOST "));
1566 assert!(is_ssrf_blocked_host("127.0.0.1"));
1568 assert!(is_ssrf_blocked_host("[::1]"));
1569 assert!(is_ssrf_blocked_host("169.254.169.254"));
1570 }
1571
1572 #[test]
1573 fn require_host_allowlist_defaults_false() {
1574 assert!(!require_host_allowlist());
1576 }
1577
1578 #[test]
1579 fn check_host_allowed_denylist_mode_permits_public_blocks_sensitive() {
1580 assert!(check_host_allowed("github.com").is_ok());
1582 assert!(check_host_allowed("localhost").is_err());
1583 }
1584
1585 #[test]
1586 fn is_ssrf_blocked_host_allows_public_hosts() {
1587 assert!(!is_ssrf_blocked_host("github.com"));
1588 assert!(!is_ssrf_blocked_host("example.com"));
1589 assert!(!is_ssrf_blocked_host("192.168.1.10"));
1591 assert!(!is_ssrf_blocked_host("10.0.0.1"));
1592 }
1593
1594 #[test]
1597 fn network_git_config_always_hardens_redirects_and_lowspeed() {
1598 let cfg = network_git_config();
1599 assert!(cfg.iter().any(|c| c == "http.followRedirects=false"));
1600 assert!(cfg.iter().any(|c| c == "http.lowSpeedLimit=1000"));
1601 assert!(cfg.iter().any(|c| c == "http.lowSpeedTime=30"));
1602 }
1603
1604 #[cfg(windows)]
1605 #[test]
1606 fn network_git_config_uses_schannel_on_windows() {
1607 let cfg = network_git_config();
1610 assert!(cfg.iter().any(|c| c == "http.sslBackend=schannel"));
1611 }
1612
1613 #[test]
1614 fn with_config_interleaves_dash_c_pairs_before_tail() {
1615 let cfg = vec!["a=1".to_owned(), "b=2".to_owned()];
1616 let args = with_config(&cfg, &["clone", "url", "dest"]);
1617 assert_eq!(args, vec!["-c", "a=1", "-c", "b=2", "clone", "url", "dest"]);
1618 }
1619
1620 #[test]
1621 fn with_config_empty_cfg_is_just_the_tail() {
1622 let cfg: Vec<String> = Vec::new();
1623 assert_eq!(with_config(&cfg, &["fetch"]), vec!["fetch"]);
1624 }
1625
1626 #[test]
1627 fn git_timeout_is_positive() {
1628 assert!(git_timeout().as_secs() > 0);
1630 }
1631
1632 #[test]
1635 fn normalize_github_tree_url() {
1636 assert_eq!(
1637 normalize_git_url("https://github.com/owner/repo/tree/main"),
1638 "https://github.com/owner/repo.git"
1639 );
1640 }
1641
1642 #[test]
1643 fn normalize_github_blob_url() {
1644 assert_eq!(
1645 normalize_git_url("https://github.com/owner/repo/blob/main/README.md"),
1646 "https://github.com/owner/repo.git"
1647 );
1648 }
1649
1650 #[test]
1651 fn normalize_github_commits_url() {
1652 assert_eq!(
1653 normalize_git_url("https://github.com/owner/repo/commits/main"),
1654 "https://github.com/owner/repo.git"
1655 );
1656 }
1657
1658 #[test]
1659 fn normalize_github_releases_url() {
1660 assert_eq!(
1661 normalize_git_url("https://github.com/owner/repo/releases"),
1662 "https://github.com/owner/repo.git"
1663 );
1664 }
1665
1666 #[test]
1667 fn normalize_github_tags_url() {
1668 assert_eq!(
1669 normalize_git_url("https://github.com/owner/repo/tags"),
1670 "https://github.com/owner/repo.git"
1671 );
1672 }
1673
1674 #[test]
1675 fn normalize_github_branches_url() {
1676 assert_eq!(
1677 normalize_git_url("https://github.com/owner/repo/branches"),
1678 "https://github.com/owner/repo.git"
1679 );
1680 }
1681
1682 #[test]
1683 fn normalize_github_plain_clone_url_unchanged() {
1684 let url = "https://github.com/owner/repo.git";
1685 assert_eq!(normalize_git_url(url), url);
1686 }
1687
1688 #[test]
1689 fn normalize_gitlab_tree_url() {
1690 assert_eq!(
1691 normalize_git_url("https://gitlab.com/group/subgroup/repo/-/tree/main"),
1692 "https://gitlab.com/group/subgroup/repo.git"
1693 );
1694 }
1695
1696 #[test]
1697 fn normalize_gitlab_blob_url() {
1698 assert_eq!(
1699 normalize_git_url("https://gitlab.com/org/repo/-/blob/main/src/lib.rs"),
1700 "https://gitlab.com/org/repo.git"
1701 );
1702 }
1703
1704 #[test]
1705 fn normalize_gitlab_self_hosted() {
1706 assert_eq!(
1707 normalize_git_url("https://gitlab.corp.com/team/project/-/tree/develop"),
1708 "https://gitlab.corp.com/team/project.git"
1709 );
1710 }
1711
1712 #[test]
1713 fn normalize_bitbucket_server_browse_url() {
1714 assert_eq!(
1715 normalize_git_url("https://bitbucket.corp.com/projects/MYPROJ/repos/myrepo/browse"),
1716 "https://bitbucket.corp.com/scm/myproj/myrepo.git"
1717 );
1718 }
1719
1720 #[test]
1721 fn normalize_bitbucket_server_with_context() {
1722 assert_eq!(
1723 normalize_git_url("https://host.com/ctx/projects/PROJ/repos/repo/browse"),
1724 "https://host.com/ctx/scm/proj/repo.git"
1725 );
1726 }
1727
1728 #[test]
1729 fn normalize_bitbucket_cloud_src_url() {
1730 assert_eq!(
1731 normalize_git_url("https://bitbucket.org/workspace/repo/src/main/README.md"),
1732 "https://bitbucket.org/workspace/repo.git"
1733 );
1734 }
1735
1736 #[test]
1737 fn normalize_ssh_url_unchanged() {
1738 let url = "git@github.com:owner/repo.git";
1739 assert_eq!(normalize_git_url(url), url);
1740 }
1741
1742 #[test]
1743 fn normalize_ssh_protocol_url_unchanged() {
1744 let url = "ssh://git@github.com/owner/repo.git";
1745 assert_eq!(normalize_git_url(url), url);
1746 }
1747
1748 #[test]
1749 fn normalize_trims_leading_trailing_whitespace() {
1750 assert_eq!(
1751 normalize_git_url(" https://github.com/owner/repo/tree/main "),
1752 "https://github.com/owner/repo.git"
1753 );
1754 }
1755
1756 #[test]
1757 fn normalize_http_url_without_match_returned_unchanged() {
1758 let url = "http://internal.corp.com/repo.git";
1759 assert_eq!(normalize_git_url(url), url);
1760 }
1761
1762 #[test]
1765 fn validate_https_url_ok() {
1766 assert!(validate_clone_url("https://github.com/owner/repo.git").is_ok());
1767 }
1768
1769 #[test]
1770 fn validate_git_protocol_url_ok() {
1771 assert!(validate_clone_url("git://github.com/owner/repo.git").is_ok());
1772 }
1773
1774 #[test]
1775 fn validate_ssh_protocol_url_ok() {
1776 assert!(validate_clone_url("ssh://git@github.com/owner/repo.git").is_ok());
1777 }
1778
1779 #[test]
1780 fn validate_git_at_url_ok() {
1781 assert!(validate_clone_url("git@github.com:owner/repo.git").is_ok());
1782 }
1783
1784 #[test]
1785 fn validate_http_plain_rejected() {
1786 assert!(
1787 validate_clone_url("http://github.com/owner/repo.git").is_err(),
1788 "plain http:// must be rejected"
1789 );
1790 }
1791
1792 #[test]
1793 fn validate_link_local_169_254_rejected() {
1794 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
1795 }
1796
1797 #[test]
1798 fn validate_google_metadata_endpoint_rejected() {
1799 assert!(
1800 validate_clone_url("https://metadata.google.internal/computeMetadata/v1/").is_err()
1801 );
1802 }
1803
1804 #[test]
1805 fn validate_alibaba_metadata_rejected() {
1806 assert!(validate_clone_url("https://100.100.100.200/latest/meta-data/").is_err());
1807 }
1808
1809 #[test]
1810 fn validate_ipv6_fe80_link_local_rejected() {
1811 assert!(validate_clone_url("https://[fe80::1]/repo").is_err());
1812 }
1813
1814 #[test]
1815 fn validate_file_protocol_rejected() {
1816 assert!(validate_clone_url("file:///etc/passwd").is_err());
1817 }
1818
1819 #[test]
1820 fn validate_empty_string_rejected() {
1821 assert!(validate_clone_url("").is_err());
1822 }
1823
1824 #[test]
1825 fn validate_rfc1918_10_allowed() {
1826 assert!(validate_clone_url("https://10.0.0.1/repo.git").is_ok());
1828 }
1829
1830 #[test]
1831 fn validate_rfc1918_192_168_allowed() {
1832 assert!(validate_clone_url("https://192.168.1.1/repo.git").is_ok());
1833 }
1834
1835 #[test]
1836 fn validate_rfc1918_172_16_allowed() {
1837 assert!(validate_clone_url("https://172.16.0.1/repo.git").is_ok());
1838 }
1839
1840 #[test]
1841 fn validate_rfc1918_172_31_allowed() {
1842 assert!(validate_clone_url("https://172.31.255.255/repo.git").is_ok());
1843 }
1844
1845 #[test]
1846 fn validate_ipv6_ula_fd_allowed() {
1847 assert!(validate_clone_url("https://[fd12:3456:789a::1]/repo").is_ok());
1849 }
1850
1851 #[test]
1853 fn port_https_default() {
1854 assert_eq!(port_of_git_url("https://github.com/o/r.git"), Some(443));
1855 }
1856
1857 #[test]
1858 fn port_explicit_overrides_default() {
1859 assert_eq!(
1860 port_of_git_url("https://gitlab.corp:8443/o/r.git"),
1861 Some(8443)
1862 );
1863 }
1864
1865 #[test]
1866 fn port_git_scheme_default() {
1867 assert_eq!(port_of_git_url("git://example.com/r.git"), Some(9418));
1868 }
1869
1870 #[test]
1871 fn port_scp_like_is_ssh() {
1872 assert_eq!(port_of_git_url("git@github.com:owner/repo.git"), Some(22));
1873 }
1874
1875 #[test]
1876 fn port_ipv6_with_explicit_port() {
1877 assert_eq!(port_of_git_url("https://[fd00::1]:7000/r"), Some(7000));
1878 }
1879
1880 #[test]
1881 fn port_ipv6_default() {
1882 assert_eq!(port_of_git_url("https://[fd00::1]/r"), Some(443));
1883 }
1884
1885 #[test]
1886 fn validate_metadata_ip_literal_still_rejected() {
1887 assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
1889 }
1890
1891 #[test]
1892 fn validate_loopback_127_rejected() {
1893 assert!(validate_clone_url("https://127.0.0.1/repo.git").is_err());
1894 }
1895
1896 #[test]
1897 fn validate_localhost_rejected() {
1898 assert!(validate_clone_url("https://localhost/repo.git").is_err());
1899 }
1900
1901 #[test]
1902 fn validate_unspecified_0_0_0_0_rejected() {
1903 assert!(validate_clone_url("https://0.0.0.0/repo.git").is_err());
1904 }
1905
1906 #[test]
1911 fn host_of_git_url_https_with_port_and_creds() {
1912 assert_eq!(
1913 host_of_git_url("https://user:pw@gitlab.corp.com:8443/team/repo.git").as_deref(),
1914 Some("gitlab.corp.com")
1915 );
1916 }
1917
1918 #[test]
1919 fn host_of_git_url_scp_syntax() {
1920 assert_eq!(
1921 host_of_git_url("git@github.com:owner/repo.git").as_deref(),
1922 Some("github.com")
1923 );
1924 }
1925
1926 #[test]
1927 fn host_of_git_url_ipv6_literal() {
1928 assert_eq!(
1929 host_of_git_url("https://[fe80::1]:443/repo").as_deref(),
1930 Some("fe80::1")
1931 );
1932 }
1933
1934 #[test]
1935 fn validate_clone_url_path_with_version_number_not_blocked() {
1936 assert!(validate_clone_url("https://github.com/acme/release-v10.2.git").is_ok());
1938 assert!(validate_clone_url("https://github.com/foo/bar-127-baz.git").is_ok());
1939 }
1940
1941 #[test]
1944 fn bitbucket_server_uppercase_project_lowercased() {
1945 let r = try_normalize_bitbucket_server(
1946 "https",
1947 "bb.corp.com",
1948 "/projects/PROJ/repos/myrepo/browse",
1949 );
1950 assert_eq!(
1951 r,
1952 Some("https://bb.corp.com/scm/proj/myrepo.git".to_owned())
1953 );
1954 }
1955
1956 #[test]
1957 fn bitbucket_server_without_projects_returns_none() {
1958 assert!(
1959 try_normalize_bitbucket_server("https", "bb.corp.com", "/scm/proj/repo.git").is_none()
1960 );
1961 }
1962
1963 #[test]
1964 fn bitbucket_server_missing_repos_segment_returns_none() {
1965 assert!(
1966 try_normalize_bitbucket_server("https", "bb.corp.com", "/projects/PROJ/browse")
1967 .is_none()
1968 );
1969 }
1970
1971 #[test]
1974 fn gitlab_dash_tree_normalized() {
1975 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo/-/tree/main");
1976 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
1977 }
1978
1979 #[test]
1980 fn gitlab_no_dash_returns_none() {
1981 assert!(try_normalize_gitlab("https", "gitlab.com", "/group/repo").is_none());
1982 }
1983
1984 #[test]
1985 fn gitlab_strips_existing_dot_git_before_readding() {
1986 let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo.git/-/tree/main");
1987 assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
1988 }
1989
1990 #[test]
1993 fn github_tree_normalized() {
1994 let r = try_normalize_github("https", "github.com", "/owner/repo/tree/main");
1995 assert_eq!(r, Some("https://github.com/owner/repo.git".to_owned()));
1996 }
1997
1998 #[test]
1999 fn github_non_github_host_returns_none() {
2000 assert!(try_normalize_github("https", "gitlab.com", "/owner/repo/tree/main").is_none());
2001 }
2002
2003 #[test]
2004 fn github_plain_two_segment_path_returns_none() {
2005 assert!(try_normalize_github("https", "github.com", "/owner/repo").is_none());
2006 }
2007
2008 #[test]
2009 fn github_unknown_third_segment_returns_none() {
2010 assert!(try_normalize_github("https", "github.com", "/owner/repo/wiki").is_none());
2011 }
2012
2013 #[test]
2016 fn bitbucket_cloud_src_normalized() {
2017 let r = try_normalize_bitbucket_cloud(
2018 "https",
2019 "bitbucket.org",
2020 "/workspace/repo/src/main/README.md",
2021 );
2022 assert_eq!(
2023 r,
2024 Some("https://bitbucket.org/workspace/repo.git".to_owned())
2025 );
2026 }
2027
2028 #[test]
2029 fn bitbucket_cloud_non_bitbucket_host_returns_none() {
2030 assert!(
2031 try_normalize_bitbucket_cloud("https", "github.com", "/ws/repo/src/main").is_none()
2032 );
2033 }
2034
2035 #[test]
2036 fn bitbucket_cloud_without_src_segment_returns_none() {
2037 assert!(try_normalize_bitbucket_cloud("https", "bitbucket.org", "/ws/repo").is_none());
2038 }
2039
2040 #[test]
2043 fn parse_ref_line_all_fields() {
2044 let line = "main|abc1234|2024-01-15T10:00:00+00:00|Initial commit";
2045 let r = parse_ref_line(line, GitRefKind::Branch);
2046 assert_eq!(r.name, "main");
2047 assert_eq!(r.sha, "abc1234");
2048 assert!(r.date.is_some());
2049 assert_eq!(r.message.as_deref(), Some("Initial commit"));
2050 assert!(matches!(r.kind, GitRefKind::Branch));
2051 }
2052
2053 #[test]
2054 fn parse_ref_line_tag_kind() {
2055 let line = "v1.0.0|deadbeef|2024-01-01T00:00:00+00:00|Release v1.0.0";
2056 let r = parse_ref_line(line, GitRefKind::Tag);
2057 assert_eq!(r.name, "v1.0.0");
2058 assert!(matches!(r.kind, GitRefKind::Tag));
2059 }
2060
2061 #[test]
2062 fn parse_ref_line_name_only() {
2063 let r = parse_ref_line("main", GitRefKind::Branch);
2064 assert_eq!(r.name, "main");
2065 assert_eq!(r.sha, "");
2066 assert!(r.date.is_none());
2067 assert!(r.message.is_none());
2068 }
2069
2070 #[test]
2071 fn parse_ref_line_invalid_date_gives_none() {
2072 let r = parse_ref_line("main|abc|not-a-date|msg", GitRefKind::Branch);
2073 assert!(r.date.is_none());
2074 assert_eq!(r.message.as_deref(), Some("msg"));
2075 }
2076
2077 #[test]
2078 fn parse_ref_line_empty_string() {
2079 let r = parse_ref_line("", GitRefKind::Branch);
2080 assert_eq!(r.name, "");
2081 }
2082
2083 #[test]
2086 fn parse_commit_line_all_fields() {
2087 let line =
2088 "abc1234567890abcdef|abc1234|Alice Smith|2024-01-15T10:00:00+00:00|Fix critical bug";
2089 let c = parse_commit_line(line);
2090 assert_eq!(c.sha, "abc1234567890abcdef");
2091 assert_eq!(c.short_sha, "abc1234");
2092 assert_eq!(c.author, "Alice Smith");
2093 assert_eq!(c.subject, "Fix critical bug");
2094 }
2095
2096 #[test]
2097 fn parse_commit_line_empty() {
2098 let c = parse_commit_line("");
2099 assert_eq!(c.sha, "");
2100 assert_eq!(c.short_sha, "");
2101 assert_eq!(c.author, "");
2102 assert_eq!(c.subject, "");
2103 }
2104
2105 #[test]
2106 fn parse_commit_line_partial_fields() {
2107 let c = parse_commit_line("sha1|sha_short");
2108 assert_eq!(c.sha, "sha1");
2109 assert_eq!(c.short_sha, "sha_short");
2110 assert_eq!(c.author, "");
2111 }
2112
2113 #[test]
2114 fn parse_commit_line_subject_with_pipe() {
2115 let line = "sha|short|author|2024-01-01T00:00:00+00:00|subject with | pipe inside";
2117 let c = parse_commit_line(line);
2118 assert_eq!(c.subject, "subject with | pipe inside");
2119 }
2120
2121 #[test]
2124 fn parse_git_date_valid_rfc3339() {
2125 let dt = parse_git_date("2024-01-15T10:30:00+00:00");
2126 assert!(dt.is_some());
2127 }
2128
2129 #[test]
2130 fn parse_git_date_invalid_returns_none() {
2131 assert!(parse_git_date("not-a-date").is_none());
2132 assert!(parse_git_date("").is_none());
2133 }
2134
2135 #[test]
2136 fn parse_git_date_with_offset_converts_to_utc() {
2137 let dt = parse_git_date("2024-06-01T12:00:00+05:00").unwrap();
2138 assert_eq!(dt.time().hour(), 7);
2140 }
2141
2142 #[test]
2143 fn port_of_git_url_unknown_scheme_returns_none() {
2144 assert_eq!(port_of_git_url("https://host/repo"), Some(443));
2146 assert_eq!(port_of_git_url("ssh://host/repo"), Some(22));
2147 assert_eq!(port_of_git_url("git://host/repo"), Some(9418));
2148 assert_eq!(port_of_git_url("file://host/repo"), None);
2150 assert_eq!(port_of_git_url("ftp://host/repo"), None);
2151 }
2152
2153 #[test]
2156 fn hostkey_normalizes_punctuation_and_case() {
2157 assert_eq!(
2158 hostkey("bitbucket.instance2.com"),
2159 "BITBUCKET_INSTANCE2_COM"
2160 );
2161 assert_eq!(hostkey("git-host.corp"), "GIT_HOST_CORP");
2162 assert_eq!(hostkey("host:7990"), "HOST_7990");
2163 }
2164
2165 #[test]
2166 fn resolve_credential_https_env_wins() {
2167 let _g = env_lock();
2168 let host = "cred-https-test.example";
2169 let key = format!("SLOC_GIT_CRED_{}", hostkey(host));
2170 unsafe { std::env::set_var(&key, "alice:secrettoken") };
2172 let cred = resolve_credential(host, None);
2173 unsafe { std::env::remove_var(&key) };
2175 match cred {
2176 Some(GitCredential::Https { user, token }) => {
2177 assert_eq!(user, "alice");
2178 assert_eq!(token, "secrettoken");
2179 }
2180 _ => panic!("expected an HTTPS credential from the env registry"),
2181 }
2182 }
2183
2184 #[test]
2185 fn resolve_credential_ssh_key_env() {
2186 let _g = env_lock();
2187 let host = "cred-ssh-test.example";
2188 let key = format!("SLOC_GIT_SSHKEY_{}", hostkey(host));
2189 unsafe { std::env::set_var(&key, "/home/u/.ssh/id_ed25519") };
2191 let cred = resolve_credential(host, None);
2192 unsafe { std::env::remove_var(&key) };
2194 match cred {
2195 Some(GitCredential::Ssh { key_path }) => {
2196 assert_eq!(key_path, "/home/u/.ssh/id_ed25519");
2197 }
2198 _ => panic!("expected an SSH credential from the env registry"),
2199 }
2200 }
2201
2202 #[test]
2203 fn resolve_credential_none_falls_through() {
2204 assert!(resolve_credential("no-such-cred-host.invalid", None).is_none());
2206 }
2207
2208 #[test]
2209 fn cred_injection_https_keeps_secret_out_of_config() {
2210 let _g = env_lock();
2211 let host = "inj-test.example";
2212 let key = format!("SLOC_GIT_CRED_{}", hostkey(host));
2213 unsafe { std::env::set_var(&key, "bob:tok123") };
2215 let inj = cred_injection(host, None);
2216 unsafe { std::env::remove_var(&key) };
2218
2219 assert_eq!(
2221 inj.config.first().map(String::as_str),
2222 Some("credential.helper=")
2223 );
2224 assert!(
2225 inj.config
2226 .iter()
2227 .any(|c| c.contains("$GIT_U") && c.contains("$GIT_P"))
2228 );
2229 assert!(
2230 !inj.config.iter().any(|c| c.contains("tok123")),
2231 "the token must NEVER appear in git config / argv"
2232 );
2233 assert!(inj.env.iter().any(|(k, v)| k == "GIT_U" && v == "bob"));
2234 assert!(inj.env.iter().any(|(k, v)| k == "GIT_P" && v == "tok123"));
2235 }
2236
2237 #[test]
2238 fn resolve_credential_port_qualified_key_wins() {
2239 let _g = env_lock();
2240 let host = "cred-port-test.example";
2241 let port_key = format!("SLOC_GIT_CRED_{}", hostkey(&format!("{host}:7990")));
2242 let bare_key = format!("SLOC_GIT_CRED_{}", hostkey(host));
2243 unsafe {
2245 std::env::set_var(&port_key, "svc-port:porttoken");
2246 std::env::set_var(&bare_key, "svc-bare:baretoken");
2247 }
2248 let with_port = resolve_credential(host, Some(7990));
2249 let without_port = resolve_credential(host, None);
2250 unsafe {
2252 std::env::remove_var(&port_key);
2253 std::env::remove_var(&bare_key);
2254 }
2255 match with_port {
2256 Some(GitCredential::Https { user, .. }) => assert_eq!(user, "svc-port"),
2257 _ => panic!("port-qualified key should win when a port is present"),
2258 }
2259 match without_port {
2260 Some(GitCredential::Https { user, .. }) => assert_eq!(user, "svc-bare"),
2261 _ => panic!("bare-host key should resolve when no port is given"),
2262 }
2263 }
2264
2265 #[test]
2266 fn resolve_credential_falls_back_to_bare_when_only_bare_key_set() {
2267 let _g = env_lock();
2268 let host = "cred-fallback-test.example";
2269 let bare_key = format!("SLOC_GIT_CRED_{}", hostkey(host));
2270 unsafe { std::env::set_var(&bare_key, "svc:tok") };
2272 let cred = resolve_credential(host, Some(7990));
2274 unsafe { std::env::remove_var(&bare_key) };
2276 assert!(matches!(cred, Some(GitCredential::Https { .. })));
2277 }
2278
2279 #[test]
2280 fn explicit_port_of_git_url_extracts_or_none() {
2281 assert_eq!(
2282 explicit_port_of_git_url("https://git.corp:7990/team/repo.git"),
2283 Some(7990)
2284 );
2285 assert_eq!(
2286 explicit_port_of_git_url("https://git.corp/team/repo.git"),
2287 None
2288 );
2289 assert_eq!(
2290 explicit_port_of_git_url("ssh://git@host:2222/repo.git"),
2291 Some(2222)
2292 );
2293 assert_eq!(
2295 explicit_port_of_git_url("git@github.com:owner/repo.git"),
2296 None
2297 );
2298 assert_eq!(
2299 explicit_port_of_git_url("https://[fe80::1]:443/repo"),
2300 Some(443)
2301 );
2302 assert_eq!(explicit_port_of_git_url("https://[fe80::1]/repo"), None);
2303 }
2304
2305 #[test]
2308 fn classify_source_recognizes_each_form() {
2309 assert!(matches!(
2310 classify_source("https://github.com/o/r.git"),
2311 GitSource::Remote
2312 ));
2313 assert!(matches!(
2314 classify_source("git@github.com:o/r.git"),
2315 GitSource::Remote
2316 ));
2317 assert!(matches!(
2318 classify_source("ssh://git@h/o/r.git"),
2319 GitSource::Remote
2320 ));
2321 assert!(matches!(
2322 classify_source("file:///srv/mirror/r"),
2323 GitSource::FileUrl
2324 ));
2325 assert!(matches!(
2326 classify_source("/srv/mirror/r.bundle"),
2327 GitSource::Bundle
2328 ));
2329 assert!(matches!(
2330 classify_source(r"C:\mirror\r"),
2331 GitSource::LocalPath
2332 ));
2333 }
2334
2335 #[test]
2336 fn normalize_git_url_passes_local_sources_through() {
2337 for u in [
2338 "file:///srv/mirror/r",
2339 r"C:\mirror\repo",
2340 r"\\srv\share\repo",
2341 "/srv/x.bundle",
2342 ] {
2343 assert_eq!(
2344 normalize_git_url(u),
2345 u,
2346 "local source must pass through unchanged: {u}"
2347 );
2348 }
2349 }
2350
2351 #[test]
2352 fn file_url_to_path_posix_windows_and_rejects_host() {
2353 assert_eq!(
2354 file_url_to_path("file:///home/u/repo").unwrap(),
2355 "/home/u/repo"
2356 );
2357 assert_eq!(
2358 file_url_to_path("file:///C:/mirror/repo").unwrap(),
2359 "C:/mirror/repo"
2360 );
2361 assert!(
2362 file_url_to_path("file://server/share/repo").is_err(),
2363 "a file:// URL with a host authority must be rejected"
2364 );
2365 }
2366
2367 #[test]
2370 fn validate_local_source_rejected_when_disabled() {
2371 let _g = env_lock();
2372 unsafe {
2374 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2375 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2376 }
2377 assert!(validate_local_source("/srv/x.bundle", &GitSource::Bundle).is_err());
2378 }
2379
2380 #[test]
2381 fn validate_local_source_requires_root_when_enabled() {
2382 let _g = env_lock();
2383 unsafe {
2385 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2386 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2387 }
2388 let err = validate_local_source("/srv/x.bundle", &GitSource::Bundle)
2389 .unwrap_err()
2390 .to_string();
2391 unsafe { std::env::remove_var("SLOC_GIT_ALLOW_LOCAL") };
2393 assert!(
2394 err.contains("SLOC_GIT_LOCAL_ROOT"),
2395 "must fail closed without a configured root: {err}"
2396 );
2397 }
2398
2399 #[test]
2400 fn validate_local_source_rejects_outside_root() {
2401 let _g = env_lock();
2402 let root = tempfile::tempdir().unwrap();
2403 let outside = tempfile::tempdir().unwrap();
2404 unsafe {
2406 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2407 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2408 }
2409 let outside_path = outside.path().to_string_lossy().into_owned();
2410 let res = validate_local_source(&outside_path, &GitSource::LocalPath);
2411 unsafe {
2413 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2414 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2415 }
2416 assert!(
2417 res.is_err(),
2418 "a source outside SLOC_GIT_LOCAL_ROOT must be rejected"
2419 );
2420 }
2421
2422 #[test]
2423 fn validate_local_source_accepts_inside_root() {
2424 let _g = env_lock();
2425 let root = tempfile::tempdir().unwrap();
2426 let inside = root.path().join("sub");
2427 std::fs::create_dir_all(&inside).unwrap();
2428 unsafe {
2430 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2431 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2432 }
2433 let inside_path = inside.to_string_lossy().into_owned();
2434 let res = validate_local_source(&inside_path, &GitSource::LocalPath);
2435 unsafe {
2437 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2438 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2439 }
2440 assert!(
2441 res.is_ok(),
2442 "a source under the root must be accepted: {res:?}"
2443 );
2444 }
2445
2446 #[test]
2447 fn validate_local_source_rejects_unc_even_when_enabled() {
2448 let _g = env_lock();
2449 let root = tempfile::tempdir().unwrap();
2450 unsafe {
2452 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2453 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2454 }
2455 let res = validate_local_source(r"\\attacker\share\repo", &GitSource::LocalPath);
2456 unsafe {
2458 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2459 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2460 }
2461 assert!(
2462 res.is_err(),
2463 "UNC/SMB paths must be treated as remote and rejected"
2464 );
2465 }
2466
2467 #[test]
2468 fn clone_or_fetch_file_url_rejected_without_gate() {
2469 let _g = env_lock();
2471 unsafe { std::env::remove_var("SLOC_GIT_ALLOW_LOCAL") };
2473 let dest = tempfile::tempdir().unwrap();
2474 assert!(clone_or_fetch("file:///etc/passwd", dest.path()).is_err());
2475 }
2476}
2477
2478#[cfg(test)]
2485mod git_integration {
2486 use super::*;
2487 use std::path::Path;
2488 use tempfile::tempdir;
2489
2490 fn git(dir: &Path, args: &[&str]) {
2493 let status = std::process::Command::new("git")
2494 .args(args)
2495 .current_dir(dir)
2496 .env("GIT_AUTHOR_NAME", "Test")
2497 .env("GIT_AUTHOR_EMAIL", "test@example.com")
2498 .env("GIT_COMMITTER_NAME", "Test")
2499 .env("GIT_COMMITTER_EMAIL", "test@example.com")
2500 .status()
2501 .expect("git must be on PATH");
2502 assert!(status.success(), "git {args:?} failed");
2503 }
2504
2505 fn make_repo(dir: &Path) {
2507 git(dir, &["init", "-b", "main"]);
2508 std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
2509 git(dir, &["add", "hello.txt"]);
2510 git(dir, &["commit", "--no-gpg-sign", "-m", "initial"]);
2511 }
2512
2513 #[test]
2516 fn publish_dir_pushes_snapshot_to_local_bare_repo() {
2517 let _g = env_lock();
2518 let root = tempdir().unwrap();
2519 let bare = root.path().join("target.git");
2521 git(root.path(), &["init", "--bare", bare.to_str().unwrap()]);
2522
2523 let src = tempdir().unwrap();
2525 std::fs::write(src.path().join("result.json"), b"{\"ok\":true}").unwrap();
2526 std::fs::create_dir_all(src.path().join("html")).unwrap();
2527 std::fs::write(src.path().join("html").join("r.html"), b"<html></html>").unwrap();
2528
2529 let work = root.path().join("work");
2530 unsafe {
2533 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2534 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2535 }
2536 let res = publish_dir(
2537 bare.to_str().unwrap(),
2538 "reports",
2539 "run-1",
2540 src.path(),
2541 "publish run-1",
2542 &work,
2543 );
2544 unsafe {
2546 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2547 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2548 }
2549 assert!(res.is_ok(), "publish_dir failed: {res:?}");
2550
2551 let out = std::process::Command::new("git")
2553 .args([
2554 &format!("--git-dir={}", bare.to_str().unwrap()),
2555 "ls-tree",
2556 "-r",
2557 "--name-only",
2558 "reports",
2559 ])
2560 .output()
2561 .expect("git must be on PATH");
2562 let listing = String::from_utf8_lossy(&out.stdout);
2563 assert!(
2564 listing.contains("run-1/result.json"),
2565 "expected run-1/result.json in: {listing}"
2566 );
2567 assert!(
2568 listing.contains("run-1/html/r.html"),
2569 "expected run-1/html/r.html in: {listing}"
2570 );
2571 }
2572
2573 #[test]
2576 fn run_git_success_returns_stdout() {
2577 let dir = tempdir().unwrap();
2578 make_repo(dir.path());
2579 let sha = run_git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
2581 assert_eq!(sha.len(), 40, "full SHA must be 40 hex chars: {sha}");
2582 }
2583
2584 #[test]
2585 fn run_git_failure_returns_error() {
2586 let dir = tempdir().unwrap();
2587 make_repo(dir.path());
2588 let result = run_git(dir.path(), &["rev-parse", "nonexistent-ref-xyz"]);
2589 assert!(result.is_err(), "nonexistent ref must return an error");
2590 }
2591
2592 #[test]
2595 fn clone_or_fetch_clones_local_repo() {
2596 let src = tempdir().unwrap();
2597 make_repo(src.path());
2598
2599 let dest_root = tempdir().unwrap();
2600 let dest = dest_root.path().join("clone");
2601
2602 std::fs::create_dir_all(&dest).unwrap();
2611 let src_str = src.path().to_str().unwrap();
2612 let dest_str = dest.to_str().unwrap();
2613 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
2614 assert!(dest.join(".git").exists(), "clone must create .git dir");
2615
2616 std::fs::write(src.path().join("second.txt"), "v2\n").unwrap();
2618 git(src.path(), &["add", "second.txt"]);
2619 git(src.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
2620
2621 run_git(&dest, &["fetch", "--all", "--tags", "--prune"]).unwrap();
2626 }
2627
2628 #[test]
2629 fn list_branches_excludes_origin_head_symref() {
2630 let src = tempdir().unwrap();
2634 let inner = src.path().join("inner");
2635 std::fs::create_dir_all(&inner).unwrap();
2636 make_repo(&inner);
2637 git(&inner, &["branch", "feature-x"]);
2638
2639 let dest_root = tempdir().unwrap();
2640 let dest = dest_root.path().join("clone");
2641 let src_str = inner.to_str().unwrap();
2642 let dest_str = dest.to_str().unwrap();
2643 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
2644 let _ = run_git(&dest, &["remote", "set-head", "origin", "--auto"]);
2646
2647 let branches = list_branches(&dest).unwrap();
2648 let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
2649 assert!(
2650 !names.contains(&"origin"),
2651 "origin/HEAD symref must not appear as a branch: {names:?}"
2652 );
2653 assert!(
2654 names.contains(&"main"),
2655 "main branch must be listed: {names:?}"
2656 );
2657 assert!(
2658 names.contains(&"feature-x"),
2659 "real branches must still be listed: {names:?}"
2660 );
2661 }
2662
2663 #[test]
2664 fn clone_or_fetch_rejects_http_plain_url() {
2665 let dest = tempdir().unwrap();
2666 let result = clone_or_fetch("http://example.com/repo.git", dest.path());
2667 assert!(
2668 result.is_err(),
2669 "http:// must be rejected by validate_clone_url"
2670 );
2671 }
2672
2673 #[test]
2674 fn clone_or_fetch_rejects_link_local_url() {
2675 let dest = tempdir().unwrap();
2676 let result = clone_or_fetch("https://169.254.169.254/repo", dest.path());
2677 assert!(result.is_err());
2678 }
2679
2680 #[test]
2683 fn clone_or_fetch_imports_git_bundle_under_local_root() {
2684 let _g = env_lock();
2685 let root = tempdir().unwrap();
2687 let src = root.path().join("src");
2688 std::fs::create_dir_all(&src).unwrap();
2689 make_repo(&src);
2690 let bundle = root.path().join("repo.bundle");
2691 run_git(
2692 &src,
2693 &["bundle", "create", bundle.to_str().unwrap(), "--all"],
2694 )
2695 .unwrap();
2696
2697 unsafe {
2699 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2700 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2701 }
2702 let dest_root = tempdir().unwrap();
2703 let dest = dest_root.path().join("clone");
2704 let res = clone_or_fetch(bundle.to_str().unwrap(), &dest);
2705 let refs = res.as_ref().ok().and(list_refs(&dest).ok());
2706 unsafe {
2708 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2709 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2710 }
2711
2712 res.unwrap();
2713 assert!(
2714 dest.join(".git").exists(),
2715 "bundle import must produce a clone"
2716 );
2717 let names: Vec<String> = refs
2718 .expect("refs must be listable from the imported clone")
2719 .branches
2720 .into_iter()
2721 .map(|b| b.name)
2722 .collect();
2723 assert!(
2724 names.iter().any(|n| n == "main"),
2725 "bundle clone must expose the main branch: {names:?}"
2726 );
2727 }
2728
2729 #[test]
2730 fn clone_or_fetch_imports_local_path_under_root() {
2731 let _g = env_lock();
2732 let root = tempdir().unwrap();
2733 let src = root.path().join("mirror");
2734 std::fs::create_dir_all(&src).unwrap();
2735 make_repo(&src);
2736
2737 unsafe {
2739 std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
2740 std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
2741 }
2742 let dest_root = tempdir().unwrap();
2743 let dest = dest_root.path().join("clone");
2744 let res = clone_or_fetch(src.to_str().unwrap(), &dest);
2745 unsafe {
2747 std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
2748 std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
2749 }
2750
2751 res.unwrap();
2752 assert!(
2753 dest.join(".git").exists(),
2754 "local-path import must produce a clone"
2755 );
2756 }
2757
2758 #[test]
2761 fn get_sha_returns_full_commit_hash() {
2762 let dir = tempdir().unwrap();
2763 make_repo(dir.path());
2764 let sha = get_sha(dir.path(), "HEAD").unwrap();
2765 assert_eq!(sha.len(), 40);
2766 assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
2767 }
2768
2769 #[test]
2770 fn get_sha_nonexistent_ref_errors() {
2771 let dir = tempdir().unwrap();
2772 make_repo(dir.path());
2773 assert!(get_sha(dir.path(), "refs/heads/nonexistent").is_err());
2774 }
2775
2776 #[test]
2779 fn list_commits_returns_at_least_one_commit() {
2780 let dir = tempdir().unwrap();
2781 make_repo(dir.path());
2782 let commits = list_commits(dir.path(), "HEAD", 10).unwrap();
2783 assert!(
2784 !commits.is_empty(),
2785 "must return at least the initial commit"
2786 );
2787 let c = &commits[0];
2788 assert_eq!(c.sha.len(), 40);
2789 assert!(!c.short_sha.is_empty());
2790 assert_eq!(c.author, "Test");
2791 assert_eq!(c.subject, "initial");
2792 }
2793
2794 #[test]
2795 fn list_commits_respects_limit() {
2796 let dir = tempdir().unwrap();
2797 make_repo(dir.path());
2798 std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
2800 git(dir.path(), &["add", "b.txt"]);
2801 git(dir.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
2802
2803 let one = list_commits(dir.path(), "HEAD", 1).unwrap();
2804 assert_eq!(one.len(), 1, "limit=1 must return exactly 1 commit");
2805
2806 let two = list_commits(dir.path(), "HEAD", 10).unwrap();
2807 assert_eq!(two.len(), 2, "limit=10 must return both commits");
2808 }
2809
2810 #[test]
2813 fn list_refs_returns_main_branch() {
2814 let src = tempdir().unwrap();
2815 make_repo(src.path());
2816
2817 let dest_root = tempdir().unwrap();
2819 let dest = dest_root.path().join("clone");
2820 let src_str = src.path().to_str().unwrap();
2821 let dest_str = dest.to_str().unwrap();
2822 run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
2823
2824 let refs = list_refs(&dest).unwrap();
2825 let branch_names: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
2826 assert!(
2827 branch_names.contains(&"main"),
2828 "branches must include 'main', got: {branch_names:?}"
2829 );
2830 }
2831
2832 #[test]
2833 fn list_refs_returns_tag() {
2834 let src = tempdir().unwrap();
2835 make_repo(src.path());
2836 git(src.path(), &["tag", "v1.0.0"]);
2837
2838 let dest_root = tempdir().unwrap();
2839 let dest = dest_root.path().join("clone");
2840 let src_str = src.path().to_str().unwrap();
2841 run_git(src.path(), &["clone", src_str, dest.to_str().unwrap()]).unwrap();
2842 run_git(&dest, &["fetch", "--tags"]).unwrap();
2844
2845 let refs = list_refs(&dest).unwrap();
2846 let tag_names: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
2847 assert!(
2848 tag_names.contains(&"v1.0.0"),
2849 "tags must include 'v1.0.0', got: {tag_names:?}"
2850 );
2851 }
2852
2853 #[test]
2856 fn create_and_destroy_worktree() {
2857 let repo = tempdir().unwrap();
2858 make_repo(repo.path());
2859
2860 let sha = get_sha(repo.path(), "HEAD").unwrap();
2861
2862 let wt_root = tempdir().unwrap();
2863 let wt_path = wt_root.path().join("worktree");
2864
2865 create_worktree(repo.path(), &sha, &wt_path).unwrap();
2866 assert!(
2867 wt_path.exists(),
2868 "worktree directory must exist after creation"
2869 );
2870 assert!(
2871 wt_path.join("hello.txt").exists(),
2872 "worktree must contain committed files"
2873 );
2874
2875 destroy_worktree(repo.path(), &wt_path).unwrap();
2876 assert!(
2877 !wt_path.exists(),
2878 "worktree directory must be removed after destroy"
2879 );
2880 }
2881
2882 #[test]
2883 fn destroy_worktree_on_nonexistent_path_succeeds() {
2884 let repo = tempdir().unwrap();
2886 make_repo(repo.path());
2887 let nonexistent = repo.path().join("does_not_exist");
2888 assert!(destroy_worktree(repo.path(), &nonexistent).is_ok());
2889 }
2890
2891 #[test]
2892 fn create_worktree_resolves_non_default_remote_branch() {
2893 let src = tempdir().unwrap();
2897 let inner = src.path().join("inner");
2898 std::fs::create_dir_all(&inner).unwrap();
2899 make_repo(&inner);
2900 git(&inner, &["checkout", "-b", "feature-x"]);
2901 std::fs::write(inner.join("feat.txt"), "feature\n").unwrap();
2902 git(&inner, &["add", "feat.txt"]);
2903 git(&inner, &["commit", "--no-gpg-sign", "-m", "feature commit"]);
2904 git(&inner, &["checkout", "main"]);
2905
2906 let dest_root = tempdir().unwrap();
2907 let dest = dest_root.path().join("clone");
2908 run_git(
2909 src.path(),
2910 &["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
2911 )
2912 .unwrap();
2913
2914 let wt_root = tempdir().unwrap();
2916 let wt = wt_root.path().join("wt");
2917 create_worktree(&dest, "feature-x", &wt).unwrap();
2918 assert!(
2919 wt.join("feat.txt").exists(),
2920 "worktree must contain the feature branch's file"
2921 );
2922 destroy_worktree(&dest, &wt).unwrap();
2923 }
2924
2925 #[test]
2926 fn resolve_committish_falls_back_to_origin_and_rejects_unknown() {
2927 let src = tempdir().unwrap();
2928 let inner = src.path().join("inner");
2929 std::fs::create_dir_all(&inner).unwrap();
2930 make_repo(&inner);
2931 git(&inner, &["branch", "release-1"]);
2932
2933 let dest_root = tempdir().unwrap();
2934 let dest = dest_root.path().join("clone");
2935 run_git(
2936 src.path(),
2937 &["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
2938 )
2939 .unwrap();
2940
2941 let sha = resolve_committish(&dest, "release-1").unwrap();
2943 assert_eq!(sha.len(), 40, "must resolve to a full SHA: {sha}");
2944 assert!(resolve_committish(&dest, "no-such-branch").is_err());
2946 }
2947
2948 #[test]
2951 fn is_local_repo_path_true_for_repo_dir() {
2952 let dir = tempdir().unwrap();
2953 make_repo(dir.path());
2954 assert!(is_local_repo_path(dir.path().to_str().unwrap()));
2955 }
2956
2957 #[test]
2958 fn is_local_repo_path_false_for_plain_dir() {
2959 let dir = tempdir().unwrap();
2960 assert!(!is_local_repo_path(dir.path().to_str().unwrap()));
2961 }
2962
2963 #[test]
2964 fn is_local_repo_path_false_for_remote_or_url_forms() {
2965 assert!(!is_local_repo_path("https://github.com/owner/repo.git"));
2966 assert!(!is_local_repo_path("git@github.com:owner/repo.git"));
2967 assert!(!is_local_repo_path("ssh://git@host/repo.git"));
2968 assert!(!is_local_repo_path("file:///tmp/repo"));
2969 assert!(!is_local_repo_path(""));
2970 }
2971
2972 #[test]
2973 fn open_local_repo_returns_toplevel() {
2974 let dir = tempdir().unwrap();
2975 make_repo(dir.path());
2976 let root = open_local_repo(dir.path()).unwrap();
2977 assert!(
2980 root.join("hello.txt").exists(),
2981 "toplevel must be the repo root: {root:?}"
2982 );
2983 }
2984
2985 #[test]
2986 fn open_local_repo_errs_on_non_repo() {
2987 let dir = tempdir().unwrap();
2988 assert!(open_local_repo(dir.path()).is_err());
2989 }
2990
2991 #[test]
2994 fn list_refs_local_lists_local_branches_and_tags() {
2995 let dir = tempdir().unwrap();
2996 make_repo(dir.path());
2997 git(dir.path(), &["branch", "feature-x"]);
2998 git(dir.path(), &["tag", "v1.0.0"]);
2999
3000 let refs = list_refs_local(dir.path()).unwrap();
3001 let branches: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
3002 assert!(
3003 branches.contains(&"main"),
3004 "local heads must include main: {branches:?}"
3005 );
3006 assert!(
3007 branches.contains(&"feature-x"),
3008 "local heads must include feature-x (no clone/origin needed): {branches:?}"
3009 );
3010 let tags: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
3011 assert!(
3012 tags.contains(&"v1.0.0"),
3013 "tags must include v1.0.0: {tags:?}"
3014 );
3015 assert!(
3016 !refs.recent_commits.is_empty(),
3017 "recent commits must be listed"
3018 );
3019 }
3020
3021 #[test]
3024 fn submodule_url_is_safe_classification() {
3025 assert!(submodule_url_is_safe("../sibling.git"));
3027 assert!(submodule_url_is_safe("./nested/mod.git"));
3028 assert!(!submodule_url_is_safe("/etc/passwd"));
3030 assert!(!submodule_url_is_safe("file:///etc/shadow"));
3031 assert!(!submodule_url_is_safe("https://169.254.169.254/x.git"));
3033 assert!(!submodule_url_is_safe(
3034 "http://metadata.google.internal/x.git"
3035 ));
3036 assert!(submodule_url_is_safe("https://github.com/owner/repo.git"));
3038 }
3039
3040 #[test]
3041 fn populate_submodules_noop_without_gitmodules() {
3042 let dir = tempdir().unwrap();
3043 make_repo(dir.path());
3044 assert!(populate_submodules(dir.path()).unwrap().is_empty());
3045 }
3046
3047 #[test]
3048 fn populate_submodules_skips_ssrf_submodule() {
3049 let dir = tempdir().unwrap();
3052 make_repo(dir.path());
3053 let gitmodules =
3054 "[submodule \"evil\"]\n\tpath = evil\n\turl = https://169.254.169.254/x.git\n";
3055 std::fs::write(dir.path().join(".gitmodules"), gitmodules).unwrap();
3056 let skipped = populate_submodules(dir.path()).unwrap();
3057 assert_eq!(
3058 skipped,
3059 vec!["evil".to_string()],
3060 "the unsafe submodule must be reported skipped"
3061 );
3062 }
3063}