Skip to main content

provenant/parsers/
yarn_lock.rs

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