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

mikronova/modgunn

src/registry.rs

//! Registry **yank / deprecation** feed import (feature `scan`).
//!
//! A published package version that the registry itself has **pulled back** —
//! *yanked* on crates.io, *deprecated* on npm — is a supply-chain signal with no
//! CVE: the maintainer (or the registry) is telling you *do not depend on this
//! version*. Reasons range from a broken release to an actively-malicious one that
//! was scrubbed after publish. OSV/NVD do not carry it, so this module imports the
//! signal offline (the same airgap sealed-znippy path as [`crate::feeds`]) and
//! produces [`Advisory`]s that land in the SAME `cve_advisories` warehouse table —
//! no second seam (L5): a yank is coords + an affected version + a severity + a
//! stable id, which is exactly what an [`Advisory`] is.
//!
//! - **crates.io yanks** — the crates.io API `GET /api/v1/crates/{crate}` returns a
//!   `versions[]` array, each `{ num, yanked, crate }`. Every `yanked: true` version
//!   becomes a `MODGUNN-YANK-cargo-*` [`Advisory`] (ecosystem `cargo`), exact-version
//!   keyed. ([`parse_crates_yanks`])
//! - **npm deprecations** — the npm registry `GET /{pkg}` packument returns a
//!   `versions{}` map, each version object optionally carrying a `deprecated` string
//!   (the `npm deprecate` message). Every deprecated version becomes a
//!   `MODGUNN-DEPRECATED-npm-*` [`Advisory`] (ecosystem `npm`). ([`parse_npm_deprecations`])
//!
//! **Anti-silent-empty:** a document that lacks the `versions` field is a malformed
//! feed — [`parse_crates_yanks`] / [`parse_npm_deprecations`] return an `Err` (the
//! `versions` field is non-optional, so serde rejects it), which `cmd_import` counts
//! and names on stderr. A well-formed document whose versions are simply *none
//! yanked / none deprecated* legitimately yields an empty `Vec` — that is a real
//! zero, not a truncated fetch, and the two must not be confused.
//!
//! Both signals ride at `Severity::Low`: a yank/deprecation is "stop depending on
//! this", not a scored vulnerability, so it must be VISIBLE in the ledger without
//! escalating a verdict to refuse on its own. The [`crate::scan`] verdict + the
//! Mímir yank-cascade join (`.nornir/baddie-detection-design.md`) are what escalate
//! it, by combining the yank with release-lineage reachability.

use serde::Deserialize;

use crate::core::Severity;
use crate::scan::Advisory;

/// One entry of the crates.io `versions[]` array. `crate` (the owning crate name)
/// is carried on every version object, so the package coord is recoverable even
/// from a bare version list; `yanked` defaults to `false` for forward-compat with
/// versions that omit it.
#[derive(Debug, Deserialize)]
struct CratesVersion {
    num: String,
    #[serde(default)]
    yanked: bool,
    /// The owning crate name (crates.io stamps it on each version object).
    #[serde(rename = "crate", default)]
    crate_name: String,
}

/// A crates.io `GET /api/v1/crates/{crate}` response. `versions` is **non-optional**
/// so a truncated/wrong document (no `versions`) is a parse error, never a silent
/// empty. The optional top-level `crate` block supplies the name when a version
/// object omits it.
#[derive(Debug, Deserialize)]
struct CratesResponse {
    versions: Vec<CratesVersion>,
    #[serde(rename = "crate", default)]
    krate: Option<CrateMeta>,
}

#[derive(Debug, Deserialize)]
struct CrateMeta {
    #[serde(default)]
    name: String,
}

/// Parse a crates.io crate document into one `MODGUNN-YANK-cargo-*` [`Advisory`] per
/// **yanked** version. A well-formed document with no yanked versions yields an
/// empty `Vec` (a real zero); a document without a `versions` array is an `Err`.
pub fn parse_crates_yanks(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
    let resp: CratesResponse = serde_json::from_str(json)?;
    let top_name = resp.krate.as_ref().map(|c| c.name.as_str()).unwrap_or("");
    // The clean (non-yanked) versions — the candidate upgrade targets for the
    // baddie-detection yank-cascade remediation (b3).
    let clean: Vec<&str> = resp
        .versions
        .iter()
        .filter(|v| !v.yanked)
        .map(|v| v.num.as_str())
        .collect();
    let mut out = Vec::new();
    for v in &resp.versions {
        if !v.yanked {
            continue;
        }
        // Prefer the version's own `crate` field; fall back to the top-level name.
        let pkg = if v.crate_name.is_empty() {
            top_name
        } else {
            v.crate_name.as_str()
        };
        if pkg.is_empty() {
            // A yanked version we cannot attribute to a crate is useless (and would
            // land a coordless row); skip it rather than emit a phantom package.
            continue;
        }
        out.push(
            Advisory::basic(
                format!("MODGUNN-YANK-cargo-{pkg}-{}", v.num),
                "cargo",
                pkg,
                vec![v.num.clone()],
                Severity::Low,
                yank_summary(
                    "crates.io",
                    pkg,
                    &v.num,
                    earliest_clean_successor("cargo", &clean, &v.num),
                ),
            )
            .with_source("crates.io"),
        );
    }
    Ok(out)
}

/// The earliest **clean** version strictly greater than `after`, under `ecosystem`'s
/// version ordering — the actionable "upgrade to" target for a yanked/deprecated
/// version (the baddie-detection **b3 yank-cascade remediation**: given a yanked
/// pin, which release supersedes it). `None` when nothing clean supersedes it (the
/// whole tail is yanked, or `after` is already the latest) — an honest "no fix
/// forward" the lens can surface distinctly. Pure: reuses [`crate::versions::compare`],
/// so per-ecosystem prerelease/epoch rules apply (semver / PEP 440 / …).
pub fn earliest_clean_successor<'a>(
    ecosystem: &str,
    clean: &[&'a str],
    after: &str,
) -> Option<&'a str> {
    clean
        .iter()
        .copied()
        .filter(|c| crate::versions::compare(ecosystem, c, after) == std::cmp::Ordering::Greater)
        .min_by(|a, b| crate::versions::compare(ecosystem, a, b))
}

/// Build a yank/deprecation summary, appending the remediation when a clean
/// successor exists (`; upgrade to X`) or noting its absence — so the actionable
/// fix rides the round-tripping `summary` column for smid's Mímir lens.
fn yank_summary(source: &str, pkg: &str, version: &str, successor: Option<&str>) -> String {
    match successor {
        Some(s) => format!("yanked {source} version {version} of {pkg}; upgrade to {s}"),
        None => format!("yanked {source} version {version} of {pkg} (no clean successor)"),
    }
}

/// A single npm packument version object. `deprecated` is a free `Value` because npm
/// historically stores the deprecation *message* (a string) but tolerates a bare
/// `true`; [`deprecation_reason`] coerces both.
#[derive(Debug, Deserialize)]
struct NpmVersion {
    #[serde(default)]
    deprecated: Option<serde_json::Value>,
}

/// An npm `GET /{pkg}` packument. `versions` is **non-optional** (anti-silent-empty:
/// a document without it is a parse error, not a fake pass). `name` supplies the
/// package coord.
#[derive(Debug, Deserialize)]
struct NpmPackument {
    #[serde(default)]
    name: String,
    versions: std::collections::HashMap<String, NpmVersion>,
}

/// Coerce npm's `deprecated` value into an actual deprecation reason. A non-empty
/// string is the message; `true` is a bare deprecation with no message; a `false`
/// / empty string / absent value is **not** deprecated (`None`).
fn deprecation_reason(v: &Option<serde_json::Value>) -> Option<String> {
    match v {
        Some(serde_json::Value::String(s)) if !s.trim().is_empty() => Some(s.trim().to_string()),
        Some(serde_json::Value::Bool(true)) => Some(String::new()),
        _ => None,
    }
}

