#!/bin/sh
# Copyright (C) 2026 Christian Wirtz <doc@snowheaven.de>
# License: GPL-2.0-only, see LICENSE in the repository root.
#
# Checkmk agent plug-in: detects pending system/package updates.
#
# POSIX /bin/sh dispatcher for Linux -- deliberately NOT Python, unlike an earlier
# draft of this script. Live-verified (see CONCEPT.md changelog v14) that python3 is
# NOT present by default on Rocky Linux 8 (dnf), openSUSE Leap 15.6 (zypper), or
# Alpine 3.20 (apk) -- only Ubuntu ships it out of the box among the four Phase-1
# targets. A Python-based host script would have made this plug-in silently
# non-functional on 3 of 4 target distro families. Matches the approach our own
# checkmk_alpine_agent project already uses for exactly this reason.
#
# Verified to run under: dash/bash (Ubuntu), bash (Rocky Linux 8, openSUSE Leap
# 15.6), busybox ash (Alpine 3.20) -- `local` inside functions works on all of them
# even though POSIX itself doesn't mandate it; every other construct here is plain
# POSIX (no bashisms: no arrays, no [[, no $(( )) with non-POSIX operators).
#
# Windows uses a completely separate script (agents/system_updates_windows.ps1, not this
# file).
#
# STATUS (see CONCEPT.md section 12's honesty convention): apt, dnf/yum, zypper and
# apk are all implemented. zypper's security/normal classification could not be
# live-verified against a real pending security patch (see collect_zypper() below)
# -- zypper updates are conservatively reported as "unknown" for now.
#
# Optional config file (deployed by bakery/system_updates.py's BakeryPlugin, only
# present if the "System Updates (Linux)" rule is assigned) -- simple key=value
# lines, not JSON, specifically so this stays parsable with plain POSIX text tools:
#
#   $MK_CONFDIR/system_updates.cfg
#   refresh_mode=off|always|if_older_than
#   refresh_max_age_seconds=<integer, only meaningful for if_older_than>
#
# Absent config (no rule assigned, or file missing/unreadable) means every default:
# refresh_mode=off -- read each package manager's existing local cache only, never
# trigger a metadata refresh ourselves. See CONCEPT.md section 6.

set -u

# Language independence: parse every package manager's output in its untranslated
# form, regardless of the host's system locale (same goal as the Windows plug-in).
# Verified live on Rocky Linux 9 with a German locale: without this, dnf translates
# the advisory type ("Important/Sec.") and every security update was counted as
# normal. LANGUAGE must go too -- Python's gettext (dnf, zypper helpers) consults it
# even before LC_ALL.
LC_ALL=C
LANG=C
export LC_ALL LANG
unset LANGUAGE

SECTION_NAME="system_updates"
INFO_SECTION_NAME="system_updates_info"

CONFDIR="${MK_CONFDIR:-/etc/check_mk}"
CONFIG_FILE="$CONFDIR/system_updates.cfg"
[ -f "$CONFIG_FILE" ] || CONFIG_FILE="/etc/check_mk/system_updates.cfg"

WORKDIR="$(mktemp -d 2>/dev/null || mktemp -d -t sysupd)"
trap 'rm -rf "$WORKDIR"' EXIT INT TERM

REFRESH_MODE="off"
REFRESH_MAX_AGE_SECONDS=""
# Never refresh package metadata more often than this, whatever the mode -- protects
# the distribution's mirrors (and the host from being rate-limited by them), e.g.
# with "always" and a short execution interval. Measured against the cache age, so
# a refresh done by the system itself (apt-daily, dnf-makecache, ...) counts too.
# Can't go below 1 hour, whatever the config says.
MIN_REFRESH_INTERVAL_SECONDS=14400
MIN_REFRESH_INTERVAL_FLOOR=3600
if [ -f "$CONFIG_FILE" ]; then
	v="$(grep -m1 '^refresh_mode=' "$CONFIG_FILE" 2>/dev/null | cut -d= -f2-)"
	[ -n "$v" ] && REFRESH_MODE="$v"
	v="$(grep -m1 '^refresh_max_age_seconds=' "$CONFIG_FILE" 2>/dev/null | cut -d= -f2-)"
	[ -n "$v" ] && REFRESH_MAX_AGE_SECONDS="$v"
	v="$(grep -m1 '^min_refresh_interval_seconds=' "$CONFIG_FILE" 2>/dev/null | cut -d= -f2-)"
	case "$v" in '' | *[!0-9]*) ;; *) MIN_REFRESH_INTERVAL_SECONDS="$v" ;; esac
fi
[ "$MIN_REFRESH_INTERVAL_SECONDS" -ge "$MIN_REFRESH_INTERVAL_FLOOR" ] || MIN_REFRESH_INTERVAL_SECONDS="$MIN_REFRESH_INTERVAL_FLOOR"

NOW="$(date +%s)"

# Our own per-backend stamps (in the agent's state directory):
#   system_updates_refresh_attempt.<backend> -- last refresh this plug-in started;
#     the minimum interval is enforced against it, so even a failing mirror or a
#     cache that still looks old after a refresh is never retried sooner.
#   system_updates_refreshed.<backend> -- last refresh that succeeded; counts as
#     cache age. Needed for apt: it sets the downloaded lists' mtime to the server's
#     Last-Modified time (verified on real Debian 13/Proxmox hosts: 15 h "old" right
#     after apt-get update), so those files alone overstate the cache age.
STATE_DIR="${MK_VARDIR:-/var/lib/check_mk_agent}"

UPDATES_FILE="$WORKDIR/updates.tsv"
INFO_FILE="$WORKDIR/info.tsv"
: >"$UPDATES_FILE"
: >"$INFO_FILE"
DETECTED_BACKENDS=""

# should_refresh <cache_age_seconds_or_empty> <backend> -- exit 0 (true) if a refresh is due.
should_refresh() {
	case "$REFRESH_MODE" in
	always | if_older_than) ;;
	*) return 1 ;;
	esac
	# Hard floor against our own last attempt, whatever the cache looks like.
	last_attempt="$(newest_mtime_age "$STATE_DIR/system_updates_refresh_attempt.$2")"
	[ -z "$last_attempt" ] || [ "$last_attempt" -ge "$MIN_REFRESH_INTERVAL_SECONDS" ] || return 1
	age="$1"
	# No cache at all yet -> refresh (once; afterwards the age applies).
	[ -n "$age" ] || return 0
	# Hard floor, see MIN_REFRESH_INTERVAL_SECONDS.
	[ "$age" -ge "$MIN_REFRESH_INTERVAL_SECONDS" ] || return 1
	[ "$REFRESH_MODE" = "always" ] && return 0
	[ -n "$REFRESH_MAX_AGE_SECONDS" ] || return 1
	[ "$age" -gt "$REFRESH_MAX_AGE_SECONDS" ]
}

