Skip to main content

provenant/parsers/
pnpm_lock.rs

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