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