Files
Panama/tests/setup/interview-contract
T
Gabriel Brown 13f3648e4d 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
2026-08-20 19:36:53 -04:00

122 lines
5.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# The interview asks, the stages consume, and nothing survives the run.
#
# Three properties matter enough to pin:
#
# 1. Every question maps to a stage that reads its answer. A prompt whose
# answer nothing consumes is a control that lies -- the same defect this
# repository refused to ship on the SSH Keys page -- and it is an easy one
# to introduce, because asking is cheap and wiring up is not.
# 2. Every answer a stage reads is one the interview asks. The reverse gap is
# quieter and worse: the stage silently takes its fallback forever.
# 3. The answers file is deleted on every exit path. It carries an email
# address, and it is transient by design -- there is deliberately no
# remembered state between runs.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
interview="$repo_dir/setup/scripts/interview"
install_script="$repo_dir/install"
findings=()
note() { findings+=("$1"); }
# ── 1 & 2. Questions and consumers agree ─────────────────────────────────────
asked="$(grep -oE '^record [A-Z_]+' "$interview" | awk '{print $2}' | sort -u)"
# Stages read answers as ${PANAMA_FOO:-default}; install re-exports them.
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, 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
grep -qx "$key" <<<"$consumed" \
|| note "the interview asks for $key, but no stage ever reads it"
done <<<"$asked"
while read -r key; do
[[ -n "$key" ]] || continue
[[ "$key" =~ $INFRASTRUCTURE ]] && continue
grep -qx "$key" <<<"$asked" \
|| note "a stage reads $key, but the interview never asks for it"
done <<<"$consumed"
# ── 3. Nothing is left behind ────────────────────────────────────────────────
grep -q 'trap cleanup EXIT INT TERM' "$install_script" \
|| note 'install does not arm a cleanup trap on EXIT INT TERM'
grep -q 'rm -f "$PANAMA_ANSWERS"' "$install_script" \
|| note 'the cleanup trap does not delete the answers file'
grep -qE 'mktemp' "$install_script" \
|| note 'install does not create the answers file with mktemp'
# Declining must stop the run rather than count as one failed stage among five.
grep -qE 'if ! "\$PANAMA_PATH/setup/scripts/interview"; then' "$install_script" \
|| note 'install does not treat a declined interview as fatal'
# ── 4. A real run, with gum stubbed ──────────────────────────────────────────
#
# The interview is built on gum, which needs a terminal. Standing in a stub on
# PATH exercises the actual script -- its ordering, its quoting, and the file it
# writes -- rather than asserting things about its source text.
stub_dir="$(mktemp -d)"
answers_file="$(mktemp)"
trap 'rm -rf "$stub_dir" "$answers_file"' EXIT
cat >"$stub_dir/gum" <<'STUB'
#!/usr/bin/env bash
case "$1" in
input) printf '%s\n' "$GUM_STUB_INPUT" ;;
confirm) [[ "$GUM_STUB_CONFIRM" == yes ]] ;;
style) shift; printf '%s\n' "${@: -1}" ;;
*) exit 0 ;;
esac
STUB
chmod +x "$stub_dir/gum"
# A value containing a space and a quote, to prove %q survives being sourced.
GUM_STUB_INPUT="O'Brien Test" GUM_STUB_CONFIRM=yes \
PANAMA_ANSWERS="$answers_file" PATH="$stub_dir:$PATH" \
bash "$interview" >/dev/null 2>&1
interview_status=$?
(( interview_status == 0 )) || note "the interview exited $interview_status on a run that answered everything"
# Sourcing it back must reproduce the value exactly, not a mangled fragment.
(
# shellcheck source=/dev/null
source "$answers_file"
[[ "${PANAMA_GIT_NAME:-}" == "O'Brien Test" ]]
) || note 'an answer containing a quote and a space does not survive being sourced'
# Declining at the confirmation must fail, so install stops.
GUM_STUB_INPUT="x" GUM_STUB_CONFIRM=no \
PANAMA_ANSWERS="$answers_file" PATH="$stub_dir:$PATH" \
bash "$interview" >/dev/null 2>&1 \
&& note 'declining the final confirmation still exits zero, so install would proceed'
# Refusing to invent an answers path keeps the file where the caller can delete it.
PATH="$stub_dir:$PATH" bash "$interview" >/dev/null 2>&1 \
&& note 'the interview runs without PANAMA_ANSWERS instead of refusing'
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'interview contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'interview contract: PASS\n'