1use std::collections::HashMap;
23use std::path::Path;
24
25use crate::parser_warn as warn;
26use yaml_serde::Value;
27
28use crate::models::{
29 DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha1Digest,
30};
31use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
32
33use super::PackageParser;
34
35const PRIMARY_LANGUAGE: &str = "Objective-C";
36
37pub struct PodfileLockParser;
52
53impl PackageParser for PodfileLockParser {
54 const PACKAGE_TYPE: PackageType = PackageType::Cocoapods;
55
56 fn is_match(path: &Path) -> bool {
57 path.file_name()
58 .and_then(|name| name.to_str())
59 .is_some_and(|name| name == "Podfile.lock")
60 }
61
62 fn extract_packages(path: &Path) -> Vec<PackageData> {
63 let content = match read_file_to_string(path, None) {
64 Ok(c) => c,
65 Err(e) => {
66 warn!("Failed to read Podfile.lock at {:?}: {}", path, e);
67 return vec![default_package_data()];
68 }
69 };
70
71 let data: Value = match yaml_serde::from_str(&content) {
72 Ok(d) => d,
73 Err(e) => {
74 warn!("Failed to parse Podfile.lock at {:?}: {}", path, e);
75 return vec![default_package_data()];
76 }
77 };
78
79 vec![parse_podfile_lock(&data)]
80 }
81}
82
83struct DependencyDataByPurl {
84 versions_by_base_purl: HashMap<String, String>,
85 direct_dependency_purls: Vec<String>,
86 spec_by_base_purl: HashMap<String, String>,
87 checksum_by_base_purl: HashMap<String, String>,
88 external_sources_by_base_purl: HashMap<String, String>,
89}
90
91impl DependencyDataByPurl {
92 fn collect(data: &Value) -> Self {
93 let mut dep_data = DependencyDataByPurl {
94 versions_by_base_purl: HashMap::new(),
95 direct_dependency_purls: Vec::new(),
96 spec_by_base_purl: HashMap::new(),
97 checksum_by_base_purl: HashMap::new(),
98 external_sources_by_base_purl: HashMap::new(),
99 };
100
101 if let Some(pods) = data.get("PODS").and_then(|v| v.as_sequence()) {
102 for pod in pods.iter().take(MAX_ITERATION_COUNT) {
103 let main_pod_str = match pod {
104 Value::String(s) => Some(s.as_str()),
105 Value::Mapping(m) => m.keys().next().and_then(|k| k.as_str()),
106 _ => None,
107 };
108 if let Some(main_pod_str) = main_pod_str {
109 let (base_purl, version) = parse_dep_to_base_purl_and_version(main_pod_str);
110 if let Some(version) = version {
111 dep_data.versions_by_base_purl.insert(base_purl, version);
112 }
113 }
114 }
115 }
116
117 if let Some(deps) = data.get("DEPENDENCIES").and_then(|v| v.as_sequence()) {
118 for dep in deps.iter().take(MAX_ITERATION_COUNT) {
119 if let Some(dep_str) = dep.as_str() {
120 let (base_purl, _) = parse_dep_to_base_purl_and_version(dep_str);
121 dep_data.direct_dependency_purls.push(base_purl);
122 }
123 }
124 }
125
126 if let Some(spec_repos) = data.get("SPEC REPOS").and_then(|v| v.as_mapping()) {
127 for (repo_key, packages) in spec_repos.iter().take(MAX_ITERATION_COUNT) {
128 let repo_name = match repo_key.as_str() {
129 Some(s) => truncate_field(s.to_string()),
130 None => continue,
131 };
132 if let Some(packages) = packages.as_sequence() {
133 for package in packages.iter().take(MAX_ITERATION_COUNT) {
134 if let Some(pkg_str) = package.as_str() {
135 let (base_purl, _) = parse_dep_to_base_purl_and_version(pkg_str);
136 dep_data
137 .spec_by_base_purl
138 .insert(base_purl, repo_name.clone());
139 }
140 }
141 }
142 }
143 }
144
145 if let Some(checksums) = data.get("SPEC CHECKSUMS").and_then(|v| v.as_mapping()) {
146 for (name_key, checksum_val) in checksums.iter().take(MAX_ITERATION_COUNT) {
147 if let (Some(name), Some(checksum)) = (name_key.as_str(), checksum_val.as_str()) {
148 let (base_purl, _) = parse_dep_to_base_purl_and_version(name);
149 dep_data
150 .checksum_by_base_purl
151 .insert(base_purl, truncate_field(checksum.to_string()));
152 }
153 }
154 }
155
156 if let Some(checkout_opts) = data.get("CHECKOUT OPTIONS").and_then(|v| v.as_mapping()) {
157 for (name_key, source) in checkout_opts.iter().take(MAX_ITERATION_COUNT) {
158 if let (Some(name), Some(mapping)) = (name_key.as_str(), source.as_mapping()) {
159 let base_purl = make_base_purl(name);
160 let processed = truncate_field(process_external_source(mapping));
161 dep_data
162 .external_sources_by_base_purl
163 .insert(base_purl, processed);
164 }
165 }
166 }
167
168 if let Some(ext_sources) = data.get("EXTERNAL SOURCES").and_then(|v| v.as_mapping()) {
169 for (name_key, source) in ext_sources.iter().take(MAX_ITERATION_COUNT) {
170 if let (Some(name), Some(mapping)) = (name_key.as_str(), source.as_mapping()) {
171 let base_purl = make_base_purl(name);
172 if dep_data
173 .external_sources_by_base_purl
174 .contains_key(&base_purl)
175 {
176 continue;
177 }
178 let processed = truncate_field(process_external_source(mapping));
179 dep_data
180 .external_sources_by_base_purl
181 .insert(base_purl, processed);
182 }
183 }
184 }
185
186 dep_data
187 }
188}
189
190fn parse_podfile_lock(data: &Value) -> PackageData {
191 let dep_data = DependencyDataByPurl::collect(data);
192 let mut dependencies = Vec::new();
193
194 if let Some(pods) = data.get("PODS").and_then(|v| v.as_sequence()) {
195 for pod in pods.iter().take(MAX_ITERATION_COUNT) {
196 match pod {
197 Value::Mapping(m) => {
198 for (main_pod_key, dep_pods_val) in m.iter().take(MAX_ITERATION_COUNT) {
199 if let Some(main_pod_str) = main_pod_key.as_str() {
200 let dep_pods: Vec<&str> = dep_pods_val
201 .as_sequence()
202 .map(|seq| seq.iter().filter_map(|v| v.as_str()).collect())
203 .unwrap_or_default();
204
205 let nested_deps = build_dependencies_for_resolved(&dep_data, &dep_pods);
206 let dep = build_pod_dependency(&dep_data, main_pod_str, nested_deps);
207 dependencies.push(dep);
208 }
209 }
210 }
211 Value::String(s) => {
212 let dep = build_pod_dependency(&dep_data, s, Vec::new());
213 dependencies.push(dep);
214 }
215 _ => {}
216 }
217 }
218 }
219
220 let cocoapods_version = data
221 .get("COCOAPODS")
222 .and_then(|v| v.as_str())
223 .map(|s| truncate_field(s.to_string()));
224 let podfile_checksum = data
225 .get("PODFILE CHECKSUM")
226 .and_then(|v| v.as_str())
227 .map(|s| truncate_field(s.to_string()));
228
229 let mut extra_data = HashMap::new();
230 if let Some(v) = cocoapods_version {
231 extra_data.insert("cocoapods".to_string(), serde_json::Value::String(v));
232 }
233 if let Some(v) = podfile_checksum {
234 extra_data.insert("podfile_checksum".to_string(), serde_json::Value::String(v));
235 }
236
237 let mut pkg = default_package_data();
238 pkg.dependencies = dependencies;
239 pkg.extra_data = if extra_data.is_empty() {
240 None
241 } else {
242 Some(extra_data)
243 };
244 pkg
245}
246
247fn build_pod_dependency(
248 dep_data: &DependencyDataByPurl,
249 main_pod: &str,
250 nested_deps: Vec<Dependency>,
251) -> Dependency {
252 let (namespace, name, version, requirement) = parse_dep_requirements(main_pod);
253 let base_purl = make_base_purl_from_parts(namespace.as_deref(), &name);
254
255 let is_direct = dep_data.direct_dependency_purls.contains(&base_purl);
256
257 let checksum = dep_data.checksum_by_base_purl.get(&base_purl).cloned();
258 let spec_repo = dep_data.spec_by_base_purl.get(&base_purl).cloned();
259 let external_source = dep_data
260 .external_sources_by_base_purl
261 .get(&base_purl)
262 .cloned();
263
264 let mut resolved_extra_data: HashMap<String, serde_json::Value> = HashMap::new();
265 if let Some(repo) = spec_repo {
266 resolved_extra_data.insert("spec_repo".to_string(), serde_json::Value::String(repo));
267 }
268 if let Some(source) = external_source {
269 resolved_extra_data.insert(
270 "external_source".to_string(),
271 serde_json::Value::String(source),
272 );
273 }
274
275 let resolved_package = ResolvedPackage {
276 primary_language: Some(PRIMARY_LANGUAGE.to_string()),
277 download_url: None,
278 sha1: checksum.and_then(|h| Sha1Digest::from_hex(&h).ok()),
279 sha256: None,
280 sha512: None,
281 md5: None,
282 is_virtual: true,
283 extra_data: if resolved_extra_data.is_empty() {
284 None
285 } else {
286 Some(resolved_extra_data)
287 },
288 dependencies: nested_deps,
289 repository_homepage_url: None,
290 repository_download_url: None,
291 api_data_url: None,
292 datasource_id: Some(DatasourceId::CocoapodsPodfileLock),
293 purl: None,
294 ..ResolvedPackage::new(
295 PodfileLockParser::PACKAGE_TYPE,
296 namespace.clone().unwrap_or_default(),
297 name.clone(),
298 version.clone().unwrap_or_default(),
299 )
300 };
301
302 let purl = create_cocoapods_purl(namespace.as_deref(), &name, version.as_deref());
303
304 Dependency {
305 purl,
306 extracted_requirement: requirement,
307 scope: Some("dependencies".to_string()),
308 is_runtime: None,
309 is_optional: None,
310 is_pinned: Some(true),
311 is_direct: Some(is_direct),
312 resolved_package: Some(Box::new(resolved_package)),
313 extra_data: None,
314 }
315}
316
317fn build_dependencies_for_resolved(
318 dep_data: &DependencyDataByPurl,
319 dep_pods: &[&str],
320) -> Vec<Dependency> {
321 dep_pods
322 .iter()
323 .map(|dep_pod| {
324 let (namespace, name, version, requirement) = parse_dep_requirements(dep_pod);
325 let base_purl = make_base_purl_from_parts(namespace.as_deref(), &name);
326
327 let resolved_version = dep_data.versions_by_base_purl.get(&base_purl);
328
329 let final_version = resolved_version.cloned().or(version);
330 let final_requirement = requirement.or_else(|| resolved_version.cloned());
331
332 let purl = create_cocoapods_purl(namespace.as_deref(), &name, final_version.as_deref());
333
334 Dependency {
335 purl,
336 extracted_requirement: final_requirement,
337 scope: Some("dependencies".to_string()),
338 is_runtime: None,
339 is_optional: None,
340 is_pinned: Some(true),
341 is_direct: Some(true),
342 resolved_package: None,
343 extra_data: None,
344 }
345 })
346 .collect()
347}
348
349pub(crate) fn parse_dep_requirements(
350 dep: &str,
351) -> (Option<String>, String, Option<String>, Option<String>) {
352 let dep = dep.trim();
353 let (name_part, version, requirement) = if let Some(paren_idx) = dep.find('(') {
354 let name_part = dep[..paren_idx].trim();
355 let version_part = dep[paren_idx..].trim_matches(|c| c == '(' || c == ')' || c == ' ');
356 let requirement = truncate_field(version_part.to_string());
357 let version = version_part.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.');
358 let version = version.trim();
359 (
360 name_part.to_string(),
361 if version.is_empty() {
362 None
363 } else {
364 Some(truncate_field(version.to_string()))
365 },
366 Some(requirement),
367 )
368 } else {
369 (dep.trim_end_matches(')').to_string(), None, None)
370 };
371
372 let (namespace, name) = if name_part.contains('/') {
373 let (ns, n) = name_part.split_once('/').unwrap_or(("", &name_part));
374 (
375 Some(truncate_field(ns.trim().to_string())),
376 truncate_field(n.trim().to_string()),
377 )
378 } else {
379 (None, truncate_field(name_part.trim().to_string()))
380 };
381
382 (namespace, name, version, requirement)
383}
384
385fn parse_dep_to_base_purl_and_version(dep: &str) -> (String, Option<String>) {
386 let (namespace, name, _version, requirement) = parse_dep_requirements(dep);
387 let base_purl = make_base_purl_from_parts(namespace.as_deref(), &name);
388 (base_purl, requirement)
389}
390
391fn make_base_purl(name: &str) -> String {
392 format!("pkg:cocoapods/{}", name)
393}
394
395fn make_base_purl_from_parts(namespace: Option<&str>, name: &str) -> String {
396 match namespace {
397 Some(ns) if !ns.is_empty() => format!("pkg:cocoapods/{}/{}", ns, name),
398 _ => make_base_purl(name),
399 }
400}
401
402fn create_cocoapods_purl(
403 namespace: Option<&str>,
404 name: &str,
405 version: Option<&str>,
406) -> Option<String> {
407 let ns_part = match namespace {
408 Some(ns) if !ns.is_empty() => format!("{}/", ns),
409 _ => String::new(),
410 };
411 let version_part = match version {
412 Some(v) if !v.is_empty() => format!("@{}", v),
413 _ => String::new(),
414 };
415 Some(format!("pkg:cocoapods/{}{}{}", ns_part, name, version_part))
416}
417
418fn process_external_source(mapping: &yaml_serde::Mapping) -> String {
419 let get_str = |key: &str| -> Option<String> {
420 mapping
421 .get(Value::String(key.to_string()))
422 .and_then(|v| v.as_str())
423 .map(|s| s.to_string())
424 };
425
426 if mapping.len() == 1 {
427 return mapping
428 .values()
429 .next()
430 .and_then(|v| v.as_str())
431 .unwrap_or("")
432 .to_string();
433 }
434
435 if mapping.len() == 2
436 && let Some(git_url) = get_str(":git")
437 {
438 let repo_url = git_url
439 .replace(".git", "")
440 .replace("git@", "https://")
441 .trim_end_matches('/')
442 .to_string();
443
444 if let Some(commit) = get_str(":commit") {
445 return format!("{}/tree/{}", repo_url, commit);
446 }
447 if let Some(branch) = get_str(":branch") {
448 return format!("{}/tree/{}", repo_url, branch);
449 }
450 }
451
452 format!("{:?}", mapping)
453}
454
455fn default_package_data() -> PackageData {
456 PackageData {
457 package_type: Some(PodfileLockParser::PACKAGE_TYPE),
458 primary_language: Some(PRIMARY_LANGUAGE.to_string()),
459 datasource_id: Some(DatasourceId::CocoapodsPodfileLock),
460 ..Default::default()
461 }
462}
463
464crate::register_parser!(
465 "Cocoapods Podfile.lock",
466 &["**/Podfile.lock"],
467 "cocoapods",
468 "Objective-C",
469 Some("https://guides.cocoapods.org/using/the-podfile.html"),
470);