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