Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
- Gem hosted and vendored modes wire only the manifest Bundler loads. A `gems.rb`
twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer
leads to an edit of an ignored `Gemfile` that reports success and attests an
Expand Down
68 changes: 48 additions & 20 deletions crates/socket-patch-core/src/crawlers/composer_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -371,7 +371,7 @@ async fn get_composer_home() -> Option<PathBuf> {
// 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 {
Expand All @@ -382,30 +382,58 @@ async fn get_composer_home() -> Option<PathBuf> {
}
}

// 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<std::ffi::OsString>,
windows: bool,
) -> Vec<PathBuf> {
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
Expand Down
16 changes: 11 additions & 5 deletions crates/socket-patch-core/src/crawlers/npm_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,11 +472,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<String, String> {
get_npm_global_prefix_with(&SystemCommandRunner)
get_npm_global_prefix_with(&GlobalProbeRunner)
}

/// Version of `get_npm_global_prefix` that accepts an injected
Expand Down Expand Up @@ -506,7 +512,7 @@ pub fn parse_npm_root_output(stdout: &str) -> Option<String> {

/// Get the yarn global `node_modules` path via `yarn global dir`.
pub fn get_yarn_global_prefix() -> Option<String> {
get_yarn_global_prefix_with(&SystemCommandRunner)
get_yarn_global_prefix_with(&GlobalProbeRunner)
}

/// Version of `get_yarn_global_prefix` that accepts an injected
Expand Down Expand Up @@ -538,7 +544,7 @@ pub fn parse_yarn_dir_output(stdout: &str) -> Option<String> {

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

/// Version of `get_pnpm_global_prefix` that accepts an injected
Expand All @@ -559,7 +565,7 @@ pub fn parse_pnpm_root_output(stdout: &str) -> Option<String> {

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

/// Version of `get_bun_global_prefix` that accepts an injected
Expand Down
117 changes: 108 additions & 9 deletions crates/socket-patch-core/src/utils/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,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
Expand All @@ -135,16 +146,82 @@ pub(crate) struct SystemCommandRunner;

impl CommandRunner for SystemCommandRunner {
fn run(&self, bin: &str, args: &[&str]) -> Option<String> {
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<String> {
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<PathBuf> {
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<OsString>) -> Option<PathBuf> {
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<String> {
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,
// but `std`'s own `.exe` search launches it; keep that path
// rather than lose a tool the bare spawn always found. `std`
// never searches the cwd on Windows.
None if cfg!(windows) => PathBuf::from(bin),
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)
}
}

Expand Down Expand Up @@ -246,6 +323,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::<OsString>;
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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading