Skip to main content

provenant/parsers/
pnpm_lock.rs

1//! Parser for pnpm-lock.yaml lockfiles.
2//!
3//! Extracts resolved dependency information from pnpm lockfiles supporting
4//! multiple format versions (v5, v6, v9+).
5//!
6//! # Supported Formats
7//! - pnpm-lock.yaml (v5.x, v6.x, v9.x)
8//!
9//! # Key Features
10//! - Multi-version format support (v5, v6, v9)
11//! - Direct dependency detection from `importers` section
12//! - Development and optional dependency tracking
13//! - Integrity hash extraction (sha512, sha256, md5)
14//! - Package URL (purl) generation for scoped packages
15//! - Nested dependency resolution
16//!
17//! # Implementation Notes
18//! - v9: Uses `@scope+name@version` format in package keys
19//! - v6: Uses `/scope/name/version` format
20//! - v5: Similar to v6 but with different dependency structure
21//! - Direct dependencies tracked via `importers['.'].dependencies`
22
23use 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
32/// pnpm lockfile parser supporting v5, v6, and v9 formats.
33///
34/// Extracts pinned dependency versions from pnpm-lock.yaml and shrinkwrap.yaml files.
35pub 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
68/// Returns a default empty PackageData for error cases
69fn 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
78/// Compute which packages are dev-only in pnpm v9 lockfiles
79///
80/// Strategy:
81/// 1. Parse importers section to get direct prod and dev dependencies
82/// 2. Build dependency graph from snapshots section
83/// 3. Traverse graph from prod roots to find all prod-reachable packages
84/// 4. Return packages NOT reachable from prod (= dev-only packages)
85fn 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    // Step 1: Parse importers section to identify direct dependencies
92    if let Some(importers) = lock_data.get("importers").and_then(|v| v.as_mapping()) {
93        for (_importer_path, importer_data) in importers {
94            // Get production dependencies
95            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            // Get dev dependencies
108            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    // Step 2: Build dependency graph from snapshots section
123    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    // Step 3: BFS from prod roots to find all prod-reachable packages
156    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(&current) {
166            for child in children {
167                if prod_reachable.insert(child.clone()) {
168                    queue.push_back(child.clone());
169                }
170            }
171        }
172    }
173
174    // Step 4: Dev-only packages = all packages NOT reachable from prod
175    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
185/// Format package key for v9 (name@version format)
186fn format_package_key_v9(name: &str, version: &str) -> String {
187    // Handle scoped packages and peer dependencies
188    // Version might contain peer dep info like "8.28.2(vue@2.7.16)"
189    let clean_version = version.split('(').next().unwrap_or(version);
190    format!("{}@{}", name, clean_version)
191}
192
193/// Parse pnpm lockfile and extract package data
194fn 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    // For v9: Build dependency graph to determine dev status
201    // For v5/v6: Use dev flag from packages section
202    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    // Extract packages based on version
209    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            // Clean purl_fields based on version
217            let clean_purl_fields = clean_purl_fields(purl_fields_str, &lockfile_version);
218
219            // For v9, check if package is in dev-only set
220            let is_dev_only_v9 = lockfile_version.starts_with('9')
221                && dev_only_packages.contains(&clean_purl_fields.to_string());
222
223            // Extract package info and create dependency
224            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
235/// Detect pnpm lockfile version from the lock data
236pub 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
273/// Clean purl_fields based on lockfile version
274pub 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        // v5 format: /<name>/<version>_<peer_hash> or /@scope/name/version_<peer_hash>
283        // _<peer_hash> is optional
284        let components: Vec<&str> = purl_fields.split('/').collect();
285
286        if let Some(last_component) = components.last() {
287            if last_component.contains('_') {
288                // Need to determine where version ends and peer hash begins
289                // Strategy: Find the first underscore that comes AFTER a valid semver pattern
290                // Semver pattern: digits.digits.digits (possibly with -prerelease or +build)
291
292                // Try to find version pattern: look for pattern like "1.2.3" followed by underscore
293                // We'll iterate through possible split points and check if the left part looks like a version
294                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                        // Found the version, reconstruct path without peer hash
300                        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                // Fallback: if no version pattern found, assume no peer hash (keep everything)
311                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
325/// Check if a string looks like a semantic version
326///
327/// A version typically:
328/// - Contains at least one dot (e.g., "1.0", "1.2.3")
329/// - Starts with a digit
330/// - May contain hyphens for prerelease (e.g., "1.0.0-alpha")
331/// - May contain plus for build metadata (e.g., "1.0.0+build")
332fn is_likely_version(s: &str) -> bool {
333    if s.is_empty() {
334        return false;
335    }
336
337    // Must start with a digit
338    if !s
339        .chars()
340        .next()
341        .map(|c| c.is_ascii_digit())
342        .unwrap_or(false)
343    {
344        return false;
345    }
346
347    // Must contain at least one dot (for major.minor or major.minor.patch)
348    if !s.contains('.') {
349        return false;
350    }
351
352    // Check if it matches a basic version pattern
353    // Split by '-' or '+' to get the core version part
354    let core_version = s.split(&['-', '+'][..]).next().unwrap_or(s);
355
356    // Core version should be digits separated by dots
357    let parts: Vec<&str> = core_version.split('.').collect();
358    if parts.is_empty() {
359        return false;
360    }
361
362    // Each part should be numeric (allowing leading zeros)
363    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
453/// Extract dependency from package data
454pub 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    // Create PURL
463    let purl = create_purl(&namespace, &name, &version);
464
465    // Extract integrity hash from resolution
466    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    // Extract pnpm-specific fields for extra_data
481    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    // Check if this is an optional dependency
495    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    // Check if this is a dev dependency
504    // For v5/v6: Use the dev flag from packages section
505    // For v9: Use the is_dev_only_v9 parameter (computed from graph traversal)
506    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    // Determine scope based on dev/optional flags
517    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    // Dev dependencies are not runtime dependencies
526    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
570/// Parse namespace, name, and version from purl_fields based on lockfile version
571pub 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
662/// Parse integrity field to extract sha1, sha256, sha512, and md5
663fn 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);