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