Skip to main content

provenant/parsers/
npm_lock.rs

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