#!/bin/bash
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2023 SUSE LLC
set -e
shopt -s nullglob

export LC_ALL=C

# The five secrets that can arrive in the environment.  A prefix
# assignment scopes them to this process, but this script spawns
# systemd-cryptenroll, systemd-pcrlock, cryptsetup and dracut, and a
# child inherits the whole environment: every one of them would carry
# the secret in /proc/PID/environ, readable for as long as it lives, and
# dracut lives for minutes.  Copying the values into plain shell
# variables and unsetting the exported ones keeps them in this process,
# so the only thing a child sees is what is handed to it on purpose,
# the way `enroll_password` passes NEWPASSWORD
#
# "unset" is what removes them from the export list; assigning an empty
# value would still export the name
#
# This is here, and not with the other globals below, to be ahead of
# the first child (`uname` a few lines down) and ahead of the `set -x`
# that --start-trace-code turns on, which would otherwise write these
# assignments to the log
env_current_pw="$CURRENT_PW"
env_key="$KEY"
env_pw="$PW"
env_pin="$PIN"
env_recovery_pin="$RECOVERY_PIN"
unset CURRENT_PW KEY PW PIN RECOVERY_PIN

DEBUG_LOG="/var/log/sdbootutil.log"
verbose=

if [[ "$*" =~ "--start-trace-code" ]] && ! touch "$DEBUG_LOG" 2>/dev/null; then
	echo "Cannot enable the code trace, $DEBUG_LOG is not writable" >&2
	exit 1
fi
if [[ "$*" =~ "--stop-trace-code" ]] && ! rm -f "$DEBUG_LOG" 2>/dev/null; then
	echo "Cannot disable the code trace, $DEBUG_LOG cannot be removed" >&2
	exit 1
fi
# The trace is only enabled if the log can be opened for writing, so a
# trace left behind by root does not break the tool for the other users.
# The completion data is also excluded, as the messages and the trace
# itself interfere with the shell completion
if [ "$1" != "_print_bash_completion_data" ] && [ -f "$DEBUG_LOG" ] && { exec 3>>"$DEBUG_LOG"; } 2>/dev/null; then
	verbose=3
	echo "The trace of the code is being stored in $DEBUG_LOG" >&2
	echo "Remove the file or use --stop-trace-code to stop tracing the code" >&2
	echo "Audit the file before sharing, as secrets can be leaked" >&2
	export BASH_XTRACEFD=3
	export PS4='+ \D{%F %T} ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
	set -x
fi

nl=$'\n'
shimdir="/usr/share/efi/$(uname -m)"
arg_esp_path="$SYSTEMD_ESP_PATH"
arg_entry_token=
arg_arch=
arg_all_entries=
arg_entry_keys=()
arg_no_variables=
arg_no_reuse_initrd=
arg_no_random_seed=
arg_portable=
arg_secure_boot=
arg_sync=
arg_only_default=
arg_default_snapshot=
arg_ask_key=
arg_ask_pin=
arg_ask_recovery_pin=
arg_ask_pw=
arg_ask_current_pw=
arg_method=
arg_no_measure_pcr=
arg_measure_pcr=
arg_pcr=
arg_rootfs=
arg_rootfs_data=
arg_esp_free_space=
arg_devicetree_source=
arg_force=
arg_strict=
arg_disable_predictions=
arg_full=
have_snapshots=
in_buildroot=
# Snapshot that owns the "/" subvolume, that is not always the one
# that provides /usr (see `detect_applied_snapshot`)
running_snapshot=
# Snapshot mapped over the running system by `transactional-update
# apply` (see `detect_applied_snapshot`)
applied_snapshot=
# Possible values: vmlinuz image vmlinux linux bzImage uImage Image zImage
image=
unlock_method=

chroot_dir=

# The output goes to a terminal, so it is read by a person and can
# carry colors and extra decorations.  When it is captured by another
# program only the plain data is printed: `transactional-update` reads
# `list-entries` with `popen` and opens the result as a file name, and
# `weak-modules2` parses `show-entry` with a command substitution
interactive=
[ ! -t 1 ] || interactive=1

color_red=
color_end=
# SYSTEMD_COLORS can only disable the colors, as documented in the help
# message.  Honouring a forced "true" would leak escape sequences into
# the programs above, that inherit the variable from the environment of
# the caller
if [ -n "$interactive" ] \
    && [ "$SYSTEMD_COLORS" != "false" ] && [ "$SYSTEMD_COLORS" != "0" ]; then
	color_red="\e[31m"
	color_green="\e[32m"
	color_yellow="\e[33m"
	color_bu="\e[1;4m" # bold underscore
	color_end="\e[m"
fi

# State file for transactional systems
state_file="/var/lib/misc/transactional-update.state"

update_predictions=
# Set only when "update-predictions" is the command, to tell it apart
# from the predictions that are a side effect of another command
explicit_predictions=

tracked_devices=()

rollback=()

# Set by `enroll` while /etc/crypttab already asks for an unlock method
# that no keyslot backs yet.  Cleared once the enrollment succeeded
restore_crypttab=

# Lifetime of the keys that sdbootutil adds to the kernel keyring.  It
# is re-armed when the script exits, so it measures how long a secret
# outlives the run that published it, and never expires in the middle
# of one
keyctl_timeout=600
created_keys=()

# Purposes already reported by deprecated_keyring_secret, so a system
# with several devices does not repeat the same warning per device
warned_keyring_secret=

declare -A eventlog

tmpdir=$(mktemp -d -t sdbootutil.XXXXXX)
cleanup()
{
	local i

	# Restart the countdown of the keys that this run created, so
	# that it is not spent by the run itself.  Only the keys that we
	# created are touched: shortening the lifetime of an entry that
	# an installer left behind can make it disappear under the
	# installer
	for i in "${created_keys[@]}"; do
		keyctl timeout "$i" "$keyctl_timeout" 2> /dev/null || \
			warn "Failed to set the timeout of the key $i"
	done

	for i in "${rollback[@]}"; do
		if [ -e "$i.bak" ]; then
			info "Restoring $i"
			mv "$i.bak" "$i"
		else
			info "Removing $i"
			rm -f "$i"
		fi
	done
	# A device that keeps the option in /etc/crypttab but has no slot
	# for it falls back to the password prompt during the boot,
	# hiding the failed enrollment.  The initrd embeds /etc/crypttab,
	# so it has to be generated again.
	#
	# This is here and not in `enroll` because every fatal path after
	# the options are added has to undo them, and there are several:
	# `install_all_kernels`, `generate_tpm2_predictions` (the
	# prediction that cannot be signed), and a PIN that cannot be
	# asked because there is no terminal.  After the rollback above,
	# so that the initrds are rebuilt from the restored file instead
	# of being undone by it
	if [ -n "$restore_crypttab" ] && ! cmp -s "$tmpdir/crypttab" /etc/crypttab; then
		info "Restoring /etc/crypttab"
		cp -a "$tmpdir/crypttab" /etc/crypttab
		# In a subshell because `err` is an `exit`, and from
		# inside the trap that would skip the rest of this
		# function without even removing $tmpdir
		( install_all_kernels "$root_snapshot" ) || \
			warn "The initrd still carries the /etc/crypttab of the failed enrollment"
	fi

	dbg "Cleaning temporary directory $tmpdir"
	rm -rf "$tmpdir"

	[ -z "$chroot_dir" ] || umount_chroot "$chroot_dir"
}
trap cleanup EXIT

entryfile="$tmpdir/entries.json"
initialentryfile="$tmpdir/initial_entries.json"
snapperfile="$tmpdir/snapper.json"
tmpfile="$tmpdir/tmp"
devicefile="$tmpdir/devices.json"

helpandquit()
{
	# Tabs are removed from the start of the line.  Use spaces to
	# indent
	cat <<-EOF
		Usage: $0 [OPTIONS] [COMMAND]
		OPTIONS:
		  --esp-path		Manually specify path to ESP
		  --arch		Manually set architecture
		  --entry-token		Override entry token
		  --image		Specify Linux kernel file name
		  --all			List all entries (inc. from other systems)
		  --entry-keys		Comma separated list of keys
		  --no-variables	Do not update UEFI variables
		  --no-reuse-initrd	Always regenerate initrd
		  --sync		Synchronize (update, downgrade) the bootloader
		  --portable		Handle bootloader on portable devices
		                        (also --removable possible)
		  --secure-boot		When installing the bootloader, use the shim
		  --only-default	Only list the default entry
		  --default-snapshot	[SNAPSHOT] refers to the default snapshot
		  --ask-key		Ask the new recovery key (or randomly generated)
		  --ask-pin		Ask the new TPM2 PIN, and the recovery PIN
		  --ask-recovery-pin	Ask only the recovery PIN
		  --ask-pw		Ask the new password
		  --ask-current-pw	Ask the password that already opens the devices
		  --method		"tpm2", "tpm2+pin", "fido2", "password", "recovery-key"
		  --no-measure-pcr	During enrollment, do not include PCR 15 protection
		  --measure-pcr		Update the PCR 15 prediction if needed
		                        (the LUKS2 password will be asked)
		  --pcr			Comma seperated list of PCRs to enroll
		                        (the ones that cannot be predicted are dropped,
		                        unless --strict is used)
		  --devices		Comma separated list of devices to enroll or unenroll
		                        (by default all (not ignored) devices are [un]enrolled)
		  --rootfs		During bootloader installation, select the kind of 'root'
		                        valid selectors
		                        (uuid, partuuid, label, partlabel, device)
		  --rootfs-data		Extra data when --rootfs is used
		  --esp-free-space	Percentage of free space in the ESP
		  --devicetree-source	Absolute source path of a device tree blob to copy
		                        into the boot partition and reference from generated
		                        BLS entries. The source path is resolved in the
		                        target rootfs/snapshot. %K expands to the full
		                        kernel version and %V to the kernel package version
		                        without the flavor suffix. If configured, the
		                        resolved file must exist and be readable, and
		                        must be usable without Secure Boot, or kernel
		                        installation is aborted.
		  --force		Force certain operations inside a transaction, and
		                        force the regeneration of the TPM2 policy
		  --strict		Every requested PCR must be part of the TPM2 policy,
		                        or nothing is applied (like systemd-pcrlock --strict=)
		  --disable-predictions	Disable automatic update predictions
		  --full		"status" reports every detail, not the summary
		  -v, --verbose		More verbose output
		  --start-trace-code	Create /var/log/sdbootutil.log to trace the code.
		                        (verbose output is enabled)
		  --stop-trace-code	Remove /var/log/sdbootutil.log to stop tracing the code
		  -h, --help		This screen

		COMMAND:
		bootloader [SNAPSHOT]
		           Print the detected bootloader

		add-kernel VERSION [SNAPSHOT]
		           Create boot entry for specified kernel

		add-all-kernels [SNAPSHOT]
		           Create boot entries for all kernels in SNAPSHOT

		mkinitrd [SNAPSHOT]
		           Create boot entries for all kernels in SNAPSHOT,
		           assumes --no-reuse-initrd to regenerate initrds

		remove-kernel VERSION [SNAPSHOT]
		           Remove boot entry for specified kernel

		remove-all-kernels [SNAPSHOT]
		           Remove boot entries for all kernels in SNAPSHOT

		cleanup [SNAPSHOT]
		           Remove boot entries with missing kernels from SNAPSHOT

		list-kernels [SNAPSHOT]
		           List all kernels related to SNAPSHOT

		list-entries [SNAPSHOT]
		           List all entries related to SNAPSHOT.  On a
		           terminal the entry is marked with "+" when it
		           is the default one, "-" when the system booted
		           from it, and "*" when it is both

		list-snapshots
		           List all snapshots.  On a terminal the snapshot
		           is marked with "+" when it is the default one,
		           "-" when the system is running from it, and "*"
		           when it is both

		list-devices
		           List encrypted devices that are tracked

		status
		           Report what the machine boots and whether the TPM2
		           will open it.  Only reads, and prints no secret, so
		           it is safe to attach to a bug report.  --full for
		           every detail instead of the summary

		show-entry VERSION [SNAPSHOT]
		           Show fields for an entry with an specified kernel
		           version

		update-entry VERSION [SNAPSHOT]
		           Update "options" field from /etc/kernel/cmdline
		           for an entry

		update-all-entries [SNAPSHOT]
		           Update "options" field from /etc/kernel/cmdline
		           for all entries

		set-default-snapshot [SNAPSHOT]
		           Make SNAPSHOT the default for next boot.
		           Also install all kernels if needed

		is-bootable [SNAPSHOT]
		           Check whether SNAPSHOT has any kernels registered, ie
		           is potentially bootable

		install
		           Install the bootloader and shim into ESP

		needs-update
		           Check whether the bootloader in ESP needs updating

		update
		           Update the bootloader in the ESP if a newer version
		           is available. Passing the --sync option will also
		           allow downgrades, ensuring that the version in the ESP
		           matches the one installed in the system.

		force-update
		           Update the bootloader in any case

		set-default ID
		           Set default boot loader entry

		get-default
		           Get default boot loader entry

		set-timeout SECONDS|menu-disabled|menu-hidden|menu-force
		           Set the menu timeout
		           menu-disabled or menu-hidden=0; menu-force=-1

		get-timeout
		           Get the menu timeout in seconds

		print-loader-path
		           Print path to currently booted boot loader binary

		enroll
		           Enroll a TPM2 (+PIN), a FIDO2 key or a password for
		           all devices

		unenroll
		           Unenroll a TPM2 (+PIN), a FIDO2 key or a password for
		           all devices

		update-predictions
		           Update TPM2 predictions

		Secrets:
		Each one is taken from the --ask-X parameter, the X variable
		or the %u:sdbootutil-x keyring entry, in that order:

		                  parameter          variable      keyring (%u)
		Current password  --ask-current-pw   CURRENT_PW    cryptenroll
		New recovery key  --ask-key          KEY           sdbootutil-key
		New password      --ask-pw           PW            sdbootutil-pw
		TPM2 PIN          --ask-pin          PIN           sdbootutil-tpm2-pin
		Recovery PIN      --ask-recovery-pin RECOVERY_PIN  sdbootutil-recovery-pin

		The TPM2 PIN is typed at every boot; the recovery PIN
		re-authorizes the pcrlock NVIndex, and PIN also names it when
		there is no TPM2 PIN (--method=tpm2, update-predictions).
		--ask-pin asks both.  With no source, the recovery key and
		the recovery PIN are generated and printed.

		Prefer the keyring, it expires and the environment does not.
		%u:sdbootutil (any secret, read last) and %u:sdbootutil-pin
		(now -recovery-pin) are deprecated.

		Variables:
		SYSTEMD_COLORS	Set 0 to disable colored output
		CURRENT_PW, KEY, PW, PIN and RECOVERY_PIN are in Secrets:

		Misc:
		Ignoring Devices	A LUKS2 device can be un-tracked (ignored)
		                        by sdbootutil if is present in /etc/crypttab
		                        and has the "x-sdbootutil.ignore" option
		Trace Code		Create /var/log/sdbootutil.log to debug deep
		                        sdbootutil calls.  Remove the file to stop the
		                        trace, and audit the content before sharing
		Applied Snapshots	After a "transactional-update apply" the running
		                        system is a mix of two snapshots.  [SNAPSHOT] has
		                        no meaningful default then, so commands that
		                        change the ESP require an explicit snapshot
		                        number or --default-snapshot


	EOF
	exit 0
}

dbg()
{
	[ "${verbose:-0}" -gt 1 ] || return 0
	echo -e "DEBUG: $*" >&2
}

dbg_var()
{
	[ "${verbose:-0}" -gt 1 ] || return 0
	local v="${1:?}"
	echo "DEBUG: $v: ${!v}" >&2
}

dbg_cat()
{
	[ "${verbose:-0}" -gt 1 ] || return 0
	[ ! -e "$1" ] || { echo "DEBUG: $1" >&2; cat "$1" >&2; }
}

info()
{
	[ "${verbose:-0}" -gt 0 ] || return 0
	echo -e "$@" >&2
}

warn()
{
	echo -e "WARNING: $*" >&2
}

err()
{
	echo -e "ERROR: $*" >&2
	exit 1
}

is_config_file()
{
	[ -e /usr/etc/default/sdbootutil ] ||
		[ -e /etc/default/sdbootutil ] ||
		[ -e /usr/etc/default/fde-tools ] ||
		[ -e /etc/default/fde-tools ] ||
		[ -e /etc/sysconfig/fde-tools ]
}

set_default_config_values()
{
	FDE_SEAL_PCR_LIST="$arg_pcr"
	if is_installed && [ -z "${FDE_SEAL_PCR_LIST}" ]; then
		if systemd-detect-virt -q; then
			info "Virtualized systemd detected ($(systemd-detect-virt)). Dropping PCR0 and PCR2"
			FDE_SEAL_PCR_LIST=""
		else
			FDE_SEAL_PCR_LIST="0,2,"
		fi
		if is_sdboot; then
			FDE_SEAL_PCR_LIST+="4,7,9"
		elif is_grub2_bls; then
			FDE_SEAL_PCR_LIST+="4,7,8,9"
		else
			err "Bootloader not detected"
		fi
	fi

	if is_rootfs_crypt; then
		# `measure-pcr-validator` (PRC15) protect from
		# impostor LUKS2 volumes, as it measures the volume
		# key.  But if there is a volume in a non-encrypted
		# device (USB, for example) that has the same metadata
		# (referenced by /etc/fstab) as the internal volume in
		# the encrypted root device, then there is a chance
		# that this will get mounted in /sysroot.  systemd can
		# measure elements from the file system (like UUID or
		# /etc/machine-id) via systemd-validatefs@.service,
		# but this data is public and can be replicated
		#
		# See bsc#1229934 for more details
		ROOTFS="device"
	else
		ROOTFS="uuid"
	fi
	ROOTFS="${arg_rootfs:-$ROOTFS}"

	ESP_FREE_SPACE="${arg_esp_free_space:-10}"
	DEVICETREE_SOURCE="${arg_devicetree_source:-}"
}

create_default_config_file()
{
	! is_config_file || return 0

	set_default_config_values
	cat <<-EOF > /etc/default/sdbootutil
	# Default sdbootutil configuration file generated automatically

	# List of PCRs.  For VMs PCRs 0 and 2 are dropped
	FDE_SEAL_PCR_LIST="$FDE_SEAL_PCR_LIST"

	# Parameter used in the cmdline to find the root (device, [part]uuid, [part]label)
	# For encrypted devices it is forced "device"
	ROOTFS="$ROOTFS"

	# Percentage (%) of free space in the ESP that sdbootutil should guarantee
	# Default values is 10%
	ESP_FREE_SPACE="$ESP_FREE_SPACE"

	# Optional absolute path to a device tree blob. If set, it is copied to the
	# boot partition and a devicetree field is added to generated BLS entries.
	# The source path is resolved in the target rootfs/snapshot. %K expands to
	# the full kernel version, %V to the kernel package version without the
	# flavor suffix. If set, the resolved file must exist and be readable,
	# otherwise kernel installation aborts. Currently supported only with
	# Secure Boot disabled.
	DEVICETREE_SOURCE="$DEVICETREE_SOURCE"
	EOF
}

load_config_file()
{
	local f

	set_default_config_values

	# Prioritize sdbootutil configuration file, and use fde-tools
	# as a fallback
	for f in /usr/etc/default/sdbootutil \
			 /etc/default/sdbootutil \
			 /usr/etc/default/fde-tools \
			 /etc/default/fde-tools \
			 /etc/sysconfig/fde-tools; do
		[ ! -e "$f" ] || {
			# shellcheck disable=SC1090
			. "$f"
			info "Loading config file $f"
			dbg_cat "$f"
		}
	done

	# Force back "device" for ROOTFS if is encrypted
	if is_rootfs_crypt; then
		[ "$ROOTFS" = "device" ] || info "Fix the /etc/default/sdbootutil to set ROOTFS as 'device'"
		ROOTFS="device"
	fi

	# If arguments are passed, use them
	FDE_SEAL_PCR_LIST="${arg_pcr:-$FDE_SEAL_PCR_LIST}"
	ROOTFS="${arg_rootfs:-$ROOTFS}"
	ESP_FREE_SPACE="${arg_esp_free_space:-$ESP_FREE_SPACE}"
	DEVICETREE_SOURCE="${arg_devicetree_source:-$DEVICETREE_SOURCE}"
}

is_secure_boot()
{
	grep -q $'\x01' /sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c 2> /dev/null
}

bootloader_type()
{
	# If LOADER_TYPE is set, use that
	if [ -n "$LOADER_TYPE" ]; then
		echo "$LOADER_TYPE"
		return
	fi

	local sdboot grub2_bls

	sdboot="$(find_sdboot "${1-$root_snapshot}")"
	grub2_bls="$(find_grub2_bls "${1-$root_snapshot}")"

	# If LOADER_TYPE is unset and only systemd-boot is installed, use that
	if [ -e "$sdboot" ] && ! [ -e "$grub2_bls" ]; then
		echo "systemd-boot"
		return
	fi

	# Otherwise, use grub2-bls
	echo "grub2-bls"
}

is_sdboot()
{
	[ "$(bootloader_type "${1-$root_snapshot}")" = "systemd-boot" ]
}

is_grub2_bls()
{
	[ "$(bootloader_type "${1-$root_snapshot}")" = "grub2-bls" ]
}

reset_rollback()
{
	for i in "${rollback[@]}"; do
		[ -e "$i.bak" ] || continue
		info "Removing $i.bak"
		rm -f "$i.bak"
	done
	rollback=()
}

is_transactional()
{
	findmnt --fstab / -O ro &> /dev/null
}

# %user:sdbootutil is a single entry that is read as a different secret
# depending on the method (the new recovery key, the new password, the
# TPM2 PIN, the recovery PIN of the pcrlock NVIndex), so a value left
# there for one purpose is used for another one.  Each purpose has now
# its own entry, and this one is still read, last, only for the
# installers that have not moved yet
deprecated_keyring_secret()
{
	local purpose="${1:?}"
	local name="${2:?}"
	local old="${3:-sdbootutil}"

	[[ "$warned_keyring_secret" != *"|$old>$name|"* ]] || return 0
	warned_keyring_secret+="|$old>$name|"
	warn "Reading the $purpose from %user:$old is deprecated. Use %user:$name"
}

# The recovery PIN used to live in %user:sdbootutil-pin, one character
# away from the %user:sdbootutil-tpm2-pin that holds the PIN typed at
# every boot.  They are different secrets, as the recovery one rewrites
# the pcrlock policy, so the old name is deprecated but still read
recovery_pin_keyid()
{
	local keyid

	if keyid="$(keyctl id %user:sdbootutil-recovery-pin 2> /dev/null)"; then
		echo "$keyid"
	elif keyid="$(keyctl id %user:sdbootutil-pin 2> /dev/null)"; then
		deprecated_keyring_secret "recovery PIN" "sdbootutil-recovery-pin" "sdbootutil-pin"
		echo "$keyid"
	else
		return 1
	fi
}

keyctl_add_with_timeout()
{
	local key="$1"
	local value="$2"

	# Installers do not agree on the keyring: yast-bootloader,
	# disk-encryption-tool and jeos-firstboot use @u, but
	# yast-storage-ng uses @s.  `keyctl padd` only updates an entry
	# that is already in the *target* keyring, and `keyctl link`
	# displaces (and destroys) a homonym entry of the destination,
	# so any fixed keyring damages one of them.  `keyctl pupdate`
	# rewrites the payload where the entry already lives
	local keyid
	if keyid="$(keyctl id %user:"$key" 2> /dev/null)"; then
		info "Updating key $key"
		echo -n "$value" | keyctl pupdate "$keyid" || \
			warn "Failed to update the key $key"
		# The entry belongs to somebody else, so neither its
		# permissions nor its lifetime are changed here
		return 0
	fi

	info "Creating key $key with timeout"
	# When we create a new key with sudo, we do not create a new
	# login session, so the key is not accessible to the owner
	# See https://mjg59.dreamwidth.org/37333.html
	keyid="$(echo -n "$value" | keyctl padd user "$key" @u)"
	# Remove permission from the possesor.  For some reason that I
	# do not understand, this can fail in YaST when called via
	# cheetah
	# Returning here would leave the secret in the keyring with no
	# expiry at all, which is worse than the wrong permissions
	keyctl setperm "$keyid" 0x003f0000 2> /dev/null || \
		warn "Failed to change the permissions of $key"
	# The countdown starts here only as a backstop for a run that is
	# killed before `cleanup` can restart it.  Every consumer is
	# behind a slow step (dracut, `systemd-pcrlock make-policy`) or
	# in the next invocation: `sdbootutil-enroll` chains
	# --method=recovery-key and --method=tpm2, and the recovery key
	# that the first one leaves here is what authorizes the new slot
	# in the second one
	keyctl timeout "$keyid" "$keyctl_timeout"
	created_keys+=("$keyid")
	# systemd tools are doing `keyctl request user "$key"`, and for
	# some reason this means that the key must also be reachable
	# from the session keyring
	keyctl link "$keyid" @s
}

ask_password()
{
	local msg="$1"
	local -n nameref_pw="$2"
	# `read -p` prints the prompt only when the input is a
	# terminal, so without this check a non-interactive caller
	# gets an empty password and no indication of why
	[ -t 0 ] || err "\"$msg\" is required, but there is no terminal to ask for it"
	# A closed terminal (^D) makes `read` fail, and as a bare command
	# under `set -e` that ends the script without a word, while the
	# case just above, no terminal at all, does explain itself
	read -r -s -p "$msg: " nameref_pw || err "\"$msg\" was not provided"
	echo >&2
}

ask_new_password()
{
	local msg="$1"
	local -n nameref_pw="$2"
	local pw1 pw2
	while :; do
		ask_password "New $msg" pw1
		ask_password "Re-type $msg" pw2
		# Pressing only Enter is not an answer.  An empty
		# password is enrolled by `systemd-cryptenroll` as a
		# keyslot that nothing can open afterwards, and it
		# reports success, and an empty TPM2 PIN makes it abort
		# on an assertion
		if [ -z "$pw1" ]; then
			warn "The $msg cannot be empty!"
		elif [ "$pw1" != "$pw2" ]; then
			warn "Inputs did't match!"
		else
			break
		fi
	done
	# shellcheck disable=SC2034
	nameref_pw="$pw1"
}

subvol_is_ro()
{
	[ -n "$have_snapshots" ] || return 1
	local subvol="${1:?}"

	while read -r line; do
		[ "$line" = "ro=true" ] && return 0
	done < <(btrfs prop get -t s "${subvol#"${subvol_prefix}"}" ro)
	return 1
}

detect_parent()
{
	local subvol="$1"
	[ -n "$have_snapshots" ] || return 0

	local parent_uuid
	parent_uuid="$(btrfs subvol show "${subvol#"${subvol_prefix}"}" | sed -ne 's/\s*Parent UUID:\s*//p')"
	[ "$parent_uuid" != '-' ] || return 0

	local -a parent_subvol_uuid
	local btrfs_subvol_status=0
	# shellcheck disable=SC2207
	parent_subvol_uuid=($(btrfs subvol show -u "$parent_uuid" "${subvol#"${subvol_prefix}"}" 2> /dev/null)) || btrfs_subvol_status=$?
	[ "$btrfs_subvol_status" = 0 ] || return 0
	parent_subvol="${parent_subvol_uuid[0]}"

	parent_snapshot="${parent_subvol#"${subvol_prefix}"/.snapshots/}"
	if [ "$parent_subvol" = "$parent_snapshot" ]; then
		unset parent_subvol parent_snapshot
	else
		parent_snapshot="${parent_snapshot%/snapshot}"
	fi

	dbg_var "parent_subvol"
	dbg_var "parent_snapshot"
}

snapshot_from_fsroot()
{
	local fsroot="${1#/}"
	local snapshot="${fsroot#"${subvol_prefix}"/.snapshots/}"

	[ "$snapshot" != "$fsroot" ] || return 0
	echo "${snapshot%%/*}"
}

detect_applied_snapshot()
{
	local fsroot
	local usr_snapshot=""

	[ -n "$have_snapshots" ] || return 0

	# With more than one `apply` the mounts stack, and the last
	# line is the visible one
	while read -r fsroot; do
		usr_snapshot="$(snapshot_from_fsroot "$fsroot")"
	done < <(findmnt --noheadings --output FSROOT /usr 2> /dev/null)

	[ -n "$usr_snapshot" ] || return 0
	[ "$usr_snapshot" != "$running_snapshot" ] || return 0
	echo "$usr_snapshot"
}

assert_snapshot()
{
	[ -n "$applied_snapshot" ] || return 0
	[ -z "$arg_default_snapshot" ] || return 0
	[ -z "$1" ] || return 0

	err "The running system is a mix of snapshot $running_snapshot (mounted in /) and" \
	    "snapshot $applied_snapshot (mounted in /usr), as left by 'transactional-update apply'." \
	    "\nRe-run the command with an explicit snapshot number, or with --default-snapshot" \
	    "to select the snapshot that will be booted next."
}

is_rootfs_crypt()
{
	local device
	read -r device < <(findmnt / -v -n -o SOURCE 2> /dev/null)
	[ -n "$device" ] && [ "$(lsblk --noheadings -o TYPE "$device" 2> /dev/null)" = "crypt" ]
}

get_rootfs()
{
	local rootfs_data

	case "$ROOTFS" in
		uuid) read -r rootfs_data < <(findmnt / -v -n -o UUID 2> /dev/null) ;;
		label) read -r rootfs_data < <(findmnt / -v -n -o LABEL 2> /dev/null) ;;
		partuuid) read -r rootfs_data < <(findmnt / -v -n -o PARTUUID 2> /dev/null) ;;
		partlabel) read -r rootfs_data < <(findmnt / -v -n -o PARTLABEL 2> /dev/null) ;;
		device) read -r rootfs_data < <(findmnt / -v -n -o SOURCE 2> /dev/null) ;;
		*)
			info "Can't determine rootfs ($ROOTFS). Using UUID as default"
			ROOTFS="uuid"
			read -r rootfs_data < <(findmnt / -v -n -o UUID 2> /dev/null)
			;;
	esac
	rootfs_data="${arg_rootfs_data:-$rootfs_data}"

	if [ "$ROOTFS" = "device" ]; then
		echo "$rootfs_data"
	else
		echo "${ROOTFS^^}=$rootfs_data"
	fi
}

get_all_rootfs()
{
	local rootfs rootfs_data

	read -r rootfs_data < <(findmnt / -v -n -o SOURCE)
	rootfs="$rootfs_data"

	read -r rootfs_data < <(findmnt / -v -n -o UUID)
	[ -z "$rootfs_data" ] || rootfs="$rootfs|UUID=$rootfs_data"

	read -r rootfs_data < <(findmnt / -v -n -o LABEL)
	[ -z "$rootfs_data" ] || rootfs="$rootfs|LABEL=$rootfs_data"

	read -r rootfs_data < <(findmnt / -v -n -o PARTUUID)
	[ -z "$rootfs_data" ] || rootfs="$rootfs|PARTUUID=$rootfs_data"

	read -r rootfs_data < <(findmnt / -v -n -o PARTLABEL)
	[ -z "$rootfs_data" ] || rootfs="$rootfs|PARTLABEL=$rootfs_data"

	echo "$rootfs"
}

sedrootflags()
{
	local subvol="$1"
	local root_param
	root_param="$(get_rootfs)"

	# - Delete everything before BOOT_IMAGE= and initrd=
	#   (see https://github.com/openSUSE/sdbootutil/issues/182)
	# - Delete BOOT_IMAGE= and initrd=
	# - Replace or add root= to refers to [PART]{UUID, LABEL}, or
	#   mapped device
	# - Replace or add rootflags to point at correct subvolume
	# - Delete systemd.machine_id if /etc/machine-id is present
	#
	# From the sed manual:
	# ‘t’
	#     branch conditionally (that is: jump to a label) _only
	#     if_ a ‘s///’ command has succeeded since the last input
	#     line was read or another conditional branch was taken.
	#
	# We use the t command to jump over an expression that appends
	# a parameter if replacing the parameter succeeded (ie it was
	# already there). Since we always operate on the same line,
	# "empty" t jumps are used to reset the condition after very
	# s///.
	local sed_arguments=("-e s/[ \t]\+/ /g"
		"-e s/^.*\(initrd=[^ ]*\|BOOT_IMAGE=[^ ]*\)\s*/\1 /"
		"-e s/\<\(BOOT_IMAGE\|initrd\)=[^ ]* \?//"
		"-e s/\$//;ta;:a"
		"-e s,\<root=[^ ]*,root=$root_param,;tb;s,\$, root=$root_param,;tc;:c;:b")
	[ -z "$subvol" ] || sed_arguments+=("-e s,\<rootflags=subvol=[^ ]*,rootflags=subvol=$subvol,;td;s,\$, rootflags=subvol=$subvol,;te;:e;:d")
	[ -n "$machine_id" ] || sed_arguments+=("-e s/\<systemd.machine_id=[^ ]* \?//")
	sed "${sed_arguments[@]}"
}


entry_filter=("cat")
update_entries()
{
	[ -z "$1" ] || entry_filter=("$@")
	bootctl list --json=short | "${entry_filter[@]}" > "$entryfile"
	dbg "Entry filter: ${entry_filter[*]}"
	dbg_cat "$entryfile"
}

update_entries_for_subvol()
{
	local subvol="$1"
	local ext="${2:-}"

	local root
	root="$(get_all_rootfs)"

	[ -z "$ext" ] || ext="|$ext"
	update_entries jq "[.[]|select(has(\"options\"))|select(.options|test(\"root=(?:$root) .*rootflags=subvol=$subvol\")$ext)]"
}

update_entries_for_snapshot()
{
	local n="$1"
	update_entries_for_subvol "${subvol_prefix}/.snapshots/$n/snapshot"
}

