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