/// Parse an npm packument into one `MODGUNN-DEPRECATED-npm-*` [`Advisory`] per
/// **deprecated** version. A well-formed packument with no deprecated versions
/// yields an empty `Vec`; a document without a `versions` map is an `Err`.
pub fn parse_npm_deprecations(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
    let pkg: NpmPackument = serde_json::from_str(json)?;
    if pkg.name.is_empty() {
        // A packument with no top-level name cannot key its versions to a package —
        // treat it as malformed so the row is never coordless.
        return Err(serde::de::Error::custom("npm packument missing `name`"));
    }
    let mut out = Vec::new();
    // The clean (non-deprecated) versions — the b3 upgrade-target candidates.
    let clean: Vec<&str> = pkg
        .versions
        .iter()
        .filter(|(_, m)| deprecation_reason(&m.deprecated).is_none())
        .map(|(v, _)| v.as_str())
        .collect();
    // Sort by version key so the emitted advisory order is deterministic (HashMap
    // iteration is not) — matters for stable warehouse generations + test assertions.
    let mut versions: Vec<(&String, &NpmVersion)> = pkg.versions.iter().collect();
    versions.sort_by(|a, b| a.0.cmp(b.0));
    for (ver, meta) in versions {
        let Some(reason) = deprecation_reason(&meta.deprecated) else {
            continue;
        };
        let base = if reason.is_empty() {
            format!("deprecated npm version {ver} of {}", pkg.name)
        } else {
            format!("deprecated npm version {ver} of {}: {reason}", pkg.name)
        };
        let summary = match earliest_clean_successor("npm", &clean, ver) {
            Some(s) => format!("{base}; upgrade to {s}"),
            None => base,
        };
        out.push(
            Advisory::basic(
                format!("MODGUNN-DEPRECATED-npm-{}-{ver}", pkg.name),
                "npm",
                &pkg.name,
                vec![ver.clone()],
                Severity::Low,
                summary,
            )
            .with_source("npm"),
        );
    }
    Ok(out)
}

/// A single PyPI release-file record (one wheel/sdist). PEP 592 yanks operate at
/// the **file** level; `yanked` defaults to `false`.
#[derive(Debug, Deserialize)]
struct PypiFile {
    #[serde(default)]
    yanked: bool,
    #[serde(default)]
    yanked_reason: Option<String>,
}

/// A PyPI `GET /pypi/{project}/json` response. `releases` (version → files) is
/// **non-optional** (anti-silent-empty). `info.name` supplies the package coord.
#[derive(Debug, Deserialize)]
struct PypiProject {
    info: PypiInfo,
    releases: std::collections::HashMap<String, Vec<PypiFile>>,
}

#[derive(Debug, Deserialize)]
struct PypiInfo {
    #[serde(default)]
    name: String,
}

/// Parse a PyPI project document into one `MODGUNN-YANK-pypi-*` [`Advisory`] per
/// **yanked release** (PEP 592). A release is treated as yanked when it has files
/// and **every** file is yanked — matching PyPI's release-level yank (a *partial*
/// file yank leaves the version installable, so it is not flagged). The reason is
/// the first non-empty `yanked_reason`. A well-formed project with no fully-yanked
/// release yields an empty `Vec`; a document without a `releases` map is an `Err`.
pub fn parse_pypi_yanks(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
    let proj: PypiProject = serde_json::from_str(json)?;
    if proj.info.name.is_empty() {
        return Err(serde::de::Error::custom("PyPI project missing `info.name`"));
    }
    let mut versions: Vec<(&String, &Vec<PypiFile>)> = proj.releases.iter().collect();
    versions.sort_by(|a, b| a.0.cmp(b.0)); // deterministic emit order (HashMap is not)
                                           // The clean (still-installable) releases — a release with files, not all yanked —
                                           // are the upgrade-target candidates for the b3 remediation.
    let clean: Vec<&str> = proj
        .releases
        .iter()
        .filter(|(_, files)| !files.is_empty() && !files.iter().all(|f| f.yanked))
        .map(|(v, _)| v.as_str())
        .collect();
    let mut out = Vec::new();
    for (ver, files) in versions {
        // A release with no files at all is not "yanked" — it is simply absent.
        if files.is_empty() || !files.iter().all(|f| f.yanked) {
            continue;
        }
        let reason = files
            .iter()
            .find_map(|f| {
                f.yanked_reason
                    .as_deref()
                    .map(str::trim)
                    .filter(|s| !s.is_empty())
            })
            .unwrap_or("");
        let base = if reason.is_empty() {
            format!("yanked PyPI release {ver} of {}", proj.info.name)
        } else {
            format!("yanked PyPI release {ver} of {}: {reason}", proj.info.name)
        };
        let summary = match earliest_clean_successor("pypi", &clean, ver) {
            Some(s) => format!("{base}; upgrade to {s}"),
            None => base,
        };
        out.push(
            Advisory::basic(
                format!("MODGUNN-YANK-pypi-{}-{ver}", proj.info.name),
                "pypi",
                &proj.info.name,
                vec![ver.clone()],
                Severity::Low,
                summary,
            )
            .with_source("pypi"),
        );
    }
    Ok(out)
}

/// Parse a Go module's **`go.mod`** into one `MODGUNN-YANK-go-*` [`Advisory`] per
/// **`retract`ed** version. Go has no central yank registry — a module withdraws
/// its own releases with `retract` directives in its `go.mod`, so this is the only
/// Go yank signal. Handles the single form (`retract v1.2.3`), the inline range
/// (`retract [v1.0.0, v1.2.0]`, inclusive both ends), and the block form
/// (`retract ( … )`); `// rationale` comments and blank lines are ignored. Returns
/// an empty `Vec` for a `go.mod` with no retractions (a real zero); a `go.mod` with
/// no `module` line yields an empty `Vec` (nothing to attribute), never a panic.
pub fn parse_go_retractions(gomod: &str) -> Vec<Advisory> {
    // The module path (needed for coords) — `module <path>` at the top.
    let module = gomod.lines().map(str::trim).find_map(|l| {
        l.strip_prefix("module ")
            .map(|m| m.trim().trim_matches('"'))
    });
    let Some(module) = module.filter(|m| !m.is_empty()) else {
        return Vec::new();
    };

    let mut out = Vec::new();
    let mut in_block = false;
    for raw in gomod.lines() {
        // Drop an end-of-line `// comment`, then trim.
        let line = raw.split("//").next().unwrap_or(raw).trim();
        if line.is_empty() {
            continue;
        }
        if in_block {
            if line == ")" {
                in_block = false;
                continue;
            }
            push_retraction(module, line, &mut out);
            continue;
        }
        if let Some(rest) = line.strip_prefix("retract") {
            let rest = rest.trim();
            if rest == "(" {
                in_block = true;
            } else if !rest.is_empty() {
                push_retraction(module, rest, &mut out);
            }
        }
    }
    out
}

/// Turn one retraction spec — a single `vX.Y.Z` or an inclusive range
/// `[vLow, vHigh]` — into an [`Advisory`] for `module`, appended to `out`.
fn push_retraction(module: &str, spec: &str, out: &mut Vec<Advisory>) {
    use crate::versions::RangeEvent;
    let spec = spec.trim();
    if let Some(inner) = spec.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
        // `[low, high]` — an inclusive interval of retracted versions.
        let mut it = inner.split(',');
        let (Some(low), Some(high)) = (it.next(), it.next()) else {
            return;
        };
        let (low, high) = (low.trim(), high.trim());
        if low.is_empty() || high.is_empty() {
            return;
        }
        out.push(
            Advisory::basic(
                format!("MODGUNN-YANK-go-{module}-{low}-{high}"),
                "go",
                module,
                Vec::new(),
                Severity::Low,
                format!("retracted go module range {low}..={high} of {module}"),
            )
            .with_source("go")
            .with_ranges(vec![
                RangeEvent::Introduced(low.into()),
                RangeEvent::LastAffected(high.into()),
            ]),
        );
    } else {
        let version = spec.trim_matches('"');
        if version.is_empty() {
            return;
        }
        out.push(
            Advisory::basic(
                format!("MODGUNN-YANK-go-{module}-{version}"),
                "go",
                module,
                vec![version.to_string()],
                Severity::Low,
                format!("retracted go module version {version} of {module}"),
            )
            .with_source("go"),
        );
    }
}

// ── ecosyste.ms package-status import ────────────────────────────────────────
//
// packages.ecosyste.ms aggregates registry metadata across ALL ecosystems with a
// uniform, no-auth JSON API. Its per-package `status` (`removed` / `unpublished` /
// `deprecated`) is a PACKAGE-level "stop depending on this" signal the per-registry
// yank parsers above don't capture — a whole package pulled from the registry
// (often a malicious-package takedown) or maintainer-deprecated. Unlike a version
// yank it is not version-scoped, so it lands as an ALL-VERSIONS advisory (empty
// affected set ⇒ `matches` flags every version — correct: the whole package is
// gone). Low severity, same as the yanks: visible in the ledger, not a refuse on
// its own; the lens escalates via the yank-cascade join.

/// The ecosyste.ms `registry` sub-object (carries the ecosystem when the top-level
/// field is absent).
#[derive(Debug, Default, Deserialize)]
struct EcosystemsRegistry {
    #[serde(default)]
    ecosystem: String,
    #[serde(default)]
    name: String,
}

