diff --git a/setup/lib/artifact-provenance b/setup/lib/artifact-provenance index 62776ca..317d51a 100755 --- a/setup/lib/artifact-provenance +++ b/setup/lib/artifact-provenance @@ -96,12 +96,12 @@ load_installer_provenance() { local -A parsed=() allowed=() required_values=() for name in \ - BUN_VERSION BUN_X86_64_URL BUN_X86_64_SHA256 BUN_X86_64_MAX_BYTES \ - BUN_AARCH64_URL BUN_AARCH64_SHA256 BUN_AARCH64_MAX_BYTES \ - NODE_VERSION NODE_X86_64_URL NODE_X86_64_SHA256 NODE_X86_64_MAX_BYTES \ - NODE_AARCH64_URL NODE_AARCH64_SHA256 NODE_AARCH64_MAX_BYTES \ - CODEX_VERSION CODEX_X86_64_URL CODEX_X86_64_SHA256 CODEX_X86_64_MAX_BYTES \ - CODEX_AARCH64_URL CODEX_AARCH64_SHA256 CODEX_AARCH64_MAX_BYTES \ + BUN_VERSION BUN_X86_64_URL BUN_X86_64_SHA256 BUN_X86_64_BINARY_SHA256 BUN_X86_64_MAX_BYTES \ + BUN_AARCH64_URL BUN_AARCH64_SHA256 BUN_AARCH64_BINARY_SHA256 BUN_AARCH64_MAX_BYTES \ + NODE_VERSION NODE_X86_64_URL NODE_X86_64_SHA256 NODE_X86_64_BINARY_SHA256 NODE_X86_64_MAX_BYTES \ + NODE_AARCH64_URL NODE_AARCH64_SHA256 NODE_AARCH64_BINARY_SHA256 NODE_AARCH64_MAX_BYTES \ + CODEX_VERSION CODEX_X86_64_URL CODEX_X86_64_SHA256 CODEX_X86_64_BINARY_SHA256 CODEX_X86_64_MAX_BYTES \ + CODEX_AARCH64_URL CODEX_AARCH64_SHA256 CODEX_AARCH64_BINARY_SHA256 CODEX_AARCH64_MAX_BYTES \ RUSTDESK_VERSION RUSTDESK_X86_64_URL RUSTDESK_X86_64_SHA256 RUSTDESK_X86_64_MAX_BYTES \ FEDORA_RELEASE RPMFUSION_FREE_RELEASE_URL RPMFUSION_FREE_RELEASE_MAX_BYTES \ RPMFUSION_NONFREE_RELEASE_URL RPMFUSION_NONFREE_RELEASE_MAX_BYTES TERRA_BASEURL \ diff --git a/setup/provenance/README.md b/setup/provenance/README.md index ef0fca9..efe6eeb 100644 --- a/setup/provenance/README.md +++ b/setup/provenance/README.md @@ -64,6 +64,14 @@ Artifact digests were checked with `sha256sum ARTIFACT` against the lowercase 64-hex values in `installers.conf`; all runtime downloads also enforce the recorded byte limit before atomic replacement. +The `*_BINARY_SHA256` values are the SHA-256 digests of the exact executable +bytes selected from each verified runtime archive. They were derived without +executing the artifacts: `tar -xO` selected Node's `bin/node` and Codex's +`bin/codex`, while `unzip -p` selected Bun's `bun`. Runtime version directories +record both the reviewed archive digest and this reviewed installed-byte digest +in `.panama-provenance`; later runs require the receipt and re-hash the installed +binary before treating an existing directory as trusted. + ## Rotation policy Do not replace a key on an automated update. A key rotation is a reviewed diff --git a/setup/provenance/installers.conf b/setup/provenance/installers.conf index 7e3cb75..9f8550c 100644 --- a/setup/provenance/installers.conf +++ b/setup/provenance/installers.conf @@ -1,23 +1,29 @@ BUN_VERSION=1.4.0 BUN_X86_64_URL=https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64.zip BUN_X86_64_SHA256=2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452 +BUN_X86_64_BINARY_SHA256=33d56b070be6a9e3da0ab013038b43d1645d0534ca811ecdba4472599117eb4b BUN_X86_64_MAX_BYTES=67108864 BUN_AARCH64_URL=https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-aarch64.zip BUN_AARCH64_SHA256=4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e +BUN_AARCH64_BINARY_SHA256=086c4121c8738a8e0f5ed730e8a461bc3973b4444e372ddb77aef9a747fa2ae9 BUN_AARCH64_MAX_BYTES=67108864 NODE_VERSION=24.20.0 NODE_X86_64_URL=https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-x64.tar.xz NODE_X86_64_SHA256=2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2 +NODE_X86_64_BINARY_SHA256=89af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7 NODE_X86_64_MAX_BYTES=67108864 NODE_AARCH64_URL=https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-arm64.tar.xz NODE_AARCH64_SHA256=5f4ddab610c1ab2016b3c227cebdbf6d9495161487e4739c7b90090595f465f7 +NODE_AARCH64_BINARY_SHA256=23a5637c2470fde09fcc1acc77c1b92e04e3d7e3e6e80ff7df6f5831958d1477 NODE_AARCH64_MAX_BYTES=67108864 CODEX_VERSION=0.150.1 CODEX_X86_64_URL=https://github.com/openai/codex/releases/download/rust-v0.150.1/codex-package-x86_64-unknown-linux-musl.tar.gz CODEX_X86_64_SHA256=00aba704f029f6dc0d948be407a756e0c97cc840132fd691353b2c6b0a505b17 +CODEX_X86_64_BINARY_SHA256=abf1bb1643a79f73aa78ee627e111e02d4f8c98f25813a0cf6ce277709664386 CODEX_X86_64_MAX_BYTES=134217728 CODEX_AARCH64_URL=https://github.com/openai/codex/releases/download/rust-v0.150.1/codex-package-aarch64-unknown-linux-musl.tar.gz CODEX_AARCH64_SHA256=1ecac3f87823efb98153233b076ea3d6e34a7a8cebe43c5285dc5f79e1514639 +CODEX_AARCH64_BINARY_SHA256=7a49aabe11fd95a1c968d79e16b5f1b17c3219002c5f7129d467f415f8460feb CODEX_AARCH64_MAX_BYTES=134217728 RUSTDESK_VERSION=1.4.9 RUSTDESK_X86_64_URL=https://github.com/rustdesk/rustdesk/releases/download/1.4.9/rustdesk-1.4.9-0.x86_64.rpm diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index 521bc4e..79d1eb7 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -126,12 +126,17 @@ _set_artifact_arch() { esac } +_archive_path_is_safe() { + local member="$1" + [[ -n "$member" && "$member" != /* && "$member" != *'//'* + && ! "$member" =~ (^|/)\.\.?(/|$) ]] +} + _archive_member_is_safe() { local member="$1" expected_top="$2" - [[ -n "$member" && "$member" != /* && "$member" != *'//'* - && ! "$member" =~ (^|/)\.\.?(/|$) - && ( "$member" == "$expected_top" || "$member" == "$expected_top/" \ - || "$member" == "$expected_top/"* ) ]] + _archive_path_is_safe "$member" + [[ "$member" == "$expected_top" || "$member" == "$expected_top/" \ + || "$member" == "$expected_top/"* ]] } _tree_links_stay_inside() { @@ -142,17 +147,59 @@ _tree_links_stay_inside() { done < <(find "$root" -type l -print0) } -_atomic_symlink() { - local target="$1" destination="$2" directory temporary +_tree_hardlinks_stay_inside() { + local root="$1" device inode link_count key + local -A names_in_tree=() inode_links=() + while read -r device inode link_count; do + key="$device:$inode" + names_in_tree["$key"]=$(( ${names_in_tree[$key]:-0} + 1 )) + inode_links["$key"]="$link_count" + done < <(find "$root" -type f -printf '%D %i %n\n') + for key in "${!names_in_tree[@]}"; do + [[ "${names_in_tree[$key]}" == "${inode_links[$key]}" ]] || return 1 + done +} + +_atomic_symlink() ( + local target="$1" destination="$2" directory temporary="" + trap '[[ -z "$temporary" ]] || rm -f -- "$temporary"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM 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 +) + +_activate_directory_no_replace() { + local staged="$1" destination="$2" + mv -Tn -- "$staged" "$destination" || return 1 + [[ ! -e "$staged" && ! -L "$staged" && -d "$destination" && ! -L "$destination" ]] +} + +_write_runtime_receipt() { + local directory="$1" artifact_digest="$2" binary_digest="$3" + local receipt="$directory/.panama-provenance" + [[ -d "$directory" && ! -L "$directory" && ! -e "$receipt" && ! -L "$receipt" ]] \ + || return 1 + ( umask 077 && printf 'schema=1\nartifact_sha256=%s\nbinary_sha256=%s\n' \ + "$artifact_digest" "$binary_digest" > "$receipt" ) +} + +_runtime_receipt_matches() { + local directory="$1" binary="$2" artifact_digest="$3" binary_digest="$4" + local receipt="$directory/.panama-provenance" actual + [[ -d "$directory" && ! -L "$directory" + && -f "$receipt" && ! -L "$receipt" + && -f "$binary" && ! -L "$binary" ]] || return 1 + cmp -s "$receipt" <(printf 'schema=1\nartifact_sha256=%s\nbinary_sha256=%s\n' \ + "$artifact_digest" "$binary_digest") || return 1 + actual="$(sha256sum "$binary" | awk '{ print $1 }')" || return 1 + [[ "$actual" == "$binary_digest" ]] } _load_nvm() { @@ -165,8 +212,13 @@ _load_nvm() { declare -F nvm >/dev/null } -_install_node() { +_install_node() ( local artifact_arch machine_arch archive_top parent target stage archive extract listing member + local artifact_digest binary_digest staged_binary + stage="" + trap '[[ -z "$stage" ]] || rm -rf -- "$stage"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM _set_artifact_arch || return 1 _load_nvm || return 1 case "$artifact_arch" in @@ -176,8 +228,12 @@ _install_node() { 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]}" + artifact_digest="${INSTALLER_PROVENANCE[NODE_${artifact_arch}_SHA256]}" + binary_digest="${INSTALLER_PROVENANCE[NODE_${artifact_arch}_BINARY_SHA256]}" if [[ -e "$target" || -L "$target" ]]; then - [[ -d "$target" && ! -L "$target" && -x "$target/bin/node" + _runtime_receipt_matches "$target" "$target/bin/node" \ + "$artifact_digest" "$binary_digest" || return 1 + [[ -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 @@ -204,17 +260,34 @@ _install_node() { 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; } + _tree_hardlinks_stay_inside "$extract/$archive_top" \ + || { rm -rf -- "$stage"; return 1; } + staged_binary="$extract/$archive_top/bin/node" + [[ "$(sha256sum "$staged_binary" | awk '{ print $1 }')" == "$binary_digest" ]] \ + || { rm -rf -- "$stage"; return 1; } + [[ -d "$extract/$archive_top" && ! -L "$extract/$archive_top" + && -x "$staged_binary" + && "$($staged_binary --version 2>/dev/null)" \ + == "v${INSTALLER_PROVENANCE[NODE_VERSION]}" ]] \ + || { rm -rf -- "$stage"; return 1; } + _write_runtime_receipt "$extract/$archive_top" "$artifact_digest" "$binary_digest" \ + || { rm -rf -- "$stage"; return 1; } + _activate_directory_no_replace "$extract/$archive_top" "$target" \ + || { rm -rf -- "$stage"; return 1; } + _runtime_receipt_matches "$target" "$target/bin/node" \ + "$artifact_digest" "$binary_digest" || { rm -rf -- "$stage"; return 1; } + [[ -x "$target/bin/node" + && "$($target/bin/node --version 2>/dev/null)" \ + == "v${INSTALLER_PROVENANCE[NODE_VERSION]}" ]] \ + || { rm -rf -- "$stage"; return 1; } + _tree_links_stay_inside "$target" || { rm -rf -- "$stage"; return 1; } + _tree_hardlinks_stay_inside "$target" || { rm -rf -- "$stage"; return 1; } rm -rf -- "$stage" + stage="" nvm alias default "${INSTALLER_PROVENANCE[NODE_VERSION]}" >/dev/null 2>&1 -} +) install_node() { _install_node || _record_installer_failure Node @@ -226,15 +299,20 @@ setup_node() { } install_pnpm() { - if sudo dnf install -y pnpm >/dev/null; then + if require_reviewed_fedora_release \ + && sudo dnf install -y --repo=fedora --repo=updates pnpm >/dev/null; then return 0 fi _record_installer_failure pnpm } -_install_bun() { +_install_bun() ( local artifact_arch archive_top target bin_link parent stage archive listing - local staged_binary version_dir + local staged_binary version_dir artifact_digest binary_digest + stage="" + trap '[[ -z "$stage" ]] || rm -rf -- "$stage"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM _set_artifact_arch || return 1 case "$artifact_arch" in X86_64) archive_top=bun-linux-x64 ;; @@ -243,8 +321,12 @@ _install_bun() { version_dir="$HOME/.bun/versions/${INSTALLER_PROVENANCE[BUN_VERSION]}" target="$version_dir/bin/bun" bin_link="$HOME/.bun/bin/bun" + artifact_digest="${INSTALLER_PROVENANCE[BUN_${artifact_arch}_SHA256]}" + binary_digest="${INSTALLER_PROVENANCE[BUN_${artifact_arch}_BINARY_SHA256]}" if [[ -e "$version_dir" || -L "$version_dir" ]]; then - [[ -d "$version_dir" && ! -L "$version_dir" && -x "$target" + _runtime_receipt_matches "$version_dir" "$target" \ + "$artifact_digest" "$binary_digest" || return 1 + [[ -x "$target" && "$($target --version 2>/dev/null)" == "${INSTALLER_PROVENANCE[BUN_VERSION]}" ]] \ || return 1 [[ -L "$bin_link" && "$(readlink -- "$bin_link")" == "$target" ]] \ @@ -264,22 +346,35 @@ _install_bun() { 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" \ + [[ "$listing" == "$archive_top/"$'\n'"$archive_top/bun" ]] \ || { 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" 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" + && "$(sha256sum "$staged_binary" | awk '{ print $1 }')" == "$binary_digest" && "$($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; } + _write_runtime_receipt "$stage/version" "$artifact_digest" "$binary_digest" \ + || { rm -rf -- "$stage"; return 1; } + _activate_directory_no_replace "$stage/version" "$version_dir" \ + || { rm -rf -- "$stage"; return 1; } + _runtime_receipt_matches "$version_dir" "$target" \ + "$artifact_digest" "$binary_digest" || { rm -rf -- "$stage"; return 1; } + [[ -x "$target" && "$($target --version 2>/dev/null)" \ + == "${INSTALLER_PROVENANCE[BUN_VERSION]}" ]] \ + || { rm -rf -- "$stage"; return 1; } rm -rf -- "$stage" + stage="" _atomic_symlink "$target" "$bin_link" -} +) install_bun() { _install_bun || _record_installer_failure Bun @@ -292,20 +387,23 @@ _codex_version_matches() { [[ "$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 +_install_codex() ( + local artifact_arch version_dir target bin_link parent expected_listing member + local stage archive listing staged_binary artifact_digest binary_digest + stage="" + trap '[[ -z "$stage" ]] || rm -rf -- "$stage"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM _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" + artifact_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_SHA256]}" + binary_digest="${INSTALLER_PROVENANCE[CODEX_${artifact_arch}_BINARY_SHA256]}" if [[ -e "$version_dir" || -L "$version_dir" ]]; then - [[ -d "$version_dir" && ! -L "$version_dir" && -x "$target" ]] || return 1 + _runtime_receipt_matches "$version_dir" "$target" \ + "$artifact_digest" "$binary_digest" || return 1 + [[ -x "$target" ]] || return 1 _codex_version_matches "$target" || return 1 [[ -L "$bin_link" && "$(readlink -- "$bin_link")" == "$target" ]] \ || _atomic_symlink "$target" "$bin_link" @@ -324,31 +422,47 @@ _install_codex() { 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; } + expected_listing=$'bin/\nbin/codex\nbin/codex-code-mode-host\ncodex-package.json\ncodex-path/\ncodex-path/rg\ncodex-resources/\ncodex-resources/bwrap\ncodex-resources/zsh/\ncodex-resources/zsh/bin/\ncodex-resources/zsh/bin/zsh' + [[ "$listing" == "$expected_listing" ]] || { rm -rf -- "$stage"; return 1; } + while IFS= read -r member; do + _archive_path_is_safe "$member" || { rm -rf -- "$stage"; return 1; } + done <<<"$listing" 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" ]] \ + _tree_links_stay_inside "$stage/extract" || { rm -rf -- "$stage"; return 1; } + _tree_hardlinks_stay_inside "$stage/extract" || { rm -rf -- "$stage"; return 1; } + staged_binary="$stage/extract/bin/codex" + [[ -f "$staged_binary" && ! -L "$staged_binary" && -x "$staged_binary" + && "$(sha256sum "$staged_binary" | awk '{ print $1 }')" == "$binary_digest" ]] \ || { 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; } + _write_runtime_receipt "$stage/version" "$artifact_digest" "$binary_digest" \ + || { rm -rf -- "$stage"; return 1; } + _activate_directory_no_replace "$stage/version" "$version_dir" \ + || { rm -rf -- "$stage"; return 1; } + _runtime_receipt_matches "$version_dir" "$target" \ + "$artifact_digest" "$binary_digest" || { rm -rf -- "$stage"; return 1; } + _codex_version_matches "$target" || { rm -rf -- "$stage"; return 1; } rm -rf -- "$stage" + stage="" _atomic_symlink "$target" "$bin_link" -} +) install_codex() { _install_codex || _record_installer_failure Codex } -_install_rustdesk() { +_install_rustdesk() ( local artifact_arch installed_version="" work rpm_path status=0 + work="" + trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM _set_artifact_arch || return 1 if [[ "$artifact_arch" == AARCH64 ]]; then log "RustDesk ${INSTALLER_PROVENANCE[RUSTDESK_VERSION]} has no reviewed aarch64 RPM" @@ -367,10 +481,14 @@ _install_rustdesk() { rm -rf -- "$work" return 1 fi - sudo dnf install -y --setopt=localpkg_gpgcheck=1 "$rpm_path" >/dev/null || status=$? + # 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=$? rm -rf -- "$work" + work="" return "$status" -} +) install_rustdesk() { _install_rustdesk || _record_installer_failure RustDesk @@ -945,7 +1063,8 @@ _install_claude_code() { "$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama \ "$staged_repo" /etc/yum.repos.d/claude-code.repo || status=$? if (( status == 0 )); then - sudo dnf install -y claude-code || status=$? + sudo dnf install -y --repo=claude-code --repo=fedora --repo=updates \ + claude-code || status=$? fi rm -rf -- "$work" return "$status" diff --git a/tests/setup/package-provenance-contract b/tests/setup/package-provenance-contract index 498483e..52bae54 100755 --- a/tests/setup/package-provenance-contract +++ b/tests/setup/package-provenance-contract @@ -253,13 +253,19 @@ expect_success load_installer_provenance "$config" for reviewed_value in \ 'NODE_VERSION 24.20.0' \ 'NODE_X86_64_SHA256 2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2' \ + 'NODE_X86_64_BINARY_SHA256 89af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7' \ 'NODE_AARCH64_SHA256 5f4ddab610c1ab2016b3c227cebdbf6d9495161487e4739c7b90090595f465f7' \ + 'NODE_AARCH64_BINARY_SHA256 23a5637c2470fde09fcc1acc77c1b92e04e3d7e3e6e80ff7df6f5831958d1477' \ 'BUN_VERSION 1.4.0' \ 'BUN_X86_64_SHA256 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ + 'BUN_X86_64_BINARY_SHA256 33d56b070be6a9e3da0ab013038b43d1645d0534ca811ecdba4472599117eb4b' \ 'BUN_AARCH64_SHA256 4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e' \ + 'BUN_AARCH64_BINARY_SHA256 086c4121c8738a8e0f5ed730e8a461bc3973b4444e372ddb77aef9a747fa2ae9' \ 'CODEX_VERSION 0.150.1' \ 'CODEX_X86_64_SHA256 00aba704f029f6dc0d948be407a756e0c97cc840132fd691353b2c6b0a505b17' \ + 'CODEX_X86_64_BINARY_SHA256 abf1bb1643a79f73aa78ee627e111e02d4f8c98f25813a0cf6ce277709664386' \ 'CODEX_AARCH64_SHA256 1ecac3f87823efb98153233b076ea3d6e34a7a8cebe43c5285dc5f79e1514639' \ + 'CODEX_AARCH64_BINARY_SHA256 7a49aabe11fd95a1c968d79e16b5f1b17c3219002c5f7129d467f415f8460feb' \ 'RUSTDESK_VERSION 1.4.9' \ 'RUSTDESK_X86_64_SHA256 eb1b053ac5b2f774f2271f7fbbfd2ea475899f7a55135c5e172bc54b9388f108'; do read -r name expected <<<"$reviewed_value" @@ -332,26 +338,46 @@ for arch_spec in \ 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' \ + printf '#!/usr/bin/env bash\n# fixture %s\nprintf "v24.20.0\\n"\n' "$machine" \ > "$artifact_root/build/$node_top/bin/node" chmod +x "$artifact_root/build/$node_top/bin/node" + cp "$artifact_root/build/$node_top/bin/node" "$artifact_root/node-$machine.bin" 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' \ + printf '#!/usr/bin/env bash\n# fixture %s\nprintf "1.4.0\\n"\n' "$machine" \ > "$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") + cp "$artifact_root/build/$bun_top/bun" "$artifact_root/bun-$machine.bin" + (cd "$artifact_root/build" && zip -q "$artifact_root/bun-$machine.zip" \ + "$bun_top/" "$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" + mkdir -p "$artifact_root/build/bin" "$artifact_root/build/codex-path" \ + "$artifact_root/build/codex-resources/zsh/bin" + printf '#!/usr/bin/env bash\n# fixture %s\nprintf "codex-cli 0.150.1\\n"\n' "$machine" \ + > "$artifact_root/build/bin/codex" + cp "$artifact_root/build/bin/codex" "$artifact_root/codex-$machine.bin" + printf '#!/usr/bin/env bash\nprintf "code mode host\\n"\n' \ + > "$artifact_root/build/bin/codex-code-mode-host" + printf '{"target":"%s"}\n' "$machine" > "$artifact_root/build/codex-package.json" + for package_binary in codex-path/rg codex-resources/bwrap codex-resources/zsh/bin/zsh; do + printf '#!/usr/bin/env bash\nprintf "package resource\\n"\n' \ + > "$artifact_root/build/$package_binary" + done + chmod +x "$artifact_root/build/bin/codex" \ + "$artifact_root/build/bin/codex-code-mode-host" \ + "$artifact_root/build/codex-path/rg" "$artifact_root/build/codex-resources/bwrap" \ + "$artifact_root/build/codex-resources/zsh/bin/zsh" + tar -C "$artifact_root/build" --no-recursion -czf "$artifact_root/codex-$machine.tar.gz" \ + bin/ bin/codex bin/codex-code-mode-host codex-package.json \ + codex-path/ codex-path/rg codex-resources/ codex-resources/bwrap \ + codex-resources/zsh/ codex-resources/zsh/bin/ codex-resources/zsh/bin/zsh + rm -rf -- "$artifact_root/build/bin" "$artifact_root/build/codex-path" \ + "$artifact_root/build/codex-resources" + rm -f -- "$artifact_root/build/codex-package.json" 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" @@ -364,8 +390,15 @@ printf '#!/usr/bin/env bash\nprintf "1.4.0\\n"\n' > "$artifact_root/build/bun-li 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) + bun-linux-x64/ bun-linux-x64/bun bun-linux-x64/extra) printf 'reviewed rustdesk fixture\n' > "$artifact_root/rustdesk.rpm" +printf '#!/usr/bin/env bash\nprintf "executed\\n" > "$OUTSIDE_EXECUTED"\nprintf "v24.20.0\\n"\n' \ + > "$artifact_root/outside-node" +chmod +x "$artifact_root/outside-node" +node_escape_top='node-v24.20.0-linux-x64' +mkdir -p "$artifact_root/build/$node_escape_top/bin" +ln -s "$artifact_root/outside-node" "$artifact_root/build/$node_escape_top/bin/node" +tar -C "$artifact_root/build" -cJf "$artifact_root/node-symlink-escape.tar.xz" "$node_escape_top" rm -rf -- "$artifact_root/build" make_stub_commands() { @@ -383,6 +416,69 @@ STUB cat > "$case_root/bin/uname" <<'STUB' #!/usr/bin/env bash printf '%s\n' "${STUB_ARCH:-x86_64}" +STUB + + cat > "$case_root/bin/tar" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +/usr/bin/tar "$@" +if [[ "${STUB_SIGNAL_PHASE:-}" == extract && "$*" == *'-x'* ]]; then + printf 'signal:extract\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 +fi +if [[ "${STUB_NODE_ESCAPE:-}" == hardlink && "$*" == *'-xJf'* ]]; then + destination='' + while (($#)); do + if [[ "$1" == -C ]]; then + destination="$2" + break + fi + shift + done + [[ -n "$destination" ]] || exit 71 + node="$destination/node-v24.20.0-linux-x64/bin/node" + rm -f -- "$node" + ln "$ARTIFACT_ROOT/outside-node" "$node" +fi +STUB + + cat > "$case_root/bin/mv" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +destination="${!#}" +if [[ "${STUB_SIGNAL_PHASE:-}" == activation ]]; then + case "$destination" in + */.nvm/versions/node/v24.20.0|*/.bun/versions/1.4.0|*/.local/lib/panama/codex/0.150.1) + printf 'signal:activation\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 + ;; + esac +elif [[ "${STUB_SIGNAL_PHASE:-}" == link ]]; then + case "$destination" in + */.bun/bin/bun|*/.local/bin/codex) + printf 'signal:link\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 + ;; + esac +fi +case "${STUB_LATE_COLLISION:-}:$destination" in + Node:*/.nvm/versions/node/v24.20.0|\ + Bun:*/.bun/versions/1.4.0|\ + Codex:*/.local/lib/panama/codex/0.150.1) + if [[ ! -e "$destination" ]]; then + mkdir -p -- "$destination" + printf 'preserved collision\n' > "$destination/collision-marker" + printf 'mv:late-collision:%s\n' "$STUB_LATE_COLLISION" >> "$COMMAND_LOG" + fi + ;; +esac +exec /usr/bin/mv "$@" STUB cat > "$case_root/bin/rpm" <<'STUB' @@ -427,6 +523,13 @@ 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_SIGNAL_PHASE:-}" == download ]]; then + printf 'partial' > "$output" + printf 'signal:download\n' >> "$COMMAND_LOG" + pgid="$(ps -o pgid= -p $$ | tr -d ' ')" + kill -TERM -- "-$pgid" + sleep 2 +fi if [[ "${STUB_DOWNLOAD_INTERRUPT:-}" == 1 ]]; then printf 'partial' > "$output" exit 42 @@ -436,8 +539,12 @@ case "$url" in *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 \ + if [[ "${STUB_NODE_ESCAPE:-}" == symlink ]]; then + cp "$ARTIFACT_ROOT/node-symlink-escape.tar.xz" "$output" + else + cp "$ARTIFACT_ROOT/${STUB_BAD_LAYOUT:+node-bad.tar.xz}" "$output" 2>/dev/null \ || cp "$ARTIFACT_ROOT/node-x86_64.tar.xz" "$output" + fi ;; *node-v24.20.0-linux-arm64.tar.xz) cp "$ARTIFACT_ROOT/node-aarch64.tar.xz" "$output" ;; *bun-linux-x64.zip) @@ -467,6 +574,7 @@ for spec in \ 'node-x86_64.tar.xz 2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2' \ 'node-aarch64.tar.xz 5f4ddab610c1ab2016b3c227cebdbf6d9495161487e4739c7b90090595f465f7' \ 'node-bad.tar.xz 2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2' \ + 'node-symlink-escape.tar.xz 2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2' \ 'bun-x86_64.zip 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ 'bun-aarch64.zip 4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e' \ 'bun-bad.zip 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452' \ @@ -480,6 +588,19 @@ for spec in \ exit 0 fi done +for binary_spec in \ + 'node-x86_64.bin 89af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7' \ + 'node-aarch64.bin 23a5637c2470fde09fcc1acc77c1b92e04e3d7e3e6e80ff7df6f5831958d1477' \ + 'bun-x86_64.bin 33d56b070be6a9e3da0ab013038b43d1645d0534ca811ecdba4472599117eb4b' \ + 'bun-aarch64.bin 086c4121c8738a8e0f5ed730e8a461bc3973b4444e372ddb77aef9a747fa2ae9' \ + 'codex-x86_64.bin abf1bb1643a79f73aa78ee627e111e02d4f8c98f25813a0cf6ce277709664386' \ + 'codex-aarch64.bin 7a49aabe11fd95a1c968d79e16b5f1b17c3219002c5f7129d467f415f8460feb'; do + read -r fixture digest <<<"$binary_spec" + if cmp -s "$file" "$ARTIFACT_ROOT/$fixture"; then + printf '%s %s\n' "$digest" "$file" + exit 0 + fi +done /usr/bin/sha256sum "$@" STUB @@ -571,6 +692,17 @@ for argument in "$@"; do esac done printf 'sudo:%s\n' "${logged[*]}" >> "$COMMAND_LOG" +if [[ "$original" == *'/rustdesk.rpm'* ]]; then + [[ "$original" == *'--setopt=localpkg_gpgcheck=0'* \ + && "$original" != *'--setopt=localpkg_gpgcheck=1'* ]] || exit 70 +fi +if [[ "$original" == *' pnpm' || "$original" == *' claude-code' ]]; then + case "$original" in + 'dnf install -y --repo=fedora --repo=updates pnpm'|\ + 'dnf install -y --repo=claude-code --repo=fedora --repo=updates claude-code') ;; + *) exit 71 ;; + esac +fi if [[ -n "${STUB_DNF_FAIL_MATCH:-}" && "$original" == *"$STUB_DNF_FAIL_MATCH"* ]]; then exit 68 fi @@ -731,7 +863,9 @@ write_flathub_descriptor() { run_installer_function() { local name="$1" function_name="$2" case_root case_root="$test_tmp/cases/$name" - rm -rf -- "$case_root" + if [[ "${STUB_REUSE_CASE:-}" != 1 ]]; then + rm -rf -- "$case_root" + fi make_stub_commands "$case_root" : > "$case_root/commands.log" printf '0\n' > "$case_root/install-counter" @@ -760,7 +894,7 @@ run_installer_function() { "$case_root/home/.local/bin/codex" ;; esac - case "${STUB_SEED_EXACT:-}" in + case "${STUB_SEED_LEGACY:-}" 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' \ @@ -899,25 +1033,28 @@ 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" \ - FLATHUB_DESCRIPTOR="$case_root/flathub.flatpakrepo" \ - STUB_ETC="$case_root/etc" \ - STUB_FLATPAK_STATE="$case_root/flatpak-state" \ - STUB_FLATPAK_REPO="$case_root/flatpak-repo" \ - 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; status=0; "$1" || status=$?; (( ${#softly_failed[@]} == 0 )) || printf "%s\n" "${softly_failed[@]}" > "$SOFT_LOG"; exit "$status"' \ - bash "$function_name" > "$case_root/output" 2>&1 + ( + COMMAND_LOG="$case_root/commands.log" \ + SOFT_LOG="$case_root/softly-failed" \ + ARTIFACT_ROOT="$artifact_root" \ + OUTSIDE_EXECUTED="$case_root/outside-executed" \ + FIXTURE_ROOT="$installer_fixture" \ + REVIEWED_KEYS="$repo_dir/setup/provenance/keys" \ + SIGNED_RPM="$test_tmp/signed-fixture.rpm" \ + FLATHUB_DESCRIPTOR="$case_root/flathub.flatpakrepo" \ + STUB_ETC="$case_root/etc" \ + STUB_FLATPAK_STATE="$case_root/flatpak-state" \ + STUB_FLATPAK_REPO="$case_root/flatpak-repo" \ + 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" \ + 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" + ) > "$case_root/output" 2>&1 } assert_log() { @@ -944,8 +1081,9 @@ assert_no_download() { 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)" ]] \ + [[ -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)" ]] \ || fail "$name left private runtime staging behind" } @@ -996,6 +1134,37 @@ for runtime_case in \ assert_no_runtime_staging "$name" done +for receipt_spec in \ + 'node-x86_64 .nvm/versions/node/v24.20.0 2f2c0da162318f0de47665410c7c8c2ed3d36c8f3105de4bbc61176c70a7cbf2 89af8424dd53e560b1933f87ba650d8bf57c83ca5a04600eefb31f416aabbae7' \ + 'node-aarch64 .nvm/versions/node/v24.20.0 5f4ddab610c1ab2016b3c227cebdbf6d9495161487e4739c7b90090595f465f7 23a5637c2470fde09fcc1acc77c1b92e04e3d7e3e6e80ff7df6f5831958d1477' \ + 'bun-x86_64 .bun/versions/1.4.0 2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452 33d56b070be6a9e3da0ab013038b43d1645d0534ca811ecdba4472599117eb4b' \ + 'bun-aarch64 .bun/versions/1.4.0 4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e 086c4121c8738a8e0f5ed730e8a461bc3973b4444e372ddb77aef9a747fa2ae9' \ + 'codex-x86_64 .local/lib/panama/codex/0.150.1 00aba704f029f6dc0d948be407a756e0c97cc840132fd691353b2c6b0a505b17 abf1bb1643a79f73aa78ee627e111e02d4f8c98f25813a0cf6ce277709664386' \ + 'codex-aarch64 .local/lib/panama/codex/0.150.1 1ecac3f87823efb98153233b076ea3d6e34a7a8cebe43c5285dc5f79e1514639 7a49aabe11fd95a1c968d79e16b5f1b17c3219002c5f7129d467f415f8460feb'; do + read -r name target_relative artifact_digest binary_digest <<<"$receipt_spec" + assert_file_bytes "$test_tmp/cases/$name/home/$target_relative/.panama-provenance" \ + "$(printf 'schema=1\nartifact_sha256=%s\nbinary_sha256=%s\n' \ + "$artifact_digest" "$binary_digest")" +done + +# No-op trust comes only from directories produced and attested by a successful +# installer run, never from a handcrafted executable that prints the version. +for exact_spec in \ + 'node-x86_64 x86_64 install_node' \ + 'node-aarch64 aarch64 install_node' \ + 'bun-x86_64 x86_64 install_bun' \ + 'bun-aarch64 aarch64 install_bun' \ + 'codex-x86_64 x86_64 install_codex' \ + 'codex-aarch64 aarch64 install_codex'; do + read -r name arch function_name <<<"$exact_spec" + reset_installer_fixture + STUB_REUSE_CASE=1 STUB_ARCH="$arch" expect_success \ + run_installer_function "$name" "$function_name" + assert_no_download "$name" + [[ ! -s "$test_tmp/cases/$name/softly-failed" ]] \ + || fail "$name rejected its installer-produced provenance receipt" +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" \ @@ -1026,6 +1195,41 @@ for unsupported_case in \ assert_soft_failure "$name" "$component" done +# A target can appear after the initial absence check. Activation must not +# replace it or move the staged directory inside it, and active older tools +# must stay selected. +for component_spec in \ + 'Node install_node .nvm/versions/node/v24.20.0' \ + 'Bun install_bun .bun/versions/1.4.0' \ + 'Codex install_codex .local/lib/panama/codex/0.150.1'; do + read -r component function_name target_relative <<<"$component_spec" + name="${component,,}-late-collision" + reset_installer_fixture + STUB_SEED_OLD="$component" STUB_LATE_COLLISION="$component" \ + expect_failure run_installer_function "$name" "$function_name" + target="$test_tmp/cases/$name/home/$target_relative" + assert_file_bytes "$target/collision-marker" 'preserved collision' + [[ -z "$(find "$target" -mindepth 1 ! -name collision-marker -print -quit)" ]] \ + || fail "$name nested verified staging into the late collision" + assert_old_runtime_preserved "$name" "$component" + assert_soft_failure "$name" "$component" + assert_no_runtime_staging "$name" +done + +# A staged Node must prove that links stay inside the verified tree before its +# executable can run. The hard-link adapter changes the extracted inode at the +# filesystem boundary, which catches checks that inspect tar names only. +for escape_kind in symlink hardlink; do + name="node-$escape_kind-escape" + reset_installer_fixture + STUB_NODE_ESCAPE="$escape_kind" expect_failure \ + run_installer_function "$name" install_node + [[ ! -e "$test_tmp/cases/$name/outside-executed" ]] \ + || fail "$name executed a Node target outside the staged tree" + assert_soft_failure "$name" Node + assert_no_runtime_staging "$name" +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 @@ -1063,6 +1267,31 @@ for failure_mode in digest interrupted; do assert_no_runtime_staging "$name" done +# Deliver a real signal to each isolated installer process group while private +# state exists. Cleanup must run for download, extraction, directory activation, +# and active-link replacement without changing an older selected runtime. +for signal_spec in \ + 'node-signal-extract install_node Node extract' \ + 'bun-signal-activation install_bun Bun activation' \ + 'codex-signal-download install_codex Codex download' \ + 'rustdesk-signal-download install_rustdesk RustDesk download' \ + 'bun-signal-link install_bun Bun link' \ + 'codex-signal-link install_codex Codex link'; do + read -r name function_name component phase <<<"$signal_spec" + reset_installer_fixture + if [[ "$component" == RustDesk ]]; then + STUB_RUSTDESK_VERSION=1.4.8 STUB_SIGNAL_PHASE="$phase" \ + expect_failure run_installer_function "$name" "$function_name" 2>/dev/null + else + STUB_SEED_OLD="$component" STUB_SIGNAL_PHASE="$phase" \ + expect_failure run_installer_function "$name" "$function_name" 2>/dev/null + assert_old_runtime_preserved "$name" "$component" + fi + grep -qFx "signal:$phase" "$test_tmp/cases/$name/commands.log" \ + || fail "$name did not deliver its real process-group signal" + 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' \ @@ -1083,6 +1312,45 @@ for component_spec in 'Bun install_bun .bun/bin/bun .bun/versions/1.4.0/bin/bun' fi done +# Receipt or executable tampering preserves the directory and leaves an older +# active link unchanged. A matching version string is not an attestation. +for tamper_spec in \ + 'Node install_node .nvm/versions/node/v24.20.0 bin/node v24.20.0' \ + 'Bun install_bun .bun/versions/1.4.0 bin/bun 1.4.0' \ + 'Codex install_codex .local/lib/panama/codex/0.150.1 codex codex-cli_0.150.1'; do + read -r component function_name target_relative binary_relative version_text <<<"$tamper_spec" + for tamper_kind in receipt binary; do + name="${component,,}-$tamper_kind-tamper" + reset_installer_fixture + STUB_SEED_OLD="$component" expect_success \ + run_installer_function "$name" "$function_name" + home="$test_tmp/cases/$name/home" + target="$home/$target_relative" + if [[ "$component" == Bun ]]; then + ln -sfn "$home/.bun/versions/1.3.0/bin/bun" "$home/.bun/bin/bun" + elif [[ "$component" == Codex ]]; then + ln -sfn "$home/.local/lib/panama/codex/0.149.0/codex" "$home/.local/bin/codex" + fi + if [[ "$tamper_kind" == receipt ]]; then + sed -i 's/^artifact_sha256=.*/artifact_sha256=0000000000000000000000000000000000000000000000000000000000000000/' \ + "$target/.panama-provenance" + else + version_text="${version_text//_/ }" + printf '#!/usr/bin/env bash\nprintf "%s\\n"\n' "$version_text" \ + > "$target/$binary_relative" + chmod +x "$target/$binary_relative" + fi + STUB_REUSE_CASE=1 expect_failure \ + run_installer_function "$name" "$function_name" + assert_no_download "$name" + assert_soft_failure "$name" "$component" + if [[ "$component" != Node ]]; then + assert_old_runtime_preserved "$name" "$component" + fi + assert_no_runtime_staging "$name" + done +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 \ @@ -1098,9 +1366,9 @@ for layout_case in \ 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 +# Legacy version-only and mismatched collisions are preserved and reported; +# neither can be deleted and recreated or treated as installer provenance. +for collision_mode in legacy collision; do for component_spec in \ 'Node install_node' \ 'Bun install_bun' \ @@ -1108,11 +1376,10 @@ for collision_mode in exact collision; 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 \ + if [[ "$collision_mode" == legacy ]]; then + STUB_SEED_LEGACY="$component" expect_failure \ run_installer_function "$name" "$function_name" - [[ ! -s "$test_tmp/cases/$name/softly-failed" ]] \ - || fail "$name reported a failure for the exact installed version" + assert_soft_failure "$name" "$component" else STUB_SEED_COLLISION="$component" expect_failure \ run_installer_function "$name" "$function_name" @@ -1128,7 +1395,7 @@ 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=1 RUSTDESK_LOCAL +sudo:dnf install -y --setopt=localpkg_gpgcheck=0 RUSTDESK_LOCAL EXPECTED )" assert_no_runtime_staging rustdesk-x86_64 @@ -1140,7 +1407,11 @@ 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' +assert_log pnpm "$(cat <<'EXPECTED' +rpm:release +sudo:dnf install -y --repo=fedora --repo=updates pnpm +EXPECTED +)" reset_installer_fixture STUB_DNF_FAIL_MATCH=pnpm expect_failure run_installer_function pnpm-failure install_pnpm @@ -1232,7 +1503,7 @@ rpm:release gpg:fingerprint:31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE 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 claude-code +sudo:dnf install -y --repo=claude-code --repo=fedora --repo=updates claude-code EXPECTED )" cmp -s "$installer_fixture/setup/provenance/keys/claude-code.asc" \ @@ -1427,7 +1698,7 @@ 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 \ - configure_hyprland_repository ensure_flathub_remote install_claude_code \ + configure_hyprland_repository ensure_flathub_remote install_pnpm install_claude_code \ install_claude_desktop_if_trusted; do reset_installer_fixture name="wrong-fedora-${function_name}" @@ -1435,6 +1706,8 @@ for function_name in install_rpmfusion_repositories install_terra_repository \ assert_log "$name" 'rpm:release' if [[ "$function_name" == install_claude_code ]]; then assert_soft_failure "$name" 'Claude Code' + elif [[ "$function_name" == install_pnpm ]]; then + assert_soft_failure "$name" pnpm fi done