1use 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
54pub 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 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 if let Some(source) = json_content.get(FIELD_SOURCE) {
137 extra_data.insert("source".to_string(), source.clone());
138 }
139
140 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 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
290fn 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
300fn 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 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
363fn 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 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 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 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
397fn 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 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 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
451fn 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
488fn 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
501fn get_hashed_path(name: &str) -> Option<String> {
505 use md5::{Digest, Md5};
506
507 if name.is_empty() {
508 return None;
509 }
510
511 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}