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