1use std::path::Path;
26use std::sync::LazyLock;
27
28use crate::parser_warn as warn;
29use packageurl::PackageUrl;
30use regex::Regex;
31
32use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
33use crate::parsers::PackageParser;
34use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
35
36pub struct PodfileParser;
48
49impl PackageParser for PodfileParser {
50 const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
51
52 fn is_match(path: &Path) -> bool {
53 path.file_name().is_some_and(|name| name == "Podfile")
54 }
55
56 fn extract_packages(path: &Path) -> Vec<PackageData> {
57 let content = match read_file_to_string(path, None) {
58 Ok(c) => c,
59 Err(e) => {
60 warn!("Failed to read {:?}: {}", path, e);
61 return vec![default_package_data()];
62 }
63 };
64
65 let dependencies = extract_dependencies(&content);
66
67 vec![PackageData {
68 package_type: Some(Self::PACKAGE_TYPE),
69 namespace: None,
70 name: None,
71 version: None,
72 qualifiers: None,
73 subpath: None,
74 primary_language: Some("Objective-C".to_string()),
75 description: None,
76 release_date: None,
77 parties: Vec::new(),
78 keywords: Vec::new(),
79 homepage_url: None,
80 download_url: None,
81 size: None,
82 sha1: None,
83 md5: None,
84 sha256: None,
85 sha512: None,
86 bug_tracking_url: None,
87 code_view_url: None,
88 vcs_url: None,
89 copyright: None,
90 holder: None,
91 declared_license_expression: None,
92 declared_license_expression_spdx: None,
93 license_detections: Vec::new(),
94 other_license_expression: None,
95 other_license_expression_spdx: None,
96 other_license_detections: Vec::new(),
97 extracted_license_statement: None,
98 notice_text: None,
99 source_packages: Vec::new(),
100 file_references: Vec::new(),
101 extra_data: None,
102 dependencies,
103 repository_homepage_url: None,
104 repository_download_url: None,
105 api_data_url: None,
106 datasource_id: Some(DatasourceId::CocoapodsPodfile),
107 purl: None,
108 is_private: false,
109 is_virtual: false,
110 }]
111 }
112}
113
114fn default_package_data() -> PackageData {
115 PackageData {
116 package_type: Some(PodfileParser::PACKAGE_TYPE),
117 primary_language: Some("Objective-C".to_string()),
118 datasource_id: Some(DatasourceId::CocoapodsPodfile),
119 ..Default::default()
120 }
121}
122
123static POD_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
124 Regex::new(
125 r#"pod\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?(?:\s*,\s*:git\s*=>\s*['"]([^'"]+)['"])?(?:\s*,\s*:path\s*=>\s*['"]([^'"]+)['"])?"#
126 ).expect("valid regex")
127});
128
129fn extract_dependencies(content: &str) -> Vec<Dependency> {
131 let mut dependencies = Vec::new();
132
133 for line in content.lines().take(MAX_ITERATION_COUNT) {
134 let cleaned_line = pre_process(line);
135 if let Some(caps) = POD_PATTERN.captures(&cleaned_line) {
136 let name = truncate_field(caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string());
137 let version_req = caps.get(2).map(|m| truncate_field(m.as_str().to_string()));
138 let git_url = caps.get(3).map(|m| truncate_field(m.as_str().to_string()));
139 let local_path = caps.get(4).map(|m| truncate_field(m.as_str().to_string()));
140
141 if let Some(dep) = create_dependency(name, version_req, git_url, local_path) {
142 dependencies.push(dep);
143 }
144 }
145 }
146
147 dependencies
148}
149
150fn create_dependency(
152 name: String,
153 version_req: Option<String>,
154 _git_url: Option<String>,
155 _local_path: Option<String>,
156) -> Option<Dependency> {
157 if name.is_empty() {
158 return None;
159 }
160
161 let purl = PackageUrl::new("cocoapods", &name).ok()?;
162
163 let is_pinned = version_req
164 .as_ref()
165 .map(|v| !v.contains(&['~', '>', '<', '='][..]))
166 .unwrap_or(false);
167
168 Some(Dependency {
169 purl: Some(truncate_field(purl.to_string())),
170 extracted_requirement: version_req.map(truncate_field),
171 scope: Some("dependencies".to_string()),
172 is_runtime: None,
173 is_optional: None,
174 is_pinned: Some(is_pinned),
175 is_direct: Some(true),
176 resolved_package: None,
177 extra_data: None,
178 })
179}
180
181fn pre_process(line: &str) -> String {
183 let line = if let Some(comment_pos) = line.find('#') {
184 &line[..comment_pos]
185 } else {
186 line
187 };
188 line.trim().to_string()
189}
190
191crate::register_parser!(
192 "CocoaPods Podfile",
193 &["**/Podfile"],
194 "cocoapods",
195 "Objective-C",
196 Some("https://guides.cocoapods.org/using/the-podfile.html"),
197);
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn test_is_match() {
205 assert!(PodfileParser::is_match(Path::new("Podfile")));
206 assert!(PodfileParser::is_match(Path::new("project/Podfile")));
207 assert!(!PodfileParser::is_match(Path::new("Podfile.lock")));
208 assert!(!PodfileParser::is_match(Path::new("FooPodfile")));
209 assert!(!PodfileParser::is_match(Path::new("config.podfile")));
210 assert!(!PodfileParser::is_match(Path::new("MyLib.podspec")));
211 assert!(!PodfileParser::is_match(Path::new("MyLib.podspec.json")));
212 }
213
214 #[test]
215 fn test_extract_simple_pod() {
216 let content = r#"
217platform :ios, '9.0'
218
219target 'MyApp' do
220 pod 'AFNetworking', '~> 4.0'
221 pod 'Alamofire'
222end
223"#;
224 let deps = extract_dependencies(content);
225 assert_eq!(deps.len(), 2);
226
227 assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
228 assert_eq!(deps[0].extracted_requirement, Some("~> 4.0".to_string()));
229 assert_eq!(deps[0].is_pinned, Some(false));
230 assert_eq!(deps[0].scope, Some("dependencies".to_string()));
231 assert_eq!(deps[0].is_runtime, None);
232 assert_eq!(deps[0].is_optional, None);
233
234 assert_eq!(deps[1].purl, Some("pkg:cocoapods/Alamofire".to_string()));
235 assert_eq!(deps[1].extracted_requirement, None);
236 }
237
238 #[test]
239 fn test_extract_pod_with_git() {
240 let content = r#"
241pod 'AFNetworking', :git => 'https://github.com/AFNetworking/AFNetworking.git'
242"#;
243 let deps = extract_dependencies(content);
244 assert_eq!(deps.len(), 1);
245 assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
246 }
247
248 #[test]
249 fn test_extract_pod_with_path() {
250 let content = r#"
251pod 'MyLocalPod', :path => '../MyLocalPod'
252"#;
253 let deps = extract_dependencies(content);
254 assert_eq!(deps.len(), 1);
255 assert_eq!(deps[0].purl, Some("pkg:cocoapods/MyLocalPod".to_string()));
256 }
257
258 #[test]
259 fn test_extract_pod_with_version_and_git() {
260 let content = r#"
261pod 'RestKit', '~> 0.20', :git => 'https://github.com/RestKit/RestKit.git'
262"#;
263 let deps = extract_dependencies(content);
264 assert_eq!(deps.len(), 1);
265 assert_eq!(deps[0].purl, Some("pkg:cocoapods/RestKit".to_string()));
266 assert_eq!(deps[0].extracted_requirement, Some("~> 0.20".to_string()));
267 }
268
269 #[test]
270 fn test_ignores_comments() {
271 let content = r#"
272# pod 'Commented', '1.0'
273pod 'Active', '2.0' # inline comment
274"#;
275 let deps = extract_dependencies(content);
276 assert_eq!(deps.len(), 1);
277 assert_eq!(deps[0].purl, Some("pkg:cocoapods/Active".to_string()));
278 }
279}