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::{
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
35/// pnpm lockfile parser supporting v5, v6, and v9 formats.
36///
37/// Extracts pinned dependency versions from pnpm-lock.yaml and shrinkwrap.yaml files.
38pub 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
71/// Returns a default empty PackageData for error cases
72fn 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
81/// Compute which packages are dev-only in pnpm v9 lockfiles
82///
83/// Strategy:
84/// 1. Parse importers section to get direct prod and dev dependencies
85/// 2. Build dependency graph from snapshots section
86/// 3. Traverse graph from prod roots to find all prod-reachable packages
87/// 4. Return packages NOT reachable from prod (= dev-only packages)
88fn 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    // Step 1: Parse importers section to identify direct dependencies
95    if let Some(importers) = lock_data.get("importers").and_then(|v| v.as_mapping()) {
96        for (_importer_path, importer_data) in importers {
97            // Get production dependencies
98            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            // Get dev dependencies
111            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    // Step 2: Build dependency graph from snapshots section
126    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    // Step 3: BFS from prod roots to find all prod-reachable packages
159    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(&current) {
169            for child in children {
170                if prod_reachable.insert(child.clone()) {
171                    queue.push_back(child.clone());
172                }
173            }
174        }
175    }
176
177    // Step 4: Dev-only packages = all packages NOT reachable from prod
178    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
188/// Format package key for v9 (name@version format)
189fn format_package_key_v9(name: &str, version: &str) -> String {
190    // Handle scoped packages and peer dependencies
191    // Version might contain peer dep info like "8.28.2(vue@2.7.16)"
192    let clean_version = version.split('(').next().unwrap_or(version);
193    format!("{}@{}", name, clean_version)
194}
195
196/// Parse pnpm lockfile and extract package data
197fn 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    // For v9: Build dependency graph to determine dev status
204    // For v5/v6: Use dev flag from packages section
205    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    // Extract packages based on version
212    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            // Clean purl_fields based on version
220            let clean_purl_fields = clean_purl_fields(purl_fields_str, &lockfile_version);
221
222            // For v9, check if package is in dev-only set
223            let is_dev_only_v9 = lockfile_version.starts_with('9')
224                && dev_only_packages.contains(&clean_purl_fields.to_string());
225
226            // Extract package info and create dependency
227            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
238/// Detect pnpm lockfile version from the lock data
239pub 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
276/// Clean purl_fields based on lockfile version
277pub 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        // v5 format: /<name>/<version>_<peer_hash> or /@scope/name/version_<peer_hash>
286        // _<peer_hash> is optional
287        let components: Vec<&str> = purl_fields.split('/').collect();
288
289        if let Some(last_component) = components.last() {
290            if last_component.contains('_') {
291                // Need to determine where version ends and peer hash begins
292                // Strategy: Find the first underscore that comes AFTER a valid semver pattern
293                // Semver pattern: digits.digits.digits (possibly with -prerelease or +build)
294
295                // Try to find version pattern: look for pattern like "1.2.3" followed by underscore
296                // We'll iterate through possible split points and check if the left part looks like a version
297                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                        // Found the version, reconstruct path without peer hash
303                        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                // Fallback: if no version pattern found, assume no peer hash (keep everything)
314                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
328/// Check if a string looks like a semantic version
329///
330/// A version typically:
331/// - Contains at least one dot (e.g., "1.0", "1.2.3")
332/// - Starts with a digit
333/// - May contain hyphens for prerelease (e.g., "1.0.0-alpha")
334/// - May contain plus for build metadata (e.g., "1.0.0+build")
335fn is_likely_version(s: &str) -> bool {
336    if s.is_empty() {
337        return false;
338    }
339
340    // Must start with a digit
341    if !s
342        .chars()
343        .next()
344        .map(|c| c.is_ascii_digit())
345        .unwrap_or(false)
346    {
347        return false;
348    }
349
350    // Must contain at least one dot (for major.minor or major.minor.patch)
351    if !s.contains('.') {
352        return false;
353    }
354
355    // Check if it matches a basic version pattern
356    // Split by '-' or '+' to get the core version part
357    let core_version = s.split(&['-', '+'][..]).next().unwrap_or(s);
358
359    // Core version should be digits separated by dots
360    let parts: Vec<&str> = core_version.split('.').collect();
361    if parts.is_empty() {
362        return false;
363    }
364
365    // Each part should be numeric (allowing leading zeros)
366    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
456/// Extract dependency from package data
457pub 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    // Create PURL
466    let purl = create_purl(&namespace, &name, &version);
467
468    // Extract integrity hash from resolution
469    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    // Extract pnpm-specific fields for extra_data
484    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    // Check if this is an optional dependency
498    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    // Check if this is a dev dependency
507    // For v5/v6: Use the dev flag from packages section
508    // For v9: Use the is_dev_only_v9 parameter (computed from graph traversal)
509    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    // Determine scope based on dev/optional flags
520    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    // Dev dependencies are not runtime dependencies
529    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
575/// Parse namespace, name, and version from purl_fields based on lockfile version
576pub 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);