From 076da91f8a2541320a9c9f91d832803ca2eb4665 Mon Sep 17 00:00:00 2001 From: "Steve J. South(NamJungGu)" Date: Sun, 19 Jul 2026 22:14:12 +0900 Subject: [PATCH] fix(hooks): strip interpreter flags from launcher shebang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pipx writes launcher shebangs as `#!/path/to/venv/bin/python -E`. The shebang probe in _PYTHON_DETECT kept the trailing flag, so the path allowlist that runs right after rejected the whole value (a space is not a valid path character) and blanked GRAPHIFY_PYTHON. Detection then fell through to the last-resort `python3` probe, which on many machines is an unrelated system interpreter carrying an older graphify — every commit rebuilt the graph with a stale version and tripped the node-shrink guard. Drop everything after the first token before the allowlist check, so both `#!/path/to/python -E` (pipx) and `#!/usr/bin/env python3 -E` resolve to a usable interpreter. Flagless shebangs are unaffected. Adds a regression test that runs the generated detection snippet against a fake pipx-style launcher and asserts the venv interpreter is picked. --- graphify/hooks.py | 6 ++++++ tests/test_hooks.py | 51 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/graphify/hooks.py b/graphify/hooks.py index 77ef39cd3..84c6a4337 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -76,6 +76,12 @@ */env\\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;; *) GRAPHIFY_PYTHON="$_SHEBANG" ;; esac + # Drop interpreter flags: pipx writes shebangs like + # `#!/path/to/venv/bin/python -E`, and uv/venv launchers may add -s/-I. + # The flag makes the value fail the path allowlist below, which silently + # blanks a perfectly good interpreter and lets the last-resort probe pick + # an unrelated system python that may hold a stale graphify. + GRAPHIFY_PYTHON="${GRAPHIFY_PYTHON%% *}" # Allowlist: only keep characters valid in a filesystem path to prevent # injection if the shebang contains shell metacharacters. case "$GRAPHIFY_PYTHON" in diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 8e95aabbe..6db76eaca 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -2,6 +2,7 @@ import os import shutil import subprocess +import sys from types import SimpleNamespace from pathlib import Path import pytest @@ -589,3 +590,53 @@ def test_uninstall_removes_merge_driver_keeps_other_attrs(tmp_path): content = (repo / ".gitattributes").read_text(encoding="utf-8") assert "*.png binary" in content assert "merge=graphify" not in content + + +def _run_python_detect(tmp_path: Path, launcher_shebang: str) -> str: + """Run the _PYTHON_DETECT snippet against a fake launcher and report its pick. + + Isolates probe 3 (shebang parsing): the pinned-python placeholder is blanked + and the working directory holds no graphify-out/.graphify_python, so the only + interpreter the snippet can find is the one named by the fake launcher. + """ + from graphify.hooks import _PYTHON_DETECT + + bindir = tmp_path / "bin" + bindir.mkdir() + launcher = bindir / "graphify" + launcher.write_text(launcher_shebang + '\nexit 0\n', encoding="utf-8", newline="\n") + launcher.chmod(0o755) + + script = _PYTHON_DETECT.replace("__PINNED_PYTHON__", "") + script += 'printf "%s" "$GRAPHIFY_PYTHON"\n' + script_path = tmp_path / "detect.sh" + script_path.write_text(script, encoding="utf-8", newline="\n") + + # The snippet shells out to head/tr/sed, so the standard bin dirs stay on + # PATH; only the graphify launcher is replaced by the fake one. + env = dict(os.environ, PATH=os.pathsep.join([str(bindir), "/usr/bin", "/bin"])) + res = subprocess.run( + ["/bin/sh", str(script_path)], + cwd=str(tmp_path), env=env, capture_output=True, text=True, + ) + return res.stdout.strip() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX shebang probe") +def test_shebang_probe_strips_interpreter_flags(tmp_path): + """pipx writes `#!/path/to/venv/bin/python -E`. + + The flag must be dropped before the path allowlist runs. Keeping it makes the + allowlist reject the whole value (space is not a path character), blanking a + working interpreter and letting the last-resort probe fall through to an + unrelated system python that may carry a stale graphify. + """ + picked = _run_python_detect(tmp_path, f"#!{sys.executable} -E") + assert picked == sys.executable + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX shebang probe") +def test_shebang_probe_accepts_plain_interpreter(tmp_path): + """A flagless shebang must keep working exactly as before.""" + picked = _run_python_detect(tmp_path, f"#!{sys.executable}") + assert picked == sys.executable