Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/modgunn

src/versions.rs

//! Per-ecosystem version ordering + OSV **range → exact** expansion (feature
//! `scan`).
//!
//! OSV ships most advisories as *ranges* (`affected[].ranges` with
//! `introduced`/`fixed`/`last_affected` events), not exact version lists. Engine
//! A's [`crate::scan::Advisory`] matches **exact** versions (see
//! [`crate::scan::Advisory::matches`]) to stay offline + ecosystem-agnostic. This
//! module is the front-end that bridges the two: given the set of versions that
//! actually exist for a package (from the dep-graph / registry index), it expands
//! a range into the exact versions inside it, per the ecosystem's version grammar.
//!
//! Supported orderings: **cargo**/**npm**/**go**/**nuget**/**composer** (SemVer),
//! **pypi** (PEP 440-lite), **maven** (Maven version-tokens-lite), **gem**
//! (RubyGems segments), **deb**/**rpm**/**apk** (distro grammars); anything else
//! (notably NVD's `cpe`) falls back to a loose dotted-numeric comparison, which is
//! NOT prerelease-aware. The orderings are good enough to evaluate OSV
//! `introduced`/`fixed` bounds — they are not full reimplementations of each
//! ecosystem's resolver.

use std::cmp::Ordering;

/// One OSV range event. `Introduced("0")` means "from the beginning of time".
///
/// `Serialize`/`Deserialize` so a range can round-trip through the
/// `cve_advisories.ranges` warehouse column (JSON-encoded) — the range-native
/// source of truth Engine A matches against at scan time.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum RangeEvent {
    /// The vulnerability is introduced **at** this version (inclusive lower bound).
    Introduced(String),
    /// The vulnerability is introduced **strictly after** this version (EXCLUSIVE
    /// lower bound): the named version itself is NOT affected.
    ///
    /// OSV has no such event — this exists for NVD's `versionStartExcluding`, which
    /// means "affected versions are strictly greater than this". Mapping that onto
    /// [`RangeEvent::Introduced`] would report the boundary version itself as
    /// vulnerable: a false positive on the exact version the vendor called clean.
    IntroducedExclusive(String),
    /// The vulnerability is fixed **at** this version (exclusive upper bound).
    Fixed(String),
    /// The last version that is still affected (inclusive upper bound).
    LastAffected(String),
}

/// Compare two versions under `ecosystem`'s grammar.
pub fn compare(ecosystem: &str, a: &str, b: &str) -> Ordering {
    // OSV may suffix a release (`Alpine:v3.20`, `Debian:12`); the grammar is the head's.
    let lower = ecosystem.to_ascii_lowercase();
    let head = lower.split(':').next().unwrap_or(&lower).trim();
    match head {
        // SemVer-grammar ecosystems. Go, NuGet and Composer/Packagist all use the
        // SemVer prerelease rule (`1.5.0-rc.1` < `1.5.0`); the loose dotted-numeric
        // fallback gets that BACKWARDS (it reads `-rc.1` as extra numeric segments
        // and ranks the prerelease ABOVE its release), which silently clears a
        // vulnerable prerelease of a `Fixed` bound.
        "cargo" | "npm" | "go" | "nuget" | "composer" => semver_cmp(a, b),
        // `pip` and `pypi` are the SAME ecosystem under two labels: the OSV/verdict
        // plane normalizes to `pypi` (osv::normalize_ecosystem, the behavior CLI),
        // but the manifest parser tags Python components `pip` (manifests::PipParser).
        // Both MUST get PEP 440 — a `pip`-labeled version falling through to
        // `loose_cmp` ranks `1.0.0rc1` ABOVE `1.0.0` (loose reads no prerelease rule),
        // silently clearing a vulnerable prerelease of a `Fixed` bound. Folding the
        // alias here keeps range-matching correct no matter which label the row
        // carries — the join-time canonicalization the multi-ecosystem fleet scan
        // needs when nornir's ecosystem column lands (see baddie-detection-design.md).
        "pip" | "pypi" => pep440_cmp(a, b),
        // RubyGems has its own grammar (`1.0.0.rc1`, dot-separated, letter segments
        // rank below numeric ones) — not SemVer, and equally broken under loose_cmp.
        "gem" => gem_cmp(a, b),
        "maven" => maven_cmp(a, b),
        // Distro version grammars (P4). Debian/Ubuntu = dpkg; Red Hat/SUSE/Rocky/
        // Alma = rpmvercmp; Alpine = apkvercmp (its own `_suffix` + `-rN` scheme).
        "deb" => deb_cmp(a, b),
        "rpm" => rpm_cmp(a, b),
        // Wolfi and Chainguard are apk distributions, as Alpine is: `1.2.3-r10` is
        // newer than `1.2.3-r9`, and `_rc1` sorts before its release.
        "apk" | "alpine" | "wolfi" | "chainguard" => apk_cmp(a, b),
        _ => loose_cmp(a, b),
    }
}

/// Is `version` affected by the OSV `events` of a single range, under `ecosystem`?
///
/// Implements the OSV range semantics: walking the events in version order, an
/// `Introduced` turns the affected-state on, an `IntroducedExclusive` turns it on
/// only *strictly after* its version, a `Fixed` turns it off (exclusive), and a
/// `LastAffected` turns it off *after* that version (inclusive).
pub fn affected_by(ecosystem: &str, events: &[RangeEvent], version: &str) -> bool {
    // Order events by their version so the walk is monotone; `Introduced("0")`
    // sorts first (treated as -infinity).
    let mut sorted: Vec<&RangeEvent> = events.iter().collect();
    sorted.sort_by(|x, y| event_cmp(ecosystem, x, y));

    let mut affected = false;
    for ev in sorted {
        match ev {
            RangeEvent::Introduced(v) => {
                if v == "0" || compare(ecosystem, version, v) != Ordering::Less {
                    affected = true; // version >= introduced
                }
            }
            RangeEvent::IntroducedExclusive(v) => {
                if compare(ecosystem, version, v) == Ordering::Greater {
                    affected = true; // version > introduced (the bound itself is clean)
                }
            }
            RangeEvent::Fixed(v) => {
                if compare(ecosystem, version, v) != Ordering::Less {
                    affected = false; // version >= fixed (exclusive)
                }
            }
            RangeEvent::LastAffected(v) => {
                if compare(ecosystem, version, v) == Ordering::Greater {
                    affected = false; // version > last_affected
                }
            }
        }
    }
    affected
}

/// Is `version` affected by **any** of `ranges`?
///
/// An OSV `affected[]` entry carries a LIST of range objects, and the versions it
/// covers are their **union** — each range object is walked on its own. The events
/// of two ranges must never be concatenated into one list: [`affected_by`]'s walk
/// is a monotone on/off sweep that is only meaningful for a single well-formed
/// range, so splicing `[I 0, F 2.0.0]` and `[I 1.5.0, F 1.9.0]` together yields
/// `[I 0, I 1.5.0, F 1.9.0, F 2.0.0]` and reports 1.9.5 as NOT affected, even
/// though the first range plainly covers it.
pub fn affected_by_any(ecosystem: &str, ranges: &[Vec<RangeEvent>], version: &str) -> bool {
    ranges
        .iter()
        .any(|events| affected_by(ecosystem, events, version))
}

/// Decode the warehouse `ranges` column into range-object form.
///
/// Schema v3 stores an array of range objects (array of event arrays). Rows
/// written by the older schema stored ONE flat event array; those decode as a
/// single range object so an existing warehouse keeps matching. Garbage / `""`
/// degrade to "no ranges".
pub fn decode_range_sets(json: &str) -> Vec<Vec<RangeEvent>> {
    if let Ok(sets) = serde_json::from_str::<Vec<Vec<RangeEvent>>>(json) {
        return sets;
    }
    match serde_json::from_str::<Vec<RangeEvent>>(json) {
        Ok(flat) if !flat.is_empty() => vec![flat],
        _ => Vec::new(),
    }
}

