1use 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
55pub 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 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 if let Some(source) = json_content.get(FIELD_SOURCE) {
138 extra_data.insert("source".to_string(), source.clone());
139 }
140
141 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 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
281fn 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
291fn 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 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
354fn 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 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 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 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
388fn 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 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 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
442fn 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
479fn 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
492fn get_hashed_path(name: &str) -> Option<String> {
496 use md5::{Digest, Md5};
497
498 if name.is_empty() {
499 return None;
500 }
501
502 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}