#!/bin/sh
# iron-plugin — install and manage root filesystem plugins. Changes take effect
# when the initramfs assembles the root at the next boot.
# See docs/architecture.md "Plugins".
#
#   IRON_GETENT   the resolver `fetch` checks a host name with (default getent)
set -u

IRON_LIB=${IRON_LIB:-/usr/lib/iron/lib.sh}
# shellcheck source=tools/lib.sh
. "$IRON_LIB"

IRON_PROG=iron-plugin
IRON_LOG_FILE="$IRON_VAR_DIR/log/iron/plugin.log"
# vendor.cert and vendor.cert.sig are a third-party plugin's (docs/plugins.md
# "Third-party plugins").
MEMBERS="plugin.manifest plugin.manifest.sig vendor.cert vendor.cert.sig plugin.erofs plugin.verity"
STAGE=
FETCH_TMP=

usage() {
	cat >&2 <<'EOT'
usage: iron-plugin list [--json]
       iron-plugin inspect FILE [--json]
       iron-plugin fetch URL
       iron-plugin install FILE [--roothash HEX] [--allow-unsigned] [--enable]
       iron-plugin enable ID | disable ID | remove ID
       iron-plugin dev-layer status [--json] | reset
FILE is a .ironplug plugin file or a directory holding its members.
--roothash refuses the install unless FILE is the plugin whose root hash was
inspected (the API passes it, so an approval is for that plugin, not for
whatever is at FILE by the time the install runs).
Changes take effect at the next boot.
EOT
	exit 2
}

cleanup() {
	[ -z "$STAGE" ] || rm -rf "$STAGE"
	[ -z "$FETCH_TMP" ] || rm -f "$FETCH_TMP"
}
trap cleanup EXIT
trap 'exit 130' INT TERM

# stage_plugin SOURCE: unpack a plugin file (or copy a directory) into $STAGE.
stage_plugin() {
	_src=$1
	_sd="$IRON_VAR_DIR/lib/iron/staging"
	mkdir -p "$_sd" || iron_die 1 "cannot create $_sd"
	STAGE=$(mktemp -d "$_sd/plugin.XXXXXX") || iron_die 1 "cannot create a staging directory"
	if [ -d "$_src" ]; then
		for _m in $MEMBERS; do
			[ ! -f "$_src/$_m" ] || cp "$_src/$_m" "$STAGE/$_m" || iron_die 1 "cannot copy $_m"
		done
	elif [ -f "$_src" ]; then
		_list=$(tar -tf "$_src" 2>/dev/null) || iron_die 1 "$_src is not a plugin file"
		for _m in $_list; do
			case " $MEMBERS " in
			*" ${_m#./} "*) ;;
			*) iron_die 1 "unexpected member ${_m} in $_src" ;;
			esac
		done
		# A symlink, hardlink, device or FIFO is refused before anything is
		# unpacked, not discovered afterwards (security.md S5).
		iron_tar_regular_only "$_src" || iron_die 2 "$_src has a member that is not a regular file"
		tar -xf "$_src" -C "$STAGE" 2>/dev/null || iron_die 1 "cannot unpack $_src"
	else
		iron_die 1 "$_src not found"
	fi
	for _m in plugin.manifest plugin.erofs plugin.verity; do
		[ -e "$STAGE/$_m" ] || [ -L "$STAGE/$_m" ] || iron_die 1 "plugin is missing $_m"
	done
	# And checked again once unpacked: [ -f ] alone follows a symlink.
	for _m in $MEMBERS; do
		if { [ -e "$STAGE/$_m" ] || [ -L "$STAGE/$_m" ]; } && ! iron_regular_file "$STAGE/$_m"; then
			iron_die 2 "plugin member $_m is not a regular file"
		fi
	done
}

# evaluate: check the staged plugin; sets E_*, P_* and, for a vendor plugin,
# V_*. Fails only when the manifest cannot be read.
#
# E_TRUST is what the plugin claims to be -- vendor, signed or unsigned
# (iron_plugin_trust_verify) -- and E_SIG_VALID whether the claim holds. A
# vendor plugin whose chain does not hold is E_VENDOR_REFUSED: it is not
# installed at all, not even as unsigned, so that a revoked or expired vendor
# is not one checkbox away from installing anyway.
evaluate() {
	E_MANIFEST_OK=0
	E_CHECKSUMS_OK=0
	E_SIGNED=0
	E_SIG_VALID=0
	E_COMPATIBLE=0
	E_TRUST=unsigned
	E_VENDOR_REFUSED=0
	E_REASON=
	E_COMPAT_REASON=
	E_INSTALLED_VERSION=
	V_CERT_OK=0
	V_EXPIRED=0
	if [ -f "$STAGE/plugin.manifest.sig" ] || [ -f "$STAGE/vendor.cert" ]; then E_SIGNED=1; fi
	if ! iron_plugin_manifest_load "$STAGE"; then
		E_REASON="the plugin manifest is invalid"
		return 1
	fi
	E_MANIFEST_OK=1
	if iron_plugin_trust_verify "$STAGE"; then
		E_SIG_VALID=1
	elif [ "$PV_TRUST" = vendor ]; then
		E_VENDOR_REFUSED=1
		E_REASON=$V_REASON
	fi
	E_TRUST=$PV_TRUST
	if iron_plugin_check_files "$STAGE"; then
		E_CHECKSUMS_OK=1
	else
		E_REASON="the plugin files do not match its manifest"
	fi
	if E_COMPAT_REASON=$(iron_plugin_compat "$P_REQUIRES_ALPINE" "$P_REQUIRES_KERNEL"); then
		E_COMPATIBLE=1
	else
		[ -n "$E_REASON" ] || E_REASON=$E_COMPAT_REASON
	fi
	if [ -f "$IRON_PLUGINS_DIR/$P_PLUGIN_ID/plugin.manifest" ]; then
		E_INSTALLED_VERSION=$(iron_kv_get_default "$IRON_PLUGINS_DIR/$P_PLUGIN_ID/plugin.manifest" VERSION "")
	fi
	if [ "$E_SIGNED" = 1 ] && [ "$E_SIG_VALID" = 0 ] && [ -z "$E_REASON" ]; then
		E_REASON="the plugin signature is not valid"
	fi
	return 0
}

