aube-resolver 1.41.0

Dependency resolver for Aube
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
use aube_registry::Packument;

/// Outcome of [`pick_version`]. Distinguishes "nothing in the range
/// at all" from "the cutoff filtered every otherwise-satisfying
/// version" so the caller can surface a meaningful strict-mode error
/// instead of pretending the range itself was wrong.
#[derive(Debug)]
pub enum PickResult<'a> {
    Found(&'a aube_registry::VersionMetadata),
    NoMatch,
    /// Strict mode (or any caller treating the cutoff as a hard wall):
    /// at least one version satisfied the range, but all of them were
    /// filtered out by the cutoff.
    AgeGated,
}

#[cfg(test)]
impl<'a> PickResult<'a> {
    pub(crate) fn unwrap(self) -> &'a aube_registry::VersionMetadata {
        match self {
            PickResult::Found(m) => m,
            other => panic!("expected PickResult::Found, got {other:?}"),
        }
    }
}

/// Single-package version pick for `aube add`'s manifest step, honoring
/// `minimumReleaseAge` with the same dist-tag preference, exemption, and
/// strict/lenient fallback semantics [`pick_version`] applies inside full
/// resolution. Without it, `add` writes the freshly published version into
/// the manifest as a pinned spec, which the resolver's lenient fallback then
/// honors — bypassing the very gate `minimumReleaseAge` exists to provide.
///
/// `registry_name` keys the `minimumReleaseAgeExclude` match: the real
/// registry identity, not a user-facing alias. Pass `None` for
/// `minimum_release_age` to get today's ungated pick (dist-tag preference,
/// then highest satisfying).
///
/// A gated `latest` range is normalized to `*` here, at the API boundary,
/// so no caller can reintroduce the bypass: [`pick_version`]'s internal
/// dist-tag fallback turns `latest` into the tagged version's exact range,
/// whose lenient fallback would admit a fresh publish — the very thing the
/// gate exists to block. `*` keeps the dist-tag preference for a mature
/// `latest`, steers a gated one to the newest version clearing the cutoff,
/// and (unlike the tag) still resolves when `dist-tags.latest` is missing.
pub fn pick_version_for_add<'a>(
    packument: &'a Packument,
    registry_name: &str,
    range: &str,
    minimum_release_age: Option<&crate::MinimumReleaseAge>,
) -> PickResult<'a> {
    let cutoff = minimum_release_age.and_then(|m| m.cutoff());
    let range = registry_alias_range(range);
    let range = if range == "latest" && cutoff.is_some() {
        "*"
    } else {
        range
    };
    let strict = minimum_release_age.is_some_and(|m| m.strict);
    let exclude = minimum_release_age.map(|m| &m.exclude);
    let is_age_exempt = |ver: &str, parsed: Option<&node_semver::Version>| {
        exclude.is_some_and(|ex| match parsed {
            Some(v) => ex.matches(registry_name, v),
            None => match node_semver::Version::parse(ver) {
                Ok(v) => ex.matches(registry_name, &v),
                Err(_) => ex.matches_name_only(registry_name),
            },
        })
    };
    pick_version(
        packument,
        range,
        None,
        false,
        cutoff.as_deref(),
        None,
        strict,
        is_age_exempt,
    )
}

/// Return the version tail from an `npm:` alias spec. Resolution preprocesses
/// this protocol before picking; report-only callers use this entry point
/// directly, so normalize it here as well.
fn registry_alias_range(range: &str) -> &str {
    if let Some(rest) = range.strip_prefix("npm:") {
        match rest.rfind('@') {
            Some(at) if at > 0 => &rest[at + 1..],
            _ => "latest",
        }
    } else {
        range
    }
}

