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