# run_refresh <backend> <command...> -- runs a metadata refresh, recording our stamps.
run_refresh() {
	backend="$1"
	shift
	[ -d "$STATE_DIR" ] || mkdir -p "$STATE_DIR" 2>/dev/null
	touch "$STATE_DIR/system_updates_refresh_attempt.$backend" 2>/dev/null
	if "$@" >/dev/null 2>&1; then
		touch "$STATE_DIR/system_updates_refreshed.$backend" 2>/dev/null
	fi
	return 0
}

# newest_mtime_age <glob...> -- prints the age in seconds of the newest matching
# file, or nothing if none match. Uses `set --` + a for-loop instead of `ls -t` to
# avoid depending on `ls`'s exact sort-stability/locale behavior across distros.
newest_mtime_age() {
	newest=""
	for f in "$@"; do
		[ -e "$f" ] || continue
		mtime="$(stat -c %Y "$f" 2>/dev/null)" || continue
		if [ -z "$newest" ] || [ "$mtime" -gt "$newest" ]; then
			newest="$mtime"
		fi
	done
	[ -n "$newest" ] || return 0
	age=$((NOW - newest))
	[ "$age" -ge 0 ] && echo "$age" || echo 0
}

# Update rows (section system_updates, tab-separated):
#   name, installed, candidate, classification, backend, advisory_ids, upgrade_mode,
#   held, optional (Windows only, always "false" here), issued
# "issued" is the Unix time the oldest applicable security advisory was published,
# empty where the backend doesn't provide it (apt, apk) or for non-security rows --
# the server derives oldest_pending_security_update_age from it.

# _to_epoch "<YYYY-MM-DD HH:MM:SS>" -- Unix time (UTC) or nothing.
_to_epoch() {
	[ -n "$1" ] || return 0
	TZ=UTC date -d "$1" +%s 2>/dev/null
}

# _min_issued <issued-tsv> <comma-separated advisory ids> -- oldest epoch among
# the given advisories (tsv: id<TAB>epoch), or nothing.
_min_issued() {
	for _id in $(printf '%s' "$2" | tr ',' ' '); do
		awk -F'\t' -v i="$_id" '$1 == i && $2 != "" {print $2}' "$1"
	done | sort -n | head -1
}

# --- apt backend (Debian/Ubuntu/…) -------------------------------------------------
#
# Real "Inst" line shape, verified live against a real Ubuntu 22.04 apt-get (see
# CONCEPT.md changelog v12), e.g.:
#   Inst curl [7.81.0-1ubuntu1.25] (7.81.0-1ubuntu1.27 Ubuntu:22.04/jammy-updates,
#     Ubuntu:22.04/jammy-security [amd64]) []
# The origin/suite list right before the trailing "[arch]" already carries the
# security classification (a "-security" suite) -- no separate `apt-cache policy`
# call per package needed for that part.
#
# Held packages: `apt-mark showhold` only lists packages *explicitly* pinned by the
# admin. A package kept back purely as a consequence of another package's hold
# (verified live: holding `curl` also keeps `libcurl4` back) has no "Inst" line
# either -- it's taken from dist-upgrade's "The following packages have been kept
# back:" block, reliable to parse now that the agent runs with LC_ALL=C, and
# reported as held too.

apt_inst_lines_to_tsv() {
	# stdin: raw `apt-get --just-print upgrade|dist-upgrade` output.
	# stdout: name<TAB>old<TAB>new<TAB>classification, one row per "Inst" line.
	grep '^Inst ' | while IFS= read -r line; do
		name="$(printf '%s\n' "$line" | awk '{print $2}')"
		old="$(printf '%s\n' "$line" | sed -n 's/^Inst [^ ]* \[\([^]]*\)\].*/\1/p')"
		[ -n "$old" ] || old="unknown"
		inner="$(printf '%s\n' "$line" | sed -n 's/^Inst [^ ]* \[[^]]*\] (\([^)]*\)).*/\1/p')"
		[ -n "$inner" ] || continue
		new="$(printf '%s\n' "$inner" | awk '{print $1}')"
		origins="$(printf '%s\n' "$inner" | sed -E 's/^[^ ]+ //; s/ \[[^]]*\]$//')"
		classification="normal"
		case "$origins" in *-security*) classification="security" ;; esac
		printf '%s\t%s\t%s\t%s\n' "$name" "$old" "$new" "$classification"
	done
}

# Signal sources, in order (all verified live):
#   1. /var/run/reboot-required -- created by Ubuntu's update-notifier-common hooks
#      (with the triggering packages in .pkgs).
#   2. `needrestart -b -k` -- NEEDRESTART-KSTA 2/3 means a newer kernel is installed
#      than the running one. Needed on Debian: Debian has no update-notifier-common
#      at all, so nothing ever creates the file -- not even a kernel update (verified
#      in debian:12/13) -- and "file absent" used to read as a false "no reboot".
#   3. No file, but Ubuntu's notifier is installed -> "false".
#   4. Running kernel vs. newest installed kernel of the same flavor in /boot (what
#      needrestart -k does) -- covers plain Debian without needrestart.
#   5. Otherwise "unknown" rather than a misleading "false".
apt_reboot_required() {
	if [ -e /var/run/reboot-required ]; then
		echo "true"
		if [ -r /var/run/reboot-required.pkgs ]; then
			tr '\n' ',' <"/var/run/reboot-required.pkgs" | sed 's/,$//; s/,/, /g'
		else
			echo ""
		fi
		return 0
	fi
	if command -v needrestart >/dev/null 2>&1; then
		nr="$(needrestart -b -k 2>/dev/null)"
		ksta="$(printf '%s\n' "$nr" | sed -n 's/^NEEDRESTART-KSTA: *//p')"
		case "$ksta" in
		2 | 3)
			echo "true"
			printf 'kernel %s -> %s\n' "$(printf '%s\n' "$nr" | sed -n 's/^NEEDRESTART-KCUR: *//p')" \
				"$(printf '%s\n' "$nr" | sed -n 's/^NEEDRESTART-KEXP: *//p')"
			return 0
			;;
		1)
			echo "false"
			echo ""
			return 0
			;;
		esac
	fi
	if [ -x /usr/share/update-notifier/notify-reboot-required ]; then
		echo "false"
		echo ""
		return 0
	fi
	# Neither signal source available (typical plain Debian without needrestart):
	# compare the running kernel with the newest installed kernel of the same flavor
	# -- the same check `needrestart -k` does. Flavor = uname -r minus its leading
	# version, so a second installed flavor can't cause a false "reboot required".
	# Version forms (all verified live): "6.1.0-53-amd64" (Debian 12, with ABI
	# number), "6.12.111+deb13-amd64" (Debian 13, none), "6.8.0-146-generic"
	# (Ubuntu); flavors like "amd64", "cloud-amd64", "rt-amd64", "generic".
	running="$(uname -r)"
	flavor="$(printf '%s\n' "$running" | sed -n -E 's/^[0-9][^-]*(-[0-9][^-]*)?-//p')"
	newest=""
	if [ -n "$flavor" ]; then
		newest="$(for f in /boot/vmlinuz-*-"$flavor"; do [ -e "$f" ] && printf '%s\n' "${f#/boot/vmlinuz-}"; done | sort -V | tail -1)"
	fi
	if [ -z "$newest" ]; then
		echo "unknown"
		echo ""
	elif [ "$newest" != "$running" ]; then
		echo "true"
		printf 'kernel %s -> %s\n' "$running" "$newest"
	else
		echo "false"
		echo ""
	fi
}

