1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
24use crate::parsers::utils::{npm_purl, parse_sri};
25use log::warn;
26use serde_json::Value as JsonValue;
27use serde_yaml::Value;
28use std::collections::{HashMap, HashSet};
29use std::fs;
30use std::path::Path;
31
32use super::PackageParser;
33
34pub struct YarnLockParser;
38
39#[derive(Clone, Debug, PartialEq, Eq)]
40struct ManifestDependencyInfo {
41 scope: &'static str,
42 is_runtime: bool,
43 is_optional: bool,
44}
45
46impl PackageParser for YarnLockParser {
47 const PACKAGE_TYPE: PackageType = PackageType::Npm;
48
49 fn is_match(path: &Path) -> bool {
50 path.file_name()
51 .and_then(|name| name.to_str())
52 .map(|name| name == "yarn.lock")
53 .unwrap_or(false)
54 }
55
56 fn extract_packages(path: &Path) -> Vec<PackageData> {
57 let content = match fs::read_to_string(path) {
58 Ok(content) => content,
59 Err(e) => {
60 warn!("Failed to read yarn.lock at {:?}: {}", path, e);
61 return vec![default_package_data()];
62 }
63 };
64
65 let is_v2 = detect_yarn_version(&content);
66 let manifest_dependencies = load_manifest_dependency_info(path);
67
68 vec![if is_v2 {
69 parse_yarn_v2(&content, &manifest_dependencies)
70 } else {
71 parse_yarn_v1(&content, &manifest_dependencies)
72 }]
73 }
74}
75
76pub fn detect_yarn_version(content: &str) -> bool {
78 content
79 .lines()
80 .take(10)
81 .any(|line| line.contains("__metadata:"))
82}
83
84fn parse_yarn_v2(
86 content: &str,
87 manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
88) -> PackageData {
89 let yaml_value: Value = match serde_yaml::from_str(content) {
90 Ok(val) => val,
91 Err(e) => {
92 warn!("Failed to parse yarn.lock v2 YAML: {}", e);
93 return default_package_data();
94 }
95 };
96
97 let yaml_map = match yaml_value.as_mapping() {
98 Some(map) => map,
99 None => return default_package_data(),
100 };
101
102 let mut dependencies = Vec::new();
103
104 for (spec, details) in yaml_map {
105 if spec.as_str().map(|s| s == "__metadata").unwrap_or(false) {
106 continue;
107 }
108
109 let _spec_str = match spec.as_str() {
110 Some(s) => s,
111 None => continue,
112 };
113
114 let details_map = match details.as_mapping() {
115 Some(map) => map,
116 None => continue,
117 };
118
119 let _version = extract_yaml_string(details_map, "version").unwrap_or_default();
120 let resolution = extract_yaml_string(details_map, "resolution").unwrap_or_default();
121
122 let (namespace_opt, name, resolved_version) = parse_yarn_v2_resolution(&resolution);
123 let namespace = namespace_opt.unwrap_or_default();
124 let full_name = full_package_name(&namespace, &name);
125 let manifest_info = manifest_dependencies.get(&full_name);
126 let purl = create_purl(&namespace, &name, &resolved_version);
127 let checksum = extract_yaml_string(details_map, "checksum");
128
129 let deps_yaml = details_map.get("dependencies");
130 let peer_deps_yaml = details_map.get("peerDependencies");
131
132 let nested_deps = parse_yaml_dependencies(deps_yaml);
133 let peer_deps = parse_yaml_dependencies(peer_deps_yaml);
134
135 let all_deps = if peer_deps.is_empty() {
136 nested_deps
137 } else {
138 let mut combined = nested_deps;
139 for mut dep in peer_deps {
140 dep.scope = Some("peerDependencies".to_string());
141 dep.is_optional = Some(true);
142 dep.is_runtime = Some(false);
143 combined.push(dep);
144 }
145 combined
146 };
147
148 let resolved_package = ResolvedPackage {
149 package_type: YarnLockParser::PACKAGE_TYPE,
150 namespace: namespace.clone(),
151 name: name.clone(),
152 version: resolved_version.clone(),
153 primary_language: Some("JavaScript".to_string()),
154 download_url: None,
155 sha1: None,
156 sha256: None,
157 sha512: checksum,
158 md5: None,
159 is_virtual: true,
160 extra_data: None,
161 dependencies: all_deps,
162 repository_homepage_url: None,
163 repository_download_url: None,
164 api_data_url: None,
165 datasource_id: Some(DatasourceId::YarnLock),
166 purl: None,
167 };
168
169 let (scope, is_runtime, is_optional, is_direct) = manifest_info
170 .map(|info| {
171 (
172 info.scope.to_string(),
173 info.is_runtime,
174 info.is_optional,
175 true,
176 )
177 })
178 .unwrap_or_else(|| {
179 (
180 "dependencies".to_string(),
181 true,
182 false,
183 resolution.contains("workspace:"),
184 )
185 });
186
187 let dependency = Dependency {
188 purl,
189 extracted_requirement: Some(resolved_version),
190 scope: Some(scope),
191 is_runtime: Some(is_runtime),
192 is_optional: Some(is_optional),
193 is_pinned: Some(true),
194 is_direct: Some(is_direct),
195 resolved_package: Some(Box::new(resolved_package)),
196 extra_data: None,
197 };
198
199 dependencies.push(dependency);
200 }
201
202 PackageData {
203 package_type: Some(YarnLockParser::PACKAGE_TYPE),
204 namespace: None,
205 name: None,
206 version: None,
207 qualifiers: None,
208 subpath: None,
209 primary_language: None,
210 description: None,
211 release_date: None,
212 parties: Vec::new(),
213 keywords: Vec::new(),
214 homepage_url: None,
215 download_url: None,
216 size: None,
217 sha1: None,
218 md5: None,
219 sha256: None,
220 sha512: None,
221 bug_tracking_url: None,
222 code_view_url: None,
223 vcs_url: None,
224 copyright: None,
225 holder: None,
226 declared_license_expression: None,
227 declared_license_expression_spdx: None,
228 license_detections: Vec::new(),
229 other_license_expression: None,
230 other_license_expression_spdx: None,
231 other_license_detections: Vec::new(),
232 extracted_license_statement: None,
233 notice_text: None,
234 source_packages: Vec::new(),
235 file_references: Vec::new(),
236 is_private: false,
237 is_virtual: false,
238 extra_data: None,
239 dependencies,
240 repository_homepage_url: None,
241 repository_download_url: None,
242 api_data_url: None,
243 datasource_id: Some(DatasourceId::YarnLock),
244 purl: None,
245 }
246}
247
248fn parse_yarn_v1(
250 content: &str,
251 manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
252) -> PackageData {
253 let mut dependencies = Vec::new();
254 let mut seen_purls = HashSet::new();
255
256 for block in content.split("\n\n") {
257 if is_empty_or_comment_block(block) {
258 continue;
259 }
260
261 if let Some(dep) = parse_yarn_v1_block(block, manifest_dependencies) {
262 if let Some(ref purl) = dep.purl {
263 if seen_purls.insert(purl.clone()) {
264 dependencies.push(dep);
265 }
266 } else {
267 dependencies.push(dep);
268 }
269 }
270 }
271
272 PackageData {
273 package_type: Some(YarnLockParser::PACKAGE_TYPE),
274 namespace: None,
275 name: None,
276 version: None,
277 qualifiers: None,
278 subpath: None,
279 primary_language: None,
280 description: None,
281 release_date: None,
282 parties: Vec::new(),
283 keywords: Vec::new(),
284 homepage_url: None,
285 download_url: None,
286 size: None,
287 sha1: None,
288 md5: None,
289 sha256: None,
290 sha512: None,
291 bug_tracking_url: None,
292 code_view_url: None,
293 vcs_url: None,
294 copyright: None,
295 holder: None,
296 declared_license_expression: None,
297 declared_license_expression_spdx: None,
298 license_detections: Vec::new(),
299 other_license_expression: None,
300 other_license_expression_spdx: None,
301 other_license_detections: Vec::new(),
302 extracted_license_statement: None,
303 notice_text: None,
304 source_packages: Vec::new(),
305 file_references: Vec::new(),
306 is_private: false,
307 is_virtual: false,
308 extra_data: None,
309 dependencies,
310 repository_homepage_url: None,
311 repository_download_url: None,
312 api_data_url: None,
313 datasource_id: Some(DatasourceId::YarnLock),
314 purl: None,
315 }
316}
317
318fn is_empty_or_comment_block(block: &str) -> bool {
319 block
320 .lines()
321 .all(|line| line.trim().is_empty() || line.trim().starts_with('#'))
322}
323
324fn parse_integrity_field(integrity: &str) -> Option<String> {
326 parse_sri(integrity).and_then(|(algo, hex_digest)| {
327 if algo == "sha512" {
328 Some(hex_digest)
329 } else {
330 None
331 }
332 })
333}
334
335pub fn extract_namespace_and_name(package_name: &str) -> (String, String) {
337 if package_name.starts_with('@') {
338 let parts: Vec<&str> = package_name.splitn(2, '/').collect();
339 if parts.len() == 2 {
340 (parts[0].to_string(), parts[1].to_string())
341 } else {
342 (String::new(), package_name.to_string())
343 }
344 } else {
345 (String::new(), package_name.to_string())
346 }
347}
348
349fn create_purl(namespace: &str, name: &str, version: &str) -> Option<String> {
350 let full_name = if namespace.is_empty() {
351 name.to_string()
352 } else {
353 format!("{}/{}", namespace, name)
354 };
355 let version_opt = if version.is_empty() {
356 None
357 } else {
358 Some(version)
359 };
360 npm_purl(&full_name, version_opt)
361}
362
363fn default_package_data() -> PackageData {
364 PackageData {
365 package_type: Some(YarnLockParser::PACKAGE_TYPE),
366 datasource_id: Some(DatasourceId::YarnLock),
367 ..Default::default()
368 }
369}
370
371fn parse_yarn_v1_block(
373 block: &str,
374 manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
375) -> Option<Dependency> {
376 let lines: Vec<&str> = block.lines().collect();
377 if lines.is_empty() {
378 return None;
379 }
380
381 let requirement_line = lines[0]
382 .trim()
383 .strip_suffix(':')
384 .unwrap_or_else(|| lines[0].trim())
385 .trim_matches('"');
386 if requirement_line.is_empty() || requirement_line.starts_with('#') {
387 return None;
388 }
389
390 let (namespace, name, constraint) = parse_yarn_v1_requirement(requirement_line);
391
392 if name.is_empty() {
393 return None;
394 }
395
396 let mut version = String::new();
397 let mut resolved_url = String::new();
398 let mut integrity = String::new();
399 let mut nested_deps = Vec::new();
400
401 for line in &lines[1..] {
402 let trimmed = line.trim();
403 if trimmed.is_empty() {
404 continue;
405 }
406
407 if trimmed.starts_with("version") {
408 version = extract_quoted_value(trimmed).unwrap_or_default();
409 } else if trimmed.starts_with("resolved") {
410 resolved_url = extract_quoted_value(trimmed).unwrap_or_default();
411 } else if trimmed.starts_with("integrity") {
412 integrity = trimmed
413 .strip_prefix("integrity")
414 .map(|s| s.trim().to_string())
415 .unwrap_or_default();
416 } else if trimmed.starts_with("dependencies") {
417 continue;
419 } else if trimmed.starts_with(" ") && !trimmed.starts_with(" ") {
420 let dep_line = trimmed.trim();
422 if let Some(dep) = parse_yarn_v1_dependency_line(dep_line, &namespace, &name, &version)
423 {
424 nested_deps.push(dep);
425 }
426 }
427 }
428
429 let sha512 = if integrity.is_empty() {
430 None
431 } else {
432 parse_integrity_field(&integrity)
433 };
434
435 let full_name = full_package_name(&namespace, &name);
436 let manifest_info = manifest_dependencies.get(&full_name);
437 let purl = create_purl(&namespace, &name, &version);
438 let (scope, is_runtime, is_optional, is_direct) = manifest_info
439 .map(|info| {
440 (
441 info.scope.to_string(),
442 info.is_runtime,
443 info.is_optional,
444 true,
445 )
446 })
447 .unwrap_or_else(|| ("dependencies".to_string(), true, false, false));
448
449 let resolved_package = ResolvedPackage {
450 package_type: YarnLockParser::PACKAGE_TYPE,
451 namespace: namespace.clone(),
452 name: name.clone(),
453 version: version.clone(),
454 primary_language: Some("JavaScript".to_string()),
455 download_url: if resolved_url.is_empty() {
456 None
457 } else {
458 Some(resolved_url)
459 },
460 sha1: None,
461 sha256: None,
462 sha512,
463 md5: None,
464 is_virtual: true,
465 extra_data: None,
466 dependencies: nested_deps,
467 repository_homepage_url: None,
468 repository_download_url: None,
469 api_data_url: None,
470 datasource_id: Some(DatasourceId::YarnLock),
471 purl: None,
472 };
473
474 Some(Dependency {
475 purl,
476 extracted_requirement: Some(constraint),
477 scope: Some(scope),
478 is_runtime: Some(is_runtime),
479 is_optional: Some(is_optional),
480 is_pinned: Some(true),
481 is_direct: Some(is_direct),
482 resolved_package: Some(Box::new(resolved_package)),
483 extra_data: None,
484 })
485}
486
487fn full_package_name(namespace: &str, name: &str) -> String {
488 if namespace.is_empty() {
489 name.to_string()
490 } else {
491 format!("{namespace}/{name}")
492 }
493}
494
495fn load_manifest_dependency_info(path: &Path) -> HashMap<String, ManifestDependencyInfo> {
496 let Some(parent) = path.parent() else {
497 return HashMap::new();
498 };
499
500 let manifest_path = parent.join("package.json");
501 let Ok(content) = fs::read_to_string(manifest_path) else {
502 return HashMap::new();
503 };
504
505 let Ok(json) = serde_json::from_str::<JsonValue>(&content) else {
506 return HashMap::new();
507 };
508
509 let peer_optional = json
510 .get("peerDependenciesMeta")
511 .and_then(|value| value.as_object())
512 .map(|meta| {
513 meta.iter()
514 .filter_map(|(name, value)| {
515 value
516 .as_object()
517 .and_then(|entry| entry.get("optional"))
518 .and_then(|value| value.as_bool())
519 .map(|optional| (name.clone(), optional))
520 })
521 .collect::<HashMap<_, _>>()
522 })
523 .unwrap_or_default();
524
525 let mut dependencies = HashMap::new();
526 insert_manifest_dependency_info(
527 &mut dependencies,
528 &json,
529 "dependencies",
530 ManifestDependencyInfo {
531 scope: "dependencies",
532 is_runtime: true,
533 is_optional: false,
534 },
535 );
536 insert_manifest_dependency_info(
537 &mut dependencies,
538 &json,
539 "devDependencies",
540 ManifestDependencyInfo {
541 scope: "devDependencies",
542 is_runtime: false,
543 is_optional: true,
544 },
545 );
546 insert_manifest_dependency_info(
547 &mut dependencies,
548 &json,
549 "optionalDependencies",
550 ManifestDependencyInfo {
551 scope: "optionalDependencies",
552 is_runtime: true,
553 is_optional: true,
554 },
555 );
556
557 if let Some(peer_dependencies) = json
558 .get("peerDependencies")
559 .and_then(|value| value.as_object())
560 {
561 for name in peer_dependencies.keys() {
562 dependencies.insert(
563 name.clone(),
564 ManifestDependencyInfo {
565 scope: "peerDependencies",
566 is_runtime: false,
567 is_optional: peer_optional.get(name).copied().unwrap_or(false),
568 },
569 );
570 }
571 }
572
573 dependencies
574}
575
576fn insert_manifest_dependency_info(
577 dependencies: &mut HashMap<String, ManifestDependencyInfo>,
578 json: &JsonValue,
579 field: &str,
580 info: ManifestDependencyInfo,
581) {
582 if let Some(entries) = json.get(field).and_then(|value| value.as_object()) {
583 for name in entries.keys() {
584 dependencies.insert(name.clone(), info.clone());
585 }
586 }
587}
588
589pub fn parse_yarn_v1_requirement(line: &str) -> (String, String, String) {
591 if line.contains(", ") {
593 let first_part = line.split(", ").next().unwrap_or(line);
595 return parse_single_yarn_v1_requirement(first_part);
596 }
597 parse_single_yarn_v1_requirement(line)
598}
599
600fn parse_single_yarn_v1_requirement(line: &str) -> (String, String, String) {
602 if let Some(at_pos) = line.rfind('@') {
603 let name_part = &line[..at_pos];
604 let constraint = &line[at_pos + 1..];
605 let (namespace, name) = extract_namespace_and_name(name_part);
606
607 if !name.is_empty() {
608 return (namespace, name, constraint.to_string());
609 }
610 }
611
612 (String::new(), String::new(), String::new())
613}
614
615fn parse_yarn_v1_dependency_line(
617 line: &str,
618 _parent_namespace: &str,
619 _parent_name: &str,
620 parent_version: &str,
621) -> Option<Dependency> {
622 let trimmed = line.trim_matches('"');
623 if !trimmed.contains('@') {
624 return None;
625 }
626
627 let (namespace, name, constraint) = parse_yarn_v1_requirement(trimmed);
628
629 let purl = create_purl(&namespace, &name, parent_version);
630
631 Some(Dependency {
632 purl,
633 extracted_requirement: Some(constraint),
634 scope: Some("dependencies".to_string()),
635 is_runtime: Some(true),
636 is_optional: Some(false),
637 is_pinned: Some(false),
638 is_direct: Some(false),
639 resolved_package: None,
640 extra_data: None,
641 })
642}
643
644fn extract_quoted_value(line: &str) -> Option<String> {
646 line.find('"').and_then(|start| {
647 let rest = &line[start + 1..];
648 rest.find('"').map(|end| rest[..end].to_string())
649 })
650}
651
652pub fn parse_yarn_v2_resolution(resolution: &str) -> (Option<String>, String, String) {
654 if resolution.contains("@npm:") {
655 let parts: Vec<&str> = resolution.split("@npm:").collect();
656 if parts.len() == 2 {
657 let package_name = parts[0];
658 let version = parts[1];
659 let (namespace, name) = extract_namespace_and_name(package_name);
660 let namespace_opt = if namespace.is_empty() {
661 None
662 } else {
663 Some(namespace)
664 };
665 return (namespace_opt, name, version.to_string());
666 }
667 }
668
669 let (namespace, name) = extract_namespace_and_name(resolution);
670 let namespace_opt = if namespace.is_empty() {
671 None
672 } else {
673 Some(namespace)
674 };
675 (namespace_opt, name, "*".to_string())
676}
677
678fn extract_yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option<String> {
680 map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
681}
682
683fn parse_yaml_dependencies(yaml_value: Option<&Value>) -> Vec<Dependency> {
685 let mut dependencies = Vec::new();
686
687 if let Some(deps_value) = yaml_value
688 && let Some(mapping) = deps_value.as_mapping()
689 {
690 for (key, value) in mapping {
691 let name = match key.as_str() {
692 Some(s) => s.to_string(),
693 None => continue,
694 };
695
696 let constraint = match value.as_str() {
697 Some(s) => s.to_string(),
698 None => "*".to_string(),
699 };
700
701 let (namespace, dep_name) = extract_namespace_and_name(&name);
702 let purl = create_purl(&namespace, &dep_name, &constraint);
703
704 dependencies.push(Dependency {
705 purl,
706 extracted_requirement: Some(constraint),
707 scope: Some("dependencies".to_string()),
708 is_runtime: Some(true),
709 is_optional: Some(false),
710 is_pinned: Some(false),
711 is_direct: Some(false),
712 resolved_package: None,
713 extra_data: None,
714 });
715 }
716 }
717 dependencies
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use std::path::PathBuf;
724
725 #[test]
726 fn test_is_match_yarn_lock() {
727 let valid_path = PathBuf::from("/some/path/yarn.lock");
728 assert!(YarnLockParser::is_match(&valid_path));
729 }
730
731 #[test]
732 fn test_is_not_match_package_json() {
733 let invalid_path = PathBuf::from("/some/path/package.json");
734 assert!(!YarnLockParser::is_match(&invalid_path));
735 }
736
737 #[test]
738 fn test_detect_yarn_v2() {
739 let content = r#"# This file is generated by running "yarn install"
740__metadata:
741 version: 6
742"#;
743 assert!(detect_yarn_version(content));
744 }
745
746 #[test]
747 fn test_detect_yarn_v1() {
748 let content = r#"# THIS IS AN AUTOGENERATED FILE
749# yarn lockfile v1
750
751abbrev@1:
752 version "1.0.9"
753"#;
754 assert!(!detect_yarn_version(content));
755 }
756
757 #[test]
758 fn test_extract_namespace_and_name_scoped() {
759 let (namespace, name) = extract_namespace_and_name("@types/node");
760 assert_eq!(namespace, "@types");
761 assert_eq!(name, "node");
762 }
763
764 #[test]
765 fn test_extract_namespace_and_name_regular() {
766 let (namespace, name) = extract_namespace_and_name("express");
767 assert_eq!(namespace, "");
768 assert_eq!(name, "express");
769 }
770
771 #[test]
772 fn test_parse_yarn_v1_requirement() {
773 let (namespace, name, constraint) = parse_yarn_v1_requirement("express@^4.0.0");
774 assert_eq!(namespace, "");
775 assert_eq!(name, "express");
776 assert_eq!(constraint, "^4.0.0");
777 }
778
779 #[test]
780 fn test_parse_yarn_v1_requirement_scoped() {
781 let (namespace, name, constraint) = parse_yarn_v1_requirement("@types/node@^18.0.0");
782 assert_eq!(namespace, "@types");
783 assert_eq!(name, "node");
784 assert_eq!(constraint, "^18.0.0");
785 }
786
787 #[test]
788 fn test_parse_yarn_v2_resolution() {
789 let (namespace, name, version) = parse_yarn_v2_resolution("@actions/core@npm:1.2.6");
790 assert_eq!(namespace, Some("@actions".to_string()));
791 assert_eq!(name, "core");
792 assert_eq!(version, "1.2.6");
793 }
794}
795
796crate::register_parser!(
797 "yarn.lock lockfile (v1 and v2+)",
798 &["**/yarn.lock"],
799 "npm",
800 "JavaScript",
801 Some("https://classic.yarnpkg.com/lang/en/docs/yarn-lock/"),
802);