Skip to main content

provenant/parsers/
yarn_lock.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Parser for Yarn yarn.lock lockfiles.
8//!
9//! Extracts resolved dependency information from Yarn lockfiles supporting both
10//! Yarn Classic (v1) and Yarn Berry (v2+) formats with different syntax and structures.
11//!
12//! # Supported Formats
13//! - yarn.lock (Classic v1 format - key-value style)
14//! - yarn.lock (Berry v2+ format - YAML-like structure with different key format)
15//!
16//! # Key Features
17//! - Multi-format support for Yarn Classic and Berry versions
18//! - Direct vs transitive dependency tracking (`is_direct`)
19//! - Integrity hash extraction (sha1, sha512, sha256)
20//! - Package URL (purl) generation for scoped and unscoped packages
21//! - Workspace and monorepo dependency resolution
22//!
23//! # Implementation Notes
24//! - v1 format: `"@scope/name@version"` keys with nested `version` and `integrity` fields
25//! - v2+ format: Similar structure but different key generation with workspace awareness
26//! - All lockfile versions are pinned (`is_pinned: Some(true)`)
27//! - Graceful error handling with `warn!()` logs
28
29use 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
41/// Yarn lockfile parser supporting both Yarn Classic (v1) and Berry (v2+) formats.
42///
43/// Extracts pinned dependency versions with integrity hashes from yarn.lock files.
44pub 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
93/// Detect if yarn.lock is v2 (has __metadata) or v1 (has "yarn lockfile v1")
94pub fn detect_yarn_version(content: &str) -> bool {
95    content
96        .lines()
97        .take(10)
98        .any(|line| line.contains("__metadata:"))
99}
100
101/// Parse yarn.lock v2 format (standard YAML)
102fn 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
267/// Parse yarn.lock v1 format (custom YAML-like)
268fn 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
345/// Parse integrity field (format: "sha512-base64string==")
346fn 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
356/// Extract namespace and name from package name ("@types/node" -> ("@types", "node"))
357pub 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
392/// Parse a single yarn v1 dependency block
393fn 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            // Dependencies block - parse indented lines
449            continue;
450        } else if trimmed.starts_with("  ") && !trimmed.starts_with("    ") {
451            // Dependency line (2-space indent)
452            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
626/// Parse yarn v1 requirement line: "express@^4.0.0" or "@babel/core@^7.1.0"
627pub fn parse_yarn_v1_requirement(line: &str) -> (String, String, String) {
628    // Handle multiple aliases: "rimraf@2, rimraf@~2.5.1"
629    if line.contains(", ") {
630        // Use the first part for parsing
631        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
637/// Parse a single yarn v1 requirement
638fn 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        let (namespace, name) = extract_namespace_and_name(name_part);
643
644        if !name.is_empty() {
645            return (namespace, name, constraint.to_string());
646        }
647    }
648
649    (String::new(), String::new(), String::new())
650}
651
652/// Parse a dependency line from yarn v1 block: "\"dep@^1.0.0\""
653fn parse_yarn_v1_dependency_line(
654    line: &str,
655    _parent_namespace: &str,
656    _parent_name: &str,
657    parent_version: &str,
658) -> Option<Dependency> {
659    let trimmed = line.trim_matches('"');
660    if !trimmed.contains('@') {
661        return None;
662    }
663
664    let (namespace, name, constraint) = parse_yarn_v1_requirement(trimmed);
665    let namespace = truncate_field(namespace);
666    let name = truncate_field(name);
667    let constraint = truncate_field(constraint);
668
669    let purl = create_purl(&namespace, &name, parent_version).map(truncate_field);
670
671    Some(Dependency {
672        purl,
673        extracted_requirement: Some(constraint),
674        scope: Some("dependencies".to_string()),
675        is_runtime: Some(true),
676        is_optional: Some(false),
677        is_pinned: Some(false),
678        is_direct: Some(false),
679        resolved_package: None,
680        extra_data: None,
681    })
682}
683
684/// Extract value from quoted line: 'version "1.0.0"' -> "1.0.0"
685fn extract_quoted_value(line: &str) -> Option<String> {
686    line.find('"').and_then(|start| {
687        let rest = &line[start + 1..];
688        rest.find('"').map(|end| rest[..end].to_string())
689    })
690}
691
692/// Parse yarn v2 resolution: "@actions/core@npm:1.2.6" -> ("@actions", "core", "1.2.6")
693pub fn parse_yarn_v2_resolution(resolution: &str) -> (Option<String>, String, String) {
694    if resolution.contains("@npm:") {
695        let parts: Vec<&str> = resolution.split("@npm:").collect();
696        if parts.len() == 2 {
697            let package_name = parts[0];
698            let version = parts[1];
699            let (namespace, name) = extract_namespace_and_name(package_name);
700            let namespace_opt = if namespace.is_empty() {
701                None
702            } else {
703                Some(namespace)
704            };
705            return (namespace_opt, name, version.to_string());
706        }
707    }
708
709    if let Some((ident, reference)) = split_yarn_locator(resolution) {
710        let (namespace, name) = extract_namespace_and_name(ident);
711        let namespace_opt = if namespace.is_empty() {
712            None
713        } else {
714            Some(namespace)
715        };
716        return (namespace_opt, name, reference.to_string());
717    }
718
719    let (namespace, name) = extract_namespace_and_name(resolution);
720    let namespace_opt = if namespace.is_empty() {
721        None
722    } else {
723        Some(namespace)
724    };
725    (namespace_opt, name, "*".to_string())
726}
727
728fn split_yarn_locator(resolution: &str) -> Option<(&str, &str)> {
729    if resolution.is_empty() {
730        return None;
731    }
732
733    let separator_index = if resolution.starts_with('@') {
734        let slash_index = resolution.find('/')?;
735        let rest = &resolution[slash_index + 1..];
736        let at_index = rest.find('@')?;
737        slash_index + 1 + at_index
738    } else {
739        resolution.find('@')?
740    };
741
742    let ident = &resolution[..separator_index];
743    let reference = &resolution[separator_index + 1..];
744
745    if ident.is_empty() || reference.is_empty() {
746        None
747    } else {
748        Some((ident, reference))
749    }
750}
751
752fn extract_yarn_v2_package_extra_data(
753    yaml_map: &yaml_serde::Mapping,
754) -> Option<HashMap<String, JsonValue>> {
755    let metadata = yaml_map.get("__metadata")?.as_mapping()?;
756    let mut extra_data = HashMap::new();
757
758    for field in ["version", "cacheKey"] {
759        if let Some(value) = metadata.get(field).and_then(yaml_value_to_json) {
760            extra_data.insert(field.to_string(), value);
761        }
762    }
763
764    (!extra_data.is_empty()).then_some(extra_data)
765}
766
767fn extract_yarn_v2_resolved_extra_data(
768    details_map: &yaml_serde::Mapping,
769    resolution: &str,
770) -> Option<HashMap<String, JsonValue>> {
771    let mut extra_data = HashMap::new();
772    extra_data.insert(
773        "resolution".to_string(),
774        JsonValue::String(truncate_field(resolution.to_string())),
775    );
776
777    for field in ["languageName", "linkType", "bin", "dependenciesMeta"] {
778        if let Some(value) = details_map.get(field).and_then(yaml_value_to_json) {
779            extra_data.insert(field.to_string(), value);
780        }
781    }
782
783    Some(extra_data)
784}
785
786fn yaml_value_to_json(value: &Value) -> Option<JsonValue> {
787    serde_json::to_value(value).ok()
788}
789
790/// Extract string value from YAML mapping
791fn extract_yaml_string(map: &yaml_serde::Mapping, key: &str) -> Option<String> {
792    map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
793}
794
795/// Parse dependencies from YAML Value
796fn parse_yaml_dependencies(yaml_value: Option<&Value>) -> Vec<Dependency> {
797    let mut dependencies = Vec::new();
798
799    if let Some(deps_value) = yaml_value
800        && let Some(mapping) = deps_value.as_mapping()
801    {
802        let limit = capped_iteration_limit(mapping.len(), "yarn.lock v2 nested dependencies");
803        for (key, value) in mapping.iter().take(limit) {
804            let name = match key.as_str() {
805                Some(s) => s.to_string(),
806                None => continue,
807            };
808
809            let constraint = match value.as_str() {
810                Some(s) => s.to_string(),
811                None => "*".to_string(),
812            };
813
814            let (namespace, dep_name) = extract_namespace_and_name(&name);
815            let namespace = truncate_field(namespace);
816            let dep_name = truncate_field(dep_name);
817            let constraint = truncate_field(constraint);
818            let purl = create_purl(&namespace, &dep_name, &constraint).map(truncate_field);
819
820            dependencies.push(Dependency {
821                purl,
822                extracted_requirement: Some(constraint),
823                scope: Some("dependencies".to_string()),
824                is_runtime: Some(true),
825                is_optional: Some(false),
826                is_pinned: Some(false),
827                is_direct: Some(false),
828                resolved_package: None,
829                extra_data: None,
830            });
831        }
832    }
833    dependencies
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use std::path::PathBuf;
840
841    #[test]
842    fn test_is_match_yarn_lock() {
843        let valid_path = PathBuf::from("/some/path/yarn.lock");
844        assert!(YarnLockParser::is_match(&valid_path));
845    }
846
847    #[test]
848    fn test_is_not_match_package_json() {
849        let invalid_path = PathBuf::from("/some/path/package.json");
850        assert!(!YarnLockParser::is_match(&invalid_path));
851    }
852
853    #[test]
854    fn test_detect_yarn_v2() {
855        let content = r#"# This file is generated by running "yarn install"
856__metadata:
857  version: 6
858"#;
859        assert!(detect_yarn_version(content));
860    }
861
862    #[test]
863    fn test_detect_yarn_v1() {
864        let content = r#"# THIS IS AN AUTOGENERATED FILE
865# yarn lockfile v1
866
867abbrev@1:
868  version "1.0.9"
869"#;
870        assert!(!detect_yarn_version(content));
871    }
872
873    #[test]
874    fn test_parse_yarn_v2_uses_yarn_lock_v2_datasource_ids() {
875        let content = r#"# This file is generated by running \"yarn install\"
876__metadata:
877  version: 6
878
879lodash@npm:^4.17.21:
880  version: 4.17.21
881  resolution: "lodash@npm:4.17.21"
882"#;
883
884        let package_data = parse_yarn_v2(content, &HashMap::new());
885
886        assert_eq!(package_data.datasource_id, Some(DatasourceId::YarnLockV2));
887        assert_eq!(
888            package_data.dependencies[0]
889                .resolved_package
890                .as_ref()
891                .and_then(|pkg| pkg.datasource_id),
892            Some(DatasourceId::YarnLockV2)
893        );
894    }
895
896    #[test]
897    fn test_parse_yarn_v1_uses_yarn_lock_v1_datasource_ids() {
898        let content = r#"# THIS IS AN AUTOGENERATED FILE
899# yarn lockfile v1
900
901left-pad@^1.3.0:
902  version \"1.3.0\"
903"#;
904
905        let package_data = parse_yarn_v1(content, &HashMap::new());
906
907        assert_eq!(package_data.datasource_id, Some(DatasourceId::YarnLockV1));
908        assert_eq!(
909            package_data.dependencies[0]
910                .resolved_package
911                .as_ref()
912                .and_then(|pkg| pkg.datasource_id),
913            Some(DatasourceId::YarnLockV1)
914        );
915    }
916
917    #[test]
918    fn test_extract_namespace_and_name_scoped() {
919        let (namespace, name) = extract_namespace_and_name("@types/node");
920        assert_eq!(namespace, "@types");
921        assert_eq!(name, "node");
922    }
923
924    #[test]
925    fn test_extract_namespace_and_name_regular() {
926        let (namespace, name) = extract_namespace_and_name("express");
927        assert_eq!(namespace, "");
928        assert_eq!(name, "express");
929    }
930
931    #[test]
932    fn test_parse_yarn_v1_requirement() {
933        let (namespace, name, constraint) = parse_yarn_v1_requirement("express@^4.0.0");
934        assert_eq!(namespace, "");
935        assert_eq!(name, "express");
936        assert_eq!(constraint, "^4.0.0");
937    }
938
939    #[test]
940    fn test_parse_yarn_v1_requirement_scoped() {
941        let (namespace, name, constraint) = parse_yarn_v1_requirement("@types/node@^18.0.0");
942        assert_eq!(namespace, "@types");
943        assert_eq!(name, "node");
944        assert_eq!(constraint, "^18.0.0");
945    }
946
947    #[test]
948    fn test_parse_yarn_v2_resolution() {
949        let (namespace, name, version) = parse_yarn_v2_resolution("@actions/core@npm:1.2.6");
950        assert_eq!(namespace, Some("@actions".to_string()));
951        assert_eq!(name, "core");
952        assert_eq!(version, "1.2.6");
953    }
954}