# 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 ({func}`prime_sudo`) and its
background keepalive ({func}`_start_sudo_keepalive`), escalation-policy
resolution ({func}`_resolved_sudo`), sudo-failure detection
({func}`_is_sudo_auth_failure`), and the hidden-prompt stall watchdog
({class}`_StallWatchdog`). The execution engine
({mod}`meta_package_manager.execution`) consumes the policy pieces to wrap and
diagnose escalated commands; the CLI calls {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
{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 ({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
{data}`~meta_package_manager.execution.MUTATING_TIMEOUT`, so the user gets the
hint while the hidden password prompt can still be answered. See
{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 {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 {func}`_start_sudo_keepalive` and cleared when the context closes. A warm
cache serves internal escalations ({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.
{meth}`CLIExecutor.build_cli
<meta_package_manager.execution.CLIExecutor.build_cli>` emits it and
{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 {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 {func}`prime_sudo` already ran this invocation."""
def _resolved_sudo(manager: CLIExecutor) -> bool:
"""Whether `manager` escalates: its
{attr}`~meta_package_manager.execution.CLIExecutor.sudo` override if set,
else its built-in
{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 {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 {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 {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
{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`
({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 ({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 or a plan run (no state-changing 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 or manager.plan 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 escalation with `--no-sudo` or a "
"`[mpm] sudo = false` entry in your configuration file.",
)
# 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 ({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 {func}`prime_sudo` left the
cache cold, {meth}`CLIExecutor.run
<meta_package_manager.execution.CLIExecutor.run>` arms this watchdog around
the spawn: once {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 {attr}`tee`, the logger
{meth}`CLIExecutor.run <meta_package_manager.execution.CLIExecutor.run>`
hands to {func}`click_extra.execution.run_cli` in place of the root logger:
{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 {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 {class}`logging.Logger` construction, never
{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 {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
# Instantiated directly, not via getLogger: the tee must stay out of the
# registry (re-arming would reuse it, stacking handlers) and have no
# parent, so this handler is its only sink and nothing double-emits.
self.tee = logging.Logger( # noqa: LOG001
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 {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: # noqa: BLE001
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()