From a5b0a1ca353bdd49e61caad5aefa4892526662dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:23:49 +0000 Subject: [PATCH 1/3] Start fix for #1629 Assisted-by: Claude Code:claude-opus-5-5 From bf88a2b76757ff0883ce11c56452e972b840762a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:41:04 +0000 Subject: [PATCH 2/3] Fix --update hint for a moved yarn global folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `yarn global add` install whose global folder was moved with yarn's `global-folder` setting (YARN_GLOBAL_FOLDER, npm_config_global_folder, ~/.yarnrc, ~/.npmrc, ...) to a folder not named `global` looked like a small yarn project, so `socket-patch --update` told the user to run `yarn add` from wherever they were. That edits some other project and leaves the installed copy stale. Self-update now reads the `global-folder` values yarn 1 reads, resolves each path the way yarn 1.22 does, and treats an install inside one of those folders as yarn's global folder: `yarn global add …@latest`. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/update.rs | 9 +- .../socket-patch-cli/src/update_notifier.rs | 5 +- .../tests/update/self_update_channels_e2e.rs | 93 +++++++ .../src/patch/redirect/yarnrc.rs | 221 ++++++++++++---- .../src/patch/redirect/yarnrc_tests.rs | 113 ++++++++ .../socket-patch-core/src/update/channel.rs | 245 +++++++++++++----- 6 files changed, 563 insertions(+), 123 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs index 93a5c85d6..5f49e21ef 100644 --- a/crates/socket-patch-cli/src/commands/update.rs +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -219,8 +219,9 @@ pub async fn run(args: UpdateArgs) -> i32 { Ok(p) => p, Err(e) => return fail(&args, e.error_code(), &e.to_string()).await, }; - let channel = detect_channel(&install_path, &ChannelEnv::from_env()); - let hint = upgrade_hint_for(channel, &install_path); + let channel_env = ChannelEnv::from_env(); + let channel = detect_channel(&install_path, &channel_env); + let hint = upgrade_hint_for(channel, &install_path, &channel_env); if channel != InstallChannel::Standalone { if args.force { note_warning( @@ -230,7 +231,7 @@ pub async fn run(args: UpdateArgs) -> i32 { format!( "this install is managed by {} — its next upgrade will overwrite \ the updated binary.", - channel_label_for(channel, &install_path) + channel_label_for(channel, &install_path, &channel_env) ), ); } else { @@ -241,7 +242,7 @@ pub async fn run(args: UpdateArgs) -> i32 { "this socket-patch binary ({}) is managed by {}; update it with `{}` \ instead, or pass --force to replace it in place", install_path.display(), - channel_label_for(channel, &install_path), + channel_label_for(channel, &install_path, &channel_env), hint ), ) diff --git a/crates/socket-patch-cli/src/update_notifier.rs b/crates/socket-patch-cli/src/update_notifier.rs index bad589efa..a67c86c28 100644 --- a/crates/socket-patch-cli/src/update_notifier.rs +++ b/crates/socket-patch-cli/src/update_notifier.rs @@ -240,7 +240,10 @@ async fn refresh_latest(debug: bool) -> Option { /// its managed-install refusal. fn upgrade_command() -> &'static str { match core_update::resolve_install_path() { - Ok(p) => upgrade_hint_for(detect_channel(&p, &ChannelEnv::from_env()), &p), + Ok(p) => { + let env = ChannelEnv::from_env(); + upgrade_hint_for(detect_channel(&p, &env), &p, &env) + } Err(_) => upgrade_hint(InstallChannel::Standalone), } } diff --git a/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs b/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs index a7f41c6b8..280afb7a8 100644 --- a/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs +++ b/crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs @@ -211,6 +211,99 @@ async fn yarn_global_refuses_with_yarn_hint() { install.assert_binary_intact(); } +/// #1629: a yarn global folder moved by yarn's `global-folder` setting +/// to a dir not named `global` looks like a small yarn project on disk +/// (yarn writes its package.json and yarn.lock there). Yarn's config +/// names it, so the refusal must still say `yarn global add`: `yarn add` +/// run from the user's cwd would edit some project and leave this copy +/// stale. +#[tokio::test] +async fn yarn_configured_global_folder_refuses_with_yarn_global_hint() { + let rel_bin = "tools/yarn-g/node_modules/@socketsecurity/socket-patch-x/bin"; + let staged = || { + let install = staged_install_at(rel_bin); + let folder = install.root.path().join("tools/yarn-g"); + std::fs::write( + folder.join("package.json"), + r#"{"dependencies":{"@socketsecurity/socket-patch":"^4.0.0"}}"#, + ) + .unwrap(); + std::fs::write(folder.join("yarn.lock"), "# yarn lockfile v1\n").unwrap(); + let home = install.root.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + (install, folder, home) + }; + let refuse = |install: &update_fixture::StagedInstall, env: &[(&str, &str)], case: &str| { + let mut env = env.to_vec(); + env.push(("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL)); + let (code, _stdout, stderr) = run_installed(install, &["--update", "--yes"], &env); + assert_eq!(code, 1, "{case}: managed install must refuse.\n{stderr}"); + assert!(stderr.contains("managed by yarn"), "{case}: {stderr}"); + assert!( + stderr.contains("`yarn global add @socketsecurity/socket-patch@latest`"), + "{case}: a configured yarn global folder must get `yarn global add`: {stderr}" + ); + install.assert_binary_intact(); + }; + + // `YARN_GLOBAL_FOLDER` (`yarn_*` env, any case). + let (install, folder, home) = staged(); + let (folder, home) = (folder.display().to_string(), home.display().to_string()); + for var in [ + "YARN_GLOBAL_FOLDER", + "yarn_global_folder", + "npm_config_global_folder", + ] { + refuse( + &install, + &[("HOME", &home), ("USERPROFILE", &home), (var, &folder)], + var, + ); + } + + // `global-folder` in the user's `~/.yarnrc` and `~/.npmrc`. + let (install, folder, home) = staged(); + std::fs::write( + home.join(".yarnrc"), + format!("global-folder \"{}\"\n", folder.display()).replace('\\', "/"), + ) + .unwrap(); + let home_s = home.display().to_string(); + refuse( + &install, + &[("HOME", &home_s), ("USERPROFILE", &home_s)], + "~/.yarnrc", + ); + std::fs::remove_file(home.join(".yarnrc")).unwrap(); + std::fs::write( + home.join(".npmrc"), + format!("global-folder={}\n", folder.display()).replace('\\', "/"), + ) + .unwrap(); + refuse( + &install, + &[("HOME", &home_s), ("USERPROFILE", &home_s)], + "~/.npmrc", + ); + + // Without any config the folder is a yarn 1 project, as before. + std::fs::remove_file(home.join(".npmrc")).unwrap(); + let (code, _stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[ + ("HOME", &home_s), + ("USERPROFILE", &home_s), + ("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL), + ], + ); + assert_eq!(code, 1, "{stderr}"); + assert!( + stderr.contains("`yarn add @socketsecurity/socket-patch@latest`"), + "{stderr}" + ); +} + /// An npm-bundled binary (any `node_modules` component) refuses with the /// npm upgrade command — and the refusal happens before ANY release /// traffic: a fully valid, newer release is mounted and its routes must diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index 37cc4daee..dd69381b6 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -13,6 +13,10 @@ //! mirror is the npm value overridden by the yarn value, and `false` in //! either one turns it off. //! +//! [`configured_yarn_global_folders`] reads yarn 1's `global-folder` +//! from the same layers, for self-update's install-channel detection +//! (#1629). +//! //! Every text is read BOM-stripped: yarn honours a `.yarnrc` / `.npmrc` //! saved as "UTF-8 with signature" (#1078). @@ -129,6 +133,11 @@ 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(); @@ -147,10 +156,9 @@ pub fn yarnrc_value(text: &str) -> Option { None => (line, ""), }, }; - if key == KEY { + if key == key_name { let rest = rest.trim_start(); - let rest = rest.strip_prefix(':').unwrap_or(rest); - found = Some(mirror_value(rest)); + found = Some(rest.strip_prefix(':').unwrap_or(rest)); } } found @@ -160,6 +168,12 @@ pub fn yarnrc_value(text: &str) -> Option { /// `key = value` lines, `#`/`;` comments, `[section]` headers end the /// top level). pub fn npmrc_value(text: &str) -> Option { + npmrc_setting(text, KEY).map(mirror_value) +} + +/// The last raw top-level `key` value in an `.npmrc` (see +/// [`npmrc_value`]). +fn npmrc_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(); @@ -172,8 +186,8 @@ pub fn npmrc_value(text: &str) -> Option { let Some((key, value)) = line.split_once('=') else { continue; }; - if unquote_rc_value(key) == KEY { - found = Some(mirror_value(value)); + if unquote_rc_value(key) == key_name { + found = Some(value); } } found @@ -233,47 +247,8 @@ pub fn resolve_outer_yarn_mirror( project: &Path, read: impl Fn(&Path) -> Option, ) -> OuterYarnMirror { - let home = env.home.clone(); - let user_home = if root_user && !env.windows && env.var("FAKEROOTKEY").is_none() { - Some(PathBuf::from("/usr/local/share")) - } else { - home.clone() - }; - // yarn's `getGlobalPrefix`: `PREFIX`, else node's directory (Windows) - // or its install root (Unix, `DESTDIR`-rooted). - let prefix: Option = env.var("PREFIX").map(PathBuf::from).or_else(|| { - let bin = env.node_exe.as_deref()?.parent()?; - if env.windows { - return Some(bin.to_path_buf()); - } - let root = bin.parent()?; - Some(match env.var("DESTDIR") { - Some(dest) => Path::new(dest).join(root.strip_prefix("/").unwrap_or(root)), - None => root.to_path_buf(), - }) - }); - let files = |rc: &str, userconfig: Option<(String, String)>| -> Vec { - let local = format!(".{rc}"); - let mut out = Vec::new(); - match userconfig { - Some((_, path)) if !path.is_empty() => out.push(project.join(path)), - _ => out.extend(user_home.as_ref().map(|h| h.join(&local))), - } - out.extend(prefix.as_ref().map(|p| p.join("etc").join(rc))); - if let (Some(home), Some(user_home)) = (&home, &user_home) { - if home != user_home { - out.push(home.join(&local)); - } - } - out.extend( - project - .ancestors() - .skip(1) - .filter(|dir| dir.parent().is_some()) - .map(|dir| dir.join(&local)), - ); - out - }; + let layout = RcLayout::new(env, root_user); + let files = |rc: &str, userconfig| layout.files(project, rc, userconfig); let first_file = |paths: Vec, parse: fn(&str) -> Option| { paths.into_iter().find_map(|path| { let value = parse(&read(&path)?)?; @@ -314,6 +289,160 @@ pub fn resolve_outer_yarn_mirror( } } +/// Where yarn 1 looks for rc files outside the project (see the module +/// docs). +struct RcLayout { + /// `~`, from `HOME` (`USERPROFILE` on Windows). + home: Option, + /// yarn's `userHome`: `/usr/local/share` for root, else `home`. + user_home: Option, + /// yarn's `getGlobalPrefix`. + prefix: Option, +} + +impl RcLayout { + /// `root_user` is whether yarn runs as uid 0 (its user rc then lives + /// in `/usr/local/share`, unless `FAKEROOTKEY` is set or on Windows). + fn new(env: &NpmConfigEnv, root_user: bool) -> Self { + let home = env.home.clone(); + let user_home = if root_user && !env.windows && env.var("FAKEROOTKEY").is_none() { + Some(PathBuf::from("/usr/local/share")) + } else { + home.clone() + }; + // yarn's `getGlobalPrefix`: `PREFIX`, else node's directory (Windows) + // or its install root (Unix, `DESTDIR`-rooted). + let prefix: Option = env.var("PREFIX").map(PathBuf::from).or_else(|| { + let bin = env.node_exe.as_deref()?.parent()?; + if env.windows { + return Some(bin.to_path_buf()); + } + let root = bin.parent()?; + Some(match env.var("DESTDIR") { + Some(dest) => Path::new(dest).join(root.strip_prefix("/").unwrap_or(root)), + None => root.to_path_buf(), + }) + }); + RcLayout { + home, + user_home, + prefix, + } + } + + /// The `` files yarn run in `project` reads after the project's + /// own, in its order; `userconfig` is the env override of the user + /// file. + fn files( + &self, + project: &Path, + rc: &str, + userconfig: Option<(String, String)>, + ) -> Vec { + let local = format!(".{rc}"); + let mut out = Vec::new(); + match userconfig { + Some((_, path)) if !path.is_empty() => out.push(project.join(path)), + _ => out.extend(self.user_home.as_ref().map(|h| h.join(&local))), + } + out.extend(self.prefix.as_ref().map(|p| p.join("etc").join(rc))); + if let (Some(home), Some(user_home)) = (&self.home, &self.user_home) { + if home != user_home { + out.push(home.join(&local)); + } + } + out.extend( + project + .ancestors() + .skip(1) + .filter(|dir| dir.parent().is_some()) + .map(|dir| dir.join(&local)), + ); + out + } + + /// yarn's `resolveWithHome`: a leading `~/` (also `~\` on Windows) + /// is `userHome`, anything else resolves against `cwd`. + fn resolve_with_home(&self, value: &str, cwd: &Path, windows: bool) -> Option { + let tilde = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\").filter(|_| windows)); + match tilde { + Some(rest) => self.user_home.as_ref().map(|h| h.join(rest)), + None => Some(cwd.join(value)), + } + } +} + +const GLOBAL_FOLDER_KEY: &str = "global-folder"; + +/// Every folder yarn 1 run in `cwd` is configured to use as its global +/// folder (`yarn global dir`) by a `global-folder` setting: the `YARN_*` +/// and `npm_config_*` env layers, the `.yarnrc` files and the `.npmrc` +/// files (the project's in `cwd`, then the outer ones). Yarn uses the +/// first of them by its precedence, but a folder any layer names was set +/// up as a yarn global folder, so all of them count. +/// +/// Paths resolve as in `yarn@1.22.22`: a `.yarnrc` value against the +/// folder of its file (`RELATIVE_KEYS`), anything else through +/// `resolveWithHome` (`~/` is yarn's `userHome`, else the cwd). Empty +/// values set nothing. +pub fn configured_yarn_global_folders( + env: &NpmConfigEnv, + root_user: bool, + cwd: &Path, + read: impl Fn(&Path) -> Option, +) -> Vec { + let layout = RcLayout::new(env, root_user); + let resolve = |value: &str| layout.resolve_with_home(value, cwd, env.windows); + let mut out = Vec::new(); + for prefix in ["yarn_", "npm_config_"] { + if let Some((_, value)) = env_value(env, &[prefix], GLOBAL_FOLDER_KEY) { + out.extend( + Some(value) + .filter(|v| !v.is_empty()) + .and_then(|v| resolve(&v)), + ); + } + } + let rc_files = |rc: &str, prefixes: &[&str]| { + let mut files = vec![cwd.join(format!(".{rc}"))]; + files.extend(layout.files(cwd, rc, env_value(env, prefixes, "userconfig"))); + files + }; + for path in rc_files("yarnrc", &["yarn_"]) { + let Some(text) = read(&path) else { continue }; + let Some(value) = yarnrc_setting(&text, GLOBAL_FOLDER_KEY).map(unquote_rc_value) else { + continue; + }; + if let (false, Some(dir)) = (value.is_empty(), path.parent()) { + out.push(dir.join(value)); + } + } + for path in rc_files("npmrc", &["yarn_", "npm_config_"]) { + let Some(text) = read(&path) else { continue }; + let Some(value) = npmrc_setting(&text, GLOBAL_FOLDER_KEY).map(unquote_rc_value) else { + continue; + }; + out.extend(Some(value).filter(|v| !v.is_empty()).and_then(resolve)); + } + out +} + +/// [`configured_yarn_global_folders`] for this process, run in its cwd, +/// reading regular files only. +pub fn configured_yarn_global_folders_for_process() -> Vec { + let Ok(cwd) = std::env::current_dir() else { + return Vec::new(); + }; + configured_yarn_global_folders( + &NpmConfigEnv::from_process(), + process_is_root_user(), + &cwd, + |path| crate::utils::fs::read_regular_to_string_sync(path).ok(), + ) +} + /// Whether this process runs as uid 0 (yarn's `getuid() === 0`). pub fn process_is_root_user() -> bool { #[cfg(unix)] diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs index a61f6a97e..5a5dd5d2c 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs @@ -427,3 +427,116 @@ fn yarn_classic_workspace_members_follow_the_root_globs() { write("package.json", r#"{"private":true}"#); assert!(yarn_classic_workspace_members(root).is_empty()); } + +impl Host { + /// [`configured_yarn_global_folders`] for yarn run in `cwd`. + fn global_folders(&self, cwd: &str) -> Vec { + let env = NpmConfigEnv::from_parts( + self.vars.clone(), + Some(PathBuf::from("/opt/node/bin/node")), + false, + ); + configured_yarn_global_folders(&env, self.root_user, Path::new(cwd), |p| { + self.files.get(p).cloned() + }) + } +} + +fn paths(list: &[&str]) -> Vec { + list.iter().map(PathBuf::from).collect() +} + +/// #1629: every layer yarn 1 reads `global-folder` from names a global +/// folder. Each expectation matches `yarn global dir` on yarn 1.22.22. +#[test] +fn global_folder_layers_match_yarn() { + // Env, any prefix spelling yarn's `mergeEnv` accepts. + for var in [ + "YARN_GLOBAL_FOLDER", + "yarn_global_folder", + "npm_config_global_folder", + ] { + assert_eq!( + Host::new().var(var, "/opt/yg").global_folders("/w"), + paths(&["/opt/yg"]), + "{var}" + ); + } + // The project's own rc, the user's, the global prefix's and an + // ancestor's. + for (file, text) in [ + ("/w/.yarnrc", "global-folder \"/opt/yg\"\n"), + ("/home/u/.yarnrc", "global-folder /opt/yg\n"), + ("/home/u/.yarnrc", "\"global-folder\": '/opt/yg'\n"), + ("/opt/node/etc/yarnrc", "global-folder /opt/yg\n"), + ("/w/.npmrc", "global-folder=/opt/yg\n"), + ("/home/u/.npmrc", "global-folder = \"/opt/yg\"\n"), + ("/opt/node/etc/npmrc", "global-folder=/opt/yg\n"), + ] { + assert_eq!( + Host::new().file(file, text).global_folders("/w"), + paths(&["/opt/yg"]), + "{file}: {text:?}" + ); + } + assert_eq!( + Host::new() + .file("/w/.yarnrc", "global-folder /opt/yg\n") + .global_folders("/w/sub"), + paths(&["/opt/yg"]), + "an ancestor's .yarnrc" + ); + // Every layer counts, env first. + assert_eq!( + Host::new() + .var("YARN_GLOBAL_FOLDER", "/opt/a") + .var("npm_config_global_folder", "/opt/b") + .file("/home/u/.yarnrc", "global-folder /opt/c\n") + .file("/home/u/.npmrc", "global-folder=/opt/d\n") + .global_folders("/w"), + paths(&["/opt/a", "/opt/b", "/opt/c", "/opt/d"]) + ); + // Unset, empty, commented out or under an `.npmrc` section: nothing. + for host in [ + Host::new(), + Host::new().var("YARN_GLOBAL_FOLDER", ""), + Host::new().file("/home/u/.yarnrc", "# global-folder /opt/yg\n"), + Host::new().file("/home/u/.yarnrc", "global-folder \"\"\n"), + Host::new().file("/home/u/.npmrc", "[x]\nglobal-folder=/opt/yg\n"), + Host::new().file("/home/u/.yarnrc", "global-folders /opt/yg\n"), + ] { + assert_eq!(host.global_folders("/w"), Vec::::new()); + } +} + +/// #1629: yarn resolves a `.yarnrc` `global-folder` against that file's +/// folder (no `~` expansion), and an env or `.npmrc` value through +/// `resolveWithHome`: `~/` is yarn's user home (`/usr/local/share` for +/// root), anything else is taken against the cwd. +#[test] +fn global_folder_paths_resolve_like_yarn() { + let home = Host::new().file("/home/u/.yarnrc", "global-folder gf\n"); + assert_eq!(home.global_folders("/w"), paths(&["/home/u/gf"])); + let tilde = Host::new().file("/home/u/.yarnrc", "global-folder \"~/gf\"\n"); + assert_eq!(tilde.global_folders("/w"), paths(&["/home/u/~/gf"])); + let ancestor = Host::new().file("/w/.yarnrc", "global-folder ../gf\n"); + assert_eq!(ancestor.global_folders("/w/sub"), paths(&["/w/../gf"])); + + for host in [ + Host::new().var("YARN_GLOBAL_FOLDER", "gf"), + Host::new().file("/home/u/.npmrc", "global-folder=gf\n"), + ] { + assert_eq!(host.global_folders("/w"), paths(&["/w/gf"])); + } + for host in [ + Host::new().var("YARN_GLOBAL_FOLDER", "~/gf"), + Host::new().file("/home/u/.npmrc", "global-folder=~/gf\n"), + ] { + assert_eq!(host.global_folders("/w"), paths(&["/home/u/gf"])); + assert_eq!( + host.root().global_folders("/w"), + paths(&["/usr/local/share/gf"]), + "root's userHome" + ); + } +} diff --git a/crates/socket-patch-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs index 23acf2078..39450f2fe 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -45,6 +45,10 @@ pub struct ChannelEnv { pub xdg_cache_home: Option, pub home: Option, pub local_app_data: Option, + /// The canonicalized folders yarn 1's `global-folder` config names + /// (`YARN_GLOBAL_FOLDER`, `~/.yarnrc`, ...): a `yarn global add` + /// moved there looks like a small yarn project on disk (#1629). + pub yarn_global_folders: Vec, } impl ChannelEnv { @@ -62,6 +66,11 @@ impl ChannelEnv { xdg_cache_home: path_var("XDG_CACHE_HOME"), home: crate::utils::fs::home_dir(), local_app_data: path_var("LOCALAPPDATA"), + yarn_global_folders: + crate::patch::redirect::yarnrc::configured_yarn_global_folders_for_process() + .into_iter() + .filter_map(|dir| std::fs::canonicalize(dir).ok()) + .collect(), } } } @@ -156,13 +165,21 @@ pub fn upgrade_hint(channel: InstallChannel) -> &'static str { /// declaration, and `yarn upgrade-interactive --latest` is the one /// command that reaches a member's. `npm install` in a yarn project /// writes a stray `package-lock.json` beside `yarn.lock`. -pub fn upgrade_hint_for(channel: InstallChannel, canonical_exe: &Path) -> &'static str { +/// +/// A folder `env` names as yarn's configured global folder +/// ([`ChannelEnv::yarn_global_folders`]) is yarn's global folder whatever +/// it is called. +pub fn upgrade_hint_for( + channel: InstallChannel, + canonical_exe: &Path, + env: &ChannelEnv, +) -> &'static str { if channel != InstallChannel::Npm { return upgrade_hint(channel); } let global = is_global_npm_install(canonical_exe); let holder = outermost_node_modules_holder(canonical_exe); - match node_manager(canonical_exe) { + match node_manager(canonical_exe, env) { NodeManager::Pnpm if global => "pnpm add -g @socketsecurity/socket-patch@latest", NodeManager::Pnpm if holder.is_some_and(|dir| { @@ -177,7 +194,8 @@ pub fn upgrade_hint_for(channel: InstallChannel, canonical_exe: &Path) -> &'stat NodeManager::Bun => "bun add @socketsecurity/socket-patch@latest", NodeManager::Bunx => "bunx @socketsecurity/socket-patch@latest …", NodeManager::Yarn - if is_yarn_global_store(canonical_exe) || is_prefix_global_install(canonical_exe) => + if is_yarn_global_store(canonical_exe, env) + || is_prefix_global_install(canonical_exe) => { "yarn global add @socketsecurity/socket-patch@latest" } @@ -201,11 +219,15 @@ pub fn upgrade_hint_for(channel: InstallChannel, canonical_exe: &Path) -> &'stat /// Short human label for refusal messages ("managed by pnpm"): the /// [`channel_label`], naming the npm-family manager for a `node_modules` /// install. -pub fn channel_label_for(channel: InstallChannel, canonical_exe: &Path) -> &'static str { +pub fn channel_label_for( + channel: InstallChannel, + canonical_exe: &Path, + env: &ChannelEnv, +) -> &'static str { if channel != InstallChannel::Npm { return channel_label(channel); } - match node_manager(canonical_exe) { + match node_manager(canonical_exe, env) { NodeManager::Npm => "npm", NodeManager::Pnpm => "pnpm", NodeManager::Bun | NodeManager::Bunx => "bun", @@ -239,7 +261,7 @@ enum NodeManager { /// `node_modules`, after pnpm's and Bun's and only when no npm lock sits /// there too (a project mid-migration keeps npm's hint). Anything else is /// npm. -fn node_manager(path: &Path) -> NodeManager { +fn node_manager(path: &Path, env: &ChannelEnv) -> NodeManager { let holder = outermost_node_modules_holder(path); // bunx's cache is told by its dir name alone, whether or not the dir // still holds a package.json. @@ -271,7 +293,7 @@ fn node_manager(path: &Path) -> NodeManager { { return NodeManager::Bun; } - if is_yarn_global_store(path) + if is_yarn_global_store(path, env) || (holder_has(YARN_LOCK) && !crate::constants::npm_family::NPM_LOCKS .iter() @@ -287,7 +309,14 @@ fn node_manager(path: &Path) -> NodeManager { /// `/usr/local/share/.config/yarn/global` as root) or `Yarn\Data` on /// Windows, or holding yarn's own `yarn.lock` (a `global-folder` set /// elsewhere). A project that merely sits under some `global` dir is not. -fn is_yarn_global_store(path: &Path) -> bool { +/// Any folder yarn's config names as its global folder is, whatever its +/// name (#1629). +fn is_yarn_global_store(path: &Path, env: &ChannelEnv) -> bool { + if outermost_node_modules_holder(path) + .is_some_and(|dir| env.yarn_global_folders.iter().any(|g| g == dir)) + { + return true; + } let names = normal_components(path); let Some(first_nm) = names.iter().position(|n| *n == "node_modules") else { return false; @@ -524,6 +553,16 @@ fn launcher_cache_roots(env: &ChannelEnv) -> Vec { mod tests { use super::*; + /// [`upgrade_hint_for`] with no yarn `global-folder` configured. + fn hint(channel: InstallChannel, exe: &Path) -> &'static str { + upgrade_hint_for(channel, exe, &ChannelEnv::default()) + } + + /// [`channel_label_for`] with no yarn `global-folder` configured. + fn label(channel: InstallChannel, exe: &Path) -> &'static str { + channel_label_for(channel, exe, &ChannelEnv::default()) + } + fn env_with_home(home: &str) -> ChannelEnv { ChannelEnv { home: Some(PathBuf::from(home)), @@ -806,7 +845,7 @@ mod tests { ]; for p in global { assert_eq!( - upgrade_hint_for(InstallChannel::Npm, Path::new(p)), + hint(InstallChannel::Npm, Path::new(p)), "npm update -g @socketsecurity/socket-patch", "{p}" ); @@ -823,7 +862,7 @@ mod tests { for rel in local { let p = project.path().join(rel); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "npm install @socketsecurity/socket-patch@latest", "{}", p.display() @@ -841,7 +880,7 @@ mod tests { .join(prefix) .join("node_modules/@socketsecurity/socket-patch-win32-x64/bin/socket-patch.exe"); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "npm update -g @socketsecurity/socket-patch", "{}", p.display() @@ -849,7 +888,7 @@ mod tests { } // Other channels are path-independent. assert_eq!( - upgrade_hint_for(InstallChannel::Pypi, Path::new("/work/app/node_modules/x")), + hint(InstallChannel::Pypi, Path::new("/work/app/node_modules/x")), upgrade_hint(InstallChannel::Pypi) ); } @@ -869,13 +908,13 @@ mod tests { std::fs::write(project.path().join("package.json"), r#"{"name":"app"}"#).unwrap(); let exe = project.path().join(bin); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &exe), + hint(InstallChannel::Npm, &exe), "npm install @socketsecurity/socket-patch@latest", "no vlt-lock.json: an npm project" ); std::fs::write(project.path().join("vlt-lock.json"), "{}").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &exe), + hint(InstallChannel::Npm, &exe), "vlt install @socketsecurity/socket-patch@latest" ); // A lock in a nested dir between the holder and the binary is not @@ -885,7 +924,7 @@ mod tests { std::fs::create_dir_all(nested.path().join("node_modules/.vlt")).unwrap(); std::fs::write(nested.path().join("node_modules/.vlt/vlt-lock.json"), "{}").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &nested.path().join(bin)), + hint(InstallChannel::Npm, &nested.path().join(bin)), "npm install @socketsecurity/socket-patch@latest" ); @@ -901,19 +940,19 @@ mod tests { .unwrap(); std::fs::write(vlx.join("vlt-lock.json"), "{}").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &vlx.join(bin)), + hint(InstallChannel::Npm, &vlx.join(bin)), "vlx -y -- @socketsecurity/socket-patch@latest …" ); // A project merely named like vlx's dir but with another name is a // vlt project, not the vlx cache. std::fs::write(vlx.join("package.json"), r#"{"name":"vlxx"}"#).unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &vlx.join(bin)), + hint(InstallChannel::Npm, &vlx.join(bin)), "vlt install @socketsecurity/socket-patch@latest" ); // Global installs keep the global hint whatever vlt files exist. assert_eq!( - upgrade_hint_for( + hint( InstallChannel::Npm, Path::new( "/usr/local/lib/node_modules/@socketsecurity/socket-patch/bin/socket-patch" @@ -942,22 +981,22 @@ mod tests { for p in global { let p = Path::new(p); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, p), + hint(InstallChannel::Npm, p), "pnpm add -g @socketsecurity/socket-patch@latest", "{}", p.display() ); - assert_eq!(channel_label_for(InstallChannel::Npm, p), "pnpm"); + assert_eq!(label(InstallChannel::Npm, p), "pnpm"); } // Yarn's global store is not pnpm's. let yarn = Path::new( "/home/u/.config/yarn/global/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", ); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, yarn), + hint(InstallChannel::Npm, yarn), "yarn global add @socketsecurity/socket-patch@latest" ); - assert_eq!(channel_label_for(InstallChannel::Npm, yarn), "yarn"); + assert_eq!(label(InstallChannel::Npm, yarn), "yarn"); } #[test] @@ -971,22 +1010,22 @@ mod tests { std::fs::write(project.path().join("package.json"), "{}").unwrap(); let p = project.path().join(isolated); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "pnpm add @socketsecurity/socket-patch@latest" ); - assert_eq!(channel_label_for(InstallChannel::Npm, &p), "pnpm"); + assert_eq!(label(InstallChannel::Npm, &p), "pnpm"); // `node-linker=hoisted` has no `.pnpm`: pnpm-lock.yaml decides. let p = project.path().join(hoisted); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "npm install @socketsecurity/socket-patch@latest" ); std::fs::write(project.path().join("pnpm-lock.yaml"), "").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "pnpm add @socketsecurity/socket-patch@latest" ); - assert_eq!(channel_label_for(InstallChannel::Npm, &p), "pnpm"); + assert_eq!(label(InstallChannel::Npm, &p), "pnpm"); // A workspace root refuses a bare `pnpm add` (ERR_PNPM_ADDING_TO_ROOT) // and the dependency may be declared by any member, so update it @@ -996,7 +1035,7 @@ mod tests { std::fs::write(ws.path().join("pnpm-workspace.yaml"), "packages: [a]\n").unwrap(); let p = ws.path().join(isolated); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "pnpm update --recursive --latest @socketsecurity/socket-patch" ); } @@ -1017,30 +1056,30 @@ mod tests { for p in global { let p = Path::new(p); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, p), + hint(InstallChannel::Npm, p), "bun add -g @socketsecurity/socket-patch@latest", "{}", p.display() ); - assert_eq!(channel_label_for(InstallChannel::Npm, p), "bun"); + assert_eq!(label(InstallChannel::Npm, p), "bun"); } // Neither npm's `lib` prefix nor yarn's global store is Bun's. let npm = Path::new( "/usr/local/lib/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", ); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, npm), + hint(InstallChannel::Npm, npm), "npm update -g @socketsecurity/socket-patch" ); - assert_eq!(channel_label_for(InstallChannel::Npm, npm), "npm"); + assert_eq!(label(InstallChannel::Npm, npm), "npm"); let yarn = Path::new( "/home/u/.config/yarn/global/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", ); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, yarn), + hint(InstallChannel::Npm, yarn), "yarn global add @socketsecurity/socket-patch@latest" ); - assert_eq!(channel_label_for(InstallChannel::Npm, yarn), "yarn"); + assert_eq!(label(InstallChannel::Npm, yarn), "yarn"); } #[test] @@ -1053,22 +1092,22 @@ mod tests { std::fs::write(project.path().join("package.json"), "{}").unwrap(); let p = project.path().join(isolated); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "bun add @socketsecurity/socket-patch@latest" ); - assert_eq!(channel_label_for(InstallChannel::Npm, &p), "bun"); + assert_eq!(label(InstallChannel::Npm, &p), "bun"); // The `.bun` store is Bun's even beside a pnpm lock left over from // a migration. std::fs::write(project.path().join("pnpm-lock.yaml"), "").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "bun add @socketsecurity/socket-patch@latest" ); std::fs::remove_file(project.path().join("pnpm-lock.yaml")).unwrap(); // The hoisted linker: the text or binary lockfile decides. let p = project.path().join(hoisted); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "npm install @socketsecurity/socket-patch@latest" ); for lock in ["bun.lock", "bun.lockb"] { @@ -1077,11 +1116,11 @@ mod tests { std::fs::write(project.path().join(lock), "").unwrap(); let p = project.path().join(hoisted); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "bun add @socketsecurity/socket-patch@latest", "{lock}" ); - assert_eq!(channel_label_for(InstallChannel::Npm, &p), "bun"); + assert_eq!(label(InstallChannel::Npm, &p), "bun"); } // A lock in a nested dir between the holder and the binary is not // the holder's. @@ -1090,7 +1129,7 @@ mod tests { std::fs::create_dir_all(nested.path().join("node_modules/x")).unwrap(); std::fs::write(nested.path().join("node_modules/x/bun.lock"), "").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &nested.path().join(hoisted)), + hint(InstallChannel::Npm, &nested.path().join(hoisted)), "npm install @socketsecurity/socket-patch@latest" ); } @@ -1113,11 +1152,11 @@ mod tests { std::fs::write(holder.join("bun.lock"), "").unwrap(); let p = holder.join(bin); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "bunx @socketsecurity/socket-patch@latest …", "{dir}" ); - assert_eq!(channel_label_for(InstallChannel::Npm, &p), "bun"); + assert_eq!(label(InstallChannel::Npm, &p), "bun"); } // A cache dir without its package.json (which would otherwise read // as a global prefix) is still bunx's. @@ -1127,7 +1166,7 @@ mod tests { std::fs::create_dir_all(&holder).unwrap(); std::fs::write(holder.join("bun.lockb"), "").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &holder.join(bin)), + hint(InstallChannel::Npm, &holder.join(bin)), "bunx @socketsecurity/socket-patch@latest …" ); // A Bun project merely named like the cache, or nested below one @@ -1143,7 +1182,7 @@ mod tests { std::fs::write(holder.join("package.json"), "{}").unwrap(); std::fs::write(holder.join("bun.lock"), "").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &holder.join(bin)), + hint(InstallChannel::Npm, &holder.join(bin)), "bun add @socketsecurity/socket-patch@latest", "{dir}" ); @@ -1168,12 +1207,12 @@ mod tests { for p in global { let p = Path::new(p); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, p), + hint(InstallChannel::Npm, p), "yarn global add @socketsecurity/socket-patch@latest", "{}", p.display() ); - assert_eq!(channel_label_for(InstallChannel::Npm, p), "yarn"); + assert_eq!(label(InstallChannel::Npm, p), "yarn"); } // A global folder elsewhere (`--global-folder` / `global-folder`) // that is still named `global`: its yarn.lock names the owner. @@ -1183,16 +1222,16 @@ mod tests { std::fs::write(store.join("package.json"), r#"{"dependencies":{}}"#).unwrap(); let p = store.join("node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch"); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "npm update -g @socketsecurity/socket-patch", "no yarn.lock: not yarn's" ); std::fs::write(store.join("yarn.lock"), "# yarn lockfile v1\n").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "yarn global add @socketsecurity/socket-patch@latest" ); - assert_eq!(channel_label_for(InstallChannel::Npm, &p), "yarn"); + assert_eq!(label(InstallChannel::Npm, &p), "yarn"); // Volta runs `yarn global add` with its global folder set to the // package image's `lib/`, so yarn's package.json and yarn.lock sit // beside a prefix-style `lib/node_modules`. That is still a global @@ -1208,14 +1247,14 @@ mod tests { "node_modules/@socketsecurity/socket-patch/node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch", ); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "yarn global add @socketsecurity/socket-patch@latest" ); - assert_eq!(channel_label_for(InstallChannel::Npm, &p), "yarn"); + assert_eq!(label(InstallChannel::Npm, &p), "yarn"); // A `global` dir with no yarn home above it and no yarn.lock stays // an npm global. assert_eq!( - upgrade_hint_for( + hint( InstallChannel::Npm, Path::new("/opt/global/node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch"), ), @@ -1223,6 +1262,68 @@ mod tests { ); } + /// #1629: a global folder moved by yarn's `global-folder` config to a + /// dir not named `global` reads like a small yarn project on disk; the + /// config names it, so it upgrades with `yarn global add` (`yarn add` + /// from the user's cwd would edit some project instead). + #[test] + fn yarn_configured_global_folder_gets_yarn_global_add() { + let tmp = tempfile::tempdir().unwrap(); + let folder = tmp.path().join("tools/yarn-g"); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("package.json"), r#"{"dependencies":{}}"#).unwrap(); + std::fs::write(folder.join("yarn.lock"), "# yarn lockfile v1\n").unwrap(); + let bin = "node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch"; + let p = folder.join(bin); + assert_eq!( + hint(InstallChannel::Npm, &p), + "yarn add @socketsecurity/socket-patch@latest" + ); + + let env = ChannelEnv { + yarn_global_folders: vec![folder.clone()], + ..Default::default() + }; + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p, &env), + "yarn global add @socketsecurity/socket-patch@latest" + ); + assert_eq!(channel_label_for(InstallChannel::Npm, &p, &env), "yarn"); + // A nested copy under the folder's package still belongs to it. + let nested = folder.join( + "node_modules/@socketsecurity/socket-patch/node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch", + ); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &nested, &env), + "yarn global add @socketsecurity/socket-patch@latest" + ); + // Even before yarn wrote its yarn.lock there. + std::fs::remove_file(folder.join("yarn.lock")).unwrap(); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p, &env), + "yarn global add @socketsecurity/socket-patch@latest" + ); + assert_eq!(channel_label_for(InstallChannel::Npm, &p, &env), "yarn"); + // A project elsewhere is still a project. + let project = tmp.path().join("app"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("package.json"), "{}").unwrap(); + std::fs::write(project.join("yarn.lock"), "# yarn lockfile v1\n").unwrap(); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &project.join(bin), &env), + "yarn add @socketsecurity/socket-patch@latest" + ); + // Only the configured folder itself counts, not a project below it. + let below = folder.join("sub"); + std::fs::create_dir_all(&below).unwrap(); + std::fs::write(below.join("package.json"), "{}").unwrap(); + std::fs::write(below.join("yarn.lock"), "# yarn lockfile v1\n").unwrap(); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &below.join(bin), &env), + "yarn add @socketsecurity/socket-patch@latest" + ); + } + #[test] fn yarn_project_installs_get_yarn_add() { let bin = "node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch"; @@ -1232,15 +1333,15 @@ mod tests { std::fs::write(project.path().join("package.json"), r#"{"name":"app"}"#).unwrap(); let p = project.path().join(bin); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "npm install @socketsecurity/socket-patch@latest" ); std::fs::write(project.path().join("yarn.lock"), "# yarn lockfile v1\n").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "yarn add @socketsecurity/socket-patch@latest" ); - assert_eq!(channel_label_for(InstallChannel::Npm, &p), "yarn"); + assert_eq!(label(InstallChannel::Npm, &p), "yarn"); // A yarn 1 workspace root refuses a bare `yarn add` without -W, // and `yarn upgrade` there never touches a member's declaration; @@ -1251,7 +1352,7 @@ mod tests { ) .unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "yarn upgrade-interactive --latest" ); // ... including the object form of `workspaces`. @@ -1261,7 +1362,7 @@ mod tests { ) .unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "yarn upgrade-interactive --latest" ); // When the root declares socket-patch itself, `yarn upgrade` updates @@ -1272,7 +1373,7 @@ mod tests { ) .unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "yarn upgrade @socketsecurity/socket-patch --latest" ); // An empty `workspaces` (or one with only `nohoist`) is no workspace @@ -1283,7 +1384,7 @@ mod tests { ] { std::fs::write(project.path().join("package.json"), manifest).unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "yarn add @socketsecurity/socket-patch@latest", "{manifest}" ); @@ -1293,11 +1394,11 @@ mod tests { for lock in ["package-lock.json", "npm-shrinkwrap.json"] { std::fs::write(project.path().join(lock), "{}").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "npm install @socketsecurity/socket-patch@latest", "{lock}" ); - assert_eq!(channel_label_for(InstallChannel::Npm, &p), "npm"); + assert_eq!(label(InstallChannel::Npm, &p), "npm"); std::fs::remove_file(project.path().join(lock)).unwrap(); } // A yarn project that sits below some `global` dir is still a @@ -1308,7 +1409,7 @@ mod tests { std::fs::write(app.join("package.json"), r#"{"name":"app"}"#).unwrap(); std::fs::write(app.join("yarn.lock"), "# yarn lockfile v1\n").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &app.join(bin)), + hint(InstallChannel::Npm, &app.join(bin)), "yarn add @socketsecurity/socket-patch@latest" ); @@ -1340,11 +1441,11 @@ mod tests { } let p = berry.path().join(bin); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &p), + hint(InstallChannel::Npm, &p), "yarn up @socketsecurity/socket-patch@latest", "{berry_marker}" ); - assert_eq!(channel_label_for(InstallChannel::Npm, &p), "yarn"); + assert_eq!(label(InstallChannel::Npm, &p), "yarn"); } // pnpm's and Bun's lockfiles outrank a leftover yarn.lock, and so @@ -1355,7 +1456,7 @@ mod tests { std::fs::write(mixed.path().join("yarn.lock"), "").unwrap(); std::fs::write(mixed.path().join(lock), "").unwrap(); assert_ne!( - channel_label_for(InstallChannel::Npm, &mixed.path().join(bin)), + label(InstallChannel::Npm, &mixed.path().join(bin)), "yarn", "{lock}" ); @@ -1363,7 +1464,7 @@ mod tests { let pnpm_store = project.path().join( "node_modules/.pnpm/@socketsecurity+socket-patch-linux-x64-gnu@4.0.0/node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch", ); - assert_eq!(channel_label_for(InstallChannel::Npm, &pnpm_store), "pnpm"); + assert_eq!(label(InstallChannel::Npm, &pnpm_store), "pnpm"); // A yarn.lock in a nested dir between the holder and the binary is // not the holder's. @@ -1372,7 +1473,7 @@ mod tests { std::fs::create_dir_all(nested.path().join("node_modules/x")).unwrap(); std::fs::write(nested.path().join("node_modules/x/yarn.lock"), "").unwrap(); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, &nested.path().join(bin)), + hint(InstallChannel::Npm, &nested.path().join(bin)), "npm install @socketsecurity/socket-patch@latest" ); } @@ -1384,12 +1485,12 @@ mod tests { let bin = project .path() .join("node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch"); - assert_eq!(channel_label_for(InstallChannel::Npm, &bin), "npm"); + assert_eq!(label(InstallChannel::Npm, &bin), "npm"); std::fs::write(project.path().join("vlt-lock.json"), "{}").unwrap(); - assert_eq!(channel_label_for(InstallChannel::Npm, &bin), "vlt"); + assert_eq!(label(InstallChannel::Npm, &bin), "vlt"); // Non-npm channels keep their path-independent label. assert_eq!( - channel_label_for(InstallChannel::Homebrew, &bin), + label(InstallChannel::Homebrew, &bin), channel_label(InstallChannel::Homebrew) ); } @@ -1401,7 +1502,7 @@ mod tests { r"C:\Users\u\AppData\Roaming\npm\node_modules\@socketsecurity\socket-patch-win32-x64\bin\socket-patch.exe", ); assert_eq!( - upgrade_hint_for(InstallChannel::Npm, p), + hint(InstallChannel::Npm, p), "npm update -g @socketsecurity/socket-patch" ); } From 9e104c49c74bca4cb6a2071da5f1aa21021abf83 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:58:05 +0000 Subject: [PATCH 3/3] Read yarn's global-folder like yarn does Review follow-ups for the moved yarn global folder fix: - Accept a `--global-folder` default flag in a .yarnrc. - Read a .yarnrc value as one token of yarn's rc grammar, so a trailing `# comment` is dropped and a double-quoted value has its escapes decoded (Windows paths). - Decode an .npmrc value like npm's ini parser (inline comments, quotes, escapes) and expand its ${NAME} references like yarn. - Read yarn's config only when a yarn install is being classified, so other install channels never walk the rc files. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/npmrc.rs | 2 +- .../src/patch/redirect/yarnrc.rs | 174 +++++++++++++++--- .../src/patch/redirect/yarnrc_tests.rs | 67 +++++++ .../socket-patch-core/src/update/channel.rs | 55 ++++-- 4 files changed, 254 insertions(+), 44 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/npmrc.rs b/crates/socket-patch-core/src/patch/redirect/npmrc.rs index e082375f7..a29b93df7 100644 --- a/crates/socket-patch-core/src/patch/redirect/npmrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/npmrc.rs @@ -359,7 +359,7 @@ impl NpmConfigEnv { /// A variable as npm's `process.env[name]` reads it: exact case on /// Unix, case-insensitive on Windows. `Some("")` when set but empty. - fn var_raw(&self, name: &str) -> Option<&str> { + pub(crate) fn var_raw(&self, name: &str) -> Option<&str> { self.vars .iter() .find(|(k, _)| { diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index dd69381b6..e51474154 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -378,15 +378,18 @@ const GLOBAL_FOLDER_KEY: &str = "global-folder"; /// Every folder yarn 1 run in `cwd` is configured to use as its global /// folder (`yarn global dir`) by a `global-folder` setting: the `YARN_*` -/// and `npm_config_*` env layers, the `.yarnrc` files and the `.npmrc` -/// files (the project's in `cwd`, then the outer ones). Yarn uses the -/// first of them by its precedence, but a folder any layer names was set -/// up as a yarn global folder, so all of them count. +/// and `npm_config_*` env layers, the `.yarnrc` files (also as a +/// `--global-folder` default flag) and the `.npmrc` files (the project's +/// in `cwd`, then the outer ones). Yarn uses the first of them by its +/// precedence, but a folder any layer names was set up as a yarn global +/// folder, so all of them count. /// -/// Paths resolve as in `yarn@1.22.22`: a `.yarnrc` value against the -/// folder of its file (`RELATIVE_KEYS`), anything else through -/// `resolveWithHome` (`~/` is yarn's `userHome`, else the cwd). Empty -/// values set nothing. +/// 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}` +/// references expanded ([`yarn_env_replace`]); env and `.npmrc` values +/// then go through `resolveWithHome` (`~/` is yarn's `userHome`, else the +/// cwd). Empty values set nothing. pub fn configured_yarn_global_folders( env: &NpmConfigEnv, root_user: bool, @@ -394,16 +397,18 @@ pub fn configured_yarn_global_folders( read: impl Fn(&Path) -> Option, ) -> Vec { let layout = RcLayout::new(env, root_user); - let resolve = |value: &str| layout.resolve_with_home(value, cwd, env.windows); let mut out = Vec::new(); - for prefix in ["yarn_", "npm_config_"] { - if let Some((_, value)) = env_value(env, &[prefix], GLOBAL_FOLDER_KEY) { - out.extend( - Some(value) - .filter(|v| !v.is_empty()) - .and_then(|v| resolve(&v)), - ); + let mut push = |value: Option, resolve: &dyn Fn(&str) -> Option| { + if let Some(path) = value.filter(|v| !v.is_empty()).and_then(|v| resolve(&v)) { + out.push(path); } + }; + let with_home = |value: &str| layout.resolve_with_home(value, cwd, env.windows); + for prefix in ["yarn_", "npm_config_"] { + push( + env_value(env, &[prefix], GLOBAL_FOLDER_KEY).map(|(_, value)| value), + &with_home, + ); } let rc_files = |rc: &str, prefixes: &[&str]| { let mut files = vec![cwd.join(format!(".{rc}"))]; @@ -412,23 +417,142 @@ pub fn configured_yarn_global_folders( }; for path in rc_files("yarnrc", &["yarn_"]) { let Some(text) = read(&path) else { continue }; - let Some(value) = yarnrc_setting(&text, GLOBAL_FOLDER_KEY).map(unquote_rc_value) else { - continue; - }; - if let (false, Some(dir)) = (value.is_empty(), path.parent()) { - out.push(dir.join(value)); + 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)), + ); } } for path in rc_files("npmrc", &["yarn_", "npm_config_"]) { let Some(text) = read(&path) else { continue }; - let Some(value) = npmrc_setting(&text, GLOBAL_FOLDER_KEY).map(unquote_rc_value) else { - continue; - }; - out.extend(Some(value).filter(|v| !v.is_empty()).and_then(resolve)); + push( + npmrc_setting(&text, GLOBAL_FOLDER_KEY) + .map(ini_value) + .and_then(|value| yarn_env_replace(env, &value)), + &with_home, + ); } 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), +/// with `\\`, `\;` and `\#` read as the escaped character. +fn ini_value(raw: &str) -> String { + let raw = raw.trim(); + if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') { + if let Ok(value) = serde_json::from_str::(raw) { + return value; + } + } + if raw.len() >= 2 && raw.starts_with('\'') && raw.ends_with('\'') { + return raw[1..raw.len() - 1].to_string(); + } + let mut out = String::with_capacity(raw.len()); + let mut escaped = false; + for c in raw.chars() { + if escaped { + if !matches!(c, '\\' | ';' | '#') { + out.push('\\'); + } + out.push(c); + escaped = false; + } else if matches!(c, ';' | '#') { + break; + } else if c == '\\' { + escaped = true; + } else { + out.push(c); + } + } + if escaped { + out.push('\\'); + } + out.trim().to_string() +} + +/// yarn 1's `envReplace` over an `.npmrc` value: `${NAME}` becomes the +/// variable (`HOME` is the user's home), and an odd run of backslashes +/// before it keeps the whole expression verbatim, while an even run is +/// replaced with it. An unset variable makes yarn fail to load its +/// config: `None`. +fn yarn_env_replace(env: &NpmConfigEnv, value: &str) -> Option { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(start) = rest.find("${") { + let Some(len) = rest[start + 2..].find('}') else { + break; + }; + let name = &rest[start + 2..start + 2 + len]; + let before = &rest[..start]; + let slashes = before.len() - before.trim_end_matches('\\').len(); + let expr_end = start + 2 + len + 1; + if name.is_empty() || slashes % 2 == 1 { + out.push_str(&rest[..expr_end]); + } else { + out.push_str(&before[..before.len() - slashes]); + let home = env.home.as_ref().and_then(|h| h.to_str()); + out.push_str(match name { + "HOME" => home?, + _ => env.var_raw(name)?, + }); + } + rest = &rest[expr_end..]; + } + out.push_str(rest); + Some(out) +} + /// [`configured_yarn_global_folders`] for this process, run in its cwd, /// reading regular files only. pub fn configured_yarn_global_folders_for_process() -> Vec { diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs index 5a5dd5d2c..b39393c0c 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs @@ -540,3 +540,70 @@ fn global_folder_paths_resolve_like_yarn() { ); } } + +/// #1629: `global-folder` values decode as yarn 1.22.22 decodes them +/// (each expectation checked with `yarn global dir`). +#[test] +fn global_folder_values_read_like_yarn() { + let yarnrc = |text: &str| { + Host::new() + .file("/home/u/.yarnrc", text) + .global_folders("/w") + }; + // A default `--global-folder` flag. + assert_eq!(yarnrc("--global-folder \"/opt/rc\"\n"), paths(&["/opt/rc"])); + assert_eq!(yarnrc("--global-folder /opt/rc\n"), paths(&["/opt/rc"])); + // A trailing comment is its own token. + assert_eq!( + yarnrc("global-folder /opt/yg # moved\n"), + paths(&["/opt/yg"]) + ); + // A double-quoted value is JSON. + assert_eq!( + yarnrc("global-folder \"/opt/a\\\\b\"\n"), + paths(&["/opt/a\\b"]) + ); + // yarn's YAML fallback reads a single-quoted value. + assert_eq!( + yarnrc("\"global-folder\": '/opt/yg'\n"), + paths(&["/opt/yg"]) + ); + // `~` can't start a bare word: yarn ignores the line. + assert_eq!(yarnrc("global-folder ~/yg\n"), Vec::::new()); + + let npmrc = |host: Host, text: &str| host.file("/home/u/.npmrc", text).global_folders("/w"); + // `${NAME}` expands; an unset name makes yarn drop its config. + assert_eq!( + npmrc(Host::new(), "global-folder=${HOME}/x\n"), + paths(&["/home/u/x"]) + ); + assert_eq!( + npmrc(Host::new().var("YG", "/opt/yg"), "global-folder=${YG}\n"), + paths(&["/opt/yg"]) + ); + assert_eq!( + npmrc(Host::new(), "global-folder=${NOPE}/x\n"), + Vec::::new() + ); + // An escaped expression stays verbatim (`ini` reads `\\` as `\` + // first). + for text in [ + "global-folder=\\${HOME}/x\n", + "global-folder=\\\\${HOME}/x\n", + ] { + assert_eq!( + npmrc(Host::new(), text), + paths(&["/w/\\${HOME}/x"]), + "{text:?}" + ); + } + // `ini` ends an unquoted value at a comment and unquotes a quoted one. + assert_eq!( + npmrc(Host::new(), "global-folder=/opt/n ; moved\n"), + paths(&["/opt/n"]) + ); + assert_eq!( + npmrc(Host::new(), "global-folder=\"/opt/nq\"\n"), + paths(&["/opt/nq"]) + ); +} diff --git a/crates/socket-patch-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs index 39450f2fe..143a9af97 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -15,6 +15,7 @@ use crate::formats::yarn::berry_gates::{YARNRC, YARN_LOCK}; use std::path::{Component, Path, PathBuf}; +use std::sync::OnceLock; /// How the currently-running binary appears to have been installed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -47,8 +48,12 @@ pub struct ChannelEnv { pub local_app_data: Option, /// The canonicalized folders yarn 1's `global-folder` config names /// (`YARN_GLOBAL_FOLDER`, `~/.yarnrc`, ...): a `yarn global add` - /// moved there looks like a small yarn project on disk (#1629). - pub yarn_global_folders: Vec, + /// moved there looks like a small yarn project on disk (#1629). Read + /// through [`ChannelEnv::yarn_global_folders`]; tests set it directly. + pub yarn_global_folders: OnceLock>, + /// Whether [`ChannelEnv::yarn_global_folders`] reads yarn's config + /// from this process when it is first asked (set by `from_env`). + read_yarn_config: bool, } impl ChannelEnv { @@ -66,13 +71,25 @@ impl ChannelEnv { xdg_cache_home: path_var("XDG_CACHE_HOME"), home: crate::utils::fs::home_dir(), local_app_data: path_var("LOCALAPPDATA"), - yarn_global_folders: - crate::patch::redirect::yarnrc::configured_yarn_global_folders_for_process() - .into_iter() - .filter_map(|dir| std::fs::canonicalize(dir).ok()) - .collect(), + yarn_global_folders: OnceLock::new(), + read_yarn_config: true, } } + + /// [`ChannelEnv::yarn_global_folders`], reading yarn's config on first + /// use: only a yarn-owned `node_modules` install needs it, so the + /// other channels never walk the rc files. + pub fn yarn_global_folders(&self) -> &[PathBuf] { + self.yarn_global_folders.get_or_init(|| { + if !self.read_yarn_config { + return Vec::new(); + } + crate::patch::redirect::yarnrc::configured_yarn_global_folders_for_process() + .into_iter() + .filter_map(|dir| std::fs::canonicalize(dir).ok()) + .collect() + }) + } } /// Classify the canonicalized executable path. First match wins; anything @@ -293,11 +310,11 @@ fn node_manager(path: &Path, env: &ChannelEnv) -> NodeManager { { return NodeManager::Bun; } - if is_yarn_global_store(path, env) - || (holder_has(YARN_LOCK) - && !crate::constants::npm_family::NPM_LOCKS - .iter() - .any(|lock| holder_has(lock))) + if (holder_has(YARN_LOCK) + && !crate::constants::npm_family::NPM_LOCKS + .iter() + .any(|lock| holder_has(lock))) + || is_yarn_global_store(path, env) { return NodeManager::Yarn; } @@ -312,11 +329,13 @@ fn node_manager(path: &Path, env: &ChannelEnv) -> NodeManager { /// Any folder yarn's config names as its global folder is, whatever its /// name (#1629). fn is_yarn_global_store(path: &Path, env: &ChannelEnv) -> bool { - if outermost_node_modules_holder(path) - .is_some_and(|dir| env.yarn_global_folders.iter().any(|g| g == dir)) - { - return true; - } + is_default_yarn_global_store(path) + || outermost_node_modules_holder(path) + .is_some_and(|dir| env.yarn_global_folders().iter().any(|g| g == dir)) +} + +/// [`is_yarn_global_store`] by the folder's name alone. +fn is_default_yarn_global_store(path: &Path) -> bool { let names = normal_components(path); let Some(first_nm) = names.iter().position(|n| *n == "node_modules") else { return false; @@ -1281,7 +1300,7 @@ mod tests { ); let env = ChannelEnv { - yarn_global_folders: vec![folder.clone()], + yarn_global_folders: OnceLock::from(vec![folder.clone()]), ..Default::default() }; assert_eq!(