Skip to main content

provenant/parsers/
composer.rs

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