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