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

mikronova/modgunn

src/manifests.rs

//! Multi-ecosystem manifest / lockfile **ingest** (feature `manifests`).
//!
//! The other end of [`sbom`](crate::sbom): where `sbom` *emits* a resolved
//! component set as CycloneDX, this module *reads* a project's dependency
//! manifest or lockfile and turns it into that SAME shared component model
//! ([`crate::sbom::SbomComponent`]) — so a downstream verdict / SBOM emit never
//! learns which ecosystem it came from. Purl carries the ecosystem
//! (`pkg:cargo/…`, `pkg:pip/…`, `pkg:npm/…`, …).
//!
//! **One seam, one parser per ecosystem.** Every ecosystem is a
//! [`EcosystemParser`]: it declares the manifest basenames it owns
//! ([`handles`](EcosystemParser::handles)) and turns a manifest's *text* into
//! components ([`parse`](EcosystemParser::parse)). The model is never forked —
//! `cargo` (Cargo.lock) is the template and every other ecosystem lands its
//! components in the exact same struct. Dispatch is by filename basename via
//! [`parse_manifest`].
//!
//! **Pure-Rust, zero-shell.** No parser shells out to `pip` / `npm` / `go` /
//! `mvn`: text formats are hand-parsed, TOML via `toml`, XML via `roxmltree`,
//! JSON via `serde_json`. Nothing here runs the target project's tooling.
//!
//! Ecosystems: **cargo** (Cargo.lock), **pip** (requirements.txt / poetry.lock /
//! Pipfile.lock), **npm** (package-lock.json / pnpm-lock.yaml), **maven**
//! (pom.xml), **go** (go.mod / go.sum), **nuget** (packages.lock.json / .csproj),
//! **gem** (Gemfile.lock).

use crate::sbom::SbomComponent;

/// A manifest parse failed. Carries a short human reason (bad TOML/JSON/XML, or
/// a structurally-unexpected document); the ecosystem seam never panics on
/// malformed input — it returns this.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError(pub String);

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "manifest parse error: {}", self.0)
    }
}
impl std::error::Error for ParseError {}

type Parsed = Result<Vec<SbomComponent>, ParseError>;

/// One ecosystem's manifest reader — the single seam every ecosystem plugs into.
///
/// A parser owns one or more manifest *basenames* (a lockfile, a manifest, or
/// both) and maps their text into the shared [`SbomComponent`] model. It is
/// object-safe so [`all`] can hold `Box<dyn EcosystemParser>` and
/// [`parse_manifest`] can dispatch dynamically.
pub trait EcosystemParser {
    /// The purl ecosystem tag stamped on every component this parser yields
    /// (`cargo`, `pip`, `npm`, `maven`, `go`, `nuget`, `gem`).
    fn ecosystem(&self) -> &'static str;

    /// Manifest basenames this parser owns (e.g. `["Cargo.lock"]`, or the pip
    /// trio). `.csproj` is matched by suffix, handled in [`handles`].
    fn manifests(&self) -> &'static [&'static str];

    /// Does this parser recognize a file called `filename` (basename)? Default:
    /// exact match against [`manifests`](EcosystemParser::manifests).
    fn handles(&self, filename: &str) -> bool {
        self.manifests().iter().any(|m| *m == filename)
    }

    /// Parse `content` (the manifest text for `filename`) into components.
    /// `filename` disambiguates a parser that owns several formats.
    fn parse(&self, filename: &str, content: &str) -> Parsed;
}

/// Every registered ecosystem parser, in a stable order (cargo first — the
/// template). A consumer can iterate this to advertise supported ecosystems.
pub fn all() -> Vec<Box<dyn EcosystemParser>> {
    vec![
        Box::new(CargoParser),
        Box::new(PipParser),
        Box::new(NpmParser),
        Box::new(MavenParser),
        Box::new(GoParser),
        Box::new(NugetParser),
        Box::new(GemParser),
    ]
}

/// Parse a manifest by dispatching on its basename to the owning ecosystem
/// parser. `None` ⇒ no parser owns this filename (unknown ecosystem). A path is
/// accepted; only the final component is matched.
pub fn parse_manifest(filename: &str, content: &str) -> Option<Parsed> {
    let base = basename(filename);
    let parser = all().into_iter().find(|p| p.handles(base))?;
    let result = parser.parse(base, content);
    // Introspection marker — the multi-ecosystem ingest surface reports each
    // manifest it parses (GREEN = parsed N components; RED = malformed manifest)
    // so `nornir test` SEES the ecosystem seam, one row per ecosystem. Feature-
    // gated: a compiled-out `#[inline]` no-op with no nornir dep in a lean build.
    #[cfg(feature = "testmatrix")]
    {
        let detail = match &result {
            Ok(comps) => format!("{base}: {} component(s)", comps.len()),
            Err(e) => format!("{base}: {e}"),
        };
        crate::functional_status(
            &format!("modgunn/manifest-ingest-{}", parser.ecosystem()),
            "parse_manifest",
            result.is_ok(),
            &detail,
        );
    }
    Some(result)
}

fn basename(path: &str) -> &str {
    path.rsplit(['/', '\\']).next().unwrap_or(path)
}

fn comp(ecosystem: &str, name: impl Into<String>, version: impl Into<String>) -> SbomComponent {
    SbomComponent {
        ecosystem: ecosystem.into(),
        name: name.into(),
        version: version.into(),
        license: None,
    }
}

fn comp_lic(
    ecosystem: &str,
    name: impl Into<String>,
    version: impl Into<String>,
    license: Option<String>,
) -> SbomComponent {
    SbomComponent {
        ecosystem: ecosystem.into(),
        name: name.into(),
        version: version.into(),
        license,
    }
}

// ─────────────────────────── shared TOML `[[package]]` ──────────────────────
// Cargo.lock and poetry.lock share the exact `[[package]] name/version` shape,
// so both ecosystems fold through one helper — the model, and the parse, stay
// single-sourced.
fn toml_packages(ecosystem: &str, content: &str) -> Parsed {
    let doc: toml::Value = content
        .parse::<toml::Table>().map(toml::Value::Table)
        .map_err(|e| ParseError(format!("toml: {e}")))?;
    let pkgs = match doc.get("package").and_then(|p| p.as_array()) {
        Some(a) => a,
        None => return Ok(Vec::new()), // no [[package]] table ⇒ empty, not an error
    };
    let mut out = Vec::new();
    for p in pkgs {
        let (name, version) = (
            p.get("name").and_then(|v| v.as_str()),
            p.get("version").and_then(|v| v.as_str()),
        );
        if let (Some(n), Some(v)) = (name, version) {
            out.push(comp(ecosystem, n, v));
        }
    }
    Ok(out)
}

// ───────────────────────────────── cargo ────────────────────────────────────

/// **cargo** — `Cargo.lock` (TOML `[[package]]`). The template every other
/// ecosystem mirrors.
pub struct CargoParser;
impl EcosystemParser for CargoParser {
    fn ecosystem(&self) -> &'static str {
        "cargo"
    }
    fn manifests(&self) -> &'static [&'static str] {
        &["Cargo.lock"]
    }
    fn parse(&self, _filename: &str, content: &str) -> Parsed {
        toml_packages("cargo", content)
    }
}

