Skip to main content

provenant/parsers/
npm.rs

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