# Detects whether any *enabled* apt source actually points at a security suite --
# without this, a disabled/misconfigured security repo would otherwise make the
# main service silently report "0 security updates" as if everything were fine
# (CONCEPT.md section 13a Punkt 1 / offener Punkt 15). Live-verified against a real
# Ubuntu 22.04 sources.list (a real "-security" suite line, e.g.
# "deb http://security.ubuntu.com/ubuntu/ jammy-security main restricted").
_apt_security_repo_configured() {
	for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list; do
		[ -f "$f" ] || continue
		grep -E '^[[:space:]]*deb[[:space:]]' "$f" 2>/dev/null | grep -q -- '-security' && { echo "true"; return 0; }
	done
	# deb822 format (.sources files, Ubuntu 24.04+ and Debian 13): a "Suites:" line
	# containing "-security" -- verified live on Ubuntu 24.04 and 26.04.
	for f in /etc/apt/sources.list.d/*.sources; do
		[ -f "$f" ] || continue
		awk '/^Suites:/ && /-security/ {found=1} END{exit !found}' "$f" && { echo "true"; return 0; }
	done
	echo "false"
}

# The lists' mtime is the server's Last-Modified time (an upper bound of the age);
# apt's own periodic stamps and ours record when an update really ran.
_apt_cache_age() {
	newest_mtime_age /var/lib/apt/lists/*Packages* \
		/var/lib/apt/periodic/update-success-stamp \
		/var/lib/apt/periodic/update-stamp \
		"$STATE_DIR/system_updates_refreshed.apt"
}

# apt-get update exits 0 even when every mirror is unreachable (verified live on
# Debian 13: only "Err:"/"W: Failed to fetch" lines) -- that's not a refresh.
_apt_update() {
	out="$(apt-get update 2>&1)" || return 1
	! printf '%s\n' "$out" | grep -qE '^(Err:|E: |W: (Failed to fetch|Some index files failed))'
}

collect_apt() {
	command -v apt-get >/dev/null 2>&1 || return 0
	DETECTED_BACKENDS="${DETECTED_BACKENDS}apt "

	cache_age="$(_apt_cache_age)"
	refreshed="false"
	if should_refresh "$cache_age" apt; then
		run_refresh apt _apt_update
		refreshed="true"
		cache_age="$(_apt_cache_age)"
	fi

	apt-get --just-print upgrade 2>/dev/null | grep '^Inst ' | awk '{print $2}' | sort -u >"$WORKDIR/apt_standard_names"
	apt-get --just-print dist-upgrade 2>/dev/null >"$WORKDIR/apt_dist_upgrade.txt"
	apt_inst_lines_to_tsv <"$WORKDIR/apt_dist_upgrade.txt" >"$WORKDIR/apt_full.tsv"
	{
		apt-mark showhold 2>/dev/null
		awk '/^The following packages have been kept back:/ {k=1; next} k && /^  / {for (i=1; i<=NF; i++) print $i; next} {k=0}' \
			"$WORKDIR/apt_dist_upgrade.txt"
	} | sort -u >"$WORKDIR/apt_held_names"

	while IFS="$(printf '\t')" read -r name old new classification; do
		[ -n "$name" ] || continue
		upgrade_mode="full_only"
		grep -qxF "$name" "$WORKDIR/apt_standard_names" 2>/dev/null && upgrade_mode="standard"
		held="false"
		grep -qxF "$name" "$WORKDIR/apt_held_names" 2>/dev/null && held="true"
		printf '%s\t%s\t%s\t%s\tapt\t\t%s\t%s\tfalse\t\n' "$name" "$old" "$new" "$classification" "$upgrade_mode" "$held" >>"$UPDATES_FILE"
	done <"$WORKDIR/apt_full.tsv"

	# Held packages are excluded from apt's own upgrade calculation entirely (no
	# "Inst" line at all, verified live) -- report them separately via
	# `apt-cache policy` so a deliberately pinned-but-outdated package doesn't just
	# silently disappear from view.
	cut -f1 "$WORKDIR/apt_full.tsv" | sort -u >"$WORKDIR/apt_full_names"
	while IFS= read -r name; do
		[ -n "$name" ] || continue
		grep -qxF "$name" "$WORKDIR/apt_full_names" 2>/dev/null && continue
		installed="$(apt-cache policy "$name" 2>/dev/null | awk -F': ' '/^  Installed:/{print $2}')"
		candidate="$(apt-cache policy "$name" 2>/dev/null | awk -F': ' '/^  Candidate:/{print $2}')"
		[ -n "$installed" ] && [ -n "$candidate" ] || continue
		[ "$installed" = "$candidate" ] && continue
		[ "$candidate" = "(none)" ] && continue
		printf '%s\t%s\t%s\tunknown\tapt\t\tstandard\ttrue\tfalse\t\n' "$name" "$installed" "$candidate" >>"$UPDATES_FILE"
	done <"$WORKDIR/apt_held_names"

	printf 'cache_age_seconds:apt\t%s\n' "${cache_age:-}" >>"$INFO_FILE"
	printf 'refreshed_before_check:apt\t%s\n' "$refreshed" >>"$INFO_FILE"
	printf 'security_repo_configured:apt\t%s\n' "$(_apt_security_repo_configured)" >>"$INFO_FILE"
}

# --- dnf/yum backend (RHEL family) --------------------------------------------------
#
# Real shapes, verified live against a real Rocky Linux 8 container (see CONCEPT.md
# changelog v14):
#   `dnf check-update` line: "curl.x86_64    7.61.1-34.el8_10.13    baseos" -- exactly
#   one line per candidate package.
#   `dnf updateinfo list` line: "RLSA-2026:57462 Important/Sec.  curl-7.61.1-34.el8_10.13.x86_64"
#   -- advisory ID, a type (ends "/Sec." for security, else "bugfix"/"enhancement"),
#   and a NEVRA. Classification doesn't try to parse the NEVRA back into a bare
#   package name (ambiguous in general) -- it checks whether the NEVRA *starts with*
#   a known-good candidate name (from check-update) followed by "-" instead.

dnf_binary() {
	command -v dnf 2>/dev/null || command -v yum 2>/dev/null
}

# _dnf_held_name <versionlock-line> -- prints the bare package name a
# `dnf versionlock list` line refers to, or nothing if no installed package matches.
#
# Real line shape, live-verified: "bash-0:4.4.20-4.el8_6.*" (name-epoch:version-
# release.*). Like the advisory NEVRA above, parsing the bare name back out is
# ambiguous in general (names can contain hyphens) -- resolved the same way, by
# prefix-matching against a known-good list (every installed package name this
# time) and picking the *longest* match, since a shorter installed name can itself
# be a prefix of a longer one (e.g. "bash" vs. "bash-completion").
_dnf_held_name() {
	awk -F'\t' -v vline="$1" '
		index(vline, $1 "-") == 1 {
			if (length($1) > best_len) { best = $1; best_len = length($1) }
		}
		END { if (best != "") print best }
	' "$WORKDIR/dnf_installed.tsv"
}

dnf_reboot_required() {
	# Prefer the `dnf needs-restarting` plugin subcommand (dnf-plugins-core, installed
	# by default on RHEL 8-10 family): the standalone `needs-restarting` binary comes
	# from dnf-utils/yum-utils, which is no longer installed by default on Rocky 10
	# (verified live in rockylinux/rockylinux:10) -- relying on it alone reported
	# "unknown" there.
	#
	# Only probe the subcommand when the package manager really is dnf: legacy yum
	# (RHEL/CentOS 7, Amazon Linux 2) has no such plugin, yet `yum needs-restarting
	# --help` still exits 0 (generic usage text), and `yum needs-restarting -r` then
	# fails with a non-zero exit code that read as "reboot required" (verified live
	# in centos:7 and amazonlinux:2). There, only the standalone binary (yum-utils)
	# applies.
	binary="$(dnf_binary)"
	if [ -n "$binary" ] && case "$(basename "$binary")" in dnf*) true ;; *) false ;; esac &&
		"$binary" needs-restarting --help >/dev/null 2>&1; then
		out="$("$binary" needs-restarting -r 2>&1)"
		rc=$?
	elif command -v needs-restarting >/dev/null 2>&1; then
		out="$(needs-restarting -r 2>&1)"
		rc=$?
	else
		echo "unknown"
		echo ""
		return 0
	fi
	if [ "$rc" -eq 0 ]; then
		echo "false"
		echo ""
		return 0
	fi
	# Documented exit codes: 0 = no reboot needed, 1 = reboot needed. Anything else
	# is an error (e.g. rpmdb lock) and must not be reported as a pending reboot.
	if [ "$rc" -ne 1 ]; then
		echo "unknown"
		echo ""
		return 0
	fi
	echo "true"
	# The "reboot needed" output lists affected packages as indented "  * <name>"
	# lines under a "Core libraries or services have been updated since boot-up:"
	# header. Live-verified on Rocky Linux 8/9/10 (dnf 4.7/4.14/4.20) by reinstalling
	# core packages after container start (needs-restarting compares install times
	# against PID 1's start time, so this triggers the real code path). Falls back
	# to an empty reason rather than guessing further if the shape differs.
	# Only the indented lines right below that header: dnf5 first prints indented
	# repository-loading progress lines when it refreshes metadata (verified live on
	# Fedora 44 -- they ended up in the reason).
	printf '%s\n' "$out" |
		awk '/^Core libraries or services have been updated/ {f = 1; next} f && /^[[:space:]]/ {print; next} {f = 0}' |
		sed -e 's/^[[:space:]]*//' -e 's/^\*[[:space:]]*//' -e 's/[[:space:]]*$//' |
		grep -v '^$' | tr '\n' ',' | sed 's/,$//; s/,/, /g'
}