/// Extract the **dependency-graph edges** from a `Cargo.lock` as `(from, to)` pairs,
/// each node `cargo:<name>@<version>` — the same node format the Engine-B
/// world-snapshot `dep_edges` use, so the two sources land in one table. A
/// `[[package]].dependencies` entry is `"<name>"` or `"<name> <version>"` (an old
/// `"<name> <version> (<source>)"` third field is ignored); a version-less dep is
/// unambiguous — Cargo.lock spells the version only when a name resolves to more
/// than one locked version — so it resolves to that name's sole version. This
/// populates the dep-graph the baddie-detection joins (reachable-vuln / malicious /
/// yank cascade) walk, offline from the lockfile with no SCIP index needed.
pub fn cargo_lock_edges(content: &str) -> Result<Vec<(String, String)>, ParseError> {
    let doc: toml::Value = content
        .parse::<toml::Table>().map(toml::Value::Table)
        .map_err(|e| ParseError(format!("toml: {e}")))?;
    let Some(pkgs) = doc.get("package").and_then(|p| p.as_array()) else {
        return Ok(Vec::new()); // no [[package]] table ⇒ no edges, not an error
    };
    // name → its locked version(s) (usually exactly one), to resolve a version-less dep.
    let mut versions: std::collections::HashMap<&str, Vec<&str>> = std::collections::HashMap::new();
    for p in pkgs {
        if let (Some(n), Some(v)) = (
            p.get("name").and_then(|v| v.as_str()),
            p.get("version").and_then(|v| v.as_str()),
        ) {
            versions.entry(n).or_default().push(v);
        }
    }
    let node = |n: &str, v: &str| format!("cargo:{n}@{v}");
    let mut out = Vec::new();
    for p in pkgs {
        let (Some(name), Some(ver)) = (
            p.get("name").and_then(|v| v.as_str()),
            p.get("version").and_then(|v| v.as_str()),
        ) else {
            continue;
        };
        let Some(deps) = p.get("dependencies").and_then(|d| d.as_array()) else {
            continue;
        };
        let from = node(name, ver);
        for d in deps {
            let Some(spec) = d.as_str() else { continue };
            let mut parts = spec.split_whitespace();
            let Some(dname) = parts.next() else { continue };
            // Explicit version if present, else the dep's sole locked version.
            let resolved = match parts.next() {
                Some(v) => Some(v),
                None => match versions.get(dname) {
                    Some(vs) if vs.len() == 1 => Some(vs[0]),
                    _ => None, // ambiguous/unknown — the lock would have spelled it
                },
            };
            if let Some(dv) = resolved {
                out.push((from.clone(), node(dname, dv)));
            }
        }
    }
    Ok(out)
}

/// Extract the **dependency-graph edges** from a `poetry.lock` as `(from, to)` pairs,
/// nodes `pip:<name>@<version>` (matching the `pip` ecosystem tag the SBOM side
/// uses). poetry locks exactly ONE version per package, so a dependency named in a
/// `[package.dependencies]` table resolves unambiguously to that name's locked
/// version (the constraint value is a range and is ignored — only the dep name
/// matters). An extra/optional dep not present in the lock is skipped.
pub fn poetry_lock_edges(content: &str) -> Result<Vec<(String, String)>, ParseError> {
    let doc: toml::Value = content
        .parse::<toml::Table>().map(toml::Value::Table)
        .map_err(|e| ParseError(format!("toml: {e}")))?;
    let Some(pkgs) = doc.get("package").and_then(|p| p.as_array()) else {
        return Ok(Vec::new());
    };
    // Normalized name (lowercase, `_`→`-`, per PEP 503) → (actual name, version).
    let norm = |n: &str| n.to_ascii_lowercase().replace('_', "-");
    let mut versions: std::collections::HashMap<String, (&str, &str)> =
        std::collections::HashMap::new();
    for p in pkgs {
        if let (Some(n), Some(v)) = (
            p.get("name").and_then(|v| v.as_str()),
            p.get("version").and_then(|v| v.as_str()),
        ) {
            versions.insert(norm(n), (n, v));
        }
    }
    let node = |n: &str, v: &str| format!("pip:{n}@{v}");
    let mut out = Vec::new();
    for p in pkgs {
        let (Some(name), Some(ver)) = (
            p.get("name").and_then(|v| v.as_str()),
            p.get("version").and_then(|v| v.as_str()),
        ) else {
            continue;
        };
        let Some(deps) = p.get("dependencies").and_then(|d| d.as_table()) else {
            continue;
        };
        let from = node(name, ver);
        for dep_name in deps.keys() {
            if let Some((actual, dv)) = versions.get(&norm(dep_name)) {
                out.push((from.clone(), node(actual, dv)));
            }
        }
    }
    Ok(out)
}

/// Extract the **dependency-graph edges** from a `package-lock.json` (lockfileVersion
/// 2/3, the `packages` map) as `(from, to)` pairs, nodes `npm:<name>@<version>`. The
/// `packages` map keys ARE the resolved install paths (`node_modules/a`,
/// `node_modules/a/node_modules/b`), so a dependency `D` of the package at path `P`
/// resolves by npm's own rule — the DEEPEST `…/node_modules/D` that is an ancestor of
/// `P` (nested wins over hoisted). Precise, no range solving. v1 lockfiles (no
/// `packages` map) yield no edges.
pub fn npm_lock_edges(content: &str) -> Result<Vec<(String, String)>, ParseError> {
    let doc: serde_json::Value =
        serde_json::from_str(content).map_err(|e| ParseError(format!("json: {e}")))?;
    let Some(pkgs) = doc.get("packages").and_then(|v| v.as_object()) else {
        return Ok(Vec::new());
    };
    // The installed name for a path is everything after the LAST `node_modules/`.
    let name_of = |path: &str| -> Option<String> {
        if path.is_empty() {
            // The root project — use its declared name if any.
            return pkgs
                .get("")
                .and_then(|r| r.get("name"))
                .and_then(|v| v.as_str())
                .map(String::from);
        }
        path.rsplit("node_modules/")
            .next()
            .filter(|s| !s.is_empty())
            .map(String::from)
    };
    let version_of =
        |path: &str| -> Option<&str> { pkgs.get(path)?.get("version").and_then(|v| v.as_str()) };
    // Resolve dep `d` for the package installed at `path`, walking node_modules up.
    let resolve = |path: &str, d: &str| -> Option<String> {
        let mut cur = path.to_string();
        loop {
            let cand = if cur.is_empty() {
                format!("node_modules/{d}")
            } else {
                format!("{cur}/node_modules/{d}")
            };
            if pkgs.contains_key(&cand) {
                return Some(cand);
            }
            if cur.is_empty() {
                return None;
            }
            // Walk to the parent package path (strip the trailing `/node_modules/<seg>`).
            match cur.rfind("/node_modules/") {
                Some(i) => cur.truncate(i),
                None => cur.clear(),
            }
        }
    };
    let node = |n: &str, v: &str| format!("npm:{n}@{v}");
    let mut out = Vec::new();
    for (path, meta) in pkgs {
        let (Some(from_name), Some(from_ver)) = (name_of(path), version_of(path)) else {
            continue;
        };
        let from = node(&from_name, from_ver);
        // Runtime + optional deps form the resolved graph.
        for key in ["dependencies", "optionalDependencies"] {
            let Some(deps) = meta.get(key).and_then(|d| d.as_object()) else {
                continue;
            };
            for d in deps.keys() {
                if let Some(dep_path) = resolve(path, d) {
                    if let Some(dv) = version_of(&dep_path) {
                        out.push((from.clone(), node(d, dv)));
                    }
                }
            }
        }
    }
    out.sort();
    out.dedup();
    Ok(out)
}

