1use crate::models::{
29 DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha1Digest, Sha512Digest,
30};
31use crate::parser_warn as warn;
32use crate::parsers::utils::{
33 MAX_RECURSION_DEPTH, RecursionGuard, capped_iteration_limit, npm_purl, parse_sri,
34 read_file_to_string, truncate_field,
35};
36use serde_json::Value;
37use std::collections::HashMap;
38use std::path::Path;
39
40use super::PackageParser;
41use super::license_normalization::normalize_spdx_declared_license;
42use super::metadata::ParserMetadata;
43
44const FIELD_LOCKFILE_VERSION: &str = "lockfileVersion";
46const FIELD_NAME: &str = "name";
47const FIELD_VERSION: &str = "version";
48const FIELD_DEPENDENCIES: &str = "dependencies";
49const FIELD_PACKAGES: &str = "packages";
50const FIELD_RESOLVED: &str = "resolved";
51const FIELD_INTEGRITY: &str = "integrity";
52const FIELD_DEV: &str = "dev";
53const FIELD_OPTIONAL: &str = "optional";
54const FIELD_DEV_OPTIONAL: &str = "devOptional";
55const FIELD_LINK: &str = "link";
56const FIELD_LICENSE: &str = "license";
57const FIELD_LICENSES: &str = "licenses";
58
59pub struct NpmLockParser;
64
65impl PackageParser for NpmLockParser {
66 const PACKAGE_TYPE: PackageType = PackageType::Npm;
67
68 fn metadata() -> Vec<ParserMetadata> {
69 vec![ParserMetadata {
70 description: "npm package-lock.json lockfile",
71 file_patterns: &[
72 "**/package-lock.json",
73 "**/.package-lock.json",
74 "**/npm-shrinkwrap.json",
75 ],
76 package_type: "npm",
77 primary_language: "JavaScript",
78 documentation_url: Some(
79 "https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json",
80 ),
81 }]
82 }
83
84 fn is_match(path: &Path) -> bool {
85 path.file_name()
86 .and_then(|name| name.to_str())
87 .map(|name| {
88 name == "package-lock.json"
89 || name == ".package-lock.json"
90 || name == "npm-shrinkwrap.json"
91 || name == ".npm-shrinkwrap.json"
92 })
93 .unwrap_or(false)
94 }
95
96 fn extract_packages(path: &Path) -> Vec<PackageData> {
97 let content = match read_file_to_string(path, None) {
98 Ok(content) => content,
99 Err(e) => {
100 warn!("Failed to read package-lock.json at {:?}: {}", path, e);
101 return vec![default_package_data()];
102 }
103 };
104
105 let json: Value = match serde_json::from_str(&content) {
106 Ok(json) => json,
107 Err(e) => {
108 warn!("Failed to parse package-lock.json at {:?}: {}", path, e);
109 return vec![default_package_data()];
110 }
111 };
112
113 let lockfile_version = json
114 .get(FIELD_LOCKFILE_VERSION)
115 .and_then(|v| v.as_i64())
116 .unwrap_or(1);
117
118 let root_name = truncate_field(
119 json.get(FIELD_NAME)
120 .and_then(|v| v.as_str())
121 .unwrap_or("")
122 .to_string(),
123 );
124
125 let root_version = truncate_field(
126 json.get(FIELD_VERSION)
127 .and_then(|v| v.as_str())
128 .unwrap_or("")
129 .to_string(),
130 );
131
132 vec![if lockfile_version == 1 {
133 parse_lockfile_v1(&json, root_name, root_version, lockfile_version)
134 } else {
135 parse_lockfile_v2_plus(&json, root_name, root_version, lockfile_version)
136 }]
137 }
138}
139
140fn default_package_data() -> PackageData {
142 PackageData {
143 package_type: Some(NpmLockParser::PACKAGE_TYPE),
144 datasource_id: Some(DatasourceId::NpmPackageLockJson),
145 ..Default::default()
146 }
147}
148
149fn parse_lockfile_v2_plus(
151 json: &Value,
152 root_name: String,
153 root_version: String,
154 lockfile_version: i64,
155) -> PackageData {
156 let packages = match json.get(FIELD_PACKAGES).and_then(|v| v.as_object()) {
157 Some(packages) => packages,
158 None => {
159 warn!("No 'packages' field found in lockfile v2+");
160 return default_package_data();
161 }
162 };
163
164 let (root_name, root_version) = extract_root_package_identity(json, root_name, root_version);
165 let (namespace, name, version, purl) =
166 normalize_root_package_metadata(&root_name, &root_version);
167 let linked_workspace_names = collect_linked_workspace_names(packages);
168
169 let root_license_statement = packages.get("").and_then(extract_package_license);
171 let (
172 root_declared_license_expression,
173 root_declared_license_expression_spdx,
174 root_license_detections,
175 ) = normalize_spdx_declared_license(root_license_statement.as_deref());
176
177 let mut root_deps = std::collections::HashSet::new();
179
180 if let Some(root_deps_obj) = json.get(FIELD_DEPENDENCIES).and_then(|v| v.as_object()) {
182 let limit = capped_iteration_limit(root_deps_obj.len(), "npm lock: root dependencies");
183 for key in root_deps_obj.keys().take(limit) {
184 root_deps.insert(key.clone());
185 }
186 }
187 if let Some(root_dev_deps_obj) = json.get("devDependencies").and_then(|v| v.as_object()) {
188 let limit =
189 capped_iteration_limit(root_dev_deps_obj.len(), "npm lock: root devDependencies");
190 for key in root_dev_deps_obj.keys().take(limit) {
191 root_deps.insert(key.clone());
192 }
193 }
194 if let Some(root_package) = packages.get("").and_then(|value| value.as_object()) {
195 collect_root_dependency_names(root_package.get(FIELD_DEPENDENCIES), &mut root_deps);
196 collect_root_dependency_names(root_package.get("devDependencies"), &mut root_deps);
197 collect_root_dependency_names(root_package.get("optionalDependencies"), &mut root_deps);
198 }
199
200 let mut dependencies = Vec::new();
201
202 let packages_limit =
203 capped_iteration_limit(packages.len(), "npm lock: lockfile v2/v3 packages");
204 for (key, value) in packages.iter().take(packages_limit) {
205 if key.is_empty() {
207 continue;
208 }
209
210 let install_name = extract_package_name_from_path(key);
212 if install_name.is_empty() {
213 continue;
214 }
215
216 let package_name = value
217 .get(FIELD_NAME)
218 .and_then(|v| v.as_str())
219 .map(str::trim)
220 .filter(|name| !name.is_empty())
221 .map(str::to_string)
222 .or_else(|| linked_workspace_names.get(key).cloned())
223 .unwrap_or_else(|| install_name.clone());
224
225 let version = value
226 .get(FIELD_VERSION)
227 .and_then(|v| v.as_str())
228 .map(|v| truncate_field(v.to_string()));
229
230 let is_dev = value
231 .get(FIELD_DEV)
232 .and_then(|v| v.as_bool())
233 .unwrap_or(false);
234 let is_optional = value
235 .get(FIELD_OPTIONAL)
236 .and_then(|v| v.as_bool())
237 .unwrap_or(false);
238 let is_dev_optional = value
239 .get(FIELD_DEV_OPTIONAL)
240 .and_then(|v| v.as_bool())
241 .unwrap_or(false);
242
243 let resolved = value
244 .get(FIELD_RESOLVED)
245 .and_then(|v| v.as_str())
246 .map(|v| truncate_field(v.to_string()));
247 let integrity = value.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
248 let from = value.get("from").and_then(|v| v.as_str());
249 let in_bundle = value
250 .get("inBundle")
251 .and_then(|v| v.as_bool())
252 .unwrap_or(false);
253 let is_link = value
254 .get(FIELD_LINK)
255 .and_then(|v| v.as_bool())
256 .unwrap_or(false);
257 let is_direct = root_deps.contains(&install_name) && is_direct_dependency_path(key);
258 let license_statement = extract_package_license(value);
259
260 let dependency = match version {
261 Some(version) => build_npm_dependency(
262 &package_name,
263 version,
264 is_dev,
265 is_dev_optional,
266 is_optional,
267 resolved,
268 integrity,
269 is_direct,
270 from,
271 in_bundle,
272 Vec::new(),
273 license_statement,
274 ),
275 None if is_link => build_link_dependency(
276 &package_name,
277 is_dev,
278 is_dev_optional,
279 is_optional,
280 resolved,
281 is_direct,
282 ),
283 None => continue,
284 };
285
286 dependencies.push(dependency);
287 }
288
289 let extra_data = Some(HashMap::from([(
290 "lockfileVersion".to_string(),
291 Value::from(lockfile_version),
292 )]));
293
294 PackageData {
295 package_type: Some(NpmLockParser::PACKAGE_TYPE),
296 namespace: namespace.clone(),
297 name,
298 version,
299 qualifiers: None,
300 subpath: None,
301 primary_language: None,
302 description: None,
303 release_date: None,
304 parties: Vec::new(),
305 keywords: Vec::new(),
306 homepage_url: None,
307 download_url: None,
308 size: None,
309 sha1: None,
310 md5: None,
311 sha256: None,
312 sha512: None,
313 bug_tracking_url: None,
314 code_view_url: None,
315 vcs_url: None,
316 copyright: None,
317 holder: None,
318 declared_license_expression: root_declared_license_expression,
319 declared_license_expression_spdx: root_declared_license_expression_spdx,
320 license_detections: root_license_detections,
321 other_license_expression: None,
322 other_license_expression_spdx: None,
323 other_license_detections: Vec::new(),
324 extracted_license_statement: root_license_statement,
325 notice_text: None,
326 source_packages: Vec::new(),
327 file_references: Vec::new(),
328 is_private: false,
329 is_virtual: false,
330 extra_data,
331 dependencies,
332 repository_homepage_url: None,
333 repository_download_url: None,
334 api_data_url: None,
335 datasource_id: Some(DatasourceId::NpmPackageLockJson),
336 purl,
337 }
338}
339
340fn collect_linked_workspace_names(
341 packages: &serde_json::Map<String, Value>,
342) -> HashMap<String, String> {
343 let mut linked_names = HashMap::new();
344
345 let packages_limit =
346 capped_iteration_limit(packages.len(), "npm lock: linked workspace packages");
347 for (key, value) in packages.iter().take(packages_limit) {
348 let is_link = value
349 .get(FIELD_LINK)
350 .and_then(|v| v.as_bool())
351 .unwrap_or(false);
352 if !is_link {
353 continue;
354 }
355
356 let Some(resolved) = value.get(FIELD_RESOLVED).and_then(|v| v.as_str()) else {
357 continue;
358 };
359
360 let install_name = extract_package_name_from_path(key);
361 if install_name.is_empty() || install_name == key.as_str() {
362 continue;
363 }
364
365 linked_names.insert(resolved.to_string(), install_name);
366 }
367
368 linked_names
369}
370
371fn parse_lockfile_v1(
373 json: &Value,
374 root_name: String,
375 root_version: String,
376 _lockfile_version: i64,
377) -> PackageData {
378 let dependencies_obj = match json.get(FIELD_DEPENDENCIES).and_then(|v| v.as_object()) {
379 Some(deps) => deps,
380 None => {
381 warn!("No 'dependencies' field found in lockfile v1");
382 return default_package_data();
383 }
384 };
385
386 let (namespace, name, version, purl) =
387 normalize_root_package_metadata(&root_name, &root_version);
388
389 let dependencies = parse_dependencies_v1(dependencies_obj);
390
391 PackageData {
392 package_type: Some(NpmLockParser::PACKAGE_TYPE),
393 namespace: namespace.clone(),
394 name,
395 version,
396 qualifiers: None,
397 subpath: None,
398 primary_language: None,
399 description: None,
400 release_date: None,
401 parties: Vec::new(),
402 keywords: Vec::new(),
403 homepage_url: None,
404 download_url: None,
405 size: None,
406 sha1: None,
407 md5: None,
408 sha256: None,
409 sha512: None,
410 bug_tracking_url: None,
411 code_view_url: None,
412 vcs_url: None,
413 copyright: None,
414 holder: None,
415 declared_license_expression: None,
416 declared_license_expression_spdx: None,
417 license_detections: Vec::new(),
418 other_license_expression: None,
419 other_license_expression_spdx: None,
420 other_license_detections: Vec::new(),
421 extracted_license_statement: None,
422 notice_text: None,
423 source_packages: Vec::new(),
424 file_references: Vec::new(),
425 is_private: false,
426 is_virtual: false,
427 extra_data: None,
428 dependencies,
429 repository_homepage_url: None,
430 repository_download_url: None,
431 api_data_url: None,
432 datasource_id: Some(DatasourceId::NpmPackageLockJson),
433 purl,
434 }
435}
436
437fn parse_dependencies_v1(dependencies_obj: &serde_json::Map<String, Value>) -> Vec<Dependency> {
442 let mut guard = RecursionGuard::<()>::depth_only();
443 parse_dependencies_v1_with_depth(dependencies_obj, &mut guard)
444}
445
446fn parse_dependencies_v1_with_depth(
448 dependencies_obj: &serde_json::Map<String, Value>,
449 guard: &mut RecursionGuard<()>,
450) -> Vec<Dependency> {
451 if guard.descend() {
452 warn!(
453 "Max recursion depth {} exceeded in v1 dependency parsing",
454 MAX_RECURSION_DEPTH
455 );
456 return Vec::new();
457 }
458
459 let mut dependencies = Vec::new();
460
461 let limit = capped_iteration_limit(dependencies_obj.len(), "npm lock: v1 dependencies");
462 for (package_name, dep_data) in dependencies_obj.iter().take(limit) {
463 let version = match dep_data.get(FIELD_VERSION).and_then(|v| v.as_str()) {
464 Some(v) => truncate_field(v.to_string()),
465 None => continue,
466 };
467
468 let is_dev = dep_data
469 .get(FIELD_DEV)
470 .and_then(|v| v.as_bool())
471 .unwrap_or(false);
472 let is_optional = dep_data
473 .get(FIELD_OPTIONAL)
474 .and_then(|v| v.as_bool())
475 .unwrap_or(false);
476
477 let resolved = dep_data
478 .get(FIELD_RESOLVED)
479 .and_then(|v| v.as_str())
480 .map(|v| truncate_field(v.to_string()));
481 let integrity = dep_data.get(FIELD_INTEGRITY).and_then(|v| v.as_str());
482 let from = dep_data.get("from").and_then(|v| v.as_str());
483 let in_bundle = dep_data
484 .get("inBundle")
485 .and_then(|v| v.as_bool())
486 .unwrap_or(false);
487
488 let nested_deps = dep_data
489 .get(FIELD_DEPENDENCIES)
490 .and_then(|v| v.as_object())
491 .map(|nested| parse_dependencies_v1_with_depth(nested, guard))
492 .unwrap_or_default();
493
494 let is_direct = guard.depth() == 1;
495
496 let dependency = build_npm_dependency(
497 package_name,
498 version,
499 is_dev,
500 false,
501 is_optional,
502 resolved,
503 integrity,
504 is_direct,
505 from,
506 in_bundle,
507 nested_deps,
508 None,
509 );
510
511 dependencies.push(dependency);
512 }
513
514 guard.ascend();
515 dependencies
516}
517
518fn extract_namespace_and_name(package_name: &str) -> (String, String) {
521 if package_name.starts_with('@') {
522 let parts: Vec<&str> = package_name.splitn(2, '/').collect();
524 if parts.len() == 2 {
525 (parts[0].to_string(), parts[1].to_string())
526 } else {
527 (String::new(), package_name.to_string())
529 }
530 } else {
531 (String::new(), package_name.to_string())
533 }
534}
535
536fn extract_package_name_from_path(path: &str) -> String {
538 if let Some(pos) = path.rfind("node_modules/") {
540 let after_node_modules = &path[pos + "node_modules/".len()..];
541
542 if after_node_modules.starts_with('@') {
544 if let Some(slash_pos) = after_node_modules.find('/') {
546 let scope_and_package = &after_node_modules[..=slash_pos];
547 let remaining = &after_node_modules[slash_pos + 1..];
549 if let Some(next_slash) = remaining.find('/') {
550 return format!("{}{}", scope_and_package, &remaining[..next_slash]);
552 } else {
553 return after_node_modules.to_string();
555 }
556 }
557 } else {
558 if let Some(slash_pos) = after_node_modules.find('/') {
560 return after_node_modules[..slash_pos].to_string();
561 } else {
562 return after_node_modules.to_string();
563 }
564 }
565 }
566
567 path.to_string()
568}
569
570fn create_purl(namespace: &str, name: &str, version: Option<&str>) -> Option<String> {
571 let full_name = if namespace.is_empty() {
572 name.to_string()
573 } else {
574 format!("{}/{}", namespace, name)
575 };
576 npm_purl(&full_name, version.filter(|value| !value.is_empty()))
577}
578
579fn normalize_root_package_metadata(
580 root_name: &str,
581 root_version: &str,
582) -> (
583 Option<String>,
584 Option<String>,
585 Option<String>,
586 Option<String>,
587) {
588 let (namespace, name) = extract_namespace_and_name(root_name);
589 let normalized_name = non_empty_string(&name);
590 let normalized_namespace = normalized_name.as_ref().map(|_| namespace);
591 let normalized_version = normalized_name
592 .as_ref()
593 .and_then(|_| non_empty_string(root_version));
594 let purl = normalized_name.as_deref().and_then(|name| {
595 create_purl(
596 normalized_namespace.as_deref().unwrap_or(""),
597 name,
598 normalized_version.as_deref(),
599 )
600 });
601
602 (
603 normalized_namespace,
604 normalized_name,
605 normalized_version,
606 purl,
607 )
608}
609
610fn extract_root_package_identity(
611 json: &Value,
612 root_name: String,
613 root_version: String,
614) -> (String, String) {
615 let root_package = json
616 .get(FIELD_PACKAGES)
617 .and_then(|value| value.as_object())
618 .and_then(|packages| packages.get(""))
619 .and_then(|value| value.as_object());
620
621 let name = non_empty_string(&root_name).or_else(|| {
622 root_package
623 .and_then(|package| package.get(FIELD_NAME))
624 .and_then(|value| value.as_str())
625 .map(str::to_string)
626 .filter(|value| !value.trim().is_empty())
627 });
628 let version = non_empty_string(&root_version).or_else(|| {
629 root_package
630 .and_then(|package| package.get(FIELD_VERSION))
631 .and_then(|value| value.as_str())
632 .map(str::to_string)
633 .filter(|value| !value.trim().is_empty())
634 });
635
636 (name.unwrap_or_default(), version.unwrap_or_default())
637}
638
639fn extract_package_license(value: &Value) -> Option<String> {
648 if let Some(license) = value.get(FIELD_LICENSE) {
649 if let Some(text) = license.as_str() {
650 return non_empty_string(text).map(truncate_field);
651 }
652 if let Some(type_val) = license.get("type").and_then(|v| v.as_str()) {
653 return non_empty_string(type_val).map(truncate_field);
654 }
655 }
656
657 if let Some(licenses) = value.get(FIELD_LICENSES).and_then(|v| v.as_array()) {
658 let limit = capped_iteration_limit(licenses.len(), "npm lock: package licenses array");
659 let types: Vec<String> = licenses
660 .iter()
661 .take(limit)
662 .filter_map(|entry| entry.get("type").and_then(|v| v.as_str()))
663 .filter_map(non_empty_string)
664 .collect();
665 if !types.is_empty() {
666 let joined = types.join(" OR ");
667 let statement = if types.len() > 1 {
668 format!("({})", joined)
669 } else {
670 joined
671 };
672 return Some(truncate_field(statement));
673 }
674 }
675
676 None
677}
678
679fn non_empty_string(value: &str) -> Option<String> {
680 let trimmed = value.trim();
681 if trimmed.is_empty() {
682 None
683 } else {
684 Some(trimmed.to_string())
685 }
686}
687
688fn collect_root_dependency_names(
689 value: Option<&Value>,
690 root_deps: &mut std::collections::HashSet<String>,
691) {
692 if let Some(entries) = value.and_then(|value| value.as_object()) {
693 let limit = capped_iteration_limit(entries.len(), "npm lock: root dependency name section");
694 for key in entries.keys().take(limit) {
695 root_deps.insert(key.clone());
696 }
697 }
698}
699
700fn is_direct_dependency_path(package_path: &str) -> bool {
701 let node_modules_count = package_path.matches("node_modules/").count();
702
703 match node_modules_count {
704 0 => true,
705 1 => package_path.starts_with("node_modules/") || package_path.starts_with(".pnpm/"),
706 _ => false,
707 }
708}
709
710fn parse_integrity_field(integrity: Option<&str>) -> (Option<String>, Option<String>) {
713 let integrity = match integrity {
714 Some(i) => i,
715 None => return (None, None),
716 };
717
718 match parse_sri(integrity) {
719 Some((algo, hex_digest)) => match algo.as_str() {
720 "sha1" => (Some(hex_digest), None),
721 "sha512" => (None, Some(hex_digest)),
722 _ => (None, None),
723 },
724 None => (None, None),
725 }
726}
727
728fn parse_resolved_url(url: &str) -> Option<String> {
731 if let Some(hash_pos) = url.rfind('#') {
733 let hash = &url[hash_pos + 1..];
734 if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
736 return Some(hash.to_string());
737 }
738 }
739 None
740}
741
742fn determine_scope(
745 is_dev: bool,
746 is_dev_optional: bool,
747 is_optional: bool,
748) -> (&'static str, bool, bool) {
749 if is_dev || is_dev_optional {
750 ("devDependencies", false, true)
751 } else if is_optional {
752 ("dependencies", true, true)
753 } else {
754 ("dependencies", true, false)
755 }
756}
757
758fn parse_npm_alias_spec(version_spec: &str) -> Option<(String, String, String)> {
759 let aliased_spec = version_spec.strip_prefix("npm:")?;
760 let (aliased_name, constraint) = aliased_spec.rsplit_once('@')?;
761 let (namespace, name) = extract_namespace_and_name(aliased_name);
762
763 if name.is_empty() || constraint.trim().is_empty() {
764 None
765 } else {
766 Some((namespace, name, constraint.to_string()))
767 }
768}
769
770fn is_exact_version(version: &str) -> bool {
771 let version = version.trim();
772
773 if version.is_empty() {
774 return false;
775 }
776
777 if version.starts_with('~')
778 || version.starts_with('^')
779 || version.starts_with('>')
780 || version.starts_with('<')
781 || version.starts_with('=')
782 || version.starts_with('*')
783 || version.contains("||")
784 || version.contains(" - ")
785 {
786 return false;
787 }
788
789 !is_non_version_dependency(version)
790}
791
792fn is_non_version_dependency(version: &str) -> bool {
793 let version = version.trim();
794
795 version.starts_with("http://")
796 || version.starts_with("https://")
797 || version.starts_with("git://")
798 || version.starts_with("git+ssh://")
799 || version.starts_with("git+http://")
800 || version.starts_with("git+https://")
801 || version.starts_with("git+file://")
802 || version.starts_with("git@")
803 || version.starts_with("file:")
804 || version.starts_with("link:")
805 || version.starts_with("github:")
806 || version.starts_with("gitlab:")
807 || version.starts_with("bitbucket:")
808 || version.starts_with("gist:")
809}
810
811fn non_version_download_url(version: &str, resolved: Option<&str>) -> Option<String> {
812 resolved
813 .map(str::to_string)
814 .or_else(|| match version.trim() {
815 version if version.starts_with("http://") || version.starts_with("https://") => {
816 Some(version.to_string())
817 }
818 _ => None,
819 })
820}
821
822#[allow(clippy::too_many_arguments)]
824fn build_npm_dependency(
825 package_name: &str,
826 version: String,
827 is_dev: bool,
828 is_dev_optional: bool,
829 is_optional: bool,
830 resolved: Option<String>,
831 integrity: Option<&str>,
832 is_direct: bool,
833 from: Option<&str>,
834 in_bundle: bool,
835 nested_deps: Vec<Dependency>,
836 license_statement: Option<String>,
837) -> Dependency {
838 let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
839 let dep_namespace = truncate_field(dep_namespace);
840 let dep_name = truncate_field(dep_name);
841 let (scope, is_runtime, is_optional_flag) =
842 determine_scope(is_dev, is_dev_optional, is_optional);
843
844 let alias_spec = parse_npm_alias_spec(&version);
845 let (purl_namespace, purl_name, resolved_version, is_pinned, dep_purl, download_url) =
846 if let Some((alias_namespace, alias_name, alias_constraint)) = alias_spec.clone() {
847 let alias_namespace = truncate_field(alias_namespace);
848 let alias_name = truncate_field(alias_name);
849 let alias_constraint = truncate_field(alias_constraint);
850 let is_pinned = is_exact_version(&alias_constraint);
851 let dep_purl = create_purl(
852 &alias_namespace,
853 &alias_name,
854 is_pinned.then_some(alias_constraint.as_str()),
855 );
856 let download_url = non_version_download_url(&alias_constraint, resolved.as_deref());
857
858 (
859 alias_namespace,
860 alias_name,
861 alias_constraint,
862 is_pinned,
863 dep_purl,
864 download_url,
865 )
866 } else {
867 let is_pinned = is_exact_version(&version);
868 let dep_purl = create_purl(
869 &dep_namespace,
870 &dep_name,
871 is_pinned.then_some(version.as_str()),
872 );
873 let download_url = non_version_download_url(&version, resolved.as_deref());
874
875 (
876 dep_namespace.clone(),
877 dep_name.clone(),
878 version.clone(),
879 is_pinned,
880 dep_purl,
881 download_url,
882 )
883 };
884
885 let (sha1_from_integrity, sha512_from_integrity) = parse_integrity_field(integrity);
886 let sha1_from_url = resolved.as_deref().and_then(parse_resolved_url);
887 let sha1 = sha1_from_integrity.or(sha1_from_url);
888
889 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
890 normalize_spdx_declared_license(license_statement.as_deref());
891
892 let mut dep_extra_data = HashMap::new();
893 if let Some(from) = from {
894 dep_extra_data.insert("from".to_string(), Value::String(from.to_string()));
895 }
896 if in_bundle {
897 dep_extra_data.insert("inBundle".to_string(), Value::Bool(true));
898 }
899
900 let resolved_package = ResolvedPackage {
901 primary_language: Some("JavaScript".to_string()),
902 download_url,
903 sha1: sha1.and_then(|h| Sha1Digest::from_hex(&h).ok()),
904 sha256: None,
905 sha512: sha512_from_integrity.and_then(|h| Sha512Digest::from_hex(&h).ok()),
906 md5: None,
907 declared_license_expression,
908 declared_license_expression_spdx,
909 license_detections,
910 extracted_license_statement: license_statement,
911 is_virtual: true,
912 extra_data: None,
913 dependencies: nested_deps,
914 repository_homepage_url: None,
915 repository_download_url: None,
916 api_data_url: None,
917 datasource_id: Some(DatasourceId::NpmPackageLockJson),
918 purl: None,
919 ..ResolvedPackage::new(
920 NpmLockParser::PACKAGE_TYPE,
921 purl_namespace,
922 purl_name,
923 resolved_version,
924 )
925 };
926
927 Dependency {
928 purl: dep_purl,
929 extracted_requirement: Some(truncate_field(version)),
930 scope: Some(scope.to_string()),
931 is_runtime: Some(is_runtime),
932 is_optional: Some(is_optional_flag),
933 is_pinned: Some(is_pinned),
934 is_direct: Some(is_direct),
935 resolved_package: Some(Box::new(resolved_package)),
936 extra_data: (!dep_extra_data.is_empty()).then_some(dep_extra_data),
937 }
938}
939
940fn build_link_dependency(
941 package_name: &str,
942 is_dev: bool,
943 is_dev_optional: bool,
944 is_optional: bool,
945 resolved: Option<String>,
946 is_direct: bool,
947) -> Dependency {
948 let (dep_namespace, dep_name) = extract_namespace_and_name(package_name);
949 let dep_namespace = truncate_field(dep_namespace);
950 let dep_name = truncate_field(dep_name);
951 let (scope, is_runtime, is_optional_flag) =
952 determine_scope(is_dev, is_dev_optional, is_optional);
953 let mut extra_data = HashMap::from([("link".to_string(), Value::Bool(true))]);
954
955 if let Some(resolved) = &resolved {
956 extra_data.insert("resolved".to_string(), Value::String(resolved.clone()));
957 }
958
959 Dependency {
960 purl: create_purl(&dep_namespace, &dep_name, None),
961 extracted_requirement: resolved.map(truncate_field),
962 scope: Some(scope.to_string()),
963 is_runtime: Some(is_runtime),
964 is_optional: Some(is_optional_flag),
965 is_pinned: Some(false),
966 is_direct: Some(is_direct),
967 resolved_package: None,
968 extra_data: Some(extra_data),
969 }
970}