# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Privilege-escalation machinery for the mutating fan-outs.
This module owns ``sudo`` credential priming (:py:func:`prime_sudo`) and its
background keepalive (:py:func:`_start_sudo_keepalive`), escalation-policy
resolution (:py:func:`_resolved_sudo`), sudo-failure detection
(:py:func:`_is_sudo_auth_failure`), and the hidden-prompt stall watchdog
(:py:class:`_StallWatchdog`). The execution engine
(:py:mod:`meta_package_manager.execution`) consumes the policy pieces to wrap and
diagnose escalated commands; the CLI calls :py:func:`prime_sudo` at the top of
each mutating subcommand.
Why priming exists: a concurrent state-changing command mutes per-manager output
and feeds each child ``stdin=/dev/null``, so a ``sudo`` password prompt raised
mid-run (by mpm's own ``sudo --non-interactive`` or by a manager that escalates
internally, like
Homebrew ``cask``) lands invisibly on ``/dev/tty`` and can stall the run up to the
mutating timeout. Priming first probes the credential cache non-interactively:
found warm, it is silently kept alive for the whole run; found cold on a terminal,
the managers mpm itself escalates get a single up-front password prompt, naming
them and branded ``[mpm]``. Internal escalators never prompt up front: their rare
cold-cache escalation is covered by the silent-call stall notice instead, raised
while the hidden prompt can still be answered.
.. note::
Everything in this module is UNIX-only: a Windows run returns early at
:py:func:`prime_sudo`'s guard and never arms the watchdog (the internal
escalators are macOS-only managers today).
"""
from __future__ import annotations
import logging
import os
import subprocess
import sys
import threading
import time
from typing import Final
from click_extra import echo
from extra_platforms import is_any_windows
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable
from click import Context
from .execution import CLIExecutor
from .manager import PackageManager
_STALL_NOTICE_DELAY: Final = 30
"""Seconds of child silence before an armed stall watchdog raises its notice.
Counted on a terminal, during a mutating call of a manager that runs ``sudo``
internally (:py:attr:`CLIExecutor.internal_sudo
<meta_package_manager.execution.CLIExecutor.internal_sudo>`). Long enough that
ordinary quiet stretches (dependency resolution, download lulls that still tick
progress lines) rarely trip it, yet far below
:py:data:`~meta_package_manager.execution.MUTATING_TIMEOUT`, so the user gets the
hint while the hidden password prompt can still be answered. See
:py:class:`_StallWatchdog`.
"""
_STALL_NOTICE_OPERATIONS: Final[frozenset[str]] = frozenset(
{"install", "remove", "upgrade", "upgrade_all"},
)
"""Operations whose commands may block on an interactive internal ``sudo``.
Matched against :py:attr:`CLIExecutor._active_operation
<meta_package_manager.execution.CLIExecutor._active_operation>` when arming the
stall watchdog: the mutating operations whose installers may escalate mid-flight
(``restore`` stamps ``"install"``, so it is covered). ``sync`` and ``cleanup`` are
excluded on purpose, to avoid false notices on ``brew update``/``brew cleanup``,
whose long silent phases never escalate. The trade-off is a known gap: ``fink``
does re-exec ``fink selfupdate``/``fink cleanup`` through ``sudo``, so a cold-cache
``mpm sync``/``mpm cleanup`` of ``fink`` can still stall unflagged on a hidden
prompt.
"""
_SUDO_CACHE_WARM: Final = threading.Event()
"""Set while a priming keepalive maintains the credential cache for the invocation.
Armed by :py:func:`_start_sudo_keepalive` and cleared when the context closes. A warm
cache serves internal escalations (:py:attr:`CLIExecutor.internal_sudo
<meta_package_manager.execution.CLIExecutor.internal_sudo>`) silently, so the
silent-call stall watchdog skips arming while this flag is set.
.. note::
The flag records that ``mpm`` holds a validated credential, assuming sudo's default
timestamp semantics. Under a hardened sudoers policy (``timestamp_timeout=0``, or a
``timestamp_type`` keyed to the process rather than the terminal) a manager's own
child ``sudo`` may not be able to spend that credential, and its mid-run prompt then
goes unflagged. Priming still authenticates; only the watchdog is suppressed.
"""
_SUDO_ESCALATION_PREFIX: Final = ("sudo", "--non-interactive")
"""Argv prefix mpm prepends to escalate a manager command non-interactively.
:py:meth:`CLIExecutor.build_cli
<meta_package_manager.execution.CLIExecutor.build_cli>` emits it and
:py:meth:`CLIExecutor.run <meta_package_manager.execution.CLIExecutor.run>` matches
it byte-for-byte to turn a ``sudo --non-interactive`` authentication failure into
an actionable
hint, so the two sites must stay in lockstep.
"""
_SUDO_KEEPALIVE_INTERVAL: Final = 60
"""Seconds between ``sudo --non-interactive --validate`` credential-cache
refreshes during a run.
Comfortably under sudo's default ``timestamp_timeout`` (5 minutes), so the cache warmed
by :py:func:`prime_sudo` stays valid for the whole command. A host configured with a
shorter ``timestamp_timeout`` may still see a mid-run escalation re-prompt or fail.
"""
_SUDO_PRIMED: Final = "mpm_sudo_primed"
"""``ctx.meta`` key marking that :py:func:`prime_sudo` already ran this invocation."""
def _resolved_sudo(manager: CLIExecutor) -> bool:
"""Whether ``manager`` escalates: its
:py:attr:`~meta_package_manager.execution.CLIExecutor.sudo` override if set,
else its built-in
:py:attr:`~meta_package_manager.execution.CLIExecutor.default_sudo`."""
return manager.sudo if manager.sudo is not None else manager.default_sudo
def _is_sudo_auth_failure(error: str) -> bool:
"""Whether ``error`` is ``sudo`` refusing to authenticate non-interactively.
``sudo --non-interactive`` writes one of these to ``<stderr>`` when it has no
cached credentials
and cannot prompt for a password (nothing cached, no controlling terminal, no
askpass helper). Lets :py:meth:`CLIExecutor.run
<meta_package_manager.execution.CLIExecutor.run>` turn an opaque escalation
failure into an actionable hint.
"""
lowered = error.lower()
return "sudo:" in lowered and any(
marker in lowered
for marker in (
"a password is required",
"a terminal is required",
"no tty present",
"askpass",
)
)
def _start_sudo_keepalive(ctx: Context) -> None:
"""Keep the ``sudo`` credential cache fresh for the rest of the invocation.
Refreshes the cache every :py:data:`_SUDO_KEEPALIVE_INTERVAL` seconds so a long
fan-out does not outlast sudo's timestamp and re-prompt mid-flight. Output is
captured so a failed refresh cannot smear the aggregate spinner drawing on
stderr. Sets :py:data:`_SUDO_CACHE_WARM` for the whole run; the daemon thread is
stopped and the flag cleared when the context closes (normal exit or Ctrl+C both
run close callbacks).
"""
stop = threading.Event()
def keepalive() -> None:
while not stop.wait(_SUDO_KEEPALIVE_INTERVAL):
subprocess.run(
("sudo", "--non-interactive", "--validate"),
capture_output=True,
check=False,
)
threading.Thread(target=keepalive, daemon=True).start()
_SUDO_CACHE_WARM.set()
def teardown() -> None:
stop.set()
_SUDO_CACHE_WARM.clear()
ctx.call_on_close(teardown)
[docs]
def prime_sudo(ctx: Context, managers: Iterable[PackageManager]) -> None:
"""Warm the ``sudo`` credential cache, up front, for a mutating fan-out.
Probes the cache non-interactively (``sudo --non-interactive --validate``)
before considering any
prompt. A warm cache (pre-authenticated ``sudo --validate``, a ``NOPASSWD`` rule, a
recent run) is silently kept fresh for the whole invocation by
:py:func:`_start_sudo_keepalive`, so every later escalation on the same
terminal, mpm's own ``sudo --non-interactive`` as well as a manager's internal
``sudo``
(:py:attr:`CLIExecutor.internal_sudo
<meta_package_manager.execution.CLIExecutor.internal_sudo>`), spends the cache
instead of blocking on an invisible prompt inside the concurrent fan-out. Only
a cold cache, on an interactive terminal, with managers that mpm itself
escalates (:py:func:`_resolved_sudo`), triggers the interactive path: a notice
naming the managers and the subcommand, then a single branded ``sudo`` password
prompt.
Call at the top of each mutating subcommand, before the fan-out draws its
spinner. Never prompts when:
- Windows (no ``sudo``) or the process is already root,
- no selected manager escalates, through mpm or internally,
- a dry run (no real CLI is executed),
- already primed once this invocation (idempotent),
- the ``sudo`` executable is missing (one warning is logged),
- the probe finds the cache already warm (keepalive only, fully silent),
- no interactive terminal is available: one warning names the managers mpm
escalates and leaves them to fail fast rather than block on a prompt no one
can answer, while an internal-only selection stays silent, or
- only internal escalators are selected on a cold cache: most such runs never
escalate, so the rare mid-run prompt is covered by the silent-call stall
notice instead.
"""
managers = list(managers)
if is_any_windows() or getattr(os, "geteuid", lambda: 1)() == 0:
return
escalating = sorted({m.id for m in managers if _resolved_sudo(m)})
internal = any(m.internal_sudo for m in managers)
if not escalating and not internal:
return
if any(manager.dry_run for manager in managers):
return
if ctx.meta.get(_SUDO_PRIMED):
return
ctx.meta[_SUDO_PRIMED] = True
try:
probe = subprocess.run(
("sudo", "--non-interactive", "--validate"),
capture_output=True,
check=False,
)
except OSError:
# No sudo on PATH (FileNotFoundError), or one that cannot be run: not executable
# for this user (PermissionError), not a valid binary (OSError). Degrade to a
# warning and let unprivileged managers proceed rather than crash.
logging.warning("sudo could not be run: managers needing root may fail.")
return
if probe.returncode == 0:
# Cache already warm (a prior ``sudo --validate``, a NOPASSWD rule):
# keep it fresh,
# silently. A CI job with pre-cached credentials thus gets the keepalive
# instead of the no-terminal warning.
_start_sudo_keepalive(ctx)
return
ids = ", ".join(escalating)
if not (sys.stdin.isatty() and sys.stderr.isatty()):
if escalating:
logging.warning(
f"{ids} need{'s' if len(escalating) == 1 else ''} administrator "
"rights, but no terminal is available to prompt for a password: "
"they may fail. Re-run in a terminal, pre-authenticate with "
"`sudo --validate`, or drop them with --no-sudo.",
)
# An internal-only selection stays silent: each manager's own sudo fails
# fast and surfaces through its error path.
return
if not escalating:
# Internal-only selection on a cold cache (a stock macOS cask/fink run):
# return without prompting. Most such runs never escalate, so an up-front
# password prompt on every run would be the mirror-image regression. The
# silent-call stall notice covers the rare mid-run prompt instead.
return
echo(
f"{ids} need{'s' if len(escalating) == 1 else ''} administrator rights to "
f"{ctx.command.name}: enter your password.",
err=True,
)
# ``sudo --prompt`` expands %-escapes; manager IDs are plain slugs, so the escaping
# is belt-and-braces.
prompt = f"[mpm] password for {ids}: ".replace("%", "%%")
prompt_cli = ("sudo", "--validate", "--prompt", prompt)
if subprocess.run(prompt_cli, check=False).returncode != 0:
logging.warning(
"Could not acquire sudo credentials: managers needing root may fail.",
)
return
_start_sudo_keepalive(ctx)
class _StallWatchdog(logging.Handler):
"""Warn when a CLI call that may hide a ``sudo`` password prompt goes silent.
A manager that escalates internally (:py:attr:`CLIExecutor.internal_sudo
<meta_package_manager.execution.CLIExecutor.internal_sudo>`) can raise a
``sudo`` prompt from inside its own commands. The child reads ``stdin`` from
``/dev/null`` and its output streams to ``DEBUG`` logs, so on a cold
credential cache the prompt lands invisibly on ``/dev/tty``: the run looks
stuck until the mutating timeout kills it. When :py:func:`prime_sudo` left the
cache cold, :py:meth:`CLIExecutor.run
<meta_package_manager.execution.CLIExecutor.run>` arms this watchdog around
the spawn: once :py:data:`_STALL_NOTICE_DELAY` seconds pass without a fresh
output line, a daemon thread logs one ``WARNING`` naming the manager and
quoting its last line, so the user can tell a hidden prompt from a slow
download and answer it on the terminal while it still waits. Each silence
episode warns at most once; a fresh line starts a new episode.
The watchdog doubles as the sole handler of :py:attr:`tee`, the logger
:py:meth:`CLIExecutor.run <meta_package_manager.execution.CLIExecutor.run>`
hands to :py:func:`click_extra.execution.run_cli` in place of the root logger:
:py:meth:`emit` tracks the child's activity, then forwards every record
verbatim to the root logger, whose level click-extra's ``--verbosity``
manages, keeping the display byte-identical to an un-teed run at every
verbosity.
.. note::
Considered alternative: a ``SUDO_ASKPASS`` helper. ``brew`` documents
passing ``--askpass`` to its internal ``sudo`` whenever that variable is
set, so mpm could export a helper into the child environment and rebrand
the hidden prompt itself ("[mpm] cask needs your password..."), working
even under hardened sudoers policies whose timestamps the priming cache
cannot serve (see :py:data:`_SUDO_CACHE_WARM`). Rejected for now: the
helper reads the raw password and pipes it to ``sudo`` (a security
surface this notice avoids entirely), it needs a side channel to pause
the spinner that would smear its prompt, and it only covers tools
honoring the variable (``brew`` does, ``fink``'s plain ``sudo`` re-exec
does not). Revisit if this notice proves insufficient in the field; the
scoped ``sudo = true`` opt-in documented in ``docs/sudo.md`` already
covers users wanting a guaranteed up-front prompt.
"""
tee: logging.Logger
"""Stand-in destination for ``run_cli``'s streamed records while armed.
Deliberately a direct :py:class:`logging.Logger` construction, never
:py:func:`logging.getLogger`: unregistered, each armed call gets a private tee
that concurrent calls cannot cross-contaminate; parentless, its records cannot
propagate straight to the root handlers, which would bypass the root level
gate and leak ``DEBUG`` lines at default verbosity. Its ``DEBUG`` level lets
every record reach :py:meth:`emit`: dropping is the root logger's decision.
"""
def __init__(self, manager_id: str) -> None:
"""Arm the watchdog for one CLI call of ``manager_id``."""
super().__init__()
self._manager_id = manager_id
self._started = time.monotonic()
# Latest child activity, one ``(monotonic timestamp, output line)`` pair.
# Written by emit() in a single reference assignment and read the same way
# by the notice thread, so the pair stays consistent without a lock
# (free-threading safe). Starts at arming time, with no line seen yet.
self._activity: tuple[float, str | None] = (self._started, None)
# Activity timestamp of the silence episode already noticed, so each
# episode warns at most once. Touched by the notice thread only.
self._noticed: float | None = None
self.tee = logging.Logger(f"mpm-stall-tee-{manager_id}", logging.DEBUG)
self.tee.addHandler(self)
self._stop = threading.Event()
self._thread = threading.Thread(target=self._watch, daemon=True)
self._thread.start()
def emit(self, record: logging.LogRecord) -> None:
"""Track child activity, then forward ``record`` verbatim to the root.
Only the streamed output lines refresh the activity state: they are the
records carrying a ``label`` attribute (``run_cli`` labels the child's
output lines only, never its own prompt-disclosure or PID-tracking lines),
and only genuine output vouches that the child is not blocked on a prompt.
Forwarding re-enters :py:meth:`logging.Logger.log` on the root logger so
its level gate (the one click-extra's ``--verbosity`` manages) and its
handlers apply exactly as if ``run_cli`` had logged there directly.
"""
try:
message = record.getMessage()
label = getattr(record, "label", None)
root = logging.getLogger()
if label is None:
root.log(record.levelno, message)
else:
self._activity = (time.monotonic(), message)
root.log(record.levelno, message, extra={"label": label})
except Exception:
self.handleError(record)
def _watch(self) -> None:
"""Notice-thread body: warn once per silence episode.
A silence episode is identified by the timestamp of the latest activity: a
fresh output line moves it, which re-arms the notice for the next silent
stretch. Wakes at most every second (sooner when the delay itself is
shorter) so a stop request is honored promptly.
"""
while not self._stop.wait(min(1.0, _STALL_NOTICE_DELAY)):
last_stamp, last_line = self._activity
silence = time.monotonic() - last_stamp
if silence < _STALL_NOTICE_DELAY or self._noticed == last_stamp:
continue
self._noticed = last_stamp
if last_line is None:
detail = "No output since the command started."
else:
# Cap the quoted line at 120 characters, ellipsis included.
if len(last_line) > 120:
last_line = last_line[:119] + "β¦"
detail = f'Last output: "{last_line}"'
# WARNING survives the default verbosity, and click-extra's handler
# prints it above any animating spinner frame. The wording never
# instructs the user to type blindly: the prompt may not exist.
logging.warning(
f"No output for {int(silence)}s: may be waiting on a hidden "
f"password prompt. {detail}",
extra={"label": self._manager_id},
)
def stop(self) -> None:
"""Disarm: stop the notice thread, join it, and detach the handler."""
self._stop.set()
self._thread.join()
self.close()