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

mikronova/modgunn

src/osv.rs

//! OSV import — parse the **Open Source Vulnerability** format (the SCA
//! lingua-franca: OSV.dev / GHSA / RUSTSEC all export it) into [`Advisory`]s.
//!
//! OSV is how the world ships advisories; modgunn imports an offline OSV/GHSA
//! snapshot (a sealed-znippy in airgap) into its `cve_advisories` table so Engine
//! A scans against the warehouse, not a network call.
//!
//! **Scope of this importer.** One [`Advisory`] per `(vuln, affected-package)`.
//! Exact `affected[].versions` are imported verbatim into
//! [`Advisory::affected_versions`]; **range** specs (`affected[].ranges`) need an
//! ecosystem-specific semver-range expander (cargo/PEP440/npm/Maven grammars) and
//! are left to that front-end — an Advisory with empty `affected_versions` means
//! *all versions* (see [`Advisory::matches`]), so a range-only OSV entry imports
//! as all-versions unless the expander narrows it. EPSS/KEV are **not** in OSV
//! (separate FIRST/CISA feeds) — they stay `None`/`false` here, enriched later.

use std::cmp::Ordering;

use serde::Deserialize;

use crate::core::Severity;
use crate::scan::Advisory;
use crate::versions::{compare, expand_range, RangeEvent};

/// A single OSV record (the subset modgunn reads). `serde(default)` keeps it
/// tolerant of the many optional OSV fields.
#[derive(Debug, Deserialize)]
struct OsvRecord {
    id: String,
    /// Other ids this advisory is known by (CVE ↔ GHSA ↔ RUSTSEC) — the dedup key.
    #[serde(default)]
    aliases: Vec<String>,
    /// Present (an RFC3339 timestamp) ⇒ the advisory was retracted.
    #[serde(default)]
    withdrawn: Option<String>,
    #[serde(default)]
    summary: String,
    #[serde(default)]
    details: String,
    #[serde(default)]
    severity: Vec<OsvSeverity>,
    #[serde(default)]
    affected: Vec<OsvAffected>,
    #[serde(default)]
    database_specific: DatabaseSpecific,
}

#[derive(Debug, Default, Deserialize)]
struct DatabaseSpecific {
    /// GHSA/RUSTSEC put a coarse label here ("CRITICAL"/"HIGH"/"MODERATE"/"LOW").
    #[serde(default)]
    severity: Option<String>,
    /// GHSA (and other OSV producers) carry the advisory's CWE ids here as
    /// `["CWE-79", …]`. Read so an OSV/GHSA import captures the weakness class —
    /// without this, modgunn only ever got a CWE from NVD (by alias), so a GHSA-only
    /// advisory reached the warehouse with an empty `cwe` and could not be mapped to
    /// an ATT&CK technique ([`crate::capec`]).
    #[serde(default)]
    cwe_ids: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct OsvSeverity {
    #[serde(rename = "type")]
    kind: String,
    score: String,
}

#[derive(Debug, Deserialize)]
struct OsvAffected {
    package: OsvPackage,
    #[serde(default)]
    versions: Vec<String>,
    #[serde(default)]
    ranges: Vec<OsvRange>,
    /// Per-ecosystem extension. The Go vuln DB (vuln.go.dev) is the one OSV source
    /// that ships **symbol-level** granularity here (`imports[].symbols`) — the
    /// exact functions the CVE lives in — which is what the A8 reachability gate
    /// ([`crate::scip::reachability_gated_vulns`]) needs to kill 80–90% of the
    /// present-but-unreachable false positives.
    #[serde(default)]
    ecosystem_specific: EcosystemSpecific,
}

/// OSV `affected[].ecosystem_specific`. Only the Go DB's `imports` are modelled —
/// other ecosystems put unrelated keys here (ignored via `serde(default)`).
#[derive(Debug, Default, Deserialize)]
struct EcosystemSpecific {
    #[serde(default)]
    imports: Vec<GoImport>,
}

/// One Go `imports` entry: an import `path` and the exported `symbols` in it the
/// advisory covers. An entry with `path` but **no** `symbols` means the WHOLE
/// package is vulnerable (no function-level narrowing available).
#[derive(Debug, Default, Deserialize)]
struct GoImport {
    #[serde(default)]
    path: String,
    #[serde(default)]
    symbols: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct OsvPackage {
    ecosystem: String,
    name: String,
}

#[derive(Debug, Deserialize)]
struct OsvRange {
    /// OSV `ranges[].type`: `SEMVER` | `ECOSYSTEM` | `GIT`. A `GIT` range's events
    /// are COMMIT HASHES, not versions — they must never reach the version
    /// comparator (a hash parses as version `0`, sorts to the front, and its
    /// `fixed` event switches the whole advisory off for every real version).
    #[serde(default, rename = "type")]
    kind: String,
    #[serde(default)]
    events: Vec<OsvEvent>,
}

impl OsvRange {
    /// Is this range expressed in a version grammar we can compare?
    /// Everything but `GIT` is; an absent type is assumed version-based (OSV
    /// defaults it to `ECOSYSTEM` for a package-scoped `affected` entry).
    fn is_version_range(&self) -> bool {
        !self.kind.eq_ignore_ascii_case("GIT")
    }
}

/// One OSV range event — exactly one of these keys is set per object.
#[derive(Debug, Default, Deserialize)]
struct OsvEvent {
    #[serde(default)]
    introduced: Option<String>,
    #[serde(default)]
    fixed: Option<String>,
    #[serde(default)]
    last_affected: Option<String>,
}

impl OsvAffected {
    /// This entry's version ranges, **one event list per OSV range object** — the
    /// disjunction [`Advisory::ranges`] carries.
    ///
    /// The range objects are deliberately NOT flattened into one list: an entry
    /// routinely carries a `GIT` range beside the `SEMVER` one (dropped here), and
    /// a multi-branch advisory carries several `SEMVER` ranges. Splicing their
    /// events together makes one range's `fixed` cancel another's window, silencing
    /// the advisory for versions it really covers.
    fn range_sets(&self) -> Vec<Vec<RangeEvent>> {
        self.ranges
            .iter()
            .filter(|r| r.is_version_range())
            .map(|r| {
                r.events
                    .iter()
                    .filter_map(|e| {
                        if let Some(v) = &e.introduced {
                            Some(RangeEvent::Introduced(v.clone()))
                        } else if let Some(v) = &e.fixed {
                            Some(RangeEvent::Fixed(v.clone()))
                        } else {
                            e.last_affected.clone().map(RangeEvent::LastAffected)
                        }
                    })
                    .collect::<Vec<_>>()
            })
            .filter(|events| !events.is_empty())
            .collect()
    }

    /// Did this entry declare ranges we could not evaluate (GIT-only)? Such an
    /// entry must NOT fall through to the empty-ranges sentinel, which would mean
    /// "every version affected".
    fn only_unusable_ranges(&self) -> bool {
        !self.ranges.is_empty() && !self.ranges.iter().any(|r| r.is_version_range())
    }
}

/// A package's affected ranges from an OSV record, ready for
/// [`crate::versions::expand_range`]: `(ecosystem, package, events)`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AffectedRange {
    pub id: String,
    pub ecosystem: String,
    pub package: String,
    pub severity: Severity,
    pub summary: String,
    pub events: Vec<RangeEvent>,
}

/// A **symbol-level** vulnerability record extracted from an OSV
/// `affected[].ecosystem_specific.imports` block (the Go vuln DB is the source
/// that ships this). It names the exact function/method a CVE lives in, so the A8
/// reachability gate can suppress a finding whose vulnerable symbol is *present but
/// never called*. `symbol == ""` means the whole `package` is vulnerable (no
/// function-level narrowing) — reachability gating does not apply and the
/// present-on-disk base join covers it instead.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VulnSymbol {
    /// Advisory id (`GO-2024-1234`, or its CVE alias).
    pub id: String,
    /// Normalized ecosystem (`go` for the Go DB).
    pub ecosystem: String,
    /// Import path the symbol lives in (`github.com/foo/bar`).
    pub package: String,
    /// Exported symbol (`Parse`, or a method `Decoder.Decode`); `""` ⇒ whole package.
    pub symbol: String,
    pub severity: Severity,
}