/// Extract dependency-graph edges from a **deps.dev** `…:dependencies` JSON response
/// (Google's cross-ecosystem package graph) as `(from, to)` pairs, nodes
/// `ecosystem:name@version`. The response is `{ nodes: [{versionKey:{system,name,
/// version}}], edges: [{fromNode, toNode}] }`; each edge references node indices. This
/// gives the FULL resolved transitive graph for a package from the registry — richer
/// than a lockfile (which is only the local project's graph) and available for any
/// ecosystem deps.dev covers. Malformed / out-of-range indices are skipped, not a panic.
pub fn depsdev_edges(content: &str) -> Result<Vec<(String, String)>, ParseError> {
    let doc: serde_json::Value =
        serde_json::from_str(content).map_err(|e| ParseError(format!("json: {e}")))?;
    // deps.dev `system` → modgunn ecosystem key. Python MUST be `pip` (NOT `pypi`):
    // the dep_edges node id is `ecosystem:name@version`, and every OTHER edge
    // producer that lands in the SAME `dep_edges` table — `poetry_lock_edges` and the
    // SBOM `PipParser` — tags Python `pip`. Emitting `pypi:` here would give a
    // deps.dev-imported Python subgraph node ids no lockfile/SBOM node ever shares,
    // silently DISCONNECTING it from the baddie-detection graph walk (a whole-
    // ecosystem false negative). Every other label already matches its siblings.
    let eco = |sys: &str| -> Option<&'static str> {
        match sys.to_ascii_uppercase().as_str() {
            "NPM" => Some("npm"),
            "CARGO" => Some("cargo"),
            "MAVEN" => Some("maven"),
            "PYPI" => Some("pip"),
            "GO" => Some("go"),
            "NUGET" => Some("nuget"),
            _ => None,
        }
    };
    let nodes = doc
        .get("nodes")
        .and_then(|v| v.as_array())
        .map(Vec::as_slice)
        .unwrap_or(&[]);
    // Precompute each node's `ecosystem:name@version` string (None if unmappable).
    let node_strs: Vec<Option<String>> = nodes
        .iter()
        .map(|n| {
            let vk = n.get("versionKey")?;
            let sys = vk.get("system").and_then(|v| v.as_str())?;
            let name = vk.get("name").and_then(|v| v.as_str())?;
            let ver = vk.get("version").and_then(|v| v.as_str())?;
            Some(format!("{}:{name}@{ver}", eco(sys)?))
        })
        .collect();
    let mut out = Vec::new();
    if let Some(edges) = doc.get("edges").and_then(|v| v.as_array()) {
        for e in edges {
            let from = e
                .get("fromNode")
                .and_then(|v| v.as_u64())
                .map(|i| i as usize);
            let to = e.get("toNode").and_then(|v| v.as_u64()).map(|i| i as usize);
            if let (Some(fi), Some(ti)) = (from, to) {
                if let (Some(Some(f)), Some(Some(t))) = (node_strs.get(fi), node_strs.get(ti)) {
                    out.push((f.clone(), t.clone()));
                }
            }
        }
    }
    Ok(out)
}

// ────────────────────────────────── pip ─────────────────────────────────────

/// **pip** — `requirements.txt` (pinned `name==version` lines), `poetry.lock`
/// (TOML `[[package]]`), and `Pipfile.lock` (JSON `default`/`develop`).
pub struct PipParser;
impl EcosystemParser for PipParser {
    fn ecosystem(&self) -> &'static str {
        "pip"
    }
    fn manifests(&self) -> &'static [&'static str] {
        &["requirements.txt", "poetry.lock", "Pipfile.lock"]
    }
    fn parse(&self, filename: &str, content: &str) -> Parsed {
        match filename {
            "poetry.lock" => toml_packages("pip", content),
            "Pipfile.lock" => pipfile_lock(content),
            _ => Ok(requirements_txt(content)),
        }
    }
}

/// `requirements.txt`: only the exact `name==version` pins are components (a
/// range/`>=`/unpinned line is not a resolved version). Handles inline extras
/// (`pkg[extra]==1.0`), environment markers (`; python_version<'3.9'`), inline
/// `# comments`, and skips blank / comment / option (`-r`, `--hash`) lines.
fn requirements_txt(content: &str) -> Vec<SbomComponent> {
    let mut out = Vec::new();
    for raw in content.lines() {
        let line = raw.split('#').next().unwrap_or("").trim();
        if line.is_empty() || line.starts_with('-') {
            continue;
        }
        // Drop an environment marker and any inline hash options.
        let spec = line
            .split(';')
            .next()
            .unwrap_or("")
            .split("--")
            .next()
            .unwrap_or("")
            .trim();
        let Some((name, ver)) = spec.split_once("==") else {
            continue;
        };
        // Strip extras: `pkg[extra]` → `pkg`.
        let name = name.split('[').next().unwrap_or(name).trim();
        // PEP 440 `===` (arbitrary equality) splits at the first `==`, leaving a stray
        // leading `=` on the version (`foo===1.0.0custom` → `=1.0.0custom`) that would
        // never match an advisory — strip it. (Versions never begin with `=`.)
        let ver = ver.trim().trim_start_matches('=');
        if !name.is_empty() && !ver.is_empty() {
            out.push(comp("pip", name, ver));
        }
    }
    out
}

/// `Pipfile.lock` (JSON): components live under `default` and `develop`, each a
/// `{ "name": { "version": "==1.2.3" } }` map. The leading `==` is stripped.
fn pipfile_lock(content: &str) -> Parsed {
    let doc: serde_json::Value =
        serde_json::from_str(content).map_err(|e| ParseError(format!("json: {e}")))?;
    let mut out = Vec::new();
    for section in ["default", "develop"] {
        if let Some(map) = doc.get(section).and_then(|v| v.as_object()) {
            for (name, spec) in map {
                if let Some(v) = spec.get("version").and_then(|v| v.as_str()) {
                    out.push(comp("pip", name.as_str(), v.trim_start_matches("==")));
                }
            }
        }
    }
    Ok(out)
}

// ────────────────────────────────── npm ─────────────────────────────────────

/// **npm** — `package-lock.json` (v2/v3 `packages` map, v1 `dependencies`) and
/// `pnpm-lock.yaml` (the `packages:` keys).
pub struct NpmParser;
impl EcosystemParser for NpmParser {
    fn ecosystem(&self) -> &'static str {
        "npm"
    }
    fn manifests(&self) -> &'static [&'static str] {
        &["package-lock.json", "pnpm-lock.yaml"]
    }
    fn parse(&self, filename: &str, content: &str) -> Parsed {
        match filename {
            "pnpm-lock.yaml" => Ok(pnpm_lock(content)),
            _ => package_lock_json(content),
        }
    }
}

/// `package-lock.json`: prefer the lockfileVersion-2/3 `packages` map (keyed by
/// install path, `node_modules/foo` ⇒ name `foo`, scoped kept intact; the `""`
/// root entry is skipped), carrying the declared `license`. Falls back to the
/// v1 flat `dependencies` map when `packages` is absent.
fn package_lock_json(content: &str) -> Parsed {
    let doc: serde_json::Value =
        serde_json::from_str(content).map_err(|e| ParseError(format!("json: {e}")))?;
    let mut out = Vec::new();
    if let Some(pkgs) = doc.get("packages").and_then(|v| v.as_object()) {
        for (path, meta) in pkgs {
            if path.is_empty() {
                continue; // the root project, not a dependency
            }
            // The installed name is everything after the LAST `node_modules/`
            // (nested deps: `node_modules/a/node_modules/b` ⇒ `b`).
            let name = path.rsplit("node_modules/").next().unwrap_or(path);
            let version = meta.get("version").and_then(|v| v.as_str()).unwrap_or("");
            if version.is_empty() {
                continue; // link/workspace entry with no resolved version
            }
            let license = meta
                .get("license")
                .and_then(|v| v.as_str())
                .map(String::from);
            out.push(comp_lic("npm", name, version, license));
        }
    } else if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_object()) {
        // v1 flat map — but a version-conflicted transitive is NESTED under its
        // parent's own `dependencies` (npm v1's dedup-with-conflict layout), so a
        // top-level-only walk drops it (a false negative). Recurse, deduping exact
        // (name, version) pairs the hoist repeats.
        let mut seen = std::collections::HashSet::new();
        collect_v1_deps(deps, &mut seen, &mut out);
    }
    Ok(out)
}

