1#[cfg(feature = "git-resolver")]
11pub(crate) mod cache;
12#[cfg(feature = "git-resolver")]
13pub(crate) mod config;
14pub(crate) mod error;
15#[cfg(feature = "git-resolver")]
16pub(crate) mod fetch;
17#[cfg(feature = "git-resolver")]
18mod git;
19#[cfg(feature = "git-resolver")]
20pub(crate) mod lock;
21#[cfg(feature = "git-resolver")]
22pub(crate) mod policy;
23pub(crate) mod scope;
24#[cfg(feature = "git-resolver")]
25pub(crate) mod trust;
26pub(crate) mod types;
27#[cfg(feature = "git-resolver")]
28pub(crate) mod verify;
29#[cfg(feature = "git-resolver")]
30pub(crate) mod versions;
31
32#[cfg(feature = "git-resolver")]
33use std::collections::BTreeMap;
34#[cfg(feature = "git-resolver")]
35use std::path::Path;
36#[cfg(feature = "git-resolver")]
37use std::path::PathBuf;
38#[cfg(feature = "git-resolver")]
39use std::sync::Arc;
40
41use async_trait::async_trait;
42#[cfg(feature = "git-resolver")]
43use bon::Builder;
44#[cfg(feature = "git-resolver")]
45use futures::future::BoxFuture;
46#[cfg(feature = "git-resolver")]
47use futures::future::FutureExt;
48#[cfg(feature = "git-resolver")]
49use path_clean::PathClean;
50use semver::Version;
51
52#[cfg(feature = "git-resolver")]
53use crate::Lockfile;
54#[cfg(feature = "git-resolver")]
55use crate::Manifest;
56use crate::dependency::DependencyName;
57use crate::dependency::DependencySource;
58#[cfg(feature = "git-resolver")]
59use crate::dependency::GitModulePath;
60#[cfg(feature = "git-resolver")]
61use crate::dependency::GitSelector;
62#[cfg(feature = "git-resolver")]
63use crate::hash::NON_MODULE_CONTENT;
64#[cfg(feature = "git-resolver")]
65use crate::lockfile::DependencyEntry;
66#[cfg(feature = "git-resolver")]
67use crate::lockfile::GitCommit;
68#[cfg(feature = "git-resolver")]
69use crate::lockfile::ResolvedSource;
70use crate::module::Module;
71#[cfg(feature = "git-resolver")]
72use crate::module_walk::ModuleWalkError;
73#[cfg(feature = "git-resolver")]
74use crate::relative_path::RelativePath;
75#[cfg(feature = "git-resolver")]
76use crate::resolver::cache::CacheKey;
77#[cfg(feature = "git-resolver")]
78pub use crate::resolver::config::LargeFileWarning;
79#[cfg(feature = "git-resolver")]
80pub use crate::resolver::config::LargeFileWarningError;
81#[cfg(feature = "git-resolver")]
82pub use crate::resolver::config::ModulesConfig;
83#[cfg(feature = "git-resolver")]
84pub use crate::resolver::config::TrustMode;
85pub use crate::resolver::error::GitRefKind;
86pub use crate::resolver::error::MissingFileKind;
87pub use crate::resolver::error::ResolverError;
88#[cfg(feature = "git-resolver")]
89use crate::resolver::fetch::GitFetcher;
90#[cfg(feature = "git-resolver")]
91pub use crate::resolver::lock::DependencyChange;
92#[cfg(feature = "git-resolver")]
93pub use crate::resolver::lock::DependencyUpdate;
94#[cfg(feature = "git-resolver")]
95pub use crate::resolver::lock::LockfileDiff;
96#[cfg(feature = "git-resolver")]
97pub use crate::resolver::lock::NewSigner;
98#[cfg(feature = "git-resolver")]
99pub use crate::resolver::lock::RelockOutcome;
100#[cfg(feature = "git-resolver")]
101pub use crate::resolver::lock::RelockStats;
102#[cfg(feature = "git-resolver")]
103pub use crate::resolver::lock::partial_relock;
104#[cfg(feature = "git-resolver")]
105pub use crate::resolver::policy::ResolverPolicy;
106pub use crate::resolver::scope::DependencyScope;
107#[cfg(feature = "git-resolver")]
108use crate::resolver::scope::ResolutionMode;
109#[cfg(feature = "git-resolver")]
110pub use crate::resolver::trust::TrustEntry;
111#[cfg(feature = "git-resolver")]
112pub use crate::resolver::trust::TrustStore;
113#[cfg(feature = "git-resolver")]
114pub use crate::resolver::trust::TrustStoreError;
115pub use crate::resolver::types::MaterializedFile;
116pub use crate::resolver::types::ResolvedDependency;
117pub use crate::resolver::types::ResolvedModule;
118pub use crate::resolver::types::ResolvedTree;
119#[cfg(feature = "git-resolver")]
120use crate::resolver::verify::VerifiedModule;
121use crate::symbolic_path::SymbolicPath;
122
123#[async_trait]
125pub trait Resolver: std::fmt::Debug + Send + Sync {
126 async fn materialize(
144 &self,
145 consumer: &Module,
146 path: &SymbolicPath,
147 ) -> Result<MaterializedFile, ResolverError>;
148
149 async fn resolve_tree(&self, consumer: &Module) -> Result<ResolvedTree, ResolverError>;
156
157 async fn discover_versions(
164 &self,
165 name: &DependencyName,
166 source: &DependencySource,
167 scope: DependencyScope,
168 ) -> Result<Vec<Version>, ResolverError>;
169}
170
171#[cfg(feature = "git-resolver")]
172#[derive(Builder, Clone, Debug)]
179pub struct GitResolver {
180 #[builder(into)]
183 cache_root: PathBuf,
184 #[builder(default, into)]
186 policy: Arc<ResolverPolicy>,
187 trust: TrustStore,
189 lockfile: Lockfile,
194}
195
196#[cfg(feature = "git-resolver")]
197impl GitResolver {
198 pub fn cache_root(&self) -> &Path {
200 &self.cache_root
201 }
202
203 pub fn trust_store(&self) -> &TrustStore {
205 &self.trust
206 }
207
208 fn fetcher(&self) -> GitFetcher {
210 GitFetcher::new(self.policy.clone())
211 }
212
213 pub fn lockfile(&self) -> &Lockfile {
215 &self.lockfile
216 }
217
218 fn validate_locked_local(
220 &self,
221 consumer: &Module,
222 name: &DependencyName,
223 path: &Path,
224 ) -> Result<(), ResolverError> {
225 let locked_entry = self
226 .lockfile
227 .find_scoped(&consumer.lockfile_scope, name)
228 .ok_or_else(|| ResolverError::NotInLockfile {
229 dep: name.manifest().to_string(),
230 })?;
231 if let ResolvedSource::Path { path: locked_path } = &locked_entry.source {
232 let locked_resolved = consumer.resolve_local_path(locked_path);
237 if path != locked_resolved {
238 return Err(ResolverError::LockfileSourceMismatch {
239 dep: name.manifest().to_string(),
240 });
241 }
242 } else {
243 return Err(ResolverError::LockfileSourceMismatch {
244 dep: name.manifest().to_string(),
245 });
246 }
247 Ok(())
248 }
249
250 async fn materialize_git(
255 &self,
256 name: &DependencyName,
257 url: &url::Url,
258 scope: DependencyScope,
259 plan: &GitMaterializationPlan,
260 ) -> Result<MaterializedRoot, ResolverError> {
261 let fetcher = self.fetcher();
262 let dep_for_clone = name.clone();
263 let url_for_clone = url.clone();
264 let leaf_for_clone = plan.leaf.clone();
265 let commit_for_clone = plan.commit.clone();
266 let sparse_path = plan.sparse_path.clone();
267 let result = tokio::task::spawn_blocking(move || {
268 fetcher.ensure_materialized(
269 &dep_for_clone,
270 &url_for_clone,
271 commit_for_clone.as_str(),
272 &[sparse_path.as_str()],
273 scope,
274 &leaf_for_clone,
275 )
276 })
277 .await
278 .unwrap();
282
283 if let Err(err) = result {
284 if plan.leaf.starts_with(&self.cache_root)
285 && plan.leaf.exists()
286 && let Err(io_err) = std::fs::remove_dir_all(&plan.leaf)
287 {
288 tracing::warn!(
289 path = %plan.leaf.display(),
290 error = %io_err,
291 "failed to clean up cache leaf after materialization failure",
292 );
293 }
294 return Err(err);
295 }
296
297 Ok(MaterializedRoot::Cached {
298 module_root: plan.module_path.clone(),
299 cache_leaf: plan.leaf.clone(),
300 })
301 }
302
303 fn resolve_dependencies<'a>(
308 &'a self,
309 deps: &'a BTreeMap<DependencyName, DependencySource>,
310 parent_root: &'a Path,
311 parent: Option<&'a ResolvedSource>,
312 chain: &'a mut Vec<(DependencyName, ResolvedSource)>,
313 ) -> BoxFuture<'a, Result<BTreeMap<DependencyName, ResolvedDependency>, ResolverError>> {
314 async move {
315 let mut out = BTreeMap::new();
316 let scope = if parent.is_some() {
317 DependencyScope::Transitive
318 } else {
319 DependencyScope::TopLevel
320 };
321
322 for (name, source) in deps {
323 if matches!(source, DependencySource::LocalPath { .. })
327 && matches!(parent, Some(ResolvedSource::Git { .. }))
328 {
329 return Err(ResolverError::LocalPathInTransitive {
330 dep: name.manifest().to_string(),
331 });
332 }
333
334 if let DependencySource::Git { url, .. } = source {
336 self.policy.check_git_url(name, url, scope)?;
337 }
338
339 let (resolved_source, manifest, module_root) = match source {
341 DependencySource::LocalPath { path, .. } => {
342 let resolved_path = if path.is_absolute() {
343 path.clean()
344 } else {
345 parent_root.join(path).clean()
346 };
347 let manifest = read_manifest(&resolved_path)?;
348 let resolved = ResolvedSource::Path {
349 path: resolved_path.clone(),
350 };
351 let root = MaterializedRoot::Local(resolved_path);
352 (resolved, manifest, root)
353 }
354 DependencySource::Git {
355 url,
356 selector,
357 path,
358 ..
359 } => {
360 let plan = self
361 .plan_git_materialization(
362 name,
363 url,
364 selector,
365 path,
366 scope,
367 ResolutionMode::Fresh,
368 )
369 .await?;
370 let root = self.materialize_git(name, url, scope, &plan).await?;
371 let manifest = read_manifest(&plan.module_path)?;
372 check_tag_manifest_match(
373 plan.path_prefix.as_deref(),
374 plan.selected_version.as_ref(),
375 &manifest.version,
376 )?;
377 let resolved = ResolvedSource::Git {
378 git: url.clone(),
379 commit: plan.commit,
380 path: path.clone(),
381 selector: selector.clone(),
382 };
383 (resolved, manifest, root)
384 }
385 };
386
387 if let Some(at) = chain.iter().position(|(_, s)| *s == resolved_source) {
389 let mut path: Vec<String> =
390 chain[at..].iter().map(|(n, _)| n.manifest().to_string()).collect();
391 path.push(name.manifest().to_string());
392 return Err(ResolverError::Cycle { path });
393 }
394
395 let source_url = resolved_source.source_url();
397 let source_path = resolved_source.source_path();
398 let VerifiedModule { checksum, signer } =
399 crate::resolver::verify::verify(
400 &self.policy,
401 &self.trust,
402 name,
403 module_root.module_root(),
404 Some((&source_url, source_path)),
405 )
406 .inspect_err(|e| {
407 if let MaterializedRoot::Cached { cache_leaf, .. } = &module_root {
408 tracing::warn!(
409 dep = name.manifest(),
410 cache_leaf = %cache_leaf.display(),
411 error = %e,
412 "verification failed; run `sprocket module clean` to remove the cached module",
413 );
414 }
415 })?;
416
417 let child_root = module_root.module_root();
421 chain.push((name.clone(), resolved_source.clone()));
422 let inner = self
423 .resolve_dependencies(
424 &manifest.dependencies,
425 child_root,
426 Some(&resolved_source),
427 chain,
428 )
429 .await
430 .inspect_err(|_| {
431 chain.pop();
432 })?;
433 chain.pop();
434
435 out.insert(name.clone(), ResolvedDependency {
436 source: resolved_source,
437 version: manifest.version,
438 checksum,
439 signer,
440 dependencies: inner,
441 });
442 }
443 Ok(out)
444 }
445 .boxed()
446 }
447
448 async fn resolve_git_selector(
455 &self,
456 name: &DependencyName,
457 url: &url::Url,
458 selector: &GitSelector,
459 path_prefix: Option<&str>,
460 scope: DependencyScope,
461 ) -> Result<(Option<Version>, GitCommit), ResolverError> {
462 let fetcher = self.fetcher();
463 match selector {
464 GitSelector::Version(requirement) => {
465 let dep = name.clone();
466 let url = url.clone();
467 let requirement = requirement.clone();
468 let path_prefix_owned = path_prefix.map(str::to_string);
469 let refs =
470 tokio::task::spawn_blocking(move || fetcher.list_tags(&dep, &url, scope))
471 .await
472 .unwrap()?;
475 let (version, commit) = crate::resolver::versions::resolve_version_to_commit(
476 &refs,
477 path_prefix_owned.as_deref(),
478 &requirement,
479 )
480 .map_err(|e| match e {
481 crate::resolver::versions::VersionError::NoSatisfyingVersion {
482 requirement,
483 considered,
484 } => ResolverError::NoSatisfyingVersion {
485 dep: name.manifest().to_string(),
486 requirement,
487 considered,
488 },
489 })?;
490 Ok((Some(version), commit))
491 }
492 GitSelector::Tag(tag) => {
493 let dep = name.clone();
494 let url = url.clone();
495 let fetcher = self.fetcher();
496 let refs =
497 tokio::task::spawn_blocking(move || fetcher.list_tags(&dep, &url, scope))
498 .await
499 .unwrap()?;
501 let commit =
502 refs.get(tag)
503 .cloned()
504 .ok_or_else(|| ResolverError::UnknownGitRef {
505 dep: name.manifest().to_string(),
506 kind: GitRefKind::Tag,
507 name: tag.clone(),
508 })?;
509 Ok((None, commit))
510 }
511 GitSelector::Branch(branch) => {
512 let dep = name.clone();
513 let url = url.clone();
514 let fetcher = self.fetcher();
515 let refs =
516 tokio::task::spawn_blocking(move || fetcher.list_branches(&dep, &url, scope))
517 .await
518 .unwrap()?;
520 let commit =
521 refs.get(branch)
522 .cloned()
523 .ok_or_else(|| ResolverError::UnknownGitRef {
524 dep: name.manifest().to_string(),
525 kind: GitRefKind::Branch,
526 name: branch.clone(),
527 })?;
528 Ok((None, commit))
529 }
530 GitSelector::Commit(commit) => Ok((None, commit.clone())),
531 }
532 }
533
534 async fn plan_git_materialization(
542 &self,
543 name: &DependencyName,
544 url: &url::Url,
545 selector: &GitSelector,
546 path: &Option<GitModulePath>,
547 scope: DependencyScope,
548 mode: ResolutionMode<'_>,
549 ) -> Result<GitMaterializationPlan, ResolverError> {
550 let path_prefix = path.as_ref().map(GitModulePath::as_str).map(str::to_string);
551
552 let (selected_version, commit) = match mode {
553 ResolutionMode::Locked { lockfile_scope } => {
554 let locked_entry =
555 self.lockfile
556 .find_scoped(lockfile_scope, name)
557 .ok_or_else(|| ResolverError::NotInLockfile {
558 dep: name.manifest().to_string(),
559 })?;
560 let (locked_url, locked_commit, locked_path, locked_selector) =
561 match &locked_entry.source {
562 ResolvedSource::Git {
563 git: lu,
564 commit: lc,
565 path: lp,
566 selector: ls,
567 } => (lu, lc, lp, ls),
568 _ => {
569 return Err(ResolverError::NotInLockfile {
570 dep: name.manifest().to_string(),
571 });
572 }
573 };
574 if url != locked_url
575 || path != locked_path
576 || !locked_selector_satisfies(
577 locked_entry,
578 selector,
579 locked_commit,
580 locked_selector,
581 )
582 {
583 return Err(ResolverError::LockfileSourceMismatch {
584 dep: name.manifest().to_string(),
585 });
586 }
587 (None, locked_commit.clone())
588 }
589 ResolutionMode::Fresh => {
590 self.resolve_git_selector(name, url, selector, path_prefix.as_deref(), scope)
591 .await?
592 }
593 };
594
595 let key = CacheKey::from_git_url(url, &commit);
596 let leaf = key.absolute_path(&self.cache_root);
597 let sparse_path = path_prefix.clone().unwrap_or_else(|| ".".to_string());
598 let module_path = match path.as_ref() {
599 Some(p) => leaf.join(p.as_path()),
600 None => leaf.clone(),
601 };
602
603 Ok(GitMaterializationPlan {
604 selected_version,
605 commit,
606 path_prefix,
607 leaf,
608 sparse_path,
609 module_path,
610 })
611 }
612}
613
614#[cfg(feature = "git-resolver")]
615#[async_trait]
616impl Resolver for GitResolver {
617 async fn materialize(
618 &self,
619 consumer: &Module,
620 path: &SymbolicPath,
621 ) -> Result<MaterializedFile, ResolverError> {
622 let name = path.dep_name();
624 tracing::debug!(dep = %name.manifest(), "materializing symbolic import");
625 let scope = if consumer.lockfile_scope.is_empty() {
626 DependencyScope::TopLevel
627 } else {
628 DependencyScope::Transitive
629 };
630 let source = consumer.manifest.dependencies.get(name).ok_or_else(|| {
631 ResolverError::NotADependency {
632 name: name.manifest().to_string(),
633 }
634 })?;
635
636 if let DependencySource::Git { url, .. } = source {
638 self.policy.check_git_url(name, url, scope)?;
639 }
640
641 let (resolved_source, manifest, module_root) = match source {
643 DependencySource::LocalPath { path, .. } => {
644 let resolved_path = consumer.resolve_local_path(path);
645 self.validate_locked_local(consumer, name, &resolved_path)?;
646 let manifest = read_manifest(&resolved_path)?;
647 let resolved = ResolvedSource::Path {
648 path: resolved_path.clone(),
649 };
650 let root = MaterializedRoot::Local(resolved_path);
651 (resolved, manifest, root)
652 }
653 DependencySource::Git {
654 url,
655 selector,
656 path,
657 ..
658 } => {
659 let plan = self
660 .plan_git_materialization(
661 name,
662 url,
663 selector,
664 path,
665 scope,
666 ResolutionMode::Locked {
667 lockfile_scope: &consumer.lockfile_scope,
668 },
669 )
670 .await?;
671 let root = self.materialize_git(name, url, scope, &plan).await?;
672 let manifest = read_manifest(&plan.module_path)?;
673 check_tag_manifest_match(
674 plan.path_prefix.as_deref(),
675 plan.selected_version.as_ref(),
676 &manifest.version,
677 )?;
678 let resolved = ResolvedSource::Git {
679 git: url.clone(),
680 commit: plan.commit,
681 path: path.clone(),
682 selector: selector.clone(),
683 };
684 (resolved, manifest, root)
685 }
686 };
687
688 let root_path = module_root.module_root();
690 let source_url = resolved_source.source_url();
691 let source_path = resolved_source.source_path();
692 let verified = crate::resolver::verify::verify(
693 &self.policy,
694 &self.trust,
695 name,
696 root_path,
697 Some((&source_url, source_path)),
698 )?;
699
700 crate::resolver::verify::verify_against_lockfile(
702 &self.lockfile,
703 &consumer.lockfile_scope,
704 name,
705 &verified.checksum,
706 verified.signer.as_ref(),
707 )?;
708
709 let (rel, kind) = match path.sub_path() {
711 None => {
712 let p = manifest.entrypoint_filename();
713 (
714 RelativePath::try_from(Path::new(p))?,
715 MissingFileKind::Entrypoint,
716 )
717 }
718 Some(sub) => {
719 let s = sub.display().to_string().replace('\\', "/");
720 let wdl = format!("{s}.wdl");
721 (
722 RelativePath::try_from(Path::new(&wdl))?,
723 MissingFileKind::SubPath,
724 )
725 }
726 };
727
728 if exclude_set(&manifest.exclude)?.is_match(rel.as_path()) {
730 return Err(ResolverError::MissingFile {
731 dep: name.manifest().to_string(),
732 path: rel.as_path().to_path_buf(),
733 kind: MissingFileKind::Excluded,
734 });
735 }
736
737 let canonical =
739 resolve_content_file(module_root.module_root(), &rel, name).map_err(|e| match e {
740 ResolverError::Io { source, .. }
741 if source.kind() == std::io::ErrorKind::NotFound =>
742 {
743 ResolverError::MissingFile {
744 dep: name.manifest().to_string(),
745 path: rel.as_path().to_path_buf(),
746 kind,
747 }
748 }
749 other => other,
750 })?;
751
752 Ok(MaterializedFile {
753 path: canonical,
754 module_root: root_path.to_path_buf(),
755 source: resolved_source,
756 manifest: std::sync::Arc::new(manifest),
757 })
758 }
759
760 async fn resolve_tree(&self, consumer: &Module) -> Result<ResolvedTree, ResolverError> {
761 let mut chain: Vec<(DependencyName, ResolvedSource)> = Vec::new();
764 let dependencies = self
765 .resolve_dependencies(
766 &consumer.manifest.dependencies,
767 &consumer.root,
768 None,
769 &mut chain,
770 )
771 .await?;
772 Ok(ResolvedTree { dependencies })
773 }
774
775 async fn discover_versions(
776 &self,
777 name: &DependencyName,
778 source: &DependencySource,
779 scope: DependencyScope,
780 ) -> Result<Vec<Version>, ResolverError> {
781 match source {
782 DependencySource::Git {
783 url,
784 selector,
785 path,
786 ..
787 } => {
788 let GitSelector::Version(requirement) = selector else {
792 return Ok(Vec::new());
793 };
794
795 let fetcher = self.fetcher();
798 let dep = name.clone();
799 let url = url.clone();
800 let path_prefix = path.as_ref().map(GitModulePath::as_str).map(str::to_string);
801 let requirement = requirement.clone();
802 tokio::task::spawn_blocking(move || -> Result<Vec<Version>, ResolverError> {
803 let refs = fetcher.list_tags(&dep, &url, scope)?;
804 Ok(crate::resolver::versions::filter_matching(
805 &refs,
806 path_prefix.as_deref(),
807 &requirement,
808 ))
809 })
810 .await
811 .unwrap()
815 }
816 DependencySource::LocalPath { path, .. } => {
817 let manifest_path = path.join(crate::MANIFEST_FILENAME);
820 let bytes = std::fs::read(&manifest_path).map_err(|source| ResolverError::Io {
821 path: manifest_path.clone(),
822 source,
823 })?;
824 let manifest = Manifest::parse(&bytes)?;
825 Ok(vec![manifest.version])
826 }
827 }
828 }
829}
830
831#[cfg(feature = "git-resolver")]
832#[derive(Debug)]
834struct GitMaterializationPlan {
835 selected_version: Option<Version>,
837 commit: GitCommit,
839 path_prefix: Option<String>,
841 leaf: PathBuf,
843 sparse_path: String,
845 module_path: PathBuf,
847}
848
849#[cfg(feature = "git-resolver")]
850#[derive(Clone, Debug)]
853enum MaterializedRoot {
854 Local(PathBuf),
856 Cached {
858 module_root: PathBuf,
860 cache_leaf: PathBuf,
862 },
863}
864
865#[cfg(feature = "git-resolver")]
866impl MaterializedRoot {
867 fn module_root(&self) -> &Path {
869 match self {
870 Self::Local(root) => root,
871 Self::Cached { module_root, .. } => module_root,
872 }
873 }
874}
875
876#[cfg(feature = "git-resolver")]
877fn locked_selector_satisfies(
880 entry: &DependencyEntry,
881 selector: &GitSelector,
882 locked_commit: &GitCommit,
883 locked_selector: &GitSelector,
884) -> bool {
885 match selector {
886 GitSelector::Version(requirement) => requirement.matches(&entry.version),
887 GitSelector::Commit(commit) => commit == locked_commit,
888 GitSelector::Tag(tag) => {
889 matches!(locked_selector, GitSelector::Tag(locked) if locked == tag)
890 }
891 GitSelector::Branch(branch) => {
892 matches!(locked_selector, GitSelector::Branch(locked) if locked == branch)
893 }
894 }
895}
896
897#[cfg(feature = "git-resolver")]
898fn resolve_content_file(
902 root: &Path,
903 rel: &crate::relative_path::RelativePath,
904 dep: &DependencyName,
905) -> Result<PathBuf, ResolverError> {
906 if rel
907 .as_str()
908 .split('/')
909 .any(|name| NON_MODULE_CONTENT.contains(&name))
910 {
911 return Err(ResolverError::Walk(
912 ModuleWalkError::SymlinkTargetsMetadata(rel.to_string()),
913 ));
914 }
915
916 let candidate = root.join(rel.as_path());
917 if !candidate.exists() {
918 return Err(ResolverError::Io {
919 path: candidate,
920 source: std::io::Error::new(
921 std::io::ErrorKind::NotFound,
922 "materialized content file does not exist",
923 ),
924 });
925 }
926
927 let meta = std::fs::symlink_metadata(&candidate).map_err(|source| ResolverError::Io {
928 path: candidate.clone(),
929 source,
930 })?;
931
932 if meta.file_type().is_symlink() {
933 let canonical_root = std::fs::canonicalize(root).map_err(|source| ResolverError::Io {
934 path: root.to_path_buf(),
935 source,
936 })?;
937 let target = std::fs::canonicalize(&candidate).map_err(|source| ResolverError::Io {
938 path: candidate.clone(),
939 source,
940 })?;
941
942 if !target.starts_with(&canonical_root) {
943 return Err(ResolverError::MaterializedSymlinkEscape {
944 dep: dep.manifest().to_string(),
945 path: candidate,
946 });
947 }
948
949 if let Ok(target_rel) = target.strip_prefix(&canonical_root) {
950 if target_rel.to_str().is_none() {
951 return Err(ResolverError::Walk(ModuleWalkError::NonUtf8SymlinkTarget(
952 candidate.display().to_string(),
953 )));
954 }
955 if target_rel
958 .components()
959 .any(|c| NON_MODULE_CONTENT.contains(&c.as_os_str().to_str().unwrap()))
960 {
961 return Err(ResolverError::Walk(
962 ModuleWalkError::SymlinkTargetsMetadata(rel.to_string()),
963 ));
964 }
965 }
966
967 Ok(target)
968 } else {
969 candidate
970 .canonicalize()
971 .map_err(|source| ResolverError::Io {
972 path: candidate,
973 source,
974 })
975 }
976}
977
978#[cfg(feature = "git-resolver")]
979fn read_manifest(dir: &Path) -> Result<Manifest, ResolverError> {
981 let path = dir.join(crate::MANIFEST_FILENAME);
982 let bytes = std::fs::read(&path).map_err(|source| ResolverError::Io {
983 path: path.clone(),
984 source,
985 })?;
986 Manifest::parse(&bytes).map_err(ResolverError::from)
987}
988
989#[cfg(feature = "git-resolver")]
990fn check_tag_manifest_match(
993 path_prefix: Option<&str>,
994 expected: Option<&semver::Version>,
995 declared: &semver::Version,
996) -> Result<(), ResolverError> {
997 if let Some(exp) = expected
998 && exp != declared
999 {
1000 let tag = crate::resolver::versions::VersionTag::new(
1001 path_prefix.map(str::to_string),
1002 exp.clone(),
1003 )
1004 .to_string();
1005 return Err(ResolverError::TagManifestMismatch {
1006 tag,
1007 declared: declared.clone(),
1008 });
1009 }
1010 Ok(())
1011}
1012
1013#[cfg(feature = "git-resolver")]
1014fn exclude_set(
1016 patterns: &[crate::relative_path::RelativePath],
1017) -> Result<globset::GlobSet, ResolverError> {
1018 if patterns.is_empty() {
1019 return Ok(globset::GlobSet::empty());
1020 }
1021 let mut builder = globset::GlobSetBuilder::new();
1022 for p in patterns {
1023 let s: &str = p.as_ref();
1024 let glob = globset::Glob::new(s).map_err(|source| ResolverError::InvalidExclude {
1025 pattern: s.to_string(),
1026 source,
1027 })?;
1028 builder.add(glob);
1029 }
1030 Ok(builder.build().unwrap())
1034}
1035
1036#[cfg(all(test, feature = "git-resolver"))]
1037mod tests {
1038 use std::fs;
1039 use std::sync::Arc;
1040
1041 use tempfile::TempDir;
1042 use tempfile::tempdir;
1043
1044 use super::*;
1045
1046 fn module(manifest: Manifest, root: &Path) -> Module {
1049 Module::new(Arc::new(manifest), root.to_path_buf())
1050 }
1051
1052 fn checksum() -> crate::hash::ContentHash {
1053 "sha256:0000000000000000000000000000000000000000000000000000000000000000"
1054 .parse()
1055 .unwrap()
1056 }
1057
1058 fn json_path(p: &Path) -> String {
1062 p.display().to_string().replace('\\', "/")
1063 }
1064
1065 fn write_manifest(dir: &Path, name: &str, version: &str, deps: &[(&str, &str)]) {
1068 fs::create_dir_all(dir).unwrap();
1069 let deps_obj = if deps.is_empty() {
1070 String::new()
1071 } else {
1072 let entries: Vec<String> = deps.iter().map(|(k, v)| format!("\"{k}\":{v}")).collect();
1073 format!(",\"dependencies\":{{{}}}", entries.join(","))
1074 };
1075 let body = format!(
1076 "{{\"name\":\"{name}\",\"version\":\"{version}\",\"license\":\"MIT\"{deps_obj}}}"
1077 );
1078 fs::write(dir.join(crate::MANIFEST_FILENAME), body).unwrap();
1079 }
1080
1081 fn resolver(cache: &TempDir) -> GitResolver {
1082 resolver_with_lockfile(cache, Lockfile::default())
1083 }
1084
1085 fn resolver_with_lockfile(cache: &TempDir, lockfile: Lockfile) -> GitResolver {
1086 GitResolver::builder()
1087 .cache_root(cache.path())
1088 .trust(TrustStore::default())
1089 .lockfile(lockfile)
1090 .build()
1091 }
1092
1093 async fn resolve_and_lock(cache: &TempDir, consumer: &Module) -> (GitResolver, Lockfile) {
1095 resolve_and_lock_with_config(
1096 cache,
1097 consumer,
1098 ResolverPolicy::default(),
1099 TrustStore::default(),
1100 )
1101 .await
1102 }
1103
1104 async fn resolve_and_lock_with_config(
1105 cache: &TempDir,
1106 consumer: &Module,
1107 policy: ResolverPolicy,
1108 trust: TrustStore,
1109 ) -> (GitResolver, Lockfile) {
1110 let r = resolver(cache);
1111 let tree = r.resolve_tree(consumer).await.unwrap();
1112 let outcome =
1113 crate::resolver::lock::partial_relock(&consumer.manifest, &Lockfile::default(), &tree)
1114 .unwrap();
1115 let locked = GitResolver::builder()
1116 .cache_root(cache.path())
1117 .trust(trust)
1118 .lockfile(outcome.lockfile.clone())
1119 .policy(policy)
1120 .build();
1121 (locked, outcome.lockfile)
1122 }
1123
1124 fn write_signature(dir: &Path, signer: &crate::signing::SigningKey) {
1127 let digest = crate::hash::hash_directory(dir).unwrap();
1128 let signature = signer.sign(&digest);
1129 let sig = crate::signing::ModuleSignature {
1130 public_key: signer.verifying_key(),
1131 signature,
1132 };
1133 let mut buf = Vec::new();
1134 sig.write(&mut buf).unwrap();
1135 fs::write(dir.join(crate::SIGNATURE_FILENAME), buf).unwrap();
1136 }
1137
1138 #[test]
1139 fn builds_with_explicit_paths() {
1140 let cache = tempdir().unwrap();
1141 let r = GitResolver::builder()
1142 .cache_root(cache.path())
1143 .trust(TrustStore::default())
1144 .lockfile(Lockfile::default())
1145 .build();
1146 assert_eq!(r.cache_root(), cache.path());
1147 assert!(r.trust_store().entries.is_empty());
1148 }
1149
1150 #[tokio::test]
1151 async fn transitive_dep_on_unallowed_host_is_rejected() {
1152 let cache = tempdir().unwrap();
1153 let r = resolver(&cache);
1154
1155 let dep: DependencyName = "widget".parse().unwrap();
1156 let source: DependencySource = serde_json::from_str(
1157 r#"{"git": "https://bitbucket.org/acme/widget", "version": "^1.0.0"}"#,
1158 )
1159 .unwrap();
1160
1161 let err = r
1162 .discover_versions(&dep, &source, DependencyScope::Transitive)
1163 .await
1164 .unwrap_err();
1165
1166 assert_eq!(
1167 err.to_string(),
1168 "`widget` git URL `https://bitbucket.org/acme/widget` targets host `bitbucket.org` \
1169 which is not in the configured allow list; to allow it, add `bitbucket.org` to \
1170 `allowed_transitive_hosts` in the `[modules]` section of your `sprocket.toml`"
1171 );
1172 }
1173
1174 #[tokio::test]
1175 async fn github_rejected_when_removed_from_transitive_allowlist() {
1176 let cache = tempdir().unwrap();
1177 let policy = ResolverPolicy::try_from(&ModulesConfig {
1178 allowed_transitive_hosts: vec!["gitlab.com".into()],
1179 ..ModulesConfig::default()
1180 })
1181 .unwrap();
1182 let r = GitResolver::builder()
1183 .cache_root(cache.path())
1184 .trust(TrustStore::default())
1185 .lockfile(Lockfile::default())
1186 .policy(policy)
1187 .build();
1188
1189 let dep: DependencyName = "widget".parse().unwrap();
1190 let source: DependencySource = serde_json::from_str(
1191 r#"{"git": "https://github.com/acme/widget", "version": "^1.0.0"}"#,
1192 )
1193 .unwrap();
1194
1195 let err = r
1196 .discover_versions(&dep, &source, DependencyScope::Transitive)
1197 .await
1198 .unwrap_err();
1199
1200 assert_eq!(
1201 err.to_string(),
1202 "`widget` git URL `https://github.com/acme/widget` targets host `github.com` which is \
1203 not in the configured allow list; to allow it, add `github.com` to \
1204 `allowed_transitive_hosts` in the `[modules]` section of your `sprocket.toml`"
1205 );
1206 }
1207
1208 #[tokio::test]
1209 async fn resolve_tree_recurses_into_local_path_deps() {
1210 let workdir = tempdir().unwrap();
1211 let consumer_dir = workdir.path().join("consumer");
1212 let dep_dir = workdir.path().join("dep");
1213
1214 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1215 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1216 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1217
1218 let bytes = fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap();
1219 let consumer = Manifest::parse(&bytes).unwrap();
1220 let consumer = module(consumer, &consumer_dir);
1221
1222 let cache = tempdir().unwrap();
1223 let tree = resolver(&cache).resolve_tree(&consumer).await.unwrap();
1224 let dep = tree.dependencies.get(&"dep".parse().unwrap()).unwrap();
1225 assert!(matches!(&dep.source, ResolvedSource::Path { .. }));
1226 assert!(dep.dependencies.is_empty());
1227 assert_eq!(dep.version, Version::parse("1.0.0").unwrap());
1228 }
1229
1230 fn hash_from_byte(byte: u8) -> crate::hash::ContentHash {
1231 format!("sha256:{}", hex::encode([byte; 32]))
1232 .parse()
1233 .unwrap()
1234 }
1235
1236 #[tokio::test]
1237 async fn materialize_returns_not_in_lockfile_when_dep_missing_from_lock() {
1238 let workdir = tempdir().unwrap();
1239 let dep_dir = workdir.path().join("dep");
1240 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1241 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
1242
1243 let consumer_dir = workdir.path().join("consumer");
1244 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1245 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1246 let consumer =
1247 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1248 .unwrap();
1249 let consumer = module(consumer, &consumer_dir);
1250
1251 let cache = tempdir().unwrap();
1252 let err = resolver(&cache)
1253 .materialize(&consumer, &"dep".parse().unwrap())
1254 .await
1255 .unwrap_err();
1256 assert!(
1257 matches!(err, ResolverError::NotInLockfile { .. }),
1258 "expected `NotInLockfile`, got: {err}"
1259 );
1260 }
1261
1262 #[tokio::test]
1263 async fn materialize_rejects_lockfile_checksum_mismatch() {
1264 let workdir = tempdir().unwrap();
1265 let dep_dir = workdir.path().join("dep");
1266 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1267 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
1268
1269 let consumer_dir = workdir.path().join("consumer");
1270 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1271 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1272 let consumer =
1273 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1274 .unwrap();
1275 let consumer = module(consumer, &consumer_dir);
1276
1277 let cache = tempdir().unwrap();
1278 let (_, mut lockfile) = resolve_and_lock(&cache, &consumer).await;
1279 let dep_name = "dep".parse().unwrap();
1280 lockfile.dependencies.get_mut(&dep_name).unwrap().checksum = hash_from_byte(42);
1281 let r = resolver_with_lockfile(&cache, lockfile);
1282 let err = r
1283 .materialize(&consumer, &"dep".parse().unwrap())
1284 .await
1285 .unwrap_err();
1286 assert!(
1287 matches!(err, ResolverError::ChecksumMismatch { .. }),
1288 "expected `ChecksumMismatch`, got: {err}"
1289 );
1290 }
1291
1292 #[tokio::test]
1293 async fn materialize_succeeds_with_matching_lockfile_checksum() {
1294 let workdir = tempdir().unwrap();
1295 let dep_dir = workdir.path().join("dep");
1296 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1297 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
1298
1299 let consumer_dir = workdir.path().join("consumer");
1300 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1301 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1302 let consumer =
1303 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1304 .unwrap();
1305 let consumer = module(consumer, &consumer_dir);
1306
1307 let cache = tempdir().unwrap();
1308 let (r, _) = resolve_and_lock(&cache, &consumer).await;
1309 let mat = r
1310 .materialize(&consumer, &"dep".parse().unwrap())
1311 .await
1312 .unwrap();
1313 assert_eq!(mat.path, dep_dir.join("index.wdl").canonicalize().unwrap());
1314 }
1315
1316 #[tokio::test]
1317 async fn materialize_detects_content_drift_against_lockfile() {
1318 let workdir = tempdir().unwrap();
1319 let dep_dir = workdir.path().join("dep");
1320 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1321 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
1322
1323 let consumer_dir = workdir.path().join("consumer");
1324 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1325 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1326 let consumer =
1327 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1328 .unwrap();
1329 let consumer = module(consumer, &consumer_dir);
1330
1331 let cache = tempdir().unwrap();
1332 let (_, lockfile) = resolve_and_lock(&cache, &consumer).await;
1333
1334 fs::write(dep_dir.join("extra.wdl"), b"workflow extra {}").unwrap();
1336
1337 let r = resolver_with_lockfile(&cache, lockfile);
1338 let err = r
1339 .materialize(&consumer, &"dep".parse().unwrap())
1340 .await
1341 .unwrap_err();
1342 assert!(
1343 matches!(err, ResolverError::ChecksumMismatch { .. }),
1344 "expected `ChecksumMismatch` after content drift, got: {err}"
1345 );
1346 }
1347
1348 #[tokio::test]
1349 async fn materialize_rejects_changed_local_path_after_lock() {
1350 let workdir = tempdir().unwrap();
1351 let dep_dir = workdir.path().join("dep");
1352 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1353 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
1354
1355 let consumer_dir = workdir.path().join("consumer");
1356 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1357 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1358 let consumer =
1359 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1360 .unwrap();
1361 let consumer = module(consumer, &consumer_dir);
1362
1363 let cache = tempdir().unwrap();
1364 let (_, lockfile) = resolve_and_lock(&cache, &consumer).await;
1365
1366 let other_dir = workdir.path().join("other");
1368 write_manifest(&other_dir, "dep", "1.0.0", &[]);
1369 fs::write(other_dir.join("index.wdl"), b"workflow w {}").unwrap();
1370 let other_src = format!("{{\"path\":\"{}\"}}", json_path(&other_dir));
1371 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &other_src)]);
1372 let consumer2 =
1373 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1374 .unwrap();
1375 let consumer2 = module(consumer2, &consumer_dir);
1376
1377 let r = resolver_with_lockfile(&cache, lockfile);
1378 let err = r
1379 .materialize(&consumer2, &"dep".parse().unwrap())
1380 .await
1381 .unwrap_err();
1382 assert!(
1383 matches!(err, ResolverError::LockfileSourceMismatch { .. }),
1384 "expected `LockfileSourceMismatch`, got: {err}"
1385 );
1386 }
1387
1388 #[tokio::test]
1389 async fn materialize_checks_transitive_git_policy_for_child_module()
1390 -> Result<(), Box<dyn std::error::Error>> {
1391 let workdir = tempdir()?;
1392 let child_dir = workdir.path().join("child");
1393 let ssh_dep = r#"{"git":"ssh://git@github.com/openwdl/tasks","commit":"0000000000000000000000000000000000000001"}"#;
1394 write_manifest(&child_dir, "child", "1.0.0", &[("dep", ssh_dep)]);
1395 let child = Manifest::parse(&fs::read(child_dir.join(crate::MANIFEST_FILENAME))?)?;
1396
1397 let parent_dir = workdir.path().join("parent");
1398 write_manifest(&parent_dir, "parent", "1.0.0", &[]);
1399 let parent = Manifest::parse(&fs::read(parent_dir.join(crate::MANIFEST_FILENAME))?)?;
1400 let parent = module(parent, &parent_dir);
1401 let child_name = "child".parse()?;
1402 let child = parent.child(child_name, Arc::new(child), child_dir);
1403
1404 let cache = tempdir()?;
1405 let symbolic_path = "dep".parse()?;
1406 let err = match resolver(&cache).materialize(&child, &symbolic_path).await {
1407 Ok(_) => panic!("expected transitive git policy rejection"),
1408 Err(err) => err,
1409 };
1410 assert!(
1411 matches!(err, ResolverError::GitUrlPolicyViolation { .. }),
1412 "expected `GitUrlPolicyViolation`, got: {err}"
1413 );
1414 Ok(())
1415 }
1416
1417 #[tokio::test]
1418 async fn materialize_uses_top_level_git_policy_for_top_level_module()
1419 -> Result<(), Box<dyn std::error::Error>> {
1420 let workdir = tempdir()?;
1421 let consumer_dir = workdir.path().join("consumer");
1422 let ssh_dep = r#"{"git":"ssh://git@github.com/openwdl/tasks","commit":"0000000000000000000000000000000000000001"}"#;
1423 write_manifest(&consumer_dir, "consumer", "1.0.0", &[("dep", ssh_dep)]);
1424 let consumer = Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME))?)?;
1425 let consumer = module(consumer, &consumer_dir);
1426 assert!(
1427 consumer.lockfile_scope.is_empty(),
1428 "consumer must be a top-level `Module`"
1429 );
1430
1431 let cache = tempdir()?;
1432 let symbolic_path = "dep".parse()?;
1433 let err = match resolver(&cache)
1434 .materialize(&consumer, &symbolic_path)
1435 .await
1436 {
1437 Ok(_) => panic!("expected lockfile rejection, not git policy rejection"),
1438 Err(err) => err,
1439 };
1440 assert!(
1441 !matches!(err, ResolverError::GitUrlPolicyViolation { .. }),
1442 "top-level `ssh://` dep must pass `DependencyScope::TopLevel` policy; got: {err}"
1443 );
1444 Ok(())
1445 }
1446
1447 fn locked_git_resolver(cache: &TempDir, dep: &str, entry: DependencyEntry) -> GitResolver {
1448 let mut lockfile = Lockfile::default();
1449 lockfile.dependencies.insert(dep.parse().unwrap(), entry);
1450 resolver_with_lockfile(cache, lockfile)
1451 }
1452
1453 fn locked_git_entry(selector: GitSelector) -> DependencyEntry {
1454 DependencyEntry {
1455 source: ResolvedSource::Git {
1456 git: "https://github.com/openwdl/tasks".parse().unwrap(),
1457 commit: "0000000000000000000000000000000000000001".parse().unwrap(),
1458 path: None,
1459 selector,
1460 },
1461 version: Version::parse("1.0.0").unwrap(),
1462 checksum: checksum(),
1463 signer: None,
1464 dependencies: Default::default(),
1465 }
1466 }
1467
1468 #[tokio::test]
1469 async fn locked_git_materialization_rejects_version_selector_mismatch() {
1470 let cache = tempdir().unwrap();
1471 let r = locked_git_resolver(
1472 &cache,
1473 "dep",
1474 locked_git_entry(GitSelector::Version("^1".parse().unwrap())),
1475 );
1476 let dep = "dep".parse().unwrap();
1477 let url = "https://github.com/openwdl/tasks".parse().unwrap();
1478 let selector = GitSelector::Version("^2".parse().unwrap());
1479 let err = r
1480 .plan_git_materialization(
1481 &dep,
1482 &url,
1483 &selector,
1484 &None,
1485 DependencyScope::TopLevel,
1486 ResolutionMode::Locked {
1487 lockfile_scope: &[],
1488 },
1489 )
1490 .await
1491 .unwrap_err();
1492 assert!(matches!(err, ResolverError::LockfileSourceMismatch { .. }));
1493 }
1494
1495 #[tokio::test]
1496 async fn locked_git_materialization_rejects_commit_selector_mismatch() {
1497 let cache = tempdir().unwrap();
1498 let r = locked_git_resolver(
1499 &cache,
1500 "dep",
1501 locked_git_entry(GitSelector::Version("^1".parse().unwrap())),
1502 );
1503 let dep = "dep".parse().unwrap();
1504 let url = "https://github.com/openwdl/tasks".parse().unwrap();
1505 let selector =
1506 GitSelector::Commit("0000000000000000000000000000000000000002".parse().unwrap());
1507 let err = r
1508 .plan_git_materialization(
1509 &dep,
1510 &url,
1511 &selector,
1512 &None,
1513 DependencyScope::TopLevel,
1514 ResolutionMode::Locked {
1515 lockfile_scope: &[],
1516 },
1517 )
1518 .await
1519 .unwrap_err();
1520 assert!(matches!(err, ResolverError::LockfileSourceMismatch { .. }));
1521 }
1522
1523 #[tokio::test]
1524 async fn locked_git_materialization_rejects_tag_selector_mismatch() {
1525 let cache = tempdir().unwrap();
1526 let r = locked_git_resolver(
1527 &cache,
1528 "dep",
1529 locked_git_entry(GitSelector::Tag("v1.0.0".to_string())),
1530 );
1531 let dep = "dep".parse().unwrap();
1532 let url = "https://github.com/openwdl/tasks".parse().unwrap();
1533 let selector = GitSelector::Tag("v2.0.0".to_string());
1534 let err = r
1535 .plan_git_materialization(
1536 &dep,
1537 &url,
1538 &selector,
1539 &None,
1540 DependencyScope::TopLevel,
1541 ResolutionMode::Locked {
1542 lockfile_scope: &[],
1543 },
1544 )
1545 .await
1546 .unwrap_err();
1547 assert!(matches!(err, ResolverError::LockfileSourceMismatch { .. }));
1548 }
1549
1550 #[tokio::test]
1551 async fn locked_git_materialization_uses_scoped_lockfile_entry()
1552 -> Result<(), Box<dyn std::error::Error>> {
1553 let cache = tempdir()?;
1554 let parent_dir = cache.path().join("parent");
1555 let parent: DependencyName = "parent".parse()?;
1556 let dep: DependencyName = "dep".parse()?;
1557 let selector = GitSelector::Commit("0000000000000000000000000000000000000001".parse()?);
1558
1559 let mut parent_entry = DependencyEntry {
1560 source: ResolvedSource::Path { path: parent_dir },
1561 version: Version::parse("1.0.0")?,
1562 checksum: checksum(),
1563 signer: None,
1564 dependencies: Default::default(),
1565 };
1566 parent_entry
1567 .dependencies
1568 .insert(dep.clone(), locked_git_entry(selector.clone()));
1569
1570 let mut lockfile = Lockfile::default();
1571 lockfile.dependencies.insert(parent.clone(), parent_entry);
1572 let r = resolver_with_lockfile(&cache, lockfile);
1573 let url = "https://github.com/openwdl/tasks".parse()?;
1574 let plan = r
1575 .plan_git_materialization(
1576 &dep,
1577 &url,
1578 &selector,
1579 &None,
1580 DependencyScope::Transitive,
1581 ResolutionMode::Locked {
1582 lockfile_scope: &[parent],
1583 },
1584 )
1585 .await?;
1586 assert_eq!(
1587 plan.commit,
1588 "0000000000000000000000000000000000000001".parse()?
1589 );
1590 Ok(())
1591 }
1592
1593 #[tokio::test]
1594 async fn materialize_resolves_default_entrypoint() {
1595 let workdir = tempdir().unwrap();
1596 let dep_dir = workdir.path().join("dep");
1597 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1598 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
1599
1600 let consumer_dir = workdir.path().join("consumer");
1601 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1602 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1603 let bytes = fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap();
1604 let consumer = Manifest::parse(&bytes).unwrap();
1605 let consumer = module(consumer, &consumer_dir);
1606
1607 let cache = tempdir().unwrap();
1608 let (r, _) = resolve_and_lock(&cache, &consumer).await;
1609 let mat = r
1610 .materialize(&consumer, &"dep".parse().unwrap())
1611 .await
1612 .unwrap();
1613 assert_eq!(mat.path, dep_dir.join("index.wdl").canonicalize().unwrap());
1614 assert!(matches!(mat.source, ResolvedSource::Path { .. }));
1615 }
1616
1617 #[tokio::test]
1618 async fn materialize_resolves_named_entrypoint() {
1619 let workdir = tempdir().unwrap();
1620 let dep_dir = workdir.path().join("dep");
1621 fs::create_dir_all(&dep_dir).unwrap();
1622 fs::write(
1625 dep_dir.join(crate::MANIFEST_FILENAME),
1626 br#"{"name":"dep","version":"1.0.0","license":"MIT","entrypoint":"main.wdl"}"#,
1627 )
1628 .unwrap();
1629 fs::write(dep_dir.join("main.wdl"), b"workflow w {}").unwrap();
1630
1631 let consumer_dir = workdir.path().join("consumer");
1632 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1633 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1634 let consumer =
1635 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1636 .unwrap();
1637 let consumer = module(consumer, &consumer_dir);
1638
1639 let cache = tempdir().unwrap();
1640 let (r, _) = resolve_and_lock(&cache, &consumer).await;
1641 let mat = r
1642 .materialize(&consumer, &"dep".parse().unwrap())
1643 .await
1644 .unwrap();
1645 assert_eq!(mat.path, dep_dir.join("main.wdl").canonicalize().unwrap());
1646 assert!(matches!(mat.source, ResolvedSource::Path { .. }));
1647 }
1648
1649 #[tokio::test]
1650 async fn materialize_resolves_sub_path_to_wdl_file() {
1651 let workdir = tempdir().unwrap();
1652 let dep_dir = workdir.path().join("dep");
1653 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1654 fs::write(dep_dir.join("cut.wdl"), b"workflow cut {}").unwrap();
1655
1656 let consumer_dir = workdir.path().join("consumer");
1657 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1658 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1659 let bytes = fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap();
1660 let consumer = Manifest::parse(&bytes).unwrap();
1661 let consumer = module(consumer, &consumer_dir);
1662
1663 let cache = tempdir().unwrap();
1664 let (r, _) = resolve_and_lock(&cache, &consumer).await;
1665 let mat = r
1666 .materialize(&consumer, &"dep/cut".parse().unwrap())
1667 .await
1668 .unwrap();
1669 assert_eq!(mat.path, dep_dir.join("cut.wdl").canonicalize().unwrap());
1670 assert!(matches!(mat.source, ResolvedSource::Path { .. }));
1671 }
1672
1673 #[tokio::test]
1674 async fn manifest_parse_rejects_invalid_commit_sha() {
1675 let workdir = tempdir().unwrap();
1676 let consumer_dir = workdir.path().join("consumer");
1677 let bad_src = "{\"git\":\"https://example.com/repo.git\",\"commit\":\"not-a-sha\"}";
1678 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", bad_src)]);
1679 let bytes = fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap();
1680 let err = Manifest::parse(&bytes).unwrap_err();
1681 assert!(
1682 matches!(err, crate::manifest::ManifestError::InvalidJson(_)),
1683 "expected `InvalidJson` from manifest parse, got: {err}"
1684 );
1685 }
1686
1687 #[tokio::test]
1688 async fn materialize_blocks_excluded_glob() {
1689 let workdir = tempdir().unwrap();
1690 let dep_dir = workdir.path().join("dep");
1691 fs::create_dir_all(dep_dir.join("internal")).unwrap();
1692 let body = r#"{"name":"dep","version":"1.0.0","license":"MIT","exclude":["internal/**"]}"#;
1693 fs::write(dep_dir.join(crate::MANIFEST_FILENAME), body).unwrap();
1694 fs::write(
1695 dep_dir.join("internal").join("private.wdl"),
1696 b"workflow w {}",
1697 )
1698 .unwrap();
1699
1700 let consumer_dir = workdir.path().join("consumer");
1701 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1702 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1703 let consumer =
1704 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1705 .unwrap();
1706 let consumer = module(consumer, &consumer_dir);
1707
1708 let cache = tempdir().unwrap();
1709 let (r, _) = resolve_and_lock(&cache, &consumer).await;
1710 let err = r
1711 .materialize(&consumer, &"dep/internal/private".parse().unwrap())
1712 .await
1713 .unwrap_err();
1714 let ResolverError::MissingFile { kind, .. } = err else {
1715 panic!("expected `MissingFile`, got: {err}");
1716 };
1717 assert_eq!(kind, MissingFileKind::Excluded);
1718 }
1719
1720 #[tokio::test]
1721 async fn materialize_rejects_unsigned_when_require_signed() {
1722 let workdir = tempdir().unwrap();
1723 let dep_dir = workdir.path().join("dep");
1724 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1725 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
1726
1727 let consumer_dir = workdir.path().join("consumer");
1728 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1729 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1730 let consumer =
1731 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1732 .unwrap();
1733 let consumer = module(consumer, &consumer_dir);
1734
1735 let cache = tempdir().unwrap();
1739 let (_, lockfile) = resolve_and_lock(&cache, &consumer).await;
1740
1741 let r = GitResolver::builder()
1742 .cache_root(cache.path())
1743 .trust(TrustStore::default())
1744 .policy(
1745 ResolverPolicy::try_from(&ModulesConfig {
1746 require_signed: true,
1747 ..ModulesConfig::default()
1748 })
1749 .unwrap(),
1750 )
1751 .lockfile(lockfile)
1752 .build();
1753 let err = r
1754 .materialize(&consumer, &"dep".parse().unwrap())
1755 .await
1756 .unwrap_err();
1757 assert!(
1758 matches!(err, ResolverError::RequireSignedViolation { .. }),
1759 "got: {err}"
1760 );
1761 }
1762
1763 #[tokio::test]
1764 async fn materialize_rejects_tampered_signed_dependency() {
1765 let workdir = tempdir().unwrap();
1766 let dep_dir = workdir.path().join("dep");
1767 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1768 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
1769 let signer = crate::signing::test_utils::signing_key_from_seed(7);
1770 write_signature(&dep_dir, &signer);
1771
1772 let consumer_dir = workdir.path().join("consumer");
1773 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1774 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1775 let consumer =
1776 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1777 .unwrap();
1778 let consumer = module(consumer, &consumer_dir);
1779
1780 let cache = tempdir().unwrap();
1781 let (_, lockfile) = resolve_and_lock(&cache, &consumer).await;
1782
1783 fs::write(dep_dir.join("extra.wdl"), b"workflow extra {}").unwrap();
1785
1786 let r = resolver_with_lockfile(&cache, lockfile);
1787 let err = r
1788 .materialize(&consumer, &"dep".parse().unwrap())
1789 .await
1790 .unwrap_err();
1791 assert!(
1794 matches!(
1795 err,
1796 ResolverError::SignatureVerificationFailed { .. }
1797 | ResolverError::ChecksumMismatch { .. }
1798 ),
1799 "got: {err}"
1800 );
1801 }
1802
1803 #[tokio::test]
1804 async fn materialize_rejects_trust_pin_mismatch() {
1805 let workdir = tempdir().unwrap();
1806 let dep_dir = workdir.path().join("dep");
1807 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1808 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
1809 let signer = crate::signing::test_utils::signing_key_from_seed(7);
1810 write_signature(&dep_dir, &signer);
1811
1812 let consumer_dir = workdir.path().join("consumer");
1813 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1814 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1815 let consumer =
1816 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1817 .unwrap();
1818 let consumer = module(consumer, &consumer_dir);
1819
1820 let pinned = crate::signing::test_utils::signing_key_from_seed(99).verifying_key();
1821 let trust = TrustStore {
1822 entries: vec![TrustEntry {
1823 dep: "dep".parse().unwrap(),
1824 source: json_path(&dep_dir),
1825 path: None,
1826 key: pinned,
1827 }],
1828 };
1829 let cache = tempdir().unwrap();
1830 let (r, _) =
1831 resolve_and_lock_with_config(&cache, &consumer, ResolverPolicy::default(), trust).await;
1832 let err = r
1833 .materialize(&consumer, &"dep".parse().unwrap())
1834 .await
1835 .unwrap_err();
1836 assert!(
1837 matches!(err, ResolverError::SignerKeyMismatch { .. }),
1838 "got: {err}"
1839 );
1840 }
1841
1842 #[tokio::test]
1843 async fn materialize_rejects_entrypoint_symlink_escaping_dep_root() {
1844 let workdir = tempdir().unwrap();
1845 let outside = workdir.path().join("outside");
1846 fs::create_dir_all(&outside).unwrap();
1847 fs::write(outside.join("evil.wdl"), b"workflow evil {}").unwrap();
1848
1849 let dep_dir = workdir.path().join("dep");
1850 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1851 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
1852
1853 let consumer_dir = workdir.path().join("consumer");
1854 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1855 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1856 let consumer =
1857 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1858 .unwrap();
1859 let consumer = module(consumer, &consumer_dir);
1860
1861 let cache = tempdir().unwrap();
1862 let (_, lockfile) = resolve_and_lock(&cache, &consumer).await;
1863
1864 fs::remove_file(dep_dir.join("index.wdl")).unwrap();
1866 let target = outside.join("evil.wdl");
1867 #[cfg(unix)]
1868 std::os::unix::fs::symlink(&target, dep_dir.join("index.wdl")).unwrap();
1869 #[cfg(windows)]
1870 std::os::windows::fs::symlink_file(&target, dep_dir.join("index.wdl")).unwrap();
1871
1872 let r = resolver_with_lockfile(&cache, lockfile);
1873 let err = r
1874 .materialize(&consumer, &"dep".parse().unwrap())
1875 .await
1876 .unwrap_err();
1877 assert!(
1878 matches!(
1879 err,
1880 ResolverError::MaterializedSymlinkEscape { .. }
1881 | ResolverError::Walk(ModuleWalkError::SymlinkEscapesRoot(_))
1882 | ResolverError::ChecksumMismatch { .. }
1883 ),
1884 "got: {err}"
1885 );
1886 }
1887
1888 #[tokio::test]
1889 async fn materialize_errors_on_undeclared_dependency() {
1890 let workdir = tempdir().unwrap();
1891 let consumer_dir = workdir.path().join("consumer");
1892 write_manifest(&consumer_dir, "consumer", "0.1.0", &[]);
1893 let bytes = fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap();
1894 let consumer = Manifest::parse(&bytes).unwrap();
1895 let consumer = module(consumer, &consumer_dir);
1896
1897 let cache = tempdir().unwrap();
1898 let err = resolver(&cache)
1899 .materialize(&consumer, &"missing".parse().unwrap())
1900 .await
1901 .unwrap_err();
1902 assert!(matches!(err, ResolverError::NotADependency { .. }));
1903 }
1904
1905 #[tokio::test]
1906 async fn signed_dependency_records_signer() {
1907 let workdir = tempdir().unwrap();
1908 let dep_dir = workdir.path().join("dep");
1909 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1910 let signer = crate::signing::test_utils::signing_key_from_seed(7);
1911 write_signature(&dep_dir, &signer);
1912
1913 let consumer_dir = workdir.path().join("consumer");
1914 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1915 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1916 let consumer =
1917 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1918 .unwrap();
1919 let consumer = module(consumer, &consumer_dir);
1920
1921 let cache = tempdir().unwrap();
1922 let tree = resolver(&cache).resolve_tree(&consumer).await.unwrap();
1923 let dep = tree.dependencies.get(&"dep".parse().unwrap()).unwrap();
1924 assert_eq!(dep.signer.as_ref(), Some(&signer.verifying_key()));
1925 }
1926
1927 #[tokio::test]
1928 async fn resolve_tree_rejects_unsigned_when_require_signed() {
1929 let workdir = tempdir().unwrap();
1930 let dep_dir = workdir.path().join("dep");
1931 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
1932
1933 let consumer_dir = workdir.path().join("consumer");
1934 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
1935 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
1936 let consumer =
1937 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1938 .unwrap();
1939 let consumer = module(consumer, &consumer_dir);
1940
1941 let cache = tempdir().unwrap();
1942 let r = GitResolver::builder()
1943 .cache_root(cache.path())
1944 .trust(TrustStore::default())
1945 .policy(
1946 ResolverPolicy::try_from(&ModulesConfig {
1947 require_signed: true,
1948 ..ModulesConfig::default()
1949 })
1950 .unwrap(),
1951 )
1952 .lockfile(Lockfile::default())
1953 .build();
1954 let err = r.resolve_tree(&consumer).await.unwrap_err();
1955 assert!(
1956 matches!(err, ResolverError::RequireSignedViolation { .. }),
1957 "expected `RequireSignedViolation`, got: {err}"
1958 );
1959 }
1960
1961 #[tokio::test]
1962 async fn resolve_tree_verifies_parent_before_transitive_dependencies() {
1963 let workdir = tempdir().unwrap();
1964 let child_dir = workdir.path().join("child");
1965 write_manifest(&child_dir, "child", "1.0.0", &[]);
1966
1967 let parent_dir = workdir.path().join("parent");
1968 let child_src = format!("{{\"path\":\"{}\"}}", json_path(&child_dir));
1969 write_manifest(&parent_dir, "parent", "1.0.0", &[("child", &child_src)]);
1970
1971 let consumer_dir = workdir.path().join("consumer");
1972 let parent_src = format!("{{\"path\":\"{}\"}}", json_path(&parent_dir));
1973 write_manifest(
1974 &consumer_dir,
1975 "consumer",
1976 "0.1.0",
1977 &[("parent", &parent_src)],
1978 );
1979 let consumer =
1980 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
1981 .unwrap();
1982 let consumer = module(consumer, &consumer_dir);
1983
1984 let cache = tempdir().unwrap();
1985 let r = GitResolver::builder()
1986 .cache_root(cache.path())
1987 .trust(TrustStore::default())
1988 .policy(
1989 ResolverPolicy::try_from(&ModulesConfig {
1990 require_signed: true,
1991 ..ModulesConfig::default()
1992 })
1993 .unwrap(),
1994 )
1995 .lockfile(Lockfile::default())
1996 .build();
1997
1998 let err = r.resolve_tree(&consumer).await.unwrap_err();
1999 let ResolverError::RequireSignedViolation { dep } = err else {
2000 panic!("expected parent verification to run before transitive dependency traversal");
2001 };
2002 assert_eq!(dep, "parent");
2003 }
2004
2005 #[tokio::test]
2006 async fn resolve_tree_rejects_tampered_signed_dependency() {
2007 let workdir = tempdir().unwrap();
2008 let dep_dir = workdir.path().join("dep");
2009 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2010 let signer = crate::signing::test_utils::signing_key_from_seed(7);
2011 write_signature(&dep_dir, &signer);
2012 fs::write(dep_dir.join("extra.wdl"), b"workflow w {}").unwrap();
2014
2015 let consumer_dir = workdir.path().join("consumer");
2016 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2017 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2018 let consumer =
2019 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2020 .unwrap();
2021 let consumer = module(consumer, &consumer_dir);
2022
2023 let cache = tempdir().unwrap();
2024 let err = resolver(&cache).resolve_tree(&consumer).await.unwrap_err();
2025 assert!(
2026 matches!(err, ResolverError::SignatureVerificationFailed { .. }),
2027 "expected `SignatureVerificationFailed`, got: {err}"
2028 );
2029 }
2030
2031 #[tokio::test]
2032 async fn resolve_tree_rejects_trust_pin_mismatch() {
2033 let workdir = tempdir().unwrap();
2034 let dep_dir = workdir.path().join("dep");
2035 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2036 let signer = crate::signing::test_utils::signing_key_from_seed(7);
2037 write_signature(&dep_dir, &signer);
2038
2039 let consumer_dir = workdir.path().join("consumer");
2040 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2041 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2042 let consumer =
2043 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2044 .unwrap();
2045 let consumer = module(consumer, &consumer_dir);
2046
2047 let pinned = crate::signing::test_utils::signing_key_from_seed(99).verifying_key();
2048 let trust = TrustStore {
2049 entries: vec![TrustEntry {
2050 dep: "dep".parse().unwrap(),
2051 source: json_path(&dep_dir),
2052 path: None,
2053 key: pinned,
2054 }],
2055 };
2056 let cache = tempdir().unwrap();
2057 let r = GitResolver::builder()
2058 .cache_root(cache.path())
2059 .trust(trust)
2060 .lockfile(Lockfile::default())
2061 .build();
2062 let err = r.resolve_tree(&consumer).await.unwrap_err();
2063 assert!(
2064 matches!(err, ResolverError::SignerKeyMismatch { .. }),
2065 "expected `SignerKeyMismatch`, got: {err}"
2066 );
2067 }
2068
2069 #[tokio::test]
2070 async fn resolve_tree_rejects_symlink_escaping_module_root() {
2071 let workdir = tempdir().unwrap();
2072 let outside = workdir.path().join("outside");
2073 fs::create_dir_all(&outside).unwrap();
2074 fs::write(outside.join("secret.txt"), b"sensitive").unwrap();
2075
2076 let dep_dir = workdir.path().join("dep");
2077 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2078 let link = dep_dir.join("escape");
2079 #[cfg(unix)]
2080 std::os::unix::fs::symlink(&outside, &link).unwrap();
2081 #[cfg(windows)]
2082 std::os::windows::fs::symlink_dir(&outside, &link).unwrap();
2083
2084 let consumer_dir = workdir.path().join("consumer");
2085 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2086 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2087 let consumer =
2088 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2089 .unwrap();
2090 let consumer = module(consumer, &consumer_dir);
2091
2092 let cache = tempdir().unwrap();
2093 let err = resolver(&cache).resolve_tree(&consumer).await.unwrap_err();
2094 assert!(
2095 matches!(
2096 err,
2097 ResolverError::Walk(ModuleWalkError::SymlinkEscapesRoot(_))
2098 ),
2099 "expected `Hash(SymlinkEscapesRoot)`, got: {err}"
2100 );
2101 }
2102
2103 #[tokio::test]
2104 async fn resolve_tree_rejects_too_many_materialized_files() {
2105 let workdir = tempdir().unwrap();
2106 let dep_dir = workdir.path().join("dep");
2107 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2108 fs::write(dep_dir.join("one.wdl"), b"workflow one {}").unwrap();
2109 fs::write(dep_dir.join("two.wdl"), b"workflow two {}").unwrap();
2110
2111 let consumer_dir = workdir.path().join("consumer");
2112 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2113 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2114 let consumer =
2115 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2116 .unwrap();
2117 let consumer = module(consumer, &consumer_dir);
2118
2119 let cache = tempdir().unwrap();
2120 let r = GitResolver::builder()
2121 .cache_root(cache.path())
2122 .trust(TrustStore::default())
2123 .lockfile(Lockfile::default())
2124 .policy(
2125 ResolverPolicy::try_from(&ModulesConfig {
2126 max_materialized_files: Some(1),
2127 ..ModulesConfig::default()
2128 })
2129 .unwrap(),
2130 )
2131 .build();
2132 let err = r.resolve_tree(&consumer).await.unwrap_err();
2133 assert!(
2134 matches!(err, ResolverError::MaterializedTreeLimitExceeded { .. }),
2135 "expected `MaterializedTreeLimitExceeded`, got: {err}"
2136 );
2137 }
2138
2139 #[tokio::test]
2140 async fn resolve_tree_rejects_too_many_materialized_bytes() {
2141 let workdir = tempdir().unwrap();
2142 let dep_dir = workdir.path().join("dep");
2143 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2144 fs::write(dep_dir.join("big.wdl"), vec![b'x'; 1024]).unwrap();
2145
2146 let consumer_dir = workdir.path().join("consumer");
2147 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2148 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2149 let consumer =
2150 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2151 .unwrap();
2152 let consumer = module(consumer, &consumer_dir);
2153
2154 let cache = tempdir().unwrap();
2155 let r = GitResolver::builder()
2156 .cache_root(cache.path())
2157 .trust(TrustStore::default())
2158 .lockfile(Lockfile::default())
2159 .policy(
2160 ResolverPolicy::try_from(&ModulesConfig {
2161 max_materialized_bytes: Some(100),
2162 ..ModulesConfig::default()
2163 })
2164 .unwrap(),
2165 )
2166 .build();
2167 let err = r.resolve_tree(&consumer).await.unwrap_err();
2168 assert!(
2169 matches!(err, ResolverError::MaterializedTreeLimitExceeded { .. }),
2170 "expected `MaterializedTreeLimitExceeded`, got: {err}"
2171 );
2172 }
2173
2174 #[tokio::test]
2175 async fn tree_limit_does_not_delete_local_path_dep() {
2176 let workdir = tempdir().unwrap();
2177 let dep_dir = workdir.path().join("dep");
2178 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2179 fs::write(dep_dir.join("one.wdl"), b"workflow one {}").unwrap();
2180 fs::write(dep_dir.join("two.wdl"), b"workflow two {}").unwrap();
2181
2182 let consumer_dir = workdir.path().join("consumer");
2183 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2184 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2185 let consumer =
2186 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2187 .unwrap();
2188 let consumer = module(consumer, &consumer_dir);
2189
2190 let cache = tempdir().unwrap();
2191 let r = GitResolver::builder()
2192 .cache_root(cache.path())
2193 .trust(TrustStore::default())
2194 .lockfile(Lockfile::default())
2195 .policy(
2196 ResolverPolicy::try_from(&ModulesConfig {
2197 max_materialized_files: Some(1),
2198 ..ModulesConfig::default()
2199 })
2200 .unwrap(),
2201 )
2202 .build();
2203 let err = r.resolve_tree(&consumer).await.unwrap_err();
2204 assert!(matches!(
2205 err,
2206 ResolverError::MaterializedTreeLimitExceeded { .. }
2207 ));
2208 assert!(
2209 dep_dir.exists(),
2210 "local-path dep directory must survive the limit error"
2211 );
2212 assert!(dep_dir.join("one.wdl").exists());
2213 assert!(dep_dir.join("two.wdl").exists());
2214 }
2215
2216 #[tokio::test]
2217 async fn local_path_relock_refreshes_on_content_change() {
2218 let workdir = tempdir().unwrap();
2219 let dep_dir = workdir.path().join("dep");
2220 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2221 fs::write(dep_dir.join("index.wdl"), b"workflow original {}").unwrap();
2222
2223 let consumer_dir = workdir.path().join("consumer");
2224 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2225 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2226 let consumer =
2227 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2228 .unwrap();
2229 let consumer = module(consumer, &consumer_dir);
2230
2231 let cache = tempdir().unwrap();
2232 let (_, lockfile_v1) = resolve_and_lock(&cache, &consumer).await;
2233 let v1_checksum = lockfile_v1
2234 .dependencies
2235 .get(&"dep".parse().unwrap())
2236 .unwrap()
2237 .checksum;
2238
2239 fs::write(dep_dir.join("index.wdl"), b"workflow changed {}").unwrap();
2240
2241 let (_, lockfile_v2) = resolve_and_lock(&cache, &consumer).await;
2242 let v2_checksum = lockfile_v2
2243 .dependencies
2244 .get(&"dep".parse().unwrap())
2245 .unwrap()
2246 .checksum;
2247
2248 assert_ne!(
2249 v1_checksum, v2_checksum,
2250 "local path relock must produce a new checksum when content changes"
2251 );
2252 }
2253
2254 #[tokio::test]
2255 async fn resolve_tree_detects_self_cycle() {
2256 let workdir = tempdir().unwrap();
2257 let dep_dir = workdir.path().join("self-loop");
2258
2259 let self_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2261 write_manifest(&dep_dir, "loop", "1.0.0", &[("loop", &self_src)]);
2262
2263 let consumer_dir = workdir.path().join("consumer");
2264 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("loop", &self_src)]);
2265 let bytes = fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap();
2266 let consumer = Manifest::parse(&bytes).unwrap();
2267 let consumer = module(consumer, &consumer_dir);
2268
2269 let cache = tempdir().unwrap();
2270 let err = resolver(&cache).resolve_tree(&consumer).await.unwrap_err();
2271 let ResolverError::Cycle { path } = err else {
2272 panic!("expected `Cycle`, got: {err}");
2273 };
2274 assert_eq!(path.len(), 2, "self-loop should report a 2-element chain");
2275 }
2276
2277 #[tokio::test]
2278 async fn resolve_tree_detects_relative_local_path_cycle() {
2279 let workdir = tempdir().expect("failed to create temporary directory");
2280 let consumer_dir = workdir.path().join("consumer");
2281 let dep_a_dir = workdir.path().join("a");
2282 let dep_b_dir = workdir.path().join("b");
2283
2284 write_manifest(&dep_a_dir, "a", "1.0.0", &[("b", r#"{"path":"../b"}"#)]);
2285 write_manifest(&dep_b_dir, "b", "1.0.0", &[("a", r#"{"path":"../a"}"#)]);
2286 write_manifest(
2287 &consumer_dir,
2288 "consumer",
2289 "0.1.0",
2290 &[("a", r#"{"path":"../a"}"#)],
2291 );
2292
2293 let bytes = fs::read(consumer_dir.join(crate::MANIFEST_FILENAME))
2294 .expect("failed to read consumer manifest");
2295 let consumer = Manifest::parse(&bytes).expect("failed to parse consumer manifest");
2296 let consumer = module(consumer, &consumer_dir);
2297
2298 let cache = tempdir().expect("failed to create cache directory");
2299 let err = resolver(&cache)
2300 .resolve_tree(&consumer)
2301 .await
2302 .expect_err("relative local path cycle should be rejected");
2303 let ResolverError::Cycle { path } = err else {
2304 panic!("expected `Cycle`, got: {err}");
2305 };
2306 assert_eq!(path, ["a", "b", "a"]);
2307 }
2308
2309 #[tokio::test]
2310 async fn materialize_rejects_entrypoint_symlink_to_nested_metadata() {
2311 let workdir = tempdir().unwrap();
2312 let dep_dir = workdir.path().join("dep");
2313 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2314 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
2315
2316 let consumer_dir = workdir.path().join("consumer");
2317 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2318 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2319 let consumer =
2320 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2321 .unwrap();
2322 let consumer = module(consumer, &consumer_dir);
2323
2324 let cache = tempdir().unwrap();
2325 let (_, lockfile) = resolve_and_lock(&cache, &consumer).await;
2326
2327 fs::remove_file(dep_dir.join("index.wdl")).unwrap();
2330 fs::create_dir_all(dep_dir.join("nested").join(".git")).unwrap();
2331 fs::write(
2332 dep_dir.join("nested").join(".git").join("config"),
2333 b"private",
2334 )
2335 .unwrap();
2336 #[cfg(unix)]
2337 std::os::unix::fs::symlink(
2338 dep_dir.join("nested").join(".git").join("config"),
2339 dep_dir.join("index.wdl"),
2340 )
2341 .unwrap();
2342 #[cfg(windows)]
2343 std::os::windows::fs::symlink_file(
2344 dep_dir.join("nested").join(".git").join("config"),
2345 dep_dir.join("index.wdl"),
2346 )
2347 .unwrap();
2348
2349 let r = resolver_with_lockfile(&cache, lockfile);
2350 let err = r
2351 .materialize(&consumer, &"dep".parse().unwrap())
2352 .await
2353 .unwrap_err();
2354 assert!(
2355 matches!(
2356 err,
2357 ResolverError::Walk(ModuleWalkError::SymlinkTargetsMetadata(_))
2358 ),
2359 "expected `SymlinkTargetsMetadata`, got: {err}"
2360 );
2361 }
2362
2363 #[tokio::test]
2364 async fn materialize_rejects_signature_downgrade() {
2365 let workdir = tempdir().unwrap();
2366 let dep_dir = workdir.path().join("dep");
2367 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2368 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
2369 let signer = crate::signing::test_utils::signing_key_from_seed(7);
2370 write_signature(&dep_dir, &signer);
2371
2372 let consumer_dir = workdir.path().join("consumer");
2373 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2374 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2375 let consumer =
2376 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2377 .unwrap();
2378 let consumer = module(consumer, &consumer_dir);
2379
2380 let cache = tempdir().unwrap();
2381 let (_, lockfile) = resolve_and_lock(&cache, &consumer).await;
2382 let dep_name = "dep".parse().unwrap();
2383 assert!(
2384 lockfile
2385 .dependencies
2386 .get(&dep_name)
2387 .unwrap()
2388 .signer
2389 .is_some(),
2390 "lockfile should record the signer"
2391 );
2392
2393 fs::remove_file(dep_dir.join(crate::SIGNATURE_FILENAME)).unwrap();
2394
2395 let r = resolver_with_lockfile(&cache, lockfile);
2396 let err = r
2397 .materialize(&consumer, &"dep".parse().unwrap())
2398 .await
2399 .unwrap_err();
2400 assert!(
2401 matches!(err, ResolverError::SignatureDowngrade { .. }),
2402 "expected `SignatureDowngrade`, got: {err}"
2403 );
2404 }
2405
2406 #[tokio::test]
2407 async fn materialize_rejects_signer_key_change() {
2408 let workdir = tempdir().unwrap();
2409 let dep_dir = workdir.path().join("dep");
2410 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2411 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
2412 let signer_a = crate::signing::test_utils::signing_key_from_seed(7);
2413 write_signature(&dep_dir, &signer_a);
2414
2415 let consumer_dir = workdir.path().join("consumer");
2416 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2417 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2418 let consumer =
2419 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2420 .unwrap();
2421 let consumer = module(consumer, &consumer_dir);
2422
2423 let cache = tempdir().unwrap();
2424 let (_, lockfile) = resolve_and_lock(&cache, &consumer).await;
2425
2426 let signer_b = crate::signing::test_utils::signing_key_from_seed(99);
2427 write_signature(&dep_dir, &signer_b);
2428
2429 let r = resolver_with_lockfile(&cache, lockfile);
2430 let err = r
2431 .materialize(&consumer, &"dep".parse().unwrap())
2432 .await
2433 .unwrap_err();
2434 assert!(
2435 matches!(err, ResolverError::SignerKeyMismatch { .. }),
2436 "expected `SignerKeyMismatch`, got: {err}"
2437 );
2438 }
2439
2440 #[tokio::test]
2441 async fn materialize_accepts_unsigned_when_lockfile_has_no_signer() {
2442 let workdir = tempdir().unwrap();
2443 let dep_dir = workdir.path().join("dep");
2444 write_manifest(&dep_dir, "dep", "1.0.0", &[]);
2445 fs::write(dep_dir.join("index.wdl"), b"workflow w {}").unwrap();
2446
2447 let consumer_dir = workdir.path().join("consumer");
2448 let dep_src = format!("{{\"path\":\"{}\"}}", json_path(&dep_dir));
2449 write_manifest(&consumer_dir, "consumer", "0.1.0", &[("dep", &dep_src)]);
2450 let consumer =
2451 Manifest::parse(&fs::read(consumer_dir.join(crate::MANIFEST_FILENAME)).unwrap())
2452 .unwrap();
2453 let consumer = module(consumer, &consumer_dir);
2454
2455 let cache = tempdir().unwrap();
2456 let (r, _) = resolve_and_lock(&cache, &consumer).await;
2457 let mat = r
2458 .materialize(&consumer, &"dep".parse().unwrap())
2459 .await
2460 .unwrap();
2461 assert!(mat.path.exists());
2462 }
2463
2464 #[tokio::test]
2465 async fn discover_versions_returns_matching_tags() {
2466 let upstream = tempdir().unwrap();
2467 let repo = git2::Repository::init(upstream.path()).unwrap();
2468 let sig = git2::Signature::now("test", "test@example.com").unwrap();
2469
2470 write_manifest(upstream.path(), "dep", "1.0.0", &[]);
2471 let mut index = repo.index().unwrap();
2472 index
2473 .add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
2474 .unwrap();
2475 index.write().unwrap();
2476 let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
2477 let oid = repo
2478 .commit(Some("HEAD"), &sig, &sig, "v1.0.0", &tree, &[])
2479 .unwrap();
2480 repo.tag_lightweight("v1.0.0", &repo.find_object(oid, None).unwrap(), false)
2481 .unwrap();
2482
2483 let source = DependencySource::Git {
2484 url: url::Url::from_file_path(upstream.path()).unwrap(),
2485 selector: GitSelector::Version("^1".parse().unwrap()),
2486 path: None,
2487 extra: Default::default(),
2488 };
2489
2490 let cache = tempdir().unwrap();
2491 let r = GitResolver::builder()
2492 .cache_root(cache.path())
2493 .trust(TrustStore::default())
2494 .lockfile(Lockfile::default())
2495 .policy(
2496 ResolverPolicy::try_from(&ModulesConfig {
2497 allowed_schemes: vec!["https".into(), "ssh".into(), "file".into()],
2498 ..ModulesConfig::default()
2499 })
2500 .unwrap(),
2501 )
2502 .build();
2503 let dep = "tasks".parse().unwrap();
2504 let versions = r
2505 .discover_versions(&dep, &source, DependencyScope::TopLevel)
2506 .await
2507 .unwrap();
2508 assert_eq!(
2509 versions,
2510 vec![semver::Version::parse("1.0.0").unwrap()],
2511 "should discover `v1.0.0` tag"
2512 );
2513 }
2514
2515 #[test]
2516 fn tag_manifest_mismatch_errors_on_disagreement() {
2517 let v_expected = semver::Version::parse("2.0.0").unwrap();
2518 let v_declared = semver::Version::parse("1.0.0").unwrap();
2519 let err = check_tag_manifest_match(None, Some(&v_expected), &v_declared).unwrap_err();
2520 let ResolverError::TagManifestMismatch { tag, declared } = err else {
2521 panic!("got: {err:?}");
2522 };
2523 assert_eq!(tag, "v2.0.0");
2524 assert_eq!(declared, v_declared);
2525 }
2526
2527 #[test]
2528 fn check_tag_manifest_match_succeeds_when_versions_agree() {
2529 let v = semver::Version::parse("1.2.3").unwrap();
2530 check_tag_manifest_match(Some("csvkit"), Some(&v), &v).unwrap();
2531 }
2532
2533 #[test]
2534 fn check_tag_manifest_match_succeeds_when_no_expected_version() {
2535 check_tag_manifest_match(None, None, &semver::Version::parse("0.0.1").unwrap()).unwrap();
2536 }
2537}