139 lines
7.5 KiB
Bash
Executable File
139 lines
7.5 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"
|
||
mkdir -p "$(dirname "$settings")"
|
||
|
||
run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; }
|
||
|
||
# ── 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}' >"$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')" == "2" ]] || fail 'snapshot key count is wrong'
|
||
|
||
printf '{"gapsOut":99}' >"$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 '.favorites[0].id' "$home")" == "light.desk" ]] || fail 'restore did not bring back Home favourites'
|
||
[[ "$(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'
|
||
|
||
# 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'
|
||
|
||
# ── 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")" == "17" ]] || 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 favourites was restored'
|
||
[[ "$(jq -r .gapsOut "$settings")" == "17" ]] || 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'
|