/// Expand a single range's `events` into the subset of `candidates` it affects,
/// returned in ascending version order. `candidates` is the set of versions that
/// actually exist for the package (registry index / dep-graph).
pub fn expand_range(ecosystem: &str, events: &[RangeEvent], candidates: &[String]) -> Vec<String> {
    let mut out: Vec<String> = candidates
        .iter()
        .filter(|v| affected_by(ecosystem, events, v))
        .cloned()
        .collect();
    out.sort_by(|a, b| compare(ecosystem, a, b));
    out.dedup();
    out
}

/// Order a `RangeEvent` by its version (`Introduced("0")` = minimal).
fn event_cmp(eco: &str, x: &RangeEvent, y: &RangeEvent) -> Ordering {
    let key = |e: &RangeEvent| -> Option<String> {
        match e {
            RangeEvent::Introduced(v) if v == "0" => None,
            RangeEvent::Introduced(v)
            | RangeEvent::IntroducedExclusive(v)
            | RangeEvent::Fixed(v)
            | RangeEvent::LastAffected(v) => Some(v.clone()),
        }
    };
    match (key(x), key(y)) {
        (None, None) => Ordering::Equal,
        (None, Some(_)) => Ordering::Less,
        (Some(_), None) => Ordering::Greater,
        (Some(a), Some(b)) => compare(eco, &a, &b),
    }
}

// ── SemVer (cargo / npm) ─────────────────────────────────────────────────────

fn semver_cmp(a: &str, b: &str) -> Ordering {
    let (ca, pa) = split_semver(a);
    let (cb, pb) = split_semver(b);
    match cmp_numeric_parts(&ca, &cb) {
        Ordering::Equal => cmp_prerelease(&pa, &pb),
        other => other,
    }
}

/// Split a SemVer string into (core numeric parts, prerelease identifiers).
/// Build metadata (`+...`) is stripped; a missing prerelease yields an empty vec.
fn split_semver(v: &str) -> (Vec<u64>, Vec<String>) {
    let v = v.trim().trim_start_matches('v');
    let v = v.split('+').next().unwrap_or(v); // drop build metadata
    let (core, pre) = match v.split_once('-') {
        Some((c, p)) => (c, p),
        None => (v, ""),
    };
    let core = core
        .split('.')
        .map(|p| p.parse::<u64>().unwrap_or(0))
        .collect();
    let pre = if pre.is_empty() {
        Vec::new()
    } else {
        pre.split('.').map(str::to_string).collect()
    };
    (core, pre)
}

fn cmp_numeric_parts(a: &[u64], b: &[u64]) -> Ordering {
    let n = a.len().max(b.len());
    for i in 0..n {
        let (x, y) = (
            a.get(i).copied().unwrap_or(0),
            b.get(i).copied().unwrap_or(0),
        );
        match x.cmp(&y) {
            Ordering::Equal => {}
            other => return other,
        }
    }
    Ordering::Equal
}

/// SemVer prerelease comparison: a version WITH a prerelease has *lower* precedence
/// than one without; identifiers compare numerically when both numeric, else ASCII.
fn cmp_prerelease(a: &[String], b: &[String]) -> Ordering {
    match (a.is_empty(), b.is_empty()) {
        (true, true) => return Ordering::Equal,
        (true, false) => return Ordering::Greater, // no prerelease > prerelease
        (false, true) => return Ordering::Less,
        (false, false) => {}
    }
    for i in 0..a.len().max(b.len()) {
        match (a.get(i), b.get(i)) {
            (Some(x), Some(y)) => {
                let ord = match (x.parse::<u64>(), y.parse::<u64>()) {
                    (Ok(nx), Ok(ny)) => nx.cmp(&ny),
                    (Ok(_), Err(_)) => Ordering::Less, // numeric < alphanumeric
                    (Err(_), Ok(_)) => Ordering::Greater,
                    (Err(_), Err(_)) => x.cmp(y),
                };
                if ord != Ordering::Equal {
                    return ord;
                }
            }
            (Some(_), None) => return Ordering::Greater, // more fields = higher
            (None, Some(_)) => return Ordering::Less,
            (None, None) => {}
        }
    }
    Ordering::Equal
}

// ── PEP 440 (pypi) — lite ────────────────────────────────────────────────────

fn pep440_cmp(a: &str, b: &str) -> Ordering {
    parse_pep440(a).cmp(&parse_pep440(b))
}

/// A comparable PEP 440 key, following the `packaging` library's sentinel scheme:
/// `(epoch, release, pre_stage, pre_num, post, dev)`. The ordering precedence is
/// `dev < pre(a<b<rc) < final < post`, realized by sentinels — a dev-only release
/// gets the minimal `pre_stage`, a final/post release the maximal; absent `post`
/// is minimal and absent `dev` is maximal so a plain final outranks its devs.
fn parse_pep440(v: &str) -> (u64, Vec<u64>, i64, i64, i64, i64) {
    const NEG: i64 = i64::MIN;
    const POS: i64 = i64::MAX;

    let v = v.trim().to_ascii_lowercase();
    let v = v.strip_prefix('v').unwrap_or(&v).to_string();
    let (epoch, rest) = match v.split_once('!') {
        Some((e, r)) => (e.parse().unwrap_or(0), r.to_string()),
        None => (0, v),
    };
    // Strip the PEP 440 local segment (`+abc`): it is NOT a pre-release. Left in, its
    // letters trip the substring pre-stage scan below (`1.0.0+abc` → the `b` reads as
    // a beta BELOW `1.0.0`, a false positive on a patched build). PEP 440 sorts a
    // local ≥ its public release; for vuln matching, ignoring it (⇒ equal to the
    // release) is the correct, safe reduction.
    let rest = rest.split('+').next().unwrap_or(&rest).to_string();
    // Release = the leading numeric/dot run; the rest is the suffix.
    let mut release = String::new();
    let mut suffix = String::new();
    let mut in_suffix = false;
    for ch in rest.chars() {
        if !in_suffix && (ch.is_ascii_digit() || ch == '.') {
            release.push(ch);
        } else {
            in_suffix = true;
            suffix.push(ch);
        }
    }
    let seg: Vec<u64> = release
        .split('.')
        .filter(|s| !s.is_empty())
        .map(|s| s.parse().unwrap_or(0))
        .collect();

    let num_in = |s: &str| -> i64 {
        s.chars()
            .filter(|c| c.is_ascii_digit())
            .collect::<String>()
            .parse()
            .unwrap_or(0)
    };

    let has_dev = suffix.contains("dev");
    let dev = if has_dev {
        suffix
            .find("dev")
            .map(|i| num_in(&suffix[i..]))
            .unwrap_or(0)
    } else {
        POS // no dev ⇒ sorts after any dev of the same release
    };
    let post = if let Some(i) = suffix.find("post") {
        num_in(&suffix[i..])
    } else {
        NEG
    };

    // pre stage: a < b < rc; a dev-only (no pre, no post) is NEG; otherwise (final
    // or post, no pre) is POS.
    let (pre_stage, pre_num) = if let Some(i) = suffix.find("rc") {
        (2, num_in(&suffix[i..]))
    } else if let Some(i) = suffix.find('c') {
        // PEP 440: a bare `c` is an alias for `rc` (`1.1.0c3` == `1.1.0rc3`). Without
        // this it falls through to the final/post arm and reads as the RELEASE, so
        // `1.1.0c3` compares EQUAL to `1.1.0` and a vulnerable rc of the fix is
        // silently cleared (a false negative). Checked before `b`/`a` (neither can
        // precede a `c` marker) but after `rc` (which already consumed the `c`).
        (2, num_in(&suffix[i..]))
    } else if let Some(i) = suffix.find('b') {
        (1, num_in(&suffix[i..]))
    } else if let Some(i) = suffix.find('a') {
        (0, num_in(&suffix[i..]))
    } else if post == NEG && has_dev {
        (NEG, 0) // dev-only release sorts below pre-releases
    } else {
        (POS, 0) // final / post release sorts above pre-releases
    };

    (epoch, seg, pre_stage, pre_num, post, dev)
}

// ── Maven — lite ─────────────────────────────────────────────────────────────

