use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Sha256Digest};
use crate::parser_warn as warn;
use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
use packageurl::PackageUrl;
use serde_json::json;
use std::collections::{HashMap, HashSet, hash_map::Entry};
use std::path::Path;
use toml::Value;
use super::PackageParser;
pub struct CargoLockParser;
impl PackageParser for CargoLockParser {
const PACKAGE_TYPE: PackageType = PackageType::Cargo;
fn is_match(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("cargo.lock"))
}
fn extract_packages(path: &Path) -> Vec<PackageData> {
let content = match read_cargo_lock(path) {
Ok(content) => content,
Err(e) => {
warn!("Failed to read or parse Cargo.lock at {:?}: {}", path, e);
return vec![default_package_data()];
}
};
let packages = match content.get("package").and_then(|v| v.as_array()) {
Some(pkgs) => pkgs,
None => {
warn!("No 'package' array found in Cargo.lock at {:?}", path);
return vec![default_package_data()];
}
};
let identity_package = select_identity_package(packages);
let dependency_root_package = select_dependency_root_package(packages);
let name = identity_package
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.map(|s| truncate_field(s.to_string()));
let version = identity_package
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.map(|s| truncate_field(s.to_string()));
let checksum = identity_package
.and_then(|p| p.get("checksum"))
.and_then(|v| v.as_str())
.map(|s| truncate_field(s.to_string()));
let (sha256, extra_data) = match checksum.as_deref() {
Some(h) if h.len() == 64 && Sha256Digest::from_hex(h).is_ok() => {
(Sha256Digest::from_hex(h).ok(), None)
}
Some(h) if hex::decode(h).is_ok() => {
let mut map = HashMap::new();
map.insert("checksum".to_string(), json!(h));
(None, Some(map))
}
_ => (None, None),
};
let dependencies = extract_all_dependencies(packages, dependency_root_package);
let purl = match (&name, &version) {
(Some(n), Some(v)) => PackageUrl::new("cargo", n).ok().and_then(|mut p| {
p.with_version(v.as_str()).ok()?;
Some(truncate_field(p.to_string()))
}),
_ => None,
};
let api_data_url = match (&name, &version) {
(Some(n), Some(v)) => Some(truncate_field(format!(
"https://crates.io/api/v1/crates/{}/{}",
n, v
))),
(Some(n), None) => Some(truncate_field(format!(
"https://crates.io/api/v1/crates/{}",
n
))),
_ => None,
};
vec![PackageData {
package_type: Some(Self::PACKAGE_TYPE),
namespace: None,
name,
version,
qualifiers: None,
subpath: None,
primary_language: None,
description: None,
release_date: None,
parties: Vec::new(),
keywords: Vec::new(),
homepage_url: None,
download_url: None,
size: None,
sha1: None,
md5: None,
sha256,
sha512: None,
bug_tracking_url: None,
code_view_url: None,
vcs_url: None,
copyright: None,
holder: None,
declared_license_expression: None,
declared_license_expression_spdx: None,
license_detections: Vec::new(),
other_license_expression: None,
other_license_expression_spdx: None,
other_license_detections: Vec::new(),
extracted_license_statement: None,
notice_text: None,
source_packages: Vec::new(),
file_references: Vec::new(),
is_private: false,
is_virtual: false,
extra_data,
dependencies,
repository_homepage_url: None,
repository_download_url: None,
api_data_url,
datasource_id: Some(DatasourceId::CargoLock),
purl,
}]
}
}
fn read_cargo_lock(path: &Path) -> Result<Value, String> {
let content =
read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))
}
fn select_dependency_root_package(packages: &[Value]) -> Option<&toml::map::Map<String, Value>> {
packages
.iter()
.filter_map(|package| package.as_table())
.find(|table| table.get("source").is_none())
.or_else(|| packages.first().and_then(|package| package.as_table()))
}
fn select_identity_package(packages: &[Value]) -> Option<&toml::map::Map<String, Value>> {
let local_packages: Vec<_> = packages
.iter()
.filter_map(|package| package.as_table())
.filter(|table| table.get("source").is_none())
.collect();
match local_packages.as_slice() {
[] => packages.first().and_then(|package| package.as_table()),
[only] => Some(*only),
_ => select_unique_root_like_local_package(&local_packages),
}
}
fn select_unique_root_like_local_package<'a>(
local_packages: &[&'a toml::map::Map<String, Value>],
) -> Option<&'a toml::map::Map<String, Value>> {
let local_keys: HashSet<(String, String)> = local_packages
.iter()
.filter_map(|table| package_key_from_table(table))
.map(|(name, version)| (name.to_string(), version.to_string()))
.collect();
let referenced_local_keys: HashSet<(String, String)> = local_packages
.iter()
.flat_map(|table| {
table
.get("dependencies")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.filter_map(|dep| {
let parsed = parse_dependency_string(dep);
(!parsed.name.is_empty() && !parsed.version.is_empty())
.then(|| (parsed.name.to_string(), parsed.version.to_string()))
})
.filter(|key| local_keys.contains(key))
.collect::<Vec<_>>()
})
.collect();
let root_candidates: Vec<_> = local_packages
.iter()
.copied()
.filter(|table| {
package_key_from_table(table).is_some_and(|(name, version)| {
!referenced_local_keys.contains(&(name.to_string(), version.to_string()))
})
})
.collect();
match root_candidates.as_slice() {
[only] => Some(*only),
_ => None,
}
}
fn extract_all_dependencies(
packages: &[Value],
root_package: Option<&toml::map::Map<String, Value>>,
) -> Vec<Dependency> {
let mut all_dependencies: HashMap<CargoDependencyKey, Dependency> = HashMap::new();
let package_versions = build_package_versions(packages);
let package_provenance = build_package_provenance(packages);
let root_package_key = root_package.and_then(package_key_from_table);
for package in packages.iter().take(MAX_ITERATION_COUNT) {
if let Some(pkg_table) = package.as_table() {
let is_root_package = package_key_from_table(pkg_table)
.zip(root_package_key)
.is_some_and(|(package_key, root_key)| package_key == root_key);
if let Some(deps) = pkg_table.get("dependencies").and_then(|v| v.as_array()) {
for dep in deps.iter().take(MAX_ITERATION_COUNT) {
if let Some(dep_str) = dep.as_str() {
let parsed_dependency = parse_dependency_string(dep_str);
let name = parsed_dependency.name;
let resolved_version = if parsed_dependency.version.is_empty() {
package_versions
.get(name)
.and_then(|versions| (versions.len() == 1).then_some(versions[0]))
.unwrap_or("")
} else {
parsed_dependency.version
};
if !name.is_empty() {
let purl = if resolved_version.is_empty() {
PackageUrl::new("cargo", name)
.ok()
.map(|p| truncate_field(p.to_string()))
} else {
PackageUrl::new("cargo", name).ok().and_then(|mut p| {
p.with_version(resolved_version).ok()?;
Some(truncate_field(p.to_string()))
})
};
let extra_data = build_dependency_extra_data(
name,
resolved_version,
parsed_dependency.source,
&package_provenance,
);
let dependency = Dependency {
purl,
extracted_requirement: if resolved_version.is_empty() {
None
} else {
Some(truncate_field(resolved_version.to_string()))
},
scope: None,
is_runtime: None,
is_optional: None,
is_pinned: Some(true),
is_direct: Some(is_root_package),
resolved_package: None,
extra_data,
};
let key = CargoDependencyKey::from_dependency(&dependency);
match all_dependencies.entry(key) {
Entry::Vacant(entry) => {
entry.insert(dependency);
}
Entry::Occupied(mut entry) => {
if is_root_package {
entry.get_mut().is_direct = Some(true);
}
}
}
}
}
}
}
}
}
for package in packages
.iter()
.take(MAX_ITERATION_COUNT)
.filter_map(|package| package.as_table())
{
let Some((name, version)) = package_key_from_table(package) else {
continue;
};
if package.get("source").is_some() {
continue;
}
let Some(mut purl) = PackageUrl::new("cargo", name).ok() else {
continue;
};
if purl.with_version(version).is_err() {
continue;
}
let dependency = Dependency {
purl: Some(truncate_field(purl.to_string())),
extracted_requirement: Some(truncate_field(version.to_string())),
scope: None,
is_runtime: None,
is_optional: None,
is_pinned: Some(true),
is_direct: Some(true),
resolved_package: None,
extra_data: build_dependency_extra_data(name, version, None, &package_provenance),
};
let key = CargoDependencyKey::from_dependency(&dependency);
match all_dependencies.entry(key) {
Entry::Vacant(entry) => {
entry.insert(dependency);
}
Entry::Occupied(mut entry) => {
entry.get_mut().is_direct = Some(true);
}
}
}
let mut dependencies: Vec<_> = all_dependencies.into_values().collect();
dependencies.sort_by(|left, right| {
left.purl
.as_deref()
.cmp(&right.purl.as_deref())
.then_with(|| {
left.extracted_requirement
.as_deref()
.cmp(&right.extracted_requirement.as_deref())
})
});
dependencies
}
#[derive(Hash, PartialEq, Eq)]
struct CargoDependencyKey {
purl: Option<String>,
extracted_requirement: Option<String>,
source: Option<String>,
}
impl CargoDependencyKey {
fn from_dependency(dependency: &Dependency) -> Self {
let source = dependency
.extra_data
.as_ref()
.and_then(|extra_data| extra_data.get("source"))
.and_then(|value| value.as_str())
.map(ToOwned::to_owned);
Self {
purl: dependency.purl.clone(),
extracted_requirement: dependency.extracted_requirement.clone(),
source,
}
}
}
fn build_package_versions(packages: &[Value]) -> HashMap<&str, Vec<&str>> {
packages
.iter()
.filter_map(|package| package.as_table())
.filter_map(|table| {
Some((
table.get("name")?.as_str()?,
table.get("version")?.as_str()?,
))
})
.fold(HashMap::new(), |mut acc, (name, version)| {
acc.entry(name).or_default().push(version);
acc
})
}
fn build_package_provenance<'a>(
packages: &'a [Value],
) -> HashMap<(&'a str, &'a str), Vec<DependencyProvenance<'a>>> {
packages
.iter()
.filter_map(|package| package.as_table())
.filter_map(|table| {
Some((
(
table.get("name")?.as_str()?,
table.get("version")?.as_str()?,
),
DependencyProvenance {
source: table.get("source").and_then(|value| value.as_str()),
checksum: table.get("checksum").and_then(|value| value.as_str()),
},
))
})
.fold(HashMap::new(), |mut acc, (key, provenance)| {
acc.entry(key).or_default().push(provenance);
acc
})
}
fn build_dependency_extra_data(
name: &str,
resolved_version: &str,
source_hint: Option<&str>,
package_provenance: &HashMap<(&str, &str), Vec<DependencyProvenance<'_>>>,
) -> Option<HashMap<String, serde_json::Value>> {
let mut extra_data = HashMap::new();
if !resolved_version.is_empty()
&& let Some(provenance) = package_provenance
.get(&(name, resolved_version))
.and_then(|candidates| select_dependency_provenance(candidates, source_hint))
{
if let Some(source) = provenance.source {
extra_data.insert(
"source".to_string(),
json!(truncate_field(source.to_string())),
);
}
if let Some(checksum) = provenance.checksum {
extra_data.insert(
"checksum".to_string(),
json!(truncate_field(checksum.to_string())),
);
}
}
if !extra_data.contains_key("source")
&& let Some(source) = source_hint
{
extra_data.insert(
"source".to_string(),
json!(truncate_field(source.to_string())),
);
}
if extra_data.is_empty() {
None
} else {
Some(extra_data)
}
}
fn select_dependency_provenance<'a>(
candidates: &'a [DependencyProvenance<'a>],
source_hint: Option<&str>,
) -> Option<DependencyProvenance<'a>> {
if let Some(source_hint) = source_hint {
return candidates
.iter()
.copied()
.find(|candidate| candidate.source == Some(source_hint));
}
(candidates.len() == 1).then_some(candidates[0])
}
fn package_key_from_table(table: &toml::map::Map<String, Value>) -> Option<(&str, &str)> {
Some((
table.get("name")?.as_str()?,
table.get("version")?.as_str()?,
))
}
fn parse_dependency_string(dep_str: &str) -> ParsedDependency<'_> {
let trimmed = dep_str.trim();
let source = trimmed
.find(" (")
.and_then(|source_start| trimmed[source_start + 2..].strip_suffix(')'));
let without_source = trimmed
.find(" (")
.map(|source_start| &trimmed[..source_start])
.unwrap_or(trimmed);
let mut parts = without_source.split_whitespace();
let name = parts.next().unwrap_or("");
let version = parts.next().unwrap_or("");
ParsedDependency {
name,
version,
source,
}
}
#[derive(Clone, Copy)]
struct ParsedDependency<'a> {
name: &'a str,
version: &'a str,
source: Option<&'a str>,
}
#[derive(Clone, Copy)]
struct DependencyProvenance<'a> {
source: Option<&'a str>,
checksum: Option<&'a str>,
}
fn default_package_data() -> PackageData {
PackageData {
package_type: Some(CargoLockParser::PACKAGE_TYPE),
datasource_id: Some(DatasourceId::CargoLock),
..Default::default()
}
}
crate::register_parser!(
"Rust Cargo.lock lockfile",
&["**/Cargo.lock", "**/cargo.lock"],
"cargo",
"Rust",
Some("https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html"),
);