diff --git a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs index f89791226..74461b588 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -1224,6 +1224,86 @@ fn pnp_loader_under_explicit_pnp_linker_still_refuses() { assert_eq!(std::fs::read(&index).unwrap(), ORIGINAL_BYTES); } +/// #1100: yarn expands `${NAME}` / `${NAME:-fallback}` in `.yarnrc.yml` +/// values, so a live loader under an interpolated linker that resolves to +/// `pnp` (yarn's own `config get nodeLinker` says `pnp`) is Plug'n'Play: +/// apply refuses instead of exiting 0 `package_not_installed`. A +/// reference yarn cannot expand fails closed the same way. +#[test] +fn pnp_loader_under_interpolated_pnp_linker_refuses() { + for (rc, env) in [ + ("nodeLinker: \"${SOCKET_PATCH_T1100_L:-pnp}\"\n", &[][..]), + ( + "nodeLinker: \"${SOCKET_PATCH_T1100_L}\"\n", + &[("SOCKET_PATCH_T1100_L", "pnp")][..], + ), + ( + "nodeLinker: \"${SOCKET_PATCH_T1100_L:-node-modules}\"\n", + &[("SOCKET_PATCH_T1100_L", "pnp")][..], + ), + ("nodeLinker: ${SOCKET_PATCH_T1100_L}\n", &[][..]), + ] { + let dir = tempfile::tempdir().unwrap(); + make_yarn_berry_project(dir.path()); + std::fs::write(dir.path().join(".yarnrc.yml"), rc).unwrap(); + let index = stage_applicable_package(dir.path()); + let (code, stdout, _) = run_with_env(dir.path(), &["apply", "--json"], env); + assert_eq!(code, 1, "{rc:?}: {stdout}"); + let envelope = parse_json_envelope(&stdout); + assert_eq!( + envelope_error_code(&envelope), + Some("yarn_pnp_unsupported"), + "{rc:?}" + ); + assert_eq!(std::fs::read(&index).unwrap(), ORIGINAL_BYTES, "{rc:?}"); + } +} + +/// #1100: yarn parses `.yarnrc.yml` as YAML and reads `yarn_*` environment +/// keys in any case. A quoted key, a value on the next line, a flow map, +/// or `yarn_node_linker` all set `node-modules`, so a stale loader is +/// disowned and apply patches the installed copy instead of refusing. +#[test] +fn stale_pnp_loader_under_yaml_spelled_linker_applies() { + let mut cases: Vec<(Option<&str>, &[(&str, &str)])> = vec![ + (Some("\"nodeLinker\": node-modules\n"), &[]), + (Some("nodeLinker:\n node-modules\n"), &[]), + (Some("{nodeLinker: node-modules}\n"), &[]), + ( + Some("nodeLinker: \"${SOCKET_PATCH_T1100_L:-node-modules}\"\n"), + &[], + ), + ]; + if !cfg!(windows) { + cases.push((None, &[("yarn_node_linker", "node-modules")])); + } + for (rc, env) in cases { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path(); + std::fs::write( + cwd.join("package.json"), + r#"{"name":"migrated","version":"0.0.0","private":true}"#, + ) + .unwrap(); + std::fs::write(cwd.join("yarn.lock"), "__metadata:\n version: 8\n").unwrap(); + std::fs::write(cwd.join(".pnp.js"), b"// stale Yarn 2 loader\n").unwrap(); + if let Some(rc) = rc { + std::fs::write(cwd.join(".yarnrc.yml"), rc).unwrap(); + } + let index = stage_applicable_package(cwd); + let (code, stdout, stderr) = run_with_env(cwd, &["apply", "--json"], env); + assert_eq!( + code, 0, + "{rc:?} {env:?}: a stale loader must not refuse.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!( + std::fs::read(&index).unwrap(), + PATCHED_BYTES, + "{rc:?} {env:?}: the node_modules copy is patched" + ); + } +} + /// #1129: pnpm's `node-linker=pnp` with a custom `modulesDir` keeps its /// store in `/.pnpm`, not `node_modules/.pnpm`. The layout /// detector reads the same modules dirs as the crawler, so the tree is diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index b48e2d855..c53d2eaa8 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1408,6 +1408,11 @@ async fn berry_lock_only_pnp_project_refused_up_front() { ), ("default linker", Some("enableGlobalCache: false\n")), ("no yarnrc", None), + // #1100: yarn expands the reference to `pnp`. + ( + "interpolated pnp", + Some("nodeLinker: \"${SOCKET_PATCH_T1100_UNSET:-pnp}\"\nenableGlobalCache: false\n"), + ), ] { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 18f70789d..794253435 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -166,29 +166,65 @@ pub fn detect_npm_pkg_manager(project_root: &Path) -> NpmPkgManager { /// The yarn environment that decides which rc files yarn berry reads: /// `YARN_NODE_LINKER`, `YARN_RC_FILENAME` (the rc file name yarn looks for /// in every folder, `.yarnrc.yml` by default) and the home folder, whose -/// rc file yarn reads last. +/// rc file yarn reads last; plus the variables an rc value's `${NAME}` +/// references expand from (#1100). #[derive(Debug, Clone)] pub(crate) struct YarnEnv { pub node_linker: Option, pub rc_filename: String, pub home: Option, + /// The variables `${NAME}` references read; `None` reads this + /// process's environment. + pub vars: Option>, } impl YarnEnv { pub(crate) fn current() -> Self { - let set = |k: &str| { - std::env::var(k) - .ok() - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - }; Self { - node_linker: set("YARN_NODE_LINKER"), - rc_filename: set("YARN_RC_FILENAME").unwrap_or_else(|| ".yarnrc.yml".to_string()), + // Yarn takes the last `yarn_node_linker` spelling in the + // environment (each one overwrites the setting) but the first + // `yarn_rc_filename` (`getRcFilename` returns on a match). + node_linker: yarn_env_key(std::env::vars_os(), "YARN_NODE_LINKER", true), + rc_filename: yarn_env_key(std::env::vars_os(), "YARN_RC_FILENAME", false) + .unwrap_or_else(|| ".yarnrc.yml".to_string()), home: crate::utils::fs::home_dir(), + vars: None, + } + } + + /// The repository alone, not the host it is scanned on: no + /// environment override, no home rc file, and no variables, so a + /// `${NAME}` reference takes its fallback or fails closed. For a view + /// of a repository that is not checked out on this host. + pub(crate) fn repository_only() -> Self { + Self { + node_linker: None, + rc_filename: ".yarnrc.yml".to_string(), + home: None, + vars: Some(Default::default()), } } + fn var(&self, name: &str) -> Option { + match &self.vars { + Some(vars) => vars.get(name).cloned(), + None => std::env::var(name).ok(), + } + } + + /// A `nodeLinker` value as yarn resolves it: its `${…}` references + /// expanded. When one cannot be (an unset variable with no fallback: + /// yarn refuses to run), the value expands to nothing, or it still + /// holds a literal `${` (no linker yarn knows), it is the text as + /// written, which [`yarn_linker_is_pnp`] reads as PnP (fail closed). + /// So a value a refusal names never carries an expanded variable. + fn resolve_linker(&self, raw: &str) -> String { + crate::formats::yarn::rc::expand_env(raw, &mut |name| Ok(self.var(name))) + .ok() + .filter(|v| !v.is_empty() && !v.contains("${")) + .unwrap_or_else(|| raw.to_string()) + } + /// The `nodeLinker` the home folder's rc file sets: yarn reads it after /// every project-side rc file, so it applies only when none of those /// sets the key. @@ -197,15 +233,37 @@ impl YarnEnv { &self.home.as_ref()?.join(&self.rc_filename), ) .ok()?; - rc_node_linker(&rc) + rc_node_linker(&rc, self) + } +} + +/// The yarn environment setting `key` (upper case) in `vars`: yarn +/// lower-cases every environment key before matching it, so +/// `yarn_node_linker` sets the linker as `YARN_NODE_LINKER` does (#1100; +/// Windows keys are case-insensitive anyway). `last` takes the last match +/// in environment order, else the first; an empty value sets nothing. +fn yarn_env_key( + vars: impl Iterator, + key: &str, + last: bool, +) -> Option { + let mut found = vars.filter_map(|(k, v)| { + let (k, v) = (k.to_str()?, v.to_str()?.trim()); + (k.eq_ignore_ascii_case(key) && !v.is_empty()).then(|| v.to_string()) + }); + if last { + found.last() + } else { + found.next() } } -/// The `nodeLinker` the rc file text `rc` sets, if any. -fn rc_node_linker(rc: &str) -> Option { - crate::formats::yarn::berry_gates::yarnrc_scalar(rc, "nodeLinker") +/// The `nodeLinker` the rc file text `rc` sets, if any, as yarn resolves +/// it under `env` ([`YarnEnv::resolve_linker`]). +pub(crate) fn rc_node_linker(rc: &str, env: &YarnEnv) -> Option { + crate::formats::yarn::rc::raw_setting(rc, "nodeLinker") .filter(|v| !v.is_empty()) - .map(str::to_string) + .map(|raw| env.resolve_linker(&raw)) } /// The `nodeLinker` yarn berry resolves for `project_root`: @@ -220,8 +278,8 @@ pub fn yarn_node_linker(project_root: &Path) -> Option { } pub(crate) fn yarn_node_linker_in(project_root: &Path, env: &YarnEnv) -> Option { - if let Some(linker) = env.node_linker.clone() { - return Some(linker); + if let Some(linker) = &env.node_linker { + return Some(env.resolve_linker(linker)); } let start = std::path::absolute(project_root).unwrap_or_else(|_| project_root.to_path_buf()); start @@ -229,7 +287,7 @@ pub(crate) fn yarn_node_linker_in(project_root: &Path, env: &YarnEnv) -> Option< .find_map(|dir| { let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(&env.rc_filename)).ok()?; - rc_node_linker(&rc) + rc_node_linker(&rc, env) }) .or_else(|| env.home_node_linker()) } @@ -250,9 +308,11 @@ pub(crate) fn effective_yarn_linker( } /// Whether a yarn `nodeLinker` setting (`None` = unset) is Plug'n'Play: -/// `pnp` itself, or nothing set at all (berry's default). +/// `pnp` itself, nothing set at all (berry's default), or a `${…}` +/// reference left unexpanded because yarn could not expand it either +/// ([`YarnEnv::resolve_linker`]): an unknown linker fails closed. pub fn yarn_linker_is_pnp(linker: Option<&str>) -> bool { - linker.is_none_or(|l| l == "pnp") + linker.is_none_or(|l| l == "pnp" || l.contains("${")) } /// The PnP loader file that makes `project_root` a live yarn Plug'n'Play @@ -497,6 +557,120 @@ mod tests { ); } + /// #1100: yarn parses `.yarnrc.yml` as YAML and expands `${…}` in its + /// values, so a live loader under `nodeLinker: "${L:-pnp}"` is PnP, and + /// a quoted key, a next-line value or a flow map still says + /// `node-modules`. A reference yarn cannot expand counts as PnP while a + /// loader exists (fail closed). + #[test] + fn yarnrc_linker_is_read_as_yarn_resolves_it() { + for (rc, live) in [ + ("nodeLinker: \"${SOCKET_PATCH_T1100_UNSET:-pnp}\"\n", true), + ("nodeLinker: \"${SOCKET_PATCH_T1100_UNSET-pnp}\"\n", true), + ("nodeLinker: ${SOCKET_PATCH_T1100_UNSET}\n", true), + ( + "nodeLinker: \"${SOCKET_PATCH_T1100_UNSET:-node-modules}\"\n", + false, + ), + ("\"nodeLinker\": node-modules\n", false), + ("nodeLinker:\n node-modules\n", false), + ("{nodeLinker: node-modules}\n", false), + ] { + let d = tempfile::tempdir().unwrap(); + std::fs::write(d.path().join(".pnp.cjs"), "").unwrap(); + std::fs::write(d.path().join(".yarnrc.yml"), rc).unwrap(); + assert_eq!( + live_pnp_marker_with( + || yarn_node_linker_in(d.path(), &bare_env(None)), + |m| d.path().join(m).is_file() + ), + live.then_some(".pnp.cjs"), + "{rc:?}" + ); + } + } + + /// #1100: `${NAME}` references read the environment yarn runs in, in + /// the rc file and in `YARN_NODE_LINKER` alike. + #[test] + fn yarnrc_linker_references_read_the_environment() { + let d = tempfile::tempdir().unwrap(); + let mut env = bare_env(None); + env.vars = Some([("L".to_string(), "pnp".to_string())].into()); + for (rc, linker) in [ + ("nodeLinker: \"${L}\"\n", "pnp"), + ("nodeLinker: \"${L:-node-modules}\"\n", "pnp"), + ("nodeLinker: \"${M:-node-modules}\"\n", "node-modules"), + ] { + std::fs::write(d.path().join(".yarnrc.yml"), rc).unwrap(); + assert_eq!( + yarn_node_linker_in(d.path(), &env).as_deref(), + Some(linker), + "{rc:?}" + ); + } + env.node_linker = Some("${L}".into()); + assert_eq!(yarn_node_linker_in(d.path(), &env).as_deref(), Some("pnp")); + } + + /// #1100: yarn matches its `yarn_` environment keys case-insensitively: + /// the last spelling of a setting wins, the first of the rc file name. + #[test] + fn yarn_env_keys_match_any_case() { + let vars = |pairs: &[(&str, &str)]| { + pairs + .iter() + .map(|(k, v)| (k.into(), v.into())) + .collect::>() + .into_iter() + }; + let linker = |pairs| yarn_env_key(vars(pairs), "YARN_NODE_LINKER", true); + assert_eq!( + linker(&[("yarn_node_linker", "node-modules")]).as_deref(), + Some("node-modules") + ); + assert_eq!( + linker(&[ + ("YARN_NODE_LINKER", "node-modules"), + ("yarn_node_linker", "pnp"), + ]) + .as_deref(), + Some("pnp") + ); + assert_eq!( + linker(&[("YARN_NODE_LINKER", " "), ("yarn_nodelinker", "x")]), + None + ); + assert_eq!( + yarn_env_key( + vars(&[ + ("yarn_rc_filename", ".a.yml"), + ("YARN_RC_FILENAME", ".b.yml") + ]), + "YARN_RC_FILENAME", + false + ) + .as_deref(), + Some(".a.yml") + ); + } + + /// #1100: a project rc whose reference expands to nothing does not + /// fall through to a parent rc (yarn keeps the nearest value); it is + /// no linker yarn knows, so a loader counts (fail closed). + #[test] + fn an_empty_expansion_does_not_inherit_a_parent_linker() { + let d = tempfile::tempdir().unwrap(); + let member = d.path().join("app"); + std::fs::create_dir_all(&member).unwrap(); + std::fs::write(d.path().join(".yarnrc.yml"), "nodeLinker: node-modules\n").unwrap(); + std::fs::write(member.join(".yarnrc.yml"), "nodeLinker: \"${L}\"\n").unwrap(); + let mut env = bare_env(None); + env.vars = Some([("L".to_string(), String::new())].into()); + assert_eq!(yarn_node_linker_in(&member, &env).as_deref(), Some("${L}")); + assert!(yarn_linker_is_pnp(Some("${L}"))); + } + /// yarn's own precedence: the env var, then the nearest rc file that /// sets the key, walking up past rc files that don't. #[test] @@ -533,6 +707,7 @@ mod tests { node_linker: node_linker.map(str::to_string), rc_filename: ".yarnrc.yml".into(), home: None, + vars: Some(Default::default()), } } diff --git a/crates/socket-patch-core/src/formats/yarn/berry_gates.rs b/crates/socket-patch-core/src/formats/yarn/berry_gates.rs index 955b4bca7..003f7f34d 100644 --- a/crates/socket-patch-core/src/formats/yarn/berry_gates.rs +++ b/crates/socket-patch-core/src/formats/yarn/berry_gates.rs @@ -162,10 +162,18 @@ pub fn check_yarnrc(yarnrc: Yarnrc<'_>) -> Result<(), BerryGate> { Yarnrc::Unreadable(error) => Err(BerryGate::YarnrcUnreadable { error: error.to_string(), }), - Yarnrc::Text(rc) => match yarnrc_compression_level(rc) { - Some(level) if level != "0" => Err(BerryGate::Compression { - level: level.to_string(), - }), + // The level yarn uses is the value with its `${…}` references + // expanded (#1100). They expand from no environment at all: the + // gate decides on the repository, not on whichever host scans it + // (hosted scans read a repository that is not checked out here), + // so a reference passes only through a fallback of 0. A level + // some environment set at install time is in the lock's + // `cacheKey`, which the cache-key gate checks. A refusal names + // the value as written. + Yarnrc::Text(rc) => match super::rc::setting(rc, "compressionLevel", &|_| None) { + Some(level) if level.value.as_deref() != Some("0") => { + Err(BerryGate::Compression { level: level.raw }) + } _ => Ok(()), }, } @@ -179,44 +187,15 @@ pub(crate) fn cache_key(blocks: &[LockBlock]) -> Option<&str> { berry_field(&berry_metadata(blocks)?.lines, "cacheKey") } -/// The `.yarnrc.yml` `compressionLevel` value, when set. A flat line scan is -/// enough: yarn writes the knob as a top-level scalar, and any -/// value we cannot positively read as `0` makes the caller refuse. /// CRLF lines split like LF ones (`str::lines`), and a leading BOM is -/// skipped the way yarn's YAML parser skips it — otherwise a knob on the -/// first line of a BOM'd file would read as unset (the offline-reproducible -/// default) while yarn applies it and every install fails YN0018. -/// -/// The value is read as a YAML scalar: a quoted value ends at its closing -/// quote, and a plain value ends before a whitespace-separated `#` comment -/// (`compressionLevel: 0 # keep yarn default` is `0`, #370). A `#` with no -/// whitespace before it stays part of a plain value, as in YAML. -pub fn yarnrc_compression_level(rc: &str) -> Option<&str> { - yarnrc_scalar(rc, "compressionLevel") -} - -/// The top-level `.yarnrc.yml` scalar `key`, when set, read as -/// [`yarnrc_compression_level`] describes. -pub fn yarnrc_scalar<'a>(rc: &'a str, key: &str) -> Option<&'a str> { - let rc = crate::formats::text::strip_bom(rc); - rc.lines().find_map(|line| { - let rest = line.strip_prefix(key)?.strip_prefix(':')?.trim(); - if let Some(quote) = rest.chars().next().filter(|c| matches!(c, '\'' | '"')) { - if let Some(end) = rest[1..].find(quote) { - return Some(&rest[1..1 + end]); - } - } - let value = rest - .char_indices() - .find(|&(i, c)| c == '#' && rest[..i].ends_with([' ', '\t'])) - .map_or(rest, |(i, _)| &rest[..i]); - Some(value.trim_end().trim_matches(['\'', '"'])) - }) -} - #[cfg(test)] mod tests { use super::*; + /// The `compressionLevel` as written, read as the gate reads it. + fn raw_level(rc: &str) -> Option { + super::super::rc::raw_setting(rc, "compressionLevel") + } + fn lock(cache_key: &str) -> String { format!( "# yarn lockfile\n\n__metadata:\n version: 8\n cacheKey: {cache_key}\n\n\ @@ -305,6 +284,40 @@ mod tests { ); } + /// #1100: yarn expands `${…}` before it reads the level, so a + /// reference that resolves to 0 is the supported default, and one that + /// resolves elsewhere (or cannot resolve) still refuses, naming the + /// value as written. + #[test] + fn an_interpolated_compression_level_is_read_as_yarn_resolves_it() { + let lf = lock("10c0"); + for rc in [ + "compressionLevel: \"${SOCKET_PATCH_T1100_UNSET:-0}\"\n", + "\"compressionLevel\": 0\n", + "{compressionLevel: 0, nodeLinker: node-modules}\n", + ] { + assert_eq!(check(&lf, None, Yarnrc::Text(rc)), Ok(()), "{rc:?}"); + } + for (rc, level) in [ + ( + "compressionLevel: \"${SOCKET_PATCH_T1100_UNSET:-9}\"\n", + "${SOCKET_PATCH_T1100_UNSET:-9}", + ), + ( + "compressionLevel: ${SOCKET_PATCH_T1100_UNSET}\n", + "${SOCKET_PATCH_T1100_UNSET}", + ), + ] { + assert_eq!( + check(&lf, None, Yarnrc::Text(rc)), + Err(BerryGate::Compression { + level: level.into() + }), + "{rc:?}" + ); + } + } + #[test] fn a_sub_map_or_later_block_never_supplies_the_cache_key() { let text = "__metadata:\n version: 8\n nested:\n cacheKey: 10c0\n\n\ @@ -317,20 +330,17 @@ mod tests { #[test] fn yarnrc_compression_level_reads_past_a_bom_and_crlf() { assert_eq!( - yarnrc_compression_level("\u{feff}compressionLevel: mixed\r\nnodeLinker: pnp\r\n"), + raw_level("\u{feff}compressionLevel: mixed\r\nnodeLinker: pnp\r\n").as_deref(), Some("mixed") ); assert_eq!( - yarnrc_compression_level("nodeLinker: pnp\r\ncompressionLevel: 0\r\n"), + raw_level("nodeLinker: pnp\r\ncompressionLevel: 0\r\n").as_deref(), Some("0") ); - assert_eq!( - yarnrc_compression_level("\u{feff}nodeLinker: pnp\r\n"), - None - ); + assert_eq!(raw_level("\u{feff}nodeLinker: pnp\r\n").as_deref(), None); // A second BOM is content (#905): the key is not at column 0. assert_eq!( - yarnrc_compression_level("\u{feff}\u{feff}compressionLevel: 0\n"), + raw_level("\u{feff}\u{feff}compressionLevel: 0\n").as_deref(), None ); } @@ -348,7 +358,7 @@ mod tests { ("compressionLevel: mixed # not the default\n", "mixed"), ("compressionLevel: 9 #\r\n", "9"), ] { - assert_eq!(yarnrc_compression_level(rc), Some(level), "{rc:?}"); + assert_eq!(raw_level(rc).as_deref(), Some(level), "{rc:?}"); } } @@ -356,12 +366,9 @@ mod tests { /// so `0#x` is not the default and must still refuse (fail closed). #[test] fn yarnrc_compression_level_keeps_an_unseparated_hash() { + assert_eq!(raw_level("compressionLevel: 0#x\n").as_deref(), Some("0#x")); assert_eq!( - yarnrc_compression_level("compressionLevel: 0#x\n"), - Some("0#x") - ); - assert_eq!( - yarnrc_compression_level("compressionLevel: \"0 # in quotes\"\n"), + raw_level("compressionLevel: \"0 # in quotes\"\n").as_deref(), Some("0 # in quotes") ); } diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs index 585d5776f..5f53523f1 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -9,6 +9,7 @@ //! * where yarn 1 installs a block's copy from ([`source`]); //! * the stanza view the hosted berry writers re-key and re-order //! entries in ([`stanzas`]); +//! * a `.yarnrc.yml` setting as yarn resolves it ([`rc`]); //! * the berry pinned-entry renderer ([`berry_entry`]) and the walk that //! drops the entries a pin leaves unreachable ([`berry_prune`]). //! @@ -23,6 +24,7 @@ pub(crate) mod berry_prune; pub(crate) mod blocks; pub(crate) mod classic_deps; pub(crate) mod patterns; +pub mod rc; pub(crate) mod source; pub(crate) mod stanzas; diff --git a/crates/socket-patch-core/src/formats/yarn/rc.rs b/crates/socket-patch-core/src/formats/yarn/rc.rs new file mode 100644 index 000000000..abf58e52a --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/rc.rs @@ -0,0 +1,327 @@ +//! `.yarnrc.yml` settings, read the way yarn berry reads them (#1100). +//! +//! Yarn parses the file as YAML, so a setting can sit under a quoted key, +//! on the line after its key, or in a flow mapping, and the last +//! occurrence of a key wins. Every value then has its environment +//! references expanded (yarn's `replaceEnvVariables`): `${NAME}`, +//! `${NAME-fallback}` (the fallback when `NAME` is unset) and +//! `${NAME:-fallback}` (also when it is empty). A reference to an unset +//! variable with no fallback is an error yarn refuses to run on. +//! +//! Every `.yarnrc.yml` scalar socket-patch decides on (`nodeLinker`, +//! `compressionLevel`, `pnpmStoreFolder`) is read through [`setting`], so +//! no reader takes the literal `${…}` text for the value yarn uses. + +use std::collections::HashMap; + +use serde_saphyr::granit_parser::{Event, Options, Parser}; + +use crate::formats::text::strip_bom; + +/// A top-level `.yarnrc.yml` setting. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YarnrcSetting { + /// The scalar as written in the file, quotes removed and nothing + /// expanded: what a refusal names, so it never prints the value of an + /// environment variable the file pulls in. + pub raw: String, + /// The value yarn uses, its environment references expanded; `None` + /// when a reference cannot be (an unset variable with no fallback). + pub value: Option, +} + +/// The top-level setting `key` of the rc text `rc`, its references +/// expanded through `var` (a variable's value, `None` when unset). `None` +/// when the file does not set `key` to a scalar. +pub fn setting(rc: &str, key: &str, var: &dyn Fn(&str) -> Option) -> Option { + let raw = raw_setting(rc, key)?; + let value = expand_env(&raw, &mut |name| Ok(var(name))).ok(); + Some(YarnrcSetting { raw, value }) +} + +/// [`setting`] with the references expanded from this process's +/// environment. +pub fn process_setting(rc: &str, key: &str) -> Option { + setting(rc, key, &|name| std::env::var(name).ok()) +} + +/// The top-level scalar `key` of `rc` as written (quotes removed, nothing +/// expanded), or `None` when it is absent or not a scalar. The file is +/// parsed as YAML after one leading BOM (yarn's parser skips one); a file +/// that does not parse as one YAML document falls back to a column-0 line +/// scan, so a setting stays readable in a file yarn would refuse anyway. +pub fn raw_setting(rc: &str, key: &str) -> Option { + let rc = strip_bom(rc); + // A second BOM is content to yarn (#905), which the YAML parser would + // skip as a marker; the line scan keeps it in the key. + if strip_bom(rc).len() < rc.len() { + return line_scan(rc, key); + } + match top_level_scalars(rc) { + Ok(mut settings) => settings.remove(key).flatten(), + Err(()) => line_scan(rc, key), + } +} + +/// The root mapping's keys, each with its scalar value (`None` for a +/// nested mapping, sequence or alias); the last occurrence of a key wins, +/// as in yarn. A root that is not a mapping (a list, a lone scalar) sets +/// nothing: yarn refuses such a file. `Err` when the text is not a single +/// YAML document. +fn top_level_scalars(rc: &str) -> Result>, ()> { + let mut options = Options::default(); + options.emit_comments = false; + let mut out = HashMap::new(); + let mut depth = 0usize; + let mut documents = 0usize; + let mut pending_key: Option = None; + // Whether the next root-level node is a key (true) or a value. + let mut at_key = true; + let mut root_is_mapping = false; + for event in Parser::new_from_str_with_options(rc, options) { + let (event, _) = event.map_err(|_| ())?; + match event { + Event::StreamStart | Event::StreamEnd | Event::DocumentEnd | Event::Comment(..) => {} + Event::DocumentStart(..) => { + documents += 1; + if documents > 1 { + return Err(()); + } + } + Event::MappingStart(..) | Event::SequenceStart(..) => { + if depth == 0 { + root_is_mapping = matches!(event, Event::MappingStart(..)); + } else if depth == 1 && !at_key { + if let Some(key) = pending_key.take() { + out.insert(key, None); + } + at_key = true; + } else if depth == 1 { + // A complex key: not a setting name. + pending_key = None; + at_key = false; + } + 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()); + at_key = false; + } else { + if let Some(key) = pending_key.take() { + out.insert(key, Some(text.into_owned())); + } + at_key = true; + } + } + Event::Alias(_) if depth == 1 && root_is_mapping => { + if !at_key { + if let Some(key) = pending_key.take() { + out.insert(key, None); + } + } + at_key = !at_key; + } + _ => {} + } + } + Ok(out) +} + +/// The column-0 `key: value` line scan [`raw_setting`] falls back to: a +/// quoted value ends at its closing quote, and a plain value ends before +/// a whitespace-separated `#` comment (#370). +fn line_scan(rc: &str, key: &str) -> Option { + rc.lines().find_map(|line| { + let rest = line.strip_prefix(key)?.strip_prefix(':')?.trim(); + if let Some(quote) = rest.chars().next().filter(|c| matches!(c, '\'' | '"')) { + if let Some(end) = rest[1..].find(quote) { + return Some(rest[1..1 + end].to_string()); + } + } + let value = rest + .char_indices() + .find(|&(i, c)| c == '#' && rest[..i].ends_with([' ', '\t'])) + .map_or(rest, |(i, _)| &rest[..i]); + Some(value.trim_end().trim_matches(['\'', '"']).to_string()) + }) +} + +/// Yarn's `replaceEnvVariables` over `value`: each `${NAME}`, +/// `${NAME-fallback}` and `${NAME:-fallback}` (`NAME` of ASCII letters, +/// digits and `_`) is replaced by what `lookup` gives for `NAME` (`None`: +/// unset), by the fallback, or is an `Err` naming the unset variable. +/// `lookup` may itself refuse a variable with an `Err`. Text that is not +/// such a reference stays as written. +pub fn expand_env( + value: &str, + lookup: &mut dyn FnMut(&str) -> Result, String>, +) -> Result { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(at) = rest.find("${") { + out.push_str(&rest[..at]); + let body = &rest[at + 2..]; + let name_len = body + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(body.len()); + let name = &body[..name_len]; + let mut tail = &body[name_len..]; + let colon = tail.starts_with(':'); + if colon { + tail = &tail[1..]; + } + let fallback = match tail.strip_prefix('-') { + Some(after) => match after.find('}') { + Some(close) => { + tail = &after[close..]; + Some(&after[..close]) + } + None => None, + }, + None => None, + }; + let Some(after) = tail.strip_prefix('}').filter(|_| !name.is_empty()) else { + // Not a reference yarn recognizes: kept literally. + out.push_str("${"); + rest = body; + continue; + }; + let expanded = match (lookup(name)?, fallback) { + (Some(v), _) if !v.is_empty() => v, + (Some(v), _) if !colon => v, + (_, Some(fallback)) => fallback.to_string(), + (_, None) => return Err(format!("the environment variable {name} is not set")), + }; + out.push_str(&expanded); + rest = after; + } + out.push_str(rest); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name| { + vars.iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.to_string()) + } + } + + fn value(rc: &str, key: &str, vars: &[(&str, &str)]) -> Option> { + setting(rc, key, &env(vars)).map(|s| s.value) + } + + /// The #1100 shapes: yarn expands `${…}` in the value it uses. + #[test] + fn references_expand_like_yarn() { + let pnp = Some(Some("pnp".to_string())); + assert_eq!(value("nodeLinker: \"${L:-pnp}\"\n", "nodeLinker", &[]), pnp); + assert_eq!( + value("nodeLinker: \"${L}\"\n", "nodeLinker", &[("L", "pnp")]), + pnp + ); + assert_eq!( + value( + "nodeLinker: \"${L:-node-modules}\"\n", + "nodeLinker", + &[("L", "pnp")] + ), + pnp + ); + assert_eq!( + value("compressionLevel: \"${CL:-0}\"\n", "compressionLevel", &[]), + Some(Some("0".to_string())) + ); + // `-` without `:` keeps an empty set value; `:-` replaces it. + assert_eq!( + value("a: x${E-d}y\n", "a", &[("E", "")]), + Some(Some("xy".to_string())) + ); + assert_eq!( + value("a: x${E:-d}y\n", "a", &[("E", "")]), + Some(Some("xdy".to_string())) + ); + assert_eq!( + value("a: ${E}\n", "a", &[("E", "")]), + Some(Some(String::new())) + ); + // Unset with no fallback: yarn refuses to run. + assert_eq!(value("a: ${NOPE}\n", "a", &[]), Some(None)); + assert_eq!(value("a: ${NOPE:}\n", "a", &[]), Some(None)); + // Not a reference: literal. + assert_eq!( + value("a: \"${} ${-x} $X ${A B}\"\n", "a", &[]), + Some(Some("${} ${-x} $X ${A B}".to_string())) + ); + // The raw text never carries an expanded value. + let s = setting("a: \"${T}\"\n", "a", &env(&[("T", "secret")])).unwrap(); + assert_eq!( + (s.raw.as_str(), s.value.as_deref()), + ("${T}", Some("secret")) + ); + } + + /// The #1100 fail-closed rows: yarn reads these as `node-modules`. + #[test] + fn yaml_shapes_read_like_yarn() { + for rc in [ + "\"nodeLinker\": node-modules\n", + "'nodeLinker': node-modules\n", + "nodeLinker:\n node-modules\n", + "{nodeLinker: node-modules}\n", + "{\"nodeLinker\": \"node-modules\", compressionLevel: 0}\n", + "nodeLinker: pnp\nnodeLinker: node-modules\n", + "\u{feff}nodeLinker: node-modules # comment\r\n", + "---\nnodeLinker: node-modules\n...\n", + ] { + assert_eq!( + raw_setting(rc, "nodeLinker").as_deref(), + Some("node-modules"), + "{rc:?}" + ); + } + } + + #[test] + fn non_scalars_and_other_levels_are_not_settings() { + let rc = "npmScopes:\n nodeLinker: pnp\nlist:\n - nodeLinker\npackageExtensions: {nodeLinker: x}\n"; + assert_eq!(raw_setting(rc, "nodeLinker"), None); + assert_eq!(raw_setting(rc, "npmScopes"), None); + assert_eq!(raw_setting("", "nodeLinker"), None); + assert_eq!(raw_setting("# only a comment\n", "nodeLinker"), None); + assert_eq!(raw_setting("- a\n- b\n", "nodeLinker"), None); + // A root list is not settings, even one that pairs up as a key and + // a value (yarn refuses the file). + assert_eq!( + raw_setting("- nodeLinker\n- node-modules\n", "nodeLinker"), + None + ); + assert_eq!( + raw_setting("[nodeLinker, node-modules]\n", "nodeLinker"), + None + ); + let rc = "a: &x pnp\nnodeLinker: *x\nb: 1\n"; + assert_eq!(raw_setting(rc, "nodeLinker"), None); + assert_eq!(raw_setting(rc, "b").as_deref(), Some("1")); + } + + /// A file that is not one YAML document still has its column-0 + /// settings read (fail closed on the knobs that refuse). + #[test] + fn an_unparseable_file_falls_back_to_the_line_scan() { + assert_eq!( + raw_setting("compressionLevel: '0'# no gap\n", "compressionLevel").as_deref(), + Some("0") + ); + assert_eq!( + raw_setting("a: [\ncompressionLevel: 9\n", "compressionLevel").as_deref(), + Some("9") + ); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 688aa9947..167c385bd 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -598,53 +598,13 @@ fn yaml_path_in(lines: &[String], path: &[&str]) -> Result, Strin /// the default registry with `upstream_registry_fallback`, as for the Bun /// and pnpm settings, which never expand an arbitrary variable either. fn yarn_expand_env(value: &str, var: &dyn Fn(&str) -> Option) -> Result { - let mut out = String::with_capacity(value.len()); - let mut rest = value; - while let Some(at) = rest.find("${") { - out.push_str(&rest[..at]); - let body = &rest[at + 2..]; - let name_len = body - .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) - .unwrap_or(body.len()); - let name = &body[..name_len]; - let mut tail = &body[name_len..]; - let colon = tail.starts_with(':'); - if colon { - tail = &tail[1..]; - } - let fallback = match tail.strip_prefix('-') { - Some(after) => match after.find('}') { - Some(close) => { - tail = &after[close..]; - Some(&after[..close]) - } - None => None, - }, - None => None, - }; - let Some(after) = tail.strip_prefix('}').filter(|_| !name.is_empty()) else { - // Not a reference yarn recognizes: kept literally. - out.push_str("${"); - rest = body; - continue; - }; - let set = var(name); - let expanded = match (set, fallback) { - (Some(v), _) if !v.is_empty() => { - return Err(format!( - "it references the environment variable {name}, which socket-patch does \ - not expand into a registry URL" - )) - } - (Some(v), _) if !colon => v, - (_, Some(fallback)) => fallback.to_string(), - (_, None) => return Err(format!("the environment variable {name} is not set")), - }; - out.push_str(&expanded); - rest = after; - } - out.push_str(rest); - Ok(out) + crate::formats::yarn::rc::expand_env(value, &mut |name| match var(name) { + Some(v) if !v.is_empty() => Err(format!( + "it references the environment variable {name}, which socket-patch does \ + not expand into a registry URL" + )), + unset_or_empty => Ok(unset_or_empty), + }) } /// The yarn berry settings that decide which registry a package resolves diff --git a/crates/socket-patch-core/src/patch/shared_store.rs b/crates/socket-patch-core/src/patch/shared_store.rs index 04efcb727..a1159dfc6 100644 --- a/crates/socket-patch-core/src/patch/shared_store.rs +++ b/crates/socket-patch-core/src/patch/shared_store.rs @@ -351,7 +351,10 @@ fn in_yarn_pnpm_store(node_modules: &Path, real: &Path) -> bool { .collect(); let nearest = |key: &str| { rcs.iter().find_map(|(dir, rc)| { - crate::formats::yarn::berry_gates::yarnrc_scalar(rc, key).map(|v| (*dir, v.to_string())) + // The value yarn uses (`${…}` expanded, #1100); one it cannot + // expand admits nothing. + crate::formats::yarn::rc::process_setting(rc, key) + .map(|setting| (*dir, setting.value.unwrap_or_default())) }) }; if nearest("nodeLinker").is_none_or(|(_, linker)| linker != "pnpm") { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index 8723561c1..c55ec4549 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -865,11 +865,11 @@ impl<'a> ProjectView<'a> { ProjectView::Memory(project) => { let lock = project.read_text("yarn.lock").ok(); effective_yarn_linker(lock.as_deref(), || { - let node_linker = |rc: &str| { - crate::formats::yarn::berry_gates::yarnrc_scalar(rc, "nodeLinker") - .filter(|v| !v.is_empty()) - .map(str::to_string) - }; + // The repository alone: a `${NAME}` reference never + // reads the scanning host's environment. + let env = crate::crawlers::pkg_managers::YarnEnv::repository_only(); + let node_linker = + |rc: &str| crate::crawlers::pkg_managers::rc_node_linker(rc, &env); project .read_text(".yarnrc.yml") .ok() diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index f7160b769..ddce7b4e4 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -378,12 +378,16 @@ async fn detect_vendorable_npm_flavor_with( if !crate::crawlers::pkg_managers::yarn_linker_is_pnp(linker.as_deref()) { return Ok(found); } - let why = match linker { - Some(_) => "the configured yarn linker is `nodeLinker: pnp`", - None => { - "no yarn rc file (the project's, its parents' or the home folder's) \ - sets `nodeLinker`, so yarn berry uses its default `pnp` linker" - } + let why = match linker.as_deref() { + Some("pnp") => "the configured yarn linker is `nodeLinker: pnp`".to_string(), + Some(other) => format!( + "the configured yarn linker `nodeLinker: {other}` references an environment \ + variable that is unset with no fallback (or expands to nothing), so it is \ + treated as yarn berry's default `pnp` linker" + ), + None => "no yarn rc file (the project's, its parents' or the home folder's) \ + sets `nodeLinker`, so yarn berry uses its default `pnp` linker" + .to_string(), }; Err(( "vendor_yarn_berry_unsupported", @@ -1151,6 +1155,7 @@ mod tests { node_linker: None, rc_filename: ".yarnrc.yml".into(), home: Some(home.path().to_path_buf()), + vars: Some(Default::default()), }; let (flavor, _) = detect_vendorable_npm_flavor_with(tmp.path(), || yarn_node_linker_in(tmp.path(), &env)) @@ -1165,6 +1170,7 @@ mod tests { node_linker: None, rc_filename: ".yarnrc.ci.yml".into(), home: None, + vars: Some(Default::default()), }; let (flavor, _) = detect_vendorable_npm_flavor_with(tmp.path(), || yarn_node_linker_in(tmp.path(), &env)) @@ -1173,6 +1179,41 @@ mod tests { assert_eq!(flavor, NpmLockFlavor::YarnBerry, "YARN_RC_FILENAME"); } + /// #1100 review: a linker that still holds a literal `${` after its + /// references expand is refused as PnP, and the refusal names it as + /// written: it never prints the variable it expanded, from the rc file + /// or from `YARN_NODE_LINKER`. + #[tokio::test] + async fn unexpandable_linker_refusal_never_prints_an_expanded_variable() { + use crate::crawlers::pkg_managers::{yarn_node_linker_in, YarnEnv}; + let raw = "${SOCKET_PATCH_T_SECRET}${"; + for (rc, env_linker) in [ + (format!("nodeLinker: \"{raw}\"\n"), None), + ( + "enableGlobalCache: false\n".to_string(), + Some(raw.to_string()), + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + touch(tmp.path(), "yarn.lock", YARN_BERRY).await; + touch(tmp.path(), ".yarnrc.yml", &rc).await; + let env = YarnEnv { + node_linker: env_linker, + rc_filename: ".yarnrc.yml".into(), + home: None, + vars: Some([("SOCKET_PATCH_T_SECRET".to_string(), "secret".to_string())].into()), + }; + let (code, message) = detect_vendorable_npm_flavor_with(tmp.path(), || { + yarn_node_linker_in(tmp.path(), &env) + }) + .await + .unwrap_err(); + assert_eq!(code, "vendor_yarn_berry_unsupported", "{rc:?}"); + assert!(!message.contains("secret"), "{rc:?}: {message}"); + assert!(message.contains(raw), "{rc:?}: {message}"); + } + } + /// Stage the root markers a real `pnpm install` with /// `node-linker=pnp` emits (layout verified against pnpm 10.28.2): /// the `.pnp.cjs` loader, pnpm-lock.yaml, and an installed virtual