1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Sha256Digest};
27use crate::parser_warn as warn;
28use crate::parsers::utils::{capped_iteration_limit, 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 let limit = capped_iteration_limit(packages.len(), "Cargo.lock packages");
261 for package in packages.iter().take(limit) {
262 if let Some(pkg_table) = package.as_table() {
263 let is_root_package = package_key_from_table(pkg_table)
264 .zip(root_package_key)
265 .is_some_and(|(package_key, root_key)| package_key == root_key);
266
267 if let Some(deps) = pkg_table.get("dependencies").and_then(|v| v.as_array()) {
268 let dep_limit =
269 capped_iteration_limit(deps.len(), "Cargo.lock package dependencies");
270 for dep in deps.iter().take(dep_limit) {
271 if let Some(dep_str) = dep.as_str() {
272 let parsed_dependency = parse_dependency_string(dep_str);
273 let name = parsed_dependency.name;
274 let resolved_version = if parsed_dependency.version.is_empty() {
275 package_versions
276 .get(name)
277 .and_then(|versions| (versions.len() == 1).then_some(versions[0]))
278 .unwrap_or("")
279 } else {
280 parsed_dependency.version
281 };
282
283 if !name.is_empty() {
284 let purl = if resolved_version.is_empty() {
285 PackageUrl::new("cargo", name)
286 .ok()
287 .map(|p| truncate_field(p.to_string()))
288 } else {
289 PackageUrl::new("cargo", name).ok().and_then(|mut p| {
290 p.with_version(resolved_version).ok()?;
291 Some(truncate_field(p.to_string()))
292 })
293 };
294
295 let extra_data = build_dependency_extra_data(
296 name,
297 resolved_version,
298 parsed_dependency.source,
299 &package_provenance,
300 );
301
302 let dependency = Dependency {
303 purl,
304 extracted_requirement: if resolved_version.is_empty() {
305 None
306 } else {
307 Some(truncate_field(resolved_version.to_string()))
308 },
309 scope: None,
310 is_runtime: None,
311 is_optional: None,
312 is_pinned: Some(true),
313 is_direct: Some(is_root_package),
314 resolved_package: None,
315 extra_data,
316 };
317
318 let key = CargoDependencyKey::from_dependency(&dependency);
319 match all_dependencies.entry(key) {
320 Entry::Vacant(entry) => {
321 entry.insert(dependency);
322 }
323 Entry::Occupied(mut entry) => {
324 if is_root_package {
325 entry.get_mut().is_direct = Some(true);
326 }
327 }
328 }
329 }
330 }
331 }
332 }
333 }
334 }
335
336 let provenance_limit = capped_iteration_limit(packages.len(), "Cargo.lock provenance packages");
337 for package in packages
338 .iter()
339 .take(provenance_limit)
340 .filter_map(|package| package.as_table())
341 {
342 let Some((name, version)) = package_key_from_table(package) else {
343 continue;
344 };
345
346 if package.get("source").is_some() {
347 continue;
348 }
349
350 let Some(mut purl) = PackageUrl::new("cargo", name).ok() else {
351 continue;
352 };
353 if purl.with_version(version).is_err() {
354 continue;
355 }
356
357 let dependency = Dependency {
358 purl: Some(truncate_field(purl.to_string())),
359 extracted_requirement: Some(truncate_field(version.to_string())),
360 scope: None,
361 is_runtime: None,
362 is_optional: None,
363 is_pinned: Some(true),
364 is_direct: Some(true),
365 resolved_package: None,
366 extra_data: build_dependency_extra_data(name, version, None, &package_provenance),
367 };
368
369 let key = CargoDependencyKey::from_dependency(&dependency);
370 match all_dependencies.entry(key) {
371 Entry::Vacant(entry) => {
372 entry.insert(dependency);
373 }
374 Entry::Occupied(mut entry) => {
375 entry.get_mut().is_direct = Some(true);
376 }
377 }
378 }
379
380 let mut dependencies: Vec<_> = all_dependencies.into_values().collect();
381 dependencies.sort_by(|left, right| {
382 left.purl
383 .as_deref()
384 .cmp(&right.purl.as_deref())
385 .then_with(|| {
386 left.extracted_requirement
387 .as_deref()
388 .cmp(&right.extracted_requirement.as_deref())
389 })
390 });
391 dependencies
392}
393
394#[derive(Hash, PartialEq, Eq)]
395struct CargoDependencyKey {
396 purl: Option<String>,
397 extracted_requirement: Option<String>,
398 source: Option<String>,
399}
400
401impl CargoDependencyKey {
402 fn from_dependency(dependency: &Dependency) -> Self {
403 let source = dependency
404 .extra_data
405 .as_ref()
406 .and_then(|extra_data| extra_data.get("source"))
407 .and_then(|value| value.as_str())
408 .map(ToOwned::to_owned);
409
410 Self {
411 purl: dependency.purl.clone(),
412 extracted_requirement: dependency.extracted_requirement.clone(),
413 source,
414 }
415 }
416}
417
418fn build_package_versions(packages: &[Value]) -> HashMap<&str, Vec<&str>> {
419 packages
420 .iter()
421 .filter_map(|package| package.as_table())
422 .filter_map(|table| {
423 Some((
424 table.get("name")?.as_str()?,
425 table.get("version")?.as_str()?,
426 ))
427 })
428 .fold(HashMap::new(), |mut acc, (name, version)| {
429 acc.entry(name).or_default().push(version);
430 acc
431 })
432}
433
434fn build_package_provenance<'a>(
435 packages: &'a [Value],
436) -> HashMap<(&'a str, &'a str), Vec<DependencyProvenance<'a>>> {
437 packages
438 .iter()
439 .filter_map(|package| package.as_table())
440 .filter_map(|table| {
441 Some((
442 (
443 table.get("name")?.as_str()?,
444 table.get("version")?.as_str()?,
445 ),
446 DependencyProvenance {
447 source: table.get("source").and_then(|value| value.as_str()),
448 checksum: table.get("checksum").and_then(|value| value.as_str()),
449 },
450 ))
451 })
452 .fold(HashMap::new(), |mut acc, (key, provenance)| {
453 acc.entry(key).or_default().push(provenance);
454 acc
455 })
456}
457
458fn build_dependency_extra_data(
459 name: &str,
460 resolved_version: &str,
461 source_hint: Option<&str>,
462 package_provenance: &HashMap<(&str, &str), Vec<DependencyProvenance<'_>>>,
463) -> Option<HashMap<String, serde_json::Value>> {
464 let mut extra_data = HashMap::new();
465
466 if !resolved_version.is_empty()
467 && let Some(provenance) = package_provenance
468 .get(&(name, resolved_version))
469 .and_then(|candidates| select_dependency_provenance(candidates, source_hint))
470 {
471 if let Some(source) = provenance.source {
472 extra_data.insert(
473 "source".to_string(),
474 json!(truncate_field(source.to_string())),
475 );
476 }
477 if let Some(checksum) = provenance.checksum {
478 extra_data.insert(
479 "checksum".to_string(),
480 json!(truncate_field(checksum.to_string())),
481 );
482 }
483 }
484
485 if !extra_data.contains_key("source")
486 && let Some(source) = source_hint
487 {
488 extra_data.insert(
489 "source".to_string(),
490 json!(truncate_field(source.to_string())),
491 );
492 }
493
494 if extra_data.is_empty() {
495 None
496 } else {
497 Some(extra_data)
498 }
499}
500
501fn select_dependency_provenance<'a>(
502 candidates: &'a [DependencyProvenance<'a>],
503 source_hint: Option<&str>,
504) -> Option<DependencyProvenance<'a>> {
505 if let Some(source_hint) = source_hint {
506 return candidates
507 .iter()
508 .copied()
509 .find(|candidate| candidate.source == Some(source_hint));
510 }
511
512 (candidates.len() == 1).then_some(candidates[0])
513}
514
515fn package_key_from_table(table: &toml::map::Map<String, Value>) -> Option<(&str, &str)> {
516 Some((
517 table.get("name")?.as_str()?,
518 table.get("version")?.as_str()?,
519 ))
520}
521
522fn parse_dependency_string(dep_str: &str) -> ParsedDependency<'_> {
523 let trimmed = dep_str.trim();
524 let source = trimmed
525 .find(" (")
526 .and_then(|source_start| trimmed[source_start + 2..].strip_suffix(')'));
527 let without_source = trimmed
528 .find(" (")
529 .map(|source_start| &trimmed[..source_start])
530 .unwrap_or(trimmed);
531
532 let mut parts = without_source.split_whitespace();
533 let name = parts.next().unwrap_or("");
534 let version = parts.next().unwrap_or("");
535
536 ParsedDependency {
537 name,
538 version,
539 source,
540 }
541}
542
543#[derive(Clone, Copy)]
544struct ParsedDependency<'a> {
545 name: &'a str,
546 version: &'a str,
547 source: Option<&'a str>,
548}
549
550#[derive(Clone, Copy)]
551struct DependencyProvenance<'a> {
552 source: Option<&'a str>,
553 checksum: Option<&'a str>,
554}
555
556fn default_package_data() -> PackageData {
557 PackageData {
558 package_type: Some(CargoLockParser::PACKAGE_TYPE),
559 datasource_id: Some(DatasourceId::CargoLock),
560 ..Default::default()
561 }
562}