Skip to main content

provenant/parsers/
podfile_lock.rs

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