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