fn maven_cmp(a: &str, b: &str) -> Ordering {
    let ta = maven_tokens(a);
    let tb = maven_tokens(b);
    for i in 0..ta.len().max(tb.len()) {
        let x = ta.get(i);
        let y = tb.get(i);
        let ord = match (x, y) {
            (Some(MavenTok::Num(nx)), Some(MavenTok::Num(ny))) => nx.cmp(ny),
            (Some(MavenTok::Num(_)), Some(MavenTok::Str(_))) => Ordering::Greater,
            (Some(MavenTok::Str(_)), Some(MavenTok::Num(_))) => Ordering::Less,
            (Some(MavenTok::Str(sx)), Some(MavenTok::Str(sy))) => maven_qualifier_rank(sx)
                .cmp(&maven_qualifier_rank(sy))
                .then_with(|| sx.cmp(sy)),
            // a missing trailing token is treated as ".0" / release
            (Some(MavenTok::Num(nx)), None) => nx.cmp(&0),
            (None, Some(MavenTok::Num(ny))) => 0.cmp(ny),
            (Some(MavenTok::Str(sx)), None) => maven_qualifier_rank(sx).cmp(&0),
            (None, Some(MavenTok::Str(sy))) => 0.cmp(&maven_qualifier_rank(sy)),
            (None, None) => Ordering::Equal,
        };
        if ord != Ordering::Equal {
            return ord;
        }
    }
    Ordering::Equal
}

enum MavenTok {
    Num(u64),
    Str(String),
}

fn maven_tokens(v: &str) -> Vec<MavenTok> {
    // Split on the separators AND at every digit↔letter boundary, so an attached
    // qualifier number — `rc1`, `beta1`, `alpha2`, `m1` (the COMMON Maven spelling,
    // e.g. Spring's `5.3.0-rc1`) — tokenizes as `["rc", 1]`, not the opaque `"rc1"`.
    // Without the boundary split, `"rc1"` is not a known key in `maven_qualifier_rank`
    // and falls to the "unknown ⇒ sorts ABOVE the release" arm — which ranks the
    // release candidate ABOVE its own release and silently clears a vulnerable
    // prerelease of a `Fixed` bound (a false negative). `gem_segments` splits the
    // same way; Maven was the one ecosystem that didn't.
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut cur_digit = false;
    let flush = |cur: &mut String, is_digit: bool, out: &mut Vec<MavenTok>| {
        if cur.is_empty() {
            return;
        }
        if is_digit {
            out.push(MavenTok::Num(cur.parse::<u64>().unwrap_or(0)));
        } else {
            out.push(MavenTok::Str(std::mem::take(cur)));
        }
        cur.clear();
    };
    for c in v.trim().to_ascii_lowercase().chars() {
        if c == '.' || c == '-' || c == '_' {
            flush(&mut cur, cur_digit, &mut out);
            continue;
        }
        let is_digit = c.is_ascii_digit();
        if !cur.is_empty() && is_digit != cur_digit {
            flush(&mut cur, cur_digit, &mut out);
        }
        cur_digit = is_digit;
        cur.push(c);
    }
    flush(&mut cur, cur_digit, &mut out);
    out
}

/// Maven qualifier precedence: well-known pre-release qualifiers sort below the
/// release (rank 0); unknown qualifiers sort above (Maven treats them as > release).
fn maven_qualifier_rank(q: &str) -> i64 {
    match q {
        "alpha" | "a" => -6,
        "beta" | "b" => -5,
        "milestone" | "m" => -4,
        "rc" | "cr" => -3,
        "snapshot" => -2,
        "" | "ga" | "final" | "release" => 0,
        "sp" => 1,
        _ => 2,
    }
}

// ── RubyGems (gem) ───────────────────────────────────────────────────────────

/// One RubyGems version segment.
#[derive(Debug, PartialEq, Eq)]
enum GemSeg {
    Num(u64),
    Str(String),
}

/// Split a Gem version into segments: separators (`.`, `-`, `_`) AND every
/// digit↔letter boundary start a new segment, so `1.0.0.rc1` and `1.0.0-rc1` both
/// become `[1, 0, 0, "rc", 1]`.
fn gem_segments(v: &str) -> Vec<GemSeg> {
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut cur_digit = false;
    let flush = |cur: &mut String, is_digit: bool, out: &mut Vec<GemSeg>| {
        if cur.is_empty() {
            return;
        }
        if is_digit {
            out.push(GemSeg::Num(cur.parse::<u64>().unwrap_or(0)));
        } else {
            out.push(GemSeg::Str(std::mem::take(cur)));
            return;
        }
        cur.clear();
    };
    for c in v.trim().trim_start_matches('v').chars() {
        if c == '.' || c == '-' || c == '_' || c == '+' {
            flush(&mut cur, cur_digit, &mut out);
            continue;
        }
        let is_digit = c.is_ascii_digit();
        if !cur.is_empty() && is_digit != cur_digit {
            flush(&mut cur, cur_digit, &mut out);
        }
        cur_digit = is_digit;
        cur.push(c);
    }
    flush(&mut cur, cur_digit, &mut out);
    out
}

/// RubyGems version comparison. Numeric segments compare numerically; a **string**
/// segment ranks BELOW any numeric one, and a missing trailing segment counts as
/// `0` — which is what makes the prerelease `1.0.0.rc1` sort under its release
/// `1.0.0` (`"rc"` vs the implicit `0`).
fn gem_cmp(a: &str, b: &str) -> Ordering {
    let (sa, sb) = (gem_segments(a), gem_segments(b));
    for i in 0..sa.len().max(sb.len()) {
        let ord = match (sa.get(i), sb.get(i)) {
            (Some(GemSeg::Num(x)), Some(GemSeg::Num(y))) => x.cmp(y),
            (Some(GemSeg::Str(x)), Some(GemSeg::Str(y))) => x.cmp(y),
            (Some(GemSeg::Num(_)), Some(GemSeg::Str(_))) => Ordering::Greater,
            (Some(GemSeg::Str(_)), Some(GemSeg::Num(_))) => Ordering::Less,
            (Some(GemSeg::Num(x)), None) => x.cmp(&0),
            (None, Some(GemSeg::Num(y))) => 0.cmp(y),
            (Some(GemSeg::Str(_)), None) => Ordering::Less,
            (None, Some(GemSeg::Str(_))) => Ordering::Greater,
            (None, None) => Ordering::Equal,
        };
        if ord != Ordering::Equal {
            return ord;
        }
    }
    Ordering::Equal
}

// ── Generic dotted-numeric fallback ──────────────────────────────────────────

/// The fallback ordering for ecosystems with no grammar of their own — in
/// practice **NVD's `cpe`**, which is where the great majority of this crate's
/// bound comparisons happen.
///
/// Both sides are reduced to a vector of numeric segments and compared with
/// [`cmp_numeric_parts`], which **zero-pads the shorter side**. So `1.0` and
/// `1.0.0` reduce to `[1, 0]` and `[1, 0, 0]`, compare as `[1, 0, 0]` vs
/// `[1, 0, 0]`, and are the same version — which is exactly what they are.
///
/// **They must therefore compare `Equal`, and this used to tie-break on
/// `a.cmp(b)` instead.** That string compare made `1.0.0` > `1.0`: two spellings
/// of one version, ordered. In a version *list* that is a cosmetic wart. Here it
/// is a correctness bug in the only thing this crate does, because the ordering
/// is what decides an OSV/NVD range boundary:
///
/// * `IntroducedExclusive("1.0")` opens the window *strictly above* `1.0` — the
///   whole reason the variant exists is that the named version is NOT affected.
///   With the string tie-break, a package whose version is *spelled* `1.0.0` read
///   as greater than the bound and was reported **affected**: a false positive on
///   the exact version the vendor called clean, reintroduced purely by spelling.
/// * `Fixed("2.0")` closes the window at `2.0`. A version spelled `2.0.0` read as
///   greater and was cleared — correct here, but the mirror case
///   `LastAffected("2.0.0")` against a version spelled `2.0` read as *less*, so a
///   vulnerable version could equally be **missed**. Both directions are live.
///
/// Numerically equal versions now compare `Equal`. Two spellings of the same
/// version are the same version, whichever way the bound points.
///
/// **What is deliberately NOT claimed here.** This fallback is not
/// prerelease-aware and does not pretend to be: a trailing non-numeric segment
/// (`1.0.0-rc1`, `1.0.0a`) contributes `0`, so it compares equal to the plain
/// release rather than below it. That was already true before this change — the
/// old string tie-break ranked such a version *above* its release, which is the
/// wrong direction anyway (see the note on [`compare`] about the SemVer
/// ecosystems). An ecosystem that needs prerelease ordering has its own arm in
/// [`compare`]; CPE carries update/prerelease components in a separate CPE field
/// rather than in the version string, which is why the `cpe` path lands here.
fn loose_cmp(a: &str, b: &str) -> Ordering {
    let parse = |v: &str| -> Vec<u64> {
        v.trim()
            .trim_start_matches('v')
            .split(['.', '-', '+', '_'])
            .map(|p| {
                p.chars()
                    .take_while(|c| c.is_ascii_digit())
                    .collect::<String>()
            })
            .map(|s| s.parse::<u64>().unwrap_or(0))
            .collect()
    };
    cmp_numeric_parts(&parse(a), &parse(b))
}

