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
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`.
- 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
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 @@ -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<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 @@ -674,7 +680,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 @@ -706,7 +712,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 @@ -727,7 +733,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
159 changes: 150 additions & 9 deletions crates/socket-patch-core/src/utils/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OsString>,
) -> Option<PathBuf> {
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.
Expand Down Expand Up @@ -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
Expand All @@ -135,16 +164,84 @@ 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;
// 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)
}
}

Expand Down Expand Up @@ -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::<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 Expand Up @@ -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();
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