Skip to main content

provenant/parsers/
composer.rs

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