// ── Debian / dpkg (deb; apk approximated) ────────────────────────────────────

/// dpkg version comparison of `[epoch:]upstream[-revision]`: epoch (numeric,
/// default 0) first, then upstream, then revision — each segment via the Debian
/// algorithm [`deb_ver_cmp`].
fn deb_cmp(a: &str, b: &str) -> Ordering {
    let (ea, ua, ra) = split_deb(a);
    let (eb, ub, rb) = split_deb(b);
    ea.cmp(&eb)
        .then_with(|| deb_ver_cmp(&ua, &ub))
        .then_with(|| deb_ver_cmp(&ra, &rb))
}

/// `(epoch, upstream, revision)`; missing epoch ⇒ 0, missing revision ⇒ "".
fn split_deb(v: &str) -> (u64, String, String) {
    let v = v.trim();
    let (epoch, rest) = match v.split_once(':') {
        Some((e, r)) if !e.is_empty() && e.bytes().all(|c| c.is_ascii_digit()) => {
            (e.parse().unwrap_or(0), r)
        }
        _ => (0, v),
    };
    let (upstream, revision) = match rest.rsplit_once('-') {
        Some((u, r)) => (u, r),
        None => (rest, ""),
    };
    (epoch, upstream.to_string(), revision.to_string())
}

/// dpkg `verrevcmp`: alternating non-digit runs (lexical — `~` before everything,
/// letters before other punctuation, end-of-string between `~` and the rest) and
/// digit runs (numeric, leading zeros ignored, longer number wins).
fn deb_ver_cmp(a: &str, b: &str) -> Ordering {
    let (a, b) = (a.as_bytes(), b.as_bytes());
    let (mut i, mut j) = (0usize, 0usize);
    let at = |s: &[u8], k: usize| -> u8 { s.get(k).copied().unwrap_or(0) };
    // dpkg order(): '~' sorts first (-1), a DIGIT and end-of-string are 0, letters by
    // ASCII, other punctuation by ASCII+256. The digit arm is load-bearing: the
    // non-digit run below advances while EITHER side is non-digit (dpkg's condition),
    // so when a digit on one side aligns with a letter/punct on the other, `order` is
    // asked to rank that digit — and dpkg ranks digits BELOW letters (0 < 65..). Miss
    // the arm and the digit falls to `ASCII+256`, ranking ABOVE letters: a straight
    // inversion (`1a1` vs `1ab`) that flips deb range boundaries → a false verdict.
    let order = |c: u8| -> i32 {
        if c == b'~' {
            -1
        } else if c.is_ascii_digit() || c == 0 {
            0
        } else if c.is_ascii_alphabetic() {
            c as i32
        } else {
            c as i32 + 256
        }
    };
    loop {
        if i >= a.len() && j >= b.len() {
            return Ordering::Equal;
        }
        while (i < a.len() && !a[i].is_ascii_digit()) || (j < b.len() && !b[j].is_ascii_digit()) {
            let (vc, rc) = (order(at(a, i)), order(at(b, j)));
            if vc != rc {
                return vc.cmp(&rc);
            }
            i += 1;
            j += 1;
        }
        while at(a, i) == b'0' {
            i += 1;
        }
        while at(b, j) == b'0' {
            j += 1;
        }
        let mut first_diff = 0i32;
        while at(a, i).is_ascii_digit() && at(b, j).is_ascii_digit() {
            if first_diff == 0 {
                first_diff = a[i] as i32 - b[j] as i32;
            }
            i += 1;
            j += 1;
        }
        if at(a, i).is_ascii_digit() {
            return Ordering::Greater;
        }
        if at(b, j).is_ascii_digit() {
            return Ordering::Less;
        }
        if first_diff != 0 {
            return first_diff.cmp(&0);
        }
    }
}

// ── RPM / rpmvercmp (rpm) ────────────────────────────────────────────────────

fn strip_zeros(s: &[u8]) -> &[u8] {
    let mut k = 0;
    while k < s.len() && s[k] == b'0' {
        k += 1;
    }
    &s[k..]
}

/// rpmvercmp: compare RPM version/release segments. Separators are ignored; `~`
/// sorts before everything (pre-release), `^` after (post-release); a digit run
/// outranks an alpha run; numeric runs compare by value (leading zeros stripped,
/// longer wins), alpha runs by ASCII. Plain version strings (OSV distro ranges
/// carry no epoch/release here).
fn rpm_cmp(a: &str, b: &str) -> Ordering {
    if a == b {
        return Ordering::Equal;
    }
    let (a, b) = (a.as_bytes(), b.as_bytes());
    let (mut i, mut j) = (0usize, 0usize);
    let sep = |c: u8| !c.is_ascii_alphanumeric() && c != b'~' && c != b'^';
    let at = |s: &[u8], k: usize| -> Option<u8> { s.get(k).copied() };
    loop {
        while i < a.len() && sep(a[i]) {
            i += 1;
        }
        while j < b.len() && sep(b[j]) {
            j += 1;
        }
        // tilde: the side carrying it is older.
        if at(a, i) == Some(b'~') || at(b, j) == Some(b'~') {
            if at(a, i) != Some(b'~') {
                return Ordering::Greater;
            }
            if at(b, j) != Some(b'~') {
                return Ordering::Less;
            }
            i += 1;
            j += 1;
            continue;
        }
        // caret: the side carrying it is newer, unless it has nothing after.
        if at(a, i) == Some(b'^') || at(b, j) == Some(b'^') {
            if i >= a.len() {
                return Ordering::Less;
            }
            if j >= b.len() {
                return Ordering::Greater;
            }
            if at(a, i) != Some(b'^') {
                return Ordering::Less;
            }
            if at(b, j) != Some(b'^') {
                return Ordering::Greater;
            }
            i += 1;
            j += 1;
            continue;
        }
        if i >= a.len() || j >= b.len() {
            break;
        }
        // grab one all-digit or all-alpha segment from `a`, then the same type from `b`.
        let isnum = a[i].is_ascii_digit();
        let start_a = i;
        if isnum {
            while i < a.len() && a[i].is_ascii_digit() {
                i += 1;
            }
        } else {
            while i < a.len() && a[i].is_ascii_alphabetic() {
                i += 1;
            }
        }
        let s1 = &a[start_a..i];
        let start_b = j;
        if isnum {
            while j < b.len() && b[j].is_ascii_digit() {
                j += 1;
            }
        } else {
            while j < b.len() && b[j].is_ascii_alphabetic() {
                j += 1;
            }
        }
        let s2 = &b[start_b..j];
        // `b` has no segment of this type here → a numeric run is newer, alpha older.
        if s2.is_empty() {
            return if isnum {
                Ordering::Greater
            } else {
                Ordering::Less
            };
        }
        let ord = if isnum {
            let (t1, t2) = (strip_zeros(s1), strip_zeros(s2));
            if t1.len() != t2.len() {
                t1.len().cmp(&t2.len())
            } else {
                t1.cmp(t2)
            }
        } else {
            s1.cmp(s2)
        };
        if ord != Ordering::Equal {
            return ord;
        }
    }
    match (i >= a.len(), j >= b.len()) {
        (true, false) => Ordering::Less,
        (false, true) => Ordering::Greater,
        _ => Ordering::Equal,
    }
}

