Skip to main content

provenant/parsers/
podfile_lock.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for CocoaPods Podfile.lock lockfiles.
5//!
6//! Extracts resolved dependency information from Podfile.lock files which maintain
7//! the exact versions of all dependencies used by a CocoaPods project.
8//!
9//! # Supported Formats
10//! - Podfile.lock (YAML-based lockfile with multiple sections)
11//!
12//! # Key Features
13//! - Direct vs transitive dependency tracking
14//! - Exact version resolution from lockfile
15//! - Pod source and repository information
16//! - Spec repository tracking
17//! - YAML multi-section aggregation (PODS, DEPENDENCIES, SPEC REPOS, PODFILE LOCK)
18//!
19//! # Implementation Notes
20//! - Uses YAML parsing via `yaml_serde`
21//! - All lockfile versions are pinned (`is_pinned: Some(true)`)
22//! - Data aggregation across PODS, DEPENDENCIES, and metadata sections
23//! - Graceful error handling with `warn!()` logs
24
25use 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
40/// Parses CocoaPods lockfiles (Podfile.lock).
41///
42/// Extracts pinned dependency versions from Podfile.lock using data aggregation
43/// across multiple YAML sections.
44///
45/// # Data Aggregation
46/// Correlates information from 5 sections:
47/// - **PODS**: Dependency tree with versions
48/// - **DEPENDENCIES**: Direct dependencies
49/// - **SPEC REPOS**: Source repositories
50/// - **CHECKSUMS**: SHA1 hashes
51/// - **EXTERNAL SOURCES**: Git/path sources
52///
53/// Uses `PodfileLockDataByPurl` pattern to aggregate data by package URL.
54pub 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);