#!/usr/bin/env bats # Unit tests for the installer/archangel monolith. # # Coverage scope: gather_input() in unattended mode — defaulting of # optional values, preservation of explicit ones, and the # filesystem-specific encryption checks. Required-field, disk, and # timezone validation moved to validate_config (called from main # before gather_input); its coverage lives in test_config.bats. # The interactive branch (everything reachable via # `if [[ "$UNATTENDED" != true ]]`) is not unit-tested per the # project's testing-strategy.org policy on fzf / arch-chroot / # mkfs / cryptsetup wrappers. # # Sourcing archangel relies on the source-guard at the bottom of # the script: when sourced, function definitions load but main is # not called, init_logging is not run (so /tmp/archangel-*.log is # not created), and the banner is not printed. setup() { # shellcheck disable=SC1091 source "${BATS_TEST_DIRNAME}/../../installer/archangel" UNATTENDED=true # Tests that call install_failure_cleanup in this process would # otherwise run the real disarm_failure_trap, which clears bats' own # EXIT trap, and bats reports a failing assertion from that trap: the # failure would vanish instead of printing "not ok". The trap arming # itself is tested in a child bash, which sources the real functions. disarm_failure_trap() { :; } # Point the LUKS-mapping check at an empty directory, so a machine with # a real /dev/mapper/cryptroot can't leak into the cleanup tests. MAPPER_DIR="$BATS_TEST_TMPDIR/mapper" mkdir -p "$MAPPER_DIR" } ############################# # Optional-field defaults ############################# # Default values themselves are pinned in test_config.bats (config.sh # is the single source of truth). The remaining test here covers the # adjacent guarantee: gather_input doesn't clobber values the user set. @test "gather_input unattended preserves explicit non-default values" { HOSTNAME=h TIMEZONE=UTC ROOT_PASSWORD=x SELECTED_DISKS=(/dev/sda) FILESYSTEM=btrfs NO_ENCRYPT=yes LOCALE="en_GB.UTF-8" KEYMAP="dvorak" ENABLE_SSH="no" gather_input >/dev/null [ "$FILESYSTEM" = "btrfs" ] [ "$LOCALE" = "en_GB.UTF-8" ] [ "$KEYMAP" = "dvorak" ] [ "$ENABLE_SSH" = "no" ] } ############################# # Filesystem-specific encryption validation ############################# @test "gather_input unattended errors when ZFS without ZFS_PASSPHRASE and encryption on" { HOSTNAME=h TIMEZONE=UTC ROOT_PASSWORD=x SELECTED_DISKS=(/dev/sda) FILESYSTEM=zfs NO_ENCRYPT=no ZFS_PASSPHRASE="" run gather_input [ "$status" -eq 1 ] [[ "$output" == *"ZFS_PASSPHRASE"* ]] } @test "gather_input unattended errors when Btrfs without LUKS_PASSPHRASE and encryption on" { HOSTNAME=h TIMEZONE=UTC ROOT_PASSWORD=x SELECTED_DISKS=(/dev/sda) FILESYSTEM=btrfs NO_ENCRYPT=no LUKS_PASSPHRASE="" run gather_input [ "$status" -eq 1 ] [[ "$output" == *"LUKS_PASSPHRASE"* ]] } @test "gather_input unattended accepts ZFS with NO_ENCRYPT=yes and no passphrase" { HOSTNAME=h TIMEZONE=UTC ROOT_PASSWORD=x SELECTED_DISKS=(/dev/sda) FILESYSTEM=zfs NO_ENCRYPT=yes ZFS_PASSPHRASE="" run gather_input [ "$status" -eq 0 ] } ############################# # Filesystem validity ############################# # Validation moved to validate_filesystem in lib/config.sh — covered # by test_config.bats. main() calls it between check_config and # gather_input so a bad FILESYSTEM= never reaches install time. ############################# # RAID-level defaulting ############################# @test "gather_input unattended defaults RAID_LEVEL to mirror for multi-disk install" { HOSTNAME=h TIMEZONE=UTC ROOT_PASSWORD=x SELECTED_DISKS=(/dev/sda /dev/sdb) FILESYSTEM=zfs NO_ENCRYPT=yes RAID_LEVEL="" gather_input >/dev/null [ "$RAID_LEVEL" = "mirror" ] } @test "gather_input unattended preserves an explicit RAID_LEVEL on multi-disk install" { HOSTNAME=h TIMEZONE=UTC ROOT_PASSWORD=x SELECTED_DISKS=(/dev/sda /dev/sdb /dev/sdc) FILESYSTEM=zfs NO_ENCRYPT=yes RAID_LEVEL="raidz1" gather_input >/dev/null [ "$RAID_LEVEL" = "raidz1" ] } @test "gather_input unattended leaves RAID_LEVEL empty for single-disk install" { HOSTNAME=h TIMEZONE=UTC ROOT_PASSWORD=x SELECTED_DISKS=(/dev/sda) FILESYSTEM=zfs NO_ENCRYPT=yes RAID_LEVEL="" gather_input >/dev/null [ -z "$RAID_LEVEL" ] } ############################# # Failure trap arming ############################# # arm_failure_trap / disarm_failure_trap own the trap set that routes a # mid-install failure to install_failure_cleanup. These run in a child # bash (via `run bash -c`) because trap inheritance is a property of # the live shell. Before the EXIT trap, a failure inside any install step # ended the script through errexit without the cleanup running, and an # `exit 1` from error() reached no ERR trap at all. Both were how the # 2026-08-06 and 2026-09-12 retries found a still-mounted disk. @test "arm_failure_trap fires the cleanup for a command failing inside a function" { run bash -c 'source "$1" install_failure_cleanup() { disarm_failure_trap; echo CLEANUP-RAN; exit 7; } arm_failure_trap f() { false; } f echo NOT-REACHED' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" [ "$status" -eq 7 ] [[ "$output" == *"CLEANUP-RAN"* ]] [[ "$output" != *"NOT-REACHED"* ]] } @test "arm_failure_trap fires the cleanup when error() exits from inside a function" { run bash -c 'source "$1" install_failure_cleanup() { disarm_failure_trap; echo CLEANUP-RAN; exit 7; } arm_failure_trap f() { false || error "pacstrap failed"; } f echo NOT-REACHED' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" [ "$status" -eq 7 ] [[ "$output" == *"CLEANUP-RAN"* ]] [[ "$output" != *"NOT-REACHED"* ]] } @test "arm_failure_trap fires the cleanup for a failing pipeline inside a function" { run bash -c 'source "$1" install_failure_cleanup() { disarm_failure_trap; echo CLEANUP-RAN; exit 7; } arm_failure_trap f() { printf "\n" | false; } f echo NOT-REACHED' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" [ "$status" -eq 7 ] [[ "$output" == *"CLEANUP-RAN"* ]] [[ "$output" != *"NOT-REACHED"* ]] } @test "disarm_failure_trap lets the success path exit without running the cleanup" { run bash -c 'source "$1" install_failure_cleanup() { disarm_failure_trap; echo CLEANUP-RAN; exit 7; } arm_failure_trap f() { true; } f disarm_failure_trap echo SUCCESS' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" [ "$status" -eq 0 ] [[ "$output" == *"SUCCESS"* ]] [[ "$output" != *"CLEANUP-RAN"* ]] } @test "arm_failure_trap fires the cleanup when the install is sent TERM" { run bash -c 'source "$1" install_failure_cleanup() { disarm_failure_trap; echo CLEANUP-RAN; exit 7; } arm_failure_trap f() { kill -TERM $$; sleep 2; echo NOT-REACHED; } f' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" [ "$status" -eq 7 ] [[ "$output" == *"CLEANUP-RAN"* ]] [[ "$output" != *"NOT-REACHED"* ]] } # A failing command substitution whose status is masked (an argument to # echo, a `local` declaration, a `|| true`) is not an install failure. With # errtrace on, bash ran the whole cleanup inside the substitution's # subshell, unmounting /mnt while the install carried on in the parent. @test "a masked failing command substitution does not run the cleanup" { local marker="$BATS_TEST_TMPDIR/cleanup-ran" run bash -c 'source "$1"; M="$2" install_failure_cleanup() { disarm_failure_trap; echo ran >> "$M"; exit 7; } arm_failure_trap f() { echo "$(false)" >/dev/null; local y; y=$(false) || true; echo STEP-DONE; } f disarm_failure_trap echo SUCCESS' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" "$marker" [ "$status" -eq 0 ] [[ "$output" == *"STEP-DONE"* ]] [[ "$output" == *"SUCCESS"* ]] [ ! -e "$marker" ] } # A SIGTERM to the whole process group also reaches the tee init_logging # puts on stdout. With the tee gone, the cleanup's first warn raised # SIGPIPE and killed the shell before anything was unmounted: a Btrfs # install TERM'd mid-pacstrap in the VM on 2026-09-13 was left with /mnt # and the LUKS mapping still open. Ctrl-C's SIGINT leaves the tee alive # (bash starts a process substitution with SIGINT ignored), so the INT # test guards the ordinary Ctrl-C path rather than the SIGPIPE fix. # # The child starts through `env --default-signal=INT` because bats runs it # in the background, and a background job in a non-interactive shell # begins with SIGINT ignored, which bash then refuses to trap. Without the # reset, the INT case would pass by never delivering the signal at all. signal_group_with_logging_tee() { local sig="$1" d="$2" env --default-signal=INT setsid bash -c 'source "$1"; D="$2" FILESYSTEM=zfs; POOL_NAME=zroot umount() { echo "umount $*" >> "$D/calls"; } zpool() { echo "zpool $*" >> "$D/calls"; [[ "$1" == list ]]; } exec > >(tee -a "$D/tee.log") 2>&1 arm_failure_trap grep SigIgn /proc/$$/status > "$D/sigign" echo $$ > "$D/armed" sleep 30' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" "$d" /dev/null 2>&1 & local i pid="" for i in $(seq 1 100); do [ -s "$d/armed" ] && pid=$(cat "$d/armed") && break sleep 0.1 done [ -n "$pid" ] || return 1 kill "-$sig" -- "-$pid" for i in $(seq 1 100); do kill -0 "$pid" 2>/dev/null || break sleep 0.1 done kill -0 "$pid" 2>/dev/null && kill -KILL -- "-$pid" return 0 } @test "install_failure_cleanup finishes when SIGTERM to the group also killed its logging tee" { local d="$BATS_TEST_TMPDIR" signal_group_with_logging_tee TERM "$d" grep -qx "zpool export zroot" "$d/calls" } @test "install_failure_cleanup finishes when Ctrl-C's SIGINT reaches the whole group" { local d="$BATS_TEST_TMPDIR" signal_group_with_logging_tee INT "$d" # Guard against the vacuous pass: SIGINT (mask bit 0x2) must not be # ignored in the child, or the signal never arrived. local mask mask=$(awk '{print $2}' "$d/sigign") [ $(( 0x$mask & 0x2 )) -eq 0 ] grep -qx "zpool export zroot" "$d/calls" } # The busy retries can keep the cleanup silent for several seconds, and a # second Ctrl-C or SIGTERM in that window used to kill it halfway: the # export never finished and the pool stayed imported. @test "a second SIGTERM during the busy-retry window doesn't stop the cleanup" { local d="$BATS_TEST_TMPDIR" env --default-signal=INT setsid bash -c 'source "$1"; D="$2" FILESYSTEM=zfs; POOL_NAME=zroot; CLEANUP_BUSY_ATTEMPTS=6 umount() { :; } zpool() { [[ "$1" == list ]] && return 0 if [[ "$*" == "export zroot" ]]; then echo try >> "$D/exports" [ "$(wc -l < "$D/exports")" -ge 4 ] && { echo exported >> "$D/done"; return 0; } return 1 fi [[ "$*" == "export -f zroot" ]] && echo forced >> "$D/done" return 0 } arm_failure_trap echo $$ > "$D/armed" sleep 30' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" "$d" /dev/null 2>&1 & local i pid="" for i in $(seq 1 100); do [ -s "$d/armed" ] && pid=$(cat "$d/armed") && break sleep 0.1 done [ -n "$pid" ] kill -TERM -- "-$pid" for i in $(seq 1 50); do [ -s "$d/exports" ] && break sleep 0.1 done [ -s "$d/exports" ] kill -TERM -- "-$pid" 2>/dev/null || true for i in $(seq 1 100); do kill -0 "$pid" 2>/dev/null || break sleep 0.1 done kill -0 "$pid" 2>/dev/null && kill -KILL -- "-$pid" [ "$(cat "$d/done" 2>/dev/null)" = "exported" ] } @test "install_failure_cleanup runs exactly once when its own exit re-fires the trap" { run bash -c 'source "$1" FILESYSTEM=zfs; POOL_NAME=zroot umount() { :; } zpool() { return 1; } arm_failure_trap f() { false; } f' _ "${BATS_TEST_DIRNAME}/../../installer/archangel" [ "$status" -eq 1 ] [ "$(grep -c 'cleaning up' <<<"$output")" -eq 1 ] [[ "$output" == *"system cleaned up"* ]] } ############################# # install_failure_cleanup ############################# # install_failure_cleanup is the trap target for ERR / INT / TERM / EXIT # during install_zfs and install_btrfs. It clears sensitive vars, # dispatches on FILESYSTEM, and exits non-zero. Tests use function # overrides to capture which system tools the cleanup invokes; the # tools themselves (umount, zpool, btrfs_cleanup, # btrfs_close_encryption) are deliberately VM-tested per # testing-strategy.org. ############################# # retry_busy ############################# # Teardown after an interrupted pacstrap races pacman's children in the # chroot, which can still be exiting: the first export or LUKS close fails # busy and succeeds a moment later. retry_busy gives it a bounded window. @test "retry_busy runs a command that succeeds once, without sleeping" { CALLS=() sleep() { CALLS+=("sleep $*"); } ok() { CALLS+=("ok"); return 0; } retry_busy 5 ok [ "${#CALLS[@]}" -eq 1 ] [ "${CALLS[0]}" = "ok" ] } @test "retry_busy succeeds when the command first succeeds on the last attempt" { CALLS=(); TRIES=0 sleep() { CALLS+=("sleep $*"); } flaky() { TRIES=$((TRIES + 1)); [ "$TRIES" -ge 3 ]; } retry_busy 3 flaky [ "$TRIES" -eq 3 ] [ "${#CALLS[@]}" -eq 2 ] } @test "retry_busy returns 1 after the last attempt, sleeping only between attempts" { CALLS=(); TRIES=0 sleep() { CALLS+=("sleep $*"); } never() { TRIES=$((TRIES + 1)); return 1; } run retry_busy 4 never [ "$status" -eq 1 ] retry_busy 4 never || true [ "$TRIES" -eq 4 ] [ "${#CALLS[@]}" -eq 3 ] } # bats fails a test through errexit. The cleanup turns errexit off for # itself, and before `local -` scoped that, every assertion after a direct # call except the last one stopped being able to fail. @test "install_failure_cleanup leaves the caller's errexit on" { FILESYSTEM=zfs POOL_NAME=zroot umount() { :; } zpool() { return 1; } warn() { :; } error() { return 1; } install_failure_cleanup || true # Capture the flags, then restore errexit before asserting. If the # cleanup leaked errexit-off, a bare assertion here couldn't fail. local flags="$-" set -e [[ "$flags" == *e* ]] } @test "install_failure_cleanup ZFS path retries the export while the pool is still busy" { FILESYSTEM=zfs POOL_NAME=zroot CALLS=(); EXPORTS=0 umount() { :; } zpool() { CALLS+=("zpool $*") [[ "$1" == list ]] && return 0 if [[ "$*" == "export zroot" ]]; then EXPORTS=$((EXPORTS + 1)) [ "$EXPORTS" -ge 3 ] return fi return 0 } sleep() { CALLS+=("sleep $*"); } warn() { :; } error() { return 1; } install_failure_cleanup || true [ "$EXPORTS" -eq 3 ] [[ " ${CALLS[*]} " != *" zpool export -f zroot "* ]] } @test "install_failure_cleanup ZFS path forces the export once the busy window runs out" { FILESYSTEM=zfs POOL_NAME=zroot CLEANUP_BUSY_ATTEMPTS=4 CALLS=(); EXPORTS=0; SLEEPS=0 umount() { :; } zpool() { CALLS+=("zpool $*") [[ "$1" == list ]] && return 0 [[ "$*" == "export zroot" ]] && { EXPORTS=$((EXPORTS + 1)); return 1; } return 0 } sleep() { SLEEPS=$((SLEEPS + 1)); } warn() { :; } error() { return 1; } install_failure_cleanup || true [ "$EXPORTS" -eq 4 ] [ "$SLEEPS" -eq 3 ] [[ " ${CALLS[*]} " == *" zpool export -f zroot "* ]] } @test "install_failure_cleanup Btrfs path retries closing LUKS until the mapping is gone" { FILESYSTEM=btrfs CLOSES=0 umount() { :; } btrfs_cleanup() { :; } btrfs_close_encryption() { CLOSES=$((CLOSES + 1)); } luks_mappings_open() { [ "$CLOSES" -lt 3 ]; } sleep() { :; } warn() { :; } error() { return 1; } install_failure_cleanup || true [ "$CLOSES" -eq 3 ] } @test "install_failure_cleanup Btrfs path stops retrying the LUKS close after the busy window" { FILESYSTEM=btrfs CLEANUP_BUSY_ATTEMPTS=4 CLOSES=0 umount() { :; } btrfs_cleanup() { :; } btrfs_close_encryption() { CLOSES=$((CLOSES + 1)); } luks_mappings_open() { return 0; } sleep() { :; } warn() { :; } error() { return 1; } install_failure_cleanup || true [ "$CLOSES" -eq 4 ] } @test "install_failure_cleanup clears sensitive variables before exiting" { FILESYSTEM=zfs POOL_NAME=zroot ROOT_PASSWORD="topsecret" ZFS_PASSPHRASE="anothersecret" LUKS_PASSPHRASE="thirdsecret" # Mocks: silent no-ops for system tools; error returns non-zero # so the function returns instead of exiting the test process. umount() { :; } zpool() { return 1; } btrfs_cleanup() { :; } btrfs_close_encryption() { :; } warn() { :; } error() { return 1; } install_failure_cleanup || true [ -z "$ROOT_PASSWORD" ] [ -z "$ZFS_PASSPHRASE" ] [ -z "$LUKS_PASSPHRASE" ] } @test "install_failure_cleanup dispatches to ZFS path when FILESYSTEM=zfs" { FILESYSTEM=zfs POOL_NAME=zroot CALLS=() # Mocks track invocations via CALLS array. Array assignment is not # affected by the production code's >/dev/null 2>&1 redirects on # the zpool list check, so we capture the call regardless of where # the mock's stdout would have gone. umount() { CALLS+=("umount $*"); return 0; } zpool() { CALLS+=("zpool $*") [[ "$1" == "list" ]] && return 0 return 0 } btrfs_cleanup() { CALLS+=("btrfs_cleanup"); } btrfs_close_encryption() { CALLS+=("btrfs_close_encryption"); } warn() { :; } error() { CALLS+=("error"); return 1; } install_failure_cleanup || true [[ " ${CALLS[*]} " == *" umount /mnt/efi "* ]] [[ " ${CALLS[*]} " == *" umount -R /mnt "* ]] [[ " ${CALLS[*]} " == *" zpool list zroot "* ]] [[ " ${CALLS[*]} " == *" zpool export zroot "* ]] [[ " ${CALLS[*]} " != *" btrfs_cleanup "* ]] [[ " ${CALLS[*]} " != *" btrfs_close_encryption "* ]] } @test "install_failure_cleanup dispatches to Btrfs path when FILESYSTEM=btrfs" { FILESYSTEM=btrfs CALLS=() umount() { CALLS+=("umount $*"); return 0; } zpool() { CALLS+=("zpool $*"); return 0; } btrfs_cleanup() { CALLS+=("btrfs_cleanup"); } btrfs_close_encryption() { CALLS+=("btrfs_close_encryption"); } warn() { :; } error() { CALLS+=("error"); return 1; } install_failure_cleanup || true [[ " ${CALLS[*]} " == *" umount /mnt/efi "* ]] [[ " ${CALLS[*]} " == *" btrfs_cleanup "* ]] [[ " ${CALLS[*]} " == *" btrfs_close_encryption "* ]] [[ " ${CALLS[*]} " != *" zpool"* ]] } # btrfs_cleanup unmounts only the subvolumes it mounted, one level at a # time. A pacstrap interrupted mid-transaction can leave its /proc, /sys # and /dev bind mounts under /mnt, which keep the root busy, so the LUKS # mapping can't close and the retry's disk_in_use still sees a mountpoint. @test "install_failure_cleanup Btrfs path unmounts the target root recursively before closing LUKS" { FILESYSTEM=btrfs CALLS=() umount() { CALLS+=("umount $*"); return 0; } zpool() { CALLS+=("zpool $*"); return 0; } btrfs_cleanup() { CALLS+=("btrfs_cleanup"); } btrfs_close_encryption() { CALLS+=("btrfs_close_encryption"); } warn() { :; } error() { CALLS+=("error"); return 1; } install_failure_cleanup || true local i recursive=-1 close=-1 for i in "${!CALLS[@]}"; do [[ "${CALLS[$i]}" == "umount -R /mnt" ]] && recursive=$i [[ "${CALLS[$i]}" == "btrfs_close_encryption" ]] && close=$i done [ "$recursive" -ge 0 ] [ "$close" -gt "$recursive" ] } @test "install_failure_cleanup Btrfs path falls back to a lazy recursive unmount when the root is busy" { FILESYSTEM=btrfs CALLS=() umount() { CALLS+=("umount $*") [[ "$*" == "-R /mnt" ]] && return 32 return 0 } btrfs_cleanup() { CALLS+=("btrfs_cleanup"); } btrfs_close_encryption() { CALLS+=("btrfs_close_encryption"); } warn() { :; } error() { CALLS+=("error"); return 1; } install_failure_cleanup || true [[ " ${CALLS[*]} " == *" umount -R -l /mnt "* ]] [[ " ${CALLS[*]} " == *" btrfs_close_encryption "* ]] } @test "install_failure_cleanup ZFS path skips zpool export when pool not imported" { FILESYSTEM=zfs POOL_NAME=zroot CALLS=() umount() { CALLS+=("umount $*"); return 0; } zpool() { CALLS+=("zpool $*") [[ "$1" == "list" ]] && return 1 # pool NOT imported return 0 } btrfs_cleanup() { :; } btrfs_close_encryption() { :; } warn() { :; } error() { return 1; } install_failure_cleanup || true [[ " ${CALLS[*]} " == *" zpool list zroot "* ]] [[ " ${CALLS[*]} " != *" zpool export"* ]] } @test "install_failure_cleanup ZFS path falls back to lazy unmount when a mount is busy" { FILESYSTEM=zfs POOL_NAME=zroot CALLS=() # A pacstrap-interrupted target can leave busy mounts that a plain # umount can't release; cleanup must retry lazily so the retry sees a # clean disk. Non-lazy umount fails here; the -l fallback succeeds. umount() { CALLS+=("umount $*") [[ "$*" == *"-l"* ]] && return 0 return 1 } zpool() { CALLS+=("zpool $*"); return 0; } warn() { :; } error() { return 1; } install_failure_cleanup || true [[ " ${CALLS[*]} " == *" umount -l /mnt/efi "* ]] [[ " ${CALLS[*]} " == *" umount -R -l /mnt "* ]] # The pool still gets exported after the lazy unmount. [[ " ${CALLS[*]} " == *" zpool export zroot "* ]] } @test "install_failure_cleanup Btrfs path falls back to lazy unmount when EFI is busy" { FILESYSTEM=btrfs CALLS=() umount() { CALLS+=("umount $*") [[ "$*" == *"-l"* ]] && return 0 return 1 } btrfs_cleanup() { CALLS+=("btrfs_cleanup"); } btrfs_close_encryption() { CALLS+=("btrfs_close_encryption"); } warn() { :; } error() { return 1; } install_failure_cleanup || true [[ " ${CALLS[*]} " == *" umount -l /mnt/efi "* ]] } ############################# # validate_environment ############################# # Boundary wrappers (is_uefi_boot, required_commands) are stubbed so the # composition's fail-fast wiring is exercised without depending on the # host's firmware mode or installed tools. The real command list lives in # test_common.bats; the real UEFI/network probes run in the VM harness. @test "validate_environment errors when not booted in UEFI mode" { is_uefi_boot() { return 1; } required_commands() { return 0; } FILESYSTEM=zfs run validate_environment [ "$status" -eq 1 ] [[ "$output" == *"UEFI"* ]] } @test "validate_environment errors when a required command is missing" { is_uefi_boot() { return 0; } required_commands() { echo "definitely-not-a-real-cmd-xyz"; } FILESYSTEM=zfs run validate_environment [ "$status" -eq 1 ] [[ "$output" == *"definitely-not-a-real-cmd-xyz"* ]] } @test "validate_environment passes when UEFI present and commands resolve" { is_uefi_boot() { return 0; } required_commands() { echo "bash"; } FILESYSTEM=zfs run validate_environment [ "$status" -eq 0 ] } ############################# # validate_install_targets ############################# # disk_in_use / disk_size_bytes / network_available are the system-boundary # wrappers; stubbing them drives the real composition + real # disk_meets_min_size. Live probes run in the VM harness on the happy path. @test "validate_install_targets errors when a disk is in use" { SELECTED_DISKS=(/dev/sda) disk_in_use() { return 0; } disk_size_bytes() { echo 500107862016; } network_available() { return 0; } run validate_install_targets [ "$status" -eq 1 ] [[ "$output" == *"in use"* ]] } @test "validate_install_targets errors when a disk is too small" { SELECTED_DISKS=(/dev/sda) disk_in_use() { return 1; } disk_size_bytes() { echo 1000000; } network_available() { return 0; } run validate_install_targets [ "$status" -eq 1 ] [[ "$output" == *"too small"* ]] } @test "validate_install_targets errors when disk size is unreadable" { SELECTED_DISKS=(/dev/sda) disk_in_use() { return 1; } disk_size_bytes() { echo ""; } network_available() { return 0; } run validate_install_targets [ "$status" -eq 1 ] } @test "validate_install_targets errors when the network is unreachable" { SELECTED_DISKS=(/dev/sda) disk_in_use() { return 1; } disk_size_bytes() { echo 500107862016; } network_available() { return 1; } run validate_install_targets [ "$status" -eq 1 ] [[ "$output" == *"network"* || "$output" == *"connectivity"* ]] } @test "validate_install_targets passes when disks idle, large enough, network up" { SELECTED_DISKS=(/dev/sda /dev/sdb) disk_in_use() { return 1; } disk_size_bytes() { echo 500107862016; } network_available() { return 0; } run validate_install_targets [ "$status" -eq 0 ] } ############################# # network_available ############################# # The two probes it composes — DNS via getent and a TCP-443 open via # timeout+bash /dev/tcp — are the system boundary; mocking them drives the # fail-fast wiring without a live network. The real probe runs in the VM # harness. These pin the "network failure before pacstrap" error path that # validate_install_targets surfaces. @test "network_available returns 1 when DNS resolution fails" { getent() { return 1; } run network_available [ "$status" -eq 1 ] } @test "network_available returns 1 when DNS resolves but the TCP connect fails" { getent() { return 0; } timeout() { return 1; } run network_available [ "$status" -eq 1 ] } @test "network_available returns 0 when DNS resolves and the TCP connect opens" { getent() { return 0; } timeout() { return 0; } run network_available [ "$status" -eq 0 ] } ############################# # configure_zfs_keyfile ############################# # Encrypted ZFS installs prompt for the same passphrase twice: # ZFSBootMenu unlocks the pool to read the kernel and initramfs, then # kexecs, and the key doesn't survive kexec — so the booted initramfs # re-imports the pool, finds keylocation=prompt, and asks again. # # configure_zfs_keyfile closes the second prompt the same way the Btrfs # path already closes its LUKS equivalent: write the passphrase to a # keyfile inside the encrypted root, point the encryption root at it, # and bake it into the initramfs via FILES=. ZFSBootMenu can't read a # file inside a dataset it hasn't unlocked yet, so it still prompts # once — that surviving prompt is the intended behavior, not a bug. # # zfs is the stubbed system boundary. The keyfile write, its # permissions, and the FILES= wiring are exercised for real. zfs_keyfile_fixture() { TEST_ROOT=$(mktemp -d) MNTPOINT="$TEST_ROOT" ZFS_ARGS_LOG="$TEST_ROOT/zfs-args" mkdir -p "$MNTPOINT/etc" printf '%s\n' 'FILES=()' > "$MNTPOINT/etc/mkinitcpio.conf" zfs() { echo "$*" >> "$ZFS_ARGS_LOG"; return 0; } } @test "configure_zfs_keyfile writes the passphrase with no trailing newline" { zfs_keyfile_fixture configure_zfs_keyfile "correct horse" zroot # The keyfile lands mode 000, which locks out the owner too — only root # bypasses that, and these tests don't run as root. Restore read access to # inspect the content; the mode itself is asserted separately below. chmod u+r "$MNTPOINT/etc/zfs/zroot.key" # A trailing newline would become part of the passphrase ZFS reads back, # so the key would never match what's typed at the ZBM prompt. 13 bytes, # not 14: no terminator. [ "$(wc -c < "$MNTPOINT/etc/zfs/zroot.key")" -eq 13 ] [ "$(cat "$MNTPOINT/etc/zfs/zroot.key")" = "correct horse" ] rm -rf "$TEST_ROOT" } @test "configure_zfs_keyfile points the encryption root at the keyfile" { zfs_keyfile_fixture configure_zfs_keyfile testpass zroot grep -qE '^set +keylocation=file:///etc/zfs/zroot\.key +zroot$' "$ZFS_ARGS_LOG" rm -rf "$TEST_ROOT" } @test "configure_zfs_keyfile changes the location without rekeying the pool" { zfs_keyfile_fixture configure_zfs_keyfile testpass zroot # keylocation is settable with plain `zfs set` (zfsprops(7)), and # keyformat is already passphrase from pool creation. Reaching for # `zfs change-key` here would rekey the pool and prompt for new key # material mid-install — and losing keyformat=passphrase would leave # ZFSBootMenu with no way to accept a typed passphrase at all. ! grep -qF 'change-key' "$ZFS_ARGS_LOG" rm -rf "$TEST_ROOT" } @test "configure_zfs_keyfile bakes the keyfile into the initramfs" { zfs_keyfile_fixture configure_zfs_keyfile testpass zroot grep -qF 'FILES=(/etc/zfs/zroot.key)' "$MNTPOINT/etc/mkinitcpio.conf" rm -rf "$TEST_ROOT" } @test "configure_zfs_keyfile leaves the keyfile unreadable to other users" { zfs_keyfile_fixture configure_zfs_keyfile testpass zroot # Protected at rest by the encrypted dataset, but a stray mode 644 # would expose it to any local user on the running system. [ "$(stat -c '%a' "$MNTPOINT/etc/zfs/zroot.key")" -eq 0 ] rm -rf "$TEST_ROOT" } @test "configure_zfs_keyfile preserves a passphrase containing shell metacharacters" { zfs_keyfile_fixture configure_zfs_keyfile 'a$b "c" \d*' zroot chmod u+r "$MNTPOINT/etc/zfs/zroot.key" [ "$(cat "$MNTPOINT/etc/zfs/zroot.key")" = 'a$b "c" \d*' ] rm -rf "$TEST_ROOT" } @test "configure_zfs_keyfile aborts when the key change fails" { zfs_keyfile_fixture zfs() { return 1; } run configure_zfs_keyfile testpass zroot # Silently continuing would ship an initramfs whose keyfile doesn't # match the pool, turning one prompt into an unbootable system. [ "$status" -eq 1 ] rm -rf "$TEST_ROOT" }