Skip to main content

provenant/parsers/
pnpm_lock.rs

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