1use std::collections::HashMap;
23use std::fs;
24use std::path::Path;
25
26use log::warn;
27use serde_yaml::Value;
28
29use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
30
31use super::PackageParser;
32
33const PRIMARY_LANGUAGE: &str = "Objective-C";
34
35pub struct PodfileLockParser;
50
51impl PackageParser for PodfileLockParser {
52 const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
53
54 fn is_match(path: &Path) -> bool {
55 path.file_name()
56 .and_then(|name| name.to_str())
57 .is_some_and(|name| name == "Podfile.lock")
58 }
59
60 fn extract_packages(path: &Path) -> Vec<PackageData> {
61 let content = match fs::read_to_string(path) {
62 Ok(c) => c,
63 Err(e) => {
64 warn!("Failed to read Podfile.lock at {:?}: {}", path, e);
65 return vec![default_package_data()];
66 }
67 };
68
69 let data: Value = match serde_yaml::from_str(&content) {
70 Ok(d) => d,
71 Err(e) => {
72 warn!("Failed to parse Podfile.lock at {:?}: {}", path, e);
73 return vec![default_package_data()];
74 }
75 };
76
77 vec![parse_podfile_lock(&data)]
78 }
79}
80
81struct DependencyDataByPurl {
82 versions_by_base_purl: HashMap<String, String>,
83 direct_dependency_purls: Vec<String>,
84 spec_by_base_purl: HashMap<String, String>,
85 checksum_by_base_purl: HashMap<String, String>,
86 external_sources_by_base_purl: HashMap<String, String>,
87}
88
89impl DependencyDataByPurl {
90 fn collect(data: &Value) -> Self {
91 let mut dep_data = DependencyDataByPurl {
92 versions_by_base_purl: HashMap::new(),
93 direct_dependency_purls: Vec::new(),
94 spec_by_base_purl: HashMap::new(),
95 checksum_by_base_purl: HashMap::new(),
96 external_sources_by_base_purl: HashMap::new(),
97 };
98
99 if let Some(pods) = data.get("PODS").and_then(|v| v.as_sequence()) {
100 for pod in pods {
101 let main_pod_str = match pod {
102 Value::String(s) => Some(s.as_str()),
103 Value::Mapping(m) => m.keys().next().and_then(|k| k.as_str()),
104 _ => None,
105 };
106 if let Some(main_pod_str) = main_pod_str {
107 let (base_purl, version) = parse_dep_to_base_purl_and_version(main_pod_str);
108 if let Some(version) = version {
109 dep_data.versions_by_base_purl.insert(base_purl, version);
110 }
111 }
112 }
113 }
114
115 if let Some(deps) = data.get("DEPENDENCIES").and_then(|v| v.as_sequence()) {
116 for dep in deps {
117 if let Some(dep_str) = dep.as_str() {
118 let (base_purl, _) = parse_dep_to_base_purl_and_version(dep_str);
119 dep_data.direct_dependency_purls.push(base_purl);
120 }
121 }
122 }
123
124 if let Some(spec_repos) = data.get("SPEC REPOS").and_then(|v| v.as_mapping()) {
125 for (repo_key, packages) in spec_repos {
126 let repo_name = match repo_key.as_str() {
127 Some(s) => s.to_string(),
128 None => continue,
129 };
130 if let Some(packages) = packages.as_sequence() {
131 for package in packages {
132 if let Some(pkg_str) = package.as_str() {
133 let (base_purl, _) = parse_dep_to_base_purl_and_version(pkg_str);
134 dep_data
135 .spec_by_base_purl
136 .insert(base_purl, repo_name.clone());
137 }
138 }
139 }
140 }
141 }
142
143 if let Some(checksums) = data.get("SPEC CHECKSUMS").and_then(|v| v.as_mapping()) {
144 for (name_key, checksum_val) in checksums {
145 if let (Some(name), Some(checksum)) = (name_key.as_str(), checksum_val.as_str()) {
146 let (base_purl, _) = parse_dep_to_base_purl_and_version(name);
147 dep_data
148 .checksum_by_base_purl
149 .insert(base_purl, checksum.to_string());
150 }
151 }
152 }
153
154 if let Some(checkout_opts) = data.get("CHECKOUT OPTIONS").and_then(|v| v.as_mapping()) {
155 for (name_key, source) in checkout_opts {
156 if let (Some(name), Some(mapping)) = (name_key.as_str(), source.as_mapping()) {
157 let base_purl = make_base_purl(name);
158 let processed = process_external_source(mapping);
159 dep_data
160 .external_sources_by_base_purl
161 .insert(base_purl, processed);
162 }
163 }
164 }
165
166 if let Some(ext_sources) = data.get("EXTERNAL SOURCES").and_then(|v| v.as_mapping()) {
167 for (name_key, source) in ext_sources {
168 if let (Some(name), Some(mapping)) = (name_key.as_str(), source.as_mapping()) {
169 let base_purl = make_base_purl(name);
170 if dep_data
171 .external_sources_by_base_purl
172 .contains_key(&base_purl)
173 {
174 continue;
175 }
176 let processed = process_external_source(mapping);
177 dep_data
178 .external_sources_by_base_purl
179 .insert(base_purl, processed);
180 }
181 }
182 }
183
184 dep_data
185 }
186}
187
188fn parse_podfile_lock(data: &Value) -> PackageData {
189 let dep_data = DependencyDataByPurl::collect(data);
190 let mut dependencies = Vec::new();
191
192 if let Some(pods) = data.get("PODS").and_then(|v| v.as_sequence()) {
193 for pod in pods {
194 match pod {
195 Value::Mapping(m) => {
196 for (main_pod_key, dep_pods_val) in m {
197 if let Some(main_pod_str) = main_pod_key.as_str() {
198 let dep_pods: Vec<&str> = dep_pods_val
199 .as_sequence()
200 .map(|seq| seq.iter().filter_map(|v| v.as_str()).collect())
201 .unwrap_or_default();
202
203 let nested_deps = build_dependencies_for_resolved(&dep_data, &dep_pods);
204 let dep = build_pod_dependency(&dep_data, main_pod_str, nested_deps);
205 dependencies.push(dep);
206 }
207 }
208 }
209 Value::String(s) => {
210 let dep = build_pod_dependency(&dep_data, s, Vec::new());
211 dependencies.push(dep);
212 }
213 _ => {}
214 }
215 }
216 }
217
218 let cocoapods_version = data
219 .get("COCOAPODS")
220 .and_then(|v| v.as_str())
221 .map(|s| s.to_string());
222 let podfile_checksum = data
223 .get("PODFILE CHECKSUM")
224 .and_then(|v| v.as_str())
225 .map(|s| s.to_string());
226
227 let mut extra_data = HashMap::new();
228 if let Some(v) = cocoapods_version {
229 extra_data.insert("cocoapods".to_string(), serde_json::Value::String(v));
230 }
231 if let Some(v) = podfile_checksum {
232 extra_data.insert("podfile_checksum".to_string(), serde_json::Value::String(v));
233 }
234
235 let mut pkg = default_package_data();
236 pkg.dependencies = dependencies;
237 pkg.extra_data = if extra_data.is_empty() {
238 None
239 } else {
240 Some(extra_data)
241 };
242 pkg
243}
244
245fn build_pod_dependency(
246 dep_data: &DependencyDataByPurl,
247 main_pod: &str,
248 nested_deps: Vec<Dependency>,
249) -> Dependency {
250 let (namespace, name, version, requirement) = parse_dep_requirements(main_pod);
251 let base_purl = make_base_purl_from_parts(namespace.as_deref(), &name);
252
253 let is_direct = dep_data.direct_dependency_purls.contains(&base_purl);
254
255 let checksum = dep_data.checksum_by_base_purl.get(&base_purl).cloned();
256 let spec_repo = dep_data.spec_by_base_purl.get(&base_purl).cloned();
257 let external_source = dep_data
258 .external_sources_by_base_purl
259 .get(&base_purl)
260 .cloned();
261
262 let mut resolved_extra_data: HashMap<String, serde_json::Value> = HashMap::new();
263 if let Some(repo) = spec_repo {
264 resolved_extra_data.insert("spec_repo".to_string(), serde_json::Value::String(repo));
265 }
266 if let Some(source) = external_source {
267 resolved_extra_data.insert(
268 "external_source".to_string(),
269 serde_json::Value::String(source),
270 );
271 }
272
273 let resolved_package = ResolvedPackage {
274 package_type: PodfileLockParser::PACKAGE_TYPE,
275 namespace: namespace.clone().unwrap_or_default(),
276 name: name.clone(),
277 version: version.clone().unwrap_or_default(),
278 primary_language: Some(PRIMARY_LANGUAGE.to_string()),
279 download_url: None,
280 sha1: checksum,
281 sha256: None,
282 sha512: None,
283 md5: None,
284 is_virtual: true,
285 extra_data: if resolved_extra_data.is_empty() {
286 None
287 } else {
288 Some(resolved_extra_data)
289 },
290 dependencies: nested_deps,
291 repository_homepage_url: None,
292 repository_download_url: None,
293 api_data_url: None,
294 datasource_id: Some(DatasourceId::CocoapodsPodfileLock),
295 purl: None,
296 };
297
298 let purl = create_cocoapods_purl(namespace.as_deref(), &name, version.as_deref());
299
300 Dependency {
301 purl,
302 extracted_requirement: requirement,
303 scope: Some("dependencies".to_string()),
304 is_runtime: None,
305 is_optional: None,
306 is_pinned: Some(true),
307 is_direct: Some(is_direct),
308 resolved_package: Some(Box::new(resolved_package)),
309 extra_data: None,
310 }
311}
312
313fn build_dependencies_for_resolved(
314 dep_data: &DependencyDataByPurl,
315 dep_pods: &[&str],
316) -> Vec<Dependency> {
317 dep_pods
318 .iter()
319 .map(|dep_pod| {
320 let (namespace, name, version, requirement) = parse_dep_requirements(dep_pod);
321 let base_purl = make_base_purl_from_parts(namespace.as_deref(), &name);
322
323 let resolved_version = dep_data.versions_by_base_purl.get(&base_purl);
324
325 let final_version = resolved_version.cloned().or(version);
326 let final_requirement = requirement.or_else(|| resolved_version.cloned());
327
328 let purl = create_cocoapods_purl(namespace.as_deref(), &name, final_version.as_deref());
329
330 Dependency {
331 purl,
332 extracted_requirement: final_requirement,
333 scope: Some("dependencies".to_string()),
334 is_runtime: None,
335 is_optional: None,
336 is_pinned: Some(true),
337 is_direct: Some(true),
338 resolved_package: None,
339 extra_data: None,
340 }
341 })
342 .collect()
343}
344
345pub(crate) fn parse_dep_requirements(
346 dep: &str,
347) -> (Option<String>, String, Option<String>, Option<String>) {
348 let dep = dep.trim();
349 let (name_part, version, requirement) = if let Some(paren_idx) = dep.find('(') {
350 let name_part = dep[..paren_idx].trim();
351 let version_part = dep[paren_idx..].trim_matches(|c| c == '(' || c == ')' || c == ' ');
352 let requirement = version_part.to_string();
353 let version = version_part.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.');
354 let version = version.trim();
355 (
356 name_part.to_string(),
357 if version.is_empty() {
358 None
359 } else {
360 Some(version.to_string())
361 },
362 Some(requirement),
363 )
364 } else {
365 (dep.trim_end_matches(')').to_string(), None, None)
366 };
367
368 let (namespace, name) = if name_part.contains('/') {
369 let (ns, n) = name_part.split_once('/').unwrap_or(("", &name_part));
370 (Some(ns.trim().to_string()), n.trim().to_string())
371 } else {
372 (None, name_part.trim().to_string())
373 };
374
375 (namespace, name, version, requirement)
376}
377
378fn parse_dep_to_base_purl_and_version(dep: &str) -> (String, Option<String>) {
379 let (namespace, name, _version, requirement) = parse_dep_requirements(dep);
380 let base_purl = make_base_purl_from_parts(namespace.as_deref(), &name);
381 (base_purl, requirement)
382}
383
384fn make_base_purl(name: &str) -> String {
385 format!("pkg:cocoapods/{}", name)
386}
387
388fn make_base_purl_from_parts(namespace: Option<&str>, name: &str) -> String {
389 match namespace {
390 Some(ns) if !ns.is_empty() => format!("pkg:cocoapods/{}/{}", ns, name),
391 _ => make_base_purl(name),
392 }
393}
394
395fn create_cocoapods_purl(
396 namespace: Option<&str>,
397 name: &str,
398 version: Option<&str>,
399) -> Option<String> {
400 let ns_part = match namespace {
401 Some(ns) if !ns.is_empty() => format!("{}/", ns),
402 _ => String::new(),
403 };
404 let version_part = match version {
405 Some(v) if !v.is_empty() => format!("@{}", v),
406 _ => String::new(),
407 };
408 Some(format!("pkg:cocoapods/{}{}{}", ns_part, name, version_part))
409}
410
411fn process_external_source(mapping: &serde_yaml::Mapping) -> String {
412 let get_str = |key: &str| -> Option<String> {
413 mapping
414 .get(Value::String(key.to_string()))
415 .and_then(|v| v.as_str())
416 .map(|s| s.to_string())
417 };
418
419 if mapping.len() == 1 {
420 return mapping
421 .values()
422 .next()
423 .and_then(|v| v.as_str())
424 .unwrap_or("")
425 .to_string();
426 }
427
428 if mapping.len() == 2
429 && let Some(git_url) = get_str(":git")
430 {
431 let repo_url = git_url
432 .replace(".git", "")
433 .replace("git@", "https://")
434 .trim_end_matches('/')
435 .to_string();
436
437 if let Some(commit) = get_str(":commit") {
438 return format!("{}/tree/{}", repo_url, commit);
439 }
440 if let Some(branch) = get_str(":branch") {
441 return format!("{}/tree/{}", repo_url, branch);
442 }
443 }
444
445 format!("{:?}", mapping)
446}
447
448fn default_package_data() -> PackageData {
449 PackageData {
450 package_type: Some(PodfileLockParser::PACKAGE_TYPE),
451 primary_language: Some(PRIMARY_LANGUAGE.to_string()),
452 datasource_id: Some(DatasourceId::CocoapodsPodfileLock),
453 ..Default::default()
454 }
455}
456
457crate::register_parser!(
458 "Cocoapods Podfile.lock",
459 &["**/Podfile.lock"],
460 "cocoapods",
461 "Objective-C",
462 Some("https://guides.cocoapods.org/using/the-podfile.html"),
463);