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