Files
skeptical-updater/skeptical-updater
T
bryanpedini 875c8294bf feat(show): merge the installed record with the upstream candidate
N?? and `show PKG` now print the installed record in the package
manager's own key/value layout, after one line explaining the format:

    # lines starting with > come from the upstream candidate: a differing
    value, or a field the installed record lacks

After each field whose candidate value differs comes the candidate's
line prefixed with "> "; fields only the candidate has are appended the
same way; installed-only fields stay; identical fields print once.
Multi-line values (pacman Optional Deps, dnf Description, RFC-822
continuation lines) are compared as blocks and a differing candidate
block gets "> " on every line. A package that is not installed shows the
candidate alone ("# not installed: this is the upstream candidate").

Merged where the backend gives comparable records for both sides:
- pacman: -Qi vs -Si REPO/PKG, REPO = the first repository -Sl lists
- apt: dpkg-query -s (Status, Conffiles, the long description; richer
  than apt-cache show PKG=<installed>, which gives the archive record
  when that version is still listed) vs apt-cache show --no-all-versions;
  Description-xx pairs with Description and a one-line candidate
  Description equal to the installed first line counts as identical
  (the archives ship the long text in Translation files)
- dnf: info --installed vs info --upgrades (--available lists other
  architectures first and nothing for an up-to-date package); not
  installed: --available; dnf5's differently padded keys are re-padded
zypper keeps `zypper info --requires`: rpm -qi and zypper info share
almost no keys and zypper cannot print the installed record itself.
No candidate, or not exactly one record per side, keeps the old output.

