diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 19cc35481..2d6acefd5 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -2343,12 +2343,47 @@ pub fn get_yarn_global_prefix() -> Option { /// Version of `get_yarn_global_prefix` that accepts an injected /// `CommandRunner`. See `get_npm_global_prefix_with`. pub fn get_yarn_global_prefix_with(runner: &dyn CommandRunner) -> Option { - parse_yarn_dir_output( + get_yarn_global_prefix_or( + runner, + crate::patch::redirect::yarnrc::yarn_1_0_global_folders_for_process, + ) +} + +/// [`get_yarn_global_prefix_with`] with the yarn 1.0.x global folder +/// candidates injected. Yarn 1.0.0 - 1.0.2 has no `yarn global dir` (it +/// exits 1 with "Invalid subcommand"), so the first candidate that holds +/// a `node_modules` stands in for its answer when `yarn --version` +/// reports 1.0.x (#437). The version is asked only once such a folder +/// exists, so a yarn without the subcommand for another reason (Berry +/// has no `global`) costs no second spawn. +pub fn get_yarn_global_prefix_or( + runner: &dyn CommandRunner, + yarn_1_0_folders: impl FnOnce() -> Vec, +) -> Option { + let reported = parse_yarn_dir_output( runner .run("yarn", &["global", "dir"]) .as_deref() .unwrap_or(""), - ) + ); + if reported.is_some() { + return reported; + } + let node_modules = yarn_1_0_folders() + .into_iter() + .map(|dir| dir.join("node_modules")) + .find(|dir| is_dir_sync(dir))?; + let version = runner.run("yarn", &["--version"])?; + is_yarn_1_0(&version).then(|| node_modules.to_string_lossy().to_string()) +} + +/// Whether `yarn --version` stdout names a 1.0.x release. +fn is_yarn_1_0(version: &str) -> bool { + version + .trim() + .strip_prefix("1.0.") + .and_then(|patch| patch.split(['-', '+']).next()) + .is_some_and(|patch| !patch.is_empty() && patch.bytes().all(|b| b.is_ascii_digit())) } /// Pure parser for `yarn global dir` stdout. Returns `/node_modules` diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index e51474154..5e40f0f17 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -567,6 +567,41 @@ pub fn configured_yarn_global_folders_for_process() -> Vec { ) } +/// The global folder yarn 1.0.x uses by default, which it can't report: +/// `yarn global dir` only exists from yarn 1.1.0 (#437). This is yarn +/// 1.0.2's `GLOBAL_MODULE_DIRECTORY`, `/global`, where +/// the config directory is `%LOCALAPPDATA%\Yarn\config` on Windows (when +/// `LOCALAPPDATA` is set) and `/.config/yarn` otherwise. +pub fn yarn_1_0_default_global_folder(env: &NpmConfigEnv, root_user: bool) -> Option { + if env.windows { + if let Some(local) = env.var("LOCALAPPDATA") { + return Some(Path::new(local).join("Yarn").join("config").join("global")); + } + } + let user_home = RcLayout::new(env, root_user).user_home?; + Some(user_home.join(".config").join("yarn").join("global")) +} + +/// Where yarn 1.0.x keeps this user's global packages, most likely +/// first: any folder configured as yarn's global folder (yarn 1.0.x reads +/// `--global-folder` from `.yarnrc`), then its default. The config is read +/// as yarn run from the neutral probe dir sees it, like the `yarn global +/// dir` probe it stands in for, so a project's `.yarnrc` can't answer +/// where the machine-wide install lives. +pub fn yarn_1_0_global_folders_for_process() -> Vec { + let env = NpmConfigEnv::from_process(); + let root_user = process_is_root_user(); + let mut folders = crate::utils::process::neutral_probe_dir() + .map(|dir| { + configured_yarn_global_folders(&env, root_user, &dir, |path| { + crate::utils::fs::read_regular_to_string_sync(path).ok() + }) + }) + .unwrap_or_default(); + folders.extend(yarn_1_0_default_global_folder(&env, root_user)); + folders +} + /// 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 6e975243d..2bb9e1d0f 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs @@ -610,3 +610,69 @@ fn global_folder_values_read_like_yarn() { paths(&["/opt/nq"]) ); } + +/// #437: yarn 1.0.x can't report its global folder, so it is derived the +/// way yarn 1.0.2's `constants.js` does: `/.config/yarn/global` +/// (`userHome` is `/usr/local/share` for a root user that isn't under +/// fakeroot), or `%LOCALAPPDATA%\Yarn\config\global` on Windows. +#[test] +fn yarn_1_0_default_global_folder_follows_yarn_1_0_2() { + let vars = |pairs: &[(&str, &str)]| -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + }; + let unix = NpmConfigEnv::from_parts(vars(&[("HOME", "/home/u")]), None, false); + assert_eq!( + yarn_1_0_default_global_folder(&unix, false), + Some(PathBuf::from("/home/u/.config/yarn/global")) + ); + assert_eq!( + yarn_1_0_default_global_folder(&unix, true), + Some(PathBuf::from("/usr/local/share/.config/yarn/global")) + ); + let fakeroot = NpmConfigEnv::from_parts( + vars(&[("HOME", "/home/u"), ("FAKEROOTKEY", "1")]), + None, + false, + ); + assert_eq!( + yarn_1_0_default_global_folder(&fakeroot, true), + Some(PathBuf::from("/home/u/.config/yarn/global")) + ); + assert_eq!( + yarn_1_0_default_global_folder(&NpmConfigEnv::from_parts(Vec::new(), None, false), false), + None, + "no home, no folder" + ); + + let windows = NpmConfigEnv::from_parts( + vars(&[ + ("USERPROFILE", r"C:\Users\u"), + ("LOCALAPPDATA", r"C:\Users\u\AppData\Local"), + ]), + None, + true, + ); + assert_eq!( + yarn_1_0_default_global_folder(&windows, false), + Some( + Path::new(r"C:\Users\u\AppData\Local") + .join("Yarn") + .join("config") + .join("global") + ) + ); + // Without LOCALAPPDATA yarn 1.0.x falls back to the home layout. + let windows = NpmConfigEnv::from_parts(vars(&[("USERPROFILE", r"C:\Users\u")]), None, true); + assert_eq!( + yarn_1_0_default_global_folder(&windows, false), + Some( + Path::new(r"C:\Users\u") + .join(".config") + .join("yarn") + .join("global") + ) + ); +} diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index 880c016a1..39079287c 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -10,9 +10,9 @@ use socket_patch_core::crawlers::npm_crawler::{ build_npm_purl, bun_global_dir_from_env, find_store_peer_variant_copies, get_bun_global_prefix, get_bun_global_prefix_with, get_npm_global_prefix, get_npm_global_prefix_with, get_pnpm_global_prefix, get_pnpm_global_prefix_with, get_yarn_global_prefix, - get_yarn_global_prefix_with, parse_bun_ls_global_output, parse_npm_root_output, - parse_package_name, parse_pnpm_root_output, parse_yarn_dir_output, read_package_json, - resolve_bun_global_prefix_with, + get_yarn_global_prefix_or, get_yarn_global_prefix_with, parse_bun_ls_global_output, + parse_npm_root_output, parse_package_name, parse_pnpm_root_output, parse_yarn_dir_output, + read_package_json, resolve_bun_global_prefix_with, }; use socket_patch_core::crawlers::types::CrawlerOptions; use socket_patch_core::crawlers::NpmCrawler; @@ -352,6 +352,130 @@ fn get_yarn_global_prefix_with_mock_runner_success() { ); } +/// Sets environment variables for a test and puts the old values back on +/// drop, a panic included. +struct EnvGuard(Vec<(&'static str, Option)>); + +impl EnvGuard { + fn set(vars: &[(&'static str, &Path)]) -> Self { + let saved = vars + .iter() + .map(|(k, v)| { + let old = std::env::var_os(k); + std::env::set_var(k, v); + (*k, old) + }) + .collect(); + EnvGuard(saved) + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + for (k, v) in self.0.drain(..) { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + } +} + +/// #437: yarn 1.0.0 - 1.0.2 has no `yarn global dir` (it exits 1), so +/// the folder yarn 1.0.x itself uses stands in for the answer it can't +/// give. Real process environment: HOME points at a scratch home and +/// FAKEROOTKEY keeps a root test runner on the plain-home layout. +#[cfg(unix)] +#[test] +#[serial_test::serial] +fn get_yarn_global_prefix_with_falls_back_for_yarn_1_0() { + let home = tempfile::tempdir().unwrap(); + let global = home.path().join(".config/yarn/global/node_modules"); + std::fs::create_dir_all(global.join("left-pad")).unwrap(); + let _env = EnvGuard::set(&[("HOME", home.path()), ("FAKEROOTKEY", Path::new("1"))]); + let runner = common::MockCommandRunner::new() + .with_response("yarn", &["global", "dir"], None) + .with_response("yarn", &["--version"], Some("1.0.2\n")); + assert_eq!( + get_yarn_global_prefix_with(&runner).map(PathBuf::from), + Some(global.clone()) + ); + + // A project's `.yarnrc` in the cwd names a global folder that exists: + // it can't stand in for the machine-wide answer, which yarn run from + // the neutral probe dir (HOME) never sees. + let project = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(project.path().join("g/node_modules")).unwrap(); + std::fs::write(project.path().join(".yarnrc"), "--global-folder \"./g\"\n").unwrap(); + let cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(project.path()).unwrap(); + let got = get_yarn_global_prefix_with(&runner); + std::env::set_current_dir(cwd).unwrap(); + assert_eq!(got.map(PathBuf::from), Some(global)); +} + +/// The fallback is for yarn 1.0.x only: a newer yarn whose +/// `yarn global dir` failed, or no yarn at all, still finds nothing. The +/// first candidate folder that exists wins, and none existing finds +/// nothing without asking yarn for its version. +#[test] +#[serial_test::parallel] +fn get_yarn_global_prefix_or_falls_back_only_for_yarn_1_0() { + let tmp = tempfile::tempdir().unwrap(); + let configured = tmp.path().join("configured"); + let default = tmp.path().join("default"); + std::fs::create_dir_all(default.join("node_modules")).unwrap(); + let folders = || vec![configured.clone(), default.clone()]; + let want = default.join("node_modules"); + for version in ["1.0.0", "1.0.1\n", "1.0.2", "1.0.2-rc.1"] { + let runner = common::MockCommandRunner::new() + .with_response("yarn", &["global", "dir"], None) + .with_response("yarn", &["--version"], Some(version)); + assert_eq!( + get_yarn_global_prefix_or(&runner, folders).map(PathBuf::from), + Some(want.clone()), + "{version:?}" + ); + } + for version in [ + Some("1.1.0"), + Some("1.22.22"), + Some("1.0"), + Some("4.5.0"), + None, + ] { + let runner = common::MockCommandRunner::new() + .with_response("yarn", &["global", "dir"], None) + .with_response("yarn", &["--version"], version); + assert_eq!( + get_yarn_global_prefix_or(&runner, folders), + None, + "{version:?}" + ); + } + // A configured global folder that exists comes before the default. + std::fs::create_dir_all(configured.join("node_modules")).unwrap(); + let runner = common::MockCommandRunner::new() + .with_response("yarn", &["global", "dir"], None) + .with_response("yarn", &["--version"], Some("1.0.2")); + assert_eq!( + get_yarn_global_prefix_or(&runner, folders).map(PathBuf::from), + Some(configured.join("node_modules")) + ); + // No candidate folder: no version probe is needed (the mock has no + // answer for it either way). + let runner = common::MockCommandRunner::new().with_response("yarn", &["global", "dir"], None); + assert_eq!(get_yarn_global_prefix_or(&runner, Vec::new), None); + // A yarn that answers `yarn global dir` is taken at its word. + let runner = common::MockCommandRunner::new() + .with_response("yarn", &["global", "dir"], Some("/other/global\n")) + .with_response("yarn", &["--version"], Some("1.0.2")); + assert_eq!( + get_yarn_global_prefix_or(&runner, folders).map(PathBuf::from), + Some(Path::new("/other/global").join("node_modules")) + ); +} + #[test] #[serial_test::parallel] fn get_pnpm_global_prefix_with_mock_runner_success() {