use std::path::{Path, PathBuf};
use crate::PathDisplayExt;
use crate::errors::{ModuleError, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitSource {
pub repo_url: String,
pub tag: Option<String>,
pub git_ref: Option<String>,
pub subdir: Option<String>,
}
pub fn is_git_source(source: &str) -> bool {
if source.starts_with("https://")
|| source.starts_with("http://")
|| source.starts_with("git@")
|| source.starts_with("ssh://")
{
return true;
}
if source.starts_with("file://") && std::env::var("CFGD_ALLOW_LOCAL_SOURCES").is_ok() {
return true;
}
false
}
pub fn parse_git_source(source: &str) -> Result<GitSource> {
if !is_git_source(source) {
return Err(ModuleError::InvalidSpec {
name: source.to_string(),
message: "not a git URL".into(),
}
.into());
}
let mut url = source.to_string();
let mut tag = None;
let mut git_ref = None;
let mut subdir = None;
if let Some(ref_pos) = url.find("?ref=") {
let after_ref = &url[ref_pos + 5..];
let end = after_ref.find("//").unwrap_or(after_ref.len());
let ref_val = after_ref[..end].to_string();
let remainder = &after_ref[end..];
url = format!("{}{}", &url[..ref_pos], remainder);
git_ref = Some(ref_val);
}
let search_start = url.find("://").map(|p| p + 3).unwrap_or(0);
if let Some(rel_pos) = url[search_start..].find("//") {
let subdir_pos = search_start + rel_pos;
let subdir_part = url[subdir_pos + 2..].to_string();
url = url[..subdir_pos].to_string();
if let Some(at_pos) = subdir_part.rfind('@') {
subdir = Some(subdir_part[..at_pos].to_string());
tag = Some(subdir_part[at_pos + 1..].to_string());
} else {
subdir = Some(subdir_part);
}
} else {
if let Some(git_suffix_pos) = url.find(".git") {
let after_git = &url[git_suffix_pos + 4..];
if let Some(at_pos) = after_git.find('@') {
tag = Some(after_git[at_pos + 1..].to_string());
url = url[..git_suffix_pos + 4].to_string();
}
} else if let Some(at_pos) = url.rfind('@') {
let skip_to = if url.starts_with("git@") {
url.find('@').map(|p| p + 1).unwrap_or(0)
} else {
url.find("://").map(|p| p + 3).unwrap_or(0)
};
if at_pos > skip_to {
tag = Some(url[at_pos + 1..].to_string());
url = url[..at_pos].to_string();
}
}
}
Ok(GitSource {
repo_url: url,
tag,
git_ref,
subdir,
})
}
pub fn git_cache_dir(cache_base: &Path, repo_url: &str) -> PathBuf {
let hash = crate::sha256_hex(repo_url.as_bytes());
cache_base.join(&hash[..32])
}
pub fn default_module_cache_dir() -> Result<PathBuf> {
if let Some(home) = crate::util::test_home_override() {
return Ok(home.join(".cache").join("cfgd").join("modules"));
}
let base = directories::BaseDirs::new().ok_or_else(|| ModuleError::GitFetchFailed {
module: String::new(),
url: String::new(),
message: "cannot determine home directory".into(),
})?;
Ok(base.cache_dir().join("cfgd").join("modules"))
}
pub(super) fn resolve_subdir(
base: PathBuf,
subdir: &Option<String>,
module: &str,
url: &str,
) -> Result<PathBuf> {
match subdir {
Some(sub) => {
crate::validate_no_traversal(std::path::Path::new(sub)).map_err(|_| {
ModuleError::GitFetchFailed {
module: module.to_string(),
url: url.to_string(),
message: format!("subdir contains path traversal: {sub}"),
}
})?;
Ok(base.join(sub))
}
None => Ok(base),
}
}
pub fn fetch_git_source(
git_src: &GitSource,
cache_base: &Path,
module_name: &str,
printer: &crate::output::Printer,
) -> Result<PathBuf> {
let cache_dir = git_cache_dir(cache_base, &git_src.repo_url);
if cache_dir.join(".git").exists() || cache_dir.join("HEAD").exists() {
fetch_existing_repo(&cache_dir, git_src, module_name, printer)?;
} else {
clone_repo(&cache_dir, git_src, module_name, printer)?;
}
checkout_ref(&cache_dir, git_src, module_name)?;
resolve_subdir(cache_dir, &git_src.subdir, module_name, &git_src.repo_url)
}
pub(super) fn open_repo(path: &Path, module: &str, url: &str) -> Result<git2::Repository> {
git2::Repository::open(path).map_err(|e| {
ModuleError::GitFetchFailed {
module: module.to_string(),
url: url.to_string(),
message: format!("cannot open repo: {e}"),
}
.into()
})
}
fn git_fetch_options<'a>() -> git2::FetchOptions<'a> {
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(crate::git_ssh_credentials);
let mut fetch_opts = git2::FetchOptions::new();
fetch_opts.remote_callbacks(callbacks);
fetch_opts
}
pub(super) fn clone_repo(
dest: &Path,
git_src: &GitSource,
module_name: &str,
printer: &crate::output::Printer,
) -> Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| ModuleError::GitFetchFailed {
module: module_name.to_string(),
url: git_src.repo_url.clone(),
message: format!("cannot create cache directory: {e}"),
})?;
}
let mut cmd = crate::git_cmd_safe(Some(&git_src.repo_url), None);
cmd.args(["clone", &git_src.repo_url, &dest.display().to_string()]);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let label = format!("Cloning module '{}'", module_name);
let cli_result = printer.run(&mut cmd, &label);
if matches!(&cli_result, Ok(output) if output.status.success()) {
return Ok(());
}
let _ = std::fs::remove_dir_all(dest);
if let Some(parent) = dest.parent() {
let _ = std::fs::create_dir_all(parent);
}
let spinner = printer.spinner(format!("Cloning module '{}' (libgit2)...", module_name));
let result = git2::build::RepoBuilder::new()
.fetch_options(git_fetch_options())
.clone(&git_src.repo_url, dest)
.map_err(|e| ModuleError::GitFetchFailed {
module: module_name.to_string(),
url: git_src.repo_url.clone(),
message: e.to_string(),
});
match &result {
Ok(_) => {
let _ = spinner.finish_ok(format!("Cloned module '{}' (libgit2)", module_name));
}
Err(e) => {
let _ = spinner
.finish_fail(format!(
"Failed to clone module '{}' (libgit2)",
module_name
))
.detail(crate::output::collapse_to_subject_line(e));
}
}
result?;
Ok(())
}
pub(super) fn fetch_existing_repo(
repo_path: &Path,
git_src: &GitSource,
module_name: &str,
printer: &crate::output::Printer,
) -> Result<()> {
let mut cmd = crate::git_cmd_safe(Some(&git_src.repo_url), None);
cmd.args(["-C", &repo_path.display().to_string(), "fetch", "origin"]);
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let label = format!("Fetching module '{}'", module_name);
let cli_result = printer.run(&mut cmd, &label);
if matches!(&cli_result, Ok(output) if output.status.success()) {
return Ok(());
}
let spinner = printer.spinner(format!("Fetching module '{}' (libgit2)...", module_name));
let repo = open_repo(repo_path, module_name, &git_src.repo_url)?;
let mut remote = repo
.find_remote("origin")
.map_err(|e| ModuleError::GitFetchFailed {
module: module_name.to_string(),
url: git_src.repo_url.clone(),
message: format!("no 'origin' remote: {e}"),
})?;
let refspecs: Vec<String> = remote
.refspecs()
.filter_map(|rs| rs.str().map(String::from))
.collect();
let refspec_strs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
let fetch_result = remote
.fetch(&refspec_strs, Some(&mut git_fetch_options()), None)
.map_err(|e| ModuleError::GitFetchFailed {
module: module_name.to_string(),
url: git_src.repo_url.clone(),
message: format!("fetch failed: {e}"),
});
match &fetch_result {
Ok(_) => {
let _ = spinner.finish_ok(format!("Fetched module '{}' (libgit2)", module_name));
}
Err(e) => {
let _ = spinner
.finish_fail(format!(
"Failed to fetch module '{}' (libgit2)",
module_name
))
.detail(crate::output::collapse_to_subject_line(e));
}
}
fetch_result?;
Ok(())
}
fn checkout_ref(repo_path: &Path, git_src: &GitSource, module_name: &str) -> Result<()> {
let repo = open_repo(repo_path, module_name, &git_src.repo_url)?;
let target_ref = git_src.tag.as_deref().or(git_src.git_ref.as_deref());
let Some(ref_name) = target_ref else {
return Ok(());
};
let obj = repo
.revparse_single(&format!("refs/tags/{ref_name}"))
.or_else(|_| repo.revparse_single(&format!("refs/remotes/origin/{ref_name}")))
.or_else(|_| repo.revparse_single(ref_name))
.map_err(|e| ModuleError::GitFetchFailed {
module: module_name.to_string(),
url: git_src.repo_url.clone(),
message: format!("cannot find ref '{ref_name}': {e}"),
})?;
let commit = obj
.peel_to_commit()
.map_err(|e| ModuleError::GitFetchFailed {
module: module_name.to_string(),
url: git_src.repo_url.clone(),
message: format!("ref '{ref_name}' does not point to a commit: {e}"),
})?;
repo.set_head_detached(commit.id())
.map_err(|e| ModuleError::GitFetchFailed {
module: module_name.to_string(),
url: git_src.repo_url.clone(),
message: format!("cannot detach HEAD to '{ref_name}': {e}"),
})?;
repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
.map_err(|e| ModuleError::GitFetchFailed {
module: module_name.to_string(),
url: git_src.repo_url.clone(),
message: format!("checkout failed for '{ref_name}': {e}"),
})?;
Ok(())
}
pub fn get_head_commit_sha(repo_path: &Path) -> Result<String> {
let path_str = repo_path.display_posix();
let repo = open_repo(repo_path, &path_str, &path_str)?;
let head = repo.head().map_err(|e| ModuleError::GitFetchFailed {
module: path_str.clone(),
url: path_str.clone(),
message: format!("cannot read HEAD: {e}"),
})?;
let commit = head
.peel_to_commit()
.map_err(|e| ModuleError::GitFetchFailed {
module: path_str.clone(),
url: path_str,
message: format!("HEAD is not a commit: {e}"),
})?;
Ok(commit.id().to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TagSignatureStatus {
LightweightTag,
Unsigned,
SignaturePresent,
TagNotFound,
}
pub fn check_tag_signature(
repo_path: &Path,
tag_name: &str,
module_name: &str,
) -> Result<TagSignatureStatus> {
let repo = open_repo(repo_path, module_name, "")?;
let tag_ref = match repo.revparse_single(&format!("refs/tags/{tag_name}")) {
Ok(obj) => obj,
Err(_) => return Ok(TagSignatureStatus::TagNotFound),
};
let tag = match tag_ref.as_tag() {
Some(t) => t,
None => return Ok(TagSignatureStatus::LightweightTag),
};
let message = match tag.message() {
Some(m) => m,
None => return Ok(TagSignatureStatus::Unsigned),
};
if message.contains("-----BEGIN PGP SIGNATURE-----")
|| message.contains("-----BEGIN SSH SIGNATURE-----")
{
Ok(TagSignatureStatus::SignaturePresent)
} else {
Ok(TagSignatureStatus::Unsigned)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_git_source_accepts_https() {
assert!(is_git_source("https://github.com/user/repo.git"));
}
#[test]
fn is_git_source_accepts_http() {
assert!(is_git_source("http://example.com/repo"));
}
#[test]
fn is_git_source_accepts_ssh() {
assert!(is_git_source("ssh://git@github.com/user/repo.git"));
}
#[test]
fn is_git_source_accepts_git_at() {
assert!(is_git_source("git@github.com:user/repo.git"));
}
#[test]
fn is_git_source_rejects_local_path() {
assert!(!is_git_source("/home/user/dotfiles"));
assert!(!is_git_source("./local/path"));
assert!(!is_git_source("relative/path"));
}
#[test]
#[serial_test::serial]
fn is_git_source_rejects_file_url_by_default() {
let _guard = crate::test_helpers::EnvVarGuard::unset("CFGD_ALLOW_LOCAL_SOURCES");
assert!(!is_git_source("file:///tmp/repo"));
}
#[test]
#[serial_test::serial]
fn is_git_source_accepts_file_url_when_env_set() {
let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
assert!(is_git_source("file:///tmp/repo"));
}
#[test]
fn parse_plain_https_url() {
let gs = parse_git_source("https://github.com/user/repo.git").unwrap();
assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
assert_eq!(gs.tag, None);
assert_eq!(gs.git_ref, None);
assert_eq!(gs.subdir, None);
}
#[test]
fn parse_https_with_tag() {
let gs = parse_git_source("https://github.com/user/repo.git@v2.1.0").unwrap();
assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
assert_eq!(gs.tag.as_deref(), Some("v2.1.0"));
}
#[test]
fn parse_https_with_ref() {
let gs = parse_git_source("https://github.com/user/repo.git?ref=dev").unwrap();
assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
assert_eq!(gs.git_ref.as_deref(), Some("dev"));
assert_eq!(gs.tag, None);
}
#[test]
fn parse_https_with_subdir() {
let gs = parse_git_source("https://github.com/user/repo.git//configs/base").unwrap();
assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
assert_eq!(gs.subdir.as_deref(), Some("configs/base"));
assert_eq!(gs.tag, None);
}
#[test]
fn parse_https_with_subdir_and_tag() {
let gs = parse_git_source("https://github.com/user/repo.git//configs/base@v2.1.0").unwrap();
assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
assert_eq!(gs.subdir.as_deref(), Some("configs/base"));
assert_eq!(gs.tag.as_deref(), Some("v2.1.0"));
}
#[test]
fn parse_ssh_with_tag() {
let gs = parse_git_source("git@github.com:user/repo.git@v1.0.0").unwrap();
assert_eq!(gs.repo_url, "git@github.com:user/repo.git");
assert_eq!(gs.tag.as_deref(), Some("v1.0.0"));
}
#[test]
fn parse_ssh_plain() {
let gs = parse_git_source("git@github.com:user/repo.git").unwrap();
assert_eq!(gs.repo_url, "git@github.com:user/repo.git");
assert_eq!(gs.tag, None);
assert_eq!(gs.git_ref, None);
}
#[test]
fn parse_ref_with_subdir() {
let gs = parse_git_source("https://github.com/user/repo.git?ref=dev//subdir").unwrap();
assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
assert_eq!(gs.git_ref.as_deref(), Some("dev"));
assert_eq!(gs.subdir.as_deref(), Some("subdir"));
}
#[test]
fn parse_no_dot_git_with_tag() {
let gs = parse_git_source("https://github.com/user/repo@v3.0").unwrap();
assert_eq!(gs.repo_url, "https://github.com/user/repo");
assert_eq!(gs.tag.as_deref(), Some("v3.0"));
}
#[test]
fn parse_rejects_non_git_url() {
let err = parse_git_source("/local/path").expect_err("local path rejected");
let msg = err.to_string();
assert!(msg.contains("not a git URL"), "got: {msg}");
}
#[test]
fn git_cache_dir_is_deterministic() {
let base = Path::new("/tmp/cache");
let d1 = git_cache_dir(base, "https://github.com/user/repo.git");
let d2 = git_cache_dir(base, "https://github.com/user/repo.git");
assert_eq!(d1, d2);
}
#[test]
fn git_cache_dir_differs_for_different_urls() {
let base = Path::new("/tmp/cache");
let d1 = git_cache_dir(base, "https://github.com/user/repo-a.git");
let d2 = git_cache_dir(base, "https://github.com/user/repo-b.git");
assert_ne!(d1, d2);
}
#[test]
fn git_cache_dir_uses_first_32_hex_chars() {
let base = Path::new("/cache");
let d = git_cache_dir(base, "https://example.com/repo");
let dir_name = d.file_name().unwrap().to_str().unwrap();
assert_eq!(dir_name.len(), 32);
assert!(dir_name.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn resolve_subdir_none_returns_base() {
let base = PathBuf::from("/cache/abc123");
let result = resolve_subdir(base.clone(), &None, "mod", "url").unwrap();
assert_eq!(result, base);
}
#[test]
fn resolve_subdir_appends_path() {
let base = PathBuf::from("/cache/abc123");
let result =
resolve_subdir(base.clone(), &Some("configs/base".into()), "mod", "url").unwrap();
assert_eq!(result, base.join("configs/base"));
}
#[test]
fn resolve_subdir_rejects_traversal() {
let base = PathBuf::from("/cache/abc123");
let err = resolve_subdir(base, &Some("../escape".into()), "mod", "url")
.expect_err("traversal rejected");
let msg = err.to_string();
assert!(
msg.contains("traversal"),
"error must mention traversal, got: {msg}"
);
}
#[test]
fn check_tag_signature_returns_tag_not_found() {
let dir = tempfile::tempdir().unwrap();
git2::Repository::init(dir.path()).unwrap();
let result = check_tag_signature(dir.path(), "nonexistent", "test-mod").unwrap();
assert_eq!(result, TagSignatureStatus::TagNotFound);
}
#[test]
fn check_tag_signature_lightweight_tag() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let obj = repo.find_object(commit_oid, None).unwrap();
repo.tag_lightweight("v1.0.0", &obj, false).unwrap();
let result = check_tag_signature(dir.path(), "v1.0.0", "test-mod").unwrap();
assert_eq!(result, TagSignatureStatus::LightweightTag);
}
#[test]
fn check_tag_signature_annotated_unsigned() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let obj = repo.find_object(commit_oid, None).unwrap();
repo.tag("v2.0.0", &obj, &sig, "release v2.0.0", false)
.unwrap();
let result = check_tag_signature(dir.path(), "v2.0.0", "test-mod").unwrap();
assert_eq!(result, TagSignatureStatus::Unsigned);
}
#[test]
fn get_head_commit_sha_returns_hex_hash() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let sha = get_head_commit_sha(dir.path()).unwrap();
assert_eq!(sha, commit_oid.to_string());
assert_eq!(sha.len(), 40);
assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn get_head_commit_sha_errors_on_non_repo() {
let dir = tempfile::tempdir().unwrap();
let err = get_head_commit_sha(dir.path()).expect_err("non-repo must error");
let msg = err.to_string();
assert!(
msg.contains("cannot open repo"),
"error must mention repo open failure, got: {msg}"
);
}
#[test]
fn default_module_cache_dir_with_test_home() {
let dir = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(dir.path());
let cache = default_module_cache_dir().unwrap();
assert!(
cache.starts_with(dir.path()),
"cache dir must be under test home, got: {}",
cache.display()
);
assert!(
cache.ends_with("cfgd/modules"),
"must end with cfgd/modules, got: {}",
cache.display()
);
}
#[test]
fn parse_ssh_without_dot_git_with_tag() {
let gs = parse_git_source("git@gitlab.example.com:user/repo@v9.9.9").unwrap();
assert_eq!(gs.repo_url, "git@gitlab.example.com:user/repo");
assert_eq!(gs.tag.as_deref(), Some("v9.9.9"));
}
#[test]
fn parse_https_no_dot_git_skips_to_scheme_for_at_lookup() {
let gs = parse_git_source("https://internal.host/proj@v3.0").unwrap();
assert_eq!(gs.repo_url, "https://internal.host/proj");
assert_eq!(gs.tag.as_deref(), Some("v3.0"));
}
#[test]
fn parse_url_with_no_at_in_path_returns_no_tag() {
let gs = parse_git_source("https://example.com/path/to/repo").unwrap();
assert_eq!(gs.repo_url, "https://example.com/path/to/repo");
assert_eq!(gs.tag, None);
}
fn build_local_fixture_repo() -> (tempfile::TempDir, String) {
let src = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(src.path()).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let _commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let head = repo.head().unwrap().target().unwrap();
let obj = repo.find_object(head, None).unwrap();
repo.tag_lightweight("v0.1.0", &obj, false).unwrap();
let url = crate::test_helpers::file_url(src.path());
(src, url)
}
#[test]
#[serial_test::serial]
fn fetch_git_source_clones_then_reuses_existing_cache_on_second_call() {
let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
let (_src, url) = build_local_fixture_repo();
let cache_base = tempfile::tempdir().unwrap();
let printer = crate::test_helpers::test_printer();
let git_src = parse_git_source(&url).unwrap();
let path1 = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer)
.expect("first fetch must clone successfully");
assert!(path1.join("HEAD").exists() || path1.join(".git").exists());
let path2 = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer)
.expect("second fetch must reuse cache and succeed");
assert_eq!(path1, path2, "cached path must be stable across calls");
}
#[test]
#[serial_test::serial]
fn fetch_git_source_with_tag_checks_out_tag() {
let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
let (_src, url) = build_local_fixture_repo();
let cache_base = tempfile::tempdir().unwrap();
let printer = crate::test_helpers::test_printer();
let url_with_tag = format!("{}@v0.1.0", url);
let git_src = parse_git_source(&url_with_tag).unwrap();
assert_eq!(git_src.tag.as_deref(), Some("v0.1.0"));
let result = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer);
assert!(
result.is_ok(),
"checkout-by-tag against local fixture must succeed: {:?}",
result.err()
);
}
#[test]
#[serial_test::serial]
fn fetch_git_source_with_missing_tag_returns_err() {
let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
let (_src, url) = build_local_fixture_repo();
let cache_base = tempfile::tempdir().unwrap();
let printer = crate::test_helpers::test_printer();
let url_with_tag = format!("{}@no-such-tag", url);
let git_src = parse_git_source(&url_with_tag).unwrap();
let err = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer)
.expect_err("missing tag must error");
let msg = err.to_string();
assert!(
msg.contains("cannot find ref") || msg.contains("no-such-tag"),
"error must mention missing ref, got: {msg}"
);
}
#[test]
fn open_repo_errors_on_non_repo() {
let dir = tempfile::tempdir().unwrap();
let result = open_repo(dir.path(), "mod", "url");
let err = match result {
Ok(_) => panic!("non-repo must error"),
Err(e) => e,
};
assert!(
err.to_string().contains("cannot open repo"),
"error must mention cannot open repo: {err}"
);
}
#[test]
fn check_tag_signature_signature_present_pgp() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let obj = repo.find_object(commit_oid, None).unwrap();
let msg =
"release v3.0.0\n-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----\n";
repo.tag("v3.0.0", &obj, &sig, msg, false).unwrap();
let result = check_tag_signature(dir.path(), "v3.0.0", "mod").unwrap();
assert_eq!(result, TagSignatureStatus::SignaturePresent);
}
#[test]
fn check_tag_signature_signature_present_ssh() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let obj = repo.find_object(commit_oid, None).unwrap();
let msg = "release v4\n-----BEGIN SSH SIGNATURE-----\nfake\n-----END SSH SIGNATURE-----\n";
repo.tag("v4.0.0", &obj, &sig, msg, false).unwrap();
let result = check_tag_signature(dir.path(), "v4.0.0", "mod").unwrap();
assert_eq!(result, TagSignatureStatus::SignaturePresent);
}
#[test]
fn get_head_commit_sha_returns_err_when_repo_has_no_head() {
let dir = tempfile::tempdir().unwrap();
git2::Repository::init(dir.path()).unwrap();
let err = get_head_commit_sha(dir.path()).expect_err("no HEAD must error");
let msg = err.to_string();
assert!(
msg.contains("cannot read HEAD") || msg.contains("cannot open repo"),
"error must mention HEAD or repo: {msg}"
);
}
#[test]
#[serial_test::serial]
fn fetch_git_source_with_bare_repo_branch_checks_out_branch() {
let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
let bare = crate::test_helpers::BareGitRepo::builder()
.commit("init", &[("README.md", "hello")])
.branch("feature", &[("feature.txt", "feature-data")])
.build();
let cache_base = tempfile::tempdir().expect("cache tempdir");
let printer = crate::test_helpers::test_printer();
let url_with_ref = format!("{}?ref=feature", bare.url());
let git_src = parse_git_source(&url_with_ref).expect("parse ref url");
assert_eq!(git_src.git_ref.as_deref(), Some("feature"));
let path = fetch_git_source(&git_src, cache_base.path(), "branchy", &printer)
.expect("fetch with branch checkout must succeed");
assert!(path.join("feature.txt").exists(), "branch file must exist");
assert_eq!(
std::fs::read_to_string(path.join("feature.txt")).unwrap(),
"feature-data"
);
}
#[test]
#[serial_test::serial]
fn fetch_git_source_with_bare_repo_tag_checks_out_tag() {
let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
let bare = crate::test_helpers::BareGitRepo::builder()
.commit("first", &[("a.txt", "first content")])
.tag("v1.0.0")
.build();
let cache_base = tempfile::tempdir().expect("cache tempdir");
let printer = crate::test_helpers::test_printer();
let url_with_tag = format!("{}@v1.0.0", bare.url());
let git_src = parse_git_source(&url_with_tag).expect("parse tag url");
assert_eq!(git_src.tag.as_deref(), Some("v1.0.0"));
let path = fetch_git_source(&git_src, cache_base.path(), "tagged", &printer)
.expect("fetch with tag checkout must succeed");
assert!(path.join("a.txt").exists());
let path2 = fetch_git_source(&git_src, cache_base.path(), "tagged", &printer)
.expect("second fetch (fetch_existing_repo path) must succeed");
assert_eq!(path, path2);
}
#[test]
fn check_tag_signature_returns_unsigned_when_tag_has_no_message() {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
.unwrap();
let obj = repo.find_object(commit_oid, None).unwrap();
repo.tag("vNoSig", &obj, &sig, " ", false).unwrap();
let result = check_tag_signature(dir.path(), "vNoSig", "mod").unwrap();
assert_eq!(result, TagSignatureStatus::Unsigned);
}
#[test]
#[serial_test::serial]
fn default_module_cache_dir_test_home_uses_home_join() {
let dir = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(dir.path());
let cache = default_module_cache_dir().expect("default_module_cache_dir under test-home");
assert_eq!(
cache,
dir.path().join(".cache").join("cfgd").join("modules")
);
}
}