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