Skip to content

Commit 6cd3754

Browse files
Fix global PM probes spawning bare names from the project (#421, #434, #438, #440) (#442)
* Start fix for #421, #434, #438, #440 Assisted-by: Claude Code:claude-opus-5-5 * Find global package managers on Windows Global mode asked npm, yarn, pnpm, bun, gem and composer where their global installs live by spawning the bare tool name. On Windows those tools are .cmd/.bat shims that a bare spawn never finds, so scan -g silently reported nothing to patch. The yarn probe also ran inside the scanned project, where Yarn Berry runs the project's own "global" script and its output picked the directory scanned as global. Probes now resolve the tool through PATHEXT (and never from a relative PATH entry), npm-family global probes run from the home directory, and Composer's home falls back to %APPDATA%\Composer and $XDG_CONFIG_HOME/composer like Composer does. Fixes #421, #434, #438, #440. Assisted-by: Claude Code:claude-opus-5-5 * Keep Windows tools std alone can launch A Windows App Execution Alias (the Store python3.exe) is a reparse point the PATH lookup can't stat, though a bare spawn launches it. The Python probe shares this runner, so fall back to the bare name on Windows when the lookup finds nothing, rather than lose an interpreter that used to be found. Also satisfies clippy's redundant closure lint. Assisted-by: Claude Code:claude-opus-5-5 * Isolate Composer HOME-fallback tests from APPDATA Global Composer discovery now also tries Composer's own defaults, %APPDATA%\Composer and $XDG_CONFIG_HOME/composer. On a Windows runner APPDATA points at a real Composer home, which outranks the ~/.composer and ~/.config/composer candidates these tests stage. Unset both variables there, as the tests already do for HOME and PATH, so they keep exercising the HOME candidates. Assisted-by: Claude Code:claude-opus-5-5 * Drop unrelated formatting churn An earlier cargo fmt run over the whole workspace reformatted 125 files this change doesn't touch. Restore them to main so the diff holds only the global-probe fix. Assisted-by: Claude Code:claude-opus-5-5 * Isolate the remaining Composer global tests from APPDATA The no-composer and empty-HOME tests still inherited APPDATA and XDG_CONFIG_HOME. Global discovery now probes %APPDATA%\Composer and $XDG_CONFIG_HOME/composer, so a machine with a real Composer home there made both tests see a vendor dir and fail. Unset both variables in these tests too, as the sibling HOME-fallback tests already do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKwBoKTsqpGR3ZcCAcEyCA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bf0e0d1 commit 6cd3754

7 files changed

Lines changed: 631 additions & 34 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ limits, and required install commands.
102102

103103
### Fixed
104104

105+
- Global mode (`-g`) finds npm, yarn, pnpm, bun, RubyGems and Composer on
106+
Windows, where they install as `.cmd` / `.bat` shims, instead of reporting
107+
an empty scan. The yarn and npm-family global lookups no longer run from the
108+
scanned project, so a Yarn Berry project's `global` script can't run or pick
109+
the directory treated as the global install. Composer's global home also
110+
falls back to `%APPDATA%\Composer` and `$XDG_CONFIG_HOME/composer`.
105111
- Agent-mode PyPI `apply` patches every installed copy of a release, not just
106112
the first one found. A Pipenv project with both a WORKON_HOME venv and a
107113
`./.venv`, or a global install with the same release in the user site and a

‎crates/socket-patch-core/src/crawlers/composer_crawler.rs‎

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use super::types::{CrawledPackage, CrawlerOptions};
66
use crate::patch::path_safety;
77
use crate::utils::composer_version::composer_versions_equivalent;
88
use crate::utils::fs::{is_dir, is_dir_sync, is_file, normalize_lexically, run_blocking};
9-
use crate::utils::process::{CommandRunner, SystemCommandRunner};
9+
use crate::utils::process::{CommandRunner, GlobalProbeRunner};
1010

1111
#[cfg(test)]
1212
mod oracle;
@@ -371,7 +371,7 @@ async fn get_composer_home() -> Option<PathBuf> {
371371
// memoized for an unchanged environment, see `COMPOSER_GLOBAL_HOME`)
372372
let stdout = run_blocking(|| {
373373
COMPOSER_GLOBAL_HOME
374-
.get_or_run(|| SystemCommandRunner.run("composer", &["global", "config", "home"]))
374+
.get_or_run(|| GlobalProbeRunner.run("composer", &["global", "config", "home"]))
375375
})
376376
.await;
377377
if let Some(stdout) = stdout {
@@ -382,30 +382,58 @@ async fn get_composer_home() -> Option<PathBuf> {
382382
}
383383
}
384384

385-
// Platform defaults. A set-but-empty HOME counts as unset: honoring
386-
// `""` would turn the `.composer`/`.config/composer` probes below into
387-
// CWD-relative paths inside the user's project (same rule as
388-
// `utils::fs::home_dir`).
389-
let home_dir = std::env::var("HOME")
390-
.ok()
391-
.filter(|h| !h.is_empty())
392-
.or_else(|| std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()))?;
393-
let home = PathBuf::from(home_dir);
394-
395-
let candidates = [
396-
home.join(".composer"),
397-
home.join(".config").join("composer"),
398-
];
399-
400-
for candidate in &candidates {
401-
if is_dir(candidate).await {
402-
return Some(candidate.clone());
385+
// Platform defaults (see `composer_home_candidates`).
386+
let var = |name: &str| std::env::var_os(name);
387+
for candidate in composer_home_candidates(&var, cfg!(windows)) {
388+
if is_dir(&candidate).await {
389+
return Some(candidate);
403390
}
404391
}
405392

406393
None
407394
}
408395

396+
/// The directories Composer itself uses as its home when `COMPOSER_HOME`
397+
/// is unset, in the order to probe them (`Factory::getHomeDir`):
398+
///
399+
/// - Windows: `%APPDATA%\Composer` — the only default there; the
400+
/// `~/.composer` probe is kept after it for older layouts.
401+
/// - elsewhere: `~/.composer` when it exists, else the XDG location,
402+
/// `$XDG_CONFIG_HOME/composer` or `~/.config/composer`.
403+
///
404+
/// A set-but-empty or relative variable counts as unset: honoring `""`
405+
/// would turn these probes into CWD-relative paths inside the user's
406+
/// project (same rule as `utils::fs::home_dir`).
407+
pub(crate) fn composer_home_candidates(
408+
var: &impl Fn(&str) -> Option<std::ffi::OsString>,
409+
windows: bool,
410+
) -> Vec<PathBuf> {
411+
let absolute = |name: &str| {
412+
var(name)
413+
.map(PathBuf::from)
414+
.filter(|path| path.is_absolute())
415+
};
416+
let home = absolute("HOME").or_else(|| absolute("USERPROFILE"));
417+
let mut candidates = Vec::new();
418+
if windows {
419+
if let Some(app_data) = absolute("APPDATA") {
420+
candidates.push(app_data.join("Composer"));
421+
}
422+
}
423+
if let Some(home) = &home {
424+
candidates.push(home.join(".composer"));
425+
}
426+
if !windows {
427+
if let Some(xdg) = absolute("XDG_CONFIG_HOME") {
428+
candidates.push(xdg.join("composer"));
429+
}
430+
}
431+
if let Some(home) = &home {
432+
candidates.push(home.join(".config").join("composer"));
433+
}
434+
candidates
435+
}
436+
409437
/// Normalize a Composer version string for PURL identity.
410438
///
411439
/// Composer's `installed.json` records the *pretty* version, which for

‎crates/socket-patch-core/src/crawlers/npm_crawler.rs‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -834,11 +834,17 @@ struct StoreEntryDir {
834834
// Global prefix detection helpers
835835
// ---------------------------------------------------------------------------
836836

837-
use crate::utils::process::{CommandRunner, SystemCommandRunner};
837+
use crate::utils::process::{CommandRunner, GlobalProbeRunner};
838838

839839
/// Get the npm global `node_modules` path via `npm root -g`.
840+
///
841+
/// This and the yarn / pnpm / bun probes below run through
842+
/// [`GlobalProbeRunner`]: the tool is resolved through `PATHEXT` (the
843+
/// Windows `npm.cmd` shim) and asked from a neutral directory, never from
844+
/// the scanned project, whose own scripts and config must not answer a
845+
/// question about the machine-wide install.
840846
pub fn get_npm_global_prefix() -> Result<String, String> {
841-
get_npm_global_prefix_with(&SystemCommandRunner)
847+
get_npm_global_prefix_with(&GlobalProbeRunner)
842848
}
843849

844850
/// Version of `get_npm_global_prefix` that accepts an injected
@@ -868,7 +874,7 @@ pub fn parse_npm_root_output(stdout: &str) -> Option<String> {
868874

869875
/// Get the yarn global `node_modules` path via `yarn global dir`.
870876
pub fn get_yarn_global_prefix() -> Option<String> {
871-
get_yarn_global_prefix_with(&SystemCommandRunner)
877+
get_yarn_global_prefix_with(&GlobalProbeRunner)
872878
}
873879

874880
/// Version of `get_yarn_global_prefix` that accepts an injected
@@ -900,7 +906,7 @@ pub fn parse_yarn_dir_output(stdout: &str) -> Option<String> {
900906

901907
/// Get the pnpm global `node_modules` path via `pnpm root -g`.
902908
pub fn get_pnpm_global_prefix() -> Option<String> {
903-
get_pnpm_global_prefix_with(&SystemCommandRunner)
909+
get_pnpm_global_prefix_with(&GlobalProbeRunner)
904910
}
905911

906912
/// Version of `get_pnpm_global_prefix` that accepts an injected
@@ -921,7 +927,7 @@ pub fn parse_pnpm_root_output(stdout: &str) -> Option<String> {
921927

922928
/// Get the bun global `node_modules` path via `bun pm bin -g`.
923929
pub fn get_bun_global_prefix() -> Option<String> {
924-
get_bun_global_prefix_with(&SystemCommandRunner)
930+
get_bun_global_prefix_with(&GlobalProbeRunner)
925931
}
926932

927933
/// Version of `get_bun_global_prefix` that accepts an injected

‎crates/socket-patch-core/src/utils/process.rs‎

Lines changed: 150 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,24 @@ pub(crate) fn resolve_tool_with(
7676
None
7777
}
7878

79+
/// `name.exe` as a directory entry of any kind (an App Execution Alias is a
80+
/// reparse point `is_file` can't follow) on an ABSOLUTE `PATH` entry, or
81+
/// `None`. The Windows fallback when [`resolve_tool`] finds nothing; same
82+
/// relative-entry rule, so a project-local executable is never chosen.
83+
#[cfg_attr(not(windows), allow(dead_code))]
84+
pub(crate) fn resolve_app_alias_with(
85+
name: &str,
86+
var: &impl Fn(&str) -> Option<OsString>,
87+
) -> Option<PathBuf> {
88+
let path = var("PATH")?;
89+
std::env::split_paths(&path)
90+
.filter(|dir| dir.is_absolute())
91+
.map(|dir| dir.join(format!("{name}.exe")))
92+
.find(|candidate| {
93+
std::fs::symlink_metadata(candidate).is_ok_and(|meta| !meta.is_dir())
94+
})
95+
}
96+
7997
/// A plain file that cannot be executed (a stray `bun` data file on PATH)
8098
/// is skipped in favour of the next entry, like execvp does; Windows has no
8199
/// mode bits, PATHEXT is the executability rule there.
@@ -127,6 +145,17 @@ pub trait CommandRunner {
127145

128146
/// Default runner: spawns the real binary via `std::process::Command`.
129147
///
148+
/// The program is looked up with [`resolve_tool`] and the RESOLVED path is
149+
/// spawned, never the bare name: on Windows `std` appends only `.exe`, so a
150+
/// bare `Command::new("npm")` never finds the `npm.cmd` / `yarn.cmd` /
151+
/// `gem.cmd` / `composer.bat` shim those tools install as, and every probe
152+
/// silently answered "not installed". The lookup also skips relative `PATH`
153+
/// entries, so a tool planted in the scanned project is never run.
154+
///
155+
/// The child inherits the caller's working directory: some probes must ask
156+
/// from the project (`gem env` follows rbenv's `.ruby-version` there). A
157+
/// probe about the machine-wide install uses [`GlobalProbeRunner`].
158+
///
130159
/// `output()` nulls stdin so the child can't block waiting for
131160
/// input. stdout is captured; stderr is captured and dropped (we
132161
/// don't surface CLI diagnostics — the helpers fall back to other
@@ -135,16 +164,84 @@ pub(crate) struct SystemCommandRunner;
135164

136165
impl CommandRunner for SystemCommandRunner {
137166
fn run(&self, bin: &str, args: &[&str]) -> Option<String> {
138-
let output = Command::new(bin).args(args).output().ok()?;
139-
if !output.status.success() {
140-
return None;
141-
}
142-
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
143-
if stdout.is_empty() {
144-
None
145-
} else {
146-
Some(stdout)
167+
run_resolved(bin, args, None)
168+
}
169+
}
170+
171+
/// [`SystemCommandRunner`] for a question about a package manager's GLOBAL
172+
/// install (`npm root -g`, `yarn global dir`, ...): the child runs from
173+
/// [`neutral_probe_dir`], never from the scanned project. From inside a
174+
/// project the tool reads that project's configuration — Yarn Berry has no
175+
/// `global` command and runs the project's `"global"` package.json script
176+
/// instead, whose stdout then picked the directory scanned (and patched) as
177+
/// the global install; a `.yarnrc.yml` `yarnPath` runs a project-supplied
178+
/// JS file for any `yarn` call.
179+
pub(crate) struct GlobalProbeRunner;
180+
181+
impl CommandRunner for GlobalProbeRunner {
182+
fn run(&self, bin: &str, args: &[&str]) -> Option<String> {
183+
run_resolved(bin, args, Some(&neutral_probe_dir()?))
184+
}
185+
}
186+
187+
/// Where global probes run: the user's home directory (theirs, not a
188+
/// checkout's, and never world-writable like the temp dir), else the root
189+
/// of the current drive. `None` only when neither can be determined, and
190+
/// then the probe is not run at all rather than run from the project.
191+
pub(crate) fn neutral_probe_dir() -> Option<PathBuf> {
192+
neutral_probe_dir_with(&|var| std::env::var_os(var))
193+
}
194+
195+
/// [`neutral_probe_dir`] over an injected environment reader (tests).
196+
pub(crate) fn neutral_probe_dir_with(var: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
197+
let home = ["HOME", "USERPROFILE"]
198+
.into_iter()
199+
.filter_map(var)
200+
.map(PathBuf::from)
201+
.find(|path| path.is_absolute() && path.is_dir());
202+
home.or_else(|| {
203+
std::env::current_dir()
204+
.ok()?
205+
.ancestors()
206+
.last()
207+
.map(Path::to_path_buf)
208+
})
209+
}
210+
211+
/// Spawn `bin` (looked up with [`resolve_tool`]; a value that already
212+
/// names a path is spawned as given) with `args`, optionally from `cwd`,
213+
/// and return its trimmed stdout under the [`CommandRunner`] contract.
214+
fn run_resolved(bin: &str, args: &[&str], cwd: Option<&Path>) -> Option<String> {
215+
let program = if Path::new(bin).components().count() > 1 {
216+
PathBuf::from(bin)
217+
} else {
218+
match resolve_tool(bin) {
219+
Some(path) => path,
220+
// A Windows App Execution Alias (the Store `python3.exe` in
221+
// WindowsApps) is a reparse point the file probe can't stat;
222+
// look for it on absolute PATH entries only. Never hand the bare
223+
// name back to `std`: its Windows search also walks relative
224+
// PATH entries such as `.` (against the PARENT's cwd, before the
225+
// child's `current_dir` applies), so a `yarn.exe` planted in the
226+
// scanned project would run.
227+
None if cfg!(windows) => resolve_app_alias_with(bin, &|var| std::env::var_os(var))?,
228+
None => return None,
147229
}
230+
};
231+
let mut command = command_for(&program);
232+
command.args(args);
233+
if let Some(cwd) = cwd {
234+
command.current_dir(cwd);
235+
}
236+
let output = command.output().ok()?;
237+
if !output.status.success() {
238+
return None;
239+
}
240+
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
241+
if stdout.is_empty() {
242+
None
243+
} else {
244+
Some(stdout)
148245
}
149246
}
150247

@@ -246,6 +343,28 @@ mod tests {
246343
assert_eq!(out.as_deref(), Some("forwarded"));
247344
}
248345

346+
/// Global probes run from the home dir; a relative or missing HOME is
347+
/// never used (it would resolve against the project), and with no
348+
/// usable home the probe falls back to the drive root, not the cwd.
349+
#[test]
350+
fn neutral_probe_dir_prefers_an_absolute_home_and_never_the_cwd() {
351+
let tmp = tempfile::tempdir().unwrap();
352+
let home = tmp.path().to_path_buf();
353+
let env = |name: &str| (name == "HOME").then(|| home.clone().into_os_string());
354+
assert_eq!(neutral_probe_dir_with(&env), Some(home.clone()));
355+
356+
let profile = |name: &str| match name {
357+
"HOME" => Some(OsString::from("relative/home")),
358+
"USERPROFILE" => Some(home.clone().into_os_string()),
359+
_ => None,
360+
};
361+
assert_eq!(neutral_probe_dir_with(&profile), Some(home.clone()));
362+
363+
let none = |_: &str| None::<OsString>;
364+
let root = neutral_probe_dir_with(&none).expect("the drive root");
365+
assert!(root.is_absolute() && root.parent().is_none(), "{root:?}");
366+
}
367+
249368
// ───────────────────────── resolve_tool / command_for ─────────────────────────
250369

251370
/// Mark an existing file executable (no-op off Unix: PATHEXT rules there).
@@ -299,6 +418,28 @@ mod tests {
299418

300419
/// The name is honoured exactly: a `bunx` beside no `bun` is not `bun`,
301420
/// and a directory named `bun` is not a program.
421+
/// The Windows App Execution Alias fallback takes the same absolute-only
422+
/// rule as `resolve_tool`: a `yarn.exe` reached only through a relative
423+
/// entry (`.`, the empty component, a bare dir name) is never chosen;
424+
/// one on an absolute entry is.
425+
#[test]
426+
fn resolve_app_alias_skips_relative_entries() {
427+
let tmp = tempfile::tempdir().unwrap();
428+
let safe = tmp.path().join("bin");
429+
std::fs::create_dir_all(&safe).unwrap();
430+
let relative = [PathBuf::from("."), PathBuf::from(""), PathBuf::from("planted")];
431+
432+
let only_relative = std::env::join_paths(&relative).unwrap();
433+
let var = |name: &str| (name == "PATH").then(|| only_relative.clone());
434+
assert_eq!(resolve_app_alias_with("yarn", &var), None);
435+
436+
std::fs::write(safe.join("yarn.exe"), b"").unwrap();
437+
let with_safe =
438+
std::env::join_paths(relative.iter().cloned().chain([safe.clone()])).unwrap();
439+
let var = |name: &str| (name == "PATH").then(|| with_safe.clone());
440+
assert_eq!(resolve_app_alias_with("yarn", &var), Some(safe.join("yarn.exe")));
441+
}
442+
302443
#[test]
303444
fn resolve_tool_matches_the_exact_leaf_only() {
304445
let tmp = tempfile::tempdir().unwrap();

‎crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ async fn get_vendor_paths_global_nonexistent_composer_home_falls_back() {
7272
let _composer_home = EnvVarGuard::set("COMPOSER_HOME", bogus_home.as_os_str());
7373
let _home = EnvVarGuard::set("HOME", tmp.path().as_os_str());
7474
let _path = EnvVarGuard::set("PATH", empty_path.path().as_os_str());
75+
// Composer's other platform defaults (`%APPDATA%\Composer` on Windows,
76+
// `$XDG_CONFIG_HOME/composer`) would outrank the HOME candidate here.
77+
let _app_data = EnvVarGuard::remove("APPDATA");
78+
let _xdg = EnvVarGuard::remove("XDG_CONFIG_HOME");
7579

7680
let crawler = ComposerCrawler;
7781
let paths = crawler

0 commit comments

Comments
 (0)