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