Skip to main content

provenant/parsers/
npm.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.json manifests.
7//!
8//! Extracts package metadata, dependencies, and license information from
9//! package.json files used by Node.js/npm projects.
10//!
11//! # Supported Formats
12//! - package.json (manifest)
13//!
14//! # Key Features
15//! - Full dependency extraction (dependencies, devDependencies, peerDependencies, optionalDependencies, bundledDependencies)
16//! - Package URL (purl) generation for scoped and unscoped packages
17//! - VCS repository URL extraction
18//! - Distribution integrity hash extraction (sha1, sha512)
19//! - Support for legacy formats (licenses array, license objects)
20//!
21//! # Implementation Notes
22//! - Uses serde_json for JSON parsing
23//! - Namespace format: `@org` for scoped packages (e.g., `@babel/core`)
24//! - Graceful error handling: logs warnings and returns default on parse failure
25
26use crate::models::{
27    DatasourceId, Dependency, PackageData, PackageType, Party, PartyType, Sha1Digest, Sha256Digest,
28    Sha512Digest,
29};
30use crate::parser_warn as warn;
31use crate::parsers::utils::{
32    CappedIterExt, capped_iteration_limit, npm_purl, parse_sri, truncate_field,
33};
34use serde_json::Value;
35use std::collections::HashMap;
36use std::path::Path;
37
38use super::PackageParser;
39use super::license_normalization::normalize_spdx_declared_license;
40use super::metadata::ParserMetadata;
41
42const FIELD_NAME: &str = "name";
43const FIELD_VERSION: &str = "version";
44const FIELD_LICENSE: &str = "license";
45const FIELD_LICENSES: &str = "licenses";
46const FIELD_HOMEPAGE: &str = "homepage";
47const FIELD_REPOSITORY: &str = "repository";
48const FIELD_AUTHOR: &str = "author";
49const FIELD_CONTRIBUTORS: &str = "contributors";
50const FIELD_MAINTAINERS: &str = "maintainers";
51const FIELD_DEPENDENCIES: &str = "dependencies";
52const FIELD_DEV_DEPENDENCIES: &str = "devDependencies";
53const FIELD_PEER_DEPENDENCIES: &str = "peerDependencies";
54const FIELD_OPTIONAL_DEPENDENCIES: &str = "optionalDependencies";
55const FIELD_BUNDLED_DEPENDENCIES: &str = "bundledDependencies";
56const FIELD_RESOLUTIONS: &str = "resolutions";
57const FIELD_DESCRIPTION: &str = "description";
58const FIELD_KEYWORDS: &str = "keywords";
59const FIELD_ENGINES: &str = "engines";
60const FIELD_OS: &str = "os";
61const FIELD_CPU: &str = "cpu";
62const FIELD_LIBC: &str = "libc";
63const FIELD_DEPRECATED: &str = "deprecated";
64const FIELD_HAS_BIN: &str = "hasBin";
65const FIELD_PACKAGE_MANAGER: &str = "packageManager";
66const FIELD_WORKSPACES: &str = "workspaces";
67const FIELD_PRIVATE: &str = "private";
68const FIELD_BUGS: &str = "bugs";
69const FIELD_DIST: &str = "dist";
70const FIELD_OVERRIDES: &str = "overrides";
71const FIELD_PEER_DEPENDENCIES_META: &str = "peerDependenciesMeta";
72const FIELD_DEPENDENCIES_META: &str = "dependenciesMeta";
73
74/// npm package parser for package.json manifests.
75///
76/// Supports all npm dependency types (dependencies, devDependencies, peerDependencies,
77/// optionalDependencies, bundledDependencies) and workspace configurations.
78pub struct NpmParser;
79
80impl PackageParser for NpmParser {
81    const PACKAGE_TYPE: PackageType = PackageType::Npm;
82
83    fn metadata() -> Vec<ParserMetadata> {
84        vec![ParserMetadata {
85            description: "npm package.json manifest",
86            file_patterns: &["**/package.json"],
87            package_type: "npm",
88            primary_language: "JavaScript",
89            documentation_url: Some("https://docs.npmjs.com/cli/v10/configuring-npm/package-json"),
90        }]
91    }
92
93    fn extract_packages(path: &Path) -> Vec<PackageData> {
94        let (json, _field_lines) = match read_and_parse_json_with_lines(path) {
95            Ok((json, lines)) => (json, lines),
96            Err(e) => {
97                warn!("Failed to read or parse package.json at {:?}: {}", path, e);
98                return vec![default_package_data()];
99            }
100        };
101
102        let name = extract_non_empty_string(&json, FIELD_NAME);
103        let version = extract_non_empty_string(&json, FIELD_VERSION);
104        let namespace = extract_namespace(&name);
105        let package_name = extract_package_name(&name);
106        let description = extract_description(&json);
107
108        let extracted_license_statement = extract_license_statement(&json);
109        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
110            normalize_spdx_declared_license(extract_declared_license_candidate(&json).as_deref());
111        let peer_dependencies_meta = extract_peer_dependencies_meta(&json);
112        let dependencies = extract_dependencies(&json, false);
113        let dev_dependencies = extract_dependencies(&json, true);
114        let peer_dependencies = extract_peer_dependencies(&json, &peer_dependencies_meta);
115        let optional_dependencies = extract_optional_dependencies(&json);
116        let bundled_dependencies = extract_bundled_dependencies(&json);
117        let purl = create_package_url(&name, &version, &namespace);
118        let keywords_vec = extract_keywords_as_vec(&json);
119
120        let mut extra_data_map = HashMap::new();
121
122        if let Some(resolutions) = extract_resolutions(&json) {
123            extra_data_map = combine_extra_data(Some(extra_data_map), resolutions);
124        }
125
126        if let Some(engines) = extract_engines(&json) {
127            extra_data_map.insert("engines".to_string(), engines);
128        }
129
130        for field in [
131            FIELD_OS,
132            FIELD_CPU,
133            FIELD_LIBC,
134            FIELD_DEPRECATED,
135            FIELD_HAS_BIN,
136        ] {
137            if let Some(value) = extract_raw_extra_data_field(&json, field) {
138                extra_data_map.insert(field.to_string(), value);
139            }
140        }
141
142        if let Some(package_manager) = extract_package_manager(&json) {
143            extra_data_map.insert(
144                "packageManager".to_string(),
145                serde_json::Value::String(package_manager),
146            );
147        }
148
149        if let Some(workspaces) = extract_workspaces(&json) {
150            extra_data_map.insert("workspaces".to_string(), workspaces);
151        }
152
153        if let Some(overrides) = extract_overrides(&json) {
154            extra_data_map.insert("overrides".to_string(), overrides);
155        }
156
157        if let Some(private) = extract_private(&json) {
158            extra_data_map.insert("private".to_string(), serde_json::Value::Bool(private));
159        }
160
161        if let Some(dependencies_meta) = extract_dependencies_meta(&json) {
162            extra_data_map.insert("dependenciesMeta".to_string(), dependencies_meta);
163        }
164
165        let extra_data = if extra_data_map.is_empty() {
166            None
167        } else {
168            Some(extra_data_map)
169        };
170
171        let (dist_sha1, dist_sha256, dist_sha512) = match json.get(FIELD_DIST) {
172            Some(dist) => extract_dist_hashes(dist),
173            None => (None, None, None),
174        };
175
176        let download_url = json
177            .get(FIELD_DIST)
178            .and_then(extract_dist_tarball)
179            .or_else(|| generate_registry_download_url(&namespace, &package_name, &version));
180
181        let api_data_url = generate_npm_api_url(&namespace, &package_name, &version);
182        let repository_homepage_url = generate_repository_homepage_url(&namespace, &package_name);
183        let repository_download_url =
184            generate_repository_download_url(&namespace, &package_name, &version);
185        let vcs_url = extract_vcs_url(&json);
186
187        vec![PackageData {
188            package_type: Some(Self::PACKAGE_TYPE),
189            namespace,
190            name: package_name,
191            version,
192            qualifiers: None,
193            subpath: None,
194            primary_language: Some("JavaScript".to_string()),
195            description,
196            release_date: None,
197            parties: extract_parties(&json),
198            keywords: keywords_vec,
199            homepage_url: extract_homepage_url(&json),
200            download_url,
201            size: None,
202            sha1: dist_sha1.and_then(|h| Sha1Digest::from_hex(&h).ok()),
203            md5: None,
204            sha256: dist_sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
205            sha512: dist_sha512.and_then(|h| Sha512Digest::from_hex(&h).ok()),
206            bug_tracking_url: extract_bugs(&json),
207            code_view_url: None,
208            vcs_url,
209            copyright: None,
210            holder: None,
211            declared_license_expression,
212            declared_license_expression_spdx,
213            license_detections,
214            other_license_expression: None,
215            other_license_expression_spdx: None,
216            other_license_detections: Vec::new(),
217            extracted_license_statement,
218            notice_text: None,
219            source_packages: Vec::new(),
220            file_references: Vec::new(),
221            is_private: json
222                .get("private")
223                .and_then(|v| v.as_bool())
224                .unwrap_or(false),
225            is_virtual: false,
226            extra_data,
227            dependencies: [
228                dependencies,
229                dev_dependencies,
230                peer_dependencies,
231                optional_dependencies,
232                bundled_dependencies,
233            ]
234            .concat(),
235            repository_homepage_url,
236            repository_download_url,
237            api_data_url,
238            datasource_id: Some(DatasourceId::NpmPackageJson),
239            purl,
240        }]
241    }
242
243    fn is_match(path: &Path) -> bool {
244        path.file_name().is_some_and(|name| name == "package.json")
245    }
246}
247
248/// Reads and parses a JSON file while tracking line numbers of fields
249fn read_and_parse_json_with_lines(path: &Path) -> Result<(Value, HashMap<String, usize>), String> {
250    // Read file once into string
251    let content = crate::parsers::utils::read_file_to_string(path, None)
252        .map_err(|e| format!("Failed to read file: {}", e))?;
253
254    // Parse JSON
255    let json: Value =
256        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
257
258    // Track line numbers for each field by iterating over lines
259    let mut field_lines = HashMap::new();
260    for (line_num, line) in content
261        .lines()
262        .enumerate()
263        .capped("npm: package.json field line tracking")
264    {
265        let trimmed = line.trim();
266        if let Some(field_name) = extract_field_name(trimmed) {
267            field_lines.insert(field_name, line_num + 1);
268        }
269    }
270
271    Ok((json, field_lines))
272}
273
274/// Extracts field name from a JSON line
275fn extract_field_name(line: &str) -> Option<String> {
276    // Simple regex-free parsing for field names
277    let line = line.trim();
278    if line.is_empty() || !line.starts_with("\"") {
279        return None;
280    }
281
282    // Find the closing quote of the field name
283    let mut chars = line.chars();
284    chars.next(); // Skip opening quote
285
286    let mut field_name = String::new();
287    for c in chars {
288        if c == '"' {
289            break;
290        }
291        field_name.push(c);
292    }
293
294    if field_name.is_empty() {
295        None
296    } else {
297        Some(field_name)
298    }
299}
300
301fn extract_namespace(name: &Option<String>) -> Option<String> {
302    name.as_ref().and_then(|n| {
303        if n.contains('/') {
304            n.split('/').next().map(String::from)
305        } else {
306            None
307        }
308    })
309}
310
311fn extract_package_name(name: &Option<String>) -> Option<String> {
312    name.as_ref().map(|n| {
313        if n.contains('/') {
314            n.split('/').nth(1).unwrap_or(n).to_string()
315        } else {
316            n.clone()
317        }
318    })
319}
320
321fn create_package_url(
322    name: &Option<String>,
323    version: &Option<String>,
324    _namespace: &Option<String>,
325) -> Option<String> {
326    // Note: We extract and store namespace in PackageData for metadata purposes,
327    // but the full package name (e.g., "@babel/core") is used for PURL generation.
328    let name = name.as_ref()?;
329    npm_purl(name, version.as_deref())
330}
331
332fn extract_license_statement(json: &Value) -> Option<String> {
333    let mut statements = Vec::new();
334
335    if let Some(license_value) = json.get(FIELD_LICENSE) {
336        if let Some(license_str) = license_value.as_str() {
337            statements.push(format!("- {}", license_str));
338        } else if let Some(license_obj) = license_value.as_object()
339            && let Some(type_val) = license_obj.get("type").and_then(|v| v.as_str())
340        {
341            statements.push(format!("- type: {}", type_val));
342            if let Some(url_val) = license_obj.get("url").and_then(|v| v.as_str()) {
343                statements.push(format!("  url: {}", url_val));
344            }
345        }
346    }
347
348    if let Some(licenses) = json.get(FIELD_LICENSES).and_then(|v| v.as_array()) {
349        let limit = capped_iteration_limit(licenses.len(), "npm: licenses array");
350        for license in licenses.iter().take(limit) {
351            if let Some(license_obj) = license.as_object()
352                && let Some(type_val) = license_obj.get("type").and_then(|v| v.as_str())
353            {
354                statements.push(format!("- type: {}", type_val));
355                if let Some(url_val) = license_obj.get("url").and_then(|v| v.as_str()) {
356                    statements.push(format!("  url: {}", url_val));
357                }
358            }
359        }
360    }
361
362    if statements.is_empty() {
363        None
364    } else {
365        Some(truncate_field(format!("{}\n", statements.join("\n"))))
366    }
367}
368
369fn extract_declared_license_candidate(json: &Value) -> Option<String> {
370    json.get(FIELD_LICENSE)
371        .and_then(|value| value.as_str())
372        .map(str::trim)
373        .filter(|value| !value.is_empty())
374        .map(|s| truncate_field(s.to_string()))
375}
376
377/// Extracts the repository URL from the repository field.
378/// Extracts and normalizes VCS URL from the repository field.
379/// Supports both string and object formats with optional 'type' and 'directory' fields.
380fn extract_vcs_url(json: &Value) -> Option<String> {
381    let (vcs_tool, vcs_repository) = match json.get(FIELD_REPOSITORY) {
382        Some(Value::String(url)) => {
383            let normalized = normalize_repo_url(url);
384            if normalized.is_empty() {
385                return None;
386            }
387            (None, normalized)
388        }
389        Some(Value::Object(obj)) => {
390            let repo_url = obj.get("url").and_then(|u| u.as_str()).unwrap_or("");
391            let normalized = normalize_repo_url(repo_url);
392            if normalized.is_empty() {
393                return None;
394            }
395            let tool = obj
396                .get("type")
397                .and_then(|t| t.as_str())
398                .unwrap_or("git")
399                .to_string();
400            let tool_for_prefix = if normalized.starts_with("git://")
401                || normalized.starts_with("git+")
402                || normalized.starts_with("hg://")
403                || normalized.starts_with("hg+")
404                || normalized.starts_with("svn://")
405                || normalized.starts_with("svn+")
406            {
407                None
408            } else {
409                Some(tool)
410            };
411            (tool_for_prefix, normalized)
412        }
413        _ => return None,
414    };
415
416    if vcs_repository.is_empty() {
417        return None;
418    }
419
420    let mut vcs_url = vcs_tool.map_or_else(
421        || vcs_repository.clone(),
422        |tool| format!("{}+{}", tool, vcs_repository),
423    );
424
425    if let Some(vcs_revision) = json
426        .get("gitHead")
427        .and_then(|v| v.as_str())
428        .and_then(normalize_non_empty_string)
429    {
430        vcs_url.push('@');
431        vcs_url.push_str(&vcs_revision);
432    }
433
434    if let Some(Value::Object(obj)) = json.get(FIELD_REPOSITORY)
435        && let Some(directory) = obj.get("directory").and_then(|d| d.as_str())
436    {
437        vcs_url.push('#');
438        vcs_url.push_str(directory);
439    }
440
441    Some(truncate_field(vcs_url))
442}
443
444/// Normalizes repository URLs by converting various formats to a standard HTTPS URL.
445/// Based on normalize_vcs_url() from Python reference.
446fn normalize_repo_url(url: &str) -> String {
447    let url = url.trim();
448
449    if url.is_empty() {
450        return String::new();
451    }
452
453    let normalized_schemes = [
454        "https://",
455        "http://",
456        "git://",
457        "git+git://",
458        "git+https://",
459        "git+http://",
460        "hg://",
461        "hg+http://",
462        "hg+https://",
463        "svn://",
464        "svn+http://",
465        "svn+https://",
466    ];
467    if normalized_schemes
468        .iter()
469        .any(|scheme| url.starts_with(scheme))
470    {
471        return url.to_string();
472    }
473
474    if let Some((host, repo)) = url
475        .strip_prefix("git@")
476        .and_then(|rest| rest.split_once(':'))
477    {
478        return format!("https://{}/{}", host, repo);
479    }
480
481    if let Some((platform, repo)) = url.split_once(':') {
482        let host_url = match platform {
483            "github" => "https://github.com/",
484            "gitlab" => "https://gitlab.com/",
485            "bitbucket" => "https://bitbucket.org/",
486            "gist" => "https://gist.github.com/",
487            _ => return url.to_string(),
488        };
489        return format!("{}{}", host_url, repo);
490    }
491
492    if !url.contains(':') && url.chars().filter(|&c| c == '/').count() == 1 {
493        return format!("https://github.com/{}", url);
494    }
495
496    url.to_string()
497}
498
499/// Extracts party information (emails) from the `author`, `contributors`, and `maintainers` fields.
500fn extract_parties(json: &Value) -> Vec<Party> {
501    let mut parties = Vec::new();
502
503    // Extract author field (can be single value or array)
504    if let Some(author) = json.get(FIELD_AUTHOR) {
505        if let Some(author_list) = extract_parties_from_array(author) {
506            // Author is an array
507            for mut party in author_list {
508                if party.role.is_none() {
509                    party.role = Some("author".to_string());
510                }
511                parties.push(party);
512            }
513        } else if let Some(mut party) = extract_party_from_field(author) {
514            // Author is a single value
515            party.role = Some("author".to_string());
516            parties.push(party);
517        }
518    }
519
520    // Extract contributors field
521    if let Some(contributors) = json.get(FIELD_CONTRIBUTORS)
522        && let Some(mut party_list) = extract_parties_from_array(contributors)
523    {
524        for party in &mut party_list {
525            if party.role.is_none() {
526                party.role = Some("contributor".to_string());
527            }
528        }
529        parties.extend(party_list);
530    }
531
532    // Extract maintainers field
533    if let Some(maintainers) = json.get(FIELD_MAINTAINERS)
534        && let Some(mut party_list) = extract_parties_from_array(maintainers)
535    {
536        for party in &mut party_list {
537            if party.role.is_none() {
538                party.role = Some("maintainer".to_string());
539            }
540        }
541        parties.extend(party_list);
542    }
543
544    parties
545}
546
547/// Extracts a party from a JSON field, which can be a string or an object with name/email fields.
548fn extract_party_from_field(field: &Value) -> Option<Party> {
549    match field {
550        Value::String(s) => {
551            if let Some(email) = extract_email_from_string(s) {
552                Some(Party {
553                    r#type: Some(PartyType::Person),
554                    role: None,
555                    name: extract_name_from_author_string(s).map(truncate_field),
556                    email: Some(truncate_field(email)),
557                    url: None,
558                    organization: None,
559                    organization_url: None,
560                    timezone: None,
561                })
562            } else {
563                Some(Party {
564                    r#type: Some(PartyType::Person),
565                    role: None,
566                    name: Some(truncate_field(s.clone())),
567                    email: None,
568                    url: None,
569                    organization: None,
570                    organization_url: None,
571                    timezone: None,
572                })
573            }
574        }
575        Value::Object(obj) => Some(Party {
576            r#type: Some(PartyType::Person),
577            role: obj
578                .get("role")
579                .and_then(|v| v.as_str())
580                .map(|s| truncate_field(s.to_string())),
581            name: obj
582                .get("name")
583                .and_then(|v| v.as_str())
584                .map(|s| truncate_field(s.to_string())),
585            email: obj
586                .get("email")
587                .and_then(|v| v.as_str())
588                .map(|s| truncate_field(s.to_string())),
589            url: obj
590                .get("url")
591                .and_then(|v| v.as_str())
592                .and_then(normalize_optional_party_url)
593                .map(truncate_field),
594            organization: None,
595            organization_url: None,
596            timezone: None,
597        }),
598        _ => None,
599    }
600}
601
602/// Extracts multiple parties from a JSON array.
603fn extract_parties_from_array(array: &Value) -> Option<Vec<Party>> {
604    if let Value::Array(items) = array {
605        let limit = capped_iteration_limit(items.len(), "npm: parties array");
606        let parties = items
607            .iter()
608            .take(limit)
609            .filter_map(extract_party_from_field)
610            .collect::<Vec<_>>();
611        if !parties.is_empty() {
612            return Some(parties);
613        }
614    }
615    None
616}
617
618/// Extracts email from a string in the format "Name <email@example.com>".
619fn extract_email_from_string(author_str: &str) -> Option<String> {
620    if let Some(email_start) = author_str.find('<')
621        && let Some(email_end) = author_str.find('>')
622        && email_start < email_end
623    {
624        return Some(author_str[email_start + 1..email_end].to_string());
625    }
626    None
627}
628
629/// Extracts name from a string in the format "Name <email@example.com>" or returns full string as name.
630fn extract_name_from_author_string(author_str: &str) -> Option<String> {
631    if let Some(end_idx) = author_str.find('<') {
632        let name = author_str[..end_idx].trim();
633        if !name.is_empty() {
634            return Some(name.to_string());
635        }
636    } else {
637        return Some(author_str.trim().to_string());
638    }
639    None
640}
641
642fn default_package_data() -> PackageData {
643    PackageData {
644        package_type: Some(NpmParser::PACKAGE_TYPE),
645        primary_language: Some("JavaScript".to_string()),
646        datasource_id: Some(DatasourceId::NpmPackageJson),
647        ..Default::default()
648    }
649}
650
651fn parse_alias_adapter(version_str: &str) -> Option<(&str, &str)> {
652    if version_str.contains(':') && version_str.contains('@') {
653        let (aliased_package_part, constraint) = version_str.rsplit_once('@')?;
654        let (_, actual_package_name) = aliased_package_part.rsplit_once(':')?;
655        return Some((actual_package_name, constraint));
656    }
657    None
658}
659
660fn extract_non_empty_string(json: &Value, field: &str) -> Option<String> {
661    json.get(field)
662        .and_then(|value| value.as_str())
663        .map(str::trim)
664        .filter(|value| !value.is_empty())
665        .map(|s| truncate_field(s.to_string()))
666}
667
668fn generate_npm_api_url(
669    namespace: &Option<String>,
670    name: &Option<String>,
671    version: &Option<String>,
672) -> Option<String> {
673    const REGISTRY: &str = "https://registry.npmjs.org";
674    name.as_ref()?;
675
676    let ns_name = if let Some(ns) = namespace {
677        format!("{}/{}", ns, name.as_ref()?).replace('/', "%2f")
678    } else {
679        name.as_ref()?.clone()
680    };
681
682    let url = if let Some(ver) = version {
683        format!("{}/{}/{}", REGISTRY, ns_name, ver)
684    } else {
685        format!("{}/{}", REGISTRY, ns_name)
686    };
687
688    Some(url)
689}
690
691fn build_registry_package_path(
692    namespace: &Option<String>,
693    name: &Option<String>,
694) -> Option<String> {
695    match (namespace.as_ref(), name.as_ref()) {
696        (Some(namespace), Some(name)) => Some(format!("{namespace}/{name}")),
697        (None, Some(name)) => Some(name.clone()),
698        _ => None,
699    }
700}
701
702fn generate_repository_homepage_url(
703    namespace: &Option<String>,
704    name: &Option<String>,
705) -> Option<String> {
706    build_registry_package_path(namespace, name)
707        .map(|package_path| format!("https://www.npmjs.com/package/{package_path}"))
708}
709
710fn generate_registry_download_url(
711    namespace: &Option<String>,
712    name: &Option<String>,
713    version: &Option<String>,
714) -> Option<String> {
715    match (
716        build_registry_package_path(namespace, name),
717        name.as_ref(),
718        version.as_ref(),
719    ) {
720        (Some(package_path), Some(name), Some(version)) => Some(format!(
721            "https://registry.npmjs.org/{}/-/{}-{}.tgz",
722            package_path, name, version
723        )),
724        _ => None,
725    }
726}
727
728fn generate_repository_download_url(
729    namespace: &Option<String>,
730    name: &Option<String>,
731    version: &Option<String>,
732) -> Option<String> {
733    generate_registry_download_url(namespace, name, version)
734}
735
736fn extract_dependency_group(
737    json: &Value,
738    field: &str,
739    scope: &str,
740    is_runtime: bool,
741    is_optional: bool,
742    optional_meta: Option<&HashMap<String, bool>>,
743) -> Vec<Dependency> {
744    json.get(field)
745        .and_then(|deps| deps.as_object())
746        .map_or_else(Vec::new, |deps| {
747            let limit = capped_iteration_limit(deps.len(), "npm: dependency group");
748            deps.iter()
749                .take(limit)
750                .filter_map(|(name, version)| {
751                    let version_str = version.as_str()?;
752
753                    if version_str.starts_with("workspace:") {
754                        let package_url = npm_purl(name, None)?;
755                        let is_opt = if let Some(meta) = optional_meta {
756                            meta.get(name).copied()
757                        } else {
758                            Some(is_optional)
759                        };
760                        return Some(Dependency {
761                            purl: Some(package_url),
762                            extracted_requirement: Some(truncate_field(version_str.to_string())),
763                            scope: Some(scope.to_string()),
764                            is_runtime: Some(is_runtime),
765                            is_optional: is_opt,
766                            is_pinned: Some(false),
767                            is_direct: Some(true),
768                            resolved_package: None,
769                            extra_data: None,
770                        });
771                    }
772
773                    let actual_package_name = if let Some((actual_package_name, _constraint)) =
774                        parse_alias_adapter(version_str)
775                    {
776                        actual_package_name
777                    } else {
778                        name.as_str()
779                    };
780
781                    let package_url = npm_purl(actual_package_name, None)?;
782
783                    let is_opt = if let Some(meta) = optional_meta {
784                        meta.get(name).copied()
785                    } else {
786                        Some(is_optional)
787                    };
788
789                    Some(Dependency {
790                        purl: Some(package_url),
791                        extracted_requirement: Some(truncate_field(version_str.to_string())),
792                        scope: Some(scope.to_string()),
793                        is_runtime: Some(is_runtime),
794                        is_optional: is_opt,
795                        is_pinned: Some(false),
796                        is_direct: Some(true),
797                        resolved_package: None,
798                        extra_data: None,
799                    })
800                })
801                .collect()
802        })
803}
804
805/// Extracts dependencies from the `dependencies` or `devDependencies` field in the JSON.
806fn extract_dependencies(json: &Value, is_optional: bool) -> Vec<Dependency> {
807    let field = if is_optional {
808        FIELD_DEV_DEPENDENCIES
809    } else {
810        FIELD_DEPENDENCIES
811    };
812
813    let scope = if is_optional {
814        "devDependencies"
815    } else {
816        "dependencies"
817    };
818
819    extract_dependency_group(json, field, scope, !is_optional, is_optional, None)
820}
821
822fn extract_peer_dependencies(json: &Value, meta: &HashMap<String, bool>) -> Vec<Dependency> {
823    extract_dependency_group(
824        json,
825        FIELD_PEER_DEPENDENCIES,
826        "peerDependencies",
827        true,
828        false,
829        Some(meta),
830    )
831}
832
833/// Extracts optional dependencies from the `optionalDependencies` field in the JSON.
834/// Optional dependencies are marked with is_optional: true, is_runtime: true, and scope "optionalDependencies".
835fn extract_optional_dependencies(json: &Value) -> Vec<Dependency> {
836    extract_dependency_group(
837        json,
838        FIELD_OPTIONAL_DEPENDENCIES,
839        "optionalDependencies",
840        true,
841        true,
842        None,
843    )
844}
845
846fn extract_bundled_dependencies(json: &Value) -> Vec<Dependency> {
847    if let Some(bundled) = json
848        .get(FIELD_BUNDLED_DEPENDENCIES)
849        .and_then(|v| v.as_array())
850    {
851        extract_bundled_list(bundled)
852    } else {
853        Vec::new()
854    }
855}
856
857/// Helper function to extract bundled dependencies from an array of package names.
858fn extract_bundled_list(bundled_array: &[Value]) -> Vec<Dependency> {
859    let limit = capped_iteration_limit(bundled_array.len(), "npm: bundled dependencies");
860    bundled_array
861        .iter()
862        .take(limit)
863        .filter_map(|value| {
864            let name = value.as_str()?;
865            // Create PURL without version for bundled dependencies
866            let package_url = npm_purl(name, None)?;
867
868            Some(Dependency {
869                purl: Some(package_url),
870                extracted_requirement: None,
871                scope: Some("bundledDependencies".to_string()),
872                is_runtime: Some(true),
873                is_optional: Some(false),
874                is_pinned: Some(false),
875                is_direct: Some(true),
876                resolved_package: None,
877                extra_data: None,
878            })
879        })
880        .collect()
881}
882
883/// Extracts Yarn resolutions from the `resolutions` field.
884/// Returns resolutions as a HashMap to be stored in extra_data.
885fn extract_resolutions(json: &Value) -> Option<HashMap<String, serde_json::Value>> {
886    json.get(FIELD_RESOLUTIONS)
887        .and_then(|resolutions| resolutions.as_object())
888        .map(|resolutions_obj| {
889            let mut extra_data = HashMap::new();
890            extra_data.insert(
891                "resolutions".to_string(),
892                serde_json::Value::Object(resolutions_obj.clone()),
893            );
894            extra_data
895        })
896}
897
898fn extract_peer_dependencies_meta(json: &Value) -> HashMap<String, bool> {
899    json.get(FIELD_PEER_DEPENDENCIES_META)
900        .and_then(|meta| meta.as_object())
901        .map_or_else(HashMap::new, |meta_obj| {
902            let limit = capped_iteration_limit(meta_obj.len(), "npm: peerDependenciesMeta");
903            meta_obj
904                .iter()
905                .take(limit)
906                .filter_map(|(package_name, meta_value)| {
907                    meta_value.as_object().and_then(|obj| {
908                        obj.get("optional")
909                            .and_then(|opt| opt.as_bool())
910                            .map(|optional| (package_name.clone(), optional))
911                    })
912                })
913                .collect()
914        })
915}
916
917fn extract_dependencies_meta(json: &Value) -> Option<serde_json::Value> {
918    json.get(FIELD_DEPENDENCIES_META).cloned()
919}
920
921fn extract_overrides(json: &Value) -> Option<serde_json::Value> {
922    json.get(FIELD_OVERRIDES).cloned()
923}
924
925fn extract_description(json: &Value) -> Option<String> {
926    json.get(FIELD_DESCRIPTION)
927        .and_then(|v| v.as_str())
928        .map(|s| truncate_field(s.to_string()))
929}
930
931fn extract_homepage_url(json: &Value) -> Option<String> {
932    match json.get(FIELD_HOMEPAGE) {
933        Some(Value::String(homepage)) => normalize_non_empty_string(homepage).map(truncate_field),
934        _ => None,
935    }
936}
937
938fn normalize_non_empty_string(value: &str) -> Option<String> {
939    let trimmed = value.trim();
940    if trimmed.is_empty() {
941        None
942    } else {
943        Some(trimmed.to_string())
944    }
945}
946
947fn normalize_optional_party_url(value: &str) -> Option<String> {
948    let normalized = normalize_non_empty_string(value)?;
949
950    if normalized.eq_ignore_ascii_case("none") {
951        None
952    } else {
953        Some(normalized)
954    }
955}
956
957fn extract_keywords_as_vec(json: &Value) -> Vec<String> {
958    json.get(FIELD_KEYWORDS)
959        .and_then(|v| {
960            if let Some(str) = v.as_str() {
961                Some(vec![str.to_string()])
962            } else if let Some(arr) = v.as_array() {
963                let limit = capped_iteration_limit(arr.len(), "npm: keywords array");
964                let keywords: Vec<String> = arr
965                    .iter()
966                    .take(limit)
967                    .filter_map(|kw| kw.as_str())
968                    .map(|s| truncate_field(s.to_string()))
969                    .collect();
970                if keywords.is_empty() {
971                    None
972                } else {
973                    Some(keywords)
974                }
975            } else {
976                None
977            }
978        })
979        .unwrap_or_default()
980}
981
982fn extract_engines(json: &Value) -> Option<serde_json::Value> {
983    json.get(FIELD_ENGINES).cloned()
984}
985
986fn extract_raw_extra_data_field(json: &Value, field: &str) -> Option<serde_json::Value> {
987    json.get(field).cloned()
988}
989
990fn extract_package_manager(json: &Value) -> Option<String> {
991    json.get(FIELD_PACKAGE_MANAGER)
992        .and_then(|v| v.as_str())
993        .map(|s| truncate_field(s.to_string()))
994}
995
996fn extract_workspaces(json: &Value) -> Option<serde_json::Value> {
997    json.get(FIELD_WORKSPACES).cloned()
998}
999
1000fn extract_private(json: &Value) -> Option<bool> {
1001    json.get(FIELD_PRIVATE).and_then(|v| v.as_bool())
1002}
1003
1004fn extract_bugs(json: &Value) -> Option<String> {
1005    match json.get(FIELD_BUGS) {
1006        Some(bugs) => {
1007            if let Some(url) = bugs.as_str() {
1008                normalize_non_empty_string(url).map(truncate_field)
1009            } else if let Some(obj) = bugs.as_object() {
1010                obj.get("url")
1011                    .and_then(|v| v.as_str())
1012                    .and_then(normalize_non_empty_string)
1013                    .map(truncate_field)
1014            } else {
1015                None
1016            }
1017        }
1018        None => None,
1019    }
1020}
1021
1022fn extract_dist_hashes(dist: &Value) -> (Option<String>, Option<String>, Option<String>) {
1023    let mut sha1 = dist
1024        .get("shasum")
1025        .and_then(|v| v.as_str())
1026        .and_then(normalize_non_empty_string);
1027    let mut sha256 = None;
1028    let mut sha512 = None;
1029
1030    if let Some(integrity) = dist.get("integrity").and_then(|v| v.as_str())
1031        && let Some((algo, hex_digest)) = parse_sri(integrity)
1032    {
1033        match algo.as_str() {
1034            "sha1" if sha1.is_none() => sha1 = Some(hex_digest),
1035            "sha1" => {}
1036            "sha256" => sha256 = Some(hex_digest),
1037            "sha512" => sha512 = Some(hex_digest),
1038            _ => {}
1039        }
1040    }
1041
1042    (sha1, sha256, sha512)
1043}
1044
1045fn extract_dist_tarball(dist: &Value) -> Option<String> {
1046    dist.get("tarball")
1047        .or_else(|| dist.get("dnl_url"))
1048        .and_then(|v| v.as_str())
1049        .map(normalize_npm_registry_tarball_url)
1050        .map(truncate_field)
1051}
1052
1053fn normalize_npm_registry_tarball_url(url: &str) -> String {
1054    if let Some(path) = url.strip_prefix("http://registry.npmjs.org/") {
1055        format!("https://registry.npmjs.org/{path}")
1056    } else {
1057        url.to_string()
1058    }
1059}
1060
1061fn combine_extra_data(
1062    extra_data: Option<HashMap<String, serde_json::Value>>,
1063    additional_data: HashMap<String, serde_json::Value>,
1064) -> HashMap<String, serde_json::Value> {
1065    let mut combined = extra_data.unwrap_or_default();
1066    for (key, value) in additional_data {
1067        combined.insert(key, value);
1068    }
1069    combined
1070}