/// A packages.ecosyste.ms package document (only the fields modgunn keys on;
/// tolerant of the rest). `status` is optional — `None`/`"active"` means nothing to
/// flag (a real zero, not a malformed feed).
#[derive(Debug, Deserialize)]
struct EcosystemsPackage {
    #[serde(default)]
    name: String,
    #[serde(default)]
    ecosystem: String,
    #[serde(default)]
    registry: EcosystemsRegistry,
    #[serde(default)]
    status: Option<String>,
}

/// Map an ecosyste.ms ecosystem label to modgunn's canonical key (most already
/// align; `rubygems`→`gem`, `packagist`→`composer`).
fn ecosystems_eco(label: &str) -> Option<&'static str> {
    match label.trim().to_ascii_lowercase().as_str() {
        "npm" => Some("npm"),
        "pypi" => Some("pypi"),
        "cargo" | "crates" => Some("cargo"),
        "maven" => Some("maven"),
        "go" => Some("go"),
        "nuget" => Some("nuget"),
        "rubygems" | "gem" => Some("gem"),
        "packagist" | "composer" => Some("composer"),
        _ => None,
    }
}

/// Parse one ecosyste.ms package document into a package-level status
/// [`Advisory`] — `Some` only when `status` is a recognized pulled/deprecated state
/// (`removed`/`unpublished`/`deprecated`), `None` for active/unknown status or a
/// package we can't key (no name / unmapped ecosystem). The ecosystem is taken from
/// the top-level `ecosystem`, else `registry.ecosystem`, else `registry.name`.
pub fn parse_ecosystems_package(json: &str) -> Result<Option<Advisory>, serde_json::Error> {
    let pkg: EcosystemsPackage = serde_json::from_str(json)?;
    let status = match pkg.status.as_deref().map(|s| s.trim().to_ascii_lowercase()) {
        Some(s) if matches!(s.as_str(), "removed" | "unpublished" | "deprecated") => s,
        _ => return Ok(None), // active / null / unknown ⇒ nothing to flag (a real zero)
    };
    if pkg.name.trim().is_empty() {
        return Ok(None);
    }
    let eco_label = if !pkg.ecosystem.is_empty() {
        pkg.ecosystem.as_str()
    } else if !pkg.registry.ecosystem.is_empty() {
        pkg.registry.ecosystem.as_str()
    } else {
        pkg.registry.name.as_str()
    };
    let Some(eco) = ecosystems_eco(eco_label) else {
        return Ok(None); // an ecosystem modgunn doesn't model — can't key the join
    };
    let name = pkg.name.trim();
    let marker = if status == "deprecated" {
        "DEPRECATED"
    } else {
        "REMOVED"
    };
    Ok(Some(
        Advisory::basic(
            format!("MODGUNN-ECOSYSTEMS-{marker}-{eco}-{name}"),
            eco,
            name,
            Vec::new(), // package-level ⇒ all versions affected
            Severity::Low,
            format!("package `{name}` is {status} on {eco} (ecosyste.ms package status)"),
        )
        .with_source("ecosyste.ms"),
    ))
}

// ── libraries.io project-status import ───────────────────────────────────────
//
// libraries.io is the elder cross-ecosystem package index (ecosyste.ms is its open
// successor — see above). Its per-project `status` field carries the SAME
// "stop depending on this" package-level signal: `Deprecated` / `Removed` /
// `Unmaintained` / `Hidden`, plus a free-text `deprecation_reason`. Like the
// ecosyste.ms status row it lands as an all-versions Low advisory (empty affected
// set ⇒ every version flagged — correct: the package itself is pulled/deprecated).
//
// LIVE FETCH: the libraries.io API (`GET https://libraries.io/api/:platform/:name`)
// requires a FREE registered `api_key` query param. This OFFLINE parser is
// key-free — it reads the already-downloaded project JSON, matching modgunn's
// file-in collector model (obtaining the file is out of scope).

/// A libraries.io project document (only the fields modgunn keys on; tolerant of
/// the rest). `platform` is libraries.io's ecosystem label (`NPM`/`Pypi`/`Cargo`/
/// `Go`/`Maven`/`Rubygems`/`Packagist`/`NuGet`), mapped through [`ecosystems_eco`]
/// (case-folded — the same label set ecosyste.ms uses).
#[derive(Debug, Default, Deserialize)]
struct LibrariesioProject {
    #[serde(default)]
    name: String,
    #[serde(default)]
    platform: String,
    /// `Active` / `Deprecated` / `Removed` / `Unmaintained` / `Hidden` / null.
    #[serde(default)]
    status: Option<String>,
    /// Free-text deprecation note; present ⇒ deprecated even if `status` is null.
    #[serde(default)]
    deprecation_reason: Option<String>,
}

/// Parse one **libraries.io** project document into a package-level status
/// [`Advisory`] — `Some` only when the project is in a pulled/deprecated/abandoned
/// state (`status` ∈ {removed, deprecated, unmaintained, hidden} OR a non-empty
/// `deprecation_reason`), `None` for an active/unknown project, an empty name, or
/// an ecosystem modgunn doesn't model (can't key the join). Mirrors
/// [`parse_ecosystems_package`] — the same all-versions Low "stop depending on
/// this" row, distinguished only by the `MODGUNN-LIBRARIESIO-*` id + source label.
pub fn parse_librariesio_project(json: &str) -> Result<Option<Advisory>, serde_json::Error> {
    let proj: LibrariesioProject = serde_json::from_str(json)?;
    if proj.name.trim().is_empty() {
        return Ok(None);
    }
    let Some(eco) = ecosystems_eco(&proj.platform) else {
        return Ok(None); // an ecosystem modgunn doesn't model — can't key the join
    };
    let reason = proj
        .deprecation_reason
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty());
    let status = proj
        .status
        .as_deref()
        .map(|s| s.trim().to_ascii_lowercase());
    let (marker, state): (&str, String) = match status.as_deref() {
        Some("removed") => ("REMOVED", "removed".into()),
        Some("deprecated") => ("DEPRECATED", "deprecated".into()),
        Some("unmaintained") => ("UNMAINTAINED", "unmaintained".into()),
        Some("hidden") => ("HIDDEN", "hidden".into()),
        // status is active/null/unknown — a deprecation_reason alone still flags it.
        _ if reason.is_some() => ("DEPRECATED", "deprecated".into()),
        _ => return Ok(None), // active / null / unknown, no reason ⇒ a real zero
    };
    let name = proj.name.trim();
    let summary = match reason {
        Some(r) => format!("package `{name}` is {state} on {eco} (libraries.io): {r}"),
        None => format!("package `{name}` is {state} on {eco} (libraries.io project status)"),
    };
    Ok(Some(
        Advisory::basic(
            format!("MODGUNN-LIBRARIESIO-{marker}-{eco}-{name}"),
            eco,
            name,
            Vec::new(), // package-level ⇒ all versions affected
            Severity::Low,
            summary,
        )
        .with_source("libraries.io"),
    ))
}

// ── Sonatype OSS-Index — the purl-keyed component vulnerability report ────────
//
// OSS-Index answers "what is wrong with THIS exact package version?" across every
// major ecosystem with one uniform, purl-keyed document — the same join key
// `vuln_findings` uses. Unlike the registry parsers above (which report a package's
// *status*), this is a genuine advisory feed, so its rows are real CVE advisories,
// not `MODGUNN-*` synthetic ones: when the report carries a `cve` the advisory is
// keyed by that CVE id, so `merge_by_alias` folds it into the SAME row the OSV/NVD
// import produced instead of creating a duplicate finding. Only a report entry with
// no CVE at all (an OSS-Index-only finding, identified by its Sonatype UUID) gets a
// `MODGUNN-OSSINDEX-*` id.

/// One `vulnerabilities[]` entry of an OSS-Index component report.
#[derive(Debug, Deserialize)]
struct OssIndexVuln {
    #[serde(default)]
    id: String,
    #[serde(default)]
    title: String,
    #[serde(default)]
    description: String,
    #[serde(default)]
    cve: Option<String>,
    #[serde(rename = "displayName", default)]
    display_name: String,
    #[serde(rename = "cvssScore", default)]
    cvss_score: Option<f64>,
    #[serde(rename = "cvssVector", default)]
    cvss_vector: String,
    #[serde(default)]
    cwe: String,
}

/// One component entry of an OSS-Index report. `coordinates` is a **purl**.
#[derive(Debug, Deserialize)]
struct OssIndexComponent {
    #[serde(default)]
    coordinates: String,
    #[serde(default)]
    vulnerabilities: Vec<OssIndexVuln>,
}

