Skip to main content

provenant/parsers/
cpan_makefile_pl.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for CPAN Perl Makefile.PL files.
5//!
6//! Extracts Perl package metadata from `Makefile.PL` files used by ExtUtils::MakeMaker.
7//!
8//! # Supported Formats
9//! - `Makefile.PL` - CPAN ExtUtils::MakeMaker build configuration
10//!
11//! # Implementation Notes
12//! - Format: Perl script with WriteMakefile() or WriteMakefile1() calls
13//! - Spec: https://metacpan.org/pod/ExtUtils::MakeMaker
14//! - Extracts: NAME, VERSION, AUTHOR, LICENSE, ABSTRACT, PREREQ_PM, BUILD_REQUIRES, TEST_REQUIRES, CONFIGURE_REQUIRES
15//! - Uses regex-based extraction (no Perl code execution for security)
16//! - Python reference has stub-only handler with no parse() method - this is BEYOND PARITY
17
18use std::collections::HashMap;
19use std::path::Path;
20use std::sync::LazyLock;
21
22use crate::parser_warn as warn;
23use crate::parsers::utils::{
24    CappedIterExt, MAX_ITERATION_COUNT, read_file_to_string, truncate_field,
25};
26use packageurl::PackageUrl;
27use regex::Regex;
28use serde_json::json;
29
30use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
31
32use super::PackageParser;
33use super::license_normalization::{
34    DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
35    empty_declared_license_data, normalize_declared_license_key, normalize_spdx_expression,
36};
37
38static RE_WRITEMAKEFILE: LazyLock<Regex> = LazyLock::new(|| {
39    Regex::new(r"WriteMakefile1?\s*\(").expect("valid regex: WriteMakefile call pattern")
40});
41static RE_SIMPLE_KV: LazyLock<Regex> = LazyLock::new(|| {
42    Regex::new(r#"(?m)^\s*([A-Z_]+)\s*=>\s*(?:'([^']*)'|"([^"]*)"|q\{([^}]*)\}|q\(([^)]*)\))"#)
43        .expect("valid regex: simple key=>value pattern")
44});
45static RE_HASH_BLOCK: LazyLock<Regex> = LazyLock::new(|| {
46    Regex::new(r"([A-Z_]+)\s*=>\s*\{([^}]*)\}").expect("valid regex: hash block pattern")
47});
48static RE_AUTHOR_ARRAY: LazyLock<Regex> = LazyLock::new(|| {
49    Regex::new(r"AUTHOR\s*=>\s*\[([^\]]*)\]").expect("valid regex: AUTHOR array pattern")
50});
51static RE_QUOTED_STRING: LazyLock<Regex> = LazyLock::new(|| {
52    Regex::new(r#"['"]([^'"]*)['"']"#).expect("valid regex: quoted string pattern")
53});
54static RE_DEP_PAIR: LazyLock<Regex> = LazyLock::new(|| {
55    Regex::new(r#"['"]([^'"]+)['"]\s*=>\s*(?:'([^']*)'|"([^"]*)"|(\d+))"#)
56        .expect("valid regex: dependency pair pattern")
57});
58static RE_VERSION_ASSIGNMENT: LazyLock<Regex> = LazyLock::new(|| {
59    Regex::new(
60        r#"(?m)^\s*(?:our\s+)?\$(?:[A-Za-z_][\w:]*::)?VERSION\s*=\s*(?:'([^']+)'|"([^"]+)")"#,
61    )
62    .expect("valid regex: VERSION assignment pattern")
63});
64
65const PACKAGE_TYPE: PackageType = PackageType::Cpan;
66const MAX_METADATA_FILE_SIZE: u64 = 1024 * 1024;
67
68pub struct CpanMakefilePlParser;
69
70impl PackageParser for CpanMakefilePlParser {
71    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
72
73    fn is_match(path: &Path) -> bool {
74        path.file_name().is_some_and(|name| name == "Makefile.PL")
75    }
76
77    fn extract_packages(path: &Path) -> Vec<PackageData> {
78        let content = match read_file_to_string(path, None) {
79            Ok(c) => c,
80            Err(e) => {
81                warn!("Failed to read Makefile.PL file {:?}: {}", path, e);
82                return vec![PackageData {
83                    package_type: Some(PACKAGE_TYPE),
84                    primary_language: Some("Perl".to_string()),
85                    datasource_id: Some(DatasourceId::CpanMakefile),
86                    ..Default::default()
87                }];
88            }
89        };
90
91        vec![parse_makefile_pl_with_base(&content, path.parent())]
92    }
93
94    fn metadata() -> Vec<super::metadata::ParserMetadata> {
95        vec![super::metadata::ParserMetadata {
96            description: "CPAN Perl Makefile.PL",
97            file_patterns: &["*/Makefile.PL"],
98            package_type: "cpan",
99            primary_language: "Perl",
100            documentation_url: Some("https://metacpan.org/pod/ExtUtils::MakeMaker"),
101        }]
102    }
103}
104
105#[cfg(test)]
106pub(crate) fn parse_makefile_pl(content: &str) -> PackageData {
107    parse_makefile_pl_with_base(content, None)
108}
109
110pub(crate) fn parse_makefile_pl_with_base(content: &str, base_dir: Option<&Path>) -> PackageData {
111    // Find WriteMakefile or WriteMakefile1 call
112    let makefile_block = extract_writemakefile_block(content);
113    if makefile_block.is_empty() {
114        return default_package_data();
115    }
116
117    let fields = parse_hash_fields(&makefile_block);
118
119    let name = fields.get("NAME").and_then(|n| sanitize_scalar_field(n));
120    let resolved_metadata = resolve_referenced_metadata(&fields, base_dir);
121
122    let version = fields
123        .get("VERSION")
124        .and_then(|v| sanitize_scalar_field(v))
125        .or_else(|| resolved_metadata.version.clone());
126    let description = fields
127        .get("ABSTRACT")
128        .and_then(|d| sanitize_scalar_field(d))
129        .or_else(|| resolved_metadata.abstract_text.clone());
130    let extracted_license_statement = fields.get("LICENSE").and_then(|l| sanitize_scalar_field(l));
131    let (declared_license_expression, declared_license_expression_spdx, license_detections) =
132        extracted_license_statement
133            .as_deref()
134            .and_then(normalize_cpan_makefile_license)
135            .map(|normalized| {
136                build_declared_license_data(
137                    normalized,
138                    DeclaredLicenseMatchMetadata::single_line(
139                        extracted_license_statement.as_deref().unwrap_or_default(),
140                    ),
141                )
142            })
143            .unwrap_or_else(empty_declared_license_data);
144
145    let parties = parse_author(&fields);
146    let dependencies = parse_dependencies(&fields);
147
148    let mut extra_data = HashMap::new();
149    if let Some(min_perl) = fields
150        .get("MIN_PERL_VERSION")
151        .and_then(|value| sanitize_scalar_field(value))
152    {
153        extra_data.insert("MIN_PERL_VERSION".to_string(), json!(min_perl));
154    }
155    if let Some(version_from) = fields
156        .get("VERSION_FROM")
157        .and_then(|value| sanitize_scalar_field(value))
158    {
159        extra_data.insert("VERSION_FROM".to_string(), json!(version_from));
160    }
161    if let Some(abstract_from) = fields
162        .get("ABSTRACT_FROM")
163        .and_then(|value| sanitize_scalar_field(value))
164    {
165        extra_data.insert("ABSTRACT_FROM".to_string(), json!(abstract_from));
166    }
167
168    // Build PURL: convert Foo::Bar to Foo-Bar for CPAN naming convention
169    let purl = name.as_ref().and_then(|n| {
170        let purl_name = crate::parsers::cpan::cpan_distribution_name(n);
171        PackageUrl::new("cpan", &purl_name).ok().map(|mut p| {
172            if let Some(v) = &version {
173                let _ = p.with_version(v).ok();
174            }
175            p.to_string()
176        })
177    });
178
179    PackageData {
180        package_type: Some(PACKAGE_TYPE),
181        namespace: Some("cpan".to_string()),
182        name,
183        version,
184        description,
185        declared_license_expression,
186        declared_license_expression_spdx,
187        license_detections,
188        extracted_license_statement,
189        parties,
190        dependencies,
191        extra_data: if extra_data.is_empty() {
192            None
193        } else {
194            Some(extra_data)
195        },
196        purl,
197        datasource_id: Some(DatasourceId::CpanMakefile),
198        primary_language: Some("Perl".to_string()),
199        ..Default::default()
200    }
201}
202
203#[derive(Default)]
204struct ResolvedMetadata {
205    version: Option<String>,
206    abstract_text: Option<String>,
207}
208
209fn default_package_data() -> PackageData {
210    PackageData {
211        package_type: Some(PACKAGE_TYPE),
212        primary_language: Some("Perl".to_string()),
213        datasource_id: Some(DatasourceId::CpanMakefile),
214        ..Default::default()
215    }
216}
217
218fn normalize_cpan_makefile_license(value: &str) -> Option<NormalizedDeclaredLicense> {
219    match value.trim() {
220        "perl_5" | "Perl_5" => Some(NormalizedDeclaredLicense::new(
221            "gpl-1.0-plus OR artistic-perl-1.0",
222            "GPL-1.0-or-later OR Artistic-1.0-Perl",
223        )),
224        "artistic_2" => Some(NormalizedDeclaredLicense::new(
225            "artistic-2.0",
226            "Artistic-2.0",
227        )),
228        "apache_2_0" => Some(NormalizedDeclaredLicense::new("apache-2.0", "Apache-2.0")),
229        other => normalize_spdx_expression(other).or_else(|| normalize_declared_license_key(other)),
230    }
231}
232
233fn sanitize_scalar_field(value: &str) -> Option<String> {
234    let trimmed = value.trim();
235    if trimmed.is_empty() || looks_like_unresolved_template_value(trimmed) {
236        return None;
237    }
238
239    Some(truncate_field(trimmed.to_string()))
240}
241
242fn looks_like_unresolved_template_value(value: &str) -> bool {
243    let trimmed = value.trim();
244    let uppercase = trimmed.to_ascii_uppercase();
245
246    trimmed.contains("[%")
247        || trimmed.contains("%]")
248        || trimmed.contains("<%")
249        || trimmed.contains("%>")
250        || (trimmed.contains("{{") && trimmed.contains("}}"))
251        || trimmed.contains("${{")
252        || trimmed.contains("[d2%")
253        || trimmed.contains("%2d]")
254        || matches!(
255            uppercase.as_str(),
256            "YOUR NAME" | "YOUR APPLICATION ABSTRACT" | "YOUREMAIL@EXAMPLE.COM"
257        )
258}
259
260fn resolve_referenced_metadata(
261    fields: &HashMap<String, String>,
262    base_dir: Option<&Path>,
263) -> ResolvedMetadata {
264    let Some(base_dir) = base_dir else {
265        return ResolvedMetadata::default();
266    };
267
268    let mut resolved = ResolvedMetadata::default();
269    let mut cache: HashMap<String, Option<String>> = HashMap::new();
270
271    if let Some(version_from) = fields.get("VERSION_FROM")
272        && !looks_like_unresolved_template_value(version_from)
273        && let Some(content) = load_referenced_metadata_file(base_dir, version_from, &mut cache)
274    {
275        resolved.version = extract_version_from_module_content(content);
276    }
277
278    if let Some(abstract_from) = fields.get("ABSTRACT_FROM")
279        && !looks_like_unresolved_template_value(abstract_from)
280        && let Some(content) = load_referenced_metadata_file(base_dir, abstract_from, &mut cache)
281    {
282        resolved.abstract_text = extract_abstract_from_module_content(content);
283    }
284
285    resolved
286}
287
288fn load_referenced_metadata_file<'a>(
289    base_dir: &Path,
290    relative_path: &str,
291    cache: &'a mut HashMap<String, Option<String>>,
292) -> Option<&'a String> {
293    let entry = cache
294        .entry(relative_path.to_string())
295        .or_insert_with(|| read_safe_metadata_file(base_dir, relative_path));
296    entry.as_ref()
297}
298
299fn read_safe_metadata_file(base_dir: &Path, relative_path: &str) -> Option<String> {
300    let ref_path = Path::new(relative_path);
301    if ref_path.is_absolute() {
302        return None;
303    }
304
305    let base_dir = base_dir.canonicalize().ok()?;
306    let candidate = base_dir.join(ref_path);
307    let canonical_candidate = candidate.canonicalize().ok()?;
308    if !canonical_candidate.starts_with(&base_dir) {
309        return None;
310    }
311
312    let metadata = std::fs::metadata(&canonical_candidate).ok()?;
313    if !metadata.is_file() || metadata.len() > MAX_METADATA_FILE_SIZE {
314        return None;
315    }
316
317    read_file_to_string(&canonical_candidate, None).ok()
318}
319
320fn extract_version_from_module_content(content: &str) -> Option<String> {
321    RE_VERSION_ASSIGNMENT
322        .captures(content)
323        .and_then(|caps| caps.get(1).or_else(|| caps.get(2)))
324        .map(|m| m.as_str().trim().to_string())
325        .map(truncate_field)
326        .filter(|value| !value.is_empty())
327}
328
329fn extract_abstract_from_module_content(content: &str) -> Option<String> {
330    let mut in_name_section = false;
331
332    for line in content.lines() {
333        let trimmed = line.trim();
334        if trimmed == "=head1 NAME" {
335            in_name_section = true;
336            continue;
337        }
338
339        if in_name_section {
340            if trimmed.starts_with('=') {
341                break;
342            }
343            if trimmed.is_empty() {
344                continue;
345            }
346
347            if let Some((_, abstract_text)) = trimmed.split_once(" - ") {
348                let abstract_text = abstract_text.trim();
349                if !abstract_text.is_empty() {
350                    return Some(truncate_field(abstract_text.to_string()));
351                }
352            }
353        }
354    }
355
356    None
357}
358
359fn extract_writemakefile_block(content: &str) -> String {
360    let start_match = match RE_WRITEMAKEFILE.find(content) {
361        Some(m) => m,
362        None => return String::new(),
363    };
364
365    let start_pos = start_match.end();
366    let content_from_start = &content[start_pos..];
367
368    // Find the matching closing parenthesis
369    let mut depth = 1;
370    let mut end_pos = 0;
371    let chars: Vec<char> = content_from_start.chars().collect();
372
373    for (i, &ch) in chars.iter().enumerate() {
374        if i >= MAX_ITERATION_COUNT {
375            crate::parser_warn!(
376                "Truncated Makefile.PL WriteMakefile block scan at {} characters (MAX_ITERATION_COUNT); closing parenthesis not found within the cap",
377                MAX_ITERATION_COUNT
378            );
379            break;
380        }
381        match ch {
382            '(' => depth += 1,
383            ')' => {
384                depth -= 1;
385                if depth == 0 {
386                    end_pos = i;
387                    break;
388                }
389            }
390            _ => {}
391        }
392    }
393
394    if end_pos > 0 {
395        content_from_start[..end_pos].to_string()
396    } else {
397        String::new()
398    }
399}
400
401fn parse_hash_fields(content: &str) -> HashMap<String, String> {
402    let mut fields = HashMap::new();
403
404    for cap in RE_SIMPLE_KV
405        .captures_iter(content)
406        .capped("Makefile.PL key/value fields")
407    {
408        let key = cap.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
409        let value = cap
410            .get(2)
411            .or_else(|| cap.get(3))
412            .or_else(|| cap.get(4))
413            .or_else(|| cap.get(5))
414            .map(|m| m.as_str().to_string());
415
416        if let Some(v) = value {
417            fields.insert(key, v);
418        }
419    }
420
421    // Parse hash values (PREREQ_PM, BUILD_REQUIRES, etc.)
422    parse_hash_dependencies(content, &mut fields);
423
424    // Parse array refs for AUTHOR
425    parse_author_array(content, &mut fields);
426
427    fields
428}
429
430fn parse_hash_dependencies(content: &str, fields: &mut HashMap<String, String>) {
431    for cap in RE_HASH_BLOCK
432        .captures_iter(content)
433        .capped("Makefile.PL hash blocks")
434    {
435        let key = cap.get(1).map(|m| m.as_str()).unwrap_or("");
436        let hash_content = cap.get(2).map(|m| m.as_str()).unwrap_or("");
437
438        // For dependency hashes, we'll store them with a special marker
439        // so parse_dependencies can find them
440        if matches!(
441            key,
442            "PREREQ_PM" | "BUILD_REQUIRES" | "TEST_REQUIRES" | "CONFIGURE_REQUIRES"
443        ) {
444            fields.insert(format!("_HASH_{}", key), hash_content.to_string());
445        }
446    }
447}
448
449fn parse_author_array(content: &str, fields: &mut HashMap<String, String>) {
450    if let Some(cap) = RE_AUTHOR_ARRAY.captures(content) {
451        let array_content = cap.get(1).map(|m| m.as_str()).unwrap_or("");
452
453        let authors: Vec<String> = RE_QUOTED_STRING
454            .captures_iter(array_content)
455            .capped("Makefile.PL author array")
456            .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
457            .collect();
458
459        if !authors.is_empty() {
460            // Store as JSON array for later processing
461            fields.insert("_ARRAY_AUTHOR".to_string(), authors.join("||"));
462        }
463    }
464}
465
466fn parse_author(fields: &HashMap<String, String>) -> Vec<Party> {
467    // Check for array of authors first
468    if let Some(authors_str) = fields.get("_ARRAY_AUTHOR") {
469        return authors_str
470            .split("||")
471            .filter_map(|author_str| {
472                if author_str.trim().is_empty() {
473                    return None;
474                }
475                let (name, email) = parse_author_string(author_str);
476                build_author_party(name, email)
477            })
478            .collect();
479    }
480
481    if let Some(author_str) = fields.get("AUTHOR") {
482        let (name, email) = parse_author_string(author_str);
483        return build_author_party(name, email).into_iter().collect();
484    }
485
486    Vec::new()
487}
488
489fn build_author_party(name: Option<String>, email: Option<String>) -> Option<Party> {
490    if name.is_none() && email.is_none() {
491        return None;
492    }
493
494    Some(Party {
495        role: Some("author".to_string()),
496        name,
497        email,
498        r#type: Some(PartyType::Person),
499        url: None,
500        organization: None,
501        organization_url: None,
502        timezone: None,
503    })
504}
505
506fn parse_author_string(s: &str) -> (Option<String>, Option<String>) {
507    if let Some(start) = s.find('<')
508        && let Some(end) = s.find('>')
509        && start < end
510    {
511        let name = s[..start].trim();
512        let email = s[start + 1..end].trim();
513        return (sanitize_scalar_field(name), sanitize_scalar_field(email));
514    }
515    (sanitize_scalar_field(s), None)
516}
517
518fn parse_dependencies(fields: &HashMap<String, String>) -> Vec<Dependency> {
519    let mut dependencies = Vec::new();
520
521    // Parse PREREQ_PM as runtime dependencies
522    if let Some(hash_content) = fields.get("_HASH_PREREQ_PM") {
523        dependencies.extend(extract_deps_from_hash(hash_content, "runtime", true));
524    }
525
526    // Parse BUILD_REQUIRES
527    if let Some(hash_content) = fields.get("_HASH_BUILD_REQUIRES") {
528        dependencies.extend(extract_deps_from_hash(hash_content, "build", false));
529    }
530
531    // Parse TEST_REQUIRES
532    if let Some(hash_content) = fields.get("_HASH_TEST_REQUIRES") {
533        dependencies.extend(extract_deps_from_hash(hash_content, "test", false));
534    }
535
536    // Parse CONFIGURE_REQUIRES
537    if let Some(hash_content) = fields.get("_HASH_CONFIGURE_REQUIRES") {
538        dependencies.extend(extract_deps_from_hash(hash_content, "configure", false));
539    }
540
541    dependencies
542}
543
544fn extract_deps_from_hash(hash_content: &str, scope: &str, is_runtime: bool) -> Vec<Dependency> {
545    let mut deps = Vec::new();
546
547    for cap in RE_DEP_PAIR
548        .captures_iter(hash_content)
549        .capped("Makefile.PL dependency pairs")
550    {
551        let module_name = cap.get(1).map(|m| m.as_str()).unwrap_or("");
552
553        // Skip perl itself
554        if module_name == "perl" {
555            continue;
556        }
557
558        let version = cap
559            .get(2)
560            .or_else(|| cap.get(3))
561            .or_else(|| cap.get(4))
562            .map(|m| m.as_str());
563
564        let extracted_requirement = match version {
565            Some("0") | Some("") | None => None,
566            Some(v) => Some(truncate_field(v.to_string())),
567        };
568
569        let purl = PackageUrl::new(
570            "cpan",
571            crate::parsers::cpan::cpan_distribution_name(module_name),
572        )
573        .ok()
574        .map(|p| p.to_string());
575
576        deps.push(Dependency {
577            purl,
578            extracted_requirement,
579            scope: Some(truncate_field(scope.to_string())),
580            is_runtime: Some(is_runtime),
581            is_optional: Some(false),
582            is_pinned: None,
583            is_direct: Some(true),
584            resolved_package: None,
585            extra_data: None,
586        });
587    }
588
589    deps
590}