Skip to content

Commit 083fc76

Browse files
committed
Preserve caller cwd in macOS shebang launcher (#293)
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/<cmd> -> /opt/<app>/bin/<cmd>) 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.
1 parent eff2980 commit 083fc76

2 files changed

Lines changed: 69 additions & 11 deletions

File tree

‎relenv/common.py‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -156,18 +156,26 @@ def toolchain_root_dir() -> pathlib.Path:
156156
"""\
157157
#!/bin/sh
158158
"true" ''''
159-
TARGET_FILE=$0
160-
cd "$(dirname "$TARGET_FILE")" || return
161-
TARGET_FILE=$(basename "$TARGET_FILE")
162-
# Iterate down a (possible) chain of symlinks
163-
while [ -L "$TARGET_FILE" ]
164-
do
165-
TARGET_FILE=$(readlink "$TARGET_FILE")
166-
cd "$(dirname "$TARGET_FILE")" || return
159+
# Resolve $0 to its physical path inside a subshell so the cd's required to
160+
# walk symlinks on macOS (which lacks `readlink -f` in /usr/bin pre-Big Sur)
161+
# don't leak out and clobber the caller's working directory. Without the
162+
# subshell, every entry point produced by relenv on macOS started Python
163+
# with cwd set to the install dir, breaking anything that resolved relative
164+
# paths against os.getcwd() (Saltfile auto-discovery, --config-dir=./...,
165+
# config_dir: ./..., etc). See
166+
# https://github.com/saltstack/relenv/issues/293.
167+
REALPATH=$(
168+
TARGET_FILE=$0
169+
cd "$(dirname "$TARGET_FILE")" || exit
167170
TARGET_FILE=$(basename "$TARGET_FILE")
168-
done
169-
PHYS_DIR=$(pwd -P)
170-
REALPATH=$PHYS_DIR/$TARGET_FILE
171+
while [ -L "$TARGET_FILE" ]
172+
do
173+
TARGET_FILE=$(readlink "$TARGET_FILE")
174+
cd "$(dirname "$TARGET_FILE")" || exit
175+
TARGET_FILE=$(basename "$TARGET_FILE")
176+
done
177+
echo "$(pwd -P)/$TARGET_FILE"
178+
)
171179
# shellcheck disable=SC2093
172180
"exec" "$(dirname "$REALPATH")"{} "$REALPATH" "$@"
173181
' '''

‎tests/test_common.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,56 @@ def test_format_shebang_newline() -> None:
316316
assert format_shebang("python3", SHEBANG_TPL_LINUX).endswith("\n")
317317

318318

319+
@mark_skipif(sys.platform == "win32", reason="POSIX shell launcher; no /bin/sh on Windows")
320+
def test_macos_shebang_preserves_caller_cwd(tmp_path: pathlib.Path) -> None:
321+
"""
322+
Regression for https://github.com/saltstack/relenv/issues/293.
323+
324+
The pre-fix SHEBANG_TPL_MACOS resolved $0 through a cd / readlink loop
325+
that ran in the script's own shell, so by the time it `exec`'d Python
326+
the caller's working directory had been replaced with the install
327+
directory. The new template confines those cd's to a subshell.
328+
329+
Exercised here on plain /bin/sh — Python's launcher polyglot is
330+
POSIX-portable, so a Linux runner reproduces the bug the same way a
331+
macOS one does.
332+
"""
333+
install_dir = tmp_path / "install"
334+
shim_dir = tmp_path / "shim"
335+
caller_dir = tmp_path / "caller"
336+
install_dir.mkdir()
337+
shim_dir.mkdir()
338+
caller_dir.mkdir()
339+
340+
# Real Python next to the launcher, reachable through the shebang's
341+
# "$(dirname "$REALPATH")<interp>" suffix — pass /python so it resolves
342+
# to install_dir/python.
343+
(install_dir / "python").symlink_to(sys.executable)
344+
345+
body = "import os\nprint(os.getcwd())\n"
346+
launcher = install_dir / "cli"
347+
launcher.write_text(format_shebang("/python", SHEBANG_TPL_MACOS) + body)
348+
launcher.chmod(0o755)
349+
350+
# The Salt-on-macOS deployment shape: /usr/local/bin/<cmd> is a symlink
351+
# to /opt/<app>/bin/<cmd>. Reproduce that here.
352+
shim = shim_dir / "cli"
353+
shim.symlink_to(launcher)
354+
355+
result = subprocess.run(
356+
[str(shim)],
357+
cwd=str(caller_dir),
358+
capture_output=True,
359+
text=True,
360+
check=False,
361+
)
362+
assert result.returncode == 0, f"stderr={result.stderr!r}"
363+
reported_cwd = pathlib.Path(result.stdout.strip()).resolve()
364+
assert reported_cwd == caller_dir.resolve(), (
365+
f"launcher leaked cwd: got {reported_cwd}, expected {caller_dir.resolve()}"
366+
)
367+
368+
319369
def test_relative_interpreter_default_location() -> None:
320370
assert relative_interpreter("/tmp/relenv", "/tmp/relenv/bin", "/tmp/relenv/bin/python3") == pathlib.Path(
321371
"..", "bin", "python3"

0 commit comments

Comments
 (0)