Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 96 additions & 10 deletions src/specify_cli/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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)
Expand All @@ -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 = (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -587,6 +660,7 @@ def _detect_install_method(
editable_marker_seen=False,
installer_registries_consulted=tuple(consulted),
resolved_method=method,
installer_rejections=tuple(rejections),
Comment thread
mnriem marked this conversation as resolved.
)
return method

Expand All @@ -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

Expand Down Expand Up @@ -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 [
Expand All @@ -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 [
Expand Down Expand Up @@ -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
Expand All @@ -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 = (
Expand All @@ -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

Expand Down
16 changes: 10 additions & 6 deletions src/specify_cli/selfs/command_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions tests/specify_cli/authentication/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading
Loading