1use std::collections::HashMap;
23use std::fs;
24use std::path::Path;
25
26use crate::parser_warn as warn;
27use yaml_serde::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 yaml_serde::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 primary_language: Some(PRIMARY_LANGUAGE.to_string()),
275 download_url: None,
276 sha1: checksum,
277 sha256: None,
278 sha512: None,
279 md5: None,
280 is_virtual: true,
281 extra_data: if resolved_extra_data.is_empty() {
282 None
283 } else {
284 Some(resolved_extra_data)
285 },
286 dependencies: nested_deps,
287 repository_homepage_url: None,
288 repository_download_url: None,
289 api_data_url: None,
290 datasource_id: Some(DatasourceId::CocoapodsPodfileLock),
291 purl: None,
292 ..ResolvedPackage::new(
293 PodfileLockParser::PACKAGE_TYPE,
294 namespace.clone().unwrap_or_default(),
295 name.clone(),
296 version.clone().unwrap_or_default(),
297 )
298 };
299
300 let purl = create_cocoapods_purl(namespace.as_deref(), &name, version.as_deref());
301
302 Dependency {
303 purl,
304 extracted_requirement: requirement,
305 scope: Some("dependencies".to_string()),
306 is_runtime: None,
307 is_optional: None,
308 is_pinned: Some(true),
309 is_direct: Some(is_direct),
310 resolved_package: Some(Box::new(resolved_package)),
311 extra_data: None,
312 }
313}
314
315fn build_dependencies_for_resolved(
316 dep_data: &DependencyDataByPurl,
317 dep_pods: &[&str],
318) -> Vec<Dependency> {
319 dep_pods
320 .iter()
321 .map(|dep_pod| {
322 let (namespace, name, version, requirement) = parse_dep_requirements(dep_pod);
323 let base_purl = make_base_purl_from_parts(namespace.as_deref(), &name);
324
325 let resolved_version = dep_data.versions_by_base_purl.get(&base_purl);
326
327 let final_version = resolved_version.cloned().or(version);
328 let final_requirement = requirement.or_else(|| resolved_version.cloned());
329
330 let purl = create_cocoapods_purl(namespace.as_deref(), &name, final_version.as_deref());
331
332 Dependency {
333 purl,
334 extracted_requirement: final_requirement,
335 scope: Some("dependencies".to_string()),
336 is_runtime: None,
337 is_optional: None,
338 is_pinned: Some(true),
339 is_direct: Some(true),
340 resolved_package: None,
341 extra_data: None,
342 }
343 })
344 .collect()
345}
346
347pub(crate) fn parse_dep_requirements(
348 dep: &str,
349) -> (Option<String>, String, Option<String>, Option<String>) {
350 let dep = dep.trim();
351 let (name_part, version, requirement) = if let Some(paren_idx) = dep.find('(') {
352 let name_part = dep[..paren_idx].trim();
353 let version_part = dep[paren_idx..].trim_matches(|c| c == '(' || c == ')' || c == ' ');
354 let requirement = version_part.to_string();
355 let version = version_part.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.');
356 let version = version.trim();
357 (
358 name_part.to_string(),
359 if version.is_empty() {
360 None
361 } else {
362 Some(version.to_string())
363 },
364 Some(requirement),
365 )
366 } else {
367 (dep.trim_end_matches(')').to_string(), None, None)
368 };
369
370 let (namespace, name) = if name_part.contains('/') {
371 let (ns, n) = name_part.split_once('/').unwrap_or(("", &name_part));
372 (Some(ns.trim().to_string()), n.trim().to_string())
373 } else {
374 (None, name_part.trim().to_string())
375 };
376
377 (namespace, name, version, requirement)
378}
379
380fn parse_dep_to_base_purl_and_version(dep: &str) -> (String, Option<String>) {
381 let (namespace, name, _version, requirement) = parse_dep_requirements(dep);
382 let base_purl = make_base_purl_from_parts(namespace.as_deref(), &name);
383 (base_purl, requirement)
384}
385
386fn make_base_purl(name: &str) -> String {
387 format!("pkg:cocoapods/{}", name)
388}
389
390fn make_base_purl_from_parts(namespace: Option<&str>, name: &str) -> String {
391 match namespace {
392 Some(ns) if !ns.is_empty() => format!("pkg:cocoapods/{}/{}", ns, name),
393 _ => make_base_purl(name),
394 }
395}
396
397fn create_cocoapods_purl(
398 namespace: Option<&str>,
399 name: &str,
400 version: Option<&str>,
401) -> Option<String> {
402 let ns_part = match namespace {
403 Some(ns) if !ns.is_empty() => format!("{}/", ns),
404 _ => String::new(),
405 };
406 let version_part = match version {
407 Some(v) if !v.is_empty() => format!("@{}", v),
408 _ => String::new(),
409 };
410 Some(format!("pkg:cocoapods/{}{}{}", ns_part, name, version_part))
411}
412
413fn process_external_source(mapping: &yaml_serde::Mapping) -> String {
414 let get_str = |key: &str| -> Option<String> {
415 mapping
416 .get(Value::String(key.to_string()))
417 .and_then(|v| v.as_str())
418 .map(|s| s.to_string())
419 };
420
421 if mapping.len() == 1 {
422 return mapping
423 .values()
424 .next()
425 .and_then(|v| v.as_str())
426 .unwrap_or("")
427 .to_string();
428 }
429
430 if mapping.len() == 2
431 && let Some(git_url) = get_str(":git")
432 {
433 let repo_url = git_url
434 .replace(".git", "")
435 .replace("git@", "https://")
436 .trim_end_matches('/')
437 .to_string();
438
439 if let Some(commit) = get_str(":commit") {
440 return format!("{}/tree/{}", repo_url, commit);
441 }
442 if let Some(branch) = get_str(":branch") {
443 return format!("{}/tree/{}", repo_url, branch);
444 }
445 }
446
447 format!("{:?}", mapping)
448}
449
450fn default_package_data() -> PackageData {
451 PackageData {
452 package_type: Some(PodfileLockParser::PACKAGE_TYPE),
453 primary_language: Some(PRIMARY_LANGUAGE.to_string()),
454 datasource_id: Some(DatasourceId::CocoapodsPodfileLock),
455 ..Default::default()
456 }
457}
458
459crate::register_parser!(
460 "Cocoapods Podfile.lock",
461 &["**/Podfile.lock"],
462 "cocoapods",
463 "Objective-C",
464 Some("https://guides.cocoapods.org/using/the-podfile.html"),
465);