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