aube_resolver/semver_util.rs
1use aube_registry::Packument;
2
3/// Outcome of [`pick_version`]. Distinguishes "nothing in the range
4/// at all" from "the cutoff filtered every otherwise-satisfying
5/// version" so the caller can surface a meaningful strict-mode error
6/// instead of pretending the range itself was wrong.
7#[derive(Debug)]
8pub enum PickResult<'a> {
9 Found(&'a aube_registry::VersionMetadata),
10 NoMatch,
11 /// Strict mode (or any caller treating the cutoff as a hard wall):
12 /// at least one version satisfied the range, but all of them were
13 /// filtered out by the cutoff.
14 AgeGated,
15}
16
17#[cfg(test)]
18impl<'a> PickResult<'a> {
19 pub(crate) fn unwrap(self) -> &'a aube_registry::VersionMetadata {
20 match self {
21 PickResult::Found(m) => m,
22 other => panic!("expected PickResult::Found, got {other:?}"),
23 }
24 }
25}
26
27/// Single-package version pick for `aube add`'s manifest step, honoring
28/// `minimumReleaseAge` with the same dist-tag preference, exemption, and
29/// strict/lenient fallback semantics [`pick_version`] applies inside full
30/// resolution. Without it, `add` writes the freshly published version into
31/// the manifest as a pinned spec, which the resolver's lenient fallback then
32/// honors — bypassing the very gate `minimumReleaseAge` exists to provide.
33///
34/// `registry_name` keys the `minimumReleaseAgeExclude` match: the real
35/// registry identity, not a user-facing alias. Pass `None` for
36/// `minimum_release_age` to get today's ungated pick (dist-tag preference,
37/// then highest satisfying).
38///
39/// A gated `latest` range is normalized to `*` here, at the API boundary,
40/// so no caller can reintroduce the bypass: [`pick_version`]'s internal
41/// dist-tag fallback turns `latest` into the tagged version's exact range,
42/// whose lenient fallback would admit a fresh publish — the very thing the
43/// gate exists to block. `*` keeps the dist-tag preference for a mature
44/// `latest`, steers a gated one to the newest version clearing the cutoff,
45/// and (unlike the tag) still resolves when `dist-tags.latest` is missing.
46pub fn pick_version_for_add<'a>(
47 packument: &'a Packument,
48 registry_name: &str,
49 range: &str,
50 minimum_release_age: Option<&crate::MinimumReleaseAge>,
51) -> PickResult<'a> {
52 let cutoff = minimum_release_age.and_then(|m| m.cutoff());
53 let range = if range == "latest" && cutoff.is_some() {
54 "*"
55 } else {
56 range
57 };
58 let strict = minimum_release_age.is_some_and(|m| m.strict);
59 let exclude = minimum_release_age.map(|m| &m.exclude);
60 let is_age_exempt = |ver: &str, parsed: Option<&node_semver::Version>| {
61 exclude.is_some_and(|ex| match parsed {
62 Some(v) => ex.matches(registry_name, v),
63 None => match node_semver::Version::parse(ver) {
64 Ok(v) => ex.matches(registry_name, &v),
65 Err(_) => ex.matches_name_only(registry_name),
66 },
67 })
68 };
69 pick_version(
70 packument,
71 range,
72 None,
73 false,
74 cutoff.as_deref(),
75 None,
76 strict,
77 is_age_exempt,
78 )
79}
80
81/// Pick the best version from a packument that satisfies the given range.
82///
83/// `pick_lowest` flips the scan order — used by
84/// `resolution-mode=time-based` for direct deps. `cutoff` filters out
85/// versions whose registry publish time is later than the cutoff
86/// (lexicographic compare on ISO-8601 UTC strings, which sort
87/// correctly). When the packument has no `time` entry for a version
88/// (e.g. abbreviated corgi payload in `Highest` mode), the cutoff is
89/// ignored and the version stays eligible.
90///
91/// `strict` controls fallback when the cutoff filters out every
92/// satisfying version: with `strict=true` we return `None` and the
93/// caller errors out; with `strict=false` (the pnpm default) we make a
94/// second pass that picks the *lowest* satisfying version ignoring the
95/// cutoff. The lowest-satisfying fallback is pnpm's deliberate choice
96/// — the oldest version in the range is least likely to be the freshly
97/// pushed compromise that triggered the filter in the first place.
98///
99/// `is_age_exempt` lets the caller wave a specific version past the
100/// cutoff — used to honor `minimumReleaseAgeExclude` (bare names, name
101/// globs, and exact-version unions). It receives the candidate version
102/// string plus its already-parsed form when the caller has one (every
103/// hot-path call site here does, so the exemption check needn't reparse),
104/// and returns `true` to treat that version as if it cleared the cutoff.
105/// Pass `|_, _| false` when no exemptions apply.
106///
107/// `exempt_cutoff` is the time-based hard wall applied to exempt
108/// versions: a version waved past the age-gate by `is_age_exempt` must
109/// still clear `exempt_cutoff` (the time-based resolution cutoff). Pass
110/// `None` to fully bypass the cutoff for exempt versions (no time-based
111/// wall in effect).
112///
113/// Deprecated versions stay eligible but never win over a
114/// non-deprecated one in the same range — see [`outranks`].
115#[inline]
116#[allow(clippy::too_many_arguments)]
117pub(crate) fn pick_version<'a>(
118 packument: &'a Packument,
119 range_str: &str,
120 locked: Option<&str>,
121 pick_lowest: bool,
122 cutoff: Option<&str>,
123 exempt_cutoff: Option<&str>,
124 strict: bool,
125 is_age_exempt: impl Fn(&str, Option<&node_semver::Version>) -> bool,
126) -> PickResult<'a> {
127 // Handle dist-tag references. If the requested range is a tag
128 // name and the packument has that tag, use the tagged version
129 // as the effective range. Special case `latest`: some registries
130 // serve packuments where dist-tags.latest is absent (fresh
131 // publish race, all versions deprecated, private mirror bug).
132 // Old code then tried to parse "latest" as a semver range,
133 // failed, returned NoMatch. Caller could not tell whether the
134 // range was genuinely unsatisfiable or the tag was just missing.
135 // npm and pnpm fall back to the highest non-prerelease version.
136 // Do the same so `aube install foo` does not silently fail on a
137 // packument that just happens to lack the tag.
138 let range = match node_semver::Range::parse(normalize_range(range_str)) {
139 Ok(r) => r,
140 Err(_) => {
141 // Reject protocol-prefixed ranges that survived workspace /
142 // catalog / npm-alias preprocessing. An attacker can register
143 // a dist-tag literally named `workspace:*` or `catalog:` on
144 // a package they publish; without this gate the dist-tag
145 // fallback below would resolve the protocol spec to whatever
146 // version they pinned (dependency-confusion class). npm's
147 // own dist-tag rules forbid colon in tag names but the
148 // registry does not enforce that.
149 if looks_like_protocol_range(range_str) {
150 return PickResult::NoMatch;
151 }
152 let effective_range = if let Some(tagged_version) = packument.dist_tags.get(range_str) {
153 tagged_version.clone()
154 } else if range_str == "latest" {
155 match highest_stable_version(packument) {
156 Some(v) => v,
157 None => return PickResult::NoMatch,
158 }
159 } else {
160 return PickResult::NoMatch;
161 };
162 match node_semver::Range::parse(normalize_range(&effective_range)) {
163 Ok(r) => r,
164 Err(_) => return PickResult::NoMatch,
165 }
166 }
167 };
168
169 // Does `ver` clear `effective` (the cutoff that applies to it)?
170 // `None` => no wall, keep the version. Missing time => keep it: we'd
171 // rather risk a slightly newer transitive than fail to resolve the
172 // range entirely.
173 let passes_effective_cutoff = |ver: &str, effective: Option<&str>| -> bool {
174 let Some(c) = effective else { return true };
175 match packument.time.get(ver) {
176 Some(t) => t.as_str() <= c,
177 None => true,
178 }
179 };
180
181 // A version's effective cutoff: exempt versions answer to the
182 // time-based wall (`exempt_cutoff`) only; everyone else answers to
183 // the merged `cutoff`.
184 let passes_cutoff = |ver: &str, parsed: Option<&node_semver::Version>| -> bool {
185 let effective = if is_age_exempt(ver, parsed) {
186 exempt_cutoff
187 } else {
188 cutoff
189 };
190 passes_effective_cutoff(ver, effective)
191 };
192
193 // Prefer locked version if it satisfies and clears the cutoff.
194 if let Some(locked_ver) = locked
195 && let Ok(v) = node_semver::Version::parse(locked_ver)
196 && v.satisfies(&range)
197 && passes_cutoff(locked_ver, Some(&v))
198 && let Some(meta) = packument.versions.get(locked_ver)
199 {
200 return PickResult::Found(meta);
201 }
202
203 // If `dist-tags.latest` satisfies the range, prefer it over the
204 // strictly-highest matching version. Matches npm and pnpm: a fresh
205 // `npm install foo@^1.0.0` returns the version the publisher last
206 // tagged `latest`, not whatever happens to be the highest in the
207 // version list (which can be a stray prerelease, hotfix on an old
208 // line, or unwithdrawn experimental publish). Skipped when
209 // `pick_lowest` is on (TimeBased mode wants the floor of the range,
210 // not the publisher's preferred build).
211 if !pick_lowest
212 && let Some(latest_ver) = packument.dist_tags.get("latest")
213 && let Ok(v) = node_semver::Version::parse(latest_ver)
214 && v.satisfies(&range)
215 && passes_cutoff(latest_ver, Some(&v))
216 && let Some(meta) = packument.versions.get(latest_ver)
217 {
218 return PickResult::Found(meta);
219 }
220
221 // Track whether *any* version satisfied the range — if so but
222 // every one was rejected by the cutoff, the failure is age-gate
223 // related, not a real "no match in range".
224 let mut had_satisfying_but_age_gated = false;
225
226 let mut best: Option<(node_semver::Version, &'a aube_registry::VersionMetadata)> = None;
227 let mut fallback_lowest: Option<(node_semver::Version, &'a aube_registry::VersionMetadata)> =
228 None;
229
230 for (ver_str, meta) in &packument.versions {
231 let Ok(v) = node_semver::Version::parse(ver_str) else {
232 continue;
233 };
234 if !v.satisfies(&range) {
235 continue;
236 }
237
238 // The lenient fallback drops the minimumReleaseAge gate but never
239 // the time-based hard wall, so only versions that clear
240 // `exempt_cutoff` are eligible (a no-op `None` when time-based
241 // mode is off).
242 if passes_effective_cutoff(ver_str, exempt_cutoff)
243 && outranks(&v, meta, fallback_lowest.as_ref(), true)
244 {
245 fallback_lowest = Some((v.clone(), meta));
246 }
247
248 if passes_cutoff(ver_str, Some(&v)) {
249 if outranks(&v, meta, best.as_ref(), pick_lowest) {
250 best = Some((v, meta));
251 }
252 } else {
253 had_satisfying_but_age_gated = true;
254 }
255 }
256
257 if let Some((_, meta)) = best {
258 return PickResult::Found(meta);
259 }
260
261 // Strict mode (or no cutoff active): give up. Distinguish age-gate
262 // failures so the caller can surface a meaningful error instead of
263 // pretending the range itself was wrong.
264 if strict || cutoff.is_none() {
265 return if had_satisfying_but_age_gated {
266 PickResult::AgeGated
267 } else {
268 PickResult::NoMatch
269 };
270 }
271
272 // Lenient fallback: pnpm's `pickPackageFromMetaUsingTime` bypasses
273 // the minimumReleaseAge gate and picks the *lowest* satisfying
274 // version (lowest non-deprecated, per `outranks`) — the candidate
275 // already cleared the time-based wall above.
276 if let Some((_, meta)) = fallback_lowest {
277 return PickResult::Found(meta);
278 }
279 // Nothing left: either the range was unsatisfiable, or the
280 // time-based wall excluded every satisfying version. Report the age
281 // gate in the latter case so the caller surfaces a meaningful error
282 // rather than a bogus "no matching version".
283 if had_satisfying_but_age_gated {
284 PickResult::AgeGated
285 } else {
286 PickResult::NoMatch
287 }
288}
289
290/// Does the candidate `(v, meta)` beat the incumbent pick?
291///
292/// A non-deprecated version outranks a deprecated one whatever their
293/// order; between two versions of equal deprecation status `lowest`
294/// decides the direction. This is pnpm's `pickVersionByVersionRange`
295/// rule — when the highest match carries a `deprecated` message it
296/// re-runs the range match over the non-deprecated versions and only
297/// keeps the deprecated pick when the range admits nothing else —
298/// expressed as a comparison so every scan in this crate gets it from
299/// one place. Real case: `codemirror@6.65.7` is an accidentally
300/// mis-tagged republish of `5.65.7`, so it sorts above every genuine
301/// 6.x and a plain highest-satisfying scan hands it to anyone whose
302/// range reaches past `dist-tags.latest`.
303///
304/// pnpm applies the rule on its highest-version path only; aube applies
305/// it in both directions on purpose. A deprecated floor is no safer
306/// than a non-deprecated one, so `resolution-mode=time-based` has
307/// nothing to gain from pinning a version the publisher withdrew.
308#[inline]
309pub(crate) fn outranks(
310 v: &node_semver::Version,
311 meta: &aube_registry::VersionMetadata,
312 incumbent: Option<&(node_semver::Version, &aube_registry::VersionMetadata)>,
313 lowest: bool,
314) -> bool {
315 let Some((cur_v, cur_meta)) = incumbent else {
316 return true;
317 };
318 let live = meta.deprecated.is_none();
319 if live != cur_meta.deprecated.is_none() {
320 return live;
321 }
322 if lowest { v < cur_v } else { v > cur_v }
323}
324
325/// Walk the packument's versions and return the highest non
326/// prerelease version string. Used as the `latest` tag fallback
327/// when the registry response lacks `dist-tags.latest`. Some
328/// private mirrors and mid-publish races drop the tag briefly
329/// and returning NoMatch there would break `aube install foo` for
330/// no real reason. npm and pnpm both fall back to highest stable.
331#[inline]
332/// True when `range_str` looks like a non-registry protocol selector
333/// that should never reach the dist-tag fallback (workspace / catalog
334/// / file / link / npm-alias / jsr-alias / git / http(s)). Lowercased
335/// so an attacker dist-tag named `Workspace:*` cannot bypass the gate.
336fn looks_like_protocol_range(range_str: &str) -> bool {
337 let Some(idx) = range_str.find(':') else {
338 return false;
339 };
340 let prefix = range_str[..idx].to_ascii_lowercase();
341 matches!(
342 prefix.as_str(),
343 "workspace"
344 | "catalog"
345 | "npm"
346 | "jsr"
347 | "file"
348 | "link"
349 | "git"
350 | "git+ssh"
351 | "git+http"
352 | "git+https"
353 | "git+file"
354 | "ssh"
355 | "http"
356 | "https"
357 | "github"
358 | "gitlab"
359 | "bitbucket"
360 | "gist"
361 )
362}
363
364#[inline]
365pub(crate) fn highest_stable_version(packument: &Packument) -> Option<String> {
366 let mut best: Option<(node_semver::Version, String)> = None;
367 for key in packument.versions.keys() {
368 let Ok(v) = node_semver::Version::parse(key) else {
369 continue;
370 };
371 // Skip prereleases so we match npm semantics. Registry
372 // with only prereleases returns None and caller gets
373 // NoMatch, same as before.
374 if !v.pre_release.is_empty() {
375 continue;
376 }
377 match &best {
378 None => best = Some((v, key.clone())),
379 Some((cur, _)) if v > *cur => best = Some((v, key.clone())),
380 _ => {}
381 }
382 }
383 best.map(|(_, k)| k)
384}
385/// Extract the trailing `@<version>` from an `npm:<name>@<version>`
386/// or `jsr:<name>@<version>` alias spec. Returns the input unchanged
387/// when the spec isn't an alias or doesn't carry a version tail.
388#[inline]
389pub(crate) fn strip_alias_prefix(range: &str) -> &str {
390 for prefix in ["npm:", "jsr:"] {
391 if let Some(rest) = range.strip_prefix(prefix) {
392 return match rest.rfind('@') {
393 Some(at) if at > 0 => &rest[at + 1..],
394 _ => rest,
395 };
396 }
397 }
398 range
399}
400
401#[inline]
402pub(crate) fn version_satisfies(version: &str, range_str: &str) -> bool {
403 with_cached_version(version, |v| {
404 let Some(v) = v else { return false };
405 with_cached_range(normalize_range(range_str), |r| match r {
406 Some(r) => v.satisfies(r),
407 None => false,
408 })
409 })
410}
411
412/// npm / pnpm / yarn all treat an empty or whitespace-only version
413/// range as equivalent to `"*"` (match any). `node_semver` rejects it
414/// with `No valid ranges could be parsed`. Normalize here so the
415/// resolver and every `version_satisfies` caller agree with the
416/// upstream registry semantics. Real-world case: `hashring@0.0.8`
417/// declares `"bisection": ""` in its dependencies.
418pub(crate) fn normalize_range(range_str: &str) -> &str {
419 if range_str.trim().is_empty() {
420 "*"
421 } else {
422 range_str
423 }
424}
425
426/// Thread-local `node_semver::Range` parse cache.
427///
428/// Resolver hot loops (sibling dedupe, lockfile-reuse scan,
429/// peer-context fixed-point, catalog pick) call `version_satisfies`
430/// thousands of times against a small repeating range set
431/// (`"^18.2.0"`, `"*"`, `"1.x"`). Re-parsing burns CPU. Memo turns
432/// 15k reparses on a 500-pkg graph into ~500 parses plus hits.
433///
434/// `thread_local!` beats a global mutex. Each tokio worker owns its
435/// slice of ranges, lock contention would erase the parse savings.
436/// Two workers parsing the same range twice is cheaper than one
437/// lock round-trip.
438fn with_cached_range<R>(range_str: &str, f: impl FnOnce(Option<&node_semver::Range>) -> R) -> R {
439 thread_local! {
440 static CACHE: std::cell::RefCell<crate::FxHashMap<String, Option<node_semver::Range>>> =
441 std::cell::RefCell::default();
442 }
443 CACHE.with(|cell| {
444 let mut map = cell.borrow_mut();
445 if !map.contains_key(range_str) {
446 let parsed = node_semver::Range::parse(range_str).ok();
447 map.insert(range_str.to_string(), parsed);
448 }
449 f(map.get(range_str).and_then(Option::as_ref))
450 })
451}
452
453// Mirrors with_cached_range. Locked-version side hits same string
454// thousands of times across peer-context + dedupe passes. Hit rate
455// trends to 1.0 after first BFS layer.
456fn with_cached_version<R>(version: &str, f: impl FnOnce(Option<&node_semver::Version>) -> R) -> R {
457 thread_local! {
458 static CACHE: std::cell::RefCell<crate::FxHashMap<String, Option<node_semver::Version>>> =
459 std::cell::RefCell::default();
460 }
461 CACHE.with(|cell| {
462 let mut map = cell.borrow_mut();
463 if !map.contains_key(version) {
464 let parsed = node_semver::Version::parse(version).ok();
465 map.insert(version.to_string(), parsed);
466 }
467 f(map.get(version).and_then(Option::as_ref))
468 })
469}