# shellcheck disable=SC2153 # P_* are set by iron_plugin_manifest_load, V_* by iron_plugin_trust_verify
print_inspect_json() {
	if [ "$E_MANIFEST_OK" = 1 ]; then
		printf '{"id":%s,"name":%s,"version":%s,"build_id":%s,"vendor":%s,"description":%s,"writable_root":%s,"requires_alpine":%s,"requires_kernel":%s,"features":%s,"services":%s,"kernel_args_required":%s,' \
			"$(iron_json_str "$P_PLUGIN_ID")" "$(iron_json_str "$P_NAME")" "$(iron_json_str "$P_VERSION")" \
			"$(iron_json_str "$P_BUILD_ID")" "$(iron_json_str_or_null "$P_VENDOR")" \
			"$(iron_json_str_or_null "$P_DESCRIPTION")" "$(iron_json_bool "$P_WRITABLE_ROOT")" \
			"$(iron_json_str "$P_REQUIRES_ALPINE")" "$(iron_json_str_or_null "$P_REQUIRES_KERNEL")" \
			"$(iron_json_str_array "$P_FEATURES")" "$(iron_json_str_array "$P_SERVICES")" \
			"$(iron_json_str_array "$P_KERNEL_ARGS_REQUIRED")"
	else
		printf '{"id":null,"name":null,"version":null,"build_id":null,"vendor":null,"description":null,"writable_root":false,"requires_alpine":null,"requires_kernel":null,"features":[],"services":[],"kernel_args_required":[],'
	fi
	_rh=
	# shellcheck disable=SC2153 # P_* are set by iron_plugin_manifest_load
	[ "$E_MANIFEST_OK" = 0 ] || _rh=$P_ROOTHASH
	printf '"signed":%s,"signature_valid":%s,"trust":%s,%s,"checksums_ok":%s,"compatible":%s,"reason":%s,"installed_version":%s,"roothash":%s}\n' \
		"$(iron_json_bool "$E_SIGNED")" "$(iron_json_bool "$E_SIG_VALID")" "$(iron_json_str "$E_TRUST")" \
		"$(vendor_json)" "$(iron_json_bool "$E_CHECKSUMS_OK")" \
		"$(iron_json_bool "$E_COMPATIBLE")" "$(iron_json_str_or_null "$E_REASON")" \
		"$(iron_json_str_or_null "$E_INSTALLED_VERSION")" "$(iron_json_str_or_null "$_rh")"
}

# vendor_json: the "vendor_id", "vendor_name" and "vendor_cert" members for the
# certificate V_* holds -- nulls unless it is certified by this host's update
# key and well formed (V_CERT_OK); a certificate that is not is nobody's word.
vendor_json() {
	if [ "$V_CERT_OK" != 1 ]; then
		printf '"vendor_id":null,"vendor_name":null,"vendor_cert":null'
		return 0
	fi
	set -f
	_vj=$(iron_json_str_array "$V_PLUGIN_IDS")
	set +f
	printf '"vendor_id":%s,"vendor_name":%s,"vendor_cert":{"plugin_ids":%s,"not_before":%s,"not_after":%s,"expired":%s}' \
		"$(iron_json_str "$V_ID")" "$(iron_json_str "$V_NAME")" "$_vj" \
		"$(iron_json_str "$V_NOT_BEFORE")" "$(iron_json_str "$V_NOT_AFTER")" "$(iron_json_bool "$V_EXPIRED")"
}

