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