#!/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=' 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 < 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): 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' 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 || 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 _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=() # "backendpackage" -> "reasonadded" 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` -> nameoldnewrepo # 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...` -> nameinstalledcandidateorigin 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) || 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) || 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" `, 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) || 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; } apt_rdepends() { apt-cache rdepends --installed --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances "$1" /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) || { 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) || { 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) || 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 | 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) || 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" &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[@]}" &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 >"$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)$'\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 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) || 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> ... -> namevalue 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" &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.archevr", ascending so the highest version wins rpm -qa --qf '%{NAME}.%{ARCH}\t%|EPOCH?{%{EPOCH}:}|%{VERSION}-%{RELEASE}\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 | sort -u | su_cap_names | sort -u) ((${#caps[@]})) || break mapfile -t provs < <(rpm -q --whatprovides --qf '%{NAME}\n' "${caps[@]}" /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: "capabilityrequiring-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]' "$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 || true rpm -ql "$pkg" /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 '' 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) || out='' [[ -n ${out//[[:space:]]/} ]] || out=$(rpm -q --qf '%{SUMMARY}\n' "$1" /dev/null) || out='' [[ -n ${out//[[:space:]]/} ]] || out=$("$DNF" -q repoquery --latest-limit=1 --queryformat $'%{summary}\n' "$1" /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 | sed '/^[^[:space:]][^:]*$/d'; } dnf_candidate_record() { local w=--available [[ ${2:-} != installed ]] || w=--upgrades "$DNF" -q info "$w" "$1" /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) || { 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" &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 (dup/update/remove --dry-run) # as "sectionnameoldnewrepo"; 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/*} ;; '*} _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 *} _su_xattr "$tag" name || true name=$REPLY _su_xattr "$tag" edition || true new=$REPLY _su_xattr "$tag" edition-old || true old=$REPLY src=${line#*/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) || return 1 printf '%s\n' "$out" } zypper_full_info() { zypper --non-interactive --quiet info --requires "$1" /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 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" &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): 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 _installed_record PKG and # _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 _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 "$@"