1use 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
33pub 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);