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::VerifyingKey;
18#[cfg(feature = "git-resolver")]
19use crate::version_requirement::VersionRequirement;
20
21/// An error returned by the [`Resolver`](crate::Resolver) trait or
22/// any resolver-layer operation.
23#[derive(Debug, Error)]
24pub enum ResolverError {
25    /// The symbolic path's head does not appear in the consumer's
26    /// `dependencies` map.
27    #[error("`{name}` is not a declared dependency")]
28    NotADependency {
29        /// The undeclared dependency name.
30        name: String,
31    },
32
33    /// A required file was not found, or was excluded.
34    #[error("{}", missing_file_message(.dep, .path, .kind))]
35    MissingFile {
36        /// The owning dependency.
37        dep: String,
38        /// The relative path that was looked up.
39        path: PathBuf,
40        /// Which kind of lookup failed.
41        kind: MissingFileKind,
42    },
43
44    /// A path-prefixed Git tag's `module.json` declares a different
45    /// version than the tag itself.
46    #[cfg(feature = "git-resolver")]
47    #[error("tag `{tag}` points to a `module.json` declaring version `{declared}`")]
48    TagManifestMismatch {
49        /// The tag name (after stripping any path prefix).
50        tag: String,
51        /// The version declared in the tagged commit's `module.json`.
52        declared: Version,
53    },
54
55    /// The dependency graph contains a cycle.
56    #[cfg(feature = "git-resolver")]
57    #[error("dependency cycle: {}", format_cycle(.path))]
58    Cycle {
59        /// The cycle path, in resolution order.
60        path: Vec<String>,
61    },
62
63    /// No discovered version satisfies the dependency's version
64    /// requirement.
65    #[cfg(feature = "git-resolver")]
66    #[error(
67        "no version satisfies `{dep}` requirement `{requirement}` (considered: {})",
68        format_versions(.considered)
69    )]
70    NoSatisfyingVersion {
71        /// The dependency name.
72        dep: String,
73        /// The unmet version requirement.
74        requirement: VersionRequirement,
75        /// The versions discovered before filtering by the requirement.
76        considered: Vec<Version>,
77    },
78
79    /// A dependency is not present in the lockfile. Run
80    /// `sprocket module lock` to update it.
81    #[error("`{dep}` is not in `module-lock.json`; run `sprocket module lock` to update")]
82    NotInLockfile {
83        /// The missing dependency.
84        dep: String,
85    },
86
87    /// The manifest source for a dependency does not match the
88    /// lockfile source. Run `sprocket module lock` to update.
89    #[error(
90        "`{dep}` manifest source differs from the lockfile; run `sprocket module lock` to update"
91    )]
92    LockfileSourceMismatch {
93        /// The dependency whose source changed.
94        dep: String,
95    },
96
97    /// A cached module's content hash does not match the lockfile's
98    /// recorded checksum.
99    #[cfg(feature = "git-resolver")]
100    #[error(
101        "cached `{dep}` content hash does not match the lockfile (expected `{expected}`, observed \
102         `{observed}`)"
103    )]
104    ChecksumMismatch {
105        /// The owning dependency.
106        dep: String,
107        /// The hash recorded in the lockfile.
108        expected: ContentHash,
109        /// The hash observed in the cache.
110        observed: ContentHash,
111    },
112
113    /// A cached module's signature key does not match the lockfile's
114    /// recorded signer.
115    #[error(
116        "signer for `{dep}` has changed since the lockfile was written (run `sprocket module \
117         trust {dep}` to accept the new key)"
118    )]
119    SignerKeyMismatch {
120        /// The owning dependency.
121        dep: String,
122        /// The signer key recorded in the lockfile.
123        expected: Box<VerifyingKey>,
124        /// The signer key observed in the cache.
125        observed: Box<VerifyingKey>,
126    },
127
128    /// A dependency was signed when the lockfile was written but is now
129    /// unsigned. This prevents a supply-chain downgrade where an
130    /// attacker strips the signature from a module whose content hash
131    /// has not changed (since `module.sig` is excluded from the hash).
132    #[error("`{dep}` was signed when locked but is now unsigned; this may indicate tampering")]
133    SignatureDowngrade {
134        /// The owning dependency.
135        dep: String,
136        /// The signer key recorded in the lockfile.
137        expected_signer: Box<VerifyingKey>,
138    },
139
140    /// A Git tag or branch named in a dependency's selector does not
141    /// exist on the remote.
142    #[cfg(feature = "git-resolver")]
143    #[error("`{dep}` selector references unknown {kind} `{name}`")]
144    UnknownGitRef {
145        /// The owning dependency.
146        dep: String,
147        /// The kind of ref that was missing.
148        kind: GitRefKind,
149        /// The ref name as it appeared in the manifest.
150        name: String,
151    },
152
153    /// A `commit` selector did not parse as a valid 40-character lowercase
154    /// hex SHA.
155    #[cfg(feature = "git-resolver")]
156    #[error("`{dep}` `commit` value `{value}` is not a valid Git commit SHA")]
157    InvalidCommit {
158        /// The owning dependency.
159        dep: String,
160        /// The unparsable value.
161        value: String,
162    },
163
164    /// A `module.sig` file was present but failed to verify against the
165    /// observed content hash.
166    #[cfg(feature = "git-resolver")]
167    #[error(
168        "`{dep}` signature does not match observed content (signer: `{}`)",
169        signer.to_openssh()
170    )]
171    SignatureVerificationFailed {
172        /// The owning dependency.
173        dep: String,
174        /// The signer key from the rejected `module.sig`.
175        signer: Box<VerifyingKey>,
176    },
177
178    /// A `module.sig` file failed to parse.
179    #[cfg(feature = "git-resolver")]
180    #[error("`{dep}` `module.sig` failed to parse")]
181    SignatureParse {
182        /// The owning dependency.
183        dep: String,
184        /// The underlying parse error.
185        #[source]
186        source: crate::signing::SignatureFileError,
187    },
188
189    /// A manifest `exclude` pattern is not a valid glob.
190    #[cfg(feature = "git-resolver")]
191    #[error("invalid `exclude` pattern `{pattern}`")]
192    InvalidExclude {
193        /// The offending pattern.
194        pattern: String,
195        /// The underlying glob error.
196        #[source]
197        source: globset::Error,
198    },
199
200    /// `require_signed` is enabled and the dependency is unsigned.
201    #[error("`{dep}` is unsigned but `require_signed` is enabled")]
202    RequireSignedViolation {
203        /// The unsigned dependency.
204        dep: String,
205    },
206
207    /// A transitive dependency declared a local-path source from a
208    /// non-local parent.
209    #[cfg(feature = "git-resolver")]
210    #[error(
211        "`{dep}` declares a local-path source but is reachable through a non-local parent; only \
212         locally-rooted projects may use local-path dependencies"
213    )]
214    LocalPathInTransitive {
215        /// The offending dependency name.
216        dep: String,
217    },
218
219    /// A dependency declared by the consumer was missing from the
220    /// freshly-resolved tree and not satisfied by the prior lockfile.
221    #[cfg(feature = "git-resolver")]
222    #[error("`{dep}` is declared by the consumer but absent from the freshly-resolved tree")]
223    MissingFreshDependency {
224        /// The missing dependency name.
225        dep: String,
226    },
227
228    /// A Git URL violates the configured scheme policy.
229    #[cfg(feature = "git-resolver")]
230    #[error("`{dep}` git URL `{url}` uses scheme `{scheme}` which is not allowed by policy")]
231    GitUrlPolicyViolation {
232        /// The owning dependency.
233        dep: String,
234        /// The rejected URL.
235        url: String,
236        /// The rejected scheme.
237        scheme: String,
238    },
239
240    /// DNS resolution for a Git URL's hostname failed. The resolver
241    /// rejects the URL rather than allowing a potentially spoofed host
242    /// through.
243    #[cfg(feature = "git-resolver")]
244    #[error("`{dep}` git URL `{url}` host `{host}` could not be resolved")]
245    GitHostResolutionFailed {
246        /// The owning dependency.
247        dep: String,
248        /// The URL that failed resolution.
249        url: String,
250        /// The hostname that could not be resolved.
251        host: String,
252    },
253
254    /// A Git URL violates the configured host policy.
255    #[cfg(feature = "git-resolver")]
256    #[error("`{dep}` git URL `{url}` targets host `{host}` which is not allowed by policy")]
257    GitHostPolicyViolation {
258        /// The owning dependency.
259        dep: String,
260        /// The rejected URL.
261        url: String,
262        /// The rejected host.
263        host: String,
264    },
265
266    /// A Git URL's host is not in the configured allow list for its scope.
267    #[cfg(feature = "git-resolver")]
268    #[error(
269        "`{dep}` git URL `{url}` targets host `{host}` which is not in the configured allow list; \
270         to allow it, add `{host}` to `{config_key}` in the `[modules]` section of your \
271         `sprocket.toml`"
272    )]
273    GitHostNotAllowed {
274        /// The owning dependency.
275        dep: String,
276        /// The rejected URL.
277        url: String,
278        /// The rejected host.
279        host: String,
280        /// The config key that would permit the host for this scope.
281        config_key: &'static str,
282    },
283
284    /// A materialized module tree exceeded configured resource limits.
285    #[cfg(feature = "git-resolver")]
286    #[error("`{dep}` materialized tree exceeds limits (files: {files}, bytes: {bytes})")]
287    MaterializedTreeLimitExceeded {
288        /// The owning dependency.
289        dep: String,
290        /// Number of files observed.
291        files: usize,
292        /// Total bytes observed.
293        bytes: u64,
294    },
295
296    /// A Git operation failed.
297    #[cfg(feature = "git-resolver")]
298    #[error(transparent)]
299    Git(#[from] crate::resolver::git::GitError),
300
301    /// A materialized file resolved through a symlink that escapes the
302    /// module root.
303    #[cfg(feature = "git-resolver")]
304    #[error("`{dep}` materialized path escapes module root: `{path}`")]
305    MaterializedSymlinkEscape {
306        /// The owning dependency.
307        dep: String,
308        /// The escaping path as observed before canonicalization.
309        path: PathBuf,
310    },
311
312    /// An I/O error.
313    #[error("i/o error at `{path}`")]
314    Io {
315        /// The path involved.
316        path: PathBuf,
317        /// The underlying I/O error.
318        #[source]
319        source: std::io::Error,
320    },
321
322    /// A module-walk error (symlink containment, metadata target, etc.).
323    #[cfg(feature = "git-resolver")]
324    #[error(transparent)]
325    Walk(#[from] ModuleWalkError),
326
327    /// Hashing a cache leaf or local path failed.
328    #[cfg(feature = "git-resolver")]
329    #[error(transparent)]
330    Hash(#[from] HashError),
331
332    /// A `Manifest` parse or validation error.
333    #[error(transparent)]
334    Manifest(#[from] ManifestError),
335
336    /// A `Lockfile` parse or validation error.
337    #[error(transparent)]
338    Lockfile(#[from] LockfileError),
339
340    /// A `RelativePath` validation error.
341    #[error(transparent)]
342    RelativePath(#[from] crate::relative_path::RelativePathError),
343}
344
345/// The kind of Git reference named in a [`ResolverError::UnknownGitRef`].
346#[derive(Clone, Copy, Debug, PartialEq, Eq)]
347pub enum GitRefKind {
348    /// The reference was an annotated or lightweight tag.
349    Tag,
350    /// The reference was a branch (head).
351    Branch,
352}
353
354impl std::fmt::Display for GitRefKind {
355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        match self {
357            Self::Tag => f.write_str("tag"),
358            Self::Branch => f.write_str("branch"),
359        }
360    }
361}
362
363/// The kind of file lookup that failed in a [`ResolverError::MissingFile`].
364#[derive(Clone, Copy, Debug, PartialEq, Eq)]
365pub enum MissingFileKind {
366    /// The dependency's entrypoint file (manifest `entrypoint` or default
367    /// `index.wdl`) was missing.
368    Entrypoint,
369    /// The symbolic-import sub-path resolved to a file that does not
370    /// exist.
371    SubPath,
372    /// The path exists but the manifest's `exclude` masks it.
373    Excluded,
374}
375
376/// Renders the message for a [`ResolverError::MissingFile`].
377fn missing_file_message(dep: &str, path: &std::path::Path, kind: &MissingFileKind) -> String {
378    let p = path.display();
379    match kind {
380        MissingFileKind::Entrypoint => {
381            format!("`{dep}` declares entrypoint `{p}` but the file does not exist")
382        }
383        MissingFileKind::SubPath => format!("`{dep}/{p}` not found"),
384        MissingFileKind::Excluded => format!("`{dep}/{p}` is excluded by the module manifest"),
385    }
386}
387
388/// Renders a cycle path as a chain of arrows for error display.
389#[cfg(feature = "git-resolver")]
390fn format_cycle(path: &[String]) -> String {
391    path.join(" → ")
392}
393
394/// Renders a list of versions for error display, or `<none>` when empty.
395#[cfg(feature = "git-resolver")]
396fn format_versions(versions: &[Version]) -> String {
397    if versions.is_empty() {
398        return "<none>".to_string();
399    }
400    versions
401        .iter()
402        .map(ToString::to_string)
403        .collect::<Vec<_>>()
404        .join(", ")
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn dep() -> String {
412        "foo".to_string()
413    }
414
415    #[test]
416    fn missing_file_kind_renders_distinctly() {
417        let entry = ResolverError::MissingFile {
418            dep: dep(),
419            path: "index.wdl".into(),
420            kind: MissingFileKind::Entrypoint,
421        };
422        let sub = ResolverError::MissingFile {
423            dep: dep(),
424            path: "missing.wdl".into(),
425            kind: MissingFileKind::SubPath,
426        };
427        let excl = ResolverError::MissingFile {
428            dep: dep(),
429            path: "internal/x.wdl".into(),
430            kind: MissingFileKind::Excluded,
431        };
432
433        assert!(entry.to_string().contains("entrypoint"));
434        assert!(sub.to_string().contains("not found"));
435        assert!(excl.to_string().contains("excluded"));
436    }
437}