Skip to main content

provenant/parsers/
composer.rs

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