Skip to main content

wdl_modules/resolver/
error.rs

1//! Top-level error type for the resolver layer.
2
3use std::path::PathBuf;
4
5#[cfg(feature = "git-resolver")]
6use semver::Version;
7use thiserror::Error;
8
9#[cfg(feature = "git-resolver")]
10use crate::hash::ContentHash;
11#[cfg(feature = "git-resolver")]
12use crate::hash::HashError;
13use crate::lockfile::LockfileError;
14use crate::manifest::ManifestError;
15#[cfg(feature = "git-resolver")]
16use crate::module_walk::ModuleWalkError;
17use crate::signing::SignerIdentity;
18use crate::signing::VerifyingKey;
19#[cfg(feature = "git-resolver")]
20use crate::version_requirement::VersionRequirement;
21
22/// An error returned by the [`Resolver`](crate::Resolver) trait or
23/// any resolver-layer operation.
24#[derive(Debug, Error)]
25pub enum ResolverError {
26    /// The symbolic path's head does not appear in the consumer's
27    /// `dependencies` map.
28    #[error("`{name}` is not a declared dependency")]
29    NotADependency {
30        /// The undeclared dependency name.
31        name: String,
32    },
33
34    /// A required file was not found, or was excluded.
35    #[error("{}", missing_file_message(.dep, .path, .kind))]
36    MissingFile {
37        /// The owning dependency.
38        dep: String,
39        /// The relative path that was looked up.
40        path: PathBuf,
41        /// Which kind of lookup failed.
42        kind: MissingFileKind,
43    },
44
45    /// A symbolic sub-path component matched more than one directory
46    /// entry after hyphen-to-underscore normalization.
47    #[error(
48        "symbolic path `{path}` in `{dep}` is ambiguous: it matches multiple entries ({})",
49        .entries.join(", ")
50    )]
51    AmbiguousSubPath {
52        /// The owning dependency.
53        dep: String,
54        /// The symbolic sub-path being resolved.
55        path: String,
56        /// The competing on-disk entry names.
57        entries: Vec<String>,
58    },
59
60    /// A dependency directory did not contain a `module.json`.
61    #[error(
62        "no `module.json` found at `{path}`; the dependency is not a WDL module (or the `path` is \
63         wrong)"
64    )]
65    MissingManifest {
66        /// The missing manifest path.
67        path: PathBuf,
68    },
69
70    /// The dependency graph contains a cycle.
71    #[cfg(feature = "git-resolver")]
72    #[error("dependency cycle: {}", format_cycle(.path))]
73    Cycle {
74        /// The cycle path, in resolution order.
75        path: Vec<String>,
76    },
77
78    /// No discovered version satisfies the dependency's version
79    /// requirement.
80    #[cfg(feature = "git-resolver")]
81    #[error(
82        "{}",
83        no_satisfying_version_message(.dep, .requirement, .considered, .path.as_deref())
84    )]
85    NoSatisfyingVersion {
86        /// The dependency name.
87        dep: String,
88        /// The unmet version requirement.
89        requirement: VersionRequirement,
90        /// The versions discovered before filtering by the requirement.
91        considered: Vec<Version>,
92        /// Optional path prefix used for path-scoped tags.
93        path: Option<String>,
94    },
95
96    /// A dependency is not present in the lockfile. Run
97    /// `sprocket dev module lock` to update it.
98    #[error("`{dep}` is not in `module-lock.json`; run `sprocket dev module lock` to update")]
99    NotInLockfile {
100        /// The missing dependency.
101        dep: String,
102    },
103
104    /// A locked Git dependency has not been fetched into the cache yet.
105    #[cfg(feature = "git-resolver")]
106    #[error("`{dep}` is not fetched in the module cache; run `sprocket dev module fetch`")]
107    NotFetched {
108        /// The dependency that is missing from cache.
109        dep: String,
110    },
111
112    /// The manifest source for a dependency does not match the
113    /// lockfile source. Run `sprocket dev module lock` to update.
114    #[error(
115        "`{dep}` manifest source differs from the lockfile; run `sprocket dev module lock` to \
116         update"
117    )]
118    LockfileSourceMismatch {
119        /// The dependency whose source changed.
120        dep: String,
121    },
122
123    /// A cached module's content hash does not match the lockfile's
124    /// recorded checksum.
125    #[cfg(feature = "git-resolver")]
126    #[error(
127        "cached `{dep}` content hash does not match the lockfile (expected `{expected}`, observed \
128         `{observed}`)"
129    )]
130    ChecksumMismatch {
131        /// The owning dependency.
132        dep: String,
133        /// The hash recorded in the lockfile.
134        expected: ContentHash,
135        /// The hash observed in the cache.
136        observed: ContentHash,
137    },
138
139    /// A cached module's signature key does not match the lockfile's
140    /// recorded signer.
141    #[error(
142        "signer for `{dep}` has changed since the lockfile was written ({})",
143        trust_all_hint(.observed.as_ref(), None)
144    )]
145    SignerKeyMismatch {
146        /// The owning dependency.
147        dep: String,
148        /// The source URL or path to trust.
149        source_url: Option<String>,
150        /// The subdirectory module path, when present.
151        path: Option<String>,
152        /// The signer key recorded in the lockfile.
153        expected: Box<VerifyingKey>,
154        /// The signer key observed in the cache.
155        observed: Box<VerifyingKey>,
156    },
157
158    /// A locked dependency signer is not present in the trust store.
159    #[error(
160        "`{dep}` is signed by an untrusted key ({})",
161        trust_all_hint(.signer.as_ref(), .identity.as_ref())
162    )]
163    UntrustedSigner {
164        /// The owning dependency.
165        dep: String,
166        /// The signer key recorded in the lockfile.
167        signer: Box<VerifyingKey>,
168        /// Optional signer identity metadata.
169        identity: Option<SignerIdentity>,
170    },
171
172    /// A dependency was unsigned when locked but now has a signature.
173    #[error(
174        "`{dep}` gained an unexpected signature after the lockfile was written ({})",
175        trust_all_hint(.observed.as_ref(), .identity.as_ref())
176    )]
177    UnexpectedSigner {
178        /// The owning dependency.
179        dep: String,
180        /// The signer key observed in `module.sig`.
181        observed: Box<VerifyingKey>,
182        /// Authenticated signer identity metadata.
183        identity: Option<SignerIdentity>,
184    },
185
186    /// A dependency was signed when the lockfile was written but is now
187    /// unsigned. This prevents a supply-chain downgrade where an
188    /// attacker strips the signature from a module whose content hash
189    /// has not changed (since `module.sig` is excluded from the hash).
190    #[error("`{dep}` was signed when locked but is now unsigned; this may indicate tampering")]
191    SignatureDowngrade {
192        /// The owning dependency.
193        dep: String,
194        /// The signer key recorded in the lockfile.
195        expected_signer: Box<VerifyingKey>,
196    },
197
198    /// A Git tag or branch named in a dependency's selector does not
199    /// exist on the remote.
200    #[cfg(feature = "git-resolver")]
201    #[error("`{dep}` selector references unknown {kind} `{name}`")]
202    UnknownGitRef {
203        /// The owning dependency.
204        dep: String,
205        /// The kind of ref that was missing.
206        kind: GitRefKind,
207        /// The ref name as it appeared in the manifest.
208        name: String,
209    },
210
211    /// A `commit` selector did not parse as a valid 40-character lowercase
212    /// hex SHA.
213    #[cfg(feature = "git-resolver")]
214    #[error("`{dep}` `commit` value `{value}` is not a valid Git commit SHA")]
215    InvalidCommit {
216        /// The owning dependency.
217        dep: String,
218        /// The unparsable value.
219        value: String,
220    },
221
222    /// A `module.sig` file was present but failed to verify against the
223    /// observed content hash.
224    #[cfg(feature = "git-resolver")]
225    #[error(
226        "`{dep}` signature does not match observed content (signer: `{}`)",
227        signer.to_openssh()
228    )]
229    SignatureVerificationFailed {
230        /// The owning dependency.
231        dep: String,
232        /// The signer key from the rejected `module.sig`.
233        signer: Box<VerifyingKey>,
234    },
235
236    /// A `module.sig` file failed to parse.
237    #[cfg(feature = "git-resolver")]
238    #[error("`{dep}` `module.sig` failed to parse")]
239    SignatureParse {
240        /// The owning dependency.
241        dep: String,
242        /// The underlying parse error.
243        #[source]
244        source: crate::signing::SignatureFileError,
245    },
246
247    /// A manifest `exclude` pattern is not a valid glob.
248    #[cfg(feature = "git-resolver")]
249    #[error("invalid `exclude` pattern `{pattern}`")]
250    InvalidExclude {
251        /// The offending pattern.
252        pattern: String,
253        /// The underlying glob error.
254        #[source]
255        source: globset::Error,
256    },
257
258    /// `require_signed` is enabled and the dependency is unsigned.
259    #[error("`{dep}` is unsigned but `require_signed` is enabled")]
260    RequireSignedViolation {
261        /// The unsigned dependency.
262        dep: String,
263    },
264
265    /// A transitive dependency declared a local-path source from a
266    /// non-local parent.
267    #[cfg(feature = "git-resolver")]
268    #[error(
269        "`{dep}` declares a local-path source but is reachable through a non-local parent; only \
270         locally-rooted projects may use local-path dependencies"
271    )]
272    LocalPathInTransitive {
273        /// The offending dependency name.
274        dep: String,
275    },
276
277    /// A dependency declared by the consumer was missing from the
278    /// freshly-resolved tree and not satisfied by the prior lockfile.
279    #[cfg(feature = "git-resolver")]
280    #[error("`{dep}` is declared by the consumer but absent from the freshly-resolved tree")]
281    MissingFreshDependency {
282        /// The missing dependency name.
283        dep: String,
284    },
285
286    /// A Git URL violates the configured scheme policy.
287    #[cfg(feature = "git-resolver")]
288    #[error("`{dep}` git URL `{url}` uses scheme `{scheme}` which is not allowed by policy")]
289    GitUrlPolicyViolation {
290        /// The owning dependency.
291        dep: String,
292        /// The rejected URL.
293        url: String,
294        /// The rejected scheme.
295        scheme: String,
296    },
297
298    /// DNS resolution for a Git URL's hostname failed. The resolver
299    /// rejects the URL rather than allowing a potentially spoofed host
300    /// through.
301    #[cfg(feature = "git-resolver")]
302    #[error("`{dep}` git URL `{url}` host `{host}` could not be resolved")]
303    GitHostResolutionFailed {
304        /// The owning dependency.
305        dep: String,
306        /// The URL that failed resolution.
307        url: String,
308        /// The hostname that could not be resolved.
309        host: String,
310    },
311
312    /// A Git URL violates the configured host policy.
313    #[cfg(feature = "git-resolver")]
314    #[error("`{dep}` git URL `{url}` targets host `{host}` which is not allowed by policy")]
315    GitHostPolicyViolation {
316        /// The owning dependency.
317        dep: String,
318        /// The rejected URL.
319        url: String,
320        /// The rejected host.
321        host: String,
322    },
323
324    /// A Git URL's host is not in the configured allow list for its scope.
325    #[cfg(feature = "git-resolver")]
326    #[error(
327        "`{dep}` git URL `{url}` targets host `{host}` which is not in the configured allow list; \
328         to allow it, add `{host}` to `{config_key}` in the `[modules]` section of your \
329         `sprocket.toml`"
330    )]
331    GitHostNotAllowed {
332        /// The owning dependency.
333        dep: String,
334        /// The rejected URL.
335        url: String,
336        /// The rejected host.
337        host: String,
338        /// The config key that would permit the host for this scope.
339        config_key: &'static str,
340    },
341
342    /// A materialized module tree exceeded configured resource limits.
343    #[cfg(feature = "git-resolver")]
344    #[error("`{dep}` materialized tree exceeds limits (files: {files}, bytes: {bytes})")]
345    MaterializedTreeLimitExceeded {
346        /// The owning dependency.
347        dep: String,
348        /// Number of files observed.
349        files: usize,
350        /// Total bytes observed.
351        bytes: u64,
352    },
353
354    /// A Git operation failed.
355    #[cfg(feature = "git-resolver")]
356    #[error(transparent)]
357    Git(#[from] crate::resolver::git::ops::GitError),
358
359    /// A materialized module contains a symbolic link, which is not
360    /// permitted anywhere in a module tree.
361    #[cfg(feature = "git-resolver")]
362    #[error("`{dep}` contains a symbolic link, which is not permitted in a module: `{path}`")]
363    MaterializedSymlink {
364        /// The owning dependency.
365        dep: String,
366        /// The offending path.
367        path: PathBuf,
368    },
369
370    /// A quoted `import` inside a module resolves to a file outside the
371    /// module root, which makes the module invalid.
372    #[error(
373        "`{dep}` file `{file}` has a quoted import `{import}` that resolves outside the module \
374         root"
375    )]
376    QuotedImportEscapesRoot {
377        /// The owning dependency.
378        dep: String,
379        /// The `.wdl` file containing the offending import, relative to
380        /// the module root.
381        file: String,
382        /// The offending import target as written.
383        import: String,
384    },
385
386    /// An I/O error.
387    #[error("i/o error at `{path}`")]
388    Io {
389        /// The path involved.
390        path: PathBuf,
391        /// The underlying I/O error.
392        #[source]
393        source: std::io::Error,
394    },
395
396    /// A module-walk error (symlink containment, metadata target, etc.).
397    #[cfg(feature = "git-resolver")]
398    #[error(transparent)]
399    Walk(#[from] ModuleWalkError),
400
401    /// Hashing a cache leaf or local path failed.
402    #[cfg(feature = "git-resolver")]
403    #[error(transparent)]
404    Hash(#[from] HashError),
405
406    /// A `Manifest` parse or validation error.
407    #[error(transparent)]
408    Manifest(#[from] ManifestError),
409
410    /// A `Lockfile` parse or validation error.
411    #[error(transparent)]
412    Lockfile(#[from] LockfileError),
413
414    /// A `RelativePath` validation error.
415    #[error(transparent)]
416    RelativePath(#[from] crate::relative_path::RelativePathError),
417}
418
419/// The kind of Git reference named in a `ResolverError::UnknownGitRef` variant.
420#[cfg(feature = "git-resolver")]
421#[derive(Clone, Copy, Debug, PartialEq, Eq)]
422pub enum GitRefKind {
423    /// The reference was an annotated or lightweight tag.
424    Tag,
425    /// The reference was a branch (head).
426    Branch,
427}
428
429#[cfg(feature = "git-resolver")]
430impl std::fmt::Display for GitRefKind {
431    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432        match self {
433            Self::Tag => f.write_str("tag"),
434            Self::Branch => f.write_str("branch"),
435        }
436    }
437}
438
439/// The kind of file lookup that failed in a [`ResolverError::MissingFile`].
440#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub enum MissingFileKind {
442    /// The dependency's entrypoint file (manifest `entrypoint` or default
443    /// `index.wdl`) was missing.
444    Entrypoint,
445    /// The symbolic-import sub-path resolved to a file that does not
446    /// exist.
447    SubPath,
448    /// The path exists but the manifest's `exclude` masks it.
449    Excluded,
450}
451
452/// Renders the message for a [`ResolverError::MissingFile`].
453fn missing_file_message(dep: &str, path: &std::path::Path, kind: &MissingFileKind) -> String {
454    let p = path.display();
455    match kind {
456        MissingFileKind::Entrypoint => {
457            format!("`{dep}` declares entrypoint `{p}` but the file does not exist")
458        }
459        MissingFileKind::SubPath => format!("`{dep}/{p}` not found"),
460        MissingFileKind::Excluded => format!("`{dep}/{p}` is excluded by the module manifest"),
461    }
462}
463
464/// Renders a cycle path as a chain of arrows for error display.
465#[cfg(feature = "git-resolver")]
466fn format_cycle(path: &[String]) -> String {
467    path.join(" → ")
468}
469
470/// Renders a list of versions for error display, or `<none>` when empty.
471#[cfg(feature = "git-resolver")]
472fn format_versions(versions: &[Version]) -> String {
473    if versions.is_empty() {
474        return "<none>".to_string();
475    }
476    versions
477        .iter()
478        .map(ToString::to_string)
479        .collect::<Vec<_>>()
480        .join(", ")
481}
482
483/// Renders the `module trust all` command hint for a changed signer.
484fn trust_all_hint(observed: &VerifyingKey, identity: Option<&SignerIdentity>) -> String {
485    let key = render_signer(observed, identity);
486    format!("{key}; run `sprocket dev module trust all` to accept signer trust changes")
487}
488
489/// Renders a signer key with optional identity metadata.
490fn render_signer(key: &VerifyingKey, identity: Option<&SignerIdentity>) -> String {
491    let key = key.to_openssh();
492    if let Some(identity) = identity {
493        match identity {
494            SignerIdentity::Signer { name, email } => format!("{key} {name} <{email}>"),
495            SignerIdentity::Comment { comment } => format!("{key} {comment}"),
496        }
497    } else {
498        key
499    }
500}
501
502/// Renders the no-satisfying-version error with an optional path-scoped hint.
503#[cfg(feature = "git-resolver")]
504fn no_satisfying_version_message(
505    dep: &str,
506    requirement: &VersionRequirement,
507    considered: &[Version],
508    path: Option<&str>,
509) -> String {
510    let mut message = format!(
511        "no version satisfies `{dep}` requirement `{requirement}` (considered: {})",
512        format_versions(considered)
513    );
514    if let Some(path) = path {
515        message.push_str(&format!(
516            "; for a subdirectory module, Git tags must be named `{path}/v<semver>`"
517        ));
518    }
519    message
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    fn dep() -> String {
527        "foo".to_string()
528    }
529
530    fn key(s: &str) -> Box<VerifyingKey> {
531        // The test passes complete OpenSSH Ed25519 public keys.
532        Box::new(s.parse().unwrap())
533    }
534
535    #[test]
536    fn missing_file_kind_renders_distinctly() {
537        let entry = ResolverError::MissingFile {
538            dep: dep(),
539            path: "index.wdl".into(),
540            kind: MissingFileKind::Entrypoint,
541        };
542        let sub = ResolverError::MissingFile {
543            dep: dep(),
544            path: "missing.wdl".into(),
545            kind: MissingFileKind::SubPath,
546        };
547        let excl = ResolverError::MissingFile {
548            dep: dep(),
549            path: "internal/x.wdl".into(),
550            kind: MissingFileKind::Excluded,
551        };
552
553        assert!(entry.to_string().contains("entrypoint"));
554        assert!(sub.to_string().contains("not found"));
555        assert!(excl.to_string().contains("excluded"));
556    }
557
558    #[cfg(feature = "git-resolver")]
559    #[test]
560    fn no_satisfying_version_includes_path_hint_when_present() {
561        let err = ResolverError::NoSatisfyingVersion {
562            dep: dep(),
563            requirement: "^2".parse().unwrap(),
564            considered: vec!["1.0.0".parse().unwrap()],
565            path: Some("tasks".to_string()),
566        };
567        assert!(err.to_string().contains("tasks/v<semver>"));
568    }
569
570    #[test]
571    fn signer_mismatch_includes_key_and_trust_all_command() {
572        let err = ResolverError::SignerKeyMismatch {
573            dep: "divination".to_string(),
574            source_url: Some("file:///spellbook".to_string()),
575            path: Some("modules/divination".to_string()),
576            expected: key(
577                "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINiRUmfYzFTjksGItM2fSm9s1eCL8NnMJGQgW724Uph1"
578            ),
579            observed: key(
580                "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIX5S41sfLWGBzdeYMeIAT8E96dtk+ymT4WqiY7oq+21"
581            ),
582        };
583
584        let message = err.to_string();
585        assert!(message.contains(
586            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIX5S41sfLWGBzdeYMeIAT8E96dtk+ymT4WqiY7oq+21"
587        ));
588        assert!(message.contains("sprocket dev module trust all"));
589    }
590}