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