Skip to main content

provenant/parsers/
pnpm_lock.rs

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