3099 lines
110 KiB
Bash
Executable File
3099 lines
110 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
# Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
# skeptical-updater - a manual, conscious update cycle for apt, dnf/yum, pacman/pman
|
|
# and zypper: list what would be upgraded, interrogate every package, remember the
|
|
# ones you trust, then run the native upgrade (with per-run exclusions).
|
|
#
|
|
# Version: 0.6.0
|
|
#
|
|
# Specification: docs/DESIGN.md. Single file, bash >= 4.3, no runtime dependency
|
|
# beyond the distro's package manager and coreutils/sed/grep (awk only on the apt
|
|
# and pacman backends, where it is always installed; flock is used when present).
|
|
|
|
if [ -z "${BASH_VERSINFO:-}" ] || ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3))); then
|
|
echo "skeptical-updater: bash >= 4.3 is required" >&2
|
|
exit 1
|
|
fi
|
|
|
|
set -euo pipefail
|
|
export LC_ALL=C DEBIAN_FRONTEND=noninteractive
|
|
# On a terminal the prompt uses readline with a history of this session's lines only: in memory,
|
|
# never read from or written to a file (no HISTFILE; `history -r`/`-w`/`-a` are never used).
|
|
unset HISTFILE
|
|
|
|
readonly SU_VERSION=0.6.0
|
|
readonly SU_PROG=skeptical-updater
|
|
readonly SU_STATE_HEADER='# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)'
|
|
readonly SU_PROMPT_HELP='<enter> upgrade | N? what is it | N?? full info | N- exclude this run | Nx purge now | N+ trust forever'
|
|
readonly US=$'\x1f' # field separator used internally (non-whitespace: empty fields survive `read`)
|
|
|
|
# --sudo-reexec (hidden, first argument only): set by our own sudo re-exec, never forwarded;
|
|
# a second re-exec would loop forever.
|
|
SU_REEXEC=0
|
|
if [[ ${1:-} == --sudo-reexec ]]; then
|
|
SU_REEXEC=1
|
|
shift
|
|
fi
|
|
SU_ARGS=("$@")
|
|
# Effective uid. SKEPTICAL_UPDATER_EUID only exists for the test-suite (it can only make
|
|
# the tool *believe* it is (not) root; the package manager still enforces permissions).
|
|
SU_EUID=${SKEPTICAL_UPDATER_EUID:-$EUID}
|
|
# Lock waits (seconds) - SKEPTICAL_UPDATER_LOCK_WAIT only exists for the test-suite.
|
|
SU_LOCK_WAIT=${SKEPTICAL_UPDATER_LOCK_WAIT:-}
|
|
SU_LOCK_TAKEOVER_PAUSE=${SKEPTICAL_UPDATER_LOCK_TAKEOVER_PAUSE:-}
|
|
|
|
# ---- options ------------------------------------------------------------------------
|
|
opt_backend=
|
|
opt_all=0
|
|
opt_refresh=1
|
|
opt_dry_run=0
|
|
opt_color=1
|
|
opt_state_dir=
|
|
opt_state_dir_set=0
|
|
opt_aur= # AUR helper (pacman backend): '' = detect paru, then yay; paru | yay | none
|
|
cmd=
|
|
cmd_args=()
|
|
|
|
BACKEND=
|
|
STATE_DIR=
|
|
STATE_FILE=
|
|
|
|
C_BOLD='' C_DIM='' C_RED='' C_GREEN='' C_YELLOW='' C_RESET=''
|
|
|
|
# ---- messages -----------------------------------------------------------------------
|
|
# Every message tolerates a closed stdout/stderr: the restore paths must never be cut short
|
|
# by a write error (the exit handler ignores SIGPIPE; the writes then fail with EPIPE).
|
|
say() { printf '%s\n' "$*" 2>/dev/null || true; }
|
|
err() { printf '%s: error: %s\n' "$SU_PROG" "$*" >&2 2>/dev/null || true; }
|
|
warn() { printf '%s: warning: %s\n' "$SU_PROG" "$*" >&2 2>/dev/null || true; }
|
|
die() {
|
|
err "$@"
|
|
exit 1
|
|
}
|
|
usage_error() {
|
|
err "$*"
|
|
printf "Try '%s --help'.\n" "$SU_PROG" >&2 2>/dev/null || true
|
|
exit 1
|
|
}
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
usage: $SU_PROG [options] [command]
|
|
|
|
Review every upgradable package before running the native upgrade.
|
|
|
|
commands:
|
|
(none) interactive review + upgrade
|
|
list print the review list and exit (no prompt)
|
|
trusted print the statefile
|
|
trust PKG... add PKG and its installed dependency closure to the statefile
|
|
untrust PKG... remove rows for PKG (and rows whose reason is dep:PKG)
|
|
refresh recompute dependency closures for every 'user' root
|
|
info PKG description, install reason and installed dependents (as N?)
|
|
show PKG installed record merged with the upstream candidate (as N??)
|
|
|
|
options:
|
|
--backend=NAME force apt|dnf|pacman|zypper (default: detect pacman > apt > dnf/yum > zypper)
|
|
--all also list trusted packages (marked with '=')
|
|
--no-refresh skip the metadata refresh step
|
|
--dry-run print native commands instead of running mutating ones
|
|
--state-dir=DIR override /var/lib/skeptical-updater (env: SKEPTICAL_UPDATER_STATE_DIR)
|
|
--no-color disable ANSI colours (also honours NO_COLOR and non-tty stdout)
|
|
--aur-helper=NAME pacman backend: list and upgrade AUR packages with paru or yay, or
|
|
none (default: paru, else yay, when installed; other backends ignore it)
|
|
--no-aur same as --aur-helper=none
|
|
-h, --help this help
|
|
-v, --version print the version
|
|
|
|
interactive commands (one line, whitespace-separated tokens, N = number in brackets):
|
|
<empty line> proceed: run the native upgrade with the current exclusions
|
|
N? what is it: the description, 'reason:' (explicitly installed, installed as
|
|
a dependency, unknown or not installed) and 'required by:', the packages
|
|
that depend on it - installed packages only
|
|
N?? full native info: the installed record; lines starting with '*' come
|
|
from the upstream candidate (differing values, fields it adds)
|
|
For N? and N??, a listed package name may be used instead of N.
|
|
N- exclude from this run (toggle); shown with a '-' marker
|
|
Nx purge it now with its now-unneeded dependencies (shown, asks y/N); refused
|
|
when other installed packages depend on it, like pacman -Rns
|
|
consecutive Nx commands are purged together after one confirmation
|
|
N+ trust it forever (with its installed dependency closure)
|
|
N++ trust the topmost installed dependents and their dependency closures
|
|
*+ / *- apply + / - to every listed package
|
|
a show packages trusted during this session again
|
|
l redraw the list
|
|
q quit without upgrading (exit 0)
|
|
h, ? this help
|
|
end of input abort without upgrading (exit 2)
|
|
|
|
A '*' right after the number marks an AUR package (pacman backend with paru or yay): <enter>
|
|
upgrades the repository packages with pacman first, then the AUR ones with the helper, run as
|
|
the user who invoked sudo; on a terminal the helper asks as usual (PKGBUILD review).
|
|
|
|
exit status: 0 ok, 1 usage/backend error, 2 aborted, otherwise the package
|
|
manager's exit status from the upgrade step.
|
|
EOF
|
|
}
|
|
|
|
session_help() {
|
|
cat <<'EOF'
|
|
<enter> upgrade now, skipping the rows marked '-'
|
|
N? what is it: description, install reason + installed packages that need it
|
|
N?? full native info: installed record, '*' lines from the upstream candidate
|
|
A listed package name may replace N for ? and ?? (e.g. okular?).
|
|
N- exclude N from this run (toggle)
|
|
Nx purge N now: lists every package the purge removes, then asks y/N.
|
|
x refuses when other installed packages depend on the target, like
|
|
pacman -Rns; purge the dependents first
|
|
consecutive Nx commands are purged together after one confirmation
|
|
N+ trust N (and its installed dependencies) forever
|
|
N++ trust the topmost installed dependents of N and their dependencies
|
|
*+ *- trust / exclude every listed package (*- again re-includes them)
|
|
a show packages trusted during this session again
|
|
l redraw the list (a '*' after the number marks an AUR package)
|
|
q quit without upgrading
|
|
h ? this help
|
|
Several tokens can be given on one line, e.g. "1? 3- 4+".
|
|
EOF
|
|
}
|
|
|
|
# ---- argument parsing -----------------------------------------------------------------
|
|
parse_args() {
|
|
local a only_pos=0
|
|
while (($#)); do
|
|
a=$1
|
|
shift
|
|
if ((only_pos)) || [[ $a != -* || $a == - ]]; then
|
|
if [[ -z $cmd ]]; then cmd=$a; else cmd_args+=("$a"); fi
|
|
continue
|
|
fi
|
|
case $a in
|
|
--) only_pos=1 ;;
|
|
--backend=*) opt_backend=${a#*=} ;;
|
|
--backend)
|
|
(($#)) || usage_error "--backend needs a value"
|
|
opt_backend=$1
|
|
shift
|
|
;;
|
|
--state-dir=*)
|
|
opt_state_dir=${a#*=}
|
|
opt_state_dir_set=1
|
|
;;
|
|
--state-dir)
|
|
(($#)) || usage_error "--state-dir needs a value"
|
|
opt_state_dir=$1
|
|
opt_state_dir_set=1
|
|
shift
|
|
;;
|
|
--aur-helper=*)
|
|
opt_aur=${a#*=}
|
|
[[ -n $opt_aur ]] || usage_error "--aur-helper needs a value"
|
|
;;
|
|
--aur-helper)
|
|
(($#)) || usage_error "--aur-helper needs a value"
|
|
opt_aur=$1
|
|
shift
|
|
;;
|
|
--no-aur) opt_aur=none ;;
|
|
--all) opt_all=1 ;;
|
|
--no-refresh) opt_refresh=0 ;;
|
|
--dry-run) opt_dry_run=1 ;;
|
|
--no-color | --no-colour) opt_color=0 ;;
|
|
-h | --help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
-v | --version)
|
|
printf '%s %s\n' "$SU_PROG" "$SU_VERSION"
|
|
exit 0
|
|
;;
|
|
*) usage_error "unknown option: $a" ;;
|
|
esac
|
|
done
|
|
|
|
case $cmd in
|
|
'' | list | trusted | refresh)
|
|
((${#cmd_args[@]} == 0)) || usage_error "'${cmd:-(interactive)}' takes no arguments (got: ${cmd_args[*]})"
|
|
;;
|
|
trust | untrust)
|
|
((${#cmd_args[@]} > 0)) || usage_error "'$cmd' needs at least one package name"
|
|
;;
|
|
info | show)
|
|
((${#cmd_args[@]} == 1)) || usage_error "'$cmd' needs exactly one package name"
|
|
;;
|
|
*) usage_error "unknown command: $cmd" ;;
|
|
esac
|
|
local p
|
|
for p in ${cmd_args[@]+"${cmd_args[@]}"}; do
|
|
valid_pkg_name "$p" || usage_error "invalid package name: '$p'"
|
|
done
|
|
if [[ -n $opt_backend ]]; then
|
|
case $opt_backend in
|
|
apt | dnf | yum | pacman | pman | zypper) ;;
|
|
*) usage_error "unknown backend '$opt_backend' (expected apt, dnf, pacman or zypper)" ;;
|
|
esac
|
|
fi
|
|
case $opt_aur in
|
|
'' | paru | yay | none) ;;
|
|
*) usage_error "unknown AUR helper '$opt_aur' (expected paru, yay or none)" ;;
|
|
esac
|
|
if ((opt_state_dir_set)); then
|
|
STATE_DIR=$opt_state_dir
|
|
else
|
|
STATE_DIR=${SKEPTICAL_UPDATER_STATE_DIR:-/var/lib/skeptical-updater}
|
|
fi
|
|
[[ -n $STATE_DIR ]] || usage_error "empty state directory"
|
|
STATE_FILE=$STATE_DIR/trusted.tsv
|
|
}
|
|
|
|
# Package names: no leading '-', no whitespace/shell/path characters.
|
|
valid_pkg_name() {
|
|
local re='^[A-Za-z0-9_+@][A-Za-z0-9_+.@:~-]*$'
|
|
[[ $1 =~ $re ]]
|
|
}
|
|
|
|
setup_colors() {
|
|
if ((opt_color)) && [[ -z ${NO_COLOR:-} && -t 1 ]]; then
|
|
C_BOLD=$'\e[1m' C_DIM=$'\e[2m' C_RED=$'\e[31m' C_GREEN=$'\e[32m' C_YELLOW=$'\e[33m' C_RESET=$'\e[0m'
|
|
fi
|
|
}
|
|
|
|
# ---- cleanup / signals ------------------------------------------------------------------
|
|
SU_TMP=() # temp files and directories removed on exit
|
|
APT_HOLDS_ACTIVE=0 # 1 while holds of ours may be in place (restored on exit)
|
|
APT_DRY_HOLDS=() # --dry-run: holds we "placed"
|
|
ZYPP_PRIVATE= # private zypp configuration directory of a running upgrade
|
|
declare -A SU_LOCK=() # lock name -> "fd:N" (flock) or "dir:PATH" (mkdir lock)
|
|
|
|
su_on_exit() {
|
|
local rc=$? t
|
|
# nothing may interrupt the restore: a second Ctrl-C, TERM, HUP, QUIT, or a closed pipe
|
|
trap '' INT TERM HUP QUIT PIPE
|
|
trap - EXIT
|
|
set +e
|
|
if ((APT_HOLDS_ACTIVE)); then apt_holds_restore; fi
|
|
zypper_private_cleanup
|
|
pacman_exit_cleanup
|
|
for t in ${SU_TMP[@]+"${SU_TMP[@]}"}; do rm -rf -- "$t" 2>/dev/null; done
|
|
su_unlock_all
|
|
exit "$rc"
|
|
}
|
|
su_on_signal() {
|
|
printf '\n%s: interrupted\n' "$SU_PROG" >&2 2>/dev/null || true
|
|
exit "$1" # runs su_on_exit
|
|
}
|
|
su_traps_set() {
|
|
trap 'su_on_signal 130' INT
|
|
trap 'su_on_signal 131' QUIT
|
|
trap 'su_on_signal 143' TERM
|
|
trap 'su_on_signal 129' HUP
|
|
trap 'su_on_signal 141' PIPE
|
|
}
|
|
trap su_on_exit EXIT
|
|
su_traps_set
|
|
|
|
su_mktemp() { # su_mktemp [-d] -> path in $REPLY, registered for removal on exit
|
|
local p
|
|
p=$(mktemp "$@" "${TMPDIR:-/tmp}/$SU_PROG.XXXXXX") || die "mktemp failed"
|
|
SU_TMP+=("$p")
|
|
REPLY=$p
|
|
}
|
|
|
|
# ---- locks (in the state directory) ------------------------------------------------------------
|
|
# su_lock NAME WAIT: lock $STATE_DIR/NAME.lock, waiting up to WAIT seconds (0 = try once).
|
|
# flock(1) when available, else an atomic mkdir lock whose owner pid is recorded (a lock whose
|
|
# owner is gone is stale and taken over).
|
|
su_lock() {
|
|
local name=$1 wait=$2 f d t pid end fd
|
|
[[ -z ${SU_LOCK[$name]:-} ]] || return 0
|
|
if ((wait > 0)) && [[ -n $SU_LOCK_WAIT ]]; then wait=$SU_LOCK_WAIT; fi
|
|
state_mkdir
|
|
f=$STATE_DIR/$name.lock
|
|
if have flock; then
|
|
exec {fd}>>"$f" || return 1
|
|
if flock -w "$wait" "$fd"; then
|
|
SU_LOCK[$name]=fd:$fd
|
|
return 0
|
|
fi
|
|
exec {fd}>&-
|
|
return 1
|
|
fi
|
|
# mkdir fallback: the lock directory appears atomically WITH its pid file (built under a
|
|
# temporary name, then renamed into place: rename fails while the lock is held)
|
|
d=$f.d
|
|
end=$((SECONDS + wait))
|
|
su_lock_clean "$name" "$wait"
|
|
while :; do
|
|
t=$(mktemp -d -- "$STATE_DIR/.$name.lock.XXXXXX") || return 1
|
|
printf '%s\n' "$$" >"$t/pid"
|
|
if mv -T -- "$t" "$d" 2>/dev/null; then
|
|
SU_LOCK[$name]=dir:$d
|
|
return 0
|
|
fi
|
|
rm -rf -- "$t"
|
|
pid=$(cat -- "$d/pid" 2>/dev/null) || pid=
|
|
# a lock whose owner is gone is stale: take it over - unless that fails (no busy loop)
|
|
if [[ $pid =~ ^[0-9]+$ ]] && ! pid_alive "$pid" && su_lock_takeover "$d"; then
|
|
continue
|
|
fi
|
|
((SECONDS < end)) || return 1
|
|
sleep 1
|
|
done
|
|
}
|
|
|
|
# su_lock_takeover DIR: remove the stale lock DIR. Two waiters may have seen the same dead owner:
|
|
# the lock is first renamed away (only one rename succeeds) and its pid checked again inside the
|
|
# renamed directory - a live lock that replaced the stale one meanwhile is put back, never removed.
|
|
su_lock_takeover() {
|
|
local d=$1 s=$1.stale.$$ pid
|
|
[[ -z $SU_LOCK_TAKEOVER_PAUSE ]] || sleep "$SU_LOCK_TAKEOVER_PAUSE" # (test-suite only)
|
|
mv -T -- "$d" "$s" 2>/dev/null || return 1
|
|
pid=$(cat -- "$s/pid" 2>/dev/null) || pid=
|
|
if [[ $pid =~ ^[0-9]+$ ]] && pid_alive "$pid"; then
|
|
mv -T -- "$s" "$d" 2>/dev/null || true
|
|
return 1
|
|
fi
|
|
rm -rf -- "$s" 2>/dev/null
|
|
[[ ! -e $s ]]
|
|
}
|
|
|
|
# su_lock_clean NAME WAIT: remove what crashed acquirers left: temporary lock directories and
|
|
# renamed stale locks whose pid is dead, or that have no pid and are older than max(WAIT, 60) s
|
|
su_lock_clean() {
|
|
local name=$1 max=$2 x pid now mt
|
|
((max >= 60)) || max=60
|
|
now=$(date +%s)
|
|
for x in "$STATE_DIR/.$name.lock."* "$STATE_DIR/$name.lock.d.stale."*; do
|
|
[[ -d $x ]] || continue
|
|
pid=$(cat -- "$x/pid" 2>/dev/null) || pid=
|
|
if [[ $pid =~ ^[0-9]+$ ]]; then
|
|
pid_alive "$pid" && continue
|
|
else
|
|
mt=$(stat -c %Y -- "$x" 2>/dev/null) || continue
|
|
((now - mt > max)) || continue
|
|
fi
|
|
rm -rf -- "$x" 2>/dev/null
|
|
done
|
|
return 0
|
|
}
|
|
# pid_alive PID: also true for a process of another user (kill -0 would fail with EPERM)
|
|
pid_alive() {
|
|
if [[ -d /proc/self ]]; then
|
|
[[ -d /proc/$1 ]]
|
|
return
|
|
fi
|
|
kill -0 "$1" 2>/dev/null && return 0
|
|
[[ $(kill -0 "$1" 2>&1) == *ermitted* ]]
|
|
}
|
|
su_unlock() {
|
|
local v=${SU_LOCK[$1]:-} fd
|
|
case $v in
|
|
fd:*)
|
|
fd=${v#fd:}
|
|
exec {fd}>&-
|
|
;;
|
|
dir:*)
|
|
# only a lock directory that is really ours (also one a waiter renamed away meanwhile)
|
|
local x
|
|
for x in "${v#dir:}" "${v#dir:}".stale.*; do
|
|
[[ -d $x && $(cat -- "$x/pid" 2>/dev/null) == "$$" ]] && rm -rf -- "$x" 2>/dev/null
|
|
done
|
|
;;
|
|
esac
|
|
unset 'SU_LOCK[$1]'
|
|
}
|
|
su_unlock_all() {
|
|
local n
|
|
for n in ${SU_LOCK[@]+"${!SU_LOCK[@]}"}; do su_unlock "$n"; done
|
|
}
|
|
|
|
# ---- command execution ----------------------------------------------------------------------
|
|
fmt_cmd() { # the command line, shell-quoted where needed
|
|
local out='' a q re='^[A-Za-z0-9_@%+=:,./-]+$'
|
|
for a in "$@"; do
|
|
if [[ $a =~ $re ]]; then q=$a; else printf -v q '%q' "$a"; fi
|
|
out+="${out:+ }$q"
|
|
done
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
# run_child CMD...: run CMD with stdin from /dev/null and our lock descriptors closed (a daemon
|
|
# started by a maintainer script must not inherit, and so keep, our flock).
|
|
run_child() { run_child_stdin "$@" </dev/null; }
|
|
# run_child_stdin CMD...: the same, but CMD keeps our stdin (an AUR helper asking on a terminal)
|
|
run_child_stdin() {
|
|
(
|
|
local v fd
|
|
for v in ${SU_LOCK[@]+"${SU_LOCK[@]}"}; do
|
|
if [[ $v == fd:* ]]; then
|
|
fd=${v#fd:}
|
|
exec {fd}>&-
|
|
fi
|
|
done
|
|
exec "$@"
|
|
)
|
|
}
|
|
|
|
# run_cmd CMD...: run a MUTATING native command (output streamed); under --dry-run print
|
|
# "+ CMD" to stdout instead and succeed.
|
|
run_cmd() {
|
|
if ((opt_dry_run)); then
|
|
printf '+ %s\n' "$(fmt_cmd "$@")" 2>/dev/null || true
|
|
return 0
|
|
fi
|
|
printf '%s+ %s%s\n' "$C_DIM" "$(fmt_cmd "$@")" "$C_RESET" 2>/dev/null || true
|
|
run_child "$@"
|
|
}
|
|
|
|
# refresh_run CMD...: metadata refresh; never fatal (warns, returns the command's status).
|
|
refresh_run() {
|
|
local out rc=0
|
|
if ((SU_EUID != 0)); then
|
|
# only reachable with --dry-run: everything else that refreshes re-execs through sudo
|
|
say 'Refreshing package lists... skipped (not root), the command would be:'
|
|
say "+ $(fmt_cmd "$@")"
|
|
return 0
|
|
fi
|
|
printf 'Refreshing package lists... ' 2>/dev/null || true
|
|
out=$(run_child "$@" 2>&1) || rc=$?
|
|
if ((rc == 0)); then
|
|
say 'done.'
|
|
else
|
|
say 'FAILED.'
|
|
[[ -z $out ]] || printf '%s\n' "$out" >&2
|
|
warn "metadata refresh failed ($(fmt_cmd "$@") exited $rc); continuing with the cached package lists"
|
|
fi
|
|
return "$rc"
|
|
}
|
|
|
|
be() { # be FUNCTION ARGS...: call <backend>_FUNCTION
|
|
local f=${BACKEND}_$1
|
|
shift
|
|
"$f" "$@"
|
|
}
|
|
|
|
# ---- system facts ------------------------------------------------------------------------------
|
|
os_release_field() { # os_release_field KEY -> value (unquoted) or empty
|
|
local f=${SKEPTICAL_UPDATER_OS_RELEASE:-} k v
|
|
if [[ -z $f ]]; then
|
|
f=/etc/os-release
|
|
[[ -r $f ]] || f=/usr/lib/os-release
|
|
fi
|
|
[[ -r $f ]] || return 0
|
|
while IFS='=' read -r k v || [[ -n $k ]]; do
|
|
[[ $k == "$1" ]] || continue
|
|
v=${v%$'\r'}
|
|
if [[ $v == \"*\" || $v == \'*\' ]]; then v=${v:1:${#v}-2}; fi
|
|
printf '%s' "$v"
|
|
return 0
|
|
done <"$f"
|
|
}
|
|
|
|
have() { command -v "$1" >/dev/null 2>&1; }
|
|
plural() { if (($1 == 1)); then printf '%s' "$2"; else printf '%s' "$3"; fi; }
|
|
join_by() { # join_by SEP WORD...
|
|
local sep=$1 out=''
|
|
shift
|
|
while (($#)); do
|
|
out+=${out:+$sep}$1
|
|
shift
|
|
done
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
detect_backend() {
|
|
case $opt_backend in
|
|
'')
|
|
if have pacman; then
|
|
BACKEND=pacman
|
|
elif have apt-get; then
|
|
BACKEND=apt
|
|
elif have dnf || have yum; then
|
|
BACKEND=dnf
|
|
elif have zypper; then
|
|
BACKEND=zypper
|
|
else
|
|
die "no supported package manager found in PATH (pacman, apt-get, dnf/yum, zypper)"
|
|
fi
|
|
;;
|
|
pman) BACKEND=pacman ;;
|
|
yum) BACKEND=dnf ;;
|
|
*) BACKEND=$opt_backend ;;
|
|
esac
|
|
be init
|
|
}
|
|
|
|
# ===================================================================================================
|
|
# statefile
|
|
# ===================================================================================================
|
|
ST_COMMENTS=() # comment (and unparsable) lines, file order
|
|
declare -A ST_ROW=() # "backend<TAB>package" -> "reason<TAB>added"
|
|
ST_OPS=() # this session's changes, replayed onto the file as it is at save time
|
|
ST_CHANGES=() # human-readable pending changes (printed under --dry-run)
|
|
ST_DIRTY=0
|
|
|
|
now_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
|
|
|
# state_check_access: a state directory we cannot enter must not look like "no statefile"
|
|
state_check_access() {
|
|
if [[ -e $STATE_DIR && ! -d $STATE_DIR ]]; then die "the state directory $STATE_DIR is not a directory"; fi
|
|
if [[ -d $STATE_DIR && ! -x $STATE_DIR ]]; then die "cannot access the state directory $STATE_DIR (permission denied)"; fi
|
|
if [[ -e $STATE_FILE && ! -r $STATE_FILE ]]; then die "cannot read the statefile $STATE_FILE (permission denied)"; fi
|
|
return 0
|
|
}
|
|
|
|
# state_read [quiet]: (re)read the file into ST_COMMENTS / ST_ROW
|
|
state_read() {
|
|
local quiet=${1:-} line n=0 b p r a extra key
|
|
ST_COMMENTS=() ST_ROW=()
|
|
[[ -e $STATE_FILE ]] || return 0
|
|
[[ -r $STATE_FILE ]] || die "cannot read the statefile $STATE_FILE"
|
|
while IFS= read -r line || [[ -n $line ]]; do
|
|
n=$((n + 1))
|
|
line=${line%$'\r'}
|
|
[[ -n ${line//[[:space:]]/} ]] || continue
|
|
if [[ $line == '#'* ]]; then
|
|
ST_COMMENTS+=("$line")
|
|
continue
|
|
fi
|
|
IFS=$US read -r b p r a extra <<<"${line//$'\t'/$US}"
|
|
if [[ -z $b || -z $p || -z $a || -n $extra ]] || [[ $r != user && ! $r =~ ^dep:.+ ]]; then
|
|
[[ -n $quiet ]] || warn "$STATE_FILE:$n: malformed line kept verbatim: $line"
|
|
ST_COMMENTS+=("$line")
|
|
continue
|
|
fi
|
|
key=$b$'\t'$p
|
|
if [[ -n ${ST_ROW[$key]+x} ]]; then
|
|
# duplicate (hand edit): a 'user' row wins over a 'dep:' row, else the first one
|
|
[[ $r == user && ${ST_ROW[$key]%%$'\t'*} != user ]] || continue
|
|
fi
|
|
ST_ROW[$key]=$r$'\t'$a
|
|
done <"$STATE_FILE"
|
|
}
|
|
|
|
state_load() {
|
|
state_check_access
|
|
ST_OPS=() ST_CHANGES=() ST_DIRTY=0
|
|
state_read
|
|
}
|
|
|
|
is_trusted() { [[ -n ${ST_ROW["$BACKEND"$'\t'"$1"]+x} ]]; }
|
|
trusted_reason() { local v=${ST_ROW["$BACKEND"$'\t'"$1"]-}; printf '%s' "${v%%$'\t'*}"; }
|
|
|
|
# state_apply_add KEY REASON DATE: 0 if ST_ROW changes. Never downgrades a 'user' row to 'dep:';
|
|
# an existing 'dep:' row is upgraded to 'user', otherwise kept as is.
|
|
state_apply_add() {
|
|
local cur=${ST_ROW[$1]-}
|
|
if [[ -n $cur ]]; then
|
|
[[ $2 == user && ${cur%%$'\t'*} != user ]] || return 1
|
|
fi
|
|
ST_ROW[$1]=$2$'\t'$3
|
|
}
|
|
|
|
# state_add BACKEND PKG REASON DATE: returns 0 if the file changes
|
|
state_add() {
|
|
local key=$1$'\t'$2 cur
|
|
cur=${ST_ROW[$key]-}
|
|
state_apply_add "$key" "$3" "$4" || return 1
|
|
if [[ -n $cur ]]; then
|
|
ST_CHANGES+=("update: $1 $2 user (was ${cur%%$'\t'*})")
|
|
else
|
|
ST_CHANGES+=("add: $1 $2 $3")
|
|
fi
|
|
ST_OPS+=("add$US$key$US$3$US$4")
|
|
ST_DIRTY=1
|
|
return 0
|
|
}
|
|
|
|
state_del() { # state_del KEY
|
|
local v=${ST_ROW[$1]}
|
|
ST_CHANGES+=("remove: ${1//$'\t'/ } ${v%%$'\t'*}")
|
|
ST_OPS+=("del$US$1$US${v%%$'\t'*}")
|
|
unset 'ST_ROW[$1]'
|
|
ST_DIRTY=1
|
|
}
|
|
|
|
state_mkdir() {
|
|
[[ -d $STATE_DIR ]] && return 0
|
|
(umask 022 && mkdir -p -- "$STATE_DIR") || die "cannot create the state directory $STATE_DIR"
|
|
chmod 0755 -- "$STATE_DIR" || die "cannot chmod 0755 $STATE_DIR"
|
|
}
|
|
|
|
# state_save: under the statefile lock, re-read the file and replay this session's changes onto
|
|
# it (a concurrent `trust` in another terminal is kept), then write it atomically (tmp file in the
|
|
# same directory + mv). Under --dry-run the changes are printed as "+ ..." lines instead.
|
|
state_save() {
|
|
((ST_DIRTY)) || return 0
|
|
local c tmp k v op key r a
|
|
if ((opt_dry_run)); then
|
|
[[ -d $STATE_DIR ]] || say "+ $(fmt_cmd mkdir -p -m 0755 "$STATE_DIR")"
|
|
for c in ${ST_CHANGES[@]+"${ST_CHANGES[@]}"}; do say "+ $STATE_FILE $c"; done
|
|
ST_CHANGES=() ST_OPS=() ST_DIRTY=0
|
|
return 0
|
|
fi
|
|
state_mkdir
|
|
su_lock trusted 30 || die "another $SU_PROG is writing $STATE_FILE (lock $STATE_DIR/trusted.lock): try again"
|
|
state_read quiet
|
|
for c in ${ST_OPS[@]+"${ST_OPS[@]}"}; do
|
|
IFS=$US read -r op key r a <<<"$c"
|
|
case $op in
|
|
add) state_apply_add "$key" "$r" "$a" || true ;;
|
|
del) if [[ -n ${ST_ROW[$key]+x} && ${ST_ROW[$key]%%$'\t'*} == "$r" ]]; then unset 'ST_ROW[$key]'; fi ;;
|
|
esac
|
|
done
|
|
tmp=$STATE_DIR/trusted.tsv.tmp.$$
|
|
SU_TMP+=("$tmp")
|
|
{
|
|
if ((${#ST_COMMENTS[@]})); then printf '%s\n' "${ST_COMMENTS[@]}"; else printf '%s\n' "$SU_STATE_HEADER"; fi
|
|
if ((${#ST_ROW[@]})); then
|
|
for k in ${ST_ROW[@]+"${!ST_ROW[@]}"}; do
|
|
v=${ST_ROW[$k]}
|
|
printf '%s\t%s\n' "$k" "$v"
|
|
done | sort -t $'\t' -k1,1 -k2,2
|
|
fi
|
|
} >"$tmp" || die "cannot write $tmp"
|
|
chmod 0644 -- "$tmp" || die "cannot chmod $tmp"
|
|
mv -f -- "$tmp" "$STATE_FILE" || die "cannot replace $STATE_FILE"
|
|
su_unlock trusted
|
|
ST_CHANGES=() ST_OPS=() ST_DIRTY=0
|
|
}
|
|
|
|
# state_writable: can we (as the current user) create/replace the statefile?
|
|
state_writable() {
|
|
local d=$STATE_DIR
|
|
if [[ -d $d ]]; then
|
|
[[ -w $d && -x $d && (! -e $STATE_FILE || -w $STATE_FILE) ]]
|
|
return
|
|
fi
|
|
while [[ ! -e $d && $d == */* ]]; do d=${d%/*}; done
|
|
[[ -n $d ]] || d=/
|
|
[[ -e $d ]] || d=.
|
|
[[ -d $d && -w $d ]]
|
|
}
|
|
|
|
# closure_of PKG -> array CLOSURE (installed dependency closure, PKG excluded)
|
|
CLOSURE=()
|
|
closure_of() {
|
|
local out=''
|
|
CLOSURE=()
|
|
out=$(be closure "$1") || {
|
|
warn "could not compute the dependency closure of $1"
|
|
out=''
|
|
}
|
|
local d
|
|
while IFS= read -r d; do
|
|
[[ -n $d && $d != "$1" ]] && CLOSURE+=("$d")
|
|
done <<<"$out"
|
|
return 0
|
|
}
|
|
|
|
# trust_pkg PKG DATE: add the 'user' row and the 'dep:PKG' rows; prints a summary line
|
|
trust_pkg() {
|
|
local pkg=$1 now=$2 d n=0 msg
|
|
local -a added=()
|
|
if state_add "$BACKEND" "$pkg" user "$now"; then msg="trusted $pkg"; else msg="$pkg was already trusted"; fi
|
|
closure_of "$pkg"
|
|
for d in ${CLOSURE[@]+"${CLOSURE[@]}"}; do
|
|
if state_add "$BACKEND" "$d" "dep:$pkg" "$now"; then
|
|
added+=("$d")
|
|
n=$((n + 1))
|
|
fi
|
|
done
|
|
if ((n)); then
|
|
msg+=" (+$n $(plural "$n" dependency dependencies): ${added[*]})"
|
|
elif ((${#CLOSURE[@]} == 1)); then
|
|
msg+=" (its installed dependency was already trusted)"
|
|
elif ((${#CLOSURE[@]})); then
|
|
msg+=" (all ${#CLOSURE[@]} installed dependencies were already trusted)"
|
|
fi
|
|
say "$msg"
|
|
}
|
|
|
|
# ===================================================================================================
|
|
# root handling
|
|
# ===================================================================================================
|
|
needs_root() {
|
|
((opt_dry_run)) && return 1
|
|
case $cmd in
|
|
'') return 0 ;;
|
|
list) ((opt_refresh)) ;;
|
|
trust | untrust | refresh) ! state_writable ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# self_path: absolute path of this script (works for a copy that is not executable or not in PATH)
|
|
self_path() {
|
|
local src=${BASH_SOURCE[0]}
|
|
if [[ $src == */* ]]; then
|
|
printf '%s/%s' "$(cd -- "${src%/*}" && pwd -P)" "${src##*/}"
|
|
elif [[ -f $src ]]; then
|
|
printf '%s/%s' "$(pwd -P)" "$src"
|
|
else
|
|
command -v -- "$src"
|
|
fi
|
|
}
|
|
|
|
ensure_root() {
|
|
((SU_EUID == 0)) && return 0
|
|
needs_root || return 0
|
|
if ((SU_REEXEC)); then
|
|
die "re-executed through sudo but still not running as root (euid $SU_EUID); not trying again"
|
|
fi
|
|
if have sudo; then
|
|
local self
|
|
local -a run=() extra=()
|
|
self=$(self_path) || die "cannot locate myself ($0) to re-run through sudo"
|
|
# sudo runs this very file, so a sudoers rule for it keeps matching; only a copy that is not
|
|
# executable is run through bash
|
|
if [[ -x $self ]]; then run=("$self"); else run=("$BASH" "$self"); fi
|
|
if ((!opt_state_dir_set)) && [[ -n ${SKEPTICAL_UPDATER_STATE_DIR:-} ]]; then extra+=("--state-dir=$STATE_DIR"); fi
|
|
if [[ -n ${NO_COLOR:-} ]]; then extra+=(--no-color); fi
|
|
exec sudo -- "${run[@]}" --sudo-reexec ${extra[@]+"${extra[@]}"} ${SU_ARGS[@]+"${SU_ARGS[@]}"}
|
|
fi
|
|
local what
|
|
case $cmd in
|
|
'') what="upgrading needs root" ;;
|
|
list) what="refreshing the package lists needs root (use --no-refresh)" ;;
|
|
*) what="writing $STATE_FILE needs root" ;;
|
|
esac
|
|
die "$what and sudo is not available; run $SU_PROG as root (or use --dry-run)"
|
|
}
|
|
|
|
# recover_leftovers: at the start of every root run, undo what an interrupted run (kill -9,
|
|
# power loss) left behind: apt temporary holds (journal), a private zypper configuration, a
|
|
# throwaway pacman database. Each only under its lock / when its owner is gone.
|
|
recover_leftovers() {
|
|
((SU_EUID == 0 && !opt_dry_run)) || return 0
|
|
case $BACKEND in
|
|
apt)
|
|
[[ -d $STATE_DIR && -f $APT_JOURNAL ]] || return 0
|
|
if ! su_lock holds 0; then
|
|
say "Note: another $SU_PROG run is using $APT_JOURNAL; leaving its temporary holds alone."
|
|
return 0
|
|
fi
|
|
# the lock stays held if the replay failed (released at exit; the journal stays for next time)
|
|
if apt_holds_restore replay; then su_unlock holds; fi
|
|
;;
|
|
zypper)
|
|
[[ -d $STATE_DIR && -f $STATE_DIR/zypper-private ]] || return 0
|
|
if ! su_lock zypper 0; then
|
|
say "Note: another $SU_PROG run is using its private zypper configuration; leaving it alone."
|
|
return 0
|
|
fi
|
|
zypper_private_cleanup replay
|
|
su_unlock zypper
|
|
;;
|
|
pacman) pacman_stale_cleanup ;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# ===================================================================================================
|
|
# backend: apt (Debian, Ubuntu)
|
|
# ===================================================================================================
|
|
# discovery/apt/parse-inst.awk: "Inst" lines of `apt-get -s` -> name<TAB>old<TAB>new<TAB>repo
|
|
# shellcheck disable=SC2016 # awk program, not shell
|
|
readonly AWK_APT_INST='
|
|
/^Inst / {
|
|
name = $2
|
|
rest = $0
|
|
sub(/^Inst [^ ]+ /, "", rest)
|
|
old = "-"
|
|
if (substr(rest, 1, 1) == "[") { # "[old] (new origins [arch])"
|
|
old = rest
|
|
sub(/^\[/, "", old)
|
|
sub(/\].*$/, "", old)
|
|
sub(/^\[[^]]*\] /, "", rest)
|
|
}
|
|
sub(/^\(/, "", rest) # "new origins [arch]) ..."
|
|
new = rest
|
|
sub(/ .*$/, "", new)
|
|
sub(/^[^ ]+ ?/, "", rest) # "origins [arch]) ..."
|
|
i = index(rest, "])")
|
|
repo = substr(rest, 1, i - 1) # "origins [arch"
|
|
sub(/ ?\[[^[]*$/, "", repo) # drop " [arch"
|
|
printf "%s\t%s\t%s\t%s\n", name, old, new, repo
|
|
}'
|
|
# the "kept back" block of `apt-get -s upgrade` (held and otherwise kept-back packages)
|
|
# shellcheck disable=SC2016 # awk program, not shell
|
|
readonly AWK_APT_KEPT='/^The following packages have been kept back:/{k=1;next} k&&/^ /{for(i=1;i<=NF;i++)print $i;next} {k=0}'
|
|
# `apt-cache policy P...` -> name<TAB>installed<TAB>candidate<TAB>origin of the candidate
|
|
# shellcheck disable=SC2016 # awk program, not shell
|
|
readonly AWK_APT_POLICY='
|
|
/^[^ ].*:$/ { name = substr($0, 1, length($0) - 1); vt = 0; want = 0; names[name] = 1; next }
|
|
name == "" { next }
|
|
/^ Installed: / { inst[name] = $2; next }
|
|
/^ Candidate: / { cand[name] = $2; next }
|
|
/^ Version table:/ { vt = 1; next }
|
|
vt && /^ (\*\*\*| ) [^ ]/ { v = $0; sub(/^ (\*\*\*| ) /, "", v); sub(/ .*$/, "", v); want = (v == cand[name] && !(name in repo)); next }
|
|
vt && want && /^ *[0-9-]+ / {
|
|
o = $0; sub(/^ *[0-9-]+ /, "", o)
|
|
if (o !~ /^\/var\/lib\/dpkg\/status/) { sub(/ Packages$/, "", o); repo[name] = o; want = 0 }
|
|
next
|
|
}
|
|
END { for (n in names) printf "%s\t%s\t%s\t%s\n", n, ((n in inst) ? inst[n] : "-"), ((n in cand) ? cand[n] : "-"), ((n in repo) ? repo[n] : "-") }'
|
|
# discovery/apt/closure-dpkg.awk: installed dependency closure from the dpkg database (roots included)
|
|
# shellcheck disable=SC2016 # awk program, not shell
|
|
readonly AWK_APT_CLOSURE='
|
|
function trim(s) { gsub(/^[ ]+|[ ]+$/, "", s); return s }
|
|
substr($1, 2, 1) ~ /[nc]/ { next } # not-installed / config-files-only
|
|
{
|
|
inst[$2] = 1
|
|
deps[$2] = $3 "," $4
|
|
n = split($5, pv, ",")
|
|
for (i = 1; i <= n; i++) {
|
|
p = trim(pv[i]); sub(/ .*/, "", p); sub(/:.*/, "", p)
|
|
if (p != "") prov[p] = prov[p] " " $2
|
|
}
|
|
}
|
|
END {
|
|
nr = split(roots, r, " ")
|
|
for (i = 1; i <= nr; i++) if (inst[r[i]] && !seen[r[i]]) { seen[r[i]] = 1; q[++t] = r[i] }
|
|
for (h = 1; h <= t; h++) {
|
|
p = q[h]; print p
|
|
m = split(deps[p], d, ",")
|
|
for (j = 1; j <= m; j++) {
|
|
na = split(d[j], alt, "|")
|
|
for (k = 1; k <= na; k++) {
|
|
a = alt[k]
|
|
gsub(/\([^)]*\)/, "", a); gsub(/\[[^]]*\]/, "", a); gsub(/<[^>]*>/, "", a)
|
|
a = trim(a); sub(/:(any|native)$/, "", a)
|
|
if (a == "") continue
|
|
if (inst[a] && !seen[a]) { seen[a] = 1; q[++t] = a }
|
|
np = split(prov[a], pp, " ")
|
|
for (x = 1; x <= np; x++) if (!seen[pp[x]]) { seen[pp[x]] = 1; q[++t] = pp[x] }
|
|
}
|
|
}
|
|
}
|
|
}'
|
|
|
|
APT_JOURNAL=
|
|
|
|
apt_init() {
|
|
have apt-get || die "apt backend: apt-get not found in PATH"
|
|
APT_JOURNAL=$STATE_DIR/holds.added
|
|
}
|
|
apt_label() { printf 'apt'; }
|
|
apt_refresh() { refresh_run apt-get update -qq -o APT::Update::Error-Mode=any; }
|
|
|
|
apt_upgradable() {
|
|
local sim rc=0 kept pol line n
|
|
local -A seen=()
|
|
local -a back=()
|
|
sim=$(apt-get -s --with-new-pkgs upgrade </dev/null) || rc=$?
|
|
if ((rc)); then
|
|
err "apt-get -s --with-new-pkgs upgrade failed (exit $rc)"
|
|
return "$rc"
|
|
fi
|
|
while IFS= read -r line; do
|
|
[[ -n $line ]] || continue
|
|
n=${line%%$'\t'*}
|
|
seen[$n]=1
|
|
[[ $line != *$'\t' ]] || line+=-
|
|
printf '%s\t\n' "$line"
|
|
done < <(awk "$AWK_APT_INST" <<<"$sim")
|
|
kept=$(awk "$AWK_APT_KEPT" <<<"$sim")
|
|
while IFS= read -r n; do
|
|
[[ -n $n && -z ${seen[$n]+x} ]] || continue
|
|
seen[$n]=1
|
|
back+=("$n")
|
|
done <<<"$kept"
|
|
((${#back[@]})) || return 0
|
|
pol=$(apt-cache policy "${back[@]}" </dev/null 2>/dev/null) || true
|
|
awk "$AWK_APT_POLICY" <<<"$pol" | while IFS=$'\t' read -r n line; do
|
|
printf '%s\t%s\theld\n' "$n" "$line"
|
|
done
|
|
}
|
|
|
|
apt_describe() {
|
|
local out
|
|
out=$(apt-cache show --no-all-versions "$1" </dev/null 2>/dev/null) || return 1
|
|
# Description: or, with Translation files, Description-en: / Description-pt_BR: (discovery/apt.md item 3)
|
|
out=$(sed -n -E '/^Description(-[a-z][a-z](_[A-Z][A-Z])?)?: /{s///p;q}' <<<"$out")
|
|
[[ -n $out ]] || out='(no description available)'
|
|
printf '%s\n' "$out"
|
|
}
|
|
apt_full_info() { apt-cache show --no-all-versions "$1" </dev/null | gutter; }
|
|
# apt_installed_record PKG: dpkg's own record (`dpkg-query -s`: richer than `apt-cache show
|
|
# PKG=<installed version>`, which gives the archive's record when that version is still listed:
|
|
# no Status, no Conffiles, often only the short description); a package dpkg merely remembers
|
|
# (removed, config files left) is not installed. apt_candidate_record PKG: the candidate.
|
|
apt_installed_record() {
|
|
local out st
|
|
out=$(dpkg-query -s -- "$1" </dev/null 2>/dev/null) || return 1
|
|
st=$(sed -n 's/^Status: //p' <<<"$out")
|
|
case ${st##* } in '' | not-installed | config-files) return 1 ;; esac
|
|
printf '%s\n' "$out"
|
|
}
|
|
apt_candidate_record() { apt-cache show --no-all-versions "$1" </dev/null 2>/dev/null; }
|
|
apt_rdepends() {
|
|
apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances "$1" </dev/null 2>/dev/null |
|
|
awk '/^Reverse Depends:/{p=1;next} p{sub(/^[ |]+/,""); print}' | sort -u
|
|
}
|
|
# apt_reason PKG: apt's auto flag (discovery/apt.md item 13): listed by `apt-mark showauto` =
|
|
# dependency, by `showmanual` = explicit; in neither list but installed (dpkg) = explicit, else not
|
|
# installed. Both are read-only: no root, no dpkg lock (verified). apt-mark prints native packages
|
|
# without their architecture qualifier and treats a name that is no package as a pattern, hence
|
|
# the exact-line match.
|
|
apt_reason() {
|
|
local out st
|
|
out=$(apt-mark showauto "$1" </dev/null 2>/dev/null) || {
|
|
printf 'unknown\n'
|
|
return 0
|
|
}
|
|
if apt_mark_lists "$1" "$out"; then
|
|
printf 'dependency\n'
|
|
return 0
|
|
fi
|
|
out=$(apt-mark showmanual "$1" </dev/null 2>/dev/null) || {
|
|
printf 'unknown\n'
|
|
return 0
|
|
}
|
|
if apt_mark_lists "$1" "$out"; then
|
|
printf 'explicit\n'
|
|
return 0
|
|
fi
|
|
# ${db:Status-Abbrev}: 2nd character = state; n(ot-installed) and c(onfig-files) = not installed
|
|
st=$(dpkg-query -W -f '${db:Status-Abbrev}' "$1" </dev/null 2>/dev/null) || st=
|
|
case ${st:1:1} in
|
|
'' | n | c) printf 'not-installed\n' ;;
|
|
*) printf 'explicit\n' ;;
|
|
esac
|
|
}
|
|
apt_mark_lists() { # apt_mark_lists PKG OUTPUT: does OUTPUT (apt-mark show*) name PKG (name:arch too)?
|
|
local o=$'\n'$2$'\n'
|
|
[[ $o == *$'\n'"$1"$'\n'* ]] && return 0
|
|
[[ $1 == *:* && $o == *$'\n'"${1%:*}"$'\n'* ]]
|
|
}
|
|
apt_closure() {
|
|
dpkg-query -W -f '${db:Status-Abbrev}\t${Package}\t${Pre-Depends}\t${Depends}\t${Provides}\n' </dev/null 2>/dev/null |
|
|
awk -F'\t' -v roots="$1" "$AWK_APT_CLOSURE" | awk -v r="$1" '$0 != r' | sort -u
|
|
}
|
|
|
|
# apt_purge_plan PKG: every package `apt_purge` will remove (PKG, its reverse dependencies, and
|
|
# the automatically installed dependencies this purge orphans). `apt-get purge --auto-remove`
|
|
# is system-wide (it also takes pre-existing orphans, e.g. old kernels: verified), so the
|
|
# orphans that were already autoremovable before are subtracted and the rest named explicitly.
|
|
apt_purge_plan() {
|
|
local pre sim w name rest
|
|
local -A before=() requested=()
|
|
local -a args=("$@")
|
|
for name in "$@"; do requested[$name]=1; done
|
|
pre=$(apt-get -s autoremove </dev/null 2>/dev/null) || pre=''
|
|
while read -r w name rest; do
|
|
[[ $w == Remv || $w == Purg ]] && before[$name]=1
|
|
done <<<"$pre"
|
|
sim=$(apt-get -s purge --auto-remove "$@" </dev/null 2>&1) || {
|
|
printf '%s\n' "$sim" >&2
|
|
return 1
|
|
}
|
|
while read -r w name rest; do
|
|
[[ $w == Remv || $w == Purg ]] || continue
|
|
[[ -z ${before[$name]+x} && -z ${requested[$name]+x} ]] && args+=("$name")
|
|
done <<<"$sim"
|
|
sim=$(apt-get -s purge "${args[@]}" </dev/null 2>&1) || {
|
|
printf '%s\n' "$sim" >&2
|
|
return 1
|
|
}
|
|
while read -r w name rest; do
|
|
if [[ $w == Remv || $w == Purg ]]; then printf '%s\n' "$name"; fi
|
|
done <<<"$sim"
|
|
}
|
|
apt_purge() { run_cmd apt-get -y purge "$@"; }
|
|
|
|
# apt_lock_busy OUTPUT: does OUTPUT report a busy apt/dpkg lock (and nothing else, e.g. a
|
|
# package name that merely contains "lock")? apt-mark itself prints dpkg's
|
|
# "dpkg frontend lock was locked by another process with pid N" (dpkg 1.21) or
|
|
# "... was locked by /usr/bin/perl process with pid N" (dpkg 1.22+) - both verified.
|
|
apt_lock_busy() {
|
|
case $1 in
|
|
*'Could not get lock'* | *'Unable to acquire the dpkg frontend lock'* | *'Unable to lock'* | *'lock was locked by '*) return 0 ;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
# apt_mark hold|unhold NAME...: apt-mark ignores DPkg::Lock::Timeout (verified), so retry for up
|
|
# to 60 s while dpkg's lock is busy, like the upgrade itself waits (-o DPkg::Lock::Timeout=60).
|
|
apt_mark() {
|
|
local out rc end=$((SECONDS + 60))
|
|
while :; do
|
|
rc=0
|
|
out=$(run_child apt-mark "$@" 2>&1) || rc=$?
|
|
if ((rc != 0)) && apt_lock_busy "$out" && ((SECONDS < end)); then
|
|
sleep 1
|
|
continue
|
|
fi
|
|
break
|
|
done
|
|
[[ -z $out ]] || say "$out"
|
|
return "$rc"
|
|
}
|
|
|
|
# Temporary holds with a write-ahead journal ($STATE_DIR/holds.added, used under holds.lock): only
|
|
# packages that are not already held are held, and exactly those are released afterwards - also
|
|
# after a crash: the journal is replayed at the start of the next root run (discovery/apt.md item 6).
|
|
apt_holds_begin() {
|
|
(($#)) || return 0
|
|
local held p
|
|
local -a add=()
|
|
held=$'\n'$(apt-mark showhold </dev/null)$'\n' || return 1
|
|
for p in "$@"; do
|
|
[[ $held == *$'\n'"$p"$'\n'* ]] && continue
|
|
add+=("$p")
|
|
done
|
|
((${#add[@]})) || return 0
|
|
if ((opt_dry_run)); then
|
|
APT_DRY_HOLDS=("${add[@]}")
|
|
run_cmd apt-mark hold "${add[@]}"
|
|
return 0
|
|
fi
|
|
printf '%s\n' "${add[@]}" >>"$APT_JOURNAL" || return 1 # journal first ...
|
|
APT_HOLDS_ACTIVE=1
|
|
say "+ $(fmt_cmd apt-mark hold "${add[@]}")"
|
|
apt_mark hold "${add[@]}" # ... then hold
|
|
}
|
|
|
|
# apt_holds_restore [replay]: release exactly the journalled holds that are still in place.
|
|
# Runs from the exit handler too: every print is guarded, nothing here may abort half-way.
|
|
apt_holds_restore() {
|
|
local mode=${1:-} held p
|
|
local -a ours=() rel=()
|
|
local -A done_=()
|
|
if ((opt_dry_run)); then
|
|
if ((${#APT_DRY_HOLDS[@]})); then run_cmd apt-mark unhold "${APT_DRY_HOLDS[@]}"; fi
|
|
APT_DRY_HOLDS=()
|
|
return 0
|
|
fi
|
|
[[ -f $APT_JOURNAL ]] || {
|
|
APT_HOLDS_ACTIVE=0
|
|
return 0
|
|
}
|
|
mapfile -t ours <"$APT_JOURNAL"
|
|
held=$'\n'$(apt-mark showhold </dev/null 2>/dev/null)$'\n' || return 1
|
|
for p in ${ours[@]+"${ours[@]}"}; do
|
|
[[ -n $p && -z ${done_[$p]+x} ]] || continue
|
|
done_[$p]=1
|
|
[[ $held == *$'\n'"$p"$'\n'* ]] && rel+=("$p")
|
|
done
|
|
if ((${#rel[@]})); then
|
|
say "+ $(fmt_cmd apt-mark unhold "${rel[@]}")"
|
|
apt_mark unhold "${rel[@]}" || {
|
|
err "could not release the temporary holds (${rel[*]}); they are recorded in $APT_JOURNAL and will be released by the next run"
|
|
return 1
|
|
}
|
|
fi
|
|
rm -f -- "$APT_JOURNAL"
|
|
APT_HOLDS_ACTIVE=0
|
|
if [[ $mode == replay ]]; then
|
|
if ((${#rel[@]})); then
|
|
say "Released temporary holds left over by an interrupted run: ${rel[*]}"
|
|
else
|
|
say "Removed a stale hold journal of an interrupted run (nothing was still held)."
|
|
fi
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# the holds lock is released only once our holds are gone: while APT_HOLDS_ACTIVE=1 the exit
|
|
# handler's retry must still run under it (it could otherwise release another run's fresh holds)
|
|
apt_holds_unlock() { if ((!opt_dry_run && !APT_HOLDS_ACTIVE)); then su_unlock holds; fi; }
|
|
|
|
apt_upgrade() {
|
|
local rc=0
|
|
if ((!opt_dry_run)); then
|
|
su_lock holds 60 || {
|
|
err "another $SU_PROG upgrade is running (lock $STATE_DIR/holds.lock)"
|
|
return 1
|
|
}
|
|
fi
|
|
if ! apt_holds_begin "$@"; then
|
|
err "could not place the temporary holds"
|
|
apt_holds_restore || true
|
|
apt_holds_unlock
|
|
return 1
|
|
fi
|
|
run_cmd apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold \
|
|
-o DPkg::Lock::Timeout=60 --with-new-pkgs upgrade || rc=$?
|
|
apt_holds_restore || { ((rc)) || rc=1; }
|
|
apt_holds_unlock
|
|
return "$rc"
|
|
}
|
|
|
|
# ===================================================================================================
|
|
# backend: pacman (Arch Linux; label "pman" when pman is installed)
|
|
# ===================================================================================================
|
|
# The refresh syncs a THROWAWAY database (discovery/pacman.md design B): `pacman -Sy --dbpath TMP`
|
|
# with TMP/local -> the real local database. Listing, list-only runs, q, EOF and --dry-run never
|
|
# touch the real sync database. At <enter> the reviewed transaction is checked against TMP
|
|
# (`-Sup`; on failure nothing is changed), then TMP's databases become the system's and
|
|
# `pacman -Su` applies exactly the metadata that was reviewed.
|
|
PM_DB=() # (--dbpath TMP) once the throwaway database is synced
|
|
PACMAN_SY_FAILED=0 # the refresh was attempted and failed
|
|
PACMAN_DB_LOCK_OURS= # pacman's db.lck while we hold it (removed by the exit handler too)
|
|
PACMAN_DBS_PENDING=0 # reviewed databases installed, `pacman -Su` not completed yet
|
|
|
|
pacman_db_unlock() {
|
|
if [[ -n $PACMAN_DB_LOCK_OURS ]]; then rm -f -- "$PACMAN_DB_LOCK_OURS" 2>/dev/null; fi
|
|
PACMAN_DB_LOCK_OURS=
|
|
}
|
|
pacman_pending_warning() {
|
|
warn "the system's pacman sync database now holds the reviewed metadata but 'pacman -Su' did not complete: it is newer than the installed packages. Finish the upgrade with 'pacman -Su' (or run $SU_PROG again) before installing anything with 'pacman -S'."
|
|
}
|
|
pacman_exit_cleanup() {
|
|
pacman_db_unlock
|
|
if ((PACMAN_DBS_PENDING)); then pacman_pending_warning; fi
|
|
PACMAN_DBS_PENDING=0
|
|
}
|
|
|
|
pacman_init() {
|
|
have pacman || die "pacman backend: pacman not found in PATH"
|
|
aur_detect
|
|
}
|
|
pacman_label() { if have pman; then printf 'pman'; else printf 'pacman'; fi; }
|
|
_pm() { COLUMNS=0 pacman ${PM_DB[@]+"${PM_DB[@]}"} "$@" </dev/null; }
|
|
pacman_dbpath() {
|
|
local p=''
|
|
if have pacman-conf; then p=$(pacman-conf DBPath 2>/dev/null) || p=''; fi
|
|
p=${p:-/var/lib/pacman/}
|
|
printf '%s' "${p%/}"
|
|
}
|
|
# throwaway databases live in /tmp (not $TMPDIR: pacman's download sandbox user must be able to
|
|
# traverse it) and carry their owner's pid in the name, so a stale one is recognisable
|
|
readonly PACMAN_TMP_PREFIX=/tmp/$SU_PROG-db.
|
|
|
|
# pacman_stale_cleanup: remove the throwaway databases of runs that are gone (kill -9)
|
|
pacman_stale_cleanup() {
|
|
local d pid
|
|
for d in "$PACMAN_TMP_PREFIX"*; do
|
|
[[ -d $d ]] || continue
|
|
pid=${d#"$PACMAN_TMP_PREFIX"}
|
|
pid=${pid%%.*}
|
|
[[ $pid =~ ^[0-9]+$ ]] || continue
|
|
pid_alive "$pid" && continue
|
|
# rm -rf removes the "local" symlink itself, never the real database it points at
|
|
rm -rf -- "$d" && say "Removed the temporary pacman database of an interrupted run ($d)."
|
|
done
|
|
return 0
|
|
}
|
|
|
|
pacman_refresh() {
|
|
local real tmp f
|
|
real=$(pacman_dbpath)
|
|
if ((SU_EUID != 0)); then
|
|
refresh_run pacman -Sy --dbpath "${PACMAN_TMP_PREFIX}PID.XXXXXX" --logfile /dev/null
|
|
return 0
|
|
fi
|
|
if ! tmp=$(mktemp -d "$PACMAN_TMP_PREFIX$$.XXXXXX"); then
|
|
warn "cannot create a temporary pacman database in /tmp: not refreshing"
|
|
PACMAN_SY_FAILED=1
|
|
return 0
|
|
fi
|
|
SU_TMP+=("$tmp")
|
|
if ! chmod 0755 -- "$tmp" || ! ln -s -- "$real/local" "$tmp/local" || ! mkdir -- "$tmp/sync"; then
|
|
warn "cannot set up the temporary pacman database $tmp (chmod/ln/mkdir failed): not refreshing"
|
|
PACMAN_SY_FAILED=1
|
|
return 0
|
|
fi
|
|
# start from the current databases (and their signatures, needed with SigLevel = Required):
|
|
# only what changed is downloaded
|
|
for f in "$real"/sync/*.db "$real"/sync/*.db.sig; do
|
|
[[ -f $f ]] || continue
|
|
cp -p -- "$f" "$tmp/sync/" || warn "cannot copy $f into the temporary pacman database"
|
|
done
|
|
if refresh_run pacman -Sy --dbpath "$tmp" --logfile /dev/null; then
|
|
PM_DB=(--dbpath "$tmp")
|
|
else
|
|
PACMAN_SY_FAILED=1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ---- AUR (pacman backend only): paru or yay ---------------------------------------------------------
|
|
# discovery/pacman.md section 15. The helper lists (-Qua), describes (-Sia) and upgrades (-Sua) the
|
|
# AUR packages; everything about installed packages (N?, N+, Nx) stays with pacman. It never runs as
|
|
# root (makepkg refuses to build as root): when we are root through our sudo re-exec it runs as the
|
|
# invoking user ($SUDO_USER, with that user's HOME and from that home); unprivileged (list
|
|
# --no-refresh, info, show, --dry-run) it runs as we are; a root login (no SUDO_USER) gets no AUR.
|
|
AUR_HELPER= # paru | yay; '' = no AUR helper in use
|
|
AUR_STATE= # '' = not decided yet | on | off (aur_prepare)
|
|
AUR_RUN=() # prefix running the helper as the invoking user: runuser|sudo -u USER -- env HOME=...
|
|
AUR_HOME= # the helper's working directory (that user's home) when AUR_RUN is set
|
|
AUR_PENDING_WARN= # why the AUR is off, printed right after the banner
|
|
PW_UID='' PW_HOME='' # passwd_entry results
|
|
|
|
# aur_detect (pacman_init, i.e. before any sudo re-exec): --aur-helper/--no-aur, else paru, then yay;
|
|
# a helper asked for by name that is not installed is an error
|
|
aur_detect() {
|
|
AUR_HELPER=
|
|
case $opt_aur in
|
|
none) ;;
|
|
paru | yay)
|
|
have "$opt_aur" || die "--aur-helper=$opt_aur: $opt_aur not found in PATH"
|
|
AUR_HELPER=$opt_aur
|
|
;;
|
|
*) if have paru; then AUR_HELPER=paru; elif have yay; then AUR_HELPER=yay; fi ;;
|
|
esac
|
|
}
|
|
|
|
# passwd_entry USER: PW_UID and PW_HOME of USER (getent, else /etc/passwd); 1 if unknown
|
|
passwd_entry() {
|
|
local line='' name x uid home
|
|
if have getent; then
|
|
line=$(getent passwd "$1" 2>/dev/null) || line=''
|
|
elif [[ -r /etc/passwd ]]; then
|
|
while IFS= read -r x; do
|
|
if [[ ${x%%:*} == "$1" ]]; then
|
|
line=$x
|
|
break
|
|
fi
|
|
done </etc/passwd
|
|
fi
|
|
IFS=: read -r name x uid x x home x <<<"$line"
|
|
[[ $name == "$1" && $uid =~ ^[0-9]+$ && -n $home ]] || return 1
|
|
PW_UID=$uid PW_HOME=$home
|
|
}
|
|
|
|
# aur_prepare [quiet]: decide once how the helper runs; 1 = no AUR this run. The reason for "no AUR"
|
|
# is warned about once: right away, or with "quiet" after the banner (aur_warn_pending); never from a
|
|
# subshell (the listing runs in one: the banner decides first).
|
|
aur_prepare() {
|
|
case $AUR_STATE in on) return 0 ;; off) return 1 ;; esac
|
|
[[ -n $AUR_HELPER ]] || {
|
|
AUR_STATE=off
|
|
return 1
|
|
}
|
|
if ((SU_EUID != 0)); then
|
|
AUR_RUN=() AUR_HOME='' AUR_STATE=on
|
|
return 0
|
|
fi
|
|
local u=${SUDO_USER:-} why='' re='^[A-Za-z0-9_][A-Za-z0-9_.-]*[$]?$'
|
|
if [[ -z $u ]]; then
|
|
why="$AUR_HELPER is never run as root and there is no invoking user (SUDO_USER is not set)"
|
|
elif [[ ! $u =~ $re ]] || ! passwd_entry "$u"; then
|
|
why="the invoking user '$u' (SUDO_USER) is unknown"
|
|
elif ((PW_UID == 0)); then
|
|
why="$AUR_HELPER is never run as root and the invoking user ($u) is root"
|
|
elif have runuser; then
|
|
AUR_RUN=(runuser -u "$u" -- env HOME="$PW_HOME")
|
|
elif have sudo; then
|
|
AUR_RUN=(sudo -u "$u" -- env HOME="$PW_HOME")
|
|
else
|
|
why="neither runuser nor sudo is available to run $AUR_HELPER as $u"
|
|
fi
|
|
if [[ -n $why ]]; then
|
|
AUR_STATE=off
|
|
why="aur: $why: AUR packages are neither listed nor upgraded (run $SU_PROG through sudo from your own account, or use --no-aur)"
|
|
if [[ ${1:-} == quiet ]]; then
|
|
AUR_PENDING_WARN=$why
|
|
elif ((BASH_SUBSHELL == 0)); then
|
|
warn "$why"
|
|
fi
|
|
return 1
|
|
fi
|
|
AUR_HOME=$PW_HOME AUR_STATE=on
|
|
}
|
|
aur_warn_pending() {
|
|
[[ -z $AUR_PENDING_WARN ]] || warn "$AUR_PENDING_WARN"
|
|
AUR_PENDING_WARN=
|
|
}
|
|
|
|
# aur_exec ARGS...: run the helper (as the invoking user when we are root), stdin from /dev/null,
|
|
# our lock descriptors closed, from that user's home; COLUMNS=0: yay wraps -Sia lists at 80 columns
|
|
# otherwise, even without a terminal (discovery/pacman.md 15.2; the merge unwraps anyway)
|
|
aur_exec() {
|
|
(
|
|
export COLUMNS=0
|
|
if [[ -n $AUR_HOME ]]; then cd -- "$AUR_HOME" 2>/dev/null || cd /; fi
|
|
run_child ${AUR_RUN[@]+"${AUR_RUN[@]}"} "$AUR_HELPER" "$@"
|
|
)
|
|
}
|
|
|
|
# aur_upgradable: AUR rows (name<TAB>old<TAB>new<TAB>aur<TAB>aur[ held]) from `<helper> -Qua`:
|
|
# "name old -> new", then " [ignored]" (paru; yay leaves IgnorePkg packages out) or yay 13's time
|
|
# since the AUR change (" [122d13h]"). The helper asks the AUR RPC every time (no refresh needed).
|
|
# Exit 1 with nothing on stdout and stderr = nothing to upgrade; any other failure (the AUR is
|
|
# unreachable) is warned about with the helper's messages, and no AUR row is listed.
|
|
aur_upgradable() {
|
|
local out rc=0 errf err='' l
|
|
errf=$(mktemp "${TMPDIR:-/tmp}/$SU_PROG.XXXXXX") || errf=/dev/null
|
|
out=$(aur_exec -Qua 2>"$errf") || rc=$?
|
|
if [[ $errf != /dev/null ]]; then
|
|
err=$(cat -- "$errf")
|
|
rm -f -- "$errf"
|
|
fi
|
|
if ((rc)); then
|
|
[[ $rc -ne 1 || -n $out || -n ${err//[[:space:]]/} ]] || return 0
|
|
while IFS= read -r l; do [[ -z ${l//[[:space:]]/} ]] || warn "$AUR_HELPER: $l"; done <<<"$err"
|
|
warn "aur: $AUR_HELPER -Qua failed, AUR packages not listed"
|
|
return 0
|
|
fi
|
|
awk '{ gsub(/\033\[[0-9;]*m/, "") }
|
|
NF >= 4 && $3 == "->" && $1 ~ /^[A-Za-z0-9_+@][A-Za-z0-9_+.@-]*$/ {
|
|
printf "%s\t%s\t%s\taur\taur%s\n", $1, $2, $4, (($5 == "[ignored]") ? " held" : "")
|
|
}' <<<"$out"
|
|
}
|
|
|
|
# aur_info PKG: the AUR record of PKG, pacman-style "Key : Value" lines (paru: keys padded like
|
|
# pacman's, yay: wider; one record either way). -Sia: plain `yay -Si` only asks the repositories.
|
|
aur_info() { aur_exec -Sia "$1" 2>/dev/null; }
|
|
|
|
# aur_upgrade EXCLUDED...: `<helper> -Sua [--noconfirm] [--ignore a,b]` as the invoking user. With a
|
|
# terminal on stdin the helper gets it and asks as usual (its PKGBUILD review); otherwise
|
|
# --noconfirm, stdin from /dev/null. --dry-run prints the command line.
|
|
aur_upgrade() {
|
|
local -a c=(${AUR_RUN[@]+"${AUR_RUN[@]}"} "$AUR_HELPER" -Sua)
|
|
[[ -t 0 ]] || c+=(--noconfirm)
|
|
if (($#)); then c+=(--ignore "$(join_by , "$@")"); fi
|
|
if ((opt_dry_run)); then
|
|
say "+ $(fmt_cmd "${c[@]}")"
|
|
return 0
|
|
fi
|
|
printf '%s+ %s%s\n' "$C_DIM" "$(fmt_cmd "${c[@]}")" "$C_RESET" 2>/dev/null || true
|
|
(
|
|
if [[ -n $AUR_HOME ]]; then cd -- "$AUR_HOME" 2>/dev/null || cd /; fi
|
|
if [[ -t 0 ]]; then run_child_stdin "${c[@]}"; else run_child "${c[@]}"; fi
|
|
)
|
|
}
|
|
|
|
# discovery/pacman/lib.sh pm_upgradable: -Qu joined with the first -Sl repo; [ignored] => held.
|
|
# FILENAME == ARGV[1] (not FNR == NR): correct also when `pacman -Sl` prints nothing.
|
|
pacman_upgradable() {
|
|
local qu sup sl quraw n errf
|
|
local -A listed=()
|
|
local -a extra=()
|
|
# stderr is kept: a database pacman cannot read (e.g. a bad signature) must be visible
|
|
errf=$(mktemp "${TMPDIR:-/tmp}/$SU_PROG.XXXXXX") || errf=/dev/null
|
|
sl=$(_pm -Sl 2>>"$errf") || true
|
|
quraw=$(_pm -Qu 2>>"$errf") || true
|
|
qu=$(awk '
|
|
FILENAME == ARGV[1] { if (!($2 in repo)) repo[$2] = $1; next }
|
|
NF >= 4 { printf "%s\t%s\t%s\t%s\t%s\n", $1, $2, $4, (($1 in repo) ? repo[$1] : "-"), (($5 == "[ignored]") ? "held" : "") }
|
|
' <(printf '%s\n' "$sl") <(printf '%s\n' "$quraw"))
|
|
[[ -z $qu ]] || printf '%s\n' "$qu"
|
|
while IFS= read -r n; do [[ -n $n ]] && listed[${n%%$'\t'*}]=1; done <<<"$qu"
|
|
# AUR rows; a name the repositories list wins (a package that moved from the AUR into a
|
|
# repository, while the system's sync database the helper reads does not know that yet)
|
|
if aur_prepare; then
|
|
while IFS= read -r n; do
|
|
[[ -n $n && -z ${listed[${n%%$'\t'*}]+x} ]] && printf '%s\n' "$n"
|
|
done < <(aur_upgradable)
|
|
fi
|
|
# what the upgrade really installs: new dependencies and replacements are not in -Qu
|
|
sup=$(_pm -Sup --print-format '%n' 2>>"$errf") || sup=''
|
|
while IFS= read -r n; do
|
|
[[ -n $n && -z ${listed[$n]+x} ]] && extra+=("$n")
|
|
done <<<"$sup"
|
|
if ((${#extra[@]})); then printf '#the upgrade also installs (new dependencies or replacements): %s\n' "${extra[*]}"; fi
|
|
if [[ $errf != /dev/null ]]; then
|
|
while IFS= read -r n; do
|
|
case $n in *error:* | *'database '*' is not valid'* | *'invalid or corrupted'*) warn "pacman: $n" ;; esac
|
|
done <"$errf"
|
|
rm -f -- "$errf"
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# discovery/pacman/lib.sh pm_field: pm_field <-Qi|-Si> <Field> <target>... -> name<TAB>value
|
|
pm_field() {
|
|
local op=$1 field=$2
|
|
shift 2
|
|
_pm "$op" -- "$@" 2>/dev/null | awk -v f="$field" '
|
|
function flush() { if (name != "") print name "\t" val; name = ""; val = ""; infield = 0 }
|
|
/^[ \t]*$/ { flush(); next }
|
|
/^[^ \t]/ {
|
|
infield = 0
|
|
i = index($0, ":"); k = substr($0, 1, i - 1); sub(/[ \t]+$/, "", k)
|
|
v = substr($0, i + 1); sub(/^[ \t]+/, "", v)
|
|
if (k == "Name") name = v
|
|
else if (k == f) { val = v; infield = 1 }
|
|
next
|
|
}
|
|
infield { sub(/^[ \t]+/, ""); val = val " " $0 }
|
|
END { flush() }'
|
|
}
|
|
|
|
pacman_describe() {
|
|
local out='' op
|
|
for op in -Qi -Si; do
|
|
out=$(pm_field "$op" Description "$1") || true
|
|
out=${out%%$'\n'*}
|
|
out=${out#*$'\t'}
|
|
[[ -z $out ]] || break
|
|
done
|
|
[[ -n $out ]] || return 1
|
|
printf '%s\n' "$out"
|
|
}
|
|
# pacman_full_info PKG: -Qi and -Si as pacman prints them (each record ends with a blank line), with
|
|
# the " " gutter. A package in no repository: the AUR record when a helper is in use, every line
|
|
# marked "* " (reached only when it does not merge with -Qi - paru's and yay's records do)
|
|
pacman_full_info() {
|
|
local ok=1 qi si aur=''
|
|
qi=$(_pm -Qi -- "$1" 2>/dev/null) && ok=0
|
|
si=$(_pm -Si -- "$1" 2>/dev/null) && ok=0
|
|
if [[ -z $si ]] && aur_prepare; then aur=$(aur_info "$1") || aur=''; fi
|
|
[[ -z $aur ]] || say "# lines starting with * are the AUR record ($AUR_HELPER -Sia) as it prints it"
|
|
[[ -z $qi ]] || printf '%s\n\n' "$qi" | gutter
|
|
[[ -z $si ]] || printf '%s\n\n' "$si" | gutter
|
|
if [[ -n $aur ]]; then
|
|
printf '%s\n' "$aur" | sed '/./s/^/* /'
|
|
ok=0
|
|
fi
|
|
return "$ok"
|
|
}
|
|
# pacman_installed_record PKG: -Qi. pacman_candidate_record PKG: -Si of the repository pacman would
|
|
# take it from (the first one `-Sl` lists it in, as in pacman_upgradable); in no repository, the
|
|
# AUR record when a helper is in use
|
|
pacman_installed_record() { _pm -Qi -- "$1" 2>/dev/null; }
|
|
pacman_candidate_record() {
|
|
local repo
|
|
repo=$(_pm -Sl 2>/dev/null | awk -v p="$1" '$2 == p && r == "" { r = $1 } END { if (r != "") print r }') || repo=''
|
|
if [[ -n $repo ]]; then
|
|
_pm -Si -- "$repo/$1" 2>/dev/null
|
|
return
|
|
fi
|
|
aur_prepare || return 1
|
|
aur_info "$1"
|
|
}
|
|
pacman_rdepends() {
|
|
local out w
|
|
local -a words=()
|
|
out=$(pm_field -Qi 'Required By' "$1") || true
|
|
out=${out#*$'\t'}
|
|
read -r -a words <<<"$out" || true
|
|
for w in ${words[@]+"${words[@]}"}; do [[ $w == None ]] || printf '%s\n' "$w"; done | sort -u
|
|
}
|
|
# pacman_reason PKG: the "Install Reason" of `pacman -Qi` (discovery/pacman.md section 13); -Qi
|
|
# fails (exit 1, "package 'x' was not found") for a package that is not installed
|
|
pacman_reason() {
|
|
local out rc=0
|
|
out=$(pm_field -Qi 'Install Reason' "$1") || rc=$?
|
|
out=${out%%$'\n'*}
|
|
if ((rc)) && [[ -z $out ]]; then
|
|
printf 'not-installed\n'
|
|
return 0
|
|
fi
|
|
case ${out#*$'\t'} in
|
|
'Explicitly installed') printf 'explicit\n' ;;
|
|
'Installed as a dependency for another package') printf 'dependency\n' ;;
|
|
*) printf 'unknown\n' ;;
|
|
esac
|
|
}
|
|
|
|
# discovery/pacman/lib.sh pm_closure (forward mode): one pacman -Qi dump + awk BFS, roots excluded
|
|
pacman_closure() {
|
|
_pm -Qi 2>/dev/null | awk -v roots="$*" '
|
|
function norm(s) { gsub(/[ \t]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s); return (s == "None") ? "" : s }
|
|
function flush() {
|
|
if (name == "") return
|
|
real[name] = 1; dep[name] = norm(F["Depends On"]); pv[name] = norm(F["Provides"])
|
|
name = ""; delete F; cur = ""
|
|
}
|
|
function resolve(d, b) { b = d; sub(/[<>=].*/, "", b); if (b in real) return b; return prov[b] }
|
|
/^[ \t]*$/ { flush(); next }
|
|
/^[^ \t]/ { i = index($0, ":"); k = substr($0, 1, i - 1); sub(/[ \t]+$/, "", k)
|
|
v = substr($0, i + 1); sub(/^[ \t]+/, "", v); cur = k; F[k] = v
|
|
if (k == "Name") name = v; next }
|
|
{ sub(/^[ \t]+/, ""); F[cur] = F[cur] " " $0 }
|
|
END {
|
|
flush()
|
|
for (n in real) { c = split(pv[n], a, " "); for (i = 1; i <= c; i++) { p = a[i]; sub(/[<>=].*/, "", p); prov[p] = (p in prov) ? prov[p] " " n : n } }
|
|
nr = split(roots, r, " "); head = 0; tail = 0
|
|
for (i = 1; i <= nr; i++) { m = split(resolve(r[i]), a, " "); for (j = 1; j <= m; j++) if (!(a[j] in seen)) { seen[a[j]] = 1; isroot[a[j]] = 1; q[++tail] = a[j] } }
|
|
while (head < tail) {
|
|
n = q[++head]
|
|
c = split(dep[n], a, " "); for (i = 1; i <= c; i++) { m = split(resolve(a[i]), b, " "); for (j = 1; j <= m; j++) if (!(b[j] in seen)) { seen[b[j]] = 1; q[++tail] = b[j] } }
|
|
}
|
|
for (n in seen) if (!(n in isroot)) print n
|
|
}' | sort
|
|
}
|
|
# -Rs: the target and its dependencies nothing else needs; pacman refuses when an installed
|
|
# package still depends on the target. (--print cannot be combined with -n: verified.)
|
|
# On refusal pacman explains on stdout (":: removing X breaks dependency ..."): show all of it.
|
|
pacman_purge_plan() {
|
|
local out rc=0 l
|
|
out=$(COLUMNS=0 pacman -Rs --print --print-format '%n' -- "$@" </dev/null 2>&1) || rc=$?
|
|
if ((rc)); then
|
|
printf '%s\n' "$out" >&2
|
|
return "$rc"
|
|
fi
|
|
while IFS= read -r l; do [[ $l =~ ^[^[:space:]:]+$ ]] && printf '%s\n' "$l"; done <<<"$out"
|
|
return 0
|
|
}
|
|
pacman_purge() { run_cmd pacman -Rns --noconfirm -- "$@"; }
|
|
# same_content A B: identical files? (sha256sum: coreutils; cmp is diffutils, not in Arch's base)
|
|
same_content() {
|
|
local a b
|
|
a=$(sha256sum <"$1") && b=$(sha256sum <"$2") && [[ $a == "$b" ]]
|
|
}
|
|
|
|
# pacman_install_dbs SRC DBPATH: make the reviewed databases (SRC/*.db and *.db.sig) the system's.
|
|
# Under pacman's own lock (DBPATH/db.lck, created atomically: no pacman runs meanwhile); never
|
|
# backwards (a system database that changed since the review and is newer than the reviewed copy
|
|
# => refuse, exit status 2); unchanged ones are skipped; all-or-nothing: every file is copied to a
|
|
# temporary name first, then all are renamed with the signals ignored, each .db.sig right before
|
|
# its .db. Returns 0 (installed or nothing to do), 1 (error, nothing changed), 2 (refused).
|
|
pacman_install_dbs() {
|
|
local src=$1 real=$2 dst=$2/sync f n x t rc=0
|
|
local -a dbs=() show=() newer=() todo=()
|
|
local -A same=()
|
|
for f in "$src"/*.db; do [[ -f $f ]] && dbs+=("$f"); done
|
|
((${#dbs[@]})) || return 0
|
|
for f in "${dbs[@]}"; do
|
|
show+=("$f")
|
|
[[ ! -f $f.sig ]] || show+=("$f.sig")
|
|
done
|
|
if ((opt_dry_run)); then
|
|
say "+ $(fmt_cmd cp -p -- "${show[@]}" "$dst/")"
|
|
return 0
|
|
fi
|
|
if ! (set -o noclobber && : >"$real/db.lck") 2>/dev/null; then
|
|
err "the pacman database is locked by another process ($real/db.lck); nothing was changed"
|
|
return 1
|
|
fi
|
|
PACMAN_DB_LOCK_OURS=$real/db.lck
|
|
for f in "${dbs[@]}"; do
|
|
n=${f##*/}
|
|
[[ -f $dst/$n ]] || continue
|
|
if same_content "$dst/$n" "$f"; then
|
|
same[$n]=1
|
|
elif [[ $dst/$n -nt $f ]]; then
|
|
newer+=("${n%.db}")
|
|
fi
|
|
done
|
|
if ((${#newer[@]})); then
|
|
pacman_db_unlock
|
|
err "package databases changed since the review (${newer[*]}); run $SU_PROG again"
|
|
return 2
|
|
fi
|
|
show=()
|
|
for f in "${dbs[@]}"; do
|
|
n=${f##*/}
|
|
[[ -z ${same[$n]+x} ]] || continue
|
|
for x in "$f.sig" "$f"; do
|
|
[[ -f $x ]] || continue
|
|
t=$dst/.${x##*/}.$SU_PROG
|
|
SU_TMP+=("$t")
|
|
if ! cp -p -- "$x" "$t"; then
|
|
rc=1
|
|
break 2
|
|
fi
|
|
todo+=("$t")
|
|
show+=("$x")
|
|
done
|
|
done
|
|
if ((rc)); then
|
|
rm -f -- ${todo[@]+"${todo[@]}"}
|
|
pacman_db_unlock
|
|
err "cannot copy the reviewed package databases into $dst; nothing was changed"
|
|
return 1
|
|
fi
|
|
if ((${#todo[@]} == 0)); then
|
|
say "The system's package databases are the reviewed ones already."
|
|
pacman_db_unlock
|
|
return 0
|
|
fi
|
|
say "+ $(fmt_cmd cp -p -- "${show[@]}" "$dst/")"
|
|
trap '' INT TERM HUP QUIT
|
|
PACMAN_DBS_PENDING=1
|
|
for t in "${todo[@]}"; do
|
|
x=${t##*/.}
|
|
x=${x%."$SU_PROG"}
|
|
mv -f -- "$t" "$dst/$x" || rc=1
|
|
done
|
|
su_traps_set
|
|
pacman_db_unlock
|
|
if ((rc)); then
|
|
err "renaming the reviewed package databases into $dst failed"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# pacman_upgrade EXCLUDED...: the repository packages with pacman (pacman_upgrade_repo), then - when
|
|
# AUR rows remain to upgrade - the AUR packages with the helper (`-Sua --ignore EXCLUDED`), only after
|
|
# a successful repository step. With nothing for pacman to do but AUR rows to upgrade, pacman is not
|
|
# run at all.
|
|
pacman_upgrade() {
|
|
local i n rc=0 n_repo=0 n_aur=0 ran=0
|
|
local -a repo_ex=()
|
|
local -A aur=()
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
((R_GONE[i] == 0)) || continue
|
|
if ((R_AUR[i])); then aur[${R_NAME[i]}]=1; fi
|
|
((R_EXCL[i] == 0)) && [[ ${R_FLAGS[i]} != held ]] || continue
|
|
if ((R_AUR[i])); then n_aur=$((n_aur + 1)); else n_repo=$((n_repo + 1)); fi
|
|
done
|
|
for n in "$@"; do [[ -n ${aur[$n]+x} ]] || repo_ex+=("$n"); done
|
|
if ((n_repo || !n_aur || ${#R_NOTES[@]})); then
|
|
pacman_upgrade_repo ${repo_ex[@]+"${repo_ex[@]}"} || return $?
|
|
ran=1
|
|
else
|
|
say "No repository package to upgrade: pacman is not run, only $AUR_HELPER."
|
|
fi
|
|
((n_aur)) || return 0
|
|
aur_upgrade "$@" || rc=$?
|
|
if ((rc)); then
|
|
UPGRADE_FAILED_MSG="the AUR upgrade failed: $AUR_HELPER exited with status $rc"
|
|
((ran == 0)) || UPGRADE_FAILED_MSG+=" (the repository packages were upgraded)"
|
|
fi
|
|
return "$rc"
|
|
}
|
|
|
|
# pacman_upgrade_repo EXCLUDED...: the reviewed repository transaction (see the top of this section)
|
|
pacman_upgrade_repo() {
|
|
local rc=0 real tmp out
|
|
local -a ign=()
|
|
if (($#)); then ign=(--ignore "$(join_by , "$@")"); fi
|
|
# without a refresh (--no-refresh, or a failed one): apply the existing databases
|
|
if ((!opt_refresh || PACMAN_SY_FAILED)); then
|
|
run_cmd pacman -Su --noconfirm ${ign[@]+"${ign[@]}"}
|
|
return
|
|
fi
|
|
real=$(pacman_dbpath)
|
|
if ((${#PM_DB[@]} == 0)); then
|
|
# --dry-run as non-root: the refresh was printed, not run
|
|
say "+ $(fmt_cmd pacman -Sup --noconfirm ${ign[@]+"${ign[@]}"} --dbpath "${PACMAN_TMP_PREFIX}PID.XXXXXX")"
|
|
say "+ cp -p -- ${PACMAN_TMP_PREFIX}PID.XXXXXX/sync/*.db* $real/sync/"
|
|
run_cmd pacman -Su --noconfirm ${ign[@]+"${ign[@]}"}
|
|
return
|
|
fi
|
|
tmp=${PM_DB[1]}
|
|
# 1. the reviewed transaction must be possible: when it is not, nothing on the system changes
|
|
printf 'Checking the reviewed transaction (pacman -Sup)... ' 2>/dev/null || true
|
|
out=$(COLUMNS=0 run_child pacman -Sup --noconfirm ${ign[@]+"${ign[@]}"} --dbpath "$tmp" --print-format '%n %v' 2>&1) || rc=$?
|
|
if ((rc == 0)); then say 'ok.'; else say 'FAILED.'; fi
|
|
if ((rc)); then
|
|
printf '%s\n' "$out" >&2 2>/dev/null || true
|
|
err "the reviewed upgrade cannot be done (pacman -Sup exited $rc): nothing was changed, the system's package databases are untouched"
|
|
return "$rc"
|
|
fi
|
|
# 2. the reviewed databases become the system's (locked, never backwards, all-or-nothing)
|
|
pacman_install_dbs "$tmp/sync" "$real" || return $?
|
|
# 3. apply exactly them (no further sync)
|
|
run_cmd pacman -Su --noconfirm ${ign[@]+"${ign[@]}"} || rc=$?
|
|
if ((rc && PACMAN_DBS_PENDING)); then pacman_pending_warning; fi
|
|
PACMAN_DBS_PENDING=0
|
|
return "$rc"
|
|
}
|
|
|
|
# ===================================================================================================
|
|
# rpm helpers shared by dnf and zypper (discovery/rpm/lib-rpm.sh, pure rpm + coreutils/sed/grep)
|
|
# ===================================================================================================
|
|
_su_installed_evr() { # "name.arch<TAB>evr", ascending so the highest version wins
|
|
rpm -qa --qf '%{NAME}.%{ARCH}\t%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' </dev/null |
|
|
sort -t $'\t' -k1,1 -k2,2V
|
|
}
|
|
|
|
# su_cap_names: stdin = one REQUIRENAME per line; stdout = plain capability names to look up
|
|
# (rpmlib() dropped, rich deps expanded to their members, if/unless conditions dropped)
|
|
su_cap_names() {
|
|
local cap tok w depth cond skip o c n
|
|
set -f
|
|
while IFS= read -r cap; do
|
|
case $cap in
|
|
'' | rpmlib\(*) ;;
|
|
\(*)
|
|
depth=0 cond=-1 skip=0
|
|
for tok in $cap; do
|
|
w=$tok n=0
|
|
while [[ ${w:0:1} == '(' ]]; do
|
|
w=${w:1}
|
|
n=$((n + 1))
|
|
done
|
|
depth=$((depth + n))
|
|
o=${w//[^(]/} c=${w//[^)]/}
|
|
n=$((${#c} - ${#o}))
|
|
((n >= 0)) || n=0
|
|
((n == 0)) || w=${w%"${w: -$n}"}
|
|
if ((skip)); then
|
|
skip=0
|
|
else
|
|
case $w in
|
|
if | unless) cond=$depth ;;
|
|
else) ((cond != depth)) || cond=-1 ;;
|
|
and | or | with | without | '') ;;
|
|
'=' | '<' | '>' | '<=' | '>=' | '==') skip=1 ;;
|
|
*) ((cond >= 0)) || printf '%s\n' "$w" ;;
|
|
esac
|
|
fi
|
|
depth=$((depth - n))
|
|
if ((cond >= 0 && depth < cond)); then cond=-1; fi
|
|
done
|
|
;;
|
|
*) printf '%s\n' "$cap" ;;
|
|
esac
|
|
done
|
|
set +f
|
|
}
|
|
|
|
# rpm_closure PKG...: installed packages PKG... need, transitively (roots excluded, sorted)
|
|
rpm_closure() {
|
|
local -A seen=()
|
|
local -a frontier=("$@") caps=() provs=() next=() out=()
|
|
local p
|
|
for p in "$@"; do seen[$p]=1; done
|
|
while ((${#frontier[@]})); do
|
|
mapfile -t caps < <(rpm -q --qf '[%{REQUIRENAME}\n]' "${frontier[@]}" </dev/null 2>/dev/null | sort -u | su_cap_names | sort -u)
|
|
((${#caps[@]})) || break
|
|
mapfile -t provs < <(rpm -q --whatprovides --qf '%{NAME}\n' "${caps[@]}" </dev/null 2>/dev/null | grep -v -e '^no package provides ' | sort -u)
|
|
next=()
|
|
for p in ${provs[@]+"${provs[@]}"}; do
|
|
if [[ -n $p && -z ${seen[$p]:-} ]]; then
|
|
seen[$p]=1
|
|
next+=("$p")
|
|
out+=("$p")
|
|
fi
|
|
done
|
|
frontier=(${next[@]+"${next[@]}"})
|
|
done
|
|
if ((${#out[@]})); then printf '%s\n' "${out[@]}" | sort -u; fi
|
|
return 0
|
|
}
|
|
|
|
# su_req_table FILE: "capability<TAB>requiring-package" for every installed package, sorted.
|
|
# Inside [...] the scalar tag MUST be %{=NAME} (plain %{NAME} breaks on rpm 4.16..6.0).
|
|
su_req_table() {
|
|
local dest=$1 cap name capn all
|
|
su_mktemp
|
|
all=$REPLY
|
|
rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]' </dev/null >"$all"
|
|
{
|
|
grep -v -e '^rpmlib(' -e '^(' "$all" || true
|
|
{ grep -e '^(' "$all" || true; } | while IFS=$'\t' read -r cap name; do
|
|
su_cap_names <<<"$cap" | while IFS= read -r capn; do printf '%s\t%s\n' "$capn" "$name"; done
|
|
done
|
|
} | sort -u >"$dest"
|
|
rm -f -- "$all"
|
|
}
|
|
|
|
# rpm_rdeps PKG: installed packages that DIRECTLY require PKG's name, a Provides or an owned file
|
|
rpm_rdeps() {
|
|
local pkg=$1 tab caps
|
|
su_mktemp
|
|
tab=$REPLY
|
|
su_req_table "$tab"
|
|
su_mktemp
|
|
caps=$REPLY
|
|
{
|
|
rpm -q --qf '[%{PROVIDENAME}\n]' "$pkg" </dev/null 2>/dev/null || true
|
|
rpm -ql "$pkg" </dev/null 2>/dev/null | grep -v -e '^(contains no files)$' || true
|
|
} | sort -u >"$caps"
|
|
join -t $'\t' "$caps" "$tab" | cut -f2 | sort -u | grep -v -x -F -e "$pkg" || true
|
|
rm -f -- "$caps" "$tab"
|
|
return 0
|
|
}
|
|
|
|
# _su_xattr '<tag text>' attr -> $REPLY (XML-unescaped); rc 1 if absent
|
|
_su_xattr() {
|
|
local v=${1#* "$2"=\"}
|
|
if [[ $v == "$1" ]]; then
|
|
REPLY=
|
|
return 1
|
|
fi
|
|
v=${v%%\"*}
|
|
v=${v//</<}
|
|
v=${v//>/>}
|
|
v=${v//"/\"}
|
|
v=${v//'/\'}
|
|
v=${v//&/"&"} # quoted: with bash >= 5.2 (patsub_replacement) a bare & re-inserts the match
|
|
REPLY=$v
|
|
}
|
|
|
|
# ===================================================================================================
|
|
# backend: dnf (dnf4 on EL9/EL10, dnf5 on Fedora; yum is the same binary on EL9+)
|
|
# ===================================================================================================
|
|
DNF=dnf
|
|
dnf_init() {
|
|
if have dnf; then
|
|
DNF=dnf
|
|
elif have yum; then
|
|
DNF=yum
|
|
else
|
|
die "dnf backend: neither dnf nor yum found in PATH"
|
|
fi
|
|
have rpm || die "dnf backend: rpm not found in PATH"
|
|
}
|
|
dnf_label() { printf '%s' "$DNF"; }
|
|
dnf_refresh() { refresh_run "$DNF" -q --refresh --setopt=skip_if_unavailable=0 makecache; }
|
|
|
|
# check-update: exit 100 = updates, 0 = none, 1 = error. dnf4 prints a leading blank line and a
|
|
# trailing "Obsoleting Packages" section, dnf5 an "Upgrades" header: only 3-field rows count.
|
|
dnf_upgradable() {
|
|
local out rc=0 na ver repo rest name k evr n=0 obs=0
|
|
local -A old=()
|
|
local -A oldn=()
|
|
local -a obsoleting=()
|
|
out=$("$DNF" -q check-update --setopt=skip_if_unavailable=0 </dev/null) || rc=$?
|
|
case $rc in
|
|
0) return 0 ;;
|
|
100) ;;
|
|
*)
|
|
err "$DNF check-update failed (exit $rc)"
|
|
return "$rc"
|
|
;;
|
|
esac
|
|
while IFS=$'\t' read -r k evr; do
|
|
evr=${evr#0:}
|
|
old[$k]=$evr
|
|
oldn[${k%.*}]=$evr
|
|
done < <(_su_installed_evr)
|
|
while IFS= read -r rest; do
|
|
read -r na ver repo k <<<"$rest"
|
|
if [[ $na == Obsoleting ]]; then
|
|
obs=1
|
|
continue
|
|
fi
|
|
[[ -n $repo && -z $k ]] || continue
|
|
name=${na%.*}
|
|
if ((obs)); then
|
|
# "Obsoleting Packages": the obsoleting package at column 0, what it obsoletes indented
|
|
[[ $rest == [[:space:]]* ]] || obsoleting+=("$name")
|
|
continue
|
|
fi
|
|
printf '%s\t%s\t%s\t%s\t\n' "$name" "${old[$na]:-${oldn[$name]:-?}}" "$ver" "$repo"
|
|
n=$((n + 1))
|
|
done <<<"$out"
|
|
if ((${#obsoleting[@]})); then
|
|
printf '#the upgrade also installs (obsoleting installed packages): %s\n' "${obsoleting[*]}"
|
|
elif ((n == 0)); then
|
|
printf '#%s check-update reports pending changes that are not listed here\n' "$DNF"
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
dnf_describe() {
|
|
local out='' l
|
|
out=$("$DNF" -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n' "$1" </dev/null 2>/dev/null) || out=''
|
|
[[ -n ${out//[[:space:]]/} ]] || out=$(rpm -q --qf '%{SUMMARY}\n' "$1" </dev/null 2>/dev/null) || out=''
|
|
[[ -n ${out//[[:space:]]/} ]] || out=$("$DNF" -q repoquery --latest-limit=1 --queryformat $'%{summary}\n' "$1" </dev/null 2>/dev/null) || out=''
|
|
while IFS= read -r l; do
|
|
if [[ -n $l ]]; then
|
|
printf '%s\n' "$l"
|
|
return 0
|
|
fi
|
|
done <<<"$out"
|
|
return 1
|
|
}
|
|
dnf_full_info() { "$DNF" -q info "$1" </dev/null | gutter; }
|
|
# dnf_installed_record PKG / dnf_candidate_record PKG installed|not-installed: `dnf info` blocks
|
|
# without their heading line ("Installed Packages", dnf5 "Available upgrades", ...). The candidate
|
|
# of an installed package is its upgrade (--upgrades: the installed architecture only, nothing for
|
|
# an up-to-date package; --available would also list other architectures and older builds).
|
|
dnf_installed_record() { "$DNF" -q info --installed "$1" </dev/null 2>/dev/null | sed '/^[^[:space:]][^:]*$/d'; }
|
|
dnf_candidate_record() {
|
|
local w=--available
|
|
[[ ${2:-} != installed ]] || w=--upgrades
|
|
"$DNF" -q info "$w" "$1" </dev/null 2>/dev/null | sed '/^[^[:space:]][^:]*$/d'
|
|
}
|
|
dnf_rdepends() { rpm_rdeps "$1"; }
|
|
dnf_closure() { rpm_closure "$1"; }
|
|
# dnf_reason PKG: dnf's own record, `repoquery --installed --queryformat %{reason}` (discovery/rpm.md
|
|
# section 14): dnf4 user/dependency/weak-dependency/group/unknown (unknown = installed with plain rpm),
|
|
# dnf5 User/Dependency/Weak Dependency/Group/External User (= installed with plain rpm, which dnf5
|
|
# counts as user-installed). A group member was asked for with its group: explicit. No output = not
|
|
# installed. Works unprivileged and never touches the repositories (verified).
|
|
dnf_reason() {
|
|
local out l=
|
|
out=$("$DNF" -q repoquery --installed --queryformat $'%{reason}\n' "$1" </dev/null 2>/dev/null) || {
|
|
printf 'unknown\n'
|
|
return 0
|
|
}
|
|
while IFS= read -r l; do [[ -z $l ]] || break; done <<<"$out"
|
|
case $l in
|
|
user | User | group | Group | 'External User') printf 'explicit\n' ;;
|
|
dependency | Dependency | weak-dependency | 'Weak Dependency') printf 'dependency\n' ;;
|
|
'') printf 'not-installed\n' ;;
|
|
*) printf 'unknown\n' ;;
|
|
esac
|
|
}
|
|
# dnf_purge_plan PKG: the "Removing..." sections of `dnf --assumeno remove PKG` (dnf4 and dnf5
|
|
# tables; --assumeno always exits 1, so success = a "Transaction Summary" or "Nothing to do")
|
|
dnf_purge_plan() {
|
|
local out line in=0 wrapped=0
|
|
local -a f=()
|
|
local arch_re='^(noarch|x86_64|x86_64_v[0-9]|aarch64|i[3-6]86|ppc64le|ppc64|s390x|armv7hl|armv7l|riscv64|loongarch64|src)$'
|
|
out=$("$DNF" --assumeno remove "$@" </dev/null 2>&1) || true
|
|
if [[ $out != *'Transaction Summary'* ]]; then
|
|
if [[ $out == *'Nothing to do'* ]]; then return 0; fi
|
|
printf '%s\n' "$out" >&2
|
|
return 1
|
|
fi
|
|
while IFS= read -r line; do
|
|
case $line in
|
|
'Transaction Summary'*) break ;;
|
|
Removing*:) in=1 ;;
|
|
[A-Za-z]*:) in=0 ;;
|
|
' '*)
|
|
((in)) || continue
|
|
read -r -a f <<<"$line"
|
|
((${#f[@]})) || continue
|
|
# a long name is wrapped: the name alone on one line, "arch version repo size" on the
|
|
# next one - that continuation belongs to the row above
|
|
if ((wrapped)) && [[ ${f[0]} =~ $arch_re ]]; then
|
|
wrapped=0
|
|
continue
|
|
fi
|
|
wrapped=0
|
|
[[ ${f[0]} == replacing || ${f[0]} == Package ]] && continue
|
|
printf '%s\n' "${f[0]}"
|
|
((${#f[@]} > 1)) || wrapped=1
|
|
;;
|
|
esac
|
|
done <<<"$out"
|
|
}
|
|
dnf_purge() { run_cmd "$DNF" -y remove "$@"; }
|
|
dnf_upgrade() {
|
|
local -a ex=()
|
|
if (($#)); then
|
|
local IFS=,
|
|
ex=("--exclude=$*")
|
|
fi
|
|
# *.metadata_expire=-1: apply the metadata that was reviewed (a repo's own metadata_expire
|
|
# overrides the main option, hence the repo glob; verified on dnf4 and dnf5)
|
|
run_cmd "$DNF" -y --setopt=skip_if_unavailable=0 '--setopt=*.metadata_expire=-1' upgrade ${ex[@]+"${ex[@]}"}
|
|
}
|
|
|
|
# ===================================================================================================
|
|
# backend: zypper (openSUSE Tumbleweed: dist-upgrade, Leap/SLE: update)
|
|
# ===================================================================================================
|
|
zypper_init() {
|
|
have zypper || die "zypper backend: zypper not found in PATH"
|
|
have rpm || die "zypper backend: rpm not found in PATH"
|
|
}
|
|
zypper_label() { printf 'zypper'; }
|
|
zypper_refresh() { refresh_run zypper --non-interactive --quiet refresh; }
|
|
zypper_verb() {
|
|
case $(os_release_field ID) in
|
|
opensuse-tumbleweed | opensuse-slowroll) printf 'dist-upgrade' ;;
|
|
*) printf 'update' ;;
|
|
esac
|
|
}
|
|
|
|
# zypper_xml_rows SECTIONED: the solvables of an <install-summary> (dup/update/remove --dry-run)
|
|
# as "section<TAB>name<TAB>old<TAB>new<TAB>repo"; XML flattened with tr, split with sed.
|
|
zypper_xml_rows() {
|
|
local line sec=other tag name new old repo
|
|
printf '%s\n' "$1" | tr '\n' ' ' | sed -e 's/<to-/\n<to-/g' -e 's/<solvable /\n<solvable /g' |
|
|
while IFS= read -r line || [[ -n $line ]]; do
|
|
case $line in
|
|
'<to-'*)
|
|
sec=${line#<to-}
|
|
sec=${sec%%>*}
|
|
;;
|
|
'<solvable '*)
|
|
tag=${line%%>*}
|
|
_su_xattr "$tag" type || true
|
|
[[ $REPLY == package ]] || continue
|
|
_su_xattr "$tag" name || true
|
|
name=$REPLY
|
|
_su_xattr "$tag" edition || true
|
|
new=$REPLY
|
|
if _su_xattr "$tag" edition-old; then old=$REPLY; else old=-; fi
|
|
_su_xattr "$tag" repository || true
|
|
repo=${REPLY:--}
|
|
printf '%s\t%s\t%s\t%s\t%s\n' "$sec" "$name" "$old" "$new" "$repo"
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# Listing: on dist-upgrade systems the review list is the real plan, `dist-upgrade --dry-run`
|
|
# (list-updates can be empty while dup has work; discovery/rpm.md 7.3), which needs root;
|
|
# otherwise (and as non-root) `--xmlout list-updates` (exit 0 also with updates; locks hidden).
|
|
zypper_upgradable() {
|
|
local xml rc=0 line tag name new old src alias sec repo
|
|
local -a removes=()
|
|
if [[ $(zypper_verb) == dist-upgrade ]]; then
|
|
if ((SU_EUID == 0)); then
|
|
xml=$(zypper --non-interactive --no-refresh --xmlout dist-upgrade --dry-run </dev/null) || rc=$?
|
|
if ((rc)); then
|
|
err "zypper dist-upgrade --dry-run failed (exit $rc)"
|
|
return "$rc"
|
|
fi
|
|
while IFS=$'\t' read -r sec name old new repo; do
|
|
[[ -n $name ]] || continue
|
|
if [[ $sec == remove ]]; then
|
|
removes+=("$name")
|
|
else
|
|
printf '%s\t%s\t%s\t%s\t\n' "$name" "$old" "$new" "$repo"
|
|
fi
|
|
done < <(zypper_xml_rows "$xml")
|
|
if ((${#removes[@]})); then printf '#the dist-upgrade also removes: %s\n' "${removes[*]}"; fi
|
|
return 0
|
|
fi
|
|
printf '#not root: this is "zypper list-updates"; the dist-upgrade plan (which may differ) needs root\n'
|
|
fi
|
|
xml=$(zypper --non-interactive --xmlout list-updates </dev/null) || rc=$?
|
|
if ((rc)); then
|
|
err "zypper list-updates failed (exit $rc)"
|
|
return "$rc"
|
|
fi
|
|
# after tr there is no trailing newline: `|| [[ -n $line ]]` keeps the LAST record
|
|
printf '%s\n' "$xml" | tr '\n' ' ' | sed 's/<update /\n<update /g' | while IFS= read -r line || [[ -n $line ]]; do
|
|
case $line in '<update kind="package" '*) ;; *) continue ;; esac
|
|
tag=${line%%>*}
|
|
_su_xattr "$tag" name || true
|
|
name=$REPLY
|
|
_su_xattr "$tag" edition || true
|
|
new=$REPLY
|
|
_su_xattr "$tag" edition-old || true
|
|
old=$REPLY
|
|
src=${line#*<source }
|
|
alias=-
|
|
if [[ $src != "$line" ]] && _su_xattr "$src" alias; then alias=$REPLY; fi
|
|
printf '%s\t%s\t%s\t%s\t\n' "$name" "${old:-?}" "$new" "${alias:--}"
|
|
done
|
|
}
|
|
|
|
zypper_describe() {
|
|
local out rc=0 l
|
|
out=$(zypper --non-interactive --quiet info "$1" </dev/null 2>/dev/null) || rc=$?
|
|
if ((rc == 0)); then
|
|
while IFS= read -r l; do
|
|
case $l in
|
|
Summary*:*)
|
|
printf '%s\n' "${l#*: }"
|
|
return 0
|
|
;;
|
|
esac
|
|
done <<<"$out"
|
|
fi
|
|
out=$(rpm -q --qf '%{SUMMARY}\n' "$1" </dev/null 2>/dev/null) || return 1
|
|
printf '%s\n' "$out"
|
|
}
|
|
zypper_full_info() { zypper --non-interactive --quiet info --requires "$1" </dev/null | gutter; }
|
|
zypper_rdepends() { rpm_rdeps "$1"; }
|
|
zypper_closure() { rpm_closure "$1"; }
|
|
# zypper_reason PKG: the "Installed" line of `zypper info` - "Yes (automatically)", "Yes" or "No"
|
|
# (discovery/rpm.md section 14). Without an answer from zypper (exit 7: another zypper holds the
|
|
# lock, root only; exit 104: unknown name) rpm and libzypp's own AutoInstalled list decide.
|
|
# SKEPTICAL_UPDATER_ZYPP_ROOT is a path prefix for the test-suite.
|
|
zypper_reason() {
|
|
local out rc=0 l auto=${SKEPTICAL_UPDATER_ZYPP_ROOT:-}/var/lib/zypp/AutoInstalled
|
|
out=$(zypper --non-interactive --no-refresh --quiet info "$1" </dev/null 2>/dev/null) || rc=$?
|
|
if ((rc == 0)); then
|
|
while IFS= read -r l; do
|
|
[[ $l =~ ^Installed[[:space:]]*:[[:space:]]*(.*)$ ]] || continue # not "Installed Size :"
|
|
case ${BASH_REMATCH[1]} in
|
|
'Yes (automatically)') printf 'dependency\n' ;;
|
|
Yes) printf 'explicit\n' ;;
|
|
No) printf 'not-installed\n' ;;
|
|
*) printf 'unknown\n' ;;
|
|
esac
|
|
return 0
|
|
done <<<"$out"
|
|
fi
|
|
if ! rpm -q "$1" </dev/null >/dev/null 2>&1; then
|
|
printf 'not-installed\n'
|
|
elif [[ ! -r $auto ]]; then
|
|
printf 'unknown\n'
|
|
elif grep -qxF -e "$1" -- "$auto"; then
|
|
printf 'dependency\n'
|
|
else
|
|
printf 'explicit\n'
|
|
fi
|
|
}
|
|
zypper_purge_plan() {
|
|
local xml rc=0 sec name rest
|
|
xml=$(zypper --non-interactive --no-refresh --xmlout remove --dry-run --clean-deps "$@" </dev/null 2>&1) || rc=$?
|
|
if ((rc)); then
|
|
[[ $rc != 104 ]] || say "zypper: no installed package provides '$1' (exit 104)" >&2
|
|
((rc == 104)) || printf '%s\n' "$xml" >&2
|
|
return 1
|
|
fi
|
|
while IFS=$'\t' read -r sec name rest; do
|
|
if [[ $sec == remove && -n $name ]]; then printf '%s\n' "$name"; fi
|
|
done < <(zypper_xml_rows "$xml")
|
|
}
|
|
zypper_purge() { run_cmd zypper --non-interactive --no-refresh remove --clean-deps "$@"; }
|
|
|
|
# zypper_conf_files: the configuration files libzypp reads, in order (last assignment wins).
|
|
# $ZYPP_CONF alone when set (libzypp then reads no drop-ins: verified); else the main file
|
|
# (/etc/zypp/zypp.conf, or the vendor /usr/etc/zypp/zypp.conf) and the zypp.conf.d drop-ins of
|
|
# /usr/etc and /etc (same name: /etc wins), by file name - a drop-in may set locksfile.path.
|
|
# SKEPTICAL_UPDATER_ZYPP_ROOT is a path prefix for the test-suite.
|
|
zypper_conf_files() {
|
|
local r=${SKEPTICAL_UPDATER_ZYPP_ROOT:-} main f n
|
|
local -A dropin=()
|
|
if [[ -n ${ZYPP_CONF:-} ]]; then
|
|
[[ -r $ZYPP_CONF ]] && printf '%s\n' "$ZYPP_CONF"
|
|
return 0
|
|
fi
|
|
main=$r/etc/zypp/zypp.conf
|
|
[[ -r $main ]] || main=$r/usr/etc/zypp/zypp.conf
|
|
[[ -r $main ]] && printf '%s\n' "$main"
|
|
for f in "$r"/usr/etc/zypp/zypp.conf.d/*.conf "$r"/etc/zypp/zypp.conf.d/*.conf; do
|
|
[[ -f $f && -r $f ]] && dropin[${f##*/}]=$f
|
|
done
|
|
if ((${#dropin[@]})); then
|
|
while IFS= read -r n; do printf '%s\n' "${dropin[$n]}"; done < <(printf '%s\n' "${!dropin[@]}" | sort)
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# zypper_private_cleanup [replay]: remove the private configuration of this run, or (replay) the
|
|
# one an interrupted run recorded in $STATE_DIR/zypper-private.
|
|
zypper_private_cleanup() {
|
|
local marker=$STATE_DIR/zypper-private d
|
|
if [[ ${1:-} == replay ]]; then
|
|
[[ -f $marker ]] || return 0
|
|
d=$(<"$marker") || d=''
|
|
if [[ $d == */"$SU_PROG".* && -d $d ]]; then
|
|
rm -rf -- "$d" 2>/dev/null
|
|
say "Removed the private zypper configuration of an interrupted run ($d)."
|
|
fi
|
|
rm -f -- "$marker" 2>/dev/null
|
|
return 0
|
|
fi
|
|
[[ -n $ZYPP_PRIVATE ]] || return 0
|
|
rm -rf -- "$ZYPP_PRIVATE" 2>/dev/null
|
|
rm -f -- "$marker" 2>/dev/null
|
|
ZYPP_PRIVATE=
|
|
return 0
|
|
}
|
|
|
|
# Per-run exclusion without touching the real lock set (discovery/rpm.md 6.2, recipe A): a private
|
|
# ZYPP_CONF = the effective configuration (main file + drop-ins) whose locksfile.path points at a
|
|
# copy of the effective locks file, plus our addlocks. --no-refresh: apply what was reviewed.
|
|
zypper_upgrade() {
|
|
local verb rc=0 locks line d f
|
|
local -a confs=()
|
|
verb=$(zypper_verb)
|
|
if (($# == 0)); then
|
|
run_cmd zypper --non-interactive --no-refresh "$verb" || rc=$?
|
|
return "$rc"
|
|
fi
|
|
mapfile -t confs < <(zypper_conf_files)
|
|
if ((${#confs[@]} == 0)); then
|
|
err "cannot read the zypp configuration (${ZYPP_CONF:-/etc/zypp/zypp.conf, /usr/etc/zypp/zypp.conf})"
|
|
return 1
|
|
fi
|
|
locks=
|
|
for f in "${confs[@]}"; do
|
|
while IFS= read -r line || [[ -n $line ]]; do
|
|
if [[ $line =~ ^[[:space:]]*locksfile\.path[[:space:]]*=[[:space:]]*(.*[^[:space:]])[[:space:]]*$ ]]; then
|
|
locks=${BASH_REMATCH[1]}
|
|
fi
|
|
done <"$f"
|
|
done
|
|
: "${locks:=/etc/zypp/locks}"
|
|
if ((opt_dry_run)); then
|
|
d="${TMPDIR:-/tmp}/$SU_PROG.XXXXXX"
|
|
say "+ # private $d/zypp.conf: $(join_by ' + ' "${confs[@]}") with locksfile.path = $d/locks (a copy of $locks)"
|
|
else
|
|
# held from the marker until the cleanup: a concurrent run's replay must not remove it
|
|
su_lock zypper 60 || {
|
|
err "another $SU_PROG zypper upgrade is running (lock $STATE_DIR/zypper.lock)"
|
|
return 1
|
|
}
|
|
su_mktemp -d
|
|
d=$REPLY
|
|
ZYPP_PRIVATE=$d
|
|
printf '%s\n' "$d" >"$STATE_DIR/zypper-private" || true
|
|
for f in "${confs[@]}"; do
|
|
{
|
|
cat -- "$f"
|
|
printf '\n'
|
|
} >>"$d/zypp.conf" || return 1
|
|
done
|
|
if [[ -e $locks ]]; then cp -- "$locks" "$d/locks" || return 1; else : >"$d/locks"; fi
|
|
printf '\n[main]\nlocksfile.path = %s\n' "$d/locks" >>"$d/zypp.conf"
|
|
fi
|
|
run_cmd env ZYPP_CONF="$d/zypp.conf" zypper --non-interactive --quiet addlock "$@" || {
|
|
rc=$?
|
|
err "zypper addlock failed (exit $rc)"
|
|
zypper_private_cleanup
|
|
((opt_dry_run)) || su_unlock zypper
|
|
return "$rc"
|
|
}
|
|
run_cmd env ZYPP_CONF="$d/zypp.conf" zypper --non-interactive --no-refresh "$verb" || rc=$?
|
|
zypper_private_cleanup
|
|
((opt_dry_run)) || su_unlock zypper
|
|
return "$rc"
|
|
}
|
|
|
|
# ===================================================================================================
|
|
# commands
|
|
# ===================================================================================================
|
|
banner() {
|
|
local pretty via=''
|
|
pretty=$(os_release_field PRETTY_NAME)
|
|
# pacman: the AUR helper in use (decided here, once: the listing runs in subshells)
|
|
if [[ $BACKEND == pacman ]] && aur_prepare quiet; then via=" + AUR via $AUR_HELPER"; fi
|
|
say "${C_BOLD}Backend: $(be label)${pretty:+ ($pretty)}$via${C_RESET}"
|
|
if [[ $BACKEND == pacman ]]; then aur_warn_pending; fi
|
|
}
|
|
|
|
maybe_refresh() { if ((opt_refresh)); then be refresh || true; fi; }
|
|
|
|
# rows of the review list: R_* arrays indexed 0..n-1, sorted by name; R_NOTES: what the backend
|
|
# reports beyond the rows (new dependencies, removals, pending changes it does not list)
|
|
# R_FLAGS: what is shown in brackets ("held"); R_AUR: 1 for an AUR package (flag "aur" from the
|
|
# backend; shown as a "*" right after the number)
|
|
R_NAME=() R_OLD=() R_NEW=() R_REPO=() R_FLAGS=() R_AUR=() R_NUM=() R_GONE=() R_EXCL=() R_NOTES=()
|
|
N_IDX=() # number -> row index
|
|
N_MAX=0
|
|
SESSION_SHOW_TRUSTED=0
|
|
declare -A SESSION_TRUSTED=()
|
|
|
|
load_rows() {
|
|
local out rc=0 line name old new repo flags i n=0 w aur shown
|
|
local -a fw=()
|
|
out=$(be upgradable | sort -t $'\t' -k1,1 -u) || rc=$?
|
|
if ((rc)); then
|
|
err "could not list the upgradable packages ($(be label) exited $rc)"
|
|
exit 1
|
|
fi
|
|
R_NAME=() R_OLD=() R_NEW=() R_REPO=() R_FLAGS=() R_AUR=() R_NUM=() R_GONE=() R_EXCL=() N_IDX=() R_NOTES=()
|
|
while IFS= read -r line; do
|
|
[[ -n $line ]] || continue
|
|
if [[ $line == '#'* ]]; then
|
|
R_NOTES+=("${line#\#}")
|
|
continue
|
|
fi
|
|
IFS=$US read -r name old new repo flags <<<"${line//$'\t'/$US}"
|
|
[[ -n $name ]] || continue
|
|
aur=0 shown=''
|
|
read -r -a fw <<<"$flags" || true
|
|
for w in ${fw[@]+"${fw[@]}"}; do
|
|
if [[ $w == aur ]]; then aur=1; else shown+=${shown:+ }$w; fi
|
|
done
|
|
R_NAME+=("$name") R_OLD+=("${old:--}") R_NEW+=("${new:--}") R_REPO+=("${repo:--}") R_FLAGS+=("$shown") R_AUR+=("$aur")
|
|
R_GONE+=(0) R_EXCL+=(0)
|
|
done <<<"$out"
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
if ((opt_all)) || ! is_trusted "${R_NAME[i]}"; then
|
|
n=$((n + 1))
|
|
R_NUM[i]=$n
|
|
N_IDX[n]=$i
|
|
else
|
|
R_NUM[i]=0
|
|
fi
|
|
done
|
|
N_MAX=$n
|
|
}
|
|
|
|
rows_left() { # number of rows not purged
|
|
local i n=0
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do ((R_GONE[i])) || n=$((n + 1)); done
|
|
printf '%s' "$n"
|
|
}
|
|
|
|
row_visible() { # row_visible IDX
|
|
local i=$1
|
|
((R_GONE[i] == 0 && R_NUM[i] > 0)) || return 1
|
|
((opt_all)) || ! is_trusted "${R_NAME[i]}" || { ((SESSION_SHOW_TRUSTED)) && [[ -n ${SESSION_TRUSTED[${R_NAME[i]}]+x} ]]; }
|
|
}
|
|
|
|
print_list() {
|
|
local i total=0 trusted=0 review=0 namew=4 oldw=3 neww=3 numw marker aurm line flags repo note
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
((R_GONE[i] == 0)) || continue
|
|
total=$((total + 1))
|
|
if is_trusted "${R_NAME[i]}"; then trusted=$((trusted + 1)); fi
|
|
row_visible "$i" || continue
|
|
is_trusted "${R_NAME[i]}" || review=$((review + 1))
|
|
((${#R_NAME[i]} <= namew)) || namew=${#R_NAME[i]}
|
|
((${#R_OLD[i]} <= oldw)) || oldw=${#R_OLD[i]}
|
|
((${#R_NEW[i]} <= neww)) || neww=${#R_NEW[i]}
|
|
done
|
|
if ((opt_all)); then
|
|
say "$total upgradable, $trusted trusted (marked with =), $review to review:"
|
|
elif ((SESSION_SHOW_TRUSTED)); then
|
|
say "$total upgradable, $trusted trusted (session trusted marked with =), $review to review:"
|
|
else
|
|
say "$total upgradable, $trusted trusted (hidden, use --all to show), $review to review:"
|
|
fi
|
|
say ''
|
|
numw=$((${#N_MAX} + 2))
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
row_visible "$i" || continue
|
|
marker=' '
|
|
if ((R_EXCL[i])); then
|
|
marker="${C_RED}-${C_RESET}"
|
|
elif is_trusted "${R_NAME[i]}"; then
|
|
marker="${C_DIM}=${C_RESET}"
|
|
fi
|
|
# an AUR package: "*" right after the bracket, every other row a space there
|
|
aurm=' '
|
|
((R_AUR[i] == 0)) || aurm="${C_YELLOW}*${C_RESET}"
|
|
repo=${R_REPO[i]}
|
|
[[ $repo != - ]] || repo=
|
|
flags=
|
|
[[ -z ${R_FLAGS[i]} ]] || flags=" ${C_YELLOW}[${R_FLAGS[i]}]${C_RESET}"
|
|
printf -v line '%s%*s%s %s%-*s%s %-*s -> %s%-*s%s %s%s' "$marker" "$numw" "[${R_NUM[i]}]" "$aurm" \
|
|
"$C_BOLD" "$namew" "${R_NAME[i]}" "$C_RESET" "$oldw" "${R_OLD[i]}" \
|
|
"$C_GREEN" "$neww" "${R_NEW[i]}" "$C_RESET" "$repo" "$flags"
|
|
line=${line%"${line##*[! ]}"}
|
|
say "$line"
|
|
done
|
|
for note in ${R_NOTES[@]+"${R_NOTES[@]}"}; do say "Note: $note"; done
|
|
if ((total == 0 && ${#R_NOTES[@]})); then
|
|
say "0 to review, but $(be label) reports pending changes (see the notes): <enter> runs the upgrade."
|
|
fi
|
|
}
|
|
|
|
# print_info PKG [OLD]: description, install reason and the installed packages that depend on PKG
|
|
# (N? / info). OLD "-" (a row of a new dependency) is not installed: the backend is not asked.
|
|
print_info() {
|
|
local desc rd reason rc=0
|
|
desc=$(be describe "$1") || rc=$?
|
|
if ((rc)); then
|
|
say "$1: (no description available: unknown package?)"
|
|
else
|
|
say "$C_BOLD$1$C_RESET: $desc"
|
|
fi
|
|
if [[ ${2:-} == - ]]; then
|
|
reason=not-installed
|
|
else
|
|
reason=$(be reason "$1" 2>/dev/null) || reason=unknown
|
|
fi
|
|
case $reason in
|
|
explicit) say 'reason: explicitly installed' ;;
|
|
dependency) say 'reason: installed as a dependency' ;;
|
|
not-installed) say 'reason: not installed' ;;
|
|
*) say 'reason: unknown' ;;
|
|
esac
|
|
rd=$(be rdepends "$1") || rd=''
|
|
rd=$(printf '%s\n' "$rd" | sed '/^$/d' | tr '\n' ' ')
|
|
rd=${rd% }
|
|
rd=${rd// /, }
|
|
say "required by: ${rd:-nothing}"
|
|
return "$rc"
|
|
}
|
|
|
|
# N?? / show: the installed record as the base, merged with the upstream candidate. Backends that
|
|
# give comparable key/value records for both sides define <be>_installed_record PKG and
|
|
# <be>_candidate_record PKG installed|not-installed (one native record each, no output = none):
|
|
# pacman, apt, dnf. zypper does not (rpm -qi and zypper info share almost no keys, and zypper cannot
|
|
# print the installed record by itself) and keeps <be>_full_info, as does any package for which
|
|
# either side is missing or is not exactly one record.
|
|
readonly SU_MERGE_NOTE='# lines starting with * come from the upstream candidate: a differing value, or a field the installed record lacks'
|
|
readonly SU_CANDIDATE_NOTE='# not installed: lines starting with * come from the upstream candidate (all of them)'
|
|
# gutter: the two-character gutter of N?? / show for native output shown as it is (" " before every
|
|
# non-empty line; the merge itself uses " " for installed lines and "* " for the candidate's)
|
|
gutter() { sed '/./s/^/ /'; }
|
|
# merge: every line gets a two-character gutter: " " for the installed record, "* " for the
|
|
# candidate. Each installed field is printed as it is; when the candidate's value differs, the
|
|
# candidate's line(s) follow; fields only the candidate has are appended. A field starts on a line
|
|
# beginning with a non-blank character that holds a ":" (key = the text before it, trailing blanks
|
|
# dropped; a heading without ":" before the record is skipped); lines beginning with a blank continue
|
|
# it (pacman Optional Deps, dnf Description, RFC-822 continuation lines), so multi-line values are
|
|
# compared - and marked - as blocks, every line trimmed. A blank line ends a record: exit 3 unless
|
|
# each side holds exactly one. Description-xx (apt Translation files) pairs with Description.
|
|
# apt=1: the archives carry only the short description of the candidate (the long text is in the
|
|
# Translation files), so a one-line candidate Description equal to the first installed line is the
|
|
# same description. When both records align every ":" in one column (pacman, dnf) but in different
|
|
# ones (dnf5 pads installed and available keys differently, yay -Sia pads to 30), the candidate lines
|
|
# are re-padded to the installed record's column.
|
|
# pm=1 (pacman: -Qi with -Si or an AUR helper's -Sia; discovery/pacman.md sections 14-15): values are
|
|
# compared unwrapped (continuation lines joined: the AUR helpers wrap long lists at the terminal
|
|
# width), runs of blanks as one; list fields (Depends On, Provides, ...) as token sets, in any order
|
|
# (a version constraint is part of its token); Optional Deps by package names only (-Qi prints
|
|
# "name: description [installed]" per line, the AUR prints bare names). A differing candidate value
|
|
# is one line, as -Qi prints it - but Optional Deps, one entry per line under the value column - and
|
|
# candidate-only fields go next to their installed counterpart (the anchor table below: Repository
|
|
# after Version, AUR URL after URL, ...), the others at the end in the candidate's order.
|
|
# shellcheck disable=SC2016 # awk program, not shell
|
|
readonly AWK_MERGE_RECORDS='
|
|
function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s }
|
|
function repad(l, i, k) {
|
|
if (shift == 0) return l
|
|
if (l ~ /^[^ \t]/) { i = index(l, ":"); k = substr(l, 1, i - 1); sub(/[ \t]+$/, "", k); return sprintf("%-" (col[1] - 1) "s", k) substr(l, i) }
|
|
if (shift > 0) return sprintf("%" shift "s", "") l
|
|
return substr(l, 1 - shift)
|
|
}
|
|
function mine(s, n, a, m) { n = split(s, a, "\n"); for (m = 1; m <= n; m++) print " " a[m] }
|
|
function theirs(s, n, a, m) { n = split(s, a, "\n"); for (m = 1; m <= n; m++) print "* " repad(a[m]) }
|
|
# kv K V: a "Key : value" line in the layout of the installed record
|
|
function kv(k, v) { return (col[1] > 0) ? sprintf("%-" (col[1] - 1) "s: %s", k, v) : k " : " v }
|
|
function isort(a, n, i, j, t) { for (i = 2; i <= n; i++) { t = a[i]; for (j = i - 1; j > 0 && a[j] > t; j--) a[j + 1] = a[j]; a[j + 1] = t } }
|
|
function joined(a, n, i, r) { r = ""; for (i = 1; i <= n; i++) r = r (i > 1 ? " " : "") a[i]; return r }
|
|
function flat(s) { gsub(/[ \t\n]+/, " ", s); return trim(s) }
|
|
# tokens S: the sorted tokens of a list value
|
|
function tokens(s, t, n, i, u, m) { n = split(flat(s), t, " "); m = 0; for (i = 1; i <= n; i++) if (t[i] != "") u[++m] = t[i]; isort(u, m); return joined(u, m) }
|
|
# entries S E: the entries of an Optional Deps value: one per line, or separated by 2+ blanks
|
|
function entries(s, e, n, a, m, c, b, x, ne) {
|
|
ne = 0; n = split(s, a, "\n")
|
|
for (m = 1; m <= n; m++) { c = split(a[m], b, /[ \t][ \t]+/); for (x = 1; x <= c; x++) { b[x] = trim(b[x]); if (b[x] != "") e[++ne] = b[x] } }
|
|
return ne
|
|
}
|
|
# names S: the sorted package names of an Optional Deps value ("None": none)
|
|
function names(s, e, n, i, x, u, m) {
|
|
n = entries(s, e); m = 0
|
|
for (i = 1; i <= n; i++) { x = e[i]; sub(/[ \t]*\[installed\]$/, "", x); sub(/:( .*)?$/, "", x); x = trim(x); if (x != "" && x != "None") u[++m] = x }
|
|
isort(u, m); return joined(u, m)
|
|
}
|
|
function same(k) {
|
|
if (!pm) return val[1, k] == val[2, k]
|
|
if (k == "Optional Deps") return names(val[1, k]) == names(val[2, k])
|
|
if (k in LIST) return tokens(val[1, k]) == tokens(val[2, k])
|
|
return flat(val[1, k]) == flat(val[2, k])
|
|
}
|
|
function up(k, e, n, i, ind, v) {
|
|
done[k] = 1
|
|
if (!pm) { theirs(raw[2, k]); return }
|
|
if (k == "Optional Deps") {
|
|
n = entries(val[2, k], e); ind = (col[1] > 0) ? col[1] + 1 : length(k) + 3
|
|
print "* " kv(k, e[1])
|
|
for (i = 2; i <= n; i++) print "* " sprintf("%" ind "s", "") e[i]
|
|
return
|
|
}
|
|
if (nl[2, k] == 1) { theirs(raw[2, k]); return }
|
|
v = val[2, k]; gsub(/\n/, (k in LIST) ? " " : " ", v)
|
|
print "* " kv(k, v)
|
|
}
|
|
BEGIN {
|
|
n = split("Groups|Provides|Depends On|Required By|Optional For|Conflicts With|Replaces|Make Deps|Check Deps|Licenses|Keywords", t, "|")
|
|
for (i = 1; i <= n; i++) LIST[t[i]] = 1
|
|
na = split("Repository>Version|AUR URL>URL|Keywords>Groups|Make Deps>Optional Deps|Check Deps>Optional Deps|Maintainer>Packager|Last Modified>Build Date|First Submitted>Build Date|Download Size>Installed Size", t, "|")
|
|
for (i = 1; i <= na; i++) { split(t[i], p, ">"); AK[i] = p[1]; AA[i] = p[2] }
|
|
}
|
|
FNR == 1 { side = (FILENAME == ARGV[1]) ? 1 : 2; cur = "" }
|
|
/^[ \t]*$/ { if (nk[side]) ended[side] = 1; next }
|
|
/^[^ \t]/ {
|
|
i = index($0, ":")
|
|
if (i == 0) { if (nk[side]) bad = 1; next }
|
|
if (ended[side]) { bad = 1; next }
|
|
if (!nk[side]) col[side] = i
|
|
else if (col[side] != i) col[side] = -1
|
|
k = substr($0, 1, i - 1); sub(/[ \t]+$/, "", k)
|
|
if (k ~ /^Description-[a-z][a-z](_[A-Z][A-Z])?$/) k = "Description"
|
|
if (++seen[side, k] > 1) k = k "#" seen[side, k]
|
|
key[side, ++nk[side]] = k
|
|
raw[side, k] = $0; val[side, k] = trim(substr($0, i + 1)); first[side, k] = val[side, k]; nl[side, k] = 1
|
|
cur = k
|
|
next
|
|
}
|
|
{
|
|
if (cur == "" || ended[side]) { bad = 1; next }
|
|
raw[side, cur] = raw[side, cur] "\n" $0; val[side, cur] = val[side, cur] "\n" trim($0); nl[side, cur]++
|
|
}
|
|
END {
|
|
if (bad || !nk[1] || !nk[2]) exit 3
|
|
shift = (col[1] > 0 && col[2] > 0) ? col[1] - col[2] : 0
|
|
for (j = 1; j <= nk[1]; j++) {
|
|
k = key[1, j]
|
|
mine(raw[1, k])
|
|
if (((2, k) in raw) && !same(k) && !(apt && k == "Description" && nl[2, k] == 1 && val[2, k] == first[1, k])) up(k)
|
|
if (pm) for (a = 1; a <= na; a++) if (AA[a] == k && ((2, AK[a]) in raw) && !((1, AK[a]) in raw) && !(AK[a] in done)) up(AK[a])
|
|
}
|
|
for (j = 1; j <= nk[2]; j++) { k = key[2, j]; if (!((1, k) in raw) && !(k in done)) up(k) }
|
|
}'
|
|
|
|
print_full_info() { # print_full_info PKG [OLD] (N?? / show); 1 if the package manager knows nothing
|
|
local rc=0 inst='' cand='' out apt=0 pm=0
|
|
if declare -F "${BACKEND}_installed_record" >/dev/null; then
|
|
[[ $BACKEND != apt ]] || apt=1
|
|
[[ $BACKEND != pacman ]] || pm=1
|
|
# a row whose old version is "-" is a new dependency: not installed, nothing to ask
|
|
[[ ${2:-} == - ]] || inst=$(be installed_record "$1") || inst=''
|
|
if [[ -n $inst ]]; then
|
|
cand=$(be candidate_record "$1" installed) || cand=''
|
|
if [[ -n $cand ]] && out=$(awk -v apt="$apt" -v pm="$pm" "$AWK_MERGE_RECORDS" <(printf '%s\n' "$inst") <(printf '%s\n' "$cand")); then
|
|
say "$SU_MERGE_NOTE"
|
|
say "$out"
|
|
return 0
|
|
fi
|
|
else
|
|
cand=$(be candidate_record "$1" not-installed) || cand=''
|
|
if [[ -n $cand ]]; then
|
|
say "$SU_CANDIDATE_NOTE"
|
|
printf '%s\n' "$cand" | sed '/./s/^/* /'
|
|
return 0
|
|
fi
|
|
fi
|
|
fi
|
|
# the native output as it is (<be>_full_info prints it with the gutter)
|
|
be full_info "$1" || rc=$?
|
|
((rc == 0)) && return 0
|
|
err "no package information for '$1' ($(be label) exited $rc)"
|
|
return 1
|
|
}
|
|
|
|
cmd_list() {
|
|
banner
|
|
maybe_refresh
|
|
state_load
|
|
load_rows
|
|
if ((${#R_NAME[@]} == 0 && ${#R_NOTES[@]} == 0)); then
|
|
say "Nothing to upgrade."
|
|
return 0
|
|
fi
|
|
print_list
|
|
}
|
|
|
|
cmd_trusted() {
|
|
state_check_access
|
|
if [[ ! -e $STATE_FILE ]]; then
|
|
say "No trusted packages yet ($STATE_FILE does not exist)."
|
|
return 0
|
|
fi
|
|
[[ -r $STATE_FILE ]] || die "cannot read $STATE_FILE"
|
|
cat -- "$STATE_FILE"
|
|
}
|
|
|
|
cmd_trust() {
|
|
local p now
|
|
now=$(now_utc)
|
|
state_load
|
|
for p in "${cmd_args[@]}"; do trust_pkg "$p" "$now"; done
|
|
state_save
|
|
}
|
|
|
|
cmd_untrust() {
|
|
local p k n reason
|
|
state_load
|
|
for p in "${cmd_args[@]}"; do
|
|
n=0
|
|
for k in ${ST_ROW[@]+"${!ST_ROW[@]}"}; do
|
|
[[ ${k%%$'\t'*} == "$BACKEND" ]] || continue
|
|
reason=${ST_ROW[$k]%%$'\t'*}
|
|
if [[ ${k#*$'\t'} == "$p" || $reason == "dep:$p" ]]; then
|
|
state_del "$k"
|
|
n=$((n + 1))
|
|
fi
|
|
done
|
|
if ((n)); then
|
|
say "untrusted $p ($n $(plural "$n" row rows) removed)"
|
|
else
|
|
warn "$p is not trusted (no row for backend $BACKEND)"
|
|
fi
|
|
done
|
|
state_save
|
|
}
|
|
|
|
cmd_refresh() {
|
|
local k p r now root d n_added=0 n_removed=0
|
|
local -a roots=()
|
|
local -A inclosure=()
|
|
state_load
|
|
for k in ${ST_ROW[@]+"${!ST_ROW[@]}"}; do
|
|
[[ ${k%%$'\t'*} == "$BACKEND" && ${ST_ROW[$k]%%$'\t'*} == user ]] && roots+=("${k#*$'\t'}")
|
|
done
|
|
if ((${#roots[@]} == 0)); then
|
|
say "No trusted root packages for backend $BACKEND: nothing to refresh."
|
|
return 0
|
|
fi
|
|
mapfile -t roots < <(printf '%s\n' "${roots[@]}" | sort)
|
|
for root in "${roots[@]}"; do
|
|
closure_of "$root"
|
|
for d in ${CLOSURE[@]+"${CLOSURE[@]}"}; do inclosure[$root$'\t'$d]=1; done
|
|
done
|
|
# drop dep rows whose root is gone or no longer needs the package
|
|
for k in ${ST_ROW[@]+"${!ST_ROW[@]}"}; do
|
|
[[ ${k%%$'\t'*} == "$BACKEND" ]] || continue
|
|
r=${ST_ROW[$k]%%$'\t'*}
|
|
[[ $r == dep:* ]] || continue
|
|
p=${k#*$'\t'}
|
|
root=${r#dep:}
|
|
if [[ -z ${inclosure[$root$'\t'$p]+x} ]] || [[ $(trusted_reason "$root") != user ]]; then
|
|
state_del "$k"
|
|
n_removed=$((n_removed + 1))
|
|
fi
|
|
done
|
|
now=$(now_utc)
|
|
for root in "${roots[@]}"; do
|
|
closure_of "$root"
|
|
for d in ${CLOSURE[@]+"${CLOSURE[@]}"}; do
|
|
if state_add "$BACKEND" "$d" "dep:$root" "$now"; then n_added=$((n_added + 1)); fi
|
|
done
|
|
done
|
|
say "refreshed the dependency closures of ${#roots[@]} trusted $(plural "${#roots[@]}" root roots): $n_added added, $n_removed removed"
|
|
state_save
|
|
}
|
|
|
|
cmd_info() { print_info "${cmd_args[0]}"; }
|
|
cmd_show() { print_full_info "${cmd_args[0]}"; }
|
|
|
|
# ---- interactive session -----------------------------------------------------------------------
|
|
eof_abort() {
|
|
printf '\n' >&2 2>/dev/null || true
|
|
err "end of input: aborted, nothing was upgraded"
|
|
exit 2
|
|
}
|
|
|
|
# read_line PROMPT [history] -> $REPLY; EOF aborts (exit 2). On a terminal the line is read with
|
|
# readline (bash's defaults: arrows, Home/End, ^A/^E, ...; the prompt is drawn on stderr) and, with
|
|
# "history", a non-empty line joins this session's in-memory history that Up/Down walk. Otherwise
|
|
# a plain read, and the input is echoed after the prompt so logs stay readable.
|
|
read_line() {
|
|
REPLY=
|
|
if [[ -t 0 ]]; then
|
|
IFS= read -e -r -p "$1" REPLY || [[ -n $REPLY ]] || eof_abort
|
|
if [[ ${2:-} == history && $REPLY == *[![:space:]]* ]]; then history -s -- "$REPLY"; fi
|
|
return 0
|
|
fi
|
|
printf '%s' "$1" 2>/dev/null || true
|
|
IFS= read -r REPLY || [[ -n $REPLY ]] || eof_abort
|
|
REPLY=${REPLY%$'\r'}
|
|
say "$REPLY"
|
|
}
|
|
|
|
num_row() { # num_row N -> $REPLY = row index of a listed row, rc 1 if N is not listed
|
|
local n=$1
|
|
REPLY=
|
|
((n >= 1 && n <= N_MAX)) || return 1
|
|
[[ -n ${N_IDX[n]+x} ]] || return 1
|
|
row_visible "${N_IDX[n]}" || return 1
|
|
REPLY=${N_IDX[n]}
|
|
}
|
|
|
|
# session_purge IDX...: pacman's -Rns principle on every backend - a package that other installed
|
|
# packages still depend on is never purged (they are listed, as N? does; nothing cascades);
|
|
# otherwise the package goes with the dependencies it alone needed, as the y/N prompt lists.
|
|
session_purge() {
|
|
local i name rd out rc=0 p prompt idx dep changed
|
|
local -a targets=() target_names=() total_plan=() total_others=() extra=() rdeps=()
|
|
local -A purge_selected=() rejected=() reverse=() gone=() allowed=() still=()
|
|
local line
|
|
|
|
for idx in "$@"; do purge_selected[${R_NAME[idx]}]=$idx; done
|
|
mapfile -t targets < <(printf '%s\n' "${purge_selected[@]}" | sort -n)
|
|
for i in "${targets[@]}"; do
|
|
name=${R_NAME[i]}
|
|
if [[ ${R_OLD[i]} == - ]]; then
|
|
say "$name is not installed (the upgrade would install it as a new dependency): nothing to purge; use $((R_NUM[i]))- to exclude it from this run."
|
|
rejected[$name]=1
|
|
continue
|
|
fi
|
|
rd=$(be rdepends "$name") || rd=''
|
|
reverse[$name]=$(printf '%s\n' "$rd" | sed '/^$/d' | tr '\n' ' ')
|
|
done
|
|
# A refused dependent also blocks its dependencies, including through a chain.
|
|
changed=1
|
|
while ((changed)); do
|
|
changed=0
|
|
for i in "${targets[@]}"; do
|
|
name=${R_NAME[i]}
|
|
[[ -z ${rejected[$name]+x} ]] || continue
|
|
read -r -a rdeps <<<"${reverse[$name]}"
|
|
for dep in "${rdeps[@]}"; do
|
|
if [[ -z ${purge_selected[$dep]+x} || -n ${rejected[$dep]+x} ]]; then
|
|
rd=${reverse[$name]% }
|
|
say "not purged: $name is required by: ${rd// /, } - purge the dependents first"
|
|
rejected[$name]=1
|
|
changed=1
|
|
break
|
|
fi
|
|
done
|
|
done
|
|
done
|
|
for i in "${targets[@]}"; do
|
|
name=${R_NAME[i]}
|
|
[[ -z ${rejected[$name]+x} ]] || continue
|
|
target_names+=("$name")
|
|
allowed[$name]=1
|
|
closure_of "$name"
|
|
for dep in ${CLOSURE[@]+"${CLOSURE[@]}"}; do allowed[$dep]=1; done
|
|
done
|
|
((${#target_names[@]})) || return 0
|
|
out=$(be purge_plan "${target_names[@]}" | sort -u) || rc=$?
|
|
if ((rc)); then
|
|
err "cannot purge ${target_names[*]}: $(be label) refuses (see above); nothing was removed"
|
|
return 0
|
|
fi
|
|
while IFS= read -r p; do
|
|
[[ -n $p ]] || continue
|
|
total_plan+=("$p")
|
|
gone[$p]=1
|
|
[[ -n ${purge_selected[$p]+x} && -z ${rejected[$p]+x} ]] || total_others+=("$p")
|
|
[[ -n ${allowed[$p]+x} && -z ${rejected[$p]+x} ]] || extra+=("$p")
|
|
done <<<"$out"
|
|
if ((${#total_plan[@]} == 0)); then
|
|
say "${target_names[*]} is not installed: nothing to purge."
|
|
return 0
|
|
fi
|
|
for name in "${target_names[@]}"; do
|
|
if [[ -z ${gone[$name]+x} ]]; then
|
|
err "cannot purge $name: $(be label) did not include it in the plan; nothing was removed"
|
|
return 0
|
|
fi
|
|
done
|
|
# belt and braces: no package outside the targets and their dependencies may be removed.
|
|
if ((${#extra[@]})); then
|
|
err "cannot purge ${target_names[*]}: $(be label) would also remove $(join_by ', ' "${extra[@]}"), which ${target_names[*]} does not depend on (dependents of it?); nothing was removed"
|
|
return 0
|
|
fi
|
|
prompt="Purge ${target_names[*]} now?"
|
|
((${#total_others[@]} == 0)) || prompt+=" This also removes: $(join_by ', ' "${total_others[@]}")"
|
|
read_line "$prompt [y/N] "
|
|
case $REPLY in
|
|
y | Y | yes | YES | Yes) ;;
|
|
*)
|
|
say "not purged: ${target_names[*]}"
|
|
return 0
|
|
;;
|
|
esac
|
|
rc=0
|
|
be purge "${total_plan[@]}" || rc=$?
|
|
if ((rc)); then
|
|
if ((${#target_names[@]} == 1)); then
|
|
err "purge of ${target_names[0]} failed ($(be label) exited $rc); the row stays in the list"
|
|
else
|
|
err "purge of ${target_names[*]} failed ($(be label) exited $rc); the rows stay in the list"
|
|
fi
|
|
return 0
|
|
fi
|
|
say "purged: ${total_plan[*]}"
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
if [[ -n ${gone[${R_NAME[i]}]+x} ]]; then
|
|
R_GONE[i]=1
|
|
R_EXCL[i]=0
|
|
fi
|
|
done
|
|
((opt_dry_run)) && return 0
|
|
# anything else the purge took with it: drop rows that are no longer upgradable
|
|
out=$(be upgradable 2>/dev/null) || return 0
|
|
while IFS= read -r line; do
|
|
[[ -n $line && $line != '#'* ]] || continue
|
|
still[${line%%$'\t'*}]=1
|
|
done <<<"$out"
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
if ((R_GONE[i] == 0)) && [[ -z ${still[${R_NAME[i]}]+x} ]]; then
|
|
R_GONE[i]=1
|
|
R_EXCL[i]=0
|
|
say " (${R_NAME[i]} is no longer upgradable, removed from the list)"
|
|
fi
|
|
done
|
|
}
|
|
|
|
session_trust() { # session_trust IDX...
|
|
local i j d now covered
|
|
local -a selected=() roots=()
|
|
local -A seen=() reach=()
|
|
for i in "$@"; do
|
|
[[ -z ${seen[$i]+x} ]] || continue
|
|
seen[$i]=1
|
|
selected+=("$i")
|
|
done
|
|
for i in "${selected[@]}"; do
|
|
closure_of "${R_NAME[i]}"
|
|
for d in ${CLOSURE[@]+"${CLOSURE[@]}"}; do reach["$i,$d"]=1; done
|
|
done
|
|
for i in "${selected[@]}"; do
|
|
covered=0
|
|
for j in "${selected[@]}"; do
|
|
[[ $i != "$j" && -n ${reach[$j,${R_NAME[i]}]+x} ]] || continue
|
|
if [[ -z ${reach[$i,${R_NAME[j]}]+x} ]] || ((j < i)); then covered=1; break; fi
|
|
done
|
|
((covered)) || roots+=("$i")
|
|
done
|
|
now=$(now_utc)
|
|
for i in "${roots[@]}"; do
|
|
trust_pkg "${R_NAME[i]}" "$now"
|
|
R_EXCL[i]=0
|
|
done
|
|
session_finish_trust
|
|
}
|
|
|
|
session_finish_trust() {
|
|
local i
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
if ((R_NUM[i] > 0 && !R_GONE[i])) && is_trusted "${R_NAME[i]}"; then SESSION_TRUSTED[${R_NAME[i]}]=1; fi
|
|
done
|
|
# a row this trust hides must not stay excluded behind the user's back
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
if ((R_EXCL[i] && !R_GONE[i])) && is_trusted "${R_NAME[i]}" && ! row_visible "$i"; then
|
|
R_EXCL[i]=0
|
|
say " (${R_NAME[i]} is trusted now: no longer excluded from this run)"
|
|
fi
|
|
done
|
|
state_save
|
|
}
|
|
|
|
session_trust_dependents() {
|
|
local i=$1 node dep out root other candidate now head blocked
|
|
local -a queue=() nodes=() roots=() walk=() deps=()
|
|
local -A seen=() edges=() reach=() visited=()
|
|
if [[ ${R_OLD[i]} == - ]]; then
|
|
say "${R_NAME[i]} is not installed: cannot find installed dependents."
|
|
return 0
|
|
fi
|
|
queue=("${R_NAME[i]}")
|
|
seen[${R_NAME[i]}]=1
|
|
for ((head = 0; head < ${#queue[@]}; head++)); do
|
|
node=${queue[head]}
|
|
nodes+=("$node")
|
|
if ! out=$(be rdepends "$node"); then
|
|
err "cannot find installed dependents of $node; nothing was trusted"
|
|
return 0
|
|
fi
|
|
while IFS= read -r dep; do
|
|
[[ -n $dep ]] || continue
|
|
edges[$node]+=" $dep"
|
|
if [[ -z ${seen[$dep]+x} ]]; then seen[$dep]=1; queue+=("$dep"); fi
|
|
done <<<"$out"
|
|
done
|
|
for root in "${nodes[@]}"; do
|
|
walk=("$root")
|
|
visited=(["$root"]=1)
|
|
for ((head = 0; head < ${#walk[@]}; head++)); do
|
|
node=${walk[head]}
|
|
read -r -a deps <<<"${edges[$node]-}"
|
|
for dep in "${deps[@]}"; do
|
|
[[ -z ${visited[$dep]+x} ]] || continue
|
|
visited[$dep]=1
|
|
walk+=("$dep")
|
|
done
|
|
done
|
|
for node in "${walk[@]}"; do reach["$root,$node"]=1; done
|
|
done
|
|
mapfile -t nodes < <(printf '%s\n' "${nodes[@]}" | sort -u)
|
|
for root in "${nodes[@]}"; do
|
|
blocked=0
|
|
for other in "${nodes[@]}"; do
|
|
[[ $root != "$other" && -n ${reach[$root,$other]+x} ]] || continue
|
|
if [[ -z ${reach[$other,$root]+x} || $other < $root ]]; then blocked=1; break; fi
|
|
done
|
|
((blocked)) || roots+=("$root")
|
|
done
|
|
now=$(now_utc)
|
|
for candidate in "${roots[@]}"; do
|
|
trust_pkg "$candidate" "$now"
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
[[ ${R_NAME[i]} != "$candidate" ]] || R_EXCL[i]=0
|
|
done
|
|
done
|
|
session_finish_trust
|
|
}
|
|
|
|
session_toggle_all() {
|
|
local i all=1
|
|
local -a idx=()
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
row_visible "$i" || continue
|
|
idx+=("$i")
|
|
((R_EXCL[i])) || all=0
|
|
done
|
|
((${#idx[@]})) || {
|
|
say "nothing listed"
|
|
return 0
|
|
}
|
|
for i in "${idx[@]}"; do if ((all)); then R_EXCL[i]=0; else R_EXCL[i]=1; fi; done
|
|
if ((all)); then say "re-included all ${#idx[@]} listed packages"; else say "excluded all ${#idx[@]} listed packages from this run"; fi
|
|
}
|
|
|
|
SESSION_PROCEED=0
|
|
UPGRADE_FAILED_MSG= # set by <be>_upgrade when the failing step is not the package manager's own
|
|
|
|
session_flush_purges() {
|
|
local -n pb=$1
|
|
if ((${#pb[@]})); then
|
|
session_purge "${pb[@]}"
|
|
pb=()
|
|
if (($(rows_left) == 0 && ${#R_NOTES[@]} == 0)); then
|
|
say "Nothing left to upgrade."
|
|
exit 0
|
|
fi
|
|
fi
|
|
}
|
|
|
|
session_line() {
|
|
local tok n op i name
|
|
local -a toks=() batch=() purge_batch=()
|
|
local -A initial=() named=()
|
|
local re='^([0-9]{1,6})(\?\?|\?|-|x|\+\+|\+)$'
|
|
local name_re='^([A-Za-z0-9_+@][A-Za-z0-9_+.@:~-]*)(\?\?|\?)$'
|
|
read -r -a toks <<<"$1" || true
|
|
if ((${#toks[@]} == 0)); then
|
|
SESSION_PROCEED=1
|
|
return 0
|
|
fi
|
|
for tok in "${toks[@]}"; do
|
|
if [[ ! $tok =~ $re && ! $tok =~ $name_re ]]; then
|
|
case $tok in
|
|
'*+' | '*-' | a | l | q | h | '?') ;;
|
|
*)
|
|
err "unknown command '$tok' (h for help)"
|
|
return 0
|
|
;;
|
|
esac
|
|
fi
|
|
done
|
|
for ((n = 1; n <= N_MAX; n++)); do
|
|
if num_row "$n"; then
|
|
initial[$n]=$REPLY
|
|
named[${R_NAME[REPLY]}]=$REPLY
|
|
fi
|
|
done
|
|
for tok in "${toks[@]}"; do
|
|
if [[ $tok =~ $re ]]; then
|
|
op=${BASH_REMATCH[2]}
|
|
if [[ $op == x ]]; then
|
|
if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi
|
|
elif [[ $op == + ]]; then
|
|
session_flush_purges purge_batch
|
|
else
|
|
if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi
|
|
session_flush_purges purge_batch
|
|
fi
|
|
elif [[ $tok =~ $name_re ]]; then
|
|
if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi
|
|
session_flush_purges purge_batch
|
|
else
|
|
if ((${#batch[@]})); then session_trust "${batch[@]}"; batch=(); fi
|
|
session_flush_purges purge_batch
|
|
fi
|
|
|
|
if [[ $tok =~ $re ]]; then
|
|
n=$((10#${BASH_REMATCH[1]}))
|
|
op=${BASH_REMATCH[2]}
|
|
if [[ -z ${initial[$n]+x} ]] || ((R_GONE[${initial[$n]:-0}])); then
|
|
err "no package [$n] in the list (l to redraw)"
|
|
continue
|
|
fi
|
|
i=${initial[$n]}
|
|
if [[ $op == + ]]; then
|
|
batch+=("$i")
|
|
continue
|
|
fi
|
|
if [[ $op == x ]]; then
|
|
purge_batch+=("$i")
|
|
continue
|
|
fi
|
|
case $op in
|
|
'++') session_trust_dependents "$i" ;;
|
|
'?') print_info "${R_NAME[i]}" "${R_OLD[i]}" || true ;;
|
|
'??') print_full_info "${R_NAME[i]}" "${R_OLD[i]}" || true ;;
|
|
-)
|
|
if ((R_EXCL[i])); then
|
|
R_EXCL[i]=0
|
|
say "re-included: ${R_NAME[i]}"
|
|
else
|
|
R_EXCL[i]=1
|
|
say "excluded from this run: ${R_NAME[i]}"
|
|
fi
|
|
;;
|
|
esac
|
|
elif [[ $tok =~ $name_re ]]; then
|
|
name=${BASH_REMATCH[1]}
|
|
op=${BASH_REMATCH[2]}
|
|
if [[ -z ${named[$name]+x} ]] || ((R_GONE[${named[$name]:-0}])); then
|
|
err "no package '$name' in the list (l to redraw)"
|
|
continue
|
|
fi
|
|
i=${named[$name]}
|
|
if [[ $op == '?' ]]; then
|
|
print_info "${R_NAME[i]}" "${R_OLD[i]}" || true
|
|
else
|
|
print_full_info "${R_NAME[i]}" "${R_OLD[i]}" || true
|
|
fi
|
|
else
|
|
case $tok in
|
|
'*+')
|
|
local -a idx=()
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
if row_visible "$i" && ! is_trusted "${R_NAME[i]}"; then idx+=("$i"); fi
|
|
done
|
|
if ((${#idx[@]})); then session_trust "${idx[@]}"; else say "nothing to trust"; fi
|
|
;;
|
|
'*-') session_toggle_all ;;
|
|
a)
|
|
SESSION_SHOW_TRUSTED=1
|
|
for ((n = 1; n <= N_MAX; n++)); do
|
|
if num_row "$n"; then initial[$n]=$REPLY; named[${R_NAME[REPLY]}]=$REPLY; fi
|
|
done
|
|
print_list
|
|
;;
|
|
l) print_list ;;
|
|
q)
|
|
say "Quit: nothing was upgraded."
|
|
exit 0
|
|
;;
|
|
h | '?') session_help ;;
|
|
esac
|
|
fi
|
|
done
|
|
if ((${#batch[@]})); then session_trust "${batch[@]}"; fi
|
|
session_flush_purges purge_batch
|
|
}
|
|
|
|
cmd_interactive() {
|
|
local i rc=0
|
|
local -a excl=()
|
|
banner
|
|
maybe_refresh
|
|
state_load
|
|
load_rows
|
|
if ((${#R_NAME[@]} == 0 && ${#R_NOTES[@]} == 0)); then
|
|
say "Nothing to upgrade."
|
|
return 0
|
|
fi
|
|
print_list
|
|
say ''
|
|
say "$SU_PROMPT_HELP"
|
|
while ((!SESSION_PROCEED)); do
|
|
read_line '> ' history
|
|
session_line "$REPLY"
|
|
done
|
|
for ((i = 0; i < ${#R_NAME[@]}; i++)); do
|
|
((R_GONE[i] == 0 && R_EXCL[i])) && excl+=("${R_NAME[i]}")
|
|
done
|
|
if ((${#excl[@]})); then
|
|
say "Excluded from this run: ${excl[*]}"
|
|
else
|
|
say 'Excluded from this run: none'
|
|
fi
|
|
be upgrade ${excl[@]+"${excl[@]}"} || rc=$?
|
|
if ((rc == 0)); then
|
|
if ((opt_dry_run)); then say "Dry run: nothing was changed."; else say "Upgrade finished."; fi
|
|
elif [[ -n $UPGRADE_FAILED_MSG ]]; then
|
|
err "$UPGRADE_FAILED_MSG"
|
|
else
|
|
err "the upgrade failed: $(be label) exited with status $rc"
|
|
upgrade_hint "$rc" ${excl[@]+"${excl[@]}"}
|
|
fi
|
|
return "$rc"
|
|
}
|
|
|
|
upgrade_hint() { # upgrade_hint RC EXCLUDED...
|
|
local rc=$1 msg=''
|
|
shift
|
|
case $BACKEND in
|
|
pacman)
|
|
(($#)) && msg="hint: pacman aborts the whole transaction under --noconfirm when an ignored package is needed at a newer version by another upgrade; exclude its dependants too."
|
|
;;
|
|
dnf)
|
|
(($#)) && msg="hint: dnf4 refuses to upgrade a package whose required newer dependency is excluded (exit 1); exclude its dependants too, or run '$DNF upgrade --nobest' by hand."
|
|
;;
|
|
zypper)
|
|
case $rc in
|
|
4) msg="hint: zypper hit a dependency/vendor problem that --non-interactive cancels (exit 4); run 'zypper $(zypper_verb)' by hand to choose a solution." ;;
|
|
104) msg="hint: zypper did not find a requested package (exit 104)." ;;
|
|
esac
|
|
;;
|
|
esac
|
|
[[ -z $msg ]] || printf '%s\n' "$msg" >&2 2>/dev/null || true
|
|
return 0
|
|
}
|
|
|
|
main() {
|
|
parse_args "$@"
|
|
setup_colors
|
|
if [[ $cmd == trusted ]]; then
|
|
cmd_trusted
|
|
return
|
|
fi
|
|
detect_backend
|
|
ensure_root
|
|
if [[ $BACKEND != pacman && ($opt_aur == paru || $opt_aur == yay) ]]; then
|
|
warn "--aur-helper=$opt_aur is ignored: AUR helpers only apply to the pacman backend (Arch Linux and derivatives)"
|
|
fi
|
|
recover_leftovers
|
|
case $cmd in
|
|
'') cmd_interactive ;;
|
|
list) cmd_list ;;
|
|
trust) cmd_trust ;;
|
|
untrust) cmd_untrust ;;
|
|
refresh) cmd_refresh ;;
|
|
info) cmd_info ;;
|
|
show) cmd_show ;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|