update_entries_for_this_system()
{
	local root
	root="$(get_all_rootfs)"

	update_entries jq "[.[]|select(has(\"options\"))|select(.options|test(\"root=(?:$root)\"))]"
}

update_entries_for_extra()
{
	update_entries jq "[.[]|select((.linux//.efi//\"\")|startswith(\"/EFI/extra\"))]"
}

entry_conf_file()
{
	local kernel_version="${1:?}"
	local snapshot="$2"
	local tries="$3"

	# GRUB2 with the BLS patches does not follow the expected
	# ordering rules, using only rpmvercmp() with the entry
	# filename.  To provide an order we add a prefix ("system")
	# for entries that are not RO, making it newer that any other
	# entry name that start with the "snapper" prefix: ("system" >
	# "snapper").
	local prefix=""
	local subvol=""
	[ -z "$have_snapshots" ] || subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot"
	if ! is_transactional && is_grub2_bls; then
		if ! subvol_is_ro "$subvol"; then
			prefix="system"
		else
			prefix="snapper"
		fi
	fi

	echo "${prefix:+$prefix-}$entry_token-$kernel_version${snapshot:+-$snapshot}${tries:++$tries}.conf"
}

find_conf_file()
{
	local kernel_version="${1:?}"
	local snapshot="$2"
	local id
	id="$(entry_conf_file "$kernel_version" "$snapshot")"

	if [ -n "$snapshot" ]; then
		update_entries_for_snapshot "$snapshot"
	else
		update_entries_for_this_system
	fi

	while IFS= read -r path; do
		if [ -f "$path" ]; then
			echo "$path"
			return 0
		fi
	done < <(jq -r --arg id "$id" '.[] | select(.id == $id) | .path' < "$entryfile")

	return 1
}

# The only place that knows how a type#1 entry is written: a key, a run
# of blanks and the value, one pair per line, with comments and empty
# lines in between.  The pairs are streamed instead of collected in an
# associative array because a key can appear more than once: "initrd"
# does whenever there is a microcode image, and the specification also
# allows several "options"
#
# A missing file is not an error.  Every caller here treats an entry
# that says nothing and an entry that is not there in the same way
entry_fields()
{
	local conf="${1:?}"
	local key value

	[ -f "$conf" ] || return 0

	while read -r key value; do
		[ -n "$key" ] || continue
		[ "${key:0:1}" != '#' ] || continue
		printf '%s %s\n' "$key" "$value"
	done < "$conf"
}

# The values of one key, in the order in which they appear
entry_field()
{
	local conf="${1:?}"
	local field="${2:?}"
	local key value

	while read -r key value; do
		[ "$key" = "$field" ] || continue
		printf '%s\n' "$value"
	done < <(entry_fields "$conf")
}

# Every file that the entry references, as a path relative to the boot
# partition
entry_files()
{
	local conf="${1:?}"
	local key value

	while read -r key value; do
		case "$key" in
			linux|initrd|devicetree) printf '%s\n' "$value" ;;
		esac
	done < <(entry_fields "$conf")
}

# An entry is usable only if it points to this kernel and every file
# that it names is in the ESP
entry_is_complete()
{
	local conf="${1:?}"
	local linux_dst="${2:?}"
	local path

	while read -r path; do
		[ -e "${boot_root}${path}" ] || {
			dbg "$conf points to the missing ${boot_root}${path}"
			return 1
		}
	done < <(entry_files "$conf")

	# The presence of *some* initrd for the version says nothing: the
	# entry can name an initrd checksum that was already cleaned up,
	# and such an entry does not boot.  And "install_kernel" refuses
	# to write an entry without an initrd, so one without it was not
	# written by us and does not boot here either
	[ -n "$(entry_field "$conf" initrd)" ] || {
		dbg "$conf has no initrd line"
		return 1
	}

	# "$linux_dst" carries the checksum of the kernel, so a kernel
	# rebuilt without changing its version does not match the "linux"
	# line of the entry of the previous build
	path="$(entry_field "$conf" linux)"
	[ "$path" = "$linux_dst" ] || {
		dbg "$conf points to ${path:-no kernel} and not to $linux_dst"
		return 1
	}
}

# There is no type#1 entry in the ESP.  Note that "nullglob" makes no
# difference between an empty and a missing directory, which is what
# the caller expects: both mean that no kernel was installed yet
no_boot_entries()
{
	local confs=("${boot_root}/loader/entries/"*.conf)

	[ "${#confs[@]}" -eq 0 ]
}

settle_entry_token()
{
	local snapshot="$1"
	set_os_release "$snapshot"
	set_machine_id "$snapshot"
	case "$arg_entry_token" in
		""|auto)
			if [ -s '/etc/kernel/entry-token' ]; then
				read -r entry_token < '/etc/kernel/entry-token'
			else
				local var
				for var in machine_id os_release_IMAGE_ID os_release_ID; do
					entry_token="${!var}"
					[ -z "$entry_token" ] || break
				done
			fi
			;;
		machine-id)
			[ -n "$machine_id" ] || err "Couldn't determine machine-id"
			entry_token="$machine_id"
			;;
		os-id)
			# shellcheck disable=SC2154
			entry_token="$os_release_ID"
			[ -n "$entry_token" ] || err "Missing ID"
			;;
		os-image)
			# shellcheck disable=SC2154
			entry_token="$os_release_IMAGE_ID"
			[ -n "$entry_token" ] || err "Missing IMAGE_ID"
			;;
		literal:*)
			entry_token="${arg_entry_token#literal:}"
			;;
		*) err "Unexpected parameter for --entry-token=: $arg_entry_token" ;;
	esac
	[ -n "$entry_token" ] || err "Can't determine entry-token"

	dbg_var "entry_token"
	return 0
}

remove_kernel()
{
	local snapshot="$1"
	local kernel_version="$2"
	[ -n "$kernel_version" ] || err "Missing kernel version"

	info "Removing kernel $kernel_version"
	dbg_var "snapshot"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Removing kernels must be done outside the transaction"
		return 0
	}

	settle_entry_token "${snapshot}"
	local id
	id="$(entry_conf_file "$kernel_version" "$snapshot")"
	info "Removing boot entry $id"
	bootctl unlink "$id"

	# If we remove the default entry, `bootctl` will mark a new
	# default, but we still need to update the EFI var (or the
	# grubenv value)
	local default
	default="$(get_default_bootctl)"
	if [ -z "$default" ]; then
		warn "Can't determine the new default entry"
	else
		set_default_entry "$default"
	fi

	# This action will require to update the PCR predictions
	update_predictions=1
}

install_with_rollback()
{
	local src="${1:?}"
	local dst="${2:?}"
	local old="${3:-$dst}"

	# Install `src` as `dst` if the file is new or different.  If
	# the file is present with the `old` name, if will be kept if
	# the file did not change, else it will be installed with the
	# new name (`dst`)
	if [ -e "$old" ]; then
		if cmp -s "$src" "$old"; then
			info "$old unchanged"
			return 0
		fi
		mv "$old" "$old.bak" || return "$?"
	fi
	rollback+=("$old")
	install -p -m 0644 "$src" "$dst" || return "$?"
	chown root:root "$dst" 2> /dev/null || true
	info "Installed $dst"
}

update_snapper()
{
	snapper --jsonout --no-dbus list --disable-used-space > "$snapperfile"
	dbg_cat "$snapperfile"
}

set_snapper_title_and_sortkey()
{
	[ -n "$have_snapshots" ] || return 0
	snapshot="${1:?}"
	local type date desc important pre_num
	local snapshot_info

	[ -s "$snapperfile" ] || update_snapper

	# shellcheck disable=SC2046
	IFS="|" read -r type date desc important pre_num <<<\
		$(jq -r --arg snapshot "$snapshot" \
		'.["root"][]|select(.number==($snapshot|tonumber))|[.type,.date,(.description|gsub("\\|";"_")),.userdata.important,."pre-number"//""]|join("|")'\
		< "$snapperfile")

	if [ -z "$desc" ] && [ "$type" = "post" ] && [ -n "$pre_num" ]; then
		read -r desc <<<"$(jq -r --arg snapshot "$pre_num" '.["root"][]|select(.number==($snapshot|tonumber))|.description' < "$snapperfile")"
	fi

	if [ "$important" = "yes" ]; then important="*"; else important=""; fi
	[ "$type" = "single" ] && type=""
	snapshot_info="$snapshot,$kernel_version,$date${type:+, $type}${desc:+, $desc}"

	# shellcheck disable=SC2154
	title="Snapper: ${important}$title ($snapshot_info)"
	sort_key="snapper-$sort_key"
}

set_os_release()
{
	local snapshot="$1"
	local subvol=""
	[ -z "$have_snapshots" ] || subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot"
	os_release_files=(
		"${subvol#"${subvol_prefix}"}/usr/lib/os-release"
		"${subvol#"${subvol_prefix}"}/etc/os-release"
	)

	for file in "${os_release_files[@]}"; do
		[ -f "$file" ] || continue
		eval "$(sed -ne '/^[A-Z_]\+=/s/^/os_release_/p' < "$file")"
		break
	done
}

set_machine_id()
{
	local snapshot="$1"
	local subvol=""
	[ -z "$have_snapshots" ] || subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot"
	machine_id_files=()
	if is_transactional && [ -z "$TRANSACTIONAL_UPDATE" ]; then
		[ -n "$snapshot" ] && machine_id_files+=("/var/lib/overlay/$snapshot/etc/machine-id")
	fi
	machine_id_files+=("${subvol#"${subvol_prefix}"}/etc/machine-id")

	for file in "${machine_id_files[@]}"; do
		if [ -s "$file" ]; then
			read -r machine_id < "$file"
			[ "$machine_id" != "uninitialized" ] || machine_id=
			[ -z "$machine_id" ] || break
		fi
	done
}

reuse_initrd()
{
	local snapshot="$1"
	local subvol="$2"
	local kernel_version="${3:?}"
	local conf

	[ -z "$arg_no_reuse_initrd" ] || return 1
	settle_entry_token "$snapshot"

	local find_conf_status=0
	conf="$(find_conf_file "$kernel_version" "$snapshot")" || find_conf_status=$?

	if [ $find_conf_status -ne 0 ]; then
		# Check if we can reuse the initrd from the parent to
		# avoid expensive regeneration
		detect_parent "$subvol"
		if [ -n "$parent_subvol" ]; then
			settle_entry_token "$parent_snapshot"
			find_conf_status=0
			conf="$(find_conf_file "$kernel_version" "$parent_snapshot")" || find_conf_status=$?
		fi
	fi

	if [ "$find_conf_status" -eq 0 ]; then
		local v
		while read -r v; do
			[ -f "${boot_root}$v" ] || continue
			info "Found existing initrd $v"
			dstinitrd+=("$v")
		done < <(entry_field "$conf" initrd)
		[ "${#dstinitrd[@]}" -eq 0 ] || return 0
	fi

	return 1
}

mount_chroot()
{
	local snapshot_dir="$1"

	# We include the rootfs (the first line usually), as is needed
	# to appear in the mounts under the chroot, allowing dracut to
	# properly detect the fs type and load the relevant module.
	findmnt -o TARGET,FSTYPE,FSROOT -Rv --pairs / > "$tmpdir/mounts"
	mount --bind "$snapshot_dir" "$snapshot_dir"
	# Register the chroot before mounting anything else, so a
	# failure in the middle of the loop is unwound by `cleanup`
	chroot_dir="$snapshot_dir"
	while read -r line; do
		eval "$line"
		# shellcheck disable=SC2153
		[ "$FSTYPE" = "btrfs" ] || [ "$FSTYPE" = "vfat" ] || [ "$FSTYPE" = "xfs" ] || [[ "$FSTYPE" == ext* ]] || continue
		[ "$TARGET" != "/" ] || continue
		[ "$TARGET" = "/etc" ] && [ "$FSTYPE" = "btrfs" ] && continue
		[[ "$TARGET" != /.snapshots* ]] || continue
		[[ "$TARGET" != /run/media/* ]] || continue
		# After a `transactional-update apply` the running
		# system has /usr and /boot bind mounted from the new
		# default snapshot.  Those belong to a different
		# snapshot and must not shadow the ones that
		# "$snapshot_dir" provides
		# shellcheck disable=SC2153
		[ -z "$(snapshot_from_fsroot "$FSROOT")" ] || continue
		# Not every mount point of the running system is present
		# in the snapshot.  For example the directory that
		# `transactional-update` mounts under /tmp while a
		# transaction is open, or any external media.  They are
		# not needed to generate the initrd, and the snapshot can
		# be read-only, so the directory cannot be created
		if [ ! -d "$snapshot_dir$TARGET" ]; then
			dbg "Skipping $TARGET, not present in $snapshot_dir"
			continue
		fi
		mountpoint --quiet "$snapshot_dir$TARGET" || mount --bind "$TARGET" "$snapshot_dir$TARGET"
	done < "$tmpdir/mounts"
	rm "$tmpdir/mounts"

	mount -t tmpfs -o size=10m tmpfs "$snapshot_dir/run"
	if [ -e /run/systemd/journal ]; then
		mkdir -p "$snapshot_dir/run/systemd/journal"
		mount --bind /run/systemd/journal "$snapshot_dir/run/systemd/journal"
	fi
	for i in proc dev sys tmp; do
		mount --bind "/$i" "$snapshot_dir/$i"
	done
}

umount_chroot()
{
	local snapshot_dir="$1"

	umount -R "$snapshot_dir"
	chroot_dir=
}

mount_etc()
{
	local snapshot_dir="$1"

	# Don't mount if we are within a transactional-update shell
	[ -z "$TRANSACTIONAL_UPDATE" ] || return 0

	# Only overlayfs needs special treatment
	[ "$(findmnt --tab-file "${snapshot_dir}/etc/fstab" --noheadings --nofsroot --output FSTYPE /etc)" = "overlay" ] || return 0

	IFS=',' read -ra fields <<<\
	   "$(findmnt --tab-file "${snapshot_dir}/etc/fstab" --noheadings --nofsroot --output OPTIONS /etc | sed 's#/sysroot##g' | sed 's#:/etc,#:'"${snapshot_dir}"'/etc,#g')"

	local lower=""
	local upper=""
	for element in "${fields[@]}"; do
		IFS='=' read -r key value <<<"$element"
		[ "$key" = "lowerdir" ] && lower="$value"
		[ "$key" = "upperdir" ] && upper="$value"
	done

	mount overlay -t overlay -o ro,"lowerdir=${upper}:${lower}" "${snapshot_dir}/etc"
}

add_version_to_title()
{
	# TW pretty name does not include the version
	# shellcheck disable=SC2154
	[ -n "$os_release_VERSION" ] || title="$title $os_release_VERSION_ID"
}

pending_kernel_size()
{
	echo $(($(stat -c %s "$1") / 1024 + 1))
}

pending_initrds_size()
{
	local size=0
	local i=0
	while [ -e "$1/initrd-$i" ]; do
		size=$((size + $(stat -c %s "$1/initrd-$i")))
		((++i))
	done
	echo $((size / 1024 + 1))
}

pending_devicetree_size()
{
	local devicetree="$1"
	[ -n "$devicetree" ] || {
		echo 0
		return 0
	}
	echo $(($(stat -L -c %s "$devicetree") / 1024 + 1))
}

boot_space()
{
	local size
	size="$(findmnt --noheadings --bytes -o SIZE --target "${boot_root}" | head -n 1)"
	# Called from a command substitution, so `err` only ends the
	# sub-shell and the caller can detect the failure via the exit
	# status
	[[ "$size" =~ ^[0-9]+$ ]] || err "Can't determine the size of ${boot_root}"
	echo $((size / 1024))
}

boot_free_space()
{
	local avail
	avail="$(findmnt --noheadings --bytes -o AVAIL --target "${boot_root}" | head -n 1)"
	[[ "$avail" =~ ^[0-9]+$ ]] || err "Can't determine the free space in ${boot_root}"
	echo $((avail / 1024))
}

# The snapshots whose boot entries must be preserved: the default and
# the active ones, plus the snapshot from where the kernel is being
# installed, if any.  Printed one number per line
snapshots_in_use()
{
	local snapshot="$1"

	[ -n "$have_snapshots" ] || return 0

	{
		[ -z "$snapshot" ] || echo "$snapshot"
		[ -z "$root_snapshot" ] || echo "$root_snapshot"
		[ -s "$snapperfile" ] || update_snapper
		jq -r '.root[]|select(.active==true or .default==true)|.number' "$snapperfile"
	} | sort -un
}

# Print the ids of the boot entries that can be removed to make room in
# the ESP, in the order in which they should be removed.
#
# `bootctl unlink` garbage collects the kernel and the initrd only when
# the last entry that references them is removed, so entries are peeled
# off one snapshot at a time and the free space is re-evaluated after
# each removal.  Removing more than needed would leave older snapshots
# without a bootable entry.
#
# The order is:
#
#   1. entries of the snapshots that are not in use, oldest snapshot
#      first and, inside a snapshot, oldest kernel first
#   2. entries of "root_snapshot" (the default snapshot, or the running
#      one), as a last resort
#
# Never returned, and so never removed:
#
#   - the default and the selected entries
#   - the entries of the snapshots in use, except the ones of
#     "root_snapshot", that are last in the list
#
# This covers both layouts.  In MicroOS the default snapshot is the
# newest one and in Tumbleweed it is the oldest one, but in both cases
# the snapshots that are not in use are leftovers that can be removed
# from the oldest to the newest.  Snapshots created after a rollback
# fall in the same group and are removed the same way.
#
# The snapshot and the kernel version are read from "options" and
# "linux", which sdbootutil writes itself, and only then from "version"
# and "id".  An entry where none of them can be parsed (a kernel from a
# third party repository, an entry written by another tool, a system
# without snapshots) is still a candidate, but is sorted last inside
# its group: it can be removed, yet only after the entries that can be
# ordered with confidence
removable_entries()
{
	local snapshot="$1"

	local in_use
	mapfile -t in_use < <(snapshots_in_use "$snapshot")
	dbg "Snapshots that keep their boot entries: ${in_use[*]:-none}"

	update_entries_for_this_system

	# Six sort columns are emitted, and the last one is the id:
	#
	#   1. 0 for a snapshot that is not in use, 1 for "root_snapshot"
	#   2. 0 if the snapshot number is known, 1 if it is not
	#   3. the snapshot number
	#   4. 0 if the kernel version is known, 1 if it is not
	#   5. the kernel version
	#   6. the id, to make the order stable
	#
	# The columns are never empty, as `read` and `sort` would
	# collapse consecutive tabs and shift the fields
	jq -r --arg in_use "${in_use[*]}" --arg last "$root_snapshot" '
		def snapshot_of:
			((.options // "") | capture("rootflags=subvol=[^ ]*/\\.snapshots/(?<n>[0-9]+)/snapshot") | .n)
			// ((.version // "") | capture("^(?<n>[0-9]+)@") | .n)
			// ((.id // "") | capture("-(?<n>[0-9]+)(\\+[0-9]+(-[0-9]+)?)?\\.conf$") | .n)
			// "";
		def kernel_of:
			((.linux // "") | capture("^/[^/]+/(?<k>[^/]+)/[^/]+$") | .k)
			// ((.version // "") | capture("@(?<k>.+)$") | .k)
			// "";

		($in_use | split(" ") | map(select(. != ""))) as $in_use
		| .[]
		| select(.isDefault != true and .isSelected != true)
		| snapshot_of as $s
		| kernel_of as $k
		| select(($in_use | index($s)) == null or $s == $last)
		| [ (if $s == $last then 1 else 0 end),
		    (if $s == "" then 1 else 0 end),
		    (if $s == "" then 0 else ($s | tonumber) end),
		    (if $k == "" then 1 else 0 end),
		    (if $k == "" then "-" else $k end),
		    .id ]
		| @tsv
	' < "$entryfile" |
		sort -t$'\t' -k1,1n -k2,2n -k3,3n -k4,4n -k5,5V -k6,6 |
		cut -f6
}

# Try to make room in the ESP for "required_size" KB.  Note that the
# caller collects the exit status (`make_free_space ... || err ...`),
# so `set -e` is not active here and every failure must be reported,
# or it will be presented as a lack of space
make_free_space()
{
	local snapshot="$1"
	local required_size="$2"

	[[ "$required_size" =~ ^[0-9]+$ ]] || {
		warn "Can't determine the size of the new kernel and initrd"
		return 1
	}

	local total_space free_space
	total_space="$(boot_space)" || return 1
	free_space="$(boot_free_space)" || return 1

	# The extra size is a reserve that sdbootutil tries to keep
	# free in the ESP.  It is a goal, not a requirement: it must
	# never block the installation of a kernel that still fits
	local required_size_extra wanted_size
	required_size_extra=$((total_space * ESP_FREE_SPACE / 100))
	wanted_size=$((required_size + required_size_extra))

	info "Required free space in ESP: ${required_size}KB + ${required_size_extra}KB (${ESP_FREE_SPACE}% ESP size)"

	# If there is already free space, shortcut the code
	[ "$wanted_size" -gt "$free_space" ] || return 0

	# "Cleaning /boot/efi" message is presented via stderr
	dbg "Calling bootctl cleanup"
	bootctl -q cleanup 2> /dev/null || warn "Failed to clean up ${boot_root}"

	# Remove the entries one by one, from the least to the most
	# valuable one, until there is enough space.  It is re-evaluated
	# after every removal, as `bootctl unlink` frees the kernel and
	# the initrd only when the last entry that references them is
	# gone
	local id
	while read -r id; do
		free_space="$(boot_free_space)" || return 1
		dbg "Free space in the ESP: $free_space KB"
		[ "$wanted_size" -gt "$free_space" ] || return 0
		info "Removing boot entry $id"
		bootctl unlink "$id" || warn "Failed to remove the boot entry $id"
	done < <(removable_entries "$snapshot")

	free_space="$(boot_free_space)" || return 1
	dbg "Free space in the ESP after deallocation: $free_space KB"

	[ "$wanted_size" -gt "$free_space" ] || return 0

	# The reserve could not be honoured, but the new kernel and
	# initrd still fit.  Warn about it, as the next installation
	# can fail, but do not block this one
	[ "$required_size" -gt "$free_space" ] || {
		warn "Only ${free_space}KB free in ${boot_root}, below the ${ESP_FREE_SPACE}% reserve"
		warn "Remove the old kernels, or increase the size of ${boot_root}"
		return 0
	}

	warn "Needed ${required_size}KB in ${boot_root}, but only ${free_space}KB are free"
	warn "There are no more boot entries that can be removed automatically"
	return 1
}

make_free_space_for_kernel()
{
	local snapshot="$1"
	local kernel="$2"
	local initrds="$3"
	local devicetree="${4:-}"

	# Calculate the free space and the required size.  All sizes
	# are in Kb to avoid big numbers
	local free_space total_size
	total_size=$(($(pending_kernel_size "$kernel") + $(pending_initrds_size "$initrds") + $(pending_devicetree_size "$devicetree")))

	make_free_space "$snapshot" "$total_size"
}

resolve_devicetree_source()
{
	local subvol="$1"
	local kernel_version="$2"
	local kernel_package_version="${kernel_version%-*}"
	local devicetree_source="${DEVICETREE_SOURCE:-}"

	[ -n "$devicetree_source" ] || return 0
	devicetree_source="${devicetree_source//%K/$kernel_version}"
	devicetree_source="${devicetree_source//%V/$kernel_package_version}"
	[[ "$devicetree_source" == '/'* ]] || err "DEVICETREE_SOURCE must be an absolute path"

	local snapshot_prefix="${subvol#"${subvol_prefix}"}"
	devicetree_source="$snapshot_prefix$devicetree_source"
	[ -f "$devicetree_source" ] || err "Can't find device tree blob $devicetree_source"
	[ -r "$devicetree_source" ] || err "Device tree blob is not readable: $devicetree_source"
	echo "$devicetree_source"
}

# Detect when the script is not operating over the system that is
# running, but over one that is mounted somewhere else and entered via
# `chroot` (the installer, or a `zypper --root` transaction), or over
# an image that is being built inside a container
foreign_system()
{
	[ -z "$in_buildroot" ] || return 0

	# `--chroot` compares "/" with "/proc/1/root", and reports a
	# chroot also when "/proc" is not mounted and the comparison
	# cannot be made.  Both answers are the ones that the caller
	# expects: without "/proc" there is no "/proc/cmdline" to take a
	# decision about either
	systemd-detect-virt -q --chroot && return 0
	systemd-detect-virt -q --container && return 0

	return 1
}

# List every file that contributes to the kernel command line of the
# entry of "subvol", in the order that they have to be aggregated: the
# first configuration file that is found, followed by the drop-in
# snippets that extend it.  Fails when the system does not configure a
# command line in any of them
kernel_cmdline_files()
{
	local subvol="$1"

	local sources=("${subvol:1}/etc/kernel/cmdline" "${subvol:1}/usr/lib/kernel/cmdline" /etc/kernel/cmdline /usr/lib/kernel/cmdline)
	# "/proc/cmdline" is the command line of the kernel that is
	# running, so it is a valid last resort only when the entry
	# belongs to the system that is running.  Inside the installer it
	# describes the installation medium, and parameters like
	# "Y2DEBUG=1", "linuxrc.debug" or "console=ttyS0" would be
	# inherited by the installed system, that would also miss the
	# ones that the installer is expected to provide, like "quiet"
	if foreign_system; then
		dbg "Ignoring /proc/cmdline: not the running system"
	else
		sources+=(/proc/cmdline)
	fi

	local found=
	local i
	for i in "${sources[@]}"; do
		[ -f "$i" ] || continue
		echo "$i"
		found=1
		break
	done

	# A drop-in in "/etc" replaces the one with the same name in
	# "/usr", and an empty one is a way of masking it
	for i in "${subvol:1}"/usr/kernel/cmdline.d/* /usr/kernel/cmdline.d/*; do
		[ -e "${subvol:1}/etc/kernel/cmdline.d/$(basename "$i")" ] && continue
		[ -s "$i" ] || continue
		echo "$i"
		found=1
	done

	for i in "${subvol:1}"/etc/kernel/cmdline.d/* /etc/kernel/cmdline.d/*; do
		[ -s "$i" ] || continue
		echo "$i"
		found=1
	done

	[ -n "$found" ]
}

create_boot_options() {
	local subvol="$1"
	local cmdline
	local boot_options

	local cmdline_files=()
	mapfile -t cmdline_files < <(kernel_cmdline_files "$subvol")

	local i
	for i in "${cmdline_files[@]}"; do
		dbg_cat "$i"
		while read -r line; do
			[[ "$line" == '#'* ]] && continue
			cmdline="${cmdline:+$cmdline }${line}"
		done < "$i"
	done

	# Now that "/proc/cmdline" is not always a valid fallback there
	# can be no command line at all.  Generate the options anyway:
	# "sedrootflags" adds "root=" and "rootflags=", which are the
	# minimum required to boot the system
	boot_options="$(echo "$cmdline" | sedrootflags "$subvol")"
	echo "${boot_options# }"
}

install_kernel()
{
	local snapshot="$1"
	local subvol=""
	[ -z "$have_snapshots" ] || subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot"
	local kernel_version="$2"
	local dstinitrd=()
	local src="${subvol#"${subvol_prefix}"}/lib/modules/$kernel_version/$image"
	[ -n "$kernel_version" ] || err "Missing kernel version"
	[ -e "$src" ] || err "Can't find $src"

	info "Installing kernel $kernel_version"
	dbg_var "snapshot"

	# The RPM triggers install the kernel while the system is still
	# being installed, before the installer wrote
	# "/etc/kernel/cmdline".  The entry created now would carry a
	# command line that is not the one of the installed system, and
	# the initrd would be generated from a system that is not yet
	# configured.  Skip the installation: the installer calls
	# "add-all-kernels" once the system is set up.  An empty ESP is
	# what tells this situation apart from a configured system where
	# the administrator removed "/etc/kernel/cmdline"
	#
	# The subvolume is resolved like the one used later to generate
	# the options, so both look at the same files
	local cmdline_subvol="$subvol"
	[ -z "$in_buildroot" ] || cmdline_subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot"
	[ -n "$arg_force" ] || kernel_cmdline_files "$cmdline_subvol" > /dev/null || ! no_boot_entries || {
		# Building an image is a single pass: nothing will
		# install the kernel later, so a missing command line
		# is a configuration error and not a transient state
		[ -z "$in_buildroot" ] || err "No kernel command line configured for $kernel_version"
		warn "The system seems to be not fully configured (no kernel command line)"
		warn "Kernel $kernel_version will not be installed"
		return 0
	}

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Installing kernels must be done outside the transaction"
		return 0
	}

	calc_chksum "$src"
	settle_entry_token "${snapshot}"
	local dst="/$entry_token/$kernel_version/linux-$chksum"
	local devicetree_src=
	local devicetree_dst=

	local conf
	conf="$(find_conf_file "$kernel_version" "$snapshot")" || conf=
	[ -z "$conf" ] \
		|| ! entry_is_complete "$conf" "$dst" \
		|| [ -n "$arg_no_reuse_initrd" ] \
		|| [ -n "$arg_force" ] \
		|| {
		info "Kernel $kernel_version already in the ESP"
		return 0
	}

	local initrd="${src%/*}/initrd"

	# Resolve the device tree before creating any ESP directory, so a
	# missing (but configured) blob aborts without leaving an empty
	# "$entry_token/$kernel_version" directory behind
	devicetree_src="$(resolve_devicetree_source "$subvol" "$kernel_version")" || err "Failed to resolve DEVICETREE_SOURCE"
	if [ -n "$devicetree_src" ]; then
		! is_secure_boot || err "External devicetree files are not supported when Secure Boot is enabled"
		calc_chksum "$devicetree_src"
		devicetree_dst="${dst%/*}/devicetree-$chksum.dtb"
	fi

	if [ -e "$initrd" ]; then
		ln -s "$initrd" "$tmpdir/initrd-0"
	elif ! reuse_initrd "$snapshot" "$subvol" "$kernel_version"; then
		local dracut_args=(
			'--reproducible'
			'--force'
			'--tmpdir' '/var/tmp'
		)
		if [ "${verbose:-0}" -le 1 ]; then
			dracut_args+=('--quiet')
		fi

		info "Generating new initrd"

		# When the running system is a mix of snapshots the live
		# /usr is not the one of "$root_subvol", so the initrd
		# needs to be generated inside the snapshot in any case
		if [ -n "$have_snapshots" ] && { [ "$subvol" != "$root_subvol" ] || [ -n "$applied_snapshot" ]; }; then
			mount_chroot "${subvol#"${subvol_prefix}"}"
			# In MicroOS we need to be sure to have the same /etc
			# inside the snapshot.  For example, /etc/crypttab can
			# have modifications in the overlay that will be
			# visible once the snapshot is active, but the version
			# in /.snapshots is still the unmodified base
			is_transactional && mount_etc "${subvol#"${subvol_prefix}"}"
			chroot "${subvol#"${subvol_prefix}"}" dracut "${dracut_args[@]}" "$tmpdir/initrd-0" "$kernel_version" \
				|| err "Failed to generate the initrd for $kernel_version"
			umount_chroot "${subvol#"${subvol_prefix}"}"
		else
			dracut "${dracut_args[@]}" "$tmpdir/initrd-0" "$kernel_version" \
				|| err "Failed to generate the initrd for $kernel_version"
		fi
	fi

	make_free_space_for_kernel "$snapshot" "$src" "$tmpdir" "$devicetree_src" || err "No free space in ${boot_root} for new kernel"

	local boot_options
	[ -z "$in_buildroot" ] || subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot"
	boot_options="$(create_boot_options "$subvol")"

	if [ "${#dstinitrd[@]}" -eq 0 ] && [ -e "$tmpdir/initrd-0" ]; then
		i=0
		while [ -e "$tmpdir/initrd-$i" ]; do
			calc_chksum "$tmpdir/initrd-$i"
			dstinitrd+=("${dst%/*}/initrd-$chksum")
			((++i))
		done
	fi

	# A boot entry without an initrd will not boot.  This can only
	# happen if the initrd generation failed in a context where
	# `set -e` does not apply, like a command substitution
	[ "${#dstinitrd[@]}" -ne 0 ] || err "No initrd found for $kernel_version in snapshot $snapshot"

	title="${os_release_PRETTY_NAME:-Linux $kernel_version}"
	# shellcheck disable=SC2154
	sort_key="$os_release_ID"

	add_version_to_title
	if is_transactional && [ "$snapshot" = 1 ]; then
		title="${title} (Initial Installation)"
	elif ! is_transactional && subvol_is_ro "$subvol"; then
		set_snapper_title_and_sortkey "$snapshot"
	fi

	local entry_machine_id=
	[ "$entry_token" = "$machine_id" ] && entry_machine_id="$machine_id"

	cat > "$tmpdir/entry.conf" <<-EOF
	# Boot Loader Specification type#1 entry
	title      $title
	version    ${snapshot:+$snapshot@}$kernel_version${entry_machine_id:+${nl}machine-id $entry_machine_id}${sort_key:+${nl}sort-key   $sort_key}
	options    $boot_options
	linux      $dst${devicetree_dst:+${nl}devicetree ${devicetree_dst}}
	EOF
	for i in "${dstinitrd[@]}"; do
		echo "initrd     $i" >> "$tmpdir/entry.conf"
	done
	dbg "Generated new boot entry"
	dbg_cat "$tmpdir/entry.conf"

	mkdir -p "${boot_root}${dst%/*}"

	local failed=
	if [ ! -e "${boot_root}$dst" ]; then
		install_with_rollback "$src" "${boot_root}$dst" || failed=kernel
	else
		info "Reusing ${boot_root}$dst"
	fi
	if [ -z "$failed" ] && [ -n "$devicetree_src" ]; then
		if [ ! -e "${boot_root}${devicetree_dst}" ]; then
			install_with_rollback "$devicetree_src" "${boot_root}${devicetree_dst}" || failed=devicetree
		else
			info "Reusing ${boot_root}${devicetree_dst}"
		fi
	fi
	if [ -z "$failed" ] && [ -e "$tmpdir/initrd-0" ]; then
		i=0
		while [ -e "$tmpdir/initrd-$i" ]; do
			if [ ! -e "${boot_root}${dstinitrd[$i]}" ]; then
				install_with_rollback "$tmpdir/initrd-$i" "${boot_root}${dstinitrd[$i]}" || { failed=initrd; break; }
				rm -f "$tmpdir/initrd-$i"
			fi
			((++i))
		done
	fi
	if [ -z "$failed" ]; then
		local tries
		if [ -f /etc/kernel/tries ]; then
			read -r tries < /etc/kernel/tries
		fi

		if ! [[ "$tries" =~ ^[0-9]+$ ]]; then
			tries=
		fi

		# The directory can be missing if the user removed it,
		# or if the kernel is installed before the bootloader.
		# Note that `find` fails when the directory is not
		# present, and `install` does not create it
		mkdir -p "${boot_root}/loader/entries"

		# The entry can be already present with or without
		# boot counter.  In any case, if a new `initrd` was
		# created with a different `sha1`, then this boot
		# entry needs to be replaced, but under the new name
		# (with the boot counter configured in
		# `/etc/kernel/tries`)
		#
		# This replacement is done to avoid duplication.
		# Hopefully doing this check this late give the chance
		# of adding the kernel or initrd in case that the user
		# removes them
		local entry_name loader_entry loader_entry_current
		entry_name="$(entry_conf_file "$kernel_version" "$snapshot")"
		loader_entry="${boot_root}/loader/entries/$(entry_conf_file "$kernel_version" "$snapshot" "$tries")"
		# Only the boot counter can differ ("$entry+3.conf" and
		# "$entry+3-0.conf").  A trailing "*" would also match a
		# different snapshot, as the number is not delimited
		# (snapshot 1 matches 10, 11, ...), and the entry of that
		# other snapshot will be silently replaced
		loader_entry_current="$(find "${boot_root}/loader/entries" -maxdepth 1 -type f \
			\( -name "$entry_name" -o -name "${entry_name%.conf}+*" \) -print -quit)"
		install_with_rollback "$tmpdir/entry.conf" "$loader_entry" "$loader_entry_current" || failed="bootloader entry"
		rm -f "$tmpdir/entry.conf"
	fi
	[ -z "$failed" ] || err "Failed to install $failed"
	reset_rollback

	# Do a final cleanup, as sometimes we are replacing an old
	# initrd
	bootctl -q cleanup 2> /dev/null

	# This action will require to update the PCR predictions
	update_predictions=1
}

install_all_kernels()
{
	local snapshot="$1"

	info "Installing all kernels"
	dbg_var "snapshot"
	dbg_var "arg_no_reuse_initrd"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Installing kernels / initrds must be done outside the transaction"
		return 0
	}

	find_kernels "$snapshot"
	for kv in "${!found_kernels[@]}"; do
		install_kernel "${snapshot}" "$kv"
	done
}

remove_all_kernels()
{
	local snapshot="$1"

	info "Removing all kernels"
	dbg_var "snapshot"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Removing kernels must be done outside the transaction"
		return 0
	}

	find_kernels "$snapshot"
	for kv in "${!found_kernels[@]}"; do
		remove_kernel "${snapshot}" "$kv"
	done
}

cleanup_entries()
{
	local snapshot="$1"

	info "Cleaning up boot entries"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Cleaning up boot entries must be done outside the transaction"
		return 0
	}

	if [ ! -s "$entryfile" ]; then
		if [ -n "$snapshot" ]; then
			update_entries_for_snapshot "$snapshot"
		else
			update_entries_for_this_system
		fi
	fi

	local id path snapshot kernel_version subvol src
	while read -r id; do
		read -r path
		read -r snapshot
		read -r kernel_version
		subvol=
		if [ -n "$snapshot" ] && [ "$snapshot" != "null" ]; then
			subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot"
		fi
		src="${subvol#"${subvol_prefix}"}/lib/modules/$kernel_version/$image"
		[ -e "$src" ] || {
			info "Cleaning boot entry $id"
			rm "$path"
		}
	done < <(jq -r '.[] | .id, .path, (.version | capture("((?<snapshot>[0-9]*)@)?(?<kernel>.*)")) as $v | $v.snapshot, $v.kernel' "$entryfile")

	dbg "Calling bootctl cleanup"
	bootctl -q cleanup 2> /dev/null

	# Guarantee ESP_FREE_SPACE.  Not being able to reach it is not
	# an error here: nothing is going to be installed
	make_free_space "${1:-$root_snapshot}" "0" || warn "Can't free space in ${boot_root}"
}

list_entries()
{
	local snapshot="$1"

	info "Listing boot entries"

	if [ ! -s "$entryfile" ]; then
		if [ -n "$snapshot" ]; then
			update_entries_for_snapshot "$snapshot"
		elif [ -n "$arg_all_entries" ]; then
			update_entries
		else
			update_entries_for_this_system
		fi
	fi

	# `bootctl` builds "isSelected" by matching the entry ID against
	# "LoaderEntrySelected", but grub2-bls writes the variable without
	# the ".conf" suffix, so nothing matches there and "isSelected" is
	# always null.  Read the variable to compare it here too, both
	# spellings, until grub2-bls is fixed.  Note that the value is
	# lower-cased by `bli_efi_var_get`
	local selected=
	[ -z "$interactive" ] || selected="$(bli_efi_var_get "LoaderEntrySelected")"

	local isdefault isselected isreported type id root conf title marker booted
	while read -r isdefault isselected isreported type id root conf title; do
		color=
		if [ "$isdefault" = "true" ]; then
			color="$color_bu"
		elif [ -n "$arg_only_default" ]; then
			continue
		fi
		# The entry that the boot loader reported as booted is
		# not necessarily the default one, so both are marked
		# separately.  The marker is kept out of the highlight,
		# as "list_snapshots" does
		marker=
		if [ -n "$interactive" ]; then
			booted=
			if [ "$isselected" = "true" ] || [ "${id,,}" = "$selected" ] \
			    || [ "${id,,}" = "$selected.conf" ]; then
				booted=1
			fi
			if [ "$isdefault" = "true" ]; then
				marker="+"
				[ -z "$booted" ] || marker="*"
			elif [ -n "$booted" ]; then
				marker="-"
			fi
		fi
		if [ "$isreported" = "false" ]; then
			color="$color${color_green}"
		fi
		if [ "$type" = "loader" ]; then
			color="$color${color_yellow}"
		fi

		local errors=()
		if [ -n "$verbose" ] && [ -n "$conf" ]; then
			local v
			while read -r v; do
				if [ ! -e "$root$v" ]; then
					errors+=("$root/$v does not exist")
				fi
			done < <(entry_files "$conf")
			local snapshot
			while [ -n "$have_snapshots" ] && read -r v; do
				# shellcheck disable=SC2001
				snapshot="$(echo "$v" | sed -e "s,.*rootflags=subvol=${subvol_prefix}/.snapshots/\([0-9]\+\)/snapshot.*,\1,")"
				if [ ! -d "/.snapshots/$snapshot/snapshot" ]; then
					errors+=("/.snapshot/$snapshot/snapshot does not exist")
				fi
			done < <(entry_field "$conf" options)
		fi
		if [ "${#errors[@]}" -gt 0 ]; then
			echo -e "  ${color_red}${errors[*]}${color_end}" >&2
		fi
		echo -e "$marker$color$id${verbose:+: $title}${color:+$color_end}"
	done < <(jq '.[]|[.isDefault, .isSelected // false, if has("isReported") then .isReported else 0 end, if has("type") then .type else "unknown" end, .id, .root, .path, .showTitle]|join(" ")' -r < "$entryfile")
}

show_entry_fields()
{
	local snapshot="$1"
	local kernel_version="$2"
	[ -n "$kernel_version" ] || err "Missing kernel version"
	settle_entry_token "${snapshot}"
	local id
	id="$(entry_conf_file "$kernel_version" "$snapshot")"

	local conf
	# Without collecting the status here `set -e` ends the script
	# with an empty message when there is no entry for the kernel
	conf="$(find_conf_file "$kernel_version" "$snapshot")" || \
		err "Missing boot entry for kernel $kernel_version"

	[ -z "$verbose" ] || echo -e "ID\t$id"
	local k
	local v
	while read -r k v; do
		case "$k" in
			title|version|sort-key|options|linux|initrd|devicetree) ;;
			*) continue ;;
		esac

		if [ "${#arg_entry_keys[@]}" -eq 0 ] || [[ ${arg_entry_keys[*]} == *"all"* ]] || [[ ${arg_entry_keys[*]} == *"$k"* ]]; then
			echo -e "$k\t$v"
		fi
	done < <(entry_fields "$conf")
}

update_entry_conf()
{
	local conf="$1"
	local snapshot="$2"

	local subvol=""
	[ -z "$have_snapshots" ] || subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot"

	local boot_options
	boot_options="$(create_boot_options "$subvol")"

	cp "$conf" "$tmpdir/entry.conf"
	sed -i "s|^options\s*.*$|options    $boot_options|g" "$tmpdir/entry.conf"
	cp "$tmpdir/entry.conf" "$conf"
}

update_entry()
{
	local snapshot="$1"
	local kernel_version="$2"
	[ -n "$kernel_version" ] || err "Missing kernel version"

	settle_entry_token "${snapshot}"
	local id
	id="$(entry_conf_file "$kernel_version" "$snapshot")"

	local conf
	# A missing entry is not an error, but `set -e` would end the
	# script before the check below
	conf="$(find_conf_file "$kernel_version" "$snapshot")" || :
	[ -f "$conf" ] || return 0

	info "Updating boot entry $id"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Updating boot entries must be done outside the transaction"
		return 0
	}

	update_entry_conf "$conf" "$snapshot"

	# This action will require to update the PCR predictions
	update_predictions=1
}

update_all_entries()
{
	local snapshot="$1"

	info "Updating all boot entries"

	settle_entry_token "${snapshot}"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Updating boot entries must be done outside the transaction"
		return 0
	}

	make_free_space "$snapshot" 1024 || warn "Can't free space in ${boot_root}"

	if [ -n "$snapshot" ]; then
		update_entries_for_snapshot "$snapshot"
	else
		update_entries_for_this_system
	fi

	while read -r conf; do
		update_entry_conf "$conf" "$snapshot"
	done < <(jq -r '.[]|.path' < "$entryfile")

	# This action will require to update the PCR predictions
	update_predictions=1
}

list_snapshots()
{
	[ -n "$have_snapshots"  ] || { info "System does not support snapshots."; return 0; }
	[ -s "$snapperfile" ] || update_snapper 2>"$tmpfile" || err "$(cat "$tmpfile")"

	info "Listing snapshots"

	local n=0
	local marker
	while read -r n isdefault title; do
		[ "$n" != "0" ] || continue
		local id="$n"
		if [ "$isdefault" = "true" ]; then
			id="$color_bu$id$color_end"
		fi
		update_kernels "$n"
		[ "$is_bootable" = 1 ] || id="!$id"
		# The snapshot the system is running from is not
		# necessarily the default one, so both are marked
		# separately
		marker=
		if [ -n "$interactive" ]; then
			if [ "$isdefault" = "true" ]; then
				marker="+"
				[ "$n" != "$running_snapshot" ] || marker="*"
			elif [ "$n" = "$running_snapshot" ]; then
				marker="-"
			fi
		fi
		echo -e "$marker$id $title"
	done < <(jq '.root|.[]|[.number, .default, .description]|join(" ")' -r < "$snapperfile")
}

calc_chksum()
{
	# shellcheck disable=SC2046
	set -- $(sha1sum "$1")
	chksum="$1"
}

# map with kernel version as key and checksum as value
declare -A found_kernels
find_kernels()
{
	local subvol=""
	[ -z "$have_snapshots" ] || subvol="${subvol_prefix}/.snapshots/${1:?}/snapshot"
	local fn kv
	found_kernels=()

	for fn in "${subvol#"${subvol_prefix}"}"/usr/lib/modules/*/"$image"; do
		kv="${fn%/*}"
		kv="${kv##*/}"
		calc_chksum "$fn"
		found_kernels["$kv"]="$chksum"
		info "Found kernel $kv = $chksum"
	done
}

# Map that uses expected path on the ESP for each installed kernel as
# key.  The value is the entry id if an entry exists.
declare -A installed_kernels
# Map of ESP path to id of kernels that are not in the subvol
declare -A stale_kernels
is_bootable=
update_kernels()
{
	local snapshot="$1"
	local path id
	installed_kernels=()
	stale_kernels=()
	is_bootable=
	find_kernels "$snapshot"
	settle_entry_token "${snapshot}"
	for kv in "${!found_kernels[@]}"; do
		installed_kernels["/$entry_token/$kv/linux-${found_kernels[$kv]}"]=''
	done
	if [ -n "$snapshot" ]; then
		update_entries_for_snapshot "$snapshot"
	else
		update_entries_for_this_system
	fi

	# XXX: maybe we should parse the actual path in the entry
	while read -r path id; do
		if [ "${installed_kernels[$path]+none}" = 'none' ]; then
			installed_kernels["$path"]="$id"
			is_bootable=1
		else
			# kernel in ESP that is not installed
			stale_kernels["$path"]="$id"
		fi
	done < <(jq -r '.[]|select(has("linux"))|[.linux,.id]|join(" ")'< "$entryfile")
}

list_kernels()
{
	local snapshot=""
	[ -z "$have_snapshots" ] || snapshot="${1:?}"

	info "Listing kernels"

	update_kernels "$snapshot"
	local kernelfiles=("${!installed_kernels[@]}")
	for k in "${kernelfiles[@]}"; do
		local id="${installed_kernels[$k]}"
		local kv="${k%/*}"
		kv="${kv##*/}"
		if [ -z "$id" ]; then
			echo -e "${color_yellow}missing /lib/modules/$kv/$image${color_end}"
		else
			echo -e "${color_green}ok /lib/modules/$kv/$image -> $id${color_end}"
		fi
	done
	kernelfiles=("${!stale_kernels[@]}")
	for k in "${kernelfiles[@]}"; do
		local id="${stale_kernels[$k]}"
		printf "${color_red}stale %s${color_end}\n" "$id"
	done
}

list_devices()
{
	info "Listing devices"

	detect_tracked_devices

	for dev in "${tracked_devices[@]}"; do
		echo "$dev"
	done
}

is_shim_installed()
{
	# Check if shim is installed in the ESP
	# In installed systems there should be a shim.efi
	[ ! -f "${esp_root}${esp_dst}/shim.efi" ] || return 0
	# In removable media there is only BOOT${arch}.EFI
	! grep -q "UEFI SHIM" "${esp_root}/EFI/BOOT/BOOT${firmware_arch^^}.EFI" 2> /dev/null || return 0
	return 1
}

is_bootable()
{
	local snapshot="$1"
	update_kernels "$snapshot"

	[ "$is_bootable" = 1 ] || return 1
	return 0
}

bootloader_version()
{
	local fn="$1"
	if [ -z "$1" ]; then
		if is_shim_installed; then
			fn="${esp_root}${esp_dst}/grub.efi"
		elif [ -n "$arg_portable" ]; then
			fn="${esp_root}/EFI/BOOT/BOOT${firmware_arch^^}.EFI"
		else
			local bootloader
			bootloader="$(find_bootloader)"
			fn="${esp_root}${esp_dst}/${bootloader##*/}"
		fi
	fi
	[ -e "$fn" ] || return 1
	if is_sdboot; then
		read -r _ _ _ v _ < <(grep -ao '#### LoaderInfo: systemd-boot [^#]\+ ####' "$fn")
	else
		# Useless as it reports mayor.minor, so append the
		# last update time until the minutes, as the FAT store
		# dates differently than other filesystems
		read -r _ _ _ v _ < <(grep -aoP 'GNU GRUB  version %s\x00+[^\x00]+\x00' "$fn")
		v="${v:2}-$(date -r "$fn" +'%Y%m%d%H%M')"
	fi
	[ -n "$v" ] || return 1

	dbg "Bootloader version $v"

	echo "$v"
}

is_installed()
{
	info "Checking if the bootloader is installed"
	bootloader_version > /dev/null && [ -e "${esp_root}/${esp_dst}/installed_by_sdbootutil" ]
}

find_sdboot()
{
	local prefix=""
	[ -z "$have_snapshots" ] || prefix="/.snapshots/${1-$root_snapshot}/snapshot"
	# XXX: this is a hack in case we need to inject a signed
	# systemd-boot from a separate package
	local sdboot="$prefix/usr/lib/systemd-boot/systemd-boot$firmware_arch.efi"
	[ -e "$sdboot" ] || sdboot="$prefix/usr/lib/systemd/boot/efi/systemd-boot$firmware_arch.efi"
	echo "$sdboot"
}

find_grub2_bls()
{
	local prefix=""
	[ -z "$have_snapshots" ] || prefix="/.snapshots/${1-$root_snapshot}/snapshot"

	local grub2_arch
	grub2_arch="$(uname -m)"
	case "$grub2_arch" in
		i[[3456]]86) grub2_arch=i386 ;;
		x86_64) grub2_arch=x86_64 ;;
		amd64) grub2_arch=x86_64 ;;
		sparc) grub2_arch=sparc64 ;;
		mipsel|mips64el) grub2_arch=mipsel ;;
		mips|mips64) grub2_arch=mips ;;
		arm*) grub2_arch=arm ;;
		aarch64*) grub2_arch=arm64 ;;
		loongarch64) grub2_arch=loongarch64 ;;
		riscv32*) grub2_arch=riscv32 ;;
		riscv64*) grub2_arch=riscv64 ;;
	esac

	# The old grub.efi will contain the BLS patches, but we cannot
	# use it because we also dropped the process of creating the
	# configuration file and installing bli.mod
	echo "$prefix/usr/share/grub2/${grub2_arch}-efi/grubbls.efi"
}

