Skip to main content

provenant/parsers/
yarn_lock.rs

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