Skip to main content

provenant/parsers/
podfile_lock.rs

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