Complete settings snapshot restoration

This commit is contained in:
Gabriel Brown
2026-08-18 02:22:23 -04:00
parent cae72b5179
commit 172b099a04
4 changed files with 585 additions and 63 deletions
+63 -2
View File
@@ -22,6 +22,7 @@ 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")"
@@ -33,15 +34,43 @@ run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported su
# ── 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"
run restore "$name" >/dev/null || fail 'restore failed'
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 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'
# 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')"
@@ -52,12 +81,44 @@ 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")" == "24" ]] || fail 'a refused restore still damaged the settings file'
[[ "$(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 ─────────────────────────────────
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# The helper proves the two stores round-trip in an isolated XDG tree. This
# source contract pins the live handoff without launching a second copy of the
# daily-driver shell or invoking Hyprland during tests.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml"
fail() {
printf 'settings backup live contract: %s\n' "$1" >&2
exit 1
}
rg -q 'DesktopPreferences\.reload\(\)' "$service" \
|| fail 'restore does not reload DesktopPreferences'
rg -q 'HomePreferences\.resetHomeDefaults\(\)' "$service" \
|| fail 'restore does not clear current Home state before reloading it'
rg -q 'HomePreferences\.initialize\(' "$service" \
|| fail 'restore does not reload restored Home favourites through the public API'
rg -q 'HomePreferences\.setAlias\(' "$service" \
|| fail 'restore does not reload restored Home aliases through the public API'
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$service"; then
fail 'restore bypasses the durable HomePreferences API with direct alias mutation'
fi
rg -q 'SystemSettings\.applyPersistedDisplayPolicy\(\)' "$service" \
|| fail 'restore does not reapply compositor-backed preferences'
rg -q 'Keybinds\.applyReload\(\)' "$service" \
|| fail 'restore does not regenerate and reload rebound shortcuts'
rg -q 'Wallpaper\.set\(' "$service" \
|| fail 'restore does not reapply the restored wallpaper'
rg -q 'Quickshell\.reload\(false\)' "$service" \
|| fail 'restore does not soft-reload HomePreferences and reactive theme state'
rg -q 'actionRun\.exec\(\[root\.helperPath, "save", root\.serialiseHomeState\(\)\]\)' "$service" \
|| fail 'save does not hand the live HomePreferences state to the canonical backup store'
# User-controlled snapshot names must remain argv values. Restoring through a
# shell command would make validation in the helper the only line of defence.
rg -q 'actionRun\.exec\(\[root\.helperPath, "restore", name\]\)' "$service" \
|| fail 'restore is not executed through an argument array'
if rg -q 'bash.*-c|sh.*-c' "$service"; then
fail 'the restore service constructs a shell command'
fi
printf 'settings backup live contract: PASS\n'