/// Pick the best version from a packument that satisfies the given range.
///
/// `pick_lowest` flips the scan order — used by
/// `resolution-mode=time-based` for direct deps. `cutoff` filters out
/// versions whose registry publish time is later than the cutoff
/// (lexicographic compare on ISO-8601 UTC strings, which sort
/// correctly). When the packument has no `time` entry for a version
/// (e.g. abbreviated corgi payload in `Highest` mode), the cutoff is
/// ignored and the version stays eligible.
///
/// `strict` controls fallback when the cutoff filters out every
/// satisfying version: with `strict=true` we return `None` and the
/// caller errors out; with `strict=false` (the pnpm default) we make a
/// second pass that picks the *lowest* satisfying version ignoring the
/// cutoff. The lowest-satisfying fallback is pnpm's deliberate choice
/// — the oldest version in the range is least likely to be the freshly
/// pushed compromise that triggered the filter in the first place.
///
/// `is_age_exempt` lets the caller wave a specific version past the
/// cutoff — used to honor `minimumReleaseAgeExclude` (bare names, name
/// globs, and exact-version unions). It receives the candidate version
/// string plus its already-parsed form when the caller has one (every
/// hot-path call site here does, so the exemption check needn't reparse),
/// and returns `true` to treat that version as if it cleared the cutoff.
/// Pass `|_, _| false` when no exemptions apply.
///
/// `exempt_cutoff` is the time-based hard wall applied to exempt
/// versions: a version waved past the age-gate by `is_age_exempt` must
/// still clear `exempt_cutoff` (the time-based resolution cutoff). Pass
/// `None` to fully bypass the cutoff for exempt versions (no time-based
/// wall in effect).
///
/// Deprecated versions stay eligible but never win over a
/// non-deprecated one in the same range — see [`outranks`].
#[inline]
#[allow(clippy::too_many_arguments)]
pub(crate) fn pick_version<'a>(
    packument: &'a Packument,
    range_str: &str,
    locked: Option<&str>,
    pick_lowest: bool,
    cutoff: Option<&str>,
    exempt_cutoff: Option<&str>,
    strict: bool,
    is_age_exempt: impl Fn(&str, Option<&node_semver::Version>) -> bool,
) -> PickResult<'a> {
    // Handle dist-tag references. If the requested range is a tag
    // name and the packument has that tag, use the tagged version
    // as the effective range. Special case `latest`: some registries
    // serve packuments where dist-tags.latest is absent (fresh
    // publish race, all versions deprecated, private mirror bug).
    // Old code then tried to parse "latest" as a semver range,
    // failed, returned NoMatch. Caller could not tell whether the
    // range was genuinely unsatisfiable or the tag was just missing.
    // npm and pnpm fall back to the highest non-prerelease version.
    // Do the same so `aube install foo` does not silently fail on a
    // packument that just happens to lack the tag.
    let range = match node_semver::Range::parse(normalize_range(range_str)) {
        Ok(r) => r,
        Err(_) => {
            // Reject protocol-prefixed ranges that survived workspace /
            // catalog / npm-alias preprocessing. An attacker can register
            // a dist-tag literally named `workspace:*` or `catalog:` on
            // a package they publish; without this gate the dist-tag
            // fallback below would resolve the protocol spec to whatever
            // version they pinned (dependency-confusion class). npm's
            // own dist-tag rules forbid colon in tag names but the
            // registry does not enforce that.
            if looks_like_protocol_range(range_str) {
                return PickResult::NoMatch;
            }
            let effective_range = if let Some(tagged_version) = packument.dist_tags.get(range_str) {
                tagged_version.clone()
            } else if range_str == "latest" {
                match highest_stable_version(packument) {
                    Some(v) => v,
                    None => return PickResult::NoMatch,
                }
            } else {
                return PickResult::NoMatch;
            };
            match node_semver::Range::parse(normalize_range(&effective_range)) {
                Ok(r) => r,
                Err(_) => return PickResult::NoMatch,
            }
        }
    };

    // Does `ver` clear `effective` (the cutoff that applies to it)?
    // `None` => no wall, keep the version. Missing time => keep it: we'd
    // rather risk a slightly newer transitive than fail to resolve the
    // range entirely.
    let passes_effective_cutoff = |ver: &str, effective: Option<&str>| -> bool {
        let Some(c) = effective else { return true };
        match packument.time.get(ver) {
            Some(t) => t.as_str() <= c,
            None => true,
        }
    };

    // A version's effective cutoff: exempt versions answer to the
    // time-based wall (`exempt_cutoff`) only; everyone else answers to
    // the merged `cutoff`.
    let passes_cutoff = |ver: &str, parsed: Option<&node_semver::Version>| -> bool {
        let effective = if is_age_exempt(ver, parsed) {
            exempt_cutoff
        } else {
            cutoff
        };
        passes_effective_cutoff(ver, effective)
    };

    // Prefer locked version if it satisfies and clears the cutoff.
    if let Some(locked_ver) = locked
        && let Ok(v) = node_semver::Version::parse(locked_ver)
        && v.satisfies(&range)
        && passes_cutoff(locked_ver, Some(&v))
        && let Some(meta) = packument.versions.get(locked_ver)
    {
        return PickResult::Found(meta);
    }

    // If `dist-tags.latest` satisfies the range, prefer it over the
    // strictly-highest matching version. Matches npm and pnpm: a fresh
    // `npm install foo@^1.0.0` returns the version the publisher last
    // tagged `latest`, not whatever happens to be the highest in the
    // version list (which can be a stray prerelease, hotfix on an old
    // line, or unwithdrawn experimental publish). Skipped when
    // `pick_lowest` is on (TimeBased mode wants the floor of the range,
    // not the publisher's preferred build).
    if !pick_lowest
        && let Some(latest_ver) = packument.dist_tags.get("latest")
        && let Ok(v) = node_semver::Version::parse(latest_ver)
        && v.satisfies(&range)
        && passes_cutoff(latest_ver, Some(&v))
        && let Some(meta) = packument.versions.get(latest_ver)
    {
        return PickResult::Found(meta);
    }

    // Track whether *any* version satisfied the range — if so but
    // every one was rejected by the cutoff, the failure is age-gate
    // related, not a real "no match in range".
    let mut had_satisfying_but_age_gated = false;

    let mut best: Option<(node_semver::Version, &'a aube_registry::VersionMetadata)> = None;
    let mut fallback_lowest: Option<(node_semver::Version, &'a aube_registry::VersionMetadata)> =
        None;

    for (ver_str, meta) in &packument.versions {
        let Ok(v) = node_semver::Version::parse(ver_str) else {
            continue;
        };
        if !v.satisfies(&range) {
            continue;
        }

        // The lenient fallback drops the minimumReleaseAge gate but never
        // the time-based hard wall, so only versions that clear
        // `exempt_cutoff` are eligible (a no-op `None` when time-based
        // mode is off).
        if passes_effective_cutoff(ver_str, exempt_cutoff)
            && outranks(&v, meta, fallback_lowest.as_ref(), true)
        {
            fallback_lowest = Some((v.clone(), meta));
        }

        if passes_cutoff(ver_str, Some(&v)) {
            if outranks(&v, meta, best.as_ref(), pick_lowest) {
                best = Some((v, meta));
            }
        } else {
            had_satisfying_but_age_gated = true;
        }
    }

    if let Some((_, meta)) = best {
        return PickResult::Found(meta);
    }

    // Strict mode (or no cutoff active): give up. Distinguish age-gate
    // failures so the caller can surface a meaningful error instead of
    // pretending the range itself was wrong.
    if strict || cutoff.is_none() {
        return if had_satisfying_but_age_gated {
            PickResult::AgeGated
        } else {
            PickResult::NoMatch
        };
    }

    // Lenient fallback: pnpm's `pickPackageFromMetaUsingTime` bypasses
    // the minimumReleaseAge gate and picks the *lowest* satisfying
    // version (lowest non-deprecated, per `outranks`) — the candidate
    // already cleared the time-based wall above.
    if let Some((_, meta)) = fallback_lowest {
        return PickResult::Found(meta);
    }
    // Nothing left: either the range was unsatisfiable, or the
    // time-based wall excluded every satisfying version. Report the age
    // gate in the latter case so the caller surfaces a meaningful error
    // rather than a bogus "no matching version".
    if had_satisfying_but_age_gated {
        PickResult::AgeGated
    } else {
        PickResult::NoMatch
    }
}

/// Does the candidate `(v, meta)` beat the incumbent pick?
///
/// A non-deprecated version outranks a deprecated one whatever their
/// order; between two versions of equal deprecation status `lowest`
/// decides the direction. This is pnpm's `pickVersionByVersionRange`
/// rule — when the highest match carries a `deprecated` message it
/// re-runs the range match over the non-deprecated versions and only
/// keeps the deprecated pick when the range admits nothing else —
/// expressed as a comparison so every scan in this crate gets it from
/// one place. Real case: `codemirror@6.65.7` is an accidentally
/// mis-tagged republish of `5.65.7`, so it sorts above every genuine
/// 6.x and a plain highest-satisfying scan hands it to anyone whose
/// range reaches past `dist-tags.latest`.
///
/// pnpm applies the rule on its highest-version path only; aube applies
/// it in both directions on purpose. A deprecated floor is no safer
/// than a non-deprecated one, so `resolution-mode=time-based` has
/// nothing to gain from pinning a version the publisher withdrew.
#[inline]
pub(crate) fn outranks(
    v: &node_semver::Version,
    meta: &aube_registry::VersionMetadata,
    incumbent: Option<&(node_semver::Version, &aube_registry::VersionMetadata)>,
    lowest: bool,
) -> bool {
    let Some((cur_v, cur_meta)) = incumbent else {
        return true;
    };
    let live = meta.deprecated.is_none();
    if live != cur_meta.deprecated.is_none() {
        return live;
    }
    if lowest { v < cur_v } else { v > cur_v }
}

/// Walk the packument's versions and return the highest non
/// prerelease version string. Used as the `latest` tag fallback
/// when the registry response lacks `dist-tags.latest`. Some
/// private mirrors and mid-publish races drop the tag briefly
/// and returning NoMatch there would break `aube install foo` for
/// no real reason. npm and pnpm both fall back to highest stable.
#[inline]
/// True when `range_str` looks like a non-registry protocol selector
/// that should never reach the dist-tag fallback (workspace / catalog
/// / file / link / npm-alias / jsr-alias / git / http(s)). Lowercased
/// so an attacker dist-tag named `Workspace:*` cannot bypass the gate.
fn looks_like_protocol_range(range_str: &str) -> bool {
    let Some(idx) = range_str.find(':') else {
        return false;
    };
    let prefix = range_str[..idx].to_ascii_lowercase();
    matches!(
        prefix.as_str(),
        "workspace"
            | "catalog"
            | "npm"
            | "jsr"
            | "file"
            | "link"
            | "git"
            | "git+ssh"
            | "git+http"
            | "git+https"
            | "git+file"
            | "ssh"
            | "http"
            | "https"
            | "github"
            | "gitlab"
            | "bitbucket"
            | "gist"
    )
}

#[inline]
pub(crate) fn highest_stable_version(packument: &Packument) -> Option<String> {
    let mut best: Option<(node_semver::Version, String)> = None;
    for key in packument.versions.keys() {
        let Ok(v) = node_semver::Version::parse(key) else {
            continue;
        };
        // Skip prereleases so we match npm semantics. Registry
        // with only prereleases returns None and caller gets
        // NoMatch, same as before.
        if !v.pre_release.is_empty() {
            continue;
        }
        match &best {
            None => best = Some((v, key.clone())),
            Some((cur, _)) if v > *cur => best = Some((v, key.clone())),
            _ => {}
        }
    }
    best.map(|(_, k)| k)
}
/// Extract the trailing `@<version>` from an `npm:<name>@<version>`
/// or `jsr:<name>@<version>` alias spec. Returns the input unchanged
/// when the spec isn't an alias or doesn't carry a version tail.
#[inline]
pub(crate) fn strip_alias_prefix(range: &str) -> &str {
    for prefix in ["npm:", "jsr:"] {
        if let Some(rest) = range.strip_prefix(prefix) {
            return match rest.rfind('@') {
                Some(at) if at > 0 => &rest[at + 1..],
                _ => rest,
            };
        }
    }
    range
}

#[inline]
pub(crate) fn version_satisfies(version: &str, range_str: &str) -> bool {
    with_cached_version(version, |v| {
        let Some(v) = v else { return false };
        with_cached_range(normalize_range(range_str), |r| match r {
            Some(r) => v.satisfies(r),
            None => false,
        })
    })
}

/// npm / pnpm / yarn all treat an empty or whitespace-only version
/// range as equivalent to `"*"` (match any). `node_semver` rejects it
/// with `No valid ranges could be parsed`. Normalize here so the
/// resolver and every `version_satisfies` caller agree with the
/// upstream registry semantics. Real-world case: `hashring@0.0.8`
/// declares `"bisection": ""` in its dependencies.
pub(crate) fn normalize_range(range_str: &str) -> &str {
    if range_str.trim().is_empty() {
        "*"
    } else {
        range_str
    }
}

/// Thread-local `node_semver::Range` parse cache.
///
/// Resolver hot loops (sibling dedupe, lockfile-reuse scan,
/// peer-context fixed-point, catalog pick) call `version_satisfies`
/// thousands of times against a small repeating range set
/// (`"^18.2.0"`, `"*"`, `"1.x"`). Re-parsing burns CPU. Memo turns
/// 15k reparses on a 500-pkg graph into ~500 parses plus hits.
///
/// `thread_local!` beats a global mutex. Each tokio worker owns its
/// slice of ranges, lock contention would erase the parse savings.
/// Two workers parsing the same range twice is cheaper than one
/// lock round-trip.
fn with_cached_range<R>(range_str: &str, f: impl FnOnce(Option<&node_semver::Range>) -> R) -> R {
    thread_local! {
        static CACHE: std::cell::RefCell<crate::FxHashMap<String, Option<node_semver::Range>>> =
            std::cell::RefCell::default();
    }
    CACHE.with(|cell| {
        let mut map = cell.borrow_mut();
        if !map.contains_key(range_str) {
            let parsed = node_semver::Range::parse(range_str).ok();
            map.insert(range_str.to_string(), parsed);
        }
        f(map.get(range_str).and_then(Option::as_ref))
    })
}

// Mirrors with_cached_range. Locked-version side hits same string
// thousands of times across peer-context + dedupe passes. Hit rate
// trends to 1.0 after first BFS layer.
fn with_cached_version<R>(version: &str, f: impl FnOnce(Option<&node_semver::Version>) -> R) -> R {
    thread_local! {
        static CACHE: std::cell::RefCell<crate::FxHashMap<String, Option<node_semver::Version>>> =
            std::cell::RefCell::default();
    }
    CACHE.with(|cell| {
        let mut map = cell.borrow_mut();
        if !map.contains_key(version) {
            let parsed = node_semver::Version::parse(version).ok();
            map.insert(version.to_string(), parsed);
        }
        f(map.get(version).and_then(Option::as_ref))
    })
}