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