From 8264ca632090d2d34bdee2dbdbee662aea9579c8 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 27 Aug 2026 07:49:12 -0400 Subject: [PATCH] Fix: Pin runtime and agent artifacts --- setup/scripts/install-packages | 355 ++++++++++---- .../quickshell/declared-dependencies-contract | 10 +- tests/setup/package-provenance-contract | 445 +++++++++++++++++- 3 files changed, 719 insertions(+), 91 deletions(-) diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index 5338dd7..521bc4e 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -107,72 +107,273 @@ install_list() { fi } -# --- Node and pnpm, through nvm ---------------------------------------------- -# -# nvm is a shell function rather than a binary, so it has to be sourced before -# it can be used at all -- and its script reads variables that `set -u` above -# treats as fatal, so the strictness is lifted for exactly that source and put -# straight back. -# -# Deliberately not dnf's nodejs: config/bash/shell switches Node per project -# from .nvmrc, and a system Node earlier on PATH would win every switch, leaving -# `nvm use` looking like it did nothing. -# -# pnpm goes inside the nvm-managed Node rather than beside it as its own dnf -# package, so it travels with the version it belongs to instead of outliving it. +# --- Reviewed language runtimes and agent tools ------------------------------ + +_record_installer_failure() { + local component="$1" + log "$component install did not complete; continuing" + softly_failed+=("$component") + return 1 +} + +_set_artifact_arch() { + local machine_arch + machine_arch="$(uname -m)" || return 1 + case "$machine_arch" in + x86_64) artifact_arch=X86_64 ;; + aarch64) artifact_arch=AARCH64 ;; + *) log "Unsupported architecture: $machine_arch"; return 1 ;; + esac +} + +_archive_member_is_safe() { + local member="$1" expected_top="$2" + [[ -n "$member" && "$member" != /* && "$member" != *'//'* + && ! "$member" =~ (^|/)\.\.?(/|$) + && ( "$member" == "$expected_top" || "$member" == "$expected_top/" \ + || "$member" == "$expected_top/"* ) ]] +} + +_tree_links_stay_inside() { + local root="$1" link resolved + while IFS= read -r -d '' link; do + resolved="$(realpath -m -- "$link")" || return 1 + [[ "$resolved" == "$root" || "$resolved" == "$root/"* ]] || return 1 + done < <(find "$root" -type l -print0) +} + +_atomic_symlink() { + local target="$1" destination="$2" directory temporary + directory="$(dirname -- "$destination")" + mkdir -p -- "$directory" || return 1 + temporary="$(mktemp "$directory/.$(basename -- "$destination").link.XXXXXX")" || return 1 + rm -f -- "$temporary" || return 1 + ln -s -- "$target" "$temporary" || return 1 + if ! mv -Tf -- "$temporary" "$destination"; then + rm -f -- "$temporary" + return 1 + fi +} + +_load_nvm() { + local nvm_script="$PANAMA_SYSTEM_ETC/profile.d/nvm.sh" + [[ -s "$nvm_script" ]] || return 1 + set +u + # shellcheck source=/dev/null + source "$nvm_script" + set -u + declare -F nvm >/dev/null +} + +_install_node() { + local artifact_arch machine_arch archive_top parent target stage archive extract listing member + _set_artifact_arch || return 1 + _load_nvm || return 1 + case "$artifact_arch" in + X86_64) machine_arch=x64 ;; + AARCH64) machine_arch=arm64 ;; + esac + archive_top="node-v${INSTALLER_PROVENANCE[NODE_VERSION]}-linux-$machine_arch" + parent="${NVM_DIR:-$HOME/.nvm}/versions/node" + target="$parent/v${INSTALLER_PROVENANCE[NODE_VERSION]}" + if [[ -e "$target" || -L "$target" ]]; then + [[ -d "$target" && ! -L "$target" && -x "$target/bin/node" + && "$($target/bin/node --version 2>/dev/null)" == "v${INSTALLER_PROVENANCE[NODE_VERSION]}" ]] \ + || return 1 + nvm alias default "${INSTALLER_PROVENANCE[NODE_VERSION]}" >/dev/null 2>&1 || return 1 + return 0 + fi + mkdir -p -- "$parent" || return 1 + stage="$(mktemp -d "$parent/.v${INSTALLER_PROVENANCE[NODE_VERSION]}.stage.XXXXXX")" \ + || return 1 + chmod 0700 "$stage" + archive="$stage/artifact" + extract="$stage/extract" + mkdir -m 0700 "$extract" || { rm -rf -- "$stage"; return 1; } + if ! download_sha256 "${INSTALLER_PROVENANCE[NODE_${artifact_arch}_URL]}" \ + "${INSTALLER_PROVENANCE[NODE_${artifact_arch}_SHA256]}" \ + "${INSTALLER_PROVENANCE[NODE_${artifact_arch}_MAX_BYTES]}" "$archive"; then + rm -rf -- "$stage" + return 1 + fi + listing="$(tar -tJf "$archive")" || { rm -rf -- "$stage"; return 1; } + [[ -n "$listing" ]] || { rm -rf -- "$stage"; return 1; } + while IFS= read -r member; do + _archive_member_is_safe "$member" "$archive_top" \ + || { rm -rf -- "$stage"; return 1; } + done <<<"$listing" + tar -xJf "$archive" --no-same-owner --no-same-permissions -C "$extract" \ + || { rm -rf -- "$stage"; return 1; } + [[ -d "$extract/$archive_top" && ! -L "$extract/$archive_top" + && -x "$extract/$archive_top/bin/node" + && "$($extract/$archive_top/bin/node --version 2>/dev/null)" \ + == "v${INSTALLER_PROVENANCE[NODE_VERSION]}" ]] \ + || { rm -rf -- "$stage"; return 1; } + _tree_links_stay_inside "$extract/$archive_top" \ + || { rm -rf -- "$stage"; return 1; } + mv -- "$extract/$archive_top" "$target" || { rm -rf -- "$stage"; return 1; } + rm -rf -- "$stage" + nvm alias default "${INSTALLER_PROVENANCE[NODE_VERSION]}" >/dev/null 2>&1 +} + +install_node() { + _install_node || _record_installer_failure Node +} + +# Kept as the call-site name used by the desktop-first ordering contract. setup_node() { - if [[ -s /etc/profile.d/nvm.sh ]]; then - log "Installing the latest Node LTS through nvm" - set +u - # shellcheck source=/dev/null - source /etc/profile.d/nvm.sh - if nvm install --lts >/dev/null 2>&1; then - nvm alias default 'lts/*' >/dev/null 2>&1 || true - npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); } - log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)" - else - log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)") - fi - set -u - else - log "nvm is not installed, so Node was not set up" - fi + install_node } -# --- Applications no repository packages ------------------------------------- -# -# Everything else Panama installs comes from dnf or Flathub. These do not -# exist in either, so each is an explicit exception with a reason, and each is -# skipped when already present so a re-run costs nothing. -# -# None of them pins a version. sunhat pinned URLs -- upscayl 2.11.5, LACT 0.5.4, -# a fedora-40 RPM -- and every one of them was a 404 within a release cycle. An -# installer that resolves "latest" keeps working; one that names a version rots. -# -# A failure here is logged and stepped over rather than aborting: an -# unreachable third-party host should not cost the rest of the run. +install_pnpm() { + if sudo dnf install -y pnpm >/dev/null; then + return 0 + fi + _record_installer_failure pnpm +} + +_install_bun() { + local artifact_arch archive_top target bin_link parent stage archive listing + local staged_binary version_dir + _set_artifact_arch || return 1 + case "$artifact_arch" in + X86_64) archive_top=bun-linux-x64 ;; + AARCH64) archive_top=bun-linux-aarch64 ;; + esac + version_dir="$HOME/.bun/versions/${INSTALLER_PROVENANCE[BUN_VERSION]}" + target="$version_dir/bin/bun" + bin_link="$HOME/.bun/bin/bun" + if [[ -e "$version_dir" || -L "$version_dir" ]]; then + [[ -d "$version_dir" && ! -L "$version_dir" && -x "$target" + && "$($target --version 2>/dev/null)" == "${INSTALLER_PROVENANCE[BUN_VERSION]}" ]] \ + || return 1 + [[ -L "$bin_link" && "$(readlink -- "$bin_link")" == "$target" ]] \ + || _atomic_symlink "$target" "$bin_link" + return + 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" + archive="$stage/artifact" + if ! download_sha256 "${INSTALLER_PROVENANCE[BUN_${artifact_arch}_URL]}" \ + "${INSTALLER_PROVENANCE[BUN_${artifact_arch}_SHA256]}" \ + "${INSTALLER_PROVENANCE[BUN_${artifact_arch}_MAX_BYTES]}" "$archive"; then + rm -rf -- "$stage" + return 1 + fi + listing="$(unzip -Z1 "$archive")" || { rm -rf -- "$stage"; return 1; } + [[ "$listing" == "$archive_top/bun" ]] || { rm -rf -- "$stage"; return 1; } + _archive_member_is_safe "$listing" "$archive_top" \ + || { rm -rf -- "$stage"; return 1; } + mkdir -m 0700 "$stage/extract" "$stage/version" "$stage/version/bin" \ + || { rm -rf -- "$stage"; return 1; } + unzip -q "$archive" -d "$stage/extract" || { rm -rf -- "$stage"; return 1; } + staged_binary="$stage/extract/$archive_top/bun" + [[ -f "$staged_binary" && ! -L "$staged_binary" && -x "$staged_binary" + && "$($staged_binary --version 2>/dev/null)" == "${INSTALLER_PROVENANCE[BUN_VERSION]}" ]] \ + || { rm -rf -- "$stage"; return 1; } + mv -- "$staged_binary" "$stage/version/bin/bun" \ + || { rm -rf -- "$stage"; return 1; } + mv -- "$stage/version" "$version_dir" || { rm -rf -- "$stage"; return 1; } + rm -rf -- "$stage" + _atomic_symlink "$target" "$bin_link" +} -# Bun: the JavaScript runtime and package manager. No RPM, no flatpak. install_bun() { - if [[ -x "$HOME/.bun/bin/bun" ]]; then - log "Bun already installed at \"$HOME/.bun/bin/bun\"" - else - log "Installing Bun via curl..." - curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); } - fi + _install_bun || _record_installer_failure Bun } -# Codex: OpenAI's CLI. Distributed through npm, which is why this runs after -# setup_node -- the nvm-managed Node is the one it should land in. -install_codex() { - if command -v codex >/dev/null 2>&1; then - log "Codex already installed at \"$(command -v codex)\"" - elif command -v npm >/dev/null 2>&1; then - log "Installing Codex via npm..." - npm install -g @openai/codex >/dev/null 2>&1 || { log "Codex install failed; skipping"; softly_failed+=("Codex"); } - else - log "npm is not available, so Codex was not installed"; softly_failed+=("Codex") +_codex_version_matches() { + local binary="$1" output version_pattern + output="$($binary --version 2>/dev/null)" || return 1 + version_pattern="${INSTALLER_PROVENANCE[CODEX_VERSION]//./\\.}" + [[ "$output" =~ (^|[^0-9])${version_pattern}([^0-9]|$) ]] +} + +_install_codex() { + local artifact_arch machine_arch archive_name version_dir target bin_link parent + local stage archive listing staged_binary + _set_artifact_arch || return 1 + case "$artifact_arch" in + X86_64) machine_arch=x86_64 ;; + AARCH64) machine_arch=aarch64 ;; + esac + archive_name="codex-$machine_arch-unknown-linux-musl" + version_dir="$HOME/.local/lib/panama/codex/${INSTALLER_PROVENANCE[CODEX_VERSION]}" + target="$version_dir/codex" + bin_link="$HOME/.local/bin/codex" + if [[ -e "$version_dir" || -L "$version_dir" ]]; then + [[ -d "$version_dir" && ! -L "$version_dir" && -x "$target" ]] || return 1 + _codex_version_matches "$target" || return 1 + [[ -L "$bin_link" && "$(readlink -- "$bin_link")" == "$target" ]] \ + || _atomic_symlink "$target" "$bin_link" + return 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" + archive="$stage/artifact" + if ! download_sha256 "${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_URL]}" \ + "${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_SHA256]}" \ + "${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_MAX_BYTES]}" "$archive"; then + rm -rf -- "$stage" + return 1 + fi + listing="$(tar -tzf "$archive")" || { rm -rf -- "$stage"; return 1; } + [[ "$listing" == "$archive_name" ]] || { rm -rf -- "$stage"; return 1; } + [[ "$listing" != /* && ! "$listing" =~ (^|/)\.\.?(/|$) ]] \ + || { rm -rf -- "$stage"; return 1; } + mkdir -m 0700 "$stage/extract" "$stage/version" \ + || { rm -rf -- "$stage"; return 1; } + tar -xzf "$archive" --no-same-owner --no-same-permissions -C "$stage/extract" \ + || { rm -rf -- "$stage"; return 1; } + staged_binary="$stage/extract/$archive_name" + [[ -f "$staged_binary" && ! -L "$staged_binary" && -x "$staged_binary" ]] \ + || { rm -rf -- "$stage"; return 1; } + _codex_version_matches "$staged_binary" \ + || { rm -rf -- "$stage"; return 1; } + mv -- "$staged_binary" "$stage/version/codex" \ + || { rm -rf -- "$stage"; return 1; } + mv -- "$stage/version" "$version_dir" || { rm -rf -- "$stage"; return 1; } + rm -rf -- "$stage" + _atomic_symlink "$target" "$bin_link" +} + +install_codex() { + _install_codex || _record_installer_failure Codex +} + +_install_rustdesk() { + local artifact_arch installed_version="" work rpm_path status=0 + _set_artifact_arch || return 1 + if [[ "$artifact_arch" == AARCH64 ]]; then + log "RustDesk ${INSTALLER_PROVENANCE[RUSTDESK_VERSION]} has no reviewed aarch64 RPM" + return 1 + fi + installed_version="$(rpm -q --queryformat '%{VERSION}' rustdesk 2>/dev/null)" || true + if [[ "$installed_version" == "${INSTALLER_PROVENANCE[RUSTDESK_VERSION]}" ]]; then + return 0 + fi + work="$(mktemp -d)" || return 1 + chmod 0700 "$work" + rpm_path="$work/rustdesk.rpm" + if ! download_sha256 "${INSTALLER_PROVENANCE[RUSTDESK_X86_64_URL]}" \ + "${INSTALLER_PROVENANCE[RUSTDESK_X86_64_SHA256]}" \ + "${INSTALLER_PROVENANCE[RUSTDESK_X86_64_MAX_BYTES]}" "$rpm_path"; then + rm -rf -- "$work" + return 1 + fi + sudo dnf install -y --setopt=localpkg_gpgcheck=1 "$rpm_path" >/dev/null || status=$? + rm -rf -- "$work" + return "$status" +} + +install_rustdesk() { + _install_rustdesk || _record_installer_failure RustDesk } # --- What was stepped over --------------------------------------------------- @@ -713,7 +914,7 @@ ensure_flathub_remote() { return "$status" } -install_claude_code() { +_install_claude_code() { local work staged_key staged_repo status if command -v claude >/dev/null 2>&1; then log "Claude Code already installed at \"$(command -v claude)\"" @@ -750,6 +951,10 @@ install_claude_code() { return "$status" } +install_claude_code() { + _install_claude_code || _record_installer_failure "Claude Code" +} + _claude_desktop_manual() { log "Claude Desktop is optional; configure its reviewed local-key repository manually to install it" } @@ -811,10 +1016,13 @@ if [[ "$ROLE" == server ]]; then sudo dnf update -y --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 + set -e report_soft_failures exit 0 fi @@ -931,10 +1139,13 @@ else log "Package list was not in specified path: $DEV_FILE" fi +set +e setup_node +install_pnpm install_bun install_claude_code install_codex +set -e # Claude Desktop remains optional. Panama never downloads its community setup # script; only a repository an operator has already configured with the exact @@ -944,29 +1155,9 @@ if ! install_claude_desktop_if_trusted; then softly_failed+=("Claude Desktop") fi -# RustDesk: remote desktop. The flatpak cannot register the root-owned system -# service that unattended access needs -- see panama-doctor's rustdesk check -- -# so this takes the RPM. The download URL is resolved from the latest release -# rather than written down, so it does not go stale. -if rpm -q rustdesk >/dev/null 2>&1; then - log "RustDesk already installed" -else - log "Resolving the latest RustDesk release..." - # `|| true` because a failed curl -- unauthenticated GitHub API calls get - # rate-limited -- would otherwise trip set -e and kill the stage before the - # empty-result fallback below could do its job. - rustdesk_url="$(curl -fsSL https://api.github.com/repos/rustdesk/rustdesk/releases/latest 2>/dev/null \ - | jq -r --arg arch "$(uname -m)" '.assets[].browser_download_url | select(test($arch + "\\.rpm$")) | select(test("suse") | not)' \ - | head -1 || true)" - if [[ -n "$rustdesk_url" ]]; then - log "Installing RustDesk from $rustdesk_url" - # The RPM ships rustdesk.service already enabled, which is what provides - # unattended access; Panama deliberately does not start it a second time. - sudo dnf install -y "$rustdesk_url" > /dev/null || { log "RustDesk install failed; skipping"; softly_failed+=("RustDesk"); } - else - log "Could not resolve a RustDesk release; skipping"; softly_failed+=("RustDesk") - fi -fi +# The RPM ships rustdesk.service already enabled, which is what provides +# unattended access; Panama deliberately does not start it a second time. +install_rustdesk || true # --- Install Flatpak Packages --- FLATPAK_FILE="$PANAMA_PATH/setup/packages/flatpak-packages" diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract index 4ff42c1..c088aec 100755 --- a/tests/quickshell/declared-dependencies-contract +++ b/tests/quickshell/declared-dependencies-contract @@ -36,7 +36,7 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio # authselect is on the list for the same reason: it manages Fedora's PAM and # nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the # fingerprint aliases in config/bash can rely on it without declaring it. -BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig|sysctl)$' +BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|tar|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig|sysctl)$' # bootctl and coredumpctl ship in systemd-udev, which every Fedora install # carries -- it is the udev half of systemd, not an optional tool. Declaring @@ -45,10 +45,10 @@ BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|fi SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|coredumpctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$' # Installed by install-packages itself rather than by a package list. Two -# reasons, both deliberate: bun, claude and codex have no RPM or flatpak at -# all (codex comes through npm), and node, npm and pnpm come from nvm on -# purpose -- a dnf nodejs earlier on PATH would win every per-project -# `nvm use`, which is the whole point of having nvm. +# reasons, both deliberate: bun and codex use reviewed release archives, +# Claude Code and pnpm use signed DNF repositories, and node/npm use a reviewed +# Node archive inside nvm's version directory. A system nodejs earlier on PATH +# would win every per-project `nvm use`, which is the whole point of having nvm. # Anything added here needs a matching install block and a stated reason. SELF_INSTALLED='^(bun|claude|codex|node|npm|pnpm)$' diff --git a/tests/setup/package-provenance-contract b/tests/setup/package-provenance-contract index c4c35c5..498483e 100755 --- a/tests/setup/package-provenance-contract +++ b/tests/setup/package-provenance-contract @@ -106,6 +106,28 @@ before_system_flathub_key="$(snapshot_file_state /var/lib/flatpak/repo/flathub.t before_user_flatpak="$(snapshot_file_state "$HOME/.local/share/flatpak/repo/config")" before_bashrc="$(snapshot_file_state "$HOME/.bashrc")" +# Runtime and agent installs must consume the reviewed provenance table. Keep +# 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" +unsafe_installers=() +for forbidden in \ + 'curl[^|]*\|[[:space:]]*bash' \ + 'nvm[[:space:]]+install[[:space:]]+--lts' \ + 'npm[[:space:]]+install[[:space:]]+-g[[:space:]]+pnpm' \ + 'npm[[:space:]]+install[[:space:]]+-g[[:space:]]+@openai/codex' \ + 'releases/latest' \ + 'api\.github\.com/.*/releases/latest'; do + while IFS= read -r finding; do + [[ -n "$finding" ]] && unsafe_installers+=("$finding") + done < <(grep -nE "$forbidden" "$installer" || true) +done +if (( ${#unsafe_installers[@]} > 0 )); then + printf 'package provenance contract: moving or piped installer inputs:\n' >&2 + printf ' %s\n' "${unsafe_installers[@]}" >&2 + fail 'replace each finding with a reviewed, verified installation path' +fi + # This must be the only production file sourced by the contract. # shellcheck source=../../setup/lib/artifact-provenance source "$repo_dir/setup/lib/artifact-provenance" @@ -228,6 +250,22 @@ expect_failure rpm_signature_matches \ "$test_tmp/wrong-signer-fixture.rpm" "$test_tmp/combined-key.asc" "$fixture_fingerprint" expect_success load_installer_provenance "$config" [[ "${INSTALLER_PROVENANCE[BUN_VERSION]:-}" == '1.4.0' ]] || fail 'valid provenance was not loaded' +for reviewed_value in \ + 'NODE_VERSION 24.20.0' \ + 'NODE_X86_64_SHA256 2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2' \ + 'NODE_AARCH64_SHA256 5f4ddab610c1ab2016b3c227cebdbf6d9495161487e4739c7b90090595f465f7' \ + 'BUN_VERSION 1.4.0' \ + 'BUN_X86_64_SHA256 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ + 'BUN_AARCH64_SHA256 4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e' \ + 'CODEX_VERSION 0.150.1' \ + 'CODEX_X86_64_SHA256 00aba704f029f6dc0d948be407a756e0c97cc840132fd691353b2c6b0a505b17' \ + 'CODEX_AARCH64_SHA256 1ecac3f87823efb98153233b076ea3d6e34a7a8cebe43c5285dc5f79e1514639' \ + 'RUSTDESK_VERSION 1.4.9' \ + 'RUSTDESK_X86_64_SHA256 eb1b053ac5b2f774f2271f7fbbfd2ea475899f7a55135c5e172bc54b9388f108'; do + read -r name expected <<<"$reviewed_value" + [[ "${INSTALLER_PROVENANCE[$name]:-}" == "$expected" ]] \ + || fail "$name does not match the reviewed release" +done for key_spec in \ 'terra44 TERRA_FINGERPRINT' \ 'claude-code CLAUDE_CODE_FINGERPRINT' \ @@ -286,11 +324,67 @@ cp "$repo_dir"/setup/provenance/keys/*.asc "$installer_fixture/setup/provenance/ sed '/^# --- The server path/,$d' "$repo_dir/setup/scripts/install-packages" \ > "$installer_fixture/setup/scripts/install-packages" +artifact_root="$test_tmp/runtime-artifacts" +mkdir -p "$artifact_root/build" +for arch_spec in \ + 'x86_64 x64 x64' \ + 'aarch64 arm64 aarch64'; do + read -r machine node_arch bun_arch <<<"$arch_spec" + node_top="node-v24.20.0-linux-$node_arch" + mkdir -p "$artifact_root/build/$node_top/bin" + printf '#!/usr/bin/env bash\nprintf "v24.20.0\\n"\n' \ + > "$artifact_root/build/$node_top/bin/node" + chmod +x "$artifact_root/build/$node_top/bin/node" + tar -C "$artifact_root/build" -cJf "$artifact_root/node-$machine.tar.xz" "$node_top" + rm -rf -- "$artifact_root/build/$node_top" + + bun_top="bun-linux-$bun_arch" + mkdir -p "$artifact_root/build/$bun_top" + printf '#!/usr/bin/env bash\nprintf "1.4.0\\n"\n' \ + > "$artifact_root/build/$bun_top/bun" + chmod +x "$artifact_root/build/$bun_top/bun" + (cd "$artifact_root/build" && zip -q "$artifact_root/bun-$machine.zip" "$bun_top/bun") + rm -rf -- "$artifact_root/build/$bun_top" + + codex_name="codex-$machine-unknown-linux-musl" + printf '#!/usr/bin/env bash\nprintf "codex-cli 0.150.1\\n"\n' \ + > "$artifact_root/build/$codex_name" + chmod +x "$artifact_root/build/$codex_name" + tar -C "$artifact_root/build" -czf "$artifact_root/codex-$machine.tar.gz" "$codex_name" + rm -f -- "$artifact_root/build/$codex_name" +done +mkdir -p "$artifact_root/build/wrong-node/bin" "$artifact_root/build/wrong-codex" +printf '#!/usr/bin/env bash\nprintf "v24.20.0\\n"\n' > "$artifact_root/build/wrong-node/bin/node" +printf '#!/usr/bin/env bash\nprintf "codex-cli 0.150.1\\n"\n' > "$artifact_root/build/wrong-codex/codex" +chmod +x "$artifact_root/build/wrong-node/bin/node" "$artifact_root/build/wrong-codex/codex" +tar -C "$artifact_root/build" -cJf "$artifact_root/node-bad.tar.xz" wrong-node +tar -C "$artifact_root/build" -czf "$artifact_root/codex-bad.tar.gz" wrong-codex/codex +mkdir -p "$artifact_root/build/bun-linux-x64" +printf '#!/usr/bin/env bash\nprintf "1.4.0\\n"\n' > "$artifact_root/build/bun-linux-x64/bun" +printf 'unexpected\n' > "$artifact_root/build/bun-linux-x64/extra" +chmod +x "$artifact_root/build/bun-linux-x64/bun" +(cd "$artifact_root/build" && zip -q "$artifact_root/bun-bad.zip" \ + bun-linux-x64/bun bun-linux-x64/extra) +printf 'reviewed rustdesk fixture\n' > "$artifact_root/rustdesk.rpm" +rm -rf -- "$artifact_root/build" + make_stub_commands() { local case_root="$1" - mkdir -p "$case_root/bin" "$case_root/home" "$case_root/tmp" "$case_root/etc/yum.repos.d" \ + 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" + cat > "$case_root/etc/profile.d/nvm.sh" <<'STUB' +nvm() { + printf 'nvm:%s\n' "$*" >> "$COMMAND_LOG" +} +STUB + + cat > "$case_root/bin/uname" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "${STUB_ARCH:-x86_64}" +STUB + cat > "$case_root/bin/rpm" <<'STUB' #!/usr/bin/env bash set -euo pipefail @@ -298,10 +392,15 @@ if [[ "$*" == '-E %fedora' ]]; then printf 'rpm:release\n' >> "$COMMAND_LOG" printf '%s\n' "${STUB_FEDORA_RELEASE:-44}" elif [[ "${1:-}" == -q ]]; then - printf 'rpm:query:%s\n' "${2:-}" >> "$COMMAND_LOG" - case "${2:-}" in + package="${!#}" + printf 'rpm:query:%s\n' "$package" >> "$COMMAND_LOG" + case "$package" in terra-release) [[ "${STUB_TERRA_INSTALLED:-0}" == 1 ]] ;; claude-desktop-extra) [[ "${STUB_CLAUDE_DESKTOP_INSTALLED:-0}" == 1 ]] ;; + rustdesk) + [[ -n "${STUB_RUSTDESK_VERSION:-}" ]] || exit 1 + [[ "$*" != *--queryformat* ]] || printf '%s' "$STUB_RUSTDESK_VERSION" + ;; *) exit 1 ;; esac else @@ -328,12 +427,60 @@ output_name="$(basename "$output")" output_name="${output_name#.}" output_name="${output_name%.part.*}" printf 'curl:%s:max=%s:output=%s\n' "$url" "$max_filesize" "$output_name" >> "$COMMAND_LOG" +if [[ "${STUB_DOWNLOAD_INTERRUPT:-}" == 1 ]]; then + printf 'partial' > "$output" + exit 42 +fi case "$url" in *rpmfusion-free*) cp "$SIGNED_RPM" "$output" ;; *rpmfusion-nonfree*) cp "$SIGNED_RPM" "$output" ;; *flathub.flatpakrepo) cp "$FLATHUB_DESCRIPTOR" "$output" ;; + *node-v24.20.0-linux-x64.tar.xz) + cp "$ARTIFACT_ROOT/${STUB_BAD_LAYOUT:+node-bad.tar.xz}" "$output" 2>/dev/null \ + || cp "$ARTIFACT_ROOT/node-x86_64.tar.xz" "$output" + ;; + *node-v24.20.0-linux-arm64.tar.xz) cp "$ARTIFACT_ROOT/node-aarch64.tar.xz" "$output" ;; + *bun-linux-x64.zip) + cp "$ARTIFACT_ROOT/${STUB_BAD_LAYOUT:+bun-bad.zip}" "$output" 2>/dev/null \ + || cp "$ARTIFACT_ROOT/bun-x86_64.zip" "$output" + ;; + *bun-linux-aarch64.zip) cp "$ARTIFACT_ROOT/bun-aarch64.zip" "$output" ;; + *codex-package-x86_64-unknown-linux-musl.tar.gz) + cp "$ARTIFACT_ROOT/${STUB_BAD_LAYOUT:+codex-bad.tar.gz}" "$output" 2>/dev/null \ + || cp "$ARTIFACT_ROOT/codex-x86_64.tar.gz" "$output" + ;; + *codex-package-aarch64-unknown-linux-musl.tar.gz) cp "$ARTIFACT_ROOT/codex-aarch64.tar.gz" "$output" ;; + *rustdesk-1.4.9-0.x86_64.rpm) cp "$ARTIFACT_ROOT/rustdesk.rpm" "$output" ;; *) exit 66 ;; esac +STUB + + cat > "$case_root/bin/sha256sum" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +file="${!#}" +if [[ "${STUB_DIGEST_MISMATCH:-}" == 1 ]]; then + printf '%064d %s\n' 0 "$file" + exit 0 +fi +for spec in \ + 'node-x86_64.tar.xz 2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2' \ + 'node-aarch64.tar.xz 5f4ddab610c1ab2016b3c227cebdbf6d9495161487e4739c7b90090595f465f7' \ + 'node-bad.tar.xz 2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2' \ + 'bun-x86_64.zip 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ + 'bun-aarch64.zip 4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e' \ + 'bun-bad.zip 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ + 'codex-x86_64.tar.gz 00aba704f029f6dc0d948be407a756e0c97cc840132fd691353b2c6b0a505b17' \ + 'codex-aarch64.tar.gz 1ecac3f87823efb98153233b076ea3d6e34a7a8cebe43c5285dc5f79e1514639' \ + 'codex-bad.tar.gz 00aba704f029f6dc0d948be407a756e0c97cc840132fd691353b2c6b0a505b17' \ + 'rustdesk.rpm eb1b053ac5b2f774f2271f7fbbfd2ea475899f7a55135c5e172bc54b9388f108'; do + read -r fixture digest <<<"$spec" + if cmp -s "$file" "$ARTIFACT_ROOT/$fixture"; then + printf '%s %s\n' "$digest" "$file" + exit 0 + fi +done +/usr/bin/sha256sum "$@" STUB cat > "$case_root/bin/gpg" <<'STUB' @@ -418,6 +565,7 @@ for argument in "$@"; do case "$(basename "$argument")" in rpmfusion-free-release.rpm) logged+=(RPMFUSION_FREE) ;; rpmfusion-nonfree-release.rpm) logged+=(RPMFUSION_NONFREE) ;; + rustdesk.rpm) logged+=(RUSTDESK_LOCAL) ;; flathub-key.asc) logged+=(FLATHUB_KEY) ;; *) logged+=("$argument") ;; esac @@ -588,6 +736,73 @@ run_installer_function() { : > "$case_root/commands.log" printf '0\n' > "$case_root/install-counter" printf 'preserved\n' > "$case_root/flatpak-state" + : > "$case_root/softly-failed" + case "${STUB_SEED_OLD:-}" in + Node) + mkdir -p "$case_root/home/.nvm/versions/node/v23.0.0/bin" + printf '#!/usr/bin/env bash\nprintf "v23.0.0\\n"\n' \ + > "$case_root/home/.nvm/versions/node/v23.0.0/bin/node" + chmod +x "$case_root/home/.nvm/versions/node/v23.0.0/bin/node" + ;; + Bun) + mkdir -p "$case_root/home/.bun/versions/1.3.0/bin" "$case_root/home/.bun/bin" + printf '#!/usr/bin/env bash\nprintf "1.3.0\\n"\n' \ + > "$case_root/home/.bun/versions/1.3.0/bin/bun" + chmod +x "$case_root/home/.bun/versions/1.3.0/bin/bun" + ln -s "$case_root/home/.bun/versions/1.3.0/bin/bun" "$case_root/home/.bun/bin/bun" + ;; + Codex) + mkdir -p "$case_root/home/.local/lib/panama/codex/0.149.0" "$case_root/home/.local/bin" + printf '#!/usr/bin/env bash\nprintf "codex-cli 0.149.0\\n"\n' \ + > "$case_root/home/.local/lib/panama/codex/0.149.0/codex" + chmod +x "$case_root/home/.local/lib/panama/codex/0.149.0/codex" + ln -s "$case_root/home/.local/lib/panama/codex/0.149.0/codex" \ + "$case_root/home/.local/bin/codex" + ;; + esac + case "${STUB_SEED_EXACT:-}" in + Node) + mkdir -p "$case_root/home/.nvm/versions/node/v24.20.0/bin" + printf '#!/usr/bin/env bash\nprintf "v24.20.0\\n"\n' \ + > "$case_root/home/.nvm/versions/node/v24.20.0/bin/node" + chmod +x "$case_root/home/.nvm/versions/node/v24.20.0/bin/node" + ;; + Bun) + mkdir -p "$case_root/home/.bun/versions/1.4.0/bin" "$case_root/home/.bun/bin" + printf '#!/usr/bin/env bash\nprintf "1.4.0\\n"\n' \ + > "$case_root/home/.bun/versions/1.4.0/bin/bun" + chmod +x "$case_root/home/.bun/versions/1.4.0/bin/bun" + ln -s "$case_root/home/.bun/versions/1.4.0/bin/bun" "$case_root/home/.bun/bin/bun" + ;; + Codex) + mkdir -p "$case_root/home/.local/lib/panama/codex/0.150.1" "$case_root/home/.local/bin" + printf '#!/usr/bin/env bash\nprintf "codex-cli 0.150.1\\n"\n' \ + > "$case_root/home/.local/lib/panama/codex/0.150.1/codex" + chmod +x "$case_root/home/.local/lib/panama/codex/0.150.1/codex" + ln -s "$case_root/home/.local/lib/panama/codex/0.150.1/codex" \ + "$case_root/home/.local/bin/codex" + ;; + esac + case "${STUB_SEED_COLLISION:-}" in + Node) + mkdir -p "$case_root/home/.nvm/versions/node/v24.20.0/bin" + printf '#!/usr/bin/env bash\nprintf "v0.0.0\\n"\n' \ + > "$case_root/home/.nvm/versions/node/v24.20.0/bin/node" + chmod +x "$case_root/home/.nvm/versions/node/v24.20.0/bin/node" + ;; + Bun) + mkdir -p "$case_root/home/.bun/versions/1.4.0/bin" + printf '#!/usr/bin/env bash\nprintf "0.0.0\\n"\n' \ + > "$case_root/home/.bun/versions/1.4.0/bin/bun" + chmod +x "$case_root/home/.bun/versions/1.4.0/bin/bun" + ;; + Codex) + mkdir -p "$case_root/home/.local/lib/panama/codex/0.150.1" + printf '#!/usr/bin/env bash\nprintf "codex-cli 10.150.10\\n"\n' \ + > "$case_root/home/.local/lib/panama/codex/0.150.1/codex" + chmod +x "$case_root/home/.local/lib/panama/codex/0.150.1/codex" + ;; + esac write_flathub_descriptor "$case_root/flathub.flatpakrepo" \ "${STUB_FLATHUB_KEY_FILE:-$installer_fixture/setup/provenance/keys/flathub.asc}" \ "${STUB_FLATHUB_VERIFY_LINE:-}" "${STUB_FLATHUB_URL:-https://dl.flathub.org/repo/}" @@ -685,6 +900,8 @@ run_installer_function() { esac COMMAND_LOG="$case_root/commands.log" \ + SOFT_LOG="$case_root/softly-failed" \ + ARTIFACT_ROOT="$artifact_root" \ FIXTURE_ROOT="$installer_fixture" \ REVIEWED_KEYS="$repo_dir/setup/provenance/keys" \ SIGNED_RPM="$test_tmp/signed-fixture.rpm" \ @@ -695,10 +912,11 @@ run_installer_function() { STUB_INSTALL_COUNTER="$case_root/install-counter" \ LC_ALL="${STUB_CALLER_LOCALE:-C}" \ HOME="$case_root/home" \ + NVM_DIR="$case_root/home/.nvm" \ TMPDIR="$case_root/tmp" \ PANAMA_PATH="$installer_fixture" \ PATH="$case_root/bin:/usr/bin:/bin" \ - 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; "$1"' \ + 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" > "$case_root/output" 2>&1 } @@ -712,6 +930,222 @@ assert_log() { } } +assert_soft_failure() { + local name="$1" component="$2" + [[ "$(<"$test_tmp/cases/$name/softly-failed")" == "$component" ]] \ + || fail "$name did not record exactly one $component soft failure" +} + +assert_no_download() { + local name="$1" + [[ "$(<"$test_tmp/cases/$name/commands.log")" != *'curl:'* ]] \ + || fail "$name reached curl" +} + +assert_no_runtime_staging() { + local name="$1" + [[ -z "$(find "$test_tmp/cases/$name/home" "$test_tmp/cases/$name/tmp" \ + \( -name '*.part.*' -o -name '*.stage.*' -o -name '*.link.*' \) -print -quit)" ]] \ + || fail "$name left private runtime staging behind" +} + +assert_old_runtime_preserved() { + local name="$1" component="$2" home="$test_tmp/cases/$name/home" + case "$component" in + Node) + [[ "$($home/.nvm/versions/node/v23.0.0/bin/node --version)" == v23.0.0 ]] \ + || fail "$name changed the known-good Node" + ;; + Bun) + [[ "$(readlink "$home/.bun/bin/bun")" == \ + "$home/.bun/versions/1.3.0/bin/bun" ]] \ + || fail "$name changed the active Bun link" + [[ "$($home/.bun/bin/bun --version)" == 1.3.0 ]] \ + || fail "$name changed the known-good Bun" + ;; + Codex) + [[ "$(readlink "$home/.local/bin/codex")" == \ + "$home/.local/lib/panama/codex/0.149.0/codex" ]] \ + || fail "$name changed the active Codex link" + [[ "$($home/.local/bin/codex --version)" == 'codex-cli 0.149.0' ]] \ + || fail "$name changed the known-good Codex" + ;; + esac +} + +# Each supported architecture selects its own reviewed URL, digest and archive +# layout. Successful activation leaves no private download or extraction tree. +for runtime_case in \ + 'node-x86_64 x86_64 install_node Node https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-x64.tar.xz 67108864' \ + 'node-aarch64 aarch64 install_node Node https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-arm64.tar.xz 67108864' \ + 'bun-x86_64 x86_64 install_bun Bun https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64.zip 67108864' \ + 'bun-aarch64 aarch64 install_bun Bun https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-aarch64.zip 67108864' \ + 'codex-x86_64 x86_64 install_codex Codex https://github.com/openai/codex/releases/download/rust-v0.150.1/codex-package-x86_64-unknown-linux-musl.tar.gz 134217728' \ + 'codex-aarch64 aarch64 install_codex Codex https://github.com/openai/codex/releases/download/rust-v0.150.1/codex-package-aarch64-unknown-linux-musl.tar.gz 134217728'; do + read -r name arch function_name component url max_bytes <<<"$runtime_case" + reset_installer_fixture + if ! STUB_ARCH="$arch" run_installer_function "$name" "$function_name"; then + tail -n 120 "$test_tmp/cases/$name/output" >&2 + fail "expected successful $name activation" + fi + grep -qFx "curl:$url:max=$max_bytes:output=artifact" \ + "$test_tmp/cases/$name/commands.log" \ + || { sed -n '1,80p' "$test_tmp/cases/$name/output" >&2; sed -n '1,80p' "$test_tmp/cases/$name/commands.log" >&2; fail "$name did not select its reviewed artifact"; } + [[ ! -s "$test_tmp/cases/$name/softly-failed" ]] \ + || fail "$name recorded a soft failure after successful activation" + assert_no_runtime_staging "$name" +done + +[[ "$($test_tmp/cases/node-x86_64/home/.nvm/versions/node/v24.20.0/bin/node --version)" \ + == v24.20.0 ]] || fail 'x86_64 Node activation has the wrong version' +grep -qFx 'nvm:alias default 24.20.0' "$test_tmp/cases/node-x86_64/commands.log" \ + || fail 'Node did not set the exact nvm default alias' +[[ "$($test_tmp/cases/bun-x86_64/home/.bun/bin/bun --version)" == 1.4.0 ]] \ + || fail 'x86_64 Bun activation has the wrong version' +[[ "$(readlink "$test_tmp/cases/bun-x86_64/home/.bun/bin/bun")" == \ + "$test_tmp/cases/bun-x86_64/home/.bun/versions/1.4.0/bin/bun" ]] \ + || fail 'Bun did not atomically activate the reviewed version path' +[[ "$($test_tmp/cases/codex-x86_64/home/.local/bin/codex --version)" == \ + 'codex-cli 0.150.1' ]] || fail 'x86_64 Codex activation has the wrong version' +[[ "$(readlink "$test_tmp/cases/codex-x86_64/home/.local/bin/codex")" == \ + "$test_tmp/cases/codex-x86_64/home/.local/lib/panama/codex/0.150.1/codex" ]] \ + || fail 'Codex did not atomically activate the reviewed version path' + +# Unsupported CPUs stop before curl. RustDesk's reviewed RPM is x86_64-only, +# so aarch64 is also an intentional, recorded soft failure without a download. +for unsupported_case in \ + 'node-unsupported install_node Node riscv64' \ + 'bun-unsupported install_bun Bun riscv64' \ + 'codex-unsupported install_codex Codex riscv64' \ + 'rustdesk-unsupported install_rustdesk RustDesk riscv64' \ + 'rustdesk-aarch64 install_rustdesk RustDesk aarch64'; do + read -r name function_name component arch <<<"$unsupported_case" + reset_installer_fixture + STUB_ARCH="$arch" expect_failure run_installer_function "$name" "$function_name" + assert_no_download "$name" + assert_soft_failure "$name" "$component" +done + +# A bad digest or interrupted transfer cannot replace the previously active +# tool and cannot leave reusable bytes behind. +for failure_mode in digest interrupted; do + for component_spec in \ + 'Node install_node' \ + 'Bun install_bun' \ + 'Codex install_codex'; do + read -r component function_name <<<"$component_spec" + name="${component,,}-$failure_mode" + reset_installer_fixture + if [[ "$failure_mode" == digest ]]; then + STUB_SEED_OLD="$component" STUB_DIGEST_MISMATCH=1 \ + expect_failure run_installer_function "$name" "$function_name" + else + STUB_SEED_OLD="$component" STUB_DOWNLOAD_INTERRUPT=1 \ + expect_failure run_installer_function "$name" "$function_name" + fi + assert_soft_failure "$name" "$component" + assert_old_runtime_preserved "$name" "$component" + assert_no_runtime_staging "$name" + done + + name="rustdesk-$failure_mode" + reset_installer_fixture + if [[ "$failure_mode" == digest ]]; then + STUB_RUSTDESK_VERSION=1.4.8 STUB_DIGEST_MISMATCH=1 \ + expect_failure run_installer_function "$name" install_rustdesk + else + STUB_RUSTDESK_VERSION=1.4.8 STUB_DOWNLOAD_INTERRUPT=1 \ + expect_failure run_installer_function "$name" install_rustdesk + fi + assert_soft_failure "$name" RustDesk + [[ "$(<"$test_tmp/cases/$name/commands.log")" != *'sudo:'* ]] \ + || fail "$name reached DNF with an unverified RPM" + assert_no_runtime_staging "$name" +done + +# 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' \ + 'Codex install_codex .local/bin/codex .local/lib/panama/codex/0.150.1/codex'; do + read -r component function_name active_relative target_relative <<<"$component_spec" + name="${component,,}-atomic-update" + reset_installer_fixture + STUB_SEED_OLD="$component" expect_success run_installer_function "$name" "$function_name" + [[ "$(readlink "$test_tmp/cases/$name/home/$active_relative")" == \ + "$test_tmp/cases/$name/home/$target_relative" ]] \ + || fail "$name did not atomically replace the active symlink" + if [[ "$component" == Bun ]]; then + [[ "$($test_tmp/cases/$name/home/.bun/versions/1.3.0/bin/bun --version)" == 1.3.0 ]] \ + || fail "$name removed the prior version directory" + else + [[ "$($test_tmp/cases/$name/home/.local/lib/panama/codex/0.149.0/codex --version)" \ + == 'codex-cli 0.149.0' ]] || fail "$name removed the prior version directory" + fi +done + +# A reviewed digest does not excuse a malformed archive. Reject the wrong top +# level or any extra member before a version path or active link appears. +for layout_case in \ + 'node-layout install_node Node .nvm/versions/node/v24.20.0' \ + 'bun-layout install_bun Bun .bun/versions/1.4.0' \ + 'codex-layout install_codex Codex .local/lib/panama/codex/0.150.1'; do + read -r name function_name component relative_target <<<"$layout_case" + reset_installer_fixture + STUB_BAD_LAYOUT=1 expect_failure run_installer_function "$name" "$function_name" + assert_soft_failure "$name" "$component" + [[ ! -e "$test_tmp/cases/$name/home/$relative_target" ]] \ + || fail "$name activated an archive with an unexpected layout" + assert_no_runtime_staging "$name" +done + +# A valid collision is a no-download no-op. An invalid collision is preserved +# and reported instead of being deleted and recreated. +for collision_mode in exact collision; do + for component_spec in \ + 'Node install_node' \ + 'Bun install_bun' \ + 'Codex install_codex'; do + read -r component function_name <<<"$component_spec" + name="${component,,}-$collision_mode" + reset_installer_fixture + if [[ "$collision_mode" == exact ]]; then + STUB_SEED_EXACT="$component" expect_success \ + run_installer_function "$name" "$function_name" + [[ ! -s "$test_tmp/cases/$name/softly-failed" ]] \ + || fail "$name reported a failure for the exact installed version" + else + STUB_SEED_COLLISION="$component" expect_failure \ + run_installer_function "$name" "$function_name" + assert_soft_failure "$name" "$component" + fi + assert_no_download "$name" + done +done + +reset_installer_fixture +STUB_ARCH=x86_64 STUB_RUSTDESK_VERSION=1.4.8 \ + expect_success run_installer_function rustdesk-x86_64 install_rustdesk +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=1 RUSTDESK_LOCAL +EXPECTED +)" +assert_no_runtime_staging rustdesk-x86_64 + +reset_installer_fixture +STUB_ARCH=x86_64 STUB_RUSTDESK_VERSION=1.4.9 \ + expect_success run_installer_function rustdesk-exact install_rustdesk +assert_log rustdesk-exact 'rpm:query:rustdesk' + +reset_installer_fixture +expect_success run_installer_function pnpm install_pnpm +assert_log pnpm 'sudo:dnf install -y pnpm' + +reset_installer_fixture +STUB_DNF_FAIL_MATCH=pnpm expect_failure run_installer_function pnpm-failure install_pnpm +assert_soft_failure pnpm-failure pnpm + reset_installer_fixture expect_success run_installer_function rpmfusion install_rpmfusion_repositories assert_log rpmfusion "$(cat <<'EXPECTED' @@ -999,6 +1433,9 @@ for function_name in install_rpmfusion_repositories install_terra_repository \ name="wrong-fedora-${function_name}" STUB_FEDORA_RELEASE=45 expect_failure run_installer_function "$name" "$function_name" assert_log "$name" 'rpm:release' + if [[ "$function_name" == install_claude_code ]]; then + assert_soft_failure "$name" 'Claude Code' + fi done reset_installer_fixture