1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
24use crate::parsers::utils::npm_purl;
25use serde_yaml::Value;
26use std::fs;
27use std::path::Path;
28
29use super::PackageParser;
30use super::yarn_lock::extract_namespace_and_name;
31
32pub struct PnpmLockParser;
36
37impl PackageParser for PnpmLockParser {
38 const PACKAGE_TYPE: PackageType = PackageType::PnpmLock;
39
40 fn is_match(path: &Path) -> bool {
41 path.file_name()
42 .and_then(|name| name.to_str())
43 .map(|name| name == "pnpm-lock.yaml" || name == "shrinkwrap.yaml")
44 .unwrap_or(false)
45 }
46
47 fn extract_packages(path: &Path) -> Vec<PackageData> {
48 let content = match fs::read_to_string(path) {
49 Ok(content) => content,
50 Err(e) => {
51 log::warn!("Failed to read pnpm lockfile at {:?}: {}", path, e);
52 return vec![default_package_data()];
53 }
54 };
55
56 let lock_data: Value = match serde_yaml::from_str(&content) {
57 Ok(data) => data,
58 Err(e) => {
59 log::warn!("Failed to parse pnpm lockfile at {:?}: {}", path, e);
60 return vec![default_package_data()];
61 }
62 };
63
64 vec![parse_pnpm_lockfile(&lock_data)]
65 }
66}
67
68fn default_package_data() -> PackageData {
70 PackageData {
71 package_type: Some(PnpmLockParser::PACKAGE_TYPE),
72 extra_data: Some(std::collections::HashMap::new()),
73 datasource_id: Some(DatasourceId::PnpmLockYaml),
74 ..Default::default()
75 }
76}
77
78fn compute_dev_only_packages_v9(lock_data: &Value) -> std::collections::HashSet<String> {
86 use std::collections::{HashMap, HashSet, VecDeque};
87
88 let mut prod_roots = HashSet::new();
89 let mut dev_roots = HashSet::new();
90
91 if let Some(importers) = lock_data.get("importers").and_then(|v| v.as_mapping()) {
93 for (_importer_path, importer_data) in importers {
94 if let Some(deps) = importer_data
96 .get("dependencies")
97 .and_then(|v| v.as_mapping())
98 {
99 for (name, version_data) in deps {
100 if let Some(version) = version_data.get("version").and_then(|v| v.as_str()) {
101 let pkg_key = format_package_key_v9(name.as_str().unwrap_or(""), version);
102 prod_roots.insert(pkg_key);
103 }
104 }
105 }
106
107 if let Some(dev_deps) = importer_data
109 .get("devDependencies")
110 .and_then(|v| v.as_mapping())
111 {
112 for (name, version_data) in dev_deps {
113 if let Some(version) = version_data.get("version").and_then(|v| v.as_str()) {
114 let pkg_key = format_package_key_v9(name.as_str().unwrap_or(""), version);
115 dev_roots.insert(pkg_key);
116 }
117 }
118 }
119 }
120 }
121
122 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
124
125 if let Some(snapshots) = lock_data.get("snapshots").and_then(|v| v.as_mapping()) {
126 for (pkg_key, pkg_data) in snapshots {
127 let pkg_key_str = pkg_key.as_str().unwrap_or("").to_string();
128 let mut children = Vec::new();
129
130 if let Some(deps) = pkg_data.get("dependencies").and_then(|v| v.as_mapping()) {
131 for (dep_name, dep_version) in deps {
132 let dep_name_str = dep_name.as_str().unwrap_or("");
133 let dep_version_str = dep_version.as_str().unwrap_or("");
134 let child_key = format!("{}@{}", dep_name_str, dep_version_str);
135 children.push(child_key);
136 }
137 }
138
139 if let Some(opt_deps) = pkg_data
140 .get("optionalDependencies")
141 .and_then(|v| v.as_mapping())
142 {
143 for (dep_name, dep_version) in opt_deps {
144 let dep_name_str = dep_name.as_str().unwrap_or("");
145 let dep_version_str = dep_version.as_str().unwrap_or("");
146 let child_key = format!("{}@{}", dep_name_str, dep_version_str);
147 children.push(child_key);
148 }
149 }
150
151 graph.insert(pkg_key_str, children);
152 }
153 }
154
155 let mut prod_reachable = HashSet::new();
157 let mut queue = VecDeque::new();
158
159 for root in &prod_roots {
160 queue.push_back(root.clone());
161 prod_reachable.insert(root.clone());
162 }
163
164 while let Some(current) = queue.pop_front() {
165 if let Some(children) = graph.get(¤t) {
166 for child in children {
167 if prod_reachable.insert(child.clone()) {
168 queue.push_back(child.clone());
169 }
170 }
171 }
172 }
173
174 let mut dev_only = HashSet::new();
176 for pkg_key in graph.keys() {
177 if !prod_reachable.contains(pkg_key) {
178 dev_only.insert(pkg_key.clone());
179 }
180 }
181
182 dev_only
183}
184
185fn format_package_key_v9(name: &str, version: &str) -> String {
187 let clean_version = version.split('(').next().unwrap_or(version);
190 format!("{}@{}", name, clean_version)
191}
192
193fn parse_pnpm_lockfile(lock_data: &Value) -> PackageData {
195 let lockfile_version = detect_pnpm_version(lock_data);
196
197 let mut result = default_package_data();
198 result.package_type = Some(PackageType::PnpmLock);
199
200 let dev_only_packages = if lockfile_version.starts_with('9') {
203 compute_dev_only_packages_v9(lock_data)
204 } else {
205 std::collections::HashSet::new()
206 };
207
208 if let Some(packages_map) = lock_data.get("packages").and_then(|v| v.as_mapping()) {
210 for (purl_fields, data) in packages_map {
211 let purl_fields_str = match purl_fields.as_str() {
212 Some(s) => s,
213 None => continue,
214 };
215
216 let clean_purl_fields = clean_purl_fields(purl_fields_str, &lockfile_version);
218
219 let is_dev_only_v9 = lockfile_version.starts_with('9')
221 && dev_only_packages.contains(&clean_purl_fields.to_string());
222
223 if let Some(dependency) =
225 extract_dependency(&clean_purl_fields, data, &lockfile_version, is_dev_only_v9)
226 {
227 result.dependencies.push(dependency);
228 }
229 }
230 }
231
232 result
233}
234
235pub fn detect_pnpm_version(lock_data: &Value) -> String {
237 if let Some(version) = lock_data.get("lockfileVersion") {
238 if let Some(version_str) = version.as_str() {
239 return version_str.to_string();
240 }
241 if let Some(version_num) = version.as_i64() {
242 return version_num.to_string();
243 }
244 if let Some(version_float) = version.as_f64() {
245 return version_float.to_string();
246 }
247 }
248
249 if let Some(version) = lock_data.get("shrinkwrapVersion") {
250 if let Some(version_str) = version.as_str() {
251 if let Some(minor_str) = lock_data
252 .get("shrinkwrapMinorVersion")
253 .and_then(|v| v.as_str())
254 {
255 return format!("{}.{}", version_str, minor_str);
256 }
257 return version_str.to_string();
258 }
259 if let Some(version_num) = version.as_i64() {
260 if let Some(minor_num) = lock_data
261 .get("shrinkwrapMinorVersion")
262 .and_then(|v| v.as_i64())
263 {
264 return format!("{}.{}", version_num, minor_num);
265 }
266 return version_num.to_string();
267 }
268 }
269
270 "5.0".to_string()
271}
272
273pub fn clean_purl_fields(purl_fields: &str, lockfile_version: &str) -> String {
275 let cleaned = if lockfile_version.starts_with('6') {
276 purl_fields
277 .split('(')
278 .next()
279 .unwrap_or(purl_fields)
280 .to_string()
281 } else if lockfile_version.starts_with('5') {
282 let components: Vec<&str> = purl_fields.split('/').collect();
285
286 if let Some(last_component) = components.last() {
287 if last_component.contains('_') {
288 let parts: Vec<&str> = last_component.split('_').collect();
295 for i in 1..=parts.len() {
296 let potential_version = parts[..i].join("_");
297
298 if is_likely_version(&potential_version) {
299 let mut result_components = components[..components.len() - 1].to_vec();
301 result_components.push(&potential_version);
302 return result_components
303 .join("/")
304 .strip_prefix('/')
305 .unwrap_or(&result_components.join("/"))
306 .to_string();
307 }
308 }
309
310 purl_fields.to_string()
312 } else {
313 purl_fields.to_string()
314 }
315 } else {
316 purl_fields.to_string()
317 }
318 } else {
319 purl_fields.to_string()
320 };
321
322 cleaned.strip_prefix('/').unwrap_or(&cleaned).to_string()
323}
324
325fn is_likely_version(s: &str) -> bool {
333 if s.is_empty() {
334 return false;
335 }
336
337 if !s
339 .chars()
340 .next()
341 .map(|c| c.is_ascii_digit())
342 .unwrap_or(false)
343 {
344 return false;
345 }
346
347 if !s.contains('.') {
349 return false;
350 }
351
352 let core_version = s.split(&['-', '+'][..]).next().unwrap_or(s);
355
356 let parts: Vec<&str> = core_version.split('.').collect();
358 if parts.is_empty() {
359 return false;
360 }
361
362 for part in parts {
364 if part.is_empty() || !part.chars().all(|c| c.is_ascii_digit()) {
365 return false;
366 }
367 }
368
369 true
370}
371
372fn parse_nested_dependencies(data: &Value) -> Vec<Dependency> {
373 let mut all_dependencies = Vec::new();
374
375 if let Some(deps) = data.get("dependencies").and_then(|v| v.as_mapping()) {
376 for (name, version) in deps {
377 if let Some(dep) = create_simple_dependency(name.as_str(), version.as_str(), None) {
378 all_dependencies.push(dep);
379 }
380 }
381 }
382
383 if let Some(dev_deps) = data.get("devDependencies").and_then(|v| v.as_mapping()) {
384 for (name, version) in dev_deps {
385 if let Some(dep) =
386 create_simple_dependency(name.as_str(), version.as_str(), Some("dev".to_string()))
387 {
388 all_dependencies.push(dep);
389 }
390 }
391 }
392
393 if let Some(peer_deps) = data.get("peerDependencies").and_then(|v| v.as_mapping()) {
394 for (name, version) in peer_deps {
395 if let Some(dep) =
396 create_simple_dependency(name.as_str(), version.as_str(), Some("peer".to_string()))
397 {
398 all_dependencies.push(dep);
399 }
400 }
401 }
402
403 if let Some(opt_deps) = data
404 .get("optionalDependencies")
405 .and_then(|v| v.as_mapping())
406 {
407 for (name, version) in opt_deps {
408 if let Some(dep) = create_simple_dependency(
409 name.as_str(),
410 version.as_str(),
411 Some("optional".to_string()),
412 ) {
413 all_dependencies.push(dep);
414 }
415 }
416 }
417
418 all_dependencies
419}
420
421fn create_simple_dependency(
422 name: Option<&str>,
423 version: Option<&str>,
424 scope: Option<String>,
425) -> Option<Dependency> {
426 let name = name?;
427 let version = version?;
428
429 let (namespace_str, pkg_name) = extract_namespace_and_name(name);
430 let namespace = if !namespace_str.is_empty() {
431 Some(namespace_str)
432 } else {
433 None
434 };
435 let purl = create_purl(&namespace, &pkg_name, version);
436
437 let is_runtime = scope.as_deref() != Some("dev");
438 let is_optional = scope.as_deref() == Some("optional");
439
440 Some(Dependency {
441 purl: Some(purl),
442 extracted_requirement: Some(version.to_string()),
443 scope,
444 is_runtime: Some(is_runtime),
445 is_optional: Some(is_optional),
446 is_pinned: Some(true),
447 is_direct: Some(false),
448 resolved_package: None,
449 extra_data: None,
450 })
451}
452
453pub fn extract_dependency(
455 clean_purl_fields: &str,
456 data: &Value,
457 lockfile_version: &str,
458 is_dev_only_v9: bool,
459) -> Option<Dependency> {
460 let (namespace, name, version) = parse_purl_fields(clean_purl_fields, lockfile_version)?;
461
462 let purl = create_purl(&namespace, &name, &version);
464
465 let (sha1, sha256, sha512, md5) = if let Some(resolution) = data.get("resolution") {
467 if let Some(integrity) = resolution.get("integrity") {
468 if let Some(integrity_str) = integrity.as_str() {
469 parse_integrity(integrity_str)
470 } else {
471 (None, None, None, None)
472 }
473 } else {
474 (None, None, None, None)
475 }
476 } else {
477 (None, None, None, None)
478 };
479
480 let mut extra_data = std::collections::HashMap::new();
482
483 if let (Some(_has_bin), Some(true)) = (
484 data.get("hasBin"),
485 data.get("hasBin").and_then(|v| v.as_bool()),
486 ) {
487 extra_data.insert("hasBin".to_string(), serde_json::Value::Bool(true));
488 }
489
490 if data.get("requiresBuild").and_then(|v| v.as_bool()) == Some(true) {
491 extra_data.insert("requiresBuild".to_string(), serde_json::Value::Bool(true));
492 }
493
494 let is_optional = data
496 .get("optional")
497 .and_then(|v| v.as_bool())
498 .unwrap_or(false);
499 if is_optional {
500 extra_data.insert("optional".to_string(), serde_json::Value::Bool(true));
501 }
502
503 let is_dev = if lockfile_version.starts_with('9') {
507 is_dev_only_v9
508 } else {
509 data.get("dev").and_then(|v| v.as_bool()).unwrap_or(false)
510 };
511
512 if is_dev {
513 extra_data.insert("dev".to_string(), serde_json::Value::Bool(true));
514 }
515
516 let scope = if is_dev {
518 Some("dev".to_string())
519 } else if is_optional {
520 Some("optional".to_string())
521 } else {
522 None
523 };
524
525 let is_runtime = !is_dev;
527
528 let all_dependencies = parse_nested_dependencies(data);
529
530 let resolved_package = ResolvedPackage {
531 package_type: PackageType::Npm,
532 namespace: namespace.clone().unwrap_or_default(),
533 name: name.clone(),
534 version: version.clone(),
535 primary_language: Some("JavaScript".to_string()),
536 download_url: None,
537 sha1,
538 sha256,
539 sha512,
540 md5,
541 is_virtual: true,
542 extra_data: None,
543 dependencies: all_dependencies,
544 repository_homepage_url: None,
545 repository_download_url: None,
546 api_data_url: None,
547 datasource_id: Some(DatasourceId::PnpmLockYaml),
548 purl: None,
549 };
550
551 let dependency = Dependency {
552 purl: Some(purl),
553 extracted_requirement: Some(version),
554 scope,
555 is_runtime: Some(is_runtime),
556 is_optional: Some(is_optional),
557 is_pinned: Some(true),
558 is_direct: Some(false),
559 resolved_package: Some(Box::new(resolved_package)),
560 extra_data: if extra_data.is_empty() {
561 None
562 } else {
563 Some(extra_data)
564 },
565 };
566
567 Some(dependency)
568}
569
570pub fn parse_purl_fields(
572 clean_purl_fields: &str,
573 lockfile_version: &str,
574) -> Option<(Option<String>, String, String)> {
575 let sections: Vec<&str> = clean_purl_fields.split('/').collect();
576
577 if lockfile_version.starts_with('6') {
578 let last_at_pos = clean_purl_fields.rfind('@')?;
579 let version = clean_purl_fields[last_at_pos + 1..].to_string();
580 let name_part = &clean_purl_fields[..last_at_pos];
581
582 if let Some(stripped) = name_part.strip_prefix('@') {
583 let parts: Vec<&str> = stripped.split('/').collect();
584 if parts.len() == 2 {
585 Some((
586 Some(format!("@{}", parts[0])),
587 parts[1].to_string(),
588 version,
589 ))
590 } else {
591 None
592 }
593 } else if name_part.contains('/') {
594 let parts: Vec<&str> = name_part.split('/').collect();
595 if parts.len() == 2 && parts[0].starts_with('@') {
596 Some((Some(parts[0].to_string()), parts[1].to_string(), version))
597 } else if parts.len() == 2 {
598 Some((None, format!("{}/{}", parts[0], parts[1]), version))
599 } else {
600 Some((None, name_part.to_string(), version))
601 }
602 } else {
603 Some((None, name_part.to_string(), version))
604 }
605 } else if lockfile_version.starts_with('9') {
606 let last_at_pos = clean_purl_fields.rfind('@')?;
607 let name_part = &clean_purl_fields[..last_at_pos];
608 let version = clean_purl_fields[last_at_pos + 1..].to_string();
609
610 if let Some(stripped) = name_part.strip_prefix('@') {
611 let parts: Vec<&str> = stripped.split('/').collect();
612 if parts.len() == 2 {
613 Some((Some(parts[0].to_string()), parts[1].to_string(), version))
614 } else {
615 None
616 }
617 } else {
618 Some((None, name_part.to_string(), version))
619 }
620 } else if lockfile_version.starts_with('5') {
621 if sections.len() == 4 && sections[0].is_empty() && sections[1].starts_with('@') {
622 let scope = sections[1];
623 let name = sections[2];
624 let version = sections[3].to_string();
625 Some((Some(scope.to_string()), name.to_string(), version))
626 } else if sections.len() == 4 && sections[0].is_empty() && !sections[1].starts_with('@') {
627 let name = sections[1];
628 let version = sections[2].to_string();
629 Some((None, name.to_string(), version))
630 } else if sections.len() == 3 && sections[0].starts_with('@') {
631 let scope = sections[0];
632 let name = sections[1];
633 let version = sections[2].to_string();
634 Some((Some(scope.to_string()), name.to_string(), version))
635 } else if sections.len() == 2 {
636 let name = sections[0];
637 let version = sections[1].to_string();
638 Some((None, name.to_string(), version))
639 } else {
640 None
641 }
642 } else {
643 None
644 }
645}
646
647pub fn create_purl(namespace: &Option<String>, name: &str, version: &str) -> String {
648 let full_name = match namespace {
649 Some(ns) if !ns.is_empty() => {
650 let ns_with_at = if ns.starts_with('@') {
651 ns.clone()
652 } else {
653 format!("@{}", ns)
654 };
655 format!("{}/{}", ns_with_at, name)
656 }
657 _ => name.to_string(),
658 };
659 npm_purl(&full_name, Some(version)).unwrap_or_else(|| format!("pkg:npm/{}", name))
660}
661
662fn parse_integrity(
664 integrity: &str,
665) -> (
666 Option<String>,
667 Option<String>,
668 Option<String>,
669 Option<String>,
670) {
671 if let Some(dash_pos) = integrity.find('-') {
672 let algo = integrity[..dash_pos].to_lowercase();
673 let hash = integrity[dash_pos + 1..].to_string();
674
675 if algo.contains("sha1") {
676 (Some(hash), None, None, None)
677 } else if algo.contains("sha256") {
678 (None, Some(hash), None, None)
679 } else if algo.contains("sha512") {
680 (None, None, Some(hash), None)
681 } else if algo.contains("md5") {
682 (None, None, None, Some(hash))
683 } else {
684 (None, None, None, None)
685 }
686 } else {
687 (None, None, None, None)
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 #[test]
696 fn test_detect_pnpm_version_v5() {
697 let yaml = "lockfileVersion: 5.4\n";
698 let data: Value = serde_yaml::from_str(yaml).unwrap();
699 assert_eq!(detect_pnpm_version(&data), "5.4");
700 }
701
702 #[test]
703 fn test_detect_pnpm_version_v6() {
704 let yaml = "lockfileVersion: '6.0'\n";
705 let data: Value = serde_yaml::from_str(yaml).unwrap();
706 assert_eq!(detect_pnpm_version(&data), "6.0");
707 }
708
709 #[test]
710 fn test_detect_pnpm_version_v9() {
711 let yaml = "lockfileVersion: '9.0'\n";
712 let data: Value = serde_yaml::from_str(yaml).unwrap();
713 assert_eq!(detect_pnpm_version(&data), "9.0");
714 }
715
716 #[test]
717 fn test_clean_purl_fields_v6() {
718 let purl_fields = "@babel/runtime@7.18.9(react@18.0.0)";
719 assert_eq!(
720 clean_purl_fields(purl_fields, "6.0"),
721 "@babel/runtime@7.18.9"
722 );
723
724 let purl_fields = "@babel/runtime@7.18.9(";
725 assert_eq!(
726 clean_purl_fields(purl_fields, "6.0"),
727 "@babel/runtime@7.18.9"
728 );
729 }
730
731 #[test]
732 fn test_clean_purl_fields_v5() {
733 let purl_fields = "/_/@headlessui/react/1.6.6_biqbaboplfbrettd7655fr4n2y";
734 assert_eq!(
735 clean_purl_fields(purl_fields, "5.0"),
736 "_/@headlessui/react/1.6.6"
737 );
738 }
739
740 #[test]
741 fn test_clean_purl_fields_v9() {
742 let purl_fields = "@babel/helper-string-parser@7.24.8";
743 assert_eq!(
744 clean_purl_fields(purl_fields, "9.0"),
745 "@babel/helper-string-parser@7.24.8"
746 );
747 }
748
749 #[test]
750 fn test_parse_purl_fields_v6_scoped() {
751 let (namespace, name, version) = parse_purl_fields("@babel/runtime@7.18.9", "6.0").unwrap();
752 assert_eq!(namespace, Some("@babel".to_string()));
753 assert_eq!(name, "runtime".to_string());
754 assert_eq!(version, "7.18.9".to_string());
755 }
756
757 #[test]
758 fn test_parse_purl_fields_v9_scoped() {
759 let (namespace, name, version) =
760 parse_purl_fields("@babel/helper-string-parser@7.24.8", "9.0").unwrap();
761 assert_eq!(namespace, Some("babel".to_string()));
762 assert_eq!(name, "helper-string-parser".to_string());
763 assert_eq!(version, "7.24.8".to_string());
764 }
765
766 #[test]
767 fn test_parse_purl_fields_v9_non_scoped() {
768 let (namespace, name, version) =
769 parse_purl_fields("anve-upload-upyun@1.0.8", "9.0").unwrap();
770 assert_eq!(namespace, None);
771 assert_eq!(name, "anve-upload-upyun".to_string());
772 assert_eq!(version, "1.0.8".to_string());
773 }
774
775 #[test]
776 fn test_parse_purl_fields_v5_scoped() {
777 let (namespace, name, version) = parse_purl_fields("@babel/runtime/7.18.9", "5.0").unwrap();
778 assert_eq!(namespace, Some("@babel".to_string()));
779 assert_eq!(name, "runtime".to_string());
780 assert_eq!(version, "7.18.9".to_string());
781 }
782
783 #[test]
784 fn test_parse_integrity() {
785 let (sha1, sha256, sha512, md5) = parse_integrity(
786 "sha512-luRj/9OnHgR0f5t4e38q9K9A7l4t8uq4nB/eZ/eZ/e2/e3/e4/e5/e6/e7/e8/e9/e0/eva",
787 );
788 assert!(sha1.is_none());
789 assert!(sha256.is_none());
790 assert!(sha512.is_some());
791 assert!(md5.is_none());
792
793 let (sha1, sha256, sha512, md5) = parse_integrity("sha1-abc123");
794 assert!(sha1.is_some());
795 assert!(sha256.is_none());
796 assert!(sha512.is_none());
797 assert!(md5.is_none());
798 }
799}
800
801crate::register_parser!(
802 "pnpm lockfile",
803 &["**/pnpm-lock.yaml", "**/shrinkwrap.yaml"],
804 "npm",
805 "JavaScript",
806 Some("https://pnpm.io/next/git#lockfile-compatibility"),
807);