/// Map an OSS-Index / purl **type** to modgunn's canonical ecosystem key. purl uses
/// `golang`, `rubygems` and `cocoapods` where modgunn/OSV use `go` and `gem`; the
/// rest already align. An unmodelled type yields `None` — the row is skipped rather
/// than landing under a label nothing joins on.
fn purl_type_to_ecosystem(t: &str) -> Option<&'static str> {
    match t.trim().to_ascii_lowercase().as_str() {
        "npm" => Some("npm"),
        "pypi" => Some("pypi"),
        "cargo" | "crates" => Some("cargo"),
        "maven" => Some("maven"),
        "golang" | "go" => Some("go"),
        "nuget" => Some("nuget"),
        "gem" | "rubygems" => Some("gem"),
        "composer" | "packagist" => Some("composer"),
        "conan" => Some("conan"),
        _ => None,
    }
}

/// Parse a **Sonatype OSS-Index** component report into [`Advisory`]s — one per
/// `(component, vulnerability)` pair, keyed to the component's **exact version**
/// (the report is a per-version answer, so the affected set is that single version).
///
/// The document is the API's response shape: a bare `[ … ]` array of components, or
/// a `{ "componentReports": [ … ] }` wrapper. Each component's `coordinates` is a
/// purl, parsed by the ONE purl grammar ([`crate::sbom::parse_purl`]) rather than a
/// hand-rolled split — so a scoped npm (`pkg:npm/%40scope/name@1.0.0`) and a maven
/// `group/artifact` land on exactly the coords the join keys on.
///
/// **Id choice matters for de-duplication.** A report entry carrying a `cve` is
/// keyed by that CVE id and lists the OSS-Index UUID as an alias, so
/// [`merge_by_alias`](crate::scan::merge_by_alias) folds it into the SAME advisory
/// the OSV/NVD import already produced (range union + max severity) instead of
/// double-reporting one vulnerability. Only a CVE-less finding gets a synthetic
/// `MODGUNN-OSSINDEX-<uuid>` id. Severity comes from the numeric `cvssScore` through
/// the SHARED [`crate::osv::severity_from_base`] bucketing, falling back to the
/// `cvssVector` and finally to `Medium`.
///
/// A component with no vulnerabilities is a **real zero** (the common case — that is
/// what a clean component looks like); a document that is not JSON is an `Err`.
pub fn parse_ossindex_report(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
    let doc: serde_json::Value = serde_json::from_str(json)?;
    let components: Vec<OssIndexComponent> = if let Some(arr) = doc.get("componentReports") {
        serde_json::from_value(arr.clone())?
    } else if doc.is_array() {
        serde_json::from_value(doc)?
    } else {
        vec![serde_json::from_value(doc)?]
    };
    let mut out = Vec::new();
    for comp in &components {
        let Some((ptype, name, version)) = crate::sbom::parse_purl(&comp.coordinates) else {
            continue;
        };
        let Some(eco) = purl_type_to_ecosystem(&ptype) else {
            continue;
        };
        if name.is_empty() || version.is_empty() {
            // A package-level coord cannot be keyed to an affected version, and an
            // all-versions advisory here would be a mass false positive (OSS-Index
            // findings are version-scoped by construction).
            continue;
        }
        for v in &comp.vulnerabilities {
            let cve = v
                .cve
                .as_deref()
                .map(str::trim)
                .filter(|s| s.starts_with("CVE-"))
                .or_else(|| Some(v.display_name.trim()).filter(|s| s.starts_with("CVE-")));
            let (id, aliases) = match cve {
                Some(c) => (
                    c.to_string(),
                    if v.id.is_empty() {
                        Vec::new()
                    } else {
                        vec![v.id.clone()]
                    },
                ),
                None if !v.id.is_empty() => (format!("MODGUNN-OSSINDEX-{}", v.id), Vec::new()),
                None => continue, // no id at all ⇒ nothing stable to key or dedup on
            };
            let severity = v
                .cvss_score
                .map(crate::osv::severity_from_base)
                .or_else(|| crate::osv::severity_from_cvss_vector(&v.cvss_vector))
                .unwrap_or(Severity::Medium);
            let summary = if !v.title.trim().is_empty() {
                v.title.trim().to_string()
            } else if !v.description.trim().is_empty() {
                v.description.trim().to_string()
            } else {
                format!("OSS-Index finding for {name}@{version}")
            };
            let mut adv = Advisory::basic(id, eco, &name, vec![version.clone()], severity, summary)
                .with_source("oss-index");
            adv.aliases = aliases;
            adv.cvss_vector = v.cvss_vector.trim().to_string();
            adv.cwe = if v.cwe.trim().is_empty() {
                Vec::new()
            } else {
                vec![v.cwe.trim().to_string()]
            };
            out.push(adv);
        }
    }
    Ok(out)
}

// ── socket.dev supply-chain alerts (SCAFFOLD — PENDING paid API key) ──────────
//
// Socket scores a package's *behaviour* rather than its CVEs: install scripts,
// obfuscated code, telemetry/network at install, shell access, and — the killer
// signal — `malware`. Each finding is an `alert` with a `type` + `severity`
// + `category`. This is a supply-chain-risk signal the CVE feeds do not carry, so
// it lands as a per-package-version [`Advisory`] (worst-alert severity; any
// malware/critical alert ⇒ Critical), keyed on the purl `type`→ecosystem the join
// already uses.
//
// ⚠ PENDING — live ingestion needs a **PAID** Socket API key
// (`GET https://api.socket.dev/v0/...`, HTTP Basic). This parser is written to
// Socket's PUBLIC alert schema (docs.socket.dev) but the exact response ENVELOPE
// is NOT yet validated against a live paid response, so treat the wiring as a
// scaffold: the model + parse + tests are real (empty-is-RED holds), but the
// field-for-field fidelity to the live feed is unverified. See feeds-design.md §D.

/// One Socket `alert` (a single supply-chain finding on a package version).
#[derive(Debug, Default, Deserialize)]
struct SocketAlert {
    #[serde(default, rename = "type")]
    kind: String,
    #[serde(default)]
    severity: String,
    #[serde(default)]
    category: String,
}

/// A Socket package-issues document (`{type, name, version, alerts:[…]}`); only the
/// fields modgunn keys on. Tolerant of a single object OR a batch array (below).
#[derive(Debug, Default, Deserialize)]
struct SocketPackage {
    #[serde(default, rename = "type")]
    purl_type: String,
    #[serde(default)]
    name: String,
    #[serde(default)]
    version: String,
    #[serde(default)]
    alerts: Vec<SocketAlert>,
}

/// Map a Socket alert `severity` label to a modgunn [`Severity`].
fn socket_severity(label: &str) -> Severity {
    match label.trim().to_ascii_lowercase().as_str() {
        "critical" => Severity::Critical,
        "high" => Severity::High,
        "middle" | "medium" => Severity::Medium,
        _ => Severity::Low,
    }
}

/// Parse a **socket.dev** package-issues document (single object or a batch array)
/// into per-package-version supply-chain [`Advisory`]s. A package with no alerts
/// yields nothing (a real zero — Socket found nothing). Severity is the worst alert
/// present, and ANY `malware` alert (by type or category) escalates to `Critical`
/// regardless of the reported label. A malformed document is an `Err`.
///
/// ⚠ SCAFFOLD / PENDING — see the module note: the live Socket API is paid, so the
/// response envelope is unverified against a real feed.
pub fn parse_socket_alerts(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
    let doc: serde_json::Value = serde_json::from_str(json)?;
    // Accept a bare array (batch), a `{ "packages": [ … ] }` envelope, or one object.
    let packages: Vec<SocketPackage> = if let Some(arr) = doc.as_array() {
        serde_json::from_value(serde_json::Value::Array(arr.clone()))?
    } else if let Some(arr) = doc.get("packages").and_then(|v| v.as_array()) {
        serde_json::from_value(serde_json::Value::Array(arr.clone()))?
    } else {
        vec![serde_json::from_value(doc)?]
    };
    let mut out = Vec::new();
    for pkg in &packages {
        if pkg.alerts.is_empty() || pkg.name.trim().is_empty() {
            continue; // no finding, or nothing to key on — a real zero
        }
        let Some(eco) = purl_type_to_ecosystem(&pkg.purl_type) else {
            continue; // an ecosystem modgunn doesn't model — can't key the join
        };
        let is_malware = pkg.alerts.iter().any(|a| {
            a.kind.eq_ignore_ascii_case("malware") || a.category.eq_ignore_ascii_case("malware")
        });
        let severity = if is_malware {
            Severity::Critical
        } else {
            pkg.alerts
                .iter()
                .map(|a| socket_severity(&a.severity))
                .max()
                .unwrap_or(Severity::Low)
        };
        let name = pkg.name.trim();
        let version = pkg.version.trim();
        let mut kinds: Vec<&str> = pkg
            .alerts
            .iter()
            .map(|a| a.kind.trim())
            .filter(|s| !s.is_empty())
            .collect();
        kinds.sort_unstable();
        kinds.dedup();
        let affected = if version.is_empty() {
            Vec::new()
        } else {
            vec![version.to_string()]
        };
        let vpart = if version.is_empty() {
            "*".to_string()
        } else {
            version.to_string()
        };
        out.push(
            Advisory::basic(
                format!("MODGUNN-SOCKET-{eco}-{name}-{vpart}"),
                eco,
                name,
                affected,
                severity,
                format!(
                    "socket.dev supply-chain alerts on `{name}`{}: {}",
                    if version.is_empty() {
                        String::new()
                    } else {
                        format!("@{version}")
                    },
                    kinds.join(", ")
                ),
            )
            .with_source("socket.dev"),
        );
    }
    Ok(out)
}

