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