1use std::path::Path;
31use std::sync::LazyLock;
32
33use crate::parser_warn as warn;
34use crate::parsers::podfile_lock::build_cocoapods_purl;
35use md5::{Digest, Md5};
36use regex::Regex;
37
38use super::metadata::ParserMetadata;
39use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
40use crate::parsers::PackageParser;
41use crate::parsers::license_normalization::{
42 DeclaredLicenseMatchMetadata, build_declared_license_data_from_pair,
43 normalize_spdx_declared_license,
44};
45use crate::parsers::utils::{
46 CappedIterExt, capped_iteration_limit, read_file_to_string, truncate_field,
47};
48
49pub struct PodspecParser;
62
63impl PackageParser for PodspecParser {
64 const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
65
66 fn metadata() -> Vec<ParserMetadata> {
67 vec![ParserMetadata {
68 description: "CocoaPods podspec file",
69 file_patterns: &["**/*.podspec"],
70 package_type: "cocoapods",
71 primary_language: "Objective-C",
72 documentation_url: Some("https://guides.cocoapods.org/syntax/podspec.html"),
73 }]
74 }
75
76 fn is_match(path: &Path) -> bool {
77 path.extension().is_some_and(|ext| {
78 ext == "podspec"
79 && path
80 .file_name()
81 .is_some_and(|name| !name.to_string_lossy().ends_with(".json.podspec"))
82 })
83 }
84
85 fn extract_packages(path: &Path) -> Vec<PackageData> {
86 let content = match read_file_to_string(path, None) {
87 Ok(c) => c,
88 Err(e) => {
89 warn!("Failed to read {:?}: {}", path, e);
90 return vec![default_package_data()];
91 }
92 };
93
94 let raw_name = extract_raw_field(&content, &NAME_PATTERN);
95 let raw_version = extract_raw_field(&content, &VERSION_PATTERN);
96 let name = extract_metadata_field(&content, &NAME_PATTERN).map(truncate_field);
97 let version = extract_metadata_field(&content, &VERSION_PATTERN).map(truncate_field);
98 let summary = extract_metadata_field(&content, &SUMMARY_PATTERN).map(truncate_field);
99 let description =
100 merge_summary_and_description(summary.as_deref(), extract_description(&content))
101 .map(truncate_field);
102 let homepage_url = extract_metadata_field(&content, &HOMEPAGE_PATTERN).map(truncate_field);
103 let license = extract_license_statement(&content).map(truncate_field);
104 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
105 normalize_podspec_declared_license(&content, license.as_deref());
106 let source = extract_source_url(&content).map(truncate_field);
107 let authors = extract_authors(&content);
108
109 let parties = authors
110 .into_iter()
111 .map(|(name, email)| Party {
112 r#type: Some(PartyType::Person),
113 name: Some(truncate_field(name)),
114 email: email.map(truncate_field),
115 url: None,
116 role: Some("author".to_string()),
117 organization: None,
118 organization_url: None,
119 timezone: None,
120 })
121 .collect();
122
123 let dependencies = extract_dependencies(&content);
124 let mut extra_data = serde_json::Map::new();
125 let has_dynamic_identity_placeholders = raw_name
126 .as_deref()
127 .is_some_and(looks_like_nonliteral_podspec_expression)
128 && raw_version
129 .as_deref()
130 .is_some_and(looks_like_nonliteral_podspec_expression);
131 if has_dynamic_identity_placeholders {
132 extra_data.insert(
133 "dynamic_identity_placeholders".to_string(),
134 serde_json::Value::Bool(true),
135 );
136 }
137 if let Some(raw_license) = extract_field(&content, &LICENSE_PATTERN)
138 && let Some(license_file) = extract_ruby_hash_file(&raw_license)
139 {
140 extra_data.insert(
141 "license_file".to_string(),
142 serde_json::Value::String(license_file),
143 );
144 }
145 let repository_homepage_url = name
146 .as_ref()
147 .map(|n| format!("https://cocoapods.org/pods/{}", n));
148 let repository_download_url = match (source.as_deref(), version.as_deref()) {
149 (Some(vcs_url), Some(version_str)) => get_repo_base_url(vcs_url)
150 .map(|base| format!("{}/archive/refs/tags/{}.zip", base, version_str)),
151 _ => None,
152 };
153 let code_view_url = match (source.as_deref(), version.as_deref()) {
154 (Some(vcs_url), Some(version_str)) => {
155 get_repo_base_url(vcs_url).map(|base| format!("{}/tree/{}", base, version_str))
156 }
157 _ => None,
158 };
159 let bug_tracking_url = source
160 .as_deref()
161 .and_then(get_repo_base_url)
162 .map(|base| format!("{}/issues/", base));
163 let api_data_url = match (name.as_deref(), version.as_deref()) {
164 (Some(name_str), Some(version_str)) => get_hashed_path(name_str).map(|hashed| {
165 format!(
166 "https://raw.githubusercontent.com/CocoaPods/Specs/blob/master/Specs/{}/{}/{}/{}.podspec.json",
167 hashed, name_str, version_str, name_str
168 )
169 }),
170 _ => None,
171 };
172 let purl = name
173 .as_deref()
174 .and_then(|name_str| build_cocoapods_purl(name_str, version.as_deref()))
175 .map(truncate_field);
176
177 vec![PackageData {
178 package_type: Some(Self::PACKAGE_TYPE),
179 namespace: None,
180 name,
181 version,
182 qualifiers: None,
183 subpath: None,
184 primary_language: Some("Objective-C".to_string()),
185 description,
186 release_date: None,
187 parties,
188 keywords: Vec::new(),
189 homepage_url,
190 download_url: None,
191 size: None,
192 sha1: None,
193 md5: None,
194 sha256: None,
195 sha512: None,
196 bug_tracking_url,
197 code_view_url,
198 vcs_url: source,
199 copyright: None,
200 holder: None,
201 declared_license_expression,
202 declared_license_expression_spdx,
203 license_detections,
204 other_license_expression: None,
205 other_license_expression_spdx: None,
206 other_license_detections: Vec::new(),
207 extracted_license_statement: license,
208 notice_text: None,
209 source_packages: Vec::new(),
210 file_references: Vec::new(),
211 extra_data: (!extra_data.is_empty()).then_some(extra_data.into_iter().collect()),
212 dependencies,
213 repository_homepage_url,
214 repository_download_url,
215 api_data_url,
216 datasource_id: Some(DatasourceId::CocoapodsPodspec),
217 purl,
218 is_private: false,
219 is_virtual: false,
220 }]
221 }
222}
223
224fn default_package_data() -> PackageData {
225 PackageData {
226 package_type: Some(PodspecParser::PACKAGE_TYPE),
227 primary_language: Some("Objective-C".to_string()),
228 datasource_id: Some(DatasourceId::CocoapodsPodspec),
229 ..Default::default()
230 }
231}
232
233static NAME_PATTERN: LazyLock<Regex> =
234 LazyLock::new(|| Regex::new(r"\.name\s*=\s*(.+)").expect("valid regex"));
235static VERSION_PATTERN: LazyLock<Regex> =
236 LazyLock::new(|| Regex::new(r"\.version\s*=\s*(.+)").expect("valid regex"));
237static SUMMARY_PATTERN: LazyLock<Regex> =
238 LazyLock::new(|| Regex::new(r"\.summary\s*=\s*(.+)").expect("valid regex"));
239static DESCRIPTION_PATTERN: LazyLock<Regex> =
240 LazyLock::new(|| Regex::new(r"\.description\s*=\s*(.+)").expect("valid regex"));
241static HOMEPAGE_PATTERN: LazyLock<Regex> =
242 LazyLock::new(|| Regex::new(r"\.homepage\s*=\s*(.+)").expect("valid regex"));
243static LICENSE_PATTERN: LazyLock<Regex> =
244 LazyLock::new(|| Regex::new(r"\.license\s*=\s*(.+)").expect("valid regex"));
245static SOURCE_PATTERN: LazyLock<Regex> =
246 LazyLock::new(|| Regex::new(r"\.source\s*=\s*(.+)").expect("valid regex"));
247static AUTHOR_PATTERN: LazyLock<Regex> =
248 LazyLock::new(|| Regex::new(r"\.authors?\s*=\s*(.+)").expect("valid regex"));
249static SOURCE_GIT_PATTERN: LazyLock<Regex> =
250 LazyLock::new(|| Regex::new(r#":git\s*=>\s*['\"]([^'\"]+)['\"]"#).expect("valid regex"));
251static SOURCE_GIT_DYNAMIC_PATTERN: LazyLock<Regex> =
252 LazyLock::new(|| Regex::new(r#":git\s*(?:=>|=)\s*([^,}]+)"#).expect("valid regex"));
253static SOURCE_HTTP_PATTERN: LazyLock<Regex> =
254 LazyLock::new(|| Regex::new(r#":http\s*=>\s*['\"]([^'\"]+)['\"]"#).expect("valid regex"));
255
256static DEPENDENCY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
257 Regex::new(
258 r#"(?:s\.)?(?:dependency|add_dependency|add_(?:runtime|development)_dependency)\s+['"]([^'"]+)['"](?:\s*,\s*(.+))?"#
259).expect("valid regex")
260});
261
262fn extract_license_statement(content: &str) -> Option<String> {
263 let raw_license = extract_raw_field(content, &LICENSE_PATTERN)?;
264 if let Some(license_type) = extract_ruby_hash_type(&raw_license) {
268 return normalize_podspec_dynamic_metadata_value(&license_type);
269 }
270 extract_field(content, &LICENSE_PATTERN)
271 .map(|value| normalize_ruby_hash_literal(&value))
272 .and_then(|value| normalize_podspec_dynamic_metadata_value(&value))
273}
274
275fn normalize_podspec_declared_license(
276 content: &str,
277 extracted_license_statement: Option<&str>,
278) -> (
279 Option<String>,
280 Option<String>,
281 Vec<crate::models::LicenseDetection>,
282) {
283 let Some(raw_license) = extract_raw_field(content, &LICENSE_PATTERN) else {
284 return super::license_normalization::empty_declared_license_data();
285 };
286 if looks_like_nonliteral_podspec_expression(&raw_license) {
287 return build_declared_license_data_from_pair(
288 "unknown".to_string(),
289 "LicenseRef-scancode-unknown".to_string(),
290 DeclaredLicenseMatchMetadata::single_line(
291 extracted_license_statement.unwrap_or(raw_license.as_str()),
292 ),
293 );
294 }
295 let normalized_candidate = if raw_license.contains("=>") || raw_license.contains('=') {
296 extract_ruby_hash_type(&raw_license)
297 .map(|license_type| canonicalize_cocoapods_license_type(&license_type))
298 } else {
299 extracted_license_statement.map(canonicalize_cocoapods_license_type)
300 };
301
302 normalize_spdx_declared_license(normalized_candidate.as_deref())
303}
304
305fn extract_ruby_hash_file(raw_license: &str) -> Option<String> {
306 let normalized = raw_license.replace("=>", "=");
307 let file_regex = Regex::new(r#":file\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
308 file_regex
309 .captures(&normalized)
310 .and_then(|caps| caps.get(1))
311 .map(|value| value.as_str().trim().to_string())
312 .filter(|value| !value.is_empty())
313}
314
315fn canonicalize_cocoapods_license_type(value: &str) -> String {
316 match value.trim() {
317 "Apache License, Version 2.0" => "Apache-2.0".to_string(),
318 other => other.to_string(),
319 }
320}
321
322fn extract_ruby_hash_type(raw_license: &str) -> Option<String> {
323 let normalized = raw_license.replace("=>", "=");
324 let type_regex = Regex::new(r#":type\s*=\s*['\"]([^'\"]+)['\"]"#).ok()?;
325 type_regex
326 .captures(&normalized)
327 .and_then(|caps| caps.get(1))
328 .map(|value| value.as_str().trim().to_string())
329 .filter(|value| !value.is_empty())
330}
331
332fn normalize_ruby_hash_literal(value: &str) -> String {
333 if !value.contains('=') && !value.contains("=>") {
334 return value.to_string();
335 }
336
337 value
338 .replace("=>", "=")
339 .replace(['\'', '"'], "")
340 .split_whitespace()
341 .collect::<Vec<_>>()
342 .join(" ")
343}
344
345fn normalize_podspec_dynamic_metadata_value(value: &str) -> Option<String> {
346 let trimmed = value.trim();
347 if trimmed.is_empty() {
348 return None;
349 }
350
351 if looks_like_podspec_dynamic_metadata_value(trimmed) {
352 let normalized = trimmed
353 .chars()
354 .filter(|c| c.is_ascii_alphanumeric())
355 .collect::<String>()
356 .to_ascii_lowercase();
357 return (!normalized.is_empty()).then_some(normalized);
358 }
359
360 Some(trimmed.to_string())
361}
362
363fn looks_like_podspec_dynamic_metadata_value(value: &str) -> bool {
364 value.contains("['") || value.contains("[\"")
365}
366
367fn looks_like_nonliteral_podspec_expression(value: &str) -> bool {
368 let trimmed = value.trim();
369 if trimmed.is_empty() {
370 return false;
371 }
372
373 if trimmed.contains("=>") || trimmed.starts_with('{') {
374 return false;
375 }
376
377 if matches!(trimmed.chars().next(), Some('\'' | '"'))
378 || trimmed.starts_with("%q{")
379 || trimmed.starts_with("%Q{")
380 {
381 return false;
382 }
383
384 if trimmed
385 .chars()
386 .all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '_' | '+'))
387 {
388 return false;
389 }
390
391 true
392}
393
394fn extract_metadata_field(content: &str, pattern: &Regex) -> Option<String> {
395 extract_field(content, pattern)
396 .and_then(|value| normalize_podspec_dynamic_metadata_value(&value))
397}
398
399fn extract_raw_field(content: &str, pattern: &Regex) -> Option<String> {
400 for line in content.lines().capped("podspec extract_raw_field lines") {
401 let cleaned_line = pre_process(line);
402 if let Some(value) = pattern.captures(&cleaned_line).and_then(|caps| caps.get(1)) {
403 return Some(value.as_str().trim().to_string());
404 }
405 }
406 None
407}
408
409fn extract_field(content: &str, pattern: &Regex) -> Option<String> {
411 for line in content.lines().capped("podspec extract_field lines") {
412 let cleaned_line = pre_process(line);
413 if let Some(value) = pattern.captures(&cleaned_line).and_then(|caps| caps.get(1)) {
414 return Some(clean_string(value.as_str()));
415 }
416 }
417 None
418}
419
420fn extract_description(content: &str) -> Option<String> {
422 let lines: Vec<&str> = content
423 .lines()
424 .capped("podspec extract_description lines")
425 .collect();
426
427 for (i, line) in lines.iter().enumerate() {
428 let cleaned = pre_process(line);
429 if let Some(value) = DESCRIPTION_PATTERN
430 .captures(&cleaned)
431 .and_then(|caps| caps.get(1))
432 {
433 let value_str = value.as_str();
434
435 if value_str.contains("<<-") {
436 return extract_multiline_description(&lines, i);
437 } else {
438 return normalize_podspec_dynamic_metadata_value(&clean_string(value_str));
439 }
440 }
441 }
442 None
443}
444
445fn merge_summary_and_description(
446 summary: Option<&str>,
447 description: Option<String>,
448) -> Option<String> {
449 match (
450 summary.map(str::trim).filter(|s| !s.is_empty()),
451 description,
452 ) {
453 (Some(summary), Some(description)) if description.starts_with(summary) => Some(description),
454 (Some(summary), Some(description)) => Some(format!("{}\n{}", summary, description)),
455 (Some(summary), None) => Some(summary.to_string()),
456 (None, description) => description,
457 }
458}
459
460fn extract_multiline_description(lines: &[&str], start_index: usize) -> Option<String> {
462 let start_line = lines.get(start_index)?;
463
464 let delimiter = start_line
466 .split("<<-")
467 .nth(1)?
468 .trim()
469 .trim_matches(|c| c == '"' || c == '\'');
470
471 let mut description_lines = Vec::new();
472 let mut found_start = false;
473
474 let limit = capped_iteration_limit(lines.len(), "podspec extract_multiline_description lines");
475 for line in lines.iter().take(limit).skip(start_index) {
476 if !found_start && line.contains("<<-") {
477 found_start = true;
478 continue;
479 }
480
481 if found_start {
482 let trimmed = line.trim();
483 if trimmed == delimiter {
484 break;
485 }
486 description_lines.push(*line);
487 }
488 }
489
490 if description_lines.is_empty() {
491 None
492 } else {
493 Some(description_lines.join("\n").trim().to_string())
494 }
495}
496
497fn extract_authors(content: &str) -> Vec<(String, Option<String>)> {
499 let mut authors = Vec::new();
500
501 for line in content.lines().capped("podspec extract_authors lines") {
502 let cleaned_line = pre_process(line);
503 if let Some(value) = AUTHOR_PATTERN
504 .captures(&cleaned_line)
505 .and_then(|caps| caps.get(1))
506 {
507 let value_str = value.as_str();
508
509 if value_str.contains("=>") {
510 for part in value_str.split(',') {
511 if let Some((name, email)) = parse_author_hash_entry(part) {
512 authors.push((name, Some(email)));
513 }
514 }
515 } else {
516 let cleaned = clean_string(value_str);
517 let Some(cleaned) = normalize_podspec_dynamic_metadata_value(&cleaned) else {
518 continue;
519 };
520 let (name, email) = parse_author_string(&cleaned);
521 authors.push((name, email));
522 }
523 }
524 }
525
526 authors
527}
528
529fn extract_source_url(content: &str) -> Option<String> {
530 for line in content.lines().capped("podspec extract_source_url lines") {
531 let cleaned_line = pre_process(line);
532 let Some(value) = SOURCE_PATTERN
533 .captures(&cleaned_line)
534 .and_then(|caps| caps.get(1))
535 .map(|m| m.as_str())
536 else {
537 continue;
538 };
539
540 if let Some(caps) = SOURCE_GIT_PATTERN.captures(value)
541 && let Some(url) = caps.get(1)
542 {
543 return Some(clean_string(url.as_str()));
544 }
545
546 if let Some(caps) = SOURCE_GIT_DYNAMIC_PATTERN.captures(value)
547 && let Some(url) = caps.get(1)
548 {
549 let cleaned = clean_string(url.as_str());
550 if !cleaned.is_empty() {
551 return Some(cleaned);
552 }
553 }
554
555 if let Some(caps) = SOURCE_HTTP_PATTERN.captures(value)
556 && let Some(url) = caps.get(1)
557 {
558 return Some(clean_string(url.as_str()));
559 }
560
561 return Some(clean_string(value));
562 }
563
564 None
565}
566
567fn parse_author_hash_entry(entry: &str) -> Option<(String, String)> {
569 let parts: Vec<&str> = entry.split("=>").collect();
570 if parts.len() == 2 {
571 let name = clean_string(parts[0].trim())
572 .trim()
573 .trim_matches(['\'', '"'])
574 .to_string();
575 let email = clean_string(parts[1].trim())
576 .trim()
577 .trim_matches(['\'', '"'])
578 .to_string();
579 Some((name, email))
580 } else {
581 None
582 }
583}
584
585fn parse_author_string(author: &str) -> (String, Option<String>) {
587 if let Some(email_start) = author.find('<')
588 && let Some(email_end) = author.find('>')
589 {
590 let name = author[..email_start].trim().to_string();
591 let email = author[email_start + 1..email_end].trim().to_string();
592 return (name, Some(email));
593 }
594 (author.to_string(), None)
595}
596
597fn extract_dependencies(content: &str) -> Vec<Dependency> {
599 let mut dependencies = Vec::new();
600
601 for line in content.lines().capped("podspec extract_dependencies lines") {
602 let cleaned_line = pre_process(line);
603 if let Some(caps) = DEPENDENCY_PATTERN.captures(&cleaned_line) {
604 let method = caps.get(0).map(|m| m.as_str()).unwrap_or("");
605 let name = caps.get(1).map(|m| m.as_str()).unwrap_or("");
606 let version_req = caps.get(2).map(|m| clean_string(m.as_str()));
607
608 if let Some(dep) = create_dependency(name, version_req, method) {
609 dependencies.push(dep);
610 }
611 }
612 }
613
614 dependencies
615}
616
617fn create_dependency(name: &str, version_req: Option<String>, method: &str) -> Option<Dependency> {
619 if name.is_empty() {
620 return None;
621 }
622
623 let purl = build_cocoapods_purl(name, None)?;
624
625 let is_pinned = version_req
627 .as_ref()
628 .map(|v| !v.contains(&['~', '>', '<', '='][..]))
629 .unwrap_or(false);
630
631 let is_development = method.contains("add_development_dependency");
632
633 Some(Dependency {
634 purl: Some(truncate_field(purl)),
635 extracted_requirement: version_req.map(truncate_field),
636 scope: Some(
637 if is_development {
638 "development"
639 } else {
640 "runtime"
641 }
642 .to_string(),
643 ),
644 is_runtime: Some(!is_development),
645 is_optional: Some(is_development),
646 is_pinned: Some(is_pinned),
647 is_direct: Some(true),
648 resolved_package: None,
649 extra_data: None,
650 })
651}
652
653fn pre_process(line: &str) -> String {
655 let line = if let Some(comment_pos) = line.find('#') {
656 &line[..comment_pos]
657 } else {
658 line
659 };
660 line.trim().to_string()
661}
662
663fn clean_string(s: &str) -> String {
665 let after_removing_special_patterns = s.trim().replace("%q", "").replace(".freeze", "");
666
667 after_removing_special_patterns
668 .trim_matches(|c| {
669 c == '\''
670 || c == '"'
671 || c == '{'
672 || c == '}'
673 || c == '['
674 || c == ']'
675 || c == '<'
676 || c == '>'
677 })
678 .trim()
679 .to_string()
680}
681
682fn get_repo_base_url(vcs_url: &str) -> Option<String> {
683 if vcs_url.is_empty() {
684 return None;
685 }
686
687 if vcs_url.ends_with(".git") {
688 Some(vcs_url.trim_end_matches(".git").to_string())
689 } else {
690 Some(vcs_url.to_string())
691 }
692}
693
694fn get_hashed_path(name: &str) -> Option<String> {
695 if name.is_empty() {
696 return None;
697 }
698
699 let mut hasher = Md5::new();
700 hasher.update(name.as_bytes());
701 let hash_str = hex::encode(hasher.finalize());
702
703 Some(format!(
704 "{}/{}/{}",
705 &hash_str[0..1],
706 &hash_str[1..2],
707 &hash_str[2..3]
708 ))
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 #[test]
716 fn test_is_match() {
717 assert!(PodspecParser::is_match(Path::new("AFNetworking.podspec")));
718 assert!(PodspecParser::is_match(Path::new("project/MyLib.podspec")));
719 assert!(!PodspecParser::is_match(Path::new(
720 "AFNetworking.podspec.json"
721 )));
722 assert!(!PodspecParser::is_match(Path::new("Podfile")));
723 assert!(!PodspecParser::is_match(Path::new("Podfile.lock")));
724 }
725
726 #[test]
727 fn test_clean_string() {
728 assert_eq!(clean_string("'AFNetworking'"), "AFNetworking");
729 assert_eq!(clean_string("\"AFNetworking\""), "AFNetworking");
730 assert_eq!(clean_string("'test'.freeze"), "test");
731 assert_eq!(clean_string("%q{test}"), "test");
732 }
733
734 #[test]
735 fn test_extract_simple_field() {
736 let content = r#"
737Pod::Spec.new do |s|
738 s.name = "AFNetworking"
739 s.version = "4.0.1"
740end
741"#;
742 assert_eq!(
743 extract_metadata_field(content, &NAME_PATTERN),
744 Some("AFNetworking".to_string())
745 );
746 assert_eq!(
747 extract_metadata_field(content, &VERSION_PATTERN),
748 Some("4.0.1".to_string())
749 );
750 }
751
752 #[test]
753 fn test_extract_dynamic_metadata_field_uses_stable_placeholder() {
754 let content = r#"
755Pod::Spec.new do |s|
756 s.name = package['name']
757 s.version = package['version']
758 s.summary = package['description']
759 s.homepage = package['repository']['url']
760end
761"#;
762
763 assert_eq!(
764 extract_metadata_field(content, &NAME_PATTERN),
765 Some("packagename".to_string())
766 );
767 assert_eq!(
768 extract_metadata_field(content, &VERSION_PATTERN),
769 Some("packageversion".to_string())
770 );
771 assert_eq!(
772 extract_metadata_field(content, &SUMMARY_PATTERN),
773 Some("packagedescription".to_string())
774 );
775 assert_eq!(
776 extract_metadata_field(content, &HOMEPAGE_PATTERN),
777 Some("packagerepositoryurl".to_string())
778 );
779 }
780
781 #[test]
782 fn test_detects_nonliteral_podspec_expressions_generically() {
783 assert!(looks_like_nonliteral_podspec_expression("package['name']"));
784 assert!(looks_like_nonliteral_podspec_expression("pod_name"));
785 assert!(looks_like_nonliteral_podspec_expression(
786 "SpecMetadata.version"
787 ));
788 assert!(!looks_like_nonliteral_podspec_expression("'Demo'"));
789 assert!(!looks_like_nonliteral_podspec_expression("\"1.0.0\""));
790 assert!(!looks_like_nonliteral_podspec_expression("1.0.0"));
791 }
792
793 #[test]
794 fn test_extract_multiline_description() {
795 let content = r#"
796Pod::Spec.new do |s|
797 s.description = <<-DESC
798 A delightful networking library.
799 Features include:
800 - Modern API
801 DESC
802end
803"#;
804 let desc = extract_description(content);
805 assert!(desc.is_some());
806 let desc_text = desc.unwrap();
807 assert!(desc_text.contains("delightful networking"));
808 assert!(desc_text.contains("Modern API"));
809 }
810
811 #[test]
812 fn test_extract_dependency() {
813 let content = r#"
814Pod::Spec.new do |s|
815 s.dependency "AFNetworking", "~> 4.0"
816 s.dependency "Alamofire"
817end
818"#;
819 let deps = extract_dependencies(content);
820 assert_eq!(deps.len(), 2);
821
822 assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
823 assert_eq!(deps[0].extracted_requirement, Some("~> 4.0".to_string()));
824 assert_eq!(deps[0].is_pinned, Some(false)); assert_eq!(deps[1].purl, Some("pkg:cocoapods/Alamofire".to_string()));
827 assert_eq!(deps[1].extracted_requirement, None);
828 }
829
830 #[test]
831 fn test_extract_runtime_and_development_dependency_scopes() {
832 let content = r#"
833Pod::Spec.new do |s|
834 s.add_dependency 'AFNetworking', '~> 4.0'
835 s.add_runtime_dependency 'Alamofire', '~> 5.0'
836 s.add_development_dependency 'Quick', '~> 7.0'
837end
838"#;
839
840 let deps = extract_dependencies(content);
841 assert_eq!(deps.len(), 3);
842
843 assert_eq!(deps[0].scope.as_deref(), Some("runtime"));
844 assert_eq!(deps[0].is_runtime, Some(true));
845 assert_eq!(deps[0].is_optional, Some(false));
846
847 assert_eq!(deps[1].scope.as_deref(), Some("runtime"));
848 assert_eq!(deps[1].is_runtime, Some(true));
849 assert_eq!(deps[1].is_optional, Some(false));
850
851 assert_eq!(deps[2].scope.as_deref(), Some("development"));
852 assert_eq!(deps[2].is_runtime, Some(false));
853 assert_eq!(deps[2].is_optional, Some(true));
854 }
855
856 #[test]
857 fn test_parse_author_string() {
858 assert_eq!(
859 parse_author_string("John Doe <john@example.com>"),
860 ("John Doe".to_string(), Some("john@example.com".to_string()))
861 );
862 assert_eq!(
863 parse_author_string("Jane Smith"),
864 ("Jane Smith".to_string(), None)
865 );
866 }
867
868 #[test]
869 fn test_normalize_podspec_license_string() {
870 let content = r#"
871Pod::Spec.new do |s|
872 s.license = 'Apache License, Version 2.0'
873end
874"#;
875
876 let extracted = extract_license_statement(content);
877 let (declared, declared_spdx, detections) =
878 normalize_podspec_declared_license(content, extracted.as_deref());
879
880 assert_eq!(declared.as_deref(), Some("apache-2.0"));
881 assert_eq!(declared_spdx.as_deref(), Some("Apache-2.0"));
882 assert_eq!(detections.len(), 1);
883 }
884
885 #[test]
886 fn test_normalize_podspec_hash_type_only() {
887 let content = r#"
888Pod::Spec.new do |s|
889 s.license = { :type => 'MIT', :file => 'LICENSE' }
890end
891"#;
892
893 let extracted = extract_license_statement(content);
894 let (declared, declared_spdx, detections) =
895 normalize_podspec_declared_license(content, extracted.as_deref());
896
897 assert_eq!(declared.as_deref(), Some("mit"));
898 assert_eq!(declared_spdx.as_deref(), Some("MIT"));
899 assert_eq!(detections.len(), 1);
900 }
901
902 #[test]
903 fn test_extract_license_statement_type_only_hash_uses_clean_value() {
904 let content = r#"
905Pod::Spec.new do |s|
906 s.license = { :type => 'BSD' }
907end
908"#;
909
910 assert_eq!(extract_license_statement(content).as_deref(), Some("BSD"));
913 }
914
915 #[test]
916 fn test_podspec_type_only_hash_resolves_declared_license() {
917 let content = r#"
918Pod::Spec.new do |s|
919 s.name = 'mypod'
920 s.version = '1.0.0'
921 s.license = { :type => 'BSD' }
922 s.summary = 'test'
923end
924"#;
925
926 let temp_dir = tempfile::tempdir().expect("create temp dir");
927 let file_path = temp_dir.path().join("mypod.podspec");
928 std::fs::write(&file_path, content).expect("write podspec");
929
930 let package_data = PodspecParser::extract_first_package(&file_path);
931
932 assert_eq!(
933 package_data.extracted_license_statement.as_deref(),
934 Some("BSD")
935 );
936 assert_eq!(
937 package_data.declared_license_expression.as_deref(),
938 Some("bsd-new")
939 );
940 assert_eq!(
941 package_data.declared_license_expression_spdx.as_deref(),
942 Some("BSD-3-Clause")
943 );
944 }
945
946 #[test]
947 fn test_podspec_license_hash_preserves_license_file_reference() {
948 let content = r#"
949Pod::Spec.new do |s|
950 s.name = "Demo"
951 s.version = "1.0.0"
952 s.license = { :type => 'MIT', :file => 'LICENSE.txt' }
953end
954"#;
955
956 let temp_dir = tempfile::tempdir().unwrap();
957 let file_path = temp_dir.path().join("Demo.podspec");
958 std::fs::write(&file_path, content).unwrap();
959
960 let package_data = PodspecParser::extract_first_package(&file_path);
961 assert_eq!(package_data.license_detections.len(), 1);
962 assert_eq!(
963 package_data.license_detections[0].matches[0]
964 .referenced_filenames
965 .as_ref(),
966 Some(&vec!["LICENSE.txt".to_string()])
967 );
968 }
969
970 #[test]
971 fn test_extract_packages_marks_dynamic_identity_placeholders_for_nonliteral_fields() {
972 let content = r#"
973Pod::Spec.new do |s|
974
975 s.name = pod_name
976 s.version = pod_version
977 s.summary = package['description']
978 s.homepage = homepage_url
979 s.license = license_name
980 s.author = author_name
981 s.source = { :git => homepage_url, :tag => 'v#{pod_version}' }
982
983 s.requires_arc = true
984 s.ios.deployment_target = '8.0'
985 s.tvos.deployment_target = '9.0'
986
987 s.preserve_paths = 'README.md', 'package.json', 'index.js'
988 s.source_files = 'ios/*.{h,m}'
989
990 s.dependency 'React-Core'
991
992end
993"#;
994
995 let temp_dir = tempfile::tempdir().unwrap();
996 let file_path = temp_dir.path().join("dynamic-identity.podspec");
997 std::fs::write(&file_path, content).unwrap();
998
999 let package_data = PodspecParser::extract_first_package(&file_path);
1000
1001 assert_eq!(package_data.name.as_deref(), Some("pod_name"));
1002 assert_eq!(package_data.version.as_deref(), Some("pod_version"));
1003 assert_eq!(
1004 package_data.description.as_deref(),
1005 Some("packagedescription")
1006 );
1007 assert_eq!(package_data.homepage_url.as_deref(), Some("homepage_url"));
1008 assert_eq!(
1009 package_data.extracted_license_statement.as_deref(),
1010 Some("license_name")
1011 );
1012 assert_eq!(
1013 package_data.declared_license_expression.as_deref(),
1014 Some("unknown")
1015 );
1016 assert_eq!(
1017 package_data.declared_license_expression_spdx.as_deref(),
1018 Some("LicenseRef-scancode-unknown")
1019 );
1020 assert_eq!(package_data.license_detections.len(), 1);
1021 assert_eq!(package_data.vcs_url.as_deref(), Some("homepage_url"));
1022 assert_eq!(
1023 package_data.repository_homepage_url.as_deref(),
1024 Some("https://cocoapods.org/pods/pod_name")
1025 );
1026 assert_eq!(
1027 package_data.repository_download_url.as_deref(),
1028 Some("homepage_url/archive/refs/tags/pod_version.zip")
1029 );
1030 assert_eq!(
1031 package_data.code_view_url.as_deref(),
1032 Some("homepage_url/tree/pod_version")
1033 );
1034 assert_eq!(
1035 package_data.bug_tracking_url.as_deref(),
1036 Some("homepage_url/issues/")
1037 );
1038 assert_eq!(
1039 package_data.purl.as_deref(),
1040 Some("pkg:cocoapods/pod_name@pod_version")
1041 );
1042 assert_eq!(package_data.parties.len(), 1);
1043 assert_eq!(package_data.parties[0].name.as_deref(), Some("author_name"));
1044 assert_eq!(
1045 package_data
1046 .extra_data
1047 .as_ref()
1048 .and_then(|data| data.get("dynamic_identity_placeholders"))
1049 .and_then(|value| value.as_bool()),
1050 Some(true)
1051 );
1052 assert_eq!(package_data.dependencies.len(), 1);
1053 assert_eq!(
1054 package_data.dependencies[0].purl.as_deref(),
1055 Some("pkg:cocoapods/React-Core")
1056 );
1057 }
1058}