// ── GUAC CertifyVuln ingest (SCAFFOLD — PENDING a running GUAC instance) ──────
//
// GUAC (Graph for Understanding Artifact Composition) aggregates SBOMs, attestations
// and vuln claims into one graph exposed over GraphQL. Its `CertifyVuln` edges say
// "this package version is certified vulnerable to this vulnerability" — a package↔
// vuln join modgunn can ingest as advisories, keyed BY the vulnerability id (so a
// `cve`/`ghsa`/`osv` id folds into the row OSV/NVD already produced via
// `merge_by_alias`). GUAC's package model is the "package trie": type → namespaces →
// names → versions.
//
// ⚠ PENDING — ingestion needs a **running GUAC GraphQL endpoint** with data loaded
// (`POST http://localhost:8080/query`); there is no offline corpus to point at here.
// This parser targets GUAC's documented `CertifyVuln` GraphQL response shape, with
// real tests, but is a scaffold until validated against a live GUAC. `novuln` (GUAC's
// "certified NOT vulnerable" marker) and empty ids are skipped. See feeds-design.md §D.

#[derive(Debug, Default, Deserialize)]
struct GuacResponse {
    #[serde(default)]
    data: GuacData,
}
#[derive(Debug, Default, Deserialize)]
struct GuacData {
    #[serde(default, rename = "CertifyVuln")]
    certify_vuln: Vec<GuacCertifyVuln>,
}
#[derive(Debug, Default, Deserialize)]
struct GuacCertifyVuln {
    #[serde(default)]
    package: GuacPackage,
    #[serde(default)]
    vulnerability: GuacVulnerability,
}
#[derive(Debug, Default, Deserialize)]
struct GuacPackage {
    #[serde(default, rename = "type")]
    purl_type: String,
    #[serde(default)]
    namespaces: Vec<GuacNamespace>,
}
#[derive(Debug, Default, Deserialize)]
struct GuacNamespace {
    #[serde(default)]
    namespace: String,
    #[serde(default)]
    names: Vec<GuacName>,
}
#[derive(Debug, Default, Deserialize)]
struct GuacName {
    #[serde(default)]
    name: String,
    #[serde(default)]
    versions: Vec<GuacVersion>,
}
#[derive(Debug, Default, Deserialize)]
struct GuacVersion {
    #[serde(default)]
    version: String,
}
#[derive(Debug, Default, Deserialize)]
struct GuacVulnerability {
    /// `cve` / `ghsa` / `osv` / `novuln`.
    #[serde(default, rename = "type")]
    vuln_type: String,
    #[serde(default, rename = "vulnerabilityIDs")]
    vulnerability_ids: Vec<GuacVulnId>,
}
#[derive(Debug, Default, Deserialize)]
struct GuacVulnId {
    #[serde(default, rename = "vulnerabilityID")]
    vulnerability_id: String,
}