/// Normalize an OSV ecosystem label onto modgunn's short ecosystem keys.
/// OSV uses `crates.io`/`PyPI`/`Go`/`Maven`/`RubyGems`/`NuGet`; modgunn uses the
/// lowercase package-manager name. Unknown ecosystems pass through lowercased.
fn normalize_ecosystem(eco: &str) -> String {
    // OSV may suffix a registry (`Maven:...`, `Packagist`); take the head.
    let head = eco.split(':').next().unwrap_or(eco).trim();
    match head.to_ascii_lowercase().as_str() {
        "crates.io" => "cargo",
        "pypi" => "pypi",
        "npm" => "npm",
        "go" => "go",
        "maven" => "maven",
        "rubygems" => "gem",
        "nuget" => "nuget",
        "packagist" => "composer",
        // Distro feeds (OSV.dev exports them) → the package FORMAT key so the right
        // version comparator applies: Debian/Ubuntu = dpkg, Alpine = apk, the
        // RPM family = rpmvercmp. (P4 version grammars in `versions.rs`.)
        "debian" | "ubuntu" => "deb",
        "alpine" => "apk",
        "red hat" | "redhat" | "rocky linux" | "almalinux" | "suse" | "opensuse"
        | "opensuse.org" => "rpm",
        other => return other.to_string(),
    }
    .to_string()
}

/// Map an OSV record's severity onto modgunn's [`Severity`], preferring the
/// coarse `database_specific.severity` label, then a CVSS base score bucket.
fn severity_of(rec: &OsvRecord) -> Severity {
    if let Some(label) = &rec.database_specific.severity {
        match label.to_ascii_uppercase().as_str() {
            "CRITICAL" => return Severity::Critical,
            "HIGH" => return Severity::High,
            "MODERATE" | "MEDIUM" => return Severity::Medium,
            "LOW" => return Severity::Low,
            _ => {}
        }
    }
    // CVSS vector → base-score bucket. Try EACH CVSS entry, not just the first: a
    // record may list several (e.g. `CVSS_V4` before `CVSS_V3`), and modgunn scores
    // v2/v3 but not yet v4. Stopping at the first entry would let an uncomputable v4
    // shadow a computable v3 in the same record → the whole advisory defaults to
    // Medium, UNDER-severing a real Critical. Use the first entry that computes.
    for s in rec.severity.iter().filter(|s| s.kind.starts_with("CVSS")) {
        if let Some(base) = cvss_base_score(&s.score) {
            return severity_from_base(base);
        }
    }
    // Unknown severity is treated as Medium (a Warn) — present-but-unrated, not
    // silently dropped.
    Severity::Medium
}

/// The CVSS **vector** string to store on the advisory: the first entry that yields
/// a COMPUTABLE base score (so it matches the severity [`severity_of`] derived),
/// else the first CVSS entry present (kept for display even when that version's
/// calculator isn't implemented). Empty when the record carries no CVSS vector.
fn cvss_vector_of(rec: &OsvRecord) -> String {
    let cvss = || rec.severity.iter().filter(|s| s.kind.starts_with("CVSS"));
    cvss()
        .find(|s| cvss_base_score(&s.score).is_some())
        .or_else(|| cvss().next())
        .map(|s| s.score.trim().to_string())
        .unwrap_or_default()
}

/// Compute a [`Severity`] from a CVSS **vector** (v2 or v3.x) or a bare numeric
/// score string, for consumers outside this module (e.g. the gitlab/gemnasium
/// importer). `None` if the vector is unscoreable (a v4 vector / malformed). The
/// single public entry to the offline CVSS scoring the OSV severity path uses.
pub fn severity_from_cvss_vector(vector: &str) -> Option<Severity> {
    cvss_base_score(vector).map(severity_from_base)
}

/// Bucket a CVSS base score (0.0–10.0) into a [`Severity`] using the standard
/// qualitative thresholds (Critical ≥9, High ≥7, Medium ≥4, Low >0, else Info). The
/// single source of the mapping, shared by the OSV, rustsec **and OSS-Index**
/// severity paths — `pub` so a feed that ships a numeric CVSS score (OSS-Index's
/// `cvssScore`) buckets it exactly as a vector-derived one does, instead of
/// inventing a second threshold table (L5).
pub fn severity_from_base(base: f64) -> Severity {
    match base {
        b if b >= 9.0 => Severity::Critical,
        b if b >= 7.0 => Severity::High,
        b if b >= 4.0 => Severity::Medium,
        b if b > 0.0 => Severity::Low,
        _ => Severity::Info,
    }
}

/// Extract a numeric CVSS base score. Accepts a bare number (`"7.5"`) OR computes it
/// from a **CVSS v3.0/3.1 vector** string (`"CVSS:3.1/AV:N/…"`) via the spec's
/// deterministic base-score formula ([`cvss_v3_base`]). A v2 or v4 vector (different
/// formulae — v4's is far more involved) still returns `None`, so those fall back to
/// the `database_specific` label / the Medium default, unchanged. Computing the v3
/// score offline stops a CVSS-vector-only advisory (very common in OSV) from being
/// silently under-rated to Medium.
fn cvss_base_score(score: &str) -> Option<f64> {
    let s = score.trim();
    if let Ok(n) = s.parse::<f64>() {
        return Some(n);
    }
    if s.starts_with("CVSS:3") {
        return cvss_v3_base(s);
    }
    // A CVSS **v2** vector carries no `CVSS:` prefix and is distinguished by its
    // `Au:` (Authentication) metric, which v3 replaced with PR/UI. Older CVEs still
    // ship v2-only, so scoring it keeps them off the Medium default too.
    if s.contains("AV:") && s.contains("Au:") {
        return cvss_v2_base(s);
    }
    None
}

/// Compute the CVSS **v2** base score from a vector string, per the FIRST v2 spec.
/// `None` if a required metric (AV/AC/Au/C/I/A) is missing/unrecognized.
fn cvss_v2_base(vector: &str) -> Option<f64> {
    let mut m = std::collections::HashMap::new();
    for part in vector.split('/') {
        if let Some((k, v)) = part.split_once(':') {
            m.insert(k, v);
        }
    }
    let av = match *m.get("AV")? {
        "L" => 0.395,
        "A" => 0.646,
        "N" => 1.0,
        _ => return None,
    };
    let ac = match *m.get("AC")? {
        "H" => 0.35,
        "M" => 0.61,
        "L" => 0.71,
        _ => return None,
    };
    let au = match *m.get("Au")? {
        "M" => 0.45,
        "S" => 0.56,
        "N" => 0.704,
        _ => return None,
    };
    let impact_metric = |v: &str| -> Option<f64> {
        match v {
            "N" => Some(0.0),
            "P" => Some(0.275),
            "C" => Some(0.660),
            _ => None,
        }
    };
    let c = impact_metric(m.get("C")?)?;
    let i = impact_metric(m.get("I")?)?;
    let a = impact_metric(m.get("A")?)?;

    let impact = 10.41 * (1.0 - (1.0 - c) * (1.0 - i) * (1.0 - a));
    let exploitability = 20.0 * av * ac * au;
    let f_impact = if impact == 0.0 { 0.0 } else { 1.176 };
    let base = ((0.6 * impact) + (0.4 * exploitability) - 1.5) * f_impact;
    // v2 rounds the base score to one decimal (round half up).
    Some((base * 10.0).round() / 10.0)
}

/// Compute the CVSS v3.0/3.1 **base score** from a vector string, per the FIRST
/// specification. Returns `None` if a required base metric (AV/AC/PR/UI/S/C/I/A) is
/// missing or unrecognized. v3.0 and v3.1 differ only in the roundup rule; the v3.1
/// roundup (used here) differs from v3.0 by at most 0.1 and effectively never crosses
/// a severity bucket, so one implementation serves both.
fn cvss_v3_base(vector: &str) -> Option<f64> {
    let mut m = std::collections::HashMap::new();
    for part in vector.split('/') {
        if let Some((k, v)) = part.split_once(':') {
            m.insert(k, v);
        }
    }
    let scope_changed = *m.get("S")? == "C";
    let av = match *m.get("AV")? {
        "N" => 0.85,
        "A" => 0.62,
        "L" => 0.55,
        "P" => 0.2,
        _ => return None,
    };
    let ac = match *m.get("AC")? {
        "L" => 0.77,
        "H" => 0.44,
        _ => return None,
    };
    // Privileges-Required weights depend on Scope (a changed scope makes L/H heavier).
    let pr = match (*m.get("PR")?, scope_changed) {
        ("N", _) => 0.85,
        ("L", false) => 0.62,
        ("L", true) => 0.68,
        ("H", false) => 0.27,
        ("H", true) => 0.50,
        _ => return None,
    };
    let ui = match *m.get("UI")? {
        "N" => 0.85,
        "R" => 0.62,
        _ => return None,
    };
    let impact_metric = |v: &str| -> Option<f64> {
        match v {
            "H" => Some(0.56),
            "L" => Some(0.22),
            "N" => Some(0.0),
            _ => None,
        }
    };
    let c = impact_metric(m.get("C")?)?;
    let i = impact_metric(m.get("I")?)?;
    let a = impact_metric(m.get("A")?)?;

    let iss = 1.0 - ((1.0 - c) * (1.0 - i) * (1.0 - a));
    let impact = if scope_changed {
        7.52 * (iss - 0.029) - 3.25 * (iss - 0.02).powi(15)
    } else {
        6.42 * iss
    };
    if impact <= 0.0 {
        return Some(0.0);
    }
    let exploitability = 8.22 * av * ac * pr * ui;
    let raw = if scope_changed {
        (1.08 * (impact + exploitability)).min(10.0)
    } else {
        (impact + exploitability).min(10.0)
    };
    Some(cvss_roundup(raw))
}