find_bootloader()
{
	if is_sdboot "${1-$root_snapshot}"; then
		find_sdboot "${1-$root_snapshot}"
	elif is_grub2_bls "${1-$root_snapshot}"; then
		find_grub2_bls "${1-$root_snapshot}"
	else
		err "Bootloader not detected"
	fi
}

bootloader_needs_update()
{
	local prefix=""
	local snapshot=""
	if [ -n "$have_snapshots" ]; then
		snapshot="${1-$root_snapshot}"
		prefix="/.snapshots/${snapshot}/snapshot"
	fi

	info "Checking if bootloader needs update"

	local bldr_name
	local v nv
	# The status of the next commands is checked below, so it must
	# be collected in the same line: `set -e` would end the script
	# before the check, and without any message
	v="$(bootloader_version)" || :
	[ -n "$v" ] || return 1
	info "Deployed version $v"
	nv="$(bootloader_version "$(find_bootloader "$snapshot")")" || :
	[ -n "$nv" ] || return 1
	info "System version $nv"
	local status=0
	systemd-analyze compare-versions "$v" "$nv" 2> /dev/null || status="$?"
	bldr_name=$(bootloader_name "$snapshot")
	if [ "$status" = "11" ]; then
		info "$bldr_name is newer than system bootloader"
		return 2
	elif [ "$status" = "12" ]; then
		info "$bldr_name needs to be updated"
		return 0
	fi
	info "$bldr_name is already up-to-date"
	return 1
}

esp_root_drive_partno_dseq()
{
	local blkpart drive disk_seq_min
	blkpart="$(findmnt -nvo SOURCE "$esp_root")"
	drive="${blkpart##*/}"
	lsblk -o NAME,PKNAME,PARTN,DISK-SEQ --json > "$devicefile"
	# Read the json file with all the devices and, recursively,
	# inspect the childrens looking for the partition that mounts
	# /boot/efi.  Once it is found gets the partition number and
	# the (kernel) name of the parent.
	#
	# Use DISK-SEQ to guess the ordering of devices that GRUB2
	# will see during boot (hdX,partY).  I saw that this value not
	# always start with 0 for the kernel, but with 0 in GRUB2, so
	# rebase it using the minimal found value.
	disk_seq_min="$(jq -r '[.. | ."disk-seq"? | numbers] | min' < "$devicefile")"
	jq -r --arg name "$drive" --arg base "$disk_seq_min" \
	   'first(
	     .blockdevices[]
	     | recurse(.children[]?)
	     | select(.children? | arrays | map(.name == $name) | any)
	     | {
		 "partn": .children[] | select(.name == $name).partn | tostring,
		 "name": .name,
		 "pkname": .pkname,
		 "disk-seq": ."disk-seq" - ($base | tonumber) | tostring
	       })
	   | if .pkname then "/dev/" + .pkname + " " + .partn + " " + ."disk-seq" else "/dev/" + .name + " " + .partn + " " + ."disk-seq" end
	   ' < "$devicefile"
}

extra_entry_needs_update()
{
	local entry="$1"
	local nv v

	# If we do not have a version in the entry file (or the entry
	# file is missing), then we assume that an update is required
	[ -n "$entry" ] || return 0
	nv="$(entry_field "$entry" version)"
	[ -n "$nv" ] || return 0

	entry="${boot_root}/loader/entries/$(basename "$entry")"
	v="$(entry_field "$entry" version)"
	[ -n "$v" ] || return 0

	systemd-analyze compare-versions "$v" "<" "$nv" 2> /dev/null
}

install_extra_entry()
{
	local subvol="$1"
	local entry="$2"

	local -A e
	local -a l
	while read -r line; do
		[[ "$line" == '#'* ]] && continue
		IFS="=" read -r -a l <<<"$line"
		[ "${#l[@]}" -eq 2 ] || err "Cannot parse line $line"
		e["${l[0]}"]="${l[1]}"
	done < "$entry"

	[ -n "${e[EFI]}" ] || return 0
	[ -f "${subvol:1}${e[EFI]}" ] || err "Missing extra entry file ${e[EFI]}"
	[ -z "${e[ENTRY]}" ] || [ -f "${subvol:1}${e[ENTRY]}" ] || err "Missing extra entry file ${e[ENTRY]}"

	# If the file can be installed, echo the name so it can be
	# registered as installed (even if does not needs to be
	# updated)
	echo "/EFI/extra/$(basename "${e[EFI]}")"

	extra_entry_needs_update "${subvol:1}${e[ENTRY]}" || return 0

	mkdir -p "${esp_root}/EFI/extra"
	install -p -D "${subvol:1}${e[EFI]}" "${esp_root}/EFI/extra"
	[ -z "${e[ENTRY]}" ] || install -p -D "${subvol:1}${e[ENTRY]}" "${boot_root}/loader/entries"
}

install_extra_entries()
{
	local snapshot="$1"

	local subvol=""
	[ -z "$have_snapshots" ] || subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot"

	update_entries_for_extra

	local efi install_extra_entry_status
	local -A entries
	for i in "${subvol:1}"/usr/lib/sdbootutil/entries.d/*.conf; do
		[ -e "${subvol:1}/etc/sdbootutil/entries.d/$(basename "$i")" ] && continue
		dbg_cat "$i"
		# A broken entry is reported and skipped, but `set -e`
		# would end the script before the status is checked
		install_extra_entry_status=0
		efi="$(install_extra_entry "$subvol" "$i")" || install_extra_entry_status=$?
		[ "$install_extra_entry_status" -eq 0 ] || {
			warn "Error installing extra entry $(basename "$i")"
		}
		[ -z "$efi" ] || entries["$efi"]=1
	done

	for i in "${subvol:1}"/etc/sdbootutil/entries.d/*.conf; do
		dbg_cat "$i"
		install_extra_entry_status=0
		efi="$(install_extra_entry "$subvol" "$i")" || install_extra_entry_status=$?
		[ "$install_extra_entry_status" -eq 0 ] || {
			warn "Error installing extra entry $(basename "$i")"
		}
		[ -z "$efi" ] || entries["$efi"]=1
	done

	# Remove the entry + efi pairs not installed
	local entry
	while read -r efi; do
		read -r entry
		[ -n "${entries[$efi]}" ] || {
			rm -f "${esp_root}/EFI${efi}"
			rm -f "$entry"
		}
	done < <(jq -r '.[] | .linux // .efi, .path' < "$entryfile")

	# Finally, remove the single efi files not installed
	for i in "${esp_root}"/EFI/extra/*; do
		efi="${i#"${esp_root}"}"
		[ "${entries[$efi]:-0}" -eq 1 ] || rm -f "${esp_root}${efi}"
	done

	# This action will require to update the PCR predictions
	update_predictions=1
}

install_bootloader()
{
	local snapshot=""
	local prefix=""
	if [ -n "$have_snapshots" ]; then
		snapshot="${1:-$root_snapshot}"
		# The binaries are taken from the same snapshot that
		# `bootloader_needs_update` inspected, that is not
		# necessarily the running one
		prefix="/.snapshots/${snapshot}/snapshot"
	fi

	info "Installing bootloader"
	dbg_var "$snapshot"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Installing bootloader must be done outside the transaction"
		return 0
	}

	local bootloader bldr_name
	settle_entry_token "${snapshot}"

	bootloader=$(find_bootloader "$snapshot")
	bldr_name=$(bootloader_name "$snapshot")
	dbg_var "bootloader"
	dbg_var "bldr_name"

	mkdir -p "${esp_root}/loader"
	mkdir -p "${boot_root}/loader/entries"

	if [ -n "$arg_secure_boot" ] && [ ! -e "$prefix$shimdir/shim.efi" ]; then
		warn "A secure boot installation cannot be done. The shim package is not installed"
	fi

	if [ -n "$arg_secure_boot" ] && [ -e "$prefix$shimdir/shim.efi" ]; then
		info "Installing $bldr_name with shim into $esp_root"
		entry="$esp_dst/shim.efi"
		for i in MokManager shim; do
			[ -n "$arg_portable" ] || install -p -D "$prefix$shimdir/$i.efi" "${esp_root}${esp_dst}/$i.efi"
		done
		install -p -D "$bootloader" "${esp_root}${esp_dst}/grub.efi"

		# boot entry point
		install -p -D "$prefix$shimdir/MokManager.efi" "${esp_root}/EFI/BOOT/MokManager.efi"
		[ -n "$arg_portable" ] || install -p -D "$prefix$shimdir/fallback.efi" "${esp_root}/EFI/BOOT/fallback.efi"
		install -p -D "$prefix$shimdir/shim.efi" "${esp_root}/EFI/BOOT/BOOT${firmware_arch^^}.EFI"
	else
		info "Installing $bldr_name without shim into $esp_root"
		entry="$esp_dst/${bootloader##*/}"
		[ -n "$arg_portable" ] || install -p -D "$bootloader" "${esp_root}$entry"
		install -p -D "$bootloader" "${esp_root}/EFI/BOOT/BOOT${firmware_arch^^}.EFI"
	fi

	# shellcheck disable=SC2154
	[ -n "${os_release_NAME}" ] || set_os_release "${snapshot}"
	local split
	IFS=" " read -r -a split <<<"${os_release_NAME}"
	local boot_manager
	boot_manager="${split[0]} Boot Manager"

	# This is for shim to create the entry if missing
	[ -n "$arg_portable" ] || echo "${entry##*/},$boot_manager" | { echo -ne "\xff\xfe"; iconv -f ascii -t ucs-2le; } > "${esp_root}${esp_dst}/boot.csv"

	mkdir -p "${boot_root}/${entry_token}"
	echo "$entry_token" > "${esp_root}${esp_dst}/installed_by_sdbootutil"

	mkdir -p "/etc/kernel"
	[ -s /etc/kernel/entry-token ] || echo "$entry_token" > /etc/kernel/entry-token
	update_random_seed

	if is_sdboot "$snapshot"; then
		[ -e "${esp_root}/loader/loader.conf" ] || echo -e "#timeout 3\n#console-mode keep\n" > "${esp_root}/loader/loader.conf"
		[ -s "${boot_root}/loader/entries.srel" ] || echo type1 > "${boot_root}/loader/entries.srel"
	fi

	# Create boot menu entry if it does not exist
	if [ -z "$arg_no_variables" ] && [ -z "$arg_portable" ] && mountpoint -q "$esp_root"; then
		local drive partno
		read -r drive partno _ < <(esp_root_drive_partno_dseq)

		local escaped_entry="${entry//\//\\\\}"
		efibootmgr | grep -q "Boot.*${boot_manager}.*${escaped_entry}" || efibootmgr -q --create --disk "$drive" --part "$partno" --label "${boot_manager} ($bldr_name)" --loader "$entry" || true

		# Make it the first option.  A system that never booted
		# from disk can have no `BootOrder` at all
		local boot_order
		boot_order="$(efibootmgr | grep BootOrder)" || :
		boot_order="${boot_order#BootOrder: }"

		# There can be more than one match, for example a stale
		# entry left behind by a previous installation.
		# `efibootmgr` expects a single ID, so take the first
		# active one (the `*` marker), as an inactive entry is
		# never booted by the firmware
		local boot_entry
		boot_entry="$(efibootmgr | grep -m1 "Boot[0-9A-Fa-f]\{4\}\*.*${boot_manager}.*${escaped_entry}")" || :
		# The ID is printed as `BootXXXX`
		boot_entry="${boot_entry:4:4}"

		[ -z "$boot_entry" ] || efibootmgr -q -D -o "$boot_entry${boot_order:+,$boot_order}" || true
	fi

	# This action will require to update the PCR predictions
	update_predictions=1
}

bootloader_update()
{
	local status=0

	info "Updating bootloader"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Updating bootloader must be done outside the transaction"
		return 0
	}

	bootloader_needs_update "${1:-$root_snapshot}" || status=$?
	if [ $status -eq 0 ]; then
		info "The bootloader needs to be updated"
		install_bootloader "${1:-$root_snapshot}"
	elif [ -n "$arg_sync" ] && [ $status -eq 2 ]; then
		info "The bootloader will be downgraded"
		install_bootloader "${1:-$root_snapshot}"
	fi

	install_extra_entries "${1:-$root_snapshot}"
}