// ── Alpine / apkvercmp (apk) ─────────────────────────────────────────────────

/// Rank of an Alpine `_suffix`: pre-release suffixes sort before the release
/// (rank 4), post-release (`_p`) after. Unknown suffix ⇒ release-level.
fn apk_suffix_rank(s: &str) -> i32 {
    match s {
        "alpha" => 0,
        "beta" => 1,
        "pre" => 2,
        "rc" => 3,
        "cvs" => 5,
        "svn" => 6,
        "git" => 7,
        "hg" => 8,
        "p" => 9,
        _ => 4,
    }
}

struct ApkVer {
    epoch: u64,
    nums: Vec<u64>,
    letter: Option<char>,
    suffix_rank: i32,
    suffix_num: u64,
    revision: u64,
}

/// Parse an Alpine version `[epoch:]N(.N)*[letter][_suffix[num]][~commit][-rN]`.
fn parse_apk(v: &str) -> ApkVer {
    let v = v.trim();
    let (epoch, rest) = match v.split_once(':') {
        Some((e, r)) if !e.is_empty() && e.bytes().all(|c| c.is_ascii_digit()) => {
            (e.parse().unwrap_or(0), r)
        }
        _ => (0, v),
    };
    let (rest, revision) = match rest.rsplit_once("-r") {
        Some((h, r)) if !r.is_empty() && r.bytes().all(|c| c.is_ascii_digit()) => {
            (h, r.parse().unwrap_or(0))
        }
        _ => (rest, 0),
    };
    let rest = rest.split('~').next().unwrap_or(rest); // drop ~commit
    let (core, suffix_rank, suffix_num) = match rest.split_once('_') {
        Some((c, suf)) => {
            let name: String = suf
                .chars()
                .take_while(|ch| ch.is_ascii_alphabetic())
                .collect();
            let num = suf[name.len()..].parse().unwrap_or(0);
            (c, apk_suffix_rank(&name), num)
        }
        None => (rest, 4, 0),
    };
    let (digits, letter) = match core.chars().last() {
        Some(ch) if ch.is_ascii_alphabetic() => (&core[..core.len() - ch.len_utf8()], Some(ch)),
        _ => (core, None),
    };
    let nums = digits
        .split('.')
        .filter(|s| !s.is_empty())
        .map(|s| s.parse().unwrap_or(0))
        .collect();
    ApkVer {
        epoch,
        nums,
        letter,
        suffix_rank,
        suffix_num,
        revision,
    }
}

