diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df0877f..f479f704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,12 @@ limits, and required install commands. ### Fixed +- Global mode (`-g`) finds npm, yarn, pnpm, bun, RubyGems and Composer on + Windows, where they install as `.cmd` / `.bat` shims, instead of reporting + an empty scan. The yarn and npm-family global lookups no longer run from the + scanned project, so a Yarn Berry project's `global` script can't run or pick + the directory treated as the global install. Composer's global home also + falls back to `%APPDATA%\Composer` and `$XDG_CONFIG_HOME/composer`. - Agent-mode PyPI `apply` patches every installed copy of a release, not just the first one found. A Pipenv project with both a WORKON_HOME venv and a `./.venv`, or a global install with the same release in the user site and a diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 2411edd1..606b0ef1 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -6,7 +6,7 @@ use super::types::{CrawledPackage, CrawlerOptions}; use crate::patch::path_safety; use crate::utils::composer_version::composer_versions_equivalent; use crate::utils::fs::{is_dir, is_dir_sync, is_file, normalize_lexically, run_blocking}; -use crate::utils::process::{CommandRunner, SystemCommandRunner}; +use crate::utils::process::{CommandRunner, GlobalProbeRunner}; #[cfg(test)] mod oracle; @@ -371,7 +371,7 @@ async fn get_composer_home() -> Option { // memoized for an unchanged environment, see `COMPOSER_GLOBAL_HOME`) let stdout = run_blocking(|| { COMPOSER_GLOBAL_HOME - .get_or_run(|| SystemCommandRunner.run("composer", &["global", "config", "home"])) + .get_or_run(|| GlobalProbeRunner.run("composer", &["global", "config", "home"])) }) .await; if let Some(stdout) = stdout { @@ -382,30 +382,58 @@ async fn get_composer_home() -> Option { } } - // Platform defaults. A set-but-empty HOME counts as unset: honoring - // `""` would turn the `.composer`/`.config/composer` probes below into - // CWD-relative paths inside the user's project (same rule as - // `utils::fs::home_dir`). - let home_dir = std::env::var("HOME") - .ok() - .filter(|h| !h.is_empty()) - .or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()))?; - let home = PathBuf::from(home_dir); - - let candidates = [ - home.join(".composer"), - home.join(".config").join("composer"), - ]; - - for candidate in &candidates { - if is_dir(candidate).await { - return Some(candidate.clone()); + // Platform defaults (see `composer_home_candidates`). + let var = |name: &str| std::env::var_os(name); + for candidate in composer_home_candidates(&var, cfg!(windows)) { + if is_dir(&candidate).await { + return Some(candidate); } } None } +/// The directories Composer itself uses as its home when `COMPOSER_HOME` +/// is unset, in the order to probe them (`Factory::getHomeDir`): +/// +/// - Windows: `%APPDATA%\Composer` — the only default there; the +/// `~/.composer` probe is kept after it for older layouts. +/// - elsewhere: `~/.composer` when it exists, else the XDG location, +/// `$XDG_CONFIG_HOME/composer` or `~/.config/composer`. +/// +/// A set-but-empty or relative variable counts as unset: honoring `""` +/// would turn these probes into CWD-relative paths inside the user's +/// project (same rule as `utils::fs::home_dir`). +pub(crate) fn composer_home_candidates( + var: &impl Fn(&str) -> Option, + windows: bool, +) -> Vec { + let absolute = |name: &str| { + var(name) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + }; + let home = absolute("HOME").or_else(|| absolute("USERPROFILE")); + let mut candidates = Vec::new(); + if windows { + if let Some(app_data) = absolute("APPDATA") { + candidates.push(app_data.join("Composer")); + } + } + if let Some(home) = &home { + candidates.push(home.join(".composer")); + } + if !windows { + if let Some(xdg) = absolute("XDG_CONFIG_HOME") { + candidates.push(xdg.join("composer")); + } + } + if let Some(home) = &home { + candidates.push(home.join(".config").join("composer")); + } + candidates +} + /// Normalize a Composer version string for PURL identity. /// /// Composer's `installed.json` records the *pretty* version, which for diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 80b31c3e..26ff8b67 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -640,11 +640,17 @@ struct StoreEntryDir { // Global prefix detection helpers // --------------------------------------------------------------------------- -use crate::utils::process::{CommandRunner, SystemCommandRunner}; +use crate::utils::process::{CommandRunner, GlobalProbeRunner}; /// Get the npm global `node_modules` path via `npm root -g`. +/// +/// This and the yarn / pnpm / bun probes below run through +/// [`GlobalProbeRunner`]: the tool is resolved through `PATHEXT` (the +/// Windows `npm.cmd` shim) and asked from a neutral directory, never from +/// the scanned project, whose own scripts and config must not answer a +/// question about the machine-wide install. pub fn get_npm_global_prefix() -> Result { - get_npm_global_prefix_with(&SystemCommandRunner) + get_npm_global_prefix_with(&GlobalProbeRunner) } /// Version of `get_npm_global_prefix` that accepts an injected @@ -674,7 +680,7 @@ pub fn parse_npm_root_output(stdout: &str) -> Option { /// Get the yarn global `node_modules` path via `yarn global dir`. pub fn get_yarn_global_prefix() -> Option { - get_yarn_global_prefix_with(&SystemCommandRunner) + get_yarn_global_prefix_with(&GlobalProbeRunner) } /// Version of `get_yarn_global_prefix` that accepts an injected @@ -706,7 +712,7 @@ pub fn parse_yarn_dir_output(stdout: &str) -> Option { /// Get the pnpm global `node_modules` path via `pnpm root -g`. pub fn get_pnpm_global_prefix() -> Option { - get_pnpm_global_prefix_with(&SystemCommandRunner) + get_pnpm_global_prefix_with(&GlobalProbeRunner) } /// Version of `get_pnpm_global_prefix` that accepts an injected @@ -727,7 +733,7 @@ pub fn parse_pnpm_root_output(stdout: &str) -> Option { /// Get the bun global `node_modules` path via `bun pm bin -g`. pub fn get_bun_global_prefix() -> Option { - get_bun_global_prefix_with(&SystemCommandRunner) + get_bun_global_prefix_with(&GlobalProbeRunner) } /// Version of `get_bun_global_prefix` that accepts an injected diff --git a/crates/socket-patch-core/src/utils/process.rs b/crates/socket-patch-core/src/utils/process.rs index 2c9a074b..13038c67 100644 --- a/crates/socket-patch-core/src/utils/process.rs +++ b/crates/socket-patch-core/src/utils/process.rs @@ -76,6 +76,24 @@ pub(crate) fn resolve_tool_with( None } +/// `name.exe` as a directory entry of any kind (an App Execution Alias is a +/// reparse point `is_file` can't follow) on an ABSOLUTE `PATH` entry, or +/// `None`. The Windows fallback when [`resolve_tool`] finds nothing; same +/// relative-entry rule, so a project-local executable is never chosen. +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn resolve_app_alias_with( + name: &str, + var: &impl Fn(&str) -> Option, +) -> Option { + let path = var("PATH")?; + std::env::split_paths(&path) + .filter(|dir| dir.is_absolute()) + .map(|dir| dir.join(format!("{name}.exe"))) + .find(|candidate| { + std::fs::symlink_metadata(candidate).is_ok_and(|meta| !meta.is_dir()) + }) +} + /// A plain file that cannot be executed (a stray `bun` data file on PATH) /// is skipped in favour of the next entry, like execvp does; Windows has no /// mode bits, PATHEXT is the executability rule there. @@ -127,6 +145,17 @@ pub trait CommandRunner { /// Default runner: spawns the real binary via `std::process::Command`. /// +/// The program is looked up with [`resolve_tool`] and the RESOLVED path is +/// spawned, never the bare name: on Windows `std` appends only `.exe`, so a +/// bare `Command::new("npm")` never finds the `npm.cmd` / `yarn.cmd` / +/// `gem.cmd` / `composer.bat` shim those tools install as, and every probe +/// silently answered "not installed". The lookup also skips relative `PATH` +/// entries, so a tool planted in the scanned project is never run. +/// +/// The child inherits the caller's working directory: some probes must ask +/// from the project (`gem env` follows rbenv's `.ruby-version` there). A +/// probe about the machine-wide install uses [`GlobalProbeRunner`]. +/// /// `output()` nulls stdin so the child can't block waiting for /// input. stdout is captured; stderr is captured and dropped (we /// don't surface CLI diagnostics — the helpers fall back to other @@ -135,16 +164,84 @@ pub(crate) struct SystemCommandRunner; impl CommandRunner for SystemCommandRunner { fn run(&self, bin: &str, args: &[&str]) -> Option { - let output = Command::new(bin).args(args).output().ok()?; - if !output.status.success() { - return None; - } - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if stdout.is_empty() { - None - } else { - Some(stdout) + run_resolved(bin, args, None) + } +} + +/// [`SystemCommandRunner`] for a question about a package manager's GLOBAL +/// install (`npm root -g`, `yarn global dir`, ...): the child runs from +/// [`neutral_probe_dir`], never from the scanned project. From inside a +/// project the tool reads that project's configuration — Yarn Berry has no +/// `global` command and runs the project's `"global"` package.json script +/// instead, whose stdout then picked the directory scanned (and patched) as +/// the global install; a `.yarnrc.yml` `yarnPath` runs a project-supplied +/// JS file for any `yarn` call. +pub(crate) struct GlobalProbeRunner; + +impl CommandRunner for GlobalProbeRunner { + fn run(&self, bin: &str, args: &[&str]) -> Option { + run_resolved(bin, args, Some(&neutral_probe_dir()?)) + } +} + +/// Where global probes run: the user's home directory (theirs, not a +/// checkout's, and never world-writable like the temp dir), else the root +/// of the current drive. `None` only when neither can be determined, and +/// then the probe is not run at all rather than run from the project. +pub(crate) fn neutral_probe_dir() -> Option { + neutral_probe_dir_with(&|var| std::env::var_os(var)) +} + +/// [`neutral_probe_dir`] over an injected environment reader (tests). +pub(crate) fn neutral_probe_dir_with(var: &impl Fn(&str) -> Option) -> Option { + let home = ["HOME", "USERPROFILE"] + .into_iter() + .filter_map(var) + .map(PathBuf::from) + .find(|path| path.is_absolute() && path.is_dir()); + home.or_else(|| { + std::env::current_dir() + .ok()? + .ancestors() + .last() + .map(Path::to_path_buf) + }) +} + +/// Spawn `bin` (looked up with [`resolve_tool`]; a value that already +/// names a path is spawned as given) with `args`, optionally from `cwd`, +/// and return its trimmed stdout under the [`CommandRunner`] contract. +fn run_resolved(bin: &str, args: &[&str], cwd: Option<&Path>) -> Option { + let program = if Path::new(bin).components().count() > 1 { + PathBuf::from(bin) + } else { + match resolve_tool(bin) { + Some(path) => path, + // A Windows App Execution Alias (the Store `python3.exe` in + // WindowsApps) is a reparse point the file probe can't stat; + // look for it on absolute PATH entries only. Never hand the bare + // name back to `std`: its Windows search also walks relative + // PATH entries such as `.` (against the PARENT's cwd, before the + // child's `current_dir` applies), so a `yarn.exe` planted in the + // scanned project would run. + None if cfg!(windows) => resolve_app_alias_with(bin, &|var| std::env::var_os(var))?, + None => return None, } + }; + let mut command = command_for(&program); + command.args(args); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + let output = command.output().ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + None + } else { + Some(stdout) } } @@ -246,6 +343,28 @@ mod tests { assert_eq!(out.as_deref(), Some("forwarded")); } + /// Global probes run from the home dir; a relative or missing HOME is + /// never used (it would resolve against the project), and with no + /// usable home the probe falls back to the drive root, not the cwd. + #[test] + fn neutral_probe_dir_prefers_an_absolute_home_and_never_the_cwd() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let env = |name: &str| (name == "HOME").then(|| home.clone().into_os_string()); + assert_eq!(neutral_probe_dir_with(&env), Some(home.clone())); + + let profile = |name: &str| match name { + "HOME" => Some(OsString::from("relative/home")), + "USERPROFILE" => Some(home.clone().into_os_string()), + _ => None, + }; + assert_eq!(neutral_probe_dir_with(&profile), Some(home.clone())); + + let none = |_: &str| None::; + let root = neutral_probe_dir_with(&none).expect("the drive root"); + assert!(root.is_absolute() && root.parent().is_none(), "{root:?}"); + } + // ───────────────────────── resolve_tool / command_for ───────────────────────── /// Mark an existing file executable (no-op off Unix: PATHEXT rules there). @@ -299,6 +418,28 @@ mod tests { /// The name is honoured exactly: a `bunx` beside no `bun` is not `bun`, /// and a directory named `bun` is not a program. + /// The Windows App Execution Alias fallback takes the same absolute-only + /// rule as `resolve_tool`: a `yarn.exe` reached only through a relative + /// entry (`.`, the empty component, a bare dir name) is never chosen; + /// one on an absolute entry is. + #[test] + fn resolve_app_alias_skips_relative_entries() { + let tmp = tempfile::tempdir().unwrap(); + let safe = tmp.path().join("bin"); + std::fs::create_dir_all(&safe).unwrap(); + let relative = [PathBuf::from("."), PathBuf::from(""), PathBuf::from("planted")]; + + let only_relative = std::env::join_paths(&relative).unwrap(); + let var = |name: &str| (name == "PATH").then(|| only_relative.clone()); + assert_eq!(resolve_app_alias_with("yarn", &var), None); + + std::fs::write(safe.join("yarn.exe"), b"").unwrap(); + let with_safe = + std::env::join_paths(relative.iter().cloned().chain([safe.clone()])).unwrap(); + let var = |name: &str| (name == "PATH").then(|| with_safe.clone()); + assert_eq!(resolve_app_alias_with("yarn", &var), Some(safe.join("yarn.exe"))); + } + #[test] fn resolve_tool_matches_the_exact_leaf_only() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs index 24a50d9b..3c4c872f 100644 --- a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs +++ b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs @@ -72,6 +72,10 @@ async fn get_vendor_paths_global_nonexistent_composer_home_falls_back() { let _composer_home = EnvVarGuard::set("COMPOSER_HOME", bogus_home.as_os_str()); let _home = EnvVarGuard::set("HOME", tmp.path().as_os_str()); let _path = EnvVarGuard::set("PATH", empty_path.path().as_os_str()); + // Composer's other platform defaults (`%APPDATA%\Composer` on Windows, + // `$XDG_CONFIG_HOME/composer`) would outrank the HOME candidate here. + let _app_data = EnvVarGuard::remove("APPDATA"); + let _xdg = EnvVarGuard::remove("XDG_CONFIG_HOME"); let crawler = ComposerCrawler; let paths = crawler diff --git a/crates/socket-patch-core/tests/crawler_composer_e2e.rs b/crates/socket-patch-core/tests/crawler_composer_e2e.rs index bb78f6ae..9ca86bbf 100644 --- a/crates/socket-patch-core/tests/crawler_composer_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_composer_e2e.rs @@ -414,6 +414,12 @@ async fn get_vendor_paths_global_via_home_dot_composer_fallback() { std::env::remove_var("COMPOSER_HOME"); std::env::set_var("HOME", tmp.path()); std::env::set_var("PATH", empty_path.path()); + // Composer's other platform defaults (`%APPDATA%\Composer` on Windows, + // `$XDG_CONFIG_HOME/composer`) would outrank the HOME candidates here. + let prev_app_data = std::env::var_os("APPDATA"); + let prev_xdg = std::env::var_os("XDG_CONFIG_HOME"); + std::env::remove_var("APPDATA"); + std::env::remove_var("XDG_CONFIG_HOME"); let crawler = ComposerCrawler; let opts = CrawlerOptions { @@ -436,6 +442,12 @@ async fn get_vendor_paths_global_via_home_dot_composer_fallback() { } else { std::env::remove_var("PATH"); } + if let Some(v) = prev_app_data { + std::env::set_var("APPDATA", v); + } + if let Some(v) = prev_xdg { + std::env::set_var("XDG_CONFIG_HOME", v); + } assert_eq!( paths, @@ -466,6 +478,12 @@ async fn get_vendor_paths_global_via_home_xdg_config_composer_fallback() { std::env::remove_var("COMPOSER_HOME"); std::env::set_var("HOME", tmp.path()); std::env::set_var("PATH", empty_path.path()); + // Composer's other platform defaults (`%APPDATA%\Composer` on Windows, + // `$XDG_CONFIG_HOME/composer`) would outrank the HOME candidates here. + let prev_app_data = std::env::var_os("APPDATA"); + let prev_xdg = std::env::var_os("XDG_CONFIG_HOME"); + std::env::remove_var("APPDATA"); + std::env::remove_var("XDG_CONFIG_HOME"); let crawler = ComposerCrawler; let opts = CrawlerOptions { @@ -488,6 +506,12 @@ async fn get_vendor_paths_global_via_home_xdg_config_composer_fallback() { } else { std::env::remove_var("PATH"); } + if let Some(v) = prev_app_data { + std::env::set_var("APPDATA", v); + } + if let Some(v) = prev_xdg { + std::env::set_var("XDG_CONFIG_HOME", v); + } assert_eq!( paths, @@ -510,6 +534,10 @@ async fn get_vendor_paths_global_no_composer_no_home_layout_returns_empty() { let prev_composer = std::env::var("COMPOSER_HOME").ok(); let prev_home = std::env::var("HOME").ok(); let prev_path = std::env::var("PATH").ok(); + let prev_app_data = std::env::var_os("APPDATA"); + let prev_xdg = std::env::var_os("XDG_CONFIG_HOME"); + std::env::remove_var("APPDATA"); + std::env::remove_var("XDG_CONFIG_HOME"); std::env::remove_var("COMPOSER_HOME"); // HOME is set, but the temp HOME has no .composer / .config/composer. std::env::set_var("HOME", tmp.path()); @@ -537,6 +565,12 @@ async fn get_vendor_paths_global_no_composer_no_home_layout_returns_empty() { } else { std::env::remove_var("PATH"); } + if let Some(v) = prev_app_data { + std::env::set_var("APPDATA", v); + } + if let Some(v) = prev_xdg { + std::env::set_var("XDG_CONFIG_HOME", v); + } assert!( paths.is_empty(), @@ -564,6 +598,10 @@ async fn get_vendor_paths_global_empty_home_not_cwd_relative() { let prev_home = std::env::var("HOME").ok(); let prev_profile = std::env::var("USERPROFILE").ok(); let prev_path = std::env::var("PATH").ok(); + let prev_app_data = std::env::var_os("APPDATA"); + let prev_xdg = std::env::var_os("XDG_CONFIG_HOME"); + std::env::remove_var("APPDATA"); + std::env::remove_var("XDG_CONFIG_HOME"); std::env::remove_var("COMPOSER_HOME"); std::env::set_var("HOME", ""); std::env::set_var("USERPROFILE", ""); @@ -597,6 +635,12 @@ async fn get_vendor_paths_global_empty_home_not_cwd_relative() { } else { std::env::remove_var("PATH"); } + if let Some(v) = prev_app_data { + std::env::set_var("APPDATA", v); + } + if let Some(v) = prev_xdg { + std::env::set_var("XDG_CONFIG_HOME", v); + } assert!( paths.is_empty(), diff --git a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs new file mode 100644 index 00000000..e447bf95 --- /dev/null +++ b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs @@ -0,0 +1,368 @@ +//! Global-mode discovery asks each package manager where its global tree +//! lives (`npm root -g`, `yarn global dir`, `gem env gemdir`, `composer +//! global config home`, ...). These tests put a fake tool on `PATH` the way +//! the real ones install on each OS — an executable script on Unix, a +//! `.cmd` shim on Windows — and check what global discovery makes of it: +//! +//! - #434 / #421 / #438: on Windows the shim must be found (a bare +//! `Command::new("npm")` only tries `npm.exe`), so global discovery is not +//! silently empty. +//! - #440: a global probe must run from a neutral directory, never from the +//! scanned project (Yarn Berry runs the project's `global` script for +//! `yarn global dir`, and its stdout picked the "global" directory). +//! - #438: Composer's own platform defaults (`%APPDATA%\Composer`, +//! `$XDG_CONFIG_HOME/composer`) are probed when the CLI can't answer. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use serial_test::serial; +use socket_patch_core::crawlers::npm_crawler::{ + get_bun_global_prefix, get_npm_global_prefix, get_pnpm_global_prefix, get_yarn_global_prefix, +}; +use socket_patch_core::crawlers::types::CrawlerOptions; +use socket_patch_core::crawlers::{ComposerCrawler, RubyCrawler}; + +/// Set (or remove) env vars and the cwd for the guard's lifetime; restored +/// on drop so a failing assertion can't leak state into later tests. +struct Env { + saved: Vec<(&'static str, Option)>, + cwd: Option, +} + +impl Env { + fn new() -> Self { + Env { + saved: Vec::new(), + cwd: None, + } + } + + fn set(&mut self, name: &'static str, value: Option<&std::ffi::OsStr>) -> &mut Self { + self.saved.push((name, std::env::var_os(name))); + match value { + Some(v) => std::env::set_var(name, v), + None => std::env::remove_var(name), + } + self + } + + fn chdir(&mut self, dir: &Path) -> &mut Self { + if self.cwd.is_none() { + self.cwd = std::env::current_dir().ok(); + } + std::env::set_current_dir(dir).unwrap(); + self + } +} + +impl Drop for Env { + fn drop(&mut self) { + if let Some(cwd) = self.cwd.take() { + let _ = std::env::set_current_dir(cwd); + } + for (name, value) in self.saved.drain(..).rev() { + match value { + Some(v) => std::env::set_var(name, v), + None => std::env::remove_var(name), + } + } + } +} + +/// Install a fake `name` tool in `bin` the way the real one ships on this +/// OS: an executable `sh` script on Unix, a `name.cmd` shim (no `.exe`) on +/// Windows. It prints `line`, or its working directory when `line` is None. +fn fake_tool(bin: &Path, name: &str, line: Option<&str>) { + std::fs::create_dir_all(bin).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let body = match line { + Some(line) => format!("printf '%s\\n' '{line}'"), + None => "pwd -P".to_string(), + }; + let path = bin.join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + #[cfg(windows)] + { + let body = match line { + Some(line) => format!("echo {line}"), + None => "echo %CD%".to_string(), + }; + std::fs::write( + bin.join(format!("{name}.cmd")), + format!("@echo off\r\n{body}\r\n"), + ) + .unwrap(); + } +} + +fn canon(path: &Path) -> PathBuf { + dunce_canonical(path) +} + +/// `canonicalize` without Windows' `\\?\` verbatim prefix, so a path a +/// `.cmd` shim echoed compares equal to the one the test created. +fn dunce_canonical(path: &Path) -> PathBuf { + let canonical = std::fs::canonicalize(path).unwrap(); + let text = canonical.to_string_lossy(); + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => canonical, + } +} + +/// A temp layout: `home/`, a `proj/` project and `bin/` (the only PATH +/// entry), with HOME / USERPROFILE pointed at `home`. +struct Layout { + _tmp: tempfile::TempDir, + home: PathBuf, + proj: PathBuf, + bin: PathBuf, +} + +fn layout() -> Layout { + let tmp = tempfile::tempdir().unwrap(); + let root = canon(tmp.path()); + let home = root.join("home"); + let proj = root.join("proj"); + let bin = root.join("bin"); + for dir in [&home, &proj, &bin] { + std::fs::create_dir_all(dir).unwrap(); + } + Layout { + _tmp: tmp, + home, + proj, + bin, + } +} + +fn point_env_at(env: &mut Env, l: &Layout) { + env.set("PATH", Some(l.bin.as_os_str())) + .set("HOME", Some(l.home.as_os_str())) + .set("USERPROFILE", Some(l.home.as_os_str())); + #[cfg(windows)] + env.set("PATHEXT", Some(std::ffi::OsStr::new(".COM;.EXE;.BAT;.CMD"))); +} + +// ───────────────────────────── npm family (#434) ───────────────────────────── + +/// #434: the npm / pnpm / bun global probes find the tool as it is +/// installed on this OS (on Windows: `npm.cmd`, `pnpm.cmd`, `bun.cmd`). +#[test] +#[serial] +fn npm_family_global_probes_find_the_installed_shims() { + let l = layout(); + let npm_root = l.home.join("npm-global").join("node_modules"); + let pnpm_root = l.home.join("pnpm-global").join("node_modules"); + let bun_bin = l.home.join(".bun").join("bin"); + fake_tool(&l.bin, "npm", Some(&npm_root.to_string_lossy())); + fake_tool(&l.bin, "pnpm", Some(&pnpm_root.to_string_lossy())); + fake_tool(&l.bin, "bun", Some(&bun_bin.to_string_lossy())); + let mut env = Env::new(); + point_env_at(&mut env, &l); + + assert_eq!( + get_npm_global_prefix().map(PathBuf::from), + Ok(npm_root), + "`npm root -g` must be asked through the installed npm shim" + ); + assert_eq!(get_pnpm_global_prefix().map(PathBuf::from), Some(pnpm_root)); + assert_eq!( + get_bun_global_prefix().map(PathBuf::from), + Some( + l.home + .join(".bun") + .join("install") + .join("global") + .join("node_modules") + ) + ); +} + +/// #434 (yarn, per the follow-up comment) and #440: `yarn global dir` is +/// found through the installed shim AND runs from the user's home, not from +/// the scanned project, so a project's `global` script can't answer it. +#[test] +#[serial] +fn yarn_global_probe_runs_outside_the_scanned_project() { + let l = layout(); + std::fs::write( + l.proj.join("package.json"), + r#"{"name":"p","scripts":{"global":"node mark.js"}}"#, + ) + .unwrap(); + fake_tool(&l.bin, "yarn", None); + let mut env = Env::new(); + point_env_at(&mut env, &l); + env.chdir(&l.proj); + + let prefix = get_yarn_global_prefix().expect("yarn global dir must be answered"); + let asked_from = canon(Path::new(&prefix).parent().unwrap()); + assert_eq!( + asked_from, + l.home, + "the yarn probe must run from the home dir, not the project ({})", + l.proj.display() + ); +} + +/// #440's sibling: a probe never runs a tool planted in the project via a +/// relative PATH entry (`.`), even when that is the first entry. +#[cfg(unix)] +#[test] +#[serial] +fn global_probe_ignores_a_tool_planted_on_a_relative_path_entry() { + let l = layout(); + fake_tool(&l.proj, "npm", Some("/planted/node_modules")); + fake_tool(&l.bin, "npm", Some("/real/node_modules")); + let mut env = Env::new(); + point_env_at(&mut env, &l); + let path = std::env::join_paths([PathBuf::from("."), l.bin.clone()]).unwrap(); + env.set("PATH", Some(path.as_os_str())); + env.chdir(&l.proj); + + assert_eq!( + get_npm_global_prefix().as_deref(), + Ok("/real/node_modules"), + "the project's ./npm must never be spawned" + ); +} + +/// #440 on Windows: with no safe `npm` installed and `.` on PATH, a real +/// executable planted in the project as `npm.exe` must not run. The +/// fallback for App Execution Aliases used to hand the bare name to `std`, +/// whose Windows search walks relative PATH entries against the parent's +/// cwd (the project), before the child's neutral `current_dir` applies. +/// The plant is a copy of `cmd.exe`, which prints its banner and exits 0 on +/// a null stdin, so running it would yield a "prefix". +#[cfg(windows)] +#[test] +#[serial] +fn global_probe_never_runs_an_executable_planted_in_the_project() { + let l = layout(); + let system_root = std::env::var_os("SystemRoot").expect("SystemRoot is set on Windows"); + std::fs::copy( + PathBuf::from(system_root).join("System32").join("cmd.exe"), + l.proj.join("npm.exe"), + ) + .unwrap(); + let mut env = Env::new(); + point_env_at(&mut env, &l); + env.set("PATH", Some(std::ffi::OsStr::new("."))); + env.chdir(&l.proj); + + let prefix = get_npm_global_prefix(); + assert!( + prefix.is_err(), + "the project's npm.exe must never be spawned; got {prefix:?}" + ); +} + +// ───────────────────────────────── RubyGems (#421) ───────────────────────────────── + +/// #421: `gem env gemdir` / `gem env gempath` are answered through the +/// installed `gem` (RubyInstaller ships `gem.cmd`, no `gem.exe`), so +/// global mode scans that gem home. +#[tokio::test] +#[serial] +async fn global_gem_paths_come_from_the_installed_gem_shim() { + let l = layout(); + let gem_home = l.home.join("ruby-gems"); + std::fs::create_dir_all(gem_home.join("gems")).unwrap(); + fake_tool(&l.bin, "gem", Some(&gem_home.to_string_lossy())); + let mut env = Env::new(); + point_env_at(&mut env, &l); + env.set("GEM_HOME", None).set("GEM_PATH", None); + + let options = CrawlerOptions { + cwd: l.proj.clone(), + global: true, + global_prefix: None, + }; + let paths = RubyCrawler::new().get_gem_paths(&options).await.unwrap(); + assert!( + paths.contains(&gem_home.join("gems")), + "global gem paths must include the `gem env` home; got {paths:?}" + ); +} + +// ───────────────────────────────── Composer (#438) ───────────────────────────────── + +fn global_options(cwd: &Path) -> CrawlerOptions { + CrawlerOptions { + cwd: cwd.to_path_buf(), + global: true, + global_prefix: None, + } +} + +/// #438 (1): `composer global config home` is answered through the +/// installed `composer` (`composer.bat` on Windows). +#[tokio::test] +#[serial] +async fn composer_home_comes_from_the_installed_composer_shim() { + let l = layout(); + let composer_home = l.home.join("composer-home"); + std::fs::create_dir_all(composer_home.join("vendor")).unwrap(); + fake_tool(&l.bin, "composer", Some(&composer_home.to_string_lossy())); + let mut env = Env::new(); + point_env_at(&mut env, &l); + env.set("COMPOSER_HOME", None) + .set("APPDATA", None) + .set("XDG_CONFIG_HOME", None); + + let paths = ComposerCrawler + .get_vendor_paths(&global_options(&l.proj)) + .await + .unwrap(); + assert_eq!(paths, vec![composer_home.join("vendor")]); +} + +/// #438 (2), Windows: with no `composer` to ask, Composer's Windows +/// default `%APPDATA%\Composer` is probed. +#[cfg(windows)] +#[tokio::test] +#[serial] +async fn composer_home_falls_back_to_appdata_on_windows() { + let l = layout(); + let app_data = l.home.join("AppData").join("Roaming"); + let vendor = app_data.join("Composer").join("vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + let mut env = Env::new(); + point_env_at(&mut env, &l); + env.set("COMPOSER_HOME", None) + .set("APPDATA", Some(app_data.as_os_str())); + + let paths = ComposerCrawler + .get_vendor_paths(&global_options(&l.proj)) + .await + .unwrap(); + assert_eq!(paths, vec![vendor]); +} + +/// #438 (2), Unix: with no `composer` to ask and no `~/.composer`, +/// Composer uses `$XDG_CONFIG_HOME/composer`. +#[cfg(unix)] +#[tokio::test] +#[serial] +async fn composer_home_falls_back_to_xdg_config_home() { + let l = layout(); + let xdg = l.home.join("xdg"); + let vendor = xdg.join("composer").join("vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + let mut env = Env::new(); + point_env_at(&mut env, &l); + env.set("COMPOSER_HOME", None) + .set("XDG_CONFIG_HOME", Some(xdg.as_os_str())); + + let paths = ComposerCrawler + .get_vendor_paths(&global_options(&l.proj)) + .await + .unwrap(); + assert_eq!(paths, vec![vendor]); +}