Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions crates/socket-patch-core/src/crawlers/npm_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2343,12 +2343,47 @@ pub fn get_yarn_global_prefix() -> Option<String> {
/// 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<String> {
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<PathBuf>,
) -> Option<String> {
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 `<dir>/node_modules`
Expand Down
35 changes: 35 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/yarnrc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,41 @@ pub fn configured_yarn_global_folders_for_process() -> Vec<PathBuf> {
)
}

/// 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`, `<CONFIG_DIRECTORY>/global`, where
/// the config directory is `%LOCALAPPDATA%\Yarn\config` on Windows (when
/// `LOCALAPPDATA` is set) and `<userHome>/.config/yarn` otherwise.
pub fn yarn_1_0_default_global_folder(env: &NpmConfigEnv, root_user: bool) -> Option<PathBuf> {
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<PathBuf> {
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
Comment thread
mikolalysenko marked this conversation as resolved.
Resolved
}

/// Whether this process runs as uid 0 (yarn's `getuid() === 0`).
pub fn process_is_root_user() -> bool {
#[cfg(unix)]
Expand Down
66 changes: 66 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<userHome>/.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")
)
);
}
130 changes: 127 additions & 3 deletions crates/socket-patch-core/tests/crawler_npm_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<std::ffi::OsString>)>);

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() {
Expand Down
Loading