#!/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          full native package information (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 dump
  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
  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_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_rdepends() {
	local out w
	local -a words=()
	out=$(pm_field -Qi 'Required By' "$1") || true
	out=${out#*$'\t'}
	read -r -a words <<<"$out" || true
	for w in ${words[@]+"${words[@]}"}; do [[ $w == None ]] || printf '%s\n' "$w"; done | sort -u
}
# pacman_reason PKG: the "Install Reason" of `pacman -Qi` (discovery/pacman.md section 13); -Qi
# fails (exit 1, "package 'x' was not found") for a package that is not installed
pacman_reason() {
	local out rc=0
	out=$(pm_field -Qi 'Install Reason' "$1") || rc=$?
	out=${out%%$'\n'*}
	if ((rc)) && [[ -z $out ]]; then
		printf 'not-installed\n'
		return 0
	fi
	case ${out#*$'\t'} in
	'Explicitly installed') printf 'explicit\n' ;;
	'Installed as a dependency for another package') printf 'dependency\n' ;;
	*) printf 'unknown\n' ;;
	esac
}

# discovery/pacman/lib.sh pm_closure (forward mode): one pacman -Qi dump + awk BFS, roots excluded
pacman_closure() {
	_pm -Qi 2>/dev/null | awk -v roots="$*" '
    function norm(s) { gsub(/[ \t]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s); return (s == "None") ? "" : s }
    function flush() {
      if (name == "") return
      real[name] = 1; dep[name] = norm(F["Depends On"]); pv[name] = norm(F["Provides"])
      name = ""; delete F; cur = ""
    }
    function resolve(d,   b) { b = d; sub(/[<>=].*/, "", b); if (b in real) return b; return prov[b] }
    /^[ \t]*$/ { flush(); next }
    /^[^ \t]/ { i = index($0, ":"); k = substr($0, 1, i - 1); sub(/[ \t]+$/, "", k)
                v = substr($0, i + 1); sub(/^[ \t]+/, "", v); cur = k; F[k] = v
                if (k == "Name") name = v; next }
    { sub(/^[ \t]+/, ""); F[cur] = F[cur] " " $0 }
    END {
      flush()
      for (n in real) { c = split(pv[n], a, " "); for (i = 1; i <= c; i++) { p = a[i]; sub(/[<>=].*/, "", p); prov[p] = (p in prov) ? prov[p] " " n : n } }
      nr = split(roots, r, " "); head = 0; tail = 0
      for (i = 1; i <= nr; i++) { m = split(resolve(r[i]), a, " "); for (j = 1; j <= m; j++) if (!(a[j] in seen)) { seen[a[j]] = 1; isroot[a[j]] = 1; q[++tail] = a[j] } }
      while (head < tail) {
        n = q[++head]
        c = split(dep[n], a, " "); for (i = 1; i <= c; i++) { m = split(resolve(a[i]), b, " "); for (j = 1; j <= m; j++) if (!(b[j] in seen)) { seen[b[j]] = 1; q[++tail] = b[j] } }
      }
      for (n in seen) if (!(n in isroot)) print n
    }' | sort
}
# -Rs: the target and its dependencies nothing else needs; pacman refuses when an installed
# package still depends on the target. (--print cannot be combined with -n: verified.)
# On refusal pacman explains on stdout (":: removing X breaks dependency ..."): show all of it.
pacman_purge_plan() {
	local out rc=0 l
	out=$(COLUMNS=0 pacman -Rs --print --print-format '%n' -- "$1" </dev/null 2>&1) || rc=$?
	if ((rc)); then
		printf '%s\n' "$out" >&2
		return "$rc"
	fi
	while IFS= read -r l; do [[ $l =~ ^[^[:space:]:]+$ ]] && printf '%s\n' "$l"; done <<<"$out"
	return 0
}
pacman_purge() { run_cmd pacman -Rns --noconfirm -- "$@"; }
# same_content A B: identical files? (sha256sum: coreutils; cmp is diffutils, not in Arch's base)
same_content() {
	local a b
	a=$(sha256sum <"$1") && b=$(sha256sum <"$2") && [[ $a == "$b" ]]
}

