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