1use 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
75pub 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
245fn read_and_parse_json_with_lines(path: &Path) -> Result<(Value, HashMap<String, usize>), String> {
247 let content = crate::parsers::utils::read_file_to_string(path, None)
249 .map_err(|e| format!("Failed to read file: {}", e))?;
250
251 let json: Value =
253 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
254
255 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
271fn extract_field_name(line: &str) -> Option<String> {
273 let line = line.trim();
275 if line.is_empty() || !line.starts_with("\"") {
276 return None;
277 }
278
279 let mut chars = line.chars();
281 chars.next(); 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 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
374fn 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
441fn 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
496fn extract_parties(json: &Value) -> Vec<Party> {
498 let mut parties = Vec::new();
499
500 if let Some(author) = json.get(FIELD_AUTHOR) {
502 if let Some(author_list) = extract_parties_from_array(author) {
503 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 party.role = Some("author".to_string());
513 parties.push(party);
514 }
515 }
516
517 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 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
544fn 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
599fn 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
615fn 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
626fn 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
802fn 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
830fn 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
854fn 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 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
880fn 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
909fn 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 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
936fn 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 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
997fn 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 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
1057fn override_package_selector(selector: &str) -> &str {
1063 let selector = selector.trim();
1064 if let Some(at_index) = selector.rfind('@') {
1065 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}