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