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