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