From e71c8a4af3547b71e09181caeb91b302f1fab882 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:08:38 -0400 Subject: [PATCH] fix(exec): resolve wrapper recursion when shims are in PATH When both a wrapper directory (e.g. .devcontainer/bin) and the shims directory are in PATH, with the wrapper appearing first, `mise x -- tool` would resolve the tool back to the wrapper script instead of the real binary. This caused infinite recursion, growing the environment on each iteration until exceeding ARG_MAX (~2MB), resulting in E2BIG errors. The root cause is PathEnv ordering: paths before shims go into `pre`, tool bins go into `mise`, and `to_vec()` returns `pre + mise + post`. So wrapper dirs in `pre` are searched before tool bins in `mise`. The existing shim-stripping in `exec_program` only removed the shims directory, leaving other wrapper directories untouched. Fix: pass tool bin paths into `exec_program` and prepend them to the lookup PATH (used only for binary resolution, not inherited by the child process). This ensures the real tool binary is always found before any wrapper scripts regardless of PATH ordering. Co-Authored-By: Claude Opus 4.6 --- .../test_exec_wrapper_recursion_with_shims | 45 ++++++++++ src/cli/exec.rs | 82 +++++++++++++------ 2 files changed, 104 insertions(+), 23 deletions(-) create mode 100755 e2e/cli/test_exec_wrapper_recursion_with_shims diff --git a/e2e/cli/test_exec_wrapper_recursion_with_shims b/e2e/cli/test_exec_wrapper_recursion_with_shims new file mode 100755 index 0000000000..f6f0d366f9 --- /dev/null +++ b/e2e/cli/test_exec_wrapper_recursion_with_shims @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +# Regression test: a wrapper script that calls `mise x -- tool` should not +# cause infinite recursion when BOTH the wrapper directory AND the shims +# directory are in PATH, with the wrapper directory appearing first. +# +# This is the scenario that occurs in devcontainers where .devcontainer/bin +# contains wrapper scripts and mise shims are also in PATH. The PathEnv +# ordering puts "pre-shims" paths (including the wrapper dir) before +# mise-managed tool paths, so `which` finds the wrapper instead of the +# real binary, causing infinite recursion until E2BIG. + +# 1. Install a mise-managed tool +cat >mise.toml <<'EOF' +[tools] +dummy = "latest" +EOF +mise i + +# 2. Create a wrapper script that calls `mise x -- dummy` (simulating +# .devcontainer/bin/dummy or similar) +wrapperdir="$HOME/wrapper_bin" +mkdir -p "$wrapperdir" +cat >"$wrapperdir/dummy" <>(); let program = program.to_executable(); - // Strip shims directory from PATH for program resolution only, to prevent - // recursive shim execution. Wrapper scripts may call `mise x -- tool`, - // which re-enters Exec. If shims remain in PATH (due to - // not_found_auto_install), the wrapper is found again instead of the real - // tool, causing an infinite loop that grows PATH until E2BIG. - // The child process still inherits the full PATH (with shims) so - // subprocesses can find tools via shims. let program = if program.to_string_lossy().contains('/') { // Already a path, no need to resolve program } else { let cwd = crate::dirs::CWD.clone().unwrap_or_default(); let lookup_path = env.get(&*env::PATH_KEY).map(|path_val| { + // For program resolution, reorder PATH so that paths added by mise + // (tool bins, _.path entries) come before paths from the original + // system PATH. This prevents wrapper scripts in the system PATH + // (e.g. .devcontainer/bin/tool) from being found before the real + // tool binary, which would cause infinite recursion and E2BIG. + // + // User-configured paths (_.path/venv) maintain their position + // relative to tool paths since both are "mise-added". + // The child process still inherits the full unmodified PATH. let shims_dir = &*crate::dirs::SHIMS; - let filtered: Vec<_> = std::env::split_paths(&OsString::from(path_val)) - .filter(|p| p != shims_dir) + let pristine: std::collections::HashSet<_> = crate::env::PATH.iter().collect(); + let all_paths: Vec<_> = std::env::split_paths(&OsString::from(path_val)).collect(); + // Mise-added paths first (preserving relative order) + let mise_added: Vec<_> = all_paths + .iter() + .filter(|p| !pristine.contains(p)) + .cloned() .collect(); - std::env::join_paths(&filtered).unwrap() + // Then original system paths (minus shims) + let original: Vec<_> = all_paths + .iter() + .filter(|p| pristine.contains(p) && *p != shims_dir) + .cloned() + .collect(); + std::env::join_paths(mise_added.iter().chain(original.iter())).unwrap() }); match which::which_in(&program, lookup_path, cwd) { Ok(resolved) => resolved.into_os_string(), @@ -229,27 +242,50 @@ where } let cwd = crate::dirs::CWD.clone().unwrap_or_default(); let program = program.to_executable(); - // Strip shims directory from PATH for program resolution only, to prevent - // recursive shim execution. On Windows, "file" mode shim scripts call - // `mise x -- tool`, which re-enters Exec. If shims remain in PATH (due to - // not_found_auto_install), which::which_in resolves "tool" back to the shim, - // causing an infinite loop. The child process still inherits the full PATH - // (with shims) so subprocesses can find tools via shims. + // Reorder PATH for program resolution: mise-added paths first, then + // original system paths (minus shims). See Unix version for full rationale. let lookup_path = env.get(&*env::PATH_KEY).map(|path_val| { - // Compare with ~ expansion, normalized separators, and case-insensitive - // to handle Windows path variations (e.g. ~/.local/share/mise\shims vs - // C:\Users\user\.local\share\mise\shims) let shims_normalized = crate::dirs::SHIMS .to_string_lossy() .to_lowercase() .replace('/', "\\"); - let filtered: Vec<_> = std::env::split_paths(&OsString::from(path_val)) + let is_shims = |p: &std::path::PathBuf| { + let expanded = crate::file::replace_path(p); + expanded.to_string_lossy().to_lowercase().replace('/', "\\") == shims_normalized + }; + let pristine: std::collections::HashSet<_> = crate::env::PATH + .iter() + .map(|p| { + crate::file::replace_path(p) + .to_string_lossy() + .to_lowercase() + .replace('/', "\\") + }) + .collect(); + let all_paths: Vec<_> = std::env::split_paths(&OsString::from(path_val)).collect(); + let mise_added: Vec<_> = all_paths + .iter() + .filter(|p| { + let normalized = crate::file::replace_path(p) + .to_string_lossy() + .to_lowercase() + .replace('/', "\\"); + !pristine.contains(&normalized) + }) + .cloned() + .collect(); + let original: Vec<_> = all_paths + .iter() .filter(|p| { - let expanded = crate::file::replace_path(p); - expanded.to_string_lossy().to_lowercase().replace('/', "\\") != shims_normalized + let normalized = crate::file::replace_path(p) + .to_string_lossy() + .to_lowercase() + .replace('/', "\\"); + pristine.contains(&normalized) && !is_shims(p) }) + .cloned() .collect(); - std::env::join_paths(&filtered).unwrap() + std::env::join_paths(mise_added.iter().chain(original.iter())).unwrap() }); let program = which::which_in(program, lookup_path, cwd)?; let cmd = cmd::cmd(program, args);