Skip to main content

provenant/parsers/
cargo_lock.rs

1//! Parser for Cargo.lock lockfiles.
2//!
3//! Extracts resolved dependency information including exact versions and
4//! checksums from Rust Cargo.lock files.
5//!
6//! # Supported Formats
7//! - Cargo.lock (lockfile)
8//!
9//! # Key Features
10//! - Exact version resolution from lockfile
11//! - Direct vs transitive dependency tracking (`is_direct`)
12//! - Checksum extraction for verification
13//! - Package URL (purl) generation
14//! - Dependency graph with source tracking (crates.io, git, path)
15//!
16//! # Implementation Notes
17//! - All lockfile versions are pinned (`is_pinned: Some(true)`)
18//! - Direct dependencies determined from root package's dependency list
19//! - Uses TOML parsing for structured data extraction
20
21use 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
32/// Rust Cargo.lock lockfile parser.
33///
34/// Extracts pinned dependency versions with checksums from Cargo-managed Rust projects.
35pub 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);