diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 2d6acefd5..a6e5c00ea 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,19 @@ 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 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 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); - } - } - } - 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 Some(mut settings) = classic_rc::settings(rc) else { + return YarnrcModulesFolder::default(); + }; + 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"), } - 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 +8143,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 +8167,71 @@ mod tests { } } + /// REGRESSION (#1901): the crawler reads a `.yarnrc` line the way + /// 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\": '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 'deps'\n", + "--modules-folder ~/x\n", + "--modules-folder deps\n--modules-folder ~/x\n", + "--modules-folder deps, 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: '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(); + write_pkg(&root.join("deps/left-pad"), "left-pad", "1.3.0"); + 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()] + ); + } + 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..5a57b72ee --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/classic_rc.rs @@ -0,0 +1,515 @@ +//! 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 [`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). + +use std::collections::HashMap; + +use serde_saphyr::granit_parser::{Event, Options, Parser}; + +use crate::formats::text::strip_bom; + +/// 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), +} + +/// 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)) +} + +/// 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, + } +} + +/// 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), + } +} + +#[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) + } + // 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 { + // yarn throws "Invalid number of spaces". + out.push(Token::Invalid); + break; + } + (Some(Token::Indent(size / 2)), size) + } + 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), + }; + 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); + 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) +} + +/// 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) { + 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) if !key.is_empty() => 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, + } + } + Some(()) +} + +/// 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, span) = event.ok()?; + match event { + Event::DocumentStart(..) => { + documents += 1; + if documents > 1 { + return None; + } + } + 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 = 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, _, 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)?; + } + } + 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)?; + } + _ => {} + } + } + Some(out) +} + +#[cfg(test)] +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") + } + + /// Each `.yarnrc` below was checked against yarn 1.22.22 (where + /// `yarn install` put the package). + #[test] + 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\" \"./my deps\"\n").as_deref(), + Some("./my deps") + ); + assert_eq!( + v("--modules-folder \"C:\\\\deps\"\n").as_deref(), + Some("C:\\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") + ); + assert_eq!( + 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 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_file_or_value_is_none() { + for rc in [ + "", + "# --modules-folder deps\n", + "--modules-folder-x deps\n", + "--modules-folder\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!(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)) + ); + } + + /// 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); + } + + /// 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() { + 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 280befcbe..6a4b1ea82 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` 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`]). //! @@ -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..27a383b98 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::{self, RcScalar}; const KEY: &str = "yarn-offline-mirror"; @@ -117,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 ['"', '\''] { @@ -129,39 +130,19 @@ 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 (`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 { - 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)); - } + match classic_rc::scalar(text, KEY)? { + // 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. + RcScalar::Bool(true) | RcScalar::Num(_) => None, } - found } /// The last top-level `yarn-offline-mirror` value in an `.npmrc` (ini @@ -384,9 +365,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 ([`yarnrc_token`]) 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. @@ -418,11 +399,13 @@ 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( - yarnrc_setting(&text, key).and_then(yarnrc_token), - &|value| Some(dir.join(value)), - ); + push(classic_rc::take_str(&mut settings, key), &|value| { + Some(dir.join(value)) + }); } } for path in rc_files("npmrc", &["yarn_", "npm_config_"]) { @@ -437,53 +420,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),