# pacman_install_dbs SRC DBPATH: make the reviewed databases (SRC/*.db and *.db.sig) the system's.
# Under pacman's own lock (DBPATH/db.lck, created atomically: no pacman runs meanwhile); never
# backwards (a system database that changed since the review and is newer than the reviewed copy
# => refuse, exit status 2); unchanged ones are skipped; all-or-nothing: every file is copied to a
# temporary name first, then all are renamed with the signals ignored, each .db.sig right before
# its .db. Returns 0 (installed or nothing to do), 1 (error, nothing changed), 2 (refused).
pacman_install_dbs() {
	local src=$1 real=$2 dst=$2/sync f n x t rc=0
	local -a dbs=() show=() newer=() todo=()
	local -A same=()
	for f in "$src"/*.db; do [[ -f $f ]] && dbs+=("$f"); done
	((${#dbs[@]})) || return 0
	for f in "${dbs[@]}"; do
		show+=("$f")
		[[ ! -f $f.sig ]] || show+=("$f.sig")
	done
	if ((opt_dry_run)); then
		say "+ $(fmt_cmd cp -p -- "${show[@]}" "$dst/")"
		return 0
	fi
	if ! (set -o noclobber && : >"$real/db.lck") 2>/dev/null; then
		err "the pacman database is locked by another process ($real/db.lck); nothing was changed"
		return 1
	fi
	PACMAN_DB_LOCK_OURS=$real/db.lck
	for f in "${dbs[@]}"; do
		n=${f##*/}
		[[ -f $dst/$n ]] || continue
		if same_content "$dst/$n" "$f"; then
			same[$n]=1
		elif [[ $dst/$n -nt $f ]]; then
			newer+=("${n%.db}")
		fi
	done
	if ((${#newer[@]})); then
		pacman_db_unlock
		err "package databases changed since the review (${newer[*]}); run $SU_PROG again"
		return 2
	fi
	show=()
	for f in "${dbs[@]}"; do
		n=${f##*/}
		[[ -z ${same[$n]+x} ]] || continue
		for x in "$f.sig" "$f"; do
			[[ -f $x ]] || continue
			t=$dst/.${x##*/}.$SU_PROG
			SU_TMP+=("$t")
			if ! cp -p -- "$x" "$t"; then
				rc=1
				break 2
			fi
			todo+=("$t")
			show+=("$x")
		done
	done
	if ((rc)); then
		rm -f -- ${todo[@]+"${todo[@]}"}
		pacman_db_unlock
		err "cannot copy the reviewed package databases into $dst; nothing was changed"
		return 1
	fi
	if ((${#todo[@]} == 0)); then
		say "The system's package databases are the reviewed ones already."
		pacman_db_unlock
		return 0
	fi
	say "+ $(fmt_cmd cp -p -- "${show[@]}" "$dst/")"
	trap '' INT TERM HUP QUIT
	PACMAN_DBS_PENDING=1
	for t in "${todo[@]}"; do
		x=${t##*/.}
		x=${x%."$SU_PROG"}
		mv -f -- "$t" "$dst/$x" || rc=1
	done
	su_traps_set
	pacman_db_unlock
	if ((rc)); then
		err "renaming the reviewed package databases into $dst failed"
		return 1
	fi
	return 0
}

pacman_upgrade() {
	local rc=0 real tmp out
	local -a ign=()
	if (($#)); then ign=(--ignore "$(join_by , "$@")"); fi
	# without a refresh (--no-refresh, or a failed one): apply the existing databases
	if ((!opt_refresh || PACMAN_SY_FAILED)); then
		run_cmd pacman -Su --noconfirm ${ign[@]+"${ign[@]}"}
		return
	fi
	real=$(pacman_dbpath)
	if ((${#PM_DB[@]} == 0)); then
		# --dry-run as non-root: the refresh was printed, not run
		say "+ $(fmt_cmd pacman -Sup --noconfirm ${ign[@]+"${ign[@]}"} --dbpath "${PACMAN_TMP_PREFIX}PID.XXXXXX")"
		say "+ cp -p -- ${PACMAN_TMP_PREFIX}PID.XXXXXX/sync/*.db* $real/sync/"
		run_cmd pacman -Su --noconfirm ${ign[@]+"${ign[@]}"}
		return
	fi
	tmp=${PM_DB[1]}
	# 1. the reviewed transaction must be possible: when it is not, nothing on the system changes
	printf 'Checking the reviewed transaction (pacman -Sup)... ' 2>/dev/null || true
	out=$(COLUMNS=0 run_child pacman -Sup --noconfirm ${ign[@]+"${ign[@]}"} --dbpath "$tmp" --print-format '%n %v' 2>&1) || rc=$?
	if ((rc == 0)); then say 'ok.'; else say 'FAILED.'; fi
	if ((rc)); then
		printf '%s\n' "$out" >&2 2>/dev/null || true
		err "the reviewed upgrade cannot be done (pacman -Sup exited $rc): nothing was changed, the system's package databases are untouched"
		return "$rc"
	fi
	# 2. the reviewed databases become the system's (locked, never backwards, all-or-nothing)
	pacman_install_dbs "$tmp/sync" "$real" || return $?
	# 3. apply exactly them (no further sync)
	run_cmd pacman -Su --noconfirm ${ign[@]+"${ign[@]}"} || rc=$?
	if ((rc && PACMAN_DBS_PENDING)); then pacman_pending_warning; fi
	PACMAN_DBS_PENDING=0
	return "$rc"
}

# ===================================================================================================
# rpm helpers shared by dnf and zypper (discovery/rpm/lib-rpm.sh, pure rpm + coreutils/sed/grep)
# ===================================================================================================
_su_installed_evr() { # "name.arch<TAB>evr", ascending so the highest version wins
	rpm -qa --qf '%{NAME}.%{ARCH}\t%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\n' </dev/null |
		sort -t $'\t' -k1,1 -k2,2V
}

# su_cap_names: stdin = one REQUIRENAME per line; stdout = plain capability names to look up
# (rpmlib() dropped, rich deps expanded to their members, if/unless conditions dropped)
su_cap_names() {
	local cap tok w depth cond skip o c n
	set -f
	while IFS= read -r cap; do
		case $cap in
		'' | rpmlib\(*) ;;
		\(*)
			depth=0 cond=-1 skip=0
			for tok in $cap; do
				w=$tok n=0
				while [[ ${w:0:1} == '(' ]]; do
					w=${w:1}
					n=$((n + 1))
				done
				depth=$((depth + n))
				o=${w//[^(]/} c=${w//[^)]/}
				n=$((${#c} - ${#o}))
				((n >= 0)) || n=0
				((n == 0)) || w=${w%"${w: -$n}"}
				if ((skip)); then
					skip=0
				else
					case $w in
					if | unless) cond=$depth ;;
					else) ((cond != depth)) || cond=-1 ;;
					and | or | with | without | '') ;;
					'=' | '<' | '>' | '<=' | '>=' | '==') skip=1 ;;
					*) ((cond >= 0)) || printf '%s\n' "$w" ;;
					esac
				fi
				depth=$((depth - n))
				if ((cond >= 0 && depth < cond)); then cond=-1; fi
			done
			;;
		*) printf '%s\n' "$cap" ;;
		esac
	done
	set +f
}

# rpm_closure PKG...: installed packages PKG... need, transitively (roots excluded, sorted)
rpm_closure() {
	local -A seen=()
	local -a frontier=("$@") caps=() provs=() next=() out=()
	local p
	for p in "$@"; do seen[$p]=1; done
	while ((${#frontier[@]})); do
		mapfile -t caps < <(rpm -q --qf '[%{REQUIRENAME}\n]' "${frontier[@]}" </dev/null 2>/dev/null | sort -u | su_cap_names | sort -u)
		((${#caps[@]})) || break
		mapfile -t provs < <(rpm -q --whatprovides --qf '%{NAME}\n' "${caps[@]}" </dev/null 2>/dev/null | grep -v -e '^no package provides ' | sort -u)
		next=()
		for p in ${provs[@]+"${provs[@]}"}; do
			if [[ -n $p && -z ${seen[$p]:-} ]]; then
				seen[$p]=1
				next+=("$p")
				out+=("$p")
			fi
		done
		frontier=(${next[@]+"${next[@]}"})
	done
	if ((${#out[@]})); then printf '%s\n' "${out[@]}" | sort -u; fi
	return 0
}

# su_req_table FILE: "capability<TAB>requiring-package" for every installed package, sorted.
# Inside [...] the scalar tag MUST be %{=NAME} (plain %{NAME} breaks on rpm 4.16..6.0).
su_req_table() {
	local dest=$1 cap name capn all
	su_mktemp
	all=$REPLY
	rpm -qa --qf '[%{REQUIRENAME}\t%{=NAME}\n]' </dev/null >"$all"
	{
		grep -v -e '^rpmlib(' -e '^(' "$all" || true
		{ grep -e '^(' "$all" || true; } | while IFS=$'\t' read -r cap name; do
			su_cap_names <<<"$cap" | while IFS= read -r capn; do printf '%s\t%s\n' "$capn" "$name"; done
		done
	} | sort -u >"$dest"
	rm -f -- "$all"
}

# rpm_rdeps PKG: installed packages that DIRECTLY require PKG's name, a Provides or an owned file
rpm_rdeps() {
	local pkg=$1 tab caps
	su_mktemp
	tab=$REPLY
	su_req_table "$tab"
	su_mktemp
	caps=$REPLY
	{
		rpm -q --qf '[%{PROVIDENAME}\n]' "$pkg" </dev/null 2>/dev/null || true
		rpm -ql "$pkg" </dev/null 2>/dev/null | grep -v -e '^(contains no files)$' || true
	} | sort -u >"$caps"
	join -t $'\t' "$caps" "$tab" | cut -f2 | sort -u | grep -v -x -F -e "$pkg" || true
	rm -f -- "$caps" "$tab"
	return 0
}

# _su_xattr '<tag text>' attr -> $REPLY (XML-unescaped); rc 1 if absent
_su_xattr() {
	local v=${1#* "$2"=\"}
	if [[ $v == "$1" ]]; then
		REPLY=
		return 1
	fi
	v=${v%%\"*}
	v=${v//&lt;/<}
	v=${v//&gt;/>}
	v=${v//&quot;/\"}
	v=${v//&apos;/\'}
	v=${v//&amp;/"&"} # quoted: with bash >= 5.2 (patsub_replacement) a bare & re-inserts the match
	REPLY=$v
}

# ===================================================================================================
# backend: dnf (dnf4 on EL9/EL10, dnf5 on Fedora; yum is the same binary on EL9+)
# ===================================================================================================
DNF=dnf
dnf_init() {
	if have dnf; then
		DNF=dnf
	elif have yum; then
		DNF=yum
	else
		die "dnf backend: neither dnf nor yum found in PATH"
	fi
	have rpm || die "dnf backend: rpm not found in PATH"
}
dnf_label() { printf '%s' "$DNF"; }
dnf_refresh() { refresh_run "$DNF" -q --refresh --setopt=skip_if_unavailable=0 makecache; }

# check-update: exit 100 = updates, 0 = none, 1 = error. dnf4 prints a leading blank line and a
# trailing "Obsoleting Packages" section, dnf5 an "Upgrades" header: only 3-field rows count.
dnf_upgradable() {
	local out rc=0 na ver repo rest name k evr n=0 obs=0
	local -A old=()
	local -A oldn=()
	local -a obsoleting=()
	out=$("$DNF" -q check-update --setopt=skip_if_unavailable=0 </dev/null) || rc=$?
	case $rc in
	0) return 0 ;;
	100) ;;
	*)
		err "$DNF check-update failed (exit $rc)"
		return "$rc"
		;;
	esac
	while IFS=$'\t' read -r k evr; do
		evr=${evr#0:}
		old[$k]=$evr
		oldn[${k%.*}]=$evr
	done < <(_su_installed_evr)
	while IFS= read -r rest; do
		read -r na ver repo k <<<"$rest"
		if [[ $na == Obsoleting ]]; then
			obs=1
			continue
		fi
		[[ -n $repo && -z $k ]] || continue
		name=${na%.*}
		if ((obs)); then
			# "Obsoleting Packages": the obsoleting package at column 0, what it obsoletes indented
			[[ $rest == [[:space:]]* ]] || obsoleting+=("$name")
			continue
		fi
		printf '%s\t%s\t%s\t%s\t\n' "$name" "${old[$na]:-${oldn[$name]:-?}}" "$ver" "$repo"
		n=$((n + 1))
	done <<<"$out"
	if ((${#obsoleting[@]})); then
		printf '#the upgrade also installs (obsoleting installed packages): %s\n' "${obsoleting[*]}"
	elif ((n == 0)); then
		printf '#%s check-update reports pending changes that are not listed here\n' "$DNF"
	fi
	return 0
}

dnf_describe() {
	local out='' l
	out=$("$DNF" -q repoquery --upgrades --latest-limit=1 --queryformat $'%{summary}\n' "$1" </dev/null 2>/dev/null) || out=''
	[[ -n ${out//[[:space:]]/} ]] || out=$(rpm -q --qf '%{SUMMARY}\n' "$1" </dev/null 2>/dev/null) || out=''
	[[ -n ${out//[[:space:]]/} ]] || out=$("$DNF" -q repoquery --latest-limit=1 --queryformat $'%{summary}\n' "$1" </dev/null 2>/dev/null) || out=''
	while IFS= read -r l; do
		if [[ -n $l ]]; then
			printf '%s\n' "$l"
			return 0
		fi
	done <<<"$out"
	return 1
}
dnf_full_info() { "$DNF" -q info "$1" </dev/null; }
dnf_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"
}

print_full_info() { # print_full_info PKG (N?? / show); 1 if the package manager knows nothing
	local rc=0
	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]}" || 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 "$@"