/// Recursively collect npm lockfileVersion-1 `dependencies` — each entry may carry
/// its own nested `dependencies` for a version that conflicted with the hoisted one.
fn collect_v1_deps(
    deps: &serde_json::Map<String, serde_json::Value>,
    seen: &mut std::collections::HashSet<(String, String)>,
    out: &mut Vec<SbomComponent>,
) {
    for (name, meta) in deps {
        if let Some(v) = meta.get("version").and_then(|v| v.as_str()) {
            if seen.insert((name.clone(), v.to_string())) {
                out.push(comp("npm", name.as_str(), v));
            }
        }
        if let Some(nested) = meta.get("dependencies").and_then(|v| v.as_object()) {
            collect_v1_deps(nested, seen, out);
        }
    }
}

/// `pnpm-lock.yaml` (hand-parsed — no YAML dep): the `packages:` block lists one
/// key per resolved package. Handles the two key spellings pnpm has shipped —
/// v6+/v9 `foo@1.2.3` (and quoted `'@scope/foo@1.2.3'`) and v5 `/foo/1.2.3` —
/// plus peer-suffixed keys (`foo@1.2.3(bar@2.0.0)`). The version is the segment
/// after the final `@` (or the trailing `/x.y.z` in v5), the name everything
/// before, so a scoped `@scope/name` survives its leading `@`.
fn pnpm_lock(content: &str) -> Vec<SbomComponent> {
    let mut out = Vec::new();
    let mut in_packages = false;
    for raw in content.lines() {
        let trimmed = raw.trim_end();
        if trimmed.is_empty() {
            continue;
        }
        let indent = raw.len() - raw.trim_start().len();
        // A zero-indent `packages:` header opens the block; any other
        // zero-indent key (snapshots:, settings:, …) closes it.
        if indent == 0 {
            in_packages = trimmed == "packages:";
            continue;
        }
        if !in_packages || indent != 2 || !trimmed.ends_with(':') {
            continue; // only the 2-space package keys, not their nested fields
        }
        let key = trimmed[..trimmed.len() - 1]
            .trim()
            .trim_matches(['\'', '"']);
        if let Some((name, version)) = pnpm_split(key) {
            out.push(comp("npm", name, version));
        }
    }
    out
}

/// Split a pnpm package key into `(name, version)`. Both v5 (`/name/version`,
/// `/@scope/name/version`) and v6+ (`/name@version`, `@scope/name@version`) carry
/// an optional leading `/`; they differ only in whether `@` or `/` separates the
/// version. A `(peer)` suffix is already dropped by the caller-visible `(`.
fn pnpm_split(key: &str) -> Option<(&str, &str)> {
    let key = key.split('(').next().unwrap_or(key); // drop the v6 `(peer)` suffix
    let key = key.strip_prefix('/').unwrap_or(key);
    // Drop the pnpm **v5** peer suffix — v5 separates it with `_` (not the v6 `(…)`),
    // e.g. `react-dom/17.0.2_react@17.0.2`. Left in, the peer's `@` is mistaken for
    // the version separator and pollutes the name (`react-dom/17.0.2_react`) → a
    // false negative on the most common (peer-carrying) packages. Strip from the `_`
    // in the LAST `/`-segment, but ONLY when the pre-`_` part is version-like (starts
    // with a digit), so a `_` inside a package NAME (v6 `@scope/foo_bar@1.0.0`) is
    // never touched.
    let key = if key.contains('/') {
        // The `_`-separated peer suffix exists ONLY in v5 keys (`<name>/<version>_<peer>`),
        // which are `/`-shaped. A v6 unscoped key like `2_foo@1.0.0` has NO `/`, so its
        // `_` is a NAME character — running the strip there truncates a digit-leading
        // unscoped name (`2_foo` → `2`) and DROPS the package (a false negative).
        let seg_start = key.rfind('/').map(|i| i + 1).unwrap_or(0);
        match key[seg_start..].find('_') {
            // pre-`_` part of the last segment is version-like ⇒ it's the v5 peer sep.
            Some(rel)
                if key[seg_start..seg_start + rel].starts_with(|c: char| c.is_ascii_digit()) =>
            {
                &key[..seg_start + rel]
            }
            _ => key,
        }
    } else {
        key
    };
    // v6+: a non-leading `@` before a numeric version separates name/version, so a
    // scoped `@scope/name` keeps its leading `@`.
    if let Some(at) = key.rfind('@').filter(|&i| i > 0) {
        let (name, version) = (&key[..at], &key[at + 1..]);
        if version.starts_with(|c: char| c.is_ascii_digit()) && !name.is_empty() {
            return Some((name, version));
        }
    }
    // v5: the last `/` segment is the version (`@scope/name` survives).
    let (name, version) = key.rsplit_once('/')?;
    (!name.is_empty() && !version.is_empty()).then_some((name, version))
}

// ───────────────────────────────── maven ────────────────────────────────────

/// **maven** — `pom.xml`. Component name is `groupId:artifactId`, version the
/// declared `<version>`. Only `<dependency>` elements with a literal version are
/// emitted (a version managed via `dependencyManagement`/a property is skipped).
pub struct MavenParser;
impl EcosystemParser for MavenParser {
    fn ecosystem(&self) -> &'static str {
        "maven"
    }
    fn manifests(&self) -> &'static [&'static str] {
        &["pom.xml"]
    }
    fn parse(&self, _filename: &str, content: &str) -> Parsed {
        let doc =
            roxmltree::Document::parse(content).map_err(|e| ParseError(format!("xml: {e}")))?;
        let mut out = Vec::new();
        for dep in doc.descendants().filter(|n| n.has_tag_name("dependency")) {
            // A `<dependency>` under `<dependencyManagement>` only PINS a version for
            // descendants to inherit — it is not itself on the classpath. Emitting it
            // flags a managed-but-unused version as present (a false positive that
            // contradicts this parser's contract). `ancestors()` includes `dep`
            // itself, which is a `dependency` not a `dependencyManagement`, so the
            // check is exact.
            if dep
                .ancestors()
                .any(|n| n.has_tag_name("dependencyManagement"))
            {
                continue;
            }
            let child = |tag| {
                dep.children()
                    .find(|c| c.has_tag_name(tag))
                    .and_then(|c| c.text())
                    .map(str::trim)
            };
            let (g, a, v) = (child("groupId"), child("artifactId"), child("version"));
            if let (Some(g), Some(a), Some(v)) = (g, a, v) {
                if !v.is_empty() && !v.starts_with("${") {
                    out.push(comp("maven", format!("{g}:{a}"), v));
                }
            }
        }
        Ok(out)
    }
}

// ────────────────────────────────── go ──────────────────────────────────────

/// **go** — `go.mod` (`require` directives) and `go.sum` (checksummed module
/// versions). Both key by module path + `vX.Y.Z`.
pub struct GoParser;
impl EcosystemParser for GoParser {
    fn ecosystem(&self) -> &'static str {
        "go"
    }
    fn manifests(&self) -> &'static [&'static str] {
        &["go.mod", "go.sum"]
    }
    fn parse(&self, filename: &str, content: &str) -> Parsed {
        Ok(if filename == "go.sum" {
            go_sum(content)
        } else {
            go_mod(content)
        })
    }
}

