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