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