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