diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index bcfe42caa6..4720fd155c 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -17,6 +17,7 @@ import re import shlex import shutil +import stat import subprocess import sys import urllib.error @@ -24,13 +25,14 @@ import urllib.request from dataclasses import dataclass from enum import Enum -from pathlib import Path +from pathlib import Path, PurePath from typing import Literal, overload import typer from packaging.version import InvalidVersion, Version from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited from ._console import console +from .file_helper import _strip_extended_length_prefix GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" _RESOLUTION_FAILURE_OFFLINE = "offline or timeout" @@ -51,6 +53,7 @@ ) _TIER3_REGISTRY_TIMEOUT_SECS = 5 _VERIFY_TIMEOUT_SECS = 10 +_WINDOWS_MOUNT_POINT_TAG = 0xA0000003 # IO_REPARSE_TAG_MOUNT_POINT def _get_installed_version() -> str: @@ -367,17 +370,13 @@ def _expand_prefix(prefix: str) -> Path | None: return None -def _path_is_within_prefix(path: Path, prefix: Path) -> bool: +def _path_is_within_prefix(path: PurePath, prefix: PurePath) -> bool: """Return whether absolute `path` is under absolute `prefix`.""" if not path.is_absolute() or not prefix.is_absolute(): return False - try: - common = os.path.commonpath( - [os.path.normcase(str(path)), os.path.normcase(str(prefix))] - ) - except ValueError: - return False - return common == os.path.normcase(str(prefix)) + return _strip_extended_length_prefix(path).is_relative_to( + _strip_extended_length_prefix(prefix) + ) def _resolve_path_or_original(path: Path) -> Path: @@ -490,37 +489,129 @@ def _editable_marker_seen() -> bool: 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( + """Find an installer outside the working directory and current Git tree.""" + context: tuple[os.stat_result, os.stat_result | None] | None = None + rejections: list[str] = [] + + def rejected_lookup(resolved: str) -> str: + if not Path(resolved).is_absolute(): + return ( 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." + return ( + f"Installer {name} rejected: lookup or target is not trusted ({resolved!r}). " + "Run from another directory and ensure PATH points to the installed tool." + ) + + def eligible(path: Path, *, executable: bool) -> bool: + nonlocal context + if context is None: + cwd = Path.cwd().resolve() + project_root = _git_ancestor(cwd) + context = ( + cwd.stat(), + project_root.stat() if project_root is not None else None, + ) + cwd_status, project_status = context + # Check that traversal terminates before inspecting intermediate links. + path.resolve(strict=True) + if not executable and os.path.samestat(path.stat(), cwd_status): + return False + pending = [(path, executable)] + seen: set[tuple[Path, bool]] = set() + while pending: + current, leaf_executable = pending.pop() + if leaf_executable and os.path.samestat(current.parent.stat(), cwd_status): + return False + for ancestor in (current, *current.parents): + is_executable = leaf_executable and ancestor == current + key = (ancestor, is_executable) + if key in seen: + continue + seen.add(key) + entry_status = ancestor.lstat() + is_link = stat.S_ISLNK(entry_status.st_mode) + is_reparse = bool( + getattr(entry_status, "st_file_attributes", 0) + & stat.FILE_ATTRIBUTE_REPARSE_POINT + ) + is_junction = ( + is_reparse + and stat.S_ISDIR(entry_status.st_mode) + and getattr(entry_status, "st_reparse_tag", 0) + == _WINDOWS_MOUNT_POINT_TAG + ) + if is_reparse and not (is_link or is_junction): + return False + if project_status is not None and os.path.samestat( + entry_status, project_status + ): + return False + if is_link or is_junction: + if os.path.samestat(ancestor.parent.stat(), cwd_status): + return False + target = ancestor.readlink() + pending.append( + ( + target + if target.is_absolute() + else ancestor.parent / target, + is_executable, + ) + ) + return True + + suffixes = [""] + if sys.platform == "win32": + # Python 3.11's which() does not expand PATHEXT for absolute commands. + suffixes = [ + suffix + for suffix in (os.environ.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD").split( + ";" ) - 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 + if suffix + ] + + for entry in os.get_exec_path(): + if sys.platform == "win32": + entry = entry.strip('"') + directory = Path(entry) + if not directory.is_absolute(): + if entry: + for suffix in suffixes: + candidate = str(directory / f"{name}{suffix}") + if os.path.lexists(candidate): + rejections.append(rejected_lookup(candidate)) + continue + try: + for suffix in suffixes: + found = shutil.which(str(directory / f"{name}{suffix}")) + if found is not None: + executable = Path(found) + if ( + executable.is_absolute() + and eligible(directory, executable=False) + and eligible(executable, executable=True) + ): + return str(executable) + rejections.append(rejected_lookup(found)) + except (OSError, RuntimeError) as exc: + rejections.append( + f"Installer {name} rejected: could not validate its path ({exc}). " + "Run from an existing, accessible working directory and check PATH." + ) + console.print( + f"Skipping installer PATH entry {directory} while finding {name}: {exc}", + style="yellow", + markup=False, + soft_wrap=True, + ) + if rejections: + raise _InstallerRejected(rejections[0]) + return None @overload diff --git a/tests/conftest.py b/tests/conftest.py index 6fe0c1e2f7..58957ac744 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -176,6 +176,19 @@ def clean_environ(monkeypatch): monkeypatch.delenv("GITHUB_TOKEN", raising=False) +@pytest.fixture +def installer_paths(tmp_path): + bin_dir = tmp_path / "installer-bin" + bin_dir.mkdir() + paths = {} + for name in ("uv", "pipx"): + executable = bin_dir / (f"{name}.exe" if os.name == "nt" else name) + executable.write_bytes(b"installer fixture; never executed\n") + executable.chmod(0o755) + paths[name] = str(executable) + return paths + + def _fake_self_upgrade_argv0(monkeypatch, tmp_path, env_name, path_parts): """Create a fake executable under tmp_path and point sys.argv[0] at it.""" monkeypatch.setenv(env_name, str(tmp_path)) diff --git a/tests/specify_cli/self_upgrade_helpers.py b/tests/specify_cli/self_upgrade_helpers.py index fc0f339f92..a68f13b37c 100644 --- a/tests/specify_cli/self_upgrade_helpers.py +++ b/tests/specify_cli/self_upgrade_helpers.py @@ -12,6 +12,7 @@ from specify_cli._version import ( _InstallMethod, + _DetectionSignals, _UpgradePlan, _assemble_installer_argv, _detect_install_method, @@ -28,6 +29,7 @@ "_assemble_installer_argv", "_completed_process", "_detect_install_method", + "_detection_result", "_verify_upgrade", "mock_urlopen_response", "requires_posix", @@ -53,6 +55,17 @@ SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" +def _detection_result(method: _InstallMethod) -> tuple[_InstallMethod, _DetectionSignals]: + return method, _DetectionSignals( + sys_argv0="", + matched_tier=None, + matched_prefix=None, + editable_marker_seen=False, + installer_registries_consulted=(), + resolved_method=method, + ) + + def _completed_process( returncode: int, stdout: str = "", stderr: str = "" ) -> subprocess.CompletedProcess: diff --git a/tests/specify_cli/selfs/test_command_upgrade.py b/tests/specify_cli/selfs/test_command_upgrade.py index cfb03d640b..d0f22622b4 100644 --- a/tests/specify_cli/selfs/test_command_upgrade.py +++ b/tests/specify_cli/selfs/test_command_upgrade.py @@ -2,6 +2,7 @@ import subprocess import sys +from pathlib import Path from unittest.mock import patch import pytest @@ -57,7 +58,7 @@ def test_registry_probe_rejection_guidance_is_preserved( 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, + side_effect=lambda command: f"./{name}" if Path(command).stem == name else None, ), patch( "specify_cli._version._editable_marker_seen", return_value=False ), patch("specify_cli._version.subprocess.run") as mock_run: @@ -104,7 +105,7 @@ def test_deleted_working_directory_gets_rejection_guidance( @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, tmp_path, dry_run, rejected_name, installer_paths ): monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) other_name = "pipx" if rejected_name == "uv" else "uv" @@ -114,10 +115,11 @@ def test_rejected_probe_prevents_selecting_a_different_installer( ) def fake_which(name): + name = Path(name).stem if name == rejected_name: return f"./{name}" if name == other_name: - return sys.executable + return installer_paths[other_name] return None with patch( @@ -128,7 +130,7 @@ 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, *probe_args], + args=[installer_paths[other_name], *probe_args], returncode=0, stdout=probe_output, stderr="", @@ -145,15 +147,18 @@ def fake_which(name): 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] + assert mock_run.call_args.args[0] == [installer_paths[other_name], *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=sys.executable), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" + def test_valid_stable_tag(self, installer_paths, uv_tool_argv0, clean_environ): + with ( + patch( + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] + ), + patch("specify_cli._version._get_installed_version", return_value="0.7.5"), ): result = runner.invoke( app, @@ -161,9 +166,12 @@ 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=sys.executable), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" + def test_valid_dev_suffix_tag(self, installer_paths, uv_tool_argv0, clean_environ): + with ( + patch( + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] + ), + patch("specify_cli._version._get_installed_version", return_value="0.7.5"), ): result = runner.invoke( app, @@ -172,9 +180,12 @@ def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ): assert result.exit_code == 0 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=sys.executable), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" + def test_valid_rc_tag(self, installer_paths, uv_tool_argv0, clean_environ): + with ( + patch( + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] + ), + patch("specify_cli._version._get_installed_version", return_value="0.7.5"), ): result = runner.invoke( app, @@ -183,10 +194,15 @@ def test_valid_rc_tag(self, uv_tool_argv0, clean_environ): assert result.exit_code == 0 def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( - "specify_cli._version._get_installed_version", return_value="1.0.0b1" + with ( + patch( + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] + ), + patch( + "specify_cli._version._get_installed_version", return_value="1.0.0b1" + ), ): result = runner.invoke( app, @@ -197,9 +213,14 @@ def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop( result.output ) - def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): - with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" + def test_valid_build_metadata_tag( + self, installer_paths, uv_tool_argv0, clean_environ + ): + with ( + patch( + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] + ), + patch("specify_cli._version._get_installed_version", return_value="0.7.5"), ): result = runner.invoke( app, @@ -209,10 +230,13 @@ def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ): assert "Target version: v0.8.0+build.42" in strip_ansi(result.output) def test_uppercase_v_prefix_is_folded_to_lowercase( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" + with ( + patch( + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] + ), + patch("specify_cli._version._get_installed_version", return_value="0.7.5"), ): result = runner.invoke( app, @@ -222,10 +246,13 @@ def test_uppercase_v_prefix_is_folded_to_lowercase( assert "Target version: v0.7.6" in strip_ansi(result.output) def test_valid_prerelease_with_build_metadata_tag( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( - "specify_cli._version._get_installed_version", return_value="0.7.5" + with ( + patch( + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] + ), + patch("specify_cli._version._get_installed_version", return_value="0.7.5"), ): result = runner.invoke( app, diff --git a/tests/specify_cli/test_version_detection.py b/tests/specify_cli/test_version_detection.py index 8235995b12..a295ebc88e 100644 --- a/tests/specify_cli/test_version_detection.py +++ b/tests/specify_cli/test_version_detection.py @@ -3,9 +3,11 @@ import importlib.metadata import json import os +import stat import subprocess import sys -from pathlib import Path +from pathlib import Path, PureWindowsPath +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -19,6 +21,7 @@ _assemble_installer_argv, _completed_process, _detect_install_method, + _detection_result, mock_urlopen_response, runner, strip_ansi, @@ -85,16 +88,18 @@ def test_prefix_match_does_not_accept_sibling_directory(self, monkeypatch, tmp_p def test_tier3_uv_tool_when_registry_lists_exact_name( self, + installer_paths, monkeypatch, tmp_path, ): monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) def fake_which(name): - return sys.executable if name == "uv" else None + name = Path(name).stem if Path(name).is_absolute() else name + return installer_paths["uv"] if name == "uv" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == [sys.executable, "tool", "list"]: + if argv[:3] == [installer_paths["uv"], "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -113,11 +118,12 @@ def fake_run(argv, *args, **kwargs): assert signals.matched_tier == 3 assert "uv tool list" in signals.installer_registries_consulted - def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch): + def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, installer_paths, monkeypatch): monkeypatch.setattr("sys.argv", ["specify"]) def fake_which(name): - return sys.executable if name == "uv" else None + name = Path(name).stem if Path(name).is_absolute() else name + return installer_paths["uv"] if name == "uv" else None def fake_run(argv, *args, **kwargs): return subprocess.CompletedProcess( @@ -135,20 +141,21 @@ def fake_run(argv, *args, **kwargs): assert signals.installer_registries_consulted == () def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection( - self, monkeypatch, tmp_path + self, installer_paths, monkeypatch, tmp_path ): missing_specify = tmp_path / "missing" / "specify" monkeypatch.setattr("sys.argv", ["specify"]) def fake_which(name): + name = Path(name).stem if Path(name).is_absolute() else name if name == "specify": return str(missing_specify) if name == "uv": - return sys.executable + return installer_paths["uv"] return None def fake_run(argv, *args, **kwargs): - if argv[:3] == [sys.executable, "tool", "list"]: + if argv[:3] == [installer_paths["uv"], "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -194,13 +201,15 @@ def fake_which(name): def test_tier3_uv_tool_ignores_substring_false_positive( self, + installer_paths, unsupported_argv0, ): def fake_which(name): - return sys.executable if name == "uv" else None + name = Path(name).stem if Path(name).is_absolute() else name + return installer_paths["uv"] if name == "uv" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == [sys.executable, "tool", "list"]: + if argv[:3] == [installer_paths["uv"], "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -219,13 +228,15 @@ def fake_run(argv, *args, **kwargs): def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint( self, + installer_paths, unsupported_argv0, ): def fake_which(name): - return sys.executable if name == "uv" else None + name = Path(name).stem if Path(name).is_absolute() else name + return installer_paths["uv"] if name == "uv" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == [sys.executable, "tool", "list"]: + if argv[:3] == [installer_paths["uv"], "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -244,6 +255,7 @@ def fake_run(argv, *args, **kwargs): def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint( self, + installer_paths, monkeypatch, tmp_path, ): @@ -255,14 +267,15 @@ def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint( monkeypatch.setattr("sys.argv", ["specify"]) def fake_which(name): + name = Path(name).stem if Path(name).is_absolute() else name if name == "specify": return str(fake_specify) if name == "uv": - return sys.executable + return installer_paths["uv"] return None def fake_run(argv, *args, **kwargs): - if argv[:3] == [sys.executable, "tool", "list"]: + if argv[:3] == [installer_paths["uv"], "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -377,7 +390,7 @@ def test_case_alias_of_working_directory_is_rejected( ("operation", "error"), [ ("cwd", FileNotFoundError("working directory was deleted")), - ("samefile", PermissionError("cannot stat installer directory")), + ("stat", PermissionError("cannot stat installer directory")), ("resolve", RuntimeError("symlink loop")), ], ) @@ -386,7 +399,7 @@ def test_path_comparison_errors_are_rejected( ): with patch( "specify_cli._version.shutil.which", - side_effect=lambda command: sys.executable if command == name else None, + side_effect=lambda command: sys.executable if Path(command).stem == name else None, ), patch( "specify_cli._version._resolved_argv0_path", return_value=tmp_path / "missing" / "specify", @@ -394,6 +407,8 @@ def test_path_comparison_errors_are_rejected( "specify_cli._version._expand_prefix", return_value=None ), patch( "specify_cli._version._editable_marker_seen", return_value=False + ), patch( + "specify_cli._version._tier3_registry_lookup_allowed", return_value=True ), patch( "specify_cli._version.subprocess.run" ) as mock_run, patch.object( @@ -455,7 +470,7 @@ def test_unsafe_lookup_result_is_not_used_for_upgrade_or_registry_probe( with patch( "specify_cli._version.shutil.which", - side_effect=lambda command: paths[location] if command == name else None, + side_effect=lambda command: paths[location] if Path(command).stem == name else None, ), patch( "specify_cli._version._editable_marker_seen", return_value=False ), patch("specify_cli._version.subprocess.run") as mock_run: @@ -604,11 +619,11 @@ def test_absolute_symlink_to_working_directory_installer_is_rejected( class TestArgvAssemblyUvTool: """uv-tool installer argv shape.""" - def test_stable_tag_produces_expected_argv(self): - with patch("specify_cli._version.shutil.which", return_value=sys.executable): + def test_stable_tag_produces_expected_argv(self, installer_paths): + with patch("specify_cli._version.shutil.which", return_value=installer_paths["uv"]): argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") assert argv == [ - sys.executable, + installer_paths["uv"], "tool", "install", "specify-cli", @@ -617,8 +632,8 @@ def test_stable_tag_produces_expected_argv(self): "git+https://github.com/github/spec-kit.git@v0.7.6", ] - def test_dev_suffix_tag_embedded_literally(self): - with patch("specify_cli._version.shutil.which", return_value=sys.executable): + def test_dev_suffix_tag_embedded_literally(self, installer_paths): + with patch("specify_cli._version.shutil.which", return_value=installer_paths["uv"]): 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 ( @@ -630,12 +645,832 @@ def test_missing_uv_returns_no_installer_argv(self): assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None +class TestWindowsPathContainment: + @pytest.mark.parametrize("unc", [False, True]) + @pytest.mark.parametrize("root_extended", [False, True]) + @pytest.mark.parametrize("target_extended", [False, True]) + @pytest.mark.parametrize("location", ["root", "child", "sibling"]) + def test_namespace_spellings_share_containment( + self, unc, root_extended, target_extended, location + ): + plain = r"\\server\share\project" if unc else r"C:\project" + extended = r"\\?\UNC\server\share\project" if unc else r"\\?\C:\project" + root = PureWindowsPath(extended if root_extended else plain) + target = PureWindowsPath(extended if target_extended else plain) + if location == "child": + target /= "bin" + elif location == "sibling": + target = target.with_name("project-sibling") / "bin" + + assert specify_cli._version._path_is_within_prefix(target, root) is ( + location != "sibling" + ) + + +class TestInstallerLookupSecurity: + @staticmethod + def executable(directory, name): + executable = directory / (f"{name}.exe" if os.name == "nt" else name) + executable.write_bytes(b"installer lookup fixture; never executed\n") + executable.chmod(0o755) + return executable + + @staticmethod + def junction(path, target): + result = subprocess.run( + ["cmd.exe", "/c", "mklink", "/J", str(path), str(target)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode: + pytest.skip( + f"Windows junction creation unavailable: {result.stderr or result.stdout}" + ) + + @pytest.mark.parametrize( + "method,name", + [(_InstallMethod.UV_TOOL, "uv"), (_InstallMethod.PIPX, "pipx")], + ) + @pytest.mark.parametrize( + "kind", + [ + "aliased-directory", + "directory-target", + "file", + "file-aliased-directory", + "cwd-file-chain", + "cwd-directory-chain", + ], + ) + @pytest.mark.parametrize("operation", ["lookup", "registry", "upgrade"]) + @pytest.mark.parametrize("fallback", [False, True]) + def test_symlink_bypasses_are_rejected( + self, monkeypatch, tmp_path, method, name, kind, operation, fallback + ): + project = tmp_path / "project" + project.mkdir() + if kind not in {"cwd-file-chain", "cwd-directory-chain"}: + (project / ".git").mkdir() + external = tmp_path / "external" + external.mkdir() + trusted = tmp_path / "trusted" + trusted.mkdir() + expected = self.executable(trusted, name) + try: + if kind == "cwd-directory-chain": + payload_dir = tmp_path / "payload" + payload_dir.mkdir() + self.executable(payload_dir, name) + (project / "bin").symlink_to(payload_dir, target_is_directory=True) + unsafe = external / "alias" + unsafe.symlink_to(project / "bin", target_is_directory=True) + elif kind == "cwd-file-chain": + payload_dir = tmp_path / "payload" + payload_dir.mkdir() + payload = self.executable(payload_dir, name) + malicious = project / payload.name + malicious.symlink_to(payload) + (external / payload.name).symlink_to(malicious) + unsafe = external + elif kind != "file": + self.executable(external, name) + (project / "bin").symlink_to(external, target_is_directory=True) + alias = tmp_path / "alias" + alias.symlink_to(project, target_is_directory=True) + if kind == "aliased-directory": + unsafe = alias / "bin" + elif kind == "directory-target": + unsafe = tmp_path / "directory-target" + unsafe.symlink_to(project / "bin", target_is_directory=True) + else: + unsafe = tmp_path / "file-bin" + unsafe.mkdir() + executable_name = f"{name}.exe" if os.name == "nt" else name + (unsafe / executable_name).symlink_to( + alias / "bin" / executable_name + ) + else: + malicious = self.executable(project, name) + (external / malicious.name).symlink_to(malicious) + unsafe = external + except OSError: + pytest.skip("symlinks are unavailable") + monkeypatch.chdir(project) + monkeypatch.setenv( + "PATH", + os.pathsep.join([str(unsafe), str(trusted)] if fallback else [str(unsafe)]), + ) + monkeypatch.setattr("sys.argv", [str(project / "missing" / "specify")]) + stdout = ( + "specify-cli v0.7.6\n" if name == "uv" else '{"venvs":{"specify-cli":{}}}' + ) + + with ( + 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._verify_upgrade", return_value="0.7.6"), + patch( + "specify_cli._version.subprocess.run", + return_value=_completed_process(0, stdout=stdout), + ) as run, + ): + if operation == "lookup": + if fallback: + argv = _assemble_installer_argv(method, "v0.7.6") + assert argv is not None + assert Path(argv[0]) == expected + else: + with pytest.raises(specify_cli._version._InstallerRejected): + _assemble_installer_argv(method, "v0.7.6") + run.assert_not_called() + return + if operation == "registry": + detected = _detect_install_method() + assert detected == (method if fallback else _InstallMethod.UNSUPPORTED) + else: + with patch( + "specify_cli._version._detect_install_method", return_value=_detection_result(method) + ): + result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.6"]) + assert result.exit_code == (0 if fallback else 3) + if not fallback: + assert f"Installer {name} rejected" in strip_ansi( + result.output + ) + + if fallback: + run.assert_called_once() + assert Path(run.call_args.args[0][0]) == expected + else: + run.assert_not_called() + + @pytest.mark.parametrize("name", ["uv", "pipx"]) + @pytest.mark.parametrize("operation", ["lookup", "upgrade"]) + def test_external_file_symlink_preserves_dispatch_name( + self, monkeypatch, tmp_path, name, operation + ): + project = tmp_path / "project" + project.mkdir() + (project / ".git").mkdir() + installed = tmp_path / "installed" + installed.mkdir() + dispatcher = self.executable(installed, "dispatcher") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + selected = bin_dir / (f"{name}.exe" if os.name == "nt" else name) + try: + selected.symlink_to(dispatcher) + except OSError: + pytest.skip("file symlinks are unavailable") + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", str(bin_dir)) + + if operation == "lookup": + found = specify_cli._version._find_installer(name) + assert found is not None + assert Path(found) == selected + else: + method = _InstallMethod.UV_TOOL if name == "uv" else _InstallMethod.PIPX + with ( + patch( + "specify_cli._version._detect_install_method", return_value=_detection_result(method) + ), + patch( + "specify_cli._version._get_installed_version", return_value="0.7.5" + ), + patch("specify_cli._version._verify_upgrade", return_value="0.7.6"), + patch( + "specify_cli._version.subprocess.run", + return_value=_completed_process(0), + ) as run, + ): + result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.6"]) + assert result.exit_code == 0 + assert Path(run.call_args.args[0][0]) == selected + + @pytest.mark.parametrize("name", ["uv", "pipx"]) + @pytest.mark.parametrize("git_project", [False, True]) + @pytest.mark.parametrize("fallback", [False, True]) + def test_case_aliases_are_rejected_on_case_insensitive_volumes( + self, monkeypatch, tmp_path, name, git_project, fallback + ): + project = tmp_path / "project" + project.mkdir() + alias = project.with_name("PROJECT") + if not alias.exists() or not alias.samefile(project): + pytest.skip("case-insensitive volume required") + unsafe = project + if git_project: + (project / ".git").mkdir() + unsafe = project / "bin" + unsafe.mkdir() + self.executable(unsafe, name) + trusted = tmp_path / "trusted" + trusted.mkdir() + expected = self.executable(trusted, name) + monkeypatch.chdir(project) + entry = alias / "bin" if git_project else alias + monkeypatch.setenv( + "PATH", + os.pathsep.join([str(entry), str(trusted)] if fallback else [str(entry)]), + ) + + if fallback: + found = specify_cli._version._find_installer(name) + assert found is not None + assert Path(found) == expected + else: + with pytest.raises(specify_cli._version._InstallerRejected): + specify_cli._version._find_installer(name) + + @pytest.mark.parametrize("name", ["uv", "pipx"]) + @pytest.mark.parametrize("stage", ["resolve", "lstat"]) + @pytest.mark.parametrize( + "error", [PermissionError("denied"), RuntimeError("link loop")] + ) + def test_inspection_failure_warns_and_checks_later_entries( + self, monkeypatch, tmp_path, capsys, name, stage, error + ): + project = tmp_path / "project" + project.mkdir() + (project / ".git").mkdir() + bad = tmp_path / "bad" + bad.mkdir() + self.executable(bad, name) + trusted = tmp_path / "trusted" + trusted.mkdir() + expected = self.executable(trusted, name) + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", os.pathsep.join([str(bad), str(trusted)])) + original = getattr(Path, stage) + + def inspect(path, *args, **kwargs): + if path == bad: + raise error + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, stage, inspect) + + found = specify_cli._version._find_installer(name) + assert found is not None + assert Path(found) == expected + output = capsys.readouterr().out + assert "Skipping installer PATH entry" in output + assert str(bad) in output + assert str(error) in output + + @pytest.mark.parametrize("name", ["uv", "pipx"]) + @pytest.mark.parametrize("tag", [0, 0x8000001B]) + def test_non_symlink_reparse_entries_are_rejected_portably( + self, monkeypatch, tmp_path, name, tag + ): + project = tmp_path / "project" + project.mkdir() + (project / ".git").mkdir() + reparse = tmp_path / "reparse" + reparse.mkdir() + self.executable(reparse, name) + trusted = tmp_path / "trusted" + trusted.mkdir() + expected = self.executable(trusted, name) + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", os.pathsep.join([str(reparse), str(trusted)])) + original = Path.lstat + + def lstat(path, *args, **kwargs): + result = original(path, *args, **kwargs) + if path == reparse: + return SimpleNamespace( + st_mode=result.st_mode, + st_file_attributes=0x400, + st_reparse_tag=tag, + st_ino=result.st_ino, + st_dev=result.st_dev, + ) + return result + + monkeypatch.setattr(Path, "lstat", lstat) + + found = specify_cli._version._find_installer(name) + assert found is not None + assert Path(found) == expected + + @pytest.mark.parametrize("name", ["uv", "pipx"]) + @pytest.mark.parametrize("operation", ["lookup", "registry", "upgrade"]) + @pytest.mark.parametrize( + "native", + [ + False, + pytest.param( + True, + marks=pytest.mark.skipif( + os.name != "nt", reason="Native Windows junctions required" + ), + ), + ], + ) + def test_external_junction_preserves_installer_paths( + self, monkeypatch, tmp_path, name, operation, native + ): + project = tmp_path / "project" + project.mkdir() + (project / ".git").mkdir() + install = tmp_path / "install" + install.mkdir() + version = install / "version" + version.mkdir() + scripts = version / "Scripts" + scripts.mkdir() + executable = self.executable(scripts, name) + current = install / "current" + if native: + self.junction(current, version) + assert not current.is_symlink() + else: + try: + current.symlink_to(version, target_is_directory=True) + except OSError: + pytest.skip("directory symlinks are unavailable") + original = Path.lstat + + def lstat(path, *args, **kwargs): + result = original(path, *args, **kwargs) + if path == current: + return SimpleNamespace( + st_mode=stat.S_IFDIR | 0o755, + st_file_attributes=stat.FILE_ATTRIBUTE_REPARSE_POINT, + st_reparse_tag=0xA0000003, + st_ino=result.st_ino, + st_dev=result.st_dev, + ) + return result + + monkeypatch.setattr(Path, "lstat", lstat) + selected = current / "Scripts" / executable.name + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", str(selected.parent)) + monkeypatch.setattr("sys.argv", [str(project / "missing" / "specify")]) + method = _InstallMethod.UV_TOOL if name == "uv" else _InstallMethod.PIPX + stdout = ( + "specify-cli v0.7.6\n" if name == "uv" else '{"venvs":{"specify-cli":{}}}' + ) + + with ( + 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._verify_upgrade", return_value="0.7.6"), + patch( + "specify_cli._version.subprocess.run", + return_value=_completed_process(0, stdout=stdout), + ) as run, + ): + if operation == "lookup": + argv = _assemble_installer_argv(method, "v0.7.6") + assert argv is not None + assert Path(argv[0]) == selected + run.assert_not_called() + return + if operation == "registry": + assert _detect_install_method() == method + else: + with patch( + "specify_cli._version._detect_install_method", return_value=_detection_result(method) + ): + result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.6"]) + assert result.exit_code == 0 + run.assert_called_once() + assert Path(run.call_args.args[0][0]) == selected + + @pytest.mark.skipif( + os.name != "nt", reason="Native Windows namespace paths required" + ) + @pytest.mark.parametrize("name", ["uv", "pipx"]) + @pytest.mark.parametrize("git_project", [False, True]) + @pytest.mark.parametrize("fallback", [False, True]) + def test_native_windows_extended_path_entries_are_rejected( + self, monkeypatch, tmp_path, name, git_project, fallback + ): + project = tmp_path / "project" + project.mkdir() + unsafe = project + if git_project: + (project / ".git").mkdir() + unsafe = project / "bin" + unsafe.mkdir() + self.executable(unsafe, name) + trusted = tmp_path / "trusted" + trusted.mkdir() + expected = self.executable(trusted, name) + raw = str(unsafe) + extended = ( + "\\\\?\\UNC\\" + raw[2:] if raw.startswith("\\\\") else "\\\\?\\" + raw + ) + monkeypatch.chdir(project) + monkeypatch.setenv( + "PATH", + os.pathsep.join([extended, str(trusted)] if fallback else [extended]), + ) + + if fallback: + found = specify_cli._version._find_installer(name) + assert found is not None + assert Path(found) == expected + else: + with pytest.raises(specify_cli._version._InstallerRejected): + specify_cli._version._find_installer(name) + + @pytest.mark.skipif(os.name != "nt", reason="Native Windows junctions required") + @pytest.mark.parametrize("name", ["uv", "pipx"]) + @pytest.mark.parametrize("fallback", [False, True]) + @pytest.mark.parametrize("git_project", [False, True]) + def test_native_windows_junction_chain_is_rejected( + self, monkeypatch, tmp_path, name, fallback, git_project + ): + project = tmp_path / "project" + project.mkdir() + if git_project: + (project / ".git").mkdir() + external = tmp_path / "external" + external.mkdir() + self.executable(external, name) + trusted = tmp_path / "trusted" + trusted.mkdir() + expected = self.executable(trusted, name) + alias = tmp_path / "alias" + for path, target in ((project / "bin", external), (alias, project / "bin")): + self.junction(path, target) + assert not alias.is_symlink() + monkeypatch.chdir(project) + monkeypatch.setenv( + "PATH", + os.pathsep.join([str(alias), str(trusted)] if fallback else [str(alias)]), + ) + + if fallback: + found = specify_cli._version._find_installer(name) + assert found is not None + assert Path(found) == expected + else: + with pytest.raises(specify_cli._version._InstallerRejected): + specify_cli._version._find_installer(name) + + @pytest.mark.parametrize("name", ["uv", "pipx"]) + def test_relative_lookup_result_is_rejected(self, monkeypatch, tmp_path, name): + project = tmp_path / "project" + project.mkdir() + trusted = tmp_path / "trusted" + trusted.mkdir() + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", str(trusted)) + with patch("specify_cli._version.shutil.which", return_value=name): + with pytest.raises(specify_cli._version._InstallerRejected): + specify_cli._version._find_installer(name) + + @pytest.mark.parametrize("name", ["uv", "pipx"]) + def test_file_symlink_to_non_project_cwd_is_rejected( + self, monkeypatch, tmp_path, name + ): + project = tmp_path / "project" + project.mkdir() + malicious = self.executable(project, name) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + try: + (bin_dir / malicious.name).symlink_to(malicious) + except OSError: + pytest.skip("file symlinks are unavailable") + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", str(bin_dir)) + + with pytest.raises(specify_cli._version._InstallerRejected): + specify_cli._version._find_installer(name) + + @pytest.mark.parametrize( + "method,name", + [ + (_InstallMethod.UV_TOOL, "uv"), + (_InstallMethod.PIPX, "pipx"), + ], + ) + @pytest.mark.parametrize( + "entry", ["", ".", "bin", "absolute", "symlink", "symlink-out"] + ) + def test_project_path_entries_cannot_override_installer( + self, monkeypatch, tmp_path, method, name, entry + ): + project = tmp_path / "project" + project.mkdir() + (project / ".git").mkdir() + project_bin = project / "bin" + project_bin.mkdir() + trusted = tmp_path / "trusted" + trusted.mkdir() + self.executable(project, name) + self.executable(project_bin, name) + expected = self.executable(trusted, name) + if entry == "absolute": + entry = str(project_bin) + elif entry in {"symlink", "symlink-out"}: + link = tmp_path / "project-bin-link" + target = project_bin + if entry == "symlink-out": + link = project / "external-bin" + target = tmp_path / "untrusted" + target.mkdir() + self.executable(target, name) + try: + link.symlink_to(target, target_is_directory=True) + except OSError: + pytest.skip("directory symlinks are unavailable") + entry = str(link) + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", os.pathsep.join([entry, str(trusted)])) + + argv = _assemble_installer_argv(method, "v0.7.6") + + assert argv is not None + assert Path(argv[0]) == expected + + @pytest.mark.parametrize( + "method,name", + [ + (_InstallMethod.UV_TOOL, "uv"), + (_InstallMethod.PIPX, "pipx"), + ], + ) + def test_implicit_windows_cwd_search_is_bypassed( + self, monkeypatch, tmp_path, method, name + ): + project = tmp_path / "project" + project.mkdir() + trusted = tmp_path / "trusted" + trusted.mkdir() + malicious = self.executable(project, name) + expected = self.executable(trusted, name) + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", str(trusted)) + real_which = specify_cli._version.shutil.which + + def windows_style_which(command): + if not Path(command).is_absolute(): + return str(malicious) + return real_which(command) + + with patch( + "specify_cli._version.shutil.which", side_effect=windows_style_which + ): + argv = _assemble_installer_argv(method, "v0.7.6") + + assert argv is not None + assert Path(argv[0]) == expected + + @pytest.mark.parametrize( + "method,name", + [ + (_InstallMethod.UV_TOOL, "uv"), + (_InstallMethod.PIPX, "pipx"), + ], + ) + @pytest.mark.parametrize("path", ["", ".", "bin"]) + def test_only_project_local_installer_is_not_available( + self, monkeypatch, tmp_path, method, name, path + ): + (tmp_path / "bin").mkdir() + self.executable(tmp_path, name) + self.executable(tmp_path / "bin", name) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PATH", path) + + if not path: + assert _assemble_installer_argv(method, "v0.7.6") is None + else: + with pytest.raises(specify_cli._version._InstallerRejected): + _assemble_installer_argv(method, "v0.7.6") + + def test_registry_probes_do_not_execute_project_local_installers( + self, monkeypatch, tmp_path + ): + self.executable(tmp_path, "uv") + self.executable(tmp_path, "pipx") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) + + with ( + patch("specify_cli._version._editable_marker_seen", return_value=False), + patch("specify_cli._version.subprocess.run") as run, + ): + method, signals = _detect_install_method(include_signals=True) + + assert method == _InstallMethod.UNSUPPORTED + assert signals.installer_registries_consulted == () + run.assert_not_called() + + @pytest.mark.parametrize( + "method,name", + [ + (_InstallMethod.UV_TOOL, "uv"), + (_InstallMethod.PIPX, "pipx"), + ], + ) + def test_missing_safe_installer_fails_without_execution( + self, monkeypatch, tmp_path, method, name + ): + self.executable(tmp_path, name) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PATH", str(tmp_path)) + with ( + patch("specify_cli._version._detect_install_method", return_value=_detection_result(method)), + patch("specify_cli._version._get_installed_version", return_value="0.7.5"), + patch("specify_cli._version.subprocess.run") as run, + ): + result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.6"]) + + assert result.exit_code == 3 + assert f"Installer {name} rejected" in strip_ansi(result.output) + run.assert_not_called() + + @pytest.mark.parametrize( + "method,name", + [ + (_InstallMethod.UV_TOOL, "uv"), + (_InstallMethod.PIPX, "pipx"), + ], + ) + def test_absolute_path_order_is_preserved( + self, monkeypatch, tmp_path, method, name + ): + project = tmp_path / "project" + project.mkdir() + first = tmp_path / "first" + first.mkdir() + second = tmp_path / "second" + second.mkdir() + expected = self.executable(first, name) + self.executable(second, name) + monkeypatch.chdir(project) + monkeypatch.setenv( + "PATH", + os.pathsep.join([str(tmp_path / "missing"), str(first), str(second)]), + ) + + argv = _assemble_installer_argv(method, "v0.7.6") + + assert argv is not None + assert Path(argv[0]) == expected + + @pytest.mark.parametrize("name", ["uv", "pipx"]) + @pytest.mark.parametrize("pathext", [None, ".CMD;.EXE"]) + @pytest.mark.parametrize("quoted_path", [False, True]) + def test_windows_pathext_is_expanded_before_absolute_lookup( + self, monkeypatch, tmp_path, name, pathext, quoted_path + ): + project = tmp_path / "project" + project.mkdir() + trusted = tmp_path / "trusted" + trusted.mkdir() + expected = str(trusted / f"{name}.EXE") + Path(expected).write_bytes(b"installer fixture; never executed\n") + Path(expected).chmod(0o755) + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", f'"{trusted}"' if quoted_path else str(trusted)) + if pathext is None: + monkeypatch.delenv("PATHEXT", raising=False) + else: + monkeypatch.setenv("PATHEXT", pathext) + + with ( + patch("specify_cli._version.sys") as platform, + patch( + "specify_cli._version.shutil.which", + side_effect=lambda command: expected if command == expected else None, + ) as which, + ): + platform.platform = "win32" + found = specify_cli._version._find_installer(name) + + assert found == expected + assert [call.args[0] for call in which.call_args_list] == [ + str(trusted / f"{name}{suffix}") + for suffix in ([".COM", ".EXE"] if pathext is None else [".CMD", ".EXE"]) + ] + + @pytest.mark.parametrize( + "method,name", + [ + (_InstallMethod.UV_TOOL, "uv"), + (_InstallMethod.PIPX, "pipx"), + ], + ) + def test_registry_probes_use_absolute_installer_path( + self, monkeypatch, tmp_path, method, name + ): + project = tmp_path / "project" + project.mkdir() + trusted = tmp_path / "trusted" + trusted.mkdir() + self.executable(project, "uv") + self.executable(project, "pipx") + expected = self.executable(trusted, name) + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", os.pathsep.join([".", str(trusted)])) + monkeypatch.setattr("sys.argv", [str(project / "missing" / "specify")]) + stdout = ( + "specify-cli v0.7.6\n" if name == "uv" else '{"venvs":{"specify-cli":{}}}' + ) + + with ( + patch("specify_cli._version._editable_marker_seen", return_value=False), + patch( + "specify_cli._version.subprocess.run", + return_value=_completed_process(0, stdout=stdout), + ) as run, + ): + detected, signals = _detect_install_method(include_signals=True) + + assert detected == _InstallMethod.UNSUPPORTED + assert signals.installer_rejections + run.assert_called_once() + assert Path(run.call_args.args[0][0]) == expected + + @pytest.mark.parametrize( + "method,name", + [ + (_InstallMethod.UV_TOOL, "uv"), + (_InstallMethod.PIPX, "pipx"), + ], + ) + def test_upgrade_executes_selected_absolute_installer( + self, monkeypatch, tmp_path, method, name + ): + project = tmp_path / "project" + project.mkdir() + trusted = tmp_path / "trusted" + trusted.mkdir() + self.executable(project, name) + expected = self.executable(trusted, name) + monkeypatch.chdir(project) + monkeypatch.setenv("PATH", os.pathsep.join([".", str(trusted)])) + + with ( + patch("specify_cli._version._detect_install_method", return_value=_detection_result(method)), + patch("specify_cli._version._get_installed_version", return_value="0.7.5"), + patch("specify_cli._version._verify_upgrade", return_value="0.7.6"), + patch( + "specify_cli._version.subprocess.run", + return_value=_completed_process(0), + ) as run, + ): + result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.6"]) + + assert result.exit_code == 0 + run.assert_called_once() + argv = run.call_args.args[0] + assert Path(argv[0]) == expected + assert run.call_args.kwargs["shell"] is False + assert specify_cli._version._render_argv(argv) in strip_ansi(result.output) + + @pytest.mark.parametrize("name", ["uv", "pipx"]) + def test_user_bin_below_non_project_working_directory_is_allowed( + self, monkeypatch, tmp_path, name + ): + user_bin = tmp_path / ".local" / "bin" + user_bin.mkdir(parents=True) + expected = self.executable(user_bin, name) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PATH", str(user_bin)) + + found = specify_cli._version._find_installer(name) + + assert found is not None + assert Path(found) == expected + + @pytest.mark.parametrize("name", ["uv", "pipx"]) + def test_project_bin_is_rejected_from_nested_working_directory( + self, monkeypatch, tmp_path, name + ): + project = tmp_path / "project" + project.mkdir() + (project / ".git").write_text("gitdir: unused\n") + project_bin = project / "bin" + project_bin.mkdir() + nested = project / "nested" + nested.mkdir() + self.executable(project_bin, name) + monkeypatch.chdir(nested) + monkeypatch.setenv("PATH", str(project_bin)) + + with pytest.raises(specify_cli._version._InstallerRejected): + specify_cli._version._find_installer(name) + + class TestBareUpgradeUvTool: """uv-tool happy path, bare invocation.""" - def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): + def test_happy_path_end_to_end(self, installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -654,11 +1489,11 @@ def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ): for call in mock_run.call_args_list: assert call.kwargs.get("shell", False) is False - def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ): + def test_one_user_action_no_prompt(self, installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -675,12 +1510,12 @@ class TestAlreadyLatestUvTool: """already on latest, no installer launched.""" def test_already_latest_exits_zero_no_subprocess( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), 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"]) @@ -690,7 +1525,7 @@ def test_already_latest_exits_zero_no_subprocess( assert mock_run.call_count == 0 def test_trailing_zero_equivalent_version_reports_latest_not_newer( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): # Version("1.0") == Version("1.0.0") under packaging even though their # canonical strings differ. The no-op message must use Version equality @@ -698,7 +1533,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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), 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"]) @@ -710,12 +1545,12 @@ def test_trailing_zero_equivalent_version_reports_latest_not_newer( assert mock_run.call_count == 0 def test_dev_build_ahead_of_release_reports_newer_noop( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), 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"]) @@ -725,12 +1560,12 @@ def test_dev_build_ahead_of_release_reports_newer_noop( assert mock_run.call_count == 0 def test_unparseable_current_version_does_not_false_noop( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), 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 = [ @@ -746,12 +1581,12 @@ def test_unparseable_current_version_does_not_false_noop( assert mock_run.call_count == 2 def test_unparseable_resolved_target_fails_before_literal_noop( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), 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"]) @@ -764,9 +1599,9 @@ def test_unparseable_resolved_target_fails_before_literal_noop( assert mock_run.call_count == 0 def test_pinned_older_tag_still_runs_installer( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( + with patch("specify_cli._version.shutil.which", return_value=installer_paths["uv"]), patch( "specify_cli._version.subprocess.run" ) as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.6" @@ -786,9 +1621,9 @@ def test_pinned_older_tag_still_runs_installer( assert mock_run.call_count == 2 def test_pinned_rc_tag_uses_canonical_version_equality_for_noop( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): - with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( + with patch("specify_cli._version.shutil.which", return_value=installer_paths["uv"]), patch( "specify_cli._version._get_installed_version", return_value="1.0.0rc1" ): result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"]) @@ -802,13 +1637,14 @@ class TestDryRunUvTool: def test_dry_run_without_tag_resolves_network_but_no_subprocess( self, + installer_paths, uv_tool_argv0, clean_environ, ): 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), 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"]) @@ -822,11 +1658,11 @@ def test_dry_run_without_tag_resolves_network_but_no_subprocess( assert "Command that would be executed:" in out assert mock_run.call_count == 0 - def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ): + def test_dry_run_with_tag_skips_network(self, installer_paths, 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=sys.executable), patch( + ), patch("specify_cli._version.shutil.which", return_value=installer_paths["uv"]), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): result = runner.invoke( @@ -838,12 +1674,12 @@ def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ): mock_urlopen.assert_not_called() def test_dry_run_rejects_unparseable_network_tag_before_preview( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), 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"} @@ -890,16 +1726,18 @@ def test_posix_pipx_prefix_matches(self, pipx_argv0): def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it( self, + installer_paths, monkeypatch, tmp_path, ): monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) def fake_which(name): - return sys.executable if name == "pipx" else None + name = Path(name).stem if Path(name).is_absolute() else name + return installer_paths["pipx"] if name == "pipx" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == [sys.executable, "list", "--json"]: + if argv[:3] == [installer_paths["pipx"], "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -920,13 +1758,15 @@ def fake_run(argv, *args, **kwargs): def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint( self, + installer_paths, unsupported_argv0, ): def fake_which(name): - return sys.executable if name == "pipx" else None + name = Path(name).stem if Path(name).is_absolute() else name + return installer_paths["pipx"] if name == "pipx" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == [sys.executable, "list", "--json"]: + if argv[:3] == [installer_paths["pipx"], "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -945,13 +1785,15 @@ def fake_run(argv, *args, **kwargs): def test_tier3_pipx_ignores_malformed_json_output( self, + installer_paths, unsupported_argv0, ): def fake_which(name): - return sys.executable if name == "pipx" else None + name = Path(name).stem if Path(name).is_absolute() else name + return installer_paths["pipx"] if name == "pipx" else None def fake_run(argv, *args, **kwargs): - if argv[:3] == [sys.executable, "list", "--json"]: + if argv[:3] == [installer_paths["pipx"], "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -970,27 +1812,29 @@ def fake_run(argv, *args, **kwargs): def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported( self, + installer_paths, monkeypatch, tmp_path, ): monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) def fake_which(name): + name = Path(name).stem if Path(name).is_absolute() else name if name == "uv": - return sys.executable + return installer_paths["uv"] if name == "pipx": - return sys.executable + return installer_paths["pipx"] return None def fake_run(argv, *args, **kwargs): - if argv[:3] == [sys.executable, "tool", "list"]: + if argv[:3] == [installer_paths["uv"], "tool", "list"]: return subprocess.CompletedProcess( args=argv, returncode=0, stdout="specify-cli v0.7.6\n", stderr="", ) - if argv[:3] == [sys.executable, "list", "--json"]: + if argv[:3] == [installer_paths["pipx"], "list", "--json"]: return subprocess.CompletedProcess( args=argv, returncode=0, @@ -1013,7 +1857,7 @@ def fake_run(argv, *args, **kwargs): @pytest.mark.parametrize("rejected_name", ["uv", "pipx"]) def test_tier3_rejected_probe_leaves_ownership_ambiguous( - self, monkeypatch, tmp_path, rejected_name + self, monkeypatch, tmp_path, rejected_name, installer_paths ): monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")]) other_name = "pipx" if rejected_name == "uv" else "uv" @@ -1023,10 +1867,11 @@ def test_tier3_rejected_probe_leaves_ownership_ambiguous( ) def fake_which(name): + name = Path(name).stem if name == rejected_name: return f"./{name}" if name == other_name: - return sys.executable + return installer_paths[other_name] return None with patch( @@ -1035,7 +1880,7 @@ def fake_which(name): "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], + args=[installer_paths[other_name], *probe_args], returncode=0, stdout=probe_output, stderr="", @@ -1048,7 +1893,7 @@ def fake_which(name): 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] + assert mock_run.call_args.args[0] == [installer_paths[other_name], *probe_args] class TestEditableInstallMetadata: @@ -1117,9 +1962,9 @@ def locate_file(self, file): class TestTagValidationWhitespace: - def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ): + def test_tag_whitespace_is_trimmed_before_validation(self, installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -1137,11 +1982,11 @@ def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_ 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=sys.executable): + def test_pipx_argv_uses_install_force_positional_not_upgrade(self, installer_paths): + with patch("specify_cli._version.shutil.which", return_value=installer_paths["pipx"]): argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") assert argv == [ - sys.executable, + installer_paths["pipx"], "install", "--force", "git+https://github.com/github/spec-kit.git@v0.7.6", @@ -1157,9 +2002,9 @@ def test_missing_pipx_returns_no_installer_argv(self): class TestBareUpgradePipx: """pipx happy path.""" - def test_happy_path(self, pipx_argv0, clean_environ): + def test_happy_path(self, installer_paths, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["pipx"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -1193,9 +2038,9 @@ def test_pipx_argv0_prefix_short_circuits_before_registry_checks( class TestDryRunPipx: - def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ): + def test_dry_run_preview_names_pipx(self, installer_paths, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["pipx"] ), 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 de884e5c5b..3d9b336d42 100644 --- a/tests/specify_cli/test_version_execution.py +++ b/tests/specify_cli/test_version_execution.py @@ -2,7 +2,6 @@ import errno import subprocess -import sys from unittest.mock import patch from specify_cli import app @@ -151,7 +150,7 @@ def fake_run(argv, *args, **kwargs): raise FileNotFoundError(str(fake_uv)) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", + "specify_cli._version._find_installer", side_effect=lambda name: str(fake_uv) if name == "uv" else None, ), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" @@ -232,10 +231,10 @@ def test_relative_installer_path_not_executable_gets_path_specific_message( assert "Installer ./uv is not executable" not in out def test_real_installer_exit_126_is_not_treated_as_invalid_path( - self, uv_tool_argv0, clean_environ + self, installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -308,10 +307,10 @@ def test_exec_oserror_is_treated_as_invalid_installer( assert "not found on PATH" not in out def test_bare_invalid_installer_message_does_not_call_it_a_path( - self, uv_tool_argv0, clean_environ + self, installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", return_value=[ @@ -398,9 +397,9 @@ def test_transient_exec_oserror_is_not_treated_as_invalid_installer( class TestInstallerFailed: """Installer non-zero exit → propagate code, print rollback hint.""" - def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): + def test_installer_exit_2_propagates(self, installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -421,9 +420,9 @@ def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): # No verification attempted after a failed installer run. assert mock_run.call_count == 1 - def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ): + def test_installer_exit_127_propagates(self, installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -433,17 +432,17 @@ def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ): assert result.exit_code == 127 def test_installer_timeout_prints_timeout_specific_message( - self, uv_tool_argv0, clean_environ, monkeypatch + self, installer_paths, uv_tool_argv0, clean_environ, monkeypatch ): 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ - subprocess.TimeoutExpired(cmd=["uv"], timeout=12) + subprocess.TimeoutExpired(cmd=[installer_paths["uv"]], timeout=12) ] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 124 @@ -452,11 +451,11 @@ def test_installer_timeout_prints_timeout_specific_message( assert "SPECIFY_UPGRADE_TIMEOUT_SECS=12" in out def test_non_finite_timeout_warns_and_runs_without_timeout( - self, uv_tool_argv0, clean_environ, monkeypatch + self, installer_paths, uv_tool_argv0, clean_environ, monkeypatch ): 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -474,10 +473,10 @@ def test_non_finite_timeout_warns_and_runs_without_timeout( assert mock_run.call_args_list[0].kwargs["timeout"] is None def test_real_installer_exit_124_is_not_treated_as_timeout( - self, uv_tool_argv0, clean_environ + self, installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -489,9 +488,9 @@ def test_real_installer_exit_124_is_not_treated_as_timeout( assert "Upgrade failed. Installer exit code: 124." in out assert "Upgrade timed out while waiting for the installer subprocess." not in out - def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ): + def test_pipx_failure_prints_pipx_rollback_hint(self, installer_paths, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( - "specify_cli._version.shutil.which", return_value=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["pipx"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -506,10 +505,10 @@ def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ) ) in out def test_rollback_hint_accepts_normalizable_stable_snapshot( - self, uv_tool_argv0, clean_environ + self, installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="v0.7.5" ): @@ -526,10 +525,10 @@ def test_rollback_hint_accepts_normalizable_stable_snapshot( assert "Previous version was not an exact stable release tag" not in out def test_prerelease_failure_degrades_rollback_hint_to_releases_page( - self, uv_tool_argv0, clean_environ + self, installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), 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 b137e9b310..669ac1291e 100644 --- a/tests/specify_cli/test_version_verification.py +++ b/tests/specify_cli/test_version_verification.py @@ -1,6 +1,5 @@ """Verification, resolution, and validation tests for `specify self upgrade`.""" -import sys import urllib.error from unittest.mock import patch @@ -31,11 +30,12 @@ class TestVerificationMismatch: def test_installer_ok_but_verify_returns_old_version( self, + installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -54,11 +54,12 @@ def test_installer_ok_but_verify_returns_old_version( def test_verify_nonzero_exit_is_not_treated_as_success( self, + installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -76,11 +77,12 @@ def test_verify_nonzero_exit_is_not_treated_as_success( def test_verify_accepts_pep440_equivalent_rc_version( self, + installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.9.0" ): @@ -96,11 +98,12 @@ def test_verify_accepts_pep440_equivalent_rc_version( def test_verify_accepts_specify_cli_binary_name_in_version_output( self, + installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -116,11 +119,12 @@ def test_verify_accepts_specify_cli_binary_name_in_version_output( def test_verify_accepts_capitalized_binary_name_in_version_output( self, + installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -136,11 +140,12 @@ def test_verify_accepts_capitalized_binary_name_in_version_output( def test_verify_rejects_output_without_parseable_version( self, + installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -345,12 +350,12 @@ def test_http_error_categorization( assert expected in strip_ansi(result.output) def test_unparseable_resolved_release_tag_exits_1_without_traceback( - self, uv_tool_argv0, clean_environ + self, installer_paths, uv_tool_argv0, clean_environ ): 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), 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"]) @@ -368,11 +373,12 @@ class TestUnknownCurrent: def test_unknown_current_renders_literal_in_notice( self, + installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" ): @@ -390,11 +396,12 @@ def test_unknown_current_renders_literal_in_notice( def test_unknown_current_rollback_hint_degrades( self, + installer_paths, 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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="unknown" ): @@ -413,6 +420,7 @@ class TestTokenScrubbing: def test_env_passed_to_subprocess_has_no_github_tokens( self, + installer_paths, uv_tool_argv0, monkeypatch, ): @@ -423,7 +431,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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): @@ -446,6 +454,7 @@ def test_env_passed_to_subprocess_has_no_github_tokens( def test_env_scrubbing_is_case_insensitive( self, + installer_paths, uv_tool_argv0, monkeypatch, ): @@ -456,7 +465,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=sys.executable + "specify_cli._version.shutil.which", return_value=installer_paths["uv"] ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ):