The repository audit made any Terra that is not Panama's own pinned form a trust-root failure, and status 78 then stopped every stage before it ran. A machine that installed Terra the way Terra documents it -- terra-release's own repo file, a metalink, the key at its stock path -- was classified hostile and had no way back, because install_terra_repository refused to touch a machine terra-release had already reached. A gate with no door. The trust root is the signing key, and that key is byte-for-byte the fingerprint this repository reviewed and pinned, with every signature check already on. So verify the fingerprint and adopt the configuration into the pinned form instead of refusing it. Adoption needs no network and no DNF, it runs before any other transaction in the stage, and it is repeatable, which it has to be: terra-release owns that file and restores it on update. Adoption stays narrow. The pinned fingerprint must match both the reviewed key and the key the machine actually verifies against, the gpgkey must be a local file under the system trust directory, and the endpoint must be one Terra itself serves -- so the reviewed baseurl or the reviewed metalink host, now pinned as TERRA_METALINK_BASEURL. An unknown key, a redirected baseurl, a second enabled Terra, or a disabled signature check is still a hard refusal. A refusal also stops less than it did. It suppresses the stages that open DNF and the migrations, which may run a transaction of their own, and the run still exits 78. It no longer stops link-dotfiles, link-skills or link-user, which read no repository and install no package. Exiting before them is what left this laptop with a stale ~/.claude/skills and no shipped skill reachable. Also stub ensure_flathub_remote in the extras contract, which has been failing since that call was added to install_extra_category without one. Claude-Session: https://claude.ai/code/session_01PeTrG9dGY89UWuhGm4Pr1s
688 lines
28 KiB
Bash
Executable File
688 lines
28 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# `panama update`: the routine command, and the promises that make it routine.
|
|
#
|
|
# An update that asks questions is an update nobody runs, and a machine nobody
|
|
# updates drifts until the next reinstall. So the properties below are the
|
|
# whole point of the command rather than details of it:
|
|
#
|
|
# 1. --upgrade NEVER runs the interview. This is the regression that started
|
|
# the redesign: ./install asked the full first-install questionnaire every
|
|
# time, including on a machine whose answers could not have changed.
|
|
# 2. --upgrade runs exactly the stages that need no answer, and never
|
|
# setup-identity or install-hardware. Both act only on interview answers.
|
|
# 3. install-packages is gated on a content hash of the package lists, and
|
|
# the hash is NOT recorded when the stage fails -- the same rule
|
|
# panama-migrate applies to its markers, for the same reason.
|
|
# 4. A conflicted `git stash pop` leaves no conflict markers in the tree.
|
|
# Every dotfile here is a symlink into the checkout, so a half-merged file
|
|
# is not something to fix later: it is live in ~/.config immediately, and
|
|
# a broken .qml costs the running shell.
|
|
#
|
|
# Driven against fixture stages in a throwaway PANAMA_PATH, with sudo and
|
|
# gsettings shimmed, so this never touches the machine running it.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
installer="$repo_dir/install"
|
|
panama="$repo_dir/bin/panama"
|
|
|
|
findings=()
|
|
note() { findings+=("$1"); }
|
|
|
|
[[ -x "$installer" ]] || { printf 'update command contract: no installer at %s\n' "$installer" >&2; exit 1; }
|
|
|
|
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
|
|
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
|
|
link-vicinae-scripts setup-server link-server setup-identity
|
|
install-hardware)
|
|
PACKAGE_BEHAVIOR_INPUTS=(
|
|
setup/lib/artifact-provenance
|
|
setup/lib/chatgpt-package
|
|
setup/lib/extras-catalog
|
|
setup/lib/machine-role
|
|
)
|
|
|
|
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
|
|
)
|
|
for relative in "${PACKAGE_BEHAVIOR_INPUTS[@]}"; do
|
|
mkdir -p "$(dirname "$root/$relative")"
|
|
cp -- "$repo_dir/$relative" "$root/$relative"
|
|
done
|
|
}
|
|
|
|
# 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() {
|
|
local root="$1" packages_rc="${2:-0}" trust_rc="${3:-0}"
|
|
rm -rf "$root"
|
|
mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \
|
|
"$root/setup/lib" "$root/setup/provenance/keys" \
|
|
"$root/config/dot/quickshell/scripts"
|
|
|
|
cp "$installer" "$root/install"
|
|
: >"$root/bin/ascii"
|
|
copy_hash_inputs "$root"
|
|
|
|
local stage
|
|
for stage in "${STAGE_NAMES[@]}"; do
|
|
cat >"$root/setup/scripts/$stage" <<EOF
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "$stage" >>"\$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/setup/scripts/$stage"
|
|
done
|
|
# The one stage whose exit code the caller wants to control.
|
|
cat >"$root/setup/scripts/install-packages" <<EOF
|
|
#!/usr/bin/env bash
|
|
if [[ "\${1:-}" == --trust-preflight ]]; then
|
|
printf 'trust-preflight\n' >>"\$PANAMA_RAN"
|
|
exit $trust_rc
|
|
fi
|
|
printf 'install-packages\n' >>"\$PANAMA_RAN"
|
|
exit $packages_rc
|
|
EOF
|
|
chmod +x "$root/setup/scripts/install-packages"
|
|
|
|
cat >"$root/setup/scripts/interview" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'interview\n' >>"$PANAMA_RAN"
|
|
: >"$PANAMA_ANSWERS"
|
|
EOF
|
|
chmod +x "$root/setup/scripts/interview"
|
|
|
|
cat >"$root/bin/panama-migrate" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'migrate %s\n' "${1:-run}" >>"$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/bin/panama-migrate"
|
|
|
|
cat >"$root/config/dot/quickshell/scripts/panama-doctor" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'doctor\n' >>"$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/config/dot/quickshell/scripts/panama-doctor"
|
|
|
|
# Nothing that reaches the real machine. sudo would prompt in a test run,
|
|
# and gsettings would genuinely change the tester's screensaver.
|
|
mkdir -p "$root/shim"
|
|
cat >"$root/shim/sudo" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
[[ "${1:-}" == -v || "${1:-}" == -n ]] && exit 0
|
|
exit 0
|
|
EOF
|
|
cat >"$root/shim/gsettings" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
exit 0
|
|
EOF
|
|
cat >"$root/shim/hostnamectl" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
exit 0
|
|
EOF
|
|
cat >"$root/shim/dnf" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'dnf-transaction\n' >>"$PANAMA_RAN"
|
|
exit 0
|
|
EOF
|
|
for prerequisite in gum lspci mokutil fwupdmgr; do
|
|
ln -s gsettings "$root/shim/$prerequisite"
|
|
done
|
|
chmod +x "$root/shim"/*
|
|
}
|
|
|
|
# Run the fixture installer and echo what ran, one stage per line.
|
|
run_install() {
|
|
local root="$1"; shift
|
|
local status=0
|
|
: >"$root/ran"
|
|
PATH="$root/shim:$PATH" PANAMA_PATH="$root" PANAMA_RAN="$root/ran" \
|
|
XDG_STATE_HOME="$root/state" bash "$root/install" "$@" \
|
|
>"$root/out" 2>&1 || status=$?
|
|
cat "$root/ran"
|
|
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"
|
|
install_status=0
|
|
ran="$(run_install "$tmp/a" --upgrade)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "initial install --upgrade failed with status $install_status"
|
|
|
|
if grep -qx 'interview' <<<"$ran"; then
|
|
note 'install --upgrade ran the interview, which is the whole regression this prevents'
|
|
fi
|
|
|
|
# And the control: a real install must still ask.
|
|
build_fixture "$tmp/b"
|
|
install_status=0
|
|
ran_install="$(run_install "$tmp/b")" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "plain fixture install failed with status $install_status"
|
|
if ! grep -qx 'interview' <<<"$ran_install"; then
|
|
note 'a plain ./install no longer asks the interview, so a new machine is never configured'
|
|
fi
|
|
|
|
# ── 2. Exactly the answer-free stages ────────────────────────────────────────
|
|
|
|
for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts; do
|
|
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
|
|
done
|
|
for stage in setup-identity install-hardware; do
|
|
grep -qx "$stage" <<<"$ran" \
|
|
&& note "install --upgrade ran $stage, which exists only to act on interview answers"
|
|
done
|
|
|
|
# A stage added to STAGES without a decision about which path owns it shows up
|
|
# here, because this list is written down twice on purpose.
|
|
declared=()
|
|
if declared_output="$(python3 - "$installer" <<'PY'
|
|
import re
|
|
import sys
|
|
|
|
found = False
|
|
for line in open(sys.argv[1], encoding="utf-8"):
|
|
match = re.match(r'^\s*STAGES=\((.*)\)\s*$', line)
|
|
if match and "$" not in match.group(1):
|
|
found = True
|
|
print("\n".join(re.findall(r"[\w-]+", match.group(1))))
|
|
if not found:
|
|
print("install has no STAGES assignment", file=sys.stderr)
|
|
sys.exit(1)
|
|
PY
|
|
)"; then
|
|
while IFS= read -r stage; do
|
|
[[ -n "$stage" ]] && declared+=("$stage")
|
|
done <<<"$declared_output"
|
|
else
|
|
note 'could not read the install STAGES assignment'
|
|
fi
|
|
for stage in "${declared[@]}"; do
|
|
printf '%s\n' "${STAGE_NAMES[@]}" | grep -qx "$stage" \
|
|
|| note "install declares a stage this contract has never heard of: $stage"
|
|
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=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "repeat install --upgrade failed with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_again" \
|
|
&& note 'install-packages ran again with the package lists unchanged'
|
|
|
|
# --packages overrides the hash.
|
|
install_status=0
|
|
ran_forced="$(run_install "$tmp/a" --upgrade --packages)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "install --upgrade --packages failed with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_forced" \
|
|
|| note '--packages did not force install-packages to run'
|
|
|
|
# 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' "${PACKAGE_BEHAVIOR_INPUTS[@]}"; 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 changing $relative with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_input_changed" \
|
|
|| 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 "${PACKAGE_BEHAVIOR_INPUTS[@]}"; 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'
|
|
|
|
# Inputs changed while install-packages was running were not the inputs it
|
|
# consumed at the start. Do not stamp the later bytes as successfully applied.
|
|
build_fixture "$tmp/hash-mid-stage-drift"
|
|
cat >"$tmp/hash-mid-stage-drift/setup/scripts/install-packages" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
if [[ "${1:-}" == --trust-preflight ]]; then
|
|
printf 'trust-preflight\n' >>"$PANAMA_RAN"
|
|
exit 0
|
|
fi
|
|
printf 'install-packages\n' >>"$PANAMA_RAN"
|
|
printf '\nchanged during package installation\n' >>"$PANAMA_PATH/setup/lib/machine-role"
|
|
EOF
|
|
chmod +x "$tmp/hash-mid-stage-drift/setup/scripts/install-packages"
|
|
install_status=0
|
|
run_install "$tmp/hash-mid-stage-drift" --upgrade >/dev/null || install_status=$?
|
|
[[ "$install_status" -ne 0 ]] \
|
|
|| note 'mid-stage package input drift returned success'
|
|
[[ ! -e "$tmp/hash-mid-stage-drift/state/panama/packages-hash" ]] \
|
|
|| note 'mid-stage package input drift stamped bytes the stage did not start with'
|
|
|
|
# A failing stage must not record the hash, or the failure is hidden forever.
|
|
build_fixture "$tmp/c" 1
|
|
install_status=0
|
|
run_install "$tmp/c" --upgrade >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 1 ]] \
|
|
|| note "install --upgrade returned $install_status instead of the failing stage status"
|
|
if [[ -r "$tmp/c/state/panama/packages-hash" ]]; then
|
|
note 'install-packages failed but its hash was recorded, so it will never be retried'
|
|
fi
|
|
grep -qx 'link-dotfiles' "$tmp/c/ran" \
|
|
|| note 'an ordinary package-stage failure no longer allows later safe stages'
|
|
|
|
# An untrusted Terra root is not an ordinary package failure, and it is not a
|
|
# reason to abandon the machine either. It suppresses the stages that open DNF
|
|
# and the migrations, which are free to run a transaction of their own. Every
|
|
# stage that only links configuration still runs, and the status stays 78.
|
|
SAFE_STAGES=(link-dotfiles link-skills link-user link-vicinae-scripts)
|
|
DNF_SUPPRESSED=(install-packages change-settings install-hardware)
|
|
|
|
assert_trust_refusal() {
|
|
local root="$1" label="$2" suppressed safe
|
|
for suppressed in "${DNF_SUPPRESSED[@]}"; do
|
|
grep -qx "$suppressed" "$root/ran" \
|
|
&& note "$label still ran $suppressed"
|
|
done
|
|
grep -q '^migrate ' "$root/ran" \
|
|
&& note "$label still ran migrations, which may open a DNF transaction"
|
|
for safe in "${SAFE_STAGES[@]}"; do
|
|
grep -qx "$safe" "$root/ran" \
|
|
|| note "$label suppressed $safe, which touches no repository"
|
|
done
|
|
}
|
|
|
|
build_fixture "$tmp/terra-preflight-hard" 0 78
|
|
install_status=0
|
|
run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 78 ]] \
|
|
|| note "initial Terra trust failure returned $install_status instead of 78"
|
|
grep -qx 'trust-preflight' "$tmp/terra-preflight-hard/ran" \
|
|
|| note 'initial Terra trust fixture never reached the preflight'
|
|
assert_trust_refusal "$tmp/terra-preflight-hard" 'initial Terra trust failure'
|
|
|
|
# The trust verifier is itself mandatory. Losing its executable adapter refuses
|
|
# package work exactly as a failing verdict does, rather than being ignored.
|
|
build_fixture "$tmp/terra-preflight-missing"
|
|
rm "$tmp/terra-preflight-missing/setup/scripts/install-packages"
|
|
install_status=0
|
|
run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 78 ]] \
|
|
|| note "missing Terra trust verifier returned $install_status instead of 78"
|
|
assert_trust_refusal "$tmp/terra-preflight-missing" 'missing Terra trust verifier'
|
|
|
|
# The package stage repeats the preflight to close a configuration-change race.
|
|
# Its hard status suppresses the DNF stages that would have followed it.
|
|
build_fixture "$tmp/terra-stage-hard" 78 0
|
|
install_status=0
|
|
run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 78 ]] \
|
|
|| note "stage-time Terra trust failure returned $install_status instead of 78"
|
|
grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \
|
|
|| note 'stage-time Terra trust fixture never reached install-packages'
|
|
for suppressed in change-settings install-hardware; do
|
|
grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \
|
|
&& note "stage-time Terra trust failure still ran $suppressed"
|
|
done
|
|
grep -q '^migrate ' "$tmp/terra-stage-hard/ran" \
|
|
&& note 'stage-time Terra trust failure still ran migrations'
|
|
for safe in "${SAFE_STAGES[@]}"; do
|
|
grep -qx "$safe" "$tmp/terra-stage-hard/ran" \
|
|
|| note "stage-time Terra trust failure suppressed $safe"
|
|
done
|
|
|
|
# A full install always runs the stage, whatever any recorded hash says.
|
|
build_fixture "$tmp/d"
|
|
install_status=0
|
|
run_install "$tmp/d" --upgrade >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "hash-seeding install --upgrade failed with status $install_status"
|
|
install_status=0
|
|
ran_full="$(run_install "$tmp/d")" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "full fixture reinstall failed with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_full" \
|
|
|| note 'a full ./install skipped install-packages because of a recorded hash'
|
|
|
|
# ── 4. A conflicted pop never leaves markers in a live config ────────────────
|
|
#
|
|
# The fixture also covers a clean fast-forward, installer status propagation,
|
|
# and the boundary between update and sync before forcing the conflict below.
|
|
|
|
# Make every ambient configuration source hostile before constructing the Git
|
|
# fixtures. A hermetic fixture overrides these values with its own empty state;
|
|
# consuming any of them either leaves a sentinel or prevents a commit.
|
|
hostile="$tmp/hostile-environment"
|
|
mkdir -p "$hostile/home" "$hostile/xdg-config" "$hostile/xdg-state" \
|
|
"$hostile/xdg-cache" "$hostile/xdg-data" "$hostile/hooks" \
|
|
"$hostile/template/hooks"
|
|
for profile in .bash_profile .bashrc .profile; do
|
|
cat >"$hostile/home/$profile" <<'EOF'
|
|
printf 'profile\n' >>"${PANAMA_HOSTILE_PROFILE_SENTINEL:?}"
|
|
EOF
|
|
done
|
|
cat >"$hostile/bash-env" <<'EOF'
|
|
printf 'BASH_ENV\n' >>"${PANAMA_HOSTILE_BASH_ENV_SENTINEL:?}"
|
|
EOF
|
|
cat >"$hostile/hooks/pre-commit" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'global hook\n' >>"${PANAMA_HOSTILE_GIT_SENTINEL:?}"
|
|
exit 97
|
|
EOF
|
|
cat >"$hostile/template/hooks/pre-commit" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
# PANAMA_HOSTILE_TEMPLATE_HOOK
|
|
printf 'template hook\n' >>"${PANAMA_HOSTILE_TEMPLATE_SENTINEL:?}"
|
|
exit 98
|
|
EOF
|
|
chmod +x "$hostile/hooks/pre-commit" "$hostile/template/hooks/pre-commit"
|
|
cat >"$hostile/global.gitconfig" <<EOF
|
|
[core]
|
|
hooksPath = $hostile/hooks
|
|
[commit]
|
|
gpgSign = true
|
|
[init]
|
|
templateDir = $hostile/template
|
|
EOF
|
|
cp "$hostile/global.gitconfig" "$hostile/system.gitconfig"
|
|
|
|
export HOME="$hostile/home"
|
|
export XDG_CONFIG_HOME="$hostile/xdg-config"
|
|
export XDG_STATE_HOME="$hostile/xdg-state"
|
|
export XDG_CACHE_HOME="$hostile/xdg-cache"
|
|
export XDG_DATA_HOME="$hostile/xdg-data"
|
|
export BASH_ENV="$hostile/bash-env"
|
|
export PANAMA_HOSTILE_PROFILE_SENTINEL="$hostile/profile-sourced"
|
|
export PANAMA_HOSTILE_BASH_ENV_SENTINEL="$hostile/bash-env-sourced"
|
|
export PANAMA_HOSTILE_GIT_SENTINEL="$hostile/global-config-sourced"
|
|
export PANAMA_HOSTILE_TEMPLATE_SENTINEL="$hostile/template-hook-sourced"
|
|
export GIT_CONFIG_NOSYSTEM=0
|
|
export GIT_CONFIG_SYSTEM="$hostile/system.gitconfig"
|
|
export GIT_CONFIG_GLOBAL="$hostile/global.gitconfig"
|
|
export GIT_CONFIG_COUNT=1
|
|
export GIT_CONFIG_KEY_0=core.hooksPath
|
|
export GIT_CONFIG_VALUE_0="$hostile/hooks"
|
|
export GIT_TEMPLATE_DIR="$hostile/template"
|
|
|
|
prepare_cli_fixture_environment() {
|
|
local root="$1"
|
|
mkdir -p "$root/home" "$root/xdg-config" "$root/xdg-state" \
|
|
"$root/xdg-cache" "$root/xdg-data" "$root/xdg-runtime" \
|
|
"$root/empty-templates" "$root/empty-hooks"
|
|
chmod 700 "$root/xdg-runtime"
|
|
}
|
|
|
|
run_cli_fixture_environment() {
|
|
local root="$1"
|
|
shift
|
|
env -u BASH_ENV -u ENV -u GIT_CONFIG_PARAMETERS \
|
|
-u GIT_CONFIG_KEY_0 -u GIT_CONFIG_VALUE_0 \
|
|
HOME="$root/home" \
|
|
XDG_CONFIG_HOME="$root/xdg-config" \
|
|
XDG_STATE_HOME="$root/xdg-state" \
|
|
XDG_CACHE_HOME="$root/xdg-cache" \
|
|
XDG_DATA_HOME="$root/xdg-data" \
|
|
XDG_RUNTIME_DIR="$root/xdg-runtime" \
|
|
GIT_CONFIG_NOSYSTEM=1 \
|
|
GIT_CONFIG_SYSTEM=/dev/null \
|
|
GIT_CONFIG_GLOBAL=/dev/null \
|
|
GIT_CONFIG_COUNT=0 \
|
|
GIT_TEMPLATE_DIR="$root/empty-templates" \
|
|
"$@"
|
|
}
|
|
|
|
fixture_git() {
|
|
local root="$1"
|
|
shift
|
|
run_cli_fixture_environment "$root" \
|
|
git -c commit.gpgSign=false -c tag.gpgSign=false \
|
|
-c core.hooksPath="$root/empty-hooks" "$@"
|
|
}
|
|
|
|
configure_fixture_repo() {
|
|
local root="$1" repository="$2"
|
|
fixture_git "$root" -C "$repository" config user.email contract@panama || return 1
|
|
fixture_git "$root" -C "$repository" config user.name contract || return 1
|
|
fixture_git "$root" -C "$repository" config commit.gpgSign false || return 1
|
|
fixture_git "$root" -C "$repository" config tag.gpgSign false || return 1
|
|
fixture_git "$root" -C "$repository" config core.hooksPath "$root/empty-hooks" || return 1
|
|
}
|
|
|
|
# Each fixture has the same three repositories as a real update: a bare remote,
|
|
# a clone that publishes upstream changes, and the machine clone being updated.
|
|
build_cli_fixture() (
|
|
local root="$1"
|
|
rm -rf "$root" || return 1
|
|
mkdir -p "$root" || return 1
|
|
prepare_cli_fixture_environment "$root" || return 1
|
|
fixture_git "$root" init -q --bare "$root/origin.git" || return 1
|
|
fixture_git "$root" -C "$root/origin.git" config core.hooksPath "$root/empty-hooks" || return 1
|
|
fixture_git "$root" clone -q "$root/origin.git" "$root/upstream" 2>/dev/null || return 1
|
|
configure_fixture_repo "$root" "$root/upstream" || return 1
|
|
|
|
mkdir -p "$root/upstream/bin" || return 1
|
|
cp "$panama" "$root/upstream/bin/panama" || return 1
|
|
cat >"$root/upstream/install" <<'EOF' || return 1
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "$*" >>"${PANAMA_UPDATE_FIXTURE_LOG:?}"
|
|
exit "${PANAMA_UPDATE_INSTALL_RC:-0}"
|
|
EOF
|
|
chmod +x "$root/upstream/bin/panama" "$root/upstream/install" || return 1
|
|
printf 'one\n' >"$root/upstream/f" || return 1
|
|
fixture_git "$root" -C "$root/upstream" add -A || return 1
|
|
fixture_git "$root" -C "$root/upstream" commit -qm initial || return 1
|
|
fixture_git "$root" -C "$root/upstream" push -qu origin HEAD || return 1
|
|
|
|
fixture_git "$root" clone -q "$root/origin.git" "$root/machine" || return 1
|
|
configure_fixture_repo "$root" "$root/machine" || return 1
|
|
)
|
|
|
|
advance_upstream() (
|
|
local root="$1" file="$2" contents="$3"
|
|
printf '%s\n' "$contents" >"$root/upstream/$file" || return 1
|
|
fixture_git "$root" -C "$root/upstream" add "$file" || return 1
|
|
fixture_git "$root" -C "$root/upstream" commit -qm "update $file" || return 1
|
|
fixture_git "$root" -C "$root/upstream" push -q || return 1
|
|
)
|
|
|
|
# This write fails before the later Git commands. The helper must return that
|
|
# failure rather than let a final successful push hide it.
|
|
helper_probe="$tmp/helper-failure"
|
|
if build_cli_fixture "$helper_probe"; then
|
|
if advance_upstream "$helper_probe" missing/child probe \
|
|
2>"$helper_probe/intermediate-failure.err"; then
|
|
note 'advance_upstream hid an intermediate fixture setup failure'
|
|
fi
|
|
else
|
|
note 'the fixture helper failure probe could not be built'
|
|
fi
|
|
|
|
clean="$tmp/clean-update"
|
|
if build_cli_fixture "$clean" && advance_upstream "$clean" release new; then
|
|
machine_before="$(fixture_git "$clean" -C "$clean/machine" rev-parse HEAD)"
|
|
upstream_after="$(fixture_git "$clean" -C "$clean/upstream" rev-parse HEAD)"
|
|
[[ "$machine_before" != "$upstream_after" ]] \
|
|
|| note 'the clean update fixture started current, so it cannot prove a fast-forward'
|
|
|
|
: >"$clean/install.log"
|
|
update_status=0
|
|
run_cli_fixture_environment "$clean" \
|
|
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" \
|
|
"$clean/machine/bin/panama" update >"$clean/update.out" 2>&1 \
|
|
|| update_status=$?
|
|
[[ "$update_status" -eq 0 ]] \
|
|
|| note "panama update failed on a clean clone with status $update_status"
|
|
[[ "$(fixture_git "$clean" -C "$clean/machine" rev-parse HEAD)" == "$upstream_after" ]] \
|
|
|| note 'panama update did not fast-forward the clean machine clone'
|
|
grep -qx -- '--upgrade' "$clean/install.log" \
|
|
|| note 'panama update did not invoke the installer with --upgrade'
|
|
|
|
: >"$clean/install.log"
|
|
update_status=0
|
|
run_cli_fixture_environment "$clean" \
|
|
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" PANAMA_UPDATE_INSTALL_RC=23 \
|
|
"$clean/machine/bin/panama" update >"$clean/failing-update.out" 2>&1 \
|
|
|| update_status=$?
|
|
[[ "$update_status" -eq 23 ]] \
|
|
|| note "panama update returned $update_status instead of installer status 23"
|
|
grep -qx -- '--upgrade' "$clean/install.log" \
|
|
|| note 'the failing update did not reach the fixture installer'
|
|
|
|
: >"$clean/install.log"
|
|
printf 'local sync\n' >"$clean/machine/synced"
|
|
sync_status=0
|
|
printf 'y\ncontract sync\n' \
|
|
| run_cli_fixture_environment "$clean" \
|
|
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" \
|
|
"$clean/machine/bin/panama" sync >"$clean/sync.out" 2>&1 \
|
|
|| sync_status=$?
|
|
[[ "$sync_status" -eq 0 ]] \
|
|
|| note "panama sync failed in the local fixture with status $sync_status"
|
|
[[ ! -s "$clean/install.log" ]] \
|
|
|| note 'panama sync invoked the installer; sync and update are separate jobs'
|
|
else
|
|
note 'the clean update fixture could not be built'
|
|
fi
|
|
|
|
# A conflicted stash pop must not leave markers in the checkout. The failed pop
|
|
# keeps the stash, so the local version remains recoverable after the reset.
|
|
conflict="$tmp/conflict-update"
|
|
if build_cli_fixture "$conflict"; then
|
|
printf 'local\n' >"$conflict/machine/f"
|
|
if advance_upstream "$conflict" f upstream; then
|
|
: >"$conflict/install.log"
|
|
conflict_status=0
|
|
run_cli_fixture_environment "$conflict" \
|
|
PANAMA_UPDATE_FIXTURE_LOG="$conflict/install.log" \
|
|
"$conflict/machine/bin/panama" update >"$conflict/update.out" 2>&1 \
|
|
|| conflict_status=$?
|
|
[[ "$conflict_status" -eq 0 ]] \
|
|
|| note "panama update failed while recovering a stash conflict with status $conflict_status"
|
|
[[ "$(<"$conflict/machine/f")" == upstream ]] \
|
|
|| note 'panama update did not reset the conflicted file to the upstream version'
|
|
if fixture_git "$conflict" -C "$conflict/machine" \
|
|
grep -qE '^(<<<<<<<|=======|>>>>>>>)' -- .; then
|
|
note 'panama update left conflict markers in the machine checkout'
|
|
fi
|
|
[[ -n "$(fixture_git "$conflict" -C "$conflict/machine" stash list)" ]] \
|
|
|| note 'panama update dropped the stash after its conflicted pop'
|
|
recovered="$(fixture_git "$conflict" -C "$conflict/machine" \
|
|
show 'stash@{0}:f' 2>/dev/null)"
|
|
[[ "$recovered" == local ]] \
|
|
|| note 'the stash left by panama update does not contain the local version'
|
|
else
|
|
note 'the conflict fixture could not publish its upstream edit'
|
|
fi
|
|
else
|
|
note 'the conflict update fixture could not be built'
|
|
fi
|
|
|
|
for sentinel in profile-sourced bash-env-sourced global-config-sourced template-hook-sourced; do
|
|
[[ ! -e "$hostile/$sentinel" ]] \
|
|
|| note "the update Git fixture consumed hostile state: $sentinel"
|
|
done
|
|
template_copy="$(
|
|
find "$tmp" -path "$hostile" -prune -o \
|
|
-type f -path '*/hooks/pre-commit' \
|
|
-exec grep -lF 'PANAMA_HOSTILE_TEMPLATE_HOOK' {} + 2>/dev/null
|
|
)"
|
|
[[ -z "$template_copy" ]] \
|
|
|| note "the update Git fixture copied a hostile template hook: $template_copy"
|
|
|
|
if (( ${#findings[@]} > 0 )); then
|
|
printf 'update command contract: %d finding(s)\n' "${#findings[@]}" >&2
|
|
printf ' - %s\n' "${findings[@]}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'update command contract: PASS\n'
|