Skip to main content

provenant/parsers/
podfile_lock.rs

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