From f6bbea81b615460962155d35b7379d00c6efb6d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:24:04 +0000 Subject: [PATCH 1/7] Start fix for #1901 Assisted-by: Claude Code:claude-opus-5-5 From b83a4576bc9d129ff418207677ea1a1f7198a44a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:31:26 +0000 Subject: [PATCH 2/7] Read .yarnrc lines through one yarn tokenizer The npm crawler and the hosted redirect each tokenized yarn classic's .yarnrc, and the copies drifted. Given --modules-folder 'deps', the crawler kept the quotes and crawled a folder named 'deps', missing the real install, so its packages were reported as not installed. It also accepted values yarn can't read (~/x, an unquoted C:\deps). Both now call formats::yarn::classic_rc, built from the redirect's yarn-faithful reader. A value followed by anything but a # comment is unreadable to yarn, so it now sets nothing for either caller. Fixes #1901 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/npm_crawler.rs | 113 +++++++------ .../src/formats/yarn/classic_rc.rs | 160 ++++++++++++++++++ .../socket-patch-core/src/formats/yarn/mod.rs | 4 +- .../src/patch/redirect/yarnrc.rs | 87 +--------- 4 files changed, 229 insertions(+), 135 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/yarn/classic_rc.rs diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 2d6acefd5..387448c11 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -9,6 +9,7 @@ use super::pnpm_layout::MODULES_YAML as PNPM_MODULES_YAML; use super::types::{CrawledPackage, CrawlerOptions}; use super::walk_pool::{par_map, run_walk}; use crate::formats::text::strip_bom; +use crate::formats::yarn::classic_rc; use crate::patch::path_safety; use crate::utils::fs::{is_dir, is_dir_sync, read_dir_entries_sync}; use crate::utils::purl::{percent_decode_purl_component, strip_purl_qualifiers}; @@ -337,57 +338,16 @@ struct YarnrcModulesFolder { } /// The `--modules-folder` and command-scoped `--install.modules-folder` -/// values of a yarn classic `.yarnrc`. The file is yarn's lockfile -/// syntax: one `key value` pair per line, either side optionally -/// double-quoted, an optional `:` after the key, `#` comment lines. For -/// each key the last setting wins. +/// values of a yarn classic `.yarnrc`, read through the one `.yarnrc` +/// reader ([`crate::formats::yarn::classic_rc`]): for each key the last +/// setting wins, and an empty value or one yarn can't tokenize sets +/// nothing. fn parse_yarnrc_modules_folder(rc: &str) -> YarnrcModulesFolder { - let mut found = YarnrcModulesFolder::default(); - for line in strip_bom(rc).lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let Some((key, rest)) = split_yarnrc_token(line, true) else { - continue; - }; - let slot = match key.as_str() { - "--modules-folder" => &mut found.general, - "--install.modules-folder" => &mut found.install, - _ => continue, - }; - let rest = rest.trim_start(); - let rest = rest.strip_prefix(':').unwrap_or(rest).trim_start(); - if let Some((value, _)) = split_yarnrc_token(rest, false) { - if !value.is_empty() { - *slot = Some(value); - } - } + let read = |key| classic_rc::value(rc, key).filter(|v| !v.is_empty()); + YarnrcModulesFolder { + general: read("--modules-folder"), + install: read("--install.modules-folder"), } - found -} - -/// Split the leading token off `s`: a double-quoted string (with `\"` and -/// `\\` escapes) or a run of non-whitespace — for a key (`is_key`) also -/// ending at a `:`, which a value may contain (`C:\deps`). Returns the -/// unquoted token and the remainder. -fn split_yarnrc_token(s: &str, is_key: bool) -> Option<(String, &str)> { - if let Some(quoted) = s.strip_prefix('"') { - let mut value = String::new(); - let mut chars = quoted.char_indices(); - while let Some((i, c)) = chars.next() { - match c { - '\\' => value.push(chars.next()?.1), - '"' => return Some((value, "ed[i + 1..])), - c => value.push(c), - } - } - return None; - } - let end = s - .find(|c: char| c.is_whitespace() || (is_key && c == ':')) - .unwrap_or(s.len()); - (end > 0).then(|| (s[..end].to_string(), &s[end..])) } // --------------------------------------------------------------------------- @@ -8180,10 +8140,9 @@ mod tests { parse("--modules-folder \"C:\\\\deps\"\n").as_deref(), Some("C:\\deps") ); - assert_eq!( - parse("--modules-folder C:\\deps\n").as_deref(), - Some("C:\\deps") - ); + // An unquoted `C:\deps` is a token yarn can't read (a bare word + // ends at `:`, and `\` starts no token): not a setting. + assert_eq!(parse("--modules-folder C:\\deps\n"), None); assert_eq!( parse("--modules-folder a\n--modules-folder b\n").as_deref(), Some("b") @@ -8205,6 +8164,54 @@ mod tests { } } + /// REGRESSION (#1901): the crawler reads a `.yarnrc` line the way + /// yarn's tokenizer (and the hosted redirect's reader) does. A + /// single-quoted value loses its quotes, and a value yarn can't + /// tokenize (`~/x`) sets nothing, so the crawl never roots at a folder + /// yarn does not install into. + #[test] + fn yarnrc_modules_folder_reads_like_yarn() { + let parse = |rc: &str| parse_yarnrc_modules_folder(rc).general; + assert_eq!(parse("--modules-folder 'deps'\n").as_deref(), Some("deps")); + assert_eq!( + parse("\"--modules-folder\" 'my deps'\n").as_deref(), + Some("my deps") + ); + assert_eq!( + parse_yarnrc_modules_folder("--install.modules-folder 'vm'\n").install, + Some("vm".into()) + ); + for rc in [ + "--modules-folder ~/x\n", + "--modules-folder deps\n--modules-folder ~/x\n", + "--modules-folder 'unterminated\n", + ] { + assert_eq!(parse(rc), None, "{rc:?}"); + } + // A double-quoted value is JSON: its escapes are decoded. + assert_eq!( + parse("--modules-folder \"a\\u0062\"\n").as_deref(), + Some("ab") + ); + } + + /// REGRESSION (#1901): a single-quoted `--modules-folder` is a crawl + /// root at its unquoted path. + #[tokio::test] + async fn test_yarnrc_single_quoted_modules_folder_is_a_crawl_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("package.json"), r#"{"name":"app"}"#).unwrap(); + std::fs::write(root.join(".yarnrc"), "--modules-folder 'deps'\n").unwrap(); + write_pkg(&root.join("deps/left-pad"), "left-pad", "1.3.0"); + let found = NpmCrawler::new().crawl_all(&local_options(root)).await; + assert!( + found.iter().any(|p| p.purl == "pkg:npm/left-pad@1.3.0"), + "{:?}", + found.iter().map(|p| &p.purl).collect::>() + ); + } + fn local_options(cwd: &Path) -> CrawlerOptions { CrawlerOptions { cwd: cwd.to_path_buf(), diff --git a/crates/socket-patch-core/src/formats/yarn/classic_rc.rs b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs new file mode 100644 index 000000000..d171b9efb --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs @@ -0,0 +1,160 @@ +//! Yarn classic's `.yarnrc`: one `key value` pair per line, read the way +//! yarn 1's lockfile tokenizer reads it (yarn parses `.yarnrc` with the +//! lockfile grammar, falling back to YAML). +//! +//! Every `.yarnrc` reader goes through [`setting`] (the line walk) and +//! [`token`] (the value), or [`value`] for both, so the npm crawler's +//! `--modules-folder` read and the hosted redirect's `global-folder` and +//! `yarn-offline-mirror` reads cannot disagree on a line (#1901). + +use crate::formats::text::strip_bom; + +/// The raw text after the last `key_name` in a `.yarnrc` (`key value`, +/// `key: value` or `key:value` lines, the key optionally double-quoted, +/// `#` comment lines); later lines override earlier ones. Empty when the +/// key has no value. Decode it with [`token`]. +pub fn setting<'a>(text: &'a str, key_name: &str) -> Option<&'a str> { + let mut found = None; + for line in strip_bom(text).lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, rest) = match line.strip_prefix('"') { + Some(quoted) => match quoted.split_once('"') { + Some((key, rest)) => (key, rest), + None => continue, + }, + // yarn's tokenizer also ends an unquoted key at `:`, so + // `key: value` and `key:value` set the key like `key value`. + None => match line.split_once(|c: char| c.is_whitespace() || c == ':') { + Some((key, rest)) => (key, rest), + None => (line, ""), + }, + }; + if key == key_name { + let rest = rest.trim_start(); + found = Some(rest.strip_prefix(':').unwrap_or(rest)); + } + } + found +} + +/// The value token that starts `rest` (what follows a key in a `.yarnrc` +/// line), read like yarn's lockfile tokenizer: a double-quoted string is +/// JSON (escapes decoded), a bare word starts with a letter, `/`, `.` or +/// `-` and ends at a space, `:` or `,` (so a trailing `# comment` is not +/// part of it), and a run of digits is a number. A single-quoted string +/// is what yarn's YAML fallback reads in a `"key": '…'` line. Anything +/// else (`~/x`, an unterminated quote) is a token yarn can't read, and so +/// is a line that goes on past the value with anything but a `#` comment +/// (`C:\deps` is the word `C`, a `:` and an unreadable `\`): `None`. +pub fn token(rest: &str) -> Option { + let (value, tail) = split_token(rest)?; + let tail = tail.trim(); + (tail.is_empty() || tail.starts_with('#')).then_some(value) +} + +/// The value token that starts `rest` and the text after it (see +/// [`token`]). +fn split_token(rest: &str) -> Option<(String, &str)> { + let rest = rest.trim_start_matches([' ', '\t']); + if let Some(quoted) = rest.strip_prefix('\'') { + let mut out = String::new(); + let mut chars = quoted.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + match c { + '\'' if matches!(chars.peek(), Some((_, '\''))) => { + chars.next(); + out.push('\''); + } + '\'' => return Some((out, "ed[i + 1..])), + c => out.push(c), + } + } + return None; + } + if rest.starts_with('"') { + let bytes = rest.as_bytes(); + let mut end = rest.len(); + for i in 1..bytes.len() { + let escaped = bytes[i - 1] == b'\\' && (i < 2 || bytes[i - 2] != b'\\'); + if bytes[i] == b'"' && !escaped { + end = i + 1; + break; + } + } + let value = serde_json::from_str(&rest[..end]).ok()?; + return Some((value, &rest[end..])); + } + let first = rest.chars().next()?; + let end = if first.is_ascii_digit() { + rest.find(|c: char| !c.is_ascii_digit()) + } else if first.is_ascii_alphabetic() || matches!(first, '/' | '.' | '-') { + rest.find([':', ' ', ',', '\r', '\n']) + } else { + return None; + } + .unwrap_or(rest.len()); + Some((rest[..end].to_string(), &rest[end..])) +} + +/// The decoded value of the last `key_name` line in a `.yarnrc` +/// ([`setting`] then [`token`]). `None` when the key is unset, has no +/// value, or its last value is a token yarn can't read. +pub fn value(text: &str, key_name: &str) -> Option { + setting(text, key_name).and_then(token) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn value_reads_keys_and_values_like_yarn() { + let v = |rc: &str| value(rc, "--modules-folder"); + assert_eq!(v("--modules-folder deps\n").as_deref(), Some("deps")); + assert_eq!(v("--modules-folder: lib\n").as_deref(), Some("lib")); + assert_eq!(v("--modules-folder:lib\n").as_deref(), Some("lib")); + assert_eq!( + v("\"--modules-folder\" \"./my deps\"\n").as_deref(), + Some("./my deps") + ); + assert_eq!(v("--modules-folder 'deps'\n").as_deref(), Some("deps")); + assert_eq!(v("--modules-folder 'it''s'\n").as_deref(), Some("it's")); + assert_eq!( + v("--modules-folder \"C:\\\\deps\"\n").as_deref(), + Some("C:\\deps") + ); + assert_eq!(v("--modules-folder deps # c\n").as_deref(), Some("deps")); + assert_eq!( + v("\u{feff}# c\r\n--modules-folder deps\r\n").as_deref(), + Some("deps") + ); + assert_eq!( + v("--modules-folder a\n--modules-folder b\n").as_deref(), + Some("b") + ); + } + + /// A value yarn can't tokenize, or none at all, sets nothing, and + /// as the last setting it hides an earlier readable one. + #[test] + fn unreadable_or_missing_value_is_none() { + for rc in [ + "", + "# --modules-folder deps\n", + "--modules-folder-x deps\n", + "--modules-folder\n", + "--modules-folder ~/x\n", + "--modules-folder C:\\deps\n", + "--modules-folder \"unterminated\n", + "--modules-folder 'unterminated\n", + "--modules-folder deps\n--modules-folder ~/x\n", + "--modules-folder deps, more\n", + "--modules-folder 'deps' x\n", + ] { + assert_eq!(value(rc, "--modules-folder"), None, "{rc:?}"); + } + } +} diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs index 280befcbe..ad2c2d1dc 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -10,7 +10,8 @@ //! package a copy keyed by another dependency name holds ([`copy`]); //! * the stanza view the hosted berry writers re-key and re-order //! entries in ([`stanzas`]); -//! * a `.yarnrc.yml` setting as yarn resolves it ([`rc`]); +//! * a `.yarnrc.yml` setting as yarn resolves it ([`rc`]), and a yarn +//! classic `.yarnrc` line as yarn 1 tokenizes it ([`classic_rc`]); //! * the berry pinned-entry renderer ([`berry_entry`]) and the walk that //! drops the entries a pin leaves unreachable ([`berry_prune`]). //! @@ -24,6 +25,7 @@ pub mod berry_gates; pub(crate) mod berry_prune; pub(crate) mod blocks; pub(crate) mod classic_deps; +pub(crate) mod classic_rc; pub(crate) mod copy; pub(crate) mod patterns; pub mod rc; diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index 5e40f0f17..ac1880cd8 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -24,6 +24,7 @@ use std::path::{Path, PathBuf}; use super::npmrc::NpmConfigEnv; use crate::formats::text::strip_bom; +use crate::formats::yarn::classic_rc; const KEY: &str = "yarn-offline-mirror"; @@ -133,35 +134,7 @@ fn unquote_rc_value(raw: &str) -> &str { /// `key: value` lines, key optionally quoted, `#` comments); later lines /// override earlier ones. pub fn yarnrc_value(text: &str) -> Option { - yarnrc_setting(text, KEY).map(mirror_value) -} - -/// The last raw `key` value in a `.yarnrc` (see [`yarnrc_value`]). -fn yarnrc_setting<'a>(text: &'a str, key_name: &str) -> Option<&'a str> { - let mut found = None; - for line in strip_bom(text).lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, rest) = match line.strip_prefix('"') { - Some(quoted) => match quoted.split_once('"') { - Some((key, rest)) => (key, rest), - None => continue, - }, - // yarn's `.yarnrc` parser also ends an unquoted key at `:`, so - // `key: value` and `key:value` set the key like `key value`. - None => match line.split_once(|c: char| c.is_whitespace() || c == ':') { - Some((key, rest)) => (key, rest), - None => (line, ""), - }, - }; - if key == key_name { - let rest = rest.trim_start(); - found = Some(rest.strip_prefix(':').unwrap_or(rest)); - } - } - found + classic_rc::setting(text, KEY).map(mirror_value) } /// The last top-level `yarn-offline-mirror` value in an `.npmrc` (ini @@ -385,7 +358,7 @@ const GLOBAL_FOLDER_KEY: &str = "global-folder"; /// folder, so all of them count. /// /// Values read as in `yarn@1.22.22`: a `.yarnrc` value is one token of -/// yarn's rc grammar ([`yarnrc_token`]) resolved against the folder of +/// yarn's rc grammar ([`classic_rc::token`]) resolved against the folder of /// its file (`RELATIVE_KEYS`); an `.npmrc` value has its `${NAME}` /// references expanded ([`yarn_env_replace`]); env and `.npmrc` values /// then go through `resolveWithHome` (`~/` is yarn's `userHome`, else the @@ -419,10 +392,9 @@ pub fn configured_yarn_global_folders( let Some(text) = read(&path) else { continue }; let Some(dir) = path.parent() else { continue }; for key in [GLOBAL_FOLDER_KEY, "--global-folder"] { - push( - yarnrc_setting(&text, key).and_then(yarnrc_token), - &|value| Some(dir.join(value)), - ); + push(classic_rc::value(&text, key), &|value| { + Some(dir.join(value)) + }); } } for path in rc_files("npmrc", &["yarn_", "npm_config_"]) { @@ -437,53 +409,6 @@ pub fn configured_yarn_global_folders( out } -/// The value token that starts `rest` (what follows a key in a `.yarnrc` -/// line), read like yarn's lockfile tokenizer: a double-quoted string is -/// JSON (escapes decoded), a bare word starts with a letter, `/`, `.` or -/// `-` and ends at a space, `:` or `,` (so a trailing `# comment` is not -/// part of it), and a run of digits is a number. A single-quoted string -/// is what yarn's YAML fallback reads in a `"key": '…'` line. Anything -/// else (`~/x`) is a token yarn can't read: `None`. -fn yarnrc_token(rest: &str) -> Option { - let rest = rest.trim_start_matches([' ', '\t']); - if let Some(quoted) = rest.strip_prefix('\'') { - let mut out = String::new(); - let mut chars = quoted.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '\'' if chars.peek() == Some(&'\'') => { - chars.next(); - out.push('\''); - } - '\'' => return Some(out), - c => out.push(c), - } - } - return None; - } - if rest.starts_with('"') { - let bytes = rest.as_bytes(); - let mut end = rest.len(); - for i in 1..bytes.len() { - let escaped = bytes[i - 1] == b'\\' && (i < 2 || bytes[i - 2] != b'\\'); - if bytes[i] == b'"' && !escaped { - end = i + 1; - break; - } - } - return serde_json::from_str(&rest[..end]).ok(); - } - let first = rest.chars().next()?; - let word = if first.is_ascii_digit() { - rest.split(|c: char| !c.is_ascii_digit()).next() - } else if first.is_ascii_alphabetic() || matches!(first, '/' | '.' | '-') { - rest.split([':', ' ', ',', '\r', '\n']).next() - } else { - None - }; - word.map(str::to_string) -} - /// An `.npmrc` value as the `ini` parser yarn 1 bundles decodes it: a /// quoted value loses its quotes (a double-quoted one is JSON), and an /// unquoted one ends at an unescaped `;` or `#` (an inline comment), From 5cbab6c598d7a79a6d17429ca716751a0aa05571 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:42:59 +0000 Subject: [PATCH 3/7] Read single-quoted .yarnrc values like yarn 1 yarn 1.22 reads a single-quoted .yarnrc value only through its YAML fallback, so only in a `key: 'value'` pair. A bare `--modules-folder 'deps'` line is ignored and the install goes to node_modules (checked against yarn 1.22.22). The shared reader now accepts a single-quoted value only after a `:` and whitespace, so neither the crawler nor the redirect roots a crawl at a folder yarn never installs into. Refs #1901 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/npm_crawler.rs | 49 +++++++++++----- .../src/formats/yarn/classic_rc.rs | 58 ++++++++++++++----- 2 files changed, 75 insertions(+), 32 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 387448c11..255cc7ff5 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -8165,26 +8165,29 @@ mod tests { } /// REGRESSION (#1901): the crawler reads a `.yarnrc` line the way - /// yarn's tokenizer (and the hosted redirect's reader) does. A - /// single-quoted value loses its quotes, and a value yarn can't - /// tokenize (`~/x`) sets nothing, so the crawl never roots at a folder - /// yarn does not install into. + /// yarn 1.22.22 (and the hosted redirect's reader) does. A + /// single-quoted value loses its quotes in a `key: '…'` pair and sets + /// nothing in a `key '…'` line (yarn installs into `node_modules` + /// then), and a value yarn can't tokenize (`~/x`) sets nothing, so the + /// crawl never roots at a folder yarn does not install into. #[test] fn yarnrc_modules_folder_reads_like_yarn() { let parse = |rc: &str| parse_yarnrc_modules_folder(rc).general; - assert_eq!(parse("--modules-folder 'deps'\n").as_deref(), Some("deps")); + assert_eq!(parse("--modules-folder: 'deps'\n").as_deref(), Some("deps")); assert_eq!( - parse("\"--modules-folder\" 'my deps'\n").as_deref(), + parse("\"--modules-folder\": 'my deps'\n").as_deref(), Some("my deps") ); assert_eq!( - parse_yarnrc_modules_folder("--install.modules-folder 'vm'\n").install, + parse_yarnrc_modules_folder("--install.modules-folder: 'vm'\n").install, Some("vm".into()) ); for rc in [ + "--modules-folder 'deps'\n", "--modules-folder ~/x\n", "--modules-folder deps\n--modules-folder ~/x\n", - "--modules-folder 'unterminated\n", + "--modules-folder deps, x\n", + "--modules-folder: 'unterminated\n", ] { assert_eq!(parse(rc), None, "{rc:?}"); } @@ -8195,20 +8198,34 @@ mod tests { ); } - /// REGRESSION (#1901): a single-quoted `--modules-folder` is a crawl - /// root at its unquoted path. + /// REGRESSION (#1901): a single-quoted `--modules-folder: 'deps'` is a + /// crawl root at its unquoted path, and `--modules-folder 'deps'` + /// (which yarn does not read) leaves `node_modules` the root. #[tokio::test] async fn test_yarnrc_single_quoted_modules_folder_is_a_crawl_root() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); std::fs::write(root.join("package.json"), r#"{"name":"app"}"#).unwrap(); - std::fs::write(root.join(".yarnrc"), "--modules-folder 'deps'\n").unwrap(); write_pkg(&root.join("deps/left-pad"), "left-pad", "1.3.0"); - let found = NpmCrawler::new().crawl_all(&local_options(root)).await; - assert!( - found.iter().any(|p| p.purl == "pkg:npm/left-pad@1.3.0"), - "{:?}", - found.iter().map(|p| &p.purl).collect::>() + write_pkg(&root.join("node_modules/is-number"), "is-number", "7.0.0"); + let crawl = |rc: &'static str| async move { + std::fs::write(root.join(".yarnrc"), rc).unwrap(); + let mut purls: Vec = NpmCrawler::new() + .crawl_all(&local_options(root)) + .await + .into_iter() + .map(|p| p.purl) + .collect(); + purls.sort(); + purls + }; + let left_pad = "pkg:npm/left-pad@1.3.0".to_string(); + assert!(crawl("--modules-folder: 'deps'\n") + .await + .contains(&left_pad)); + assert_eq!( + crawl("--modules-folder 'deps'\n").await, + vec!["pkg:npm/is-number@7.0.0".to_string()] ); } diff --git a/crates/socket-patch-core/src/formats/yarn/classic_rc.rs b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs index d171b9efb..bbb9d5594 100644 --- a/crates/socket-patch-core/src/formats/yarn/classic_rc.rs +++ b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs @@ -2,8 +2,8 @@ //! yarn 1's lockfile tokenizer reads it (yarn parses `.yarnrc` with the //! lockfile grammar, falling back to YAML). //! -//! Every `.yarnrc` reader goes through [`setting`] (the line walk) and -//! [`token`] (the value), or [`value`] for both, so the npm crawler's +//! Every `.yarnrc` reader goes through [`value`] (or [`setting`] for the +//! raw text), so the npm crawler's //! `--modules-folder` read and the hosted redirect's `global-folder` and //! `yarn-offline-mirror` reads cannot disagree on a line (#1901). @@ -12,8 +12,14 @@ use crate::formats::text::strip_bom; /// The raw text after the last `key_name` in a `.yarnrc` (`key value`, /// `key: value` or `key:value` lines, the key optionally double-quoted, /// `#` comment lines); later lines override earlier ones. Empty when the -/// key has no value. Decode it with [`token`]. +/// key has no value. [`value`] decodes it. pub fn setting<'a>(text: &'a str, key_name: &str) -> Option<&'a str> { + last_line(text, key_name).map(|(rest, _)| rest) +} + +/// [`setting`], and whether the key and value are separated by a `:` and +/// whitespace: the `key: value` pair yarn's YAML fallback reads. +fn last_line<'a>(text: &'a str, key_name: &str) -> Option<(&'a str, bool)> { let mut found = None; for line in strip_bom(text).lines() { let line = line.trim(); @@ -27,14 +33,17 @@ pub fn setting<'a>(text: &'a str, key_name: &str) -> Option<&'a str> { }, // yarn's tokenizer also ends an unquoted key at `:`, so // `key: value` and `key:value` set the key like `key value`. - None => match line.split_once(|c: char| c.is_whitespace() || c == ':') { - Some((key, rest)) => (key, rest), + None => match line.find(|c: char| c.is_whitespace() || c == ':') { + Some(end) => (&line[..end], &line[end..]), None => (line, ""), }, }; if key == key_name { let rest = rest.trim_start(); - found = Some(rest.strip_prefix(':').unwrap_or(rest)); + found = Some(match rest.strip_prefix(':') { + Some(value) => (value, value.starts_with([' ', '\t'])), + None => (rest, false), + }); } } found @@ -45,21 +54,26 @@ pub fn setting<'a>(text: &'a str, key_name: &str) -> Option<&'a str> { /// JSON (escapes decoded), a bare word starts with a letter, `/`, `.` or /// `-` and ends at a space, `:` or `,` (so a trailing `# comment` is not /// part of it), and a run of digits is a number. A single-quoted string -/// is what yarn's YAML fallback reads in a `"key": '…'` line. Anything +/// is read only in a `key: '…'` line (`yaml_pair`): yarn's tokenizer +/// rejects the quote, and its YAML fallback reads the file only as such +/// pairs, so `key 'value'` sets nothing. Anything /// else (`~/x`, an unterminated quote) is a token yarn can't read, and so /// is a line that goes on past the value with anything but a `#` comment /// (`C:\deps` is the word `C`, a `:` and an unreadable `\`): `None`. -pub fn token(rest: &str) -> Option { - let (value, tail) = split_token(rest)?; +fn token(rest: &str, yaml_pair: bool) -> Option { + let (value, tail) = split_token(rest, yaml_pair)?; let tail = tail.trim(); (tail.is_empty() || tail.starts_with('#')).then_some(value) } /// The value token that starts `rest` and the text after it (see /// [`token`]). -fn split_token(rest: &str) -> Option<(String, &str)> { +fn split_token(rest: &str, yaml_pair: bool) -> Option<(String, &str)> { let rest = rest.trim_start_matches([' ', '\t']); if let Some(quoted) = rest.strip_prefix('\'') { + if !yaml_pair { + return None; + } let mut out = String::new(); let mut chars = quoted.char_indices().peekable(); while let Some((i, c)) = chars.next() { @@ -100,10 +114,10 @@ fn split_token(rest: &str) -> Option<(String, &str)> { } /// The decoded value of the last `key_name` line in a `.yarnrc` -/// ([`setting`] then [`token`]). `None` when the key is unset, has no +/// (the last [`setting`], decoded like yarn's tokenizer). `None` when the key is unset, has no /// value, or its last value is a token yarn can't read. pub fn value(text: &str, key_name: &str) -> Option { - setting(text, key_name).and_then(token) + last_line(text, key_name).and_then(|(rest, yaml_pair)| token(rest, yaml_pair)) } #[cfg(test)] @@ -120,8 +134,14 @@ mod tests { v("\"--modules-folder\" \"./my deps\"\n").as_deref(), Some("./my deps") ); - assert_eq!(v("--modules-folder 'deps'\n").as_deref(), Some("deps")); - assert_eq!(v("--modules-folder 'it''s'\n").as_deref(), Some("it's")); + // A single-quoted value only in a YAML `key: '…'` pair (each + // checked against yarn 1.22.22). + assert_eq!(v("--modules-folder: 'deps'\n").as_deref(), Some("deps")); + assert_eq!(v("--modules-folder:\t'deps'\n").as_deref(), Some("deps")); + assert_eq!(v("--modules-folder : 'deps'\n").as_deref(), Some("deps")); + assert_eq!(v("\"--modules-folder\": 'deps'\n").as_deref(), Some("deps")); + assert_eq!(v("--modules-folder: 'deps' # c\n").as_deref(), Some("deps")); + assert_eq!(v("--modules-folder: 'it''s'\n").as_deref(), Some("it's")); assert_eq!( v("--modules-folder \"C:\\\\deps\"\n").as_deref(), Some("C:\\deps") @@ -149,10 +169,16 @@ mod tests { "--modules-folder ~/x\n", "--modules-folder C:\\deps\n", "--modules-folder \"unterminated\n", - "--modules-folder 'unterminated\n", + "--modules-folder: 'unterminated\n", "--modules-folder deps\n--modules-folder ~/x\n", "--modules-folder deps, more\n", - "--modules-folder 'deps' x\n", + "--modules-folder: 'deps' x\n", + "--modules-folder 'deps'\n", + "--modules-folder 'my deps'\n", + "--modules-folder:'deps'\n", + "\"--modules-folder\" 'deps'\n", + "\"--modules-folder\":'deps'\n", + "--modules-folder C:deps\n", ] { assert_eq!(value(rc, "--modules-folder"), None, "{rc:?}"); } From a9e154f74e26b9870f29a9af5477bc586f5496bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:54:42 +0000 Subject: [PATCH 4/7] Read .yarnrc as yarn 1 reads the whole file yarn 1 parses .yarnrc with its lockfile grammar, and if that fails anywhere in the file, re-reads the whole file as YAML. A line-by-line reader could not model this. `--modules-folder: my deps` installs into `my deps`, while one unreadable line elsewhere (`yarn-path ~/y.js`) makes yarn ignore every setting in the file. formats::yarn::classic_rc now runs yarn's tokenizer and parser over the file and falls back to a YAML read. The crawler's --modules-folder, the redirect's global-folder and the yarn-offline-mirror read all go through it. Every case in the tests was checked against yarn 1.22.22. Refs #1901 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/npm_crawler.rs | 16 +- .../src/formats/yarn/classic_rc.rs | 466 +++++++++++++----- .../socket-patch-core/src/formats/yarn/mod.rs | 2 +- .../src/patch/redirect/yarnrc.rs | 19 +- 4 files changed, 368 insertions(+), 135 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 255cc7ff5..58cf39a9f 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -338,12 +338,18 @@ struct YarnrcModulesFolder { } /// The `--modules-folder` and command-scoped `--install.modules-folder` -/// values of a yarn classic `.yarnrc`, read through the one `.yarnrc` -/// reader ([`crate::formats::yarn::classic_rc`]): for each key the last -/// setting wins, and an empty value or one yarn can't tokenize sets -/// nothing. +/// values of a yarn classic `.yarnrc`, read as yarn 1 reads the file +/// ([`crate::formats::yarn::classic_rc`]). A key whose value is empty, not +/// a string, or in a file yarn can't read is unset (an empty value set +/// last still overrides an earlier one, as in yarn). fn parse_yarnrc_modules_folder(rc: &str) -> YarnrcModulesFolder { - let read = |key| classic_rc::value(rc, key).filter(|v| !v.is_empty()); + let Some(mut settings) = classic_rc::settings(rc) else { + return YarnrcModulesFolder::default(); + }; + let mut read = |key| match settings.remove(key) { + Some(classic_rc::RcScalar::Str(value)) if !value.is_empty() => Some(value), + _ => None, + }; YarnrcModulesFolder { general: read("--modules-folder"), install: read("--install.modules-folder"), diff --git a/crates/socket-patch-core/src/formats/yarn/classic_rc.rs b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs index bbb9d5594..7c47a951b 100644 --- a/crates/socket-patch-core/src/formats/yarn/classic_rc.rs +++ b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs @@ -1,152 +1,326 @@ -//! Yarn classic's `.yarnrc`: one `key value` pair per line, read the way -//! yarn 1's lockfile tokenizer reads it (yarn parses `.yarnrc` with the -//! lockfile grammar, falling back to YAML). +//! Yarn classic's `.yarnrc`, read the way yarn 1.22 reads the whole file +//! (`lockfile/parse.js`): first with the lockfile grammar, and when that +//! fails anywhere in the file, as YAML (`safeLoad`, FAILSAFE schema). A +//! file neither grammar reads makes yarn exit with an error, so it sets +//! nothing here. //! -//! Every `.yarnrc` reader goes through [`value`] (or [`setting`] for the -//! raw text), so the npm crawler's -//! `--modules-folder` read and the hosted redirect's `global-folder` and -//! `yarn-offline-mirror` reads cannot disagree on a line (#1901). +//! Every `.yarnrc` reader goes through [`scalar`] or [`value`], so the npm +//! crawler's `--modules-folder` read and the hosted redirect's +//! `global-folder` and `yarn-offline-mirror` reads cannot disagree on a +//! file (#1901). + +use std::collections::HashMap; + +use serde_saphyr::granit_parser::{Event, Options, Parser}; use crate::formats::text::strip_bom; -/// The raw text after the last `key_name` in a `.yarnrc` (`key value`, -/// `key: value` or `key:value` lines, the key optionally double-quoted, -/// `#` comment lines); later lines override earlier ones. Empty when the -/// key has no value. [`value`] decodes it. -pub fn setting<'a>(text: &'a str, key_name: &str) -> Option<&'a str> { - last_line(text, key_name).map(|(rest, _)| rest) +/// A top-level `.yarnrc` value yarn hands to its config. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RcScalar { + /// A string: a double-quoted JSON string or a bare word, or any YAML + /// scalar (the FAILSAFE schema types nothing else). + Str(String), + /// A lockfile-grammar `true` / `false`. + Bool(bool), + /// A lockfile-grammar run of digits, as written. + Num(String), } -/// [`setting`], and whether the key and value are separated by a `:` and -/// whitespace: the `key: value` pair yarn's YAML fallback reads. -fn last_line<'a>(text: &'a str, key_name: &str) -> Option<(&'a str, bool)> { - let mut found = None; - for line in strip_bom(text).lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, rest) = match line.strip_prefix('"') { - Some(quoted) => match quoted.split_once('"') { - Some((key, rest)) => (key, rest), - None => continue, - }, - // yarn's tokenizer also ends an unquoted key at `:`, so - // `key: value` and `key:value` set the key like `key value`. - None => match line.find(|c: char| c.is_whitespace() || c == ':') { - Some(end) => (&line[..end], &line[end..]), - None => (line, ""), - }, - }; - if key == key_name { - let rest = rest.trim_start(); - found = Some(match rest.strip_prefix(':') { - Some(value) => (value, value.starts_with([' ', '\t'])), - None => (rest, false), - }); - } +/// The top-level `key_name` value of a `.yarnrc` as yarn 1 reads the file +/// (see the module docs). `None` when the key is unset, holds a nested +/// block, or yarn can't read the file. +pub fn scalar(text: &str, key_name: &str) -> Option { + settings(text)?.remove(key_name) +} + +/// Every top-level scalar setting of a `.yarnrc` (see [`scalar`]), for a +/// caller that reads several keys from one file. `None` when yarn can't +/// read the file. +pub fn settings(text: &str) -> Option> { + top_level(strip_bom(text)) +} + +/// The string value of `key_name` (see [`scalar`]). A boolean or number +/// is not a path or name yarn can use, so it is `None` too. +pub fn value(text: &str, key_name: &str) -> Option { + match scalar(text, key_name)? { + RcScalar::Str(value) => Some(value), + RcScalar::Bool(_) | RcScalar::Num(_) => None, } - found } -/// The value token that starts `rest` (what follows a key in a `.yarnrc` -/// line), read like yarn's lockfile tokenizer: a double-quoted string is -/// JSON (escapes decoded), a bare word starts with a letter, `/`, `.` or -/// `-` and ends at a space, `:` or `,` (so a trailing `# comment` is not -/// part of it), and a run of digits is a number. A single-quoted string -/// is read only in a `key: '…'` line (`yaml_pair`): yarn's tokenizer -/// rejects the quote, and its YAML fallback reads the file only as such -/// pairs, so `key 'value'` sets nothing. Anything -/// else (`~/x`, an unterminated quote) is a token yarn can't read, and so -/// is a line that goes on past the value with anything but a `#` comment -/// (`C:\deps` is the word `C`, a `:` and an unreadable `\`): `None`. -fn token(rest: &str, yaml_pair: bool) -> Option { - let (value, tail) = split_token(rest, yaml_pair)?; - let tail = tail.trim(); - (tail.is_empty() || tail.starts_with('#')).then_some(value) +/// Every top-level scalar setting, or `None` when neither grammar reads +/// the file. +fn top_level(text: &str) -> Option> { + match lockfile_grammar(text) { + Some(found) => Some(found), + None => yaml_fallback(text), + } } -/// The value token that starts `rest` and the text after it (see -/// [`token`]). -fn split_token(rest: &str, yaml_pair: bool) -> Option<(String, &str)> { - let rest = rest.trim_start_matches([' ', '\t']); - if let Some(quoted) = rest.strip_prefix('\'') { - if !yaml_pair { - return None; - } - let mut out = String::new(); - let mut chars = quoted.char_indices().peekable(); - while let Some((i, c)) = chars.next() { - match c { - '\'' if matches!(chars.peek(), Some((_, '\''))) => { - chars.next(); - out.push('\''); +#[derive(Debug, Clone, PartialEq)] +enum Token { + Newline, + Comment, + Indent(usize), + Str(String), + Num(String), + Bool(bool), + Colon, + Comma, + Invalid, + Eof, +} + +/// yarn 1's lockfile tokenizer (`tokenise` in `lockfile/parse.js`). +fn tokenize(mut input: &str) -> Vec { + let mut out = Vec::new(); + let mut last_newline = false; + while !input.is_empty() { + let bytes = input.as_bytes(); + let (token, chop) = match bytes[0] { + b'\n' | b'\r' => { + let chop = if bytes.get(1) == Some(&b'\n') { 2 } else { 1 }; + (Some(Token::Newline), chop) + } + b'#' => { + let end = input.find(['\n', '\r']).unwrap_or(input.len()); + (Some(Token::Comment), end.max(1)) + } + b' ' if last_newline => { + let size = bytes.iter().take_while(|b| **b == b' ').count(); + if size % 2 == 1 { + // yarn throws "Invalid number of spaces". + out.push(Token::Invalid); + break; } - '\'' => return Some((out, "ed[i + 1..])), - c => out.push(c), + (Some(Token::Indent(size / 2)), size) } - } - return None; - } - if rest.starts_with('"') { - let bytes = rest.as_bytes(); - let mut end = rest.len(); - for i in 1..bytes.len() { - let escaped = bytes[i - 1] == b'\\' && (i < 2 || bytes[i - 2] != b'\\'); - if bytes[i] == b'"' && !escaped { - end = i + 1; + b' ' => (None, 1), + b'"' => { + let mut end = input.len(); + for i in 1..bytes.len() { + let escaped = bytes[i - 1] == b'\\' && (i < 2 || bytes[i - 2] != b'\\'); + if bytes[i] == b'"' && !escaped { + end = i + 1; + break; + } + } + let token = serde_json::from_str::(&input[..end]) + .map(Token::Str) + .unwrap_or(Token::Invalid); + (Some(token), end) + } + b'0'..=b'9' => { + let end = bytes.iter().take_while(|b| b.is_ascii_digit()).count(); + (Some(Token::Num(input[..end].to_string())), end) + } + _ if input.starts_with("true") => (Some(Token::Bool(true)), 4), + _ if input.starts_with("false") => (Some(Token::Bool(false)), 5), + b':' => (Some(Token::Colon), 1), + b',' => (Some(Token::Comma), 1), + c if c.is_ascii_alphabetic() || matches!(c, b'/' | b'.' | b'-') => { + let end = input + .find([':', ' ', '\n', '\r', ',']) + .unwrap_or(input.len()); + (Some(Token::Str(input[..end].to_string())), end) + } + _ => (Some(Token::Invalid), 0), + }; + match token { + Some(Token::Invalid) | None if chop == 0 => { + out.push(Token::Invalid); break; } + Some(token) => { + last_newline = token == Token::Newline; + out.push(token); + } + None => last_newline = false, } - let value = serde_json::from_str(&rest[..end]).ok()?; - return Some((value, &rest[end..])); + input = &input[chop..]; } - let first = rest.chars().next()?; - let end = if first.is_ascii_digit() { - rest.find(|c: char| !c.is_ascii_digit()) - } else if first.is_ascii_alphabetic() || matches!(first, '/' | '.' | '-') { - rest.find([':', ' ', ',', '\r', '\n']) - } else { - return None; + out.push(Token::Eof); + out +} + +/// A parsed lockfile-grammar value. +enum Node { + Scalar(RcScalar), + Block, +} + +/// yarn 1's lockfile parser (`Parser.parse` in `lockfile/parse.js`), +/// keeping the top-level scalars. `None` where yarn throws. +fn lockfile_grammar(text: &str) -> Option> { + let tokens = tokenize(text); + let mut pos = 0; + let mut out = HashMap::new(); + parse_block(&tokens, &mut pos, 0, Some(&mut out))?; + Some(out) +} + +fn parse_block( + tokens: &[Token], + pos: &mut usize, + indent: usize, + mut out: Option<&mut HashMap>, +) -> Option<()> { + let at = |pos: usize| tokens.get(pos).unwrap_or(&Token::Eof); + loop { + match at(*pos) { + Token::Newline => { + *pos += 1; + if indent == 0 { + continue; + } + match at(*pos) { + Token::Indent(n) if *n == indent => *pos += 1, + _ => break, + } + } + Token::Indent(n) => { + if *n == indent { + *pos += 1; + } else { + break; + } + } + Token::Eof => break, + Token::Comment => *pos += 1, + Token::Str(key) => { + if key.is_empty() { + return None; + } + let mut keys = vec![key.clone()]; + *pos += 1; + while *at(*pos) == Token::Comma { + *pos += 1; + match at(*pos) { + Token::Str(key) => keys.push(key.clone()), + _ => return None, + } + *pos += 1; + } + let was_colon = *at(*pos) == Token::Colon; + if was_colon { + *pos += 1; + } + let node = match at(*pos) { + Token::Str(v) => Node::Scalar(RcScalar::Str(v.clone())), + Token::Num(v) => Node::Scalar(RcScalar::Num(v.clone())), + Token::Bool(v) => Node::Scalar(RcScalar::Bool(*v)), + _ if was_colon => { + parse_block(tokens, pos, indent + 1, None)?; + Node::Block + } + _ => return None, + }; + if let Node::Scalar(_) = node { + *pos += 1; + } + if let Some(out) = out.as_deref_mut() { + for key in keys { + match &node { + Node::Scalar(v) => out.insert(key, v.clone()), + Node::Block => out.remove(&key), + }; + } + } + if matches!(node, Node::Block) + && indent > 0 + && !matches!(at(*pos), Token::Indent(_)) + { + break; + } + } + _ => return None, + } } - .unwrap_or(rest.len()); - Some((rest[..end].to_string(), &rest[end..])) + Some(()) } -/// The decoded value of the last `key_name` line in a `.yarnrc` -/// (the last [`setting`], decoded like yarn's tokenizer). `None` when the key is unset, has no -/// value, or its last value is a token yarn can't read. -pub fn value(text: &str, key_name: &str) -> Option { - last_line(text, key_name).and_then(|(rest, yaml_pair)| token(rest, yaml_pair)) +/// yarn's YAML fallback: the file's root mapping, every scalar a string. +/// A root that is not a mapping sets nothing; a YAML error means yarn +/// rethrows the lockfile error (`None`). +fn yaml_fallback(text: &str) -> Option> { + let mut options = Options::default(); + options.emit_comments = false; + let mut out = HashMap::new(); + let mut depth = 0usize; + let mut documents = 0usize; + let mut root_is_mapping = false; + let mut pending_key: Option = None; + let mut at_key = true; + for event in Parser::new_from_str_with_options(text, options) { + let (event, _) = event.ok()?; + match event { + Event::DocumentStart(..) => { + documents += 1; + if documents > 1 { + return None; + } + } + Event::MappingStart(..) | Event::SequenceStart(..) => { + if depth == 0 { + root_is_mapping = matches!(event, Event::MappingStart(..)); + } else if depth == 1 { + if let Some(key) = pending_key.take() { + out.remove(&key); + } + at_key = !at_key; + } + depth += 1; + } + Event::MappingEnd | Event::SequenceEnd => depth = depth.saturating_sub(1), + Event::Scalar(text, ..) if depth == 1 && root_is_mapping => { + if at_key { + pending_key = Some(text.into_owned()); + } else if let Some(key) = pending_key.take() { + out.insert(key, RcScalar::Str(text.into_owned())); + } + at_key = !at_key; + } + Event::Alias(_) if depth == 1 => { + pending_key = None; + at_key = !at_key; + } + _ => {} + } + } + Some(out) } #[cfg(test)] mod tests { use super::*; + fn v(rc: &str) -> Option { + value(rc, "--modules-folder") + } + + /// Each `.yarnrc` below was checked against yarn 1.22.22 (where + /// `yarn install` put the package). #[test] - fn value_reads_keys_and_values_like_yarn() { - let v = |rc: &str| value(rc, "--modules-folder"); + fn lockfile_grammar_reads_like_yarn() { assert_eq!(v("--modules-folder deps\n").as_deref(), Some("deps")); + assert_eq!(v("--modules-folder \"deps\"\n").as_deref(), Some("deps")); + assert_eq!(v("--modules-folder deps # c\n").as_deref(), Some("deps")); + assert_eq!(v("--modules-folder:deps\n").as_deref(), Some("deps")); assert_eq!(v("--modules-folder: lib\n").as_deref(), Some("lib")); - assert_eq!(v("--modules-folder:lib\n").as_deref(), Some("lib")); assert_eq!( v("\"--modules-folder\" \"./my deps\"\n").as_deref(), Some("./my deps") ); - // A single-quoted value only in a YAML `key: '…'` pair (each - // checked against yarn 1.22.22). - assert_eq!(v("--modules-folder: 'deps'\n").as_deref(), Some("deps")); - assert_eq!(v("--modules-folder:\t'deps'\n").as_deref(), Some("deps")); - assert_eq!(v("--modules-folder : 'deps'\n").as_deref(), Some("deps")); - assert_eq!(v("\"--modules-folder\": 'deps'\n").as_deref(), Some("deps")); - assert_eq!(v("--modules-folder: 'deps' # c\n").as_deref(), Some("deps")); - assert_eq!(v("--modules-folder: 'it''s'\n").as_deref(), Some("it's")); assert_eq!( v("--modules-folder \"C:\\\\deps\"\n").as_deref(), Some("C:\\deps") ); - assert_eq!(v("--modules-folder deps # c\n").as_deref(), Some("deps")); + assert_eq!( + v("--modules-folder: deps\nfoo bar\n").as_deref(), + Some("deps") + ); assert_eq!( v("\u{feff}# c\r\n--modules-folder deps\r\n").as_deref(), Some("deps") @@ -155,32 +329,78 @@ mod tests { v("--modules-folder a\n--modules-folder b\n").as_deref(), Some("b") ); + // `a, b value` sets both keys. + let rc = "--modules-folder, --install.modules-folder deps\n"; + assert_eq!(v(rc).as_deref(), Some("deps")); + assert_eq!( + value(rc, "--install.modules-folder").as_deref(), + Some("deps") + ); + } + + /// When the lockfile grammar fails anywhere, yarn reads the file as + /// YAML: a single-quoted value or one with spaces counts only in a + /// `key: value` pair. + #[test] + fn yaml_fallback_reads_like_yarn() { + for (rc, want) in [ + ("--modules-folder: 'deps'\n", "deps"), + ("--modules-folder:\t'deps'\n", "deps"), + ("--modules-folder : 'deps'\n", "deps"), + ("\"--modules-folder\": 'deps'\n", "deps"), + ("--modules-folder: 'deps' # c\n", "deps"), + ("--modules-folder: 'it''s'\n", "it's"), + ("--modules-folder: my deps\n", "my deps"), + ("--modules-folder: ~/x\n", "~/x"), + ] { + assert_eq!(v(rc).as_deref(), Some(want), "{rc:?}"); + } } - /// A value yarn can't tokenize, or none at all, sets nothing, and - /// as the last setting it hides an earlier readable one. + /// A file yarn reads as no mapping, or can't read at all (yarn exits + /// with an error), sets nothing; so does a boolean or number value. #[test] - fn unreadable_or_missing_value_is_none() { + fn unreadable_file_or_value_is_none() { for rc in [ "", "# --modules-folder deps\n", "--modules-folder-x deps\n", "--modules-folder\n", - "--modules-folder ~/x\n", - "--modules-folder C:\\deps\n", - "--modules-folder \"unterminated\n", - "--modules-folder: 'unterminated\n", - "--modules-folder deps\n--modules-folder ~/x\n", - "--modules-folder deps, more\n", - "--modules-folder: 'deps' x\n", "--modules-folder 'deps'\n", "--modules-folder 'my deps'\n", "--modules-folder:'deps'\n", "\"--modules-folder\" 'deps'\n", "\"--modules-folder\":'deps'\n", + "--modules-folder ~/x\n", + "--modules-folder C:\\deps\n", "--modules-folder C:deps\n", + "--modules-folder deps, x\n", + "--modules-folder trueDeps\n", + "--modules-folder false\n", + "--modules-folder 0\n", + "--modules-folder\tdeps\n", + "--modules-folder \"unterminated\n", + "--modules-folder deps\nyarn-path ~/y.js\n", + "--modules-folder deps\n odd\n", ] { - assert_eq!(value(rc, "--modules-folder"), None, "{rc:?}"); + assert_eq!(v(rc), None, "{rc:?}"); } + // An empty value set last overrides an earlier one. + assert_eq!( + v("--modules-folder deps\n--modules-folder \"\"\n").as_deref(), + Some("") + ); + assert_eq!( + scalar("--modules-folder false\n", "--modules-folder"), + Some(RcScalar::Bool(false)) + ); + } + + /// A key holding a nested block is not a scalar setting. + #[test] + fn nested_block_is_not_a_scalar() { + let rc = "--modules-folder deps\n--modules-folder:\n x y\nother z\n"; + assert_eq!(v(rc), None); + assert_eq!(value(rc, "other").as_deref(), Some("z")); } } diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs index ad2c2d1dc..6a4b1ea82 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -11,7 +11,7 @@ //! * the stanza view the hosted berry writers re-key and re-order //! entries in ([`stanzas`]); //! * a `.yarnrc.yml` setting as yarn resolves it ([`rc`]), and a yarn -//! classic `.yarnrc` line as yarn 1 tokenizes it ([`classic_rc`]); +//! classic `.yarnrc` as yarn 1 reads it (`classic_rc`); //! * the berry pinned-entry renderer ([`berry_entry`]) and the walk that //! drops the entries a pin leaves unreachable ([`berry_prune`]). //! diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index ac1880cd8..1d35a8b2b 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -24,7 +24,7 @@ use std::path::{Path, PathBuf}; use super::npmrc::NpmConfigEnv; use crate::formats::text::strip_bom; -use crate::formats::yarn::classic_rc; +use crate::formats::yarn::classic_rc::{self, RcScalar}; const KEY: &str = "yarn-offline-mirror"; @@ -130,11 +130,18 @@ fn unquote_rc_value(raw: &str) -> &str { raw } -/// The last `yarn-offline-mirror` value in a `.yarnrc` (`key value` or -/// `key: value` lines, key optionally quoted, `#` comments); later lines -/// override earlier ones. +/// The `yarn-offline-mirror` value of a `.yarnrc`, read as yarn 1 reads +/// the file ([`classic_rc`]): `false` disables the mirror, and a value +/// yarn can't resolve as a path (`true`, a number) or a file yarn can't +/// read sets nothing. pub fn yarnrc_value(text: &str) -> Option { - classic_rc::setting(text, KEY).map(mirror_value) + match classic_rc::scalar(text, KEY)? { + RcScalar::Str(value) if value == "false" => Some(MirrorValue::Disabled), + RcScalar::Str(value) => Some(MirrorValue::Path(value)), + RcScalar::Bool(false) => Some(MirrorValue::Disabled), + // yarn's `path.resolve` throws on a `true` or a number. + RcScalar::Bool(true) | RcScalar::Num(_) => None, + } } /// The last top-level `yarn-offline-mirror` value in an `.npmrc` (ini @@ -358,7 +365,7 @@ const GLOBAL_FOLDER_KEY: &str = "global-folder"; /// folder, so all of them count. /// /// Values read as in `yarn@1.22.22`: a `.yarnrc` value is one token of -/// yarn's rc grammar ([`classic_rc::token`]) resolved against the folder of +/// yarn's rc grammar (`formats::yarn::classic_rc`) resolved against the folder of /// its file (`RELATIVE_KEYS`); an `.npmrc` value has its `${NAME}` /// references expanded ([`yarn_env_replace`]); env and `.npmrc` values /// then go through `resolveWithHome` (`~/` is yarn's `userHome`, else the From 33c82c49e280c28c10a7b2220ca9bd7deba0a461 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:09:51 +0000 Subject: [PATCH 5/7] Fix .yarnrc doc links to the shared reader The public yarnrc docs linked to a crate-private module, which rustdoc reports as a private link. Name it in code instead. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/patch/redirect/yarnrc.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index 1d35a8b2b..22c4f0a01 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -131,9 +131,9 @@ fn unquote_rc_value(raw: &str) -> &str { } /// The `yarn-offline-mirror` value of a `.yarnrc`, read as yarn 1 reads -/// the file ([`classic_rc`]): `false` disables the mirror, and a value -/// yarn can't resolve as a path (`true`, a number) or a file yarn can't -/// read sets nothing. +/// the file (`formats::yarn::classic_rc`): `false` disables the mirror, +/// and a value yarn can't resolve as a path (`true`, a number) or a file +/// yarn can't read sets nothing. pub fn yarnrc_value(text: &str) -> Option { match classic_rc::scalar(text, KEY)? { RcScalar::Str(value) if value == "false" => Some(MirrorValue::Disabled), @@ -364,9 +364,9 @@ const GLOBAL_FOLDER_KEY: &str = "global-folder"; /// precedence, but a folder any layer names was set up as a yarn global /// folder, so all of them count. /// -/// Values read as in `yarn@1.22.22`: a `.yarnrc` value is one token of -/// yarn's rc grammar (`formats::yarn::classic_rc`) resolved against the folder of -/// its file (`RELATIVE_KEYS`); an `.npmrc` value has its `${NAME}` +/// Values read as in `yarn@1.22.22`: a `.yarnrc` value is a string as +/// yarn reads the file (`formats::yarn::classic_rc`), resolved against +/// the folder of its file (`RELATIVE_KEYS`); an `.npmrc` value has its `${NAME}` /// references expanded ([`yarn_env_replace`]); env and `.npmrc` values /// then go through `resolveWithHome` (`~/` is yarn's `userHome`, else the /// cwd). Empty values set nothing. From 11551ef670e0c23977d15223ab2b87730b38b681 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:30:16 +0000 Subject: [PATCH 6/7] Match js-yaml and yarn's tokenizer in .yarnrc Review against yarn 1.22's parse.js and js-yaml's FAILSAFE schema found these gaps in the shared reader: - An empty YAML value is null (unset), not the string "~". - A duplicate key or an unknown tag makes yarn reject the file. - An alias reads its anchored value. - Only \n or \r\n starts an indent, and a comment runs to \n. - An empty key after a comma is an error. The offline-mirror read now treats only boolean false as "no mirror": yarn resolves a string "false" as a mirror path. Callers that read several keys parse each file once. Refs #1901 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/npm_crawler.rs | 5 +- .../src/formats/yarn/classic_rc.rs | 155 +++++++++++++----- .../src/patch/redirect/yarnrc.rs | 18 +- 3 files changed, 127 insertions(+), 51 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 58cf39a9f..a6e5c00ea 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -346,10 +346,7 @@ fn parse_yarnrc_modules_folder(rc: &str) -> YarnrcModulesFolder { let Some(mut settings) = classic_rc::settings(rc) else { return YarnrcModulesFolder::default(); }; - let mut read = |key| match settings.remove(key) { - Some(classic_rc::RcScalar::Str(value)) if !value.is_empty() => Some(value), - _ => None, - }; + let mut read = |key| classic_rc::take_str(&mut settings, key).filter(|v| !v.is_empty()); YarnrcModulesFolder { general: read("--modules-folder"), install: read("--install.modules-folder"), diff --git a/crates/socket-patch-core/src/formats/yarn/classic_rc.rs b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs index 7c47a951b..a66b5864d 100644 --- a/crates/socket-patch-core/src/formats/yarn/classic_rc.rs +++ b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs @@ -4,7 +4,7 @@ //! file neither grammar reads makes yarn exit with an error, so it sets //! nothing here. //! -//! Every `.yarnrc` reader goes through [`scalar`] or [`value`], so the npm +//! Every `.yarnrc` reader goes through [`settings`] or [`scalar`], so the npm //! crawler's `--modules-folder` read and the hosted redirect's //! `global-folder` and `yarn-offline-mirror` reads cannot disagree on a //! file (#1901). @@ -41,10 +41,10 @@ pub fn settings(text: &str) -> Option> { top_level(strip_bom(text)) } -/// The string value of `key_name` (see [`scalar`]). A boolean or number -/// is not a path or name yarn can use, so it is `None` too. -pub fn value(text: &str, key_name: &str) -> Option { - match scalar(text, key_name)? { +/// Take `key_name`'s string value out of [`settings`]. A boolean or +/// number is not a path or name yarn can use, so it is `None` too. +pub fn take_str(settings: &mut HashMap, key_name: &str) -> Option { + match settings.remove(key_name)? { RcScalar::Str(value) => Some(value), RcScalar::Bool(_) | RcScalar::Num(_) => None, } @@ -84,10 +84,11 @@ fn tokenize(mut input: &str) -> Vec { let chop = if bytes.get(1) == Some(&b'\n') { 2 } else { 1 }; (Some(Token::Newline), chop) } - b'#' => { - let end = input.find(['\n', '\r']).unwrap_or(input.len()); - (Some(Token::Comment), end.max(1)) - } + // A comment runs to the next `\n`: a lone `\r` doesn't end it. + b'#' => ( + Some(Token::Comment), + input.find('\n').unwrap_or(input.len()), + ), b' ' if last_newline => { let size = bytes.iter().take_while(|b| **b == b' ').count(); if size % 2 == 1 { @@ -128,17 +129,13 @@ fn tokenize(mut input: &str) -> Vec { } _ => (Some(Token::Invalid), 0), }; - match token { - Some(Token::Invalid) | None if chop == 0 => { - out.push(Token::Invalid); - break; - } - Some(token) => { - last_newline = token == Token::Newline; - out.push(token); - } - None => last_newline = false, + if chop == 0 { + out.push(Token::Invalid); + break; } + out.extend(token); + // yarn starts an indent only after `\n` or `\r\n`, not a lone `\r`. + last_newline = input.starts_with('\n') || input.starts_with("\r\n"); input = &input[chop..]; } out.push(Token::Eof); @@ -198,7 +195,7 @@ fn parse_block( while *at(*pos) == Token::Comma { *pos += 1; match at(*pos) { - Token::Str(key) => keys.push(key.clone()), + Token::Str(key) if !key.is_empty() => keys.push(key.clone()), _ => return None, } *pos += 1; @@ -241,20 +238,49 @@ fn parse_block( Some(()) } -/// yarn's YAML fallback: the file's root mapping, every scalar a string. -/// A root that is not a mapping sets nothing; a YAML error means yarn -/// rethrows the lockfile error (`None`). +/// yarn's YAML fallback (js-yaml `safeLoad`, FAILSAFE schema): the +/// file's root mapping, every scalar a string and an empty value null +/// (unset). A root that is not a mapping sets nothing. Where js-yaml +/// throws (a duplicate key, a tag the FAILSAFE schema doesn't know, a +/// syntax error), yarn rethrows the lockfile error: `None`. fn yaml_fallback(text: &str) -> Option> { let mut options = Options::default(); options.emit_comments = false; let mut out = HashMap::new(); + let mut keys = std::collections::HashSet::new(); + // Anchored scalars, for an alias to read. + let mut anchors: HashMap> = HashMap::new(); let mut depth = 0usize; let mut documents = 0usize; let mut root_is_mapping = false; let mut pending_key: Option = None; let mut at_key = true; + // A root-level key or value: a scalar (`None` for null or a + // collection). + let mut root_node = |node: Option, + pending_key: &mut Option, + at_key: &mut bool, + out: &mut HashMap| + -> Option<()> { + if *at_key { + // A non-scalar key is not a setting name; js-yaml would read + // it as "[object Object]", which no caller asks for. + let key = node.unwrap_or_default(); + if !keys.insert(key.clone()) { + return None; + } + *pending_key = Some(key); + } else if let Some(key) = pending_key.take() { + match node { + Some(value) => out.insert(key, RcScalar::Str(value)), + None => out.remove(&key), + }; + } + *at_key = !*at_key; + Some(()) + }; for event in Parser::new_from_str_with_options(text, options) { - let (event, _) = event.ok()?; + let (event, span) = event.ok()?; match event { Event::DocumentStart(..) => { documents += 1; @@ -262,29 +288,39 @@ fn yaml_fallback(text: &str) -> Option> { return None; } } - Event::MappingStart(..) | Event::SequenceStart(..) => { + Event::MappingStart(_, _, ref tag) | Event::SequenceStart(_, _, ref tag) => { + let is_map = matches!(event, Event::MappingStart(..)); + let known = if is_map { "map" } else { "seq" }; + if tag + .as_ref() + .is_some_and(|t| !t.is_yaml_core_schema_tag(known)) + { + return None; + } if depth == 0 { - root_is_mapping = matches!(event, Event::MappingStart(..)); - } else if depth == 1 { - if let Some(key) = pending_key.take() { - out.remove(&key); - } - at_key = !at_key; + root_is_mapping = is_map; + } else if depth == 1 && root_is_mapping { + root_node(None, &mut pending_key, &mut at_key, &mut out)?; } depth += 1; } Event::MappingEnd | Event::SequenceEnd => depth = depth.saturating_sub(1), - Event::Scalar(text, ..) if depth == 1 && root_is_mapping => { - if at_key { - pending_key = Some(text.into_owned()); - } else if let Some(key) = pending_key.take() { - out.insert(key, RcScalar::Str(text.into_owned())); + Event::Scalar(text, _, anchor, tag) => { + if tag.is_some_and(|t| !t.is_yaml_core_schema_tag("str")) { + return None; + } + // An empty node is null; a written `~` is the string `~`. + let node = (!span.is_empty()).then(|| text.into_owned()); + if anchor != 0 { + anchors.insert(anchor, node.clone()); + } + if depth == 1 && root_is_mapping { + root_node(node, &mut pending_key, &mut at_key, &mut out)?; } - at_key = !at_key; } - Event::Alias(_) if depth == 1 => { - pending_key = None; - at_key = !at_key; + Event::Alias(anchor) if depth == 1 && root_is_mapping => { + let node = anchors.get(&anchor).cloned().flatten(); + root_node(node, &mut pending_key, &mut at_key, &mut out)?; } _ => {} } @@ -296,6 +332,10 @@ fn yaml_fallback(text: &str) -> Option> { mod tests { use super::*; + fn value(rc: &str, key: &str) -> Option { + take_str(&mut settings(rc)?, key) + } + fn v(rc: &str) -> Option { value(rc, "--modules-folder") } @@ -396,6 +436,41 @@ mod tests { ); } + /// js-yaml's FAILSAFE reading: an empty value is null (unset), a + /// written `~` or `false` is a string, an alias reads its anchor, and + /// a duplicate key or an unknown tag fails the whole file. + #[test] + fn yaml_fallback_follows_js_yaml_failsafe() { + // `'x'` forces the YAML fallback in each file. + assert_eq!(v("a: 'x'\n--modules-folder:\n"), None); + assert_eq!(v("a: 'x'\n--modules-folder: ~\n").as_deref(), Some("~")); + assert_eq!( + scalar("a: 'x'\n--modules-folder: false\n", "--modules-folder"), + Some(RcScalar::Str("false".into())) + ); + assert_eq!( + v("a: &x 'deps'\n--modules-folder: *x\n").as_deref(), + Some("deps") + ); + assert_eq!(v("--modules-folder: 'a'\n--modules-folder: 'b'\n"), None); + assert_eq!(v("--modules-folder: !!int 'deps'\n"), None); + assert_eq!( + v("--modules-folder: !!str 'deps'\n").as_deref(), + Some("deps") + ); + } + + /// Only `\n` or `\r\n` start an indent, and a comment runs to `\n`. + #[test] + fn lone_cr_follows_yarns_tokenizer() { + assert_eq!( + v("a b\r c d\r--modules-folder deps\n").as_deref(), + Some("deps") + ); + assert_eq!(v("# c\r--modules-folder deps\r"), None); + assert_eq!(v("--modules-folder, \"\" deps\n"), None); + } + /// A key holding a nested block is not a scalar setting. #[test] fn nested_block_is_not_a_scalar() { diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index 22c4f0a01..27a383b98 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -118,8 +118,8 @@ fn mirror_value(raw: &str) -> MirrorValue { } } -/// Strip one pair of matching quotes, as yarn's `.yarnrc` parser and npm's -/// ini parser both do. +/// Strip one pair of matching quotes, as npm's ini parser does (`.npmrc` +/// only: a `.yarnrc` is read through `formats::yarn::classic_rc`). fn unquote_rc_value(raw: &str) -> &str { let raw = raw.trim(); for q in ['"', '\''] { @@ -131,12 +131,13 @@ fn unquote_rc_value(raw: &str) -> &str { } /// The `yarn-offline-mirror` value of a `.yarnrc`, read as yarn 1 reads -/// the file (`formats::yarn::classic_rc`): `false` disables the mirror, -/// and a value yarn can't resolve as a path (`true`, a number) or a file -/// yarn can't read sets nothing. +/// the file (`formats::yarn::classic_rc`): a boolean `false` disables +/// the mirror, and a value yarn can't resolve as a path (`true`, a +/// number) or a file yarn can't read sets nothing. pub fn yarnrc_value(text: &str) -> Option { match classic_rc::scalar(text, KEY)? { - RcScalar::Str(value) if value == "false" => Some(MirrorValue::Disabled), + // Only the boolean turns the mirror off: yarn resolves a string + // `"false"` (or a YAML-read `false`) as a mirror path. RcScalar::Str(value) => Some(MirrorValue::Path(value)), RcScalar::Bool(false) => Some(MirrorValue::Disabled), // yarn's `path.resolve` throws on a `true` or a number. @@ -398,8 +399,11 @@ pub fn configured_yarn_global_folders( for path in rc_files("yarnrc", &["yarn_"]) { let Some(text) = read(&path) else { continue }; let Some(dir) = path.parent() else { continue }; + let Some(mut settings) = classic_rc::settings(&text) else { + continue; + }; for key in [GLOBAL_FOLDER_KEY, "--global-folder"] { - push(classic_rc::value(&text, key), &|value| { + push(classic_rc::take_str(&mut settings, key), &|value| { Some(dir.join(value)) }); } From b75e13c04ef5c8dbdd73f232ced8ec48273e2c2f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:15:58 +0000 Subject: [PATCH 7/7] Cap .yarnrc block nesting before recursing The .yarnrc parser recursed once per nested block with no limit, so a repository .yarnrc nested a few thousand levels deep overflowed the stack and aborted the whole scan. Past 64 levels the file now reads as unreadable, which is also how yarn's own recursive parser fails on it. Refs #1901 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/formats/yarn/classic_rc.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/socket-patch-core/src/formats/yarn/classic_rc.rs b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs index a66b5864d..5a57b72ee 100644 --- a/crates/socket-patch-core/src/formats/yarn/classic_rc.rs +++ b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs @@ -158,12 +158,21 @@ fn lockfile_grammar(text: &str) -> Option> { Some(out) } +/// How deep [`parse_block`] nests before it calls the file unreadable. +/// The `.yarnrc` comes from the (untrusted) project, and the parser +/// recurses once per nested block, so a hostile nesting depth must not +/// overflow the stack. No yarn setting nests more than a level or two. +const MAX_BLOCK_DEPTH: usize = 64; + fn parse_block( tokens: &[Token], pos: &mut usize, indent: usize, mut out: Option<&mut HashMap>, ) -> Option<()> { + if indent > MAX_BLOCK_DEPTH { + return None; + } let at = |pos: usize| tokens.get(pos).unwrap_or(&Token::Eof); loop { match at(*pos) { @@ -471,6 +480,31 @@ mod tests { assert_eq!(v("--modules-folder, \"\" deps\n"), None); } + /// Nesting past [`MAX_BLOCK_DEPTH`] makes the file unreadable instead + /// of recursing without bound (a repository `.yarnrc` is untrusted). + #[test] + fn deep_nesting_is_unreadable_not_a_stack_overflow() { + let nested = |levels: usize| { + let mut rc = String::from("--modules-folder deps\n"); + for level in 0..levels { + rc.push_str(&" ".repeat(level)); + rc.push_str("x:\n"); + } + rc + }; + assert_eq!(v(&nested(3)).as_deref(), Some("deps")); + assert_eq!(lockfile_grammar(&nested(MAX_BLOCK_DEPTH + 10)), None); + // Run the deep case on a small stack: it must return, not overflow. + let rc = nested(2_000); + let found = std::thread::Builder::new() + .stack_size(256 * 1024) + .spawn(move || lockfile_grammar(&rc)) + .unwrap() + .join() + .unwrap(); + assert_eq!(found, None); + } + /// A key holding a nested block is not a scalar setting. #[test] fn nested_block_is_not_a_scalar() {