1use std::collections::HashMap;
20use std::fs::File;
21use std::io::Read;
22use std::path::Path;
23
24use crate::parser_warn as 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 primary_language: Some("PHP".to_string()),
416 download_url: dist_url,
417 sha1,
418 sha256,
419 sha512,
420 md5: None,
421 is_virtual: true,
422 extra_data: None,
423 dependencies: Vec::new(),
424 repository_homepage_url: None,
425 repository_download_url: None,
426 api_data_url: None,
427 datasource_id: Some(DatasourceId::PhpComposerLock),
428 purl: None,
429 ..ResolvedPackage::new(
430 ComposerLockParser::PACKAGE_TYPE,
431 namespace.clone().unwrap_or_default(),
432 package_name.clone(),
433 version.to_string(),
434 )
435 };
436
437 Some(Dependency {
438 purl,
439 extracted_requirement: None,
440 scope: Some(scope.to_string()),
441 is_runtime: Some(is_runtime),
442 is_optional: Some(is_optional),
443 is_pinned: Some(true),
444 is_direct: Some(true),
445 resolved_package: Some(Box::new(resolved_package)),
446 extra_data,
447 })
448}
449
450fn extract_dist_hashes(
451 dist: Option<&serde_json::Map<String, Value>>,
452) -> (
453 Option<String>,
454 Option<String>,
455 Option<String>,
456 Option<String>,
457) {
458 let mut sha1 = None;
459 let mut sha256 = None;
460 let mut sha512 = None;
461 let mut raw_shasum = None;
462
463 if let Some(dist) = dist {
464 if let Some(shasum) = dist.get("shasum").and_then(|value| value.as_str()) {
465 let trimmed = shasum.trim();
466 if !trimmed.is_empty() {
467 raw_shasum = Some(trimmed.to_string());
468 let (parsed_sha1, parsed_sha256, parsed_sha512) = parse_hash_value(trimmed);
469 sha1 = parsed_sha1;
470 sha256 = parsed_sha256;
471 sha512 = parsed_sha512;
472 }
473 }
474
475 if let Some(value) = dist.get("sha1").and_then(|value| value.as_str())
476 && is_hex_hash(value)
477 {
478 sha1 = Some(value.to_string());
479 }
480 if let Some(value) = dist.get("sha256").and_then(|value| value.as_str())
481 && is_hex_hash(value)
482 {
483 sha256 = Some(value.to_string());
484 }
485 if let Some(value) = dist.get("sha512").and_then(|value| value.as_str())
486 && is_hex_hash(value)
487 {
488 sha512 = Some(value.to_string());
489 }
490 }
491
492 (sha1, sha256, sha512, raw_shasum)
493}
494
495fn parse_hash_value(hash: &str) -> (Option<String>, Option<String>, Option<String>) {
496 let trimmed = hash.trim();
497 if trimmed.is_empty() || !is_hex_hash(trimmed) {
498 return (None, None, None);
499 }
500
501 match trimmed.len() {
502 40 => (Some(trimmed.to_string()), None, None),
503 64 => (None, Some(trimmed.to_string()), None),
504 128 => (None, None, Some(trimmed.to_string())),
505 _ => (None, None, None),
506 }
507}
508
509fn is_hex_hash(value: &str) -> bool {
510 value.chars().all(|c| c.is_ascii_hexdigit())
511}
512
513fn extract_license_statement(json_content: &Value) -> Option<String> {
514 let mut licenses = Vec::new();
515
516 if let Some(license_value) = json_content.get(FIELD_LICENSE) {
517 match license_value {
518 Value::String(value) => {
519 let trimmed = value.trim();
520 if !trimmed.is_empty() {
521 licenses.push(trimmed.to_string());
522 }
523 }
524 Value::Array(values) => {
525 for value in values {
526 if let Some(license_str) = value.as_str() {
527 let trimmed = license_str.trim();
528 if !trimmed.is_empty() {
529 licenses.push(trimmed.to_string());
530 }
531 }
532 }
533 }
534 _ => {}
535 }
536 }
537
538 if licenses.is_empty() {
539 return None;
540 }
541
542 if licenses.len() == 1 {
543 Some(licenses[0].clone())
544 } else {
545 Some(licenses.join(" OR "))
546 }
547}
548
549fn extract_license_data(
550 json_content: &Value,
551 is_private: bool,
552) -> (
553 Option<String>,
554 Option<String>,
555 Option<String>,
556 Vec<LicenseDetection>,
557) {
558 let extracted_license_statement = extract_license_statement(json_content)
559 .or_else(|| is_private.then(|| "proprietary-license".to_string()));
560 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
561 normalize_composer_license_data(extracted_license_statement.as_deref());
562
563 (
564 extracted_license_statement,
565 declared_license_expression,
566 declared_license_expression_spdx,
567 license_detections,
568 )
569}
570
571fn normalize_composer_license_data(
572 extracted_license_statement: Option<&str>,
573) -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
574 let Some(extracted_license_statement) = extracted_license_statement
575 .map(str::trim)
576 .filter(|value| !value.is_empty())
577 else {
578 return super::license_normalization::empty_declared_license_data();
579 };
580
581 if extracted_license_statement.eq_ignore_ascii_case("proprietary") {
582 return build_declared_license_data_from_pair(
583 "proprietary-license",
584 "LicenseRef-scancode-proprietary-license",
585 DeclaredLicenseMatchMetadata::single_line(extracted_license_statement),
586 );
587 }
588
589 if extracted_license_statement.eq_ignore_ascii_case("proprietary-license") {
590 return build_declared_license_data_from_pair(
591 "proprietary-license",
592 "LicenseRef-scancode-proprietary-license",
593 DeclaredLicenseMatchMetadata::single_line(extracted_license_statement),
594 );
595 }
596
597 normalize_spdx_declared_license(Some(extracted_license_statement))
598}
599
600fn extract_keywords(json_content: &Value) -> Vec<String> {
601 json_content
602 .get(FIELD_KEYWORDS)
603 .and_then(|value| value.as_array())
604 .map(|values| {
605 values
606 .iter()
607 .filter_map(|value| value.as_str().map(|value| value.to_string()))
608 .collect()
609 })
610 .unwrap_or_default()
611}
612
613fn extract_parties(json_content: &Value, namespace: &Option<String>) -> Vec<Party> {
614 let mut parties = Vec::new();
615
616 if let Some(authors) = json_content
617 .get(FIELD_AUTHORS)
618 .and_then(|value| value.as_array())
619 {
620 for author in authors {
621 if let Some(author) = author.as_object() {
622 let name = author
623 .get("name")
624 .and_then(|value| value.as_str())
625 .map(|value| value.to_string());
626 let role = author
627 .get("role")
628 .and_then(|value| value.as_str())
629 .map(|value| value.to_string())
630 .or(Some("author".to_string()));
631 let email = author
632 .get("email")
633 .and_then(|value| value.as_str())
634 .map(|value| value.to_string());
635 let url = author
636 .get("homepage")
637 .and_then(|value| value.as_str())
638 .map(|value| value.to_string());
639
640 if name.is_some() || email.is_some() || url.is_some() {
641 parties.push(Party {
642 r#type: Some("person".to_string()),
643 role,
644 name,
645 email,
646 url,
647 organization: None,
648 organization_url: None,
649 timezone: None,
650 });
651 }
652 }
653 }
654 }
655
656 if let Some(vendor) = namespace
657 .as_ref()
658 .map(|value| value.trim())
659 .filter(|value| !value.is_empty())
660 {
661 parties.push(Party {
662 r#type: Some("person".to_string()),
663 role: Some("vendor".to_string()),
664 name: Some(vendor.to_string()),
665 email: None,
666 url: None,
667 organization: None,
668 organization_url: None,
669 timezone: None,
670 });
671 }
672
673 parties
674}
675
676fn extract_support(json_content: &Value) -> (Option<String>, Option<String>) {
677 let support = json_content.get(FIELD_SUPPORT).and_then(|v| v.as_object());
678
679 if let Some(support_obj) = support {
680 let bug_tracking_url = support_obj
681 .get("issues")
682 .and_then(|v| v.as_str())
683 .map(|s| s.to_string());
684
685 let code_view_url = support_obj
686 .get("source")
687 .and_then(|v| v.as_str())
688 .map(|s| s.to_string());
689
690 (bug_tracking_url, code_view_url)
691 } else {
692 (None, None)
693 }
694}
695
696fn build_extra_data(json_content: &Value) -> Option<HashMap<String, Value>> {
697 let mut extra_data = HashMap::new();
698
699 if let Some(package_type) = json_content
700 .get(FIELD_TYPE)
701 .and_then(|value| value.as_str())
702 {
703 extra_data.insert("type".to_string(), Value::String(package_type.to_string()));
704 }
705
706 if let Some(autoload) = json_content
707 .get(FIELD_AUTOLOAD)
708 .and_then(|value| value.as_object())
709 && let Some(psr4) = autoload.get(FIELD_PSR4)
710 {
711 extra_data.insert("autoload_psr4".to_string(), psr4.clone());
712 }
713
714 if let Some(repositories) = json_content.get(FIELD_REPOSITORIES) {
715 extra_data.insert("repositories".to_string(), repositories.clone());
716 }
717
718 if extra_data.is_empty() {
719 None
720 } else {
721 Some(extra_data)
722 }
723}
724
725fn extract_source_vcs_url(json_content: &Value) -> Option<String> {
726 let source = json_content.get(FIELD_SOURCE)?.as_object()?;
727 let source_type = source.get("type")?.as_str()?.trim();
728 let source_url = source.get("url")?.as_str()?.trim();
729 let source_reference = source
730 .get("reference")
731 .and_then(|value| value.as_str())
732 .map(str::trim)
733 .filter(|value| !value.is_empty());
734
735 if source_type.is_empty() || source_url.is_empty() {
736 return None;
737 }
738
739 Some(match source_reference {
740 Some(reference) => format!("{}+{}@{}", source_type, source_url, reference),
741 None => format!("{}+{}", source_type, source_url),
742 })
743}
744
745fn extract_dist_download_url(json_content: &Value) -> Option<String> {
746 json_content
747 .get(FIELD_DIST)
748 .and_then(|value| value.as_object())
749 .and_then(|dist| dist.get("url"))
750 .and_then(|value| value.as_str())
751 .map(|value| value.trim().to_string())
752 .filter(|value| !value.is_empty())
753}
754
755fn build_repository_homepage_url(
756 namespace: &Option<String>,
757 name: &Option<String>,
758) -> Option<String> {
759 match (
760 namespace.as_ref().filter(|value| !value.is_empty()),
761 name.as_ref(),
762 ) {
763 (Some(ns), Some(name)) => Some(format!("https://packagist.org/packages/{}/{}", ns, name)),
764 (None, Some(name)) => Some(format!("https://packagist.org/packages/{}", name)),
765 _ => None,
766 }
767}
768
769fn build_api_data_url(namespace: &Option<String>, name: &Option<String>) -> Option<String> {
770 match (namespace.as_ref(), name.as_ref()) {
771 (Some(ns), Some(name)) if !ns.is_empty() => Some(format!(
772 "https://packagist.org/p/packages/{}/{}.json",
773 ns, name
774 )),
775 (None, Some(name)) => Some(format!("https://packagist.org/p/packages/{}.json", name)),
776 (Some(_), Some(name)) => Some(format!("https://packagist.org/p/packages/{}.json", name)),
777 _ => None,
778 }
779}
780
781fn build_package_purl(
782 namespace: &Option<String>,
783 name: &Option<String>,
784 version: &Option<String>,
785) -> Option<String> {
786 let name = name.as_ref()?;
787 let mut package_url = match PackageUrl::new(ComposerJsonParser::PACKAGE_TYPE.as_str(), name) {
788 Ok(purl) => purl,
789 Err(e) => {
790 warn!(
791 "Failed to create PackageUrl for composer package '{}': {}",
792 name, e
793 );
794 return None;
795 }
796 };
797
798 if let Some(namespace) = namespace.as_ref().filter(|value| !value.is_empty())
799 && let Err(e) = package_url.with_namespace(namespace)
800 {
801 warn!(
802 "Failed to set namespace '{}' for composer package '{}': {}",
803 namespace, name, e
804 );
805 return None;
806 }
807
808 if let Some(version) = version.as_ref()
809 && let Err(e) = package_url.with_version(version)
810 {
811 warn!(
812 "Failed to set version '{}' for composer package '{}': {}",
813 version, name, e
814 );
815 return None;
816 }
817
818 Some(package_url.to_string())
819}
820
821fn build_dependency_purl(
822 namespace: Option<&str>,
823 name: &str,
824 version: Option<&str>,
825) -> Option<String> {
826 let mut package_url = match PackageUrl::new(ComposerJsonParser::PACKAGE_TYPE.as_str(), name) {
827 Ok(purl) => purl,
828 Err(e) => {
829 warn!(
830 "Failed to create PackageUrl for composer package '{}': {}",
831 name, e
832 );
833 return None;
834 }
835 };
836
837 if let Some(namespace) = namespace.filter(|value| !value.is_empty())
838 && let Err(e) = package_url.with_namespace(namespace)
839 {
840 warn!(
841 "Failed to set namespace '{}' for composer package '{}': {}",
842 namespace, name, e
843 );
844 return None;
845 }
846
847 if let Some(version) = version
848 && let Err(e) = package_url.with_version(version)
849 {
850 warn!(
851 "Failed to set version '{}' for composer package '{}': {}",
852 version, name, e
853 );
854 return None;
855 }
856
857 Some(package_url.to_string())
858}
859
860fn split_optional_namespace_name(full_name: Option<&str>) -> (Option<String>, Option<String>) {
861 match full_name {
862 Some(full_name) => {
863 let (namespace, name) = split_namespace_name(full_name);
864 (namespace, Some(name))
865 }
866 None => (None, None),
867 }
868}
869
870fn split_namespace_name(full_name: &str) -> (Option<String>, String) {
871 let mut iter = full_name.splitn(2, '/');
872 let first = iter.next().unwrap_or("");
873 let second = iter.next();
874
875 if let Some(name) = second {
876 (Some(first.to_string()), name.to_string())
877 } else {
878 (None, first.to_string())
879 }
880}
881
882fn normalize_requirement_version(requirement: &str) -> String {
883 let trimmed = requirement.trim();
884 trimmed.trim_start_matches('=').trim().to_string()
885}
886
887fn is_composer_version_pinned(version: &str) -> bool {
888 let trimmed = version.trim();
889 if trimmed.is_empty() {
890 return false;
891 }
892
893 if trimmed.contains(" - ")
894 || trimmed.contains('|')
895 || trimmed.contains(',')
896 || trimmed.contains('^')
897 || trimmed.contains('~')
898 || trimmed.contains('>')
899 || trimmed.contains('<')
900 || trimmed.contains('*')
901 {
902 return false;
903 }
904
905 let without_prefix = trimmed.trim_start_matches('=').trim();
906 let without_prefix = without_prefix.strip_prefix('v').unwrap_or(without_prefix);
907 if without_prefix.is_empty() {
908 return false;
909 }
910
911 let lower = without_prefix.to_lowercase();
912 if lower.contains("dev") {
913 return false;
914 }
915
916 if without_prefix
917 .chars()
918 .any(|c| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+')
919 {
920 return false;
921 }
922
923 without_prefix.matches('.').count() >= 2
924}
925
926fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
927 PackageData {
928 package_type: Some(ComposerJsonParser::PACKAGE_TYPE),
929 primary_language: Some("PHP".to_string()),
930 datasource_id,
931 ..Default::default()
932 }
933}
934
935crate::register_parser!(
936 "PHP composer manifest",
937 &["**/*composer.json", "**/composer.*.json"],
938 "composer",
939 "PHP",
940 Some("https://getcomposer.org/doc/04-schema.md"),
941);
942
943crate::register_parser!(
944 "PHP composer lockfile",
945 &["**/*composer.lock", "**/composer.*.lock"],
946 "composer",
947 "PHP",
948 Some("https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file"),
949);