1use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27use std::sync::LazyLock;
28
29use crate::parser_warn as warn;
30use packageurl::PackageUrl;
31use regex::Regex;
32
33use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
34use crate::parsers::PackageParser;
35use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
36
37pub struct PodfileParser;
49
50impl PackageParser for PodfileParser {
51 const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
52
53 fn is_match(path: &Path) -> bool {
54 path.file_name().is_some_and(|name| name == "Podfile")
55 }
56
57 fn extract_packages(path: &Path) -> Vec<PackageData> {
58 let content = match read_file_to_string(path, None) {
59 Ok(c) => c,
60 Err(e) => {
61 warn!("Failed to read {:?}: {}", path, e);
62 return vec![default_package_data()];
63 }
64 };
65
66 let dependencies = extract_dependencies_with_context(&content, path.parent());
67
68 vec![PackageData {
69 package_type: Some(Self::PACKAGE_TYPE),
70 namespace: None,
71 name: None,
72 version: None,
73 qualifiers: None,
74 subpath: None,
75 primary_language: Some("Objective-C".to_string()),
76 description: None,
77 release_date: None,
78 parties: Vec::new(),
79 keywords: Vec::new(),
80 homepage_url: None,
81 download_url: None,
82 size: None,
83 sha1: None,
84 md5: None,
85 sha256: None,
86 sha512: None,
87 bug_tracking_url: None,
88 code_view_url: None,
89 vcs_url: None,
90 copyright: None,
91 holder: None,
92 declared_license_expression: None,
93 declared_license_expression_spdx: None,
94 license_detections: Vec::new(),
95 other_license_expression: None,
96 other_license_expression_spdx: None,
97 other_license_detections: Vec::new(),
98 extracted_license_statement: None,
99 notice_text: None,
100 source_packages: Vec::new(),
101 file_references: Vec::new(),
102 extra_data: None,
103 dependencies,
104 repository_homepage_url: None,
105 repository_download_url: None,
106 api_data_url: None,
107 datasource_id: Some(DatasourceId::CocoapodsPodfile),
108 purl: None,
109 is_private: false,
110 is_virtual: false,
111 }]
112 }
113}
114
115fn default_package_data() -> PackageData {
116 PackageData {
117 package_type: Some(PodfileParser::PACKAGE_TYPE),
118 primary_language: Some("Objective-C".to_string()),
119 datasource_id: Some(DatasourceId::CocoapodsPodfile),
120 ..Default::default()
121 }
122}
123
124static POD_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
125 Regex::new(r#"^\s*pod\s+['"]([^'"]+)['"](?:\s*,\s*(.+))?$"#).expect("valid regex")
126});
127
128static POD_HASH_LOOKUP_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
129 Regex::new(r#"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\[\s*['"]([^'"]+)['"]\s*\]"#).expect("valid regex")
130});
131
132static POD_QUOTED_VALUE_PATTERN: LazyLock<Regex> =
133 LazyLock::new(|| Regex::new(r#"^\s*['"]([^'"]+)['"]"#).expect("valid regex"));
134
135static POD_OPTION_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
136 Regex::new(r#"(?:^|,\s*):([A-Za-z_][A-Za-z0-9_]*)\s*=>\s*['"]([^'"]+)['"]"#)
137 .expect("valid regex")
138});
139
140static REQUIRE_RELATIVE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
141 Regex::new(r#"(?m)^\s*require_relative\s+['"]([^'"]+)['"]"#).expect("valid regex")
142});
143
144static HASH_ASSIGNMENT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
145 Regex::new(r#"(?ms)^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{(.*?)\}"#).expect("valid regex")
146});
147
148static HASH_ENTRY_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
149 Regex::new(r#"['"]([^'"]+)['"]\s*=>\s*['"]([^'"]+)['"]"#).expect("valid regex")
150});
151
152#[cfg(test)]
154fn extract_dependencies(content: &str) -> Vec<Dependency> {
155 extract_dependencies_with_context(content, None)
156}
157
158fn extract_dependencies_with_context(content: &str, base_dir: Option<&Path>) -> Vec<Dependency> {
159 let mut dependencies = Vec::new();
160 let contexts = load_podfile_contexts(content, base_dir);
161 let version_hashes = extract_podfile_hash_assignments(&contexts);
162
163 for line in content.lines().take(MAX_ITERATION_COUNT) {
164 let cleaned_line = pre_process(line);
165 if let Some(caps) = POD_PATTERN.captures(&cleaned_line) {
166 let name = truncate_field(caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string());
167 let args = caps.get(2).map(|m| m.as_str()).unwrap_or("");
168 let version_req = extract_pod_version_requirement(args, &version_hashes);
169 let git_url = extract_pod_option(args, "git");
170 let local_path = extract_pod_option(args, "path");
171
172 if let Some(dep) = create_dependency(name, version_req, git_url, local_path) {
173 dependencies.push(dep);
174 }
175 }
176 }
177
178 dependencies
179}
180
181fn create_dependency(
183 name: String,
184 version_req: Option<String>,
185 _git_url: Option<String>,
186 _local_path: Option<String>,
187) -> Option<Dependency> {
188 if name.is_empty() {
189 return None;
190 }
191
192 let purl = PackageUrl::new("cocoapods", &name).ok()?;
193
194 let is_pinned = version_req
195 .as_ref()
196 .map(|v| !v.contains(&['~', '>', '<', '='][..]))
197 .unwrap_or(false);
198
199 Some(Dependency {
200 purl: Some(truncate_field(purl.to_string())),
201 extracted_requirement: version_req.map(truncate_field),
202 scope: Some("dependencies".to_string()),
203 is_runtime: None,
204 is_optional: None,
205 is_pinned: Some(is_pinned),
206 is_direct: Some(true),
207 resolved_package: None,
208 extra_data: None,
209 })
210}
211
212fn pre_process(line: &str) -> String {
214 let line = if let Some(comment_pos) = line.find('#') {
215 &line[..comment_pos]
216 } else {
217 line
218 };
219 line.trim().to_string()
220}
221
222fn extract_pod_version_requirement(
223 args: &str,
224 version_hashes: &HashMap<String, HashMap<String, String>>,
225) -> Option<String> {
226 if args.is_empty() {
227 return None;
228 }
229
230 if let Some(captures) = POD_QUOTED_VALUE_PATTERN.captures(args) {
231 return captures
232 .get(1)
233 .map(|value| truncate_field(value.as_str().to_string()));
234 }
235
236 let captures = POD_HASH_LOOKUP_PATTERN.captures(args)?;
237 let hash_name = captures.get(1)?.as_str();
238 let key = captures.get(2)?.as_str();
239 version_hashes
240 .get(hash_name)
241 .and_then(|entries| entries.get(key))
242 .cloned()
243 .map(truncate_field)
244}
245
246fn extract_pod_option(args: &str, key: &str) -> Option<String> {
247 POD_OPTION_PATTERN.captures_iter(args).find_map(|captures| {
248 (captures.get(1)?.as_str() == key)
249 .then(|| {
250 captures
251 .get(2)
252 .map(|value| truncate_field(value.as_str().to_string()))
253 })
254 .flatten()
255 })
256}
257
258fn load_podfile_contexts(content: &str, base_dir: Option<&Path>) -> Vec<String> {
259 let mut contexts = vec![content.to_string()];
260 let Some(base_dir) = base_dir else {
261 return contexts;
262 };
263 let Ok(allowed_root) = base_dir.canonicalize() else {
264 return contexts;
265 };
266
267 for captures in REQUIRE_RELATIVE_PATTERN
268 .captures_iter(content)
269 .take(MAX_ITERATION_COUNT)
270 {
271 let Some(required) = captures.get(1).map(|value| value.as_str()) else {
272 continue;
273 };
274 for candidate in candidate_require_relative_paths(base_dir, required) {
275 let Ok(canonical_candidate) = candidate.canonicalize() else {
276 continue;
277 };
278 if !canonical_candidate.starts_with(&allowed_root) {
279 continue;
280 }
281 if let Ok(required_content) = read_file_to_string(&canonical_candidate, None) {
282 contexts.push(required_content);
283 break;
284 }
285 }
286 }
287
288 contexts
289}
290
291fn candidate_require_relative_paths(base_dir: &Path, required: &str) -> Vec<PathBuf> {
292 let required = if required.ends_with(".rb") {
293 required.to_string()
294 } else {
295 format!("{required}.rb")
296 };
297 vec![base_dir.join(required)]
298}
299
300fn extract_podfile_hash_assignments(
301 contexts: &[String],
302) -> HashMap<String, HashMap<String, String>> {
303 let mut hashes = HashMap::new();
304
305 for context in contexts.iter().take(MAX_ITERATION_COUNT) {
306 for captures in HASH_ASSIGNMENT_PATTERN
307 .captures_iter(context)
308 .take(MAX_ITERATION_COUNT)
309 {
310 let Some(hash_name) = captures.get(1).map(|value| value.as_str().to_string()) else {
311 continue;
312 };
313 let Some(body) = captures.get(2).map(|value| value.as_str()) else {
314 continue;
315 };
316
317 let mut entries = HashMap::new();
318 for entry in HASH_ENTRY_PATTERN
319 .captures_iter(body)
320 .take(MAX_ITERATION_COUNT)
321 {
322 let Some(key) = entry.get(1).map(|value| value.as_str().to_string()) else {
323 continue;
324 };
325 let Some(value) = entry.get(2).map(|value| value.as_str().to_string()) else {
326 continue;
327 };
328 entries.insert(key, value);
329 }
330
331 if !entries.is_empty() {
332 hashes.insert(hash_name, entries);
333 }
334 }
335 }
336
337 hashes
338}
339
340crate::register_parser!(
341 "CocoaPods Podfile",
342 &["**/Podfile"],
343 "cocoapods",
344 "Objective-C",
345 Some("https://guides.cocoapods.org/using/the-podfile.html"),
346);
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn test_is_match() {
354 assert!(PodfileParser::is_match(Path::new("Podfile")));
355 assert!(PodfileParser::is_match(Path::new("project/Podfile")));
356 assert!(!PodfileParser::is_match(Path::new("Podfile.lock")));
357 assert!(!PodfileParser::is_match(Path::new("FooPodfile")));
358 assert!(!PodfileParser::is_match(Path::new("config.podfile")));
359 assert!(!PodfileParser::is_match(Path::new("MyLib.podspec")));
360 assert!(!PodfileParser::is_match(Path::new("MyLib.podspec.json")));
361 }
362
363 #[test]
364 fn test_extract_simple_pod() {
365 let content = r#"
366platform :ios, '9.0'
367
368target 'MyApp' do
369 pod 'AFNetworking', '~> 4.0'
370 pod 'Alamofire'
371end
372"#;
373 let deps = extract_dependencies(content);
374 assert_eq!(deps.len(), 2);
375
376 assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
377 assert_eq!(deps[0].extracted_requirement, Some("~> 4.0".to_string()));
378 assert_eq!(deps[0].is_pinned, Some(false));
379 assert_eq!(deps[0].scope, Some("dependencies".to_string()));
380 assert_eq!(deps[0].is_runtime, None);
381 assert_eq!(deps[0].is_optional, None);
382
383 assert_eq!(deps[1].purl, Some("pkg:cocoapods/Alamofire".to_string()));
384 assert_eq!(deps[1].extracted_requirement, None);
385 }
386
387 #[test]
388 fn test_extract_pod_with_git() {
389 let content = r#"
390pod 'AFNetworking', :git => 'https://github.com/AFNetworking/AFNetworking.git'
391"#;
392 let deps = extract_dependencies(content);
393 assert_eq!(deps.len(), 1);
394 assert_eq!(deps[0].purl, Some("pkg:cocoapods/AFNetworking".to_string()));
395 }
396
397 #[test]
398 fn test_extract_pod_with_path() {
399 let content = r#"
400pod 'MyLocalPod', :path => '../MyLocalPod'
401"#;
402 let deps = extract_dependencies(content);
403 assert_eq!(deps.len(), 1);
404 assert_eq!(deps[0].purl, Some("pkg:cocoapods/MyLocalPod".to_string()));
405 }
406
407 #[test]
408 fn test_extract_pod_with_version_and_git() {
409 let content = r#"
410pod 'RestKit', '~> 0.20', :git => 'https://github.com/RestKit/RestKit.git'
411"#;
412 let deps = extract_dependencies(content);
413 assert_eq!(deps.len(), 1);
414 assert_eq!(deps[0].purl, Some("pkg:cocoapods/RestKit".to_string()));
415 assert_eq!(deps[0].extracted_requirement, Some("~> 0.20".to_string()));
416 }
417
418 #[test]
419 fn test_ignores_comments() {
420 let content = r#"
421# pod 'Commented', '1.0'
422pod 'Active', '2.0' # inline comment
423"#;
424 let deps = extract_dependencies(content);
425 assert_eq!(deps.len(), 1);
426 assert_eq!(deps[0].purl, Some("pkg:cocoapods/Active".to_string()));
427 }
428
429 #[test]
430 fn test_extract_pod_version_from_required_hash() {
431 let temp_dir = tempfile::tempdir().expect("temp dir");
432 let version_file = temp_dir.path().join("PodVersions.rb");
433 std::fs::write(
434 &version_file,
435 r#"
436versions = {
437 'Flipper' => '0.125.0',
438}
439"#,
440 )
441 .expect("write version helper");
442 let podfile_path = temp_dir.path().join("Podfile");
443 std::fs::write(
444 &podfile_path,
445 r#"
446require_relative 'PodVersions'
447
448target 'Example' do
449 pod 'FlipperKit', versions['Flipper']
450end
451"#,
452 )
453 .expect("write podfile");
454
455 let package_data = PodfileParser::extract_first_package(&podfile_path);
456 assert_eq!(package_data.dependencies.len(), 1);
457 assert_eq!(
458 package_data.dependencies[0].purl.as_deref(),
459 Some("pkg:cocoapods/FlipperKit")
460 );
461 assert_eq!(
462 package_data.dependencies[0]
463 .extracted_requirement
464 .as_deref(),
465 Some("0.125.0")
466 );
467 assert_eq!(package_data.dependencies[0].is_pinned, Some(true));
468 }
469}