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.
2606 lines
89 KiB
Bash
Executable File
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//</<}
|
|
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; }
|
|
# 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 "$@"
|