1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
24use crate::parsers::utils::npm_purl;
25use std::fs;
26use std::path::Path;
27use yaml_serde::Value;
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 crate::parser_warn!("Failed to read pnpm lockfile at {:?}: {}", path, e);
52 return vec![default_package_data()];
53 }
54 };
55
56 let lock_data: Value = match yaml_serde::from_str(&content) {
57 Ok(data) => data,
58 Err(e) => {
59 crate::parser_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 primary_language: Some("JavaScript".to_string()),
532 download_url: None,
533 sha1,
534 sha256,
535 sha512,
536 md5,
537 is_virtual: true,
538 extra_data: None,
539 dependencies: all_dependencies,
540 repository_homepage_url: None,
541 repository_download_url: None,
542 api_data_url: None,
543 datasource_id: Some(DatasourceId::PnpmLockYaml),
544 purl: None,
545 ..ResolvedPackage::new(
546 PackageType::Npm,
547 namespace.clone().unwrap_or_default(),
548 name.clone(),
549 version.clone(),
550 )
551 };
552
553 let dependency = Dependency {
554 purl: Some(purl),
555 extracted_requirement: Some(version),
556 scope,
557 is_runtime: Some(is_runtime),
558 is_optional: Some(is_optional),
559 is_pinned: Some(true),
560 is_direct: Some(false),
561 resolved_package: Some(Box::new(resolved_package)),
562 extra_data: if extra_data.is_empty() {
563 None
564 } else {
565 Some(extra_data)
566 },
567 };
568
569 Some(dependency)
570}
571
572pub fn parse_purl_fields(
574 clean_purl_fields: &str,
575 lockfile_version: &str,
576) -> Option<(Option<String>, String, String)> {
577 let sections: Vec<&str> = clean_purl_fields.split('/').collect();
578
579 if lockfile_version.starts_with('6') {
580 let last_at_pos = clean_purl_fields.rfind('@')?;
581 let version = clean_purl_fields[last_at_pos + 1..].to_string();
582 let name_part = &clean_purl_fields[..last_at_pos];
583
584 if let Some(stripped) = name_part.strip_prefix('@') {
585 let parts: Vec<&str> = stripped.split('/').collect();
586 if parts.len() == 2 {
587 Some((
588 Some(format!("@{}", parts[0])),
589 parts[1].to_string(),
590 version,
591 ))
592 } else {
593 None
594 }
595 } else if name_part.contains('/') {
596 let parts: Vec<&str> = name_part.split('/').collect();
597 if parts.len() == 2 && parts[0].starts_with('@') {
598 Some((Some(parts[0].to_string()), parts[1].to_string(), version))
599 } else if parts.len() == 2 {
600 Some((None, format!("{}/{}", parts[0], parts[1]), version))
601 } else {
602 Some((None, name_part.to_string(), version))
603 }
604 } else {
605 Some((None, name_part.to_string(), version))
606 }
607 } else if lockfile_version.starts_with('9') {
608 let last_at_pos = clean_purl_fields.rfind('@')?;
609 let name_part = &clean_purl_fields[..last_at_pos];
610 let version = clean_purl_fields[last_at_pos + 1..].to_string();
611
612 if let Some(stripped) = name_part.strip_prefix('@') {
613 let parts: Vec<&str> = stripped.split('/').collect();
614 if parts.len() == 2 {
615 Some((Some(parts[0].to_string()), parts[1].to_string(), version))
616 } else {
617 None
618 }
619 } else {
620 Some((None, name_part.to_string(), version))
621 }
622 } else if lockfile_version.starts_with('5') {
623 if sections.len() == 4 && sections[0].is_empty() && sections[1].starts_with('@') {
624 let scope = sections[1];
625 let name = sections[2];
626 let version = sections[3].to_string();
627 Some((Some(scope.to_string()), name.to_string(), version))
628 } else if sections.len() == 4 && sections[0].is_empty() && !sections[1].starts_with('@') {
629 let name = sections[1];
630 let version = sections[2].to_string();
631 Some((None, name.to_string(), version))
632 } else if sections.len() == 3 && sections[0].starts_with('@') {
633 let scope = sections[0];
634 let name = sections[1];
635 let version = sections[2].to_string();
636 Some((Some(scope.to_string()), name.to_string(), version))
637 } else if sections.len() == 2 {
638 let name = sections[0];
639 let version = sections[1].to_string();
640 Some((None, name.to_string(), version))
641 } else {
642 None
643 }
644 } else {
645 None
646 }
647}
648
649pub fn create_purl(namespace: &Option<String>, name: &str, version: &str) -> String {
650 let full_name = match namespace {
651 Some(ns) if !ns.is_empty() => {
652 let ns_with_at = if ns.starts_with('@') {
653 ns.clone()
654 } else {
655 format!("@{}", ns)
656 };
657 format!("{}/{}", ns_with_at, name)
658 }
659 _ => name.to_string(),
660 };
661 npm_purl(&full_name, Some(version)).unwrap_or_else(|| format!("pkg:npm/{}", name))
662}
663
664fn parse_integrity(
666 integrity: &str,
667) -> (
668 Option<String>,
669 Option<String>,
670 Option<String>,
671 Option<String>,
672) {
673 if let Some(dash_pos) = integrity.find('-') {
674 let algo = integrity[..dash_pos].to_lowercase();
675 let hash = integrity[dash_pos + 1..].to_string();
676
677 if algo.contains("sha1") {
678 (Some(hash), None, None, None)
679 } else if algo.contains("sha256") {
680 (None, Some(hash), None, None)
681 } else if algo.contains("sha512") {
682 (None, None, Some(hash), None)
683 } else if algo.contains("md5") {
684 (None, None, None, Some(hash))
685 } else {
686 (None, None, None, None)
687 }
688 } else {
689 (None, None, None, None)
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 #[test]
698 fn test_detect_pnpm_version_v5() {
699 let yaml = "lockfileVersion: 5.4\n";
700 let data: Value = yaml_serde::from_str(yaml).unwrap();
701 assert_eq!(detect_pnpm_version(&data), "5.4");
702 }
703
704 #[test]
705 fn test_detect_pnpm_version_v6() {
706 let yaml = "lockfileVersion: '6.0'\n";
707 let data: Value = yaml_serde::from_str(yaml).unwrap();
708 assert_eq!(detect_pnpm_version(&data), "6.0");
709 }
710
711 #[test]
712 fn test_detect_pnpm_version_v9() {
713 let yaml = "lockfileVersion: '9.0'\n";
714 let data: Value = yaml_serde::from_str(yaml).unwrap();
715 assert_eq!(detect_pnpm_version(&data), "9.0");
716 }
717
718 #[test]
719 fn test_clean_purl_fields_v6() {
720 let purl_fields = "@babel/runtime@7.18.9(react@18.0.0)";
721 assert_eq!(
722 clean_purl_fields(purl_fields, "6.0"),
723 "@babel/runtime@7.18.9"
724 );
725
726 let purl_fields = "@babel/runtime@7.18.9(";
727 assert_eq!(
728 clean_purl_fields(purl_fields, "6.0"),
729 "@babel/runtime@7.18.9"
730 );
731 }
732
733 #[test]
734 fn test_clean_purl_fields_v5() {
735 let purl_fields = "/_/@headlessui/react/1.6.6_biqbaboplfbrettd7655fr4n2y";
736 assert_eq!(
737 clean_purl_fields(purl_fields, "5.0"),
738 "_/@headlessui/react/1.6.6"
739 );
740 }
741
742 #[test]
743 fn test_clean_purl_fields_v9() {
744 let purl_fields = "@babel/helper-string-parser@7.24.8";
745 assert_eq!(
746 clean_purl_fields(purl_fields, "9.0"),
747 "@babel/helper-string-parser@7.24.8"
748 );
749 }
750
751 #[test]
752 fn test_parse_purl_fields_v6_scoped() {
753 let (namespace, name, version) = parse_purl_fields("@babel/runtime@7.18.9", "6.0").unwrap();
754 assert_eq!(namespace, Some("@babel".to_string()));
755 assert_eq!(name, "runtime".to_string());
756 assert_eq!(version, "7.18.9".to_string());
757 }
758
759 #[test]
760 fn test_parse_purl_fields_v9_scoped() {
761 let (namespace, name, version) =
762 parse_purl_fields("@babel/helper-string-parser@7.24.8", "9.0").unwrap();
763 assert_eq!(namespace, Some("babel".to_string()));
764 assert_eq!(name, "helper-string-parser".to_string());
765 assert_eq!(version, "7.24.8".to_string());
766 }
767
768 #[test]
769 fn test_parse_purl_fields_v9_non_scoped() {
770 let (namespace, name, version) =
771 parse_purl_fields("anve-upload-upyun@1.0.8", "9.0").unwrap();
772 assert_eq!(namespace, None);
773 assert_eq!(name, "anve-upload-upyun".to_string());
774 assert_eq!(version, "1.0.8".to_string());
775 }
776
777 #[test]
778 fn test_parse_purl_fields_v5_scoped() {
779 let (namespace, name, version) = parse_purl_fields("@babel/runtime/7.18.9", "5.0").unwrap();
780 assert_eq!(namespace, Some("@babel".to_string()));
781 assert_eq!(name, "runtime".to_string());
782 assert_eq!(version, "7.18.9".to_string());
783 }
784
785 #[test]
786 fn test_parse_integrity() {
787 let (sha1, sha256, sha512, md5) = parse_integrity(
788 "sha512-luRj/9OnHgR0f5t4e38q9K9A7l4t8uq4nB/eZ/eZ/e2/e3/e4/e5/e6/e7/e8/e9/e0/eva",
789 );
790 assert!(sha1.is_none());
791 assert!(sha256.is_none());
792 assert!(sha512.is_some());
793 assert!(md5.is_none());
794
795 let (sha1, sha256, sha512, md5) = parse_integrity("sha1-abc123");
796 assert!(sha1.is_some());
797 assert!(sha256.is_none());
798 assert!(sha512.is_none());
799 assert!(md5.is_none());
800 }
801}
802
803crate::register_parser!(
804 "pnpm lockfile",
805 &["**/pnpm-lock.yaml", "**/shrinkwrap.yaml"],
806 "npm",
807 "JavaScript",
808 Some("https://pnpm.io/next/git#lockfile-compatibility"),
809);