1use 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 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 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 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_dependencies(content, &mut fields);
423
424 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 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 fields.insert("_ARRAY_AUTHOR".to_string(), authors.join("||"));
462 }
463 }
464}
465
466fn parse_author(fields: &HashMap<String, String>) -> Vec<Party> {
467 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 if let Some(hash_content) = fields.get("_HASH_PREREQ_PM") {
523 dependencies.extend(extract_deps_from_hash(hash_content, "runtime", true));
524 }
525
526 if let Some(hash_content) = fields.get("_HASH_BUILD_REQUIRES") {
528 dependencies.extend(extract_deps_from_hash(hash_content, "build", false));
529 }
530
531 if let Some(hash_content) = fields.get("_HASH_TEST_REQUIRES") {
533 dependencies.extend(extract_deps_from_hash(hash_content, "test", false));
534 }
535
536 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 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}