Skip to main content

provenant/parsers/
podspec_json.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 .podspec.json manifests.
7//!
8//! Extracts package metadata and dependencies from .podspec.json files used by
9//! CocoaPods for iOS/macOS package management.
10//!
11//! # Supported Formats
12//! - *.podspec.json (CocoaPods manifest JSON format)
13//!
14//! # Key Features
15//! - Dependency extraction from dependencies dictionary
16//! - License handling (both string and dict formats with "type" and "text" keys)
17//! - VCS and download URL extraction from source field
18//! - Author/party information parsing
19//! - Full JSON storage in extra_data
20//!
21//! # Implementation Notes
22//! - Uses serde_json for JSON parsing
23//! - Handles license as both string and dict (joins dict values)
24//! - Extracts dependencies from dict (key=name, value=version requirement)
25//! - All dependencies have scope="dependencies" and is_runtime=true
26//! - Source dict stored in extra_data["source"]
27
28use std::collections::HashMap;
29use std::path::Path;
30
31use crate::parser_warn as warn;
32use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
33use packageurl::PackageUrl;
34use serde_json::Value;
35
36use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party, PartyType};
37
38use super::PackageParser;
39use super::license_normalization::normalize_spdx_declared_license;
40use super::metadata::ParserMetadata;
41
42const FIELD_NAME: &str = "name";
43const FIELD_VERSION: &str = "version";
44const FIELD_SUMMARY: &str = "summary";
45const FIELD_DESCRIPTION: &str = "description";
46const FIELD_HOMEPAGE: &str = "homepage";
47const FIELD_LICENSE: &str = "license";
48const FIELD_SOURCE: &str = "source";
49const FIELD_AUTHORS: &str = "authors";
50const FIELD_DEPENDENCIES: &str = "dependencies";
51
52const PRIMARY_LANGUAGE: &str = "Objective-C";
53
54/// CocoaPods .podspec.json parser.
55///
56/// Parses .podspec.json manifest files from CocoaPods ecosystem.
57pub struct PodspecJsonParser;
58
59impl PackageParser for PodspecJsonParser {
60    const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
61
62    fn metadata() -> Vec<ParserMetadata> {
63        vec![ParserMetadata {
64            description: "CocoaPods .podspec.json manifest",
65            file_patterns: &["**/*.podspec.json"],
66            package_type: "cocoapods",
67            primary_language: "Objective-C",
68            documentation_url: Some("https://guides.cocoapods.org/syntax/podspec.html"),
69        }]
70    }
71
72    fn extract_packages(path: &Path) -> Vec<PackageData> {
73        let json_content = match read_json_file(path) {
74            Ok(content) => content,
75            Err(e) => {
76                warn!("Failed to read .podspec.json at {:?}: {}", path, e);
77                return vec![default_package_data()];
78            }
79        };
80
81        let name = json_content
82            .get(FIELD_NAME)
83            .and_then(|v| v.as_str())
84            .map(|s| truncate_field(s.trim().to_string()))
85            .filter(|s| !s.is_empty());
86
87        let version = json_content
88            .get(FIELD_VERSION)
89            .and_then(|v| v.as_str())
90            .map(|s| truncate_field(s.trim().to_string()))
91            .filter(|s| !s.is_empty());
92
93        let summary = json_content
94            .get(FIELD_SUMMARY)
95            .and_then(|v| v.as_str())
96            .map(|s| truncate_field(s.trim().to_string()))
97            .filter(|s| !s.is_empty());
98
99        let mut description = json_content
100            .get(FIELD_DESCRIPTION)
101            .and_then(|v| v.as_str())
102            .map(|s| truncate_field(s.trim().to_string()))
103            .filter(|s| !s.is_empty());
104
105        // If summary exists and description doesn't start with summary, prepend it
106        if let (Some(summary_text), Some(desc_text)) = (&summary, &description) {
107            if !desc_text.starts_with(summary_text) {
108                description = Some(format!("{}. {}", summary_text, desc_text));
109            }
110        } else if summary.is_some() && description.is_none() {
111            description = summary.clone();
112        }
113
114        let homepage_url = json_content
115            .get(FIELD_HOMEPAGE)
116            .and_then(|v| v.as_str())
117            .map(|s| truncate_field(s.trim().to_string()))
118            .filter(|s| !s.is_empty());
119
120        let extracted_license_statement = extract_license_statement(&json_content);
121        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
122            normalize_podspec_json_declared_license(
123                &json_content,
124                extracted_license_statement.as_deref(),
125            );
126
127        let (vcs_url, download_url) = extract_source_urls(&json_content);
128
129        let parties = extract_parties(&json_content);
130
131        let dependencies = extract_dependencies(&json_content);
132
133        let mut extra_data = HashMap::new();
134
135        // Store source dict in extra_data
136        if let Some(source) = json_content.get(FIELD_SOURCE) {
137            extra_data.insert("source".to_string(), source.clone());
138        }
139
140        // Store dependencies dict in extra_data if present
141        if let Some(deps) = json_content.get(FIELD_DEPENDENCIES)
142            && let Some(obj) = deps.as_object()
143            && !obj.is_empty()
144        {
145            extra_data.insert(FIELD_DEPENDENCIES.to_string(), deps.clone());
146        }
147
148        if let Some(license_file) = json_content
149            .get(FIELD_LICENSE)
150            .and_then(|license| license.as_object())
151            .and_then(|license| license.get("file"))
152            .and_then(|value| value.as_str())
153            .filter(|value| !value.trim().is_empty())
154        {
155            extra_data.insert(
156                "license_file".to_string(),
157                Value::String(license_file.trim().to_string()),
158            );
159        }
160
161        let raw_json = serde_json::to_string(&json_content).unwrap_or_default();
162        if raw_json.len() <= 10 * 1024 * 1024 {
163            extra_data.insert("podspec.json".to_string(), json_content.clone());
164        } else {
165            warn!(
166                "Skipping podspec.json extra_data entry: serialized size {} bytes exceeds 10MB limit",
167                raw_json.len()
168            );
169        }
170
171        let extra_data = if extra_data.is_empty() {
172            None
173        } else {
174            Some(extra_data)
175        };
176
177        // Generate URLs using CocoaPods patterns
178        let repository_homepage_url = name
179            .as_ref()
180            .map(|n| format!("https://cocoapods.org/pods/{}", n));
181        let repository_download_url =
182            if let (Some(_name_str), Some(version_str)) = (&name, &version) {
183                if let Some(homepage) = &homepage_url {
184                    Some(format!("{}/archive/{}.zip", homepage, version_str))
185                } else if let Some(vcs) = &vcs_url {
186                    let repo_base = get_repo_base_url(vcs);
187                    repo_base.map(|base| format!("{}/archive/refs/tags/{}.zip", base, version_str))
188                } else {
189                    None
190                }
191            } else {
192                None
193            };
194
195        let code_view_url = if let (Some(vcs), Some(version_str)) = (&vcs_url, &version) {
196            let repo_base = get_repo_base_url(vcs);
197            repo_base.map(|base| format!("{}/tree/{}", base, version_str))
198        } else {
199            None
200        };
201
202        let bug_tracking_url = vcs_url.as_ref().and_then(|vcs| {
203            let repo_base = get_repo_base_url(vcs);
204            repo_base.map(|base| format!("{}/issues/", base))
205        });
206
207        let api_data_url = if let (Some(name_str), Some(version_str)) = (&name, &version) {
208            get_hashed_path(name_str).map(|hashed| {
209                format!(
210                    "https://raw.githubusercontent.com/CocoaPods/Specs/blob/master/Specs/{}/{}/{}/{}.podspec.json",
211                    hashed, name_str, version_str, name_str
212                )
213            })
214        } else {
215            None
216        };
217
218        let purl = if let Some(name_str) = &name {
219            let purl = PackageUrl::new(Self::PACKAGE_TYPE.as_str(), name_str)
220                .or_else(|_| PackageUrl::new("generic", name_str))
221                .ok();
222            purl.map(|mut p| {
223                if let Some(version_str) = &version {
224                    let _ = p.with_version(version_str);
225                }
226                p.to_string()
227            })
228        } else {
229            None
230        };
231
232        vec![PackageData {
233            package_type: Some(Self::PACKAGE_TYPE),
234            namespace: None,
235            name: name.clone(),
236            version: version.clone(),
237            qualifiers: None,
238            subpath: None,
239            primary_language: Some(PRIMARY_LANGUAGE.to_string()),
240            description,
241            release_date: None,
242            parties,
243            keywords: Vec::new(),
244            homepage_url,
245            download_url,
246            size: None,
247            sha1: None,
248            md5: None,
249            sha256: None,
250            sha512: None,
251            bug_tracking_url,
252            code_view_url,
253            vcs_url,
254            copyright: None,
255            holder: None,
256            declared_license_expression,
257            declared_license_expression_spdx,
258            license_detections,
259            other_license_expression: None,
260            other_license_expression_spdx: None,
261            other_license_detections: Vec::new(),
262            extracted_license_statement,
263            notice_text: None,
264            source_packages: Vec::new(),
265            file_references: Vec::new(),
266            is_private: false,
267            is_virtual: false,
268            extra_data,
269            dependencies,
270            repository_homepage_url,
271            repository_download_url,
272            api_data_url,
273            datasource_id: Some(DatasourceId::CocoapodsPodspecJson),
274            purl,
275        }]
276    }
277
278    fn is_match(path: &Path) -> bool {
279        path.file_name()
280            .and_then(|name| name.to_str())
281            .is_some_and(|name| name.ends_with(".podspec.json"))
282    }
283}
284
285fn read_json_file(path: &Path) -> Result<Value, String> {
286    let contents = read_file_to_string(path, None).map_err(|e| e.to_string())?;
287    serde_json::from_str(&contents).map_err(|e| format!("Failed to parse JSON: {}", e))
288}
289
290/// Returns a default empty PackageData.
291fn default_package_data() -> PackageData {
292    PackageData {
293        package_type: Some(PodspecJsonParser::PACKAGE_TYPE),
294        primary_language: Some(PRIMARY_LANGUAGE.to_string()),
295        datasource_id: Some(DatasourceId::CocoapodsPodspecJson),
296        ..Default::default()
297    }
298}
299
300/// Extracts license statement from JSON.
301/// Handles both string and dict formats.
302fn extract_license_statement(json: &Value) -> Option<String> {
303    json.get(FIELD_LICENSE).and_then(|lic| {
304        if let Some(lic_str) = lic.as_str() {
305            Some(truncate_field(lic_str.trim().to_string()))
306        } else if let Some(lic_obj) = lic.as_object() {
307            // If license is a dict, join all values with space
308            let values: Vec<String> = lic_obj
309                .values()
310                .filter_map(|v| v.as_str())
311                .map(|s| s.trim().to_string())
312                .filter(|s| !s.is_empty())
313                .collect();
314            if values.is_empty() {
315                None
316            } else {
317                Some(values.join(" "))
318            }
319        } else {
320            None
321        }
322    })
323}
324
325fn normalize_podspec_json_declared_license(
326    json: &Value,
327    extracted_license_statement: Option<&str>,
328) -> (
329    Option<String>,
330    Option<String>,
331    Vec<crate::models::LicenseDetection>,
332) {
333    let normalized_candidate = json
334        .get(FIELD_LICENSE)
335        .and_then(|license| {
336            license
337                .as_str()
338                .map(str::trim)
339                .filter(|value| !value.is_empty())
340                .map(canonicalize_cocoapods_license_type)
341                .or_else(|| {
342                    license
343                        .as_object()
344                        .and_then(|obj| obj.get("type"))
345                        .and_then(|value| value.as_str())
346                        .map(str::trim)
347                        .filter(|value| !value.is_empty())
348                        .map(canonicalize_cocoapods_license_type)
349                })
350        })
351        .or_else(|| extracted_license_statement.map(canonicalize_cocoapods_license_type));
352
353    normalize_spdx_declared_license(normalized_candidate.as_deref())
354}
355
356fn canonicalize_cocoapods_license_type(value: &str) -> String {
357    match value.trim() {
358        "Apache License, Version 2.0" => "Apache-2.0".to_string(),
359        other => other.to_string(),
360    }
361}
362
363/// Extracts VCS URL and download URL from source field.
364fn extract_source_urls(json: &Value) -> (Option<String>, Option<String>) {
365    let mut vcs_url = None;
366    let mut download_url = None;
367
368    if let Some(source) = json.get(FIELD_SOURCE) {
369        if let Some(source_obj) = source.as_object() {
370            // Git URL takes precedence for vcs_url
371            if let Some(git_url) = source_obj.get("git").and_then(|v| v.as_str()) {
372                let git_str = truncate_field(git_url.trim().to_string());
373                if !git_str.is_empty() {
374                    vcs_url = Some(git_str);
375                }
376            }
377
378            // HTTP URL is download_url
379            if let Some(http_url) = source_obj.get("http").and_then(|v| v.as_str()) {
380                let http_str = truncate_field(http_url.trim().to_string());
381                if !http_str.is_empty() {
382                    download_url = Some(http_str);
383                }
384            }
385        } else if let Some(source_str) = source.as_str() {
386            // If source is a string, use as vcs_url
387            let source_trimmed = truncate_field(source_str.trim().to_string());
388            if !source_trimmed.is_empty() {
389                vcs_url = Some(source_trimmed);
390            }
391        }
392    }
393
394    (vcs_url, download_url)
395}
396
397/// Extracts party information from authors field.
398fn extract_parties(json: &Value) -> Vec<Party> {
399    let mut parties = Vec::new();
400
401    if let Some(authors) = json.get(FIELD_AUTHORS) {
402        if let Some(authors_obj) = authors.as_object() {
403            // Authors as dict: key=name, value=url
404            for (name, value) in authors_obj.iter().take(MAX_ITERATION_COUNT) {
405                let name_str = truncate_field(name.trim().to_string());
406                if !name_str.is_empty() {
407                    let url = value.as_str().and_then(|s| {
408                        let trimmed = s.trim();
409                        if trimmed.is_empty() {
410                            None
411                        } else if trimmed.contains("://") || trimmed.contains('.') {
412                            Some(truncate_field(trimmed.to_string()))
413                        } else {
414                            Some(truncate_field(format!("{}.com", trimmed)))
415                        }
416                    });
417
418                    parties.push(Party {
419                        r#type: Some(PartyType::Organization),
420                        role: Some("owner".to_string()),
421                        name: Some(name_str),
422                        email: None,
423                        url,
424                        organization: None,
425                        organization_url: None,
426                        timezone: None,
427                    });
428                }
429            }
430        } else if let Some(authors_str) = authors.as_str() {
431            // Authors as string
432            let authors_trimmed = truncate_field(authors_str.trim().to_string());
433            if !authors_trimmed.is_empty() {
434                parties.push(Party {
435                    r#type: Some(PartyType::Organization),
436                    role: Some("owner".to_string()),
437                    name: Some(authors_trimmed),
438                    email: None,
439                    url: None,
440                    organization: None,
441                    organization_url: None,
442                    timezone: None,
443                });
444            }
445        }
446    }
447
448    parties
449}
450
451/// Extracts dependencies from dependencies dict.
452fn extract_dependencies(json: &Value) -> Vec<Dependency> {
453    let mut dependencies = Vec::new();
454
455    if let Some(deps) = json.get(FIELD_DEPENDENCIES)
456        && let Some(deps_obj) = deps.as_object()
457    {
458        for (name, requirement) in deps_obj.iter().take(MAX_ITERATION_COUNT) {
459            let name_str = name.trim();
460            if name_str.is_empty() {
461                continue;
462            }
463
464            let requirement_str = requirement
465                .as_str()
466                .map(|s| truncate_field(s.trim().to_string()))
467                .filter(|s| !s.is_empty());
468
469            let purl = Some(truncate_field(format!("pkg:cocoapods/{}", name_str)));
470
471            dependencies.push(Dependency {
472                purl,
473                extracted_requirement: requirement_str,
474                scope: Some("runtime".to_string()),
475                is_runtime: Some(true),
476                is_optional: Some(false),
477                is_pinned: None,
478                is_direct: None,
479                resolved_package: None,
480                extra_data: None,
481            });
482        }
483    }
484
485    dependencies
486}
487
488/// Gets the repository base URL from a VCS URL by removing .git suffix.
489fn get_repo_base_url(vcs_url: &str) -> Option<String> {
490    if vcs_url.is_empty() {
491        return None;
492    }
493
494    if vcs_url.ends_with(".git") {
495        Some(vcs_url.trim_end_matches(".git").to_string())
496    } else {
497        Some(vcs_url.to_string())
498    }
499}
500
501/// Computes the hashed path prefix for CocoaPods Specs repository.
502///
503/// Uses MD5 hash of package name to generate the path prefix (first 3 chars).
504fn get_hashed_path(name: &str) -> Option<String> {
505    use md5::{Digest, Md5};
506
507    if name.is_empty() {
508        return None;
509    }
510
511    // Compute MD5 hash
512    let mut hasher = Md5::new();
513    hasher.update(name.as_bytes());
514    let result = hasher.finalize();
515    let hash_str = hex::encode(result);
516
517    if hash_str.len() >= 3 {
518        Some(format!(
519            "{}/{}/{}",
520            &hash_str[0..1],
521            &hash_str[1..2],
522            &hash_str[2..3]
523        ))
524    } else {
525        Some(hash_str)
526    }
527}