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