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