From 13f3648e4d131b90fce028287350cb8ea3ab08b5 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 20 Aug 2026 19:36:53 -0400 Subject: [PATCH] Install the driver, enrol the key, and still never ask twice Phase 3 of the fresh-install spec: the parts of a run that depend on what the machine actually is. NVIDIA, Secure Boot, Fedora's preinstalled extras, firmware. Two of these looked like they would force a compromise, and neither did. sunhat opened an editor in the middle of its run so grub could be hand-corrected, and that single step is why walking away from an install did not work. The step existed to delete duplicated kernel arguments -- and grubby replaces an argument that already exists rather than appending a second copy, so the duplicates cannot accumulate and there is nothing to correct. The editor was load-bearing for a problem that a different tool does not have. MOK enrolment needs a password now and the same password at the next boot's blue screen, which reads like a prompt that has to happen mid-run. mokutil has --generate-hash and --hash-file for exactly this: the interview asks, hashes it on the spot, and records only the hash. The plaintext never reaches the answers file, the environment, or a command line, and the stage runs without asking. The stage runs last rather than fourth as the spec's table had it. The constraint was always "late" and fourth of eight is not late: enrolment arms a prompt for the next boot and firmware may want a reboot, so a machine that reboots out of this stage should already be completely configured. Every question names what was found -- the card, the packages actually installed -- and is not asked at all on a machine it would do nothing to. sunhat's debloat list no longer describes Fedora 44: totem became showtime and LibreOffice is not preinstalled, so the list is curated and a package that is not installed is never passed to dnf, which is what lets it outlive a release. This stage cannot be verified by running it. It installs a proprietary driver and queues a Secure Boot enrolment, and this machine is an AMD desktop. So every privileged command is stood in on PATH and the contract asserts which answer led to which call: that no answers means no commands, that a failed driver install is not followed by arguments and services for a driver that is not there, that the hash reaches mokutil through a file and never a command line, and that removal is offered only for packages that are installed. The contract was checked by breaking the stage three ways and confirming it caught each. It does not verify that akmod-nvidia builds, and says so where a reader would otherwise assume it did. The README's stage table listed three of seven stages; the interview and identity work never reached it. Corrected rather than extended, since a table that lists three of seven is worse than one that lists none. The Desktops section still describes a GNOME session nothing installs -- that is phase 5. Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj --- README.md | 15 +- .../2026-08-20-panama-fresh-install-design.md | 68 ++++-- install | 12 +- setup/packages/desktop-packages | 4 + setup/scripts/install-hardware | 136 ++++++++++++ setup/scripts/interview | 88 +++++++- tests/setup/hardware-contract | 199 ++++++++++++++++++ tests/setup/interview-contract | 7 +- 8 files changed, 502 insertions(+), 27 deletions(-) create mode 100755 setup/scripts/install-hardware create mode 100755 tests/setup/hardware-contract diff --git a/README.md b/README.md index 120e01c..4e46c45 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,22 @@ git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama ~/.local/share/Panama/install ``` -`install` runs everything in `setup/scripts/` in order: +`install` asks its questions first and then runs the stages in `setup/scripts/` +in order, without stopping again: | Script | Does | |---|---| -| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), then the four package lists in `setup/packages/` | -| `link-dotfiles` | Symlinks `config/dot/` → `~/.config/` | +| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path | +| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), then the package lists in `setup/packages/` | +| `link-dotfiles` | Symlinks `config/dot/` → `~/.config/`, and seeds the wallpaper and cursor theme | | `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services | +| `link-vicinae-scripts` | Publishes the Vicinae script commands | +| `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for | +| `install-hardware` | NVIDIA, Secure Boot enrolment, Fedora's extras, firmware — each only if it was asked for. Last, because enrolment and firmware are consumed at the next boot | + +The run ends with a health summary from `panama-doctor`, which reports what is +actually running rather than what was attempted. It never fails the install: on a +fresh machine it legitimately reports things as not yet configured. Existing configs are moved to `config/old/` rather than overwritten. diff --git a/docs/superpowers/specs/2026-08-20-panama-fresh-install-design.md b/docs/superpowers/specs/2026-08-20-panama-fresh-install-design.md index eb0a409..550de76 100644 --- a/docs/superpowers/specs/2026-08-20-panama-fresh-install-design.md +++ b/docs/superpowers/specs/2026-08-20-panama-fresh-install-design.md @@ -156,24 +156,39 @@ way. The new order: | Stage | Status | Responsibility | |---|---|---| | `interview` | new | Every prompt, before any work | -| `install-packages` | edited | Repos, package lists, extras, debloat | -| `install-cursors` | new | Vendored cursor theme into `~/.local/share/icons` | -| `install-hardware` | new | NVIDIA, akmods, secure boot, firmware — all conditional | -| `link-dotfiles` | edited | Adds the wallpaper; drops `forge` | +| `install-packages` | edited | Repos, package lists, extras | +| `link-dotfiles` | edited | Adds the wallpaper and the cursor theme; drops `forge` | | `change-settings` | edited | Unchanged in substance | | `link-vicinae-scripts` | unchanged | | | `setup-identity` | new | git config, `gh auth login`, SSH key | +| `install-hardware` | new | NVIDIA, akmods, secure boot, debloat, firmware — all conditional | + +Two things in this table changed while building it, and the reasons are worth +keeping. + +There is no `install-cursors`. The cursor theme is vendored into the repository +and exposed by the same narrow per-directory symlink `link-dotfiles` already uses +for icons and Vicinae themes, so it needs no stage of its own — and because +`link-dotfiles` precedes `change-settings`, the ordering constraint that stage +was invented to satisfy holds anyway. + +`install-hardware` runs *last*, not fourth. The constraint was always "late", and +fourth of eight is not late: MOK enrolment arms a prompt consumed at the next +boot and a firmware update may ask for a reboot, so a machine that reboots out of +this stage should already be completely configured — dotfiles linked, settings +applied, identity set. Placing it after `setup-identity` costs nothing, since +nothing later depends on it. Ordering constraints that are not obvious and must not be broken: -- `install-cursors` precedes `change-settings`, because `change-settings` sets a +- `link-dotfiles` precedes `change-settings`, because `change-settings` sets a gsettings value naming a cursor theme. Setting it before the theme exists is not an error, but it does mean the first session renders with a fallback. Fonts are installed by `install-packages`, which already runs first. - `setup-identity` follows `install-packages`, because `gh` is installed there. -- `install-hardware` runs late; MOK enrollment sets a password consumed at the next - boot, and firmware updates may prompt for a reboot. Neither should sit in front of - twenty minutes of package work. +- `install-hardware` is last, for the reason above. It also has to follow its own + driver install: akmods generates the certificate that MOK enrolment enrols, so + there is nothing to enrol until `akmod-nvidia` is on the machine. Every stage stays independently re-runnable and independently failable, which is the existing contract and the reason Panama exists. @@ -297,12 +312,30 @@ hostname and it was one of the things that made sunhat unmaintainable. Everything below blocks sunhat's deletion. -- **NVIDIA and secure boot** — `akmod-nvidia`, `xorg-x11-drv-nvidia-cuda`, MOK - enrollment via `mokutil --import`, and the `rebuild_nvidia_kmod` helper. sunhat's - version opened an editor to hand-edit grub; the replacement does not, because a - step that requires a human mid-run is the failure this repository exists to avoid. -- **Fedora debloat** — removing `gnome-contacts`, `gnome-tour`, `gnome-maps`, - `totem` and the LibreOffice components, behind an interview question. +- **NVIDIA and secure boot** — `akmod-nvidia`, `xorg-x11-drv-nvidia-cuda` and MOK + enrolment via `mokutil --import`. sunhat's version opened an editor to hand-edit + grub; the replacement does not, because a step that requires a human mid-run is + the failure this repository exists to avoid. It turned out not to need one: + sunhat's editor step existed to delete duplicated kernel arguments, and `grubby + --update-kernel=ALL --args=` replaces an argument that already exists rather than + appending a second copy, so the duplicates cannot accumulate. + + Enrolment is likewise non-interactive, and did not need a compromise either. + `mokutil --generate-hash` and `--hash-file` are exactly the pair for this: the + interview asks for the password, hashes it immediately, and records only the + hash — so the plaintext never reaches the answers file, the environment, or a + command line, and the stage still runs without prompting. + + `rebuild_nvidia_kmod` was not ported. akmods already rebuilds on kernel updates + through its own systemd unit, and a helper that duplicates that is one more + thing to keep true. +- **Fedora debloat** — behind an interview question. sunhat's list was + `gnome-contacts`, `gnome-tour`, `gnome-maps`, `totem` and three LibreOffice + components; on Fedora 44 `totem` has been replaced by `showtime` and LibreOffice + is not preinstalled at all, so the list is `gnome-contacts`, `gnome-tour`, + `gnome-maps` and `showtime`. A package that is not installed is never passed to + dnf, which is what lets the list outlive a Fedora release rather than starting to + fail on one. - **Firmware** — `fwupdmgr refresh` and `update`, behind an interview question. Panama's Updates page covers this after installation; the installer covers the first run. @@ -370,6 +403,13 @@ three places instead: - **The assets contract**, which encodes each gap found here as a test that fails on the broken tree. +- **The hardware contract**, for the one stage that cannot be verified by running + it. `install-hardware` installs a proprietary driver and queues a Secure Boot + enrolment, and the machine Panama is developed on is an AMD desktop; so every + privileged command is stood in on `PATH` and the contract asserts which answer + led to which call. That covers the decisions, which is the part that can be + wrong. It does not cover whether `akmod-nvidia` builds, and does not claim to — + the first real NVIDIA machine is still the first real test. - **`panama doctor` at the end of every install**, which reports what is actually running rather than what was attempted. - **`panama test`**, which makes the existing 119 contracts runnable in one command diff --git a/install b/install index 16f5ac3..dc01075 100755 --- a/install +++ b/install @@ -53,8 +53,11 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true # Explicit order, not glob order: change-settings runs `vicinae theme set`, # which needs both vicinae itself (installed by install-packages) and the # theme files it selects among (symlinked into place by link-dotfiles); -# setup-identity needs the gh and git-all that install-packages provides. New -# scripts must be added here explicitly, or they will not run at all. +# setup-identity needs the gh and git-all that install-packages provides; and +# install-hardware is last because MOK enrolment arms a prompt consumed at the +# next boot and a firmware update may ask for a reboot -- a machine that reboots +# out of the final stage has already been completely configured. New scripts +# must be added here explicitly, or they will not run at all. # The interview is not in that list, because it is the one stage whose output the # installer reads back -- and because declining it must stop everything rather @@ -73,7 +76,8 @@ fi # shellcheck source=/dev/null source "$PANAMA_ANSWERS" export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \ - PANAMA_GH_LOGIN PANAMA_SSH_KEY + PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA PANAMA_MOK_HASH \ + PANAMA_DEBLOAT PANAMA_FIRMWARE # Applied here rather than in a stage, and applied early: it needs sudo, and # sudo is warm right now. At the end of a long unattended run the timestamp has @@ -84,7 +88,7 @@ if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then echo "Hostname set to: $(hostname)" fi -STAGES=(install-packages link-dotfiles change-settings link-vicinae-scripts setup-identity) +STAGES=(install-packages link-dotfiles change-settings link-vicinae-scripts setup-identity install-hardware) failed=() for stage in "${STAGES[@]}"; do script="$PANAMA_PATH/setup/scripts/$stage" diff --git a/setup/packages/desktop-packages b/setup/packages/desktop-packages index 978748a..ce9fd86 100644 --- a/setup/packages/desktop-packages +++ b/setup/packages/desktop-packages @@ -19,6 +19,10 @@ gnome-calendar # panels it deliberately does not own -- Online Accounts, Region, Colour, # Accessibility, Wellbeing, Wacom. See SystemSettings.qml's panel allow-list. gnome-control-center +# install-hardware sets the nouveau blacklist and nvidia-drm.modeset with +# grubby, which replaces an argument it already set rather than appending a +# second copy -- the reason that stage needs no editor. +grubby gstreamer1-plugin-fmp4 gstreamer1-plugin-gif gstreamer1-plugin-gtk4 diff --git a/setup/scripts/install-hardware b/setup/scripts/install-hardware new file mode 100755 index 0000000..b713511 --- /dev/null +++ b/setup/scripts/install-hardware @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +# The parts of an install that depend on what the machine actually is: the +# NVIDIA driver, the machine owner key that lets it load under Secure Boot, +# Fedora's preinstalled extras, and firmware. +# +# Runs last. MOK enrolment arms a prompt consumed at the next boot and firmware +# updates can ask for a reboot, so neither belongs in front of the package work +# or the dotfiles -- a machine that reboots out of this stage has already been +# fully configured. +# +# Nothing here decides anything. Every branch is an answer the interview +# collected before the run began, and an absent answer means no, which is what +# makes this safe to re-run by hand while repairing one piece of a machine. +# +# sunhat's version of this opened an editor in the middle of the run so grub +# could be hand-corrected. That is the exact failure this repository exists to +# avoid, and it is unnecessary: grubby replaces an argument that already exists +# rather than appending a second copy, so the duplicates that had to be cleaned +# up by hand cannot accumulate in the first place. + +set -uo pipefail + +log() { echo -e "\033[1;34m[INFO]\033[0m $*"; } +warn() { echo -e "\033[1;33m[WARN]\033[0m $*" >&2; } + +# Fedora ships these and Panama uses none of them. Named here rather than in the +# interview so there is one list: the interview asks for it with --debloat-list +# to name what it is about to remove, and this stage removes it. A package that +# is not installed is skipped rather than passed to dnf, so the list can outlive +# a Fedora release -- totem left in Fedora 43 and the list should not start +# failing because of it. +DEBLOAT=(gnome-contacts gnome-tour gnome-maps showtime) + +if [[ "${1:-}" == "--debloat-list" ]]; then + printf '%s\n' "${DEBLOAT[@]}" + exit 0 +fi + +# ── NVIDIA ─────────────────────────────────────────────────────────────────── + +if [[ "${PANAMA_NVIDIA:-no}" == yes ]]; then + log "Installing the NVIDIA driver" + if sudo dnf install -y akmod-nvidia xorg-x11-drv-nvidia-cuda; then + # nouveau has to be out of the way before the kernel would otherwise + # bind it, which is why these are kernel arguments and not a modprobe + # drop-in. modeset=1 is what makes the Wayland session work at all. + if command -v grubby >/dev/null 2>&1; then + sudo grubby --update-kernel=ALL \ + --args="rd.driver.blacklist=nouveau modprobe.blacklist=nouveau nvidia-drm.modeset=1" + log "Kernel arguments set for every installed kernel" + else + warn "grubby is not installed; nouveau was not blacklisted" + fi + + # Suspend and resume are where a proprietary driver most visibly fails. + # These units save and restore VRAM across it. + sudo systemctl enable nvidia-hibernate.service nvidia-suspend.service \ + nvidia-resume.service nvidia-powerd.service + log "NVIDIA power management services enabled" + else + warn "The NVIDIA driver did not install; skipping its kernel arguments and services" + fi +fi + +# ── Secure Boot ────────────────────────────────────────────────────────────── +# +# akmods signs the modules it builds with a key it generates on installation. +# Under Secure Boot that key means nothing until it is enrolled, and enrolment +# is deliberately a thing only somebody at the physical machine can complete: +# the request is queued here, and the next boot shows a blue screen asking for +# the password before it will trust the key. +# +# The password itself never reaches this stage. The interview hashed it and +# recorded the hash, so nothing readable is passed on a command line or left in +# this process's environment -- which is the whole reason mokutil has +# --generate-hash and --hash-file. + +mok_hash="${PANAMA_MOK_HASH:-}" +if [[ -n "$mok_hash" ]]; then + # Overridable so the contract can exercise this against a certificate it is + # allowed to create. Nothing else sets it. + cert="${PANAMA_MOK_CERT:-/etc/pki/akmods/certs/public_key.der}" + + if [[ ! -r "$cert" ]]; then + warn "No akmods certificate at $cert, so there is no key to enrol" + elif mokutil --test-key "$cert" 2>/dev/null | grep -q 'already enrolled'; then + log "The akmods key is already enrolled" + else + hash_file="$(mktemp -t panama-mok.XXXXXX)" + chmod 600 "$hash_file" + printf '%s\n' "$mok_hash" >"$hash_file" + if sudo mokutil --import "$cert" --hash-file "$hash_file"; then + log "Key enrolment requested" + log "At the next boot, choose 'Enrol MOK' and enter the password you gave the installer" + else + warn "Key enrolment failed; the NVIDIA module will not load until it is enrolled" + fi + rm -f "$hash_file" + fi +fi + +# ── Fedora's preinstalled extras ───────────────────────────────────────────── + +if [[ "${PANAMA_DEBLOAT:-no}" == yes ]]; then + present=() + for package in "${DEBLOAT[@]}"; do + rpm -q "$package" >/dev/null 2>&1 && present+=("$package") + done + + if (( ${#present[@]} > 0 )); then + log "Removing ${present[*]}" + sudo dnf remove -y "${present[@]}" >/dev/null \ + || warn "Some packages could not be removed" + else + log "None of Fedora's extras are installed" + fi +fi + +# ── Firmware ───────────────────────────────────────────────────────────────── +# +# Panama's Updates page covers this from then on; the installer covers the +# first run. --no-reboot-check because deciding to reboot is not this stage's +# call to make in the middle of an unattended install. + +if [[ "${PANAMA_FIRMWARE:-no}" == yes ]]; then + if command -v fwupdmgr >/dev/null 2>&1; then + log "Refreshing firmware metadata" + sudo fwupdmgr refresh --force >/dev/null 2>&1 + log "Applying firmware updates" + sudo fwupdmgr update -y --no-reboot-check \ + || log "No firmware updates were applied" + else + warn "fwupdmgr is not installed; skipping firmware" + fi +fi diff --git a/setup/scripts/interview b/setup/scripts/interview index 8ab950d..3458fdc 100755 --- a/setup/scripts/interview +++ b/setup/scripts/interview @@ -15,9 +15,14 @@ # somebody else could clone. Re-answering a handful of questions costs less than # maintaining an answers file that drifts out of date. # -# This asks only what a stage in this repository actually consumes. Extras, -# hardware and debloat questions arrive with the stages that act on them; a prompt -# whose answer nothing reads is a control that lies. +# This asks only what a stage in this repository actually consumes. The extras +# checklist arrives with the stage that acts on it; a prompt whose answer nothing +# reads is a control that lies. +# +# The hardware questions name what was found rather than asking a person to +# recite their own machine, and they are not asked at all on a machine they +# would do nothing to. Detection alone would be worse: it would remove the +# ability to decline a proprietary driver on a machine that has the card. set -uo pipefail @@ -84,6 +89,77 @@ elif yes_no "Generate an SSH key?"; then fi record PANAMA_SSH_KEY "$ssh_key" +# ── Hardware ───────────────────────────────────────────────────────────────── +# +# Each question names what was detected, so declining is a decision about this +# machine rather than an answer to a hypothetical. A machine with no NVIDIA card +# is never asked about drivers, and one with nothing to remove is never asked +# about removing it. + +heading "Hardware" + +nvidia=no +mok_hash="" +nvidia_card="$(lspci 2>/dev/null | grep -iE 'vga compatible|3d controller' | grep -i nvidia | sed 's/.*: //' | head -1)" + +if [[ -n "$nvidia_card" ]]; then + if yes_no "Found $nvidia_card — install the NVIDIA driver?"; then + nvidia=yes + + # Only asked where it does something. On a machine with Secure Boot off, + # akmods' signature is never checked and enrolling a key is ceremony. + if mokutil --sb-state 2>/dev/null | grep -qi 'secureboot enabled'; then + printf 'Secure Boot is on, so the driver must be signed with a key you enrol.\n' + printf 'The next boot will ask for this password on a blue screen.\n' + if yes_no "Enrol a machine owner key?"; then + # Hashed here and only the hash recorded. The password never + # reaches the answers file, the environment, or a command line + # -- mokutil takes a hash file precisely so it does not have to. + while :; do + first="$(gum input --password --header "MOK password")" + if [[ -z "$first" ]]; then + printf 'No password given; skipping enrolment.\n' + break + fi + second="$(gum input --password --header "MOK password again")" + if [[ "$first" == "$second" ]]; then + mok_hash="$(mokutil --generate-hash="$first")" + break + fi + printf 'Those did not match.\n' + done + unset first second + fi + fi + fi +else + printf 'No NVIDIA card found.\n' +fi +record PANAMA_NVIDIA "$nvidia" +record PANAMA_MOK_HASH "$mok_hash" + +# The stage that removes them owns the list, so there is one copy of it. +debloat=no +mapfile -t removable < <("$(dirname "${BASH_SOURCE[0]}")/install-hardware" --debloat-list) +installed=() +for package in "${removable[@]}"; do + rpm -q "$package" >/dev/null 2>&1 && installed+=("$package") +done +if (( ${#installed[@]} > 0 )); then + if yes_no "Remove Fedora's preinstalled extras (${installed[*]})?"; then + debloat=yes + fi +fi +record PANAMA_DEBLOAT "$debloat" + +firmware=no +if command -v fwupdmgr >/dev/null 2>&1; then + if yes_no "Update firmware with fwupdmgr?"; then + firmware=yes + fi +fi +record PANAMA_FIRMWARE "$firmware" + # ── Confirm ────────────────────────────────────────────────────────────────── # # The last chance to catch a typo before twenty minutes of package work that @@ -98,7 +174,11 @@ gum style --border rounded --padding "0 1" "$( printf 'Git email %s\n' "$(shown "$git_email")" printf 'Git editor %s\n' "$(shown "$git_editor")" printf 'GitHub %s\n' "$([[ "$gh_login" == yes ]] && echo "sign in" || echo "no change")" - printf 'SSH key %s' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")" + printf 'SSH key %s\n' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")" + printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")" + printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enrol a key" || echo "no change")" + printf 'Extras %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")" + printf 'Firmware %s' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")" )" if ! gum confirm --default=true "Install with these answers?"; then diff --git a/tests/setup/hardware-contract b/tests/setup/hardware-contract new file mode 100755 index 0000000..1bde136 --- /dev/null +++ b/tests/setup/hardware-contract @@ -0,0 +1,199 @@ +#!/usr/bin/env bash + +# What install-hardware does, and — more importantly — what it does not. +# +# This stage cannot be verified the way the rest of Panama is. It installs a +# proprietary driver, rewrites kernel arguments and queues a Secure Boot +# enrolment, and the machine it was written on is an AMD desktop with no NVIDIA +# card in it. Running it to see what happens is not available. +# +# So every privileged command it can reach is stood in on PATH, and the contract +# asserts what was called with what. That verifies the decisions — which answer +# leads to which command — which is the part that can be wrong. It does not +# verify that akmod-nvidia builds, and nothing here should be read as claiming it +# does. +# +# The properties worth pinning: +# +# 1. Absent answers do nothing at all. Every stage in this repository is +# independently re-runnable while repairing one piece of a machine, and a +# hardware stage that acted on its own defaults would be the one that +# installed a driver nobody asked for. +# 2. The MOK password never appears in a command line or an environment. It is +# hashed in the interview and reaches mokutil through a file, which is what +# --generate-hash and --hash-file exist for. +# 3. Nothing opens an editor. sunhat stopped in the middle of a run so grub +# could be hand-corrected, and that single step is why walking away from an +# install did not work. +# 4. Removal is offered only for packages that are actually installed, and the +# list has exactly one home. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +stage="$repo_dir/setup/scripts/install-hardware" +interview="$repo_dir/setup/scripts/interview" + +findings=() +note() { findings+=("$1"); } + +[[ -x "$stage" ]] || { printf 'hardware contract: %s is not executable\n' "$stage" >&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# ── The stand-ins ──────────────────────────────────────────────────────────── +# +# Each records its own name and arguments and succeeds. `sudo` records the +# command it was asked to run and then runs it through the same stubs, so a +# privileged call is visible whether or not it went through sudo. + +stub_dir="$work/bin" +mkdir -p "$stub_dir" +calls="$work/calls" + +for command in dnf grubby systemctl mokutil fwupdmgr rpm; do + cat >"$stub_dir/$command" <>"\$PANAMA_CALLS" +exit \${STUB_${command^^}_STATUS:-0} +STUB + chmod +x "$stub_dir/$command" +done + +cat >"$stub_dir/sudo" <<'STUB' +#!/usr/bin/env bash +printf 'sudo %s\n' "$*" >>"$PANAMA_CALLS" +exec "$@" +STUB +chmod +x "$stub_dir/sudo" + +# `rpm -q` decides what debloat has to work with, so it answers for exactly one +# package. Overrides the recording stub for that one query. +cat >"$stub_dir/rpm" <<'STUB' +#!/usr/bin/env bash +printf 'rpm %s\n' "$*" >>"$PANAMA_CALLS" +if [[ "${1:-}" == "-q" ]]; then + [[ "${2:-}" == "${STUB_INSTALLED:-}" ]] && exit 0 + exit 1 +fi +exit 0 +STUB +chmod +x "$stub_dir/rpm" + +# Runs the stage with the given answers and returns everything it invoked. +run_stage() { + : >"$calls" + env -i HOME="$HOME" PATH="$stub_dir:/usr/bin:/bin" PANAMA_CALLS="$calls" \ + STUB_INSTALLED="${STUB_INSTALLED:-}" STUB_DNF_STATUS="${STUB_DNF_STATUS:-0}" \ + "$@" bash "$stage" >/dev/null 2>&1 + cat "$calls" +} + +called() { grep -q -- "$2" <<<"$1"; } + +# ── 1. Nothing asked for, nothing done ─────────────────────────────────────── + +quiet="$(run_stage)" +if [[ -n "$quiet" ]]; then + note "with no answers the stage still ran: $(head -1 <<<"$quiet")" +fi + +# ── The NVIDIA path ────────────────────────────────────────────────────────── + +nvidia="$(run_stage PANAMA_NVIDIA=yes)" + +called "$nvidia" 'dnf install -y akmod-nvidia' \ + || note 'answering yes to NVIDIA does not install akmod-nvidia' +called "$nvidia" 'xorg-x11-drv-nvidia-cuda' \ + || note 'the CUDA driver is not installed alongside the kernel module' +called "$nvidia" 'grubby --update-kernel=ALL' \ + || note 'the kernel arguments are never set' +called "$nvidia" 'modprobe.blacklist=nouveau' \ + || note 'nouveau is not blacklisted, so it can bind the card before nvidia does' +called "$nvidia" 'nvidia-drm.modeset=1' \ + || note 'nvidia-drm.modeset is not set, which a Wayland session needs' +called "$nvidia" 'systemctl enable nvidia-hibernate.service' \ + || note 'the suspend and resume services are never enabled' + +# A driver that failed to install must not be followed by arguments and services +# for a driver that is not there. +failed="$(STUB_DNF_STATUS=1 run_stage PANAMA_NVIDIA=yes)" +if called "$failed" 'grubby --update-kernel'; then + note 'kernel arguments are set even when the driver failed to install' +fi + +# ── 2. The MOK password stays out of sight ─────────────────────────────────── + +cert="$work/public_key.der" +printf 'not a real certificate\n' >"$cert" +hash='$6$notarealsalt$notarealhashvalue' + +mok="$(run_stage PANAMA_MOK_HASH="$hash" PANAMA_MOK_CERT="$cert")" + +called "$mok" 'mokutil --import' \ + || note 'a recorded MOK hash does not queue an enrolment' +called "$mok" -- '--hash-file' \ + || note 'the enrolment does not pass a hash file, so mokutil would prompt for a password' +if grep -qF -- "$hash" <<<"$mok"; then + note 'the MOK hash is passed on a command line where any process can read it' +fi + +# No certificate means akmods never generated a key. Requesting enrolment of a +# key that does not exist is worse than skipping: it queues a prompt at the next +# boot for nothing. +without_cert="$(run_stage PANAMA_MOK_HASH="$hash" PANAMA_MOK_CERT="$work/absent.der")" +if called "$without_cert" 'mokutil --import'; then + note 'enrolment is requested even with no akmods certificate to enrol' +fi + +# ── 4. Removal is honest about what it removes ─────────────────────────────── + +removable="$("$stage" --debloat-list)" +[[ -n "$removable" ]] || note '--debloat-list prints nothing, so the interview cannot name what it removes' + +# Exactly one of them is installed, so exactly one may be passed to dnf. +present="$(head -1 <<<"$removable")" +absent="$(tail -1 <<<"$removable")" +debloat="$(STUB_INSTALLED="$present" run_stage PANAMA_DEBLOAT=yes)" + +called "$debloat" "dnf remove -y $present" \ + || note "an installed package ($present) is not removed" +removal="$(grep 'dnf remove' <<<"$debloat")" +if grep -q -- "$absent" <<<"$removal"; then + note "a package that is not installed ($absent) is still passed to dnf remove" +fi + +# ── Firmware ───────────────────────────────────────────────────────────────── + +firmware="$(run_stage PANAMA_FIRMWARE=yes)" +called "$firmware" 'fwupdmgr refresh' \ + || note 'firmware metadata is never refreshed' +called "$firmware" 'fwupdmgr update' \ + || note 'firmware updates are never applied' +called "$firmware" -- '--no-reboot-check' \ + || note 'the firmware update may reboot the machine in the middle of an install' + +# ── 3. Nothing stops for a human ───────────────────────────────────────────── + +if grep -qE 'sudoedit|EDITOR=|\bnvim\b|kitty .*-e|read -r? *-?p' "$stage"; then + note 'the stage opens an editor or waits for input, which is the failure the interview exists to prevent' +fi + +# The interview must ask for every answer this stage reads. The interview +# contract checks that in general; what it cannot check is that the two agree on +# the list itself, which is the coupling that breaks when a question is renamed. +for key in PANAMA_NVIDIA PANAMA_MOK_HASH PANAMA_DEBLOAT PANAMA_FIRMWARE; do + grep -q "record $key " "$interview" \ + || note "install-hardware reads $key, but the interview never records it" +done + +# ── Report ─────────────────────────────────────────────────────────────────── + +if (( ${#findings[@]} > 0 )); then + printf 'hardware contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'hardware contract: PASS\n' diff --git a/tests/setup/interview-contract b/tests/setup/interview-contract index 9f828bd..5f20d11 100755 --- a/tests/setup/interview-contract +++ b/tests/setup/interview-contract @@ -31,8 +31,11 @@ asked="$(grep -oE '^record [A-Z_]+' "$interview" | awk '{print $2}' | sort -u)" consumed="$(grep -rhoE '\$\{PANAMA_[A-Z_]+' "$repo_dir"/setup/scripts/* "$install_script" 2>/dev/null \ | sed 's/^\${//' | sort -u)" -# Not answers: paths the installer sets up for itself. -INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR)$' +# Not answers: paths the installer sets up for itself, plus PANAMA_MOK_CERT, +# which is the akmods certificate path with an override on it. Nothing sets that +# override outside the hardware contract, which needs a certificate it is allowed +# to create -- enrolling the real one to find out what happens is not a test. +INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR|PANAMA_MOK_CERT)$' while read -r key; do [[ -n "$key" ]] || continue