Skip to main content

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