Fix: Lock installer input state

This commit is contained in:
Gabriel Brown
2026-08-27 09:48:28 -04:00
parent 1ffd05f0e1
commit ba4e5e6677
5 changed files with 3239 additions and 54 deletions
+105 -25
View File
@@ -40,6 +40,20 @@ STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-setting
link-vicinae-scripts setup-server link-server setup-identity
install-hardware)
copy_hash_inputs() {
local root="$1" source relative
while IFS= read -r -d '' source; do
relative="${source#"$repo_dir"/}"
mkdir -p "$(dirname "$root/$relative")"
cp -- "$source" "$root/$relative"
done < <(
find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0
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"
}
# A PANAMA_PATH that looks enough like the real one for install to run, and
# records what it was asked to do instead of doing it.
build_fixture() {
@@ -51,10 +65,7 @@ build_fixture() {
cp "$installer" "$root/install"
: >"$root/bin/ascii"
printf 'base-package\n' >"$root/setup/packages/core-packages"
printf 'artifact provenance\n' >"$root/setup/lib/artifact-provenance"
printf 'reviewed installer inputs\n' >"$root/setup/provenance/installers.conf"
printf 'reviewed key\n' >"$root/setup/provenance/keys/fixture.asc"
copy_hash_inputs "$root"
local stage
for stage in "${STAGE_NAMES[@]}"; do
@@ -134,6 +145,20 @@ run_install() {
return "$status"
}
run_hash() {
local root="$1"
sed -n '/^hash_packages() {/,/^}$/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"
}
assert_hash_failure() {
local root="$1" description="$2" status=0 digest
digest="$(run_hash "$root")" || status=$?
[[ "$status" -ne 0 && -z "$digest" ]] \
|| note "$description produced a digest instead of failing closed"
}
# ── 1. The interview never runs on an upgrade ────────────────────────────────
build_fixture "$tmp/a"
@@ -197,6 +222,19 @@ done
# ── 3. The packages hash gates the stage, and a failure does not record it ───
package_inputs=()
while IFS= read -r -d '' input; do
package_inputs+=("${input#"$repo_dir"/}")
done < <(find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0)
provenance_inputs=()
while IFS= read -r -d '' input; do
provenance_inputs+=("${input#"$repo_dir"/}")
done < <(find "$repo_dir/setup/provenance" -type f -print0)
(( ${#package_inputs[@]} > 0 )) \
|| note 'the current repository has no top-level package input to exercise'
(( ${#provenance_inputs[@]} > 0 )) \
|| note 'the current repository has no provenance input to exercise'
# Second run, nothing changed: the stage must be skipped.
install_status=0
ran_again="$(run_install "$tmp/a" --upgrade)" || install_status=$?
@@ -213,33 +251,75 @@ ran_forced="$(run_install "$tmp/a" --upgrade --packages)" || install_status=$?
grep -qx 'install-packages' <<<"$ran_forced" \
|| note '--packages did not force install-packages to run'
# A changed list brings the stage back.
printf 'another-package\n' >>"$tmp/a/setup/packages/core-packages"
install_status=0
ran_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "install --upgrade failed after a package-list change with status $install_status"
grep -qx 'install-packages' <<<"$ran_changed" \
|| note 'a changed package list did not bring install-packages back'
# The package stage consumes more than package lists. Each reviewed input must
# independently invalidate a recorded package state so an upgrade cannot keep
# using a stale trust or installer policy.
for state_input in \
'setup/scripts/install-packages installer adapter' \
'setup/lib/artifact-provenance provenance helper' \
'setup/provenance/installers.conf installer provenance' \
'setup/provenance/keys/fixture.asc provenance key'; do
read -r relative label <<<"$state_input"
printf 'changed %s\n' "$label" >>"$tmp/a/$relative"
# Every current package and provenance member is part of the state definition.
# 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
printf 'changed %s\n' "$relative" >>"$tmp/a/$relative"
install_status=0
ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "install --upgrade failed after a $label change with status $install_status"
|| note "install --upgrade failed after changing $relative with status $install_status"
grep -qx 'install-packages' <<<"$ran_input_changed" \
|| note "a changed $label did not bring install-packages back"
|| note "a changed $relative did not bring install-packages back"
done
# A path-only change must invalidate state even when the file bytes are exact.
for relative in "${package_inputs[0]:-}" "${provenance_inputs[0]:-}"; do
[[ -n "$relative" ]] || continue
build_fixture "$tmp/path-rename"
run_install "$tmp/path-rename" --upgrade >/dev/null
mv -- "$tmp/path-rename/$relative" "$tmp/path-rename/$relative.renamed"
install_status=0
ran_renamed="$(run_install "$tmp/path-rename" --upgrade)" || install_status=$?
[[ "$install_status" -eq 0 ]] \
|| note "install --upgrade failed after renaming $relative with status $install_status"
grep -qx 'install-packages' <<<"$ran_renamed" \
|| note "renaming $relative without changing bytes did not bring install-packages back"
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 case_name in missing directory symlink unreadable; do
case_root="$tmp/hash-${fixed_input//\//-}-$case_name"
build_fixture "$case_root"
fixed_path="$case_root/$fixed_input"
case "$case_name" in
missing) rm -- "$fixed_path" ;;
directory) rm -- "$fixed_path"; mkdir -- "$fixed_path" ;;
symlink)
printf 'untrusted target\n' >"$case_root/untrusted-target"
rm -- "$fixed_path"
ln -s "$case_root/untrusted-target" "$fixed_path"
;;
unreadable) chmod 000 "$fixed_path" ;;
esac
assert_hash_failure "$case_root" "$fixed_input $case_name"
done
done
read_failure_root="$tmp/hash-package-read-failure"
build_fixture "$read_failure_root"
chmod 000 "$read_failure_root/${package_inputs[0]}"
assert_hash_failure "$read_failure_root" "${package_inputs[0]} unreadable"
# 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"
run_install "$tmp/hash-failure" --upgrade >/dev/null
cp -- "$tmp/hash-failure/state/panama/packages-hash" "$tmp/hash-failure/stamp-before"
rm -- "$tmp/hash-failure/setup/lib/artifact-provenance"
install_status=0
ran_hash_failure="$(run_install "$tmp/hash-failure" --upgrade)" || install_status=$?
[[ "$install_status" -ne 0 ]] \
|| note 'a failed package-state hash returned success'
grep -qx 'install-packages' <<<"$ran_hash_failure" \
&& note 'a failed package-state hash still ran install-packages'
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'
# A failing stage must not record the hash, or the failure is hidden forever.
build_fixture "$tmp/c" 1
install_status=0