Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package sdbootutil for openSUSE:Factory checked in at 2026-09-30 16:21:53 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/sdbootutil (Old) and /work/SRC/openSUSE:Factory/.sdbootutil.new.1465845 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "sdbootutil" Wed Sep 30 16:21:53 2026 rev:113 rq:1381547 version:1+git20260929.26b6989 Changes: -------- --- /work/SRC/openSUSE:Factory/sdbootutil/sdbootutil.changes 2026-09-29 19:01:42.103659621 +0200 +++ /work/SRC/openSUSE:Factory/.sdbootutil.new.1465845/sdbootutil.changes 2026-09-30 16:22:40.803416791 +0200 @@ -1,0 +2,43 @@ +Tue Sep 29 20:24:08 UTC 2026 - Alberto Planas Dominguez <[email protected]> + +- Update to version 1+git20260929.26b6989: + * Increase the timeout for the update-prediction service + * Keep /.snapshots mounted for the shutdown helper + * Serialize the update-predictions service and the shutdown helper + * Do not ask to fix ROOTFS when the root is encrypted + * Use >&2 instead of /dev/stderr + * Revert "Move back from oneshot the update-predictions service" + * Move back from oneshot the update-predictions service + * Fix SELinux AVC from grep redirector + * Use DSP for the LUKS2 swap partition + * Add missing tight ESP unit test scenario + * Don't count reused kernel when calculating free space + +------------------------------------------------------------------- +Thu Sep 24 20:05:03 UTC 2026 - Alberto Planas Dominguez <[email protected]> + +- Update to version 1+git20260924.2b7b94e: + * Improve detection of encrypted device when RAID is used + * Support btrfs RAID1 configurations + * Move the service from oneshot to exec to avoid the wait + * Drop shift variations already present as a component + * Fix Supplement use of 'if' instead of 'and' + * Hide the warning for entries that uses @ + * Accept _ instead of @ as snapshot prefix for version + * Drop chown and set ownership via install + * Use bootctl to generate the random seed + * Start the validation with the strongest bank + * Parse the JSON output of findmnt + * Use stdin for qrencode + * Fix log file permissions + * Improve PCR15 diagnosis in status command + * Avoid abrmd TCTI error message + * Do not fail if pcrlock lock verb cannot reproduce the event log + * The completion subpackage supplements the main one + * Detect when grubenv is full + * Use systemd-analyze to compare versions in status + * Fix bootcounter in GRUB2 EFI variable + * Drop lowercase in dd + * Fix loader_conf_set for paths + +------------------------------------------------------------------- Old: ---- sdbootutil-1+git20260909.7cfa1f0.obscpio New: ---- sdbootutil-1+git20260929.26b6989.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ sdbootutil.spec ++++++ --- /var/tmp/diff_new_pack.WBaDkZ/_old 2026-09-30 16:22:42.001466890 +0200 +++ /var/tmp/diff_new_pack.WBaDkZ/_new 2026-09-30 16:22:42.003466974 +0200 @@ -55,7 +55,7 @@ %{nil} Name: sdbootutil -Version: 1+git20260909.7cfa1f0 +Version: 1+git20260929.26b6989 Release: 0 Summary: Bootctl wrapper for BLS boot loaders License: MIT @@ -151,6 +151,7 @@ Requires: %{name} = %{version} Requires: bash Requires: bash-completion +Supplements: (%{name} and bash-completion) BuildArch: noarch %description bash-completion ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.WBaDkZ/_old 2026-09-30 16:22:42.045468730 +0200 +++ /var/tmp/diff_new_pack.WBaDkZ/_new 2026-09-30 16:22:42.047468814 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/openSUSE/sdbootutil.git</param> - <param name="changesrevision">7cfa1f0ab1bba2c808ef5ff63aff22b26aa42d08</param></service></servicedata> + <param name="changesrevision">26b6989e346388bfa244b226c809a1e6a3824a0d</param></service></servicedata> (No newline at EOF) ++++++ sdbootutil-1+git20260909.7cfa1f0.obscpio -> sdbootutil-1+git20260929.26b6989.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/.github/ISSUE_TEMPLATE/bug_report.yml new/sdbootutil-1+git20260929.26b6989/.github/ISSUE_TEMPLATE/bug_report.yml --- old/sdbootutil-1+git20260909.7cfa1f0/.github/ISSUE_TEMPLATE/bug_report.yml 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/.github/ISSUE_TEMPLATE/bug_report.yml 2026-09-29 22:21:06.000000000 +0200 @@ -121,7 +121,7 @@ attributes: label: Debug trace excerpts (sanitized) description: | - If you enabled the **opt‑in debug trace** (create `/var/log/sdbootutil.log` and set permissions to 600 before running), please paste only the **relevant, sanitized excerpts** here. + If you enabled the **opt‑in debug trace** (`sdbootutil --start-trace-code`, which creates `/var/log/sdbootutil.log` with mode 600), please paste only the **relevant, sanitized excerpts** here. ⚠️ **Privacy warning:** trace lines may include sensitive data (e.g. passwords typed as command arguments). **You must review and redact** any secrets before sharing. If in doubt, **omit** the trace and instead describe what you observed. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/10-sdbootutil.snapper new/sdbootutil-1+git20260929.26b6989/10-sdbootutil.snapper --- old/sdbootutil-1+git20260909.7cfa1f0/10-sdbootutil.snapper 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/10-sdbootutil.snapper 2026-09-29 22:21:06.000000000 +0200 @@ -94,8 +94,8 @@ # means that it is executed multiple times) # # * In case that a reboot or shutdown is commanded when the service is -# running, it will not accept the SIGTERM signal, and will require 2 -# minutes before forcing it down +# running, it will not accept the SIGTERM signal, and will have 5 +# minutes to complete before being forced down # # * Finally, if the reboot or shutdown command is launched before the # timer triggers, a transient service will be executed to run the @@ -105,6 +105,15 @@ # * The same marker file is used to detect that there is a pending # update-predictions # +# * The transient service is ordered before the service, so at +# shutdown it is stopped only after the service has finished. Both +# can be pending at once (a new snapshot while the service runs sets +# the marker again and re-creates the transient service), and two +# concurrent update-predictions are last-writer-wins: an iteration +# still predicting for the previous default snapshot can overwrite +# the new policy. Serialized, the ExecStop only acts if the service +# left the marker set (it failed, or was killed), so it is the retry +# # * If no device is unlocked with the TPM2 the predictions are a # no-op, so no transient unit is created at all. This is the same # condition that guards sdbootutil-update-predictions.service @@ -141,6 +150,13 @@ # reboot: if it could not update PCR 15, the next boot halts in # measure-pcr-validator, and this journal entry is the only # warning that it was coming + # + # /.snapshots is required because update-predictions asks + # snapper which snapshots to include. Being ordered after + # sdbootutil-update-predictions.service, this can run late in + # the shutdown, and without the mount snapper fails and the + # prediction silently loses the snapper default and the last + # working snapshots if ! systemctl --quiet is-active sdbootutil-update-predictions-shutdown.service; then systemd-run --unit=sdbootutil-update-predictions-shutdown.service \ --property=Description="Update TPM predictions before shutdown" \ @@ -149,8 +165,9 @@ --property=DefaultDependencies=no \ --property=Conflicts=umount.target \ --property=Before=umount.target \ - --property=RequiresMountsFor="/ /var /run /tmp /boot/efi" \ - --property=TimeoutStopSec=120 \ + --property=Before=sdbootutil-update-predictions.service \ + --property=RequiresMountsFor="/ /.snapshots /var /run /tmp /boot/efi" \ + --property=TimeoutStopSec=300 \ --property=ExecStop='/bin/bash -c "[ $(systemctl is-system-running) = stopping ] || exit 0; [ -f /run/sdbootutil/update-predictions ] || exit 0; exec /usr/bin/sdbootutil -v update-predictions"' \ /bin/true fi diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/ARCHITECTURE.md new/sdbootutil-1+git20260929.26b6989/ARCHITECTURE.md --- old/sdbootutil-1+git20260909.7cfa1f0/ARCHITECTURE.md 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/ARCHITECTURE.md 2026-09-29 22:21:06.000000000 +0200 @@ -83,7 +83,7 @@ An entry file might now look like this: title openSUSE Tumbleweed - version [email protected] + version 15_1.2.3-1-default machine-id 2ceda9f sort-key opensuse-tumbleweed options root=UUID=abc... rootflags=subvol=@/.snapshots/15/snapshot @@ -107,7 +107,7 @@ So that makes an entry look like this title openSUSE Tumbleweed - version [email protected] + version 15_1.2.3-1-default machine-id 2ceda9f sort-key opensuse-tumbleweed options root=UUID=abc... rootflags=subvol=@/.snapshots/15/snapshot diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/CLAUDE.md new/sdbootutil-1+git20260929.26b6989/CLAUDE.md --- old/sdbootutil-1+git20260909.7cfa1f0/CLAUDE.md 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/CLAUDE.md 2026-09-29 22:21:06.000000000 +0200 @@ -185,7 +185,7 @@ ``` title openSUSE Tumbleweed -version [email protected] +version 15_6.2.1-1-default machine-id 2ceda9f sort-key opensuse-tumbleweed options root=UUID=... rootflags=subvol=@/.snapshots/15/snapshot diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/measure-pcr-validator.sh new/sdbootutil-1+git20260929.26b6989/measure-pcr-validator.sh --- old/sdbootutil-1+git20260909.7cfa1f0/measure-pcr-validator.sh 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/measure-pcr-validator.sh 2026-09-29 22:21:06.000000000 +0200 @@ -27,7 +27,8 @@ fi local res=1 - for sha in sha1 sha256 sha384 sha512; do + # Strongest bank first + for sha in sha512 sha384 sha256 sha1; do [ -e "/sys/class/tpm/tpm0/pcr-$sha/15" ] || continue read -r expected_pcr_15 < "/sys/class/tpm/tpm0/pcr-$sha/15" grep -Fixq "$expected_pcr_15" /var/lib/sdbootutil/measure-pcr-prediction; res="$?" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/sdbootutil new/sdbootutil-1+git20260929.26b6989/sdbootutil --- old/sdbootutil-1+git20260909.7cfa1f0/sdbootutil 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/sdbootutil 2026-09-29 22:21:06.000000000 +0200 @@ -33,7 +33,10 @@ DEBUG_LOG="/var/log/sdbootutil.log" verbose= -if [[ "$*" =~ "--start-trace-code" ]] && ! touch "$DEBUG_LOG" 2>/dev/null; then +# The trace can record secrets (passwords and PINs passed as command +# line arguments to the tools that this script calls), so the log is +# created 0600 and never with whatever the umask happens to be +if [[ "$*" =~ "--start-trace-code" ]] && ! (umask 077; touch "$DEBUG_LOG") 2>/dev/null; then echo "Cannot enable the code trace, $DEBUG_LOG is not writable" >&2 exit 1 fi @@ -43,9 +46,12 @@ 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 +# The mode is reasserted on every run, so a log created by hand or by an +# older version is tightened before anything is written to it, and the +# trace stays off if it cannot be. 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" ] && { chmod 0600 "$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 @@ -582,9 +588,12 @@ } done - # Force back "device" for ROOTFS if is encrypted + # Force back "device" for ROOTFS if is encrypted. Not a user + # error: the config file is generated before disk-encryption-tool + # encrypts the root on first boot, so it keeps "uuid", and + # `status` already reports the value as forced if is_rootfs_crypt; then - [ "$ROOTFS" = "device" ] || info "Fix the /etc/default/sdbootutil to set ROOTFS as 'device'" + [ "$ROOTFS" = "device" ] || dbg "ROOTFS=$ROOTFS in the config file, forcing 'device' as the root is encrypted" ROOTFS="device" fi @@ -853,7 +862,7 @@ { local device read -r device < <(findmnt / -v -n -o SOURCE 2> /dev/null) - [ -n "$device" ] && [ "$(lsblk --noheadings -o TYPE "$device" 2> /dev/null)" = "crypt" ] + [ -n "$device" ] && lsblk --noheadings --list --inverse -o TYPE "$device" 2> /dev/null | grep -qx crypt } get_rootfs() @@ -865,7 +874,9 @@ 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) ;; + # "/dev/mapper/NAME", as "/dev/dm-N" depends on the + # order in which the devices were opened + device) read -r rootfs_data < <(lsblk --noheadings --nodeps -o PATH "$(findmnt / -v -n -o SOURCE 2> /dev/null)" 2> /dev/null) ;; *) info "Can't determine rootfs ($ROOTFS). Using UUID as default" ROOTFS="uuid" @@ -883,12 +894,18 @@ get_all_rootfs() { - local rootfs rootfs_data + local rootfs rootfs_data kname path read -r rootfs_data < <(findmnt / -v -n -o SOURCE) rootfs="$rootfs_data" + # Every device of the file system (btrfs can span several), by + # both names, as older entries can use "/dev/dm-N" read -r rootfs_data < <(findmnt / -v -n -o UUID) + [ -z "$rootfs_data" ] || while read -r kname path; do + rootfs="$rootfs|/dev/$kname|$path" + done < <(lsblk --noheadings --raw -o KNAME,PATH -Q "UUID == \"$rootfs_data\"" 2> /dev/null) + [ -z "$rootfs_data" ] || rootfs="$rootfs|UUID=$rootfs_data" read -r rootfs_data < <(findmnt / -v -n -o LABEL) @@ -938,12 +955,33 @@ sed "${sed_arguments[@]}" } +# Entries written before the "N_" prefix say "N@", and systemd >= v262 +# warns about the "@" on every parse. Only the warning, the entry is +# accepted and sorts the same +bootctl_noise="^.*: Version string '[0-9]+@[^']*' is not a valid version, accepting anyway\.$" + +# `bootctl` for the commands that parse the entries. The noise is +# kept when the user asked for more output, with `--verbose` or with +# SYSTEMD_LOG_LEVEL +bootctl_entries() +{ + if [ -n "$verbose" ] || [ -n "$SYSTEMD_LOG_LEVEL" ]; then + bootctl "$@" + return + fi + # No `2> >(...)`: it opens /proc/self/fd/N with O_CREAT, which + # SELinux denies (dac_override, add_name) + local rc=0 + bootctl "$@" 2> "$tmpdir/bootctl.err" || rc=$? + grep -Ev "$bootctl_noise" "$tmpdir/bootctl.err" >&2 || : + return "$rc" +} entry_filter=("cat") update_entries() { [ -z "$1" ] || entry_filter=("$@") - bootctl list --json=short | "${entry_filter[@]}" > "$entryfile" + bootctl_entries list --json=short | "${entry_filter[@]}" > "$entryfile" dbg "Entry filter: ${entry_filter[*]}" dbg_cat "$entryfile" } @@ -971,7 +1009,7 @@ local root root="$(get_all_rootfs)" - update_entries jq "[.[]|select(has(\"options\"))|select(.options|test(\"root=(?:$root)\"))]" + update_entries jq "[.[]|select(has(\"options\"))|select(.options|test(\"root=(?:$root)(?: |$)\"))]" } update_entries_for_extra() @@ -1012,6 +1050,24 @@ echo "${prefix:+$prefix-}$entry_token-$kernel_version${snapshot:+-$snapshot}${tries:++$tries}.conf" } +# An entry name reduced to what identifies it, so that a name from +# `bootctl` and one from a boot loader can be compared. +# +# `bootctl` reports the ID with its ".conf" suffix and without the boot +# counter that "entry_conf_file" puts in the file name. grub2-bls +# writes "LoaderEntrySelected" the other way around: no suffix, and the +# counter still there, because it records the name before +# "systemd-bless-boot" renames the file +entry_key() +{ + # "+3" until the loader counts a boot, "+2-1" afterwards + local name="${1%.conf}" + + [[ ! "$name" =~ ^(.+)\+[0-9]+(-[0-9]+)?$ ]] || name="${BASH_REMATCH[1]}" + + echo "$name" +} + find_conf_file() { local kernel_version="${1:?}" @@ -1167,7 +1223,7 @@ | select(.type? == "type1") | select(.path != null) | .id, .path, - ((.version // "") | capture("((?<snapshot>[0-9]*)@)?(?<kernel>.*)")) as $v + ((.version // "") | capture("((?<snapshot>[0-9]*)[@_])?(?<kernel>.*)")) as $v | $v.snapshot, $v.kernel') } @@ -1304,7 +1360,7 @@ local id id="$(entry_conf_file "$kernel_version" "$snapshot")" info "Removing boot entry $id" - bootctl unlink "$id" + bootctl_entries 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 @@ -1339,8 +1395,7 @@ mv "$old" "$old.bak" || return "$?" fi rollback+=("$old") - install -p -m 0644 "$src" "$dst" || return "$?" - chown root:root "$dst" 2> /dev/null || true + install -p -m 0644 -o root -g root "$src" "$dst" || return "$?" info "Installed $dst" } @@ -1446,7 +1501,11 @@ info "Found existing initrd $v" dstinitrd+=("$v") done < <(entry_field "$conf" initrd) - [ "${#dstinitrd[@]}" -eq 0 ] || return 0 + # Freeing space must not remove the initrd reused here + [ "${#dstinitrd[@]}" -eq 0 ] || { + reused_entry="$conf" + return 0 + } fi return 1 @@ -1459,38 +1518,36 @@ # 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" + findmnt -o TARGET,FSTYPE,FSROOT -Rv --json / > "$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 + local target fstype fsroot + while IFS=$'\t' read -r target fstype fsroot; do + [ "$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 + [ -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" + 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" + mountpoint --quiet "$snapshot_dir$target" || mount --bind "$target" "$snapshot_dir$target" + done < <(jq -r '..|objects|select(has("target"))|[.target,.fstype,.fsroot]|@tsv' "$tmpdir/mounts") rm "$tmpdir/mounts" mount -t tmpfs -o size=10m tmpfs "$snapshot_dir/run" @@ -1544,6 +1601,10 @@ pending_kernel_size() { + [ -n "$1" ] || { + echo 0 + return 0 + } echo $(($(stat -c %s "$1") / 1024 + 1)) } @@ -1660,20 +1721,20 @@ # # 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" ' + jq -r --arg in_use "${in_use[*]}" --arg last "$root_snapshot" --arg keep "${reused_entry:-}" ' def snapshot_of: ((.options // "") | capture("rootflags=subvol=[^ ]*/\\.snapshots/(?<n>[0-9]+)/snapshot") | .n) - // ((.version // "") | capture("^(?<n>[0-9]+)@") | .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) + // ((.version // "") | capture("^[0-9]+[@_](?<k>.+)$") | .k) // ""; ($in_use | split(" ") | map(select(. != ""))) as $in_use | .[] - | select(.isDefault != true and .isSelected != true) + | select(.isDefault != true and .isSelected != true and .path != $keep) | snapshot_of as $s | kernel_of as $k | select(($in_use | index($s)) == null or $s == $last) @@ -1911,6 +1972,7 @@ [ -z "$have_snapshots" ] || subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot" local kernel_version="$2" local dstinitrd=() + local reused_entry= local src="${subvol#"${subvol_prefix}"}/lib/modules/$kernel_version/$image" [ -n "$kernel_version" ] || err "Missing kernel version" [ -e "$src" ] || err "Can't find $src" @@ -2010,7 +2072,11 @@ fi fi - make_free_space_for_kernel "$snapshot" "$src" "$tmpdir" "$devicetree_src" || err "No free space in ${boot_root} for new kernel" + # Files already in the ESP are reused, and need no space + local kernel_size_src="$src" devicetree_size_src="$devicetree_src" + [ ! -e "${boot_root}$dst" ] || kernel_size_src= + [ -z "$devicetree_dst" ] || [ ! -e "${boot_root}$devicetree_dst" ] || devicetree_size_src= + make_free_space_for_kernel "$snapshot" "$kernel_size_src" "$tmpdir" "$devicetree_size_src" || err "No free space in ${boot_root} for new kernel" local boot_options [ -z "$in_buildroot" ] || subvol="${subvol_prefix}/.snapshots/${snapshot}/snapshot" @@ -2044,10 +2110,18 @@ local entry_machine_id= [ "$entry_token" = "$machine_id" ] && entry_machine_id="$machine_id" + # The snapshot number goes in front of the kernel version, so + # that the boot loader sorts the entries by snapshot first. The + # separator is "_": it is one of the characters that + # `strverscmp_improved()` drops as a plain segment separator, + # like the "@" used before, so both sort identically. "@" is + # outside the UAPI.10 charset and systemd v262 warns about it on + # every entry it parses. Entries written with "@" are still on + # disk, and every reader accepts both 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} + 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 @@ -2206,7 +2280,7 @@ 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") + done < <(jq -r '.[] | .id, .path, (.version | capture("((?<snapshot>[0-9]*)[@_])?(?<kernel>.*)")) as $v | $v.snapshot, $v.kernel' "$entryfile") # The loop above drops the entry whose kernel is gone. The # opposite case -- the kernel is still there and what went missing @@ -2260,13 +2334,12 @@ 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` + # "LoaderEntrySelected", but grub2-bls spells the variable + # differently and nothing matches there, so "isSelected" is always + # null. Compare it here too, through "entry_key", until grub2-bls + # is fixed local selected= - [ -z "$interactive" ] || selected="$(bli_efi_var_get "LoaderEntrySelected")" + [ -z "$interactive" ] || selected="$(entry_key "$(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 @@ -2283,8 +2356,7 @@ marker= if [ -n "$interactive" ]; then booted= - if [ "$isselected" = "true" ] || [ "${id,,}" = "$selected" ] \ - || [ "${id,,}" = "$selected.conf" ]; then + if [ "$isselected" = "true" ] || [ "${id%.conf}" = "$selected" ]; then booted=1 fi if [ "$isdefault" = "true" ]; then @@ -3055,14 +3127,20 @@ for ((i=0;i<${#s};i+=2)); do echo -ne "\x${s:$i:2}"; done } +# `bootctl random-seed` hashes fresh entropy together with the seed +# that is already in the ESP, it also writes the "LoaderSystemToken" +# EFI variable. 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" + + local extra=() + [ -z "$arg_no_variables" ] && [ -z "$arg_portable" ] && mountpoint -q "$esp_root" || extra=("--no-variables") + + local output + output="$(bootctl "${extra[@]}" random-seed 2>&1)" || \ + warn "Failed to update the random seed${output:+: $output}" + return 0 } has_efivars() @@ -3074,7 +3152,8 @@ { # 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' + # 4 bytes of attributes, then the value as UTF-16LE + [ ! -e "$efi_var" ] || dd "if=$efi_var" bs=2 skip=2 status=none | tr -d '\0' } bli_efi_var_set() @@ -3096,7 +3175,8 @@ [ -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" + # "|" as the delimiter, as a value can contain a path + sed -i -e "s|^$key .*|$key $value|" "${esp_root}/loader/loader.conf" else echo "$key $value" >> "${esp_root}/loader/loader.conf" fi @@ -3127,9 +3207,15 @@ done < "${esp_root}${esp_dst}/grubenv" echo "$key=$value" >> "$grubenv" + # GRUB2 reads a block of exactly 1024 bytes, and what is not a + # variable is padding. One `printf` for the whole padding: the + # old `seq 1 $filler` loop printed nothing for a full block, and + # `printf` writes its format once when it has no arguments, so a + # block that was already full got a 1025th byte local filler filler=$((1024 - $(stat -c %s "$grubenv"))) - printf '#%.0s' $(seq 1 $filler) >> "$grubenv" + [ "$filler" -ge 0 ] || err "grubenv has no room left for $key" + printf '%*s' "$filler" "" | tr ' ' '#' >> "$grubenv" mv "$grubenv" "${esp_root}${esp_dst}/grubenv" } @@ -3299,19 +3385,14 @@ # The snapshot that an entry describes, read from the entry list in # stdin. Empty when the entry is not in the list or describes no # snapshot -# -# The ID is matched case insensitively: when it comes from -# "LoaderEntryDefault" it has been through `bli_efi_var_get`, which -# lower-cases what it reads, and an entry token is not always lower -# case (same workaround as in "list_entries") entry_snapshot() { local id="${1:?}" jq -r --arg id "$id" '.[] - | select((.id | ascii_downcase) == ($id | ascii_downcase)) + | select(.id == $id) | ((.options // "") | capture("rootflags=subvol=[^ ]*/\\.snapshots/(?<n>[0-9]+)/snapshot") | .n) - // ((.version // "") | capture("^(?<n>[0-9]+)@") | .n) + // ((.version // "") | capture("^(?<n>[0-9]+)[@_]") | .n) // empty' } @@ -3547,6 +3628,22 @@ return "$status" } +# `systemd-pcrlock` refuses to generate a component when the event +# log does not replay to the current value of one of the PCRs that +# the component describes, and it checks all of them, not only the +# ones that the policy is going to seal. Two cases are routine: +# PCR 7 when secure boot is disabled, and PCR 0 under a firmware +# that does not log everything that it measures, like the vTPM of +# VMware Workstation. Neither is a reason to stop, as a PCR left +# without a component is dropped by `predict` and reported by +# `get_final_pcrs` +pcrlock_lock() +{ + local err status=0 + err="$(pcrlock "$@" 2>&1)" || status=$? + [ "$status" -eq 0 ] || dbg "No component for '$1': ${err:-exit $status}" +} + is_pcr_oracle() { [ -e /etc/systemd/tpm2-pcr-public-key.pem ] && \ @@ -3687,9 +3784,9 @@ # 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 + # - The version of the entry has no "N_" (or "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 @@ -3697,7 +3794,7 @@ # and by "as", so the filter has to reach jq unexpanded # shellcheck disable=SC2016 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)})' + filter='def priority(id): id as $id | $ids | split(" ") | index($id); map(. + {"priority": priority(.version | scan("^(\\d+)[@_]") | .[]), "kernel": .version | scan("^\\d+[@_](?:(\\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 @@ -3777,6 +3874,26 @@ find /var/lib/pcrlock.d/"$component".pcrlock.d -name '*.pcrlock' ! -name 'shift-*.pcrlock' -delete } +# The shifted variation matches the current event log, and when the +# component did not change it is identical to the one that was just +# generated. `systemd-pcrlock` drops the duplicated values from the +# prediction, but only after walking every combination of variations, +# so each copy doubles the cost of `predict` and `make-policy`. +drop_duplicated_shifts() +{ + local shifted variation + + for shifted in /var/lib/pcrlock.d/*.pcrlock.d/shift-*.pcrlock; do + for variation in "${shifted%/*}"/*.pcrlock; do + [[ "$(basename "$variation")" != shift-* ]] || continue + cmp -s "$shifted" "$variation" || continue + dbg "Dropping $shifted, identical to $variation" + rm "$shifted" + break + done + done +} + uint64_le() { # 64 bit little endian representation of a number, as escape @@ -4417,6 +4534,10 @@ get_predicted_hashes() { + # Nothing generated any component yet, which is a valid state + # and not a reason to print a `find` error + [ -d /var/lib/pcrlock.d ] || return 0 + find /var/lib/pcrlock.d/ -name "*.pcrlock" -type f -exec jq -r '.records[].digests[] | select(.hashAlg == "sha256") | .digest' {} + | sort -u } @@ -4926,18 +5047,16 @@ shift_component 250-firmware-code-early shift_component 550-firmware-code-late - pcrlock lock-firmware-code + pcrlock_lock lock-firmware-code shift_component 250-firmware-config-early shift_component 550-firmware-config-late - pcrlock lock-firmware-config + pcrlock_lock 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 + pcrlock_lock lock-secureboot-policy shift_component 620-secureboot-authority - pcrlock lock-secureboot-authority &> /dev/null || true + pcrlock_lock lock-secureboot-authority # Generates 620-secureboot-authority when the verb cannot pcrlock_secureboot_sbatlevel @@ -5003,6 +5122,8 @@ pcrlock_grub2_bls fi + drop_duplicated_shifts + # 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 @@ -5181,7 +5302,7 @@ echo "Recovery PIN: $pin" if [ -x /usr/bin/qrencode ]; then echo "You can also scan it with your mobile phone:" - qrencode -t utf8i "$pin" + echo -n "$pin" | qrencode -t utf8i fi # Add the generated recovery PIN to the kernel @@ -5407,8 +5528,8 @@ # 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 + # Note that dracut will add the swap partition even if + # it is not marked as x-initrd.attach [[ "$name" = \#* ]] && continue [[ "$opts" != *"tpm2-device="* ]] && continue [[ "$opts" != *"tpm2-measure-pcr="* ]] && continue @@ -5793,7 +5914,21 @@ in_lockout() { command -v tpm2_getcap &> /dev/null || { warn "tpm2_getcap not found"; return 1; } - tpm2_getcap properties-variable | grep -q 'inLockout: *1' + + # `tpm2-tools` probes a fixed list of TCTIs and `tabrmd` comes + # before the device. A system that has `libtss2-tcti-tabrmd0` + # installed but no `tpm2-abrmd` running loads the library, fails + # to reach the service on the bus, and prints a GDBus warning + # plus "Could not initialize TCTI file" before falling back to + # /dev/tpmrm0. Nothing is broken by that, but it is the first + # thing on stderr, and an installer that collects stderr reports + # it as the reason for whatever fails next. Naming the device + # skips the probe. + # + # This is the same device that every `systemd-cryptenroll` and + # `systemd-pcrlock` call here already talks to: `tpm2_context_new` + # hardcodes it, and for the same reason + TPM2TOOLS_TCTI="device:/dev/tpmrm0" tpm2_getcap properties-variable | grep -q 'inLockout: *1' } is_same_device() @@ -6178,7 +6313,7 @@ echo "Recovery key: $key" if [ -x /usr/bin/qrencode ]; then echo "You can also scan it with your mobile phone:" - qrencode -t utf8i "$key" + echo -n "$key" | qrencode -t utf8i fi fi @@ -6610,6 +6745,7 @@ status_tpm2_device= status_pin_reachable= status_dangling= +status_pcr15_halt= # Whether "$status_dangling" holds this entry. The names in it carry # the ".conf" that the boot loader interface variables leave out, so @@ -6984,11 +7120,27 @@ fi status_row "Enabled" "yes" + # The same three checks that `measure-pcr-validator` makes at + # boot, in the same order. Any of them failing ends in + # `FailureAction=poweroff-immediate`, so this is not a detail of + # the report but the answer to "why did the machine power off" + if [ ! -e "$prediction" ]; then + status_pcr15_halt="there is no prediction file" + elif [ ! -e "$prediction.sha256" ]; then + status_pcr15_halt="the prediction is not signed" + elif ! openssl dgst -sha256 \ + -verify /var/lib/sdbootutil/measure-pcr-public.pem \ + -signature "$prediction.sha256" \ + "$prediction" &> /dev/null; then + status_pcr15_halt="the signature of the prediction is not valid" + fi + 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 [ -n "$status_pcr15_halt" ]; then + status_row "Prediction" "UNUSABLE, $status_pcr15_halt" + else + status_row "Prediction" "present, signed" + fi if [ -e /var/lib/sdbootutil/measure-pcr-public.pem ]; then status_row "Public key" "yes" @@ -7201,6 +7353,28 @@ EOF } +# One row for a binary in the ESP against the one that the system ships, +# decided the same way that `bootloader_needs_update` decides +status_version_row() +{ + local label="$1" deployed="$2" system="$3" + + if [ -z "$system" ]; then + status_row "$label" "$deployed, the version of the system cannot be read" + return + fi + + # The comparison goes to stdout ("261.2 == 261.2"), and only the + # exit status is wanted here + local status=0 + systemd-analyze compare-versions "$deployed" "$system" > /dev/null 2>&1 || status="$?" + case "$status" in + 11) status_row "$label" "$deployed, newer than the $system of the system" ;; + 12) status_row "$label" "OUTDATED: $deployed in the ESP, $system in the system" ;; + *) status_row "$label" "up to date ($deployed)" ;; + esac +} + # What the machine booted, and what it will boot next status_boot() { @@ -7220,18 +7394,19 @@ 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 + status_version_row "In the ESP" "$deployed" "$system" + fi + + # `install_bootloader` deploys the shim and the bootloader + # together, so a shim that is not the one of the system is also a + # reason to run it, the way `bootloader_needs_update` counts it. + # Without a shim in the ESP there is nothing to compare + local deployed_shim="" system_shim="" + deployed_shim="$(shim_version 2> /dev/null)" || deployed_shim="" + if [ -n "$deployed_shim" ]; then + system_shim="$(shim_version "$(find_shim)" 2> /dev/null)" || system_shim="" + status_version_row "Shim" "$deployed_shim" "$system_shim" fi # Its own copy of the entry list, and not "update_entries": that @@ -7242,17 +7417,16 @@ [ -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="" + # "LoaderEntrySelected", but grub2-bls spells the variable + # differently and nothing matches there, so "isSelected" is always + # null. Compare it here too, through "entry_key" (same workaround + # as in "list_entries") + local selected="" key="" booted="" selected="$(bli_efi_var_get "LoaderEntrySelected" 2> /dev/null)" || selected="" - booted="$(jq -r --arg s "$selected" \ + key="$(entry_key "$selected")" + booted="$(jq -r --arg k "$key" \ 'first(.[] | select(.isSelected == true - or (.id | ascii_downcase) == $s - or (.id | ascii_downcase) == ($s + ".conf")) + or (.id | sub("\\.conf$"; "")) == $k) | .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 @@ -7416,6 +7590,16 @@ warn "Run 'sdbootutil cleanup --repair' to write them again from the kernels that are still installed" } + # The validator runs on every boot and powers the machine off + # when the prediction it needs is not usable. Nothing else in + # the report says that the next boot does not finish, and the + # rows that carry the two halves of it are behind `--full` + [ -z "$status_pcr15_halt" ] || { + echo + warn "/etc/crypttab asks to measure PCR 15, but $status_pcr15_halt. 'measure-pcr-validator' powers the machine off at the next boot" + warn "Run 'sdbootutil update-predictions' to write it, or boot once with 'measure-pcr-validator.ignore=yes' in the cmdline" + } + # 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 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/sdbootutil-enroll new/sdbootutil-1+git20260929.26b6989/sdbootutil-enroll --- old/sdbootutil-1+git20260909.7cfa1f0/sdbootutil-enroll 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/sdbootutil-enroll 2026-09-29 22:21:06.000000000 +0200 @@ -183,7 +183,7 @@ # This is not an error: the service is enabled by the image, while the # credentials come from the deployment [ -n "$rk$pw$tpm2_pin$tpm2$fido2" ] || { - echo "No enrollment method requested. The device is still opened only by the key that disk-encryption-tool generated, readable from %user:cryptenroll until this boot ends" > /dev/stderr + echo "No enrollment method requested. The device is still opened only by the key that disk-encryption-tool generated, readable from %user:cryptenroll until this boot ends" >&2 exit 0 } @@ -220,7 +220,7 @@ KEY="$key" sdbootutil enroll --method=recovery-key "$extra")"; then [ -z "$recovery_key" ] || write_issue_file "$recovery_key" else - echo "Failed to enroll the recovery key" > /dev/stderr + echo "Failed to enroll the recovery key" >&2 error=1 fi } @@ -236,7 +236,7 @@ fi CURRENT_PW="$current_pw" RECOVERY_PIN="$recovery_pin" \ PW="$pw" sdbootutil enroll --method=password "$extra" || { - echo "Failed to enroll the password" > /dev/stderr + echo "Failed to enroll the password" >&2 error=1 } } @@ -247,7 +247,7 @@ SDB_ADD_INITIAL_COMPONENT=1 PIN="$tpm2_pin" sdbootutil enroll --method=tpm2+pin)"; then write_recovery_pin "$out" else - echo "Failed to enroll the TPM2 with PIN" > /dev/stderr + echo "Failed to enroll the TPM2 with PIN" >&2 error=1 fi elif [ -n "$tpm2" ]; then @@ -256,7 +256,7 @@ SDB_ADD_INITIAL_COMPONENT=1 sdbootutil enroll --method=tpm2)"; then write_recovery_pin "$out" else - echo "Failed to enroll the TPM2" > /dev/stderr + echo "Failed to enroll the TPM2" >&2 error=1 fi fi @@ -265,7 +265,7 @@ echo "Enrolling a FIDO2 key" CURRENT_PW="$current_pw" RECOVERY_PIN="$recovery_pin" \ sdbootutil enroll --method=fido2 || { - echo "Failed to enroll the FIDO2 key" > /dev/stderr + echo "Failed to enroll the FIDO2 key" >&2 error=1 } } @@ -277,7 +277,7 @@ # open it. Exiting non-zero also makes the failure visible, as # sdbootutil-enroll.service is the only trace that this ran [ "$error" -eq 0 ] || { - echo "Keeping the enrollment key, as some enrollment failed" > /dev/stderr + echo "Keeping the enrollment key, as some enrollment failed" >&2 exit 1 } @@ -287,9 +287,9 @@ while read -r dev; do wipe_enrollment_key "$dev" case "$?" in - 1) echo "Keeping the enrollment key of $dev, as no other method can open it" > /dev/stderr + 1) echo "Keeping the enrollment key of $dev, as no other method can open it" >&2 error=1 ;; - 2) echo "Failed to remove the enrollment key of $dev" > /dev/stderr + 2) echo "Failed to remove the enrollment key of $dev" >&2 error=1 ;; esac done < <(sdbootutil list-devices) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/sdbootutil-update-predictions.service new/sdbootutil-1+git20260929.26b6989/sdbootutil-update-predictions.service --- old/sdbootutil-1+git20260909.7cfa1f0/sdbootutil-update-predictions.service 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/sdbootutil-update-predictions.service 2026-09-29 22:21:06.000000000 +0200 @@ -3,7 +3,7 @@ ConditionSecurity=tpm2 [Service] -Type=oneshot +Type=exec KeyringMode=shared PrivateTmp=yes # Predictions are only needed when a device is unlocked with the TPM2. @@ -39,14 +39,22 @@ else \ echo "Command failed. Reason: $SERVICE_RESULT (Status: $EXIT_STATUS). No backup found, keeping the current predictions"; \ fi; \ + [ ! -d /run/sdbootutil ] || touch /run/sdbootutil/update-predictions; \ fi; \ rm -rf "/tmp/$INVOCATION_ID"' ImportCredential=sdbootutil-update-predictions.* +# On failure the marker is set again, so the predictions are still +# pending: if this was interrupted by a reboot, the transient +# sdbootutil-update-predictions-shutdown.service (snapper plugin) is +# ordered after this service and retries them. Without the marker the +# loop has removed it already, and nothing would redo them +# # The service can be triggered by a timer (snapper plugin), this # prevents systemd from killing the script mid-run during a reboot, -# and gives 2 minutes to complete +# and gives 5 minutes to complete. The loop can run update-predictions +# more than once, and a single run takes about a minute on a slow VM KillSignal=SIGCONT -TimeoutStopSec=120 +TimeoutStopSec=300 [Install] WantedBy=default.target diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/sdbootutil.spec new/sdbootutil-1+git20260929.26b6989/sdbootutil.spec --- old/sdbootutil-1+git20260909.7cfa1f0/sdbootutil.spec 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/sdbootutil.spec 2026-09-29 22:21:06.000000000 +0200 @@ -151,6 +151,7 @@ Requires: %{name} = %{version} Requires: bash Requires: bash-completion +Supplements: (%{name} and bash-completion) BuildArch: noarch %description bash-completion diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/tests/README.md new/sdbootutil-1+git20260929.26b6989/tests/README.md --- old/sdbootutil-1+git20260909.7cfa1f0/tests/README.md 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/tests/README.md 2026-09-29 22:21:06.000000000 +0200 @@ -61,6 +61,8 @@ | `update-all-entries` | editing an entry in place is not a silent no-op, and is deterministic | | `boot-counter` | a `+N` counter is kept, and does not hide the entry from removal | | `repair-entry` | `cleanup --repair` restores the file and keeps a hand-edited command line | +| `set-default` | the default entry is written where the same loader reads it back | +| `tight-esp` | on a nearly full ESP, reinstalling a kernel that is already there evicts nothing | ### What it reports today diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/tests/scenarios/set-default new/sdbootutil-1+git20260929.26b6989/tests/scenarios/set-default --- old/sdbootutil-1+git20260909.7cfa1f0/tests/scenarios/set-default 1970-01-01 01:00:00.000000000 +0100 +++ new/sdbootutil-1+git20260929.26b6989/tests/scenarios/set-default 2026-09-29 22:21:06.000000000 +0200 @@ -0,0 +1,36 @@ +#!/bin/bash +# scenario: set-default +# Set the default boot entry and read it back +# +# The default is written to a different place on each loader -- +# loader.conf for systemd-boot, grubenv for grub2-bls -- and the reader +# has to look in the same one. It fails silently: the write lands +# somewhere nothing reads, get-default keeps answering with bootctl's +# guess, and the machine boots the entry it booted before. +# +# --no-variables is what puts the on-disk path under test, and is also +# what keeps this out of the real EFI variables. +sdb() +{ + "$SDBOOTUTIL" --esp-path "$ESP" --no-variables --disable-predictions "$@" +} + +# Prefer an entry that is not the default already, so that the write has +# something to change, and settle for the only one there is otherwise. +# `first` over an empty stream prints nothing rather than "null", so the +# preference has to be expressed inside jq: a guard out here would read +# the empty output as an answer and skip the guest +target="$(bootctl list --json=short | jq -r ' + [.[] | select(.type == "type1")] as $entries + | (first($entries[] | select(.isDefault != true)) + // first($entries[]) // empty) | .id')" +[ -n "$target" ] || exit 77 + +sdb set-default "$target" + +got="$(sdb get-default)" +[ "$got" = "$target" ] || { + echo "get-default returned '$got', expected '$target'" + exit 1 +} +echo "default round trip: $target" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/tests/scenarios/tight-esp new/sdbootutil-1+git20260929.26b6989/tests/scenarios/tight-esp --- old/sdbootutil-1+git20260909.7cfa1f0/tests/scenarios/tight-esp 1970-01-01 01:00:00.000000000 +0100 +++ new/sdbootutil-1+git20260929.26b6989/tests/scenarios/tight-esp 2026-09-29 22:21:06.000000000 +0200 @@ -0,0 +1,90 @@ +#!/bin/bash +# scenario: tight-esp +# Re-installing a kernel that is already in the ESP needs no space +# +# make_free_space unlinks boot entries, from the least to the most +# valuable one, until the new kernel and initrd fit. A kernel whose +# file is already in the ESP is reused, and so is an initrd taken from +# an existing entry, so neither needs any space. Counting them anyway +# (github issue 449) evicts entries that nothing needed gone, and those +# can be the ones of the default snapshot. +# +# The copy of the ESP normally lives in /var/tmp, where the free space +# is never short and the eviction never runs. So it is moved to a +# tmpfs with half a kernel of free space, and the reserve is set to +# zero: the reinstallation must then leave every entry in place. +# +# Scoped to the running subvolume, like boot-counter: that is the entry +# that add-kernel finds and reinstalls with --force +version="$(uname -r)" +subvol="$(findmnt --noheadings -o FSROOT --target / | head -n1)" +subvol="${subvol#/}" + +[ ! -e "/usr/lib/modules/$version/initrd" ] || { + echo "a prebuilt initrd is installed, and its size is counted" + exit 77 +} + +linux="$(bootctl list --json=short | jq -r --arg v "$version" --arg s "$subvol" ' + [.[] | select(.type=="type1") | + select((.version // "") | endswith($v)) | + select($s == "" or $s == "/" or ((.options // "") | contains("subvol=" + $s)))] | + if length == 1 then .[0].linux // empty else empty end')" +[ -n "$linux" ] && [ -f "$ESP$linux" ] || { + echo "expected one entry with a kernel for the running $version" + exit 77 +} + +tight="$ESP.tight" +mkdir -p "$tight" +mount -t tmpfs -o size=1G tmpfs "$tight" || { echo "cannot mount a tmpfs"; exit 77; } +trap 'umount "$tight"; rmdir "$tight"' EXIT +cp -a "$ESP"/. "$tight" + +used="$(findmnt --noheadings --bytes -o USED --target "$tight")" +kernel="$(stat -c %s "$ESP$linux")" +size=$(((used + kernel / 2) / 1024)) +mount -o remount,size="${size}k" "$tight" || { echo "cannot shrink the tmpfs to ${size}k"; exit 77; } +echo "ESP on a tmpfs of ${size}k, $(findmnt --noheadings -o AVAIL --target "$tight") free, the kernel is $((kernel / 1024))k" + +# By id and not by file name: the reinstalled entry can come back +# with a boot counter, and it is still the same entry +ids() +{ + SYSTEMD_ESP_PATH="$tight" bootctl list --json=short | + jq -r '.[] | select(.type=="type1") | .id' | sort +} + +ids > "$tight.before" + +SYSTEMD_ESP_PATH="$tight" "$SDBOOTUTIL" --esp-path "$tight" --esp-free-space 0 \ + --no-variables --disable-predictions --force -v add-kernel "$version" 2>&1 | + tee "$tight.log" +status="${PIPESTATUS[0]}" + +ids > "$tight.after" +changed="$(diff "$tight.before" "$tight.after")" +rm -f "$tight.before" "$tight.after" + +# An initrd that was not found is generated, and then it does need the +# space: what is evicted for it is the designed behaviour, not this bug. +# It happens when the entry of the snapshot is under another name +grep -q "Generating new initrd" "$tight.log" && { + rm -f "$tight.log" + echo "the initrd was not reused, so there was something to make room for" + exit 77 +} +rm -f "$tight.log" + +# The result goes back where fde-state looks, so the invariants are +# checked on what the tight ESP was left with +rm -rf "${ESP:?}"/* +cp -a "$tight"/. "$ESP" + +[ "$status" = 0 ] || { echo "FAIL: add-kernel exited with $status"; exit 1; } +[ -z "$changed" ] || { + echo "FAIL: reinstalling $version changed the entries:" + echo "$changed" + exit 1 +} +echo "every entry is still in place" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/sdbootutil-1+git20260909.7cfa1f0/tests/unit new/sdbootutil-1+git20260929.26b6989/tests/unit --- old/sdbootutil-1+git20260909.7cfa1f0/tests/unit 2026-09-09 13:40:14.000000000 +0200 +++ new/sdbootutil-1+git20260929.26b6989/tests/unit 2026-09-29 22:21:06.000000000 +0200 @@ -2,8 +2,11 @@ # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: Copyright 2026 SUSE LLC # -# Unit tests for the routing of the secrets: which source wins, what is -# published to the kernel keyring, and which warnings are printed. +# Unit tests for the library half of sdbootutil: the routing of the +# secrets -- which source wins, what is published to the keyring, and +# which warnings are printed -- the entry names the boot loaders spell +# differently, the versions the status report compares, `grubenv`, and +# the entries that may be removed to make room in the ESP. # # These are decisions the tool takes *before* it writes anything to the # TPM2, so they do not need one, and they do not need a guest either: a @@ -41,8 +44,11 @@ KEYS=(cryptenroll sdbootutil sdbootutil-key sdbootutil-pw sdbootutil-pin sdbootutil-recovery-pin sdbootutil-tpm2-pin) +# A boot entry ID as `bootctl` reports it, for the entry_key cases +ENTRY="opensuse-microos-7.2.3-1-default-10" + [ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] || { - sed -n '4,26p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' + sed -n '4,27p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' exit 0 } @@ -333,6 +339,193 @@ no_key sdbootutil-recovery-pin } +# entry_key: the spellings a boot loader can leave in +# "LoaderEntrySelected", all reduced to the ID that `bootctl` reports. +# systemd-boot writes that ID as it is; grub2-bls drops the ".conf" +# suffix and keeps the boot counter, which it records before +# "systemd-bless-boot" renames the file + +t_ek_systemd_boot() +{ + load + + is "the ID itself" "$ENTRY" "$(entry_key "$ENTRY.conf")" +} + +t_ek_no_suffix() +{ + load + + is "no .conf suffix" "$ENTRY" "$(entry_key "$ENTRY")" +} + +t_ek_counter() +{ + load + + is "counter alone" "$ENTRY" "$(entry_key "$ENTRY+3")" + is "counter and suffix" "$ENTRY" "$(entry_key "$ENTRY+3.conf")" +} + +t_ek_counted_boot() +{ + load + + is "one attempt counted" "$ENTRY" "$(entry_key "$ENTRY+2-1")" +} + +t_ek_not_a_counter() +{ + load + + # Only "+" followed by digits is a counter, so an entry token + # that carries one keeps it + is "a + in the name" "my+entry-1" "$(entry_key "my+entry-1.conf")" + is "nothing to reduce" "" "$(entry_key "")" +} + +# status_version_row: the three answers that the report can give about a +# binary in the ESP, the same ones that `bootloader_needs_update` gives + +t_svr_current() +{ + load + + says "row" "$(status_version_row "In the ESP" 261.2 261.2)" "up to date (261.2)" +} + +t_svr_outdated() +{ + load + + says "row" "$(status_version_row "In the ESP" 258.4 261.2)" \ + "OUTDATED: 258.4 in the ESP, 261.2 in the system" +} + +t_svr_newer() +{ + load + + says "row" "$(status_version_row "In the ESP" 262 261.2)" \ + "262, newer than the 261.2 of the system" +} + +# The shim reports a major.minor that does not change between builds, so +# the modification time appended to it is what decides +t_svr_shim_mtime() +{ + load + + says "older" "$(status_version_row Shim 16.1-202506170714 16.1-202601011200)" "OUTDATED" + says "newer" "$(status_version_row Shim 16.1-202601011200 16.1-202506170714)" "newer than" +} + +t_svr_no_system() +{ + load + + says "row" "$(status_version_row Shim 16.1-202506170714 "")" \ + "the version of the system cannot be read" +} + +# grubenv_set: GRUB2 reads a block of exactly 1024 bytes, so the size is +# the whole of what the padding has to get right + +t_grubenv_size() +{ + load + # shellcheck disable=SC2034 # both are read by the sourced library + esp_root="$workdir" esp_dst="" + + grubenv_set default opensuse-tumbleweed-7.2.3-1-default-10 + is "size" 1024 "$(stat -c %s "$workdir/grubenv")" + + grubenv_set timeout 5 + is "size after a second key" 1024 "$(stat -c %s "$workdir/grubenv")" + + is "default" opensuse-tumbleweed-7.2.3-1-default-10 "$(grubenv_get default)" + is "timeout" 5 "$(grubenv_get timeout)" +} + +# A block whose variables already fill it needs no padding at all, and +# that is where one byte used to be written anyway +t_grubenv_full() +{ + load + # shellcheck disable=SC2034 # both are read by the sourced library + esp_root="$workdir" esp_dst="" + + # 25 bytes of header, and a "default=" line that takes the rest + printf '%s\n' "# GRUB Environment Block" > "$workdir/grubenv" + printf 'default=%s\n' "$(printf '%*s' 990 "" | tr ' ' x)" >> "$workdir/grubenv" + + grubenv_set default "$(printf '%*s' 990 "" | tr ' ' y)" + is "size" 1024 "$(stat -c %s "$workdir/grubenv")" +} + +# removable_entries: the entries that make_free_space may unlink when +# the ESP is short of space. A new snapshot reuses the initrd of an +# entry of its parent (github issue 449), and unlinking that entry frees +# the initrd the new entry is about to point at + +# The ESP of a Tumbleweed where snapshot 11 is being created from 10, +# the default, which has two kernels. Snapshot 5 is a leftover +removable_setup() +{ + load + # shellcheck disable=SC2034 # read by the sourced library + have_snapshots=1 root_snapshot=10 entryfile="$workdir/entries.json" + + # What reads the system: bootctl, and the snapper database + # shellcheck disable=SC2329 # both are called by the sourced library + update_entries_for_this_system() { :; } + # shellcheck disable=SC2329 + snapshots_in_use() { printf '%s\n' 10 11; } + + local e=/boot/efi/loader/entries t=opensuse-tumbleweed s=@/.snapshots + jq -n --arg e "$e" --arg t "$t" --arg s "$s" '[ + {id: "\($t)-6.1.0-1-default-5.conf", path: "\($e)/\($t)-6.1.0-1-default-5.conf", + options: "rootflags=subvol=\($s)/5/snapshot", linux: "/\($t)/6.1.0-1-default/linux-a"}, + {id: "\($t)-6.1.0-1-default-10.conf", path: "\($e)/\($t)-6.1.0-1-default-10.conf", + options: "rootflags=subvol=\($s)/10/snapshot", linux: "/\($t)/6.1.0-1-default/linux-a"}, + {id: "\($t)-6.2.0-1-default-10.conf", path: "\($e)/\($t)-6.2.0-1-default-10.conf", + options: "rootflags=subvol=\($s)/10/snapshot", linux: "/\($t)/6.2.0-1-default/linux-b", + isDefault: true} + ]' > "$entryfile" +} + +t_re_order() +{ + removable_setup + + # The leftover first, the default snapshot as a last resort, and + # never the default entry + is "candidates" \ + "opensuse-tumbleweed-6.1.0-1-default-5.conf opensuse-tumbleweed-6.1.0-1-default-10.conf" \ + "$(removable_entries 11 | paste -sd' ')" +} + +t_re_reused() +{ + removable_setup + # shellcheck disable=SC2034 # read by the sourced library + reused_entry="/boot/efi/loader/entries/opensuse-tumbleweed-6.1.0-1-default-10.conf" + + is "candidates" "opensuse-tumbleweed-6.1.0-1-default-5.conf" \ + "$(removable_entries 11 | paste -sd' ')" +} + +# pending_kernel_size: a kernel that is already in the ESP is reused, so +# it needs no space +t_pks_reused() +{ + load + + is "no kernel to copy" 0 "$(pending_kernel_size "")" + head -c 4096 /dev/zero > "$workdir/linux" + is "a 4K kernel" 5 "$(pending_kernel_size "$workdir/linux")" +} + ####### the runner ####### run_case() @@ -380,6 +573,21 @@ t_erk_recovery_pin_env enroll_recovery_key enrolls the presented recovery PIN as the key t_erk_diverging enroll_recovery_key reports a key that differs from the PIN t_erk_unreachable_pin enroll_recovery_key publishes nothing when a PIN it cannot reach exists + t_ek_systemd_boot entry_key takes the ID that systemd-boot writes + t_ek_no_suffix entry_key takes the ID without the ".conf" suffix + t_ek_counter entry_key drops the boot counter + t_ek_counted_boot entry_key drops a counter with the attempts done + t_ek_not_a_counter entry_key keeps a "+" that is not a boot counter + t_svr_current status_version_row reports a version that is the one of the system + t_svr_outdated status_version_row reports a version older than the one of the system + t_svr_newer status_version_row reports a version newer than the one of the system + t_svr_shim_mtime status_version_row compares the shim by its modification time + t_svr_no_system status_version_row reports a system version that cannot be read + t_grubenv_size grubenv_set writes a block of exactly 1024 bytes + t_grubenv_full grubenv_set does not pad a block that is already full + t_re_order removable_entries keeps the default and removes leftovers first + t_re_reused removable_entries keeps the entry whose initrd is being reused + t_pks_reused pending_kernel_size counts nothing for a kernel already in the ESP EOF echo ++++++ sdbootutil.obsinfo ++++++ --- /var/tmp/diff_new_pack.WBaDkZ/_old 2026-09-30 16:22:42.179474334 +0200 +++ /var/tmp/diff_new_pack.WBaDkZ/_new 2026-09-30 16:22:42.182474460 +0200 @@ -1,5 +1,5 @@ name: sdbootutil -version: 1+git20260909.7cfa1f0 -mtime: 1788954014 -commit: 7cfa1f0ab1bba2c808ef5ff63aff22b26aa42d08 +version: 1+git20260929.26b6989 +mtime: 1790713266 +commit: 26b6989e346388bfa244b226c809a1e6a3824a0d