/// apkvercmp: epoch, then numeric dot-parts, then an optional trailing letter
/// (none < `a` < `b`…), then the `_suffix` rank (`_alpha`<`_rc`<release<`_p`) and
/// its number, then the `-rN` revision.
fn apk_cmp(a: &str, b: &str) -> Ordering {
    let (pa, pb) = (parse_apk(a), parse_apk(b));
    pa.epoch
        .cmp(&pb.epoch)
        .then_with(|| cmp_numeric_parts(&pa.nums, &pb.nums))
        .then_with(|| pa.letter.cmp(&pb.letter))
        .then_with(|| pa.suffix_rank.cmp(&pb.suffix_rank))
        .then_with(|| pa.suffix_num.cmp(&pb.suffix_num))
        .then_with(|| pa.revision.cmp(&pb.revision))
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── P4: distro version grammars ──────────────────────────────────────────

    #[test]
    fn deb_version_ordering() {
        // numeric segments compare by value, not lexically.
        assert_eq!(compare("deb", "2.10", "2.9"), Ordering::Greater);
        // epoch dominates the rest.
        assert_eq!(compare("deb", "1:1.0", "2.0"), Ordering::Greater);
        // '~' sorts before the release it precedes (pre-release).
        assert_eq!(compare("deb", "1.0~rc1", "1.0"), Ordering::Less);
        assert_eq!(compare("deb", "1.0~beta", "1.0~rc1"), Ordering::Less);
        // debian revision breaks ties.
        assert_eq!(compare("deb", "1.0-1", "1.0-2"), Ordering::Less);
        // a real Debian-style upstream+revision.
        assert_eq!(
            compare("deb", "1.4.0-1ubuntu2", "1.4.0-1ubuntu1"),
            Ordering::Greater
        );
        assert_eq!(compare("deb", "2.14.1", "2.14.1"), Ordering::Equal);
    }

    /// RED-when-reverted: dpkg's `order()` ranks a DIGIT below a letter (digit → 0,
    /// letters → their ASCII ≥ 65). The non-digit run advances while EITHER side is
    /// non-digit, so a digit aligned against a letter IS ranked via `order` — miss
    /// the digit arm and the digit falls to `ASCII+256`, ranking ABOVE the letter, a
    /// straight inversion that flips a deb range boundary. `dpkg --compare-versions`
    /// agrees with these.
    #[test]
    fn deb_digit_ranks_below_letter_like_dpkg() {
        // At the 3rd position a='1' (digit) aligns with b='b' (letter): digit < letter.
        assert_eq!(compare("deb", "1a1", "1ab"), Ordering::Less);
        // Digit-vs-punctuation: dpkg digit(0) < '.'(order 302).
        assert_eq!(compare("deb", "1a1", "1a."), Ordering::Less);
        // A digit revision suffix sorts below a lettered one at the same slot.
        assert_eq!(compare("deb", "1.0-1x", "1.0-ax"), Ordering::Less);
        // Sanity: the fix leaves '~' and pure-numeric ordering intact.
        assert_eq!(compare("deb", "1.0~rc1", "1.0"), Ordering::Less);
        assert_eq!(compare("deb", "2.10", "2.9"), Ordering::Greater);
    }

    #[test]
    fn rpm_version_ordering() {
        assert_eq!(compare("rpm", "1.10", "1.9"), Ordering::Greater);
        assert_eq!(compare("rpm", "1.0", "1.0.1"), Ordering::Less);
        // tilde = pre-release (older); caret = post (newer).
        assert_eq!(compare("rpm", "1.0~rc1", "1.0"), Ordering::Less);
        assert_eq!(compare("rpm", "1.0^post", "1.0"), Ordering::Greater);
        // leading zeros don't change numeric value.
        assert_eq!(compare("rpm", "1.007", "1.7"), Ordering::Equal);
        assert_eq!(compare("rpm", "2.4.57", "2.4.57"), Ordering::Equal);
    }

    #[test]
    fn apk_version_ordering() {
        assert_eq!(compare("apk", "1.10", "1.9"), Ordering::Greater);
        // pre-release suffix sorts before the release.
        assert_eq!(compare("apk", "1.0_rc1", "1.0"), Ordering::Less);
        assert_eq!(compare("apk", "1.0_alpha", "1.0_beta"), Ordering::Less);
        // post-release suffix sorts after.
        assert_eq!(compare("apk", "1.0_p1", "1.0"), Ordering::Greater);
        // build revision breaks ties; trailing letter is a higher version.
        assert_eq!(compare("apk", "1.0-r2", "1.0-r1"), Ordering::Greater);
        assert_eq!(compare("apk", "1.0a", "1.0"), Ordering::Greater);
        assert_eq!(compare("apk", "1.2.3-r0", "1.2.3-r0"), Ordering::Equal);
    }

    /// Wolfi, Chainguard and Alpine (OSV's `Alpine:v3.x` too) are apk
    /// distributions: their versions are apk versions, not loose dotted ones.
    #[test]
    fn wolfi_chainguard_and_alpine_use_the_apk_grammar() {
        for eco in ["wolfi", "Wolfi", "chainguard", "alpine", "Alpine:v3.20", "alpine:v3.19"] {
            assert_eq!(compare(eco, "1.2.3-r10", "1.2.3-r9"), Ordering::Greater, "{eco}");
            assert_eq!(compare(eco, "1.0_rc1", "1.0"), Ordering::Less, "{eco}");
            assert_eq!(compare(eco, "1.0_p1", "1.0-r5"), Ordering::Greater, "{eco}");
            assert_eq!(compare(eco, "1.0a", "1.0"), Ordering::Greater, "{eco}");
        }
        // A package rebuilt past the fix is not affected by `fixed: 1.0-r9`.
        let fixed = [RangeEvent::Introduced("0".into()), RangeEvent::Fixed("1.0-r9".into())];
        assert!(!affected_by("wolfi", &fixed, "1.0-r10"));
        assert!(affected_by("wolfi", &fixed, "1.0-r8"));
    }

    #[test]
    fn deb_range_match_for_a_debian_advisory() {
        // An OSV Debian range [introduced 2.0, fixed 2.15.0) over deb versions.
        let events = vec![
            RangeEvent::Introduced("2.0".into()),
            RangeEvent::Fixed("2.15.0".into()),
        ];
        assert!(affected_by("deb", &events, "2.14.1-1"), "inside the window");
        assert!(
            !affected_by("deb", &events, "2.15.0"),
            "fixed bound is exclusive"
        );
        assert!(
            !affected_by("deb", &events, "1.2.17"),
            "below the introduced bound"
        );
    }

    #[test]
    fn semver_orders_core_and_prerelease() {
        assert_eq!(compare("cargo", "1.2.3", "1.2.4"), Ordering::Less);
        assert_eq!(compare("cargo", "1.10.0", "1.9.0"), Ordering::Greater);
        assert_eq!(compare("cargo", "2.0.0", "2.0.0"), Ordering::Equal);
        // prerelease < release
        assert_eq!(compare("npm", "1.0.0-alpha", "1.0.0"), Ordering::Less);
        assert_eq!(
            compare("npm", "1.0.0-alpha.1", "1.0.0-alpha.2"),
            Ordering::Less
        );
        assert_eq!(compare("npm", "1.0.0-alpha", "1.0.0-beta"), Ordering::Less);
        // build metadata + v-prefix ignored
        assert_eq!(compare("cargo", "v1.2.3+build", "1.2.3"), Ordering::Equal);
    }

    #[test]
    fn pep440_orders_pre_dev_post() {
        assert_eq!(compare("pypi", "1.0.0", "1.0.1"), Ordering::Less);
        assert_eq!(compare("pypi", "1.0.0a1", "1.0.0"), Ordering::Less);
        assert_eq!(compare("pypi", "1.0.0rc1", "1.0.0"), Ordering::Less);
        assert_eq!(compare("pypi", "1.0.0a1", "1.0.0b1"), Ordering::Less);
        assert_eq!(compare("pypi", "1.0.0.dev1", "1.0.0a1"), Ordering::Less);
        assert_eq!(compare("pypi", "1.0.0.post1", "1.0.0"), Ordering::Greater);
    }

    /// RED-when-reverted: two PEP 440 edges the audit surfaced.
    /// (a) a `+local` build tag is NOT a pre-release — before stripping it, the `b`
    ///     in `1.0.0+abc` tripped the beta scan and read as BELOW `1.0.0` (a false
    ///     positive on a patched build).
    /// (b) a bare `c` is the `rc` alias — before handling it, `1.1.0c3` read as the
    ///     final release (EQUAL to `1.1.0`) and a vulnerable rc of the fix was cleared.
    #[test]
    fn pep440_local_build_and_c_alias() {
        // (a) local build metadata is ignored → equal to the public release, not below.
        assert_eq!(
            compare("pypi", "1.0.0+abc", "1.0.0"),
            Ordering::Equal,
            "local build ≠ prerelease"
        );
        let events = vec![
            RangeEvent::Introduced("0".into()),
            RangeEvent::Fixed("1.0.0".into()),
        ];
        assert!(
            !affected_by("pypi", &events, "1.0.0+abc"),
            "a local build of the FIX is not affected"
        );
        // (b) `c` == `rc`: 1.1.0c3 is a prerelease below 1.1.0.
        assert_eq!(
            compare("pypi", "1.1.0c3", "1.1.0"),
            Ordering::Less,
            "c3 == rc3 < release"
        );
        assert_eq!(
            compare("pypi", "1.1.0c3", "1.1.0rc3"),
            Ordering::Equal,
            "c and rc are the same stage"
        );
        let events2 = vec![
            RangeEvent::Introduced("0".into()),
            RangeEvent::Fixed("1.1.0".into()),
        ];
        assert!(
            affected_by("pypi", &events2, "1.1.0c3"),
            "a vulnerable rc of the fix must match"
        );
    }

    /// RED-when-reverted: the `pip` label (what `manifests::PipParser` tags Python
    /// components) MUST get PEP 440, not the loose fallback. Under `loose_cmp` a
    /// prerelease sorts ABOVE its release (`1.0.0rc1` > `1.0.0`), which clears a
    /// vulnerable prerelease of a `Fixed` bound — a false negative. This pins that
    /// `pip` and `pypi` compare identically.
    #[test]
    fn pip_label_gets_pep440_like_pypi() {
        assert_eq!(
            compare("pip", "1.0.0rc1", "1.0.0"),
            Ordering::Less,
            "prerelease < release under pip"
        );
        assert_eq!(compare("pip", "1.0.0a1", "1.0.0b1"), Ordering::Less);
        assert_eq!(compare("pip", "1.0.0.post1", "1.0.0"), Ordering::Greater);
        // pip and pypi are the same ecosystem — identical ordering.
        assert_eq!(
            compare("pip", "2.0.0", "2.0.1"),
            compare("pypi", "2.0.0", "2.0.1")
        );
    }

    #[test]
    fn maven_orders_numeric_and_qualifiers() {
        assert_eq!(compare("maven", "1.0", "1.1"), Ordering::Less);
        assert_eq!(compare("maven", "1.0-alpha", "1.0"), Ordering::Less);
        assert_eq!(compare("maven", "1.0-SNAPSHOT", "1.0"), Ordering::Less);
        assert_eq!(compare("maven", "1.0-rc1", "1.0-beta"), Ordering::Greater);
        assert_eq!(compare("maven", "2.0", "10.0"), Ordering::Less);
    }

    /// RED-when-reverted: a NUMBERED pre-release qualifier (`rc1`, `beta1`, `m1`) —
    /// the common Maven spelling — MUST rank below its release. Before splitting
    /// maven tokens at the digit↔letter boundary, `"rc1"` was one opaque token, not a
    /// known qualifier, so it fell to the "unknown ⇒ ABOVE release" arm and ranked
    /// `2.0.0-rc1` ABOVE `2.0.0` — silently clearing a vulnerable RC of a `Fixed`
    /// bound. (The bare `-alpha`/`-SNAPSHOT` forms the other test covers always
    /// worked; only the numbered forms broke, so it went uncaught.)
    #[test]
    fn maven_numbered_qualifier_ranks_below_release() {
        assert_eq!(
            compare("maven", "2.0.0-rc1", "2.0.0"),
            Ordering::Less,
            "rc1 < release"
        );
        assert_eq!(compare("maven", "2.0.0-beta1", "2.0.0"), Ordering::Less);
        assert_eq!(compare("maven", "2.0.0-alpha2", "2.0.0"), Ordering::Less);
        assert_eq!(
            compare("maven", "5.3.0-m1", "5.3.0"),
            Ordering::Less,
            "milestone1 < release"
        );
        // ...so an RC of the fix is still inside [0, 2.0.0) and IS matched.
        let events = vec![
            RangeEvent::Introduced("0".into()),
            RangeEvent::Fixed("2.0.0".into()),
        ];
        assert!(
            affected_by("maven", &events, "2.0.0-rc1"),
            "a vulnerable RC of the fix must match"
        );
        // A post-release build number after the release is still ABOVE it (clean).
        assert_eq!(compare("maven", "2.0.0-1", "2.0.0"), Ordering::Greater);
        // Ordering among numbered qualifiers stays sane.
        assert_eq!(compare("maven", "1.0-alpha1", "1.0-alpha2"), Ordering::Less);
        assert_eq!(compare("maven", "1.0-rc1", "1.0-rc2"), Ordering::Less);
    }

    #[test]
    fn affected_by_introduced_fixed_window() {
        let events = vec![
            RangeEvent::Introduced("1.0.0".into()),
            RangeEvent::Fixed("1.2.0".into()),
        ];
        assert!(!affected_by("cargo", &events, "0.9.0"));
        assert!(affected_by("cargo", &events, "1.0.0")); // inclusive lower
        assert!(affected_by("cargo", &events, "1.1.5"));
        assert!(!affected_by("cargo", &events, "1.2.0")); // exclusive upper
        assert!(!affected_by("cargo", &events, "1.3.0"));
    }

    #[test]
    fn affected_by_introduced_zero_and_last_affected() {
        let events = vec![
            RangeEvent::Introduced("0".into()),
            RangeEvent::LastAffected("2.0.0".into()),
        ];
        assert!(affected_by("cargo", &events, "0.1.0"));
        assert!(affected_by("cargo", &events, "2.0.0")); // inclusive
        assert!(!affected_by("cargo", &events, "2.0.1"));
    }

    #[test]
    fn expand_range_picks_versions_in_window_sorted() {
        let events = vec![
            RangeEvent::Introduced("1.0.0".into()),
            RangeEvent::Fixed("1.2.0".into()),
        ];
        let candidates = vec![
            "1.2.0".to_string(),
            "0.9.0".to_string(),
            "1.0.0".to_string(),
            "1.1.0".to_string(),
            "1.3.0".to_string(),
        ];
        let got = expand_range("cargo", &events, &candidates);
        assert_eq!(got, vec!["1.0.0", "1.1.0"]);
    }

    // ── Exclusive lower bounds (NVD `versionStartExcluding`) ─────────────────

    /// RED-when-broken: `IntroducedExclusive` opens the window STRICTLY ABOVE its
    /// version. The bound itself must stay clean — that is the whole reason the
    /// variant exists, because `Introduced` (inclusive) over-flags it.
    #[test]
    fn introduced_exclusive_leaves_the_boundary_unaffected() {
        let events = vec![
            RangeEvent::IntroducedExclusive("1.0".into()),
            RangeEvent::Fixed("2.0".into()),
        ];
        assert!(
            !affected_by("cpe", &events, "1.0"),
            "the exclusive lower bound itself is clean"
        );
        assert!(
            affected_by("cpe", &events, "1.0.1"),
            "strictly above the bound is affected"
        );
        assert!(affected_by("cpe", &events, "1.9"), "inside the window");
        assert!(!affected_by("cpe", &events, "0.9"), "below the bound");
        assert!(
            !affected_by("cpe", &events, "2.0"),
            "the fix stays exclusive"
        );
        // Contrast: the inclusive variant DOES flag the boundary. If these two
        // behaved the same, the new variant would be doing nothing.
        let inclusive = vec![
            RangeEvent::Introduced("1.0".into()),
            RangeEvent::Fixed("2.0".into()),
        ];
        assert!(
            affected_by("cpe", &inclusive, "1.0"),
            "Introduced is inclusive by contrast"
        );
    }

    /// An exclusive lower bound with no upper bound runs to +∞, and sorts/evaluates
    /// correctly under a real ecosystem grammar too (SemVer prerelease rule).
    #[test]
    fn introduced_exclusive_open_ended_and_semver_aware() {
        let open = vec![RangeEvent::IntroducedExclusive("3.1.0".into())];
        assert!(!affected_by("cargo", &open, "3.1.0"));
        assert!(affected_by("cargo", &open, "3.1.1"));
        assert!(affected_by("cargo", &open, "9.9.9"));
        // A prerelease of the bound is BELOW it ⇒ not yet introduced.
        assert!(!affected_by("cargo", &open, "3.1.0-rc.1"));
    }

    /// Regression for the f48e285 (Log4Shell-class) fix: a range anchored with an
    /// implicit `Introduced("0")` and an exclusive upper bound must still match
    /// everything below the fix. Adding `IntroducedExclusive` must not disturb the
    /// `Introduced("0")` = -infinity special case or the event ordering.
    #[test]
    fn unbounded_below_range_still_matches_below_the_fix() {
        let events = vec![
            RangeEvent::Introduced("0".into()),
            RangeEvent::Fixed("2.15.0".into()),
        ];
        assert!(affected_by("cpe", &events, "2.14.1"), "just below the fix");
        assert!(
            affected_by("cpe", &events, "0.0.1"),
            "from the beginning of time"
        );
        assert!(
            !affected_by("cpe", &events, "2.15.0"),
            "the fix is exclusive"
        );
        assert!(!affected_by("cpe", &events, "2.17.0"), "above the fix");
    }

    // ── The loose/CPE fallback: equal is EQUAL, whatever the spelling ─────────

    /// **RED when broken (M-1).** Under the CPE fallback, versions that differ
    /// only in how many trailing `.0` segments they are spelled with are the SAME
    /// version and must compare `Equal`. `loose_cmp` used to tie-break equal
    /// numeric parts on `a.cmp(b)`, so `1.0.0` compared *greater* than `1.0` — a
    /// verdict that depended on spelling rather than on the version.
    ///
    /// Restore the `Ordering::Equal => a.cmp(b)` tie-break and every pair below
    /// goes red in one direction or the other.
    #[test]
    fn loose_equal_versions_compare_equal_however_they_are_spelled() {
        // Each pair is one version written two ways.
        let same = [
            ("1.0", "1.0.0"),
            ("1.0.0", "1.0.0.0"),
            ("1", "1.0"),
            ("1", "1.0.0"),
            ("2.14", "2.14.0"),
            ("v1.0", "1.0.0"),
            ("0", "0.0.0"),
            ("10.2", "10.2.0.0"),
        ];
        for (a, b) in same {
            assert_eq!(
                compare("cpe", a, b),
                Ordering::Equal,
                "`{a}` and `{b}` are the same version — the verdict must not depend on spelling"
            );
            assert_eq!(
                compare("cpe", b, a),
                Ordering::Equal,
                "…and symmetrically (`{b}` vs `{a}`)"
            );
        }
        // The fallback still ORDERS versions that really differ — the fix must not
        // flatten everything to Equal.
        assert_eq!(compare("cpe", "1.0.1", "1.0"), Ordering::Greater);
        assert_eq!(compare("cpe", "1.0", "1.0.1"), Ordering::Less);
        assert_eq!(
            compare("cpe", "2.0", "10.0"),
            Ordering::Less,
            "numeric, not lexicographic"
        );
    }

    /// **RED when broken (M-1), at the boundary that matters.** The spelling bug
    /// is not cosmetic: it moves range verdicts. An `IntroducedExclusive("1.0")`
    /// window must leave `1.0` clean *however the scanned package spells it* —
    /// `1.0.0` used to sort above the bound and be reported **affected**, which is
    /// precisely the boundary false positive the exclusive variant exists to
    /// prevent, reintroduced as a function of spelling.
    ///
    /// The mirror direction is asserted too: a `LastAffected("2.0.0")` bound is
    /// inclusive, so a package spelled `2.0` must still be flagged — under the old
    /// tie-break it sorted *below* `2.0.0`, which happens to keep it affected here,
    /// while `Fixed("2.0")` against a package spelled `2.0.0` sorted *above* the
    /// fix and was silently cleared. Both are covered below.
    #[test]
    fn cpe_range_verdicts_do_not_depend_on_version_spelling() {
        // Exclusive lower bound: the bound version is clean, any spelling of it.
        let excl = vec![
            RangeEvent::IntroducedExclusive("1.0".into()),
            RangeEvent::Fixed("2.0".into()),
        ];
        for v in ["1.0", "1.0.0", "1.0.0.0", "v1.0.0"] {
            assert!(
                !affected_by("cpe", &excl, v),
                "`{v}` IS the exclusive lower bound `1.0` — a false positive here is the exact \
                 boundary bug IntroducedExclusive exists to prevent"
            );
        }
        assert!(
            affected_by("cpe", &excl, "1.0.1"),
            "strictly above the bound is still affected"
        );

        // Exclusive upper bound: the fix version is clean, any spelling of it —
        // and one segment below it is still affected, any spelling.
        let fixed = vec![
            RangeEvent::Introduced("0".into()),
            RangeEvent::Fixed("2.0".into()),
        ];
        for v in ["2.0", "2.0.0", "2.0.0.0"] {
            assert!(
                !affected_by("cpe", &fixed, v),
                "`{v}` IS the fix `2.0` — not affected"
            );
        }
        for v in ["1.9", "1.9.0", "1.99.9"] {
            assert!(
                affected_by("cpe", &fixed, v),
                "`{v}` is below the fix — still affected"
            );
        }

        // Inclusive upper bound: the bound version IS affected, any spelling. A
        // miss here is a vulnerable package reported clean.
        let last = vec![
            RangeEvent::Introduced("1.0".into()),
            RangeEvent::LastAffected("2.0.0".into()),
        ];
        for v in ["2.0", "2.0.0", "2.0.0.0"] {
            assert!(
                affected_by("cpe", &last, v),
                "`{v}` IS the last affected version `2.0.0` — missing it hides a real hit"
            );
        }
        assert!(
            !affected_by("cpe", &last, "2.0.1"),
            "above the last affected version is clean"
        );
        // And the inclusive LOWER bound flags its own version, any spelling.
        for v in ["1.0", "1.0.0"] {
            assert!(
                affected_by("cpe", &last, v),
                "`{v}` IS the inclusive Introduced bound `1.0`"
            );
        }
    }

    // ── Ecosystem grammars beyond the loose fallback ─────────────────────────

    /// Go uses strict SemVer: a prerelease of the FIX is still vulnerable. Under
    /// the loose dotted-numeric fallback `1.5.0-rc.1` parses to [1,5,0,0,1] and
    /// ranks ABOVE `1.5.0`, so the `Fixed("1.5.0")` bound clears it — a vulnerable
    /// version reported clean. The inverse over-flags at an `Introduced` bound.
    #[test]
    fn go_prerelease_sorts_below_its_release() {
        assert_eq!(compare("go", "1.5.0-rc.1", "1.5.0"), Ordering::Less);
        assert_eq!(compare("go", "v1.5.0-rc.1", "v1.5.0"), Ordering::Less);
        assert_eq!(compare("go", "1.5.1", "1.5.0"), Ordering::Greater);
        let events = vec![
            RangeEvent::Introduced("0".into()),
            RangeEvent::Fixed("1.5.0".into()),
        ];
        assert!(
            affected_by("go", &events, "1.5.0-rc.1"),
            "prerelease of the fix is affected"
        );
        assert!(
            !affected_by("go", &events, "1.5.0"),
            "the fix itself is not"
        );
        // Over-flag direction: a prerelease is BELOW the introduced bound.
        let intro = vec![RangeEvent::Introduced("1.0.0".into())];
        assert!(
            !affected_by("go", &intro, "1.0.0-rc1"),
            "1.0.0-rc1 < 1.0.0 ⇒ not introduced yet"
        );
    }

    #[test]
    fn nuget_and_composer_use_semver_prerelease_rule() {
        assert_eq!(compare("nuget", "3.1.0-preview.2", "3.1.0"), Ordering::Less);
        assert_eq!(compare("nuget", "3.1.0.1", "3.1.0"), Ordering::Greater); // 4-part
        assert_eq!(compare("composer", "2.0.0-beta1", "2.0.0"), Ordering::Less);
    }

    /// RubyGems spells prereleases with a dot (`1.0.0.rc1`); a letter segment ranks
    /// below the implicit 0 of the release.
    #[test]
    fn gem_prerelease_sorts_below_its_release() {
        assert_eq!(compare("gem", "1.0.0.rc1", "1.0.0"), Ordering::Less);
        assert_eq!(compare("gem", "1.0.0.pre", "1.0.0.rc1"), Ordering::Less);
        assert_eq!(compare("gem", "1.0.0.rc2", "1.0.0.rc1"), Ordering::Greater);
        assert_eq!(compare("gem", "1.10.0", "1.9.0"), Ordering::Greater);
        assert_eq!(compare("gem", "1.0.0", "1.0.0"), Ordering::Equal);
        let events = vec![
            RangeEvent::Introduced("0".into()),
            RangeEvent::Fixed("1.0.0".into()),
        ];
        assert!(
            affected_by("gem", &events, "1.0.0.rc1"),
            "prerelease of the fix is affected"
        );
        assert!(!affected_by("gem", &events, "1.0.0"));
    }

    // ── Range disjunction ────────────────────────────────────────────────────

    /// Two ranges of the same advisory are a UNION, evaluated separately. Walking
    /// their concatenated events instead lets the narrower range's `Fixed` cancel
    /// the wider one: [I 0, F 2.0.0] + [I 1.5.0, F 1.9.0] spliced sorts to
    /// [I 0, I 1.5.0, F 1.9.0, F 2.0.0] and calls 1.9.5 unaffected.
    #[test]
    fn affected_by_any_unions_ranges_rather_than_splicing_events() {
        let wide = vec![
            RangeEvent::Introduced("0".into()),
            RangeEvent::Fixed("2.0.0".into()),
        ];
        let narrow = vec![
            RangeEvent::Introduced("1.5.0".into()),
            RangeEvent::Fixed("1.9.0".into()),
        ];
        let sets = vec![wide.clone(), narrow.clone()];
        assert!(
            affected_by_any("cargo", &sets, "1.9.5"),
            "covered by the wide range"
        );
        assert!(affected_by_any("cargo", &sets, "1.6.0"), "covered by both");
        assert!(
            affected_by_any("cargo", &sets, "0.1.0"),
            "covered by the wide range"
        );
        assert!(!affected_by_any("cargo", &sets, "2.0.0"), "above both");

        // The splice this replaces really is wrong — pin the failure mode.
        let spliced: Vec<RangeEvent> = wide.into_iter().chain(narrow).collect();
        assert!(
            !affected_by("cargo", &spliced, "1.9.5"),
            "concatenation cancels the wide range"
        );
    }

    /// Multi-branch advisories (e.g. a 1.x and a 2.x window) are disjoint ranges.
    #[test]
    fn affected_by_any_handles_disjoint_branches() {
        let sets = vec![
            vec![
                RangeEvent::Introduced("1.0.0".into()),
                RangeEvent::Fixed("1.4.2".into()),
            ],
            vec![
                RangeEvent::Introduced("2.0.0".into()),
                RangeEvent::Fixed("2.1.3".into()),
            ],
        ];
        assert!(affected_by_any("cargo", &sets, "1.4.1"));
        assert!(affected_by_any("cargo", &sets, "2.1.0"));
        assert!(
            !affected_by_any("cargo", &sets, "1.5.0"),
            "between the branches"
        );
        assert!(!affected_by_any("cargo", &sets, "2.1.3"));
    }

    /// The warehouse `ranges` column: v3 rows are an array of range objects, older
    /// rows one flat event array — both must decode, garbage must not.
    #[test]
    fn decode_range_sets_accepts_both_schema_shapes() {
        let v3 = r#"[[{"Introduced":"1.0.0"},{"Fixed":"1.2.0"}],[{"Introduced":"2.0.0"}]]"#;
        assert_eq!(decode_range_sets(v3).len(), 2);
        let legacy = r#"[{"Introduced":"1.0.0"},{"Fixed":"1.2.0"}]"#;
        let got = decode_range_sets(legacy);
        assert_eq!(got.len(), 1, "a legacy flat list is one range object");
        assert_eq!(got[0].len(), 2);
        assert!(decode_range_sets("[]").is_empty());
        assert!(decode_range_sets("").is_empty());
        assert!(decode_range_sets("not json").is_empty());
    }
}