From 30e4c0440d4ea9596333b1d6e563b7dc80129953 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:14:58 -0500 Subject: [PATCH 1/4] fix: reject unsafe self-upgrade installer paths Reject relative and working-directory installer lookup results before registry probes and self-upgrades, including symlinks to working-directory executables. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_version.py | 21 +- .../specify_cli/selfs/test_command_upgrade.py | 43 +++- tests/specify_cli/test_version_detection.py | 239 +++++++++++++++--- tests/specify_cli/test_version_execution.py | 21 +- .../specify_cli/test_version_verification.py | 23 +- 5 files changed, 273 insertions(+), 74 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 93b4c6a618..2dedbe7c93 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -477,6 +477,19 @@ def _editable_marker_seen() -> bool: return editable_root is not None and _git_ancestor(editable_root) is not None +def _find_installer(name: str) -> str | None: + """Resolve an installer without trusting working-directory executables.""" + resolved = shutil.which(name) + if resolved is None: + return None + installer = Path(resolved) + # Windows lookup may prepend cwd even when PATH contains only absolute + # directories. Never absolutize that result or fall back to a bare command. + if not installer.is_absolute() or installer.resolve().parent == Path.cwd().resolve(): + return None + return resolved + + def _detect_install_method( argv0: str | None = None, include_signals: bool = False, @@ -530,7 +543,7 @@ def _detect_install_method( consulted: list[str] = [] if _tier3_registry_lookup_allowed(argv0_path): uv_tool_match = False - uv_bin = shutil.which("uv") + uv_bin = _find_installer("uv") if uv_bin is not None: consulted.append("uv tool list") try: @@ -550,7 +563,7 @@ def _detect_install_method( pass pipx_match = False - pipx_bin = shutil.which("pipx") + pipx_bin = _find_installer("pipx") if pipx_bin is not None: consulted.append("pipx list --json") try: @@ -631,7 +644,7 @@ def _assemble_installer_argv( source_spec = _source_spec(target_tag) if method == _InstallMethod.UV_TOOL: - uv_bin = shutil.which("uv") + uv_bin = _find_installer("uv") if uv_bin is None: return None return [ @@ -647,7 +660,7 @@ def _assemble_installer_argv( if method == _InstallMethod.PIPX: # pipx 1.5+ removed `--spec`; PACKAGE_SPEC is now positional and the # package name is auto-detected from the source's pyproject.toml. - pipx_bin = shutil.which("pipx") + pipx_bin = _find_installer("pipx") if pipx_bin is None: return None return [ diff --git a/tests/specify_cli/selfs/test_command_upgrade.py b/tests/specify_cli/selfs/test_command_upgrade.py index 5a39a9ddb7..447394f1df 100644 --- a/tests/specify_cli/selfs/test_command_upgrade.py +++ b/tests/specify_cli/selfs/test_command_upgrade.py @@ -1,18 +1,45 @@ """Command tests for ``specify self upgrade``.""" +import sys from unittest.mock import patch import pytest from specify_cli import app -from tests.specify_cli.self_upgrade_helpers import runner, strip_ansi +from tests.specify_cli.self_upgrade_helpers import _InstallMethod, runner, strip_ansi + + +@pytest.mark.parametrize(("method", "name"), [("uv-tool", "uv"), ("pipx", "pipx")]) +@pytest.mark.parametrize("absolute", [False, True]) +def test_unsafe_installer_lookup_aborts_without_execution( + monkeypatch, tmp_path, method, name, absolute +): + monkeypatch.chdir(tmp_path) + decoy = tmp_path / name + decoy.write_text("decoy; must never be executed\n", encoding="utf-8") + decoy.chmod(0o755) + unsafe_path = str(decoy) if absolute else f"./{name}" + + with patch( + "specify_cli._version._detect_install_method", + return_value=_InstallMethod(method), + ), patch( + "specify_cli._version.shutil.which", return_value=unsafe_path + ), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch("specify_cli._version.subprocess.run") as mock_run: + result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.6"]) + + assert result.exit_code == 3 + assert f"Installer {name} not found on PATH" in strip_ansi(result.output) + mock_run.assert_not_called() class TestTagValidation: """--tag option parsing and command help.""" def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -22,7 +49,7 @@ def test_valid_stable_tag(self, uv_tool_argv0, clean_environ): assert result.exit_code == 0 def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -33,7 +60,7 @@ def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): assert "Target version: v0.8.0.dev0" in strip_ansi(result.output) def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -45,7 +72,7 @@ def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( "specify_cli._version._get_installed_version", return_value="1.0.0b1" ): result = runner.invoke( @@ -58,7 +85,7 @@ def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( ) def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -71,7 +98,7 @@ def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): def test_uppercase_v_prefix_is_folded_to_lowercase( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -84,7 +111,7 @@ def test_uppercase_v_prefix_is_folded_to_lowercase( def test_valid_prerelease_with_build_metadata_tag( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( diff --git a/tests/specify_cli/test_version_detection.py b/tests/specify_cli/test_version_detection.py index c769665920..1d19d3321c 100644 --- a/tests/specify_cli/test_version_detection.py +++ b/tests/specify_cli/test_version_detection.py @@ -4,6 +4,7 @@ import json import os import subprocess +import sys from pathlib import Path from unittest.mock import patch @@ -90,10 +91,10 @@ def test_tier3_uv_tool_when_registry_lists_exact_name( monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) def fake_which(name): - return "uv" if name == "uv" else None + return sys.executable if name == "uv" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["uv", "tool", "list"]: + if argv[:3] == [sys.executable, "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -116,7 +117,7 @@ def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch) monkeypatch.setattr("sys.argv", ["specify"]) def fake_which(name): - return "uv" if name == "uv" else None + return sys.executable if name == "uv" else None def fake_run(argv, *args, **kwargs): return subprocess.CompletedProcess( @@ -143,11 +144,11 @@ def fake_which(name): if name == "specify": return str(missing_specify) if name == "uv": - return "uv" + return sys.executable return None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["uv", "tool", "list"]: + if argv[:3] == [sys.executable, "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -196,10 +197,10 @@ def test_tier3_uv_tool_ignores_substring_false_positive( unsupported_argv0, ): def fake_which(name): - return "uv" if name == "uv" else None + return sys.executable if name == "uv" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["uv", "tool", "list"]: + if argv[:3] == [sys.executable, "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -221,10 +222,10 @@ def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint( unsupported_argv0, ): def fake_which(name): - return "uv" if name == "uv" else None + return sys.executable if name == "uv" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["uv", "tool", "list"]: + if argv[:3] == [sys.executable, "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -257,11 +258,11 @@ def fake_which(name): if name == "specify": return str(fake_specify) if name == "uv": - return "uv" + return sys.executable return None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["uv", "tool", "list"]: + if argv[:3] == [sys.executable, "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -323,14 +324,170 @@ def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self): assert result == Path("/broken/specify") +class TestInstallerResolution: + @pytest.mark.parametrize( + ("method", "name"), + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) + @pytest.mark.parametrize( + "location", + ["bare", "current-relative", "relative-directory", "windows-relative", "current-absolute"], + ) + def test_unsafe_lookup_result_is_not_used_for_upgrade_or_registry_probe( + self, monkeypatch, tmp_path, method, name, location + ): + monkeypatch.chdir(tmp_path) + paths = { + "bare": name, + "current-relative": f"./{name}", + "relative-directory": f"bin/{name}", + "windows-relative": f".\\{name}.exe", + "current-absolute": str(tmp_path / name), + } + decoy = tmp_path / name + decoy.write_text("#!/bin/sh\n", encoding="utf-8") + decoy.chmod(0o755) + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + + with patch( + "specify_cli._version.shutil.which", + side_effect=lambda command: paths[location] if command == name else None, + ), patch( + "specify_cli._version._editable_marker_seen", return_value=False + ), patch("specify_cli._version.subprocess.run") as mock_run: + argv = _assemble_installer_argv(method, "v0.7.6") + detected, signals = _detect_install_method(include_signals=True) + + assert argv is None + assert detected == _InstallMethod.UNSUPPORTED + assert signals.installer_registries_consulted == () + mock_run.assert_not_called() + + @pytest.mark.parametrize( + ("method", "name"), + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) + def test_absolute_installer_outside_working_directory_is_preserved( + self, monkeypatch, tmp_path, method, name + ): + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + installer = tmp_path / "installed tools" / name + installer.parent.mkdir() + installer.write_text("#!/bin/sh\n", encoding="utf-8") + installer.chmod(0o755) + + with patch( + "specify_cli._version.shutil.which", return_value=str(installer) + ): + argv = _assemble_installer_argv(method, "v0.7.6") + + assert argv is not None + assert argv[0] == str(installer) + + @pytest.mark.parametrize( + ("method", "name"), + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) + @pytest.mark.parametrize("path_entry", ["empty", "dot", "relative", "current-absolute"]) + def test_real_lookup_rejects_working_directory_and_relative_path_entries( + self, monkeypatch, tmp_path, method, name, path_entry + ): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("NoDefaultCurrentDirectoryInExePath", raising=False) + monkeypatch.setenv("PATHEXT", ".EXE") + filename = f"{name}.exe" if os.name == "nt" else name + (tmp_path / "bin").mkdir() + for directory in (tmp_path, tmp_path / "bin"): + executable = directory / filename + executable.write_text("decoy; must never be executed\n", encoding="utf-8") + executable.chmod(0o755) + entries = { + "empty": "", + "dot": ".", + "relative": "bin", + "current-absolute": str(tmp_path), + } + monkeypatch.setenv("PATH", entries[path_entry]) + + assert _assemble_installer_argv(method, "v0.7.6") is None + + @pytest.mark.parametrize( + ("method", "name"), + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) + def test_real_lookup_preserves_installed_tool_on_absolute_path( + self, monkeypatch, tmp_path, method, name + ): + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + directory = tmp_path / "installed tools" + directory.mkdir() + filename = f"{name}.exe" if os.name == "nt" else name + installer = directory / filename + installer.write_text("installer fixture; never executed\n", encoding="utf-8") + installer.chmod(0o755) + monkeypatch.setenv("PATH", str(directory)) + monkeypatch.setenv("PATHEXT", ".EXE") + + argv = _assemble_installer_argv(method, "v0.7.6") + + assert argv is not None + assert Path(argv[0]) == installer + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows implicit cwd search") + @pytest.mark.parametrize( + ("method", "name"), + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) + def test_windows_implicit_cwd_lookup_cannot_shadow_installed_tool( + self, monkeypatch, tmp_path, method, name + ): + project = tmp_path / "project" + project.mkdir() + directory = tmp_path / "installed tools" + directory.mkdir() + for parent in (project, directory): + executable = parent / f"{name}.exe" + executable.write_text("fixture; must never be executed\n", encoding="utf-8") + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", str(directory)) + monkeypatch.setenv("PATHEXT", ".EXE") + monkeypatch.delenv("NoDefaultCurrentDirectoryInExePath", raising=False) + + argv = _assemble_installer_argv(method, "v0.7.6") + + assert argv is None or Path(argv[0]) == directory / f"{name}.exe" + + @pytest.mark.skipif(os.name == "nt", reason="requires unprivileged symlinks") + def test_absolute_symlink_to_working_directory_installer_is_rejected( + self, monkeypatch, tmp_path + ): + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + decoy = project / "uv" + decoy.write_text("#!/bin/sh\n", encoding="utf-8") + decoy.chmod(0o755) + installer = tmp_path / "uv" + installer.symlink_to(decoy) + + with patch( + "specify_cli._version.shutil.which", return_value=str(installer) + ): + assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None + + class TestArgvAssemblyUvTool: """uv-tool installer argv shape.""" def test_stable_tag_produces_expected_argv(self): - with patch("specify_cli._version.shutil.which", return_value="uv"): + with patch("specify_cli._version.shutil.which", return_value=sys.executable): argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") assert argv == [ - "uv", + sys.executable, "tool", "install", "specify-cli", @@ -340,7 +497,7 @@ def test_stable_tag_produces_expected_argv(self): ] def test_dev_suffix_tag_embedded_literally(self): - with patch("specify_cli._version.shutil.which", return_value="uv"): + with patch("specify_cli._version.shutil.which", return_value=sys.executable): argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0") assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv assert ( @@ -357,7 +514,7 @@ class TestBareUpgradeUvTool: def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -380,7 +537,7 @@ def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ): # The single `invoke` represents the single user action — no prompt. # If a prompt existed, runner.invoke would hang waiting for input. with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -402,7 +559,7 @@ def test_already_latest_exits_zero_no_subprocess( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version._get_installed_version", return_value="0.7.6"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) @@ -420,7 +577,7 @@ def test_trailing_zero_equivalent_version_reports_latest_not_newer( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version._get_installed_version", return_value="1.0"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"}) result = runner.invoke(app, ["self", "upgrade"]) @@ -437,7 +594,7 @@ def test_dev_build_ahead_of_release_reports_newer_noop( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) @@ -452,7 +609,7 @@ def test_unparseable_current_version_does_not_false_noop( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version._get_installed_version", return_value="release-main"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ @@ -473,7 +630,7 @@ def test_unparseable_resolved_target_fails_before_literal_noop( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version._get_installed_version", return_value="release-main"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) result = runner.invoke(app, ["self", "upgrade"]) @@ -488,7 +645,7 @@ def test_unparseable_resolved_target_fails_before_literal_noop( def test_pinned_older_tag_still_runs_installer( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.6" @@ -510,7 +667,7 @@ def test_pinned_older_tag_still_runs_installer( def test_pinned_rc_tag_uses_canonical_version_equality_for_noop( self, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value="uv"), patch( + with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( "specify_cli._version._get_installed_version", return_value="1.0.0rc1" ): result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"]) @@ -530,7 +687,7 @@ def test_dry_run_without_tag_resolves_network_but_no_subprocess( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) @@ -548,7 +705,7 @@ def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ): # --dry-run with --tag must NOT hit the network. with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" - ), patch("specify_cli._version.shutil.which", return_value="uv"), patch( + ), patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -565,7 +722,7 @@ def test_dry_run_rejects_unparseable_network_tag_before_preview( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = mock_urlopen_response( {"tag_name": "v0.9.0;echo unsafe"} @@ -618,10 +775,10 @@ def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it( monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) def fake_which(name): - return "pipx" if name == "pipx" else None + return sys.executable if name == "pipx" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["pipx", "list", "--json"]: + if argv[:3] == [sys.executable, "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -645,10 +802,10 @@ def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint( unsupported_argv0, ): def fake_which(name): - return "pipx" if name == "pipx" else None + return sys.executable if name == "pipx" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["pipx", "list", "--json"]: + if argv[:3] == [sys.executable, "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -670,10 +827,10 @@ def test_tier3_pipx_ignores_malformed_json_output( unsupported_argv0, ): def fake_which(name): - return "pipx" if name == "pipx" else None + return sys.executable if name == "pipx" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["pipx", "list", "--json"]: + if argv[:3] == [sys.executable, "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -699,20 +856,20 @@ def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported( def fake_which(name): if name == "uv": - return "uv" + return sys.executable if name == "pipx": - return "pipx" + return sys.executable return None def fake_run(argv, *args, **kwargs): - if argv[:3] == ["uv", "tool", "list"]: + if argv[:3] == [sys.executable, "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, stdout="specify-cli v0.7.6\n", stderr="", ) - if argv[:3] == ["pipx", "list", "--json"]: + if argv[:3] == [sys.executable, "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -801,7 +958,7 @@ def locate_file(self, file): class TestTagValidationWhitespace: def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -820,10 +977,10 @@ class TestArgvAssemblyPipx: """pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`.""" def test_pipx_argv_uses_install_force_positional_not_upgrade(self): - with patch("specify_cli._version.shutil.which", return_value="pipx"): + with patch("specify_cli._version.shutil.which", return_value=sys.executable): argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") assert argv == [ - "pipx", + sys.executable, "install", "--force", "git+https://github.com/github/spec-kit.git@v0.7.6", @@ -841,7 +998,7 @@ class TestBareUpgradePipx: def test_happy_path(self, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="pipx" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -877,7 +1034,7 @@ def test_pipx_argv0_prefix_short_circuits_before_registry_checks( class TestDryRunPipx: def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="pipx" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): diff --git a/tests/specify_cli/test_version_execution.py b/tests/specify_cli/test_version_execution.py index 9e3f2514fe..de884e5c5b 100644 --- a/tests/specify_cli/test_version_execution.py +++ b/tests/specify_cli/test_version_execution.py @@ -2,6 +2,7 @@ import errno import subprocess +import sys from unittest.mock import patch from specify_cli import app @@ -234,7 +235,7 @@ def test_real_installer_exit_126_is_not_treated_as_invalid_path( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -310,7 +311,7 @@ def test_bare_invalid_installer_message_does_not_call_it_a_path( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", return_value=[ @@ -399,7 +400,7 @@ class TestInstallerFailed: def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -422,7 +423,7 @@ def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -436,7 +437,7 @@ def test_installer_timeout_prints_timeout_specific_message( ): monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12") with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -455,7 +456,7 @@ def test_non_finite_timeout_warns_and_runs_without_timeout( ): monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan") with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -476,7 +477,7 @@ def test_real_installer_exit_124_is_not_treated_as_timeout( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -490,7 +491,7 @@ def test_real_installer_exit_124_is_not_treated_as_timeout( def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="pipx" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -508,7 +509,7 @@ def test_rollback_hint_accepts_normalizable_stable_snapshot( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="v0.7.5" ): @@ -528,7 +529,7 @@ def test_prerelease_failure_degrades_rollback_hint_to_releases_page( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="1.0.0rc1" ): diff --git a/tests/specify_cli/test_version_verification.py b/tests/specify_cli/test_version_verification.py index f3311bb23f..b137e9b310 100644 --- a/tests/specify_cli/test_version_verification.py +++ b/tests/specify_cli/test_version_verification.py @@ -1,5 +1,6 @@ """Verification, resolution, and validation tests for `specify self upgrade`.""" +import sys import urllib.error from unittest.mock import patch @@ -34,7 +35,7 @@ def test_installer_ok_but_verify_returns_old_version( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -57,7 +58,7 @@ def test_verify_nonzero_exit_is_not_treated_as_success( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -79,7 +80,7 @@ def test_verify_accepts_pep440_equivalent_rc_version( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.9.0" ): @@ -99,7 +100,7 @@ def test_verify_accepts_specify_cli_binary_name_in_version_output( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -119,7 +120,7 @@ def test_verify_accepts_capitalized_binary_name_in_version_output( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -139,7 +140,7 @@ def test_verify_rejects_output_without_parseable_version( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -349,7 +350,7 @@ def test_unparseable_resolved_release_tag_exits_1_without_traceback( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"}) result = runner.invoke(app, ["self", "upgrade"]) @@ -371,7 +372,7 @@ def test_unknown_current_renders_literal_in_notice( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" ): @@ -393,7 +394,7 @@ def test_unknown_current_rollback_hint_degrades( clean_environ, ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" ): @@ -422,7 +423,7 @@ def test_env_passed_to_subprocess_has_no_github_tokens( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli.authentication.http.urllib.request.build_opener" ) as mock_build_opener, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -455,7 +456,7 @@ def test_env_scrubbing_is_case_insensitive( with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli.authentication.http.urllib.request.build_opener" ) as mock_build_opener, patch( - "specify_cli._version.shutil.which", return_value="uv" + "specify_cli._version.shutil.which", return_value=sys.executable ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): From 8970a7036d4daa7093fb1e2eef5ff817f3124690 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:42:46 -0500 Subject: [PATCH 2/4] fix: close installer path bypasses and preserve rejection guidance Compare both lookup and resolved target directories by filesystem identity, rejecting path-validation errors. Preserve rejection details for registry probes, upgrades, and dry runs without masking unrelated missing installers. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_version.py | 97 ++++++++++-- src/specify_cli/selfs/command_upgrade.py | 16 +- .../specify_cli/selfs/test_command_upgrade.py | 116 ++++++++++++++- tests/specify_cli/test_version_detection.py | 139 ++++++++++++++++-- 4 files changed, 330 insertions(+), 38 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 2dedbe7c93..3ca8ffe474 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -25,6 +25,7 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path +from typing import Literal, overload import typer from packaging.version import InvalidVersion, Version @@ -226,6 +227,10 @@ class _InstallerResult: returncode: int | None = None +class _InstallerRejected(ValueError): + """Installer lookup succeeded but its path could not be trusted.""" + + @dataclass(frozen=True) class _UpgradePlan: """Resolved upgrade decision shared by preview and apply paths.""" @@ -236,6 +241,8 @@ class _UpgradePlan: installer_argv: list[str] | None preview_summary: str pre_upgrade_snapshot: str + installer_rejections: tuple[str, ...] = () + installer_rejected: bool = False @dataclass(frozen=True) @@ -248,6 +255,7 @@ class _DetectionSignals: editable_marker_seen: bool installer_registries_consulted: tuple[str, ...] resolved_method: _InstallMethod + installer_rejections: tuple[str, ...] = () _GITHUB_CREDENTIAL_SUFFIXES = ( @@ -479,17 +487,58 @@ def _editable_marker_seen() -> bool: def _find_installer(name: str) -> str | None: """Resolve an installer without trusting working-directory executables.""" - resolved = shutil.which(name) - if resolved is None: - return None - installer = Path(resolved) - # Windows lookup may prepend cwd even when PATH contains only absolute - # directories. Never absolutize that result or fall back to a bare command. - if not installer.is_absolute() or installer.resolve().parent == Path.cwd().resolve(): - return None + try: + resolved = shutil.which(name) + if resolved is None: + return None + installer = Path(resolved) + # Windows lookup may prepend cwd even when PATH is absolute. Check + # both the lookup entry and symlink target using filesystem identity. + if not installer.is_absolute(): + raise _InstallerRejected( + f"Installer {name} rejected: lookup returned a relative path {resolved!r}. " + "Use an absolute PATH entry for the installed tool and retry." + ) + cwd = Path.cwd() + if ( + installer.parent.samefile(cwd) + or installer.resolve(strict=True).parent.samefile(cwd) + ): + raise _InstallerRejected( + f"Installer {name} rejected: lookup or target is in the current " + f"working directory ({resolved!r}). Run from another directory " + "and ensure PATH points to the installed tool." + ) + except (OSError, RuntimeError) as exc: + raise _InstallerRejected( + f"Installer {name} rejected: could not validate its path ({exc}). " + "Run from an existing, accessible working directory and check PATH." + ) from exc return resolved +@overload +def _detect_install_method( + argv0: str | None = None, + include_signals: Literal[False] = False, +) -> _InstallMethod: ... + + +@overload +def _detect_install_method( + argv0: str | None = None, + *, + include_signals: Literal[True], +) -> tuple[_InstallMethod, _DetectionSignals]: ... + + +@overload +def _detect_install_method( + argv0: str | None = None, + include_signals: bool = False, +) -> _InstallMethod | tuple[_InstallMethod, _DetectionSignals]: ... + + def _detect_install_method( argv0: str | None = None, include_signals: bool = False, @@ -541,9 +590,14 @@ def _detect_install_method( # --- Tier 3: PATH + registry reconciliation --- consulted: list[str] = [] + rejections: list[str] = [] if _tier3_registry_lookup_allowed(argv0_path): uv_tool_match = False - uv_bin = _find_installer("uv") + try: + uv_bin = _find_installer("uv") + except _InstallerRejected as exc: + rejections.append(str(exc)) + uv_bin = None if uv_bin is not None: consulted.append("uv tool list") try: @@ -563,7 +617,11 @@ def _detect_install_method( pass pipx_match = False - pipx_bin = _find_installer("pipx") + try: + pipx_bin = _find_installer("pipx") + except _InstallerRejected as exc: + rejections.append(str(exc)) + pipx_bin = None if pipx_bin is not None: consulted.append("pipx list --json") try: @@ -596,6 +654,7 @@ def _detect_install_method( editable_marker_seen=False, installer_registries_consulted=tuple(consulted), resolved_method=method, + installer_rejections=tuple(rejections), ) return method @@ -609,6 +668,7 @@ def _detect_install_method( editable_marker_seen=False, installer_registries_consulted=tuple(consulted), resolved_method=method, + installer_rejections=tuple(rejections), ) return method @@ -706,7 +766,8 @@ def _build_upgrade_plan( A valid `target_tag_override` skips network resolution entirely. A fetched target tag is validated before installer argv construction. """ - method = _detect_install_method() + method, signals = _detect_install_method(include_signals=True) + rejections = list(signals.installer_rejections) if target_tag_override is not None: target_tag = target_tag_override @@ -733,10 +794,18 @@ def _build_upgrade_plan( target_tag = None current = _get_installed_version() - argv = _assemble_installer_argv(method, target_tag) + installer_rejected = False + try: + argv = _assemble_installer_argv(method, target_tag) + except _InstallerRejected as exc: + rejections.append(str(exc)) + installer_rejected = True + argv = None if argv is None and method in (_InstallMethod.UV_TOOL, _InstallMethod.PIPX): command_preview = ( - f"(installer {_installer_binary_name(method)} not found on PATH)" + "(installer rejected)" + if installer_rejected + else f"(installer {_installer_binary_name(method)} not found on PATH)" ) else: command_preview = ( @@ -757,6 +826,8 @@ def _build_upgrade_plan( installer_argv=argv, preview_summary=preview, pre_upgrade_snapshot=current, + installer_rejections=tuple(rejections), + installer_rejected=installer_rejected, ) return plan, None diff --git a/src/specify_cli/selfs/command_upgrade.py b/src/specify_cli/selfs/command_upgrade.py index 1f645c0e45..445ebc1475 100644 --- a/src/specify_cli/selfs/command_upgrade.py +++ b/src/specify_cli/selfs/command_upgrade.py @@ -44,7 +44,7 @@ def self_upgrade( installer itself exits 2, that installer failure code is propagated verbatim 3 installer binary not found on PATH, or resolved installer path is - missing / non-executable + rejected / missing / non-executable 124 internal installer timeout when SPECIFY_UPGRADE_TIMEOUT_SECS is set, or a real installer exit code 124 propagated verbatim; scripts should treat 124 as ambiguous and inspect the failure message @@ -99,6 +99,9 @@ def self_upgrade( _emit_failure(failure_reason, plan=plan) raise typer.Exit(1) + for rejection in plan.installer_rejections: + console.print(rejection, soft_wrap=True, markup=False) + if dry_run: if plan.method in ( _InstallMethod.UVX_EPHEMERAL, @@ -121,11 +124,12 @@ def self_upgrade( raise typer.Exit(0) if plan.installer_argv is None: - _emit_failure( - _FAILURE_INSTALLER_MISSING, - plan=plan, - installer_name=_installer_binary_name(plan.method), - ) + if not plan.installer_rejected: + _emit_failure( + _FAILURE_INSTALLER_MISSING, + plan=plan, + installer_name=_installer_binary_name(plan.method), + ) raise typer.Exit(3) if plan.target_tag is None: diff --git a/tests/specify_cli/selfs/test_command_upgrade.py b/tests/specify_cli/selfs/test_command_upgrade.py index 447394f1df..02a1e8c1a3 100644 --- a/tests/specify_cli/selfs/test_command_upgrade.py +++ b/tests/specify_cli/selfs/test_command_upgrade.py @@ -1,19 +1,22 @@ """Command tests for ``specify self upgrade``.""" +import subprocess import sys from unittest.mock import patch import pytest from specify_cli import app -from tests.specify_cli.self_upgrade_helpers import _InstallMethod, runner, strip_ansi +from tests.specify_cli.self_upgrade_helpers import runner, strip_ansi @pytest.mark.parametrize(("method", "name"), [("uv-tool", "uv"), ("pipx", "pipx")]) @pytest.mark.parametrize("absolute", [False, True]) +@pytest.mark.parametrize("dry_run", [False, True]) def test_unsafe_installer_lookup_aborts_without_execution( - monkeypatch, tmp_path, method, name, absolute + monkeypatch, tmp_path, request, method, name, absolute, dry_run ): + request.getfixturevalue("uv_tool_argv0" if method == "uv-tool" else "pipx_argv0") monkeypatch.chdir(tmp_path) decoy = tmp_path / name decoy.write_text("decoy; must never be executed\n", encoding="utf-8") @@ -21,20 +24,117 @@ def test_unsafe_installer_lookup_aborts_without_execution( unsafe_path = str(decoy) if absolute else f"./{name}" with patch( - "specify_cli._version._detect_install_method", - return_value=_InstallMethod(method), - ), patch( "specify_cli._version.shutil.which", return_value=unsafe_path ), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ), patch("specify_cli._version.subprocess.run") as mock_run: - result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.6"]) + args = ["self", "upgrade", "--tag", "v0.7.6"] + if dry_run: + args.append("--dry-run") + result = runner.invoke(app, args) + + assert result.exit_code == (0 if dry_run else 3) + output = strip_ansi(result.output) + assert f"Installer {name} rejected" in output + assert "PATH" in output + assert ("Run from another directory" if absolute else "absolute PATH") in output + assert "reinstall" not in output + assert "not found on PATH" not in output + mock_run.assert_not_called() + + +@pytest.mark.parametrize("name", ["uv", "pipx"]) +@pytest.mark.parametrize("dry_run", [False, True]) +def test_registry_probe_rejection_guidance_is_preserved( + monkeypatch, tmp_path, name, dry_run +): + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + with patch( + "specify_cli._version.shutil.which", + side_effect=lambda command: f"./{name}" if command == name else None, + ), patch( + "specify_cli._version._editable_marker_seen", return_value=False + ), patch("specify_cli._version.subprocess.run") as mock_run: + args = ["self", "upgrade", "--tag", "v0.7.6"] + if dry_run: + args.append("--dry-run") + result = runner.invoke(app, args) + + assert result.exit_code == 0 + output = strip_ansi(result.output) + assert f"Installer {name} rejected" in output + assert "absolute PATH" in output + mock_run.assert_not_called() - assert result.exit_code == 3 - assert f"Installer {name} not found on PATH" in strip_ansi(result.output) + +@pytest.mark.parametrize(("method", "name"), [("uv-tool", "uv"), ("pipx", "pipx")]) +@pytest.mark.parametrize("dry_run", [False, True]) +def test_deleted_working_directory_gets_rejection_guidance( + request, method, name, dry_run +): + request.getfixturevalue("uv_tool_argv0" if method == "uv-tool" else "pipx_argv0") + with patch( + "specify_cli._version.shutil.which", return_value=sys.executable + ), patch( + "specify_cli._version.Path.cwd", + side_effect=FileNotFoundError("working directory was deleted"), + ), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch("specify_cli._version.subprocess.run") as mock_run: + args = ["self", "upgrade", "--tag", "v0.7.6"] + if dry_run: + args.append("--dry-run") + result = runner.invoke(app, args) + + assert result.exit_code == (0 if dry_run else 3) + output = strip_ansi(result.output) + assert f"Installer {name} rejected: could not validate its path" in output + assert "working directory was deleted" in output + assert "existing, accessible working directory" in output mock_run.assert_not_called() +@pytest.mark.parametrize("dry_run", [False, True]) +def test_rejected_probe_does_not_hide_a_different_missing_installer( + monkeypatch, tmp_path, dry_run +): + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + pipx_results = iter([sys.executable, None]) + + def fake_which(name): + if name == "uv": + return "./uv" + if name == "pipx": + return next(pipx_results) + return None + + with patch( + "specify_cli._version.shutil.which", side_effect=fake_which + ), patch( + "specify_cli._version._editable_marker_seen", return_value=False + ), patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), patch("specify_cli._version.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[sys.executable, "list", "--json"], + returncode=0, + stdout='{"venvs":{"specify-cli":{}}}', + stderr="", + ) + args = ["self", "upgrade", "--tag", "v0.7.6"] + if dry_run: + args.append("--dry-run") + result = runner.invoke(app, args) + + assert result.exit_code == (0 if dry_run else 3) + output = strip_ansi(result.output) + assert "Installer uv rejected" in output + assert "pipx not found on PATH" in output + assert "Installer pipx rejected" not in output + assert mock_run.call_count == 1 + assert mock_run.call_args.args[0] == [sys.executable, "list", "--json"] + + class TestTagValidation: """--tag option parsing and command help.""" diff --git a/tests/specify_cli/test_version_detection.py b/tests/specify_cli/test_version_detection.py index 1d19d3321c..272aed5984 100644 --- a/tests/specify_cli/test_version_detection.py +++ b/tests/specify_cli/test_version_detection.py @@ -325,6 +325,110 @@ def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self): class TestInstallerResolution: + @pytest.mark.skipif(os.name == "nt", reason="requires unprivileged symlinks") + @pytest.mark.parametrize( + ("method", "name"), + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) + def test_working_directory_symlink_to_nested_target_is_rejected( + self, monkeypatch, tmp_path, method, name + ): + project = tmp_path / "project" + target = project / "scripts" / name + target.parent.mkdir(parents=True) + target.write_text("decoy; must never be executed\n", encoding="utf-8") + target.chmod(0o755) + installer = project / name + installer.symlink_to(target) + monkeypatch.chdir(project) + + with patch( + "specify_cli._version.shutil.which", return_value=str(installer) + ), pytest.raises(ValueError, match=f"Installer {name} rejected"): + _assemble_installer_argv(method, "v0.7.6") + + @pytest.mark.parametrize( + ("method", "name"), + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) + def test_case_alias_of_working_directory_is_rejected( + self, monkeypatch, tmp_path, method, name + ): + project = tmp_path / "Project" + project.mkdir() + alias = tmp_path / "pROJECT" + if not alias.exists(): + pytest.skip("requires a case-insensitive filesystem") + installer = project / name + installer.write_text("decoy; must never be executed\n", encoding="utf-8") + installer.chmod(0o755) + monkeypatch.chdir(project) + + with patch( + "specify_cli._version.shutil.which", return_value=str(alias / name) + ), pytest.raises(ValueError, match=f"Installer {name} rejected"): + _assemble_installer_argv(method, "v0.7.6") + + @pytest.mark.parametrize( + ("method", "name"), + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) + @pytest.mark.parametrize( + ("operation", "error"), + [ + ("cwd", FileNotFoundError("working directory was deleted")), + ("samefile", PermissionError("cannot stat installer directory")), + ("resolve", RuntimeError("symlink loop")), + ], + ) + def test_path_comparison_errors_are_rejected( + self, tmp_path, method, name, operation, error + ): + with patch( + "specify_cli._version.shutil.which", + side_effect=lambda command: sys.executable if command == name else None, + ), patch( + "specify_cli._version._resolved_argv0_path", + return_value=tmp_path / "missing" / "specify", + ), patch( + "specify_cli._version._expand_prefix", return_value=None + ), patch( + "specify_cli._version._editable_marker_seen", return_value=False + ), patch( + "specify_cli._version.subprocess.run" + ) as mock_run, patch.object( + Path, operation, side_effect=error + ): + with pytest.raises(ValueError, match=f"Installer {name} rejected"): + _assemble_installer_argv(method, "v0.7.6") + detected, signals = _detect_install_method(include_signals=True) + + assert detected == _InstallMethod.UNSUPPORTED + assert signals.installer_registries_consulted == () + assert len(signals.installer_rejections) == 1 + assert str(error) in signals.installer_rejections[0] + mock_run.assert_not_called() + + @pytest.mark.parametrize( + ("method", "name"), + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) + @pytest.mark.parametrize("target_missing", [False, True]) + def test_missing_installer_paths_are_rejected( + self, monkeypatch, tmp_path, method, name, target_missing + ): + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + directory = tmp_path / "installer-bin" + if target_missing: + directory.mkdir() + + with patch( + "specify_cli._version.shutil.which", return_value=str(directory / name) + ), pytest.raises(ValueError, match=f"Installer {name} rejected"): + _assemble_installer_argv(method, "v0.7.6") + @pytest.mark.parametrize( ("method", "name"), [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], @@ -355,12 +459,14 @@ def test_unsafe_lookup_result_is_not_used_for_upgrade_or_registry_probe( ), patch( "specify_cli._version._editable_marker_seen", return_value=False ), patch("specify_cli._version.subprocess.run") as mock_run: - argv = _assemble_installer_argv(method, "v0.7.6") + with pytest.raises(ValueError, match=f"Installer {name} rejected"): + _assemble_installer_argv(method, "v0.7.6") detected, signals = _detect_install_method(include_signals=True) - assert argv is None assert detected == _InstallMethod.UNSUPPORTED assert signals.installer_registries_consulted == () + assert len(signals.installer_rejections) == 1 + assert f"Installer {name} rejected" in signals.installer_rejections[0] mock_run.assert_not_called() @pytest.mark.parametrize( @@ -411,7 +517,11 @@ def test_real_lookup_rejects_working_directory_and_relative_path_entries( } monkeypatch.setenv("PATH", entries[path_entry]) - assert _assemble_installer_argv(method, "v0.7.6") is None + if path_entry == "empty": + assert _assemble_installer_argv(method, "v0.7.6") is None + else: + with pytest.raises(ValueError, match=f"Installer {name} rejected"): + _assemble_installer_argv(method, "v0.7.6") @pytest.mark.parametrize( ("method", "name"), @@ -457,27 +567,34 @@ def test_windows_implicit_cwd_lookup_cannot_shadow_installed_tool( monkeypatch.setenv("PATHEXT", ".EXE") monkeypatch.delenv("NoDefaultCurrentDirectoryInExePath", raising=False) - argv = _assemble_installer_argv(method, "v0.7.6") - - assert argv is None or Path(argv[0]) == directory / f"{name}.exe" + try: + argv = _assemble_installer_argv(method, "v0.7.6") + except specify_cli._version._InstallerRejected: + return + assert argv is not None + assert Path(argv[0]) == directory / f"{name}.exe" @pytest.mark.skipif(os.name == "nt", reason="requires unprivileged symlinks") + @pytest.mark.parametrize( + ("method", "name"), + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) def test_absolute_symlink_to_working_directory_installer_is_rejected( - self, monkeypatch, tmp_path + self, monkeypatch, tmp_path, method, name ): project = tmp_path / "project" project.mkdir() monkeypatch.chdir(project) - decoy = project / "uv" + decoy = project / name decoy.write_text("#!/bin/sh\n", encoding="utf-8") decoy.chmod(0o755) - installer = tmp_path / "uv" + installer = tmp_path / name installer.symlink_to(decoy) with patch( "specify_cli._version.shutil.which", return_value=str(installer) - ): - assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None + ), pytest.raises(ValueError, match=f"Installer {name} rejected"): + _assemble_installer_argv(method, "v0.7.6") class TestArgvAssemblyUvTool: From f5c3063378e55148b2968428281a24f4140c404d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:33:51 -0500 Subject: [PATCH 3/4] fix: isolate installer decoys and clarify Windows lookup guidance Keep decoy executables in a separate working directory from installer fixtures and exercise the Windows directory layout on all platforms. Explain how to avoid implicit current-directory matches when installer lookup returns a relative path. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_version.py | 4 +++- tests/specify_cli/selfs/test_command_upgrade.py | 11 +++++++++-- tests/specify_cli/test_version_detection.py | 6 +++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 3ca8ffe474..8157572a39 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -497,7 +497,9 @@ def _find_installer(name: str) -> str | None: if not installer.is_absolute(): raise _InstallerRejected( f"Installer {name} rejected: lookup returned a relative path {resolved!r}. " - "Use an absolute PATH entry for the installed tool and retry." + "Use an absolute PATH entry for the installed tool. " + "Run from a directory without a matching installer executable " + "to avoid implicit current-directory matches on Windows, and retry." ) cwd = Path.cwd() if ( diff --git a/tests/specify_cli/selfs/test_command_upgrade.py b/tests/specify_cli/selfs/test_command_upgrade.py index 02a1e8c1a3..f986aa00c4 100644 --- a/tests/specify_cli/selfs/test_command_upgrade.py +++ b/tests/specify_cli/selfs/test_command_upgrade.py @@ -17,8 +17,12 @@ def test_unsafe_installer_lookup_aborts_without_execution( monkeypatch, tmp_path, request, method, name, absolute, dry_run ): request.getfixturevalue("uv_tool_argv0" if method == "uv-tool" else "pipx_argv0") - monkeypatch.chdir(tmp_path) - decoy = tmp_path / name + # Exercise the Windows fixture directory collision on every platform. + (tmp_path / name).mkdir(exist_ok=True) + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + decoy = project / name decoy.write_text("decoy; must never be executed\n", encoding="utf-8") decoy.chmod(0o755) unsafe_path = str(decoy) if absolute else f"./{name}" @@ -38,6 +42,8 @@ def test_unsafe_installer_lookup_aborts_without_execution( assert f"Installer {name} rejected" in output assert "PATH" in output assert ("Run from another directory" if absolute else "absolute PATH") in output + if not absolute: + assert "Run from a directory without a matching installer executable" in output assert "reinstall" not in output assert "not found on PATH" not in output mock_run.assert_not_called() @@ -64,6 +70,7 @@ def test_registry_probe_rejection_guidance_is_preserved( output = strip_ansi(result.output) assert f"Installer {name} rejected" in output assert "absolute PATH" in output + assert "Run from a directory without a matching installer executable" in output mock_run.assert_not_called() diff --git a/tests/specify_cli/test_version_detection.py b/tests/specify_cli/test_version_detection.py index 272aed5984..4a3cdf258a 100644 --- a/tests/specify_cli/test_version_detection.py +++ b/tests/specify_cli/test_version_detection.py @@ -459,7 +459,7 @@ def test_unsafe_lookup_result_is_not_used_for_upgrade_or_registry_probe( ), patch( "specify_cli._version._editable_marker_seen", return_value=False ), patch("specify_cli._version.subprocess.run") as mock_run: - with pytest.raises(ValueError, match=f"Installer {name} rejected"): + with pytest.raises(ValueError, match=f"Installer {name} rejected") as rejection: _assemble_installer_argv(method, "v0.7.6") detected, signals = _detect_install_method(include_signals=True) @@ -467,6 +467,10 @@ def test_unsafe_lookup_result_is_not_used_for_upgrade_or_registry_probe( assert signals.installer_registries_consulted == () assert len(signals.installer_rejections) == 1 assert f"Installer {name} rejected" in signals.installer_rejections[0] + if location == "windows-relative": + guidance = "Run from a directory without a matching installer executable" + assert guidance in str(rejection.value) + assert guidance in signals.installer_rejections[0] mock_run.assert_not_called() @pytest.mark.parametrize( From 8b85bf7e94e68132738a13c5d24a218326cbbc68 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:01:34 -0500 Subject: [PATCH 4/4] fix: reject installer ownership when a registry probe is unsafe Treat rejected Tier-3 probes as unresolved ownership rather than selecting the other installer. Cover both installer directions and normal/dry-run CLI behavior without invoking an upgrade. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_version.py | 6 +-- .../specify_cli/selfs/test_command_upgrade.py | 34 +++++++++------- tests/specify_cli/test_version_detection.py | 40 +++++++++++++++++++ 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 951be08897..bcfe42caa6 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -647,10 +647,10 @@ def _detect_install_method( except (subprocess.TimeoutExpired, OSError, ValueError): pass - # If both registries claim ownership, the active entrypoint is ambiguous. - # Treat it as unsupported rather than guessing and upgrading the wrong install. + # Both matches or a rejected probe leave ownership ambiguous. + # Never guess and risk upgrading the wrong installation. exactly_one_match = uv_tool_match != pipx_match - if exactly_one_match: + if exactly_one_match and not rejections: method = _InstallMethod.UV_TOOL if uv_tool_match else _InstallMethod.PIPX if include_signals: return method, _DetectionSignals( diff --git a/tests/specify_cli/selfs/test_command_upgrade.py b/tests/specify_cli/selfs/test_command_upgrade.py index f986aa00c4..cfb03d640b 100644 --- a/tests/specify_cli/selfs/test_command_upgrade.py +++ b/tests/specify_cli/selfs/test_command_upgrade.py @@ -102,17 +102,22 @@ def test_deleted_working_directory_gets_rejection_guidance( @pytest.mark.parametrize("dry_run", [False, True]) -def test_rejected_probe_does_not_hide_a_different_missing_installer( - monkeypatch, tmp_path, dry_run +@pytest.mark.parametrize("rejected_name", ["uv", "pipx"]) +def test_rejected_probe_prevents_selecting_a_different_installer( + monkeypatch, tmp_path, dry_run, rejected_name ): monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) - pipx_results = iter([sys.executable, None]) + other_name = "pipx" if rejected_name == "uv" else "uv" + probe_args = ["list", "--json"] if other_name == "pipx" else ["tool", "list"] + probe_output = ( + '{"venvs":{"specify-cli":{}}}' if other_name == "pipx" else "specify-cli v0.7.6\n" + ) def fake_which(name): - if name == "uv": - return "./uv" - if name == "pipx": - return next(pipx_results) + if name == rejected_name: + return f"./{name}" + if name == other_name: + return sys.executable return None with patch( @@ -123,9 +128,9 @@ def fake_which(name): "specify_cli._version._get_installed_version", return_value="0.7.5" ), patch("specify_cli._version.subprocess.run") as mock_run: mock_run.return_value = subprocess.CompletedProcess( - args=[sys.executable, "list", "--json"], + args=[sys.executable, *probe_args], returncode=0, - stdout='{"venvs":{"specify-cli":{}}}', + stdout=probe_output, stderr="", ) args = ["self", "upgrade", "--tag", "v0.7.6"] @@ -133,13 +138,14 @@ def fake_which(name): args.append("--dry-run") result = runner.invoke(app, args) - assert result.exit_code == (0 if dry_run else 3) + assert result.exit_code == 0 output = strip_ansi(result.output) - assert "Installer uv rejected" in output - assert "pipx not found on PATH" in output - assert "Installer pipx rejected" not in output + assert f"Installer {rejected_name} rejected" in output + assert "Could not identify your install method automatically" in output + assert "Upgrading specify-cli" not in output + assert "not found on PATH" not in output assert mock_run.call_count == 1 - assert mock_run.call_args.args[0] == [sys.executable, "list", "--json"] + assert mock_run.call_args.args[0] == [sys.executable, *probe_args] class TestTagValidation: diff --git a/tests/specify_cli/test_version_detection.py b/tests/specify_cli/test_version_detection.py index 4a3cdf258a..8235995b12 100644 --- a/tests/specify_cli/test_version_detection.py +++ b/tests/specify_cli/test_version_detection.py @@ -1011,6 +1011,46 @@ def fake_run(argv, *args, **kwargs): assert "pipx list --json" in signals.installer_registries_consulted + @pytest.mark.parametrize("rejected_name", ["uv", "pipx"]) + def test_tier3_rejected_probe_leaves_ownership_ambiguous( + self, monkeypatch, tmp_path, rejected_name + ): + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + other_name = "pipx" if rejected_name == "uv" else "uv" + probe_args = ["list", "--json"] if other_name == "pipx" else ["tool", "list"] + probe_output = ( + '{"venvs":{"specify-cli":{}}}' if other_name == "pipx" else "specify-cli v0.7.6\n" + ) + + def fake_which(name): + if name == rejected_name: + return f"./{name}" + if name == other_name: + return sys.executable + return None + + with patch( + "specify_cli._version.shutil.which", side_effect=fake_which + ), patch( + "specify_cli._version._editable_marker_seen", return_value=False + ), patch("specify_cli._version.subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess( + args=[sys.executable, *probe_args], + returncode=0, + stdout=probe_output, + stderr="", + ) + method, signals = _detect_install_method(include_signals=True) + + assert method == _InstallMethod.UNSUPPORTED + assert signals.matched_tier is None + assert signals.installer_registries_consulted == (" ".join([other_name, *probe_args]),) + assert len(signals.installer_rejections) == 1 + assert f"Installer {rejected_name} rejected" in signals.installer_rejections[0] + mock_run.assert_called_once() + assert mock_run.call_args.args[0] == [sys.executable, *probe_args] + + class TestEditableInstallMetadata: @pytest.mark.skipif( not hasattr(importlib.metadata, "InvalidMetadataError"),