/// The CVSS v3.1 `Roundup` — round UP to one decimal place, but exactly on a
/// tenth-boundary keep it (avoids `4.0` becoming `4.1` from float noise). Defined
/// over integer hundred-thousandths to match the spec's reference pseudocode.
fn cvss_roundup(x: f64) -> f64 {
    let int_input = (x * 100_000.0).round() as i64;
    if int_input % 10_000 == 0 {
        int_input as f64 / 100_000.0
    } else {
        ((int_input / 10_000) + 1) as f64 / 10.0
    }
}

/// Parse a single OSV JSON document into its [`Advisory`]s (one per affected
/// package). A record with no `affected` packages yields none.
pub fn parse_osv(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
    let rec: OsvRecord = serde_json::from_str(json)?;
    Ok(advisories_from(&rec))
}

/// Parse an OSV *batch* — a JSON array of OSV records — into all their advisories.
pub fn parse_osv_batch(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
    let recs: Vec<OsvRecord> = serde_json::from_str(json)?;
    Ok(recs.iter().flat_map(advisories_from).collect())
}

/// Parse a single OSV doc, tolerant of both shapes: a batch array first, else a
/// single record. Unlike [`parse_and_merge`], this surfaces the parse error so a
/// caller (e.g. the `import` CLI) can name the offending file and count it as a
/// skipped source instead of silently dropping coverage.
pub fn parse_doc(doc: &str) -> Result<Vec<Advisory>, serde_json::Error> {
    match parse_osv_batch(doc) {
        Ok(v) => Ok(v),
        Err(_) => parse_osv(doc),
    }
}

/// Normalize + dedup a set of OSV documents into a warehouse-ready advisory
/// corpus — the **offline normalize step** of the ingest pipeline (P1). Each
/// `doc` may be a single OSV record or a JSON array of them; records are parsed
/// (range-native — ranges carried, not pre-expanded), then folded ACROSS feeds by
/// alias (the same CVE from OSV + GHSA + NVD collapses) via
/// [`crate::scan::merge_by_alias`]. The result is what
/// [`crate::warehouse::Warehouse::append_advisories`] writes under a `db_rev`.
/// Malformed docs are skipped (a bad file never sinks a whole import).
pub fn parse_and_merge<I, S>(docs: I) -> Vec<Advisory>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    let mut all = Vec::new();
    for (i, doc) in docs.into_iter().enumerate() {
        let d = doc.as_ref();
        match parse_doc(d) {
            Ok(parsed) => all.extend(parsed),
            // Never silently reduce coverage: surface the skipped doc on stderr.
            // (Callers with a filename should prefer [`parse_doc`] to name it.)
            Err(e) => eprintln!(
                "warning: skipping unparseable OSV doc #{i} ({} bytes): {e}",
                d.len()
            ),
        }
    }
    crate::scan::merge_by_alias(all)
}

/// Parse the **range** specs out of an OSV document (single or batch). These are
/// the entries whose affected versions are expressed as `introduced`/`fixed`
/// windows rather than an exact `versions` list — feed them through
/// [`expand_advisories`] (or [`crate::versions::expand_range`]) with the package's
/// known versions to get exact [`Advisory`]s.
pub fn parse_osv_ranges(json: &str) -> Result<Vec<AffectedRange>, serde_json::Error> {
    // Accept either a single record or a batch array.
    let recs: Vec<OsvRecord> = match serde_json::from_str::<Vec<OsvRecord>>(json) {
        Ok(v) => v,
        Err(_) => vec![serde_json::from_str(json)?],
    };
    Ok(recs.iter().flat_map(ranges_from).collect())
}

fn ranges_from(rec: &OsvRecord) -> Vec<AffectedRange> {
    let severity = severity_of(rec);
    let summary = if !rec.summary.is_empty() {
        rec.summary.clone()
    } else {
        rec.details.clone()
    };
    // One AffectedRange per RANGE OBJECT, not per affected entry: each range is
    // expanded on its own and the results union through `affected_versions`.
    rec.affected
        .iter()
        .flat_map(|aff| {
            let (eco, pkg) = (
                normalize_ecosystem(&aff.package.ecosystem),
                aff.package.name.clone(),
            );
            let (id, sum) = (rec.id.clone(), summary.clone());
            aff.range_sets()
                .into_iter()
                .map(move |events| AffectedRange {
                    id: id.clone(),
                    ecosystem: eco.clone(),
                    package: pkg.clone(),
                    severity,
                    summary: sum.clone(),
                    events,
                })
        })
        .collect()
}

/// Parse the **symbol-level** vulnerability records out of an OSV document (single
/// or batch) — the `affected[].ecosystem_specific.imports` blocks the Go vuln DB
/// ships. Each `(import path, symbol)` pair becomes one [`VulnSymbol`]; an import
/// with no `symbols` yields one package-level record (`symbol == ""`). A document
/// with no such blocks (every non-Go OSV source) yields an empty vec — never an
/// error, so it composes with the normal advisory import. These seed the A8
/// reachability gate's [`crate::scip::VulnSink`]s.
pub fn parse_osv_vuln_symbols(json: &str) -> Result<Vec<VulnSymbol>, serde_json::Error> {
    let recs: Vec<OsvRecord> = match serde_json::from_str::<Vec<OsvRecord>>(json) {
        Ok(v) => v,
        Err(_) => vec![serde_json::from_str(json)?],
    };
    Ok(recs.iter().flat_map(vuln_symbols_from).collect())
}

fn vuln_symbols_from(rec: &OsvRecord) -> Vec<VulnSymbol> {
    let severity = severity_of(rec);
    let mut out = Vec::new();
    for aff in &rec.affected {
        let eco = normalize_ecosystem(&aff.package.ecosystem);
        for imp in &aff.ecosystem_specific.imports {
            if imp.path.is_empty() {
                continue; // a symbols-only import with no path can't be joined — skip.
            }
            if imp.symbols.is_empty() {
                // Whole-package vulnerability (no function-level narrowing).
                out.push(VulnSymbol {
                    id: rec.id.clone(),
                    ecosystem: eco.clone(),
                    package: imp.path.clone(),
                    symbol: String::new(),
                    severity,
                });
            } else {
                for sym in &imp.symbols {
                    if sym.is_empty() {
                        continue;
                    }
                    out.push(VulnSymbol {
                        id: rec.id.clone(),
                        ecosystem: eco.clone(),
                        package: imp.path.clone(),
                        symbol: sym.clone(),
                        severity,
                    });
                }
            }
        }
    }
    out
}

/// Expand OSV `ranges` into exact-version [`Advisory`]s, given a `known_versions`
/// lookup that returns the versions in existence for an `(ecosystem, package)`.
/// This is the bridge from OSV's range grammar to Engine A's exact matching — a
/// range with no matching known version yields no advisory (nothing affected).
pub fn expand_advisories(
    ranges: &[AffectedRange],
    mut known_versions: impl FnMut(&str, &str) -> Vec<String>,
) -> Vec<Advisory> {
    ranges
        .iter()
        .filter_map(|r| {
            let candidates = known_versions(&r.ecosystem, &r.package);
            let affected = expand_range(&r.ecosystem, &r.events, &candidates);
            if affected.is_empty() {
                return None;
            }
            Some(Advisory {
                id: r.id.clone(),
                ecosystem: r.ecosystem.clone(),
                package: r.package.clone(),
                affected_versions: affected,
                severity: r.severity,
                summary: r.summary.clone(),
                epss: None,
                kev: false,
                // P1 wires these to carry the OSV ranges range-native; for now the
                // pre-expanded affected_versions above is the match surface.
                ranges: Vec::new(),
                aliases: Vec::new(),
                withdrawn: false,
                source: "osv".into(),
                cvss_vector: String::new(),
                cwe: Vec::new(),
            })
        })
        .collect()
}

fn advisories_from(rec: &OsvRecord) -> Vec<Advisory> {
    let severity = severity_of(rec);
    let summary = if !rec.summary.is_empty() {
        rec.summary.clone()
    } else {
        rec.details.clone()
    };
    let withdrawn = rec.withdrawn.is_some();
    // CWE ids the OSV/GHSA record carries in `database_specific.cwe_ids` (`CWE-79`).
    // Shared by every advisory the record yields. Trimmed; empties dropped.
    let cwe: Vec<String> = rec
        .database_specific
        .cwe_ids
        .iter()
        .map(|c| c.trim().to_string())
        .filter(|c| !c.is_empty())
        .collect();
    // The CVSS **vector** string OSV carries in `severity[].score`. NVD stores its
    // vector; OSV's was discarded, losing the precise metrics a consumer
    // re-scores/displays. `cvss_vector_of` picks the entry consistent with the
    // computed severity (first computable, else first present).
    let cvss_vector = cvss_vector_of(rec);
    rec.affected
        .iter()
        // An entry whose ONLY ranges are GIT commit windows carries no evaluable
        // version information; emitting it would hit the empty-ranges sentinel and
        // flag EVERY version of the package. Drop it instead of over-flagging.
        .filter(|aff| !(aff.versions.is_empty() && aff.only_unusable_ranges()))
        .map(|aff| Advisory {
            id: rec.id.clone(),
            ecosystem: normalize_ecosystem(&aff.package.ecosystem),
            package: aff.package.name.clone(),
            // Carry BOTH the explicit versions and the ranges: matches() ORs the
            // exact list with affected_by(ranges) — range-native, no pre-expansion
            // against a registry index (that lossy path is gone).
            affected_versions: aff.versions.clone(),
            severity,
            summary: summary.clone(),
            epss: None,
            kev: false,
            ranges: aff.range_sets(),
            aliases: rec.aliases.clone(),
            withdrawn,
            source: "osv".into(),
            cvss_vector: cvss_vector.clone(),
            cwe: cwe.clone(),
        })
        .collect()
}

// ── RUSTSEC advisory-db markdown import ──────────────────────────────────────
//
// The rustsec advisory-db ships one Markdown file per advisory with a ```toml
// front-matter block (`[advisory]` + `[versions]`). It is cargo-only and, unlike
// OSV, expresses affected versions as the **complement** of `patched`/`unaffected`
// semver requirements (cargo-audit's rule: a version is affected iff no `patched`
// requirement matches it AND it is not `unaffected`). modgunn matches range-native
// via [`RangeEvent`], so the import inverts those *safe*-version requirements into
// affected `Introduced`/`Fixed` windows — see [`rustsec_affected_ranges`], the one
// non-mechanical piece (and the one the oracle tests pin against cargo-audit's rule).

/// A version bound: `ver` with `incl` = whether the bound itself is included.
#[derive(Clone)]
struct VBound {
    ver: String,
    incl: bool,
}

/// A *safe* (not-affected) version interval `[lo, hi)`-ish. `lo == None` means
/// "from 0" (the bottom); `hi == None` means "to +∞". Inclusivity per [`VBound`].
struct SafeIv {
    lo: Option<VBound>,
    hi: Option<VBound>,
}

impl SafeIv {
    /// An interval that contains no version (e.g. `>= 2, < 1`) — dropped before
    /// inversion so it can't punch a phantom hole in the affected set.
    fn is_empty(&self) -> bool {
        match (&self.lo, &self.hi) {
            (Some(lo), Some(hi)) => match compare("cargo", &lo.ver, &hi.ver) {
                Ordering::Greater => true,
                Ordering::Equal => !(lo.incl && hi.incl),
                Ordering::Less => false,
            },
            _ => false,
        }
    }
}

/// Invert rustsec `patched` + `unaffected` semver requirements into the affected
/// [`RangeEvent`] windows modgunn matches against. Empty result ⇒ *every* version
/// is affected (no patched release and nothing unaffected) — an [`Advisory`] with
/// empty ranges + empty `affected_versions` matches all versions (see
/// [`Advisory::matches`]).
///
/// Equivalence target (the oracle): for any `v`,
/// `versions::affected_by("cargo", &out, v)` == `!patched.matches(v) &&
/// !unaffected.matches(v)` (cargo-audit's rule). Exact for the `>=`/`<`/`=` shapes
/// rustsec uses; the rare exclusive-lower / inclusive-upper safe bound is
/// approximated in the *over*-flag (never under-flag) direction.
pub fn rustsec_affected_ranges(patched: &[String], unaffected: &[String]) -> Vec<RangeEvent> {
    let mut safe: Vec<SafeIv> = patched
        .iter()
        .chain(unaffected.iter())
        .filter_map(|r| parse_safe_req(r))
        .filter(|iv| !iv.is_empty())
        .collect();
    if safe.is_empty() {
        return Vec::new(); // nothing safe ⇒ all versions affected
    }
    safe.sort_by(|a, b| cmp_lo(&a.lo, &b.lo));

    // Merge overlapping / touching safe intervals so the gaps between them are the
    // affected windows.
    let mut merged: Vec<SafeIv> = Vec::new();
    for iv in safe {
        match merged.last_mut() {
            Some(last) if connects(&last.hi, &iv.lo) => {
                last.hi = max_hi(last.hi.take(), iv.hi);
            }
            _ => merged.push(iv),
        }
    }

    let mut events = Vec::new();
    // `cursor` = lower bound of the next affected region; `None` = "from 0".
    let mut cursor: Option<VBound> = None;
    let mut live = true; // false once safe runs to +∞ (nothing affected above)
    for s in &merged {
        if !live {
            break;
        }
        if let Some(lo) = &s.lo {
            // Affected gap [cursor, lo): on below where this safe interval starts.
            push_introduced(&mut events, &cursor);
            if lo.incl {
                events.push(RangeEvent::Fixed(lo.ver.clone())); // affected < lo
            } else {
                events.push(RangeEvent::LastAffected(lo.ver.clone())); // affected <= lo
            }
        }
        match &s.hi {
            None => live = false, // safe to +∞
            // Affected resumes where safe ends (inclusive iff safe was exclusive there).
            Some(hi) => {
                cursor = Some(VBound {
                    ver: hi.ver.clone(),
                    incl: !hi.incl,
                })
            }
        }
    }
    if live {
        // Tail: affected from `cursor` to +∞ (an Introduced with no Fixed).
        push_introduced(&mut events, &cursor);
    }
    events
}

fn push_introduced(events: &mut Vec<RangeEvent>, cursor: &Option<VBound>) {
    match cursor {
        None => events.push(RangeEvent::Introduced("0".into())),
        Some(b) => events.push(RangeEvent::Introduced(b.ver.clone())),
    }
}

/// Parse one rustsec requirement string (e.g. `">= 1.2.4, < 1.3.0"`) into the
/// safe interval it describes (the AND of its comma-separated comparators).
fn parse_safe_req(req: &str) -> Option<SafeIv> {
    let mut lo: Option<VBound> = None;
    let mut hi: Option<VBound> = None;
    let mut any = false;
    for part in req.split(',') {
        let part = part.trim();
        if part.is_empty() {
            continue;
        }
        let (op, ver) = split_op(part);
        if ver.is_empty() {
            continue;
        }
        any = true;
        match op {
            ">=" => tighten_lo(&mut lo, ver, true),
            ">" => tighten_lo(&mut lo, ver, false),
            "<=" => tighten_hi(&mut hi, ver, true),
            "<" => tighten_hi(&mut hi, ver, false),
            "^" => {
                tighten_lo(&mut lo, ver, true);
                tighten_hi(&mut hi, &caret_upper(ver), false);
            }
            "~" => {
                tighten_lo(&mut lo, ver, true);
                tighten_hi(&mut hi, &tilde_upper(ver), false);
            }
            // "=" or a bare version ⇒ the exact version is safe.
            _ => {
                tighten_lo(&mut lo, ver, true);
                tighten_hi(&mut hi, ver, true);
            }
        }
    }
    any.then_some(SafeIv { lo, hi })
}

/// Split a comparator into `(op, version)`. Longer ops first so `>=` beats `>`.
fn split_op(part: &str) -> (&str, &str) {
    for op in [">=", "<=", "^", "~", ">", "<", "="] {
        if let Some(rest) = part.strip_prefix(op) {
            return (op, rest.trim());
        }
    }
    ("=", part.trim())
}

/// Keep the greater (more restrictive) lower bound; exclusive wins ties.
fn tighten_lo(cur: &mut Option<VBound>, ver: &str, incl: bool) {
    let cand = VBound {
        ver: ver.to_string(),
        incl,
    };
    match cur {
        None => *cur = Some(cand),
        Some(c) => match compare("cargo", &cand.ver, &c.ver) {
            Ordering::Greater => *cur = Some(cand),
            Ordering::Equal if c.incl && !incl => *cur = Some(cand),
            _ => {}
        },
    }
}

