#!/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'