1use crate::models::{
30 DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha512Digest,
31};
32use crate::parser_warn as warn;
33use crate::parsers::utils::{capped_iteration_limit, npm_purl, parse_sri, truncate_field};
34use serde_json::Value as JsonValue;
35use std::collections::{HashMap, HashSet};
36use std::path::Path;
37use yaml_serde::Value;
38
39use super::PackageParser;
40
41pub struct YarnLockParser;
45
46#[derive(Clone, Debug, PartialEq, Eq)]
47struct ManifestDependencyInfo {
48 scope: &'static str,
49 is_runtime: bool,
50 is_optional: bool,
51}
52
53impl PackageParser for YarnLockParser {
54 const PACKAGE_TYPE: PackageType = PackageType::Npm;
55
56 fn is_match(path: &Path) -> bool {
57 path.file_name()
58 .and_then(|name| name.to_str())
59 .map(|name| name == "yarn.lock")
60 .unwrap_or(false)
61 }
62
63 fn extract_packages(path: &Path) -> Vec<PackageData> {
64 let content = match crate::parsers::utils::read_file_to_string(path, None) {
65 Ok(content) => content,
66 Err(e) => {
67 warn!("Failed to read yarn.lock at {:?}: {}", path, e);
68 return vec![default_package_data(Some(DatasourceId::YarnLock))];
69 }
70 };
71
72 let is_v2 = detect_yarn_version(&content);
73 let manifest_dependencies = load_manifest_dependency_info(path);
74
75 vec![if is_v2 {
76 parse_yarn_v2(&content, &manifest_dependencies)
77 } else {
78 parse_yarn_v1(&content, &manifest_dependencies)
79 }]
80 }
81
82 fn metadata() -> Vec<super::metadata::ParserMetadata> {
83 vec![super::metadata::ParserMetadata {
84 description: "yarn.lock lockfile (v1 and v2+)",
85 file_patterns: &["**/yarn.lock"],
86 package_type: "npm",
87 primary_language: "JavaScript",
88 documentation_url: Some("https://classic.yarnpkg.com/lang/en/docs/yarn-lock/"),
89 }]
90 }
91}
92
93pub fn detect_yarn_version(content: &str) -> bool {
95 content
96 .lines()
97 .take(10)
98 .any(|line| line.contains("__metadata:"))
99}
100
101fn parse_yarn_v2(
103 content: &str,
104 manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
105) -> PackageData {
106 let yaml_value: Value = match yaml_serde::from_str(content) {
107 Ok(val) => val,
108 Err(e) => {
109 warn!("Failed to parse yarn.lock v2 YAML: {}", e);
110 return default_package_data(Some(DatasourceId::YarnLockV2));
111 }
112 };
113
114 let yaml_map = match yaml_value.as_mapping() {
115 Some(map) => map,
116 None => return default_package_data(Some(DatasourceId::YarnLockV2)),
117 };
118
119 let mut dependencies = Vec::new();
120 let package_extra_data = extract_yarn_v2_package_extra_data(yaml_map);
121
122 let limit = capped_iteration_limit(yaml_map.len(), "yarn.lock v2 entries");
123 for (spec, details) in yaml_map.iter().take(limit) {
124 if spec.as_str().map(|s| s == "__metadata").unwrap_or(false) {
125 continue;
126 }
127
128 let _spec_str = match spec.as_str() {
129 Some(s) => s,
130 None => continue,
131 };
132
133 let details_map = match details.as_mapping() {
134 Some(map) => map,
135 None => continue,
136 };
137
138 let resolution = extract_yaml_string(details_map, "resolution").unwrap_or_default();
139
140 let (namespace_opt, name, resolved_version) = parse_yarn_v2_resolution(&resolution);
141 let namespace = namespace_opt.map(truncate_field).unwrap_or_default();
142 let name = truncate_field(name);
143 let resolved_version = truncate_field(resolved_version);
144 let full_name = full_package_name(&namespace, &name);
145 let manifest_info = manifest_dependencies.get(&full_name);
146 let purl = create_purl(&namespace, &name, &resolved_version).map(truncate_field);
147 let checksum = extract_yaml_string(details_map, "checksum").map(truncate_field);
148
149 let deps_yaml = details_map.get("dependencies");
150 let peer_deps_yaml = details_map.get("peerDependencies");
151 let resolved_extra_data = extract_yarn_v2_resolved_extra_data(details_map, &resolution);
152
153 let nested_deps = parse_yaml_dependencies(deps_yaml);
154 let peer_deps = parse_yaml_dependencies(peer_deps_yaml);
155
156 let all_deps = if peer_deps.is_empty() {
157 nested_deps
158 } else {
159 let mut combined = nested_deps;
160 for mut dep in peer_deps {
161 dep.scope = Some("peerDependencies".to_string());
162 dep.is_optional = Some(true);
163 dep.is_runtime = Some(false);
164 combined.push(dep);
165 }
166 combined
167 };
168
169 let resolved_package = ResolvedPackage {
170 primary_language: Some("JavaScript".to_string()),
171 download_url: None,
172 sha1: None,
173 sha256: None,
174 sha512: checksum.and_then(|h| Sha512Digest::from_hex(&h).ok()),
175 md5: None,
176 is_virtual: true,
177 extra_data: resolved_extra_data,
178 dependencies: all_deps,
179 repository_homepage_url: None,
180 repository_download_url: None,
181 api_data_url: None,
182 datasource_id: Some(DatasourceId::YarnLockV2),
183 purl: None,
184 ..ResolvedPackage::new(
185 YarnLockParser::PACKAGE_TYPE,
186 namespace.clone(),
187 name.clone(),
188 resolved_version.clone(),
189 )
190 };
191
192 let (scope, is_runtime, is_optional, is_direct) = manifest_info
193 .map(|info| {
194 (
195 Some(info.scope.to_string()),
196 Some(info.is_runtime),
197 Some(info.is_optional),
198 Some(true),
199 )
200 })
201 .unwrap_or((None, None, None, None));
202
203 let dependency = Dependency {
204 purl,
205 extracted_requirement: Some(truncate_field(resolved_version.clone())),
206 scope,
207 is_runtime,
208 is_optional,
209 is_pinned: Some(true),
210 is_direct,
211 resolved_package: Some(Box::new(resolved_package)),
212 extra_data: Some(HashMap::from([(
213 "resolution".to_string(),
214 JsonValue::String(truncate_field(resolution)),
215 )])),
216 };
217
218 dependencies.push(dependency);
219 }
220
221 PackageData {
222 package_type: Some(YarnLockParser::PACKAGE_TYPE),
223 namespace: None,
224 name: None,
225 version: None,
226 qualifiers: None,
227 subpath: None,
228 primary_language: None,
229 description: None,
230 release_date: None,
231 parties: Vec::new(),
232 keywords: Vec::new(),
233 homepage_url: None,
234 download_url: None,
235 size: None,
236 sha1: None,
237 md5: None,
238 sha256: None,
239 sha512: None,
240 bug_tracking_url: None,
241 code_view_url: None,
242 vcs_url: None,
243 copyright: None,
244 holder: None,
245 declared_license_expression: None,
246 declared_license_expression_spdx: None,
247 license_detections: Vec::new(),
248 other_license_expression: None,
249 other_license_expression_spdx: None,
250 other_license_detections: Vec::new(),
251 extracted_license_statement: None,
252 notice_text: None,
253 source_packages: Vec::new(),
254 file_references: Vec::new(),
255 is_private: false,
256 is_virtual: false,
257 extra_data: package_extra_data,
258 dependencies,
259 repository_homepage_url: None,
260 repository_download_url: None,
261 api_data_url: None,
262 datasource_id: Some(DatasourceId::YarnLockV2),
263 purl: None,
264 }
265}
266
267fn parse_yarn_v1(
269 content: &str,
270 manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
271) -> PackageData {
272 let mut dependencies = Vec::new();
273 let mut seen_purls = HashSet::new();
274
275 let block_count = content.split("\n\n").count();
276 let limit = capped_iteration_limit(block_count, "yarn.lock v1 blocks");
277 for block in content.split("\n\n").take(limit) {
278 if is_empty_or_comment_block(block) {
279 continue;
280 }
281
282 if let Some(dep) = parse_yarn_v1_block(block, manifest_dependencies) {
283 if let Some(ref purl) = dep.purl {
284 if seen_purls.insert(purl.clone()) {
285 dependencies.push(dep);
286 }
287 } else {
288 dependencies.push(dep);
289 }
290 }
291 }
292
293 PackageData {
294 package_type: Some(YarnLockParser::PACKAGE_TYPE),
295 namespace: None,
296 name: None,
297 version: None,
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: None,
318 declared_license_expression_spdx: None,
319 license_detections: Vec::new(),
320 other_license_expression: None,
321 other_license_expression_spdx: None,
322 other_license_detections: Vec::new(),
323 extracted_license_statement: None,
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: None,
330 dependencies,
331 repository_homepage_url: None,
332 repository_download_url: None,
333 api_data_url: None,
334 datasource_id: Some(DatasourceId::YarnLockV1),
335 purl: None,
336 }
337}
338
339fn is_empty_or_comment_block(block: &str) -> bool {
340 block
341 .lines()
342 .all(|line| line.trim().is_empty() || line.trim().starts_with('#'))
343}
344
345fn parse_integrity_field(integrity: &str) -> Option<String> {
347 parse_sri(integrity).and_then(|(algo, hex_digest)| {
348 if algo == "sha512" {
349 Some(hex_digest)
350 } else {
351 None
352 }
353 })
354}
355
356pub fn extract_namespace_and_name(package_name: &str) -> (String, String) {
358 if package_name.starts_with('@') {
359 let parts: Vec<&str> = package_name.splitn(2, '/').collect();
360 if parts.len() == 2 {
361 (parts[0].to_string(), parts[1].to_string())
362 } else {
363 (String::new(), package_name.to_string())
364 }
365 } else {
366 (String::new(), package_name.to_string())
367 }
368}
369
370fn create_purl(namespace: &str, name: &str, version: &str) -> Option<String> {
371 let full_name = if namespace.is_empty() {
372 name.to_string()
373 } else {
374 format!("{}/{}", namespace, name)
375 };
376 let version_opt = if version.is_empty() {
377 None
378 } else {
379 Some(version)
380 };
381 npm_purl(&full_name, version_opt)
382}
383
384fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
385 PackageData {
386 package_type: Some(YarnLockParser::PACKAGE_TYPE),
387 datasource_id,
388 ..Default::default()
389 }
390}
391
392fn parse_yarn_v1_block(
394 block: &str,
395 manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
396) -> Option<Dependency> {
397 let lines: Vec<&str> = block.lines().collect();
398 if lines.is_empty() {
399 return None;
400 }
401
402 let requirement_index = lines.iter().position(|line| {
403 let trimmed = line.trim();
404 !trimmed.is_empty() && !trimmed.starts_with('#')
405 })?;
406
407 let requirement_line = lines[requirement_index]
408 .trim()
409 .strip_suffix(':')
410 .unwrap_or_else(|| lines[requirement_index].trim())
411 .trim_matches('"');
412 if requirement_line.is_empty() {
413 return None;
414 }
415
416 let (namespace, name, constraint) = parse_yarn_v1_requirement(requirement_line);
417 let namespace = truncate_field(namespace);
418 let name = truncate_field(name);
419 let constraint = truncate_field(constraint);
420
421 if name.is_empty() {
422 return None;
423 }
424
425 let mut version = String::new();
426 let mut resolved_url = String::new();
427 let mut integrity = String::new();
428 let mut nested_deps = Vec::new();
429
430 for line in &lines[requirement_index + 1..] {
431 let trimmed = line.trim();
432 if trimmed.is_empty() {
433 continue;
434 }
435
436 if trimmed.starts_with("version") {
437 version = truncate_field(extract_quoted_value(trimmed).unwrap_or_default());
438 } else if trimmed.starts_with("resolved") {
439 resolved_url = truncate_field(extract_quoted_value(trimmed).unwrap_or_default());
440 } else if trimmed.starts_with("integrity") {
441 integrity = truncate_field(
442 trimmed
443 .strip_prefix("integrity")
444 .map(|s| s.trim().to_string())
445 .unwrap_or_default(),
446 );
447 } else if trimmed.starts_with("dependencies") {
448 continue;
450 } else if trimmed.starts_with(" ") && !trimmed.starts_with(" ") {
451 let dep_line = trimmed.trim();
453 if let Some(dep) = parse_yarn_v1_dependency_line(dep_line, &namespace, &name, &version)
454 {
455 nested_deps.push(dep);
456 }
457 }
458 }
459
460 let sha512 = if integrity.is_empty() {
461 None
462 } else {
463 parse_integrity_field(&integrity)
464 };
465
466 let full_name = full_package_name(&namespace, &name);
467 let manifest_info = manifest_dependencies.get(&full_name);
468 let purl = create_purl(&namespace, &name, &version).map(truncate_field);
469 let (scope, is_runtime, is_optional, is_direct) = manifest_info
470 .map(|info| {
471 (
472 Some(info.scope.to_string()),
473 Some(info.is_runtime),
474 Some(info.is_optional),
475 Some(true),
476 )
477 })
478 .unwrap_or((None, None, None, None));
479
480 let resolved_package = ResolvedPackage {
481 primary_language: Some("JavaScript".to_string()),
482 download_url: if resolved_url.is_empty() {
483 None
484 } else {
485 Some(resolved_url)
486 },
487 sha1: None,
488 sha256: None,
489 sha512: sha512.and_then(|h| Sha512Digest::from_hex(&h).ok()),
490 md5: None,
491 is_virtual: true,
492 extra_data: None,
493 dependencies: nested_deps,
494 repository_homepage_url: None,
495 repository_download_url: None,
496 api_data_url: None,
497 datasource_id: Some(DatasourceId::YarnLockV1),
498 purl: None,
499 ..ResolvedPackage::new(
500 YarnLockParser::PACKAGE_TYPE,
501 namespace.clone(),
502 name.clone(),
503 version.clone(),
504 )
505 };
506
507 Some(Dependency {
508 purl,
509 extracted_requirement: Some(constraint),
510 scope,
511 is_runtime,
512 is_optional,
513 is_pinned: Some(true),
514 is_direct,
515 resolved_package: Some(Box::new(resolved_package)),
516 extra_data: None,
517 })
518}
519
520fn full_package_name(namespace: &str, name: &str) -> String {
521 if namespace.is_empty() {
522 name.to_string()
523 } else {
524 format!("{namespace}/{name}")
525 }
526}
527
528fn load_manifest_dependency_info(path: &Path) -> HashMap<String, ManifestDependencyInfo> {
529 let Some(parent) = path.parent() else {
530 return HashMap::new();
531 };
532
533 let manifest_path = parent.join("package.json");
534 let Ok(content) = crate::parsers::utils::read_file_to_string(&manifest_path, None) else {
535 return HashMap::new();
536 };
537
538 let Ok(json) = serde_json::from_str::<JsonValue>(&content) else {
539 return HashMap::new();
540 };
541
542 let peer_optional = json
543 .get("peerDependenciesMeta")
544 .and_then(|value| value.as_object())
545 .map(|meta| {
546 meta.iter()
547 .filter_map(|(name, value)| {
548 value
549 .as_object()
550 .and_then(|entry| entry.get("optional"))
551 .and_then(|value| value.as_bool())
552 .map(|optional| (name.clone(), optional))
553 })
554 .collect::<HashMap<_, _>>()
555 })
556 .unwrap_or_default();
557
558 let mut dependencies = HashMap::new();
559 insert_manifest_dependency_info(
560 &mut dependencies,
561 &json,
562 "dependencies",
563 ManifestDependencyInfo {
564 scope: "dependencies",
565 is_runtime: true,
566 is_optional: false,
567 },
568 );
569 insert_manifest_dependency_info(
570 &mut dependencies,
571 &json,
572 "devDependencies",
573 ManifestDependencyInfo {
574 scope: "devDependencies",
575 is_runtime: false,
576 is_optional: true,
577 },
578 );
579 insert_manifest_dependency_info(
580 &mut dependencies,
581 &json,
582 "optionalDependencies",
583 ManifestDependencyInfo {
584 scope: "optionalDependencies",
585 is_runtime: true,
586 is_optional: true,
587 },
588 );
589
590 if let Some(peer_dependencies) = json
591 .get("peerDependencies")
592 .and_then(|value| value.as_object())
593 {
594 let limit =
595 capped_iteration_limit(peer_dependencies.len(), "yarn manifest peerDependencies");
596 for name in peer_dependencies.keys().take(limit) {
597 dependencies.insert(
598 name.clone(),
599 ManifestDependencyInfo {
600 scope: "peerDependencies",
601 is_runtime: false,
602 is_optional: peer_optional.get(name).copied().unwrap_or(false),
603 },
604 );
605 }
606 }
607
608 dependencies
609}
610
611fn insert_manifest_dependency_info(
612 dependencies: &mut HashMap<String, ManifestDependencyInfo>,
613 json: &JsonValue,
614 field: &str,
615 info: ManifestDependencyInfo,
616) {
617 if let Some(entries) = json.get(field).and_then(|value| value.as_object()) {
618 let context = format!("yarn manifest {field}");
619 let limit = capped_iteration_limit(entries.len(), &context);
620 for name in entries.keys().take(limit) {
621 dependencies.insert(name.clone(), info.clone());
622 }
623 }
624}
625
626pub fn parse_yarn_v1_requirement(line: &str) -> (String, String, String) {
628 if line.contains(", ") {
630 let first_part = line.split(", ").next().unwrap_or(line);
632 return parse_single_yarn_v1_requirement(first_part);
633 }
634 parse_single_yarn_v1_requirement(line)
635}
636
637fn parse_single_yarn_v1_requirement(line: &str) -> (String, String, String) {
639 if let Some(at_pos) = line.rfind('@') {
640 let name_part = &line[..at_pos];
641 let constraint = &line[at_pos + 1..];
642
643 let (namespace, name) = resolve_npm_alias_name(name_part);
648
649 if !name.is_empty() {
650 return (namespace, name, constraint.to_string());
651 }
652 }
653
654 (String::new(), String::new(), String::new())
655}
656
657fn resolve_npm_alias_name(name_part: &str) -> (String, String) {
665 if let Some(npm_idx) = name_part.find("@npm:") {
666 let aliased_target = &name_part[npm_idx + "@npm:".len()..];
667 if !aliased_target.is_empty() {
668 return extract_namespace_and_name(aliased_target);
669 }
670 }
671
672 extract_namespace_and_name(name_part)
673}
674
675fn parse_yarn_v1_dependency_line(
677 line: &str,
678 _parent_namespace: &str,
679 _parent_name: &str,
680 parent_version: &str,
681) -> Option<Dependency> {
682 let trimmed = line.trim_matches('"');
683 if !trimmed.contains('@') {
684 return None;
685 }
686
687 let (namespace, name, constraint) = parse_yarn_v1_requirement(trimmed);
688 let namespace = truncate_field(namespace);
689 let name = truncate_field(name);
690 let constraint = truncate_field(constraint);
691
692 let purl = create_purl(&namespace, &name, parent_version).map(truncate_field);
693
694 Some(Dependency {
695 purl,
696 extracted_requirement: Some(constraint),
697 scope: Some("dependencies".to_string()),
698 is_runtime: Some(true),
699 is_optional: Some(false),
700 is_pinned: Some(false),
701 is_direct: Some(false),
702 resolved_package: None,
703 extra_data: None,
704 })
705}
706
707fn extract_quoted_value(line: &str) -> Option<String> {
709 line.find('"').and_then(|start| {
710 let rest = &line[start + 1..];
711 rest.find('"').map(|end| rest[..end].to_string())
712 })
713}
714
715pub fn parse_yarn_v2_resolution(resolution: &str) -> (Option<String>, String, String) {
717 if resolution.contains("@npm:") {
718 let parts: Vec<&str> = resolution.split("@npm:").collect();
719 if parts.len() == 2 {
720 let package_name = parts[0];
721 let version = parts[1];
722 let (namespace, name) = extract_namespace_and_name(package_name);
723 let namespace_opt = if namespace.is_empty() {
724 None
725 } else {
726 Some(namespace)
727 };
728 return (namespace_opt, name, version.to_string());
729 }
730 }
731
732 if let Some((ident, reference)) = split_yarn_locator(resolution) {
733 let (namespace, name) = extract_namespace_and_name(ident);
734 let namespace_opt = if namespace.is_empty() {
735 None
736 } else {
737 Some(namespace)
738 };
739 return (namespace_opt, name, reference.to_string());
740 }
741
742 let (namespace, name) = extract_namespace_and_name(resolution);
743 let namespace_opt = if namespace.is_empty() {
744 None
745 } else {
746 Some(namespace)
747 };
748 (namespace_opt, name, "*".to_string())
749}
750
751fn split_yarn_locator(resolution: &str) -> Option<(&str, &str)> {
752 if resolution.is_empty() {
753 return None;
754 }
755
756 let separator_index = if resolution.starts_with('@') {
757 let slash_index = resolution.find('/')?;
758 let rest = &resolution[slash_index + 1..];
759 let at_index = rest.find('@')?;
760 slash_index + 1 + at_index
761 } else {
762 resolution.find('@')?
763 };
764
765 let ident = &resolution[..separator_index];
766 let reference = &resolution[separator_index + 1..];
767
768 if ident.is_empty() || reference.is_empty() {
769 None
770 } else {
771 Some((ident, reference))
772 }
773}
774
775fn extract_yarn_v2_package_extra_data(
776 yaml_map: &yaml_serde::Mapping,
777) -> Option<HashMap<String, JsonValue>> {
778 let metadata = yaml_map.get("__metadata")?.as_mapping()?;
779 let mut extra_data = HashMap::new();
780
781 for field in ["version", "cacheKey"] {
782 if let Some(value) = metadata.get(field).and_then(yaml_value_to_json) {
783 extra_data.insert(field.to_string(), value);
784 }
785 }
786
787 (!extra_data.is_empty()).then_some(extra_data)
788}
789
790fn extract_yarn_v2_resolved_extra_data(
791 details_map: &yaml_serde::Mapping,
792 resolution: &str,
793) -> Option<HashMap<String, JsonValue>> {
794 let mut extra_data = HashMap::new();
795 extra_data.insert(
796 "resolution".to_string(),
797 JsonValue::String(truncate_field(resolution.to_string())),
798 );
799
800 for field in ["languageName", "linkType", "bin", "dependenciesMeta"] {
801 if let Some(value) = details_map.get(field).and_then(yaml_value_to_json) {
802 extra_data.insert(field.to_string(), value);
803 }
804 }
805
806 Some(extra_data)
807}
808
809fn yaml_value_to_json(value: &Value) -> Option<JsonValue> {
810 serde_json::to_value(value).ok()
811}
812
813fn extract_yaml_string(map: &yaml_serde::Mapping, key: &str) -> Option<String> {
815 map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
816}
817
818fn parse_yaml_dependencies(yaml_value: Option<&Value>) -> Vec<Dependency> {
820 let mut dependencies = Vec::new();
821
822 if let Some(deps_value) = yaml_value
823 && let Some(mapping) = deps_value.as_mapping()
824 {
825 let limit = capped_iteration_limit(mapping.len(), "yarn.lock v2 nested dependencies");
826 for (key, value) in mapping.iter().take(limit) {
827 let name = match key.as_str() {
828 Some(s) => s.to_string(),
829 None => continue,
830 };
831
832 let constraint = match value.as_str() {
833 Some(s) => s.to_string(),
834 None => "*".to_string(),
835 };
836
837 let (namespace, dep_name) = extract_namespace_and_name(&name);
838 let namespace = truncate_field(namespace);
839 let dep_name = truncate_field(dep_name);
840 let constraint = truncate_field(constraint);
841 let purl = create_purl(&namespace, &dep_name, &constraint).map(truncate_field);
842
843 dependencies.push(Dependency {
844 purl,
845 extracted_requirement: Some(constraint),
846 scope: Some("dependencies".to_string()),
847 is_runtime: Some(true),
848 is_optional: Some(false),
849 is_pinned: Some(false),
850 is_direct: Some(false),
851 resolved_package: None,
852 extra_data: None,
853 });
854 }
855 }
856 dependencies
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use std::path::PathBuf;
863
864 #[test]
865 fn test_is_match_yarn_lock() {
866 let valid_path = PathBuf::from("/some/path/yarn.lock");
867 assert!(YarnLockParser::is_match(&valid_path));
868 }
869
870 #[test]
871 fn test_is_not_match_package_json() {
872 let invalid_path = PathBuf::from("/some/path/package.json");
873 assert!(!YarnLockParser::is_match(&invalid_path));
874 }
875
876 #[test]
877 fn test_detect_yarn_v2() {
878 let content = r#"# This file is generated by running "yarn install"
879__metadata:
880 version: 6
881"#;
882 assert!(detect_yarn_version(content));
883 }
884
885 #[test]
886 fn test_detect_yarn_v1() {
887 let content = r#"# THIS IS AN AUTOGENERATED FILE
888# yarn lockfile v1
889
890abbrev@1:
891 version "1.0.9"
892"#;
893 assert!(!detect_yarn_version(content));
894 }
895
896 #[test]
897 fn test_parse_yarn_v2_uses_yarn_lock_v2_datasource_ids() {
898 let content = r#"# This file is generated by running \"yarn install\"
899__metadata:
900 version: 6
901
902lodash@npm:^4.17.21:
903 version: 4.17.21
904 resolution: "lodash@npm:4.17.21"
905"#;
906
907 let package_data = parse_yarn_v2(content, &HashMap::new());
908
909 assert_eq!(package_data.datasource_id, Some(DatasourceId::YarnLockV2));
910 assert_eq!(
911 package_data.dependencies[0]
912 .resolved_package
913 .as_ref()
914 .and_then(|pkg| pkg.datasource_id),
915 Some(DatasourceId::YarnLockV2)
916 );
917 }
918
919 #[test]
920 fn test_parse_yarn_v1_uses_yarn_lock_v1_datasource_ids() {
921 let content = r#"# THIS IS AN AUTOGENERATED FILE
922# yarn lockfile v1
923
924left-pad@^1.3.0:
925 version \"1.3.0\"
926"#;
927
928 let package_data = parse_yarn_v1(content, &HashMap::new());
929
930 assert_eq!(package_data.datasource_id, Some(DatasourceId::YarnLockV1));
931 assert_eq!(
932 package_data.dependencies[0]
933 .resolved_package
934 .as_ref()
935 .and_then(|pkg| pkg.datasource_id),
936 Some(DatasourceId::YarnLockV1)
937 );
938 }
939
940 #[test]
941 fn test_extract_namespace_and_name_scoped() {
942 let (namespace, name) = extract_namespace_and_name("@types/node");
943 assert_eq!(namespace, "@types");
944 assert_eq!(name, "node");
945 }
946
947 #[test]
948 fn test_extract_namespace_and_name_regular() {
949 let (namespace, name) = extract_namespace_and_name("express");
950 assert_eq!(namespace, "");
951 assert_eq!(name, "express");
952 }
953
954 #[test]
955 fn test_parse_yarn_v1_requirement() {
956 let (namespace, name, constraint) = parse_yarn_v1_requirement("express@^4.0.0");
957 assert_eq!(namespace, "");
958 assert_eq!(name, "express");
959 assert_eq!(constraint, "^4.0.0");
960 }
961
962 #[test]
963 fn test_parse_yarn_v1_requirement_scoped() {
964 let (namespace, name, constraint) = parse_yarn_v1_requirement("@types/node@^18.0.0");
965 assert_eq!(namespace, "@types");
966 assert_eq!(name, "node");
967 assert_eq!(constraint, "^18.0.0");
968 }
969
970 #[test]
971 fn test_parse_yarn_v2_resolution() {
972 let (namespace, name, version) = parse_yarn_v2_resolution("@actions/core@npm:1.2.6");
973 assert_eq!(namespace, Some("@actions".to_string()));
974 assert_eq!(name, "core");
975 assert_eq!(version, "1.2.6");
976 }
977
978 #[test]
979 fn test_parse_yarn_v1_requirement_npm_alias_scoped_target() {
980 let (namespace, name, constraint) =
983 parse_yarn_v1_requirement("react-loadable@npm:@docusaurus/react-loadable@6.0.0");
984 assert_eq!(namespace, "@docusaurus");
985 assert_eq!(name, "react-loadable");
986 assert_eq!(constraint, "6.0.0");
987 }
988
989 #[test]
990 fn test_parse_yarn_v1_requirement_npm_alias_renames_scope() {
991 let (namespace, name, constraint) =
992 parse_yarn_v1_requirement("ajv@npm:@redocly/ajv@8.18.0");
993 assert_eq!(namespace, "@redocly");
994 assert_eq!(name, "ajv");
995 assert_eq!(constraint, "8.18.0");
996 }
997
998 #[test]
999 fn test_parse_yarn_v1_requirement_npm_alias_unscoped_target() {
1000 let (namespace, name, constraint) =
1003 parse_yarn_v1_requirement("string-width@npm:string-width-cjs@4.2.3");
1004 assert_eq!(namespace, "");
1005 assert_eq!(name, "string-width-cjs");
1006 assert_eq!(constraint, "4.2.3");
1007 }
1008
1009 #[test]
1010 fn test_parse_yarn_v1_block_npm_alias_emits_aliased_purl() {
1011 let content = r#"# THIS IS AN AUTOGENERATED FILE
1012# yarn lockfile v1
1013
1014"react-loadable@npm:@docusaurus/react-loadable@6.0.0":
1015 version "6.0.0"
1016 resolved "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz"
1017 integrity sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==
1018"#;
1019
1020 let package_data = parse_yarn_v1(content, &HashMap::new());
1021
1022 assert_eq!(package_data.dependencies.len(), 1);
1023 let dep = &package_data.dependencies[0];
1024 assert_eq!(
1025 dep.purl.as_deref(),
1026 Some("pkg:npm/%40docusaurus/react-loadable@6.0.0")
1027 );
1028 let resolved = dep
1029 .resolved_package
1030 .as_ref()
1031 .expect("aliased dependency should carry a resolved package");
1032 assert_eq!(resolved.namespace, "@docusaurus");
1033 assert_eq!(resolved.name, "react-loadable");
1034 assert_eq!(resolved.version, "6.0.0");
1035 }
1036}