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