_dnf_cache_age() {
	newest_mtime_age /var/cache/dnf/*/repodata/repomd.xml \
		/var/cache/libdnf5/*/repodata/repomd.xml \
		/var/cache/yum/*/*/*/cachecookie \
		/var/cache/yum/*/*/*/*primary.sqlite* \
		"$STATE_DIR/system_updates_refreshed.dnf"
}

collect_dnf() {
	binary="$(dnf_binary)"
	[ -n "$binary" ] || return 0
	DETECTED_BACKENDS="${DETECTED_BACKENDS}dnf "

	# Cache locations: dnf 4 (/var/cache/dnf), dnf5 (Fedora 41+: /var/cache/libdnf5)
	# and legacy yum (/var/cache/yum/<arch>/<releasever>/<repo>) -- all verified live.
	# For yum, repomd.xml keeps the server's timestamp (showed ~2 years on centos:7
	# right after makecache); its per-repo `cachecookie` is touched on every metadata
	# check instead (verified live in amazonlinux:2). yum 3.4 on CentOS 7 writes no
	# cachecookie, but the downloaded *primary.sqlite* carries the local download
	# time there (verified live in centos:7).
	cache_age="$(_dnf_cache_age)"
	refreshed="false"
	if should_refresh "$cache_age" dnf; then
		run_refresh dnf "$binary" makecache
		refreshed="true"
		cache_age="$(_dnf_cache_age)"
	fi

	# All reads below use -C (cache only): without it, dnf/yum silently download
	# metadata whenever the cache is older than the repo's metadata_expire (6 h on
	# Rocky) -- network access despite refresh_mode=off, and with an unreachable
	# mirror every run retried it and check-update failed, reporting 0 updates
	# (verified live on Rocky Linux 9 with a dead proxy). Refreshing is done only by
	# the explicit makecache above.
	# check-update columns: "<name>.<arch>  <version>  <repo>" -- name never
	# contains whitespace, so simple field-splitting on the dot separating name/arch
	# via `sed` is safer/more portable across awk implementations than trying to
	# regex-match a fixed arch list inside awk.
	"$binary" -C check-update 2>/dev/null |
		sed -n -E 's/^([^[:space:].]+)\.[^[:space:].]+[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]*$/\1\t\2/p' \
			>"$WORKDIR/dnf_candidates.tsv"

	# updateinfo columns differ by dnf generation, normalized here to
	# "<advisory>\t<type>\t<package-nevra>" with security advisories as "<sev>/Sec.":
	#   dnf 4/yum: "RLSA-2026:57462 Important/Sec.  curl-7.61.1-34.el8_10.13.x86_64"
	#   dnf5:      "FEDORA-2026-044e56d9ff security Moderate tar-2:1.35-9.fc44.x86_64 2026-09-09 04:47:08"
	#              (header line "Name Type Severity Package Issued" skipped)
	"$binary" -C updateinfo list 2>/dev/null | awk '
		NF==3 { print $1"\t"$2"\t"$3; next }
		NF>=5 && $2!="Type" { t=$2; if (t=="security") t=$3"/Sec."; print $1"\t"t"\t"$4 }
	' >"$WORKDIR/dnf_advisories.tsv"

	# Publication date per security advisory (verified live): dnf 4 on RHEL clones
	# only has "Updated:", yum 3 and dnf5 have "Issued:" (preferred); the advisory id
	# is "Update ID" (dnf 4, yum) or a top-level "Name" (dnf5).
	"$binary" -C updateinfo info --security 2>/dev/null | awk '
		{ i = index($0, ":"); if (!i) next
		  k = substr($0, 1, i - 1); v = substr($0, i + 1)
		  gsub(/^[ \t]+|[ \t]+$/, "", k); gsub(/^[ \t]+/, "", v); split(v, w, " ") }
		k == "Update ID" || (k == "Name" && $0 !~ /^[ \t]/) { if (id != "" && d != "") print id "\t" d; id = w[1]; d = ""; iss = 0; next }
		k == "Issued" { d = w[1] " " w[2]; iss = 1; next }
		k == "Updated" && !iss { d = w[1] " " w[2] }
		END { if (id != "" && d != "") print id "\t" d }
	' | while IFS="$(printf '\t')" read -r adv when; do
		printf '%s\t%s\n' "$adv" "$(_to_epoch "$when")"
	done >"$WORKDIR/dnf_issued.tsv"

	# Whether any enabled repo provides advisory metadata (updateinfo) at all --
	# without it (e.g. CentOS Stream, CentOS 7) nothing can ever be classified as
	# security, and "0 security updates" would silently look like good news.
	# yum 3 takes "all" as an argument (it rejects --all with an error on stdout --
	# verified on centos:7, where that error briefly read as "metadata present").
	if case "$(basename "$binary")" in dnf*) true ;; *) false ;; esac; then
		ui_all="$("$binary" -C -q updateinfo list --all 2>/dev/null)" || ui_all=""
	else
		ui_all="$("$binary" -C -q updateinfo list all 2>/dev/null)" || ui_all=""
	fi
	if [ -n "$ui_all" ]; then
		dnf_security_metadata="true"
	else
		dnf_security_metadata="false"
	fi

	rpm -qa --queryformat '%{NAME}\t%|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n' >"$WORKDIR/dnf_installed.tsv" 2>/dev/null

	# `dnf versionlock list` needs a separate plugin not installed by default
	# (verified live: exits 1 with an error, not just empty output, when absent) --
	# harmless here since that just means no held names are found below.
	# Line shapes differ by generation (all verified live):
	#   dnf 4:   "attr-0:2.5.1-3.el9.*"              (name-epoch:version-release.*)
	#   yum 3:   "0:curl-8.3.0-1.amzn2.0.12.*"       (epoch first -- stripped here)
	#   dnf5:    "# Added by ..." / "Package name: ca-certificates" / "evr = ..."
	#            (the versionlock.toml content -- name given verbatim)
	: >"$WORKDIR/dnf_held_names"
	"$binary" -C versionlock list 2>/dev/null | while IFS= read -r vline; do
		case "$vline" in
		"" | "#"* | "evr "*) continue ;;
		"Package name: "*) printf '%s\n' "${vline#Package name: }" ;;
		*) _dnf_held_name "$(printf '%s' "$vline" | sed 's/^[0-9][0-9]*://')" ;;
		esac
	done | sort -u >"$WORKDIR/dnf_held_names"

	cut -f1 "$WORKDIR/dnf_candidates.tsv" | sort -u >"$WORKDIR/dnf_candidate_names"

	while IFS="$(printf '\t')" read -r name candidate; do
		[ -n "$name" ] || continue
		installed="$(awk -F'\t' -v n="$name" '$1==n{print $2; exit}' "$WORKDIR/dnf_installed.tsv")"
		[ -n "$installed" ] || installed="unknown"
		matches="$(awk -F'\t' -v n="$name" 'index($3, n"-")==1 {print}' "$WORKDIR/dnf_advisories.tsv")"
		advisory_ids="$(printf '%s\n' "$matches" | awk -F'\t' 'NF{print $1}' | sort -u | tr '\n' ',' | sed 's/,$//')"
		classification="normal"
		if printf '%s\n' "$matches" | awk -F'\t' '$2 ~ /\/Sec\.$/{found=1} END{exit !found}'; then
			classification="security"
		fi
		held="false"
		grep -qxF "$name" "$WORKDIR/dnf_held_names" 2>/dev/null && held="true"
		issued=""
		[ "$classification" = "security" ] && issued="$(_min_issued "$WORKDIR/dnf_issued.tsv" "$advisory_ids")"
		printf '%s\t%s\t%s\t%s\tdnf\t%s\tstandard\t%s\tfalse\t%s\n' "$name" "$installed" "$candidate" "$classification" "$advisory_ids" "$held" "$issued" >>"$UPDATES_FILE"
	done <"$WORKDIR/dnf_candidates.tsv"

	# versionlock excludes a held package from check-update entirely (verified
	# live, same behavior as apt-mark hold) -- look those up separately via
	# repoquery so a deliberately pinned-but-outdated package doesn't just
	# silently disappear from view, same reasoning as the apt backend above.
	while IFS= read -r name; do
		[ -n "$name" ] || continue
		grep -qxF "$name" "$WORKDIR/dnf_candidate_names" 2>/dev/null && continue
		# yum 3 has no `yum repoquery` (only yum-utils' standalone one, not installed
		# by default) -- check-update with the versionlock plugin disabled for this
		# one call shows the locked package's candidate instead (verified live in
		# amazonlinux:2).
		if case "$(basename "$binary")" in dnf*) true ;; *) false ;; esac; then
			candidate="$("$binary" -C repoquery --available --latest-limit=1 --qf '%{evr}' "$name" 2>/dev/null | head -1)"
		else
			candidate="$("$binary" -C -q --disableplugin=versionlock check-update "$name" 2>/dev/null |
				awk -v n="$name" 'NF==3 && index($1, n".")==1 {print $2; exit}')"
		fi
		candidate="${candidate#0:}"
		installed="$(awk -F'\t' -v n="$name" '$1==n{print $2; exit}' "$WORKDIR/dnf_installed.tsv")"
		[ -n "$candidate" ] && [ -n "$installed" ] && [ "$candidate" != "$installed" ] || continue
		printf '%s\t%s\t%s\tunknown\tdnf\t\tstandard\ttrue\tfalse\t\n' "$name" "$installed" "$candidate" >>"$UPDATES_FILE"
	done <"$WORKDIR/dnf_held_names"

	printf 'cache_age_seconds:dnf\t%s\n' "${cache_age:-}" >>"$INFO_FILE"
	printf 'refreshed_before_check:dnf\t%s\n' "$refreshed" >>"$INFO_FILE"
	printf 'security_repo_configured:dnf\t%s\n' "$dnf_security_metadata" >>"$INFO_FILE"
}

# --- zypper backend (openSUSE/SLES) --------------------------------------------------
#
# Real, live-verified (see CONCEPT.md changelog v14) against a real openSUSE
# Leap 15.6 container: `zypper --xmlout list-updates --all` produces
#   <update kind="package" name="..." edition="<new>" arch="..." edition-old="<old>">
# per pending package -- `--all` matters: without it, a real pending update from a
# different-priority repo was silently excluded.
#
# Classification (live-verified on Leap 15.6 with a real pending security patch,
# forced by installing the GA libexpat1 from repo-oss): `zypper patch --dry-run`
# lists the packages each patch set would upgrade as
#   <solvable type="package" name="libexpat1" ...> inside <to-upgrade>.
# A package upgraded by a security patch -> "security"; by any other patch only ->
# "normal"; not covered by any patch (plain version bump, e.g. everything on
# Tumbleweed, which has no patches) -> "unknown". Caveat: if a patch for the update
# stack itself (zypper/libzypp) is pending, zypper only offers that one first, so
# other packages stay "unknown" until it's installed -- conservative, never wrongly
# "normal". --dry-run never changes anything.

collect_zypper() {
	# `zypper --version` (not just `command -v zypper`), because a Checkmk site's own
	# OMD wrapper scripts include a same-named `zypper` stub on non-SUSE images too
	# (`/omd/sites/<site>/bin/zypper`, only reachable when running under that site
	# user's own shell/PATH, e.g. `su - <site>`) that would otherwise be a false
	# positive -- found live while smoke-testing this plug-in itself against a
	# Checkmk site (see CONCEPT.md changelog v14). A real production deployment (the
	# Checkmk agent running as root with a plain system PATH) never hits this, but
	# the extra check costs nothing and is more correct regardless.
	zypper --version >/dev/null 2>&1 || return 0
	DETECTED_BACKENDS="${DETECTED_BACKENDS}zypper "

	cache_age="$(newest_mtime_age /var/cache/zypp/raw/*/repodata/repomd.xml "$STATE_DIR/system_updates_refreshed.zypper")"
	refreshed="false"
	no_refresh_flag="--no-refresh"
	if should_refresh "$cache_age" zypper; then
		run_refresh zypper zypper --non-interactive refresh
		refreshed="true"
		no_refresh_flag=""
		cache_age="$(newest_mtime_age /var/cache/zypp/raw/*/repodata/repomd.xml "$STATE_DIR/system_updates_refreshed.zypper")"
	fi

	# shellcheck disable=SC2086 (intentionally empty/one-word flag, not user input)
	# zypper puts several <update> elements on one line (verified live: "vim" and
	# "vim-data-common" shared a line, and the line-based sed below only ever
	# matched one of them) -- split them onto their own lines first.
	zypper --xmlout $no_refresh_flag list-updates --all 2>/dev/null |
		awk '{ gsub(/<update /, "\n<update ") } 1' |
		sed -n -E 's/.*<update kind="package" name="([^"]+)" edition="([^"]+)"[^>]*edition-old="([^"]+)".*/\1\t\3\t\2/p' \
			>"$WORKDIR/zypper_updates.tsv"

	# `zypper locks` table: "<num> | <name> | <type> | <repo> | <comment>" -- unlike
	# apt/dnf, a locked zypper package is NOT excluded from list-updates (verified
	# live: locking a package with a pending update still shows it there) -- so no
	# separate fallback lookup is needed here, just a name cross-reference.
	zypper --no-refresh locks 2>/dev/null | awk -F'|' 'NF>=2 && $1 ~ /^[0-9]+ *$/ {gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2}' >"$WORKDIR/zypper_held_names"

	# shellcheck disable=SC2086 (intentionally empty/one-word flag, not user input)
	zypper --non-interactive --xmlout $no_refresh_flag patch --dry-run --category security 2>/dev/null |
		sed -n 's/.*<solvable type="package" name="\([^"]*\)".*/\1/p' | sort -u >"$WORKDIR/zypper_security_names"
	# Publication date per pending security patch (<issue-date time_t=...> in
	# list-patches) and the packages each patch updates (its "Conflicts" list in
	# `zypper info -t patch`, "    name.arch < version") -- both verified live on Leap.
	# shellcheck disable=SC2086
	zypper --non-interactive --xmlout $no_refresh_flag list-patches --category security 2>/dev/null |
		awk '{ gsub(/<update /, "\n<update ") } 1' |
		awk 'match($0, /<update kind="patch" name="[^"]*"/) { p = substr($0, RSTART + 27, RLENGTH - 28) }
			match($0, /<issue-date time_t="[0-9]+"/) { if (p != "") print p "\t" substr($0, RSTART + 20, RLENGTH - 21); p = "" }' \
		>"$WORKDIR/zypper_patch_issued.tsv"
	: >"$WORKDIR/zypper_pkg_patch.tsv"
	if [ -s "$WORKDIR/zypper_patch_issued.tsv" ]; then
		# shellcheck disable=SC2046,SC2086
		zypper --non-interactive $no_refresh_flag info -t patch $(cut -f1 "$WORKDIR/zypper_patch_issued.tsv") 2>/dev/null |
			awk '/^Information for patch / { p = $4; sub(/:$/, "", p); c = 0; next }
				/^Conflicts/ { c = 1; next }
				c && /^    / { n = $1; if (n ~ /:/) next
					sub(/\.(noarch|x86_64|i586|i686|aarch64|ppc64le|s390x|armv7hl|src|nosrc)$/, "", n); print n "\t" p; next }
				{ c = 0 }' | sort -u >"$WORKDIR/zypper_pkg_patch.tsv"
	fi

	# shellcheck disable=SC2086
	zypper --non-interactive --xmlout $no_refresh_flag patch --dry-run 2>/dev/null |
		sed -n 's/.*<solvable type="package" name="\([^"]*\)".*/\1/p' | sort -u >"$WORKDIR/zypper_patch_names"

	while IFS="$(printf '\t')" read -r name old new; do
		[ -n "$name" ] || continue
		held="false"
		grep -qxF "$name" "$WORKDIR/zypper_held_names" 2>/dev/null && held="true"
		classification="unknown"
		if grep -qxF "$name" "$WORKDIR/zypper_security_names" 2>/dev/null; then
			classification="security"
		elif grep -qxF "$name" "$WORKDIR/zypper_patch_names" 2>/dev/null; then
			classification="normal"
		fi
		issued=""
		if [ "$classification" = "security" ]; then
			issued="$(_min_issued "$WORKDIR/zypper_patch_issued.tsv" \
				"$(awk -F'\t' -v n="$name" '$1==n {print $2}' "$WORKDIR/zypper_pkg_patch.tsv" | tr '\n' ',')")"
		fi
		printf '%s\t%s\t%s\t%s\tzypper\t\tstandard\t%s\tfalse\t%s\n' "$name" "$old" "$new" "$classification" "$held" "$issued" >>"$UPDATES_FILE"
	done <"$WORKDIR/zypper_updates.tsv"

	printf 'cache_age_seconds:zypper\t%s\n' "${cache_age:-}" >>"$INFO_FILE"
	printf 'refreshed_before_check:zypper\t%s\n' "$refreshed" >>"$INFO_FILE"
}

