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