Skip to main content

provenant/parsers/
composer.rs

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