Skip to main content

provenant/parsers/
npm_lock.rs

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