cmd_inspect() {
	src=
	json=0
	while [ $# -gt 0 ]; do
		case $1 in
		--json) json=1 ;;
		-*) usage ;;
		*) [ -z "$src" ] || usage; src=$1 ;;
		esac
		shift
	done
	[ -n "$src" ] || usage
	stage_plugin "$src"
	evaluate
	if [ "$json" = 1 ]; then
		print_inspect_json
	elif [ "$E_MANIFEST_OK" = 1 ]; then
		printf 'id: %s\nname: %s\nversion: %s (build %s)\nvendor: %s\nroot hash: %s\n' \
			"$P_PLUGIN_ID" "$P_NAME" "$P_VERSION" "$P_BUILD_ID" "${P_VENDOR:--}" "$P_ROOTHASH"
		printf 'requires: Alpine %s%s\nwritable root: %s\n' \
			"$P_REQUIRES_ALPINE" "${P_REQUIRES_KERNEL:+, kernel $P_REQUIRES_KERNEL}" "$P_WRITABLE_ROOT"
		[ -z "$P_FEATURES" ] || printf 'features: %s\n' "$P_FEATURES"
		[ -z "$P_SERVICES" ] || printf 'services: %s\n' "$P_SERVICES"
		[ -z "$P_KERNEL_ARGS_REQUIRED" ] || printf 'kernel arguments: %s\n' "$P_KERNEL_ARGS_REQUIRED"
		if [ "$E_SIG_VALID" = 1 ] && [ "$E_TRUST" = vendor ]; then
			sig="valid (vendor $V_NAME, $V_ID; certified for $V_PLUGIN_IDS until $V_NOT_AFTER)"
		elif [ "$E_SIG_VALID" = 1 ]; then
			sig=valid
		elif [ "$E_VENDOR_REFUSED" = 1 ]; then
			sig="refused ($E_REASON)"
		elif [ "$E_SIGNED" = 1 ]; then
			sig=invalid
		else
			sig=unsigned
		fi
		printf 'signature: %s\nchecksums: %s\ncompatible: %s\n' \
			"$sig" \
			"$([ "$E_CHECKSUMS_OK" = 1 ] && echo ok || echo mismatch)" \
			"$([ "$E_COMPATIBLE" = 1 ] && echo yes || echo no)"
		[ -z "$E_INSTALLED_VERSION" ] || printf 'installed version: %s\n' "$E_INSTALLED_VERSION"
		[ -z "$E_REASON" ] || printf 'note: %s\n' "$E_REASON"
	else
		printf 'invalid plugin: %s\n' "$E_REASON"
	fi
	[ "$E_MANIFEST_OK" = 1 ] && [ "$E_CHECKSUMS_OK" = 1 ] || return 1
	[ "$E_COMPATIBLE" = 1 ] || return 3
	return 0
}

# fetch_v4_ok ADDR: whether an IPv4 address may be fetched from.
fetch_v4_ok() {
	case $1 in *[!0-9.]* | *..* | .* | *.) return 1 ;; esac
	_fifs=$IFS
	IFS=.
	# shellcheck disable=SC2086 # splitting on the dots is the point
	set -- $1
	IFS=$_fifs
	[ $# = 4 ] || return 1
	for _fo; do
		# No leading zero: some parsers read 010 as octal.
		case $_fo in 0?*) return 1 ;; esac
		[ "${#_fo}" -le 3 ] && [ "$_fo" -le 255 ] || return 1
	done
	# 0/8 unspecified, 127/8 loopback, 169.254/16 link-local (and the cloud
	# metadata service at 169.254.169.254), 224/4 multicast, 240/4 reserved
	# and the broadcast address.
	[ "$1" -ne 0 ] && [ "$1" -ne 127 ] && [ "$1" -lt 224 ] || return 1
	[ "$1" -ne 169 ] || [ "$2" -ne 254 ]
}

# fetch_addr_ok ADDR: whether a resolved address may be fetched from.
#
# Refused: loopback, link-local, unspecified and multicast, v4 and v6 -- what
# a download running as root on this host has no business reaching, and what
# answers only on the host or its own link (the agent's own ports, a cloud's
# metadata service). Private ranges are allowed, RFC 1918 and fc00::/7 alike:
# a depot on the LAN is the ordinary case (alloy/docs/depots.md).
fetch_addr_ok() {
	_fa=$(printf '%s' "$1" | tr 'A-F' 'a-f')
	case $_fa in
	*:*) ;;
	*) fetch_v4_ok "$_fa"; return ;;
	esac
	_fa=${_fa%%%*}
	_fh=${_fa%%:*}
	case $_fh in
	'' | 0 | 00 | 000 | 0000)
		# ::/16 is all special: ::, ::1, the deprecated v4-compatible
		# forms. Only a v4-mapped address stands for something reachable,
		# and it is judged as the v4 address it is.
		case $_fa in
		::ffff:*.*.*.*) fetch_v4_ok "${_fa#::ffff:}"; return ;;
		esac
		return 1
		;;
	fe8? | fe9? | fea? | feb? | ff??) return 1 ;;
	esac
	case $_fh in *[!0-9a-f]*) return 1 ;; esac
	return 0
}

# fetch_target URL: set F_HOST and F_PORT from an http(s) URL.
fetch_target() {
	case $1 in
	http://*) F_PORT=80 _fr=${1#http://} ;;
	https://*) F_PORT=443 _fr=${1#https://} ;;
	*) return 1 ;;
	esac
	_fauth=${_fr%%[/?#]*}
	_fauth=${_fauth##*@}
	case $_fauth in
	\[*\]*)
		F_HOST=${_fauth#\[}
		F_HOST=${F_HOST%%\]*}
		_fp=${_fauth#*\]}
		case $_fp in '') ;; :*) F_PORT=${_fp#:} ;; *) return 1 ;; esac
		;;
	*:*) F_HOST=${_fauth%%:*} F_PORT=${_fauth#*:} ;;
	*) F_HOST=$_fauth ;;
	esac
	[ -n "$F_HOST" ] && iron_is_uint "$F_PORT" && [ "$F_PORT" -ge 1 ] && [ "$F_PORT" -le 65535 ]
}

