diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index c8fcad7c7e..bcfe42caa6 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 @@ -230,6 +231,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.""" @@ -240,6 +245,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) @@ -252,6 +259,7 @@ class _DetectionSignals: editable_marker_seen: bool installer_registries_consulted: tuple[str, ...] resolved_method: _InstallMethod + installer_rejections: tuple[str, ...] = () _GITHUB_CREDENTIAL_SUFFIXES = ( @@ -481,6 +489,62 @@ 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.""" + 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. " + "Run from a directory without a matching installer executable " + "to avoid implicit current-directory matches on Windows, 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, @@ -532,9 +596,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 = shutil.which("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: @@ -554,7 +623,11 @@ def _detect_install_method( pass pipx_match = False - pipx_bin = shutil.which("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: @@ -574,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( @@ -587,6 +660,7 @@ def _detect_install_method( editable_marker_seen=False, installer_registries_consulted=tuple(consulted), resolved_method=method, + installer_rejections=tuple(rejections), ) return method @@ -600,6 +674,7 @@ def _detect_install_method( editable_marker_seen=False, installer_registries_consulted=tuple(consulted), resolved_method=method, + installer_rejections=tuple(rejections), ) return method @@ -635,7 +710,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 [ @@ -651,7 +726,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 [ @@ -697,7 +772,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 @@ -724,10 +800,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 = ( @@ -748,6 +832,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/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 31c94f2ad1..afed3ec30e 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -1706,7 +1706,9 @@ def test_release_check_preserves_cli_failure_reason(self): "Run 'gh auth login --hostname github.com'.", ]) @pytest.mark.parametrize("command,exit_code", [("check", 0), ("upgrade", 1)]) - def test_release_commands_preserve_cli_failure(self, detail, command, exit_code): + def test_release_commands_preserve_cli_failure( + self, detail, command, exit_code, uv_tool_argv0 + ): from unittest.mock import patch from typer.testing import CliRunner from specify_cli import app, _version @@ -1716,8 +1718,6 @@ def test_release_commands_preserve_cli_failure(self, detail, command, exit_code) ) with patch.object( GitHubAuth, "_acquire_via_gh_cli", side_effect=error, - ), patch.object( - _version, "_detect_install_method", return_value=_version._InstallMethod.UV_TOOL, ), patch.object( _version, "_get_installed_version", return_value="1.0.0", ), patch( diff --git a/tests/specify_cli/selfs/test_command_upgrade.py b/tests/specify_cli/selfs/test_command_upgrade.py index 5a39a9ddb7..cfb03d640b 100644 --- a/tests/specify_cli/selfs/test_command_upgrade.py +++ b/tests/specify_cli/selfs/test_command_upgrade.py @@ -1,5 +1,7 @@ """Command tests for ``specify self upgrade``.""" +import subprocess +import sys from unittest.mock import patch import pytest @@ -8,11 +10,149 @@ 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, request, method, name, absolute, dry_run +): + request.getfixturevalue("uv_tool_argv0" if method == "uv-tool" else "pipx_argv0") + # 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}" + + with 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: + 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 + 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() + + +@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 + assert "Run from a directory without a matching installer executable" in output + mock_run.assert_not_called() + + +@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]) +@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")]) + 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._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, *probe_args], + returncode=0, + stdout=probe_output, + 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 + output = strip_ansi(result.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, *probe_args] + + 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 +162,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 +173,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 +185,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 +198,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 +211,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 +224,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..8235995b12 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,291 @@ def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self): assert result == Path("/broken/specify") +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")], + ) + @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: + 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) + + 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] + 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( + ("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]) + + 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"), + [(_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) + + 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, method, name + ): + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + decoy = project / name + decoy.write_text("#!/bin/sh\n", encoding="utf-8") + decoy.chmod(0o755) + installer = tmp_path / name + installer.symlink_to(decoy) + + 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") + + 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 +618,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 +635,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 +658,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 +680,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 +698,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 +715,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 +730,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 +751,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 +766,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 +788,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 +808,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 +826,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 +843,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 +896,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 +923,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 +948,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 +977,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, @@ -733,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"), @@ -801,7 +1119,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 +1138,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 +1159,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 +1195,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" ):