hex_to_binary()
{
	local s="$1"
	local i
	for ((i=0;i<${#s};i+=2)); do echo -ne "\x${s:$i:2}"; done
}

update_random_seed()
{
	[ -z "$arg_no_random_seed" ] || return 0
	local s _p
	read -r s _p < <({ dd if=/dev/urandom bs=32 count=1 status=none; [ -e "${esp_root}/loader/random-seed" ] && dd if="${esp_root}/loader/random-seed" bs=32 count=1 status=none; } | sha256sum)
	[ "${#s}" = 64 ] || { warn "Invalid random seed"; return 0; }
	hex_to_binary "$s" > "${esp_root}/loader/random-seed.new"
	mv "${esp_root}/loader/random-seed.new" "${esp_root}/loader/random-seed"
}

has_efivars()
{
	[ -w /sys/firmware/efi/efivars ]
}

bli_efi_var_get()
{
	# BLI uses this vendor UUID
	local efi_var="/sys/firmware/efi/efivars/${1:?}-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
	[ ! -e "$efi_var" ] || dd "if=$efi_var" bs=2 skip=2 conv=lcase status=none | tr -d '\0'
}

bli_efi_var_set()
{
	# BLI uses this vendor UUID
	local efi_var="/sys/firmware/efi/efivars/${1:?}-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
	local value="${2:?}"
	has_efivars || return 1
	[ -e "$efi_var" ] && chattr -i "$efi_var"
	echo -ne "\x07\x00${value}\x00" | iconv -t UTF-16LE > "$efi_var"
	chattr +i "$efi_var"
}

loader_conf_set()
{
	local key="${1:?}"
	local value="${2:?}"

	[ -e "${esp_root}/loader/loader.conf" ] || touch "${esp_root}/loader/loader.conf"

	if grep -q "^$key " "${esp_root}/loader/loader.conf"; then
		sed -i -e "s/^$key .*/$key $value/" "${esp_root}/loader/loader.conf"
	else
		echo "$key $value" >> "${esp_root}/loader/loader.conf"
	fi
}

loader_conf_get()
{
	local key="${1:?}"
	if [ -e "${esp_root}/loader/loader.conf" ]; then
		IFS=' ' read -r key value < <(grep "^$key " "${esp_root}/loader/loader.conf")
		echo -n "$value"
	fi
}

grubenv_set()
{
	local key="${1:?}"
	local value="${2:?}"

	[ -e "${esp_root}${esp_dst}/grubenv" ] || touch "${esp_root}${esp_dst}/grubenv"

	grubenv="$(mktemp -t grubenv.XXXXXX)"
	echo "# GRUB Environment Block" > "$grubenv"
	while read -r line; do
		[[ "$line" == '#'* ]] && continue
		[[ "$line" == "$key"=* ]] && continue
		echo "$line" >> "$grubenv"
	done < "${esp_root}${esp_dst}/grubenv"
	echo "$key=$value" >> "$grubenv"

	local filler
	filler=$((1024 - $(stat -c %s "$grubenv")))
	printf '#%.0s' $(seq 1 $filler) >> "$grubenv"

	mv "$grubenv" "${esp_root}${esp_dst}/grubenv"
}

grubenv_get()
{
	local key="${1:?}"

	if [ -e "${esp_root}${esp_dst}/grubenv" ]; then
		IFS='=' read -r key value < <(grep "^$key=" "${esp_root}${esp_dst}/grubenv")
		echo -n "$value"
	fi
}

set_default_sdboot()
{
	local id="${1:?}"

	if ! has_efivars || [ -n "$arg_no_variables" ]; then
		loader_conf_set "default" "$id"
	elif ! bli_efi_var_set "LoaderEntryDefault" "$id"; then
		warn "EFI variable LoaderEntryDefault cannot be set.  Falling back to configuration file"
		loader_conf_set "default" "$id"
	fi
}

set_default_grub2_bls()
{
	local id="${1:?}"
	if systemd-analyze compare-versions "$(bootloader_version)" "<" "2.13" || ! has_efivars || [ -n "$arg_no_variables" ]; then
		grubenv_set "default" "$id"
	elif ! bli_efi_var_set "LoaderEntryDefault" "$id"; then
		warn "EFI variable LoaderEntryDefault cannot be set.  Falling back to configuration file"
		grubenv_set "default" "$id"
	fi
}

get_top_entry_id()
{
	local snapshot="$1"

	if [ -n "$snapshot" ]; then
		update_entries_for_snapshot "$snapshot"
	else
		update_entries_for_this_system
	fi

	mapfile -t ids < <(jq -r '.[]|.id' < "$entryfile")
	if [ -z "${ids[0]}" ]; then
		info "No boot entries found, trying to create them..."
		install_all_kernels "$snapshot"
		if [ -n "$snapshot" ]; then
			update_entries_for_snapshot "$snapshot"
		else
			update_entries_for_this_system
		fi

		mapfile -t ids < <(jq -r '.[]|.id' < "$entryfile")
		if [ -z "${ids[0]}" ]; then
			err "snapshot $snapshot has no kernels"
		fi
	fi

	echo "${ids[0]}"
}

set_default_entry()
{
	local id="$1"
	# `get_top_entry_id` can install kernels, and inside a command
	# substitution `set -e` does not abort this shell, so the
	# result needs to be validated
	[ -n "$1" ] || id=$(get_top_entry_id "$root_snapshot")
	[ -n "$id" ] || err "Can't determine the top boot entry for snapshot $root_snapshot"

	info "Setting default entry $id"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Setting default entries must be done outside the transaction"
		return 0
	}

	if [ ! -f "${boot_root}/loader/entries/$id" ] && [ ! -f "${boot_root}/loader/entries/$id.conf" ]; then
		update_entries
		while read -r path; do
			if [ ! -f "$path" ]; then
				err "Boot loader entry $id not found"
 			fi
		done < <(jq -r --arg id "$id" '.[] | select(.id == $id) | .path' < "$entryfile")
	fi
	if is_sdboot; then
		set_default_sdboot "$id"
	elif is_grub2_bls; then
		set_default_grub2_bls "$id"
	else
		err "Bootloader not detected"
	fi

	# Setting a different boot entry invalidates "$entryfile"
	rm -f "$entryfile"

	# This action will require to update the PCR predictions
	update_predictions=1
}

get_default_bootctl()
{
	update_entries_for_this_system
	jq -r '.[] | select(.isDefault == true) | .id' < "$entryfile"
}

get_default_sdboot()
{
	local val
	if has_efivars && [ -z "$arg_no_variables" ]; then
		val="$(bli_efi_var_get "LoaderEntryDefault")"
	fi
	[ -n "$val" ] || val="$(loader_conf_get "default")"
	[ -n "$val" ] || val="$(get_default_bootctl)"
	[ -z "$val" ] || echo "$val"
}

get_default_grub2_bls()
{
	local val
	if has_efivars && [ -z "$arg_no_variables" ]; then
		val="$(bli_efi_var_get "LoaderEntryDefault")"
	fi
	[ -n "$val" ] || val="$(grubenv_get "default")"
	[ -n "$val" ] || val="$(get_default_bootctl)"
	[ -z "$val" ] || echo "$val"
}

get_default_entry()
{
	if is_sdboot; then
		get_default_sdboot
	elif is_grub2_bls; then
		get_default_grub2_bls
	else
		err "Bootloader not detected"
	fi
}

set_timeout_sdboot()
{
	local timeout="${1:?}"
	# `bootctl set-timeout menu-hidden` writes "0", and
	# "menu-disabled" write the full text.  In both cases the menu
	# is hidden.
	#
	# `bootctl set-timeout menu-force` writes 2^32-1 in the EFI
	# variable (fix needs to be backported) (bsc#1258944)
	[ "$timeout" = "0" ] && timeout="menu-disabled"
	[ "$timeout" = "-1" ] && timeout="menu-force"

	# `bootctl set-timeout menu-force` writes 2^32-1 in the EFI
	# variable (fix needs to be backported) (bsc#1258944)
	if ! has_efivars || [ -n "$arg_no_variables" ]; then
		loader_conf_set "timeout" "$timeout"
	elif ! bli_efi_var_set "LoaderConfigTimeout" "$timeout"; then
		warn "EFI variable LoaderConfigTimeout cannot be set.  Falling back to configuration file"
		loader_conf_set "timeout" "$timeout"
	fi
}

set_timeout_grub2_bls()
{
	local timeout="${1:?}"
	# `bootctl set-timeout menu-hidden` writes "0", and
	# "menu-disabled" write the full text.  In both cases the menu
	# is hidden.
	#
	# `bootctl set-timeout menu-force` writes 2^32-1 in the EFI
	# variable (fix needs to be backported) (bsc#1258944)
	#
	# We map the same string that works in grub2-bls with the
	# downstream patch
	[ "$timeout" = "menu-disabled" ] && timeout="menu-hidden"
	[ "$timeout" = "0" ] && timeout="menu-hidden"
	[ "$timeout" = "-1" ] && timeout="menu-force"

	if systemd-analyze compare-versions "$(bootloader_version)" "<" "2.13" || ! has_efivars || [ -n "$arg_no_variables" ]; then
		[ "$timeout" = "menu-disabled" ] || [ "$timeout" = "menu-hidden" ] && timeout=0
		[ "$timeout" = "menu-force" ] && timeout=-1
		grubenv_set "timeout" "$timeout"
	elif ! bli_efi_var_set "LoaderConfigTimeout" "$timeout"; then
		warn "EFI variable LoaderConfigTimeout cannot be set.  Falling back to configuration file"
		[ "$timeout" = "menu-disabled" ] || [ "$timeout" = "menu-hidden" ] && timeout=0
		[ "$timeout" = "menu-force" ] && timeout=-1
		grubenv_set "timeout" "$timeout"
	fi
}

set_timeout()
{
	local timeout="${1:?}"
	info "Setting timeout $timeout"
	if is_sdboot; then
		set_timeout_sdboot "$timeout"
	elif is_grub2_bls; then
		set_timeout_grub2_bls "$timeout"
	else
		err "Bootloader not detected"
	fi

	# This action will require to update the PCR predictions
	update_predictions=1
}

get_timeout_sdboot()
{
	local val
	if has_efivars && [ -z "$arg_no_variables" ]; then
		val="$(bli_efi_var_get "LoaderConfigTimeout")"
	fi
	[ -n "$val" ] || val="$(loader_conf_get "timeout")"
	[ "$val" = "menu-disabled" ] || [ "$val" = "menu-hidden" ] && val=0
	[ "$val" = 4294967295 ] || [ "$val" = "menu-force" ] && val=-1
	[ -z "$val" ] || echo "$val"
}

get_timeout_grub2_bls()
{
	local val
	if has_efivars && [ -z "$arg_no_variables" ]; then
		val="$(bli_efi_var_get "LoaderConfigTimeout")"
	fi
	[ -n "$val" ] || val="$(grubenv_get "timeout")"
	[ "$val" = "menu-disabled" ] || [ "$val" = "menu-hidden" ] && val=0
	[ "$val" = 4294967295 ] || [ "$val" = "menu-force" ] && val=-1
	[ -z "$val" ] || echo "$val"
}

get_timeout()
{
	if is_sdboot; then
		get_timeout_sdboot
	elif is_grub2_bls; then
		get_timeout_grub2_bls
	else
		err "Bootloader not detected"
	fi
}

print_loader_path()
{
	local loader_path
	loader_path="$(bootctl --print-loader-path 2> /dev/null)" && {
		echo "${loader_path}"
		return 0
	} || true

	# Simple approach: try the different options
	local bootloader
	bootloader=$(find_bootloader "$snapshot")
	for i in "grub.efi" "${bootloader##*/}" "BOOT${firmware_arch^^}.EFI"; do
		if [ -e "${esp_root}${esp_dst}/$i" ]; then
			echo "${esp_root}${esp_dst}/$i"
			return 0
		fi
	done

	echo "No loader EFI binary path found"
	return 1
}

set_default_snapshot()
{
	[ -n "$have_snapshots" ] || {
		info "System does not support snapshots."
		# suse-module-tools is calling `sdbootutil
		# set-default-snapshot` to set the top entry as the
		# default entry.  If the system does not support
		# snapshots we set the top entry as default and exit
		# early
		set_default_entry
		return 0
	}
	local snapshot="${1:?}"
	local id

	info "Setting default snapshot $snapshot"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Setting default snapshots must be done outside the transaction"
		return 0
	}

	# `get_top_entry_id` can install kernels, and inside a command
	# substitution `set -e` does not abort this shell, so the
	# result needs to be validated
	id="$(get_top_entry_id "$snapshot")"
	[ -n "$id" ] || err "Can't determine the top boot entry for snapshot $snapshot"
	set_default_entry "$id"

	# The default snapshot is not the criteria used for the
	# bootloader to select the default boot entry, but for
	# coherence we synchronize it here, invalidating any previous
	# "$snapperfile"
	#
	# We do not use snapper, to avoid recursion and do not trigger
	# any plugin
	local subvolume_id
	read -r _ subvolume_id _ < <(btrfs subvolume list -o /.snapshots | grep "${subvol_prefix}/.snapshots/$snapshot/snapshot")
	btrfs subvolume set-default "${subvolume_id}" /.snapshots
	rm -f "$snapperfile"
}

have_pcrlock()
{
	[ -e /usr/bin/systemd-pcrlock ] || [ -e /usr/lib/systemd/systemd-pcrlock ]
}

have_pcr_oracle()
{
	[ -e /usr/bin/pcr-oracle ]
}

# Warnings that `systemd-pcrlock` emits, once per event log
# record, when the firmware pads the UEFI device paths stored in
# the log.  All of them are produced while collecting the
# description of the record, that is only used to display the log,
# so neither the policy nor the predictions are affected by them
pcrlock_noise="^(Garbage after device path end|Device path too short|Device path size does not match|Device path element too short), ignoring\.$"

pcrlock()
{
	dbg "systemd-pcrlock $*"
	local pcrlock_cmd="/usr/bin/systemd-pcrlock"
	[ -e "$pcrlock_cmd" ] || pcrlock_cmd="/usr/lib/systemd/systemd-pcrlock"

	if [ -n "$verbose" ]; then
		SYSTEMD_LOG_LEVEL="${SYSTEMD_LOG_LEVEL:-warning}" "$pcrlock_cmd" "$@"
		return
	fi

	local status=0
	SYSTEMD_LOG_LEVEL="${SYSTEMD_LOG_LEVEL:-warning}" "$pcrlock_cmd" "$@" \
		2>"$tmpdir/pcrlock.err" || status=$?
	grep -Ev "$pcrlock_noise" "$tmpdir/pcrlock.err" >&2 || :
	rm -f "$tmpdir/pcrlock.err"

	return "$status"
}

is_pcr_oracle()
{
	[ -e /etc/systemd/tpm2-pcr-public-key.pem ] && \
	    [ -e /etc/systemd/tpm2-pcr-private-key.pem ] && \
	    have_pcr_oracle
}

pcr_oracle_public_key()
{
	# The public key that pcr-oracle used to sign the PCR policy
	# is not removed when the package goes away, and
	# systemd-cryptenroll picks it up from any of the
	# configuration directories.  Print the first one found.
	local dir
	for dir in /etc/systemd /run/systemd /usr/local/lib/systemd /usr/lib/systemd; do
		if [ -e "$dir/tpm2-pcr-public-key.pem" ]; then
			echo "$dir/tpm2-pcr-public-key.pem"
			return 0
		fi
	done

	return 1
}

snapshot_ids_for_prediction()
{
	# Select the ID of the snapshots that participate in the
	# prediction.  The order is important, so the most relevant
	# should be presented first

	# Without snapshots there is no snapper to ask, and calling it
	# only prints "snapper: command not found" twice per run
	[ -n "$have_snapshots" ] || return 0

	# Get the numbers for the last three snapshots
	[ -s "$snapperfile" ] || update_snapper

	# Select the default and the active snapshots.
	local -A snapshots
	local -a snapshot_ids
	local id
	if [ -n "$root_snapshot" ]; then
		[ -n "${snapshots[$root_snapshot]}" ] || snapshot_ids+=("$root_snapshot")
		snapshots["$root_snapshot"]=1
	fi
	while read -r id; do
		[ -n "${snapshots[$id]}" ] || snapshot_ids+=("$id")
		snapshots[$id]=1
	done < <(jq -r '.root[]|select(.default==true)|.number' "$snapperfile")
	while read -r id; do
		[ -n "${snapshots[$id]}" ] || snapshot_ids+=("$id")
		snapshots[$id]=1
	done < <(jq -r '.root[]|select(.active==true)|.number' "$snapperfile")

	if is_transactional && [ -e "${state_file}" ]; then
		# shellcheck disable=SC1090
		. "${state_file}"
		for id in $LAST_WORKING_SNAPSHOTS; do
			if [ "${#snapshots[@]}" -lt 3 ]; then
				[ -n "${snapshots[$id]}" ] || snapshot_ids+=("$id")
				snapshots[$id]=1
			fi
		done
	else
		while read -r id; do
			if [ "${#snapshots[@]}" -lt 3 ]; then
				[ -n "${snapshots[$id]}" ] || snapshot_ids+=("$id")
				snapshots[$id]=1
			fi
		done < <(jq -r '.root|sort_by(.date)[-2:]|reverse|.[]|.number' "$snapperfile")
	fi

	echo "${snapshot_ids[@]}"
}

regex_snapshot_ids_for_prediction()
{
	local re
	re="$(snapshot_ids_for_prediction)"
	re="${re// /|}"
	echo "(?:$re)"
}

select_entries_for_prediction()
{
	# The regex of ids is ordered by relevance, but the returns
	# set is not.  We need to add a "priority" and "kernel"
	# (version) field that can be used to order the set
	local ids
	ids="$(snapshot_ids_for_prediction)"

	if [ -n "$have_snapshots" ]; then
		update_entries_for_snapshot "$(regex_snapshot_ids_for_prediction)"
	else
		# There is no snapshot to select, and asking for one
		# ("rootflags=subvol=/.snapshots//snapshot") matches no
		# entry at all.  The prediction would then be done
		# without the kernel, the initrd and the command line,
		# and `systemd-pcrlock` drops the PCRs that those
		# components describe
		update_entries_for_this_system
	fi

	# Extend the entry file with a "priority" field, based on the
	# ordered snapshot IDs, and "kernel" field, based on the
	# kernel version.  Note that the textual suffix (-default,
	# -slowroll) is removed and does not participate in the
	# ordering
	#
	# Expected order for prediction:
	#
	# MicroOS
	#   - The default snapshot usually has the top priority (0),
	#     then the active (1).  The order is set by
	#     `snapshot_ids_for_prediction`
	#   - Last working snapshots are added after those.  Also set
	#     by `snapshot_ids_for_prediction`
	#   - If the snapshot contains multiple kernel, the higher
	#     version has more priority
	#
	# Tumbleweed
	#   - Same than MicroOS (`snapshot_ids_for_prediction`)
	#   - Newest working snapshot are added after those.  Also set
	#     by `snapshot_ids_for_prediction`
	#   - If the snapshot contains multiple kernel, the higher
	#     version has more priority
	#
	# Without snapshots
	#   - The version of the entry has no "N@" prefix, so there is
	#     no snapshot to order by and every entry shares the same
	#     priority.  Only the kernel version separates them, the
	#     higher one first
	#
	local filter
	if [ -n "$have_snapshots" ]; then
		filter='def priority(id): id as $id | $ids | split(" ") | index($id); map(. + {"priority": priority(.version | scan("(\\d+)@") | .[]), "kernel": .version | scan(".*@(?:(\\d+).(\\d+).(\\d+)-(\\d+))") | map(. | tonumber)})'
	else
		filter='map(([.version // "" | scan("(\\d+)\\.(\\d+)\\.(\\d+)-(\\d+)")] | first) as $kernel | . + {"priority": 0, "kernel": (($kernel // []) | map(tonumber))})'
	fi
	jq --arg ids "$ids" "$filter" < "$entryfile" > "${entryfile}.ext"
	mv "${entryfile}.ext" "$entryfile"
	dbg "Added priority and kernel version to entry file (for prediction)"
	dbg_cat "$entryfile"
}

backup_initial_components()
{
	select_entries_for_prediction
	mv "$entryfile" "$initialentryfile"
	cp -a "${boot_root}/." "$tmpdir"
	[ "$esp_root" = "$boot_root" ] || cp -a "${esp_root}/." "$tmpdir"
}

parse_eventlog()
{
	[ "${#eventlog[@]}" -eq 0 ] || return 0

	while read -r line; do
		eventlog["$line"]=1
	done < <(pcrlock log --json=short | jq -r '.log | .[] | .sha256' | sort -u)
}

find_matching_variation()
{
	local component="$1"

	local hashes=()
	for variation in /var/lib/pcrlock.d/"$component".pcrlock.d/*.pcrlock; do
		mapfile -t hashes < <(jq -r '.records | .[] | .digests | .[] | select(.hashAlg == "sha256") | .digest' < "$variation")
		for h in "${hashes[@]}"; do
			[ "${eventlog["$h"]+_}" ] || continue 2
		done
		echo "$variation"
		break
	done
}

shift_component()
{
	local component="$1"

	parse_eventlog

	dbg "Shifting component $component"
	[ -d "/var/lib/pcrlock.d/$component.pcrlock.d" ] || {
		dbg "The component is not a directory or is missing"
		return 0
	}

	# Instead of moving all the variations of a component (as this
	# will increase the amount of combinations, reaching early the
	# PolicyOR limitation), we keep the one that matches the
	# current eventlog.  If the variation is also duplicated, it
	# will be dropped by `systemd-pcrlock`
	local variation vname
	variation="$(find_matching_variation "$component")"
	if [ -n "$variation" ]; then
		vname="$(basename "$variation")"
		dbg "$variation found in the eventlog"
		[[ "$vname" == shift-* ]] || {
			# Drop any previous shifted variation, as
			# there is no guarantee that the new one will
			# share the name
			find /var/lib/pcrlock.d/"$component".pcrlock.d -name 'shift-*.pcrlock' -delete
			mv "$variation" "/var/lib/pcrlock.d/$component.pcrlock.d/shift-$vname"
		}
	else
		dbg "No matching variation found for $component"
		return 0
	fi

	# Remove the rest of the variations
	find /var/lib/pcrlock.d/"$component".pcrlock.d -name '*.pcrlock' ! -name 'shift-*.pcrlock' -delete
}

uint64_le()
{
	# 64 bit little endian representation of a number, as escape
	# sequences for `echo -ne`.  Nothing that is measured here is
	# big enough to reach the high 32 bits
	local value="$1"

	printf '\\x%02x' \
	       "$((value & 255))" \
	       "$((value >> 8 & 255))" \
	       "$((value >> 16 & 255))" \
	       "$((value >> 24 & 255))" \
	       0 0 0 0
}

pcrlock_secureboot_sbatlevel()
{
	# shim measures the `SbatLevel` variable into PCR 7 as an
	# EV_EFI_VARIABLE_AUTHORITY event.  When Secure Boot is
	# enabled `systemd-pcrlock lock-secureboot-authority` collects
	# every EV_EFI_VARIABLE_AUTHORITY record of PCR 7 in event log
	# order, without filtering by variable name, so this
	# measurement is already covered and there is nothing to do.
	# But when Secure Boot is disabled that command removes the
	# component and generates nothing, leaving the shim measurement
	# without an owner.  A measurement that no component can claim
	# drops the full PCR from the policy, so PCR 7 can never be
	# sealed in a system that boots via shim without Secure Boot.
	#
	# The prediction is written under the name that the failing
	# verb owns, and not under a new one.  `SbatLevel` is exactly
	# the record that `lock-secureboot-authority` would have
	# claimed, so the position in the replay order is the correct
	# one, and if Secure Boot is enabled later the verb overwrites
	# the file with the complete set of authorities.  A component
	# with a name of our own would survive that transition and
	# replay `SbatLevel` twice, breaking the PCR that it is meant
	# to fix
	local component_dir=/var/lib/pcrlock.d/620-secureboot-authority.pcrlock.d

	! is_secure_boot || {
		dbg "Secure Boot enabled, SbatLevel is locked by lock-secureboot-authority"
		return 0
	}

	local guid="605dab50-e046-4300-abb6-3dd810dd8b23"
	# The same GUID in binary form: the first three fields are
	# stored in little endian, and the last two as they are
	# written
	local guid_le='\x50\xab\x5d\x60\x46\xe0\x00\x43\xab\xb6\x3d\xd8\x10\xdd\x8b\x23'

	# The variable is measured as `SbatLevel`, but is exposed at
	# runtime as `SbatLevelRT`
	local name="SbatLevel"
	local var="/sys/firmware/efi/efivars/${name}RT-$guid"

	# Without shim there is no `SbatLevel` measurement to claim, and
	# `lock-secureboot-authority` already removed the component
	[ -e "$var" ] || {
		dbg "Missing $var, skipping the SbatLevel component"
		return 0
	}

	# What is measured is a UEFI_VARIABLE_DATA structure: the
	# GUID, the length of the name in characters, the length of
	# the content in bytes, the name in UTF-16LE, and the content.
	# The first four bytes of the efivarfs file are the
	# attributes, and are not part of the variable content
	local size
	size="$(stat -c%s "$var")"
	size=$((size - 4))

	{
		echo -ne "$guid_le$(uint64_le "${#name}")$(uint64_le "$size")"
		echo -n "$name" | iconv -t UTF-16LE
		tail -c "$size" "$var"
	} > "$tmpdir/sbatlevel"

	mkdir -p "$component_dir"
	pcrlock \
		lock-raw \
		--pcr=7 \
		--pcrlock="$component_dir/generated.pcrlock" \
		"$tmpdir/sbatlevel"
	rm "$tmpdir/sbatlevel"
	dbg_cat "$component_dir/generated.pcrlock"
}

pcrlock_cmdline_initrd()
{
	local cmdline="$1"
	local initrd="$2"
	local suffix="$3"

	# 710-kernel-cmdline-initrd-entry.pcrlock.d is not part of the
	# pcrlock standards
	#
	# When using systemd-boot or GRUB2-BLS >= 2.14, the cmdline is
	# send to the kernel via UEFI with null termination (double
	# \x00, as is UTF-16 enconding), but this is missing in
	# GRUB2-BLS <= 2.12
	echo -n "$cmdline" > "$tmpdir/cmdline"
	pcrlock \
		lock-kernel-cmdline \
		--pcrlock="$tmpdir/cmdline.pcrlock" \
		"$tmpdir/cmdline"
	pcrlock \
		lock-kernel-initrd \
		--pcrlock="$tmpdir/initrd.pcrlock" \
		"$initrd" 2> /dev/null
	mkdir -p /var/lib/pcrlock.d/710-kernel-cmdline-initrd-entry.pcrlock.d
	jq --slurp '{"records": [.[].records[0]]}' \
	   "$tmpdir/cmdline.pcrlock" \
	   "$tmpdir/initrd.pcrlock" \
	   > "/var/lib/pcrlock.d/710-kernel-cmdline-initrd-entry.pcrlock.d/cmdline-initrd-$suffix.pcrlock"
	rm "$tmpdir/cmdline"
	rm "$tmpdir/cmdline.pcrlock"
	rm "$tmpdir/initrd.pcrlock"

	# 710-kernel-cmdline-boot-loader.pcrlock.d is not part of the
	# pcrlock standards

	# We cannot use lock-kernel-cmdline, as it ignore --pcr=12,
	# and assign PCR 9 in any case
	echo -ne "$cmdline\x00" > "$tmpdir/cmdline"
	iconv -t UTF-16LE -o "$tmpdir/cmdline.utf16" "$tmpdir/cmdline"
	pcrlock \
		lock-raw \
		--pcr=12 \
		--pcrlock="/var/lib/pcrlock.d/710-kernel-cmdline-boot-loader.pcrlock.d/cmdline-$suffix.pcrlock" \
		"$tmpdir/cmdline.utf16"
	rm "$tmpdir/cmdline.utf16"
	rm "$tmpdir/cmdline"
}

pcrlock_grub2_bls_kernel_initrd_cmdline_initrd()
{
	local linux="$1"
	local initrd="$2"
	local devicetree="$3"
	local cmdline="$4"
	local suffix="$5"

	local elements=("$linux" "$initrd")
	[ -z "$devicetree" ] || elements+=("$devicetree")
	local locks=()
	local n=0
	for element in "${elements[@]}"; do
		n=$((n+1))
		pcrlock \
			lock-raw \
			--pcr=9 \
			--pcrlock "$tmpdir/element-$n.pcrlock" \
			"$element" 2> /dev/null
		locks+=("$tmpdir/element-$n.pcrlock")
	done

	# When using systemd-boot or GRUB2-BLS >= 2.14, the cmdline is
	# send to the kernel via UEFI with null termination (double
	# \x00, as is UTF-16 enconding), but this is missing in
	# GRUB2-BLS <= 2.12
	echo -n "$cmdline" > "$tmpdir/cmdline"
	n=$((n+1))
	if systemd-analyze compare-versions "$(bootloader_version)" "<" "2.13"; then
		iconv -t UTF-16LE -o "$tmpdir/cmdline.utf16" "$tmpdir/cmdline"
		pcrlock \
			lock-raw \
			--pcr=9 \
			--pcrlock="$tmpdir/element-$n.pcrlock" \
			"$tmpdir/cmdline.utf16"
		rm "$tmpdir/cmdline.utf16"
	else
		pcrlock \
			lock-kernel-cmdline \
			--pcrlock="$tmpdir/element-$n.pcrlock" \
			"$tmpdir/cmdline"
	fi
	locks+=("$tmpdir/element-$n.pcrlock")
	rm "$tmpdir/cmdline"

	n=$((n+1))
	pcrlock \
		lock-kernel-initrd \
		--pcrlock="$tmpdir/element-$n.pcrlock" \
		"$initrd" 2> /dev/null
	locks+=("$tmpdir/element-$n.pcrlock")

	mkdir -p /var/lib/pcrlock.d/710-grub2-bls-kernel-initrd-kernel-cmdline-initrd-entry.pcrlock.d
	jq --slurp '{"records": [.[].records[0]]}' \
	   "${locks[@]}" \
	   > "/var/lib/pcrlock.d/710-grub2-bls-kernel-initrd-kernel-cmdline-initrd-entry.pcrlock.d/kernel-initrd-cmdline-initrd-$suffix.pcrlock"
	rm "${locks[@]}"
}

pcrlock_grub2_bls_cmdline()
{
	local linux="$1"
	local cmdline="$2"
	local initrd="$3"
	local devicetree="$4"
	local suffix="$5"
	local lines=("$linux" "$cmdline" "$initrd")
	[ -z "$devicetree" ] || lines+=("$devicetree")

	local locks=()
	local n=0
	for line in "${lines[@]}"; do
		n=$((n+1))
		echo -n "$line" > "$tmpdir/line"
		pcrlock \
			lock-raw \
			--pcr=8 \
			--pcrlock "$tmpdir/line-$n.pcrlock" \
			"$tmpdir/line"
		locks+=("$tmpdir/line-$n.pcrlock")
		rm "$tmpdir/line"
	done
	mkdir -p /var/lib/pcrlock.d/650-grub2-bls-entry-cmdline.pcrlock.d
	jq --slurp '{"records": [.[].records[0]]}' \
	   "${locks[@]}" \
	   > "/var/lib/pcrlock.d/650-grub2-bls-entry-cmdline.pcrlock.d/cmdline-$suffix.pcrlock"
	rm "${locks[@]}"
}

pcrlock_grub2_bls_entry_files()
{
	local suffix="${1:+-$1}"
	local base="${2:-${boot_root}}"
	local locks=()
	local n=0
	for i in "$base"/loader/entries/*.conf; do
		n=$((n+1))
		pcrlock \
			lock-raw \
			--pcr=9 \
			--pcrlock="$tmpdir/entry-$n.pcrlock" \
			"$i"
		locks+=("$tmpdir/entry-$n.pcrlock")
	done
	mkdir -p /var/lib/pcrlock.d/643-grub2-bls-entry-files.pcrlock.d
	[ "${#locks[@]}" -eq 0 ] || {
		jq --slurp '{"records": [.[].records[0]]}' \
		   "${locks[@]}" \
		   > "/var/lib/pcrlock.d/643-grub2-bls-entry-files.pcrlock.d/generated$suffix.pcrlock"
		rm "${locks[@]}"
	}
}

pcrlock_sdboot()
{
	info "Generating TPM2 predictions with systemd-pcrlock (systemd-boot)"

	# 641-sdboot-loader-conf.pcrlock is not part of the pcrlock
	# standards
	if [ -e "${esp_root}/loader/loader.conf" ]; then
		shift_component 641-sdboot-loader-conf
		pcrlock \
			lock-raw \
			--pcr=5 \
			--pcrlock=/var/lib/pcrlock.d/641-sdboot-loader-conf.pcrlock.d/generated.pcrlock \
			"${esp_root}/loader/loader.conf"
	fi

	# 650-kernel-efi-application.pcrlock is not part of the
	# pcrlock standards
	# TODO: move to kernel-TYPE-pcrlock.rpm
	shift_component 650-kernel-efi-application
	local n=0
	local -A kernels
	while read -r linux; do
		[ -f "${boot_root}$linux" ] || {
			info "Missing ${boot_root}$linux, ignoring entry for prediction"
			continue
		}
		[ -z "${kernels["$linux"]}" ] || continue
		kernels["$linux"]=1
		n=$((n+1))
		# Limit to 4 because of the separator
		[ "$n" -le 4 ] || {
			info "More than 4 variations for 650-kernel-efi-application"
			continue
		}
		pcrlock \
			lock-pe \
			--pcrlock="/var/lib/pcrlock.d/650-kernel-efi-application.pcrlock.d/linux-$n.pcrlock" \
			"${boot_root}/$linux"
	done < <(jq --raw-output 'sort_by(.priority, (.kernel | map(-.))) | map(.linux) | .[]' "$entryfile")

	# Join the cmdline and the initrd in a single component
	shift_component 710-kernel-cmdline-initrd-entry
	shift_component 710-kernel-cmdline-boot-loader
	n=0
	while read -r cmdline; do
		read -r initrd
		[ -f "${boot_root}$initrd" ] || {
			info "Missing ${boot_root}$initrd, ignoring entry for prediction"
			continue
		}
		n=$((n+1))
		# `get_final_pcrs` drops a PCR with more than 8 predicted
		# values (PolicyOR), and the values of a PCR are the
		# product of the variations of every component that
		# measures it, times two because of the separator.  This
		# is the only component measuring PCR 9 under
		# systemd-boot, so the budget is 2*n <= 8.  Measured: 4
		# variations predict 8 values, 5 predict 10 and PCR 9 is
		# dropped, losing the constraint over the kernel command
		# line.  See bsc#1274948
		[ "$n" -le 4 ] || {
			info "More than 4 variations for 710-kernel-cmdline-initrd-entry"
			continue
		}
		pcrlock_cmdline_initrd "initrd=$cmdline" "${boot_root}$initrd" "$n"
	done < <(jq --raw-output 'sort_by(.priority, (.kernel | map(-.))) | .[] | ([(.initrd[0] | sub("/"; "\\"; "g")), .options] | join(" ")), .initrd[0]' "$entryfile")

	# Generate variation for 710-kernel-cmdline-initrd-entry
	# component that contains the current cmdline and the current
	# initrd, even if this will never be used again.  This is
	# required because disk-encryption-tool generates a new initrd
	# during the first boot, making the event log impossible to
	# align for systemd-pcrlock
	#
	# These variations land in the same component as the loop
	# above, so "n" is not reset: the budget is shared and the
	# component stays inside the PolicyOR limit.  This only runs
	# from the installer (SDB_ADD_INITIAL_COMPONENT=1), where
	# there is a single snapshot and the loop above has consumed
	# almost nothing
	if [ "$SDB_ADD_INITIAL_COMPONENT" = "1" ]; then
		while read -r cmdline; do
			read -r initrd
			n=$((n+1))
			[ "$n" -le 4 ] || {
				info "More than 4 variations for 710-kernel-cmdline-initrd-entry (initial)"
				continue
			}
			pcrlock_cmdline_initrd "initrd=$cmdline" "${tmpdir}$initrd" "0-$n"
		done < <(jq --raw-output '.[] | ([(.initrd[0] | sub("/"; "\\"; "g")), .options] | join(" ")), .initrd[0]' "$initialentryfile")
	fi
}

pcrlock_grub2_bls()
{
	info "Generating TPM2 predictions with systemd-pcrlock (grub2-bls)"

	# 641-grub2-bls-grubenv.pcrlock is not part of the pcrlock
	# standards
	if [ -e "${esp_root}${esp_dst}/grubenv" ]; then
		shift_component 641-grub2-bls-grubenv
		pcrlock \
			lock-raw \
			--pcr=9 \
			--pcrlock=/var/lib/pcrlock.d/641-grub2-bls-grubenv.pcrlock.d/generated.pcrlock \
			"${esp_root}${esp_dst}/grubenv"
	fi

	# 643-grub2-bls-entry-files.pcrlock is not part of the pcrlock
	# standards
	shift_component 643-grub2-bls-entry-files
	pcrlock_grub2_bls_entry_files
	if [ "$SDB_ADD_INITIAL_COMPONENT" = "1" ]; then
		pcrlock_grub2_bls_entry_files "0" "$tmpdir"
	fi

	local grub2_bls_drive
	if systemd-analyze compare-versions "$(bootloader_version)" "<" "2.13"; then
		local partno
		read -r _ partno dseq < <(esp_root_drive_partno_dseq)
		# We are assuming that the disk sequence (that start
		# with 1) is related with the disk sequence that GRUB2
		# see during boot time (that start with 0).
		# esp_root_drive_partno_dseq already set the correct
		# start
		#
		# Also if for GRUB2 >= 2.14 this section is not
		# included
		grub2_bls_drive="(hd$dseq,gpt$partno)"
	fi

	# Join linux, initrd and cmdline in a single pcrlock file
	shift_component 650-grub2-bls-entry-cmdline
	n=0
	while read -r options; do
		read -r linux
		read -r initrd
		read -r devicetree
		[ -f "${esp_root}$linux" ] || {
			info "Missing ${esp_root}$linux, ignoring entry for prediction"
			continue
		}
		[ -f "${esp_root}$initrd" ] || {
			info "Missing ${esp_root}$initrd, ignoring entry for prediction"
			continue
		}
		if [ -n "$devicetree" ] && [ ! -f "${esp_root}$devicetree" ]; then
			info "Missing ${esp_root}$devicetree, ignoring entry for prediction"
			continue
		fi
		n=$((n+1))
		[ "$n" -le 8 ] || {
			info "More than 8 variations for 650-grub2-bls-entry-cmdline"
			continue
		}
		pcrlock_grub2_bls_cmdline "linux ${grub2_bls_drive}$linux $options" \
					  "${grub2_bls_drive}$linux $options" \
					  "initrd ${grub2_bls_drive}$initrd" \
					  "${devicetree:+devicetree ${grub2_bls_drive}$devicetree}" \
					  "$n"
	done < <(jq --raw-output 'sort_by(.priority, (.kernel | map(-.))) | .[] | .options, .linux, .initrd[0], (.devicetree // "")' "$entryfile")

	# Generate variation for 650-grub2-bls-entry-cmdline component
	# that contains the current cmdline and the current initrd,
	# even if this will never be used again.  This is required
	# because disk-encryption-tool generates a new initrd during
	# the first boot, making the event log impossible to align for
	# systemd-pcrlock
	#
	# These variations land in the same component as the loop
	# above, so "n" is not reset: the budget is shared and the
	# component stays inside the PolicyOR limit
	if [ "$SDB_ADD_INITIAL_COMPONENT" = "1" ]; then
		while read -r options; do
			read -r linux
			read -r initrd
			read -r devicetree
			n=$((n+1))
			[ "$n" -le 8 ] || {
				info "More than 8 variations for 650-grub2-bls-entry-cmdline (initial)"
				continue
			}
			pcrlock_grub2_bls_cmdline "linux ${grub2_bls_drive}$linux $options" \
						  "${grub2_bls_drive}$linux $options" \
						  "initrd ${grub2_bls_drive}$initrd" \
						  "${devicetree:+devicetree ${grub2_bls_drive}$devicetree}" \
						  "0-$n"
		done < <(jq --raw-output '.[] | .options, .linux, .initrd[0], (.devicetree // "")' "$initialentryfile")
	fi

	# If shim is installed, grub2-bls invokes shim to extend PCR4
	if is_shim_installed || is_secure_boot; then
		# 650-kernel-efi-application.pcrlock is not part of
		# the pcrlock standards
		# TODO: move to kernel-TYPE-pcrlock.rpm
		shift_component 650-kernel-efi-application
		local n=0
		local -A kernels
		while read -r linux; do
			[ -f "${esp_root}$linux" ] || {
				info "Missing ${esp_root}$linux, ignoring entry for prediction"
				continue
			}
			[ -z "${kernels["$linux"]}" ] || continue
			kernels["$linux"]=1
			n=$((n+1))
			# Limit to 4 because of the separator
			[ "$n" -le 4 ] || {
				info "More than 4 variations for 650-kernel-efi-application"
				continue
			}
			pcrlock \
				lock-pe \
				--pcrlock="/var/lib/pcrlock.d/650-kernel-efi-application.pcrlock.d/linux-$n.pcrlock" \
				"${esp_root}/$linux"
		done < <(jq --raw-output 'sort_by(.priority, (.kernel | map(-.))) | map(.linux) | .[]' "$entryfile")
	fi

	# In MicroOS with GRUB2-BLS we can easily exceed to 8 arms
	# limit for PolicyOR in PCR#9.  For each update:
	#  - 641-grub2-bls-grubenv: `grubenv` change after the
	#    `set-default` for the new boot entry
	#  - 643-grub2-bls-entry-files: a new boot entry is added wrt
	#    the current state
	#  - 710-kernel-cmdline-initrd-entry: the new snapshot
	#    requires a new cmdline (rootflags), so there is one per
	#    snapshot
	#  - 710-grub2-bls-kernel-initrd-entry: if the kernel or
	#    initrd gets updated, a new entry with different hashes
	#    will appear here
	#
	# If not kernel/initrd gets updated, the possible values for
	# PCR#9 are 2 for 641 (the shift and the next one that
	# includes the new grubenv), 2 for 643 (again, the shift and
	# the variation that includes the hash of the new entry), n
	# for 710-kernel (one per snapshot), and 2 for 710-grub2 (only
	# if there is a new kernel or initrd): [2x]2xn[x2]
	#
	# Solutions:
	#  - GRUB2-BLS use BLI and drops `grubenv`  [done, GRUB2 2.14]
	#  - MicroOS uses only systemd-boot
	#  - PolicyOR limit is resolved in systemd-pcrlock
	#  - Merge both 710 components, to decrease the variations
	#    [done, see below]
	#  - Make n=2 (710-kernel-cmdline-initrd-entry)  [done, the cap
	#    of the merged component below]
	#
	# With the first and the last one the product is 2 (separator)
	# x 1 (641, `grubenv` is not rewritten anymore) x 2 (643) x 2
	# (the merged 710) = 8, which is the limit.  The factor of 641
	# is only 1 while the EFI variables are usable: with GRUB2
	# 2.12, without efivars or with `--no-variables` the `grubenv`
	# is written again on every `set-default` and PCR#9 is dropped.
	#
	# The merge solution makes sense.  The order would be:
	#  - GRUB2: full sha256 of the kernel
	#  - GRUB2: sha256 of the initrd
	#  - Kernel: sha256 of the cmdline
	#  - Kernel: sha256 of the initrd (rep)
	#
	# The kernel is again measured a-la pesign in PCR#4 via
	# 650-kernel-efi-application (by UEFI/shim)

	# Remove old components before the merge
	rm -fr /var/lib/pcrlock.d/710-grub2-bls-kernel-initrd-entry.pcrlock.d
	rm -fr /var/lib/pcrlock.d/710-kernel-cmdline-initrd-entry.pcrlock.d

	# Join the kernel, initrd (GRUB2) and cmdline, initrd (kernel)
	# in a single component
	shift_component 710-grub2-bls-kernel-initrd-kernel-cmdline-initrd-entry
	n=0
	while read -r cmdline; do
		read -r linux
		read -r initrd
		read -r devicetree
		[ -f "${esp_root}$linux" ] || {
			info "Missing ${esp_root}$linux, ignoring entry for prediction"
			continue
		}
		[ -f "${esp_root}$initrd" ] || {
			info "Missing ${esp_root}$initrd, ignoring entry for prediction"
			continue
		}
		if [ -n "$devicetree" ] && [ ! -f "${esp_root}$devicetree" ]; then
			info "Missing ${esp_root}$devicetree, ignoring entry for prediction"
			continue
		fi
		n=$((n+1))
		# `get_final_pcrs` drops a PCR with more than 8 predicted
		# values (PolicyOR), and the values of a PCR are the
		# product of the variations of every component that
		# measures it, times two because of the separator.  For
		# PCR 9 that is 2 * 641 * 643 * n.
		#
		# 641-grub2-bls-grubenv is 1: since GRUB2 2.14 the
		# default entry and the timeout live in the EFI variables
		# (see `set_default_grub2_bls`), so `grubenv` is never
		# rewritten and its variation matches the shifted one.
		#
		# 643-grub2-bls-entry-files is 2 whenever an entry was
		# added or removed since the last boot, which is exactly
		# when the predictions are regenerated, so it has to be
		# accounted for.
		#
		# That leaves 2 * 1 * 2 * n <= 8, so n <= 2.  Measured on
		# grub2-bls with n=4: 8 values while 643 is stable, 16 as
		# soon as it shifts.  See bsc#1274948
		[ "$n" -le 2 ] || {
			info "More than 2 variations for 710-grub2-bls-kernel-initrd-kernel-cmdline-initrd-entry"
			continue
		}
		pcrlock_grub2_bls_kernel_initrd_cmdline_initrd \
			"${esp_root}$linux" "${esp_root}$initrd" \
			"${devicetree:+${esp_root}$devicetree}" \
			"BOOT_IMAGE=${grub2_bls_drive}$linux $cmdline" \
			"$n"
	done < <(jq --raw-output 'sort_by(.priority, (.kernel | map(-.))) | .[] | .options, .linux, .initrd[0], (.devicetree // "")' "$entryfile")

	# Generate variation for
	# 710-grub2-bls-kernel-initrd-kernel-cmdline-initrd-entry for
	# the same reason than before.
	#
	# These variations land in the same component as the loop
	# above, so "n" is not reset: the budget is shared and the
	# component stays inside the PolicyOR limit
	if [ "$SDB_ADD_INITIAL_COMPONENT" = "1" ]; then
		while read -r cmdline; do
			read -r linux
			read -r initrd
			read -r devicetree
			n=$((n+1))
			[ "$n" -le 2 ] || {
				info "More than 2 variations for 710-grub2-bls-kernel-initrd-kernel-cmdline-initrd-entry (initial)"
				continue
			}
			pcrlock_grub2_bls_kernel_initrd_cmdline_initrd \
				"${tmpdir}$linux" "${tmpdir}$initrd" \
				"${devicetree:+${tmpdir}$devicetree}" \
				"BOOT_IMAGE=${grub2_bls_drive}$linux $cmdline" \
				"0-$n"
		done < <(jq --raw-output '.[] | .options, .linux, .initrd[0], (.devicetree // "")' "$initialentryfile")
	fi
}

clean_pcrlock_d()
{
	[ -d /var/lib/pcrlock.d ] || return 0

	# Remove the shifted measurements since the last reboot.  They
	# are used to link the current components with the event log,
	# so pcrlock can work with the aligments.  For example, if a
	# file gets replaced (loader.conf) the new measurement cannot
	# be found in the event log, as contains the old hash, making
	# the aligment fail.
	#
	# The masked components (see `mask_unmatched_components`) are
	# also a statement about the current event log, and expire in
	# the same way.  If the measurement that is missing today is
	# done in a later boot, for example because the initrd gained
	# `systemd-pcrextend`, the mask would hide the component that
	# owns it, and the new records would drop the PCR from the
	# policy.  Unlike a missing component, that is reported and
	# can be masked, an unclaimed record cannot be recovered from,
	# so the masks are dropped and re-evaluated after every reboot.
	# They cannot be recognized by name, as a mask is required to
	# be named after the component that it hides, but they are the
	# only empty files in the directory
	local btime
	read -r _ btime < <(grep btime /proc/stat)
	local minutes=$((1 + ($(date +%s) - btime) / 60))
	dbg "Cleaning shifted and masked measurements older than $minutes minutes"
	find /var/lib/pcrlock.d -type f \
	     \( -name 'shift-*.pcrlock' -o \( -name '*.pcrlock' -empty \) \) \
	     -cmin +"$minutes" -delete

	# Remove older (1 week) generated measurements.  This will
	# keep the predictions at minimum and decrease the
	# combinations.  Removing all can be a problem in certain
	# conditions.  For example, after the first boot some pcrlock
	# files contain hashes for the original ESP assets, that are
	# required for the event log aligment.
	find /var/lib/pcrlock.d -name '*.pcrlock' -type f -mtime +7 -delete

	# Sometimes, like in openQA tests, the user will generate new
	# entries and reboot in a short period of time
	if [ "$(find /var/lib/pcrlock.d -type f -name '*-7.pcrlock' | wc -l)" -gt 0 ]; then
		rm -fr /var/lib/pcrlock.d/* || true
	fi
}

get_predicted_hashes()
{
	find /var/lib/pcrlock.d/ -name "*.pcrlock" -type f -exec jq -r '.records[].digests[] | select(.hashAlg == "sha256") | .digest' {} + | sort -u
}

component_pcrs()
{
	# PCRs that a component measures, read from the `.pcrlock` files
	# that describe it.  A component is either a single file or a
	# directory of variations, and both forms can appear in any of
	# the `systemd-pcrlock` directories, so all of them are collected
	local component="$1"

	local d f
	local -a files=()
	for d in /etc/pcrlock.d /run/pcrlock.d /var/lib/pcrlock.d \
		 /usr/local/lib/pcrlock.d /usr/lib/pcrlock.d; do
		for f in "$d/$component.pcrlock" "$d/$component.pcrlock.d"/*.pcrlock; do
			# A mask is an empty file, and `jq` fails on it
			[ -s "$f" ] || continue
			files+=("$f")
		done
	done

	[ "${#files[@]}" -gt 0 ] || return 0

	jq -r '.records[] | .pcr // empty' "${files[@]}" 2>/dev/null | sort -nu
}

mask_unmatched_components()
{
	# `systemd-pcrlock` drops from the protection mask every PCR
	# that is touched by a component that cannot be found in the
	# event log.  Some of the components shipped in
	# /usr/lib/pcrlock.d describe measurements that the system
	# never does.  For example `750-enter-initrd` and
	# `750-os-separator` are measured by `systemd-pcrextend`, that
	# is not always present in the initrd.  As `750-os-separator`
	# touches PCRs 0-7, 9 and 12-14, a single missing component is
	# enough to leave the policy without a single PCR.
	#
	# A component is masked with an empty file placed in a
	# directory with more priority.  The name of the mask has to be
	# the name of the component, as `systemd-pcrlock` collects the
	# components with `conf_files_list_strv`, that resolves the
	# priority and the masking by file name.  A symlink to
	# /dev/null is also understood as a mask, but the SELinux
	# policy does not allow `lnk_file` in the pcrlock directory.
	# Only the components provided by the system are masked: a
	# component generated here already lives in the directory with
	# more priority, so there is nothing left to hide it with.
	#
	# The mask describes the current event log, so it is dropped
	# after a reboot by `clean_pcrlock_d`, like the shifted
	# measurements.
	#
	# Returns 0 if something was masked, so the caller knows that
	# the prediction is stale
	local pcrs="$1"

	local i
	local -a list
	local -A requested
	IFS=',' read -r -a list <<<"$pcrs"
	for i in "${list[@]}"; do
		requested[$i]=1
	done

	local component masked=1
	while read -r component; do
		[ -e "/usr/lib/pcrlock.d/$component.pcrlock" ] || \
		    [ -d "/usr/lib/pcrlock.d/$component.pcrlock.d" ] || {
			# A component generated here has no lower priority
			# copy to hide, so it cannot be masked.  It is only
			# a problem when it takes part in a PCR that is
			# being sealed: `600-gpt` for example cannot align
			# in the boot that rewrites the partition table (a
			# disk image grown to the size of the disk, or
			# `systemd-repart`), because the firmware measured
			# the table as it was before, and PCR 5 is not in
			# any of the default lists
			local affected=
			for i in $(component_pcrs "$component"); do
				[ -n "${requested[$i]+_}" ] || continue
				affected+="${affected:+,}$i"
			done
			if [ -z "$affected" ]; then
				info "Component $component cannot be found in the event log, no sealed PCR is affected"
			else
				warn "Component $component cannot be found in the event log, PCR $affected cannot be predicted"
			fi
			continue
		}
		[ ! -e "/var/lib/pcrlock.d/$component.pcrlock" ] || continue
		info "Masking component $component (not found in the event log)"
		: > "/var/lib/pcrlock.d/$component.pcrlock"
		masked=0
	done < <(sed -n "s/^Couldn't find component '\(.*\)' in event log\.\$/\1/p" "$tmpfile")

	return "$masked"
}

predict_pcrs()
{
	local pcrs="$1"

	# The components that cannot be aligned are reported at
	# `notice` level, and `mask_unmatched_components` needs them
	SYSTEMD_LOG_LEVEL=notice pcrlock --pcr="$pcrs" --json=short predict 2>"$tmpfile"
}

remove_pcr()
{
	local pcrs="$1"
	local drop="$2"

	local i
	local -a list
	local result=
	IFS=',' read -r -a list <<<"$pcrs"
	for i in "${list[@]}"; do
		[ "$i" != "$drop" ] || continue
		result+="${result:+,}$i"
	done

	echo "$result"
}

policy_missing_pcrs()
{
	# Requested PCRs that the current policy does not bind.  The
	# policy stores the list, so nothing has to be tracked here
	local pcrs="$1"

	[ -e /var/lib/systemd/pcrlock.json ] || return 0

	local i
	local -A policy_pcrs
	while read -r i; do
		policy_pcrs[$i]=1
	done < <(jq -r '.pcrValues[].pcr' < /var/lib/systemd/pcrlock.json)

	local -a list
	local missing=
	IFS=',' read -r -a list <<<"$pcrs"
	for i in "${list[@]}"; do
		[ -z "${policy_pcrs[$i]}" ] || continue
		missing+="${missing:+,}$i"
	done

	echo "$missing"
}

policy_stale_pcrs()
{
	# Bound PCRs whose current value is not one of the alternatives
	# that the policy accepts.  This is exactly what the TPM2 checks
	# when the recovery PIN has to be unsealed to re-authorize the
	# NVIndex, and answering it here costs two reads and no
	# credential at all
	[ -e /var/lib/systemd/pcrlock.json ] || return 0

	local bank
	bank="$(jq -r '.pcrBank // "sha256"' < /var/lib/systemd/pcrlock.json)"

	local i current stale=
	while read -r i; do
		# A PCR that the kernel does not export cannot be
		# compared, and assuming the worst would invent a
		# problem that may not be there
		[ -r "/sys/class/tpm/tpm0/pcr-$bank/$i" ] || continue
		# sysfs writes them in upper case, the policy in lower
		current="$(tr 'A-Z' 'a-z' < "/sys/class/tpm/tpm0/pcr-$bank/$i")"
		jq -e --arg c "$current" --argjson p "$i" \
		   '.pcrValues[] | select(.pcr == $p) | .values | index($c)' \
		   < /var/lib/systemd/pcrlock.json > /dev/null || \
			stale+="${stale:+,}$i"
	done < <(jq -r '.pcrValues[].pcr' < /var/lib/systemd/pcrlock.json)

	echo "$stale"
}

# `systemd-pcrlock make-policy` has to authorize the NVIndex against
# the policy that is already stored in it.  With a recovery PIN that is
# a PolicySigned and no PCR takes part, but without one the PIN is first
# unsealed from the TPM2, and that unseal is gated by the *previous*
# prediction evaluated against the *current* PCR values.  So a PCR that
# moved out of what the last policy accepted locks the update out: the
# same failure comes back at every boot, and no amount of retrying or
# regenerating the components changes it.  That is github issue 250,
# where re-enrolling was the only way out and nobody could say why
#
# The strings come from `tpm2_policy_super_pcr()`: "None of the
# alternative values" for a PCR that has several, and the retry message
# when a single one keeps not matching.  systemd 258 (3ab04fbc4c) added
# both, and older versions leave only the `Esys_PolicyOR` failure and
# "Failed to submit super PCR policy"
explain_pcrlock_policy_error()
{
	local errors="$1"
	local pin="$2"

	grep -qE "None of the alternative values|PCR values kept changing|Failed to submit super PCR policy|Failed to unseal PIN" "$errors" || return 0

	# A recovery PIN was used, so the PCRs were not part of the
	# authorization and this is a different problem.  Saying anything
	# about PCRs here would only send the reader the wrong way
	[ -z "$pin" ] || return 0

	local stale
	stale="$(policy_stale_pcrs)"

	warn "The policy stored in the TPM2 cannot be re-authorized${stale:+: PCR $stale no longer match it}"
	warn "No recovery PIN was available, so the one sealed in the TPM2 had to be used, and reading it back needs the current PCRs to match the previous prediction.  This does not recover by itself, every new run fails the same way"
	warn "With the recovery PIN, re-run with --ask-recovery-pin (or RECOVERY_PIN=...)"
	warn "Without it, the policy has to be created again: 'systemd-pcrlock remove-policy' and then 'sdbootutil enroll --method=tpm2'.  Until that is done the TPM2 does not open the device, so first make sure that a password does"
}

shim_changed()
{
	# The shim in the ESP is not the one that produced the current
	# event log.  `shift_component` keeps the variation that the
	# event log matches, and `lock-pe` regenerates the one for the
	# binary that is installed now, so comparing both answers the
	# question without tracking any state of our own
	local dir=/var/lib/pcrlock.d/630-shim-efi-application.pcrlock.d

	[ -e "$dir/generated.pcrlock" ] || return 1

	local shifted
	for shifted in "$dir"/shift-*.pcrlock; do
		cmp -s "$shifted" "$dir/generated.pcrlock" || return 0
	done

	return 1
}

get_final_pcrs()
{
	local pcrs="$1"

	# Generate a JSON prediction, to get the final PCRs that will
	# participate in the policy.  To avoid the PolicyOR limit, we
	# filter the PCRs with more that 8 values
	#
	# The status is collected in the same command: with `set -e` a
	# failing command substitution ends the shell before the next
	# line can read `$?`, and the error message that is in $tmpfile
	# would never be printed
	local output
	local pcrlock_status=0
	output="$(predict_pcrs "$pcrs")" || pcrlock_status=$?

	if [ "$pcrlock_status" -ne 0 ]; then
		warn "Error creating the systemd-pcrlock prediction!\n$(cat "$tmpfile")"
		return 1
	fi

	# Predict again if some component was masked, as the previous
	# prediction was done with it in place
	if mask_unmatched_components "$pcrs"; then
		pcrlock_status=0
		output="$(predict_pcrs "$pcrs")" || pcrlock_status=$?

		if [ "$pcrlock_status" -ne 0 ]; then
			warn "Error creating the systemd-pcrlock prediction!\n$(cat "$tmpfile")"
			return 1
		fi
	fi

	# An empty prediction means that no PCR survived the event log
	# alignment.  A policy can still be created from it, but it
	# will not bind the NVIndex to any PCR, silently dropping the
	# protection that the user asked for
	[ "$output" != "{}" ] || {
		warn "No PCR from $pcrs can be predicted, the event log cannot be aligned!\n$(cat "$tmpfile")"
		return 1
	}

	# PCRs that participate in the policy
	local pcrs_policy
	pcrs_policy="$(jq -r '[.sha256[] | select(.values | length <= 8) | .pcr] | join(",")' <<<"$output")"

	# PCRs dropped because they have more than 8 values
	local pcrs_policyor
	pcrs_policyor="$(jq -r '[.sha256[] | select(.values | length > 8) | .pcr] | join(",")' <<<"$output")"

	# The lists are comma separated but do not end with a comma, so
	# `read -d ','` would always drop the last PCR
	local i
	local -a list
	local -A pcrs_policy_table
	local -A pcrs_policyor_table
	IFS=',' read -r -a list <<<"$pcrs_policy"
	for i in "${list[@]}"; do
		pcrs_policy_table[$i]=1
	done
	IFS=',' read -r -a list <<<"$pcrs_policyor"
	for i in "${list[@]}"; do
		pcrs_policyor_table[$i]=1
	done

	# A PCR dropped by the PolicyOR limit is also absent from the
	# policy, so it would be reported twice, and the first reason
	# would be the wrong one.  The list is built in the order that
	# the user requested: the keys of an associative array come out
	# in hash order
	local pcrs_policy_missing=
	IFS=',' read -r -a list <<<"$pcrs"
	for i in "${list[@]}"; do
		[ -z "${pcrs_policy_table[$i]}" ] || continue
		[ -z "${pcrs_policyor_table[$i]}" ] || continue
		pcrs_policy_missing+="${pcrs_policy_missing:+,}$i"
	done

	# `systemd-pcrlock` can report that a PCR matches the event log
	# and still predict a set of values that does not contain the
	# current one, for example when a component is replayed in the
	# wrong order.  The resulting policy is valid, but the device
	# will not be unsealed after a reboot that changes nothing, so
	# it is better to report it
	local pcr_current
	for i in "${!pcrs_policy_table[@]}"; do
		[ -e "/sys/class/tpm/tpm0/pcr-sha256/$i" ] || continue
		read -r pcr_current < "/sys/class/tpm/tpm0/pcr-sha256/$i"
		jq -e --argjson pcr "$i" --arg value "${pcr_current,,}" \
			'[.sha256[] | select(.pcr == $pcr) | .values[]] | index($value)' \
			<<<"$output" > /dev/null || \
			warn "The prediction for PCR $i does not contain the current value"
	done

	# Dropping a PCR silently removes protection that the user asked
	# for, so it is a warning and not an `info`: `info` returns early
	# unless `--verbose` was passed, and the enroll and installer
	# paths are not verbose
	[ -z "$pcrs_policy_missing" ] || warn "PCRs $pcrs_policy_missing dropped from the policy (component mapping)"
	[ -z "$pcrs_policyor" ] || warn "PCRs $pcrs_policyor dropped from the policy (PolicyOR)"
	[ "$pcrs" = "$pcrs_policy" ] || info "Final PCRs list ($pcrs -> $pcrs_policy)"

	# With `--strict` the requested list is taken literally: if some
	# PCR cannot be predicted nothing is applied, and the reasons
	# are in the warnings above.  This is what
	# `systemd-pcrlock --strict=` does for the drops that it decides
	# itself, extended here to the PolicyOR limit, which is our own
	#
	# Without it a reduced policy is applied.  The list usually
	# comes from the configuration file or from the defaults, where
	# it can legitimately name a PCR that this machine cannot
	# predict, and refusing to update would leave the system with a
	# stale policy after every kernel update
	#
	# `err` exits the command substitution that the caller uses, so
	# the caller sees a failure and stops before creating any policy
	[ -z "$arg_strict" ] || [ -z "$pcrs_policy_missing$pcrs_policyor" ] || \
		err "Not all the requested PCRs can be predicted ($pcrs -> ${pcrs_policy:-none}). No policy is applied, run without --strict to accept the subset"

	echo "$pcrs_policy"
}

generate_tpm2_predictions_pcrlock()
{
	local pcrs="$FDE_SEAL_PCR_LIST"

	info "Generating TPM2 predictions"

	# Select the affected entries
	select_entries_for_prediction

	clean_pcrlock_d

	shift_component 250-firmware-code-early
	shift_component 550-firmware-code-late
	pcrlock lock-firmware-code

	shift_component 250-firmware-config-early
	shift_component 550-firmware-config-late
	pcrlock lock-firmware-config

	# If secure boot is disabled, this can fail.  There is patch
	# for the policy generation, and for the authority is planned
	shift_component 240-secureboot-policy
	pcrlock lock-secureboot-policy &> /dev/null || true
	shift_component 620-secureboot-authority
	pcrlock lock-secureboot-authority &> /dev/null || true
	# Generates 620-secureboot-authority when the verb cannot
	pcrlock_secureboot_sbatlevel

	# Uses / by default, but firmware measures GPT of the disk a
	# boot application was loaded from, which is effectively the
	# disk where our ESP is located.
	shift_component 600-gpt
	pcrlock lock-gpt "$esp_root"

	# Measure the boot loader.  Combinations:
	#   - Removable media with shim: BOOTX64.EFI, grub.efi
	#   - Removable media w/out shim: BOOTX64.EFI
	#   - Installed system with shim: shim.efi, grub.efi
	#   - Installed system w/out shim and grub2-bls: grub.efi
	#   - Installed system w/out shim and systemd-boot: systemd-bootx64.efi
	local shim_path bootloader_path
	if is_shim_installed; then
		if [ -n "$arg_portable" ]; then
			shim_path="${esp_root}/EFI/BOOT/BOOT${firmware_arch^^}.EFI"
			bootloader_path="${esp_root}/EFI/BOOT/grub.efi"
		else
			shim_path="${esp_root}${esp_dst}/shim.efi"
			bootloader_path="${esp_root}${esp_dst}/grub.efi"
		fi
	else
		local bootloader_filename
		if is_sdboot; then
			bootloader_filename="systemd-boot${firmware_arch,,}.efi"
		elif is_grub2_bls; then
			bootloader_filename="grubbls.efi"
		fi
		if [ -n "$arg_portable" ]; then
			bootloader_path="${esp_root}/EFI/BOOT/BOOT${firmware_arch^^}.EFI"
		else
			bootloader_path="${esp_root}${esp_dst}/${bootloader_filename}"
		fi
	fi

	if [ -n "$shim_path" ]; then
		# 630-shim-efi-application is not part of the pcrlock
		# standards
		# TODO: move to shim-pcrlock.rpm
		shift_component 630-shim-efi-application
		pcrlock \
			lock-pe \
			--pcrlock=/var/lib/pcrlock.d/630-shim-efi-application.pcrlock.d/generated.pcrlock \
			"${shim_path}"
	fi

	# 640-boot-loader-efi-application is not part of the
	# pcrlock standards
	# This is measuring the grub / systemd-boot EFI binary
	# TODO: move to systemd-boot-pcrlock.rpm / grub2-bls.rpm
	shift_component 640-boot-loader-efi-application
	pcrlock \
		lock-pe \
		--pcrlock=/var/lib/pcrlock.d/640-boot-loader-efi-application.pcrlock.d/generated.pcrlock \
		"${bootloader_path}"

	if is_sdboot; then
		pcrlock_sdboot
	elif is_grub2_bls; then
		pcrlock_grub2_bls
	fi

	# The copy in the ESP is imported by `dracut-pcr-signature`,
	# and can be missing after a new ESP installation.  Both copies
	# are identical, so a missing one can be restored from the
	# other, and by itself it does not require a new policy.
	#
	# Restoring the local copy also matters because it stores the
	# PIN that `systemd-pcrlock` needs to update the NVIndex.
	# Without it a new policy cannot replace the current one
	if [ -e /var/lib/systemd/pcrlock.json ] && [ ! -e "${esp_root}${esp_dst}/pcrlock.json" ]; then
		dbg "Restoring the pcrlock policy in the ESP"
		cp /var/lib/systemd/pcrlock.json "${esp_root}${esp_dst}" || \
			warn "Cannot copy the pcrlock policy into the ESP"
	elif [ ! -e /var/lib/systemd/pcrlock.json ] && [ -e "${esp_root}${esp_dst}/pcrlock.json" ]; then
		dbg "Restoring the pcrlock policy from the ESP"
		cp "${esp_root}${esp_dst}/pcrlock.json" /var/lib/systemd/ || \
			warn "Cannot restore the pcrlock policy from the ESP"
	fi

	# Decide if the current policy can be reused before running
	# any expensive operation (`predict` and `make-policy`), and
	# before asking for any PIN.
	#
	# The hashes that can participate in the policy are compared
	# with the ones that were used to build the current policy, and
	# not with the ones present before regenerating the components.
	# The components are also regenerated at boot by
	# `sdbootutil-update-predictions.service`, so a change in the
	# firmware is already absorbed by `/var/lib/pcrlock.d` before
	# the user can call this command.  Comparing with the state of
	# this run would make such a change invisible forever, and only
	# wiping `/var/lib/pcrlock.d` would recover.
	#
	# Duplicated hashes do not add new values, so the list is
	# deduplicated
	local post_hashes
	post_hashes="$(get_predicted_hashes)"

	# A PCR that cannot be predicted is dropped from the policy,
	# and the reason is very often temporary: it is a statement
	# about the current event log, that the next boot resolves.
	# The components do not need to change for that to happen, so
	# the hashes alone would reuse the reduced policy forever
	local missing_pcrs
	missing_pcrs="$(policy_missing_pcrs "$pcrs")"

	if [ -n "$arg_force" ]; then
		dbg "A new policy is forced (--force)"
	elif [ ! -e /var/lib/systemd/pcrlock.json ]; then
		# There is no policy to reuse, independently of how
		# the components compare
		dbg "Missing /var/lib/systemd/pcrlock.json"
	elif [ ! -e /var/lib/sdbootutil/pcrlock-hashes ]; then
		# The policy was created by a version that did not
		# track the hashes, so there is nothing to compare
		# with.  `make-policy` will detect that the prediction
		# is unchanged and will keep the NVIndex untouched
		dbg "Missing /var/lib/sdbootutil/pcrlock-hashes"
	elif ! echo "$post_hashes" | cmp -s - /var/lib/sdbootutil/pcrlock-hashes; then
		dbg "The components changed since the current policy was created"
	elif [ -n "$missing_pcrs" ]; then
		dbg "The current policy does not cover the requested PCR $missing_pcrs"
	else
		info "Policy is up to date"
		return 0
	fi

	# shim raises `SbatLevel` to the level built into the binary
	# that it runs from, and measures it into PCR 7 before the
	# volume is unlocked.  When the shim in the ESP is not the one
	# that produced the current event log the new value is unknown
	# here, so PCR 7 is left out of this policy and re-added by
	# `sdbootutil-update-predictions.service` on the next boot,
	# once the event log contains what was measured
	if [[ ",$pcrs," == *",7,"* ]] && shim_changed; then
		info "The shim in the ESP is a new one, PCR 7 is left out until the next boot"
		pcrs="$(remove_pcr "$pcrs" 7)"

		# `--strict` asks for all the requested PCRs or none
		[ -z "$arg_strict" ] || \
			err "The next SbatLevel measurement cannot be predicted, so PCR 7 cannot be part of the policy. No policy is applied, run without --strict to accept the subset"
	fi

	local final_pcrs
	final_pcrs="$(get_final_pcrs "$pcrs")" || return 1

	# If the prediction fails, the system will ask for a password,
	# but we can do a re-enrollment using the recovery PIN.  To
	# register a recovery PIN the installer (sdbootutil-enroll,
	# YaST) will call this script deploying in the %u keyring
	# "sdbootutil-recovery-pin" entry (or the deprecated
	# "sdbootutil-pin" and "sdbootutil" ones).  For re-enrollments we
	# can use the same entry, the RECOVERY_PIN or PIN environment
	# variables, or the --ask-recovery-pin parameter.
	local pin
	local extra=()
	local keyid keyid_int
	keyid="$(keyctl id %user:sdbootutil 2> /dev/null)" || true
	keyid_int="$(recovery_pin_keyid)" || true
	# For "tpm2+pin" both PIN and %user:sdbootutil hold the TPM2
	# PIN that is typed at every boot, and that one must not be the
	# recovery PIN too: it re-authorizes the NVIndex, so seeing the
	# boot PIN once would be enough to rewrite the policy.  With
	# --ask-pin the two are already asked separately, and both
	# RECOVERY_PIN and %user:sdbootutil-recovery-pin name the
	# recovery PIN, so all of them are still read.  Left with no
	# source, systemd-pcrlock generates one and it is printed below
	local pin_env="$env_pin"
	if [ "$arg_method" = "tpm2+pin" ]; then
		pin_env=
		keyid=
	fi
	# RECOVERY_PIN is unambiguous, so it wins over the overloaded
	# PIN and is the only environment variable for "tpm2+pin"
	[ -z "$env_recovery_pin" ] || pin_env="$env_recovery_pin"
	if [ -n "$arg_ask_pin$arg_ask_recovery_pin" ]; then
		# When enrolling, we should ask two times
		if [ -n "$arg_method" ]; then
			info "The recovery PIN is used when the TPM2 policy needs to be updated"
			ask_new_password "recovery PIN" pin
		else
			ask_password "Recovery PIN" pin
		fi
		extra=("--recovery-pin=yes")
	elif [ -n "$pin_env" ]; then
		pin="$pin_env"
		extra=("--recovery-pin=yes")
	elif [ -n "$keyid_int" ]; then
		pin="$(keyctl pipe "$keyid_int")"
		extra=("--recovery-pin=yes")
	elif [ -n "$keyid" ]; then
		deprecated_keyring_secret "recovery PIN" "sdbootutil-recovery-pin"
		pin="$(keyctl pipe "$keyid")"
		extra=("--recovery-pin=yes")
	else
		# No PIN was provided, systemd-pcrlock will generate
		# one
		extra=("--recovery-pin=show")
	fi

	# Rewrite the NV index even if systemd-pcrlock considers the
	# prediction unchanged
	[ -z "$arg_force" ] || extra+=("--force")

	# `systemd-pcrlock` skips the update when the new prediction is
	# identical to the one stored in the current policy, and it does
	# so before authenticating against the TPM2.  The recovery PIN is
	# then never used, and an incorrect one is silently accepted.
	# The policy file is rewritten only when the NVIndex is updated,
	# so it can be used to tell both cases apart
	local policy_digest=
	[ ! -e /var/lib/systemd/pcrlock.json ] || \
		policy_digest="$(sha256sum < /var/lib/systemd/pcrlock.json)"

	# Like in `get_final_pcrs`, the status cannot be read in the next
	# line: `set -e` would end the script here, without printing the
	# reason that `systemd-pcrlock` left in $tmpfile
	local output
	local pcrlock_status=0
	output="$(PIN="$pin" pcrlock --pcr="$final_pcrs" "${extra[@]}" make-policy 2>"$tmpfile")" || pcrlock_status=$?

	# `systemd-pcrlock` explains in stderr what it did, but the
	# messages are discarded when the command succeeds
	dbg_cat "$tmpfile"

	if [ "$pcrlock_status" -ne 0 ]; then
		warn "Error creating the systemd-pcrlock policy!\n$(cat "$tmpfile")"
		explain_pcrlock_policy_error "$tmpfile" "$pin"
		return 1
	elif echo "$output" | grep -q "recovery PIN"; then
		local split
		IFS=":" read -r -a split <<<"$output"
		pin="${split[1]}"
		pin="${pin## }"
		pin="${pin%% }"

		echo "Recovery PIN: $pin"
		if [ -x /usr/bin/qrencode ]; then
			echo "You can also scan it with your mobile phone:"
			qrencode -t utf8i "$pin"
		fi

		# Add the generated recovery PIN to the kernel
		# keyring, so that it is available to `sdbootutil
		# enroll --method=recovery-key`
		keyctl_add_with_timeout "sdbootutil-recovery-pin" "$pin"
	fi

	[ -e /var/lib/systemd/pcrlock.json ] || {
		warn "Missing /var/lib/systemd/pcrlock.json after the policy creation"
		return 1
	}

	# Track the hashes used for this policy, so a later run can
	# detect that the components diverged from it
	echo "$post_hashes" > /var/lib/sdbootutil/pcrlock-hashes

	# Publish the assets in the ESP, so can be imported by
	# dracut-pcr-signature
	cp /var/lib/systemd/pcrlock.json "${esp_root}${esp_dst}" || \
		warn "Cannot copy the pcrlock policy into the ESP"

	# With --force the NVIndex is always rewritten, and the policy
	# file can still be identical: the serializations of the NVIndex,
	# the SRK and the sealed PIN are reused from the old policy, so
	# only the prediction can make it different
	if [ -n "$arg_force" ] || \
	    [ "$policy_digest" != "$(sha256sum < /var/lib/systemd/pcrlock.json)" ]; then
		echo "NVIndex policy created"
	else
		info "The prediction did not change, so the NVIndex was not updated"
		# Only the TPM2 can tell if the recovery PIN is
		# correct, and it is asked to do it when the NVIndex is
		# written.  Without this message a wrong PIN looks
		# accepted, as the command succeeds
		[ -z "$pin" ] || \
			warn "The recovery PIN was not needed, so it was not validated. Use --force to rewrite the NVIndex with it"
	fi
}

valid_password()
{
	local dev="${1:?}"
	local pw="$2"

	[ -n "$pw" ] || return 1
	# Without --disable-external-tokens an enrolled TPM2 or FIDO2
	# token opens the device and any password is reported as valid
	cryptsetup luksOpen --test-passphrase --disable-external-tokens "$dev" <<<"$pw" &> /dev/null
}

get_device_password()
{
	local dev="${1:?}"
	local pw='' keyid candidate
	local from_keyring=()
	local candidates=()

	# %user:cryptenroll is read first because systemd-cryptenroll
	# reads it on its own, so when it already unlocks the device
	# there is nothing to add.  It can hold several passwords, one
	# per device, separated by NUL
	if keyid="$(keyctl id %user:cryptenroll 2> /dev/null)"; then
		mapfile -d '' -t from_keyring < <(keyctl pipe "$keyid")
	fi

	# CURRENT_PW is the only source that declares itself to be a
	# *current* password, so it is tried first
	[ -z "$env_current_pw" ] || candidates+=("$env_current_pw")

	# PW, KEY and the %user:sdbootutil* entries hold the *new*
	# secret for the password, recovery-key and tpm2+pin methods,
	# and during the first enrollment they can contain the recovery
	# PIN, so every candidate is validated before it is used.
	# Adding a wrong one is not harmless: keyctl replaces the
	# payload, so it would drop a valid password.  Because of the
	# validation, %user:sdbootutil is not ambiguous here and it is
	# not deprecated as a candidate
	[ -z "$env_pw" ] || candidates+=("$env_pw")
	[ -z "$env_key" ] || candidates+=("$env_key")
	local name
	for name in sdbootutil-pw sdbootutil-key sdbootutil-recovery-pin sdbootutil-pin sdbootutil; do
		if keyid="$(keyctl id %user:"$name" 2> /dev/null)"; then
			candidates+=("$(keyctl pipe "$keyid")")
		fi
	done

	local in_keyring=
	for candidate in "${from_keyring[@]}"; do
		valid_password "$dev" "$candidate" || continue
		pw="$candidate"
		in_keyring=1
		break
	done
	if [ -z "$pw" ]; then
		for candidate in "${candidates[@]}"; do
			valid_password "$dev" "$candidate" || continue
			pw="$candidate"
			break
		done
	fi

	# Ask the password only as a last resort, and only if
	# --measure-pcr or --ask-current-pw are set.  Any other --ask-*
	# also means that there is someone in front of a terminal
	# willing to type it
	if [ -z "$pw" ] && { [ -n "$arg_measure_pcr" ] || [ -n "$arg_ask_key$arg_ask_pin$arg_ask_recovery_pin$arg_ask_pw$arg_ask_current_pw" ]; }; then
		ask_password "Password for $dev" pw
		valid_password "$dev" "$pw" || {
			warn "The password does not unlock $dev"
			pw=''
		}
	fi

	if [ -n "$pw" ] && [ -z "$in_keyring" ]; then
		keyctl_add_with_timeout "cryptenroll" "$pw"
	fi

	echo "$pw"
}

get_volume_key()
{
	local dev="$1"
	local pw out
	pw="$(get_device_password "$dev")"
	# The error is signaled by an empty volume key.  There is no
	# retry: the password was already validated against the device,
	# so a second call returns the same one.  Revoking
	# %user:cryptenroll to force a different answer is not an
	# option either, as it can hold the password of another device
	[ -n "$pw" ] || return 0
	out="$(cryptsetup luksDump --batch-mode --dump-master-key "$dev" 2> /dev/null<<<"$pw")" || return 0
	echo "$out" | sed -n '/MK dump:/,$p' | sed -E 's/MK dump:|[[:blank:]]+//g' | sed -z 's/\n//g'
}

extend_pcr()
{
	local dgst="$1"
	local pcr="$2"
	local val="$3"
	local digest

	command -v "${dgst}sum" &> /dev/null || { warn "${dgst}sum not found"; return 1; }
	hex_to_binary "$pcr$val" > "$tmpdir/pcr"
	read -r digest _ < <("${dgst}sum" "$tmpdir/pcr")
	echo "$digest"
}

# Create the key pair that signs the PCR 15 prediction, if it is not
# already there.  The public key is copied into the initrd by dracut
# (module-setup.sh), so any initrd generated before a new pair cannot
# validate PCR 15, and the callers have to tell the three outcomes
# apart: 0 a new pair was created, 1 it was already there, 2 it could
# not be created
create_measure_pcr_keys()
{
	local private="/var/lib/sdbootutil/measure-pcr-private.pem"
	local public="/var/lib/sdbootutil/measure-pcr-public.pem"

	[ ! -f "$private" ] || [ ! -f "$public" ] || return 1

	# A private key can be there and still be unusable: openssl
	# creates the file before it writes to it, so an interrupted or
	# failed run leaves an empty one behind.  Only the "-f" test
	# above guarded this, and it kept skipping the generation while
	# the public key was never written, on every run from then on
	if [ -f "$private" ] && ! openssl rsa -in "$private" -noout &> /dev/null; then
		warn "Replacing $private, which cannot be read"
		rm -f "$private"
	fi

	if [ ! -f "$private" ]; then
		info "Creating the key pair that signs the PCR 15 prediction"
		# A public key that comes from a different private key
		# cannot validate anything
		rm -f "$public"
		# The half-written file is removed, so that the next run
		# does not find it and take it for a usable key
		openssl genrsa -out "$private" 4096 || { rm -f "$private"; return 2; }
	fi
	# Writes "writing RSA key" in stderr and -noout is not doing
	# what I was expecting
	[ -f "$public" ] || openssl rsa -in "$private" -pubout -out "$public" 2> /dev/null || {
		rm -f "$public"
		return 2
	}
}

generate_tpm2_predictions_pcr_15()
{
	local devs=()
	local msgs=()
	local vks=()
	local name dev opts extra uuid pw

	info "Generating predictions for PCR15"

	# Read /etc/crypttab lines that contains tpm2-device and
	# tpm2-measure-pcr.  This code is the similar from
	# measure-pcr-generator.sh, so we guarantee the same ordering
	# for PCR 15 extension
	while read -r name dev _ opts; do
		# Only the entries in /etc/crypttab in the initrd
		# (marked with x-initrd.attach) should participate
		# from the extension for now.  The reason is that
		# extensions after the switch root cannot participate
		# in abort the boot process from initrd itself
		#
		# Note that dracut will add the cr_swap partition even
		# if it is not marked as x-initrd.attach
		[[ "$name" = \#* ]] && continue
		[[ "$opts" != *"tpm2-device="* ]] && continue
		[[ "$opts" != *"tpm2-measure-pcr="* ]] && continue

		# If the device name is UUID= convert as a real device
		# name, and if not, retrieve the UUID
		if [[ "$dev" = "UUID="* ]]; then
			uuid="${dev#"UUID="}"
			dev="$(blkid --uuid "$uuid")"
		else
			uuid="$(blkid "$dev" -o value -s UUID)"
		fi

		# Get the FSTYPE of the real device (crypto_LUKS) and
		# the slave / holder one (btrfs, swap, etc).  Also get
		# the mount point so we can identify /etc or /var.
		#
		# According to https://systemd.io/MOUNT_REQUIREMENTS/
		# /etc and swap are mounted in initrd, and /var is
		# mounted after initrd, but in MicroOS (via
		# microos-tools) is adding /var to be mounted in the
		# initrd stage too because of selinux
		# (98selinux-microos).
		extra="$(lsblk --noheadings -o FSTYPE,MOUNTPOINT "$dev")"
		if [[ "$extra" != *"swap"* ]] && [[ "$extra" != *"/etc"* ]] && { [[ "$extra" != *"/var"* ]] || ! is_transactional; }; then
			[[ "$opts" != *"x-initrd.attach"* ]] && continue
		fi

		dbg "Adding $dev (cryptsetup:$name:$uuid) for PCR15"

		devs+=("$dev")
		msgs+=("cryptsetup:$name:$uuid")
	done < /etc/crypttab
	# We need to separate this into a different loop because we
	# cannot nest two reads (one for crypttab and another for the
	# password)
	for dev in "${devs[@]}"; do
		local vk
		vk="$(get_volume_key "$dev")"
		[ -n "$vk" ] || { warn "Volume key cannot be extracted. Dropping PCR 15"; return 0; }
		vks+=("$vk")
	done

	# The key pair is created during the enrollment, but not when
	# `--no-measure-pcr` is used.  If PCR 15 is enabled later in
	# /etc/crypttab there is nothing to sign the prediction with,
	# and an unsigned prediction also halts the boot
	local private="/var/lib/sdbootutil/measure-pcr-private.pem"
	# "|| rc=$?" and not a bare call, that with "set -e" would end
	# the script on any return that is not 0
	local rc=0
	create_measure_pcr_keys || rc="$?"
	case "$rc" in
		0) warn "The public key is only added to the initrd by 'sdbootutil mkinitrd'" ;;
		# Not fatal here on purpose.  The signing below fails
		# too, and that is the path that removes the stale
		# prediction and its signature from the ESP, which the
		# next boot would otherwise still find and trust
		2) warn "The key pair that signs the PCR 15 prediction cannot be created" ;;
	esac

	rm -f /var/lib/sdbootutil/measure-pcr-prediction
	rm -f /var/lib/sdbootutil/measure-pcr-prediction.sha256
	local dgsts=("sha1" "sha256" "sha384" "sha512")
	local sizes=(40 64 96 128)
	local pcr15 hmac

	for i in "${!dgsts[@]}"; do
		pcr15="$(printf '0%.0s' $(seq 1 "${sizes[i]}"))"
		for j in "${!msgs[@]}"; do
			hmac="$(echo -ne "${msgs[j]}" | /usr/libexec/sdbootutil/uhmac "${dgsts[i]}" <(echo "${vks[j]}"))"
			# `extend_pcr` reports why it failed, but with
			# `set -e` the script would end here, leaving
			# an incomplete and unsigned prediction behind.
			# Drop only the affected hash algorithm
			pcr15="$(extend_pcr "${dgsts[i]}" "$pcr15" "$hmac")" || continue 2
			dbg "${msgs[j]} (${dgsts[i]}): $hmac"
		done
		echo "$pcr15" >> "/var/lib/sdbootutil/measure-pcr-prediction"
	done

	# An unsigned prediction is worse than no prediction at all, as
	# the ESP can still contain the signature of a previous one
	openssl dgst -sha256 \
		-sign "$private" \
		-out /var/lib/sdbootutil/measure-pcr-prediction.sha256 \
		/var/lib/sdbootutil/measure-pcr-prediction || {
		rm -f /var/lib/sdbootutil/measure-pcr-prediction
		rm -f /var/lib/sdbootutil/measure-pcr-prediction.sha256
		rm -f "${esp_root}${esp_dst}/measure-pcr-prediction"
		rm -f "${esp_root}${esp_dst}/measure-pcr-prediction.sha256"
		err "The PCR 15 prediction cannot be signed with $private"
	}

	# Register the hash of the parsed crypttab
	local crypttab_sha1
	read -r crypttab_sha1 _ < <(sha1sum /etc/crypttab)
	echo "$crypttab_sha1" > /var/lib/sdbootutil/crypttab.sha1

	# Publish the assets in the ESP, so can be imported by
	# dracut-pcr-signature
	[ ! -e /var/lib/sdbootutil/measure-pcr-prediction ] || \
		cp /var/lib/sdbootutil/measure-pcr-prediction "${esp_root}${esp_dst}"
	[ ! -e /var/lib/sdbootutil/measure-pcr-prediction.sha256 ] || \
		cp /var/lib/sdbootutil/measure-pcr-prediction.sha256 "${esp_root}${esp_dst}"
}

updated_crypttab()
{
	local crypttab_sha1
	grep -q "tpm2-measure-pcr=yes" /etc/crypttab || return 0
	read -r crypttab_sha1 _ < <(sha1sum /etc/crypttab)
	grep -Fixq "$crypttab_sha1" /var/lib/sdbootutil/crypttab.sha1 2> /dev/null
}

generate_tpm2_predictions()
{
	[ -e /etc/crypttab ] || return 0
	grep -q "tpm2-device" /etc/crypttab || return 0
	! in_lockout || err "The TPM2 is in lockout. Use 'tpm2_dictionarylockout -c [ -p passwd ]' to clear the DA lockout and re-try the sdbootutil command"

	info "Updating predictions"

	[ -z "$TRANSACTIONAL_UPDATE" ] || [ -n "$arg_force" ] || {
		warn "Inside transactional-update. Updating predictions must be done outside the transaction"
		return 0
	}

	[ -z "${DEVICETREE_SOURCE:-}" ] || ! is_sdboot || {
		warn "TPM2 predictions with systemd-boot devicetree entries are not validated. Use --disable-predictions if this affects boot."
	}

	! is_pcr_oracle || {
		warn "System enrolled with pcr-oracle. Re-enroll to use systemd-pcrlock"
		return 0
	}

	# is_pcr_oracle() is false once the pcr-oracle package is
	# gone, but the LUKS2 token can still require a signed PCR
	# policy that nothing signs anymore, in which case the pcrlock
	# predictions are updated but the TPM2 unlock keeps falling
	# back to the password.  Only a re-enrollment fixes that.
	local dev
	if detect_tracked_devices; then
		for dev in "${tracked_devices[@]}"; do
			! has_signed_pcr_policy "$dev" || \
				warn "$dev is enrolled with a signed PCR policy. Re-enroll to use systemd-pcrlock"
		done
	fi

	generate_tpm2_predictions_pcrlock

	# Generate a PCR 15 prediction only in certain cases, as for
	# now this will ask the password (can be resolved by an
	# external tool that extract the password from the TPM2 if the
	# policy is still valid)
	#
	# For initial enrollments the LUKS2 key will be in the
	# keyring, so no password will be asked.  Internally the
	# function that ask for the volume key password will check
	# arg_measure_pcr (--measure-pcr)
	local missing_pcr_15=
	updated_crypttab || {
		generate_tpm2_predictions_pcr_15
		# shellcheck disable=SC2015
		if ! updated_crypttab || [ ! -f "/var/lib/sdbootutil/measure-pcr-prediction" ]; then
			warn "File measure-pcr-prediction should be updated"
			warn "Call sdbootutil update-predictions --measure-pcr"
			missing_pcr_15=1
		fi
	}

	# The user can remove measure-pcr-prediction file from the
	# ESP, but generate_tpm2_predictions_pcr_15 will not be called
	[ -f "${esp_root}${esp_dst}/measure-prediction" ] || {
		[ ! -e /var/lib/sdbootutil/measure-pcr-prediction ] || \
			cp /var/lib/sdbootutil/measure-pcr-prediction "${esp_root}${esp_dst}"
		[ ! -e /var/lib/sdbootutil/measure-pcr-prediction.sha256 ] || \
			cp /var/lib/sdbootutil/measure-pcr-prediction.sha256 "${esp_root}${esp_dst}"
	}

	# /etc/crypttab measures PCR 15 but the prediction does not
	# match it anymore, so `measure-pcr-validator` will halt the
	# next boot.  Report it to the caller of "update-predictions",
	# that can be a script that is about to do something
	# irreversible, like removing the key that is needed to
	# recompute the prediction.  When the predictions are a side
	# effect of another command (a kernel or a bootloader update)
	# the warning is enough, as failing there would also abort the
	# RPM transaction that triggered it
	[ -z "$missing_pcr_15" ] || [ -z "$explicit_predictions" ] || return 1
}

have_tracked_devices()
{
	[ "${#tracked_devices[@]}" -gt 0 ]
}

# A LUKS2 device is managed by sdbootutil when /etc/crypttab has a line
# for it, unless that line carries the "x-sdbootutil.ignore" option.
#
# The decision is which crypttab line describes this device, and that is
# what "is_same_device" answers, the same way that
# "add_crypttab_option" asks it.  Searching the file for the name of the
# device instead gets it wrong four ways: the match is not anchored to
# the device field, so a mention inside a comment counts; "." and "-" in
# a path are regular expression syntax; "/dev/sda2" is found in
# "/dev/sda21"; and a line that names the device by "PARTUUID=",
# "LABEL=" or "PARTLABEL=" is not found at all, which silently leaves
# the device unmanaged
is_tracked_device()
{
	local dev="${1:?}"
	local name device key opts

	# With no crypttab there is nothing that could exclude the
	# device, and nothing that could name it either
	[ -e /etc/crypttab ] || return 0

	while read -r name device key opts; do
		[[ "$name" != \#* ]] || continue
		[ -n "$name" ] || continue
		is_same_device "$dev" "$device" || continue
		[[ "$opts" != *"x-sdbootutil.ignore"* ]] || {
			dbg "Ignoring encrypted device $dev, un-tracked in /etc/crypttab"
			return 1
		}
		return 0
	done < /etc/crypttab

	dbg "Ignoring encrypted device $dev, absent from /etc/crypttab"
	return 1
}

detect_tracked_devices()
{
	local dev fstype
	! have_tracked_devices || return 0

	dbg_cat "/etc/crypttab"

	while read -r dev fstype; do
		[ "$fstype" = 'crypto_LUKS' ] || continue
		cryptsetup isLuks --type luks2 "$dev" || continue
		is_tracked_device "$dev" || continue
		dbg "Tracking encrypted device $dev"
		tracked_devices+=("$dev")
	done < <(lsblk --noheadings -o PATH,FSTYPE)
	have_tracked_devices
}

have_tpm2()
{
	[ -n "$(systemd-cryptenroll --tpm2-device=list 2> /dev/null)" ]
}

have_fido2()
{
	[ -n "$(systemd-cryptenroll --fido2-device=list 2> /dev/null)" ]
}

have_slot()
{
	local dev="${1:?}"
	local kind="${2:?}"
	grep -q "$kind" < <(systemd-cryptenroll "$dev")
}

luks_header()
{
	cryptsetup luksDump --dump-json-metadata "${1:?}"
}

# systemd-cryptenroll exits successfully even when it did not enroll
# anything.  A slot of the expected kind is not evidence by itself, as
# it can come from a previous enrollment, so the LUKS2 header is
# compared with the one collected before the enrollment
check_enrolled()
{
	local dev="${1:?}"
	local kind="${2:?}"
	local header="${3:?}"

	# Both are a failed enrollment, but they are not the same
	# failure and they are reported apart: the first one leaves the
	# device exactly as it was, and the second one does not.  Only
	# "/etc/crypttab" and the initrd are rolled back by the caller,
	# never the LUKS2 header, so whatever was written stays
	if [ "$header" = "$(luks_header "$dev")" ]; then
		warn "systemd-cryptenroll reported no error, but it left the header of $dev untouched: nothing was enrolled with $kind"
		return 1
	fi

	if ! have_slot "$dev" "$kind"; then
		warn "systemd-cryptenroll reported no error and did change the header of $dev, but there is no $kind slot in it. The change is something else, and it is still there"
		return 1
	fi
}

has_signed_pcr_policy()
{
	# A "tpm2_pubkey" field in the token means that a shard of the
	# sealed key is locked to a signed PCR policy, and that
	# unlocking the device also requires a tpm2-pcr-signature.json
	local dev="${1:?}"
	cryptsetup luksDump --dump-json-metadata "$dev" 2> /dev/null | \
		jq -e 'any(.tokens[]?; .type == "systemd-tpm2" and has("tpm2_pubkey"))' &> /dev/null
}

# A slot that can authorize a new one with nobody in front of the
# machine.  A TPM2 slot with a PIN cannot: systemd-cryptenroll has to
# unseal it, and it reads the current PIN from a terminal only.  Unlike
# its FIDO2 and password paths it does not pass ASK_PASSWORD_ACCEPT_CACHED,
# so %user:tpm2-pin is never consulted, and the only other source is
# $CREDENTIALS_DIRECTORY/cryptenroll.tpm2-pin, which nothing sets here.
# A FIDO2 token cannot either when it asks for its PIN, for user
# verification, or just for a touch
#
# "have_slot" cannot tell these apart, as it only greps the list that
# systemd-cryptenroll prints.  The LUKS2 token does say so
slot_is_unattended()
{
	local dev="${1:?}"
	local kind="${2:?}"
	local type interactive

	case "$kind" in
		tpm2)
			type="systemd-tpm2"
			interactive='."tpm2-pin" // false'
			;;
		fido2)
			type="systemd-fido2"
			interactive='[."fido2-clientPin-required", ."fido2-up-required", ."fido2-uv-required"] | any'
			;;
		*)
			return 1
			;;
	esac

	local header
	header="$(luks_header "$dev" 2> /dev/null)" || return 1

	jq -e --arg type "$type" 'any(.tokens[]?; .type == $type)' <<< "$header" &> /dev/null || return 1

	if jq -e --arg type "$type" "any(.tokens[]?; .type == \$type and ($interactive))" \
	      <<< "$header" &> /dev/null; then
		info "Not unlocking using ${kind^^}: the slot of $dev asks for a PIN or a touch"
		return 1
	fi
}

# Whether a slot of this kind can authorize a new one right now.  With
# a terminal there is somebody who can answer the PIN prompt that
# systemd-cryptenroll or the token itself raises, so any slot will do.
# Without one, only a slot that unseals on its own
can_unlock()
{
	local dev="${1:?}"
	local kind="${2:?}"

	if [ -t 0 ]; then
		have_slot "$dev" "$kind"
	else
		slot_is_unattended "$dev" "$kind"
	fi
}

in_lockout()
{
	command -v tpm2_getcap &> /dev/null || { warn "tpm2_getcap not found"; return 1; }
	tpm2_getcap properties-variable | grep -q 'inLockout: *1'
}

is_same_device()
{
	local dev1="$1"
	local dev2="$2"

	# If the device name is a specification, convert it as a real
	# device name.  crypttab(5) documents only "UUID=", but
	# systemd-cryptsetup-generator resolves these four (and only
	# these four) into /dev/disk/by-*
	local spec="^(UUID|PARTUUID|LABEL|PARTLABEL)="
	[[ "$dev1" =~ $spec ]] && dev1="$(blkid --list-one --output device --match-token "$dev1")"
	[[ "$dev2" =~ $spec ]] && dev2="$(blkid --list-one --output device --match-token "$dev2")"

	dev1="$(readlink -f "$dev1")"
	dev2="$(readlink -f "$dev2")"

	# A name that does not resolve into a block device is not the
	# same device as anything else.  Without this any two of them
	# match, as the device number is empty when `stat` fails, and
	# 0:0 for the image file that crypttab also accepts
	[ -b "$dev1" ] && [ -b "$dev2" ] || return 1

	local id1 id2
	id1="$(stat -c "%t:%T" "$dev1")"
	id2="$(stat -c "%t:%T" "$dev2")"

	[ "$id1" = "$id2" ]
}

# /etc/crypttab belongs to whoever wrote it, so only the line of the
# device being enrolled is ever rewritten: the comments, the blank lines
# and the spacing of every other entry are copied back untouched.  Both
# callers build the new file the same way and end here
#
# And it is installed only when a line really changed.  Replacing the
# file with an identical copy still moves the mtime and, on a
# transactional system, adds it to the snapshot, so a run with nothing
# to do would leave the same trace as a run that enrolled a device
replace_crypttab()
{
	local new="${1:?}"
	local changed="$2"

	[ -n "$changed" ] || {
		rm -f "$new"
		return 0
	}

	mv -Z "$new" /etc/crypttab
	chmod 644 /etc/crypttab
}

# Copy a line of /etc/crypttab that this run does not modify.  The
# header that sdbootutil used to write is the single exception: it
# announces that the comments will be removed, which is exactly what
# stopped being true, and dropping it is not a reason to rewrite the
# file by itself
copy_crypttab_line()
{
	local line="$1"
	local new="$2"

	[[ "$line" != "# File created by sdbootutil."* ]] || return 0
	printf '%s\n' "$line" >> "$new"
}

add_crypttab_option()
{
	local dev="$1"
	local option="$2"

	dbg "Adding \"$option\" for \"$dev\" to /etc/crypttab"
	dbg_cat "/etc/crypttab"

	local crypttab
	crypttab="$(mktemp -t crypttab.XXXXXX)"

	local found=
	local changed=
	local line
	local name
	local device
	local key
	local opts
	# A file with no newline at the end still has a last entry
	while IFS= read -r line || [ -n "$line" ]; do
		read -r name device key opts <<<"$line"
		# A comment, a blank line, another device, or one that
		# asked not to be tracked
		if [ -z "$name" ] || [[ "$name" = \#* ]] || \
		   ! is_same_device "$dev" "$device" || \
		   [[ "$opts" = *"x-sdbootutil.ignore"* ]]; then
			copy_crypttab_line "$line" "$crypttab"
			continue
		fi

		found=1
		if [[ "$opts" = *"$option"* ]]; then
			copy_crypttab_line "$line" "$crypttab"
			continue
		fi

		[ -z "$opts" ] && opts="$option" || opts="$opts,$option"
		# The key is written even when the entry did not have
		# one: with an empty third field the options would be
		# read as the key
		printf '%s\n' "$name $device ${key:-none} $opts" >> "$crypttab"
		changed=1
		# crypttab has changed so initrd needs to be updated
		arg_no_reuse_initrd=1
	done < /etc/crypttab

	replace_crypttab "$crypttab" "$changed"

	# The enrollment alone does not make the device unlock at boot:
	# when the option is not in /etc/crypttab (nor in the initrd
	# that embeds it) the boot can only ask for the password
	[ -n "$found" ] || \
		warn "$dev is not tracked by /etc/crypttab, so \"$option\" was not added"

	dbg "Added \"$option\" for \"$dev\" to /etc/crypttab"
	dbg_cat "/etc/crypttab"
}

remove_crypttab_option()
{
	local dev="$1"
	local option="$2"

	dbg "Removing \"$option\" for \"$dev\" to /etc/crypttab"
	dbg_cat "/etc/crypttab"

	local crypttab
	crypttab="$(mktemp -t crypttab.XXXXXX)"

	local changed=
	local line
	local name
	local device
	local key
	local opts
	while IFS= read -r line || [ -n "$line" ]; do
		read -r name device key opts <<<"$line"
		if [ -z "$name" ] || [[ "$name" = \#* ]] || \
		   ! is_same_device "$dev" "$device" || \
		   [[ "$opts" = *"x-sdbootutil.ignore"* ]] || \
		   [[ "$opts" != *"$option"* ]]; then
			copy_crypttab_line "$line" "$crypttab"
			continue
		fi

		opts="${opts#"$option",}"
		opts="${opts//,"$option"}"
		opts="${opts//"$option"}"
		printf '%s\n' "$name $device ${key:-none}${opts:+ $opts}" >> "$crypttab"
		changed=1
		# crypttab has changed so initrd needs to be updated
		arg_no_reuse_initrd=1
	done < /etc/crypttab

	replace_crypttab "$crypttab" "$changed"

	dbg "Removed \"$option\" for \"$dev\" to /etc/crypttab"
	dbg_cat "/etc/crypttab"
}

# systemd-cryptenroll needs to authorize the new slot with a
# credential that the device already accepts: a password (that it
# reads from %user:cryptenroll) or a token that can unlock it.  With
# neither it falls back to its own prompt, and when there is no
# terminal to answer it the enrollment blocks forever
require_unlock()
{
	local dev="${1:?}"

	unlock_method=

	# A password that the device accepts comes first, as a TPM2 or
	# a FIDO2 slot can be there and still not unlock: the policy
	# can be stale after a firmware update (which is a reason to
	# enroll again), and the token can be unplugged.  The password
	# is not used here, the side effect of get_device_password is
	# what matters: a valid one is left in %user:cryptenroll,
	# where systemd-cryptenroll looks for it
	[ -z "$(get_device_password "$dev")" ] || return 0

	# Do not use TPM2 slot for enrolling TPM2
	if [ "$arg_method" != "tpm2" ] && [ "$arg_method" != "tpm2+pin" ] && can_unlock "$dev" "tpm2"; then
		info "Unlocking using TPM2"
		unlock_method="--unlock-tpm2-device=auto"
	# Same for FIDO2
	elif [ "$arg_method" != "fido2" ] && can_unlock "$dev" "fido2"; then
		info "Unlocking using FIDO2"
		unlock_method="--unlock-fido2-device=auto"
	fi

	# A terminal means that there is somebody that can answer the
	# prompt from systemd-cryptenroll
	if [ -z "$unlock_method" ] && [ ! -t 0 ]; then
		warn "No valid password found for $dev, and there is no terminal to ask for one"
		warn "Provide it in the %user:cryptenroll kernel keyring entry"
		return 1
	fi

	return 0
}

enroll_tpm2()
{
	local dev="$1"
	local tpm2_pin="$2"
	local extra_args=()

	if [ -z "$tpm2_pin" ]; then
		info "Enrolling with TPM2: $dev"
	else
		info "Enrolling with TPM2+PIN: $dev"
		extra_args+=(--tpm2-with-pin=1)
	fi

	if [ ! -f /var/lib/systemd/pcrlock.json ]; then
		warn "Could not find /var/lib/systemd/pcrlock.json"
	fi

	# Make sure that systemd-cryptenroll can authorize the new
	# slot, with the password in cryptenroll or with a token
	require_unlock "$dev" || return 1
	if [ -n "$unlock_method" ]; then
		extra_args+=("$unlock_method")
	fi

	# Unless an empty --tpm2-public-key is passed,
	# systemd-cryptenroll loads "tpm2-pcr-public-key.pem" from the
	# configuration directories and adds a signed PCR policy as a
	# second shard of the sealed key, next to the pcrlock one.
	local pcr_oracle_key
	if pcr_oracle_key="$(pcr_oracle_public_key)"; then
		warn "Ignoring $pcr_oracle_key for the enrollment. Remove it, and the private key, if pcr-oracle is not used anymore"
	fi

	local header
	header="$(luks_header "$dev")"

	# Note that the PCRs are now not stored in the LUKS2 header
	NEWPIN="$tpm2_pin" systemd-cryptenroll \
	       --wipe-slot=tpm2 \
	       --tpm2-device=auto \
	       --tpm2-public-key= \
	       "${extra_args[@]}" \
	       --tpm2-pcrlock=/var/lib/systemd/pcrlock.json \
	       "$dev" || return 1
	check_enrolled "$dev" "tpm2" "$header" || return 1

	# Not all PCR predictions match the current system state and
	# will be excluded from the policy. Trigger an update on the
	# next boot which should include them
	systemctl --quiet enable sdbootutil-update-predictions.service || true
}

enroll_fido2()
{
	local dev="$1"

	info "Enrolling with FIDO2: $dev"

	local extra_args=()
	# Make sure that systemd-cryptenroll can authorize the new
	# slot, with the password in cryptenroll or with a token
	require_unlock "$dev" || return 1
	if [ -n "$unlock_method" ]; then
		extra_args+=("$unlock_method")
	fi

	local header
	header="$(luks_header "$dev")"

	systemd-cryptenroll --wipe-slot=fido2 --fido2-device=auto "${extra_args[@]}" "$dev" || return 1
	check_enrolled "$dev" "fido2" "$header" || return 1
}

enroll_password()
{
	local dev="$1"
	local pw="$2"

	info "Enrolling with password: $dev"

	local extra_args=()
	# Make sure that systemd-cryptenroll can authorize the new
	# slot, with the password in cryptenroll or with a token
	require_unlock "$dev" || return 1
	if [ -n "$unlock_method" ]; then
		extra_args+=("$unlock_method")
	fi

	local header
	header="$(luks_header "$dev")"

	NEWPASSWORD="$pw" systemd-cryptenroll --wipe-slot=password --password "${extra_args[@]}" "$dev" || return 1
	check_enrolled "$dev" "password" "$header" || return 1
}

enroll_recovery_key()
{
	local dev="$1"

	info "Enrolling with recovery key: $dev"

	local extra_args=()
	# Make sure that systemd-cryptenroll can authorize the new
	# slot, with the password in cryptenroll or with a token
	require_unlock "$dev" || return 1
	if [ -n "$unlock_method" ]; then
		extra_args+=("$unlock_method")
	fi

	# If no recovery key is provided, systemd-cryptenroll will
	# generate one
	local key keyid keyid_key keyid_int keyid_dep
	keyid_key="$(keyctl id %user:sdbootutil-key 2> /dev/null)" || true
	keyid_int="$(recovery_pin_keyid)" || true
	keyid_dep="$(keyctl id %user:sdbootutil 2> /dev/null)" || true
	if [ -n "$arg_ask_key" ]; then
		ask_new_password "recovery key" key
	elif [ -n "$env_key" ]; then
		key="$env_key"
	elif [ -n "$keyid_key" ]; then
		key="$(keyctl pipe "$keyid_key")"
	elif [ -n "$keyid_int" ]; then
		# There is no recovery key, but there is a recovery PIN:
		# make both the same one (see below)
		key="$(keyctl pipe "$keyid_int")"
	elif [ -n "$keyid_dep" ]; then
		deprecated_keyring_secret "recovery key" "sdbootutil-key"
		key="$(keyctl pipe "$keyid_dep")"
	fi

	local header
	header="$(luks_header "$dev")"

	local generated_key=
	# This function will be called for every device.  Let systemd
	# generate a secure recovery key only the first time if there
	# is no recovery key or pin selected (%user:sdbootutil-key,
	# %user:sdbootutil-recovery-pin)
	if [ -z "$key" ]; then
		# systemd-cryptenroll will put in stdout the recovery
		# key, and the rest of the information in stderr
		key="$(systemd-cryptenroll --wipe-slot=recovery --recovery-key "${extra_args[@]}" "$dev" 2> /dev/null)"
		generated_key=1
	else
		# A recovery key has already been generated, use it
		# for all the devices.  systemd-cryptenroll always
		# generates a random recovery key, but we want $key.
		# Replace it by using cryptsetup, so we can still use
		# %u:cryptenroll in systemd-cryptenroll, and the
		# temporary recovery key in cryptsetup, to avoid
		# requesting a password
		local tmp_key
		tmp_key="$(systemd-cryptenroll --wipe-slot=recovery --recovery-key "${extra_args[@]}" "$dev" 2> /dev/null)"
		local split
		read -r -a split < <(systemd-cryptenroll "$dev" | grep recovery)
		local keyslot="${split[0]}"
		# cryptsetup can only read the new passphrase from a
		# keyfile
		local tmp_key_file
		tmp_key_file="$(mktemp -t key_file.XXXXXX)"
		echo -n "$key" > "$tmp_key_file"
		cryptsetup luksChangeKey --key-slot "$keyslot" --force-password "$dev" "$tmp_key_file" <<<"$tmp_key"
		shred "$tmp_key_file"
	fi

	check_enrolled "$dev" "recovery" "$header" || return 1

	# If we enroll a recovery key first, we can use the generated
	# key as a recovery PIN later when we enroll a TPM2[+PIN]
	# (note that the recovery PIN is not the same PIN for the
	# tpm2+pin method).
	#
	# If we enroll the recovery key after the TPM2 enrollment and
	# we send the recovery PIN via the keyring, then we can make
	# the recovery key the same as the recovery PIN.  But if the
	# PIN is missing from the keyring, then we missed the
	# synchronization and the key and the PIN are different.
	if [ -z "$keyid_int" ] && have_slot "$dev" "tpm2"; then
		warn "There is already a recovery PIN for the TPM2"
		warn "The recovery key and the recovery PIN are now different"
	fi

	if [ -n "$generated_key" ]; then
		echo "Recovery key: $key"
		if [ -x /usr/bin/qrencode ]; then
			echo "You can also scan it with your mobile phone:"
			qrencode -t utf8i "$key"
		fi
	fi

	# Make sure that the registered recovery key is in the kernel
	# keyring, so that it is available to systemd-cryptenroll
	keyid="$(keyctl id %user:cryptenroll 2> /dev/null)" || true
	if [ -z "$keyid" ] && [ -z "$unlock_method" ]; then
		keyctl_add_with_timeout "cryptenroll" "$key"
	fi
	# ... and to --method=tpm2[+pin] via a private keyring.  An
	# entry that is already there holds the recovery PIN that
	# protects the pcrlock NV index, and it is not overwritten:
	# that PIN cannot be recovered from anywhere else
	if [ -z "$keyid_int" ]; then
		keyctl_add_with_timeout "sdbootutil-recovery-pin" "$key"
	elif [ "$key" != "$(keyctl pipe "$keyid_int")" ]; then
		warn "The recovery key and the recovery PIN are now different"
	fi
}

enroll_device()
{
	local dev="$1"
	local pin_or_pw="$2"

	case "$arg_method" in
		"tpm2"|"tpm2+pin")
			enroll_tpm2 "$dev" "$pin_or_pw"
			;;

		"fido2")
			enroll_fido2 "$dev"
			;;

		"password")
			enroll_password "$dev" "$pin_or_pw"
			;;

		"recovery-key")
			enroll_recovery_key "$dev"
			;;

		*)
			local msg
			if [ -n "$arg_method" ]; then
				msg="Unexpected parameter for --method=: $arg_method"
			else
				msg="Missing value for parameter --method="
			fi
			err "$msg\nValid methods: tpm2,tpm2+pin,fido2,password,recovery-key"
			;;
	esac
}

enroll()
{
	[ -e /etc/crypttab ] || { info "/etc/crypttab not found. No encrypted devices?"; return 0; }
	[ -e /usr/bin/systemd-cryptenroll ] || { info "systemd-cryptenroll not found"; return 0; }
	detect_tracked_devices || { info "No LUKS2 devices found"; return 0; }

	info "Enrolling devices ($arg_method): ${tracked_devices[*]}"

	# From here on /etc/crypttab can ask for a method that no keyslot
	# backs yet, and the trap puts it back if anything below fails
	cp -a /etc/crypttab "$tmpdir/crypttab"
	restore_crypttab=1

	# Prepare /etc/crypttab and update initrd if required
	case "$arg_method" in
		"tpm2"|"tpm2+pin")
			have_tpm2 || err "No TPM2 found"
			have_pcrlock || err "No systemd-pcrlock found"
			! in_lockout || err "The TPM2 is in lockout. Use 'tpm2_dictionarylockout -c [ -p passwd ]' to continue"
			# For predicting PCR 15 we need to sign a file.
			# Create the public and private key if missing,
			# before the initrd is regenerated below, as the
			# public key is installed inside it
			if [ -z "$arg_no_measure_pcr" ]; then
				# "|| rc=$?" and not a bare call, that
				# with "set -e" would end the script on
				# any return that is not 0
				local rc=0
				create_measure_pcr_keys || rc="$?"
				case "$rc" in
					# A new key pair must reach the
					# initrd, as the validator checks
					# the signature of the prediction
					# with the public key installed
					# there.  The rebuild below is
					# driven by /etc/crypttab, that can
					# be already correct (an image where
					# the device is enrolled after the
					# deployment, or a re-enrollment),
					# so ask for it here.  Otherwise the
					# prediction is signed with a key
					# that the initrd does not know, and
					# measure-pcr-validator.service
					# powers the machine off
					0) arg_no_reuse_initrd=1 ;;
					# Fatal, and here rather than a few
					# lines below, so that it happens
					# before /etc/crypttab is touched
					# and there is nothing to undo.
					# Carrying on would enroll the TPM2
					# against a prediction that cannot
					# be signed
					2) err "The key pair that signs the PCR 15 prediction cannot be created" ;;
				esac
			fi
			for dev in "${tracked_devices[@]}"; do
				add_crypttab_option "$dev" 'tpm2-device=auto'
				[ -n "$arg_no_measure_pcr" ] || add_crypttab_option "$dev" 'tpm2-measure-pcr=yes'
			done
			;;

		"fido2")
			have_fido2 || err "No FIDO2 key found"
			for dev in "${tracked_devices[@]}"; do
				add_crypttab_option "$dev" 'fido2-device=auto'
			done
			;;
	esac

	# If the crypttab file changed (that is expected), we need to
	# generate a new initrd
	if [ "$arg_no_reuse_initrd" = "1" ]; then
		install_all_kernels "$root_snapshot"
	fi

	if [ "$arg_method" = "tpm2" ] || [ "$arg_method" = "tpm2+pin" ]; then
		# During the initial enrollment it is expected that
		# for systemd-pcrlock the recovery PIN will be
		# extracted from the %u keyring "sdbootutil[-pin]"
		# entry
		#
		# This is done even when /etc/crypttab did not change,
		# as the enrollment points to an NV index that can be
		# missing (for example after a `tpm2_clear`) or to a
		# policy that does not match the current system
		generate_tpm2_predictions
		# Avoid the call of generate_tpm2_predictions at the
		# end of the script
		update_predictions=
	fi

	# For the PIN (tpm2+pin) or password (password), we can get it
	# from the %u keyring "sdbootutil-tpm2-pin" / "sdbootutil-pw"
	# entry, the PIN or PW environment variable, or introduced by the
	# user.  Like --ask-key in enroll_recovery_key, an explicit
	# --ask-pin or --ask-pw takes precedence over the other sources
	local pin_or_pw keyid keyid_dep
	if [ "$arg_method" = "tpm2+pin" ]; then
		keyid="$(keyctl id %user:sdbootutil-tpm2-pin 2> /dev/null)" || true
		keyid_dep="$(keyctl id %user:sdbootutil 2> /dev/null)" || true
		if [ -n "$arg_ask_pin" ]; then
			ask_new_password "TPM2 PIN" pin_or_pw
		elif [ -n "$env_pin" ]; then
			pin_or_pw="$env_pin"
		elif [ -n "$keyid" ]; then
			pin_or_pw="$(keyctl pipe "$keyid")"
		elif [ -n "$keyid_dep" ]; then
			deprecated_keyring_secret "TPM2 PIN" "sdbootutil-tpm2-pin"
			pin_or_pw="$(keyctl pipe "$keyid_dep")"
		else
			ask_new_password "TPM2 PIN" pin_or_pw
		fi
	elif [ "$arg_method" = "password" ]; then
		keyid="$(keyctl id %user:sdbootutil-pw 2> /dev/null)" || true
		keyid_dep="$(keyctl id %user:sdbootutil 2> /dev/null)" || true
		if [ -n "$arg_ask_pw" ]; then
			ask_new_password "password" pin_or_pw
		elif [ -n "$env_pw" ]; then
			pin_or_pw="$env_pw"
		elif [ -n "$keyid" ]; then
			pin_or_pw="$(keyctl pipe "$keyid")"
		elif [ -n "$keyid_dep" ]; then
			deprecated_keyring_secret "password" "sdbootutil-pw"
			pin_or_pw="$(keyctl pipe "$keyid_dep")"
		else
			ask_new_password "password" pin_or_pw
		fi
	fi

	local failed=
	for dev in "${tracked_devices[@]}"; do
		enroll_device "$dev" "$pin_or_pw" || { failed="$dev"; break; }
	done

	# /etc/crypttab is put back by the trap
	[ -z "$failed" ] || err "Failed to enroll ($arg_method): $failed"

	# The slots are in place, so the options in /etc/crypttab are
	# backed now and must stay
	restore_crypttab=
}

unenroll_all_pcrlock()
{
	systemctl --quiet disable sdbootutil-update-predictions.service || true
	pcrlock remove-policy &> /dev/null || true
	rm -fr /var/lib/pcrlock.d/* || true
	rm -f /var/lib/systemd/pcrlock.json
	rm -f "${esp_root}${esp_dst}/pcrlock.json"
	rm -f /var/lib/sdbootutil/pcrlock-hashes
	rm -f /var/lib/sdbootutil/crypttab.sha1
	rm -f /var/lib/sdbootutil/measure-pcr-private.pem
	rm -f /var/lib/sdbootutil/measure-pcr-public.pem
	rm -f /var/lib/sdbootutil/measure-pcr-prediction
	rm -f /var/lib/sdbootutil/measure-pcr-prediction.sha256
	rm -f "${esp_root}${esp_dst}/measure-pcr-prediction"
	rm -f "${esp_root}${esp_dst}/measure-pcr-prediction.sha256"
}

unenroll_all_pcr_oracle()
{
	rm -f /etc/systemd/tpm2-pcr-private-key.pem
	rm -f /etc/systemd/tpm2-pcr-public-key.pem
	rm -f /etc/systemd/tpm2-pcr-signature.json
	rm -f "${esp_root}${esp_dst}/tpm2-pcr-public-key.pem"
	rm -f "${esp_root}${esp_dst}/tpm2-pcr-signature.json"
	rm -f /var/lib/sdbootutil/measure-pcr-prediction
	rm -f /var/lib/sdbootutil/measure-pcr-prediction.sha256
	rm -f "${esp_root}${esp_dst}/measure-pcr-prediction"
	rm -f "${esp_root}${esp_dst}/measure-pcr-prediction.sha256"
}

unenroll_device()
{
	local dev="$1"

	case "$arg_method" in
		"tpm2"|"tpm2+pin")
			systemd-cryptenroll \
				--wipe-slot=tpm2 \
				"$dev"
			;;

		"fido2")
			systemd-cryptenroll \
				--wipe-slot=fido2 \
				"$dev"
			;;

		"password")
			systemd-cryptenroll \
				--wipe-slot=password \
				"$dev"
			;;

		"recovery-key")
			systemd-cryptenroll \
				--wipe-slot=recovery \
				"$dev"
			;;

		*)
			local msg
			if [ -n "$arg_method" ]; then
				msg="Unexpected parameter for --method=: $arg_method"
			else
				msg="Missing value for parameter --method="
			fi

			local methods=()
			local line
			while read -r line; do
				[[ "$line" =~ [[:digit:]]+[[:space:]] ]] || continue
				IFS=" " read -r -a s <<<"$line"
				methods+=("${s[1]}")
			done < <(systemd-cryptenroll "$dev")
			if [ "${#methods[@]}" -eq 0 ]; then
				err "$msg\nThe device $dev has no enrollment"
			else
				local methods
				methods="$(IFS=','; echo "${methods[*]}")"
				err "$msg\nMethods present in $dev: $methods"
			fi
			;;
	esac
}

unenroll()
{
	[ -e /etc/crypttab ] || { info "/etc/crypttab not found. No encrypted devices?"; return 0; }
	[ -e /usr/bin/systemd-cryptenroll ] || { info "systemd-cryptenroll not found"; return 0; }
	detect_tracked_devices || { info "No LUKS2 devices found"; return 0; }

	info "Unenrolling devices ($arg_method): ${tracked_devices[*]}"

	# Prepare /etc/crypttab and update initrd if required
	case "$arg_method" in
		"tpm2"|"tpm2+pin")
			have_tpm2 || err "No TPM2 found"
			for dev in "${tracked_devices[@]}"; do
				remove_crypttab_option "$dev" 'tpm2-device=auto'
				remove_crypttab_option "$dev" 'tpm2-measure-pcr=yes'
			done
			;;

		"fido2")
			have_fido2 || err "No FIDO2 key found"
			for dev in "${tracked_devices[@]}"; do
				remove_crypttab_option "$dev" 'fido2-device=auto'
			done
			;;
	esac
	if [ "$arg_no_reuse_initrd" = "1" ]; then
		install_all_kernels "$root_snapshot"
		# Avoid the call of generate_tpm2_predictions at the
		# end of the script
		update_predictions=
	fi

	for dev in "${tracked_devices[@]}"; do
		unenroll_device "$dev"
	done

	# We can have a partial unenroll, so we drop the policy and
	# the assets only if there is no device left with a "tpm2"
	# slot.  Multiple devices shares the same NVIndex.
	if [ "$arg_method" = "tpm2" ] || [ "$arg_method" = "tpm2+pin" ]; then
		local unenroll_all=1
		# Reset the detected devices
		tracked_devices=()
		detect_tracked_devices
		for dev in "${tracked_devices[@]}"; do
			if have_slot "$dev" "tpm2"; then
				unenroll_all=0
				break
			fi
		done
		if [ "$unenroll_all" = 1 ]; then
			unenroll_all_pcrlock
			unenroll_all_pcr_oracle
		fi
	fi
}

# Every full disk encryption report starts with the same two questions:
# how is this machine encrypted, and is the TPM2 going to open it on the
# next boot.  Answering them by hand means reading `cryptsetup luksDump`,
# /etc/crypttab, /var/lib/systemd/pcrlock.json, /var/lib/pcrlock.d, the
# LUKS2 tokens and the journal, and knowing how the four relate.  That is
# the first exchange of every issue, and half of them are closed without
# the answer ever arriving
#
# Everything here only reads, so it is safe to ask a reporter to run: no
# policy is created, no keyring entry is written, the NVIndex is not
# touched and not a single file changes.  It also has to say something
# useful on a machine whose TPM2 is unusable, whose policy is missing, or
# that is not enrolled at all, which is why so much of it reads "unknown"
#
# No secret is ever printed, only whether it exists.  The recovery PIN,
# the recovery key and the volume key are named, never shown
#
# Each section prints as it is probed.  jq is used where it parses what
# something else produced (the LUKS2 header, `bootctl list`, the pcrlock
# policy, the measurement log) and nowhere else: a probe that dies half
# way then costs one section instead of the whole report

# The label is padded so that every value in the report starts at the
# same column, which is what makes a wrong one easy to find.  Devices
# indent their rows one level and pad two characters less, so the values
# still line up with the rest
status_row()
{
	printf '  %-17s%s\n' "${1}:" "$2"
}

status_subrow()
{
	printf '    %-15s%s\n' "${1}:" "$2"
}

# "yes" for a flag that is set, "no" for one that is not
status_yn()
{
	[ -z "$1" ] && echo no || echo yes
}

# "a, b, c", empty for no arguments
status_join()
{
	local out="" i

	for i in "$@"; do
		[ -n "$out" ] && out="$out, $i" || out="$i"
	done
	echo "$out"
}

# What the trailing warnings need, and the only thing that crosses from
# one section to another.  They are set while their section prints
status_policy_backend=
status_policy_stale=
status_tpm2_device=
status_pin_reachable=

status_crypttab_field()
{
	# The crypttab line that describes this device, if any.  Which
	# line that is, is what "is_same_device" answers, the same way
	# that "is_tracked_device" and "add_crypttab_option" ask it
	local dev="${1:?}"
	local field="${2:?}"
	local name device key opts

	[ -e /etc/crypttab ] || return 0

	while read -r name device key opts; do
		[[ "$name" != \#* ]] || continue
		[ -n "$name" ] || continue
		is_same_device "$dev" "$device" || continue
		case "$field" in
			name) echo "$name" ;;
			options) echo "$opts" ;;
		esac
		return 0
	done < /etc/crypttab
}

status_device()
{
	local dev="${1:?}"
	local header name opts

	# A header that cannot be read is not a reason to give up on the
	# rest of the report.  It is the answer to "why does this device
	# not open", so it is reported as such
	header="$(luks_header "$dev" 2> /dev/null)" || header=""
	name="$(status_crypttab_field "$dev" name)"
	opts="$(status_crypttab_field "$dev" options)"

	local tracked=1 ignored=
	is_tracked_device "$dev" 2> /dev/null || tracked=
	[[ "$opts" != *"x-sdbootutil.ignore"* ]] || ignored=1

	# The keyslots come from the LUKS2 JSON and not from the "SLOT
	# TYPE" column that `systemd-cryptenroll` prints, which calls
	# everything it does not know "other".  A slot that no token
	# points at is a plain password: that is the only thing that can
	# still open the device when the TPM2 refuses to, so it is the
	# one fact worth getting right
	#
	# A "tpm2_pubkey" in the token means that a shard of the sealed
	# key is locked to a signed PCR policy, so unlocking also needs a
	# tpm2-pcr-signature.json.  "tpm2_pcrlock" means the NVIndex
	# policy instead.  They are two different backends and the
	# difference decides where to look next
	local slots=""
	[ -z "$header" ] || slots="$(jq -r '
		(.tokens // {}) as $tokens |
		(.keyslots // {}) | keys[] | . as $slot |
		($tokens | to_entries
		         | map(select(.value.keyslots // [] | index($slot)))
		         | first) as $token |
		[ $slot,
		  (if $token == null then "password"
		   elif $token.value.type == "systemd-tpm2" then
			(if ($token.value."tpm2-pin" // false) then "tpm2+pin" else "tpm2" end)
		   elif $token.value.type == "systemd-fido2" then "fido2"
		   elif $token.value.type == "systemd-recovery" then "recovery-key"
		   else $token.value.type end),
		  ($token.value.type // ""),
		  (if $token == null then ""
		   elif ($token.value | has("tpm2_pubkey")) then ", signed policy"
		   elif ($token.value.tpm2_pcrlock // false) then ", pcrlock NVIndex"
		   else "" end)
		] | @tsv' <<<"$header")"

	local slot method token extra methods=()
	while IFS=$'\t' read -r slot method token extra; do
		[ -n "$slot" ] || continue
		methods+=("$method")
	done <<<"$slots"

	# Sorted and without repetitions, so that the same machine always
	# reads the same way
	local unique=()
	[ "${#methods[@]}" -eq 0 ] || \
		mapfile -t unique < <(printf '%s\n' "${methods[@]}" | sort -u)

	# A device that only the TPM2 can open is one firmware update
	# away from being unopenable, and that is worth saying out loud
	local tpm2_only="" m
	if [ "${#unique[@]}" -gt 0 ]; then
		tpm2_only=1
		for m in "${unique[@]}"; do
			[[ "$m" == tpm2* ]] || tpm2_only=
			[[ "$m" != tpm2* ]] || status_tpm2_device=1
		done
	fi

	echo "  ${dev}${name:+ ($name)}"

	# In the summary the tracking is only worth a line when it is not
	# the plain "yes": an untracked or an ignored device is the
	# explanation for a password prompt that nobody expected
	if [ -n "$arg_full" ] || [ -n "$ignored" ] || [ -z "$tracked" ]; then
		if [ -n "$ignored" ]; then
			status_subrow "Tracked" "no (x-sdbootutil.ignore)"
		elif [ -n "$tracked" ]; then
			status_subrow "Tracked" "yes"
		else
			status_subrow "Tracked" "no (not in /etc/crypttab)"
		fi
	fi

	[ -n "$header" ] || status_subrow "LUKS2 header" "CANNOT BE READ"

	if [ "${#unique[@]}" -eq 0 ]; then
		status_subrow "Unlocks with" "nothing"
	else
		status_subrow "Unlocks with" "$(status_join "${unique[@]}")"
	fi

	if [ -n "$arg_full" ]; then
		if [[ "$opts" == *"tpm2-measure-pcr=yes"* ]]; then
			status_subrow "PCR 15" "measured"
		else
			status_subrow "PCR 15" "not measured"
		fi
	fi

	[ -z "$tpm2_only" ] || \
		echo "    WARNING: only the TPM2 can open this device.  A firmware update, a bootloader update or a cleared TPM2 leaves it unopenable"

	[ -z "$arg_full" ] || \
		while IFS=$'\t' read -r slot method token extra; do
			[ -n "$slot" ] || continue
			echo "    slot ${slot}: ${method}${token:+ ($token)}${extra}"
		done <<<"$slots"
}

status_devices()
{
	local dev fstype all=()

	detect_tracked_devices > /dev/null 2>&1 || :

	# Not only the tracked ones.  A device that is missing from
	# /etc/crypttab, or that carries "x-sdbootutil.ignore", is
	# precisely the kind of thing that explains a boot that asks for
	# a password nobody expected, so it is listed and marked
	while read -r dev fstype; do
		[ "$fstype" = 'crypto_LUKS' ] || continue
		cryptsetup isLuks --type luks2 "$dev" 2> /dev/null || continue
		all+=("$dev")
	done < <(lsblk --noheadings -o PATH,FSTYPE)

	echo "Devices"
	if [ "${#all[@]}" -eq 0 ]; then
		echo "  none"
		return 0
	fi

	for dev in "${all[@]}"; do
		status_device "$dev"
	done
}

status_policy()
{
	local policy=/var/lib/systemd/pcrlock.json
	local esp_policy="${esp_root}${esp_dst}/pcrlock.json"

	if [ -e "$policy" ]; then
		status_policy_backend="pcrlock"
	elif is_pcr_oracle; then
		status_policy_backend="pcr-oracle"
	fi

	echo "TPM2 policy"
	status_row "Backend" "${status_policy_backend:-none}"

	[ -n "$status_policy_backend" ] || {
		echo "  No policy: the TPM2 does not open any device"
		return 0
	}

	# The two copies are meant to be identical: the one in the ESP is
	# imported by `dracut-pcr-signature`, and the local one stores
	# the sealed PIN that `systemd-pcrlock` needs to replace the
	# NVIndex.  A machine with only one of them still boots, and
	# fails the *next* update
	local esp_state="missing"
	if [ -e "$esp_policy" ]; then
		if [ ! -e "$policy" ]; then
			esp_state="only-in-esp"
		elif cmp -s "$policy" "$esp_policy"; then
			esp_state="same"
		else
			esp_state="different"
		fi
	fi

	# `is_pcr_oracle` is also a backend, and it leaves no pcrlock.json
	# behind.  Everything that is read out of that file is then
	# unknown, which is not the same as absent
	local have_policy="" bank="sha256"
	if [ -e "$policy" ]; then
		have_policy=1
		bank="$(jq -r '.pcrBank // "sha256"' < "$policy")"
	fi

	if [ -n "$arg_full" ]; then
		local nv_index=""
		[ -z "$have_policy" ] || nv_index="$(jq -r '.nvIndex // empty' < "$policy")"
		status_row "NVIndex" "${nv_index:-unknown}"
		if [ -z "$have_policy" ]; then
			status_row "PCR bank" "unknown"
		else
			status_row "PCR bank" "$bank"
		fi
		status_row "Copy in the ESP" "$esp_state"
		if [ -z "$have_policy" ]; then
			status_row "Shim changed" "unknown"
		elif shim_changed; then
			status_row "Shim changed" "yes"
		else
			status_row "Shim changed" "no"
		fi
	fi

	# Rebuilt instead of echoed back, so that a list with an empty or
	# a repeated field still reads the way the rest of the report does
	local requested="" i
	for i in ${FDE_SEAL_PCR_LIST//,/ }; do
		[ -n "$i" ] || continue
		[ -n "$requested" ] && requested="$requested,$i" || requested="$i"
	done
	status_row "Requested PCRs" "${requested:-none}"

	echo "  Bound PCRs:"

	[ -n "$have_policy" ] || {
		echo "    none, the NVIndex is not bound to any PCR"
		return 0
	}

	# For every bound PCR: how many alternative values the policy
	# accepts, how far that is from the limit where the PCR gets
	# dropped from the next policy, and whether the value the TPM2
	# has right now is one of them.  The last one is exactly what the
	# TPM2 checks when the NVIndex has to be re-authorized, and
	# reading it costs two files and no credential at all
	local values current mark match bound=""
	while read -r i; do
		bound=1
		values="$(jq -r --argjson p "$i" '.pcrValues[] | select(.pcr == $p) | .values | length' < "$policy")"
		if [ ! -r "/sys/class/tpm/tpm0/pcr-$bank/$i" ]; then
			mark="?"
			match="cannot be read"
		else
			# sysfs writes them in upper case, the policy in
			# lower
			current="$(tr 'A-Z' 'a-z' < "/sys/class/tpm/tpm0/pcr-$bank/$i")"
			if jq -e --arg c "$current" --argjson p "$i" \
			      '.pcrValues[] | select(.pcr == $p) | .values | index($c)' \
			      < "$policy" > /dev/null; then
				mark=" "
				match="matches"
			else
				mark="!"
				match="DOES NOT MATCH"
			fi
		fi
		# The mark is what the eye finds first, so it is spent on
		# the PCR that does not match and not on the ones that do
		echo "   $mark PCR $i: $values value(s), $((8 - values)) before the PolicyOR limit, current value $match"
	done < <(jq -r '.pcrValues[].pcr' < "$policy")

	[ -n "$bound" ] || echo "    none, the NVIndex is not bound to any PCR"

	status_policy_stale="$(policy_stale_pcrs)"

	local missing_list
	missing_list="$(policy_missing_pcrs "$FDE_SEAL_PCR_LIST")"
	[ -n "$missing_list" ] || return 0

	# The reason a PCR was dropped only comes out of a prediction,
	# and `systemd-pcrlock predict` takes seconds.  It is run only
	# when there is something to explain, so the ordinary report,
	# where every requested PCR is bound, stays instant
	local prediction="" pcrlock_status=0
	info "Predicting PCRs to find out why $missing_list were dropped, this takes a few seconds"
	prediction="$(SYSTEMD_LOG_LEVEL=warning pcrlock --pcr="$FDE_SEAL_PCR_LIST" --json=short predict 2> /dev/null)" || pcrlock_status=$?
	[ "$pcrlock_status" -eq 0 ] || prediction=""

	echo "  Requested but not bound:"
	local reason
	for i in ${missing_list//,/ }; do
		if [ -z "$prediction" ]; then
			reason="unknown, the prediction failed"
		elif jq -e --argjson p "$i" '[.sha256[]? | select(.pcr == $p and (.values | length > 8))] | length > 0' \
			<<<"$prediction" > /dev/null; then
			reason="too many alternative values for the PolicyOR"
		elif jq -e --argjson p "$i" '[.sha256[]? | select(.pcr == $p)] | length > 0' \
			<<<"$prediction" > /dev/null; then
			# It can be predicted now but the policy does not
			# bind it, so the policy is simply older than the
			# prediction
			reason="predictable now, the policy is older than the components"
		else
			reason="no component matches the event log"
		fi
		echo "    PCR $i: $reason"
	done
}

# Replay the sha256 PCR 15 events that systemd recorded for this boot,
# and return the value that PCR 15 had once the last one was measured.
# Empty when nothing was measured
#
# The measurements of interest are the volume keys, one per encrypted
# device that carries `tpm2-measure-pcr=yes`, extended in the order in
# which the devices were unlocked.  That order is what
# measure-pcr-generator fixes, and it is why the prediction is tied to
# the crypttab
status_pcr15_replay()
{
	local log="${1:?}"
	local value="0000000000000000000000000000000000000000000000000000000000000000"
	local digest measured=

	# The log is RFC 7464 JSON-seq, so every record is introduced by a
	# 0x1e separator that jq refuses to read as JSON
	while read -r digest; do
		# TPM_Extend is a hash over the concatenation of the two
		# binary digests.  `printf %b` writes the NUL bytes
		# straight to the pipe, which a command substitution
		# around the binary would have eaten
		value="$(printf '%b' "$(sed 's/../\\x&/g' <<<"$value$digest")" | \
				openssl dgst -sha256 -r | cut -d' ' -f1)"
		measured=1
	done < <(tr -d '\036' < "$log" | \
			jq -r 'select(.pcr == 15 and .content.eventType == "volume-key")
			       | .digests[] | select(.hashAlg == "sha256") | .digest' \
			   2> /dev/null)

	[ -z "$measured" ] || echo "$value"
}

status_pcr15()
{
	local prediction=/var/lib/sdbootutil/measure-pcr-prediction

	echo "PCR 15"

	if ! grep -q "tpm2-measure-pcr=yes" /etc/crypttab 2> /dev/null; then
		status_row "Enabled" "no"
		return 0
	fi
	status_row "Enabled" "yes"

	if [ -n "$arg_full" ]; then
		local present="MISSING" signed=", NOT SIGNED"
		[ ! -e "$prediction" ] || present="present"
		[ ! -e "$prediction.sha256" ] || signed=", signed"
		status_row "Prediction" "$present$signed"

		if [ -e /var/lib/sdbootutil/measure-pcr-public.pem ]; then
			status_row "Public key" "yes"
		else
			status_row "Public key" "no"
		fi

		if [ -d /usr/lib/dracut/modules.d/50measure-pcr ]; then
			status_row "Dracut module" "yes"
		else
			status_row "Dracut module" "no"
		fi

		# The prediction is tied to the crypttab it was made
		# from: the order of the entries decides the order of the
		# extensions, so a crypttab that changed invalidates it
		local crypttab_sha1
		if [ ! -e /etc/crypttab ] || [ ! -e /var/lib/sdbootutil/crypttab.sha1 ]; then
			status_row "Crypttab" "unknown"
		else
			read -r crypttab_sha1 _ < <(sha1sum /etc/crypttab)
			if grep -Fixq "$crypttab_sha1" /var/lib/sdbootutil/crypttab.sha1; then
				status_row "Crypttab" "unchanged since the prediction"
			else
				status_row "Crypttab" "CHANGED after the prediction, the order of the extensions may differ"
			fi
		fi
	fi

	# The prediction has one line per digest, in the order that
	# `generate_tpm2_predictions_pcr_15` writes them, and the second
	# one is sha256
	local predicted=""
	[ ! -r "$prediction" ] || predicted="$(sed -n 2p "$prediction")"
	predicted="${predicted,,}"

	# Do *not* compare against the register in /sys.  The prediction
	# describes PCR 15 right after the volume keys are measured, which
	# is the only moment measure-pcr-validator looks at it, and after
	# that anything is free to extend it further (systemd-pcrfs and
	# systemd-pcrmachine do, on the systems that ship them).  A machine
	# that booted perfectly then reads back a value that matches
	# nothing, and reporting that as a mismatch is a false alarm
	#
	# Replay instead systemd's own record of what it measured in this
	# boot, which is what the validator saw.  Its absence is not an
	# error either: it lives in /run and only exists once something has
	# been measured
	local measured="" log=/run/log/systemd/tpm2-measure.log
	[ ! -r "$log" ] || measured="$(status_pcr15_replay "$log")"

	# Nothing measured at all is a different failure from a wrong
	# measurement: it is what happens when the initrd carries no
	# generator, or when the generator produced no drop-in for the
	# device, and it leaves PCR 15 untouched instead of wrong
	if [ -r "$log" ] && [ -z "$measured" ]; then
		status_row "This boot" "NOTHING was measured into PCR 15"
	elif [ -z "$predicted" ] || [ -z "$measured" ]; then
		status_row "This boot" "cannot be told, systemd recorded no measurement"
	elif [ "$predicted" = "$measured" ]; then
		status_row "This boot" "measured as predicted"
	else
		status_row "This boot" "the measurement DOES NOT MATCH the prediction"
	fi
}

status_recovery_pin()
{
	# Only whether the recovery PIN can be reached, never the PIN.
	# Without it a policy that no longer matches the PCRs cannot be
	# rewritten, and that is a dead end rather than an inconvenience
	# (github issue 250)
	local keyring="" environment="" issue="" f

	! recovery_pin_keyid > /dev/null 2>&1 || keyring=1
	[ -z "$env_recovery_pin$env_pin" ] || environment=1
	for f in /run/issue.d/91-recovery-pin.issue /run/issue.d/91-recovery-pin.conf; do
		[ ! -e "$f" ] || issue=1
	done

	[ -z "$keyring$environment$issue" ] || status_pin_reachable=1

	echo "Recovery PIN (only whether it can be reached, never its value)"

	if [ -n "$arg_full" ]; then
		status_row "Kernel keyring" "$(status_yn "$keyring")"
		status_row "Environment" "$(status_yn "$environment")"
		status_row "Issue file" "$(status_yn "$issue")"
		return 0
	fi

	local where=()
	[ -z "$keyring" ] || where+=("kernel keyring")
	[ -z "$environment" ] || where+=("environment")
	[ -z "$issue" ] || where+=("issue file")

	if [ "${#where[@]}" -eq 0 ]; then
		status_row "Reachable" "no"
	else
		status_row "Reachable" "yes ($(status_join "${where[@]}"))"
	fi
}

status_service()
{
	local unit="${1:?}"
	local result exit_status timestamp

	result="$(systemctl show -P Result "$unit" 2> /dev/null)" || result=""
	exit_status="$(systemctl show -P ExecMainStatus "$unit" 2> /dev/null)" || exit_status=""
	timestamp="$(systemctl show -P ExecMainExitTimestamp "$unit" 2> /dev/null)" || timestamp=""

	# `Result` is "success" for a unit that never ran, and for one
	# that systemd does not know about at all.  Reporting that as a
	# success is worse than saying nothing: it answers "did the
	# predictions get updated" with a yes.  The exit timestamp is
	# what separates the two
	[ -n "$timestamp" ] || { result=""; exit_status=""; }

	echo "Last update-predictions"
	status_row "Result" "${result:-never ran}${exit_status:+ (exit $exit_status)}"
	status_row "Finished" "${timestamp:-never}"
}

# Every file that can configure this run, in the order that they are
# read.  /etc/sysconfig/bootloader comes first and carries LOADER_TYPE,
# SECURE_BOOT and UPDATE_NVRAM; the sdbootutil and fde-tools files come
# afterwards, in the order that `load_config_file` uses.  They are all
# sourced, so the last assignment of a name is the one that survives
status_config_files=(/etc/sysconfig/bootloader
		     /usr/etc/default/sdbootutil
		     /etc/default/sdbootutil
		     /usr/etc/default/fde-tools
		     /etc/default/fde-tools
		     /etc/sysconfig/fde-tools)

# The last file that assigns this name, which is the one that decided
# the value.  Empty when no file mentions it
#
# A grep and not a re-source: sourcing the files again to find out
# where a value came from would run their code a second time, and
# "status" only reads
status_config_origin()
{
	local name="${1:?}" f origin=""

	for f in "${status_config_files[@]}"; do
		[ -e "$f" ] || continue
		grep -qE "^[[:space:]]*(export[[:space:]]+)?$name=" "$f" 2> /dev/null || continue
		origin="$f"
	done
	echo "$origin"
}

# The configuration this run ended up with, and where each value came
# from.  A wrong PCR list or a ROOTFS that does not match the cmdline
# explains an entry that does not boot, and neither is visible anywhere
# else
status_config()
{
	local read_files=() f
	for f in "${status_config_files[@]}"; do
		[ ! -e "$f" ] || read_files+=("$f")
	done

	echo "Configuration"
	if [ "${#read_files[@]}" -eq 0 ]; then
		status_row "Files read" "none, every value below is a default"
	else
		status_row "Files read" "$(status_join "${read_files[@]}")"
	fi
	status_row "Entry token" "${entry_token:-unknown}"
	status_row "ESP directory" "${esp_root}${esp_dst}"

	# Every line is the name of the setting, the CLI argument that
	# overrides it ("-" when it has none and is only read from a
	# file), and what the setting does.  The name alone is not always
	# enough: SECURE_BOOT does not describe the firmware, it only asks
	# for the shim to be installed, and nothing in sdbootutil decides
	# it by looking at what the firmware enforces
	local name arg note value origin cli where
	while read -r name arg note; do
		[ -n "$name" ] || continue
		value="${!name}"
		[ "$arg" = "-" ] && cli="" || cli="${!arg}"
		origin="$(status_config_origin "$name")"

		if [ -n "$cli" ]; then
			where="from the command line"
		elif [ "$name" = "ROOTFS" ] && is_rootfs_crypt; then
			where="forced, the root device is encrypted"
		elif [ -n "$origin" ] && [ -z "$value" ]; then
			# A file that assigns an empty value is not the
			# same as a name that no file mentions, even
			# though both end up unset
			where="empty in $origin"
		elif [ -n "$origin" ]; then
			where="from $origin"
		else
			where="not set anywhere, this is the default"
		fi

		printf '  %-25s%s\n' "${name}:" "${value:-(unset)}"
		# What the setting does and where the value came from, on
		# their own line: the name is not always enough, and both
		# together do not fit in a terminal
		echo "      $note; $where"
	done <<-EOF
		FDE_SEAL_PCR_LIST arg_pcr the PCRs to seal against
		ROOTFS arg_rootfs how the cmdline names the root
		ESP_FREE_SPACE arg_esp_free_space percentage of the ESP to keep free
		DEVICETREE_SOURCE arg_devicetree_source device tree blob to copy
		LOADER_TYPE - the boot loader to manage
		SECURE_BOOT - "yes" installs the shim, whatever the firmware does
		UPDATE_NVRAM - "no" keeps sdbootutil out of the EFI variables
		LAST_WORKING_SNAPSHOTS - extra snapshots to keep in the prediction
	EOF
}

# What the machine booted, and what it will boot next
status_boot()
{
	echo "Boot loader"
	status_row "Type" "$(bootloader_name 2> /dev/null || echo unknown)"

	# The version in the ESP against the version that the system
	# ships.  Both can be missing on their own: no bootloader
	# installed yet, or a snapshot with no bootloader package
	local deployed="" system="" path=""
	deployed="$(bootloader_version 2> /dev/null)" || deployed=""
	path="$(find_bootloader 2> /dev/null)" || path=""
	# Only with a path: with an empty argument "bootloader_version"
	# falls back to the copy in the ESP, and it would compare the
	# deployed version against itself
	[ -z "$path" ] || system="$(bootloader_version "$path" 2> /dev/null)" || system=""

	if [ -z "$deployed" ]; then
		status_row "In the ESP" "no bootloader found in the ESP"
	elif [ -z "$system" ]; then
		status_row "In the ESP" "$deployed, the version of the system cannot be read"
	else
		# The comparison goes to stdout ("261.2 == 261.2"), and
		# only the exit status is wanted here
		local cmp=0
		systemd-analyze compare-versions "$deployed" "$system" > /dev/null 2>&1 || cmp="$?"
		case "$cmp" in
			11) status_row "In the ESP" "$deployed, newer than the $system of the system" ;;
			12) status_row "In the ESP" "OUTDATED: $deployed in the ESP, $system in the system" ;;
			*)  status_row "In the ESP" "up to date ($deployed)" ;;
		esac
	fi

	# Its own copy of the entry list, and not "update_entries": that
	# one writes "$entryfile", which the rest of the report reads for
	# other purposes
	local entries
	entries="$(bootctl list --json=short 2> /dev/null)" || entries="[]"
	[ -n "$entries" ] || entries="[]"

	# `bootctl` builds "isSelected" by matching the entry ID against
	# "LoaderEntrySelected", but grub2-bls writes the variable without
	# the ".conf" suffix, so nothing matches there and "isSelected" is
	# always null.  Compare both spellings against the variable, which
	# `bli_efi_var_get` returns lower-cased (same workaround as in
	# "list_entries")
	local selected="" booted=""
	selected="$(bli_efi_var_get "LoaderEntrySelected" 2> /dev/null)" || selected=""
	booted="$(jq -r --arg s "$selected" \
		     'first(.[] | select(.isSelected == true
					 or (.id | ascii_downcase) == $s
					 or (.id | ascii_downcase) == ($s + ".conf"))
			   | .id) // empty' <<<"$entries")"
	# No entry claims it, so report the raw variable: on a machine
	# that booted something the ESP no longer offers, the name is the
	# answer and "unknown" is not
	[ -n "$booted" ] || booted="$selected"

	# grub2-bls does not write the variable at all, so on those
	# systems nothing above can answer.  Look instead for the entry
	# that describes what is running: the kernel release, and the
	# subvolume of the running snapshot where there are snapshots.
	# Only when a single entry matches, because two entries that
	# describe the same thing cannot be told apart from here
	#
	# This is where the entry booted from, unless it was rewritten
	# since, so it is worth having but has to be labelled as a guess
	local guess=""
	if [ -z "$booted" ]; then
		local subvol=""
		[ -z "$have_snapshots" ] || subvol="${subvol_prefix}/.snapshots/${root_snapshot}/snapshot"
		booted="$(jq -r --arg k "$(uname -r)" --arg s "$subvol" \
			     '[.[] | select(.type? == "type1")
				   | select((.version // "") | endswith($k))
				   | select($s == "" or ((.options // "") | contains("rootflags=subvol=" + $s)))]
			      | if length == 1 then .[0].id else empty end' <<<"$entries")"
		[ -z "$booted" ] || guess=" (from the running kernel, the boot loader reported nothing)"
	fi

	if [ -n "$booted" ]; then
		status_row "Booted entry" "$booted$guess"
	else
		status_row "Booted entry" "unknown, the boot loader reported none"
	fi

	# A one shot entry is consumed by the boot loader on the next
	# boot, so while it is set it, and not the default, is what comes
	# next
	local oneshot="" default=""
	oneshot="$(bli_efi_var_get "LoaderEntryOneShot" 2> /dev/null)" || oneshot=""
	default="$(get_default_entry 2> /dev/null)" || default=""

	if [ -n "$oneshot" ]; then
		status_row "Next boot" "$oneshot (one shot, and then ${default:-the default})"
	else
		status_row "Next boot" "${default:-unknown, no default is set}"
	fi

	[ -z "$arg_full" ] || {
		# Only the real entries: the list also carries what the
		# boot loader adds by itself ("Reboot Into Firmware
		# Interface" and the like), and counting those answers a
		# question nobody asked
		status_row "Entries" "$(jq -r '[.[] | select(.type? == "type1")] | length' <<<"$entries")"
		local timeout=""
		timeout="$(get_timeout 2> /dev/null)" || timeout=""
		status_row "Menu timeout" "${timeout:-not set}"
	}
}

status_system()
{
	echo "System"
	status_row "ESP" "$esp_root"
	# The state of the firmware, which is a different thing from the
	# SECURE_BOOT of the configuration: that one only asks for the
	# shim to be installed, and never looks at whether the firmware
	# enforces anything
	if is_secure_boot; then
		status_row "Secure Boot" "enabled in the firmware"
	else
		status_row "Secure Boot" "disabled in the firmware"
	fi
	if is_shim_installed; then
		status_row "Shim" "installed"
	else
		status_row "Shim" "not installed"
	fi
	if is_transactional; then
		status_row "Transactional" "yes"
	else
		status_row "Transactional" "no"
	fi
	# The lockout is why a device that is enrolled and predicted
	# correctly still does not open
	if ! have_tpm2; then
		status_row "TPM2" "no"
	elif in_lockout 2> /dev/null; then
		status_row "TPM2" "yes (IN LOCKOUT)"
	else
		status_row "TPM2" "yes"
	fi
}

fde_status()
{
	status_system

	[ -z "$arg_full" ] || { echo; status_config; }

	echo
	status_boot
	echo
	status_devices
	echo
	status_policy
	echo
	status_pcr15
	echo
	status_recovery_pin
	echo
	status_service sdbootutil-update-predictions.service

	# A policy with no device behind it is not an error, but it is
	# never what the reader assumes when they see "Backend: pcrlock".
	# It is what a partial unenroll leaves, and it makes the whole
	# TPM2 section describe something that opens nothing
	[ -z "$status_policy_backend" ] || [ -n "$status_tpm2_device" ] || {
		echo
		warn "There is a TPM2 policy, but no device has a TPM2 keyslot. Nothing is opened by the TPM2, and everything above about the policy describes a leftover"
	}

	# The two cases of a stale PCR are opposite, and the difference is
	# the whole answer: routine when the next `update-predictions`
	# rewrites the policy, terminal when it cannot, because
	# re-authorizing the NVIndex without a recovery PIN needs the
	# current PCRs to satisfy the *old* policy (github issue 250)
	[ -z "$status_policy_stale" ] || {
		echo
		if [ -n "$status_pin_reachable" ]; then
			warn "PCR $status_policy_stale no longer match the policy. The next 'sdbootutil update-predictions' rewrites it, and a recovery PIN is reachable to authorize the new NVIndex"
		else
			warn "PCR $status_policy_stale no longer match the policy, and no recovery PIN is reachable"
			warn "The PIN sealed in the TPM2 is the only one left, and reading it back needs the current PCRs to match the previous prediction, which is what just failed. This does not recover by itself: every 'update-predictions' from now on fails the same way"
			warn "First make sure that a password opens the devices, then 'systemd-pcrlock remove-policy' and 'sdbootutil enroll --method=tpm2'"
		fi
	}

	# Only for somebody reading it.  A report that is being collected
	# is already redirected, and there the line is noise
	[ -z "$interactive" ] || [ -n "$arg_full" ] || {
		echo
		echo "Run 'sdbootutil status --full' for the full report"
	}
}

eval_bootctl()
{
	# XXX: bootctl should have json output for that too
	# shellcheck disable=SC2016
	eval "$(bootctl 2> /dev/null | sed -ne 's/Firmware Arch: *\(\w\+\)/firmware_arch="\1"/p;s/ *token: *\(\w\+\)/entry_token="\1"/p')"
	esp_root="$(bootctl --print-esp-path)"
	boot_root="$(bootctl --print-boot-path)"
}

bootloader_name()
{
	info "Checking the bootloader name"

	if is_sdboot "${1:-$root_snapshot}"; then
		echo "systemd-boot"
	elif is_grub2_bls "${1:-$root_snapshot}"; then
		echo "grub2-bls"
	else
		err "Bootloader not detected"
	fi
}

set_image_name() {
	[ -z "$image" ] || return 0

	declare -gA arch_image_map=(
		[x64]="vmlinuz"
		[aa64]="Image"
		[riscv64]="Image"
	)

	if [ -n "${arch_image_map[$firmware_arch]}" ]; then
		image="${arch_image_map[$firmware_arch]}"
	else
		err "Unsupported architecture $firmware_arch"
	fi
}

define_commands() {
	declare -gA commands=(
		[install]=""
		[needs-update]=""
		[update]=""
		[force-update]=""
		[add-kernel]="kernel"
		[remove-kernel]="kernel"
		[cleanup]=""
		[set-default-snapshot]=""
		[add-all-kernels]=""
		[mkinitrd]=""
		[remove-all-kernels]=""
		[is-installed]=""
		[list-snapshots]=""
		[list-entries]=""
		[list-kernels]=""
		[list-devices]=""
		[status]=""
		[show-entry]="kernel"
		[update-entry]="kernel"
		[update-all-entries]=""
		[is-bootable]=""
		[set-default]="id"
		[get-default]=""
		[set-timeout]="seconds"
		[get-timeout]=""
		[print-loader-path]=""
		[enroll]=""
		[unenroll]=""
		[update-predictions]=""
		[bootloader]=""
	)
}

define_options() {
	declare -gA options_with_arg=(
		[help]=""
		[verbose]=""
		[start-trace-code]=""
		[stop-trace-code]=""
		[esp-path]="_path"
		[entry-token]="_path"
		[arch]="_arch_name"
		[image]="_image_name"
		[entry-keys]="_find_kernels"
		[no-variables]=""
		[no-reuse-initrd]=""
		[no-random-seed]=""
		[all]=""
		[sync]=""
		[portable]=""
		[secure-boot]=""
		[removable]=""
		[only-default]=""
		[default-snapshot]=""
		[ask-key]=""
		[ask-pin]=""
		[ask-recovery-pin]=""
		[ask-pw]=""
		[ask-current-pw]=""
		[method]="_method"
		[no-measure-pcr]=""
		[measure-pcr]=""
		[pcr]="_none"
		[devices]="_devices"
		[rootfs]="_rootfs"
		[rootfs-data]="_rootfs_data"
		[esp-free-space]="_esp_free_space"
		[devicetree-source]="_file"
		[force]=""
		[strict]=""
		[disable-predictions]=""
		[full]=""
	)
	opts_long=""
	for opt in "${!options_with_arg[@]}"; do
		if [ "${options_with_arg[$opt]}" ]; then
			opts_long+="${opt}:,"
		else
			opts_long+="${opt},"
		fi
	done
	opts_long="${opts_long%,}"
}

####### main #######

if [ "$1" = "_print_bash_completion_data" ]; then
	declare -f set_image_name
	declare -f eval_bootctl
	declare -f define_commands
	declare -f define_options
	exit 0
fi

define_options
getopt_tmp=$(getopt -o hv --long "$opts_long" -n "${0##*/}" -- "$@")
eval set -- "$getopt_tmp"

while true ; do
	case "$1" in
		-h|--help) helpandquit ;;
		-v|--verbose) verbose=$((++verbose)); shift ;;
		--start-trace-code) shift ;;
		--stop-trace-code) shift ;;
		--esp-path) arg_esp_path="$2"; shift 2 ;;
		--arch) arg_arch="$2"; shift 2 ;;
		--entry-token) arg_entry_token="$2"; shift 2 ;;
		--image) image="$2"; shift 2 ;;
		--entry-keys) IFS=',' read -r -a arg_entry_keys <<<"$2"; shift 2 ;;
		--no-variables) arg_no_variables=1; shift ;;
		--no-reuse-initrd) arg_no_reuse_initrd=1; shift ;;
		--no-random-seed) arg_no_random_seed=1; shift ;;
		--all) arg_all_entries=1; shift ;;
		--sync) arg_sync=1; shift ;;
		--portable) arg_portable=1; shift ;;
		--removable) arg_portable=1; shift ;;
		--secure-boot) arg_secure_boot=1; shift ;;
		--only-default) arg_only_default=1; shift ;;
		--default-snapshot) arg_default_snapshot=1; shift ;;
		--ask-key) arg_ask_key=1; shift ;;
		--ask-pin) arg_ask_pin=1; shift ;;
		--ask-recovery-pin) arg_ask_recovery_pin=1; shift ;;
		--ask-pw) arg_ask_pw=1; shift ;;
		--ask-current-pw) arg_ask_current_pw=1; shift ;;
		--method) arg_method="$2"; shift 2 ;;
		--no-measure-pcr) arg_no_measure_pcr=1; shift ;;
		--measure-pcr) arg_measure_pcr=1; shift ;;
		--pcr) arg_pcr="$2"; shift 2 ;;
		--devices) IFS=',' read -r -a tracked_devices <<<"$2"; shift 2 ;;
		--rootfs) arg_rootfs="$2"; shift 2 ;;
		--rootfs-data) arg_rootfs_data="$2"; shift 2 ;;
		--esp-free-space) arg_esp_free_space="$2"; shift 2 ;;
		--devicetree-source) arg_devicetree_source="$2"; shift 2 ;;
		--force) arg_force=1; shift ;;
		--strict) arg_strict=1; shift ;;
		--disable-predictions) arg_disable_predictions=1; shift ;;
		--full) arg_full=1; shift ;;
		--) shift ; break ;;
		*) echo "Internal error!" ; exit 1 ;;
	esac
