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