309 lines
17 KiB
Bash
Executable File
309 lines
17 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"
|
||
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'
|