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