1use 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#[derive(Debug, Error)]
25pub enum ResolverError {
26 #[error("`{name}` is not a declared dependency")]
29 NotADependency {
30 name: String,
32 },
33
34 #[error("{}", missing_file_message(.dep, .path, .kind))]
36 MissingFile {
37 dep: String,
39 path: PathBuf,
41 kind: MissingFileKind,
43 },
44
45 #[error(
48 "symbolic path `{path}` in `{dep}` is ambiguous: it matches multiple entries ({})",
49 .entries.join(", ")
50 )]
51 AmbiguousSubPath {
52 dep: String,
54 path: String,
56 entries: Vec<String>,
58 },
59
60 #[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 path: PathBuf,
68 },
69
70 #[cfg(feature = "git-resolver")]
72 #[error("dependency cycle: {}", format_cycle(.path))]
73 Cycle {
74 path: Vec<String>,
76 },
77
78 #[cfg(feature = "git-resolver")]
81 #[error(
82 "{}",
83 no_satisfying_version_message(.dep, .requirement, .considered, .path.as_deref())
84 )]
85 NoSatisfyingVersion {
86 dep: String,
88 requirement: VersionRequirement,
90 considered: Vec<Version>,
92 path: Option<String>,
94 },
95
96 #[error("`{dep}` is not in `module-lock.json`; run `sprocket dev module lock` to update")]
99 NotInLockfile {
100 dep: String,
102 },
103
104 #[cfg(feature = "git-resolver")]
106 #[error("`{dep}` is not fetched in the module cache; run `sprocket dev module fetch`")]
107 NotFetched {
108 dep: String,
110 },
111
112 #[error(
115 "`{dep}` manifest source differs from the lockfile; run `sprocket dev module lock` to \
116 update"
117 )]
118 LockfileSourceMismatch {
119 dep: String,
121 },
122
123 #[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 dep: String,
133 expected: ContentHash,
135 observed: ContentHash,
137 },
138
139 #[error(
142 "signer for `{dep}` has changed since the lockfile was written ({})",
143 trust_all_hint(.observed.as_ref(), None)
144 )]
145 SignerKeyMismatch {
146 dep: String,
148 source_url: Option<String>,
150 path: Option<String>,
152 expected: Box<VerifyingKey>,
154 observed: Box<VerifyingKey>,
156 },
157
158 #[error(
160 "`{dep}` is signed by an untrusted key ({})",
161 trust_all_hint(.signer.as_ref(), .identity.as_ref())
162 )]
163 UntrustedSigner {
164 dep: String,
166 signer: Box<VerifyingKey>,
168 identity: Option<SignerIdentity>,
170 },
171
172 #[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 dep: String,
180 observed: Box<VerifyingKey>,
182 identity: Option<SignerIdentity>,
184 },
185
186 #[error("`{dep}` was signed when locked but is now unsigned; this may indicate tampering")]
191 SignatureDowngrade {
192 dep: String,
194 expected_signer: Box<VerifyingKey>,
196 },
197
198 #[cfg(feature = "git-resolver")]
201 #[error("`{dep}` selector references unknown {kind} `{name}`")]
202 UnknownGitRef {
203 dep: String,
205 kind: GitRefKind,
207 name: String,
209 },
210
211 #[cfg(feature = "git-resolver")]
214 #[error("`{dep}` `commit` value `{value}` is not a valid Git commit SHA")]
215 InvalidCommit {
216 dep: String,
218 value: String,
220 },
221
222 #[cfg(feature = "git-resolver")]
225 #[error(
226 "`{dep}` signature does not match observed content (signer: `{}`)",
227 signer.to_openssh()
228 )]
229 SignatureVerificationFailed {
230 dep: String,
232 signer: Box<VerifyingKey>,
234 },
235
236 #[cfg(feature = "git-resolver")]
238 #[error("`{dep}` `module.sig` failed to parse")]
239 SignatureParse {
240 dep: String,
242 #[source]
244 source: crate::signing::SignatureFileError,
245 },
246
247 #[cfg(feature = "git-resolver")]
249 #[error("invalid `exclude` pattern `{pattern}`")]
250 InvalidExclude {
251 pattern: String,
253 #[source]
255 source: globset::Error,
256 },
257
258 #[error("`{dep}` is unsigned but `require_signed` is enabled")]
260 RequireSignedViolation {
261 dep: String,
263 },
264
265 #[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 dep: String,
275 },
276
277 #[cfg(feature = "git-resolver")]
280 #[error("`{dep}` is declared by the consumer but absent from the freshly-resolved tree")]
281 MissingFreshDependency {
282 dep: String,
284 },
285
286 #[cfg(feature = "git-resolver")]
288 #[error("`{dep}` git URL `{url}` uses scheme `{scheme}` which is not allowed by policy")]
289 GitUrlPolicyViolation {
290 dep: String,
292 url: String,
294 scheme: String,
296 },
297
298 #[cfg(feature = "git-resolver")]
302 #[error("`{dep}` git URL `{url}` host `{host}` could not be resolved")]
303 GitHostResolutionFailed {
304 dep: String,
306 url: String,
308 host: String,
310 },
311
312 #[cfg(feature = "git-resolver")]
314 #[error("`{dep}` git URL `{url}` targets host `{host}` which is not allowed by policy")]
315 GitHostPolicyViolation {
316 dep: String,
318 url: String,
320 host: String,
322 },
323
324 #[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 dep: String,
334 url: String,
336 host: String,
338 config_key: &'static str,
340 },
341
342 #[cfg(feature = "git-resolver")]
344 #[error("`{dep}` materialized tree exceeds limits (files: {files}, bytes: {bytes})")]
345 MaterializedTreeLimitExceeded {
346 dep: String,
348 files: usize,
350 bytes: u64,
352 },
353
354 #[cfg(feature = "git-resolver")]
356 #[error(transparent)]
357 Git(#[from] crate::resolver::git::ops::GitError),
358
359 #[cfg(feature = "git-resolver")]
362 #[error("`{dep}` contains a symbolic link, which is not permitted in a module: `{path}`")]
363 MaterializedSymlink {
364 dep: String,
366 path: PathBuf,
368 },
369
370 #[error(
373 "`{dep}` file `{file}` has a quoted import `{import}` that resolves outside the module \
374 root"
375 )]
376 QuotedImportEscapesRoot {
377 dep: String,
379 file: String,
382 import: String,
384 },
385
386 #[error("i/o error at `{path}`")]
388 Io {
389 path: PathBuf,
391 #[source]
393 source: std::io::Error,
394 },
395
396 #[cfg(feature = "git-resolver")]
398 #[error(transparent)]
399 Walk(#[from] ModuleWalkError),
400
401 #[cfg(feature = "git-resolver")]
403 #[error(transparent)]
404 Hash(#[from] HashError),
405
406 #[error(transparent)]
408 Manifest(#[from] ManifestError),
409
410 #[error(transparent)]
412 Lockfile(#[from] LockfileError),
413
414 #[error(transparent)]
416 RelativePath(#[from] crate::relative_path::RelativePathError),
417}
418
419#[cfg(feature = "git-resolver")]
421#[derive(Clone, Copy, Debug, PartialEq, Eq)]
422pub enum GitRefKind {
423 Tag,
425 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
441pub enum MissingFileKind {
442 Entrypoint,
445 SubPath,
448 Excluded,
450}
451
452fn 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#[cfg(feature = "git-resolver")]
466fn format_cycle(path: &[String]) -> String {
467 path.join(" → ")
468}
469
470#[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
483fn 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
489fn 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#[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 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}