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#[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}