1use std::path::{Path, PathBuf};
4
5use crate::PathDisplayExt;
6use crate::errors::{ModuleError, Result};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct GitSource {
15 pub repo_url: String,
17 pub tag: Option<String>,
19 pub git_ref: Option<String>,
21 pub subdir: Option<String>,
23}
24
25pub fn is_git_source(source: &str) -> bool {
32 if source.starts_with("https://")
33 || source.starts_with("http://")
34 || source.starts_with("git@")
35 || source.starts_with("ssh://")
36 {
37 return true;
38 }
39 if source.starts_with("file://") && std::env::var("CFGD_ALLOW_LOCAL_SOURCES").is_ok() {
40 return true;
41 }
42 false
43}
44
45pub fn parse_git_source(source: &str) -> Result<GitSource> {
55 if !is_git_source(source) {
56 return Err(ModuleError::InvalidSpec {
57 name: source.to_string(),
58 message: "not a git URL".into(),
59 }
60 .into());
61 }
62
63 let mut url = source.to_string();
64 let mut tag = None;
65 let mut git_ref = None;
66 let mut subdir = None;
67
68 if let Some(ref_pos) = url.find("?ref=") {
71 let after_ref = &url[ref_pos + 5..];
72 let end = after_ref.find("//").unwrap_or(after_ref.len());
73 let ref_val = after_ref[..end].to_string();
74 let remainder = &after_ref[end..];
75 url = format!("{}{}", &url[..ref_pos], remainder);
76 git_ref = Some(ref_val);
77 }
78
79 let search_start = url.find("://").map(|p| p + 3).unwrap_or(0);
82 if let Some(rel_pos) = url[search_start..].find("//") {
83 let subdir_pos = search_start + rel_pos;
84 let subdir_part = url[subdir_pos + 2..].to_string();
85 url = url[..subdir_pos].to_string();
86
87 if let Some(at_pos) = subdir_part.rfind('@') {
89 subdir = Some(subdir_part[..at_pos].to_string());
90 tag = Some(subdir_part[at_pos + 1..].to_string());
91 } else {
92 subdir = Some(subdir_part);
93 }
94 } else {
95 if let Some(git_suffix_pos) = url.find(".git") {
99 let after_git = &url[git_suffix_pos + 4..];
100 if let Some(at_pos) = after_git.find('@') {
101 tag = Some(after_git[at_pos + 1..].to_string());
102 url = url[..git_suffix_pos + 4].to_string();
103 }
104 } else if let Some(at_pos) = url.rfind('@') {
105 let skip_to = if url.starts_with("git@") {
109 url.find('@').map(|p| p + 1).unwrap_or(0)
110 } else {
111 url.find("://").map(|p| p + 3).unwrap_or(0)
112 };
113 if at_pos > skip_to {
114 tag = Some(url[at_pos + 1..].to_string());
115 url = url[..at_pos].to_string();
116 }
117 }
118 }
119
120 Ok(GitSource {
121 repo_url: url,
122 tag,
123 git_ref,
124 subdir,
125 })
126}
127
128pub fn git_cache_dir(cache_base: &Path, repo_url: &str) -> PathBuf {
131 let hash = crate::sha256_hex(repo_url.as_bytes());
132 cache_base.join(&hash[..32])
133}
134
135pub fn default_module_cache_dir() -> Result<PathBuf> {
142 if let Some(home) = crate::util::test_home_override() {
143 return Ok(home.join(".cache").join("cfgd").join("modules"));
144 }
145 let base = directories::BaseDirs::new().ok_or_else(|| ModuleError::GitFetchFailed {
146 module: String::new(),
147 url: String::new(),
148 message: "cannot determine home directory".into(),
149 })?;
150 Ok(base.cache_dir().join("cfgd").join("modules"))
151}
152
153pub(super) fn resolve_subdir(
155 base: PathBuf,
156 subdir: &Option<String>,
157 module: &str,
158 url: &str,
159) -> Result<PathBuf> {
160 match subdir {
161 Some(sub) => {
162 crate::validate_no_traversal(std::path::Path::new(sub)).map_err(|_| {
163 ModuleError::GitFetchFailed {
164 module: module.to_string(),
165 url: url.to_string(),
166 message: format!("subdir contains path traversal: {sub}"),
167 }
168 })?;
169 Ok(base.join(sub))
170 }
171 None => Ok(base),
172 }
173}
174
175pub fn fetch_git_source(
184 git_src: &GitSource,
185 cache_base: &Path,
186 module_name: &str,
187 printer: &crate::output::Printer,
188) -> Result<PathBuf> {
189 let cache_dir = git_cache_dir(cache_base, &git_src.repo_url);
190
191 if cache_dir.join(".git").exists() || cache_dir.join("HEAD").exists() {
192 fetch_existing_repo(&cache_dir, git_src, module_name, printer)?;
193 } else {
194 clone_repo(&cache_dir, git_src, module_name, printer)?;
195 }
196
197 checkout_ref(&cache_dir, git_src, module_name)?;
198
199 resolve_subdir(cache_dir, &git_src.subdir, module_name, &git_src.repo_url)
200}
201
202pub(super) fn open_repo(path: &Path, module: &str, url: &str) -> Result<git2::Repository> {
204 git2::Repository::open(path).map_err(|e| {
205 ModuleError::GitFetchFailed {
206 module: module.to_string(),
207 url: url.to_string(),
208 message: format!("cannot open repo: {e}"),
209 }
210 .into()
211 })
212}
213
214fn git_fetch_options<'a>() -> git2::FetchOptions<'a> {
216 let mut callbacks = git2::RemoteCallbacks::new();
217 callbacks.credentials(crate::git_ssh_credentials);
218 let mut fetch_opts = git2::FetchOptions::new();
219 fetch_opts.remote_callbacks(callbacks);
220 fetch_opts
221}
222
223pub(super) fn clone_repo(
224 dest: &Path,
225 git_src: &GitSource,
226 module_name: &str,
227 printer: &crate::output::Printer,
228) -> Result<()> {
229 if let Some(parent) = dest.parent() {
230 std::fs::create_dir_all(parent).map_err(|e| ModuleError::GitFetchFailed {
231 module: module_name.to_string(),
232 url: git_src.repo_url.clone(),
233 message: format!("cannot create cache directory: {e}"),
234 })?;
235 }
236
237 let mut cmd = crate::git_cmd_safe(Some(&git_src.repo_url), None);
239 cmd.args(["clone", &git_src.repo_url, &dest.display().to_string()]);
240 cmd.stdout(std::process::Stdio::piped());
241 cmd.stderr(std::process::Stdio::piped());
242
243 let label = format!("Cloning module '{}'", module_name);
244 let cli_result = printer.run(&mut cmd, &label);
245 if matches!(&cli_result, Ok(output) if output.status.success()) {
246 return Ok(());
247 }
248
249 let _ = std::fs::remove_dir_all(dest);
251 if let Some(parent) = dest.parent() {
252 let _ = std::fs::create_dir_all(parent);
253 }
254
255 let spinner = printer.spinner(format!("Cloning module '{}' (libgit2)...", module_name));
257
258 let result = git2::build::RepoBuilder::new()
259 .fetch_options(git_fetch_options())
260 .clone(&git_src.repo_url, dest)
261 .map_err(|e| ModuleError::GitFetchFailed {
262 module: module_name.to_string(),
263 url: git_src.repo_url.clone(),
264 message: e.to_string(),
265 });
266
267 match &result {
268 Ok(_) => {
269 let _ = spinner.finish_ok(format!("Cloned module '{}' (libgit2)", module_name));
270 }
271 Err(e) => {
272 let _ = spinner
273 .finish_fail(format!(
274 "Failed to clone module '{}' (libgit2)",
275 module_name
276 ))
277 .detail(crate::output::collapse_to_subject_line(e));
278 }
279 }
280 result?;
281
282 Ok(())
283}
284
285pub(super) fn fetch_existing_repo(
286 repo_path: &Path,
287 git_src: &GitSource,
288 module_name: &str,
289 printer: &crate::output::Printer,
290) -> Result<()> {
291 let mut cmd = crate::git_cmd_safe(Some(&git_src.repo_url), None);
293 cmd.args(["-C", &repo_path.display().to_string(), "fetch", "origin"]);
294 cmd.stdout(std::process::Stdio::piped());
295 cmd.stderr(std::process::Stdio::piped());
296
297 let label = format!("Fetching module '{}'", module_name);
298 let cli_result = printer.run(&mut cmd, &label);
299 if matches!(&cli_result, Ok(output) if output.status.success()) {
300 return Ok(());
301 }
302
303 let spinner = printer.spinner(format!("Fetching module '{}' (libgit2)...", module_name));
305
306 let repo = open_repo(repo_path, module_name, &git_src.repo_url)?;
307
308 let mut remote = repo
309 .find_remote("origin")
310 .map_err(|e| ModuleError::GitFetchFailed {
311 module: module_name.to_string(),
312 url: git_src.repo_url.clone(),
313 message: format!("no 'origin' remote: {e}"),
314 })?;
315
316 let refspecs: Vec<String> = remote
317 .refspecs()
318 .filter_map(|rs| rs.str().map(String::from))
319 .collect();
320 let refspec_strs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
321
322 let fetch_result = remote
323 .fetch(&refspec_strs, Some(&mut git_fetch_options()), None)
324 .map_err(|e| ModuleError::GitFetchFailed {
325 module: module_name.to_string(),
326 url: git_src.repo_url.clone(),
327 message: format!("fetch failed: {e}"),
328 });
329
330 match &fetch_result {
331 Ok(_) => {
332 let _ = spinner.finish_ok(format!("Fetched module '{}' (libgit2)", module_name));
333 }
334 Err(e) => {
335 let _ = spinner
336 .finish_fail(format!(
337 "Failed to fetch module '{}' (libgit2)",
338 module_name
339 ))
340 .detail(crate::output::collapse_to_subject_line(e));
341 }
342 }
343 fetch_result?;
344
345 Ok(())
346}
347
348fn checkout_ref(repo_path: &Path, git_src: &GitSource, module_name: &str) -> Result<()> {
349 let repo = open_repo(repo_path, module_name, &git_src.repo_url)?;
350
351 let target_ref = git_src.tag.as_deref().or(git_src.git_ref.as_deref());
352
353 let Some(ref_name) = target_ref else {
354 return Ok(());
356 };
357
358 let obj = repo
360 .revparse_single(&format!("refs/tags/{ref_name}"))
361 .or_else(|_| repo.revparse_single(&format!("refs/remotes/origin/{ref_name}")))
362 .or_else(|_| repo.revparse_single(ref_name))
363 .map_err(|e| ModuleError::GitFetchFailed {
364 module: module_name.to_string(),
365 url: git_src.repo_url.clone(),
366 message: format!("cannot find ref '{ref_name}': {e}"),
367 })?;
368
369 let commit = obj
371 .peel_to_commit()
372 .map_err(|e| ModuleError::GitFetchFailed {
373 module: module_name.to_string(),
374 url: git_src.repo_url.clone(),
375 message: format!("ref '{ref_name}' does not point to a commit: {e}"),
376 })?;
377
378 repo.set_head_detached(commit.id())
379 .map_err(|e| ModuleError::GitFetchFailed {
380 module: module_name.to_string(),
381 url: git_src.repo_url.clone(),
382 message: format!("cannot detach HEAD to '{ref_name}': {e}"),
383 })?;
384
385 repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
386 .map_err(|e| ModuleError::GitFetchFailed {
387 module: module_name.to_string(),
388 url: git_src.repo_url.clone(),
389 message: format!("checkout failed for '{ref_name}': {e}"),
390 })?;
391
392 Ok(())
393}
394
395pub fn get_head_commit_sha(repo_path: &Path) -> Result<String> {
397 let path_str = repo_path.display_posix();
398 let repo = open_repo(repo_path, &path_str, &path_str)?;
399 let head = repo.head().map_err(|e| ModuleError::GitFetchFailed {
400 module: path_str.clone(),
401 url: path_str.clone(),
402 message: format!("cannot read HEAD: {e}"),
403 })?;
404 let commit = head
405 .peel_to_commit()
406 .map_err(|e| ModuleError::GitFetchFailed {
407 module: path_str.clone(),
408 url: path_str,
409 message: format!("HEAD is not a commit: {e}"),
410 })?;
411 Ok(commit.id().to_string())
412}
413
414#[derive(Debug, Clone, PartialEq, Eq)]
416pub enum TagSignatureStatus {
417 LightweightTag,
419 Unsigned,
421 SignaturePresent,
423 TagNotFound,
425}
426
427pub fn check_tag_signature(
433 repo_path: &Path,
434 tag_name: &str,
435 module_name: &str,
436) -> Result<TagSignatureStatus> {
437 let repo = open_repo(repo_path, module_name, "")?;
438
439 let tag_ref = match repo.revparse_single(&format!("refs/tags/{tag_name}")) {
440 Ok(obj) => obj,
441 Err(_) => return Ok(TagSignatureStatus::TagNotFound),
442 };
443
444 let tag = match tag_ref.as_tag() {
445 Some(t) => t,
446 None => return Ok(TagSignatureStatus::LightweightTag),
447 };
448
449 let message = match tag.message() {
450 Some(m) => m,
451 None => return Ok(TagSignatureStatus::Unsigned),
452 };
453
454 if message.contains("-----BEGIN PGP SIGNATURE-----")
455 || message.contains("-----BEGIN SSH SIGNATURE-----")
456 {
457 Ok(TagSignatureStatus::SignaturePresent)
458 } else {
459 Ok(TagSignatureStatus::Unsigned)
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
470 fn is_git_source_accepts_https() {
471 assert!(is_git_source("https://github.com/user/repo.git"));
472 }
473
474 #[test]
475 fn is_git_source_accepts_http() {
476 assert!(is_git_source("http://example.com/repo"));
477 }
478
479 #[test]
480 fn is_git_source_accepts_ssh() {
481 assert!(is_git_source("ssh://git@github.com/user/repo.git"));
482 }
483
484 #[test]
485 fn is_git_source_accepts_git_at() {
486 assert!(is_git_source("git@github.com:user/repo.git"));
487 }
488
489 #[test]
490 fn is_git_source_rejects_local_path() {
491 assert!(!is_git_source("/home/user/dotfiles"));
492 assert!(!is_git_source("./local/path"));
493 assert!(!is_git_source("relative/path"));
494 }
495
496 #[test]
497 #[serial_test::serial]
498 fn is_git_source_rejects_file_url_by_default() {
499 let _guard = crate::test_helpers::EnvVarGuard::unset("CFGD_ALLOW_LOCAL_SOURCES");
500 assert!(!is_git_source("file:///tmp/repo"));
501 }
502
503 #[test]
504 #[serial_test::serial]
505 fn is_git_source_accepts_file_url_when_env_set() {
506 let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
507 assert!(is_git_source("file:///tmp/repo"));
508 }
509
510 #[test]
513 fn parse_plain_https_url() {
514 let gs = parse_git_source("https://github.com/user/repo.git").unwrap();
515 assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
516 assert_eq!(gs.tag, None);
517 assert_eq!(gs.git_ref, None);
518 assert_eq!(gs.subdir, None);
519 }
520
521 #[test]
522 fn parse_https_with_tag() {
523 let gs = parse_git_source("https://github.com/user/repo.git@v2.1.0").unwrap();
524 assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
525 assert_eq!(gs.tag.as_deref(), Some("v2.1.0"));
526 }
527
528 #[test]
529 fn parse_https_with_ref() {
530 let gs = parse_git_source("https://github.com/user/repo.git?ref=dev").unwrap();
531 assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
532 assert_eq!(gs.git_ref.as_deref(), Some("dev"));
533 assert_eq!(gs.tag, None);
534 }
535
536 #[test]
537 fn parse_https_with_subdir() {
538 let gs = parse_git_source("https://github.com/user/repo.git//configs/base").unwrap();
539 assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
540 assert_eq!(gs.subdir.as_deref(), Some("configs/base"));
541 assert_eq!(gs.tag, None);
542 }
543
544 #[test]
545 fn parse_https_with_subdir_and_tag() {
546 let gs = parse_git_source("https://github.com/user/repo.git//configs/base@v2.1.0").unwrap();
547 assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
548 assert_eq!(gs.subdir.as_deref(), Some("configs/base"));
549 assert_eq!(gs.tag.as_deref(), Some("v2.1.0"));
550 }
551
552 #[test]
553 fn parse_ssh_with_tag() {
554 let gs = parse_git_source("git@github.com:user/repo.git@v1.0.0").unwrap();
555 assert_eq!(gs.repo_url, "git@github.com:user/repo.git");
556 assert_eq!(gs.tag.as_deref(), Some("v1.0.0"));
557 }
558
559 #[test]
560 fn parse_ssh_plain() {
561 let gs = parse_git_source("git@github.com:user/repo.git").unwrap();
562 assert_eq!(gs.repo_url, "git@github.com:user/repo.git");
563 assert_eq!(gs.tag, None);
564 assert_eq!(gs.git_ref, None);
565 }
566
567 #[test]
568 fn parse_ref_with_subdir() {
569 let gs = parse_git_source("https://github.com/user/repo.git?ref=dev//subdir").unwrap();
570 assert_eq!(gs.repo_url, "https://github.com/user/repo.git");
571 assert_eq!(gs.git_ref.as_deref(), Some("dev"));
572 assert_eq!(gs.subdir.as_deref(), Some("subdir"));
573 }
574
575 #[test]
576 fn parse_no_dot_git_with_tag() {
577 let gs = parse_git_source("https://github.com/user/repo@v3.0").unwrap();
578 assert_eq!(gs.repo_url, "https://github.com/user/repo");
579 assert_eq!(gs.tag.as_deref(), Some("v3.0"));
580 }
581
582 #[test]
583 fn parse_rejects_non_git_url() {
584 let err = parse_git_source("/local/path").expect_err("local path rejected");
585 let msg = err.to_string();
586 assert!(msg.contains("not a git URL"), "got: {msg}");
587 }
588
589 #[test]
592 fn git_cache_dir_is_deterministic() {
593 let base = Path::new("/tmp/cache");
594 let d1 = git_cache_dir(base, "https://github.com/user/repo.git");
595 let d2 = git_cache_dir(base, "https://github.com/user/repo.git");
596 assert_eq!(d1, d2);
597 }
598
599 #[test]
600 fn git_cache_dir_differs_for_different_urls() {
601 let base = Path::new("/tmp/cache");
602 let d1 = git_cache_dir(base, "https://github.com/user/repo-a.git");
603 let d2 = git_cache_dir(base, "https://github.com/user/repo-b.git");
604 assert_ne!(d1, d2);
605 }
606
607 #[test]
608 fn git_cache_dir_uses_first_32_hex_chars() {
609 let base = Path::new("/cache");
610 let d = git_cache_dir(base, "https://example.com/repo");
611 let dir_name = d.file_name().unwrap().to_str().unwrap();
612 assert_eq!(dir_name.len(), 32);
613 assert!(dir_name.chars().all(|c| c.is_ascii_hexdigit()));
614 }
615
616 #[test]
619 fn resolve_subdir_none_returns_base() {
620 let base = PathBuf::from("/cache/abc123");
621 let result = resolve_subdir(base.clone(), &None, "mod", "url").unwrap();
622 assert_eq!(result, base);
623 }
624
625 #[test]
626 fn resolve_subdir_appends_path() {
627 let base = PathBuf::from("/cache/abc123");
628 let result =
629 resolve_subdir(base.clone(), &Some("configs/base".into()), "mod", "url").unwrap();
630 assert_eq!(result, base.join("configs/base"));
631 }
632
633 #[test]
634 fn resolve_subdir_rejects_traversal() {
635 let base = PathBuf::from("/cache/abc123");
636 let err = resolve_subdir(base, &Some("../escape".into()), "mod", "url")
637 .expect_err("traversal rejected");
638 let msg = err.to_string();
639 assert!(
640 msg.contains("traversal"),
641 "error must mention traversal, got: {msg}"
642 );
643 }
644
645 #[test]
648 fn check_tag_signature_returns_tag_not_found() {
649 let dir = tempfile::tempdir().unwrap();
650 git2::Repository::init(dir.path()).unwrap();
651 let result = check_tag_signature(dir.path(), "nonexistent", "test-mod").unwrap();
652 assert_eq!(result, TagSignatureStatus::TagNotFound);
653 }
654
655 #[test]
656 fn check_tag_signature_lightweight_tag() {
657 let dir = tempfile::tempdir().unwrap();
658 let repo = git2::Repository::init(dir.path()).unwrap();
659
660 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
661 let tree_id = repo.index().unwrap().write_tree().unwrap();
662 let tree = repo.find_tree(tree_id).unwrap();
663 let commit_oid = repo
664 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
665 .unwrap();
666
667 let obj = repo.find_object(commit_oid, None).unwrap();
668 repo.tag_lightweight("v1.0.0", &obj, false).unwrap();
669
670 let result = check_tag_signature(dir.path(), "v1.0.0", "test-mod").unwrap();
671 assert_eq!(result, TagSignatureStatus::LightweightTag);
672 }
673
674 #[test]
675 fn check_tag_signature_annotated_unsigned() {
676 let dir = tempfile::tempdir().unwrap();
677 let repo = git2::Repository::init(dir.path()).unwrap();
678
679 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
680 let tree_id = repo.index().unwrap().write_tree().unwrap();
681 let tree = repo.find_tree(tree_id).unwrap();
682 let commit_oid = repo
683 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
684 .unwrap();
685
686 let obj = repo.find_object(commit_oid, None).unwrap();
687 repo.tag("v2.0.0", &obj, &sig, "release v2.0.0", false)
688 .unwrap();
689
690 let result = check_tag_signature(dir.path(), "v2.0.0", "test-mod").unwrap();
691 assert_eq!(result, TagSignatureStatus::Unsigned);
692 }
693
694 #[test]
697 fn get_head_commit_sha_returns_hex_hash() {
698 let dir = tempfile::tempdir().unwrap();
699 let repo = git2::Repository::init(dir.path()).unwrap();
700
701 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
702 let tree_id = repo.index().unwrap().write_tree().unwrap();
703 let tree = repo.find_tree(tree_id).unwrap();
704 let commit_oid = repo
705 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
706 .unwrap();
707
708 let sha = get_head_commit_sha(dir.path()).unwrap();
709 assert_eq!(sha, commit_oid.to_string());
710 assert_eq!(sha.len(), 40);
711 assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
712 }
713
714 #[test]
715 fn get_head_commit_sha_errors_on_non_repo() {
716 let dir = tempfile::tempdir().unwrap();
717 let err = get_head_commit_sha(dir.path()).expect_err("non-repo must error");
718 let msg = err.to_string();
719 assert!(
720 msg.contains("cannot open repo"),
721 "error must mention repo open failure, got: {msg}"
722 );
723 }
724
725 #[test]
728 fn default_module_cache_dir_with_test_home() {
729 let dir = tempfile::tempdir().unwrap();
730 let _guard = crate::with_test_home_guard(dir.path());
731 let cache = default_module_cache_dir().unwrap();
732 assert!(
733 cache.starts_with(dir.path()),
734 "cache dir must be under test home, got: {}",
735 cache.display()
736 );
737 assert!(
738 cache.ends_with("cfgd/modules"),
739 "must end with cfgd/modules, got: {}",
740 cache.display()
741 );
742 }
743
744 #[test]
747 fn parse_ssh_without_dot_git_with_tag() {
748 let gs = parse_git_source("git@gitlab.example.com:user/repo@v9.9.9").unwrap();
752 assert_eq!(gs.repo_url, "git@gitlab.example.com:user/repo");
753 assert_eq!(gs.tag.as_deref(), Some("v9.9.9"));
754 }
755
756 #[test]
757 fn parse_https_no_dot_git_skips_to_scheme_for_at_lookup() {
758 let gs = parse_git_source("https://internal.host/proj@v3.0").unwrap();
761 assert_eq!(gs.repo_url, "https://internal.host/proj");
762 assert_eq!(gs.tag.as_deref(), Some("v3.0"));
763 }
764
765 #[test]
766 fn parse_url_with_no_at_in_path_returns_no_tag() {
767 let gs = parse_git_source("https://example.com/path/to/repo").unwrap();
770 assert_eq!(gs.repo_url, "https://example.com/path/to/repo");
771 assert_eq!(gs.tag, None);
772 }
773
774 fn build_local_fixture_repo() -> (tempfile::TempDir, String) {
777 let src = tempfile::tempdir().unwrap();
778 let repo = git2::Repository::init(src.path()).unwrap();
779 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
780 let tree_id = repo.index().unwrap().write_tree().unwrap();
781 let tree = repo.find_tree(tree_id).unwrap();
782 let _commit_oid = repo
783 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
784 .unwrap();
785 let head = repo.head().unwrap().target().unwrap();
787 let obj = repo.find_object(head, None).unwrap();
788 repo.tag_lightweight("v0.1.0", &obj, false).unwrap();
789 let url = crate::test_helpers::file_url(src.path());
790 (src, url)
791 }
792
793 #[test]
794 #[serial_test::serial]
795 fn fetch_git_source_clones_then_reuses_existing_cache_on_second_call() {
796 let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
797 let (_src, url) = build_local_fixture_repo();
798
799 let cache_base = tempfile::tempdir().unwrap();
800 let printer = crate::test_helpers::test_printer();
801
802 let git_src = parse_git_source(&url).unwrap();
803
804 let path1 = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer)
806 .expect("first fetch must clone successfully");
807 assert!(path1.join("HEAD").exists() || path1.join(".git").exists());
808
809 let path2 = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer)
811 .expect("second fetch must reuse cache and succeed");
812 assert_eq!(path1, path2, "cached path must be stable across calls");
813 }
814
815 #[test]
816 #[serial_test::serial]
817 fn fetch_git_source_with_tag_checks_out_tag() {
818 let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
819 let (_src, url) = build_local_fixture_repo();
820
821 let cache_base = tempfile::tempdir().unwrap();
822 let printer = crate::test_helpers::test_printer();
823
824 let url_with_tag = format!("{}@v0.1.0", url);
825 let git_src = parse_git_source(&url_with_tag).unwrap();
826 assert_eq!(git_src.tag.as_deref(), Some("v0.1.0"));
827
828 let result = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer);
829 assert!(
830 result.is_ok(),
831 "checkout-by-tag against local fixture must succeed: {:?}",
832 result.err()
833 );
834 }
835
836 #[test]
837 #[serial_test::serial]
838 fn fetch_git_source_with_missing_tag_returns_err() {
839 let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
840 let (_src, url) = build_local_fixture_repo();
841
842 let cache_base = tempfile::tempdir().unwrap();
843 let printer = crate::test_helpers::test_printer();
844
845 let url_with_tag = format!("{}@no-such-tag", url);
846 let git_src = parse_git_source(&url_with_tag).unwrap();
847
848 let err = fetch_git_source(&git_src, cache_base.path(), "fixture", &printer)
849 .expect_err("missing tag must error");
850 let msg = err.to_string();
851 assert!(
852 msg.contains("cannot find ref") || msg.contains("no-such-tag"),
853 "error must mention missing ref, got: {msg}"
854 );
855 }
856
857 #[test]
860 fn open_repo_errors_on_non_repo() {
861 let dir = tempfile::tempdir().unwrap();
862 let result = open_repo(dir.path(), "mod", "url");
863 let err = match result {
864 Ok(_) => panic!("non-repo must error"),
865 Err(e) => e,
866 };
867 assert!(
868 err.to_string().contains("cannot open repo"),
869 "error must mention cannot open repo: {err}"
870 );
871 }
872
873 #[test]
876 fn check_tag_signature_signature_present_pgp() {
877 let dir = tempfile::tempdir().unwrap();
878 let repo = git2::Repository::init(dir.path()).unwrap();
879 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
880 let tree_id = repo.index().unwrap().write_tree().unwrap();
881 let tree = repo.find_tree(tree_id).unwrap();
882 let commit_oid = repo
883 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
884 .unwrap();
885 let obj = repo.find_object(commit_oid, None).unwrap();
886 let msg =
889 "release v3.0.0\n-----BEGIN PGP SIGNATURE-----\nfake\n-----END PGP SIGNATURE-----\n";
890 repo.tag("v3.0.0", &obj, &sig, msg, false).unwrap();
891 let result = check_tag_signature(dir.path(), "v3.0.0", "mod").unwrap();
892 assert_eq!(result, TagSignatureStatus::SignaturePresent);
893 }
894
895 #[test]
896 fn check_tag_signature_signature_present_ssh() {
897 let dir = tempfile::tempdir().unwrap();
898 let repo = git2::Repository::init(dir.path()).unwrap();
899 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
900 let tree_id = repo.index().unwrap().write_tree().unwrap();
901 let tree = repo.find_tree(tree_id).unwrap();
902 let commit_oid = repo
903 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
904 .unwrap();
905 let obj = repo.find_object(commit_oid, None).unwrap();
906 let msg = "release v4\n-----BEGIN SSH SIGNATURE-----\nfake\n-----END SSH SIGNATURE-----\n";
907 repo.tag("v4.0.0", &obj, &sig, msg, false).unwrap();
908 let result = check_tag_signature(dir.path(), "v4.0.0", "mod").unwrap();
909 assert_eq!(result, TagSignatureStatus::SignaturePresent);
910 }
911
912 #[test]
915 fn get_head_commit_sha_returns_err_when_repo_has_no_head() {
916 let dir = tempfile::tempdir().unwrap();
917 git2::Repository::init(dir.path()).unwrap();
919 let err = get_head_commit_sha(dir.path()).expect_err("no HEAD must error");
920 let msg = err.to_string();
921 assert!(
922 msg.contains("cannot read HEAD") || msg.contains("cannot open repo"),
923 "error must mention HEAD or repo: {msg}"
924 );
925 }
926
927 #[test]
935 #[serial_test::serial]
936 fn fetch_git_source_with_bare_repo_branch_checks_out_branch() {
937 let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
938 let bare = crate::test_helpers::BareGitRepo::builder()
939 .commit("init", &[("README.md", "hello")])
940 .branch("feature", &[("feature.txt", "feature-data")])
941 .build();
942
943 let cache_base = tempfile::tempdir().expect("cache tempdir");
944 let printer = crate::test_helpers::test_printer();
945
946 let url_with_ref = format!("{}?ref=feature", bare.url());
949 let git_src = parse_git_source(&url_with_ref).expect("parse ref url");
950 assert_eq!(git_src.git_ref.as_deref(), Some("feature"));
951
952 let path = fetch_git_source(&git_src, cache_base.path(), "branchy", &printer)
953 .expect("fetch with branch checkout must succeed");
954
955 assert!(path.join("feature.txt").exists(), "branch file must exist");
956 assert_eq!(
957 std::fs::read_to_string(path.join("feature.txt")).unwrap(),
958 "feature-data"
959 );
960 }
961
962 #[test]
963 #[serial_test::serial]
964 fn fetch_git_source_with_bare_repo_tag_checks_out_tag() {
965 let _guard = crate::test_helpers::EnvVarGuard::set("CFGD_ALLOW_LOCAL_SOURCES", "1");
966 let bare = crate::test_helpers::BareGitRepo::builder()
967 .commit("first", &[("a.txt", "first content")])
968 .tag("v1.0.0")
969 .build();
970
971 let cache_base = tempfile::tempdir().expect("cache tempdir");
972 let printer = crate::test_helpers::test_printer();
973
974 let url_with_tag = format!("{}@v1.0.0", bare.url());
975 let git_src = parse_git_source(&url_with_tag).expect("parse tag url");
976 assert_eq!(git_src.tag.as_deref(), Some("v1.0.0"));
977
978 let path = fetch_git_source(&git_src, cache_base.path(), "tagged", &printer)
979 .expect("fetch with tag checkout must succeed");
980 assert!(path.join("a.txt").exists());
981
982 let path2 = fetch_git_source(&git_src, cache_base.path(), "tagged", &printer)
984 .expect("second fetch (fetch_existing_repo path) must succeed");
985 assert_eq!(path, path2);
986 }
987
988 #[test]
989 fn check_tag_signature_returns_unsigned_when_tag_has_no_message() {
990 let dir = tempfile::tempdir().unwrap();
994 let repo = git2::Repository::init(dir.path()).unwrap();
995 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
996 let tree_id = repo.index().unwrap().write_tree().unwrap();
997 let tree = repo.find_tree(tree_id).unwrap();
998 let commit_oid = repo
999 .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
1000 .unwrap();
1001 let obj = repo.find_object(commit_oid, None).unwrap();
1002 repo.tag("vNoSig", &obj, &sig, " ", false).unwrap();
1005
1006 let result = check_tag_signature(dir.path(), "vNoSig", "mod").unwrap();
1007 assert_eq!(result, TagSignatureStatus::Unsigned);
1008 }
1009
1010 #[test]
1011 #[serial_test::serial]
1012 fn default_module_cache_dir_test_home_uses_home_join() {
1013 let dir = tempfile::tempdir().unwrap();
1015 let _guard = crate::with_test_home_guard(dir.path());
1016 let cache = default_module_cache_dir().expect("default_module_cache_dir under test-home");
1017 assert_eq!(
1018 cache,
1019 dir.path().join(".cache").join("cfgd").join("modules")
1020 );
1021 }
1022}