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