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, MAX_RECURSION_DEPTH, RecursionGuard, npm_purl, parse_sri,
28    read_file_to_string, truncate_field,
29};
30use serde_json::Value;
31use std::collections::HashMap;
32use std::path::Path;
33
34use super::PackageParser;
35
36// Field name constants
37const FIELD_LOCKFILE_VERSION: &str = "lockfileVersion";
38const FIELD_NAME: &str = "name";
39const FIELD_VERSION: &str = "version";
40const FIELD_DEPENDENCIES: &str = "dependencies";
41const FIELD_PACKAGES: &str = "packages";
42const FIELD_RESOLVED: &str = "resolved";
43const FIELD_INTEGRITY: &str = "integrity";
44const FIELD_DEV: &str = "dev";
45const FIELD_OPTIONAL: &str = "optional";
46const FIELD_DEV_OPTIONAL: &str = "devOptional";
47const FIELD_LINK: &str = "link";
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    let mut guard = RecursionGuard::<()>::depth_only();
369    parse_dependencies_v1_with_depth(dependencies_obj, &mut guard)
370}
371
372/// Recursively parse v1 dependencies with depth tracking
373fn parse_dependencies_v1_with_depth(
374    dependencies_obj: &serde_json::Map<String, Value>,
375    guard: &mut RecursionGuard<()>,
376) -> Vec<Dependency> {
377    if guard.descend() {
378        warn!(
379            "Max recursion depth {} exceeded in v1 dependency parsing",
380            MAX_RECURSION_DEPTH
381        );
382        return Vec::new();
383    }
384
385    let mut dependencies = Vec::new();
386
387    for (package_name, dep_data) in dependencies_obj.iter().take(MAX_ITERATION_COUNT) {
388        let version = match dep_data.get(FIELD_VERSION).and_then(|v| v.as_str()) {
389            Some(v) => truncate_field(v.to_string()),
390            None => continue,
391        };
392
393        let is_dev = dep_data
394            .get(FIELD_DEV)
395            .and_then(|v| v.as_bool())
396            .unwrap_or(false);
397        let is_optional = dep_data
398            .get(FIELD_OPTIONAL)
399            .and_then(|v| v.as_bool())
400            .unwrap_or(false);
401
402        let resolved = dep_data
403            .get(FIELD_RESOLVED)
404            .and_then(|v| v.as_str())
405            .map(|v| truncate_field(v.to_string()));
406        let integrity = dep_data.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
407        let from = dep_data.get("from").and_then(|v| v.as_str());
408        let in_bundle = dep_data
409            .get("inBundle")
410            .and_then(|v| v.as_bool())
411            .unwrap_or(false);
412
413        let nested_deps = dep_data
414            .get(FIELD_DEPENDENCIES)
415            .and_then(|v| v.as_object())
416            .map(|nested| parse_dependencies_v1_with_depth(nested, guard))
417            .unwrap_or_default();
418
419        let is_direct = guard.depth() == 1;
420
421        let dependency = build_npm_dependency(
422            package_name,
423            version,
424            is_dev,
425            false,
426            is_optional,
427            resolved,
428            integrity,
429            is_direct,
430            from,
431            in_bundle,
432            nested_deps,
433        );
434
435        dependencies.push(dependency);
436    }
437
438    guard.ascend();
439    dependencies
440}
441
442/// Extract namespace and name from a package name like "@types/node" or "express"
443/// Returns: (namespace, name) where namespace is empty string "" for non-scoped packages
444fn extract_namespace_and_name(package_name: &str) -> (String, String) {
445    if package_name.starts_with('@') {
446        // Scoped package like "@types/node"
447        let parts: Vec<&str> = package_name.splitn(2, '/').collect();
448        if parts.len() == 2 {
449            (parts[0].to_string(), parts[1].to_string())
450        } else {
451            // Invalid format, treat as non-scoped
452            (String::new(), package_name.to_string())
453        }
454    } else {
455        // Regular package like "express"
456        (String::new(), package_name.to_string())
457    }
458}
459
460/// Extract package name from path like "node_modules/@types/node" or "node_modules/express"
461fn extract_package_name_from_path(path: &str) -> String {
462    // Find the last occurrence of "node_modules/"
463    if let Some(pos) = path.rfind("node_modules/") {
464        let after_node_modules = &path[pos + "node_modules/".len()..];
465
466        // Handle scoped packages: "@scope/package"
467        if after_node_modules.starts_with('@') {
468            // Find the second slash (after @scope/)
469            if let Some(slash_pos) = after_node_modules.find('/') {
470                let scope_and_package = &after_node_modules[..=slash_pos];
471                // Find if there's another segment after the package name
472                let remaining = &after_node_modules[slash_pos + 1..];
473                if let Some(next_slash) = remaining.find('/') {
474                    // Return just @scope/package
475                    return format!("{}{}", scope_and_package, &remaining[..next_slash]);
476                } else {
477                    // Return the full scoped package name
478                    return after_node_modules.to_string();
479                }
480            }
481        } else {
482            // Regular package: take everything until first slash (or end of string)
483            if let Some(slash_pos) = after_node_modules.find('/') {
484                return after_node_modules[..slash_pos].to_string();
485            } else {
486                return after_node_modules.to_string();
487            }
488        }
489    }
490
491    path.to_string()
492}
493
494fn create_purl(namespace: &str, name: &str, version: Option<&str>) -> Option<String> {
495    let full_name = if namespace.is_empty() {
496        name.to_string()
497    } else {
498        format!("{}/{}", namespace, name)
499    };
500    npm_purl(&full_name, version.filter(|value| !value.is_empty()))
501}
502
503fn normalize_root_package_metadata(
504    root_name: &str,
505    root_version: &str,
506) -> (
507    Option<String>,
508    Option<String>,
509    Option<String>,
510    Option<String>,
511) {
512    let (namespace, name) = extract_namespace_and_name(root_name);
513    let normalized_name = non_empty_string(&name);
514    let normalized_namespace = normalized_name.as_ref().map(|_| namespace);
515    let normalized_version = normalized_name
516        .as_ref()
517        .and_then(|_| non_empty_string(root_version));
518    let purl = normalized_name.as_deref().and_then(|name| {
519        create_purl(
520            normalized_namespace.as_deref().unwrap_or(""),
521            name,
522            normalized_version.as_deref(),
523        )
524    });
525
526    (
527        normalized_namespace,
528        normalized_name,
529        normalized_version,
530        purl,
531    )
532}
533
534fn extract_root_package_identity(
535    json: &Value,
536    root_name: String,
537    root_version: String,
538) -> (String, String) {
539    let root_package = json
540        .get(FIELD_PACKAGES)
541        .and_then(|value| value.as_object())
542        .and_then(|packages| packages.get(""))
543        .and_then(|value| value.as_object());
544
545    let name = non_empty_string(&root_name).or_else(|| {
546        root_package
547            .and_then(|package| package.get(FIELD_NAME))
548            .and_then(|value| value.as_str())
549            .map(str::to_string)
550            .filter(|value| !value.trim().is_empty())
551    });
552    let version = non_empty_string(&root_version).or_else(|| {
553        root_package
554            .and_then(|package| package.get(FIELD_VERSION))
555            .and_then(|value| value.as_str())
556            .map(str::to_string)
557            .filter(|value| !value.trim().is_empty())
558    });
559
560    (name.unwrap_or_default(), version.unwrap_or_default())
561}
562
563fn non_empty_string(value: &str) -> Option<String> {
564    let trimmed = value.trim();
565    if trimmed.is_empty() {
566        None
567    } else {
568        Some(trimmed.to_string())
569    }
570}
571
572fn collect_root_dependency_names(
573    value: Option<&Value>,
574    root_deps: &mut std::collections::HashSet<String>,
575) {
576    if let Some(entries) = value.and_then(|value| value.as_object()) {
577        for key in entries.keys().take(MAX_ITERATION_COUNT) {
578            root_deps.insert(key.clone());
579        }
580    }
581}
582
583fn is_direct_dependency_path(package_path: &str) -> bool {
584    let node_modules_count = package_path.matches("node_modules/").count();
585
586    match node_modules_count {
587        0 => true,
588        1 => package_path.starts_with("node_modules/") || package_path.starts_with(".pnpm/"),
589        _ => false,
590    }
591}
592
593/// Parse integrity field like "sha512-base64string==" or "sha1-base64string="
594/// Returns: (sha1, sha512) as hex strings
595fn parse_integrity_field(integrity: Option<&str>) -> (Option<String>, Option<String>) {
596    let integrity = match integrity {
597        Some(i) => i,
598        None => return (None, None),
599    };
600
601    match parse_sri(integrity) {
602        Some((algo, hex_digest)) => match algo.as_str() {
603            "sha1" => (Some(hex_digest), None),
604            "sha512" => (None, Some(hex_digest)),
605            _ => (None, None),
606        },
607        None => (None, None),
608    }
609}
610
611/// Parse resolved URL to extract sha1 checksum if present
612/// Example: "https://registry.npmjs.org/package/-/package-1.0.0.tgz#abc123def"
613fn parse_resolved_url(url: &str) -> Option<String> {
614    // Look for # followed by hex characters
615    if let Some(hash_pos) = url.rfind('#') {
616        let hash = &url[hash_pos + 1..];
617        // Verify it's a hex string (sha1 is 40 characters)
618        if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
619            return Some(hash.to_string());
620        }
621    }
622    None
623}
624
625/// Determine scope, is_runtime, and is_optional based on dev/optional flags
626/// Returns: (scope, is_runtime, is_optional)
627fn determine_scope(
628    is_dev: bool,
629    is_dev_optional: bool,
630    is_optional: bool,
631) -> (&'static str, bool, bool) {
632    if is_dev || is_dev_optional {
633        ("devDependencies", false, true)
634    } else if is_optional {
635        ("dependencies", true, true)
636    } else {
637        ("dependencies", true, false)
638    }
639}
640
641fn parse_npm_alias_spec(version_spec: &str) -> Option<(String, String, String)> {
642    let aliased_spec = version_spec.strip_prefix("npm:")?;
643    let (aliased_name, constraint) = aliased_spec.rsplit_once('@')?;
644    let (namespace, name) = extract_namespace_and_name(aliased_name);
645
646    if name.is_empty() || constraint.trim().is_empty() {
647        None
648    } else {
649        Some((namespace, name, constraint.to_string()))
650    }
651}
652
653fn is_exact_version(version: &str) -> bool {
654    let version = version.trim();
655
656    if version.is_empty() {
657        return false;
658    }
659
660    if version.starts_with('~')
661        || version.starts_with('^')
662        || version.starts_with('>')
663        || version.starts_with('<')
664        || version.starts_with('=')
665        || version.starts_with('*')
666        || version.contains("||")
667        || version.contains(" - ")
668    {
669        return false;
670    }
671
672    !is_non_version_dependency(version)
673}
674
675fn is_non_version_dependency(version: &str) -> bool {
676    let version = version.trim();
677
678    version.starts_with("http://")
679        || version.starts_with("https://")
680        || version.starts_with("git://")
681        || version.starts_with("git+ssh://")
682        || version.starts_with("git+http://")
683        || version.starts_with("git+https://")
684        || version.starts_with("git+file://")
685        || version.starts_with("git@")
686        || version.starts_with("file:")
687        || version.starts_with("link:")
688        || version.starts_with("github:")
689        || version.starts_with("gitlab:")
690        || version.starts_with("bitbucket:")
691        || version.starts_with("gist:")
692}
693
694fn non_version_download_url(version: &str, resolved: Option<&str>) -> Option<String> {
695    resolved
696        .map(str::to_string)
697        .or_else(|| match version.trim() {
698            version if version.starts_with("http://") || version.starts_with("https://") => {
699                Some(version.to_string())
700            }
701            _ => None,
702        })
703}
704
705#[allow(clippy::too_many_arguments)]
706fn build_npm_dependency(
707    package_name: &str,
708    version: String,
709    is_dev: bool,
710    is_dev_optional: bool,
711    is_optional: bool,
712    resolved: Option<String>,
713    integrity: Option<&str>,
714    is_direct: bool,
715    from: Option<&str>,
716    in_bundle: bool,
717    nested_deps: Vec<Dependency>,
718) -> Dependency {
719    let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
720    let dep_namespace = truncate_field(dep_namespace);
721    let dep_name = truncate_field(dep_name);
722    let (scope, is_runtime, is_optional_flag) =
723        determine_scope(is_dev, is_dev_optional, is_optional);
724
725    let alias_spec = parse_npm_alias_spec(&version);
726    let (purl_namespace, purl_name, resolved_version, is_pinned, dep_purl, download_url) =
727        if let Some((alias_namespace, alias_name, alias_constraint)) = alias_spec.clone() {
728            let alias_namespace = truncate_field(alias_namespace);
729            let alias_name = truncate_field(alias_name);
730            let alias_constraint = truncate_field(alias_constraint);
731            let is_pinned = is_exact_version(&alias_constraint);
732            let dep_purl = create_purl(
733                &alias_namespace,
734                &alias_name,
735                is_pinned.then_some(alias_constraint.as_str()),
736            );
737            let download_url = non_version_download_url(&alias_constraint, resolved.as_deref());
738
739            (
740                alias_namespace,
741                alias_name,
742                alias_constraint,
743                is_pinned,
744                dep_purl,
745                download_url,
746            )
747        } else {
748            let is_pinned = is_exact_version(&version);
749            let dep_purl = create_purl(
750                &dep_namespace,
751                &dep_name,
752                is_pinned.then_some(version.as_str()),
753            );
754            let download_url = non_version_download_url(&version, resolved.as_deref());
755
756            (
757                dep_namespace.clone(),
758                dep_name.clone(),
759                version.clone(),
760                is_pinned,
761                dep_purl,
762                download_url,
763            )
764        };
765
766    let (sha1_from_integrity, sha512_from_integrity) = parse_integrity_field(integrity);
767    let sha1_from_url = resolved.as_deref().and_then(parse_resolved_url);
768    let sha1 = sha1_from_integrity.or(sha1_from_url);
769
770    let mut dep_extra_data = HashMap::new();
771    if let Some(from) = from {
772        dep_extra_data.insert("from".to_string(), Value::String(from.to_string()));
773    }
774    if in_bundle {
775        dep_extra_data.insert("inBundle".to_string(), Value::Bool(true));
776    }
777
778    let resolved_package = ResolvedPackage {
779        primary_language: Some("JavaScript".to_string()),
780        download_url,
781        sha1: sha1.and_then(|h| Sha1Digest::from_hex(&h).ok()),
782        sha256: None,
783        sha512: sha512_from_integrity.and_then(|h| Sha512Digest::from_hex(&h).ok()),
784        md5: None,
785        is_virtual: true,
786        extra_data: None,
787        dependencies: nested_deps,
788        repository_homepage_url: None,
789        repository_download_url: None,
790        api_data_url: None,
791        datasource_id: Some(DatasourceId::NpmPackageLockJson),
792        purl: None,
793        ..ResolvedPackage::new(
794            NpmLockParser::PACKAGE_TYPE,
795            purl_namespace,
796            purl_name,
797            resolved_version,
798        )
799    };
800
801    Dependency {
802        purl: dep_purl,
803        extracted_requirement: Some(truncate_field(version)),
804        scope: Some(scope.to_string()),
805        is_runtime: Some(is_runtime),
806        is_optional: Some(is_optional_flag),
807        is_pinned: Some(is_pinned),
808        is_direct: Some(is_direct),
809        resolved_package: Some(Box::new(resolved_package)),
810        extra_data: (!dep_extra_data.is_empty()).then_some(dep_extra_data),
811    }
812}
813
814fn build_link_dependency(
815    package_name: &str,
816    is_dev: bool,
817    is_dev_optional: bool,
818    is_optional: bool,
819    resolved: Option<String>,
820    is_direct: bool,
821) -> Dependency {
822    let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
823    let dep_namespace = truncate_field(dep_namespace);
824    let dep_name = truncate_field(dep_name);
825    let (scope, is_runtime, is_optional_flag) =
826        determine_scope(is_dev, is_dev_optional, is_optional);
827    let mut extra_data = HashMap::from([("link".to_string(), Value::Bool(true))]);
828
829    if let Some(resolved) = &resolved {
830        extra_data.insert("resolved".to_string(), Value::String(resolved.clone()));
831    }
832
833    Dependency {
834        purl: create_purl(&dep_namespace, &dep_name, None),
835        extracted_requirement: resolved.map(truncate_field),
836        scope: Some(scope.to_string()),
837        is_runtime: Some(is_runtime),
838        is_optional: Some(is_optional_flag),
839        is_pinned: Some(false),
840        is_direct: Some(is_direct),
841        resolved_package: None,
842        extra_data: Some(extra_data),
843    }
844}
845
846crate::register_parser!(
847    "npm package-lock.json lockfile",
848    &[
849        "**/package-lock.json",
850        "**/.package-lock.json",
851        "**/npm-shrinkwrap.json"
852    ],
853    "npm",
854    "JavaScript",
855    Some("https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json"),
856);