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