1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Sha256Digest};
27use crate::parser_warn as warn;
28use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
29use packageurl::PackageUrl;
30use serde_json::json;
31use std::collections::{HashMap, HashSet, hash_map::Entry};
32use std::path::Path;
33use toml::Value;
34
35use super::PackageParser;
36
37pub struct CargoLockParser;
41
42impl PackageParser for CargoLockParser {
43 const PACKAGE_TYPE: PackageType = PackageType::Cargo;
44
45 fn is_match(path: &Path) -> bool {
46 path.file_name()
47 .and_then(|name| name.to_str())
48 .is_some_and(|name| name.eq_ignore_ascii_case("cargo.lock"))
49 }
50
51 fn extract_packages(path: &Path) -> Vec<PackageData> {
52 let content = match read_cargo_lock(path) {
53 Ok(content) => content,
54 Err(e) => {
55 warn!("Failed to read or parse Cargo.lock at {:?}: {}", path, e);
56 return vec![default_package_data()];
57 }
58 };
59
60 let packages = match content.get("package").and_then(|v| v.as_array()) {
61 Some(pkgs) => pkgs,
62 None => {
63 warn!("No 'package' array found in Cargo.lock at {:?}", path);
64 return vec![default_package_data()];
65 }
66 };
67
68 let identity_package = select_identity_package(packages);
69 let dependency_root_package = select_dependency_root_package(packages);
70
71 let name = identity_package
72 .and_then(|p| p.get("name"))
73 .and_then(|v| v.as_str())
74 .map(|s| truncate_field(s.to_string()));
75
76 let version = identity_package
77 .and_then(|p| p.get("version"))
78 .and_then(|v| v.as_str())
79 .map(|s| truncate_field(s.to_string()));
80
81 let checksum = identity_package
82 .and_then(|p| p.get("checksum"))
83 .and_then(|v| v.as_str())
84 .map(|s| truncate_field(s.to_string()));
85
86 let (sha256, extra_data) = match checksum.as_deref() {
87 Some(h) if h.len() == 64 && Sha256Digest::from_hex(h).is_ok() => {
88 (Sha256Digest::from_hex(h).ok(), None)
89 }
90 Some(h) if hex::decode(h).is_ok() => {
91 let mut map = HashMap::new();
92 map.insert("checksum".to_string(), json!(h));
93 (None, Some(map))
94 }
95 _ => (None, None),
96 };
97
98 let dependencies = extract_all_dependencies(packages, dependency_root_package);
99
100 let purl = match (&name, &version) {
101 (Some(n), Some(v)) => PackageUrl::new("cargo", n).ok().and_then(|mut p| {
102 p.with_version(v.as_str()).ok()?;
103 Some(truncate_field(p.to_string()))
104 }),
105 _ => None,
106 };
107
108 let api_data_url = match (&name, &version) {
109 (Some(n), Some(v)) => Some(truncate_field(format!(
110 "https://crates.io/api/v1/crates/{}/{}",
111 n, v
112 ))),
113 (Some(n), None) => Some(truncate_field(format!(
114 "https://crates.io/api/v1/crates/{}",
115 n
116 ))),
117 _ => None,
118 };
119
120 vec![PackageData {
121 package_type: Some(Self::PACKAGE_TYPE),
122 namespace: None,
123 name,
124 version,
125 qualifiers: None,
126 subpath: None,
127 primary_language: None,
128 description: None,
129 release_date: None,
130 parties: Vec::new(),
131 keywords: Vec::new(),
132 homepage_url: None,
133 download_url: None,
134 size: None,
135 sha1: None,
136 md5: None,
137 sha256,
138 sha512: None,
139 bug_tracking_url: None,
140 code_view_url: None,
141 vcs_url: None,
142 copyright: None,
143 holder: None,
144 declared_license_expression: None,
145 declared_license_expression_spdx: None,
146 license_detections: Vec::new(),
147 other_license_expression: None,
148 other_license_expression_spdx: None,
149 other_license_detections: Vec::new(),
150 extracted_license_statement: None,
151 notice_text: None,
152 source_packages: Vec::new(),
153 file_references: Vec::new(),
154 is_private: false,
155 is_virtual: false,
156 extra_data,
157 dependencies,
158 repository_homepage_url: None,
159 repository_download_url: None,
160 api_data_url,
161 datasource_id: Some(DatasourceId::CargoLock),
162 purl,
163 }]
164 }
165
166 fn metadata() -> Vec<super::metadata::ParserMetadata> {
167 vec![super::metadata::ParserMetadata {
168 description: "Rust Cargo.lock lockfile",
169 file_patterns: &["**/Cargo.lock", "**/cargo.lock"],
170 package_type: "cargo",
171 primary_language: "Rust",
172 documentation_url: Some(
173 "https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html",
174 ),
175 }]
176 }
177}
178
179fn read_cargo_lock(path: &Path) -> Result<Value, String> {
180 let content =
181 read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
182 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))
183}
184
185fn select_dependency_root_package(packages: &[Value]) -> Option<&toml::map::Map<String, Value>> {
186 packages
187 .iter()
188 .filter_map(|package| package.as_table())
189 .find(|table| table.get("source").is_none())
190 .or_else(|| packages.first().and_then(|package| package.as_table()))
191}
192
193fn select_identity_package(packages: &[Value]) -> Option<&toml::map::Map<String, Value>> {
194 let local_packages: Vec<_> = packages
195 .iter()
196 .filter_map(|package| package.as_table())
197 .filter(|table| table.get("source").is_none())
198 .collect();
199
200 match local_packages.as_slice() {
201 [] => packages.first().and_then(|package| package.as_table()),
202 [only] => Some(*only),
203 _ => select_unique_root_like_local_package(&local_packages),
204 }
205}
206
207fn select_unique_root_like_local_package<'a>(
208 local_packages: &[&'a toml::map::Map<String, Value>],
209) -> Option<&'a toml::map::Map<String, Value>> {
210 let local_keys: HashSet<(String, String)> = local_packages
211 .iter()
212 .filter_map(|table| package_key_from_table(table))
213 .map(|(name, version)| (name.to_string(), version.to_string()))
214 .collect();
215
216 let referenced_local_keys: HashSet<(String, String)> = local_packages
217 .iter()
218 .flat_map(|table| {
219 table
220 .get("dependencies")
221 .and_then(Value::as_array)
222 .into_iter()
223 .flatten()
224 .filter_map(Value::as_str)
225 .filter_map(|dep| {
226 let parsed = parse_dependency_string(dep);
227 (!parsed.name.is_empty() && !parsed.version.is_empty())
228 .then(|| (parsed.name.to_string(), parsed.version.to_string()))
229 })
230 .filter(|key| local_keys.contains(key))
231 .collect::<Vec<_>>()
232 })
233 .collect();
234
235 let root_candidates: Vec<_> = local_packages
236 .iter()
237 .copied()
238 .filter(|table| {
239 package_key_from_table(table).is_some_and(|(name, version)| {
240 !referenced_local_keys.contains(&(name.to_string(), version.to_string()))
241 })
242 })
243 .collect();
244
245 match root_candidates.as_slice() {
246 [only] => Some(*only),
247 _ => None,
248 }
249}
250
251fn extract_all_dependencies(
252 packages: &[Value],
253 root_package: Option<&toml::map::Map<String, Value>>,
254) -> Vec<Dependency> {
255 let mut all_dependencies: HashMap<CargoDependencyKey, Dependency> = HashMap::new();
256
257 let package_versions = build_package_versions(packages);
258 let package_provenance = build_package_provenance(packages);
259 let root_package_key = root_package.and_then(package_key_from_table);
260 for package in packages.iter().take(MAX_ITERATION_COUNT) {
261 if let Some(pkg_table) = package.as_table() {
262 let is_root_package = package_key_from_table(pkg_table)
263 .zip(root_package_key)
264 .is_some_and(|(package_key, root_key)| package_key == root_key);
265
266 if let Some(deps) = pkg_table.get("dependencies").and_then(|v| v.as_array()) {
267 for dep in deps.iter().take(MAX_ITERATION_COUNT) {
268 if let Some(dep_str) = dep.as_str() {
269 let parsed_dependency = parse_dependency_string(dep_str);
270 let name = parsed_dependency.name;
271 let resolved_version = if parsed_dependency.version.is_empty() {
272 package_versions
273 .get(name)
274 .and_then(|versions| (versions.len() == 1).then_some(versions[0]))
275 .unwrap_or("")
276 } else {
277 parsed_dependency.version
278 };
279
280 if !name.is_empty() {
281 let purl = if resolved_version.is_empty() {
282 PackageUrl::new("cargo", name)
283 .ok()
284 .map(|p| truncate_field(p.to_string()))
285 } else {
286 PackageUrl::new("cargo", name).ok().and_then(|mut p| {
287 p.with_version(resolved_version).ok()?;
288 Some(truncate_field(p.to_string()))
289 })
290 };
291
292 let extra_data = build_dependency_extra_data(
293 name,
294 resolved_version,
295 parsed_dependency.source,
296 &package_provenance,
297 );
298
299 let dependency = Dependency {
300 purl,
301 extracted_requirement: if resolved_version.is_empty() {
302 None
303 } else {
304 Some(truncate_field(resolved_version.to_string()))
305 },
306 scope: None,
307 is_runtime: None,
308 is_optional: None,
309 is_pinned: Some(true),
310 is_direct: Some(is_root_package),
311 resolved_package: None,
312 extra_data,
313 };
314
315 let key = CargoDependencyKey::from_dependency(&dependency);
316 match all_dependencies.entry(key) {
317 Entry::Vacant(entry) => {
318 entry.insert(dependency);
319 }
320 Entry::Occupied(mut entry) => {
321 if is_root_package {
322 entry.get_mut().is_direct = Some(true);
323 }
324 }
325 }
326 }
327 }
328 }
329 }
330 }
331 }
332
333 for package in packages
334 .iter()
335 .take(MAX_ITERATION_COUNT)
336 .filter_map(|package| package.as_table())
337 {
338 let Some((name, version)) = package_key_from_table(package) else {
339 continue;
340 };
341
342 if package.get("source").is_some() {
343 continue;
344 }
345
346 let Some(mut purl) = PackageUrl::new("cargo", name).ok() else {
347 continue;
348 };
349 if purl.with_version(version).is_err() {
350 continue;
351 }
352
353 let dependency = Dependency {
354 purl: Some(truncate_field(purl.to_string())),
355 extracted_requirement: Some(truncate_field(version.to_string())),
356 scope: None,
357 is_runtime: None,
358 is_optional: None,
359 is_pinned: Some(true),
360 is_direct: Some(true),
361 resolved_package: None,
362 extra_data: build_dependency_extra_data(name, version, None, &package_provenance),
363 };
364
365 let key = CargoDependencyKey::from_dependency(&dependency);
366 match all_dependencies.entry(key) {
367 Entry::Vacant(entry) => {
368 entry.insert(dependency);
369 }
370 Entry::Occupied(mut entry) => {
371 entry.get_mut().is_direct = Some(true);
372 }
373 }
374 }
375
376 let mut dependencies: Vec<_> = all_dependencies.into_values().collect();
377 dependencies.sort_by(|left, right| {
378 left.purl
379 .as_deref()
380 .cmp(&right.purl.as_deref())
381 .then_with(|| {
382 left.extracted_requirement
383 .as_deref()
384 .cmp(&right.extracted_requirement.as_deref())
385 })
386 });
387 dependencies
388}
389
390#[derive(Hash, PartialEq, Eq)]
391struct CargoDependencyKey {
392 purl: Option<String>,
393 extracted_requirement: Option<String>,
394 source: Option<String>,
395}
396
397impl CargoDependencyKey {
398 fn from_dependency(dependency: &Dependency) -> Self {
399 let source = dependency
400 .extra_data
401 .as_ref()
402 .and_then(|extra_data| extra_data.get("source"))
403 .and_then(|value| value.as_str())
404 .map(ToOwned::to_owned);
405
406 Self {
407 purl: dependency.purl.clone(),
408 extracted_requirement: dependency.extracted_requirement.clone(),
409 source,
410 }
411 }
412}
413
414fn build_package_versions(packages: &[Value]) -> HashMap<&str, Vec<&str>> {
415 packages
416 .iter()
417 .filter_map(|package| package.as_table())
418 .filter_map(|table| {
419 Some((
420 table.get("name")?.as_str()?,
421 table.get("version")?.as_str()?,
422 ))
423 })
424 .fold(HashMap::new(), |mut acc, (name, version)| {
425 acc.entry(name).or_default().push(version);
426 acc
427 })
428}
429
430fn build_package_provenance<'a>(
431 packages: &'a [Value],
432) -> HashMap<(&'a str, &'a str), Vec<DependencyProvenance<'a>>> {
433 packages
434 .iter()
435 .filter_map(|package| package.as_table())
436 .filter_map(|table| {
437 Some((
438 (
439 table.get("name")?.as_str()?,
440 table.get("version")?.as_str()?,
441 ),
442 DependencyProvenance {
443 source: table.get("source").and_then(|value| value.as_str()),
444 checksum: table.get("checksum").and_then(|value| value.as_str()),
445 },
446 ))
447 })
448 .fold(HashMap::new(), |mut acc, (key, provenance)| {
449 acc.entry(key).or_default().push(provenance);
450 acc
451 })
452}
453
454fn build_dependency_extra_data(
455 name: &str,
456 resolved_version: &str,
457 source_hint: Option<&str>,
458 package_provenance: &HashMap<(&str, &str), Vec<DependencyProvenance<'_>>>,
459) -> Option<HashMap<String, serde_json::Value>> {
460 let mut extra_data = HashMap::new();
461
462 if !resolved_version.is_empty()
463 && let Some(provenance) = package_provenance
464 .get(&(name, resolved_version))
465 .and_then(|candidates| select_dependency_provenance(candidates, source_hint))
466 {
467 if let Some(source) = provenance.source {
468 extra_data.insert(
469 "source".to_string(),
470 json!(truncate_field(source.to_string())),
471 );
472 }
473 if let Some(checksum) = provenance.checksum {
474 extra_data.insert(
475 "checksum".to_string(),
476 json!(truncate_field(checksum.to_string())),
477 );
478 }
479 }
480
481 if !extra_data.contains_key("source")
482 && let Some(source) = source_hint
483 {
484 extra_data.insert(
485 "source".to_string(),
486 json!(truncate_field(source.to_string())),
487 );
488 }
489
490 if extra_data.is_empty() {
491 None
492 } else {
493 Some(extra_data)
494 }
495}
496
497fn select_dependency_provenance<'a>(
498 candidates: &'a [DependencyProvenance<'a>],
499 source_hint: Option<&str>,
500) -> Option<DependencyProvenance<'a>> {
501 if let Some(source_hint) = source_hint {
502 return candidates
503 .iter()
504 .copied()
505 .find(|candidate| candidate.source == Some(source_hint));
506 }
507
508 (candidates.len() == 1).then_some(candidates[0])
509}
510
511fn package_key_from_table(table: &toml::map::Map<String, Value>) -> Option<(&str, &str)> {
512 Some((
513 table.get("name")?.as_str()?,
514 table.get("version")?.as_str()?,
515 ))
516}
517
518fn parse_dependency_string(dep_str: &str) -> ParsedDependency<'_> {
519 let trimmed = dep_str.trim();
520 let source = trimmed
521 .find(" (")
522 .and_then(|source_start| trimmed[source_start + 2..].strip_suffix(')'));
523 let without_source = trimmed
524 .find(" (")
525 .map(|source_start| &trimmed[..source_start])
526 .unwrap_or(trimmed);
527
528 let mut parts = without_source.split_whitespace();
529 let name = parts.next().unwrap_or("");
530 let version = parts.next().unwrap_or("");
531
532 ParsedDependency {
533 name,
534 version,
535 source,
536 }
537}
538
539#[derive(Clone, Copy)]
540struct ParsedDependency<'a> {
541 name: &'a str,
542 version: &'a str,
543 source: Option<&'a str>,
544}
545
546#[derive(Clone, Copy)]
547struct DependencyProvenance<'a> {
548 source: Option<&'a str>,
549 checksum: Option<&'a str>,
550}
551
552fn default_package_data() -> PackageData {
553 PackageData {
554 package_type: Some(CargoLockParser::PACKAGE_TYPE),
555 datasource_id: Some(DatasourceId::CargoLock),
556 ..Default::default()
557 }
558}