From 889571cf97316f87b75a3c67bb3ce3fb9cc1ba80 Mon Sep 17 00:00:00 2001 From: Tom Fitzhenry Date: Wed, 22 Jul 2026 11:34:12 +0000 Subject: [PATCH 1/2] nixos/tests/grub: split into a directory and expand coverage Move nixos/tests/grub.nix to nixos/tests/grub/basic.nix unchanged, and add new GRUB boot tests under nixos/tests/grub/: - hashed-password: GRUB user authentication with a users..hashedPasswordFile (a grub-mkpasswd-pbkdf2 hash). The hash is generated in a derivation and passed by store path (via toString) rather than read at evaluation time, to avoid import-from-derivation. Kept separate from basic so each login is exercised on its own VM boot rather than multiplexed onto one menu. - efi: boot through GRUB's EFI support. - graphical: verify GRUB renders its menu in graphical (gfxterm) mode. A marker string is baked into the splash image (which GRUB only draws in gfxterm) and read back via OCR, so a silent fallback to text mode would fail the test rather than pass it. - mirrored-boots: verify GRUB is installed and configured on every path in boot.loader.grub.mirroredBoots. Assisted-by: Claude Opus 4.8 --- nixos/tests/all-tests.nix | 8 ++- nixos/tests/{grub.nix => grub/basic.nix} | 0 nixos/tests/grub/efi.nix | 45 ++++++++++++++ nixos/tests/grub/graphical.nix | 57 ++++++++++++++++++ nixos/tests/grub/hashed-password.nix | 77 ++++++++++++++++++++++++ nixos/tests/grub/mirrored-boots.nix | 61 +++++++++++++++++++ 6 files changed, 247 insertions(+), 1 deletion(-) rename nixos/tests/{grub.nix => grub/basic.nix} (100%) create mode 100644 nixos/tests/grub/efi.nix create mode 100644 nixos/tests/grub/graphical.nix create mode 100644 nixos/tests/grub/hashed-password.nix create mode 100644 nixos/tests/grub/mirrored-boots.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 029a2543ec93..21dad5f3ced8 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -726,7 +726,13 @@ in greetd-no-shadow = runTest ./greetd-no-shadow.nix; grocy = runTest ./grocy.nix; grow-partition = runTest ./grow-partition.nix; - grub = runTest ./grub.nix; + grub = { + basic = runTest ./grub/basic.nix; + efi = runTest ./grub/efi.nix; + graphical = runTest ./grub/graphical.nix; + hashed-password = runTest ./grub/hashed-password.nix; + mirrored-boots = runTest ./grub/mirrored-boots.nix; + }; gs1200-exporter = runTest ./gs1200-exporter.nix; guacamole-server = runTest ./guacamole-server.nix; guix = handleTest ./guix { }; diff --git a/nixos/tests/grub.nix b/nixos/tests/grub/basic.nix similarity index 100% rename from nixos/tests/grub.nix rename to nixos/tests/grub/basic.nix diff --git a/nixos/tests/grub/efi.nix b/nixos/tests/grub/efi.nix new file mode 100644 index 000000000000..953548bb24bb --- /dev/null +++ b/nixos/tests/grub/efi.nix @@ -0,0 +1,45 @@ +{ lib, ... }: +{ + name = "grub-efi"; + + meta = with lib.maintainers; { + maintainers = [ + tomfitzhenry + rnhmjoj + ]; + }; + + nodes.machine = + { ... }: + { + virtualisation.useBootLoader = true; + virtualisation.useEFIBoot = true; + + boot.loader.grub = { + enable = true; + efiSupport = true; + device = "nodev"; + + # Read GRUB from the serial console so its output can be matched + # deterministically with wait_for_console_text, rather than via OCR. + extraConfig = "serial; terminal_output serial"; + }; + boot.loader.efi.canTouchEfiVariables = true; + }; + + testScript = '' + machine.start() + + with subtest("Enters GRUB"): + machine.wait_for_console_text("GNU GRUB") + + with subtest("Loads kernel"): + machine.wait_for_console_text("Linux version") + + with subtest("Reaches multi-user target"): + machine.wait_for_unit("multi-user.target") + + with subtest("Boots via UEFI"): + machine.succeed("test -d /sys/firmware/efi") + ''; +} diff --git a/nixos/tests/grub/graphical.nix b/nixos/tests/grub/graphical.nix new file mode 100644 index 000000000000..54d25ce3d843 --- /dev/null +++ b/nixos/tests/grub/graphical.nix @@ -0,0 +1,57 @@ +{ lib, ... }: +{ + name = "grub-graphical"; + + meta = with lib.maintainers; { + maintainers = [ + tomfitzhenry + rnhmjoj + ]; + }; + + nodes.machine = + { pkgs, ... }: + let + # GRUB only draws a background image when it is in graphical gfxterm + # mode. Bake a marker string into the splash so the OCR check below can + # only succeed when gfxterm actually rendered it; a silent fallback to + # text mode would show the plain menu without this image (and its text). + # + # The image must be an 8-bit sRGB PNG, otherwise GRUB's png module fails + # to load it and silently falls back to its default (non-graphical) menu. + splash = pkgs.runCommand "grub-gfxterm-splash.png" { nativeBuildInputs = [ pkgs.imagemagick ]; } '' + magick -size 1024x768 xc:'#2d2d2d' \ + -font ${pkgs.dejavu_fonts.minimal}/share/fonts/truetype/DejaVuSans.ttf \ + -gravity south -pointsize 48 -fill white \ + -annotate +0+150 'GFXTERMOK' \ + -depth 8 -type TrueColor PNG24:$out + ''; + in + { + virtualisation.useBootLoader = true; + + # Leave the menu up long enough for OCR to catch it; we boot early with a + # keypress once it has, so this does not slow the test down. + boot.loader.timeout = 30; + boot.loader.grub = { + enable = true; + splashImage = splash; + }; + }; + + enableOCR = true; + + testScript = '' + machine.start() + + with subtest("GRUB renders its menu graphically (gfxterm), showing the splash"): + # The marker text lives inside the background image, so reading it back + # proves GRUB displayed the image rather than falling back to text mode. + machine.wait_for_text("GFXTERMOK") + machine.screenshot("grub_gfxterm") + + with subtest("Machine boots into NixOS from GRUB"): + machine.send_key("ret") + machine.wait_for_unit("multi-user.target") + ''; +} diff --git a/nixos/tests/grub/hashed-password.nix b/nixos/tests/grub/hashed-password.nix new file mode 100644 index 000000000000..191468f8b69a --- /dev/null +++ b/nixos/tests/grub/hashed-password.nix @@ -0,0 +1,77 @@ +{ lib, ... }: +{ + name = "grub-hashed-password"; + + meta = with lib.maintainers; { + maintainers = [ + tomfitzhenry + rnhmjoj + ]; + }; + + nodes.machine = + { pkgs, ... }: + let + mkGrubPbkdf2HashFile = + password: + toString ( + pkgs.runCommandLocal "grub-pbkdf2-hash" { nativeBuildInputs = [ pkgs.grub2 ]; } '' + printf "%s\n%s\n" "${password}" "${password}" | grub-mkpasswd-pbkdf2 | grep -o 'grub\.pbkdf2\.[^[:space:]]*' > $out + '' + ); + in + { + virtualisation.useBootLoader = true; + + boot.loader.timeout = null; + boot.loader.grub = { + enable = true; + users.bob.hashedPasswordFile = mkGrubPbkdf2HashFile "bobsecret"; + + # Read GRUB from the serial console so its output can be matched + # deterministically; OCR would work but is flakier, which matters for + # the multi-step interactive login exercised below. + extraConfig = "serial; terminal_output serial"; + }; + }; + + testScript = '' + def grub_login_as(user, password): + """ + Enters user and password to log into GRUB + """ + machine.wait_for_console_text("Enter username:") + machine.send_chars(user + "\n") + machine.wait_for_console_text("Enter password:") + machine.send_chars(password + "\n") + + + def grub_select_all_configurations(): + """ + Selects "All configurations" from the GRUB menu + to trigger a login request. + """ + machine.send_monitor_command("sendkey down") + machine.send_monitor_command("sendkey ret") + + + machine.start() + + # wait for grub screen + machine.wait_for_console_text("GNU GRUB") + + grub_select_all_configurations() + with subtest("Invalid hashedPassword credentials are rejected"): + grub_login_as("bob", "wrongsecret") + machine.wait_for_console_text("access denied") + + grub_select_all_configurations() + with subtest("Valid hashedPassword credentials are accepted"): + grub_login_as("bob", "bobsecret") + machine.send_chars("\n") # press enter to boot + machine.wait_for_console_text("Linux version") + + with subtest("Machine boots correctly"): + machine.wait_for_unit("multi-user.target") + ''; +} diff --git a/nixos/tests/grub/mirrored-boots.nix b/nixos/tests/grub/mirrored-boots.nix new file mode 100644 index 000000000000..f3059bf675bc --- /dev/null +++ b/nixos/tests/grub/mirrored-boots.nix @@ -0,0 +1,61 @@ +{ lib, ... }: +{ + name = "grub-mirrored-boots"; + + meta = with lib.maintainers; { + maintainers = [ + tomfitzhenry + rnhmjoj + ]; + }; + + nodes.machine = + { lib, ... }: + { + virtualisation.useBootLoader = true; + + boot.loader.timeout = null; + boot.loader.grub = { + enable = true; + device = lib.mkOverride 0 ""; + mirroredBoots = [ + { + path = "/boot1"; + devices = [ "/dev/vda" ]; + } + { + path = "/boot2"; + devices = [ "nodev" ]; + } + ]; + + # Read GRUB from the serial console so its output can be matched + # deterministically with wait_for_console_text, rather than via OCR. + extraConfig = "serial; terminal_output serial"; + }; + }; + + testScript = '' + machine.start() + + # wait for grub screen + machine.wait_for_console_text("GNU GRUB") + + machine.send_chars("\n") # press enter to boot default option + + with subtest("Machine boots correctly"): + machine.wait_for_unit("multi-user.target") + + with subtest("Verify boot path 1 GRUB installation and configuration"): + machine.succeed("test -d /boot1/grub") + machine.succeed("test -f /boot1/grub/grub.cfg") + machine.succeed("test -f /boot1/grub/state") + machine.succeed("grep -q 'menuentry' /boot1/grub/grub.cfg") + + with subtest("Verify boot path 2 GRUB installation and configuration"): + machine.succeed("test -d /boot2/grub") + machine.succeed("test -f /boot2/grub/grub.cfg") + machine.succeed("test -f /boot2/grub/state") + machine.succeed("grep -q 'menuentry' /boot2/grub/grub.cfg") + ''; +} From 36a72f6f004f66a31d2b7ce38eae7ea130339d6e Mon Sep 17 00:00:00 2001 From: Tom Fitzhenry Date: Wed, 22 Jul 2026 11:34:12 +0000 Subject: [PATCH 2/2] nixos/tests/installer: add grubCryptodiskLegacyBios and unbreak fullDiskEncryption grubCryptodiskLegacyBios covers boot.loader.grub.enableCryptodisk on legacy BIOS: GRUB itself decrypts /boot, entering the LUKS passphrase interactively, and stage 1 then unlocks the root device with a keyfile embedded via boot.initrd.secrets. Using a keyfile avoids a second, interactive passphrase prompt in the initrd, whose wording differs between the scripted and systemd stage 1 implementations, so the test passes under both (installer and installer-systemd-stage-1). fullDiskEncryption was marked broken. On the target boot GRUB's EFI keyboard input appears to drop characters when the passphrase is typed at the default speed, producing an "Invalid passphrase" error, so the disk never unlocked. Type the passphrase more slowly and unbreak the test. Assisted-by: Claude Opus 4.8 --- nixos/tests/installer.nix | 48 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/nixos/tests/installer.nix b/nixos/tests/installer.nix index 930d3afc203d..d90fbafd93e8 100644 --- a/nixos/tests/installer.nix +++ b/nixos/tests/installer.nix @@ -1467,7 +1467,6 @@ in # Full disk encryption (root, kernel and initrd encrypted) using GRUB, GPT/UEFI, # LVM-on-LUKS and a keyfile in initrd.secrets to enter the passphrase once fullDiskEncryption = makeInstallerTest "fullDiskEncryption" { - broken = true; createPartitions = '' installer.succeed( "flock /dev/vda parted --script /dev/vda -- mklabel gpt" @@ -1508,6 +1507,53 @@ in ''; enableOCR = true; postBootCommands = '' + target.wait_for_text("Enter passphrase for") + # GRUB's EFI keyboard input appears to drop characters when typed at the + # default speed (producing an "Invalid passphrase" error), so type slowly. + target.send_chars("supersecret\n", 0.2) + ''; + }; + + # Root, kernel and initrd encrypted using GRUB cryptodisk, MBR/legacy BIOS, + # plain LUKS and a keyfile in initrd.secrets to enter the passphrase once + grubCryptodiskLegacyBios = makeInstallerTest "grubCryptodiskLegacyBios" { + meta.maintainers = [ maintainers.tomfitzhenry ]; + createPartitions = '' + installer.succeed( + "flock /dev/vda parted --script /dev/vda -- mklabel msdos mkpart primary ext4 1MiB -1GiB mkpart primary linux-swap -1GiB 100%", + "udevadm settle", + "echo -n supersecret | cryptsetup luksFormat -q --pbkdf-force-iterations 1000 --type luks1 /dev/vda1 -", + "echo -n supersecret | cryptsetup luksOpen /dev/vda1 cryptroot", + "mkfs.ext4 -L nixos /dev/mapper/cryptroot", + "mkswap -L swap /dev/vda2", + "swapon -L swap", + "mount LABEL=nixos /mnt", + "mkdir -p /mnt/etc/nixos", + # Add a keyfile so stage 1 can unlock the root device without a second, + # interactive prompt. This keeps the test independent of the stage 1 + # implementation, which matters because the scripted and systemd + # initrds word their passphrase prompts differently. Only GRUB (which + # is stage 1 independent) then prompts interactively. + "dd if=/dev/urandom of=/mnt/etc/nixos/luks.key bs=256 count=1", + "echo -n supersecret | cryptsetup luksAddKey -q --pbkdf-force-iterations 1000 --key-file - /dev/vda1 /mnt/etc/nixos/luks.key", + ) + ''; + bootLoader = "grub"; + # GRUB draws its cryptodisk passphrase prompt on the console terminal + # without a trailing newline, so the line-based wait_for_console_text can + # never observe it. Use OCR (wait_for_text) to read it off the screen. + enableOCR = true; + extraConfig = '' + boot.loader.grub.enableCryptodisk = true; + boot.initrd.secrets."/luks.key" = "/etc/nixos/luks.key"; + boot.initrd.luks.devices.cryptroot = { + device = lib.mkForce "/dev/vda1"; + keyFile = "/luks.key"; + }; + ''; + postBootCommands = '' + # GRUB has to unlock the disk to read /boot before it can boot the kernel; + # stage 1 then unlocks the root device with the embedded keyfile. target.wait_for_text("Enter passphrase for") target.send_chars("supersecret\n") '';