From 05bff6e6f966fd81362c566418c97c80dded629a Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Thu, 25 Jun 2026 18:29:34 -0700 Subject: [PATCH] Preserve caller cwd in macOS shebang launcher (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS polyglot launcher template (SHEBANG_TPL_MACOS) emulated `readlink -f` with a cd/readlink loop because macOS's /usr/bin/readlink predates the -f flag. All those cd's happened in the launcher's own shell, so by the time it `exec`'d Python the caller's working directory had been replaced with the install dir. Every entry point relenv produces for macOS (salt, salt-ssh, salt-call, salt-run, ...) inherited this cwd, which broke anything resolving relative paths against os.getcwd(): Saltfile auto-discovery from the current directory, config_dir: ./..., --config-dir=./..., root_dir: ./, ssh_pre_flight: ./..., and so on. Confine the symlink-resolution loop to a subshell — its cd's no longer leak into the parent — and emit the resolved physical path on its stdout so the parent can capture it. Python is exec'd from the launcher's untouched cwd. Verified against the original reproducer in the issue on a real macOS (Sonoma) box: before, the launcher's Python sees the install dir as cwd; after, it sees the directory the user invoked the command from. Linux-side template (SHEBANG_TPL_LINUX) is unaffected — it uses `readlink -f` directly and never cd's. Adds tests/test_common.py::test_macos_shebang_preserves_caller_cwd, a sh-driven integration test that reproduces the symlinked Salt-on-macOS deployment shape (/usr/local/bin/ -> /opt//bin/) and asserts the launcher's Python sees the caller's cwd. The test runs on any POSIX runner — the launcher polyglot is portable, so a Linux runner catches the regression too. Confirmed the test fails on the pre-fix template and passes on the new one. --- relenv/common.py | 30 ++++++++++++++++---------- tests/test_common.py | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/relenv/common.py b/relenv/common.py index 30997558..ab217735 100644 --- a/relenv/common.py +++ b/relenv/common.py @@ -156,18 +156,26 @@ def toolchain_root_dir() -> pathlib.Path: """\ #!/bin/sh "true" '''' -TARGET_FILE=$0 -cd "$(dirname "$TARGET_FILE")" || return -TARGET_FILE=$(basename "$TARGET_FILE") -# Iterate down a (possible) chain of symlinks -while [ -L "$TARGET_FILE" ] -do - TARGET_FILE=$(readlink "$TARGET_FILE") - cd "$(dirname "$TARGET_FILE")" || return +# Resolve $0 to its physical path inside a subshell so the cd's required to +# walk symlinks on macOS (which lacks `readlink -f` in /usr/bin pre-Big Sur) +# don't leak out and clobber the caller's working directory. Without the +# subshell, every entry point produced by relenv on macOS started Python +# with cwd set to the install dir, breaking anything that resolved relative +# paths against os.getcwd() (Saltfile auto-discovery, --config-dir=./..., +# config_dir: ./..., etc). See +# https://github.com/saltstack/relenv/issues/293. +REALPATH=$( + TARGET_FILE=$0 + cd "$(dirname "$TARGET_FILE")" || exit TARGET_FILE=$(basename "$TARGET_FILE") -done -PHYS_DIR=$(pwd -P) -REALPATH=$PHYS_DIR/$TARGET_FILE + while [ -L "$TARGET_FILE" ] + do + TARGET_FILE=$(readlink "$TARGET_FILE") + cd "$(dirname "$TARGET_FILE")" || exit + TARGET_FILE=$(basename "$TARGET_FILE") + done + echo "$(pwd -P)/$TARGET_FILE" +) # shellcheck disable=SC2093 "exec" "$(dirname "$REALPATH")"{} "$REALPATH" "$@" ' ''' diff --git a/tests/test_common.py b/tests/test_common.py index 92362dac..dbfae985 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -316,6 +316,56 @@ def test_format_shebang_newline() -> None: assert format_shebang("python3", SHEBANG_TPL_LINUX).endswith("\n") +@mark_skipif(sys.platform == "win32", reason="POSIX shell launcher; no /bin/sh on Windows") +def test_macos_shebang_preserves_caller_cwd(tmp_path: pathlib.Path) -> None: + """ + Regression for https://github.com/saltstack/relenv/issues/293. + + The pre-fix SHEBANG_TPL_MACOS resolved $0 through a cd / readlink loop + that ran in the script's own shell, so by the time it `exec`'d Python + the caller's working directory had been replaced with the install + directory. The new template confines those cd's to a subshell. + + Exercised here on plain /bin/sh — Python's launcher polyglot is + POSIX-portable, so a Linux runner reproduces the bug the same way a + macOS one does. + """ + install_dir = tmp_path / "install" + shim_dir = tmp_path / "shim" + caller_dir = tmp_path / "caller" + install_dir.mkdir() + shim_dir.mkdir() + caller_dir.mkdir() + + # Real Python next to the launcher, reachable through the shebang's + # "$(dirname "$REALPATH")" suffix — pass /python so it resolves + # to install_dir/python. + (install_dir / "python").symlink_to(sys.executable) + + body = "import os\nprint(os.getcwd())\n" + launcher = install_dir / "cli" + launcher.write_text(format_shebang("/python", SHEBANG_TPL_MACOS) + body) + launcher.chmod(0o755) + + # The Salt-on-macOS deployment shape: /usr/local/bin/ is a symlink + # to /opt//bin/. Reproduce that here. + shim = shim_dir / "cli" + shim.symlink_to(launcher) + + result = subprocess.run( + [str(shim)], + cwd=str(caller_dir), + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f"stderr={result.stderr!r}" + reported_cwd = pathlib.Path(result.stdout.strip()).resolve() + assert reported_cwd == caller_dir.resolve(), ( + f"launcher leaked cwd: got {reported_cwd}, expected {caller_dir.resolve()}" + ) + + def test_relative_interpreter_default_location() -> None: assert relative_interpreter("/tmp/relenv", "/tmp/relenv/bin", "/tmp/relenv/bin/python3") == pathlib.Path( "..", "bin", "python3"