Skip to main content

provenant/parsers/
cargo_lock.rs

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