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());
}
}