/// `go.mod`: `require ( … )` blocks plus single-line `require mod version`. An
/// `// indirect` trailing comment is ignored (the module is still a component).
fn go_mod(content: &str) -> Vec<SbomComponent> {
    let mut out = Vec::new();
    let mut in_block = false;
    for raw in content.lines() {
        let line = raw.split("//").next().unwrap_or("").trim();
        if line.is_empty() {
            continue;
        }
        if in_block {
            if line == ")" {
                in_block = false;
            } else if let Some((m, v)) = go_pair(line) {
                out.push(comp("go", m, v));
            }
            continue;
        }
        if line == "require (" {
            in_block = true;
        } else if let Some(rest) = line.strip_prefix("require ") {
            if let Some((m, v)) = go_pair(rest.trim()) {
                out.push(comp("go", m, v));
            }
        }
    }
    out
}

/// `go.sum`: `module version hash` and `module version/go.mod hash` lines. The
/// `/go.mod` variant is folded onto the module version and duplicates deduped,
/// so each `(module, version)` appears once.
fn go_sum(content: &str) -> Vec<SbomComponent> {
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    for raw in content.lines() {
        let mut it = raw.split_whitespace();
        let (Some(module), Some(version)) = (it.next(), it.next()) else {
            continue;
        };
        let version = version.strip_suffix("/go.mod").unwrap_or(version);
        if seen.insert((module.to_string(), version.to_string())) {
            out.push(comp("go", module, version));
        }
    }
    out
}

/// One `module vX.Y.Z` pair (a go.mod require line, minus any comment).
fn go_pair(line: &str) -> Option<(&str, &str)> {
    let mut it = line.split_whitespace();
    let (module, version) = (it.next()?, it.next()?);
    (version.starts_with('v')).then_some((module, version))
}

// ───────────────────────────────── nuget ────────────────────────────────────

/// **nuget** — `packages.lock.json` (the resolved lock), `*.csproj`
/// (`<PackageReference Include=.. Version=..>`), and the classic `packages.config`
/// (`<package id=.. version=.. />`) still shipped by non-SDK-style projects.
pub struct NugetParser;
impl EcosystemParser for NugetParser {
    fn ecosystem(&self) -> &'static str {
        "nuget"
    }
    fn manifests(&self) -> &'static [&'static str] {
        &["packages.lock.json", "packages.config"]
    }
    fn handles(&self, filename: &str) -> bool {
        filename == "packages.lock.json"
            || filename == "packages.config"
            || filename.ends_with(".csproj")
    }
    fn parse(&self, filename: &str, content: &str) -> Parsed {
        if filename.ends_with(".csproj") {
            csproj(content)
        } else if filename == "packages.config" {
            packages_config(content)
        } else {
            packages_lock_json(content)
        }
    }
}

/// Classic `packages.config`: `<packages><package id="X" version="Y"
/// targetFramework=".."/></packages>`. Each `<package>` is a resolved, exactly
/// pinned dependency — the pre-SDK NuGet lockfile. A `<package>` missing `id` or
/// `version` is skipped (malformed / not a real dependency), never emitted as an
/// all-versions match. Deduped by `(id, version)` (a package can repeat per
/// framework, like the JSON lock).
fn packages_config(content: &str) -> Parsed {
    let doc = roxmltree::Document::parse(content).map_err(|e| ParseError(format!("xml: {e}")))?;
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    for p in doc.descendants().filter(|n| n.has_tag_name("package")) {
        if let (Some(id), Some(version)) = (p.attribute("id"), p.attribute("version")) {
            if !id.is_empty()
                && !version.is_empty()
                && seen.insert((id.to_string(), version.to_string()))
            {
                out.push(comp("nuget", id, version));
            }
        }
    }
    Ok(out)
}

/// `packages.lock.json`: `dependencies` → per-framework map → per-package
/// `{ "resolved": "1.2.3" }`. Deduped across frameworks by `(name, version)`.
fn packages_lock_json(content: &str) -> Parsed {
    let doc: serde_json::Value =
        serde_json::from_str(content).map_err(|e| ParseError(format!("json: {e}")))?;
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    if let Some(frameworks) = doc.get("dependencies").and_then(|v| v.as_object()) {
        for pkgs in frameworks.values().filter_map(|f| f.as_object()) {
            for (name, meta) in pkgs {
                if let Some(v) = meta.get("resolved").and_then(|v| v.as_str()) {
                    if seen.insert((name.clone(), v.to_string())) {
                        out.push(comp("nuget", name.as_str(), v));
                    }
                }
            }
        }
    }
    Ok(out)
}

/// `*.csproj`: every `<PackageReference Include="X" Version="Y" />`. A reference
/// without a literal `Version` (central package management) is skipped.
fn csproj(content: &str) -> Parsed {
    let doc = roxmltree::Document::parse(content).map_err(|e| ParseError(format!("xml: {e}")))?;
    let mut out = Vec::new();
    for r in doc
        .descendants()
        .filter(|n| n.has_tag_name("PackageReference"))
    {
        let name = r.attribute("Include");
        // Version can be an attribute or a child element.
        let version = r.attribute("Version").map(str::to_string).or_else(|| {
            r.children()
                .find(|c| c.has_tag_name("Version"))
                .and_then(|c| c.text())
                .map(|t| t.trim().to_string())
        });
        if let (Some(name), Some(version)) = (name, version) {
            if !version.is_empty() && !version.starts_with("$(") {
                out.push(comp("nuget", name, version));
            }
        }
    }
    Ok(out)
}

// ────────────────────────────────── gem ─────────────────────────────────────

/// **gem** — `Gemfile.lock`. The `GEM … specs:` block lists each resolved gem
/// as `    name (version)` at 4-space indent; the 6-space-indented lines beneath
/// are that gem's *dependency constraints* (not resolved versions) and skipped.
pub struct GemParser;
impl EcosystemParser for GemParser {
    fn ecosystem(&self) -> &'static str {
        "gem"
    }
    fn manifests(&self) -> &'static [&'static str] {
        &["Gemfile.lock"]
    }
    fn parse(&self, _filename: &str, content: &str) -> Parsed {
        Ok(gemfile_lock(content))
    }
}