/// Keep the lesser (more restrictive) upper bound; exclusive wins ties.
fn tighten_hi(cur: &mut Option<VBound>, ver: &str, incl: bool) {
    let cand = VBound {
        ver: ver.to_string(),
        incl,
    };
    match cur {
        None => *cur = Some(cand),
        Some(c) => match compare("cargo", &cand.ver, &c.ver) {
            Ordering::Less => *cur = Some(cand),
            Ordering::Equal if c.incl && !incl => *cur = Some(cand),
            _ => {}
        },
    }
}

/// Order safe-interval lower bounds; `None` (bottom) sorts first, inclusive before
/// exclusive at the same version (the inclusive one starts marginally earlier).
fn cmp_lo(a: &Option<VBound>, b: &Option<VBound>) -> Ordering {
    match (a, b) {
        (None, None) => Ordering::Equal,
        (None, _) => Ordering::Less,
        (_, None) => Ordering::Greater,
        (Some(x), Some(y)) => match compare("cargo", &x.ver, &y.ver) {
            Ordering::Equal => match (x.incl, y.incl) {
                (true, false) => Ordering::Less,
                (false, true) => Ordering::Greater,
                _ => Ordering::Equal,
            },
            o => o,
        },
    }
}

/// Do two adjacent safe intervals (upper `hi`, then lower `lo`) leave no affected
/// gap between them?
fn connects(hi: &Option<VBound>, lo: &Option<VBound>) -> bool {
    let hi = match hi {
        None => return true, // +∞ covers everything above
        Some(h) => h,
    };
    let lo = match lo {
        None => return true, // next starts at 0 ⇒ overlaps
        Some(l) => l,
    };
    match compare("cargo", &lo.ver, &hi.ver) {
        Ordering::Less => true,
        Ordering::Greater => false,
        Ordering::Equal => hi.incl || lo.incl,
    }
}

/// The upper (covers-more) of two safe-interval upper bounds; `None` = +∞ wins.
fn max_hi(a: Option<VBound>, b: Option<VBound>) -> Option<VBound> {
    match (a, b) {
        (None, _) | (_, None) => None,
        (Some(x), Some(y)) => match compare("cargo", &x.ver, &y.ver) {
            Ordering::Greater => Some(x),
            Ordering::Less => Some(y),
            Ordering::Equal => Some(VBound {
                ver: x.ver,
                incl: x.incl || y.incl,
            }),
        },
    }
}

/// `(major, minor, patch)` of a version's release core (pre-release / build dropped).
fn parse3(v: &str) -> (u64, u64, u64) {
    let core = v.split(['-', '+']).next().unwrap_or(v);
    let mut it = core.split('.').map(|x| x.parse::<u64>().unwrap_or(0));
    (
        it.next().unwrap_or(0),
        it.next().unwrap_or(0),
        it.next().unwrap_or(0),
    )
}

/// Caret-range upper bound (`^1.2.3` ⇒ `2.0.0`, `^0.2.3` ⇒ `0.3.0`, `^0.0.3` ⇒
/// `0.0.4`). Fully-specified forms are exact; partial forms are approximated
/// (rustsec uses explicit `>=`/`<`, so this path is rarely hit).
fn caret_upper(v: &str) -> String {
    let (maj, min, pat) = parse3(v);
    if maj > 0 {
        format!("{}.0.0", maj + 1)
    } else if min > 0 {
        format!("0.{}.0", min + 1)
    } else {
        format!("0.0.{}", pat + 1)
    }
}

/// Tilde-range upper bound (`~1.2.3` ⇒ `1.3.0`).
fn tilde_upper(v: &str) -> String {
    let (maj, min, _) = parse3(v);
    format!("{}.{}.0", maj, min + 1)
}

/// Extract the ```toml fenced front-matter from a rustsec advisory `.md`.
#[cfg(feature = "rustsec")]
fn rustsec_front_matter(text: &str) -> Option<&str> {
    let after = &text[text.find("```toml")? + "```toml".len()..];
    let end = after.find("```")?;
    Some(after[..end].trim_matches('\n'))
}

