Skip to main content

provenant/parsers/
npm_lock.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for npm package-lock.json and npm-shrinkwrap.json lockfiles.
5//!
6//! Extracts resolved dependency information including exact versions, integrity hashes,
7//! and dependency trees from npm lockfile formats (v1, v2, v3).
8//!
9//! # Supported Formats
10//! - package-lock.json (lockfile v1, v2, v3)
11//! - npm-shrinkwrap.json
12//!
13//! # Key Features
14//! - Lockfile version detection (v1, v2, v3)
15//! - Direct vs transitive dependency tracking (`is_direct`)
16//! - Integrity hash extraction (sha512, sha256, sha1, md5)
17//! - Package URL (purl) generation
18//! - Dependency graph traversal with proper nesting
19//!
20//! # Implementation Notes
21//! - v1: Dependencies nested in `dependencies` objects
22//! - v2+: Flat dependency structure with `node_modules/` prefix for nesting
23//! - Direct dependencies determined by top-level `dependencies` and `devDependencies`
24
25use crate::models::{
26    DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha1Digest, Sha512Digest,
27};
28use crate::parser_warn as warn;
29use crate::parsers::utils::{
30    MAX_ITERATION_COUNT, MAX_RECURSION_DEPTH, RecursionGuard, npm_purl, parse_sri,
31    read_file_to_string, truncate_field,
32};
33use serde_json::Value;
34use std::collections::HashMap;
35use std::path::Path;
36
37use super::PackageParser;
38use super::metadata::ParserMetadata;
39
40// Field name constants
41const FIELD_LOCKFILE_VERSION: &str = "lockfileVersion";
42const FIELD_NAME: &str = "name";
43const FIELD_VERSION: &str = "version";
44const FIELD_DEPENDENCIES: &str = "dependencies";
45const FIELD_PACKAGES: &str = "packages";
46const FIELD_RESOLVED: &str = "resolved";
47const FIELD_INTEGRITY: &str = "integrity";
48const FIELD_DEV: &str = "dev";
49const FIELD_OPTIONAL: &str = "optional";
50const FIELD_DEV_OPTIONAL: &str = "devOptional";
51const FIELD_LINK: &str = "link";
52
53/// npm lockfile parser supporting package-lock.json v1, v2, and v3 formats.
54///
55/// Extracts pinned dependency versions with integrity hashes from lockfiles
56/// including npm-shrinkwrap.json variants.
57pub struct NpmLockParser;
58
59impl PackageParser for NpmLockParser {
60    const PACKAGE_TYPE: PackageType = PackageType::Npm;
61
62    fn metadata() -> Vec<ParserMetadata> {
63        vec![ParserMetadata {
64            description: "npm package-lock.json lockfile",
65            file_patterns: &[
66                "**/package-lock.json",
67                "**/.package-lock.json",
68                "**/npm-shrinkwrap.json",
69            ],
70            package_type: "npm",
71            primary_language: "JavaScript",
72            documentation_url: Some(
73                "https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json",
74            ),
75        }]
76    }
77
78    fn is_match(path: &Path) -> bool {
79        path.file_name()
80            .and_then(|name| name.to_str())
81            .map(|name| {
82                name == "package-lock.json"
83                    || name == ".package-lock.json"
84                    || name == "npm-shrinkwrap.json"
85                    || name == ".npm-shrinkwrap.json"
86            })
87            .unwrap_or(false)
88    }
89
90    fn extract_packages(path: &Path) -> Vec<PackageData> {
91        let content = match read_file_to_string(path, None) {
92            Ok(content) => content,
93            Err(e) => {
94                warn!("Failed to read package-lock.json at {:?}: {}", path, e);
95                return vec![default_package_data()];
96            }
97        };
98
99        let json: Value = match serde_json::from_str(&content) {
100            Ok(json) => json,
101            Err(e) => {
102                warn!("Failed to parse package-lock.json at {:?}: {}", path, e);
103                return vec![default_package_data()];
104            }
105        };
106
107        let lockfile_version = json
108            .get(FIELD_LOCKFILE_VERSION)
109            .and_then(|v| v.as_i64())
110            .unwrap_or(1);
111
112        let root_name = truncate_field(
113            json.get(FIELD_NAME)
114                .and_then(|v| v.as_str())
115                .unwrap_or("")
116                .to_string(),
117        );
118
119        let root_version = truncate_field(
120            json.get(FIELD_VERSION)
121                .and_then(|v| v.as_str())
122                .unwrap_or("")
123                .to_string(),
124        );
125
126        vec![if lockfile_version == 1 {
127            parse_lockfile_v1(&json, root_name, root_version, lockfile_version)
128        } else {
129            parse_lockfile_v2_plus(&json, root_name, root_version, lockfile_version)
130        }]
131    }
132}
133
134/// Returns a default empty PackageData for error cases
135fn default_package_data() -> PackageData {
136    PackageData {
137        package_type: Some(NpmLockParser::PACKAGE_TYPE),
138        datasource_id: Some(DatasourceId::NpmPackageLockJson),
139        ..Default::default()
140    }
141}
142
143/// Parse lockfile version 2 or 3 (flat structure with "packages" key)
144fn parse_lockfile_v2_plus(
145    json: &Value,
146    root_name: String,
147    root_version: String,
148    lockfile_version: i64,
149) -> PackageData {
150    let packages = match json.get(FIELD_PACKAGES).and_then(|v| v.as_object()) {
151        Some(packages) => packages,
152        None => {
153            warn!("No 'packages' field found in lockfile v2+");
154            return default_package_data();
155        }
156    };
157
158    let (root_name, root_version) = extract_root_package_identity(json, root_name, root_version);
159    let (namespace, name, version, purl) =
160        normalize_root_package_metadata(&root_name, &root_version);
161    let linked_workspace_names = collect_linked_workspace_names(packages);
162
163    // Collect root-level dependencies from top-level sections
164    let mut root_deps = std::collections::HashSet::new();
165
166    // Root dependencies are in top-level "dependencies" and "devDependencies"
167    if let Some(root_deps_obj) = json.get(FIELD_DEPENDENCIES).and_then(|v| v.as_object()) {
168        for key in root_deps_obj.keys().take(MAX_ITERATION_COUNT) {
169            root_deps.insert(key.clone());
170        }
171    }
172    if let Some(root_dev_deps_obj) = json.get("devDependencies").and_then(|v| v.as_object()) {
173        for key in root_dev_deps_obj.keys().take(MAX_ITERATION_COUNT) {
174            root_deps.insert(key.clone());
175        }
176    }
177    if let Some(root_package) = packages.get("").and_then(|value| value.as_object()) {
178        collect_root_dependency_names(root_package.get(FIELD_DEPENDENCIES), &mut root_deps);
179        collect_root_dependency_names(root_package.get("devDependencies"), &mut root_deps);
180        collect_root_dependency_names(root_package.get("optionalDependencies"), &mut root_deps);
181    }
182
183    let mut dependencies = Vec::new();
184
185    for (key, value) in packages.iter().take(MAX_ITERATION_COUNT) {
186        // Skip the root package (empty string key)
187        if key.is_empty() {
188            continue;
189        }
190
191        // Extract package name from path like "node_modules/@types/node" or "node_modules/express"
192        let install_name = extract_package_name_from_path(key);
193        if install_name.is_empty() {
194            continue;
195        }
196
197        let package_name = value
198            .get(FIELD_NAME)
199            .and_then(|v| v.as_str())
200            .map(str::trim)
201            .filter(|name| !name.is_empty())
202            .map(str::to_string)
203            .or_else(|| linked_workspace_names.get(key).cloned())
204            .unwrap_or_else(|| install_name.clone());
205
206        let version = value
207            .get(FIELD_VERSION)
208            .and_then(|v| v.as_str())
209            .map(|v| truncate_field(v.to_string()));
210
211        let is_dev = value
212            .get(FIELD_DEV)
213            .and_then(|v| v.as_bool())
214            .unwrap_or(false);
215        let is_optional = value
216            .get(FIELD_OPTIONAL)
217            .and_then(|v| v.as_bool())
218            .unwrap_or(false);
219        let is_dev_optional = value
220            .get(FIELD_DEV_OPTIONAL)
221            .and_then(|v| v.as_bool())
222            .unwrap_or(false);
223
224        let resolved = value
225            .get(FIELD_RESOLVED)
226            .and_then(|v| v.as_str())
227            .map(|v| truncate_field(v.to_string()));
228        let integrity = value.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
229        let from = value.get("from").and_then(|v| v.as_str());
230        let in_bundle = value
231            .get("inBundle")
232            .and_then(|v| v.as_bool())
233            .unwrap_or(false);
234        let is_link = value
235            .get(FIELD_LINK)
236            .and_then(|v| v.as_bool())
237            .unwrap_or(false);
238        let is_direct = root_deps.contains(&install_name) && is_direct_dependency_path(key);
239
240        let dependency = match version {
241            Some(version) => build_npm_dependency(
242                &package_name,
243                version,
244                is_dev,
245                is_dev_optional,
246                is_optional,
247                resolved,
248                integrity,
249                is_direct,
250                from,
251                in_bundle,
252                Vec::new(),
253            ),
254            None if is_link => build_link_dependency(
255                &package_name,
256                is_dev,
257                is_dev_optional,
258                is_optional,
259                resolved,
260                is_direct,
261            ),
262            None => continue,
263        };
264
265        dependencies.push(dependency);
266    }
267
268    let extra_data = Some(HashMap::from([(
269        "lockfileVersion".to_string(),
270        Value::from(lockfile_version),
271    )]));
272
273    PackageData {
274        package_type: Some(NpmLockParser::PACKAGE_TYPE),
275        namespace: namespace.clone(),
276        name,
277        version,
278        qualifiers: None,
279        subpath: None,
280        primary_language: None,
281        description: None,
282        release_date: None,
283        parties: Vec::new(),
284        keywords: Vec::new(),
285        homepage_url: None,
286        download_url: None,
287        size: None,
288        sha1: None,
289        md5: None,
290        sha256: None,
291        sha512: None,
292        bug_tracking_url: None,
293        code_view_url: None,
294        vcs_url: None,
295        copyright: None,
296        holder: None,
297        declared_license_expression: None,
298        declared_license_expression_spdx: None,
299        license_detections: Vec::new(),
300        other_license_expression: None,
301        other_license_expression_spdx: None,
302        other_license_detections: Vec::new(),
303        extracted_license_statement: None,
304        notice_text: None,
305        source_packages: Vec::new(),
306        file_references: Vec::new(),
307        is_private: false,
308        is_virtual: false,
309        extra_data,
310        dependencies,
311        repository_homepage_url: None,
312        repository_download_url: None,
313        api_data_url: None,
314        datasource_id: Some(DatasourceId::NpmPackageLockJson),
315        purl,
316    }
317}
318
319fn collect_linked_workspace_names(
320    packages: &serde_json::Map<String, Value>,
321) -> HashMap<String, String> {
322    let mut linked_names = HashMap::new();
323
324    for (key, value) in packages.iter().take(MAX_ITERATION_COUNT) {
325        let is_link = value
326            .get(FIELD_LINK)
327            .and_then(|v| v.as_bool())
328            .unwrap_or(false);
329        if !is_link {
330            continue;
331        }
332
333        let Some(resolved) = value.get(FIELD_RESOLVED).and_then(|v| v.as_str()) else {
334            continue;
335        };
336
337        let install_name = extract_package_name_from_path(key);
338        if install_name.is_empty() || install_name == key.as_str() {
339            continue;
340        }
341
342        linked_names.insert(resolved.to_string(), install_name);
343    }
344
345    linked_names
346}
347
348/// Parse lockfile version 1 (nested structure with "dependencies" key)
349fn parse_lockfile_v1(
350    json: &Value,
351    root_name: String,
352    root_version: String,
353    _lockfile_version: i64,
354) -> PackageData {
355    let dependencies_obj = match json.get(FIELD_DEPENDENCIES).and_then(|v| v.as_object()) {
356        Some(deps) => deps,
357        None => {
358            warn!("No 'dependencies' field found in lockfile v1");
359            return default_package_data();
360        }
361    };
362
363    let (namespace, name, version, purl) =
364        normalize_root_package_metadata(&root_name, &root_version);
365
366    let dependencies = parse_dependencies_v1(dependencies_obj);
367
368    PackageData {
369        package_type: Some(NpmLockParser::PACKAGE_TYPE),
370        namespace: namespace.clone(),
371        name,
372        version,
373        qualifiers: None,
374        subpath: None,
375        primary_language: None,
376        description: None,
377        release_date: None,
378        parties: Vec::new(),
379        keywords: Vec::new(),
380        homepage_url: None,
381        download_url: None,
382        size: None,
383        sha1: None,
384        md5: None,
385        sha256: None,
386        sha512: None,
387        bug_tracking_url: None,
388        code_view_url: None,
389        vcs_url: None,
390        copyright: None,
391        holder: None,
392        declared_license_expression: None,
393        declared_license_expression_spdx: None,
394        license_detections: Vec::new(),
395        other_license_expression: None,
396        other_license_expression_spdx: None,
397        other_license_detections: Vec::new(),
398        extracted_license_statement: None,
399        notice_text: None,
400        source_packages: Vec::new(),
401        file_references: Vec::new(),
402        is_private: false,
403        is_virtual: false,
404        extra_data: None,
405        dependencies,
406        repository_homepage_url: None,
407        repository_download_url: None,
408        api_data_url: None,
409        datasource_id: Some(DatasourceId::NpmPackageLockJson),
410        purl,
411    }
412}
413
414/// Recursively parse v1 dependencies object
415///
416/// For v1 lockfiles, root dependencies are at nesting level 0 (direct children of the root
417/// "dependencies" object). Transitive dependencies are nested within parent dependencies.
418fn parse_dependencies_v1(dependencies_obj: &serde_json::Map<String, Value>) -> Vec<Dependency> {
419    let mut guard = RecursionGuard::<()>::depth_only();
420    parse_dependencies_v1_with_depth(dependencies_obj, &mut guard)
421}
422
423/// Recursively parse v1 dependencies with depth tracking
424fn parse_dependencies_v1_with_depth(
425    dependencies_obj: &serde_json::Map<String, Value>,
426    guard: &mut RecursionGuard<()>,
427) -> Vec<Dependency> {
428    if guard.descend() {
429        warn!(
430            "Max recursion depth {} exceeded in v1 dependency parsing",
431            MAX_RECURSION_DEPTH
432        );
433        return Vec::new();
434    }
435
436    let mut dependencies = Vec::new();
437
438    for (package_name, dep_data) in dependencies_obj.iter().take(MAX_ITERATION_COUNT) {
439        let version = match dep_data.get(FIELD_VERSION).and_then(|v| v.as_str()) {
440            Some(v) => truncate_field(v.to_string()),
441            None => continue,
442        };
443
444        let is_dev = dep_data
445            .get(FIELD_DEV)
446            .and_then(|v| v.as_bool())
447            .unwrap_or(false);
448        let is_optional = dep_data
449            .get(FIELD_OPTIONAL)
450            .and_then(|v| v.as_bool())
451            .unwrap_or(false);
452
453        let resolved = dep_data
454            .get(FIELD_RESOLVED)
455            .and_then(|v| v.as_str())
456            .map(|v| truncate_field(v.to_string()));
457        let integrity = dep_data.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
458        let from = dep_data.get("from").and_then(|v| v.as_str());
459        let in_bundle = dep_data
460            .get("inBundle")
461            .and_then(|v| v.as_bool())
462            .unwrap_or(false);
463
464        let nested_deps = dep_data
465            .get(FIELD_DEPENDENCIES)
466            .and_then(|v| v.as_object())
467            .map(|nested| parse_dependencies_v1_with_depth(nested, guard))
468            .unwrap_or_default();
469
470        let is_direct = guard.depth() == 1;
471
472        let dependency = build_npm_dependency(
473            package_name,
474            version,
475            is_dev,
476            false,
477            is_optional,
478            resolved,
479            integrity,
480            is_direct,
481            from,
482            in_bundle,
483            nested_deps,
484        );
485
486        dependencies.push(dependency);
487    }
488
489    guard.ascend();
490    dependencies
491}
492
493/// Extract namespace and name from a package name like "@types/node" or "express"
494/// Returns: (namespace, name) where namespace is empty string "" for non-scoped packages
495fn extract_namespace_and_name(package_name: &str) -> (String, String) {
496    if package_name.starts_with('@') {
497        // Scoped package like "@types/node"
498        let parts: Vec<&str> = package_name.splitn(2, '/').collect();
499        if parts.len() == 2 {
500            (parts[0].to_string(), parts[1].to_string())
501        } else {
502            // Invalid format, treat as non-scoped
503            (String::new(), package_name.to_string())
504        }
505    } else {
506        // Regular package like "express"
507        (String::new(), package_name.to_string())
508    }
509}
510
511/// Extract package name from path like "node_modules/@types/node" or "node_modules/express"
512fn extract_package_name_from_path(path: &str) -> String {
513    // Find the last occurrence of "node_modules/"
514    if let Some(pos) = path.rfind("node_modules/") {
515        let after_node_modules = &path[pos + "node_modules/".len()..];
516
517        // Handle scoped packages: "@scope/package"
518        if after_node_modules.starts_with('@') {
519            // Find the second slash (after @scope/)
520            if let Some(slash_pos) = after_node_modules.find('/') {
521                let scope_and_package = &after_node_modules[..=slash_pos];
522                // Find if there's another segment after the package name
523                let remaining = &after_node_modules[slash_pos + 1..];
524                if let Some(next_slash) = remaining.find('/') {
525                    // Return just @scope/package
526                    return format!("{}{}", scope_and_package, &remaining[..next_slash]);
527                } else {
528                    // Return the full scoped package name
529                    return after_node_modules.to_string();
530                }
531            }
532        } else {
533            // Regular package: take everything until first slash (or end of string)
534            if let Some(slash_pos) = after_node_modules.find('/') {
535                return after_node_modules[..slash_pos].to_string();
536            } else {
537                return after_node_modules.to_string();
538            }
539        }
540    }
541
542    path.to_string()
543}
544
545fn create_purl(namespace: &str, name: &str, version: Option<&str>) -> Option<String> {
546    let full_name = if namespace.is_empty() {
547        name.to_string()
548    } else {
549        format!("{}/{}", namespace, name)
550    };
551    npm_purl(&full_name, version.filter(|value| !value.is_empty()))
552}
553
554fn normalize_root_package_metadata(
555    root_name: &str,
556    root_version: &str,
557) -> (
558    Option<String>,
559    Option<String>,
560    Option<String>,
561    Option<String>,
562) {
563    let (namespace, name) = extract_namespace_and_name(root_name);
564    let normalized_name = non_empty_string(&name);
565    let normalized_namespace = normalized_name.as_ref().map(|_| namespace);
566    let normalized_version = normalized_name
567        .as_ref()
568        .and_then(|_| non_empty_string(root_version));
569    let purl = normalized_name.as_deref().and_then(|name| {
570        create_purl(
571            normalized_namespace.as_deref().unwrap_or(""),
572            name,
573            normalized_version.as_deref(),
574        )
575    });
576
577    (
578        normalized_namespace,
579        normalized_name,
580        normalized_version,
581        purl,
582    )
583}
584
585fn extract_root_package_identity(
586    json: &Value,
587    root_name: String,
588    root_version: String,
589) -> (String, String) {
590    let root_package = json
591        .get(FIELD_PACKAGES)
592        .and_then(|value| value.as_object())
593        .and_then(|packages| packages.get(""))
594        .and_then(|value| value.as_object());
595
596    let name = non_empty_string(&root_name).or_else(|| {
597        root_package
598            .and_then(|package| package.get(FIELD_NAME))
599            .and_then(|value| value.as_str())
600            .map(str::to_string)
601            .filter(|value| !value.trim().is_empty())
602    });
603    let version = non_empty_string(&root_version).or_else(|| {
604        root_package
605            .and_then(|package| package.get(FIELD_VERSION))
606            .and_then(|value| value.as_str())
607            .map(str::to_string)
608            .filter(|value| !value.trim().is_empty())
609    });
610
611    (name.unwrap_or_default(), version.unwrap_or_default())
612}
613
614fn non_empty_string(value: &str) -> Option<String> {
615    let trimmed = value.trim();
616    if trimmed.is_empty() {
617        None
618    } else {
619        Some(trimmed.to_string())
620    }
621}
622
623fn collect_root_dependency_names(
624    value: Option<&Value>,
625    root_deps: &mut std::collections::HashSet<String>,
626) {
627    if let Some(entries) = value.and_then(|value| value.as_object()) {
628        for key in entries.keys().take(MAX_ITERATION_COUNT) {
629            root_deps.insert(key.clone());
630        }
631    }
632}
633
634fn is_direct_dependency_path(package_path: &str) -> bool {
635    let node_modules_count = package_path.matches("node_modules/").count();
636
637    match node_modules_count {
638        0 => true,
639        1 => package_path.starts_with("node_modules/") || package_path.starts_with(".pnpm/"),
640        _ => false,
641    }
642}
643
644/// Parse integrity field like "sha512-base64string==" or "sha1-base64string="
645/// Returns: (sha1, sha512) as hex strings
646fn parse_integrity_field(integrity: Option<&str>) -> (Option<String>, Option<String>) {
647    let integrity = match integrity {
648        Some(i) => i,
649        None => return (None, None),
650    };
651
652    match parse_sri(integrity) {
653        Some((algo, hex_digest)) => match algo.as_str() {
654            "sha1" => (Some(hex_digest), None),
655            "sha512" => (None, Some(hex_digest)),
656            _ => (None, None),
657        },
658        None => (None, None),
659    }
660}
661
662/// Parse resolved URL to extract sha1 checksum if present
663/// Example: "https://registry.npmjs.org/package/-/package-1.0.0.tgz#abc123def"
664fn parse_resolved_url(url: &str) -> Option<String> {
665    // Look for # followed by hex characters
666    if let Some(hash_pos) = url.rfind('#') {
667        let hash = &url[hash_pos + 1..];
668        // Verify it's a hex string (sha1 is 40 characters)
669        if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
670            return Some(hash.to_string());
671        }
672    }
673    None
674}
675
676/// Determine scope, is_runtime, and is_optional based on dev/optional flags
677/// Returns: (scope, is_runtime, is_optional)
678fn determine_scope(
679    is_dev: bool,
680    is_dev_optional: bool,
681    is_optional: bool,
682) -> (&'static str, bool, bool) {
683    if is_dev || is_dev_optional {
684        ("devDependencies", false, true)
685    } else if is_optional {
686        ("dependencies", true, true)
687    } else {
688        ("dependencies", true, false)
689    }
690}
691
692fn parse_npm_alias_spec(version_spec: &str) -> Option<(String, String, String)> {
693    let aliased_spec = version_spec.strip_prefix("npm:")?;
694    let (aliased_name, constraint) = aliased_spec.rsplit_once('@')?;
695    let (namespace, name) = extract_namespace_and_name(aliased_name);
696
697    if name.is_empty() || constraint.trim().is_empty() {
698        None
699    } else {
700        Some((namespace, name, constraint.to_string()))
701    }
702}
703
704fn is_exact_version(version: &str) -> bool {
705    let version = version.trim();
706
707    if version.is_empty() {
708        return false;
709    }
710
711    if version.starts_with('~')
712        || version.starts_with('^')
713        || version.starts_with('>')
714        || version.starts_with('<')
715        || version.starts_with('=')
716        || version.starts_with('*')
717        || version.contains("||")
718        || version.contains(" - ")
719    {
720        return false;
721    }
722
723    !is_non_version_dependency(version)
724}
725
726fn is_non_version_dependency(version: &str) -> bool {
727    let version = version.trim();
728
729    version.starts_with("http://")
730        || version.starts_with("https://")
731        || version.starts_with("git://")
732        || version.starts_with("git+ssh://")
733        || version.starts_with("git+http://")
734        || version.starts_with("git+https://")
735        || version.starts_with("git+file://")
736        || version.starts_with("git@")
737        || version.starts_with("file:")
738        || version.starts_with("link:")
739        || version.starts_with("github:")
740        || version.starts_with("gitlab:")
741        || version.starts_with("bitbucket:")
742        || version.starts_with("gist:")
743}
744
745fn non_version_download_url(version: &str, resolved: Option<&str>) -> Option<String> {
746    resolved
747        .map(str::to_string)
748        .or_else(|| match version.trim() {
749            version if version.starts_with("http://") || version.starts_with("https://") => {
750                Some(version.to_string())
751            }
752            _ => None,
753        })
754}
755
756#[allow(clippy::too_many_arguments)]
757fn build_npm_dependency(
758    package_name: &str,
759    version: String,
760    is_dev: bool,
761    is_dev_optional: bool,
762    is_optional: bool,
763    resolved: Option<String>,
764    integrity: Option<&str>,
765    is_direct: bool,
766    from: Option<&str>,
767    in_bundle: bool,
768    nested_deps: Vec<Dependency>,
769) -> Dependency {
770    let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
771    let dep_namespace = truncate_field(dep_namespace);
772    let dep_name = truncate_field(dep_name);
773    let (scope, is_runtime, is_optional_flag) =
774        determine_scope(is_dev, is_dev_optional, is_optional);
775
776    let alias_spec = parse_npm_alias_spec(&version);
777    let (purl_namespace, purl_name, resolved_version, is_pinned, dep_purl, download_url) =
778        if let Some((alias_namespace, alias_name, alias_constraint)) = alias_spec.clone() {
779            let alias_namespace = truncate_field(alias_namespace);
780            let alias_name = truncate_field(alias_name);
781            let alias_constraint = truncate_field(alias_constraint);
782            let is_pinned = is_exact_version(&alias_constraint);
783            let dep_purl = create_purl(
784                &alias_namespace,
785                &alias_name,
786                is_pinned.then_some(alias_constraint.as_str()),
787            );
788            let download_url = non_version_download_url(&alias_constraint, resolved.as_deref());
789
790            (
791                alias_namespace,
792                alias_name,
793                alias_constraint,
794                is_pinned,
795                dep_purl,
796                download_url,
797            )
798        } else {
799            let is_pinned = is_exact_version(&version);
800            let dep_purl = create_purl(
801                &dep_namespace,
802                &dep_name,
803                is_pinned.then_some(version.as_str()),
804            );
805            let download_url = non_version_download_url(&version, resolved.as_deref());
806
807            (
808                dep_namespace.clone(),
809                dep_name.clone(),
810                version.clone(),
811                is_pinned,
812                dep_purl,
813                download_url,
814            )
815        };
816
817    let (sha1_from_integrity, sha512_from_integrity) = parse_integrity_field(integrity);
818    let sha1_from_url = resolved.as_deref().and_then(parse_resolved_url);
819    let sha1 = sha1_from_integrity.or(sha1_from_url);
820
821    let mut dep_extra_data = HashMap::new();
822    if let Some(from) = from {
823        dep_extra_data.insert("from".to_string(), Value::String(from.to_string()));
824    }
825    if in_bundle {
826        dep_extra_data.insert("inBundle".to_string(), Value::Bool(true));
827    }
828
829    let resolved_package = ResolvedPackage {
830        primary_language: Some("JavaScript".to_string()),
831        download_url,
832        sha1: sha1.and_then(|h| Sha1Digest::from_hex(&h).ok()),
833        sha256: None,
834        sha512: sha512_from_integrity.and_then(|h| Sha512Digest::from_hex(&h).ok()),
835        md5: None,
836        is_virtual: true,
837        extra_data: None,
838        dependencies: nested_deps,
839        repository_homepage_url: None,
840        repository_download_url: None,
841        api_data_url: None,
842        datasource_id: Some(DatasourceId::NpmPackageLockJson),
843        purl: None,
844        ..ResolvedPackage::new(
845            NpmLockParser::PACKAGE_TYPE,
846            purl_namespace,
847            purl_name,
848            resolved_version,
849        )
850    };
851
852    Dependency {
853        purl: dep_purl,
854        extracted_requirement: Some(truncate_field(version)),
855        scope: Some(scope.to_string()),
856        is_runtime: Some(is_runtime),
857        is_optional: Some(is_optional_flag),
858        is_pinned: Some(is_pinned),
859        is_direct: Some(is_direct),
860        resolved_package: Some(Box::new(resolved_package)),
861        extra_data: (!dep_extra_data.is_empty()).then_some(dep_extra_data),
862    }
863}
864
865fn build_link_dependency(
866    package_name: &str,
867    is_dev: bool,
868    is_dev_optional: bool,
869    is_optional: bool,
870    resolved: Option<String>,
871    is_direct: bool,
872) -> Dependency {
873    let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
874    let dep_namespace = truncate_field(dep_namespace);
875    let dep_name = truncate_field(dep_name);
876    let (scope, is_runtime, is_optional_flag) =
877        determine_scope(is_dev, is_dev_optional, is_optional);
878    let mut extra_data = HashMap::from([("link".to_string(), Value::Bool(true))]);
879
880    if let Some(resolved) = &resolved {
881        extra_data.insert("resolved".to_string(), Value::String(resolved.clone()));
882    }
883
884    Dependency {
885        purl: create_purl(&dep_namespace, &dep_name, None),
886        extracted_requirement: resolved.map(truncate_field),
887        scope: Some(scope.to_string()),
888        is_runtime: Some(is_runtime),
889        is_optional: Some(is_optional_flag),
890        is_pinned: Some(false),
891        is_direct: Some(is_direct),
892        resolved_package: None,
893        extra_data: Some(extra_data),
894    }
895}