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| 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);