#[cfg(feature = "rustsec")]
fn toml_string_list(v: Option<&toml::Value>) -> Vec<String> {
    v.and_then(|v| v.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|x| x.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default()
}

/// Severity for a non-informational rustsec advisory. `cvss` is usually a v3 vector;
/// [`cvss_base_score`] now computes its base score offline, so it is bucketed via the
/// SAME [`severity_from_base`] the OSV path uses. Only a vector we cannot score (v2/v4
/// or malformed) falls back to `High` (block-worthy — a real rustsec advisory with an
/// unrated vector). Informational advisories are mapped to `Low` by the caller.
#[cfg(feature = "rustsec")]
fn rustsec_severity(cvss: &str) -> Severity {
    if let Some(base) = cvss_base_score(cvss) {
        return severity_from_base(base);
    }
    Severity::High
}

/// Parse one rustsec advisory `.md` (toml front-matter) into an [`Advisory`].
/// Returns `None` if the file carries no `[advisory]` block or no package name.
/// Affected versions are carried **range-native** (the `patched`/`unaffected`
/// inversion of [`rustsec_affected_ranges`]); an `informational` advisory
/// (unmaintained / unsound / notice) is mapped to `Severity::Low` (⇒ Warn), never
/// the real-vuln severity (preserving the Warn-not-Block rule).
#[cfg(feature = "rustsec")]
pub fn parse_rustsec_md(md: &str) -> Option<Advisory> {
    let front = rustsec_front_matter(md)?;
    let doc: toml::Value = toml::from_str(front).ok()?;
    let adv = doc.get("advisory")?;
    let id = adv.get("id").and_then(|v| v.as_str())?.to_string();
    let package = adv.get("package").and_then(|v| v.as_str())?.to_string();
    let summary = adv
        .get("title")
        .and_then(|v| v.as_str())
        .unwrap_or_default()
        .to_string();
    let aliases = toml_string_list(adv.get("aliases"));
    let informational = adv.get("informational").and_then(|v| v.as_str());
    let cvss = adv
        .get("cvss")
        .and_then(|v| v.as_str())
        .unwrap_or_default()
        .to_string();

    let versions = doc.get("versions");
    let patched = toml_string_list(versions.and_then(|v| v.get("patched")));
    let unaffected = toml_string_list(versions.and_then(|v| v.get("unaffected")));

    let severity = if informational.is_some() {
        Severity::Low
    } else {
        rustsec_severity(&cvss)
    };

    Some(Advisory {
        id,
        ecosystem: "cargo".into(),
        package,
        affected_versions: Vec::new(),
        severity,
        summary,
        epss: None,
        kev: false,
        // The inversion already merges its intervals into ONE well-formed event
        // walk, so it is a single range object (empty ⇒ all versions affected).
        ranges: match rustsec_affected_ranges(&patched, &unaffected) {
            r if r.is_empty() => Vec::new(),
            r => vec![r],
        },
        aliases,
        withdrawn: false,
        source: "rustsec".into(),
        cvss_vector: cvss,
        cwe: Vec::new(),
    })
}

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

    #[test]
    fn parses_ghsa_style_osv_with_label_severity() {
        let json = r#"{
            "id": "GHSA-xxxx-yyyy-zzzz",
            "summary": "Prototype pollution in left-pad",
            "severity": [],
            "affected": [
                { "package": { "ecosystem": "npm", "name": "left-pad" },
                  "versions": ["1.0.0", "1.1.0"] }
            ],
            "database_specific": { "severity": "HIGH" }
        }"#;
        let advs = parse_osv(json).unwrap();
        assert_eq!(advs.len(), 1);
        let a = &advs[0];
        assert_eq!(a.id, "GHSA-xxxx-yyyy-zzzz");
        assert_eq!(a.ecosystem, "npm");
        assert_eq!(a.package, "left-pad");
        assert_eq!(a.severity, Severity::High);
        assert_eq!(a.affected_versions, vec!["1.0.0", "1.1.0"]);
        assert!(a.summary.contains("Prototype pollution"));
        // The imported advisory matches the affected coords.
        assert!(a.matches(&ArtifactRef {
            ecosystem: "npm".into(),
            name: "left-pad".into(),
            version: "1.0.0".into(),
            blob_sha256: "x".into(),
        }));
    }

    /// Go vuln-DB `ecosystem_specific.imports` → symbol-level [`VulnSymbol`]s: each
    /// `(path, symbol)` pair, a method (`Type.Method`) kept intact, a symbols-less
    /// import folded to a whole-package record, and a non-Go record contributing
    /// nothing. RED if the ecosystem_specific parse or the whole-package fallback
    /// regresses (the A8 gate would then have no real sinks to gate on).
    /// RED-when-reverted: an OSV/GHSA record carries its CWE ids in
    /// `database_specific.cwe_ids`; they must reach the [`Advisory`]. Before this was
    /// read, a GHSA-only advisory landed with an empty `cwe` — losing the weakness
    /// class NVD would only supply if the CVE happened to alias-merge in, and
    /// breaking the CWE→ATT&CK ([`crate::capec`]) enrichment.
    #[test]
    fn ghsa_database_specific_cwe_ids_are_captured() {
        let json = r#"{
            "id": "GHSA-xxxx-yyyy-zzzz",
            "summary": "XSS in foo",
            "database_specific": { "severity": "HIGH", "cwe_ids": ["CWE-79", " CWE-80 ", ""] },
            "affected": [ { "package": { "ecosystem": "npm", "name": "foo" }, "versions": ["1.0.0"] } ]
        }"#;
        let advs = parse_osv(json).unwrap();
        assert_eq!(advs.len(), 1);
        assert_eq!(
            advs[0].cwe,
            vec!["CWE-79", "CWE-80"],
            "cwe_ids captured + trimmed, empties dropped"
        );
    }

    /// RED-when-reverted: the CVSS **vector** string OSV carries in `severity[].score`
    /// must be stored on the advisory (NVD's path does; OSV's dropped it), so a
    /// consumer keeps the precise metrics, not just the bucketed severity.
    #[test]
    fn osv_cvss_vector_is_captured() {
        let vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H";
        let json = format!(
            r#"{{ "id": "CVE-2024-1", "summary": "x",
                  "severity": [ {{ "type": "CVSS_V3", "score": "{vector}" }} ],
                  "affected": [ {{ "package": {{ "ecosystem": "npm", "name": "foo" }}, "versions": ["1.0.0"] }} ] }}"#
        );
        let advs = parse_osv(&json).unwrap();
        assert_eq!(
            advs[0].cvss_vector, vector,
            "OSV CVSS vector reaches the advisory"
        );
        assert_eq!(
            advs[0].severity,
            Severity::Critical,
            "and it still drives the severity bucket (9.8 → Critical)"
        );
    }

    /// RED-when-reverted: a record listing an uncomputable `CVSS_V4` entry BEFORE a
    /// computable `CVSS_V3` must still derive its severity from the v3 vector — not
    /// stop at the v4 and default to Medium (an under-severity). The stored vector is
    /// the computable (v3) one, consistent with the derived severity.
    #[test]
    fn cvss_v4_first_does_not_shadow_computable_v3() {
        let v4 = "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N";
        let v3 = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"; // 9.8
                                                                 // Pre-req: modgunn does not yet score v4 (else this test proves nothing).
        assert!(
            cvss_base_score(v4).is_none(),
            "v4 is uncomputable today — the premise of the shadowing bug"
        );
        let json = format!(
            r#"{{ "id": "CVE-2024-2",
                  "severity": [ {{ "type": "CVSS_V4", "score": "{v4}" }}, {{ "type": "CVSS_V3", "score": "{v3}" }} ],
                  "affected": [ {{ "package": {{ "ecosystem": "npm", "name": "foo" }}, "versions": ["1.0.0"] }} ] }}"#
        );
        let advs = parse_osv(&json).unwrap();
        assert_eq!(
            advs[0].severity,
            Severity::Critical,
            "v3 9.8 wins over the uncomputable v4-first"
        );
        assert_eq!(
            advs[0].cvss_vector, v3,
            "the stored vector is the computable v3, matching the severity"
        );
    }

    #[test]
    fn go_vulndb_symbols_extracted_per_import() {
        let json = r#"[
          { "id": "GO-2024-0001",
            "summary": "auth bypass in parser",
            "database_specific": { "severity": "HIGH" },
            "affected": [
              { "package": { "ecosystem": "Go", "name": "github.com/foo/bar" },
                "ranges": [ { "type": "SEMVER", "events": [ {"introduced": "0"}, {"fixed": "1.2.0"} ] } ],
                "ecosystem_specific": {
                  "imports": [
                    { "path": "github.com/foo/bar", "symbols": ["Parse", "Decoder.Decode"] },
                    { "path": "github.com/foo/bar/internal" }
                  ]
                } }
            ] },
          { "id": "GHSA-cargo", "affected": [ { "package": { "ecosystem": "crates.io", "name": "widget" } } ] }
        ]"#;
        let mut syms = parse_osv_vuln_symbols(json).unwrap();
        syms.sort_by(|a, b| {
            (a.package.as_str(), a.symbol.as_str()).cmp(&(b.package.as_str(), b.symbol.as_str()))
        });
        assert_eq!(
            syms.len(),
            3,
            "2 named symbols + 1 whole-package; the cargo record has no imports"
        );
        assert_eq!(
            syms[0],
            VulnSymbol {
                id: "GO-2024-0001".into(),
                ecosystem: "go".into(),
                package: "github.com/foo/bar".into(),
                symbol: "Decoder.Decode".into(),
                severity: Severity::High,
            }
        );
        assert_eq!(syms[1].symbol, "Parse", "exported function symbol kept");
        assert_eq!(syms[2].package, "github.com/foo/bar/internal");
        assert_eq!(
            syms[2].symbol, "",
            "an import with no `symbols` ⇒ whole-package record"
        );
        assert!(
            syms.iter().all(|s| s.id == "GO-2024-0001"),
            "the cargo record contributes no symbols"
        );
    }

    #[test]
    fn normalizes_osv_ecosystem_labels() {
        assert_eq!(normalize_ecosystem("crates.io"), "cargo");
        assert_eq!(normalize_ecosystem("PyPI"), "pypi");
        assert_eq!(normalize_ecosystem("Maven:org.example"), "maven");
        assert_eq!(normalize_ecosystem("RubyGems"), "gem");
    }

    #[test]
    fn cvss_score_bucketed_when_no_label() {
        let json = r#"{
            "id": "CVE-2024-9",
            "details": "buffer overflow",
            "severity": [ { "type": "CVSS_V3", "score": "9.8" } ],
            "affected": [ { "package": { "ecosystem": "crates.io", "name": "openssl" },
                            "versions": ["0.1.0"] } ]
        }"#;
        let advs = parse_osv(json).unwrap();
        assert_eq!(advs[0].ecosystem, "cargo");
        assert_eq!(advs[0].severity, Severity::Critical, "9.8 ⇒ Critical");
        assert_eq!(advs[0].summary, "buffer overflow", "falls back to details");
    }

    /// The offline CVSS v3.1 base-score calculator, validated against FIRST's
    /// published reference scores. Each `assert` is a real vector whose score is
    /// documented — a wrong formula moves one of these off its known value.
    #[test]
    fn cvss_v3_base_matches_published_scores() {
        let s = |v: &str| cvss_v3_base(v).unwrap();
        // Log4Shell — scope-changed, all-High → the canonical 10.0.
        assert!((s("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H") - 10.0).abs() < 1e-9);
        // Scope-unchanged all-High → 9.8.
        assert!((s("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") - 9.8).abs() < 1e-9);
        // Availability-only network RCE → 7.5.
        assert!((s("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H") - 7.5).abs() < 1e-9);
        // High privileges, all-High, unchanged → 7.2.
        assert!((s("CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H") - 7.2).abs() < 1e-9);
        // Local, low-priv, confidentiality-only → 5.5.
        assert!((s("CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N") - 5.5).abs() < 1e-9);
        // A genuinely low one → 3.1.
        assert!((s("CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N") - 3.1).abs() < 1e-9);
        // No impact → 0.0.
        assert!((s("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N") - 0.0).abs() < 1e-9);
        // A missing metric is unscoreable (None), not a wrong number.
        assert!(cvss_v3_base("CVSS:3.1/AV:N/AC:L").is_none());
    }

    /// CVSS v2 base scores, validated against FIRST's published reference values.
    /// A v2 vector has no `CVSS:` prefix and is detected by its `Au:` metric.
    #[test]
    fn cvss_v2_base_matches_published_scores() {
        let s = |v: &str| cvss_base_score(v).unwrap();
        assert!(
            (s("AV:N/AC:L/Au:N/C:C/I:C/A:C") - 10.0).abs() < 1e-9,
            "full network compromise"
        );
        assert!((s("AV:N/AC:L/Au:N/C:P/I:P/A:P") - 7.5).abs() < 1e-9);
        assert!((s("AV:N/AC:M/Au:N/C:P/I:N/A:N") - 4.3).abs() < 1e-9);
        assert!((s("AV:L/AC:H/Au:N/C:C/I:C/A:C") - 6.2).abs() < 1e-9);
        assert!(
            (s("AV:N/AC:L/Au:N/C:N/I:N/A:N") - 0.0).abs() < 1e-9,
            "no impact ⇒ 0"
        );
    }

    /// The whole path: a CVSS-**vector**-only OSV advisory (no numeric score, no
    /// database_specific label) is now rated by its computed base score instead of
    /// silently defaulting to Medium.
    #[test]
    fn vector_only_advisory_is_scored_not_defaulted_to_medium() {
        let json = r#"{
            "id": "CVE-2021-44228",
            "details": "log4shell",
            "severity": [ { "type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H" } ],
            "affected": [ { "package": { "ecosystem": "Maven", "name": "log4j-core" },
                            "versions": ["2.14.1"] } ]
        }"#;
        let advs = parse_osv(json).unwrap();
        assert_eq!(
            advs[0].severity,
            Severity::Critical,
            "10.0 vector ⇒ Critical, not the Medium default"
        );
    }

    #[test]
    fn ranges_expand_to_exact_versions() {
        let json = r#"{
            "id": "RUSTSEC-2024-0003",
            "summary": "panic in foo",
            "affected": [
                { "package": { "ecosystem": "crates.io", "name": "foo" },
                  "ranges": [ { "type": "SEMVER", "events": [
                      { "introduced": "1.0.0" }, { "fixed": "1.2.0" } ] } ] }
            ],
            "database_specific": { "severity": "HIGH" }
        }"#;
        let ranges = parse_osv_ranges(json).unwrap();
        assert_eq!(ranges.len(), 1);
        assert_eq!(ranges[0].ecosystem, "cargo");
        // Known versions of `foo` in the index.
        let known = |_eco: &str, _pkg: &str| {
            vec![
                "0.9.0".into(),
                "1.0.0".into(),
                "1.1.0".into(),
                "1.2.0".into(),
            ]
        };
        let advs = expand_advisories(&ranges, known);
        assert_eq!(advs.len(), 1);
        assert_eq!(advs[0].affected_versions, vec!["1.0.0", "1.1.0"]);
        assert_eq!(advs[0].severity, Severity::High);
        assert!(advs[0].matches(&ArtifactRef {
            ecosystem: "cargo".into(),
            name: "foo".into(),
            version: "1.1.0".into(),
            blob_sha256: "x".into(),
        }));
    }

    #[test]
    fn range_with_no_known_affected_version_yields_no_advisory() {
        let json = r#"{
            "id": "OSV-X",
            "affected": [
                { "package": { "ecosystem": "npm", "name": "bar" },
                  "ranges": [ { "events": [ { "introduced": "2.0.0" } ] } ] }
            ]
        }"#;
        let ranges = parse_osv_ranges(json).unwrap();
        let advs = expand_advisories(&ranges, |_, _| vec!["1.0.0".into(), "1.5.0".into()]);
        assert!(advs.is_empty(), "no known version is >= 2.0.0");
    }

    #[test]
    fn batch_flattens_multi_package_records() {
        let json = r#"[
            { "id": "OSV-1",
              "affected": [
                  { "package": { "ecosystem": "npm", "name": "a" }, "versions": ["1.0.0"] },
                  { "package": { "ecosystem": "npm", "name": "b" }, "versions": ["2.0.0"] }
              ],
              "database_specific": { "severity": "LOW" } }
        ]"#;
        let advs = parse_osv_batch(json).unwrap();
        assert_eq!(advs.len(), 2);
        assert_eq!(advs[0].package, "a");
        assert_eq!(advs[1].package, "b");
        assert!(advs.iter().all(|a| a.severity == Severity::Low));
    }

    // ── P1: range-native ingest + aliases + withdrawn + cross-feed dedup ─────

    /// A range OSV record is now carried **range-native** — the advisory holds the
    /// range events and matches by `affected_by` directly (no `known_versions`
    /// pre-expansion), and the OSV `aliases` ride along.
    #[test]
    fn range_record_is_carried_range_native_with_aliases() {
        use crate::core::ArtifactRef;
        let json = r#"{
            "id": "GHSA-xxxx",
            "aliases": ["CVE-2026-0001"],
            "affected": [
                { "package": { "ecosystem": "crates.io", "name": "widget" },
                  "ranges": [ { "type": "SEMVER", "events": [
                      { "introduced": "1.0.0" }, { "fixed": "1.2.0" } ] } ] }
            ],
            "database_specific": { "severity": "HIGH" } }"#;
        let advs = parse_osv(json).unwrap();
        assert_eq!(advs.len(), 1);
        let a = &advs[0];
        assert_eq!(a.ecosystem, "cargo");
        assert_eq!(
            a.ranges,
            vec![vec![
                RangeEvent::Introduced("1.0.0".into()),
                RangeEvent::Fixed("1.2.0".into())
            ]],
            "the range object is carried whole, not pre-expanded"
        );
        assert!(
            a.aliases.iter().any(|x| x == "CVE-2026-0001"),
            "OSV aliases parsed"
        );
        assert_eq!(a.source, "osv");
        let aref = |v: &str| ArtifactRef {
            ecosystem: "cargo".into(),
            name: "widget".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        assert!(
            a.matches(&aref("1.1.0")),
            "range-native match inside window"
        );
        assert!(!a.matches(&aref("1.2.0")), "fixed bound exclusive");
    }

    /// A withdrawn OSV record parses with `withdrawn = true` and never matches.
    #[test]
    fn withdrawn_osv_record_never_matches() {
        use crate::core::ArtifactRef;
        let json = r#"{
            "id": "OSV-W",
            "withdrawn": "2026-01-01T00:00:00Z",
            "affected": [ { "package": { "ecosystem": "npm", "name": "x" }, "versions": ["1.0.0"] } ],
            "database_specific": { "severity": "HIGH" } }"#;
        let a = &parse_osv(json).unwrap()[0];
        assert!(a.withdrawn);
        let aref = ArtifactRef {
            ecosystem: "npm".into(),
            name: "x".into(),
            version: "1.0.0".into(),
            blob_sha256: String::new(),
        };
        assert!(!a.matches(&aref), "withdrawn advisory never matches");
    }

    /// `parse_and_merge` folds the SAME vuln arriving as two feeds (a CVE record
    /// and a GHSA record that aliases it) for the same package into one advisory.
    #[test]
    fn parse_and_merge_dedups_same_vuln_across_feeds() {
        let from_nvd = r#"{
            "id": "CVE-2026-0001",
            "affected": [ { "package": { "ecosystem": "crates.io", "name": "widget" },
                "ranges": [ { "events": [ { "introduced": "1.0.0" } ] } ] } ],
            "database_specific": { "severity": "MODERATE" } }"#;
        let from_ghsa = r#"{
            "id": "GHSA-xxxx",
            "aliases": ["CVE-2026-0001"],
            "affected": [ { "package": { "ecosystem": "crates.io", "name": "widget" },
                "versions": ["1.1.0"] } ],
            "database_specific": { "severity": "HIGH" } }"#;
        let merged = parse_and_merge([from_nvd, from_ghsa]);
        assert_eq!(
            merged.len(),
            1,
            "the same vuln from two feeds folds to one row"
        );
        let a = &merged[0];
        assert_eq!(a.severity, Severity::High, "max severity across feeds");
        assert!(
            !a.ranges.is_empty() && a.affected_versions.contains(&"1.1.0".to_string()),
            "ranges + versions unioned across feeds"
        );
    }

    /// An OSV record that carries a GIT range (commit hashes) beside the SEMVER
    /// one. Flattening both into a single event list makes the commit hash parse as
    /// version `0`, sort to the front, and its `fixed` event switch the advisory
    /// OFF for every real version below the SEMVER fix — silencing the whole
    /// advisory. The GIT range must be dropped and the SEMVER range kept intact.
    #[test]
    fn git_range_beside_semver_does_not_silence_the_advisory() {
        use crate::core::ArtifactRef;
        let json = r#"{
            "id": "GHSA-git-semver",
            "affected": [
                { "package": { "ecosystem": "crates.io", "name": "widget" },
                  "ranges": [
                      { "type": "GIT", "repo": "https://example.invalid/w",
                        "events": [ { "introduced": "0" }, { "fixed": "6a3c1f2b9d4e" } ] },
                      { "type": "SEMVER",
                        "events": [ { "introduced": "0" }, { "fixed": "1.0.0" } ] } ] }
            ],
            "database_specific": { "severity": "HIGH" } }"#;
        let advs = parse_osv(json).unwrap();
        assert_eq!(advs.len(), 1);
        let a = &advs[0];
        assert_eq!(
            a.ranges.len(),
            1,
            "the GIT range is dropped, the SEMVER one kept"
        );
        let aref = |v: &str| ArtifactRef {
            ecosystem: "cargo".into(),
            name: "widget".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        assert!(a.matches(&aref("0.1.0")), "below the fix ⇒ affected");
        assert!(a.matches(&aref("0.9.0")), "below the fix ⇒ affected");
        assert!(!a.matches(&aref("1.0.0")), "the fix itself is clean");
    }

    /// Two SEMVER range objects (a maintained 1.x branch and a 2.x branch) are a
    /// UNION. Splicing their events makes the 1.x `fixed` cancel the 2.x window.
    #[test]
    fn multi_branch_semver_ranges_stay_separate() {
        use crate::core::ArtifactRef;
        let json = r#"{
            "id": "GHSA-two-branches",
            "affected": [
                { "package": { "ecosystem": "crates.io", "name": "widget" },
                  "ranges": [
                      { "type": "SEMVER",
                        "events": [ { "introduced": "0" }, { "fixed": "1.4.2" } ] },
                      { "type": "SEMVER",
                        "events": [ { "introduced": "2.0.0" }, { "fixed": "2.1.3" } ] } ] }
            ],
            "database_specific": { "severity": "HIGH" } }"#;
        let a = &parse_osv(json).unwrap()[0];
        assert_eq!(a.ranges.len(), 2, "one event list per range object");
        let aref = |v: &str| ArtifactRef {
            ecosystem: "cargo".into(),
            name: "widget".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        assert!(a.matches(&aref("1.4.1")), "inside the 1.x window");
        assert!(a.matches(&aref("2.1.0")), "inside the 2.x window");
        assert!(!a.matches(&aref("1.5.0")), "between the windows");
        assert!(!a.matches(&aref("2.1.3")), "the 2.x fix");
    }

    /// An entry whose ONLY ranges are GIT commit windows carries nothing we can
    /// evaluate. It must be dropped, never emitted as an empty-ranges advisory —
    /// that sentinel means "every version affected" and would flag the whole package.
    #[test]
    fn git_only_entry_is_dropped_not_turned_into_all_versions() {
        let json = r#"{
            "id": "OSV-git-only",
            "affected": [
                { "package": { "ecosystem": "crates.io", "name": "widget" },
                  "ranges": [ { "type": "GIT", "repo": "https://example.invalid/w",
                      "events": [ { "introduced": "0" }, { "fixed": "6a3c1f2b9d4e" } ] } ] }
            ] }"#;
        assert!(
            parse_osv(json).unwrap().is_empty(),
            "no evaluable version info ⇒ no advisory"
        );
    }

    // ── M1: rustsec patched/unaffected → RangeEvent inversion ─────────────────

    /// cargo-audit's rule, expressed with the real `semver` crate — the oracle the
    /// range inversion must agree with. Mirrors nornir's `req_matches`, including
    /// the pre-release-stripping so `>= 0.10` doesn't wrongly flag `0.11.0-rc`.
    fn oracle_affected(patched: &[&str], unaffected: &[&str], v: &str) -> bool {
        fn req_matches(reqs: &[&str], v: &semver::Version) -> bool {
            let mut bare = v.clone();
            bare.pre = semver::Prerelease::EMPTY;
            reqs.iter().any(|r| {
                semver::VersionReq::parse(r)
                    .map(|req| req.matches(v) || req.matches(&bare))
                    .unwrap_or(false)
            })
        }
        let ver = semver::Version::parse(v).unwrap();
        !req_matches(patched, &ver) && !req_matches(unaffected, &ver)
    }

    fn assert_matches_oracle(patched: &[&str], unaffected: &[&str], versions: &[&str]) {
        let p: Vec<String> = patched.iter().map(|s| s.to_string()).collect();
        let u: Vec<String> = unaffected.iter().map(|s| s.to_string()).collect();
        let ranges = rustsec_affected_ranges(&p, &u);
        for v in versions {
            // Empty ranges ⇒ every version affected (Advisory::matches all-versions).
            let got = ranges.is_empty() || crate::versions::affected_by("cargo", &ranges, v);
            let want = oracle_affected(patched, unaffected, v);
            assert_eq!(
                got, want,
                "patched={patched:?} unaffected={unaffected:?} v={v} ranges={ranges:?}"
            );
        }
    }

    #[test]
    fn rustsec_single_patched_branch() {
        assert_matches_oracle(
            &[">= 1.2.0"],
            &[],
            &["0.1.0", "1.1.9", "1.2.0", "1.2.1", "2.0.0"],
        );
    }

    #[test]
    fn rustsec_patched_plus_unaffected_lower() {
        assert_matches_oracle(
            &[">= 1.2.0"],
            &["< 0.5.0"],
            &["0.1.0", "0.5.0", "0.9.0", "1.1.0", "1.2.0", "3.0.0"],
        );
    }

    #[test]
    fn rustsec_multi_branch_unpatched_between_ranges() {
        // The aws-lc-style case: two maintained branches, affected in the gap.
        assert_matches_oracle(
            &[">= 1.2.4, < 1.3.0", ">= 1.3.2"],
            &[],
            &[
                "1.2.3", "1.2.4", "1.2.9", "1.3.0", "1.3.1", "1.3.2", "1.4.0",
            ],
        );
    }

    #[test]
    fn rustsec_no_patched_all_affected() {
        assert!(
            rustsec_affected_ranges(&[], &[]).is_empty(),
            "no patched + no unaffected ⇒ all versions affected"
        );
        assert_matches_oracle(&[], &[], &["0.0.1", "1.0.0", "9.9.9"]);
    }

    #[test]
    fn rustsec_no_patched_but_unaffected_lower() {
        assert_matches_oracle(&[], &["< 0.5.0"], &["0.1.0", "0.5.0", "1.0.0"]);
    }

    #[test]
    fn rustsec_exact_unaffected_carveout() {
        // `= 1.0.0` unaffected carves a single safe point; we tolerate over-flag at
        // exactly that point (never under-flag), so the battery avoids 1.0.0 itself.
        assert_matches_oracle(&[">= 1.1.0"], &["= 1.0.0"], &["0.9.0", "1.0.5", "1.1.0"]);
    }

    #[cfg(feature = "rustsec")]
    #[test]
    fn parse_rustsec_md_real_advisory() {
        let md = "```toml\n\
            [advisory]\n\
            id = \"RUSTSEC-2021-0001\"\n\
            package = \"widget\"\n\
            title = \"Use-after-free in widget\"\n\
            aliases = [\"CVE-2021-1234\"]\n\
            cvss = \"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H\"\n\
            \n\
            [versions]\n\
            patched = [\">= 1.2.0\"]\n\
            ```\n\n# Use-after-free\n";
        let a = parse_rustsec_md(md).expect("parses");
        assert_eq!(a.id, "RUSTSEC-2021-0001");
        assert_eq!(a.ecosystem, "cargo");
        assert_eq!(a.package, "widget");
        // The vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H computes to 9.8 —
        // now scored offline (was defaulted to High before the CVSS v3 calculator).
        assert_eq!(
            a.severity,
            Severity::Critical,
            "9.8 vector ⇒ Critical, computed not defaulted"
        );
        assert_eq!(a.source, "rustsec");
        assert!(a.aliases.iter().any(|x| x == "CVE-2021-1234"));
        let aref = |v: &str| ArtifactRef {
            ecosystem: "cargo".into(),
            name: "widget".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        assert!(a.matches(&aref("1.1.0")), "below patched ⇒ affected");
        assert!(!a.matches(&aref("1.2.0")), "patched ⇒ not affected");
    }

    #[cfg(feature = "rustsec")]
    #[test]
    fn parse_rustsec_md_informational_warns() {
        let md = "```toml\n\
            [advisory]\n\
            id = \"RUSTSEC-2020-0099\"\n\
            package = \"oldcrate\"\n\
            title = \"oldcrate is unmaintained\"\n\
            informational = \"unmaintained\"\n\
            \n\
            [versions]\n\
            patched = []\n\
            ```\nbody\n";
        let a = parse_rustsec_md(md).expect("parses");
        assert_eq!(
            a.severity,
            Severity::Low,
            "informational ⇒ Low (Warn), not Block"
        );
        let aref = ArtifactRef {
            ecosystem: "cargo".into(),
            name: "oldcrate".into(),
            version: "0.3.0".into(),
            blob_sha256: String::new(),
        };
        assert!(a.matches(&aref), "no patched ⇒ all versions affected");
    }
}