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