Skip to main content

wdl_modules/
resolver.rs

1//! Public resolver API.
2//!
3//! The trait definitions (`Resolver`, `ResolverError`, `MaterializedFile`,
4//! etc.) are always available. The Git-backed implementation and supporting
5//! infrastructure (cache, config, fetch, lock, trust, etc.) are gated
6//! behind the `resolver` cargo feature so that consumers like `wdl-doc`
7//! that only need the manifest/lockfile/hashing types do not pay for
8//! `git2` and friends.
9
10#[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/// Resolves WDL module imports to concrete files on disk.
124#[async_trait]
125pub trait Resolver: std::fmt::Debug + Send + Sync {
126    /// Materializes a single symbolic import on disk and returns the path
127    /// to the resulting file.
128    ///
129    /// The primary call site for `wdl-analysis`. When the analyzer
130    /// encounters a symbolic import like `import openwdl/csvkit/cut`, it
131    /// asks the resolver for the file path that statement should route
132    /// to, then parses the result with the existing import machinery as
133    /// if the user had written `import "<that path>"`.
134    ///
135    /// `consumer` is the importing module: its manifest declares the
136    /// symbolic path's head component, and its root rebases any
137    /// relative `LocalPath` dependencies. `path` is the parsed
138    /// symbolic path. The resolver looks up the head component in
139    /// `consumer.manifest.dependencies`, materializes the dep's module
140    /// folder if not yet cached, and resolves either the manifest's
141    /// `entrypoint` (when the symbolic path has no sub-path) or
142    /// `<sub-path>.wdl` under the module folder.
143    async fn materialize(
144        &self,
145        consumer: &Module,
146        path: &SymbolicPath,
147    ) -> Result<MaterializedFile, ResolverError>;
148
149    /// Resolves every transitive dependency declared by `consumer`.
150    ///
151    /// Walks `consumer.manifest.dependencies`, recurses into each dep's
152    /// own manifest, and records every module visited along the way.
153    /// Relative `LocalPath` entries are resolved against the declaring
154    /// module's root. Detects cycles.
155    async fn resolve_tree(&self, consumer: &Module) -> Result<ResolvedTree, ResolverError>;
156
157    /// Lists discovered versions for a dependency source that satisfy
158    /// the requirement, in descending semver order.
159    ///
160    /// Used by CLI commands that surface available versions to the user
161    /// and internally by `resolve_tree` to select the version a Git dep
162    /// resolves to.
163    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/// The default Git-backed [`Resolver`].
173///
174/// Construct via [`GitResolver::builder`]. The caller is expected to
175/// load the [`TrustStore`] from disk and pass it in; the library does
176/// not derive default paths so the binary owns the policy of where
177/// configuration lives.
178#[derive(Builder, Clone, Debug)]
179pub struct GitResolver {
180    /// Filesystem root under which `(host, org, repo, commit)` cache
181    /// leaves are materialized.
182    #[builder(into)]
183    cache_root: PathBuf,
184    /// The resolved policy, derived from [`ModulesConfig`] at construction.
185    #[builder(default, into)]
186    policy: Arc<ResolverPolicy>,
187    /// The user-level trust store, loaded by the caller.
188    trust: TrustStore,
189    /// The lockfile to verify materialized dependencies against.
190    ///
191    /// `materialize` compares each dependency's observed content hash
192    /// against the locked checksum and rejects mismatches.
193    lockfile: Lockfile,
194}
195
196#[cfg(feature = "git-resolver")]
197impl GitResolver {
198    /// Returns the cache root.
199    pub fn cache_root(&self) -> &Path {
200        &self.cache_root
201    }
202
203    /// Returns the active trust store.
204    pub fn trust_store(&self) -> &TrustStore {
205        &self.trust
206    }
207
208    /// Returns a policy-enforcing Git fetcher.
209    fn fetcher(&self) -> GitFetcher {
210        GitFetcher::new(self.policy.clone())
211    }
212
213    /// Returns the lockfile.
214    pub fn lockfile(&self) -> &Lockfile {
215        &self.lockfile
216    }
217
218    /// Checks that a locked local-path dep matches the manifest declaration.
219    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            // The lockfile may store either an absolute path or one
233            // written relative to the declaring `module.json`. Rebase
234            // both sides through the consumer so the comparison is
235            // independent of how the path was originally written.
236            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    /// Runs the sparse checkout for a Git dependency and returns its root.
251    ///
252    /// On failure, cleans up the cache leaf so a corrupt partial
253    /// checkout does not persist.
254    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        // SAFETY: the closure performs only libgit2 work and
279        // does not panic; a `JoinError` would only fire on
280        // runtime shutdown.
281        .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    /// Recursively resolves a dependency map for `resolve_tree`.
304    ///
305    /// Each iteration: policy check, materialize, read manifest, cycle
306    /// check, verify, recurse into transitive deps, assemble result.
307    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                // Local-path deps under a Git parent are disallowed:
324                // the path would be meaningless outside the original
325                // machine, making the resolution non-reproducible.
326                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                // Enforce URL scheme and host policy.
335                if let DependencySource::Git { url, .. } = source {
336                    self.policy.check_git_url(name, url, scope)?;
337                }
338
339                // Materialize the dependency on disk and read its manifest.
340                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                // Detect cycles before recursing.
388                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                // Verify content hash, signature, and trust pin.
396                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                // Recurse into transitive dependencies. Pass this dep's
418                // module root so that relative `LocalPath` entries in its
419                // own manifest resolve against the right directory.
420                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    /// Resolves a [`GitSelector`] to a concrete commit SHA.
449    ///
450    /// Queries the remote at `url` for tags or branches (depending on
451    /// the selector variant), then maps the result to a commit. For
452    /// version selectors, also returns the matched semver version so
453    /// callers can verify it against the manifest.
454    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                        // SAFETY: the closure performs only Git work; a
473                        // `JoinError` would only fire on runtime shutdown.
474                        .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                        // SAFETY: the closure does not panic.
500                        .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                        // SAFETY: the closure does not panic.
519                        .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    /// Computes the materialization plan for a Git dependency.
535    ///
536    /// Resolves the commit (locked or fresh), derives cache paths from
537    /// the URL and commit, and validates lockfile consistency when in
538    /// locked mode. The returned plan carries everything
539    /// [`materialize_dependency`](Self::materialize_dependency) needs
540    /// to run the sparse checkout and verify the result.
541    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        // Look up the dependency declaration in the consumer's manifest.
623        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        // Enforce URL scheme and host policy before any network access.
637        if let DependencySource::Git { url, .. } = source {
638            self.policy.check_git_url(name, url, scope)?;
639        }
640
641        // Materialize the dependency on disk and read its manifest.
642        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        // Verify the content hash, signature, and trust pin.
689        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        // Confirm the on-disk content matches the lockfile expectations.
701        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        // Resolve the symbolic path to a concrete `.wdl` file path.
710        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        // Reject paths that match the manifest's exclude globs.
729        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        // Canonicalize the path, enforcing symlink containment.
738        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        // Walk every transitive dependency starting from the consumer's
762        // direct dependencies, collecting the full resolved tree.
763        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                // Only version selectors produce a meaningful version list;
789                // tag, branch, and commit selectors resolve to at most one
790                // version that is not yet known.
791                let GitSelector::Version(requirement) = selector else {
792                    return Ok(Vec::new());
793                };
794
795                // List remote tags and filter to those satisfying the
796                // semver requirement.
797                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                // SAFETY: the spawned closure performs pure libgit2 work
812                // and does not panic; a `JoinError` would only fire on
813                // runtime shutdown, in which case re-panicking is fine.
814                .unwrap()
815            }
816            DependencySource::LocalPath { path, .. } => {
817                // For local paths, read the manifest and return its
818                // single declared version.
819                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/// Pre-computed materialization parameters for a Git dependency.
833#[derive(Debug)]
834struct GitMaterializationPlan {
835    /// The selected version from tag resolution, if any.
836    selected_version: Option<Version>,
837    /// The resolved commit SHA.
838    commit: GitCommit,
839    /// The path prefix (from [`GitModulePath`]) for tag-version matching.
840    path_prefix: Option<String>,
841    /// The absolute path to the cache leaf directory.
842    leaf: PathBuf,
843    /// The sparse-checkout path (`path_prefix` or `"."`).
844    sparse_path: String,
845    /// The absolute path to the module root within the cache leaf.
846    module_path: PathBuf,
847}
848
849#[cfg(feature = "git-resolver")]
850/// Distinguishes resolver-owned cache paths from user-owned local
851/// paths. Only `Cached` variants may be evicted.
852#[derive(Clone, Debug)]
853enum MaterializedRoot {
854    /// A user's local module directory. Must never be evicted.
855    Local(PathBuf),
856    /// A resolver-owned cache leaf.
857    Cached {
858        /// The module content root inside the cache leaf.
859        module_root: PathBuf,
860        /// The resolver-owned cache leaf directory for this module.
861        cache_leaf: PathBuf,
862    },
863}
864
865#[cfg(feature = "git-resolver")]
866impl MaterializedRoot {
867    /// Returns the module root regardless of ownership.
868    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")]
877/// Returns true when a lockfile entry can satisfy the current Git
878/// selector in `module.json`.
879fn 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")]
898/// Resolves a relative content path under `root`, enforcing the same
899/// metadata exclusions and containment rules used by
900/// [`module_walk`](crate::module_walk).
901fn 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            // SAFETY: the `to_str` check above guarantees all
956            // components are valid UTF-8.
957            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")]
979/// Reads and parses `module.json` from `dir`.
980fn 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")]
990/// Returns `Err(TagManifestMismatch)` when a Git tag's selected
991/// semver `expected` does not equal the manifest's `declared` version.
992fn 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")]
1014/// Compiles a manifest's `exclude` patterns into a [`globset::GlobSet`].
1015fn 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    // SAFETY: `GlobSetBuilder::build` only consolidates already-compiled
1031    // globs; `Glob::new` above is the validating step, so by the time
1032    // we reach this call there is nothing left for `build` to reject.
1033    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    /// Builds a `Module` from a parsed `Manifest` and the directory it
1047    /// lives in.
1048    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    /// Builds a `module.json` at `dir` with the given name, version, and
1059    /// Converts a path to a JSON-safe string (forward slashes on all
1060    /// platforms).
1061    fn json_path(p: &Path) -> String {
1062        p.display().to_string().replace('\\', "/")
1063    }
1064
1065    /// optional `dependencies` map (each value is the JSON-encoded
1066    /// dependency source).
1067    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    /// Resolves a consumer's tree and builds a lockfile from it.
1094    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    /// Writes a `module.sig` next to `dir`'s `module.json` over the
1125    /// directory's content hash.
1126    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        // Mutate the dep content after locking.
1335        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        // Change the manifest to point to a different path.
1367        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        // Manifest declares an explicit `entrypoint` other than the
1623        // default `index.wdl`.
1624        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        // Lock with `require_signed` disabled so the unsigned dep can
1736        // be recorded in the lockfile. The replay below then enforces
1737        // `require_signed` and must reject the locked unsigned dep.
1738        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        // Tamper after locking.
1784        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        // Tampered content changes the hash, which the signature or
1792        // lockfile checksum comparison catches.
1793        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        // Replace the entrypoint with a symlink after locking.
1865        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        // Modify a file after signing — this invalidates the signature.
2013        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        // The dep declares itself as one of its own dependencies.
2260        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        // Replace the entrypoint with a symlink to nested metadata
2328        // after locking.
2329        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}