#!/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' # ── 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 snapshot’s 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'