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        vec![package_data]
215    }
216
217    fn is_match(path: &Path) -> bool {
218        path.file_name()
219            .and_then(|name| name.to_str())
220            .is_some_and(is_composer_lock_filename)
221    }
222}
223
224fn is_composer_manifest_filename(name: &str) -> bool {
225    name == "composer.json"
226        || name.ends_with(".composer.json")
227        || (name.starts_with("composer.") && name.ends_with(".json"))
228}
229
230fn is_composer_lock_filename(name: &str) -> bool {
231    name == "composer.lock"
232        || name.ends_with(".composer.lock")
233        || (name.starts_with("composer.") && name.ends_with(".lock"))
234}
235
236fn read_json_file(path: &Path) -> Result<Value, String> {
237    let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
238    serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))
239}
240
241fn extract_dependencies(
242    json_content: &Value,
243    field: &str,
244    scope: &str,
245    is_runtime: bool,
246    is_optional: bool,
247) -> Vec<Dependency> {
248    json_content
249        .get(field)
250        .and_then(|value| value.as_object())
251        .map_or_else(Vec::new, |deps| {
252            deps.iter()
253                .take(MAX_ITERATION_COUNT)
254                .filter_map(|(name, requirement)| {
255                    let requirement_str = requirement.as_str()?;
256                    let (namespace, package_name) = split_namespace_name(name);
257                    let is_pinned = is_composer_version_pinned(requirement_str);
258                    let version_for_purl = if is_pinned {
259                        Some(normalize_requirement_version(requirement_str))
260                    } else {
261                        None
262                    };
263
264                    let purl = build_dependency_purl(
265                        namespace.as_deref(),
266                        &package_name,
267                        version_for_purl.as_deref(),
268                    );
269
270                    Some(Dependency {
271                        purl,
272                        extracted_requirement: Some(truncate_field(requirement_str.to_string())),
273                        scope: Some(truncate_field(scope.to_string())),
274                        is_runtime: Some(is_runtime),
275                        is_optional: Some(is_optional),
276                        is_pinned: Some(is_pinned),
277                        is_direct: Some(true),
278                        resolved_package: None,
279                        extra_data: None,
280                    })
281                })
282                .collect()
283        })
284}
285
286fn extract_lock_dependencies(json_content: &Value) -> Vec<Dependency> {
287    let mut dependencies = Vec::new();
288
289    let packages = json_content
290        .get(FIELD_PACKAGES)
291        .and_then(|value| value.as_array())
292        .map(|packages| packages.as_slice())
293        .unwrap_or(&[]);
294    let packages_dev = json_content
295        .get(FIELD_PACKAGES_DEV)
296        .and_then(|value| value.as_array())
297        .map(|packages| packages.as_slice())
298        .unwrap_or(&[]);
299
300    dependencies.reserve(packages.len() + packages_dev.len());
301    dependencies.extend(extract_lock_package_list(packages, "require", true, false));
302    dependencies.extend(extract_lock_package_list(
303        packages_dev,
304        "require-dev",
305        false,
306        true,
307    ));
308
309    dependencies
310}
311
312fn extract_lock_package_list(
313    packages: &[Value],
314    scope: &str,
315    is_runtime: bool,
316    is_optional: bool,
317) -> Vec<Dependency> {
318    let mut dependencies = Vec::new();
319
320    for package in packages.iter().take(MAX_ITERATION_COUNT) {
321        if let Some(dependency) = build_lock_dependency(package, scope, is_runtime, is_optional) {
322            dependencies.push(dependency);
323        }
324
325        dependencies.extend(extract_lock_package_relationships(package));
326    }
327
328    dependencies
329}
330
331fn extract_lock_package_relationships(package: &Value) -> Vec<Dependency> {
332    [
333        extract_dependencies(package, FIELD_REQUIRE, "require", true, false),
334        extract_dependencies(package, FIELD_REQUIRE_DEV, "require-dev", false, true),
335        extract_dependencies(package, FIELD_PROVIDE, "provide", true, false),
336        extract_dependencies(package, FIELD_CONFLICT, "conflict", true, true),
337        extract_dependencies(package, FIELD_REPLACE, "replace", true, true),
338        extract_dependencies(package, FIELD_SUGGEST, "suggest", true, true),
339    ]
340    .concat()
341}
342
343fn build_lock_dependency(
344    package: &Value,
345    scope: &str,
346    is_runtime: bool,
347    is_optional: bool,
348) -> Option<Dependency> {
349    let name = package.get(FIELD_NAME).and_then(|value| value.as_str())?;
350    let version = package
351        .get(FIELD_VERSION)
352        .and_then(|value| value.as_str())?;
353    let package_type = package.get(FIELD_TYPE).and_then(|value| value.as_str());
354
355    let (namespace, package_name) = split_namespace_name(name);
356    let purl = build_dependency_purl(namespace.as_deref(), &package_name, Some(version));
357
358    let source = package
359        .get(FIELD_SOURCE)
360        .and_then(|value| value.as_object());
361    let dist = package.get(FIELD_DIST).and_then(|value| value.as_object());
362
363    let (sha1, sha256, sha512, dist_shasum) = extract_dist_hashes(dist);
364    let dist_url = dist
365        .and_then(|map| map.get("url"))
366        .and_then(|value| value.as_str())
367        .map(|value| truncate_field(value.to_string()));
368
369    let mut extra_data = HashMap::new();
370
371    if let Some(package_type) = package_type {
372        extra_data.insert("type".to_string(), Value::String(package_type.to_string()));
373    }
374
375    if let Some(source_map) = source {
376        if let Some(source_reference) = source_map.get("reference").and_then(|value| value.as_str())
377        {
378            extra_data.insert(
379                "source_reference".to_string(),
380                Value::String(source_reference.to_string()),
381            );
382        }
383
384        if let Some(source_url) = source_map.get("url").and_then(|value| value.as_str()) {
385            extra_data.insert(
386                "source_url".to_string(),
387                Value::String(source_url.to_string()),
388            );
389        }
390
391        if let Some(source_type) = source_map.get("type").and_then(|value| value.as_str()) {
392            extra_data.insert(
393                "source_type".to_string(),
394                Value::String(source_type.to_string()),
395            );
396        }
397    }
398
399    if let Some(dist_map) = dist {
400        if let Some(dist_reference) = dist_map.get("reference").and_then(|value| value.as_str()) {
401            extra_data.insert(
402                "dist_reference".to_string(),
403                Value::String(dist_reference.to_string()),
404            );
405        }
406
407        if let Some(dist_url) = dist_map.get("url").and_then(|value| value.as_str()) {
408            extra_data.insert("dist_url".to_string(), Value::String(dist_url.to_string()));
409        }
410
411        if let Some(dist_type) = dist_map.get("type").and_then(|value| value.as_str()) {
412            extra_data.insert(
413                "dist_type".to_string(),
414                Value::String(dist_type.to_string()),
415            );
416        }
417    }
418
419    if let Some(shasum) = dist_shasum {
420        extra_data.insert("dist_shasum".to_string(), Value::String(shasum));
421    }
422
423    let extra_data = if extra_data.is_empty() {
424        None
425    } else {
426        Some(extra_data)
427    };
428
429    let resolved_package = ResolvedPackage {
430        primary_language: Some("PHP".to_string()),
431        download_url: dist_url,
432        sha1: sha1.and_then(|h| Sha1Digest::from_hex(&h).ok()),
433        sha256: sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
434        sha512: sha512.and_then(|h| Sha512Digest::from_hex(&h).ok()),
435        md5: None,
436        is_virtual: true,
437        extra_data: None,
438        dependencies: Vec::new(),
439        repository_homepage_url: None,
440        repository_download_url: None,
441        api_data_url: None,
442        datasource_id: Some(DatasourceId::PhpComposerLock),
443        purl: None,
444        ..ResolvedPackage::new(
445            ComposerLockParser::PACKAGE_TYPE,
446            namespace.clone().unwrap_or_default(),
447            package_name.clone(),
448            version.to_string(),
449        )
450    };
451
452    Some(Dependency {
453        purl,
454        extracted_requirement: None,
455        scope: Some(truncate_field(scope.to_string())),
456        is_runtime: Some(is_runtime),
457        is_optional: Some(is_optional),
458        is_pinned: Some(true),
459        is_direct: Some(true),
460        resolved_package: Some(Box::new(resolved_package)),
461        extra_data,
462    })
463}
464
465fn extract_dist_hashes(
466    dist: Option<&serde_json::Map<String, Value>>,
467) -> (
468    Option<String>,
469    Option<String>,
470    Option<String>,
471    Option<String>,
472) {
473    let mut sha1 = None;
474    let mut sha256 = None;
475    let mut sha512 = None;
476    let mut raw_shasum = None;
477
478    if let Some(dist) = dist {
479        if let Some(shasum) = dist.get("shasum").and_then(|value| value.as_str()) {
480            let trimmed = shasum.trim();
481            if !trimmed.is_empty() {
482                raw_shasum = Some(trimmed.to_string());
483                let (parsed_sha1, parsed_sha256, parsed_sha512) = parse_hash_value(trimmed);
484                sha1 = parsed_sha1;
485                sha256 = parsed_sha256;
486                sha512 = parsed_sha512;
487            }
488        }
489
490        if let Some(value) = dist.get("sha1").and_then(|value| value.as_str())
491            && is_hex_hash(value)
492        {
493            sha1 = Some(value.to_string());
494        }
495        if let Some(value) = dist.get("sha256").and_then(|value| value.as_str())
496            && is_hex_hash(value)
497        {
498            sha256 = Some(value.to_string());
499        }
500        if let Some(value) = dist.get("sha512").and_then(|value| value.as_str())
501            && is_hex_hash(value)
502        {
503            sha512 = Some(value.to_string());
504        }
505    }
506
507    (sha1, sha256, sha512, raw_shasum)
508}
509
510fn parse_hash_value(hash: &str) -> (Option<String>, Option<String>, Option<String>) {
511    let trimmed = hash.trim();
512    if trimmed.is_empty() || !is_hex_hash(trimmed) {
513        return (None, None, None);
514    }
515
516    match trimmed.len() {
517        40 => (Some(trimmed.to_string()), None, None),
518        64 => (None, Some(trimmed.to_string()), None),
519        128 => (None, None, Some(trimmed.to_string())),
520        _ => (None, None, None),
521    }
522}
523
524fn is_hex_hash(value: &str) -> bool {
525    value.chars().all(|c| c.is_ascii_hexdigit())
526}
527
528fn extract_license_statement(json_content: &Value) -> Option<String> {
529    let mut licenses = Vec::new();
530
531    if let Some(license_value) = json_content.get(FIELD_LICENSE) {
532        match license_value {
533            Value::String(value) => {
534                let trimmed = value.trim();
535                if !trimmed.is_empty() {
536                    licenses.push(trimmed.to_string());
537                }
538            }
539            Value::Array(values) => {
540                for value in values {
541                    if let Some(license_str) = value.as_str() {
542                        let trimmed = license_str.trim();
543                        if !trimmed.is_empty() {
544                            licenses.push(trimmed.to_string());
545                        }
546                    }
547                }
548            }
549            _ => {}
550        }
551    }
552
553    if licenses.is_empty() {
554        return None;
555    }
556
557    if licenses.len() == 1 {
558        Some(truncate_field(licenses[0].clone()))
559    } else {
560        Some(truncate_field(licenses.join(" OR ")))
561    }
562}
563
564fn extract_license_data(
565    json_content: &Value,
566    is_private: bool,
567) -> (
568    Option<String>,
569    Option<String>,
570    Option<String>,
571    Vec<LicenseDetection>,
572) {
573    let extracted_license_statement = extract_license_statement(json_content)
574        .or_else(|| is_private.then(|| "proprietary-license".to_string()));
575    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
576        normalize_composer_license_data(extracted_license_statement.as_deref());
577
578    (
579        extracted_license_statement,
580        declared_license_expression,
581        declared_license_expression_spdx,
582        license_detections,
583    )
584}
585
586fn normalize_composer_license_data(
587    extracted_license_statement: Option<&str>,
588) -> (Option<String>, Option<String>, Vec<LicenseDetection>) {
589    let Some(extracted_license_statement) = extracted_license_statement
590        .map(str::trim)
591        .filter(|value| !value.is_empty())
592    else {
593        return super::license_normalization::empty_declared_license_data();
594    };
595
596    if extracted_license_statement.eq_ignore_ascii_case("proprietary") {
597        return build_declared_license_data_from_pair(
598            "proprietary-license",
599            "LicenseRef-scancode-proprietary-license",
600            DeclaredLicenseMatchMetadata::single_line(extracted_license_statement),
601        );
602    }
603
604    if extracted_license_statement.eq_ignore_ascii_case("proprietary-license") {
605        return build_declared_license_data_from_pair(
606            "proprietary-license",
607            "LicenseRef-scancode-proprietary-license",
608            DeclaredLicenseMatchMetadata::single_line(extracted_license_statement),
609        );
610    }
611
612    normalize_spdx_declared_license(Some(extracted_license_statement))
613}
614
615fn extract_keywords(json_content: &Value) -> Vec<String> {
616    json_content
617        .get(FIELD_KEYWORDS)
618        .and_then(|value| value.as_array())
619        .map(|values| {
620            values
621                .iter()
622                .take(MAX_ITERATION_COUNT)
623                .filter_map(|value| {
624                    value
625                        .as_str()
626                        .map(|value| truncate_field(value.to_string()))
627                })
628                .collect()
629        })
630        .unwrap_or_default()
631}
632
633fn extract_parties(json_content: &Value, namespace: &Option<String>) -> Vec<Party> {
634    let mut parties = Vec::new();
635
636    if let Some(authors) = json_content
637        .get(FIELD_AUTHORS)
638        .and_then(|value| value.as_array())
639    {
640        for author in authors.iter().take(MAX_ITERATION_COUNT) {
641            if let Some(author) = author.as_object() {
642                let name = author
643                    .get("name")
644                    .and_then(|value| value.as_str())
645                    .map(|value| truncate_field(value.to_string()));
646                let role = author
647                    .get("role")
648                    .and_then(|value| value.as_str())
649                    .map(|value| truncate_field(value.to_string()))
650                    .or(Some("author".to_string()));
651                let email = author
652                    .get("email")
653                    .and_then(|value| value.as_str())
654                    .map(|value| truncate_field(value.to_string()));
655                let url = author
656                    .get("homepage")
657                    .and_then(|value| value.as_str())
658                    .map(|value| truncate_field(value.to_string()));
659
660                if name.is_some() || email.is_some() || url.is_some() {
661                    parties.push(Party {
662                        r#type: Some("person".to_string()),
663                        role,
664                        name,
665                        email,
666                        url,
667                        organization: None,
668                        organization_url: None,
669                        timezone: None,
670                    });
671                }
672            }
673        }
674    }
675
676    if let Some(vendor) = namespace
677        .as_ref()
678        .map(|value| value.trim())
679        .filter(|value| !value.is_empty())
680    {
681        parties.push(Party {
682            r#type: Some("person".to_string()),
683            role: Some("vendor".to_string()),
684            name: Some(truncate_field(vendor.to_string())),
685            email: None,
686            url: None,
687            organization: None,
688            organization_url: None,
689            timezone: None,
690        });
691    }
692
693    parties
694}
695
696fn extract_support(json_content: &Value) -> (Option<String>, Option<String>) {
697    let support = json_content.get(FIELD_SUPPORT).and_then(|v| v.as_object());
698
699    if let Some(support_obj) = support {
700        let bug_tracking_url = support_obj
701            .get("issues")
702            .and_then(|v| v.as_str())
703            .map(|s| truncate_field(s.to_string()));
704
705        let code_view_url = support_obj
706            .get("source")
707            .and_then(|v| v.as_str())
708            .map(|s| truncate_field(s.to_string()));
709
710        (bug_tracking_url, code_view_url)
711    } else {
712        (None, None)
713    }
714}
715
716fn build_extra_data(json_content: &Value) -> Option<HashMap<String, Value>> {
717    let mut extra_data = HashMap::new();
718
719    if let Some(package_type) = json_content
720        .get(FIELD_TYPE)
721        .and_then(|value| value.as_str())
722    {
723        extra_data.insert("type".to_string(), Value::String(package_type.to_string()));
724    }
725
726    if let Some(autoload) = json_content
727        .get(FIELD_AUTOLOAD)
728        .and_then(|value| value.as_object())
729        && let Some(psr4) = autoload.get(FIELD_PSR4)
730    {
731        extra_data.insert("autoload_psr4".to_string(), psr4.clone());
732    }
733
734    if let Some(repositories) = json_content.get(FIELD_REPOSITORIES) {
735        extra_data.insert("repositories".to_string(), repositories.clone());
736    }
737
738    if extra_data.is_empty() {
739        None
740    } else {
741        Some(extra_data)
742    }
743}
744
745fn extract_source_vcs_url(json_content: &Value) -> Option<String> {
746    let source = json_content.get(FIELD_SOURCE)?.as_object()?;
747    let source_type = source.get("type")?.as_str()?.trim();
748    let source_url = source.get("url")?.as_str()?.trim();
749    let source_reference = source
750        .get("reference")
751        .and_then(|value| value.as_str())
752        .map(str::trim)
753        .filter(|value| !value.is_empty());
754
755    if source_type.is_empty() || source_url.is_empty() {
756        return None;
757    }
758
759    Some(truncate_field(match source_reference {
760        Some(reference) => format!("{}+{}@{}", source_type, source_url, reference),
761        None => format!("{}+{}", source_type, source_url),
762    }))
763}
764
765fn extract_dist_download_url(json_content: &Value) -> Option<String> {
766    json_content
767        .get(FIELD_DIST)
768        .and_then(|value| value.as_object())
769        .and_then(|dist| dist.get("url"))
770        .and_then(|value| value.as_str())
771        .map(|value| truncate_field(value.trim().to_string()))
772        .filter(|value| !value.is_empty())
773}
774
775fn build_repository_homepage_url(
776    namespace: &Option<String>,
777    name: &Option<String>,
778) -> Option<String> {
779    match (
780        namespace.as_ref().filter(|value| !value.is_empty()),
781        name.as_ref(),
782    ) {
783        (Some(ns), Some(name)) => Some(format!("https://packagist.org/packages/{}/{}", ns, name)),
784        (None, Some(name)) => Some(format!("https://packagist.org/packages/{}", name)),
785        _ => None,
786    }
787}
788
789fn build_api_data_url(namespace: &Option<String>, name: &Option<String>) -> Option<String> {
790    match (namespace.as_ref(), name.as_ref()) {
791        (Some(ns), Some(name)) if !ns.is_empty() => Some(format!(
792            "https://packagist.org/p/packages/{}/{}.json",
793            ns, name
794        )),
795        (None, Some(name)) => Some(format!("https://packagist.org/p/packages/{}.json", name)),
796        (Some(_), Some(name)) => Some(format!("https://packagist.org/p/packages/{}.json", name)),
797        _ => None,
798    }
799}
800
801fn build_package_purl(
802    namespace: &Option<String>,
803    name: &Option<String>,
804    version: &Option<String>,
805) -> Option<String> {
806    let name = name.as_ref()?;
807    let mut package_url = match PackageUrl::new(ComposerJsonParser::PACKAGE_TYPE.as_str(), name) {
808        Ok(purl) => purl,
809        Err(e) => {
810            warn!(
811                "Failed to create PackageUrl for composer package '{}': {}",
812                name, e
813            );
814            return None;
815        }
816    };
817
818    if let Some(namespace) = namespace.as_ref().filter(|value| !value.is_empty())
819        && let Err(e) = package_url.with_namespace(namespace)
820    {
821        warn!(
822            "Failed to set namespace '{}' for composer package '{}': {}",
823            namespace, name, e
824        );
825        return None;
826    }
827
828    if let Some(version) = version.as_ref()
829        && let Err(e) = package_url.with_version(version)
830    {
831        warn!(
832            "Failed to set version '{}' for composer package '{}': {}",
833            version, name, e
834        );
835        return None;
836    }
837
838    Some(package_url.to_string())
839}
840
841fn build_dependency_purl(
842    namespace: Option<&str>,
843    name: &str,
844    version: Option<&str>,
845) -> Option<String> {
846    let mut package_url = match PackageUrl::new(ComposerJsonParser::PACKAGE_TYPE.as_str(), name) {
847        Ok(purl) => purl,
848        Err(e) => {
849            warn!(
850                "Failed to create PackageUrl for composer package '{}': {}",
851                name, e
852            );
853            return None;
854        }
855    };
856
857    if let Some(namespace) = namespace.filter(|value| !value.is_empty())
858        && let Err(e) = package_url.with_namespace(namespace)
859    {
860        warn!(
861            "Failed to set namespace '{}' for composer package '{}': {}",
862            namespace, name, e
863        );
864        return None;
865    }
866
867    if let Some(version) = version
868        && let Err(e) = package_url.with_version(version)
869    {
870        warn!(
871            "Failed to set version '{}' for composer package '{}': {}",
872            version, name, e
873        );
874        return None;
875    }
876
877    Some(package_url.to_string())
878}
879
880fn split_optional_namespace_name(full_name: Option<&str>) -> (Option<String>, Option<String>) {
881    match full_name {
882        Some(full_name) => {
883            let (namespace, name) = split_namespace_name(full_name);
884            (namespace, Some(name))
885        }
886        None => (None, None),
887    }
888}
889
890fn split_namespace_name(full_name: &str) -> (Option<String>, String) {
891    let mut iter = full_name.splitn(2, '/');
892    let first = iter.next().unwrap_or("");
893    let second = iter.next();
894
895    if let Some(name) = second {
896        (
897            Some(truncate_field(first.to_string())),
898            truncate_field(name.to_string()),
899        )
900    } else {
901        (None, truncate_field(first.to_string()))
902    }
903}
904
905fn normalize_requirement_version(requirement: &str) -> String {
906    let trimmed = requirement.trim();
907    trimmed.trim_start_matches('=').trim().to_string()
908}
909
910fn is_composer_version_pinned(version: &str) -> bool {
911    let trimmed = version.trim();
912    if trimmed.is_empty() {
913        return false;
914    }
915
916    if trimmed.contains(" - ")
917        || trimmed.contains('|')
918        || trimmed.contains(',')
919        || trimmed.contains('^')
920        || trimmed.contains('~')
921        || trimmed.contains('>')
922        || trimmed.contains('<')
923        || trimmed.contains('*')
924    {
925        return false;
926    }
927
928    let without_prefix = trimmed.trim_start_matches('=').trim();
929    let without_prefix = without_prefix.strip_prefix('v').unwrap_or(without_prefix);
930    if without_prefix.is_empty() {
931        return false;
932    }
933
934    let lower = without_prefix.to_lowercase();
935    if lower.contains("dev") {
936        return false;
937    }
938
939    if without_prefix
940        .chars()
941        .any(|c| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+')
942    {
943        return false;
944    }
945
946    without_prefix.matches('.').count() >= 2
947}
948
949fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
950    PackageData {
951        package_type: Some(ComposerJsonParser::PACKAGE_TYPE),
952        primary_language: Some("PHP".to_string()),
953        datasource_id,
954        ..Default::default()
955    }
956}
957
958crate::register_parser!(
959    "PHP composer manifest",
960    &["**/*composer.json", "**/composer.*.json"],
961    "composer",
962    "PHP",
963    Some("https://getcomposer.org/doc/04-schema.md"),
964);
965
966crate::register_parser!(
967    "PHP composer lockfile",
968    &["**/*composer.lock", "**/composer.*.lock"],
969    "composer",
970    "PHP",
971    Some("https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file"),
972);