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::{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
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    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}