1use crate::models::{DatasourceId, Dependency, FileReference, PackageData, PackageType, Party};
27use crate::parser_warn as warn;
28use crate::parsers::utils::{
29 MAX_ITERATION_COUNT, RecursionGuard, read_file_to_string, split_name_email, truncate_field,
30};
31use packageurl::PackageUrl;
32use std::path::Path;
33use toml::Value;
34
35use super::PackageParser;
36use super::license_normalization::{
37 DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
38 normalize_spdx_expression,
39};
40
41const FIELD_PACKAGE: &str = "package";
42const FIELD_NAME: &str = "name";
43const FIELD_VERSION: &str = "version";
44const FIELD_LICENSE: &str = "license";
45const FIELD_LICENSE_FILE: &str = "license-file";
46const FIELD_AUTHORS: &str = "authors";
47const FIELD_REPOSITORY: &str = "repository";
48const FIELD_HOMEPAGE: &str = "homepage";
49const FIELD_DEPENDENCIES: &str = "dependencies";
50const FIELD_DEV_DEPENDENCIES: &str = "dev-dependencies";
51const FIELD_DEV_DEPENDENCIES_LEGACY: &str = "dev_dependencies";
52const FIELD_BUILD_DEPENDENCIES: &str = "build-dependencies";
53const FIELD_BUILD_DEPENDENCIES_LEGACY: &str = "build_dependencies";
54const FIELD_DESCRIPTION: &str = "description";
55const FIELD_KEYWORDS: &str = "keywords";
56const FIELD_CATEGORIES: &str = "categories";
57const FIELD_RUST_VERSION: &str = "rust-version";
58const FIELD_EDITION: &str = "edition";
59const FIELD_README: &str = "readme";
60const FIELD_PUBLISH: &str = "publish";
61
62pub struct CargoParser;
67
68impl PackageParser for CargoParser {
69 const PACKAGE_TYPE: PackageType = PackageType::Cargo;
70
71 fn extract_packages(path: &Path) -> Vec<PackageData> {
72 let toml_content = match read_cargo_toml(path) {
73 Ok(content) => content,
74 Err(e) => {
75 warn!("Failed to read or parse Cargo.toml at {:?}: {}", path, e);
76 return vec![default_package_data()];
77 }
78 };
79
80 let package = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table());
81
82 let name = package
83 .and_then(|p| p.get(FIELD_NAME))
84 .and_then(|v| v.as_str())
85 .map(|s| truncate_field(s.to_string()));
86
87 let version = package
88 .and_then(|p| p.get(FIELD_VERSION))
89 .and_then(|v| v.as_str())
90 .map(|s| truncate_field(s.to_string()));
91
92 let raw_license = package
93 .and_then(|p| p.get(FIELD_LICENSE))
94 .and_then(|v| v.as_str())
95 .map(|s| truncate_field(s.to_string()));
96 let file_references = extract_file_references(&toml_content);
97 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
98 raw_license
99 .as_deref()
100 .and_then(normalize_spdx_expression)
101 .map(|normalized| {
102 build_declared_license_data(
103 normalized,
104 DeclaredLicenseMatchMetadata::single_line(
105 raw_license.as_deref().unwrap_or_default(),
106 ),
107 )
108 })
109 .unwrap_or_else(empty_declared_license_data);
110
111 let extracted_license_statement = raw_license.clone();
112
113 let dependencies = extract_dependencies_for_scopes(&toml_content, &[FIELD_DEPENDENCIES]);
114 let dev_dependencies = extract_dependencies_for_scopes(
115 &toml_content,
116 &[FIELD_DEV_DEPENDENCIES, FIELD_DEV_DEPENDENCIES_LEGACY],
117 );
118 let build_dependencies = extract_dependencies_for_scopes(
119 &toml_content,
120 &[FIELD_BUILD_DEPENDENCIES, FIELD_BUILD_DEPENDENCIES_LEGACY],
121 );
122
123 let purl = create_package_url(&name, &version);
124
125 let homepage_url = package
126 .and_then(|p| p.get(FIELD_HOMEPAGE))
127 .and_then(|v| v.as_str())
128 .map(|s| truncate_field(s.to_string()))
129 .or_else(|| {
130 name.as_ref()
131 .map(|n| format!("https://crates.io/crates/{}", n))
132 });
133
134 let repository_url = package
135 .and_then(|p| p.get(FIELD_REPOSITORY))
136 .and_then(|v| v.as_str())
137 .map(|s| truncate_field(s.to_string()));
138 let download_url = None;
139
140 let api_data_url = generate_cargo_api_url(&name, &version);
141
142 let repository_homepage_url = name
143 .as_ref()
144 .map(|n| format!("https://crates.io/crates/{}", n));
145
146 let repository_download_url = match (&name, &version) {
147 (Some(n), Some(v)) => Some(format!(
148 "https://crates.io/api/v1/crates/{}/{}/download",
149 n, v
150 )),
151 _ => None,
152 };
153
154 let description = package
155 .and_then(|p| p.get(FIELD_DESCRIPTION))
156 .and_then(|v| v.as_str())
157 .map(|s| truncate_field(s.trim().to_string()));
158
159 let keywords = extract_keywords_and_categories(&toml_content);
160
161 let extra_data = extract_extra_data(&toml_content);
162 let is_private = package
163 .and_then(|p| p.get(FIELD_PUBLISH))
164 .is_some_and(|value| matches!(value, Value::Boolean(false)));
165 vec![PackageData {
166 package_type: Some(Self::PACKAGE_TYPE),
167 namespace: None,
168 name,
169 version,
170 qualifiers: None,
171 subpath: None,
172 primary_language: Some("Rust".to_string()),
173 description,
174 release_date: None,
175 parties: extract_parties(&toml_content),
176 keywords,
177 homepage_url,
178 download_url,
179 size: None,
180 sha1: None,
181 md5: None,
182 sha256: None,
183 sha512: None,
184 bug_tracking_url: None,
185 code_view_url: None,
186 vcs_url: repository_url,
187 copyright: None,
188 holder: None,
189 declared_license_expression,
190 declared_license_expression_spdx,
191 license_detections,
192 other_license_expression: None,
193 other_license_expression_spdx: None,
194 other_license_detections: Vec::new(),
195 extracted_license_statement,
196 notice_text: None,
197 source_packages: Vec::new(),
198 file_references,
199 is_private,
200 is_virtual: false,
201 extra_data,
202 dependencies: [dependencies, dev_dependencies, build_dependencies].concat(),
203 repository_homepage_url,
204 repository_download_url,
205 api_data_url,
206 datasource_id: Some(DatasourceId::CargoToml),
207 purl,
208 }]
209 }
210
211 fn is_match(path: &Path) -> bool {
212 path.file_name()
213 .and_then(|name| name.to_str())
214 .is_some_and(|name| name.eq_ignore_ascii_case("cargo.toml"))
215 }
216
217 fn metadata() -> Vec<super::metadata::ParserMetadata> {
218 vec![super::metadata::ParserMetadata {
219 description: "Rust Cargo.toml manifest",
220 file_patterns: &["**/Cargo.toml", "**/cargo.toml"],
221 package_type: "cargo",
222 primary_language: "Rust",
223 documentation_url: Some("https://doc.rust-lang.org/cargo/reference/manifest.html"),
224 }]
225 }
226}
227
228fn default_package_data() -> PackageData {
231 PackageData {
232 package_type: Some(CargoParser::PACKAGE_TYPE),
233 primary_language: Some("Rust".to_string()),
234 datasource_id: Some(DatasourceId::CargoToml),
235 ..Default::default()
236 }
237}
238
239fn read_cargo_toml(path: &Path) -> Result<Value, String> {
241 let content =
242 read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
243
244 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))
245}
246
247fn generate_cargo_api_url(name: &Option<String>, _version: &Option<String>) -> Option<String> {
248 const REGISTRY: &str = "https://crates.io/api/v1/crates";
249 name.as_ref().map(|name| format!("{}/{}", REGISTRY, name))
250}
251
252fn create_package_url(name: &Option<String>, version: &Option<String>) -> Option<String> {
253 name.as_ref().and_then(|name| {
254 let mut package_url = match PackageUrl::new(CargoParser::PACKAGE_TYPE.as_str(), name) {
255 Ok(p) => p,
256 Err(e) => {
257 warn!(
258 "Failed to create PackageUrl for cargo package '{}': {}",
259 name, e
260 );
261 return None;
262 }
263 };
264
265 if let Some(v) = version
266 && let Err(e) = package_url.with_version(v)
267 {
268 warn!(
269 "Failed to set version '{}' for cargo package '{}': {}",
270 v, name, e
271 );
272 return None;
273 }
274
275 Some(package_url.to_string())
276 })
277}
278
279fn extract_parties(toml_content: &Value) -> Vec<Party> {
281 let mut parties = Vec::new();
282
283 if let Some(package) = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table())
284 && let Some(authors) = package.get(FIELD_AUTHORS).and_then(|v| v.as_array())
285 {
286 for author in authors.iter().take(MAX_ITERATION_COUNT) {
287 if let Some(author_str) = author.as_str() {
288 let (name, email) = split_name_email(author_str);
289 parties.push(Party {
290 r#type: None,
291 role: Some("author".to_string()),
292 name,
293 email,
294 url: None,
295 organization: None,
296 organization_url: None,
297 timezone: None,
298 });
299 }
300 }
301 if authors.len() > MAX_ITERATION_COUNT {
302 warn!(
303 "Authors array has {} entries, capping at MAX_ITERATION_COUNT ({})",
304 authors.len(),
305 MAX_ITERATION_COUNT
306 );
307 }
308 }
309
310 parties
311}
312
313fn is_cargo_version_pinned(version_str: &str) -> bool {
320 let trimmed = version_str.trim();
321
322 if trimmed.is_empty() {
324 return false;
325 }
326
327 if trimmed.contains('^')
329 || trimmed.contains('~')
330 || trimmed.contains('>')
331 || trimmed.contains('<')
332 || trimmed.contains('*')
333 || trimmed.contains('=')
334 {
335 return false;
336 }
337
338 trimmed.matches('.').count() >= 2
342}
343
344fn extract_dependencies(toml_content: &Value, scope: &str) -> Vec<Dependency> {
345 use serde_json::json;
346
347 let mut dependencies = Vec::new();
348
349 let is_runtime = !scope.ends_with("dev-dependencies") && !scope.ends_with("build-dependencies");
351
352 if let Some(deps_table) = toml_content.get(scope).and_then(|v| v.as_table()) {
353 if deps_table.len() > MAX_ITERATION_COUNT {
354 warn!(
355 "Dependency table '{}' has {} entries, capping at MAX_ITERATION_COUNT ({})",
356 scope,
357 deps_table.len(),
358 MAX_ITERATION_COUNT
359 );
360 }
361 for (name, value) in deps_table.iter().take(MAX_ITERATION_COUNT) {
362 let (extracted_requirement, is_optional, extra_data_map, is_pinned) = match value {
363 Value::String(version_str) => {
364 let pinned = is_cargo_version_pinned(version_str);
366 (
367 Some(version_str.to_string()),
368 false,
369 std::collections::HashMap::new(),
370 pinned,
371 )
372 }
373 Value::Table(table) => {
374 let version = table
376 .get("version")
377 .and_then(|v| v.as_str())
378 .map(String::from);
379
380 let pinned = version.as_ref().is_some_and(|v| is_cargo_version_pinned(v));
381
382 let is_optional = table
383 .get("optional")
384 .and_then(|v| v.as_bool())
385 .unwrap_or(false);
386
387 let mut extra_data = std::collections::HashMap::new();
388
389 for (key, val) in table {
391 match key.as_str() {
392 "version" => {
393 if let Some(v) = val.as_str() {
395 extra_data.insert("version".to_string(), json!(v));
396 }
397 }
398 "features" => {
399 if let Some(features_array) = val.as_array() {
401 let features: Vec<String> = features_array
402 .iter()
403 .filter_map(|f| f.as_str().map(String::from))
404 .collect();
405 extra_data.insert("features".to_string(), json!(features));
406 }
407 }
408 "optional" => {
409 }
411 _ => {
412 if let Some(s) = val.as_str() {
414 extra_data.insert(key.clone(), json!(s));
415 } else if let Some(b) = val.as_bool() {
416 extra_data.insert(key.clone(), json!(b));
417 } else if let Some(i) = val.as_integer() {
418 extra_data.insert(key.clone(), json!(i));
419 }
420 }
421 }
422 }
423
424 (version, is_optional, extra_data, pinned)
425 }
426 _ => {
427 continue;
429 }
430 };
431
432 if extracted_requirement.is_some() || !extra_data_map.is_empty() {
434 let purl = match PackageUrl::new(CargoParser::PACKAGE_TYPE.as_str(), name) {
435 Ok(p) => p.to_string(),
436 Err(e) => {
437 warn!(
438 "Failed to create PackageUrl for cargo dependency '{}': {}",
439 name, e
440 );
441 continue; }
443 };
444
445 dependencies.push(Dependency {
446 purl: Some(purl),
447 extracted_requirement,
448 scope: Some(scope.to_string()),
449 is_runtime: Some(is_runtime),
450 is_optional: Some(is_optional),
451 is_pinned: Some(is_pinned),
452 is_direct: Some(true),
453 resolved_package: None,
454 extra_data: if extra_data_map.is_empty() {
455 None
456 } else {
457 Some(extra_data_map)
458 },
459 });
460 }
461 }
462 }
463
464 dependencies
465}
466
467fn extract_dependencies_for_scopes(toml_content: &Value, scopes: &[&str]) -> Vec<Dependency> {
468 scopes
469 .iter()
470 .flat_map(|scope| extract_dependencies(toml_content, scope))
471 .collect()
472}
473
474fn extract_keywords_and_categories(toml_content: &Value) -> Vec<String> {
476 let mut keywords = Vec::new();
477
478 if let Some(package) = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table()) {
479 if let Some(kw_array) = package.get(FIELD_KEYWORDS).and_then(|v| v.as_array()) {
480 if kw_array.len() > MAX_ITERATION_COUNT {
481 warn!(
482 "Keywords array has {} entries, capping at MAX_ITERATION_COUNT ({})",
483 kw_array.len(),
484 MAX_ITERATION_COUNT
485 );
486 }
487 for kw in kw_array.iter().take(MAX_ITERATION_COUNT) {
488 if let Some(kw_str) = kw.as_str() {
489 keywords.push(truncate_field(kw_str.to_string()));
490 }
491 }
492 }
493
494 if let Some(cat_array) = package.get(FIELD_CATEGORIES).and_then(|v| v.as_array()) {
495 if cat_array.len() > MAX_ITERATION_COUNT {
496 warn!(
497 "Categories array has {} entries, capping at MAX_ITERATION_COUNT ({})",
498 cat_array.len(),
499 MAX_ITERATION_COUNT
500 );
501 }
502 for cat in cat_array.iter().take(MAX_ITERATION_COUNT) {
503 if let Some(cat_str) = cat.as_str() {
504 keywords.push(truncate_field(cat_str.to_string()));
505 }
506 }
507 }
508 }
509
510 keywords
511}
512
513fn extract_file_references(toml_content: &Value) -> Vec<FileReference> {
514 let mut file_references = Vec::new();
515
516 if let Some(package) = toml_content
517 .get(FIELD_PACKAGE)
518 .and_then(|value| value.as_table())
519 {
520 for path in [
521 package
522 .get(FIELD_LICENSE_FILE)
523 .and_then(|value| value.as_str()),
524 package.get(FIELD_README).and_then(|value| value.as_str()),
525 ]
526 .into_iter()
527 .flatten()
528 {
529 if file_references
530 .iter()
531 .any(|reference: &FileReference| reference.path == path)
532 {
533 continue;
534 }
535
536 file_references.push(FileReference {
537 path: path.to_string(),
538 size: None,
539 sha1: None,
540 md5: None,
541 sha256: None,
542 sha512: None,
543 extra_data: None,
544 });
545 }
546 }
547
548 file_references
549}
550
551fn toml_to_json(value: &toml::Value, guard: &mut RecursionGuard<()>) -> serde_json::Value {
552 if guard.descend() {
553 warn!("TOML nesting depth exceeded, returning Null");
554 return serde_json::Value::Null;
555 }
556 let result = match value {
557 toml::Value::String(s) => serde_json::json!(s),
558 toml::Value::Integer(i) => serde_json::json!(i),
559 toml::Value::Float(f) => serde_json::json!(f),
560 toml::Value::Boolean(b) => serde_json::json!(b),
561 toml::Value::Array(a) => {
562 serde_json::Value::Array(a.iter().map(|v| toml_to_json(v, guard)).collect())
563 }
564 toml::Value::Table(t) => {
565 let map: serde_json::Map<String, serde_json::Value> = t
566 .iter()
567 .map(|(k, v)| (k.clone(), toml_to_json(v, guard)))
568 .collect();
569 serde_json::Value::Object(map)
570 }
571 toml::Value::Datetime(d) => serde_json::json!(d.to_string()),
572 };
573 guard.ascend();
574 result
575}
576
577fn extract_extra_data(
579 toml_content: &Value,
580) -> Option<std::collections::HashMap<String, serde_json::Value>> {
581 use serde_json::json;
582 let mut extra_data = std::collections::HashMap::new();
583
584 if let Some(package) = toml_content.get(FIELD_PACKAGE).and_then(|v| v.as_table()) {
585 if package.len() > MAX_ITERATION_COUNT {
586 warn!(
587 "Package table has {} entries, exceeding MAX_ITERATION_COUNT ({})",
588 package.len(),
589 MAX_ITERATION_COUNT
590 );
591 }
592 if let Some(rust_version_value) = package.get(FIELD_RUST_VERSION) {
593 if let Some(rust_version_str) = rust_version_value.as_str() {
594 extra_data.insert("rust_version".to_string(), json!(rust_version_str));
595 } else if rust_version_value
596 .as_table()
597 .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
598 {
599 extra_data.insert("rust-version".to_string(), json!("workspace"));
600 }
601 }
602
603 if let Some(edition_value) = package.get(FIELD_EDITION) {
605 if let Some(edition_str) = edition_value.as_str() {
606 extra_data.insert("rust_edition".to_string(), json!(edition_str));
607 } else if edition_value
608 .as_table()
609 .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
610 {
611 extra_data.insert("edition".to_string(), json!("workspace"));
612 }
613 }
614
615 if let Some(documentation) = package.get("documentation").and_then(|v| v.as_str()) {
617 extra_data.insert("documentation_url".to_string(), json!(documentation));
618 }
619
620 if let Some(license_file) = package.get(FIELD_LICENSE_FILE).and_then(|v| v.as_str()) {
622 extra_data.insert("license_file".to_string(), json!(license_file));
623 }
624
625 if let Some(readme_value) = package.get(FIELD_README) {
626 if let Some(readme_file) = readme_value.as_str() {
627 extra_data.insert("readme_file".to_string(), json!(readme_file));
628 } else if let Some(readme_enabled) = readme_value.as_bool() {
629 extra_data.insert("readme".to_string(), json!(readme_enabled));
630 } else if readme_value
631 .as_table()
632 .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
633 {
634 extra_data.insert("readme".to_string(), json!("workspace"));
635 }
636 }
637
638 if let Some(publish_value) = package.get(FIELD_PUBLISH) {
639 extra_data.insert(
640 "publish".to_string(),
641 toml_to_json(publish_value, &mut RecursionGuard::depth_only()),
642 );
643 }
644
645 if let Some(version_value) = package.get(FIELD_VERSION)
648 && version_value
649 .as_table()
650 .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
651 {
652 extra_data.insert("version".to_string(), json!("workspace"));
653 }
654
655 if let Some(license_value) = package.get(FIELD_LICENSE)
657 && license_value
658 .as_table()
659 .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
660 {
661 extra_data.insert("license".to_string(), json!("workspace"));
662 }
663
664 if let Some(homepage_value) = package.get(FIELD_HOMEPAGE)
666 && homepage_value
667 .as_table()
668 .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
669 {
670 extra_data.insert("homepage".to_string(), json!("workspace"));
671 }
672
673 if let Some(repository_value) = package.get(FIELD_REPOSITORY)
675 && repository_value
676 .as_table()
677 .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
678 {
679 extra_data.insert("repository".to_string(), json!("workspace"));
680 }
681
682 if let Some(categories_value) = package.get(FIELD_CATEGORIES)
684 && categories_value
685 .as_table()
686 .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
687 {
688 extra_data.insert("categories".to_string(), json!("workspace"));
689 }
690
691 if let Some(authors_value) = package.get(FIELD_AUTHORS)
693 && authors_value
694 .as_table()
695 .is_some_and(|t| t.get("workspace") == Some(&toml::Value::Boolean(true)))
696 {
697 extra_data.insert("authors".to_string(), json!("workspace"));
698 }
699 }
700
701 if let Some(workspace_value) = toml_content.get("workspace") {
703 extra_data.insert(
704 "workspace".to_string(),
705 toml_to_json(workspace_value, &mut RecursionGuard::depth_only()),
706 );
707 }
708
709 if extra_data.is_empty() {
710 None
711 } else {
712 Some(extra_data)
713 }
714}