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#[inline]
113#[allow(clippy::too_many_arguments)]
114pub(crate) fn pick_version<'a>(
115 packument: &'a Packument,
116 range_str: &str,
117 locked: Option<&str>,
118 pick_lowest: bool,
119 cutoff: Option<&str>,
120 exempt_cutoff: Option<&str>,
121 strict: bool,
122 is_age_exempt: impl Fn(&str, Option<&node_semver::Version>) -> bool,
123) -> PickResult<'a> {
124 // Handle dist-tag references. If the requested range is a tag
125 // name and the packument has that tag, use the tagged version
126 // as the effective range. Special case `latest`: some registries
127 // serve packuments where dist-tags.latest is absent (fresh
128 // publish race, all versions deprecated, private mirror bug).
129 // Old code then tried to parse "latest" as a semver range,
130 // failed, returned NoMatch. Caller could not tell whether the
131 // range was genuinely unsatisfiable or the tag was just missing.
132 // npm and pnpm fall back to the highest non-prerelease version.
133 // Do the same so `aube install foo` does not silently fail on a
134 // packument that just happens to lack the tag.
135 let range = match node_semver::Range::parse(normalize_range(range_str)) {
136 Ok(r) => r,
137 Err(_) => {
138 // Reject protocol-prefixed ranges that survived workspace /
139 // catalog / npm-alias preprocessing. An attacker can register
140 // a dist-tag literally named `workspace:*` or `catalog:` on
141 // a package they publish; without this gate the dist-tag
142 // fallback below would resolve the protocol spec to whatever
143 // version they pinned (dependency-confusion class). npm's
144 // own dist-tag rules forbid colon in tag names but the
145 // registry does not enforce that.
146 if looks_like_protocol_range(range_str) {
147 return PickResult::NoMatch;
148 }
149 let effective_range = if let Some(tagged_version) = packument.dist_tags.get(range_str) {
150 tagged_version.clone()
151 } else if range_str == "latest" {
152 match highest_stable_version(packument) {
153 Some(v) => v,
154 None => return PickResult::NoMatch,
155 }
156 } else {
157 return PickResult::NoMatch;
158 };
159 match node_semver::Range::parse(normalize_range(&effective_range)) {
160 Ok(r) => r,
161 Err(_) => return PickResult::NoMatch,
162 }
163 }
164 };
165
166 // Does `ver` clear `effective` (the cutoff that applies to it)?
167 // `None` => no wall, keep the version. Missing time => keep it: we'd
168 // rather risk a slightly newer transitive than fail to resolve the
169 // range entirely.
170 let passes_effective_cutoff = |ver: &str, effective: Option<&str>| -> bool {
171 let Some(c) = effective else { return true };
172 match packument.time.get(ver) {
173 Some(t) => t.as_str() <= c,
174 None => true,
175 }
176 };
177
178 // A version's effective cutoff: exempt versions answer to the
179 // time-based wall (`exempt_cutoff`) only; everyone else answers to
180 // the merged `cutoff`.
181 let passes_cutoff = |ver: &str, parsed: Option<&node_semver::Version>| -> bool {
182 let effective = if is_age_exempt(ver, parsed) {
183 exempt_cutoff
184 } else {
185 cutoff
186 };
187 passes_effective_cutoff(ver, effective)
188 };
189
190 // Prefer locked version if it satisfies and clears the cutoff.
191 if let Some(locked_ver) = locked
192 && let Ok(v) = node_semver::Version::parse(locked_ver)
193 && v.satisfies(&range)
194 && passes_cutoff(locked_ver, Some(&v))
195 && let Some(meta) = packument.versions.get(locked_ver)
196 {
197 return PickResult::Found(meta);
198 }
199
200 // If `dist-tags.latest` satisfies the range, prefer it over the
201 // strictly-highest matching version. Matches npm and pnpm: a fresh
202 // `npm install foo@^1.0.0` returns the version the publisher last
203 // tagged `latest`, not whatever happens to be the highest in the
204 // version list (which can be a stray prerelease, hotfix on an old
205 // line, or unwithdrawn experimental publish). Skipped when
206 // `pick_lowest` is on (TimeBased mode wants the floor of the range,
207 // not the publisher's preferred build).
208 if !pick_lowest
209 && let Some(latest_ver) = packument.dist_tags.get("latest")
210 && let Ok(v) = node_semver::Version::parse(latest_ver)
211 && v.satisfies(&range)
212 && passes_cutoff(latest_ver, Some(&v))
213 && let Some(meta) = packument.versions.get(latest_ver)
214 {
215 return PickResult::Found(meta);
216 }
217
218 // Track whether *any* version satisfied the range — if so but
219 // every one was rejected by the cutoff, the failure is age-gate
220 // related, not a real "no match in range".
221 let mut had_satisfying_but_age_gated = false;
222
223 let mut best: Option<(node_semver::Version, &'a aube_registry::VersionMetadata)> = None;
224 let mut fallback_lowest: Option<(node_semver::Version, &'a aube_registry::VersionMetadata)> =
225 None;
226
227 for (ver_str, meta) in &packument.versions {
228 let Ok(v) = node_semver::Version::parse(ver_str) else {
229 continue;
230 };
231 if !v.satisfies(&range) {
232 continue;
233 }
234
235 // The lenient fallback drops the minimumReleaseAge gate but never
236 // the time-based hard wall, so only versions that clear
237 // `exempt_cutoff` are eligible (a no-op `None` when time-based
238 // mode is off).
239 if passes_effective_cutoff(ver_str, exempt_cutoff)
240 && fallback_lowest.as_ref().is_none_or(|(cur, _)| v < *cur)
241 {
242 fallback_lowest = Some((v.clone(), meta));
243 }
244
245 if passes_cutoff(ver_str, Some(&v)) {
246 let replace = best
247 .as_ref()
248 .is_none_or(|(cur, _)| if pick_lowest { v < *cur } else { v > *cur });
249 if replace {
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 — the candidate already cleared the time-based wall above.
275 if let Some((_, meta)) = fallback_lowest {
276 return PickResult::Found(meta);
277 }
278 // Nothing left: either the range was unsatisfiable, or the
279 // time-based wall excluded every satisfying version. Report the age
280 // gate in the latter case so the caller surfaces a meaningful error
281 // rather than a bogus "no matching version".
282 if had_satisfying_but_age_gated {
283 PickResult::AgeGated
284 } else {
285 PickResult::NoMatch
286 }
287}
288
289/// Walk the packument's versions and return the highest non
290/// prerelease version string. Used as the `latest` tag fallback
291/// when the registry response lacks `dist-tags.latest`. Some
292/// private mirrors and mid-publish races drop the tag briefly
293/// and returning NoMatch there would break `aube install foo` for
294/// no real reason. npm and pnpm both fall back to highest stable.
295#[inline]
296/// True when `range_str` looks like a non-registry protocol selector
297/// that should never reach the dist-tag fallback (workspace / catalog
298/// / file / link / npm-alias / jsr-alias / git / http(s)). Lowercased
299/// so an attacker dist-tag named `Workspace:*` cannot bypass the gate.
300fn looks_like_protocol_range(range_str: &str) -> bool {
301 let Some(idx) = range_str.find(':') else {
302 return false;
303 };
304 let prefix = range_str[..idx].to_ascii_lowercase();
305 matches!(
306 prefix.as_str(),
307 "workspace"
308 | "catalog"
309 | "npm"
310 | "jsr"
311 | "file"
312 | "link"
313 | "git"
314 | "git+ssh"
315 | "git+http"
316 | "git+https"
317 | "git+file"
318 | "ssh"
319 | "http"
320 | "https"
321 | "github"
322 | "gitlab"
323 | "bitbucket"
324 | "gist"
325 )
326}
327
328#[inline]
329pub(crate) fn highest_stable_version(packument: &Packument) -> Option<String> {
330 let mut best: Option<(node_semver::Version, String)> = None;
331 for key in packument.versions.keys() {
332 let Ok(v) = node_semver::Version::parse(key) else {
333 continue;
334 };
335 // Skip prereleases so we match npm semantics. Registry
336 // with only prereleases returns None and caller gets
337 // NoMatch, same as before.
338 if !v.pre_release.is_empty() {
339 continue;
340 }
341 match &best {
342 None => best = Some((v, key.clone())),
343 Some((cur, _)) if v > *cur => best = Some((v, key.clone())),
344 _ => {}
345 }
346 }
347 best.map(|(_, k)| k)
348}
349/// Extract the trailing `@<version>` from an `npm:<name>@<version>`
350/// or `jsr:<name>@<version>` alias spec. Returns the input unchanged
351/// when the spec isn't an alias or doesn't carry a version tail.
352#[inline]
353pub(crate) fn strip_alias_prefix(range: &str) -> &str {
354 for prefix in ["npm:", "jsr:"] {
355 if let Some(rest) = range.strip_prefix(prefix) {
356 return match rest.rfind('@') {
357 Some(at) if at > 0 => &rest[at + 1..],
358 _ => rest,
359 };
360 }
361 }
362 range
363}
364
365#[inline]
366pub(crate) fn version_satisfies(version: &str, range_str: &str) -> bool {
367 with_cached_version(version, |v| {
368 let Some(v) = v else { return false };
369 with_cached_range(normalize_range(range_str), |r| match r {
370 Some(r) => v.satisfies(r),
371 None => false,
372 })
373 })
374}
375
376/// npm / pnpm / yarn all treat an empty or whitespace-only version
377/// range as equivalent to `"*"` (match any). `node_semver` rejects it
378/// with `No valid ranges could be parsed`. Normalize here so the
379/// resolver and every `version_satisfies` caller agree with the
380/// upstream registry semantics. Real-world case: `hashring@0.0.8`
381/// declares `"bisection": ""` in its dependencies.
382pub(crate) fn normalize_range(range_str: &str) -> &str {
383 if range_str.trim().is_empty() {
384 "*"
385 } else {
386 range_str
387 }
388}
389
390/// Thread-local `node_semver::Range` parse cache.
391///
392/// Resolver hot loops (sibling dedupe, lockfile-reuse scan,
393/// peer-context fixed-point, catalog pick) call `version_satisfies`
394/// thousands of times against a small repeating range set
395/// (`"^18.2.0"`, `"*"`, `"1.x"`). Re-parsing burns CPU. Memo turns
396/// 15k reparses on a 500-pkg graph into ~500 parses plus hits.
397///
398/// `thread_local!` beats a global mutex. Each tokio worker owns its
399/// slice of ranges, lock contention would erase the parse savings.
400/// Two workers parsing the same range twice is cheaper than one
401/// lock round-trip.
402fn with_cached_range<R>(range_str: &str, f: impl FnOnce(Option<&node_semver::Range>) -> R) -> R {
403 thread_local! {
404 static CACHE: std::cell::RefCell<crate::FxHashMap<String, Option<node_semver::Range>>> =
405 std::cell::RefCell::default();
406 }
407 CACHE.with(|cell| {
408 let mut map = cell.borrow_mut();
409 if !map.contains_key(range_str) {
410 let parsed = node_semver::Range::parse(range_str).ok();
411 map.insert(range_str.to_string(), parsed);
412 }
413 f(map.get(range_str).and_then(Option::as_ref))
414 })
415}
416
417// Mirrors with_cached_range. Locked-version side hits same string
418// thousands of times across peer-context + dedupe passes. Hit rate
419// trends to 1.0 after first BFS layer.
420fn with_cached_version<R>(version: &str, f: impl FnOnce(Option<&node_semver::Version>) -> R) -> R {
421 thread_local! {
422 static CACHE: std::cell::RefCell<crate::FxHashMap<String, Option<node_semver::Version>>> =
423 std::cell::RefCell::default();
424 }
425 CACHE.with(|cell| {
426 let mut map = cell.borrow_mut();
427 if !map.contains_key(version) {
428 let parsed = node_semver::Version::parse(version).ok();
429 map.insert(version.to_string(), parsed);
430 }
431 f(map.get(version).and_then(Option::as_ref))
432 })
433}