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