Formats verified in containers and recorded in discovery/*.md. Unit
tests replay recorded pacman, apt, dnf4 and dnf5 records; the E2E suite
checks 2?? on su-dummy-b on all 12 distros.
2026-10-10 14:24:39 +02:00

2606 lines
89 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.1.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.1.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
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)
-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)
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
N+ trust it forever (with its installed dependency closure)
*+ / *- apply + / - to every listed package
l redraw the list
q quit without upgrading (exit 0)
h, ? this help
end of input abort without upgrading (exit 2)
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
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
N+ trust N (and its installed dependencies) forever
*+ *- trust / exclude every listed package (*- again re-includes them)
l redraw the list
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
;;
--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
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() {
(
local v fd
for v in ${SU_LOCK[@]+"${SU_LOCK[@]}"}; do
if [[ $v == fd:* ]]; then
fd=${v#fd:}
exec {fd}>&-
fi
done
exec "$@"
) </dev/null
}
# 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; }
# 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 pkg=$1 pre sim w name rest
local -A before=()
local -a args=("$pkg")
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 "$pkg" </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} && $name != "$pkg" ]] && 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"; }
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
}
# 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"
# 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() {
local ok=1
_pm -Qi -- "$1" 2>/dev/null && ok=0
_pm -Si -- "$1" 2>/dev/null && ok=0
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)
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=''
[[ -n $repo ]] || return 1
_pm -Si -- "$repo/$1" 2>/dev/null
}
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' -- "$1" </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() {
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//&lt;/<}
v=${v//&gt;/>}
v=${v//&quot;/\"}
v=${v//&apos;/\'}
v=${v//&amp;/"&"} # 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; }
# 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 "$1" </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; }
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 "$1" </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
pretty=$(os_release_field PRETTY_NAME)
say "${C_BOLD}Backend: $(be label)${pretty:+ ($pretty)}${C_RESET}"
}
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_NAME=() R_OLD=() R_NEW=() R_REPO=() R_FLAGS=() R_NUM=() R_GONE=() R_EXCL=() R_NOTES=()
N_IDX=() # number -> row index
N_MAX=0
load_rows() {
local out rc=0 line name old new repo flags i n=0
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_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
R_NAME+=("$name") R_OLD+=("${old:--}") R_NEW+=("${new:--}") R_REPO+=("${repo:--}") R_FLAGS+=("$flags")
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]}"
}
print_list() {
local i total=0 trusted=0 review=0 namew=4 oldw=3 neww=3 numw marker 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:"
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
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' "$marker" "$numw" "[${R_NUM[i]}]" \
"$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: this is the upstream candidate'
# merge: each base field is printed as it is; when the candidate's value differs, the candidate's
# line(s) follow, prefixed with "> "; fields only the candidate has are appended the same way. 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 prefixed - 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),
# the candidate lines are re-padded to the installed record's column.
# 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 pfx(s, n, a, m) { n = split(s, a, "\n"); for (m = 1; m <= n; m++) print "> " repad(a[m]) }
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]
print raw[1, k]
if (!((2, k) in raw) || val[1, k] == val[2, k]) continue
if (apt && k == "Description" && nl[2, k] == 1 && val[2, k] == first[1, k]) continue
pfx(raw[2, k])
}
for (j = 1; j <= nk[2]; j++) { k = key[2, j]; if (!((1, k) in raw)) pfx(raw[2, 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
if declare -F "${BACKEND}_installed_record" >/dev/null; then
[[ $BACKEND != apt ]] || apt=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" "$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"
say "$cand"
return 0
fi
fi
fi
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=$1 name=${R_NAME[$1]} rc=0 out line p prompt rd d
local -a plan=() others=() extra=()
local -A still=() gone=() mine=()
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."
return 0
fi
rd=$(be rdepends "$name") || rd=''
rd=$(printf '%s\n' "$rd" | sed '/^$/d' | tr '\n' ' ')
rd=${rd% }
if [[ -n $rd ]]; then
say "not purged: $name is required by: ${rd// /, } - purge the dependents first"
return 0
fi
out=$(be purge_plan "$name" | sort -u) || rc=$?
if ((rc)); then
err "cannot purge $name: $(be label) refuses (see above); nothing was removed"
return 0
fi
while IFS= read -r p; do
[[ -n $p && -z ${gone[$p]+x} ]] || continue
gone[$p]=1
plan+=("$p")
[[ $p == "$name" ]] || others+=("$p")
done <<<"$out"
if ((${#plan[@]} == 0)); then
say "$name is not installed: nothing to purge."
return 0
fi
# belt and braces: the plan may only hold the target and its own (installed) dependencies;
# anything else means a dependent the reverse-dependency query missed (e.g. through a virtual)
if ((${#others[@]})); then
closure_of "$name"
for d in ${CLOSURE[@]+"${CLOSURE[@]}"}; do mine[$d]=1; done
for p in "${others[@]}"; do [[ -n ${mine[$p]+x} ]] || extra+=("$p"); done
if ((${#extra[@]})); then
err "cannot purge $name: $(be label) would also remove $(join_by ', ' "${extra[@]}"), which $name does not depend on (dependents of it?); nothing was removed"
return 0
fi
fi
prompt="Purge $name now?"
((${#others[@]} == 0)) || prompt+=" This also removes: $(join_by ', ' "${others[@]}")"
read_line "$prompt [y/N] "
case $REPLY in
y | Y | yes | YES | Yes) ;;
*)
say "not purged: $name"
return 0
;;
esac
be purge "${plan[@]}" || rc=$?
if ((rc)); then
err "purge of $name failed ($(be label) exited $rc); the row stays in the list"
return 0
fi
say "purged: ${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 now
now=$(now_utc)
for i in "$@"; do
trust_pkg "${R_NAME[i]}" "$now"
R_EXCL[i]=0
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] && !opt_all)) && is_trusted "${R_NAME[i]}"; then
R_EXCL[i]=0
say " (${R_NAME[i]} is trusted now: no longer excluded from this run)"
fi
done
state_save
}
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
# session_line LINE: validate every token first (a typo never executes half a line), then run them
session_line() {
local tok n op i
local -a toks=()
local re='^([0-9]{1,6})(\?\?|\?|-|x|\+)$'
read -r -a toks <<<"$1" || true
if ((${#toks[@]} == 0)); then
SESSION_PROCEED=1
return 0
fi
for tok in "${toks[@]}"; do
if [[ $tok =~ $re ]]; then
n=$((10#${BASH_REMATCH[1]}))
if ! num_row "$n"; then
err "no package [$n] in the list (l to redraw)"
return 0
fi
else
case $tok in
'*+' | '*-' | l | q | h | '?') ;;
*)
err "unknown command '$tok' (h for help)"
return 0
;;
esac
fi
done
for tok in "${toks[@]}"; do
if [[ $tok =~ $re ]]; then
n=$((10#${BASH_REMATCH[1]}))
op=${BASH_REMATCH[2]}
if ! num_row "$n"; then
err "package [$n] has left the list; ignoring the rest of the line"
return 0
fi
i=$REPLY
case $op in
'?') 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
;;
x)
session_purge "$i"
if (($(rows_left) == 0 && ${#R_NOTES[@]} == 0)); then
say "Nothing left to upgrade."
exit 0
fi
;;
+) session_trust "$i" ;;
esac
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 ;;
l) print_list ;;
q)
say "Quit: nothing was upgraded."
exit 0
;;
h | '?') session_help ;;
esac
fi
done
}
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
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
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 "$@"