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