1use std::fs;
25use std::path::Path;
26
27use crate::parser_warn as warn;
28use lazy_static::lazy_static;
29use md5::{Digest, Md5};
30use packageurl::PackageUrl;
31use regex::Regex;
32
33use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
34use crate::parsers::PackageParser;
35use crate::parsers::license_normalization::normalize_spdx_declared_license;
36
37pub struct PodspecParser;
50
51impl PackageParser for PodspecParser {
52 const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
53
54 fn is_match(path: &Path) -> bool {
55 path.extension().is_some_and(|ext| {
56 ext == "podspec"
57 && path
58 .file_name()
59 .is_some_and(|name| !name.to_string_lossy().ends_with(".json.podspec"))
60 })
61 }
62
63 fn extract_packages(path: &Path) -> Vec<PackageData> {
64 let content = match fs::read_to_string(path) {
65 Ok(c) => c,
66 Err(e) => {
67 warn!("Failed to read {:?}: {}", path, e);
68 return vec![default_package_data()];
69 }
70 };
71
72 let name = extract_field(&content, &NAME_PATTERN);
73 let version = extract_field(&content, &VERSION_PATTERN);
74 let summary = extract_field(&content, &SUMMARY_PATTERN);
75 let description =
76 merge_summary_and_description(summary.as_deref(), extract_description(&content));
77 let homepage_url = extract_field(&content, &HOMEPAGE_PATTERN);
78 let license = extract_license_statement(&content);
79 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
80 normalize_podspec_declared_license(&content, license.as_deref());
81 let source = extract_source_url(&content);
82 let authors = extract_authors(&content);
83
84 let parties = authors
85 .into_iter()
86 .map(|(name, email)| Party {
87 r#type: Some("person".to_string()),
88 name: Some(name),
89 email,
90 url: None,
91 role: Some("author".to_string()),
92 organization: None,
93 organization_url: None,
94 timezone: None,
95 })
96 .collect();
97
98 let dependencies = extract_dependencies(&content);
99 let mut extra_data = serde_json::Map::new();
100 if let Some(raw_license) = extract_field(&content, &LICENSE_PATTERN)
101 && let Some(license_file) = extract_ruby_hash_file(&raw_license)
102 {
103 extra_data.insert(
104 "license_file".to_string(),
105 serde_json::Value::String(license_file),
106 );
107 }
108 let repository_homepage_url = name
109 .as_ref()
110 .map(|n| format!("https://cocoapods.org/pods/{}", n));
111 let repository_download_url = match (source.as_deref(), version.as_deref()) {
112 (Some(vcs_url), Some(version_str)) => get_repo_base_url(vcs_url)
113 .map(|base| format!("{}/archive/refs/tags/{}.zip", base, version_str)),
114 _ => None,
115 };
116 let code_view_url = match (source.as_deref(), version.as_deref()) {
117 (Some(vcs_url), Some(version_str)) => {
118 get_repo_base_url(vcs_url).map(|base| format!("{}/tree/{}", base, version_str))
119 }
120 _ => None,
121 };
122 let bug_tracking_url = source
123 .as_deref()
124 .and_then(get_repo_base_url)
125 .map(|base| format!("{}/issues/", base));
126 let api_data_url = match (name.as_deref(), version.as_deref()) {
127 (Some(name_str), Some(version_str)) => get_hashed_path(name_str).map(|hashed| {
128 format!(
129 "https://raw.githubusercontent.com/CocoaPods/Specs/blob/master/Specs/{}/{}/{}/{}.podspec.json",
130 hashed, name_str, version_str, name_str
131 )
132 }),
133 _ => None,
134 };
135 let purl = if let Some(name_str) = &name {
136 let mut purl = PackageUrl::new(Self::PACKAGE_TYPE.as_str(), name_str)
137 .unwrap_or_else(|_| PackageUrl::new("generic", name_str).unwrap());
138 if let Some(version_str) = &version {
139 let _ = purl.with_version(version_str);
140 }
141 Some(purl.to_string())
142 } else {
143 None
144 };
145
146 vec![PackageData {
147 package_type: Some(Self::PACKAGE_TYPE),
148 namespace: None,
149 name,
150 version,
151 qualifiers: None,
152 subpath: None,
153 primary_language: Some("Objective-C".to_string()),
154 description,
155 release_date: None,
156 parties,
157 keywords: Vec::new(),
158 homepage_url,
159 download_url: None,
160 size: None,
161 sha1: None,
162 md5: None,
163 sha256: None,
164 sha512: None,
165 bug_tracking_url,
166 code_view_url,
167 vcs_url: source,
168 copyright: None,
169 holder: None,
170 declared_license_expression,
171 declared_license_expression_spdx,
172 license_detections,
173 other_license_expression: None,
174 other_license_expression_spdx: None,
175 other_license_detections: Vec::new(),
176 extracted_license_statement: license,
177 notice_text: None,
178 source_packages: Vec::new(),
179 file_references: Vec::new(),
180 extra_data: (!extra_data.is_empty()).then_some(extra_data.into_iter().collect()),
181 dependencies,
182 repository_homepage_url,
183 repository_download_url,
184 api_data_url,
185 datasource_id: Some(DatasourceId::CocoapodsPodspec),
186 purl,
187 is_private: false,
188 is_virtual: false,
189 }]
190 }
191}
192
193fn default_package_data() -> PackageData {
194 PackageData {
195 package_type: Some(PodspecParser::PACKAGE_TYPE),
196 primary_language: Some("Objective-C".to_string()),
197 datasource_id: Some(DatasourceId::CocoapodsPodspec),
198 ..Default::default()
199 }
200}
201
202lazy_static! {
203 static ref NAME_PATTERN: Regex = Regex::new(r"\.name\s*=\s*(.+)").unwrap();
205 static ref VERSION_PATTERN: Regex = Regex::new(r"\.version\s*=\s*(.+)").unwrap();
206 static ref SUMMARY_PATTERN: Regex = Regex::new(r"\.summary\s*=\s*(.+)").unwrap();
207 static ref DESCRIPTION_PATTERN: Regex = Regex::new(r"\.description\s*=\s*(.+)").unwrap();
208 static ref HOMEPAGE_PATTERN: Regex = Regex::new(r"\.homepage\s*=\s*(.+)").unwrap();
209 static ref LICENSE_PATTERN: Regex = Regex::new(r"\.license\s*=\s*(.+)").unwrap();
210 static ref SOURCE_PATTERN: Regex = Regex::new(r"\.source\s*=\s*(.+)").unwrap();
211 static ref AUTHOR_PATTERN: Regex = Regex::new(r"\.authors?\s*=\s*(.+)").unwrap();
212 static ref SOURCE_GIT_PATTERN: Regex = Regex::new(r#":git\s*=>\s*['\"]([^'\"]+)['\"]"#).unwrap();
213 static ref SOURCE_HTTP_PATTERN: Regex = Regex::new(r#":http\s*=>\s*['\"]([^'\"]+)['\"]"#).unwrap();
214
215 static ref DEPENDENCY_PATTERN: Regex = Regex::new(
217 r#"(?:s\.)?(?:dependency|add_dependency|add_(?:runtime|development)_dependency)\s+['"]([^'"]+)['"](?:\s*,\s*(.+))?"#
218 ).unwrap();
219}
220
221fn extract_license_statement(content: &str) -> Option<String> {
222 extract_field(content, &LICENSE_PATTERN).map(|value| normalize_ruby_hash_literal(&value))
223}
224
225fn normalize_podspec_declared_license(
226 content: &str,
227 extracted_license_statement: Option<&str>,
228) -> (
229 Option<String>,
230 Option<String>,
231 Vec<crate::models::LicenseDetection>,
232) {
233 let Some(raw_license) = extract_field(content, &LICENSE_PATTERN) else {
234 return super::license_normalization::empty_declared_license_data();
235 };
236 let normalized_candidate = if raw_license.contains("=>") || raw_license.contains('=') {
237 extract_ruby_hash_type(&raw_license)
238 .map(|license_type| canonicalize_cocoapods_license_type(&license_type))
239 } else {
240 extracted_license_statement.map(canonicalize_cocoapods_license_type)
241 };
242
243 normalize_spdx_declared_license(normalized_candidate.as_deref())
244}
245
246fn extract_ruby_hash_file(raw_license: &str) -> Option<String> {
247 let normalized = raw_license.replace("=>", "=");
248 let file_regex = Regex::new(r#":file\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
249 file_regex
250 .captures(&normalized)
251 .and_then(|caps| caps.get(1))
252 .map(|value| value.as_str().trim().to_string())
253 .filter(|value| !value.is_empty())
254}
255
256fn canonicalize_cocoapods_license_type(value: &str) -> String {
257 match value.trim() {
258 "Apache License, Version 2.0" => "Apache-2.0".to_string(),
259 other => other.to_string(),
260 }
261}
262
263fn extract_ruby_hash_type(raw_license: &str) -> Option<String> {
264 let normalized = raw_license.replace("=>", "=");
265 let type_regex = Regex::new(r#":type\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
266 type_regex
267 .captures(&normalized)
268 .and_then(|caps| caps.get(1))
269 .map(|value| value.as_str().trim().to_string())
270 .filter(|value| !value.is_empty())
271}
272
273fn normalize_ruby_hash_literal(value: &str) -> String {
274 if !value.contains('=') && !value.contains("=>") {
275 return value.to_string();
276 }
277
278 value
279 .replace("=>", "=")
280 .replace(['\'', '"'], "")
281 .split_whitespace()
282 .collect::<Vec<_>>()
283 .join(" ")
284}
285
286fn extract_field(content: &str, pattern: &Regex) -> Option<String> {
288 for line in content.lines() {
289 let cleaned_line = pre_process(line);
290 if let Some(value) = pattern.captures(&cleaned_line).and_then(|caps| caps.get(1)) {
291 return Some(clean_string(value.as_str()));
292 }
293 }
294 None
295}
296
297fn extract_description(content: &str) -> Option<String> {
299 let lines: Vec<&str> = content.lines().collect();
300
301 for (i, line) in lines.iter().enumerate() {
302 let cleaned = pre_process(line);
303 if let Some(value) = DESCRIPTION_PATTERN
304 .captures(&cleaned)
305 .and_then(|caps| caps.get(1))
306 {
307 let value_str = value.as_str();
308
309 if value_str.contains("<<-") {
310 return extract_multiline_description(&lines, i);
311 } else {
312 return Some(clean_string(value_str));
313 }
314 }
315 }
316 None
317}
318
319fn merge_summary_and_description(
320 summary: Option<&str>,
321 description: Option<String>,
322) -> Option<String> {
323 match (
324 summary.map(str::trim).filter(|s| !s.is_empty()),
325 description,
326 ) {
327 (Some(summary), Some(description)) if description.starts_with(summary) => Some(description),
328 (Some(summary), Some(description)) => Some(format!("{}\n{}", summary, description)),
329 (Some(summary), None) => Some(summary.to_string()),
330 (None, description) => description,
331 }
332}
333
334fn extract_multiline_description(lines: &[&str], start_index: usize) -> Option<String> {
336 let start_line = lines.get(start_index)?;
337
338 let delimiter = start_line
340 .split("<<-")
341 .nth(1)?
342 .trim()
343 .trim_matches(|c| c == '"' || c == '\'');
344
345 let mut description_lines = Vec::new();
346 let mut found_start = false;
347
348 for line in lines.iter().skip(start_index) {
349 if !found_start && line.contains("<<-") {
350 found_start = true;
351 continue;
352 }
353
354 if found_start {
355 let trimmed = line.trim();
356 if trimmed == delimiter {
357 break;
358 }
359 description_lines.push(*line);
360 }
361 }
362
363 if description_lines.is_empty() {
364 None
365 } else {
366 Some(description_lines.join("\n").trim().to_string())
367 }
368}
369
370fn extract_authors(content: &str) -> Vec<(String, Option<String>)> {
372 let mut authors = Vec::new();
373
374 for line in content.lines() {
375 let cleaned_line = pre_process(line);
376 if let Some(value) = AUTHOR_PATTERN
377 .captures(&cleaned_line)
378 .and_then(|caps| caps.get(1))
379 {
380 let value_str = value.as_str();
381
382 if value_str.contains("=>") {
383 for part in value_str.split(',') {
384 if let Some((name, email)) = parse_author_hash_entry(part) {
385 authors.push((name, Some(email)));
386 }
387 }
388 } else {
389 let cleaned = clean_string(value_str);
390 let (name, email) = parse_author_string(&cleaned);
391 authors.push((name, email));
392 }
393 }
394 }
395
396 authors
397}
398
399fn extract_source_url(content: &str) -> Option<String> {
400 for line in content.lines() {
401 let cleaned_line = pre_process(line);
402 let Some(value) = SOURCE_PATTERN
403 .captures(&cleaned_line)
404 .and_then(|caps| caps.get(1))
405 .map(|m| m.as_str())
406 else {
407 continue;
408 };
409
410 if let Some(caps) = SOURCE_GIT_PATTERN.captures(value)
411 && let Some(url) = caps.get(1)
412 {
413 return Some(clean_string(url.as_str()));
414 }
415
416 if let Some(caps) = SOURCE_HTTP_PATTERN.captures(value)
417 && let Some(url) = caps.get(1)
418 {
419 return Some(clean_string(url.as_str()));
420 }
421
422 return Some(clean_string(value));
423 }
424
425 None
426}
427
428fn parse_author_hash_entry(entry: &str) -> Option<(String, String)> {
430 let parts: Vec<&str> = entry.split("=>").collect();
431 if parts.len() == 2 {
432 let name = clean_string(parts[0].trim())
433 .trim()
434 .trim_matches(['\'', '"'])
435 .to_string();
436 let email = clean_string(parts[1].trim())
437 .trim()
438 .trim_matches(['\'', '"'])
439 .to_string();
440 Some((name, email))
441 } else {
442 None
443 }
444}
445
446fn parse_author_string(author: &str) -> (String, Option<String>) {
448 if let Some(email_start) = author.find('<')
449 && let Some(email_end) = author.find('>')
450 {
451 let name = author[..email_start].trim().to_string();
452 let email = author[email_start + 1..email_end].trim().to_string();
453 return (name, Some(email));
454 }
455 (author.to_string(), None)
456}
457
458fn extract_dependencies(content: &str) -> Vec<Dependency> {
460 let mut dependencies = Vec::new();
461
462 for line in content.lines() {
463 let cleaned_line = pre_process(line);
464 if let Some(caps) = DEPENDENCY_PATTERN.captures(&cleaned_line) {
465 let method = caps.get(0).map(|m| m.as_str()).unwrap_or("");
466 let name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
467 let version_req = caps.get(2).map(|m| clean_string(m.as_str()));
468
469 if let Some(dep) = create_dependency(name, version_req, method) {
470 dependencies.push(dep);
471 }
472 }
473 }
474
475 dependencies
476}
477
478fn create_dependency(name: &str, version_req: Option<String>, method: &str) -> Option<Dependency> {
480 if name.is_empty() {
481 return None;
482 }
483
484 let purl = PackageUrl::new("cocoapods", name).ok()?;
485
486 let is_pinned = version_req
488 .as_ref()
489 .map(|v| !v.contains(&['~', '>', '<', '='][..]))
490 .unwrap_or(false);
491
492 let is_development = method.contains("add_development_dependency");
493
494 Some(Dependency {
495 purl: Some(purl.to_string()),
496 extracted_requirement: version_req,
497 scope: Some(
498 if is_development {
499 "development"
500 } else {
501 "runtime"
502 }
503 .to_string(),
504 ),
505 is_runtime: Some(!is_development),
506 is_optional: Some(is_development),
507 is_pinned: Some(is_pinned),
508 is_direct: Some(true),
509 resolved_package: None,
510 extra_data: None,
511 })
512}
513
514fn pre_process(line: &str) -> String {
516 let line = if let Some(comment_pos) = line.find('#') {
517 &line[..comment_pos]
518 } else {
519 line
520 };
521 line.trim().to_string()
522}
523
524fn clean_string(s: &str) -> String {
526 let after_removing_special_patterns = s.trim().replace("%q", "").replace(".freeze", "");
527
528 after_removing_special_patterns
529 .trim_matches(|c| {
530 c == '\''
531 || c == '"'
532 || c == '{'
533 || c == '}'
534 || c == '['
535 || c == ']'
536 || c == '<'
537 || c == '>'
538 })
539 .trim()
540 .to_string()
541}
542
543fn get_repo_base_url(vcs_url: &str) -> Option<String> {
544 if vcs_url.is_empty() {
545 return None;
546 }
547
548 if vcs_url.ends_with(".git") {
549 Some(vcs_url.trim_end_matches(".git").to_string())
550 } else {
551 Some(vcs_url.to_string())
552 }
553}
554
555fn get_hashed_path(name: &str) -> Option<String> {
556 if name.is_empty() {
557 return None;
558 }
559
560 let mut hasher = Md5::new();
561 hasher.update(name.as_bytes());
562 let hash_str = hex::encode(hasher.finalize());
563
564 Some(format!(
565 "{}/{}/{}",
566 &hash_str[0..1],
567 &hash_str[1..2],
568 &hash_str[2..3]
569 ))
570}
571
572crate::register_parser!(
573 "CocoaPods podspec file",
574 &["**/*.podspec"],
575 "cocoapods",
576 "Objective-C",
577 Some("https://guides.cocoapods.org/syntax/podspec.html"),
578);
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 #[test]
585 fn test_is_match() {
586 assert!(PodspecParser::is_match(Path::new("AFNetworking.podspec")));
587 assert!(PodspecParser::is_match(Path::new("project/MyLib.podspec")));
588 assert!(!PodspecParser::is_match(Path::new(
589 "AFNetworking.podspec.json"
590 )));
591 assert!(!PodspecParser::is_match(Path::new("Podfile")));
592 assert!(!PodspecParser::is_match(Path::new("Podfile.lock")));
593 }
594
595 #[test]
596 fn test_clean_string() {
597 assert_eq!(clean_string("'AFNetworking'"), "AFNetworking");
598 assert_eq!(clean_string("\"AFNetworking\""), "AFNetworking");
599 assert_eq!(clean_string("'test'.freeze"), "test");
600 assert_eq!(clean_string("%q{test}"), "test");
601 }
602
603 #[test]
604 fn test_extract_simple_field() {
605 let content = r#"
606Pod::Spec.new do |s|
607 s.name = "AFNetworking"
608 s.version = "4.0.1"
609end
610"#;
611 assert_eq!(
612 extract_field(content, &NAME_PATTERN),
613 Some("AFNetworking".to_string())
614 );
615 assert_eq!(
616 extract_field(content, &VERSION_PATTERN),
617 Some("4.0.1".to_string())
618 );
619 }
620
621 #[test]
622 fn test_extract_multiline_description() {
623 let content = r#"
624Pod::Spec.new do |s|
625 s.description = <<-DESC
626 A delightful networking library.
627 Features include:
628 - Modern API
629 DESC
630end
631"#;
632 let desc = extract_description(content);
633 assert!(desc.is_some());
634 let desc_text = desc.unwrap();
635 assert!(desc_text.contains("delightful networking"));
636 assert!(desc_text.contains("Modern API"));
637 }
638
639 #[test]
640 fn test_extract_dependency() {
641 let content = r#"
642Pod::Spec.new do |s|
643 s.dependency "AFNetworking", "~> 4.0"
644 s.dependency "Alamofire"
645end
646"#;
647 let deps = extract_dependencies(content);
648 assert_eq!(deps.len(), 2);
649
650 assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
651 assert_eq!(deps[0].extracted_requirement, Some("~> 4.0".to_string()));
652 assert_eq!(deps[0].is_pinned, Some(false)); assert_eq!(deps[1].purl, Some("pkg:cocoapods/Alamofire".to_string()));
655 assert_eq!(deps[1].extracted_requirement, None);
656 }
657
658 #[test]
659 fn test_extract_runtime_and_development_dependency_scopes() {
660 let content = r#"
661Pod::Spec.new do |s|
662 s.add_dependency 'AFNetworking', '~> 4.0'
663 s.add_runtime_dependency 'Alamofire', '~> 5.0'
664 s.add_development_dependency 'Quick', '~> 7.0'
665end
666"#;
667
668 let deps = extract_dependencies(content);
669 assert_eq!(deps.len(), 3);
670
671 assert_eq!(deps[0].scope.as_deref(), Some("runtime"));
672 assert_eq!(deps[0].is_runtime, Some(true));
673 assert_eq!(deps[0].is_optional, Some(false));
674
675 assert_eq!(deps[1].scope.as_deref(), Some("runtime"));
676 assert_eq!(deps[1].is_runtime, Some(true));
677 assert_eq!(deps[1].is_optional, Some(false));
678
679 assert_eq!(deps[2].scope.as_deref(), Some("development"));
680 assert_eq!(deps[2].is_runtime, Some(false));
681 assert_eq!(deps[2].is_optional, Some(true));
682 }
683
684 #[test]
685 fn test_parse_author_string() {
686 assert_eq!(
687 parse_author_string("John Doe <john@example.com>"),
688 ("John Doe".to_string(), Some("john@example.com".to_string()))
689 );
690 assert_eq!(
691 parse_author_string("Jane Smith"),
692 ("Jane Smith".to_string(), None)
693 );
694 }
695
696 #[test]
697 fn test_normalize_podspec_license_string() {
698 let content = r#"
699Pod::Spec.new do |s|
700 s.license = 'Apache License, Version 2.0'
701end
702"#;
703
704 let extracted = extract_license_statement(content);
705 let (declared, declared_spdx, detections) =
706 normalize_podspec_declared_license(content, extracted.as_deref());
707
708 assert_eq!(declared.as_deref(), Some("apache-2.0"));
709 assert_eq!(declared_spdx.as_deref(), Some("Apache-2.0"));
710 assert_eq!(detections.len(), 1);
711 }
712
713 #[test]
714 fn test_normalize_podspec_hash_type_only() {
715 let content = r#"
716Pod::Spec.new do |s|
717 s.license = { :type => 'MIT', :file => 'LICENSE' }
718end
719"#;
720
721 let extracted = extract_license_statement(content);
722 let (declared, declared_spdx, detections) =
723 normalize_podspec_declared_license(content, extracted.as_deref());
724
725 assert_eq!(declared.as_deref(), Some("mit"));
726 assert_eq!(declared_spdx.as_deref(), Some("MIT"));
727 assert_eq!(detections.len(), 1);
728 }
729
730 #[test]
731 fn test_podspec_license_hash_preserves_license_file_reference() {
732 let content = r#"
733Pod::Spec.new do |s|
734 s.name = "Demo"
735 s.version = "1.0.0"
736 s.license = { :type => 'MIT', :file => 'LICENSE.txt' }
737end
738"#;
739
740 let temp_dir = tempfile::tempdir().unwrap();
741 let file_path = temp_dir.path().join("Demo.podspec");
742 std::fs::write(&file_path, content).unwrap();
743
744 let package_data = PodspecParser::extract_first_package(&file_path);
745 assert_eq!(package_data.license_detections.len(), 1);
746 assert_eq!(
747 package_data.license_detections[0].matches[0]
748 .referenced_filenames
749 .as_ref(),
750 Some(&vec!["LICENSE.txt".to_string()])
751 );
752 }
753}