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