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