Skip to content

Commit 96097f3

Browse files
committed
Keep relative PATH entries out of the Windows alias fallback
When the safe lookup found no tool, the Windows fallback for App Execution Aliases handed the bare name back to std. Its Windows search also walks relative PATH entries such as '.', resolved against the parent's cwd before the child's current_dir applies, so with '.' on PATH an npm.exe or yarn.exe planted in the scanned project ran during the global probe despite the neutral child cwd. Look for the alias as <name>.exe on absolute PATH entries only, using symlink_metadata since the reparse point can't be followed, and never spawn the bare name. A Windows e2e test plants a copy of cmd.exe as npm.exe in the project with only '.' on PATH and checks the global npm probe finds nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKwBoKTsqpGR3ZcCAcEyCA
1 parent 0958e11 commit 96097f3

2 files changed

Lines changed: 77 additions & 5 deletions

File tree

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

Lines changed: 47 additions & 5 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.
@@ -200,11 +218,13 @@ fn run_resolved(bin: &str, args: &[&str], cwd: Option<&Path>) -> Option<String>
200218
match resolve_tool(bin) {
201219
Some(path) => path,
202220
// A Windows App Execution Alias (the Store `python3.exe` in
203-
// WindowsApps) is a reparse point the file probe can't stat,
204-
// but `std`'s own `.exe` search launches it; keep that path
205-
// rather than lose a tool the bare spawn always found. `std`
206-
// never searches the cwd on Windows.
207-
None if cfg!(windows) => PathBuf::from(bin),
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))?,
208228
None => return None,
209229
}
210230
};
@@ -398,6 +418,28 @@ mod tests {
398418

399419
/// The name is honoured exactly: a `bunx` beside no `bun` is not `bun`,
400420
/// 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+
401443
#[test]
402444
fn resolve_tool_matches_the_exact_leaf_only() {
403445
let tmp = tempfile::tempdir().unwrap();

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,36 @@ fn global_probe_ignores_a_tool_planted_on_a_relative_path_entry() {
233233
);
234234
}
235235

236+
/// #440 on Windows: with no safe `npm` installed and `.` on PATH, a real
237+
/// executable planted in the project as `npm.exe` must not run. The
238+
/// fallback for App Execution Aliases used to hand the bare name to `std`,
239+
/// whose Windows search walks relative PATH entries against the parent's
240+
/// cwd (the project), before the child's neutral `current_dir` applies.
241+
/// The plant is a copy of `cmd.exe`, which prints its banner and exits 0 on
242+
/// a null stdin, so running it would yield a "prefix".
243+
#[cfg(windows)]
244+
#[test]
245+
#[serial]
246+
fn global_probe_never_runs_an_executable_planted_in_the_project() {
247+
let l = layout();
248+
let system_root = std::env::var_os("SystemRoot").expect("SystemRoot is set on Windows");
249+
std::fs::copy(
250+
PathBuf::from(system_root).join("System32").join("cmd.exe"),
251+
l.proj.join("npm.exe"),
252+
)
253+
.unwrap();
254+
let mut env = Env::new();
255+
point_env_at(&mut env, &l);
256+
env.set("PATH", Some(std::ffi::OsStr::new(".")));
257+
env.chdir(&l.proj);
258+
259+
let prefix = get_npm_global_prefix();
260+
assert!(
261+
prefix.is_err(),
262+
"the project's npm.exe must never be spawned; got {prefix:?}"
263+
);
264+
}
265+
236266
// ───────────────────────────────── RubyGems (#421) ─────────────────────────────────
237267

238268
/// #421: `gem env gemdir` / `gem env gempath` are answered through the

0 commit comments

Comments
 (0)