# fetch_resolve HOST: every address HOST stands for, one per line.
fetch_resolve() {
	case $1 in
	*:*) printf '%s\n' "$1"; return 0 ;;
	*[!0-9.]*) ;;
	*) printf '%s\n' "$1"; return 0 ;;
	esac
	{
		"${IRON_GETENT:-getent}" ahosts "$1" 2>/dev/null
		"${IRON_GETENT:-getent}" hosts "$1" 2>/dev/null
	} | awk 'NF { print $1 }' | sort -u
}

# fetch_check URL: refuse a URL whose host is, or resolves to, an address
# fetch_addr_ok refuses -- every address, not the first, so a name that
# answers with one of each is refused too. Sets F_PIN to "HOST:PORT:ADDR" for
# curl --resolve, so the download goes to the address that was checked rather
# than to whatever the name resolves to a moment later.
fetch_check() {
	fetch_target "$1" || iron_die 2 "fetch needs an http:// or https:// URL with a host"
	_faddrs=$(fetch_resolve "$F_HOST")
	[ -n "$_faddrs" ] || iron_die 1 "cannot resolve $F_HOST"
	F_PIN=
	for _faddr in $_faddrs; do
		fetch_addr_ok "$_faddr" ||
			iron_die 2 "fetch refuses $F_HOST: $_faddr is a loopback, link-local, unspecified or multicast address"
		if [ -z "$F_PIN" ]; then
			case $_faddr in
			*:*) F_PIN="$F_HOST:$F_PORT:[$_faddr]" ;;
			*) F_PIN="$F_HOST:$F_PORT:$_faddr" ;;
			esac
		fi
	done
	# A literal address needs no pinning.
	case $F_HOST in
	*:*) F_PIN= ;;
	*[!0-9.]*) ;;
	*) F_PIN= ;;
	esac
}

# fetch_curl ARGS...: curl to the address fetch_check pinned, http(s) only,
# following no redirect by itself.
fetch_curl() {
	if [ -n "$F_PIN" ]; then
		curl --proto =http,https --max-redirs 0 --resolve "$F_PIN" "$@"
	else
		curl --proto =http,https --max-redirs 0 "$@"
	fi
}

# fetch_redirects: at most this many redirects are followed, each checked.
fetch_redirects=5

# fetch_download URL DEST: download URL into DEST, reporting progress 0-90.
#
# Not iron_download: that lets curl follow redirects by itself, and a redirect
# is a second request to wherever the server says. Here every hop is checked
# with fetch_check before anything is sent to it.
fetch_download() {
	_fu=$1
	_fhops=0
	_fhdr=$2.headers
	FETCH_TMP=$_fhdr
	while :; do
		fetch_check "$_fu"
		_fres=$(fetch_curl -sI --max-time 30 -D "$_fhdr" -o /dev/null -w '%{http_code} %{redirect_url}' "$_fu" 2>/dev/null) || _fres=
		case ${_fres%% *} in
		301 | 302 | 303 | 307 | 308)
			_fnext=${_fres#* }
			[ -n "$_fnext" ] && [ "$_fnext" != "$_fres" ] || break
			_fhops=$((_fhops + 1))
			[ "$_fhops" -le "$fetch_redirects" ] || iron_die 1 "too many redirects from $1"
			iron_log "$_fu redirects to $_fnext"
			_fu=$_fnext
			;;
		*) break ;;
		esac
	done
	_flen=$(tr -d '\r' <"$_fhdr" | awk 'tolower($1) == "content-length:" { n = $2 } END { print n }')
	rm -f "$_fhdr"
	case $_fu in http://*) iron_log "warning: downloading over plain http; the plugin signature still protects integrity" ;; esac
	_fname=${_fu##*/}
	rm -f "$2"
	iron_log "downloading $_fu"
	iron_progress 0 "downloading $_fname"
	fetch_curl -sf --retry 2 -o "$2" "$_fu" &
	_fpid=$!
	while kill -0 "$_fpid" 2>/dev/null; do
		sleep 1
		if iron_is_uint "${_flen:-}" && [ "$_flen" -gt 0 ] && [ -f "$2" ]; then
			_fgot=$(stat -c %s "$2" 2>/dev/null) || _fgot=0
			[ "$_fgot" -gt "$_flen" ] && _fgot=$_flen
			iron_progress "$((90 * _fgot / _flen))" "downloading $_fname"
		fi
	done
	if ! wait "$_fpid"; then
		rm -f "$2"
		return 1
	fi
	iron_progress 90 "downloaded $_fname"
}