# --- apk backend (Alpine) -------------------------------------------------------------
#
# Adapted from our own checkmk_alpine_agent project (see CONCEPT.md section 2) --
# reusing its `apk list --upgradable` parsing approach, but not its code verbatim,
# and deliberately dropping its `apk update` self-refresh call to match this
# project's no-self-refresh-by-default policy (CONCEPT.md section 6). The line
# format itself was unverified there ("TODO(validate)" in that project's own
# script) -- now live-verified here against a real Alpine 3.20 container:
#   "musl-1.2.5-r3 x86_64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]"
# No native security/normal classification exists in apk (confirmed live: `apk
# audit` is filesystem-change auditing, not a CVE/security feature) -- always
# "unknown".

apk_split_name_version() {
	# "musl-1.2.5-r3" -> prints "musl<TAB>1.2.5-r3"; apk's own "-<version>-r<rel>"
	# suffix convention (same one used in checkmk_alpine_agent).
	printf '%s\n' "$1" | sed -E 's/^(.+)-([0-9][^-]*-r[0-9]+)$/\1\t\2/'
}

collect_apk() {
	command -v apk >/dev/null 2>&1 || return 0
	DETECTED_BACKENDS="${DETECTED_BACKENDS}apk "

	cache_age="$(newest_mtime_age /var/cache/apk/APKINDEX* "$STATE_DIR/system_updates_refreshed.apk")"
	refreshed="false"
	if should_refresh "$cache_age" apk; then
		run_refresh apk apk update
		refreshed="true"
		cache_age="$(newest_mtime_age /var/cache/apk/APKINDEX* "$STATE_DIR/system_updates_refreshed.apk")"
	fi

	apk list --upgradable 2>/dev/null |
		sed -n -E 's/^([^ ]+) [^ ]+ \{[^}]*\} \([^)]*\) \[upgradable from: *([^]]+)\].*/\1\t\2/p' \
			>"$WORKDIR/apk_updates.tsv"

	while IFS="$(printf '\t')" read -r new_full old_full; do
		[ -n "$new_full" ] || continue
		name_version="$(apk_split_name_version "$new_full")"
		name="$(printf '%s\n' "$name_version" | cut -f1)"
		new_version="$(printf '%s\n' "$name_version" | cut -f2)"
		[ -n "$name" ] || { name="$new_full"; new_version="unknown"; }
		old_version="$(apk_split_name_version "$old_full" | cut -f2)"
		[ -n "$old_version" ] || old_version="unknown"
		printf '%s\t%s\t%s\tunknown\tapk\t\tstandard\tfalse\tfalse\t\n' "$name" "$old_version" "$new_version" >>"$UPDATES_FILE"
	done <"$WORKDIR/apk_updates.tsv"

	printf 'cache_age_seconds:apk\t%s\n' "${cache_age:-}" >>"$INFO_FILE"
	printf 'refreshed_before_check:apk\t%s\n' "$refreshed" >>"$INFO_FILE"
}

