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};
22use log::warn;
23use packageurl::PackageUrl;
24use std::fs::File;
25use std::io::Read;
26use std::path::Path;
27use toml::Value;
28
29use super::PackageParser;
30
31/// Rust Cargo.lock lockfile parser.
32///
33/// Extracts pinned dependency versions with checksums from Cargo-managed Rust projects.
34pub struct CargoLockParser;
35
36impl PackageParser for CargoLockParser {
37    const PACKAGE_TYPE: PackageType = PackageType::Cargo;
38
39    fn is_match(path: &Path) -> bool {
40        path.file_name()
41            .and_then(|name| name.to_str())
42            .is_some_and(|name| name.eq_ignore_ascii_case("cargo.lock"))
43    }
44
45    fn extract_packages(path: &Path) -> Vec<PackageData> {
46        let content = match read_cargo_lock(path) {
47            Ok(content) => content,
48            Err(e) => {
49                warn!("Failed to read or parse Cargo.lock at {:?}: {}", path, e);
50                return vec![default_package_data()];
51            }
52        };
53
54        let packages = match content.get("package").and_then(|v| v.as_array()) {
55            Some(pkgs) => pkgs,
56            None => {
57                warn!("No 'package' array found in Cargo.lock at {:?}", path);
58                return vec![default_package_data()];
59            }
60        };
61
62        let root_package = select_root_package(packages);
63
64        let name = root_package
65            .and_then(|p| p.get("name"))
66            .and_then(|v| v.as_str())
67            .map(String::from);
68
69        let version = root_package
70            .and_then(|p| p.get("version"))
71            .and_then(|v| v.as_str())
72            .map(String::from);
73
74        let checksum = root_package
75            .and_then(|p| p.get("checksum"))
76            .and_then(|v| v.as_str())
77            .map(String::from);
78
79        let dependencies = extract_all_dependencies(packages, root_package);
80
81        let purl = match (&name, &version) {
82            (Some(n), Some(v)) => PackageUrl::new("cargo", n).ok().and_then(|mut p| {
83                p.with_version(v.as_str()).ok()?;
84                Some(p.to_string())
85            }),
86            _ => None,
87        };
88
89        let api_data_url = match (&name, &version) {
90            (Some(n), Some(v)) => Some(format!("https://crates.io/api/v1/crates/{}/{}", n, v)),
91            (Some(n), None) => Some(format!("https://crates.io/api/v1/crates/{}", n)),
92            _ => None,
93        };
94
95        vec![PackageData {
96            package_type: Some(Self::PACKAGE_TYPE),
97            namespace: None,
98            name,
99            version,
100            qualifiers: None,
101            subpath: None,
102            primary_language: None,
103            description: None,
104            release_date: None,
105            parties: Vec::new(),
106            keywords: Vec::new(),
107            homepage_url: None,
108            download_url: None,
109            size: None,
110            sha1: None,
111            md5: None,
112            sha256: checksum,
113            sha512: None,
114            bug_tracking_url: None,
115            code_view_url: None,
116            vcs_url: None,
117            copyright: None,
118            holder: None,
119            declared_license_expression: None,
120            declared_license_expression_spdx: None,
121            license_detections: Vec::new(),
122            other_license_expression: None,
123            other_license_expression_spdx: None,
124            other_license_detections: Vec::new(),
125            extracted_license_statement: None,
126            notice_text: None,
127            source_packages: Vec::new(),
128            file_references: Vec::new(),
129            is_private: false,
130            is_virtual: false,
131            extra_data: None,
132            dependencies,
133            repository_homepage_url: None,
134            repository_download_url: None,
135            api_data_url,
136            datasource_id: Some(DatasourceId::CargoLock),
137            purl,
138        }]
139    }
140}
141
142fn read_cargo_lock(path: &Path) -> Result<Value, String> {
143    let mut file = File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
144    let mut content = String::new();
145    file.read_to_string(&mut content)
146        .map_err(|e| format!("Failed to read file: {}", e))?;
147    toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))
148}
149
150fn select_root_package(packages: &[Value]) -> Option<&toml::map::Map<String, Value>> {
151    packages
152        .iter()
153        .filter_map(|package| package.as_table())
154        .find(|table| table.get("source").is_none())
155        .or_else(|| packages.first().and_then(|package| package.as_table()))
156}
157
158fn extract_all_dependencies(
159    packages: &[Value],
160    root_package: Option<&toml::map::Map<String, Value>>,
161) -> Vec<Dependency> {
162    let mut all_dependencies = Vec::new();
163
164    let package_versions: std::collections::HashMap<&str, Vec<&str>> = packages
165        .iter()
166        .filter_map(|package| package.as_table())
167        .filter_map(|table| {
168            Some((
169                table.get("name")?.as_str()?,
170                table.get("version")?.as_str()?,
171            ))
172        })
173        .fold(
174            std::collections::HashMap::new(),
175            |mut acc, (name, version)| {
176                acc.entry(name).or_default().push(version);
177                acc
178            },
179        );
180
181    let root_package_name = root_package
182        .and_then(|t| t.get("name"))
183        .and_then(|v| v.as_str())
184        .unwrap_or("");
185
186    for (index, package) in packages.iter().enumerate() {
187        if let Some(pkg_table) = package.as_table() {
188            let pkg_name = pkg_table.get("name").and_then(|v| v.as_str()).unwrap_or("");
189            let _pkg_version = pkg_table
190                .get("version")
191                .and_then(|v| v.as_str())
192                .unwrap_or("");
193
194            let is_root_package = index == 0 && pkg_name == root_package_name;
195
196            if let Some(deps) = pkg_table.get("dependencies").and_then(|v| v.as_array()) {
197                for dep in deps {
198                    if let Some(dep_str) = dep.as_str() {
199                        let (name, version) = parse_dependency_string(dep_str);
200                        let resolved_version = if version.is_empty() {
201                            package_versions
202                                .get(name)
203                                .and_then(|versions| (versions.len() == 1).then_some(versions[0]))
204                                .unwrap_or("")
205                        } else {
206                            version
207                        };
208
209                        if !name.is_empty() {
210                            let purl = if resolved_version.is_empty() {
211                                PackageUrl::new("cargo", name).ok().map(|p| p.to_string())
212                            } else {
213                                PackageUrl::new("cargo", name).ok().and_then(|mut p| {
214                                    p.with_version(resolved_version).ok()?;
215                                    Some(p.to_string())
216                                })
217                            };
218
219                            all_dependencies.push(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: None,
233                            });
234                        }
235                    }
236                }
237            }
238        }
239    }
240
241    all_dependencies
242}
243
244fn parse_dependency_string(dep_str: &str) -> (&str, &str) {
245    if let Some(space_idx) = dep_str.find(' ') {
246        let name = &dep_str[..space_idx];
247        let version = &dep_str[space_idx + 1..];
248        (name, version)
249    } else {
250        (dep_str, "")
251    }
252}
253
254fn default_package_data() -> PackageData {
255    PackageData {
256        package_type: Some(CargoLockParser::PACKAGE_TYPE),
257        datasource_id: Some(DatasourceId::CargoLock),
258        ..Default::default()
259    }
260}
261
262crate::register_parser!(
263    "Rust Cargo.lock lockfile",
264    &["**/Cargo.lock", "**/cargo.lock"],
265    "cargo",
266    "Rust",
267    Some("https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html"),
268);