1use std::collections::HashMap;
26use std::path::Path;
27
28use crate::parser_warn as warn;
29use crate::parsers::utils::{capped_iteration_limit, read_file_to_string, truncate_field};
30use packageurl::PackageUrl;
31use serde_json::Value;
32
33use crate::models::{
34 DatasourceId, Dependency, LicenseDetection, PackageData, PackageType, Party, PartyType,
35 ResolvedPackage, Sha1Digest, Sha256Digest, Sha512Digest,
36};
37
38use super::PackageParser;
39use super::license_normalization::{
40 DeclaredLicenseMatchMetadata, build_declared_license_data_from_pair,
41 normalize_spdx_declared_license,
42};
43
44const FIELD_NAME: &str = "name";
45const FIELD_VERSION: &str = "version";
46const FIELD_DESCRIPTION: &str = "description";
47const FIELD_HOMEPAGE: &str = "homepage";
48const FIELD_TYPE: &str = "type";
49const FIELD_LICENSE: &str = "license";
50const FIELD_AUTHORS: &str = "authors";
51const FIELD_KEYWORDS: &str = "keywords";
52const FIELD_REQUIRE: &str = "require";
53const FIELD_REQUIRE_DEV: &str = "require-dev";
54const FIELD_PROVIDE: &str = "provide";
55const FIELD_CONFLICT: &str = "conflict";
56const FIELD_REPLACE: &str = "replace";
57const FIELD_SUGGEST: &str = "suggest";
58const FIELD_SUPPORT: &str = "support";
59const FIELD_AUTOLOAD: &str = "autoload";
60const FIELD_PSR4: &str = "psr-4";
61const FIELD_REPOSITORIES: &str = "repositories";
62
63const FIELD_PACKAGES: &str = "packages";
64const FIELD_PACKAGES_DEV: &str = "packages-dev";
65const FIELD_SOURCE: &str = "source";
66const FIELD_DIST: &str = "dist";
67
68pub struct ComposerJsonParser;
70
71impl PackageParser for ComposerJsonParser {
72 const PACKAGE_TYPE: PackageType = PackageType::Composer;
73
74 fn extract_packages(path: &Path) -> Vec<PackageData> {
75 let json_content = match read_json_file(path) {
76 Ok(content) => content,
77 Err(e) => {
78 warn!("Failed to read composer.json at {:?}: {}", path, e);
79 return vec![default_package_data(Some(DatasourceId::PhpComposerJson))];
80 }
81 };
82
83 let full_name = json_content
84 .get(FIELD_NAME)
85 .and_then(|value| value.as_str())
86 .map(|value| value.trim())
87 .filter(|value| !value.is_empty());
88
89 let (namespace, name) = split_optional_namespace_name(full_name);
90 let is_private = name.is_none();
91
92 let version = json_content
93 .get(FIELD_VERSION)
94 .and_then(|value| value.as_str())
95 .map(|value| truncate_field(value.trim().to_string()));
96
97 let description = json_content
98 .get(FIELD_DESCRIPTION)
99 .and_then(|value| value.as_str())
100 .map(|value| truncate_field(value.trim().to_string()))
101 .filter(|value| !value.is_empty());
102
103 let homepage_url = json_content
104 .get(FIELD_HOMEPAGE)
105 .and_then(|value| value.as_str())
106 .map(|value| truncate_field(value.trim().to_string()))
107 .filter(|value| !value.is_empty());
108
109 let keywords = extract_keywords(&json_content);
110
111 let (
112 extracted_license_statement,
113 declared_license_expression,
114 declared_license_expression_spdx,
115 license_detections,
116 ) = extract_license_data(&json_content, is_private);
117
118 let dependencies =
119 extract_dependencies(&json_content, FIELD_REQUIRE, "require", true, false);
120 let dev_dependencies =
121 extract_dependencies(&json_content, FIELD_REQUIRE_DEV, "require-dev", false, true);
122 let provide_dependencies =
123 extract_dependencies(&json_content, FIELD_PROVIDE, "provide", true, false);
124 let conflict_dependencies =
125 extract_dependencies(&json_content, FIELD_CONFLICT, "conflict", true, true);
126 let replace_dependencies =
127 extract_dependencies(&json_content, FIELD_REPLACE, "replace", true, true);
128 let suggest_dependencies =
129 extract_dependencies(&json_content, FIELD_SUGGEST, "suggest", true, true);
130
131 let (bug_tracking_url, code_view_url) = extract_support(&json_content);
132 let vcs_url = extract_source_vcs_url(&json_content);
133 let download_url = extract_dist_download_url(&json_content);
134 let extra_data = build_extra_data(&json_content);
135 let parties = extract_parties(&json_content, &namespace);
136
137 vec![PackageData {
138 package_type: Some(Self::PACKAGE_TYPE),
139 namespace: namespace.clone(),
140 name: name.clone(),
141 version: version.clone(),
142 qualifiers: None,
143 subpath: None,
144 primary_language: Some("PHP".to_string()),
145 description,
146 release_date: None,
147 parties,
148 keywords,
149 homepage_url,
150 download_url,
151 size: None,
152 sha1: None,
153 md5: None,
154 sha256: None,
155 sha512: None,
156 bug_tracking_url,
157 code_view_url,
158 vcs_url,
159 copyright: None,
160 holder: None,
161 declared_license_expression,
162 declared_license_expression_spdx,
163 license_detections,
164 other_license_expression: None,
165 other_license_expression_spdx: None,
166 other_license_detections: Vec::new(),
167 extracted_license_statement,
168 notice_text: None,
169 source_packages: Vec::new(),
170 file_references: Vec::new(),
171 is_private,
172 is_virtual: false,
173 extra_data,
174 dependencies: [
175 dependencies,
176 dev_dependencies,
177 provide_dependencies,
178 conflict_dependencies,
179 replace_dependencies,
180 suggest_dependencies,
181 ]
182 .concat(),
183 repository_homepage_url: build_repository_homepage_url(&namespace, &name),
184 repository_download_url: None,
185 api_data_url: build_api_data_url(&namespace, &name),
186 datasource_id: Some(DatasourceId::PhpComposerJson),
187 purl: build_package_purl(&namespace, &name, &version),
188 }]
189 }
190
191 fn is_match(path: &Path) -> bool {
192 path.file_name()
193 .and_then(|name| name.to_str())
194 .is_some_and(is_composer_manifest_filename)
195 }
196
197 fn metadata() -> Vec<super::metadata::ParserMetadata> {
198 vec![super::metadata::ParserMetadata {
199 description: "PHP composer manifest",
200 file_patterns: &["**/*composer.json", "**/composer.*.json"],
201 package_type: "composer",
202 primary_language: "PHP",
203 documentation_url: Some("https://getcomposer.org/doc/04-schema.md"),
204 }]
205 }
206}
207
208pub struct ComposerLockParser;
210
211impl PackageParser for ComposerLockParser {
212 const PACKAGE_TYPE: PackageType = PackageType::Composer;
213
214 fn extract_packages(path: &Path) -> Vec<PackageData> {
215 let json_content = match read_json_file(path) {
216 Ok(content) => content,
217 Err(e) => {
218 warn!("Failed to read composer.lock at {:?}: {}", path, e);
219 return vec![default_package_data(Some(DatasourceId::PhpComposerLock))];
220 }
221 };
222
223 let dependencies = extract_lock_dependencies(&json_content);
224
225 let mut package_data = default_package_data(Some(DatasourceId::PhpComposerLock));
226 package_data.dependencies = dependencies;
227
228 let mut packages = vec![package_data];
229 packages.extend(extract_lock_packages(&json_content));
230 packages
231 }
232
233 fn is_match(path: &Path) -> bool {
234 path.file_name()
235 .and_then(|name| name.to_str())
236 .is_some_and(is_composer_lock_filename)
237 }
238
239 fn metadata() -> Vec<super::metadata::ParserMetadata> {
240 vec![super::metadata::ParserMetadata {
241 description: "PHP composer lockfile",
242 file_patterns: &["**/*composer.lock", "**/composer.*.lock"],
243 package_type: "composer",
244 primary_language: "PHP",
245 documentation_url: Some(
246 "https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
247 ),
248 }]
249 }
250}
251
252fn is_composer_manifest_filename(name: &str) -> bool {
253 name == "composer.json"
254 || name.ends_with(".composer.json")
255 || (name.starts_with("composer.") && name.ends_with(".json"))
256}
257
258fn is_composer_lock_filename(name: &str) -> bool {
259 name == "composer.lock"
260 || name.ends_with(".composer.lock")
261 || (name.starts_with("composer.") && name.ends_with(".lock"))
262}
263
264fn read_json_file(path: &Path) -> Result<Value, String> {
265 let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
266 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
267}
268
269fn extract_dependencies(
270 json_content: &Value,
271 field: &str,
272 scope: &str,
273 is_runtime: bool,
274 is_optional: bool,
275) -> Vec<Dependency> {
276 json_content
277 .get(field)
278 .and_then(|value| value.as_object())
279 .map_or_else(Vec::new, |deps| {
280 let limit = capped_iteration_limit(deps.len(), "composer.json dependencies");
281 deps.iter()
282 .take(limit)
283 .filter_map(|(name, requirement)| {
284 let requirement_str = requirement.as_str()?;
285 let (namespace, package_name) = split_namespace_name(name);
286 let is_pinned = is_composer_version_pinned(requirement_str);
287 let version_for_purl = if is_pinned {
288 Some(normalize_requirement_version(requirement_str))
289 } else {
290 None
291 };
292
293 let purl = build_dependency_purl(
294 namespace.as_deref(),
295 &package_name,
296 version_for_purl.as_deref(),
297 );
298
299 Some(Dependency {
300 purl,
301 extracted_requirement: Some(truncate_field(requirement_str.to_string())),
302 scope: Some(truncate_field(scope.to_string())),
303 is_runtime: Some(is_runtime),
304 is_optional: Some(is_optional),
305 is_pinned: Some(is_pinned),
306 is_direct: Some(true),
307 resolved_package: None,
308 extra_data: None,
309 })
310 })
311 .collect()
312 })
313}
314
315fn extract_lock_dependencies(json_content: &Value) -> Vec<Dependency> {
316 let mut dependencies = Vec::new();
317
318 let packages = json_content
319 .get(FIELD_PACKAGES)
320 .and_then(|value| value.as_array())
321 .map(|packages| packages.as_slice())
322 .unwrap_or(&[]);
323 let packages_dev = json_content
324 .get(FIELD_PACKAGES_DEV)
325 .and_then(|value| value.as_array())
326 .map(|packages| packages.as_slice())
327 .unwrap_or(&[]);
328
329 dependencies.reserve(packages.len() + packages_dev.len());
330 dependencies.extend(extract_lock_package_list(packages, "require", true, false));
331 dependencies.extend(extract_lock_package_list(
332 packages_dev,
333 "require-dev",
334 false,
335 true,
336 ));
337
338 dependencies
339}
340
341fn extract_lock_packages(json_content: &Value) -> Vec<PackageData> {
342 let packages = json_content
343 .get(FIELD_PACKAGES)
344 .and_then(|value| value.as_array())
345 .map(|packages| packages.as_slice())
346 .unwrap_or(&[]);
347 let packages_dev = json_content
348 .get(FIELD_PACKAGES_DEV)
349 .and_then(|value| value.as_array())
350 .map(|packages| packages.as_slice())
351 .unwrap_or(&[]);
352
353 let mut extracted = Vec::with_capacity(packages.len() + packages_dev.len());
354
355 let packages_limit = capped_iteration_limit(packages.len(), "composer.lock packages");
356 for package in packages.iter().take(packages_limit) {
357 if let Some(package_data) = build_lock_package_data(package, false) {
358 extracted.push(package_data);
359 }
360 }
361
362 let packages_dev_limit =
363 capped_iteration_limit(packages_dev.len(), "composer.lock packages-dev");
364 for package in packages_dev.iter().take(packages_dev_limit) {
365 if let Some(package_data) = build_lock_package_data(package, true) {
366 extracted.push(package_data);
367 }
368 }
369
370 extracted
371}
372
373fn extract_lock_package_list(
374 packages: &[Value],
375 scope: &str,
376 is_runtime: bool,
377 is_optional: bool,
378) -> Vec<Dependency> {
379 let mut dependencies = Vec::new();
380
381 let limit = capped_iteration_limit(packages.len(), "composer.lock package list");
382 for package in packages.iter().take(limit) {
383 if let Some(dependency) = build_lock_dependency(package, scope, is_runtime, is_optional) {
384 dependencies.push(dependency);
385 }
386
387 dependencies.extend(extract_lock_package_relationships(package));
388 }
389
390 dependencies
391}
392
393fn extract_lock_package_relationships(package: &Value) -> Vec<Dependency> {
394 [
395 extract_dependencies(package, FIELD_REQUIRE, "require", true, false),
396 extract_dependencies(package, FIELD_REQUIRE_DEV, "require-dev", false, true),
397 extract_dependencies(package, FIELD_PROVIDE, "provide", true, false),
398 extract_dependencies(package, FIELD_CONFLICT, "conflict", true, true),
399 extract_dependencies(package, FIELD_REPLACE, "replace", true, true),
400 extract_dependencies(package, FIELD_SUGGEST, "suggest", true, true),
401 ]
402 .concat()
403}
404
405fn build_lock_dependency(
406 package: &Value,
407 scope: &str,
408 is_runtime: bool,
409 is_optional: bool,
410) -> Option<Dependency> {
411 let name = package.get(FIELD_NAME).and_then(|value| value.as_str())?;
412 let version = package
413 .get(FIELD_VERSION)
414 .and_then(|value| value.as_str())?;
415 let package_type = package.get(FIELD_TYPE).and_then(|value| value.as_str());
416
417 let (namespace, package_name) = split_namespace_name(name);
418 let purl = build_dependency_purl(namespace.as_deref(), &package_name, Some(version));
419
420 let source = package
421 .get(FIELD_SOURCE)
422 .and_then(|value| value.as_object());
423 let dist = package.get(FIELD_DIST).and_then(|value| value.as_object());
424
425 let (sha1, sha256, sha512, dist_shasum) = extract_dist_hashes(dist);
426 let dist_url = dist
427 .and_then(|map| map.get("url"))
428 .and_then(|value| value.as_str())
429 .map(|value| truncate_field(value.to_string()));
430
431 let mut extra_data = HashMap::new();
432
433 if let Some(package_type) = package_type {
434 extra_data.insert("type".to_string(), Value::String(package_type.to_string()));
435 }
436
437 if let Some(source_map) = source {
438 if let Some(source_reference) = source_map.get("reference").and_then(|value| value.as_str())
439 {
440 extra_data.insert(
441 "source_reference".to_string(),
442 Value::String(source_reference.to_string()),
443 );
444 }
445
446 if let Some(source_url) = source_map.get("url").and_then(|value| value.as_str()) {
447 extra_data.insert(
448 "source_url".to_string(),
449 Value::String(source_url.to_string()),
450 );
451 }
452
453 if let Some(source_type) = source_map.get("type").and_then(|value| value.as_str()) {
454 extra_data.insert(
455 "source_type".to_string(),
456 Value::String(source_type.to_string()),
457 );
458 }
459 }
460
461 if let Some(dist_map) = dist {
462 if let Some(dist_reference) = dist_map.get("reference").and_then(|value| value.as_str()) {
463 extra_data.insert(
464 "dist_reference".to_string(),
465 Value::String(dist_reference.to_string()),
466 );
467 }
468
469 if let Some(dist_url) = dist_map.get("url").and_then(|value| value.as_str()) {
470 extra_data.insert("dist_url".to_string(), Value::String(dist_url.to_string()));
471 }
472
473 if let Some(dist_type) = dist_map.get("type").and_then(|value| value.as_str()) {
474 extra_data.insert(
475 "dist_type".to_string(),
476 Value::String(dist_type.to_string()),
477 );
478 }
479 }
480
481 if let Some(shasum) = dist_shasum {
482 extra_data.insert("dist_shasum".to_string(), Value::String(shasum));
483 }
484
485 let extra_data = if extra_data.is_empty() {
486 None
487 } else {
488 Some(extra_data)
489 };
490
491 let (
496 extracted_license_statement,
497 declared_license_expression,
498 declared_license_expression_spdx,
499 license_detections,
500 ) = extract_license_data(package, false);
501
502 let resolved_package = ResolvedPackage {
503 primary_language: Some("PHP".to_string()),
504 declared_license_expression,
505 declared_license_expression_spdx,
506 extracted_license_statement,
507 license_detections,
508 download_url: dist_url,
509 sha1: sha1.and_then(|h| Sha1Digest::from_hex(&h).ok()),
510 sha256: sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
511 sha512: sha512.and_then(|h| Sha512Digest::from_hex(&h).ok()),
512 md5: None,
513 is_virtual: true,
514 extra_data: None,
515 dependencies: Vec::new(),
516 repository_homepage_url: None,
517 repository_download_url: None,
518 api_data_url: None,
519 datasource_id: Some(DatasourceId::PhpComposerLock),
520 purl: None,
521 ..ResolvedPackage::new(
522 ComposerLockParser::PACKAGE_TYPE,
523 namespace.clone().unwrap_or_default(),
524 package_name.clone(),
525 version.to_string(),
526 )
527 };
528
529 Some(Dependency {
530 purl,
531 extracted_requirement: None,
532 scope: Some(truncate_field(scope.to_string())),
533 is_runtime: Some(is_runtime),
534 is_optional: Some(is_optional),
535 is_pinned: Some(true),
536 is_direct: Some(true),
537 resolved_package: Some(Box::new(resolved_package)),
538 extra_data,
539 })
540}
541
542fn build_lock_package_data(package: &Value, is_dev_package: bool) -> Option<PackageData> {
543 let dependency = build_lock_dependency(
544 package,
545 if is_dev_package {
546 "packages-dev"
547 } else {
548 "packages"
549 },
550 !is_dev_package,
551 is_dev_package,
552 )?;
553 let resolved = dependency.resolved_package.as_deref()?;
554
555 let mut package_data = default_package_data(Some(DatasourceId::PhpComposerLock));
556 package_data.package_type = Some(ComposerLockParser::PACKAGE_TYPE);
557 package_data.namespace = (!resolved.namespace.is_empty()).then(|| resolved.namespace.clone());
558 package_data.name = Some(truncate_field(resolved.name.clone()));
559 package_data.version = Some(truncate_field(resolved.version.clone()));
560 package_data.primary_language = resolved.primary_language.clone();
561 package_data.description = resolved.description.clone();
562 package_data.release_date = resolved.release_date.clone();
563 package_data.parties = resolved.parties.clone();
564 package_data.keywords = resolved.keywords.clone();
565 package_data.homepage_url = resolved.homepage_url.clone();
566 package_data.download_url = resolved.download_url.clone();
567 package_data.sha1 = resolved.sha1;
568 package_data.md5 = resolved.md5;
569 package_data.sha256 = resolved.sha256;
570 package_data.sha512 = resolved.sha512;
571 package_data.bug_tracking_url = resolved.bug_tracking_url.clone();
572 package_data.code_view_url = resolved.code_view_url.clone();
573 package_data.vcs_url = resolved.vcs_url.clone();
574 package_data.copyright = resolved.copyright.clone();
575 package_data.holder = resolved.holder.clone();
576 package_data.declared_license_expression = resolved.declared_license_expression.clone();
577 package_data.declared_license_expression_spdx =
578 resolved.declared_license_expression_spdx.clone();
579 package_data.license_detections = resolved.license_detections.clone();
580 package_data.other_license_expression = resolved.other_license_expression.clone();
581 package_data.other_license_expression_spdx = resolved.other_license_expression_spdx.clone();
582 package_data.other_license_detections = resolved.other_license_detections.clone();
583 package_data.extracted_license_statement = resolved.extracted_license_statement.clone();
584 package_data.notice_text = resolved.notice_text.clone();
585 package_data.source_packages = resolved.source_packages.clone();
586 package_data.file_references = resolved.file_references.clone();
587 package_data.is_private = resolved.is_private;
588 package_data.is_virtual = resolved.is_virtual;
589 package_data.extra_data = dependency
590 .extra_data
591 .clone()
592 .or_else(|| resolved.extra_data.clone());
593 package_data.dependencies = resolved.dependencies.clone();
594 package_data.repository_homepage_url = resolved.repository_homepage_url.clone();
595 package_data.repository_download_url = resolved.repository_download_url.clone();
596 package_data.api_data_url = resolved.api_data_url.clone();
597 package_data.purl = dependency.purl.clone();
598
599 Some(package_data)
600}
601
602fn extract_dist_hashes(
603 dist: Option<&serde_json::Map<String, Value>>,
604) -> (
605 Option<String>,
606 Option<String>,
607 Option<String>,
608 Option<String>,
609) {
610 let mut sha1 = None;
611 let mut sha256 = None;
612 let mut sha512 = None;
613 let mut raw_shasum = None;
614
615 if let Some(dist) = dist {
616 if let Some(shasum) = dist.get("shasum").and_then(|value| value.as_str()) {
617 let trimmed = shasum.trim();
618 if !trimmed.is_empty() {
619 raw_shasum = Some(trimmed.to_string());
620 let (parsed_sha1, parsed_sha256, parsed_sha512) = parse_hash_value(trimmed);
621 sha1 = parsed_sha1;
622 sha256 = parsed_sha256;
623 sha512 = parsed_sha512;
624 }
625 }
626
627 if let Some(value) = dist.get("sha1").and_then(|value| value.as_str())
628 && is_hex_hash(value)
629 {
630 sha1 = Some(value.to_string());
631 }
632 if let Some(value) = dist.get("sha256").and_then(|value| value.as_str())
633 && is_hex_hash(value)
634 {
635 sha256 = Some(value.to_string());
636 }
637 if let Some(value) = dist.get("sha512").and_then(|value| value.as_str())
638 && is_hex_hash(value)
639 {
640 sha512 = Some(value.to_string());
641 }
642 }
643
644 (sha1, sha256, sha512, raw_shasum)
645}
646
647fn parse_hash_value(hash: &str) -> (Option<String>, Option<String>, Option<String>) {
648 let trimmed = hash.trim();
649 if trimmed.is_empty() || !is_hex_hash(trimmed) {
650 return (None, None, None);
651 }
652
653 match trimmed.len() {
654 40 => (Some(trimmed.to_string()), None, None),
655 64 => (None, Some(trimmed.to_string()), None),
656 128 => (None, None, Some(trimmed.to_string())),
657 _ => (None, None, None),
658 }
659}
660
661fn is_hex_hash(value: &str) -> bool {
662 value.chars().all(|c| c.is_ascii_hexdigit())
663}
664
665fn extract_license_statement(json_content: &Value) -> Option<String> {
666 let mut licenses = Vec::new();
667
668 if let Some(license_value) = json_content.get(FIELD_LICENSE) {
669 match license_value {
670 Value::String(value) => {
671 let trimmed = value.trim();
672 if !trimmed.is_empty() {
673 licenses.push(trimmed.to_string());
674 }
675 }
676 Value::Array(values) => {
677 for value in values {
678 if let Some(license_str) = value.as_str() {
679 let trimmed = license_str.trim();
680 if !trimmed.is_empty() {
681 licenses.push(trimmed.to_string());
682 }
683 }
684 }
685 }
686 _ => {}
687 }
688 }
689
690 if licenses.is_empty() {
691 return None;
692 }
693
694 if licenses.len() == 1 {
695 Some(truncate_field(licenses[0].clone()))
696 } else {
697 Some(truncate_field(licenses.join(" OR ")))
698 }
699}
700
701fn extract_license_data(
702 json_content: &Value,
703 is_private: bool,
704) -> (
705 Option<String>,
706 Option<String>,
707 Option<String>,
708 Vec<LicenseDetection>,
709) {
710 let extracted_license_statement = extract_license_statement(json_content)
711 .or_else(|| is_private.then(|| "proprietary-license".to_string()));
712 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
713 normalize_composer_license_data(extracted_license_statement.as_deref());
714
715 (
716 extracted_license_statement,
717 declared_license_expression,
718 declared_license_expression_spdx,
719 license_detections,
720 )
721}
722
723fn normalize_composer_license_data(
724 extracted_license_statement: Option<&str>,
725) -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
726 let Some(extracted_license_statement) = extracted_license_statement
727 .map(str::trim)
728 .filter(|value| !value.is_empty())
729 else {
730 return super::license_normalization::empty_declared_license_data();
731 };
732
733 if extracted_license_statement.eq_ignore_ascii_case("proprietary") {
734 return build_declared_license_data_from_pair(
735 "proprietary-license",
736 "LicenseRef-scancode-proprietary-license",
737 DeclaredLicenseMatchMetadata::single_line(extracted_license_statement),
738 );
739 }
740
741 if extracted_license_statement.eq_ignore_ascii_case("proprietary-license") {
742 return build_declared_license_data_from_pair(
743 "proprietary-license",
744 "LicenseRef-scancode-proprietary-license",
745 DeclaredLicenseMatchMetadata::single_line(extracted_license_statement),
746 );
747 }
748
749 normalize_spdx_declared_license(Some(extracted_license_statement))
750}
751
752fn extract_keywords(json_content: &Value) -> Vec<String> {
753 json_content
754 .get(FIELD_KEYWORDS)
755 .and_then(|value| value.as_array())
756 .map(|values| {
757 let limit = capped_iteration_limit(values.len(), "composer.json keywords");
758 values
759 .iter()
760 .take(limit)
761 .filter_map(|value| {
762 value
763 .as_str()
764 .map(|value| truncate_field(value.to_string()))
765 })
766 .collect()
767 })
768 .unwrap_or_default()
769}
770
771fn extract_parties(json_content: &Value, namespace: &Option<String>) -> Vec<Party> {
772 let mut parties = Vec::new();
773
774 if let Some(authors) = json_content
775 .get(FIELD_AUTHORS)
776 .and_then(|value| value.as_array())
777 {
778 let limit = capped_iteration_limit(authors.len(), "composer.json authors");
779 for author in authors.iter().take(limit) {
780 if let Some(author) = author.as_object() {
781 let name = author
782 .get("name")
783 .and_then(|value| value.as_str())
784 .map(|value| truncate_field(value.to_string()));
785 let role = author
786 .get("role")
787 .and_then(|value| value.as_str())
788 .map(|value| truncate_field(value.to_string()))
789 .or(Some("author".to_string()));
790 let email = author
791 .get("email")
792 .and_then(|value| value.as_str())
793 .map(|value| truncate_field(value.to_string()));
794 let url = author
795 .get("homepage")
796 .and_then(|value| value.as_str())
797 .map(|value| truncate_field(value.to_string()));
798
799 if name.is_some() || email.is_some() || url.is_some() {
800 parties.push(Party {
801 r#type: Some(PartyType::Person),
802 role,
803 name,
804 email,
805 url,
806 organization: None,
807 organization_url: None,
808 timezone: None,
809 });
810 }
811 }
812 }
813 }
814
815 if let Some(vendor) = namespace
816 .as_ref()
817 .map(|value| value.trim())
818 .filter(|value| !value.is_empty())
819 {
820 parties.push(Party {
821 r#type: Some(PartyType::Person),
822 role: Some("vendor".to_string()),
823 name: Some(truncate_field(vendor.to_string())),
824 email: None,
825 url: None,
826 organization: None,
827 organization_url: None,
828 timezone: None,
829 });
830 }
831
832 parties
833}
834
835fn extract_support(json_content: &Value) -> (Option<String>, Option<String>) {
836 let support = json_content.get(FIELD_SUPPORT).and_then(|v| v.as_object());
837
838 if let Some(support_obj) = support {
839 let bug_tracking_url = support_obj
840 .get("issues")
841 .and_then(|v| v.as_str())
842 .map(|s| truncate_field(s.to_string()));
843
844 let code_view_url = support_obj
845 .get("source")
846 .and_then(|v| v.as_str())
847 .map(|s| truncate_field(s.to_string()));
848
849 (bug_tracking_url, code_view_url)
850 } else {
851 (None, None)
852 }
853}
854
855fn build_extra_data(json_content: &Value) -> Option<HashMap<String, Value>> {
856 let mut extra_data = HashMap::new();
857
858 if let Some(package_type) = json_content
859 .get(FIELD_TYPE)
860 .and_then(|value| value.as_str())
861 {
862 extra_data.insert("type".to_string(), Value::String(package_type.to_string()));
863 }
864
865 if let Some(autoload) = json_content
866 .get(FIELD_AUTOLOAD)
867 .and_then(|value| value.as_object())
868 && let Some(psr4) = autoload.get(FIELD_PSR4)
869 {
870 extra_data.insert("autoload_psr4".to_string(), psr4.clone());
871 }
872
873 if let Some(repositories) = json_content.get(FIELD_REPOSITORIES) {
874 extra_data.insert("repositories".to_string(), repositories.clone());
875 }
876
877 if extra_data.is_empty() {
878 None
879 } else {
880 Some(extra_data)
881 }
882}
883
884fn extract_source_vcs_url(json_content: &Value) -> Option<String> {
885 let source = json_content.get(FIELD_SOURCE)?.as_object()?;
886 let source_type = source.get("type")?.as_str()?.trim();
887 let source_url = source.get("url")?.as_str()?.trim();
888 let source_reference = source
889 .get("reference")
890 .and_then(|value| value.as_str())
891 .map(str::trim)
892 .filter(|value| !value.is_empty());
893
894 if source_type.is_empty() || source_url.is_empty() {
895 return None;
896 }
897
898 Some(truncate_field(match source_reference {
899 Some(reference) => format!("{}+{}@{}", source_type, source_url, reference),
900 None => format!("{}+{}", source_type, source_url),
901 }))
902}
903
904fn extract_dist_download_url(json_content: &Value) -> Option<String> {
905 json_content
906 .get(FIELD_DIST)
907 .and_then(|value| value.as_object())
908 .and_then(|dist| dist.get("url"))
909 .and_then(|value| value.as_str())
910 .map(|value| truncate_field(value.trim().to_string()))
911 .filter(|value| !value.is_empty())
912}
913
914fn build_repository_homepage_url(
915 namespace: &Option<String>,
916 name: &Option<String>,
917) -> Option<String> {
918 match (
919 namespace.as_ref().filter(|value| !value.is_empty()),
920 name.as_ref(),
921 ) {
922 (Some(ns), Some(name)) => Some(format!("https://packagist.org/packages/{}/{}", ns, name)),
923 (None, Some(name)) => Some(format!("https://packagist.org/packages/{}", name)),
924 _ => None,
925 }
926}
927
928fn build_api_data_url(namespace: &Option<String>, name: &Option<String>) -> Option<String> {
929 match (namespace.as_ref(), name.as_ref()) {
930 (Some(ns), Some(name)) if !ns.is_empty() => Some(format!(
931 "https://packagist.org/p/packages/{}/{}.json",
932 ns, name
933 )),
934 (None, Some(name)) => Some(format!("https://packagist.org/p/packages/{}.json", name)),
935 (Some(_), Some(name)) => Some(format!("https://packagist.org/p/packages/{}.json", name)),
936 _ => None,
937 }
938}
939
940fn build_package_purl(
941 namespace: &Option<String>,
942 name: &Option<String>,
943 version: &Option<String>,
944) -> Option<String> {
945 let name = name.as_ref()?;
946 let mut package_url = match PackageUrl::new(ComposerJsonParser::PACKAGE_TYPE.as_str(), name) {
947 Ok(purl) => purl,
948 Err(e) => {
949 warn!(
950 "Failed to create PackageUrl for composer package '{}': {}",
951 name, e
952 );
953 return None;
954 }
955 };
956
957 if let Some(namespace) = namespace.as_ref().filter(|value| !value.is_empty())
958 && let Err(e) = package_url.with_namespace(namespace)
959 {
960 warn!(
961 "Failed to set namespace '{}' for composer package '{}': {}",
962 namespace, name, e
963 );
964 return None;
965 }
966
967 if let Some(version) = version.as_ref()
968 && let Err(e) = package_url.with_version(version)
969 {
970 warn!(
971 "Failed to set version '{}' for composer package '{}': {}",
972 version, name, e
973 );
974 return None;
975 }
976
977 Some(package_url.to_string())
978}
979
980fn build_dependency_purl(
981 namespace: Option<&str>,
982 name: &str,
983 version: Option<&str>,
984) -> Option<String> {
985 let mut package_url = match PackageUrl::new(ComposerJsonParser::PACKAGE_TYPE.as_str(), name) {
986 Ok(purl) => purl,
987 Err(e) => {
988 warn!(
989 "Failed to create PackageUrl for composer package '{}': {}",
990 name, e
991 );
992 return None;
993 }
994 };
995
996 if let Some(namespace) = namespace.filter(|value| !value.is_empty())
997 && let Err(e) = package_url.with_namespace(namespace)
998 {
999 warn!(
1000 "Failed to set namespace '{}' for composer package '{}': {}",
1001 namespace, name, e
1002 );
1003 return None;
1004 }
1005
1006 if let Some(version) = version
1007 && let Err(e) = package_url.with_version(version)
1008 {
1009 warn!(
1010 "Failed to set version '{}' for composer package '{}': {}",
1011 version, name, e
1012 );
1013 return None;
1014 }
1015
1016 Some(package_url.to_string())
1017}
1018
1019fn split_optional_namespace_name(full_name: Option<&str>) -> (Option<String>, Option<String>) {
1020 match full_name {
1021 Some(full_name) => {
1022 let (namespace, name) = split_namespace_name(full_name);
1023 (namespace, Some(name))
1024 }
1025 None => (None, None),
1026 }
1027}
1028
1029fn split_namespace_name(full_name: &str) -> (Option<String>, String) {
1030 let mut iter = full_name.splitn(2, '/');
1031 let first = iter.next().unwrap_or("");
1032 let second = iter.next();
1033
1034 if let Some(name) = second {
1035 (
1036 Some(truncate_field(first.to_string())),
1037 truncate_field(name.to_string()),
1038 )
1039 } else {
1040 (None, truncate_field(first.to_string()))
1041 }
1042}
1043
1044fn normalize_requirement_version(requirement: &str) -> String {
1045 let trimmed = requirement.trim();
1046 trimmed.trim_start_matches('=').trim().to_string()
1047}
1048
1049fn is_composer_version_pinned(version: &str) -> bool {
1050 let trimmed = version.trim();
1051 if trimmed.is_empty() {
1052 return false;
1053 }
1054
1055 if trimmed.contains(" - ")
1056 || trimmed.contains('|')
1057 || trimmed.contains(',')
1058 || trimmed.contains('^')
1059 || trimmed.contains('~')
1060 || trimmed.contains('>')
1061 || trimmed.contains('<')
1062 || trimmed.contains('*')
1063 {
1064 return false;
1065 }
1066
1067 let without_prefix = trimmed.trim_start_matches('=').trim();
1068 let without_prefix = without_prefix.strip_prefix('v').unwrap_or(without_prefix);
1069 if without_prefix.is_empty() {
1070 return false;
1071 }
1072
1073 let lower = without_prefix.to_lowercase();
1074 if lower.contains("dev") {
1075 return false;
1076 }
1077
1078 if without_prefix
1079 .chars()
1080 .any(|c| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+')
1081 {
1082 return false;
1083 }
1084
1085 without_prefix.matches('.').count() >= 2
1086}
1087
1088fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
1089 PackageData {
1090 package_type: Some(ComposerJsonParser::PACKAGE_TYPE),
1091 primary_language: Some("PHP".to_string()),
1092 datasource_id,
1093 ..Default::default()
1094 }
1095}