# fetch URL: download a plugin and print where it was staged.
#
# What Wise Foundry Alloy uses to push a plugin: a signed call carries at most
# 1 MiB and a plugin is not 1 MiB, so Alloy serves the file and tells the host
# where (docs/alloy.md, alloy/docs/depots.md). The signature is checked here,
# at once, so a download that is not signed with the Wise Global Solutions key
# is deleted rather than staged for an install to refuse later.
#
# The download runs as root, so where it may go is checked first
# (fetch_check): not this host, not its link, and not anywhere a redirect
# leads without the same check.
cmd_fetch() {
	[ $# = 1 ] || usage
	url=$1
	case $url in http://* | https://*) ;; *) iron_die 2 "fetch needs an http:// or https:// URL" ;; esac
	fetch_check "$url"
	base=${url%%\?*}
	base=${base##*/}
	case $base in '' | .* | *[!A-Za-z0-9._+-]*) base=plugin.ironplug ;; esac
	sdir="$IRON_VAR_DIR/lib/iron/staging"
	mkdir -p "$sdir" || iron_die 1 "cannot create $sdir"
	dest="$sdir/$base"
	part="$dest.part"
	fetch_download "$url" "$part" || { rm -f "$part"; iron_die 1 "download of $url failed"; }

	# Nothing in a download that is not a regular file is unpacked, or kept
	# for an install to unpack later (security.md S5).
	if ! iron_tar_regular_only "$part"; then
		rm -f "$part"
		iron_die 2 "downloaded file has a member that is not a regular file"
	fi
	check=$(mktemp -d "$sdir/fetch.XXXXXX") || { rm -f "$part"; iron_die 1 "cannot create temp directory"; }
	# The members that say who signed it: ours, or a vendor's with the
	# certificate our key signed (docs/plugins.md "Third-party plugins").
	_fm=$(tar -tf "$part" 2>/dev/null | grep -xE '(\./)?(plugin\.manifest(\.sig)?|vendor\.cert(\.sig)?)')
	# shellcheck disable=SC2086 # member names, one word each
	[ -z "$_fm" ] || tar -xf "$part" -C "$check" $_fm 2>/dev/null
	_fok=0
	if iron_regular_file "$check/plugin.manifest" && iron_regular_file "$check/plugin.manifest.sig"; then
		for _v in vendor.cert vendor.cert.sig; do
			if [ -e "$check/$_v" ] || [ -L "$check/$_v" ]; then iron_regular_file "$check/$_v" || _fok=2; fi
		done
		if [ "$_fok" = 0 ] && iron_plugin_manifest_load "$check" 2>/dev/null && iron_plugin_trust_verify "$check"; then
			_fok=1
		fi
	fi
	if [ "$_fok" != 1 ]; then
		rm -rf "$check"
		rm -f "$part"
		[ -z "${V_REASON:-}" ] || [ "${PV_TRUST:-}" != vendor ] || iron_die 1 "downloaded vendor plugin refused: $V_REASON"
		iron_die 1 "downloaded file is not a plugin signed with the Wise Global Solutions update key or by a vendor it certifies"
	fi
	id=$(iron_kv_get_default "$check/plugin.manifest" PLUGIN_ID unknown)
	version=$(iron_kv_get_default "$check/plugin.manifest" VERSION unknown)
	rm -rf "$check"
	mv -f "$part" "$dest" || { rm -f "$part"; iron_die 1 "cannot move download into place"; }
	iron_progress 100 "downloaded $id $version"
	printf '%s\n' "$dest"
}

# compat_warning ID REASON: say that a plugin does not match the running image
# and will be skipped at boot until the host runs one it matches.
compat_warning() {
	iron_log "warning: plugin $1 $2; it is installed but loads only when the host boots an image it matches"
	printf 'IRON-PLUGIN: warning id=%s %s; it loads only when the host boots an image it matches\n' "$1" "$2"
}

cmd_install() {
	src=
	allow=0
	enable=0
	want_hash=
	while [ $# -gt 0 ]; do
		case $1 in
		--allow-unsigned) allow=1 ;;
		--enable) enable=1 ;;
		--roothash)
			[ $# -ge 2 ] || usage
			iron_is_hex64 "$2" || iron_die 2 "--roothash needs a 64-digit lowercase hex root hash"
			want_hash=$2
			shift
			;;
		-*) usage ;;
		*) [ -z "$src" ] || usage; src=$1 ;;
		esac
		shift
	done
	[ -n "$src" ] || usage

	iron_progress 5 "Staging plugin"
	stage_plugin "$src"
	iron_progress 20 "Checking plugin"
	evaluate || iron_die 1 "$E_REASON"
	[ "$E_CHECKSUMS_OK" = 1 ] || iron_die 1 "$E_REASON"
	# An approval is for the plugin that was inspected, not for whatever is at
	# this path now: an unsigned plugin has nothing but its root hash to say
	# which one it is. The manifest's checksums tie the image and its hash
	# tree to that hash, and dm-verity checks it again below.
	if [ -n "$want_hash" ] && [ "$want_hash" != "$P_ROOTHASH" ]; then
		iron_die 2 "plugin $P_PLUGIN_ID is not the plugin that was inspected (root hash $P_ROOTHASH, expected $want_hash)"
	fi
	# A vendor plugin whose chain fails is refused whatever was approved
	# (evaluate): --allow-unsigned is for a plugin nobody vouches for, not
	# for one whose vendor is revoked or whose certificate has expired.
	[ "$E_VENDOR_REFUSED" = 0 ] || iron_die 3 "plugin $P_PLUGIN_ID refused: $E_REASON"
	trust=unsigned
	[ "$E_SIG_VALID" = 0 ] || trust=$E_TRUST
	if [ "$trust" = unsigned ] && [ "$allow" != 1 ]; then
		iron_die 3 "plugin $P_PLUGIN_ID is not signed with the Wise Global Solutions key; installing it requires --allow-unsigned"
	fi
	# A plugin built for another image installs all the same: the host may be
	# about to restart into that image (an update staged, not yet booted). What
	# is refused is loading it; the initramfs skips a plugin whose Alpine
	# release or kernel differs from the image it boots (docs/plugins.md
	# "Compatibility").
	[ "$E_COMPATIBLE" = 1 ] || compat_warning "$P_PLUGIN_ID" "$E_COMPAT_REASON"

	iron_progress 35 "Verifying the plugin image"
	# shellcheck disable=SC2153 # P_* are set by iron_plugin_manifest_load
	iron_verity_verify "$STAGE/plugin.erofs" "$STAGE/plugin.verity" "$P_ROOTHASH" ||
		iron_die 1 "the plugin image failed dm-verity verification"

	mkdir -p "$IRON_PLUGINS_DIR" || iron_die 1 "cannot create $IRON_PLUGINS_DIR"
	need=$((P_IMAGE_SIZE + P_VERITY_SIZE))
	free=$(iron_free_bytes "$IRON_PLUGINS_DIR")
	if iron_is_uint "$free" && [ "$free" -lt "$need" ]; then
		iron_die 1 "not enough space for plugin $P_PLUGIN_ID ($need bytes needed, $free free)"
	fi
	iron_plugins_lock || iron_die 1 "cannot lock the plugin configuration"

	iron_progress 60 "Copying $P_NAME $P_VERSION"
	dest="$IRON_PLUGINS_DIR/$P_PLUGIN_ID"
	new="$IRON_PLUGINS_DIR/.$P_PLUGIN_ID.new"
	old="$IRON_PLUGINS_DIR/.$P_PLUGIN_ID.old"
	rm -rf "$new" "$old"
	mkdir -p "$new" || iron_die 1 "cannot create $new"
	for m in plugin.manifest plugin.erofs plugin.verity; do
		cp "$STAGE/$m" "$new/$m" || { rm -rf "$new"; iron_die 1 "cannot copy $m"; }
	done
	# The initramfs checks the signature -- and a vendor plugin's whole
	# chain -- again at every boot, so what it checks goes with the plugin.
	sigs=
	case $trust in
	signed) sigs=plugin.manifest.sig ;;
	vendor) sigs="plugin.manifest.sig vendor.cert vendor.cert.sig" ;;
	esac
	for m in $sigs; do
		cp "$STAGE/$m" "$new/$m" || { rm -rf "$new"; iron_die 1 "cannot copy $m"; }
	done
	sync

	# Renaming keeps files of a plugin in use by the running boot alive (open
	# loop devices hold their inodes); the new version loads at the next boot.
	iron_progress 90 "Activating plugin files"
	if [ -e "$dest" ]; then
		mv "$dest" "$old" || { rm -rf "$new"; iron_die 1 "cannot replace $dest"; }
	fi
	if ! mv "$new" "$dest"; then
		[ ! -e "$old" ] || mv "$old" "$dest"
		iron_die 1 "cannot install plugin files into $dest"
	fi
	rm -rf "$old"

	iron_progress 95 "Recording plugin configuration"
	enabled=no
	if line=$(iron_plugins_line "$P_PLUGIN_ID"); then
		enabled=${line%% *}
	fi
	[ "$enable" = 0 ] || enabled=yes
	iron_plugins_set_line "$P_PLUGIN_ID" "$enabled" "$trust" "$P_ROOTHASH" ||
		iron_die 1 "cannot write $(iron_plugins_conf)"
	iron_log "installed plugin $P_PLUGIN_ID $P_VERSION (build $P_BUILD_ID, $trust, enabled=$enabled)"
	iron_progress 100 "Plugin $P_NAME $P_VERSION installed; restart to apply"
	printf 'IRON-PLUGIN: installed id=%s version=%s trust=%s enabled=%s\n' \
		"$P_PLUGIN_ID" "$P_VERSION" "$trust" "$enabled"
}

