git: d8acc2a9a431 - main - boot-test.sh: Add gptboot.efi tests

From: Warner Losh <imp_at_FreeBSD.org>
Date: Sat, 26 Sep 2026 06:44:56 UTC
The branch main has been updated by imp:

URL: https://cgit.FreeBSD.org/src/commit/?id=d8acc2a9a431b4d57ef79b2ca3113ecf33b95d8d

commit d8acc2a9a431b4d57ef79b2ca3113ecf33b95d8d
Author:     Warner Losh <imp@FreeBSD.org>
AuthorDate: 2026-09-24 15:02:23 +0000
Commit:     Warner Losh <imp@FreeBSD.org>
CommitDate: 2026-09-26 06:44:21 +0000

    boot-test.sh: Add gptboot.efi tests
    
    Make sure that we can chainboot with gptboot.efi. Many projects use this
    as their migration tool from gptboot to ping-pong partitions to boot
    from. This tests that functionality which I recently broke.
    
    Assisted-by: Claude Code (Fable 5, Opus 5)
    Sponsored by:           Netflix
---
 tools/boot/boot-test.sh | 125 ++++++++++++++++++++++++++++++++++++++++++++-
 tools/boot/gptattr.c    | 133 ++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 257 insertions(+), 1 deletion(-)

diff --git a/tools/boot/boot-test.sh b/tools/boot/boot-test.sh
index a2149e5da80a..909c87fa1288 100755
--- a/tools/boot/boot-test.sh
+++ b/tools/boot/boot-test.sh
@@ -199,6 +199,10 @@ fi
 # The smallest FAT32 filesystem is 33292 KB
 espsize=33292
 
+# GPT attribute bit that marks the partition to boot, from
+# sys/sys/disk/gpt.h (GPT_ENT_ATTR_BOOTME).  Used for the A/B test.
+GPT_ATTR_BOOTME=59
+
 # Linux kernel version for linuxboot tests
 LINUX_VERSION=6.18.2
 
@@ -495,6 +499,84 @@ make_one_zfs() {
     rm -f ${mt}
 }
 
