use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LockNode {
pub ecosystem: String,
pub name: String,
pub version: String,
pub line: Option<usize>,
pub direct: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockfileKind {
PackageLockJson,
CargoLock,
GoSum,
PoetryLock,
}
pub fn identify_lockfile(path: &Path) -> Option<LockfileKind> {
let name = path.file_name()?.to_str()?.to_ascii_lowercase();
match name.as_str() {
"package-lock.json" => Some(LockfileKind::PackageLockJson),
"cargo.lock" => Some(LockfileKind::CargoLock),
"go.sum" => Some(LockfileKind::GoSum),
"poetry.lock" => Some(LockfileKind::PoetryLock),
_ => None,
}
}
pub fn parse_lockfile(path: &Path, content: &str) -> Vec<LockNode> {
match identify_lockfile(path) {
Some(LockfileKind::PackageLockJson) => parse_package_lock_json(content),
Some(LockfileKind::CargoLock) => parse_cargo_lock(content),
Some(LockfileKind::GoSum) => parse_go_sum(content),
Some(LockfileKind::PoetryLock) => parse_poetry_lock(content),
None => Vec::new(),
}
}
fn dedupe(mut refs: Vec<LockNode>) -> Vec<LockNode> {
use std::collections::HashMap;
let mut seen: HashMap<(String, String, String), LockNode> = HashMap::new();
for r in refs.drain(..) {
let key = (r.ecosystem.clone(), r.name.clone(), r.version.clone());
match seen.get(&key) {
None => {
seen.insert(key, r);
}
Some(prev) => {
if r.direct && !prev.direct {
seen.insert(key, r);
}
}
}
}
let mut out: Vec<_> = seen.into_values().collect();
out.sort_by(|a, b| {
a.ecosystem
.cmp(&b.ecosystem)
.then(a.name.cmp(&b.name))
.then(a.version.cmp(&b.version))
});
out
}
pub fn parse_package_lock_json(content: &str) -> Vec<LockNode> {
let json: serde_json::Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut refs: Vec<LockNode> = Vec::new();
let line_of_needle = |needle: &str| -> Option<usize> {
for (idx, line) in content.lines().enumerate() {
if line.contains(needle) {
return Some(idx + 1);
}
}
None
};
if let Some(packages) = json.get("packages").and_then(|v| v.as_object()) {
for (path, entry) in packages {
if path.is_empty() {
continue;
}
let entry_obj = match entry.as_object() {
Some(o) => o,
None => continue,
};
let version = match entry_obj.get("version").and_then(|v| v.as_str()) {
Some(v) => v.to_string(),
None => continue,
};
if entry_obj
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
continue;
}
let name = match path.rsplit_once("node_modules/") {
Some((_, tail)) if !tail.is_empty() => tail.to_string(),
_ => continue,
};
let direct = path.matches("node_modules/").count() == 1;
let line = line_of_needle(&format!("\"{}\":", path));
refs.push(LockNode {
ecosystem: "npm".to_string(),
name,
version,
line,
direct,
});
}
return dedupe(refs);
}
if let Some(deps) = json.get("dependencies").and_then(|v| v.as_object()) {
walk_v1_deps(deps, true, &mut refs, content);
}
dedupe(refs)
}
fn walk_v1_deps(
deps: &serde_json::Map<String, serde_json::Value>,
direct: bool,
out: &mut Vec<LockNode>,
raw: &str,
) {
for (name, entry) in deps {
let obj = match entry.as_object() {
Some(o) => o,
None => continue,
};
if let Some(version) = obj.get("version").and_then(|v| v.as_str()) {
let needle = format!("\"{}\":", name);
let line = raw
.lines()
.enumerate()
.find(|(_, l)| l.contains(&needle))
.map(|(i, _)| i + 1);
out.push(LockNode {
ecosystem: "npm".to_string(),
name: name.clone(),
version: version.to_string(),
line,
direct,
});
}
if let Some(nested) = obj.get("dependencies").and_then(|v| v.as_object()) {
walk_v1_deps(nested, false, out, raw);
}
}
}
pub fn parse_cargo_lock(content: &str) -> Vec<LockNode> {
let mut refs: Vec<LockNode> = Vec::new();
let mut in_package = false;
let mut cur_name: Option<String> = None;
let mut cur_version: Option<String> = None;
let mut cur_line: Option<usize> = None;
let mut cur_has_source = false;
let flush = |cur_name: &mut Option<String>,
cur_version: &mut Option<String>,
cur_line: &mut Option<usize>,
cur_has_source: &mut bool,
refs: &mut Vec<LockNode>| {
if let (Some(n), Some(v)) = (cur_name.take(), cur_version.take()) {
if *cur_has_source {
refs.push(LockNode {
ecosystem: "crates".to_string(),
name: n,
version: v,
line: cur_line.take(),
direct: false,
});
}
}
*cur_line = None;
*cur_has_source = false;
};
for (lineno, raw) in content.lines().enumerate() {
let text = raw.trim_end();
let stripped = text.trim_start();
if stripped == "[[package]]" {
flush(
&mut cur_name,
&mut cur_version,
&mut cur_line,
&mut cur_has_source,
&mut refs,
);
in_package = true;
continue;
}
if stripped.starts_with('[') && stripped != "[[package]]" {
flush(
&mut cur_name,
&mut cur_version,
&mut cur_line,
&mut cur_has_source,
&mut refs,
);
in_package = false;
continue;
}
if !in_package {
continue;
}
if let Some(rest) = stripped.strip_prefix("name") {
if let Some(v) = extract_toml_string(rest) {
cur_name = Some(v);
cur_line = Some(lineno + 1);
continue;
}
}
if let Some(rest) = stripped.strip_prefix("version") {
if let Some(v) = extract_toml_string(rest) {
cur_version = Some(v);
continue;
}
}
if stripped.starts_with("source") && stripped.contains('=') {
cur_has_source = true;
}
}
flush(
&mut cur_name,
&mut cur_version,
&mut cur_line,
&mut cur_has_source,
&mut refs,
);
dedupe(refs)
}
fn extract_toml_string(rest: &str) -> Option<String> {
let rest = rest.trim_start();
let rest = rest.strip_prefix('=')?.trim_start();
let rest = rest.strip_prefix('"')?;
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
pub fn parse_go_sum(content: &str) -> Vec<LockNode> {
let mut refs: Vec<LockNode> = Vec::new();
for (lineno, raw) in content.lines().enumerate() {
let mut parts = raw.split_ascii_whitespace();
let name = match parts.next() {
Some(n) => n,
None => continue,
};
let mut ver = match parts.next() {
Some(v) => v,
None => continue,
};
let hash = parts.next().unwrap_or("");
if !hash.starts_with("h1:") {
continue;
}
if let Some(stripped) = ver.strip_suffix("/go.mod") {
ver = stripped;
}
let ver_body = match ver.strip_prefix('v') {
Some(b) if b.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) => b,
_ => continue,
};
refs.push(LockNode {
ecosystem: "go".to_string(),
name: name.to_string(),
version: ver_body.to_string(),
line: Some(lineno + 1),
direct: false,
});
}
dedupe(refs)
}
pub fn parse_poetry_lock(content: &str) -> Vec<LockNode> {
let mut refs: Vec<LockNode> = Vec::new();
let mut in_package = false;
let mut cur_name: Option<String> = None;
let mut cur_version: Option<String> = None;
let mut cur_line: Option<usize> = None;
let mut cur_category: Option<String> = None;
let flush = |cur_name: &mut Option<String>,
cur_version: &mut Option<String>,
cur_line: &mut Option<usize>,
cur_category: &mut Option<String>,
refs: &mut Vec<LockNode>| {
if let (Some(n), Some(v)) = (cur_name.take(), cur_version.take()) {
let direct = cur_category.take().as_deref() == Some("main");
refs.push(LockNode {
ecosystem: "pypi".to_string(),
name: n,
version: v,
line: cur_line.take(),
direct,
});
}
*cur_line = None;
};
for (lineno, raw) in content.lines().enumerate() {
let text = raw.trim_end();
let stripped = text.trim_start();
if stripped == "[[package]]" {
flush(
&mut cur_name,
&mut cur_version,
&mut cur_line,
&mut cur_category,
&mut refs,
);
in_package = true;
continue;
}
if stripped.starts_with('[') && stripped != "[[package]]" {
if !stripped.starts_with("[package.") {
flush(
&mut cur_name,
&mut cur_version,
&mut cur_line,
&mut cur_category,
&mut refs,
);
in_package = false;
}
continue;
}
if !in_package {
continue;
}
if let Some(rest) = stripped.strip_prefix("name") {
if let Some(v) = extract_toml_string(rest) {
cur_name = Some(v);
cur_line = Some(lineno + 1);
continue;
}
}
if let Some(rest) = stripped.strip_prefix("version") {
if let Some(v) = extract_toml_string(rest) {
cur_version = Some(v);
continue;
}
}
if let Some(rest) = stripped.strip_prefix("category") {
if let Some(v) = extract_toml_string(rest) {
cur_category = Some(v);
continue;
}
}
}
flush(
&mut cur_name,
&mut cur_version,
&mut cur_line,
&mut cur_category,
&mut refs,
);
dedupe(refs)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn identify_all_supported_lockfiles() {
assert_eq!(
identify_lockfile(&PathBuf::from("/a/b/package-lock.json")),
Some(LockfileKind::PackageLockJson)
);
assert_eq!(
identify_lockfile(&PathBuf::from("Cargo.lock")),
Some(LockfileKind::CargoLock)
);
assert_eq!(
identify_lockfile(&PathBuf::from("./repo/go.sum")),
Some(LockfileKind::GoSum)
);
assert_eq!(
identify_lockfile(&PathBuf::from("poetry.lock")),
Some(LockfileKind::PoetryLock)
);
assert_eq!(identify_lockfile(&PathBuf::from("random.txt")), None);
}
#[test]
fn npm_v2_extracts_direct_and_transitive() {
let content = r#"{
"name": "app",
"lockfileVersion": 3,
"packages": {
"": { "name": "app", "version": "1.0.0" },
"node_modules/lodash": { "version": "4.17.21" },
"node_modules/express": { "version": "4.18.2" },
"node_modules/express/node_modules/cookie": { "version": "0.5.0" }
}
}"#;
let nodes = parse_package_lock_json(content);
assert_eq!(nodes.len(), 3);
let cookie = nodes.iter().find(|n| n.name == "cookie").unwrap();
assert!(!cookie.direct);
let lodash = nodes.iter().find(|n| n.name == "lodash").unwrap();
assert!(lodash.direct);
assert_eq!(lodash.version, "4.17.21");
}
#[test]
fn npm_ignores_workspace_link() {
let content = r#"{
"packages": {
"node_modules/my-workspace-pkg": { "version": "0.0.0", "link": true },
"node_modules/lodash": { "version": "4.17.21" }
}
}"#;
let nodes = parse_package_lock_json(content);
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].name, "lodash");
}
#[test]
fn npm_v1_falls_back_to_dependencies() {
let content = r#"{
"lockfileVersion": 1,
"dependencies": {
"lodash": { "version": "4.17.21" },
"express": {
"version": "4.18.2",
"dependencies": {
"cookie": { "version": "0.5.0" }
}
}
}
}"#;
let nodes = parse_package_lock_json(content);
assert_eq!(nodes.len(), 3);
let cookie = nodes.iter().find(|n| n.name == "cookie").unwrap();
assert!(!cookie.direct);
let express = nodes.iter().find(|n| n.name == "express").unwrap();
assert!(express.direct);
}
#[test]
fn npm_malformed_returns_empty() {
assert!(parse_package_lock_json("not json").is_empty());
}
#[test]
fn cargo_lock_skips_root_workspace() {
let content = r#"[[package]]
name = "my-workspace"
version = "0.1.0"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "tokio"
version = "1.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#;
let nodes = parse_cargo_lock(content);
assert_eq!(nodes.len(), 2);
assert!(nodes.iter().any(|n| n.name == "serde"));
assert!(nodes.iter().any(|n| n.name == "tokio"));
assert!(!nodes.iter().any(|n| n.name == "my-workspace"));
}
#[test]
fn go_sum_dedupes_zip_and_gomod_variants() {
let content = "github.com/foo/bar v1.2.3 h1:abcdef=\n\
github.com/foo/bar v1.2.3/go.mod h1:xyz=\n\
github.com/baz/qux v0.1.0 h1:ghi=\n";
let nodes = parse_go_sum(content);
assert_eq!(nodes.len(), 2);
assert!(nodes
.iter()
.any(|n| n.name == "github.com/foo/bar" && n.version == "1.2.3"));
}
#[test]
fn poetry_lock_parses_name_version_category() {
let content = r#"[[package]]
name = "requests"
version = "2.31.0"
category = "main"
[package.dependencies]
urllib3 = "^2.0"
[[package]]
name = "pytest"
version = "7.4.0"
category = "dev"
"#;
let nodes = parse_poetry_lock(content);
assert_eq!(nodes.len(), 2);
let requests = nodes.iter().find(|n| n.name == "requests").unwrap();
assert!(requests.direct);
let pytest = nodes.iter().find(|n| n.name == "pytest").unwrap();
assert!(!pytest.direct);
}
#[test]
fn parse_lockfile_dispatches_by_basename() {
let nodes = parse_lockfile(
&PathBuf::from("some/path/Cargo.lock"),
"[[package]]\nname = \"x\"\nversion = \"0.1.0\"\nsource = \"registry+x\"\n",
);
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].ecosystem, "crates");
}
}