fn gemfile_lock(content: &str) -> Vec<SbomComponent> {
    let mut out = Vec::new();
    let mut in_specs = false;
    for raw in content.lines() {
        let indent = raw.len() - raw.trim_start().len();
        let line = raw.trim();
        if indent <= 2 {
            // Section header (`GEM`, `specs:`, `PLATFORMS`, …) at 0/2 indent.
            in_specs = line == "specs:";
            continue;
        }
        if !in_specs || indent != 4 {
            continue; // 6-indent dependency constraints, or another block
        }
        // `name (version)` — version wrapped in parens, no constraint operators.
        if let Some((name, rest)) = line.split_once(" (") {
            if let Some(version) = rest.strip_suffix(')') {
                if !version.contains(['<', '>', '=', '~']) {
                    // Strip a platform suffix on a native gem — bundler locks
                    // `nokogiri (1.16.5-x86_64-linux)`. RubyGems versions never
                    // contain `-` (a prerelease uses dots: `1.0.0.rc1`), so a `-`
                    // is ALWAYS the platform. Left in, the version reads
                    // `1.16.5-x86_64-linux` and never matches an advisory keyed on
                    // `1.16.5` — a silent false negative on the most common native
                    // gems (nokogiri/sqlite3/…).
                    let version = version.split('-').next().unwrap_or(version);
                    out.push(comp("gem", name, version));
                }
            }
        }
    }
    out
}

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

    fn names(v: &[SbomComponent]) -> Vec<(&str, &str, &str)> {
        v.iter()
            .map(|c| (c.ecosystem.as_str(), c.name.as_str(), c.version.as_str()))
            .collect()
    }

    #[test]
    fn cargo_lock_parses_packages() {
        let toml = "\
[[package]]
name = \"serde\"
version = \"1.0.203\"

[[package]]
name = \"anyhow\"
version = \"1.0.86\"
";
        let got = CargoParser.parse("Cargo.lock", toml).unwrap();
        assert_eq!(
            names(&got),
            vec![("cargo", "serde", "1.0.203"), ("cargo", "anyhow", "1.0.86")]
        );
    }

    #[test]
    fn cargo_lock_extracts_dep_edges() {
        // `app` depends on `serde` (version-less, unambiguous) and `left-pad 1.0.0`
        // (explicit, because two versions of left-pad are locked). Nodes are
        // `cargo:name@version`, matching the world-snapshot dep_edges format.
        let toml = "\
[[package]]
name = \"app\"
version = \"0.1.0\"
dependencies = [\"serde\", \"left-pad 1.0.0\"]

[[package]]
name = \"serde\"
version = \"1.0.203\"

[[package]]
name = \"left-pad\"
version = \"1.0.0\"

[[package]]
name = \"left-pad\"
version = \"2.0.0\"
";
        let mut edges = cargo_lock_edges(toml).unwrap();
        edges.sort();
        assert_eq!(
            edges,
            vec![
                ("cargo:app@0.1.0".to_string(), "cargo:left-pad@1.0.0".to_string()),
                ("cargo:app@0.1.0".to_string(), "cargo:serde@1.0.203".to_string()),
            ],
            "version-less serde resolves to its sole version; ambiguous left-pad uses the explicit one"
        );
    }

    #[test]
    fn cargo_lock_no_dependencies_is_empty_not_error() {
        let toml = "[[package]]\nname = \"solo\"\nversion = \"1.0.0\"\n";
        assert!(cargo_lock_edges(toml).unwrap().is_empty());
    }

    #[test]
    fn npm_lock_edges_resolves_nested_vs_hoisted() {
        // Root project `app` deps on `a` and `ms` (hoisted). `a` deps on `ms`, but a
        // CONFLICTED `ms@1.0.0` is nested under `a` → `a`'s edge must resolve to the
        // nested 1.0.0, while the root's `ms` resolves to the hoisted 2.1.3. Scoped
        // `@scope/util` too.
        let json = r#"{
          "lockfileVersion": 3,
          "packages": {
            "": { "name": "app", "version": "0.1.0",
                  "dependencies": { "a": "^1", "ms": "^2", "@scope/util": "^1" } },
            "node_modules/a": { "version": "1.5.0", "dependencies": { "ms": "^1" } },
            "node_modules/a/node_modules/ms": { "version": "1.0.0" },
            "node_modules/ms": { "version": "2.1.3" },
            "node_modules/@scope/util": { "version": "3.0.0" }
          }
        }"#;
        let mut edges = npm_lock_edges(json).unwrap();
        edges.sort();
        assert_eq!(
            edges,
            vec![
                ("npm:a@1.5.0".to_string(), "npm:ms@1.0.0".to_string()), // a → nested ms
                (
                    "npm:app@0.1.0".to_string(),
                    "npm:@scope/util@3.0.0".to_string()
                ),
                ("npm:app@0.1.0".to_string(), "npm:a@1.5.0".to_string()),
                ("npm:app@0.1.0".to_string(), "npm:ms@2.1.3".to_string()), // root → hoisted ms
            ]
        );
    }

    #[test]
    fn npm_lock_edges_v1_no_packages_is_empty() {
        assert!(
            npm_lock_edges(r#"{ "lockfileVersion": 1, "dependencies": {} }"#)
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn depsdev_extracts_edges_from_node_indices() {
        let json = r#"{
          "nodes": [
            { "versionKey": { "system": "NPM", "name": "app", "version": "1.0.0" }, "relation": "SELF" },
            { "versionKey": { "system": "NPM", "name": "lodash", "version": "4.17.21" }, "relation": "DIRECT" },
            { "versionKey": { "system": "NPM", "name": "ms", "version": "2.1.3" }, "relation": "INDIRECT" }
          ],
          "edges": [
            { "fromNode": 0, "toNode": 1, "requirement": "^4.0.0" },
            { "fromNode": 1, "toNode": 2, "requirement": "^2.0.0" },
            { "fromNode": 0, "toNode": 9, "requirement": "bad-index" }
          ]
        }"#;
        let mut edges = depsdev_edges(json).unwrap();
        edges.sort();
        assert_eq!(
            edges,
            vec![
                (
                    "npm:app@1.0.0".to_string(),
                    "npm:lodash@4.17.21".to_string()
                ),
                ("npm:lodash@4.17.21".to_string(), "npm:ms@2.1.3".to_string()),
            ],
            "the out-of-range edge index is skipped, not a panic"
        );
    }

    /// RED-when-reverted: a deps.dev PYPI subgraph must use the `pip:` node-id prefix
    /// (NOT `pypi:`) so it shares node ids with `poetry_lock_edges` / the SBOM
    /// PipParser in the shared `dep_edges` table. A `pypi:` prefix silently
    /// disconnects the Python subgraph from the baddie-detection graph walk.
    #[test]
    fn depsdev_python_uses_pip_prefix_matching_lockfiles() {
        let json = r#"{
          "nodes": [
            { "versionKey": { "system": "PYPI", "name": "requests", "version": "2.31.0" } },
            { "versionKey": { "system": "PYPI", "name": "urllib3", "version": "2.0.7" } }
          ],
          "edges": [ { "fromNode": 0, "toNode": 1 } ]
        }"#;
        let edges = depsdev_edges(json).unwrap();
        assert_eq!(
            edges,
            vec![(
                "pip:requests@2.31.0".to_string(),
                "pip:urllib3@2.0.7".to_string()
            )],
            "deps.dev Python nodes must match the `pip:` prefix the lockfile importers emit"
        );
    }

    #[test]
    fn poetry_lock_extracts_dep_edges() {
        // requests depends on urllib3 + certifi (each locked to one version); the
        // constraint value is ignored, name resolved to its locked version. Name
        // normalization (case/`_`) is applied. Nodes are `pip:name@version`.
        let toml = r#"
[[package]]
name = "requests"
version = "2.31.0"

[package.dependencies]
urllib3 = ">=1.21.1,<3"
Certifi = "*"

[[package]]
name = "urllib3"
version = "2.0.7"

[[package]]
name = "certifi"
version = "2023.7.22"
"#;
        let mut edges = poetry_lock_edges(toml).unwrap();
        edges.sort();
        assert_eq!(
            edges,
            vec![
                (
                    "pip:requests@2.31.0".to_string(),
                    "pip:certifi@2023.7.22".to_string()
                ),
                (
                    "pip:requests@2.31.0".to_string(),
                    "pip:urllib3@2.0.7".to_string()
                ),
            ],
            "deps resolve to locked versions; `Certifi` normalizes to `certifi`"
        );
    }

    #[test]
    fn dispatch_by_basename_selects_ecosystem() {
        let out = parse_manifest("some/dir/go.mod", "require example.com/x v1.2.3\n")
            .unwrap()
            .unwrap();
        assert_eq!(names(&out), vec![("go", "example.com/x", "v1.2.3")]);
        assert!(parse_manifest("unknown.file", "").is_none());
    }

    #[test]
    fn pip_requirements_pins_only() {
        let req = "\
# a comment
requests==2.31.0
flask[async]==3.0.0  # inline comment
urllib3>=1.26   # a range, not a pin — skipped
django==4.2.1 ; python_version >= '3.9'
-r other.txt
";
        let got = PipParser.parse("requirements.txt", req).unwrap();
        assert_eq!(
            names(&got),
            vec![
                ("pip", "requests", "2.31.0"),
                ("pip", "flask", "3.0.0"),
                ("pip", "django", "4.2.1")
            ]
        );
    }

    /// RED-when-reverted: PEP 440 `===` (arbitrary equality) must not leave a stray
    /// `=` on the version (`foo===1.0.0custom` → `1.0.0custom`, not `=1.0.0custom`),
    /// which would never match an advisory.
    #[test]
    fn pip_requirements_triple_equals() {
        let got = PipParser
            .parse("requirements.txt", "foo===1.0.0custom\n")
            .unwrap();
        assert_eq!(names(&got), vec![("pip", "foo", "1.0.0custom")]);
    }

    #[test]
    fn pip_pipfile_lock_strips_pin_operator() {
        let json = r#"{"default":{"requests":{"version":"==2.31.0"}},"develop":{"pytest":{"version":"==8.0.0"}}}"#;
        let got = PipParser.parse("Pipfile.lock", json).unwrap();
        let mut n = names(&got);
        n.sort();
        assert_eq!(
            n,
            vec![("pip", "pytest", "8.0.0"), ("pip", "requests", "2.31.0")]
        );
    }

    #[test]
    fn pip_poetry_lock_toml() {
        let toml = "[[package]]\nname = \"click\"\nversion = \"8.1.7\"\n";
        let got = PipParser.parse("poetry.lock", toml).unwrap();
        assert_eq!(names(&got), vec![("pip", "click", "8.1.7")]);
    }

    #[test]
    fn npm_package_lock_v3_packages_with_license() {
        let json = r#"{
          "lockfileVersion": 3,
          "packages": {
            "": {"name": "root", "version": "1.0.0"},
            "node_modules/left-pad": {"version": "1.3.0", "license": "WTFPL"},
            "node_modules/@scope/util": {"version": "2.0.1", "license": "MIT"},
            "node_modules/a/node_modules/nested": {"version": "0.0.1"}
          }
        }"#;
        let got = NpmParser.parse("package-lock.json", json).unwrap();
        let mut rows: Vec<_> = got
            .iter()
            .map(|c| (c.name.as_str(), c.version.as_str(), c.license.clone()))
            .collect();
        rows.sort();
        assert_eq!(
            rows,
            vec![
                ("@scope/util", "2.0.1", Some("MIT".into())),
                ("left-pad", "1.3.0", Some("WTFPL".into())),
                ("nested", "0.0.1", None),
            ]
        );
    }

    /// RED-when-reverted: npm lockfileVersion-1 nests a version-conflicted transitive
    /// under its parent's own `dependencies`. A top-level-only walk drops it (a false
    /// negative — the nested `ms@1.0.0` is a real installed dep). Recursion captures
    /// it; the hoisted duplicate `debug@4.3.0` is deduped, not doubled.
    #[test]
    fn npm_package_lock_v1_recurses_nested_dependencies() {
        let json = r#"{
          "lockfileVersion": 1,
          "dependencies": {
            "debug": { "version": "4.3.0", "dependencies": {
              "ms": { "version": "1.0.0" }
            } },
            "ms": { "version": "2.1.3" }
          }
        }"#;
        let got = NpmParser.parse("package-lock.json", json).unwrap();
        let mut rows: Vec<_> = got
            .iter()
            .map(|c| (c.name.as_str(), c.version.as_str()))
            .collect();
        rows.sort();
        assert_eq!(
            rows,
            vec![("debug", "4.3.0"), ("ms", "1.0.0"), ("ms", "2.1.3")],
            "the nested conflicting ms@1.0.0 is captured alongside the hoisted ms@2.1.3"
        );
    }

    #[test]
    fn npm_pnpm_lock_v6_and_scoped_and_peer() {
        let yaml = "\
lockfileVersion: '6.0'

packages:

  /left-pad@1.3.0:
    resolution: {integrity: sha512-x}
    dev: false

  '@scope/util@2.0.1':
    resolution: {integrity: sha512-y}

  /react-dom@18.2.0(react@18.2.0):
    resolution: {integrity: sha512-z}

snapshots:
  /should-not-appear@9.9.9:
    resolution: {integrity: sha512-w}
";
        let got = NpmParser.parse("pnpm-lock.yaml", yaml).unwrap();
        let mut n = names(&got);
        n.sort();
        assert_eq!(
            n,
            vec![
                ("npm", "@scope/util", "2.0.1"),
                ("npm", "left-pad", "1.3.0"),
                ("npm", "react-dom", "18.2.0"),
            ]
        );
    }

    /// RED-when-reverted: pnpm **v5** separates the peer suffix with `_` (not the v6
    /// `(…)`). Its `@` must not be mistaken for the version separator — the name
    /// stays `react-dom`, not `react-dom/17.0.2_react`. A scoped v5 peer key and a
    /// package NAME containing `_` (v6) are both handled without corruption.
    #[test]
    fn npm_pnpm_lock_v5_peer_suffix() {
        let yaml = "\
lockfileVersion: '5.4'

packages:

  /react-dom/17.0.2_react@17.0.2:
    resolution: {integrity: sha512-x}
  /@babel/core/7.0.0_@babel+helper@7.0.0:
    resolution: {integrity: sha512-y}
  /left-pad/1.3.0:
    resolution: {integrity: sha512-z}
  /some_pkg@1.0.0:
    resolution: {integrity: sha512-w}
";
        let got = NpmParser.parse("pnpm-lock.yaml", yaml).unwrap();
        let mut n = names(&got);
        n.sort();
        assert_eq!(
            n,
            vec![
                ("npm", "@babel/core", "7.0.0"), // scoped v5 peer key
                ("npm", "left-pad", "1.3.0"),    // v5 non-peer
                ("npm", "react-dom", "17.0.2"),  // v5 peer — name not polluted
                ("npm", "some_pkg", "1.0.0"),    // v6 name with `_` untouched
            ]
        );
    }

    /// RED-when-reverted: an unscoped npm name that starts with a digit AND contains
    /// `_` (`2_foo`) in a v6 key must not be mistaken for a v5 `version_peer` suffix.
    /// The v5 peer strip is `/`-shaped; a v6 unscoped key has no `/`, so its `_` is a
    /// name character. Reverting the `contains('/')` gate truncates `2_foo`→`2`,
    /// `@`-split fails, and the package is silently DROPPED (a false negative).
    #[test]
    fn npm_pnpm_v6_digit_leading_underscore_name_not_dropped() {
        let yaml = "\
packages:

  /2_foo@1.0.0:
    resolution: {integrity: sha512-x}
  9_lives@2.3.4:
    resolution: {integrity: sha512-y}
";
        let got = NpmParser.parse("pnpm-lock.yaml", yaml).unwrap();
        let mut n = names(&got);
        n.sort();
        assert_eq!(
            n,
            vec![("npm", "2_foo", "1.0.0"), ("npm", "9_lives", "2.3.4")]
        );
    }

    /// The classic pre-SDK NuGet `packages.config` (`<package id=.. version=.. />`)
    /// is a resolved, exactly-pinned dependency list. Not handling it silently drops
    /// every dependency of a non-SDK-style .NET project (a false negative). Deduped
    /// across repeated framework entries; a `<package>` missing id/version is skipped.
    #[test]
    fn nuget_packages_config_classic_format() {
        let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<packages>
  <package id="Newtonsoft.Json" version="13.0.1" targetFramework="net472" />
  <package id="EntityFramework" version="6.4.4" targetFramework="net472" />
  <package id="Newtonsoft.Json" version="13.0.1" targetFramework="net48" />
  <package id="Broken" />
</packages>"#;
        let got = NugetParser.parse("packages.config", xml).unwrap();
        let mut n = names(&got);
        n.sort();
        assert_eq!(
            n,
            vec![
                ("nuget", "EntityFramework", "6.4.4"),
                ("nuget", "Newtonsoft.Json", "13.0.1"), // deduped across frameworks
            ]
        );
    }

    #[test]
    fn maven_pom_group_artifact_version() {
        let xml = r#"<project>
          <dependencies>
            <dependency>
              <groupId>com.google.guava</groupId>
              <artifactId>guava</artifactId>
              <version>33.0.0-jre</version>
            </dependency>
            <dependency>
              <groupId>org.managed</groupId>
              <artifactId>managed</artifactId>
              <version>${managed.version}</version>
            </dependency>
          </dependencies>
        </project>"#;
        let got = MavenParser.parse("pom.xml", xml).unwrap();
        assert_eq!(
            names(&got),
            vec![("maven", "com.google.guava:guava", "33.0.0-jre")]
        );
    }

    /// RED-when-reverted: a `<dependencyManagement>` entry with a LITERAL version
    /// only pins a version for descendants — it is not on the classpath, so emitting
    /// it flags a managed-but-unused version as present (a false positive that
    /// breaks this parser's documented contract). Only the real `<dependencies>` are
    /// components. (The `${property}` skip was already covered; a literal-version
    /// depMgmt block was not.)
    #[test]
    fn maven_skips_literal_dependency_management() {
        let xml = r#"<project>
          <dependencyManagement>
            <dependencies>
              <dependency>
                <groupId>log4j</groupId>
                <artifactId>log4j</artifactId>
                <version>1.2.17</version>
              </dependency>
            </dependencies>
          </dependencyManagement>
          <dependencies>
            <dependency>
              <groupId>com.google.guava</groupId>
              <artifactId>guava</artifactId>
              <version>33.0.0-jre</version>
            </dependency>
          </dependencies>
        </project>"#;
        let got = MavenParser.parse("pom.xml", xml).unwrap();
        assert_eq!(
            names(&got),
            vec![("maven", "com.google.guava:guava", "33.0.0-jre")],
            "managed-only log4j 1.2.17 must NOT be emitted"
        );
    }

    #[test]
    fn go_mod_require_block_and_single() {
        let gomod = "\
module example.com/me

go 1.21

require (
    github.com/pkg/errors v0.9.1
    golang.org/x/net v0.17.0 // indirect
)

require github.com/single/dep v1.4.0
";
        let got = GoParser.parse("go.mod", gomod).unwrap();
        assert_eq!(
            names(&got),
            vec![
                ("go", "github.com/pkg/errors", "v0.9.1"),
                ("go", "golang.org/x/net", "v0.17.0"),
                ("go", "github.com/single/dep", "v1.4.0"),
            ]
        );
    }

    #[test]
    fn go_sum_dedups_gomod_variant() {
        let gosum = "\
github.com/pkg/errors v0.9.1 h1:hash=
github.com/pkg/errors v0.9.1/go.mod h1:hash2=
golang.org/x/net v0.17.0 h1:hash3=
";
        let got = GoParser.parse("go.sum", gosum).unwrap();
        assert_eq!(
            names(&got),
            vec![
                ("go", "github.com/pkg/errors", "v0.9.1"),
                ("go", "golang.org/x/net", "v0.17.0")
            ]
        );
    }

    #[test]
    fn nuget_packages_lock_resolved() {
        let json = r#"{
          "version": 1,
          "dependencies": {
            "net6.0": {
              "Newtonsoft.Json": {"type": "Direct", "resolved": "13.0.3"},
              "Serilog": {"type": "Transitive", "resolved": "3.1.1"}
            }
          }
        }"#;
        let got = NugetParser.parse("packages.lock.json", json).unwrap();
        let mut n = names(&got);
        n.sort();
        assert_eq!(
            n,
            vec![
                ("nuget", "Newtonsoft.Json", "13.0.3"),
                ("nuget", "Serilog", "3.1.1")
            ]
        );
    }

    #[test]
    fn nuget_csproj_package_references() {
        let xml = r#"<Project Sdk="Microsoft.NET.Sdk">
          <ItemGroup>
            <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
            <PackageReference Include="Managed" />
          </ItemGroup>
        </Project>"#;
        let got = NugetParser.parse("app.csproj", xml).unwrap();
        assert_eq!(names(&got), vec![("nuget", "Newtonsoft.Json", "13.0.3")]);
        assert!(NugetParser.handles("app.csproj"));
    }

    #[test]
    fn gem_gemfile_lock_specs_only() {
        let lock = "\
GEM
  remote: https://rubygems.org/
  specs:
    rake (13.0.6)
    rails (7.1.0)
      actioncable (= 7.1.0)
      activesupport (>= 5.0)

PLATFORMS
  ruby

DEPENDENCIES
  rails
";
        let got = GemParser.parse("Gemfile.lock", lock).unwrap();
        assert_eq!(
            names(&got),
            vec![("gem", "rake", "13.0.6"), ("gem", "rails", "7.1.0")]
        );
    }

    /// RED-when-reverted: a native gem is locked with a platform suffix —
    /// `nokogiri (1.16.5-x86_64-linux)`. RubyGems versions never contain `-`, so the
    /// suffix is the platform and must be stripped; left in, the `1.16.5-x86_64-linux`
    /// version never matches an advisory keyed on `1.16.5` (a false negative on the
    /// most common native gems).
    #[test]
    fn gem_strips_platform_suffix_from_native_gems() {
        let lock = "\
GEM
  remote: https://rubygems.org/
  specs:
    nokogiri (1.16.5-x86_64-linux)
    sqlite3 (1.7.3-aarch64-linux)
    rake (13.0.6)
";
        let got = GemParser.parse("Gemfile.lock", lock).unwrap();
        assert_eq!(
            names(&got),
            vec![
                ("gem", "nokogiri", "1.16.5"),
                ("gem", "sqlite3", "1.7.3"),
                ("gem", "rake", "13.0.6"),
            ],
            "platform suffix stripped so the version joins to rubygems advisories"
        );
    }

    /// The parsed components feed the SAME `sbom` emit — a cross-ecosystem SBOM
    /// gets correctly-tagged purls with no ecosystem-specific glue.
    #[test]
    fn parsed_components_feed_sbom_emit() {
        let mut comps = CargoParser
            .parse(
                "Cargo.lock",
                "[[package]]\nname=\"serde\"\nversion=\"1.0.0\"\n",
            )
            .unwrap();
        comps.extend(
            GoParser
                .parse("go.mod", "require example.com/x v1.0.0\n")
                .unwrap(),
        );
        let input = crate::sbom::SbomInput {
            subject: "poly".into(),
            components: comps,
            vulns: vec![],
        };
        let out = crate::sbom::to_cyclonedx(&input);
        assert_eq!(out["components"][0]["purl"], "pkg:cargo/serde@1.0.0");
        assert_eq!(out["components"][1]["purl"], "pkg:go/example.com/x@v1.0.0");
    }
}