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