use std::collections::HashMap;
use std::path::Path;
use crate::scanner::{normalize_pypi_name, read_file_with_limit, MAX_MANIFEST_SIZE};
type ResolvedVersions = HashMap<String, String>;
type LockParser = fn(&Path) -> Option<ResolvedVersions>;
pub fn parse_cargo_lock(path: &Path) -> Option<HashMap<String, String>> {
let content = read_file_with_limit(path, MAX_MANIFEST_SIZE).ok()?;
let parsed: toml::Value = toml::from_str(&content).ok()?;
let packages = parsed.get("package")?.as_array()?;
let mut map = HashMap::new();
for pkg in packages {
if let (Some(name), Some(version)) = (
pkg.get("name").and_then(|v| v.as_str()),
pkg.get("version").and_then(|v| v.as_str()),
) {
map.entry(name.to_string())
.or_insert_with(|| version.to_string());
}
}
Some(map)
}
pub fn resolve_from_sibling_lock(manifest_path: &Path) -> Option<HashMap<String, String>> {
let lock_path = manifest_path.parent()?.join("Cargo.lock");
if lock_path.is_file() {
parse_cargo_lock(&lock_path)
} else {
None
}
}
pub fn parse_npm_lock(path: &Path) -> Option<HashMap<String, String>> {
let content = read_file_with_limit(path, MAX_MANIFEST_SIZE).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
let mut map = HashMap::new();
if let Some(packages) = parsed.get("packages").and_then(|v| v.as_object()) {
for (key, entry) in packages {
let name = match npm_name_from_packages_key(key) {
Some(n) => n,
None => continue,
};
if let Some(version) = entry.get("version").and_then(|v| v.as_str()) {
map.entry(name).or_insert_with(|| version.to_string());
}
}
if !map.is_empty() {
return Some(map);
}
}
if let Some(deps) = parsed.get("dependencies").and_then(|v| v.as_object()) {
collect_npm_v1_deps(deps, &mut map);
}
Some(map)
}
fn npm_name_from_packages_key(key: &str) -> Option<String> {
if key.is_empty() {
return None;
}
let idx = key.rfind("node_modules/")?;
let name = &key[idx + "node_modules/".len()..];
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
fn collect_npm_v1_deps(
deps: &serde_json::Map<String, serde_json::Value>,
map: &mut HashMap<String, String>,
) {
for (name, entry) in deps {
if let Some(version) = entry.get("version").and_then(|v| v.as_str()) {
map.entry(name.clone())
.or_insert_with(|| version.to_string());
}
if let Some(nested) = entry.get("dependencies").and_then(|v| v.as_object()) {
collect_npm_v1_deps(nested, map);
}
}
}
pub fn resolve_npm_from_sibling_lock(manifest_path: &Path) -> Option<HashMap<String, String>> {
let dir = manifest_path.parent()?;
for candidate in ["package-lock.json", "npm-shrinkwrap.json"] {
let lock_path = dir.join(candidate);
if lock_path.is_file() {
if let Some(map) = parse_npm_lock(&lock_path) {
return Some(map);
}
}
}
None
}
pub fn parse_poetry_lock(path: &Path) -> Option<HashMap<String, String>> {
let content = read_file_with_limit(path, MAX_MANIFEST_SIZE).ok()?;
let parsed: toml::Value = toml::from_str(&content).ok()?;
let packages = parsed.get("package")?.as_array()?;
let mut map = HashMap::new();
for pkg in packages {
if let (Some(name), Some(version)) = (
pkg.get("name").and_then(|v| v.as_str()),
pkg.get("version").and_then(|v| v.as_str()),
) {
map.entry(normalize_pypi_name(name))
.or_insert_with(|| version.to_string());
}
}
Some(map)
}
pub fn parse_pipfile_lock(path: &Path) -> Option<HashMap<String, String>> {
let content = read_file_with_limit(path, MAX_MANIFEST_SIZE).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
let mut map = HashMap::new();
for section in ["default", "develop"] {
if let Some(obj) = parsed.get(section).and_then(|v| v.as_object()) {
for (name, entry) in obj {
if let Some(raw) = entry.get("version").and_then(|v| v.as_str()) {
let version = raw.trim_start_matches('=').trim();
if !version.is_empty() {
map.entry(normalize_pypi_name(name))
.or_insert_with(|| version.to_string());
}
}
}
}
}
Some(map)
}
pub fn resolve_pypi_from_sibling_lock(manifest_path: &Path) -> Option<HashMap<String, String>> {
let dir = manifest_path.parent()?;
let manifest = manifest_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let (lock_name, parse): (&str, LockParser) = match manifest {
"pyproject.toml" => ("poetry.lock", parse_poetry_lock),
"Pipfile" => ("Pipfile.lock", parse_pipfile_lock),
_ => return None,
};
let lock_path = dir.join(lock_name);
if lock_path.is_file() {
parse(&lock_path)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_file(dir: &Path, name: &str, contents: &str) -> std::path::PathBuf {
let path = dir.join(name);
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(contents.as_bytes()).unwrap();
path
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"slow_lynx_lockfile_test_{}_{}",
tag,
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
const SAMPLE_LOCK: &str = r#"
version = 3
[[package]]
name = "ring"
version = "0.17.8"
[[package]]
name = "rustls"
version = "0.21.10"
[[package]]
name = "some-unrelated-crate"
version = "1.2.3"
"#;
#[test]
fn parses_name_and_version_pairs() {
let dir = temp_dir("parse");
let lock = write_file(&dir, "Cargo.lock", SAMPLE_LOCK);
let map = parse_cargo_lock(&lock).expect("lock should parse");
assert_eq!(map.get("ring").map(String::as_str), Some("0.17.8"));
assert_eq!(map.get("rustls").map(String::as_str), Some("0.21.10"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolves_from_sibling_lock() {
let dir = temp_dir("sibling");
write_file(&dir, "Cargo.lock", SAMPLE_LOCK);
let manifest = write_file(&dir, "Cargo.toml", "[package]\nname = \"x\"\n");
let map = resolve_from_sibling_lock(&manifest).expect("sibling lock resolves");
assert_eq!(map.get("ring").map(String::as_str), Some("0.17.8"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn no_lock_returns_none() {
let dir = temp_dir("nolock");
let manifest = write_file(&dir, "Cargo.toml", "[package]\nname = \"x\"\n");
assert!(resolve_from_sibling_lock(&manifest).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn malformed_lock_returns_none() {
let dir = temp_dir("malformed");
let lock = write_file(&dir, "Cargo.lock", "this is not valid toml ::: {");
assert!(parse_cargo_lock(&lock).is_none());
std::fs::remove_dir_all(&dir).ok();
}
const SAMPLE_NPM_LOCK_V3: &str = r#"{
"name": "demo",
"lockfileVersion": 3,
"packages": {
"": { "name": "demo", "version": "1.0.0" },
"node_modules/node-forge": { "version": "1.3.1" },
"node_modules/@scope/cryptolib": { "version": "2.4.0" },
"node_modules/node-forge/node_modules/bcrypt": { "version": "5.1.0" },
"node_modules/linked": { "link": true }
}
}"#;
const SAMPLE_NPM_LOCK_V1: &str = r#"{
"name": "demo",
"lockfileVersion": 1,
"dependencies": {
"node-forge": {
"version": "1.3.1",
"dependencies": {
"bcrypt": { "version": "5.1.0" }
}
},
"crypto-js": { "version": "4.2.0" }
}
}"#;
#[test]
fn parses_npm_v3_packages_with_scope_and_nesting() {
let dir = temp_dir("npm_v3");
let lock = write_file(&dir, "package-lock.json", SAMPLE_NPM_LOCK_V3);
let map = parse_npm_lock(&lock).expect("npm v3 lock should parse");
assert_eq!(map.get("node-forge").map(String::as_str), Some("1.3.1"));
assert_eq!(
map.get("@scope/cryptolib").map(String::as_str),
Some("2.4.0")
);
assert_eq!(map.get("bcrypt").map(String::as_str), Some("5.1.0"));
assert!(!map.contains_key(""));
assert!(!map.contains_key("linked"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn parses_npm_v1_nested_dependencies() {
let dir = temp_dir("npm_v1");
let lock = write_file(&dir, "package-lock.json", SAMPLE_NPM_LOCK_V1);
let map = parse_npm_lock(&lock).expect("npm v1 lock should parse");
assert_eq!(map.get("node-forge").map(String::as_str), Some("1.3.1"));
assert_eq!(map.get("bcrypt").map(String::as_str), Some("5.1.0"));
assert_eq!(map.get("crypto-js").map(String::as_str), Some("4.2.0"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolves_npm_from_sibling_lock_and_shrinkwrap() {
let dir = temp_dir("npm_sibling");
write_file(&dir, "package-lock.json", SAMPLE_NPM_LOCK_V3);
let manifest = write_file(&dir, "package.json", "{\"name\":\"demo\"}");
let map = resolve_npm_from_sibling_lock(&manifest).expect("sibling npm lock resolves");
assert_eq!(map.get("node-forge").map(String::as_str), Some("1.3.1"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn npm_no_lock_returns_none() {
let dir = temp_dir("npm_nolock");
let manifest = write_file(&dir, "package.json", "{\"name\":\"demo\"}");
assert!(resolve_npm_from_sibling_lock(&manifest).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn malformed_npm_lock_returns_none() {
let dir = temp_dir("npm_malformed");
let lock = write_file(&dir, "package-lock.json", "{ not valid json ");
assert!(parse_npm_lock(&lock).is_none());
std::fs::remove_dir_all(&dir).ok();
}
const SAMPLE_POETRY_LOCK: &str = r#"
[[package]]
name = "cryptography"
version = "41.0.7"
[[package]]
name = "PyOpenSSL"
version = "23.3.0"
[[package]]
name = "requests"
version = "2.31.0"
"#;
const SAMPLE_PIPFILE_LOCK: &str = r#"{
"default": {
"cryptography": { "version": "==41.0.7" },
"PyNaCl": { "version": "==1.5.0" }
},
"develop": {
"bcrypt": { "version": "==4.1.2" }
}
}"#;
#[test]
fn parses_poetry_lock_with_normalized_names() {
let dir = temp_dir("poetry");
let lock = write_file(&dir, "poetry.lock", SAMPLE_POETRY_LOCK);
let map = parse_poetry_lock(&lock).expect("poetry lock should parse");
assert_eq!(map.get("cryptography").map(String::as_str), Some("41.0.7"));
assert_eq!(map.get("pyopenssl").map(String::as_str), Some("23.3.0"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn parses_pipfile_lock_default_and_develop() {
let dir = temp_dir("pipfile");
let lock = write_file(&dir, "Pipfile.lock", SAMPLE_PIPFILE_LOCK);
let map = parse_pipfile_lock(&lock).expect("Pipfile.lock should parse");
assert_eq!(map.get("cryptography").map(String::as_str), Some("41.0.7"));
assert_eq!(map.get("pynacl").map(String::as_str), Some("1.5.0")); assert_eq!(map.get("bcrypt").map(String::as_str), Some("4.1.2")); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolves_pypi_lock_by_manifest_kind() {
let dir = temp_dir("pypi_sibling");
write_file(&dir, "poetry.lock", SAMPLE_POETRY_LOCK);
let pyproject = write_file(&dir, "pyproject.toml", "[tool.poetry]\nname = \"x\"\n");
let map = resolve_pypi_from_sibling_lock(&pyproject).expect("poetry sibling resolves");
assert_eq!(map.get("cryptography").map(String::as_str), Some("41.0.7"));
write_file(&dir, "Pipfile.lock", SAMPLE_PIPFILE_LOCK);
let pipfile = write_file(&dir, "Pipfile", "[packages]\n");
let map2 = resolve_pypi_from_sibling_lock(&pipfile).expect("pipenv sibling resolves");
assert_eq!(map2.get("pynacl").map(String::as_str), Some("1.5.0"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn pypi_no_sibling_lock_returns_none() {
let dir = temp_dir("pypi_nolock");
let pyproject = write_file(&dir, "pyproject.toml", "[tool.poetry]\nname = \"x\"\n");
assert!(resolve_pypi_from_sibling_lock(&pyproject).is_none());
std::fs::remove_dir_all(&dir).ok();
}
}