done

if [ -z "$SYSTEMD_LOG_LEVEL" ] && [ "${verbose:-0}" -gt 1 ]; then
	if [ "$verbose" -gt 2 ]; then
		SYSTEMD_LOG_LEVEL=debug
	else
		SYSTEMD_LOG_LEVEL=info
	fi
	export SYSTEMD_LOG_LEVEL
fi

# The variables documented in the help (PW, KEY, PIN) are read from
# the environment, so they must be set before the command name.
# `getopt` moves them to the end of the argument list, where they are
# indistinguishable from an optional [SNAPSHOT] and silently ignored,
# so a command like
#
#   sdbootutil update-predictions PW=secret --measure-pcr
#
# drops the password instead of using it
for variable_arg in "$@"; do
	[[ "$variable_arg" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
	err "\"$variable_arg\" is not a command nor an option. Set the variable before the command name: ${variable_arg%%=*}=... ${0##*/} ..."
done

define_commands
if [ -z "$1" ]; then
	helpandquit
elif [ -z ${commands["$1"]+yes} ]; then
	err "unknown command $1"
fi

[ -n "$arg_esp_path" ] && export SYSTEMD_ESP_PATH="$arg_esp_path"

eval_bootctl

root_subvol=""
subvol_prefix=""
root_subvol_err=
is_btrfs_snapshots=
if [ "$(stat -f -c %T /)" = "btrfs" ] && [ -d /.snapshots ]; then
	is_btrfs_snapshots=1
	# `btrfs` lives in /usr/sbin, that is not present in every
	# PATH.  Without this check the only symptom is an empty
	# `root_subvol`, reported below as a generic "Can't determine
	# root subvolume" that hides the real cause
	command -v btrfs > /dev/null || \
		err "btrfs command not found. Install btrfsprogs or add /usr/sbin to the PATH"
	# Keep the error of the command, to report it together with
	# the failed detection below
	root_subvol=$(btrfs subvol show / 2> "$tmpdir/btrfs.err" | head -1)
	root_subvol_err="$(cat "$tmpdir/btrfs.err")" || :
	rm -f "$tmpdir/btrfs.err"
	subvol_prefix="${root_subvol%/.snapshots/*}"
	# The presence of "/.snapshots" is not enough: the system boots
	# from a snapshot only when the root subvolume is under
	# "<prefix>/.snapshots/<N>/snapshot", which is also the only
	# shape that "subvol_prefix" and "running_snapshot" can parse.
	# A plain "@" root with snapper installed (no rollback layout)
	# is a regular system, and treating it as a snapshot one builds
	# paths like "@/.snapshots/@/snapshot" that do not exist
	[[ "$root_subvol" =~ ^.+/\.snapshots/[0-9]+/snapshot$ ]] && have_snapshots=1
elif [ -e /.snapshots/1/info.xml ]; then
	# We can detect when we are in a image building situation
	# (mkosi) when we are not in btrfs, but there is a snapshots
	# directory partially available.  The `subvol_prefix` variable
	# is assumed what is used in openSUSE
	in_buildroot=1
	subvol_prefix="@"
fi

root_snapshot=""
running_snapshot=""
if [ -n "$have_snapshots" ]; then
	running_snapshot="${root_subvol#"${subvol_prefix}"/.snapshots/}"
	running_snapshot="${running_snapshot%/snapshot}"
	if [ -n "$arg_default_snapshot" ]; then
		[ -s "$snapperfile" ] || update_snapper
		read -r root_snapshot <<<"$(jq -r '.root[]|select(.default==true)|.number' < "$snapperfile")"
	else
		root_snapshot="$running_snapshot"
	fi
	applied_snapshot="$(detect_applied_snapshot)"
elif [ -n "$in_buildroot" ]; then
	root_snapshot="1"
fi

if [ -n "$arg_esp_path" ] && [ "$esp_root" != "$arg_esp_path" ]; then
	err "Mismatch of ESP path"
fi
[ -n "$arg_arch" ] && firmware_arch="$arg_arch"

[ -n "$esp_root" ] || err "No ESP detected. Legacy system?"
[ -n "$root_subvol" ] || [ -z "$is_btrfs_snapshots" ] || \
	err "Can't determine root subvolume${root_subvol_err:+: $root_subvol_err}"
[ -n "$firmware_arch" ] || err "Can't determine firmware arch"
set_image_name

mountpoint -q "$esp_root" || warn "$esp_root is not a valid mountpoint"

dbg_var "root_snapshot"
dbg_var "running_snapshot"
dbg_var "applied_snapshot"
dbg_var "esp_root"
dbg_var "boot_root"

# shellcheck disable=SC1091
[ -e /etc/sysconfig/bootloader ] && . /etc/sysconfig/bootloader

if is_sdboot; then
	esp_dst="/EFI/systemd"
elif is_grub2_bls; then
	set_os_release "${root_snapshot}"
	# shellcheck disable=SC2154
	read -r -a name <<<"${os_release_NAME,,}"
	esp_dst="/EFI/${name[0]}"
else
	msg="Bootloader not detected"
	[ -z "$LOADER_TYPE" ] || msg+=". /etc/sysconfig/bootloader has LOADER_TYPE=\"$LOADER_TYPE\", but only \"systemd-boot\" or \"grub2-bls\" are recognized."
	err "$msg"
fi

# Removable media layout is described in
# https://github.com/rhboot/shim/blob/main/README.fallback
if [ -n "$arg_portable" ]; then
	if [ -d "${esp_root}${esp_dst}" ]; then
		err "Bootloader is already installed permanently"
	fi
	esp_dst="/EFI/BOOT"
fi

# When we are doing an operation different from installation, the
# esp_dst should be already present.  We can use it to identify a
# removable installation
if [ "$1" != "install" ] && [ ! -d "${esp_root}${esp_dst}" ]; then
	info "Removable installation detected"
	esp_dst="/EFI/BOOT"
	arg_portable=1
fi

dbg_var "esp_dst"

if [ "$SECURE_BOOT" = "yes" ] || is_shim_installed; then
	arg_secure_boot=1
fi

if [ "$UPDATE_NVRAM" = "no" ]; then
	if has_efivars; then
		warn 'The system has EFI variables but UPDATE_NVRAM is set to "no"'
	fi
	arg_no_variables=1
fi

# If there is a config file, load it.  If not, but the bootloader was
# installed, create a default one (using the CLI arguments).  If not,
# use some default values.
#
# When loading a config file with missing values, the load code will
# use default values.
if is_config_file; then
	load_config_file
elif is_installed; then
	create_default_config_file
else
	set_default_config_values
fi

# Keep initial components before they are replaced by some actions
# (new initrd, new entry, etc)
if [ "$SDB_ADD_INITIAL_COMPONENT" = "1" ]; then
	backup_initial_components
fi

case "$1" in
	install)
		assert_snapshot "$2"
		install_bootloader "${2:-$root_snapshot}"
		install_extra_entries "${2:-$root_snapshot}"
		create_default_config_file ;;
	needs-update)
		bootloader_needs_update "${2:-$root_snapshot}" ;;
	update)
		assert_snapshot "$2"
		bootloader_update "${2:-$root_snapshot}" ;;
	force-update)
		if is_installed; then
			assert_snapshot "$2"
			install_bootloader "${2:-$root_snapshot}"
		else
			:
		fi ;;
	bootloader)
		bootloader_name "${2:-$root_snapshot}" ;;
	add-kernel)
		assert_snapshot "$3"
		install_kernel "${3:-$root_snapshot}" "$2" ;;
	add-all-kernels)
		assert_snapshot "$2"
		install_all_kernels "${2:-$root_snapshot}" ;;
	mkinitrd)
		assert_snapshot "$2"
		arg_no_reuse_initrd=1
		install_all_kernels "${2:-$root_snapshot}" ;;
	remove-kernel)
		assert_snapshot "$3"
		remove_kernel "${3:-$root_snapshot}" "$2" ;;
	remove-all-kernels)
		assert_snapshot "$2"
		remove_all_kernels "${2:-$root_snapshot}" ;;
	cleanup)
		cleanup_entries "${2:-}" ;;
	set-default-snapshot)
		assert_snapshot "$2"
		set_default_snapshot "${2:-$root_snapshot}" ;;
	is-installed)
		if is_installed; then
			info "systemd-boot was installed using sdbootutil"
			exit 0
		else
			info "not installed using this tool"
			exit 1
		fi ;;
	list-kernels)
		list_kernels "${2:-$root_snapshot}" ;;
	list-entries)
		list_entries "${2:-}" ;;
	list-snapshots)
		list_snapshots ;;
	list-devices)
		list_devices ;;
	status)
		fde_status ;;
	show-entry)
		show_entry_fields "${3:-$root_snapshot}" "$2" ;;
	update-entry)
		assert_snapshot "$3"
		update_entry "${3:-$root_snapshot}" "$2" ;;
	update-all-entries)
		assert_snapshot "$2"
		update_all_entries "${2:-$root_snapshot}" ;;
	is-bootable)
		is_bootable "${2:-$root_snapshot}" ;;
	set-default)
		assert_snapshot "$2"
		set_default_entry "$2" ;;
	get-default)
		get_default_entry "$2" ;;
	set-timeout)
		set_timeout "$2" ;;
	get-timeout)
		get_timeout "$2" ;;
	print-loader-path)
		print_loader_path ;;
	enroll)
		enroll ;;
	unenroll)
		unenroll ;;
	update-predictions)
		update_predictions=1
		explicit_predictions=1 ;;
	*)
		helpandquit ;;
esac

[ -z "$update_predictions" ] || [ -n "$arg_disable_predictions" ] || generate_tpm2_predictions
