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