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