Skip to main content

sdkt_core/
fetch.rs

1//! Dependency acquisition layer for the Soroban DevKit (sdkt), .
2//!
3//! Provides a reusable abstraction for fetching package dependencies into a
4//! deterministic local cache. This milestone implements **Git** acquisition
5//! (via the `git` CLI) and a path (local) source that needs no fetch. The
6//! [`DependencyFetcher`] trait is the seam: a future registry source can be
7//! added as another implementation without touching callers.
8//!
9//! Design goals:
10//! * No network during tests — tests use on-the-fly local git repositories.
11//! * Deterministic cache layout: `<cache_root>/git/<stable-hash>/` so repeated
12//!   fetches are idempotent.
13//! * No authentication helpers, no registry. The Git backend shells out to the
14//!   system `git` binary (assumed present, like `cargo`/`rustc`).
15//! * Never builds — `fetch` only materializes source; building is the caller's
16//!   responsibility.
17
18use crate::config::Dependency;
19use crate::sync::resolve_version_constraint;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22
23/// Resolve the `git` executable.
24///
25/// Normally `git` is found via `PATH`. In some spawned/subprocess environments
26/// (e.g. a test harness that restricts `PATH`) the bare `"git"` name fails to
27/// spawn even though git is installed. To stay robust we fall back to a set of
28/// well-known absolute locations when a `PATH` lookup does not produce a runnable
29/// binary. The first candidate that reports a version is used.
30pub fn git_bin() -> String {
31    for candidate in [
32        "git".to_string(),
33        "/usr/bin/git".to_string(),
34        "/usr/local/bin/git".to_string(),
35        "/bin/git".to_string(),
36    ] {
37        if Command::new(&candidate)
38            .arg("--version")
39            .output()
40            .map(|o| o.status.success())
41            .unwrap_or(false)
42        {
43            return candidate;
44        }
45    }
46    // Fall back to the bare name; callers surface a clear error if it truly fails.
47    "git".to_string()
48}
49
50/// Errors raised while acquiring a dependency.
51#[derive(Debug)]
52pub enum FetchError {
53    /// The system `git` binary is missing or not executable.
54    GitUnavailable,
55    /// `git` exited non-zero while cloning/checking out.
56    Git {
57        args: String,
58        status: Option<i32>,
59        stderr: String,
60    },
61    /// A dependency declared neither a `path` nor a `git` source.
62    NoSource(String),
63    /// A `git` dependency is missing its URL.
64    MissingUrl(String),
65    /// Cache directory creation/relocation failed.
66    Io {
67        context: String,
68        source: std::io::Error,
69    },
70}
71
72impl std::fmt::Display for FetchError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            FetchError::GitUnavailable => {
76                write!(
77                    f,
78                    "the `git` executable is required but was not found on PATH"
79                )
80            }
81            FetchError::Git {
82                args,
83                status,
84                stderr,
85            } => write!(
86                f,
87                "git {} failed (exit {:?}): {}",
88                args,
89                status,
90                stderr.trim()
91            ),
92            FetchError::NoSource(name) => {
93                write!(f, "dependency '{}' declares neither `path` nor `git`", name)
94            }
95            FetchError::MissingUrl(name) => {
96                write!(f, "git dependency '{}' is missing a `git` URL", name)
97            }
98            FetchError::Io { context, source } => {
99                write!(f, "I/O error {}: {}", context, source)
100            }
101        }
102    }
103}
104
105impl std::error::Error for FetchError {}
106
107/// The outcome of a fetch operation for one dependency.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct FetchOutcome {
110    /// The dependency name (key in `[dependencies]`).
111    pub name: String,
112    /// Where the source now lives on disk.
113    pub local_path: PathBuf,
114    /// The resolved Git commit SHA, if this was a Git source (empty for path).
115    pub resolved_rev: String,
116    /// True if the checkout was already present and up to date.
117    pub already_present: bool,
118}
119
120/// A source a dependency can be acquired from.
121pub trait DependencyFetcher {
122    /// Materialize `dep` (named `name`) into the cache and return its on-disk
123    /// location. `force` re-fetches/updates an existing checkout.
124    fn fetch(&self, name: &str, dep: &Dependency, force: bool) -> Result<FetchOutcome, FetchError>;
125}
126
127/// Stable, filesystem-safe cache key for a Git dependency.
128///
129/// Derived from the URL + reference so two deps with the same source/ref share
130/// a cache entry. Not cryptographic — uniqueness only needs to be stable.
131pub fn git_cache_key(dep: &Dependency) -> String {
132    let url = dep.git.clone().unwrap_or_default();
133    let reference = dep
134        .tag
135        .clone()
136        .or_else(|| dep.branch.clone())
137        .or_else(|| dep.rev.clone())
138        .unwrap_or_else(|| "head".to_string());
139    // Cheap FNV-1a style hash; deterministic across platforms.
140    let mut h: u64 = 0xcbf29ce484222325;
141    for b in url.as_bytes().iter().chain(reference.as_bytes()) {
142        h ^= *b as u64;
143        h = h.wrapping_mul(0x100000001b3);
144    }
145    format!("{:016x}", h)
146}
147
148/// Git-backed dependency fetcher.
149///
150/// Uses the system `git` CLI. On fetch it clones (or updates) into
151/// `<cache_root>/git/<key>/`, then checks out the requested tag/branch/rev.
152/// When `force` is false and the checkout already exists at the right commit,
153/// it is reused without re-cloning.
154pub struct GitFetcher {
155    /// Root directory holding all cached dependencies (e.g. `.sdkt-cache`).
156    pub cache_root: PathBuf,
157}
158
159impl GitFetcher {
160    /// Create a fetcher rooted at `cache_root`.
161    pub fn new(cache_root: impl Into<PathBuf>) -> Self {
162        Self {
163            cache_root: cache_root.into(),
164        }
165    }
166
167    fn git_available() -> Result<(), FetchError> {
168        Command::new(git_bin())
169            .arg("--version")
170            .output()
171            .map_err(|_| FetchError::GitUnavailable)?;
172        Ok(())
173    }
174
175    /// Resolve the current HEAD commit SHA of a checkout (empty if unknown).
176    fn head_rev(checkout: &Path) -> String {
177        let out = Command::new(git_bin())
178            .current_dir(checkout)
179            .args(["rev-parse", "HEAD"])
180            .output();
181        match out {
182            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
183            _ => String::new(),
184        }
185    }
186
187    /// The commit SHA that a given reference resolves to (empty on failure).
188    pub fn resolved_rev_for(dep: &Dependency, checkout: &Path) -> String {
189        if let Some(rev) = &dep.rev {
190            return rev.clone();
191        }
192        let refspec = dep
193            .tag
194            .as_ref()
195            .map(|t| format!("refs/tags/{}", t))
196            .or_else(|| {
197                dep.branch
198                    .as_ref()
199                    .map(|b| format!("refs/remotes/origin/{}", b))
200            })
201            .unwrap_or_default();
202        if refspec.is_empty() {
203            return Self::head_rev(checkout);
204        }
205        let out = Command::new(git_bin())
206            .current_dir(checkout)
207            .args(["rev-parse", &refspec])
208            .output();
209        match out {
210            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
211            _ => String::new(),
212        }
213    }
214}
215
216impl DependencyFetcher for GitFetcher {
217    fn fetch(&self, name: &str, dep: &Dependency, force: bool) -> Result<FetchOutcome, FetchError> {
218        let url = dep
219            .git
220            .clone()
221            .ok_or_else(|| FetchError::MissingUrl(name.to_string()))?;
222        if url.trim().is_empty() {
223            return Err(FetchError::MissingUrl(name.to_string()));
224        }
225
226        Self::git_available()?;
227
228        // — version-constrained git dep: resolve the `version` semver
229        // constraint to a concrete tag before acquiring. Reuses the single
230        // `resolve_version_constraint` helper (which shells out via `git
231        // ls-remote --tags` and `crate::package::best_version_match`); no
232        // comparator logic is duplicated here. An explicit tag/branch/rev
233        // always wins and the constraint is ignored.
234        let effective_dep = if let Some(ver) = &dep.version {
235            if dep.tag.is_none() && dep.branch.is_none() && dep.rev.is_none() {
236                match resolve_version_constraint(&url, ver) {
237                    Ok(Some((tag, _))) => Dependency {
238                        git: Some(url.clone()),
239                        tag: Some(tag),
240                        ..Default::default()
241                    },
242                    Ok(None) => {
243                        return Err(FetchError::Git {
244                            args: format!("resolve version '{}'", ver),
245                            status: Some(1),
246                            stderr: format!("no remote tag satisfies constraint '{}'", ver),
247                        });
248                    }
249                    Err(e) => {
250                        return Err(FetchError::Git {
251                            args: format!("resolve version '{}'", ver),
252                            status: None,
253                            stderr: e.to_string(),
254                        });
255                    }
256                }
257            } else {
258                // An explicit ref wins; the constraint is inert.
259                dep.clone()
260            }
261        } else {
262            dep.clone()
263        };
264
265        let key = git_cache_key(&effective_dep);
266        let checkout = self.cache_root.join("git").join(&key);
267        // Ensure the parent cache dir (`<cache_root>/git`) exists so `git clone`
268        // can create the destination checkout dir itself. The checkout dir
269        // must NOT be pre-created: cloning into an existing (even empty)
270        // directory with cwd set to it makes git silently fail to initialize
271        // `.git`, which then breaks the later checkout.
272        let parent = checkout
273            .parent()
274            .ok_or_else(|| FetchError::Io {
275                context: format!("invalid cache path {}", checkout.display()),
276                source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "no parent"),
277            })?
278            .to_path_buf();
279        std::fs::create_dir_all(&parent).map_err(|source| FetchError::Io {
280            context: format!("creating cache dir {}", parent.display()),
281            source,
282        })?;
283
284        let existing = checkout.join(".git").exists();
285        if existing && !force {
286            // Verify the checkout already resolves to the requested ref.
287            let want = Self::resolved_rev_for(&effective_dep, &checkout);
288            if !want.is_empty() && want == Self::head_rev(&checkout) {
289                return Ok(FetchOutcome {
290                    name: name.to_string(),
291                    local_path: checkout,
292                    resolved_rev: want,
293                    already_present: true,
294                });
295            }
296        }
297
298        // Clone or update. When `force` is set we use explicit refspecs so a
299        // moved tag (the core update scenario) is actually pulled: a
300        // plain `git fetch` refuses to overwrite a local lightweight tag, so we
301        // pass `+refs/tags/*:refs/tags/*` to force the update; branches use the
302        // normal `origin/<branch>` remote-tracking mapping.
303        let (workdir, clone_args) = if existing {
304            let workdir = checkout.clone();
305            let args = if force {
306                vec![
307                    "fetch".to_string(),
308                    "--force".to_string(),
309                    url.clone(),
310                    "+refs/heads/*:refs/remotes/origin/*".to_string(),
311                    "+refs/tags/*:refs/tags/*".to_string(),
312                ]
313            } else {
314                vec!["fetch".to_string()]
315            };
316            (workdir, args)
317        } else {
318            (
319                parent,
320                vec![
321                    "clone".to_string(),
322                    "--no-checkout".to_string(),
323                    url.clone(),
324                    checkout.to_string_lossy().to_string(),
325                ],
326            )
327        };
328        run_git(&workdir, &clone_args)?;
329
330        // Checkout the requested reference.
331        let checkout_ref: String = if let Some(rev) = &effective_dep.rev {
332            rev.clone()
333        } else if let Some(tag) = &effective_dep.tag {
334            tag.clone()
335        } else if let Some(branch) = &effective_dep.branch {
336            format!("origin/{}", branch)
337        } else {
338            "origin/HEAD".to_string()
339        };
340        run_git(
341            &checkout,
342            &["checkout".to_string(), "--force".to_string(), checkout_ref],
343        )?;
344
345        let resolved = Self::resolved_rev_for(&effective_dep, &checkout);
346        Ok(FetchOutcome {
347            name: name.to_string(),
348            local_path: checkout,
349            resolved_rev: resolved,
350            already_present: false,
351        })
352    }
353}
354
355/// A no-network "fetcher" for local `path` dependencies: it only validates
356/// that the path exists and returns it unchanged.
357pub struct PathResolver;
358
359impl DependencyFetcher for PathResolver {
360    fn fetch(
361        &self,
362        name: &str,
363        dep: &Dependency,
364        _force: bool,
365    ) -> Result<FetchOutcome, FetchError> {
366        let path = dep
367            .path
368            .clone()
369            .ok_or_else(|| FetchError::NoSource(name.to_string()))?;
370        if path.trim().is_empty() {
371            return Err(FetchError::NoSource(name.to_string()));
372        }
373        Ok(FetchOutcome {
374            name: name.to_string(),
375            local_path: PathBuf::from(path),
376            resolved_rev: String::new(),
377            already_present: true,
378        })
379    }
380}
381
382/// Run a `git` command, returning a [`FetchError::Git`] on failure.
383fn run_git(dir: &Path, args: &[String]) -> Result<(), FetchError> {
384    let out = Command::new(git_bin())
385        .current_dir(dir)
386        .args(args)
387        .output()
388        .map_err(|_| FetchError::GitUnavailable)?;
389    if out.status.success() {
390        Ok(())
391    } else {
392        Err(FetchError::Git {
393            args: args.join(" "),
394            status: out.status.code(),
395            stderr: String::from_utf8_lossy(&out.stderr).to_string(),
396        })
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::config::{Dependency, DevKitConfig, PackageConfig};
404    use std::collections::HashMap;
405    use std::io::Write;
406
407    fn git_cmd(dir: &Path) -> Command {
408        let mut c = Command::new("git");
409        c.current_dir(dir)
410            // Treat the temp checkout as safe so git operations succeed even on
411            // CI runners (Windows/macOS) where the temp directory ownership can
412            // trip git's "dubious ownership" protection. Applied per-command via
413            // env (no global git-config mutation, no side effects).
414            .env("GIT_CONFIG_COUNT", "1")
415            .env("GIT_CONFIG_KEY_0", "safe.directory")
416            .env("GIT_CONFIG_VALUE_0", "*");
417        c
418    }
419
420    // Build a collision-free temp directory for a throwaway git repo. A unique
421    // name (pid + nanosecond clock + per-process counter) guarantees every call
422    // gets a fresh path, even when tests run in parallel threads or a stale dir
423    // from a previous CI run lingers in the shared temp folder. This is what
424    // prevents `git init` from failing on macOS with ".git/info/exclude: File
425    // exists" (it was hitting a leftover/colliding directory).
426    fn fresh_repo_dir() -> PathBuf {
427        use std::sync::atomic::{AtomicU64, Ordering};
428        static COUNTER: AtomicU64 = AtomicU64::new(0);
429        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
430        let nanos = std::time::SystemTime::now()
431            .duration_since(std::time::UNIX_EPOCH)
432            .unwrap()
433            .as_nanos();
434        let pid = std::process::id();
435        let dir = std::env::temp_dir().join(format!("sdkt-fetch-src-{}-{}-{}", pid, nanos, n));
436        // Remove any stale directory from a prior run (best effort) so git init
437        // always starts from a clean, unique path.
438        let _ = std::fs::remove_dir_all(&dir);
439        std::fs::create_dir_all(&dir).unwrap();
440        dir
441    }
442
443    fn make_git_repo() -> PathBuf {
444        let dir = fresh_repo_dir();
445        let run = |args: &[&str]| {
446            let o = git_cmd(&dir).args(args).output().expect("git available");
447            if !o.status.success() {
448                panic!(
449                    "git {:?} failed (exit {:?}):\n--- stdout ---\n{}\n--- stderr ---\n{}",
450                    args,
451                    o.status.code(),
452                    String::from_utf8_lossy(&o.stdout),
453                    String::from_utf8_lossy(&o.stderr)
454                );
455            }
456        };
457        run(&["init", "-q"]);
458        run(&["config", "user.email", "test@sdkt.local"]);
459        run(&["config", "user.name", "sdkt test"]);
460        // v1.0.0 tag on first commit. The file handle is scoped so it is
461        // closed (dropped) before git stages the file — required on Windows,
462        // where an open handle can prevent `git add` from seeing the new
463        // content ("no changes added to commit").
464        {
465            let f = dir.join("lib.rs");
466            let mut fh = std::fs::File::create(&f).unwrap();
467            fh.write_all(b"pub fn answer() -> u32 { 42 }").unwrap();
468        }
469        run(&["add", "lib.rs"]);
470        run(&["commit", "-q", "-m", "initial"]);
471        run(&["tag", "v1.0.0"]);
472        // A second commit + main branch head.
473        {
474            let f = dir.join("lib.rs");
475            let mut fh = std::fs::File::create(&f).unwrap();
476            fh.write_all(b"pub fn answer() -> u32 { 43 }").unwrap();
477        }
478        run(&["add", "lib.rs"]);
479        run(&["commit", "-q", "-m", "second"]);
480        dir
481    }
482
483    fn config_with_git(
484        url: &str,
485        tag: Option<&str>,
486        branch: Option<&str>,
487        rev: Option<&str>,
488    ) -> DevKitConfig {
489        DevKitConfig {
490            package: Some(PackageConfig {
491                name: Some("app".to_string()),
492                version: Some("0.1.0".to_string()),
493                description: None,
494            }),
495            dependencies: {
496                let mut m = HashMap::new();
497                m.insert(
498                    "dep".to_string(),
499                    Dependency {
500                        git: Some(url.to_string()),
501                        tag: tag.map(|s| s.to_string()),
502                        branch: branch.map(|s| s.to_string()),
503                        rev: rev.map(|s| s.to_string()),
504                        ..Default::default()
505                    },
506                );
507                m
508            },
509            ..Default::default()
510        }
511    }
512
513    #[test]
514    fn fetch_git_by_tag() {
515        let src = make_git_repo();
516        let url = src.to_string_lossy().to_string();
517        let cfg = config_with_git(&url, Some("v1.0.0"), None, None);
518        let dep = cfg.dependencies.get("dep").unwrap();
519
520        let cache = std::env::temp_dir().join(format!(
521            "sdkt-fetch-cache-{}",
522            std::time::SystemTime::now()
523                .duration_since(std::time::UNIX_EPOCH)
524                .unwrap()
525                .as_nanos()
526        ));
527        let _ = std::fs::remove_dir_all(&cache);
528
529        let fetcher = GitFetcher::new(&cache);
530        let out = fetcher.fetch("dep", dep, false).expect("fetch ok");
531        assert!(out.local_path.exists());
532        assert!(out.local_path.join(".git").exists());
533        assert!(!out.resolved_rev.is_empty());
534        // The checked-out content should be the v1.0.0 (answer() == 42) blob.
535        let lib = std::fs::read_to_string(out.local_path.join("lib.rs")).unwrap();
536        assert!(lib.contains("42"), "expected tagged content, got: {}", lib);
537
538        // Idempotent: second fetch without force reuses, already_present true.
539        let out2 = fetcher.fetch("dep", dep, false).expect("fetch ok");
540        assert!(out2.already_present);
541
542        let _ = std::fs::remove_dir_all(&cache);
543        let _ = std::fs::remove_dir_all(&src);
544    }
545
546    #[test]
547    fn fetch_git_by_rev() {
548        let src = make_git_repo();
549        let url = src.to_string_lossy().to_string();
550        let cfg = config_with_git(&url, None, None, None);
551        let _dep = cfg.dependencies.get("dep").unwrap();
552        // Determine the second-commit SHA by rev-parsing origin/HEAD equiv.
553        let rev = {
554            let o = git_cmd(&src).args(["rev-parse", "HEAD"]).output().unwrap();
555            String::from_utf8_lossy(&o.stdout).trim().to_string()
556        };
557        let cfg2 = config_with_git(&url, None, None, Some(&rev));
558        let dep2 = cfg2.dependencies.get("dep").unwrap();
559
560        let cache = std::env::temp_dir().join(format!(
561            "sdkt-fetch-cache-rev-{}",
562            std::time::SystemTime::now()
563                .duration_since(std::time::UNIX_EPOCH)
564                .unwrap()
565                .as_nanos()
566        ));
567        let _ = std::fs::remove_dir_all(&cache);
568        let fetcher = GitFetcher::new(&cache);
569        let out = fetcher.fetch("dep", dep2, false).expect("fetch ok");
570        assert_eq!(out.resolved_rev, rev);
571        let lib = std::fs::read_to_string(out.local_path.join("lib.rs")).unwrap();
572        assert!(lib.contains("43"), "expected head content, got: {}", lib);
573
574        let _ = std::fs::remove_dir_all(&cache);
575        let _ = std::fs::remove_dir_all(&src);
576        let _ = &cfg; // silence unused
577    }
578
579    #[test]
580    fn path_resolver_returns_path() {
581        let tmp = std::env::temp_dir().join("sdkt-fetch-path");
582        let _ = std::fs::remove_dir_all(&tmp);
583        std::fs::create_dir_all(&tmp).unwrap();
584        let dep = Dependency {
585            path: Some(tmp.to_string_lossy().to_string()),
586            ..Default::default()
587        };
588        let out = PathResolver.fetch("local", &dep, false).unwrap();
589        assert_eq!(out.local_path, tmp);
590        assert!(out.resolved_rev.is_empty());
591        let _ = std::fs::remove_dir_all(&tmp);
592    }
593}