Files
Panama/tests/quickshell/settings-backup-contract
T

309 lines
17 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 snapshots 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'
# ── A snapshot can be given a name, and the name cannot be a path ────────────
#
# Fifteen rows reading "2026-08-24 11:03:07" are fifteen rows nobody can choose
# between, which is the same as having no backups: the one you want is the one
# you took before the thing you are undoing, and the timestamp does not say
# what that was. So `create` takes a name.
#
# A name typed by a person then reaches a filename, which is the oldest way a
# helper gets talked into writing outside its own directory. The charset is the
# snapshot charset -- letters, digits, dot, dash, underscore -- and everything
# else is either sanitized out or refused; either is fine, writing outside
# BACKUP_DIR is not.
rm -f "$backups"/*.json
printf '{"gapsOut":24}' >"$settings"
printf '{"initialized":true,"favorites":[]}' >"$home"
run create >/dev/null || fail 'create without a name failed'
[[ "$(run list | jq 'length')" == "1" ]] || fail 'an unnamed create did not produce one snapshot'
run create "Before the theme experiment" >/dev/null || fail 'create with a name failed'
named="$(run list | jq -r '.[0]')"
jq -e '.label == "Before the theme experiment"' <<<"$named" >/dev/null \
|| fail "a named snapshot did not carry its name back: $named"
named_file="$(jq -r .name <<<"$named")"
[[ "$named_file" =~ ^[A-Za-z0-9_.-]+$ ]] \
|| fail "a named snapshot produced the filename \"$named_file\", which is outside the snapshot charset"
# Every one of these either fails or produces a file inside the backup
# directory. None of them may produce a file anywhere else, and none may
# remove or overwrite something that is not a snapshot.
outside="$work/state/panama/NOT-A-BACKUP"
printf 'untouched\n' >"$outside"
for hostile in \
'../../../NOT-A-BACKUP' \
'/etc/panama-owned' \
'a/b' \
'..' \
'.' \
$'tab\there' \
'-rf'; do
run create "$hostile" >/dev/null 2>&1 || true
[[ "$(<"$outside")" == 'untouched' ]] \
|| fail "the snapshot name \"$hostile\" wrote outside the backup directory"
done
while read -r listed; do
[[ -n "$listed" ]] || continue
[[ "$listed" =~ ^[A-Za-z0-9_.-]+$ ]] \
|| fail "a hostile snapshot name produced the listed filename \"$listed\""
done < <(run list | jq -r '.[].name')
# ── A snapshot can be deleted, and only a snapshot ───────────────────────────
#
# Same boundary as restore, in the other direction, and with a worse failure:
# restore reading the wrong file overwrites settings, delete resolving the
# wrong name destroys something that is not a backup at all.
before_delete="$(run list | jq 'length')"
(( before_delete >= 2 )) || fail 'not enough snapshots to exercise delete'
victim="$(run list | jq -r '.[0].name')"
run delete "$victim" >/dev/null || fail 'deleting a listed snapshot failed'
[[ ! -e "$backups/$victim" ]] || fail 'a deleted snapshot is still on disk'
[[ "$(run list | jq 'length')" == "$((before_delete - 1))" ]] \
|| fail 'delete removed a different number of snapshots than one'
printf '{"pwned":false}' >"$work/delete-target.json"
for hostile in \
'../../delete-target.json' \
'/etc/passwd' \
"$victim" \
'settings-20000101-000000000.json' \
'' ; do
run delete "$hostile" >/dev/null 2>&1 \
&& fail "delete accepted \"$hostile\", which is not a snapshot in the backup directory"
done
[[ -f "$work/delete-target.json" ]] || fail 'delete followed a traversing name out of the backup directory'
escape="settings-20000101-000000009.json"
ln -s "$work/delete-target.json" "$backups/$escape"
run delete "$escape" >/dev/null 2>&1 \
&& fail 'delete accepted a symlink escaping the backup directory'
[[ -f "$work/delete-target.json" ]] || fail 'delete removed the target of an escaping symlink'
rm -f "$backups/$escape"
# ── The list says how much room a snapshot takes ─────────────────────────────
#
# Fifteen snapshots of a settings file are nothing; fifteen of a settings file
# somebody grew are not, and the page offers a Delete button now, which is a
# decision nobody can make without the size.
run list | jq -e 'all(.[]; (.bytes | type == "number") and .bytes > 0)' >/dev/null \
|| fail 'the snapshot list does not report each snapshots size'
listed_bytes="$(run list | jq -r '.[0].bytes')"
actual_bytes="$(stat -c %s "$backups/$(run list | jq -r '.[0].name')")"
[[ "$listed_bytes" == "$actual_bytes" ]] \
|| fail "the list reports $listed_bytes bytes for a snapshot that is $actual_bytes on disk"
# ── 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'