Skip to main content

provenant/parsers/
npm_lock.rs

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