1use std::path::Path;
28
29use crate::parser_warn as warn;
30use packageurl::PackageUrl;
31use serde_json::Value as JsonValue;
32use yaml_serde::Value as YamlValue;
33
34use crate::models::{
35 DatasourceId, Dependency, FileReference, PackageData, PackageType, Party, PartyType,
36};
37use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
38
39use super::PackageParser;
40use super::license_normalization::{
41 DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
42 combine_normalized_licenses, empty_declared_license_data, normalize_declared_license_key,
43 normalize_spdx_expression,
44};
45
46const FIELD_NAME: &str = "name";
47const FIELD_VERSION: &str = "version";
48const FIELD_ABSTRACT: &str = "abstract";
49const FIELD_DESCRIPTION: &str = "description";
50const FIELD_LICENSE: &str = "license";
51const FIELD_AUTHOR: &str = "author";
52const FIELD_RESOURCES: &str = "resources";
53const FIELD_PREREQS: &str = "prereqs";
54const FIELD_REQUIRES: &str = "requires";
55const FIELD_BUILD_REQUIRES: &str = "build_requires";
56const FIELD_TEST_REQUIRES: &str = "test_requires";
57const FIELD_CONFIGURE_REQUIRES: &str = "configure_requires";
58
59pub struct CpanMetaJsonParser;
64
65impl PackageParser for CpanMetaJsonParser {
66 const PACKAGE_TYPE: PackageType = PackageType::Cpan;
67
68 fn is_match(path: &Path) -> bool {
69 path.file_name().is_some_and(|name| name == "META.json")
70 }
71
72 fn extract_packages(path: &Path) -> Vec<PackageData> {
73 let json = match read_and_parse_json(path) {
74 Ok(json) => json,
75 Err(e) => {
76 warn!("Failed to parse META.json at {:?}: {}", path, e);
77 return vec![default_package_data(DatasourceId::CpanMetaJson)];
78 }
79 };
80
81 let name = json
82 .get(FIELD_NAME)
83 .and_then(|v| v.as_str())
84 .map(|s| truncate_field(s.to_string()));
85
86 let version = extract_version_from_json(&json);
87
88 let description = json
89 .get(FIELD_ABSTRACT)
90 .and_then(|v| v.as_str())
91 .map(|s| truncate_field(s.to_string()));
92
93 let extracted_license_statement = extract_license_from_json(&json);
94 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
95 normalize_cpan_declared_license(
96 json.get(FIELD_LICENSE),
97 extracted_license_statement.as_deref(),
98 );
99 let declared_license_expression = declared_license_expression.map(truncate_field);
100 let declared_license_expression_spdx = declared_license_expression_spdx.map(truncate_field);
101 let parties = extract_parties_from_json(&json);
102 let dependencies = extract_dependencies_from_json(&json);
103 let (homepage_url, vcs_url, code_view_url, bug_tracking_url) =
104 extract_resources_from_json(&json);
105
106 vec![PackageData {
107 package_type: Some(Self::PACKAGE_TYPE),
108 name,
109 version,
110 description,
111 declared_license_expression,
112 declared_license_expression_spdx,
113 license_detections,
114 extracted_license_statement,
115 parties,
116 dependencies,
117 homepage_url,
118 vcs_url,
119 code_view_url,
120 bug_tracking_url,
121 primary_language: Some("Perl".to_string()),
122 datasource_id: Some(DatasourceId::CpanMetaJson),
123 ..Default::default()
124 }]
125 }
126
127 fn metadata() -> Vec<super::metadata::ParserMetadata> {
128 vec![super::metadata::ParserMetadata {
129 description: "CPAN Perl META.json",
130 file_patterns: &["**/META.json"],
131 package_type: "cpan",
132 primary_language: "Perl",
133 documentation_url: Some("https://metacpan.org/pod/CPAN::Meta::Spec"),
134 }]
135 }
136}
137
138pub struct CpanMetaYmlParser;
142
143impl PackageParser for CpanMetaYmlParser {
144 const PACKAGE_TYPE: PackageType = PackageType::Cpan;
145
146 fn is_match(path: &Path) -> bool {
147 path.file_name().is_some_and(|name| name == "META.yml")
148 }
149
150 fn extract_packages(path: &Path) -> Vec<PackageData> {
151 let yaml = match read_and_parse_yaml(path) {
152 Ok(yaml) => yaml,
153 Err(e) => {
154 warn!("Failed to parse META.yml at {:?}: {}", path, e);
155 return vec![default_package_data(DatasourceId::CpanMetaYml)];
156 }
157 };
158
159 let name = yaml
160 .get(FIELD_NAME)
161 .and_then(|v| v.as_str())
162 .map(|s| truncate_field(s.to_string()));
163
164 let version = extract_version_from_yaml(&yaml);
165
166 let description = yaml
167 .get(FIELD_ABSTRACT)
168 .or_else(|| yaml.get(FIELD_DESCRIPTION))
169 .and_then(|v| v.as_str())
170 .map(|s| truncate_field(s.to_string()));
171
172 let extracted_license_statement = extract_license_from_yaml(&yaml);
173 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
174 normalize_cpan_declared_license(
175 yaml.get(YamlValue::String(FIELD_LICENSE.to_string())),
176 extracted_license_statement.as_deref(),
177 );
178 let declared_license_expression = declared_license_expression.map(truncate_field);
179 let declared_license_expression_spdx = declared_license_expression_spdx.map(truncate_field);
180 let parties = extract_parties_from_yaml(&yaml);
181 let dependencies = extract_dependencies_from_yaml(&yaml);
182 let (homepage_url, vcs_url, bug_tracking_url) = extract_resources_from_yaml(&yaml);
183
184 vec![PackageData {
185 package_type: Some(Self::PACKAGE_TYPE),
186 name,
187 version,
188 description,
189 declared_license_expression,
190 declared_license_expression_spdx,
191 license_detections,
192 extracted_license_statement,
193 parties,
194 dependencies,
195 homepage_url,
196 vcs_url,
197 bug_tracking_url,
198 primary_language: Some("Perl".to_string()),
199 datasource_id: Some(DatasourceId::CpanMetaYml),
200 ..Default::default()
201 }]
202 }
203
204 fn metadata() -> Vec<super::metadata::ParserMetadata> {
205 vec![super::metadata::ParserMetadata {
206 description: "CPAN Perl META.yml",
207 file_patterns: &["**/META.yml"],
208 package_type: "cpan",
209 primary_language: "Perl",
210 documentation_url: Some("https://metacpan.org/pod/CPAN::Meta::Spec"),
211 }]
212 }
213}
214
215pub struct CpanManifestParser;
219
220impl PackageParser for CpanManifestParser {
221 const PACKAGE_TYPE: PackageType = PackageType::Cpan;
222
223 fn is_match(path: &Path) -> bool {
224 path.file_name().is_some_and(|name| name == "MANIFEST")
225 }
226
227 fn extract_packages(path: &Path) -> Vec<PackageData> {
228 let content = match read_file_to_string(path, None) {
229 Ok(content) => content,
230 Err(e) => {
231 warn!("Failed to read MANIFEST at {:?}: {}", path, e);
232 return vec![default_package_data(DatasourceId::CpanManifest)];
233 }
234 };
235
236 let file_references = content
237 .lines()
238 .take(MAX_ITERATION_COUNT)
239 .filter(|line| !line.trim().is_empty())
240 .filter(|line| !line.trim().starts_with('#'))
241 .map(|line| {
242 let path = line.split_whitespace().next().unwrap_or(line);
243 FileReference {
244 path: truncate_field(path.to_string()),
245 size: None,
246 sha1: None,
247 md5: None,
248 sha256: None,
249 sha512: None,
250 extra_data: None,
251 }
252 })
253 .collect();
254
255 vec![PackageData {
256 package_type: Some(Self::PACKAGE_TYPE),
257 file_references,
258 primary_language: Some("Perl".to_string()),
259 datasource_id: Some(DatasourceId::CpanManifest),
260 ..Default::default()
261 }]
262 }
263
264 fn metadata() -> Vec<super::metadata::ParserMetadata> {
265 vec![super::metadata::ParserMetadata {
266 description: "CPAN Perl MANIFEST",
267 file_patterns: &["**/MANIFEST"],
268 package_type: "cpan",
269 primary_language: "Perl",
270 documentation_url: Some("https://metacpan.org/pod/Module::Manifest"),
271 }]
272 }
273}
274
275fn default_package_data(datasource_id: DatasourceId) -> PackageData {
276 PackageData {
277 package_type: Some(CpanMetaJsonParser::PACKAGE_TYPE),
278 primary_language: Some("Perl".to_string()),
279 datasource_id: Some(datasource_id),
280 ..Default::default()
281 }
282}
283
284fn read_and_parse_json(path: &Path) -> Result<serde_json::Map<String, JsonValue>, String> {
285 let content =
286 read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
287 let json: JsonValue =
288 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
289 json.as_object()
290 .cloned()
291 .ok_or_else(|| "Root JSON is not an object".to_string())
292}
293
294fn read_and_parse_yaml(path: &Path) -> Result<yaml_serde::Mapping, String> {
295 let content =
296 read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
297 let yaml: YamlValue =
298 yaml_serde::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
299 yaml.as_mapping()
300 .cloned()
301 .ok_or_else(|| "Root YAML is not a mapping".to_string())
302}
303
304fn extract_version_from_json(json: &serde_json::Map<String, JsonValue>) -> Option<String> {
305 json.get(FIELD_VERSION).and_then(|v| match v {
306 JsonValue::String(s) => Some(truncate_field(s.clone())),
307 JsonValue::Number(n) => Some(truncate_field(n.to_string())),
308 _ => None,
309 })
310}
311
312fn extract_version_from_yaml(yaml: &yaml_serde::Mapping) -> Option<String> {
313 yaml.get(YamlValue::String(FIELD_VERSION.to_string()))
314 .and_then(|v| match v {
315 YamlValue::String(s) => Some(truncate_field(s.clone())),
316 YamlValue::Number(n) => Some(truncate_field(n.to_string())),
317 _ => None,
318 })
319}
320
321fn extract_license_from_json(json: &serde_json::Map<String, JsonValue>) -> Option<String> {
322 json.get(FIELD_LICENSE).and_then(|v| match v {
323 JsonValue::String(s) => Some(truncate_field(s.clone())),
324 JsonValue::Array(arr) => {
325 let licenses: Vec<String> = arr
326 .iter()
327 .take(MAX_ITERATION_COUNT)
328 .filter_map(|item| item.as_str().map(|s| truncate_field(s.to_string())))
329 .collect();
330 if licenses.is_empty() {
331 None
332 } else {
333 Some(truncate_field(licenses.join(" AND ")))
334 }
335 }
336 _ => None,
337 })
338}
339
340fn extract_license_from_yaml(yaml: &yaml_serde::Mapping) -> Option<String> {
341 yaml.get(YamlValue::String(FIELD_LICENSE.to_string()))
342 .and_then(|v| match v {
343 YamlValue::String(s) => Some(truncate_field(s.clone())),
344 YamlValue::Sequence(arr) => {
345 let licenses: Vec<String> = arr
346 .iter()
347 .take(MAX_ITERATION_COUNT)
348 .filter_map(|item| item.as_str().map(|s| truncate_field(s.to_string())))
349 .collect();
350 if licenses.is_empty() {
351 None
352 } else {
353 Some(truncate_field(licenses.join(" AND ")))
354 }
355 }
356 _ => None,
357 })
358}
359
360fn normalize_cpan_declared_license(
361 raw_license: Option<&impl LicenseValueAdapter>,
362 extracted_license_statement: Option<&str>,
363) -> (
364 Option<String>,
365 Option<String>,
366 Vec<crate::models::LicenseDetection>,
367) {
368 let Some(raw_license) = raw_license else {
369 return empty_declared_license_data();
370 };
371 let normalized = raw_license
372 .license_values()
373 .into_iter()
374 .map(|value| normalize_cpan_license_value(&value))
375 .collect::<Option<Vec<_>>>();
376
377 if let Some(normalized) = normalized
378 && let Some(combined) = combine_normalized_licenses(normalized, " AND ")
379 {
380 return build_declared_license_data(
381 combined,
382 DeclaredLicenseMatchMetadata::single_line(
383 extracted_license_statement.unwrap_or_default(),
384 ),
385 );
386 }
387
388 empty_declared_license_data()
389}
390
391trait LicenseValueAdapter {
392 fn license_values(&self) -> Vec<String>;
393}
394
395impl LicenseValueAdapter for JsonValue {
396 fn license_values(&self) -> Vec<String> {
397 match self {
398 JsonValue::String(value) => vec![truncate_field(value.trim().to_string())],
399 JsonValue::Array(values) => values
400 .iter()
401 .take(MAX_ITERATION_COUNT)
402 .filter_map(|value| value.as_str())
403 .map(str::trim)
404 .filter(|value| !value.is_empty())
405 .map(|s| truncate_field(s.to_string()))
406 .collect(),
407 _ => Vec::new(),
408 }
409 }
410}
411
412impl LicenseValueAdapter for YamlValue {
413 fn license_values(&self) -> Vec<String> {
414 match self {
415 YamlValue::String(value) => vec![truncate_field(value.trim().to_string())],
416 YamlValue::Sequence(values) => values
417 .iter()
418 .take(MAX_ITERATION_COUNT)
419 .filter_map(|value| value.as_str())
420 .map(str::trim)
421 .filter(|value| !value.is_empty())
422 .map(|s| truncate_field(s.to_string()))
423 .collect(),
424 _ => Vec::new(),
425 }
426 }
427}
428
429fn normalize_cpan_license_value(value: &str) -> Option<NormalizedDeclaredLicense> {
430 match value.trim() {
431 "perl_5" | "Perl_5" => Some(NormalizedDeclaredLicense::new(
432 "gpl-1.0-plus OR artistic-perl-1.0",
433 "GPL-1.0-or-later OR Artistic-1.0-Perl",
434 )),
435 "artistic_2" => Some(NormalizedDeclaredLicense::new(
436 "artistic-2.0",
437 "Artistic-2.0",
438 )),
439 "apache_2_0" => Some(NormalizedDeclaredLicense::new("apache-2.0", "Apache-2.0")),
440 other => normalize_spdx_expression(other).or_else(|| normalize_declared_license_key(other)),
441 }
442}
443
444fn extract_parties_from_json(json: &serde_json::Map<String, JsonValue>) -> Vec<Party> {
445 json.get(FIELD_AUTHOR)
446 .and_then(|v| v.as_array())
447 .map_or_else(Vec::new, |authors| {
448 authors
449 .iter()
450 .take(MAX_ITERATION_COUNT)
451 .filter_map(|author| {
452 author.as_str().map(|s| {
453 let (name, email) = parse_author_string(s);
454 Party {
455 r#type: Some(PartyType::Person),
456 role: Some("author".to_string()),
457 name,
458 email,
459 url: None,
460 organization: None,
461 organization_url: None,
462 timezone: None,
463 }
464 })
465 })
466 .collect()
467 })
468}
469
470fn extract_parties_from_yaml(yaml: &yaml_serde::Mapping) -> Vec<Party> {
471 yaml.get(YamlValue::String(FIELD_AUTHOR.to_string()))
472 .and_then(|v| v.as_sequence())
473 .map_or_else(Vec::new, |authors| {
474 authors
475 .iter()
476 .take(MAX_ITERATION_COUNT)
477 .filter_map(|author| {
478 author.as_str().map(|s| {
479 let (name, email) = parse_author_string(s);
480 Party {
481 r#type: Some(PartyType::Person),
482 role: Some("author".to_string()),
483 name,
484 email,
485 url: None,
486 organization: None,
487 organization_url: None,
488 timezone: None,
489 }
490 })
491 })
492 .collect()
493 })
494}
495
496fn parse_author_string(author_str: &str) -> (Option<String>, Option<String>) {
497 if let Some(email_start) = author_str.find('<')
498 && let Some(email_end) = author_str.find('>')
499 && email_start < email_end
500 {
501 let name = author_str[..email_start].trim();
502 let email = author_str[email_start + 1..email_end].trim();
503 return (
504 if name.is_empty() {
505 None
506 } else {
507 Some(truncate_field(name.to_string()))
508 },
509 if email.is_empty() {
510 None
511 } else {
512 Some(truncate_field(email.to_string()))
513 },
514 );
515 }
516 let trimmed = author_str.trim();
517 (
518 if trimmed.is_empty() {
519 None
520 } else {
521 Some(truncate_field(trimmed.to_string()))
522 },
523 None,
524 )
525}
526
527fn extract_resources_from_json(
528 json: &serde_json::Map<String, JsonValue>,
529) -> (
530 Option<String>,
531 Option<String>,
532 Option<String>,
533 Option<String>,
534) {
535 let resources = match json.get(FIELD_RESOURCES).and_then(|v| v.as_object()) {
536 Some(r) => r,
537 None => return (None, None, None, None),
538 };
539
540 let homepage_url = resources
541 .get("homepage")
542 .and_then(|v| v.as_str())
543 .map(|s| truncate_field(s.to_string()));
544
545 let vcs_url = resources.get("repository").and_then(|v| match v {
546 JsonValue::String(s) => Some(truncate_field(s.clone())),
547 JsonValue::Object(obj) => obj
548 .get("url")
549 .and_then(|u| u.as_str())
550 .map(|s| truncate_field(s.to_string())),
551 _ => None,
552 });
553
554 let code_view_url = resources
555 .get("repository")
556 .and_then(|v| v.as_object())
557 .and_then(|obj| {
558 obj.get("web")
559 .and_then(|u| u.as_str())
560 .map(|s| truncate_field(s.to_string()))
561 });
562
563 let bug_tracking_url = resources.get("bugtracker").and_then(|v| match v {
564 JsonValue::String(s) => Some(truncate_field(s.clone())),
565 JsonValue::Object(obj) => obj
566 .get("web")
567 .and_then(|u| u.as_str())
568 .map(|s| truncate_field(s.to_string())),
569 _ => None,
570 });
571
572 (homepage_url, vcs_url, code_view_url, bug_tracking_url)
573}
574
575fn extract_resources_from_yaml(
576 yaml: &yaml_serde::Mapping,
577) -> (Option<String>, Option<String>, Option<String>) {
578 let resources = match yaml
579 .get(YamlValue::String(FIELD_RESOURCES.to_string()))
580 .and_then(|v| v.as_mapping())
581 {
582 Some(r) => r,
583 None => return (None, None, None),
584 };
585
586 let homepage_url = resources
587 .get(YamlValue::String("homepage".to_string()))
588 .and_then(|v| v.as_str())
589 .map(|s| truncate_field(s.to_string()));
590
591 let vcs_url = resources
592 .get(YamlValue::String("repository".to_string()))
593 .and_then(|v| v.as_str())
594 .map(|s| truncate_field(s.to_string()));
595
596 let bug_tracking_url = resources
597 .get(YamlValue::String("bugtracker".to_string()))
598 .and_then(|v| v.as_str())
599 .map(|s| truncate_field(s.to_string()));
600
601 (homepage_url, vcs_url, bug_tracking_url)
602}
603
604fn extract_dependencies_from_json(json: &serde_json::Map<String, JsonValue>) -> Vec<Dependency> {
605 let mut dependencies = Vec::new();
606
607 let prereqs = match json.get(FIELD_PREREQS).and_then(|v| v.as_object()) {
608 Some(p) => p,
609 None => return dependencies,
610 };
611
612 if let Some(runtime) = prereqs.get("runtime").and_then(|v| v.as_object())
614 && let Some(requires) = runtime.get("requires").and_then(|v| v.as_object())
615 {
616 dependencies.extend(extract_dependency_group(requires, "runtime", true, false));
617 }
618
619 if let Some(build) = prereqs.get("build").and_then(|v| v.as_object())
621 && let Some(requires) = build.get("requires").and_then(|v| v.as_object())
622 {
623 dependencies.extend(extract_dependency_group(requires, "build", false, false));
624 }
625
626 if let Some(test) = prereqs.get("test").and_then(|v| v.as_object())
628 && let Some(requires) = test.get("requires").and_then(|v| v.as_object())
629 {
630 dependencies.extend(extract_dependency_group(requires, "test", false, false));
631 }
632
633 if let Some(configure) = prereqs.get("configure").and_then(|v| v.as_object())
635 && let Some(requires) = configure.get("requires").and_then(|v| v.as_object())
636 {
637 dependencies.extend(extract_dependency_group(
638 requires,
639 "configure",
640 false,
641 false,
642 ));
643 }
644
645 dependencies
646}
647
648fn extract_dependencies_from_yaml(yaml: &yaml_serde::Mapping) -> Vec<Dependency> {
649 let mut dependencies = Vec::new();
650
651 if let Some(requires) = yaml
653 .get(YamlValue::String(FIELD_REQUIRES.to_string()))
654 .and_then(|v| v.as_mapping())
655 {
656 dependencies.extend(extract_yaml_dependency_group(
657 requires, "runtime", true, false,
658 ));
659 }
660
661 if let Some(build_requires) = yaml
662 .get(YamlValue::String(FIELD_BUILD_REQUIRES.to_string()))
663 .and_then(|v| v.as_mapping())
664 {
665 dependencies.extend(extract_yaml_dependency_group(
666 build_requires,
667 "build",
668 false,
669 false,
670 ));
671 }
672
673 if let Some(test_requires) = yaml
674 .get(YamlValue::String(FIELD_TEST_REQUIRES.to_string()))
675 .and_then(|v| v.as_mapping())
676 {
677 dependencies.extend(extract_yaml_dependency_group(
678 test_requires,
679 "test",
680 false,
681 false,
682 ));
683 }
684
685 if let Some(configure_requires) = yaml
686 .get(YamlValue::String(FIELD_CONFIGURE_REQUIRES.to_string()))
687 .and_then(|v| v.as_mapping())
688 {
689 dependencies.extend(extract_yaml_dependency_group(
690 configure_requires,
691 "configure",
692 false,
693 false,
694 ));
695 }
696
697 dependencies
698}
699
700fn extract_dependency_group(
701 deps: &serde_json::Map<String, JsonValue>,
702 scope: &str,
703 is_runtime: bool,
704 is_optional: bool,
705) -> Vec<Dependency> {
706 deps.iter()
707 .take(MAX_ITERATION_COUNT)
708 .filter_map(|(name, version)| {
709 if name == "perl" {
710 return None;
711 }
712
713 let purl = PackageUrl::new("cpan", name).ok().map(|p| p.to_string());
714 let purl = purl.map(truncate_field);
715
716 let extracted_requirement = match version {
717 JsonValue::String(s) => Some(truncate_field(s.clone())),
718 JsonValue::Number(n) => Some(truncate_field(n.to_string())),
719 _ => None,
720 };
721
722 Some(Dependency {
723 purl,
724 extracted_requirement,
725 scope: Some(truncate_field(scope.to_string())),
726 is_runtime: Some(is_runtime),
727 is_optional: Some(is_optional),
728 is_pinned: None,
729 is_direct: Some(true),
730 resolved_package: None,
731 extra_data: None,
732 })
733 })
734 .collect()
735}
736
737fn extract_yaml_dependency_group(
738 deps: &yaml_serde::Mapping,
739 scope: &str,
740 is_runtime: bool,
741 is_optional: bool,
742) -> Vec<Dependency> {
743 deps.iter()
744 .take(MAX_ITERATION_COUNT)
745 .filter_map(|(key, value)| {
746 let name = key.as_str()?;
747
748 if name == "perl" {
749 return None;
750 }
751
752 let purl = PackageUrl::new("cpan", name).ok().map(|p| p.to_string());
753 let purl = purl.map(truncate_field);
754
755 let extracted_requirement = match value {
756 YamlValue::String(s) => Some(truncate_field(s.clone())),
757 YamlValue::Number(n) => Some(truncate_field(n.to_string())),
758 _ => None,
759 };
760
761 Some(Dependency {
762 purl,
763 extracted_requirement,
764 scope: Some(truncate_field(scope.to_string())),
765 is_runtime: Some(is_runtime),
766 is_optional: Some(is_optional),
767 is_pinned: None,
768 is_direct: Some(true),
769 resolved_package: None,
770 extra_data: None,
771 })
772 })
773 .collect()
774}