# load_line ID: set L_ENABLED L_TRUST L_ROOTHASH L_FLAG; fail when not listed.
load_line() {
	_l=$(iron_plugins_line "$1") || return 1
	read -r L_ENABLED L_TRUST L_ROOTHASH L_FLAG <<EOT
$_l
EOT
}

need_id() {
	[ $# = 1 ] || usage
	iron_plugin_id_valid "$1" || { iron_log "invalid plugin ID: $1"; exit 2; }
}

# need_installed_id ID: an id of the right shape, reserved or not, so a plugin
# installed before a name was reserved can still be disabled and removed.
need_installed_id() {
	[ $# = 1 ] || usage
	iron_plugin_id_shape "$1" || { iron_log "invalid plugin ID: $1"; exit 2; }
}

cmd_enable() {
	need_id "$@"
	id=$1
	iron_plugins_lock || iron_die 1 "cannot lock the plugin configuration"
	load_line "$id" || iron_die 1 "plugin $id is not installed"
	m="$IRON_PLUGINS_DIR/$id/plugin.manifest"
	[ -f "$m" ] || iron_die 1 "the files of plugin $id are missing"
	if ! why=$(iron_plugin_compat "$(iron_kv_get_default "$m" REQUIRES_ALPINE "")" "$(iron_kv_get_default "$m" REQUIRES_KERNEL "")"); then
		compat_warning "$id" "$why"
	fi
	iron_plugins_set_line "$id" yes "$L_TRUST" "$L_ROOTHASH" || iron_die 1 "cannot write $(iron_plugins_conf)"
	iron_log "enabled plugin $id"
	printf 'IRON-PLUGIN: enabled id=%s (restart required)\n' "$id"
}

cmd_disable() {
	need_installed_id "$@"
	id=$1
	iron_plugins_lock || iron_die 1 "cannot lock the plugin configuration"
	load_line "$id" || iron_die 1 "plugin $id is not installed"
	iron_plugins_set_line "$id" no "$L_TRUST" "$L_ROOTHASH" "$L_FLAG" || iron_die 1 "cannot write $(iron_plugins_conf)"
	iron_log "disabled plugin $id"
	printf 'IRON-PLUGIN: disabled id=%s (restart required)\n' "$id"
}

cmd_remove() {
	need_installed_id "$@"
	id=$1
	iron_plugins_lock || iron_die 1 "cannot lock the plugin configuration"
	if ! load_line "$id"; then
		[ -d "$IRON_PLUGINS_DIR/$id" ] || iron_die 1 "plugin $id is not installed"
		rm -rf "${IRON_PLUGINS_DIR:?}/$id" || iron_die 1 "cannot delete $IRON_PLUGINS_DIR/$id"
		printf 'IRON-PLUGIN: removed id=%s\n' "$id"
		return 0
	fi
	state=$(iron_plugin_boot_state "$id" | cut -f1)
	if [ "$state" = active ]; then
		iron_plugins_set_line "$id" no "$L_TRUST" "$L_ROOTHASH" remove || iron_die 1 "cannot write $(iron_plugins_conf)"
		iron_log "plugin $id will be removed at the next boot"
		printf 'IRON-PLUGIN: remove-pending id=%s (restart required)\n' "$id"
	else
		rm -rf "${IRON_PLUGINS_DIR:?}/$id" || iron_die 1 "cannot delete $IRON_PLUGINS_DIR/$id"
		iron_plugins_drop_line "$id" || iron_die 1 "cannot write $(iron_plugins_conf)"
		iron_log "removed plugin $id"
		printf 'IRON-PLUGIN: removed id=%s\n' "$id"
	fi
}

dev_layer_json() {
	_dl="$IRON_PLUGINS_DIR/.devroot"
	_exists=0
	_size=
	if [ -d "$_dl/upper" ]; then
		_exists=1
		_size=$(du -sk "$_dl/upper" 2>/dev/null | awk '{ printf "%.0f", $1 * 1024 }')
	fi
	_reset=0
	[ "$(iron_plugins_kv_get DEVROOT_RESET || :)" != yes ] || _reset=1
	printf '{"active":%s,"exists":%s,"size_bytes":%s,"reset_pending":%s}' \
		"$(iron_boot_json_bool dev_root)" "$(iron_json_bool "$_exists")" \
		"$(iron_json_num_or_null "$_size")" "$(iron_json_bool "$_reset")"
}

cmd_dev_layer() {
	sub=${1:-status}
	[ $# = 0 ] || shift
	case $sub in
	status)
		if [ "${1:-}" = --json ]; then
			dev_layer_json
			printf '\n'
		else
			j=$(dev_layer_json)
			printf 'development layer: %s\n' "$j"
		fi
		;;
	reset)
		[ $# = 0 ] || usage
		iron_plugins_lock || iron_die 1 "cannot lock the plugin configuration"
		iron_plugins_kv_set DEVROOT_RESET yes || iron_die 1 "cannot write $(iron_plugins_conf)"
		iron_log "development layer will be discarded at the next boot"
		printf 'IRON-PLUGIN: dev-layer reset pending (restart required)\n'
		;;
	*) usage ;;
	esac
}

cmd_list() {
	json=0
	case ${1:-} in
	--json) json=1 ;;
	'') ;;
	*) usage ;;
	esac
	noplugins=$(iron_boot_json_bool noplugins)
	pending=0
	[ "$(iron_plugins_kv_get DEVROOT_RESET || :)" != yes ] || pending=1
	items=
	text=
	for id in $(iron_plugins_ids); do
		load_line "$id" || continue
		m="$IRON_PLUGINS_DIR/$id/plugin.manifest"
		name=''
		version=''
		build=''
		vendor=''
		desc=''
		ra=''
		rk=''
		size=''
		wr=no
		feats=''
		svcs=''
		kargs=''
		vjson='"vendor_id":null,"vendor_name":null,"vendor_cert":null'
		vexpired=no
		# What an installed vendor plugin's certificate says, and whether it
		# has expired since. An expired certificate does not unload a plugin
		# that is installed -- the initramfs has no clock to judge it by --
		# it is reported, here and in the consoles (docs/plugins.md
		# "Third-party plugins").
		if [ "$L_TRUST" = vendor ] && iron_vendor_cert_load "$IRON_PLUGINS_DIR/$id/vendor.cert" 2>/dev/null; then
			iron_vendor_cert_time
			V_CERT_OK=1
			[ "$V_EXPIRED" = 0 ] || vexpired=yes
			vjson=$(vendor_json)
		fi
		if [ -f "$m" ]; then
			name=$(iron_kv_get_default "$m" NAME "")
			version=$(iron_kv_get_default "$m" VERSION "")
			build=$(iron_kv_get_default "$m" BUILD_ID "")
			vendor=$(iron_kv_get_default "$m" VENDOR "")
			desc=$(iron_kv_get_default "$m" DESCRIPTION "")
			ra=$(iron_kv_get_default "$m" REQUIRES_ALPINE "")
			rk=$(iron_kv_get_default "$m" REQUIRES_KERNEL "")
			wr=$(iron_kv_get_default "$m" WRITABLE_ROOT no)
			feats=$(iron_kv_get_default "$m" FEATURES "")
			svcs=$(iron_kv_get_default "$m" SERVICES "")
			kargs=$(iron_kv_get_default "$m" KERNEL_ARGS_REQUIRED "")
			is=$(iron_kv_get_default "$m" IMAGE_SIZE 0)
			vs=$(iron_kv_get_default "$m" VERITY_SIZE 0)
			if iron_is_uint "$is" && iron_is_uint "$vs"; then size=$((is + vs)); fi
		fi
		compatible=0
		creason=
		if [ ! -f "$m" ]; then
			creason="the plugin files are missing"
		elif creason=$(iron_plugin_compat "$ra" "$rk"); then
			compatible=1
		fi
		bs=$(iron_plugin_boot_state "$id")
		bstate=$(printf '%s' "$bs" | cut -f1)
		breason=$(printf '%s' "$bs" | cut -f2)
		bversion=$(printf '%s' "$bs" | cut -f3)
		reason=
		ipending=0
		if [ "$L_FLAG" = remove ]; then
			state="pending-remove"
		elif [ "$L_ENABLED" = yes ]; then
			if [ "$noplugins" = true ]; then
				state=skipped
				reason="plugins are not loaded in rescue mode"
			elif [ "$bstate" = active ]; then
				state=active
				# A replaced plugin keeps running its old version until restart.
				[ -z "$bversion" ] || [ "$bversion" = "$version" ] || ipending=1
			elif [ "$bstate" = skipped ]; then
				state=skipped
				reason=$breason
			else
				state="pending-enable"
			fi
		elif [ "$bstate" = active ]; then
			state="pending-disable"
		else
			state=disabled
		fi
		[ -n "$reason" ] || [ "$compatible" = 1 ] || reason=$creason
		case $state in pending-*) ipending=1 ;; esac
		[ "$ipending" = 0 ] || pending=1
		# running_version is what booted (docs/web-api.md "Plugins"): an
		# upgrade stays "active" on its old version until the restart, and
		# Alloy's baseline compliance must not read the new one as running.
		rversion=
		[ "$bstate" != active ] || rversion=${bversion:-$version}
		item=$(printf '{"id":%s,"name":%s,"version":%s,"build_id":%s,"vendor":%s,"description":%s,"trust":%s,%s,"vendor_cert_expired":%s,"enabled":%s,"state":%s,"reason":%s,"writable_root":%s,"requires_alpine":%s,"requires_kernel":%s,"compatible":%s,"size_bytes":%s,"features":%s,"services":%s,"kernel_args_required":%s,"running_version":%s,"pending":%s}' \
			"$(iron_json_str "$id")" "$(iron_json_str_or_null "$name")" "$(iron_json_str_or_null "$version")" \
			"$(iron_json_str_or_null "$build")" "$(iron_json_str_or_null "$vendor")" \
			"$(iron_json_str_or_null "$desc")" "$(iron_json_str "$L_TRUST")" "$vjson" "$(iron_json_bool "$vexpired")" \
			"$(iron_json_bool "$L_ENABLED")" \
			"$(iron_json_str "$state")" "$(iron_json_str_or_null "$reason")" "$(iron_json_bool "$wr")" \
			"$(iron_json_str_or_null "$ra")" "$(iron_json_str_or_null "$rk")" \
			"$(iron_json_bool "$compatible")" "$(iron_json_num_or_null "$size")" \
			"$(iron_json_str_array "$feats")" "$(iron_json_str_array "$svcs")" "$(iron_json_str_array "$kargs")" \
			"$(iron_json_str_or_null "$rversion")" "$(iron_json_bool "$([ "$ipending" = 1 ] && echo yes || echo no)")")
		items="${items:+$items,}$item"
		text="$text$(printf '%-16s %-10s %-9s %-4s %-16s %s' "$id" "${version:--}" "$L_TRUST" "$L_ENABLED" "$state" "$reason")
"
	done
	if [ "$json" = 1 ]; then
		printf '{"plugins":[%s],"pending_restart":%s,"dev_layer":%s,"reverted":%s,"noplugins":%s}\n' \
			"$items" "$(iron_json_bool "$pending")" "$(dev_layer_json)" \
			"$(iron_boot_json_bool reverted)" "$noplugins"
	else
		printf '%-16s %-10s %-9s %-4s %-16s %s\n' ID VERSION TRUST ON STATE REASON
		printf '%s' "$text"
		[ "$pending" = 0 ] || printf 'Restart the host to apply plugin changes.\n'
		[ "$(iron_boot_json_bool reverted)" != true ] ||
			printf 'Plugin changes were reverted because the host did not start successfully.\n'
	fi
}

[ $# -ge 1 ] || usage
cmd=$1
shift
case $cmd in
list) cmd_list "$@" ;;
inspect) cmd_inspect "$@" ;;
fetch) cmd_fetch "$@" ;;
install) cmd_install "$@" ;;
enable) cmd_enable "$@" ;;
disable) cmd_disable "$@" ;;
remove) cmd_remove "$@" ;;
dev-layer) cmd_dev_layer "$@" ;;
*) usage ;;
esac
