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