aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore20
-rw-r--r--README.org57
-rw-r--r--build-aur.sh15
-rwxr-xr-xbuild.sh35
-rwxr-xr-xinstaller/archangel62
-rw-r--r--installer/lib/btrfs.sh69
-rw-r--r--installer/lib/common.sh41
-rw-r--r--installer/lib/config.sh22
-rwxr-xr-xscripts/test-install.sh93
-rw-r--r--tests/unit/test_archangel.bats95
-rw-r--r--tests/unit/test_btrfs.bats35
-rw-r--r--tests/unit/test_build_aur.bats11
-rw-r--r--tests/unit/test_common.bats70
-rw-r--r--tests/unit/test_config.bats58
-rw-r--r--tests/unit/test_test_install.bats50
15 files changed, 622 insertions, 111 deletions
diff --git a/.gitignore b/.gitignore
index ed1cf19..4135ece 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,8 +15,11 @@ aur-packages.staging/
# Personal session/workflow docs (not project documentation)
.ai/
+.claude/
todo.org
inbox/
+CLAUDE.md
+AGENTS.md
# Host-specific installer configs (contain secrets); see *.conf.example templates
installer/velox-*.conf
@@ -25,3 +28,20 @@ installer/velox-*.conf
*~
\#*\#
.\#*
+
+# --- elisp ruleset ---
+githooks/
+*.elc
+*.eln
+
+# --- python ruleset ---
+__pycache__/
+*.pyc
+*.pyo
+.coverage
+coverage.json
+htmlcov/
+.pytest_cache/
+
+# Claude Code: task archive (follows todo file privacy)
+/archive/task-archive.org
diff --git a/README.org b/README.org
index 7eaca4f..0c3dabf 100644
--- a/README.org
+++ b/README.org
@@ -27,15 +27,24 @@ The ISO also doubles as a [[#rescue-disk][rescue disk]] with data recovery, boot
Archangel currently uses linux-lts for stability. Choosing linux and linux-zen kernel varieties coming shortly.
** Filesystem Comparison
+:PROPERTIES:
+:CUSTOM_ID: filesystem-comparison
+:END:
| Feature | ZFS | Btrfs |
|------------------+----------------------------+----------------------|
| Bootloader | ZFSBootMenu | GRUB + grub-btrfs |
+|------------------+----------------------------+----------------------|
| Encryption | Native ZFS encryption | LUKS2 |
+|------------------+----------------------------+----------------------|
| Snapshot utility | zfssnapshot | snapper |
+|------------------+----------------------------+----------------------|
| Snapshot boot | Built into ZFSBootMenu | grub-btrfs menu |
+|------------------+----------------------------+----------------------|
| RAID support | mirror, raidz1/2/3, stripe | RAID0, RAID1, RAID10 |
+|------------------+----------------------------+----------------------|
| EFI size | 512MB | 1GB |
+|------------------+----------------------------+----------------------|
* Prerequisites
@@ -69,9 +78,9 @@ back to the upstream URLs.
them into the ISO as a local pacman repo at ~/usr/share/aur-packages~.
They work in the live environment (~pacman -Sl aur~) and install onto
the target offline, so the installer never needs the AUR or a build
-toolchain on the target. The v1 set (audited 2026-06-09):
+toolchain on the target. The v1 set (re-audited 2026-06-16):
~downgrade~, ~yay~, ~informant~, ~zrepl~, ~pacman-cleanup-hook~,
-~sanoid~, ~zfs-auto-snapshot~, ~topgrade~, ~ventoy-bin~.
+~zfs-auto-snapshot~, ~topgrade~, ~ventoy-bin~.
Building these needs ~base-devel~ and ~git~ on the build host, and the
build must run under ~sudo~ so it can drop to your user for ~makepkg~
@@ -155,6 +164,9 @@ make build
2. Boot from USB and wait for the live environment to load.
** SSH Access
+:PROPERTIES:
+:CUSTOM_ID: ssh-access
+:END:
The live environment automatically starts an SSH server, allowing remote access
for headless installations, rescue operations, or VM testing. Avahi is
@@ -172,6 +184,9 @@ The default root password is set in ~build.sh~ (variable ~LIVE_ROOT_PASSWORD~).
operations only. Do not expose the live environment to untrusted networks.
** Rescue Disk
+:PROPERTIES:
+:CUSTOM_ID: rescue-disk
+:END:
The ISO serves as a general-purpose rescue disk with a comprehensive set of
recovery and diagnostic tools pre-installed:
@@ -231,6 +246,9 @@ After configuration, the installation runs without intervention:
- Genesis snapshot creation
** Unattended Installation
+:PROPERTIES:
+:CUSTOM_ID: unattended-installation
+:END:
For automated or headless installations, you can specify a simple plain text config file containing the choices.
@@ -283,19 +301,30 @@ A complete example with all options is available at ~installer/archangel.conf.ex
*** Config File Reference
-| Field | Required | Default | Description |
-|----------------+----------+-------------+--------------------------------------------|
+| Field | Required | Default | Description |
+|------------------+----------+-------------+--------------------------------------------|
| ~FILESYSTEM~ | No | zfs | Filesystem type (zfs or btrfs) |
+|------------------+----------+-------------+--------------------------------------------|
| ~HOSTNAME~ | Yes | - | System hostname |
+|------------------+----------+-------------+--------------------------------------------|
| ~TIMEZONE~ | Yes | - | Timezone (Region/City format) |
+|------------------+----------+-------------+--------------------------------------------|
| ~DISKS~ | Yes | - | Comma-separated disk paths |
+|------------------+----------+-------------+--------------------------------------------|
| ~ZFS_PASSPHRASE~ | Yes* | - | Encryption passphrase (*if not NO_ENCRYPT) |
+|------------------+----------+-------------+--------------------------------------------|
| ~ROOT_PASSWORD~ | Yes | - | Root user password |
+|------------------+----------+-------------+--------------------------------------------|
| ~LOCALE~ | No | en_US.UTF-8 | System locale |
+|------------------+----------+-------------+--------------------------------------------|
| ~KEYMAP~ | No | us | Console keyboard layout |
+|------------------+----------+-------------+--------------------------------------------|
| ~RAID_LEVEL~ | No | mirror | RAID type for multi-disk |
+|------------------+----------+-------------+--------------------------------------------|
| ~ENABLE_SSH~ | No | yes | Enable SSH server |
+|------------------+----------+-------------+--------------------------------------------|
| ~NO_ENCRYPT~ | No | no | Skip encryption (testing only) |
+|------------------+----------+-------------+--------------------------------------------|
* Post-Reboot
@@ -310,6 +339,9 @@ A complete example with all options is available at ~installer/archangel.conf.ex
3. Log in as root
* Post-Installation
+:PROPERTIES:
+:CUSTOM_ID: post-installation
+:END:
** ZFS Snapshot Management
@@ -363,6 +395,9 @@ installation and can decline, or set ~ENABLE_SSH=no~ in a config file.
and consider installing ~fail2ban~.
* Testing
+:PROPERTIES:
+:CUSTOM_ID: testing
+:END:
Two test layers: ~make test~ runs shellcheck + bats unit tests for the pure
logic in ~installer/lib/*.sh~ (fast, no VMs). ~make test-install~ runs the full
@@ -425,11 +460,15 @@ archangel/
** Script Descriptions
-| Script | Description |
-|---------------------+--------------------------------------------------------------------------------------------------------------|
-| ~build.sh~ | Builds the ISO. Copies releng profile, adds packages, configures kernel, runs mkarchiso |
-| ~installer/archangel~ | Interactive installer. Handles disk partitioning, filesystem creation, base system install, bootloader setup |
-| ~scripts/test-vm.sh~ | Launches QEMU VM for testing. Supports single and multi-disk configurations |
+| Script | Description |
+|-----------------------+----------------------------------------------------------------------------------------------|
+| ~build.sh~ | Builds the ISO. Copies releng profile, adds packages, configures kernel, runs mkarchiso |
+|-----------------------+----------------------------------------------------------------------------------------------|
+| ~installer/archangel~ | Interactive installer. Handles disk partitioning, filesystem creation, base system install, |
+| | bootloader setup |
+|-----------------------+----------------------------------------------------------------------------------------------|
+| ~scripts/test-vm.sh~ | Launches QEMU VM for testing. Supports single and multi-disk configurations |
+|-----------------------+----------------------------------------------------------------------------------------------|
* Troubleshooting
diff --git a/build-aur.sh b/build-aur.sh
index 9b83a33..3bec75c 100644
--- a/build-aur.sh
+++ b/build-aur.sh
@@ -18,12 +18,14 @@
#############################
# The v1 genuine-AUR build set: packages with no exact official-repo match
-# whose runtime + make deps all resolve from official / archzfs / the baked
-# local repo (the v1 dependency gate). paru (second helper) and
-# mkinitcpio-firmware (pulls AUR firmware deps) are deferred to vNext.
-# Audited 2026-06-09. This list is the one place the set is named; build.sh
-# reads it for the package-list append, and the manifest records what
-# actually shipped.
+# whose runtime + make deps all resolve from the official repos / archzfs
+# (the v1 dependency gate). paru (second helper), mkinitcpio-firmware, and
+# sanoid are deferred to vNext: each pulls an AUR-of-AUR dependency that
+# makepkg -s can't resolve from the official repos (sanoid needs
+# perl-config-inifiles, which is AUR-only). Re-audited 2026-06-16 against the
+# official sync dbs after a real build caught sanoid's missing dep. This list
+# is the one place the set is named; build.sh reads it for the package-list
+# append, and the manifest records what actually shipped.
aur_v1_packages() {
printf '%s\n' \
downgrade \
@@ -31,7 +33,6 @@ aur_v1_packages() {
informant \
zrepl \
pacman-cleanup-hook \
- sanoid \
zfs-auto-snapshot \
topgrade \
ventoy-bin
diff --git a/build.sh b/build.sh
index 6dbdef0..566a2a7 100755
--- a/build.sh
+++ b/build.sh
@@ -447,33 +447,14 @@ EOF
info "Setting root password for live ISO..."
# Generate password hash
PASS_HASH=$(openssl passwd -6 "$LIVE_ROOT_PASSWORD")
-# Modify the existing shadow file's root entry (don't replace entire file)
-# The releng template has multiple accounts; replacing breaks the file
-if [[ -f "$PROFILE_DIR/airootfs/etc/shadow" ]]; then
- sed -i "s|^root:[^:]*:|root:${PASS_HASH}:|" "$PROFILE_DIR/airootfs/etc/shadow"
-else
- # Fallback: create complete shadow file if it doesn't exist
- cat > "$PROFILE_DIR/airootfs/etc/shadow" << EOF
-root:${PASS_HASH}:19000:0:99999:7:::
-bin:!*:19000::::::
-daemon:!*:19000::::::
-mail:!*:19000::::::
-ftp:!*:19000::::::
-http:!*:19000::::::
-nobody:!*:19000::::::
-dbus:!*:19000::::::
-systemd-coredump:!*:19000::::::
-systemd-network:!*:19000::::::
-systemd-oom:!*:19000::::::
-systemd-journal-remote:!*:19000::::::
-systemd-resolve:!*:19000::::::
-systemd-timesync:!*:19000::::::
-tss:!*:19000::::::
-uuidd:!*:19000::::::
-polkitd:!*:19000::::::
-avahi:!*:19000::::::
-EOF
-fi
+# Modify the existing shadow file's root entry (don't replace the whole
+# file — the releng template ships /etc/shadow with multiple accounts and
+# rewriting it from scratch would drop them). The profile is always copied
+# fresh from releng above, so the file is present; if it's missing, that
+# copy is broken — fail loudly rather than silently rebuilding a stale list.
+[[ -f "$PROFILE_DIR/airootfs/etc/shadow" ]] \
+ || error "Expected shadow file missing: $PROFILE_DIR/airootfs/etc/shadow (releng profile copy broken?)"
+sed -i "s|^root:[^:]*:|root:${PASS_HASH}:|" "$PROFILE_DIR/airootfs/etc/shadow"
chmod 400 "$PROFILE_DIR/airootfs/etc/shadow"
# Allow root SSH login with password (for testing)
diff --git a/installer/archangel b/installer/archangel
index ec3017a..b9817e8 100755
--- a/installer/archangel
+++ b/installer/archangel
@@ -791,9 +791,24 @@ EOF
info "Exposing baked AUR repo to pacstrap..."
append_aur_repo /etc/pacman.conf "file://$aur_repo_dir"
mapfile -t aur_packages < <(aur_manifest_names "$aur_repo_dir/manifest.tsv")
+ # Drop ZFS-only AUR tooling on a non-ZFS target. The baked repo carries
+ # the full set on every ISO, but e.g. zfs-auto-snapshot has a hard zfs
+ # dependency that can't resolve on a btrfs install and would abort the
+ # whole pacstrap transaction.
+ mapfile -t aur_packages < <(filter_aur_for_fs "$FILESYSTEM" "${aur_packages[@]}")
info "Baked AUR packages to install: ${aur_packages[*]:-none}"
fi
+ # Force-refresh package databases before pacstrap. The ISO bakes an
+ # archzfs sync db at build time, so as the ISO ages that db pins an older
+ # zfs-dkms while linux-lts is pulled current from the live mirror, and the
+ # DKMS build then fails against the newer kernel. A single -y can be
+ # skipped by pacman's freshness check against the GitHub-served archzfs db
+ # (no reliable timestamps), so -yy forces the refresh. This keeps the
+ # "zfs-dkms always matches the kernel" guarantee true regardless of ISO age.
+ info "Refreshing package databases..."
+ pacman -Syy --noconfirm || error "Failed to refresh package databases"
+
info "Installing base packages (this takes a while)..."
local packages
@@ -910,6 +925,47 @@ configure_ssh() {
fi
}
+# Close the second passphrase prompt on encrypted ZFS boots.
+#
+# ZFSBootMenu unlocks the pool to read this kernel and initramfs, then kexecs
+# into it — and the loaded key does not survive kexec. With no key inside the
+# initramfs, the zfs hook re-imports the pool, finds keylocation=prompt, and
+# asks for the same passphrase a second time.
+#
+# Pointing the encryption root at a keyfile that lives inside the encrypted
+# dataset closes it. ZFSBootMenu cannot read a file in a dataset it has not
+# unlocked, so it overrides the file:// URI and prompts once — documented
+# upstream behavior, not a side effect. The booted initramfs carries the
+# keyfile and loads the key silently.
+#
+# keyformat stays passphrase: it is what lets ZFSBootMenu accept the typed
+# value, and a raw key would leave it no way in at all. keylocation alone is
+# settable with `zfs set` (zfsprops(7)), so this never reaches for `zfs
+# change-key`, which would rekey the pool and prompt for new key material
+# mid-install.
+#
+# Never relocate this keyfile onto the ESP or into a custom ZFSBootMenu image.
+# Both are unencrypted; the protection here comes entirely from the keyfile and
+# the initramfs living inside the encrypted dataset.
+configure_zfs_keyfile() {
+ local passphrase="$1"
+ local pool="$2"
+ local keyfile="/etc/zfs/zroot.key"
+
+ mkdir -p "$MNTPOINT$(dirname "$keyfile")"
+
+ # No trailing newline: ZFS reads the file's bytes as the passphrase, so a
+ # stray newline would not match what the user types at the ZBM prompt.
+ printf '%s' "$passphrase" > "$MNTPOINT$keyfile"
+ chmod 000 "$MNTPOINT$keyfile"
+
+ zfs set keylocation="file://$keyfile" "$pool" \
+ || error "Failed to point $pool at $keyfile"
+
+ ensure_initramfs_files "$keyfile" "$MNTPOINT/etc/mkinitcpio.conf"
+ info "Keyfile embedded in initramfs - one passphrase prompt at boot."
+}
+
configure_initramfs() {
step "Configuring Initramfs for ZFS"
@@ -969,6 +1025,12 @@ EOF
# system. (Audited 2026-04-27 against silent-sed pattern.)
sed -i 's/^HOOKS=.*/HOOKS=(base udev microcode modconf kms keyboard keymap consolefont block zfs filesystems)/' $MNTPOINT/etc/mkinitcpio.conf
+ # Embed the pool key so the booted initramfs doesn't re-prompt. Must run
+ # before mkinitcpio -P below, which is what bakes FILES= into the image.
+ if [[ "$NO_ENCRYPT" != "yes" ]]; then
+ configure_zfs_keyfile "$ZFS_PASSPHRASE" "$POOL_NAME"
+ fi
+
# Get the installed kernel version (not the running kernel)
local kernel_ver
kernel_ver=$(ls $MNTPOINT/usr/lib/modules | grep lts | head -1)
diff --git a/installer/lib/btrfs.sh b/installer/lib/btrfs.sh
index 0a34be0..67c96a0 100644
--- a/installer/lib/btrfs.sh
+++ b/installer/lib/btrfs.sh
@@ -340,6 +340,36 @@ create_btrfs_subvolumes() {
# Btrfs Mount Functions
#############################
+# Compose the mount-option string for a single subvolume: the shared
+# BTRFS_OPTS prefixed with subvol=<name>, then the per-subvol extra
+# flags applied. compress=no and nodatacow both drop the default
+# compress=zstd; nodatacow also appends nodatacow; nosuid appends
+# nosuid,nodev. Pure string transform — no I/O. Shared by
+# mount_btrfs_subvolumes and generate_btrfs_fstab so the two stay in sync.
+# Usage: parse_btrfs_subvol_opts NAME EXTRA
+parse_btrfs_subvol_opts() {
+ local name="$1" extra="$2"
+ local opts="subvol=$name,$BTRFS_OPTS"
+
+ if [[ -n "$extra" ]]; then
+ # compress=no: drop the default compression, don't add anything
+ if [[ "$extra" == *"compress=no"* ]]; then
+ opts=$(echo "$opts" | sed 's/,compress=zstd//')
+ fi
+ # nodatacow implies no compression (incompatible), so drop it too
+ if [[ "$extra" == *"nodatacow"* ]]; then
+ opts="$opts,nodatacow"
+ opts=$(echo "$opts" | sed 's/,compress=zstd//')
+ fi
+ # nosuid,nodev hardening for tmp subvolumes
+ if [[ "$extra" == *"nosuid"* ]]; then
+ opts="$opts,nosuid,nodev"
+ fi
+ fi
+
+ echo "$opts"
+}
+
mount_btrfs_subvolumes() {
local partition="$1"
@@ -356,25 +386,8 @@ mount_btrfs_subvolumes() {
# Skip root, already mounted
[[ "$name" == "@" ]] && continue
- # Build mount options
- local opts="subvol=$name,$BTRFS_OPTS"
-
- # Apply extra options (override defaults where specified)
- if [[ -n "$extra" ]]; then
- # Handle compress=no by removing compress from opts and not adding it
- if [[ "$extra" == *"compress=no"* ]]; then
- opts=$(echo "$opts" | sed 's/,compress=zstd//')
- fi
- # Handle nodatacow
- if [[ "$extra" == *"nodatacow"* ]]; then
- opts="$opts,nodatacow"
- opts=$(echo "$opts" | sed 's/,compress=zstd//')
- fi
- # Handle nosuid,nodev for tmp
- if [[ "$extra" == *"nosuid"* ]]; then
- opts="$opts,nosuid,nodev"
- fi
- fi
+ local opts
+ opts=$(parse_btrfs_subvol_opts "$name" "$extra")
info "Mounting $name -> $MNTPOINT$mountpoint"
mkdir -p "$MNTPOINT$mountpoint"
@@ -412,22 +425,8 @@ EOF
for subvol_spec in "${BTRFS_SUBVOLS[@]}"; do
IFS=':' read -r name mountpoint extra <<< "$subvol_spec"
- # Build mount options
- local opts="subvol=$name,$BTRFS_OPTS"
-
- # Apply extra options
- if [[ -n "$extra" ]]; then
- if [[ "$extra" == *"compress=no"* ]]; then
- opts=$(echo "$opts" | sed 's/,compress=zstd//')
- fi
- if [[ "$extra" == *"nodatacow"* ]]; then
- opts="$opts,nodatacow"
- opts=$(echo "$opts" | sed 's/,compress=zstd//')
- fi
- if [[ "$extra" == *"nosuid"* ]]; then
- opts="$opts,nosuid,nodev"
- fi
- fi
+ local opts
+ opts=$(parse_btrfs_subvol_opts "$name" "$extra")
echo "UUID=$uuid $mountpoint btrfs $opts 0 0" >> $MNTPOINT/etc/fstab
done
diff --git a/installer/lib/common.sh b/installer/lib/common.sh
index 0317034..0378756 100644
--- a/installer/lib/common.sh
+++ b/installer/lib/common.sh
@@ -166,6 +166,38 @@ aur_manifest_names() {
awk -F'\t' 'NR>1 {print $1}' "$manifest"
}
+# Print the baked AUR packages that are ZFS-only tooling, one per line. The ISO
+# bakes the full AUR set on every build, but these require a ZFS root:
+# zfs-auto-snapshot has a hard `zfs` dependency, and zrepl is ZFS replication.
+# On a non-ZFS install neither dependency exists, so installing them is at best
+# pointless and at worst aborts pacstrap (zfs-auto-snapshot's unmet `zfs` dep
+# fails the whole transaction). Keep in lockstep with build-aur.sh's
+# aur_v1_packages: a new ZFS-only AUR package added there belongs here too.
+aur_zfs_only_packages() {
+ printf '%s\n' \
+ zfs-auto-snapshot \
+ zrepl
+}
+
+# Filter a list of AUR package names for the target filesystem, printing the
+# kept names one per line in input order. On a ZFS target every package passes
+# through. On any other filesystem the ZFS-only tooling (aur_zfs_only_packages)
+# is dropped so it never reaches pacstrap. install_base runs the baked manifest
+# names through this before appending them to the pacstrap set.
+filter_aur_for_fs() {
+ local fs="$1"; shift
+ local -A drop=()
+ if [[ "$fs" != zfs ]]; then
+ local z
+ while IFS= read -r z; do drop["$z"]=1; done < <(aur_zfs_only_packages)
+ fi
+ local pkg
+ for pkg in "$@"; do
+ [[ -n "${drop[$pkg]:-}" ]] && continue
+ printf '%s\n' "$pkg"
+ done
+}
+
# Remove the named repo's stanza (its [name] header and the config lines up to
# the next [section] or EOF) from the pacman.conf at $2. Used to ensure the
# installed target never references the baked [aur] repo, whose
@@ -181,7 +213,14 @@ strip_repo_stanza() {
skip { next }
{ print }
' "$pacman_conf" > "$tmp"
- mv "$tmp" "$pacman_conf"
+ # Truncate-write in place rather than `mv` the temp over the target: mktemp
+ # creates the temp 0600, and a mv would carry that onto pacman.conf,
+ # clobbering its pristine 0644 and leaving the installed config root-only.
+ # That broke every user-level makepkg/yay ("config file /etc/pacman.conf
+ # could not be read: Permission denied"). Writing through the existing file
+ # keeps its inode and mode.
+ cat "$tmp" > "$pacman_conf"
+ rm -f "$tmp"
}
#############################
diff --git a/installer/lib/config.sh b/installer/lib/config.sh
index 3ba2bb3..ed54e36 100644
--- a/installer/lib/config.sh
+++ b/installer/lib/config.sh
@@ -116,20 +116,30 @@ check_config() {
validate_config() {
local errors=0
- [[ -z "$HOSTNAME" ]] && { warn "HOSTNAME not set"; ((errors++)); }
- [[ -z "$TIMEZONE" ]] && { warn "TIMEZONE not set"; ((errors++)); }
- [[ ${#SELECTED_DISKS[@]} -eq 0 ]] && { warn "No disks selected"; ((errors++)); }
- [[ -z "$ROOT_PASSWORD" ]] && { warn "ROOT_PASSWORD not set"; ((errors++)); }
+ [[ -z "$HOSTNAME" ]] && { warn "HOSTNAME not set"; ((++errors)); }
+ [[ -z "$TIMEZONE" ]] && { warn "TIMEZONE not set"; ((++errors)); }
+ [[ ${#SELECTED_DISKS[@]} -eq 0 ]] && { warn "No disks selected"; ((++errors)); }
+ [[ -z "$ROOT_PASSWORD" ]] && { warn "ROOT_PASSWORD not set"; ((++errors)); }
# Validate disks exist
for disk in "${SELECTED_DISKS[@]}"; do
- [[ -b "$disk" ]] || { warn "Disk not found: $disk"; ((errors++)); }
+ [[ -b "$disk" ]] || { warn "Disk not found: $disk"; ((++errors)); }
done
# Validate timezone
if [[ -n "$TIMEZONE" && ! -f "/usr/share/zoneinfo/$TIMEZONE" ]]; then
warn "Invalid timezone: $TIMEZONE"
- ((errors++))
+ ((++errors))
+ fi
+
+ # Validate the RAID level against the selected disk count. The
+ # interactive path only offers levels valid for the count, so this
+ # guards the unattended config, where RAID_LEVEL is set by hand and
+ # can name a level the disk count can't support. raid_is_valid treats
+ # an empty level on a single disk (no RAID) as valid.
+ if ! raid_is_valid "$RAID_LEVEL" "${#SELECTED_DISKS[@]}"; then
+ warn "Invalid RAID_LEVEL '$RAID_LEVEL' for ${#SELECTED_DISKS[@]} disk(s)"
+ ((++errors))
fi
if [[ $errors -gt 0 ]]; then
diff --git a/scripts/test-install.sh b/scripts/test-install.sh
index 0bc4e9a..bb556cd 100755
--- a/scripts/test-install.sh
+++ b/scripts/test-install.sh
@@ -477,11 +477,21 @@ run_install() {
local config_name
config_name=$(basename "$config" .conf)
- # Copy latest archangel script and lib/ to VM (in case ISO is outdated)
- sshpass -p "$SSH_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- -P "$SSH_PORT" "$PROJECT_DIR/installer/archangel" root@localhost:/usr/local/bin/archangel 2>/dev/null
- sshpass -p "$SSH_PASSWORD" scp -r -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- -P "$SSH_PORT" "$PROJECT_DIR/installer/lib" root@localhost:/usr/local/bin/ 2>/dev/null
+ # Copy latest archangel script and lib/ to VM (in case ISO is outdated).
+ # Both pushes are checked: an unreported failure here leaves the guest
+ # without a usable installer, and the run then dies at `archangel
+ # --config-file` with exit 127 and no output at all — indistinguishable
+ # from a real install regression. Say which push failed instead.
+ if ! sshpass -p "$SSH_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
+ -P "$SSH_PORT" "$PROJECT_DIR/installer/archangel" root@localhost:/usr/local/bin/archangel 2>/dev/null; then
+ echo "[ERROR] Failed to push installer/archangel to the VM"
+ return 1
+ fi
+ if ! sshpass -p "$SSH_PASSWORD" scp -r -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
+ -P "$SSH_PORT" "$PROJECT_DIR/installer/lib" root@localhost:/usr/local/bin/ 2>/dev/null; then
+ echo "[ERROR] Failed to push installer/lib to the VM"
+ return 1
+ fi
# Copy config file to VM
sshpass -p "$SSH_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
@@ -877,6 +887,25 @@ run_test() {
local config_name
config_name=$(basename "$config" .conf)
+ # Scope the installed-system password to this one test. It used to be
+ # exported and cleared by a single `unset` on the success path, so any
+ # failure after the reboot step returned early and leaked the *installed*
+ # system's password into the next scenario — where ssh_cmd then presented
+ # it to the *live ISO* and every SSH call failed instantly, with no output
+ # and no package requests. That turned one flaky check into six silent
+ # install failures on 2026-08-01. Declaring it local makes bash clear it on
+ # every return path, so the class of bug can't come back.
+ #
+ # Safe as a local rather than an export: bash's dynamic scoping makes it
+ # visible to ssh_cmd and every verify_* helper called from here. The one
+ # child process in this flow — the `bash -c` install in the retry loop
+ # below — runs before this is ever assigned, and an unexported local
+ # reaches it as empty rather than as any value, which ssh_cmd's
+ # ${INSTALLED_PASSWORD:-$SSH_PASSWORD} treats the same as unset. That also
+ # shadows a stray INSTALLED_PASSWORD inherited from the caller's
+ # environment, which the old export did not.
+ local INSTALLED_PASSWORD=""
+
TESTS_RUN=$((TESTS_RUN + 1))
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -973,12 +1002,17 @@ run_test() {
warn "Stale archzfs package in the host pacoloco cache (archzfs re-uploads same-filename assets)."
warn "Rebuild the ISO (build.sh clears it) or run: sudo rm -f /var/cache/pacoloco/pkgs/archzfs/zfs-* — then retry."
fi
- stop_vm "$config_name"
-
- # Save logs
- ssh_cmd "cat /tmp/archangel-*.log" > "$LOG_DIR/${config_name}-install.log" 2>/dev/null || true
+ # Save logs BEFORE stopping the VM. The retry loop above already
+ # fetched the in-VM install log into $install_log while the guest was
+ # still up, so write that rather than re-asking a guest that may be
+ # gone. The previous order ran stop_vm first and then tried to ssh
+ # into the corpse, so every *-install.log this harness ever wrote was
+ # 0 bytes — which is why the April 2026 `mirror` failure went 96 days
+ # without a diagnosis.
+ printf '%s\n' "$install_log" > "$LOG_DIR/${config_name}-install.log"
cp "$SERIAL_LOG" "$LOG_DIR/${config_name}-serial.log" 2>/dev/null || true
+ stop_vm "$config_name"
cleanup_disks "$config_name"
TESTS_FAILED=$((TESTS_FAILED + 1))
FAILED_TESTS+=("$config_name")
@@ -1089,8 +1123,10 @@ run_test() {
return 1
fi
- # Use installed system's password for subsequent SSH commands
- export INSTALLED_PASSWORD="$installed_password"
+ # Use installed system's password for subsequent SSH commands.
+ # Plain assignment to the local declared at the top of run_test — see
+ # the note there for why this must not be exported.
+ INSTALLED_PASSWORD="$installed_password"
# Verify reboot survival
if ! verify_reboot_survival "$config"; then
@@ -1102,15 +1138,22 @@ run_test() {
return 1
fi
- # Verify rollback functionality
- if ! verify_rollback "$config"; then
- warn "Rollback verification had issues"
- # Don't fail the test for rollback issues - it's a bonus check
- fi
-
- # Verify zfssnapshot wrapper end-to-end (ZFS only — no-op for Btrfs).
- # Unlike verify_rollback, this is the wrapper's only runtime
- # coverage, so a regression here fails the test outright.
+ # Order matters here, and it is the opposite of what reads naturally.
+ #
+ # On ZFS, verify_rollback rolls back zroot/ROOT/default while it is
+ # mounted and running. That reverts the live root underneath the OS —
+ # open file handles, cached inodes and sshd's own state stop matching
+ # what is on disk — so SSH work afterwards fails intermittently. When
+ # the wrapper check ran second it inherited that damage and failed on
+ # the scp or the chmod at random, which is what took out the ZFS half
+ # of the 2026-08-01 suite. Btrfs never showed it because snapper's
+ # rollback does not take effect until reboot.
+ #
+ # So the fatal check runs first, on a clean freshly-booted guest, and
+ # the destabilising one runs last where it can only warn. Both still
+ # run. The real fix is to reboot the guest between them; that needs
+ # the encrypted-pool passphrase re-sent via monitor sendkey, so it is
+ # filed rather than done here.
if ! verify_zfssnapshot_wrapper "$config"; then
error "zfssnapshot wrapper verification failed"
stop_vm "$config_name"
@@ -1119,11 +1162,17 @@ run_test() {
FAILED_TESTS+=("$config_name")
return 1
fi
+
+ # Non-fatal by design: this is a bonus check, and it is also the step
+ # that leaves the guest inconsistent, so nothing depends on it after.
+ if ! verify_rollback "$config"; then
+ warn "Rollback verification had issues"
+ fi
fi
- # Cleanup
+ # Cleanup. INSTALLED_PASSWORD needs no reset here — it's a local, so bash
+ # clears it on every return path including the failure ones.
step "Cleaning up..."
- unset INSTALLED_PASSWORD # Reset for next test
stop_vm "$config_name"
cleanup_disks "$config_name"
diff --git a/tests/unit/test_archangel.bats b/tests/unit/test_archangel.bats
index 645b6e6..983bfd2 100644
--- a/tests/unit/test_archangel.bats
+++ b/tests/unit/test_archangel.bats
@@ -401,3 +401,98 @@ setup() {
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"
+}
diff --git a/tests/unit/test_btrfs.bats b/tests/unit/test_btrfs.bats
index 890bba2..15bf141 100644
--- a/tests/unit/test_btrfs.bats
+++ b/tests/unit/test_btrfs.bats
@@ -54,3 +54,38 @@ setup() {
[ "$status" -eq 0 ]
[ -z "$output" ]
}
+
+#############################
+# parse_btrfs_subvol_opts
+#############################
+# Composes the mount-option string for one subvolume from the shared
+# BTRFS_OPTS plus the per-subvol extra flags. Pure string transform,
+# shared by mount_btrfs_subvolumes and generate_btrfs_fstab. BTRFS_OPTS
+# is set at the top of btrfs.sh (sourced in setup), so these pin behavior
+# against the real default option string.
+
+@test "parse_btrfs_subvol_opts: no extra flags keeps the default opts" {
+ run parse_btrfs_subvol_opts "@home" ""
+ [ "$status" -eq 0 ]
+ [ "$output" = "subvol=@home,noatime,compress=zstd,space_cache=v2,discard=async" ]
+}
+
+@test "parse_btrfs_subvol_opts: compress=no drops compress=zstd" {
+ run parse_btrfs_subvol_opts "@media" "compress=no"
+ [ "$output" = "subvol=@media,noatime,space_cache=v2,discard=async" ]
+}
+
+@test "parse_btrfs_subvol_opts: nodatacow adds nodatacow and drops compress=zstd" {
+ run parse_btrfs_subvol_opts "@vms" "nodatacow"
+ [ "$output" = "subvol=@vms,noatime,space_cache=v2,discard=async,nodatacow" ]
+}
+
+@test "parse_btrfs_subvol_opts: nosuid adds nosuid,nodev and keeps compression" {
+ run parse_btrfs_subvol_opts "@tmp" "nosuid"
+ [ "$output" = "subvol=@tmp,noatime,compress=zstd,space_cache=v2,discard=async,nosuid,nodev" ]
+}
+
+@test "parse_btrfs_subvol_opts: nodatacow and nosuid combine" {
+ run parse_btrfs_subvol_opts "@x" "nodatacow,nosuid"
+ [ "$output" = "subvol=@x,noatime,space_cache=v2,discard=async,nodatacow,nosuid,nodev" ]
+}
diff --git a/tests/unit/test_build_aur.bats b/tests/unit/test_build_aur.bats
index 4da66fe..360f2b7 100644
--- a/tests/unit/test_build_aur.bats
+++ b/tests/unit/test_build_aur.bats
@@ -15,12 +15,12 @@ setup() {
# aur_v1_packages — single source of truth for the v1 build set
#############################
-@test "aur_v1_packages lists the nine audited v1 packages" {
+@test "aur_v1_packages lists the eight audited v1 packages" {
run aur_v1_packages
[ "$status" -eq 0 ]
- [ "$(echo "$output" | wc -l)" -eq 9 ]
+ [ "$(echo "$output" | wc -l)" -eq 8 ]
for pkg in downgrade yay informant zrepl pacman-cleanup-hook \
- sanoid zfs-auto-snapshot topgrade ventoy-bin; do
+ zfs-auto-snapshot topgrade ventoy-bin; do
[[ "$output" == *"$pkg"* ]]
done
}
@@ -28,9 +28,12 @@ setup() {
@test "aur_v1_packages excludes the vNext-deferred packages" {
run aur_v1_packages
[ "$status" -eq 0 ]
- # paru (second helper) and mkinitcpio-firmware (AUR-of-AUR deps) are vNext
+ # paru (second helper), mkinitcpio-firmware, and sanoid all pull AUR-of-AUR
+ # deps (sanoid needs perl-config-inifiles, AUR-only) — deferred to the
+ # vNext helper-driven dependency-resolution work.
[[ "$output" != *"paru"* ]]
[[ "$output" != *"mkinitcpio-firmware"* ]]
+ [[ "$output" != *"sanoid"* ]]
}
@test "aur_v1_packages emits one package per line" {
diff --git a/tests/unit/test_common.bats b/tests/unit/test_common.bats
index 6f9d1b1..c76b6a4 100644
--- a/tests/unit/test_common.bats
+++ b/tests/unit/test_common.bats
@@ -666,6 +666,26 @@ Boot0001* ZFSBootMenu"
rm -f "$f"
}
+@test "strip_repo_stanza preserves the target file mode (no 0600 clobber)" {
+ local f
+ f=$(mktemp)
+ printf '%s\n' '[core]' '[aur]' 'Server = file:///usr/share/aur-packages' '[extra]' > "$f"
+ chmod 644 "$f"
+ strip_repo_stanza aur "$f"
+ [ "$(stat -c %a "$f")" = "644" ]
+ rm -f "$f"
+}
+
+@test "strip_repo_stanza preserves a non-default file mode" {
+ local f
+ f=$(mktemp)
+ printf '%s\n' '[core]' '[aur]' 'Server = x' '[extra]' > "$f"
+ chmod 640 "$f"
+ strip_repo_stanza aur "$f"
+ [ "$(stat -c %a "$f")" = "640" ]
+ rm -f "$f"
+}
+
#############################
# aur_repo_available
#############################
@@ -720,3 +740,53 @@ Boot0001* ZFSBootMenu"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
+
+#############################
+# aur_zfs_only_packages / filter_aur_for_fs
+#############################
+
+@test "aur_zfs_only_packages lists zfs-auto-snapshot and zrepl" {
+ run aur_zfs_only_packages
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"zfs-auto-snapshot"* ]]
+ [[ "$output" == *"zrepl"* ]]
+}
+
+@test "filter_aur_for_fs zfs keeps every package including zfs-only tooling" {
+ run filter_aur_for_fs zfs downgrade yay zrepl zfs-auto-snapshot topgrade
+ [ "$status" -eq 0 ]
+ [ "${#lines[@]}" -eq 5 ]
+ [[ "$output" == *"zfs-auto-snapshot"* ]]
+ [[ "$output" == *"zrepl"* ]]
+ [[ "$output" == *"yay"* ]]
+}
+
+@test "filter_aur_for_fs btrfs drops zfs-only tooling, keeps the rest" {
+ run filter_aur_for_fs btrfs downgrade yay zrepl zfs-auto-snapshot topgrade
+ [ "$status" -eq 0 ]
+ [ "${#lines[@]}" -eq 3 ]
+ [[ "$output" != *"zfs-auto-snapshot"* ]]
+ [[ "$output" != *"zrepl"* ]]
+ [[ "$output" == *"downgrade"* ]]
+ [[ "$output" == *"yay"* ]]
+ [[ "$output" == *"topgrade"* ]]
+}
+
+@test "filter_aur_for_fs btrfs with only zfs-only tooling prints nothing" {
+ run filter_aur_for_fs btrfs zfs-auto-snapshot zrepl
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "filter_aur_for_fs with no package arguments prints nothing" {
+ run filter_aur_for_fs btrfs
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "filter_aur_for_fs preserves input order" {
+ run filter_aur_for_fs zfs yay downgrade topgrade
+ [ "$status" -eq 0 ]
+ [ "${lines[0]}" = "yay" ]
+ [ "${lines[2]}" = "topgrade" ]
+}
diff --git a/tests/unit/test_config.bats b/tests/unit/test_config.bats
index af23e4a..4169c5e 100644
--- a/tests/unit/test_config.bats
+++ b/tests/unit/test_config.bats
@@ -5,6 +5,8 @@ setup() {
# shellcheck disable=SC1091
source "${BATS_TEST_DIRNAME}/../../installer/lib/common.sh"
# shellcheck disable=SC1091
+ source "${BATS_TEST_DIRNAME}/../../installer/lib/raid.sh"
+ # shellcheck disable=SC1091
source "${BATS_TEST_DIRNAME}/../../installer/lib/config.sh"
}
@@ -93,6 +95,62 @@ EOF
[[ "$output" == *"4 error"* ]]
}
+@test "validate_config under set -e reports every error, not just the first" {
+ # Reproduces the monolith's call structure: `set -e` is active and
+ # validate_config is invoked as the final command of an && list. A
+ # post-increment that returns the pre-increment value (0 on the first
+ # error) trips set -e and aborts the function after one warning. This
+ # test runs outside bats' `run` shield (which sets +e) so the real
+ # accumulate-and-report behavior is exercised.
+ run bash -c '
+ set -e
+ source "'"${BATS_TEST_DIRNAME}"'/../../installer/lib/common.sh"
+ source "'"${BATS_TEST_DIRNAME}"'/../../installer/lib/raid.sh"
+ source "'"${BATS_TEST_DIRNAME}"'/../../installer/lib/config.sh"
+ HOSTNAME=""; TIMEZONE=""; SELECTED_DISKS=(); ROOT_PASSWORD=""
+ UNATTENDED=true
+ [[ "$UNATTENDED" == true ]] && validate_config
+ '
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"HOSTNAME not set"* ]]
+ [[ "$output" == *"TIMEZONE not set"* ]]
+ [[ "$output" == *"No disks selected"* ]]
+ [[ "$output" == *"ROOT_PASSWORD not set"* ]]
+ [[ "$output" == *"4 error"* ]]
+}
+
+@test "validate_config rejects a RAID_LEVEL invalid for the disk count" {
+ HOSTNAME=h
+ TIMEZONE=UTC
+ ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda /dev/sdb)
+ RAID_LEVEL=raidz1
+ run validate_config
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Invalid RAID_LEVEL"* ]]
+ [[ "$output" == *"raidz1"* ]]
+}
+
+@test "validate_config accepts a RAID_LEVEL valid for the disk count" {
+ HOSTNAME=h
+ TIMEZONE=UTC
+ ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda /dev/sdb /dev/sdc)
+ RAID_LEVEL=raidz1
+ run validate_config
+ [[ "$output" != *"Invalid RAID_LEVEL"* ]]
+}
+
+@test "validate_config accepts an empty RAID_LEVEL for a single disk" {
+ HOSTNAME=h
+ TIMEZONE=UTC
+ ROOT_PASSWORD=x
+ SELECTED_DISKS=(/dev/sda)
+ RAID_LEVEL=""
+ run validate_config
+ [[ "$output" != *"Invalid RAID_LEVEL"* ]]
+}
+
@test "validate_config rejects an invalid timezone" {
HOSTNAME="h"
TIMEZONE="Not/A_Real_Zone_xyz"
diff --git a/tests/unit/test_test_install.bats b/tests/unit/test_test_install.bats
index f339baf..bf43dd8 100644
--- a/tests/unit/test_test_install.bats
+++ b/tests/unit/test_test_install.bats
@@ -300,3 +300,53 @@ error: failed to commit transaction (invalid or corrupted package (checksum))
run is_archzfs_cache_corruption ""
[ "$status" -eq 1 ]
}
+
+#############################
+# INSTALLED_PASSWORD scoping
+#############################
+# After the reboot step, run_test switches ssh_cmd over to the installed
+# system's root password. That value must not outlive the test. When it leaks
+# into the next scenario, ssh_cmd presents the installed password to the *live
+# ISO* — whose password is different — so every SSH call fails instantly and
+# the install dies with no output and no package requests.
+#
+# That is exactly what happened on 2026-08-01: the reset was a single `unset`
+# on the success path, three failure paths returned early past it, and one
+# flaky check cascaded into six silent ZFS install failures. The fix declares
+# it `local` in run_test so bash clears it on every return path.
+
+@test "ssh_cmd picks up a caller-scoped INSTALLED_PASSWORD" {
+ # Proves local-instead-of-export still reaches ssh_cmd: bash's dynamic
+ # scoping exposes a caller's local to the functions it calls.
+ sshpass() { echo "$2"; }
+ ssh() { :; }
+ caller_with_local() {
+ local INSTALLED_PASSWORD="installed-secret"
+ ssh_cmd true
+ }
+ run caller_with_local
+ [[ "$output" == *"installed-secret"* ]]
+}
+
+@test "a caller-scoped INSTALLED_PASSWORD does not leak past a failed return" {
+ sshpass() { echo "$2"; }
+ ssh() { :; }
+ SSH_PASSWORD="live-iso-password"
+ failing_caller() {
+ local INSTALLED_PASSWORD="installed-secret"
+ return 1
+ }
+ failing_caller || true
+ run ssh_cmd true
+ [[ "$output" == *"live-iso-password"* ]]
+ [[ "$output" != *"installed-secret"* ]]
+}
+
+@test "run_test declares INSTALLED_PASSWORD local and never exports it" {
+ # Structural guard: run_test itself drives qemu and ssh, so this file
+ # can't exercise it directly. An export here would silently restore the
+ # cascade, so pin the shape that prevents it.
+ local src="${BATS_TEST_DIRNAME}/../../scripts/test-install.sh"
+ grep -qE '^[[:space:]]*local INSTALLED_PASSWORD=' "$src"
+ ! grep -qE '^[[:space:]]*export INSTALLED_PASSWORD' "$src"
+}