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