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