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 vec![package_data]
215 }
216
217 fn is_match(path: &Path) -> bool {
218 path.file_name()
219 .and_then(|name| name.to_str())
220 .is_some_and(is_composer_lock_filename)
221 }
222}
223
224fn is_composer_manifest_filename(name: &str) -> bool {
225 name == "composer.json"
226 || name.ends_with(".composer.json")
227 || (name.starts_with("composer.") && name.ends_with(".json"))
228}
229
230fn is_composer_lock_filename(name: &str) -> bool {
231 name == "composer.lock"
232 || name.ends_with(".composer.lock")
233 || (name.starts_with("composer.") && name.ends_with(".lock"))
234}
235
236fn read_json_file(path: &Path) -> Result<Value, String> {
237 let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
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 .take(MAX_ITERATION_COUNT)
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(truncate_field(requirement_str.to_string())),
273 scope: Some(truncate_field(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.iter().take(MAX_ITERATION_COUNT) {
321 if let Some(dependency) = build_lock_dependency(package, scope, is_runtime, is_optional) {
322 dependencies.push(dependency);
323 }
324
325 dependencies.extend(extract_lock_package_relationships(package));
326 }
327
328 dependencies
329}
330
331fn extract_lock_package_relationships(package: &Value) -> Vec<Dependency> {
332 [
333 extract_dependencies(package, FIELD_REQUIRE, "require", true, false),
334 extract_dependencies(package, FIELD_REQUIRE_DEV, "require-dev", false, true),
335 extract_dependencies(package, FIELD_PROVIDE, "provide", true, false),
336 extract_dependencies(package, FIELD_CONFLICT, "conflict", true, true),
337 extract_dependencies(package, FIELD_REPLACE, "replace", true, true),
338 extract_dependencies(package, FIELD_SUGGEST, "suggest", true, true),
339 ]
340 .concat()
341}
342
343fn build_lock_dependency(
344 package: &Value,
345 scope: &str,
346 is_runtime: bool,
347 is_optional: bool,
348) -> Option<Dependency> {
349 let name = package.get(FIELD_NAME).and_then(|value| value.as_str())?;
350 let version = package
351 .get(FIELD_VERSION)
352 .and_then(|value| value.as_str())?;
353 let package_type = package.get(FIELD_TYPE).and_then(|value| value.as_str());
354
355 let (namespace, package_name) = split_namespace_name(name);
356 let purl = build_dependency_purl(namespace.as_deref(), &package_name, Some(version));
357
358 let source = package
359 .get(FIELD_SOURCE)
360 .and_then(|value| value.as_object());
361 let dist = package.get(FIELD_DIST).and_then(|value| value.as_object());
362
363 let (sha1, sha256, sha512, dist_shasum) = extract_dist_hashes(dist);
364 let dist_url = dist
365 .and_then(|map| map.get("url"))
366 .and_then(|value| value.as_str())
367 .map(|value| truncate_field(value.to_string()));
368
369 let mut extra_data = HashMap::new();
370
371 if let Some(package_type) = package_type {
372 extra_data.insert("type".to_string(), Value::String(package_type.to_string()));
373 }
374
375 if let Some(source_map) = source {
376 if let Some(source_reference) = source_map.get("reference").and_then(|value| value.as_str())
377 {
378 extra_data.insert(
379 "source_reference".to_string(),
380 Value::String(source_reference.to_string()),
381 );
382 }
383
384 if let Some(source_url) = source_map.get("url").and_then(|value| value.as_str()) {
385 extra_data.insert(
386 "source_url".to_string(),
387 Value::String(source_url.to_string()),
388 );
389 }
390
391 if let Some(source_type) = source_map.get("type").and_then(|value| value.as_str()) {
392 extra_data.insert(
393 "source_type".to_string(),
394 Value::String(source_type.to_string()),
395 );
396 }
397 }
398
399 if let Some(dist_map) = dist {
400 if let Some(dist_reference) = dist_map.get("reference").and_then(|value| value.as_str()) {
401 extra_data.insert(
402 "dist_reference".to_string(),
403 Value::String(dist_reference.to_string()),
404 );
405 }
406
407 if let Some(dist_url) = dist_map.get("url").and_then(|value| value.as_str()) {
408 extra_data.insert("dist_url".to_string(), Value::String(dist_url.to_string()));
409 }
410
411 if let Some(dist_type) = dist_map.get("type").and_then(|value| value.as_str()) {
412 extra_data.insert(
413 "dist_type".to_string(),
414 Value::String(dist_type.to_string()),
415 );
416 }
417 }
418
419 if let Some(shasum) = dist_shasum {
420 extra_data.insert("dist_shasum".to_string(), Value::String(shasum));
421 }
422
423 let extra_data = if extra_data.is_empty() {
424 None
425 } else {
426 Some(extra_data)
427 };
428
429 let resolved_package = ResolvedPackage {
430 primary_language: Some("PHP".to_string()),
431 download_url: dist_url,
432 sha1: sha1.and_then(|h| Sha1Digest::from_hex(&h).ok()),
433 sha256: sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
434 sha512: sha512.and_then(|h| Sha512Digest::from_hex(&h).ok()),
435 md5: None,
436 is_virtual: true,
437 extra_data: None,
438 dependencies: Vec::new(),
439 repository_homepage_url: None,
440 repository_download_url: None,
441 api_data_url: None,
442 datasource_id: Some(DatasourceId::PhpComposerLock),
443 purl: None,
444 ..ResolvedPackage::new(
445 ComposerLockParser::PACKAGE_TYPE,
446 namespace.clone().unwrap_or_default(),
447 package_name.clone(),
448 version.to_string(),
449 )
450 };
451
452 Some(Dependency {
453 purl,
454 extracted_requirement: None,
455 scope: Some(truncate_field(scope.to_string())),
456 is_runtime: Some(is_runtime),
457 is_optional: Some(is_optional),
458 is_pinned: Some(true),
459 is_direct: Some(true),
460 resolved_package: Some(Box::new(resolved_package)),
461 extra_data,
462 })
463}
464
465fn extract_dist_hashes(
466 dist: Option<&serde_json::Map<String, Value>>,
467) -> (
468 Option<String>,
469 Option<String>,
470 Option<String>,
471 Option<String>,
472) {
473 let mut sha1 = None;
474 let mut sha256 = None;
475 let mut sha512 = None;
476 let mut raw_shasum = None;
477
478 if let Some(dist) = dist {
479 if let Some(shasum) = dist.get("shasum").and_then(|value| value.as_str()) {
480 let trimmed = shasum.trim();
481 if !trimmed.is_empty() {
482 raw_shasum = Some(trimmed.to_string());
483 let (parsed_sha1, parsed_sha256, parsed_sha512) = parse_hash_value(trimmed);
484 sha1 = parsed_sha1;
485 sha256 = parsed_sha256;
486 sha512 = parsed_sha512;
487 }
488 }
489
490 if let Some(value) = dist.get("sha1").and_then(|value| value.as_str())
491 && is_hex_hash(value)
492 {
493 sha1 = Some(value.to_string());
494 }
495 if let Some(value) = dist.get("sha256").and_then(|value| value.as_str())
496 && is_hex_hash(value)
497 {
498 sha256 = Some(value.to_string());
499 }
500 if let Some(value) = dist.get("sha512").and_then(|value| value.as_str())
501 && is_hex_hash(value)
502 {
503 sha512 = Some(value.to_string());
504 }
505 }
506
507 (sha1, sha256, sha512, raw_shasum)
508}
509
510fn parse_hash_value(hash: &str) -> (Option<String>, Option<String>, Option<String>) {
511 let trimmed = hash.trim();
512 if trimmed.is_empty() || !is_hex_hash(trimmed) {
513 return (None, None, None);
514 }
515
516 match trimmed.len() {
517 40 => (Some(trimmed.to_string()), None, None),
518 64 => (None, Some(trimmed.to_string()), None),
519 128 => (None, None, Some(trimmed.to_string())),
520 _ => (None, None, None),
521 }
522}
523
524fn is_hex_hash(value: &str) -> bool {
525 value.chars().all(|c| c.is_ascii_hexdigit())
526}
527
528fn extract_license_statement(json_content: &Value) -> Option<String> {
529 let mut licenses = Vec::new();
530
531 if let Some(license_value) = json_content.get(FIELD_LICENSE) {
532 match license_value {
533 Value::String(value) => {
534 let trimmed = value.trim();
535 if !trimmed.is_empty() {
536 licenses.push(trimmed.to_string());
537 }
538 }
539 Value::Array(values) => {
540 for value in values {
541 if let Some(license_str) = value.as_str() {
542 let trimmed = license_str.trim();
543 if !trimmed.is_empty() {
544 licenses.push(trimmed.to_string());
545 }
546 }
547 }
548 }
549 _ => {}
550 }
551 }
552
553 if licenses.is_empty() {
554 return None;
555 }
556
557 if licenses.len() == 1 {
558 Some(truncate_field(licenses[0].clone()))
559 } else {
560 Some(truncate_field(licenses.join(" OR ")))
561 }
562}
563
564fn extract_license_data(
565 json_content: &Value,
566 is_private: bool,
567) -> (
568 Option<String>,
569 Option<String>,
570 Option<String>,
571 Vec<LicenseDetection>,
572) {
573 let extracted_license_statement = extract_license_statement(json_content)
574 .or_else(|| is_private.then(|| "proprietary-license".to_string()));
575 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
576 normalize_composer_license_data(extracted_license_statement.as_deref());
577
578 (
579 extracted_license_statement,
580 declared_license_expression,
581 declared_license_expression_spdx,
582 license_detections,
583 )
584}
585
586fn normalize_composer_license_data(
587 extracted_license_statement: Option<&str>,
588) -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
589 let Some(extracted_license_statement) = extracted_license_statement
590 .map(str::trim)
591 .filter(|value| !value.is_empty())
592 else {
593 return super::license_normalization::empty_declared_license_data();
594 };
595
596 if extracted_license_statement.eq_ignore_ascii_case("proprietary") {
597 return build_declared_license_data_from_pair(
598 "proprietary-license",
599 "LicenseRef-scancode-proprietary-license",
600 DeclaredLicenseMatchMetadata::single_line(extracted_license_statement),
601 );
602 }
603
604 if extracted_license_statement.eq_ignore_ascii_case("proprietary-license") {
605 return build_declared_license_data_from_pair(
606 "proprietary-license",
607 "LicenseRef-scancode-proprietary-license",
608 DeclaredLicenseMatchMetadata::single_line(extracted_license_statement),
609 );
610 }
611
612 normalize_spdx_declared_license(Some(extracted_license_statement))
613}
614
615fn extract_keywords(json_content: &Value) -> Vec<String> {
616 json_content
617 .get(FIELD_KEYWORDS)
618 .and_then(|value| value.as_array())
619 .map(|values| {
620 values
621 .iter()
622 .take(MAX_ITERATION_COUNT)
623 .filter_map(|value| {
624 value
625 .as_str()
626 .map(|value| truncate_field(value.to_string()))
627 })
628 .collect()
629 })
630 .unwrap_or_default()
631}
632
633fn extract_parties(json_content: &Value, namespace: &Option<String>) -> Vec<Party> {
634 let mut parties = Vec::new();
635
636 if let Some(authors) = json_content
637 .get(FIELD_AUTHORS)
638 .and_then(|value| value.as_array())
639 {
640 for author in authors.iter().take(MAX_ITERATION_COUNT) {
641 if let Some(author) = author.as_object() {
642 let name = author
643 .get("name")
644 .and_then(|value| value.as_str())
645 .map(|value| truncate_field(value.to_string()));
646 let role = author
647 .get("role")
648 .and_then(|value| value.as_str())
649 .map(|value| truncate_field(value.to_string()))
650 .or(Some("author".to_string()));
651 let email = author
652 .get("email")
653 .and_then(|value| value.as_str())
654 .map(|value| truncate_field(value.to_string()));
655 let url = author
656 .get("homepage")
657 .and_then(|value| value.as_str())
658 .map(|value| truncate_field(value.to_string()));
659
660 if name.is_some() || email.is_some() || url.is_some() {
661 parties.push(Party {
662 r#type: Some("person".to_string()),
663 role,
664 name,
665 email,
666 url,
667 organization: None,
668 organization_url: None,
669 timezone: None,
670 });
671 }
672 }
673 }
674 }
675
676 if let Some(vendor) = namespace
677 .as_ref()
678 .map(|value| value.trim())
679 .filter(|value| !value.is_empty())
680 {
681 parties.push(Party {
682 r#type: Some("person".to_string()),
683 role: Some("vendor".to_string()),
684 name: Some(truncate_field(vendor.to_string())),
685 email: None,
686 url: None,
687 organization: None,
688 organization_url: None,
689 timezone: None,
690 });
691 }
692
693 parties
694}
695
696fn extract_support(json_content: &Value) -> (Option<String>, Option<String>) {
697 let support = json_content.get(FIELD_SUPPORT).and_then(|v| v.as_object());
698
699 if let Some(support_obj) = support {
700 let bug_tracking_url = support_obj
701 .get("issues")
702 .and_then(|v| v.as_str())
703 .map(|s| truncate_field(s.to_string()));
704
705 let code_view_url = support_obj
706 .get("source")
707 .and_then(|v| v.as_str())
708 .map(|s| truncate_field(s.to_string()));
709
710 (bug_tracking_url, code_view_url)
711 } else {
712 (None, None)
713 }
714}
715
716fn build_extra_data(json_content: &Value) -> Option<HashMap<String, Value>> {
717 let mut extra_data = HashMap::new();
718
719 if let Some(package_type) = json_content
720 .get(FIELD_TYPE)
721 .and_then(|value| value.as_str())
722 {
723 extra_data.insert("type".to_string(), Value::String(package_type.to_string()));
724 }
725
726 if let Some(autoload) = json_content
727 .get(FIELD_AUTOLOAD)
728 .and_then(|value| value.as_object())
729 && let Some(psr4) = autoload.get(FIELD_PSR4)
730 {
731 extra_data.insert("autoload_psr4".to_string(), psr4.clone());
732 }
733
734 if let Some(repositories) = json_content.get(FIELD_REPOSITORIES) {
735 extra_data.insert("repositories".to_string(), repositories.clone());
736 }
737
738 if extra_data.is_empty() {
739 None
740 } else {
741 Some(extra_data)
742 }
743}
744
745fn extract_source_vcs_url(json_content: &Value) -> Option<String> {
746 let source = json_content.get(FIELD_SOURCE)?.as_object()?;
747 let source_type = source.get("type")?.as_str()?.trim();
748 let source_url = source.get("url")?.as_str()?.trim();
749 let source_reference = source
750 .get("reference")
751 .and_then(|value| value.as_str())
752 .map(str::trim)
753 .filter(|value| !value.is_empty());
754
755 if source_type.is_empty() || source_url.is_empty() {
756 return None;
757 }
758
759 Some(truncate_field(match source_reference {
760 Some(reference) => format!("{}+{}@{}", source_type, source_url, reference),
761 None => format!("{}+{}", source_type, source_url),
762 }))
763}
764
765fn extract_dist_download_url(json_content: &Value) -> Option<String> {
766 json_content
767 .get(FIELD_DIST)
768 .and_then(|value| value.as_object())
769 .and_then(|dist| dist.get("url"))
770 .and_then(|value| value.as_str())
771 .map(|value| truncate_field(value.trim().to_string()))
772 .filter(|value| !value.is_empty())
773}
774
775fn build_repository_homepage_url(
776 namespace: &Option<String>,
777 name: &Option<String>,
778) -> Option<String> {
779 match (
780 namespace.as_ref().filter(|value| !value.is_empty()),
781 name.as_ref(),
782 ) {
783 (Some(ns), Some(name)) => Some(format!("https://packagist.org/packages/{}/{}", ns, name)),
784 (None, Some(name)) => Some(format!("https://packagist.org/packages/{}", name)),
785 _ => None,
786 }
787}
788
789fn build_api_data_url(namespace: &Option<String>, name: &Option<String>) -> Option<String> {
790 match (namespace.as_ref(), name.as_ref()) {
791 (Some(ns), Some(name)) if !ns.is_empty() => Some(format!(
792 "https://packagist.org/p/packages/{}/{}.json",
793 ns, name
794 )),
795 (None, Some(name)) => Some(format!("https://packagist.org/p/packages/{}.json", name)),
796 (Some(_), Some(name)) => Some(format!("https://packagist.org/p/packages/{}.json", name)),
797 _ => None,
798 }
799}
800
801fn build_package_purl(
802 namespace: &Option<String>,
803 name: &Option<String>,
804 version: &Option<String>,
805) -> Option<String> {
806 let name = name.as_ref()?;
807 let mut package_url = match PackageUrl::new(ComposerJsonParser::PACKAGE_TYPE.as_str(), name) {
808 Ok(purl) => purl,
809 Err(e) => {
810 warn!(
811 "Failed to create PackageUrl for composer package '{}': {}",
812 name, e
813 );
814 return None;
815 }
816 };
817
818 if let Some(namespace) = namespace.as_ref().filter(|value| !value.is_empty())
819 && let Err(e) = package_url.with_namespace(namespace)
820 {
821 warn!(
822 "Failed to set namespace '{}' for composer package '{}': {}",
823 namespace, name, e
824 );
825 return None;
826 }
827
828 if let Some(version) = version.as_ref()
829 && let Err(e) = package_url.with_version(version)
830 {
831 warn!(
832 "Failed to set version '{}' for composer package '{}': {}",
833 version, name, e
834 );
835 return None;
836 }
837
838 Some(package_url.to_string())
839}
840
841fn build_dependency_purl(
842 namespace: Option<&str>,
843 name: &str,
844 version: Option<&str>,
845) -> Option<String> {
846 let mut package_url = match PackageUrl::new(ComposerJsonParser::PACKAGE_TYPE.as_str(), name) {
847 Ok(purl) => purl,
848 Err(e) => {
849 warn!(
850 "Failed to create PackageUrl for composer package '{}': {}",
851 name, e
852 );
853 return None;
854 }
855 };
856
857 if let Some(namespace) = namespace.filter(|value| !value.is_empty())
858 && let Err(e) = package_url.with_namespace(namespace)
859 {
860 warn!(
861 "Failed to set namespace '{}' for composer package '{}': {}",
862 namespace, name, e
863 );
864 return None;
865 }
866
867 if let Some(version) = version
868 && let Err(e) = package_url.with_version(version)
869 {
870 warn!(
871 "Failed to set version '{}' for composer package '{}': {}",
872 version, name, e
873 );
874 return None;
875 }
876
877 Some(package_url.to_string())
878}
879
880fn split_optional_namespace_name(full_name: Option<&str>) -> (Option<String>, Option<String>) {
881 match full_name {
882 Some(full_name) => {
883 let (namespace, name) = split_namespace_name(full_name);
884 (namespace, Some(name))
885 }
886 None => (None, None),
887 }
888}
889
890fn split_namespace_name(full_name: &str) -> (Option<String>, String) {
891 let mut iter = full_name.splitn(2, '/');
892 let first = iter.next().unwrap_or("");
893 let second = iter.next();
894
895 if let Some(name) = second {
896 (
897 Some(truncate_field(first.to_string())),
898 truncate_field(name.to_string()),
899 )
900 } else {
901 (None, truncate_field(first.to_string()))
902 }
903}
904
905fn normalize_requirement_version(requirement: &str) -> String {
906 let trimmed = requirement.trim();
907 trimmed.trim_start_matches('=').trim().to_string()
908}
909
910fn is_composer_version_pinned(version: &str) -> bool {
911 let trimmed = version.trim();
912 if trimmed.is_empty() {
913 return false;
914 }
915
916 if trimmed.contains(" - ")
917 || trimmed.contains('|')
918 || trimmed.contains(',')
919 || trimmed.contains('^')
920 || trimmed.contains('~')
921 || trimmed.contains('>')
922 || trimmed.contains('<')
923 || trimmed.contains('*')
924 {
925 return false;
926 }
927
928 let without_prefix = trimmed.trim_start_matches('=').trim();
929 let without_prefix = without_prefix.strip_prefix('v').unwrap_or(without_prefix);
930 if without_prefix.is_empty() {
931 return false;
932 }
933
934 let lower = without_prefix.to_lowercase();
935 if lower.contains("dev") {
936 return false;
937 }
938
939 if without_prefix
940 .chars()
941 .any(|c| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+')
942 {
943 return false;
944 }
945
946 without_prefix.matches('.').count() >= 2
947}
948
949fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
950 PackageData {
951 package_type: Some(ComposerJsonParser::PACKAGE_TYPE),
952 primary_language: Some("PHP".to_string()),
953 datasource_id,
954 ..Default::default()
955 }
956}
957
958crate::register_parser!(
959 "PHP composer manifest",
960 &["**/*composer.json", "**/composer.*.json"],
961 "composer",
962 "PHP",
963 Some("https://getcomposer.org/doc/04-schema.md"),
964);
965
966crate::register_parser!(
967 "PHP composer lockfile",
968 &["**/*composer.lock", "**/composer.*.lock"],
969 "composer",
970 "PHP",
971 Some("https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file"),
972);