Skip to main content

mlua_pkg/
fetcher.rs

1//! Git-based package fetcher.
2//!
3//! The [`Fetcher`] trait abstracts over different fetch backends (git, luarocks,
4//! http).  [`GitFetcher`] implements the git backend using libgit2 via the
5//! [`git2`] crate.  No subprocess `git` invocations are used.
6//!
7//! # Cache layout
8//!
9//! ```text
10//! <cache_root>/git/<host>/<path…>/<sha>/
11//! ```
12//!
13//! For example, `https://github.com/ynishi/lshape` at SHA `abc123` becomes:
14//!
15//! ```text
16//! <cache_root>/git/github.com/ynishi/lshape/abc123/
17//! ```
18//!
19//! # Cache hit without a clone
20//!
21//! Before cloning, the ref is resolved cheaply:
22//!
23//! | pin | network needed to resolve |
24//! |-----|---------------------------|
25//! | `rev` = full 40-hex SHA | none |
26//! | `tag` (exact or prefix) / `branch` / no pin | one `ls-remote` (ref listing, no objects) |
27//! | `rev` = short SHA / revspec | falls through to a clone |
28//!
29//! If `<cache_root>/git/<host>/<path…>/<sha>/` already exists for the
30//! resolved SHA, that directory is returned as is and **no clone happens**.
31//! Only a cache miss pays for a full clone (into a temp dir, then renamed
32//! into place).
33//!
34//! # Authentication
35//!
36//! `GitFetcher` uses a `RemoteCallbacks`-based credential cascade:
37//!
38//! 1. SSH agent (`Cred::ssh_key_from_agent`) — tried only when the remote
39//!    advertises `SSH_KEY` in `allowed_types`.
40//! 2. Credential helper (`Cred::credential_helper`) — tried when `USER_PASS_PLAINTEXT`
41//!    is advertised.
42//! 3. `Cred::default()` — last resort.
43//!
44//! The callback tracks attempted credential types to avoid infinite retry loops.
45
46use 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
58/// Monotonic counter for unique temp-clone directory names within one process.
59static TMP_CTR: AtomicU64 = AtomicU64::new(0);
60
61// Test-only: number of full clones performed on the current thread, so a
62// test can assert that a cache hit did not clone.  Thread-local because
63// the test harness runs tests in parallel.
64#[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
74// ── Fetcher trait ─────────────────────────────────────────────────────────────
75
76/// Abstraction over package fetch backends.
77///
78/// Implementations are *not* required to be [`Send`] or [`Sync`] — the MVP
79/// is single-threaded.
80pub trait Fetcher {
81    /// Fetch the package described by `dep` and return a [`FetchedPkg`].
82    ///
83    /// # Errors
84    ///
85    /// Returns [`PkgError`] on any git, I/O, or validation failure.
86    fn fetch(&self, dep: &Dep) -> Result<FetchedPkg, PkgError>;
87}
88
89// ── FetchedPkg ────────────────────────────────────────────────────────────────
90
91/// Result of a successful [`Fetcher::fetch`] call.
92#[derive(Debug, Clone)]
93pub struct FetchedPkg {
94    /// Absolute path to the cloned repository on disk.
95    pub cache_path: PathBuf,
96
97    /// The resolved commit SHA (40-character hex string).
98    pub sha: String,
99
100    /// The parsed `mlua-pkg.toml` found at `cache_path`, if present.
101    pub manifest: Option<Manifest>,
102
103    /// The concrete tag name actually resolved.  Equals `dep.tag` for exact
104    /// pins; for prefix pins (e.g. `"v0.1"`) holds the picked release
105    /// (e.g. `"v0.1.0"`).  `None` when no tag pin was used (rev / branch /
106    /// HEAD resolution).
107    pub resolved_tag: Option<String>,
108}
109
110// ── GitFetcher ────────────────────────────────────────────────────────────────
111
112/// [`Fetcher`] implementation backed by libgit2.
113pub struct GitFetcher {
114    /// Root directory under which all git caches are stored.
115    cache_root: PathBuf,
116}
117
118impl GitFetcher {
119    /// Create a new `GitFetcher` that stores clones under `cache_root`.
120    pub fn new(cache_root: PathBuf) -> Self {
121        Self { cache_root }
122    }
123
124    /// Compute the cache directory for the given URL and SHA.
125    ///
126    /// Layout: `<cache_root>/git/<host>/<path…>/<sha>/`
127    ///
128    /// # Errors
129    ///
130    /// Returns [`PkgError::Validation`] if the URL cannot be parsed or if any
131    /// URL-derived path component contains `..` (path traversal defence).
132    fn cache_dir(&self, url: &str, sha: &str) -> Result<PathBuf, PkgError> {
133        // Strip protocol prefix and trailing `.git`.
134        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(':', "/") // git@github.com:user/repo → github.com/user/repo
140            .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        // Defend against path traversal in every component.
150        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        // Validate SHA is safe (hex chars only).
161        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            // Extra check: ensure no path component resolves to `..` via PathBuf.
173            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    /// Validate the URL structure before any network operation.
190    ///
191    /// Rejects URLs that contain `..` or `.` path components (path traversal
192    /// defence).  Called at the start of [`Fetcher::fetch`] so that invalid
193    /// URLs are rejected without making a network connection.
194    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    /// Return a unique temp-clone path inside `git_base` (same filesystem, so
223    /// `std::fs::rename` works atomically).
224    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    /// Resolve the git ref from `dep` into `(sha, resolved_tag)`.
231    ///
232    /// Resolution order:
233    /// 1. `rev` — treated as a revspec; peels to commit. `resolved_tag` = None.
234    /// 2. `tag` — classified via [`crate::version::classify_tag_pin`]:
235    ///    - **Exact** / unparseable → resolved literally via `refs/tags/<tag>`.
236    ///    - **Prefix** (`"v1.0"` etc.) → enumerate local tags, pick the
237    ///      SemVer-max release that matches the prefix, resolve that.
238    ///
239    ///    `resolved_tag` holds the literal tag name actually used.
240    /// 3. `branch` — `refs/remotes/origin/<branch>` (peeled). `resolved_tag` = None.
241    /// 4. No ref — `HEAD`. `resolved_tag` = None.
242    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        // Default: HEAD.
259        let oid = repo.head()?.peel_to_commit()?.id();
260        Ok((oid.to_string(), None))
261    }
262
263    /// Resolve `tag` to a concrete local tag name in `repo`.
264    ///
265    /// For [`crate::version::TagPin::Exact`] (or unclassifiable) values the
266    /// input is returned verbatim — callers will hit `refs/tags/<tag>` and
267    /// see a NotFound error if the tag truly does not exist.  For
268    /// [`crate::version::TagPin::Prefix`] values the local tag list is
269    /// enumerated and the SemVer-max matching release is picked; pre-release
270    /// tags are skipped.  Returns [`PkgError::Validation`] when a prefix pin
271    /// has no matching release.
272    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    /// Same policy as [`resolve_tag_pin`](Self::resolve_tag_pin) over an
282    /// already-collected tag list (local or from `ls-remote`).
283    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            // Exact, or unparseable: use the literal value.
288            _ => 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    /// Resolve `dep` to `(sha, resolved_tag)` **without cloning**, or `None`
296    /// when that is not possible (short / symbolic `rev`, or the ref is not
297    /// advertised by the remote — the clone path then produces the real
298    /// error).
299    ///
300    /// - full 40-hex `rev` → no network at all
301    /// - `tag` / `branch` / no pin → one `ls-remote` via [`ls_remote_refs`](Self::ls_remote_refs)
302    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            // Annotated tags advertise a peeled `^{}` entry pointing at the
323            // commit; lightweight tags only have the plain ref.
324            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    /// Tag names (de-duplicated, `^{}` stripped) from an `ls-remote` listing.
335    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    /// Reset the worktree of `repo` hard to the commit named by `sha`.
349    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    /// List remote tag names by ls-remote (no clone).
359    ///
360    /// Connects to `url` in fetch direction, enumerates `refs/tags/*`, strips
361    /// the `refs/tags/` prefix and the `^{}` peeled-tag suffix when present,
362    /// and returns a de-duplicated `Vec<String>` in arbitrary order.
363    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    /// `ls-remote`: every advertised ref as `(name, oid hex)`, e.g.
368    /// `("refs/tags/v1.0.0^{}", "abc…")` / `("refs/heads/main", "…")` /
369    /// `("HEAD", "…")`.  Transfers ref names only, no objects.
370    pub fn ls_remote_refs(&self, url: &str) -> Result<Vec<(String, String)>, PkgError> {
371        Self::validate_url(url)?;
372
373        // Transient repo in a temp dir to host the anonymous remote.
374        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    /// Resolve `dep` to its commit **without cloning**, when the pin allows
385    /// it: a full 40-hex `rev` needs no network, `tag` / `branch` / no pin
386    /// cost one `ls-remote`.  Returns `None` for a short / symbolic `rev`
387    /// or a ref the remote does not advertise (a real fetch then produces
388    /// the error).  Used by `install` to decide whether a `patch_dir` is
389    /// still based on the pinned commit before touching the cache.
390    ///
391    /// # Errors
392    ///
393    /// [`PkgError::Validation`] for a malformed URL, [`PkgError::GitFetch`]
394    /// when the remote cannot be listed.
395    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    /// Describe a package whose root directory is already on disk (a cache
403    /// checkout or a `patch_dir`): read the author manifest and assemble the
404    /// [`FetchedPkg`].  Shared by the cache-hit, clone and patch paths.
405    ///
406    /// Without an `mlua-pkg.toml`, an in-tree LuaRocks rockspec (root or
407    /// `rockspecs/`, see [`rockspec::find_in_tree`](crate::rockspec::find_in_tree))
408    /// is evaluated and turned into a synthesized author manifest whose
409    /// `entry` is folded from `build.modules`.  A rockspec that cannot be
410    /// evaluated is an error; one that yields no entry hint (C modules,
411    /// irregular layout) leaves `manifest` as `None`.
412    ///
413    /// # Errors
414    ///
415    /// [`PkgError::ManifestParse`] / [`PkgError::Validation`] from the
416    /// manifest or rockspec, [`PkgError::Io`] from directory listing.
417    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    /// Build `RemoteCallbacks` with the credential cascade (extracted so both
462    /// `make_fetch_options` and `list_tags` can share it).
463    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    /// Build `FetchOptions` with the credential cascade callback.
494    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        // Reject obviously malformed / traversal URLs before any I/O.
506        Self::validate_url(url)?;
507
508        // All git caches live under `<cache_root>/git/`.
509        let git_base = self.cache_root.join("git");
510        std::fs::create_dir_all(&git_base)?;
511
512        // Cache hit without a clone: resolve the ref cheaply first (no
513        // network for a full-SHA rev, one ls-remote otherwise) and return
514        // the existing cache directory when it is already populated.
515        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        // Clone into a temp directory that lives on the same filesystem as the
526        // final cache location so that `std::fs::rename` is atomic.
527        let tmp_path = Self::temp_clone_path(&git_base);
528
529        // ── Clone ────────────────────────────────────────────────────────────
530        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                // Best-effort cleanup on error.
535                let _ = std::fs::remove_dir_all(&tmp_path);
536                return Err(e.into());
537            }
538        };
539
540        // ── Resolve SHA + concrete tag ───────────────────────────────────────
541        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        // ── Checkout resolved commit ─────────────────────────────────────────
550        // `clone()` leaves the worktree at the cloned HEAD (= default branch
551        // tip), which for tag/rev/branch lookups is the wrong commit.  Reset
552        // hard to the resolved SHA so the cached content matches the SHA we
553        // pin in the lockfile.
554        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        // ── Compute final cache path ─────────────────────────────────────────
560        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            // Already cached — discard the temp clone.
570            drop(repo);
571            let _ = std::fs::remove_dir_all(&tmp_path);
572        } else {
573            // Ensure the parent directory exists, then rename temp → final.
574            if let Some(parent) = cache_path.parent() {
575                std::fs::create_dir_all(parent)?;
576            }
577            drop(repo); // Release file handles before rename.
578            std::fs::rename(&tmp_path, &cache_path)?;
579        }
580
581        FetchedPkg::at_root(cache_path, sha, resolved_tag)
582    }
583}
584
585// ── Unit tests ────────────────────────────────────────────────────────────────
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use git2::{Repository, Signature};
591    use std::fs;
592    use tempfile::TempDir;
593
594    /// Create a minimal git repo in `dir` with one commit and return the SHA.
595    fn init_repo_with_commit(dir: &std::path::Path) -> String {
596        let repo = Repository::init(dir).unwrap();
597
598        // Configure identity for the test repo.
599        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        // Create an initial file and commit.
605        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    /// Add an annotated tag to the HEAD commit of `repo`.
622    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    // ── 1. clone a local file:// repo (happy path) ───────────────────────────
631
632    #[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    // ── 2. resolve tag → SHA ──────────────────────────────────────────────────
662
663    #[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    // ── 3. resolve rev → SHA ──────────────────────────────────────────────────
692
693    #[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    // ── 4. nonexistent repo returns GitFetch error ────────────────────────────
718
719    #[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    // ── 5. second fetch of same repo uses cache (skip re-clone) ──────────────
743
744    #[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    // ── 5b. cache hit does not clone ─────────────────────────────────────────
775
776    #[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        // Remote gone: a full-SHA rev pin must still resolve from the cache.
797        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        // Prefix pin: resolution needs the remote tag list, not a clone.
819        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        // Advance the branch on the remote.
874        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    // ── 5c. in-tree rockspec drives the entry ────────────────────────────────
892
893    #[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        // Layout the fallback chain would misread: a `src/` without Lua and
898        // the real module under `lib/`, declared by the rockspec.
899        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    // ── 6. path traversal in URL is rejected ──────────────────────────────────
948
949    #[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    // ── 7. manifest is parsed when mlua-pkg.toml is present ──────────────────
973
974    #[test]
975    fn manifest_parsed_when_present() {
976        let src = TempDir::new().unwrap();
977
978        // Write mlua-pkg.toml before the initial commit.
979        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    // ── 8. fetched worktree content matches the resolved ref, not HEAD ───────
1028    //
1029    // Regression for the v0.4.0 bug where fetch() resolved the SHA from
1030    // `tag = "v0.1.0"` and pinned it in the lockfile / cache dir name, but
1031    // left the worktree at the cloned HEAD (= default branch tip).  Consumers
1032    // therefore got the latest content with a stale SHA — reproducibility lost.
1033
1034    #[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        // Commit 1: VERSION = "0.1.0", tagged v0.1.0.
1045        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        // Commit 2: VERSION = "0.2.0", HEAD advances. No tag.
1059        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        // Fetch tag v0.1.0.
1072        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        // SHA must point at the tag commit, not HEAD.
1087        assert_eq!(fetched.sha, v010_sha, "SHA must resolve to tag commit");
1088
1089        // Worktree content must match the tag commit content, not HEAD's.
1090        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    // ── 9. cache_dir rejects SHA with non-hex chars ───────────────────────────
1098
1099    #[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}