Skip to main content

provenant/parsers/
npm_lock.rs

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