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