# container_type -- prints the container technology (docker, podman, lxc, ...) or
# nothing on a real host/VM. A container runs on its host's kernel, so a
# reboot-required signal doesn't apply there. Signals, in order:
#   /.dockerenv (Docker), /run/.containerenv (Podman) -- verified live;
#   /run/systemd/container and PID 1's "container=" variable -- set by systemd and
#   LXC/LXD (e.g. "lxc"); needed for Proxmox LXC, which has neither file above
#   (reported from a real Debian 13 LXC: running the host's -pve kernel, no /boot,
#   so Reboot Required showed UNKNOWN);
#   `systemd-detect-virt --container` as a last resort.
container_type() {
	if [ -f /.dockerenv ]; then echo docker; return; fi
	if [ -f /run/.containerenv ]; then echo podman; return; fi
	if [ -s /run/systemd/container ]; then head -1 /run/systemd/container; return; fi
	ct="$(tr '\0' '\n' </proc/1/environ 2>/dev/null | sed -n 's/^container=//p' | head -1)"
	if [ -n "$ct" ]; then echo "$ct"; return; fi
	if command -v systemd-detect-virt >/dev/null 2>&1; then
		ct="$(systemd-detect-virt --container 2>/dev/null)"
		[ -n "$ct" ] && [ "$ct" != "none" ] && echo "$ct"
	fi
}

