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