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 = 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}