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_npm_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_npm_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 resolution_dependencies = extract_resolution_dependencies(&json);
119        let override_dependencies = extract_override_dependencies(&json);
120        let purl = create_package_url(&name, &version, &namespace);
121        let keywords_vec = extract_keywords_as_vec(&json);
122
123        let mut extra_data_map = HashMap::new();
124
125        if let Some(engines) = extract_engines(&json) {
126            extra_data_map.insert("engines".to_string(), engines);
127        }
128
129        for field in [
130            FIELD_OS,
131            FIELD_CPU,
132            FIELD_LIBC,
133            FIELD_DEPRECATED,
134            FIELD_HAS_BIN,
135        ] {
136            if let Some(value) = extract_raw_extra_data_field(&json, field) {
137                extra_data_map.insert(field.to_string(), value);
138            }
139        }
140
141        if let Some(package_manager) = extract_package_manager(&json) {
142            extra_data_map.insert(
143                "packageManager".to_string(),
144                serde_json::Value::String(package_manager),
145            );
146        }
147
148        if let Some(workspaces) = extract_workspaces(&json) {
149            extra_data_map.insert("workspaces".to_string(), workspaces);
150        }
151
152        if let Some(private) = extract_private(&json) {
153            extra_data_map.insert("private".to_string(), serde_json::Value::Bool(private));
154        }
155
156        if let Some(dependencies_meta) = extract_dependencies_meta(&json) {
157            extra_data_map.insert("dependenciesMeta".to_string(), dependencies_meta);
158        }
159
160        let extra_data = if extra_data_map.is_empty() {
161            None
162        } else {
163            Some(extra_data_map)
164        };
165
166        let (dist_sha1, dist_sha256, dist_sha512) = match json.get(FIELD_DIST) {
167            Some(dist) => extract_dist_hashes(dist),
168            None => (None, None, None),
169        };
170
171        let download_url = json
172            .get(FIELD_DIST)
173            .and_then(extract_dist_tarball)
174            .or_else(|| generate_registry_download_url(&namespace, &package_name, &version));
175
176        let api_data_url = generate_npm_api_url(&namespace, &package_name, &version);
177        let repository_homepage_url = generate_repository_homepage_url(&namespace, &package_name);
178        let repository_download_url =
179            generate_repository_download_url(&namespace, &package_name, &version);
180        let vcs_url = extract_vcs_url(&json);
181
182        vec![PackageData {
183            package_type: Some(Self::PACKAGE_TYPE),
184            namespace,
185            name: package_name,
186            version,
187            qualifiers: None,
188            subpath: None,
189            primary_language: Some("JavaScript".to_string()),
190            description,
191            release_date: None,
192            parties: extract_parties(&json),
193            keywords: keywords_vec,
194            homepage_url: extract_homepage_url(&json),
195            download_url,
196            size: None,
197            sha1: dist_sha1.and_then(|h| Sha1Digest::from_hex(&h).ok()),
198            md5: None,
199            sha256: dist_sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
200            sha512: dist_sha512.and_then(|h| Sha512Digest::from_hex(&h).ok()),
201            bug_tracking_url: extract_bugs(&json),
202            code_view_url: None,
203            vcs_url,
204            copyright: None,
205            holder: None,
206            declared_license_expression,
207            declared_license_expression_spdx,
208            license_detections,
209            other_license_expression: None,
210            other_license_expression_spdx: None,
211            other_license_detections: Vec::new(),
212            extracted_license_statement,
213            notice_text: None,
214            source_packages: Vec::new(),
215            file_references: Vec::new(),
216            is_private: json
217                .get("private")
218                .and_then(|v| v.as_bool())
219                .unwrap_or(false),
220            is_virtual: false,
221            extra_data,
222            dependencies: [
223                dependencies,
224                dev_dependencies,
225                peer_dependencies,
226                optional_dependencies,
227                bundled_dependencies,
228                resolution_dependencies,
229                override_dependencies,
230            ]
231            .concat(),
232            repository_homepage_url,
233            repository_download_url,
234            api_data_url,
235            datasource_id: Some(DatasourceId::NpmPackageJson),
236            purl,
237        }]
238    }
239
240    fn is_match(path: &Path) -> bool {
241        path.file_name().is_some_and(|name| name == "package.json")
242    }
243}
244
245/// Reads and parses a JSON file while tracking line numbers of fields
246fn read_and_parse_json_with_lines(path: &Path) -> Result<(Value, HashMap<String, usize>), String> {
247    // Read file once into string
248    let content = crate::parsers::utils::read_file_to_string(path, None)
249        .map_err(|e| format!("Failed to read file: {}", e))?;
250
251    // Parse JSON
252    let json: Value =
253        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
254
255    // Track line numbers for each field by iterating over lines
256    let mut field_lines = HashMap::new();
257    for (line_num, line) in content
258        .lines()
259        .enumerate()
260        .capped("npm: package.json field line tracking")
261    {
262        let trimmed = line.trim();
263        if let Some(field_name) = extract_field_name(trimmed) {
264            field_lines.insert(field_name, line_num + 1);
265        }
266    }
267
268    Ok((json, field_lines))
269}
270
271/// Extracts field name from a JSON line
272fn extract_field_name(line: &str) -> Option<String> {
273    // Simple regex-free parsing for field names
274    let line = line.trim();
275    if line.is_empty() || !line.starts_with("\"") {
276        return None;
277    }
278
279    // Find the closing quote of the field name
280    let mut chars = line.chars();
281    chars.next(); // Skip opening quote
282
283    let mut field_name = String::new();
284    for c in chars {
285        if c == '"' {
286            break;
287        }
288        field_name.push(c);
289    }
290
291    if field_name.is_empty() {
292        None
293    } else {
294        Some(field_name)
295    }
296}
297
298fn extract_namespace(name: &Option<String>) -> Option<String> {
299    name.as_ref().and_then(|n| {
300        if n.contains('/') {
301            n.split('/').next().map(String::from)
302        } else {
303            None
304        }
305    })
306}
307
308fn extract_package_name(name: &Option<String>) -> Option<String> {
309    name.as_ref().map(|n| {
310        if n.contains('/') {
311            n.split('/').nth(1).unwrap_or(n).to_string()
312        } else {
313            n.clone()
314        }
315    })
316}
317
318fn create_package_url(
319    name: &Option<String>,
320    version: &Option<String>,
321    _namespace: &Option<String>,
322) -> Option<String> {
323    // Note: We extract and store namespace in PackageData for metadata purposes,
324    // but the full package name (e.g., "@babel/core") is used for PURL generation.
325    let name = name.as_ref()?;
326    npm_purl(name, version.as_deref())
327}
328
329fn extract_license_statement(json: &Value) -> Option<String> {
330    let mut statements = Vec::new();
331
332    if let Some(license_value) = json.get(FIELD_LICENSE) {
333        if let Some(license_str) = license_value.as_str() {
334            statements.push(format!("- {}", license_str));
335        } else if let Some(license_obj) = license_value.as_object()
336            && let Some(type_val) = license_obj.get("type").and_then(|v| v.as_str())
337        {
338            statements.push(format!("- type: {}", type_val));
339            if let Some(url_val) = license_obj.get("url").and_then(|v| v.as_str()) {
340                statements.push(format!("  url: {}", url_val));
341            }
342        }
343    }
344
345    if let Some(licenses) = json.get(FIELD_LICENSES).and_then(|v| v.as_array()) {
346        let limit = capped_iteration_limit(licenses.len(), "npm: licenses array");
347        for license in licenses.iter().take(limit) {
348            if let Some(license_obj) = license.as_object()
349                && let Some(type_val) = license_obj.get("type").and_then(|v| v.as_str())
350            {
351                statements.push(format!("- type: {}", type_val));
352                if let Some(url_val) = license_obj.get("url").and_then(|v| v.as_str()) {
353                    statements.push(format!("  url: {}", url_val));
354                }
355            }
356        }
357    }
358
359    if statements.is_empty() {
360        None
361    } else {
362        Some(truncate_field(format!("{}\n", statements.join("\n"))))
363    }
364}
365
366fn extract_declared_license_candidate(json: &Value) -> Option<String> {
367    json.get(FIELD_LICENSE)
368        .and_then(|value| value.as_str())
369        .map(str::trim)
370        .filter(|value| !value.is_empty())
371        .map(|s| truncate_field(s.to_string()))
372}
373
374/// Extracts the repository URL from the repository field.
375/// Extracts and normalizes VCS URL from the repository field.
376/// Supports both string and object formats with optional 'type' and 'directory' fields.
377fn extract_vcs_url(json: &Value) -> Option<String> {
378    let (vcs_tool, vcs_repository) = match json.get(FIELD_REPOSITORY) {
379        Some(Value::String(url)) => {
380            let normalized = normalize_repo_url(url);
381            if normalized.is_empty() {
382                return None;
383            }
384            (None, normalized)
385        }
386        Some(Value::Object(obj)) => {
387            let repo_url = obj.get("url").and_then(|u| u.as_str()).unwrap_or("");
388            let normalized = normalize_repo_url(repo_url);
389            if normalized.is_empty() {
390                return None;
391            }
392            let tool = obj
393                .get("type")
394                .and_then(|t| t.as_str())
395                .unwrap_or("git")
396                .to_string();
397            let tool_for_prefix = if normalized.starts_with("git://")
398                || normalized.starts_with("git+")
399                || normalized.starts_with("hg://")
400                || normalized.starts_with("hg+")
401                || normalized.starts_with("svn://")
402                || normalized.starts_with("svn+")
403            {
404                None
405            } else {
406                Some(tool)
407            };
408            (tool_for_prefix, normalized)
409        }
410        _ => return None,
411    };
412
413    if vcs_repository.is_empty() {
414        return None;
415    }
416
417    let mut vcs_url = vcs_tool.map_or_else(
418        || vcs_repository.clone(),
419        |tool| format!("{}+{}", tool, vcs_repository),
420    );
421
422    if let Some(vcs_revision) = json
423        .get("gitHead")
424        .and_then(|v| v.as_str())
425        .and_then(normalize_non_empty_string)
426    {
427        vcs_url.push('@');
428        vcs_url.push_str(&vcs_revision);
429    }
430
431    if let Some(Value::Object(obj)) = json.get(FIELD_REPOSITORY)
432        && let Some(directory) = obj.get("directory").and_then(|d| d.as_str())
433    {
434        vcs_url.push('#');
435        vcs_url.push_str(directory);
436    }
437
438    Some(truncate_field(vcs_url))
439}
440
441/// Normalizes repository URLs by converting various formats to a standard HTTPS URL.
442/// Based on normalize_vcs_url() from Python reference.
443fn normalize_repo_url(url: &str) -> String {
444    let url = url.trim();
445
446    if url.is_empty() {
447        return String::new();
448    }
449
450    let normalized_schemes = [
451        "https://",
452        "http://",
453        "git://",
454        "git+git://",
455        "git+https://",
456        "git+http://",
457        "hg://",
458        "hg+http://",
459        "hg+https://",
460        "svn://",
461        "svn+http://",
462        "svn+https://",
463    ];
464    if normalized_schemes
465        .iter()
466        .any(|scheme| url.starts_with(scheme))
467    {
468        return url.to_string();
469    }
470
471    if let Some((host, repo)) = url
472        .strip_prefix("git@")
473        .and_then(|rest| rest.split_once(':'))
474    {
475        return format!("https://{}/{}", host, repo);
476    }
477
478    if let Some((platform, repo)) = url.split_once(':') {
479        let host_url = match platform {
480            "github" => "https://github.com/",
481            "gitlab" => "https://gitlab.com/",
482            "bitbucket" => "https://bitbucket.org/",
483            "gist" => "https://gist.github.com/",
484            _ => return url.to_string(),
485        };
486        return format!("{}{}", host_url, repo);
487    }
488
489    if !url.contains(':') && url.chars().filter(|&c| c == '/').count() == 1 {
490        return format!("https://github.com/{}", url);
491    }
492
493    url.to_string()
494}
495
496/// Extracts party information (emails) from the `author`, `contributors`, and `maintainers` fields.
497fn extract_parties(json: &Value) -> Vec<Party> {
498    let mut parties = Vec::new();
499
500    // Extract author field (can be single value or array)
501    if let Some(author) = json.get(FIELD_AUTHOR) {
502        if let Some(author_list) = extract_parties_from_array(author) {
503            // Author is an array
504            for mut party in author_list {
505                if party.role.is_none() {
506                    party.role = Some("author".to_string());
507                }
508                parties.push(party);
509            }
510        } else if let Some(mut party) = extract_party_from_field(author) {
511            // Author is a single value
512            party.role = Some("author".to_string());
513            parties.push(party);
514        }
515    }
516
517    // Extract contributors field
518    if let Some(contributors) = json.get(FIELD_CONTRIBUTORS)
519        && let Some(mut party_list) = extract_parties_from_array(contributors)
520    {
521        for party in &mut party_list {
522            if party.role.is_none() {
523                party.role = Some("contributor".to_string());
524            }
525        }
526        parties.extend(party_list);
527    }
528
529    // Extract maintainers field
530    if let Some(maintainers) = json.get(FIELD_MAINTAINERS)
531        && let Some(mut party_list) = extract_parties_from_array(maintainers)
532    {
533        for party in &mut party_list {
534            if party.role.is_none() {
535                party.role = Some("maintainer".to_string());
536            }
537        }
538        parties.extend(party_list);
539    }
540
541    parties
542}
543
544/// Extracts a party from a JSON field, which can be a string or an object with name/email fields.
545fn extract_party_from_field(field: &Value) -> Option<Party> {
546    match field {
547        Value::String(s) => {
548            if let Some(email) = extract_email_from_string(s) {
549                Some(Party {
550                    r#type: Some(PartyType::Person),
551                    role: None,
552                    name: extract_name_from_author_string(s).map(truncate_field),
553                    email: Some(truncate_field(email)),
554                    url: None,
555                    organization: None,
556                    organization_url: None,
557                    timezone: None,
558                })
559            } else {
560                Some(Party {
561                    r#type: Some(PartyType::Person),
562                    role: None,
563                    name: Some(truncate_field(s.clone())),
564                    email: None,
565                    url: None,
566                    organization: None,
567                    organization_url: None,
568                    timezone: None,
569                })
570            }
571        }
572        Value::Object(obj) => Some(Party {
573            r#type: Some(PartyType::Person),
574            role: obj
575                .get("role")
576                .and_then(|v| v.as_str())
577                .map(|s| truncate_field(s.to_string())),
578            name: obj
579                .get("name")
580                .and_then(|v| v.as_str())
581                .map(|s| truncate_field(s.to_string())),
582            email: obj
583                .get("email")
584                .and_then(|v| v.as_str())
585                .map(|s| truncate_field(s.to_string())),
586            url: obj
587                .get("url")
588                .and_then(|v| v.as_str())
589                .and_then(normalize_optional_party_url)
590                .map(truncate_field),
591            organization: None,
592            organization_url: None,
593            timezone: None,
594        }),
595        _ => None,
596    }
597}
598
599/// Extracts multiple parties from a JSON array.
600fn extract_parties_from_array(array: &Value) -> Option<Vec<Party>> {
601    if let Value::Array(items) = array {
602        let limit = capped_iteration_limit(items.len(), "npm: parties array");
603        let parties = items
604            .iter()
605            .take(limit)
606            .filter_map(extract_party_from_field)
607            .collect::<Vec<_>>();
608        if !parties.is_empty() {
609            return Some(parties);
610        }
611    }
612    None
613}
614
615/// Extracts email from a string in the format "Name <email@example.com>".
616fn extract_email_from_string(author_str: &str) -> Option<String> {
617    if let Some(email_start) = author_str.find('<')
618        && let Some(email_end) = author_str.find('>')
619        && email_start < email_end
620    {
621        return Some(author_str[email_start + 1..email_end].to_string());
622    }
623    None
624}
625
626/// Extracts name from a string in the format "Name <email@example.com>" or returns full string as name.
627fn extract_name_from_author_string(author_str: &str) -> Option<String> {
628    if let Some(end_idx) = author_str.find('<') {
629        let name = author_str[..end_idx].trim();
630        if !name.is_empty() {
631            return Some(name.to_string());
632        }
633    } else {
634        return Some(author_str.trim().to_string());
635    }
636    None
637}
638
639fn default_package_data() -> PackageData {
640    PackageData {
641        package_type: Some(NpmParser::PACKAGE_TYPE),
642        primary_language: Some("JavaScript".to_string()),
643        datasource_id: Some(DatasourceId::NpmPackageJson),
644        ..Default::default()
645    }
646}
647
648fn parse_alias_adapter(version_str: &str) -> Option<(&str, &str)> {
649    if version_str.contains(':') && version_str.contains('@') {
650        let (aliased_package_part, constraint) = version_str.rsplit_once('@')?;
651        let (_, actual_package_name) = aliased_package_part.rsplit_once(':')?;
652        return Some((actual_package_name, constraint));
653    }
654    None
655}
656
657fn extract_non_empty_string(json: &Value, field: &str) -> Option<String> {
658    json.get(field)
659        .and_then(|value| value.as_str())
660        .map(str::trim)
661        .filter(|value| !value.is_empty())
662        .map(|s| truncate_field(s.to_string()))
663}
664
665fn generate_npm_api_url(
666    namespace: &Option<String>,
667    name: &Option<String>,
668    version: &Option<String>,
669) -> Option<String> {
670    const REGISTRY: &str = "https://registry.npmjs.org";
671    name.as_ref()?;
672
673    let ns_name = if let Some(ns) = namespace {
674        format!("{}/{}", ns, name.as_ref()?).replace('/', "%2f")
675    } else {
676        name.as_ref()?.clone()
677    };
678
679    let url = if let Some(ver) = version {
680        format!("{}/{}/{}", REGISTRY, ns_name, ver)
681    } else {
682        format!("{}/{}", REGISTRY, ns_name)
683    };
684
685    Some(url)
686}
687
688fn build_registry_package_path(
689    namespace: &Option<String>,
690    name: &Option<String>,
691) -> Option<String> {
692    match (namespace.as_ref(), name.as_ref()) {
693        (Some(namespace), Some(name)) => Some(format!("{namespace}/{name}")),
694        (None, Some(name)) => Some(name.clone()),
695        _ => None,
696    }
697}
698
699fn generate_repository_homepage_url(
700    namespace: &Option<String>,
701    name: &Option<String>,
702) -> Option<String> {
703    build_registry_package_path(namespace, name)
704        .map(|package_path| format!("https://www.npmjs.com/package/{package_path}"))
705}
706
707fn generate_registry_download_url(
708    namespace: &Option<String>,
709    name: &Option<String>,
710    version: &Option<String>,
711) -> Option<String> {
712    match (
713        build_registry_package_path(namespace, name),
714        name.as_ref(),
715        version.as_ref(),
716    ) {
717        (Some(package_path), Some(name), Some(version)) => Some(format!(
718            "https://registry.npmjs.org/{}/-/{}-{}.tgz",
719            package_path, name, version
720        )),
721        _ => None,
722    }
723}
724
725fn generate_repository_download_url(
726    namespace: &Option<String>,
727    name: &Option<String>,
728    version: &Option<String>,
729) -> Option<String> {
730    generate_registry_download_url(namespace, name, version)
731}
732
733fn extract_dependency_group(
734    json: &Value,
735    field: &str,
736    scope: &str,
737    is_runtime: bool,
738    is_optional: bool,
739    optional_meta: Option<&HashMap<String, bool>>,
740) -> Vec<Dependency> {
741    json.get(field)
742        .and_then(|deps| deps.as_object())
743        .map_or_else(Vec::new, |deps| {
744            let limit = capped_iteration_limit(deps.len(), "npm: dependency group");
745            deps.iter()
746                .take(limit)
747                .filter_map(|(name, version)| {
748                    let version_str = version.as_str()?;
749
750                    if version_str.starts_with("workspace:") {
751                        let package_url = npm_purl(name, None)?;
752                        let is_opt = if let Some(meta) = optional_meta {
753                            meta.get(name).copied()
754                        } else {
755                            Some(is_optional)
756                        };
757                        return Some(Dependency {
758                            purl: Some(package_url),
759                            extracted_requirement: Some(truncate_field(version_str.to_string())),
760                            scope: Some(scope.to_string()),
761                            is_runtime: Some(is_runtime),
762                            is_optional: is_opt,
763                            is_pinned: Some(false),
764                            is_direct: Some(true),
765                            resolved_package: None,
766                            extra_data: None,
767                        });
768                    }
769
770                    let actual_package_name = if let Some((actual_package_name, _constraint)) =
771                        parse_alias_adapter(version_str)
772                    {
773                        actual_package_name
774                    } else {
775                        name.as_str()
776                    };
777
778                    let package_url = npm_purl(actual_package_name, None)?;
779
780                    let is_opt = if let Some(meta) = optional_meta {
781                        meta.get(name).copied()
782                    } else {
783                        Some(is_optional)
784                    };
785
786                    Some(Dependency {
787                        purl: Some(package_url),
788                        extracted_requirement: Some(truncate_field(version_str.to_string())),
789                        scope: Some(scope.to_string()),
790                        is_runtime: Some(is_runtime),
791                        is_optional: is_opt,
792                        is_pinned: Some(false),
793                        is_direct: Some(true),
794                        resolved_package: None,
795                        extra_data: None,
796                    })
797                })
798                .collect()
799        })
800}
801
802/// Extracts dependencies from the `dependencies` or `devDependencies` field in the JSON.
803fn extract_dependencies(json: &Value, is_optional: bool) -> Vec<Dependency> {
804    let field = if is_optional {
805        FIELD_DEV_DEPENDENCIES
806    } else {
807        FIELD_DEPENDENCIES
808    };
809
810    let scope = if is_optional {
811        "devDependencies"
812    } else {
813        "dependencies"
814    };
815
816    extract_dependency_group(json, field, scope, !is_optional, is_optional, None)
817}
818
819fn extract_peer_dependencies(json: &Value, meta: &HashMap<String, bool>) -> Vec<Dependency> {
820    extract_dependency_group(
821        json,
822        FIELD_PEER_DEPENDENCIES,
823        "peerDependencies",
824        true,
825        false,
826        Some(meta),
827    )
828}
829
830/// Extracts optional dependencies from the `optionalDependencies` field in the JSON.
831/// Optional dependencies are marked with is_optional: true, is_runtime: true, and scope "optionalDependencies".
832fn extract_optional_dependencies(json: &Value) -> Vec<Dependency> {
833    extract_dependency_group(
834        json,
835        FIELD_OPTIONAL_DEPENDENCIES,
836        "optionalDependencies",
837        true,
838        true,
839        None,
840    )
841}
842
843fn extract_bundled_dependencies(json: &Value) -> Vec<Dependency> {
844    if let Some(bundled) = json
845        .get(FIELD_BUNDLED_DEPENDENCIES)
846        .and_then(|v| v.as_array())
847    {
848        extract_bundled_list(bundled)
849    } else {
850        Vec::new()
851    }
852}
853
854/// Helper function to extract bundled dependencies from an array of package names.
855fn extract_bundled_list(bundled_array: &[Value]) -> Vec<Dependency> {
856    let limit = capped_iteration_limit(bundled_array.len(), "npm: bundled dependencies");
857    bundled_array
858        .iter()
859        .take(limit)
860        .filter_map(|value| {
861            let name = value.as_str()?;
862            // Create PURL without version for bundled dependencies
863            let package_url = npm_purl(name, None)?;
864
865            Some(Dependency {
866                purl: Some(package_url),
867                extracted_requirement: None,
868                scope: Some("bundledDependencies".to_string()),
869                is_runtime: Some(true),
870                is_optional: Some(false),
871                is_pinned: Some(false),
872                is_direct: Some(true),
873                resolved_package: None,
874                extra_data: None,
875            })
876        })
877        .collect()
878}
879
880/// Extracts Yarn `resolutions` entries as dependency rows.
881///
882/// `resolutions` are version-override pins applied across the transitive
883/// dependency graph (Yarn-specific), not direct declared dependencies. They
884/// prove a pin (a concrete version constraint the project asserts) but do not
885/// prove runtime/optional/direct intent, so `is_pinned` is set while the
886/// intent booleans are left unset per the honest-unknowns guardrail.
887///
888/// Keys may carry Yarn glob prefixes such as `**/` or `pkg/**/`; the trailing
889/// package selector is used to build the purl. Aliased requirements of the
890/// `npm:name@range` form are unwrapped to the real package name, matching the
891/// other npm dependency scopes.
892fn extract_resolution_dependencies(json: &Value) -> Vec<Dependency> {
893    json.get(FIELD_RESOLUTIONS)
894        .and_then(|resolutions| resolutions.as_object())
895        .map_or_else(Vec::new, |resolutions_obj| {
896            let limit = capped_iteration_limit(resolutions_obj.len(), "npm: resolutions");
897            resolutions_obj
898                .iter()
899                .take(limit)
900                .filter_map(|(selector, requirement)| {
901                    let requirement_str = requirement.as_str()?;
902                    let package_selector = resolution_package_selector(selector);
903                    override_pin_dependency(package_selector, requirement_str, "resolutions")
904                })
905                .collect()
906        })
907}
908
909/// Extracts the target package selector from a Yarn `resolutions` key.
910///
911/// Yarn resolution keys can scope a pin to a nested path, e.g.
912/// `**/@scope/pkg`, `parent/**/pkg`, `parent/pkg`, or `@scope/parent/pkg`.
913/// The pin always targets the final package. The target package is the last
914/// path segment, unless the target is itself scoped — in which case it is the
915/// last two segments (`@scope/name`), detected by a second-to-last segment
916/// beginning with `@`. This avoids mistaking a scoped *parent* (e.g. the
917/// `@scope/parent` in `@scope/parent/pkg`) for the target package.
918fn resolution_package_selector(selector: &str) -> &str {
919    let selector = selector.trim();
920    let mut segments = selector.rsplitn(3, '/');
921    let Some(last) = segments.next() else {
922        return selector;
923    };
924    let second_to_last = segments.next();
925
926    match second_to_last {
927        // A scoped target keeps its `@scope/name` pair as the trailing segment.
928        Some(scope) if scope.starts_with('@') => {
929            let scope_start = selector.len() - last.len() - scope.len() - 1;
930            &selector[scope_start..]
931        }
932        _ => last,
933    }
934}
935
936/// Builds a pin dependency row for a `resolutions`/`overrides` entry.
937///
938/// Returns `None` when the selector does not resolve to a usable npm package
939/// name (for example a bare `**` glob with no trailing package).
940fn override_pin_dependency(
941    package_selector: &str,
942    requirement: &str,
943    scope: &str,
944) -> Option<Dependency> {
945    let package_selector = package_selector.trim();
946    if package_selector.is_empty() || package_selector == "**" {
947        return None;
948    }
949
950    // Unwrap `npm:name@range` aliases to the real package name, matching the
951    // behavior of the standard dependency scopes.
952    let package_name =
953        if let Some((actual_package_name, _constraint)) = parse_alias_adapter(requirement) {
954            actual_package_name
955        } else {
956            package_selector
957        };
958
959    let purl = npm_purl(package_name, None)?;
960
961    Some(Dependency {
962        purl: Some(purl),
963        extracted_requirement: Some(truncate_field(requirement.to_string())),
964        scope: Some(scope.to_string()),
965        is_runtime: None,
966        is_optional: None,
967        is_pinned: Some(true),
968        is_direct: None,
969        resolved_package: None,
970        extra_data: None,
971    })
972}
973
974fn extract_peer_dependencies_meta(json: &Value) -> HashMap<String, bool> {
975    json.get(FIELD_PEER_DEPENDENCIES_META)
976        .and_then(|meta| meta.as_object())
977        .map_or_else(HashMap::new, |meta_obj| {
978            let limit = capped_iteration_limit(meta_obj.len(), "npm: peerDependenciesMeta");
979            meta_obj
980                .iter()
981                .take(limit)
982                .filter_map(|(package_name, meta_value)| {
983                    meta_value.as_object().and_then(|obj| {
984                        obj.get("optional")
985                            .and_then(|opt| opt.as_bool())
986                            .map(|optional| (package_name.clone(), optional))
987                    })
988                })
989                .collect()
990        })
991}
992
993fn extract_dependencies_meta(json: &Value) -> Option<serde_json::Value> {
994    json.get(FIELD_DEPENDENCIES_META).cloned()
995}
996
997/// Extracts npm `overrides` entries as dependency rows.
998///
999/// `overrides` are version-override pins applied across the transitive
1000/// dependency graph (npm-specific), not direct declared dependencies. Like
1001/// `resolutions`, they prove a pin but not runtime/optional/direct intent, so
1002/// `is_pinned` is set while the intent booleans stay unset.
1003///
1004/// npm `overrides` support several shapes:
1005/// - `"pkg": "1.2.3"` — pin `pkg` to a version everywhere.
1006/// - `"pkg@range": "1.2.3"` — pin only matching ranges; the `name@range`
1007///   key is unwrapped to the package name for the purl.
1008/// - `"pkg": { ".": "1.2.3", "child": "4.5.6" }` — the `.` self-reference
1009///   pins `pkg` itself; nested non-`.` keys pin children when nested under
1010///   `pkg`.
1011///
1012/// Common shapes are handled: every top-level key plus the immediate children
1013/// of a nested object are emitted. Deeper nesting beyond one level is treated
1014/// as a documented limitation and is not recursively expanded, to keep parsing
1015/// bounded and avoid guessing transitive scoping semantics.
1016fn extract_override_dependencies(json: &Value) -> Vec<Dependency> {
1017    json.get(FIELD_OVERRIDES)
1018        .and_then(|overrides| overrides.as_object())
1019        .map_or_else(Vec::new, |overrides_obj| {
1020            let mut dependencies = Vec::new();
1021            let limit = capped_iteration_limit(overrides_obj.len(), "npm: overrides");
1022            for (selector, value) in overrides_obj.iter().take(limit) {
1023                let package_selector = override_package_selector(selector);
1024                match value {
1025                    Value::String(requirement) => {
1026                        if let Some(dep) =
1027                            override_pin_dependency(package_selector, requirement, "overrides")
1028                        {
1029                            dependencies.push(dep);
1030                        }
1031                    }
1032                    Value::Object(nested) => {
1033                        let nested_limit =
1034                            capped_iteration_limit(nested.len(), "npm: nested overrides");
1035                        for (child_key, child_value) in nested.iter().take(nested_limit) {
1036                            // The `.` self-reference pins the parent package itself.
1037                            let target = if child_key == "." {
1038                                package_selector
1039                            } else {
1040                                override_package_selector(child_key)
1041                            };
1042                            if let Some(requirement) = child_value.as_str()
1043                                && let Some(dep) =
1044                                    override_pin_dependency(target, requirement, "overrides")
1045                            {
1046                                dependencies.push(dep);
1047                            }
1048                        }
1049                    }
1050                    _ => {}
1051                }
1052            }
1053            dependencies
1054        })
1055}
1056
1057/// Extracts the package name from an npm `overrides` key.
1058///
1059/// Keys may use the `name@range` form to scope an override to matching
1060/// version ranges; the override still targets `name`, so the `@range` suffix
1061/// is stripped (while preserving the leading `@` of scoped package names).
1062fn override_package_selector(selector: &str) -> &str {
1063    let selector = selector.trim();
1064    if let Some(at_index) = selector.rfind('@') {
1065        // Keep the leading `@` of a scoped package (e.g. `@scope/pkg`), but
1066        // strip a trailing `@range` qualifier (e.g. `pkg@^1` or `@scope/pkg@^1`).
1067        if at_index > 0 {
1068            return &selector[..at_index];
1069        }
1070    }
1071    selector
1072}
1073
1074fn extract_description(json: &Value) -> Option<String> {
1075    json.get(FIELD_DESCRIPTION)
1076        .and_then(|v| v.as_str())
1077        .map(|s| truncate_field(s.to_string()))
1078}
1079
1080fn extract_homepage_url(json: &Value) -> Option<String> {
1081    match json.get(FIELD_HOMEPAGE) {
1082        Some(Value::String(homepage)) => normalize_non_empty_string(homepage).map(truncate_field),
1083        _ => None,
1084    }
1085}
1086
1087fn normalize_non_empty_string(value: &str) -> Option<String> {
1088    let trimmed = value.trim();
1089    if trimmed.is_empty() {
1090        None
1091    } else {
1092        Some(trimmed.to_string())
1093    }
1094}
1095
1096fn normalize_optional_party_url(value: &str) -> Option<String> {
1097    let normalized = normalize_non_empty_string(value)?;
1098
1099    if normalized.eq_ignore_ascii_case("none") {
1100        None
1101    } else {
1102        Some(normalized)
1103    }
1104}
1105
1106fn extract_keywords_as_vec(json: &Value) -> Vec<String> {
1107    json.get(FIELD_KEYWORDS)
1108        .and_then(|v| {
1109            if let Some(str) = v.as_str() {
1110                Some(vec![str.to_string()])
1111            } else if let Some(arr) = v.as_array() {
1112                let limit = capped_iteration_limit(arr.len(), "npm: keywords array");
1113                let keywords: Vec<String> = arr
1114                    .iter()
1115                    .take(limit)
1116                    .filter_map(|kw| kw.as_str())
1117                    .map(|s| truncate_field(s.to_string()))
1118                    .collect();
1119                if keywords.is_empty() {
1120                    None
1121                } else {
1122                    Some(keywords)
1123                }
1124            } else {
1125                None
1126            }
1127        })
1128        .unwrap_or_default()
1129}
1130
1131fn extract_engines(json: &Value) -> Option<serde_json::Value> {
1132    json.get(FIELD_ENGINES).cloned()
1133}
1134
1135fn extract_raw_extra_data_field(json: &Value, field: &str) -> Option<serde_json::Value> {
1136    json.get(field).cloned()
1137}
1138
1139fn extract_package_manager(json: &Value) -> Option<String> {
1140    json.get(FIELD_PACKAGE_MANAGER)
1141        .and_then(|v| v.as_str())
1142        .map(|s| truncate_field(s.to_string()))
1143}
1144
1145fn extract_workspaces(json: &Value) -> Option<serde_json::Value> {
1146    json.get(FIELD_WORKSPACES).cloned()
1147}
1148
1149fn extract_private(json: &Value) -> Option<bool> {
1150    json.get(FIELD_PRIVATE).and_then(|v| v.as_bool())
1151}
1152
1153fn extract_bugs(json: &Value) -> Option<String> {
1154    match json.get(FIELD_BUGS) {
1155        Some(bugs) => {
1156            if let Some(url) = bugs.as_str() {
1157                normalize_non_empty_string(url).map(truncate_field)
1158            } else if let Some(obj) = bugs.as_object() {
1159                obj.get("url")
1160                    .and_then(|v| v.as_str())
1161                    .and_then(normalize_non_empty_string)
1162                    .map(truncate_field)
1163            } else {
1164                None
1165            }
1166        }
1167        None => None,
1168    }
1169}
1170
1171fn extract_dist_hashes(dist: &Value) -> (Option<String>, Option<String>, Option<String>) {
1172    let mut sha1 = dist
1173        .get("shasum")
1174        .and_then(|v| v.as_str())
1175        .and_then(normalize_non_empty_string);
1176    let mut sha256 = None;
1177    let mut sha512 = None;
1178
1179    if let Some(integrity) = dist.get("integrity").and_then(|v| v.as_str())
1180        && let Some((algo, hex_digest)) = parse_sri(integrity)
1181    {
1182        match algo.as_str() {
1183            "sha1" if sha1.is_none() => sha1 = Some(hex_digest),
1184            "sha1" => {}
1185            "sha256" => sha256 = Some(hex_digest),
1186            "sha512" => sha512 = Some(hex_digest),
1187            _ => {}
1188        }
1189    }
1190
1191    (sha1, sha256, sha512)
1192}
1193
1194fn extract_dist_tarball(dist: &Value) -> Option<String> {
1195    dist.get("tarball")
1196        .or_else(|| dist.get("dnl_url"))
1197        .and_then(|v| v.as_str())
1198        .map(normalize_npm_registry_tarball_url)
1199        .map(truncate_field)
1200}
1201
1202fn normalize_npm_registry_tarball_url(url: &str) -> String {
1203    if let Some(path) = url.strip_prefix("http://registry.npmjs.org/") {
1204        format!("https://registry.npmjs.org/{path}")
1205    } else {
1206        url.to_string()
1207    }
1208}