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