Skip to main content

provenant/parsers/
cargo_lock.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for Cargo.lock lockfiles.
7//!
8//! Extracts resolved dependency information including exact versions and
9//! checksums from Rust Cargo.lock files.
10//!
11//! # Supported Formats
12//! - Cargo.lock (lockfile)
13//!
14//! # Key Features
15//! - Exact version resolution from lockfile
16//! - Direct vs transitive dependency tracking (`is_direct`)
17//! - Checksum extraction for verification
18//! - Package URL (purl) generation
19//! - Dependency graph with source tracking (crates.io, git, path)
20//!
21//! # Implementation Notes
22//! - All lockfile versions are pinned (`is_pinned: Some(true)`)
23//! - Direct dependencies determined from root package's dependency list
24//! - Uses TOML parsing for structured data extraction
25
26use 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
37/// Rust Cargo.lock lockfile parser.
38///
39/// Extracts pinned dependency versions with checksums from Cargo-managed Rust projects.
40pub 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}