# --- main ----------------------------------------------------------------------------

collect_apt
collect_dnf
collect_zypper
collect_apk

echo "<<<${SECTION_NAME}:sep(9)>>>"
cat "$UPDATES_FILE"

echo "<<<${INFO_SECTION_NAME}:sep(9)>>>"
backends_csv="$(printf '%s\n' "$DETECTED_BACKENDS" | sed 's/ /,/g; s/,$//')"
[ -n "$backends_csv" ] && printf 'package_managers\t%s\n' "$backends_csv"
cat "$INFO_FILE"

# zypper_reboot_required -- `zypper needs-rebooting` (exit 102 = reboot suggested,
# 0 = not needed; it also honors /run/reboot-needed, which libzypp creates after
# installing packages flagged reboot-needed, e.g. kernel-default). Verified live on
# Leap 15.6, Tumbleweed and SLES 15 SP6. Older zypper without the command: fall
# back to the file alone.
zypper_reboot_required() {
	zypper needs-rebooting >/dev/null 2>&1
	rc=$?
	case "$rc" in
	102) echo "true"; echo "core libraries or services updated since boot (zypper)" ;;
	0) echo "false"; echo "" ;;
	*)
		if [ -e /run/reboot-needed ]; then
			echo "true"; echo "/run/reboot-needed present"
		else
			echo "unknown"; echo ""
		fi
		;;
	esac
}

