1use std::{
47 path::{Component, PathBuf},
48 sync::atomic::{AtomicU64, AtomicU8, Ordering},
49};
50
51use git2::{build::RepoBuilder, CredentialType, FetchOptions, RemoteCallbacks, Repository};
52
53use crate::{
54 manifest::{Dep, Manifest},
55 PkgError,
56};
57
58static TMP_CTR: AtomicU64 = AtomicU64::new(0);
60
61#[cfg(test)]
65thread_local! {
66 static CLONE_CTR: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
67}
68
69#[cfg(test)]
70fn clone_count() -> u64 {
71 CLONE_CTR.with(|c| c.get())
72}
73
74pub trait Fetcher {
81 fn fetch(&self, dep: &Dep) -> Result<FetchedPkg, PkgError>;
87}
88
89#[derive(Debug, Clone)]
93pub struct FetchedPkg {
94 pub cache_path: PathBuf,
96
97 pub sha: String,
99
100 pub manifest: Option<Manifest>,
102
103 pub resolved_tag: Option<String>,
108}
109
110pub struct GitFetcher {
114 cache_root: PathBuf,
116}
117
118impl GitFetcher {
119 pub fn new(cache_root: PathBuf) -> Self {
121 Self { cache_root }
122 }
123
124 fn cache_dir(&self, url: &str, sha: &str) -> Result<PathBuf, PkgError> {
133 let stripped = url
135 .trim_start_matches("https://")
136 .trim_start_matches("http://")
137 .trim_start_matches("ssh://")
138 .trim_start_matches("git@")
139 .replace(':', "/") .trim_end_matches(".git")
141 .to_owned();
142
143 if stripped.is_empty() {
144 return Err(PkgError::Validation {
145 message: format!("cannot derive cache path from URL: {url:?}"),
146 });
147 }
148
149 for component in stripped.split('/') {
151 if component == ".." || component == "." {
152 return Err(PkgError::Validation {
153 message: format!(
154 "URL {url:?} contains a path traversal component: {component:?}"
155 ),
156 });
157 }
158 }
159
160 if sha.is_empty() || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
162 return Err(PkgError::Validation {
163 message: format!("invalid SHA: {sha:?}"),
164 });
165 }
166
167 let mut path = self.cache_root.join("git");
168 for segment in stripped.split('/') {
169 if segment.is_empty() {
170 continue;
171 }
172 let p = path.join(segment);
174 for c in p.components() {
175 if c == Component::ParentDir {
176 return Err(PkgError::Validation {
177 message: format!(
178 "URL {url:?} resolves to a path with parent-dir traversal"
179 ),
180 });
181 }
182 }
183 path = p;
184 }
185 path = path.join(sha);
186 Ok(path)
187 }
188
189 fn validate_url(url: &str) -> Result<(), PkgError> {
195 let stripped = url
196 .trim_start_matches("https://")
197 .trim_start_matches("http://")
198 .trim_start_matches("ssh://")
199 .trim_start_matches("git@")
200 .replace(':', "/")
201 .trim_end_matches(".git")
202 .to_owned();
203
204 if stripped.is_empty() {
205 return Err(PkgError::Validation {
206 message: format!("cannot derive cache path from URL: {url:?}"),
207 });
208 }
209
210 for component in stripped.split('/') {
211 if component == ".." || component == "." {
212 return Err(PkgError::Validation {
213 message: format!(
214 "URL {url:?} contains a path traversal component: {component:?}"
215 ),
216 });
217 }
218 }
219 Ok(())
220 }
221
222 fn temp_clone_path(git_base: &std::path::Path) -> PathBuf {
225 let n = TMP_CTR.fetch_add(1, Ordering::Relaxed);
226 let pid = std::process::id();
227 git_base.join(format!(".fetch-{pid}-{n}"))
228 }
229
230 fn resolve_ref(repo: &Repository, dep: &Dep) -> Result<(String, Option<String>), PkgError> {
243 if let Some(rev) = &dep.rev {
244 let oid = repo.revparse_single(rev)?.peel_to_commit()?.id();
245 return Ok((oid.to_string(), None));
246 }
247 if let Some(tag) = &dep.tag {
248 let resolved = Self::resolve_tag_pin(repo, tag)?;
249 let refname = format!("refs/tags/{resolved}");
250 let oid = repo.find_reference(&refname)?.peel_to_commit()?.id();
251 return Ok((oid.to_string(), Some(resolved)));
252 }
253 if let Some(branch) = &dep.branch {
254 let refname = format!("refs/remotes/origin/{branch}");
255 let oid = repo.find_reference(&refname)?.peel_to_commit()?.id();
256 return Ok((oid.to_string(), None));
257 }
258 let oid = repo.head()?.peel_to_commit()?.id();
260 Ok((oid.to_string(), None))
261 }
262
263 fn resolve_tag_pin(repo: &Repository, tag: &str) -> Result<String, PkgError> {
273 let tag_names = repo.tag_names(None)?;
274 let local_tags: Vec<String> = tag_names
275 .iter()
276 .filter_map(|t| t.map(|s| s.to_string()))
277 .collect();
278 Self::pick_tag_pin(&local_tags, tag)
279 }
280
281 fn pick_tag_pin(tags: &[String], tag: &str) -> Result<String, PkgError> {
284 use crate::version::{classify_tag_pin, pick_latest_for_pin, TagPin};
285 let prefix = match classify_tag_pin(tag) {
286 Some(TagPin::Prefix(p)) => p,
287 _ => return Ok(tag.to_string()),
289 };
290 pick_latest_for_pin(tags, &prefix).ok_or_else(|| PkgError::Validation {
291 message: format!("tag prefix '{tag}' has no matching SemVer release on remote"),
292 })
293 }
294
295 fn preresolve(
303 &self,
304 url: &str,
305 dep: &Dep,
306 ) -> Result<Option<(String, Option<String>)>, PkgError> {
307 if let Some(rev) = &dep.rev {
308 let is_full_sha = rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit());
309 return Ok(is_full_sha.then(|| (rev.to_ascii_lowercase(), None)));
310 }
311
312 let refs = self.ls_remote_refs(url)?;
313 let lookup = |name: &str| {
314 refs.iter()
315 .find(|(n, _)| n == name)
316 .map(|(_, oid)| oid.clone())
317 };
318
319 if let Some(tag) = &dep.tag {
320 let tag_names = Self::tag_names_from_refs(&refs);
321 let resolved = Self::pick_tag_pin(&tag_names, tag)?;
322 let oid = lookup(&format!("refs/tags/{resolved}^{{}}"))
325 .or_else(|| lookup(&format!("refs/tags/{resolved}")));
326 return Ok(oid.map(|sha| (sha, Some(resolved))));
327 }
328 if let Some(branch) = &dep.branch {
329 return Ok(lookup(&format!("refs/heads/{branch}")).map(|sha| (sha, None)));
330 }
331 Ok(lookup("HEAD").map(|sha| (sha, None)))
332 }
333
334 fn tag_names_from_refs(refs: &[(String, String)]) -> Vec<String> {
336 let mut tags: Vec<String> = Vec::new();
337 for (name, _) in refs {
338 if let Some(t) = name.strip_prefix("refs/tags/") {
339 let s = t.trim_end_matches("^{}").to_string();
340 if !tags.contains(&s) {
341 tags.push(s);
342 }
343 }
344 }
345 tags
346 }
347
348 fn checkout_sha(repo: &Repository, sha: &str) -> Result<(), PkgError> {
350 let oid = git2::Oid::from_str(sha).map_err(|e| PkgError::Validation {
351 message: format!("invalid SHA {sha}: {e}"),
352 })?;
353 let obj = repo.find_object(oid, None)?;
354 repo.reset(&obj, git2::ResetType::Hard, None)?;
355 Ok(())
356 }
357
358 pub fn list_tags(&self, url: &str) -> Result<Vec<String>, PkgError> {
364 Ok(Self::tag_names_from_refs(&self.ls_remote_refs(url)?))
365 }
366
367 pub fn ls_remote_refs(&self, url: &str) -> Result<Vec<(String, String)>, PkgError> {
371 Self::validate_url(url)?;
372
373 let scratch = self.cache_root.join("git").join(".ls-remote");
375 std::fs::create_dir_all(&scratch)?;
376 let tmp = Self::temp_clone_path(&scratch);
377 std::fs::create_dir_all(&tmp)?;
378
379 let result = Self::ls_remote_inner(&tmp, url);
380 let _ = std::fs::remove_dir_all(&tmp);
381 result
382 }
383
384 pub fn resolve_sha(&self, dep: &Dep) -> Result<Option<(String, Option<String>)>, PkgError> {
396 Self::validate_url(&dep.git)?;
397 self.preresolve(&dep.git, dep)
398 }
399}
400
401impl FetchedPkg {
402 pub fn at_root(
418 cache_path: PathBuf,
419 sha: String,
420 resolved_tag: Option<String>,
421 ) -> Result<FetchedPkg, PkgError> {
422 let manifest_path = cache_path.join("mlua-pkg.toml");
423 let manifest = if manifest_path.exists() {
424 Some(Manifest::from_path(&manifest_path)?)
425 } else {
426 match crate::rockspec::find_in_tree(&cache_path, resolved_tag.as_deref())? {
427 Some(p) => Some(crate::rockspec::Rockspec::from_path(&p)?.to_manifest()),
428 None => None,
429 }
430 };
431 Ok(FetchedPkg {
432 cache_path,
433 sha,
434 manifest,
435 resolved_tag,
436 })
437 }
438}
439
440impl GitFetcher {
441 fn ls_remote_inner(
442 tmp: &std::path::Path,
443 url: &str,
444 ) -> Result<Vec<(String, String)>, PkgError> {
445 let repo = Repository::init(tmp)?;
446 let mut remote = repo.remote_anonymous(url)?;
447 remote.connect_auth(
448 git2::Direction::Fetch,
449 Some(Self::make_credentials_callbacks()),
450 None,
451 )?;
452 let refs: Vec<(String, String)> = remote
453 .list()?
454 .iter()
455 .map(|head| (head.name().to_string(), head.oid().to_string()))
456 .collect();
457 let _ = remote.disconnect();
458 Ok(refs)
459 }
460
461 fn make_credentials_callbacks() -> RemoteCallbacks<'static> {
464 let mut callbacks = RemoteCallbacks::new();
465
466 let tried = AtomicU8::new(0);
467 callbacks.credentials(move |_url, username, allowed| {
468 let tried_bits = tried.load(Ordering::Relaxed);
469
470 if allowed.contains(CredentialType::SSH_KEY) && (tried_bits & 0b001 == 0) {
471 tried.fetch_or(0b001, Ordering::Relaxed);
472 let user = username.unwrap_or("git");
473 return git2::Cred::ssh_key_from_agent(user);
474 }
475
476 if allowed.contains(CredentialType::USER_PASS_PLAINTEXT) && (tried_bits & 0b010 == 0) {
477 tried.fetch_or(0b010, Ordering::Relaxed);
478 if let Ok(cfg) = git2::Config::open_default() {
479 return git2::Cred::credential_helper(&cfg, _url, username);
480 }
481 }
482
483 if tried_bits & 0b100 == 0 {
484 tried.fetch_or(0b100, Ordering::Relaxed);
485 return git2::Cred::default();
486 }
487
488 Err(git2::Error::from_str("all credential types exhausted"))
489 });
490 callbacks
491 }
492
493 fn make_fetch_options() -> FetchOptions<'static> {
495 let mut fo = FetchOptions::new();
496 fo.remote_callbacks(Self::make_credentials_callbacks());
497 fo
498 }
499}
500
501impl Fetcher for GitFetcher {
502 fn fetch(&self, dep: &Dep) -> Result<FetchedPkg, PkgError> {
503 let url = &dep.git;
504
505 Self::validate_url(url)?;
507
508 let git_base = self.cache_root.join("git");
510 std::fs::create_dir_all(&git_base)?;
511
512 if let Some((sha, resolved_tag)) = self.preresolve(url, dep)? {
516 let cache_path = self.cache_dir(url, &sha)?;
517 if cache_path.exists() {
518 return FetchedPkg::at_root(cache_path, sha, resolved_tag);
519 }
520 }
521
522 #[cfg(test)]
523 CLONE_CTR.with(|c| c.set(c.get() + 1));
524
525 let tmp_path = Self::temp_clone_path(&git_base);
528
529 let fo = Self::make_fetch_options();
531 let repo = match RepoBuilder::new().fetch_options(fo).clone(url, &tmp_path) {
532 Ok(r) => r,
533 Err(e) => {
534 let _ = std::fs::remove_dir_all(&tmp_path);
536 return Err(e.into());
537 }
538 };
539
540 let (sha, resolved_tag) = match Self::resolve_ref(&repo, dep) {
542 Ok(s) => s,
543 Err(e) => {
544 let _ = std::fs::remove_dir_all(&tmp_path);
545 return Err(e);
546 }
547 };
548
549 if let Err(e) = Self::checkout_sha(&repo, &sha) {
555 let _ = std::fs::remove_dir_all(&tmp_path);
556 return Err(e);
557 }
558
559 let cache_path = match self.cache_dir(url, &sha) {
561 Ok(p) => p,
562 Err(e) => {
563 let _ = std::fs::remove_dir_all(&tmp_path);
564 return Err(e);
565 }
566 };
567
568 if cache_path.exists() {
569 drop(repo);
571 let _ = std::fs::remove_dir_all(&tmp_path);
572 } else {
573 if let Some(parent) = cache_path.parent() {
575 std::fs::create_dir_all(parent)?;
576 }
577 drop(repo); std::fs::rename(&tmp_path, &cache_path)?;
579 }
580
581 FetchedPkg::at_root(cache_path, sha, resolved_tag)
582 }
583}
584
585#[cfg(test)]
588mod tests {
589 use super::*;
590 use git2::{Repository, Signature};
591 use std::fs;
592 use tempfile::TempDir;
593
594 fn init_repo_with_commit(dir: &std::path::Path) -> String {
596 let repo = Repository::init(dir).unwrap();
597
598 let mut config = repo.config().unwrap();
600 config.set_str("user.name", "Test").unwrap();
601 config.set_str("user.email", "test@example.com").unwrap();
602 drop(config);
603
604 let file_path = dir.join("README.md");
606 fs::write(&file_path, "# test\n").unwrap();
607
608 let mut index = repo.index().unwrap();
609 index.add_path(std::path::Path::new("README.md")).unwrap();
610 index.write().unwrap();
611
612 let tree_id = index.write_tree().unwrap();
613 let tree = repo.find_tree(tree_id).unwrap();
614 let sig = Signature::now("Test", "test@example.com").unwrap();
615 let oid = repo
616 .commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
617 .unwrap();
618 oid.to_string()
619 }
620
621 fn add_tag(repo: &Repository, tag_name: &str) -> String {
623 let head = repo.head().unwrap().peel_to_commit().unwrap();
624 let sig = Signature::now("Test", "test@example.com").unwrap();
625 repo.tag(tag_name, head.as_object(), &sig, tag_name, false)
626 .unwrap();
627 head.id().to_string()
628 }
629
630 #[test]
633 fn clone_local_repo_happy_path() {
634 let src = TempDir::new().unwrap();
635 let sha = init_repo_with_commit(src.path());
636
637 let cache_root = TempDir::new().unwrap();
638 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
639
640 let url = format!("file://{}", src.path().display());
641 let dep = Dep {
642 git: url,
643 tag: None,
644 rev: None,
645 branch: None,
646 entry: None,
647 target_dir: None,
648 patch_dir: None,
649 patch_drift: None,
650 };
651
652 let result = fetcher.fetch(&dep).unwrap();
653 assert_eq!(result.sha, sha, "SHA should match the initial commit");
654 assert!(result.cache_path.exists(), "cache_path must exist on disk");
655 assert!(
656 result.manifest.is_none(),
657 "no mlua-pkg.toml in bare test repo"
658 );
659 }
660
661 #[test]
664 fn resolve_tag_sha() {
665 let src = TempDir::new().unwrap();
666 init_repo_with_commit(src.path());
667 let repo = Repository::open(src.path()).unwrap();
668 let expected_sha = add_tag(&repo, "v0.1.0");
669 drop(repo);
670
671 let cache_root = TempDir::new().unwrap();
672 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
673
674 let url = format!("file://{}", src.path().display());
675 let dep = Dep {
676 git: url,
677 tag: Some("v0.1.0".to_string()),
678 rev: None,
679 branch: None,
680 entry: None,
681 target_dir: None,
682 patch_dir: None,
683 patch_drift: None,
684 };
685
686 let result = fetcher.fetch(&dep).unwrap();
687 assert_eq!(result.sha, expected_sha, "tag must resolve to expected SHA");
688 assert!(result.cache_path.exists());
689 }
690
691 #[test]
694 fn resolve_rev_sha() {
695 let src = TempDir::new().unwrap();
696 let sha = init_repo_with_commit(src.path());
697
698 let cache_root = TempDir::new().unwrap();
699 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
700
701 let url = format!("file://{}", src.path().display());
702 let dep = Dep {
703 git: url,
704 rev: Some(sha.clone()),
705 tag: None,
706 branch: None,
707 entry: None,
708 target_dir: None,
709 patch_dir: None,
710 patch_drift: None,
711 };
712
713 let result = fetcher.fetch(&dep).unwrap();
714 assert_eq!(result.sha, sha, "rev should resolve to the given SHA");
715 }
716
717 #[test]
720 fn nonexistent_repo_returns_error() {
721 let cache_root = TempDir::new().unwrap();
722 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
723
724 let dep = Dep {
725 git: "file:///nonexistent/path/that/does/not/exist".to_string(),
726 tag: None,
727 rev: None,
728 branch: None,
729 entry: None,
730 target_dir: None,
731 patch_dir: None,
732 patch_drift: None,
733 };
734
735 let err = fetcher.fetch(&dep).unwrap_err();
736 assert!(
737 matches!(err, PkgError::GitFetch { .. }),
738 "expected GitFetch error, got: {err}"
739 );
740 }
741
742 #[test]
745 fn second_fetch_uses_cache() {
746 let src = TempDir::new().unwrap();
747 let sha = init_repo_with_commit(src.path());
748
749 let cache_root = TempDir::new().unwrap();
750 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
751
752 let url = format!("file://{}", src.path().display());
753 let dep = Dep {
754 git: url,
755 rev: Some(sha.clone()),
756 tag: None,
757 branch: None,
758 entry: None,
759 target_dir: None,
760 patch_dir: None,
761 patch_drift: None,
762 };
763
764 let first = fetcher.fetch(&dep).unwrap();
765 let second = fetcher.fetch(&dep).unwrap();
766
767 assert_eq!(
768 first.cache_path, second.cache_path,
769 "cache paths must be identical"
770 );
771 assert_eq!(first.sha, second.sha);
772 }
773
774 #[test]
777 fn rev_pin_cache_hit_needs_no_remote_and_no_clone() {
778 let src = TempDir::new().unwrap();
779 let sha = init_repo_with_commit(src.path());
780
781 let cache_root = TempDir::new().unwrap();
782 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
783 let url = format!("file://{}", src.path().display());
784 let dep = Dep {
785 git: url,
786 rev: Some(sha.clone()),
787 tag: None,
788 branch: None,
789 entry: None,
790 target_dir: None,
791 patch_dir: None,
792 patch_drift: None,
793 };
794
795 let first = fetcher.fetch(&dep).unwrap();
796 drop(src);
798 let clones_before = clone_count();
799 let second = fetcher.fetch(&dep).unwrap();
800
801 assert_eq!(first.cache_path, second.cache_path);
802 assert_eq!(second.sha, sha);
803 assert!(second.cache_path.join("README.md").exists());
804 assert_eq!(clone_count(), clones_before, "cache hit must not clone");
805 }
806
807 #[test]
808 fn tag_pin_cache_hit_resolves_by_ls_remote_without_clone() {
809 let src = TempDir::new().unwrap();
810 init_repo_with_commit(src.path());
811 let repo = Repository::open(src.path()).unwrap();
812 add_tag(&repo, "v1.0.0");
813 add_tag(&repo, "v1.0.4");
814
815 let cache_root = TempDir::new().unwrap();
816 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
817 let url = format!("file://{}", src.path().display());
818 let dep = Dep {
820 git: url,
821 rev: None,
822 tag: Some("v1.0".into()),
823 branch: None,
824 entry: None,
825 target_dir: None,
826 patch_dir: None,
827 patch_drift: None,
828 };
829
830 let first = fetcher.fetch(&dep).unwrap();
831 assert_eq!(first.resolved_tag.as_deref(), Some("v1.0.4"));
832
833 let clones_before = clone_count();
834 let second = fetcher.fetch(&dep).unwrap();
835
836 assert_eq!(first.cache_path, second.cache_path);
837 assert_eq!(second.resolved_tag.as_deref(), Some("v1.0.4"));
838 assert_eq!(
839 clone_count(),
840 clones_before,
841 "tag-pin cache hit must resolve via ls-remote and skip the clone"
842 );
843 }
844
845 #[test]
846 fn branch_pin_new_commit_is_a_cache_miss() {
847 let src = TempDir::new().unwrap();
848 let sha1 = init_repo_with_commit(src.path());
849
850 let cache_root = TempDir::new().unwrap();
851 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
852 let url = format!("file://{}", src.path().display());
853 let dep = Dep {
854 git: url,
855 rev: None,
856 tag: None,
857 branch: Some("master".into()),
858 entry: None,
859 target_dir: None,
860 patch_dir: None,
861 patch_drift: None,
862 };
863 let repo = Repository::open(src.path()).unwrap();
864 let branch = repo.head().unwrap().shorthand().unwrap().to_string();
865 let dep = Dep {
866 branch: Some(branch),
867 ..dep
868 };
869
870 let first = fetcher.fetch(&dep).unwrap();
871 assert_eq!(first.sha, sha1);
872
873 std::fs::write(src.path().join("main.lua"), "return { v = 2 }\n").unwrap();
875 let mut index = repo.index().unwrap();
876 index.add_path(std::path::Path::new("main.lua")).unwrap();
877 index.write().unwrap();
878 let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
879 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
880 let parent = repo.head().unwrap().peel_to_commit().unwrap();
881 let sha2 = repo
882 .commit(Some("HEAD"), &sig, &sig, "second", &tree, &[&parent])
883 .unwrap()
884 .to_string();
885
886 let second = fetcher.fetch(&dep).unwrap();
887 assert_eq!(second.sha, sha2, "branch pin must follow the new HEAD");
888 assert_ne!(first.cache_path, second.cache_path);
889 }
890
891 #[test]
894 fn in_tree_rockspec_yields_synthesized_manifest_with_entry() {
895 let src = TempDir::new().unwrap();
896 init_repo_with_commit(src.path());
897 fs::create_dir_all(src.path().join("src")).unwrap();
900 fs::write(src.path().join("src/notes.txt"), "not lua\n").unwrap();
901 fs::create_dir_all(src.path().join("lib")).unwrap();
902 fs::write(src.path().join("lib/foo.lua"), "return { v = 1 }\n").unwrap();
903 fs::write(
904 src.path().join("foo-1.0.0-1.rockspec"),
905 "package = \"foo\"\nversion = \"1.0.0-1\"\n\
906 source = { url = \"git+https://example.invalid/foo\", tag = \"v1.0.0\" }\n\
907 build = { type = \"builtin\", modules = { foo = \"lib/foo.lua\" } }\n",
908 )
909 .unwrap();
910 let repo = Repository::open(src.path()).unwrap();
911 let mut index = repo.index().unwrap();
912 index
913 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
914 .unwrap();
915 index.write().unwrap();
916 let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
917 let sig = Signature::now("Test", "test@example.com").unwrap();
918 let parent = repo.head().unwrap().peel_to_commit().unwrap();
919 repo.commit(Some("HEAD"), &sig, &sig, "rock", &tree, &[&parent])
920 .unwrap();
921 add_tag(&repo, "v1.0.0");
922
923 let cache_root = TempDir::new().unwrap();
924 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
925 let dep = Dep {
926 git: format!("file://{}", src.path().display()),
927 tag: Some("v1.0.0".into()),
928 rev: None,
929 branch: None,
930 entry: None,
931 target_dir: None,
932 patch_dir: None,
933 patch_drift: None,
934 };
935
936 let fetched = fetcher.fetch(&dep).unwrap();
937 let manifest = fetched
938 .manifest
939 .expect("rockspec should synthesize a manifest");
940 assert_eq!(manifest.package.name, "foo");
941 assert_eq!(manifest.package.version, "1.0.0");
942 assert_eq!(manifest.package.entry, Some(PathBuf::from("lib")));
943 assert!(manifest.deps.is_empty());
944 assert!(fetched.cache_path.join("lib/foo.lua").exists());
945 }
946
947 #[test]
950 fn path_traversal_in_url_is_rejected() {
951 let cache_root = TempDir::new().unwrap();
952 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
953
954 let dep = Dep {
955 git: "https://github.com/../../../etc/passwd".to_string(),
956 tag: None,
957 rev: None,
958 branch: None,
959 entry: None,
960 target_dir: None,
961 patch_dir: None,
962 patch_drift: None,
963 };
964
965 let err = fetcher.fetch(&dep).unwrap_err();
966 assert!(
967 matches!(err, PkgError::Validation { .. }),
968 "expected Validation error for path traversal, got: {err}"
969 );
970 }
971
972 #[test]
975 fn manifest_parsed_when_present() {
976 let src = TempDir::new().unwrap();
977
978 let toml_path = src.path().join("mlua-pkg.toml");
980 fs::write(
981 &toml_path,
982 r#"[package]
983name = "test-lib"
984version = "0.1.0"
985"#,
986 )
987 .unwrap();
988
989 let repo = Repository::init(src.path()).unwrap();
990 let mut config = repo.config().unwrap();
991 config.set_str("user.name", "Test").unwrap();
992 config.set_str("user.email", "test@example.com").unwrap();
993 drop(config);
994
995 let mut index = repo.index().unwrap();
996 index
997 .add_path(std::path::Path::new("mlua-pkg.toml"))
998 .unwrap();
999 index.write().unwrap();
1000 let tree_id = index.write_tree().unwrap();
1001 let tree = repo.find_tree(tree_id).unwrap();
1002 let sig = Signature::now("Test", "test@example.com").unwrap();
1003 repo.commit(Some("HEAD"), &sig, &sig, "add manifest", &tree, &[])
1004 .unwrap();
1005
1006 let cache_root = TempDir::new().unwrap();
1007 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
1008
1009 let url = format!("file://{}", src.path().display());
1010 let dep = Dep {
1011 git: url,
1012 tag: None,
1013 rev: None,
1014 branch: None,
1015 entry: None,
1016 target_dir: None,
1017 patch_dir: None,
1018 patch_drift: None,
1019 };
1020
1021 let result = fetcher.fetch(&dep).unwrap();
1022 let manifest = result.manifest.expect("manifest should be parsed");
1023 assert_eq!(manifest.package.name, "test-lib");
1024 assert_eq!(manifest.package.version, "0.1.0");
1025 }
1026
1027 #[test]
1035 fn fetched_worktree_matches_resolved_tag_not_head() {
1036 let src = TempDir::new().unwrap();
1037 let repo = Repository::init(src.path()).unwrap();
1038 let mut config = repo.config().unwrap();
1039 config.set_str("user.name", "Test").unwrap();
1040 config.set_str("user.email", "test@example.com").unwrap();
1041 drop(config);
1042 let sig = Signature::now("Test", "test@example.com").unwrap();
1043
1044 fs::write(src.path().join("VERSION"), "0.1.0").unwrap();
1046 let mut index = repo.index().unwrap();
1047 index.add_path(std::path::Path::new("VERSION")).unwrap();
1048 index.write().unwrap();
1049 let tree_id = index.write_tree().unwrap();
1050 let tree = repo.find_tree(tree_id).unwrap();
1051 let c1 = repo
1052 .commit(Some("HEAD"), &sig, &sig, "v0.1.0", &tree, &[])
1053 .unwrap();
1054 let c1_obj = repo.find_object(c1, None).unwrap();
1055 repo.tag("v0.1.0", &c1_obj, &sig, "v0.1.0", false).unwrap();
1056 let v010_sha = c1.to_string();
1057
1058 fs::write(src.path().join("VERSION"), "0.2.0").unwrap();
1060 let mut index = repo.index().unwrap();
1061 index.add_path(std::path::Path::new("VERSION")).unwrap();
1062 index.write().unwrap();
1063 let tree_id = index.write_tree().unwrap();
1064 let tree = repo.find_tree(tree_id).unwrap();
1065 let parent = repo.find_commit(c1).unwrap();
1066 let c2 = repo
1067 .commit(Some("HEAD"), &sig, &sig, "v0.2.0", &tree, &[&parent])
1068 .unwrap();
1069 assert_ne!(c1, c2, "HEAD must have advanced past the tag");
1070
1071 let cache_root = TempDir::new().unwrap();
1073 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
1074 let dep = Dep {
1075 git: format!("file://{}", src.path().display()),
1076 tag: Some("v0.1.0".to_string()),
1077 rev: None,
1078 branch: None,
1079 entry: None,
1080 target_dir: None,
1081 patch_dir: None,
1082 patch_drift: None,
1083 };
1084 let fetched = fetcher.fetch(&dep).unwrap();
1085
1086 assert_eq!(fetched.sha, v010_sha, "SHA must resolve to tag commit");
1088
1089 let version = fs::read_to_string(fetched.cache_path.join("VERSION")).unwrap();
1091 assert_eq!(
1092 version, "0.1.0",
1093 "fetched worktree must contain tag v0.1.0 content, got HEAD content instead"
1094 );
1095 }
1096
1097 #[test]
1100 fn cache_dir_rejects_invalid_sha() {
1101 let cache_root = TempDir::new().unwrap();
1102 let fetcher = GitFetcher::new(cache_root.path().to_path_buf());
1103
1104 let err = fetcher
1105 .cache_dir("https://github.com/x/y", "../evil")
1106 .unwrap_err();
1107 assert!(
1108 matches!(err, PkgError::Validation { .. }),
1109 "expected Validation error for invalid SHA, got: {err}"
1110 );
1111 }
1112}