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