Skip to main content

provenant/parsers/
yarn_lock.rs

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