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