diff --git a/install b/install index 993cec5..9093a44 100755 --- a/install +++ b/install @@ -149,20 +149,37 @@ record_packages_hash() { } # Repository trust is checked before the installer can reach its bootstrap DNF. -# Status 78 is reserved for a trust-root failure and is propagated unchanged so -# no later stage, especially install-hardware, can invoke DNF with that repo. +# Status 78 is reserved for a trust-root failure. It suppresses every stage that +# opens DNF -- install-hardware included, which would otherwise pull drivers +# through the very repository in doubt -- and the run still exits 78 at the end. +# +# It suppresses nothing else. Linking dotfiles, skills and user content reads no +# repository and installs no package, and a machine whose Terra is in question +# still wants its configuration. Refusing the safe work because the unsafe work +# is unavailable does not make the machine safer, it just leaves the machine +# unconfigured with no way to fix itself. Exiting here instead meant link-skills +# never ran on a machine whose Terra was merely unadopted, so ~/.claude/skills +# stayed the whole-directory symlink it had been before skills were linked one +# by one, and not one shipped skill was reachable. TERRA_TRUST_FAILURE_STATUS=78 +DNF_STAGES=(install-packages change-settings install-hardware) +package_trust_refused=0 + +stage_opens_dnf() { + local candidate="$1" dnf_stage + for dnf_stage in "${DNF_STAGES[@]}"; do + [[ "$candidate" == "$dnf_stage" ]] && return 0 + done + return 1 +} + trust_preflight="$PANAMA_PATH/setup/scripts/install-packages" if [[ ! -x "$trust_preflight" ]]; then printf 'install: package repository trust preflight is unavailable\n' >&2 - exit "$TERRA_TRUST_FAILURE_STATUS" -fi -if "$trust_preflight" --trust-preflight; then - : -else - trust_status=$? + package_trust_refused=1 +elif ! "$trust_preflight" --trust-preflight; then printf 'install: package repository trust preflight failed\n' >&2 - exit "$trust_status" + package_trust_refused=1 fi # ── The interview ──────────────────────────────────────────────────────────── @@ -182,7 +199,7 @@ fi # Gated exactly like the interview itself: under --upgrade no questions are # asked, so nothing here is used, and a machine that cannot install gum must # not have that stop an upgrade that never needed it. -if (( ! UPGRADE )); then +if (( ! UPGRADE && ! package_trust_refused )); then bootstrap=() command -v gum >/dev/null 2>&1 || bootstrap+=(gum) # The probe tools serve only the hardware questions, which a server is never @@ -349,6 +366,10 @@ for stage in "${STAGES[@]}"; do script="$PANAMA_PATH/setup/scripts/$stage" [[ -x "$script" ]] || continue printf '\n=== %s ===\n' "$stage" + if (( package_trust_refused )) && stage_opens_dnf "$stage"; then + echo "Skipped: the package repository trust check refused package work." + continue + fi if [[ "$stage" == install-packages ]]; then package_state_status=0 package_start_hash="$(hash_packages)" || package_state_status=2 @@ -374,9 +395,12 @@ for stage in "${STAGES[@]}"; do fi else stage_status=$? + # A configuration change between the preflight and this stage. Suppress the + # remaining DNF stages, keep the safe ones, and carry the status to the end. if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2 - exit "$stage_status" + package_trust_refused=1 + continue fi failed+=("$stage") printf '!!! %s failed\n' "$stage" >&2 @@ -402,8 +426,16 @@ done # written for -- and baselining would skip every one of them forever. Every # migration is self-guarding and a no-op where it does not apply, so running # them is the safe direction. +# +# Held back when package work was refused. A migration is free to run a DNF +# transaction -- the ChatGPT package replacement does exactly that -- so the +# repositories have to be trustworthy before any of them is allowed to run. +# They are not marked applied either, so the next run still has them pending. migrate="$PANAMA_PATH/bin/panama-migrate" -if [[ -x "$migrate" ]]; then +if (( package_trust_refused )) && [[ -x "$migrate" ]]; then + printf '\n=== migrations ===\n' + echo "Skipped: the package repository trust check refused package work." +elif [[ -x "$migrate" ]]; then printf '\n=== migrations ===\n' if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then "$migrate" run || failed+=(migrations) @@ -451,6 +483,17 @@ else retry='./install' fi +# Reported last and on its own, because it is not an ordinary stage failure: +# everything safe did run, and what did not run is named rather than buried in a +# list. The exit status stays 78 so a caller can still tell the two apart. +if (( package_trust_refused )); then + printf 'Package work was refused: the Terra repository configuration on this\n' >&2 + printf 'machine is not one Panama can verify. Skipped: %s\n' "${DNF_STAGES[*]}" >&2 + printf 'Everything that touches no repository was still applied.\n' >&2 + printf 'Inspect it with: panama diagnose\n' >&2 + exit "$TERRA_TRUST_FAILURE_STATUS" +fi + if (( ${#failed[@]} == 0 )); then if (( UPGRADE )); then echo "Panama is up to date." diff --git a/setup/lib/artifact-provenance b/setup/lib/artifact-provenance index 615632a..e55a6ec 100755 --- a/setup/lib/artifact-provenance +++ b/setup/lib/artifact-provenance @@ -112,6 +112,7 @@ load_installer_provenance() { RUSTDESK_VERSION RUSTDESK_X86_64_URL RUSTDESK_X86_64_SHA256 RUSTDESK_X86_64_MAX_BYTES \ FEDORA_RELEASE RPMFUSION_FREE_RELEASE_URL RPMFUSION_FREE_RELEASE_MAX_BYTES \ RPMFUSION_NONFREE_RELEASE_URL RPMFUSION_NONFREE_RELEASE_MAX_BYTES TERRA_BASEURL \ + TERRA_METALINK_BASEURL \ HYPRLAND_COPR_BASEURL FLATHUB_DESCRIPTOR_URL FLATHUB_DESCRIPTOR_MAX_BYTES \ CLAUDE_CODE_BASEURL CLAUDE_DESKTOP_BASEURL TERRA_FINGERPRINT CLAUDE_CODE_FINGERPRINT \ BUN_FINGERPRINT RPMFUSION_FREE_FINGERPRINT RPMFUSION_NONFREE_FINGERPRINT \ diff --git a/setup/provenance/installers.conf b/setup/provenance/installers.conf index 9f8550c..3701467 100644 --- a/setup/provenance/installers.conf +++ b/setup/provenance/installers.conf @@ -35,6 +35,7 @@ RPMFUSION_FREE_RELEASE_MAX_BYTES=4194304 RPMFUSION_NONFREE_RELEASE_URL=https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-44.noarch.rpm RPMFUSION_NONFREE_RELEASE_MAX_BYTES=4194304 TERRA_BASEURL=https://repos.fyralabs.com/terra44 +TERRA_METALINK_BASEURL=https://tetsudou.fyralabs.com/metalink HYPRLAND_COPR_BASEURL=https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/ FLATHUB_DESCRIPTOR_URL=https://flathub.org/repo/flathub.flatpakrepo FLATHUB_DESCRIPTOR_MAX_BYTES=1048576 diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index 304a7c7..d7785c4 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -725,8 +725,17 @@ _publish_repository_pair() { return "$status" } +# Reads the gpgkey of the single enabled Terra identity out of dnf's effective +# configuration, applying the structural safety rules either way: one enabled +# identity, named terra, with every signature check turned on. +# +# With require_pinned set it additionally demands Panama's own reviewed baseurl +# and key path. Without it, the answer is just "what trust root is this machine +# actually verifying against?" -- the question adoption turns on. _effective_terra_key() { - awk -v reviewed_baseurl="${INSTALLER_PROVENANCE[TERRA_BASEURL]}" ' + awk -v reviewed_baseurl="${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \ + -v reviewed_metalink="${INSTALLER_PROVENANCE[TERRA_METALINK_BASEURL]}" \ + -v require_pinned="${1:-}" ' function reset_block() { delete values delete seen @@ -746,10 +755,22 @@ _effective_terra_key() { for (key in required) { if (seen[key] != 1) bad = 1 } - if (values["baseurl"] != reviewed_baseurl || values["metalink"] != "" \ - || values["mirrorlist"] != "" || values["gpgcheck"] != "1" \ - || values["pkg_gpgcheck"] != "1" || values["repo_gpgcheck"] != "1" \ - || values["gpgkey"] != "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama") bad = 1 + if (require_pinned != "") { + if (values["baseurl"] != reviewed_baseurl || values["metalink"] != "" \ + || values["mirrorlist"] != "" \ + || values["gpgkey"] != "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama") bad = 1 + } else if (values["mirrorlist"] != "") { + bad = 1 + } else if (values["baseurl"] == reviewed_baseurl && values["metalink"] == "") { + # Already on the reviewed baseurl, just not via the Panama key path. + } else if (values["baseurl"] == "" \ + && index(values["metalink"], reviewed_metalink "?") == 1) { + # The stock terra-release metalink, on the reviewed host. + } else { + bad = 1 + } + if (values["gpgcheck"] != "1" || values["pkg_gpgcheck"] != "1" \ + || values["repo_gpgcheck"] != "1" || values["gpgkey"] == "") bad = 1 trusted_key = values["gpgkey"] } BEGIN { @@ -807,12 +828,24 @@ _effective_terra_key() { ' } +_terra_repo_config_dump() { + LC_ALL=C dnf --quiet --no-plugins --dump-repo-config='*' +} + # Status 0 is one trusted effective Terra identity, 1 is no enabled Terra # identity, and 2 is an unsafe, duplicated, or unreadable effective state. +# +# Both this and _terra_adoptable_status take an already-read dump when the +# caller needs both verdicts, so the two cannot disagree about a configuration +# that changed between them. _terra_effective_status() { local dump gpgkey parse_status=0 local_key - dump="$(LC_ALL=C dnf --quiet --no-plugins --dump-repo-config='*')" || return 2 - gpgkey="$(printf '%s\n' "$dump" | _effective_terra_key)" || parse_status=$? + if (( $# > 0 )); then + dump="$1" + else + dump="$(_terra_repo_config_dump)" || return 2 + fi + gpgkey="$(printf '%s\n' "$dump" | _effective_terra_key pinned)" || parse_status=$? (( parse_status == 0 )) || return "$parse_status" [[ "$gpgkey" == 'file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' ]] || return 2 local_key="$PANAMA_SYSTEM_ETC/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama" @@ -824,18 +857,71 @@ _terra_effective_status() { || return 2 } +# Whether an effective Terra that is not in Panama's pinned form can be safely +# converted into it rather than refused. +# +# The trust root is the signing key, not the URL it is served from. A machine +# that installed Terra the way Terra documents has terra-release's own repo +# file: a metalink instead of the reviewed baseurl, and the key at +# RPM-GPG-KEY-terra44 rather than Panama's renamed copy. Every signature check +# is already on, and that key is the same fingerprint this repository reviewed +# and pinned. Cosmetics, in other words -- not a compromised trust root. +# +# Refusing it outright built a gate with no door. install_terra_repository +# declined to touch a machine terra-release had already reached, so an ordinary +# Fedora desktop could never reach the pinned state, and a routine `panama +# update` died before it ran a single stage. Adoption is the door. +# +# It is deliberately narrow: the pinned fingerprint must match on both the +# reviewed key and the key the machine actually verifies against, and the +# gpgkey must be a local file under the system trust directory. An unknown key, +# a remote gpgkey, a second enabled Terra, or a disabled signature check is +# still a hard refusal. +_terra_adoptable_status() { + local dump gpgkey parse_status=0 key_file key_path + _require_policy_value TERRA_METALINK_BASEURL 'https://tetsudou.fyralabs.com/metalink' || return 2 + if (( $# > 0 )); then + dump="$1" + else + dump="$(_terra_repo_config_dump)" || return 2 + fi + gpgkey="$(printf '%s\n' "$dump" | _effective_terra_key)" || parse_status=$? + (( parse_status == 0 )) || return "$parse_status" + key_file="${gpgkey#file://}" + [[ "$gpgkey" == "file://$key_file" && "$key_file" == /etc/pki/rpm-gpg/* ]] || return 2 + [[ "$key_file" != *..* ]] || return 2 + key_path="$PANAMA_SYSTEM_ETC${key_file#/etc}" + [[ -f "$key_path" && ! -L "$key_path" ]] || return 2 + key_fingerprint_matches "$PANAMA_PATH/setup/provenance/keys/terra44.asc" \ + "${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" \ + && key_fingerprint_matches "$key_path" \ + "${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" \ + || return 2 +} + TERRA_TRUST_FAILURE_STATUS=78 preflight_terra_trust() { - local status=0 + local status=0 adoptable_status=0 dump _require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' \ || return "$TERRA_TRUST_FAILURE_STATUS" _require_policy_value TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F \ || return "$TERRA_TRUST_FAILURE_STATUS" - _terra_effective_status || status=$? + dump="$(_terra_repo_config_dump)" || { + log "Effective Terra repository configuration is not trusted; refusing all package work" + return "$TERRA_TRUST_FAILURE_STATUS" + } + _terra_effective_status "$dump" || status=$? if (( status == 0 || status == 1 )); then return 0 fi + # Terra signed by the pinned key, on a reviewed endpoint, passes here because + # install-packages adopts it into the pinned form before it opens any other + # DNF transaction. + _terra_adoptable_status "$dump" || adoptable_status=$? + if (( adoptable_status == 0 )); then + return 0 + fi log "Effective Terra repository configuration is not trusted; refusing all package work" return "$TERRA_TRUST_FAILURE_STATUS" } @@ -907,18 +993,67 @@ install_rpmfusion_repositories() { return "$status" } +# Rewrites an adoptable Terra into Panama's pinned form. +# +# No network and no DNF: terra-release is already installed, so this is only the +# key copy and the repository file, published as one pair so a half-written +# trust root rolls back. The effective state is re-read afterwards, because the +# only acceptable proof that adoption worked is the check that judged it. +# +# terra-release owns /etc/yum.repos.d/terra.repo, so a later update to that +# package restores the stock file. That is fine and deliberate: the next run +# adopts it again, which is why adoption has to be repeatable rather than a +# one-time migration. +adopt_terra_repository() { + local work staged_key staged_repo status=0 effective_status=0 + work="$(mktemp -d)" || return 1 + chmod 0700 "$work" + staged_key="$work/terra44.asc" + staged_repo="$work/terra.repo" + if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/terra44.asc" "$staged_key" \ + TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then + rm -rf -- "$work" + return 1 + fi + printf '%s\n' \ + '[terra]' \ + 'name=Panama reviewed Terra 44' \ + "baseurl=${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \ + 'enabled=1' \ + 'gpgcheck=1' \ + 'repo_gpgcheck=1' \ + 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' > "$staged_repo" + chmod 0600 "$staged_repo" + _publish_repository_pair \ + "$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \ + "$staged_repo" /etc/yum.repos.d/terra.repo || status=$? + if (( status == 0 )); then + _terra_effective_status || effective_status=$? + (( effective_status == 0 )) || status="$TERRA_TRUST_FAILURE_STATUS" + fi + rm -rf -- "$work" + return "$status" +} + install_terra_repository() { - local work staged_key staged_repo status effective_status=0 + local work staged_key staged_repo status effective_status=0 adoptable_status=0 dump require_reviewed_fedora_release || return 1 _require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' || return 1 _require_policy_value TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F || return 1 - _terra_effective_status || effective_status=$? + dump="$(_terra_repo_config_dump)" || return 1 + _terra_effective_status "$dump" || effective_status=$? if (( effective_status == 0 )); then log "Terra repository already configured and verified" return 0 elif (( effective_status != 1 )); then - log "Effective Terra repository configuration is not trusted" - return "$TERRA_TRUST_FAILURE_STATUS" + _terra_adoptable_status "$dump" || adoptable_status=$? + if (( adoptable_status != 0 )); then + log "Effective Terra repository configuration is not trusted" + return "$TERRA_TRUST_FAILURE_STATUS" + fi + log "Adopting the existing Terra repository into Panama's reviewed form" + adopt_terra_repository + return $? fi if rpm -q terra-release >/dev/null 2>&1; then log "terra-release is installed without one trusted enabled Terra repository" @@ -1177,16 +1312,9 @@ if [[ "$ROLE" == server ]]; then fi echo -e "\n--- Installing Repositories ---" -log "Installing RPM Fusion Free and Nonfree Repositories" -install_rpmfusion_repositories > /dev/null -log "Enabling Fedora Cisco OpenH264 Repository" -# soft: this repo does not exist on every spin, and its absence must not cost -# the desktop -- the ordering rule at soft()'s definition applies to the -# repository extras just as much as to the codec swaps below. -soft "enabling the openh264 repository" sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1 -log "Installing RPM Fusion AppStream Metadata" -soft "the core group update" sudo dnf update @core -y -soft "the RPM Fusion appstream metadata" sudo dnf install -y rpmfusion-\*-appstream-data +# Terra goes first so a machine whose Terra is enabled but not yet in Panama's +# reviewed form is adopted before any other transaction below runs against it. +# # Terra bootstraps itself: --repofrompath defines a throwaway repo just long # enough to install terra-release, which then writes the real /etc/yum.repos.d # entry. Doing that a second time is not harmless -- dnf5 refuses the whole @@ -1200,6 +1328,17 @@ soft "the RPM Fusion appstream metadata" sudo dnf install -y rpmfusion-\*-appstr log "Installing Terra Repository" install_terra_repository > /dev/null +log "Installing RPM Fusion Free and Nonfree Repositories" +install_rpmfusion_repositories > /dev/null +log "Enabling Fedora Cisco OpenH264 Repository" +# soft: this repo does not exist on every spin, and its absence must not cost +# the desktop -- the ordering rule at soft()'s definition applies to the +# repository extras just as much as to the codec swaps below. +soft "enabling the openh264 repository" sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1 +log "Installing RPM Fusion AppStream Metadata" +soft "the core group update" sudo dnf update @core -y +soft "the RPM Fusion appstream metadata" sudo dnf install -y rpmfusion-\*-appstream-data + echo -e "\n--- Installing relevant packages ---" log "Updating all packages. This may take a while" sudo dnf update -y --refresh > /dev/null diff --git a/tests/setup/extras-contract b/tests/setup/extras-contract index f071b62..730438b 100755 --- a/tests/setup/extras-contract +++ b/tests/setup/extras-contract @@ -65,6 +65,12 @@ trap 'rm -rf "$work"' EXIT filter="$(sed -n '/^packages_in()/,/^}/p' "$installer")" loop="$(sed -n '/^install_extra_category()/,/^}/p' "$installer")" +# install_extra_category verifies the Flathub remote before installing a +# flatpak, and records a soft failure when it cannot. Both live outside the +# extracted function and have contracts of their own, so they stand in here as +# trusted -- what is under test is which targets reach which installer. +deps='ensure_flathub_remote() { :; } +softly_failed=()' [[ -n "$filter" && -n "$loop" ]] || { printf 'extras contract: install-packages no longer defines packages_in and install_extra_category\n' >&2 exit 1 @@ -99,6 +105,7 @@ LIST source "$catalog" eval "$filter" eval "$loop" + eval "$deps" install_extra_category "$fixture" ) @@ -132,6 +139,7 @@ printf 'flatpak:org.example.OnlyFlatpak\n' >"$flatpak_only" source "$catalog" eval "$filter" eval "$loop" + eval "$deps" install_extra_category "$flatpak_only" ) flatpak_only_status=$? @@ -149,6 +157,7 @@ grep -q 'flatpak install -y flathub org.example.OnlyFlatpak' <<<"$(cat "$calls" source "$catalog" eval "$filter" eval "$loop" + eval "$deps" EXTRAS_DIR="$extras_dir" for extra in ${PANAMA_EXTRAS:-}; do [[ -f "$EXTRAS_DIR/$extra" ]] && install_extra_category "$EXTRAS_DIR/$extra" diff --git a/tests/setup/package-provenance-contract b/tests/setup/package-provenance-contract index faca7c9..c016d1d 100755 --- a/tests/setup/package-provenance-contract +++ b/tests/setup/package-provenance-contract @@ -894,7 +894,16 @@ if [[ -n "$query" ]]; then trusted|wrong-key) mode=trusted ;; nogpg) mode=legacy ;; wrong-url) mode=override-url ;; + stock|stock-wrong-key) mode=stock ;; esac + # Adoption rewrites the repository file. Once it is the pinned form the + # dump has to say so, or the re-verification adoption performs on itself + # could never pass. + if [[ "$mode" == stock && -f "$STUB_ETC/yum.repos.d/terra.repo" ]] \ + && grep -q '^gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama$' \ + "$STUB_ETC/yum.repos.d/terra.repo"; then + mode=trusted + fi if [[ "$mode" == auto && -f "$STUB_ETC/yum.repos.d/terra.repo" ]] \ && grep -q '^baseurl=https://repos.fyralabs.com/terra44$' "$STUB_ETC/yum.repos.d/terra.repo"; then mode=trusted @@ -926,6 +935,13 @@ if [[ -n "$query" ]]; then printf 'metalink = https://tetsudou.fyralabs.com/terra44\nmirrorlist = \n' printf 'pkg_gpgcheck = 0\nrepo_gpgcheck = 0\n' ;; + stock) + printf '======== "terra" repository configuration: ========\n' + printf 'baseurl = \nenabled = 1\ngpgcheck = 1\n' + printf 'gpgkey = file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44\n' + printf 'metalink = https://tetsudou.fyralabs.com/metalink?repo=terra44&arch=x86_64\n' + printf 'mirrorlist = \npkg_gpgcheck = 1\nrepo_gpgcheck = 1\n' + ;; override-url) printf '======== "terra" repository configuration: ========\n' printf 'baseurl = https://evil.invalid/terra44\nenabled = 1\ngpgcheck = 1\n' @@ -1120,6 +1136,17 @@ run_installer_function() { printf '[terra]\nbaseurl=https://evil.invalid/terra44\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama\n' \ > "$case_root/etc/yum.repos.d/terra.repo" ;; + stock|stock-wrong-key) + if [[ "${STUB_TERRA_REPO_MODE}" == stock ]]; then + cp "$installer_fixture/setup/provenance/keys/terra44.asc" \ + "$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44" + else + cp "$installer_fixture/setup/provenance/keys/flathub.asc" \ + "$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44" + fi + printf '[terra]\nmetalink=https://tetsudou.fyralabs.com/metalink?repo=terra44&arch=$basearch\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44\n' \ + > "$case_root/etc/yum.repos.d/terra.repo" + ;; wrong-key) cp "$installer_fixture/setup/provenance/keys/flathub.asc" \ "$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama" @@ -1770,6 +1797,39 @@ for mode in nogpg wrong-url wrong-key absent; do || fail "untrusted existing Terra $mode state reached a mutation" done +# A machine that installed Terra the way Terra documents it. The repository file +# is terra-release's own -- a metalink, and the key at its stock path -- so it is +# not Panama's pinned form, but it IS the fingerprint this repository reviewed, +# with every signature check on. That is an adoption, not a compromise. +# +# Refusing it was a gate with no door: the ordinary Fedora desktop could never +# reach the pinned state, and status 78 then stopped every stage of every run, +# including the ones that never open DNF. +reset_installer_fixture +STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE=stock \ + expect_success run_installer_function terra-stock-preflight preflight_terra_trust + +reset_installer_fixture +STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE=stock \ + expect_success run_installer_function terra-stock-adopt install_terra_repository +terra_adopted="$test_tmp/cases/terra-stock-adopt/etc/yum.repos.d/terra.repo" +grep -qx 'baseurl=https://repos.fyralabs.com/terra44' "$terra_adopted" \ + || fail 'adoption left Terra off the reviewed baseurl' +grep -qx 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' "$terra_adopted" \ + || fail 'adoption left Terra off the reviewed key path' +grep -q 'metalink' "$terra_adopted" \ + && fail 'adoption kept the metalink it was supposed to replace' +[[ "$(<"$test_tmp/cases/terra-stock-adopt/commands.log")" != *'dnf:install'* ]] \ + || fail 'adoption opened a DNF transaction it does not need' + +# Adoption is anchored on the fingerprint, not the URL. The same stock shape +# verifying against a key that is not Terra's is still a hard refusal. +reset_installer_fixture +STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE=stock-wrong-key \ + expect_failure run_installer_function terra-stock-wrong-key install_terra_repository +[[ "$(<"$test_tmp/cases/terra-stock-wrong-key/commands.log")" != *'sudo:'* ]] \ + || fail 'a stock Terra signed by an unreviewed key reached a mutation' + # An optional security field may be absent, but duplicates are malformed even # when one copy looks safe. These cases catch the absent/duplicate conflation. for duplicate_case in \ diff --git a/tests/setup/update-command-contract b/tests/setup/update-command-contract index 7005df2..7d21f61 100755 --- a/tests/setup/update-command-contract +++ b/tests/setup/update-command-contract @@ -360,30 +360,48 @@ fi grep -qx 'link-dotfiles' "$tmp/c/ran" \ || note 'an ordinary package-stage failure no longer allows later safe stages' -# An invalid enabled Terra root is not an ordinary package failure. It must -# stop before the installer's bootstrap DNF and before every stage. +# An untrusted Terra root is not an ordinary package failure, and it is not a +# reason to abandon the machine either. It suppresses the stages that open DNF +# and the migrations, which are free to run a transaction of their own. Every +# stage that only links configuration still runs, and the status stays 78. +SAFE_STAGES=(link-dotfiles link-skills link-user link-vicinae-scripts) +DNF_SUPPRESSED=(install-packages change-settings install-hardware) + +assert_trust_refusal() { + local root="$1" label="$2" suppressed safe + for suppressed in "${DNF_SUPPRESSED[@]}"; do + grep -qx "$suppressed" "$root/ran" \ + && note "$label still ran $suppressed" + done + grep -q '^migrate ' "$root/ran" \ + && note "$label still ran migrations, which may open a DNF transaction" + for safe in "${SAFE_STAGES[@]}"; do + grep -qx "$safe" "$root/ran" \ + || note "$label suppressed $safe, which touches no repository" + done +} + build_fixture "$tmp/terra-preflight-hard" 0 78 install_status=0 run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$? [[ "$install_status" -eq 78 ]] \ || note "initial Terra trust failure returned $install_status instead of 78" -asserted_preflight="$(<"$tmp/terra-preflight-hard/ran")" -[[ "$asserted_preflight" == trust-preflight ]] \ - || note "initial Terra trust failure allowed later work: ${asserted_preflight//$'\n'/,}" +grep -qx 'trust-preflight' "$tmp/terra-preflight-hard/ran" \ + || note 'initial Terra trust fixture never reached the preflight' +assert_trust_refusal "$tmp/terra-preflight-hard" 'initial Terra trust failure' -# The trust verifier is itself mandatory. Losing its executable adapter must -# fail closed before interview, bootstrap, or stage work. +# The trust verifier is itself mandatory. Losing its executable adapter refuses +# package work exactly as a failing verdict does, rather than being ignored. build_fixture "$tmp/terra-preflight-missing" rm "$tmp/terra-preflight-missing/setup/scripts/install-packages" install_status=0 run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$? [[ "$install_status" -eq 78 ]] \ || note "missing Terra trust verifier returned $install_status instead of 78" -[[ ! -s "$tmp/terra-preflight-missing/ran" ]] \ - || note 'missing Terra trust verifier allowed later work' +assert_trust_refusal "$tmp/terra-preflight-missing" 'missing Terra trust verifier' # The package stage repeats the preflight to close a configuration-change race. -# Its hard status must also stop link stages and install-hardware immediately. +# Its hard status suppresses the DNF stages that would have followed it. build_fixture "$tmp/terra-stage-hard" 78 0 install_status=0 run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$? @@ -391,10 +409,16 @@ run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$? || note "stage-time Terra trust failure returned $install_status instead of 78" grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \ || note 'stage-time Terra trust fixture never reached install-packages' -for suppressed in link-dotfiles link-skills link-user change-settings install-hardware dnf-transaction; do +for suppressed in change-settings install-hardware; do grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \ && note "stage-time Terra trust failure still ran $suppressed" done +grep -q '^migrate ' "$tmp/terra-stage-hard/ran" \ + && note 'stage-time Terra trust failure still ran migrations' +for safe in "${SAFE_STAGES[@]}"; do + grep -qx "$safe" "$tmp/terra-stage-hard/ran" \ + || note "stage-time Terra trust failure suppressed $safe" +done # A full install always runs the stage, whatever any recorded hash says. build_fixture "$tmp/d"