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