Skip to main content

soldeer_core/
install.rs

1//! Install dependencies.
2//!
3//! This module contains functions to install dependencies from the config object or from the
4//! lockfile. Dependencies can be installed in parallel.
5use crate::{
6    config::{
7        Dependency, GitIdentifier, HttpDependency, Paths, detect_config_location, read_config_deps,
8        read_soldeer_config,
9    },
10    download::{clone_repo, delete_dependency_files, download_file, unzip_file},
11    errors::{ConfigError, InstallError, LockError},
12    lock::{
13        GitLockEntry, HttpLockEntry, Integrity, LockEntry, PrivateLockEntry, forge,
14        format_install_path, read_lockfile,
15    },
16    registry::{DownloadUrl, get_dependency_url_remote, get_latest_supported_version},
17    utils::{IntegrityChecksum, canonicalize, hash_file, hash_folder, run_git_command},
18};
19use derive_more::derive::Display;
20use log::{debug, info, warn};
21use path_slash::PathBufExt as _;
22use std::{
23    collections::HashMap,
24    fmt,
25    future::Future,
26    ops::Deref,
27    path::{Path, PathBuf},
28    pin::Pin,
29};
30use tokio::{fs, sync::mpsc, task::JoinSet};
31
32pub type Result<T> = std::result::Result<T, InstallError>;
33
34#[derive(Debug, Clone, Display)]
35pub struct DependencyName(String);
36
37impl Deref for DependencyName {
38    type Target = String;
39
40    fn deref(&self) -> &Self::Target {
41        &self.0
42    }
43}
44
45impl<T: fmt::Display> From<&T> for DependencyName {
46    fn from(value: &T) -> Self {
47        Self(value.to_string())
48    }
49}
50
51/// Collection of channels to monitor the progress of the install process.
52#[derive(Debug)]
53pub struct InstallMonitoring {
54    /// Channel to receive install progress logs.
55    pub logs: mpsc::UnboundedReceiver<String>,
56
57    /// Progress for calls to the API to retrieve the packages versions.
58    pub versions: mpsc::UnboundedReceiver<DependencyName>,
59
60    /// Progress for downloading the dependencies.
61    pub downloads: mpsc::UnboundedReceiver<DependencyName>,
62
63    /// Progress for unzipping the downloaded files.
64    pub unzip: mpsc::UnboundedReceiver<DependencyName>,
65
66    /// Progress for installing subdependencies.
67    pub subdependencies: mpsc::UnboundedReceiver<DependencyName>,
68
69    /// Progress for checking the integrity of the installed dependencies.
70    pub integrity: mpsc::UnboundedReceiver<DependencyName>,
71}
72
73/// Collection of channels to notify the caller of the install progress.
74#[derive(Debug, Clone)]
75pub struct InstallProgress {
76    /// Channel to send messages to be logged to the user.
77    pub logs: mpsc::UnboundedSender<String>,
78
79    /// Progress for calls to the API to retrieve the packages versions.
80    pub versions: mpsc::UnboundedSender<DependencyName>,
81
82    /// Progress for downloading the dependencies.
83    pub downloads: mpsc::UnboundedSender<DependencyName>,
84
85    /// Progress for unzipping the downloaded files.
86    pub unzip: mpsc::UnboundedSender<DependencyName>,
87
88    /// Progress for installing subdependencies.
89    pub subdependencies: mpsc::UnboundedSender<DependencyName>,
90
91    /// Progress for checking the integrity of the installed dependencies.
92    pub integrity: mpsc::UnboundedSender<DependencyName>,
93}
94
95impl InstallProgress {
96    /// Create a new install progress tracker, with a receiving half ([InstallMonitoring]) and a
97    /// sending half ([InstallProgress]).
98    pub fn new() -> (Self, InstallMonitoring) {
99        let (logs_tx, logs_rx) = mpsc::unbounded_channel();
100        let (versions_tx, versions_rx) = mpsc::unbounded_channel();
101        let (downloads_tx, downloads_rx) = mpsc::unbounded_channel();
102        let (unzip_tx, unzip_rx) = mpsc::unbounded_channel();
103        let (subdependencies_tx, subdependencies_rx) = mpsc::unbounded_channel();
104        let (integrity_tx, integrity_rx) = mpsc::unbounded_channel();
105        (
106            Self {
107                logs: logs_tx,
108                versions: versions_tx,
109                downloads: downloads_tx,
110                unzip: unzip_tx,
111                subdependencies: subdependencies_tx,
112                integrity: integrity_tx,
113            },
114            InstallMonitoring {
115                logs: logs_rx,
116                versions: versions_rx,
117                downloads: downloads_rx,
118                unzip: unzip_rx,
119                subdependencies: subdependencies_rx,
120                integrity: integrity_rx,
121            },
122        )
123    }
124
125    /// Log a message related to progress to the caller.
126    pub fn log(&self, msg: impl fmt::Display) {
127        if let Err(e) = self.logs.send(msg.to_string()) {
128            warn!(err:err = e; "error sending log message to the install progress channel");
129        }
130    }
131
132    /// Advance all progress trackers at once, passing the dependency name.
133    pub fn update_all(&self, dependency_name: DependencyName) {
134        if let Err(e) = self.versions.send(dependency_name.clone()) {
135            warn!(err:err = e; "error sending version message to the install progress channel");
136        }
137        if let Err(e) = self.downloads.send(dependency_name.clone()) {
138            warn!(err:err = e; "error sending download message to the install progress channel");
139        }
140        if let Err(e) = self.unzip.send(dependency_name.clone()) {
141            warn!(err:err = e; "error sending unzip message to the install progress channel");
142        }
143        if let Err(e) = self.subdependencies.send(dependency_name.clone()) {
144            warn!(err:err = e; "error sending sudependencies message to the install progress channel");
145        }
146        if let Err(e) = self.integrity.send(dependency_name) {
147            warn!(err:err = e; "error sending integrity message to the install progress channel");
148        }
149    }
150}
151
152/// Status of a dependency, which can either be missing, installed and untouched, or installed but
153/// failing the integrity check.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156pub enum DependencyStatus {
157    /// The dependency is missing.
158    Missing,
159
160    /// The dependency is installed but the integrity check failed.
161    FailedIntegrity,
162
163    /// The dependency is installed and the integrity check passed.
164    Installed,
165}
166
167/// HTTP dependency installation information.
168#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
169#[builder(on(String, into))]
170struct HttpInstallInfo {
171    /// The name of the dependency.
172    name: String,
173
174    /// The version of the dependency. This is not a version requirement string but a specific.
175    /// version.
176    version: String,
177
178    /// The URL from which the zip file will be downloaded.
179    url: String,
180
181    /// The checksum of the downloaded zip file, if available (e.g. from the lockfile)
182    checksum: Option<String>,
183
184    /// An optional relative path to the project's root within the zip file.
185    ///
186    /// The project root is where the soldeer.toml or foundry.toml resides. If no path is provided,
187    /// then the zip's root must contain a Soldeer config.
188    project_root: Option<PathBuf>,
189}
190
191impl fmt::Display for HttpInstallInfo {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        // since the version is an exact version number, we use a dash and not a tilde
194        write!(f, "{}-{}", self.name, self.version)
195    }
196}
197
198/// Git dependency installation information.
199#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
200#[builder(on(String, into))]
201struct GitInstallInfo {
202    /// The name of the dependency.
203    name: String,
204
205    /// The version of the dependency.
206    version: String,
207
208    /// The URL of the git repository.
209    git: String,
210
211    /// The identifier of the git dependency (e.g. a commit hash, branch name, or tag name). If
212    /// `None` is provided, the default branch is used.
213    identifier: Option<GitIdentifier>,
214
215    /// An optional relative path to the project's root within the repository.
216    ///
217    /// The project root is where the soldeer.toml or foundry.toml resides. If no path is provided,
218    /// then the repo's root must contain a Soldeer config.
219    project_root: Option<PathBuf>,
220}
221
222impl fmt::Display for GitInstallInfo {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        write!(f, "{}-{}", self.name, self.version)
225    }
226}
227
228/// Installation information for a dependency.
229///
230/// A builder can be used to create the underlying [`HttpInstallInfo`] or [`GitInstallInfo`] and
231/// then converted into this type with `.into()`.
232#[derive(Debug, Clone, PartialEq, Eq, Hash, Display)]
233enum InstallInfo {
234    /// Installation information for an HTTP dependency.
235    Http(HttpInstallInfo),
236
237    /// Installation information for a git dependency.
238    Git(GitInstallInfo),
239
240    /// Installation information for a private dependency.
241    Private(HttpInstallInfo),
242}
243
244impl From<HttpInstallInfo> for InstallInfo {
245    fn from(value: HttpInstallInfo) -> Self {
246        Self::Http(value)
247    }
248}
249
250impl From<GitInstallInfo> for InstallInfo {
251    fn from(value: GitInstallInfo) -> Self {
252        Self::Git(value)
253    }
254}
255
256impl InstallInfo {
257    async fn from_lock(lock: LockEntry, project_root: Option<PathBuf>) -> Result<Self> {
258        match lock {
259            LockEntry::Http(lock) => Ok(HttpInstallInfo {
260                name: lock.name,
261                version: lock.version,
262                url: lock.url,
263                checksum: Some(lock.checksum),
264                project_root,
265            }
266            .into()),
267            LockEntry::Git(lock) => Ok(GitInstallInfo {
268                name: lock.name,
269                version: lock.version,
270                git: lock.git,
271                identifier: Some(GitIdentifier::from_rev(lock.rev)),
272                project_root,
273            }
274            .into()),
275            LockEntry::Private(lock) => {
276                // need to retrieve a signed download URL from the registry
277                let download = get_dependency_url_remote(
278                    &HttpDependency::builder()
279                        .name(&lock.name)
280                        .version_req(&lock.version)
281                        .build()
282                        .into(),
283                    &lock.version,
284                )
285                .await?;
286                Ok(Self::Private(HttpInstallInfo {
287                    name: lock.name,
288                    version: lock.version,
289                    url: download.url,
290                    checksum: Some(lock.checksum),
291                    project_root,
292                }))
293            }
294        }
295    }
296}
297
298/// Git submodule information
299#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
300struct Submodule {
301    url: String,
302    path: String,
303    branch: Option<String>,
304}
305
306/// Install a list of dependencies in parallel.
307///
308/// This function spawns a task for each dependency and waits for all of them to finish. Each task
309/// checks the integrity of the dependency if found on disk, downloads the dependency (zip file or
310/// cloning repo) if not already present, unzips the zip file if necessary, installs
311/// sub-dependencies and generates the lockfile entry.
312pub async fn install_dependencies(
313    dependencies: &[Dependency],
314    locks: &[LockEntry],
315    deps: impl AsRef<Path>,
316    recursive_deps: bool,
317    progress: InstallProgress,
318) -> Result<Vec<LockEntry>> {
319    let mut set = JoinSet::new();
320    for dep in dependencies {
321        debug!(dep:% = dep; "spawning task to install dependency");
322        set.spawn({
323            let d = dep.clone();
324            let p = progress.clone();
325            let lock = locks.iter().find(|l| l.name() == dep.name()).cloned();
326            let deps = deps.as_ref().to_path_buf();
327            async move {
328                install_dependency(
329                    &d,
330                    lock.as_ref(),
331                    deps,
332                    None,
333                    recursive_deps,
334                    p,
335                )
336                .await
337            }
338        });
339    }
340
341    let mut results = Vec::new();
342    while let Some(res) = set.join_next().await {
343        let res = res??;
344        debug!(dep:% = res.name(); "install task finished");
345        results.push(res);
346    }
347    debug!("all install tasks have finished");
348    Ok(results)
349}
350
351/// Install a list of dependencies sequentially.
352///
353/// This function can be used inside another tokio task to avoid spawning more tasks, useful for
354/// recursive install. For each dep, checks the integrity of the dependency if found on disk,
355/// downloads the dependency (zip file or cloning repo) if not already present, unzips the zip file
356/// if necessary, installs sub-dependencies and generates the lockfile entry.
357pub async fn install_dependencies_sequential(
358    dependencies: &[Dependency],
359    locks: &[LockEntry],
360    deps: impl AsRef<Path> + Clone,
361    recursive_deps: bool,
362    progress: InstallProgress,
363) -> Result<Vec<LockEntry>> {
364    let mut results = Vec::new();
365    for dep in dependencies {
366        debug!(dep:% = dep; "installing dependency sequentially");
367        let lock = locks.iter().find(|l| l.name() == dep.name());
368        results.push(
369            install_dependency(dep, lock, deps.clone(), None, recursive_deps, progress.clone())
370                .await?,
371        );
372        debug!(dep:% = dep; "sequential install finished");
373    }
374    debug!("all sequential installs have finished");
375    Ok(results)
376}
377
378/// Install a single dependency.
379///
380/// This function checks the integrity of the dependency if found on disk, downloads the dependency
381/// (zip file or cloning repo) if not already present, unzips the zip file if necessary, installs
382/// sub-dependencies and generates the lockfile entry.
383///
384/// If no lockfile entry is provided, the dependency is installed from the config object and
385/// integrity checks are skipped.
386pub async fn install_dependency(
387    dependency: &Dependency,
388    lock: Option<&LockEntry>,
389    deps: impl AsRef<Path>,
390    force_version: Option<String>,
391    recursive_deps: bool,
392    progress: InstallProgress,
393) -> Result<LockEntry> {
394    if let Some(lock) = lock {
395        debug!(dep:% = dependency; "installing based on lock entry");
396        match check_dependency_integrity(lock, &deps).await? {
397            DependencyStatus::Installed => {
398                info!(dep:% = dependency; "skipped install, dependency already up-to-date with lockfile");
399                progress.update_all(dependency.into());
400
401                return Ok(lock.clone());
402            }
403            DependencyStatus::FailedIntegrity => match dependency {
404                Dependency::Http(_) => {
405                    info!(dep:% = dependency; "dependency failed integrity check, reinstalling");
406                    progress.log(format!(
407                        "Dependency {dependency} failed integrity check, reinstalling"
408                    ));
409                    // we know the folder exists because otherwise we would have gotten
410                    // `Missing`
411                    delete_dependency_files(dependency, &deps).await?;
412                    debug!(dep:% = dependency; "removed dependency folder");
413                    // we won't need to retrieve the version number so we mark it as done
414                    progress.versions.send(dependency.into()).ok();
415                }
416                Dependency::Git(_) => {
417                    let commit = &lock.as_git().expect("lock entry should be of type git").rev;
418                    info!(dep:% = dependency, commit; "dependency failed integrity check, resetting to commit");
419                    progress.log(format!(
420                        "Dependency {dependency} failed integrity check, resetting to commit {commit}"
421                    ));
422
423                    reset_git_dependency(
424                        lock.as_git().expect("lock entry should be of type git"),
425                        &deps,
426                    )
427                    .await?;
428                    debug!(dep:% = dependency; "reset git dependency");
429                    // dependency should now be at the correct commit, we can exit
430                    progress.update_all(dependency.into());
431
432                    return Ok(lock.clone());
433                }
434            },
435            DependencyStatus::Missing => {
436                // make sure there is no existing directory for the dependency
437                if let Some(path) = dependency.install_path(&deps).await {
438                    fs::remove_dir_all(&path)
439                        .await
440                        .map_err(|e| InstallError::IOError { path, source: e })?;
441                }
442                info!(dep:% = dependency; "dependency is missing, installing");
443                // we won't need to retrieve the version number so we mark it as done
444                progress.versions.send(dependency.into()).ok();
445            }
446        }
447        install_dependency_inner(
448            &InstallInfo::from_lock(lock.clone(), dependency.project_root()).await?,
449            lock.install_path(&deps),
450            recursive_deps,
451            progress,
452        )
453        .await
454    } else {
455        // no lockfile entry, install from config object
456        debug!(dep:% = dependency; "no lockfile entry, installing based on config");
457        // make sure there is no existing directory for the dependency
458        if let Some(path) = dependency.install_path(&deps).await {
459            fs::remove_dir_all(&path)
460                .await
461                .map_err(|e| InstallError::IOError { path, source: e })?;
462        }
463
464        let (download, version) = match dependency.url() {
465            // for git dependencies and http dependencies which have a custom url, we use the
466            // version requirement string as version, because in that case a version requirement has
467            // little sense (we can't automatically bump the version)
468            Some(url) => (
469                DownloadUrl { url: url.clone(), private: false },
470                dependency.version_req().to_string(),
471            ),
472            None => {
473                let version = match force_version {
474                    Some(v) => v,
475                    None => get_latest_supported_version(dependency).await?,
476                };
477                (get_dependency_url_remote(dependency, &version).await?, version)
478            }
479        };
480        debug!(dep:% = dependency, version; "resolved version");
481        debug!(dep:% = dependency, url:? = download; "resolved download URL");
482        // indicate that we have retrieved the version number
483        progress.versions.send(dependency.into()).ok();
484
485        let info = match &dependency {
486            Dependency::Http(dep) => {
487                if download.private {
488                    InstallInfo::Private(
489                        HttpInstallInfo::builder()
490                            .name(&dep.name)
491                            .version(&version)
492                            .url(download.url)
493                            .build(),
494                    )
495                } else {
496                    HttpInstallInfo::builder()
497                        .name(&dep.name)
498                        .version(&version)
499                        .url(download.url)
500                        .build()
501                        .into()
502                }
503            }
504            Dependency::Git(dep) => GitInstallInfo::builder()
505                .name(&dep.name)
506                .version(&version)
507                .git(download.url)
508                .maybe_identifier(dep.identifier.clone())
509                .build()
510                .into(),
511        };
512        let install_path = format_install_path(dependency.name(), &version, &deps);
513        debug!(dep:% = dependency; "installing to path {install_path:?}");
514        install_dependency_inner(&info, install_path, recursive_deps, progress).await
515    }
516}
517
518/// Check the integrity of a dependency that was installed.
519///
520/// If any file has changed in the dependency directory (except ignored files and any `.git`
521/// directory), the integrity check will fail.
522pub async fn check_dependency_integrity(
523    lock: &LockEntry,
524    deps: impl AsRef<Path>,
525) -> Result<DependencyStatus> {
526    match lock {
527        LockEntry::Http(lock) => check_http_dependency(lock, deps).await,
528        LockEntry::Private(lock) => check_http_dependency(lock, deps).await,
529        LockEntry::Git(lock) => check_git_dependency(lock, deps).await,
530    }
531}
532
533/// Ensure that the dependencies directory exists.
534///
535/// If the directory does not exist, it will be created.
536pub fn ensure_dependencies_dir(path: impl AsRef<Path>) -> Result<()> {
537    let path = path.as_ref();
538    if !path.exists() {
539        debug!(path:?; "dependencies dir doesn't exist, creating it");
540        std::fs::create_dir(path)
541            .map_err(|e| InstallError::IOError { path: path.to_path_buf(), source: e })?;
542    }
543    Ok(())
544}
545
546/// Install a single dependency.
547async fn install_dependency_inner(
548    dep: &InstallInfo,
549    path: impl AsRef<Path>,
550    subdependencies: bool,
551    progress: InstallProgress,
552) -> Result<LockEntry> {
553    match dep {
554        InstallInfo::Http(dep) => {
555            let (zip_integrity, integrity) =
556                install_http_dependency(dep, path, subdependencies, progress).await?;
557            Ok(HttpLockEntry::builder()
558                .name(&dep.name)
559                .version(&dep.version)
560                .url(&dep.url)
561                .checksum(zip_integrity.to_string())
562                .integrity(integrity.to_string())
563                .build()
564                .into())
565        }
566        InstallInfo::Private(dep) => {
567            let (zip_integrity, integrity) =
568                install_http_dependency(dep, path, subdependencies, progress).await?;
569            Ok(PrivateLockEntry::builder()
570                .name(&dep.name)
571                .version(&dep.version)
572                .checksum(zip_integrity.to_string())
573                .integrity(integrity.to_string())
574                .build()
575                .into())
576        }
577        InstallInfo::Git(dep) => {
578            // if the dependency was specified without a commit hash and we didn't have a lockfile,
579            // clone the default branch
580            let commit = clone_repo(&dep.git, dep.identifier.as_ref(), &path).await?;
581            progress.downloads.send(dep.into()).ok();
582
583            if subdependencies {
584                debug!(dep:% = dep; "installing subdependencies");
585                install_subdependencies(&path, dep.project_root.as_ref()).await?;
586                debug!(dep:% = dep; "finished installing subdependencies");
587            }
588            progress.unzip.send(dep.into()).ok();
589            progress.subdependencies.send(dep.into()).ok();
590            progress.integrity.send(dep.into()).ok();
591            Ok(GitLockEntry::builder()
592                .name(&dep.name)
593                .version(&dep.version)
594                .git(&dep.git)
595                .rev(commit)
596                .build()
597                .into())
598        }
599    }
600}
601
602/// Install subdependencies of a dependency.
603///
604/// This function checks for a `.gitmodules` file in the dependency directory and clones the
605/// submodules if it exists. If a valid Soldeer config is found at the project root (optionally a
606/// sub-dir of the dependency folder), the soldeer dependencies are installed.
607fn install_subdependencies(
608    path: impl AsRef<Path>,
609    project_root: Option<&PathBuf>,
610) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
611    let path = path.as_ref().to_path_buf();
612    Box::pin(async move {
613        let gitmodules_path = path.join(".gitmodules");
614        if fs::metadata(&gitmodules_path).await.is_ok() {
615            debug!(path:?; "found .gitmodules, installing subdependencies with git");
616            if fs::metadata(path.join(".git")).await.is_ok() {
617                debug!(path:?; "subdependency contains .git directory, cloning submodules");
618                run_git_command(&["submodule", "update", "--init"], Some(&path)).await?;
619                // we need to recurse into each of the submodules to ensure any soldeer sub-deps
620                // of those are also installed
621                let submodules = get_submodules(&path).await?;
622                let mut set = JoinSet::new();
623                for (_, submodule) in submodules {
624                    let sub_path = path.join(submodule.path);
625                    debug!(sub_path:?; "recursing into the git submodule");
626                    set.spawn(async move { install_subdependencies(sub_path, None).await });
627                }
628                while let Some(res) = set.join_next().await {
629                    res??;
630                }
631            } else {
632                debug!(path:?; "subdependency has git submodules configuration but is not a git repository");
633                let submodule_paths = reinit_submodules(&path).await?;
634                // we need to recurse into each of the submodules to ensure any soldeer sub-deps
635                // of those are also installed
636                let mut set = JoinSet::new();
637                for sub_path in submodule_paths {
638                    debug!(sub_path:?; "recursing into the git submodule");
639                    set.spawn(async move { install_subdependencies(sub_path, None).await });
640                }
641                while let Some(res) = set.join_next().await {
642                    res??;
643                }
644            }
645        }
646        // if there's a suitable soldeer config, install the soldeer deps
647        let path = get_subdependency_root(path, project_root).await?;
648        if detect_config_location(&path).is_some() {
649            // install subdependencies
650            debug!(path:?; "found soldeer config, installing subdependencies");
651            install_subdependencies_inner(Paths::from_root(path)?).await?;
652        }
653        Ok(())
654    })
655}
656
657/// Inner logic for installing subdependencies at a given path.
658///
659/// This is a similar implementation to the one found in `soldeer_commands` but
660/// simplified.
661async fn install_subdependencies_inner(paths: Paths) -> Result<()> {
662    let config = read_soldeer_config(&paths.config)?;
663    ensure_dependencies_dir(&paths.dependencies)?;
664    let (dependencies, _) = read_config_deps(&paths.config)?;
665    let lockfile = read_lockfile(&paths.lock)?;
666    let (progress, _) = InstallProgress::new(); // not used at the moment
667    let _ = install_dependencies(
668        &dependencies,
669        &lockfile.entries,
670        &paths.dependencies,
671        config.recursive_deps,
672        progress,
673    )
674    .await?;
675    Ok(())
676}
677
678/// Download and unzip an HTTP dependency
679async fn install_http_dependency(
680    dep: &HttpInstallInfo,
681    path: impl AsRef<Path>,
682    subdependencies: bool,
683    progress: InstallProgress,
684) -> Result<(IntegrityChecksum, IntegrityChecksum)> {
685    let path = path.as_ref();
686    let zip_path = download_file(
687        &dep.url,
688        path.parent().expect("dependency install path should have a parent"),
689        &format!("{}-{}", dep.name, dep.version),
690    )
691    .await?;
692    progress.downloads.send(dep.into()).ok();
693
694    let zip_integrity = tokio::task::spawn_blocking({
695        let zip_path = zip_path.clone();
696        move || hash_file(zip_path)
697    })
698    .await?
699    .map_err(|e| InstallError::IOError { path: zip_path.clone(), source: e })?;
700    if let Some(checksum) = &dep.checksum {
701        if checksum != &zip_integrity.to_string() {
702            return Err(InstallError::ZipIntegrityError {
703                path: zip_path.clone(),
704                expected: checksum.to_string(),
705                actual: zip_integrity.to_string(),
706            });
707        }
708        debug!(zip_path:?; "archive integrity check successful");
709    } else {
710        debug!(zip_path:?; "no checksum available for archive integrity check");
711    }
712    unzip_file(&zip_path, path).await?;
713    progress.unzip.send(dep.into()).ok();
714
715    if subdependencies {
716        debug!(dep:% = dep; "installing subdependencies");
717        install_subdependencies(path, dep.project_root.as_ref()).await?;
718        debug!(dep:% = dep; "finished installing subdependencies");
719    }
720    progress.subdependencies.send(dep.into()).ok();
721
722    let integrity = tokio::task::spawn_blocking({
723        let path = path.to_path_buf();
724        move || hash_folder(&path)
725    })
726    .await?
727    .map_err(|e| InstallError::IOError { path: path.to_path_buf(), source: e })?;
728    debug!(dep:% = dep, checksum = integrity.0; "integrity checksum computed");
729    progress.integrity.send(dep.into()).ok();
730    Ok((zip_integrity, integrity))
731}
732
733/// Retrieve a map of git submodules for a path by looking at the `.gitmodules` file.
734async fn get_submodules(path: &PathBuf) -> Result<HashMap<String, Submodule>> {
735    let submodules_config =
736        run_git_command(&["config", "-f", ".gitmodules", "-l"], Some(path)).await?;
737    let mut submodules = HashMap::<String, Submodule>::new();
738    for config_line in submodules_config.trim().lines() {
739        let (item, value) = config_line.split_once('=').expect("config format should be valid");
740        let Some(item) = item.strip_prefix("submodule.") else {
741            continue;
742        };
743        let (submodule_name, item_name) =
744            item.rsplit_once('.').expect("config format should be valid");
745        let entry = submodules.entry(submodule_name.to_string()).or_default();
746        match item_name {
747            "path" => entry.path = value.to_string(),
748            "url" => entry.url = value.to_string(),
749            "branch" => entry.branch = Some(value.to_string()),
750            _ => {}
751        }
752    }
753    Ok(submodules)
754}
755
756/// Re-add submodules found in a `.gitmodules` when the folder has to be re-initialized as a git
757/// repo.
758///
759/// The file is parsed, and each module is added again with `git submodule add`.
760async fn reinit_submodules(path: &PathBuf) -> Result<Vec<PathBuf>> {
761    debug!(path:?; "running git init");
762    run_git_command(&["init"], Some(path)).await?;
763    let submodules = get_submodules(path).await?;
764    debug!(submodules:?, path:?; "got submodules config");
765    let mut foundry_lock = forge::Lockfile::new(path);
766    if foundry_lock.read().is_ok() {
767        debug!(path:?; "foundry lockfile exists");
768    }
769    let mut out = Vec::new();
770    for (submodule_name, submodule) in submodules {
771        // make sure to remove the path if it already exists
772        let dest_path = path.join(&submodule.path);
773        fs::remove_dir_all(&dest_path).await.ok(); // ignore error if folder doesn't exist
774        let mut args = vec!["submodule", "add", "-f", "--name", &submodule_name];
775        if let Some(branch) = &submodule.branch {
776            args.push("-b");
777            args.push(branch);
778        }
779        args.push(&submodule.url);
780        args.push(&submodule.path);
781        run_git_command(args, Some(path)).await?;
782        if let Some(
783            forge::DepIdentifier::Branch { rev, .. } |
784            forge::DepIdentifier::Tag { rev, .. } |
785            forge::DepIdentifier::Rev { rev },
786        ) = foundry_lock.get(Path::new(&submodule.path))
787        {
788            debug!(submodule_name, path:?; "found corresponding item in foundry lockfile");
789            run_git_command(["checkout", rev], Some(&dest_path)).await?;
790            debug!(submodule_name, path:?; "submodule checked out at {rev}");
791        }
792        debug!(submodule_name, path:?; "added submodule");
793        out.push(path.join(submodule.path));
794    }
795    Ok(out)
796}
797
798/// Check the integrity of an HTTP dependency.
799///
800/// This function hashes the contents of the dependency directory and compares it with the lockfile
801/// entry.
802async fn check_http_dependency(
803    lock: &impl Integrity,
804    deps: impl AsRef<Path>,
805) -> Result<DependencyStatus> {
806    let path = lock.install_path(deps);
807    if fs::metadata(&path).await.is_err() {
808        return Ok(DependencyStatus::Missing);
809    }
810    let current_hash = tokio::task::spawn_blocking({
811        let path = path.clone();
812        move || hash_folder(&path)
813    })
814    .await?
815    .map_err(|e| InstallError::IOError { path: path.to_path_buf(), source: e })?;
816    let Some(integrity) = lock.integrity() else {
817        return Err(LockError::MissingField {
818            field: "integrity".to_string(),
819            dep: path.to_string_lossy().to_string(),
820        }
821        .into())
822    };
823    if &current_hash.to_string() != integrity {
824        debug!(path:?, expected = integrity, computed = current_hash.0; "integrity checksum mismatch");
825        return Ok(DependencyStatus::FailedIntegrity);
826    }
827    Ok(DependencyStatus::Installed)
828}
829
830/// Check the integrity of a git dependency.
831///
832/// This function checks that the dependency is a git repository and that the current commit is the
833/// one specified in the lockfile entry.
834async fn check_git_dependency(
835    lock: &GitLockEntry,
836    deps: impl AsRef<Path>,
837) -> Result<DependencyStatus> {
838    let path = lock.install_path(deps);
839    if fs::metadata(&path).await.is_err() {
840        return Ok(DependencyStatus::Missing);
841    }
842    // check that the location is a git repository
843    let top_level = match run_git_command(
844        &["rev-parse", "--show-toplevel", path.to_string_lossy().as_ref()],
845        Some(&path),
846    )
847    .await
848    {
849        Ok(top_level) => {
850            // stdout contains the path twice, we only keep the first item
851            PathBuf::from(top_level.split_whitespace().next().unwrap_or_default())
852        }
853        Err(_) => {
854            // error getting the top level directory, assume the directory is not a git repository
855            debug!(path:?; "`git rev-parse --show-toplevel` failed");
856            return Ok(DependencyStatus::Missing);
857        }
858    };
859    let top_level = top_level.to_slash_lossy();
860    // compare the top level directory to the install path
861
862    let absolute_path = canonicalize(&path)
863        .await
864        .map_err(|e| InstallError::IOError { path: path.clone(), source: e })?;
865    if top_level.trim() != absolute_path.to_slash_lossy() {
866        // the top level directory is not the install path, assume the directory is not a git
867        // repository
868        debug!(path:?; "dependency's toplevel dir is outside of dependency folder: not a git repo");
869        return Ok(DependencyStatus::Missing);
870    }
871    // for git dependencies, the `rev` field holds the commit hash
872    match run_git_command(&["diff", "--exit-code", &lock.rev], Some(&path)).await {
873        Ok(_) => Ok(DependencyStatus::Installed),
874        Err(_) => {
875            debug!(path:?, rev = lock.rev; "git repo has non-empty diff compared to lockfile rev");
876            Ok(DependencyStatus::FailedIntegrity)
877        }
878    }
879}
880
881/// Reset a git dependency to the commit specified in the lockfile entry.
882///
883/// This function runs `git reset --hard <commit>` and `git clean -fd` in the git dependency's
884/// directory.
885async fn reset_git_dependency(lock: &GitLockEntry, deps: impl AsRef<Path>) -> Result<()> {
886    let path = lock.install_path(deps);
887    run_git_command(&["reset", "--hard", &lock.rev], Some(&path)).await?;
888    run_git_command(&["clean", "-fd"], Some(&path)).await?;
889    Ok(())
890}
891
892/// Normalize and check the path to a subdependency's project root.
893///
894/// The combination of the subdependency path with the relative path to the root must be at or below
895/// the level of the subdependency, to avoid directory traversal.
896async fn get_subdependency_root(
897    subdependency_path: PathBuf,
898    relative_root: Option<&PathBuf>,
899) -> Result<PathBuf> {
900    let path = match relative_root {
901        Some(relative_root) => {
902            let tentative_path =
903                canonicalize(subdependency_path.join(relative_root)).await.map_err(|_| {
904                    InstallError::ConfigError(ConfigError::InvalidProjectRoot {
905                        project_root: relative_root.to_owned(),
906                        dep_path: subdependency_path.clone(),
907                    })
908                })?;
909            // final path must be below the dependency's folder
910            let path_with_slashes = subdependency_path.to_slash_lossy().into_owned();
911            if !tentative_path.to_slash_lossy().starts_with(&path_with_slashes) {
912                return Err(InstallError::ConfigError(ConfigError::InvalidProjectRoot {
913                    project_root: relative_root.to_owned(),
914                    dep_path: subdependency_path.clone(),
915                }));
916            }
917            tentative_path
918        }
919        None => subdependency_path,
920    };
921    Ok(path)
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927    use crate::config::{GitDependency, HttpDependency};
928    use mockito::{Matcher, Server, ServerGuard};
929    use temp_env::async_with_vars;
930    use testdir::testdir;
931
932    async fn mock_api_server() -> ServerGuard {
933        let mut server = Server::new_async().await;
934        let data = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3389,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"},{"created_at":"2024-07-03T14:44:59.729623Z","deleted":false,"downloads":5290,"id":"fa5160fc-ba7b-40fd-8e99-8becd6dadbe4","internal_name":"forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_1_03-07-2024_14:44:59_forge-std-v1.9.1.zip","version":"1.9.1"},{"created_at":"2024-07-03T14:44:58.148723Z","deleted":false,"downloads":21,"id":"b463683a-c4b4-40bf-b707-1c4eb343c4d2","internal_name":"forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/v1_9_0_03-07-2024_14:44:57_forge-std-v1.9.0.zip","version":"1.9.0"}],"status":"success"}"#;
935        server
936            .mock("GET", "/api/v1/revision")
937            .match_query(Matcher::Any)
938            .with_header("content-type", "application/json")
939            .with_body(data)
940            .create_async()
941            .await;
942        let data2 = r#"{"data":[{"created_at":"2024-08-06T17:31:25.751079Z","deleted":false,"downloads":3391,"id":"660132e6-4902-4804-8c4b-7cae0a648054","internal_name":"forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","project_id":"37adefe5-9bc6-4777-aaf2-e56277d1f30b","url":"https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip","version":"1.9.2"}],"status":"success"}"#;
943        server
944            .mock("GET", "/api/v1/revision-cli")
945            .match_query(Matcher::Any)
946            .with_header("content-type", "application/json")
947            .with_body(data2)
948            .create_async()
949            .await;
950        server
951    }
952
953    async fn mock_api_private() -> ServerGuard {
954        let mut server = Server::new_async().await;
955        let data = r#"{"data":[{"created_at":"2025-09-28T12:36:09.526660Z","deleted":false,"downloads":0,"file_size":65083,"id":"0440c261-8cdf-4738-9139-c4dc7b0c7f3e","internal_name":"test-private/0_1_0_28-09-2025_12:36:08_test-private.zip","private":true,"project_id":"14f419e7-2d64-49e4-86b9-b44b36627786","uploader":"bf8e75f4-0c36-4bcb-a23b-2682df92f176","url":"https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip","version":"0.1.0"}],"status":"success"}"#;
956        server
957            .mock("GET", "/api/v1/revision")
958            .match_query(Matcher::Any)
959            .with_header("content-type", "application/json")
960            .with_body(data)
961            .create_async()
962            .await;
963        let data2 = r#"{"data":[{"created_at":"2025-09-28T12:36:09.526660Z","deleted":false,"id":"0440c261-8cdf-4738-9139-c4dc7b0c7f3e","internal_name":"test-private/0_1_0_28-09-2025_12:36:08_test-private.zip","private":true,"project_id":"14f419e7-2d64-49e4-86b9-b44b36627786","url":"https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip","version":"0.1.0"}],"status":"success"}"#;
964        server
965            .mock("GET", "/api/v1/revision-cli")
966            .match_query(Matcher::Any)
967            .with_header("content-type", "application/json")
968            .with_body(data2)
969            .create_async()
970            .await;
971        server
972    }
973
974    #[tokio::test]
975    async fn test_check_http_dependency() {
976        let lock = HttpLockEntry::builder()
977            .name("lib1")
978            .version("1.0.0")
979            .url("https://example.com/zip.zip")
980            .checksum("")
981            .integrity("beef")
982            .build();
983        let dir = testdir!();
984        let path = dir.join("lib1-1.0.0");
985        fs::create_dir(&path).await.unwrap();
986        fs::write(path.join("test.txt"), "foobar").await.unwrap();
987        let res = check_http_dependency(&lock, &dir).await;
988        assert!(res.is_ok(), "{res:?}");
989        assert_eq!(res.unwrap(), DependencyStatus::FailedIntegrity);
990
991        let lock = HttpLockEntry::builder()
992            .name("lib2")
993            .version("1.0.0")
994            .url("https://example.com/zip.zip")
995            .checksum("")
996            .integrity("")
997            .build();
998        let res = check_http_dependency(&lock, &dir).await;
999        assert!(res.is_ok(), "{res:?}");
1000        assert_eq!(res.unwrap(), DependencyStatus::Missing);
1001
1002        let hash = hash_folder(&path).unwrap();
1003        let lock = HttpLockEntry::builder()
1004            .name("lib1")
1005            .version("1.0.0")
1006            .url("https://example.com/zip.zip")
1007            .checksum("")
1008            .integrity(hash.to_string())
1009            .build();
1010        let res = check_http_dependency(&lock, &dir).await;
1011        assert!(res.is_ok(), "{res:?}");
1012        assert_eq!(res.unwrap(), DependencyStatus::Installed);
1013    }
1014
1015    #[tokio::test]
1016    async fn test_check_git_dependency() {
1017        // happy path
1018        let dir = testdir!();
1019        let path = &dir.join("test-repo-1.0.0");
1020        let rev = clone_repo("https://github.com/beeb/test-repo.git", None, &path).await.unwrap();
1021        let lock =
1022            GitLockEntry::builder().name("test-repo").version("1.0.0").git("").rev(rev).build();
1023        let res = check_git_dependency(&lock, &dir).await;
1024        assert!(res.is_ok(), "{res:?}");
1025        assert_eq!(res.unwrap(), DependencyStatus::Installed);
1026
1027        // replace contents of existing file, diff is not empty
1028        fs::write(path.join("foo.txt"), "foo").await.unwrap();
1029        let res = check_git_dependency(&lock, &dir).await;
1030        assert!(res.is_ok(), "{res:?}");
1031        assert_eq!(res.unwrap(), DependencyStatus::FailedIntegrity);
1032
1033        // wrong commit is checked out
1034        let lock = GitLockEntry::builder()
1035            .name("test-repo")
1036            .version("1.0.0")
1037            .git("")
1038            .rev("78c2f6a1a54db26bab6c3f501854a1564eb3707f")
1039            .build();
1040        let res = check_git_dependency(&lock, &dir).await;
1041        assert!(res.is_ok(), "{res:?}");
1042        assert_eq!(res.unwrap(), DependencyStatus::FailedIntegrity);
1043
1044        // missing folder
1045        let lock = GitLockEntry::builder().name("lib1").version("1.0.0").git("").rev("").build();
1046        let res = check_git_dependency(&lock, &dir).await;
1047        assert!(res.is_ok(), "{res:?}");
1048        assert_eq!(res.unwrap(), DependencyStatus::Missing);
1049
1050        // remove .git folder -> not a git repo
1051        let lock =
1052            GitLockEntry::builder().name("test-repo").version("1.0.0").git("").rev("").build();
1053        fs::remove_dir_all(path.join(".git")).await.unwrap();
1054        let res = check_git_dependency(&lock, &dir).await;
1055        assert!(res.is_ok(), "{res:?}");
1056        assert_eq!(res.unwrap(), DependencyStatus::Missing);
1057    }
1058
1059    #[tokio::test]
1060    async fn test_reset_git_dependency() {
1061        let dir = testdir!();
1062        let path = &dir.join("test-repo-1.0.0");
1063        clone_repo("https://github.com/beeb/test-repo.git", None, &path).await.unwrap();
1064        let lock = GitLockEntry::builder()
1065            .name("test-repo")
1066            .version("1.0.0")
1067            .git("")
1068            .rev("78c2f6a1a54db26bab6c3f501854a1564eb3707f")
1069            .build();
1070        let test = path.join("test.txt");
1071        fs::write(&test, "foobar").await.unwrap();
1072        let res = reset_git_dependency(&lock, &dir).await;
1073        assert!(res.is_ok(), "{res:?}");
1074        // non checked-in file
1075        assert!(fs::metadata(test).await.is_err());
1076        // file that is in `main` but not in `78c2f6a`
1077        assert!(fs::metadata(path.join("foo.txt")).await.is_err());
1078        let commit = run_git_command(&["rev-parse", "--verify", "HEAD"], Some(path))
1079            .await
1080            .unwrap()
1081            .trim()
1082            .to_string();
1083        assert_eq!(commit, "78c2f6a1a54db26bab6c3f501854a1564eb3707f");
1084    }
1085
1086    #[tokio::test]
1087    async fn test_install_dependency_inner_http() {
1088        let dir = testdir!();
1089        let install: InstallInfo = HttpInstallInfo::builder().name("test").version("1.0.0").url("https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip").checksum("94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468").build().into();
1090        let (progress, _) = InstallProgress::new();
1091        let res = install_dependency_inner(&install, &dir, false, progress).await;
1092        assert!(res.is_ok(), "{res:?}");
1093        let lock = res.unwrap();
1094        assert_eq!(lock.name(), "test");
1095        assert_eq!(lock.version(), "1.0.0");
1096        let lock = lock.as_http().unwrap();
1097        assert_eq!(
1098            lock.url,
1099            "https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip"
1100        );
1101        assert_eq!(
1102            lock.checksum,
1103            "94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468"
1104        );
1105        let hash = hash_folder(&dir).unwrap();
1106        assert_eq!(lock.integrity, hash.to_string());
1107    }
1108
1109    #[tokio::test]
1110    async fn test_install_dependency_inner_git() {
1111        let dir = testdir!();
1112        let install: InstallInfo = GitInstallInfo::builder()
1113            .name("test")
1114            .version("1.0.0")
1115            .git("https://github.com/beeb/test-repo.git")
1116            .build()
1117            .into();
1118        let (progress, _) = InstallProgress::new();
1119        let res = install_dependency_inner(&install, &dir, false, progress).await;
1120        assert!(res.is_ok(), "{res:?}");
1121        let lock = res.unwrap();
1122        assert_eq!(lock.name(), "test");
1123        assert_eq!(lock.version(), "1.0.0");
1124        let lock = lock.as_git().unwrap();
1125        assert_eq!(lock.git, "https://github.com/beeb/test-repo.git");
1126        assert_eq!(lock.rev, "d5d72fa135d28b2e8307650b3ea79115183f2406");
1127        assert!(dir.join(".git").exists());
1128    }
1129
1130    #[tokio::test]
1131    async fn test_install_dependency_inner_git_rev() {
1132        let dir = testdir!();
1133        let install: InstallInfo = GitInstallInfo::builder()
1134            .name("test")
1135            .version("1.0.0")
1136            .git("https://github.com/beeb/test-repo.git")
1137            .identifier(GitIdentifier::from_rev("78c2f6a1a54db26bab6c3f501854a1564eb3707f"))
1138            .build()
1139            .into();
1140        let (progress, _) = InstallProgress::new();
1141        let res = install_dependency_inner(&install, &dir, false, progress).await;
1142        assert!(res.is_ok(), "{res:?}");
1143        let lock = res.unwrap();
1144        assert_eq!(lock.name(), "test");
1145        assert_eq!(lock.version(), "1.0.0");
1146        let lock = lock.as_git().unwrap();
1147        assert_eq!(lock.git, "https://github.com/beeb/test-repo.git");
1148        assert_eq!(lock.rev, "78c2f6a1a54db26bab6c3f501854a1564eb3707f");
1149        assert!(dir.join(".git").exists());
1150    }
1151
1152    #[tokio::test]
1153    async fn test_install_dependency_inner_git_branch() {
1154        let dir = testdir!();
1155        let install: InstallInfo = GitInstallInfo::builder()
1156            .name("test")
1157            .version("1.0.0")
1158            .git("https://github.com/beeb/test-repo.git")
1159            .identifier(GitIdentifier::from_branch("dev"))
1160            .build()
1161            .into();
1162        let (progress, _) = InstallProgress::new();
1163        let res = install_dependency_inner(&install, &dir, false, progress).await;
1164        assert!(res.is_ok(), "{res:?}");
1165        let lock = res.unwrap();
1166        assert_eq!(lock.name(), "test");
1167        assert_eq!(lock.version(), "1.0.0");
1168        let lock = lock.as_git().unwrap();
1169        assert_eq!(lock.git, "https://github.com/beeb/test-repo.git");
1170        assert_eq!(lock.rev, "8d903e557e8f1b6e62bde768aa456d4ddfca72c4");
1171        assert!(dir.join(".git").exists());
1172    }
1173
1174    #[tokio::test]
1175    async fn test_install_dependency_inner_git_tag() {
1176        let dir = testdir!();
1177        let install: InstallInfo = GitInstallInfo::builder()
1178            .name("test")
1179            .version("1.0.0")
1180            .git("https://github.com/beeb/test-repo.git")
1181            .identifier(GitIdentifier::from_tag("v0.1.0"))
1182            .build()
1183            .into();
1184        let (progress, _) = InstallProgress::new();
1185        let res = install_dependency_inner(&install, &dir, false, progress).await;
1186        assert!(res.is_ok(), "{res:?}");
1187        let lock = res.unwrap();
1188        assert_eq!(lock.name(), "test");
1189        assert_eq!(lock.version(), "1.0.0");
1190        let lock = lock.as_git().unwrap();
1191        assert_eq!(lock.git, "https://github.com/beeb/test-repo.git");
1192        assert_eq!(lock.rev, "78c2f6a1a54db26bab6c3f501854a1564eb3707f");
1193        assert!(dir.join(".git").exists());
1194    }
1195
1196    #[tokio::test]
1197    async fn test_install_dependency_registry() {
1198        let server = mock_api_server().await;
1199        let dir = testdir!();
1200        let dep = HttpDependency::builder().name("forge-std").version_req("1.9.2").build().into();
1201        let (progress, _) = InstallProgress::new();
1202        let res = async_with_vars(
1203            [("SOLDEER_API_URL", Some(server.url()))],
1204            install_dependency(&dep, None, &dir, None, false, progress),
1205        )
1206        .await;
1207        assert!(res.is_ok(), "{res:?}");
1208        let lock = res.unwrap();
1209        assert_eq!(lock.name(), dep.name());
1210        assert_eq!(lock.version(), dep.version_req());
1211        let lock = lock.as_http().unwrap();
1212        assert_eq!(
1213            &lock.url,
1214            "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip"
1215        );
1216        assert_eq!(
1217            lock.checksum,
1218            "20fd008c7c69b6c737cc0284469d1c76497107bc3e004d8381f6d8781cb27980"
1219        );
1220        let hash = hash_folder(lock.install_path(&dir)).unwrap();
1221        assert_eq!(lock.integrity, hash.to_string());
1222    }
1223
1224    #[tokio::test]
1225    async fn test_install_dependency_registry_compatible() {
1226        let server = mock_api_server().await;
1227        let dir = testdir!();
1228        let dep = HttpDependency::builder().name("forge-std").version_req("^1.9.0").build().into();
1229        let (progress, _) = InstallProgress::new();
1230        let res = async_with_vars(
1231            [("SOLDEER_API_URL", Some(server.url()))],
1232            install_dependency(&dep, None, &dir, None, false, progress),
1233        )
1234        .await;
1235        assert!(res.is_ok(), "{res:?}");
1236        let lock = res.unwrap();
1237        assert_eq!(lock.name(), dep.name());
1238        assert_eq!(lock.version(), "1.9.2");
1239        let lock = lock.as_http().unwrap();
1240        assert_eq!(
1241            &lock.url,
1242            "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_9_2_06-08-2024_17:31:25_forge-std-1.9.2.zip"
1243        );
1244        let hash = hash_folder(lock.install_path(&dir)).unwrap();
1245        assert_eq!(lock.integrity, hash.to_string());
1246    }
1247
1248    #[tokio::test]
1249    async fn test_install_dependency_http() {
1250        let dir = testdir!();
1251        let dep = HttpDependency::builder().name("test").version_req("1.0.0").url("https://github.com/mario-eth/soldeer/archive/8585a7ec85a29889cec8d08f4770e15ec4795943.zip").build().into();
1252        let (progress, _) = InstallProgress::new();
1253        let res = install_dependency(&dep, None, &dir, None, false, progress).await;
1254        assert!(res.is_ok(), "{res:?}");
1255        let lock = res.unwrap();
1256        assert_eq!(lock.name(), dep.name());
1257        assert_eq!(lock.version(), dep.version_req());
1258        let lock = lock.as_http().unwrap();
1259        assert_eq!(&lock.url, dep.url().unwrap());
1260        assert_eq!(
1261            lock.checksum,
1262            "94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468"
1263        );
1264        let hash = hash_folder(lock.install_path(&dir)).unwrap();
1265        assert_eq!(lock.integrity, hash.to_string());
1266    }
1267
1268    #[tokio::test]
1269    async fn test_install_dependency_git() {
1270        let dir = testdir!();
1271        let dep = GitDependency::builder()
1272            .name("test")
1273            .version_req("1.0.0")
1274            .git("https://github.com/beeb/test-repo.git")
1275            .build()
1276            .into();
1277        let (progress, _) = InstallProgress::new();
1278        let res = install_dependency(&dep, None, &dir, None, false, progress).await;
1279        assert!(res.is_ok(), "{res:?}");
1280        let lock = res.unwrap();
1281        assert_eq!(lock.name(), dep.name());
1282        assert_eq!(lock.version(), dep.version_req());
1283        let lock = lock.as_git().unwrap();
1284        assert_eq!(&lock.git, dep.url().unwrap());
1285        assert_eq!(lock.rev, "d5d72fa135d28b2e8307650b3ea79115183f2406");
1286    }
1287
1288    #[tokio::test]
1289    async fn test_install_dependency_private() {
1290        let server = mock_api_private().await;
1291        let dir = testdir!();
1292        let dep =
1293            HttpDependency::builder().name("test-private").version_req("0.1.0").build().into();
1294        let (progress, _) = InstallProgress::new();
1295        let res = async_with_vars(
1296            [("SOLDEER_API_URL", Some(server.url()))],
1297            install_dependency(&dep, None, &dir, None, false, progress),
1298        )
1299        .await;
1300        assert!(res.is_ok(), "{res:?}");
1301        let lock = res.unwrap();
1302        assert_eq!(lock.name(), dep.name());
1303        assert_eq!(lock.version(), dep.version_req());
1304        let lock = lock.as_private().unwrap();
1305        assert_eq!(
1306            lock.checksum,
1307            "94a73dbe106f48179ea39b00d42e5d4dd96fdc6252caa3a89ce7efdaec0b9468"
1308        );
1309        let hash = hash_folder(lock.install_path(&dir)).unwrap();
1310        assert_eq!(lock.integrity, hash.to_string());
1311    }
1312}