# generic_reboot_signals -- distribution-independent signals, on top of each
# backend's own detection. Line 1: "yes" if the running kernel could be checked
# (so "no signal" really means "no reboot needed"), else "no". Further lines: one
# reason per signal that fired. All verified live in containers with the
# respective situation forced.
generic_reboot_signals() {
	running="$(uname -r)"
	det="no"
	reasons=""

	# 1. Running kernel no longer installed (package removed or replaced, e.g. every
	#    kernel upgrade on Alpine): its module directory is gone, new modules can't
	#    be loaded any more.
	for d in /lib/modules/*/ /usr/lib/modules/*/; do
		[ -d "$d" ] && det="yes"
	done
	if [ "$det" = "yes" ]; then
		if [ ! -d "/lib/modules/$running" ] && [ ! -d "/usr/lib/modules/$running" ]; then
			reasons="${reasons}running kernel $running no longer installed
"
		fi
	fi

	# 2. PID 1 (systemd/init) still maps libraries that were replaced on disk --
	#    typical after a glibc or systemd update; only a reboot (or re-exec of
	#    PID 1) picks up the new code.
	#    Only real shared objects ("libc.so.6", "ld-2.17.so", "libfoo.so"): e.g.
	#    /etc/ld.so.cache is rewritten by ldconfig on every package install and was
	#    a false positive (verified live on Tumbleweed).
	libs="$(awk '/ \(deleted\)$/ {n = split($(NF-1), p, "/"); if (p[n] ~ /\.so(\.[0-9]+)*$/) print p[n]}' /proc/1/maps 2>/dev/null | sort -u | tr '\n' ' ' | sed 's/ $//; s/ /, /g')"
	if [ -n "$libs" ]; then
		reasons="${reasons}PID 1 ($(cat /proc/1/comm 2>/dev/null)) uses replaced libraries: $libs
"
	fi

	# 3. Debian & co. without Ubuntu's notifier: core packages updated after the
	#    last boot (the same idea as RHEL's needs-restarting). dpkg keeps no install
	#    time, but rewrites each package's file list on every install/upgrade.
	if command -v dpkg >/dev/null 2>&1 && [ ! -x /usr/share/update-notifier/notify-reboot-required ]; then
		btime="$(awk '/^btime/ {print $2}' /proc/stat 2>/dev/null)"
		if [ -n "$btime" ]; then
			for pkg in libc6 systemd dbus dbus-broker intel-microcode amd64-microcode linux-firmware firmware-linux-free firmware-linux-nonfree; do
				for f in /var/lib/dpkg/info/"$pkg".list /var/lib/dpkg/info/"$pkg":*.list; do
					[ -e "$f" ] || continue
					[ "$(stat -c %Y "$f" 2>/dev/null || echo 0)" -gt "$btime" ] &&
						reasons="${reasons}$pkg updated after boot
"
					break
				done
			done
		fi
	fi

	# 4. SELinux switched from/to "disabled" in its config -- only takes effect at
	#    the next boot (enforcing <-> permissive doesn't need one).
	if [ -r /etc/selinux/config ] && command -v getenforce >/dev/null 2>&1; then
		cfg="$(sed -n 's/^SELINUX=//p' /etc/selinux/config | tr 'A-Z' 'a-z' | head -1)"
		cur="$(getenforce 2>/dev/null | tr 'A-Z' 'a-z')"
		if [ -n "$cfg" ] && [ -n "$cur" ]; then
			if { [ "$cfg" = "disabled" ] && [ "$cur" != "disabled" ]; } || { [ "$cfg" != "disabled" ] && [ "$cur" = "disabled" ]; }; then
				reasons="${reasons}SELinux mode change pending (configured: $cfg, running: $cur)
"
			fi
		fi
	fi

	echo "$det"
	printf '%s' "$reasons"
}

# collect_service_restarts -- section system_updates_services: system services
# (and, without systemd, processes) that still run code from shared libraries
# replaced on disk by an update, so the update doesn't take effect for them until
# they are restarted -- e.g. nginx still using the old libssl after an openssl
# update. Distribution-independent: one pass over /proc/*/maps for "(deleted)"
# shared objects, each process mapped to its systemd unit via /proc/<pid>/cgroup.
# PID 1 is left out (that's Reboot Required's job), as are user sessions
# (user.slice) and other scopes. Rows: name, comma-separated libraries, kind
# ("service" or "process"); a leading "__scan__" row marks a completed scan so
# the service is also discovered when nothing needs a restart.
collect_service_restarts() {
	echo "<<<system_updates_services:sep(9)>>>"
	printf '__scan__\tok\n'
	awk '/ \(deleted\)$/ {
		n = split($(NF-1), p, "/"); lib = p[n]
		if (lib !~ /\.so(\.[0-9]+)*$/) next
		split(FILENAME, f, "/"); if (f[3] != 1) print f[3] "\t" lib
	}' /proc/[0-9]*/maps 2>/dev/null | sort -u >"$WORKDIR/svc_pidlibs"
	[ -s "$WORKDIR/svc_pidlibs" ] || return 0
	cut -f1 "$WORKDIR/svc_pidlibs" | sort -u | while IFS= read -r pid; do
		cg="$(awk -F: '$1 == "0" || $2 == "name=systemd" {print $3; exit}' "/proc/$pid/cgroup" 2>/dev/null)"
		unit="${cg##*/}"
		case "$cg" in
		*/user.slice/* | user.slice/*) continue ;;
		esac
		case "$unit" in
		*.service) name="$unit"; kind="service" ;;
		*.scope) continue ;;
		*) name="$(cat "/proc/$pid/comm" 2>/dev/null)"; kind="process"; [ -n "$name" ] || continue ;;
		esac
		awk -F'\t' -v p="$pid" -v n="$name" -v k="$kind" '$1 == p {print n "\t" $2 "\t" k}' "$WORKDIR/svc_pidlibs"
	done | sort -u | awk -F'\t' '
		{ key = $1 "\t" $3; if (!(key in libs)) { order[++cnt] = key; libs[key] = $2 } else libs[key] = libs[key] "," $2 }
		END { for (i = 1; i <= cnt; i++) { split(order[i], k, "\t"); print k[1] "\t" libs[order[i]] "\t" k[2] } }'
}

ctype="$(container_type)"
if [ -n "$ctype" ]; then
	# A container shares the host's kernel -- a reboot-required signal about
	# kernel/core-library updates inside it doesn't correspond to anything the
	# container itself could act on (CONCEPT.md section 13b/offener Punkt 13).
	reboot_required="not_applicable"
	reboot_reason="running in a container ($ctype), the host's kernel applies"
else
	reboot_required="unknown"
	reboot_reason=""
	case " $DETECTED_BACKENDS " in
	*" apt "*)
		reboot_out="$(apt_reboot_required)"
		reboot_required="$(printf '%s\n' "$reboot_out" | sed -n 1p)"
		reboot_reason="$(printf '%s\n' "$reboot_out" | sed -n 2p)"
		;;
	*" dnf "*)
		reboot_out="$(dnf_reboot_required)"
		reboot_required="$(printf '%s\n' "$reboot_out" | sed -n 1p)"
		reboot_reason="$(printf '%s\n' "$reboot_out" | sed -n 2p)"
		;;
	*" zypper "*)
		reboot_out="$(zypper_reboot_required)"
		reboot_required="$(printf '%s\n' "$reboot_out" | sed -n 1p)"
		reboot_reason="$(printf '%s\n' "$reboot_out" | sed -n 2p)"
		;;
	esac

	# Combine with the distribution-independent signals: any "true" wins, all
	# reasons are listed; "no signal" counts as "false" once the running kernel
	# could be checked (e.g. Alpine, which has no backend-specific signal).
	generic="$(generic_reboot_signals)"
	g_det="$(printf '%s\n' "$generic" | sed -n 1p)"
	g_reasons="$(printf '%s\n' "$generic" | sed -n '2,$p' | grep -v '^$')"
	if [ -n "$g_reasons" ]; then
		reboot_reason="$(printf '%s\n%s\n' "$reboot_reason" "$g_reasons" | grep -v '^$' | awk 'NR > 1 {printf "; "} {printf "%s", $0}')"
		reboot_required="true"
	elif [ "$reboot_required" = "unknown" ] && [ "$g_det" = "yes" ]; then
		reboot_required="false"
	fi
fi
printf 'reboot_required\t%s\n' "$reboot_required"
printf 'reboot_required_reason\t%s\n' "$reboot_reason"

collect_service_restarts
