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