1use 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#[derive(Debug)]
53pub struct InstallMonitoring {
54 pub logs: mpsc::UnboundedReceiver<String>,
56
57 pub versions: mpsc::UnboundedReceiver<DependencyName>,
59
60 pub downloads: mpsc::UnboundedReceiver<DependencyName>,
62
63 pub unzip: mpsc::UnboundedReceiver<DependencyName>,
65
66 pub subdependencies: mpsc::UnboundedReceiver<DependencyName>,
68
69 pub integrity: mpsc::UnboundedReceiver<DependencyName>,
71}
72
73#[derive(Debug, Clone)]
75pub struct InstallProgress {
76 pub logs: mpsc::UnboundedSender<String>,
78
79 pub versions: mpsc::UnboundedSender<DependencyName>,
81
82 pub downloads: mpsc::UnboundedSender<DependencyName>,
84
85 pub unzip: mpsc::UnboundedSender<DependencyName>,
87
88 pub subdependencies: mpsc::UnboundedSender<DependencyName>,
90
91 pub integrity: mpsc::UnboundedSender<DependencyName>,
93}
94
95impl InstallProgress {
96 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156pub enum DependencyStatus {
157 Missing,
159
160 FailedIntegrity,
162
163 Installed,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
169#[builder(on(String, into))]
170struct HttpInstallInfo {
171 name: String,
173
174 version: String,
177
178 url: String,
180
181 checksum: Option<String>,
183
184 project_root: Option<PathBuf>,
189}
190
191impl fmt::Display for HttpInstallInfo {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 write!(f, "{}-{}", self.name, self.version)
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Hash, bon::Builder)]
200#[builder(on(String, into))]
201struct GitInstallInfo {
202 name: String,
204
205 version: String,
207
208 git: String,
210
211 identifier: Option<GitIdentifier>,
214
215 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Display)]
233enum InstallInfo {
234 Http(HttpInstallInfo),
236
237 Git(GitInstallInfo),
239
240 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 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#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
300struct Submodule {
301 url: String,
302 path: String,
303 branch: Option<String>,
304}
305
306pub 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
351pub 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
378pub 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 delete_dependency_files(dependency, &deps).await?;
412 debug!(dep:% = dependency; "removed dependency folder");
413 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 progress.update_all(dependency.into());
431
432 return Ok(lock.clone());
433 }
434 },
435 DependencyStatus::Missing => {
436 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 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 debug!(dep:% = dependency; "no lockfile entry, installing based on config");
457 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 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 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
518pub 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
533pub 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
546async 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 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
602fn 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 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 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 let path = get_subdependency_root(path, project_root).await?;
648 if detect_config_location(&path).is_some() {
649 debug!(path:?; "found soldeer config, installing subdependencies");
651 install_subdependencies_inner(Paths::from_root(path)?).await?;
652 }
653 Ok(())
654 })
655}
656
657async 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(); let _ = install_dependencies(
668 &dependencies,
669 &lockfile.entries,
670 &paths.dependencies,
671 config.recursive_deps,
672 progress,
673 )
674 .await?;
675 Ok(())
676}
677
678async 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
733async 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
756async 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 let dest_path = path.join(&submodule.path);
773 fs::remove_dir_all(&dest_path).await.ok(); 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
798async 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 ¤t_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
830async 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 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 PathBuf::from(top_level.split_whitespace().next().unwrap_or_default())
852 }
853 Err(_) => {
854 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 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 debug!(path:?; "dependency's toplevel dir is outside of dependency folder: not a git repo");
869 return Ok(DependencyStatus::Missing);
870 }
871 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
881async 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
892async 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 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 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 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 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 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 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 assert!(fs::metadata(test).await.is_err());
1076 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}