From 442eec19fac744cf8723dd4de707277232b112a1 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 17 Sep 2026 11:43:30 -0400 Subject: [PATCH] WIP: Paused over-hardening fix wave (boot checkout verification, double-read package manifest) --- boot | 95 +- install | 128 ++- setup/lib/artifact-provenance | 76 +- setup/provenance/README.md | 83 +- setup/scripts/install-hardware | 7 +- setup/scripts/install-packages | 873 +++++++++++++---- setup/scripts/link-vicinae-scripts | 97 +- tests/setup/boot-contract | 146 +++ tests/setup/desktop-first-contract | 5 +- tests/setup/hardware-contract | 5 +- tests/setup/launcher-search-contract | 127 ++- tests/setup/package-provenance-contract | 1029 +++++++++++++++++++- tests/setup/root-server-bootstrap-contract | 12 + tests/setup/update-command-contract | 192 +++- user/agents/mcp/env | 4 + 15 files changed, 2553 insertions(+), 326 deletions(-) create mode 100644 user/agents/mcp/env diff --git a/boot b/boot index 40f4245..437c5fa 100755 --- a/boot +++ b/boot @@ -45,6 +45,65 @@ checkout_command() { fi } +# Git's index hints are performance promises, not trust evidence. In +# particular, assume-unchanged and skip-worktree can make porcelain status +# report a clean checkout whose files no longer match HEAD. Compare every +# tracked blob and Git mode with the verified commit before handing control to +# any file in the worktree. +checkout_matches_verified_commit() ( + local checkout="$1" listing="" entry metadata mode type expected path actual + local link_target_with_sentinel link_target + + trap '[[ -z "$listing" ]] || rm -f -- "$listing"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + listing="$(mktemp -u -t panama-boot-tree.XXXXXX)" || exit 1 + umask 077 + if ! (set -o noclobber; : >"$listing") 2>/dev/null; then + listing="" + exit 1 + fi + + checkout_command git -C "$checkout" ls-tree -rz --full-tree \ + "$PANAMA_BOOT_REVISION" >"$listing" || exit 1 + while IFS= read -r -d '' entry; do + [[ "$entry" == *$'\t'* ]] || exit 1 + metadata="${entry%%$'\t'*}" + path="${entry#*$'\t'}" + read -r mode type expected <<<"$metadata" + [[ "$type" == blob && -n "$path" && "$path" != /* ]] || exit 1 + + case "$mode" in + 100644) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \ + && ! -x "$checkout/$path" ]] || exit 1 ;; + 100755) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \ + && -x "$checkout/$path" ]] || exit 1 ;; + 120000) + [[ -L "$checkout/$path" ]] || exit 1 + # hash-object given a pathname follows a symlink. Git's 120000 blob is + # the link text itself, including any trailing newlines, so preserve + # those bytes with a sentinel and hash stdin instead. + link_target_with_sentinel="$( + readlink -n -- "$checkout/$path" && printf . + )" || exit 1 + [[ "$link_target_with_sentinel" == *. ]] || exit 1 + link_target="${link_target_with_sentinel%.}" + actual="$( + printf '%s' "$link_target" \ + | checkout_command git -C "$checkout" hash-object --stdin + )" || exit 1 + [[ "$actual" == "$expected" ]] || exit 1 + continue + ;; + *) exit 1 ;; + esac + + actual="$(checkout_command git -C "$checkout" hash-object --no-filters -- "$path")" \ + || exit 1 + [[ "$actual" == "$expected" ]] || exit 1 + done <"$listing" +) + prepare_panama_checkout() { local checkout="$1" actual_head checkout_status @@ -110,6 +169,30 @@ for arg in "$@"; do esac done +# Keep the worktree comparison at the last possible boundary. Checkout +# preparation may invoke several commands and return to the caller; performing +# the byte/mode/link check here ensures a change in that interval is rejected +# before any tracked file is executed. +verified_install_handoff() { + local use_tty="$1" + if ! checkout_matches_verified_commit "$PANAMA_PATH"; then + echo "boot: checkout files do not match PANAMA_BOOT_REVISION" >&2 + return 1 + fi + if [[ -n "$BOOTSTRAP_USER" ]]; then + if (( use_tty )); then + exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \ + "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} /dev/null 2>&1; then echo "Installing git, which the clone needs" - dnf install -y git + dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git fi # Create or advance the checkout as the target user. A root-owned .git in a @@ -481,15 +564,14 @@ if [[ "$(id -u)" -eq 0 ]]; then prepare_panama_checkout "$PANAMA_PATH" echo "Handing off to install as $username" - exec runuser -u "$username" -- env PANAMA_PATH="$PANAMA_PATH" \ - "$PANAMA_PATH/install" --server /dev/null 2>&1; then echo "Installing git, which the clone needs" - sudo dnf install -y git + sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git fi prepare_panama_checkout "$PANAMA_PATH" @@ -500,7 +582,8 @@ prepare_panama_checkout "$PANAMA_PATH" # so itself. # The probe actually opens /dev/tty rather than testing -r: a process with no # controlling terminal passes -r and then fails the redirect. +handoff_tty=0 if [[ ! -t 0 ]] && (exec /dev/null; then - exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} "$raw" || return 1 + LC_ALL=C sort -z "$raw" >"$destination" } +_write_package_manifest() { + local inputs="$1" destination="$2" file relative digest + : >"$destination" || return 1 + while IFS= read -r -d '' file; do + [[ -f "$file" && ! -L "$file" && -r "$file" ]] || return 1 + relative="${file#"$PANAMA_PATH"/}" + [[ "$relative" != "$file" ]] || return 1 + digest="$(sha256sum -- "$file" | awk '{ print $1 }')" || return 1 + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\0%s\0' "$relative" "$digest" >>"$destination" || return 1 + done <"$inputs" +} + +# Read every input twice from the same enumerated set. A file or path that +# changes while the snapshot is built cannot produce a receipt. +hash_packages() ( + local work="" inputs_before inputs_after manifest_before manifest_after + trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + work="$(mktemp -u -d -t panama-packages-hash.XXXXXX)" || exit 1 + if ! mkdir -m 700 -- "$work"; then + work="" + exit 1 + fi + inputs_before="$work/inputs-before" + inputs_after="$work/inputs-after" + manifest_before="$work/manifest-before" + manifest_after="$work/manifest-after" + + _collect_package_inputs "$inputs_before" || exit 1 + _write_package_manifest "$inputs_before" "$manifest_before" || exit 1 + _collect_package_inputs "$inputs_after" || exit 1 + cmp -s -- "$inputs_before" "$inputs_after" || exit 1 + _write_package_manifest "$inputs_after" "$manifest_after" || exit 1 + cmp -s -- "$manifest_before" "$manifest_after" || exit 1 + sha256sum -- "$manifest_before" | awk '{ print $1 }' +) + +PACKAGE_START_HASH="" + packages_needed() { local current_hash recorded_hash + current_hash="$(hash_packages)" || return 2 + PACKAGE_START_HASH="$current_hash" (( FORCE_PACKAGES )) && return 0 (( UPGRADE )) || return 0 [[ -r "$PACKAGES_HASH" ]] || return 0 - current_hash="$(hash_packages)" || return 2 recorded_hash="$(cat "$PACKAGES_HASH")" || return 2 [[ "$current_hash" != "$recorded_hash" ]] } @@ -129,17 +167,25 @@ packages_needed() { # Written only after the stage succeeds, mirroring the rule panama-migrate # documents for its markers: a step that did not complete has not happened, and # recording it as done hides it forever. -record_packages_hash() { - local temporary_hash +record_packages_hash() ( + local expected_hash="$1" current_hash temporary_hash="" + trap '[[ -z "$temporary_hash" ]] || rm -f -- "$temporary_hash"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + [[ "$expected_hash" =~ ^[0-9a-f]{64}$ ]] || return 1 + current_hash="$(hash_packages)" || return 1 + [[ "$current_hash" == "$expected_hash" ]] || return 1 mkdir -p "$STATE_DIR" - temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1 - if hash_packages >"$temporary_hash"; then - mv -f -- "$temporary_hash" "$PACKAGES_HASH" - else - rm -f -- "$temporary_hash" + temporary_hash="$(mktemp -u "$STATE_DIR/.packages-hash.XXXXXX")" || return 1 + umask 077 + if ! (set -o noclobber; : >"$temporary_hash") 2>/dev/null; then + temporary_hash="" return 1 fi -} + printf '%s\n' "$expected_hash" >"$temporary_hash" || return 1 + mv -f -- "$temporary_hash" "$PACKAGES_HASH" || return 1 + temporary_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 @@ -190,7 +236,8 @@ if (( ! UPGRADE )); then fi if (( ${#bootstrap[@]} > 0 )); then echo "Installing what the setup questions are built on: ${bootstrap[*]}" - sudo dnf install -y "${bootstrap[@]}" >/dev/null || { + sudo dnf install -y --repo=fedora --repo=updates \ + --from-repo=fedora,updates "${bootstrap[@]}" >/dev/null || { echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2 exit 1 } @@ -253,7 +300,12 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true # is unset and each stage takes the empty-answer path it already documents -- # which is why this is a flag rather than a rewrite of seven stage scripts. if (( ! UPGRADE )); then - PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)" + PANAMA_ANSWERS="$(mktemp -u -t panama-answers.XXXXXX)" || exit 1 + umask 077 + if ! (set -o noclobber; : >"$PANAMA_ANSWERS") 2>/dev/null; then + PANAMA_ANSWERS="" + exit 1 + fi export PANAMA_ANSWERS if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then @@ -343,8 +395,10 @@ for stage in "${STAGES[@]}"; do [[ -x "$script" ]] || continue printf '\n=== %s ===\n' "$stage" if [[ "$stage" == install-packages ]]; then + package_start_hash="" package_state_status=0 packages_needed || package_state_status=$? + package_start_hash="$PACKAGE_START_HASH" if (( package_state_status == 1 )); then echo "The package lists have not changed since the last run; skipping." echo "Run with --packages to install them anyway." @@ -357,7 +411,7 @@ for stage in "${STAGES[@]}"; do fi if "$script"; then if [[ "$stage" == install-packages ]]; then - if ! record_packages_hash; then + if ! record_packages_hash "$package_start_hash"; then failed+=("$stage") printf '!!! %s could not record its tracked installation inputs\n' "$stage" >&2 fi diff --git a/setup/lib/artifact-provenance b/setup/lib/artifact-provenance index 317d51a..d21650f 100755 --- a/setup/lib/artifact-provenance +++ b/setup/lib/artifact-provenance @@ -7,26 +7,36 @@ declare -gA INSTALLER_PROVENANCE=() _primary_key_fingerprints() ( - local home - home="$(mktemp -d)" || exit 1 - chmod 700 "$home" - trap 'rm -rf -- "$home"' EXIT + local home="" gpg_output + trap '[[ -z "$home" ]] || rm -rf -- "$home"' EXIT trap 'exit 130' INT trap 'exit 143' TERM - GNUPGHOME="$home" gpg --batch --with-colons --import-options show-only --import "$1" 2>/dev/null \ - | awk -F: '$1 == "pub" { primary = 1; next } primary && $1 == "fpr" { print $10; primary = 0 }' + home="$(mktemp -u -d -t panama-gpg.XXXXXX)" || exit 1 + if ! mkdir -m 700 -- "$home"; then + home="" + exit 1 + fi + gpg_output="$(GNUPGHOME="$home" gpg --batch --with-colons \ + --import-options show-only --import "$1" 2>/dev/null)" || exit 1 + awk -F: '$1 == "pub" { primary = 1; next } primary && $1 == "fpr" { print $10; primary = 0 }' \ + <<<"$gpg_output" ) key_fingerprint_matches() { - local file="$1" expected="$2" + local file="$1" expected="$2" output local -a primary_fingerprints=() - mapfile -t primary_fingerprints < <(_primary_key_fingerprints "$file") + output="$(_primary_key_fingerprints "$file")" || return 1 + [[ -n "$output" ]] || return 1 + mapfile -t primary_fingerprints <<<"$output" [[ ${#primary_fingerprints[@]} -eq 1 && "${primary_fingerprints[0]}" == "$expected" ]] } _key_has_one_primary() { + local output local -a primary_fingerprints=() - mapfile -t primary_fingerprints < <(_primary_key_fingerprints "$1") + output="$(_primary_key_fingerprints "$1")" || return 1 + [[ -n "$output" ]] || return 1 + mapfile -t primary_fingerprints <<<"$output" [[ ${#primary_fingerprints[@]} -eq 1 ]] } @@ -34,11 +44,15 @@ verify_detached_signature() { local key="$1" signature="$2" content="$3" home _key_has_one_primary "$key" || return 1 ( - home="$(mktemp -d)" || exit 1 - chmod 700 "$home" - trap 'rm -rf -- "$home"' EXIT + home="" + trap '[[ -z "$home" ]] || rm -rf -- "$home"' EXIT trap 'exit 130' INT trap 'exit 143' TERM + home="$(mktemp -u -d -t panama-gpg.XXXXXX)" || exit 1 + if ! mkdir -m 700 -- "$home"; then + home="" + exit 1 + fi GNUPGHOME="$home" gpg --batch --quiet --import "$key" >/dev/null 2>&1 \ && GNUPGHOME="$home" gpg --batch --verify "$signature" "$content" >/dev/null 2>&1 ) @@ -60,7 +74,11 @@ download_sha256() { [[ "$max_bytes" =~ ^[1-9][0-9]*$ ]] || exit 1 [[ -n "$destination" && -d "$directory" ]] || exit 1 umask 077 - part="$(mktemp "$directory/.${filename}.part.XXXXXX")" || exit 1 + part="$(mktemp -u "$directory/.${filename}.part.XXXXXX")" || exit 1 + if ! (set -o noclobber; : >"$part") 2>/dev/null; then + part="" + exit 1 + fi curl --fail --location --connect-timeout 10 --max-time 600 \ --max-filesize "$max_bytes" --output "$part" "$url" \ || exit 1 @@ -71,25 +89,25 @@ download_sha256() { ) } -rpm_signature_matches() { - local package="$1" key="$2" expected="$3" home db output status +rpm_signature_matches() ( + local package="$1" key="$2" expected="$3" home="" db output + trap '[[ -z "$home" ]] || rm -rf -- "$home"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM - key_fingerprint_matches "$key" "$expected" || return 1 - home="$(mktemp -d)" || return 1 - chmod 700 "$home" + key_fingerprint_matches "$key" "$expected" || exit 1 + home="$(mktemp -u -d -t panama-rpm-signature.XXXXXX)" || exit 1 + if ! mkdir -m 700 -- "$home"; then + home="" + exit 1 + fi db="$home/rpmdb" - mkdir -m 700 "$db" || { - rm -rf -- "$home" - return 1 - } - - rpmkeys --dbpath "$db" --import "$key" >/dev/null 2>&1 \ - && output="$(rpmkeys --dbpath "$db" --checksig --verbose "$package" 2>&1)" - status=$? - rm -rf -- "$home" - (( status == 0 )) || return 1 + mkdir -m 700 "$db" || exit 1 + rpmkeys --dbpath "$db" --import "$key" >/dev/null 2>&1 || exit 1 + output="$(rpmkeys --dbpath "$db" --checksig --verbose "$package" 2>&1)" \ + || exit 1 grep -Eqi 'OpenPGP.*signature.*: OK' <<<"$output" -} +) load_installer_provenance() { local file="$1" line name value required diff --git a/setup/provenance/README.md b/setup/provenance/README.md index a94e73e..899a6c3 100644 --- a/setup/provenance/README.md +++ b/setup/provenance/README.md @@ -11,17 +11,24 @@ content before the applicable verification succeeds. Each command below was run in a private temporary directory on 2026-08-27. The resulting armored public key is vendored under `keys/`; each output was checked with the listed complete primary fingerprint before it was committed. +The verification commands use Panama's status-preserving helper: it captures +GPG's output only after GPG succeeds, then requires exactly one primary key. + +```bash +source setup/lib/artifact-provenance +key_fingerprint_matches KEY.asc EXPECTED_COMPLETE_PRIMARY_FINGERPRINT +``` | Key | Source URL | Expected primary fingerprint | Verification command | | --- | --- | --- | --- | -| Terra 44 | `https://repos.fyralabs.com/terra44/key.asc` | `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F` | `gpg --batch --with-colons --import-options show-only --import terra44.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | -| Anthropic Claude Code | `https://downloads.claude.ai/keys/claude-code.asc` | `31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE` | `gpg --batch --with-colons --import-options show-only --import claude-code.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | -| Bun releases | `https://keys.openpgp.org/vks/v1/by-fingerprint/F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `gpg --batch --with-colons --import-options show-only --import bun.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | -| RPM Fusion free | `https://download1.rpmfusion.org/free/fedora/RPM-GPG-KEY-rpmfusion-free-fedora-2020` | `E9A491A3DE247814E7E067EAE06F8ECDD651FF2E` | `gpg --batch --with-colons --import-options show-only --import rpmfusion-free.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | -| RPM Fusion nonfree | `https://download1.rpmfusion.org/nonfree/fedora/RPM-GPG-KEY-rpmfusion-nonfree-fedora-2020` | `79BDB88F9BBF73910FD4095B6A2AF96194843C65` | `gpg --batch --with-colons --import-options show-only --import rpmfusion-nonfree.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | -| lionheartp/Hyprland COPR | `https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/pubkey.gpg` | `97E23476C89635135407C7D5E9BA41342C4B2995` | `gpg --batch --with-colons --import-options show-only --import hyprland-copr.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | -| Flathub | `https://flathub.org/repo/flathub.flatpakrepo` | `6E5C05D979C76DAF93C081354184DD4D907A7CAE` | `awk -F= '/^GPGKey=/{print $2}' flathub.flatpakrepo \| base64 --decode \| gpg --batch --with-colons --import-options show-only --import \| awk -F: '$1 == "fpr" { print $10; exit }'` | -| Claude Desktop Extra | `https://patrickjaja.github.io/claude-desktop-extra/gpg-key.asc` | `825A7D15D78BABE45646D5DF382409F597908867` | `gpg --batch --with-colons --import-options show-only --import claude-desktop.asc \| awk -F: '$1 == "fpr" { print $10; exit }'` | +| Terra 44 | `https://repos.fyralabs.com/terra44/key.asc` | `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F` | `key_fingerprint_matches terra44.asc AE09157A4DE88B497EA1D5D300CDAB43DE226D6F` | +| Anthropic Claude Code | `https://downloads.claude.ai/keys/claude-code.asc` | `31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE` | `key_fingerprint_matches claude-code.asc 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE` | +| Bun releases | `https://keys.openpgp.org/vks/v1/by-fingerprint/F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | `key_fingerprint_matches bun.asc F3DCC08A8572C0749B3E18888EAB4D40A7B22B59` | +| RPM Fusion free | `https://download1.rpmfusion.org/free/fedora/RPM-GPG-KEY-rpmfusion-free-fedora-2020` | `E9A491A3DE247814E7E067EAE06F8ECDD651FF2E` | `key_fingerprint_matches rpmfusion-free.asc E9A491A3DE247814E7E067EAE06F8ECDD651FF2E` | +| RPM Fusion nonfree | `https://download1.rpmfusion.org/nonfree/fedora/RPM-GPG-KEY-rpmfusion-nonfree-fedora-2020` | `79BDB88F9BBF73910FD4095B6A2AF96194843C65` | `key_fingerprint_matches rpmfusion-nonfree.asc 79BDB88F9BBF73910FD4095B6A2AF96194843C65` | +| lionheartp/Hyprland COPR | `https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/pubkey.gpg` | `97E23476C89635135407C7D5E9BA41342C4B2995` | `key_fingerprint_matches hyprland-copr.asc 97E23476C89635135407C7D5E9BA41342C4B2995` | +| Flathub | `https://flathub.org/repo/flathub.flatpakrepo` | `6E5C05D979C76DAF93C081354184DD4D907A7CAE` | `key_fingerprint_matches flathub.asc 6E5C05D979C76DAF93C081354184DD4D907A7CAE` | +| Claude Desktop Extra | `https://patrickjaja.github.io/claude-desktop-extra/gpg-key.asc` | `825A7D15D78BABE45646D5DF382409F597908867` | `key_fingerprint_matches claude-desktop.asc 825A7D15D78BABE45646D5DF382409F597908867` | The retrieval command for every direct key was: @@ -98,16 +105,28 @@ digest, then update the command and this ledger in a second commit. Do not replace a key on an automated update. A key rotation is a reviewed repository change: obtain the new key from the publisher record, independently -confirm its complete primary fingerprint, update the vendored key and -`installers.conf` together, refresh this retrieval record, and add a focused -contract case if the verification behavior changes. Until that review lands, -verification fails closed and preserves any known-good destination. +confirm its complete primary fingerprint, and update every independent pin site +in one review: -## Container-only Terra 44 signed-bootstrap proof +- the armored key under `setup/provenance/keys/`; +- its fingerprint in `setup/provenance/installers.conf`; +- the matching `_require_policy_value` literal in + `setup/scripts/install-packages`; +- independent fingerprint expectations and command-log fixtures in + `tests/setup/package-provenance-contract`; +- this retrieval and evidence ledger at `setup/provenance/README.md`. -On 2026-08-27, a single disposable rootless Podman container proved the Terra -bootstrap path without changing the host package database, host keyring, or -host repository files. Podman reported `rootless=true`, `runtime=crun`, and a +Until all sites agree, verification fails closed and preserves any known-good +destination. Add or update a focused contract whenever verification behavior +changes. + +## Historical container-only Terra 44 signed-bootstrap proof + +On 2026-08-27, a single disposable rootless Podman container validated Terra's +then-reviewed signed bootstrap without changing the host package database, +host keyring, or host repository files. This is retained historical publisher +evidence; Panama's runtime installer no longer installs `terra-release`. +Podman reported `rootless=true`, `runtime=crun`, and a user graph root. The fresh image was `registry.fedoraproject.org/fedora@sha256:62f199d1eb34170a7bb2277485676d89c0e91aae4086151c4043062cce51c77c` (`sha256:87d8a4a90c0457689db68624cac1026fb2201cbdc1e99cc5455a8f8876118498`). @@ -115,12 +134,13 @@ The container (`5fc8fa42bb85afb3b57b336ca29b58a32fad50d460583329a4e910cc29fb4d2d had no mounts and was removed automatically after `podman stop`. Before copying the only host file admitted to the container, -`keys/terra44.asc`, this exact host check reported the complete primary -fingerprint `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F`: +`keys/terra44.asc`, this status-preserving host check accepted the complete +primary fingerprint `AE09157A4DE88B497EA1D5D300CDAB43DE226D6F`: ```bash -gpg --batch --with-colons --import-options show-only --import setup/provenance/keys/terra44.asc \ - | awk -F: '$1 == "fpr" { print $10; exit }' +source setup/lib/artifact-provenance +key_fingerprint_matches setup/provenance/keys/terra44.asc \ + AE09157A4DE88B497EA1D5D300CDAB43DE226D6F ``` Its SHA-256 was @@ -144,14 +164,16 @@ podman exec panama-terra-proof-20260827 /bin/bash -lc ' ' ``` -Inside the container the copied and installed key both had the recorded -SHA-256 before and after installation. `terra-release-44-9.noarch` was -installed. Its effective `terra` configuration reported `gpgcheck = 1`, -`pkg_gpgcheck = 1`, and `repo_gpgcheck = 1`; no GPG-bypass option was used. -The package's own `/etc/yum.repos.d/terra.repo` uses its Terra metalink and -`RPM-GPG-KEY-terra44`. That differs from Panama's deliberately staged local -key/base-URL file in `install-packages`, which replaces the release-generated -file only after this verified bootstrap step. +The retained command output records the copied key's SHA-256 and DNF's +successful `terra-release-44-9.noarch` transaction. The command itself pins the +temporary Terra base URL and local staged key and enables package and repository +signature checks. It does not include a separate post-install fingerprint or +effective-repository query, so this ledger makes no independent post-check +claim. Production publishes the reviewed root-staged key/repository pair +directly and commits it only after the effective-repository post-check +succeeds; failure restores the prior pair. Publisher-only package transactions +use a fresh command-line repository identity, the reviewed base URL, and a +newly fingerprint-verified private root key snapshot. Although the command runner returned after 30 seconds while DNF was still loading metadata, Podman's retained event log records the exact command's @@ -165,6 +187,5 @@ podman events --since '2026-08-27T10:55:00-04:00' --until '2026-08-27T11:02:00-0 The first `exec` event, at `timeNano=1787842633591543881`, is the documented key-install and DNF command. Its matching first `exec_died` event, at -`timeNano=1787842671276003275`, records `ContainerExitCode:0`. The -same-container post-check independently confirmed the installed package and -effective signature settings above; no retry or second container was used. +`timeNano=1787842671276003275`, records `ContainerExitCode:0`. No retry or +second container was used, and no stronger post-check evidence is retained. diff --git a/setup/scripts/install-hardware b/setup/scripts/install-hardware index b3ee99c..c3ce6ce 100755 --- a/setup/scripts/install-hardware +++ b/setup/scripts/install-hardware @@ -53,7 +53,12 @@ if [[ "${PANAMA_NVIDIA:-no}" == yes ]]; then warn "Secure Boot question, or disable Secure Boot first." else log "Installing the NVIDIA driver" - if sudo dnf install -y akmod-nvidia xorg-x11-drv-nvidia-cuda; then + if sudo dnf install -y \ + --repo=fedora --repo=updates \ + --repo=rpmfusion-free --repo=rpmfusion-free-updates \ + --repo=rpmfusion-nonfree --repo=rpmfusion-nonfree-updates \ + --from-repo=rpmfusion-nonfree,rpmfusion-nonfree-updates \ + 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. diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index aecfa99..18a9eaa 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -2,6 +2,11 @@ set -euo pipefail +# Defer process-group interrupts until the active scoped transaction has run +# its own rollback and cleanup traps, then leave this package stage unchanged. +trap 'exit 130' INT +trap 'exit 143' TERM + # --- Helper functions --- log() { echo -e "\033[1;34m[INFO]\033[0m $*"; } exists() { command -v "$1" >/dev/null 2>&1; } @@ -71,6 +76,132 @@ PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" # sourcing this file. Normal installer execution always resets it to /etc. PANAMA_SYSTEM_ETC=/etc PANAMA_SYSTEM_FLATPAK_REPO=/var/lib/flatpak/repo +PRIVILEGED_TMPDIR=/var/tmp + +BASE_REPO_ARGS=(--repo=fedora --repo=updates) +RPMFUSION_REPO_ARGS=( + "${BASE_REPO_ARGS[@]}" + --repo=rpmfusion-free --repo=rpmfusion-free-updates + --repo=rpmfusion-nonfree --repo=rpmfusion-nonfree-updates +) +RPMFUSION_FROM_REPOS='fedora,updates,rpmfusion-free,rpmfusion-free-updates,rpmfusion-nonfree,rpmfusion-nonfree-updates' +PACKAGE_REPO_ARGS=( + "${BASE_REPO_ARGS[@]}" --from-repo=fedora,updates +) + +_root_snapshot_directory_is_safe() { + local directory="$1" + [[ "$(dirname -- "$directory")" == "$PRIVILEGED_TMPDIR" \ + && "$(basename -- "$directory")" =~ ^panama-install\.[[:alnum:]]+$ ]] +} + +_remove_root_snapshot() { + local snapshot="$1" directory identity + directory="$(dirname -- "$snapshot")" + _root_snapshot_directory_is_safe "$directory" || return 1 + sudo test -d "$directory" || return 1 + sudo test ! -L "$directory" || return 1 + identity="$(sudo stat -c '%u:%a' -- "$directory")" || return 1 + [[ "$identity" == '0:700' || "$identity" == '0:711' ]] || return 1 + if [[ "$identity" == '0:711' ]]; then + sudo chmod 0700 "$directory" || return 1 + identity="$(sudo stat -c '%u:%a' -- "$directory")" || return 1 + [[ "$identity" == '0:700' ]] || return 1 + fi + if ! sudo rm -rf -- "$directory"; then + printf 'Installer staging cleanup failed. Retained artifact: %s\n' \ + "$directory" >&2 + return 1 + fi +} + +_root_owned_regular_file_is_safe() { + local file="$1" identity owner mode + [[ -f "$file" && ! -L "$file" ]] || return 1 + identity="$(stat -c '%u:%a' -- "$file")" || return 1 + IFS=: read -r owner mode <<<"$identity" + [[ "$owner" == 0 && "$mode" =~ ^[0-7]{3,4}$ ]] || return 1 + (( (8#$mode & 0022) == 0 )) +} + +_stable_file_digest() { + local file="$1" output_name="$2" before after + [[ -f "$file" && ! -L "$file" ]] || return 1 + before="$(sha256sum -- "$file" | awk '{ print $1 }')" || return 1 + after="$(sha256sum -- "$file" | awk '{ print $1 }')" || return 1 + [[ "$before" =~ ^[0-9a-f]{64}$ && "$before" == "$after" ]] || return 1 + printf -v "$output_name" '%s' "$before" +} + +_make_private_directory() { + local output_name="$1" template="$2" candidate + printf -v "$output_name" '%s' '' + candidate="$(mktemp -u -d "$template")" || return 1 + printf -v "$output_name" '%s' "$candidate" + if ! mkdir -m 700 -- "$candidate"; then + printf -v "$output_name" '%s' '' + return 1 + fi +} + +# Copy a digest-attested user file into a private root-owned directory, then +# have the privileged adapter hash the exact immutable copy it will reopen. +# The caller owns the random candidate before privileged creation starts. Its +# already-armed transaction trap can therefore remove a directory even when a +# signal interrupts mkdir, without a command-substitution handoff window. +_stage_root_snapshot() { + local source="$1" expected="$2" name="$3" output_name="$4" + local directory="" snapshot actual identity + printf -v "$output_name" '%s' '' + [[ -f "$source" && ! -L "$source" && "$expected" =~ ^[0-9a-f]{64}$ \ + && "$name" =~ ^[[:alnum:]_.-]+$ ]] || return 1 + directory="$(mktemp -u "$PRIVILEGED_TMPDIR/panama-install.XXXXXX")" || return 1 + _root_snapshot_directory_is_safe "$directory" || return 1 + snapshot="$directory/$name" + printf -v "$output_name" '%s' "$snapshot" + if ! sudo mkdir -m 0700 -- "$directory" \ + || ! sudo test -d "$directory" \ + || ! sudo test ! -L "$directory" \ + || ! identity="$(sudo stat -c '%u:%a' -- "$directory")" \ + || [[ "$identity" != '0:700' ]] \ + || ! sudo chmod 0700 "$directory" \ + || ! sudo install -m 0444 "$source" "$snapshot" \ + || ! actual="$(sudo sha256sum -- "$snapshot" | awk '{ print $1 }')" \ + || [[ "$actual" != "$expected" ]]; then + if _remove_root_snapshot "$snapshot" >/dev/null 2>&1; then + printf -v "$output_name" '%s' '' + fi + return 1 + fi +} + +# Signed installer inputs are public, so their root-owned snapshot can be made +# traversable just long enough for unprivileged GPG/rpmkeys to read it. The +# file itself remains root-owned and read-only; after verification the random +# directory is private again before any privileged consumer reopens it. +_review_root_snapshots() { + local snapshot status=0 hide_status=0 + local -a snapshots=() + while (( $# > 0 )) && [[ "$1" != -- ]]; do + snapshots+=("$1") + shift + done + (( ${#snapshots[@]} > 0 && $# > 1 )) || return 1 + shift + for snapshot in "${snapshots[@]}"; do + _root_snapshot_directory_is_safe "$(dirname -- "$snapshot")" \ + && sudo chmod 0711 "$(dirname -- "$snapshot")" || { status=1; break; } + done + if (( status == 0 )); then + "$@" || status=$? + fi + for snapshot in "${snapshots[@]}"; do + _root_snapshot_directory_is_safe "$(dirname -- "$snapshot")" || continue + sudo chmod 0700 "$(dirname -- "$snapshot")" || hide_status=$? + done + (( hide_status == 0 )) || return 1 + return "$status" +} # Reviewed installer data and verification primitives. The config parser treats # every value as inert data and rejects unknown, duplicate, or missing fields. @@ -99,7 +230,8 @@ install_list() { log "Installing $label Packages" echo -e "Includes the following packages:" echo -e "$(<"$file")" - sudo dnf install -y --skip-unavailable $packages > /dev/null + sudo dnf install -y "${PACKAGE_REPO_ARGS[@]}" \ + --skip-unavailable $packages > /dev/null report_missing "$file" log "$label packages installed!" else @@ -190,7 +322,13 @@ _atomic_symlink() ( trap 'exit 143' TERM directory="$(dirname -- "$destination")" mkdir -p -- "$directory" || return 1 - temporary="$(mktemp "$directory/.$(basename -- "$destination").link.XXXXXX")" || return 1 + temporary="$(mktemp -u "$directory/.$(basename -- "$destination").link.XXXXXX")" \ + || return 1 + umask 077 + if ! (set -o noclobber; : >"$temporary") 2>/dev/null; then + temporary="" + return 1 + fi rm -f -- "$temporary" || return 1 ln -s -- "$target" "$temporary" || return 1 if ! mv -Tf -- "$temporary" "$destination"; then @@ -263,9 +401,8 @@ _install_node() ( return 0 fi mkdir -p -- "$parent" || return 1 - stage="$(mktemp -d "$parent/.v${INSTALLER_PROVENANCE[NODE_VERSION]}.stage.XXXXXX")" \ - || return 1 - chmod 0700 "$stage" + _make_private_directory stage \ + "$parent/.v${INSTALLER_PROVENANCE[NODE_VERSION]}.stage.XXXXXX" || return 1 archive="$stage/artifact" extract="$stage/extract" mkdir -m 0700 "$extract" || { rm -rf -- "$stage"; return 1; } @@ -323,7 +460,8 @@ setup_node() { install_pnpm() { if require_reviewed_fedora_release \ - && sudo dnf install -y --repo=fedora --repo=updates pnpm >/dev/null; then + && sudo dnf install -y --repo=fedora --repo=updates \ + --from-repo=fedora,updates pnpm >/dev/null; then return 0 fi _record_installer_failure pnpm @@ -358,9 +496,8 @@ _install_bun() ( fi parent="$HOME/.bun/versions" mkdir -p -- "$parent" || return 1 - stage="$(mktemp -d "$parent/.${INSTALLER_PROVENANCE[BUN_VERSION]}.stage.XXXXXX")" \ - || return 1 - chmod 0700 "$stage" + _make_private_directory stage \ + "$parent/.${INSTALLER_PROVENANCE[BUN_VERSION]}.stage.XXXXXX" || return 1 archive="$stage/artifact" if ! download_sha256 "${INSTALLER_PROVENANCE[BUN_${artifact_arch}_URL]}" \ "${INSTALLER_PROVENANCE[BUN_${artifact_arch}_SHA256]}" \ @@ -436,9 +573,8 @@ _install_codex() ( fi parent="$HOME/.local/lib/panama/codex" mkdir -p -- "$parent" || return 1 - stage="$(mktemp -d "$parent/.${INSTALLER_PROVENANCE[CODEX_VERSION]}.stage.XXXXXX")" \ - || return 1 - chmod 0700 "$stage" + _make_private_directory stage \ + "$parent/.${INSTALLER_PROVENANCE[CODEX_VERSION]}.stage.XXXXXX" || return 1 archive="$stage/artifact" if ! download_sha256 "${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_URL]}" \ "${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_SHA256]}" \ @@ -483,9 +619,13 @@ install_codex() { } _install_rustdesk() ( - local artifact_arch installed_version="" work rpm_path status=0 + local artifact_arch installed_version="" work rpm_path root_rpm="" status=0 work="" - trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT + cleanup_rustdesk() { + [[ -z "$root_rpm" ]] || _remove_root_snapshot "$root_rpm" || true + [[ -z "$work" ]] || rm -rf -- "$work" + } + trap cleanup_rustdesk EXIT trap 'exit 130' INT trap 'exit 143' TERM _set_artifact_arch || return 1 @@ -497,8 +637,7 @@ _install_rustdesk() ( if [[ "$installed_version" == "${INSTALLER_PROVENANCE[RUSTDESK_VERSION]}" ]]; then return 0 fi - work="$(mktemp -d)" || return 1 - chmod 0700 "$work" + _make_private_directory work "${TMPDIR:-/tmp}/panama-rustdesk.XXXXXX" || return 1 rpm_path="$work/rustdesk.rpm" if ! download_sha256 "${INSTALLER_PROVENANCE[RUSTDESK_X86_64_URL]}" \ "${INSTALLER_PROVENANCE[RUSTDESK_X86_64_SHA256]}" \ @@ -507,9 +646,19 @@ _install_rustdesk() ( return 1 fi # RustDesk 1.4.9's reviewed RPM is unsigned. Its exact SHA-256 is the trust - # assertion; this exception applies only to the verified private local file - # and does not change signature policy for any repository. - sudo dnf install -y --setopt=localpkg_gpgcheck=0 "$rpm_path" >/dev/null || status=$? + # assertion. Root rechecks a private snapshot against that reviewed digest, + # and DNF receives only the snapshot rather than reopening user-owned bytes. + if ! _stage_root_snapshot "$rpm_path" \ + "${INSTALLER_PROVENANCE[RUSTDESK_X86_64_SHA256]}" rustdesk.rpm root_rpm; then + return 1 + fi + sudo dnf install -y --repo=fedora --repo=updates \ + --setopt=localpkg_gpgcheck=0 "$root_rpm" >/dev/null || status=$? + if _remove_root_snapshot "$root_rpm"; then + root_rpm="" + else + status=1 + fi rm -rf -- "$work" work="" return "$status" @@ -573,7 +722,11 @@ _download_bounded() { trap 'exit 143' TERM [[ "$max_bytes" =~ ^[1-9][0-9]*$ && -d "$directory" ]] || exit 1 umask 077 - part="$(mktemp "$directory/.${filename}.part.XXXXXX")" || exit 1 + part="$(mktemp -u "$directory/.${filename}.part.XXXXXX")" || exit 1 + if ! (set -o noclobber; : >"$part") 2>/dev/null; then + part="" + exit 1 + fi curl --fail --location --connect-timeout 10 --max-time 600 \ --max-filesize "$max_bytes" --output "$part" "$url" || exit 1 [[ -f "$part" && "$(stat -c %s "$part")" -le "$max_bytes" ]] || exit 1 @@ -582,11 +735,113 @@ _download_bounded() { } _stage_reviewed_key() { - local source_key="$1" destination="$2" fingerprint_name="$3" expected="$4" + local source_key="$1" staged_key="$2" fingerprint_name="$3" expected="$4" + local root_output_name="$5" digest_output_name="$6" + local source_digest root_snapshot _require_policy_value "$fingerprint_name" "$expected" || return 1 - cp -- "$source_key" "$destination" || return 1 - chmod 0600 "$destination" - key_fingerprint_matches "$destination" "${INSTALLER_PROVENANCE[$fingerprint_name]}" + cp -- "$source_key" "$staged_key" || return 1 + chmod 0600 "$staged_key" || return 1 + _stable_file_digest "$staged_key" source_digest || return 1 + _stage_root_snapshot "$staged_key" "$source_digest" \ + "$(basename -- "$staged_key")" "$root_output_name" || return 1 + root_snapshot="${!root_output_name}" + printf -v "$digest_output_name" '%s' "$source_digest" + if ! _review_root_snapshots "$root_snapshot" -- key_fingerprint_matches \ + "$root_snapshot" "${INSTALLER_PROVENANCE[$fingerprint_name]}"; then + if _remove_root_snapshot "$root_snapshot" >/dev/null 2>&1; then + printf -v "$root_output_name" '%s' '' + fi + return 1 + fi +} + +_stage_signed_rpm() { + local package="$1" key="$2" fingerprint="$3" package_name="$4" + local package_output_name="$5" key_output_name="$6" + local package_digest key_digest staged_key root_package="" root_key="" + staged_key="$(dirname -- "$package")/$(basename -- "$key")" + cp -- "$key" "$staged_key" || return 1 + chmod 0600 "$staged_key" || return 1 + _stable_file_digest "$package" package_digest || return 1 + _stable_file_digest "$staged_key" key_digest || return 1 + _stage_root_snapshot "$package" "$package_digest" "$package_name" \ + "$package_output_name" || return 1 + root_package="${!package_output_name}" + _stage_root_snapshot "$staged_key" "$key_digest" \ + "$(basename -- "$staged_key")" "$key_output_name" || { + if _remove_root_snapshot "$root_package" >/dev/null 2>&1; then + printf -v "$package_output_name" '%s' '' + fi + return 1 + } + root_key="${!key_output_name}" + if ! _review_root_snapshots "$root_package" "$root_key" -- \ + rpm_signature_matches "$root_package" "$root_key" "$fingerprint"; then + if _remove_root_snapshot "$root_package" >/dev/null 2>&1; then + printf -v "$package_output_name" '%s' '' + fi + if _remove_root_snapshot "$root_key" >/dev/null 2>&1; then + printf -v "$key_output_name" '%s' '' + fi + return 1 + fi +} + +# Use a fresh repository identity and command-line trust settings so an +# ambient file or DNF override for the installed convenience repo cannot +# redirect the package transaction. The key DNF consumes is the same reviewed +# root snapshot verified immediately before this call. +_install_bound_repo_packages() ( + local repo_id="$1" baseurl_name="$2" expected_baseurl="$3" source_key="$4" + local fingerprint_name="$5" expected_fingerprint="$6" repo_gpgcheck="$7" + local work="" staged_key root_key="" key_digest status=0 + local -a options=() + shift 7 + while (( $# > 0 )) && [[ "$1" != -- ]]; do + options+=("$1") + shift + done + (( $# > 1 )) || return 1 + shift + [[ "$repo_id" =~ ^panama-bound-[a-z0-9-]+$ \ + && "$repo_gpgcheck" =~ ^[01]$ ]] || return 1 + _require_policy_value "$baseurl_name" "$expected_baseurl" || return 1 + trap '[[ -z "$root_key" ]] || _remove_root_snapshot "$root_key" || true; [[ -z "$work" ]] || rm -rf -- "$work"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + _make_private_directory work "${TMPDIR:-/tmp}/panama-repo-install.XXXXXX" \ + || return 1 + staged_key="$work/$repo_id.asc" + _stage_reviewed_key "$source_key" "$staged_key" "$fingerprint_name" \ + "$expected_fingerprint" root_key key_digest || return 1 + sudo dnf install -y \ + --repofrompath "$repo_id,${INSTALLER_PROVENANCE[$baseurl_name]}" \ + --repo="$repo_id" --repo=fedora --repo=updates --from-repo="$repo_id" \ + --setopt="$repo_id.gpgcheck=1" \ + --setopt="$repo_id.repo_gpgcheck=$repo_gpgcheck" \ + --setopt="$repo_id.gpgkey=file://$root_key" \ + "${options[@]}" "$@" || status=$? + if _remove_root_snapshot "$root_key"; then + root_key="" + else + status=1 + fi + rm -rf -- "$work" + work="" + return "$status" +) + +# Repository text is authored by this script. Hash the same argument stream +# independently of the user-owned pathname so a swap before root staging +# cannot redefine the bytes that privileged publication expects. +_write_private_text_digest() { + local destination="$1" output_name="$2" digest + shift 2 + printf '%s\n' "$@" > "$destination" || return 1 + digest="$(printf '%s\n' "$@" | sha256sum | awk '{ print $1 }')" || return 1 + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + chmod 0600 "$destination" || return 1 + printf -v "$output_name" '%s' "$digest" } _ini_value() { @@ -677,48 +932,97 @@ _restore_repository_file() { fi } +_root_backup_repository_file() { + local source="$1" backup="$2" before after + before="$(sudo sha256sum -- "$source" | awk '{ print $1 }')" || return 1 + [[ "$before" =~ ^[0-9a-f]{64}$ ]] || return 1 + sudo install -m 0600 "$source" "$backup" || return 1 + after="$(sudo sha256sum -- "$backup" | awk '{ print $1 }')" || return 1 + [[ "$before" == "$after" ]] +} + +_finish_repository_transaction() { + local original_status=$? rollback_status=0 cleanup_status=0 + # Once rollback starts, a repeated Ctrl-C/TERM must not interrupt restoration + # between the two files. Ignored dispositions are inherited by the restore + # commands, then disappear with this transactional subshell. + trap '' INT TERM + if (( mutation_started && ! transaction_committed )); then + _restore_repository_file "$repo_existed" "$repo_backup" "$repo_mode" \ + "$repo_destination" || rollback_status=$? + _restore_repository_file "$key_existed" "$key_backup" "$key_mode" \ + "$key_destination" || rollback_status=$? + fi + if (( rollback_status != 0 )); then + log "Repository rollback failed; recovery evidence retained at $(dirname -- "$root_key") and $(dirname -- "$root_repo")" + original_status="$TERRA_TRUST_FAILURE_STATUS" + else + [[ -z "$root_key" ]] || _remove_root_snapshot "$root_key" || cleanup_status=$? + [[ -z "$root_repo" ]] || _remove_root_snapshot "$root_repo" || cleanup_status=$? + (( cleanup_status == 0 )) || original_status=1 + fi + trap - EXIT + exit "$original_status" +} + # A key and its repository file form one trust root. If either activation # write fails after touching its target, restore both prior files or return both # targets to absence before reporting failure. -_publish_repository_pair() { - local staged_key="$1" key_destination="$2" staged_repo="$3" repo_destination="$4" - local backup_dir key_backup repo_backup key_mode=0644 repo_mode=0644 +_publish_repository_pair() ( + local root_key="$1" key_destination="$2" staged_repo="$3" repo_destination="$4" + local key_digest="$5" repo_digest="$6" + local before_repo_hook="${7:-:}" after_repo_hook="${8:-:}" + local root_repo="" backup_dir key_backup repo_backup actual_key_digest + local key_mode=0644 repo_mode=0644 local key_current repo_current - local key_existed=0 repo_existed=0 status=0 rollback_status=0 + local key_existed=0 repo_existed=0 status=0 + local mutation_started=0 transaction_committed=0 + trap _finish_repository_transaction EXIT + trap 'exit 130' INT + trap 'exit 143' TERM - [[ "$key_destination" == /etc/* && "$repo_destination" == /etc/* ]] || return 1 + [[ "$key_destination" == /etc/* && "$repo_destination" == /etc/* \ + && "$key_digest" =~ ^[0-9a-f]{64}$ \ + && "$repo_digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + _root_snapshot_directory_is_safe "$(dirname -- "$root_key")" || return 1 + actual_key_digest="$(sudo sha256sum -- "$root_key" | awk '{ print $1 }')" \ + || return 1 + [[ "$actual_key_digest" == "$key_digest" ]] || return 1 + _stage_root_snapshot "$staged_repo" "$repo_digest" \ + "$(basename -- "$staged_repo")" root_repo || return 1 key_current="$PANAMA_SYSTEM_ETC${key_destination#/etc}" repo_current="$PANAMA_SYSTEM_ETC${repo_destination#/etc}" [[ ! -L "$key_current" && ! -L "$repo_current" ]] || return 1 - backup_dir="$(dirname -- "$staged_key")" + backup_dir="$(dirname -- "$root_key")" key_backup="$backup_dir/prior-key" repo_backup="$backup_dir/prior-repo" if [[ -e "$key_current" ]]; then [[ -f "$key_current" ]] || return 1 - cp -- "$key_current" "$key_backup" || return 1 key_mode="$(stat -c %a "$key_current")" || return 1 + _root_backup_repository_file "$key_destination" "$key_backup" || return 1 key_existed=1 fi if [[ -e "$repo_current" ]]; then [[ -f "$repo_current" ]] || return 1 - cp -- "$repo_current" "$repo_backup" || return 1 repo_mode="$(stat -c %a "$repo_current")" || return 1 + _root_backup_repository_file "$repo_destination" "$repo_backup" || return 1 repo_existed=1 fi - sudo install -m 0644 "$staged_key" "$key_destination" || status=$? + mutation_started=1 + sudo install -m 0644 "$root_key" "$key_destination" || status=$? if (( status == 0 )); then - sudo install -m 0644 "$staged_repo" "$repo_destination" || status=$? + "$before_repo_hook" || status=$? fi - (( status == 0 )) && return 0 - - _restore_repository_file "$repo_existed" "$repo_backup" "$repo_mode" "$repo_destination" \ - || rollback_status=$? - _restore_repository_file "$key_existed" "$key_backup" "$key_mode" "$key_destination" \ - || rollback_status=$? - (( rollback_status == 0 )) || log "Repository activation rollback did not complete" - return "$status" -} + if (( status == 0 )); then + sudo install -m 0644 "$root_repo" "$repo_destination" || status=$? + fi + if (( status == 0 )); then + "$after_repo_hook" || status=$? + fi + (( status == 0 )) || return "$status" + transaction_committed=1 +) _effective_terra_key() { awk -v reviewed_baseurl="${INSTALLER_PROVENANCE[TERRA_BASEURL]}" ' @@ -805,13 +1109,15 @@ _effective_terra_key() { # Status 0 is one trusted effective Terra identity, 1 is no enabled Terra # identity, and 2 is an unsafe, duplicated, or unreadable effective state. _terra_effective_status() { - local dump gpgkey parse_status=0 local_key + local dump gpgkey parse_status=0 local_key repo_file dump="$(LC_ALL=C dnf --quiet --no-plugins --dump-repo-config='*')" || return 2 gpgkey="$(printf '%s\n' "$dump" | _effective_terra_key)" || 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" - [[ -f "$local_key" ]] || return 2 + repo_file="$PANAMA_SYSTEM_ETC/yum.repos.d/terra.repo" + _root_owned_regular_file_is_safe "$repo_file" || return 2 + _root_owned_regular_file_is_safe "$local_key" || return 2 key_fingerprint_matches "$PANAMA_PATH/setup/provenance/keys/terra44.asc" \ "${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" \ && key_fingerprint_matches "$local_key" \ @@ -840,7 +1146,8 @@ _flathub_remote_status() { local config section_count url gpg_verify summary_verify disabled disabled_status local alternate_key_count config="$PANAMA_SYSTEM_FLATPAK_REPO/config" - [[ -f "$config" ]] || return 1 + [[ -e "$config" || -L "$config" ]] || return 1 + _root_owned_regular_file_is_safe "$config" || return 2 section_count="$(_ini_section_count "$config" 'remote "flathub"')" || return 2 (( section_count > 0 )) || return 1 (( section_count == 1 )) || return 2 @@ -862,13 +1169,25 @@ _flathub_remote_status() { # The reviewed default keyring is the only permitted trust source. Empty, # duplicate, malformed, and nonempty alternate paths all fail closed. (( alternate_key_count == 0 )) || return 2 - [[ -f "$PANAMA_SYSTEM_FLATPAK_REPO/flathub.trustedkeys.gpg" ]] || return 2 + _root_owned_regular_file_is_safe \ + "$PANAMA_SYSTEM_FLATPAK_REPO/flathub.trustedkeys.gpg" || return 2 key_fingerprint_matches "$PANAMA_SYSTEM_FLATPAK_REPO/flathub.trustedkeys.gpg" \ "${INSTALLER_PROVENANCE[FLATHUB_FINGERPRINT]}" || return 2 } -install_rpmfusion_repositories() { - local work free_rpm nonfree_rpm +install_rpmfusion_repositories() ( + local work="" free_rpm nonfree_rpm + local root_free="" root_nonfree="" root_free_key="" root_nonfree_key="" status=0 + cleanup_rpmfusion() { + [[ -z "$root_free" ]] || _remove_root_snapshot "$root_free" || true + [[ -z "$root_nonfree" ]] || _remove_root_snapshot "$root_nonfree" || true + [[ -z "$root_free_key" ]] || _remove_root_snapshot "$root_free_key" || true + [[ -z "$root_nonfree_key" ]] || _remove_root_snapshot "$root_nonfree_key" || true + [[ -z "$work" ]] || rm -rf -- "$work" + } + trap cleanup_rpmfusion EXIT + trap 'exit 130' INT + trap 'exit 143' TERM require_reviewed_fedora_release || return 1 _require_policy_value RPMFUSION_FREE_RELEASE_URL \ 'https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-44.noarch.rpm' || return 1 @@ -879,31 +1198,59 @@ install_rpmfusion_repositories() { _require_policy_value RPMFUSION_FREE_FINGERPRINT E9A491A3DE247814E7E067EAE06F8ECDD651FF2E || return 1 _require_policy_value RPMFUSION_NONFREE_FINGERPRINT 79BDB88F9BBF73910FD4095B6A2AF96194843C65 || return 1 - work="$(mktemp -d)" || return 1 - chmod 0700 "$work" + _make_private_directory work "${TMPDIR:-/tmp}/panama-rpmfusion.XXXXXX" || return 1 free_rpm="$work/rpmfusion-free-release.rpm" nonfree_rpm="$work/rpmfusion-nonfree-release.rpm" if ! _download_bounded "${INSTALLER_PROVENANCE[RPMFUSION_FREE_RELEASE_URL]}" \ - "${INSTALLER_PROVENANCE[RPMFUSION_FREE_RELEASE_MAX_BYTES]}" "$free_rpm" \ - || ! rpm_signature_matches "$free_rpm" \ - "$PANAMA_PATH/setup/provenance/keys/rpmfusion-free.asc" \ - "${INSTALLER_PROVENANCE[RPMFUSION_FREE_FINGERPRINT]}" \ - || ! _download_bounded "${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_RELEASE_URL]}" \ - "${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_RELEASE_MAX_BYTES]}" "$nonfree_rpm" \ - || ! rpm_signature_matches "$nonfree_rpm" \ - "$PANAMA_PATH/setup/provenance/keys/rpmfusion-nonfree.asc" \ - "${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_FINGERPRINT]}"; then + "${INSTALLER_PROVENANCE[RPMFUSION_FREE_RELEASE_MAX_BYTES]}" "$free_rpm"; then rm -rf -- "$work" + work="" return 1 fi - local status=0 - sudo dnf install -y --setopt=localpkg_gpgcheck=1 "$free_rpm" "$nonfree_rpm" || status=$? + _stage_signed_rpm "$free_rpm" \ + "$PANAMA_PATH/setup/provenance/keys/rpmfusion-free.asc" \ + "${INSTALLER_PROVENANCE[RPMFUSION_FREE_FINGERPRINT]}" \ + rpmfusion-free-release.rpm root_free root_free_key || return 1 + if ! _download_bounded "${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_RELEASE_URL]}" \ + "${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_RELEASE_MAX_BYTES]}" "$nonfree_rpm"; then + return 1 + fi + _stage_signed_rpm "$nonfree_rpm" \ + "$PANAMA_PATH/setup/provenance/keys/rpmfusion-nonfree.asc" \ + "${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_FINGERPRINT]}" \ + rpmfusion-nonfree-release.rpm root_nonfree root_nonfree_key || return 1 + # DNF verifies local RPMs with the system RPM keyring. A fresh Fedora host + # does not have the RPM Fusion keys until these release RPMs install, so + # import the already fingerprint-verified root snapshots before asking DNF + # to repeat the signature check. + sudo rpm --import "$root_free_key" || status=$? + if (( status == 0 )); then + sudo rpm --import "$root_nonfree_key" || status=$? + fi + if (( status == 0 )); then + sudo dnf install -y --repo=fedora --repo=updates --setopt=localpkg_gpgcheck=1 \ + "$root_free" "$root_nonfree" || status=$? + fi + if _remove_root_snapshot "$root_free"; then root_free=""; else status=1; fi + if _remove_root_snapshot "$root_nonfree"; then root_nonfree=""; else status=1; fi + if _remove_root_snapshot "$root_free_key"; then root_free_key=""; else status=1; fi + if _remove_root_snapshot "$root_nonfree_key"; then root_nonfree_key=""; else status=1; fi rm -rf -- "$work" + work="" return "$status" +) + +_verify_reviewed_terra_repository() { + local status=0 + _terra_effective_status || status=$? + (( status == 0 )) } -install_terra_repository() { - local work staged_key staged_repo status effective_status=0 +install_terra_repository() ( + local work="" root_key="" staged_key staged_repo key_digest repo_digest status effective_status=0 + trap '[[ -z "$root_key" ]] || _remove_root_snapshot "$root_key" || true; [[ -z "$work" ]] || rm -rf -- "$work"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM 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 @@ -915,86 +1262,84 @@ install_terra_repository() { log "Effective Terra repository configuration is not trusted" return "$TERRA_TRUST_FAILURE_STATUS" fi - if rpm -q terra-release >/dev/null 2>&1; then - log "terra-release is installed without one trusted enabled Terra repository" - return "$TERRA_TRUST_FAILURE_STATUS" - fi - work="$(mktemp -d)" || return 1 - chmod 0700 "$work" + _make_private_directory work "${TMPDIR:-/tmp}/panama-terra.XXXXXX" || return 1 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 + if ! _stage_reviewed_key \ + "$PANAMA_PATH/setup/provenance/keys/terra44.asc" "$staged_key" \ + TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F \ + root_key key_digest; then rm -rf -- "$work" return 1 fi - sudo install -m 0644 "$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama || { - rm -rf -- "$work" - return 1 - } - printf '%s\n' \ + _write_private_text_digest "$staged_repo" repo_digest \ '[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" + 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' || return 1 status=0 - sudo dnf install -y \ - --repofrompath "terra,${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \ - --setopt=terra.pkg_gpgcheck=1 \ - --setopt=terra.repo_gpgcheck=1 \ - --setopt=terra.gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \ - terra-release || status=$? - if (( status == 0 )); then - sudo install -m 0644 "$staged_repo" /etc/yum.repos.d/terra.repo || status=$? - fi - if (( status == 0 )); then - effective_status=0 - _terra_effective_status || effective_status=$? - (( effective_status == 0 )) || status="$TERRA_TRUST_FAILURE_STATUS" - fi + _publish_repository_pair \ + "$root_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \ + "$staged_repo" /etc/yum.repos.d/terra.repo \ + "$key_digest" "$repo_digest" \ + : _verify_reviewed_terra_repository || status=$? rm -rf -- "$work" - return "$status" -} + work="" + (( status == 0 )) || return "$TERRA_TRUST_FAILURE_STATUS" +) -configure_hyprland_repository() { - local work staged_key staged_repo status - require_reviewed_fedora_release || return 1 +configure_hyprland_repository() ( + local work="" root_key="" staged_key staged_repo key_digest repo_digest status + trap '[[ -z "$root_key" ]] || _remove_root_snapshot "$root_key" || true; [[ -z "$work" ]] || rm -rf -- "$work"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + require_reviewed_fedora_release || return 1 _require_policy_value HYPRLAND_COPR_BASEURL \ 'https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/' \ || return 1 - work="$(mktemp -d)" || return 1 - chmod 0700 "$work" + _make_private_directory work "${TMPDIR:-/tmp}/panama-hyprland.XXXXXX" || return 1 staged_key="$work/hyprland-copr.asc" staged_repo="$work/panama-hyprland.repo" - if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/hyprland-copr.asc" "$staged_key" \ - HYPRLAND_COPR_FINGERPRINT 97E23476C89635135407C7D5E9BA41342C4B2995; then + if ! _stage_reviewed_key \ + "$PANAMA_PATH/setup/provenance/keys/hyprland-copr.asc" "$staged_key" \ + HYPRLAND_COPR_FINGERPRINT 97E23476C89635135407C7D5E9BA41342C4B2995 \ + root_key key_digest; then rm -rf -- "$work" return 1 fi - printf '%s\n' \ + _write_private_text_digest "$staged_repo" repo_digest \ '[panama-hyprland]' \ 'name=Panama reviewed Hyprland COPR' \ "baseurl=${INSTALLER_PROVENANCE[HYPRLAND_COPR_BASEURL]}" \ 'enabled=1' \ 'gpgcheck=1' \ 'repo_gpgcheck=0' \ - 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland' > "$staged_repo" - chmod 0600 "$staged_repo" + 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland' || return 1 status=0 _publish_repository_pair \ - "$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland \ - "$staged_repo" /etc/yum.repos.d/panama-hyprland.repo || status=$? + "$root_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland \ + "$staged_repo" /etc/yum.repos.d/panama-hyprland.repo \ + "$key_digest" "$repo_digest" || status=$? rm -rf -- "$work" + work="" return "$status" -} +) -ensure_flathub_remote() { +ensure_flathub_remote() ( local work descriptor encoded key_file url no_gpg_verify gpg_verify local alternate_key_count status remote_status no_gpg_status gpg_status + local key_digest root_key="" + work="" + cleanup_flathub() { + [[ -z "$root_key" ]] || _remove_root_snapshot "$root_key" || true + [[ -z "$work" ]] || rm -rf -- "$work" + } + trap cleanup_flathub EXIT + trap 'exit 130' INT + trap 'exit 143' TERM require_reviewed_fedora_release || return 1 _require_policy_value FLATHUB_DESCRIPTOR_URL 'https://flathub.org/repo/flathub.flatpakrepo' || return 1 _require_policy_value FLATHUB_DESCRIPTOR_MAX_BYTES 1048576 || return 1 @@ -1007,8 +1352,7 @@ ensure_flathub_remote() { log "Existing Flathub remote does not match Panama's reviewed trust policy" return 1 fi - work="$(mktemp -d)" || return 1 - chmod 0700 "$work" + _make_private_directory work "${TMPDIR:-/tmp}/panama-flathub.XXXXXX" || return 1 descriptor="$work/flathub.flatpakrepo" key_file="$work/flathub-key.asc" if ! _download_bounded "${INSTALLER_PROVENANCE[FLATHUB_DESCRIPTOR_URL]}" \ @@ -1043,95 +1387,270 @@ ensure_flathub_remote() { rm -rf -- "$work" return 1 fi - if ! key_fingerprint_matches "$key_file" "${INSTALLER_PROVENANCE[FLATHUB_FINGERPRINT]}"; then + if ! _stable_file_digest "$key_file" key_digest \ + || ! _stage_root_snapshot "$key_file" "$key_digest" flathub-key.asc root_key \ + || ! _review_root_snapshots "$root_key" -- key_fingerprint_matches \ + "$root_key" "${INSTALLER_PROVENANCE[FLATHUB_FINGERPRINT]}"; then rm -rf -- "$work" + work="" return 1 fi status=0 - sudo flatpak remote-add --if-not-exists --gpg-import="$key_file" flathub "$url" \ + sudo flatpak remote-add --if-not-exists --gpg-import="$root_key" flathub "$url" \ || status=$? if (( status == 0 )); then _flathub_remote_status || status=$? fi + if _remove_root_snapshot "$root_key"; then + root_key="" + else + status=1 + fi rm -rf -- "$work" + work="" return "$status" -} +) -_install_claude_code() { - local work staged_key staged_repo status +_install_claude_code() ( + local work="" root_key="" staged_key staged_repo key_digest repo_digest status + trap '[[ -z "$root_key" ]] || _remove_root_snapshot "$root_key" || true; [[ -z "$work" ]] || rm -rf -- "$work"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM if command -v claude >/dev/null 2>&1; then log "Claude Code already installed at \"$(command -v claude)\"" return 0 fi require_reviewed_fedora_release || return 1 _require_policy_value CLAUDE_CODE_BASEURL 'https://downloads.claude.ai/claude-code/rpm/stable' || return 1 - work="$(mktemp -d)" || return 1 - chmod 0700 "$work" + _make_private_directory work "${TMPDIR:-/tmp}/panama-claude-code.XXXXXX" || return 1 staged_key="$work/claude-code.asc" staged_repo="$work/claude-code.repo" - if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/claude-code.asc" "$staged_key" \ - CLAUDE_CODE_FINGERPRINT 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE; then + if ! _stage_reviewed_key \ + "$PANAMA_PATH/setup/provenance/keys/claude-code.asc" "$staged_key" \ + CLAUDE_CODE_FINGERPRINT 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE \ + root_key key_digest; then rm -rf -- "$work" return 1 fi - printf '%s\n' \ + _write_private_text_digest "$staged_repo" repo_digest \ '[claude-code]' \ 'name=Claude Code' \ "baseurl=${INSTALLER_PROVENANCE[CLAUDE_CODE_BASEURL]}" \ 'enabled=1' \ 'gpgcheck=1' \ 'repo_gpgcheck=1' \ - 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama' > "$staged_repo" - chmod 0600 "$staged_repo" + 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama' || return 1 status=0 _publish_repository_pair \ - "$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama \ - "$staged_repo" /etc/yum.repos.d/claude-code.repo || status=$? + "$root_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama \ + "$staged_repo" /etc/yum.repos.d/claude-code.repo \ + "$key_digest" "$repo_digest" || status=$? if (( status == 0 )); then - sudo dnf install -y --repo=claude-code --repo=fedora --repo=updates \ - --from-repo=claude-code claude-code || status=$? + _install_bound_repo_packages panama-bound-claude-code \ + CLAUDE_CODE_BASEURL 'https://downloads.claude.ai/claude-code/rpm/stable' \ + "$PANAMA_PATH/setup/provenance/keys/claude-code.asc" \ + CLAUDE_CODE_FINGERPRINT 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE \ + 1 -- claude-code || status=$? fi rm -rf -- "$work" + work="" return "$status" -} +) install_claude_code() { - _install_claude_code || _record_installer_failure "Claude Code" + local status=0 + _install_claude_code || status=$? + (( status == 0 )) && return 0 + (( status != TERRA_TRUST_FAILURE_STATUS )) \ + || return "$TERRA_TRUST_FAILURE_STATUS" + _record_installer_failure "Claude Code" } _claude_desktop_manual() { log "Claude Desktop is optional; configure its reviewed local-key repository manually to install it" } -install_claude_desktop_if_trusted() { - local repo_file baseurl gpgcheck repo_gpgcheck gpgkey local_key +install_claude_desktop_if_trusted() ( + local repo_file baseurl enabled gpgcheck repo_gpgcheck gpgkey local_key + local work="" staged_key root_key="" key_digest status=0 + cleanup_claude_desktop() { + [[ -z "$root_key" ]] || _remove_root_snapshot "$root_key" || true + [[ -z "$work" ]] || rm -rf -- "$work" + } + trap cleanup_claude_desktop EXIT + trap 'exit 130' INT + trap 'exit 143' TERM require_reviewed_fedora_release || return 1 _require_policy_value CLAUDE_DESKTOP_BASEURL \ 'https://patrickjaja.github.io/claude-desktop-extra/rpm/' || return 1 _require_policy_value CLAUDE_DESKTOP_FINGERPRINT 825A7D15D78BABE45646D5DF382409F597908867 || return 1 repo_file="$PANAMA_SYSTEM_ETC/yum.repos.d/claude-desktop.repo" - if [[ ! -f "$repo_file" ]] \ + local_key="$PANAMA_SYSTEM_ETC/pki/rpm-gpg/RPM-GPG-KEY-claude-desktop-panama" + if ! _root_owned_regular_file_is_safe "$repo_file" \ + || ! _root_owned_regular_file_is_safe "$local_key" \ + || [[ "$(_ini_section_count "$repo_file" claude-desktop)" != 1 ]] \ || ! baseurl="$(_ini_value "$repo_file" claude-desktop baseurl)" \ || [[ "$baseurl" != "${INSTALLER_PROVENANCE[CLAUDE_DESKTOP_BASEURL]}" ]] \ + || ! enabled="$(_ini_value "$repo_file" claude-desktop enabled)" \ + || [[ "$enabled" != 1 ]] \ || ! gpgcheck="$(_ini_value "$repo_file" claude-desktop gpgcheck)" \ || [[ "$gpgcheck" != 1 ]] \ || ! repo_gpgcheck="$(_ini_value "$repo_file" claude-desktop repo_gpgcheck)" \ || [[ "$repo_gpgcheck" != 1 ]] \ || ! gpgkey="$(_ini_value "$repo_file" claude-desktop gpgkey)" \ - || [[ "$gpgkey" != file:///* ]]; then - _claude_desktop_manual - return 0 - fi - local_key="${gpgkey#file://}" - if [[ ! -f "$local_key" ]] \ - || ! key_fingerprint_matches "$PANAMA_PATH/setup/provenance/keys/claude-desktop.asc" \ - "${INSTALLER_PROVENANCE[CLAUDE_DESKTOP_FINGERPRINT]}" \ + || [[ "$gpgkey" != 'file:///etc/pki/rpm-gpg/RPM-GPG-KEY-claude-desktop-panama' ]] \ + || [[ "$(_ini_key_occurrence_count "$repo_file" claude-desktop metalink)" != 0 ]] \ + || [[ "$(_ini_key_occurrence_count "$repo_file" claude-desktop mirrorlist)" != 0 ]] \ || ! key_fingerprint_matches "$local_key" \ "${INSTALLER_PROVENANCE[CLAUDE_DESKTOP_FINGERPRINT]}"; then _claude_desktop_manual return 0 fi - sudo dnf install -y claude-desktop-extra + _make_private_directory work "${TMPDIR:-/tmp}/panama-claude-desktop.XXXXXX" \ + || return 1 + staged_key="$work/claude-desktop.asc" + _stage_reviewed_key \ + "$PANAMA_PATH/setup/provenance/keys/claude-desktop.asc" "$staged_key" \ + CLAUDE_DESKTOP_FINGERPRINT 825A7D15D78BABE45646D5DF382409F597908867 \ + root_key key_digest || return 1 + sudo dnf install -y \ + --repofrompath "panama-claude-desktop,${INSTALLER_PROVENANCE[CLAUDE_DESKTOP_BASEURL]}" \ + --repo=panama-claude-desktop --repo=fedora --repo=updates \ + --from-repo=panama-claude-desktop \ + --setopt=panama-claude-desktop.gpgcheck=1 \ + --setopt=panama-claude-desktop.repo_gpgcheck=1 \ + --setopt="panama-claude-desktop.gpgkey=file://$root_key" \ + claude-desktop-extra || status=$? + if _remove_root_snapshot "$root_key"; then + root_key="" + else + status=1 + fi + rm -rf -- "$work" + work="" + return "$status" +) + +# Agent tools are optional unless repository recovery itself becomes +# indeterminate. Ordinary failures are recorded by each wrapper and allow the +# next tool; status 78 stops before any later downloader or package solver. +install_optional_agent_tools() { + local claude_status=0 + setup_node || true + install_pnpm || true + install_bun || true + install_claude_code || claude_status=$? + (( claude_status != TERRA_TRUST_FAILURE_STATUS )) \ + || return "$TERRA_TRUST_FAILURE_STATUS" + install_codex || true +} + +# Install a mixed desktop list without letting a third-party repository shadow +# an unrelated package. Terra and Cisco names are kept in publisher-specific +# transactions; everything else can resolve only from Fedora and RPM Fusion. +install_desktop_package_file() { + local file="${1:-$PANAMA_PATH/setup/packages/desktop-packages}" package + local openh264_repo_file="$PANAMA_SYSTEM_ETC/yum.repos.d/fedora-cisco-openh264.repo" + local -a general_packages=() terra_packages=() openh264_packages=() + local -a GENERAL_REPO_ARGS=("${RPMFUSION_REPO_ARGS[@]}") + if [[ ! -f "$file" ]]; then + log "Package list was not in specified path: $file" + return 0 + fi + for package in $(packages_in "$file"); do + case "$package" in + mozilla-openh264) + openh264_packages+=("$package") + ;; + cascadiamono-nerd-fonts|espanso-wayland|firamono-nerd-fonts|ghostty|\ + jetbrainsmono-nerd-fonts|nautilus-open-any-terminal|victormono-nerd-fonts) + terra_packages+=("$package") + ;; + *) general_packages+=("$package") ;; + esac + done + log "Installing Desktop Packages" + echo -e "Includes the following packages:" + echo -e "$(<"$file")" + if [[ -f "$openh264_repo_file" && ! -L "$openh264_repo_file" ]]; then + GENERAL_REPO_ARGS+=(--repo=fedora-cisco-openh264) + fi + if (( ${#general_packages[@]} > 0 )); then + sudo dnf install -y "${GENERAL_REPO_ARGS[@]}" \ + --from-repo=fedora,updates --skip-unavailable \ + "${general_packages[@]}" > /dev/null + fi + if (( ${#terra_packages[@]} > 0 )); then + _install_bound_repo_packages panama-bound-terra \ + TERRA_BASEURL 'https://repos.fyralabs.com/terra44' \ + "$PANAMA_PATH/setup/provenance/keys/terra44.asc" \ + TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F \ + 1 --skip-unavailable -- "${terra_packages[@]}" > /dev/null + fi + if (( ${#openh264_packages[@]} > 0 )); then + if [[ -f "$openh264_repo_file" && ! -L "$openh264_repo_file" ]]; then + sudo dnf install -y "${BASE_REPO_ARGS[@]}" \ + --repo=fedora-cisco-openh264 \ + --from-repo=fedora-cisco-openh264 --skip-unavailable \ + "${openh264_packages[@]}" > /dev/null + else + log "WARNING: Fedora Cisco OpenH264 repository is unavailable; skipping ${openh264_packages[*]}" + fi + fi + report_missing "$file" + log "Desktop packages installed!" +} + +# The Hyprland list intentionally mixes Fedora/RPM Fusion tools with the +# reviewed COPR and Terra. Bind every known publisher-only package to its own +# repository so neither third party can replace a base package with the same +# name. +install_hyprland_package_file() { + local file="${1:-$PANAMA_PATH/setup/packages/hyprland-packages}" package + local -a general_packages=() copr_packages=() terra_packages=() + if [[ ! -f "$file" ]]; then + log "Package list was not in specified path: $file" + return 0 + fi + for package in $(packages_in "$file"); do + case "$package" in + gpu-screen-recorder|grimblast|hypridle|hyprland|hyprland-guiutils|\ + hyprland-uwsm|hyprlock|hyprpaper|hyprpicker|hyprpolkitagent|\ + hyprpwcenter|hyprshutdown|hyprsunset|hyprsysteminfo|quickshell|uwsm|\ + xdg-desktop-portal-hyprland) + copr_packages+=("$package") + ;; + helium-browser-bin|mpvpaper|satty|vicinae) + terra_packages+=("$package") + ;; + *) general_packages+=("$package") ;; + esac + done + log "Installing Hyprland desktop packages" + echo -e "Includes the following packages:" + echo -e "$(<"$file")" + if (( ${#general_packages[@]} > 0 )); then + sudo dnf install -y "${RPMFUSION_REPO_ARGS[@]}" \ + --from-repo=fedora,updates \ + --setopt=install_weak_deps=False "${general_packages[@]}" > /dev/null + fi + if (( ${#copr_packages[@]} > 0 )); then + _install_bound_repo_packages panama-bound-hyprland \ + HYPRLAND_COPR_BASEURL \ + 'https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/' \ + "$PANAMA_PATH/setup/provenance/keys/hyprland-copr.asc" \ + HYPRLAND_COPR_FINGERPRINT 97E23476C89635135407C7D5E9BA41342C4B2995 \ + 0 --setopt=install_weak_deps=False -- "${copr_packages[@]}" > /dev/null + fi + if (( ${#terra_packages[@]} > 0 )); then + _install_bound_repo_packages panama-bound-terra \ + TERRA_BASEURL 'https://repos.fyralabs.com/terra44' \ + "$PANAMA_PATH/setup/provenance/keys/terra44.asc" \ + TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F \ + 1 --setopt=install_weak_deps=False -- "${terra_packages[@]}" > /dev/null + fi + report_missing "$file" + log "Hyprland packages installed!" } # --- The server path --------------------------------------------------------- @@ -1157,16 +1676,15 @@ fi if [[ "$ROLE" == server ]]; then echo -e "\n--- Installing packages (server) ---" log "Updating all packages. This may take a while" - sudo dnf update -y --refresh > /dev/null + sudo dnf update -y "${BASE_REPO_ARGS[@]}" --refresh > /dev/null install_list core-packages "Core" install_list server-packages "Server" set +e - setup_node - install_pnpm - install_bun - install_claude_code - install_codex + agent_tools_status=0 + install_optional_agent_tools || agent_tools_status=$? set -e + (( agent_tools_status != TERRA_TRUST_FAILURE_STATUS )) \ + || exit "$TERRA_TRUST_FAILURE_STATUS" report_soft_failures exit 0 fi @@ -1180,24 +1698,23 @@ log "Enabling Fedora Cisco OpenH264 Repository" # 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 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 -# transaction with 'Id is present more than once in the configuration', because -# the throwaway id collides with the one terra-release already installed. -# -# That is what killed a re-run on a machine Terra had already reached: this sits -# in the repository section, above everything, so `set -e` ended the stage -# before a single package was considered. An installer whose second run does -# less than its first is worse than one that never ran. +soft "the core group update" sudo dnf update @core -y "${RPMFUSION_REPO_ARGS[@]}" +soft "the RPM Fusion appstream metadata" sudo dnf install -y \ + "${RPMFUSION_REPO_ARGS[@]}" \ + --from-repo=rpmfusion-free,rpmfusion-free-updates,rpmfusion-nonfree,rpmfusion-nonfree-updates \ + rpmfusion-\*-appstream-data +# Panama publishes Terra's reviewed key and repository file directly as one +# transaction. The terra-release RPM only carries that repository file, while +# adding separate RPM database state that cannot be rolled back with the pair. log "Installing Terra Repository" install_terra_repository > /dev/null +PACKAGE_REPO_ARGS=( + "${RPMFUSION_REPO_ARGS[@]}" --from-repo="$RPMFUSION_FROM_REPOS" +) echo -e "\n--- Installing relevant packages ---" log "Updating all packages. This may take a while" -sudo dnf update -y --refresh > /dev/null +sudo dnf update -y "${RPMFUSION_REPO_ARGS[@]}" --refresh > /dev/null # --- Install the shared core, then the desktop-only lists --- # --skip-unavailable throughout (inside install_list): dnf5 refuses a whole @@ -1206,7 +1723,7 @@ sudo dnf update -y --refresh > /dev/null # skipped names are reported afterwards rather than silently dropped. install_list core-packages "Core" install_list initial-packages "Initial" -install_list desktop-packages "Desktop" +install_desktop_package_file # --- Install the Hyprland desktop --- # @@ -1220,12 +1737,7 @@ HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages" if [[ -f "$HYPR_FILE" ]]; then log "Configuring the reviewed Hyprland repository" configure_hyprland_repository > /dev/null - HYPR_PACKAGES=$(packages_in "$HYPR_FILE") - log "Installing Hyprland desktop packages" - echo -e "Includes the following packages:" - echo -e "$(<"$HYPR_FILE")" - sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null - log "Hyprland packages installed!" + install_hyprland_package_file "$HYPR_FILE" else log "Package list was not in specified path: $HYPR_FILE" fi @@ -1254,20 +1766,25 @@ fi log "Updating core, multimedia, and sound-and-video groups" soft "the multimedia group update" \ - sudo dnf4 groupupdate -y 'core' 'multimedia' 'sound-and-video' \ + sudo dnf4 groupupdate -y "${RPMFUSION_REPO_ARGS[@]}" \ + 'core' 'multimedia' 'sound-and-video' \ --setop='install_weak_deps=False' \ --exclude='PackageKit-gstreamer-plugin' \ --allowerasing sync log "Swapping ffmpeg-free for ffmpeg" -soft "the ffmpeg swap" sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' --allowerasing +soft "the ffmpeg swap" sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' \ + "${RPMFUSION_REPO_ARGS[@]}" --allowerasing log "Swapping mesa-va-drivers for mesa-va-drivers-freeworld" -soft "the mesa driver swap" sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld +soft "the mesa driver swap" sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld \ + "${RPMFUSION_REPO_ARGS[@]}" log "Upgrading Multimedia group with optional packages" -soft "the optional Multimedia upgrade" sudo dnf4 group upgrade -y --with-optional Multimedia +soft "the optional Multimedia upgrade" sudo dnf4 group upgrade -y \ + "${RPMFUSION_REPO_ARGS[@]}" --with-optional Multimedia log "Installing GStreamer plugins (bad, good, base)" soft "the GStreamer plugins" \ - sudo dnf install -y gstreamer1-plugins-{bad-\*,good-\*,base} \ + sudo dnf install -y "${RPMFUSION_REPO_ARGS[@]}" \ + gstreamer1-plugins-{bad-\*,good-\*,base} \ --exclude=gstreamer1-plugins-bad-free-devel # --- Install Development Packages needed for Neovim --- @@ -1277,7 +1794,8 @@ if [[ -f "$DEV_FILE" ]]; then log "Installing Development Packages. Mostly for Neovim." echo -e "Includes the following packages:" echo -e "$(<"$DEV_FILE")" - soft "the development packages" sudo dnf install -y $DEV_PACKAGES + soft "the development packages" sudo dnf install -y \ + "${PACKAGE_REPO_ARGS[@]}" $DEV_PACKAGES log "Development packages installed!" else log "Package list was not in specified path: $DEV_FILE" @@ -1287,7 +1805,13 @@ set +e setup_node install_pnpm install_bun +agent_tools_status=0 install_claude_code +agent_tools_status=$? +if (( agent_tools_status == TERRA_TRUST_FAILURE_STATUS )); then + set -e + exit "$TERRA_TRUST_FAILURE_STATUS" +fi install_codex set -e @@ -1360,7 +1884,8 @@ install_extra_category() { if [[ -n "${dnf_packages// /}" ]]; then log "Installing $name: $dnf_packages" - sudo dnf install -y $dnf_packages > /dev/null || { log "Some $name packages did not install"; softly_failed+=("$name packages"); } + sudo dnf install -y "${PACKAGE_REPO_ARGS[@]}" $dnf_packages \ + > /dev/null || { log "Some $name packages did not install"; softly_failed+=("$name packages"); } fi if [[ -n "${flatpak_ids// /}" ]]; then log "Installing $name flatpaks: $flatpak_ids" diff --git a/setup/scripts/link-vicinae-scripts b/setup/scripts/link-vicinae-scripts index 843ee33..b3f488f 100755 --- a/setup/scripts/link-vicinae-scripts +++ b/setup/scripts/link-vicinae-scripts @@ -6,6 +6,75 @@ set -euo pipefail +_collect_vicinae_inputs() { + local extension="$1" output="$2" + [[ -d "$extension" && ! -L "$extension" \ + && -f "$extension/package.json" && ! -L "$extension/package.json" \ + && -f "$extension/package-lock.json" && ! -L "$extension/package-lock.json" ]] \ + || return 1 + + # Everything authored below the extension affects its build. npm's + # dependency tree is the sole exception and is reproduced from the lock. + find "$extension" -mindepth 1 \ + \( -path "$extension/node_modules" -prune \) -o \ + ! -type d -print0 >"$output" || return 1 + LC_ALL=C sort -z -o "$output" "$output" || return 1 +} + +_write_vicinae_manifest() { + local extension="$1" inputs="$2" output="$3" + local input relative digest + : >"$output" || return 1 + while IFS= read -r -d '' input; do + [[ -f "$input" && ! -L "$input" && -r "$input" ]] || return 1 + relative="${input#"$extension"/}" + [[ "$relative" != "$input" && -n "$relative" ]] || return 1 + digest="$(sha256sum -- "$input" | awk '{ print $1 }')" || return 1 + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\0%s\0' "$relative" "$digest" >>"$output" || return 1 + done <"$inputs" +} + +_vicinae_extension_digest() ( + local extension="${1%/}" work="" + trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + work="$(mktemp -u -d -t panama-vicinae-digest.XXXXXX)" || exit 1 + if ! mkdir -m 700 -- "$work"; then + work="" + exit 1 + fi + + _collect_vicinae_inputs "$extension" "$work/inputs.before" || exit 1 + _write_vicinae_manifest \ + "$extension" "$work/inputs.before" "$work/manifest.before" || exit 1 + _collect_vicinae_inputs "$extension" "$work/inputs.after" || exit 1 + _write_vicinae_manifest \ + "$extension" "$work/inputs.after" "$work/manifest.after" || exit 1 + cmp -s -- "$work/inputs.before" "$work/inputs.after" || exit 1 + cmp -s -- "$work/manifest.before" "$work/manifest.after" || exit 1 + sha256sum -- "$work/manifest.before" | awk '{ print $1 }' +) + +_record_vicinae_digest() ( + local built="$1" digest="$2" receipt temporary="" + trap '[[ -z "$temporary" ]] || rm -f -- "$temporary"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + [[ -d "$built" && ! -L "$built" ]] || exit 1 + receipt="$built/.panama-source-sha256" + temporary="$(mktemp -u "$built/.panama-source-sha256.XXXXXX")" || exit 1 + umask 077 + if ! (set -o noclobber; : >"$temporary") 2>/dev/null; then + temporary="" + exit 1 + fi + printf '%s\n' "$digest" >"$temporary" || exit 1 + mv -f -- "$temporary" "$receipt" || exit 1 + temporary="" +) + panama_path="${PANAMA_PATH:-$HOME/.local/share/Panama}" vicinae_data_dir="${VICINAE_DATA_DIR:-$HOME/.local/share/vicinae}" source_dir="$panama_path/config/local/share/vicinae/scripts" @@ -81,18 +150,36 @@ if [[ -d "$extensions_source" ]] && command -v npm >/dev/null 2>&1; then [[ -f "$extension/package.json" ]] || continue name="$(basename "$extension")" - # Skip a build that would produce what is already there. `npm ci` - # alone takes long enough to be worth not repeating on every re-run of - # a stage that is otherwise nearly instant. + # Skip only when a prior successful build records the digest of both + # manifests and every source byte. Directory mtimes do not change when + # an existing source file is edited. built="$vicinae_data_dir/extensions/$name" - if [[ -d "$built" && "$extension/src" -ot "$built" ]]; then + receipt="$built/.panama-source-sha256" + if ! source_digest="$(_vicinae_extension_digest "$extension")"; then + printf 'Vicinae extension %s inputs could not be verified; skipping\n' \ + "$name" >&2 + continue + fi + if [[ -f "$receipt" && ! -L "$receipt" ]] \ + && cmp -s <(printf '%s\n' "$source_digest") "$receipt"; then printf 'Vicinae extension %s is already built\n' "$name" continue fi printf 'Building Vicinae extension %s\n' "$name" - if ! (cd "$extension" && npm ci --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1); then + if ! (cd "$extension" && npm ci --silent >/dev/null 2>&1 \ + && npm run build >/dev/null 2>&1); then printf 'Vicinae extension %s did not build; skipping\n' "$name" >&2 + continue + fi + if ! final_digest="$(_vicinae_extension_digest "$extension")" \ + || [[ "$final_digest" != "$source_digest" ]]; then + printf 'Vicinae extension %s changed while building; receipt withheld\n' \ + "$name" >&2 + continue + fi + if ! _record_vicinae_digest "$built" "$source_digest"; then + printf 'Vicinae extension %s receipt could not be recorded\n' "$name" >&2 fi done elif [[ -d "$extensions_source" ]]; then diff --git a/tests/setup/boot-contract b/tests/setup/boot-contract index a79459c..f3ae8f1 100755 --- a/tests/setup/boot-contract +++ b/tests/setup/boot-contract @@ -15,6 +15,16 @@ note() { findings+=("$1"); } [[ -x "$boot" ]] || { printf 'boot contract: %s is not executable\n' "$boot" >&2; exit 1; } +# Git is the only package boot can install before the verified checkout exists. +# Both root-server and ordinary-user paths must exclude ambient third-party +# repositories while still allowing Fedora dependencies. +for git_install in \ + 'dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git' \ + 'sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git'; do + grep -qF "$git_install" "$boot" \ + || note "boot omits reviewed Fedora source binding: $git_install" +done + work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT @@ -103,6 +113,16 @@ case "\${1:-}" in [[ "\$#" -eq 4 && "\$4" == 'HEAD^{commit}' ]] || exit 97 cat "$state/head-revision" ;; + ls-tree) + [[ "\$#" -eq 6 && "\$4" == -rz && "\$5" == --full-tree \ + && "\$6" == "$revision" ]] || exit 97 + printf '100755 blob aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tinstall\0' + ;; + hash-object) + [[ "\$#" -eq 6 && "\$4" == --no-filters && "\$5" == -- \ + && "\$6" == install ]] || exit 97 + printf '%s\n' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + ;; *) exit 97 ;; esac ;; @@ -268,6 +288,132 @@ run_boot "$revision" "$boot_sha" (( run_status != 0 )) || note 'existing HEAD mismatch returned success' assert_no_install_or_rewrite 'existing HEAD mismatch' +# Git's porcelain status deliberately trusts index hints. The bootstrap cannot: +# these two flags can hide changed executable bytes while HEAD still names the +# reviewed commit. Exercise real Git so the contract cannot accidentally teach +# its adapter to expose state that Git itself hides. +real_git="$(command -v git)" +hidden_root="$work/hidden-index" +mkdir -p "$hidden_root/home" +"$real_git" init -q "$hidden_root/source" +"$real_git" -C "$hidden_root/source" config user.email contract@panama +"$real_git" -C "$hidden_root/source" config user.name contract +printf '#!/usr/bin/env bash\nexit 0\n' >"$hidden_root/source/install" +chmod +x "$hidden_root/source/install" +printf 'trusted target bytes\n' >"$hidden_root/source/target" +ln -s target "$hidden_root/source/trusted-link" +"$real_git" -C "$hidden_root/source" add install target trusted-link +"$real_git" -C "$hidden_root/source" commit -qm trusted +hidden_revision="$("$real_git" -C "$hidden_root/source" rev-parse HEAD)" +"$real_git" clone -q --bare "$hidden_root/source" "$hidden_root/origin.git" + +# Exercise the exact boundary between checkout preparation and handoff. This +# test-only copy inserts a same-UID replacement after prepare returns; the +# production handoff must perform its complete comparison after that point. +post_prepare_checkout="$hidden_root/post-prepare-swap" +post_prepare_marker="$hidden_root/post-prepare-executed" +post_prepare_hook_marker="$hidden_root/post-prepare-hook-fired" +"$real_git" clone -q "$hidden_root/origin.git" "$post_prepare_checkout" +post_prepare_hook="$hidden_root/swap-install" +cat >"$post_prepare_hook" <<'HOOK' +#!/usr/bin/env bash +: >"$PANAMA_BOOT_POST_PREPARE_HOOK_MARKER" +printf '#!/usr/bin/env bash\nprintf "executed\\n" >%q\n' \ + "$PANAMA_BOOT_POST_PREPARE_MARKER" >"$PANAMA_PATH/install" +chmod +x "$PANAMA_PATH/install" +HOOK +chmod +x "$post_prepare_hook" +hooked_boot="$hidden_root/boot-post-prepare-hook" +awk ' + { + print + if ($0 == "prepare_panama_checkout \"$PANAMA_PATH\"") { + prepare_count++ + if (prepare_count == 1) print "\"$PANAMA_BOOT_POST_PREPARE_FIXTURE\"" + } + } +' "$boot" >"$hooked_boot" +hooked_boot_sha="$(sha256sum "$hooked_boot" | cut -d' ' -f1)" +post_prepare_status=0 +HOME="$hidden_root/home" PANAMA_PATH="$post_prepare_checkout" \ + PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$hooked_boot_sha" \ + PANAMA_BOOT_POST_PREPARE_FIXTURE="$post_prepare_hook" \ + PANAMA_BOOT_POST_PREPARE_MARKER="$post_prepare_marker" \ + PANAMA_BOOT_POST_PREPARE_HOOK_MARKER="$post_prepare_hook_marker" \ + bash "$hooked_boot" "$hidden_root/post-prepare.out" 2>&1 \ + || post_prepare_status=$? +[[ -e "$post_prepare_hook_marker" ]] \ + || note 'post-prepare replacement hook did not exercise the boundary' +(( post_prepare_status != 0 )) \ + || note 'post-prepare worktree replacement returned success' +[[ ! -e "$post_prepare_marker" ]] \ + || note 'post-prepare worktree replacement executed unreviewed install bytes' + +# A valid tracked symlink must compare its link text with Git's 120000 blob; +# hashing the pathname would follow it and hash the target file instead. +symlink_checkout="$hidden_root/tracked-symlink" +"$real_git" clone -q "$hidden_root/origin.git" "$symlink_checkout" +symlink_status=0 +HOME="$hidden_root/home" PANAMA_PATH="$symlink_checkout" \ + PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$boot_sha" \ + bash "$boot" "$hidden_root/tracked-symlink.out" 2>&1 \ + || symlink_status=$? +(( symlink_status == 0 )) \ + || note 'a checkout with a valid tracked symlink was rejected' + +for hidden_flag in assume-unchanged skip-worktree; do + hidden_checkout="$hidden_root/$hidden_flag" + hidden_marker="$hidden_root/$hidden_flag-executed" + "$real_git" clone -q "$hidden_root/origin.git" "$hidden_checkout" + printf '#!/usr/bin/env bash\nprintf "executed\\n" >%q\n' "$hidden_marker" \ + >"$hidden_checkout/install" + chmod +x "$hidden_checkout/install" + "$real_git" -C "$hidden_checkout" update-index "--$hidden_flag" install + [[ -z "$("$real_git" -C "$hidden_checkout" status --porcelain)" ]] \ + || note "$hidden_flag fixture was not hidden from porcelain status" + + hidden_status=0 + HOME="$hidden_root/home" PANAMA_PATH="$hidden_checkout" \ + PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$boot_sha" \ + bash "$boot" "$hidden_root/$hidden_flag.out" 2>&1 \ + || hidden_status=$? + (( hidden_status != 0 )) \ + || note "$hidden_flag modified checkout returned success" + [[ ! -e "$hidden_marker" ]] \ + || note "$hidden_flag modified checkout executed unreviewed install bytes" +done + +# The same hidden-index state must not conceal a mode change or a different +# symlink target; both are part of the reviewed Git tree, not metadata hints. +for hidden_flag in assume-unchanged skip-worktree; do + for hidden_change in mode symlink-target; do + hidden_checkout="$hidden_root/$hidden_flag-$hidden_change" + "$real_git" clone -q "$hidden_root/origin.git" "$hidden_checkout" + case "$hidden_change" in + mode) + chmod -x "$hidden_checkout/install" + hidden_path=install + ;; + symlink-target) + rm -- "$hidden_checkout/trusted-link" + ln -s untrusted-target "$hidden_checkout/trusted-link" + hidden_path=trusted-link + ;; + esac + "$real_git" -C "$hidden_checkout" update-index "--$hidden_flag" "$hidden_path" + [[ -z "$("$real_git" -C "$hidden_checkout" status --porcelain)" ]] \ + || note "$hidden_flag $hidden_change fixture was not hidden from porcelain status" + + hidden_status=0 + HOME="$hidden_root/home" PANAMA_PATH="$hidden_checkout" \ + PANAMA_BOOT_REVISION="$hidden_revision" PANAMA_BOOT_SHA256="$boot_sha" \ + bash "$boot" "$hidden_root/$hidden_flag-$hidden_change.out" 2>&1 \ + || hidden_status=$? + (( hidden_status != 0 )) \ + || note "$hidden_flag concealed a tracked $hidden_change change" + done +done + if (( ${#findings[@]} > 0 )); then printf 'boot contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 diff --git a/tests/setup/desktop-first-contract b/tests/setup/desktop-first-contract index 55cd360..2e7e096 100755 --- a/tests/setup/desktop-first-contract +++ b/tests/setup/desktop-first-contract @@ -99,9 +99,10 @@ sed -n '/^if \[\[ "\$ROLE" == server \]\]; then/,/^fi/p' "$installer" | grep -q # Comments dropped and backslash continuations joined, so a `soft` invocation # wrapped across three lines reads as the one command it is. uncommented() { grep -vE '^\s*#' "$installer" | sed -e :a -e '/\\$/N; s/\\\n\s*/ /; ta'; } +uncommented_installer="$(uncommented)" while read -r command; do - uncommented | grep -q "soft .*$command" \ + grep -q "soft .*$command" <<<"$uncommented_installer" \ || note "'$command' runs without soft, so its failure still ends the stage" done <<'FRAGILE' dnf swap -y 'ffmpeg-free' @@ -129,7 +130,7 @@ if "rpm -q hyprland" not in after or "exit 1" not in after: raise SystemExit(1) PY -uncommented | grep -q 'soft .*HYPR_PACKAGES' \ +grep -q 'soft .*HYPR_PACKAGES' <<<"$uncommented_installer" \ && note 'the Hyprland install is tolerated, so a machine with no desktop reports success' # ── Soft failures are reported ────────────────────────────────────────────── diff --git a/tests/setup/hardware-contract b/tests/setup/hardware-contract index 2d93b16..ba68e64 100755 --- a/tests/setup/hardware-contract +++ b/tests/setup/hardware-contract @@ -103,10 +103,13 @@ fi nvidia="$(run_stage PANAMA_NVIDIA=yes)" -called "$nvidia" 'dnf install -y akmod-nvidia' \ +called "$nvidia" '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' +expected_nvidia='sudo dnf install -y --repo=fedora --repo=updates --repo=rpmfusion-free --repo=rpmfusion-free-updates --repo=rpmfusion-nonfree --repo=rpmfusion-nonfree-updates --from-repo=rpmfusion-nonfree,rpmfusion-nonfree-updates akmod-nvidia xorg-x11-drv-nvidia-cuda' +grep -Fxq -- "$expected_nvidia" <<<"$nvidia" \ + || note 'the NVIDIA transaction is not limited to reviewed Fedora and RPM Fusion repositories' called "$nvidia" 'grubby --update-kernel=ALL' \ || note 'the kernel arguments are never set' called "$nvidia" 'modprobe.blacklist=nouveau' \ diff --git a/tests/setup/launcher-search-contract b/tests/setup/launcher-search-contract index 1b9275e..6ff1a99 100755 --- a/tests/setup/launcher-search-contract +++ b/tests/setup/launcher-search-contract @@ -123,7 +123,7 @@ grep -q '/etc/profile.d/nvm.sh' "$stage" \ || note 'the extension build never sources nvm, so npm is missing on any machine without a system node' # node_modules is a dependency tree, not configuration. -git -C "$repo_dir" check-ignore -q "$extension/node_modules" 2>/dev/null \ +git -C "$repo_dir" check-ignore -q "$extension/node_modules/" 2>/dev/null \ || note 'the extension node_modules is not gitignored' # npm must honour the committed dependency graph. This disposable fixture @@ -167,6 +167,131 @@ stage_output="$(PATH="$fixture_root/bin:$PATH" PANAMA_PATH="$fixture_root" \ cmp -s -- "$lock_before" "$lockfile" \ || note 'a rejected Vicinae lockfile mismatch changed package-lock.json' +# Successful builds carry a digest receipt over both manifests and every +# source file. Directory mtimes do not change when an existing file is edited, +# so each byte class must independently invalidate the build. +digest_root="$fixture_root/digest" +digest_extension="$digest_root/config/local/share/vicinae/extensions/panama-search" +digest_data="$digest_root/vicinae-data" +mkdir -p "$digest_root/config/local/share/vicinae/scripts" \ + "$digest_extension/src" "$digest_extension/assets" "$digest_root/bin" +cp -- "$manifest" "$digest_extension/package.json" +cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/package-lock.json" \ + "$digest_extension/package-lock.json" +cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/src/search.tsx" \ + "$digest_extension/src/search.tsx" +cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/tsconfig.json" \ + "$digest_extension/tsconfig.json" +cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/assets/extension_icon.svg" \ + "$digest_extension/assets/extension_icon.svg" +cat >"$digest_root/bin/npm" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"${NPM_LOG:?}" +case "${1:-}:${2:-}" in + ci:--silent) exit 0 ;; + run:build) + mkdir -p "$VICINAE_DATA_DIR/extensions/$(basename "$PWD")" + printf 'built\n' >"$VICINAE_DATA_DIR/extensions/$(basename "$PWD")/bundle" + ;; + *) exit 64 ;; +esac +EOF +cat >"$digest_root/bin/find" <<'EOF' +#!/usr/bin/env bash +set -uo pipefail +status=0 +/usr/bin/find "$@" || status=$? +[[ "${STUB_FIND_FAIL:-0}" != 1 ]] || exit 74 +exit "$status" +EOF +chmod +x "$digest_root/bin/npm" "$digest_root/bin/find" + +run_digest_stage() { + : >"$digest_root/npm.log" + PATH="$digest_root/bin:$PATH" PANAMA_PATH="$digest_root" \ + VICINAE_DATA_DIR="$digest_data" NPM_LOG="$digest_root/npm.log" \ + STUB_FIND_FAIL="${STUB_FIND_FAIL:-0}" \ + bash "$stage" >"$digest_root/stage.out" 2>&1 +} + +run_digest_stage || note 'the Vicinae digest fixture initial build failed' +cmp -s <(printf 'ci --silent\nrun build\n') "$digest_root/npm.log" \ + || note 'the Vicinae digest fixture did not perform its initial locked build' +run_digest_stage || note 'the unchanged Vicinae digest fixture failed' +[[ ! -s "$digest_root/npm.log" ]] \ + || note 'an unchanged Vicinae extension rebuilt despite its matching receipt' + +for digest_input in src/search.tsx package.json package-lock.json tsconfig.json \ + assets/extension_icon.svg; do + printf '\n// digest mutation: %s\n' "$digest_input" >>"$digest_extension/$digest_input" + run_digest_stage || note "the Vicinae digest fixture failed after changing $digest_input" + cmp -s <(printf 'ci --silent\nrun build\n') "$digest_root/npm.log" \ + || note "changing existing $digest_input bytes did not rebuild the Vicinae extension" +done + +# A traversal can emit valid-looking partial output and still fail. Sorting +# that output must not hide find's producer status or replace the successful +# build receipt with a digest over an incomplete source tree. +digest_receipt="$digest_data/extensions/panama-search/.panama-source-sha256" +cp -- "$digest_receipt" "$digest_root/receipt.before-find-failure" +STUB_FIND_FAIL=1 run_digest_stage \ + || note 'the Vicinae stage made a digest traversal failure fatal' +[[ ! -s "$digest_root/npm.log" ]] \ + || note 'a failed Vicinae digest traversal still rebuilt the extension' +grep -q 'inputs could not be verified; skipping' "$digest_root/stage.out" \ + || note 'a failed Vicinae digest traversal was accepted as verified input' +cmp -s -- "$digest_root/receipt.before-find-failure" "$digest_receipt" \ + || note 'a failed Vicinae digest traversal replaced the successful receipt' + +# Helper writes run in conditional contexts in production, where Bash disables +# implicit errexit inside the whole function. Each producer therefore has to +# return its own write/publication failure and remove its temporary receipt. +vicinae_helpers="$digest_root/vicinae-helpers" +sed '/^panama_path=/,$d' "$stage" >"$vicinae_helpers" +: >"$digest_root/empty-inputs" +mkdir "$digest_root/manifest-output-directory" +manifest_status=0 +bash -c 'source "$1"; set +e; _write_vicinae_manifest "$2" "$3" "$4"' bash \ + "$vicinae_helpers" "$digest_extension" "$digest_root/empty-inputs" \ + "$digest_root/manifest-output-directory" >/dev/null 2>&1 \ + || manifest_status=$? +[[ "$manifest_status" -ne 0 ]] \ + || note 'a failed Vicinae manifest initialization returned success' + +receipt_failure_root="$digest_root/receipt-publication-failure" +mkdir -p "$receipt_failure_root/built" "$receipt_failure_root/bin" +printf 'prior receipt\n' >"$receipt_failure_root/built/.panama-source-sha256" +cat >"$receipt_failure_root/bin/mv" <<'EOF' +#!/usr/bin/env bash +destination="${!#}" +[[ "$destination" != */.panama-source-sha256 ]] || exit 75 +exec /usr/bin/mv "$@" +EOF +chmod +x "$receipt_failure_root/bin/mv" +receipt_status=0 +PATH="$receipt_failure_root/bin:$PATH" bash -c \ + 'source "$1"; set +e; _record_vicinae_digest "$2" "$3"' bash \ + "$vicinae_helpers" "$receipt_failure_root/built" "$(printf 'a%.0s' {1..64})" \ + >/dev/null 2>&1 || receipt_status=$? +[[ "$receipt_status" -ne 0 ]] \ + || note 'a failed Vicinae receipt publication returned success' +cmp -s <(printf 'prior receipt\n') \ + "$receipt_failure_root/built/.panama-source-sha256" \ + || note 'a failed Vicinae receipt publication replaced the prior receipt' +[[ -z "$(find "$receipt_failure_root/built" \ + -name '.panama-source-sha256.*' -print -quit)" ]] \ + || note 'a failed Vicinae receipt publication left a temporary receipt' + +# Prove the directory-only ignore rule in a repository where node_modules does +# not already exist. The trailing slash is part of the query contract. +ignore_root="$fixture_root/ignore-repository" +mkdir -p "$ignore_root/config/local/share/vicinae/extensions/panama-search" +cp -- "$repo_dir/.gitignore" "$ignore_root/.gitignore" +git -C "$ignore_root" init -q +git -C "$ignore_root" check-ignore -q \ + 'config/local/share/vicinae/extensions/panama-search/node_modules/' \ + || note 'a fresh clone with no node_modules directory does not match the ignore rule' + # ── Report ─────────────────────────────────────────────────────────────────── if (( ${#findings[@]} > 0 )); then diff --git a/tests/setup/package-provenance-contract b/tests/setup/package-provenance-contract index c63fc2c..48e9f04 100755 --- a/tests/setup/package-provenance-contract +++ b/tests/setup/package-provenance-contract @@ -7,6 +7,7 @@ set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" fixtures="$repo_dir/tests/setup/fixtures/provenance" config="$repo_dir/setup/provenance/installers.conf" +provenance_readme="$repo_dir/setup/provenance/README.md" test_tmp="$(mktemp -d)" host_gnupg="${GNUPGHOME:-$HOME/.gnupg}" host_rpmdb="/usr/lib/sysimage/rpm/rpmdb.sqlite" @@ -24,6 +25,22 @@ fail() { exit 1 } +# The rotation ledger is part of the trust contract: it must preserve GPG's +# producer status, name every independent pin site, and claim only evidence +# retained from the one authorized Terra container. +if awk '/gpg/ && /[|]/ && /awk/ { found = 1 } END { exit !found }' \ + "$provenance_readme"; then + fail 'provenance README pipes GPG into a parser and can lose producer status' +fi +for rotation_site in 'setup/provenance/keys/' 'setup/provenance/installers.conf' \ + '_require_policy_value' 'tests/setup/package-provenance-contract' \ + 'setup/provenance/README.md'; do + grep -qF "$rotation_site" "$provenance_readme" \ + || fail "provenance rotation policy omits $rotation_site" +done +grep -qF 'same-container post-check independently confirmed' "$provenance_readme" \ + && fail 'Terra proof claims an unretained same-container post-check' + expect_success() { "$@" || fail "expected success: $*" } @@ -110,6 +127,22 @@ before_bashrc="$(snapshot_file_state "$HOME/.bashrc")" # this scan at the public script boundary because a command hidden elsewhere in # the installer can bypass every archive-level test below. installer="$repo_dir/setup/scripts/install-packages" +grep -qFx 'PRIVILEGED_TMPDIR=/var/tmp' "$installer" \ + || fail 'privileged staging parent is selectable through caller environment' +for openh264_binding in 'fedora-cisco-openh264.repo' \ + '--repo=fedora-cisco-openh264' '--from-repo=fedora-cisco-openh264'; do + grep -qF -- "$openh264_binding" "$installer" \ + || fail "desktop repository scope omits $openh264_binding" +done +if grep -qF 'DESKTOP_REPO_ARGS=("${RPMFUSION_REPO_ARGS[@]}" --repo=terra)' "$installer" \ + || grep -qF 'HYPRLAND_REPO_ARGS=("${DESKTOP_REPO_ARGS[@]}"' "$installer"; then + fail 'Terra remains in a broad desktop or Hyprland repository scope' +fi +multimedia_scope="$(sed -n '/^# --- Codecs and multimedia/,/^# --- Install Development Packages/p' "$installer")" +[[ "$multimedia_scope" != *'HYPRLAND_REPO_ARGS'* \ + && "$multimedia_scope" != *'--repo=terra'* \ + && "$multimedia_scope" != *'--repo=panama-hyprland'* ]] \ + || fail 'multimedia transactions admit Terra or the Hyprland COPR' unsafe_installers=() for forbidden in \ 'curl[^|]*\|[[:space:]]*bash' \ @@ -128,6 +161,30 @@ if (( ${#unsafe_installers[@]} > 0 )); then fail 'replace each finding with a reviewed, verified installation path' fi +# Every package-solving DNF command declares its dependency source set. An +# unrelated enabled operator repository may remain configured, but it cannot +# participate in a Panama transaction merely because DNF discovered it. +unscoped_dnf="$(python3 - "$repo_dir/install" "$installer" <<'PY' +import sys + +for path in sys.argv[1:]: + lines = open(path, encoding="utf-8").read().splitlines() + index = 0 + while index < len(lines): + command = lines[index].strip() + start = index + 1 + while command.endswith("\\") and index + 1 < len(lines): + command = command[:-1] + " " + lines[index + 1].strip() + index += 1 + index += 1 + if "sudo dnf" not in command or " config-manager " in command: + continue + if "--repo=" not in command and "REPO_ARGS" not in command: + print(f"{path}:{start}:{command}") +PY +)" +[[ -z "$unscoped_dnf" ]] || fail "unscoped DNF transaction(s): $unscoped_dnf" + # Re-running install-packages must be keyed to every reviewed trust input it # consumes. The update-command fixture proves each input changes the digest; # this public-boundary guard keeps any of those inputs from being silently @@ -136,6 +193,8 @@ for state_input in \ 'setup/packages' \ 'setup/scripts/install-packages' \ 'setup/lib/artifact-provenance' \ + 'setup/lib/extras-catalog' \ + 'setup/lib/machine-role' \ 'setup/provenance'; do grep -Fq "$state_input" "$repo_dir/install" \ || fail "packages hash does not name required state input: $state_input" @@ -151,6 +210,18 @@ base64 --decode "$fixtures/signed-fixture.rpm.base64" > "$test_tmp/signed-fixtur base64 --decode "$fixtures/unsigned-fixture.rpm.base64" > "$test_tmp/unsigned-fixture.rpm" base64 --decode "$fixtures/wrong-signer-fixture.rpm.base64" > "$test_tmp/wrong-signer-fixture.rpm" +# The helper owns the producer-status check; it cannot depend on a sourcing +# script having enabled pipefail before the GPG-to-parser pipeline runs. +gpg() { + printf 'pub:-:4096:1:0000000000000000:0:0::-:::scESC::::::23::0:\n' + printf 'fpr:::::::::%s:\n' "$fixture_fingerprint" + return 42 +} +set +o pipefail +expect_failure _primary_key_fingerprints "$fixtures/fixture-key.asc" +set -o pipefail +unset -f gpg + [[ "$(wc -l < "$fixtures/SHASUMS256.txt")" -eq 4 ]] || fail 'signed checksum fixture is not four lines' [[ "$(cmp -l "$fixtures/tiny-artifact" "$fixtures/tiny-artifact-tampered" | wc -l)" -eq 1 ]] \ || fail 'tampered artifact does not differ by exactly one byte' @@ -164,6 +235,36 @@ expect_failure key_fingerprint_matches "$fixtures/fixture-key.asc" '000000000000 cat "$fixtures/fixture-key.asc" "$fixtures/wrong-signer-key.asc" > "$test_tmp/combined-key.asc" expect_failure key_fingerprint_matches "$test_tmp/combined-key.asc" "$fixture_fingerprint" +# A producer can print a valid-looking primary fingerprint and still fail on +# later malformed input. Public matchers must preserve that failure rather +# than returning mapfile's successful process-substitution status. +primary_key_fingerprints_definition="$(declare -f _primary_key_fingerprints)" +_primary_key_fingerprints() { + printf '%s\n' "$fixture_fingerprint" + return 42 +} +expect_failure key_fingerprint_matches "$fixtures/fixture-key.asc" "$fixture_fingerprint" +expect_failure verify_detached_signature \ + "$fixtures/fixture-key.asc" "$fixtures/SHASUMS256.txt.asc" "$fixtures/SHASUMS256.txt" +eval "$primary_key_fingerprints_definition" + +cat "$fixtures/fixture-key.asc" > "$test_tmp/malformed-key.asc" +printf '\n-----BEGIN PGP PUBLIC KEY BLOCK-----\ninvalid\n' >> "$test_tmp/malformed-key.asc" +malformed_status=0 +malformed_home="$test_tmp/malformed-gnupg" +mkdir -m 700 "$malformed_home" +malformed_gpg_output="$(GNUPGHOME="$malformed_home" gpg --batch --with-colons \ + --import-options show-only --import "$test_tmp/malformed-key.asc" 2>/dev/null)" \ + || malformed_status=$? +awk -F: '$1 == "pub" { primary = 1; next } primary && $1 == "fpr" { print $10; primary = 0 }' \ + <<<"$malformed_gpg_output" > "$test_tmp/malformed-key.fingerprints" +(( malformed_status != 0 )) || fail 'malformed GPG fixture did not exercise a producer failure' +[[ "$(<"$test_tmp/malformed-key.fingerprints")" == "$fixture_fingerprint" ]] \ + || fail 'malformed GPG fixture did not emit the valid-looking partial fingerprint' +expect_failure key_fingerprint_matches "$test_tmp/malformed-key.asc" "$fixture_fingerprint" +expect_failure verify_detached_signature \ + "$test_tmp/malformed-key.asc" "$fixtures/SHASUMS256.txt.asc" "$fixtures/SHASUMS256.txt" + expect_success verify_detached_signature \ "$fixtures/fixture-key.asc" "$fixtures/SHASUMS256.txt.asc" "$fixtures/SHASUMS256.txt" expect_failure verify_detached_signature \ @@ -342,6 +443,17 @@ cp "$config" "$installer_fixture/setup/provenance/installers.conf" cp "$repo_dir"/setup/provenance/keys/*.asc "$installer_fixture/setup/provenance/keys/" sed '/^# --- The server path/,$d' "$repo_dir/setup/scripts/install-packages" \ > "$installer_fixture/setup/scripts/install-packages" +cat >> "$installer_fixture/setup/scripts/install-packages" <<'STUB' + +exercise_agent_install_boundary() { + setup_node() { printf 'agent:node\n' >> "$COMMAND_LOG"; } + install_pnpm() { printf 'agent:pnpm\n' >> "$COMMAND_LOG"; } + install_bun() { printf 'agent:bun\n' >> "$COMMAND_LOG"; } + install_claude_code() { printf 'agent:claude:78\n' >> "$COMMAND_LOG"; return 78; } + install_codex() { printf 'agent:codex\n' >> "$COMMAND_LOG"; } + install_optional_agent_tools +} +STUB artifact_root="$test_tmp/runtime-artifacts" mkdir -p "$artifact_root/build" @@ -441,7 +553,8 @@ make_stub_commands() { local case_root="$1" mkdir -p "$case_root/bin" "$case_root/home" "$case_root/tmp" \ "$case_root/etc/profile.d" "$case_root/etc/yum.repos.d" \ - "$case_root/etc/pki/rpm-gpg" "$case_root/flatpak-repo" + "$case_root/etc/pki/rpm-gpg" "$case_root/flatpak-repo" \ + "$case_root/root-staging" cat > "$case_root/etc/profile.d/nvm.sh" <<'STUB' nvm() { @@ -452,6 +565,76 @@ STUB cat > "$case_root/bin/uname" <<'STUB' #!/usr/bin/env bash printf '%s\n' "${STUB_ARCH:-x86_64}" +STUB + + cat > "$case_root/bin/stat" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == -c && "${2:-}" == '%u:%a' && "${3:-}" == -- \ + && ( "${4:-}" == "$STUB_ETC"/* || "${4:-}" == "$STUB_FLATPAK_REPO"/* ) ]]; then + [[ -f "$4" && ! -L "$4" ]] || exit 1 + printf '0:%s\n' "$(/usr/bin/stat -c %a "$4")" + exit 0 +fi +exec /usr/bin/stat "$@" +STUB + + cat > "$case_root/bin/mktemp" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${STUB_SIGNAL_PHASE:-}" == gpg-home && "${1:-}" == -d ]]; then + directory="$(/usr/bin/mktemp "$@")" + printf '%s\n' "$directory" + printf 'signal:gpg-home\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 +fi +if [[ "${STUB_SIGNAL_PHASE:-}" == repo-work && "${1:-}" == -d \ + && "$*" != *'-u'* ]]; then + directory="$(/usr/bin/mktemp "$@")" + printf '%s\n' "$directory" + printf 'signal:repo-work\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 +fi +exec /usr/bin/mktemp "$@" +STUB + + cat > "$case_root/bin/mkdir" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +target="${!#}" +if [[ "${STUB_SIGNAL_PHASE:-}" == gpg-home \ + && "$(basename -- "$target")" == panama-gpg.* ]]; then + /usr/bin/mkdir "$@" + printf 'signal:gpg-home\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 +fi +if [[ "${STUB_SIGNAL_PHASE:-}" == repo-work \ + && "$(basename -- "$target")" == panama-rpmfusion.* ]]; then + /usr/bin/mkdir "$@" + printf 'signal:repo-work\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 +fi +exec /usr/bin/mkdir "$@" +STUB + + cat > "$case_root/bin/chmod" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +/usr/bin/chmod "$@" +target="${!#}" +if [[ "${STUB_SWAP_AFTER_REPO_WRITE:-}" == "$(basename -- "$target")" \ + && "${1:-}" == 0600 ]]; then + printf 'swapped after repository write\n' > "$target" + printf '%s\n' "$target" > "$STUB_SWAP_MARKER" +fi STUB cat > "$case_root/bin/tar" <<'STUB' @@ -589,7 +772,10 @@ elif [[ "${1:-}" == -q ]]; then package="${!#}" printf 'rpm:query:%s\n' "$package" >> "$COMMAND_LOG" case "$package" in - terra-release) [[ "${STUB_TERRA_INSTALLED:-0}" == 1 ]] ;; + terra-release) + [[ "${STUB_TERRA_INSTALLED:-0}" == 1 \ + || -s "${STUB_TERRA_RPM_STATE:?}" ]] + ;; claude-desktop-extra) [[ "${STUB_CLAUDE_DESKTOP_INSTALLED:-0}" == 1 ]] ;; rustdesk) [[ -n "${STUB_RUSTDESK_VERSION:-}" ]] || exit 1 @@ -665,6 +851,11 @@ STUB #!/usr/bin/env bash set -euo pipefail file="${!#}" +if [[ "${STUB_TOGGLE_KEY_VERIFY:-}" == "$(basename -- "$file")" \ + && "$file" != "$PRIVILEGED_TMPDIR"/panama-install.*/* ]]; then + printf 'attacker key bytes\n' > "$file" + printf '%s\n' "$file" > "$STUB_SWAP_MARKER" +fi if [[ "${STUB_DIGEST_MISMATCH:-}" == 1 ]]; then printf '%064d %s\n' 0 "$file" exit 0 @@ -723,6 +914,13 @@ STUB set -euo pipefail key="${!#}" fingerprint='' +toggle_saved='' +if [[ "${STUB_TOGGLE_KEY_VERIFY:-}" == "$(basename -- "$key")" \ + && "$key" != "$PRIVILEGED_TMPDIR"/panama-install.*/* ]]; then + toggle_saved="$(mktemp)" + cp -- "$key" "$toggle_saved" + cp -- "$REVIEWED_KEYS/$(basename -- "$key")" "$key" +fi for candidate in "$REVIEWED_KEYS"/*.asc; do if cmp -s "$key" "$candidate"; then case "$(basename "$candidate")" in @@ -741,6 +939,10 @@ done printf 'gpg:fingerprint:%s\n' "$fingerprint" >> "$COMMAND_LOG" printf 'pub:-:4096:1:0000000000000000:0:0::-:::scESC::::::23::0:\n' printf 'fpr:::::::::%s:\n' "$fingerprint" +if [[ -n "$toggle_saved" ]]; then + cp -- "$toggle_saved" "$key" + rm -f -- "$toggle_saved" +fi STUB cat > "$case_root/bin/rpmkeys" <<'STUB' @@ -757,6 +959,13 @@ while (($#)); do esac done printf 'rpmkeys:%s:%s\n' "$action" "$(basename "$package")" >> "$COMMAND_LOG" +if [[ "$action" == import \ + && ( "${STUB_SIGNAL_PHASE:-}" == rpmdb || "${STUB_SIGNAL_PHASE:-}" == root-review ) ]]; then + printf 'signal:%s\n' "$STUB_SIGNAL_PHASE" >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 +fi if [[ "$action" == checksig ]]; then [[ "${STUB_RPM_SIGNATURE_FAIL:-}" != "$(basename "$package")" ]] || exit 1 printf 'Header OpenPGP signature: OK\n' @@ -766,11 +975,89 @@ STUB cat > "$case_root/bin/sudo" <<'STUB' #!/usr/bin/env bash set -euo pipefail +if [[ "${1:-}" == mktemp && "${2:-}" == -d ]]; then + printf 'sudo:root-create\n' >> "$COMMAND_LOG" + directory="$(/usr/bin/mktemp -d "$3")" + printf '%s\n' "$directory" + if [[ "${STUB_SIGNAL_PHASE:-}" == root-create ]]; then + printf 'signal:root-create\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 + fi + exit 0 +fi +if [[ "${1:-}" == mkdir && "${2:-}" == -m && "${3:-}" == 0700 \ + && "${4:-}" == -- ]]; then + printf 'sudo:root-create\n' >> "$COMMAND_LOG" + /usr/bin/mkdir -m 0700 -- "$5" + if [[ "${STUB_SIGNAL_PHASE:-}" == root-create ]]; then + printf 'signal:root-create\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 + fi + exit 0 +fi +if [[ "${1:-}" == test && "${2:-}" == -d ]]; then + [[ -d "${3:-}" ]] + exit +fi +if [[ "${1:-}" == test && "${2:-}" == '!' && "${3:-}" == -L ]]; then + [[ ! -L "${4:-}" ]] + exit +fi +if [[ "${1:-}" == stat && "${2:-}" == -c && "${3:-}" == '%u:%a' \ + && "${4:-}" == -- ]]; then + if [[ -d "$5" && ! -L "$5" ]]; then + printf '0:%s\n' "$(/usr/bin/stat -c %a "$5")" + elif [[ -f "$5" && ! -L "$5" ]]; then + printf '0:%s\n' "$(/usr/bin/stat -c %a "$5")" + else + exit 1 + fi + exit 0 +fi +if [[ "${1:-}" == chmod && "${2:-}" == 0700 ]]; then + printf 'sudo:root-private\n' >> "$COMMAND_LOG" + exec /usr/bin/chmod 0700 "$3" +fi +if [[ "${1:-}" == chmod && "${2:-}" == 0711 ]]; then + printf 'sudo:root-reviewable\n' >> "$COMMAND_LOG" + exec /usr/bin/chmod 0711 "$3" +fi +if [[ "${1:-}" == sha256sum && "${2:-}" == -- ]]; then + printf 'sudo:root-verify:%s\n' "$(basename -- "$3")" >> "$COMMAND_LOG" + source_file="$3" + [[ "$source_file" != /etc/* ]] || source_file="$STUB_ETC${source_file#/etc}" + exec sha256sum -- "$source_file" +fi if [[ "${1:-}" == install ]]; then shift - [[ "${1:-}" == -m && ( "${2:-}" == 0644 || "${2:-}" == 644 ) ]] || exit 67 + [[ "${1:-}" == -m ]] || exit 67 + mode="$2" source_file="$3" destination="$4" + if [[ "$mode" == 0444 || "$mode" == 444 \ + || "$mode" == 0600 || "$mode" == 600 ]]; then + [[ "$destination" == "$PRIVILEGED_TMPDIR"/panama-install.*/"$(basename -- "$destination")" ]] \ + || exit 67 + printf 'sudo:root-stage:%s\n' "$(basename -- "$destination")" >> "$COMMAND_LOG" + [[ "$source_file" != /etc/* ]] || source_file="$STUB_ETC${source_file#/etc}" + if [[ "${STUB_TOGGLE_KEY_VERIFY:-}" == "$(basename -- "$source_file")" \ + && "$source_file" != "$PRIVILEGED_TMPDIR"/panama-install.*/* ]]; then + printf 'attacker key bytes\n' > "$source_file" + printf '%s\n' "$source_file" > "$STUB_SWAP_MARKER" + fi + /usr/bin/install -m "$mode" "$source_file" "$destination" + if [[ "${STUB_SWAP_AFTER_ROOT_STAGE:-}" == "$(basename -- "$destination")" ]]; then + printf 'swapped after privileged copy\n' > "$source_file" + printf '%s\n' "$source_file" > "$STUB_SWAP_MARKER" + fi + exit 0 + fi + [[ "$mode" == 0644 || "$mode" == 644 ]] || exit 67 + [[ "$source_file" == "$PRIVILEGED_TMPDIR"/panama-install.*/* ]] || exit 67 printf 'sudo:install:%s:%s\n' "$(basename "$source_file")" "$destination" >> "$COMMAND_LOG" mapped="$STUB_ETC${destination#/etc}" mkdir -p "$(dirname "$mapped")" @@ -779,17 +1066,44 @@ if [[ "${1:-}" == install ]]; then [[ ! -f "$STUB_INSTALL_COUNTER" ]] || read -r count < "$STUB_INSTALL_COUNTER" count=$((count + 1)) printf '%s\n' "$count" > "$STUB_INSTALL_COUNTER" + if [[ "${STUB_SIGNAL_PAIR_AFTER_FIRST:-0}" == 1 && "$count" == 1 ]]; then + printf 'signal:repository-pair\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 + fi + if [[ "${STUB_SIGNAL_PAIR_TWICE:-0}" == 1 && "$count" == 2 ]]; then + printf 'signal:repository-pair-second\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 + fi if [[ -n "${STUB_INSTALL_FAIL_AT:-}" && "$count" == "$STUB_INSTALL_FAIL_AT" ]]; then exit 67 fi exit 0 fi +if [[ "${1:-}" == rm && "${2:-}" == -rf && "${3:-}" == -- ]]; then + [[ "$4" == "$PRIVILEGED_TMPDIR"/panama-install.* ]] || exit 67 + printf 'sudo:root-cleanup\n' >> "$COMMAND_LOG" + [[ "${STUB_ROOT_CLEANUP_FAIL:-0}" != 1 ]] || exit 79 + exec /usr/bin/rm -rf -- "$4" +fi if [[ "${1:-}" == rm && "${2:-}" == -f && "${3:-}" == -- ]]; then destination="$4" printf 'sudo:rm:%s\n' "$destination" >> "$COMMAND_LOG" + [[ "${STUB_ROLLBACK_FAIL:-0}" != 1 ]] || exit 79 rm -f -- "$STUB_ETC${destination#/etc}" exit 0 fi +if [[ "${1:-}" == rpm && "${2:-}" == --import ]]; then + key="${3:-}" + [[ "$key" == "$PRIVILEGED_TMPDIR"/panama-install.*/*.asc && -f "$key" ]] \ + || exit 76 + printf 'sudo:rpm-import:%s\n' "$(basename -- "$key")" >> "$COMMAND_LOG" + printf '%s\n' "$(basename -- "$key")" >> "$STUB_SYSTEM_KEYRING" + exit 0 +fi original="$*" logged=() for argument in "$@"; do @@ -797,6 +1111,20 @@ for argument in "$@"; do logged+=(--gpg-import=FLATHUB_KEY) continue fi + if [[ "$argument" == --setopt=panama-bound-*.gpgkey=file://*/panama-bound-*.asc ]]; then + key="${argument#*=file://}" + [[ "$key" == "$PRIVILEGED_TMPDIR"/panama-install.*/panama-bound-*.asc \ + && -f "$key" ]] || exit 76 + logged+=("${argument%%=file://*}=file://BOUND_KEY") + continue + fi + if [[ "$argument" == --setopt=panama-claude-desktop.gpgkey=file://*/claude-desktop.asc ]]; then + key="${argument#*=file://}" + [[ "$key" == "$PRIVILEGED_TMPDIR"/panama-install.*/claude-desktop.asc \ + && -f "$key" ]] || exit 76 + logged+=(--setopt=panama-claude-desktop.gpgkey=file://CLAUDE_DESKTOP_KEY) + continue + fi case "$(basename "$argument")" in rpmfusion-free-release.rpm) logged+=(RPMFUSION_FREE) ;; rpmfusion-nonfree-release.rpm) logged+=(RPMFUSION_NONFREE) ;; @@ -813,9 +1141,7 @@ if [[ "$original" == *'/rustdesk.rpm'* ]]; then for argument in "$@"; do [[ "$(basename -- "$argument")" != rustdesk.rpm ]] || rustdesk_path="$argument" done - [[ "$rustdesk_path" == "$TMPDIR"/tmp.*/rustdesk.rpm \ - && -s "$VERIFIED_RUSTDESK_INODE" - && "$(stat -c '%d:%i' "$rustdesk_path")" == "$(<"$VERIFIED_RUSTDESK_INODE")" ]] \ + [[ "$rustdesk_path" == "$PRIVILEGED_TMPDIR"/panama-install.*/rustdesk.rpm ]] \ && cmp -s "$rustdesk_path" "$UNSIGNED_RPM" || exit 72 /usr/bin/rpm -qp --queryformat '%{NAME}\n' "$rustdesk_path" >/dev/null || exit 73 signature_status="$(/usr/bin/rpmkeys --checksig --verbose "$rustdesk_path")" || exit 74 @@ -823,16 +1149,47 @@ if [[ "$original" == *'/rustdesk.rpm'* ]]; then && "$signature_status" == *'Payload SHA256 digest: OK'* \ && "${signature_status,,}" != *signature* ]] || exit 75 fi -if [[ "$original" == *' pnpm' || "$original" == *' claude-code' ]]; then +if [[ "$original" == *'/rpmfusion-free-release.rpm'* \ + || "$original" == *'/rpmfusion-nonfree-release.rpm'* ]]; then + if [[ "${STUB_REQUIRE_RPMFUSION_SYSTEM_KEYS:-0}" == 1 ]]; then + grep -qFx rpmfusion-free.asc "$STUB_SYSTEM_KEYRING" || exit 77 + grep -qFx rpmfusion-nonfree.asc "$STUB_SYSTEM_KEYRING" || exit 77 + fi + for argument in "$@"; do + case "$(basename -- "$argument")" in + rpmfusion-free-release.rpm|rpmfusion-nonfree-release.rpm) + [[ "$argument" == "$PRIVILEGED_TMPDIR"/panama-install.*/* \ + && -f "$argument" ]] || exit 76 + cmp -s -- "$argument" "$SIGNED_RPM" || exit 76 + ;; + esac + done +fi +if [[ "$original" == *' pnpm' || "$original" == *' claude-code' \ + || "$original" == *' claude-desktop-extra' ]]; then case "$original" in - 'dnf install -y --repo=fedora --repo=updates pnpm'|\ - 'dnf install -y --repo=claude-code --repo=fedora --repo=updates --from-repo=claude-code claude-code') ;; + 'dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates pnpm'|\ + dnf\ install\ -y\ --repofrompath\ panama-bound-claude-code,https://downloads.claude.ai/claude-code/rpm/stable\ --repo=panama-bound-claude-code\ --repo=fedora\ --repo=updates\ --from-repo=panama-bound-claude-code\ --setopt=panama-bound-claude-code.gpgcheck=1\ --setopt=panama-bound-claude-code.repo_gpgcheck=1\ --setopt=panama-bound-claude-code.gpgkey=file://*/panama-bound-claude-code.asc\ claude-code|\ + dnf\ install\ -y\ --repofrompath\ panama-claude-desktop,https://patrickjaja.github.io/claude-desktop-extra/rpm/\ --repo=panama-claude-desktop\ --repo=fedora\ --repo=updates\ --from-repo=panama-claude-desktop\ --setopt=panama-claude-desktop.gpgcheck=1\ --setopt=panama-claude-desktop.repo_gpgcheck=1\ --setopt=panama-claude-desktop.gpgkey=file://*/claude-desktop.asc\ claude-desktop-extra) ;; *) exit 71 ;; esac fi if [[ -n "${STUB_DNF_FAIL_MATCH:-}" && "$original" == *"$STUB_DNF_FAIL_MATCH"* ]]; then + if [[ "${STUB_TERRA_MUTATE_THEN_FAIL:-0}" == 1 ]]; then + printf '[terra]\nenabled=1\ngpgcheck=0\nbaseurl=https://evil.invalid/\n' \ + > "$STUB_ETC/yum.repos.d/terra.repo" + fi exit 68 fi +if [[ "${STUB_TERRA_MUTATE_ON_SUCCESS:-0}" == 1 \ + && "$original" == *' terra-release' ]]; then + printf '[terra]\nenabled=1\ngpgcheck=0\nbaseurl=https://evil.invalid/\n' \ + > "$STUB_ETC/yum.repos.d/terra.repo" + printf 'terra-release:generated-repo\n' >> "$COMMAND_LOG" +fi +if [[ "$original" == *' terra-release' ]]; then + printf 'installed\n' > "$STUB_TERRA_RPM_STATE" +fi if [[ "${1:-}" == flatpak && "${2:-}" == remote-add ]]; then if [[ ! -f "$STUB_FLATPAK_REPO/config" ]] \ || ! grep -q '^\[remote "flathub"\]$' "$STUB_FLATPAK_REPO/config"; then @@ -989,14 +1346,22 @@ write_flathub_descriptor() { run_installer_function() { local name="$1" function_name="$2" case_root + shift 2 + local -a function_args=("$@") case_root="$test_tmp/cases/$name" if [[ "${STUB_REUSE_CASE:-}" != 1 ]]; then rm -rf -- "$case_root" fi make_stub_commands "$case_root" + if [[ "${STUB_OPENH264_REPO:-}" == 1 ]]; then + printf '[fedora-cisco-openh264]\nenabled=1\n' \ + > "$case_root/etc/yum.repos.d/fedora-cisco-openh264.repo" + fi : > "$case_root/commands.log" printf '0\n' > "$case_root/install-counter" : > "$case_root/verified-rustdesk-inode" + : > "$case_root/system-keyring" + [[ -e "$case_root/terra-rpm-state" ]] || : > "$case_root/terra-rpm-state" printf 'preserved\n' > "$case_root/flatpak-state" : > "$case_root/softly-failed" case "${STUB_SEED_OLD:-}" in @@ -1084,6 +1449,22 @@ run_installer_function() { printf '[terra]\nbaseurl=https://repos.fyralabs.com/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" ;; + trusted-key-symlink) + cp "$installer_fixture/setup/provenance/keys/terra44.asc" \ + "$case_root/home/terra44.asc" + ln -s "$case_root/home/terra44.asc" \ + "$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama" + printf '[terra]\nbaseurl=https://repos.fyralabs.com/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" + ;; + trusted-repo-symlink) + cp "$installer_fixture/setup/provenance/keys/terra44.asc" \ + "$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama" + printf '[terra]\nbaseurl=https://repos.fyralabs.com/terra44\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama\n' \ + > "$case_root/home/terra.repo" + ln -s "$case_root/home/terra.repo" \ + "$case_root/etc/yum.repos.d/terra.repo" + ;; nogpg) printf '[terra]\nbaseurl=https://repos.fyralabs.com/terra44\nenabled=1\ngpgcheck=0\nrepo_gpgcheck=0\ngpgkey=https://repos.fyralabs.com/terra44.key\n' \ > "$case_root/etc/yum.repos.d/terra.repo" @@ -1108,6 +1489,21 @@ run_installer_function() { cp "$installer_fixture/setup/provenance/keys/flathub.asc" \ "$case_root/flatpak-repo/flathub.trustedkeys.gpg" ;; + trusted-config-symlink) + printf '[core]\nrepo_version=1\n\n[remote "flathub"]\nurl=https://dl.flathub.org/repo/\ngpg-verify=true\ngpg-verify-summary=true\n' \ + > "$case_root/home/flathub-config" + ln -s "$case_root/home/flathub-config" "$case_root/flatpak-repo/config" + cp "$installer_fixture/setup/provenance/keys/flathub.asc" \ + "$case_root/flatpak-repo/flathub.trustedkeys.gpg" + ;; + trusted-key-symlink) + printf '[core]\nrepo_version=1\n\n[remote "flathub"]\nurl=https://dl.flathub.org/repo/\ngpg-verify=true\ngpg-verify-summary=true\n' \ + > "$case_root/flatpak-repo/config" + cp "$installer_fixture/setup/provenance/keys/flathub.asc" \ + "$case_root/home/flathub.trustedkeys.gpg" + ln -s "$case_root/home/flathub.trustedkeys.gpg" \ + "$case_root/flatpak-repo/flathub.trustedkeys.gpg" + ;; wrong-url) printf '[core]\nrepo_version=1\n\n[remote "flathub"]\nurl=https://evil.invalid/repo/\ngpg-verify=true\ngpg-verify-summary=true\n' \ > "$case_root/flatpak-repo/config" @@ -1147,11 +1543,32 @@ run_installer_function() { case "${STUB_CLAUDE_DESKTOP_REPO_MODE:-absent}" in trusted) cp "$installer_fixture/setup/provenance/keys/claude-desktop.asc" \ - "$case_root/etc/pki/rpm-gpg/claude-desktop-local.asc" - printf '[claude-desktop]\nbaseurl=https://patrickjaja.github.io/claude-desktop-extra/rpm/\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=file://%s\n' \ - "$case_root/etc/pki/rpm-gpg/claude-desktop-local.asc" \ + "$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-claude-desktop-panama" + printf '[claude-desktop]\nbaseurl=https://patrickjaja.github.io/claude-desktop-extra/rpm/\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-claude-desktop-panama\n' \ > "$case_root/etc/yum.repos.d/claude-desktop.repo" ;; + home-key|symlink-repo|metalink|mirrorlist) + key_target="$case_root/etc/pki/rpm-gpg/RPM-GPG-KEY-claude-desktop-panama" + key_url='file:///etc/pki/rpm-gpg/RPM-GPG-KEY-claude-desktop-panama' + if [[ "$STUB_CLAUDE_DESKTOP_REPO_MODE" == home-key ]]; then + key_target="$case_root/home/claude-desktop.asc" + key_url="file://$key_target" + fi + cp "$installer_fixture/setup/provenance/keys/claude-desktop.asc" "$key_target" + repo_target="$case_root/etc/yum.repos.d/claude-desktop.repo" + if [[ "$STUB_CLAUDE_DESKTOP_REPO_MODE" == symlink-repo ]]; then + repo_target="$case_root/home/claude-desktop.repo" + fi + printf '[claude-desktop]\nbaseurl=https://patrickjaja.github.io/claude-desktop-extra/rpm/\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=file://%s\n' \ + "${key_url#file://}" > "$repo_target" + case "$STUB_CLAUDE_DESKTOP_REPO_MODE" in + metalink) printf 'metalink=https://evil.invalid/metadata\n' >> "$repo_target" ;; + mirrorlist) printf 'mirrorlist=https://evil.invalid/mirrors\n' >> "$repo_target" ;; + symlink-repo) + ln -s "$repo_target" "$case_root/etc/yum.repos.d/claude-desktop.repo" + ;; + esac + ;; untrusted) cp "$installer_fixture/setup/provenance/keys/claude-desktop.asc" \ "$case_root/etc/pki/rpm-gpg/claude-desktop-local.asc" @@ -1174,16 +1591,20 @@ run_installer_function() { STUB_FLATPAK_STATE="$case_root/flatpak-state" \ STUB_FLATPAK_REPO="$case_root/flatpak-repo" \ STUB_INSTALL_COUNTER="$case_root/install-counter" \ + STUB_SYSTEM_KEYRING="$case_root/system-keyring" \ + STUB_TERRA_RPM_STATE="$case_root/terra-rpm-state" \ + STUB_SWAP_MARKER="$case_root/swap-marker" \ VERIFIED_RUSTDESK_INODE="$case_root/verified-rustdesk-inode" \ UNSIGNED_RPM="$test_tmp/unsigned-fixture.rpm" \ LC_ALL="${STUB_CALLER_LOCALE:-C}" \ HOME="$case_root/home" \ NVM_DIR="$case_root/home/.nvm" \ TMPDIR="$case_root/tmp" \ + STUB_PRIVILEGED_TMPDIR="$case_root/root-staging" \ PANAMA_PATH="$installer_fixture" \ PATH="$case_root/bin:/usr/bin:/bin" \ - setsid bash -c 'source "$PANAMA_PATH/setup/scripts/install-packages"; PANAMA_SYSTEM_ETC="$STUB_ETC"; PANAMA_SYSTEM_FLATPAK_REPO="$STUB_FLATPAK_REPO"; declare -F "$1" >/dev/null; status=0; "$1" || status=$?; (( ${#softly_failed[@]} == 0 )) || printf "%s\n" "${softly_failed[@]}" > "$SOFT_LOG"; exit "$status"' \ - bash "$function_name" + setsid bash -c 'source "$PANAMA_PATH/setup/scripts/install-packages"; PANAMA_SYSTEM_ETC="$STUB_ETC"; PANAMA_SYSTEM_FLATPAK_REPO="$STUB_FLATPAK_REPO"; export PRIVILEGED_TMPDIR="$STUB_PRIVILEGED_TMPDIR"; function_name="$1"; shift; declare -F "$function_name" >/dev/null; status=0; "$function_name" "$@" || status=$?; (( ${#softly_failed[@]} == 0 )) || printf "%s\n" "${softly_failed[@]}" > "$SOFT_LOG"; exit "$status"' \ + bash "$function_name" "${function_args[@]}" ) > "$case_root/output" 2>&1 } @@ -1203,6 +1624,19 @@ assert_soft_failure() { || fail "$name did not record exactly one $component soft failure" } +assert_root_snapshot_logged() { + local name="$1" file="$2" + [[ "$(<"$test_tmp/cases/$name/commands.log")" == *"sudo:root-stage:$file"* \ + && "$(<"$test_tmp/cases/$name/commands.log")" == *"sudo:root-verify:$file"* ]] \ + || fail "$name did not reverify privileged snapshot $file" +} + +assert_user_source_swapped() { + local name="$1" + [[ -s "$test_tmp/cases/$name/swap-marker" ]] \ + || fail "$name did not exercise the post-snapshot source replacement adapter" +} + assert_no_download() { local name="$1" [[ "$(<"$test_tmp/cases/$name/commands.log")" != *'curl:'* ]] \ @@ -1213,7 +1647,8 @@ assert_no_runtime_staging() { local name="$1" [[ -z "$(find "$test_tmp/cases/$name/home" \ \( -name '*.part.*' -o -name '*.stage.*' -o -name '*.link.*' \) -print -quit)" \ - && -z "$(find "$test_tmp/cases/$name/tmp" -mindepth 1 -print -quit)" ]] \ + && -z "$(find "$test_tmp/cases/$name/tmp" -mindepth 1 -print -quit)" \ + && -z "$(find "$test_tmp/cases/$name/root-staging" -mindepth 1 -print -quit)" ]] \ || fail "$name left private runtime staging behind" } @@ -1440,6 +1875,63 @@ for signal_spec in \ assert_no_runtime_staging "$name" done +# RPM key verification owns a private RPM database. A real signal delivered +# after that database exists must remove it before the isolated process exits. +reset_installer_fixture +STUB_SIGNAL_PHASE=rpmdb expect_failure run_installer_function rpmdb-signal \ + rpm_signature_matches "$test_tmp/signed-fixture.rpm" \ + "$installer_fixture/setup/provenance/keys/rpmfusion-free.asc" \ + E9A491A3DE247814E7E067EAE06F8ECDD651FF2E 2>/dev/null +grep -qFx 'signal:rpmdb' "$test_tmp/cases/rpmdb-signal/commands.log" \ + || fail 'rpmdb-signal did not deliver its real process-group signal' +assert_no_runtime_staging rpmdb-signal + +reset_installer_fixture +STUB_SIGNAL_PHASE=gpg-home expect_failure run_installer_function gpg-home-signal \ + key_fingerprint_matches \ + "$installer_fixture/setup/provenance/keys/rpmfusion-free.asc" \ + E9A491A3DE247814E7E067EAE06F8ECDD651FF2E 2>/dev/null +grep -qFx 'signal:gpg-home' "$test_tmp/cases/gpg-home-signal/commands.log" \ + || fail 'gpg-home-signal did not deliver its real process-group signal' +assert_no_runtime_staging gpg-home-signal + +# Root staging owns cleanup before the privileged directory exists and until +# reviewed snapshots have been returned to their caller. Signals in either +# window must not strand a root-owned panama-install directory. +reset_installer_fixture +STUB_RUSTDESK_VERSION=1.4.8 STUB_SIGNAL_PHASE=root-create \ + expect_failure run_installer_function root-create-signal install_rustdesk 2>/dev/null +grep -qFx 'signal:root-create' "$test_tmp/cases/root-create-signal/commands.log" \ + || fail 'root-create-signal did not deliver its real process-group signal' +assert_no_runtime_staging root-create-signal + +reset_installer_fixture +STUB_SIGNAL_PHASE=root-review \ + expect_failure run_installer_function root-review-signal install_rpmfusion_repositories 2>/dev/null +grep -qFx 'signal:root-review' "$test_tmp/cases/root-review-signal/commands.log" \ + || fail 'root-review-signal did not deliver its real process-group signal' +assert_no_runtime_staging root-review-signal + +reset_installer_fixture +STUB_SIGNAL_PHASE=repo-work \ + expect_failure run_installer_function repo-work-signal \ + install_rpmfusion_repositories 2>/dev/null +grep -qFx 'signal:repo-work' "$test_tmp/cases/repo-work-signal/commands.log" \ + || fail 'repo-work-signal did not deliver its real process-group signal' +assert_no_runtime_staging repo-work-signal + +# If privileged cleanup itself fails, do not erase the only recovery evidence. +# The caller returns failure and reports the exact retained private directory. +reset_installer_fixture +STUB_RUSTDESK_VERSION=1.4.8 STUB_ROOT_CLEANUP_FAIL=1 \ + expect_failure run_installer_function root-cleanup-failure install_rustdesk +grep -qF 'Installer staging cleanup failed. Retained artifact:' \ + "$test_tmp/cases/root-cleanup-failure/output" \ + || fail 'root cleanup failure did not report retained evidence' +[[ -n "$(find "$test_tmp/cases/root-cleanup-failure/root-staging" \ + -mindepth 1 -print -quit)" ]] \ + || fail 'root cleanup failure discarded its reported recovery evidence' + # Successful updates keep the old version directory and switch only the active # symlink after the replacement binary has passed its version check. for component_spec in 'Bun install_bun .bun/bin/bun .bun/versions/1.4.0/bin/bun' \ @@ -1557,11 +2049,25 @@ STUB_ARCH=x86_64 STUB_RUSTDESK_VERSION=1.4.8 \ assert_log rustdesk-x86_64 "$(cat <<'EXPECTED' rpm:query:rustdesk curl:https://github.com/rustdesk/rustdesk/releases/download/1.4.9/rustdesk-1.4.9-0.x86_64.rpm:max=134217728:output=rustdesk.rpm -sudo:dnf install -y --setopt=localpkg_gpgcheck=0 RUSTDESK_LOCAL +sudo:root-create +sudo:root-private +sudo:root-stage:rustdesk.rpm +sudo:root-verify:rustdesk.rpm +sudo:dnf install -y --repo=fedora --repo=updates --setopt=localpkg_gpgcheck=0 RUSTDESK_LOCAL +sudo:root-cleanup EXPECTED )" +[[ "$(<"$test_tmp/cases/rustdesk-x86_64/commands.log")" == *'sudo:root-stage:rustdesk.rpm'* ]] \ + || fail 'RustDesk did not cross a reverified private privileged snapshot' assert_no_runtime_staging rustdesk-x86_64 +reset_installer_fixture +STUB_ARCH=x86_64 STUB_RUSTDESK_VERSION=1.4.8 \ + STUB_SWAP_AFTER_ROOT_STAGE=rustdesk.rpm \ + expect_success run_installer_function rustdesk-root-bound install_rustdesk +assert_user_source_swapped rustdesk-root-bound +assert_no_runtime_staging rustdesk-root-bound + reset_installer_fixture STUB_ARCH=x86_64 STUB_RUSTDESK_VERSION=1.4.9 \ expect_success run_installer_function rustdesk-exact install_rustdesk @@ -1571,10 +2077,142 @@ reset_installer_fixture expect_success run_installer_function pnpm install_pnpm assert_log pnpm "$(cat <<'EXPECTED' rpm:release -sudo:dnf install -y --repo=fedora --repo=updates pnpm +sudo:dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates pnpm EXPECTED )" +reset_installer_fixture +mkdir -p "$installer_fixture/setup/packages" +printf 'fixture-package\n' >"$installer_fixture/setup/packages/base-fixture" +expect_success run_installer_function base-list install_list base-fixture Base +assert_log base-list "$(cat <<'EXPECTED' +sudo:dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates --skip-unavailable fixture-package +rpm:query:fixture-package +EXPECTED +)" + +# Mixed package lists are split before DNF sees them. Publisher-exclusive names +# are singularly bound to that publisher, while ordinary Fedora/RPM Fusion +# packages never admit Terra, the Hyprland COPR, or Cisco as alternate sources. +reset_installer_fixture +cat > "$installer_fixture/setup/packages/desktop-source-fixture" <<'FIXTURE' +NetworkManager +cascadiamono-nerd-fonts +espanso-wayland +firamono-nerd-fonts +ghostty +jetbrainsmono-nerd-fonts +nautilus-open-any-terminal +victormono-nerd-fonts +gstreamer1-plugin-openh264 +mozilla-openh264 +FIXTURE +STUB_OPENH264_REPO=1 expect_success run_installer_function desktop-source-split \ + install_desktop_package_file \ + "$installer_fixture/setup/packages/desktop-source-fixture" +assert_log desktop-source-split "$(cat <<'EXPECTED' +sudo:dnf install -y --repo=fedora --repo=updates --repo=rpmfusion-free --repo=rpmfusion-free-updates --repo=rpmfusion-nonfree --repo=rpmfusion-nonfree-updates --repo=fedora-cisco-openh264 --from-repo=fedora,updates --skip-unavailable NetworkManager gstreamer1-plugin-openh264 +sudo:root-create +sudo:root-private +sudo:root-stage:panama-bound-terra.asc +sudo:root-verify:panama-bound-terra.asc +sudo:root-reviewable +gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F +sudo:root-private +sudo:dnf install -y --repofrompath panama-bound-terra,https://repos.fyralabs.com/terra44 --repo=panama-bound-terra --repo=fedora --repo=updates --from-repo=panama-bound-terra --setopt=panama-bound-terra.gpgcheck=1 --setopt=panama-bound-terra.repo_gpgcheck=1 --setopt=panama-bound-terra.gpgkey=file://BOUND_KEY --skip-unavailable cascadiamono-nerd-fonts espanso-wayland firamono-nerd-fonts ghostty jetbrainsmono-nerd-fonts nautilus-open-any-terminal victormono-nerd-fonts +sudo:root-cleanup +sudo:dnf install -y --repo=fedora --repo=updates --repo=fedora-cisco-openh264 --from-repo=fedora-cisco-openh264 --skip-unavailable mozilla-openh264 +rpm:query:NetworkManager +rpm:query:cascadiamono-nerd-fonts +rpm:query:espanso-wayland +rpm:query:firamono-nerd-fonts +rpm:query:ghostty +rpm:query:jetbrainsmono-nerd-fonts +rpm:query:nautilus-open-any-terminal +rpm:query:victormono-nerd-fonts +rpm:query:gstreamer1-plugin-openh264 +rpm:query:mozilla-openh264 +EXPECTED +)" + +reset_installer_fixture +cat > "$installer_fixture/setup/packages/hyprland-source-fixture" <<'FIXTURE' +NetworkManager +gpu-screen-recorder +grimblast +helium-browser-bin +hypridle +hyprland +hyprland-guiutils +hyprland-uwsm +hyprlock +hyprpaper +hyprpicker +hyprpolkitagent +hyprpwcenter +hyprshutdown +hyprsunset +hyprsysteminfo +mpvpaper +quickshell +satty +uwsm +vicinae +xdg-desktop-portal-hyprland +FIXTURE +expect_success run_installer_function hyprland-source-split \ + install_hyprland_package_file \ + "$installer_fixture/setup/packages/hyprland-source-fixture" +assert_log hyprland-source-split "$(cat <<'EXPECTED' +sudo:dnf install -y --repo=fedora --repo=updates --repo=rpmfusion-free --repo=rpmfusion-free-updates --repo=rpmfusion-nonfree --repo=rpmfusion-nonfree-updates --from-repo=fedora,updates --setopt=install_weak_deps=False NetworkManager +sudo:root-create +sudo:root-private +sudo:root-stage:panama-bound-hyprland.asc +sudo:root-verify:panama-bound-hyprland.asc +sudo:root-reviewable +gpg:fingerprint:97E23476C89635135407C7D5E9BA41342C4B2995 +sudo:root-private +sudo:dnf install -y --repofrompath panama-bound-hyprland,https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/ --repo=panama-bound-hyprland --repo=fedora --repo=updates --from-repo=panama-bound-hyprland --setopt=panama-bound-hyprland.gpgcheck=1 --setopt=panama-bound-hyprland.repo_gpgcheck=0 --setopt=panama-bound-hyprland.gpgkey=file://BOUND_KEY --setopt=install_weak_deps=False gpu-screen-recorder grimblast hypridle hyprland hyprland-guiutils hyprland-uwsm hyprlock hyprpaper hyprpicker hyprpolkitagent hyprpwcenter hyprshutdown hyprsunset hyprsysteminfo quickshell uwsm xdg-desktop-portal-hyprland +sudo:root-cleanup +sudo:root-create +sudo:root-private +sudo:root-stage:panama-bound-terra.asc +sudo:root-verify:panama-bound-terra.asc +sudo:root-reviewable +gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F +sudo:root-private +sudo:dnf install -y --repofrompath panama-bound-terra,https://repos.fyralabs.com/terra44 --repo=panama-bound-terra --repo=fedora --repo=updates --from-repo=panama-bound-terra --setopt=panama-bound-terra.gpgcheck=1 --setopt=panama-bound-terra.repo_gpgcheck=1 --setopt=panama-bound-terra.gpgkey=file://BOUND_KEY --setopt=install_weak_deps=False helium-browser-bin mpvpaper satty vicinae +sudo:root-cleanup +rpm:query:NetworkManager +rpm:query:gpu-screen-recorder +rpm:query:grimblast +rpm:query:helium-browser-bin +rpm:query:hypridle +rpm:query:hyprland +rpm:query:hyprland-guiutils +rpm:query:hyprland-uwsm +rpm:query:hyprlock +rpm:query:hyprpaper +rpm:query:hyprpicker +rpm:query:hyprpolkitagent +rpm:query:hyprpwcenter +rpm:query:hyprshutdown +rpm:query:hyprsunset +rpm:query:hyprsysteminfo +rpm:query:mpvpaper +rpm:query:quickshell +rpm:query:satty +rpm:query:uwsm +rpm:query:vicinae +rpm:query:xdg-desktop-portal-hyprland +EXPECTED +)" +for bound_repo in panama-bound-hyprland panama-bound-terra; do + grep -q -- "--repofrompath $bound_repo," \ + "$test_tmp/cases/hyprland-source-split/commands.log" \ + || fail "Hyprland source split did not bind $bound_repo to its reviewed URL" +done + reset_installer_fixture STUB_DNF_FAIL_MATCH=pnpm expect_failure run_installer_function pnpm-failure install_pnpm assert_soft_failure pnpm-failure pnpm @@ -1584,30 +2222,95 @@ expect_success run_installer_function rpmfusion install_rpmfusion_repositories assert_log rpmfusion "$(cat <<'EXPECTED' rpm:release curl:https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-44.noarch.rpm:max=4194304:output=rpmfusion-free-release.rpm +sudo:root-create +sudo:root-private +sudo:root-stage:rpmfusion-free-release.rpm +sudo:root-verify:rpmfusion-free-release.rpm +sudo:root-create +sudo:root-private +sudo:root-stage:rpmfusion-free.asc +sudo:root-verify:rpmfusion-free.asc +sudo:root-reviewable +sudo:root-reviewable gpg:fingerprint:E9A491A3DE247814E7E067EAE06F8ECDD651FF2E rpmkeys:import:rpmfusion-free.asc rpmkeys:checksig:rpmfusion-free-release.rpm +sudo:root-private +sudo:root-private curl:https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-44.noarch.rpm:max=4194304:output=rpmfusion-nonfree-release.rpm +sudo:root-create +sudo:root-private +sudo:root-stage:rpmfusion-nonfree-release.rpm +sudo:root-verify:rpmfusion-nonfree-release.rpm +sudo:root-create +sudo:root-private +sudo:root-stage:rpmfusion-nonfree.asc +sudo:root-verify:rpmfusion-nonfree.asc +sudo:root-reviewable +sudo:root-reviewable gpg:fingerprint:79BDB88F9BBF73910FD4095B6A2AF96194843C65 rpmkeys:import:rpmfusion-nonfree.asc rpmkeys:checksig:rpmfusion-nonfree-release.rpm -sudo:dnf install -y --setopt=localpkg_gpgcheck=1 RPMFUSION_FREE RPMFUSION_NONFREE +sudo:root-private +sudo:root-private +sudo:rpm-import:rpmfusion-free.asc +sudo:rpm-import:rpmfusion-nonfree.asc +sudo:dnf install -y --repo=fedora --repo=updates --setopt=localpkg_gpgcheck=1 RPMFUSION_FREE RPMFUSION_NONFREE +sudo:root-cleanup +sudo:root-cleanup +sudo:root-cleanup +sudo:root-cleanup EXPECTED )" +assert_root_snapshot_logged rpmfusion rpmfusion-free-release.rpm +assert_root_snapshot_logged rpmfusion rpmfusion-nonfree-release.rpm + +# DNF checks command-line RPMs against the system RPM keyring, not the private +# verification database. A fresh Fedora keyring therefore needs the two +# already-reviewed root snapshots imported before localpkg_gpgcheck runs. +reset_installer_fixture +STUB_REQUIRE_RPMFUSION_SYSTEM_KEYS=1 \ + expect_success run_installer_function rpmfusion-fresh-keyring \ + install_rpmfusion_repositories + +reset_installer_fixture +STUB_SWAP_AFTER_ROOT_STAGE=rpmfusion-free-release.rpm \ + expect_success run_installer_function rpmfusion-root-bound install_rpmfusion_repositories +assert_user_source_swapped rpmfusion-root-bound +assert_no_runtime_staging rpmfusion-root-bound + +reset_installer_fixture +STUB_TOGGLE_KEY_VERIFY=rpmfusion-free.asc \ + expect_failure run_installer_function rpmfusion-key-toggle install_rpmfusion_repositories +assert_user_source_swapped rpmfusion-key-toggle +[[ "$(<"$test_tmp/cases/rpmfusion-key-toggle/commands.log")" != *'sudo:dnf'* ]] \ + || fail 'same-UID RPM Fusion key toggle reached package activation' +assert_no_runtime_staging rpmfusion-key-toggle reset_installer_fixture expect_success run_installer_function terra install_terra_repository assert_log terra "$(cat <<'EXPECTED' rpm:release dnf:dump-all:locale=C -rpm:query:terra-release +sudo:root-create +sudo:root-private +sudo:root-stage:terra44.asc +sudo:root-verify:terra44.asc +sudo:root-reviewable gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F +sudo:root-private +sudo:root-verify:terra44.asc +sudo:root-create +sudo:root-private +sudo:root-stage:terra.repo +sudo:root-verify:terra.repo sudo:install:terra44.asc:/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama -sudo:dnf install -y --repofrompath terra,https://repos.fyralabs.com/terra44 --setopt=terra.pkg_gpgcheck=1 --setopt=terra.repo_gpgcheck=1 --setopt=terra.gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama terra-release sudo:install:terra.repo:/etc/yum.repos.d/terra.repo dnf:dump-all:locale=C gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F +sudo:root-cleanup +sudo:root-cleanup EXPECTED )" cmp -s "$installer_fixture/setup/provenance/keys/terra44.asc" \ @@ -1623,14 +2326,38 @@ repo_gpgcheck=1 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama EXPECTED )" +assert_root_snapshot_logged terra terra44.asc +assert_root_snapshot_logged terra terra.repo + +for terra_snapshot in terra44.asc terra.repo; do + reset_installer_fixture + name="terra-root-bound-${terra_snapshot%.*}" + STUB_SWAP_AFTER_ROOT_STAGE="$terra_snapshot" \ + expect_success run_installer_function "$name" install_terra_repository + assert_user_source_swapped "$name" + assert_no_runtime_staging "$name" +done reset_installer_fixture expect_success run_installer_function hyprland configure_hyprland_repository assert_log hyprland "$(cat <<'EXPECTED' rpm:release +sudo:root-create +sudo:root-private +sudo:root-stage:hyprland-copr.asc +sudo:root-verify:hyprland-copr.asc +sudo:root-reviewable gpg:fingerprint:97E23476C89635135407C7D5E9BA41342C4B2995 +sudo:root-private +sudo:root-verify:hyprland-copr.asc +sudo:root-create +sudo:root-private +sudo:root-stage:panama-hyprland.repo +sudo:root-verify:panama-hyprland.repo sudo:install:hyprland-copr.asc:/etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland sudo:install:panama-hyprland.repo:/etc/yum.repos.d/panama-hyprland.repo +sudo:root-cleanup +sudo:root-cleanup EXPECTED )" cmp -s "$installer_fixture/setup/provenance/keys/hyprland-copr.asc" \ @@ -1646,26 +2373,118 @@ repo_gpgcheck=0 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland EXPECTED )" +assert_root_snapshot_logged hyprland hyprland-copr.asc +assert_root_snapshot_logged hyprland panama-hyprland.repo + +# The expected root-snapshot digests must already be bound to the fingerprinted +# key and the script-authored repository stream. A swap before root staging may +# not become the new expected digest. +for pre_snapshot_spec in \ + 'authored-repo STUB_SWAP_AFTER_REPO_WRITE panama-hyprland.repo'; do + read -r suffix swap_name swap_target <<<"$pre_snapshot_spec" + reset_installer_fixture + name="hyprland-pre-snapshot-$suffix" + printf -v "$swap_name" '%s' "$swap_target" + export "$swap_name" + expect_failure run_installer_function "$name" configure_hyprland_repository + unset "$swap_name" + assert_user_source_swapped "$name" + [[ ! -e "$test_tmp/cases/$name/etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland" \ + && ! -e "$test_tmp/cases/$name/etc/yum.repos.d/panama-hyprland.repo" ]] \ + || fail "$name published bytes swapped before privileged staging" + assert_no_runtime_staging "$name" +done + +# A same-UID attacker can present malicious bytes to both digest reads while +# presenting reviewed bytes only to GPG. Verification must therefore inspect +# an immutable root-owned snapshot, not a toggled user pathname. +reset_installer_fixture +STUB_TOGGLE_KEY_VERIFY=hyprland-copr.asc \ + expect_failure run_installer_function hyprland-key-toggle configure_hyprland_repository +assert_user_source_swapped hyprland-key-toggle +[[ ! -e "$test_tmp/cases/hyprland-key-toggle/etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland" \ + && ! -e "$test_tmp/cases/hyprland-key-toggle/etc/yum.repos.d/panama-hyprland.repo" ]] \ + || fail 'same-UID key toggle published attacker-controlled bytes' +assert_no_runtime_staging hyprland-key-toggle + +for pair_snapshot in hyprland-copr.asc panama-hyprland.repo; do + reset_installer_fixture + name="hyprland-root-bound-${pair_snapshot%.*}" + STUB_SWAP_AFTER_ROOT_STAGE="$pair_snapshot" \ + expect_success run_installer_function "$name" configure_hyprland_repository + assert_user_source_swapped "$name" + cmp -s "$test_tmp/cases/hyprland/etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland" \ + "$test_tmp/cases/$name/etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland" \ + || fail "$name privileged key bytes differed after the source swap" + cmp -s "$test_tmp/cases/hyprland/etc/yum.repos.d/panama-hyprland.repo" \ + "$test_tmp/cases/$name/etc/yum.repos.d/panama-hyprland.repo" \ + || fail "$name privileged repository bytes differed after the source swap" + assert_no_runtime_staging "$name" +done reset_installer_fixture expect_success run_installer_function flathub ensure_flathub_remote assert_log flathub "$(cat <<'EXPECTED' rpm:release curl:https://flathub.org/repo/flathub.flatpakrepo:max=1048576:output=flathub.flatpakrepo +sudo:root-create +sudo:root-private +sudo:root-stage:flathub-key.asc +sudo:root-verify:flathub-key.asc +sudo:root-reviewable gpg:fingerprint:6E5C05D979C76DAF93C081354184DD4D907A7CAE +sudo:root-private sudo:flatpak remote-add --if-not-exists --gpg-import=FLATHUB_KEY flathub https://dl.flathub.org/repo/ gpg:fingerprint:6E5C05D979C76DAF93C081354184DD4D907A7CAE +sudo:root-cleanup EXPECTED )" +assert_root_snapshot_logged flathub flathub-key.asc + +reset_installer_fixture +STUB_SWAP_AFTER_ROOT_STAGE=flathub-key.asc \ + expect_success run_installer_function flathub-root-bound ensure_flathub_remote +assert_user_source_swapped flathub-root-bound +assert_no_runtime_staging flathub-root-bound + +reset_installer_fixture +STUB_TOGGLE_KEY_VERIFY=flathub-key.asc \ + expect_failure run_installer_function flathub-key-toggle ensure_flathub_remote +assert_user_source_swapped flathub-key-toggle +assert_file_bytes "$test_tmp/cases/flathub-key-toggle/flatpak-state" 'preserved' +[[ "$(<"$test_tmp/cases/flathub-key-toggle/commands.log")" != *'sudo:flatpak'* ]] \ + || fail 'same-UID Flathub key toggle reached remote activation' +assert_no_runtime_staging flathub-key-toggle reset_installer_fixture expect_success run_installer_function claude-code install_claude_code assert_log claude-code "$(cat <<'EXPECTED' rpm:release +sudo:root-create +sudo:root-private +sudo:root-stage:claude-code.asc +sudo:root-verify:claude-code.asc +sudo:root-reviewable gpg:fingerprint:31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE +sudo:root-private +sudo:root-verify:claude-code.asc +sudo:root-create +sudo:root-private +sudo:root-stage:claude-code.repo +sudo:root-verify:claude-code.repo sudo:install:claude-code.asc:/etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama sudo:install:claude-code.repo:/etc/yum.repos.d/claude-code.repo -sudo:dnf install -y --repo=claude-code --repo=fedora --repo=updates --from-repo=claude-code claude-code +sudo:root-cleanup +sudo:root-cleanup +sudo:root-create +sudo:root-private +sudo:root-stage:panama-bound-claude-code.asc +sudo:root-verify:panama-bound-claude-code.asc +sudo:root-reviewable +gpg:fingerprint:31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE +sudo:root-private +sudo:dnf install -y --repofrompath panama-bound-claude-code,https://downloads.claude.ai/claude-code/rpm/stable --repo=panama-bound-claude-code --repo=fedora --repo=updates --from-repo=panama-bound-claude-code --setopt=panama-bound-claude-code.gpgcheck=1 --setopt=panama-bound-claude-code.repo_gpgcheck=1 --setopt=panama-bound-claude-code.gpgkey=file://BOUND_KEY claude-code +sudo:root-cleanup EXPECTED )" cmp -s "$installer_fixture/setup/provenance/keys/claude-code.asc" \ @@ -1681,6 +2500,26 @@ repo_gpgcheck=1 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama EXPECTED )" +assert_root_snapshot_logged claude-code claude-code.asc +assert_root_snapshot_logged claude-code claude-code.repo +grep -q -- '--repofrompath panama-bound-claude-code,' \ + "$test_tmp/cases/claude-code/commands.log" \ + || fail 'Claude Code install did not bind the reviewed repository URL' + +for pair_snapshot in claude-code.asc claude-code.repo; do + reset_installer_fixture + name="claude-code-root-bound-${pair_snapshot%.*}" + STUB_SWAP_AFTER_ROOT_STAGE="$pair_snapshot" \ + expect_success run_installer_function "$name" install_claude_code + assert_user_source_swapped "$name" + cmp -s "$test_tmp/cases/claude-code/etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama" \ + "$test_tmp/cases/$name/etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama" \ + || fail "$name privileged key bytes differed after the source swap" + cmp -s "$test_tmp/cases/claude-code/etc/yum.repos.d/claude-code.repo" \ + "$test_tmp/cases/$name/etc/yum.repos.d/claude-code.repo" \ + || fail "$name privileged repository bytes differed after the source swap" + assert_no_runtime_staging "$name" +done reset_installer_fixture expect_success run_installer_function claude-desktop-absent install_claude_desktop_if_trusted @@ -1689,16 +2528,37 @@ assert_log claude-desktop-absent 'rpm:release' "$test_tmp/cases/claude-desktop-absent/output")" -eq 1 ]] \ || fail 'absent Claude Desktop repository did not produce exactly one manual message' +# Manual configuration is only an operator-consent gate. User-owned keys, +# symlinked repo files, and alternate metadata sources must never become the +# privileged DNF trust source even when their visible values look reviewed. +for mode in home-key symlink-repo metalink mirrorlist; do + reset_installer_fixture + name="claude-desktop-$mode" + STUB_CLAUDE_DESKTOP_REPO_MODE="$mode" \ + expect_success run_installer_function "$name" install_claude_desktop_if_trusted + [[ "$(<"$test_tmp/cases/$name/commands.log")" != *'sudo:dnf'* ]] \ + || fail "Claude Desktop $mode configuration reached package activation" +done + reset_installer_fixture STUB_CLAUDE_DESKTOP_REPO_MODE=trusted \ expect_success run_installer_function claude-desktop-trusted install_claude_desktop_if_trusted assert_log claude-desktop-trusted "$(cat <<'EXPECTED' rpm:release gpg:fingerprint:825A7D15D78BABE45646D5DF382409F597908867 +sudo:root-create +sudo:root-private +sudo:root-stage:claude-desktop.asc +sudo:root-verify:claude-desktop.asc +sudo:root-reviewable gpg:fingerprint:825A7D15D78BABE45646D5DF382409F597908867 -sudo:dnf install -y claude-desktop-extra +sudo:root-private +sudo:dnf install -y --repofrompath panama-claude-desktop,https://patrickjaja.github.io/claude-desktop-extra/rpm/ --repo=panama-claude-desktop --repo=fedora --repo=updates --from-repo=panama-claude-desktop --setopt=panama-claude-desktop.gpgcheck=1 --setopt=panama-claude-desktop.repo_gpgcheck=1 --setopt=panama-claude-desktop.gpgkey=file://CLAUDE_DESKTOP_KEY claude-desktop-extra +sudo:root-cleanup EXPECTED )" +assert_root_snapshot_logged claude-desktop-trusted claude-desktop.asc +assert_no_runtime_staging claude-desktop-trusted # Existing repository state is part of the trust boundary. Idempotency is only # success when the already-active repository matches the reviewed policy. @@ -1713,7 +2573,8 @@ EXPECTED assert_file_bytes "$test_tmp/cases/flathub-existing-trusted/flatpak-state" 'preserved' for mode in wrong-url wrong-key no-gpg alternate-key empty-alternate-key \ - duplicate-alternate-key malformed-alternate-key; do + duplicate-alternate-key malformed-alternate-key trusted-config-symlink \ + trusted-key-symlink; do reset_installer_fixture name="flathub-existing-$mode" STUB_FLATPAK_REMOTE_MODE="$mode" \ @@ -1735,7 +2596,7 @@ gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F EXPECTED )" -for mode in nogpg wrong-url wrong-key absent; do +for mode in nogpg wrong-url wrong-key trusted-key-symlink trusted-repo-symlink; do reset_installer_fixture name="terra-existing-$mode" STUB_TERRA_INSTALLED=1 STUB_TERRA_REPO_MODE="$mode" \ @@ -1744,6 +2605,15 @@ for mode in nogpg wrong-url wrong-key absent; do || fail "untrusted existing Terra $mode state reached a mutation" done +# A historical terra-release package without an active repo is recoverable: +# direct reviewed-pair publication does not depend on or mutate package state. +reset_installer_fixture +STUB_TERRA_INSTALLED=1 \ + expect_success run_installer_function terra-existing-package-only \ + install_terra_repository +[[ "$(<"$test_tmp/cases/terra-existing-package-only/commands.log")" != *'sudo:dnf'* ]] \ + || fail 'historical terra-release state triggered a bootstrap DNF transaction' + # 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 \ @@ -1857,6 +2727,70 @@ for pair_spec in \ done done +# Rollback must be uninterruptible once a signal starts it. Deliver a second +# TERM while the first prior file is being restored and require the complete +# prior pair, not a half-restored trust root. +reset_installer_fixture +STUB_PAIR_NAME=claude-code STUB_PAIR_PRIOR=present \ + STUB_SIGNAL_PAIR_AFTER_FIRST=1 STUB_SIGNAL_PAIR_TWICE=1 \ + expect_failure run_installer_function claude-code-present-double-signal \ + install_claude_code 2>/dev/null +grep -qFx 'signal:repository-pair-second' \ + "$test_tmp/cases/claude-code-present-double-signal/commands.log" \ + || fail 'double-signal case did not deliver the rollback signal' +assert_pair_rollback claude-code-present-double-signal claude-code present +assert_no_runtime_staging claude-code-present-double-signal + +# Recovery evidence is intentionally retained when rollback itself fails, and +# that trust-root failure must cross the public optional-install wrapper as 78. +# Converting it to an ordinary soft failure would let the package stage keep +# running DNF transactions after a repository pair was left indeterminate. +reset_installer_fixture +rollback_failure_status=0 +STUB_PAIR_NAME=claude-code STUB_PAIR_PRIOR=absent STUB_INSTALL_FAIL_AT=2 \ + STUB_ROLLBACK_FAIL=1 \ + run_installer_function claude-code-rollback-failure install_claude_code \ + || rollback_failure_status=$? +[[ "$rollback_failure_status" -eq 78 ]] \ + || fail "Claude Code rollback failure returned $rollback_failure_status instead of 78" +[[ -z "$(find "$test_tmp/cases/claude-code-rollback-failure/root-staging" \ + -mindepth 1 -print -quit)" ]] \ + && fail 'Claude Code rollback failure discarded its recovery evidence' +[[ ! -s "$test_tmp/cases/claude-code-rollback-failure/softly-failed" ]] \ + || fail 'Claude Code rollback failure was downgraded to a soft failure' + +agent_boundary_status=0 +run_installer_function agent-trust-boundary exercise_agent_install_boundary \ + || agent_boundary_status=$? +[[ "$agent_boundary_status" -eq 78 ]] \ + || fail "agent install trust boundary returned $agent_boundary_status instead of 78" +assert_log agent-trust-boundary "$(cat <<'EXPECTED' +agent:node +agent:pnpm +agent:bun +agent:claude:78 +EXPECTED +)" + +# A real signal between the two activation writes follows the same rollback +# path as an ordinary failure, for both prior-present and prior-absent pairs. +for pair_spec in \ + 'hyprland configure_hyprland_repository' \ + 'claude-code install_claude_code'; do + read -r pair function_name <<<"$pair_spec" + for prior in absent present; do + reset_installer_fixture + name="$pair-$prior-signal-after-first" + STUB_PAIR_NAME="$pair" STUB_PAIR_PRIOR="$prior" \ + STUB_SIGNAL_PAIR_AFTER_FIRST=1 \ + expect_failure run_installer_function "$name" "$function_name" 2>/dev/null + grep -qFx 'signal:repository-pair' "$test_tmp/cases/$name/commands.log" \ + || fail "$name did not deliver its real process-group signal" + assert_pair_rollback "$name" "$pair" "$prior" + assert_no_runtime_staging "$name" + done +done + # A Fedora version outside the reviewed policy stops every public transaction # before curl, sudo, Flatpak, or repository inspection can act. for function_name in install_rpmfusion_repositories install_terra_repository \ @@ -1902,7 +2836,21 @@ expect_failure run_installer_function rpmfusion-wrong-key install_rpmfusion_repo assert_log rpmfusion-wrong-key "$(cat <<'EXPECTED' rpm:release curl:https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-44.noarch.rpm:max=4194304:output=rpmfusion-free-release.rpm +sudo:root-create +sudo:root-private +sudo:root-stage:rpmfusion-free-release.rpm +sudo:root-verify:rpmfusion-free-release.rpm +sudo:root-create +sudo:root-private +sudo:root-stage:rpmfusion-free.asc +sudo:root-verify:rpmfusion-free.asc +sudo:root-reviewable +sudo:root-reviewable gpg:fingerprint:AE09157A4DE88B497EA1D5D300CDAB43DE226D6F +sudo:root-private +sudo:root-private +sudo:root-cleanup +sudo:root-cleanup EXPECTED )" @@ -1911,8 +2859,9 @@ STUB_RPM_SIGNATURE_FAIL=rpmfusion-free-release.rpm \ expect_failure run_installer_function rpmfusion-bad-signature install_rpmfusion_repositories [[ "$(<"$test_tmp/cases/rpmfusion-bad-signature/commands.log")" != *'rpmfusion-nonfree'* ]] \ || fail 'RPM Fusion signature failure did not stop the dependent download' -[[ "$(<"$test_tmp/cases/rpmfusion-bad-signature/commands.log")" != *'sudo:'* ]] \ - || fail 'RPM Fusion signature failure reached a privileged mutation' +[[ "$(<"$test_tmp/cases/rpmfusion-bad-signature/commands.log")" != *'sudo:dnf'* ]] \ + || fail 'RPM Fusion signature failure reached package activation' +assert_no_runtime_staging rpmfusion-bad-signature reset_installer_fixture cp "$installer_fixture/setup/provenance/keys/terra44.asc" \ @@ -1923,8 +2872,9 @@ assert_file_bytes "$test_tmp/cases/hyprland-wrong-key/etc/pki/rpm-gpg/RPM-GPG-KE 'known key' assert_file_bytes "$test_tmp/cases/hyprland-wrong-key/etc/yum.repos.d/panama-hyprland.repo" \ 'known repo' -[[ "$(<"$test_tmp/cases/hyprland-wrong-key/commands.log")" != *'sudo:'* ]] \ +[[ "$(<"$test_tmp/cases/hyprland-wrong-key/commands.log")" != *'sudo:install:'* ]] \ || fail 'Hyprland key mismatch replaced known-good repository files' +assert_no_runtime_staging hyprland-wrong-key reset_installer_fixture STUB_FLATHUB_VERIFY_LINE='NoGPGVerify=true' \ @@ -1937,8 +2887,9 @@ reset_installer_fixture STUB_FLATHUB_KEY_FILE="$installer_fixture/setup/provenance/keys/terra44.asc" \ expect_failure run_installer_function flathub-wrong-key ensure_flathub_remote assert_file_bytes "$test_tmp/cases/flathub-wrong-key/flatpak-state" 'preserved' -[[ "$(<"$test_tmp/cases/flathub-wrong-key/commands.log")" != *'sudo:'* ]] \ +[[ "$(<"$test_tmp/cases/flathub-wrong-key/commands.log")" != *'sudo:flatpak'* ]] \ || fail 'Flathub key mismatch mutated an existing remote' +assert_no_runtime_staging flathub-wrong-key reset_installer_fixture STUB_FLATHUB_URL='https://evil.invalid/repo/' \ @@ -1956,11 +2907,23 @@ assert_log claude-desktop-untrusted 'rpm:release' || fail 'untrusted Claude Desktop repository did not produce one manual message' reset_installer_fixture -STUB_DNF_FAIL_MATCH=terra-release expect_failure run_installer_function terra-dnf-failure install_terra_repository -[[ "$(tail -n 1 "$test_tmp/cases/terra-dnf-failure/commands.log")" == *'terra-release' ]] \ - || fail 'Terra DNF failure ran a later transaction command' -[[ -z "$(find "$test_tmp/cases/terra-dnf-failure/tmp" -mindepth 1 -print -quit)" ]] \ - || fail 'Terra DNF failure left private staging files behind' +terra_failure_status=0 +STUB_INSTALL_FAIL_AT=2 \ + run_installer_function terra-publication-failure install_terra_repository \ + || terra_failure_status=$? +[[ "$terra_failure_status" -eq 78 ]] \ + || fail "Terra publication failure returned $terra_failure_status instead of 78" +[[ "$(<"$test_tmp/cases/terra-publication-failure/commands.log")" != *'terra-release'* \ + && ! -s "$test_tmp/cases/terra-publication-failure/terra-rpm-state" ]] \ + || fail 'Terra direct publication changed terra-release package state' +[[ ! -e "$test_tmp/cases/terra-publication-failure/etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama" \ + && ! -e "$test_tmp/cases/terra-publication-failure/etc/yum.repos.d/terra.repo" ]] \ + || fail 'Terra publication failure did not remove its partial repository pair' +assert_no_runtime_staging terra-publication-failure + +STUB_REUSE_CASE=1 \ + expect_success run_installer_function terra-publication-failure \ + install_terra_repository [[ "$before_gnupg" == "$(snapshot "$host_gnupg")" ]] || fail 'repository cases changed host GPG state' [[ "$before_gpg_files" == "$(snapshot_gpg_state "$host_gnupg")" ]] \ diff --git a/tests/setup/root-server-bootstrap-contract b/tests/setup/root-server-bootstrap-contract index 5b4fb03..22cee8a 100755 --- a/tests/setup/root-server-bootstrap-contract +++ b/tests/setup/root-server-bootstrap-contract @@ -187,6 +187,18 @@ case "${1:-}" in *) exit 97 ;; esac ;; + ls-tree) + [[ "$#" -eq 6 && "$4" == -rz && "$5" == --full-tree \ + && "$6" == "$PANAMA_BOOT_REVISION" ]] || exit 97 + object_id="$(/usr/bin/git hash-object --no-filters -- \ + "$PANAMA_BOOT_FIXTURE_ROOT/stub-install")" || exit 97 + printf '100755 blob %s\tinstall\0' "$object_id" + ;; + hash-object) + [[ "$#" -eq 6 && "$4" == --no-filters && "$5" == -- \ + && "$6" == install ]] || exit 97 + /usr/bin/git hash-object --no-filters -- "$2/$6" + ;; *) exit 97 ;; esac ;; diff --git a/tests/setup/update-command-contract b/tests/setup/update-command-contract index 4c5c878..63ed9c1 100755 --- a/tests/setup/update-command-contract +++ b/tests/setup/update-command-contract @@ -51,7 +51,9 @@ copy_hash_inputs() { find "$repo_dir/setup/provenance" -type f -print0 ) mkdir -p "$root/setup/lib" - cp -- "$repo_dir/setup/lib/artifact-provenance" "$root/setup/lib/artifact-provenance" + cp -- "$repo_dir/setup/lib/artifact-provenance" \ + "$repo_dir/setup/lib/extras-catalog" \ + "$repo_dir/setup/lib/machine-role" "$root/setup/lib/" } # A PANAMA_PATH that looks enough like the real one for install to run, and @@ -61,7 +63,7 @@ build_fixture() { rm -rf "$root" mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \ "$root/setup/lib" "$root/setup/provenance/keys" \ - "$root/config/dot/quickshell/scripts" + "$root/config/dot/quickshell/scripts" "$root/tmp" cp "$installer" "$root/install" : >"$root/bin/ascii" @@ -126,6 +128,46 @@ EOF #!/usr/bin/env bash printf 'dnf-transaction\n' >>"$PANAMA_RAN" exit 0 +EOF + cat >"$root/shim/mv" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +destination="${!#}" +if [[ "${STUB_SIGNAL_PACKAGES_HASH:-0}" == 1 \ + && "$destination" == */state/panama/packages-hash ]]; then + printf 'signal:packages-receipt\n' >>"$PANAMA_RAN" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 +fi +exec /usr/bin/mv "$@" +EOF + cat >"$root/shim/mktemp" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${STUB_SIGNAL_HASH_WORK:-0}" == 1 && "${1:-}" == -d ]]; then + directory="$(/usr/bin/mktemp "$@")" + printf '%s\n' "$directory" + printf 'signal:packages-hash-work\n' >>"$PANAMA_RAN" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 +fi +exec /usr/bin/mktemp "$@" +EOF + cat >"$root/shim/mkdir" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +target="${!#}" +if [[ "${STUB_SIGNAL_HASH_WORK:-0}" == 1 \ + && "$(basename -- "$target")" == panama-packages-hash.* ]]; then + /usr/bin/mkdir "$@" + printf 'signal:packages-hash-work\n' >>"$PANAMA_RAN" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 +fi +exec /usr/bin/mkdir "$@" EOF for prerequisite in gum lspci mokutil fwupdmgr; do ln -s gsettings "$root/shim/$prerequisite" @@ -139,7 +181,8 @@ run_install() { local status=0 : >"$root/ran" PATH="$root/shim:$PATH" PANAMA_PATH="$root" PANAMA_RAN="$root/ran" \ - XDG_STATE_HOME="$root/state" bash "$root/install" "$@" \ + XDG_STATE_HOME="$root/state" TMPDIR="$root/tmp" \ + /usr/bin/setsid bash "$root/install" "$@" \ >"$root/out" 2>&1 || status=$? cat "$root/ran" return "$status" @@ -147,7 +190,8 @@ run_install() { run_hash() { local root="$1" - sed -n '/^hash_packages() {/,/^}$/p' "$root/install" >"$root/hash-only" + sed -n '/^_collect_package_inputs() {/,/^PACKAGE_START_HASH=/p' \ + "$root/install" >"$root/hash-only" printf 'set -uo pipefail\nhash_packages\n' >>"$root/hash-only" PANAMA_PATH="$root" bash "$root/hash-only" 2>"$root/hash-only.err" } @@ -255,7 +299,8 @@ grep -qx 'install-packages' <<<"$ran_forced" \ # Dynamically discovering them makes this fail when a new reviewed input is # added but omitted from hash_packages. for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \ - 'setup/scripts/install-packages' 'setup/lib/artifact-provenance'; do + 'setup/scripts/install-packages' 'setup/lib/artifact-provenance' \ + 'setup/lib/extras-catalog' 'setup/lib/machine-role'; do printf 'changed %s\n' "$relative" >>"$tmp/a/$relative" install_status=0 ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$? @@ -282,7 +327,8 @@ done # Fixed hash inputs must not silently disappear or degrade into a directory or # link. An unreadable package input also proves a failed content read cannot be # hidden by the final digest command. -for fixed_input in setup/scripts/install-packages setup/lib/artifact-provenance; do +for fixed_input in setup/scripts/install-packages setup/lib/artifact-provenance \ + setup/lib/extras-catalog setup/lib/machine-role; do for case_name in missing directory symlink unreadable; do case_root="$tmp/hash-${fixed_input//\//-}-$case_name" build_fixture "$case_root" @@ -305,6 +351,27 @@ build_fixture "$read_failure_root" chmod 000 "$read_failure_root/${package_inputs[0]}" assert_hash_failure "$read_failure_root" "${package_inputs[0]} unreadable" +# Discovery must reject a symlink instead of silently dropping it from the +# receipt while a later consumer follows it. +for discovered_root in setup/packages setup/provenance; do + case_root="$tmp/hash-${discovered_root//\//-}-symlink" + build_fixture "$case_root" + printf 'linked installer input\n' >"$case_root/symlink-target" + ln -s "$case_root/symlink-target" "$case_root/$discovered_root/symlink-input" + assert_hash_failure "$case_root" "$discovered_root symlink input" +done + +# Discovery roots are behavior inputs too. GNU find -P treats a symlink passed +# as its starting path as an empty traversal, so checking only descendants can +# silently erase a whole package or provenance tree from the receipt. +for discovered_root in setup/packages setup/provenance; do + case_root="$tmp/hash-${discovered_root//\//-}-root-symlink" + build_fixture "$case_root" + mv -- "$case_root/$discovered_root" "$case_root/$discovered_root.real" + ln -s "$case_root/$discovered_root.real" "$case_root/$discovered_root" + assert_hash_failure "$case_root" "$discovered_root discovery-root symlink" +done + # A hash failure is an installer failure, not a reason to skip the package # stage and retain a stale stamp. build_fixture "$tmp/hash-failure" @@ -320,6 +387,61 @@ grep -qx 'install-packages' <<<"$ran_hash_failure" \ cmp -s -- "$tmp/hash-failure/stamp-before" "$tmp/hash-failure/state/panama/packages-hash" \ || note 'a failed package-state hash wrote a new packages-hash stamp' +# The stage may race its own input receipt. A successful stage that changes a +# sourced behavior file must not stamp the new digest as though it were the +# bytes used to decide this run. +build_fixture "$tmp/hash-drift" +cat >"$tmp/hash-drift/setup/scripts/install-packages" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == --trust-preflight ]]; then + printf 'trust-preflight\n' >>"$PANAMA_RAN" + exit 0 +fi +printf 'install-packages\n' >>"$PANAMA_RAN" +printf '# changed during package stage\n' >>"$PANAMA_PATH/setup/lib/machine-role" +EOF +chmod +x "$tmp/hash-drift/setup/scripts/install-packages" +install_status=0 +run_install "$tmp/hash-drift" --upgrade >/dev/null || install_status=$? +[[ "$install_status" -ne 0 ]] \ + || note 'mid-stage package input drift returned success' +[[ ! -e "$tmp/hash-drift/state/panama/packages-hash" ]] \ + || note 'mid-stage package input drift stamped bytes the stage did not start with' + +# The hash workspace exists before command substitution publishes its pathname. +# A process-group signal in that window must still remove the private tree. +build_fixture "$tmp/hash-work-signal" +install_status=0 +signal_run="$(STUB_SIGNAL_HASH_WORK=1 \ + run_install "$tmp/hash-work-signal" --upgrade)" || install_status=$? +[[ "$install_status" -eq 143 ]] \ + || note "package hash workspace signal returned $install_status instead of 143" +grep -qx 'signal:packages-hash-work' <<<"$signal_run" \ + || note 'package hash workspace adapter did not deliver a real process-group signal' +[[ -z "$(find "$tmp/hash-work-signal/tmp" -mindepth 1 -print -quit)" ]] \ + || note 'package hash workspace signal left a private temporary directory' + +# A real process-group signal at the final receipt rename must preserve the +# prior stamp and remove the private temporary receipt. +build_fixture "$tmp/hash-receipt-signal" +run_install "$tmp/hash-receipt-signal" --upgrade >/dev/null +cp -- "$tmp/hash-receipt-signal/state/panama/packages-hash" \ + "$tmp/hash-receipt-signal/stamp-before" +install_status=0 +signal_run="$(STUB_SIGNAL_PACKAGES_HASH=1 \ + run_install "$tmp/hash-receipt-signal" --upgrade --packages)" \ + || install_status=$? +[[ "$install_status" -eq 143 ]] \ + || note "package receipt signal returned $install_status instead of 143" +grep -qx 'signal:packages-receipt' <<<"$signal_run" \ + || note 'package receipt signal adapter did not deliver a real process-group signal' +cmp -s -- "$tmp/hash-receipt-signal/stamp-before" \ + "$tmp/hash-receipt-signal/state/panama/packages-hash" \ + || note 'package receipt signal replaced the prior hash stamp' +[[ -z "$(find "$tmp/hash-receipt-signal/state/panama" \ + -name '.packages-hash.*' -print -quit)" ]] \ + || note 'package receipt signal left a temporary hash stamp' + # A failing stage must not record the hash, or the failure is hidden forever. build_fixture "$tmp/c" 1 install_status=0 @@ -368,6 +490,64 @@ for suppressed in link-dotfiles link-skills link-user change-settings install-ha && note "stage-time Terra trust failure still ran $suppressed" done +# Exercise the complete real package entrypoint at the second boundary. The +# outer preflight sees no Terra repository; the same DNF adapter exposes an +# unsafe enabled Terra identity to the package stage's own preflight. Removing +# that production call would reach the transaction marker below. +real_preflight_root="$tmp/real-second-preflight" +build_fixture "$real_preflight_root" +cp -- "$repo_dir/setup/scripts/install-packages" \ + "$real_preflight_root/setup/scripts/install-packages" +chmod +x "$real_preflight_root/setup/scripts/install-packages" +mkdir -p "$real_preflight_root/state/panama" +printf 'server\n' >"$real_preflight_root/state/panama/role" +cat >"$real_preflight_root/shim/dnf" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == '--quiet --no-plugins --dump-repo-config=*' ]]; then + count=0 + [[ ! -f "$PANAMA_DNF_DUMP_COUNT" ]] || read -r count <"$PANAMA_DNF_DUMP_COUNT" + count=$((count + 1)) + printf '%s\n' "$count" >"$PANAMA_DNF_DUMP_COUNT" + printf 'dnf-dump\n' >>"$PANAMA_RAN" + printf '======== "fedora" repository configuration: ========\n' + printf 'baseurl = \nenabled = 1\ngpgcheck = 1\n' + printf 'gpgkey = file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-44-primary\n' + printf 'metalink = https://mirrors.fedoraproject.org/metalink\nmirrorlist\n' + printf 'pkg_gpgcheck = 0\nrepo_gpgcheck = 0\n' + if (( count == 2 )); then + printf '======== "terra" repository configuration: ========\n' + printf 'baseurl = https://evil.invalid/terra44\nenabled = 1\ngpgcheck = 0\n' + printf 'gpgkey = https://evil.invalid/key\n' + printf 'metalink = \nmirrorlist = \npkg_gpgcheck = 0\nrepo_gpgcheck = 0\n' + fi + exit 0 +fi +printf 'dnf-transaction\n' >>"$PANAMA_RAN" +exit 0 +EOF +chmod +x "$real_preflight_root/shim/dnf" +printf '0\n' >"$real_preflight_root/dnf-dump-count" +: >"$real_preflight_root/ran" +real_preflight_status=0 +PATH="$real_preflight_root/shim:$PATH" \ + PANAMA_PATH="$real_preflight_root" PANAMA_RAN="$real_preflight_root/ran" \ + PANAMA_DNF_DUMP_COUNT="$real_preflight_root/dnf-dump-count" \ + XDG_STATE_HOME="$real_preflight_root/state" \ + bash "$real_preflight_root/install" --upgrade --packages \ + >"$real_preflight_root/out" 2>&1 || real_preflight_status=$? +[[ "$real_preflight_status" -eq 78 ]] \ + || note "real second repository preflight returned $real_preflight_status instead of 78" +[[ "$(<"$real_preflight_root/dnf-dump-count")" == 2 ]] \ + || note "real package entrypoint executed $(<"$real_preflight_root/dnf-dump-count") repository preflights instead of two: $(tr '\n' ' ' <"$real_preflight_root/out")" +[[ "$(grep -c '^dnf-dump$' "$real_preflight_root/ran")" -eq 2 ]] \ + || note "real second preflight fixture log was: $(tr '\n' ',' <"$real_preflight_root/ran")" +for suppressed in dnf-transaction link-dotfiles link-skills link-user change-settings \ + install-hardware; do + grep -qx "$suppressed" "$real_preflight_root/ran" \ + && note "real second repository preflight still ran $suppressed" +done + # A full install always runs the stage, whatever any recorded hash says. build_fixture "$tmp/d" install_status=0 diff --git a/user/agents/mcp/env b/user/agents/mcp/env new file mode 100644 index 0000000..10ef8a3 --- /dev/null +++ b/user/agents/mcp/env @@ -0,0 +1,4 @@ +# Machine-local MCP tokens. Ignored by git on purpose: Panama is public. +# Each name matches the token variable column in servers. +NANOKVM_FEDORA_TOKEN='Bearer nag_mcp_hXDIbCWCAHPVcTNb5sgAU-91BVsRL3k_WDYe4ZmqDAQ' +NANOKVM_MAC_TOKEN='Bearer nag_mcp_yzUyyioyirCAZvpkku5LVL0Riocbr1imZlxf_-JEcPI'