+# --------------------------------------------------------------------------
+# A/B (dual root) images
+#
+# gptboot.efi is the only FreeBSD EFI component that reads the GPT bootme
+# attribute, so it is the only way to test that the loader it chainloads
+# honours the partition that was selected for it, rather than picking a root
+# of its own.
+#
+# The failure being tested for is "boots fine, wrong root", not "fails to
+# boot": a loader that ignores the selected partition still reaches /etc/rc
+# and still looks healthy.  So the two roots have to differ in what they
+# print, and only the bootme one may print the string run_one_test greps for.
+# Two identical roots (what mkimg writes if handed the same image twice)
+# would make this test permanently green.
+# --------------------------------------------------------------------------
+
+# Create one of the two A/B root images.  ${1} is the slice letter: each slice
+# gets its own UFS label, an fstab pointing at that label, and an /etc/rc that
+# names the slice it booted from.  Only slice B -- the one set bootme in
+# assemble_efi_gpt_ab -- prints SUCCESS; booting A prints WRONG SLICE instead
+# and the test fails on timeout.
+make_one_ufs_ab() {
+    slice=$1
+    img=${IMGDIR}/bootable-ufs-ab-${slice}.img
+    mt=$(mktemp ${OUTDIR}/ufs-ab-mtree.XXXXXX)
+    fstab=${OUTDIR}/fstab.ufs.ab-${slice}
+    rc=${OUTDIR}/rc.ab-${slice}
+
+    echo "  Creating UFS image for A/B slice ${slice}..."
+    echo "/dev/ufs/root${slice}	/	ufs	rw	1	1" > ${fstab}
+
+    # Both roots report which one got mounted, so a failing log says what
+    # happened; only B counts as a pass.  vfs.root.mountfrom is the direct
+    # readout of the loader's choice.
+    if [ "${slice}" = "B" ]; then
+	verdict='echo "RC COMMAND RUNNING -- SUCCESS!!!!!"'
+    else
+	verdict='echo "WRONG SLICE -- booted A, expected B"'
+    fi
+    cat > ${rc} <<RCEOF
+#!/bin/sh
+
+sysctl machdep.bootmethod
+kenv vfs.root.mountfrom
+${verdict}
+halt -p
+RCEOF
+
+    echo "./etc/fstab type=file mode=0644 contents=${fstab}" > ${mt}
+    echo "./etc/rc type=file mode=0755 contents=${rc}" >> ${mt}
+    echo "./boot/loader.efi type=file mode=0755 contents=${DESTDIR}/boot/loader_lua.efi" >> ${mt}
+    makefs -t ffs -B $(param byte_order) -M 10m -o label=root${slice} -o version=2 \
+	${img} ${mt} ${DESTDIR} >> ${LOGDIR}/imagebuild.log 2>&1
+    rm -f ${mt}
+}
+
+# Create an ESP holding gptboot.efi rather than a loader variant.  gptboot.efi
+# is a boot1 flavour: it reads the GPT, picks the bootme partition, and
+# chainloads /boot/loader.efi from it.
+make_gptboot_esp() {
+    esp=${IMGDIR}/gptboot.esp
+    mt=$(mktemp ${OUTDIR}/esp-mtree.XXXXXX)
+
+    echo "  Creating ESP with gptboot.efi..."
+    cat > ${mt} <<EOF
+./efi type=dir uname=root gname=wheel mode=0755
+./efi/boot type=dir uname=root gname=wheel mode=0755
+./efi/boot/$(param efi_bootname).efi type=file uname=root gname=wheel mode=0755 contents=${DESTDIR}/boot/gptboot.efi
+EOF
+    makefs -t msdos \
+	-o fat_type=32 \
+	-o sectors_per_cluster=1 \
+	-o volume_label=EFISYS \
+	-s ${espsize}k \
+	${esp} ${mt} >> ${LOGDIR}/imagebuild.log 2>&1
+    rm -f ${mt}
+}
+
 # Create an ESP with the given EFI loader using an mtree spec
 make_one_esp() {
     loader_name=$1
@@ -666,6 +748,12 @@ make_base_images() {
 	for l in $(param efi_loaders); do
 	    make_one_esp $l
 	done
+	# A/B: gptboot.efi on the ESP, plus two tellable-apart roots.
+	if [ -f "${DESTDIR}/boot/gptboot.efi" ]; then
+	    make_gptboot_esp
+	    make_one_ufs_ab A
+	    make_one_ufs_ab B
+	fi
     fi
 
     # Linuxboot initrd + ESP (if supported and Linux kernel is available)
@@ -748,6 +836,30 @@ assemble_efi_gpt() {
     done
 }
 
+# Build the A/B disk and register its test.  Layout mirrors an A/B upgrade
+# scheme: one ESP holding gptboot.efi, then two interchangeable root slices.
+#
+# bootme is set on p3 (slice B, the second root) on purpose.  With no bootme
+# anywhere gptboot.efi falls back to the first UFS partition, which is slice
+# A, so marking p2 instead would pass even if the attribute were ignored
+# entirely.  Marking the second root means only a loader that honours the
+# selected partition can reach slice B and print SUCCESS.
+assemble_efi_gpt_ab() {
+    echo "  Assembling EFI+GPT A/B image..."
+    name="efi-gpt-ufs-ab"
+    img=${IMGDIR}/${name}.img
+
+    mkimg -s gpt \
+	-p efi:=${IMGDIR}/gptboot.esp \
+	-p freebsd-ufs:=${IMGDIR}/bootable-ufs-ab-A.img \
+	-p freebsd-ufs:=${IMGDIR}/bootable-ufs-ab-B.img \
+	-o ${img} >> ${LOGDIR}/imagebuild.log 2>&1
+
+    ${GPTATTR} ${img} 3 ${GPT_ATTR_BOOTME} >> ${LOGDIR}/imagebuild.log 2>&1
+
+    register_test ${name} $(qemu_efi ${img})
+}
+
 assemble_efi_mbr() {
     echo "  Assembling EFI+MBR images..."
     for loader in $(param efi_loaders); do
@@ -1488,6 +1600,9 @@ assemble_all_images() {
     if has efi; then
 	if [ -r "$(param efi_firmware)" ]; then
 	    assemble_efi_gpt
+	    if [ -f "${IMGDIR}/gptboot.esp" ]; then
+		assemble_efi_gpt_ab
+	    fi
 	    if has mbr; then
 		assemble_efi_mbr
 	    fi
@@ -1693,10 +1808,18 @@ case "${NETBOOT_MODE}" in
 esac
 
 # Preflight: universal tools (per-arch qemu binaries are checked in build_all).
-for prog in jq expect makefs mkimg; do
+for prog in jq expect makefs mkimg cc; do
     need_cmd "${prog}"
 done
 
+# gptattr sets the GPT bootme attribute for the A/B test; see
+# assemble_efi_gpt_ab.  It is a host tool and independent of the target
+# architecture, so build it once here rather than per-arch.
+GPTATTR=$(mktemp -t boot-test-gptattr)
+if ! cc -o ${GPTATTR} ${SRCTOP}/tools/boot/gptattr.c -lz 2>/dev/null; then
+    die "Failed to build ${SRCTOP}/tools/boot/gptattr.c"
+fi
+
 echo "FreeBSD boot loader test suite: ${ARCHES}"
 echo ""
 
diff --git a/tools/boot/gptattr.c b/tools/boot/gptattr.c
new file mode 100644
index 000000000000..547ebde3c047
--- /dev/null
+++ b/tools/boot/gptattr.c
@@ -0,0 +1,133 @@
+/*
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+/*
+ * Set a GPT attribute on a partition of a disk image file.
+ *
+ * gpart(8) does the same thing, but only on a real device and only as root.
+ * boot-test.sh builds every image as an unprivileged user, so it needs to
+ * edit the on-disk GPT itself.  Both the primary and the secondary entry
+ * table are updated: the loader only reads the primary, but the kernel
+ * checks both, and a mismatch would be reported as a corrupt GPT during the
+ * boot under test.
+ *
+ * Usage: gptattr <image> <index> <attribute-bit>
+ * where <index> is 1-based, matching gpart(8) partition numbering.
+ */
+
+#include <sys/param.h>
+#include <sys/disk/gpt.h>
+#include <sys/endian.h>
+
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <zlib.h>
+
+#define	SECSZ	512
+
+static uint8_t *image;
+static size_t imagesz;
+
+/*
+ * Apply the attribute to one entry table and refresh the CRCs of the header
+ * at hdroff that describes it.
+ */
+static void
+patch_table(uint64_t hdroff, u_int index, uint64_t attr)
+{
+	struct gpt_hdr *hdr;
+	struct gpt_ent *ent;
+	uint64_t tbloff;
+	uint32_t entries, entsz, hdrsz;
+
+	if (hdroff + SECSZ > imagesz)
+		errx(1, "GPT header at offset %ju is past end of image",
+		    (uintmax_t)hdroff);
+
+	hdr = (struct gpt_hdr *)(image + hdroff);
+	if (memcmp(hdr->hdr_sig, GPT_HDR_SIG, sizeof(hdr->hdr_sig)) != 0)
+		errx(1, "no GPT header at offset %ju", (uintmax_t)hdroff);
+
+	hdrsz = le32toh(hdr->hdr_size);
+	entries = le32toh(hdr->hdr_entries);
+	entsz = le32toh(hdr->hdr_entsz);
+	tbloff = le64toh(hdr->hdr_lba_table) * SECSZ;
+
+	if (index < 1 || index > entries)
+		errx(1, "partition index %u out of range (1..%u)", index,
+		    entries);
+	if (tbloff + (uint64_t)entries * entsz > imagesz)
+		errx(1, "GPT entry table is past end of image");
+
+	ent = (struct gpt_ent *)(image + tbloff + (uint64_t)(index - 1) * entsz);
+	le64enc(&ent->ent_attr, le64toh(ent->ent_attr) | attr);
+
+	/*
+	 * The table CRC covers the entries, and the header CRC covers the
+	 * header including that table CRC, so recompute them in that order.
+	 * hdr_crc_self is zeroed while it is being computed over itself.
+	 */
+	le32enc(&hdr->hdr_crc_table, crc32(0, image + tbloff,
+	    entries * entsz));
+	hdr->hdr_crc_self = 0;
+	le32enc(&hdr->hdr_crc_self, crc32(0, (const uint8_t *)hdr, hdrsz));
+}
+
+int
+main(int argc, char *argv[])
+{
+	FILE *fp;
+	char *end;
+	uint64_t attr;
+	u_long bit, index;
+
+	if (argc != 4) {
+		fprintf(stderr,
+		    "usage: %s <image> <index> <attribute-bit>\n", argv[0]);
+		return (1);
+	}
+
+	errno = 0;
+	index = strtoul(argv[2], &end, 0);
+	if (errno != 0 || *end != '\0' || index == 0)
+		errx(1, "bad partition index: %s", argv[2]);
+	bit = strtoul(argv[3], &end, 0);
+	if (errno != 0 || *end != '\0' || bit > 63)
+		errx(1, "bad attribute bit: %s", argv[3]);
+	attr = (uint64_t)1 << bit;
+
+	if ((fp = fopen(argv[1], "r+")) == NULL)
+		err(1, "%s", argv[1]);
+	if (fseek(fp, 0, SEEK_END) != 0)
+		err(1, "%s", argv[1]);
+	imagesz = (size_t)ftell(fp);
+	rewind(fp);
+	if (imagesz < 2 * SECSZ)
+		errx(1, "%s is too small to hold a GPT", argv[1]);
+	if ((image = malloc(imagesz)) == NULL)
+		err(1, "malloc");
+	if (fread(image, 1, imagesz, fp) != imagesz)
+		err(1, "%s: read", argv[1]);
+
+	/* Primary header is always at LBA 1; it names the secondary. */
+	patch_table(SECSZ, index, attr);
+	patch_table(le64toh(((struct gpt_hdr *)(image + SECSZ))->hdr_lba_alt) *
+	    SECSZ, index, attr);
+
+	rewind(fp);
+	if (fwrite(image, 1, imagesz, fp) != imagesz)
+		err(1, "%s: write", argv[1]);
+	if (fclose(fp) != 0)
+		err(1, "%s: close", argv[1]);
+	free(image);
+
+	return (0);
+}