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