1use crate::ResolveTask;
2use crate::semver_util::highest_stable_version;
3use crate::trust::{MissingTimeDetails, TrustDowngradeDetails};
4use aube_codes::errors::*;
5use aube_registry::Packument;
6
7#[derive(Debug, thiserror::Error)]
8pub enum Error {
9 #[error("no version of {} matches range `{}`", .0.name, .0.range)]
10 NoMatch(Box<NoMatchDetails>),
11 #[error(
12 "no version of {} matching {} is older than {} minute(s) (minimumReleaseAgeStrict=true)",
13 .0.name, .0.range, .0.minutes
14 )]
15 AgeGate(Box<AgeGateDetails>),
16 #[error("registry error for {0}: {1}")]
17 Registry(String, String),
18 #[error(
19 "{}: catalog reference `{}` does not resolve — catalog `{}` is not defined (add it to `catalog:` / `catalogs.{}:` in pnpm-workspace.yaml, or under `workspaces.catalog` / `pnpm.catalog` in package.json)",
20 .0.name, .0.spec, .0.catalog, .0.catalog
21 )]
22 UnknownCatalog(Box<CatalogDetails>),
23 #[error(
24 "{}: catalog reference `{}` does not resolve — catalog `{}` has no entry for `{}`",
25 .0.name, .0.spec, .0.catalog, .0.name
26 )]
27 UnknownCatalogEntry(Box<CatalogDetails>),
28 #[error(
29 "blocked exotic transitive dependency {}@{} from {} (blockExoticSubdeps=true; set blockExoticSubdeps=false to allow trusted git/file/tarball subdeps)",
30 .0.name, .0.spec, .0.parent
31 )]
32 BlockedExoticSubdep(Box<ExoticSubdepDetails>),
33 #[error(
34 "trust downgrade for {}@{} (trustPolicy=no-downgrade): earlier published version {} had {} but this version has {}",
35 .0.name, .0.picked_version, .0.prior_version, .0.prior_evidence.label(),
36 .0.current_evidence.map_or("no trust evidence", |e| e.label())
37 )]
38 TrustDowngrade(Box<TrustDowngradeDetails>),
39 #[error(
40 "trust check failed for {}@{} (trustPolicy=no-downgrade): registry packument has no `time` entry for the picked version",
41 .0.name, .0.version
42 )]
43 TrustCheckMissingTime(Box<MissingTimeDetails>),
44 #[error(
45 "peer-context fixed-point did not converge after {0} iterations. mutually recursive peers, lockfile would be incomplete"
46 )]
47 PeerContextDivergence(usize),
48}
49
50#[derive(Debug)]
55pub struct NoMatchDetails {
56 pub name: String,
57 pub range: String,
58 pub importer: String,
59 pub ancestors: Vec<(String, String)>,
60 pub original_spec: Option<String>,
61 pub available: Vec<String>,
66 pub total_versions: usize,
71 pub only_prereleases: bool,
76}
77
78#[derive(Debug)]
79pub struct AgeGateDetails {
80 pub name: String,
81 pub range: String,
82 pub minutes: u64,
83 pub importer: String,
84 pub ancestors: Vec<(String, String)>,
85 pub gated: Vec<String>,
89}
90
91#[derive(Debug)]
92pub struct CatalogDetails {
93 pub name: String,
94 pub spec: String,
95 pub catalog: String,
96 pub available: Vec<String>,
101 pub chained_value: Option<String>,
107}
108
109#[derive(Debug)]
110pub struct ExoticSubdepDetails {
111 pub name: String,
112 pub spec: String,
113 pub parent: String,
114 pub ancestors: Vec<(String, String)>,
115 pub importer: String,
116}
117
118impl miette::Diagnostic for Error {
119 fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
120 Some(Box::new(match self {
121 Self::NoMatch(_) => ERR_AUBE_NO_MATCHING_VERSION,
122 Self::AgeGate(_) => ERR_AUBE_NO_MATURE_MATCHING_VERSION,
123 Self::Registry(_, _) => ERR_AUBE_REGISTRY_ERROR,
124 Self::UnknownCatalog(_) => ERR_AUBE_UNKNOWN_CATALOG,
125 Self::UnknownCatalogEntry(_) => ERR_AUBE_UNKNOWN_CATALOG_ENTRY,
126 Self::BlockedExoticSubdep(_) => ERR_AUBE_BLOCKED_EXOTIC_SUBDEP,
127 Self::TrustDowngrade(_) => ERR_AUBE_TRUST_DOWNGRADE,
128 Self::TrustCheckMissingTime(_) => ERR_AUBE_TRUST_MISSING_TIME,
129 Self::PeerContextDivergence(_) => ERR_AUBE_PEER_CONTEXT_NOT_CONVERGED,
130 }))
131 }
132
133 fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
134 match self {
135 Self::NoMatch(d) => Some(Box::new(format_no_match_help(d))),
136 Self::AgeGate(d) => Some(Box::new(format_age_gate_help(d))),
137 Self::Registry(name, msg) => Some(Box::new(format_registry_help(name, msg))),
138 Self::UnknownCatalog(d) => Some(Box::new(format_unknown_catalog_help(d))),
139 Self::UnknownCatalogEntry(d) => Some(Box::new(format_unknown_catalog_entry_help(d))),
140 Self::BlockedExoticSubdep(d) => Some(Box::new(format_exotic_subdep_help(d))),
141 Self::TrustDowngrade(d) => Some(Box::new(format_trust_downgrade_help(d))),
142 Self::TrustCheckMissingTime(d) => Some(Box::new(format_trust_missing_time_help(d))),
143 Self::PeerContextDivergence(_) => None,
144 }
145 }
146}
147
148fn format_trust_downgrade_help(d: &TrustDowngradeDetails) -> String {
149 format!(
150 "this is a supply-chain trust failure, not an ordinary version-resolution error. \
151 An earlier release carried {prior_evidence}, but {name}@{ver} carries {current_evidence}.\n\
152 \n\
153 This can signal a compromised publisher or tampered release. It can also be benign \
154 release-process drift: a maintainer manually published, backported outside the trusted \
155 workflow, skipped provenance for convenience, or used a registry that stripped metadata.\n\
156 \n\
157 Before bypassing:\n\
158 1. Inspect the package's npm release, source tag/commit, publisher identity, and tarball; \
159 compare the metadata with npmjs.org, and confirm the change is expected and nothing \
160 appears tampered with.\n\
161 2. Report inconsistent evidence to the relevant upstream owner. Package-release drift \
162 belongs with the maintainer; metadata present on npmjs.org but missing from a proxy or \
163 mirror belongs with that registry operator.\n\
164 3. Only after review, pin a version that retains evidence or add the narrow \
165 `{name}@{ver}` exception to `trustPolicyExclude`. A bare `{name}` exempts every version; \
166 `trustPolicy = off` disables this protection for the entire install.\n\
167 \n\
168 Details and known built-in exceptions: https://aube.jdx.dev/trust-policy-exceptions",
169 prior_evidence = d.prior_evidence.label(),
170 current_evidence = d
171 .current_evidence
172 .map_or("no trust evidence", |e| e.label()),
173 name = d.name,
174 ver = d.picked_version,
175 )
176}
177
178fn format_trust_missing_time_help(d: &MissingTimeDetails) -> String {
179 format!(
180 "trustPolicy=no-downgrade compares against per-version publish times in the packument. \
181 The registry serving {name} omitted `time[{ver}]` — check the registry config in .npmrc, \
182 or set `trustPolicy = off` to skip the check.",
183 name = d.name,
184 ver = d.version,
185 )
186}
187
188pub(crate) fn build_no_match(task: &ResolveTask, packument: &Packument) -> NoMatchDetails {
194 let mut stable: Vec<(node_semver::Version, &str)> = Vec::new();
195 let mut prerelease: Vec<(node_semver::Version, &str)> = Vec::new();
196 for v in packument.versions.keys() {
197 let Ok(parsed) = node_semver::Version::parse(v) else {
198 continue;
199 };
200 if parsed.pre_release.is_empty() {
201 stable.push((parsed, v.as_str()));
202 } else {
203 prerelease.push((parsed, v.as_str()));
204 }
205 }
206 stable.sort_by(|a, b| b.0.cmp(&a.0));
207 prerelease.sort_by(|a, b| b.0.cmp(&a.0));
208 let (pool, only_prereleases) = if stable.is_empty() {
209 (prerelease, true)
210 } else {
211 (stable, false)
212 };
213 let available = pool
214 .into_iter()
215 .take(5)
216 .map(|(_, s)| s.to_string())
217 .collect();
218 NoMatchDetails {
219 name: task.name.clone(),
220 range: task.range.clone(),
221 importer: task.importer.clone(),
222 ancestors: task.ancestors.to_vec(),
223 original_spec: task.original_specifier.clone(),
224 available,
225 total_versions: packument.versions.len(),
226 only_prereleases,
227 }
228}
229
230fn resolve_dist_tag_range(packument: &Packument, range_str: &str) -> String {
242 if let Some(tagged) = packument.dist_tags.get(range_str) {
243 tagged.clone()
244 } else if range_str == "latest"
245 && let Some(v) = highest_stable_version(packument)
246 {
247 v
248 } else {
249 range_str.to_string()
250 }
251}
252
253pub(crate) fn build_age_gate(
254 task: &ResolveTask,
255 packument: &Packument,
256 minutes: u64,
257) -> AgeGateDetails {
258 let effective = resolve_dist_tag_range(packument, &task.range);
264 let range = node_semver::Range::parse(&effective).ok();
265 let mut gated: Vec<(node_semver::Version, String)> = Vec::new();
266 if let Some(r) = range {
267 for ver in packument.versions.keys() {
268 let Ok(v) = node_semver::Version::parse(ver) else {
269 continue;
270 };
271 if !v.satisfies(&r) {
272 continue;
273 }
274 gated.push((v, ver.clone()));
275 }
276 }
277 gated.sort_by(|a, b| b.0.cmp(&a.0));
278 AgeGateDetails {
279 name: task.name.clone(),
280 range: task.range.clone(),
281 minutes,
282 importer: task.importer.clone(),
283 ancestors: task.ancestors.to_vec(),
284 gated: gated.into_iter().map(|(_, s)| s).collect(),
285 }
286}
287
288fn format_no_match_help(d: &NoMatchDetails) -> String {
289 let mut s = String::new();
290 push_importer(&mut s, &d.importer);
291 push_chain(&mut s, &d.ancestors, &d.name);
292 if let Some(orig) = &d.original_spec
293 && orig != &d.range
294 {
295 s.push_str(&format!(
296 "original spec: `{orig}` (rewritten to `{}`)\n",
297 d.range
298 ));
299 }
300 if d.available.is_empty() {
301 if d.total_versions == 0 {
302 s.push_str("packument has no versions — check that the package exists on the configured registry");
303 } else {
304 s.push_str(&format!(
305 "packument has {} unparseable version(s) — check registry for non-semver tags",
306 d.total_versions
307 ));
308 }
309 } else if d.only_prereleases {
310 s.push_str(&format!(
311 "no stable versions published; only prereleases available: {}\nhint: request a prerelease explicitly (e.g. `{}@{}`) or via the `next` dist-tag",
312 d.available.join(", "),
313 d.name,
314 d.available.first().map(String::as_str).unwrap_or("next"),
315 ));
316 } else {
317 s.push_str(&format!("available versions: {}", d.available.join(", ")));
318 }
319 s
320}
321
322fn format_age_gate_help(d: &AgeGateDetails) -> String {
323 let mut s = String::new();
324 push_importer(&mut s, &d.importer);
325 push_chain(&mut s, &d.ancestors, &d.name);
326 if !d.gated.is_empty() {
327 s.push_str(&format!(
328 "blocked by age gate: {}\n",
329 d.gated
330 .iter()
331 .take(5)
332 .cloned()
333 .collect::<Vec<_>>()
334 .join(", ")
335 ));
336 }
337 s.push_str("to bypass: loosen `minimumReleaseAge` in .npmrc, set `minimumReleaseAgeStrict=false` to fall back to the lowest satisfying version, or add `");
338 s.push_str(&d.name);
339 s.push_str("` to `minimumReleaseAgeExclude`");
340 s
341}
342
343pub(crate) fn format_registry_help(name: &str, msg: &str) -> String {
344 let kind = classify_registry_error(msg);
345 let mut s = String::new();
346 if !name.is_empty() && name != "(resolver)" {
347 s.push_str(&format!("package: {name}\n"));
348 }
349 s.push_str(match kind {
350 RegistryErrorKind::Tarball => {
351 "tarball download or integrity check failed — try `aube store prune` to clear the cache; if the lockfile references a tarball that moved, delete the lockfile entry for this package and re-resolve"
352 }
353 RegistryErrorKind::Fetch => {
354 "packument fetch failed — verify the registry URL in .npmrc, check auth (`npm login` / `NPM_TOKEN`), and confirm network connectivity"
355 }
356 RegistryErrorKind::Git => {
357 "git dep failed to resolve — confirm the ref exists, that credentials are configured for the host, and that the URL form is supported"
358 }
359 RegistryErrorKind::LocalSpec => {
360 "unparseable local specifier — `file:`/`link:`/`workspace:` paths must be relative to the importer, and `http(s):` URLs must end in `.tgz`"
361 }
362 RegistryErrorKind::Hook => {
363 "pnpmfile `readPackage` hook returned an error — check the hook's stack trace above for the underlying cause"
364 }
365 RegistryErrorKind::ResolverBug => {
366 "internal resolver invariant violated — please report at https://github.com/jdx/aube/discussions with the lockfile and command that reproduced this"
367 }
368 RegistryErrorKind::Generic => {
369 "registry operation failed — see the message above for the underlying cause"
370 }
371 });
372 s
373}
374
375fn format_unknown_catalog_help(d: &CatalogDetails) -> String {
376 let mut s = String::new();
377 if d.available.is_empty() {
378 s.push_str("no catalogs are defined in this workspace; add a `catalog:` block to `pnpm-workspace.yaml` or a `workspaces.catalog` entry in root `package.json`");
379 } else {
380 s.push_str(&format!("defined catalogs: {}", d.available.join(", ")));
381 }
382 s
383}
384
385fn format_unknown_catalog_entry_help(d: &CatalogDetails) -> String {
386 if let Some(chained) = &d.chained_value {
387 return format!(
388 "catalogs cannot chain — replace `{}` with a concrete semver range (e.g. `^1.0.0`) under the catalog entry",
389 chained
390 );
391 }
392 let mut s = String::new();
393 if d.available.is_empty() {
394 s.push_str(&format!(
395 "catalog `{}` is empty; add `{}: <version>` under `catalogs.{}` in pnpm-workspace.yaml",
396 d.catalog, d.name, d.catalog
397 ));
398 } else {
399 let suggestion = suggest_similar(&d.name, &d.available);
400 if let Some(best) = suggestion {
401 s.push_str(&format!(
402 "catalog `{}` defines: {} — did you mean `{}`?",
403 d.catalog,
404 truncate_list(&d.available, 8),
405 best
406 ));
407 } else {
408 s.push_str(&format!(
409 "catalog `{}` defines: {}",
410 d.catalog,
411 truncate_list(&d.available, 8)
412 ));
413 }
414 }
415 s
416}
417
418fn format_exotic_subdep_help(d: &ExoticSubdepDetails) -> String {
419 let mut s = String::new();
420 push_importer(&mut s, &d.importer);
421 push_chain(&mut s, &d.ancestors, &d.name);
422 s.push_str(&format!(
423 "to allow: either pin `{}` in your root package.json (moves the exotic spec out of the transitive graph), or set `blockExoticSubdeps=false` in .npmrc / settings.toml to trust every transitive git/file/tarball dep",
424 d.name
425 ));
426 s
427}
428
429fn push_importer(s: &mut String, importer: &str) {
430 if !importer.is_empty() && importer != "." {
431 s.push_str(&format!("importer: {importer}\n"));
432 }
433}
434
435fn push_chain(s: &mut String, ancestors: &[(String, String)], leaf: &str) {
436 if ancestors.is_empty() {
437 return;
438 }
439 s.push_str("chain: ");
440 for (i, (n, v)) in ancestors.iter().enumerate() {
441 if i > 0 {
442 s.push_str(" > ");
443 }
444 s.push_str(&format!("{n}@{v}"));
445 }
446 s.push_str(&format!(" > {leaf}\n"));
447}
448
449fn truncate_list(items: &[String], max: usize) -> String {
450 if items.len() <= max {
451 items.join(", ")
452 } else {
453 let (head, tail) = items.split_at(max);
454 format!("{} (+{} more)", head.join(", "), tail.len())
455 }
456}
457
458fn suggest_similar<'a>(needle: &str, choices: &'a [String]) -> Option<&'a str> {
464 let lower = needle.to_ascii_lowercase();
465 choices
466 .iter()
467 .map(String::as_str)
468 .find(|c| {
469 c.to_ascii_lowercase().contains(&lower) || lower.contains(&c.to_ascii_lowercase())
470 })
471 .or_else(|| {
472 choices
473 .iter()
474 .map(String::as_str)
475 .find(|c| c.chars().next() == needle.chars().next())
476 })
477}
478
479pub(crate) enum RegistryErrorKind {
480 Tarball,
481 Fetch,
482 Git,
483 LocalSpec,
484 Hook,
485 ResolverBug,
486 Generic,
487}
488
489pub(crate) fn classify_registry_error(msg: &str) -> RegistryErrorKind {
495 let lower = msg.to_ascii_lowercase();
496 if lower.starts_with("git resolve ")
501 || lower.starts_with("git dep ")
502 || lower.starts_with("git task ")
503 || lower.contains("git+")
504 {
505 RegistryErrorKind::Git
506 } else if lower.starts_with("readpackage ") || lower.contains("readpackage hook") {
507 RegistryErrorKind::Hook
508 } else if lower.starts_with("unparseable local specifier") || lower.contains("workspace:") {
509 RegistryErrorKind::LocalSpec
510 } else if lower.contains("tarball") || lower.contains("integrity") {
511 RegistryErrorKind::Tarball
512 } else if lower.starts_with("fetch ") || lower.contains("packument") || lower.contains("http") {
513 RegistryErrorKind::Fetch
514 } else if lower.contains("deferred") || lower.contains("invariant") {
515 RegistryErrorKind::ResolverBug
516 } else {
517 RegistryErrorKind::Generic
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use crate::trust::TrustEvidence;
525
526 #[test]
527 fn trust_downgrade_help_prioritizes_investigation_and_upstream_reporting() {
528 let help = format_trust_downgrade_help(&TrustDowngradeDetails {
529 name: "@scope/pkg".into(),
530 picked_version: "2.0.0".into(),
531 current_evidence: None,
532 prior_evidence: TrustEvidence::TrustedPublisher,
533 prior_version: "1.9.0".into(),
534 });
535
536 assert!(help.contains("not an ordinary version-resolution error"));
537 assert!(help.contains("carries no trust evidence"));
538 assert!(help.contains("confirm the change is expected and nothing appears tampered with"));
539 assert!(help.contains("Report inconsistent evidence to the relevant upstream owner"));
540 assert!(help.contains("belongs with that registry operator"));
541 assert!(help.contains("`@scope/pkg@2.0.0` exception"));
542 assert!(help.contains("A bare `@scope/pkg` exempts every version"));
543 assert!(help.contains("https://aube.jdx.dev/trust-policy-exceptions"));
544 }
545}