/// Parse a **GUAC** `CertifyVuln` GraphQL response into [`Advisory`]s — one per
/// (package version × vulnerability id). The advisory is keyed BY the vuln id
/// (upper-cased for CVE/GHSA so `merge_by_alias` folds it into an existing row); a
/// `novuln` certification or an empty id is skipped (GUAC's "certified NOT
/// vulnerable" is a real zero, not a finding). Package coords come out of the trie:
/// `type`→ecosystem, `namespace/name` (namespace prefixed when present — maven
/// `group/artifact`, npm `@scope/name`, go module path), per version. A malformed
/// document is an `Err`.
///
/// ⚠ SCAFFOLD / PENDING — see the module note: needs a running GUAC instance; the
/// GraphQL schema is unvalidated against a live endpoint here.
pub fn parse_guac_certify_vuln(json: &str) -> Result<Vec<Advisory>, serde_json::Error> {
    let resp: GuacResponse = serde_json::from_str(json)?;
    let mut out = Vec::new();
    for cv in &resp.data.certify_vuln {
        // GUAC uses `novuln` to record "certified NOT vulnerable" — not a finding.
        if cv.vulnerability.vuln_type.eq_ignore_ascii_case("novuln") {
            continue;
        }
        let Some(eco) = purl_type_to_ecosystem(&cv.package.purl_type) else {
            continue; // an ecosystem modgunn doesn't model — can't key the join
        };
        // Normalize each id: GUAC lower-cases them; CVE/GHSA canonicalize to upper.
        let ids: Vec<String> = cv
            .vulnerability
            .vulnerability_ids
            .iter()
            .filter_map(|v| {
                let id = v.vulnerability_id.trim();
                if id.is_empty() {
                    return None;
                }
                let up = id.to_ascii_uppercase();
                Some(if up.starts_with("CVE-") || up.starts_with("GHSA-") {
                    up
                } else {
                    id.to_string()
                })
            })
            .collect();
        if ids.is_empty() {
            continue;
        }
        for ns in &cv.package.namespaces {
            for nm in &ns.names {
                if nm.name.trim().is_empty() {
                    continue;
                }
                let full = if ns.namespace.trim().is_empty() {
                    nm.name.trim().to_string()
                } else {
                    format!("{}/{}", ns.namespace.trim(), nm.name.trim())
                };
                for ver in &nm.versions {
                    let version = ver.version.trim();
                    let affected = if version.is_empty() {
                        Vec::new()
                    } else {
                        vec![version.to_string()]
                    };
                    for id in &ids {
                        let summary = format!(
                            "GUAC CertifyVuln: `{full}`{} certified vulnerable to {id}",
                            if version.is_empty() {
                                String::new()
                            } else {
                                format!("@{version}")
                            }
                        );
                        out.push(
                            Advisory::basic(
                                id,
                                eco,
                                &full,
                                affected.clone(),
                                Severity::Medium,
                                summary,
                            )
                            .with_aliases([id.clone()])
                            .with_source("guac"),
                        );
                    }
                }
            }
        }
    }
    Ok(out)
}

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

    #[test]
    fn librariesio_flags_pulled_states_and_ignores_active() {
        // Deprecated by explicit status.
        let a = parse_librariesio_project(
            r#"{ "name": "left-pad", "platform": "NPM", "status": "Deprecated" }"#,
        )
        .unwrap()
        .expect("deprecated ⇒ an advisory");
        assert_eq!(
            (a.ecosystem.as_str(), a.package.as_str()),
            ("npm", "left-pad")
        );
        assert_eq!(a.severity, Severity::Low);
        assert!(a.id.starts_with("MODGUNN-LIBRARIESIO-DEPRECATED-npm-"));
        assert!(
            a.affected_versions.is_empty(),
            "package-level ⇒ all versions"
        );
        assert_eq!(a.source, "libraries.io");

        // Removed maps its own marker; platform label is case-folded (Pypi→pypi).
        let r = parse_librariesio_project(
            r#"{ "name": "evilpkg", "platform": "Pypi", "status": "Removed" }"#,
        )
        .unwrap()
        .unwrap();
        assert!(r.id.contains("REMOVED-pypi"));

        // deprecation_reason alone (status null) still flags, and is carried through.
        let dr = parse_librariesio_project(
            r#"{ "name": "oldlib", "platform": "Cargo", "status": null, "deprecation_reason": "use newlib" }"#,
        )
        .unwrap()
        .unwrap();
        assert!(dr.summary.contains("use newlib"));
        assert_eq!(dr.ecosystem, "cargo");

        // Active / unmapped ecosystem / no name ⇒ a real None, never a phantom row.
        assert!(parse_librariesio_project(
            r#"{ "name": "react", "platform": "NPM", "status": "Active" }"#
        )
        .unwrap()
        .is_none());
        assert!(
            parse_librariesio_project(
                r#"{ "name": "x", "platform": "CocoaPods", "status": "Removed" }"#
            )
            .unwrap()
            .is_none(),
            "unmodelled ecosystem"
        );
        assert!(parse_librariesio_project(
            r#"{ "name": "", "platform": "NPM", "status": "Removed" }"#
        )
        .unwrap()
        .is_none());
        assert!(
            parse_librariesio_project("garbage").is_err(),
            "malformed ⇒ Err"
        );
    }

    #[test]
    fn socket_alerts_worst_severity_and_malware_escalation() {
        // A batch: a malware package (escalates to Critical regardless of label) and a
        // behaviour-flagged package (worst-of the alert severities).
        let json = r#"[
            { "type": "npm", "name": "event-stream", "version": "3.3.6",
              "alerts": [ { "type": "malware", "severity": "high", "category": "supplyChainRisk" },
                          { "type": "installScript", "severity": "low" } ] },
            { "type": "pypi", "name": "requests", "version": "2.31.0",
              "alerts": [ { "type": "telemetry", "severity": "middle" },
                          { "type": "networkAccess", "severity": "low" } ] },
            { "type": "npm", "name": "clean-pkg", "version": "1.0.0", "alerts": [] },
            { "type": "cocoapods", "name": "unmodelled", "version": "1.0", "alerts": [ { "type": "x", "severity": "high" } ] }
        ]"#;
        let advs = parse_socket_alerts(json).unwrap();
        assert_eq!(
            advs.len(),
            2,
            "clean (no alerts) and unmodelled-ecosystem packages skipped"
        );
        let es = advs.iter().find(|a| a.package == "event-stream").unwrap();
        assert_eq!(
            es.severity,
            Severity::Critical,
            "any malware alert ⇒ Critical"
        );
        assert!(es.summary.contains("malware"));
        assert_eq!(es.source, "socket.dev");
        assert_eq!(es.affected_versions, vec!["3.3.6".to_string()]);
        let req = advs.iter().find(|a| a.package == "requests").unwrap();
        assert_eq!(
            req.severity,
            Severity::Medium,
            "worst of middle+low alerts = Medium"
        );

        // A single object (not a batch) parses too; malformed is an Err.
        let one = parse_socket_alerts(r#"{ "type": "npm", "name": "a", "version": "1.0.0", "alerts": [ { "type": "shellAccess", "severity": "critical" } ] }"#).unwrap();
        assert_eq!(one.len(), 1);
        assert!(parse_socket_alerts("not json").is_err());
    }

    #[test]
    fn guac_certify_vuln_keys_by_vuln_id_and_skips_novuln() {
        // GUAC's package trie: type → namespaces → names → versions, plus a vuln with
        // an id list. Keying by the CVE id (canonicalized upper) is what lets
        // merge_by_alias fold it into the OSV/NVD row instead of double-reporting.
        let json = r#"{ "data": { "CertifyVuln": [
            { "package": { "type": "golang",
                "namespaces": [ { "namespace": "github.com/foo", "names": [
                    { "name": "bar", "versions": [ { "version": "v1.2.3" } ] } ] } ] },
              "vulnerability": { "type": "cve", "vulnerabilityIDs": [ { "vulnerabilityID": "cve-2021-44228" } ] } },
            { "package": { "type": "pypi", "namespaces": [ { "namespace": "", "names": [
                    { "name": "safe", "versions": [ { "version": "1.0" } ] } ] } ] },
              "vulnerability": { "type": "novuln", "vulnerabilityIDs": [] } }
        ] } }"#;
        let advs = parse_guac_certify_vuln(json).unwrap();
        assert_eq!(
            advs.len(),
            1,
            "the novuln (certified NOT vulnerable) row adds nothing"
        );
        let a = &advs[0];
        assert_eq!(
            a.id, "CVE-2021-44228",
            "id canonicalized upper, keyed by the vuln"
        );
        assert_eq!(a.ecosystem, "go");
        assert_eq!(
            a.package, "github.com/foo/bar",
            "namespace prefixed onto the name"
        );
        assert_eq!(a.affected_versions, vec!["v1.2.3".to_string()]);
        assert!(
            a.aliases.contains(&"CVE-2021-44228".to_string()),
            "carries the id as an alias for the fold"
        );
        assert_eq!(a.source, "guac");
        assert!(parse_guac_certify_vuln("{").is_err(), "malformed ⇒ Err");
        assert!(
            parse_guac_certify_vuln(r#"{ "data": { "CertifyVuln": [] } }"#)
                .unwrap()
                .is_empty(),
            "empty graph ⇒ real empty"
        );
    }

    /// OSS-Index is purl-keyed, so the coords must come out of the ONE purl grammar —
    /// a scoped npm and a maven `group/artifact` land on exactly the coords the join
    /// keys on. And the id choice is what stops it double-reporting: a CVE-carrying
    /// entry is keyed BY the CVE (UUID as an alias) so `merge_by_alias` folds it into
    /// the row OSV already produced. Reverting either — a hand-rolled coordinate split
    /// or a `MODGUNN-OSSINDEX-*` id for a CVE-carrying finding — turns this red.
    #[test]
    fn ossindex_report_keys_by_cve_and_parses_purl_coords() {
        use crate::core::ArtifactRef;
        let json = r#"[
          { "coordinates": "pkg:npm/%40scope/name@1.2.3",
            "vulnerabilities": [
              { "id": "8dd9c0c3-1111-2222-3333-444455556666",
                "displayName": "CVE-2020-11022",
                "title": "[CVE-2020-11022] CWE-79",
                "cve": "CVE-2020-11022",
                "cvssScore": 9.1, "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
                "cwe": "CWE-79" } ] },
          { "coordinates": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
            "vulnerabilities": [
              { "id": "aaaa-bbbb", "title": "Sonatype-only finding", "cvssScore": 4.3 } ] },
          { "coordinates": "pkg:golang/github.com/foo/bar@v1.2.3", "vulnerabilities": [] },
          { "coordinates": "pkg:cocoapods/Alamofire@5.0.0",
            "vulnerabilities": [ { "id": "x", "title": "unmodelled ecosystem" } ] },
          { "coordinates": "pkg:npm/lodash",
            "vulnerabilities": [ { "id": "y", "title": "no version to key on" } ] }
        ]"#;
        let advs = parse_ossindex_report(json).unwrap();
        assert_eq!(
            advs.len(),
            2,
            "clean component, unmodelled ecosystem and version-less coord all skipped"
        );

        // ── the CVE-carrying finding ──
        let npm = &advs[0];
        assert_eq!(
            npm.id, "CVE-2020-11022",
            "keyed BY the CVE so merge_by_alias dedups it"
        );
        assert_eq!(
            npm.aliases,
            vec!["8dd9c0c3-1111-2222-3333-444455556666".to_string()]
        );
        assert_eq!(
            (npm.ecosystem.as_str(), npm.package.as_str()),
            ("npm", "@scope/name")
        );
        assert_eq!(
            npm.severity,
            Severity::Critical,
            "cvssScore 9.1 buckets through the SHARED thresholds"
        );
        assert_eq!(npm.source, "oss-index");
        assert_eq!(npm.cwe, vec!["CWE-79".to_string()]);
        assert!(npm.matches(&ArtifactRef {
            ecosystem: "npm".into(),
            name: "@scope/name".into(),
            version: "1.2.3".into(),
            blob_sha256: String::new(),
        }));
        assert!(
            !npm.matches(&ArtifactRef {
                ecosystem: "npm".into(),
                name: "@scope/name".into(),
                version: "1.2.4".into(),
                blob_sha256: String::new(),
            }),
            "version-scoped: a different version is NOT flagged"
        );

        // ── the CVE-less (Sonatype-only) finding ──
        let mvn = &advs[1];
        assert_eq!(
            mvn.id, "MODGUNN-OSSINDEX-aaaa-bbbb",
            "only a CVE-less finding gets a synthetic id"
        );
        assert_eq!(
            (mvn.ecosystem.as_str(), mvn.package.as_str()),
            ("maven", "org.apache.logging.log4j:log4j-core"),
            "maven purl group/artifact folds to the group:artifact coord OSV uses"
        );
        assert_eq!(mvn.severity, Severity::Medium);

        // De-duplication actually fires: the OSS-Index row merges into the OSV row
        // for the same CVE rather than double-reporting it.
        let osv_row = Advisory::basic(
            "GHSA-jfh8",
            "npm",
            "@scope/name",
            vec!["1.2.3".into()],
            Severity::Low,
            "x",
        )
        .with_aliases(["CVE-2020-11022"]);
        let merged = crate::scan::merge_by_alias(vec![osv_row, npm.clone()]);
        assert_eq!(
            merged.len(),
            1,
            "one vulnerability, one row — not two findings for the same CVE"
        );
        assert_eq!(
            merged[0].severity,
            Severity::Critical,
            "max severity wins the merge"
        );

        // The two container shapes and the failure edge.
        assert_eq!(
            parse_ossindex_report(&format!(r#"{{ "componentReports": {json} }}"#))
                .unwrap()
                .len(),
            2
        );
        assert!(
            parse_ossindex_report("<html>403</html>").is_err(),
            "a non-JSON document is an error"
        );
        // A clean report is a REAL empty — that is what an unaffected component is.
        assert!(parse_ossindex_report(
            r#"[{"coordinates":"pkg:npm/a@1.0.0","vulnerabilities":[]}]"#
        )
        .unwrap()
        .is_empty());
    }

    #[test]
    fn ecosystems_status_becomes_all_versions_advisory() {
        use crate::core::ArtifactRef;
        // A removed npm package → an all-versions Low advisory that flags ANY version.
        let removed = r#"{ "name": "evil-pkg", "ecosystem": "npm", "status": "removed" }"#;
        let a = parse_ecosystems_package(removed)
            .unwrap()
            .expect("removed ⇒ advisory");
        assert_eq!(a.id, "MODGUNN-ECOSYSTEMS-REMOVED-npm-evil-pkg");
        assert_eq!(a.ecosystem, "npm");
        assert_eq!(a.severity, Severity::Low);
        assert!(
            a.affected_versions.is_empty() && a.ranges.is_empty(),
            "package-level ⇒ all versions"
        );
        assert!(
            a.matches(&ArtifactRef {
                ecosystem: "npm".into(),
                name: "evil-pkg".into(),
                version: "9.9.9".into(),
                blob_sha256: String::new()
            }),
            "flags any version of the removed package"
        );
        // Deprecated → the DEPRECATED marker; ecosystem read from registry.ecosystem.
        let dep = r#"{ "name": "old", "registry": { "ecosystem": "pypi", "name": "pypi.org" }, "status": "deprecated" }"#;
        assert_eq!(
            parse_ecosystems_package(dep).unwrap().unwrap().id,
            "MODGUNN-ECOSYSTEMS-DEPRECATED-pypi-old"
        );
        // Active / null status ⇒ no advisory (a real zero, not a malformed feed).
        assert!(parse_ecosystems_package(
            r#"{ "name": "fine", "ecosystem": "npm", "status": "active" }"#
        )
        .unwrap()
        .is_none());
        assert!(
            parse_ecosystems_package(r#"{ "name": "fine", "ecosystem": "npm" }"#)
                .unwrap()
                .is_none()
        );
        // An ecosystem modgunn doesn't model ⇒ skipped (can't key the join).
        assert!(parse_ecosystems_package(
            r#"{ "name": "x", "ecosystem": "cocoapods", "status": "removed" }"#
        )
        .unwrap()
        .is_none());
    }

    #[test]
    fn crates_yanks_only_yanked_versions_become_advisories() {
        let json = r#"{
            "crate": { "name": "openssl-src" },
            "versions": [
                { "num": "300.1.0", "yanked": false, "crate": "openssl-src" },
                { "num": "300.0.9", "yanked": true,  "crate": "openssl-src" },
                { "num": "300.0.8", "yanked": true,  "crate": "openssl-src" }
            ]
        }"#;
        let advs = parse_crates_yanks(json).unwrap();
        assert_eq!(advs.len(), 2, "only the two yanked versions produce rows");
        assert!(advs.iter().all(|a| a.ecosystem == "cargo"));
        assert!(advs.iter().all(|a| a.package == "openssl-src"));
        assert!(advs.iter().all(|a| a.severity == Severity::Low));
        assert!(advs.iter().all(|a| a.source == "crates.io"));
        // Exact-version keyed so it joins to an sbom_components version row.
        let ids: Vec<_> = advs.iter().map(|a| a.id.as_str()).collect();
        assert!(ids.contains(&"MODGUNN-YANK-cargo-openssl-src-300.0.9"));
        assert!(ids.contains(&"MODGUNN-YANK-cargo-openssl-src-300.0.8"));
        let yanked = advs.iter().find(|a| a.id.ends_with("300.0.9")).unwrap();
        assert_eq!(yanked.affected_versions, vec!["300.0.9".to_string()]);
    }

    #[test]
    fn earliest_clean_successor_picks_nearest_greater_non_yanked() {
        // semver (cargo): the nearest clean version ABOVE the yanked one, skipping a
        // yanked candidate and ignoring older ones.
        let clean = ["1.0.0", "1.2.0", "1.5.0", "0.9.0"];
        assert_eq!(
            earliest_clean_successor("cargo", &clean, "1.1.0"),
            Some("1.2.0")
        );
        assert_eq!(
            earliest_clean_successor("cargo", &clean, "1.2.0"),
            Some("1.5.0"),
            "strictly greater"
        );
        assert_eq!(
            earliest_clean_successor("cargo", &clean, "1.5.0"),
            None,
            "already the latest clean"
        );
        assert_eq!(
            earliest_clean_successor("cargo", &clean, "2.0.0"),
            None,
            "nothing supersedes it"
        );
        // pep440 ordering: a prerelease successor is ranked correctly.
        let py = ["2.0.0", "2.0.0rc1", "2.1.0"];
        assert_eq!(
            earliest_clean_successor("pypi", &py, "1.9.0"),
            Some("2.0.0rc1")
        );
    }

    #[test]
    fn crates_yank_summary_carries_the_upgrade_target() {
        let json = r#"{ "crate": { "name": "leftpad-rs" }, "versions": [
            { "num": "1.2.0", "yanked": false, "crate": "leftpad-rs" },
            { "num": "1.1.0", "yanked": true,  "crate": "leftpad-rs" },
            { "num": "1.0.0", "yanked": true,  "crate": "leftpad-rs" } ] }"#;
        let advs = parse_crates_yanks(json).unwrap();
        let a11 = advs.iter().find(|a| a.id.ends_with("1.1.0")).unwrap();
        assert!(
            a11.summary.contains("upgrade to 1.2.0"),
            "b3 remediation: nearest clean successor"
        );
        let a10 = advs.iter().find(|a| a.id.ends_with("1.0.0")).unwrap();
        assert!(
            a10.summary.contains("upgrade to 1.2.0"),
            "1.0.0's successor skips the yanked 1.1.0"
        );
    }

    #[test]
    fn crates_yank_with_no_clean_successor_says_so() {
        // Every version yanked ⇒ no clean successor exists; the advisory must say so
        // (an honest "no fix forward"), not fabricate a yanked upgrade target.
        let json = r#"{ "crate": { "name": "doomed" }, "versions": [
            { "num": "1.0.0", "yanked": true, "crate": "doomed" },
            { "num": "1.1.0", "yanked": true, "crate": "doomed" } ] }"#;
        let advs = parse_crates_yanks(json).unwrap();
        let a10 = advs.iter().find(|a| a.id.ends_with("1.0.0")).unwrap();
        assert!(
            a10.summary.contains("no clean successor"),
            "no clean version supersedes it"
        );
        assert!(
            !a10.summary.contains("upgrade to"),
            "must not point at another yanked version"
        );
    }

    #[test]
    fn crates_falls_back_to_top_level_name_when_version_omits_crate() {
        let json = r#"{
            "crate": { "name": "toplevel-only" },
            "versions": [ { "num": "1.0.0", "yanked": true } ]
        }"#;
        let advs = parse_crates_yanks(json).unwrap();
        assert_eq!(advs.len(), 1);
        assert_eq!(advs[0].package, "toplevel-only");
    }

    #[test]
    fn crates_well_formed_but_no_yanks_is_a_real_empty_not_an_error() {
        // Anti-silent-empty's other edge: a valid doc where nothing is yanked is a
        // legitimate zero, NOT a parse failure. (The error case is a missing
        // `versions` field — see `crates_missing_versions_is_an_error`.)
        let json = r#"{ "crate": { "name": "clean" }, "versions": [
            { "num": "1.0.0", "yanked": false, "crate": "clean" } ] }"#;
        let advs = parse_crates_yanks(json).unwrap();
        assert!(advs.is_empty());
    }

    #[test]
    fn crates_missing_versions_is_an_error_not_a_silent_empty() {
        // Red-when-reverted: if `versions` were made Option and defaulted, a
        // truncated feed would parse to an empty Vec and report a fake PASS. The
        // non-optional field forces a hard error the importer counts.
        let json = r#"{ "crate": { "name": "truncated" } }"#;
        assert!(
            parse_crates_yanks(json).is_err(),
            "a doc with no versions[] must ERROR"
        );
    }

    #[test]
    fn npm_deprecations_only_deprecated_versions_become_advisories() {
        let json = r#"{
            "name": "left-pad",
            "versions": {
                "1.3.0": { "version": "1.3.0" },
                "1.2.0": { "version": "1.2.0", "deprecated": "use String.prototype.padStart" },
                "1.1.0": { "version": "1.1.0", "deprecated": "" }
            }
        }"#;
        let advs = parse_npm_deprecations(json).unwrap();
        assert_eq!(
            advs.len(),
            1,
            "empty-string deprecated is NOT a deprecation"
        );
        assert_eq!(advs[0].ecosystem, "npm");
        assert_eq!(advs[0].package, "left-pad");
        assert_eq!(advs[0].severity, Severity::Low);
        assert_eq!(advs[0].source, "npm");
        assert_eq!(advs[0].id, "MODGUNN-DEPRECATED-npm-left-pad-1.2.0");
        assert_eq!(advs[0].affected_versions, vec!["1.2.0".to_string()]);
        assert!(advs[0].summary.contains("use String.prototype.padStart"));
    }

    #[test]
    fn npm_deprecation_summary_carries_upgrade_target() {
        let json = r#"{ "name": "old-lib", "versions": {
            "1.0.0": { "deprecated": "buggy" },
            "1.1.0": { "deprecated": "still buggy" },
            "2.0.0": { "version": "2.0.0" } } }"#;
        let advs = parse_npm_deprecations(json).unwrap();
        // 1.0.0's nearest clean successor is 2.0.0 (1.1.0 is also deprecated).
        let a10 = advs.iter().find(|a| a.id.ends_with("1.0.0")).unwrap();
        assert!(
            a10.summary.contains("upgrade to 2.0.0"),
            "skips the deprecated 1.1.0"
        );
    }

    #[test]
    fn npm_deprecated_true_is_a_reasonless_deprecation() {
        let json = r#"{ "name": "old-pkg", "versions": {
            "2.0.0": { "deprecated": true },
            "2.1.0": { "deprecated": false } } }"#;
        let advs = parse_npm_deprecations(json).unwrap();
        assert_eq!(advs.len(), 1, "true deprecates; false does not");
        assert_eq!(advs[0].affected_versions, vec!["2.0.0".to_string()]);
    }

    #[test]
    fn npm_output_is_deterministic_by_version() {
        // HashMap iteration order is not stable; the parser must sort so the
        // warehouse generation + these assertions are reproducible.
        let json = r#"{ "name": "multi", "versions": {
            "1.2.0": { "deprecated": "b" },
            "1.10.0": { "deprecated": "c" },
            "1.1.0": { "deprecated": "a" } } }"#;
        let advs = parse_npm_deprecations(json).unwrap();
        let vers: Vec<_> = advs
            .iter()
            .flat_map(|a| a.affected_versions.clone())
            .collect();
        assert_eq!(
            vers,
            vec!["1.1.0", "1.10.0", "1.2.0"],
            "sorted string order"
        );
    }

    #[test]
    fn npm_missing_versions_is_an_error_not_a_silent_empty() {
        let json = r#"{ "name": "truncated" }"#;
        assert!(
            parse_npm_deprecations(json).is_err(),
            "a packument with no versions map must ERROR"
        );
    }

    #[test]
    fn npm_missing_name_is_rejected_so_no_coordless_row() {
        let json = r#"{ "versions": { "1.0.0": { "deprecated": "x" } } }"#;
        assert!(parse_npm_deprecations(json).is_err());
    }

    #[test]
    fn pypi_yanks_flags_fully_yanked_releases_only() {
        let json = r#"{
            "info": { "name": "ctx" },
            "releases": {
                "0.1.2": [ { "filename": "ctx-0.1.2.tar.gz", "yanked": true, "yanked_reason": "malicious" },
                           { "filename": "ctx-0.1.2-py3.whl", "yanked": true, "yanked_reason": null } ],
                "0.1.1": [ { "filename": "ctx-0.1.1.tar.gz", "yanked": false } ]
            }
        }"#;
        let advs = parse_pypi_yanks(json).unwrap();
        assert_eq!(advs.len(), 1, "only the fully-yanked release is flagged");
        assert_eq!(advs[0].ecosystem, "pypi");
        assert_eq!(advs[0].package, "ctx");
        assert_eq!(advs[0].severity, Severity::Low);
        assert_eq!(advs[0].source, "pypi");
        assert_eq!(advs[0].id, "MODGUNN-YANK-pypi-ctx-0.1.2");
        assert_eq!(advs[0].affected_versions, vec!["0.1.2".to_string()]);
        assert!(
            advs[0].summary.contains("malicious"),
            "carries the yank reason"
        );
    }

    #[test]
    fn pypi_partial_file_yank_is_not_a_release_yank() {
        // PEP 592: a version with a still-installable (un-yanked) file is NOT yanked
        // as a release — flagging it would be a false positive.
        let json = r#"{ "info": { "name": "half" }, "releases": {
            "1.0": [ { "yanked": true }, { "yanked": false } ] } }"#;
        let advs = parse_pypi_yanks(json).unwrap();
        assert!(advs.is_empty());
    }

    #[test]
    fn pypi_release_with_no_files_is_not_yanked() {
        let json = r#"{ "info": { "name": "empty-rel" }, "releases": { "1.0": [] } }"#;
        let advs = parse_pypi_yanks(json).unwrap();
        assert!(
            advs.is_empty(),
            "an empty release list is absent, not yanked"
        );
    }

    #[test]
    fn pypi_missing_releases_is_an_error_not_a_silent_empty() {
        let json = r#"{ "info": { "name": "truncated" } }"#;
        assert!(
            parse_pypi_yanks(json).is_err(),
            "a doc with no releases map must ERROR"
        );
    }

    #[test]
    fn go_retractions_single_range_and_block() {
        let gomod = "\
module github.com/acme/widget

go 1.21

require github.com/x/y v1.0.0

retract v1.4.0 // published by mistake

retract [v1.1.0, v1.2.0]

retract (
    v1.3.0 // security
    [v1.5.0, v1.6.0]
)
";
        let advs = parse_go_retractions(gomod);
        assert_eq!(advs.len(), 4);
        assert!(advs
            .iter()
            .all(|a| a.ecosystem == "go" && a.package == "github.com/acme/widget"));
        assert!(advs
            .iter()
            .all(|a| a.severity == Severity::Low && a.source == "go"));
        // single v1.4.0 → exact-version
        let single = advs.iter().find(|a| a.id.ends_with("-v1.4.0")).unwrap();
        assert_eq!(single.affected_versions, vec!["v1.4.0".to_string()]);
        assert!(single.ranges.is_empty());
        // range [v1.1.0, v1.2.0] → an inclusive range, matches the endpoints + inside
        let art = |v: &str| crate::core::ArtifactRef {
            ecosystem: "go".into(),
            name: "github.com/acme/widget".into(),
            version: v.into(),
            blob_sha256: String::new(),
        };
        let ranged = advs
            .iter()
            .find(|a| a.id.ends_with("-v1.1.0-v1.2.0"))
            .unwrap();
        assert!(ranged.matches(&art("v1.1.0")), "low endpoint inclusive");
        assert!(ranged.matches(&art("v1.1.5")), "inside the range");
        assert!(ranged.matches(&art("v1.2.0")), "high endpoint inclusive");
        assert!(!ranged.matches(&art("v1.2.1")), "just above is clean");
        assert!(!ranged.matches(&art("v1.0.0")), "just below is clean");
    }

    #[test]
    fn go_retractions_none_and_no_module_are_empty() {
        assert!(
            parse_go_retractions("module x\n\ngo 1.21\n").is_empty(),
            "no retract → empty"
        );
        assert!(
            parse_go_retractions("retract v1.0.0\n").is_empty(),
            "no module line → empty (nothing to key)"
        );
    }

    #[test]
    fn pypi_output_is_deterministic_by_version() {
        let json = r#"{ "info": { "name": "multi" }, "releases": {
            "1.2": [ { "yanked": true } ],
            "1.10": [ { "yanked": true } ],
            "1.1": [ { "yanked": true } ] } }"#;
        let advs = parse_pypi_yanks(json).unwrap();
        let vers: Vec<_> = advs
            .iter()
            .flat_map(|a| a.affected_versions.clone())
            .collect();
        assert_eq!(vers, vec!["1.1", "1.10", "1.2"], "sorted string order");
    }
}