Skip to main content

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