Phase 6, the last of the fresh-install spec. 159 scripts lose their .sh: 110 contracts, 47 Vicinae commands, 2 compositor contracts. A shebang and the executable bit already select the interpreter. The extension only ever added something that had to stay in sync, and the rename proved the point twice over in the space of an hour. The spec's stated risk was Vicinae's script discovery. One script was renamed and reloaded on its own before the other 46 followed; it came back as scripts:panama.capture and all 47 resolve. What the probe turned up instead is that the extension was never only a filename: Vicinae's command IDs embed it, so every ID changed. Nothing in this repository refers to them, so nothing breaks. The only trace is Vicinae's metadata.json, whose visited map had two Panama entries that are now orphaned -- two commands lost their usage ranking and will earn it back. Worth knowing before anyone renames these again on a machine that has a keybind pointing at one. Rewriting the references by exact filename missed two things it structurally could not see: a name built from a variable, settings-$page.sh, and a glob, -name '*.sh'. Both were in the contract that counts the generated commands, which promptly reported 47 expected and 0 found. The mechanical part of a rename is the part that looks finished. The three subcommands. panama doctor fronts a health check that already existed and already ran at the end of every install but could not be reached from a terminal. panama upgrade re-runs the installer from anywhere. panama test runs the suite, which had no entry point at all -- 121 files that were the main safety net in this repository and were invisible in it. Writing that runner found three tests nothing was running. calendar_agenda_bridge_test, home_assistant_bridge_test and kdeconnect_bridge_test are unittest suites without the executable bit, so no contract invoked them and the first draft of the runner skipped them silently. All three pass, and have passed unobserved for weeks. The runner collects *_test.py as well now, because a runner with a blind spot is worse than no runner for the same reason a dependency checker with one is: it reports PASS. Six worktrees pruned. Each was re-checked rather than trusted to the spec's list, and two needed it: panama-commands is not on feat/panama-commands but on feat/gnome-tweaks-parity, and fix/panama-displays-review reads [ahead 3] -- ahead of its remote, not of main, with every commit patch-equivalent to landed work. roadmap-completion stays; it has five commits that are genuinely unlanded. The branches are left alone: pruning a worktree costs nothing, deleting a branch is a decision. 121 contracts pass. Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
213 lines
12 KiB
Bash
Executable File
213 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
|
||
# Snapshots of the settings store.
|
||
#
|
||
# The restore path overwrites the file the whole desktop reads, so the
|
||
# properties that matter are: a corrupt snapshot is never restored over a
|
||
# working configuration, a restore snapshots what it replaces so it is itself
|
||
# undoable, and a snapshot name cannot be used to reach a file outside the
|
||
# backup directory.
|
||
|
||
set -euo pipefail
|
||
|
||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||
helper="$repo_dir/config/dot/quickshell/scripts/panama-settings-backup"
|
||
work="$(mktemp -d /tmp/panama-backup-contract.XXXXXX)"
|
||
|
||
fail() {
|
||
printf 'settings backup contract: %s\n' "$1" >&2
|
||
exit 1
|
||
}
|
||
cleanup() { rm -rf "$work"; }
|
||
trap cleanup EXIT
|
||
|
||
settings="$work/config/panama/settings.json"
|
||
home="$work/state/panama/panama-home.json"
|
||
backups="$work/state/panama/backups"
|
||
transaction_dir="$work/state/panama/transactions/settings-restore"
|
||
mkdir -p "$(dirname "$settings")"
|
||
|
||
run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; }
|
||
run_with() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" env "$@"; }
|
||
|
||
assert_transaction_clean() {
|
||
if [[ -d "$transaction_dir" ]] && find "$transaction_dir" -mindepth 1 -print -quit | rg -q .; then
|
||
fail 'restore left staged, rollback, or journal files behind'
|
||
fi
|
||
if find "$work" -type f \( \
|
||
-name '.settings-restore.*' -o -name '.home-restore.*' \
|
||
-o -name '*rollback*' -o -name '.journal.json.*' \
|
||
-o -name '.settings.json.*' -o -name '.panama-home.json.*' \
|
||
\) -print -quit | rg -q .; then
|
||
fail 'restore left a temporary target or journal file behind'
|
||
fi
|
||
}
|
||
|
||
# ── Nothing to back up ───────────────────────────────────────────────────────
|
||
run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success'
|
||
[[ "$(run list)" == "[]" ]] || fail 'an empty backup directory did not list as empty'
|
||
|
||
# ── A snapshot round-trips ───────────────────────────────────────────────────
|
||
printf '{"gapsOut":24,"windowRounding":6,"displays":{"DP-2":{"mode":"3840x2160@60","scale":2,"transform":0}}}' >"$settings"
|
||
mkdir -p "$(dirname "$home")"
|
||
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"}]}' >"$home"
|
||
run save >/dev/null || fail 'save failed on a valid settings file'
|
||
name="$(run list | jq -r '.[0].name')"
|
||
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "unexpected snapshot name: $name"
|
||
[[ "$(run list | jq -r '.[0].keys')" == "3" ]] || fail 'snapshot key count is wrong'
|
||
|
||
printf '{"gapsOut":99,"displays":{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0}}}' >"$settings"
|
||
printf '{"initialized":false,"favorites":[]}' >"$home"
|
||
restore_result="$(run restore "$name")" || fail 'restore failed'
|
||
[[ "$(jq -r .gapsOut "$settings")" == "24" ]] || fail 'restore did not bring back the snapshot contents'
|
||
[[ "$(jq -r .windowRounding "$settings")" == "6" ]] || fail 'restore lost a key'
|
||
[[ "$(jq -r '.displays["DP-2"].scale' "$settings")" == "1.5" ]] \
|
||
|| fail 'restore bypassed display confirmation by applying snapshot geometry'
|
||
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.desk" ]] || fail 'restore did not bring back Home favorites'
|
||
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Desk" ]] || fail 'restore lost a Home alias'
|
||
jq -e '.home.present == true and .home.data.favorites[0].id == "light.desk"' <<<"$restore_result" >/dev/null \
|
||
|| fail 'restore did not return Home state for the live service to reload'
|
||
|
||
# ── Absence is part of a snapshot ───────────────────────────────────────────
|
||
rm -f "$home"
|
||
printf '{"gapsOut":30}' >"$settings"
|
||
run save >/dev/null || fail 'save failed when Home state was absent'
|
||
absent_name="$(run list | jq -r '.[0].name')"
|
||
printf '{"initialized":true,"favorites":[{"id":"light.living_room","alias":"Living room"}]}' >"$home"
|
||
absent_result="$(run restore "$absent_name")" || fail 'restore failed for a snapshot without Home state'
|
||
[[ ! -e "$home" ]] || fail 'restore did not preserve the snapshot’s absent Home state'
|
||
jq -e '.home.present == false and (.home | has("data") | not)' <<<"$absent_result" >/dev/null \
|
||
|| fail 'restore did not return absent Home state for the live service to reload'
|
||
|
||
# Desktop absence is symmetric for ordinary preferences, but confirmed display
|
||
# geometry is protected state: it must survive even a Home-only snapshot.
|
||
rm -f "$settings"
|
||
printf '{"initialized":true,"favorites":[{"id":"light.porch","alias":"Porch"}]}' >"$home"
|
||
run save >/dev/null || fail 'save failed when desktop settings were absent'
|
||
desktop_absent_name="$(run list | jq -r '.[0].name')"
|
||
printf '{"gapsOut":47,"windowRounding":9,"displays":{"DP-2":{"mode":"4500x3000@60","scale":1.5,"transform":0}}}' >"$settings"
|
||
printf '{"initialized":false,"favorites":[]}' >"$home"
|
||
run restore "$desktop_absent_name" >/dev/null || fail 'Home-only snapshot restore failed'
|
||
[[ -e "$settings" ]] || fail 'Home-only restore discarded confirmed display geometry'
|
||
[[ "$(jq -r '.displays["DP-2"].scale' "$settings")" == "1.5" ]] \
|
||
|| fail 'Home-only restore changed confirmed display geometry'
|
||
[[ "$(jq 'keys == ["displays"]' "$settings")" == "true" ]] \
|
||
|| fail 'Home-only restore retained ordinary desktop preferences'
|
||
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.porch" ]] \
|
||
|| fail 'Home-only snapshot did not restore Home state'
|
||
assert_transaction_clean
|
||
|
||
printf '{"gapsOut":17}' >"$settings"
|
||
|
||
# A legacy settings-only snapshot predates presence metadata. Its safest
|
||
# interpretation is to restore desktop settings without deleting current Home
|
||
# state that the old format knew nothing about.
|
||
legacy="settings-20000101-010203004.json"
|
||
printf '{"gapsOut":17}' >"$backups/$legacy"
|
||
printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Office"}]}' >"$home"
|
||
run restore "$legacy" >/dev/null || fail 'legacy snapshot restore failed'
|
||
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || fail 'legacy snapshot did not restore desktop settings'
|
||
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy snapshot destroyed Home state it did not describe'
|
||
|
||
# `version` is a valid unknown desktop preference. It is only an envelope when
|
||
# the complete v2 shape is present.
|
||
legacy_version="settings-20000101-010203005.json"
|
||
printf '{"version":77,"gapsOut":19}' >"$backups/$legacy_version"
|
||
run restore "$legacy_version" >/dev/null || fail 'a legacy snapshot with an unknown version key was rejected'
|
||
[[ "$(jq -r '.version' "$settings")" == "77" ]] || fail 'legacy version key was not restored as desktop data'
|
||
[[ "$(jq -r '.favorites[0].id' "$home")" == "light.office" ]] || fail 'legacy version key changed Home state'
|
||
|
||
# ── A durable journal recovers a process/power-loss split ────────────────────
|
||
printf '{"gapsOut":28,"windowRounding":12}' >"$settings"
|
||
printf '{"initialized":true,"favorites":[{"id":"light.desk","alias":"Snapshot"}]}' >"$home"
|
||
run save >/dev/null || fail 'could not create crash-recovery snapshot'
|
||
crash_name="$(run list | jq -r '.[0].name')"
|
||
|
||
printf '{"gapsOut":91,"windowRounding":3}' >"$settings"
|
||
printf '{"initialized":true,"favorites":[{"id":"light.office","alias":"Before crash"}]}' >"$home"
|
||
run_with PANAMA_SETTINGS_BACKUP_TEST_CRASH=after-desktop "$helper" restore "$crash_name" >/dev/null 2>&1 \
|
||
&& fail 'crash injection completed restore instead of terminating after the first replacement'
|
||
[[ "$(jq -r '.gapsOut' "$settings")" == "28" ]] || fail 'crash did not occur after desktop replacement'
|
||
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'crash unexpectedly replaced Home state'
|
||
[[ -f "$transaction_dir/journal.json" ]] || fail 'crash left no durable recovery journal'
|
||
|
||
# Every entry point must recover before doing its own work. `list` is the least
|
||
# invasive proof and must put both stores back to the pre-restore generation.
|
||
run list >/dev/null || fail 'next invocation could not recover the interrupted restore'
|
||
[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'recovery did not roll desktop settings back'
|
||
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'recovery did not keep Home state in the same generation'
|
||
assert_transaction_clean
|
||
|
||
# Cleanup is installed before staging. A deterministic pre-journal failure
|
||
# must leave both destinations untouched and no hidden artifacts behind.
|
||
run_with PANAMA_SETTINGS_BACKUP_TEST_FAIL=after-desktop-stage "$helper" restore "$crash_name" >/dev/null 2>&1 \
|
||
&& fail 'staging failure injection unexpectedly restored the snapshot'
|
||
[[ "$(jq -r '.gapsOut' "$settings")" == "91" ]] || fail 'staging failure changed desktop settings'
|
||
[[ "$(jq -r '.favorites[0].alias' "$home")" == "Before crash" ]] || fail 'staging failure changed Home state'
|
||
assert_transaction_clean
|
||
|
||
# ── Restoring snapshots what it replaced, so it is undoable ──────────────────
|
||
count="$(run list | jq 'length')"
|
||
[[ "$count" -ge 2 ]] || fail "restore did not snapshot the replaced settings (only $count snapshots)"
|
||
|
||
# ── A corrupt snapshot is refused ────────────────────────────────────────────
|
||
bad="settings-19990101-000000000.json"
|
||
mkdir -p "$backups"
|
||
printf '{ truncated' >"$backups/$bad"
|
||
run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored'
|
||
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'a refused restore still damaged the settings file'
|
||
|
||
invalid_home="settings-19990101-000000001.json"
|
||
jq -n '{
|
||
version: 2,
|
||
desktop: {present: true, data: {gapsOut: 88}},
|
||
home: {present: true, data: {
|
||
initialized: true,
|
||
favorites: [
|
||
{id: "light.desk", alias: "Desk"},
|
||
{id: "light.desk", alias: "Duplicate"}
|
||
]
|
||
}}
|
||
}' >"$backups/$invalid_home"
|
||
run restore "$invalid_home" >/dev/null 2>&1 && fail 'a snapshot with duplicate Home favorites was restored'
|
||
[[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'an invalid Home snapshot still damaged desktop settings'
|
||
|
||
printf '{ truncated' >"$home"
|
||
run save >/dev/null 2>&1 && fail 'a corrupt Home state file was backed up'
|
||
printf '{"initialized":true,"favorites":[]}' >"$home"
|
||
|
||
# ── The live service can sync its private Home state before save ─────────────
|
||
rm -f "$home"
|
||
printf '{"gapsOut":21}' >"$settings"
|
||
live_home='{"initialized":true,"favorites":[{"id":"light.studio","alias":"Studio"}]}'
|
||
run save "$live_home" >/dev/null || fail 'save rejected valid live Home state'
|
||
live_name="$(run list | jq -r '.[0].name')"
|
||
jq -e '.home.present == true and .home.data.favorites[0].alias == "Studio"' \
|
||
"$backups/$live_name" >/dev/null \
|
||
|| fail 'live Home state was not written to the canonical snapshot'
|
||
|
||
# ── A snapshot cannot name a path outside the backup directory ───────────────
|
||
printf '{"pwned":true}' >"$work/outside.json"
|
||
run restore "../../outside.json" >/dev/null 2>&1 && fail 'a traversing snapshot name was accepted'
|
||
run restore "/etc/passwd" >/dev/null 2>&1 && fail 'an absolute snapshot path was accepted'
|
||
link_name="settings-20000101-000000001.json"
|
||
ln -s "$work/outside.json" "$backups/$link_name"
|
||
run restore "$link_name" >/dev/null 2>&1 && fail 'a snapshot symlink escaping the backup directory was accepted'
|
||
jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the backup directory was restored'
|
||
|
||
# ── A snapshot that is not listed is refused ─────────────────────────────────
|
||
run restore "settings-20000101-000000000.json" >/dev/null 2>&1 && fail 'a missing snapshot was reported restored'
|
||
|
||
# ── Snapshots are capped ─────────────────────────────────────────────────────
|
||
for _ in $(seq 1 20); do
|
||
printf '{"n":%s}' "$RANDOM" >"$settings"
|
||
run save >/dev/null
|
||
done
|
||
kept="$(run list | jq 'length')"
|
||
[[ "$kept" -le 15 ]] || fail "snapshots are not capped: $kept kept"
|
||
[[ "$kept" -ge 10 ]] || fail "snapshot pruning was too aggressive: only $kept kept"
|
||
|
||
trap - EXIT
|
||
cleanup
|
||
printf 'settings backup contract: PASS\n'
|