use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use crate::{GitCommit, GitRef, GitRefKind, RepoRefs};
fn git_host_allowlist() -> &'static [String] {
static ALLOW: OnceLock<Vec<String>> = OnceLock::new();
ALLOW.get_or_init(|| {
std::env::var("SLOC_GIT_HOST_ALLOWLIST")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
})
}
fn require_host_allowlist() -> bool {
static REQ: OnceLock<bool> = OnceLock::new();
*REQ.get_or_init(|| {
std::env::var("SLOC_GIT_REQUIRE_ALLOWLIST")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
})
}
fn ssl_no_verify() -> bool {
static NO_VERIFY: OnceLock<bool> = OnceLock::new();
*NO_VERIFY.get_or_init(|| std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some())
}
fn git_timeout() -> Duration {
static TIMEOUT: OnceLock<Duration> = OnceLock::new();
*TIMEOUT.get_or_init(|| {
let secs = std::env::var("SLOC_GIT_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&s| s > 0)
.unwrap_or(300);
Duration::from_secs(secs)
})
}
enum GitCredential {
Https { user: String, token: String },
Ssh { key_path: String },
}
fn hostkey(host: &str) -> String {
host.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect()
}
fn resolve_credential(host: &str, port: Option<u16>) -> Option<GitCredential> {
let mut keys: Vec<String> = Vec::with_capacity(2);
if let Some(pt) = port {
keys.push(hostkey(&format!("{host}:{pt}")));
}
keys.push(hostkey(host));
for key in &keys {
if let Ok(v) = std::env::var(format!("SLOC_GIT_CRED_{key}"))
&& let Some((user, token)) = v.split_once(':')
&& !token.is_empty()
{
return Some(GitCredential::Https {
user: user.to_owned(),
token: token.to_owned(),
});
}
if let Ok(p) = std::env::var(format!("SLOC_GIT_SSHKEY_{key}"))
&& !p.trim().is_empty()
{
return Some(GitCredential::Ssh { key_path: p });
}
}
cred_from_file(host)
}
fn cred_from_file(host: &str) -> Option<GitCredential> {
let path = std::env::var("SLOC_GIT_CRED_FILE").ok()?;
let path = path.trim();
if path.is_empty() {
return None;
}
warn_if_world_readable(path);
let content = std::fs::read_to_string(path).ok()?;
let host_lower = host.to_lowercase();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim().trim_matches('"').to_lowercase() != host_lower {
continue;
}
let v = v.trim().trim_matches('"');
if let Some((user, token)) = v.split_once(':')
&& !token.is_empty()
{
return Some(GitCredential::Https {
user: user.to_owned(),
token: token.to_owned(),
});
}
}
None
}
#[cfg(unix)]
fn warn_if_world_readable(path: &str) {
use std::os::unix::fs::PermissionsExt as _;
if let Ok(meta) = std::fs::metadata(path)
&& meta.permissions().mode() & 0o077 != 0
{
eprintln!(
"warning: SLOC_GIT_CRED_FILE {path:?} is group/world-readable; \
restrict it with chmod 600"
);
}
}
#[cfg(not(unix))]
fn warn_if_world_readable(_path: &str) {}
#[derive(Default)]
struct CredInjection {
config: Vec<String>,
env: Vec<(String, String)>,
}
fn cred_injection(host: &str, port: Option<u16>) -> CredInjection {
match resolve_credential(host, port) {
Some(GitCredential::Https { user, token }) => CredInjection {
config: vec![
"credential.helper=".to_owned(),
"credential.helper=!f() { test \"$1\" = get && echo \"username=$GIT_U\" && \
echo \"password=$GIT_P\"; }; f"
.to_owned(),
],
env: vec![("GIT_U".to_owned(), user), ("GIT_P".to_owned(), token)],
},
Some(GitCredential::Ssh { key_path }) => {
let mut ssh = format!("ssh -i \"{key_path}\" -o IdentitiesOnly=yes -o BatchMode=yes");
if ssh_accept_new() {
ssh.push_str(" -o StrictHostKeyChecking=accept-new");
}
CredInjection {
config: Vec::new(),
env: vec![("GIT_SSH_COMMAND".to_owned(), ssh)],
}
}
None => CredInjection::default(),
}
}
fn ssh_accept_new() -> bool {
std::env::var("SLOC_GIT_SSH_ACCEPT_NEW")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
fn allow_local() -> bool {
std::env::var("SLOC_GIT_ALLOW_LOCAL").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
fn local_root() -> Option<PathBuf> {
std::env::var("SLOC_GIT_LOCAL_ROOT")
.ok()
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
}
fn network_git_config() -> Vec<String> {
let mut cfg = vec![
"http.followRedirects=false".to_owned(),
"http.lowSpeedLimit=1000".to_owned(),
"http.lowSpeedTime=30".to_owned(),
];
if cfg!(windows) {
cfg.push("http.sslBackend=schannel".to_owned());
}
if ssl_no_verify() {
cfg.push("http.sslVerify=false".to_owned());
}
cfg
}
fn with_config<'a>(cfg: &'a [String], tail: &[&'a str]) -> Vec<&'a str> {
let mut v = Vec::with_capacity(cfg.len() * 2 + tail.len());
for c in cfg {
v.push("-c");
v.push(c.as_str());
}
v.extend_from_slice(tail);
v
}
fn persist_repo_config(dest: &Path, cfg: &[String]) {
let mut helper_reset = false;
for kv in cfg {
if let Some((key, value)) = kv.split_once('=') {
if key == "credential.helper" {
if !helper_reset {
let _ = run_git(dest, &["config", "--unset-all", "credential.helper"]);
helper_reset = true;
}
let _ = run_git(dest, &["config", "--add", "credential.helper", value]);
} else {
let _ = run_git(dest, &["config", key, value]);
}
}
}
}
fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
run_git_env(repo, args, &[])
}
fn run_git_env(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> Result<String> {
let mut cmd = std::process::Command::new("git");
cmd.env("GIT_TERMINAL_PROMPT", "0")
.env("GCM_INTERACTIVE", "never")
.env("GIT_ASKPASS", "")
.env("SSH_ASKPASS", "");
for (k, v) in extra_env {
cmd.env(k, v);
}
cmd.args(args)
.current_dir(repo)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().context("failed to spawn git process")?;
let mut out_pipe = child.stdout.take();
let mut err_pipe = child.stderr.take();
let out_handle = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(p) = out_pipe.as_mut() {
let _ = p.read_to_end(&mut buf);
}
buf
});
let err_handle = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(p) = err_pipe.as_mut() {
let _ = p.read_to_end(&mut buf);
}
buf
});
let timeout = git_timeout();
let start = Instant::now();
let status = loop {
if let Some(status) = child.try_wait().context("failed to poll git process")? {
break status;
}
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
bail!(
"git {} timed out after {}s — the remote did not respond in time. \
On a corporate network this usually means a proxy or VPN is slow or \
blocking the connection. Raise the ceiling with SLOC_GIT_TIMEOUT=<seconds>, \
or check your proxy/VPN configuration.",
args.first().copied().unwrap_or(""),
timeout.as_secs()
);
}
std::thread::sleep(Duration::from_millis(100));
};
let stdout = out_handle.join().unwrap_or_default();
let stderr = err_handle.join().unwrap_or_default();
if !status.success() {
let stderr = String::from_utf8_lossy(&stderr);
bail!(
"git {}: {}",
args.first().copied().unwrap_or(""),
stderr.trim()
);
}
Ok(String::from_utf8_lossy(&stdout).trim().to_owned())
}
#[must_use]
pub fn normalize_git_url(raw: &str) -> String {
let url = raw.trim();
if url.starts_with("git@") || url.starts_with("ssh://") {
return url.to_owned();
}
let scheme = if url.starts_with("https://") {
"https"
} else if url.starts_with("http://") {
"http"
} else {
return url.to_owned();
};
let authority_and_path = &url[scheme.len() + 3..];
let (host, path) = authority_and_path
.find('/')
.map_or((authority_and_path, "/"), |i| {
(&authority_and_path[..i], &authority_and_path[i..])
});
let path = path.trim_end_matches('/');
try_normalize_bitbucket_server(scheme, host, path)
.or_else(|| try_normalize_gitlab(scheme, host, path))
.or_else(|| try_normalize_github(scheme, host, path))
.or_else(|| try_normalize_bitbucket_cloud(scheme, host, path))
.unwrap_or_else(|| url.to_owned())
}
fn try_normalize_bitbucket_server(scheme: &str, host: &str, path: &str) -> Option<String> {
let path_lower = path.to_lowercase();
let proj_pos = path_lower.find("/projects/")?;
let after = &path[proj_pos + "/projects/".len()..];
let parts: Vec<&str> = after.splitn(4, '/').collect();
if parts.len() < 3 || !parts[1].eq_ignore_ascii_case("repos") {
return None;
}
let context = &path[..proj_pos];
let project = parts[0].to_lowercase();
let repo = parts[2].trim_end_matches(".git");
Some(format!(
"{scheme}://{host}{context}/scm/{project}/{repo}.git"
))
}
fn try_normalize_gitlab(scheme: &str, host: &str, path: &str) -> Option<String> {
let idx = path.find("/-/")?;
let repo_path = path[..idx].trim_end_matches(".git");
Some(format!("{scheme}://{host}{repo_path}.git"))
}
fn try_normalize_github(scheme: &str, host: &str, path: &str) -> Option<String> {
if host != "github.com" && !host.ends_with(".github.com") {
return None;
}
let p = path.trim_start_matches('/');
let parts: Vec<&str> = p.splitn(4, '/').collect();
if parts.len() < 3
|| !matches!(
parts[2],
"tree" | "blob" | "commits" | "commit" | "releases" | "tags" | "branches"
)
{
return None;
}
let owner = parts[0];
let repo = parts[1].trim_end_matches(".git");
Some(format!("{scheme}://{host}/{owner}/{repo}.git"))
}
fn try_normalize_bitbucket_cloud(scheme: &str, host: &str, path: &str) -> Option<String> {
if host != "bitbucket.org" {
return None;
}
let p = path.trim_start_matches('/');
let parts: Vec<&str> = p.splitn(4, '/').collect();
if parts.len() < 3 || parts[2] != "src" {
return None;
}
let ws = parts[0];
let repo = parts[1].trim_end_matches(".git");
Some(format!("{scheme}://{host}/{ws}/{repo}.git"))
}
fn validate_clone_url(url: &str) -> Result<()> {
let lower = url.to_lowercase();
let allowed = ["https://", "git://", "ssh://", "git@"];
if !allowed.iter().any(|p| lower.starts_with(p)) {
bail!(
"git URL rejected: only https://, git://, ssh://, and git@ URLs are \
permitted (got {url:?})"
);
}
let Some(host) = host_of_git_url(url) else {
return Ok(());
};
check_host_allowed(&host)?;
check_resolved_ips(&host, url)?;
Ok(())
}
fn check_host_allowed(host: &str) -> Result<()> {
let allow = git_host_allowlist();
if allow.is_empty() {
if require_host_allowlist() {
bail!(
"git URL rejected: SLOC_GIT_REQUIRE_ALLOWLIST is set but \
SLOC_GIT_HOST_ALLOWLIST is empty (no hosts are permitted)"
);
}
} else if !allow.iter().any(|h| h == host) {
bail!("git URL rejected: host {host:?} is not in SLOC_GIT_HOST_ALLOWLIST");
}
if is_ssrf_blocked_host(host) {
bail!(
"git URL rejected: loopback, link-local, and cloud-metadata \
addresses are not permitted (host {host:?})"
);
}
Ok(())
}
fn check_resolved_ips(host: &str, url: &str) -> Result<()> {
let Some(port) = port_of_git_url(url) else {
return Ok(());
};
let Ok(addrs) = resolve_host_port(host, port) else {
return Ok(());
};
for addr in addrs {
if is_ssrf_blocked_ip(addr.ip()) {
bail!(
"git URL rejected: host {host:?} resolves to a blocked \
address {} (loopback/link-local/cloud-metadata)",
addr.ip()
);
}
}
Ok(())
}
#[cfg(not(test))]
fn resolve_host_port(
host: &str,
port: u16,
) -> std::io::Result<std::vec::IntoIter<std::net::SocketAddr>> {
use std::net::ToSocketAddrs as _;
(host, port).to_socket_addrs()
}
#[cfg(test)]
fn resolve_host_port(
host: &str,
port: u16,
) -> std::io::Result<std::vec::IntoIter<std::net::SocketAddr>> {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
let ip = host
.parse::<IpAddr>()
.unwrap_or(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)));
Ok(vec![SocketAddr::new(ip, port)].into_iter())
}
fn host_of_git_url(url: &str) -> Option<String> {
let u = url.trim();
if let Some(rest) = u.strip_prefix("git@") {
let host = rest.split(':').next().unwrap_or(rest);
return Some(host.to_lowercase());
}
let after_scheme = u.split("://").nth(1)?;
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
let authority = authority.rsplit('@').next().unwrap_or(authority);
let host = authority.strip_prefix('[').map_or_else(
|| authority.split(':').next().unwrap_or(authority).to_string(),
|stripped| stripped.split(']').next().unwrap_or(stripped).to_string(),
);
Some(host.to_lowercase())
}
fn port_of_git_url(url: &str) -> Option<u16> {
let u = url.trim();
if u.starts_with("git@") {
return Some(22);
}
let (scheme, after_scheme) = u.split_once("://")?;
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
let authority = authority.rsplit('@').next().unwrap_or(authority);
let explicit = authority.strip_prefix('[').map_or_else(
|| {
authority
.rsplit_once(':')
.and_then(|(_, p)| p.parse::<u16>().ok())
},
|stripped| {
stripped
.split_once("]:")
.and_then(|(_, p)| p.parse::<u16>().ok())
},
);
explicit.or_else(|| match scheme.to_lowercase().as_str() {
"https" => Some(443),
"git" => Some(9418),
"ssh" => Some(22),
_ => None,
})
}
fn explicit_port_of_git_url(url: &str) -> Option<u16> {
let u = url.trim();
if u.starts_with("git@") {
return None;
}
let (_scheme, after_scheme) = u.split_once("://")?;
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
let authority = authority.rsplit('@').next().unwrap_or(authority);
authority.strip_prefix('[').map_or_else(
|| {
authority
.rsplit_once(':')
.and_then(|(_, p)| p.parse::<u16>().ok())
},
|stripped| {
stripped
.split_once("]:")
.and_then(|(_, p)| p.parse::<u16>().ok())
},
)
}
const BLOCKED_METADATA_HOSTNAMES: &[&str] = &[
"metadata.google.internal",
"metadata.internal",
"instance-data",
];
fn is_ssrf_blocked_host(host: &str) -> bool {
let h = host
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.to_lowercase();
if h == "localhost" || BLOCKED_METADATA_HOSTNAMES.contains(&h.as_str()) {
return true;
}
h.parse::<std::net::IpAddr>().is_ok_and(is_ssrf_blocked_ip)
}
fn is_ssrf_blocked_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_multicast()
|| v4.octets() == [100, 100, 100, 200] }
std::net::IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (v6.segments()[0] & 0xffc0) == 0xfe80 }
}
}
enum GitSource {
Remote,
FileUrl,
LocalPath,
Bundle,
}
fn classify_source(url: &str) -> GitSource {
let u = url.trim();
let lower = u.to_lowercase();
if lower.starts_with("https://")
|| lower.starts_with("http://")
|| lower.starts_with("git://")
|| lower.starts_with("ssh://")
|| u.starts_with("git@")
{
GitSource::Remote
} else if lower.starts_with("file://") {
GitSource::FileUrl
} else if lower.ends_with(".bundle") {
GitSource::Bundle
} else {
GitSource::LocalPath
}
}
pub fn clone_or_fetch(url: &str, dest: &Path) -> Result<()> {
let normalized = normalize_git_url(url);
let url = normalized.as_str();
match classify_source(url) {
GitSource::Remote => clone_or_fetch_remote(url, dest),
source => clone_or_fetch_local(url, dest, &source),
}
}
fn clone_or_fetch_remote(url: &str, dest: &Path) -> Result<()> {
validate_clone_url(url)?;
let mut cfg = network_git_config();
let inj = host_of_git_url(url)
.map(|h| cred_injection(&h, explicit_port_of_git_url(url)))
.unwrap_or_default();
cfg.extend(inj.config.iter().cloned());
let env: Vec<(&str, &str)> = inj
.env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
if dest.join(".git").exists() {
let args = with_config(&cfg, &["fetch", "--all", "--tags", "--prune"]);
run_git_env(dest, &args, &env)?;
return Ok(());
}
std::fs::create_dir_all(dest).context("failed to create clone directory")?;
let dest_str = dest.to_str().unwrap_or(".");
let parent = dest.parent().unwrap_or(dest);
let fast = with_config(
&cfg,
&[
"clone",
"--filter=blob:none",
"--no-checkout",
"--no-single-branch",
url,
dest_str,
],
);
if let Err(e) = run_git_env(parent, &fast, &env) {
let msg = e.to_string().to_lowercase();
if !(msg.contains("filter") || msg.contains("partial")) {
return Err(e);
}
let _ = std::fs::remove_dir_all(dest);
std::fs::create_dir_all(dest).context("failed to re-create clone directory")?;
let full = with_config(
&cfg,
&[
"clone",
"--no-checkout",
"--no-single-branch",
url,
dest_str,
],
);
run_git_env(parent, &full, &env)?;
}
persist_repo_config(dest, &cfg);
Ok(())
}
fn clone_or_fetch_local(url: &str, dest: &Path, source: &GitSource) -> Result<()> {
let src = validate_local_source(url, source)?;
let cfg = network_git_config();
if dest.join(".git").exists() {
let args = with_config(&cfg, &["fetch", "--all", "--tags", "--prune"]);
run_git(dest, &args)?;
return Ok(());
}
std::fs::create_dir_all(dest).context("failed to create clone directory")?;
let dest_str = dest.to_str().unwrap_or(".");
let parent = dest.parent().unwrap_or(dest);
let tail: Vec<&str> = match source {
GitSource::Bundle => vec!["clone", "--no-checkout", &src, dest_str],
GitSource::FileUrl => vec![
"clone",
"--filter=blob:none",
"--no-checkout",
"--no-single-branch",
&src,
dest_str,
],
GitSource::LocalPath => vec![
"clone",
"--no-local",
"--filter=blob:none",
"--no-checkout",
"--no-single-branch",
&src,
dest_str,
],
GitSource::Remote => unreachable!("remote sources are handled by clone_or_fetch_remote"),
};
let args = with_config(&cfg, &tail);
run_git(parent, &args)?;
persist_repo_config(dest, &cfg);
Ok(())
}
fn validate_local_source(url: &str, source: &GitSource) -> Result<String> {
if !allow_local() {
bail!(
"local/offline git source rejected: set SLOC_GIT_ALLOW_LOCAL=1 to enable bundle / \
file:// / local-path imports (got {url:?})"
);
}
let Some(root) = local_root() else {
bail!(
"SLOC_GIT_ALLOW_LOCAL is set but SLOC_GIT_LOCAL_ROOT is not — refusing local import \
(fail-closed). Point SLOC_GIT_LOCAL_ROOT at the directory holding your bundles/mirrors."
);
};
let raw = url.trim();
let path = match source {
GitSource::FileUrl => file_url_to_path(raw)?,
_ => raw.to_owned(),
};
if path.starts_with("\\\\") || path.starts_with("//") {
bail!("UNC path rejected: SMB shares are network sources, not local ({url:?})");
}
let canon = std::fs::canonicalize(&path)
.with_context(|| format!("local git source not found or unreadable: {path:?}"))?;
let root_canon = std::fs::canonicalize(&root)
.with_context(|| format!("SLOC_GIT_LOCAL_ROOT not found: {}", root.display()))?;
if !canon.starts_with(&root_canon) {
bail!(
"local git source {} is outside SLOC_GIT_LOCAL_ROOT {}",
canon.display(),
root_canon.display()
);
}
Ok(deverbatim(&canon))
}
fn file_url_to_path(url: &str) -> Result<String> {
let rest = &url.trim()[7..]; if !rest.starts_with('/') {
bail!(
"file:// URL with a host authority is not permitted (use file:///local/path): {url:?}"
);
}
let after = &rest[1..];
if after.len() >= 2 && after.as_bytes()[1] == b':' {
Ok(after.to_owned())
} else {
Ok(rest.to_owned())
}
}
fn deverbatim(p: &Path) -> String {
let s = p.to_string_lossy();
s.strip_prefix(r"\\?\").unwrap_or(&s).to_owned()
}
pub fn get_sha(repo: &Path, ref_name: &str) -> Result<String> {
run_git(repo, &["rev-parse", ref_name])
}
pub fn resolve_committish(repo: &Path, ref_name: &str) -> Result<String> {
let candidates = [
ref_name.to_owned(),
format!("origin/{ref_name}"),
format!("refs/remotes/origin/{ref_name}"),
];
for cand in &candidates {
let spec = format!("{cand}^{{commit}}");
if let Ok(sha) = run_git(repo, &["rev-parse", "--verify", "-q", &spec])
&& !sha.is_empty()
{
return Ok(sha);
}
}
bail!(
"ref {ref_name:?} not found in repository (tried it directly, as origin/{ref_name}, \
and as refs/remotes/origin/{ref_name})"
);
}
pub fn create_worktree(repo: &Path, ref_name: &str, worktree_path: &Path) -> Result<()> {
let wt = worktree_path.to_str().unwrap_or(".");
let committish = resolve_committish(repo, ref_name)?;
let env = cred_env_for_repo(repo);
let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
run_git_env(
repo,
&["worktree", "add", "--detach", wt, &committish],
&env_refs,
)?;
Ok(())
}
fn cred_env_for_repo(repo: &Path) -> Vec<(String, String)> {
let Ok(url) = run_git(repo, &["config", "--get", "remote.origin.url"]) else {
return Vec::new();
};
let Some(host) = host_of_git_url(&url) else {
return Vec::new();
};
cred_injection(&host, explicit_port_of_git_url(&url)).env
}
pub fn destroy_worktree(repo: &Path, worktree_path: &Path) -> Result<()> {
let wt = worktree_path.to_str().unwrap_or(".");
let _ = run_git(repo, &["worktree", "remove", "--force", wt]);
Ok(())
}
pub fn list_refs(repo: &Path) -> Result<RepoRefs> {
Ok(RepoRefs {
branches: list_branches(repo)?,
tags: list_tags(repo)?,
recent_commits: list_commits(repo, "HEAD", 40)?,
})
}
fn list_branches(repo: &Path) -> Result<Vec<GitRef>> {
let fmt = "%(symref)|%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
let out = run_git(repo, &["branch", "-r", &format!("--format={fmt}")])?;
let refs = out
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
let (symref, rest) = l.split_once('|')?;
if symref.trim().is_empty() {
Some(rest)
} else {
None
}
})
.map(|l| parse_ref_line(l, GitRefKind::Branch))
.map(|mut r| {
if let Some(slash) = r.name.find('/') {
r.name = r.name[slash + 1..].to_owned();
}
r
})
.collect::<Vec<_>>();
Ok(refs)
}
fn list_tags(repo: &Path) -> Result<Vec<GitRef>> {
let fmt = "%(refname:short)|%(objectname:short)|%(creatordate:iso-strict)|%(subject)";
let out = run_git(
repo,
&["tag", "--sort=-creatordate", &format!("--format={fmt}")],
)?;
Ok(out
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| parse_ref_line(l, GitRefKind::Tag))
.collect())
}
fn parse_ref_line(line: &str, kind: GitRefKind) -> GitRef {
let parts: Vec<&str> = line.splitn(4, '|').collect();
let name = parts.first().copied().unwrap_or("").to_owned();
let sha = parts.get(1).copied().unwrap_or("").to_owned();
let date = parts.get(2).copied().and_then(parse_git_date);
let message = parts.get(3).map(|s| (*s).to_owned());
GitRef {
kind,
name,
sha,
date,
message,
}
}
pub fn list_commits(repo: &Path, ref_name: &str, limit: usize) -> Result<Vec<GitCommit>> {
let fmt = "%H|%h|%an|%aI|%s";
let n = format!("-{limit}");
let out = run_git(repo, &["log", ref_name, &format!("--format={fmt}"), &n])?;
Ok(out
.lines()
.filter(|l| !l.trim().is_empty())
.map(parse_commit_line)
.collect())
}
fn parse_commit_line(line: &str) -> GitCommit {
let p: Vec<&str> = line.splitn(5, '|').collect();
let sha = p.first().copied().unwrap_or("").to_owned();
let short_sha = p.get(1).copied().unwrap_or("").to_owned();
let author = p.get(2).copied().unwrap_or("").to_owned();
let date = p
.get(3)
.copied()
.and_then(parse_git_date)
.unwrap_or_default();
let subject = p.get(4).copied().unwrap_or("").to_owned();
GitCommit {
sha,
short_sha,
author,
date,
subject,
}
}
fn parse_git_date(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|d| d.with_timezone(&chrono::Utc))
}
#[cfg(test)]
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::GitRefKind;
use chrono::Timelike as _;
#[test]
fn is_ssrf_blocked_host_blocks_localhost_and_metadata() {
assert!(is_ssrf_blocked_host("localhost"));
assert!(is_ssrf_blocked_host("metadata.google.internal"));
assert!(is_ssrf_blocked_host("metadata.internal"));
assert!(is_ssrf_blocked_host("instance-data"));
assert!(is_ssrf_blocked_host(" LOCALHOST "));
assert!(is_ssrf_blocked_host("127.0.0.1"));
assert!(is_ssrf_blocked_host("[::1]"));
assert!(is_ssrf_blocked_host("169.254.169.254"));
}
#[test]
fn require_host_allowlist_defaults_false() {
assert!(!require_host_allowlist());
}
#[test]
fn check_host_allowed_denylist_mode_permits_public_blocks_sensitive() {
assert!(check_host_allowed("github.com").is_ok());
assert!(check_host_allowed("localhost").is_err());
}
#[test]
fn is_ssrf_blocked_host_allows_public_hosts() {
assert!(!is_ssrf_blocked_host("github.com"));
assert!(!is_ssrf_blocked_host("example.com"));
assert!(!is_ssrf_blocked_host("192.168.1.10"));
assert!(!is_ssrf_blocked_host("10.0.0.1"));
}
#[test]
fn network_git_config_always_hardens_redirects_and_lowspeed() {
let cfg = network_git_config();
assert!(cfg.iter().any(|c| c == "http.followRedirects=false"));
assert!(cfg.iter().any(|c| c == "http.lowSpeedLimit=1000"));
assert!(cfg.iter().any(|c| c == "http.lowSpeedTime=30"));
}
#[cfg(windows)]
#[test]
fn network_git_config_uses_schannel_on_windows() {
let cfg = network_git_config();
assert!(cfg.iter().any(|c| c == "http.sslBackend=schannel"));
}
#[test]
fn with_config_interleaves_dash_c_pairs_before_tail() {
let cfg = vec!["a=1".to_owned(), "b=2".to_owned()];
let args = with_config(&cfg, &["clone", "url", "dest"]);
assert_eq!(args, vec!["-c", "a=1", "-c", "b=2", "clone", "url", "dest"]);
}
#[test]
fn with_config_empty_cfg_is_just_the_tail() {
let cfg: Vec<String> = Vec::new();
assert_eq!(with_config(&cfg, &["fetch"]), vec!["fetch"]);
}
#[test]
fn git_timeout_is_positive() {
assert!(git_timeout().as_secs() > 0);
}
#[test]
fn normalize_github_tree_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/tree/main"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_blob_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/blob/main/README.md"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_commits_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/commits/main"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_releases_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/releases"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_tags_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/tags"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_branches_url() {
assert_eq!(
normalize_git_url("https://github.com/owner/repo/branches"),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_github_plain_clone_url_unchanged() {
let url = "https://github.com/owner/repo.git";
assert_eq!(normalize_git_url(url), url);
}
#[test]
fn normalize_gitlab_tree_url() {
assert_eq!(
normalize_git_url("https://gitlab.com/group/subgroup/repo/-/tree/main"),
"https://gitlab.com/group/subgroup/repo.git"
);
}
#[test]
fn normalize_gitlab_blob_url() {
assert_eq!(
normalize_git_url("https://gitlab.com/org/repo/-/blob/main/src/lib.rs"),
"https://gitlab.com/org/repo.git"
);
}
#[test]
fn normalize_gitlab_self_hosted() {
assert_eq!(
normalize_git_url("https://gitlab.corp.com/team/project/-/tree/develop"),
"https://gitlab.corp.com/team/project.git"
);
}
#[test]
fn normalize_bitbucket_server_browse_url() {
assert_eq!(
normalize_git_url("https://bitbucket.corp.com/projects/MYPROJ/repos/myrepo/browse"),
"https://bitbucket.corp.com/scm/myproj/myrepo.git"
);
}
#[test]
fn normalize_bitbucket_server_with_context() {
assert_eq!(
normalize_git_url("https://host.com/ctx/projects/PROJ/repos/repo/browse"),
"https://host.com/ctx/scm/proj/repo.git"
);
}
#[test]
fn normalize_bitbucket_cloud_src_url() {
assert_eq!(
normalize_git_url("https://bitbucket.org/workspace/repo/src/main/README.md"),
"https://bitbucket.org/workspace/repo.git"
);
}
#[test]
fn normalize_ssh_url_unchanged() {
let url = "git@github.com:owner/repo.git";
assert_eq!(normalize_git_url(url), url);
}
#[test]
fn normalize_ssh_protocol_url_unchanged() {
let url = "ssh://git@github.com/owner/repo.git";
assert_eq!(normalize_git_url(url), url);
}
#[test]
fn normalize_trims_leading_trailing_whitespace() {
assert_eq!(
normalize_git_url(" https://github.com/owner/repo/tree/main "),
"https://github.com/owner/repo.git"
);
}
#[test]
fn normalize_http_url_without_match_returned_unchanged() {
let url = "http://internal.corp.com/repo.git";
assert_eq!(normalize_git_url(url), url);
}
#[test]
fn validate_https_url_ok() {
assert!(validate_clone_url("https://github.com/owner/repo.git").is_ok());
}
#[test]
fn validate_git_protocol_url_ok() {
assert!(validate_clone_url("git://github.com/owner/repo.git").is_ok());
}
#[test]
fn validate_ssh_protocol_url_ok() {
assert!(validate_clone_url("ssh://git@github.com/owner/repo.git").is_ok());
}
#[test]
fn validate_git_at_url_ok() {
assert!(validate_clone_url("git@github.com:owner/repo.git").is_ok());
}
#[test]
fn validate_http_plain_rejected() {
assert!(
validate_clone_url("http://github.com/owner/repo.git").is_err(),
"plain http:// must be rejected"
);
}
#[test]
fn validate_link_local_169_254_rejected() {
assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
}
#[test]
fn validate_google_metadata_endpoint_rejected() {
assert!(
validate_clone_url("https://metadata.google.internal/computeMetadata/v1/").is_err()
);
}
#[test]
fn validate_alibaba_metadata_rejected() {
assert!(validate_clone_url("https://100.100.100.200/latest/meta-data/").is_err());
}
#[test]
fn validate_ipv6_fe80_link_local_rejected() {
assert!(validate_clone_url("https://[fe80::1]/repo").is_err());
}
#[test]
fn validate_file_protocol_rejected() {
assert!(validate_clone_url("file:///etc/passwd").is_err());
}
#[test]
fn validate_empty_string_rejected() {
assert!(validate_clone_url("").is_err());
}
#[test]
fn validate_rfc1918_10_allowed() {
assert!(validate_clone_url("https://10.0.0.1/repo.git").is_ok());
}
#[test]
fn validate_rfc1918_192_168_allowed() {
assert!(validate_clone_url("https://192.168.1.1/repo.git").is_ok());
}
#[test]
fn validate_rfc1918_172_16_allowed() {
assert!(validate_clone_url("https://172.16.0.1/repo.git").is_ok());
}
#[test]
fn validate_rfc1918_172_31_allowed() {
assert!(validate_clone_url("https://172.31.255.255/repo.git").is_ok());
}
#[test]
fn validate_ipv6_ula_fd_allowed() {
assert!(validate_clone_url("https://[fd12:3456:789a::1]/repo").is_ok());
}
#[test]
fn port_https_default() {
assert_eq!(port_of_git_url("https://github.com/o/r.git"), Some(443));
}
#[test]
fn port_explicit_overrides_default() {
assert_eq!(
port_of_git_url("https://gitlab.corp:8443/o/r.git"),
Some(8443)
);
}
#[test]
fn port_git_scheme_default() {
assert_eq!(port_of_git_url("git://example.com/r.git"), Some(9418));
}
#[test]
fn port_scp_like_is_ssh() {
assert_eq!(port_of_git_url("git@github.com:owner/repo.git"), Some(22));
}
#[test]
fn port_ipv6_with_explicit_port() {
assert_eq!(port_of_git_url("https://[fd00::1]:7000/r"), Some(7000));
}
#[test]
fn port_ipv6_default() {
assert_eq!(port_of_git_url("https://[fd00::1]/r"), Some(443));
}
#[test]
fn validate_metadata_ip_literal_still_rejected() {
assert!(validate_clone_url("https://169.254.169.254/latest/meta-data/").is_err());
}
#[test]
fn validate_loopback_127_rejected() {
assert!(validate_clone_url("https://127.0.0.1/repo.git").is_err());
}
#[test]
fn validate_localhost_rejected() {
assert!(validate_clone_url("https://localhost/repo.git").is_err());
}
#[test]
fn validate_unspecified_0_0_0_0_rejected() {
assert!(validate_clone_url("https://0.0.0.0/repo.git").is_err());
}
#[test]
fn host_of_git_url_https_with_port_and_creds() {
assert_eq!(
host_of_git_url("https://user:pw@gitlab.corp.com:8443/team/repo.git").as_deref(),
Some("gitlab.corp.com")
);
}
#[test]
fn host_of_git_url_scp_syntax() {
assert_eq!(
host_of_git_url("git@github.com:owner/repo.git").as_deref(),
Some("github.com")
);
}
#[test]
fn host_of_git_url_ipv6_literal() {
assert_eq!(
host_of_git_url("https://[fe80::1]:443/repo").as_deref(),
Some("fe80::1")
);
}
#[test]
fn validate_clone_url_path_with_version_number_not_blocked() {
assert!(validate_clone_url("https://github.com/acme/release-v10.2.git").is_ok());
assert!(validate_clone_url("https://github.com/foo/bar-127-baz.git").is_ok());
}
#[test]
fn bitbucket_server_uppercase_project_lowercased() {
let r = try_normalize_bitbucket_server(
"https",
"bb.corp.com",
"/projects/PROJ/repos/myrepo/browse",
);
assert_eq!(
r,
Some("https://bb.corp.com/scm/proj/myrepo.git".to_owned())
);
}
#[test]
fn bitbucket_server_without_projects_returns_none() {
assert!(
try_normalize_bitbucket_server("https", "bb.corp.com", "/scm/proj/repo.git").is_none()
);
}
#[test]
fn bitbucket_server_missing_repos_segment_returns_none() {
assert!(
try_normalize_bitbucket_server("https", "bb.corp.com", "/projects/PROJ/browse")
.is_none()
);
}
#[test]
fn gitlab_dash_tree_normalized() {
let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo/-/tree/main");
assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
}
#[test]
fn gitlab_no_dash_returns_none() {
assert!(try_normalize_gitlab("https", "gitlab.com", "/group/repo").is_none());
}
#[test]
fn gitlab_strips_existing_dot_git_before_readding() {
let r = try_normalize_gitlab("https", "gitlab.com", "/group/repo.git/-/tree/main");
assert_eq!(r, Some("https://gitlab.com/group/repo.git".to_owned()));
}
#[test]
fn github_tree_normalized() {
let r = try_normalize_github("https", "github.com", "/owner/repo/tree/main");
assert_eq!(r, Some("https://github.com/owner/repo.git".to_owned()));
}
#[test]
fn github_non_github_host_returns_none() {
assert!(try_normalize_github("https", "gitlab.com", "/owner/repo/tree/main").is_none());
}
#[test]
fn github_plain_two_segment_path_returns_none() {
assert!(try_normalize_github("https", "github.com", "/owner/repo").is_none());
}
#[test]
fn github_unknown_third_segment_returns_none() {
assert!(try_normalize_github("https", "github.com", "/owner/repo/wiki").is_none());
}
#[test]
fn bitbucket_cloud_src_normalized() {
let r = try_normalize_bitbucket_cloud(
"https",
"bitbucket.org",
"/workspace/repo/src/main/README.md",
);
assert_eq!(
r,
Some("https://bitbucket.org/workspace/repo.git".to_owned())
);
}
#[test]
fn bitbucket_cloud_non_bitbucket_host_returns_none() {
assert!(
try_normalize_bitbucket_cloud("https", "github.com", "/ws/repo/src/main").is_none()
);
}
#[test]
fn bitbucket_cloud_without_src_segment_returns_none() {
assert!(try_normalize_bitbucket_cloud("https", "bitbucket.org", "/ws/repo").is_none());
}
#[test]
fn parse_ref_line_all_fields() {
let line = "main|abc1234|2024-01-15T10:00:00+00:00|Initial commit";
let r = parse_ref_line(line, GitRefKind::Branch);
assert_eq!(r.name, "main");
assert_eq!(r.sha, "abc1234");
assert!(r.date.is_some());
assert_eq!(r.message.as_deref(), Some("Initial commit"));
assert!(matches!(r.kind, GitRefKind::Branch));
}
#[test]
fn parse_ref_line_tag_kind() {
let line = "v1.0.0|deadbeef|2024-01-01T00:00:00+00:00|Release v1.0.0";
let r = parse_ref_line(line, GitRefKind::Tag);
assert_eq!(r.name, "v1.0.0");
assert!(matches!(r.kind, GitRefKind::Tag));
}
#[test]
fn parse_ref_line_name_only() {
let r = parse_ref_line("main", GitRefKind::Branch);
assert_eq!(r.name, "main");
assert_eq!(r.sha, "");
assert!(r.date.is_none());
assert!(r.message.is_none());
}
#[test]
fn parse_ref_line_invalid_date_gives_none() {
let r = parse_ref_line("main|abc|not-a-date|msg", GitRefKind::Branch);
assert!(r.date.is_none());
assert_eq!(r.message.as_deref(), Some("msg"));
}
#[test]
fn parse_ref_line_empty_string() {
let r = parse_ref_line("", GitRefKind::Branch);
assert_eq!(r.name, "");
}
#[test]
fn parse_commit_line_all_fields() {
let line =
"abc1234567890abcdef|abc1234|Alice Smith|2024-01-15T10:00:00+00:00|Fix critical bug";
let c = parse_commit_line(line);
assert_eq!(c.sha, "abc1234567890abcdef");
assert_eq!(c.short_sha, "abc1234");
assert_eq!(c.author, "Alice Smith");
assert_eq!(c.subject, "Fix critical bug");
}
#[test]
fn parse_commit_line_empty() {
let c = parse_commit_line("");
assert_eq!(c.sha, "");
assert_eq!(c.short_sha, "");
assert_eq!(c.author, "");
assert_eq!(c.subject, "");
}
#[test]
fn parse_commit_line_partial_fields() {
let c = parse_commit_line("sha1|sha_short");
assert_eq!(c.sha, "sha1");
assert_eq!(c.short_sha, "sha_short");
assert_eq!(c.author, "");
}
#[test]
fn parse_commit_line_subject_with_pipe() {
let line = "sha|short|author|2024-01-01T00:00:00+00:00|subject with | pipe inside";
let c = parse_commit_line(line);
assert_eq!(c.subject, "subject with | pipe inside");
}
#[test]
fn parse_git_date_valid_rfc3339() {
let dt = parse_git_date("2024-01-15T10:30:00+00:00");
assert!(dt.is_some());
}
#[test]
fn parse_git_date_invalid_returns_none() {
assert!(parse_git_date("not-a-date").is_none());
assert!(parse_git_date("").is_none());
}
#[test]
fn parse_git_date_with_offset_converts_to_utc() {
let dt = parse_git_date("2024-06-01T12:00:00+05:00").unwrap();
assert_eq!(dt.time().hour(), 7);
}
#[test]
fn port_of_git_url_unknown_scheme_returns_none() {
assert_eq!(port_of_git_url("https://host/repo"), Some(443));
assert_eq!(port_of_git_url("ssh://host/repo"), Some(22));
assert_eq!(port_of_git_url("git://host/repo"), Some(9418));
assert_eq!(port_of_git_url("file://host/repo"), None);
assert_eq!(port_of_git_url("ftp://host/repo"), None);
}
#[test]
fn hostkey_normalizes_punctuation_and_case() {
assert_eq!(
hostkey("bitbucket.instance2.com"),
"BITBUCKET_INSTANCE2_COM"
);
assert_eq!(hostkey("git-host.corp"), "GIT_HOST_CORP");
assert_eq!(hostkey("host:7990"), "HOST_7990");
}
#[test]
fn resolve_credential_https_env_wins() {
let _g = env_lock();
let host = "cred-https-test.example";
let key = format!("SLOC_GIT_CRED_{}", hostkey(host));
unsafe { std::env::set_var(&key, "alice:secrettoken") };
let cred = resolve_credential(host, None);
unsafe { std::env::remove_var(&key) };
match cred {
Some(GitCredential::Https { user, token }) => {
assert_eq!(user, "alice");
assert_eq!(token, "secrettoken");
}
_ => panic!("expected an HTTPS credential from the env registry"),
}
}
#[test]
fn resolve_credential_ssh_key_env() {
let _g = env_lock();
let host = "cred-ssh-test.example";
let key = format!("SLOC_GIT_SSHKEY_{}", hostkey(host));
unsafe { std::env::set_var(&key, "/home/u/.ssh/id_ed25519") };
let cred = resolve_credential(host, None);
unsafe { std::env::remove_var(&key) };
match cred {
Some(GitCredential::Ssh { key_path }) => {
assert_eq!(key_path, "/home/u/.ssh/id_ed25519");
}
_ => panic!("expected an SSH credential from the env registry"),
}
}
#[test]
fn resolve_credential_none_falls_through() {
assert!(resolve_credential("no-such-cred-host.invalid", None).is_none());
}
#[test]
fn cred_injection_https_keeps_secret_out_of_config() {
let _g = env_lock();
let host = "inj-test.example";
let key = format!("SLOC_GIT_CRED_{}", hostkey(host));
unsafe { std::env::set_var(&key, "bob:tok123") };
let inj = cred_injection(host, None);
unsafe { std::env::remove_var(&key) };
assert_eq!(
inj.config.first().map(String::as_str),
Some("credential.helper=")
);
assert!(
inj.config
.iter()
.any(|c| c.contains("$GIT_U") && c.contains("$GIT_P"))
);
assert!(
!inj.config.iter().any(|c| c.contains("tok123")),
"the token must NEVER appear in git config / argv"
);
assert!(inj.env.iter().any(|(k, v)| k == "GIT_U" && v == "bob"));
assert!(inj.env.iter().any(|(k, v)| k == "GIT_P" && v == "tok123"));
}
#[test]
fn resolve_credential_port_qualified_key_wins() {
let _g = env_lock();
let host = "cred-port-test.example";
let port_key = format!("SLOC_GIT_CRED_{}", hostkey(&format!("{host}:7990")));
let bare_key = format!("SLOC_GIT_CRED_{}", hostkey(host));
unsafe {
std::env::set_var(&port_key, "svc-port:porttoken");
std::env::set_var(&bare_key, "svc-bare:baretoken");
}
let with_port = resolve_credential(host, Some(7990));
let without_port = resolve_credential(host, None);
unsafe {
std::env::remove_var(&port_key);
std::env::remove_var(&bare_key);
}
match with_port {
Some(GitCredential::Https { user, .. }) => assert_eq!(user, "svc-port"),
_ => panic!("port-qualified key should win when a port is present"),
}
match without_port {
Some(GitCredential::Https { user, .. }) => assert_eq!(user, "svc-bare"),
_ => panic!("bare-host key should resolve when no port is given"),
}
}
#[test]
fn resolve_credential_falls_back_to_bare_when_only_bare_key_set() {
let _g = env_lock();
let host = "cred-fallback-test.example";
let bare_key = format!("SLOC_GIT_CRED_{}", hostkey(host));
unsafe { std::env::set_var(&bare_key, "svc:tok") };
let cred = resolve_credential(host, Some(7990));
unsafe { std::env::remove_var(&bare_key) };
assert!(matches!(cred, Some(GitCredential::Https { .. })));
}
#[test]
fn explicit_port_of_git_url_extracts_or_none() {
assert_eq!(
explicit_port_of_git_url("https://git.corp:7990/team/repo.git"),
Some(7990)
);
assert_eq!(
explicit_port_of_git_url("https://git.corp/team/repo.git"),
None
);
assert_eq!(
explicit_port_of_git_url("ssh://git@host:2222/repo.git"),
Some(2222)
);
assert_eq!(
explicit_port_of_git_url("git@github.com:owner/repo.git"),
None
);
assert_eq!(
explicit_port_of_git_url("https://[fe80::1]:443/repo"),
Some(443)
);
assert_eq!(explicit_port_of_git_url("https://[fe80::1]/repo"), None);
}
#[test]
fn classify_source_recognizes_each_form() {
assert!(matches!(
classify_source("https://github.com/o/r.git"),
GitSource::Remote
));
assert!(matches!(
classify_source("git@github.com:o/r.git"),
GitSource::Remote
));
assert!(matches!(
classify_source("ssh://git@h/o/r.git"),
GitSource::Remote
));
assert!(matches!(
classify_source("file:///srv/mirror/r"),
GitSource::FileUrl
));
assert!(matches!(
classify_source("/srv/mirror/r.bundle"),
GitSource::Bundle
));
assert!(matches!(
classify_source(r"C:\mirror\r"),
GitSource::LocalPath
));
}
#[test]
fn normalize_git_url_passes_local_sources_through() {
for u in [
"file:///srv/mirror/r",
r"C:\mirror\repo",
r"\\srv\share\repo",
"/srv/x.bundle",
] {
assert_eq!(
normalize_git_url(u),
u,
"local source must pass through unchanged: {u}"
);
}
}
#[test]
fn file_url_to_path_posix_windows_and_rejects_host() {
assert_eq!(
file_url_to_path("file:///home/u/repo").unwrap(),
"/home/u/repo"
);
assert_eq!(
file_url_to_path("file:///C:/mirror/repo").unwrap(),
"C:/mirror/repo"
);
assert!(
file_url_to_path("file://server/share/repo").is_err(),
"a file:// URL with a host authority must be rejected"
);
}
#[test]
fn validate_local_source_rejected_when_disabled() {
let _g = env_lock();
unsafe {
std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
}
assert!(validate_local_source("/srv/x.bundle", &GitSource::Bundle).is_err());
}
#[test]
fn validate_local_source_requires_root_when_enabled() {
let _g = env_lock();
unsafe {
std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
}
let err = validate_local_source("/srv/x.bundle", &GitSource::Bundle)
.unwrap_err()
.to_string();
unsafe { std::env::remove_var("SLOC_GIT_ALLOW_LOCAL") };
assert!(
err.contains("SLOC_GIT_LOCAL_ROOT"),
"must fail closed without a configured root: {err}"
);
}
#[test]
fn validate_local_source_rejects_outside_root() {
let _g = env_lock();
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
}
let outside_path = outside.path().to_string_lossy().into_owned();
let res = validate_local_source(&outside_path, &GitSource::LocalPath);
unsafe {
std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
}
assert!(
res.is_err(),
"a source outside SLOC_GIT_LOCAL_ROOT must be rejected"
);
}
#[test]
fn validate_local_source_accepts_inside_root() {
let _g = env_lock();
let root = tempfile::tempdir().unwrap();
let inside = root.path().join("sub");
std::fs::create_dir_all(&inside).unwrap();
unsafe {
std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
}
let inside_path = inside.to_string_lossy().into_owned();
let res = validate_local_source(&inside_path, &GitSource::LocalPath);
unsafe {
std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
}
assert!(
res.is_ok(),
"a source under the root must be accepted: {res:?}"
);
}
#[test]
fn validate_local_source_rejects_unc_even_when_enabled() {
let _g = env_lock();
let root = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
}
let res = validate_local_source(r"\\attacker\share\repo", &GitSource::LocalPath);
unsafe {
std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
}
assert!(
res.is_err(),
"UNC/SMB paths must be treated as remote and rejected"
);
}
#[test]
fn clone_or_fetch_file_url_rejected_without_gate() {
let _g = env_lock();
unsafe { std::env::remove_var("SLOC_GIT_ALLOW_LOCAL") };
let dest = tempfile::tempdir().unwrap();
assert!(clone_or_fetch("file:///etc/passwd", dest.path()).is_err());
}
}
#[cfg(test)]
mod git_integration {
use super::*;
use std::path::Path;
use tempfile::tempdir;
fn git(dir: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()
.expect("git must be on PATH");
assert!(status.success(), "git {args:?} failed");
}
fn make_repo(dir: &Path) {
git(dir, &["init", "-b", "main"]);
std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();
git(dir, &["add", "hello.txt"]);
git(dir, &["commit", "--no-gpg-sign", "-m", "initial"]);
}
#[test]
fn run_git_success_returns_stdout() {
let dir = tempdir().unwrap();
make_repo(dir.path());
let sha = run_git(dir.path(), &["rev-parse", "HEAD"]).unwrap();
assert_eq!(sha.len(), 40, "full SHA must be 40 hex chars: {sha}");
}
#[test]
fn run_git_failure_returns_error() {
let dir = tempdir().unwrap();
make_repo(dir.path());
let result = run_git(dir.path(), &["rev-parse", "nonexistent-ref-xyz"]);
assert!(result.is_err(), "nonexistent ref must return an error");
}
#[test]
fn clone_or_fetch_clones_local_repo() {
let src = tempdir().unwrap();
make_repo(src.path());
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
std::fs::create_dir_all(&dest).unwrap();
let src_str = src.path().to_str().unwrap();
let dest_str = dest.to_str().unwrap();
run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
assert!(dest.join(".git").exists(), "clone must create .git dir");
std::fs::write(src.path().join("second.txt"), "v2\n").unwrap();
git(src.path(), &["add", "second.txt"]);
git(src.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
run_git(&dest, &["fetch", "--all", "--tags", "--prune"]).unwrap();
}
#[test]
fn list_branches_excludes_origin_head_symref() {
let src = tempdir().unwrap();
let inner = src.path().join("inner");
std::fs::create_dir_all(&inner).unwrap();
make_repo(&inner);
git(&inner, &["branch", "feature-x"]);
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
let src_str = inner.to_str().unwrap();
let dest_str = dest.to_str().unwrap();
run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
let _ = run_git(&dest, &["remote", "set-head", "origin", "--auto"]);
let branches = list_branches(&dest).unwrap();
let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
assert!(
!names.contains(&"origin"),
"origin/HEAD symref must not appear as a branch: {names:?}"
);
assert!(
names.contains(&"main"),
"main branch must be listed: {names:?}"
);
assert!(
names.contains(&"feature-x"),
"real branches must still be listed: {names:?}"
);
}
#[test]
fn clone_or_fetch_rejects_http_plain_url() {
let dest = tempdir().unwrap();
let result = clone_or_fetch("http://example.com/repo.git", dest.path());
assert!(
result.is_err(),
"http:// must be rejected by validate_clone_url"
);
}
#[test]
fn clone_or_fetch_rejects_link_local_url() {
let dest = tempdir().unwrap();
let result = clone_or_fetch("https://169.254.169.254/repo", dest.path());
assert!(result.is_err());
}
#[test]
fn clone_or_fetch_imports_git_bundle_under_local_root() {
let _g = env_lock();
let root = tempdir().unwrap();
let src = root.path().join("src");
std::fs::create_dir_all(&src).unwrap();
make_repo(&src);
let bundle = root.path().join("repo.bundle");
run_git(
&src,
&["bundle", "create", bundle.to_str().unwrap(), "--all"],
)
.unwrap();
unsafe {
std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
}
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
let res = clone_or_fetch(bundle.to_str().unwrap(), &dest);
let refs = res.as_ref().ok().and(list_refs(&dest).ok());
unsafe {
std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
}
res.unwrap();
assert!(
dest.join(".git").exists(),
"bundle import must produce a clone"
);
let names: Vec<String> = refs
.expect("refs must be listable from the imported clone")
.branches
.into_iter()
.map(|b| b.name)
.collect();
assert!(
names.iter().any(|n| n == "main"),
"bundle clone must expose the main branch: {names:?}"
);
}
#[test]
fn clone_or_fetch_imports_local_path_under_root() {
let _g = env_lock();
let root = tempdir().unwrap();
let src = root.path().join("mirror");
std::fs::create_dir_all(&src).unwrap();
make_repo(&src);
unsafe {
std::env::set_var("SLOC_GIT_ALLOW_LOCAL", "1");
std::env::set_var("SLOC_GIT_LOCAL_ROOT", root.path());
}
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
let res = clone_or_fetch(src.to_str().unwrap(), &dest);
unsafe {
std::env::remove_var("SLOC_GIT_ALLOW_LOCAL");
std::env::remove_var("SLOC_GIT_LOCAL_ROOT");
}
res.unwrap();
assert!(
dest.join(".git").exists(),
"local-path import must produce a clone"
);
}
#[test]
fn get_sha_returns_full_commit_hash() {
let dir = tempdir().unwrap();
make_repo(dir.path());
let sha = get_sha(dir.path(), "HEAD").unwrap();
assert_eq!(sha.len(), 40);
assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn get_sha_nonexistent_ref_errors() {
let dir = tempdir().unwrap();
make_repo(dir.path());
assert!(get_sha(dir.path(), "refs/heads/nonexistent").is_err());
}
#[test]
fn list_commits_returns_at_least_one_commit() {
let dir = tempdir().unwrap();
make_repo(dir.path());
let commits = list_commits(dir.path(), "HEAD", 10).unwrap();
assert!(
!commits.is_empty(),
"must return at least the initial commit"
);
let c = &commits[0];
assert_eq!(c.sha.len(), 40);
assert!(!c.short_sha.is_empty());
assert_eq!(c.author, "Test");
assert_eq!(c.subject, "initial");
}
#[test]
fn list_commits_respects_limit() {
let dir = tempdir().unwrap();
make_repo(dir.path());
std::fs::write(dir.path().join("b.txt"), "b\n").unwrap();
git(dir.path(), &["add", "b.txt"]);
git(dir.path(), &["commit", "--no-gpg-sign", "-m", "second"]);
let one = list_commits(dir.path(), "HEAD", 1).unwrap();
assert_eq!(one.len(), 1, "limit=1 must return exactly 1 commit");
let two = list_commits(dir.path(), "HEAD", 10).unwrap();
assert_eq!(two.len(), 2, "limit=10 must return both commits");
}
#[test]
fn list_refs_returns_main_branch() {
let src = tempdir().unwrap();
make_repo(src.path());
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
let src_str = src.path().to_str().unwrap();
let dest_str = dest.to_str().unwrap();
run_git(src.path(), &["clone", src_str, dest_str]).unwrap();
let refs = list_refs(&dest).unwrap();
let branch_names: Vec<&str> = refs.branches.iter().map(|b| b.name.as_str()).collect();
assert!(
branch_names.contains(&"main"),
"branches must include 'main', got: {branch_names:?}"
);
}
#[test]
fn list_refs_returns_tag() {
let src = tempdir().unwrap();
make_repo(src.path());
git(src.path(), &["tag", "v1.0.0"]);
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
let src_str = src.path().to_str().unwrap();
run_git(src.path(), &["clone", src_str, dest.to_str().unwrap()]).unwrap();
run_git(&dest, &["fetch", "--tags"]).unwrap();
let refs = list_refs(&dest).unwrap();
let tag_names: Vec<&str> = refs.tags.iter().map(|t| t.name.as_str()).collect();
assert!(
tag_names.contains(&"v1.0.0"),
"tags must include 'v1.0.0', got: {tag_names:?}"
);
}
#[test]
fn create_and_destroy_worktree() {
let repo = tempdir().unwrap();
make_repo(repo.path());
let sha = get_sha(repo.path(), "HEAD").unwrap();
let wt_root = tempdir().unwrap();
let wt_path = wt_root.path().join("worktree");
create_worktree(repo.path(), &sha, &wt_path).unwrap();
assert!(
wt_path.exists(),
"worktree directory must exist after creation"
);
assert!(
wt_path.join("hello.txt").exists(),
"worktree must contain committed files"
);
destroy_worktree(repo.path(), &wt_path).unwrap();
assert!(
!wt_path.exists(),
"worktree directory must be removed after destroy"
);
}
#[test]
fn destroy_worktree_on_nonexistent_path_succeeds() {
let repo = tempdir().unwrap();
make_repo(repo.path());
let nonexistent = repo.path().join("does_not_exist");
assert!(destroy_worktree(repo.path(), &nonexistent).is_ok());
}
#[test]
fn create_worktree_resolves_non_default_remote_branch() {
let src = tempdir().unwrap();
let inner = src.path().join("inner");
std::fs::create_dir_all(&inner).unwrap();
make_repo(&inner);
git(&inner, &["checkout", "-b", "feature-x"]);
std::fs::write(inner.join("feat.txt"), "feature\n").unwrap();
git(&inner, &["add", "feat.txt"]);
git(&inner, &["commit", "--no-gpg-sign", "-m", "feature commit"]);
git(&inner, &["checkout", "main"]);
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
run_git(
src.path(),
&["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
)
.unwrap();
let wt_root = tempdir().unwrap();
let wt = wt_root.path().join("wt");
create_worktree(&dest, "feature-x", &wt).unwrap();
assert!(
wt.join("feat.txt").exists(),
"worktree must contain the feature branch's file"
);
destroy_worktree(&dest, &wt).unwrap();
}
#[test]
fn resolve_committish_falls_back_to_origin_and_rejects_unknown() {
let src = tempdir().unwrap();
let inner = src.path().join("inner");
std::fs::create_dir_all(&inner).unwrap();
make_repo(&inner);
git(&inner, &["branch", "release-1"]);
let dest_root = tempdir().unwrap();
let dest = dest_root.path().join("clone");
run_git(
src.path(),
&["clone", inner.to_str().unwrap(), dest.to_str().unwrap()],
)
.unwrap();
let sha = resolve_committish(&dest, "release-1").unwrap();
assert_eq!(sha.len(), 40, "must resolve to a full SHA: {sha}");
assert!(resolve_committish(&dest, "no-such-branch").is_err());
}
}